feat(education): add authoring, import, operations, supervision, badges, appearance and activation codes
This commit is contained in:
@@ -45,6 +45,23 @@
|
||||
<artifactId>yudao-spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.boot</groupId>
|
||||
<artifactId>yudao-module-promotion</artifactId>
|
||||
<version>${revision}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.boot</groupId>
|
||||
<artifactId>yudao-module-trade</artifactId>
|
||||
<version>${revision}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-postgresql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
|
||||
@@ -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));}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package cn.iocoder.yudao.module.education.controller.admin.blueprint;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.blueprint.vo.*;
|
||||
import cn.iocoder.yudao.module.education.service.blueprint.authoring.*;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -19,11 +22,20 @@ public class PracticeBlueprintAuthoringController {
|
||||
private final PracticeBlueprintAuthoringService service;
|
||||
public PracticeBlueprintAuthoringController(PracticeBlueprintAuthoringService service) { this.service = service; }
|
||||
|
||||
@GetMapping("/page") @PreAuthorize("@ss.hasPermission('education:practice-blueprint:query')")
|
||||
public CommonResult<PageResult<PracticeBlueprintAuthoringRespVO>> getPage(@Valid PageParam pageParam) {
|
||||
return success(BeanUtils.toBean(service.getPage(pageParam), PracticeBlueprintAuthoringRespVO.class));
|
||||
}
|
||||
@GetMapping("/get") @PreAuthorize("@ss.hasPermission('education:practice-blueprint:query')")
|
||||
public CommonResult<PracticeBlueprintAuthoringRespVO> get(@RequestParam("id") Long id) {
|
||||
return success(BeanUtils.toBean(service.get(id), PracticeBlueprintAuthoringRespVO.class));
|
||||
}
|
||||
|
||||
@PostMapping("/drafts") @PreAuthorize("@ss.hasPermission('education:practice-blueprint:author')")
|
||||
public CommonResult<Long> create(@Valid @RequestBody PracticeBlueprintDraftReqVO request) {
|
||||
return success(service.createDraft(toCommand(request, null)));
|
||||
}
|
||||
@PutMapping("/{id}/draft") @PreAuthorize("@ss.hasPermission('education:practice-blueprint:author')")
|
||||
@PutMapping("/{id}/draft") @PreAuthorize("@ss.hasPermission('education:practice-blueprint:update')")
|
||||
public CommonResult<Integer> revise(@PathVariable Long id, @Valid @RequestBody PracticeBlueprintReviseReqVO request) {
|
||||
return success(service.reviseDraft(id, toCommand(request, request.getExpectedAuthoringVersion())));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package cn.iocoder.yudao.module.education.controller.admin.category;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.category.vo.CategoryAuthoringRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.category.vo.CategoryDraftReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.category.vo.CategoryReviseReqVO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CategoryDO;
|
||||
import cn.iocoder.yudao.module.education.service.category.authoring.CategoryAuthoringCommand;
|
||||
import cn.iocoder.yudao.module.education.service.category.authoring.CategoryAuthoringService;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -23,6 +27,21 @@ public class CategoryAuthoringController {
|
||||
|
||||
public CategoryAuthoringController(CategoryAuthoringService service) { this.service = service; }
|
||||
|
||||
@GetMapping("/page")
|
||||
@PreAuthorize("@ss.hasPermission('education:category:query')")
|
||||
public CommonResult<PageResult<CategoryAuthoringRespVO>> page(@Valid PageParam pageParam) {
|
||||
PageResult<CategoryDO> source = service.getPage(pageParam);
|
||||
return success(new PageResult<>(source.getList().stream()
|
||||
.map(CategoryAuthoringRespVO::from)
|
||||
.toList(), source.getTotal()));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("@ss.hasPermission('education:category:query')")
|
||||
public CommonResult<CategoryAuthoringRespVO> get(@PathVariable("id") Long id) {
|
||||
return success(CategoryAuthoringRespVO.from(service.get(id)));
|
||||
}
|
||||
|
||||
@PostMapping("/drafts")
|
||||
@PreAuthorize("@ss.hasPermission('education:category:author')")
|
||||
public CommonResult<Long> create(@Valid @RequestBody CategoryDraftReqVO request) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package cn.iocoder.yudao.module.education.controller.admin.collection;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.collection.vo.*;
|
||||
import cn.iocoder.yudao.module.education.service.collection.authoring.*;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -19,15 +22,24 @@ public class QuestionCollectionAuthoringController {
|
||||
private final QuestionCollectionAuthoringService service;
|
||||
public QuestionCollectionAuthoringController(QuestionCollectionAuthoringService service) { this.service = service; }
|
||||
|
||||
@GetMapping("/page") @PreAuthorize("@ss.hasPermission('education:collection:query')")
|
||||
public CommonResult<PageResult<QuestionCollectionAuthoringRespVO>> getPage(@Valid PageParam pageParam) {
|
||||
return success(BeanUtils.toBean(service.getPage(pageParam), QuestionCollectionAuthoringRespVO.class));
|
||||
}
|
||||
@GetMapping("/get") @PreAuthorize("@ss.hasPermission('education:collection:query')")
|
||||
public CommonResult<QuestionCollectionAuthoringRespVO> get(@RequestParam("id") Long id) {
|
||||
return success(BeanUtils.toBean(service.get(id), QuestionCollectionAuthoringRespVO.class));
|
||||
}
|
||||
|
||||
@PostMapping("/drafts") @PreAuthorize("@ss.hasPermission('education:collection:author')")
|
||||
public CommonResult<Long> create(@Valid @RequestBody QuestionCollectionDraftReqVO request) {
|
||||
return success(service.createDraft(toCommand(request, null)));
|
||||
}
|
||||
@PutMapping("/{id}/draft") @PreAuthorize("@ss.hasPermission('education:collection:author')")
|
||||
@PutMapping("/{id}/draft") @PreAuthorize("@ss.hasPermission('education:collection:update')")
|
||||
public CommonResult<Integer> revise(@PathVariable Long id, @Valid @RequestBody QuestionCollectionReviseReqVO request) {
|
||||
return success(service.reviseDraft(id, toCommand(request, request.getExpectedAuthoringVersion())));
|
||||
}
|
||||
@PutMapping("/{id}/membership") @PreAuthorize("@ss.hasPermission('education:collection:author')")
|
||||
@PutMapping("/{id}/membership") @PreAuthorize("@ss.hasPermission('education:collection:update')")
|
||||
public CommonResult<Integer> replaceMembership(@PathVariable Long id, @Valid @RequestBody QuestionCollectionMembershipReqVO request) {
|
||||
return success(service.replaceMembership(id, request.getQuestionIds(), request.getExpectedAuthoringVersion()));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package cn.iocoder.yudao.module.education.controller.admin.contentnode;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.contentnode.vo.ContentNodeAuthoringRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.contentnode.vo.ContentNodeDraftReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.contentnode.vo.ContentNodeReviseReqVO;
|
||||
import cn.iocoder.yudao.module.education.service.contentnode.authoring.ContentNodeAuthoringCommand;
|
||||
@@ -22,6 +26,18 @@ public class ContentNodeAuthoringController {
|
||||
private final ContentNodeAuthoringService service;
|
||||
public ContentNodeAuthoringController(ContentNodeAuthoringService service) { this.service = service; }
|
||||
|
||||
@GetMapping("/page")
|
||||
@PreAuthorize("@ss.hasPermission('education:content-node:query')")
|
||||
public CommonResult<PageResult<ContentNodeAuthoringRespVO>> getPage(@Valid PageParam pageParam) {
|
||||
return success(BeanUtils.toBean(service.getPage(pageParam), ContentNodeAuthoringRespVO.class));
|
||||
}
|
||||
|
||||
@GetMapping("/get")
|
||||
@PreAuthorize("@ss.hasPermission('education:content-node:query')")
|
||||
public CommonResult<ContentNodeAuthoringRespVO> get(@RequestParam("id") Long id) {
|
||||
return success(BeanUtils.toBean(service.get(id), ContentNodeAuthoringRespVO.class));
|
||||
}
|
||||
|
||||
@PostMapping("/drafts")
|
||||
@PreAuthorize("@ss.hasPermission('education:content-node:author')")
|
||||
public CommonResult<Long> create(@Valid @RequestBody ContentNodeDraftReqVO request) {
|
||||
@@ -29,7 +45,7 @@ public class ContentNodeAuthoringController {
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/draft")
|
||||
@PreAuthorize("@ss.hasPermission('education:content-node:author')")
|
||||
@PreAuthorize("@ss.hasPermission('education:content-node:update')")
|
||||
public CommonResult<Integer> revise(@PathVariable("id") Long id,
|
||||
@Valid @RequestBody ContentNodeReviseReqVO request) {
|
||||
return success(service.reviseDraft(id, toCommand(request, request.getExpectedAuthoringVersion())));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -14,13 +14,16 @@ public class QuestionImportJobRespVO {
|
||||
private Integer previewQuestionCount;
|
||||
private Integer importedQuestionCount;
|
||||
private String failureCode;
|
||||
private String previewPayload;
|
||||
private String resultSummary;
|
||||
|
||||
public static QuestionImportJobRespVO from(QuestionImportJobProjection source) {
|
||||
QuestionImportJobRespVO target = new QuestionImportJobRespVO();
|
||||
target.id = source.id(); target.status = source.status(); target.scanStatus = source.scanStatus();
|
||||
target.parserStatus = source.parserStatus(); target.fileName = source.fileName(); target.fileSize = source.fileSize();
|
||||
target.previewQuestionCount = source.previewQuestionCount(); target.importedQuestionCount = source.importedQuestionCount();
|
||||
target.failureCode = source.failureCode();
|
||||
target.failureCode = source.failureCode(); target.previewPayload = source.previewPayload();
|
||||
target.resultSummary = source.resultSummary();
|
||||
return target;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
package cn.iocoder.yudao.module.education.controller.admin.question;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.question.vo.QuestionAuthoringRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.question.vo.QuestionDraftCreateReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.question.vo.QuestionDraftReviseReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.question.vo.QuestionPlacementReqVO;
|
||||
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand;
|
||||
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionPlacementCommand;
|
||||
@@ -32,18 +37,31 @@ public class QuestionAuthoringController {
|
||||
this.lifecycleService = lifecycleService;
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
@PreAuthorize("@ss.hasPermission('education:question:query')")
|
||||
public CommonResult<PageResult<QuestionAuthoringRespVO>> getPage(@Valid PageParam pageParam) {
|
||||
return success(BeanUtils.toBean(lifecycleService.getPage(pageParam), QuestionAuthoringRespVO.class));
|
||||
}
|
||||
|
||||
@GetMapping("/get")
|
||||
@PreAuthorize("@ss.hasPermission('education:question:query')")
|
||||
public CommonResult<QuestionAuthoringRespVO> get(@RequestParam("id") Long id) {
|
||||
return success(BeanUtils.toBean(lifecycleService.get(id), QuestionAuthoringRespVO.class));
|
||||
}
|
||||
|
||||
@PostMapping("/drafts")
|
||||
@Operation(summary = "创建租户题目草稿")
|
||||
@PreAuthorize("@ss.hasPermission('education:question:author')")
|
||||
public CommonResult<Long> createDraft(@Valid @RequestBody QuestionDraftCreateReqVO reqVO) {
|
||||
List<QuestionDraftCommand.QuestionDraftOption> options = reqVO.getOptions().stream()
|
||||
.map(option -> new QuestionDraftCommand.QuestionDraftOption(
|
||||
option.getLabel(), option.getContent(), option.getOrder()))
|
||||
.toList();
|
||||
QuestionDraftCommand command = new QuestionDraftCommand(
|
||||
reqVO.getStem(), reqVO.getType(), reqVO.getDifficulty(), options,
|
||||
reqVO.getCorrectAnswer(), reqVO.getExplanation(), reqVO.getAnalysis());
|
||||
return success(lifecycleService.createDraft(command));
|
||||
return success(lifecycleService.createDraft(toCommand(reqVO)));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/draft")
|
||||
@Operation(summary = "修订租户题目草稿")
|
||||
@PreAuthorize("@ss.hasPermission('education:question:update')")
|
||||
public CommonResult<Integer> reviseDraft(@PathVariable("id") Long id,
|
||||
@Valid @RequestBody QuestionDraftReviseReqVO reqVO) {
|
||||
return success(lifecycleService.reviseDraft(id, toCommand(reqVO), reqVO.getExpectedContentVersion()));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/placement")
|
||||
@@ -70,4 +88,13 @@ public class QuestionAuthoringController {
|
||||
lifecycleService.archive(id, getLoginUserId());
|
||||
return success(true);
|
||||
}
|
||||
|
||||
private QuestionDraftCommand toCommand(QuestionDraftCreateReqVO reqVO) {
|
||||
List<QuestionDraftCommand.QuestionDraftOption> options = reqVO.getOptions().stream()
|
||||
.map(option -> new QuestionDraftCommand.QuestionDraftOption(
|
||||
option.getLabel(), option.getContent(), option.getOrder()))
|
||||
.toList();
|
||||
return new QuestionDraftCommand(reqVO.getStem(), reqVO.getType(), reqVO.getDifficulty(), options,
|
||||
reqVO.getCorrectAnswer(), reqVO.getExplanation(), reqVO.getAnalysis());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import cn.iocoder.yudao.module.education.controller.app.engagement.vo.LearningAw
|
||||
import cn.iocoder.yudao.module.education.controller.app.engagement.vo.LearningSummaryRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.engagement.vo.StudentFeedbackCreateReqVO;
|
||||
import cn.iocoder.yudao.module.education.service.engagement.EducationEngagementService;
|
||||
import cn.iocoder.yudao.module.education.service.badge.BadgeGrantService;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.badge.vo.BadgeAdminVOs.MemberBadgeResp;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -41,6 +43,8 @@ public class EducationEngagementController {
|
||||
|
||||
@Resource
|
||||
private EducationEngagementService engagementService;
|
||||
@Resource
|
||||
private BadgeGrantService badgeGrantService;
|
||||
|
||||
@PostMapping("/feedback")
|
||||
@Operation(summary = "提交学生反馈")
|
||||
@@ -71,6 +75,14 @@ public class EducationEngagementController {
|
||||
return success(engagementService.getLeaderboard(getTenantId(), limit));
|
||||
}
|
||||
|
||||
@GetMapping("/badges")
|
||||
@Operation(summary = "查询本人已获或可解锁徽章")
|
||||
public CommonResult<List<MemberBadgeResp>> badges(
|
||||
@RequestParam(defaultValue = "false") boolean includeLocked) {
|
||||
LoginUser student = getStudentPrincipal();
|
||||
return success(badgeGrantService.getMemberBadges(getTenantId(), student.getId(), includeLocked));
|
||||
}
|
||||
|
||||
private LoginUser getStudentPrincipal() {
|
||||
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
|
||||
if (loginUser == null || loginUser.getId() == null
|
||||
|
||||
@@ -2,15 +2,17 @@ package cn.iocoder.yudao.module.education.dal.dataobject;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("education_learning_award")
|
||||
@KeySequence("education_learning_award_seq")
|
||||
@TableName(value = "education_learning_award", autoResultMap = true)
|
||||
public class LearningAwardDO extends TenantBaseDO {
|
||||
@TableId
|
||||
private Long id;
|
||||
@@ -21,4 +23,13 @@ public class LearningAwardDO extends TenantBaseDO {
|
||||
private Integer pointAmount;
|
||||
private String pointStatus;
|
||||
private String pointError;
|
||||
private Long badgeDefinitionId;
|
||||
private String awardSource;
|
||||
private String grantNote;
|
||||
@TableField(typeHandler = JacksonTypeHandler.class)
|
||||
private java.util.Map<String, Object> grantEvidence;
|
||||
private Long grantedBy;
|
||||
private Long notifyMessageId;
|
||||
private String notifyStatus;
|
||||
private String notifyError;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("education_student_feedback")
|
||||
@@ -19,4 +21,12 @@ public class StudentFeedbackDO extends TenantBaseDO {
|
||||
private String content;
|
||||
private String contact;
|
||||
private String status;
|
||||
private String priority;
|
||||
private String resolution;
|
||||
private Long handledBy;
|
||||
private LocalDateTime handledTime;
|
||||
private Integer rewardPoints;
|
||||
private String rewardStatus;
|
||||
private String rewardError;
|
||||
private Integer version;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -16,4 +16,8 @@ public class EducationClassDO extends TenantBaseDO {
|
||||
private String name;
|
||||
private String description;
|
||||
private String status;
|
||||
/** System 部门数据范围;账号、部门与角色仍由 System 模块持有。 */
|
||||
private Long deptId;
|
||||
/** 创建班级的 System 管理员,用于“仅本人”数据范围。 */
|
||||
private Long ownerUserId;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.LearningAwardDO;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import lombok.Data;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -23,12 +28,69 @@ public interface LearningAwardMapper extends BaseMapperX<LearningAwardDO> {
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id")
|
||||
int insertIgnore(LearningAwardDO record);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO education_learning_award
|
||||
(tenant_id, user_id, event_key, award_type, badge_code, point_amount, point_status,
|
||||
badge_definition_id, award_source, grant_note, grant_evidence, granted_by,
|
||||
notify_status, creator, create_time, updater, update_time, deleted)
|
||||
VALUES
|
||||
(#{record.tenantId}, #{record.userId}, #{record.eventKey}, 'BADGE', #{record.badgeCode}, 0,
|
||||
'NOT_REQUIRED', #{record.badgeDefinitionId}, #{record.awardSource}, #{record.grantNote},
|
||||
CAST(#{evidenceJson} AS JSONB), #{record.grantedBy}, 'PENDING', #{record.creator},
|
||||
CURRENT_TIMESTAMP, #{record.updater}, CURRENT_TIMESTAMP, FALSE)
|
||||
ON CONFLICT (tenant_id, user_id, badge_definition_id)
|
||||
WHERE badge_definition_id IS NOT NULL AND deleted = false DO NOTHING
|
||||
""")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "record.id")
|
||||
int insertBadgeGrantIgnore(@Param("record") LearningAwardDO record,
|
||||
@Param("evidenceJson") String evidenceJson);
|
||||
|
||||
default LearningAwardDO selectOwnerEvent(Long tenantId, Long userId, String eventKey, String awardType) {
|
||||
return selectOne(new LambdaQueryWrapper<LearningAwardDO>().eq(LearningAwardDO::getTenantId, tenantId)
|
||||
.eq(LearningAwardDO::getUserId, userId).eq(LearningAwardDO::getEventKey, eventKey)
|
||||
.eq(LearningAwardDO::getAwardType, awardType));
|
||||
}
|
||||
|
||||
default LearningAwardDO selectBadgeGrant(Long tenantId, Long userId, Long badgeDefinitionId) {
|
||||
return selectOne(new LambdaQueryWrapper<LearningAwardDO>()
|
||||
.eq(LearningAwardDO::getTenantId, tenantId)
|
||||
.eq(LearningAwardDO::getUserId, userId)
|
||||
.eq(LearningAwardDO::getBadgeDefinitionId, badgeDefinitionId)
|
||||
.eq(LearningAwardDO::getDeleted, false));
|
||||
}
|
||||
|
||||
default PageResult<LearningAwardDO> selectBadgeGrantPage(PageParam pageParam, Long tenantId, Long userId,
|
||||
Long badgeDefinitionId, String awardSource) {
|
||||
return selectPage(pageParam, new LambdaQueryWrapper<LearningAwardDO>()
|
||||
.eq(LearningAwardDO::getTenantId, tenantId)
|
||||
.eq(LearningAwardDO::getDeleted, false)
|
||||
.isNotNull(LearningAwardDO::getBadgeDefinitionId)
|
||||
.eq(userId != null, LearningAwardDO::getUserId, userId)
|
||||
.eq(badgeDefinitionId != null, LearningAwardDO::getBadgeDefinitionId, badgeDefinitionId)
|
||||
.eq(awardSource != null, LearningAwardDO::getAwardSource, awardSource)
|
||||
.orderByDesc(LearningAwardDO::getCreateTime)
|
||||
.orderByDesc(LearningAwardDO::getId));
|
||||
}
|
||||
|
||||
default List<LearningAwardDO> selectUserBadgeGrants(Long tenantId, Long userId) {
|
||||
return selectList(new LambdaQueryWrapper<LearningAwardDO>()
|
||||
.eq(LearningAwardDO::getTenantId, tenantId)
|
||||
.eq(LearningAwardDO::getUserId, userId)
|
||||
.eq(LearningAwardDO::getDeleted, false)
|
||||
.isNotNull(LearningAwardDO::getBadgeDefinitionId)
|
||||
.orderByDesc(LearningAwardDO::getCreateTime));
|
||||
}
|
||||
|
||||
@Update("""
|
||||
UPDATE education_learning_award
|
||||
SET notify_message_id=#{messageId}, notify_status=#{status}, notify_error=#{error},
|
||||
update_time=CURRENT_TIMESTAMP
|
||||
WHERE tenant_id=#{tenantId} AND id=#{id} AND deleted=false
|
||||
""")
|
||||
int updateBadgeNotify(@Param("tenantId") Long tenantId, @Param("id") Long id,
|
||||
@Param("messageId") Long messageId, @Param("status") String status,
|
||||
@Param("error") String error);
|
||||
|
||||
default int claimPoints(Long id, Long tenantId, Long userId) {
|
||||
return update(null, new LambdaUpdateWrapper<LearningAwardDO>().eq(LearningAwardDO::getId, id)
|
||||
.eq(LearningAwardDO::getTenantId, tenantId).eq(LearningAwardDO::getUserId, userId)
|
||||
@@ -63,8 +125,43 @@ public interface LearningAwardMapper extends BaseMapperX<LearningAwardDO> {
|
||||
""")
|
||||
List<LeaderboardRow> selectLeaderboard(Long tenantId, int limit);
|
||||
|
||||
interface LeaderboardRow {
|
||||
Long getUserId();
|
||||
Long getLearningScore();
|
||||
default PageResult<LearningAwardDO> selectAdminPage(PageParam pageParam, Long tenantId, Long userId,
|
||||
String awardType, String pointStatus, Boolean hasBadge) {
|
||||
return selectPage(pageParam, new LambdaQueryWrapper<LearningAwardDO>()
|
||||
.eq(LearningAwardDO::getTenantId, tenantId)
|
||||
.eq(LearningAwardDO::getDeleted, false)
|
||||
.eq(userId != null, LearningAwardDO::getUserId, userId)
|
||||
.eq(awardType != null, LearningAwardDO::getAwardType, awardType)
|
||||
.eq(pointStatus != null, LearningAwardDO::getPointStatus, pointStatus)
|
||||
.isNotNull(Boolean.TRUE.equals(hasBadge), LearningAwardDO::getBadgeCode)
|
||||
.isNull(Boolean.FALSE.equals(hasBadge), LearningAwardDO::getBadgeCode)
|
||||
.orderByDesc(LearningAwardDO::getCreateTime)
|
||||
.orderByDesc(LearningAwardDO::getId));
|
||||
}
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*) AS total,
|
||||
COUNT(DISTINCT user_id) AS active_learners,
|
||||
COALESCE(SUM(point_amount) FILTER (WHERE point_status = 'AWARDED'), 0) AS awarded_points,
|
||||
COUNT(*) FILTER (WHERE point_status = 'FAILED') AS failed_points,
|
||||
COUNT(*) FILTER (WHERE badge_code IS NOT NULL) AS badge_awards
|
||||
FROM education_learning_award
|
||||
WHERE tenant_id = #{tenantId} AND deleted = false
|
||||
""")
|
||||
AwardOverviewRow selectOverview(Long tenantId);
|
||||
|
||||
@Data
|
||||
class LeaderboardRow {
|
||||
private Long userId;
|
||||
private Long learningScore;
|
||||
}
|
||||
|
||||
@Data
|
||||
class AwardOverviewRow {
|
||||
private Long total;
|
||||
private Long activeLearners;
|
||||
private Long awardedPoints;
|
||||
private Long failedPoints;
|
||||
private Long badgeAwards;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeReportDO;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import lombok.Data;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
@@ -64,4 +65,17 @@ public interface PracticeReportMapper extends BaseMapperX<PracticeReportDO> {
|
||||
""")
|
||||
Long sumCorrectByOwner(Long tenantId, Long userId);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*) AS practice_count, COALESCE(MAX(score), 0) AS best_score
|
||||
FROM education_practice_report
|
||||
WHERE tenant_id=#{tenantId} AND user_id=#{userId} AND deleted=false
|
||||
""")
|
||||
BadgePracticeMetrics selectBadgeMetrics(Long tenantId, Long userId);
|
||||
|
||||
@Data
|
||||
class BadgePracticeMetrics {
|
||||
private Long practiceCount;
|
||||
private Integer bestScore;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.StudentFeedbackDO;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import lombok.Data;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -15,4 +20,97 @@ public interface StudentFeedbackMapper extends BaseMapperX<StudentFeedbackDO> {
|
||||
.eq(StudentFeedbackDO::getDeleted, false).orderByDesc(StudentFeedbackDO::getCreateTime)
|
||||
.last("LIMIT 100"));
|
||||
}
|
||||
|
||||
default PageResult<StudentFeedbackDO> selectAdminPage(PageParam pageParam, Long tenantId, Long userId,
|
||||
String category, String status, String priority) {
|
||||
return selectPage(pageParam, new LambdaQueryWrapper<StudentFeedbackDO>()
|
||||
.eq(StudentFeedbackDO::getTenantId, tenantId)
|
||||
.eq(StudentFeedbackDO::getDeleted, false)
|
||||
.eq(userId != null, StudentFeedbackDO::getUserId, userId)
|
||||
.eq(category != null, StudentFeedbackDO::getCategory, category)
|
||||
.eq(status != null, StudentFeedbackDO::getStatus, status)
|
||||
.eq(priority != null, StudentFeedbackDO::getPriority, priority)
|
||||
.orderByDesc(StudentFeedbackDO::getCreateTime)
|
||||
.orderByDesc(StudentFeedbackDO::getId));
|
||||
}
|
||||
|
||||
default StudentFeedbackDO selectOwnedById(Long tenantId, Long id) {
|
||||
return selectOne(new LambdaQueryWrapper<StudentFeedbackDO>()
|
||||
.eq(StudentFeedbackDO::getTenantId, tenantId)
|
||||
.eq(StudentFeedbackDO::getId, id)
|
||||
.eq(StudentFeedbackDO::getDeleted, false));
|
||||
}
|
||||
|
||||
default int handleCas(Long tenantId, Long id, int expectedVersion, String status, String priority,
|
||||
String resolution, Long actorId) {
|
||||
return update(null, new LambdaUpdateWrapper<StudentFeedbackDO>()
|
||||
.eq(StudentFeedbackDO::getTenantId, tenantId)
|
||||
.eq(StudentFeedbackDO::getId, id)
|
||||
.eq(StudentFeedbackDO::getDeleted, false)
|
||||
.eq(StudentFeedbackDO::getVersion, expectedVersion)
|
||||
.set(StudentFeedbackDO::getStatus, status)
|
||||
.set(StudentFeedbackDO::getPriority, priority)
|
||||
.set(StudentFeedbackDO::getResolution, resolution)
|
||||
.set(StudentFeedbackDO::getHandledBy, actorId)
|
||||
.setSql("handled_time = CURRENT_TIMESTAMP")
|
||||
.set(StudentFeedbackDO::getVersion, expectedVersion + 1));
|
||||
}
|
||||
|
||||
default int scheduleRewardCas(Long tenantId, Long id, int expectedVersion, int rewardPoints) {
|
||||
return update(null, new LambdaUpdateWrapper<StudentFeedbackDO>()
|
||||
.eq(StudentFeedbackDO::getTenantId, tenantId)
|
||||
.eq(StudentFeedbackDO::getId, id)
|
||||
.eq(StudentFeedbackDO::getDeleted, false)
|
||||
.eq(StudentFeedbackDO::getVersion, expectedVersion)
|
||||
.eq(StudentFeedbackDO::getStatus, "RESOLVED")
|
||||
.ne(StudentFeedbackDO::getRewardStatus, "AWARDED")
|
||||
.set(StudentFeedbackDO::getRewardPoints, rewardPoints)
|
||||
.set(StudentFeedbackDO::getRewardStatus, "PENDING")
|
||||
.set(StudentFeedbackDO::getRewardError, null)
|
||||
.set(StudentFeedbackDO::getVersion, expectedVersion + 1));
|
||||
}
|
||||
|
||||
default int claimReward(Long tenantId, Long id) {
|
||||
return update(null, new LambdaUpdateWrapper<StudentFeedbackDO>()
|
||||
.eq(StudentFeedbackDO::getTenantId, tenantId)
|
||||
.eq(StudentFeedbackDO::getId, id)
|
||||
.gt(StudentFeedbackDO::getRewardPoints, 0)
|
||||
.in(StudentFeedbackDO::getRewardStatus, "PENDING", "FAILED")
|
||||
.set(StudentFeedbackDO::getRewardStatus, "PROCESSING")
|
||||
.set(StudentFeedbackDO::getRewardError, null));
|
||||
}
|
||||
|
||||
default int finishReward(Long tenantId, Long id, String status, String error) {
|
||||
return update(null, new LambdaUpdateWrapper<StudentFeedbackDO>()
|
||||
.eq(StudentFeedbackDO::getTenantId, tenantId)
|
||||
.eq(StudentFeedbackDO::getId, id)
|
||||
.eq(StudentFeedbackDO::getRewardStatus, "PROCESSING")
|
||||
.set(StudentFeedbackDO::getRewardStatus, status)
|
||||
.set(StudentFeedbackDO::getRewardError, error));
|
||||
}
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE status IN ('OPEN', 'ACCEPTED')) AS open_count,
|
||||
COUNT(*) FILTER (WHERE status = 'RESOLVED') AS resolved_count,
|
||||
COUNT(*) FILTER (WHERE priority IN ('HIGH', 'URGENT') AND status IN ('OPEN', 'ACCEPTED'))
|
||||
AS high_priority_count
|
||||
FROM education_student_feedback
|
||||
WHERE tenant_id = #{tenantId} AND deleted = false
|
||||
""")
|
||||
FeedbackOverviewRow selectOverview(Long tenantId);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM education_student_feedback
|
||||
WHERE tenant_id=#{tenantId} AND user_id=#{userId} AND status='RESOLVED' AND deleted=false
|
||||
""")
|
||||
Long countResolved(Long tenantId, Long userId);
|
||||
|
||||
@Data
|
||||
class FeedbackOverviewRow {
|
||||
private Long total;
|
||||
private Long openCount;
|
||||
private Long resolvedCount;
|
||||
private Long highPriorityCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
@@ -33,4 +34,10 @@ public interface VocabularyProgressMapper extends BaseMapperX<VocabularyProgress
|
||||
.le(VocabularyProgressDO::getNextReviewTime, now).orderByAsc(VocabularyProgressDO::getNextReviewTime)
|
||||
.last("LIMIT " + limit));
|
||||
}
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM education_vocabulary_progress
|
||||
WHERE tenant_id=#{tenantId} AND user_id=#{userId} AND mastery_level >= 5 AND deleted=false
|
||||
""")
|
||||
Long countMastered(Long tenantId, Long userId);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CategoryDO;
|
||||
@@ -8,6 +10,15 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CategoryMapper extends BaseMapperX<CategoryDO> {
|
||||
default PageResult<CategoryDO> selectTenantOwnedPage(PageParam pageParam, Long tenantId) {
|
||||
return selectPage(pageParam, new LambdaQueryWrapperX<CategoryDO>()
|
||||
.eq(CategoryDO::getTenantId, tenantId)
|
||||
.eq(CategoryDO::getScope, "TENANT_OWNED")
|
||||
.eq(CategoryDO::getDeleted, false)
|
||||
.orderByAsc(CategoryDO::getSortOrder)
|
||||
.orderByDesc(CategoryDO::getId));
|
||||
}
|
||||
|
||||
default CategoryDO selectTenantOwnedById(Long tenantId, Long id) {
|
||||
return selectOne(new LambdaQueryWrapperX<CategoryDO>().eq(CategoryDO::getId, id)
|
||||
.eq(CategoryDO::getTenantId, tenantId).eq(CategoryDO::getScope, "TENANT_OWNED")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentEntryDO;
|
||||
@@ -57,6 +59,12 @@ public interface ContentNodeMapper extends BaseMapperX<ContentNodeDO> {
|
||||
ContentNodeDO selectAvailableParentForShare(@Param("tenantId") Long tenantId, @Param("entryId") Long entryId,
|
||||
@Param("parentId") Long parentId);
|
||||
|
||||
default PageResult<ContentNodeDO> selectTenantOwnedPage(PageParam pageParam, Long tenantId) {
|
||||
return selectPage(pageParam, new LambdaQueryWrapperX<ContentNodeDO>()
|
||||
.eq(ContentNodeDO::getTenantId, tenantId).eq(ContentNodeDO::getScope, "TENANT_OWNED")
|
||||
.eq(ContentNodeDO::getDeleted, false).orderByDesc(ContentNodeDO::getId));
|
||||
}
|
||||
|
||||
default ContentNodeDO selectTenantOwnedById(Long tenantId, Long id) {
|
||||
return selectOne(new LambdaQueryWrapperX<ContentNodeDO>().eq(ContentNodeDO::getId, id)
|
||||
.eq(ContentNodeDO::getTenantId, tenantId).eq(ContentNodeDO::getScope, "TENANT_OWNED")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.PracticeBlueprintDO;
|
||||
@@ -41,6 +43,11 @@ public interface PracticeBlueprintMapper extends BaseMapperX<PracticeBlueprintDO
|
||||
if (collectionId == null && nodeId == null) return null;
|
||||
return selectAvailableForStudent(tenantId, collectionId, nodeId);
|
||||
}
|
||||
default PageResult<PracticeBlueprintDO> selectTenantOwnedPage(PageParam pageParam, Long tenantId) {
|
||||
return selectPage(pageParam, new LambdaQueryWrapperX<PracticeBlueprintDO>()
|
||||
.eq(PracticeBlueprintDO::getTenantId, tenantId).eq(PracticeBlueprintDO::getScope, "TENANT_OWNED")
|
||||
.eq(PracticeBlueprintDO::getDeleted, false).orderByDesc(PracticeBlueprintDO::getId));
|
||||
}
|
||||
default PracticeBlueprintDO selectTenantOwnedById(Long tenantId, Long id) {
|
||||
return selectOne(new LambdaQueryWrapperX<PracticeBlueprintDO>().eq(PracticeBlueprintDO::getId, id)
|
||||
.eq(PracticeBlueprintDO::getTenantId, tenantId).eq(PracticeBlueprintDO::getScope, "TENANT_OWNED")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionCollectionDO;
|
||||
@@ -64,6 +66,11 @@ public interface QuestionCollectionMapper extends BaseMapperX<QuestionCollection
|
||||
default List<QuestionCollectionDO> selectActiveList(Long tenantId, Long entryId, Long nodeId, String collectionType, Integer limit) {
|
||||
return selectAvailableList(tenantId, entryId, nodeId, collectionType, limit != null && limit > 0 ? Math.min(limit, 200) : 100);
|
||||
}
|
||||
default PageResult<QuestionCollectionDO> selectTenantOwnedPage(PageParam pageParam, Long tenantId) {
|
||||
return selectPage(pageParam, new LambdaQueryWrapperX<QuestionCollectionDO>()
|
||||
.eq(QuestionCollectionDO::getTenantId, tenantId).eq(QuestionCollectionDO::getScope, "TENANT_OWNED")
|
||||
.eq(QuestionCollectionDO::getDeleted, false).orderByDesc(QuestionCollectionDO::getId));
|
||||
}
|
||||
default QuestionCollectionDO selectTenantOwnedById(Long tenantId, Long id) {
|
||||
return selectOne(new LambdaQueryWrapperX<QuestionCollectionDO>().eq(QuestionCollectionDO::getId, id)
|
||||
.eq(QuestionCollectionDO::getTenantId, tenantId).eq(QuestionCollectionDO::getScope, "TENANT_OWNED")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionDO;
|
||||
@@ -172,6 +174,14 @@ public interface QuestionMapper extends BaseMapperX<QuestionDO> {
|
||||
return selectOne(visible(tenantId).eq(QuestionDO::getId, id));
|
||||
}
|
||||
|
||||
default PageResult<QuestionDO> selectTenantOwnedPage(PageParam pageParam, Long tenantId) {
|
||||
return selectPage(pageParam, new LambdaQueryWrapperX<QuestionDO>()
|
||||
.eq(QuestionDO::getTenantId, tenantId)
|
||||
.eq(QuestionDO::getScope, "TENANT_OWNED")
|
||||
.eq(QuestionDO::getDeleted, false)
|
||||
.orderByDesc(QuestionDO::getId));
|
||||
}
|
||||
|
||||
default QuestionDO selectTenantOwnedById(Long tenantId, Long id) {
|
||||
return selectOne(new LambdaQueryWrapperX<QuestionDO>()
|
||||
.eq(QuestionDO::getId, id)
|
||||
@@ -179,6 +189,35 @@ public interface QuestionMapper extends BaseMapperX<QuestionDO> {
|
||||
.eq(QuestionDO::getScope, "TENANT_OWNED"));
|
||||
}
|
||||
|
||||
default long countImportedDrafts(Long tenantId, List<Long> ids) {
|
||||
if (ids == null || ids.isEmpty()) return 0;
|
||||
return selectCount(new LambdaQueryWrapperX<QuestionDO>()
|
||||
.eq(QuestionDO::getTenantId, tenantId)
|
||||
.eq(QuestionDO::getScope, "TENANT_OWNED")
|
||||
.eq(QuestionDO::getStatus, "DRAFT")
|
||||
.eq(QuestionDO::getIsPublished, false)
|
||||
.eq(QuestionDO::getContentVersion, 1)
|
||||
.in(QuestionDO::getId, ids));
|
||||
}
|
||||
|
||||
default int updateDraftContentCas(Long tenantId, Long id, QuestionDO content,
|
||||
int expectedContentVersion) {
|
||||
return update(null, new LambdaUpdateWrapper<QuestionDO>()
|
||||
.eq(QuestionDO::getId, id)
|
||||
.eq(QuestionDO::getTenantId, tenantId)
|
||||
.eq(QuestionDO::getScope, "TENANT_OWNED")
|
||||
.eq(QuestionDO::getStatus, "DRAFT")
|
||||
.eq(QuestionDO::getContentVersion, expectedContentVersion)
|
||||
.set(QuestionDO::getStem, content.getStem())
|
||||
.set(QuestionDO::getType, content.getType())
|
||||
.set(QuestionDO::getDifficulty, content.getDifficulty())
|
||||
.set(QuestionDO::getOptions, content.getOptions())
|
||||
.set(QuestionDO::getCorrectAnswer, content.getCorrectAnswer())
|
||||
.set(QuestionDO::getExplanation, content.getExplanation())
|
||||
.set(QuestionDO::getAnalysis, content.getAnalysis())
|
||||
.set(QuestionDO::getContentVersion, expectedContentVersion + 1));
|
||||
}
|
||||
|
||||
default int updateLifecycle(Long tenantId, Long id, String expectedStatus,
|
||||
String targetStatus, boolean published, Integer expectedPlacementVersion) {
|
||||
return update(null, new LambdaUpdateWrapper<QuestionDO>()
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionVersionDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface QuestionVersionMapper extends BaseMapperX<QuestionVersionDO> {
|
||||
default long countFirstVersions(Long tenantId, List<Long> questionIds) {
|
||||
if (questionIds == null || questionIds.isEmpty()) return 0;
|
||||
return selectCount(new LambdaQueryWrapperX<QuestionVersionDO>()
|
||||
.eq(QuestionVersionDO::getTenantId, tenantId)
|
||||
.eq(QuestionVersionDO::getScope, "TENANT_OWNED")
|
||||
.eq(QuestionVersionDO::getVersionNumber, 1)
|
||||
.in(QuestionVersionDO::getQuestionId, questionIds));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,11 @@ public interface EducationResourceProductBindingMapper extends BaseMapperX<Educa
|
||||
.eq(EducationResourceProductBindingDO::getResourceId, resourceId)
|
||||
.eq(EducationResourceProductBindingDO::getDeleted, false));
|
||||
}
|
||||
|
||||
default EducationResourceProductBindingDO selectByProduct(Long tenantId, Long productSpuId) {
|
||||
return selectOne(new LambdaQueryWrapperX<EducationResourceProductBindingDO>()
|
||||
.eq(EducationResourceProductBindingDO::getTenantId, tenantId)
|
||||
.eq(EducationResourceProductBindingDO::getProductSpuId, productSpuId)
|
||||
.eq(EducationResourceProductBindingDO::getDeleted, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ public interface ContentImportJobMapper extends BaseMapperX<ContentImportJobDO>
|
||||
next_attempt_at=CURRENT_TIMESTAMP, update_time=CURRENT_TIMESTAMP
|
||||
WHERE tenant_id=#{tenantId} AND id=#{id} AND status='PREVIEW_READY'
|
||||
AND scan_status='CLEAN' AND parser_status='PARSED' AND deleted=false
|
||||
AND COALESCE((preview_payload->>'executable')::BOOLEAN,FALSE)=TRUE
|
||||
""")
|
||||
int requestExecute(@Param("tenantId") Long tenantId, @Param("id") Long id,
|
||||
@Param("executeKey") String executeKey, @Param("executeRequestHash") String executeRequestHash);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -162,6 +162,41 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode LEARNING_AWARD_TYPE_INVALID = new ErrorCode(1_005_004_040, "不支持的学习奖励类型:{}");
|
||||
ErrorCode LEARNING_AWARD_POINT_FAILED = new ErrorCode(1_005_004_041, "学习积分发放失败,请稍后使用相同事件重试");
|
||||
ErrorCode STUDENT_FEEDBACK_CATEGORY_INVALID = new ErrorCode(1_005_004_042, "不支持的反馈类型:{}");
|
||||
ErrorCode STUDENT_FEEDBACK_NOT_FOUND = new ErrorCode(1_005_004_043, "反馈不存在或无权管理");
|
||||
ErrorCode STUDENT_FEEDBACK_STATUS_INVALID = new ErrorCode(1_005_004_044, "不支持的反馈状态:{}");
|
||||
ErrorCode STUDENT_FEEDBACK_PRIORITY_INVALID = new ErrorCode(1_005_004_045, "不支持的反馈优先级:{}");
|
||||
ErrorCode STUDENT_FEEDBACK_CONFLICT = new ErrorCode(1_005_004_046, "反馈已被其他管理员更新,请刷新后重试");
|
||||
ErrorCode STUDENT_FEEDBACK_REWARD_FAILED = new ErrorCode(1_005_004_047, "反馈积分奖励发放失败,可稍后重试");
|
||||
ErrorCode SUPERVISION_RULE_NOT_FOUND = new ErrorCode(1_005_004_048, "督导规则不存在或超出数据范围");
|
||||
ErrorCode SUPERVISION_RULE_CONFLICT = new ErrorCode(1_005_004_049, "督导规则已被其他管理员更新,请刷新后重试");
|
||||
ErrorCode SUPERVISION_RULE_INVALID = new ErrorCode(1_005_004_050, "督导规则配置无效:{}");
|
||||
ErrorCode STUDENT_FOLLOWUP_NOT_FOUND = new ErrorCode(1_005_004_051, "学生跟进任务不存在或超出数据范围");
|
||||
ErrorCode STUDENT_FOLLOWUP_CONFLICT = new ErrorCode(1_005_004_052, "学生跟进任务已被其他管理员更新,请刷新后重试");
|
||||
ErrorCode STUDENT_FOLLOWUP_STATUS_INVALID = new ErrorCode(1_005_004_053, "学生跟进状态无效:{}");
|
||||
ErrorCode SUPERVISION_ASSIGNEE_INVALID = new ErrorCode(1_005_004_054, "督导负责人不是有效的后台管理员");
|
||||
ErrorCode BADGE_DEFINITION_NOT_FOUND = new ErrorCode(1_005_004_055, "徽章定义不存在或无权管理");
|
||||
ErrorCode BADGE_DEFINITION_CONFLICT = new ErrorCode(1_005_004_056, "徽章定义已被其他管理员更新,请刷新后重试");
|
||||
ErrorCode BADGE_DEFINITION_CODE_CONFLICT = new ErrorCode(1_005_004_057, "徽章编码已存在或不允许修改");
|
||||
ErrorCode BADGE_RULE_INVALID = new ErrorCode(1_005_004_058, "徽章自动授予规则无效");
|
||||
ErrorCode BADGE_DEFINITION_DISABLED = new ErrorCode(1_005_004_059, "停用徽章不能发放");
|
||||
ErrorCode BADGE_TARGET_MEMBER_INVALID = new ErrorCode(1_005_004_080, "徽章目标不是有效会员用户");
|
||||
ErrorCode BADGE_GRANT_CONFLICT = new ErrorCode(1_005_004_081, "徽章发放冲突,请刷新后重试");
|
||||
|
||||
// ========== 租户外观、公开设置与主题 1-005-004-090 ~ 1-005-004-099 ==========
|
||||
ErrorCode TENANT_APPEARANCE_CONFIG_INVALID = new ErrorCode(1_005_004_090, "租户外观配置无效:{}");
|
||||
ErrorCode TENANT_APPEARANCE_SECRET_REJECTED = new ErrorCode(1_005_004_091,
|
||||
"公开配置不能包含密钥、密码或令牌,请仅保存 secretRef");
|
||||
ErrorCode TENANT_THEME_TEMPLATE_NOT_FOUND = new ErrorCode(1_005_004_092, "主题模板不存在或已停用");
|
||||
ErrorCode TENANT_THEME_DRAFT_NOT_FOUND = new ErrorCode(1_005_004_093, "没有可发布的主题草稿");
|
||||
ErrorCode TENANT_APPEARANCE_CONFLICT = new ErrorCode(1_005_004_094, "租户外观配置已更新,请刷新后重试");
|
||||
|
||||
// ========== 学习激活码 1-005-004-100 ~ 1-005-004-109 ==========
|
||||
ErrorCode ACTIVATION_CODE_BATCH_NOT_FOUND = new ErrorCode(1_005_004_100, "激活码批次不存在或无权管理");
|
||||
ErrorCode ACTIVATION_CODE_BATCH_CONFLICT = new ErrorCode(1_005_004_101, "激活码批次已更新,请刷新后重试");
|
||||
ErrorCode ACTIVATION_CODE_BATCH_INVALID = new ErrorCode(1_005_004_102, "激活码批次配置无效");
|
||||
ErrorCode ACTIVATION_CODE_NOT_FOUND = new ErrorCode(1_005_004_103, "激活码无效或不可兑换");
|
||||
ErrorCode ACTIVATION_CODE_USED = new ErrorCode(1_005_004_104, "激活码已被其他会员兑换");
|
||||
ErrorCode ACTIVATION_CODE_CONFLICT = new ErrorCode(1_005_004_105, "激活码状态已变化,请重试");
|
||||
|
||||
// ========== 题目导入任务 1-005-003-080 ~ 1-005-003-089 ==========
|
||||
ErrorCode QUESTION_IMPORT_NOT_FOUND = new ErrorCode(1_005_003_080, "题目导入任务不存在或无权访问");
|
||||
@@ -170,6 +205,7 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode QUESTION_IMPORT_SCAN_NOT_CLEAN = new ErrorCode(1_005_003_083, "导入文件未通过安全扫描");
|
||||
ErrorCode QUESTION_IMPORT_PARSER_UNAVAILABLE = new ErrorCode(1_005_003_084, "导入解析器不可用");
|
||||
ErrorCode QUESTION_IMPORT_PROVIDER_UNSUPPORTED = new ErrorCode(1_005_003_085, "当前题库数据源模式不支持执行导入:{}");
|
||||
ErrorCode QUESTION_IMPORT_INVALID_FILE = new ErrorCode(1_005_003_086, "题目导入文件格式或内容不安全:{}");
|
||||
|
||||
// ========== 内容导入任务 1-005-004-060 ~ 1-005-004-069 ==========
|
||||
ErrorCode CONTENT_IMPORT_JOB_NOT_FOUND = new ErrorCode(1_005_004_060, "内容导入任务不存在或无权访问");
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,13 @@ public class MemberPointAwardPort {
|
||||
}
|
||||
|
||||
public void addLearningPoints(Long userId, Integer points, Long awardId) {
|
||||
memberPointApi.addPoint(userId, points, MemberPointBizType.EDUCATION_LEARNING,
|
||||
memberPointApi.addPointOnce(userId, points, MemberPointBizType.EDUCATION_LEARNING,
|
||||
"education-learning-award:" + awardId);
|
||||
}
|
||||
|
||||
public void addFeedbackReward(Long userId, Integer points, Long feedbackId) {
|
||||
memberPointApi.addPointOnce(userId, points, MemberPointBizType.EDUCATION_LEARNING,
|
||||
"education-feedback-reward:" + feedbackId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,13 +3,10 @@ package cn.iocoder.yudao.module.education.runtime;
|
||||
import cn.iocoder.yudao.module.education.config.EducationProperties;
|
||||
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
|
||||
import cn.iocoder.yudao.module.education.integration.scalar.config.ScalarProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.LongAdder;
|
||||
|
||||
@Component
|
||||
public class ScalarLegacyDependencyTelemetry implements EducationRuntimeDependency {
|
||||
|
||||
private final EducationProperties educationProperties;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -55,7 +55,9 @@ public class EducationAssetAdmissionServiceImpl implements EducationAssetAdmissi
|
||||
FileDescriptor descriptor = fileApi.createFile(new FileContent(bytes, name,
|
||||
"education/import-assets/" + tenantId, contentType));
|
||||
FileScanStatus scanStatus = fileApi.scan(descriptor);
|
||||
if (scanStatus == null) scanStatus = FileScanStatus.UNAVAILABLE;
|
||||
if (scanStatus != FileScanStatus.CLEAN) {
|
||||
throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN);
|
||||
}
|
||||
|
||||
EducationImportAssetDO asset = new EducationImportAssetDO();
|
||||
asset.setTenantId(tenantId);
|
||||
@@ -63,7 +65,7 @@ public class EducationAssetAdmissionServiceImpl implements EducationAssetAdmissi
|
||||
asset.setOriginalName(name);
|
||||
asset.setContentType(contentType);
|
||||
asset.setSize((long) bytes.length);
|
||||
asset.setSha256(DigestUtil.sha256Hex(bytes));
|
||||
asset.setSha256(descriptor.checksumSha256() != null ? descriptor.checksumSha256() : DigestUtil.sha256Hex(bytes));
|
||||
asset.setFileReference(descriptor.reference());
|
||||
asset.setScanStatus(scanStatus.name());
|
||||
assetMapper.insert(asset);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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())); }
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
package cn.iocoder.yudao.module.education.service.blueprint.authoring;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.PracticeBlueprintDO;
|
||||
|
||||
public interface PracticeBlueprintAuthoringService {
|
||||
default PageResult<PracticeBlueprintDO> getPage(PageParam pageParam) { throw new UnsupportedOperationException(); }
|
||||
default PracticeBlueprintDO get(Long id) { throw new UnsupportedOperationException(); }
|
||||
Long createDraft(PracticeBlueprintAuthoringCommand command);
|
||||
int reviseDraft(Long id, PracticeBlueprintAuthoringCommand command);
|
||||
int activate(Long id, int expectedVersion, Long actorId);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package cn.iocoder.yudao.module.education.service.blueprint.authoring;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
|
||||
import cn.iocoder.yudao.module.education.config.EducationProperties;
|
||||
@@ -32,6 +34,16 @@ public class PracticeBlueprintAuthoringServiceImpl implements PracticeBlueprintA
|
||||
this.nodeMapper = nodeMapper; this.collectionMapper = collectionMapper; this.questionMapper = questionMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<PracticeBlueprintDO> getPage(PageParam pageParam) {
|
||||
assertMode(); return blueprintMapper.selectTenantOwnedPage(pageParam, TenantContextHolder.getRequiredTenantId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public PracticeBlueprintDO get(Long id) {
|
||||
assertMode(); return requireOwned(TenantContextHolder.getRequiredTenantId(), id);
|
||||
}
|
||||
|
||||
@Override @Transactional(rollbackFor = Exception.class)
|
||||
public Long createDraft(PracticeBlueprintAuthoringCommand command) {
|
||||
assertMode(); Long tenantId = TenantContextHolder.getRequiredTenantId(); Target target = validate(tenantId, command);
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package cn.iocoder.yudao.module.education.service.category.authoring;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CategoryDO;
|
||||
|
||||
public interface CategoryAuthoringService {
|
||||
default PageResult<CategoryDO> getPage(PageParam pageParam) { throw new UnsupportedOperationException(); }
|
||||
default CategoryDO get(Long categoryId) { throw new UnsupportedOperationException(); }
|
||||
Long createDraft(CategoryAuthoringCommand command);
|
||||
int reviseDraft(Long categoryId, CategoryAuthoringCommand command);
|
||||
int activate(Long categoryId, int expectedAuthoringVersion, Long actorId);
|
||||
|
||||
@@ -2,6 +2,8 @@ package cn.iocoder.yudao.module.education.service.category.authoring;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.education.config.EducationProperties;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CategoryDO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CategoryLifecycleAuditDO;
|
||||
@@ -34,6 +36,18 @@ public class CategoryAuthoringServiceImpl implements CategoryAuthoringService {
|
||||
this.subjectMapper = subjectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<CategoryDO> getPage(PageParam pageParam) {
|
||||
assertMode();
|
||||
return categoryMapper.selectTenantOwnedPage(pageParam, TenantContextHolder.getRequiredTenantId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public CategoryDO get(Long categoryId) {
|
||||
assertMode();
|
||||
return requireOwned(TenantContextHolder.getRequiredTenantId(), categoryId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createDraft(CategoryAuthoringCommand command) {
|
||||
|
||||
@@ -18,6 +18,8 @@ import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserDeptId;
|
||||
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||
|
||||
@Service
|
||||
@@ -46,6 +48,8 @@ public class EducationClassServiceImpl implements EducationClassService {
|
||||
classroom.setName(name);
|
||||
classroom.setDescription(description);
|
||||
classroom.setStatus("ACTIVE");
|
||||
classroom.setDeptId(getLoginUserDeptId());
|
||||
classroom.setOwnerUserId(getLoginUserId());
|
||||
classMapper.insert(classroom);
|
||||
return classroom.getId();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
package cn.iocoder.yudao.module.education.service.collection.authoring;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionCollectionDO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface QuestionCollectionAuthoringService {
|
||||
default PageResult<QuestionCollectionDO> getPage(PageParam pageParam) { throw new UnsupportedOperationException(); }
|
||||
default QuestionCollectionDO get(Long collectionId) { throw new UnsupportedOperationException(); }
|
||||
Long createDraft(QuestionCollectionAuthoringCommand command);
|
||||
int reviseDraft(Long collectionId, QuestionCollectionAuthoringCommand command);
|
||||
int replaceMembership(Long collectionId, List<Long> questionIds, int expectedAuthoringVersion);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package cn.iocoder.yudao.module.education.service.collection.authoring;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
|
||||
import cn.iocoder.yudao.module.education.config.EducationProperties;
|
||||
@@ -30,6 +32,16 @@ public class QuestionCollectionAuthoringServiceImpl implements QuestionCollectio
|
||||
this.auditMapper = auditMapper; this.nodeMapper = nodeMapper; this.questionMapper = questionMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<QuestionCollectionDO> getPage(PageParam pageParam) {
|
||||
assertMode(); return collectionMapper.selectTenantOwnedPage(pageParam, TenantContextHolder.getRequiredTenantId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public QuestionCollectionDO get(Long collectionId) {
|
||||
assertMode(); return requireOwned(TenantContextHolder.getRequiredTenantId(), collectionId);
|
||||
}
|
||||
|
||||
@Override @Transactional(rollbackFor = Exception.class)
|
||||
public Long createDraft(QuestionCollectionAuthoringCommand command) {
|
||||
assertMode(); Long tenantId = TenantContextHolder.getRequiredTenantId();
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package cn.iocoder.yudao.module.education.service.contentnode.authoring;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentNodeDO;
|
||||
|
||||
public interface ContentNodeAuthoringService {
|
||||
default PageResult<ContentNodeDO> getPage(PageParam pageParam) { throw new UnsupportedOperationException(); }
|
||||
default ContentNodeDO get(Long nodeId) { throw new UnsupportedOperationException(); }
|
||||
Long createDraft(ContentNodeAuthoringCommand command);
|
||||
int reviseDraft(Long nodeId, ContentNodeAuthoringCommand command);
|
||||
int activate(Long nodeId, int expectedAuthoringVersion, Long actorId);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package cn.iocoder.yudao.module.education.service.contentnode.authoring;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
|
||||
import cn.iocoder.yudao.module.education.config.EducationProperties;
|
||||
@@ -30,6 +32,18 @@ public class ContentNodeAuthoringServiceImpl implements ContentNodeAuthoringServ
|
||||
this.auditMapper = auditMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<ContentNodeDO> getPage(PageParam pageParam) {
|
||||
assertMode();
|
||||
return nodeMapper.selectTenantOwnedPage(pageParam, TenantContextHolder.getRequiredTenantId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ContentNodeDO get(Long nodeId) {
|
||||
assertMode();
|
||||
return requireOwned(TenantContextHolder.getRequiredTenantId(), nodeId);
|
||||
}
|
||||
|
||||
@Override @Transactional(rollbackFor = Exception.class)
|
||||
public Long createDraft(ContentNodeAuthoringCommand command) {
|
||||
assertMode();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package cn.iocoder.yudao.module.education.service.importjob;
|
||||
|
||||
public interface ImportObjectScanGateway {
|
||||
enum ScanResult { CLEAN, INFECTED, ERROR }
|
||||
ScanResult scan(String objectKey);
|
||||
enum ScanResult { CLEAN, INFECTED, ERROR, UNAVAILABLE }
|
||||
ScanResult scan(String reference, String name, String mimeType, long size, String checksumSha256);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -2,5 +2,6 @@ package cn.iocoder.yudao.module.education.service.importjob;
|
||||
|
||||
public record QuestionImportJobProjection(Long id, String status, String scanStatus, String parserStatus,
|
||||
String fileName, Long fileSize, Integer previewQuestionCount,
|
||||
Integer importedQuestionCount, String failureCode) {
|
||||
Integer importedQuestionCount, String failureCode,
|
||||
String previewPayload, String resultSummary) {
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
|
||||
import cn.iocoder.yudao.module.education.config.EducationProperties;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.importjob.ContentImportAssetDO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.importjob.ContentImportJobDO;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionVersionMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.importjob.ContentImportAssetMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.importjob.ContentImportJobMapper;
|
||||
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
|
||||
@@ -13,11 +15,14 @@ import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraf
|
||||
import cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -28,7 +33,8 @@ import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||
public class QuestionImportJobServiceImpl implements QuestionImportJobService {
|
||||
|
||||
private static final int MAX_ATTEMPTS = 5;
|
||||
private static final long LEASE_SECONDS = 60;
|
||||
private static final long PREVIEW_LEASE_SECONDS = 60;
|
||||
private static final long EXECUTE_LEASE_SECONDS = 600;
|
||||
|
||||
private final EducationProperties properties;
|
||||
private final ContentImportAssetMapper assetMapper;
|
||||
@@ -36,18 +42,26 @@ public class QuestionImportJobServiceImpl implements QuestionImportJobService {
|
||||
private final Optional<ImportObjectScanGateway> scanGateway;
|
||||
private final Optional<QuestionImportParser> parser;
|
||||
private final TenantQuestionLifecycleService lifecycleService;
|
||||
private final QuestionMapper questionMapper;
|
||||
private final QuestionVersionMapper versionMapper;
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
|
||||
public QuestionImportJobServiceImpl(EducationProperties properties, ContentImportAssetMapper assetMapper,
|
||||
ContentImportJobMapper jobMapper,
|
||||
Optional<ImportObjectScanGateway> scanGateway,
|
||||
Optional<QuestionImportParser> parser,
|
||||
TenantQuestionLifecycleService lifecycleService) {
|
||||
TenantQuestionLifecycleService lifecycleService,
|
||||
QuestionMapper questionMapper, QuestionVersionMapper versionMapper,
|
||||
TransactionTemplate transactionTemplate) {
|
||||
this.properties = properties;
|
||||
this.assetMapper = assetMapper;
|
||||
this.jobMapper = jobMapper;
|
||||
this.scanGateway = scanGateway;
|
||||
this.parser = parser;
|
||||
this.lifecycleService = lifecycleService;
|
||||
this.questionMapper = questionMapper;
|
||||
this.versionMapper = versionMapper;
|
||||
this.transactionTemplate = transactionTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -117,7 +131,8 @@ public class QuestionImportJobServiceImpl implements QuestionImportJobService {
|
||||
private void processClaimed(Long jobId, String expectedStatus) {
|
||||
jobMapper.failExhausted();
|
||||
String token = UUID.randomUUID().toString();
|
||||
ContentImportJobDO job = jobMapper.claimById(jobId, expectedStatus, "education-import", token, LEASE_SECONDS);
|
||||
long leaseSeconds = "EXECUTE_PENDING".equals(expectedStatus) ? EXECUTE_LEASE_SECONDS : PREVIEW_LEASE_SECONDS;
|
||||
ContentImportJobDO job = jobMapper.claimById(jobId, expectedStatus, "education-import", token, leaseSeconds);
|
||||
if (job != null) TenantUtils.execute(job.getTenantId(), () -> process(job, token));
|
||||
}
|
||||
|
||||
@@ -133,7 +148,8 @@ public class QuestionImportJobServiceImpl implements QuestionImportJobService {
|
||||
finishFailed(job, token, "SCAN_UNAVAILABLE");
|
||||
throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN);
|
||||
}
|
||||
ImportObjectScanGateway.ScanResult scanResult = gateway.scan(asset.getObjectKey());
|
||||
ImportObjectScanGateway.ScanResult scanResult = gateway.scan(asset.getObjectKey(), asset.getFileName(),
|
||||
asset.getMimeType(), asset.getFileSizeBytes(), asset.getChecksumSha256());
|
||||
if (scanResult != ImportObjectScanGateway.ScanResult.CLEAN) {
|
||||
finishFailed(job, token, "SCAN_" + scanResult.name());
|
||||
throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN);
|
||||
@@ -147,8 +163,8 @@ public class QuestionImportJobServiceImpl implements QuestionImportJobService {
|
||||
QuestionImportParser.ParsedImport parsed = availableParser.parse(asset.getObjectKey());
|
||||
parserStatus = "PARSED";
|
||||
parsedPayload = parsed.payload();
|
||||
questionCount = parsed.questions().size();
|
||||
previewPayload = "{\"mode\":\"PARSED\",\"executable\":true}";
|
||||
questionCount = parsed.questionCount();
|
||||
previewPayload = parsed.previewPayload();
|
||||
}
|
||||
int updated = jobMapper.finishClaim(job.getTenantId(), job.getId(), "PREVIEW_PENDING", "PREVIEW_READY",
|
||||
token, "CLEAN", parserStatus, previewPayload, parsedPayload, questionCount, null, null, null, null);
|
||||
@@ -158,12 +174,24 @@ public class QuestionImportJobServiceImpl implements QuestionImportJobService {
|
||||
private void processExecute(ContentImportJobDO job, String token) {
|
||||
if (!"CLEAN".equals(job.getScanStatus())) throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN);
|
||||
QuestionImportParser availableParser = parser.orElseThrow(() -> exception(QUESTION_IMPORT_PARSER_UNAVAILABLE));
|
||||
List<QuestionDraftCommand> questions = availableParser.restore(job.getParsedPayload());
|
||||
for (QuestionDraftCommand question : questions) lifecycleService.createDraft(question);
|
||||
int updated = jobMapper.finishClaim(job.getTenantId(), job.getId(), "EXECUTE_PENDING", "COMPLETED",
|
||||
token, null, null, null, job.getParsedPayload(), job.getPreviewQuestionCount(),
|
||||
"{\"result\":\"COMPLETED\"}", questions.size(), null, null);
|
||||
if (updated != 1) throw exception(CONTENT_IMPORT_LEASE_LOST);
|
||||
transactionTemplate.executeWithoutResult(status -> {
|
||||
List<QuestionDraftCommand> questions = availableParser.restore(job.getParsedPayload());
|
||||
if (job.getPreviewQuestionCount() == null || questions.size() != job.getPreviewQuestionCount()) {
|
||||
throw exception(QUESTION_IMPORT_STATE_CONFLICT);
|
||||
}
|
||||
List<Long> ids = new ArrayList<>(questions.size());
|
||||
for (QuestionDraftCommand question : questions) ids.add(lifecycleService.createDraft(question));
|
||||
if (ids.stream().anyMatch(Objects::isNull)
|
||||
|| questionMapper.countImportedDrafts(job.getTenantId(), ids) != ids.size()
|
||||
|| versionMapper.countFirstVersions(job.getTenantId(), ids) != ids.size()) {
|
||||
throw exception(QUESTION_IMPORT_STATE_CONFLICT);
|
||||
}
|
||||
String result = JsonUtils.toJsonString(new ImportResult(ids.size(), ids));
|
||||
int updated = jobMapper.finishClaim(job.getTenantId(), job.getId(), "EXECUTE_PENDING", "COMPLETED",
|
||||
token, null, null, null, job.getParsedPayload(), job.getPreviewQuestionCount(),
|
||||
result, ids.size(), null, null);
|
||||
if (updated != 1) throw exception(CONTENT_IMPORT_LEASE_LOST);
|
||||
});
|
||||
}
|
||||
|
||||
private void finishFailed(ContentImportJobDO job, String token, String failureCode) {
|
||||
@@ -207,6 +235,8 @@ public class QuestionImportJobServiceImpl implements QuestionImportJobService {
|
||||
private QuestionImportJobProjection project(ContentImportJobDO job, ContentImportAssetDO asset) {
|
||||
return new QuestionImportJobProjection(job.getId(), job.getStatus(), job.getScanStatus(),
|
||||
job.getParserStatus(), asset.getFileName(), asset.getFileSizeBytes(), job.getPreviewQuestionCount(),
|
||||
job.getImportedQuestionCount(), job.getFailureCode());
|
||||
job.getImportedQuestionCount(), job.getFailureCode(), job.getPreviewPayload(), job.getResultSummary());
|
||||
}
|
||||
|
||||
private record ImportResult(int count, List<Long> questionIds) {}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package cn.iocoder.yudao.module.education.service.importjob;
|
||||
|
||||
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface QuestionImportParser {
|
||||
ParsedImport parse(String objectKey);
|
||||
List<QuestionDraftCommand> restore(String payload);
|
||||
record ParsedImport(String payload, List<QuestionDraftCommand> questions) {}
|
||||
record ParsedImport(String payload, String previewPayload, List<QuestionDraftCommand> questions,
|
||||
int questionCount, boolean executable) {}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import cn.iocoder.yudao.module.education.controller.app.practice.vo.*;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.*;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.*;
|
||||
import cn.iocoder.yudao.module.education.service.commercialization.EducationEntitlementService;
|
||||
import cn.iocoder.yudao.module.education.service.badge.BadgeGrantService;
|
||||
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
|
||||
import cn.iocoder.yudao.module.education.service.question.QuestionContentSafety;
|
||||
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO;
|
||||
@@ -48,6 +49,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
||||
private final WrongQuestionService wrongQuestionService;
|
||||
private final ScoringService scoringService;
|
||||
private final EducationEntitlementService entitlementService;
|
||||
private final BadgeGrantService badgeGrantService;
|
||||
|
||||
public PracticeSessionServiceImpl(PracticeSessionMapper sessionMapper,
|
||||
PracticeQuestionMapper questionMapper,
|
||||
@@ -57,7 +59,8 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
||||
QuestionCatalogProvider questionCatalogProvider,
|
||||
WrongQuestionService wrongQuestionService,
|
||||
ScoringService scoringService,
|
||||
ObjectProvider<EducationEntitlementService> entitlementServiceProvider) {
|
||||
ObjectProvider<EducationEntitlementService> entitlementServiceProvider,
|
||||
ObjectProvider<BadgeGrantService> badgeGrantServiceProvider) {
|
||||
this.sessionMapper = sessionMapper;
|
||||
this.questionMapper = questionMapper;
|
||||
this.idempotencyStore = idempotencyStore;
|
||||
@@ -67,6 +70,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
||||
this.wrongQuestionService = wrongQuestionService;
|
||||
this.scoringService = scoringService;
|
||||
this.entitlementService = entitlementServiceProvider.getIfAvailable();
|
||||
this.badgeGrantService = badgeGrantServiceProvider.getIfAvailable();
|
||||
}
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -371,6 +375,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
||||
if (!"ACTIVE".equals(session.getStatus())) {
|
||||
if ("SUBMITTED".equals(session.getStatus())) {
|
||||
PracticeSubmitRespVO existingResponse = buildExistingSubmitResponse(session, tenantId, userId);
|
||||
evaluatePracticeBadges(tenantId, userId, existingResponse.getReportId());
|
||||
return completeSubmitClaim(claim, existingResponse);
|
||||
}
|
||||
throw exception(switch (session.getStatus()) {
|
||||
@@ -392,6 +397,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
||||
int reportInserted = reportMapper.insertIgnore(report);
|
||||
if (reportInserted == 0) {
|
||||
PracticeSubmitRespVO winnerResponse = buildExistingSubmitResponse(session, tenantId, userId);
|
||||
evaluatePracticeBadges(tenantId, userId, winnerResponse.getReportId());
|
||||
return completeSubmitClaim(claim, winnerResponse);
|
||||
}
|
||||
report = reportMapper.selectBySessionIdAndTenant(session.getId(), tenantId);
|
||||
@@ -411,6 +417,8 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
||||
throw exception(SUBMIT_CONCURRENT_CONFLICT);
|
||||
}
|
||||
|
||||
evaluatePracticeBadges(tenantId, userId, report.getId());
|
||||
|
||||
return completeSubmitClaim(claim, scoringService.buildSubmitResponse(report, details));
|
||||
}
|
||||
|
||||
@@ -438,6 +446,10 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
||||
return response;
|
||||
}
|
||||
|
||||
private void evaluatePracticeBadges(Long tenantId, Long userId, Long reportId) {
|
||||
if (badgeGrantService != null) badgeGrantService.evaluatePractice(tenantId, userId, reportId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PracticeSubmitRespVO getReport(Long sessionId, Long userId, Long tenantId) {
|
||||
PracticeSessionDO session = sessionMapper.selectByIdAndTenant(sessionId, tenantId);
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
package cn.iocoder.yudao.module.education.service.question.authoring;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionDO;
|
||||
|
||||
/** 租户自有题目的显式发布生命周期。 */
|
||||
public interface TenantQuestionLifecycleService {
|
||||
|
||||
default PageResult<QuestionDO> getPage(PageParam pageParam) { throw new UnsupportedOperationException(); }
|
||||
|
||||
default QuestionDO get(Long questionId) { throw new UnsupportedOperationException(); }
|
||||
|
||||
Long createDraft(QuestionDraftCommand command);
|
||||
|
||||
default int reviseDraft(Long questionId, QuestionDraftCommand command, int expectedContentVersion) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
int place(Long questionId, QuestionPlacementCommand command);
|
||||
|
||||
void publish(Long questionId, Long actorId);
|
||||
|
||||
@@ -40,6 +40,10 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc
|
||||
private static final String DRAFT = "DRAFT";
|
||||
private static final String PUBLISHED = "PUBLISHED";
|
||||
private static final String ARCHIVED = "ARCHIVED";
|
||||
private static final int MAX_STEM_LENGTH = 20_000;
|
||||
private static final int MAX_OPTION_LENGTH = 10_000;
|
||||
private static final int MAX_ANSWER_LENGTH = 500;
|
||||
private static final int MAX_EXPLANATION_LENGTH = 50_000;
|
||||
|
||||
private final EducationProperties properties;
|
||||
private final QuestionMapper questionMapper;
|
||||
@@ -87,17 +91,10 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createDraft(QuestionDraftCommand command) {
|
||||
assertAuthoringMode();
|
||||
if (command == null) throw exception(QUESTION_CONTENT_NOT_PUBLISHABLE);
|
||||
Long tenantId = TenantContextHolder.getRequiredTenantId();
|
||||
List<QuestionDraftCommand.QuestionDraftOption> suppliedOptions = command.options() != null
|
||||
? command.options() : List.of();
|
||||
List<QuestionDraftCommand.QuestionDraftOption> options = IntStream.range(0, suppliedOptions.size())
|
||||
.mapToObj(index -> {
|
||||
QuestionDraftCommand.QuestionDraftOption option = suppliedOptions.get(index);
|
||||
return new QuestionDraftCommand.QuestionDraftOption(
|
||||
option.label(), option.content(),
|
||||
option.order() != null ? option.order() : index + 1D);
|
||||
})
|
||||
.toList();
|
||||
List<QuestionDraftCommand.QuestionDraftOption> options = normalizeOptions(command);
|
||||
validateDraft(command, options);
|
||||
|
||||
QuestionDO question = new QuestionDO();
|
||||
question.setTenantId(tenantId);
|
||||
@@ -115,11 +112,65 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc
|
||||
question.setSortOrder(0);
|
||||
questionMapper.insert(question);
|
||||
|
||||
appendVersion(tenantId, question, 1);
|
||||
return question.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int reviseDraft(Long questionId, QuestionDraftCommand command, int expectedContentVersion) {
|
||||
assertAuthoringMode();
|
||||
if (command == null || expectedContentVersion < 1) {
|
||||
throw exception(QUESTION_CONTENT_NOT_PUBLISHABLE);
|
||||
}
|
||||
Long tenantId = TenantContextHolder.getRequiredTenantId();
|
||||
QuestionDO existing = questionMapper.selectTenantOwnedById(tenantId, questionId);
|
||||
if (existing == null) {
|
||||
throw exception(QUESTION_AUTHORING_NOT_FOUND);
|
||||
}
|
||||
if (!DRAFT.equals(existing.getStatus())
|
||||
|| !Integer.valueOf(expectedContentVersion).equals(existing.getContentVersion())) {
|
||||
throw exception(QUESTION_LIFECYCLE_CONFLICT, "修改");
|
||||
}
|
||||
List<QuestionDraftCommand.QuestionDraftOption> options = normalizeOptions(command);
|
||||
validateDraft(command, options);
|
||||
|
||||
QuestionDO revised = new QuestionDO();
|
||||
revised.setStem(command.stem());
|
||||
revised.setType(command.type());
|
||||
revised.setDifficulty(command.difficulty());
|
||||
revised.setOptions(JsonUtils.toJsonString(options));
|
||||
revised.setCorrectAnswer(command.correctAnswer());
|
||||
revised.setExplanation(command.explanation());
|
||||
revised.setAnalysis(command.analysis());
|
||||
int nextVersion = expectedContentVersion + 1;
|
||||
if (questionMapper.updateDraftContentCas(tenantId, questionId, revised, expectedContentVersion) != 1) {
|
||||
throw exception(QUESTION_LIFECYCLE_CONFLICT, "修改");
|
||||
}
|
||||
revised.setId(questionId);
|
||||
appendVersion(tenantId, revised, nextVersion);
|
||||
return nextVersion;
|
||||
}
|
||||
|
||||
private List<QuestionDraftCommand.QuestionDraftOption> normalizeOptions(QuestionDraftCommand command) {
|
||||
List<QuestionDraftCommand.QuestionDraftOption> suppliedOptions = command.options() != null
|
||||
? command.options() : List.of();
|
||||
return IntStream.range(0, suppliedOptions.size())
|
||||
.mapToObj(index -> {
|
||||
QuestionDraftCommand.QuestionDraftOption option = suppliedOptions.get(index);
|
||||
return option == null ? null : new QuestionDraftCommand.QuestionDraftOption(
|
||||
option.label(), option.content(),
|
||||
option.order() != null ? option.order() : index + 1D);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
private void appendVersion(Long tenantId, QuestionDO question, int versionNumber) {
|
||||
QuestionVersionDO version = new QuestionVersionDO();
|
||||
version.setTenantId(tenantId);
|
||||
version.setScope(TENANT_OWNED);
|
||||
version.setQuestionId(question.getId());
|
||||
version.setVersionNumber(1);
|
||||
version.setVersionNumber(versionNumber);
|
||||
version.setStem(question.getStem());
|
||||
version.setType(question.getType());
|
||||
version.setDifficulty(question.getDifficulty());
|
||||
@@ -128,7 +179,6 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc
|
||||
version.setExplanation(question.getExplanation());
|
||||
version.setAnalysis(question.getAnalysis());
|
||||
versionMapper.insert(version);
|
||||
return question.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -182,6 +232,44 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc
|
||||
auditMapper.insert(audit);
|
||||
}
|
||||
|
||||
private void validateDraft(QuestionDraftCommand command,
|
||||
List<QuestionDraftCommand.QuestionDraftOption> options) {
|
||||
if (blankOrLong(command.stem(), MAX_STEM_LENGTH)
|
||||
|| blankOrLong(command.type(), 32) || tooLong(command.difficulty(), 32)
|
||||
|| blankOrLong(command.correctAnswer(), MAX_ANSWER_LENGTH)
|
||||
|| tooLong(command.explanation(), MAX_EXPLANATION_LENGTH)
|
||||
|| tooLong(command.analysis(), MAX_EXPLANATION_LENGTH)) {
|
||||
throw exception(QUESTION_CONTENT_NOT_PUBLISHABLE);
|
||||
}
|
||||
try {
|
||||
List<QuestionContentSafety.SafeOption> safeOptions = options.stream()
|
||||
.map(option -> option == null ? null : new QuestionContentSafety.SafeOption(
|
||||
option.label(), option.content(), option.order()))
|
||||
.toList();
|
||||
safeOptions = validateOptionLengths(command.type(), safeOptions);
|
||||
validateAnswerKey(command.type(), command.correctAnswer(), safeOptions);
|
||||
} catch (ServiceException ex) {
|
||||
throw exception(QUESTION_CONTENT_NOT_PUBLISHABLE);
|
||||
}
|
||||
}
|
||||
|
||||
private List<QuestionContentSafety.SafeOption> validateOptionLengths(String type,
|
||||
List<QuestionContentSafety.SafeOption> options) {
|
||||
if (options.size() > 8 || options.stream().anyMatch(option -> option == null
|
||||
|| blankOrLong(option.label(), 20) || blankOrLong(option.content(), MAX_OPTION_LENGTH))) {
|
||||
throw exception(QUESTION_CONTENT_NOT_PUBLISHABLE);
|
||||
}
|
||||
return QuestionContentSafety.restoreSnapshotOptions(type, JsonUtils.toJsonString(options));
|
||||
}
|
||||
|
||||
private boolean blankOrLong(String value, int maxLength) {
|
||||
return value == null || value.isBlank() || value.length() > maxLength;
|
||||
}
|
||||
|
||||
private boolean tooLong(String value, int maxLength) {
|
||||
return value != null && value.length() > maxLength;
|
||||
}
|
||||
|
||||
private void validatePublishable(QuestionDO question) {
|
||||
if (question.getStem() == null || question.getStem().isBlank()
|
||||
|| question.getCorrectAnswer() == null || question.getCorrectAnswer().isBlank()) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user