diff --git a/yudao-module-education/pom.xml b/yudao-module-education/pom.xml index 4ab2837c..57c5bff5 100644 --- a/yudao-module-education/pom.xml +++ b/yudao-module-education/pom.xml @@ -45,6 +45,23 @@ yudao-spring-boot-starter-test test + + cn.iocoder.boot + yudao-module-promotion + ${revision} + test + + + cn.iocoder.boot + yudao-module-trade + ${revision} + test + + + org.testcontainers + testcontainers-postgresql + test + org.postgresql postgresql diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/activationcode/ActivationCodeAdminController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/activationcode/ActivationCodeAdminController.java new file mode 100644 index 00000000..d531a358 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/activationcode/ActivationCodeAdminController.java @@ -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> batchPage(@Valid BatchPageReq req){return success(service.batchPage(req));} + @PostMapping("/batch") @Operation(summary="创建激活码批次") + @PreAuthorize("@ss.hasPermission('education:activation-code:manage')") + public CommonResult 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 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 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> codePage(@Valid CodePageReq req){return success(service.codePage(req));} + @PutMapping("/code/{id}/disable") @Operation(summary="停用未兑换激活码") + @PreAuthorize("@ss.hasPermission('education:activation-code:manage')") + public CommonResult disable(@PathVariable Long id,@RequestParam Integer expectedVersion){return success(service.disable(id,expectedVersion));} +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/activationcode/vo/ActivationCodeAdminVOs.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/activationcode/vo/ActivationCodeAdminVOs.java new file mode 100644 index 00000000..ba1fea74 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/activationcode/vo/ActivationCodeAdminVOs.java @@ -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 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; + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/appearance/TenantAppearanceAdminController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/appearance/TenantAppearanceAdminController.java new file mode 100644 index 00000000..71e784e0 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/appearance/TenantAppearanceAdminController.java @@ -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 get() { return success(service.getAppearance()); } + + @PutMapping("/branding") + @Operation(summary = "更新品牌外观") + @PreAuthorize("@ss.hasPermission('education:tenant-appearance:branding')") + public CommonResult updateBranding(@Valid @RequestBody BrandingSaveReq req) { + return success(service.updateBranding(req, getLoginUserId())); + } + + @PutMapping("/settings") + @Operation(summary = "更新学生端与管理端功能设置") + @PreAuthorize("@ss.hasPermission('education:tenant-appearance:settings')") + public CommonResult 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> templates() { return success(service.getThemeTemplates()); } + + @PostMapping("/theme/preview") + @Operation(summary = "保存主题预览草稿") + @PreAuthorize("@ss.hasPermission('education:tenant-appearance:theme')") + public CommonResult 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 publish(@Valid @RequestBody ThemePublishReq req) { + return success(service.publishTheme(req, getLoginUserId())); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/appearance/vo/TenantAppearanceVOs.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/appearance/vo/TenantAppearanceVOs.java new file mode 100644 index 00000000..7a10708d --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/appearance/vo/TenantAppearanceVOs.java @@ -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 featureFlags; + private Map adminFeatureFlags; + private Map 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 theme; + private Map 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 theme; + private Map 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 featureFlags; + private Map adminFeatureFlags; + private Map publicConfig; + private String activeTemplateCode; + private Map activeTheme; + private Map activePublicAssets; + private String draftTemplateCode; + private Map draftTheme; + private Map 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 theme; + private Map 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 theme; + private Map publicAssets; + private Map featureFlags; + private Map publicConfig; + private LocalDateTime publishedTime; + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/badge/BadgeAdminController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/badge/BadgeAdminController.java new file mode 100644 index 00000000..153ad40c --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/badge/BadgeAdminController.java @@ -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> definitionPage(@Valid DefinitionPageReq req) { + return success(service.getDefinitionPage(req)); + } + @PostMapping("/definition") @Operation(summary = "创建徽章定义") + @PreAuthorize("@ss.hasPermission('education:badge:write')") + public CommonResult create(@Valid @RequestBody DefinitionSaveReq req) { + return success(service.createDefinition(req, getLoginUserId())); + } + @PutMapping("/definition/{id}") @Operation(summary = "修改徽章定义") + @PreAuthorize("@ss.hasPermission('education:badge:write')") + public CommonResult 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> grantPage(@Valid GrantPageReq req) { + return success(service.getGrantPage(req)); + } + @PostMapping("/grant") @Operation(summary = "向会员手工发放徽章") + @PreAuthorize("@ss.hasPermission('education:badge:grant')") + public CommonResult grant(@Valid @RequestBody ManualGrantReq req) { + return success(service.grant(req, getLoginUserId())); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/badge/vo/BadgeAdminVOs.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/badge/vo/BadgeAdminVOs.java new file mode 100644 index 00000000..2e5efeb7 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/badge/vo/BadgeAdminVOs.java @@ -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 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 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 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 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 evidence; + private LocalDateTime grantedTime; + @JsonIgnore private Long grantId; + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/blueprint/PracticeBlueprintAuthoringController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/blueprint/PracticeBlueprintAuthoringController.java index c747a6aa..f1ca03e2 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/blueprint/PracticeBlueprintAuthoringController.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/blueprint/PracticeBlueprintAuthoringController.java @@ -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> getPage(@Valid PageParam pageParam) { + return success(BeanUtils.toBean(service.getPage(pageParam), PracticeBlueprintAuthoringRespVO.class)); + } + @GetMapping("/get") @PreAuthorize("@ss.hasPermission('education:practice-blueprint:query')") + public CommonResult 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 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 revise(@PathVariable Long id, @Valid @RequestBody PracticeBlueprintReviseReqVO request) { return success(service.reviseDraft(id, toCommand(request, request.getExpectedAuthoringVersion()))); } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/blueprint/vo/PracticeBlueprintAuthoringRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/blueprint/vo/PracticeBlueprintAuthoringRespVO.java new file mode 100644 index 00000000..c6ff858a --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/blueprint/vo/PracticeBlueprintAuthoringRespVO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/category/CategoryAuthoringController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/category/CategoryAuthoringController.java index 0423a7e7..4206cc6d 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/category/CategoryAuthoringController.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/category/CategoryAuthoringController.java @@ -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> page(@Valid PageParam pageParam) { + PageResult 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 get(@PathVariable("id") Long id) { + return success(CategoryAuthoringRespVO.from(service.get(id))); + } + @PostMapping("/drafts") @PreAuthorize("@ss.hasPermission('education:category:author')") public CommonResult create(@Valid @RequestBody CategoryDraftReqVO request) { diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/category/vo/CategoryAuthoringRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/category/vo/CategoryAuthoringRespVO.java new file mode 100644 index 00000000..292de43f --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/category/vo/CategoryAuthoringRespVO.java @@ -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; + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/collection/QuestionCollectionAuthoringController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/collection/QuestionCollectionAuthoringController.java index 964884ac..28237d91 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/collection/QuestionCollectionAuthoringController.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/collection/QuestionCollectionAuthoringController.java @@ -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> getPage(@Valid PageParam pageParam) { + return success(BeanUtils.toBean(service.getPage(pageParam), QuestionCollectionAuthoringRespVO.class)); + } + @GetMapping("/get") @PreAuthorize("@ss.hasPermission('education:collection:query')") + public CommonResult get(@RequestParam("id") Long id) { + return success(BeanUtils.toBean(service.get(id), QuestionCollectionAuthoringRespVO.class)); + } + @PostMapping("/drafts") @PreAuthorize("@ss.hasPermission('education:collection:author')") public CommonResult 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 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 replaceMembership(@PathVariable Long id, @Valid @RequestBody QuestionCollectionMembershipReqVO request) { return success(service.replaceMembership(id, request.getQuestionIds(), request.getExpectedAuthoringVersion())); } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/collection/vo/QuestionCollectionAuthoringRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/collection/vo/QuestionCollectionAuthoringRespVO.java new file mode 100644 index 00000000..2e70e156 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/collection/vo/QuestionCollectionAuthoringRespVO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/ContentNodeAuthoringController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/ContentNodeAuthoringController.java index d4d743df..fd019024 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/ContentNodeAuthoringController.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/ContentNodeAuthoringController.java @@ -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> getPage(@Valid PageParam pageParam) { + return success(BeanUtils.toBean(service.getPage(pageParam), ContentNodeAuthoringRespVO.class)); + } + + @GetMapping("/get") + @PreAuthorize("@ss.hasPermission('education:content-node:query')") + public CommonResult 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 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 revise(@PathVariable("id") Long id, @Valid @RequestBody ContentNodeReviseReqVO request) { return success(service.reviseDraft(id, toCommand(request, request.getExpectedAuthoringVersion()))); diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/vo/ContentNodeAuthoringRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/vo/ContentNodeAuthoringRespVO.java new file mode 100644 index 00000000..22160ba2 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/vo/ContentNodeAuthoringRespVO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/importjob/vo/QuestionImportJobRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/importjob/vo/QuestionImportJobRespVO.java index 6a5be66e..28ab855a 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/importjob/vo/QuestionImportJobRespVO.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/importjob/vo/QuestionImportJobRespVO.java @@ -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; } } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/LearningOperationsAdminController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/LearningOperationsAdminController.java new file mode 100644 index 00000000..c301a7f9 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/LearningOperationsAdminController.java @@ -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 overview() { + return success(service.getOverview()); + } + + @GetMapping("/feedback/page") + @Operation(summary = "分页查询学生反馈") + @PreAuthorize("@ss.hasPermission('education:learning-operations:query')") + public CommonResult> feedbackPage( + @Valid StudentFeedbackPageReqVO reqVO) { + return success(service.getFeedbackPage(reqVO)); + } + + @GetMapping("/feedback/{feedbackId}/events") + @Operation(summary = "查询反馈处理事件") + @PreAuthorize("@ss.hasPermission('education:learning-operations:query')") + public CommonResult> feedbackEvents(@PathVariable Long feedbackId) { + return success(service.getFeedbackEvents(feedbackId)); + } + + @PutMapping("/feedback/{feedbackId}/handle") + @Operation(summary = "处理学生反馈") + @PreAuthorize("@ss.hasPermission('education:learning-operations:feedback')") + public CommonResult 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 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 retryFeedbackReward(@PathVariable Long feedbackId) { + return success(service.deliverReward(feedbackId)); + } + + @GetMapping("/award/page") + @Operation(summary = "分页查询学习积分与徽章奖励") + @PreAuthorize("@ss.hasPermission('education:learning-operations:query')") + public CommonResult> awardPage(@Valid LearningAwardPageReqVO reqVO) { + return success(service.getAwardPage(reqVO)); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/LearningAwardAdminRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/LearningAwardAdminRespVO.java new file mode 100644 index 00000000..1bc70f46 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/LearningAwardAdminRespVO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/LearningAwardPageReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/LearningAwardPageReqVO.java new file mode 100644 index 00000000..35c05340 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/LearningAwardPageReqVO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/LearningOperationsOverviewRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/LearningOperationsOverviewRespVO.java new file mode 100644 index 00000000..ce39de78 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/LearningOperationsOverviewRespVO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/StudentFeedbackAdminRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/StudentFeedbackAdminRespVO.java new file mode 100644 index 00000000..95b04dc9 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/StudentFeedbackAdminRespVO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/StudentFeedbackEventRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/StudentFeedbackEventRespVO.java new file mode 100644 index 00000000..7721e37d --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/StudentFeedbackEventRespVO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/StudentFeedbackHandleReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/StudentFeedbackHandleReqVO.java new file mode 100644 index 00000000..a405cd91 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/StudentFeedbackHandleReqVO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/StudentFeedbackPageReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/StudentFeedbackPageReqVO.java new file mode 100644 index 00000000..0d8da5de --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/StudentFeedbackPageReqVO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/StudentFeedbackRewardReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/StudentFeedbackRewardReqVO.java new file mode 100644 index 00000000..0d31f574 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/vo/StudentFeedbackRewardReqVO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/question/QuestionAuthoringController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/question/QuestionAuthoringController.java index 17e308bc..60a061e4 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/question/QuestionAuthoringController.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/question/QuestionAuthoringController.java @@ -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> getPage(@Valid PageParam pageParam) { + return success(BeanUtils.toBean(lifecycleService.getPage(pageParam), QuestionAuthoringRespVO.class)); + } + + @GetMapping("/get") + @PreAuthorize("@ss.hasPermission('education:question:query')") + public CommonResult 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 createDraft(@Valid @RequestBody QuestionDraftCreateReqVO reqVO) { - List 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 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 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()); + } } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/question/vo/QuestionAuthoringRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/question/vo/QuestionAuthoringRespVO.java new file mode 100644 index 00000000..870e36e3 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/question/vo/QuestionAuthoringRespVO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/question/vo/QuestionDraftReviseReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/question/vo/QuestionDraftReviseReqVO.java new file mode 100644 index 00000000..753da178 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/question/vo/QuestionDraftReviseReqVO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/supervision/StudentSupervisionAdminController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/supervision/StudentSupervisionAdminController.java new file mode 100644 index 00000000..d4a97b0f --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/supervision/StudentSupervisionAdminController.java @@ -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 overview() { + return success(service.getOverview()); + } + + @GetMapping("/preview") + @PreAuthorize("@ss.hasPermission('education:supervision:query')") + public CommonResult preview(@Valid PreviewReq reqVO) { + return success(service.preview(reqVO)); + } + + @GetMapping("/rules/page") + @PreAuthorize("@ss.hasPermission('education:supervision:query')") + public CommonResult> rulePage(@Valid RulePageReq reqVO) { + return success(service.getRulePage(reqVO)); + } + + @PostMapping("/rules") + @PreAuthorize("@ss.hasPermission('education:supervision:rule')") + public CommonResult createRule(@Valid @RequestBody RuleSaveReq reqVO) { + return success(service.createRule(reqVO, getLoginUserId())); + } + + @PutMapping("/rules/{id}") + @PreAuthorize("@ss.hasPermission('education:supervision:rule')") + public CommonResult 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> followupPage(@Valid FollowupPageReq reqVO) { + return success(service.getFollowupPage(reqVO)); + } + + @PostMapping("/followups/generate") + @PreAuthorize("@ss.hasPermission('education:supervision:generate')") + public CommonResult generate(@Valid @RequestBody GenerateReq reqVO) { + return success(service.generate(reqVO, getLoginUserId())); + } + + @PutMapping("/followups/{id}") + @PreAuthorize("@ss.hasPermission('education:supervision:followup')") + public CommonResult handle(@PathVariable Long id, + @Valid @RequestBody FollowupHandleReq reqVO) { + return success(service.handleFollowup(id, reqVO, getLoginUserId())); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/supervision/vo/StudentSupervisionVOs.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/supervision/vo/StudentSupervisionVOs.java new file mode 100644 index 00000000..df5dcfed --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/supervision/vo/StudentSupervisionVOs.java @@ -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> reasons; + private Map evidence; + } + + @Data + @Builder + public static class PreviewResp { + private RiskRule rules; + private Long classId; + private Long assignedAdminUserId; + private Long totalCandidates; + private List 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> reasons; + private Map 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 items; + private List> errors; + } + + @Data + @Builder + public static class OverviewResp { + private Long openFollowups; + private Long overdueFollowups; + private Long highPriorityFollowups; + private Long doneFollowups; + private Long activeRules; + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/activationcode/ActivationCodeAppController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/activationcode/ActivationCodeAppController.java new file mode 100644 index 00000000..a2c8136f --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/activationcode/ActivationCodeAppController.java @@ -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 check(@Valid @RequestBody CodeReq req){student();return success(service.check(req.getCode()));} + @PostMapping("/redeem") @Operation(summary="原子兑换激活码并发放学习权益") + public CommonResult 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;} +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/activationcode/vo/ActivationCodeAppVOs.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/activationcode/vo/ActivationCodeAppVOs.java new file mode 100644 index 00000000..6bf2dfa7 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/activationcode/vo/ActivationCodeAppVOs.java @@ -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; + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/appearance/TenantAppearancePublicController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/appearance/TenantAppearancePublicController.java new file mode 100644 index 00000000..67c433ac --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/appearance/TenantAppearancePublicController.java @@ -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 getPublicAppearance() { + return success(service.getPublicAppearance()); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/EducationEngagementController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/EducationEngagementController.java index 80e58866..b23f168d 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/EducationEngagementController.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/EducationEngagementController.java @@ -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> 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 diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/LearningAwardDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/LearningAwardDO.java index 563274fa..a0fd91da 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/LearningAwardDO.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/LearningAwardDO.java @@ -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 grantEvidence; + private Long grantedBy; + private Long notifyMessageId; + private String notifyStatus; + private String notifyError; } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/StudentFeedbackDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/StudentFeedbackDO.java index 097a8a08..fc20d9da 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/StudentFeedbackDO.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/StudentFeedbackDO.java @@ -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; } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/StudentFeedbackEventDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/StudentFeedbackEventDO.java new file mode 100644 index 00000000..f2646480 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/StudentFeedbackEventDO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.education.dal.dataobject; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("education_student_feedback_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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/activationcode/ActivationCodeBatchDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/activationcode/ActivationCodeBatchDO.java new file mode 100644 index 00000000..6edbc29b --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/activationcode/ActivationCodeBatchDO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/activationcode/ActivationCodeDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/activationcode/ActivationCodeDO.java new file mode 100644 index 00000000..23ef3d0a --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/activationcode/ActivationCodeDO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/appearance/TenantAppearanceDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/appearance/TenantAppearanceDO.java new file mode 100644 index 00000000..631e8a19 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/appearance/TenantAppearanceDO.java @@ -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 featureFlags; + @TableField(typeHandler = JacksonTypeHandler.class) + private Map adminFeatureFlags; + @TableField(typeHandler = JacksonTypeHandler.class) + private Map publicConfig; + private String activeTemplateCode; + @TableField(typeHandler = JacksonTypeHandler.class) + private Map activeTheme; + @TableField(typeHandler = JacksonTypeHandler.class) + private Map activePublicAssets; + private String draftTemplateCode; + @TableField(typeHandler = JacksonTypeHandler.class) + private Map draftTheme; + @TableField(typeHandler = JacksonTypeHandler.class) + private Map draftPublicAssets; + private String themeStatus; + private LocalDateTime publishedTime; + private Long publishedBy; + private Long draftUpdatedBy; + private Integer version; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/appearance/TenantThemeTemplateDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/appearance/TenantThemeTemplateDO.java new file mode 100644 index 00000000..5edf100a --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/appearance/TenantThemeTemplateDO.java @@ -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 theme; + @TableField(typeHandler = JacksonTypeHandler.class) + private Map publicAssets; + private Integer sortOrder; + private String status; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/badge/BadgeDefinitionDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/badge/BadgeDefinitionDO.java new file mode 100644 index 00000000..8917d41b --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/badge/BadgeDefinitionDO.java @@ -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 conditionExtra; + private Integer sortOrder; + private String status; + private Integer version; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/classroom/EducationClassDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/classroom/EducationClassDO.java index b8f36edd..614c9d72 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/classroom/EducationClassDO.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/classroom/EducationClassDO.java @@ -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; } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/supervision/StudentFollowupDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/supervision/StudentFollowupDO.java new file mode 100644 index 00000000..af84505d --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/supervision/StudentFollowupDO.java @@ -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> reasons; + @TableField(typeHandler = JacksonTypeHandler.class) + private Map evidence; + private String resultNote; + private Integer version; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/supervision/StudentRiskSnapshot.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/supervision/StudentRiskSnapshot.java new file mode 100644 index 00000000..ea5f1efe --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/supervision/StudentRiskSnapshot.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/supervision/StudentSupervisionRuleDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/supervision/StudentSupervisionRuleDO.java new file mode 100644 index 00000000..e78885fb --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/supervision/StudentSupervisionRuleDO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/LearningAwardMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/LearningAwardMapper.java index ce52552d..7ffb5a43 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/LearningAwardMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/LearningAwardMapper.java @@ -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 { @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().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() + .eq(LearningAwardDO::getTenantId, tenantId) + .eq(LearningAwardDO::getUserId, userId) + .eq(LearningAwardDO::getBadgeDefinitionId, badgeDefinitionId) + .eq(LearningAwardDO::getDeleted, false)); + } + + default PageResult selectBadgeGrantPage(PageParam pageParam, Long tenantId, Long userId, + Long badgeDefinitionId, String awardSource) { + return selectPage(pageParam, new LambdaQueryWrapper() + .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 selectUserBadgeGrants(Long tenantId, Long userId) { + return selectList(new LambdaQueryWrapper() + .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().eq(LearningAwardDO::getId, id) .eq(LearningAwardDO::getTenantId, tenantId).eq(LearningAwardDO::getUserId, userId) @@ -63,8 +125,43 @@ public interface LearningAwardMapper extends BaseMapperX { """) List selectLeaderboard(Long tenantId, int limit); - interface LeaderboardRow { - Long getUserId(); - Long getLearningScore(); + default PageResult selectAdminPage(PageParam pageParam, Long tenantId, Long userId, + String awardType, String pointStatus, Boolean hasBadge) { + return selectPage(pageParam, new LambdaQueryWrapper() + .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; } } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeReportMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeReportMapper.java index 95e49d73..1e83cbfe 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeReportMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeReportMapper.java @@ -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 { """) 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; + } + } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/StudentFeedbackEventMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/StudentFeedbackEventMapper.java new file mode 100644 index 00000000..98dc1a1e --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/StudentFeedbackEventMapper.java @@ -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 { + default List selectOwnedList(Long tenantId, Long feedbackId) { + return selectList(new LambdaQueryWrapper() + .eq(StudentFeedbackEventDO::getTenantId, tenantId) + .eq(StudentFeedbackEventDO::getFeedbackId, feedbackId) + .eq(StudentFeedbackEventDO::getDeleted, false) + .orderByAsc(StudentFeedbackEventDO::getCreateTime) + .orderByAsc(StudentFeedbackEventDO::getId)); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/StudentFeedbackMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/StudentFeedbackMapper.java index f6d67740..673b557a 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/StudentFeedbackMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/StudentFeedbackMapper.java @@ -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 { .eq(StudentFeedbackDO::getDeleted, false).orderByDesc(StudentFeedbackDO::getCreateTime) .last("LIMIT 100")); } + + default PageResult selectAdminPage(PageParam pageParam, Long tenantId, Long userId, + String category, String status, String priority) { + return selectPage(pageParam, new LambdaQueryWrapper() + .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() + .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() + .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() + .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() + .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() + .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; + } } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/VocabularyProgressMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/VocabularyProgressMapper.java index a2b9d99e..8d89a070 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/VocabularyProgressMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/VocabularyProgressMapper.java @@ -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= 5 AND deleted=false + """) + Long countMastered(Long tenantId, Long userId); } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/activationcode/ActivationCodeBatchMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/activationcode/ActivationCodeBatchMapper.java new file mode 100644 index 00000000..c9f2aa5a --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/activationcode/ActivationCodeBatchMapper.java @@ -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 { + default PageResult selectAdminPage(PageParam page, Long tenantId, String keyword, String status) { + return selectPage(page, new LambdaQueryWrapperX() + .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() + .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); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/activationcode/ActivationCodeMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/activationcode/ActivationCodeMapper.java new file mode 100644 index 00000000..db0a0dc8 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/activationcode/ActivationCodeMapper.java @@ -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 { + default PageResult selectAdminPage(PageParam page, Long tenantId, Long batchId, String status) { + return selectPage(page, new LambdaQueryWrapperX() + .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() + .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); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/appearance/TenantAppearanceMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/appearance/TenantAppearanceMapper.java new file mode 100644 index 00000000..143fb36d --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/appearance/TenantAppearanceMapper.java @@ -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 { + + default TenantAppearanceDO selectOwned(Long tenantId) { + return selectOne(new LambdaQueryWrapperX() + .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); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/appearance/TenantThemeTemplateMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/appearance/TenantThemeTemplateMapper.java new file mode 100644 index 00000000..7f349ac1 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/appearance/TenantThemeTemplateMapper.java @@ -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 { + default List selectActiveList() { + return selectList(new LambdaQueryWrapperX() + .eq(TenantThemeTemplateDO::getStatus, "ACTIVE") + .eq(TenantThemeTemplateDO::getDeleted, false) + .orderByAsc(TenantThemeTemplateDO::getSortOrder) + .orderByAsc(TenantThemeTemplateDO::getCode)); + } + + default TenantThemeTemplateDO selectActive(String code) { + return selectOne(new LambdaQueryWrapperX() + .eq(TenantThemeTemplateDO::getCode, code) + .eq(TenantThemeTemplateDO::getStatus, "ACTIVE") + .eq(TenantThemeTemplateDO::getDeleted, false)); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/badge/BadgeDefinitionMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/badge/BadgeDefinitionMapper.java new file mode 100644 index 00000000..4ab28013 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/badge/BadgeDefinitionMapper.java @@ -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 { + + default PageResult selectAdminPage(PageParam page, Long tenantId, String keyword, + String category, String triggerType, String status) { + return selectPage(page, new LambdaQueryWrapperX() + .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() + .eq(BadgeDefinitionDO::getTenantId, tenantId) + .eq(BadgeDefinitionDO::getId, id) + .eq(BadgeDefinitionDO::getDeleted, false)); + } + + default BadgeDefinitionDO selectOwnedCode(Long tenantId, String code) { + return selectOne(new LambdaQueryWrapperX() + .eq(BadgeDefinitionDO::getTenantId, tenantId) + .eq(BadgeDefinitionDO::getCode, code) + .eq(BadgeDefinitionDO::getDeleted, false)); + } + + default List selectActiveByTrigger(Long tenantId, String triggerType) { + return selectList(new LambdaQueryWrapperX() + .eq(BadgeDefinitionDO::getTenantId, tenantId) + .eq(BadgeDefinitionDO::getTriggerType, triggerType) + .eq(BadgeDefinitionDO::getStatus, "ACTIVE") + .eq(BadgeDefinitionDO::getDeleted, false) + .orderByAsc(BadgeDefinitionDO::getSortOrder) + .orderByAsc(BadgeDefinitionDO::getId)); + } + + default List selectMemberList(Long tenantId, boolean includeDisabled) { + return selectList(new LambdaQueryWrapperX() + .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); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/CategoryMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/CategoryMapper.java index 45f0fffe..c4d8d82e 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/CategoryMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/CategoryMapper.java @@ -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 { + default PageResult selectTenantOwnedPage(PageParam pageParam, Long tenantId) { + return selectPage(pageParam, new LambdaQueryWrapperX() + .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().eq(CategoryDO::getId, id) .eq(CategoryDO::getTenantId, tenantId).eq(CategoryDO::getScope, "TENANT_OWNED") diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/ContentNodeMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/ContentNodeMapper.java index 5e70d2ed..9628bfb5 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/ContentNodeMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/ContentNodeMapper.java @@ -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 selectAvailableParentForShare(@Param("tenantId") Long tenantId, @Param("entryId") Long entryId, @Param("parentId") Long parentId); + default PageResult selectTenantOwnedPage(PageParam pageParam, Long tenantId) { + return selectPage(pageParam, new LambdaQueryWrapperX() + .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().eq(ContentNodeDO::getId, id) .eq(ContentNodeDO::getTenantId, tenantId).eq(ContentNodeDO::getScope, "TENANT_OWNED") diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/PracticeBlueprintMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/PracticeBlueprintMapper.java index 26755dde..eabd5435 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/PracticeBlueprintMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/PracticeBlueprintMapper.java @@ -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 selectTenantOwnedPage(PageParam pageParam, Long tenantId) { + return selectPage(pageParam, new LambdaQueryWrapperX() + .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().eq(PracticeBlueprintDO::getId, id) .eq(PracticeBlueprintDO::getTenantId, tenantId).eq(PracticeBlueprintDO::getScope, "TENANT_OWNED") diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionCollectionMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionCollectionMapper.java index 2ca32896..7fd3a3ac 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionCollectionMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionCollectionMapper.java @@ -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 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 selectTenantOwnedPage(PageParam pageParam, Long tenantId) { + return selectPage(pageParam, new LambdaQueryWrapperX() + .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().eq(QuestionCollectionDO::getId, id) .eq(QuestionCollectionDO::getTenantId, tenantId).eq(QuestionCollectionDO::getScope, "TENANT_OWNED") diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionMapper.java index be2ca24f..ae6bdfac 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionMapper.java @@ -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 { return selectOne(visible(tenantId).eq(QuestionDO::getId, id)); } + default PageResult selectTenantOwnedPage(PageParam pageParam, Long tenantId) { + return selectPage(pageParam, new LambdaQueryWrapperX() + .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() .eq(QuestionDO::getId, id) @@ -179,6 +189,35 @@ public interface QuestionMapper extends BaseMapperX { .eq(QuestionDO::getScope, "TENANT_OWNED")); } + default long countImportedDrafts(Long tenantId, List ids) { + if (ids == null || ids.isEmpty()) return 0; + return selectCount(new LambdaQueryWrapperX() + .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() + .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() diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionVersionMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionVersionMapper.java index 70e1ab4e..76a82952 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionVersionMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionVersionMapper.java @@ -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 { + default long countFirstVersions(Long tenantId, List questionIds) { + if (questionIds == null || questionIds.isEmpty()) return 0; + return selectCount(new LambdaQueryWrapperX() + .eq(QuestionVersionDO::getTenantId, tenantId) + .eq(QuestionVersionDO::getScope, "TENANT_OWNED") + .eq(QuestionVersionDO::getVersionNumber, 1) + .in(QuestionVersionDO::getQuestionId, questionIds)); + } } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/commercialization/EducationResourceProductBindingMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/commercialization/EducationResourceProductBindingMapper.java index 990c0efa..a02ba4d5 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/commercialization/EducationResourceProductBindingMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/commercialization/EducationResourceProductBindingMapper.java @@ -14,4 +14,11 @@ public interface EducationResourceProductBindingMapper extends BaseMapperX() + .eq(EducationResourceProductBindingDO::getTenantId, tenantId) + .eq(EducationResourceProductBindingDO::getProductSpuId, productSpuId) + .eq(EducationResourceProductBindingDO::getDeleted, false)); + } } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/importjob/ContentImportJobMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/importjob/ContentImportJobMapper.java index 6d208db2..6bc23266 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/importjob/ContentImportJobMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/importjob/ContentImportJobMapper.java @@ -48,6 +48,7 @@ public interface ContentImportJobMapper extends BaseMapperX 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); diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/supervision/StudentFollowupMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/supervision/StudentFollowupMapper.java new file mode 100644 index 00000000..5552d97f --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/supervision/StudentFollowupMapper.java @@ -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 { + + default PageResult selectAdminPage(PageParam pageParam, Long tenantId, Long studentUserId, + Long classId, Long assignedAdminUserId, + String priority, String status) { + return selectPage(pageParam, new LambdaQueryWrapperX() + .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() + .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() + .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 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; + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/supervision/StudentSupervisionRuleMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/supervision/StudentSupervisionRuleMapper.java new file mode 100644 index 00000000..3a0ff5eb --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/supervision/StudentSupervisionRuleMapper.java @@ -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 { + + default PageResult selectAdminPage(PageParam pageParam, Long tenantId, + String name, String status, Long classId) { + return selectPage(pageParam, new LambdaQueryWrapperX() + .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() + .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); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java index 25521163..9f0dae33 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java @@ -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, "内容导入任务不存在或无权访问"); diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/framework/datapermission/EducationDataPermissionConfiguration.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/framework/datapermission/EducationDataPermissionConfiguration.java new file mode 100644 index 00000000..8b6f9aca --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/framework/datapermission/EducationDataPermissionConfiguration.java @@ -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 的部门/本人数据权限规则。 + * + *

Education 只冗余 System 的部门与管理员编号作为授权投影,不复制账号、角色或部门模型。

+ */ +@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"); + }; + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/member/MemberPointAwardPort.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/member/MemberPointAwardPort.java index f830376c..d3a27b8f 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/member/MemberPointAwardPort.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/member/MemberPointAwardPort.java @@ -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); + } + } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/runtime/ScalarLegacyDependencyTelemetry.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/runtime/ScalarLegacyDependencyTelemetry.java index fc3ba1e6..255e0f03 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/runtime/ScalarLegacyDependencyTelemetry.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/runtime/ScalarLegacyDependencyTelemetry.java @@ -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; diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/activationcode/ActivationCodeService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/activationcode/ActivationCodeService.java new file mode 100644 index 00000000..ad7ba9f2 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/activationcode/ActivationCodeService.java @@ -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 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 codePage(CodePageReq req); + CodeResp disable(Long id, Integer expectedVersion); + CheckResp check(String code); + RedeemResp redeem(String code, Long userId); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/activationcode/ActivationCodeServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/activationcode/ActivationCodeServiceImpl.java new file mode 100644 index 00000000..4f9e2bbe --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/activationcode/ActivationCodeServiceImpl.java @@ -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 STATUSES = Set.of("ACTIVE", "DISABLED"); + private static final Set 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 productCatalogPorts; + private final EducationEntitlementService entitlementService; + + public ActivationCodeServiceImpl(ActivationCodeBatchMapper batchMapper, ActivationCodeMapper codeMapper, + EducationResourceProductBindingMapper bindingMapper, + ObjectProvider productCatalogPorts, + EducationEntitlementService entitlementService) { + this.batchMapper=batchMapper; this.codeMapper=codeMapper; this.bindingMapper=bindingMapper; + this.productCatalogPorts=productCatalogPorts; this.entitlementService=entitlementService; + } + + @Override public PageResult batchPage(BatchPageReq req) { + PageResult 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 generated=new ArrayList<>(req.getCount()); + for(int i=0;i codePage(CodePageReq req) { + PageResult 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 allowed){if(value==null||value.isBlank())return null;return choice(value,allowed,null);} + private static String choice(String value,Set 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();} +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/appearance/TenantAppearancePolicy.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/appearance/TenantAppearancePolicy.java new file mode 100644 index 00000000..29edeffb --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/appearance/TenantAppearancePolicy.java @@ -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 THEME_MODES = Set.of("light", "dark", "auto"); + private static final Set THEME_DENSITIES = Set.of("compact", "comfortable", "dense"); + private static final Set 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 ASSET_KEYS = Set.of("logoUrl", "faviconUrl", "appIconUrl", "shareImageUrl", + "loginPosterUrl", "splashImageUrl", "iconSet", "shareCardStyle"); + private static final Set 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| sanitizeThemeTokens(Map value) { + Map source = objectValue(value); + assertPublicConfigHasNoSecrets(source, "theme"); + Map result = new LinkedHashMap<>(); + for (Map.Entry 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 sanitizePublicAssets(Map value) { + Map source = objectValue(value); + assertPublicConfigHasNoSecrets(source, "publicAssets"); + Map result = new LinkedHashMap<>(); + for (Map.Entry 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 mergeTheme(Map template, Map overrides) { + Map merged = new LinkedHashMap<>(objectValue(template)); + merged.putAll(sanitizeThemeTokens(overrides)); + return merged; + } + + public Map mergePublicAssets(Map template, Map overrides) { + Map 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 cssVars(Object value) { + Map result = new LinkedHashMap<>(); + for (Map.Entry 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 icons(Object value) { + Map result = new LinkedHashMap<>(); + for (Map.Entry 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 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 objectValue(Object value) { + if (!(value instanceof Map map)) { + return Collections.emptyMap(); + } + Map 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); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/appearance/TenantAppearanceService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/appearance/TenantAppearanceService.java new file mode 100644 index 00000000..0f3853cb --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/appearance/TenantAppearanceService.java @@ -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 getThemeTemplates(); + + AppearanceResp previewTheme(ThemePreviewReq req, Long actorId); + + AppearanceResp publishTheme(ThemePublishReq req, Long actorId); + + PublicAppearanceResp getPublicAppearance(); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/appearance/TenantAppearanceServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/appearance/TenantAppearanceServiceImpl.java new file mode 100644 index 00000000..51ae5123 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/appearance/TenantAppearanceServiceImpl.java @@ -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 featureFlags = map(req.getFeatureFlags()); + Map adminFeatureFlags = map(req.getAdminFeatureFlags()); + Map 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 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 theme = policy.mergeTheme(template.getTheme(), req.getTheme()); + Map 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 theme; + Map 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 userIds = new LinkedHashSet<>(); + if (row.getPublishedBy() != null) userIds.add(row.getPublishedBy()); + if (row.getDraftUpdatedBy() != null) userIds.add(row.getDraftUpdatedBy()); + Map 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 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 value) { return JsonUtils.toJsonString(map(value)); } + private static Map map(Map value) { + return value == null ? new LinkedHashMap<>() : new LinkedHashMap<>(value); + } + + private record TenantState(Long tenantId, TenantRespDTO systemTenant, TenantAppearanceDO row) {} +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/asset/EducationAssetAdmissionServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/asset/EducationAssetAdmissionServiceImpl.java index aa229439..66cc899e 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/asset/EducationAssetAdmissionServiceImpl.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/asset/EducationAssetAdmissionServiceImpl.java @@ -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); diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/badge/BadgeAdminService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/badge/BadgeAdminService.java new file mode 100644 index 00000000..2d3a8019 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/badge/BadgeAdminService.java @@ -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 getDefinitionPage(DefinitionPageReq req); + DefinitionResp createDefinition(DefinitionSaveReq req, Long actorId); + DefinitionResp updateDefinition(Long id, DefinitionSaveReq req, Long actorId); + PageResult getGrantPage(GrantPageReq req); + GrantResp grant(ManualGrantReq req, Long actorId); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/badge/BadgeAdminServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/badge/BadgeAdminServiceImpl.java new file mode 100644 index 00000000..a9124484 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/badge/BadgeAdminServiceImpl.java @@ -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 CATEGORIES = Set.of("LEARNING", "PRACTICE", "VOCABULARY", "FEEDBACK", "CUSTOM"); + private static final Set TRIGGERS = Set.of("MANUAL", "PRACTICE_SUBMIT", "VOCABULARY_REVIEW", "FEEDBACK_RESOLVED"); + private static final Set METRICS = Set.of("PRACTICE_COUNT", "PRACTICE_SCORE", "VOCABULARY_MASTERED_COUNT", + "FEEDBACK_RESOLVED_COUNT", "FEEDBACK_REWARD_POINTS"); + private static final Set OPERATORS = Set.of("GTE", "LTE", "EQ", "GT", "LT"); + private static final Set STATUSES = Set.of("ACTIVE", "DISABLED"); + private static final Set 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 getDefinitionPage(DefinitionPageReq req) { + PageResult 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 getGrantPage(GrantPageReq req) { + String source = optional(req.getAwardSource(), SOURCES); + PageResult 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 grantPage(PageResult page) { + Set userIds = page.getList().stream().map(LearningAwardDO::getUserId).collect(Collectors.toSet()); + Set actorIds = page.getList().stream().map(LearningAwardDO::getGrantedBy).filter(Objects::nonNull).collect(Collectors.toSet()); + Set badgeIds = page.getList().stream().map(LearningAwardDO::getBadgeDefinitionId).collect(Collectors.toSet()); + Map users = memberUserApi.getUserMap(userIds); + Map admins = adminUserApi.getUserMap(actorIds); + Map badges = definitionMapper.selectBatchIds(badgeIds).stream() + .filter(item -> Objects.equals(item.getTenantId(), tenantId())) + .collect(Collectors.toMap(BadgeDefinitionDO::getId, Function.identity())); + List 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 allowed) { return trim(value) == null ? null : choice(value, allowed, null); } + private String choice(String value, Set 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; + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/badge/BadgeGrantService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/badge/BadgeGrantService.java new file mode 100644 index 00000000..d615ec4a --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/badge/BadgeGrantService.java @@ -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 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 getMemberBadges(Long tenantId, Long userId, boolean includeLocked); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/badge/BadgeGrantServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/badge/BadgeGrantServiceImpl.java new file mode 100644 index 00000000..8435d316 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/badge/BadgeGrantServiceImpl.java @@ -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 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 evidence, Long actorId, Long tenantId) { + BadgeDefinitionDO definition = requireActive(tenantId, badgeDefinitionId); + try { + memberUserApi.validateUser(userId); + } catch (RuntimeException ex) { + throw exception(BADGE_TARGET_MEMBER_INVALID); + } + Map 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 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 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 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 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 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 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 getMemberBadges(Long tenantId, Long userId, boolean includeLocked) { + Map 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())); } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/blueprint/authoring/PracticeBlueprintAuthoringService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/blueprint/authoring/PracticeBlueprintAuthoringService.java index 8260b445..d83593b0 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/blueprint/authoring/PracticeBlueprintAuthoringService.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/blueprint/authoring/PracticeBlueprintAuthoringService.java @@ -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 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); diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/blueprint/authoring/PracticeBlueprintAuthoringServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/blueprint/authoring/PracticeBlueprintAuthoringServiceImpl.java index c094ce8e..4b7dca74 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/blueprint/authoring/PracticeBlueprintAuthoringServiceImpl.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/blueprint/authoring/PracticeBlueprintAuthoringServiceImpl.java @@ -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 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); diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringService.java index 41c09235..0bbe9910 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringService.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringService.java @@ -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 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); diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringServiceImpl.java index ef3f4f5d..b8a7cbf4 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringServiceImpl.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringServiceImpl.java @@ -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 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) { diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/classroom/EducationClassServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/classroom/EducationClassServiceImpl.java index 8ab16ad3..78628548 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/classroom/EducationClassServiceImpl.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/classroom/EducationClassServiceImpl.java @@ -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(); } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringService.java index 56f7fdb7..a4c37b14 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringService.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringService.java @@ -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 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 questionIds, int expectedAuthoringVersion); diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringServiceImpl.java index 1fbeec3f..d95fb126 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringServiceImpl.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringServiceImpl.java @@ -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 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(); diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringService.java index 8f14a991..d4dca516 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringService.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringService.java @@ -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 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); diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringServiceImpl.java index c2a0461d..3c1e0be2 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringServiceImpl.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringServiceImpl.java @@ -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 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(); diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/engagement/LearningOperationsAdminService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/engagement/LearningOperationsAdminService.java new file mode 100644 index 00000000..8c84708f --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/engagement/LearningOperationsAdminService.java @@ -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 getFeedbackPage(StudentFeedbackPageReqVO reqVO); + PageResult getAwardPage(LearningAwardPageReqVO reqVO); + List getFeedbackEvents(Long feedbackId); + StudentFeedbackAdminRespVO handleFeedback(Long feedbackId, StudentFeedbackHandleReqVO reqVO, Long actorId); + StudentFeedbackAdminRespVO scheduleReward(Long feedbackId, StudentFeedbackRewardReqVO reqVO); + StudentFeedbackAdminRespVO deliverReward(Long feedbackId); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/engagement/LearningOperationsAdminServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/engagement/LearningOperationsAdminServiceImpl.java new file mode 100644 index 00000000..6ef30e84 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/engagement/LearningOperationsAdminServiceImpl.java @@ -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 FEEDBACK_CATEGORIES = Set.of("CONTENT", "PRODUCT", "BUG", "OTHER"); + private static final Set FEEDBACK_STATUSES = Set.of("OPEN", "ACCEPTED", "REJECTED", "RESOLVED", "CLOSED"); + private static final Set FEEDBACK_PRIORITIES = Set.of("LOW", "NORMAL", "HIGH", "URGENT"); + private static final Set AWARD_TYPES = Set.of("PRACTICE_COMPLETE", "VOCABULARY_REVIEW", "BADGE"); + private static final Set 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 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 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 page = feedbackMapper.selectAdminPage(reqVO, tenantId(), reqVO.getUserId(), + category, status, priority); + return feedbackPage(page); + } + + @Override + public PageResult 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 page = awardMapper.selectAdminPage(reqVO, tenantId(), reqVO.getUserId(), + awardType, pointStatus, reqVO.getHasBadge()); + Set userIds = page.getList().stream().map(LearningAwardDO::getUserId).collect(Collectors.toSet()); + Map users = memberUserApi.getUserMap(userIds); + List items = page.getList().stream().map(item -> awardResp(item, users)).toList(); + return new PageResult<>(items, page.getTotal()); + } + + @Override + public List getFeedbackEvents(Long feedbackId) { + requireFeedback(feedbackId); + List events = feedbackEventMapper.selectOwnedList(tenantId(), feedbackId); + Map 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 feedbackPage(PageResult page) { + Set userIds = page.getList().stream().map(StudentFeedbackDO::getUserId).collect(Collectors.toSet()); + Set actorIds = page.getList().stream().map(StudentFeedbackDO::getHandledBy) + .filter(java.util.Objects::nonNull).collect(Collectors.toSet()); + Map users = memberUserApi.getUserMap(userIds); + Map 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 users = memberUserApi.getUserMap(Set.of(feedback.getUserId())); + Map 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 users, + Map 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 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 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 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 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(); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/ImportObjectScanGateway.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/ImportObjectScanGateway.java index 95ab6c1c..501e9887 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/ImportObjectScanGateway.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/ImportObjectScanGateway.java @@ -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); } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/InfraFileImportObjectScanGateway.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/InfraFileImportObjectScanGateway.java new file mode 100644 index 00000000..1a45f391 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/InfraFileImportObjectScanGateway.java @@ -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()); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobProjection.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobProjection.java index 1ece5dd2..8cae3355 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobProjection.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobProjection.java @@ -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) { } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobServiceImpl.java index 8a217122..68e26a83 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobServiceImpl.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobServiceImpl.java @@ -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 scanGateway; private final Optional 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 scanGateway, Optional 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 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 questions = availableParser.restore(job.getParsedPayload()); + if (job.getPreviewQuestionCount() == null || questions.size() != job.getPreviewQuestionCount()) { + throw exception(QUESTION_IMPORT_STATE_CONFLICT); + } + List 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 questionIds) {} } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportParser.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportParser.java index 64e7ed82..a63f28f4 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportParser.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportParser.java @@ -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 restore(String payload); - record ParsedImport(String payload, List questions) {} + record ParsedImport(String payload, String previewPayload, List questions, + int questionCount, boolean executable) {} } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/StandardQuestionImportParser.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/StandardQuestionImportParser.java new file mode 100644 index 00000000..761783f7 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/StandardQuestionImportParser.java @@ -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 HEADERS = headers(); + private static final Set UNSUPPORTED_HEADERS = Set.of( + "subject", "subjectid", "node", "nodeid", "collection", "collectionid", "tags", "status", + "publish", "published", "科目", "知识点", "节点", "题集", "标签", "状态", "发布"); + private static final Set SINGLE_TYPES = Set.of("choice", "single", "single_choice", "单选", "单选题"); + private static final Set MULTI_TYPES = Set.of("multi", "multi_choice", "多选", "多选题"); + private static final Map 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> 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 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 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 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> 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> rows = new ArrayList<>(); + List 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> parseXlsx(byte[] bytes) { + List> rows = new ArrayList<>(); + int[] characters = {0}; + try { + FastExcel.read(new ByteArrayInputStream(bytes), new ReadListener>>() { + @Override public void invoke(Map> 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 row = new ArrayList<>(Collections.nCopies(width, "")); + for (Map.Entry> 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> table) { + if (table.size() < 2) throw invalid("EMPTY_IMPORT"); + List header = table.getFirst(); + Map columns = new HashMap<>(); + Map 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 rows = new ArrayList<>(); + for (int i = 1; i < table.size(); i++) { + List raw = table.get(i); + if (raw.stream().allMatch(String::isBlank)) continue; + List issues = new ArrayList<>(); + for (Map.Entry 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 row, Map columns, List 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 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 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 validate(QuestionDraftCommand question) { + List 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 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 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 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 = 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 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> rows) { + if (rows.size() > MAX_ROWS + 1) throw invalid("ROW_LIMIT"); + long cells = 0; + for (List 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 row, StringBuilder cell) { + if (row.size() >= MAX_COLUMNS) throw invalid("COLUMN_LIMIT"); + row.add(cell.toString()); cell.setLength(0); + } + + private static void addRow(List> rows, List row) { + rows.add(List.copyOf(row)); + if (rows.size() > MAX_ROWS + 1) throw invalid("ROW_LIMIT"); + } + + private static String field(List row, Map columns, String field) { + Integer index = columns.get(field); + return index == null ? "" : value(row, index); + } + + private static String value(List 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 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 headers() { + Map 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 result, String field, String... aliases) { + for (String alias : aliases) result.put(normalizeHeader(alias), field); + } + + public record CanonicalPayload(int schemaVersion, List rows, Counts counts, boolean executable) {} + public record CanonicalRow(int rowNumber, String status, QuestionDraftCommand question, List 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, List issues) {} + public record Sample(int rowNumber, String status, String stem, List issues) {} + public record RowIssue(int rowNumber, String code, String field) {} +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java index d6030e58..7eb62c09 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java @@ -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 entitlementServiceProvider) { + ObjectProvider entitlementServiceProvider, + ObjectProvider 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); diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleService.java index cbbb433f..e261fbe8 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleService.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleService.java @@ -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 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); diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleServiceImpl.java index 27c0e501..a176081b 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleServiceImpl.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleServiceImpl.java @@ -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 suppliedOptions = command.options() != null - ? command.options() : List.of(); - List 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 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 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 normalizeOptions(QuestionDraftCommand command) { + List 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 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 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 validateOptionLengths(String type, + List 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()) { diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/supervision/StudentSupervisionAdminService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/supervision/StudentSupervisionAdminService.java new file mode 100644 index 00000000..364d6618 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/supervision/StudentSupervisionAdminService.java @@ -0,0 +1,15 @@ +package cn.iocoder.yudao.module.education.service.supervision; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.education.controller.admin.supervision.vo.StudentSupervisionVOs.*; + +public interface StudentSupervisionAdminService { + OverviewResp getOverview(); + PreviewResp preview(PreviewReq reqVO); + PageResult getRulePage(RulePageReq reqVO); + RuleResp createRule(RuleSaveReq reqVO, Long actorId); + RuleResp updateRule(Long id, RuleSaveReq reqVO, Long actorId); + PageResult getFollowupPage(FollowupPageReq reqVO); + GenerateResp generate(GenerateReq reqVO, Long actorId); + FollowupResp handleFollowup(Long id, FollowupHandleReq reqVO, Long actorId); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/supervision/StudentSupervisionAdminServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/supervision/StudentSupervisionAdminServiceImpl.java new file mode 100644 index 00000000..d4d2e160 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/supervision/StudentSupervisionAdminServiceImpl.java @@ -0,0 +1,471 @@ +package cn.iocoder.yudao.module.education.service.supervision; + +import cn.hutool.crypto.digest.DigestUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +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.supervision.vo.StudentSupervisionVOs.*; +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.StudentRiskSnapshot; +import cn.iocoder.yudao.module.education.dal.dataobject.supervision.StudentSupervisionRuleDO; +import cn.iocoder.yudao.module.education.dal.mysql.classroom.EducationClassMapper; +import cn.iocoder.yudao.module.education.dal.mysql.supervision.StudentFollowupMapper; +import cn.iocoder.yudao.module.education.dal.mysql.supervision.StudentSupervisionRuleMapper; +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.math.BigDecimal; +import java.math.RoundingMode; +import java.time.*; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.stream.Collectors; + +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.module.education.enums.ErrorCodeConstants.*; + +@Service +public class StudentSupervisionAdminServiceImpl implements StudentSupervisionAdminService { + + private static final Set RULE_STATUSES = Set.of("ACTIVE", "DISABLED", "ARCHIVED"); + private static final Set FOLLOWUP_STATUSES = Set.of("OPEN", "IN_PROGRESS", "DONE", "CANCELLED"); + private static final Set FOLLOWUP_PRIORITIES = Set.of("NORMAL", "HIGH", "URGENT"); + private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai"); + + private final StudentSupervisionRuleMapper ruleMapper; + private final StudentFollowupMapper followupMapper; + private final EducationClassMapper classMapper; + private final MemberUserApi memberUserApi; + private final AdminUserApi adminUserApi; + + public StudentSupervisionAdminServiceImpl(StudentSupervisionRuleMapper ruleMapper, + StudentFollowupMapper followupMapper, + EducationClassMapper classMapper, + MemberUserApi memberUserApi, + AdminUserApi adminUserApi) { + this.ruleMapper = ruleMapper; + this.followupMapper = followupMapper; + this.classMapper = classMapper; + this.memberUserApi = memberUserApi; + this.adminUserApi = adminUserApi; + } + + @Override + public OverviewResp getOverview() { + Long tenantId = tenantId(); + StudentFollowupMapper.FollowupOverviewRow row = followupMapper.selectOverview(tenantId); + Long activeRules = ruleMapper.selectCount(new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX() + .eq(StudentSupervisionRuleDO::getTenantId, tenantId) + .eq(StudentSupervisionRuleDO::getStatus, "ACTIVE") + .eq(StudentSupervisionRuleDO::getDeleted, false)); + return OverviewResp.builder() + .openFollowups(value(row == null ? null : row.getOpenCount())) + .overdueFollowups(value(row == null ? null : row.getOverdueCount())) + .highPriorityFollowups(value(row == null ? null : row.getHighPriorityCount())) + .doneFollowups(value(row == null ? null : row.getDoneCount())) + .activeRules(value(activeRules)).build(); + } + + @Override + public PreviewResp preview(PreviewReq reqVO) { + RiskRule rules = normalizeRules(reqVO.getRules()); + requireClass(reqVO.getClassId()); + validateAssignee(reqVO.getAssignedAdminUserId()); + List candidates = loadCandidates(rules, reqVO.getClassId(), + reqVO.getAssignedAdminUserId(), Math.min(100, reqVO.getPageSize()), null); + return PreviewResp.builder().rules(rules).classId(reqVO.getClassId()) + .assignedAdminUserId(reqVO.getAssignedAdminUserId()) + .totalCandidates((long) candidates.size()).candidates(candidates).build(); + } + + @Override + public PageResult getRulePage(RulePageReq reqVO) { + String status = optionalChoice(reqVO.getStatus(), RULE_STATUSES, SUPERVISION_RULE_INVALID); + PageResult page = ruleMapper.selectAdminPage(reqVO, tenantId(), + trim(reqVO.getName()), status, reqVO.getClassId()); + Set assigneeIds = page.getList().stream().map(StudentSupervisionRuleDO::getAssignedAdminUserId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + Map admins = adminUserApi.getUserMap(assigneeIds); + Map classes = classMap(page.getList().stream() + .map(StudentSupervisionRuleDO::getClassId).filter(Objects::nonNull).collect(Collectors.toSet())); + return new PageResult<>(page.getList().stream().map(item -> ruleResp(item, classes, admins)).toList(), + page.getTotal()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public RuleResp createRule(RuleSaveReq reqVO, Long actorId) { + StudentSupervisionRuleDO rule = buildRule(null, reqVO, actorId); + ruleMapper.insert(rule); + return ruleResp(ruleMapper.selectOwned(tenantId(), rule.getId()), classMap(singleton(rule.getClassId())), + adminMap(singleton(rule.getAssignedAdminUserId()))); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public RuleResp updateRule(Long id, RuleSaveReq reqVO, Long actorId) { + StudentSupervisionRuleDO current = requireRule(id); + if (reqVO.getExpectedVersion() == null || !reqVO.getExpectedVersion().equals(current.getVersion())) { + throw exception(SUPERVISION_RULE_CONFLICT); + } + StudentSupervisionRuleDO update = buildRule(id, reqVO, actorId); + update.setOwnerUserId(current.getOwnerUserId()); + if (ruleMapper.updateCas(tenantId(), update, reqVO.getExpectedVersion()) != 1) { + throw exception(SUPERVISION_RULE_CONFLICT); + } + StudentSupervisionRuleDO saved = requireRule(id); + return ruleResp(saved, classMap(singleton(saved.getClassId())), + adminMap(singleton(saved.getAssignedAdminUserId()))); + } + + @Override + public PageResult getFollowupPage(FollowupPageReq reqVO) { + String status = optionalChoice(reqVO.getStatus(), FOLLOWUP_STATUSES, STUDENT_FOLLOWUP_STATUS_INVALID); + String priority = optionalChoice(reqVO.getPriority(), FOLLOWUP_PRIORITIES, SUPERVISION_RULE_INVALID); + PageResult page = followupMapper.selectAdminPage(reqVO, tenantId(), reqVO.getStudentUserId(), + reqVO.getClassId(), reqVO.getAssignedAdminUserId(), priority, status); + return followupPage(page); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public GenerateResp generate(GenerateReq reqVO, Long actorId) { + StudentSupervisionRuleDO savedRule = reqVO.getRuleId() == null ? null : requireRule(reqVO.getRuleId()); + RiskRule rules = savedRule == null ? normalizeRules(reqVO.getRules()) : ruleConfig(savedRule); + Long classId = reqVO.getClassId() != null ? reqVO.getClassId() : savedRule == null ? null : savedRule.getClassId(); + Long assigneeId = reqVO.getAssignedAdminUserId() != null ? reqVO.getAssignedAdminUserId() + : savedRule == null ? null : savedRule.getAssignedAdminUserId(); + EducationClassDO classroom = requireClass(classId); + validateAssignee(assigneeId); + int limit = bound(reqVO.getLimit() != null ? reqVO.getLimit() + : savedRule == null ? 20 : savedRule.getLimitCount(), 1, 100); + Set onlyStudents = reqVO.getStudentUserIds() == null ? null : new LinkedHashSet<>(reqVO.getStudentUserIds()); + List candidates = loadCandidates(rules, classId, assigneeId, limit, onlyStudents); + String batchKey = batchKey(reqVO.getBatchKey()); + LocalDateTime dueTime = reqVO.getDueTime() == null + ? LocalDate.now(SHANGHAI).plusDays(1).atTime(18, 0) : reqVO.getDueTime(); + + List saved = new ArrayList<>(); + for (CandidateResp candidate : candidates) { + StudentFollowupDO followup = toFollowup(candidate, savedRule, classroom, actorId, batchKey, dueTime); + followupMapper.insertIgnore(followup, JsonUtils.toJsonString(followup.getReasons()), + JsonUtils.toJsonString(followup.getEvidence())); + followup = followupMapper.selectByBatchKey(tenantId(), batchKey, candidate.getStudentUserId()); + if (followup == null) { + throw exception(STUDENT_FOLLOWUP_NOT_FOUND); + } + saved.add(followup); + } + if (savedRule != null) { + ruleMapper.recordRun(tenantId(), savedRule.getId(), candidates.size(), saved.size(), null); + } + PageResult responsePage = new PageResult<>(saved, (long) saved.size()); + List items = followupPage(responsePage).getList(); + return GenerateResp.builder().batchKey(batchKey).total(candidates.size()).successCount(items.size()) + .errorCount(0).items(items).errors(List.of()).build(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public FollowupResp handleFollowup(Long id, FollowupHandleReq reqVO, Long actorId) { + StudentFollowupDO current = requireFollowup(id); + String status = requiredChoice(reqVO.getStatus(), FOLLOWUP_STATUSES, STUDENT_FOLLOWUP_STATUS_INVALID); + if (!Objects.equals(current.getVersion(), reqVO.getExpectedVersion()) || + followupMapper.updateStatusCas(tenantId(), id, reqVO.getExpectedVersion(), status, + trim(reqVO.getResultNote()), actorId) != 1) { + throw exception(STUDENT_FOLLOWUP_CONFLICT); + } + return followupPage(new PageResult<>(List.of(requireFollowup(id)), 1L)).getList().getFirst(); + } + + private StudentSupervisionRuleDO buildRule(Long id, RuleSaveReq reqVO, Long actorId) { + RiskRule rules = normalizeRules(reqVO.getRules()); + Schedule schedule = normalizeSchedule(reqVO.getSchedule()); + String status = requiredChoice(reqVO.getStatus() == null ? "ACTIVE" : reqVO.getStatus(), + RULE_STATUSES, SUPERVISION_RULE_INVALID); + EducationClassDO classroom = requireClass(reqVO.getClassId()); + validateAssignee(reqVO.getAssignedAdminUserId()); + StudentSupervisionRuleDO rule = new StudentSupervisionRuleDO(); + rule.setId(id); rule.setTenantId(tenantId()); rule.setName(reqVO.getName().trim()); rule.setStatus(status); + rule.setClassId(reqVO.getClassId()); rule.setAssignedAdminUserId(reqVO.getAssignedAdminUserId()); + rule.setDeptId(classroom == null ? getLoginUserDeptId() : classroom.getDeptId()); rule.setOwnerUserId(actorId); + applyRules(rule, rules); applySchedule(rule, schedule); + rule.setLimitCount(bound(reqVO.getLimit(), 1, 100, 20)); + rule.setNextRunTime("ACTIVE".equals(status) ? nextRun(schedule) : null); + rule.setVersion(0); rule.setCreator(String.valueOf(actorId)); rule.setUpdater(String.valueOf(actorId)); + return rule; + } + + private List loadCandidates(RiskRule rules, Long classId, Long assigneeId, int limit, + Set onlyStudents) { + int scanLimit = Math.min(2000, Math.max(limit * 8, onlyStudents == null ? 200 : onlyStudents.size() * 8)); + List snapshots = followupMapper.selectRiskSnapshots(tenantId(), classId, + rules.getWindowDays(), rules.getStaleSessionDays(), scanLimit); + if (onlyStudents != null) snapshots = snapshots.stream() + .filter(row -> onlyStudents.contains(row.getStudentUserId())).toList(); + Map users = memberUserApi.getUserMap(snapshots.stream() + .map(StudentRiskSnapshot::getStudentUserId).collect(Collectors.toSet())); + return snapshots.stream().map(row -> candidate(row, rules, assigneeId, users.get(row.getStudentUserId()))) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(CandidateResp::getRiskScore).reversed() + .thenComparing(CandidateResp::getStudentUserId)) + .limit(limit).toList(); + } + + private CandidateResp candidate(StudentRiskSnapshot row, RiskRule rules, Long assigneeId, + MemberUserRespDTO member) { + if (member == null || !CommonStatusEnum.isEnable(member.getStatus())) return null; + int answerCount = value(row.getAnswerCount()); + int correctCount = value(row.getCorrectCount()); + int unresolved = value(row.getUnresolvedWrongQuestions()); + int staleSessions = value(row.getStaleActiveSessions()); + int dueVocabulary = value(row.getDueVocabularyWords()); + LocalDateTime latestStudy = latest(row.getLatestReportTime(), row.getLatestSessionTime(), + row.getLatestVocabularyReviewTime()); + Long inactiveDays = latestStudy == null ? null : Math.max(0, ChronoUnit.DAYS.between(latestStudy, LocalDateTime.now())); + Double accuracy = answerCount == 0 ? null : (double) correctCount / answerCount; + List> reasons = new ArrayList<>(); + int score = 0; + if (inactiveDays == null || inactiveDays >= rules.getInactivityDays()) { + int points = inactiveDays == null ? 35 : (int) Math.min(55, inactiveDays * 5); + score += points; + reasons.add(reason("INACTIVE", inactiveDays == null ? "未产生学习记录" : inactiveDays + " 天未学习", + inactiveDays == null || inactiveDays >= rules.getInactivityDays() * 2L ? "HIGH" : "MEDIUM", + inactiveDays, rules.getInactivityDays())); + } + if (unresolved >= rules.getWrongQuestionThreshold()) { + score += Math.min(45, unresolved * 4); + reasons.add(reason("WRONG_BACKLOG", "未解决错题 " + unresolved + " 道", + unresolved >= rules.getWrongQuestionThreshold() * 2 ? "HIGH" : "MEDIUM", + unresolved, rules.getWrongQuestionThreshold())); + } + if (answerCount >= rules.getMinAnswers() && accuracy != null && accuracy < rules.getLowAccuracyThreshold()) { + score += (int) Math.round((rules.getLowAccuracyThreshold() - accuracy) * 100); + reasons.add(reason("LOW_ACCURACY", "近 " + rules.getWindowDays() + " 天正确率 " + Math.round(accuracy * 100) + "%", + accuracy < rules.getLowAccuracyThreshold() * 0.75 ? "HIGH" : "MEDIUM", + decimal(accuracy), rules.getLowAccuracyThreshold())); + } + if (dueVocabulary >= rules.getVocabularyDueThreshold()) { + score += Math.min(35, (int) Math.ceil((double) dueVocabulary / rules.getVocabularyDueThreshold()) * 10); + reasons.add(reason("VOCABULARY_DUE", "待复习词汇 " + dueVocabulary + " 个", + dueVocabulary >= rules.getVocabularyDueThreshold() * 2 ? "HIGH" : "MEDIUM", + dueVocabulary, rules.getVocabularyDueThreshold())); + } + if (staleSessions > 0) { + score += Math.min(25, staleSessions * 10); + reasons.add(reason("STALE_SESSION", "存在 " + staleSessions + " 个未完成练习", "MEDIUM", staleSessions, 1)); + } + if (reasons.isEmpty()) return null; + score = Math.min(100, score); + String priority = score >= 90 ? "URGENT" : score >= 60 ? "HIGH" : "NORMAL"; + String description = reasons.stream().map(item -> String.valueOf(item.get("label"))) + .collect(Collectors.joining(";")); + Map evidence = new LinkedHashMap<>(); + evidence.put("windowDays", rules.getWindowDays()); evidence.put("answerCount", answerCount); + evidence.put("correctCount", correctCount); evidence.put("accuracy", accuracy == null ? null : decimal(accuracy)); + evidence.put("latestStudyTime", latestStudy); evidence.put("inactiveDays", inactiveDays); + evidence.put("unresolvedWrongQuestions", unresolved); + evidence.put("wrongQuestionAttempts", value(row.getWrongQuestionAttempts())); + evidence.put("staleActiveSessions", staleSessions); evidence.put("dueVocabularyWords", dueVocabulary); + return CandidateResp.builder().studentUserId(row.getStudentUserId()).studentName(member.getNickname()) + .studentMobile(member.getMobile()).classId(row.getClassId()).className(row.getClassName()) + .assignedAdminUserId(assigneeId).riskScore(score).priority(priority) + .title("学习督导:" + reasons.getFirst().get("label")).description(description) + .reasons(reasons).evidence(evidence).build(); + } + + private StudentFollowupDO toFollowup(CandidateResp candidate, StudentSupervisionRuleDO rule, + EducationClassDO selectedClass, Long actorId, String batchKey, + LocalDateTime dueTime) { + StudentFollowupDO item = new StudentFollowupDO(); + item.setTenantId(tenantId()); item.setStudentUserId(candidate.getStudentUserId()); + item.setAssignedAdminUserId(candidate.getAssignedAdminUserId()); item.setClassId(candidate.getClassId()); + item.setRuleId(rule == null ? null : rule.getId()); + EducationClassDO classroom = selectedClass != null ? selectedClass : classMapper.selectOwned(tenantId(), candidate.getClassId()); + item.setDeptId(rule != null ? rule.getDeptId() : classroom == null ? getLoginUserDeptId() : classroom.getDeptId()); + item.setOwnerUserId(rule != null ? rule.getOwnerUserId() : actorId); + item.setTitle(candidate.getTitle()); item.setDescription(candidate.getDescription()); + item.setFollowupType("LEARNING"); item.setPriority(candidate.getPriority()); item.setStatus("OPEN"); + item.setDueTime(dueTime); item.setBatchKey(batchKey); item.setRiskScore(candidate.getRiskScore()); + item.setReasons(candidate.getReasons()); item.setEvidence(candidate.getEvidence()); item.setVersion(0); + item.setCreator(String.valueOf(actorId)); item.setUpdater(String.valueOf(actorId)); + return item; + } + + private PageResult followupPage(PageResult page) { + Set studentIds = page.getList().stream().map(StudentFollowupDO::getStudentUserId).collect(Collectors.toSet()); + Set adminIds = page.getList().stream().map(StudentFollowupDO::getAssignedAdminUserId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + Map users = memberUserApi.getUserMap(studentIds); + Map admins = adminUserApi.getUserMap(adminIds); + Map classes = classMap(page.getList().stream().map(StudentFollowupDO::getClassId) + .filter(Objects::nonNull).collect(Collectors.toSet())); + List list = page.getList().stream().map(item -> { + MemberUserRespDTO user = users.get(item.getStudentUserId()); + AdminUserRespDTO admin = item.getAssignedAdminUserId() == null ? null : admins.get(item.getAssignedAdminUserId()); + EducationClassDO classroom = item.getClassId() == null ? null : classes.get(item.getClassId()); + return FollowupResp.builder().id(item.getId()).studentUserId(item.getStudentUserId()) + .studentName(user == null ? null : user.getNickname()).studentMobile(user == null ? null : user.getMobile()) + .assignedAdminUserId(item.getAssignedAdminUserId()) + .assignedAdminName(admin == null ? null : admin.getNickname()).classId(item.getClassId()) + .className(classroom == null ? null : classroom.getName()).ruleId(item.getRuleId()) + .title(item.getTitle()).description(item.getDescription()).followupType(item.getFollowupType()) + .priority(item.getPriority()).status(item.getStatus()).dueTime(item.getDueTime()) + .completedTime(item.getCompletedTime()).completedBy(item.getCompletedBy()) + .batchKey(item.getBatchKey()).riskScore(item.getRiskScore()).reasons(item.getReasons()) + .evidence(item.getEvidence()).resultNote(item.getResultNote()).version(item.getVersion()) + .createTime(item.getCreateTime()).build(); + }).toList(); + return new PageResult<>(list, page.getTotal()); + } + + private RuleResp ruleResp(StudentSupervisionRuleDO item, Map classes, + Map admins) { + EducationClassDO classroom = item.getClassId() == null ? null : classes.get(item.getClassId()); + AdminUserRespDTO admin = item.getAssignedAdminUserId() == null ? null : admins.get(item.getAssignedAdminUserId()); + return RuleResp.builder().id(item.getId()).name(item.getName()).status(item.getStatus()) + .classId(item.getClassId()).className(classroom == null ? null : classroom.getName()) + .assignedAdminUserId(item.getAssignedAdminUserId()) + .assignedAdminName(admin == null ? null : admin.getNickname()).deptId(item.getDeptId()) + .rules(ruleConfig(item)).schedule(scheduleConfig(item)).limit(item.getLimitCount()) + .lastRunTime(item.getLastRunTime()).nextRunTime(item.getNextRunTime()) + .lastCandidateCount(item.getLastCandidateCount()).lastGeneratedCount(item.getLastGeneratedCount()) + .lastError(item.getLastError()).version(item.getVersion()).createTime(item.getCreateTime()).build(); + } + + private RiskRule normalizeRules(RiskRule input) { + RiskRule out = new RiskRule(); + out.setWindowDays(bound(input == null ? null : input.getWindowDays(), 3, 90, 14)); + out.setInactivityDays(bound(input == null ? null : input.getInactivityDays(), 1, 90, 7)); + out.setMinAnswers(bound(input == null ? null : input.getMinAnswers(), 1, 500, 10)); + out.setLowAccuracyThreshold(bound(input == null ? null : input.getLowAccuracyThreshold(), 0.1, 0.95, 0.6)); + out.setWrongQuestionThreshold(bound(input == null ? null : input.getWrongQuestionThreshold(), 1, 200, 5)); + out.setVocabularyDueThreshold(bound(input == null ? null : input.getVocabularyDueThreshold(), 1, 1000, 20)); + out.setStaleSessionDays(bound(input == null ? null : input.getStaleSessionDays(), 1, 30, 3)); + return out; + } + + private Schedule normalizeSchedule(Schedule input) { + Schedule out = new Schedule(); + String frequency = input == null || trim(input.getFrequency()) == null ? "MANUAL" : input.getFrequency().trim().toUpperCase(Locale.ROOT); + if (!Set.of("MANUAL", "DAILY", "WEEKLY").contains(frequency)) throw exception(SUPERVISION_RULE_INVALID, frequency); + out.setFrequency(frequency); out.setHour(bound(input == null ? null : input.getHour(), 0, 23, 9)); + out.setMinute(bound(input == null ? null : input.getMinute(), 0, 59, 0)); + List weekdays = input == null || input.getWeekdays() == null || input.getWeekdays().isEmpty() + ? List.of(1, 2, 3, 4, 5) : input.getWeekdays().stream().distinct().sorted().toList(); + out.setWeekdays(weekdays); return out; + } + + private void applyRules(StudentSupervisionRuleDO target, RiskRule rules) { + target.setWindowDays(rules.getWindowDays()); target.setInactivityDays(rules.getInactivityDays()); + target.setMinAnswers(rules.getMinAnswers()); + target.setLowAccuracyPermille((int) Math.round(rules.getLowAccuracyThreshold() * 1000)); + target.setWrongQuestionThreshold(rules.getWrongQuestionThreshold()); + target.setVocabularyDueThreshold(rules.getVocabularyDueThreshold()); + target.setStaleSessionDays(rules.getStaleSessionDays()); + } + + private void applySchedule(StudentSupervisionRuleDO target, Schedule schedule) { + target.setScheduleFrequency(schedule.getFrequency()); target.setScheduleHour(schedule.getHour()); + target.setScheduleMinute(schedule.getMinute()); + target.setScheduleWeekdays(schedule.getWeekdays().stream().map(String::valueOf).collect(Collectors.joining(","))); + } + + private RiskRule ruleConfig(StudentSupervisionRuleDO rule) { + RiskRule out = new RiskRule(); out.setWindowDays(rule.getWindowDays()); out.setInactivityDays(rule.getInactivityDays()); + out.setMinAnswers(rule.getMinAnswers()); out.setLowAccuracyThreshold(rule.getLowAccuracyPermille() / 1000D); + out.setWrongQuestionThreshold(rule.getWrongQuestionThreshold()); + out.setVocabularyDueThreshold(rule.getVocabularyDueThreshold()); out.setStaleSessionDays(rule.getStaleSessionDays()); + return out; + } + + private Schedule scheduleConfig(StudentSupervisionRuleDO rule) { + Schedule out = new Schedule(); out.setFrequency(rule.getScheduleFrequency()); out.setHour(rule.getScheduleHour()); + out.setMinute(rule.getScheduleMinute()); + out.setWeekdays(Arrays.stream(Optional.ofNullable(rule.getScheduleWeekdays()).orElse("").split(",")) + .filter(value -> !value.isBlank()).map(Integer::valueOf).toList()); return out; + } + + private LocalDateTime nextRun(Schedule schedule) { + if ("MANUAL".equals(schedule.getFrequency())) return null; + ZonedDateTime now = ZonedDateTime.now(SHANGHAI); + if ("DAILY".equals(schedule.getFrequency())) { + ZonedDateTime candidate = now.toLocalDate().atTime(schedule.getHour(), schedule.getMinute()).atZone(SHANGHAI); + return (candidate.isAfter(now) ? candidate : candidate.plusDays(1)).toLocalDateTime(); + } + for (int offset = 0; offset <= 14; offset++) { + LocalDate day = now.toLocalDate().plusDays(offset); + if (!schedule.getWeekdays().contains(day.getDayOfWeek().getValue())) continue; + ZonedDateTime candidate = day.atTime(schedule.getHour(), schedule.getMinute()).atZone(SHANGHAI); + if (candidate.isAfter(now)) return candidate.toLocalDateTime(); + } + return now.plusWeeks(1).toLocalDateTime(); + } + + private EducationClassDO requireClass(Long id) { + if (id == null) return null; + EducationClassDO classroom = classMapper.selectOwned(tenantId(), id); + if (classroom == null) throw exception(CLASS_NOT_FOUND); + if (!"ACTIVE".equals(classroom.getStatus())) throw exception(CLASS_NOT_ACTIVE); + return classroom; + } + + private StudentSupervisionRuleDO requireRule(Long id) { + StudentSupervisionRuleDO item = ruleMapper.selectOwned(tenantId(), id); + if (item == null) throw exception(SUPERVISION_RULE_NOT_FOUND); + return item; + } + + private StudentFollowupDO requireFollowup(Long id) { + StudentFollowupDO item = followupMapper.selectOwned(tenantId(), id); + if (item == null) throw exception(STUDENT_FOLLOWUP_NOT_FOUND); + return item; + } + + private void validateAssignee(Long id) { + if (id == null) return; + try { adminUserApi.validateUser(id); } + catch (RuntimeException ex) { throw exception(SUPERVISION_ASSIGNEE_INVALID); } + } + + private Map classMap(Set ids) { + Map result = new HashMap<>(); + for (Long id : ids) { EducationClassDO item = classMapper.selectOwned(tenantId(), id); if (item != null) result.put(id, item); } + return result; + } + + private Map adminMap(Set ids) { return adminUserApi.getUserMap(ids); } + private Set singleton(Long id) { return id == null ? Set.of() : Set.of(id); } + private Long tenantId() { return TenantContextHolder.getRequiredTenantId(); } + private long value(Long value) { return value == null ? 0L : value; } + private int value(Integer value) { return value == null ? 0 : value; } + private int bound(Integer value, int min, int max) { return bound(value, min, max, min); } + private int bound(Integer value, int min, int max, int fallback) { return Math.max(min, Math.min(max, value == null ? fallback : value)); } + private double bound(Double value, double min, double max, double fallback) { return Math.max(min, Math.min(max, value == null ? fallback : value)); } + private String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private String optionalChoice(String value, Set values, cn.iocoder.yudao.framework.common.exception.ErrorCode code) { + return trim(value) == null ? null : requiredChoice(value, values, code); + } + private String requiredChoice(String value, Set values, cn.iocoder.yudao.framework.common.exception.ErrorCode code) { + String normalized = trim(value) == null ? "" : value.trim().toUpperCase(Locale.ROOT); + if (!values.contains(normalized)) throw exception(code, value); return normalized; + } + private String batchKey(String value) { + String normalized = trim(value); + if (normalized == null) return "manual:" + LocalDate.now(SHANGHAI); + return normalized.length() <= 120 ? normalized : DigestUtil.sha256Hex(normalized); + } + private LocalDateTime latest(LocalDateTime... values) { return Arrays.stream(values).filter(Objects::nonNull).max(Comparator.naturalOrder()).orElse(null); } + private double decimal(double value) { return BigDecimal.valueOf(value).setScale(4, RoundingMode.HALF_UP).doubleValue(); } + private Map reason(String code, String label, String severity, Object value, Object threshold) { + Map result = new LinkedHashMap<>(); result.put("code", code); result.put("label", label); + result.put("severity", severity); result.put("value", value); result.put("threshold", threshold); return result; + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/vocabulary/VocabularyProgressServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/vocabulary/VocabularyProgressServiceImpl.java index 2d4ca0d8..2e8a27f0 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/vocabulary/VocabularyProgressServiceImpl.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/vocabulary/VocabularyProgressServiceImpl.java @@ -3,6 +3,8 @@ package cn.iocoder.yudao.module.education.service.vocabulary; import cn.iocoder.yudao.module.education.controller.app.vocabulary.vo.*; import cn.iocoder.yudao.module.education.dal.dataobject.VocabularyProgressDO; import cn.iocoder.yudao.module.education.dal.mysql.VocabularyProgressMapper; +import cn.iocoder.yudao.module.education.service.badge.BadgeGrantService; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,7 +17,11 @@ import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionU @Service public class VocabularyProgressServiceImpl implements VocabularyProgressService { private final VocabularyProgressMapper mapper; - public VocabularyProgressServiceImpl(VocabularyProgressMapper mapper) { this.mapper = mapper; } + private final BadgeGrantService badgeGrantService; + public VocabularyProgressServiceImpl(VocabularyProgressMapper mapper, + ObjectProvider badgeGrantServiceProvider) { + this.mapper = mapper; this.badgeGrantService = badgeGrantServiceProvider.getIfAvailable(); + } @Override @Transactional(rollbackFor = Exception.class) public VocabularyProgressRespVO start(VocabularyStartReqVO req, Long userId, Long tenantId) { VocabularyProgressDO row = new VocabularyProgressDO(); row.setTenantId(tenantId); row.setUserId(userId); @@ -37,6 +43,7 @@ public class VocabularyProgressServiceImpl implements VocabularyProgressService } row.setMasteryLevel(current); row.setReviewCount(row.getReviewCount() + 1); row.setLastReviewResult(req.getResult()); row.setNextReviewTime(LocalDateTime.now().plusDays(days)); row.setUpdater(String.valueOf(userId)); mapper.updateById(row); + if (badgeGrantService != null) badgeGrantService.evaluateVocabulary(tenantId, userId, row.getVocabularyKey()); return toResp(row); } @Override public List due(Long userId, Long tenantId, int limit) { diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4220__add_admin_authoring_reads.sql b/yudao-module-education/src/main/resources/db/migration/education/V4220__add_admin_authoring_reads.sql new file mode 100644 index 00000000..384a910d --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4220__add_admin_authoring_reads.sql @@ -0,0 +1,111 @@ +CREATE OR REPLACE FUNCTION education_prevent_question_content_projection_mutation() +RETURNS TRIGGER +LANGUAGE plpgsql +SET search_path = pg_catalog, pg_temp +AS $$ +BEGIN + IF OLD.status <> 'DRAFT' OR NEW.status <> 'DRAFT' + OR NEW.content_version <> OLD.content_version + 1 THEN + RAISE EXCEPTION 'question content revision requires DRAFT status and next immutable version' + USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION education_prevent_question_content_projection_mutation() FROM PUBLIC; + +DO $$ +DECLARE + installed INTEGER; + existing_buttons INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN + RETURN; + END IF; + + -- Backfill the root and early question permissions when System Menu was installed + -- after V4080/V4090. Conflicting IDs still fail the exact-shape check below. + INSERT INTO system_menu ( + id, name, permission, type, sort, parent_id, path, icon, component, + component_name, status, visible, keep_alive, always_show, creator, updater + ) VALUES + (6800, '教育管理', '', 1, 50, 0, '/education', 'ep:school', NULL, NULL, 0, true, true, true, 'education-flyway', 'education-flyway'), + (6801, '能力查询', 'education:capability', 3, 1, 6800, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6802, '题目创作', 'education:question:author', 3, 2, 6800, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6803, '题目发布', 'education:question:publish', 3, 3, 6800, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6804, '题目归档', 'education:question:archive', 3, 4, 6800, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6805, '题目归类', 'education:question:classify', 3, 5, 6800, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway') + ON CONFLICT (id) DO NOTHING; + + SELECT count(*) INTO existing_buttons + FROM system_menu + WHERE deleted = 0 AND status = 0 AND ( + (id = 6800 AND permission = '' AND type = 1 AND parent_id = 0 AND path = '/education') + OR (id = 6802 AND permission = 'education:question:author' AND type = 3) + OR (id = 6803 AND permission = 'education:question:publish' AND type = 3) + OR (id = 6804 AND permission = 'education:question:archive' AND type = 3) + OR (id = 6805 AND permission = 'education:question:classify' AND type = 3) + OR (id = 6806 AND permission = 'education:collection:author' AND type = 3) + OR (id = 6807 AND permission = 'education:collection:publish' AND type = 3) + OR (id = 6808 AND permission = 'education:collection:archive' AND type = 3) + OR (id = 6812 AND permission = 'education:practice-blueprint:author' AND type = 3) + OR (id = 6813 AND permission = 'education:practice-blueprint:publish' AND type = 3) + OR (id = 6814 AND permission = 'education:practice-blueprint:archive' AND type = 3) + ); + IF existing_buttons <> 11 THEN + RAISE EXCEPTION 'Education authoring button rows conflict' USING ERRCODE = '23505'; + END IF; + + INSERT INTO system_menu ( + id, name, permission, type, sort, parent_id, path, icon, component, + component_name, status, visible, keep_alive, always_show, creator, updater + ) VALUES + (6817, '内容节点', '', 2, 1, 6800, 'content-node', 'ep:connection', 'education/content-node/index', 'EducationContentNode', 0, true, true, true, 'education-flyway', 'education-flyway'), + (6818, '题目管理', '', 2, 2, 6800, 'question', 'ep:document', 'education/question/index', 'EducationQuestion', 0, true, true, true, 'education-flyway', 'education-flyway'), + (6819, '题集管理', '', 2, 3, 6800, 'collection', 'ep:collection', 'education/collection/index', 'EducationQuestionCollection', 0, true, true, true, 'education-flyway', 'education-flyway'), + (6820, '练习蓝图', '', 2, 4, 6800, 'practice-blueprint', 'ep:set-up', 'education/practice-blueprint/index', 'EducationPracticeBlueprint', 0, true, true, true, 'education-flyway', 'education-flyway'), + (6821, '内容节点查询', 'education:content-node:query', 3, 1, 6817, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6822, '内容节点创作', 'education:content-node:author', 3, 2, 6817, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6823, '内容节点发布', 'education:content-node:publish', 3, 3, 6817, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6824, '内容节点归档', 'education:content-node:archive', 3, 4, 6817, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6825, '题目查询', 'education:question:query', 3, 1, 6818, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6826, '题目修改', 'education:question:update', 3, 3, 6818, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6827, '题集查询', 'education:collection:query', 3, 1, 6819, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6828, '练习蓝图查询', 'education:practice-blueprint:query', 3, 1, 6820, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6829, '内容节点修改', 'education:content-node:update', 3, 5, 6817, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6830, '题集修改', 'education:collection:update', 3, 5, 6819, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6831, '练习蓝图修改', 'education:practice-blueprint:update', 3, 5, 6820, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway') + ON CONFLICT (id) DO NOTHING; + + UPDATE system_menu SET parent_id = 6818 WHERE id IN (6802, 6803, 6804, 6805); + UPDATE system_menu SET parent_id = 6819 WHERE id IN (6806, 6807, 6808); + UPDATE system_menu SET parent_id = 6820 WHERE id IN (6812, 6813, 6814); + + SELECT count(*) INTO installed + FROM system_menu + WHERE deleted = 0 AND status = 0 AND ( + (id = 6817 AND type = 2 AND parent_id = 6800 AND component = 'education/content-node/index') + OR (id = 6818 AND type = 2 AND parent_id = 6800 AND component = 'education/question/index') + OR (id = 6819 AND type = 2 AND parent_id = 6800 AND component = 'education/collection/index') + OR (id = 6820 AND type = 2 AND parent_id = 6800 AND component = 'education/practice-blueprint/index') + OR (id = 6821 AND permission = 'education:content-node:query' AND type = 3 AND parent_id = 6817) + OR (id = 6822 AND permission = 'education:content-node:author' AND type = 3 AND parent_id = 6817) + OR (id = 6823 AND permission = 'education:content-node:publish' AND type = 3 AND parent_id = 6817) + OR (id = 6824 AND permission = 'education:content-node:archive' AND type = 3 AND parent_id = 6817) + OR (id = 6825 AND permission = 'education:question:query' AND type = 3 AND parent_id = 6818) + OR (id = 6826 AND permission = 'education:question:update' AND type = 3 AND parent_id = 6818) + OR (id = 6827 AND permission = 'education:collection:query' AND type = 3 AND parent_id = 6819) + OR (id = 6828 AND permission = 'education:practice-blueprint:query' AND type = 3 AND parent_id = 6820) + OR (id = 6829 AND permission = 'education:content-node:update' AND type = 3 AND parent_id = 6817) + OR (id = 6830 AND permission = 'education:collection:update' AND type = 3 AND parent_id = 6819) + OR (id = 6831 AND permission = 'education:practice-blueprint:update' AND type = 3 AND parent_id = 6820) + OR (id IN (6802, 6803, 6804, 6805) AND type = 3 AND parent_id = 6818) + OR (id IN (6806, 6807, 6808) AND type = 3 AND parent_id = 6819) + OR (id IN (6812, 6813, 6814) AND type = 3 AND parent_id = 6820) + ); + IF installed <> 25 THEN + RAISE EXCEPTION 'Education admin authoring menu shape conflict' USING ERRCODE = '23505'; + END IF; +END; +$$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4230__add_admin_operations_pages.sql b/yudao-module-education/src/main/resources/db/migration/education/V4230__add_admin_operations_pages.sql new file mode 100644 index 00000000..4eca7866 --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4230__add_admin_operations_pages.sql @@ -0,0 +1,66 @@ +-- Expose the existing import, classroom, commercialization, and operational contracts in the Vben admin menu. +-- Existing permission rows are re-parented under visible pages; no permission semantics are changed. +DO $$ +DECLARE + installed INTEGER; + existing_buttons INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN + RETURN; + END IF; + + SELECT count(*) INTO existing_buttons + FROM system_menu + WHERE deleted = 0 AND status = 0 AND ( + (id = 6801 AND permission = 'education:capability' AND type = 3) + OR (id = 6815 AND permission = 'education:commercialization:binding' AND type = 3) + OR (id = 6816 AND permission = 'education:commercialization:entitlement' AND type = 3) + ); + IF existing_buttons <> 3 THEN + RAISE EXCEPTION 'Education operations button rows conflict' USING ERRCODE = '23505'; + END IF; + + INSERT INTO system_menu ( + id, name, permission, type, sort, parent_id, path, icon, component, + component_name, status, visible, keep_alive, always_show, creator, updater + ) VALUES + (6832, '题目导入', '', 2, 5, 6800, 'import-job', 'ep:upload-filled', 'education/import-job/index', 'EducationImportJob', 0, true, true, true, 'education-flyway', 'education-flyway'), + (6833, '导入资产上传', 'education:import-asset:create', 3, 1, 6832, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6834, '题目导入预览', 'education:question-import:create', 3, 2, 6832, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6835, '题目导入执行', 'education:question-import:execute', 3, 3, 6832, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6836, '题目导入查询', 'education:question-import:query', 3, 4, 6832, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6837, '班级管理', '', 2, 6, 6800, 'classroom', 'ep:user-filled', 'education/classroom/index', 'EducationClassroom', 0, true, true, true, 'education-flyway', 'education-flyway'), + (6838, '班级查询', 'education:class:query', 3, 1, 6837, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6839, '班级创建', 'education:class:create', 3, 2, 6837, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6840, '班级成员查询', 'education:class-member:query', 3, 3, 6837, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6841, '班级邀请创建', 'education:class-invitation:create', 3, 4, 6837, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6842, '商业化与权益', '', 2, 7, 6800, 'commercialization', 'ep:shopping-cart-full', 'education/commercialization/index', 'EducationCommercialization', 0, true, true, true, 'education-flyway', 'education-flyway'), + (6843, '运行健康', '', 2, 8, 6800, 'operations', 'ep:monitor', 'education/operations/index', 'EducationOperations', 0, true, true, true, 'education-flyway', 'education-flyway') + ON CONFLICT (id) DO NOTHING; + + UPDATE system_menu SET parent_id = 6842 WHERE id IN (6815, 6816); + UPDATE system_menu SET parent_id = 6843 WHERE id = 6801; + + SELECT count(*) INTO installed + FROM system_menu + WHERE deleted = 0 AND status = 0 AND ( + (id = 6832 AND type = 2 AND parent_id = 6800 AND component = 'education/import-job/index') + OR (id = 6833 AND permission = 'education:import-asset:create' AND type = 3 AND parent_id = 6832) + OR (id = 6834 AND permission = 'education:question-import:create' AND type = 3 AND parent_id = 6832) + OR (id = 6835 AND permission = 'education:question-import:execute' AND type = 3 AND parent_id = 6832) + OR (id = 6836 AND permission = 'education:question-import:query' AND type = 3 AND parent_id = 6832) + OR (id = 6837 AND type = 2 AND parent_id = 6800 AND component = 'education/classroom/index') + OR (id = 6838 AND permission = 'education:class:query' AND type = 3 AND parent_id = 6837) + OR (id = 6839 AND permission = 'education:class:create' AND type = 3 AND parent_id = 6837) + OR (id = 6840 AND permission = 'education:class-member:query' AND type = 3 AND parent_id = 6837) + OR (id = 6841 AND permission = 'education:class-invitation:create' AND type = 3 AND parent_id = 6837) + OR (id = 6842 AND type = 2 AND parent_id = 6800 AND component = 'education/commercialization/index') + OR (id = 6843 AND type = 2 AND parent_id = 6800 AND component = 'education/operations/index') + OR (id IN (6815, 6816) AND type = 3 AND parent_id = 6842) + OR (id = 6801 AND type = 3 AND parent_id = 6843) + ); + IF installed <> 15 THEN + RAISE EXCEPTION 'Education admin operations menu shape conflict' USING ERRCODE = '23505'; + END IF; +END; +$$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4240__add_category_and_export_admin_pages.sql b/yudao-module-education/src/main/resources/db/migration/education/V4240__add_category_and_export_admin_pages.sql new file mode 100644 index 00000000..ff103ae1 --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4240__add_category_and_export_admin_pages.sql @@ -0,0 +1,49 @@ +-- Expose category authoring and the bounded content-export policy in Vben. +DO $$ +DECLARE + installed INTEGER; + existing_buttons INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN + RETURN; + END IF; + + SELECT count(*) INTO existing_buttons + FROM system_menu + WHERE deleted = 0 AND status = 0 AND ( + (id = 6809 AND permission = 'education:category:author' AND type = 3) + OR (id = 6810 AND permission = 'education:category:publish' AND type = 3) + OR (id = 6811 AND permission = 'education:category:archive' AND type = 3) + ); + IF existing_buttons <> 3 THEN + RAISE EXCEPTION 'Education category button rows conflict' USING ERRCODE = '23505'; + END IF; + + INSERT INTO system_menu ( + id, name, permission, type, sort, parent_id, path, icon, component, + component_name, status, visible, keep_alive, always_show, creator, updater + ) VALUES + (6844, '内容分类', '', 2, 5, 6800, 'category', 'ep:folder-opened', 'education/category/index', 'EducationCategory', 0, true, true, true, 'education-flyway', 'education-flyway'), + (6845, '分类查询', 'education:category:query', 3, 1, 6844, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6846, '导出策略', '', 2, 10, 6800, 'content-export', 'ep:download', 'education/content-export/index', 'EducationContentExport', 0, true, true, true, 'education-flyway', 'education-flyway'), + (6847, '内容导出策略', 'education:content-export', 3, 1, 6846, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6848, '导出包含答案', 'education:content-export:answers', 3, 2, 6846, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway') + ON CONFLICT (id) DO NOTHING; + + UPDATE system_menu SET parent_id = 6844 WHERE id IN (6809, 6810, 6811); + + SELECT count(*) INTO installed + FROM system_menu + WHERE deleted = 0 AND status = 0 AND ( + (id = 6844 AND type = 2 AND parent_id = 6800 AND component = 'education/category/index') + OR (id = 6845 AND permission = 'education:category:query' AND type = 3 AND parent_id = 6844) + OR (id = 6846 AND type = 2 AND parent_id = 6800 AND component = 'education/content-export/index') + OR (id = 6847 AND permission = 'education:content-export' AND type = 3 AND parent_id = 6846) + OR (id = 6848 AND permission = 'education:content-export:answers' AND type = 3 AND parent_id = 6846) + OR (id IN (6809, 6810, 6811) AND type = 3 AND parent_id = 6844) + ); + IF installed <> 8 THEN + RAISE EXCEPTION 'Education category/export menu shape conflict' USING ERRCODE = '23505'; + END IF; +END; +$$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4260__add_learning_operations_admin.sql b/yudao-module-education/src/main/resources/db/migration/education/V4260__add_learning_operations_admin.sql new file mode 100644 index 00000000..383dccb6 --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4260__add_learning_operations_admin.sql @@ -0,0 +1,95 @@ +-- Tenant learning operations: feedback handling, auditable reward state and +-- a Vben page. Member remains authoritative for users/levels/point balances. +ALTER TABLE education_student_feedback + ADD COLUMN IF NOT EXISTS priority VARCHAR(16) NOT NULL DEFAULT 'NORMAL', + ADD COLUMN IF NOT EXISTS resolution VARCHAR(1000), + ADD COLUMN IF NOT EXISTS handled_by BIGINT, + ADD COLUMN IF NOT EXISTS handled_time TIMESTAMP, + ADD COLUMN IF NOT EXISTS reward_points INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS reward_status VARCHAR(16) NOT NULL DEFAULT 'NOT_REQUIRED', + ADD COLUMN IF NOT EXISTS reward_error VARCHAR(512), + ADD COLUMN IF NOT EXISTS version INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE education_student_feedback + DROP CONSTRAINT IF EXISTS ck_education_student_feedback_status; +ALTER TABLE education_student_feedback + ADD CONSTRAINT ck_education_student_feedback_status + CHECK (status IN ('OPEN', 'ACCEPTED', 'REJECTED', 'RESOLVED', 'CLOSED')), + ADD CONSTRAINT ck_education_student_feedback_priority + CHECK (priority IN ('LOW', 'NORMAL', 'HIGH', 'URGENT')), + ADD CONSTRAINT ck_education_student_feedback_reward_points + CHECK (reward_points BETWEEN 0 AND 100), + ADD CONSTRAINT ck_education_student_feedback_reward_status + CHECK (reward_status IN ('NOT_REQUIRED', 'PENDING', 'PROCESSING', 'AWARDED', 'FAILED')); + +CREATE INDEX idx_education_student_feedback_admin + ON education_student_feedback (tenant_id, status, priority, create_time DESC) + WHERE deleted = false; + +CREATE TABLE education_student_feedback_event ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + feedback_id BIGINT NOT NULL, + from_status VARCHAR(16) NOT NULL, + to_status VARCHAR(16) NOT NULL, + note VARCHAR(1000), + actor_id BIGINT NOT NULL, + creator VARCHAR(64) DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT false +); +COMMENT ON TABLE education_student_feedback_event IS '教育-学生反馈处理审计事件'; +CREATE INDEX idx_education_student_feedback_event_owner + ON education_student_feedback_event (tenant_id, feedback_id, create_time, id) + WHERE deleted = false; + +CREATE OR REPLACE FUNCTION education_guard_feedback_event_owner() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM education_student_feedback f + WHERE f.id = NEW.feedback_id AND f.tenant_id = NEW.tenant_id AND f.deleted = false + ) THEN + RAISE EXCEPTION 'education feedback event cannot cross tenants or reference missing feedback'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_education_feedback_event_owner + BEFORE INSERT OR UPDATE OF tenant_id, feedback_id ON education_student_feedback_event + FOR EACH ROW EXECUTE FUNCTION education_guard_feedback_event_owner(); + +DO $$ +DECLARE + installed INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN + RETURN; + END IF; + + INSERT INTO system_menu ( + id, name, permission, type, sort, parent_id, path, icon, component, + component_name, status, visible, keep_alive, always_show, creator, updater + ) VALUES + (6849, '会员学习运营', '', 2, 9, 6800, 'learning-operations', 'ep:data-analysis', 'education/learning-operations/index', 'EducationLearningOperations', 0, true, true, true, 'education-flyway', 'education-flyway'), + (6850, '学习运营查询', 'education:learning-operations:query', 3, 1, 6849, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6851, '反馈处理', 'education:learning-operations:feedback', 3, 2, 6849, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6852, '反馈积分奖励', 'education:learning-operations:reward', 3, 3, 6849, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway') + ON CONFLICT (id) DO NOTHING; + + SELECT count(*) INTO installed + FROM system_menu + WHERE deleted = 0 AND status = 0 AND ( + (id = 6849 AND type = 2 AND parent_id = 6800 AND component = 'education/learning-operations/index') + OR (id = 6850 AND permission = 'education:learning-operations:query' AND type = 3 AND parent_id = 6849) + OR (id = 6851 AND permission = 'education:learning-operations:feedback' AND type = 3 AND parent_id = 6849) + OR (id = 6852 AND permission = 'education:learning-operations:reward' AND type = 3 AND parent_id = 6849) + ); + IF installed <> 4 THEN + RAISE EXCEPTION 'Education learning operations menu shape conflict' USING ERRCODE = '23505'; + END IF; +END; +$$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4270__add_student_supervision_admin.sql b/yudao-module-education/src/main/resources/db/migration/education/V4270__add_student_supervision_admin.sql new file mode 100644 index 00000000..168b8129 --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4270__add_student_supervision_admin.sql @@ -0,0 +1,185 @@ +-- Tenant student supervision. System owns admins/RBAC/departments, Member owns +-- student accounts, and Education owns only learning-risk projections/rules/tasks. +ALTER TABLE education_class + ADD COLUMN IF NOT EXISTS dept_id BIGINT, + ADD COLUMN IF NOT EXISTS owner_user_id BIGINT; + +-- Best-effort adoption of existing classes without coupling a fresh Education +-- schema to System tables. Numeric audit creators are stable System admin IDs. +UPDATE education_class + SET owner_user_id = creator::BIGINT + WHERE owner_user_id IS NULL AND creator ~ '^[0-9]+$'; + +DO $$ +BEGIN + IF to_regclass('system_users') IS NOT NULL THEN + UPDATE education_class c + SET dept_id = u.dept_id + FROM system_users u + WHERE c.dept_id IS NULL AND c.owner_user_id = u.id AND c.tenant_id = u.tenant_id; + END IF; +END $$; + +CREATE INDEX idx_education_class_data_scope + ON education_class (tenant_id, dept_id, owner_user_id, id) WHERE deleted = false; + +CREATE TABLE education_student_supervision_rule ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + name VARCHAR(120) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + class_id BIGINT, + assigned_admin_user_id BIGINT, + dept_id BIGINT, + owner_user_id BIGINT NOT NULL, + window_days INTEGER NOT NULL DEFAULT 14, + inactivity_days INTEGER NOT NULL DEFAULT 7, + min_answers INTEGER NOT NULL DEFAULT 10, + low_accuracy_permille INTEGER NOT NULL DEFAULT 600, + wrong_question_threshold INTEGER NOT NULL DEFAULT 5, + vocabulary_due_threshold INTEGER NOT NULL DEFAULT 20, + stale_session_days INTEGER NOT NULL DEFAULT 3, + schedule_frequency VARCHAR(16) NOT NULL DEFAULT 'MANUAL', + schedule_hour INTEGER NOT NULL DEFAULT 9, + schedule_minute INTEGER NOT NULL DEFAULT 0, + schedule_weekdays VARCHAR(32) NOT NULL DEFAULT '1,2,3,4,5', + limit_count INTEGER NOT NULL DEFAULT 20, + last_run_time TIMESTAMP, + next_run_time TIMESTAMP, + last_candidate_count INTEGER, + last_generated_count INTEGER, + last_error VARCHAR(512), + version INTEGER NOT NULL DEFAULT 0, + creator VARCHAR(64) DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT false, + CONSTRAINT ck_education_supervision_rule_status CHECK (status IN ('ACTIVE', 'DISABLED', 'ARCHIVED')), + CONSTRAINT ck_education_supervision_rule_window CHECK (window_days BETWEEN 3 AND 90), + CONSTRAINT ck_education_supervision_rule_inactivity CHECK (inactivity_days BETWEEN 1 AND 90), + CONSTRAINT ck_education_supervision_rule_min_answers CHECK (min_answers BETWEEN 1 AND 500), + CONSTRAINT ck_education_supervision_rule_accuracy CHECK (low_accuracy_permille BETWEEN 100 AND 950), + CONSTRAINT ck_education_supervision_rule_wrong CHECK (wrong_question_threshold BETWEEN 1 AND 200), + CONSTRAINT ck_education_supervision_rule_vocabulary CHECK (vocabulary_due_threshold BETWEEN 1 AND 1000), + CONSTRAINT ck_education_supervision_rule_stale CHECK (stale_session_days BETWEEN 1 AND 30), + CONSTRAINT ck_education_supervision_schedule_frequency CHECK (schedule_frequency IN ('MANUAL', 'DAILY', 'WEEKLY')), + CONSTRAINT ck_education_supervision_schedule_hour CHECK (schedule_hour BETWEEN 0 AND 23), + CONSTRAINT ck_education_supervision_schedule_minute CHECK (schedule_minute BETWEEN 0 AND 59), + CONSTRAINT ck_education_supervision_rule_limit CHECK (limit_count BETWEEN 1 AND 100) +); +COMMENT ON TABLE education_student_supervision_rule IS '教育-租户学习督导规则;System 管理员和部门仅作授权投影'; +CREATE INDEX idx_education_supervision_rule_page + ON education_student_supervision_rule (tenant_id, status, next_run_time, id DESC) WHERE deleted = false; +CREATE INDEX idx_education_supervision_rule_scope + ON education_student_supervision_rule (tenant_id, dept_id, owner_user_id, id) WHERE deleted = false; + +CREATE TABLE education_student_followup ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + student_user_id BIGINT NOT NULL, + assigned_admin_user_id BIGINT, + class_id BIGINT, + rule_id BIGINT, + dept_id BIGINT, + owner_user_id BIGINT NOT NULL, + title VARCHAR(200) NOT NULL, + description VARCHAR(1000), + followup_type VARCHAR(16) NOT NULL DEFAULT 'LEARNING', + priority VARCHAR(16) NOT NULL DEFAULT 'NORMAL', + status VARCHAR(16) NOT NULL DEFAULT 'OPEN', + due_time TIMESTAMP, + completed_time TIMESTAMP, + completed_by BIGINT, + batch_key VARCHAR(120) NOT NULL, + risk_score INTEGER NOT NULL, + reasons JSONB NOT NULL DEFAULT '[]'::JSONB, + evidence JSONB NOT NULL DEFAULT '{}'::JSONB, + result_note VARCHAR(1000), + version INTEGER NOT NULL DEFAULT 0, + creator VARCHAR(64) DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT false, + CONSTRAINT ck_education_followup_type CHECK (followup_type IN ('LEARNING', 'SERVICE', 'SALES', 'RENEWAL', 'RISK', 'CUSTOM')), + CONSTRAINT ck_education_followup_priority CHECK (priority IN ('NORMAL', 'HIGH', 'URGENT')), + CONSTRAINT ck_education_followup_status CHECK (status IN ('OPEN', 'IN_PROGRESS', 'DONE', 'CANCELLED')), + CONSTRAINT ck_education_followup_risk CHECK (risk_score BETWEEN 0 AND 100), + CONSTRAINT ck_education_followup_completion CHECK ( + (status = 'DONE' AND completed_time IS NOT NULL AND completed_by IS NOT NULL) + OR (status <> 'DONE' AND completed_time IS NULL AND completed_by IS NULL) + ) +); +COMMENT ON TABLE education_student_followup IS '教育-学习风险跟进任务;Member/System 仍分别持有学生和管理员身份'; +CREATE UNIQUE INDEX uk_education_followup_batch_student + ON education_student_followup (tenant_id, batch_key, student_user_id) WHERE deleted = false; +CREATE INDEX idx_education_followup_page + ON education_student_followup (tenant_id, status, priority, due_time, id DESC) WHERE deleted = false; +CREATE INDEX idx_education_followup_student + ON education_student_followup (tenant_id, student_user_id, create_time DESC) WHERE deleted = false; +CREATE INDEX idx_education_followup_scope + ON education_student_followup (tenant_id, dept_id, owner_user_id, id) WHERE deleted = false; + +CREATE OR REPLACE FUNCTION education_validate_supervision_class_owner() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.class_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM education_class c + WHERE c.id = NEW.class_id AND c.tenant_id = NEW.tenant_id AND c.deleted = false + ) THEN + RAISE EXCEPTION 'education supervision object cannot cross class tenant'; + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION education_validate_followup_rule_owner() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.rule_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM education_student_supervision_rule r + WHERE r.id = NEW.rule_id AND r.tenant_id = NEW.tenant_id AND r.deleted = false + ) THEN + RAISE EXCEPTION 'education followup cannot cross rule tenant'; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER trg_education_supervision_rule_owner + BEFORE INSERT OR UPDATE OF tenant_id, class_id ON education_student_supervision_rule + FOR EACH ROW EXECUTE FUNCTION education_validate_supervision_class_owner(); +CREATE TRIGGER trg_education_followup_owner + BEFORE INSERT OR UPDATE OF tenant_id, class_id, rule_id ON education_student_followup + FOR EACH ROW EXECUTE FUNCTION education_validate_supervision_class_owner(); +CREATE TRIGGER trg_education_followup_rule_owner + BEFORE INSERT OR UPDATE OF tenant_id, rule_id ON education_student_followup + FOR EACH ROW EXECUTE FUNCTION education_validate_followup_rule_owner(); + +DO $$ +DECLARE installed INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN RETURN; END IF; + INSERT INTO system_menu ( + id, name, permission, type, sort, parent_id, path, icon, component, + component_name, status, visible, keep_alive, always_show, creator, updater + ) VALUES + (6853, '学习督导', '', 2, 10, 6800, 'supervision', 'ep:warning', 'education/supervision/index', 'EducationSupervision', 0, true, true, true, 'education-flyway', 'education-flyway'), + (6854, '学习督导查询', 'education:supervision:query', 3, 1, 6853, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6855, '督导规则管理', 'education:supervision:rule', 3, 2, 6853, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6856, '生成跟进任务', 'education:supervision:generate', 3, 3, 6853, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway'), + (6857, '跟进任务处理', 'education:supervision:followup', 3, 4, 6853, '', '', NULL, NULL, 0, false, true, true, 'education-flyway', 'education-flyway') + ON CONFLICT (id) DO NOTHING; + + SELECT count(*) INTO installed FROM system_menu WHERE deleted = 0 AND status = 0 AND ( + (id = 6853 AND type = 2 AND parent_id = 6800 AND component = 'education/supervision/index') + OR (id = 6854 AND type = 3 AND parent_id = 6853 AND permission = 'education:supervision:query') + OR (id = 6855 AND type = 3 AND parent_id = 6853 AND permission = 'education:supervision:rule') + OR (id = 6856 AND type = 3 AND parent_id = 6853 AND permission = 'education:supervision:generate') + OR (id = 6857 AND type = 3 AND parent_id = 6853 AND permission = 'education:supervision:followup') + ); + IF installed <> 5 THEN + RAISE EXCEPTION 'Education supervision menu shape conflict' USING ERRCODE = '23505'; + END IF; +END; +$$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4280__add_configurable_badges.sql b/yudao-module-education/src/main/resources/db/migration/education/V4280__add_configurable_badges.sql new file mode 100644 index 00000000..0be89e65 --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4280__add_configurable_badges.sql @@ -0,0 +1,139 @@ +-- Configurable tenant badges. Education owns learning rules and grants; Member +-- remains authoritative for student identity and System for RBAC/admin/notify. +CREATE TABLE education_badge_definition ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + code VARCHAR(64) NOT NULL, + name VARCHAR(120) NOT NULL, + description VARCHAR(1000), + category VARCHAR(24) NOT NULL DEFAULT 'CUSTOM', + icon_url VARCHAR(500), + level INTEGER, + trigger_type VARCHAR(32) NOT NULL DEFAULT 'MANUAL', + metric VARCHAR(48), + operator VARCHAR(8), + threshold_value NUMERIC(18,4), + condition_extra JSONB NOT NULL DEFAULT '{}'::JSONB, + sort_order INTEGER NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + version INTEGER NOT NULL DEFAULT 0, + creator VARCHAR(64) DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT false, + CONSTRAINT ck_education_badge_category CHECK (category IN ('LEARNING','PRACTICE','VOCABULARY','FEEDBACK','CUSTOM')), + CONSTRAINT ck_education_badge_trigger CHECK (trigger_type IN ('MANUAL','PRACTICE_SUBMIT','VOCABULARY_REVIEW','FEEDBACK_RESOLVED')), + CONSTRAINT ck_education_badge_metric CHECK (metric IS NULL OR metric IN ('PRACTICE_COUNT','PRACTICE_SCORE','VOCABULARY_MASTERED_COUNT','FEEDBACK_RESOLVED_COUNT','FEEDBACK_REWARD_POINTS')), + CONSTRAINT ck_education_badge_operator CHECK (operator IS NULL OR operator IN ('GTE','LTE','EQ','GT','LT')), + CONSTRAINT ck_education_badge_status CHECK (status IN ('ACTIVE','DISABLED')), + CONSTRAINT ck_education_badge_rule CHECK ( + (trigger_type = 'MANUAL' AND metric IS NULL AND operator IS NULL AND threshold_value IS NULL) + OR (trigger_type <> 'MANUAL' AND metric IS NOT NULL AND operator IS NOT NULL AND threshold_value IS NOT NULL) + ) +); +COMMENT ON TABLE education_badge_definition IS '教育-租户学习徽章定义与自动授予规则'; +CREATE UNIQUE INDEX uk_education_badge_definition_code + ON education_badge_definition (tenant_id, code) WHERE deleted = false; +CREATE INDEX idx_education_badge_definition_rule + ON education_badge_definition (tenant_id, status, trigger_type, sort_order, id) WHERE deleted = false; + +ALTER TABLE education_learning_award + ADD COLUMN badge_definition_id BIGINT, + ADD COLUMN award_source VARCHAR(16) NOT NULL DEFAULT 'LEGACY', + ADD COLUMN grant_note VARCHAR(1000), + ADD COLUMN grant_evidence JSONB NOT NULL DEFAULT '{}'::JSONB, + ADD COLUMN granted_by BIGINT, + ADD COLUMN notify_message_id BIGINT, + ADD COLUMN notify_status VARCHAR(16) NOT NULL DEFAULT 'NOT_REQUIRED', + ADD COLUMN notify_error VARCHAR(512), + ADD CONSTRAINT ck_education_learning_award_source CHECK (award_source IN ('LEGACY','MANUAL','AUTO','MIGRATION')), + ADD CONSTRAINT ck_education_learning_award_notify CHECK (notify_status IN ('NOT_REQUIRED','PENDING','SENT','FAILED')); + +CREATE UNIQUE INDEX uk_education_badge_grant_user_badge + ON education_learning_award (tenant_id, user_id, badge_definition_id) + WHERE badge_definition_id IS NOT NULL AND deleted = false; +CREATE INDEX idx_education_badge_grant_page + ON education_learning_award (tenant_id, badge_definition_id, award_source, create_time DESC) + WHERE badge_definition_id IS NOT NULL AND deleted = false; + +CREATE OR REPLACE FUNCTION education_validate_badge_grant_owner() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.badge_definition_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM education_badge_definition b + WHERE b.id = NEW.badge_definition_id AND b.tenant_id = NEW.tenant_id AND b.deleted = false + ) THEN + RAISE EXCEPTION 'education badge grant cannot cross badge tenant'; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER trg_education_badge_grant_owner + BEFORE INSERT OR UPDATE OF tenant_id, badge_definition_id ON education_learning_award + FOR EACH ROW EXECUTE FUNCTION education_validate_badge_grant_owner(); + +-- Reuse System Notify's public template API for badge notifications when System +-- tables share this schema. A standalone Education schema simply skips seeding. +DO $$ +DECLARE deleted_type TEXT; +BEGIN + IF to_regclass('system_notify_template') IS NULL THEN RETURN; END IF; + SELECT data_type INTO deleted_type FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = 'system_notify_template' AND column_name = 'deleted'; + IF deleted_type NOT IN ('boolean', 'smallint', 'integer', 'bigint') THEN + RAISE EXCEPTION 'system_notify_template.deleted has incompatible type %', deleted_type; + END IF; + IF EXISTS (SELECT 1 FROM system_notify_template WHERE code = 'education_badge_granted' + AND (content <> '恭喜获得徽章:{badgeName}' OR deleted::TEXT NOT IN ('false', '0'))) THEN + RAISE EXCEPTION 'System notify template code education_badge_granted has incompatible definition'; + END IF; + IF deleted_type = 'boolean' THEN + EXECUTE $sql$ + INSERT INTO system_notify_template + (id,name,code,nickname,content,type,params,status,remark,creator,create_time,updater,update_time,deleted) + SELECT COALESCE(MAX(id),0)+1,'教育徽章通知','education_badge_granted','恭学教育', + '恭喜获得徽章:{badgeName}',2,'["badgeName"]',0, + 'Education 经 System Notify 公共 API 发送','migration',CURRENT_TIMESTAMP, + 'migration',CURRENT_TIMESTAMP,false + FROM system_notify_template + WHERE NOT EXISTS (SELECT 1 FROM system_notify_template WHERE code='education_badge_granted') + $sql$; + ELSE + EXECUTE $sql$ + INSERT INTO system_notify_template + (id,name,code,nickname,content,type,params,status,remark,creator,create_time,updater,update_time,deleted) + SELECT COALESCE(MAX(id),0)+1,'教育徽章通知','education_badge_granted','恭学教育', + '恭喜获得徽章:{badgeName}',2,'["badgeName"]',0, + 'Education 经 System Notify 公共 API 发送','migration',CURRENT_TIMESTAMP, + 'migration',CURRENT_TIMESTAMP,0 + FROM system_notify_template + WHERE NOT EXISTS (SELECT 1 FROM system_notify_template WHERE code='education_badge_granted') + $sql$; + END IF; +END $$; + +DO $$ +DECLARE installed INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN RETURN; END IF; + INSERT INTO system_menu ( + id,name,permission,type,sort,parent_id,path,icon,component,component_name, + status,visible,keep_alive,always_show,creator,updater + ) VALUES + (6858,'徽章管理','',2,11,6800,'badge','ep:medal','education/badge/index','EducationBadge',0,true,true,true,'education-flyway','education-flyway'), + (6859,'徽章查询','education:badge:query',3,1,6858,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6860,'徽章配置','education:badge:write',3,2,6858,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6861,'徽章发放','education:badge:grant',3,3,6858,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway') + ON CONFLICT (id) DO NOTHING; + + SELECT count(*) INTO installed FROM system_menu WHERE deleted=0 AND status=0 AND ( + (id=6858 AND type=2 AND parent_id=6800 AND component='education/badge/index') + OR (id=6859 AND type=3 AND parent_id=6858 AND permission='education:badge:query') + OR (id=6860 AND type=3 AND parent_id=6858 AND permission='education:badge:write') + OR (id=6861 AND type=3 AND parent_id=6858 AND permission='education:badge:grant') + ); + IF installed <> 4 THEN + RAISE EXCEPTION 'Education badge menu shape conflict' USING ERRCODE='23505'; + END IF; +END $$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4290__add_tenant_appearance.sql b/yudao-module-education/src/main/resources/db/migration/education/V4290__add_tenant_appearance.sql new file mode 100644 index 00000000..22eb72a5 --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4290__add_tenant_appearance.sql @@ -0,0 +1,106 @@ +-- Tenant presentation configuration. System Tenant remains authoritative for +-- tenant identity/name; this table stores Education-specific public branding, +-- feature flags, and draft/published theme state. +CREATE TABLE education_tenant_theme_template ( + code VARCHAR(64) PRIMARY KEY, + name VARCHAR(120) NOT NULL, + description VARCHAR(500), + preview_image_url VARCHAR(500), + theme JSONB NOT NULL DEFAULT '{}'::JSONB, + public_assets JSONB NOT NULL DEFAULT '{}'::JSONB, + sort_order INTEGER NOT NULL DEFAULT 100, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT false, + CONSTRAINT ck_education_theme_template_status CHECK (status IN ('ACTIVE','DISABLED')), + CONSTRAINT ck_education_theme_template_theme_object CHECK (jsonb_typeof(theme) = 'object'), + CONSTRAINT ck_education_theme_template_assets_object CHECK (jsonb_typeof(public_assets) = 'object') +); +CREATE INDEX idx_education_theme_template_status_sort + ON education_tenant_theme_template (status, sort_order, code) WHERE deleted = false; + +INSERT INTO education_tenant_theme_template + (code,name,description,theme,public_assets,sort_order,status,creator,updater) +VALUES + ('classic','经典蓝','稳重、清晰、适合默认题库和机构后台。', + '{"mode":"light","primaryColor":"#2563eb","accentColor":"#0f766e","backgroundColor":"#f8fafc","surfaceColor":"#ffffff","textColor":"#0f172a","mutedColor":"#64748b","borderRadius":8,"buttonRadius":8,"fontFamily":"system","layoutDensity":"comfortable"}'::JSONB, + '{"iconSet":"classic","shareCardStyle":"clean"}'::JSONB,10,'ACTIVE','education-flyway','education-flyway'), + ('focus','专注绿','低干扰学习主题,适合学生端刷题和背单词场景。', + '{"mode":"light","primaryColor":"#0f766e","accentColor":"#7c3aed","backgroundColor":"#f5fbf8","surfaceColor":"#ffffff","textColor":"#10201b","mutedColor":"#55706a","borderRadius":8,"buttonRadius":8,"fontFamily":"system","layoutDensity":"compact"}'::JSONB, + '{"iconSet":"focus","shareCardStyle":"study"}'::JSONB,20,'ACTIVE','education-flyway','education-flyway'), + ('high-contrast','高对比','强化标题、按钮和导航对比度,适合强运营入口和大屏后台。', + '{"mode":"light","primaryColor":"#111827","accentColor":"#f59e0b","backgroundColor":"#f3f4f6","surfaceColor":"#ffffff","textColor":"#030712","mutedColor":"#4b5563","borderRadius":6,"buttonRadius":6,"fontFamily":"system","layoutDensity":"dense"}'::JSONB, + '{"iconSet":"contrast","shareCardStyle":"bold"}'::JSONB,30,'ACTIVE','education-flyway','education-flyway'); + +CREATE TABLE education_tenant_appearance ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + brand_name VARCHAR(120) NOT NULL, + short_name VARCHAR(80), + slogan VARCHAR(240), + org_name VARCHAR(160), + logo_url VARCHAR(500), + favicon_url VARCHAR(500), + service_wechat VARCHAR(120), + service_account_name VARCHAR(120), + feature_flags JSONB NOT NULL DEFAULT '{}'::JSONB, + admin_feature_flags JSONB NOT NULL DEFAULT '{}'::JSONB, + public_config JSONB NOT NULL DEFAULT '{}'::JSONB, + active_template_code VARCHAR(64) DEFAULT 'classic' REFERENCES education_tenant_theme_template(code) ON UPDATE CASCADE ON DELETE SET NULL, + active_theme JSONB NOT NULL DEFAULT '{"mode":"light","primaryColor":"#2563eb","accentColor":"#0f766e","backgroundColor":"#f8fafc","surfaceColor":"#ffffff","textColor":"#0f172a","mutedColor":"#64748b","borderRadius":8,"buttonRadius":8,"fontFamily":"system","layoutDensity":"comfortable"}'::JSONB, + active_public_assets JSONB NOT NULL DEFAULT '{"iconSet":"classic","shareCardStyle":"clean"}'::JSONB, + draft_template_code VARCHAR(64) REFERENCES education_tenant_theme_template(code) ON UPDATE CASCADE ON DELETE SET NULL, + draft_theme JSONB NOT NULL DEFAULT '{}'::JSONB, + draft_public_assets JSONB NOT NULL DEFAULT '{}'::JSONB, + theme_status VARCHAR(16) NOT NULL DEFAULT 'PUBLISHED', + published_time TIMESTAMP, + published_by BIGINT, + draft_updated_by BIGINT, + version INTEGER NOT NULL DEFAULT 0, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT false, + CONSTRAINT ck_education_tenant_appearance_status CHECK (theme_status IN ('DRAFT','PUBLISHED')), + CONSTRAINT ck_education_tenant_appearance_version CHECK (version >= 0), + CONSTRAINT ck_education_tenant_feature_flags_object CHECK (jsonb_typeof(feature_flags) = 'object'), + CONSTRAINT ck_education_tenant_admin_flags_object CHECK (jsonb_typeof(admin_feature_flags) = 'object'), + CONSTRAINT ck_education_tenant_public_config_object CHECK (jsonb_typeof(public_config) = 'object'), + CONSTRAINT ck_education_tenant_active_theme_object CHECK (jsonb_typeof(active_theme) = 'object'), + CONSTRAINT ck_education_tenant_active_assets_object CHECK (jsonb_typeof(active_public_assets) = 'object'), + CONSTRAINT ck_education_tenant_draft_theme_object CHECK (jsonb_typeof(draft_theme) = 'object'), + CONSTRAINT ck_education_tenant_draft_assets_object CHECK (jsonb_typeof(draft_public_assets) = 'object') +); +CREATE UNIQUE INDEX uk_education_tenant_appearance_tenant + ON education_tenant_appearance (tenant_id) WHERE deleted = false; + +DO $$ +DECLARE installed INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN RETURN; END IF; + INSERT INTO system_menu ( + id,name,permission,type,sort,parent_id,path,icon,component,component_name, + status,visible,keep_alive,always_show,creator,updater + ) VALUES + (6862,'租户外观','',2,12,6800,'tenant-appearance','ep:brush','education/tenant-appearance/index','EducationTenantAppearance',0,true,true,true,'education-flyway','education-flyway'), + (6863,'外观查询','education:tenant-appearance:query',3,1,6862,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6864,'品牌配置','education:tenant-appearance:branding',3,2,6862,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6865,'功能设置','education:tenant-appearance:settings',3,3,6862,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6866,'主题发布','education:tenant-appearance:theme',3,4,6862,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway') + ON CONFLICT (id) DO NOTHING; + + SELECT count(*) INTO installed FROM system_menu WHERE deleted=0 AND status=0 AND ( + (id=6862 AND type=2 AND parent_id=6800 AND component='education/tenant-appearance/index') + OR (id=6863 AND type=3 AND parent_id=6862 AND permission='education:tenant-appearance:query') + OR (id=6864 AND type=3 AND parent_id=6862 AND permission='education:tenant-appearance:branding') + OR (id=6865 AND type=3 AND parent_id=6862 AND permission='education:tenant-appearance:settings') + OR (id=6866 AND type=3 AND parent_id=6862 AND permission='education:tenant-appearance:theme') + ); + IF installed <> 5 THEN + RAISE EXCEPTION 'Education tenant appearance menu shape conflict' USING ERRCODE='23505'; + END IF; +END $$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4300__reuse_native_payment_and_social_admin.sql b/yudao-module-education/src/main/resources/db/migration/education/V4300__reuse_native_payment_and_social_admin.sql new file mode 100644 index 00000000..f8cccc6a --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4300__reuse_native_payment_and_social_admin.sql @@ -0,0 +1,49 @@ +-- Re-expose the native Pay application/channel and System social-client +-- administration below Education. These routes intentionally reuse the +-- existing controllers, permissions, data models, and Vben pages: Education +-- must not create shadow payment/auth-provider tables or copy credentials. +DO $$ +DECLARE installed INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN RETURN; END IF; + + INSERT INTO system_menu ( + id,name,permission,type,sort,parent_id,path,icon,component,component_name, + status,visible,keep_alive,always_show,creator,updater + ) VALUES + (6867,'支付配置','',2,13,6800,'pay-configuration','lucide:credit-card','pay/app/index','EducationPayConfiguration',0,true,true,true,'education-flyway','education-flyway'), + (6868,'支付应用查询','pay:app:query',3,1,6867,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6869,'支付应用创建','pay:app:create',3,2,6867,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6870,'支付应用修改','pay:app:update',3,3,6867,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6871,'支付应用删除','pay:app:delete',3,4,6867,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6872,'支付渠道查询','pay:channel:query',3,5,6867,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6873,'支付渠道创建','pay:channel:create',3,6,6867,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6874,'支付渠道修改','pay:channel:update',3,7,6867,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6875,'支付渠道删除','pay:channel:delete',3,8,6867,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6876,'第三方登录','',2,14,6800,'social-provider','lucide:log-in','system/social/client/index.vue','EducationSocialProvider',0,true,true,true,'education-flyway','education-flyway'), + (6877,'三方应用查询','system:social-client:query',3,1,6876,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6878,'三方应用创建','system:social-client:create',3,2,6876,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6879,'三方应用修改','system:social-client:update',3,3,6876,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6880,'三方应用删除','system:social-client:delete',3,4,6876,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway') + ON CONFLICT (id) DO NOTHING; + + SELECT count(*) INTO installed FROM system_menu WHERE deleted=0 AND status=0 AND ( + (id=6867 AND type=2 AND parent_id=6800 AND component='pay/app/index' AND component_name='EducationPayConfiguration') + OR (id=6868 AND type=3 AND parent_id=6867 AND permission='pay:app:query') + OR (id=6869 AND type=3 AND parent_id=6867 AND permission='pay:app:create') + OR (id=6870 AND type=3 AND parent_id=6867 AND permission='pay:app:update') + OR (id=6871 AND type=3 AND parent_id=6867 AND permission='pay:app:delete') + OR (id=6872 AND type=3 AND parent_id=6867 AND permission='pay:channel:query') + OR (id=6873 AND type=3 AND parent_id=6867 AND permission='pay:channel:create') + OR (id=6874 AND type=3 AND parent_id=6867 AND permission='pay:channel:update') + OR (id=6875 AND type=3 AND parent_id=6867 AND permission='pay:channel:delete') + OR (id=6876 AND type=2 AND parent_id=6800 AND component='system/social/client/index.vue' AND component_name='EducationSocialProvider') + OR (id=6877 AND type=3 AND parent_id=6876 AND permission='system:social-client:query') + OR (id=6878 AND type=3 AND parent_id=6876 AND permission='system:social-client:create') + OR (id=6879 AND type=3 AND parent_id=6876 AND permission='system:social-client:update') + OR (id=6880 AND type=3 AND parent_id=6876 AND permission='system:social-client:delete') + ); + IF installed <> 14 THEN + RAISE EXCEPTION 'Education native integration menu shape conflict' USING ERRCODE='23505'; + END IF; +END $$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4310__add_activation_codes.sql b/yudao-module-education/src/main/resources/db/migration/education/V4310__add_activation_codes.sql new file mode 100644 index 00000000..8b625284 --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4310__add_activation_codes.sql @@ -0,0 +1,75 @@ +-- Secure, one-time learning activation codes. Products remain Mall-owned and +-- grants reuse Education's idempotent entitlement event pipeline. +CREATE TABLE education_activation_code_batch ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + name VARCHAR(120) NOT NULL, + product_spu_id BIGINT NOT NULL, + duration_days INTEGER NOT NULL DEFAULT 0, + code_prefix VARCHAR(12) NOT NULL DEFAULT '', + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + total_count INTEGER NOT NULL DEFAULT 0, + redeemed_count INTEGER NOT NULL DEFAULT 0, + version INTEGER NOT NULL DEFAULT 0, + creator VARCHAR(64) DEFAULT '', create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) DEFAULT '', update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT false, + CONSTRAINT uk_education_activation_batch_tenant_id UNIQUE (tenant_id,id), + CONSTRAINT fk_education_activation_batch_product FOREIGN KEY (tenant_id,product_spu_id) + REFERENCES education_resource_product_binding(tenant_id,product_spu_id), + CONSTRAINT ck_education_activation_batch_duration CHECK (duration_days BETWEEN 0 AND 3650), + CONSTRAINT ck_education_activation_batch_prefix CHECK (code_prefix ~ '^[A-Z0-9]{0,12}$'), + CONSTRAINT ck_education_activation_batch_status CHECK (status IN ('ACTIVE','DISABLED')), + CONSTRAINT ck_education_activation_batch_counts CHECK (total_count>=0 AND redeemed_count>=0 AND redeemed_count<=total_count), + CONSTRAINT ck_education_activation_batch_version CHECK (version>=0) +); +CREATE INDEX idx_education_activation_batch_page ON education_activation_code_batch(tenant_id,status,id DESC) WHERE deleted=false; + +CREATE TABLE education_activation_code ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + batch_id BIGINT NOT NULL, + code_hash CHAR(64) NOT NULL, + code_masked VARCHAR(32) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE', + redeemed_by BIGINT, + redeemed_at TIMESTAMP, + entitlement_id BIGINT, + version INTEGER NOT NULL DEFAULT 0, + creator VARCHAR(64) DEFAULT '', create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) DEFAULT '', update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT false, + CONSTRAINT fk_education_activation_code_batch FOREIGN KEY (tenant_id,batch_id) + REFERENCES education_activation_code_batch(tenant_id,id), + CONSTRAINT fk_education_activation_code_entitlement FOREIGN KEY (entitlement_id) + REFERENCES education_entitlement(id), + CONSTRAINT ck_education_activation_code_hash CHECK (code_hash ~ '^[0-9a-f]{64}$'), + CONSTRAINT ck_education_activation_code_status CHECK (status IN ('AVAILABLE','REDEEMED','DISABLED')), + CONSTRAINT ck_education_activation_code_redemption CHECK ( + (status='REDEEMED' AND redeemed_by IS NOT NULL AND redeemed_at IS NOT NULL AND entitlement_id IS NOT NULL) + OR (status<>'REDEEMED' AND redeemed_by IS NULL AND redeemed_at IS NULL AND entitlement_id IS NULL) + ), + CONSTRAINT ck_education_activation_code_version CHECK (version>=0) +); +CREATE UNIQUE INDEX uk_education_activation_code_hash ON education_activation_code(tenant_id,code_hash) WHERE deleted=false; +CREATE INDEX idx_education_activation_code_page ON education_activation_code(tenant_id,batch_id,status,id DESC) WHERE deleted=false; + +DO $$ +DECLARE installed INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN RETURN; END IF; + INSERT INTO system_menu (id,name,permission,type,sort,parent_id,path,icon,component,component_name, + status,visible,keep_alive,always_show,creator,updater) + VALUES + (6881,'学习激活码','',2,15,6800,'activation-code','lucide:key-round','education/activation-code/index','EducationActivationCode',0,true,true,true,'education-flyway','education-flyway'), + (6882,'激活码查询','education:activation-code:query',3,1,6881,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6883,'激活码管理','education:activation-code:manage',3,2,6881,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6884,'激活码生成','education:activation-code:generate',3,3,6881,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway') + ON CONFLICT(id) DO NOTHING; + SELECT count(*) INTO installed FROM system_menu WHERE deleted=0 AND status=0 AND ( + (id=6881 AND type=2 AND parent_id=6800 AND component='education/activation-code/index') + OR (id=6882 AND type=3 AND parent_id=6881 AND permission='education:activation-code:query') + OR (id=6883 AND type=3 AND parent_id=6881 AND permission='education:activation-code:manage') + OR (id=6884 AND type=3 AND parent_id=6881 AND permission='education:activation-code:generate')); + IF installed<>4 THEN RAISE EXCEPTION 'Education activation code menu shape conflict' USING ERRCODE='23505'; END IF; +END $$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4320__activate_native_pay_admin.sql b/yudao-module-education/src/main/resources/db/migration/education/V4320__activate_native_pay_admin.sql new file mode 100644 index 00000000..b4da4496 --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4320__activate_native_pay_admin.sql @@ -0,0 +1,87 @@ +-- Activate the native Pay App/Channel administration introduced by V4300. +-- Both aggregates are tenant-owned. Existing stock/global Pay tables cannot be +-- adopted without an explicit tenant mapping, so the migration fails closed +-- instead of assigning credentials to an arbitrary tenant. +DO $$ +BEGIN + IF to_regclass('pay_app') IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=current_schema() AND table_name='pay_app' AND column_name='tenant_id' + ) THEN + RAISE EXCEPTION 'Existing pay_app requires explicit tenant mapping before V4320' + USING ERRCODE='23514'; + END IF; + IF to_regclass('pay_channel') IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=current_schema() AND table_name='pay_channel' AND column_name='tenant_id' + ) THEN + RAISE EXCEPTION 'Existing pay_channel requires explicit tenant mapping before V4320' + USING ERRCODE='23514'; + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS pay_app ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + app_key VARCHAR(64) NOT NULL, + name VARCHAR(64) NOT NULL, + status SMALLINT NOT NULL, + remark VARCHAR(255), + order_notify_url VARCHAR(1024) NOT NULL, + refund_notify_url VARCHAR(1024) NOT NULL, + transfer_notify_url VARCHAR(1024), + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT ck_pay_app_status CHECK (status IN (0,1)) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_app_tenant_key_active + ON pay_app (tenant_id,app_key) WHERE deleted=false; +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_app_tenant_id + ON pay_app (tenant_id,id); +CREATE INDEX IF NOT EXISTS idx_pay_app_tenant_status + ON pay_app (tenant_id,status,id DESC) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS pay_channel ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + code VARCHAR(32) NOT NULL, + status SMALLINT NOT NULL, + fee_rate DOUBLE PRECISION NOT NULL DEFAULT 0, + remark VARCHAR(255), + app_id BIGINT NOT NULL, + config TEXT NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT ck_pay_channel_status CHECK (status IN (0,1)), + CONSTRAINT ck_pay_channel_fee_rate CHECK (fee_rate >= 0) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_channel_tenant_app_code_active + ON pay_channel (tenant_id,app_id,code) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_pay_channel_tenant_app + ON pay_channel (tenant_id,app_id,id DESC) WHERE deleted=false; + +DO $$ +DECLARE valid_shape INTEGER; +BEGIN + SELECT count(*) INTO valid_shape + FROM information_schema.columns + WHERE table_schema=current_schema() AND ( + (table_name='pay_app' AND column_name IN + ('id','tenant_id','app_key','name','status','remark','order_notify_url','refund_notify_url', + 'transfer_notify_url','creator','create_time','updater','update_time','deleted')) + OR (table_name='pay_channel' AND column_name IN + ('id','tenant_id','code','status','fee_rate','remark','app_id','config', + 'creator','create_time','updater','update_time','deleted')) + ); + IF valid_shape <> 27 THEN + RAISE EXCEPTION 'Education native Pay table shape conflict' USING ERRCODE='23514'; + END IF; +END $$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4330__add_legacy_pay_account_import_audit.sql b/yudao-module-education/src/main/resources/db/migration/education/V4330__add_legacy_pay_account_import_audit.sql new file mode 100644 index 00000000..becf332b --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4330__add_legacy_pay_account_import_audit.sql @@ -0,0 +1,86 @@ +-- Audited bridge from legacy tenant_payment_accounts/app_private.tenant_secrets +-- into native tenant-owned Pay App/Channel records. The bridge stores no raw +-- public configuration or credential value; only source and normalized digests. +DO $$ +BEGIN + IF to_regclass('pay_app') IS NULL OR to_regclass('pay_channel') IS NULL THEN + RAISE EXCEPTION 'V4330 requires tenant-scoped pay_app and pay_channel from V4320' + USING ERRCODE='23514'; + END IF; + IF to_regclass('pay_legacy_account_import') IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=current_schema() AND table_name='pay_legacy_account_import' AND column_name='tenant_id' + ) THEN + RAISE EXCEPTION 'Existing pay_legacy_account_import requires explicit tenant mapping before V4330' + USING ERRCODE='23514'; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_channel_tenant_id + ON pay_channel (tenant_id,id); + +CREATE TABLE IF NOT EXISTS pay_legacy_account_import ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + source_tenant_id UUID NOT NULL, + source_account_id UUID NOT NULL, + source_checksum_sha256 CHAR(64) NOT NULL, + source_provider VARCHAR(32) NOT NULL, + normalized_provider VARCHAR(16) NOT NULL, + source_mode VARCHAR(32) NOT NULL, + source_status VARCHAR(16) NOT NULL, + target_app_id BIGINT NOT NULL, + target_channel_id BIGINT NOT NULL, + target_channel_code VARCHAR(32) NOT NULL, + target_status SMALLINT NOT NULL, + normalized_config_digest CHAR(64) NOT NULL, + mapping_notes JSONB NOT NULL DEFAULT '[]'::jsonb, + imported_by BIGINT NOT NULL, + imported_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT ck_pay_legacy_import_source_checksum + CHECK (source_checksum_sha256 ~ '^[0-9a-f]{64}$'), + CONSTRAINT ck_pay_legacy_import_config_digest + CHECK (normalized_config_digest ~ '^[0-9a-f]{64}$'), + CONSTRAINT ck_pay_legacy_import_provider + CHECK (normalized_provider IN ('wechat_pay','alipay')), + CONSTRAINT ck_pay_legacy_import_mode + CHECK (source_mode='tenant_collect'), + CONSTRAINT ck_pay_legacy_import_source_status + CHECK (source_status IN ('active','disabled','pending')), + CONSTRAINT ck_pay_legacy_import_target_status + CHECK (target_status IN (0,1)), + CONSTRAINT ck_pay_legacy_import_notes + CHECK (jsonb_typeof(mapping_notes)='array'), + CONSTRAINT fk_pay_legacy_import_app + FOREIGN KEY (tenant_id,target_app_id) REFERENCES pay_app (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_pay_legacy_import_channel + FOREIGN KEY (tenant_id,target_channel_id) REFERENCES pay_channel (tenant_id,id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_legacy_import_tenant_source + ON pay_legacy_account_import (tenant_id,source_account_id) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_pay_legacy_import_tenant_time + ON pay_legacy_account_import (tenant_id,imported_at DESC,id DESC) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_pay_legacy_import_tenant_provider + ON pay_legacy_account_import (tenant_id,normalized_provider,id DESC) WHERE deleted=false; + +DO $$ +DECLARE valid_shape INTEGER; +BEGIN + SELECT count(*) INTO valid_shape + FROM information_schema.columns + WHERE table_schema=current_schema() AND table_name='pay_legacy_account_import' AND column_name IN ( + 'id','tenant_id','source_tenant_id','source_account_id','source_checksum_sha256','source_provider', + 'normalized_provider','source_mode','source_status','target_app_id','target_channel_id', + 'target_channel_code','target_status','normalized_config_digest','mapping_notes','imported_by','imported_at', + 'creator','create_time','updater','update_time','deleted' + ); + IF valid_shape <> 22 THEN + RAISE EXCEPTION 'Pay legacy account import audit table shape conflict' USING ERRCODE='23514'; + END IF; +END $$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4340__activate_native_pay_transactions.sql b/yudao-module-education/src/main/resources/db/migration/education/V4340__activate_native_pay_transactions.sql new file mode 100644 index 00000000..8696bad9 --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4340__activate_native_pay_transactions.sql @@ -0,0 +1,318 @@ +-- Activate the tenant-scoped native Pay order/refund/notify runtime and expose +-- its existing Vben administration pages under Education. Existing global +-- transaction tables are never assigned to a guessed tenant. +DO $$ +DECLARE + target_table TEXT; +BEGIN + IF to_regclass('pay_app') IS NULL OR to_regclass('pay_channel') IS NULL THEN + RAISE EXCEPTION 'V4340 requires tenant-scoped pay_app and pay_channel from V4320' + USING ERRCODE='23514'; + END IF; + FOREACH target_table IN ARRAY ARRAY[ + 'pay_order','pay_order_extension','pay_refund','pay_notify_task','pay_notify_log' + ] LOOP + IF to_regclass(target_table) IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns c + WHERE c.table_schema=current_schema() AND c.table_name=target_table AND c.column_name='tenant_id' + ) THEN + RAISE EXCEPTION 'Existing % requires explicit tenant mapping before V4340', target_table + USING ERRCODE='23514'; + END IF; + END LOOP; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_channel_tenant_app_id + ON pay_channel (tenant_id,app_id,id); + +CREATE TABLE IF NOT EXISTS pay_order ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + app_id BIGINT NOT NULL, + channel_id BIGINT, + channel_code VARCHAR(32), + user_id BIGINT, + user_type SMALLINT, + merchant_order_id VARCHAR(64) NOT NULL, + subject VARCHAR(32) NOT NULL, + body VARCHAR(128), + notify_url VARCHAR(1024) NOT NULL, + price INTEGER NOT NULL, + channel_fee_rate DOUBLE PRECISION NOT NULL DEFAULT 0, + channel_fee_price INTEGER NOT NULL DEFAULT 0, + status SMALLINT NOT NULL, + user_ip VARCHAR(50) NOT NULL, + expire_time TIMESTAMP NOT NULL, + success_time TIMESTAMP, + extension_id BIGINT, + no VARCHAR(64), + refund_price INTEGER NOT NULL DEFAULT 0, + channel_user_id VARCHAR(255), + channel_order_no VARCHAR(64), + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT ck_pay_order_status CHECK (status IN (0,10,20,30)), + CONSTRAINT ck_pay_order_price CHECK (price > 0), + CONSTRAINT ck_pay_order_refund_price CHECK (refund_price >= 0 AND refund_price <= price), + CONSTRAINT ck_pay_order_channel_fee CHECK ( + channel_fee_rate >= 0 AND channel_fee_price >= 0 AND channel_fee_price <= price), + CONSTRAINT fk_pay_order_app + FOREIGN KEY (tenant_id,app_id) REFERENCES pay_app (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_pay_order_channel + FOREIGN KEY (tenant_id,app_id,channel_id) REFERENCES pay_channel (tenant_id,app_id,id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_order_tenant_id + ON pay_order (tenant_id,id); +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_order_tenant_app_id + ON pay_order (tenant_id,app_id,id); +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_order_tenant_merchant_active + ON pay_order (tenant_id,app_id,merchant_order_id) WHERE deleted=false; +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_order_tenant_no_active + ON pay_order (tenant_id,no) WHERE deleted=false AND no IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_pay_order_tenant_status_time + ON pay_order (tenant_id,status,create_time DESC,id DESC) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS pay_order_extension ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + no VARCHAR(64) NOT NULL, + order_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + channel_code VARCHAR(32) NOT NULL, + user_ip VARCHAR(50), + status SMALLINT NOT NULL, + channel_extras TEXT, + channel_error_code VARCHAR(128), + channel_error_msg VARCHAR(256), + channel_notify_data TEXT, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT ck_pay_order_extension_status CHECK (status IN (0,10,20,30)), + CONSTRAINT fk_pay_order_extension_order + FOREIGN KEY (tenant_id,order_id) REFERENCES pay_order (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_pay_order_extension_channel + FOREIGN KEY (tenant_id,channel_id) REFERENCES pay_channel (tenant_id,id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_order_extension_tenant_id + ON pay_order_extension (tenant_id,id); +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_order_extension_tenant_no_active + ON pay_order_extension (tenant_id,no) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_pay_order_extension_tenant_order + ON pay_order_extension (tenant_id,order_id,id DESC) WHERE deleted=false; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid='pay_order'::regclass AND conname='fk_pay_order_extension' + ) THEN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint constraint_definition + WHERE constraint_definition.conrelid='pay_order'::regclass + AND constraint_definition.conname='fk_pay_order_extension' + AND constraint_definition.contype='f' + AND constraint_definition.confrelid='pay_order_extension'::regclass + AND constraint_definition.conkey=ARRAY[ + (SELECT attnum::SMALLINT FROM pg_attribute + WHERE attrelid='pay_order'::regclass AND attname='tenant_id'), + (SELECT attnum::SMALLINT FROM pg_attribute + WHERE attrelid='pay_order'::regclass AND attname='extension_id')] + AND constraint_definition.confkey=ARRAY[ + (SELECT attnum::SMALLINT FROM pg_attribute + WHERE attrelid='pay_order_extension'::regclass AND attname='tenant_id'), + (SELECT attnum::SMALLINT FROM pg_attribute + WHERE attrelid='pay_order_extension'::regclass AND attname='id')] + AND constraint_definition.confdeltype='r' + ) THEN + RAISE EXCEPTION 'Existing fk_pay_order_extension shape conflict' USING ERRCODE='23514'; + END IF; + ELSE + ALTER TABLE pay_order + ADD CONSTRAINT fk_pay_order_extension + FOREIGN KEY (tenant_id,extension_id) + REFERENCES pay_order_extension (tenant_id,id) ON DELETE RESTRICT; + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS pay_refund ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + no VARCHAR(64) NOT NULL, + app_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + channel_code VARCHAR(32) NOT NULL, + order_id BIGINT NOT NULL, + order_no VARCHAR(64) NOT NULL, + user_id BIGINT, + user_type SMALLINT, + merchant_order_id VARCHAR(64) NOT NULL, + merchant_refund_id VARCHAR(64) NOT NULL, + notify_url VARCHAR(1024) NOT NULL, + status SMALLINT NOT NULL, + pay_price INTEGER NOT NULL, + refund_price INTEGER NOT NULL, + reason VARCHAR(128) NOT NULL, + user_ip VARCHAR(50), + channel_order_no VARCHAR(64) NOT NULL, + channel_refund_no VARCHAR(64), + success_time TIMESTAMP, + channel_error_code VARCHAR(128), + channel_error_msg VARCHAR(256), + channel_notify_data TEXT, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT ck_pay_refund_status CHECK (status IN (0,10,20)), + CONSTRAINT ck_pay_refund_price CHECK (pay_price > 0 AND refund_price > 0 AND refund_price <= pay_price), + CONSTRAINT fk_pay_refund_app + FOREIGN KEY (tenant_id,app_id) REFERENCES pay_app (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_pay_refund_channel + FOREIGN KEY (tenant_id,app_id,channel_id) REFERENCES pay_channel (tenant_id,app_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_pay_refund_order + FOREIGN KEY (tenant_id,app_id,order_id) REFERENCES pay_order (tenant_id,app_id,id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_refund_tenant_id + ON pay_refund (tenant_id,id); +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_refund_tenant_no_active + ON pay_refund (tenant_id,no) WHERE deleted=false; +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_refund_tenant_merchant_active + ON pay_refund (tenant_id,app_id,merchant_refund_id) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_pay_refund_tenant_order + ON pay_refund (tenant_id,order_id,id DESC) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_pay_refund_tenant_status_time + ON pay_refund (tenant_id,status,create_time DESC,id DESC) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS pay_notify_task ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + app_id BIGINT NOT NULL, + type SMALLINT NOT NULL, + data_id BIGINT NOT NULL, + merchant_order_id VARCHAR(64), + merchant_refund_id VARCHAR(64), + merchant_transfer_id VARCHAR(64), + status SMALLINT NOT NULL, + next_notify_time TIMESTAMP, + last_execute_time TIMESTAMP, + notify_times INTEGER NOT NULL DEFAULT 0, + max_notify_times INTEGER NOT NULL, + notify_url VARCHAR(1024) NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT ck_pay_notify_task_type CHECK (type IN (1,2,3)), + CONSTRAINT ck_pay_notify_task_status CHECK (status IN (0,10,20,21,22)), + CONSTRAINT ck_pay_notify_task_attempts CHECK ( + notify_times >= 0 AND max_notify_times > 0 AND notify_times <= max_notify_times), + CONSTRAINT fk_pay_notify_task_app + FOREIGN KEY (tenant_id,app_id) REFERENCES pay_app (tenant_id,id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_notify_task_tenant_id + ON pay_notify_task (tenant_id,id); +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_notify_task_tenant_data_active + ON pay_notify_task (tenant_id,type,data_id) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_pay_notify_task_tenant_due + ON pay_notify_task (tenant_id,status,next_notify_time,id) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS pay_notify_log ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + task_id BIGINT NOT NULL, + notify_times INTEGER NOT NULL, + response TEXT NOT NULL DEFAULT '', + status SMALLINT NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT ck_pay_notify_log_status CHECK (status IN (0,10,20,21,22)), + CONSTRAINT ck_pay_notify_log_attempt CHECK (notify_times > 0), + CONSTRAINT fk_pay_notify_log_task + FOREIGN KEY (tenant_id,task_id) REFERENCES pay_notify_task (tenant_id,id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_pay_notify_log_tenant_task + ON pay_notify_log (tenant_id,task_id,notify_times,id) WHERE deleted=false; + +DO $$ +DECLARE valid_shape INTEGER; +BEGIN + SELECT count(*) INTO valid_shape + FROM information_schema.columns c + WHERE c.table_schema=current_schema() AND ( + (c.table_name='pay_order' AND c.column_name IN + ('id','tenant_id','app_id','channel_id','channel_code','user_id','user_type','merchant_order_id', + 'subject','body','notify_url','price','channel_fee_rate','channel_fee_price','status','user_ip', + 'expire_time','success_time','extension_id','no','refund_price','channel_user_id','channel_order_no', + 'creator','create_time','updater','update_time','deleted')) + OR (c.table_name='pay_order_extension' AND c.column_name IN + ('id','tenant_id','no','order_id','channel_id','channel_code','user_ip','status','channel_extras', + 'channel_error_code','channel_error_msg','channel_notify_data','creator','create_time','updater', + 'update_time','deleted')) + OR (c.table_name='pay_refund' AND c.column_name IN + ('id','tenant_id','no','app_id','channel_id','channel_code','order_id','order_no','user_id','user_type', + 'merchant_order_id','merchant_refund_id','notify_url','status','pay_price','refund_price','reason', + 'user_ip','channel_order_no','channel_refund_no','success_time','channel_error_code', + 'channel_error_msg','channel_notify_data','creator','create_time','updater','update_time','deleted')) + OR (c.table_name='pay_notify_task' AND c.column_name IN + ('id','tenant_id','app_id','type','data_id','merchant_order_id','merchant_refund_id', + 'merchant_transfer_id','status','next_notify_time','last_execute_time','notify_times', + 'max_notify_times','notify_url','creator','create_time','updater','update_time','deleted')) + OR (c.table_name='pay_notify_log' AND c.column_name IN + ('id','tenant_id','task_id','notify_times','response','status','creator','create_time','updater', + 'update_time','deleted')) + ); + IF valid_shape <> 104 THEN + RAISE EXCEPTION 'Native Pay transaction table shape conflict' USING ERRCODE='23514'; + END IF; +END $$; + +DO $$ +DECLARE installed INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN RETURN; END IF; + + INSERT INTO system_menu ( + id,name,permission,type,sort,parent_id,path,icon,component,component_name, + status,visible,keep_alive,always_show,creator,updater + ) VALUES + (6890,'支付订单','',2,16,6800,'pay-orders','lucide:receipt','pay/order/index','EducationPayOrders',0,true,true,true,'education-flyway','education-flyway'), + (6891,'支付订单查询','pay:order:query',3,1,6890,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6892,'支付订单导出','pay:order:export',3,2,6890,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6893,'退款订单','',2,17,6800,'pay-refunds','lucide:undo-2','pay/refund/index','EducationPayRefunds',0,true,true,true,'education-flyway','education-flyway'), + (6894,'退款订单查询','pay:refund:query',3,1,6893,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6895,'退款订单导出','pay:refund:export',3,2,6893,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6896,'支付通知','',2,18,6800,'pay-notify','lucide:webhook','pay/notify/index','EducationPayNotify',0,true,true,true,'education-flyway','education-flyway'), + (6897,'支付通知查询','pay:notify:query',3,1,6896,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway') + ON CONFLICT (id) DO NOTHING; + + SELECT count(*) INTO installed FROM system_menu WHERE deleted=0 AND status=0 AND ( + (id=6890 AND type=2 AND parent_id=6800 AND component='pay/order/index' AND component_name='EducationPayOrders') + OR (id=6891 AND type=3 AND parent_id=6890 AND permission='pay:order:query') + OR (id=6892 AND type=3 AND parent_id=6890 AND permission='pay:order:export') + OR (id=6893 AND type=2 AND parent_id=6800 AND component='pay/refund/index' AND component_name='EducationPayRefunds') + OR (id=6894 AND type=3 AND parent_id=6893 AND permission='pay:refund:query') + OR (id=6895 AND type=3 AND parent_id=6893 AND permission='pay:refund:export') + OR (id=6896 AND type=2 AND parent_id=6800 AND component='pay/notify/index' AND component_name='EducationPayNotify') + OR (id=6897 AND type=3 AND parent_id=6896 AND permission='pay:notify:query') + ); + IF installed <> 8 THEN + RAISE EXCEPTION 'Education native Pay transaction menu shape conflict' USING ERRCODE='23505'; + END IF; +END $$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4350__add_legacy_pay_transaction_import.sql b/yudao-module-education/src/main/resources/db/migration/education/V4350__add_legacy_pay_transaction_import.sql new file mode 100644 index 00000000..103ef119 --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4350__add_legacy_pay_transaction_import.sql @@ -0,0 +1,188 @@ +-- Audited, terminal-only bridge from the legacy orders/payments/payment_events/ +-- commerce_refund_requests aggregate into native Pay. Raw payloads and provider +-- credentials are deliberately excluded from every audit table. +DO $$ +DECLARE target_table TEXT; +BEGIN + FOREACH target_table IN ARRAY ARRAY[ + 'pay_legacy_transaction_import', + 'pay_legacy_transaction_payment_import', + 'pay_legacy_transaction_refund_import' + ] LOOP + IF to_regclass(target_table) IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=current_schema() AND table_name=target_table AND column_name='tenant_id' + ) THEN + RAISE EXCEPTION 'Existing % requires explicit tenant mapping before V4350', target_table + USING ERRCODE='23514'; + END IF; + END LOOP; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_legacy_account_import_tenant_id + ON pay_legacy_account_import (tenant_id,id); + +CREATE TABLE IF NOT EXISTS pay_legacy_transaction_import ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + source_tenant_id UUID NOT NULL, + source_order_id UUID NOT NULL, + source_checksum_sha256 CHAR(64) NOT NULL, + source_order_no VARCHAR(64) NOT NULL, + source_order_status VARCHAR(32) NOT NULL, + source_payment_count INTEGER NOT NULL, + source_payment_event_count INTEGER NOT NULL, + source_refund_count INTEGER NOT NULL, + source_refunded_price INTEGER NOT NULL, + source_account_import_id BIGINT NOT NULL, + target_app_id BIGINT NOT NULL, + target_channel_id BIGINT NOT NULL, + target_order_id BIGINT NOT NULL, + normalized_payload_digest CHAR(64) NOT NULL, + mapping_notes JSONB NOT NULL DEFAULT '[]'::jsonb, + imported_by BIGINT NOT NULL, + imported_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT ck_pay_legacy_transaction_checksum + CHECK (source_checksum_sha256 ~ '^[0-9a-f]{64}$'), + CONSTRAINT ck_pay_legacy_transaction_digest + CHECK (normalized_payload_digest ~ '^[0-9a-f]{64}$'), + CONSTRAINT ck_pay_legacy_transaction_status + CHECK (source_order_status IN ('paid','failed','closed','partially_refunded','refunded')), + CONSTRAINT ck_pay_legacy_transaction_counts + CHECK (source_payment_count > 0 AND source_payment_event_count >= 0 AND source_refund_count >= 0), + CONSTRAINT ck_pay_legacy_transaction_refunded_price + CHECK (source_refunded_price >= 0), + CONSTRAINT ck_pay_legacy_transaction_notes + CHECK (jsonb_typeof(mapping_notes)='array'), + CONSTRAINT fk_pay_legacy_transaction_account_import + FOREIGN KEY (tenant_id,source_account_import_id) + REFERENCES pay_legacy_account_import (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_pay_legacy_transaction_app + FOREIGN KEY (tenant_id,target_app_id) REFERENCES pay_app (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_pay_legacy_transaction_channel + FOREIGN KEY (tenant_id,target_app_id,target_channel_id) + REFERENCES pay_channel (tenant_id,app_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_pay_legacy_transaction_order + FOREIGN KEY (tenant_id,target_app_id,target_order_id) + REFERENCES pay_order (tenant_id,app_id,id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_legacy_transaction_tenant_id + ON pay_legacy_transaction_import (tenant_id,id); +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_legacy_transaction_tenant_source + ON pay_legacy_transaction_import (tenant_id,source_order_id) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_pay_legacy_transaction_tenant_time + ON pay_legacy_transaction_import (tenant_id,imported_at DESC,id DESC) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS pay_legacy_transaction_payment_import ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + transaction_import_id BIGINT NOT NULL, + source_payment_id UUID NOT NULL, + source_status VARCHAR(32) NOT NULL, + source_provider VARCHAR(32) NOT NULL, + source_event_count INTEGER NOT NULL, + source_event_digest CHAR(64), + target_extension_id BIGINT NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT ck_pay_legacy_payment_status + CHECK (source_status IN ('paid','failed','cancelled','partially_refunded','refunded')), + CONSTRAINT ck_pay_legacy_payment_event_count CHECK (source_event_count >= 0), + CONSTRAINT ck_pay_legacy_payment_event_digest CHECK ( + (source_event_count=0 AND source_event_digest IS NULL) + OR (source_event_count>0 AND source_event_digest IS NOT NULL + AND source_event_digest ~ '^[0-9a-f]{64}$')), + CONSTRAINT fk_pay_legacy_payment_import + FOREIGN KEY (tenant_id,transaction_import_id) + REFERENCES pay_legacy_transaction_import (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_pay_legacy_payment_extension + FOREIGN KEY (tenant_id,target_extension_id) + REFERENCES pay_order_extension (tenant_id,id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_legacy_payment_tenant_source + ON pay_legacy_transaction_payment_import (tenant_id,source_payment_id) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_pay_legacy_payment_import + ON pay_legacy_transaction_payment_import (tenant_id,transaction_import_id,id) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS pay_legacy_transaction_refund_import ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + transaction_import_id BIGINT NOT NULL, + source_refund_id UUID NOT NULL, + source_status VARCHAR(32) NOT NULL, + target_refund_id BIGINT NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT ck_pay_legacy_refund_status + CHECK (source_status IN ('succeeded','failed','rejected','cancelled')), + CONSTRAINT fk_pay_legacy_refund_import + FOREIGN KEY (tenant_id,transaction_import_id) + REFERENCES pay_legacy_transaction_import (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_pay_legacy_refund_target + FOREIGN KEY (tenant_id,target_refund_id) REFERENCES pay_refund (tenant_id,id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_legacy_refund_tenant_source + ON pay_legacy_transaction_refund_import (tenant_id,source_refund_id) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_pay_legacy_refund_import + ON pay_legacy_transaction_refund_import (tenant_id,transaction_import_id,id) WHERE deleted=false; + +DO $$ +DECLARE valid_shape INTEGER; +BEGIN + SELECT count(*) INTO valid_shape + FROM information_schema.columns c + WHERE c.table_schema=current_schema() AND ( + (c.table_name='pay_legacy_transaction_import' AND c.column_name IN ( + 'id','tenant_id','source_tenant_id','source_order_id','source_checksum_sha256','source_order_no', + 'source_order_status','source_payment_count','source_payment_event_count','source_refund_count', + 'source_refunded_price','source_account_import_id','target_app_id','target_channel_id','target_order_id', + 'normalized_payload_digest','mapping_notes','imported_by','imported_at','creator','create_time','updater', + 'update_time','deleted')) + OR (c.table_name='pay_legacy_transaction_payment_import' AND c.column_name IN ( + 'id','tenant_id','transaction_import_id','source_payment_id','source_status','source_provider', + 'source_event_count','source_event_digest','target_extension_id','creator','create_time','updater', + 'update_time','deleted')) + OR (c.table_name='pay_legacy_transaction_refund_import' AND c.column_name IN ( + 'id','tenant_id','transaction_import_id','source_refund_id','source_status','target_refund_id', + 'creator','create_time','updater','update_time','deleted')) + ); + IF valid_shape <> 49 THEN + RAISE EXCEPTION 'Pay legacy transaction import table shape conflict' USING ERRCODE='23514'; + END IF; +END $$; + +DO $$ +DECLARE installed INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN RETURN; END IF; + + INSERT INTO system_menu ( + id,name,permission,type,sort,parent_id,path,icon,component,component_name, + status,visible,keep_alive,always_show,creator,updater + ) VALUES + (6898,'旧交易导入查询','pay:legacy-transaction:query',3,3,6890,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6899,'旧交易导入','pay:legacy-transaction:import',3,4,6890,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway') + ON CONFLICT (id) DO NOTHING; + + SELECT count(*) INTO installed FROM system_menu WHERE deleted=0 AND status=0 AND ( + (id=6898 AND type=3 AND parent_id=6890 AND permission='pay:legacy-transaction:query') + OR (id=6899 AND type=3 AND parent_id=6890 AND permission='pay:legacy-transaction:import') + ); + IF installed <> 2 THEN + RAISE EXCEPTION 'Education legacy Pay transaction import menu shape conflict' USING ERRCODE='23505'; + END IF; +END $$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4360__activate_native_pay_transfer_wallet.sql b/yudao-module-education/src/main/resources/db/migration/education/V4360__activate_native_pay_transfer_wallet.sql new file mode 100644 index 00000000..2c83cc76 --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4360__activate_native_pay_transfer_wallet.sql @@ -0,0 +1,280 @@ +-- Activate native Pay Transfer and Wallet as tenant-owned ledgers. The legacy +-- education backend has no equivalent wallet balance aggregate, so this +-- migration creates empty ledgers and never infers or imports historical data. +DO $$ +DECLARE target_table TEXT; +BEGIN + IF to_regclass('pay_app') IS NULL OR to_regclass('pay_channel') IS NULL + OR to_regclass('pay_order') IS NULL OR to_regclass('pay_refund') IS NULL THEN + RAISE EXCEPTION 'V4360 requires tenant-scoped native Pay runtime through V4340' + USING ERRCODE='23514'; + END IF; + FOREACH target_table IN ARRAY ARRAY[ + 'pay_transfer','pay_wallet','pay_wallet_transaction', + 'pay_wallet_recharge','pay_wallet_recharge_package' + ] LOOP + IF to_regclass(target_table) IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=current_schema() AND table_name=target_table AND column_name='tenant_id' + ) THEN + RAISE EXCEPTION 'Existing % requires explicit tenant mapping before V4360', target_table + USING ERRCODE='23514'; + END IF; + END LOOP; +END $$; + +CREATE TABLE IF NOT EXISTS pay_transfer ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + no VARCHAR(64) NOT NULL, + app_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + channel_code VARCHAR(32) NOT NULL, + user_id BIGINT, + user_type SMALLINT, + merchant_transfer_id VARCHAR(64) NOT NULL, + subject VARCHAR(256) NOT NULL, + price INTEGER NOT NULL, + user_account VARCHAR(256) NOT NULL, + user_name VARCHAR(64), + status SMALLINT NOT NULL, + success_time TIMESTAMP, + notify_url VARCHAR(1024), + user_ip VARCHAR(50), + channel_extras TEXT, + channel_transfer_no VARCHAR(64), + channel_error_code VARCHAR(128), + channel_error_msg VARCHAR(256), + channel_notify_data TEXT, + channel_package_info TEXT, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT ck_pay_transfer_status CHECK (status IN (0,5,10,20)), + CONSTRAINT ck_pay_transfer_price CHECK (price > 0), + CONSTRAINT ck_pay_transfer_user_type CHECK (user_type IS NULL OR user_type IN (1,2)), + CONSTRAINT fk_pay_transfer_app + FOREIGN KEY (tenant_id,app_id) REFERENCES pay_app (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_pay_transfer_channel + FOREIGN KEY (tenant_id,app_id,channel_id) + REFERENCES pay_channel (tenant_id,app_id,id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_transfer_tenant_id + ON pay_transfer (tenant_id,id); +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_transfer_tenant_no_active + ON pay_transfer (tenant_id,no) WHERE deleted=false; +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_transfer_tenant_merchant_active + ON pay_transfer (tenant_id,app_id,merchant_transfer_id) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_pay_transfer_tenant_status_time + ON pay_transfer (tenant_id,status,create_time DESC,id DESC) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS pay_wallet ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + user_type SMALLINT NOT NULL, + balance INTEGER NOT NULL DEFAULT 0, + freeze_price INTEGER NOT NULL DEFAULT 0, + total_expense INTEGER NOT NULL DEFAULT 0, + total_recharge INTEGER NOT NULL DEFAULT 0, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT ck_pay_wallet_user CHECK (user_id > 0 AND user_type IN (1,2)), + CONSTRAINT ck_pay_wallet_amounts CHECK ( + balance >= 0 AND freeze_price >= 0 AND total_expense >= 0 AND total_recharge >= 0) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_wallet_tenant_id + ON pay_wallet (tenant_id,id); +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_wallet_tenant_user_active + ON pay_wallet (tenant_id,user_id,user_type) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_pay_wallet_tenant_time + ON pay_wallet (tenant_id,create_time DESC,id DESC) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS pay_wallet_recharge_package ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + name VARCHAR(64) NOT NULL, + pay_price INTEGER NOT NULL, + bonus_price INTEGER NOT NULL DEFAULT 0, + status SMALLINT NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT ck_pay_wallet_package_amount CHECK (pay_price > 0 AND bonus_price >= 0), + CONSTRAINT ck_pay_wallet_package_status CHECK (status IN (0,1)) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_wallet_package_tenant_id + ON pay_wallet_recharge_package (tenant_id,id); +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_wallet_package_tenant_name_active + ON pay_wallet_recharge_package (tenant_id,name) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_pay_wallet_package_tenant_status + ON pay_wallet_recharge_package (tenant_id,status,pay_price DESC,id DESC) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS pay_wallet_transaction ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + no VARCHAR(64) NOT NULL, + wallet_id BIGINT NOT NULL, + biz_type SMALLINT NOT NULL, + biz_id VARCHAR(64) NOT NULL, + title VARCHAR(128) NOT NULL, + price INTEGER NOT NULL, + balance INTEGER NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT ck_pay_wallet_transaction_type CHECK (biz_type IN (1,2,3,4,5,6)), + CONSTRAINT ck_pay_wallet_transaction_amount CHECK (price <> 0 AND balance >= 0), + CONSTRAINT fk_pay_wallet_transaction_wallet + FOREIGN KEY (tenant_id,wallet_id) REFERENCES pay_wallet (tenant_id,id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_wallet_transaction_tenant_no_active + ON pay_wallet_transaction (tenant_id,no) WHERE deleted=false; +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_wallet_transaction_tenant_biz_active + ON pay_wallet_transaction (tenant_id,biz_type,biz_id) + WHERE deleted=false AND biz_type<>5; +CREATE INDEX IF NOT EXISTS idx_pay_wallet_transaction_tenant_wallet_time + ON pay_wallet_transaction (tenant_id,wallet_id,create_time DESC,id DESC) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS pay_wallet_recharge ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + wallet_id BIGINT NOT NULL, + total_price INTEGER NOT NULL, + pay_price INTEGER NOT NULL, + bonus_price INTEGER NOT NULL DEFAULT 0, + package_id BIGINT, + pay_status BOOLEAN NOT NULL DEFAULT FALSE, + pay_order_id BIGINT, + pay_channel_code VARCHAR(32), + pay_time TIMESTAMP, + pay_refund_id BIGINT, + refund_total_price INTEGER, + refund_pay_price INTEGER, + refund_bonus_price INTEGER, + refund_time TIMESTAMP, + refund_status SMALLINT, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT ck_pay_wallet_recharge_amount CHECK ( + pay_price > 0 AND bonus_price >= 0 AND total_price=pay_price+bonus_price), + CONSTRAINT ck_pay_wallet_recharge_paid CHECK ( + (pay_status=false AND pay_time IS NULL) + OR (pay_status=true AND pay_order_id IS NOT NULL AND pay_time IS NOT NULL)), + CONSTRAINT ck_pay_wallet_recharge_refund_status CHECK ( + refund_status IS NULL OR refund_status IN (0,10,20)), + CONSTRAINT ck_pay_wallet_recharge_refund_amounts CHECK ( + (refund_total_price IS NULL OR refund_total_price >= 0) + AND (refund_pay_price IS NULL OR refund_pay_price >= 0) + AND (refund_bonus_price IS NULL OR refund_bonus_price >= 0)), + CONSTRAINT fk_pay_wallet_recharge_wallet + FOREIGN KEY (tenant_id,wallet_id) REFERENCES pay_wallet (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_pay_wallet_recharge_package + FOREIGN KEY (tenant_id,package_id) + REFERENCES pay_wallet_recharge_package (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_pay_wallet_recharge_order + FOREIGN KEY (tenant_id,pay_order_id) REFERENCES pay_order (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_pay_wallet_recharge_refund + FOREIGN KEY (tenant_id,pay_refund_id) REFERENCES pay_refund (tenant_id,id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_wallet_recharge_tenant_id + ON pay_wallet_recharge (tenant_id,id); +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_wallet_recharge_tenant_order_active + ON pay_wallet_recharge (tenant_id,pay_order_id) + WHERE deleted=false AND pay_order_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_wallet_recharge_tenant_refund_active + ON pay_wallet_recharge (tenant_id,pay_refund_id) + WHERE deleted=false AND pay_refund_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_pay_wallet_recharge_tenant_wallet_time + ON pay_wallet_recharge (tenant_id,wallet_id,create_time DESC,id DESC) WHERE deleted=false; + +DO $$ +DECLARE valid_shape INTEGER; +BEGIN + SELECT count(*) INTO valid_shape + FROM information_schema.columns c + WHERE c.table_schema=current_schema() AND ( + (c.table_name='pay_transfer' AND c.column_name IN ( + 'id','tenant_id','no','app_id','channel_id','channel_code','user_id','user_type', + 'merchant_transfer_id','subject','price','user_account','user_name','status','success_time', + 'notify_url','user_ip','channel_extras','channel_transfer_no','channel_error_code', + 'channel_error_msg','channel_notify_data','channel_package_info','creator','create_time', + 'updater','update_time','deleted')) + OR (c.table_name='pay_wallet' AND c.column_name IN ( + 'id','tenant_id','user_id','user_type','balance','freeze_price','total_expense','total_recharge', + 'creator','create_time','updater','update_time','deleted')) + OR (c.table_name='pay_wallet_transaction' AND c.column_name IN ( + 'id','tenant_id','no','wallet_id','biz_type','biz_id','title','price','balance', + 'creator','create_time','updater','update_time','deleted')) + OR (c.table_name='pay_wallet_recharge_package' AND c.column_name IN ( + 'id','tenant_id','name','pay_price','bonus_price','status', + 'creator','create_time','updater','update_time','deleted')) + OR (c.table_name='pay_wallet_recharge' AND c.column_name IN ( + 'id','tenant_id','wallet_id','total_price','pay_price','bonus_price','package_id','pay_status', + 'pay_order_id','pay_channel_code','pay_time','pay_refund_id','refund_total_price', + 'refund_pay_price','refund_bonus_price','refund_time','refund_status', + 'creator','create_time','updater','update_time','deleted')) + ); + IF valid_shape <> 88 THEN + RAISE EXCEPTION 'Native Pay Transfer/Wallet table shape conflict' USING ERRCODE='23514'; + END IF; +END $$; + +DO $$ +DECLARE installed INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN RETURN; END IF; + + INSERT INTO system_menu ( + id,name,permission,type,sort,parent_id,path,icon,component,component_name, + status,visible,keep_alive,always_show,creator,updater + ) VALUES + (6900,'转账订单','',2,19,6800,'pay-transfer','lucide:send','pay/transfer/index','EducationPayTransfer',0,true,true,true,'education-flyway','education-flyway'), + (6901,'转账订单查询','pay:transfer:query',3,1,6900,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6902,'转账订单导出','pay:transfer:export',3,2,6900,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6903,'钱包余额','',2,20,6800,'pay-wallet','lucide:wallet-cards','pay/wallet/balance/index','EducationPayWallet',0,true,true,true,'education-flyway','education-flyway'), + (6904,'钱包余额查询','pay:wallet:query',3,1,6903,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6905,'钱包余额修改','pay:wallet:update-balance',3,2,6903,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6906,'钱包充值套餐','',2,21,6800,'pay-wallet-package','lucide:badge-dollar-sign','pay/wallet/rechargePackage/index','EducationPayWalletPackage',0,true,true,true,'education-flyway','education-flyway'), + (6907,'钱包充值套餐查询','pay:wallet-recharge-package:query',3,1,6906,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6908,'钱包充值套餐创建','pay:wallet-recharge-package:create',3,2,6906,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6909,'钱包充值套餐更新','pay:wallet-recharge-package:update',3,3,6906,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6910,'钱包充值套餐删除','pay:wallet-recharge-package:delete',3,4,6906,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6911,'钱包充值退款','pay:wallet-recharge:refund',3,3,6903,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway') + ON CONFLICT (id) DO NOTHING; + + SELECT count(*) INTO installed FROM system_menu WHERE deleted=0 AND status=0 AND ( + (id=6900 AND type=2 AND parent_id=6800 AND component='pay/transfer/index' AND component_name='EducationPayTransfer') + OR (id=6901 AND type=3 AND parent_id=6900 AND permission='pay:transfer:query') + OR (id=6902 AND type=3 AND parent_id=6900 AND permission='pay:transfer:export') + OR (id=6903 AND type=2 AND parent_id=6800 AND component='pay/wallet/balance/index' AND component_name='EducationPayWallet') + OR (id=6904 AND type=3 AND parent_id=6903 AND permission='pay:wallet:query') + OR (id=6905 AND type=3 AND parent_id=6903 AND permission='pay:wallet:update-balance') + OR (id=6906 AND type=2 AND parent_id=6800 AND component='pay/wallet/rechargePackage/index' AND component_name='EducationPayWalletPackage') + OR (id=6907 AND type=3 AND parent_id=6906 AND permission='pay:wallet-recharge-package:query') + OR (id=6908 AND type=3 AND parent_id=6906 AND permission='pay:wallet-recharge-package:create') + OR (id=6909 AND type=3 AND parent_id=6906 AND permission='pay:wallet-recharge-package:update') + OR (id=6910 AND type=3 AND parent_id=6906 AND permission='pay:wallet-recharge-package:delete') + OR (id=6911 AND type=3 AND parent_id=6903 AND permission='pay:wallet-recharge:refund') + ); + IF installed <> 12 THEN + RAISE EXCEPTION 'Education native Pay Transfer/Wallet menu shape conflict' USING ERRCODE='23505'; + END IF; +END $$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4370__activate_native_mall_product.sql b/yudao-module-education/src/main/resources/db/migration/education/V4370__activate_native_mall_product.sql new file mode 100644 index 00000000..513bab0c --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4370__activate_native_mall_product.sql @@ -0,0 +1,404 @@ +-- Activate RuoYi Vue Pro's native Mall Product module for Education. Legacy +-- `products` rows are intentionally not converted here: they do not contain +-- enough information to infer native SPU/SKU, stock, brand, or properties. +DO $$ +DECLARE target_table TEXT; +BEGIN + FOREACH target_table IN ARRAY ARRAY[ + 'product_brand','product_category','product_property','product_property_value', + 'product_spu','product_sku','product_comment','product_favorite','product_browse_history' + ] LOOP + IF to_regclass(target_table) IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=current_schema() AND table_name=target_table AND column_name='tenant_id' + ) THEN + RAISE EXCEPTION 'Existing % requires explicit tenant mapping before V4370', target_table + USING ERRCODE='23514'; + END IF; + END LOOP; +END $$; + +CREATE TABLE IF NOT EXISTS product_brand ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME product_brand_seq), + tenant_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + pic_url VARCHAR(1024) NOT NULL, + sort INTEGER NOT NULL DEFAULT 0, + description VARCHAR(1024), + status SMALLINT NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_product_brand_sort CHECK (sort >= 0), + CONSTRAINT ck_product_brand_status CHECK (status IN (0,1)) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_product_brand_tenant_name_active + ON product_brand (tenant_id,name) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_product_brand_tenant_status_sort + ON product_brand (tenant_id,status,sort,id) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS product_category ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME product_category_seq), + tenant_id BIGINT NOT NULL, + parent_id BIGINT NOT NULL DEFAULT 0, + name VARCHAR(255) NOT NULL, + pic_url VARCHAR(1024) NOT NULL, + sort INTEGER NOT NULL DEFAULT 0, + status SMALLINT NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_product_category_parent CHECK (parent_id >= 0 AND parent_id <> id), + CONSTRAINT ck_product_category_sort CHECK (sort >= 0), + CONSTRAINT ck_product_category_status CHECK (status IN (0,1)) +); + +CREATE INDEX IF NOT EXISTS idx_product_category_tenant_parent_sort + ON product_category (tenant_id,parent_id,sort,id) WHERE deleted=false; + +CREATE OR REPLACE FUNCTION product_validate_category_parent_tenant() +RETURNS TRIGGER AS $$ +DECLARE parent_parent_id BIGINT; +BEGIN + IF NEW.parent_id=0 THEN + RETURN NEW; + END IF; + SELECT parent_id INTO parent_parent_id + FROM product_category + WHERE tenant_id=NEW.tenant_id AND id=NEW.parent_id AND deleted=false; + IF NOT FOUND THEN + RAISE EXCEPTION 'fk_product_category_parent: parent % does not exist in tenant %', + NEW.parent_id, NEW.tenant_id USING ERRCODE='23503'; + END IF; + IF parent_parent_id<>0 THEN + RAISE EXCEPTION 'ck_product_category_level: product categories support at most two levels' + USING ERRCODE='23514'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_product_category_parent_tenant ON product_category; +CREATE TRIGGER trg_product_category_parent_tenant +BEFORE INSERT OR UPDATE OF tenant_id,parent_id ON product_category +FOR EACH ROW EXECUTE FUNCTION product_validate_category_parent_tenant(); + +CREATE TABLE IF NOT EXISTS product_property ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME product_property_seq), + tenant_id BIGINT NOT NULL, + name VARCHAR(64) NOT NULL, + remark VARCHAR(255), + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_product_property_tenant_name_active + ON product_property (tenant_id,name) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS product_property_value ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME product_property_value_seq), + tenant_id BIGINT NOT NULL, + property_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + remark VARCHAR(255), + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT fk_product_property_value_property + FOREIGN KEY (tenant_id,property_id) + REFERENCES product_property (tenant_id,id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_product_property_value_tenant_name_active + ON product_property_value (tenant_id,property_id,name) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS product_spu ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME product_spu_seq), + tenant_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + keyword VARCHAR(256) NOT NULL, + introduction VARCHAR(256) NOT NULL, + description TEXT NOT NULL, + category_id BIGINT NOT NULL, + brand_id BIGINT NOT NULL, + pic_url VARCHAR(1024) NOT NULL, + slider_pic_urls TEXT, + sort INTEGER NOT NULL DEFAULT 0, + status SMALLINT NOT NULL DEFAULT 1, + spec_type BOOLEAN NOT NULL, + price INTEGER NOT NULL, + market_price INTEGER, + cost_price INTEGER, + stock INTEGER NOT NULL DEFAULT 0, + delivery_types VARCHAR(64) NOT NULL, + delivery_template_id BIGINT, + give_integral INTEGER NOT NULL DEFAULT 0, + sub_commission_type BOOLEAN NOT NULL DEFAULT FALSE, + sales_count INTEGER NOT NULL DEFAULT 0, + virtual_sales_count INTEGER NOT NULL DEFAULT 0, + browse_count INTEGER NOT NULL DEFAULT 0, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_product_spu_status CHECK (status IN (-1,0,1)), + CONSTRAINT ck_product_spu_sort CHECK (sort >= 0), + CONSTRAINT ck_product_spu_amounts CHECK ( + price >= 0 AND (market_price IS NULL OR market_price >= 0) + AND (cost_price IS NULL OR cost_price >= 0) AND stock >= 0 + AND give_integral >= 0 AND sales_count >= 0 + AND virtual_sales_count >= 0 AND browse_count >= 0), + CONSTRAINT fk_product_spu_category + FOREIGN KEY (tenant_id,category_id) + REFERENCES product_category (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_product_spu_brand + FOREIGN KEY (tenant_id,brand_id) + REFERENCES product_brand (tenant_id,id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_product_spu_tenant_status_category + ON product_spu (tenant_id,status,category_id,sort,id) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_product_spu_tenant_brand + ON product_spu (tenant_id,brand_id,id) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS product_sku ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME product_sku_seq), + tenant_id BIGINT NOT NULL, + spu_id BIGINT NOT NULL, + properties TEXT, + price INTEGER NOT NULL, + market_price INTEGER, + cost_price INTEGER, + bar_code VARCHAR(64), + pic_url VARCHAR(1024) NOT NULL, + stock INTEGER NOT NULL DEFAULT 0, + weight DOUBLE PRECISION, + volume DOUBLE PRECISION, + first_brokerage_price INTEGER, + second_brokerage_price INTEGER, + sales_count INTEGER NOT NULL DEFAULT 0, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT uk_product_sku_tenant_spu_id UNIQUE (tenant_id,spu_id,id), + CONSTRAINT ck_product_sku_amounts CHECK ( + price >= 0 AND (market_price IS NULL OR market_price >= 0) + AND (cost_price IS NULL OR cost_price >= 0) AND stock >= 0 + AND (weight IS NULL OR weight >= 0) AND (volume IS NULL OR volume >= 0) + AND (first_brokerage_price IS NULL OR first_brokerage_price >= 0) + AND (second_brokerage_price IS NULL OR second_brokerage_price >= 0) + AND sales_count >= 0), + CONSTRAINT fk_product_sku_spu + FOREIGN KEY (tenant_id,spu_id) REFERENCES product_spu (tenant_id,id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_product_sku_tenant_spu + ON product_sku (tenant_id,spu_id,id) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS product_comment ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME product_comment_seq), + tenant_id BIGINT NOT NULL, + user_id BIGINT, + user_nickname VARCHAR(255), + user_avatar VARCHAR(1024), + anonymous BOOLEAN NOT NULL DEFAULT FALSE, + order_id BIGINT, + order_item_id BIGINT, + spu_id BIGINT NOT NULL, + spu_name VARCHAR(255) NOT NULL, + sku_id BIGINT NOT NULL, + sku_pic_url VARCHAR(1024), + sku_properties TEXT, + visible BOOLEAN NOT NULL DEFAULT TRUE, + scores SMALLINT NOT NULL, + description_scores SMALLINT NOT NULL, + benefit_scores SMALLINT NOT NULL, + content VARCHAR(1024) NOT NULL, + pic_urls TEXT, + reply_status BOOLEAN NOT NULL DEFAULT FALSE, + reply_user_id BIGINT, + reply_content VARCHAR(1024), + reply_time TIMESTAMP, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_product_comment_scores CHECK ( + scores BETWEEN 1 AND 5 AND description_scores BETWEEN 1 AND 5 + AND benefit_scores BETWEEN 1 AND 5), + CONSTRAINT ck_product_comment_references CHECK ( + (user_id IS NULL OR user_id >= 0) AND (order_id IS NULL OR order_id > 0) + AND (order_item_id IS NULL OR order_item_id > 0)), + CONSTRAINT fk_product_comment_spu + FOREIGN KEY (tenant_id,spu_id) REFERENCES product_spu (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_product_comment_sku + FOREIGN KEY (tenant_id,spu_id,sku_id) + REFERENCES product_sku (tenant_id,spu_id,id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_product_comment_tenant_order_item_active + ON product_comment (tenant_id,user_id,order_item_id) + WHERE deleted=false AND user_id IS NOT NULL AND order_item_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_product_comment_tenant_spu_time + ON product_comment (tenant_id,spu_id,create_time DESC,id DESC) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS product_favorite ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME product_favorite_seq), + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + spu_id BIGINT NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_product_favorite_user CHECK (user_id > 0), + CONSTRAINT fk_product_favorite_spu + FOREIGN KEY (tenant_id,spu_id) REFERENCES product_spu (tenant_id,id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_product_favorite_tenant_user_spu_active + ON product_favorite (tenant_id,user_id,spu_id) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_product_favorite_tenant_user_time + ON product_favorite (tenant_id,user_id,create_time DESC,id DESC) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS product_browse_history ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME product_browse_history_seq), + tenant_id BIGINT NOT NULL, + spu_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + user_deleted BOOLEAN NOT NULL DEFAULT FALSE, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_product_browse_history_user CHECK (user_id > 0), + CONSTRAINT fk_product_browse_history_spu + FOREIGN KEY (tenant_id,spu_id) REFERENCES product_spu (tenant_id,id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_product_browse_tenant_user_spu_active + ON product_browse_history (tenant_id,user_id,spu_id) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_product_browse_tenant_user_time + ON product_browse_history (tenant_id,user_id,create_time DESC,id DESC) WHERE deleted=false; + +DO $$ +DECLARE valid_shape INTEGER; +BEGIN + SELECT count(*) INTO valid_shape + FROM information_schema.columns c + WHERE c.table_schema=current_schema() AND ( + (c.table_name='product_brand' AND c.column_name IN ( + 'id','tenant_id','name','pic_url','sort','description','status', + 'creator','create_time','updater','update_time','deleted')) + OR (c.table_name='product_category' AND c.column_name IN ( + 'id','tenant_id','parent_id','name','pic_url','sort','status', + 'creator','create_time','updater','update_time','deleted')) + OR (c.table_name='product_property' AND c.column_name IN ( + 'id','tenant_id','name','remark','creator','create_time','updater','update_time','deleted')) + OR (c.table_name='product_property_value' AND c.column_name IN ( + 'id','tenant_id','property_id','name','remark', + 'creator','create_time','updater','update_time','deleted')) + OR (c.table_name='product_spu' AND c.column_name IN ( + 'id','tenant_id','name','keyword','introduction','description','category_id','brand_id', + 'pic_url','slider_pic_urls','sort','status','spec_type','price','market_price','cost_price', + 'stock','delivery_types','delivery_template_id','give_integral','sub_commission_type', + 'sales_count','virtual_sales_count','browse_count', + 'creator','create_time','updater','update_time','deleted')) + OR (c.table_name='product_sku' AND c.column_name IN ( + 'id','tenant_id','spu_id','properties','price','market_price','cost_price','bar_code','pic_url', + 'stock','weight','volume','first_brokerage_price','second_brokerage_price','sales_count', + 'creator','create_time','updater','update_time','deleted')) + OR (c.table_name='product_comment' AND c.column_name IN ( + 'id','tenant_id','user_id','user_nickname','user_avatar','anonymous','order_id','order_item_id', + 'spu_id','spu_name','sku_id','sku_pic_url','sku_properties','visible','scores', + 'description_scores','benefit_scores','content','pic_urls','reply_status','reply_user_id', + 'reply_content','reply_time','creator','create_time','updater','update_time','deleted')) + OR (c.table_name='product_favorite' AND c.column_name IN ( + 'id','tenant_id','user_id','spu_id','creator','create_time','updater','update_time','deleted')) + OR (c.table_name='product_browse_history' AND c.column_name IN ( + 'id','tenant_id','spu_id','user_id','user_deleted', + 'creator','create_time','updater','update_time','deleted')) + ); + IF valid_shape<>139 THEN + RAISE EXCEPTION 'Native Mall Product table shape conflict' USING ERRCODE='23514'; + END IF; +END $$; + +DO $$ +DECLARE installed INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN RETURN; END IF; + + INSERT INTO system_menu ( + id,name,permission,type,sort,parent_id,path,icon,component,component_name, + status,visible,keep_alive,always_show,creator,updater + ) VALUES + (6920,'商品中心','',1,22,6800,'mall-product','lucide:package-search',NULL,NULL,0,true,true,true,'education-flyway','education-flyway'), + (6921,'商品管理','',2,1,6920,'spu','lucide:package','mall/product/spu/index','EducationProductSpu',0,true,true,true,'education-flyway','education-flyway'), + (6922,'商品查询','product:spu:query',3,1,6921,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6923,'商品创建','product:spu:create',3,2,6921,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6924,'商品更新','product:spu:update',3,3,6921,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6925,'商品删除','product:spu:delete',3,4,6921,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6926,'商品导出','product:spu:export',3,5,6921,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6927,'商品分类','',2,2,6920,'category','lucide:folder-tree','mall/product/category/index','EducationProductCategory',0,true,true,true,'education-flyway','education-flyway'), + (6928,'分类查询','product:category:query',3,1,6927,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6929,'分类创建','product:category:create',3,2,6927,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6930,'分类更新','product:category:update',3,3,6927,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6931,'分类删除','product:category:delete',3,4,6927,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6932,'商品品牌','',2,3,6920,'brand','lucide:tags','mall/product/brand/index','EducationProductBrand',0,true,true,true,'education-flyway','education-flyway'), + (6933,'品牌查询','product:brand:query',3,1,6932,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6934,'品牌创建','product:brand:create',3,2,6932,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6935,'品牌更新','product:brand:update',3,3,6932,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6936,'品牌删除','product:brand:delete',3,4,6932,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6937,'商品属性','',2,4,6920,'property','lucide:list-tree','mall/product/property/index','EducationProductProperty',0,true,true,true,'education-flyway','education-flyway'), + (6938,'属性查询','product:property:query',3,1,6937,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6939,'属性创建','product:property:create',3,2,6937,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6940,'属性更新','product:property:update',3,3,6937,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6941,'属性删除','product:property:delete',3,4,6937,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6942,'商品评价','',2,5,6920,'comment','lucide:message-square-text','mall/product/comment/index','EducationProductComment',0,true,true,true,'education-flyway','education-flyway'), + (6943,'评价查询','product:comment:query',3,1,6942,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6944,'评价更新','product:comment:update',3,2,6942,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway') + ON CONFLICT (id) DO NOTHING; + + SELECT count(*) INTO installed FROM system_menu WHERE deleted=0 AND status=0 AND ( + (id=6920 AND type=1 AND parent_id=6800 AND path='mall-product') + OR (id=6921 AND type=2 AND parent_id=6920 AND component='mall/product/spu/index' AND component_name='EducationProductSpu') + OR (id BETWEEN 6922 AND 6926 AND type=3 AND parent_id=6921 AND permission IN ('product:spu:query','product:spu:create','product:spu:update','product:spu:delete','product:spu:export')) + OR (id=6927 AND type=2 AND parent_id=6920 AND component='mall/product/category/index' AND component_name='EducationProductCategory') + OR (id BETWEEN 6928 AND 6931 AND type=3 AND parent_id=6927 AND permission IN ('product:category:query','product:category:create','product:category:update','product:category:delete')) + OR (id=6932 AND type=2 AND parent_id=6920 AND component='mall/product/brand/index' AND component_name='EducationProductBrand') + OR (id BETWEEN 6933 AND 6936 AND type=3 AND parent_id=6932 AND permission IN ('product:brand:query','product:brand:create','product:brand:update','product:brand:delete')) + OR (id=6937 AND type=2 AND parent_id=6920 AND component='mall/product/property/index' AND component_name='EducationProductProperty') + OR (id BETWEEN 6938 AND 6941 AND type=3 AND parent_id=6937 AND permission IN ('product:property:query','product:property:create','product:property:update','product:property:delete')) + OR (id=6942 AND type=2 AND parent_id=6920 AND component='mall/product/comment/index' AND component_name='EducationProductComment') + OR (id BETWEEN 6943 AND 6944 AND type=3 AND parent_id=6942 AND permission IN ('product:comment:query','product:comment:update')) + ); + IF installed<>25 THEN + RAISE EXCEPTION 'Education native Mall Product menu shape conflict' USING ERRCODE='23505'; + END IF; +END $$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4380__activate_native_mall_coupon.sql b/yudao-module-education/src/main/resources/db/migration/education/V4380__activate_native_mall_coupon.sql new file mode 100644 index 00000000..bb5a964c --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4380__activate_native_mall_coupon.sql @@ -0,0 +1,189 @@ +-- Activate RuoYi Vue Pro's native Mall Promotion coupon lifecycle for +-- Education. Legacy `coupons` are code-based campaign rules and are not +-- converted here because native coupons are issued member-owned instances. +DO $$ +DECLARE target_table TEXT; +BEGIN + FOREACH target_table IN ARRAY ARRAY[ + 'promotion_coupon_template','promotion_coupon' + ] LOOP + IF to_regclass(target_table) IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=current_schema() AND table_name=target_table AND column_name='tenant_id' + ) THEN + RAISE EXCEPTION 'Existing % requires explicit tenant mapping before V4380', target_table + USING ERRCODE='23514'; + END IF; + END LOOP; +END $$; + +CREATE TABLE IF NOT EXISTS promotion_coupon_template ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME promotion_coupon_template_seq), + tenant_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + description VARCHAR(1024), + status SMALLINT NOT NULL, + total_count INTEGER NOT NULL, + take_limit_count INTEGER NOT NULL, + take_type SMALLINT NOT NULL, + use_price INTEGER NOT NULL DEFAULT 0, + product_scope SMALLINT NOT NULL, + product_scope_values VARCHAR(2048), + validity_type SMALLINT NOT NULL, + valid_start_time TIMESTAMP, + valid_end_time TIMESTAMP, + fixed_start_term INTEGER, + fixed_end_term INTEGER, + discount_type SMALLINT NOT NULL, + discount_percent INTEGER, + discount_price INTEGER, + discount_limit_price INTEGER, + take_count INTEGER NOT NULL DEFAULT 0, + use_count INTEGER NOT NULL DEFAULT 0, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_promotion_coupon_template_status CHECK (status IN (0,1)), + CONSTRAINT ck_promotion_coupon_template_counts CHECK ( + (total_count=-1 OR total_count>=0) + AND (take_limit_count=-1 OR take_limit_count>=1) + AND take_count>=0 AND use_count>=0 AND use_count<=take_count + AND (total_count=-1 OR take_count<=total_count)), + CONSTRAINT ck_promotion_coupon_template_take_type CHECK (take_type IN (1,2,3)), + CONSTRAINT ck_promotion_coupon_template_use_price CHECK (use_price>=0), + CONSTRAINT ck_promotion_coupon_template_scope CHECK ( + product_scope IN (1,2,3) + AND (product_scope=1 OR NULLIF(BTRIM(product_scope_values),'') IS NOT NULL)), + CONSTRAINT ck_promotion_coupon_template_validity CHECK ( + (validity_type=1 AND valid_start_time IS NOT NULL AND valid_end_time IS NOT NULL + AND valid_start_time=0 + AND fixed_end_term IS NOT NULL AND fixed_end_term>=1)), + CONSTRAINT ck_promotion_coupon_template_discount CHECK ( + (discount_type=1 AND discount_price IS NOT NULL AND discount_price>=0) + OR (discount_type=2 AND discount_percent BETWEEN 1 AND 99 + AND discount_limit_price IS NOT NULL AND discount_limit_price>=0)) +); + +CREATE INDEX IF NOT EXISTS idx_promotion_coupon_template_tenant_status_take + ON promotion_coupon_template (tenant_id,status,take_type,id DESC) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_promotion_coupon_template_tenant_scope + ON promotion_coupon_template (tenant_id,product_scope,id DESC) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS promotion_coupon ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME promotion_coupon_seq), + tenant_id BIGINT NOT NULL, + template_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + status SMALLINT NOT NULL, + user_id BIGINT NOT NULL, + take_type SMALLINT NOT NULL, + use_price INTEGER NOT NULL DEFAULT 0, + valid_start_time TIMESTAMP NOT NULL, + valid_end_time TIMESTAMP NOT NULL, + product_scope SMALLINT NOT NULL, + product_scope_values VARCHAR(2048), + discount_type SMALLINT NOT NULL, + discount_percent INTEGER, + discount_price INTEGER, + discount_limit_price INTEGER, + use_order_id BIGINT, + use_time TIMESTAMP, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT fk_promotion_coupon_template + FOREIGN KEY (tenant_id,template_id) + REFERENCES promotion_coupon_template (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT ck_promotion_coupon_status CHECK (status IN (1,2,3)), + CONSTRAINT ck_promotion_coupon_user CHECK (user_id>0), + CONSTRAINT ck_promotion_coupon_take_type CHECK (take_type IN (1,2,3)), + CONSTRAINT ck_promotion_coupon_use_price CHECK (use_price>=0), + CONSTRAINT ck_promotion_coupon_validity CHECK (valid_start_time=0) + OR (discount_type=2 AND discount_percent BETWEEN 1 AND 99 + AND discount_limit_price IS NOT NULL AND discount_limit_price>=0)), + CONSTRAINT ck_promotion_coupon_use CHECK ( + (use_order_id IS NULL OR use_order_id>0) + AND (status<>2 OR (use_order_id IS NOT NULL AND use_time IS NOT NULL))) +); + +CREATE INDEX IF NOT EXISTS idx_promotion_coupon_tenant_user_status + ON promotion_coupon (tenant_id,user_id,status,valid_end_time,id DESC) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_promotion_coupon_tenant_template_user + ON promotion_coupon (tenant_id,template_id,user_id,id DESC) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_promotion_coupon_tenant_expiry + ON promotion_coupon (tenant_id,status,valid_end_time,id) WHERE deleted=false AND status=1; + +DO $$ +DECLARE valid_shape INTEGER; +BEGIN + SELECT count(*) INTO valid_shape + FROM information_schema.columns c + WHERE c.table_schema=current_schema() AND ( + (c.table_name='promotion_coupon_template' AND c.column_name IN ( + 'id','tenant_id','name','description','status','total_count','take_limit_count','take_type', + 'use_price','product_scope','product_scope_values','validity_type','valid_start_time', + 'valid_end_time','fixed_start_term','fixed_end_term','discount_type','discount_percent', + 'discount_price','discount_limit_price','take_count','use_count', + 'creator','create_time','updater','update_time','deleted')) + OR (c.table_name='promotion_coupon' AND c.column_name IN ( + 'id','tenant_id','template_id','name','status','user_id','take_type','use_price', + 'valid_start_time','valid_end_time','product_scope','product_scope_values','discount_type', + 'discount_percent','discount_price','discount_limit_price','use_order_id','use_time', + 'creator','create_time','updater','update_time','deleted')) + ); + IF valid_shape<>50 THEN + RAISE EXCEPTION 'Native Mall Promotion coupon table shape conflict' USING ERRCODE='23514'; + END IF; +END $$; + +DO $$ +DECLARE installed INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN RETURN; END IF; + + INSERT INTO system_menu ( + id,name,permission,type,sort,parent_id,path,icon,component,component_name, + status,visible,keep_alive,always_show,creator,updater + ) VALUES + (6950,'优惠券中心','',1,23,6800,'mall-coupon','lucide:ticket-percent',NULL,NULL,0,true,true,true,'education-flyway','education-flyway'), + (6951,'优惠券模板','',2,1,6950,'template','lucide:badge-percent','mall/promotion/coupon/template/index','EducationPromotionCouponTemplate',0,true,true,true,'education-flyway','education-flyway'), + (6952,'模板查询','promotion:coupon-template:query',3,1,6951,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6953,'模板创建','promotion:coupon-template:create',3,2,6951,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6954,'模板更新','promotion:coupon-template:update',3,3,6951,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6955,'模板删除','promotion:coupon-template:delete',3,4,6951,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6956,'领取记录','',2,2,6950,'records','lucide:tickets','mall/promotion/coupon/index','EducationPromotionCoupon',0,true,true,true,'education-flyway','education-flyway'), + (6957,'优惠券查询','promotion:coupon:query',3,1,6956,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6958,'优惠券发送','promotion:coupon:send',3,2,6956,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6959,'优惠券回收','promotion:coupon:delete',3,3,6956,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway') + ON CONFLICT (id) DO NOTHING; + + SELECT count(*) INTO installed FROM system_menu WHERE deleted=0 AND status=0 AND ( + (id=6950 AND type=1 AND parent_id=6800 AND path='mall-coupon') + OR (id=6951 AND type=2 AND parent_id=6950 + AND component='mall/promotion/coupon/template/index' + AND component_name='EducationPromotionCouponTemplate') + OR (id BETWEEN 6952 AND 6955 AND type=3 AND parent_id=6951 + AND permission IN ('promotion:coupon-template:query','promotion:coupon-template:create', + 'promotion:coupon-template:update','promotion:coupon-template:delete')) + OR (id=6956 AND type=2 AND parent_id=6950 + AND component='mall/promotion/coupon/index' + AND component_name='EducationPromotionCoupon') + OR (id BETWEEN 6957 AND 6959 AND type=3 AND parent_id=6956 + AND permission IN ('promotion:coupon:query','promotion:coupon:send','promotion:coupon:delete')) + ); + IF installed<>10 THEN + RAISE EXCEPTION 'Education native Mall Promotion coupon menu shape conflict' USING ERRCODE='23505'; + END IF; +END $$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4390__activate_native_mall_trade_order.sql b/yudao-module-education/src/main/resources/db/migration/education/V4390__activate_native_mall_trade_order.sql new file mode 100644 index 00000000..40271669 --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4390__activate_native_mall_trade_order.sql @@ -0,0 +1,377 @@ +-- Activate the first tenant-safe slice of RuoYi Vue Pro's native Mall Trade +-- runtime: cart, order, order item, order log, and per-tenant trade config. +-- Legacy orders are intentionally not converted because their product/member +-- identities and normalized line-item/payment lifecycle have not been mapped. +DO $$ +DECLARE target_table TEXT; +BEGIN + FOREACH target_table IN ARRAY ARRAY[ + 'trade_config','trade_cart','trade_order','trade_order_item','trade_order_log' + ] LOOP + IF to_regclass(target_table) IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=current_schema() AND table_name=target_table AND column_name='tenant_id' + ) THEN + RAISE EXCEPTION 'Existing % requires explicit tenant mapping before V4390', target_table + USING ERRCODE='23514'; + END IF; + END LOOP; + + -- The Trade dependency exposes these controllers as soon as it is on the + -- Server classpath. Refuse to coexist with deferred legacy/global tables; + -- their tenant-aware activation belongs to later reviewed slices. + FOREACH target_table IN ARRAY ARRAY[ + 'trade_after_sale','trade_after_sale_log', + 'trade_brokerage_user','trade_brokerage_record','trade_brokerage_withdraw', + 'trade_delivery_express','trade_delivery_express_template', + 'trade_delivery_express_template_charge','trade_delivery_express_template_free', + 'trade_delivery_pick_up_store' + ] LOOP + IF to_regclass(target_table) IS NOT NULL THEN + RAISE EXCEPTION 'Existing deferred % requires dedicated tenant-safe activation after V4390', target_table + USING ERRCODE='23514'; + END IF; + END LOOP; +END $$; + +CREATE TABLE IF NOT EXISTS trade_config ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME trade_config_seq), + tenant_id BIGINT NOT NULL, + after_sale_refund_reasons TEXT NOT NULL, + after_sale_return_reasons TEXT NOT NULL, + delivery_express_free_enabled BOOLEAN NOT NULL, + delivery_express_free_price INTEGER NOT NULL DEFAULT 0, + delivery_pick_up_enabled BOOLEAN NOT NULL, + brokerage_enabled BOOLEAN NOT NULL, + brokerage_enabled_condition SMALLINT NOT NULL, + brokerage_bind_mode SMALLINT NOT NULL, + brokerage_poster_urls TEXT, + brokerage_first_percent INTEGER NOT NULL DEFAULT 0, + brokerage_second_percent INTEGER NOT NULL DEFAULT 0, + brokerage_withdraw_min_price INTEGER NOT NULL DEFAULT 0, + brokerage_withdraw_fee_percent INTEGER NOT NULL DEFAULT 0, + brokerage_frozen_days INTEGER NOT NULL DEFAULT 0, + brokerage_withdraw_types VARCHAR(128) NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_trade_config_brokerage_modes CHECK ( + brokerage_enabled_condition IN (1,2) AND brokerage_bind_mode IN (1,2,3)), + CONSTRAINT ck_trade_config_amounts CHECK ( + delivery_express_free_price>=0 AND brokerage_first_percent BETWEEN 0 AND 100 + AND brokerage_second_percent BETWEEN 0 AND 100 + AND brokerage_withdraw_min_price>=0 AND brokerage_withdraw_fee_percent>=0 + AND brokerage_frozen_days>=0) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_trade_config_tenant_active + ON trade_config (tenant_id) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS trade_cart ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME trade_cart_seq), + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + spu_id BIGINT NOT NULL, + sku_id BIGINT NOT NULL, + count INTEGER NOT NULL, + selected BOOLEAN NOT NULL DEFAULT TRUE, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_trade_cart_user_count CHECK (user_id>0 AND count>0), + CONSTRAINT fk_trade_cart_spu + FOREIGN KEY (tenant_id,spu_id) + REFERENCES product_spu (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_trade_cart_sku + FOREIGN KEY (tenant_id,spu_id,sku_id) + REFERENCES product_sku (tenant_id,spu_id,id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_trade_cart_tenant_user_sku_active + ON trade_cart (tenant_id,user_id,sku_id) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_trade_cart_tenant_user_selected + ON trade_cart (tenant_id,user_id,selected,id) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS trade_order ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME trade_order_seq), + tenant_id BIGINT NOT NULL, + no VARCHAR(64) NOT NULL, + type SMALLINT NOT NULL, + terminal SMALLINT NOT NULL, + user_id BIGINT NOT NULL, + user_ip VARCHAR(50) NOT NULL, + user_remark VARCHAR(512), + status SMALLINT NOT NULL, + product_count INTEGER NOT NULL, + finish_time TIMESTAMP, + cancel_time TIMESTAMP, + cancel_type SMALLINT, + remark VARCHAR(512), + comment_status BOOLEAN NOT NULL DEFAULT FALSE, + brokerage_user_id BIGINT, + pay_order_id BIGINT, + pay_status BOOLEAN NOT NULL DEFAULT FALSE, + pay_time TIMESTAMP, + pay_channel_code VARCHAR(32), + total_price INTEGER NOT NULL, + discount_price INTEGER NOT NULL DEFAULT 0, + delivery_price INTEGER NOT NULL DEFAULT 0, + adjust_price INTEGER NOT NULL DEFAULT 0, + pay_price INTEGER NOT NULL, + delivery_type SMALLINT NOT NULL, + logistics_id BIGINT, + logistics_no VARCHAR(128), + delivery_time TIMESTAMP, + receive_time TIMESTAMP, + receiver_name VARCHAR(128), + receiver_mobile VARCHAR(32), + receiver_area_id INTEGER, + receiver_detail_address VARCHAR(512), + pick_up_store_id BIGINT, + pick_up_verify_code VARCHAR(32), + refund_status SMALLINT NOT NULL DEFAULT 0, + refund_price INTEGER NOT NULL DEFAULT 0, + coupon_id BIGINT, + coupon_price INTEGER NOT NULL DEFAULT 0, + use_point INTEGER NOT NULL DEFAULT 0, + point_price INTEGER NOT NULL DEFAULT 0, + give_point INTEGER NOT NULL DEFAULT 0, + refund_point INTEGER NOT NULL DEFAULT 0, + vip_price INTEGER NOT NULL DEFAULT 0, + give_coupon_template_counts TEXT, + give_coupon_ids TEXT, + seckill_activity_id BIGINT, + bargain_activity_id BIGINT, + bargain_record_id BIGINT, + combination_activity_id BIGINT, + combination_head_id BIGINT, + combination_record_id BIGINT, + point_activity_id BIGINT, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_trade_order_type CHECK (type IN (0,1,2,3,4)), + CONSTRAINT ck_trade_order_terminal CHECK (terminal IN (0,10,11,20,31)), + CONSTRAINT ck_trade_order_status CHECK (status IN (0,10,20,30,40)), + CONSTRAINT ck_trade_order_cancel_type CHECK (cancel_type IS NULL OR cancel_type IN (10,20,30,40)), + CONSTRAINT ck_trade_order_delivery_type CHECK (delivery_type IN (1,2)), + CONSTRAINT ck_trade_order_refund_status CHECK (refund_status IN (0,10,20)), + CONSTRAINT ck_trade_order_user_count CHECK ( + user_id>0 AND product_count>0 AND (brokerage_user_id IS NULL OR brokerage_user_id>0)), + CONSTRAINT ck_trade_order_amounts CHECK ( + total_price>=0 AND discount_price>=0 AND delivery_price>=0 AND pay_price>=0 + AND coupon_price>=0 AND use_point>=0 AND point_price>=0 AND give_point>=0 + AND refund_point>=0 AND vip_price>=0 AND refund_price>=0 AND refund_price<=pay_price), + CONSTRAINT ck_trade_order_payment_state CHECK ( + (pay_order_id IS NULL OR pay_order_id>0) + AND (NOT pay_status OR (pay_order_id IS NOT NULL AND pay_time IS NOT NULL))), + CONSTRAINT ck_trade_order_cancel_state CHECK ( + (status<>40 AND cancel_time IS NULL AND cancel_type IS NULL) + OR (status=40 AND cancel_time IS NOT NULL AND cancel_type IS NOT NULL)), + CONSTRAINT ck_trade_order_delivery_shape CHECK ( + (delivery_type=1 AND receiver_name IS NOT NULL AND receiver_mobile IS NOT NULL + AND receiver_area_id IS NOT NULL AND receiver_detail_address IS NOT NULL) + OR (delivery_type=2 AND receiver_name IS NOT NULL AND receiver_mobile IS NOT NULL)), + CONSTRAINT fk_trade_order_pay_order + FOREIGN KEY (tenant_id,pay_order_id) + REFERENCES pay_order (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_trade_order_coupon + FOREIGN KEY (tenant_id,coupon_id) + REFERENCES promotion_coupon (tenant_id,id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_trade_order_tenant_no + ON trade_order (tenant_id,no); +CREATE UNIQUE INDEX IF NOT EXISTS uk_trade_order_tenant_pay_order_active + ON trade_order (tenant_id,pay_order_id) + WHERE deleted=false AND pay_order_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS uk_trade_order_tenant_pick_up_code_active + ON trade_order (tenant_id,pick_up_verify_code) + WHERE deleted=false AND pick_up_verify_code IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_trade_order_tenant_user_time + ON trade_order (tenant_id,user_id,create_time DESC,id DESC) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_trade_order_tenant_status_time + ON trade_order (tenant_id,status,create_time DESC,id DESC) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_trade_order_tenant_refund_time + ON trade_order (tenant_id,refund_status,create_time DESC,id DESC) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_trade_order_tenant_auto_cancel + ON trade_order (tenant_id,status,create_time,id) WHERE deleted=false AND status=0; +CREATE INDEX IF NOT EXISTS idx_trade_order_tenant_auto_receive + ON trade_order (tenant_id,status,delivery_time,id) WHERE deleted=false AND status=20; + +CREATE TABLE IF NOT EXISTS trade_order_item ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME trade_order_item_seq), + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + order_id BIGINT NOT NULL, + cart_id BIGINT, + spu_id BIGINT NOT NULL, + spu_name VARCHAR(255) NOT NULL, + sku_id BIGINT NOT NULL, + properties TEXT, + pic_url VARCHAR(1024), + count INTEGER NOT NULL, + comment_status BOOLEAN NOT NULL DEFAULT FALSE, + price INTEGER NOT NULL, + discount_price INTEGER NOT NULL DEFAULT 0, + delivery_price INTEGER NOT NULL DEFAULT 0, + adjust_price INTEGER NOT NULL DEFAULT 0, + pay_price INTEGER NOT NULL, + coupon_price INTEGER NOT NULL DEFAULT 0, + point_price INTEGER NOT NULL DEFAULT 0, + use_point INTEGER NOT NULL DEFAULT 0, + give_point INTEGER NOT NULL DEFAULT 0, + vip_price INTEGER NOT NULL DEFAULT 0, + after_sale_id BIGINT, + after_sale_status SMALLINT NOT NULL DEFAULT 0, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_trade_order_item_user_count CHECK (user_id>0 AND count>0), + CONSTRAINT ck_trade_order_item_amounts CHECK ( + price>=0 AND discount_price>=0 AND delivery_price>=0 AND pay_price>=0 + AND coupon_price>=0 AND point_price>=0 AND use_point>=0 AND give_point>=0 + AND vip_price>=0), + CONSTRAINT ck_trade_order_item_after_sale CHECK ( + after_sale_status IN (0,10,20) + AND (after_sale_status=0 OR after_sale_id IS NOT NULL)), + CONSTRAINT fk_trade_order_item_order + FOREIGN KEY (tenant_id,order_id) + REFERENCES trade_order (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_trade_order_item_cart + FOREIGN KEY (tenant_id,cart_id) + REFERENCES trade_cart (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_trade_order_item_spu + FOREIGN KEY (tenant_id,spu_id) + REFERENCES product_spu (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_trade_order_item_sku + FOREIGN KEY (tenant_id,spu_id,sku_id) + REFERENCES product_sku (tenant_id,spu_id,id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_trade_order_item_tenant_order + ON trade_order_item (tenant_id,order_id,id) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_trade_order_item_tenant_user + ON trade_order_item (tenant_id,user_id,id DESC) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_trade_order_item_tenant_sku + ON trade_order_item (tenant_id,sku_id,id DESC) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS trade_order_log ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME trade_order_log_seq), + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + user_type SMALLINT NOT NULL, + order_id BIGINT NOT NULL, + before_status SMALLINT, + after_status SMALLINT NOT NULL, + operate_type SMALLINT NOT NULL, + content VARCHAR(1024) NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_trade_order_log_user CHECK ( + (user_type=0 AND user_id=0) OR (user_type IN (1,2) AND user_id>0)), + CONSTRAINT ck_trade_order_log_status CHECK ( + (before_status IS NULL OR before_status IN (0,10,20,30,40)) + AND after_status IN (0,10,20,30,40)), + CONSTRAINT ck_trade_order_log_operate CHECK ( + operate_type IN (1,2,10,11,20,30,31,32,33,34,40,41,43,49)), + CONSTRAINT fk_trade_order_log_order + FOREIGN KEY (tenant_id,order_id) + REFERENCES trade_order (tenant_id,id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_trade_order_log_tenant_order_time + ON trade_order_log (tenant_id,order_id,create_time DESC,id DESC) WHERE deleted=false; + +DO $$ +DECLARE valid_shape INTEGER; +BEGIN + SELECT count(*) INTO valid_shape + FROM information_schema.columns c + WHERE c.table_schema=current_schema() AND ( + (c.table_name='trade_config' AND c.column_name IN ( + 'id','tenant_id','after_sale_refund_reasons','after_sale_return_reasons', + 'delivery_express_free_enabled','delivery_express_free_price','delivery_pick_up_enabled', + 'brokerage_enabled','brokerage_enabled_condition','brokerage_bind_mode','brokerage_poster_urls', + 'brokerage_first_percent','brokerage_second_percent','brokerage_withdraw_min_price', + 'brokerage_withdraw_fee_percent','brokerage_frozen_days','brokerage_withdraw_types', + 'creator','create_time','updater','update_time','deleted')) + OR (c.table_name='trade_cart' AND c.column_name IN ( + 'id','tenant_id','user_id','spu_id','sku_id','count','selected', + 'creator','create_time','updater','update_time','deleted')) + OR (c.table_name='trade_order' AND c.column_name IN ( + 'id','tenant_id','no','type','terminal','user_id','user_ip','user_remark','status', + 'product_count','finish_time','cancel_time','cancel_type','remark','comment_status', + 'brokerage_user_id','pay_order_id','pay_status','pay_time','pay_channel_code','total_price', + 'discount_price','delivery_price','adjust_price','pay_price','delivery_type','logistics_id', + 'logistics_no','delivery_time','receive_time','receiver_name','receiver_mobile', + 'receiver_area_id','receiver_detail_address','pick_up_store_id','pick_up_verify_code', + 'refund_status','refund_price','coupon_id','coupon_price','use_point','point_price', + 'give_point','refund_point','vip_price','give_coupon_template_counts','give_coupon_ids', + 'seckill_activity_id','bargain_activity_id','bargain_record_id','combination_activity_id', + 'combination_head_id','combination_record_id','point_activity_id', + 'creator','create_time','updater','update_time','deleted')) + OR (c.table_name='trade_order_item' AND c.column_name IN ( + 'id','tenant_id','user_id','order_id','cart_id','spu_id','spu_name','sku_id','properties', + 'pic_url','count','comment_status','price','discount_price','delivery_price','adjust_price', + 'pay_price','coupon_price','point_price','use_point','give_point','vip_price','after_sale_id', + 'after_sale_status','creator','create_time','updater','update_time','deleted')) + OR (c.table_name='trade_order_log' AND c.column_name IN ( + 'id','tenant_id','user_id','user_type','order_id','before_status','after_status', + 'operate_type','content','creator','create_time','updater','update_time','deleted')) + ); + IF valid_shape<>136 THEN + RAISE EXCEPTION 'Native Mall Trade order table shape conflict' USING ERRCODE='23514'; + END IF; +END $$; + +DO $$ +DECLARE installed INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN RETURN; END IF; + + INSERT INTO system_menu ( + id,name,permission,type,sort,parent_id,path,icon,component,component_name, + status,visible,keep_alive,always_show,creator,updater + ) VALUES + (6960,'交易中心','',1,24,6800,'mall-trade','lucide:shopping-bag',NULL,NULL,0,true,true,true,'education-flyway','education-flyway'), + (6961,'订单管理','',2,1,6960,'order','lucide:receipt-text','mall/trade/order/index','EducationTradeOrder',0,true,true,true,'education-flyway','education-flyway'), + (6962,'订单查询','trade:order:query',3,1,6961,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6963,'订单更新','trade:order:update',3,2,6961,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6964,'订单核销','trade:order:pick-up',3,3,6961,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6965,'交易配置','',2,2,6960,'config','lucide:settings-2','mall/trade/config/index','EducationTradeConfig',0,true,true,true,'education-flyway','education-flyway'), + (6966,'配置查询','trade:config:query',3,1,6965,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6967,'配置保存','trade:config:save',3,2,6965,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway') + ON CONFLICT (id) DO NOTHING; + + SELECT count(*) INTO installed FROM system_menu WHERE deleted=0 AND status=0 AND ( + (id=6960 AND type=1 AND parent_id=6800 AND path='mall-trade') + OR (id=6961 AND type=2 AND parent_id=6960 + AND component='mall/trade/order/index' AND component_name='EducationTradeOrder') + OR (id BETWEEN 6962 AND 6964 AND type=3 AND parent_id=6961 + AND permission IN ('trade:order:query','trade:order:update','trade:order:pick-up')) + OR (id=6965 AND type=2 AND parent_id=6960 + AND component='mall/trade/config/index' AND component_name='EducationTradeConfig') + OR (id BETWEEN 6966 AND 6967 AND type=3 AND parent_id=6965 + AND permission IN ('trade:config:query','trade:config:save')) + ); + IF installed<>8 THEN + RAISE EXCEPTION 'Education native Mall Trade order menu shape conflict' USING ERRCODE='23505'; + END IF; +END $$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4400__activate_native_mall_checkout_promotions.sql b/yudao-module-education/src/main/resources/db/migration/education/V4400__activate_native_mall_checkout_promotions.sql new file mode 100644 index 00000000..7cb5549b --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4400__activate_native_mall_checkout_promotions.sql @@ -0,0 +1,262 @@ +-- Activate the native Promotion activities that every normal Trade checkout +-- consults: limited-time discounts and reward (full reduction/gift) rules. +-- These tables remain owned by yudao-module-promotion; Education only supplies +-- tenant-safe PostgreSQL persistence and mounts the existing administrator UI. +DO $$ +DECLARE target_table TEXT; +BEGIN + FOREACH target_table IN ARRAY ARRAY[ + 'promotion_discount_activity', + 'promotion_discount_product', + 'promotion_reward_activity' + ] LOOP + IF to_regclass(target_table) IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=current_schema() AND table_name=target_table AND column_name='tenant_id' + ) THEN + RAISE EXCEPTION 'Existing % requires explicit tenant mapping before V4400', target_table + USING ERRCODE='23514'; + END IF; + END LOOP; +END $$; + +CREATE OR REPLACE FUNCTION promotion_reward_rules_valid(rule_text TEXT) +RETURNS BOOLEAN AS $$ +DECLARE + parsed JSONB; + rule_item JSONB; + coupon_key TEXT; + coupon_value JSONB; +BEGIN + IF rule_text IS NULL THEN + RETURN FALSE; + END IF; + BEGIN + parsed := rule_text::JSONB; + EXCEPTION WHEN OTHERS THEN + RETURN FALSE; + END; + IF jsonb_typeof(parsed)<>'array' OR jsonb_array_length(parsed)=0 THEN + RETURN FALSE; + END IF; + + FOR rule_item IN SELECT value FROM jsonb_array_elements(parsed) LOOP + IF jsonb_typeof(rule_item)<>'object' + OR rule_item->>'limit' IS NULL + OR rule_item->>'limit' !~ '^[0-9]+$' + OR (rule_item->>'limit')::NUMERIC NOT BETWEEN 1 AND 2147483647 + OR rule_item->>'discountPrice' IS NULL + OR rule_item->>'discountPrice' !~ '^[0-9]+$' + OR (rule_item->>'discountPrice')::NUMERIC NOT BETWEEN 1 AND 2147483647 + OR jsonb_typeof(rule_item->'freeDelivery') IS DISTINCT FROM 'boolean' + OR (rule_item ? 'point' AND rule_item->'point'<>'null'::JSONB AND ( + rule_item->>'point' !~ '^[0-9]+$' + OR (rule_item->>'point')::NUMERIC NOT BETWEEN 0 AND 2147483647 + )) THEN + RETURN FALSE; + END IF; + + IF rule_item ? 'giveCouponTemplateCounts' + AND rule_item->'giveCouponTemplateCounts'<>'null'::JSONB THEN + IF jsonb_typeof(rule_item->'giveCouponTemplateCounts') IS DISTINCT FROM 'object' THEN + RETURN FALSE; + END IF; + FOR coupon_key,coupon_value IN + SELECT key,value FROM jsonb_each(rule_item->'giveCouponTemplateCounts') + LOOP + IF coupon_key !~ '^[1-9][0-9]*$' + OR jsonb_typeof(coupon_value)<>'number' + OR coupon_value::TEXT !~ '^[0-9]+$' + OR coupon_value::TEXT::NUMERIC NOT BETWEEN 1 AND 2147483647 THEN + RETURN FALSE; + END IF; + END LOOP; + END IF; + END LOOP; + RETURN TRUE; +EXCEPTION WHEN OTHERS THEN + RETURN FALSE; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +CREATE TABLE IF NOT EXISTS promotion_discount_activity ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME promotion_discount_activity_seq), + tenant_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + status SMALLINT NOT NULL, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + remark VARCHAR(1024), + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_promotion_discount_activity_name CHECK (NULLIF(BTRIM(name),'') IS NOT NULL), + CONSTRAINT ck_promotion_discount_activity_status CHECK (status IN (0,1)), + CONSTRAINT ck_promotion_discount_activity_time CHECK (start_time=0) + OR (discount_type=2 AND discount_percent IS NOT NULL + AND discount_percent BETWEEN 1 AND 9999)), + CONSTRAINT ck_promotion_discount_product_activity CHECK ( + activity_status IN (0,1) AND NULLIF(BTRIM(activity_name),'') IS NOT NULL + AND activity_start_time45 THEN + RAISE EXCEPTION 'Native Mall checkout Promotion table shape conflict' USING ERRCODE='23514'; + END IF; +END $$; + +DO $$ +DECLARE installed INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN RETURN; END IF; + + INSERT INTO system_menu ( + id,name,permission,type,sort,parent_id,path,icon,component,component_name, + status,visible,keep_alive,always_show,creator,updater + ) VALUES + (6970,'营销活动','',1,25,6800,'mall-promotion','lucide:badge-percent',NULL,NULL,0,true,true,true,'education-flyway','education-flyway'), + (6971,'限时折扣','',2,1,6970,'discount-activity','lucide:timer','mall/promotion/discountActivity/index','PromotionDiscountActivity',0,true,true,true,'education-flyway','education-flyway'), + (6972,'折扣查询','promotion:discount-activity:query',3,1,6971,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6973,'折扣创建','promotion:discount-activity:create',3,2,6971,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6974,'折扣更新','promotion:discount-activity:update',3,3,6971,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6975,'折扣删除','promotion:discount-activity:delete',3,4,6971,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6976,'折扣关闭','promotion:discount-activity:close',3,5,6971,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6977,'满减送','',2,2,6970,'reward-activity','lucide:gift','mall/promotion/rewardActivity/index','PromotionRewardActivity',0,true,true,true,'education-flyway','education-flyway'), + (6978,'满减查询','promotion:reward-activity:query',3,1,6977,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6979,'满减创建','promotion:reward-activity:create',3,2,6977,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6980,'满减更新','promotion:reward-activity:update',3,3,6977,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6981,'满减删除','promotion:reward-activity:delete',3,4,6977,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6982,'满减关闭','promotion:reward-activity:close',3,5,6977,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway') + ON CONFLICT (id) DO NOTHING; + + SELECT count(*) INTO installed FROM system_menu WHERE deleted=0 AND status=0 AND ( + (id=6970 AND type=1 AND parent_id=6800 AND path='mall-promotion') + OR (id=6971 AND type=2 AND parent_id=6970 + AND component='mall/promotion/discountActivity/index' + AND component_name='PromotionDiscountActivity') + OR (id BETWEEN 6972 AND 6976 AND type=3 AND parent_id=6971 + AND permission IN ( + 'promotion:discount-activity:query','promotion:discount-activity:create', + 'promotion:discount-activity:update','promotion:discount-activity:delete', + 'promotion:discount-activity:close')) + OR (id=6977 AND type=2 AND parent_id=6970 + AND component='mall/promotion/rewardActivity/index' + AND component_name='PromotionRewardActivity') + OR (id BETWEEN 6978 AND 6982 AND type=3 AND parent_id=6977 + AND permission IN ( + 'promotion:reward-activity:query','promotion:reward-activity:create', + 'promotion:reward-activity:update','promotion:reward-activity:delete', + 'promotion:reward-activity:close')) + ); + IF installed<>13 THEN + RAISE EXCEPTION 'Education native Mall checkout Promotion menu shape conflict' + USING ERRCODE='23505'; + END IF; +END $$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4410__activate_native_trade_delivery.sql b/yudao-module-education/src/main/resources/db/migration/education/V4410__activate_native_trade_delivery.sql new file mode 100644 index 00000000..2945d495 --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4410__activate_native_trade_delivery.sql @@ -0,0 +1,306 @@ +-- Activate RuoYi Vue Pro's native tenant-safe delivery administration and the +-- persistence used by normal Trade checkout. The source backend has no +-- physical-shipping domain to port; this is a required native Mall dependency +-- for products that opt into express delivery or store pickup. +DO $$ +DECLARE target_table TEXT; +BEGIN + FOREACH target_table IN ARRAY ARRAY[ + 'trade_delivery_express', + 'trade_delivery_express_template', + 'trade_delivery_express_template_charge', + 'trade_delivery_express_template_free', + 'trade_delivery_pick_up_store' + ] LOOP + IF to_regclass(target_table) IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=current_schema() AND table_name=target_table AND column_name='tenant_id' + ) THEN + RAISE EXCEPTION 'Existing % requires explicit tenant mapping before V4410', target_table + USING ERRCODE='23514'; + END IF; + END LOOP; +END $$; + +CREATE OR REPLACE FUNCTION trade_delivery_positive_id_array_valid( + id_text TEXT, allow_empty BOOLEAN +) RETURNS BOOLEAN AS $$ +DECLARE + item TEXT; +BEGIN + IF id_text IS NULL THEN + RETURN FALSE; + END IF; + IF id_text='' THEN + RETURN allow_empty; + END IF; + IF id_text !~ '^[1-9][0-9]*(,[1-9][0-9]*)*$' THEN + RETURN FALSE; + END IF; + FOR item IN SELECT regexp_split_to_table(id_text, ',') LOOP + IF item::NUMERIC NOT BETWEEN 1 AND 9223372036854775807 THEN + RETURN FALSE; + END IF; + END LOOP; + RETURN TRUE; +EXCEPTION WHEN OTHERS THEN + RETURN FALSE; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +CREATE TABLE IF NOT EXISTS trade_delivery_express ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME trade_delivery_express_seq), + tenant_id BIGINT NOT NULL, + code VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + logo VARCHAR(1024), + sort INTEGER NOT NULL DEFAULT 0, + status SMALLINT NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_trade_delivery_express_identity CHECK ( + NULLIF(BTRIM(code),'') IS NOT NULL AND NULLIF(BTRIM(name),'') IS NOT NULL), + CONSTRAINT ck_trade_delivery_express_sort CHECK (sort>=0), + CONSTRAINT ck_trade_delivery_express_status CHECK (status IN (0,1)) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_trade_delivery_express_tenant_code_active + ON trade_delivery_express (tenant_id,code) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_trade_delivery_express_tenant_page + ON trade_delivery_express (tenant_id,status,sort,id) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS trade_delivery_express_template ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME trade_delivery_express_template_seq), + tenant_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + charge_mode SMALLINT NOT NULL, + sort INTEGER NOT NULL DEFAULT 0, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT uk_trade_delivery_express_template_tenant_id_mode + UNIQUE (tenant_id,id,charge_mode), + CONSTRAINT ck_trade_delivery_express_template_name CHECK ( + NULLIF(BTRIM(name),'') IS NOT NULL), + CONSTRAINT ck_trade_delivery_express_template_mode CHECK (charge_mode IN (1,2,3)), + CONSTRAINT ck_trade_delivery_express_template_sort CHECK (sort>=0) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_trade_delivery_express_template_tenant_name_active + ON trade_delivery_express_template (tenant_id,name) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_trade_delivery_express_template_tenant_page + ON trade_delivery_express_template (tenant_id,charge_mode,sort,id) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS trade_delivery_express_template_charge ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME trade_delivery_express_template_charge_seq), + tenant_id BIGINT NOT NULL, + template_id BIGINT NOT NULL, + area_ids TEXT NOT NULL, + charge_mode SMALLINT NOT NULL, + start_count DOUBLE PRECISION NOT NULL, + start_price INTEGER NOT NULL, + extra_count DOUBLE PRECISION NOT NULL, + extra_price INTEGER NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_trade_delivery_express_template_charge_areas CHECK ( + trade_delivery_positive_id_array_valid(area_ids,FALSE)), + CONSTRAINT ck_trade_delivery_express_template_charge_values CHECK ( + charge_mode IN (1,2,3) AND start_count>0 AND start_price>=0 + AND extra_count>0 AND extra_price>=0), + CONSTRAINT fk_trade_delivery_express_template_charge_template + FOREIGN KEY (tenant_id,template_id,charge_mode) + REFERENCES trade_delivery_express_template (tenant_id,id,charge_mode) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_trade_delivery_express_template_charge_tenant_template + ON trade_delivery_express_template_charge (tenant_id,template_id,id) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS trade_delivery_express_template_free ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME trade_delivery_express_template_free_seq), + tenant_id BIGINT NOT NULL, + template_id BIGINT NOT NULL, + area_ids TEXT NOT NULL, + free_price INTEGER NOT NULL, + free_count INTEGER NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_trade_delivery_express_template_free_areas CHECK ( + trade_delivery_positive_id_array_valid(area_ids,FALSE)), + CONSTRAINT ck_trade_delivery_express_template_free_values CHECK ( + free_price>=0 AND free_count>=0), + CONSTRAINT fk_trade_delivery_express_template_free_template + FOREIGN KEY (tenant_id,template_id) + REFERENCES trade_delivery_express_template (tenant_id,id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_trade_delivery_express_template_free_tenant_template + ON trade_delivery_express_template_free (tenant_id,template_id,id) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS trade_delivery_pick_up_store ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME trade_delivery_pick_up_store_seq), + tenant_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + introduction TEXT, + phone VARCHAR(32) NOT NULL, + area_id INTEGER NOT NULL, + detail_address VARCHAR(512) NOT NULL, + logo VARCHAR(1024) NOT NULL, + opening_time TIME NOT NULL, + closing_time TIME NOT NULL, + latitude DOUBLE PRECISION NOT NULL, + longitude DOUBLE PRECISION NOT NULL, + verify_user_ids TEXT, + status SMALLINT NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_trade_delivery_pick_up_store_identity CHECK ( + NULLIF(BTRIM(name),'') IS NOT NULL AND NULLIF(BTRIM(phone),'') IS NOT NULL + AND area_id>0 AND NULLIF(BTRIM(detail_address),'') IS NOT NULL + AND NULLIF(BTRIM(logo),'') IS NOT NULL), + CONSTRAINT ck_trade_delivery_pick_up_store_hours CHECK (opening_time<>closing_time), + CONSTRAINT ck_trade_delivery_pick_up_store_location CHECK ( + latitude BETWEEN -90 AND 90 AND longitude BETWEEN -180 AND 180), + CONSTRAINT ck_trade_delivery_pick_up_store_verify_users CHECK ( + verify_user_ids IS NULL OR trade_delivery_positive_id_array_valid(verify_user_ids,TRUE)), + CONSTRAINT ck_trade_delivery_pick_up_store_status CHECK (status IN (0,1)) +); + +CREATE INDEX IF NOT EXISTS idx_trade_delivery_pick_up_store_tenant_page + ON trade_delivery_pick_up_store (tenant_id,status,area_id,id DESC) WHERE deleted=false; + +-- Wire delivery ownership into the native Product and Trade records that were +-- activated in V4370/V4390. Nullable legacy rows remain valid, while every +-- configured reference is tenant-qualified. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint + WHERE conrelid='product_spu'::REGCLASS + AND conname='fk_product_spu_delivery_template') THEN + ALTER TABLE product_spu ADD CONSTRAINT fk_product_spu_delivery_template + FOREIGN KEY (tenant_id,delivery_template_id) + REFERENCES trade_delivery_express_template (tenant_id,id) ON DELETE RESTRICT; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint + WHERE conrelid='trade_order'::REGCLASS + AND conname='fk_trade_order_pick_up_store') THEN + ALTER TABLE trade_order ADD CONSTRAINT fk_trade_order_pick_up_store + FOREIGN KEY (tenant_id,pick_up_store_id) + REFERENCES trade_delivery_pick_up_store (tenant_id,id) ON DELETE RESTRICT; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint + WHERE conrelid='trade_order'::REGCLASS + AND conname='ck_trade_order_pick_up_store_shape') THEN + ALTER TABLE trade_order ADD CONSTRAINT ck_trade_order_pick_up_store_shape + CHECK (delivery_type<>2 OR pick_up_store_id IS NOT NULL); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_product_spu_tenant_delivery_template + ON product_spu (tenant_id,delivery_template_id,id) + WHERE deleted=false AND delivery_template_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_trade_order_tenant_pick_up_store + ON trade_order (tenant_id,pick_up_store_id,id DESC) + WHERE deleted=false AND pick_up_store_id IS NOT NULL; + +DO $$ +DECLARE valid_shape INTEGER; +BEGIN + SELECT count(*) INTO valid_shape + FROM information_schema.columns c + WHERE c.table_schema=current_schema() AND ( + (c.table_name='trade_delivery_express' AND c.column_name IN ( + 'id','tenant_id','code','name','logo','sort','status', + 'creator','create_time','updater','update_time','deleted')) + OR (c.table_name='trade_delivery_express_template' AND c.column_name IN ( + 'id','tenant_id','name','charge_mode','sort', + 'creator','create_time','updater','update_time','deleted')) + OR (c.table_name='trade_delivery_express_template_charge' AND c.column_name IN ( + 'id','tenant_id','template_id','area_ids','charge_mode','start_count','start_price', + 'extra_count','extra_price','creator','create_time','updater','update_time','deleted')) + OR (c.table_name='trade_delivery_express_template_free' AND c.column_name IN ( + 'id','tenant_id','template_id','area_ids','free_price','free_count', + 'creator','create_time','updater','update_time','deleted')) + OR (c.table_name='trade_delivery_pick_up_store' AND c.column_name IN ( + 'id','tenant_id','name','introduction','phone','area_id','detail_address','logo', + 'opening_time','closing_time','latitude','longitude','verify_user_ids','status', + 'creator','create_time','updater','update_time','deleted')) + ); + IF valid_shape<>66 THEN + RAISE EXCEPTION 'Native Trade delivery table shape conflict' USING ERRCODE='23514'; + END IF; +END $$; + +DO $$ +DECLARE installed INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN RETURN; END IF; + + INSERT INTO system_menu ( + id,name,permission,type,sort,parent_id,path,icon,component,component_name, + status,visible,keep_alive,always_show,creator,updater + ) VALUES + (6990,'配送管理','',1,3,6960,'delivery','lucide:truck',NULL,NULL,0,true,true,true,'education-flyway','education-flyway'), + (6991,'快递公司','',2,1,6990,'express','lucide:package-check','mall/trade/delivery/express/index','Express',0,true,true,true,'education-flyway','education-flyway'), + (6992,'快递查询','trade:delivery:express:query',3,1,6991,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6993,'快递创建','trade:delivery:express:create',3,2,6991,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6994,'快递更新','trade:delivery:express:update',3,3,6991,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6995,'快递删除','trade:delivery:express:delete',3,4,6991,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6996,'快递导出','trade:delivery:express:export',3,5,6991,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6997,'运费模板','',2,2,6990,'express-template','lucide:map','mall/trade/delivery/expressTemplate/index','ExpressTemplate',0,true,true,true,'education-flyway','education-flyway'), + (6998,'模板查询','trade:delivery:express-template:query',3,1,6997,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6999,'模板创建','trade:delivery:express-template:create',3,2,6997,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7000,'模板更新','trade:delivery:express-template:update',3,3,6997,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7001,'模板删除','trade:delivery:express-template:delete',3,4,6997,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7002,'自提门店','',2,3,6990,'pick-up-store','lucide:store','mall/trade/delivery/pickUpStore/index','PickUpStore',0,true,true,true,'education-flyway','education-flyway'), + (7003,'门店查询','trade:delivery:pick-up-store:query',3,1,7002,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7004,'门店创建','trade:delivery:pick-up-store:create',3,2,7002,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7005,'门店更新','trade:delivery:pick-up-store:update',3,3,7002,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7006,'门店删除','trade:delivery:pick-up-store:delete',3,4,7002,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway') + ON CONFLICT (id) DO NOTHING; + + SELECT count(*) INTO installed FROM system_menu WHERE deleted=0 AND status=0 AND ( + (id=6990 AND type=1 AND parent_id=6960 AND path='delivery') + OR (id=6991 AND type=2 AND parent_id=6990 + AND component='mall/trade/delivery/express/index' AND component_name='Express') + OR (id BETWEEN 6992 AND 6996 AND type=3 AND parent_id=6991 + AND permission IN ('trade:delivery:express:query','trade:delivery:express:create', + 'trade:delivery:express:update','trade:delivery:express:delete', + 'trade:delivery:express:export')) + OR (id=6997 AND type=2 AND parent_id=6990 + AND component='mall/trade/delivery/expressTemplate/index' AND component_name='ExpressTemplate') + OR (id BETWEEN 6998 AND 7001 AND type=3 AND parent_id=6997 + AND permission IN ('trade:delivery:express-template:query', + 'trade:delivery:express-template:create','trade:delivery:express-template:update', + 'trade:delivery:express-template:delete')) + OR (id=7002 AND type=2 AND parent_id=6990 + AND component='mall/trade/delivery/pickUpStore/index' AND component_name='PickUpStore') + OR (id BETWEEN 7003 AND 7006 AND type=3 AND parent_id=7002 + AND permission IN ('trade:delivery:pick-up-store:query', + 'trade:delivery:pick-up-store:create','trade:delivery:pick-up-store:update', + 'trade:delivery:pick-up-store:delete')) + ); + IF installed<>17 THEN + RAISE EXCEPTION 'Education native Trade delivery menu shape conflict' + USING ERRCODE='23505'; + END IF; +END $$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4420__activate_native_trade_after_sale.sql b/yudao-module-education/src/main/resources/db/migration/education/V4420__activate_native_trade_after_sale.sql new file mode 100644 index 00000000..2ecdc151 --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4420__activate_native_trade_after_sale.sql @@ -0,0 +1,224 @@ +-- Activate RuoYi Vue Pro's native tenant-safe after-sale state machine, audit +-- log, Pay Refund bridge, and existing Vben administration page. The source +-- backend stores order-level UUID refund requests without native line-item or +-- return-logistics semantics. Legacy requests therefore remain unmapped until +-- the prerequisite legacy Product/Order/Member mappings are explicitly reviewed. +DO $$ +DECLARE target_table TEXT; +BEGIN + FOREACH target_table IN ARRAY ARRAY['trade_after_sale','trade_after_sale_log'] LOOP + IF to_regclass(target_table) IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=current_schema() AND table_name=target_table AND column_name='tenant_id' + ) THEN + RAISE EXCEPTION 'Existing % requires explicit tenant mapping before V4420', target_table + USING ERRCODE='23514'; + END IF; + END LOOP; + + IF to_regclass('trade_order') IS NULL OR to_regclass('trade_order_item') IS NULL + OR to_regclass('product_spu') IS NULL OR to_regclass('product_sku') IS NULL + OR to_regclass('pay_refund') IS NULL OR to_regclass('trade_delivery_express') IS NULL THEN + RAISE EXCEPTION 'V4420 requires tenant-scoped Trade Order, Product, Pay Refund, and Delivery from V4340/V4370/V4390/V4410' + USING ERRCODE='23514'; + END IF; +END $$; + +-- Composite uniqueness lets every redundant after-sale identity be checked as +-- one tenant-qualified ownership path instead of trusting application lookups. +CREATE UNIQUE INDEX IF NOT EXISTS uk_trade_order_tenant_user_id + ON trade_order (tenant_id,user_id,id); +CREATE UNIQUE INDEX IF NOT EXISTS uk_trade_order_item_tenant_order_user_id + ON trade_order_item (tenant_id,order_id,user_id,id); + +CREATE TABLE IF NOT EXISTS trade_after_sale ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME trade_after_sale_seq), + tenant_id BIGINT NOT NULL, + no VARCHAR(64) NOT NULL, + status SMALLINT NOT NULL, + way SMALLINT NOT NULL, + type SMALLINT NOT NULL, + user_id BIGINT NOT NULL, + apply_reason VARCHAR(255) NOT NULL, + apply_description VARCHAR(1024), + apply_pic_urls TEXT, + order_id BIGINT NOT NULL, + order_no VARCHAR(64) NOT NULL, + order_item_id BIGINT NOT NULL, + spu_id BIGINT NOT NULL, + spu_name VARCHAR(255) NOT NULL, + sku_id BIGINT NOT NULL, + properties TEXT, + pic_url VARCHAR(1024), + count INTEGER NOT NULL, + audit_time TIMESTAMP, + audit_user_id BIGINT, + audit_reason VARCHAR(1024), + refund_price INTEGER NOT NULL, + pay_refund_id BIGINT, + refund_time TIMESTAMP, + logistics_id BIGINT, + logistics_no VARCHAR(128), + delivery_time TIMESTAMP, + receive_time TIMESTAMP, + receive_reason VARCHAR(1024), + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_trade_after_sale_enum CHECK ( + status IN (10,20,30,40,50,61,62,63) + AND way IN (10,20) AND type IN (10,20)), + CONSTRAINT ck_trade_after_sale_required CHECK ( + user_id>0 AND count>0 AND refund_price>=0 + AND btrim(no)<>'' AND btrim(order_no)<>'' AND btrim(apply_reason)<>'' + AND btrim(spu_name)<>''), + CONSTRAINT ck_trade_after_sale_json CHECK ( + (apply_pic_urls IS NULL OR jsonb_typeof(apply_pic_urls::jsonb)='array') + AND (properties IS NULL OR jsonb_typeof(properties::jsonb)='array')), + CONSTRAINT ck_trade_after_sale_audit CHECK ( + (audit_user_id IS NULL OR audit_user_id>0) + AND (status<>62 OR (audit_time IS NOT NULL AND audit_user_id IS NOT NULL + AND audit_reason IS NOT NULL AND btrim(audit_reason)<>''))), + CONSTRAINT ck_trade_after_sale_delivery CHECK ( + (logistics_id IS NULL OR logistics_id>0) + AND (status<>30 OR (way=20 AND logistics_id IS NOT NULL + AND logistics_no IS NOT NULL AND btrim(logistics_no)<>'' AND delivery_time IS NOT NULL)) + AND (way<>10 OR (logistics_id IS NULL AND logistics_no IS NULL AND delivery_time IS NULL))), + CONSTRAINT ck_trade_after_sale_receive CHECK ( + status<>63 OR (receive_time IS NOT NULL AND receive_reason IS NOT NULL + AND btrim(receive_reason)<>'')), + CONSTRAINT ck_trade_after_sale_refund CHECK ( + (pay_refund_id IS NULL OR (pay_refund_id>0 AND refund_price>0 AND status IN (40,50))) + AND (status<>50 OR refund_time IS NOT NULL)), + CONSTRAINT fk_trade_after_sale_order + FOREIGN KEY (tenant_id,user_id,order_id) + REFERENCES trade_order (tenant_id,user_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_trade_after_sale_order_item + FOREIGN KEY (tenant_id,order_id,user_id,order_item_id) + REFERENCES trade_order_item (tenant_id,order_id,user_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_trade_after_sale_spu + FOREIGN KEY (tenant_id,spu_id) + REFERENCES product_spu (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_trade_after_sale_sku + FOREIGN KEY (tenant_id,spu_id,sku_id) + REFERENCES product_sku (tenant_id,spu_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_trade_after_sale_pay_refund + FOREIGN KEY (tenant_id,pay_refund_id) + REFERENCES pay_refund (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_trade_after_sale_logistics + FOREIGN KEY (tenant_id,logistics_id) + REFERENCES trade_delivery_express (tenant_id,id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_trade_after_sale_tenant_no + ON trade_after_sale (tenant_id,no); +CREATE INDEX IF NOT EXISTS idx_trade_after_sale_tenant_page + ON trade_after_sale (tenant_id,status,create_time DESC,id DESC) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_trade_after_sale_tenant_user + ON trade_after_sale (tenant_id,user_id,create_time DESC,id DESC) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_trade_after_sale_tenant_order + ON trade_after_sale (tenant_id,order_id,order_item_id,id DESC) WHERE deleted=false; +CREATE UNIQUE INDEX IF NOT EXISTS uk_trade_after_sale_tenant_active_item + ON trade_after_sale (tenant_id,order_item_id) + WHERE deleted=false AND status IN (10,20,30,40); + +CREATE TABLE IF NOT EXISTS trade_after_sale_log ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME trade_after_sale_log_seq), + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + user_type SMALLINT NOT NULL, + after_sale_id BIGINT NOT NULL, + before_status SMALLINT, + after_status SMALLINT NOT NULL, + operate_type SMALLINT NOT NULL, + content VARCHAR(1024) NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_trade_after_sale_log_user CHECK ( + (user_type=0 AND user_id=0) OR (user_type IN (1,2) AND user_id>0)), + CONSTRAINT ck_trade_after_sale_log_status CHECK ( + (before_status IS NULL OR before_status IN (10,20,30,40,50,61,62,63)) + AND after_status IN (10,20,30,40,50,61,62,63)), + CONSTRAINT ck_trade_after_sale_log_operate CHECK ( + operate_type IN (10,11,12,20,21,22,30,31,32,40)), + CONSTRAINT ck_trade_after_sale_log_content CHECK (btrim(content)<>''), + CONSTRAINT fk_trade_after_sale_log_after_sale + FOREIGN KEY (tenant_id,after_sale_id) + REFERENCES trade_after_sale (tenant_id,id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_trade_after_sale_log_tenant_after_sale_time + ON trade_after_sale_log (tenant_id,after_sale_id,create_time DESC,id DESC) WHERE deleted=false; + +-- The order item keeps the current/last native after-sale identifier. Qualify +-- it by tenant so a same-number record in another tenant can never be attached. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint + WHERE conrelid='trade_order_item'::REGCLASS + AND conname='fk_trade_order_item_after_sale') THEN + ALTER TABLE trade_order_item ADD CONSTRAINT fk_trade_order_item_after_sale + FOREIGN KEY (tenant_id,after_sale_id) + REFERENCES trade_after_sale (tenant_id,id) ON DELETE RESTRICT; + END IF; +END $$; + +DO $$ +DECLARE valid_shape INTEGER; +BEGIN + SELECT count(*) INTO valid_shape + FROM information_schema.columns c + WHERE c.table_schema=current_schema() AND ( + (c.table_name='trade_after_sale' AND c.column_name IN ( + 'id','tenant_id','no','status','way','type','user_id','apply_reason', + 'apply_description','apply_pic_urls','order_id','order_no','order_item_id', + 'spu_id','spu_name','sku_id','properties','pic_url','count','audit_time', + 'audit_user_id','audit_reason','refund_price','pay_refund_id','refund_time', + 'logistics_id','logistics_no','delivery_time','receive_time','receive_reason', + 'creator','create_time','updater','update_time','deleted')) + OR (c.table_name='trade_after_sale_log' AND c.column_name IN ( + 'id','tenant_id','user_id','user_type','after_sale_id','before_status', + 'after_status','operate_type','content','creator','create_time','updater', + 'update_time','deleted')) + ); + IF valid_shape<>49 THEN + RAISE EXCEPTION 'Native Trade after-sale table shape conflict' USING ERRCODE='23514'; + END IF; +END $$; + +DO $$ +DECLARE installed INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN RETURN; END IF; + + INSERT INTO system_menu ( + id,name,permission,type,sort,parent_id,path,icon,component,component_name, + status,visible,keep_alive,always_show,creator,updater + ) VALUES + (7010,'售后退款','',2,3,6960,'after-sale','lucide:package-open','mall/trade/afterSale/index','TradeAfterSale',0,true,true,true,'education-flyway','education-flyway'), + (7011,'售后查询','trade:after-sale:query',3,1,7010,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7012,'同意售后','trade:after-sale:agree',3,2,7010,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7013,'拒绝售后','trade:after-sale:disagree',3,3,7010,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7014,'确认退货','trade:after-sale:receive',3,4,7010,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7015,'确认退款','trade:after-sale:refund',3,5,7010,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway') + ON CONFLICT (id) DO NOTHING; + + SELECT count(*) INTO installed FROM system_menu WHERE deleted=0 AND status=0 AND ( + (id=7010 AND type=2 AND parent_id=6960 AND path='after-sale' + AND component='mall/trade/afterSale/index' AND component_name='TradeAfterSale') + OR (id BETWEEN 7011 AND 7015 AND type=3 AND parent_id=7010 + AND permission IN ('trade:after-sale:query','trade:after-sale:agree', + 'trade:after-sale:disagree','trade:after-sale:receive','trade:after-sale:refund')) + ); + IF installed<>6 THEN + RAISE EXCEPTION 'Education native Trade after-sale menu shape conflict' + USING ERRCODE='23505'; + END IF; +END $$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4430__activate_native_trade_brokerage.sql b/yudao-module-education/src/main/resources/db/migration/education/V4430__activate_native_trade_brokerage.sql new file mode 100644 index 00000000..d5f844ea --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4430__activate_native_trade_brokerage.sql @@ -0,0 +1,257 @@ +-- Activate RuoYi Vue Pro's native tenant-safe brokerage relationships, +-- commission records, withdrawals, Pay Transfer bridge, and existing Vben +-- administration pages. The source referral/settlement UUID aggregates remain +-- unmapped until Member, referral-lead, Order, and proof/export identities are +-- explicitly reconciled. +DO $$ +DECLARE target_table TEXT; +BEGIN + FOREACH target_table IN ARRAY ARRAY[ + 'trade_brokerage_user','trade_brokerage_record','trade_brokerage_withdraw' + ] LOOP + IF to_regclass(target_table) IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=current_schema() AND table_name=target_table AND column_name='tenant_id' + ) THEN + RAISE EXCEPTION 'Existing % requires explicit tenant mapping before V4430', target_table + USING ERRCODE='23514'; + END IF; + END LOOP; + + IF to_regclass('trade_config') IS NULL OR to_regclass('trade_order') IS NULL + OR to_regclass('pay_transfer') IS NULL THEN + RAISE EXCEPTION 'V4430 requires tenant-scoped Trade Config/Order and Pay Transfer from V4360/V4390' + USING ERRCODE='23514'; + END IF; +END $$; + +-- A percentage above 100 would make the native withdrawal transfer amount +-- zero or negative. Earlier Trade activation only enforced a lower bound. +ALTER TABLE trade_config + ADD CONSTRAINT ck_trade_config_brokerage_withdraw_fee_range + CHECK (brokerage_withdraw_fee_percent BETWEEN 0 AND 99); + +CREATE TABLE IF NOT EXISTS trade_brokerage_user ( + id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + bind_user_id BIGINT, + bind_user_time TIMESTAMP, + brokerage_enabled BOOLEAN NOT NULL DEFAULT TRUE, + brokerage_time TIMESTAMP, + brokerage_price INTEGER NOT NULL DEFAULT 0, + frozen_price INTEGER NOT NULL DEFAULT 0, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_trade_brokerage_user_identity CHECK ( + id>0 AND (bind_user_id IS NULL OR (bind_user_id>0 AND bind_user_id<>id))), + CONSTRAINT ck_trade_brokerage_user_bind CHECK ( + (bind_user_id IS NULL AND bind_user_time IS NULL) + OR (bind_user_id IS NOT NULL AND bind_user_time IS NOT NULL)), + CONSTRAINT ck_trade_brokerage_user_enabled CHECK ( + NOT brokerage_enabled OR brokerage_time IS NOT NULL), + CONSTRAINT fk_trade_brokerage_user_bind + FOREIGN KEY (tenant_id,bind_user_id) + REFERENCES trade_brokerage_user (tenant_id,id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_trade_brokerage_user_tenant_bind + ON trade_brokerage_user (tenant_id,bind_user_id,bind_user_time DESC,id DESC) + WHERE deleted=false AND bind_user_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_trade_brokerage_user_tenant_enabled + ON trade_brokerage_user (tenant_id,brokerage_enabled,create_time DESC,id DESC) + WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS trade_brokerage_record ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME trade_brokerage_record_seq), + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + biz_id VARCHAR(128) NOT NULL, + biz_type SMALLINT NOT NULL, + title VARCHAR(128) NOT NULL, + description VARCHAR(1024) NOT NULL, + price INTEGER NOT NULL, + total_price INTEGER NOT NULL, + status SMALLINT NOT NULL, + frozen_days INTEGER NOT NULL DEFAULT 0, + unfreeze_time TIMESTAMP, + source_user_level SMALLINT, + source_user_id BIGINT, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_trade_brokerage_record_enum CHECK ( + biz_type IN (1,2,3) AND status IN (0,1,2)), + CONSTRAINT ck_trade_brokerage_record_required CHECK ( + user_id>0 AND price<>0 AND frozen_days>=0 + AND btrim(biz_id)<>'' AND btrim(title)<>'' AND btrim(description)<>''), + CONSTRAINT ck_trade_brokerage_record_source CHECK ( + (biz_type=1 AND source_user_level IN (1,2) AND source_user_id IS NOT NULL + AND source_user_id>0) + OR (biz_type IN (2,3) AND source_user_level IS NULL AND source_user_id IS NULL)), + CONSTRAINT ck_trade_brokerage_record_amount_direction CHECK ( + (biz_type IN (1,3) AND price>0) OR (biz_type=2 AND price<0)), + CONSTRAINT ck_trade_brokerage_record_freeze CHECK ( + status<>0 OR (frozen_days>0 AND unfreeze_time IS NOT NULL)), + CONSTRAINT fk_trade_brokerage_record_user + FOREIGN KEY (tenant_id,user_id) + REFERENCES trade_brokerage_user (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_trade_brokerage_record_source_user + FOREIGN KEY (tenant_id,source_user_id) + REFERENCES trade_brokerage_user (tenant_id,id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_trade_brokerage_record_tenant_biz_user + ON trade_brokerage_record + (tenant_id,user_id,biz_type,biz_id,COALESCE(source_user_level,0)) + WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_trade_brokerage_record_tenant_user_time + ON trade_brokerage_record (tenant_id,user_id,create_time DESC,id DESC) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_trade_brokerage_record_tenant_unfreeze + ON trade_brokerage_record (tenant_id,unfreeze_time,id) + WHERE deleted=false AND status=0; + +CREATE TABLE IF NOT EXISTS trade_brokerage_withdraw ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME trade_brokerage_withdraw_seq), + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + price INTEGER NOT NULL, + fee_price INTEGER NOT NULL DEFAULT 0, + total_price INTEGER NOT NULL DEFAULT 0, + type SMALLINT NOT NULL, + user_name VARCHAR(128), + user_account VARCHAR(256), + qr_code_url VARCHAR(1024), + bank_name VARCHAR(128), + bank_address VARCHAR(256), + status SMALLINT NOT NULL DEFAULT 0, + audit_reason VARCHAR(1024), + audit_time TIMESTAMP, + remark VARCHAR(1024), + pay_transfer_id BIGINT, + transfer_channel_code VARCHAR(32), + transfer_time TIMESTAMP, + transfer_error_msg VARCHAR(1024), + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_trade_brokerage_withdraw_enum CHECK ( + type IN (1,2,3,4,5,6) AND status IN (0,10,11,20,21)), + CONSTRAINT ck_trade_brokerage_withdraw_amount CHECK ( + user_id>0 AND price>0 AND fee_price>=0 AND fee_price'' + AND user_account IS NOT NULL AND btrim(user_account)<>'' + AND bank_name IS NOT NULL AND btrim(bank_name)<>'') + OR (type IN (3,4) AND qr_code_url IS NOT NULL AND btrim(qr_code_url)<>'') + OR (type IN (5,6) AND user_name IS NOT NULL AND btrim(user_name)<>'' + AND user_account IS NOT NULL AND btrim(user_account)<>'')), + CONSTRAINT ck_trade_brokerage_withdraw_audit CHECK ( + (status=0) + OR (status IN (10,11,21) AND audit_time IS NOT NULL) + OR (status=20 AND audit_time IS NOT NULL AND audit_reason IS NOT NULL + AND btrim(audit_reason)<>'')), + CONSTRAINT ck_trade_brokerage_withdraw_transfer CHECK ( + (pay_transfer_id IS NULL OR (type IN (1,5,6) AND pay_transfer_id>0)) + AND (transfer_time IS NULL OR pay_transfer_id IS NOT NULL)), + CONSTRAINT fk_trade_brokerage_withdraw_user + FOREIGN KEY (tenant_id,user_id) + REFERENCES trade_brokerage_user (tenant_id,id) ON DELETE RESTRICT, + CONSTRAINT fk_trade_brokerage_withdraw_pay_transfer + FOREIGN KEY (tenant_id,pay_transfer_id) + REFERENCES pay_transfer (tenant_id,id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_trade_brokerage_withdraw_tenant_user_time + ON trade_brokerage_withdraw (tenant_id,user_id,create_time DESC,id DESC) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_trade_brokerage_withdraw_tenant_status_time + ON trade_brokerage_withdraw (tenant_id,status,create_time DESC,id DESC) WHERE deleted=false; +CREATE UNIQUE INDEX IF NOT EXISTS uk_trade_brokerage_withdraw_tenant_pay_transfer + ON trade_brokerage_withdraw (tenant_id,pay_transfer_id) + WHERE deleted=false AND pay_transfer_id IS NOT NULL; + +-- Orders snapshot the first-level promoter used for commission calculation. +-- Qualify the reference by tenant so same-number members cannot cross tenants. +ALTER TABLE trade_order ADD CONSTRAINT fk_trade_order_brokerage_user + FOREIGN KEY (tenant_id,brokerage_user_id) + REFERENCES trade_brokerage_user (tenant_id,id) ON DELETE RESTRICT; + +DO $$ +DECLARE valid_shape INTEGER; +BEGIN + SELECT count(*) INTO valid_shape + FROM information_schema.columns c + WHERE c.table_schema=current_schema() AND ( + (c.table_name='trade_brokerage_user' AND c.column_name IN ( + 'id','tenant_id','bind_user_id','bind_user_time','brokerage_enabled', + 'brokerage_time','brokerage_price','frozen_price','creator','create_time', + 'updater','update_time','deleted')) + OR (c.table_name='trade_brokerage_record' AND c.column_name IN ( + 'id','tenant_id','user_id','biz_id','biz_type','title','description','price', + 'total_price','status','frozen_days','unfreeze_time','source_user_level', + 'source_user_id','creator','create_time','updater','update_time','deleted')) + OR (c.table_name='trade_brokerage_withdraw' AND c.column_name IN ( + 'id','tenant_id','user_id','price','fee_price','total_price','type','user_name', + 'user_account','qr_code_url','bank_name','bank_address','status','audit_reason', + 'audit_time','remark','pay_transfer_id','transfer_channel_code','transfer_time', + 'transfer_error_msg','creator','create_time','updater','update_time','deleted')) + ); + IF valid_shape<>57 THEN + RAISE EXCEPTION 'Native Trade brokerage table shape conflict' USING ERRCODE='23514'; + END IF; +END $$; + +DO $$ +DECLARE installed INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN RETURN; END IF; + + INSERT INTO system_menu ( + id,name,permission,type,sort,parent_id,path,icon,component,component_name, + status,visible,keep_alive,always_show,creator,updater + ) VALUES + (7020,'分销管理','',1,5,6960,'brokerage','lucide:network',NULL,NULL,0,true,true,true,'education-flyway','education-flyway'), + (7021,'分销用户','',2,1,7020,'user','lucide:users','mall/trade/brokerage/user/index','TradeBrokerageUser',0,true,true,true,'education-flyway','education-flyway'), + (7022,'分销用户查询','trade:brokerage-user:query',3,1,7021,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7023,'创建分销用户','trade:brokerage-user:create',3,2,7021,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7024,'修改上级推广人','trade:brokerage-user:update-bind-user',3,3,7021,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7025,'清除上级推广人','trade:brokerage-user:clear-bind-user',3,4,7021,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7026,'修改推广资格','trade:brokerage-user:update-brokerage-enable',3,5,7021,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7027,'佣金记录','',2,2,7020,'record','lucide:receipt','mall/trade/brokerage/record/index','TradeBrokerageRecord',0,true,true,true,'education-flyway','education-flyway'), + (7028,'佣金记录查询','trade:brokerage-record:query',3,1,7027,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7029,'佣金提现','',2,3,7020,'withdraw','lucide:banknote','mall/trade/brokerage/withdraw/index','BrokerageWithdraw',0,true,true,true,'education-flyway','education-flyway'), + (7030,'佣金提现查询','trade:brokerage-withdraw:query',3,1,7029,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7031,'佣金提现审核','trade:brokerage-withdraw:audit',3,2,7029,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway') + ON CONFLICT (id) DO NOTHING; + + SELECT count(*) INTO installed FROM system_menu WHERE deleted=0 AND status=0 AND ( + (id=7020 AND type=1 AND parent_id=6960 AND path='brokerage') + OR (id=7021 AND type=2 AND parent_id=7020 AND component='mall/trade/brokerage/user/index' + AND component_name='TradeBrokerageUser') + OR (id BETWEEN 7022 AND 7026 AND type=3 AND parent_id=7021 + AND permission IN ('trade:brokerage-user:query','trade:brokerage-user:create', + 'trade:brokerage-user:update-bind-user','trade:brokerage-user:clear-bind-user', + 'trade:brokerage-user:update-brokerage-enable')) + OR (id=7027 AND type=2 AND parent_id=7020 AND component='mall/trade/brokerage/record/index' + AND component_name='TradeBrokerageRecord') + OR (id=7028 AND type=3 AND parent_id=7027 AND permission='trade:brokerage-record:query') + OR (id=7029 AND type=2 AND parent_id=7020 AND component='mall/trade/brokerage/withdraw/index' + AND component_name='BrokerageWithdraw') + OR (id BETWEEN 7030 AND 7031 AND type=3 AND parent_id=7029 + AND permission IN ('trade:brokerage-withdraw:query','trade:brokerage-withdraw:audit')) + ); + IF installed<>12 THEN + RAISE EXCEPTION 'Education native Trade brokerage menu shape conflict' + USING ERRCODE='23505'; + END IF; +END $$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4440__activate_native_promotion_seckill.sql b/yudao-module-education/src/main/resources/db/migration/education/V4440__activate_native_promotion_seckill.sql new file mode 100644 index 00000000..03552892 --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4440__activate_native_promotion_seckill.sql @@ -0,0 +1,325 @@ +-- Activate RuoYi Vue Pro's native tenant-safe Seckill configuration, +-- activities, SKU stock, Trade order bridge, and existing Vben administration. +-- The source system has no Seckill capability, so this migration intentionally +-- starts with empty native tables instead of inventing legacy records. +DO $$ +DECLARE target_table TEXT; +BEGIN + FOREACH target_table IN ARRAY ARRAY[ + 'promotion_seckill_config','promotion_seckill_activity','promotion_seckill_product' + ] LOOP + IF to_regclass(target_table) IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=current_schema() AND table_name=target_table AND column_name='tenant_id' + ) THEN + RAISE EXCEPTION 'Existing % requires explicit tenant mapping before V4440', target_table + USING ERRCODE='23514'; + END IF; + END LOOP; + + IF to_regclass('product_spu') IS NULL OR to_regclass('product_sku') IS NULL + OR to_regclass('trade_order') IS NULL THEN + RAISE EXCEPTION 'V4440 requires tenant-scoped Product SPU/SKU and Trade Order from V4370/V4390' + USING ERRCODE='23514'; + END IF; +END $$; + +CREATE OR REPLACE FUNCTION promotion_seckill_slider_urls_valid(payload TEXT) +RETURNS BOOLEAN AS $$ +DECLARE parsed JSONB; +BEGIN + IF payload IS NULL THEN RETURN FALSE; END IF; + parsed := payload::JSONB; + RETURN jsonb_typeof(parsed)='array' + AND jsonb_array_length(parsed) BETWEEN 1 AND 5 + AND NOT EXISTS ( + SELECT 1 FROM jsonb_array_elements(parsed) item + WHERE jsonb_typeof(item)<>'string' OR NULLIF(BTRIM(item #>> '{}'),'') IS NULL + ); +EXCEPTION WHEN OTHERS THEN + RETURN FALSE; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +CREATE OR REPLACE FUNCTION promotion_seckill_clock_range_valid(start_value TEXT, end_value TEXT) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN start_value::TIME < end_value::TIME; +EXCEPTION WHEN OTHERS THEN + RETURN FALSE; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +CREATE TABLE IF NOT EXISTS promotion_seckill_config ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME promotion_seckill_config_seq), + tenant_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + start_time VARCHAR(8) NOT NULL, + end_time VARCHAR(8) NOT NULL, + slider_pic_urls TEXT NOT NULL, + status SMALLINT NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_promotion_seckill_config_name CHECK (NULLIF(BTRIM(name),'') IS NOT NULL), + CONSTRAINT ck_promotion_seckill_config_time CHECK ( + promotion_seckill_clock_range_valid(start_time,end_time)), + CONSTRAINT ck_promotion_seckill_config_slider CHECK ( + promotion_seckill_slider_urls_valid(slider_pic_urls)), + CONSTRAINT ck_promotion_seckill_config_status CHECK (status IN (0,1)) +); + +CREATE INDEX IF NOT EXISTS idx_promotion_seckill_config_tenant_page + ON promotion_seckill_config (tenant_id,status,start_time,id) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS promotion_seckill_activity ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME promotion_seckill_activity_seq), + tenant_id BIGINT NOT NULL, + spu_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + status SMALLINT NOT NULL, + remark VARCHAR(1024), + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + sort INTEGER NOT NULL DEFAULT 0, + config_ids VARCHAR(2048) NOT NULL, + total_limit_count INTEGER NOT NULL, + single_limit_count INTEGER NOT NULL, + stock INTEGER NOT NULL, + total_stock INTEGER NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_promotion_seckill_activity_name CHECK (NULLIF(BTRIM(name),'') IS NOT NULL), + CONSTRAINT ck_promotion_seckill_activity_status CHECK (status IN (0,1)), + CONSTRAINT ck_promotion_seckill_activity_time CHECK (start_time=0), + CONSTRAINT ck_promotion_seckill_activity_configs CHECK ( + config_ids ~ '^[1-9][0-9]*(,[1-9][0-9]*)*$'), + CONSTRAINT ck_promotion_seckill_activity_limits CHECK ( + total_limit_count>0 AND single_limit_count>0 AND single_limit_count<=total_limit_count), + CONSTRAINT ck_promotion_seckill_activity_stock CHECK ( + total_stock>0 AND stock>=0 AND stock<=total_stock), + CONSTRAINT fk_promotion_seckill_activity_spu + FOREIGN KEY (tenant_id,spu_id) + REFERENCES product_spu (tenant_id,id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_promotion_seckill_activity_tenant_page + ON promotion_seckill_activity (tenant_id,status,id DESC) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_promotion_seckill_activity_tenant_spu + ON promotion_seckill_activity (tenant_id,spu_id,status,id) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_promotion_seckill_activity_tenant_match + ON promotion_seckill_activity (tenant_id,status,start_time,end_time,sort,id) + WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS promotion_seckill_product ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME promotion_seckill_product_seq), + tenant_id BIGINT NOT NULL, + activity_id BIGINT NOT NULL, + config_ids VARCHAR(2048) NOT NULL, + spu_id BIGINT NOT NULL, + sku_id BIGINT NOT NULL, + seckill_price INTEGER NOT NULL, + stock INTEGER NOT NULL, + activity_status SMALLINT NOT NULL, + activity_start_time TIMESTAMP NOT NULL, + activity_end_time TIMESTAMP NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_promotion_seckill_product_configs CHECK ( + config_ids ~ '^[1-9][0-9]*(,[1-9][0-9]*)*$'), + CONSTRAINT ck_promotion_seckill_product_amount CHECK (seckill_price>0 AND stock>=0), + CONSTRAINT ck_promotion_seckill_product_status CHECK (activity_status IN (0,1)), + CONSTRAINT ck_promotion_seckill_product_time CHECK (activity_start_timecardinality(config_id_array) THEN + RAISE EXCEPTION 'ck_promotion_seckill_activity_configs: duplicate config id' + USING ERRCODE='23514'; + END IF; + SELECT count(*) INTO existing_count + FROM promotion_seckill_config + WHERE tenant_id=NEW.tenant_id AND id=ANY(config_id_array) AND deleted=false; + IF existing_count<>cardinality(config_id_array) THEN + RAISE EXCEPTION 'fk_promotion_seckill_activity_config: config does not exist in tenant %', + NEW.tenant_id USING ERRCODE='23503'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_promotion_seckill_activity_configs ON promotion_seckill_activity; +CREATE TRIGGER trg_promotion_seckill_activity_configs +BEFORE INSERT OR UPDATE OF tenant_id,config_ids ON promotion_seckill_activity +FOR EACH ROW EXECUTE FUNCTION promotion_validate_seckill_config_ids(); + +CREATE OR REPLACE FUNCTION promotion_validate_seckill_product_snapshot() +RETURNS TRIGGER AS $$ +DECLARE parent_spu_id BIGINT; +DECLARE parent_config_ids VARCHAR(2048); +DECLARE parent_status SMALLINT; +DECLARE parent_start_time TIMESTAMP; +DECLARE parent_end_time TIMESTAMP; +BEGIN + SELECT spu_id,config_ids,status,start_time,end_time + INTO parent_spu_id,parent_config_ids,parent_status,parent_start_time,parent_end_time + FROM promotion_seckill_activity + WHERE tenant_id=NEW.tenant_id AND id=NEW.activity_id AND deleted=false; + IF NOT FOUND THEN + RAISE EXCEPTION 'fk_promotion_seckill_product_activity: activity does not exist in tenant %', + NEW.tenant_id USING ERRCODE='23503'; + END IF; + IF NEW.spu_id<>parent_spu_id OR NEW.config_ids<>parent_config_ids + OR NEW.activity_status<>parent_status + OR NEW.activity_start_time<>parent_start_time OR NEW.activity_end_time<>parent_end_time THEN + RAISE EXCEPTION 'ck_promotion_seckill_product_snapshot: product snapshot differs from activity' + USING ERRCODE='23514'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_promotion_seckill_product_snapshot ON promotion_seckill_product; +CREATE TRIGGER trg_promotion_seckill_product_snapshot +BEFORE INSERT OR UPDATE OF tenant_id,activity_id,config_ids,spu_id,activity_status, + activity_start_time,activity_end_time ON promotion_seckill_product +FOR EACH ROW EXECUTE FUNCTION promotion_validate_seckill_product_snapshot(); + +CREATE OR REPLACE FUNCTION promotion_protect_used_seckill_config() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.deleted AND NOT OLD.deleted AND EXISTS ( + SELECT 1 FROM promotion_seckill_activity activity + WHERE activity.tenant_id=OLD.tenant_id AND activity.deleted=false + AND POSITION(',' || OLD.id::TEXT || ',' IN ',' || activity.config_ids || ',')>0 + ) THEN + RAISE EXCEPTION 'fk_promotion_seckill_config_activity: config is used by an activity' + USING ERRCODE='23503'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_promotion_seckill_config_delete ON promotion_seckill_config; +CREATE TRIGGER trg_promotion_seckill_config_delete +BEFORE UPDATE OF deleted ON promotion_seckill_config +FOR EACH ROW EXECUTE FUNCTION promotion_protect_used_seckill_config(); + +ALTER TABLE trade_order ADD CONSTRAINT fk_trade_order_seckill_activity + FOREIGN KEY (tenant_id,seckill_activity_id) + REFERENCES promotion_seckill_activity (tenant_id,id) ON DELETE RESTRICT; +ALTER TABLE trade_order ADD CONSTRAINT ck_trade_order_seckill_reference + CHECK ((type=1 AND seckill_activity_id IS NOT NULL) + OR (type<>1 AND seckill_activity_id IS NULL)); +CREATE INDEX IF NOT EXISTS idx_trade_order_tenant_seckill_activity + ON trade_order (tenant_id,seckill_activity_id,id) + WHERE deleted=false AND seckill_activity_id IS NOT NULL; + +DO $$ +DECLARE valid_shape INTEGER; +BEGIN + SELECT count(*) INTO valid_shape + FROM information_schema.columns c + WHERE c.table_schema=current_schema() AND ( + (c.table_name='promotion_seckill_config' AND c.column_name IN ( + 'id','tenant_id','name','start_time','end_time','slider_pic_urls','status', + 'creator','create_time','updater','update_time','deleted')) + OR (c.table_name='promotion_seckill_activity' AND c.column_name IN ( + 'id','tenant_id','spu_id','name','status','remark','start_time','end_time','sort', + 'config_ids','total_limit_count','single_limit_count','stock','total_stock', + 'creator','create_time','updater','update_time','deleted')) + OR (c.table_name='promotion_seckill_product' AND c.column_name IN ( + 'id','tenant_id','activity_id','config_ids','spu_id','sku_id','seckill_price','stock', + 'activity_status','activity_start_time','activity_end_time', + 'creator','create_time','updater','update_time','deleted')) + ); + IF valid_shape<>47 THEN + RAISE EXCEPTION 'Native Promotion Seckill table shape conflict' USING ERRCODE='23514'; + END IF; +END $$; + +DO $$ +DECLARE installed INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN RETURN; END IF; + + INSERT INTO system_menu ( + id,name,permission,type,sort,parent_id,path,icon,component,component_name, + status,visible,keep_alive,always_show,creator,updater + ) VALUES + (7040,'秒杀管理','',1,3,6970,'seckill','lucide:zap',NULL,NULL,0,true,true,true,'education-flyway','education-flyway'), + (7041,'秒杀商品','',2,1,7040,'activity','lucide:badge-percent','mall/promotion/seckill/activity/index','PromotionSeckillActivity',0,true,true,true,'education-flyway','education-flyway'), + (7042,'秒杀活动查询','promotion:seckill-activity:query',3,1,7041,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7043,'秒杀活动创建','promotion:seckill-activity:create',3,2,7041,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7044,'秒杀活动更新','promotion:seckill-activity:update',3,3,7041,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7045,'秒杀活动关闭','promotion:seckill-activity:close',3,4,7041,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7046,'秒杀活动删除','promotion:seckill-activity:delete',3,5,7041,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7047,'秒杀时段','',2,2,7040,'config','lucide:clock-3','mall/promotion/seckill/config/index','PromotionSeckillConfig',0,true,true,true,'education-flyway','education-flyway'), + (7048,'秒杀时段查询','promotion:seckill-config:query',3,1,7047,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7049,'秒杀时段创建','promotion:seckill-config:create',3,2,7047,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7050,'秒杀时段更新','promotion:seckill-config:update',3,3,7047,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7051,'秒杀时段删除','promotion:seckill-config:delete',3,4,7047,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway') + ON CONFLICT (id) DO NOTHING; + + SELECT count(*) INTO installed FROM system_menu WHERE deleted=0 AND status=0 AND ( + (id=7040 AND type=1 AND parent_id=6970 AND path='seckill') + OR (id=7041 AND type=2 AND parent_id=7040 + AND component='mall/promotion/seckill/activity/index' + AND component_name='PromotionSeckillActivity') + OR (id BETWEEN 7042 AND 7046 AND type=3 AND parent_id=7041 + AND permission IN ('promotion:seckill-activity:query','promotion:seckill-activity:create', + 'promotion:seckill-activity:update','promotion:seckill-activity:close', + 'promotion:seckill-activity:delete')) + OR (id=7047 AND type=2 AND parent_id=7040 + AND component='mall/promotion/seckill/config/index' + AND component_name='PromotionSeckillConfig') + OR (id BETWEEN 7048 AND 7051 AND type=3 AND parent_id=7047 + AND permission IN ('promotion:seckill-config:query','promotion:seckill-config:create', + 'promotion:seckill-config:update','promotion:seckill-config:delete')) + ); + IF installed<>12 THEN + RAISE EXCEPTION 'Education native Promotion Seckill menu shape conflict' + USING ERRCODE='23505'; + END IF; +END $$; diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4450__activate_native_promotion_combination.sql b/yudao-module-education/src/main/resources/db/migration/education/V4450__activate_native_promotion_combination.sql new file mode 100644 index 00000000..7deca32e --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4450__activate_native_promotion_combination.sql @@ -0,0 +1,422 @@ +-- Activate RuoYi Vue Pro's native tenant-safe Combination activities, +-- products, group records, Trade order bridge, and existing Vben administration. +-- The source system's "combination" value is an education question type, not +-- group buying, so this migration intentionally starts with empty native tables. +DO $$ +DECLARE target_table TEXT; +BEGIN + FOREACH target_table IN ARRAY ARRAY[ + 'promotion_combination_activity','promotion_combination_product','promotion_combination_record' + ] LOOP + IF to_regclass(target_table) IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=current_schema() AND table_name=target_table AND column_name='tenant_id' + ) THEN + RAISE EXCEPTION 'Existing % requires explicit tenant mapping before V4450', target_table + USING ERRCODE='23514'; + END IF; + END LOOP; + + IF to_regclass('product_spu') IS NULL OR to_regclass('product_sku') IS NULL + OR to_regclass('trade_order') IS NULL THEN + RAISE EXCEPTION 'V4450 requires tenant-scoped Product SPU/SKU and Trade Order from V4370/V4390' + USING ERRCODE='23514'; + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS promotion_combination_activity ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME promotion_combination_activity_seq), + tenant_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + spu_id BIGINT NOT NULL, + total_limit_count INTEGER NOT NULL, + single_limit_count INTEGER NOT NULL, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + user_size INTEGER NOT NULL, + virtual_group BOOLEAN NOT NULL DEFAULT FALSE, + status SMALLINT NOT NULL, + limit_duration INTEGER NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_promotion_combination_activity_name CHECK (NULLIF(BTRIM(name),'') IS NOT NULL), + CONSTRAINT ck_promotion_combination_activity_time CHECK (start_time0 AND single_limit_count>0 AND single_limit_count<=total_limit_count), + CONSTRAINT ck_promotion_combination_activity_group CHECK (user_size>=2 AND limit_duration>0), + CONSTRAINT ck_promotion_combination_activity_status CHECK (status IN (0,1)), + CONSTRAINT fk_promotion_combination_activity_spu + FOREIGN KEY (tenant_id,spu_id) + REFERENCES product_spu (tenant_id,id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_promotion_combination_activity_tenant_spu_enabled + ON promotion_combination_activity (tenant_id,spu_id) + WHERE deleted=false AND status=0; +CREATE INDEX IF NOT EXISTS idx_promotion_combination_activity_tenant_page + ON promotion_combination_activity (tenant_id,status,id DESC) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_promotion_combination_activity_tenant_match + ON promotion_combination_activity (tenant_id,status,start_time,end_time,id) WHERE deleted=false; + +CREATE TABLE IF NOT EXISTS promotion_combination_product ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME promotion_combination_product_seq), + tenant_id BIGINT NOT NULL, + activity_id BIGINT NOT NULL, + spu_id BIGINT NOT NULL, + sku_id BIGINT NOT NULL, + combination_price INTEGER NOT NULL, + activity_status SMALLINT NOT NULL, + activity_start_time TIMESTAMP NOT NULL, + activity_end_time TIMESTAMP NOT NULL, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_promotion_combination_product_amount CHECK (combination_price>0), + CONSTRAINT ck_promotion_combination_product_status CHECK (activity_status IN (0,1)), + CONSTRAINT ck_promotion_combination_product_time CHECK (activity_start_timeparent_spu_id OR NEW.activity_status<>parent_status + OR NEW.activity_start_time<>parent_start_time OR NEW.activity_end_time<>parent_end_time THEN + RAISE EXCEPTION 'ck_promotion_combination_product_snapshot: product snapshot differs from activity' + USING ERRCODE='23514'; + END IF; + IF NEW.combination_price>( + SELECT price FROM product_sku + WHERE tenant_id=NEW.tenant_id AND spu_id=NEW.spu_id AND id=NEW.sku_id AND deleted=false + ) THEN + RAISE EXCEPTION 'ck_promotion_combination_product_price: combination price exceeds SKU price' + USING ERRCODE='23514'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_promotion_combination_product_snapshot ON promotion_combination_product; +CREATE TRIGGER trg_promotion_combination_product_snapshot +BEFORE INSERT OR UPDATE OF tenant_id,activity_id,spu_id,sku_id,combination_price,activity_status, + activity_start_time,activity_end_time ON promotion_combination_product +FOR EACH ROW EXECUTE FUNCTION promotion_validate_combination_product_snapshot(); + +CREATE TABLE IF NOT EXISTS promotion_combination_record ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME promotion_combination_record_seq), + tenant_id BIGINT NOT NULL, + activity_id BIGINT NOT NULL, + combination_price INTEGER NOT NULL, + spu_id BIGINT NOT NULL, + spu_name VARCHAR(255) NOT NULL, + pic_url VARCHAR(1024) NOT NULL, + sku_id BIGINT NOT NULL, + count INTEGER NOT NULL, + user_id BIGINT NOT NULL, + nickname VARCHAR(128) NOT NULL DEFAULT '', + avatar VARCHAR(1024), + head_id BIGINT NOT NULL DEFAULT 0, + status SMALLINT NOT NULL, + order_id BIGINT NOT NULL, + user_size INTEGER NOT NULL, + user_count INTEGER NOT NULL, + virtual_group BOOLEAN NOT NULL DEFAULT FALSE, + expire_time TIMESTAMP NOT NULL, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (tenant_id,id), + CONSTRAINT ck_promotion_combination_record_amount CHECK (combination_price>0 AND count>=0), + CONSTRAINT ck_promotion_combination_record_identity CHECK ( + (user_id>0 AND order_id>0 AND count>0) + OR (user_id=0 AND order_id=0 AND count=0 AND head_id>0)), + CONSTRAINT ck_promotion_combination_record_head CHECK (head_id>=0), + CONSTRAINT ck_promotion_combination_record_status CHECK (status IN (0,1,2)), + CONSTRAINT ck_promotion_combination_record_group CHECK ( + user_size>=2 AND user_count>=1 AND user_count<=user_size), + CONSTRAINT ck_promotion_combination_record_time CHECK ( + start_time0; +CREATE UNIQUE INDEX IF NOT EXISTS uk_promotion_combination_record_tenant_user_activity_progress + ON promotion_combination_record (tenant_id,user_id,activity_id) + WHERE deleted=false AND user_id>0 AND status=0; +CREATE INDEX IF NOT EXISTS idx_promotion_combination_record_tenant_head + ON promotion_combination_record (tenant_id,head_id,status,id) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_promotion_combination_record_tenant_activity + ON promotion_combination_record (tenant_id,activity_id,status,id) WHERE deleted=false; +CREATE INDEX IF NOT EXISTS idx_promotion_combination_record_tenant_expire + ON promotion_combination_record (tenant_id,status,expire_time,id) + WHERE deleted=false AND head_id=0; + +CREATE OR REPLACE FUNCTION promotion_validate_combination_record_insert() +RETURNS TRIGGER AS $$ +DECLARE product_price INTEGER; +DECLARE parent_activity_id BIGINT; +DECLARE parent_user_size INTEGER; +DECLARE parent_status SMALLINT; +DECLARE parent_start_time TIMESTAMP; +DECLARE parent_expire_time TIMESTAMP; +DECLARE member_count INTEGER; +DECLARE order_type SMALLINT; +DECLARE order_user_id BIGINT; +DECLARE order_activity_id BIGINT; +BEGIN + SELECT combination_price INTO product_price + FROM promotion_combination_product + WHERE tenant_id=NEW.tenant_id AND activity_id=NEW.activity_id + AND spu_id=NEW.spu_id AND sku_id=NEW.sku_id AND deleted=false; + IF NOT FOUND OR product_price<>NEW.combination_price THEN + RAISE EXCEPTION 'ck_promotion_combination_record_product: record product snapshot is invalid' + USING ERRCODE='23514'; + END IF; + + IF NEW.head_id<>0 THEN + SELECT activity_id,user_size,status,start_time,expire_time + INTO parent_activity_id,parent_user_size,parent_status,parent_start_time,parent_expire_time + FROM promotion_combination_record + WHERE tenant_id=NEW.tenant_id AND id=NEW.head_id AND head_id=0 AND deleted=false + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'fk_promotion_combination_record_head: head record does not exist in tenant %', + NEW.tenant_id USING ERRCODE='23503'; + END IF; + IF parent_activity_id<>NEW.activity_id OR parent_user_size<>NEW.user_size + OR parent_status<>0 OR parent_start_time<>NEW.start_time + OR parent_expire_time<>NEW.expire_time THEN + RAISE EXCEPTION 'ck_promotion_combination_record_head: member differs from active head' + USING ERRCODE='23514'; + END IF; + SELECT count(*) INTO member_count + FROM promotion_combination_record + WHERE tenant_id=NEW.tenant_id AND head_id=NEW.head_id AND deleted=false; + IF member_count>=parent_user_size-1 THEN + RAISE EXCEPTION 'ck_promotion_combination_record_capacity: group is full' + USING ERRCODE='23514'; + END IF; + ELSIF NEW.user_count<>1 THEN + RAISE EXCEPTION 'ck_promotion_combination_record_head: a new head must start with one member' + USING ERRCODE='23514'; + END IF; + + IF NEW.order_id>0 THEN + SELECT type,user_id,combination_activity_id + INTO order_type,order_user_id,order_activity_id + FROM trade_order + WHERE tenant_id=NEW.tenant_id AND id=NEW.order_id AND deleted=false; + IF NOT FOUND THEN + RAISE EXCEPTION 'fk_promotion_combination_record_order: order does not exist in tenant %', + NEW.tenant_id USING ERRCODE='23503'; + END IF; + IF order_type<>3 OR order_user_id<>NEW.user_id OR order_activity_id<>NEW.activity_id THEN + RAISE EXCEPTION 'ck_promotion_combination_record_order: order snapshot differs from record' + USING ERRCODE='23514'; + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_promotion_combination_record_insert ON promotion_combination_record; +CREATE TRIGGER trg_promotion_combination_record_insert +BEFORE INSERT ON promotion_combination_record +FOR EACH ROW EXECUTE FUNCTION promotion_validate_combination_record_insert(); + +CREATE OR REPLACE FUNCTION promotion_protect_combination_activity_delete() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.deleted AND NOT OLD.deleted AND EXISTS ( + SELECT 1 FROM promotion_combination_record record + WHERE record.tenant_id=OLD.tenant_id AND record.activity_id=OLD.id AND record.deleted=false + ) THEN + RAISE EXCEPTION 'fk_promotion_combination_activity_record: activity has group records' + USING ERRCODE='23503'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_promotion_combination_activity_delete ON promotion_combination_activity; +CREATE TRIGGER trg_promotion_combination_activity_delete +BEFORE UPDATE OF deleted ON promotion_combination_activity +FOR EACH ROW EXECUTE FUNCTION promotion_protect_combination_activity_delete(); + +ALTER TABLE trade_order ADD CONSTRAINT fk_trade_order_combination_activity + FOREIGN KEY (tenant_id,combination_activity_id) + REFERENCES promotion_combination_activity (tenant_id,id) ON DELETE RESTRICT; +ALTER TABLE trade_order ADD CONSTRAINT fk_trade_order_combination_record + FOREIGN KEY (tenant_id,combination_record_id) + REFERENCES promotion_combination_record (tenant_id,id) ON DELETE RESTRICT; +ALTER TABLE trade_order ADD CONSTRAINT fk_trade_order_combination_head + FOREIGN KEY (tenant_id,combination_head_id) + REFERENCES promotion_combination_record (tenant_id,id) ON DELETE RESTRICT; +ALTER TABLE trade_order ADD CONSTRAINT ck_trade_order_combination_reference + CHECK ((type=3 AND combination_activity_id IS NOT NULL + AND ((combination_record_id IS NULL AND combination_head_id IS NULL) + OR (combination_record_id IS NOT NULL AND combination_head_id IS NOT NULL))) + OR (type<>3 AND combination_activity_id IS NULL + AND combination_record_id IS NULL AND combination_head_id IS NULL)); + +CREATE OR REPLACE FUNCTION trade_validate_combination_record_references() +RETURNS TRIGGER AS $$ +DECLARE record_activity_id BIGINT; +DECLARE record_head_id BIGINT; +DECLARE record_order_id BIGINT; +DECLARE head_activity_id BIGINT; +DECLARE head_head_id BIGINT; +BEGIN + IF NEW.combination_record_id IS NULL THEN RETURN NEW; END IF; + + SELECT activity_id,head_id,order_id + INTO record_activity_id,record_head_id,record_order_id + FROM promotion_combination_record + WHERE tenant_id=NEW.tenant_id AND id=NEW.combination_record_id AND deleted=false; + IF NOT FOUND OR record_activity_id<>NEW.combination_activity_id OR record_order_id<>NEW.id THEN + RAISE EXCEPTION 'ck_trade_order_combination_record: record differs from order' + USING ERRCODE='23514'; + END IF; + + SELECT activity_id,head_id INTO head_activity_id,head_head_id + FROM promotion_combination_record + WHERE tenant_id=NEW.tenant_id AND id=NEW.combination_head_id AND deleted=false; + IF NOT FOUND OR head_activity_id<>NEW.combination_activity_id OR head_head_id<>0 + OR (record_head_id=0 AND NEW.combination_head_id<>NEW.combination_record_id) + OR (record_head_id<>0 AND NEW.combination_head_id<>record_head_id) THEN + RAISE EXCEPTION 'ck_trade_order_combination_head: head differs from order record' + USING ERRCODE='23514'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_trade_order_combination_record_references ON trade_order; +CREATE TRIGGER trg_trade_order_combination_record_references +BEFORE INSERT OR UPDATE OF tenant_id,id,type,combination_activity_id, + combination_record_id,combination_head_id ON trade_order +FOR EACH ROW EXECUTE FUNCTION trade_validate_combination_record_references(); + +CREATE INDEX IF NOT EXISTS idx_trade_order_tenant_combination_activity + ON trade_order (tenant_id,combination_activity_id,id) + WHERE deleted=false AND combination_activity_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_trade_order_tenant_combination_head + ON trade_order (tenant_id,combination_head_id,id) + WHERE deleted=false AND combination_head_id IS NOT NULL; + +DO $$ +DECLARE valid_shape INTEGER; +BEGIN + SELECT count(*) INTO valid_shape + FROM information_schema.columns c + WHERE c.table_schema=current_schema() AND ( + (c.table_name='promotion_combination_activity' AND c.column_name IN ( + 'id','tenant_id','name','spu_id','total_limit_count','single_limit_count', + 'start_time','end_time','user_size','virtual_group','status','limit_duration', + 'creator','create_time','updater','update_time','deleted')) + OR (c.table_name='promotion_combination_product' AND c.column_name IN ( + 'id','tenant_id','activity_id','spu_id','sku_id','combination_price', + 'activity_status','activity_start_time','activity_end_time', + 'creator','create_time','updater','update_time','deleted')) + OR (c.table_name='promotion_combination_record' AND c.column_name IN ( + 'id','tenant_id','activity_id','combination_price','spu_id','spu_name','pic_url', + 'sku_id','count','user_id','nickname','avatar','head_id','status','order_id', + 'user_size','user_count','virtual_group','expire_time','start_time','end_time', + 'creator','create_time','updater','update_time','deleted')) + ); + IF valid_shape<>57 THEN + RAISE EXCEPTION 'Native Promotion Combination table shape conflict' USING ERRCODE='23514'; + END IF; +END $$; + +DO $$ +DECLARE installed INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN RETURN; END IF; + + INSERT INTO system_menu ( + id,name,permission,type,sort,parent_id,path,icon,component,component_name, + status,visible,keep_alive,always_show,creator,updater + ) VALUES + (7060,'拼团管理','',1,4,6970,'combination','lucide:users-round',NULL,NULL,0,true,true,true,'education-flyway','education-flyway'), + (7061,'拼团活动','',2,1,7060,'activity','lucide:boxes','mall/promotion/combination/activity/index','PromotionCombinationActivity',0,true,true,true,'education-flyway','education-flyway'), + (7062,'拼团活动查询','promotion:combination-activity:query',3,1,7061,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7063,'拼团活动创建','promotion:combination-activity:create',3,2,7061,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7064,'拼团活动更新','promotion:combination-activity:update',3,3,7061,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7065,'拼团活动关闭','promotion:combination-activity:close',3,4,7061,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7066,'拼团活动删除','promotion:combination-activity:delete',3,5,7061,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (7067,'拼团记录','promotion:combination-record:query',2,2,7060,'record','lucide:list-tree','mall/promotion/combination/record/index','PromotionCombinationRecord',0,true,true,true,'education-flyway','education-flyway') + ON CONFLICT (id) DO NOTHING; + + SELECT count(*) INTO installed FROM system_menu WHERE deleted=0 AND status=0 AND ( + (id=7060 AND type=1 AND parent_id=6970 AND path='combination') + OR (id=7061 AND type=2 AND parent_id=7060 + AND component='mall/promotion/combination/activity/index' + AND component_name='PromotionCombinationActivity') + OR (id BETWEEN 7062 AND 7066 AND type=3 AND parent_id=7061 + AND permission IN ('promotion:combination-activity:query','promotion:combination-activity:create', + 'promotion:combination-activity:update','promotion:combination-activity:close', + 'promotion:combination-activity:delete')) + OR (id=7067 AND type=2 AND parent_id=7060 + AND permission='promotion:combination-record:query' + AND component='mall/promotion/combination/record/index' + AND component_name='PromotionCombinationRecord') + ); + IF installed<>8 THEN + RAISE EXCEPTION 'Education native Promotion Combination menu shape conflict' + USING ERRCODE='23505'; + END IF; +END $$; diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/activationcode/ActivationCodeAdminControllerContractTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/activationcode/ActivationCodeAdminControllerContractTest.java new file mode 100644 index 00000000..17a43fb8 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/activationcode/ActivationCodeAdminControllerContractTest.java @@ -0,0 +1,91 @@ +package cn.iocoder.yudao.module.education.controller.admin.activationcode; + +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService; +import cn.iocoder.yudao.module.education.controller.admin.activationcode.vo.ActivationCodeAdminVOs.*; +import cn.iocoder.yudao.module.education.service.activationcode.ActivationCodeService; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = ActivationCodeAdminControllerContractTest.Config.class) +class ActivationCodeAdminControllerContractTest { + + @jakarta.annotation.Resource ActivationCodeAdminController controller; + @jakarta.annotation.Resource MutableSecurity security; + @jakarta.annotation.Resource ActivationCodeService service; + + @BeforeEach + void setUp() { + security.permissions.clear(); + LoginUser user = new LoginUser(); user.setId(7L); user.setTenantId(10L); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, List.of())); + } + + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + + @Test + void queryManageAndGeneratePermissionsAreIndependent() { + BatchPageReq batchPage = new BatchPageReq(); + CodePageReq codePage = new CodePageReq(); + BatchSaveReq save = new BatchSaveReq(); + GenerateReq generate = new GenerateReq(); + + assertThrows(AccessDeniedException.class, () -> controller.batchPage(batchPage)); + assertThrows(AccessDeniedException.class, () -> controller.create(save)); + assertThrows(AccessDeniedException.class, () -> controller.generate(1L, generate)); + + security.permissions.add("education:activation-code:query"); + controller.batchPage(batchPage); + controller.codePage(codePage); + assertThrows(AccessDeniedException.class, () -> controller.disable(1L, 0)); + + security.permissions.clear(); security.permissions.add("education:activation-code:manage"); + controller.create(save); + controller.update(1L, save); + controller.disable(1L, 0); + verify(service).createBatch(save, 7L); + assertThrows(AccessDeniedException.class, () -> controller.generate(1L, generate)); + assertThrows(AccessDeniedException.class, () -> controller.batchPage(batchPage)); + + security.permissions.clear(); security.permissions.add("education:activation-code:generate"); + controller.generate(1L, generate); + verify(service).generate(1L, generate, 7L); + assertThrows(AccessDeniedException.class, () -> controller.create(save)); + assertThrows(AccessDeniedException.class, () -> controller.codePage(codePage)); + } + + @Configuration(proxyBeanMethods = false) + @EnableMethodSecurity + static class Config { + @Bean("ss") MutableSecurity security() { return new MutableSecurity(); } + @Bean ActivationCodeService service() { return mock(ActivationCodeService.class); } + @Bean ActivationCodeAdminController controller(ActivationCodeService service) { + return new ActivationCodeAdminController(service); + } + } + + static class MutableSecurity implements SecurityFrameworkService { + final Set permissions = new HashSet<>(); + public boolean hasPermission(String p) { return permissions.contains(p); } + public boolean hasAnyPermissions(String... p) { return Arrays.stream(p).anyMatch(this::hasPermission); } + public boolean hasRole(String r) { return false; } + public boolean hasAnyRoles(String... r) { return false; } + public boolean hasScope(String s) { return false; } + public boolean hasAnyScopes(String... s) { return false; } + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/appearance/TenantAppearanceAdminControllerContractTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/appearance/TenantAppearanceAdminControllerContractTest.java new file mode 100644 index 00000000..ebebda7a --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/appearance/TenantAppearanceAdminControllerContractTest.java @@ -0,0 +1,101 @@ +package cn.iocoder.yudao.module.education.controller.admin.appearance; + +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService; +import cn.iocoder.yudao.module.education.controller.admin.appearance.vo.TenantAppearanceVOs.*; +import cn.iocoder.yudao.module.education.service.appearance.TenantAppearanceService; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = TenantAppearanceAdminControllerContractTest.Config.class) +class TenantAppearanceAdminControllerContractTest { + + @jakarta.annotation.Resource TenantAppearanceAdminController controller; + @jakarta.annotation.Resource MutableSecurity security; + @jakarta.annotation.Resource RecordingService service; + + @BeforeEach + void setUp() { + security.permissions.clear(); + LoginUser user = new LoginUser(); user.setId(7L); user.setTenantId(10L); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, List.of())); + } + + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + + @Test + void queryBrandingSettingsAndThemePermissionsAreIndependent() { + assertThrows(AccessDeniedException.class, controller::get); + assertThrows(AccessDeniedException.class, () -> controller.updateBranding(new BrandingSaveReq())); + + security.permissions.add("education:tenant-appearance:query"); + assertNotNull(controller.get().getData()); + assertNotNull(controller.templates().getData()); + assertThrows(AccessDeniedException.class, () -> controller.updateSettings(new SettingsSaveReq())); + + security.permissions.clear(); security.permissions.add("education:tenant-appearance:branding"); + controller.updateBranding(new BrandingSaveReq()); assertEquals("branding", service.action); + assertEquals(7L, service.actorId); + + security.permissions.clear(); security.permissions.add("education:tenant-appearance:settings"); + controller.updateSettings(new SettingsSaveReq()); assertEquals("settings", service.action); + assertThrows(AccessDeniedException.class, () -> controller.preview(new ThemePreviewReq())); + + security.permissions.clear(); security.permissions.add("education:tenant-appearance:theme"); + controller.preview(new ThemePreviewReq()); controller.publish(new ThemePublishReq()); + assertEquals("publish", service.action); + assertThrows(AccessDeniedException.class, controller::get); + } + + @Configuration(proxyBeanMethods = false) + @EnableMethodSecurity + static class Config { + @Bean("ss") MutableSecurity security() { return new MutableSecurity(); } + @Bean RecordingService service() { return new RecordingService(); } + @Bean TenantAppearanceAdminController controller(TenantAppearanceService service) { + return new TenantAppearanceAdminController(service); + } + } + + static class MutableSecurity implements SecurityFrameworkService { + final Set permissions = new HashSet<>(); + public boolean hasPermission(String p) { return permissions.contains(p); } + public boolean hasAnyPermissions(String... p) { return Arrays.stream(p).anyMatch(this::hasPermission); } + public boolean hasRole(String r) { return false; } + public boolean hasAnyRoles(String... r) { return false; } + public boolean hasScope(String s) { return false; } + public boolean hasAnyScopes(String... s) { return false; } + } + + static class RecordingService implements TenantAppearanceService { + String action; Long actorId; + public AppearanceResp getAppearance() { return AppearanceResp.builder().version(0).build(); } + public AppearanceResp updateBranding(BrandingSaveReq req, Long actorId) { + this.action = "branding"; this.actorId = actorId; return AppearanceResp.builder().build(); + } + public AppearanceResp updateSettings(SettingsSaveReq req, Long actorId) { + this.action = "settings"; this.actorId = actorId; return AppearanceResp.builder().build(); + } + public List getThemeTemplates() { return List.of(); } + public AppearanceResp previewTheme(ThemePreviewReq req, Long actorId) { + this.action = "preview"; this.actorId = actorId; return AppearanceResp.builder().build(); + } + public AppearanceResp publishTheme(ThemePublishReq req, Long actorId) { + this.action = "publish"; this.actorId = actorId; return AppearanceResp.builder().build(); + } + public PublicAppearanceResp getPublicAppearance() { return PublicAppearanceResp.builder().build(); } + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/badge/BadgeAdminControllerContractTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/badge/BadgeAdminControllerContractTest.java new file mode 100644 index 00000000..a61adb49 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/badge/BadgeAdminControllerContractTest.java @@ -0,0 +1,95 @@ +package cn.iocoder.yudao.module.education.controller.admin.badge; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService; +import cn.iocoder.yudao.module.education.controller.admin.badge.vo.BadgeAdminVOs.*; +import cn.iocoder.yudao.module.education.service.badge.BadgeAdminService; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = BadgeAdminControllerContractTest.Config.class) +class BadgeAdminControllerContractTest { + + @jakarta.annotation.Resource BadgeAdminController controller; + @jakarta.annotation.Resource RecordingService service; + @jakarta.annotation.Resource MutableSecurity security; + + @BeforeEach + void setUp() { + security.permissions.clear(); + LoginUser user = new LoginUser(); user.setId(7L); user.setTenantId(10L); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, List.of())); + } + + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + + @Test + void queryWriteAndGrantPermissionsAreIndependent() { + assertThrows(AccessDeniedException.class, () -> controller.definitionPage(new DefinitionPageReq())); + assertThrows(AccessDeniedException.class, () -> controller.create(new DefinitionSaveReq())); + assertThrows(AccessDeniedException.class, () -> controller.grant(new ManualGrantReq())); + + security.permissions.add("education:badge:query"); + assertNotNull(controller.definitionPage(new DefinitionPageReq()).getData()); + assertNotNull(controller.grantPage(new GrantPageReq()).getData()); + assertThrows(AccessDeniedException.class, () -> controller.create(new DefinitionSaveReq())); + + security.permissions.clear(); security.permissions.add("education:badge:write"); + controller.create(new DefinitionSaveReq()); + controller.update(1L, new DefinitionSaveReq()); + assertEquals(7L, service.actorId); + assertThrows(AccessDeniedException.class, () -> controller.grant(new ManualGrantReq())); + + security.permissions.clear(); security.permissions.add("education:badge:grant"); + controller.grant(new ManualGrantReq()); + assertEquals("grant", service.lastAction); + assertThrows(AccessDeniedException.class, () -> controller.definitionPage(new DefinitionPageReq())); + } + + @Configuration(proxyBeanMethods = false) + @EnableMethodSecurity + static class Config { + @Bean("ss") MutableSecurity security() { return new MutableSecurity(); } + @Bean RecordingService service() { return new RecordingService(); } + @Bean BadgeAdminController controller(BadgeAdminService service) { return new BadgeAdminController(service); } + } + + static class MutableSecurity implements SecurityFrameworkService { + final Set permissions = new HashSet<>(); + public boolean hasPermission(String p) { return permissions.contains(p); } + public boolean hasAnyPermissions(String... p) { return Arrays.stream(p).anyMatch(this::hasPermission); } + public boolean hasRole(String r) { return false; } + public boolean hasAnyRoles(String... r) { return false; } + public boolean hasScope(String s) { return false; } + public boolean hasAnyScopes(String... s) { return false; } + } + + static class RecordingService implements BadgeAdminService { + Long actorId; String lastAction; + public PageResult getDefinitionPage(DefinitionPageReq req) { return PageResult.empty(); } + public DefinitionResp createDefinition(DefinitionSaveReq req, Long actorId) { + this.actorId = actorId; return DefinitionResp.builder().id(1L).build(); + } + public DefinitionResp updateDefinition(Long id, DefinitionSaveReq req, Long actorId) { + this.actorId = actorId; return DefinitionResp.builder().id(id).build(); + } + public PageResult getGrantPage(GrantPageReq req) { return PageResult.empty(); } + public GrantResp grant(ManualGrantReq req, Long actorId) { + this.actorId = actorId; lastAction = "grant"; return GrantResp.builder().id(2L).build(); + } + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/category/CategoryAuthoringControllerContractTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/category/CategoryAuthoringControllerContractTest.java index 4cf8d231..51bcb072 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/category/CategoryAuthoringControllerContractTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/category/CategoryAuthoringControllerContractTest.java @@ -1,9 +1,12 @@ package cn.iocoder.yudao.module.education.controller.admin.category; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.security.core.LoginUser; import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService; 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 org.junit.jupiter.api.*; @@ -50,6 +53,16 @@ class CategoryAuthoringControllerContractTest { assertEquals(5, controller.archive(101L, 4).getData()); } + @Test void readsRequireQueryPermissionAndProjectOwnedCategories() { + assertThrows(AccessDeniedException.class, () -> controller.page(new PageParam())); + assertThrows(AccessDeniedException.class, () -> controller.get(101L)); + + security.permissions.add("education:category:query"); + assertEquals("Algebra", controller.get(101L).getData().getName()); + assertEquals(1, controller.page(new PageParam()).getData().getList().size()); + assertEquals(2, service.readCalls); + } + private CategoryDraftReqVO draft() { CategoryDraftReqVO request = new CategoryDraftReqVO(); request.setSubjectId(100L); request.setLegacyNodeId("legacy-node"); request.setName("Algebra"); @@ -75,9 +88,21 @@ class CategoryAuthoringControllerContractTest { } static class RecordingService implements CategoryAuthoringService { Long actorId; + int readCalls; + public PageResult getPage(PageParam pageParam) { + readCalls++; + return new PageResult<>(List.of(category()), 1L); + } + public CategoryDO get(Long id) { readCalls++; return category(); } public Long createDraft(CategoryAuthoringCommand command) { return 101L; } public int reviseDraft(Long id, CategoryAuthoringCommand command) { return command.expectedAuthoringVersion() + 1; } public int activate(Long id, int version, Long actor) { actorId = actor; return version + 1; } public int archive(Long id, int version, Long actor) { actorId = actor; return version + 1; } + private CategoryDO category() { + CategoryDO category = new CategoryDO(); + category.setId(101L); category.setSubjectId(100L); category.setName("Algebra"); + category.setPublicationStatus("DRAFT"); category.setAuthoringVersion(0); + return category; + } } } diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/collection/QuestionCollectionAuthoringControllerContractTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/collection/QuestionCollectionAuthoringControllerContractTest.java index 09a09c73..ce0c6c04 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/collection/QuestionCollectionAuthoringControllerContractTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/collection/QuestionCollectionAuthoringControllerContractTest.java @@ -34,6 +34,8 @@ class QuestionCollectionAuthoringControllerContractTest { @Test void permissionsAreIndependentAndActorIsServerDerived() { security.permissions.add("education:collection:author"); assertEquals(101L, controller.create(draft()).getData()); + assertThrows(AccessDeniedException.class, () -> controller.replaceMembership(101L, membership())); + security.permissions.add("education:collection:update"); assertEquals(1, controller.replaceMembership(101L, membership()).getData()); assertThrows(AccessDeniedException.class, () -> controller.activate(101L, 1)); security.permissions.clear(); security.permissions.add("education:collection:publish"); diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/ContentNodeAuthoringControllerContractTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/ContentNodeAuthoringControllerContractTest.java index c5e1ff30..b6bc9657 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/ContentNodeAuthoringControllerContractTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/ContentNodeAuthoringControllerContractTest.java @@ -53,8 +53,8 @@ class ContentNodeAuthoringControllerContractTest { controller.archive(101L, 1); } - @Test void authorCanReviseWithCas() { - security.permissions.add("education:content-node:author"); + @Test void updateCanReviseWithCas() { + security.permissions.add("education:content-node:update"); ContentNodeReviseReqVO request = new ContentNodeReviseReqVO(); request.setExpectedAuthoringVersion(2); request.setName("Revised"); request.setNodeType("category"); request.setEntryId(100L); request.setSelectable(true); diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/importjob/QuestionImportJobControllerTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/importjob/QuestionImportJobControllerTest.java index b10279d8..a297b160 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/importjob/QuestionImportJobControllerTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/importjob/QuestionImportJobControllerTest.java @@ -60,7 +60,7 @@ class QuestionImportJobControllerTest { public QuestionImportJobProjection get(Long id) { return projection("PREVIEW_READY"); } public void preview(Long id) {} public void execute(Long id) {} private QuestionImportJobProjection projection(String status) { - return new QuestionImportJobProjection(101L,status,"CLEAN","PARSED","q.csv",12L,2,null,null); + return new QuestionImportJobProjection(101L,status,"CLEAN","PARSED","q.csv",12L,2,null,null,null,null); } } private static final class MutableSecurity implements SecurityFrameworkService { diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/LearningOperationsAdminControllerContractTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/LearningOperationsAdminControllerContractTest.java new file mode 100644 index 00000000..5ecc3066 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/learningoperations/LearningOperationsAdminControllerContractTest.java @@ -0,0 +1,128 @@ +package cn.iocoder.yudao.module.education.controller.admin.learningoperations; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService; +import cn.iocoder.yudao.module.education.controller.admin.learningoperations.vo.*; +import cn.iocoder.yudao.module.education.service.engagement.LearningOperationsAdminService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = LearningOperationsAdminControllerContractTest.Config.class) +class LearningOperationsAdminControllerContractTest { + + @jakarta.annotation.Resource LearningOperationsAdminController controller; + @jakarta.annotation.Resource RecordingService service; + @jakarta.annotation.Resource MutableSecurity security; + + @BeforeEach + void setUp() { + security.permissions.clear(); + LoginUser user = new LoginUser(); user.setId(7L); user.setTenantId(10L); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, List.of())); + } + + @AfterEach + void clear() { + SecurityContextHolder.clearContext(); + } + + @Test + void queryFeedbackAndRewardPermissionsAreIndependent() { + assertThrows(AccessDeniedException.class, controller::overview); + assertThrows(AccessDeniedException.class, () -> controller.handleFeedback(5L, handleRequest())); + assertThrows(AccessDeniedException.class, () -> controller.rewardFeedback(5L, rewardRequest())); + + security.permissions.add("education:learning-operations:query"); + assertNotNull(controller.overview().getData()); + assertEquals(1, controller.feedbackPage(new StudentFeedbackPageReqVO()).getData().getList().size()); + assertThrows(AccessDeniedException.class, () -> controller.handleFeedback(5L, handleRequest())); + + security.permissions.clear(); + security.permissions.add("education:learning-operations:feedback"); + controller.handleFeedback(5L, handleRequest()); + assertEquals(7L, service.actorId); + assertThrows(AccessDeniedException.class, () -> controller.rewardFeedback(5L, rewardRequest())); + + security.permissions.clear(); + security.permissions.add("education:learning-operations:reward"); + controller.rewardFeedback(5L, rewardRequest()); + assertEquals(List.of("schedule", "deliver"), service.rewardCalls); + } + + private StudentFeedbackHandleReqVO handleRequest() { + StudentFeedbackHandleReqVO request = new StudentFeedbackHandleReqVO(); + request.setExpectedVersion(1); request.setStatus("RESOLVED"); request.setPriority("NORMAL"); + return request; + } + + private StudentFeedbackRewardReqVO rewardRequest() { + StudentFeedbackRewardReqVO request = new StudentFeedbackRewardReqVO(); + request.setExpectedVersion(2); request.setPoints(10); + return request; + } + + @Configuration(proxyBeanMethods = false) + @EnableMethodSecurity + static class Config { + @Bean("ss") MutableSecurity security() { return new MutableSecurity(); } + @Bean RecordingService service() { return new RecordingService(); } + @Bean LearningOperationsAdminController controller(LearningOperationsAdminService service) { + return new LearningOperationsAdminController(service); + } + } + + static class MutableSecurity implements SecurityFrameworkService { + final Set permissions = new HashSet<>(); + public boolean hasPermission(String p) { return permissions.contains(p); } + public boolean hasAnyPermissions(String... p) { return Arrays.stream(p).anyMatch(this::hasPermission); } + public boolean hasRole(String r) { return false; } + public boolean hasAnyRoles(String... r) { return false; } + public boolean hasScope(String s) { return false; } + public boolean hasAnyScopes(String... s) { return false; } + } + + static class RecordingService implements LearningOperationsAdminService { + Long actorId; + final List rewardCalls = new ArrayList<>(); + public LearningOperationsOverviewRespVO getOverview() { + return LearningOperationsOverviewRespVO.builder().feedbackTotal(1L).build(); + } + public PageResult getFeedbackPage(StudentFeedbackPageReqVO reqVO) { + return new PageResult<>(List.of(feedback()), 1L); + } + public PageResult getAwardPage(LearningAwardPageReqVO reqVO) { + return PageResult.empty(); + } + public List getFeedbackEvents(Long feedbackId) { return List.of(); } + public StudentFeedbackAdminRespVO handleFeedback(Long feedbackId, StudentFeedbackHandleReqVO reqVO, + Long actorId) { + this.actorId = actorId; return feedback(); + } + public StudentFeedbackAdminRespVO scheduleReward(Long feedbackId, StudentFeedbackRewardReqVO reqVO) { + rewardCalls.add("schedule"); return feedback(); + } + public StudentFeedbackAdminRespVO deliverReward(Long feedbackId) { + rewardCalls.add("deliver"); return feedback(); + } + private StudentFeedbackAdminRespVO feedback() { + StudentFeedbackAdminRespVO response = new StudentFeedbackAdminRespVO(); response.setId(5L); return response; + } + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/supervision/StudentSupervisionAdminControllerContractTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/supervision/StudentSupervisionAdminControllerContractTest.java new file mode 100644 index 00000000..54568e79 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/supervision/StudentSupervisionAdminControllerContractTest.java @@ -0,0 +1,97 @@ +package cn.iocoder.yudao.module.education.controller.admin.supervision; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService; +import cn.iocoder.yudao.module.education.controller.admin.supervision.vo.StudentSupervisionVOs.*; +import cn.iocoder.yudao.module.education.service.supervision.StudentSupervisionAdminService; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = StudentSupervisionAdminControllerContractTest.Config.class) +class StudentSupervisionAdminControllerContractTest { + @jakarta.annotation.Resource StudentSupervisionAdminController controller; + @jakarta.annotation.Resource RecordingService service; + @jakarta.annotation.Resource MutableSecurity security; + + @BeforeEach + void setUp() { + security.permissions.clear(); + LoginUser user = new LoginUser(); user.setId(7L); user.setTenantId(10L); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, List.of())); + } + + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + + @Test + void queryRuleGenerateAndFollowupPermissionsAreIndependent() { + assertThrows(AccessDeniedException.class, controller::overview); + assertThrows(AccessDeniedException.class, () -> controller.createRule(new RuleSaveReq())); + assertThrows(AccessDeniedException.class, () -> controller.generate(new GenerateReq())); + assertThrows(AccessDeniedException.class, () -> controller.handle(1L, handleRequest())); + + security.permissions.add("education:supervision:query"); + assertNotNull(controller.overview().getData()); + assertNotNull(controller.preview(new PreviewReq()).getData()); + + security.permissions.clear(); security.permissions.add("education:supervision:rule"); + controller.createRule(new RuleSaveReq()); assertEquals(7L, service.actorId); + assertThrows(AccessDeniedException.class, () -> controller.generate(new GenerateReq())); + + security.permissions.clear(); security.permissions.add("education:supervision:generate"); + controller.generate(new GenerateReq()); assertEquals("generate", service.lastAction); + + security.permissions.clear(); security.permissions.add("education:supervision:followup"); + controller.handle(1L, handleRequest()); assertEquals("handle", service.lastAction); + } + + private FollowupHandleReq handleRequest() { + FollowupHandleReq request = new FollowupHandleReq(); request.setExpectedVersion(0); request.setStatus("DONE"); + return request; + } + + @Configuration(proxyBeanMethods = false) + @EnableMethodSecurity + static class Config { + @Bean("ss") MutableSecurity security() { return new MutableSecurity(); } + @Bean RecordingService service() { return new RecordingService(); } + @Bean StudentSupervisionAdminController controller(StudentSupervisionAdminService service) { + return new StudentSupervisionAdminController(service); + } + } + + static class MutableSecurity implements SecurityFrameworkService { + final Set permissions = new HashSet<>(); + public boolean hasPermission(String p) { return permissions.contains(p); } + public boolean hasAnyPermissions(String... p) { return Arrays.stream(p).anyMatch(this::hasPermission); } + public boolean hasRole(String r) { return false; } + public boolean hasAnyRoles(String... r) { return false; } + public boolean hasScope(String s) { return false; } + public boolean hasAnyScopes(String... s) { return false; } + } + + static class RecordingService implements StudentSupervisionAdminService { + Long actorId; String lastAction; + public OverviewResp getOverview() { return OverviewResp.builder().openFollowups(1L).build(); } + public PreviewResp preview(PreviewReq reqVO) { return PreviewResp.builder().candidates(List.of()).build(); } + public PageResult getRulePage(RulePageReq reqVO) { return PageResult.empty(); } + public RuleResp createRule(RuleSaveReq reqVO, Long actorId) { this.actorId = actorId; return RuleResp.builder().id(1L).build(); } + public RuleResp updateRule(Long id, RuleSaveReq reqVO, Long actorId) { this.actorId = actorId; return RuleResp.builder().id(id).build(); } + public PageResult getFollowupPage(FollowupPageReq reqVO) { return PageResult.empty(); } + public GenerateResp generate(GenerateReq reqVO, Long actorId) { this.actorId = actorId; lastAction = "generate"; return GenerateResp.builder().items(List.of()).build(); } + public FollowupResp handleFollowup(Long id, FollowupHandleReq reqVO, Long actorId) { this.actorId = actorId; lastAction = "handle"; return FollowupResp.builder().id(id).build(); } + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/activationcode/ActivationCodeAppControllerHttpTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/activationcode/ActivationCodeAppControllerHttpTest.java new file mode 100644 index 00000000..c2759d16 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/activationcode/ActivationCodeAppControllerHttpTest.java @@ -0,0 +1,70 @@ +package cn.iocoder.yudao.module.education.controller.app.activationcode; + +import cn.iocoder.yudao.framework.common.biz.infra.logger.ApiErrorLogCommonApi; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler; +import cn.iocoder.yudao.module.education.controller.app.activationcode.vo.ActivationCodeAppVOs.*; +import cn.iocoder.yudao.module.education.service.activationcode.ActivationCodeService; +import org.junit.jupiter.api.*; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class ActivationCodeAppControllerHttpTest { + + private MockMvc mockMvc; + private ActivationCodeService service; + + @BeforeEach + void setUp() { + service = mock(ActivationCodeService.class); + mockMvc = MockMvcBuilders.standaloneSetup(new ActivationCodeAppController(service)) + .setControllerAdvice(new GlobalExceptionHandler("test", mock(ApiErrorLogCommonApi.class))) + .build(); + } + + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + + @Test + void anonymousAndAdminPrincipalsAreRejected() throws Exception { + perform("/education/activation-code/check").andExpect(jsonPath("$.code").value(401)); + setLoginUser(7L, UserTypeEnum.ADMIN); + perform("/education/activation-code/redeem").andExpect(jsonPath("$.code").value(401)); + verifyNoInteractions(service); + } + + @Test + void memberPrincipalCanCheckAndRedeemUsingAuthenticatedIdentity() throws Exception { + setLoginUser(8L, UserTypeEnum.MEMBER); + when(service.check("EDU-ABC234")).thenReturn(CheckResp.builder().valid(true).build()); + when(service.redeem("EDU-ABC234", 8L)).thenReturn(RedeemResp.builder().entitlementId(99L).build()); + + perform("/education/activation-code/check") + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.valid").value(true)); + perform("/education/activation-code/redeem") + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.entitlementId").value(99)); + + verify(service).check("EDU-ABC234"); + verify(service).redeem("EDU-ABC234", 8L); + } + + private org.springframework.test.web.servlet.ResultActions perform(String path) throws Exception { + return mockMvc.perform(post(path).contentType(MediaType.APPLICATION_JSON) + .content("{\"code\":\"EDU-ABC234\"}")); + } + + private static void setLoginUser(Long id, UserTypeEnum type) { + LoginUser user = new LoginUser(); user.setId(id); user.setTenantId(10L); user.setUserType(type.getValue()); + SecurityFrameworkUtils.setLoginUser(user, new MockHttpServletRequest()); + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/promotion/CombinationPostgreSqlIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/promotion/CombinationPostgreSqlIntegrationTest.java new file mode 100644 index 00000000..54d0d83a --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/promotion/CombinationPostgreSqlIntegrationTest.java @@ -0,0 +1,393 @@ +package cn.iocoder.yudao.module.education.integration.promotion; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.tenant.config.TenantProperties; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.tenant.core.db.TenantDatabaseInterceptor; +import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest; +import cn.iocoder.yudao.module.member.api.user.MemberUserApi; +import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; +import cn.iocoder.yudao.module.product.api.sku.ProductSkuApi; +import cn.iocoder.yudao.module.product.api.sku.dto.ProductSkuRespDTO; +import cn.iocoder.yudao.module.product.api.spu.ProductSpuApi; +import cn.iocoder.yudao.module.product.api.spu.dto.ProductSpuRespDTO; +import cn.iocoder.yudao.module.promotion.api.combination.dto.CombinationRecordCreateReqDTO; +import cn.iocoder.yudao.module.promotion.controller.admin.combination.vo.activity.CombinationActivityCreateReqVO; +import cn.iocoder.yudao.module.promotion.controller.admin.combination.vo.activity.CombinationActivityPageReqVO; +import cn.iocoder.yudao.module.promotion.controller.admin.combination.vo.activity.CombinationActivityUpdateReqVO; +import cn.iocoder.yudao.module.promotion.controller.admin.combination.vo.product.CombinationProductBaseVO; +import cn.iocoder.yudao.module.promotion.controller.admin.combination.vo.recrod.CombinationRecordReqPageVO; +import cn.iocoder.yudao.module.promotion.convert.combination.CombinationActivityConvert; +import cn.iocoder.yudao.module.promotion.dal.dataobject.combination.CombinationActivityDO; +import cn.iocoder.yudao.module.promotion.dal.dataobject.combination.CombinationProductDO; +import cn.iocoder.yudao.module.promotion.dal.dataobject.combination.CombinationRecordDO; +import cn.iocoder.yudao.module.promotion.enums.combination.CombinationRecordStatusEnum; +import cn.iocoder.yudao.module.promotion.service.combination.CombinationActivityService; +import cn.iocoder.yudao.module.promotion.service.combination.CombinationActivityServiceImpl; +import cn.iocoder.yudao.module.promotion.service.combination.CombinationRecordService; +import cn.iocoder.yudao.module.promotion.service.combination.CombinationRecordServiceImpl; +import cn.iocoder.yudao.module.system.api.social.SocialClientApi; +import cn.iocoder.yudao.module.trade.api.order.TradeOrderApi; +import com.baomidou.mybatisplus.core.incrementer.IdentifierGenerator; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import javax.sql.DataSource; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; + +@Import({ + CombinationActivityServiceImpl.class, + CombinationRecordServiceImpl.class, + CombinationPostgreSqlIntegrationTest.TenantDatabaseTestConfiguration.class +}) +class CombinationPostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest { + + private static final long SPU_ID = 100L; + private static final long SKU_ID = 101L; + private static final long SECOND_SPU_ID = 200L; + private static final long SECOND_SKU_ID = 201L; + + @Resource + private CombinationActivityService combinationActivityService; + @Resource + private CombinationRecordService combinationRecordService; + @Resource + private DataSource dataSource; + private JdbcTemplate jdbcTemplate; + private final Queue joinFailures = new java.util.concurrent.ConcurrentLinkedQueue<>(); + + @MockitoBean + private ProductSpuApi productSpuApi; + @MockitoBean + private ProductSkuApi productSkuApi; + @MockitoBean + private MemberUserApi memberUserApi; + @MockitoBean + private TradeOrderApi tradeOrderApi; + @MockitoBean + private SocialClientApi socialClientApi; + + @BeforeEach + void setUp() { + LoginUser loginUser = new LoginUser().setId(7L).setTenantId(10L) + .setUserType(UserTypeEnum.ADMIN.getValue()); + SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest()); + jdbcTemplate = new JdbcTemplate(dataSource); + jdbcTemplate.execute(""" + INSERT INTO product_brand(id,tenant_id,name,pic_url,sort,status) + VALUES (1,10,'Education','https://example.test/brand.png',0,0), + (1,20,'Education','https://example.test/brand.png',0,0); + INSERT INTO product_category(id,tenant_id,parent_id,name,pic_url,sort,status) + VALUES (1,10,0,'Course','https://example.test/category.png',0,0), + (1,20,0,'Course','https://example.test/category.png',0,0); + INSERT INTO product_spu + (id,tenant_id,name,keyword,introduction,description,category_id,brand_id,pic_url, + sort,status,spec_type,price,stock,delivery_types,give_integral,sub_commission_type, + sales_count,virtual_sales_count,browse_count) + VALUES (100,10,'Course','course','Course','Course',1,1, + 'https://example.test/spu.png',0,0,false,1000,10,'[1]',0,false,0,0,0), + (100,20,'Course','course','Course','Course',1,1, + 'https://example.test/spu.png',0,0,false,1000,10,'[1]',0,false,0,0,0), + (200,20,'Course B','course-b','Course B','Course B',1,1, + 'https://example.test/spu-b.png',0,0,false,1200,10,'[1]',0,false,0,0,0); + INSERT INTO product_sku(id,tenant_id,spu_id,price,pic_url,stock,sales_count) + VALUES (101,10,100,1000,'https://example.test/sku.png',10,0), + (101,20,100,1000,'https://example.test/sku.png',10,0), + (201,20,200,1200,'https://example.test/sku-b.png',10,0); + """); + + when(productSpuApi.getSpu(anyLong())).thenAnswer(invocation -> productSpu(invocation.getArgument(0))); + when(productSkuApi.getSku(anyLong())).thenAnswer(invocation -> productSku(invocation.getArgument(0))); + when(productSkuApi.getSkuListBySpuId(anyCollection())).thenAnswer(invocation -> { + Collection spuIds = invocation.getArgument(0); + return spuIds.contains(SECOND_SPU_ID) ? List.of(productSku(SECOND_SKU_ID)) : List.of(productSku(SKU_ID)); + }); + when(memberUserApi.getUser(anyLong())).thenAnswer(invocation -> member(invocation.getArgument(0))); + } + + @AfterEach + void clearTenantContext() { + TenantContextHolder.clear(); + SecurityContextHolder.clearContext(); + } + + @Test + void nativeCombinationServicesIsolateTenantsSynchronizeSnapshotsAndProtectGroupCapacity() throws Exception { + TenantContextHolder.setTenantId(10L); + Long tenant10ActivityId = combinationActivityService.createCombinationActivity( + activity("Tenant 10 Group", SPU_ID, SKU_ID, 2, 500)); + updateTenant10Activity(tenant10ActivityId); + assertThat(combinationActivityService.getCombinationProductListByActivityIds(List.of(tenant10ActivityId))) + .singleElement().satisfies(product -> { + assertThat(product.getActivityStatus()).isEqualTo(CommonStatusEnum.ENABLE.getStatus()); + assertThat(product.getCombinationPrice()).isEqualTo(450); + }); + combinationActivityService.closeCombinationActivityById(tenant10ActivityId); + assertThat(combinationActivityService.getCombinationProductListByActivityIds(List.of(tenant10ActivityId))) + .extracting(CombinationProductDO::getActivityStatus) + .containsExactly(CommonStatusEnum.DISABLE.getStatus()); + + TenantContextHolder.setTenantId(20L); + Long tenant20ActivityId = combinationActivityService.createCombinationActivity( + activity("Tenant 20 Group", SPU_ID, SKU_ID, 2, 600)); + Long secondActivityId = combinationActivityService.createCombinationActivity( + activity("Tenant 20 Other Group", SECOND_SPU_ID, SECOND_SKU_ID, 2, 700)); + assertThat(tenant20ActivityId).isEqualTo(tenant10ActivityId); + assertTenantActivityPage("Tenant 20 Group", "Tenant 20 Other Group"); + + insertTradeOrder(20L, 500L, 52L, tenant20ActivityId); + CombinationRecordDO head = combinationRecordService.createCombinationRecord( + record(tenant20ActivityId, SPU_ID, SKU_ID, 52L, 500L, 0L, 600)); + assertThat(CombinationActivityConvert.INSTANCE.convert4(head).getCombinationHeadId()).isEqualTo(head.getId()); + bridgeTradeOrder(20L, 500L, head.getId(), head.getId()); + assertThatThrownBy(() -> combinationRecordService.validateCombinationRecord( + 55L, secondActivityId, head.getId(), SECOND_SKU_ID, 1)) + .hasMessageContaining("不属于当前活动"); + + insertTradeOrder(20L, 501L, 53L, tenant20ActivityId); + insertTradeOrder(20L, 502L, 54L, tenant20ActivityId); + List winners = runConcurrentJoins(tenant20ActivityId, head.getId()); + assertThat(winners).as("join failures: %s", joinFailures.stream() + .map(Throwable::getMessage).toList()).hasSize(1); + CombinationRecordDO member = winners.get(0); + bridgeTradeOrder(20L, member.getOrderId(), member.getId(), head.getId()); + + assertThat(combinationRecordService.getCombinationRecordById(head.getId()).getStatus()) + .isEqualTo(CombinationRecordStatusEnum.SUCCESS.getStatus()); + assertThat(combinationRecordService.getCombinationRecordCount( + CombinationRecordStatusEnum.SUCCESS.getStatus(), null, null)).isEqualTo(2L); + assertThat(combinationRecordService.getCombinationUserCount()).isEqualTo(2L); + assertThat(jdbcTemplate.queryForObject(""" + SELECT count(*) FROM trade_order o + JOIN promotion_combination_record r + ON r.tenant_id=o.tenant_id AND r.id=o.combination_record_id + JOIN promotion_combination_record h + ON h.tenant_id=o.tenant_id AND h.id=o.combination_head_id + WHERE o.tenant_id=20 AND o.id=? AND r.order_id=o.id + AND r.activity_id=o.combination_activity_id AND h.head_id=0 + """, Long.class, member.getOrderId())).isEqualTo(1L); + + combinationActivityService.closeCombinationActivityById(tenant20ActivityId); + assertThatThrownBy(() -> combinationActivityService.deleteCombinationActivity(tenant20ActivityId)) + .hasMessageContaining("activity has group records"); + assertThat(combinationActivityService.getCombinationProductListByActivityIds(List.of(tenant20ActivityId))) + .isNotEmpty(); + + TenantContextHolder.setTenantId(10L); + assertTenantActivityPage("Tenant 10 Group"); + CombinationRecordReqPageVO recordPage = new CombinationRecordReqPageVO(); + recordPage.setPageNo(1); + recordPage.setPageSize(10); + assertThat(combinationRecordService.getCombinationRecordPage(recordPage).getTotal()).isZero(); + assertThat(combinationRecordService.getCombinationUserCount()).isZero(); + } + + private List runConcurrentJoins(Long activityId, Long headId) throws Exception { + CountDownLatch start = new CountDownLatch(1); + try (var executor = Executors.newFixedThreadPool(2)) { + Future first = executor.submit( + () -> joinAfter(start, activityId, headId, 53L, 501L)); + Future second = executor.submit( + () -> joinAfter(start, activityId, headId, 54L, 502L)); + start.countDown(); + return java.util.stream.Stream.of(first.get(), second.get()) + .filter(java.util.Objects::nonNull).toList(); + } + } + + private CombinationRecordDO joinAfter(CountDownLatch start, Long activityId, Long headId, + Long userId, Long orderId) throws InterruptedException { + start.await(); + TenantContextHolder.setTenantId(20L); + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(userId).setTenantId(20L) + .setUserType(UserTypeEnum.MEMBER.getValue()), new MockHttpServletRequest()); + try { + return combinationRecordService.createCombinationRecord( + record(activityId, SPU_ID, SKU_ID, userId, orderId, headId, 600)); + } catch (RuntimeException failure) { + joinFailures.add(failure); + return null; + } finally { + TenantContextHolder.clear(); + SecurityContextHolder.clearContext(); + } + } + + private void updateTenant10Activity(Long activityId) { + CombinationActivityUpdateReqVO request = new CombinationActivityUpdateReqVO(); + request.setId(activityId); + fillActivity(request, "Tenant 10 Group", SPU_ID, SKU_ID, 2, 450, + LocalDateTime.now().minusHours(2), LocalDateTime.now().plusDays(2)); + combinationActivityService.updateCombinationActivity(request); + } + + private void assertTenantActivityPage(String... expectedNames) { + CombinationActivityPageReqVO request = new CombinationActivityPageReqVO(); + request.setPageNo(1); + request.setPageSize(10); + PageResult page = combinationActivityService.getCombinationActivityPage(request); + assertThat(page.getList()).extracting(CombinationActivityDO::getName) + .containsExactlyInAnyOrder(expectedNames); + assertThat(page.getList()).extracting(CombinationActivityDO::getTenantId) + .containsOnly(TenantContextHolder.getTenantId()); + } + + private void insertTradeOrder(Long tenantId, Long orderId, Long userId, Long activityId) { + jdbcTemplate.update(""" + INSERT INTO trade_order + (id,tenant_id,no,type,terminal,user_id,user_ip,status,product_count,pay_status, + total_price,discount_price,delivery_price,adjust_price,pay_price,delivery_type, + receiver_name,receiver_mobile,receiver_area_id,receiver_detail_address, + refund_status,refund_price,coupon_price,use_point,point_price,give_point, + refund_point,vip_price,combination_activity_id) + VALUES (?,?,'COMBINATION-' || ?,3,20,?,'127.0.0.1',0,1,false, + 600,400,0,0,600,1,'Learner','13800000000',1,'Room A', + 0,0,0,0,0,0,0,0,?) + """, orderId, tenantId, orderId, userId, activityId); + } + + private void bridgeTradeOrder(Long tenantId, Long orderId, Long recordId, Long headId) { + jdbcTemplate.update(""" + UPDATE trade_order SET combination_record_id=?,combination_head_id=? + WHERE tenant_id=? AND id=? + """, recordId, headId, tenantId, orderId); + } + + private static CombinationActivityCreateReqVO activity(String name, Long spuId, Long skuId, + int userSize, int price) { + CombinationActivityCreateReqVO request = new CombinationActivityCreateReqVO(); + fillActivity(request, name, spuId, skuId, userSize, price, + LocalDateTime.now().minusDays(1), LocalDateTime.now().plusDays(1)); + return request; + } + + private static void fillActivity(cn.iocoder.yudao.module.promotion.controller.admin.combination.vo.activity.CombinationActivityBaseVO request, + String name, Long spuId, Long skuId, int userSize, int price, + LocalDateTime startTime, LocalDateTime endTime) { + request.setName(name); + request.setSpuId(spuId); + request.setTotalLimitCount(10); + request.setSingleLimitCount(2); + request.setStartTime(startTime); + request.setEndTime(endTime); + request.setUserSize(userSize); + request.setVirtualGroup(false); + request.setLimitDuration(24); + CombinationProductBaseVO product = new CombinationProductBaseVO(); + product.setSpuId(spuId); + product.setSkuId(skuId); + product.setCombinationPrice(price); + if (request instanceof CombinationActivityCreateReqVO createRequest) { + createRequest.setProducts(List.of(product)); + } else if (request instanceof CombinationActivityUpdateReqVO updateRequest) { + updateRequest.setProducts(List.of(product)); + } + } + + private static CombinationRecordCreateReqDTO record(Long activityId, Long spuId, Long skuId, + Long userId, Long orderId, Long headId, int price) { + CombinationRecordCreateReqDTO request = new CombinationRecordCreateReqDTO(); + request.setActivityId(activityId); + request.setSpuId(spuId); + request.setSkuId(skuId); + request.setCount(1); + request.setOrderId(orderId); + request.setUserId(userId); + request.setHeadId(headId); + request.setCombinationPrice(price); + return request; + } + + private static ProductSpuRespDTO productSpu(Long id) { + ProductSpuRespDTO product = new ProductSpuRespDTO(); + product.setId(id); + product.setName(id.equals(SECOND_SPU_ID) ? "Course B" : "Course"); + product.setPicUrl(id.equals(SECOND_SPU_ID) + ? "https://example.test/spu-b.png" : "https://example.test/spu.png"); + product.setPrice(id.equals(SECOND_SPU_ID) ? 1200 : 1000); + product.setStock(10); + return product; + } + + private static ProductSkuRespDTO productSku(Long id) { + ProductSkuRespDTO product = new ProductSkuRespDTO(); + product.setId(id); + product.setSpuId(id.equals(SECOND_SKU_ID) ? SECOND_SPU_ID : SPU_ID); + product.setPicUrl(id.equals(SECOND_SKU_ID) + ? "https://example.test/sku-b.png" : "https://example.test/sku.png"); + product.setPrice(id.equals(SECOND_SKU_ID) ? 1200 : 1000); + product.setStock(10); + return product; + } + + private static MemberUserRespDTO member(Long id) { + MemberUserRespDTO user = new MemberUserRespDTO(); + user.setId(id); + user.setNickname("Learner " + id); + user.setAvatar("https://example.test/avatar.png"); + return user; + } + + @TestConfiguration(proxyBeanMethods = false) + static class TenantDatabaseTestConfiguration { + + @Bean + IdentifierGenerator tenantEntityIdentifierGenerator() { + ConcurrentMap sequences = new ConcurrentHashMap<>(); + return entity -> { + String key = TenantContextHolder.getTenantId() + ":" + entity.getClass().getName(); + return sequences.computeIfAbsent(key, ignored -> new AtomicLong()).incrementAndGet(); + }; + } + + @Bean + static BeanPostProcessor tenantDatabaseInterceptorInstaller() { + return new BeanPostProcessor() { + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) { + if (bean instanceof MybatisPlusInterceptor interceptor) { + TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor( + new TenantDatabaseInterceptor(new TenantProperties())); + MyBatisUtils.addInterceptor(interceptor, inner, 0); + } + return bean; + } + }; + } + + } + +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/promotion/PromotionCheckoutPostgreSqlIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/promotion/PromotionCheckoutPostgreSqlIntegrationTest.java new file mode 100644 index 00000000..93269a11 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/promotion/PromotionCheckoutPostgreSqlIntegrationTest.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.education.integration.promotion; + +import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest; +import cn.iocoder.yudao.module.product.api.category.ProductCategoryApi; +import cn.iocoder.yudao.module.product.api.sku.ProductSkuApi; +import cn.iocoder.yudao.module.product.api.spu.ProductSpuApi; +import cn.iocoder.yudao.module.product.api.spu.dto.ProductSpuRespDTO; +import cn.iocoder.yudao.module.promotion.api.discount.DiscountActivityApi; +import cn.iocoder.yudao.module.promotion.api.discount.DiscountActivityApiImpl; +import cn.iocoder.yudao.module.promotion.api.reward.RewardActivityApi; +import cn.iocoder.yudao.module.promotion.api.reward.RewardActivityApiImpl; +import cn.iocoder.yudao.module.promotion.service.discount.DiscountActivityServiceImpl; +import cn.iocoder.yudao.module.promotion.service.reward.RewardActivityServiceImpl; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@Import({ + DiscountActivityServiceImpl.class, + RewardActivityServiceImpl.class, + DiscountActivityApiImpl.class, + RewardActivityApiImpl.class +}) +class PromotionCheckoutPostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest { + + @Resource + private DiscountActivityApi discountActivityApi; + @Resource + private RewardActivityApi rewardActivityApi; + + @MockitoBean + private ProductSpuApi productSpuApi; + @MockitoBean + private ProductCategoryApi productCategoryApi; + @MockitoBean + private ProductSkuApi productSkuApi; + + @Test + void normalCheckoutPromotionLookupsReturnEmptyListsAgainstPostgreSql() { + ProductSpuRespDTO spu = new ProductSpuRespDTO().setId(100L).setCategoryId(1L); + when(productSpuApi.getSpuList(List.of(100L))).thenReturn(List.of(spu)); + + assertThat(discountActivityApi.getMatchDiscountProductListBySkuIds(List.of(101L))).isEmpty(); + assertThat(rewardActivityApi.getMatchRewardActivityListBySpuIds(List.of(100L))).isEmpty(); + } + +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/promotion/SeckillPostgreSqlIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/promotion/SeckillPostgreSqlIntegrationTest.java new file mode 100644 index 00000000..5e81427a --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/promotion/SeckillPostgreSqlIntegrationTest.java @@ -0,0 +1,270 @@ +package cn.iocoder.yudao.module.education.integration.promotion; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.tenant.config.TenantProperties; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.tenant.core.db.TenantDatabaseInterceptor; +import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest; +import cn.iocoder.yudao.module.product.api.sku.ProductSkuApi; +import cn.iocoder.yudao.module.product.api.sku.dto.ProductSkuRespDTO; +import cn.iocoder.yudao.module.product.api.spu.ProductSpuApi; +import cn.iocoder.yudao.module.product.api.spu.dto.ProductSpuRespDTO; +import cn.iocoder.yudao.module.promotion.controller.admin.seckill.vo.activity.SeckillActivityCreateReqVO; +import cn.iocoder.yudao.module.promotion.controller.admin.seckill.vo.activity.SeckillActivityPageReqVO; +import cn.iocoder.yudao.module.promotion.controller.admin.seckill.vo.config.SeckillConfigCreateReqVO; +import cn.iocoder.yudao.module.promotion.controller.admin.seckill.vo.product.SeckillProductBaseVO; +import cn.iocoder.yudao.module.promotion.dal.dataobject.seckill.SeckillActivityDO; +import cn.iocoder.yudao.module.promotion.dal.dataobject.seckill.SeckillProductDO; +import cn.iocoder.yudao.module.promotion.service.seckill.SeckillActivityService; +import cn.iocoder.yudao.module.promotion.service.seckill.SeckillActivityServiceImpl; +import cn.iocoder.yudao.module.promotion.service.seckill.SeckillConfigService; +import cn.iocoder.yudao.module.promotion.service.seckill.SeckillConfigServiceImpl; +import com.baomidou.mybatisplus.core.incrementer.IdentifierGenerator; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import javax.sql.DataSource; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@Import({ + SeckillConfigServiceImpl.class, + SeckillActivityServiceImpl.class, + SeckillPostgreSqlIntegrationTest.TenantDatabaseTestConfiguration.class +}) +class SeckillPostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest { + + private static final long SPU_ID = 100L; + private static final long SKU_ID = 101L; + + @Resource + private SeckillConfigService seckillConfigService; + @Resource + private SeckillActivityService seckillActivityService; + @Resource + private DataSource dataSource; + private JdbcTemplate jdbcTemplate; + + @MockitoBean + private ProductSpuApi productSpuApi; + @MockitoBean + private ProductSkuApi productSkuApi; + + @BeforeEach + void setUp() { + LoginUser loginUser = new LoginUser().setId(7L).setTenantId(10L) + .setUserType(UserTypeEnum.ADMIN.getValue()); + SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest()); + jdbcTemplate = new JdbcTemplate(dataSource); + jdbcTemplate.execute(""" + INSERT INTO product_brand(id,tenant_id,name,pic_url,sort,status) + VALUES (1,10,'Education','https://example.test/brand.png',0,0), + (1,20,'Education','https://example.test/brand.png',0,0); + INSERT INTO product_category(id,tenant_id,parent_id,name,pic_url,sort,status) + VALUES (1,10,0,'Course','https://example.test/category.png',0,0), + (1,20,0,'Course','https://example.test/category.png',0,0); + INSERT INTO product_spu + (id,tenant_id,name,keyword,introduction,description,category_id,brand_id,pic_url, + sort,status,spec_type,price,stock,delivery_types,give_integral,sub_commission_type, + sales_count,virtual_sales_count,browse_count) + VALUES (100,10,'Course','course','Course','Course',1,1, + 'https://example.test/spu.png',0,0,false,1000,10,'[1]',0,false,0,0,0), + (100,20,'Course','course','Course','Course',1,1, + 'https://example.test/spu.png',0,0,false,1000,10,'[1]',0,false,0,0,0); + INSERT INTO product_sku(id,tenant_id,spu_id,price,pic_url,stock,sales_count) + VALUES (101,10,100,1000,'https://example.test/sku.png',10,0), + (101,20,100,1000,'https://example.test/sku.png',10,0); + """); + + when(productSpuApi.getSpu(eq(SPU_ID))).thenReturn(productSpu()); + when(productSkuApi.getSkuListBySpuId(any(Collection.class))).thenReturn(List.of(productSku())); + } + + @AfterEach + void clearTenantContext() { + TenantContextHolder.clear(); + SecurityContextHolder.clearContext(); + } + + @Test + void nativeSeckillServicesCreateSameNumberedTenantDataAndAtomicallyIsolateStock() throws Exception { + TenantContextHolder.setTenantId(10L); + Long tenant10ConfigId = seckillConfigService.createSeckillConfig(config("Tenant 10 day")); + Long tenant10ActivityId = seckillActivityService.createSeckillActivity( + activity("Tenant 10 Seckill", tenant10ConfigId, 1)); + + TenantContextHolder.setTenantId(20L); + Long tenant20ConfigId = seckillConfigService.createSeckillConfig(config("Tenant 20 day")); + Long tenant20ActivityId = seckillActivityService.createSeckillActivity( + activity("Tenant 20 Seckill", tenant20ConfigId, 3)); + + assertThat(tenant10ConfigId).isEqualTo(tenant20ConfigId); + assertThat(tenant10ActivityId).isEqualTo(tenant20ActivityId); + + TenantContextHolder.setTenantId(10L); + assertTenantState("Tenant 10 Seckill", 1); + assertThat(runConcurrentTenant10Decrements(tenant10ActivityId)).isEqualTo(1); + assertTenantState("Tenant 10 Seckill", 0); + assertThatThrownBy(() -> seckillActivityService.updateSeckillStockDecr( + tenant10ActivityId, SKU_ID, 1)).hasMessageContaining("秒杀库存不足"); + seckillActivityService.updateSeckillStockIncr(tenant10ActivityId, SKU_ID, 1); + assertTenantState("Tenant 10 Seckill", 1); + + TenantContextHolder.setTenantId(20L); + assertTenantState("Tenant 20 Seckill", 3); + + TenantContextHolder.setTenantId(10L); + seckillActivityService.closeSeckillActivity(tenant10ActivityId); + assertThat(seckillActivityService.getSeckillActivity(tenant10ActivityId).getStatus()) + .isEqualTo(CommonStatusEnum.DISABLE.getStatus()); + assertThat(seckillActivityService.getSeckillProductListByActivityId(tenant10ActivityId)) + .extracting(SeckillProductDO::getActivityStatus) + .containsExactly(CommonStatusEnum.DISABLE.getStatus()); + assertThatThrownBy(() -> seckillConfigService.deleteSeckillConfig(tenant10ConfigId)) + .hasMessageContaining("已被活动使用"); + + TenantContextHolder.setTenantId(20L); + assertThat(seckillActivityService.getSeckillActivity(tenant20ActivityId).getStatus()) + .isEqualTo(CommonStatusEnum.ENABLE.getStatus()); + } + + private int runConcurrentTenant10Decrements(Long activityId) throws Exception { + CountDownLatch start = new CountDownLatch(1); + try (var executor = Executors.newFixedThreadPool(2)) { + Future first = executor.submit(() -> decrementAfter(start, activityId)); + Future second = executor.submit(() -> decrementAfter(start, activityId)); + start.countDown(); + return (first.get() ? 1 : 0) + (second.get() ? 1 : 0); + } + } + + private boolean decrementAfter(CountDownLatch start, Long activityId) throws InterruptedException { + start.await(); + TenantContextHolder.setTenantId(10L); + try { + seckillActivityService.updateSeckillStockDecr(activityId, SKU_ID, 1); + return true; + } catch (RuntimeException ignored) { + return false; + } finally { + TenantContextHolder.clear(); + } + } + + private void assertTenantState(String expectedName, int expectedStock) { + SeckillActivityPageReqVO request = new SeckillActivityPageReqVO(); + request.setPageNo(1); + request.setPageSize(10); + PageResult page = seckillActivityService.getSeckillActivityPage(request); + assertThat(page.getList()).singleElement().satisfies(activity -> { + assertThat(activity.getTenantId()).isEqualTo(TenantContextHolder.getTenantId()); + assertThat(activity.getName()).isEqualTo(expectedName); + assertThat(activity.getStock()).isEqualTo(expectedStock); + }); + assertThat(seckillActivityService.getSeckillProductListByActivityId(page.getList().get(0).getId())) + .singleElement().satisfies(product -> { + assertThat(product.getTenantId()).isEqualTo(TenantContextHolder.getTenantId()); + assertThat(product.getStock()).isEqualTo(expectedStock); + }); + } + + private static SeckillConfigCreateReqVO config(String name) { + SeckillConfigCreateReqVO request = new SeckillConfigCreateReqVO(); + request.setName(name); + request.setStartTime("00:00"); + request.setEndTime("23:59"); + request.setSliderPicUrls(List.of("https://example.test/seckill.png")); + request.setStatus(CommonStatusEnum.ENABLE.getStatus()); + return request; + } + + private static SeckillActivityCreateReqVO activity(String name, Long configId, int stock) { + SeckillProductBaseVO product = new SeckillProductBaseVO(); + product.setSkuId(SKU_ID); + product.setSeckillPrice(500); + product.setStock(stock); + + SeckillActivityCreateReqVO request = new SeckillActivityCreateReqVO(); + request.setSpuId(SPU_ID); + request.setName(name); + request.setStartTime(LocalDateTime.now().minusDays(1)); + request.setEndTime(LocalDateTime.now().plusDays(1)); + request.setSort(0); + request.setConfigIds(List.of(configId)); + request.setTotalLimitCount(5); + request.setSingleLimitCount(2); + request.setProducts(List.of(product)); + return request; + } + + private static ProductSpuRespDTO productSpu() { + ProductSpuRespDTO product = new ProductSpuRespDTO(); + product.setId(SPU_ID); + product.setName("Course"); + product.setPrice(1000); + product.setStock(10); + return product; + } + + private static ProductSkuRespDTO productSku() { + ProductSkuRespDTO product = new ProductSkuRespDTO(); + product.setId(SKU_ID); + product.setSpuId(SPU_ID); + product.setPrice(1000); + product.setStock(10); + return product; + } + + @TestConfiguration(proxyBeanMethods = false) + static class TenantDatabaseTestConfiguration { + + @Bean + IdentifierGenerator fixedIdentifierGenerator() { + return entity -> 1L; + } + + @Bean + static BeanPostProcessor tenantDatabaseInterceptorInstaller() { + return new BeanPostProcessor() { + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) { + if (bean instanceof MybatisPlusInterceptor interceptor) { + TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor( + new TenantDatabaseInterceptor(new TenantProperties())); + MyBatisUtils.addInterceptor(interceptor, inner, 0); + } + return bean; + } + }; + } + + } + +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/trade/TradeAfterSalePostgreSqlIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/trade/TradeAfterSalePostgreSqlIntegrationTest.java new file mode 100644 index 00000000..58b700f4 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/trade/TradeAfterSalePostgreSqlIntegrationTest.java @@ -0,0 +1,262 @@ +package cn.iocoder.yudao.module.education.integration.trade; + +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.tenant.config.TenantProperties; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.tenant.core.db.TenantDatabaseInterceptor; +import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest; +import cn.iocoder.yudao.module.pay.api.refund.PayRefundApi; +import cn.iocoder.yudao.module.promotion.api.combination.CombinationRecordApi; +import cn.iocoder.yudao.module.trade.controller.admin.aftersale.vo.AfterSalePageReqVO; +import cn.iocoder.yudao.module.trade.controller.app.aftersale.vo.AppAfterSaleCreateReqVO; +import cn.iocoder.yudao.module.trade.dal.dataobject.aftersale.AfterSaleDO; +import cn.iocoder.yudao.module.trade.dal.dataobject.aftersale.AfterSaleLogDO; +import cn.iocoder.yudao.module.trade.dal.dataobject.order.TradeOrderDO; +import cn.iocoder.yudao.module.trade.dal.dataobject.order.TradeOrderItemDO; +import cn.iocoder.yudao.module.trade.dal.redis.no.TradeNoRedisDAO; +import cn.iocoder.yudao.module.trade.enums.aftersale.AfterSaleOperateTypeEnum; +import cn.iocoder.yudao.module.trade.enums.aftersale.AfterSaleStatusEnum; +import cn.iocoder.yudao.module.trade.enums.aftersale.AfterSaleWayEnum; +import cn.iocoder.yudao.module.trade.enums.order.TradeOrderItemAfterSaleStatusEnum; +import cn.iocoder.yudao.module.trade.enums.order.TradeOrderStatusEnum; +import cn.iocoder.yudao.module.trade.framework.order.config.TradeOrderProperties; +import cn.iocoder.yudao.module.trade.service.aftersale.AfterSaleLogService; +import cn.iocoder.yudao.module.trade.service.aftersale.AfterSaleLogServiceImpl; +import cn.iocoder.yudao.module.trade.service.aftersale.AfterSaleService; +import cn.iocoder.yudao.module.trade.service.aftersale.AfterSaleServiceImpl; +import cn.iocoder.yudao.module.trade.service.aftersale.bo.AfterSaleLogCreateReqBO; +import cn.iocoder.yudao.module.trade.service.delivery.DeliveryExpressService; +import cn.iocoder.yudao.module.trade.service.order.TradeOrderQueryService; +import cn.iocoder.yudao.module.trade.service.order.TradeOrderUpdateService; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import jakarta.annotation.Resource; +import javax.sql.DataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Import({ + AfterSaleServiceImpl.class, + AfterSaleLogServiceImpl.class, + TradeAfterSalePostgreSqlIntegrationTest.TenantDatabaseTestConfiguration.class +}) +class TradeAfterSalePostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest { + + @Resource + private AfterSaleService afterSaleService; + @Resource + private AfterSaleLogService afterSaleLogService; + @Resource + private DataSource dataSource; + private JdbcTemplate jdbcTemplate; + + @MockitoBean + private TradeOrderUpdateService tradeOrderUpdateService; + @MockitoBean + private TradeOrderQueryService tradeOrderQueryService; + @MockitoBean + private DeliveryExpressService deliveryExpressService; + @MockitoBean + private TradeNoRedisDAO tradeNoRedisDAO; + @MockitoBean + private PayRefundApi payRefundApi; + @MockitoBean + private CombinationRecordApi combinationRecordApi; + @MockitoBean + private TradeOrderProperties tradeOrderProperties; + + @BeforeEach + void setUp() { + LoginUser loginUser = new LoginUser().setId(7L).setTenantId(10L) + .setUserType(UserTypeEnum.ADMIN.getValue()); + SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest()); + jdbcTemplate = new JdbcTemplate(dataSource); + createOrderFixtures(); + + when(tradeOrderQueryService.getOrderItem(eq(42L), eq(810L))) + .thenAnswer(invocation -> orderItem(TenantContextHolder.getTenantId())); + when(tradeOrderQueryService.getOrder(eq(42L), eq(800L))) + .thenAnswer(invocation -> order(TenantContextHolder.getTenantId())); + when(tradeNoRedisDAO.generate(anyString())).thenReturn("AFTER-10", "AFTER-20"); + } + + @AfterEach + void clearContexts() { + TenantContextHolder.clear(); + SecurityContextHolder.clearContext(); + } + + @Test + void nativeAfterSaleServicePersistsAndReadsOnlyTheCurrentTenant() { + TenantContextHolder.setTenantId(10L); + Long tenant10AfterSaleId = afterSaleService.createAfterSale(42L, createRequest()); + afterSaleLogService.createAfterSaleLog(logRequest(tenant10AfterSaleId, "Tenant 10 requested refund")); + + TenantContextHolder.setTenantId(20L); + Long tenant20AfterSaleId = afterSaleService.createAfterSale(42L, createRequest()); + afterSaleLogService.createAfterSaleLog(logRequest(tenant20AfterSaleId, "Tenant 20 requested refund")); + + TenantContextHolder.setTenantId(10L); + PageResult tenant10Page = afterSaleService.getAfterSalePage(pageRequest()); + assertThat(tenant10Page.getList()).extracting(AfterSaleDO::getId) + .containsExactly(tenant10AfterSaleId); + assertThat(tenant10Page.getList().getFirst().getNo()).isEqualTo("AFTER-10"); + assertThat(afterSaleService.getAfterSale(tenant20AfterSaleId)).isNull(); + assertThat(afterSaleLogService.getAfterSaleLogList(tenant10AfterSaleId)) + .extracting(AfterSaleLogDO::getContent) + .containsExactly("Tenant 10 requested refund"); + assertThat(afterSaleLogService.getAfterSaleLogList(tenant20AfterSaleId)).isEmpty(); + + TenantContextHolder.setTenantId(20L); + PageResult tenant20Page = afterSaleService.getAfterSalePage(pageRequest()); + assertThat(tenant20Page.getList()).extracting(AfterSaleDO::getId) + .containsExactly(tenant20AfterSaleId); + assertThat(tenant20Page.getList().getFirst().getNo()).isEqualTo("AFTER-20"); + assertThat(afterSaleService.getAfterSale(tenant10AfterSaleId)).isNull(); + assertThat(afterSaleLogService.getAfterSaleLogList(tenant20AfterSaleId)) + .extracting(AfterSaleLogDO::getContent) + .containsExactly("Tenant 20 requested refund"); + + verify(tradeOrderUpdateService).updateOrderItemWhenAfterSaleCreate(810L, tenant10AfterSaleId); + verify(tradeOrderUpdateService).updateOrderItemWhenAfterSaleCreate(810L, tenant20AfterSaleId); + } + + private void createOrderFixtures() { + jdbcTemplate.execute(""" + INSERT INTO product_brand(id,tenant_id,name,pic_url,sort,status) + VALUES (1,10,'Education','https://example.test/brand.png',0,0), + (1,20,'Education','https://example.test/brand.png',0,0); + INSERT INTO product_category(id,tenant_id,parent_id,name,pic_url,sort,status) + VALUES (1,10,0,'Course','https://example.test/category.png',0,0), + (1,20,0,'Course','https://example.test/category.png',0,0); + INSERT INTO product_spu + (id,tenant_id,name,keyword,introduction,description,category_id,brand_id,pic_url, + sort,status,spec_type,price,stock,delivery_types,give_integral, + sub_commission_type,sales_count,virtual_sales_count,browse_count) + VALUES (100,10,'Printed course','course','Printed course','Printed course',1,1, + 'https://example.test/spu.png',0,0,false,1000,10,'[1]',0,false,0,0,0), + (100,20,'Printed course','course','Printed course','Printed course',1,1, + 'https://example.test/spu.png',0,0,false,1000,10,'[1]',0,false,0,0,0); + INSERT INTO product_sku(id,tenant_id,spu_id,price,pic_url,stock,sales_count) + VALUES (101,10,100,1000,'https://example.test/sku.png',10,0), + (101,20,100,1000,'https://example.test/sku.png',10,0); + INSERT INTO trade_order + (id,tenant_id,no,type,terminal,user_id,user_ip,status,product_count,pay_status, + total_price,discount_price,delivery_price,adjust_price,pay_price,delivery_type, + receiver_name,receiver_mobile,receiver_area_id,receiver_detail_address, + refund_status,refund_price,coupon_price,use_point,point_price,give_point, + refund_point,vip_price) + VALUES (800,10,'ORDER-SAME',0,20,42,'127.0.0.1',0,1,false, + 1000,0,0,0,1000,1,'Learner','13800000000',310000,'Building A', + 0,0,0,0,0,0,0,0), + (800,20,'ORDER-SAME',0,20,42,'127.0.0.1',0,1,false, + 1000,0,0,0,1000,1,'Learner','13900000000',310000,'Building B', + 0,0,0,0,0,0,0,0); + INSERT INTO trade_order_item + (id,tenant_id,user_id,order_id,spu_id,spu_name,sku_id,properties,pic_url, + count,comment_status,price,discount_price,delivery_price,adjust_price,pay_price, + coupon_price,point_price,use_point,give_point,vip_price,after_sale_status) + VALUES (810,10,42,800,100,'Printed course',101,'[]','https://example.test/sku.png', + 1,false,1000,0,0,0,1000,0,0,0,0,0,0), + (810,20,42,800,100,'Printed course',101,'[]','https://example.test/sku.png', + 1,false,1000,0,0,0,1000,0,0,0,0,0,0); + """); + } + + private static AppAfterSaleCreateReqVO createRequest() { + return new AppAfterSaleCreateReqVO() + .setOrderItemId(810L) + .setWay(AfterSaleWayEnum.REFUND.getWay()) + .setRefundPrice(800) + .setApplyReason("Changed mind") + .setApplyDescription("Native service integration") + .setApplyPicUrls(List.of("https://example.test/evidence.png")); + } + + private static AfterSaleLogCreateReqBO logRequest(Long afterSaleId, String content) { + return new AfterSaleLogCreateReqBO( + 42L, + UserTypeEnum.MEMBER.getValue(), + afterSaleId, + null, + AfterSaleStatusEnum.APPLY.getStatus(), + AfterSaleOperateTypeEnum.MEMBER_CREATE.getType(), + content); + } + + private static AfterSalePageReqVO pageRequest() { + AfterSalePageReqVO request = new AfterSalePageReqVO(); + request.setPageNo(1); + request.setPageSize(10); + return request; + } + + private static TradeOrderItemDO orderItem(Long tenantId) { + TradeOrderItemDO item = new TradeOrderItemDO() + .setId(810L) + .setUserId(42L) + .setOrderId(800L) + .setSpuId(100L) + .setSpuName("Printed course") + .setSkuId(101L) + .setProperties(List.of()) + .setPicUrl("https://example.test/sku.png") + .setCount(1) + .setPayPrice(1000) + .setAfterSaleStatus(TradeOrderItemAfterSaleStatusEnum.NONE.getStatus()); + item.setTenantId(tenantId); + return item; + } + + private static TradeOrderDO order(Long tenantId) { + TradeOrderDO order = new TradeOrderDO() + .setId(800L) + .setNo("ORDER-SAME") + .setUserId(42L) + .setStatus(TradeOrderStatusEnum.UNDELIVERED.getStatus()); + order.setTenantId(tenantId); + return order; + } + + @TestConfiguration(proxyBeanMethods = false) + static class TenantDatabaseTestConfiguration { + + @Bean + static BeanPostProcessor tenantDatabaseInterceptorInstaller() { + return new BeanPostProcessor() { + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) { + if (bean instanceof MybatisPlusInterceptor interceptor) { + TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor( + new TenantDatabaseInterceptor(new TenantProperties())); + MyBatisUtils.addInterceptor(interceptor, inner, 0); + } + return bean; + } + }; + } + + } + +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/trade/TradeBrokeragePostgreSqlIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/trade/TradeBrokeragePostgreSqlIntegrationTest.java new file mode 100644 index 00000000..e20fcfea --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/trade/TradeBrokeragePostgreSqlIntegrationTest.java @@ -0,0 +1,216 @@ +package cn.iocoder.yudao.module.education.integration.trade; + +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.tenant.config.TenantProperties; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.tenant.core.db.TenantDatabaseInterceptor; +import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest; +import cn.iocoder.yudao.module.member.api.user.MemberUserApi; +import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; +import cn.iocoder.yudao.module.product.api.sku.ProductSkuApi; +import cn.iocoder.yudao.module.product.api.spu.ProductSpuApi; +import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.record.BrokerageRecordPageReqVO; +import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.user.BrokerageUserPageReqVO; +import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserRankByPriceRespVO; +import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserRankPageReqVO; +import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageRecordDO; +import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageUserDO; +import cn.iocoder.yudao.module.trade.dal.dataobject.config.TradeConfigDO; +import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageBindModeEnum; +import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageEnabledConditionEnum; +import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordBizTypeEnum; +import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordStatusEnum; +import cn.iocoder.yudao.module.trade.service.brokerage.BrokerageRecordService; +import cn.iocoder.yudao.module.trade.service.brokerage.BrokerageRecordServiceImpl; +import cn.iocoder.yudao.module.trade.service.brokerage.BrokerageUserService; +import cn.iocoder.yudao.module.trade.service.brokerage.BrokerageUserServiceImpl; +import cn.iocoder.yudao.module.trade.service.brokerage.bo.BrokerageAddReqBO; +import cn.iocoder.yudao.module.trade.service.brokerage.bo.UserBrokerageSummaryRespBO; +import cn.iocoder.yudao.module.trade.service.config.TradeConfigService; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; + +@Import({ + BrokerageUserServiceImpl.class, + BrokerageRecordServiceImpl.class, + TradeBrokeragePostgreSqlIntegrationTest.TenantDatabaseTestConfiguration.class +}) +class TradeBrokeragePostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest { + + private static final long ROOT_USER_ID = 100L; + private static final long FIRST_LEVEL_USER_ID = 200L; + private static final long BUYER_USER_ID = 300L; + + @Resource + private BrokerageUserService brokerageUserService; + @Resource + private BrokerageRecordService brokerageRecordService; + + @MockitoBean + private TradeConfigService tradeConfigService; + @MockitoBean + private MemberUserApi memberUserApi; + @MockitoBean + private ProductSpuApi productSpuApi; + @MockitoBean + private ProductSkuApi productSkuApi; + + @BeforeEach + void setUp() { + LoginUser loginUser = new LoginUser().setId(7L).setTenantId(10L) + .setUserType(UserTypeEnum.ADMIN.getValue()); + SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest()); + when(tradeConfigService.getTradeConfig()).thenAnswer(invocation -> tradeConfig( + TenantContextHolder.getTenantId() == 10L ? 10 : 20, + TenantContextHolder.getTenantId() == 10L ? 5 : 10)); + when(memberUserApi.getUser(anyLong())).thenAnswer(invocation -> new MemberUserRespDTO() + .setId(invocation.getArgument(0)) + .setNickname("Member " + invocation.getArgument(0)) + .setCreateTime(LocalDateTime.now())); + } + + @AfterEach + void clearContexts() { + TenantContextHolder.clear(); + SecurityContextHolder.clearContext(); + } + + @Test + void nativeBrokerageServicesPersistRelationshipsCommissionsAndRawStatisticsPerTenant() { + LocalDateTime beginTime = LocalDateTime.now().minusMinutes(1); + + TenantContextHolder.setTenantId(10L); + createTwoLevelTeamAndCommission(); + + TenantContextHolder.setTenantId(20L); + createTwoLevelTeamAndCommission(); + + TenantContextHolder.setTenantId(10L); + assertTenantState(1000, 500); + + TenantContextHolder.setTenantId(20L); + assertTenantState(2000, 1000); + + LocalDateTime endTime = LocalDateTime.now().plusMinutes(1); + TenantContextHolder.setTenantId(10L); + assertRawStatistics(beginTime, endTime, 1000, 500); + + TenantContextHolder.setTenantId(20L); + assertRawStatistics(beginTime, endTime, 2000, 1000); + } + + private void createTwoLevelTeamAndCommission() { + assertThat(brokerageUserService.getOrCreateBrokerageUser(ROOT_USER_ID)).isNotNull(); + assertThat(brokerageUserService.bindBrokerageUser(FIRST_LEVEL_USER_ID, ROOT_USER_ID)).isTrue(); + assertThat(brokerageUserService.bindBrokerageUser(BUYER_USER_ID, FIRST_LEVEL_USER_ID)).isTrue(); + + brokerageRecordService.addBrokerage(BUYER_USER_ID, BrokerageRecordBizTypeEnum.ORDER, + List.of(new BrokerageAddReqBO( + "shared-order-1", 10_000, null, null, BUYER_USER_ID, "Course order commission"))); + } + + private void assertTenantState(int firstLevelPrice, int secondLevelPrice) { + BrokerageUserPageReqVO userPageRequest = new BrokerageUserPageReqVO(); + userPageRequest.setPageNo(1); + userPageRequest.setPageSize(10); + PageResult userPage = brokerageUserService.getBrokerageUserPage(userPageRequest); + assertThat(userPage.getList()).extracting(BrokerageUserDO::getId) + .containsExactlyInAnyOrder(ROOT_USER_ID, FIRST_LEVEL_USER_ID, BUYER_USER_ID); + assertThat(brokerageUserService.getBrokerageUser(FIRST_LEVEL_USER_ID).getBrokeragePrice()) + .isEqualTo(firstLevelPrice); + assertThat(brokerageUserService.getBrokerageUser(ROOT_USER_ID).getBrokeragePrice()) + .isEqualTo(secondLevelPrice); + + BrokerageRecordPageReqVO recordPageRequest = new BrokerageRecordPageReqVO(); + recordPageRequest.setPageNo(1); + recordPageRequest.setPageSize(10); + PageResult recordPage = brokerageRecordService.getBrokerageRecordPage(recordPageRequest); + assertThat(recordPage.getList()).hasSize(2) + .allSatisfy(record -> { + assertThat(record.getTenantId()).isEqualTo(TenantContextHolder.getTenantId()); + assertThat(record.getBizId()).isEqualTo("shared-order-1"); + assertThat(record.getStatus()).isEqualTo(BrokerageRecordStatusEnum.SETTLEMENT.getStatus()); + assertThat(record.getUnfreezeTime()).isNotNull(); + }); + assertThat(recordPage.getList()).extracting(BrokerageRecordDO::getPrice) + .containsExactlyInAnyOrder(firstLevelPrice, secondLevelPrice); + } + + private void assertRawStatistics(LocalDateTime beginTime, LocalDateTime endTime, + int firstLevelPrice, int secondLevelPrice) { + Integer firstLevelSummary = brokerageRecordService.getSummaryPriceByUserId( + FIRST_LEVEL_USER_ID, BrokerageRecordBizTypeEnum.ORDER, + BrokerageRecordStatusEnum.SETTLEMENT, beginTime, endTime); + assertThat(firstLevelSummary).isEqualTo(firstLevelPrice); + + List summaries = brokerageRecordService + .getUserBrokerageSummaryListByUserId( + List.of(ROOT_USER_ID, FIRST_LEVEL_USER_ID), + BrokerageRecordBizTypeEnum.ORDER.getType(), + BrokerageRecordStatusEnum.SETTLEMENT.getStatus()); + assertThat(summaries).extracting(UserBrokerageSummaryRespBO::getPrice) + .containsExactlyInAnyOrder(firstLevelPrice, secondLevelPrice); + + AppBrokerageUserRankPageReqVO rankRequest = new AppBrokerageUserRankPageReqVO(); + rankRequest.setPageNo(1); + rankRequest.setPageSize(10); + rankRequest.setTimes(new LocalDateTime[]{beginTime, endTime}); + PageResult rankPage = brokerageRecordService + .getBrokerageUserChildSummaryPageByPrice(rankRequest); + assertThat(rankPage.getList()).extracting(AppBrokerageUserRankByPriceRespVO::getBrokeragePrice) + .containsExactly(firstLevelPrice, secondLevelPrice); + } + + private static TradeConfigDO tradeConfig(int firstPercent, int secondPercent) { + return new TradeConfigDO() + .setBrokerageEnabled(true) + .setBrokerageEnabledCondition(BrokerageEnabledConditionEnum.ALL.getCondition()) + .setBrokerageBindMode(BrokerageBindModeEnum.ANYTIME.getMode()) + .setBrokerageFirstPercent(firstPercent) + .setBrokerageSecondPercent(secondPercent) + .setBrokerageFrozenDays(0); + } + + @TestConfiguration(proxyBeanMethods = false) + static class TenantDatabaseTestConfiguration { + + @Bean + static BeanPostProcessor tenantDatabaseInterceptorInstaller() { + return new BeanPostProcessor() { + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) { + if (bean instanceof MybatisPlusInterceptor interceptor) { + TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor( + new TenantDatabaseInterceptor(new TenantProperties())); + MyBatisUtils.addInterceptor(interceptor, inner, 0); + } + return bean; + } + }; + } + + } + +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/trade/TradeDeliveryPostgreSqlIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/trade/TradeDeliveryPostgreSqlIntegrationTest.java new file mode 100644 index 00000000..1d2f26d7 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/trade/TradeDeliveryPostgreSqlIntegrationTest.java @@ -0,0 +1,179 @@ +package cn.iocoder.yudao.module.education.integration.trade; + +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.tenant.config.TenantProperties; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.tenant.core.db.TenantDatabaseInterceptor; +import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest; +import cn.iocoder.yudao.module.member.api.address.MemberAddressApi; +import cn.iocoder.yudao.module.member.api.address.dto.MemberAddressRespDTO; +import cn.iocoder.yudao.module.trade.controller.admin.delivery.vo.expresstemplate.DeliveryExpressTemplateChargeBaseVO; +import cn.iocoder.yudao.module.trade.controller.admin.delivery.vo.expresstemplate.DeliveryExpressTemplateCreateReqVO; +import cn.iocoder.yudao.module.trade.controller.admin.delivery.vo.expresstemplate.DeliveryExpressTemplateFreeBaseVO; +import cn.iocoder.yudao.module.trade.enums.delivery.DeliveryExpressChargeModeEnum; +import cn.iocoder.yudao.module.trade.enums.delivery.DeliveryTypeEnum; +import cn.iocoder.yudao.module.trade.service.config.TradeConfigService; +import cn.iocoder.yudao.module.trade.service.delivery.DeliveryExpressTemplateService; +import cn.iocoder.yudao.module.trade.service.delivery.DeliveryExpressTemplateServiceImpl; +import cn.iocoder.yudao.module.trade.service.delivery.DeliveryPickUpStoreService; +import cn.iocoder.yudao.module.trade.service.delivery.bo.DeliveryExpressTemplateRespBO; +import cn.iocoder.yudao.module.trade.service.price.bo.TradePriceCalculateReqBO; +import cn.iocoder.yudao.module.trade.service.price.bo.TradePriceCalculateRespBO; +import cn.iocoder.yudao.module.trade.service.price.calculator.TradeDeliveryPriceCalculator; +import cn.iocoder.yudao.module.trade.service.price.calculator.TradePriceCalculatorHelper; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@Import({ + DeliveryExpressTemplateServiceImpl.class, + TradeDeliveryPriceCalculator.class, + TradeDeliveryPostgreSqlIntegrationTest.TenantDatabaseTestConfiguration.class +}) +class TradeDeliveryPostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest { + + @Resource + private DeliveryExpressTemplateService deliveryExpressTemplateService; + @Resource + private TradeDeliveryPriceCalculator tradeDeliveryPriceCalculator; + + @MockitoBean + private MemberAddressApi memberAddressApi; + @MockitoBean + private DeliveryPickUpStoreService deliveryPickUpStoreService; + @MockitoBean + private TradeConfigService tradeConfigService; + + @BeforeEach + void setAdminPrincipal() { + LoginUser loginUser = new LoginUser().setId(7L).setTenantId(10L) + .setUserType(UserTypeEnum.ADMIN.getValue()); + SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest()); + } + + @AfterEach + void clearTenantContext() { + TenantContextHolder.clear(); + SecurityContextHolder.clearContext(); + } + + @Test + void nativeTemplateServicePersistsRulesAndKeepsTenantLookupsIsolated() { + TenantContextHolder.setTenantId(10L); + Long tenant10TemplateId = deliveryExpressTemplateService.createDeliveryExpressTemplate( + template("Standard delivery", 500, 200)); + + TenantContextHolder.setTenantId(20L); + Long tenant20TemplateId = deliveryExpressTemplateService.createDeliveryExpressTemplate( + template("Standard delivery", 900, 300)); + + TenantContextHolder.setTenantId(10L); + Map tenant10Match = deliveryExpressTemplateService + .getExpressTemplateMapByIdsAndArea( + List.of(tenant10TemplateId, tenant20TemplateId), 310000); + assertThat(tenant10Match).containsOnlyKeys(tenant10TemplateId); + assertThat(tenant10Match.get(tenant10TemplateId).getCharge().getStartPrice()).isEqualTo(500); + assertThat(deliveryExpressTemplateService.getDeliveryExpressTemplateList()) + .extracting("id") + .containsExactly(tenant10TemplateId); + + Map tenant10Free = deliveryExpressTemplateService + .getExpressTemplateMapByIdsAndArea(List.of(tenant10TemplateId), 320000); + assertThat(tenant10Free.get(tenant10TemplateId).getFree().getFreePrice()).isEqualTo(5000); + + when(memberAddressApi.getAddress(10L, 42L)) + .thenReturn(new MemberAddressRespDTO().setAreaId(310000)); + when(tradeConfigService.getTradeConfig()).thenReturn(null); + TradePriceCalculateReqBO calculateRequest = new TradePriceCalculateReqBO() + .setUserId(42L) + .setDeliveryType(DeliveryTypeEnum.EXPRESS.getType()) + .setAddressId(10L) + .setItems(List.of(new TradePriceCalculateReqBO.Item() + .setSkuId(101L).setCount(2).setSelected(true))); + TradePriceCalculateRespBO.OrderItem orderItem = new TradePriceCalculateRespBO.OrderItem() + .setSkuId(101L).setCount(2).setSelected(true).setPrice(1000) + .setDeliveryTemplateId(tenant10TemplateId) + .setDeliveryTypes(List.of(DeliveryTypeEnum.EXPRESS.getType())); + TradePriceCalculateRespBO calculateResult = new TradePriceCalculateRespBO() + .setPrice(new TradePriceCalculateRespBO.Price()) + .setPromotions(new ArrayList<>()) + .setItems(List.of(orderItem)); + TradePriceCalculatorHelper.recountPayPrice(calculateResult.getItems()); + TradePriceCalculatorHelper.recountAllPrice(calculateResult); + + tradeDeliveryPriceCalculator.calculate(calculateRequest, calculateResult); + + assertThat(calculateResult.getPrice().getDeliveryPrice()).isEqualTo(700); + assertThat(calculateResult.getPrice().getPayPrice()).isEqualTo(2700); + assertThat(orderItem.getDeliveryPrice()).isEqualTo(700); + + TenantContextHolder.setTenantId(20L); + Map tenant20Match = deliveryExpressTemplateService + .getExpressTemplateMapByIdsAndArea( + List.of(tenant10TemplateId, tenant20TemplateId), 310000); + assertThat(tenant20Match).containsOnlyKeys(tenant20TemplateId); + assertThat(tenant20Match.get(tenant20TemplateId).getCharge().getStartPrice()).isEqualTo(900); + } + + private static DeliveryExpressTemplateCreateReqVO template( + String name, int startPrice, int extraPrice) { + DeliveryExpressTemplateChargeBaseVO charge = new DeliveryExpressTemplateChargeBaseVO() + .setAreaIds(List.of(310000)) + .setStartCount(1D) + .setStartPrice(startPrice) + .setExtraCount(1D) + .setExtraPrice(extraPrice); + DeliveryExpressTemplateFreeBaseVO free = new DeliveryExpressTemplateFreeBaseVO() + .setAreaIds(List.of(320000)) + .setFreePrice(5000) + .setFreeCount(3); + DeliveryExpressTemplateCreateReqVO request = new DeliveryExpressTemplateCreateReqVO(); + request.setName(name); + request.setChargeMode(DeliveryExpressChargeModeEnum.COUNT.getType()); + request.setSort(0); + request.setCharges(List.of(charge)); + request.setFrees(List.of(free)); + return request; + } + + @TestConfiguration(proxyBeanMethods = false) + static class TenantDatabaseTestConfiguration { + + @Bean + static BeanPostProcessor tenantDatabaseInterceptorInstaller() { + return new BeanPostProcessor() { + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) { + if (bean instanceof MybatisPlusInterceptor interceptor) { + TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor( + new TenantDatabaseInterceptor(new TenantProperties())); + MyBatisUtils.addInterceptor(interceptor, inner, 0); + } + return bean; + } + }; + } + + } + +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/activationcode/ActivationCodePostgreSqlIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/activationcode/ActivationCodePostgreSqlIntegrationTest.java new file mode 100644 index 00000000..15cebb28 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/activationcode/ActivationCodePostgreSqlIntegrationTest.java @@ -0,0 +1,198 @@ +package cn.iocoder.yudao.module.education.service.activationcode; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +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.RedeemResp; +import cn.iocoder.yudao.module.education.dal.dataobject.activationcode.ActivationCodeDO; +import cn.iocoder.yudao.module.education.dal.dataobject.catalog.*; +import cn.iocoder.yudao.module.education.dal.dataobject.commercialization.EducationResourceProductBindingDO; +import cn.iocoder.yudao.module.education.dal.mysql.activationcode.ActivationCodeMapper; +import cn.iocoder.yudao.module.education.dal.mysql.catalog.*; +import cn.iocoder.yudao.module.education.dal.mysql.commercialization.*; +import cn.iocoder.yudao.module.education.service.commercialization.EducationEntitlementServiceImpl; +import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.*; +import org.springframework.context.annotation.Import; + +import javax.sql.DataSource; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.sql.Connection; +import java.util.HexFormat; +import java.util.concurrent.*; + +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.ACTIVATION_CODE_USED; +import static org.assertj.core.api.Assertions.*; + +@Import({ActivationCodeServiceImpl.class, EducationEntitlementServiceImpl.class}) +class ActivationCodePostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest { + + @Resource ActivationCodeService service; + @Resource ActivationCodeMapper codeMapper; + @Resource EducationEntitlementMapper entitlementMapper; + @Resource EducationEntitlementEventMapper eventMapper; + @Resource EducationResourceProductBindingMapper bindingMapper; + @Resource ContentEntryMapper entryMapper; + @Resource ContentNodeMapper nodeMapper; + @Resource QuestionCollectionMapper collectionMapper; + @Resource DataSource dataSource; + + @BeforeEach + void setUp() { + TenantContextHolder.setTenantId(10L); + ContentEntryDO entry = new ContentEntryDO(); entry.setId(100L); entry.setTenantId(10L); + entry.setScope("TENANT_OWNED"); entry.setEntryKey("activation-paid"); entry.setName("Activation paid"); + entry.setEntryType("question"); entryMapper.insert(entry); + + ContentNodeDO node = new ContentNodeDO(); node.setId(101L); node.setTenantId(10L); node.setScope("TENANT_OWNED"); + node.setEntryId(100L); node.setName("Activation node"); node.setNodeType("category"); node.setDepth(0); + node.setIsLeaf(true); node.setIsSelectable(true); node.setIsHidden(true); node.setIsActive(false); + node.setPublicationStatus("DRAFT"); node.setAuthoringVersion(0); nodeMapper.insert(node); + activateNode(); + + QuestionCollectionDO collection = new QuestionCollectionDO(); collection.setId(102L); collection.setTenantId(10L); + collection.setScope("TENANT_OWNED"); collection.setEntryId(100L); collection.setNodeId(101L); + collection.setName("Activation collection"); collection.setCollectionType("MANUAL"); collection.setQuestionCount(0); + collection.setAccessMode("PAID"); collection.setAccessRules("{}"); collection.setIsHidden(true); + collection.setIsActive(false); collection.setSortOrder(0); collection.setPublicationStatus("DRAFT"); + collection.setAuthoringVersion(0); collection.setMembershipVersion(0); collectionMapper.insert(collection); + activateCollection(); + + EducationResourceProductBindingDO binding = EducationResourceProductBindingDO.builder() + .resourceType("QUESTION_COLLECTION").resourceId(102L).productSpuId(900L) + .status("ACTIVE").version(0).build(); + binding.setTenantId(10L); bindingMapper.insert(binding); + } + + @AfterEach void clear() { TenantContextHolder.clear(); } + + @Test + void generationPersistsDigestAndMaskWithoutPlaintextAndIsTenantScoped() { + GenerateResp generated = generate(2); + assertThat(generated.getCodes()).hasSize(2); + generated.getCodes().forEach(item -> { + ActivationCodeDO persisted = codeMapper.selectByHash(10L, sha256(item.getCode())); + assertThat(persisted).isNotNull(); + assertThat(persisted.getCodeHash()).hasSize(64).isNotEqualToIgnoringCase(item.getCode()); + assertThat(persisted.getCodeMasked()).contains("****").isEqualTo(item.getCodeMasked()); + assertThat(persisted.toString()).doesNotContain(item.getCode()); + }); + + TenantContextHolder.setTenantId(20L); + assertThat(service.batchPage(new BatchPageReq()).getList()).isEmpty(); + assertThat(service.codePage(new CodePageReq()).getList()).isEmpty(); + } + + @Test + void redemptionCreatesEntitlementEventAndReplaysOnlyForTheSameMember() { + String plain = generate(1).getCodes().getFirst().getCode(); + RedeemResp first = service.redeem(plain, 8L); + assertThat(first.getEntitlementId()).isNotNull(); + assertThat(first.getResourceId()).isEqualTo(102L); + assertThat(first.isIdempotent()).isFalse(); + assertThat(entitlementMapper.selectCount()).isEqualTo(1); + assertThat(eventMapper.selectCount()).isEqualTo(1); + + assertThat(service.redeem(plain.toLowerCase(), 8L).isIdempotent()).isTrue(); + ServiceException used = catchThrowableOfType(() -> service.redeem(plain, 9L), ServiceException.class); + assertThat(used.getCode()).isEqualTo(ACTIVATION_CODE_USED.getCode()); + assertThat(entitlementMapper.selectCount()).isEqualTo(1); + assertThat(eventMapper.selectCount()).isEqualTo(1); + } + + @Test + void batchAndBindingMustRemainActiveUntilRedemption() { + GenerateResp generated = generate(2); + BatchResp batch = generated.getBatch(); + service.updateBatch(batch.getId(), batchSave("DISABLED", batch.getVersion()), 7L); + assertThatThrownBy(() -> service.redeem(generated.getCodes().get(0).getCode(), 8L)) + .isInstanceOf(ServiceException.class); + + BatchResp reenabled = service.updateBatch(batch.getId(), batchSave("ACTIVE", batch.getVersion() + 1), 7L); + EducationResourceProductBindingDO binding = bindingMapper.selectByProduct(10L, 900L); + binding.setStatus("INACTIVE"); bindingMapper.updateById(binding); + assertThat(reenabled.getStatus()).isEqualTo("ACTIVE"); + assertThatThrownBy(() -> service.redeem(generated.getCodes().get(1).getCode(), 8L)) + .isInstanceOf(ServiceException.class); + } + + @Test + void concurrentRedemptionHasExactlyOneWinner() throws Exception { + String plain = generate(1).getCodes().getFirst().getCode(); + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try { + Future first = executor.submit(() -> redeemAfterBarrier(plain, 8L, ready, start)); + Future second = executor.submit(() -> redeemAfterBarrier(plain, 9L, ready, start)); + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); start.countDown(); + assertThat(java.util.List.of(first.get(20, TimeUnit.SECONDS), second.get(20, TimeUnit.SECONDS))) + .filteredOn(RedeemResp.class::isInstance).hasSize(1); + assertThat(java.util.List.of(first.get(), second.get())) + .filteredOn(value -> value instanceof Integer code && code == ACTIVATION_CODE_USED.getCode()).hasSize(1); + assertThat(entitlementMapper.selectCount()).isEqualTo(1); + assertThat(eventMapper.selectCount()).isEqualTo(1); + } finally { + executor.shutdownNow(); + } + } + + private Object redeemAfterBarrier(String plain, Long userId, CountDownLatch ready, CountDownLatch start) throws Exception { + TenantContextHolder.setTenantId(10L); ready.countDown(); start.await(10, TimeUnit.SECONDS); + try { return service.redeem(plain, userId); } + catch (ServiceException ex) { return ex.getCode(); } + finally { TenantContextHolder.clear(); } + } + + private GenerateResp generate(int count) { + BatchResp batch = service.createBatch(batchSave("ACTIVE", null), 7L); + GenerateReq req = new GenerateReq(); req.setCount(count); req.setExpectedVersion(batch.getVersion()); + return service.generate(batch.getId(), req, 7L); + } + + private static BatchSaveReq batchSave(String status, Integer expectedVersion) { + BatchSaveReq req = new BatchSaveReq(); req.setName("Activation batch"); req.setProductSpuId(900L); + req.setDurationDays(30); req.setCodePrefix("EDU"); req.setStatus(status); req.setExpectedVersion(expectedVersion); + return req; + } + + private static String sha256(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (Exception ex) { throw new IllegalStateException(ex); } + } + + private void activateNode() { + execute(""" + DO $activate$ + BEGIN + UPDATE education_content_node SET publication_status='ACTIVE', is_active=true, + is_hidden=false, authoring_version=1 WHERE id=101 AND tenant_id=10; + INSERT INTO education_content_node_lifecycle_audit + (tenant_id,node_id,authoring_version,actor_id,from_status,to_status) + VALUES (10,101,1,7,'DRAFT','ACTIVE'); + END $activate$; + """); + } + + private void activateCollection() { + execute(""" + DO $activate$ + BEGIN + UPDATE education_question_collection SET publication_status='ACTIVE', is_active=true, + is_hidden=false, authoring_version=1 WHERE id=102 AND tenant_id=10; + INSERT INTO education_question_collection_lifecycle_audit + (tenant_id,collection_id,authoring_version,actor_id,from_status,to_status) + VALUES (10,102,1,7,'DRAFT','ACTIVE'); + END $activate$; + """); + } + + private void execute(String sql) { + try (Connection connection = dataSource.getConnection(); var statement = connection.createStatement()) { + statement.execute(sql); + } catch (Exception ex) { throw new IllegalStateException(ex); } + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/activationcode/ActivationCodeServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/activationcode/ActivationCodeServiceImplTest.java new file mode 100644 index 00000000..8da52740 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/activationcode/ActivationCodeServiceImplTest.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.module.education.service.activationcode; + +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.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.junit.jupiter.api.*; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.ObjectProvider; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ActivationCodeServiceImplTest { + private final ActivationCodeBatchMapper batches=mock(ActivationCodeBatchMapper.class); + private final ActivationCodeMapper codes=mock(ActivationCodeMapper.class); + private final EducationResourceProductBindingMapper bindings=mock(EducationResourceProductBindingMapper.class); + private final EducationProductCatalogPort products=mock(EducationProductCatalogPort.class); + @SuppressWarnings("unchecked") + private final ObjectProvider productProviders=mock(ObjectProvider.class); + private final EducationEntitlementService entitlements=mock(EducationEntitlementService.class); + private final ActivationCodeServiceImpl service=new ActivationCodeServiceImpl(batches,codes,bindings,productProviders,entitlements); + + @BeforeEach void tenant(){TenantContextHolder.setTenantId(10L);when(productProviders.getIfAvailable()).thenReturn(products);} + @AfterEach void clear(){TenantContextHolder.clear();} + + @Test void generateReturnsPlaintextOnceButPersistsOnlyHashAndMask(){ + ActivationCodeBatchDO batch=batch(1L,"ACTIVE",2); when(batches.selectOwned(10L,1L)).thenReturn(batch,batch(1L,"ACTIVE",3)); + when(batches.addGeneratedCas(10L,1L,2,2,"7")).thenReturn(1); + GenerateReq req=new GenerateReq();req.setCount(2);req.setExpectedVersion(2); + GenerateResp result=service.generate(1L,req,7L); + assertThat(result.getCodes()).hasSize(2).allSatisfy(item->assertThat(item.getCode()).startsWith("EDU-").hasSize(20)); + ArgumentCaptor captor=ArgumentCaptor.forClass(ActivationCodeDO.class);verify(codes,times(2)).insert(captor.capture()); + assertThat(captor.getAllValues()).allSatisfy(item->{assertThat(item.getCodeHash()).matches("[0-9a-f]{64}");assertThat(item.getCodeMasked()).contains("****");}); + } + + @Test void redemptionComposesExistingEntitlementAndSameMemberReplayIsIdempotent(){ + ActivationCodeDO available=ActivationCodeDO.builder().id(5L).batchId(1L).status("AVAILABLE").build(); + when(codes.selectForUpdate(eq(10L),anyString())).thenReturn(available); + when(batches.selectOwned(10L,1L)).thenReturn(batch(1L,"ACTIVE",0)); + EducationResourceProductBindingDO binding=binding("ACTIVE");when(bindings.selectByProduct(10L,200L)).thenReturn(binding); + when(entitlements.apply(any())).thenReturn(99L);when(codes.markRedeemed(10L,5L,8L,99L)).thenReturn(1);when(batches.incrementRedeemed(10L,1L)).thenReturn(1); + assertThat(service.redeem("edu-abcdef23456789",8L).getEntitlementId()).isEqualTo(99L); + ArgumentCaptor command=ArgumentCaptor.forClass(EntitlementCommand.class);verify(entitlements).apply(command.capture()); + assertThat(command.getValue().sourceSystem()).isEqualTo("ACTIVATION_CODE"); + assertThat(command.getValue().resourceId()).isEqualTo(100L); + + available.setStatus("REDEEMED");available.setRedeemedBy(8L);available.setEntitlementId(99L); + assertThat(service.redeem("edu-abcdef23456789",8L).isIdempotent()).isTrue(); + verify(entitlements,times(1)).apply(any()); + assertThatThrownBy(()->service.redeem("edu-abcdef23456789",9L)).hasMessageContaining("已被其他会员兑换"); + } + + @Test void batchTargetMustBeAvailableAndBound(){ + BatchSaveReq req=new BatchSaveReq();req.setName("Batch");req.setProductSpuId(200L);req.setDurationDays(30);req.setStatus("ACTIVE"); + when(products.isProductAvailable(200L)).thenReturn(true); + assertThatThrownBy(()->service.createBatch(req,7L)).hasMessageContaining("权益资源不存在"); + when(bindings.selectByProduct(10L,200L)).thenReturn(binding("ACTIVE")); + doAnswer(invocation->{ActivationCodeBatchDO item=invocation.getArgument(0);item.setId(1L);return 1;}) + .when(batches).insert(any(ActivationCodeBatchDO.class)); + when(batches.selectOwned(10L,1L)).thenReturn(batch(1L,"ACTIVE",0)); + service.createBatch(req,7L);verify(batches).insert(any(ActivationCodeBatchDO.class)); + } + + @Test void generatedBatchTargetAndPrefixAreImmutable(){ + ActivationCodeBatchDO current=batch(1L,"ACTIVE",4);current.setTotalCount(1); + when(batches.selectOwned(10L,1L)).thenReturn(current); + BatchSaveReq req=new BatchSaveReq();req.setName("Changed");req.setProductSpuId(201L);req.setDurationDays(30);req.setCodePrefix("EDU");req.setStatus("ACTIVE");req.setExpectedVersion(4); + assertThatThrownBy(()->service.updateBatch(1L,req,7L)).hasMessageContaining("配置无效"); + } + + private static ActivationCodeBatchDO batch(Long id,String status,int version){return ActivationCodeBatchDO.builder().id(id).name("Batch") + .productSpuId(200L).durationDays(30).codePrefix("EDU").status(status).totalCount(0).redeemedCount(0).version(version).build();} + private static EducationResourceProductBindingDO binding(String status){return EducationResourceProductBindingDO.builder().id(2L) + .resourceType("QUESTION_COLLECTION").resourceId(100L).productSpuId(200L).status(status).version(0).build();} +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/appearance/TenantAppearancePolicyTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/appearance/TenantAppearancePolicyTest.java new file mode 100644 index 00000000..03250e83 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/appearance/TenantAppearancePolicyTest.java @@ -0,0 +1,70 @@ +package cn.iocoder.yudao.module.education.service.appearance; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import org.junit.jupiter.api.Test; + +import java.util.*; + +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; + +class TenantAppearancePolicyTest { + + private final TenantAppearancePolicy policy = new TenantAppearancePolicy(); + + @Test + void recursivelyRejectsSecretsButAllowsSecretReferences() { + Map safe = Map.of("payment", List.of(Map.of("wechatSecretRef", "secret://payment/wechat"))); + assertDoesNotThrow(() -> policy.assertPublicConfigHasNoSecrets(safe)); + + ServiceException error = assertThrows(ServiceException.class, + () -> policy.assertPublicConfigHasNoSecrets(Map.of("nested", List.of(Map.of("api_token", "plain"))))); + assertEquals(TENANT_APPEARANCE_SECRET_REJECTED.getCode(), error.getCode()); + } + + @Test + void themeKeysColorsRadiiAndCssVariablesFailClosed() { + assertInvalidTheme(Map.of("unknown", "value")); + assertInvalidTheme(Map.of("primaryColor", "red")); + assertInvalidTheme(Map.of("borderRadius", 33)); + assertInvalidTheme(Map.of("customCssVars", Map.of("--foreign-color", "#ffffff"))); + assertInvalidTheme(Map.of("customCssVars", Map.of("--tiku-bg", "url(https://evil.test/x)"))); + + Map sanitized = policy.sanitizeThemeTokens(new LinkedHashMap<>(Map.of( + "primaryColor", "#AABBCC", "borderRadius", 8.9, + "customCssVars", Map.of("--tiku-focus-ring", "#123abc")))); + assertEquals("#aabbcc", sanitized.get("primaryColor")); + assertEquals(8, sanitized.get("borderRadius")); + } + + @Test + void publicAssetsOnlyAcceptAllowlistedHttpsOrLocalPaths() { + assertEquals("/assets/logo.svg", policy.sanitizePublicAssets(Map.of("logoUrl", "/assets/logo.svg")) + .get("logoUrl")); + assertEquals("http://localhost:3000/logo.svg", + policy.sanitizePublicAssets(Map.of("logoUrl", "http://localhost:3000/logo.svg")).get("logoUrl")); + assertInvalidAssets(Map.of("logoUrl", "http://example.com/logo.svg")); + assertInvalidAssets(Map.of("logoUrl", "javascript:alert(1)")); + assertInvalidAssets(Map.of("stylesheetUrl", "https://example.com/theme.css")); + } + + @Test + void mergeIsShallowAndSanitizesOnlyOverrides() { + Map merged = policy.mergeTheme( + new LinkedHashMap<>(Map.of("mode", "light", "primaryColor", "#000000")), + Map.of("primaryColor", "#FFFFFF")); + assertEquals(Map.of("mode", "light", "primaryColor", "#ffffff"), merged); + } + + private void assertInvalidTheme(Map value) { + ServiceException error = assertThrows(ServiceException.class, () -> policy.sanitizeThemeTokens(value)); + assertTrue(error.getCode().equals(TENANT_APPEARANCE_CONFIG_INVALID.getCode()) + || error.getCode().equals(TENANT_APPEARANCE_SECRET_REJECTED.getCode())); + } + + private void assertInvalidAssets(Map value) { + ServiceException error = assertThrows(ServiceException.class, () -> policy.sanitizePublicAssets(value)); + assertTrue(error.getCode().equals(TENANT_APPEARANCE_CONFIG_INVALID.getCode()) + || error.getCode().equals(TENANT_APPEARANCE_SECRET_REJECTED.getCode())); + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/appearance/TenantAppearancePostgreSqlIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/appearance/TenantAppearancePostgreSqlIntegrationTest.java new file mode 100644 index 00000000..3be84be1 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/appearance/TenantAppearancePostgreSqlIntegrationTest.java @@ -0,0 +1,87 @@ +package cn.iocoder.yudao.module.education.service.appearance; + +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.exception.ServiceException; +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.test.PostgreSqlDbIntegrationTest; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.*; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Map; + +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.TENANT_APPEARANCE_CONFLICT; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; + +@Import({TenantAppearanceServiceImpl.class, TenantAppearancePolicy.class}) +class TenantAppearancePostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest { + + @Resource TenantAppearanceService service; + @MockitoBean TenantCommonApi tenantCommonApi; + @MockitoBean AdminUserApi adminUserApi; + + @BeforeEach + void setUp() { + TenantContextHolder.setTenantId(10L); + when(tenantCommonApi.getTenant(anyLong())).thenAnswer(invocation -> tenant(invocation.getArgument(0))); + } + + @AfterEach void clear() { TenantContextHolder.clear(); } + + @Test + void draftPublishPublicProjectionAndTenantIsolationWorkEndToEnd() { + AppearanceResp initial = service.getAppearance(); + assertEquals("System-10", initial.getBrandName()); + assertEquals("classic", initial.getActiveTemplateCode()); + assertEquals(3, service.getThemeTemplates().size()); + + SettingsSaveReq settings = new SettingsSaveReq(); settings.setExpectedVersion(initial.getVersion()); + settings.setFeatureFlags(Map.of("enableVocabulary", true)); + settings.setAdminFeatureFlags(Map.of("showInternalMetrics", true)); + settings.setPublicConfig(Map.of("appUrl", "https://tenant-10.example.com")); + AppearanceResp configured = service.updateSettings(settings, 7L); + + ThemePreviewReq preview = new ThemePreviewReq(); preview.setTemplateCode("focus"); + preview.setTheme(Map.of("primaryColor", "#123ABC")); + preview.setPublicAssets(Map.of("logoUrl", "/assets/t10-logo.svg")); + preview.setExpectedVersion(configured.getVersion()); + AppearanceResp draft = service.previewTheme(preview, 7L); + assertEquals("DRAFT", draft.getThemeStatus()); + assertEquals("focus", draft.getDraftTemplateCode()); + + ThemePublishReq publish = new ThemePublishReq(); publish.setUseDraft(true); + publish.setExpectedVersion(draft.getVersion()); + AppearanceResp published = service.publishTheme(publish, 7L); + assertEquals("PUBLISHED", published.getThemeStatus()); + assertEquals("focus", published.getActiveTemplateCode()); + assertNull(published.getDraftTemplateCode()); + + PublicAppearanceResp publicAppearance = service.getPublicAppearance(); + assertEquals("#123abc", publicAppearance.getTheme().get("primaryColor")); + assertEquals("/assets/t10-logo.svg", publicAppearance.getPublicAssets().get("logoUrl")); + assertEquals(Map.of("enableVocabulary", true), publicAppearance.getFeatureFlags()); + assertFalse(publicAppearance.getPublicConfig().containsKey("showInternalMetrics")); + + TenantContextHolder.setTenantId(20L); + PublicAppearanceResp otherTenant = service.getPublicAppearance(); + assertEquals("System-20", otherTenant.getBrandName()); + assertEquals("#2563eb", otherTenant.getTheme().get("primaryColor")); + assertTrue(otherTenant.getFeatureFlags().isEmpty()); + + TenantContextHolder.setTenantId(10L); + SettingsSaveReq stale = new SettingsSaveReq(); stale.setExpectedVersion(initial.getVersion()); + ServiceException error = assertThrows(ServiceException.class, () -> service.updateSettings(stale, 7L)); + assertEquals(TENANT_APPEARANCE_CONFLICT.getCode(), error.getCode()); + } + + private TenantRespDTO tenant(Long id) { + TenantRespDTO tenant = new TenantRespDTO(); tenant.setId(id); tenant.setName("System-" + id); tenant.setStatus(0); + return tenant; + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/appearance/TenantAppearanceServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/appearance/TenantAppearanceServiceImplTest.java new file mode 100644 index 00000000..465d0850 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/appearance/TenantAppearanceServiceImplTest.java @@ -0,0 +1,110 @@ +package cn.iocoder.yudao.module.education.service.appearance; + +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.exception.ServiceException; +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 org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; + +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class TenantAppearanceServiceImplTest { + + @Mock TenantAppearanceMapper appearanceMapper; + @Mock TenantThemeTemplateMapper templateMapper; + @Mock TenantCommonApi tenantCommonApi; + @Mock AdminUserApi adminUserApi; + TenantAppearanceServiceImpl service; + + @BeforeEach + void setUp() { + TenantContextHolder.setTenantId(10L); + service = new TenantAppearanceServiceImpl(appearanceMapper, templateMapper, + new TenantAppearancePolicy(), tenantCommonApi, adminUserApi); + TenantRespDTO tenant = new TenantRespDTO(); tenant.setId(10L); tenant.setName("System Tenant"); + lenient().when(tenantCommonApi.getTenant(10L)).thenReturn(tenant); + } + + @AfterEach void clear() { TenantContextHolder.clear(); } + + @Test + void lazyCreationUsesSystemTenantNameAndProvidesFallback() { + TenantAppearanceDO row = row(0); row.setBrandName(" "); + when(appearanceMapper.selectOwned(10L)).thenReturn(row); + + AppearanceResp response = service.getAppearance(); + + verify(appearanceMapper).insertDefaultIgnore(10L, "System Tenant", "0"); + assertEquals("System Tenant", response.getBrandName()); + assertEquals("System Tenant", response.getSystemTenantName()); + } + + @Test + void optimisticConflictIsReportedAfterCurrentRowWasLoaded() { + when(appearanceMapper.selectOwned(10L)).thenReturn(row(2)); + when(appearanceMapper.updateSettings(eq(10L), anyString(), anyString(), anyString(), eq(2), eq("7"))) + .thenReturn(0); + SettingsSaveReq request = new SettingsSaveReq(); request.setExpectedVersion(2); + + ServiceException error = assertThrows(ServiceException.class, () -> service.updateSettings(request, 7L)); + + assertEquals(TENANT_APPEARANCE_CONFLICT.getCode(), error.getCode()); + } + + @Test + void previewShallowMergesTemplateAndPersistsSanitizedDraft() { + when(appearanceMapper.selectOwned(10L)).thenReturn(row(0), row(1)); + TenantThemeTemplateDO template = new TenantThemeTemplateDO(); + template.setCode("classic"); + template.setTheme(Map.of("mode", "light", "primaryColor", "#000000")); + template.setPublicAssets(Map.of("iconSet", "classic")); + when(templateMapper.selectActive("classic")).thenReturn(template); + when(appearanceMapper.updateDraft(eq(10L), eq("classic"), contains("#ffffff"), contains("classic"), + eq(7L), eq(0), eq("7"))).thenReturn(1); + ThemePreviewReq request = new ThemePreviewReq(); request.setTemplateCode("classic"); + request.setTheme(Map.of("primaryColor", "#FFFFFF")); request.setExpectedVersion(0); + + AppearanceResp response = service.previewTheme(request, 7L); + + assertEquals(1, response.getVersion()); + } + + @Test + void publicProjectionNeverReturnsDraftOrAdminFlags() { + TenantAppearanceDO row = row(3); row.setBrandName(null); + row.setFeatureFlags(Map.of("vocabulary", true)); + row.setAdminFeatureFlags(Map.of("dangerousAdminFlag", true)); + row.setPublicConfig(Map.of("appUrl", "https://example.com")); + row.setDraftTheme(Map.of("primaryColor", "#ff0000")); + row.setActiveTheme(Map.of("primaryColor", "#00ff00")); + when(appearanceMapper.selectOwned(10L)).thenReturn(row); + + PublicAppearanceResp response = service.getPublicAppearance(); + + assertEquals("System Tenant", response.getBrandName()); + assertEquals(Map.of("primaryColor", "#00ff00"), response.getTheme()); + assertEquals(Map.of("vocabulary", true), response.getFeatureFlags()); + assertFalse(response.getPublicConfig().containsKey("dangerousAdminFlag")); + } + + private TenantAppearanceDO row(int version) { + TenantAppearanceDO row = new TenantAppearanceDO(); row.setId(1L); row.setTenantId(10L); + row.setBrandName("Brand"); row.setVersion(version); row.setThemeStatus("PUBLISHED"); + return row; + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/asset/EducationAssetAdmissionServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/asset/EducationAssetAdmissionServiceImplTest.java index 4bb0f0c1..ed82522a 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/asset/EducationAssetAdmissionServiceImplTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/asset/EducationAssetAdmissionServiceImplTest.java @@ -64,19 +64,16 @@ class EducationAssetAdmissionServiceImplTest { } @Test - void scannerUnavailableIsPersistedAndNeverPromotedToClean() { + void rejectsWhenScannerIsUnavailable() { byte[] bytes = "id\n1".getBytes(StandardCharsets.UTF_8); FileDescriptor descriptor = new FileDescriptor("opaque-ref", "questions.csv", "text/csv", bytes.length); when(fileApi.createFile(any(FileContent.class))).thenReturn(descriptor); when(fileApi.scan(descriptor)).thenReturn(FileScanStatus.UNAVAILABLE); - EducationImportAssetRespVO result = service.admit( - new MockMultipartFile("file", "questions.csv", "text/csv", bytes), 7L); - - assertEquals("UNAVAILABLE", result.getScanStatus()); - var captor = org.mockito.ArgumentCaptor.forClass(EducationImportAssetDO.class); - verify(mapper).insert(captor.capture()); - assertEquals("UNAVAILABLE", captor.getValue().getScanStatus()); + assertServiceException(() -> service.admit( + new MockMultipartFile("file", "questions.csv", "text/csv", bytes), 7L), + QUESTION_IMPORT_SCAN_NOT_CLEAN); + verify(mapper, never()).insert(any(EducationImportAssetDO.class)); } @Test diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/badge/BadgeAdminServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/badge/BadgeAdminServiceImplTest.java new file mode 100644 index 00000000..f86041cc --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/badge/BadgeAdminServiceImplTest.java @@ -0,0 +1,81 @@ +package cn.iocoder.yudao.module.education.service.badge; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.module.education.controller.admin.badge.vo.BadgeAdminVOs.DefinitionSaveReq; +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.system.api.user.AdminUserApi; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; + +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class BadgeAdminServiceImplTest { + + @Mock BadgeDefinitionMapper definitionMapper; + @Mock LearningAwardMapper awardMapper; + @Mock BadgeGrantService grantService; + @Mock MemberUserApi memberUserApi; + @Mock AdminUserApi adminUserApi; + BadgeAdminServiceImpl service; + + @BeforeEach + void setUp() { + TenantContextHolder.setTenantId(10L); + service = new BadgeAdminServiceImpl(definitionMapper, awardMapper, grantService, memberUserApi, adminUserApi); + } + + @AfterEach void clear() { TenantContextHolder.clear(); } + + @Test + void invalidTriggerMetricCombinationFailsBeforeInsert() { + DefinitionSaveReq request = automaticDefinition(); + request.setTriggerType("VOCABULARY_REVIEW"); + request.setMetric("PRACTICE_COUNT"); + + ServiceException error = assertThrows(ServiceException.class, + () -> service.createDefinition(request, 7L)); + + assertEquals(BADGE_RULE_INVALID.getCode(), error.getCode()); + verify(definitionMapper, never()).insert(any(BadgeDefinitionDO.class)); + } + + @Test + void updateCasConflictIsReportedEvenAfterVersionPrecheckPasses() { + BadgeDefinitionDO current = definition(3); + when(definitionMapper.selectOwned(10L, 5L)).thenReturn(current); + when(definitionMapper.updateCas(eq(10L), any(), anyString(), eq(3))).thenReturn(0); + DefinitionSaveReq request = automaticDefinition(); request.setExpectedVersion(3); + + ServiceException error = assertThrows(ServiceException.class, + () -> service.updateDefinition(5L, request, 7L)); + + assertEquals(BADGE_DEFINITION_CONFLICT.getCode(), error.getCode()); + } + + private DefinitionSaveReq automaticDefinition() { + DefinitionSaveReq request = new DefinitionSaveReq(); + request.setCode("FIRST_PRACTICE"); request.setName("初次练习"); + request.setTriggerType("PRACTICE_SUBMIT"); request.setMetric("PRACTICE_COUNT"); + request.setOperator("GTE"); request.setThresholdValue(BigDecimal.ONE); + return request; + } + + private BadgeDefinitionDO definition(int version) { + BadgeDefinitionDO item = new BadgeDefinitionDO(); + item.setId(5L); item.setTenantId(10L); item.setCode("FIRST_PRACTICE"); + item.setName("初次练习"); item.setStatus("ACTIVE"); item.setVersion(version); + return item; + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/badge/BadgeGrantServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/badge/BadgeGrantServiceImplTest.java new file mode 100644 index 00000000..d7f131d7 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/badge/BadgeGrantServiceImplTest.java @@ -0,0 +1,75 @@ +package cn.iocoder.yudao.module.education.service.badge; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +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.*; +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; + +import java.util.Map; + +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.BADGE_DEFINITION_DISABLED; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class BadgeGrantServiceImplTest { + + @Mock BadgeDefinitionMapper definitionMapper; + @Mock LearningAwardMapper awardMapper; + @Mock PracticeReportMapper practiceReportMapper; + @Mock VocabularyProgressMapper vocabularyMapper; + @Mock StudentFeedbackMapper feedbackMapper; + @Mock MemberUserApi memberUserApi; + @Mock NotifyMessageSendApi notifyApi; + @Mock ObjectProvider notifyProvider; + BadgeGrantServiceImpl service; + + @BeforeEach + void setUp() { + when(notifyProvider.getIfAvailable()).thenReturn(notifyApi); + service = new BadgeGrantServiceImpl(definitionMapper, awardMapper, practiceReportMapper, vocabularyMapper, + feedbackMapper, memberUserApi, notifyProvider); + } + + @Test + void disabledDefinitionCannotBeGrantedManually() { + when(definitionMapper.selectOwned(10L, 5L)).thenReturn(definition("DISABLED")); + + ServiceException error = assertThrows(ServiceException.class, + () -> service.grantManual(5L, 8L, null, null, 7L, 10L)); + + assertEquals(BADGE_DEFINITION_DISABLED.getCode(), error.getCode()); + verifyNoInteractions(memberUserApi, awardMapper, notifyApi); + } + + @Test + void notificationFailureKeepsGrantAndRecordsFailedStatus() { + BadgeDefinitionDO definition = definition("ACTIVE"); + LearningAwardDO saved = new LearningAwardDO(); saved.setId(20L); saved.setTenantId(10L); + saved.setUserId(8L); saved.setBadgeDefinitionId(5L); saved.setAwardSource("MANUAL"); + when(definitionMapper.selectOwned(10L, 5L)).thenReturn(definition); + when(awardMapper.insertBadgeGrantIgnore(any(), anyString())).thenReturn(1); + when(awardMapper.selectBadgeGrant(10L, 8L, 5L)).thenReturn(saved); + when(notifyApi.sendSingleMessageToMember(any())).thenThrow(new IllegalStateException("notify down")); + + LearningAwardDO result = service.grantManual(5L, 8L, "人工确认", Map.of("sourceId", 9), 7L, 10L); + + assertSame(saved, result); + verify(awardMapper).updateBadgeNotify(10L, 20L, null, "FAILED", "notify down"); + } + + private BadgeDefinitionDO definition(String status) { + BadgeDefinitionDO item = new BadgeDefinitionDO(); item.setId(5L); item.setTenantId(10L); + item.setCode("HELPFUL"); item.setName("乐于助人"); item.setStatus(status); return item; + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/badge/BadgePostgreSqlIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/badge/BadgePostgreSqlIntegrationTest.java new file mode 100644 index 00000000..2ad3ec25 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/badge/BadgePostgreSqlIntegrationTest.java @@ -0,0 +1,103 @@ +package cn.iocoder.yudao.module.education.service.badge; + +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.mysql.LearningAwardMapper; +import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest; +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.notify.NotifyMessageSendApi; +import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.*; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import javax.sql.DataSource; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@Import({BadgeGrantServiceImpl.class, BadgeAdminServiceImpl.class}) +class BadgePostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest { + @Resource BadgeGrantService grantService; + @Resource BadgeAdminService adminService; + @Resource LearningAwardMapper awardMapper; + @Resource DataSource dataSource; + @MockitoBean MemberUserApi memberUserApi; + @MockitoBean AdminUserApi adminUserApi; + @MockitoBean NotifyMessageSendApi notifyApi; + JdbcTemplate jdbc; + + @BeforeEach void setUp() { + TenantContextHolder.setTenantId(10L); jdbc = new JdbcTemplate(dataSource); + MemberUserRespDTO member = new MemberUserRespDTO(); member.setId(8L); member.setNickname("Ada"); member.setStatus(0); + lenient().when(memberUserApi.getUserMap(anyCollection())).thenReturn(Map.of(8L, member)); + AdminUserRespDTO admin = new AdminUserRespDTO(); admin.setId(7L); admin.setNickname("老师"); + lenient().when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of(7L, admin)); + lenient().when(notifyApi.sendSingleMessageToMember(any(NotifySendSingleToUserReqDTO.class))).thenReturn(99L); + } + @AfterEach void clear() { TenantContextHolder.clear(); } + + @Test + void practiceRuleAutomaticallyGrantsExactlyOnceAndUsesSystemNotify() { + DefinitionResp definition = adminService.createDefinition(autoDefinition(), 7L); + jdbc.update(""" + INSERT INTO education_practice_session + (id,tenant_id,user_id,client_session_id,status,question_count,version) + VALUES (100,10,8,'00000000-0000-0000-0000-000000000100','SUBMITTED',10,2) + """); + jdbc.update(""" + INSERT INTO education_practice_report + (id,tenant_id,user_id,session_id,question_count,answered_count,unanswered_count, + correct_count,incorrect_count,score,status) + VALUES (200,10,8,100,10,10,0,9,1,90,'COMPLETED') + """); + + grantService.evaluatePractice(10L, 8L, 200L); + grantService.evaluatePractice(10L, 8L, 200L); + + List grants = awardMapper.selectUserBadgeGrants(10L, 8L); + assertEquals(1, grants.size()); + LearningAwardDO grant = grants.getFirst(); + assertEquals(definition.getId(), grant.getBadgeDefinitionId()); + assertEquals("AUTO", grant.getAwardSource()); + assertEquals("SENT", grant.getNotifyStatus()); + assertEquals(new BigDecimal("1"), new BigDecimal(grant.getGrantEvidence().get("matchedValue").toString())); + verify(notifyApi, times(1)).sendSingleMessageToMember(any(NotifySendSingleToUserReqDTO.class)); + } + + @Test + void manualGrantValidatesMemberAndReturnsSameGrantOnReplay() { + DefinitionSaveReq request = new DefinitionSaveReq(); request.setCode("HELPFUL"); request.setName("乐于助人"); + request.setTriggerType("MANUAL"); + DefinitionResp definition = adminService.createDefinition(request, 7L); + ManualGrantReq grant = new ManualGrantReq(); grant.setBadgeDefinitionId(definition.getId()); + grant.setUserId(8L); grant.setNote("课堂互助"); + + GrantResp first = adminService.grant(grant, 7L); + GrantResp replay = adminService.grant(grant, 7L); + + assertEquals(first.getId(), replay.getId()); + assertEquals("Ada", replay.getUserNickname()); + assertEquals("老师", replay.getGrantedByNickname()); + assertEquals("MANUAL", replay.getAwardSource()); + verify(memberUserApi, times(2)).validateUser(8L); + verify(notifyApi, times(1)).sendSingleMessageToMember(any(NotifySendSingleToUserReqDTO.class)); + } + + private DefinitionSaveReq autoDefinition() { + DefinitionSaveReq request = new DefinitionSaveReq(); request.setCode("FIRST_PRACTICE"); + request.setName("初次练习"); request.setCategory("PRACTICE"); request.setTriggerType("PRACTICE_SUBMIT"); + request.setMetric("PRACTICE_COUNT"); request.setOperator("GTE"); request.setThresholdValue(BigDecimal.ONE); + request.setConditionExtra(Map.of("legacyUnlockType", "practice_count")); return request; + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/engagement/LearningOperationsAdminServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/engagement/LearningOperationsAdminServiceImplTest.java new file mode 100644 index 00000000..614ceb1a --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/engagement/LearningOperationsAdminServiceImplTest.java @@ -0,0 +1,138 @@ +package cn.iocoder.yudao.module.education.service.engagement; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +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.StudentFeedbackHandleReqVO; +import cn.iocoder.yudao.module.education.controller.admin.learningoperations.vo.StudentFeedbackPageReqVO; +import cn.iocoder.yudao.module.education.controller.admin.learningoperations.vo.StudentFeedbackRewardReqVO; +import cn.iocoder.yudao.module.education.dal.dataobject.StudentFeedbackDO; +import cn.iocoder.yudao.module.education.dal.mysql.LearningAwardMapper; +import cn.iocoder.yudao.module.education.dal.mysql.StudentFeedbackEventMapper; +import cn.iocoder.yudao.module.education.dal.mysql.StudentFeedbackMapper; +import cn.iocoder.yudao.module.education.integration.member.MemberPointAwardPort; +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 org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.STUDENT_FEEDBACK_CONFLICT; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LearningOperationsAdminServiceImplTest { + + @Mock StudentFeedbackMapper feedbackMapper; + @Mock StudentFeedbackEventMapper eventMapper; + @Mock LearningAwardMapper awardMapper; + @Mock MemberUserApi memberUserApi; + @Mock AdminUserApi adminUserApi; + @Mock MemberPointAwardPort pointPort; + LearningOperationsAdminServiceImpl service; + + @BeforeEach + void setUp() { + service = new LearningOperationsAdminServiceImpl(feedbackMapper, eventMapper, awardMapper, memberUserApi, + adminUserApi, pointPort); + TenantContextHolder.setTenantId(10L); + lenient().when(memberUserApi.getUserMap(anyCollection())).thenReturn(Map.of()); + lenient().when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of()); + } + + @AfterEach + void clearTenant() { + TenantContextHolder.clear(); + } + + @Test + void pageUsesRequiredTenantAndEnrichesThroughMemberPublicApi() { + StudentFeedbackDO feedback = feedback("OPEN", "NOT_REQUIRED", 2); + MemberUserRespDTO member = new MemberUserRespDTO(); + member.setId(8L); member.setNickname("Ada"); + when(feedbackMapper.selectAdminPage(any(), eq(10L), eq(8L), eq("BUG"), eq("OPEN"), eq("HIGH"))) + .thenReturn(new PageResult<>(List.of(feedback), 1L)); + when(memberUserApi.getUserMap(anyCollection())).thenReturn(Map.of(8L, member)); + StudentFeedbackPageReqVO request = new StudentFeedbackPageReqVO(); + request.setUserId(8L); request.setCategory("bug"); request.setStatus("open"); request.setPriority("high"); + + var result = service.getFeedbackPage(request); + + assertEquals(1L, result.getTotal()); + assertEquals("Ada", result.getList().getFirst().getUserNickname()); + verify(feedbackMapper).selectAdminPage(request, 10L, 8L, "BUG", "OPEN", "HIGH"); + } + + @Test + void handleUsesCasAndAppendsServerActorAudit() { + StudentFeedbackDO current = feedback("OPEN", "NOT_REQUIRED", 2); + StudentFeedbackDO updated = feedback("RESOLVED", "NOT_REQUIRED", 3); + updated.setHandledBy(7L); + when(feedbackMapper.selectOwnedById(10L, 5L)).thenReturn(current, updated); + when(feedbackMapper.handleCas(10L, 5L, 2, "RESOLVED", "HIGH", "fixed", 7L)).thenReturn(1); + StudentFeedbackHandleReqVO request = new StudentFeedbackHandleReqVO(); + request.setExpectedVersion(2); request.setStatus("resolved"); request.setPriority("high"); + request.setResolution("fixed"); request.setNote("verified"); + + service.handleFeedback(5L, request, 7L); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(cn.iocoder.yudao.module.education.dal.dataobject.StudentFeedbackEventDO.class); + verify(eventMapper).insert(captor.capture()); + assertEquals(10L, captor.getValue().getTenantId()); + assertEquals(7L, captor.getValue().getActorId()); + assertEquals("OPEN", captor.getValue().getFromStatus()); + assertEquals("RESOLVED", captor.getValue().getToStatus()); + } + + @Test + void staleHandleFailsWithoutMutation() { + when(feedbackMapper.selectOwnedById(10L, 5L)).thenReturn(feedback("OPEN", "NOT_REQUIRED", 3)); + StudentFeedbackHandleReqVO request = new StudentFeedbackHandleReqVO(); + request.setExpectedVersion(2); request.setStatus("accepted"); request.setPriority("normal"); + + ServiceException error = assertThrows(ServiceException.class, + () -> service.handleFeedback(5L, request, 7L)); + + assertEquals(STUDENT_FEEDBACK_CONFLICT.getCode(), error.getCode()); + verify(feedbackMapper, never()).handleCas(anyLong(), anyLong(), anyInt(), anyString(), anyString(), any(), anyLong()); + verifyNoInteractions(eventMapper); + } + + @Test + void resolvedFeedbackSchedulesThenDeliversThroughMemberPort() { + StudentFeedbackDO resolved = feedback("RESOLVED", "NOT_REQUIRED", 4); + StudentFeedbackDO pending = feedback("RESOLVED", "PENDING", 5); + pending.setRewardPoints(20); + when(feedbackMapper.selectOwnedById(10L, 5L)).thenReturn(resolved, pending, pending, pending, pending); + when(feedbackMapper.scheduleRewardCas(10L, 5L, 4, 20)).thenReturn(1); + when(feedbackMapper.claimReward(10L, 5L)).thenReturn(1); + when(feedbackMapper.finishReward(10L, 5L, "AWARDED", null)).thenReturn(1); + StudentFeedbackRewardReqVO request = new StudentFeedbackRewardReqVO(); + request.setExpectedVersion(4); request.setPoints(20); + + service.scheduleReward(5L, request); + service.deliverReward(5L); + + verify(pointPort).addFeedbackReward(8L, 20, 5L); + verify(feedbackMapper).finishReward(10L, 5L, "AWARDED", null); + } + + private StudentFeedbackDO feedback(String status, String rewardStatus, int version) { + StudentFeedbackDO feedback = new StudentFeedbackDO(); + feedback.setId(5L); feedback.setTenantId(10L); feedback.setUserId(8L); feedback.setCategory("BUG"); + feedback.setContent("broken"); feedback.setStatus(status); feedback.setPriority("HIGH"); + feedback.setRewardPoints(0); feedback.setRewardStatus(rewardStatus); feedback.setVersion(version); + return feedback; + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/importjob/InfraFileImportObjectScanGatewayTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/importjob/InfraFileImportObjectScanGatewayTest.java new file mode 100644 index 00000000..81727400 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/importjob/InfraFileImportObjectScanGatewayTest.java @@ -0,0 +1,23 @@ +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.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +class InfraFileImportObjectScanGatewayTest { + @Test + void mapsDescriptorAndStatus() { + FileApi api = mock(FileApi.class); + FileDescriptor descriptor = new FileDescriptor("ref", "questions.csv", "text/csv", 3, + "039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81"); + when(api.scan(descriptor)).thenReturn(FileScanStatus.ERROR); + InfraFileImportObjectScanGateway gateway = new InfraFileImportObjectScanGateway(api); + assertEquals(ImportObjectScanGateway.ScanResult.ERROR, gateway.scan(descriptor.reference(), descriptor.name(), + descriptor.contentType(), descriptor.size(), descriptor.checksumSha256())); + verify(api).scan(descriptor); + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobServiceImplTest.java index a59d4576..53a14957 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobServiceImplTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobServiceImplTest.java @@ -5,6 +5,8 @@ import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; 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; @@ -17,6 +19,12 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.SimpleTransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; + import java.util.List; import java.util.Optional; @@ -33,6 +41,9 @@ class QuestionImportJobServiceImplTest { @Mock private ImportObjectScanGateway scanGateway; @Mock private QuestionImportParser parser; @Mock private TenantQuestionLifecycleService lifecycleService; + @Mock private QuestionMapper questionMapper; + @Mock private QuestionVersionMapper versionMapper; + private TransactionTemplate transactionTemplate; private EducationProperties properties; private QuestionImportJobServiceImpl service; @@ -41,8 +52,9 @@ class QuestionImportJobServiceImplTest { void setUp() { properties = new EducationProperties(); properties.setCatalogMode(CatalogProviderMode.JAVA_READ); + transactionTemplate = new TransactionTemplate(new RecordingTransactionManager()); service = new QuestionImportJobServiceImpl(properties, assetMapper, jobMapper, Optional.of(scanGateway), - Optional.of(parser), lifecycleService); + Optional.of(parser), lifecycleService, questionMapper, versionMapper, transactionTemplate); TenantContextHolder.setTenantId(10L); } @@ -74,12 +86,12 @@ class QuestionImportJobServiceImplTest { @Test void cleanScanWithoutParserProducesMetadataOnlyPreview() { service = new QuestionImportJobServiceImpl(properties, assetMapper, jobMapper, Optional.of(scanGateway), - Optional.empty(), lifecycleService); + Optional.empty(), lifecycleService, questionMapper, versionMapper, transactionTemplate); ContentImportJobDO claimed = job(101L, "PREVIEW_PENDING"); when(jobMapper.claimById(eq(101L), eq("PREVIEW_PENDING"), anyString(), anyString(), anyLong())) .thenReturn(claimed); when(assetMapper.selectTenantAsset(10L, 88L)).thenReturn(asset(88L)); - when(scanGateway.scan("object/10/questions.csv")).thenReturn(ImportObjectScanGateway.ScanResult.CLEAN); + when(scanGateway.scan("object/10/questions.csv", "questions.csv", "text/csv", 128L, "abc")).thenReturn(ImportObjectScanGateway.ScanResult.CLEAN); when(jobMapper.finishClaim(eq(10L), eq(101L), eq("PREVIEW_PENDING"), eq("PREVIEW_READY"), anyString(), eq("CLEAN"), eq("UNAVAILABLE"), contains("METADATA_ONLY"), isNull(), isNull(), isNull(), isNull(), isNull(), isNull())).thenReturn(1); @@ -89,10 +101,28 @@ class QuestionImportJobServiceImplTest { verifyNoInteractions(parser, lifecycleService); } + @Test + void infectedAndErrorScansFailClosedBeforeParser() { + for (ImportObjectScanGateway.ScanResult result : List.of(ImportObjectScanGateway.ScanResult.INFECTED, + ImportObjectScanGateway.ScanResult.ERROR, ImportObjectScanGateway.ScanResult.UNAVAILABLE)) { + reset(jobMapper, assetMapper, scanGateway, parser, lifecycleService); + when(jobMapper.claimById(eq(101L), eq("PREVIEW_PENDING"), anyString(), anyString(), anyLong())) + .thenReturn(job(101L, "PREVIEW_PENDING")); + when(assetMapper.selectTenantAsset(10L, 88L)).thenReturn(asset(88L)); + when(scanGateway.scan("object/10/questions.csv", "questions.csv", "text/csv", 128L, "abc")) + .thenReturn(result); + assertThrows(ServiceException.class, () -> service.preview(101L)); + verify(jobMapper).finishClaim(eq(10L), eq(101L), eq("PREVIEW_PENDING"), eq("FAILED"), anyString(), + isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), + eq("SCAN_" + result.name()), eq("SCAN_" + result.name())); + verifyNoInteractions(parser, lifecycleService); + } + } + @Test void unavailableScannerFailsClosedAndFencesTerminalWrite() { service = new QuestionImportJobServiceImpl(properties, assetMapper, jobMapper, Optional.empty(), - Optional.of(parser), lifecycleService); + Optional.of(parser), lifecycleService, questionMapper, versionMapper, transactionTemplate); when(jobMapper.claimById(eq(101L), eq("PREVIEW_PENDING"), anyString(), anyString(), anyLong())) .thenReturn(job(101L, "PREVIEW_PENDING")); when(assetMapper.selectTenantAsset(10L, 88L)).thenReturn(asset(88L)); @@ -112,14 +142,18 @@ class QuestionImportJobServiceImplTest { ContentImportJobDO claimed = job(101L, "EXECUTE_PENDING"); claimed.setTenantId(42L); claimed.setScanStatus("CLEAN"); claimed.setParserStatus("PARSED"); claimed.setParsedPayload("stored-preview"); + claimed.setPreviewQuestionCount(2); when(jobMapper.claimById(eq(101L), eq("EXECUTE_PENDING"), anyString(), anyString(), anyLong())) .thenReturn(claimed); when(parser.restore("stored-preview")).thenReturn(List.of(question("One"), question("Two"))); + java.util.concurrent.atomic.AtomicLong ids = new java.util.concurrent.atomic.AtomicLong(100L); when(lifecycleService.createDraft(any())).thenAnswer(invocation -> { - assertEquals(42L, TenantContextHolder.getRequiredTenantId()); return 100L; + assertEquals(42L, TenantContextHolder.getRequiredTenantId()); return ids.incrementAndGet(); }); + when(questionMapper.countImportedDrafts(eq(42L), anyList())).thenReturn(2L); + when(versionMapper.countFirstVersions(eq(42L), anyList())).thenReturn(2L); when(jobMapper.finishClaim(eq(42L), eq(101L), eq("EXECUTE_PENDING"), eq("COMPLETED"), - anyString(), isNull(), isNull(), isNull(), eq("stored-preview"), isNull(), anyString(), eq(2), + anyString(), isNull(), isNull(), isNull(), eq("stored-preview"), eq(2), anyString(), eq(2), isNull(), isNull())).thenReturn(1); service.execute(101L); @@ -128,10 +162,38 @@ class QuestionImportJobServiceImplTest { assertNull(TenantContextHolder.getTenantId()); } + @Test + void executeLeaseLossRollsBackWholeTransaction() { + RecordingTransactionManager manager = new RecordingTransactionManager(); + service = new QuestionImportJobServiceImpl(properties, assetMapper, jobMapper, Optional.of(scanGateway), + Optional.of(parser), lifecycleService, questionMapper, versionMapper, new TransactionTemplate(manager)); + ContentImportJobDO claimed = job(101L, "EXECUTE_PENDING"); + claimed.setScanStatus("CLEAN"); claimed.setParsedPayload("stored-preview"); claimed.setPreviewQuestionCount(1); + when(jobMapper.claimById(eq(101L), eq("EXECUTE_PENDING"), anyString(), anyString(), eq(600L))) + .thenReturn(claimed); + when(parser.restore("stored-preview")).thenReturn(List.of(question("One"))); + when(lifecycleService.createDraft(any())).thenReturn(101L); + when(questionMapper.countImportedDrafts(10L, List.of(101L))).thenReturn(1L); + when(versionMapper.countFirstVersions(10L, List.of(101L))).thenReturn(1L); + + assertThrows(ServiceException.class, () -> service.execute(101L)); + + assertEquals(0, manager.commits); + assertEquals(1, manager.rollbacks); + } + + private static final class RecordingTransactionManager implements PlatformTransactionManager { + int commits; + int rollbacks; + public TransactionStatus getTransaction(TransactionDefinition definition) { return new SimpleTransactionStatus(); } + public void commit(TransactionStatus status) { commits++; } + public void rollback(TransactionStatus status) { rollbacks++; } + } + private ContentImportAssetDO asset(Long id) { ContentImportAssetDO asset = new ContentImportAssetDO(); asset.setId(id); asset.setTenantId(10L); asset.setObjectKey("object/10/questions.csv"); asset.setFileName("questions.csv"); - asset.setMimeType("text/csv"); asset.setFileSizeBytes(128L); return asset; + asset.setMimeType("text/csv"); asset.setFileSizeBytes(128L); asset.setChecksumSha256("abc"); return asset; } private ContentImportJobDO job(Long id, String status) { diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/importjob/StandardQuestionImportParserTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/importjob/StandardQuestionImportParserTest.java new file mode 100644 index 00000000..9bd489ed --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/importjob/StandardQuestionImportParserTest.java @@ -0,0 +1,85 @@ +package cn.iocoder.yudao.module.education.service.importjob; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.module.infra.api.file.FileApi; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class StandardQuestionImportParserTest { + + private final FileApi fileApi = mock(FileApi.class); + private final StandardQuestionImportParser parser = new StandardQuestionImportParser(fileApi); + + @Test + void parsesUtf8CsvAndRestoresCanonicalSnapshot() { + byte[] csv = ("题干,题型,难度,选项A,选项B,答案,解析\r\n" + + "2+2=?,单选,易,4,5,A,基础题\r\n" + + "选择质数,多选,中,2,4,A,多选解析\r\n").getBytes(StandardCharsets.UTF_8); + when(fileApi.presignGetUrl("object/questions.csv", 60)).thenReturn("https://files/questions.csv"); + try (var http = mockStatic(cn.hutool.http.HttpUtil.class)) { + http.when(() -> cn.hutool.http.HttpUtil.downloadBytes("https://files/questions.csv")).thenReturn(csv); + QuestionImportParser.ParsedImport parsed = parser.parse("object/questions.csv"); + assertFalse(parsed.executable()); + assertEquals(2, parsed.questionCount()); + assertEquals(1, parsed.questions().size()); + assertTrue(parsed.previewPayload().contains("OPTIONS_ANSWER_MISMATCH")); + assertThrows(ServiceException.class, () -> parser.restore(parsed.payload())); + } + } + + @Test + void supportsRfc4180QuotesAndAllValidRestore() { + byte[] csv = ("stem,type,optionA,optionB,answer,explanation\r\n" + + "\"What, exactly?\",choice,Yes,No,A,\"Line 1\r\nLine 2\"\r\n") + .getBytes(StandardCharsets.UTF_8); + when(fileApi.presignGetUrl("q.csv", 60)).thenReturn("https://files/q.csv"); + try (var http = mockStatic(cn.hutool.http.HttpUtil.class)) { + http.when(() -> cn.hutool.http.HttpUtil.downloadBytes("https://files/q.csv")).thenReturn(csv); + QuestionImportParser.ParsedImport parsed = parser.parse("q.csv"); + assertTrue(parsed.executable()); + assertEquals("What, exactly?", parser.restore(parsed.payload()).getFirst().stem()); + } + } + + @Test + void rejectsDangerousAndUnknownHeaders() { + assertInvalid("=cmd,type,answer\nX,text,A\n", "danger.csv"); + assertInvalid("stem,type,answer,surprise\nX,text,A,value\n", "unknown.csv"); + } + + @Test + void reportsUnsupportedKnownFieldWhenPopulated() { + QuestionImportParser.ParsedImport parsed = parseCsv("stem,type,answer,tags\nExplain,text,answer,math\n"); + assertFalse(parsed.executable()); + assertTrue(parsed.previewPayload().contains("FIELD_NOT_SUPPORTED")); + } + + @Test + void restoreRejectsTamperedSchema() { + assertThrows(ServiceException.class, () -> parser.restore("{\"schemaVersion\":2,\"rows\":[]}")); + assertThrows(ServiceException.class, () -> parser.restore("{}")); + } + + private QuestionImportParser.ParsedImport parseCsv(String content) { + when(fileApi.presignGetUrl("q.csv", 60)).thenReturn("https://files/q.csv"); + try (var http = mockStatic(cn.hutool.http.HttpUtil.class)) { + http.when(() -> cn.hutool.http.HttpUtil.downloadBytes("https://files/q.csv")) + .thenReturn(content.getBytes(StandardCharsets.UTF_8)); + return parser.parse("q.csv"); + } + } + + private void assertInvalid(String content, String key) { + when(fileApi.presignGetUrl(key, 60)).thenReturn("https://files/" + key); + try (var http = mockStatic(cn.hutool.http.HttpUtil.class)) { + http.when(() -> cn.hutool.http.HttpUtil.downloadBytes("https://files/" + key)) + .thenReturn(content.getBytes(StandardCharsets.UTF_8)); + assertThrows(ServiceException.class, () -> parser.parse(key)); + } + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleServiceImplTest.java index 04206e15..f94762c3 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleServiceImplTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleServiceImplTest.java @@ -106,6 +106,36 @@ class TenantQuestionLifecycleServiceImplTest { verifyNoInteractions(auditMapper); } + @Test + void shouldReviseDraftWithContentVersionCasAndAppendImmutableVersion() { + properties.setCatalogMode(CatalogProviderMode.JAVA_READ); + QuestionDO draft = draft(); + when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft); + when(questionMapper.updateDraftContentCas(eq(10L), eq(101L), any(QuestionDO.class), eq(1))).thenReturn(1); + + int version = service.reviseDraft(101L, command(), 1); + + assertEquals(2, version); + ArgumentCaptor captor = + ArgumentCaptor.forClass(cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionVersionDO.class); + verify(versionMapper).insert(captor.capture()); + assertEquals(2, captor.getValue().getVersionNumber()); + assertEquals("2 + 2 = ?", captor.getValue().getStem()); + } + + @Test + void shouldRejectStaleOrNonDraftRevisionBeforeCas() { + properties.setCatalogMode(CatalogProviderMode.JAVA_READ); + QuestionDO draft = draft(); draft.setContentVersion(2); + when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft); + + ServiceException ex = assertThrows(ServiceException.class, () -> service.reviseDraft(101L, command(), 1)); + + assertEquals(QUESTION_LIFECYCLE_CONFLICT.getCode(), ex.getCode()); + verify(questionMapper, never()).updateDraftContentCas(any(), any(), any(), anyInt()); + verifyNoInteractions(versionMapper); + } + @Test void shouldPublishWithCasAndAppendAudit() { properties.setCatalogMode(CatalogProviderMode.JAVA_READ); @@ -202,6 +232,31 @@ class TenantQuestionLifecycleServiceImplTest { verify(questionMapper, never()).updatePlacement(any(), any(), any(), anyInt()); } + @Test + void shouldRejectInvalidDraftBeforeDatabaseAccess() { + properties.setCatalogMode(CatalogProviderMode.JAVA_READ); + QuestionDraftCommand invalid = new QuestionDraftCommand("Pick", "choice", "easy", List.of( + new QuestionDraftCommand.QuestionDraftOption("A", "first", 1D), + new QuestionDraftCommand.QuestionDraftOption("B", "second", 2D)), + "C", null, null); + + ServiceException ex = assertThrows(ServiceException.class, () -> service.createDraft(invalid)); + + assertEquals(QUESTION_CONTENT_NOT_PUBLISHABLE.getCode(), ex.getCode()); + verifyNoInteractions(questionMapper, versionMapper, contentNodeMapper, auditMapper); + } + + @Test + void shouldRejectOptionsForOptionlessDraft() { + properties.setCatalogMode(CatalogProviderMode.JAVA_READ); + QuestionDraftCommand invalid = new QuestionDraftCommand("Explain", "text", null, List.of( + new QuestionDraftCommand.QuestionDraftOption("A", "unexpected", 1D)), + "answer", null, null); + + assertThrows(ServiceException.class, () -> service.createDraft(invalid)); + verifyNoInteractions(questionMapper, versionMapper, contentNodeMapper, auditMapper); + } + @Test void shouldPreserveRequestOrderWhenOptionOrderIsOmitted() { properties.setCatalogMode(CatalogProviderMode.JAVA_READ); diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/supervision/StudentSupervisionAdminServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/supervision/StudentSupervisionAdminServiceImplTest.java new file mode 100644 index 00000000..4db6b2a4 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/supervision/StudentSupervisionAdminServiceImplTest.java @@ -0,0 +1,153 @@ +package cn.iocoder.yudao.module.education.service.supervision; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.module.education.controller.admin.supervision.vo.StudentSupervisionVOs.*; +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.StudentRiskSnapshot; +import cn.iocoder.yudao.module.education.dal.dataobject.supervision.StudentSupervisionRuleDO; +import cn.iocoder.yudao.module.education.dal.mysql.classroom.EducationClassMapper; +import cn.iocoder.yudao.module.education.dal.mysql.supervision.StudentFollowupMapper; +import cn.iocoder.yudao.module.education.dal.mysql.supervision.StudentSupervisionRuleMapper; +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 org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.STUDENT_FOLLOWUP_CONFLICT; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class StudentSupervisionAdminServiceImplTest { + + @Mock StudentSupervisionRuleMapper ruleMapper; + @Mock StudentFollowupMapper followupMapper; + @Mock EducationClassMapper classMapper; + @Mock MemberUserApi memberUserApi; + @Mock AdminUserApi adminUserApi; + StudentSupervisionAdminServiceImpl service; + + @BeforeEach + void setUp() { + service = new StudentSupervisionAdminServiceImpl(ruleMapper, followupMapper, classMapper, + memberUserApi, adminUserApi); + TenantContextHolder.setTenantId(10L); + lenient().when(memberUserApi.getUserMap(anyCollection())).thenReturn(Map.of(8L, member(8L, "Ada"))); + lenient().when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of()); + } + + @AfterEach + void clear() { TenantContextHolder.clear(); } + + @Test + void previewBuildsRiskFromEducationStateAndMemberProjection() { + when(followupMapper.selectRiskSnapshots(eq(10L), isNull(), eq(14), eq(3), anyInt())) + .thenReturn(List.of(snapshot())); + PreviewReq request = new PreviewReq(); request.setPageSize(20); + + PreviewResp result = service.preview(request); + + assertEquals(1L, result.getTotalCandidates()); + CandidateResp candidate = result.getCandidates().getFirst(); + assertEquals(8L, candidate.getStudentUserId()); + assertEquals("Ada", candidate.getStudentName()); + assertEquals("INACTIVE", candidate.getReasons().getFirst().get("code")); + assertEquals(35, candidate.getRiskScore()); + } + + @Test + void generateUsesStableBatchKeyAndDoesNotCreateStudentAccounts() { + when(followupMapper.selectRiskSnapshots(eq(10L), isNull(), eq(14), eq(3), anyInt())) + .thenReturn(List.of(snapshot())); + doAnswer(invocation -> { StudentFollowupDO item = invocation.getArgument(0); item.setId(99L); return 1; }) + .when(followupMapper).insertIgnore(any(StudentFollowupDO.class), anyString(), anyString()); + when(followupMapper.selectByBatchKey(10L, "batch-1", 8L)).thenAnswer(invocation -> { + StudentFollowupDO item = new StudentFollowupDO(); item.setId(99L); item.setTenantId(10L); + item.setStudentUserId(8L); item.setOwnerUserId(7L); item.setBatchKey("batch-1"); + item.setStatus("OPEN"); item.setPriority("HIGH"); item.setRiskScore(35); + item.setReasons(List.of(Map.of("code", "INACTIVE"))); item.setEvidence(Map.of()); item.setVersion(0); + return item; + }); + GenerateReq request = new GenerateReq(); request.setBatchKey("batch-1"); request.setLimit(20); + + GenerateResp result = service.generate(request, 7L); + + assertEquals("batch-1", result.getBatchKey()); + assertEquals(1, result.getSuccessCount()); + ArgumentCaptor captor = ArgumentCaptor.forClass(StudentFollowupDO.class); + verify(followupMapper).insertIgnore(captor.capture(), anyString(), anyString()); + assertEquals(10L, captor.getValue().getTenantId()); + assertEquals(8L, captor.getValue().getStudentUserId()); + assertEquals(7L, captor.getValue().getOwnerUserId()); + assertEquals("batch-1", captor.getValue().getBatchKey()); + verify(memberUserApi, atLeastOnce()).getUserMap(anyCollection()); + } + + @Test + void ruleBindsSystemAdminAndClassDepartmentScope() { + EducationClassDO classroom = new EducationClassDO(); + classroom.setId(12L); classroom.setTenantId(10L); classroom.setStatus("ACTIVE"); classroom.setDeptId(3L); + when(classMapper.selectOwned(10L, 12L)).thenReturn(classroom); + doAnswer(invocation -> { StudentSupervisionRuleDO item = invocation.getArgument(0); item.setId(88L); return 1; }) + .when(ruleMapper).insert(any(StudentSupervisionRuleDO.class)); + when(ruleMapper.selectOwned(10L, 88L)).thenAnswer(invocation -> { + StudentSupervisionRuleDO item = insertedRule(); item.setId(88L); item.setClassId(12L); + item.setAssignedAdminUserId(9L); item.setDeptId(3L); return item; + }); + RuleSaveReq request = new RuleSaveReq(); request.setName("高风险学生"); request.setClassId(12L); + request.setAssignedAdminUserId(9L); + + service.createRule(request, 7L); + + verify(adminUserApi).validateUser(9L); + ArgumentCaptor captor = ArgumentCaptor.forClass(StudentSupervisionRuleDO.class); + verify(ruleMapper).insert(captor.capture()); + assertEquals(3L, captor.getValue().getDeptId()); + assertEquals(7L, captor.getValue().getOwnerUserId()); + } + + @Test + void staleFollowupUpdateFailsClosed() { + StudentFollowupDO item = new StudentFollowupDO(); item.setId(5L); item.setTenantId(10L); + item.setStudentUserId(8L); item.setStatus("OPEN"); item.setVersion(2); + when(followupMapper.selectOwned(10L, 5L)).thenReturn(item); + FollowupHandleReq request = new FollowupHandleReq(); request.setExpectedVersion(1); request.setStatus("DONE"); + + ServiceException error = assertThrows(ServiceException.class, + () -> service.handleFollowup(5L, request, 7L)); + + assertEquals(STUDENT_FOLLOWUP_CONFLICT.getCode(), error.getCode()); + verify(followupMapper, never()).updateStatusCas(anyLong(), anyLong(), anyInt(), anyString(), any(), anyLong()); + } + + private StudentRiskSnapshot snapshot() { + StudentRiskSnapshot row = new StudentRiskSnapshot(); row.setStudentUserId(8L); row.setClassId(12L); + row.setClassName("一班"); row.setAnswerCount(0); row.setCorrectCount(0); + row.setUnresolvedWrongQuestions(0); row.setWrongQuestionAttempts(0); + row.setStaleActiveSessions(0); row.setDueVocabularyWords(0); return row; + } + + private MemberUserRespDTO member(Long id, String nickname) { + MemberUserRespDTO item = new MemberUserRespDTO(); item.setId(id); item.setNickname(nickname); + item.setStatus(0); return item; + } + + private StudentSupervisionRuleDO insertedRule() { + StudentSupervisionRuleDO item = new StudentSupervisionRuleDO(); item.setTenantId(10L); item.setName("高风险学生"); + item.setStatus("ACTIVE"); item.setWindowDays(14); item.setInactivityDays(7); item.setMinAnswers(10); + item.setLowAccuracyPermille(600); item.setWrongQuestionThreshold(5); item.setVocabularyDueThreshold(20); + item.setStaleSessionDays(3); item.setScheduleFrequency("MANUAL"); item.setScheduleHour(9); + item.setScheduleMinute(0); item.setScheduleWeekdays("1,2,3,4,5"); item.setLimitCount(20); + item.setVersion(0); return item; + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/supervision/StudentSupervisionPostgreSqlIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/supervision/StudentSupervisionPostgreSqlIntegrationTest.java new file mode 100644 index 00000000..de36d195 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/supervision/StudentSupervisionPostgreSqlIntegrationTest.java @@ -0,0 +1,191 @@ +package cn.iocoder.yudao.module.education.service.supervision; + +import cn.iocoder.yudao.framework.common.biz.system.permission.PermissionCommonApi; +import cn.iocoder.yudao.framework.common.biz.system.permission.dto.DeptDataPermissionRespDTO; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.datapermission.config.YudaoDataPermissionAutoConfiguration; +import cn.iocoder.yudao.framework.datapermission.config.YudaoDeptDataPermissionAutoConfiguration; +import cn.iocoder.yudao.framework.datapermission.core.db.DataPermissionRuleHandler; +import cn.iocoder.yudao.framework.datapermission.core.rule.dept.DeptDataPermissionRule; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.module.education.dal.dataobject.supervision.StudentFollowupDO; +import cn.iocoder.yudao.module.education.dal.dataobject.supervision.StudentRiskSnapshot; +import cn.iocoder.yudao.module.education.dal.mysql.supervision.StudentFollowupMapper; +import cn.iocoder.yudao.module.education.framework.datapermission.EducationDataPermissionConfiguration; +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.education.test.PostgreSqlDbIntegrationTest; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.*; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import javax.sql.DataSource; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Import({StudentSupervisionAdminServiceImpl.class, EducationDataPermissionConfiguration.class, + YudaoDataPermissionAutoConfiguration.class, YudaoDeptDataPermissionAutoConfiguration.class}) +class StudentSupervisionPostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest { + + @Resource StudentFollowupMapper followupMapper; + @Resource StudentSupervisionAdminService supervisionService; + @Resource DataSource dataSource; + @MockitoBean MemberUserApi memberUserApi; + @MockitoBean AdminUserApi adminUserApi; + @MockitoBean PermissionCommonApi permissionCommonApi; + @Resource DeptDataPermissionRule deptDataPermissionRule; + // The PostgreSQL test profile enables global lazy initialization, so resolving this bean is what installs + // DataPermissionInterceptor into the MyBatis interceptor chain before the mapper is first used. + @Resource DataPermissionRuleHandler dataPermissionRuleHandler; + JdbcTemplate jdbcTemplate; + + @BeforeEach void setUp() { + TenantContextHolder.setTenantId(10L); + SecurityContextHolder.clearContext(); + jdbcTemplate = new JdbcTemplate(dataSource); + } + @AfterEach void clear() { TenantContextHolder.clear(); SecurityContextHolder.clearContext(); } + + @Test + void aggregatesNativeLearningStateAndEnforcesBatchIdempotency() { + jdbcTemplate.update("INSERT INTO education_class (id, tenant_id, name, status) VALUES (100, 10, '一班', 'ACTIVE')"); + jdbcTemplate.update("INSERT INTO education_class_member (tenant_id, class_id, member_user_id, role) VALUES (10, 100, 8, 'STUDENT')"); + jdbcTemplate.update(""" + INSERT INTO education_practice_session + (id, tenant_id, user_id, client_session_id, status, create_time, update_time) + VALUES (200, 10, 8, '00000000-0000-0000-0000-000000000200', 'ACTIVE', + CURRENT_TIMESTAMP - INTERVAL '5 day', CURRENT_TIMESTAMP - INTERVAL '5 day') + """); + jdbcTemplate.update(""" + INSERT INTO education_practice_report + (tenant_id, user_id, session_id, question_count, answered_count, unanswered_count, + correct_count, incorrect_count, score) + VALUES (10, 8, 200, 10, 10, 0, 4, 6, 40) + """); + jdbcTemplate.update(""" + INSERT INTO education_wrong_question + (tenant_id, user_id, question_id, stem, type, options, first_wrong_time, + last_wrong_time, wrong_count, master_status) + VALUES (10, 8, 'q-1', 'stem', 'SINGLE_CHOICE', '[]'::jsonb, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 6, 'PENDING') + """); + jdbcTemplate.update(""" + INSERT INTO education_vocabulary_progress + (tenant_id, user_id, vocabulary_key, display_text, mastery_level, next_review_time) + VALUES (10, 8, 'word', 'word', 1, CURRENT_TIMESTAMP - INTERVAL '1 day') + """); + + List rows = followupMapper.selectRiskSnapshots(10L, 100L, 14, 3, 100); + + assertEquals(1, rows.size()); + StudentRiskSnapshot row = rows.getFirst(); + assertEquals(10, row.getAnswerCount()); + assertEquals(4, row.getCorrectCount()); + assertEquals(1, row.getUnresolvedWrongQuestions()); + assertEquals(6, row.getWrongQuestionAttempts()); + assertEquals(1, row.getStaleActiveSessions()); + assertEquals(1, row.getDueVocabularyWords()); + + StudentFollowupDO first = followup(8L, "batch-1"); + assertEquals(1, followupMapper.insertIgnore(first, toJsonString(first.getReasons()), toJsonString(first.getEvidence()))); + assertNotNull(first.getId()); + assertEquals("INACTIVE", followupMapper.selectOwned(10L, first.getId()).getReasons().getFirst().get("code")); + StudentFollowupDO replay = followup(8L, "batch-1"); + assertEquals(0, followupMapper.insertIgnore(replay, toJsonString(replay.getReasons()), toJsonString(replay.getEvidence()))); + assertEquals(first.getId(), followupMapper.selectByBatchKey(10L, "batch-1", 8L).getId()); + } + + @Test + void serviceConcurrentlyReplaysOneCommittedFollowupWithoutAbortingTransactions() throws Exception { + insertRiskStudent(); + MemberUserRespDTO student = new MemberUserRespDTO(); student.setId(8L); student.setNickname("Ada"); + student.setStatus(0); + when(memberUserApi.getUserMap(anyCollection())).thenReturn(Map.of(8L, student)); + when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of()); + + var request = new cn.iocoder.yudao.module.education.controller.admin.supervision.vo.StudentSupervisionVOs.GenerateReq(); + request.setClassId(100L); request.setBatchKey("service-race"); request.setLimit(20); + CountDownLatch ready = new CountDownLatch(2); CountDownLatch go = new CountDownLatch(1); + AtomicReference first = new AtomicReference<>(); + AtomicReference second = new AtomicReference<>(); + AtomicReference firstError = new AtomicReference<>(); AtomicReference secondError = new AtomicReference<>(); + Thread firstThread = generateThread(request, ready, go, first, firstError); + Thread secondThread = generateThread(request, ready, go, second, secondError); + + firstThread.start(); secondThread.start(); + assertTrue(ready.await(5, TimeUnit.SECONDS)); go.countDown(); + firstThread.join(10_000); secondThread.join(10_000); + + assertFalse(firstThread.isAlive()); assertFalse(secondThread.isAlive()); + assertNull(firstError.get()); assertNull(secondError.get()); + assertNotNull(first.get()); assertNotNull(second.get()); + assertEquals(first.get().getItems().getFirst().getId(), second.get().getItems().getFirst().getId()); + assertEquals(1, followupMapper.selectList().size()); + } + + @Test + void riskCteHonorsRuoYiDepartmentDataPermission() { + jdbcTemplate.update("INSERT INTO education_class (id, tenant_id, name, status, dept_id) VALUES (100, 10, '可见班', 'ACTIVE', 3)"); + jdbcTemplate.update("INSERT INTO education_class (id, tenant_id, name, status, dept_id) VALUES (101, 10, '隐藏班', 'ACTIVE', 4)"); + jdbcTemplate.update("INSERT INTO education_class_member (tenant_id, class_id, member_user_id, role) VALUES (10, 100, 8, 'STUDENT')"); + jdbcTemplate.update("INSERT INTO education_class_member (tenant_id, class_id, member_user_id, role) VALUES (10, 101, 9, 'STUDENT')"); + LoginUser loginUser = new LoginUser().setId(7L).setUserType(UserTypeEnum.ADMIN.getValue()); + SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest()); + when(permissionCommonApi.getDeptDataPermission(7L)).thenReturn(new DeptDataPermissionRespDTO() + .setAll(false).setDeptIds(Set.of(3L)).setSelf(false)); + assertNotNull(dataPermissionRuleHandler); + assertTrue(deptDataPermissionRule.getTableNames().contains("education_class")); + + List rows = followupMapper.selectRiskSnapshots(10L, null, 14, 3, 100); + + verify(permissionCommonApi).getDeptDataPermission(7L); + assertEquals(List.of(8L), rows.stream().map(StudentRiskSnapshot::getStudentUserId).toList()); + } + + private Thread generateThread( + cn.iocoder.yudao.module.education.controller.admin.supervision.vo.StudentSupervisionVOs.GenerateReq request, + CountDownLatch ready, CountDownLatch go, + AtomicReference result, + AtomicReference error) { + return new Thread(() -> { + TenantContextHolder.setTenantId(10L); + try { ready.countDown(); go.await(); result.set(supervisionService.generate(request, 7L)); } + catch (Throwable ex) { error.set(ex); } + finally { TenantContextHolder.clear(); } + }); + } + + private void insertRiskStudent() { + jdbcTemplate.update("INSERT INTO education_class (id, tenant_id, name, status, dept_id) VALUES (100, 10, '一班', 'ACTIVE', 3)"); + jdbcTemplate.update("INSERT INTO education_class_member (tenant_id, class_id, member_user_id, role) VALUES (10, 100, 8, 'STUDENT')"); + } + + private StudentFollowupDO followup(Long userId, String batchKey) { + StudentFollowupDO item = new StudentFollowupDO(); item.setTenantId(10L); item.setStudentUserId(userId); + item.setClassId(100L); item.setDeptId(3L); item.setOwnerUserId(7L); item.setTitle("学习督导"); + item.setFollowupType("LEARNING"); item.setPriority("HIGH"); item.setStatus("OPEN"); + item.setBatchKey(batchKey); item.setRiskScore(80); + item.setReasons(List.of(Map.of("code", "INACTIVE", "label", "未学习"))); + item.setEvidence(Map.of("answerCount", 10)); item.setDueTime(LocalDateTime.now().plusDays(1)); + item.setVersion(0); return item; + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java index a18e36f8..dcb9bf92 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java @@ -13,17 +13,19 @@ import java.sql.SQLException; import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.function.Supplier; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class EducationFlywayMigrationIntegrationTest { - private static final String HOST = requiredEnv("EDU_TEST_POSTGRES_HOST"); - private static final String PORT = requiredEnv("EDU_TEST_POSTGRES_PORT"); - private static final String DATABASE = requiredEnv("EDU_TEST_POSTGRES_DB"); - private static final String USER = requiredEnv("EDU_TEST_POSTGRES_USER"); - private static final String PASSWORD = requiredEnv("EDU_TEST_POSTGRES_PASSWORD"); + private static final String HOST = envOrDefault("EDU_TEST_POSTGRES_HOST", EducationPostgreSqlContainer::host); + private static final String PORT = envOrDefault("EDU_TEST_POSTGRES_PORT", + () -> String.valueOf(EducationPostgreSqlContainer.port())); + private static final String DATABASE = envOrDefault("EDU_TEST_POSTGRES_DB", EducationPostgreSqlContainer::database); + private static final String USER = envOrDefault("EDU_TEST_POSTGRES_USER", EducationPostgreSqlContainer::username); + private static final String PASSWORD = envOrDefault("EDU_TEST_POSTGRES_PASSWORD", EducationPostgreSqlContainer::password); private final List schemas = new ArrayList<>(); @@ -121,7 +123,7 @@ class EducationFlywayMigrationIntegrationTest { assertThat(queryStrings(schema, "SELECT COALESCE(version, 'BASELINE') FROM flyway_schema_history ORDER BY installed_rank")) - .containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110", "4120", "4130", "4140", "4150", "4160", "4170", "4180", "4190", "4200", "4210"); + .containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110", "4120", "4130", "4140", "4150", "4160", "4170", "4180", "4190", "4200", "4210", "4220", "4230", "4240", "4250", "4260", "4270", "4280", "4290", "4300", "4310", "4320", "4330", "4340", "4350", "4360", "4370", "4380", "4390", "4400", "4410", "4420", "4430", "4440", "4450"); assertThat(queryLong(schema, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " + "AND table_name = 'education_idempotency'")) @@ -228,6 +230,47 @@ class EducationFlywayMigrationIntegrationTest { """)).hasMessageContaining("cannot cross tenants"); } + @Test + void shouldCreateSupervisionRulesFollowupsAndEnforceOwnership() throws SQLException { + String schema = createSchema("supervision"); + configureFlyway(schema, false).load().migrate(); + + assertThat(queryStrings(schema, """ + SELECT table_name FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name IN ('education_student_supervision_rule', 'education_student_followup') + ORDER BY table_name + """)).containsExactly("education_student_followup", "education_student_supervision_rule"); + assertThat(queryStrings(schema, """ + SELECT column_name FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = 'education_class' + AND column_name IN ('dept_id', 'owner_user_id') ORDER BY column_name + """)).containsExactly("dept_id", "owner_user_id"); + execute(schema, """ + INSERT INTO education_class (id, tenant_id, name, dept_id, owner_user_id) + VALUES (100, 10, 'Class A', 3, 7); + INSERT INTO education_student_supervision_rule + (id, tenant_id, name, class_id, owner_user_id) + VALUES (200, 10, 'Inactive', 100, 7); + INSERT INTO education_student_followup + (tenant_id, student_user_id, class_id, rule_id, owner_user_id, + title, batch_key, risk_score) + VALUES (10, 8, 100, 200, 7, 'Follow', 'batch-1', 80); + """); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO education_student_followup + (tenant_id, student_user_id, class_id, rule_id, owner_user_id, + title, batch_key, risk_score) + VALUES (20, 9, 100, NULL, 7, 'Cross', 'batch-2', 80); + """)).hasMessageContaining("cannot cross class tenant"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO education_student_followup + (tenant_id, student_user_id, class_id, rule_id, owner_user_id, + title, batch_key, risk_score) + VALUES (10, 8, 100, 200, 7, 'Duplicate', 'batch-1', 70); + """)).hasMessageContaining("uk_education_followup_batch_student"); + } + @Test void shouldMigrateFreshPracticeSchemaThroughFlyway() throws SQLException { String schema = createSchema("fresh"); @@ -238,7 +281,7 @@ class EducationFlywayMigrationIntegrationTest { assertThat(queryStrings(schema, "SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank")) - .containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110", "4120", "4130", "4140", "4150", "4160", "4170", "4180", "4190", "4200", "4210"); + .containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110", "4120", "4130", "4140", "4150", "4160", "4170", "4180", "4190", "4200", "4210", "4220", "4230", "4240", "4250", "4260", "4270", "4280", "4290", "4300", "4310", "4320", "4330", "4340", "4350", "4360", "4370", "4380", "4390", "4400", "4410", "4420", "4430", "4440", "4450"); assertThat(queryStrings(schema, "SELECT table_name FROM information_schema.tables " + "WHERE table_schema = current_schema() AND table_name IN (" + @@ -292,6 +335,56 @@ class EducationFlywayMigrationIntegrationTest { .isEqualTo(8L); } + @Test + void shouldCreateLearningOperationsAndIdempotentMemberPointContract() throws SQLException { + String schema = createSchema("learning_operations"); + configureFlyway(schema, false).target("4240").load().migrate(); + execute(schema, """ + CREATE TABLE member_point_record ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + biz_type INTEGER NOT NULL, + biz_id VARCHAR(255), + point INTEGER NOT NULL DEFAULT 0 + ); + """); + + configureFlyway(schema, false).load().migrate(); + + assertThat(queryStrings(schema, """ + SELECT column_name FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'education_student_feedback' + AND column_name IN ('priority', 'resolution', 'handled_by', 'handled_time', + 'reward_points', 'reward_status', 'reward_error', 'version') + ORDER BY column_name + """)).containsExactly("handled_by", "handled_time", "priority", "resolution", "reward_error", + "reward_points", "reward_status", "version"); + assertThat(queryLong(schema, "SELECT count(*) FROM pg_indexes WHERE schemaname = current_schema() " + + "AND indexname = 'uk_member_point_record_education_biz'")) + .isEqualTo(1L); + + execute(schema, "INSERT INTO member_point_record(user_id,biz_type,biz_id,point) " + + "VALUES(8,31,'education-feedback-reward:1',10)"); + assertThatThrownBy(() -> execute(schema, "INSERT INTO member_point_record(user_id,biz_type,biz_id,point) " + + "VALUES(8,31,'education-feedback-reward:1',10)")) + .hasMessageContaining("uk_member_point_record_education_biz"); + + execute(schema, """ + INSERT INTO education_student_feedback + (id, tenant_id, user_id, category, content, status) + VALUES (500, 10, 8, 'BUG', 'broken', 'OPEN'); + INSERT INTO education_student_feedback_event + (tenant_id, feedback_id, from_status, to_status, actor_id) + VALUES (10, 500, 'OPEN', 'RESOLVED', 7); + """); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO education_student_feedback_event + (tenant_id, feedback_id, from_status, to_status, actor_id) + VALUES (20, 500, 'OPEN', 'RESOLVED', 7) + """)).hasMessageContaining("cannot cross tenants"); + } + @Test void shouldCreateOperationalHeartbeatAndDeadLetterContracts() throws SQLException { String schema = createSchema("operations"); @@ -1068,6 +1161,33 @@ class EducationFlywayMigrationIntegrationTest { "education:question:publish", "education:question:archive", "education:question:classify"); + assertThat(queryStrings(schema, """ + SELECT component + FROM system_menu + WHERE id IN (6832, 6837, 6842, 6843) + ORDER BY id + """)) + .containsExactly( + "education/import-job/index", + "education/classroom/index", + "education/commercialization/index", + "education/operations/index"); + assertThat(queryLong(schema, """ + SELECT count(*) + FROM system_menu + WHERE (id BETWEEN 6833 AND 6836 AND parent_id = 6832) + OR (id BETWEEN 6838 AND 6841 AND parent_id = 6837) + OR (id IN (6815, 6816) AND parent_id = 6842) + OR (id = 6801 AND parent_id = 6843) + """)) + .isEqualTo(11); + assertThat(queryStrings(schema, """ + SELECT component + FROM system_menu + WHERE id IN (6844, 6846) + ORDER BY id + """)) + .containsExactly("education/category/index", "education/content-export/index"); } @Test @@ -1279,6 +1399,1791 @@ class EducationFlywayMigrationIntegrationTest { .isZero(); } + @Test + void shouldCreateTenantAppearanceTemplatesAndMenus() throws SQLException { + String schema = createSchema("tenant_appearance"); + createSystemMenuFixture(schema); + configureFlyway(schema, true).load().migrate(); + + assertThat(queryStrings(schema, """ + SELECT code || ':' || name || ':' || sort_order + FROM education_tenant_theme_template + WHERE status = 'ACTIVE' AND deleted = false + ORDER BY sort_order + """)).containsExactly("classic:经典蓝:10", "focus:专注绿:20", "high-contrast:高对比:30"); + assertThat(queryStrings(schema, """ + SELECT (theme->>'primaryColor') || ':' || (public_assets->>'iconSet') + FROM education_tenant_theme_template WHERE code = 'focus' + """)).containsExactly("#0f766e:focus"); + + execute(schema, """ + INSERT INTO education_tenant_appearance (tenant_id, brand_name) + VALUES (10, 'Tenant A'); + UPDATE education_tenant_appearance + SET draft_template_code='focus', + draft_theme=(SELECT theme FROM education_tenant_theme_template WHERE code='focus'), + draft_public_assets=(SELECT public_assets FROM education_tenant_theme_template WHERE code='focus'), + theme_status='DRAFT', version=version+1 + WHERE tenant_id=10 AND version=0; + UPDATE education_tenant_appearance + SET active_template_code=draft_template_code, active_theme=draft_theme, + active_public_assets=draft_public_assets, draft_template_code=NULL, + draft_theme='{}'::JSONB, draft_public_assets='{}'::JSONB, + theme_status='PUBLISHED', version=version+1 + WHERE tenant_id=10 AND version=1; + """); + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || active_template_code || ':' || theme_status || ':' || version + FROM education_tenant_appearance + """)).containsExactly("10:focus:PUBLISHED:2"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO education_tenant_appearance (tenant_id, brand_name) VALUES (10, 'Duplicate') + """)).hasMessageContaining("uk_education_tenant_appearance_tenant"); + assertThat(queryStrings(schema, """ + SELECT id || ':' || permission FROM system_menu WHERE id BETWEEN 6862 AND 6866 ORDER BY id + """)).containsExactly( + "6862:", + "6863:education:tenant-appearance:query", + "6864:education:tenant-appearance:branding", + "6865:education:tenant-appearance:settings", + "6866:education:tenant-appearance:theme"); + } + + @Test + void shouldReuseNativePaymentAndSocialAdministrationMenus() throws SQLException { + String schema = createSchema("native_integrations"); + createSystemMenuFixture(schema); + configureFlyway(schema, true).load().migrate(); + + assertThat(queryStrings(schema, """ + SELECT id || ':' || component || ':' || component_name + FROM system_menu WHERE id IN (6867, 6876) ORDER BY id + """)).containsExactly( + "6867:pay/app/index:EducationPayConfiguration", + "6876:system/social/client/index.vue:EducationSocialProvider"); + assertThat(queryStrings(schema, """ + SELECT permission FROM system_menu + WHERE id BETWEEN 6868 AND 6880 AND type = 3 ORDER BY id + """)).containsExactly( + "pay:app:query", "pay:app:create", "pay:app:update", "pay:app:delete", + "pay:channel:query", "pay:channel:create", "pay:channel:update", "pay:channel:delete", + "system:social-client:query", "system:social-client:create", + "system:social-client:update", "system:social-client:delete"); + + String conflictSchema = createSchema("native_integration_conflict"); + createSystemMenuFixture(conflictSchema); + configureFlyway(conflictSchema, true).target("4290").load().migrate(); + execute(conflictSchema, """ + INSERT INTO system_menu(id,name,permission,type,sort,parent_id,status,deleted) + VALUES(6868,'Conflict','education:integration:shadow',3,1,6867,0,0) + """); + assertThatThrownBy(() -> configureFlyway(conflictSchema, true).load().migrate()) + .hasMessageContaining("Education native integration menu shape conflict"); + } + + @Test + void shouldActivateTenantScopedNativePayAdministration() throws SQLException { + String schema = createSchema("native_pay"); + configureFlyway(schema, false).load().migrate(); + + execute(schema, """ + INSERT INTO pay_app + (tenant_id,app_key,name,status,order_notify_url,refund_notify_url) + VALUES + (10,'education','Tenant A',0,'https://a.example/pay','https://a.example/refund'), + (20,'education','Tenant B',0,'https://b.example/pay','https://b.example/refund'); + INSERT INTO pay_channel(tenant_id,code,status,fee_rate,app_id,config) + SELECT tenant_id,'mock',0,0,id,'{}' FROM pay_app; + """); + + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || app_key || ':' || name FROM pay_app ORDER BY tenant_id + """)).containsExactly("10:education:Tenant A", "20:education:Tenant B"); + assertThat(queryLong(schema, """ + SELECT count(*) FROM pay_channel WHERE deleted=false + """)).isEqualTo(2L); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO pay_app + (tenant_id,app_key,name,status,order_notify_url,refund_notify_url) + VALUES(10,'education','Duplicate',0,'https://a.example/pay','https://a.example/refund') + """)).hasMessageContaining("uk_pay_app_tenant_key_active"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO pay_channel(tenant_id,code,status,fee_rate,app_id,config) + SELECT tenant_id,'mock',0,0,id,'{}' FROM pay_app WHERE tenant_id=10 + """)).hasMessageContaining("uk_pay_channel_tenant_app_code_active"); + + String conflictSchema = createSchema("native_pay_global_conflict"); + configureFlyway(conflictSchema, false).target("4310").load().migrate(); + execute(conflictSchema, """ + CREATE TABLE pay_app ( + id BIGINT PRIMARY KEY, + app_key VARCHAR(64) NOT NULL + ) + """); + assertThatThrownBy(() -> configureFlyway(conflictSchema, false).load().migrate()) + .hasMessageContaining("Existing pay_app requires explicit tenant mapping before V4320"); + assertThat(queryLong(conflictSchema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4320' AND success=true")) + .isZero(); + } + + @Test + void shouldAuditLegacyPayImportsWithoutPersistingRawCredentials() throws SQLException { + String schema = createSchema("legacy_pay_import"); + configureFlyway(schema, false).load().migrate(); + + execute(schema, """ + INSERT INTO pay_app + (tenant_id,app_key,name,status,order_notify_url,refund_notify_url) + VALUES + (10,'legacy-a','Tenant A',0,'https://a.example/pay','https://a.example/refund'), + (20,'legacy-b','Tenant B',0,'https://b.example/pay','https://b.example/refund'); + INSERT INTO pay_channel(tenant_id,code,status,fee_rate,app_id,config) + SELECT tenant_id,'wx_lite',0,0,id,'{}' FROM pay_app; + INSERT INTO pay_legacy_account_import + (tenant_id,source_tenant_id,source_account_id,source_checksum_sha256,source_provider, + normalized_provider,source_mode,source_status,target_app_id,target_channel_id, + target_channel_code,target_status,normalized_config_digest,mapping_notes,imported_by) + SELECT app.tenant_id, + CASE app.tenant_id WHEN 10 THEN '11111111-1111-4111-8111-111111111111'::uuid + ELSE '22222222-2222-4222-8222-222222222222'::uuid END, + '33333333-3333-4333-8333-333333333333'::uuid, + repeat('a',64),'wechat-pay','wechat_pay','tenant_collect','active', + app.id,channel.id,'wx_lite',0,repeat('b',64),'["alias normalized"]'::jsonb,7 + FROM pay_app app JOIN pay_channel channel + ON channel.tenant_id=app.tenant_id AND channel.app_id=app.id; + """); + + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || normalized_provider || ':' || target_channel_code + FROM pay_legacy_account_import ORDER BY tenant_id + """)).containsExactly("10:wechat_pay:wx_lite", "20:wechat_pay:wx_lite"); + assertThat(queryLong(schema, """ + SELECT count(*) FROM information_schema.columns + WHERE table_schema=current_schema() AND table_name='pay_legacy_account_import' + AND column_name IN ('config_public','secret_json','secret_value','config') + """)).isZero(); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO pay_legacy_account_import + (tenant_id,source_tenant_id,source_account_id,source_checksum_sha256,source_provider, + normalized_provider,source_mode,source_status,target_app_id,target_channel_id, + target_channel_code,target_status,normalized_config_digest,mapping_notes,imported_by) + SELECT 10,'11111111-1111-4111-8111-111111111111', + '44444444-4444-4444-8444-444444444444',repeat('c',64),'alipay','alipay', + 'tenant_collect','active',app.id,channel.id,'alipay_wap',0,repeat('d',64),'[]',7 + FROM pay_app app JOIN pay_channel channel ON channel.tenant_id=app.tenant_id + WHERE app.tenant_id=20 + """)).hasMessageContaining("fk_pay_legacy_import_app"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO pay_legacy_account_import + (tenant_id,source_tenant_id,source_account_id,source_checksum_sha256,source_provider, + normalized_provider,source_mode,source_status,target_app_id,target_channel_id, + target_channel_code,target_status,normalized_config_digest,mapping_notes,imported_by) + SELECT 10,'11111111-1111-4111-8111-111111111111', + '55555555-5555-4555-8555-555555555555',repeat('e',64),'wechat_pay','wechat_pay', + 'service_provider','active',app.id,channel.id,'wx_lite',0,repeat('f',64),'[]',7 + FROM pay_app app JOIN pay_channel channel + ON channel.tenant_id=app.tenant_id AND channel.app_id=app.id WHERE app.tenant_id=10 + """)).hasMessageContaining("ck_pay_legacy_import_mode"); + + String conflictSchema = createSchema("legacy_pay_import_global_conflict"); + configureFlyway(conflictSchema, false).target("4320").load().migrate(); + execute(conflictSchema, "CREATE TABLE pay_legacy_account_import (id BIGINT PRIMARY KEY)"); + assertThatThrownBy(() -> configureFlyway(conflictSchema, false).load().migrate()) + .hasMessageContaining("Existing pay_legacy_account_import requires explicit tenant mapping before V4330"); + assertThat(queryLong(conflictSchema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4330' AND success=true")) + .isZero(); + } + + @Test + void shouldActivateTenantScopedNativePayTransactionsAndMenus() throws SQLException { + String schema = createSchema("native_pay_transactions"); + createSystemMenuFixture(schema); + configureFlyway(schema, true).load().migrate(); + + execute(schema, """ + INSERT INTO pay_app + (tenant_id,app_key,name,status,order_notify_url,refund_notify_url) + VALUES + (10,'orders-a','Tenant A',0,'https://a.example/pay','https://a.example/refund'), + (20,'orders-b','Tenant B',0,'https://b.example/pay','https://b.example/refund'); + INSERT INTO pay_channel(tenant_id,code,status,fee_rate,app_id,config) + SELECT tenant_id,'wx_lite',0,0,id,'{}' FROM pay_app WHERE app_key LIKE 'orders-%'; + INSERT INTO pay_order + (tenant_id,app_id,channel_id,channel_code,merchant_order_id,subject,notify_url, + price,status,user_ip,expire_time) + SELECT app.tenant_id,app.id,channel.id,channel.code,'legacy-order','Course',app.order_notify_url, + 1000,0,'127.0.0.1',CURRENT_TIMESTAMP + INTERVAL '30 minutes' + FROM pay_app app JOIN pay_channel channel + ON channel.tenant_id=app.tenant_id AND channel.app_id=app.id + WHERE app.app_key LIKE 'orders-%'; + INSERT INTO pay_order_extension + (tenant_id,no,order_id,channel_id,channel_code,user_ip,status,channel_extras) + SELECT tenant_id,'P-' || tenant_id,id,channel_id,channel_code,user_ip,0,'{}' + FROM pay_order WHERE merchant_order_id='legacy-order'; + UPDATE pay_order orders + SET extension_id=extension.id,no=extension.no + FROM pay_order_extension extension + WHERE extension.tenant_id=orders.tenant_id AND extension.order_id=orders.id; + INSERT INTO pay_refund + (tenant_id,no,app_id,channel_id,channel_code,order_id,order_no,merchant_order_id, + merchant_refund_id,notify_url,status,pay_price,refund_price,reason,user_ip,channel_order_no) + SELECT tenant_id,'R-10',app_id,channel_id,channel_code,id,no,merchant_order_id, + 'refund-1','https://a.example/refund',0,price,500,'requested',user_ip,'channel-order-1' + FROM pay_order WHERE tenant_id=10; + INSERT INTO pay_notify_task + (tenant_id,app_id,type,data_id,merchant_order_id,status,next_notify_time, + notify_times,max_notify_times,notify_url) + SELECT tenant_id,app_id,1,id,merchant_order_id,0,CURRENT_TIMESTAMP,0,9,notify_url + FROM pay_order WHERE tenant_id=10; + INSERT INTO pay_notify_log(tenant_id,task_id,notify_times,response,status) + SELECT tenant_id,id,1,'ok',10 FROM pay_notify_task WHERE tenant_id=10; + """); + + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || merchant_order_id || ':' || status + FROM pay_order ORDER BY tenant_id + """)).containsExactly("10:legacy-order:0", "20:legacy-order:0"); + assertThat(queryLong(schema, "SELECT count(*) FROM pay_order_extension")).isEqualTo(2L); + assertThat(queryLong(schema, "SELECT count(*) FROM pay_refund WHERE tenant_id=10")).isEqualTo(1L); + assertThat(queryLong(schema, "SELECT count(*) FROM pay_notify_log WHERE tenant_id=10")).isEqualTo(1L); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO pay_notify_task + (tenant_id,app_id,type,data_id,merchant_order_id,status,next_notify_time, + notify_times,max_notify_times,notify_url) + SELECT tenant_id,app_id,type,data_id,merchant_order_id,status,next_notify_time, + notify_times,max_notify_times,notify_url + FROM pay_notify_task WHERE tenant_id=10 AND deleted=false + """)).hasMessageContaining("uk_pay_notify_task_tenant_data_active"); + execute(schema, """ + UPDATE pay_notify_task SET deleted=true WHERE tenant_id=10; + INSERT INTO pay_notify_task + (tenant_id,app_id,type,data_id,merchant_order_id,status,next_notify_time, + notify_times,max_notify_times,notify_url) + SELECT tenant_id,app_id,1,id,merchant_order_id,0,CURRENT_TIMESTAMP,0,9,notify_url + FROM pay_order WHERE tenant_id=10 + """); + assertThat(queryLong(schema, + "SELECT count(*) FROM pay_notify_task WHERE tenant_id=10 AND deleted=false")).isEqualTo(1L); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO pay_order_extension + (tenant_id,no,order_id,channel_id,channel_code,status) + SELECT 10,'P-CROSS',orders.id,orders.channel_id,orders.channel_code,0 + FROM pay_order orders WHERE orders.tenant_id=20 + """)).hasMessageContaining("fk_pay_order_extension_order"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO pay_refund + (tenant_id,no,app_id,channel_id,channel_code,order_id,order_no,merchant_order_id, + merchant_refund_id,notify_url,status,pay_price,refund_price,reason,channel_order_no) + SELECT 10,'R-CROSS',app_id,channel_id,channel_code,id,no,merchant_order_id, + 'refund-cross','https://a.example/refund',0,price,100,'bad','channel-order-cross' + FROM pay_order WHERE tenant_id=20 + """)).hasMessageContaining("fk_pay_refund_app"); + assertThat(queryStrings(schema, """ + SELECT id || ':' || component || ':' || component_name + FROM system_menu WHERE id IN (6890,6893,6896) ORDER BY id + """)).containsExactly( + "6890:pay/order/index:EducationPayOrders", + "6893:pay/refund/index:EducationPayRefunds", + "6896:pay/notify/index:EducationPayNotify"); + assertThat(queryStrings(schema, """ + SELECT permission FROM system_menu WHERE id IN (6891,6892,6894,6895,6897) ORDER BY id + """)).containsExactly( + "pay:order:query", "pay:order:export", "pay:refund:query", "pay:refund:export", + "pay:notify:query"); + + String conflictSchema = createSchema("native_pay_transaction_global_conflict"); + configureFlyway(conflictSchema, false).target("4330").load().migrate(); + execute(conflictSchema, "CREATE TABLE pay_order (id BIGINT PRIMARY KEY)"); + assertThatThrownBy(() -> configureFlyway(conflictSchema, false).load().migrate()) + .hasMessageContaining("Existing pay_order requires explicit tenant mapping before V4340"); + assertThat(queryLong(conflictSchema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4340' AND success=true")) + .isZero(); + } + + @Test + void shouldCreateRedactedLegacyPayTransactionImportAudit() throws SQLException { + String schema = createSchema("legacy_pay_transactions"); + createSystemMenuFixture(schema); + configureFlyway(schema, true).load().migrate(); + + execute(schema, """ + INSERT INTO pay_app + (tenant_id,app_key,name,status,order_notify_url,refund_notify_url) + VALUES + (10,'legacy-transactions-a','Tenant A',0,'https://a.example/pay','https://a.example/refund'), + (20,'legacy-transactions-b','Tenant B',0,'https://b.example/pay','https://b.example/refund'); + INSERT INTO pay_channel(tenant_id,code,status,fee_rate,app_id,config) + SELECT tenant_id,'wx_lite',0,0,id,'{}' FROM pay_app + WHERE app_key LIKE 'legacy-transactions-%'; + INSERT INTO pay_legacy_account_import + (tenant_id,source_tenant_id,source_account_id,source_checksum_sha256,source_provider, + normalized_provider,source_mode,source_status,target_app_id,target_channel_id, + target_channel_code,target_status,normalized_config_digest,mapping_notes,imported_by) + SELECT app.tenant_id, + CASE app.tenant_id WHEN 10 THEN '11111111-1111-4111-8111-111111111111'::uuid + ELSE '22222222-2222-4222-8222-222222222222'::uuid END, + CASE app.tenant_id WHEN 10 THEN '33333333-3333-4333-8333-333333333333'::uuid + ELSE '44444444-4444-4444-8444-444444444444'::uuid END, + repeat('a',64),'wechat_pay','wechat_pay','tenant_collect','active', + app.id,channel.id,channel.code,0,repeat('b',64),'[]',7 + FROM pay_app app JOIN pay_channel channel + ON channel.tenant_id=app.tenant_id AND channel.app_id=app.id + WHERE app.app_key LIKE 'legacy-transactions-%'; + INSERT INTO pay_order + (tenant_id,app_id,channel_id,channel_code,merchant_order_id,subject,notify_url, + price,status,user_ip,expire_time,success_time,no,refund_price,channel_order_no) + SELECT app.tenant_id,app.id,channel.id,channel.code,'OLD-ORDER-' || app.tenant_id, + 'Legacy course',app.order_notify_url,1000,20,'0.0.0.0',CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP,'OLD-OUT-' || app.tenant_id,1000,'PROVIDER-' || app.tenant_id + FROM pay_app app JOIN pay_channel channel + ON channel.tenant_id=app.tenant_id AND channel.app_id=app.id + WHERE app.app_key LIKE 'legacy-transactions-%'; + INSERT INTO pay_order_extension + (tenant_id,no,order_id,channel_id,channel_code,user_ip,status,channel_extras) + SELECT tenant_id,no,id,channel_id,channel_code,user_ip,10,'{}' + FROM pay_order WHERE merchant_order_id LIKE 'OLD-ORDER-%'; + UPDATE pay_order orders SET extension_id=extension.id + FROM pay_order_extension extension + WHERE extension.tenant_id=orders.tenant_id AND extension.order_id=orders.id; + INSERT INTO pay_refund + (tenant_id,no,app_id,channel_id,channel_code,order_id,order_no,merchant_order_id, + merchant_refund_id,notify_url,status,pay_price,refund_price,reason,user_ip, + channel_order_no,channel_refund_no,success_time) + SELECT tenant_id,'OLD-REFUND-10',app_id,channel_id,channel_code,id,no,merchant_order_id, + 'OLD-REFUND-10','https://a.example/refund',10,price,price,'legacy refund',user_ip, + channel_order_no,'PROVIDER-REFUND-10',CURRENT_TIMESTAMP + FROM pay_order WHERE tenant_id=10; + INSERT INTO pay_legacy_transaction_import + (tenant_id,source_tenant_id,source_order_id,source_checksum_sha256,source_order_no, + source_order_status,source_payment_count,source_payment_event_count,source_refund_count, + source_refunded_price,source_account_import_id,target_app_id,target_channel_id,target_order_id, + normalized_payload_digest,mapping_notes,imported_by) + SELECT account.tenant_id,account.source_tenant_id, + '55555555-5555-4555-8555-555555555555'::uuid,repeat('c',64),orders.merchant_order_id, + 'refunded',1,2,CASE account.tenant_id WHEN 10 THEN 1 ELSE 0 END, + CASE account.tenant_id WHEN 10 THEN 1000 ELSE 0 END, + account.id,account.target_app_id,account.target_channel_id,orders.id, + repeat('d',64),'["raw payload omitted"]',7 + FROM pay_legacy_account_import account JOIN pay_order orders + ON orders.tenant_id=account.tenant_id AND orders.app_id=account.target_app_id; + INSERT INTO pay_legacy_transaction_payment_import + (tenant_id,transaction_import_id,source_payment_id,source_status,source_provider, + source_event_count,source_event_digest,target_extension_id) + SELECT audit.tenant_id,audit.id, + CASE audit.tenant_id WHEN 10 THEN '66666666-6666-4666-8666-666666666666'::uuid + ELSE '77777777-7777-4777-8777-777777777777'::uuid END, + 'refunded','wechat_pay',2,repeat('e',64),extension.id + FROM pay_legacy_transaction_import audit JOIN pay_order_extension extension + ON extension.tenant_id=audit.tenant_id AND extension.order_id=audit.target_order_id; + INSERT INTO pay_legacy_transaction_refund_import + (tenant_id,transaction_import_id,source_refund_id,source_status,target_refund_id) + SELECT audit.tenant_id,audit.id,'88888888-8888-4888-8888-888888888888','succeeded',refund.id + FROM pay_legacy_transaction_import audit JOIN pay_refund refund + ON refund.tenant_id=audit.tenant_id AND refund.order_id=audit.target_order_id + WHERE audit.tenant_id=10; + """); + + assertThat(queryLong(schema, "SELECT count(*) FROM pay_legacy_transaction_import")).isEqualTo(2L); + assertThat(queryLong(schema, "SELECT count(*) FROM pay_legacy_transaction_payment_import")).isEqualTo(2L); + assertThat(queryLong(schema, "SELECT count(*) FROM pay_legacy_transaction_refund_import")).isEqualTo(1L); + assertThat(queryLong(schema, """ + SELECT count(*) FROM information_schema.columns + WHERE table_schema=current_schema() + AND table_name LIKE 'pay_legacy_transaction%import' + AND column_name IN ('raw_payload','payload','channel_notify_data','config','secret_json') + """)).isZero(); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO pay_legacy_transaction_import + (tenant_id,source_tenant_id,source_order_id,source_checksum_sha256,source_order_no, + source_order_status,source_payment_count,source_payment_event_count,source_refund_count, + source_refunded_price,source_account_import_id,target_app_id,target_channel_id,target_order_id, + normalized_payload_digest,mapping_notes,imported_by) + SELECT 10,account.source_tenant_id,'99999999-9999-4999-8999-999999999999',repeat('f',64), + 'CROSS','paid',1,0,0,0,account.id,account.target_app_id,account.target_channel_id, + orders.id,repeat('1',64),'[]',7 + FROM pay_legacy_account_import account CROSS JOIN pay_order orders + WHERE account.tenant_id=10 AND orders.tenant_id=20 + """)).hasMessageContaining("fk_pay_legacy_transaction_order"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO pay_legacy_transaction_payment_import + (tenant_id,transaction_import_id,source_payment_id,source_status,source_provider, + source_event_count,source_event_digest,target_extension_id) + SELECT audit.tenant_id,audit.id,'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa','paid','wechat_pay',1,NULL, + orders.extension_id FROM pay_legacy_transaction_import audit JOIN pay_order orders + ON orders.tenant_id=audit.tenant_id AND orders.id=audit.target_order_id + WHERE audit.tenant_id=10 + """)).hasMessageContaining("ck_pay_legacy_payment_event_digest"); + assertThat(queryStrings(schema, """ + SELECT id || ':' || permission FROM system_menu WHERE id IN (6898,6899) ORDER BY id + """)).containsExactly( + "6898:pay:legacy-transaction:query", "6899:pay:legacy-transaction:import"); + assertThat(queryLong(schema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4350' AND success=true")) + .isEqualTo(1L); + + String conflictSchema = createSchema("legacy_pay_transaction_global_conflict"); + configureFlyway(conflictSchema, false).target("4340").load().migrate(); + execute(conflictSchema, "CREATE TABLE pay_legacy_transaction_import (id BIGINT PRIMARY KEY)"); + assertThatThrownBy(() -> configureFlyway(conflictSchema, false).load().migrate()) + .hasMessageContaining("Existing pay_legacy_transaction_import requires explicit tenant mapping before V4350"); + assertThat(queryLong(conflictSchema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4350' AND success=true")) + .isZero(); + } + + @Test + void shouldActivateTenantScopedNativePayTransferWalletAndMenus() throws SQLException { + String schema = createSchema("native_pay_transfer_wallet"); + createSystemMenuFixture(schema); + configureFlyway(schema, true).load().migrate(); + + execute(schema, """ + INSERT INTO pay_app + (tenant_id,app_key,name,status,order_notify_url,refund_notify_url,transfer_notify_url) + VALUES + (10,'wallet','Wallet A',0,'https://a.example/pay','https://a.example/refund','https://a.example/transfer'), + (20,'wallet','Wallet B',0,'https://b.example/pay','https://b.example/refund','https://b.example/transfer'); + INSERT INTO pay_channel(tenant_id,code,status,fee_rate,app_id,config) + SELECT tenant_id,'wallet',0,0,id,'{}' FROM pay_app WHERE app_key='wallet'; + INSERT INTO pay_transfer + (tenant_id,no,app_id,channel_id,channel_code,merchant_transfer_id,subject,price, + user_account,status,notify_url,user_ip,channel_extras) + SELECT app.tenant_id,'T-SHARED',app.id,channel.id,channel.code,'merchant-shared', + 'Reward',1000,'42',0,app.transfer_notify_url,'127.0.0.1','{}' + FROM pay_app app JOIN pay_channel channel + ON channel.tenant_id=app.tenant_id AND channel.app_id=app.id + WHERE app.app_key='wallet'; + INSERT INTO pay_wallet(tenant_id,user_id,user_type) + VALUES(10,42,1),(20,42,1); + INSERT INTO pay_wallet_recharge_package(tenant_id,name,pay_price,bonus_price,status) + VALUES(10,'Starter',1000,100,0),(20,'Starter',1000,100,0); + INSERT INTO pay_wallet_transaction + (tenant_id,no,wallet_id,biz_type,biz_id,title,price,balance) + SELECT tenant_id,'W-' || tenant_id,id,5,'adjust-1','Initial adjustment',100,100 + FROM pay_wallet; + """); + + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || no || ':' || merchant_transfer_id + FROM pay_transfer ORDER BY tenant_id + """)).containsExactly( + "10:T-SHARED:merchant-shared", "20:T-SHARED:merchant-shared"); + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || user_id || ':' || balance + FROM pay_wallet ORDER BY tenant_id + """)).containsExactly("10:42:0", "20:42:0"); + assertThat(queryLong(schema, "SELECT count(*) FROM pay_wallet_transaction")).isEqualTo(2L); + assertThat(queryLong(schema, "SELECT count(*) FROM pay_wallet_recharge_package")).isEqualTo(2L); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO pay_wallet(tenant_id,user_id,user_type) VALUES(10,42,1) + """)).hasMessageContaining("uk_pay_wallet_tenant_user_active"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO pay_wallet_transaction + (tenant_id,no,wallet_id,biz_type,biz_id,title,price,balance) + SELECT 10,'W-CROSS',id,3,'cross','Cross tenant',-10,0 + FROM pay_wallet WHERE tenant_id=20 + """)).hasMessageContaining("fk_pay_wallet_transaction_wallet"); + assertThatThrownBy(() -> execute(schema, + "UPDATE pay_wallet SET balance=-1 WHERE tenant_id=10")) + .hasMessageContaining("ck_pay_wallet_amounts"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO pay_transfer + (tenant_id,no,app_id,channel_id,channel_code,merchant_transfer_id,subject,price, + user_account,status) + SELECT 10,'T-CROSS',app.id,channel.id,channel.code,'merchant-cross','Bad',100,'42',0 + FROM pay_app app JOIN pay_channel channel + ON channel.tenant_id=app.tenant_id AND channel.app_id=app.id + WHERE app.tenant_id=20 + """)).hasMessageContaining("fk_pay_transfer_app"); + assertThat(queryStrings(schema, """ + SELECT id || ':' || component || ':' || component_name + FROM system_menu WHERE id IN (6900,6903,6906) ORDER BY id + """)).containsExactly( + "6900:pay/transfer/index:EducationPayTransfer", + "6903:pay/wallet/balance/index:EducationPayWallet", + "6906:pay/wallet/rechargePackage/index:EducationPayWalletPackage"); + assertThat(queryStrings(schema, """ + SELECT permission FROM system_menu WHERE id BETWEEN 6901 AND 6911 AND type=3 ORDER BY id + """)).containsExactly( + "pay:transfer:query", "pay:transfer:export", "pay:wallet:query", + "pay:wallet:update-balance", "pay:wallet-recharge-package:query", + "pay:wallet-recharge-package:create", "pay:wallet-recharge-package:update", + "pay:wallet-recharge-package:delete", "pay:wallet-recharge:refund"); + assertThat(queryLong(schema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4360' AND success=true")) + .isEqualTo(1L); + + String conflictSchema = createSchema("native_pay_wallet_global_conflict"); + configureFlyway(conflictSchema, false).target("4350").load().migrate(); + execute(conflictSchema, "CREATE TABLE pay_wallet (id BIGINT PRIMARY KEY)"); + assertThatThrownBy(() -> configureFlyway(conflictSchema, false).load().migrate()) + .hasMessageContaining("Existing pay_wallet requires explicit tenant mapping before V4360"); + assertThat(queryLong(conflictSchema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4360' AND success=true")) + .isZero(); + } + + @Test + void shouldActivateTenantScopedNativeMallProductAndMenus() throws SQLException { + String schema = createSchema("native_mall_product"); + createSystemMenuFixture(schema); + configureFlyway(schema, true).load().migrate(); + + execute(schema, """ + INSERT INTO product_brand(id,tenant_id,name,pic_url,sort,status) + VALUES(100,10,'Education','https://a.example/brand.png',0,0), + (100,20,'Education','https://b.example/brand.png',0,0), + (101,20,'Tenant B only','https://b.example/tenant-only.png',0,0); + INSERT INTO product_category(id,tenant_id,parent_id,name,pic_url,sort,status) + VALUES(200,10,0,'Courses','https://a.example/root.png',0,0), + (200,20,0,'Courses','https://b.example/root.png',0,0), + (201,10,200,'Exam Courses','https://a.example/child.png',0,0), + (201,20,200,'Exam Courses','https://b.example/child.png',0,0), + (202,20,0,'Tenant B only','https://b.example/tenant-only-category.png',0,0); + INSERT INTO product_property(id,tenant_id,name) VALUES(300,10,'Edition'),(300,20,'Edition'); + INSERT INTO product_property_value(id,tenant_id,property_id,name) + VALUES(301,10,300,'2026'),(301,20,300,'2026'); + INSERT INTO product_spu + (id,tenant_id,name,keyword,introduction,description,category_id,brand_id,pic_url, + slider_pic_urls,sort,status,spec_type,price,market_price,cost_price,stock, + delivery_types,give_integral,sub_commission_type,sales_count,virtual_sales_count,browse_count) + VALUES + (400,10,'Exam Course','exam','Course','Details',201,100,'https://a.example/spu.png', + '[]',0,1,false,1000,1200,500,10,'2',0,false,0,0,0), + (400,20,'Exam Course','exam','Course','Details',201,100,'https://b.example/spu.png', + '[]',0,1,false,1000,1200,500,10,'2',0,false,0,0,0); + INSERT INTO product_sku + (id,tenant_id,spu_id,properties,price,market_price,cost_price,pic_url,stock,sales_count) + VALUES(500,10,400,'[]',1000,1200,500,'https://a.example/sku.png',10,0), + (500,20,400,'[]',1000,1200,500,'https://b.example/sku.png',10,0); + INSERT INTO product_favorite(id,tenant_id,user_id,spu_id) + VALUES(600,10,42,400),(600,20,42,400); + INSERT INTO product_browse_history(id,tenant_id,user_id,spu_id) + VALUES(700,10,42,400),(700,20,42,400); + INSERT INTO product_comment + (id,tenant_id,user_id,user_nickname,user_avatar,spu_id,spu_name,sku_id,sku_pic_url, + scores,description_scores,benefit_scores,content) + VALUES(800,10,0,'Platform','https://a.example/avatar.png',400,'Exam Course',500, + 'https://a.example/sku.png',5,5,5,'Administrative comment'); + """); + + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || id || ':' || name FROM product_spu ORDER BY tenant_id + """)).containsExactly("10:400:Exam Course", "20:400:Exam Course"); + assertThat(queryLong(schema, "SELECT count(*) FROM product_sku")).isEqualTo(2L); + assertThat(queryLong(schema, "SELECT count(*) FROM product_comment WHERE user_id=0")).isEqualTo(1L); + assertThat(queryLong(schema, """ + SELECT count(*) + FROM pg_class c + JOIN pg_namespace n ON n.oid=c.relnamespace + WHERE n.nspname=current_schema() AND c.relkind='S' AND c.relname IN ( + 'product_brand_seq','product_category_seq','product_property_seq', + 'product_property_value_seq','product_spu_seq','product_sku_seq', + 'product_comment_seq','product_favorite_seq','product_browse_history_seq') + """)).isEqualTo(9L); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO product_brand(id,tenant_id,name,pic_url,sort,status) + VALUES(101,10,'Education','https://a.example/duplicate.png',0,0) + """)).hasMessageContaining("uk_product_brand_tenant_name_active"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO product_property_value(tenant_id,property_id,name) + VALUES(10,300,'2026') + """)).hasMessageContaining("uk_product_property_value_tenant_name_active"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO product_sku(tenant_id,spu_id,properties,price,pic_url,stock,sales_count) + VALUES(10,400,'[]',-1,'https://a.example/bad.png',1,0) + """)).hasMessageContaining("ck_product_sku_amounts"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO product_spu + (tenant_id,name,keyword,introduction,description,category_id,brand_id,pic_url,sort, + spec_type,price,stock,delivery_types,give_integral,sub_commission_type) + VALUES(10,'Cross Brand','cross','Cross','Cross tenant',201,101,'https://a.example/cross.png',0, + false,100,1,'2',0,false) + """)).hasMessageContaining("fk_product_spu_brand"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO product_sku(tenant_id,spu_id,properties,price,pic_url,stock,sales_count) + VALUES(10,999,'[]',100,'https://a.example/cross.png',1,0) + """)).hasMessageContaining("fk_product_sku_spu"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO product_category(tenant_id,parent_id,name,pic_url,sort,status) + VALUES(10,201,'Third level','https://a.example/third.png',0,0) + """)).hasMessageContaining("ck_product_category_level"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO product_category(tenant_id,parent_id,name,pic_url,sort,status) + VALUES(10,202,'Cross tenant','https://a.example/missing.png',0,0) + """)).hasMessageContaining("fk_product_category_parent"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO product_comment + (tenant_id,user_id,spu_id,spu_name,sku_id,scores,description_scores, + benefit_scores,content) + VALUES(10,42,400,'Exam Course',500,6,5,5,'Invalid score') + """)).hasMessageContaining("ck_product_comment_scores"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO product_favorite(tenant_id,user_id,spu_id) VALUES(10,42,400) + """)).hasMessageContaining("uk_product_favorite_tenant_user_spu_active"); + assertThat(queryStrings(schema, """ + SELECT id || ':' || component || ':' || component_name + FROM system_menu WHERE id IN (6921,6927,6932,6937,6942) ORDER BY id + """)).containsExactly( + "6921:mall/product/spu/index:EducationProductSpu", + "6927:mall/product/category/index:EducationProductCategory", + "6932:mall/product/brand/index:EducationProductBrand", + "6937:mall/product/property/index:EducationProductProperty", + "6942:mall/product/comment/index:EducationProductComment"); + assertThat(queryLong(schema, + "SELECT count(*) FROM system_menu WHERE id BETWEEN 6920 AND 6944")).isEqualTo(25L); + assertThat(queryLong(schema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4370' AND success=true")) + .isEqualTo(1L); + + String conflictSchema = createSchema("native_mall_product_global_conflict"); + configureFlyway(conflictSchema, false).target("4360").load().migrate(); + execute(conflictSchema, "CREATE TABLE product_spu (id BIGINT PRIMARY KEY)"); + assertThatThrownBy(() -> configureFlyway(conflictSchema, false).load().migrate()) + .hasMessageContaining("Existing product_spu requires explicit tenant mapping before V4370"); + assertThat(queryLong(conflictSchema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4370' AND success=true")) + .isZero(); + } + + @Test + void shouldActivateTenantScopedNativeMallCouponsAndMenus() throws SQLException { + String schema = createSchema("native_mall_coupon"); + createSystemMenuFixture(schema); + configureFlyway(schema, true).load().migrate(); + + execute(schema, """ + INSERT INTO promotion_coupon_template + (id,tenant_id,name,status,total_count,take_limit_count,take_type,use_price, + product_scope,validity_type,valid_start_time,valid_end_time,discount_type, + fixed_start_term,fixed_end_term,discount_percent,discount_price, + discount_limit_price,take_count,use_count) + VALUES + (900,10,'Enrollment discount',0,100,1,1,1000,1,1, + TIMESTAMP '2026-01-01 00:00:00',TIMESTAMP '2030-01-01 00:00:00',1, + NULL,NULL,NULL,500,NULL,1,0), + (900,20,'Enrollment discount',0,100,1,1,1000,1,1, + TIMESTAMP '2026-01-01 00:00:00',TIMESTAMP '2030-01-01 00:00:00',1, + NULL,NULL,NULL,500,NULL,1,0), + (901,20,'Tenant B only',0,-1,-1,2,0,1,2, + NULL,NULL,2,0,30,80,NULL,2000,0,0); + INSERT INTO promotion_coupon + (id,tenant_id,template_id,name,status,user_id,take_type,use_price, + valid_start_time,valid_end_time,product_scope,discount_type,discount_price) + VALUES + (910,10,900,'Enrollment discount',1,42,1,1000, + TIMESTAMP '2026-01-01 00:00:00',TIMESTAMP '2030-01-01 00:00:00',1,1,500), + (910,20,900,'Enrollment discount',1,42,1,1000, + TIMESTAMP '2026-01-01 00:00:00',TIMESTAMP '2030-01-01 00:00:00',1,1,500); + """); + + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || id || ':' || name + FROM promotion_coupon_template WHERE id=900 ORDER BY tenant_id + """)).containsExactly( + "10:900:Enrollment discount", "20:900:Enrollment discount"); + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || id || ':' || user_id + FROM promotion_coupon WHERE id=910 ORDER BY tenant_id + """)).containsExactly("10:910:42", "20:910:42"); + assertThat(queryLong(schema, """ + SELECT count(*) + FROM pg_class c + JOIN pg_namespace n ON n.oid=c.relnamespace + WHERE n.nspname=current_schema() AND c.relkind='S' + AND c.relname IN ('promotion_coupon_template_seq','promotion_coupon_seq') + """)).isEqualTo(2L); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO promotion_coupon + (tenant_id,template_id,name,status,user_id,take_type,use_price, + valid_start_time,valid_end_time,product_scope,discount_type,discount_price) + VALUES(10,901,'Cross tenant',1,42,2,0, + TIMESTAMP '2026-01-01 00:00:00',TIMESTAMP '2030-01-01 00:00:00',1,1,100) + """)).hasMessageContaining("fk_promotion_coupon_template"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO promotion_coupon_template + (tenant_id,name,status,total_count,take_limit_count,take_type,use_price, + product_scope,validity_type,fixed_start_term,fixed_end_term,discount_type, + discount_percent,discount_limit_price,take_count,use_count) + VALUES(10,'Over issued',0,1,1,1,0,1,2,0,30,2,80,100,2,0) + """)).hasMessageContaining("ck_promotion_coupon_template_counts"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO promotion_coupon_template + (tenant_id,name,status,total_count,take_limit_count,take_type,use_price, + product_scope,validity_type,fixed_start_term,fixed_end_term,discount_type, + discount_percent,discount_limit_price,take_count,use_count) + VALUES(10,'Invalid percentage',0,10,1,1,0,1,2,0,30,2,100,100,0,0) + """)).hasMessageContaining("ck_promotion_coupon_template_discount"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO promotion_coupon + (tenant_id,template_id,name,status,user_id,take_type,use_price, + valid_start_time,valid_end_time,product_scope,discount_type,discount_price) + VALUES(10,900,'Missing use order',2,42,1,0, + TIMESTAMP '2026-01-01 00:00:00',TIMESTAMP '2030-01-01 00:00:00',1,1,100) + """)).hasMessageContaining("ck_promotion_coupon_use"); + assertThat(queryStrings(schema, """ + SELECT id || ':' || component || ':' || component_name + FROM system_menu WHERE id IN (6951,6956) ORDER BY id + """)).containsExactly( + "6951:mall/promotion/coupon/template/index:EducationPromotionCouponTemplate", + "6956:mall/promotion/coupon/index:EducationPromotionCoupon"); + assertThat(queryStrings(schema, """ + SELECT permission FROM system_menu + WHERE id BETWEEN 6952 AND 6959 AND type=3 ORDER BY id + """)).containsExactly( + "promotion:coupon-template:query", "promotion:coupon-template:create", + "promotion:coupon-template:update", "promotion:coupon-template:delete", + "promotion:coupon:query", "promotion:coupon:send", "promotion:coupon:delete"); + assertThat(queryLong(schema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4380' AND success=true")) + .isEqualTo(1L); + + String conflictSchema = createSchema("native_mall_coupon_global_conflict"); + configureFlyway(conflictSchema, false).target("4370").load().migrate(); + execute(conflictSchema, "CREATE TABLE promotion_coupon (id BIGINT PRIMARY KEY)"); + assertThatThrownBy(() -> configureFlyway(conflictSchema, false).load().migrate()) + .hasMessageContaining("Existing promotion_coupon requires explicit tenant mapping before V4380"); + assertThat(queryLong(conflictSchema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4380' AND success=true")) + .isZero(); + } + + @Test + void shouldActivateTenantScopedNativeMallTradeOrdersAndMenus() throws SQLException { + String schema = createSchema("native_mall_trade_order"); + createSystemMenuFixture(schema); + configureFlyway(schema, true).load().migrate(); + + execute(schema, """ + INSERT INTO product_brand(id,tenant_id,name,pic_url,sort,status) + VALUES (1,10,'Education','https://example.test/brand.png',0,0), + (1,20,'Education','https://example.test/brand.png',0,0); + INSERT INTO product_category(id,tenant_id,parent_id,name,pic_url,sort,status) + VALUES (1,10,0,'Course','https://example.test/category.png',0,0), + (1,20,0,'Course','https://example.test/category.png',0,0); + INSERT INTO product_spu + (id,tenant_id,name,keyword,introduction,description,category_id,brand_id,pic_url, + sort,status,spec_type,price,stock,delivery_types,give_integral,sub_commission_type, + sales_count,virtual_sales_count,browse_count) + VALUES + (100,10,'Course A','course','Course A','Course A',1,1,'https://example.test/spu.png', + 0,0,false,1000,10,'[1]',0,false,0,0,0), + (100,20,'Course A','course','Course A','Course A',1,1,'https://example.test/spu.png', + 0,0,false,1000,10,'[1]',0,false,0,0,0); + INSERT INTO product_sku + (id,tenant_id,spu_id,price,pic_url,stock,sales_count) + VALUES (101,10,100,1000,'https://example.test/sku.png',10,0), + (101,20,100,1000,'https://example.test/sku.png',10,0); + + INSERT INTO trade_config + (id,tenant_id,after_sale_refund_reasons,after_sale_return_reasons, + delivery_express_free_enabled,delivery_express_free_price,delivery_pick_up_enabled, + brokerage_enabled,brokerage_enabled_condition,brokerage_bind_mode, + brokerage_first_percent,brokerage_second_percent,brokerage_withdraw_min_price, + brokerage_withdraw_fee_percent,brokerage_frozen_days,brokerage_withdraw_types) + VALUES + (50,10,'["Other"]','["Other"]',false,0,false,false,1,1,0,0,0,0,0,'[1]'), + (50,20,'["Other"]','["Other"]',false,0,false,false,1,1,0,0,0,0,0,'[1]'); + INSERT INTO trade_cart(id,tenant_id,user_id,spu_id,sku_id,count,selected) + VALUES (200,10,42,100,101,1,true), + (200,20,42,100,101,1,true), + (201,20,43,100,101,1,true); + INSERT INTO trade_order + (id,tenant_id,no,type,terminal,user_id,user_ip,status,product_count,pay_status, + total_price,discount_price,delivery_price,adjust_price,pay_price,delivery_type, + receiver_name,receiver_mobile,receiver_area_id,receiver_detail_address, + refund_status,refund_price,coupon_price,use_point,point_price,give_point, + refund_point,vip_price) + VALUES + (300,10,'ORDER-SAME',0,20,42,'127.0.0.1',0,1,false, + 1000,0,0,0,1000,1,'Learner','13800000000',1,'Room A',0,0,0,0,0,0,0,0), + (300,20,'ORDER-SAME',0,20,42,'127.0.0.1',0,1,false, + 1000,0,0,0,1000,1,'Learner','13800000000',1,'Room A',0,0,0,0,0,0,0,0); + INSERT INTO trade_order_item + (id,tenant_id,user_id,order_id,cart_id,spu_id,spu_name,sku_id,count, + price,discount_price,delivery_price,adjust_price,pay_price,coupon_price, + point_price,use_point,give_point,vip_price,after_sale_status) + VALUES (400,10,42,300,200,100,'Course A',101,1, + 1000,0,0,0,1000,0,0,0,0,0,0); + INSERT INTO trade_order_log + (id,tenant_id,user_id,user_type,order_id,before_status,after_status,operate_type,content) + VALUES (500,10,0,0,300,NULL,0,1,'Order created'); + """); + + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || id || ':' || no + FROM trade_order WHERE id=300 ORDER BY tenant_id + """)).containsExactly("10:300:ORDER-SAME", "20:300:ORDER-SAME"); + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || id FROM trade_config WHERE id=50 ORDER BY tenant_id + """)).containsExactly("10:50", "20:50"); + assertThat(queryLong(schema, """ + SELECT count(*) + FROM pg_class c + JOIN pg_namespace n ON n.oid=c.relnamespace + WHERE n.nspname=current_schema() AND c.relkind='S' + AND c.relname IN ('trade_config_seq','trade_cart_seq','trade_order_seq', + 'trade_order_item_seq','trade_order_log_seq') + """)).isEqualTo(5L); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO trade_order_item + (tenant_id,user_id,order_id,cart_id,spu_id,spu_name,sku_id,count, + price,discount_price,delivery_price,adjust_price,pay_price,coupon_price, + point_price,use_point,give_point,vip_price,after_sale_status) + VALUES (10,42,300,201,100,'Cross tenant cart',101,1, + 1000,0,0,0,1000,0,0,0,0,0,0) + """)).hasMessageContaining("fk_trade_order_item_cart"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO trade_order + (tenant_id,no,type,terminal,user_id,user_ip,status,product_count,pay_status, + total_price,discount_price,delivery_price,adjust_price,pay_price,delivery_type, + receiver_name,receiver_mobile,receiver_area_id,receiver_detail_address, + refund_status,refund_price,coupon_price,use_point,point_price,give_point, + refund_point,vip_price) + VALUES (10,'INVALID-CANCEL',0,20,42,'127.0.0.1',40,1,false, + 1000,0,0,0,1000,1,'Learner','13800000000',1,'Room A', + 0,0,0,0,0,0,0,0) + """)).hasMessageContaining("ck_trade_order_cancel_state"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO trade_config + (tenant_id,after_sale_refund_reasons,after_sale_return_reasons, + delivery_express_free_enabled,delivery_express_free_price,delivery_pick_up_enabled, + brokerage_enabled,brokerage_enabled_condition,brokerage_bind_mode, + brokerage_first_percent,brokerage_second_percent,brokerage_withdraw_min_price, + brokerage_withdraw_fee_percent,brokerage_frozen_days,brokerage_withdraw_types) + VALUES (10,'["Other"]','["Other"]',false,0,false,false,1,1,0,0,0,0,0,'[1]') + """)).hasMessageContaining("uk_trade_config_tenant_active"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO trade_config + (tenant_id,after_sale_refund_reasons,after_sale_return_reasons, + delivery_express_free_enabled,delivery_express_free_price,delivery_pick_up_enabled, + brokerage_enabled,brokerage_enabled_condition,brokerage_bind_mode, + brokerage_first_percent,brokerage_second_percent,brokerage_withdraw_min_price, + brokerage_withdraw_fee_percent,brokerage_frozen_days,brokerage_withdraw_types) + VALUES (30,'["Other"]','["Other"]',false,0,false,false,0,0,0,0,0,0,0,'[1]') + """)).hasMessageContaining("ck_trade_config_brokerage_modes"); + assertThat(queryStrings(schema, """ + SELECT id || ':' || component || ':' || component_name + FROM system_menu WHERE id IN (6961,6965) ORDER BY id + """)).containsExactly( + "6961:mall/trade/order/index:EducationTradeOrder", + "6965:mall/trade/config/index:EducationTradeConfig"); + assertThat(queryStrings(schema, """ + SELECT permission FROM system_menu + WHERE id BETWEEN 6962 AND 6967 AND type=3 ORDER BY id + """)).containsExactly( + "trade:order:query", "trade:order:update", "trade:order:pick-up", + "trade:config:query", "trade:config:save"); + assertThat(queryLong(schema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4390' AND success=true")) + .isEqualTo(1L); + + String conflictSchema = createSchema("native_mall_trade_order_global_conflict"); + configureFlyway(conflictSchema, false).target("4380").load().migrate(); + execute(conflictSchema, "CREATE TABLE trade_order (id BIGINT PRIMARY KEY)"); + assertThatThrownBy(() -> configureFlyway(conflictSchema, false).load().migrate()) + .hasMessageContaining("Existing trade_order requires explicit tenant mapping before V4390"); + assertThat(queryLong(conflictSchema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4390' AND success=true")) + .isZero(); + + String deferredConflictSchema = createSchema("native_mall_trade_deferred_conflict"); + configureFlyway(deferredConflictSchema, false).target("4380").load().migrate(); + execute(deferredConflictSchema, "CREATE TABLE trade_after_sale (id BIGINT PRIMARY KEY, tenant_id BIGINT)"); + assertThatThrownBy(() -> configureFlyway(deferredConflictSchema, false).load().migrate()) + .hasMessageContaining("Existing deferred trade_after_sale requires dedicated tenant-safe activation after V4390"); + } + + @Test + void shouldActivateTenantScopedNativeCheckoutPromotionsAndMenus() throws SQLException { + String schema = createSchema("native_mall_checkout_promotions"); + createSystemMenuFixture(schema); + configureFlyway(schema, true).load().migrate(); + execute(schema, """ + INSERT INTO product_brand(id,tenant_id,name,pic_url,sort,status) + VALUES (1,10,'Education','https://example.test/brand.png',0,0), + (1,20,'Education','https://example.test/brand.png',0,0); + INSERT INTO product_category(id,tenant_id,parent_id,name,pic_url,sort,status) + VALUES (1,10,0,'Course','https://example.test/category.png',0,0), + (1,20,0,'Course','https://example.test/category.png',0,0); + INSERT INTO product_spu + (id,tenant_id,name,keyword,introduction,description,category_id,brand_id,pic_url, + sort,status,spec_type,price,stock,delivery_types,give_integral,sub_commission_type, + sales_count,virtual_sales_count,browse_count) + VALUES + (100,10,'Course A','course','Course A','Course A',1,1,'https://example.test/spu.png', + 0,0,false,1000,10,'[1]',0,false,0,0,0), + (100,20,'Course A','course','Course A','Course A',1,1,'https://example.test/spu.png', + 0,0,false,1000,10,'[1]',0,false,0,0,0); + INSERT INTO product_sku(id,tenant_id,spu_id,price,pic_url,stock,sales_count) + VALUES (101,10,100,1000,'https://example.test/sku.png',10,0), + (101,20,100,1000,'https://example.test/sku.png',10,0); + + INSERT INTO promotion_discount_activity + (id,tenant_id,name,status,start_time,end_time) + VALUES + (300,10,'Summer discount',0,'2020-01-01','2099-01-01'), + (300,20,'Summer discount',0,'2020-01-01','2099-01-01'), + (301,20,'Tenant 20 only',0,'2020-01-01','2099-01-01'); + INSERT INTO promotion_discount_product + (id,tenant_id,activity_id,spu_id,sku_id,discount_type,discount_percent, + activity_name,activity_status,activity_start_time,activity_end_time) + VALUES + (400,10,300,100,101,2,8000,'Summer discount',0,'2020-01-01','2099-01-01'), + (400,20,300,100,101,2,8000,'Summer discount',0,'2020-01-01','2099-01-01'); + INSERT INTO promotion_reward_activity + (id,tenant_id,name,status,start_time,end_time,condition_type,product_scope, + product_scope_values,rules) + VALUES + (500,10,'Course reward',0,'2020-01-01','2099-01-01',10,2,'100', + '[{"limit":1000,"discountPrice":100,"freeDelivery":false,"point":10,"giveCouponTemplateCounts":null}]'), + (500,20,'Course reward',0,'2020-01-01','2099-01-01',10,2,'100', + '[{"limit":1000,"discountPrice":100,"freeDelivery":true,"point":0,"giveCouponTemplateCounts":{"900":2}}]'); + """); + + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || id || ':' || name + FROM promotion_discount_activity WHERE id=300 ORDER BY tenant_id + """)).containsExactly( + "10:300:Summer discount", "20:300:Summer discount"); + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || id || ':' || product_scope_values + FROM promotion_reward_activity WHERE id=500 ORDER BY tenant_id + """)).containsExactly("10:500:100", "20:500:100"); + assertThat(queryLong(schema, """ + SELECT count(*) FROM promotion_reward_activity + WHERE POSITION(',' || CAST(100 AS VARCHAR) || ',' + IN ',' || product_scope_values || ',')>0 + """)).isEqualTo(2L); + assertThat(queryLong(schema, """ + SELECT count(*) + FROM pg_class c + JOIN pg_namespace n ON n.oid=c.relnamespace + WHERE n.nspname=current_schema() AND c.relkind='S' + AND c.relname IN ('promotion_discount_activity_seq', + 'promotion_discount_product_seq', + 'promotion_reward_activity_seq') + """)).isEqualTo(3L); + + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO promotion_discount_product + (tenant_id,activity_id,spu_id,sku_id,discount_type,discount_price, + activity_name,activity_status,activity_start_time,activity_end_time) + VALUES (10,301,100,101,1,100,'Cross tenant',1,'2020-01-01','2099-01-01') + """)).hasMessageContaining("fk_promotion_discount_product_activity"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO promotion_discount_activity + (id,tenant_id,name,status,start_time,end_time) + VALUES (302,10,'Duplicate SKU activity',0,'2020-01-01','2099-01-01'); + INSERT INTO promotion_discount_product + (tenant_id,activity_id,spu_id,sku_id,discount_type,discount_price, + activity_name,activity_status,activity_start_time,activity_end_time) + VALUES (10,302,100,101,1,100,'Duplicate SKU activity',0,'2020-01-01','2099-01-01'); + """)).hasMessageContaining("uk_promotion_discount_product_tenant_sku_active"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO promotion_reward_activity + (tenant_id,name,status,start_time,end_time,condition_type,product_scope, + product_scope_values,rules) + VALUES (10,'Invalid rules',0,'2020-01-01','2099-01-01',20,2,'100', + '[{"limit":0,"discountPrice":100,"freeDelivery":false}]') + """)).hasMessageContaining("ck_promotion_reward_activity_rules"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO promotion_reward_activity + (tenant_id,name,status,start_time,end_time,condition_type,product_scope, + product_scope_values,rules) + VALUES (10,'Invalid scope',0,'2020-01-01','2099-01-01',20,3,'100,0', + '[{"limit":1,"discountPrice":1,"freeDelivery":false}]') + """)).hasMessageContaining("ck_promotion_reward_activity_scope"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO promotion_reward_activity + (tenant_id,name,status,start_time,end_time,condition_type,product_scope,rules) + VALUES (10,'Missing scoped values',0,'2020-01-01','2099-01-01',20,2, + '[{"limit":1,"discountPrice":1,"freeDelivery":false}]') + """)).hasMessageContaining("ck_promotion_reward_activity_scope"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO promotion_reward_activity + (tenant_id,name,status,start_time,end_time,condition_type,product_scope, + product_scope_values,rules) + VALUES (10,'Missing delivery flag',0,'2020-01-01','2099-01-01',20,2,'100', + '[{"limit":1,"discountPrice":1}]') + """)).hasMessageContaining("ck_promotion_reward_activity_rules"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO promotion_discount_activity + (id,tenant_id,name,status,start_time,end_time) + VALUES (303,10,'Invalid percent',0,'2020-01-01','2099-01-01'); + INSERT INTO promotion_discount_product + (tenant_id,activity_id,spu_id,sku_id,discount_type, + activity_name,activity_status,activity_start_time,activity_end_time) + VALUES (10,303,100,101,2,'Invalid percent',1,'2020-01-01','2099-01-01') + """)).hasMessageContaining("ck_promotion_discount_product_value"); + + assertThat(queryStrings(schema, """ + SELECT id || ':' || component || ':' || component_name + FROM system_menu WHERE id IN (6971,6977) ORDER BY id + """)).containsExactly( + "6971:mall/promotion/discountActivity/index:PromotionDiscountActivity", + "6977:mall/promotion/rewardActivity/index:PromotionRewardActivity"); + assertThat(queryStrings(schema, """ + SELECT permission FROM system_menu + WHERE id BETWEEN 6972 AND 6982 AND type=3 ORDER BY id + """)).containsExactly( + "promotion:discount-activity:query", + "promotion:discount-activity:create", + "promotion:discount-activity:update", + "promotion:discount-activity:delete", + "promotion:discount-activity:close", + "promotion:reward-activity:query", + "promotion:reward-activity:create", + "promotion:reward-activity:update", + "promotion:reward-activity:delete", + "promotion:reward-activity:close"); + assertThat(queryLong(schema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4400' AND success=true")) + .isEqualTo(1L); + + String conflictSchema = createSchema("native_mall_checkout_promotions_global_conflict"); + configureFlyway(conflictSchema, false).target("4390").load().migrate(); + execute(conflictSchema, "CREATE TABLE promotion_reward_activity (id BIGINT PRIMARY KEY)"); + assertThatThrownBy(() -> configureFlyway(conflictSchema, false).load().migrate()) + .hasMessageContaining("Existing promotion_reward_activity requires explicit tenant mapping before V4400"); + assertThat(queryLong(conflictSchema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4400' AND success=true")) + .isZero(); + } + + @Test + void shouldActivateTenantScopedNativeTradeDeliveryAndMenus() throws SQLException { + String schema = createSchema("native_trade_delivery"); + createSystemMenuFixture(schema); + configureFlyway(schema, true).load().migrate(); + execute(schema, """ + INSERT INTO trade_delivery_express(id,tenant_id,code,name,sort,status) + VALUES (100,10,'SF','SF Express',0,0), + (100,20,'SF','Tenant 20 SF',0,0); + INSERT INTO trade_delivery_express_template + (id,tenant_id,name,charge_mode,sort) + VALUES (600,10,'Standard delivery',1,0), + (600,20,'Standard delivery',1,0), + (601,20,'Tenant 20 only',2,1); + INSERT INTO trade_delivery_express_template_charge + (id,tenant_id,template_id,area_ids,charge_mode,start_count,start_price, + extra_count,extra_price) + VALUES (610,10,600,'310000',1,1,500,1,200), + (610,20,600,'310000',1,1,900,1,300); + INSERT INTO trade_delivery_express_template_free + (id,tenant_id,template_id,area_ids,free_price,free_count) + VALUES (620,10,600,'320000',5000,3), + (620,20,600,'320000',10000,5); + INSERT INTO trade_delivery_pick_up_store + (id,tenant_id,name,phone,area_id,detail_address,logo,opening_time,closing_time, + latitude,longitude,verify_user_ids,status) + VALUES (700,10,'Campus store','13800000000',310000,'Building A', + 'https://example.test/store.png','08:00','20:00',31.23,121.47,'7,8',0), + (700,20,'Tenant 20 store','13900000000',310000,'Building B', + 'https://example.test/store.png','09:00','21:00',31.24,121.48,'9',0), + (701,20,'Tenant 20 only','13700000000',310000,'Building C', + 'https://example.test/store.png','09:00','18:00',31.25,121.49,NULL,0); + + INSERT INTO product_brand(id,tenant_id,name,pic_url,sort,status) + VALUES (1,10,'Education','https://example.test/brand.png',0,0), + (1,20,'Education','https://example.test/brand.png',0,0); + INSERT INTO product_category(id,tenant_id,parent_id,name,pic_url,sort,status) + VALUES (1,10,0,'Course','https://example.test/category.png',0,0), + (1,20,0,'Course','https://example.test/category.png',0,0); + INSERT INTO product_spu + (id,tenant_id,name,keyword,introduction,description,category_id,brand_id,pic_url, + sort,status,spec_type,price,stock,delivery_types,delivery_template_id, + give_integral,sub_commission_type,sales_count,virtual_sales_count,browse_count) + VALUES (100,10,'Printed course','course','Printed course','Printed course',1,1, + 'https://example.test/spu.png',0,0,false,1000,10,'[1]',600,0,false,0,0,0), + (100,20,'Printed course','course','Printed course','Printed course',1,1, + 'https://example.test/spu.png',0,0,false,1000,10,'[1]',600,0,false,0,0,0); + INSERT INTO trade_order + (id,tenant_id,no,type,terminal,user_id,user_ip,status,product_count,pay_status, + total_price,discount_price,delivery_price,adjust_price,pay_price,delivery_type, + receiver_name,receiver_mobile,pick_up_store_id,pick_up_verify_code, + refund_status,refund_price,coupon_price,use_point,point_price,give_point, + refund_point,vip_price) + VALUES (800,10,'PICK-UP-SAME',0,20,42,'127.0.0.1',0,1,false, + 1000,0,0,0,1000,2,'Learner','13800000000',700,'12345678', + 0,0,0,0,0,0,0,0), + (800,20,'PICK-UP-SAME',0,20,42,'127.0.0.1',0,1,false, + 1000,0,0,0,1000,2,'Learner','13900000000',700,'87654321', + 0,0,0,0,0,0,0,0); + """); + + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || id || ':' || code + FROM trade_delivery_express WHERE id=100 ORDER BY tenant_id + """)).containsExactly("10:100:SF", "20:100:SF"); + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || id || ':' || start_price + FROM trade_delivery_express_template_charge WHERE id=610 ORDER BY tenant_id + """)).containsExactly("10:610:500", "20:610:900"); + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || id || ':' || pick_up_store_id + FROM trade_order WHERE id=800 ORDER BY tenant_id + """)).containsExactly("10:800:700", "20:800:700"); + assertThat(queryLong(schema, """ + SELECT count(*) + FROM pg_class c + JOIN pg_namespace n ON n.oid=c.relnamespace + WHERE n.nspname=current_schema() AND c.relkind='S' + AND c.relname IN ('trade_delivery_express_seq', + 'trade_delivery_express_template_seq', + 'trade_delivery_express_template_charge_seq', + 'trade_delivery_express_template_free_seq', + 'trade_delivery_pick_up_store_seq') + """)).isEqualTo(5L); + + assertThatThrownBy(() -> execute(schema, """ + UPDATE product_spu SET delivery_template_id=601 + WHERE tenant_id=10 AND id=100 + """)).hasMessageContaining("fk_product_spu_delivery_template"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO trade_delivery_express_template_charge + (tenant_id,template_id,area_ids,charge_mode,start_count,start_price, + extra_count,extra_price) + VALUES (10,600,'310000',2,1,100,1,100) + """)).hasMessageContaining("fk_trade_delivery_express_template_charge_template"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO trade_delivery_express_template_free + (tenant_id,template_id,area_ids,free_price,free_count) + VALUES (10,600,'',1000,1) + """)).hasMessageContaining("ck_trade_delivery_express_template_free_areas"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO trade_delivery_pick_up_store + (tenant_id,name,phone,area_id,detail_address,logo,opening_time,closing_time, + latitude,longitude,status) + VALUES (10,'Invalid location','13600000000',310000,'Building D', + 'https://example.test/store.png','08:00','18:00',91,121,0) + """)).hasMessageContaining("ck_trade_delivery_pick_up_store_location"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO trade_order + (tenant_id,no,type,terminal,user_id,user_ip,status,product_count,pay_status, + total_price,discount_price,delivery_price,adjust_price,pay_price,delivery_type, + receiver_name,receiver_mobile,pick_up_store_id,pick_up_verify_code, + refund_status,refund_price,coupon_price,use_point,point_price,give_point, + refund_point,vip_price) + VALUES (10,'CROSS-TENANT-PICKUP',0,20,42,'127.0.0.1',0,1,false, + 1000,0,0,0,1000,2,'Learner','13800000000',701,'11112222', + 0,0,0,0,0,0,0,0) + """)).hasMessageContaining("fk_trade_order_pick_up_store"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO trade_order + (tenant_id,no,type,terminal,user_id,user_ip,status,product_count,pay_status, + total_price,discount_price,delivery_price,adjust_price,pay_price,delivery_type, + receiver_name,receiver_mobile,pick_up_verify_code, + refund_status,refund_price,coupon_price,use_point,point_price,give_point, + refund_point,vip_price) + VALUES (10,'MISSING-PICKUP',0,20,42,'127.0.0.1',0,1,false, + 1000,0,0,0,1000,2,'Learner','13800000000','22223333', + 0,0,0,0,0,0,0,0) + """)).hasMessageContaining("ck_trade_order_pick_up_store_shape"); + + assertThat(queryStrings(schema, """ + SELECT id || ':' || component || ':' || component_name + FROM system_menu WHERE id IN (6991,6997,7002) ORDER BY id + """)).containsExactly( + "6991:mall/trade/delivery/express/index:Express", + "6997:mall/trade/delivery/expressTemplate/index:ExpressTemplate", + "7002:mall/trade/delivery/pickUpStore/index:PickUpStore"); + assertThat(queryStrings(schema, """ + SELECT permission FROM system_menu + WHERE id BETWEEN 6992 AND 7006 AND type=3 ORDER BY id + """)).containsExactly( + "trade:delivery:express:query", + "trade:delivery:express:create", + "trade:delivery:express:update", + "trade:delivery:express:delete", + "trade:delivery:express:export", + "trade:delivery:express-template:query", + "trade:delivery:express-template:create", + "trade:delivery:express-template:update", + "trade:delivery:express-template:delete", + "trade:delivery:pick-up-store:query", + "trade:delivery:pick-up-store:create", + "trade:delivery:pick-up-store:update", + "trade:delivery:pick-up-store:delete"); + assertThat(queryLong(schema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4410' AND success=true")) + .isEqualTo(1L); + + String conflictSchema = createSchema("native_trade_delivery_global_conflict"); + configureFlyway(conflictSchema, false).target("4400").load().migrate(); + execute(conflictSchema, "CREATE TABLE trade_delivery_pick_up_store (id BIGINT PRIMARY KEY)"); + assertThatThrownBy(() -> configureFlyway(conflictSchema, false).load().migrate()) + .hasMessageContaining("Existing trade_delivery_pick_up_store requires explicit tenant mapping before V4410"); + assertThat(queryLong(conflictSchema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4410' AND success=true")) + .isZero(); + } + + @Test + void shouldActivateTenantScopedNativeTradeAfterSaleAndMenus() throws SQLException { + String schema = createSchema("native_trade_after_sale"); + createSystemMenuFixture(schema); + configureFlyway(schema, true).load().migrate(); + execute(schema, """ + INSERT INTO product_brand(id,tenant_id,name,pic_url,sort,status) + VALUES (1,10,'Education','https://example.test/brand.png',0,0), + (1,20,'Education','https://example.test/brand.png',0,0); + INSERT INTO product_category(id,tenant_id,parent_id,name,pic_url,sort,status) + VALUES (1,10,0,'Course','https://example.test/category.png',0,0), + (1,20,0,'Course','https://example.test/category.png',0,0); + INSERT INTO product_spu + (id,tenant_id,name,keyword,introduction,description,category_id,brand_id,pic_url, + sort,status,spec_type,price,stock,delivery_types,give_integral, + sub_commission_type,sales_count,virtual_sales_count,browse_count) + VALUES (100,10,'Printed course','course','Printed course','Printed course',1,1, + 'https://example.test/spu.png',0,0,false,1000,10,'[1]',0,false,0,0,0), + (100,20,'Printed course','course','Printed course','Printed course',1,1, + 'https://example.test/spu.png',0,0,false,1000,10,'[1]',0,false,0,0,0); + INSERT INTO product_sku(id,tenant_id,spu_id,price,pic_url,stock,sales_count) + VALUES (101,10,100,1000,'https://example.test/sku.png',10,0), + (101,20,100,1000,'https://example.test/sku.png',10,0); + INSERT INTO trade_order + (id,tenant_id,no,type,terminal,user_id,user_ip,status,product_count,pay_status, + total_price,discount_price,delivery_price,adjust_price,pay_price,delivery_type, + receiver_name,receiver_mobile,receiver_area_id,receiver_detail_address, + refund_status,refund_price,coupon_price,use_point,point_price,give_point, + refund_point,vip_price) + VALUES (800,10,'ORDER-SAME',0,20,42,'127.0.0.1',0,1,false, + 1000,0,0,0,1000,1,'Learner','13800000000',310000,'Building A', + 0,0,0,0,0,0,0,0), + (800,20,'ORDER-SAME',0,20,42,'127.0.0.1',0,1,false, + 1000,0,0,0,1000,1,'Learner','13900000000',310000,'Building B', + 0,0,0,0,0,0,0,0), + (801,20,'ORDER-T20',0,20,42,'127.0.0.1',0,1,false, + 1000,0,0,0,1000,1,'Learner','13900000000',310000,'Building C', + 0,0,0,0,0,0,0,0); + INSERT INTO trade_order_item + (id,tenant_id,user_id,order_id,spu_id,spu_name,sku_id,properties,pic_url, + count,comment_status,price,discount_price,delivery_price,adjust_price,pay_price, + coupon_price,point_price,use_point,give_point,vip_price,after_sale_status) + VALUES (810,10,42,800,100,'Printed course',101,'[]','https://example.test/sku.png', + 1,false,1000,0,0,0,1000,0,0,0,0,0,0), + (810,20,42,800,100,'Printed course',101,'[]','https://example.test/sku.png', + 1,false,1000,0,0,0,1000,0,0,0,0,0,0), + (811,20,42,801,100,'Printed course',101,'[]','https://example.test/sku.png', + 1,false,1000,0,0,0,1000,0,0,0,0,0,0); + INSERT INTO trade_after_sale + (id,tenant_id,no,status,way,type,user_id,apply_reason,apply_pic_urls, + order_id,order_no,order_item_id,spu_id,spu_name,sku_id,properties,pic_url, + count,refund_price) + VALUES (900,10,'AFTER-SAME',10,10,10,42,'Changed mind','[]', + 800,'ORDER-SAME',810,100,'Printed course',101,'[]', + 'https://example.test/sku.png',1,1000), + (900,20,'AFTER-SAME',10,10,10,42,'Changed mind','[]', + 800,'ORDER-SAME',810,100,'Printed course',101,'[]', + 'https://example.test/sku.png',1,1000), + (901,20,'AFTER-T20',10,10,10,42,'Changed mind','[]', + 801,'ORDER-T20',811,100,'Printed course',101,'[]', + 'https://example.test/sku.png',1,1000); + INSERT INTO trade_after_sale_log + (id,tenant_id,user_id,user_type,after_sale_id,before_status,after_status, + operate_type,content) + VALUES (910,10,42,2,900,NULL,10,10,'Member requested refund'), + (910,20,42,2,900,NULL,10,10,'Tenant 20 requested refund'); + """); + + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || id || ':' || no + FROM trade_after_sale WHERE id=900 ORDER BY tenant_id + """)).containsExactly("10:900:AFTER-SAME", "20:900:AFTER-SAME"); + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || id || ':' || after_sale_id + FROM trade_after_sale_log WHERE id=910 ORDER BY tenant_id + """)).containsExactly("10:910:900", "20:910:900"); + assertThat(queryLong(schema, """ + SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace + WHERE n.nspname=current_schema() AND c.relkind='S' + AND c.relname IN ('trade_after_sale_seq','trade_after_sale_log_seq') + """)).isEqualTo(2L); + + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO trade_after_sale + (tenant_id,no,status,way,type,user_id,apply_reason,order_id,order_no, + order_item_id,spu_id,spu_name,sku_id,count,refund_price) + VALUES (10,'CROSS-TENANT',10,10,10,42,'Changed mind',801,'ORDER-T20', + 811,100,'Printed course',101,1,1000) + """)).hasMessageContaining("fk_trade_after_sale_order"); + assertThatThrownBy(() -> execute(schema, """ + UPDATE trade_order_item SET after_sale_id=901 + WHERE tenant_id=10 AND id=810 + """)).hasMessageContaining("fk_trade_order_item_after_sale"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO trade_after_sale + (tenant_id,no,status,way,type,user_id,apply_reason,order_id,order_no, + order_item_id,spu_id,spu_name,sku_id,count,refund_price,audit_time,audit_user_id) + VALUES (10,'INVALID-REJECT',62,10,10,42,'Changed mind',800,'ORDER-SAME', + 810,100,'Printed course',101,1,1000,CURRENT_TIMESTAMP,7) + """)).hasMessageContaining("ck_trade_after_sale_audit"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO trade_after_sale_log + (tenant_id,user_id,user_type,after_sale_id,after_status,operate_type,content) + VALUES (10,42,2,900,10,99,'Invalid operation') + """)).hasMessageContaining("ck_trade_after_sale_log_operate"); + + assertThat(queryStrings(schema, """ + SELECT id || ':' || component || ':' || component_name + FROM system_menu WHERE id=7010 + """)).containsExactly("7010:mall/trade/afterSale/index:TradeAfterSale"); + assertThat(queryStrings(schema, """ + SELECT permission FROM system_menu + WHERE id BETWEEN 7011 AND 7015 AND type=3 ORDER BY id + """)).containsExactly( + "trade:after-sale:query", + "trade:after-sale:agree", + "trade:after-sale:disagree", + "trade:after-sale:receive", + "trade:after-sale:refund"); + assertThat(queryLong(schema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4420' AND success=true")) + .isEqualTo(1L); + + String conflictSchema = createSchema("native_trade_after_sale_global_conflict"); + configureFlyway(conflictSchema, false).target("4410").load().migrate(); + execute(conflictSchema, "CREATE TABLE trade_after_sale (id BIGINT PRIMARY KEY)"); + assertThatThrownBy(() -> configureFlyway(conflictSchema, false).load().migrate()) + .hasMessageContaining("Existing trade_after_sale requires explicit tenant mapping before V4420"); + assertThat(queryLong(conflictSchema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4420' AND success=true")) + .isZero(); + } + + @Test + void shouldActivateTenantScopedNativeTradeBrokerageAndMenus() throws SQLException { + String schema = createSchema("native_trade_brokerage"); + createSystemMenuFixture(schema); + configureFlyway(schema, true).load().migrate(); + + execute(schema, """ + INSERT INTO trade_config + (tenant_id,after_sale_refund_reasons,after_sale_return_reasons, + delivery_express_free_enabled,delivery_express_free_price,delivery_pick_up_enabled, + brokerage_enabled,brokerage_enabled_condition,brokerage_bind_mode, + brokerage_first_percent,brokerage_second_percent,brokerage_withdraw_min_price, + brokerage_withdraw_fee_percent,brokerage_frozen_days,brokerage_withdraw_types) + VALUES (10,'[]','[]',false,0,false,true,1,1,10,5,0,0,0,'1'); + INSERT INTO trade_brokerage_user + (id,tenant_id,brokerage_enabled,brokerage_time,brokerage_price,frozen_price) + VALUES (100,10,true,CURRENT_TIMESTAMP,1000,0), + (100,20,true,CURRENT_TIMESTAMP,2000,0), + (200,20,true,CURRENT_TIMESTAMP,500,0); + INSERT INTO trade_brokerage_user + (id,tenant_id,bind_user_id,bind_user_time,brokerage_enabled,brokerage_time, + brokerage_price,frozen_price) + VALUES (101,10,100,CURRENT_TIMESTAMP,false,NULL,0,0), + (101,20,100,CURRENT_TIMESTAMP,false,NULL,0,0); + INSERT INTO trade_brokerage_record + (id,tenant_id,user_id,biz_id,biz_type,title,description,price,total_price, + status,frozen_days,source_user_level,source_user_id) + VALUES (900,10,100,'ORDER-SAME',1,'Order commission','Tenant 10 order',100,1100,1,0,1,101), + (900,20,100,'ORDER-SAME',1,'Order commission','Tenant 20 order',200,2200,1,0,1,101); + INSERT INTO trade_brokerage_withdraw + (id,tenant_id,user_id,price,fee_price,total_price,type,qr_code_url,status) + VALUES (910,10,100,100,0,1000,3,'https://example.test/tenant-10.png',0), + (910,20,100,200,0,2000,3,'https://example.test/tenant-20.png',0); + """); + + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || id || ':' || brokerage_price + FROM trade_brokerage_user WHERE id=100 ORDER BY tenant_id + """)).containsExactly("10:100:1000", "20:100:2000"); + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || id || ':' || biz_id + FROM trade_brokerage_record WHERE id=900 ORDER BY tenant_id + """)).containsExactly("10:900:ORDER-SAME", "20:900:ORDER-SAME"); + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || id || ':' || price + FROM trade_brokerage_withdraw WHERE id=910 ORDER BY tenant_id + """)).containsExactly("10:910:100", "20:910:200"); + assertThat(queryLong(schema, """ + SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace + WHERE n.nspname=current_schema() AND c.relkind='S' + AND c.relname IN ('trade_brokerage_record_seq','trade_brokerage_withdraw_seq') + """)).isEqualTo(2L); + + assertThatThrownBy(() -> execute(schema, """ + UPDATE trade_brokerage_user SET bind_user_id=200,bind_user_time=CURRENT_TIMESTAMP + WHERE tenant_id=10 AND id=101 + """)).hasMessageContaining("fk_trade_brokerage_user_bind"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO trade_brokerage_record + (tenant_id,user_id,biz_id,biz_type,title,description,price,total_price, + status,frozen_days,source_user_level,source_user_id) + VALUES (10,100,'CROSS-SOURCE',1,'Cross','Cross tenant source',100,1100,1,0,1,200) + """)).hasMessageContaining("fk_trade_brokerage_record_source_user"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO trade_brokerage_record + (tenant_id,user_id,biz_id,biz_type,title,description,price,total_price, + status,frozen_days,source_user_level,source_user_id) + VALUES (10,100,'ORDER-SAME',1,'Duplicate','Duplicate order',100,1200,1,0,1,101) + """)).hasMessageContaining("uk_trade_brokerage_record_tenant_biz_user"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO trade_brokerage_withdraw + (tenant_id,user_id,price,fee_price,total_price,type,qr_code_url,status) + VALUES (10,100,100,100,1000,3,'https://example.test/invalid.png',0) + """)).hasMessageContaining("ck_trade_brokerage_withdraw_amount"); + assertThatThrownBy(() -> execute(schema, """ + UPDATE trade_config SET brokerage_withdraw_fee_percent=100 + """)).hasMessageContaining("ck_trade_config_brokerage_withdraw_fee_range"); + assertThat(queryLong(schema, """ + SELECT count(*) FROM pg_constraint + WHERE conrelid='trade_order'::regclass + AND conname='fk_trade_order_brokerage_user' + """)).isEqualTo(1L); + + assertThat(queryStrings(schema, """ + SELECT id || ':' || component || ':' || component_name + FROM system_menu WHERE id IN (7021,7027,7029) ORDER BY id + """)).containsExactly( + "7021:mall/trade/brokerage/user/index:TradeBrokerageUser", + "7027:mall/trade/brokerage/record/index:TradeBrokerageRecord", + "7029:mall/trade/brokerage/withdraw/index:BrokerageWithdraw"); + assertThat(queryStrings(schema, """ + SELECT permission FROM system_menu + WHERE id BETWEEN 7022 AND 7031 AND type=3 ORDER BY id + """)).containsExactly( + "trade:brokerage-user:query", + "trade:brokerage-user:create", + "trade:brokerage-user:update-bind-user", + "trade:brokerage-user:clear-bind-user", + "trade:brokerage-user:update-brokerage-enable", + "trade:brokerage-record:query", + "trade:brokerage-withdraw:query", + "trade:brokerage-withdraw:audit"); + assertThat(queryLong(schema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4430' AND success=true")) + .isEqualTo(1L); + + String conflictSchema = createSchema("native_trade_brokerage_global_conflict"); + configureFlyway(conflictSchema, false).target("4420").load().migrate(); + execute(conflictSchema, "CREATE TABLE trade_brokerage_user (id BIGINT PRIMARY KEY)"); + assertThatThrownBy(() -> configureFlyway(conflictSchema, false).load().migrate()) + .hasMessageContaining("Existing trade_brokerage_user requires explicit tenant mapping before V4430"); + assertThat(queryLong(conflictSchema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4430' AND success=true")) + .isZero(); + } + + @Test + void shouldActivateTenantScopedNativePromotionSeckillAndMenus() throws SQLException { + String schema = createSchema("native_promotion_seckill"); + createSystemMenuFixture(schema); + configureFlyway(schema, true).load().migrate(); + + execute(schema, """ + INSERT INTO product_brand(id,tenant_id,name,pic_url,sort,status) + VALUES (1,10,'Education','https://example.test/brand.png',0,0), + (1,20,'Education','https://example.test/brand.png',0,0); + INSERT INTO product_category(id,tenant_id,parent_id,name,pic_url,sort,status) + VALUES (1,10,0,'Course','https://example.test/category.png',0,0), + (1,20,0,'Course','https://example.test/category.png',0,0); + INSERT INTO product_spu + (id,tenant_id,name,keyword,introduction,description,category_id,brand_id,pic_url, + sort,status,spec_type,price,stock,delivery_types,give_integral,sub_commission_type, + sales_count,virtual_sales_count,browse_count) + VALUES + (100,10,'Course A','course','Course A','Course A',1,1, + 'https://example.test/spu.png',0,0,false,1000,10,'[1]',0,false,0,0,0), + (100,20,'Course A','course','Course A','Course A',1,1, + 'https://example.test/spu.png',0,0,false,1000,10,'[1]',0,false,0,0,0); + INSERT INTO product_sku(id,tenant_id,spu_id,price,pic_url,stock,sales_count) + VALUES (101,10,100,1000,'https://example.test/sku.png',10,0), + (101,20,100,1000,'https://example.test/sku.png',10,0); + + INSERT INTO promotion_seckill_config + (id,tenant_id,name,start_time,end_time,slider_pic_urls,status) + VALUES (100,10,'Tenant 10 day','08:00','12:00','["https://example.test/10.png"]',0), + (100,20,'Tenant 20 day','08:00','12:00','["https://example.test/20.png"]',0), + (101,20,'Tenant 20 evening','18:00','22:00','["https://example.test/20b.png"]',0); + INSERT INTO promotion_seckill_activity + (id,tenant_id,spu_id,name,status,start_time,end_time,sort,config_ids, + total_limit_count,single_limit_count,stock,total_stock) + VALUES (200,10,100,'Tenant 10 Seckill',0,'2026-01-01','2027-01-01',0,'100',5,2,3,3), + (200,20,100,'Tenant 20 Seckill',0,'2026-01-01','2027-01-01',0,'100',8,3,6,6), + (201,20,100,'Tenant 20 Evening',0,'2026-01-01','2027-01-01',1,'101',8,3,2,2); + INSERT INTO promotion_seckill_product + (id,tenant_id,activity_id,config_ids,spu_id,sku_id,seckill_price,stock, + activity_status,activity_start_time,activity_end_time) + VALUES (300,10,200,'100',100,101,500,3,0,'2026-01-01','2027-01-01'), + (300,20,200,'100',100,101,600,6,0,'2026-01-01','2027-01-01'); + """); + + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || id || ':' || name + FROM promotion_seckill_config WHERE id=100 ORDER BY tenant_id + """)).containsExactly("10:100:Tenant 10 day", "20:100:Tenant 20 day"); + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || id || ':' || stock + FROM promotion_seckill_activity WHERE id=200 ORDER BY tenant_id + """)).containsExactly("10:200:3", "20:200:6"); + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || id || ':' || seckill_price + FROM promotion_seckill_product WHERE id=300 ORDER BY tenant_id + """)).containsExactly("10:300:500", "20:300:600"); + assertThat(queryLong(schema, """ + SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace + WHERE n.nspname=current_schema() AND c.relkind='S' + AND c.relname IN ('promotion_seckill_config_seq', + 'promotion_seckill_activity_seq', + 'promotion_seckill_product_seq') + """)).isEqualTo(3L); + + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO promotion_seckill_activity + (tenant_id,spu_id,name,status,start_time,end_time,sort,config_ids, + total_limit_count,single_limit_count,stock,total_stock) + VALUES (10,100,'Cross tenant config',0,'2026-01-01','2027-01-01',0,'101',5,1,1,1) + """)).hasMessageContaining("fk_promotion_seckill_activity_config"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO promotion_seckill_activity + (tenant_id,spu_id,name,status,start_time,end_time,sort,config_ids, + total_limit_count,single_limit_count,stock,total_stock) + VALUES (10,100,'Duplicate configs',0,'2026-01-01','2027-01-01',0,'100,100',5,1,1,1) + """)).hasMessageContaining("ck_promotion_seckill_activity_configs"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO promotion_seckill_product + (tenant_id,activity_id,config_ids,spu_id,sku_id,seckill_price,stock, + activity_status,activity_start_time,activity_end_time) + VALUES (10,201,'101',100,101,500,1,0,'2026-01-01','2027-01-01') + """)).hasMessageContaining("fk_promotion_seckill_product_activity"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO promotion_seckill_product + (tenant_id,activity_id,config_ids,spu_id,sku_id,seckill_price,stock, + activity_status,activity_start_time,activity_end_time) + VALUES (10,200,'100',100,101,500,1,1,'2026-01-01','2027-01-01') + """)).hasMessageContaining("ck_promotion_seckill_product_snapshot"); + assertThatThrownBy(() -> execute(schema, """ + UPDATE promotion_seckill_config SET deleted=true WHERE tenant_id=10 AND id=100 + """)).hasMessageContaining("fk_promotion_seckill_config_activity"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO trade_order + (tenant_id,no,type,terminal,user_id,user_ip,status,product_count,pay_status, + total_price,discount_price,delivery_price,adjust_price,pay_price,delivery_type, + receiver_name,receiver_mobile,receiver_area_id,receiver_detail_address, + refund_status,refund_price,coupon_price,use_point,point_price,give_point, + refund_point,vip_price,seckill_activity_id) + VALUES (10,'CROSS-SECKILL',1,20,42,'127.0.0.1',0,1,false, + 500,500,0,0,500,1,'Learner','13800000000',1,'Room A', + 0,0,0,0,0,0,0,0,201) + """)).hasMessageContaining("fk_trade_order_seckill_activity"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO trade_order + (tenant_id,no,type,terminal,user_id,user_ip,status,product_count,pay_status, + total_price,discount_price,delivery_price,adjust_price,pay_price,delivery_type, + receiver_name,receiver_mobile,receiver_area_id,receiver_detail_address, + refund_status,refund_price,coupon_price,use_point,point_price,give_point, + refund_point,vip_price,seckill_activity_id) + VALUES (10,'INVALID-SECKILL-TYPE',0,20,42,'127.0.0.1',0,1,false, + 500,500,0,0,500,1,'Learner','13800000000',1,'Room A', + 0,0,0,0,0,0,0,0,200) + """)).hasMessageContaining("ck_trade_order_seckill_reference"); + + assertThat(queryStrings(schema, """ + SELECT id || ':' || component || ':' || component_name + FROM system_menu WHERE id IN (7041,7047) ORDER BY id + """)).containsExactly( + "7041:mall/promotion/seckill/activity/index:PromotionSeckillActivity", + "7047:mall/promotion/seckill/config/index:PromotionSeckillConfig"); + assertThat(queryStrings(schema, """ + SELECT permission FROM system_menu + WHERE id BETWEEN 7042 AND 7051 AND type=3 ORDER BY id + """)).containsExactly( + "promotion:seckill-activity:query", + "promotion:seckill-activity:create", + "promotion:seckill-activity:update", + "promotion:seckill-activity:close", + "promotion:seckill-activity:delete", + "promotion:seckill-config:query", + "promotion:seckill-config:create", + "promotion:seckill-config:update", + "promotion:seckill-config:delete"); + assertThat(queryLong(schema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4440' AND success=true")) + .isEqualTo(1L); + + String conflictSchema = createSchema("native_promotion_seckill_global_conflict"); + configureFlyway(conflictSchema, false).target("4430").load().migrate(); + execute(conflictSchema, "CREATE TABLE promotion_seckill_activity (id BIGINT PRIMARY KEY)"); + assertThatThrownBy(() -> configureFlyway(conflictSchema, false).load().migrate()) + .hasMessageContaining("Existing promotion_seckill_activity requires explicit tenant mapping before V4440"); + assertThat(queryLong(conflictSchema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4440' AND success=true")) + .isZero(); + } + + @Test + void shouldActivateTenantScopedNativePromotionCombinationAndMenus() throws SQLException { + String schema = createSchema("native_promotion_combination"); + createSystemMenuFixture(schema); + configureFlyway(schema, true).load().migrate(); + + execute(schema, """ + INSERT INTO product_brand(id,tenant_id,name,pic_url,sort,status) + VALUES (1,10,'Education','https://example.test/brand.png',0,0), + (1,20,'Education','https://example.test/brand.png',0,0); + INSERT INTO product_category(id,tenant_id,parent_id,name,pic_url,sort,status) + VALUES (1,10,0,'Course','https://example.test/category.png',0,0), + (1,20,0,'Course','https://example.test/category.png',0,0); + INSERT INTO product_spu + (id,tenant_id,name,keyword,introduction,description,category_id,brand_id,pic_url, + sort,status,spec_type,price,stock,delivery_types,give_integral,sub_commission_type, + sales_count,virtual_sales_count,browse_count) + VALUES + (100,10,'Course A','course','Course A','Course A',1,1, + 'https://example.test/spu.png',0,0,false,1000,10,'[1]',0,false,0,0,0), + (100,20,'Course A','course','Course A','Course A',1,1, + 'https://example.test/spu.png',0,0,false,1000,10,'[1]',0,false,0,0,0); + INSERT INTO product_sku(id,tenant_id,spu_id,price,pic_url,stock,sales_count) + VALUES (101,10,100,1000,'https://example.test/sku.png',10,0), + (101,20,100,1000,'https://example.test/sku.png',10,0); + + INSERT INTO promotion_combination_activity + (id,tenant_id,name,spu_id,total_limit_count,single_limit_count,start_time,end_time, + user_size,virtual_group,status,limit_duration) + VALUES (200,10,'Tenant 10 Group',100,10,2,'2026-01-01','2027-01-01',2,false,0,60), + (200,20,'Tenant 20 Group',100,10,2,'2026-01-01','2027-01-01',3,false,0,60); + INSERT INTO promotion_combination_product + (id,tenant_id,activity_id,spu_id,sku_id,combination_price,activity_status, + activity_start_time,activity_end_time) + VALUES (300,10,200,100,101,500,0,'2026-01-01','2027-01-01'), + (300,20,200,100,101,600,0,'2026-01-01','2027-01-01'); + + INSERT INTO trade_order + (id,tenant_id,no,type,terminal,user_id,user_ip,status,product_count,pay_status, + total_price,discount_price,delivery_price,adjust_price,pay_price,delivery_type, + receiver_name,receiver_mobile,receiver_area_id,receiver_detail_address, + refund_status,refund_price,coupon_price,use_point,point_price,give_point, + refund_point,vip_price,combination_activity_id) + VALUES (500,10,'COMBINATION-HEAD',3,20,42,'127.0.0.1',0,1,false, + 500,500,0,0,500,1,'Learner','13800000000',1,'Room A', + 0,0,0,0,0,0,0,0,200), + (500,20,'COMBINATION-T20',3,20,52,'127.0.0.1',0,1,false, + 600,400,0,0,600,1,'Learner','13800000001',1,'Room B', + 0,0,0,0,0,0,0,0,200); + INSERT INTO promotion_combination_record + (id,tenant_id,activity_id,combination_price,spu_id,spu_name,pic_url,sku_id, + count,user_id,nickname,head_id,status,order_id,user_size,user_count,virtual_group, + expire_time,start_time) + VALUES (400,10,200,500,100,'Course A','https://example.test/spu.png',101, + 1,42,'Learner',0,0,500,2,1,false,'2026-06-01 01:00','2026-06-01'), + (400,20,200,600,100,'Course A','https://example.test/spu.png',101, + 1,52,'Learner',0,0,500,3,1,false,'2026-06-01 01:00','2026-06-01'); + UPDATE trade_order + SET combination_record_id=400,combination_head_id=400 + WHERE id=500; + """); + + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || id || ':' || user_size + FROM promotion_combination_activity WHERE id=200 ORDER BY tenant_id + """)).containsExactly("10:200:2", "20:200:3"); + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || id || ':' || combination_price + FROM promotion_combination_product WHERE id=300 ORDER BY tenant_id + """)).containsExactly("10:300:500", "20:300:600"); + assertThat(queryStrings(schema, """ + SELECT tenant_id || ':' || id || ':' || order_id + FROM promotion_combination_record WHERE id=400 ORDER BY tenant_id + """)).containsExactly("10:400:500", "20:400:500"); + assertThat(queryLong(schema, """ + SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace + WHERE n.nspname=current_schema() AND c.relkind='S' + AND c.relname IN ('promotion_combination_activity_seq', + 'promotion_combination_product_seq', + 'promotion_combination_record_seq') + """)).isEqualTo(3L); + + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO promotion_combination_product + (tenant_id,activity_id,spu_id,sku_id,combination_price,activity_status, + activity_start_time,activity_end_time) + VALUES (10,200,100,101,1100,0,'2026-01-01','2027-01-01') + """)).hasMessageContaining("ck_promotion_combination_product_price"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO promotion_combination_record + (tenant_id,activity_id,combination_price,spu_id,spu_name,pic_url,sku_id, + count,user_id,nickname,head_id,status,order_id,user_size,user_count,virtual_group, + expire_time,start_time) + VALUES (10,200,500,100,'Course A','https://example.test/spu.png',101, + 0,0,'Virtual',400,0,0,3,2,false,'2026-06-01 01:00','2026-06-01') + """)).hasMessageContaining("ck_promotion_combination_record_head"); + assertThatThrownBy(() -> execute(schema, """ + UPDATE promotion_combination_activity SET deleted=true + WHERE tenant_id=10 AND id=200 + """)).hasMessageContaining("fk_promotion_combination_activity_record"); + assertThatThrownBy(() -> execute(schema, """ + UPDATE trade_order SET combination_record_id=NULL,combination_head_id=400 + WHERE tenant_id=10 AND id=500 + """)).hasMessageContaining("ck_trade_order_combination_reference"); + + assertThat(queryStrings(schema, """ + SELECT id || ':' || component || ':' || component_name + FROM system_menu WHERE id IN (7061,7067) ORDER BY id + """)).containsExactly( + "7061:mall/promotion/combination/activity/index:PromotionCombinationActivity", + "7067:mall/promotion/combination/record/index:PromotionCombinationRecord"); + assertThat(queryStrings(schema, """ + SELECT permission FROM system_menu + WHERE id BETWEEN 7062 AND 7067 AND permission<>'' ORDER BY id + """)).containsExactly( + "promotion:combination-activity:query", + "promotion:combination-activity:create", + "promotion:combination-activity:update", + "promotion:combination-activity:close", + "promotion:combination-activity:delete", + "promotion:combination-record:query"); + assertThat(queryLong(schema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4450' AND success=true")) + .isEqualTo(1L); + + String conflictSchema = createSchema("native_promotion_combination_global_conflict"); + configureFlyway(conflictSchema, false).target("4440").load().migrate(); + execute(conflictSchema, "CREATE TABLE promotion_combination_activity (id BIGINT PRIMARY KEY)"); + assertThatThrownBy(() -> configureFlyway(conflictSchema, false).load().migrate()) + .hasMessageContaining("Existing promotion_combination_activity requires explicit tenant mapping before V4450"); + assertThat(queryLong(conflictSchema, + "SELECT count(*) FROM flyway_schema_history WHERE version='4450' AND success=true")) + .isZero(); + } + + @Test + void shouldCreateSecureActivationCodeSchemaAndMenus() throws SQLException { + String schema=createSchema("activation_codes"); createSystemMenuFixture(schema); + configureFlyway(schema,true).load().migrate(); + execute(schema,""" + INSERT INTO education_resource_product_binding(tenant_id,resource_type,resource_id,product_spu_id) + VALUES(10,'QUESTION_COLLECTION',100,200); + INSERT INTO education_activation_code_batch(tenant_id,name,product_spu_id,duration_days,code_prefix,total_count) + VALUES(10,'Batch A',200,30,'EDU',1); + INSERT INTO education_activation_code(tenant_id,batch_id,code_hash,code_masked) + SELECT 10,id,repeat('a',64),'EDU-****ABCD' FROM education_activation_code_batch WHERE tenant_id=10; + """); + assertThat(queryStrings(schema,"SELECT status||':'||code_masked FROM education_activation_code")) + .containsExactly("AVAILABLE:EDU-****ABCD"); + assertThatThrownBy(()->execute(schema,""" + INSERT INTO education_activation_code(tenant_id,batch_id,code_hash,code_masked) + SELECT 20,id,repeat('b',64),'BAD-****ABCD' FROM education_activation_code_batch WHERE tenant_id=10 + """)).hasMessageContaining("fk_education_activation_code_batch"); + assertThat(queryStrings(schema,"SELECT id||':'||permission FROM system_menu WHERE id BETWEEN 6881 AND 6884 ORDER BY id")) + .containsExactly("6881:","6882:education:activation-code:query","6883:education:activation-code:manage","6884:education:activation-code:generate"); + + String conflictSchema=createSchema("activation_code_menu_conflict"); createSystemMenuFixture(conflictSchema); + configureFlyway(conflictSchema,true).target("4300").load().migrate(); + execute(conflictSchema,""" + INSERT INTO system_menu(id,name,permission,type,sort,parent_id,status,deleted) + VALUES(6882,'Conflict','education:activation-code:shadow',3,1,6881,0,0) + """); + assertThatThrownBy(()->configureFlyway(conflictSchema,true).load().migrate()) + .hasMessageContaining("Education activation code menu shape conflict"); + } + private void createSystemMenuFixture(String schema) throws SQLException { execute(schema, """ CREATE TABLE system_menu ( @@ -1387,7 +3292,7 @@ class EducationFlywayMigrationIntegrationTest { String schema, boolean baselineOnMigrate) { return Flyway.configure() .dataSource(jdbcUrl(schema), USER, PASSWORD) - .locations("classpath:db/migration/education") + .locations("classpath:db/migration/education", "classpath:db/migration/member") .schemas(schema) .defaultSchema(schema) .baselineOnMigrate(baselineOnMigrate) @@ -1434,12 +3339,9 @@ class EducationFlywayMigrationIntegrationTest { return "jdbc:postgresql://" + HOST + ":" + PORT + "/" + DATABASE; } - private static String requiredEnv(String name) { + private static String envOrDefault(String name, Supplier defaultValue) { String value = System.getenv(name); - if (value == null || value.isBlank()) { - throw new IllegalStateException(name + " must be set for PostgreSQL integration tests"); - } - return value; + return value == null || value.isBlank() ? defaultValue.get() : value; } } diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationPostgreSqlContainer.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationPostgreSqlContainer.java new file mode 100644 index 00000000..3ce388e6 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationPostgreSqlContainer.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.education.test; + +import org.testcontainers.containers.PostgreSQLContainer; + +final class EducationPostgreSqlContainer { + + private static final PostgreSQLContainer CONTAINER = new PostgreSQLContainer<>("postgres:17-alpine") + .withDatabaseName("education_test") + .withUsername("postgres") + .withPassword("postgres"); + + static { + CONTAINER.start(); + } + + static String host() { + return CONTAINER.getHost(); + } + + static int port() { + return CONTAINER.getFirstMappedPort(); + } + + static String database() { + return CONTAINER.getDatabaseName(); + } + + static String username() { + return CONTAINER.getUsername(); + } + + static String password() { + return CONTAINER.getPassword(); + } + + private EducationPostgreSqlContainer() { + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/PostgreSqlDbIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/PostgreSqlDbIntegrationTest.java index 52d1bd07..97049dfe 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/PostgreSqlDbIntegrationTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/PostgreSqlDbIntegrationTest.java @@ -24,6 +24,7 @@ import org.springframework.test.context.jdbc.Sql; import java.sql.DriverManager; import java.sql.SQLException; import java.util.UUID; +import java.util.function.Supplier; /** * PostgreSQL persistence seam for Education tests. @@ -41,11 +42,12 @@ import java.util.UUID; @Sql(scripts = "/sql/postgresql/clean.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) public abstract class PostgreSqlDbIntegrationTest { - private static final String HOST = requiredEnv("EDU_TEST_POSTGRES_HOST"); - private static final String PORT = requiredEnv("EDU_TEST_POSTGRES_PORT"); - private static final String DATABASE = requiredEnv("EDU_TEST_POSTGRES_DB"); - private static final String USER = requiredEnv("EDU_TEST_POSTGRES_USER"); - private static final String PASSWORD = requiredEnv("EDU_TEST_POSTGRES_PASSWORD"); + private static final String HOST = envOrDefault("EDU_TEST_POSTGRES_HOST", EducationPostgreSqlContainer::host); + private static final String PORT = envOrDefault("EDU_TEST_POSTGRES_PORT", + () -> String.valueOf(EducationPostgreSqlContainer.port())); + private static final String DATABASE = envOrDefault("EDU_TEST_POSTGRES_DB", EducationPostgreSqlContainer::database); + private static final String USER = envOrDefault("EDU_TEST_POSTGRES_USER", EducationPostgreSqlContainer::username); + private static final String PASSWORD = envOrDefault("EDU_TEST_POSTGRES_PASSWORD", EducationPostgreSqlContainer::password); private static final String SCHEMA = "edu_test_" + UUID.randomUUID().toString().replace("-", ""); private static final String ROLE_SUFFIX = UUID.randomUUID().toString().replace("-", ""); private static final String FLYWAY_ROLE = "edu_flyway_" + ROLE_SUFFIX; @@ -117,12 +119,9 @@ public abstract class PostgreSqlDbIntegrationTest { return "jdbc:postgresql://" + HOST + ":" + PORT + "/" + DATABASE; } - private static String requiredEnv(String name) { + private static String envOrDefault(String name, Supplier defaultValue) { String value = System.getenv(name); - if (value == null || value.isBlank()) { - throw new IllegalStateException(name + " must be set for PostgreSQL integration tests"); - } - return value; + return value == null || value.isBlank() ? defaultValue.get() : value; } @Import({ diff --git a/yudao-module-education/src/test/resources/sql/postgresql/clean.sql b/yudao-module-education/src/test/resources/sql/postgresql/clean.sql index 28f38b52..086ab783 100644 --- a/yudao-module-education/src/test/resources/sql/postgresql/clean.sql +++ b/yudao-module-education/src/test/resources/sql/postgresql/clean.sql @@ -1,6 +1,34 @@ -- PostgreSQL persistence test cleanup. -- Module-owned Flyway creates the disposable schema; this only isolates test data. TRUNCATE TABLE + promotion_combination_record, + promotion_combination_product, + promotion_combination_activity, + promotion_seckill_product, + promotion_seckill_activity, + promotion_seckill_config, + trade_brokerage_withdraw, + trade_brokerage_record, + trade_brokerage_user, + trade_after_sale_log, + trade_after_sale, + trade_delivery_express_template_charge, + trade_delivery_express_template_free, + trade_delivery_express_template, + trade_delivery_express, + trade_delivery_pick_up_store, + education_activation_code, + education_activation_code_batch, + education_tenant_appearance, + education_tenant_theme_template, + education_badge_definition, + education_student_followup, + education_student_supervision_rule, + education_student_feedback_event, + education_student_feedback, + education_learning_award, + education_exam_reminder, + education_vocabulary_progress, education_class_invitation_audit, education_class_invitation, education_class_member, @@ -31,5 +59,9 @@ TRUNCATE TABLE education_practice_session, education_content_node, education_content_entry, - education_subject + education_subject, + product_sku, + product_spu, + product_category, + product_brand CASCADE;