diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentexport/ContentExportController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentexport/ContentExportController.java new file mode 100644 index 00000000..115d21b9 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentexport/ContentExportController.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.education.controller.admin.contentexport; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService; +import cn.iocoder.yudao.module.education.controller.admin.contentexport.vo.ContentExportRequestReqVO; +import cn.iocoder.yudao.module.education.service.contentexport.ContentExportAnswerMode; +import cn.iocoder.yudao.module.education.service.contentexport.ContentExportPolicy; +import cn.iocoder.yudao.module.education.service.contentexport.ContentExportPolicyService; +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.AccessDeniedException; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - 教育内容导出请求") +@RestController +@RequestMapping("/education/content-exports") +@Validated +@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true") +public class ContentExportController { + + private static final String ANSWERS_PERMISSION = "education:content-export:answers"; + + private final ContentExportPolicyService policyService; + private final SecurityFrameworkService securityFrameworkService; + + public ContentExportController(ContentExportPolicyService policyService, + SecurityFrameworkService securityFrameworkService) { + this.policyService = policyService; + this.securityFrameworkService = securityFrameworkService; + } + + @PostMapping + @Operation(summary = "评估内容导出请求的字段策略") + @PreAuthorize("@ss.hasPermission('education:content-export')") + public CommonResult requestExport( + @Valid @RequestBody ContentExportRequestReqVO reqVO) { + if (reqVO.getAnswerMode() == ContentExportAnswerMode.INCLUDE + && !securityFrameworkService.hasPermission(ANSWERS_PERMISSION)) { + throw new AccessDeniedException("Including answers requires " + ANSWERS_PERMISSION); + } + return success(policyService.evaluate(reqVO.getAnswerMode())); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentexport/vo/ContentExportRequestReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentexport/vo/ContentExportRequestReqVO.java new file mode 100644 index 00000000..d4fdf7ea --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentexport/vo/ContentExportRequestReqVO.java @@ -0,0 +1,13 @@ +package cn.iocoder.yudao.module.education.controller.admin.contentexport.vo; + +import cn.iocoder.yudao.module.education.service.contentexport.ContentExportAnswerMode; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - 内容导出请求") +@Data +public class ContentExportRequestReqVO { + + @Schema(description = "答案处理方式;省略时默认脱敏", example = "REDACT") + private ContentExportAnswerMode answerMode; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentexport/ContentExportAnswerMode.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentexport/ContentExportAnswerMode.java new file mode 100644 index 00000000..209f6bbc --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentexport/ContentExportAnswerMode.java @@ -0,0 +1,6 @@ +package cn.iocoder.yudao.module.education.service.contentexport; + +public enum ContentExportAnswerMode { + REDACT, + INCLUDE +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentexport/ContentExportPolicy.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentexport/ContentExportPolicy.java new file mode 100644 index 00000000..171d0964 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentexport/ContentExportPolicy.java @@ -0,0 +1,8 @@ +package cn.iocoder.yudao.module.education.service.contentexport; + +import java.util.Set; + +public record ContentExportPolicy(ContentExportAnswerMode answerMode, + Set includedFields, + Set excludedFields) { +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentexport/ContentExportPolicyService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentexport/ContentExportPolicyService.java new file mode 100644 index 00000000..c783dfc7 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentexport/ContentExportPolicyService.java @@ -0,0 +1,6 @@ +package cn.iocoder.yudao.module.education.service.contentexport; + +public interface ContentExportPolicyService { + + ContentExportPolicy evaluate(ContentExportAnswerMode requestedAnswerMode); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentexport/ContentExportPolicyServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentexport/ContentExportPolicyServiceImpl.java new file mode 100644 index 00000000..caada229 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentexport/ContentExportPolicyServiceImpl.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.education.service.contentexport; + +import org.springframework.stereotype.Service; + +import java.util.LinkedHashSet; +import java.util.Set; + +@Service +public class ContentExportPolicyServiceImpl implements ContentExportPolicyService { + + private static final Set CONTENT_FIELDS = Set.of( + "id", "contentVersion", "stem", "type", "typeLabel", "difficulty", + "questionContent", "options.label", "options.content", "options.order", + "status", "isPublished", "subjectId", "nodeId", "tags", "sortOrder"); + private static final Set ANSWER_FIELDS = Set.of( + "correctAnswer", "explanation", "analysis", "options.isCorrect"); + private static final Set PRIVATE_ADMIN_FIELDS = Set.of( + "tenantId", "scope", "metadata", "creator", "updater", "createTime", "updateTime", "deleted"); + + @Override + public ContentExportPolicy evaluate(ContentExportAnswerMode requestedAnswerMode) { + ContentExportAnswerMode answerMode = requestedAnswerMode == null + ? ContentExportAnswerMode.REDACT : requestedAnswerMode; + Set includedFields = new LinkedHashSet<>(CONTENT_FIELDS); + Set excludedFields = new LinkedHashSet<>(PRIVATE_ADMIN_FIELDS); + if (answerMode == ContentExportAnswerMode.INCLUDE) { + includedFields.addAll(ANSWER_FIELDS); + } else { + excludedFields.addAll(ANSWER_FIELDS); + } + return new ContentExportPolicy(answerMode, Set.copyOf(includedFields), Set.copyOf(excludedFields)); + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/contentexport/ContentExportControllerContractTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/contentexport/ContentExportControllerContractTest.java new file mode 100644 index 00000000..d34dd452 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/contentexport/ContentExportControllerContractTest.java @@ -0,0 +1,179 @@ +package cn.iocoder.yudao.module.education.controller.admin.contentexport; + +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.contentexport.vo.ContentExportRequestReqVO; +import cn.iocoder.yudao.module.education.service.contentexport.ContentExportAnswerMode; +import cn.iocoder.yudao.module.education.service.contentexport.ContentExportPolicy; +import cn.iocoder.yudao.module.education.service.contentexport.ContentExportPolicyService; +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.beans.factory.annotation.Autowired; +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.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = ContentExportControllerContractTest.Config.class) +class ContentExportControllerContractTest { + + private static final String EXPORT_PERMISSION = "education:content-export"; + private static final String ANSWERS_PERMISSION = "education:content-export:answers"; + + @Autowired + private ContentExportController controller; + @Autowired + private RecordingPolicyService service; + @Autowired + private MutableSecurity security; + + @BeforeEach + void setUp() { + service.requestedAnswerMode = null; + security.permissions.clear(); + LoginUser user = new LoginUser(); + user.setId(7L); + user.setTenantId(10L); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, List.of())); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void basePermissionAllowsDefaultRedactedRequest() { + security.permissions.add(EXPORT_PERMISSION); + + ContentExportPolicy result = controller.requestExport(new ContentExportRequestReqVO()).getData(); + + assertEquals(ContentExportAnswerMode.REDACT, result.answerMode()); + assertEquals(null, service.requestedAnswerMode); + } + + @Test + void basePermissionAllowsExplicitRedaction() { + security.permissions.add(EXPORT_PERMISSION); + ContentExportRequestReqVO request = request(ContentExportAnswerMode.REDACT); + + assertEquals(ContentExportAnswerMode.REDACT, controller.requestExport(request).getData().answerMode()); + assertEquals(ContentExportAnswerMode.REDACT, service.requestedAnswerMode); + } + + @Test + void answerPermissionWithoutBasePermissionCannotRequestExport() { + security.permissions.add(ANSWERS_PERMISSION); + + assertThrows(AccessDeniedException.class, + () -> controller.requestExport(request(ContentExportAnswerMode.INCLUDE))); + assertEquals(null, service.requestedAnswerMode); + } + + @Test + void basePermissionAloneCannotIncludeAnswers() { + security.permissions.add(EXPORT_PERMISSION); + + assertThrows(AccessDeniedException.class, + () -> controller.requestExport(request(ContentExportAnswerMode.INCLUDE))); + assertEquals(null, service.requestedAnswerMode); + } + + @Test + void includingAnswersRequiresBothPermissionsAndExplicitRequest() { + security.permissions.add(EXPORT_PERMISSION); + security.permissions.add(ANSWERS_PERMISSION); + + ContentExportPolicy result = controller.requestExport(request(ContentExportAnswerMode.INCLUDE)).getData(); + + assertEquals(ContentExportAnswerMode.INCLUDE, result.answerMode()); + assertEquals(ContentExportAnswerMode.INCLUDE, service.requestedAnswerMode); + } + + private ContentExportRequestReqVO request(ContentExportAnswerMode answerMode) { + ContentExportRequestReqVO request = new ContentExportRequestReqVO(); + request.setAnswerMode(answerMode); + return request; + } + + @Configuration(proxyBeanMethods = false) + @EnableMethodSecurity + static class Config { + @Bean("ss") + MutableSecurity security() { + return new MutableSecurity(); + } + + @Bean + RecordingPolicyService policyService() { + return new RecordingPolicyService(); + } + + @Bean + ContentExportController controller(ContentExportPolicyService service, SecurityFrameworkService security) { + return new ContentExportController(service, security); + } + } + + static class MutableSecurity implements SecurityFrameworkService { + private final Set permissions = new HashSet<>(); + + @Override + public boolean hasPermission(String permission) { + return permissions.contains(permission); + } + + @Override + public boolean hasAnyPermissions(String... permissions) { + return Arrays.stream(permissions).anyMatch(this::hasPermission); + } + + @Override + public boolean hasRole(String role) { + return false; + } + + @Override + public boolean hasAnyRoles(String... roles) { + return false; + } + + @Override + public boolean hasScope(String scope) { + return false; + } + + @Override + public boolean hasAnyScopes(String... scopes) { + return false; + } + } + + static class RecordingPolicyService implements ContentExportPolicyService { + private ContentExportAnswerMode requestedAnswerMode; + + @Override + public ContentExportPolicy evaluate(ContentExportAnswerMode requestedAnswerMode) { + this.requestedAnswerMode = requestedAnswerMode; + ContentExportAnswerMode effective = requestedAnswerMode == null + ? ContentExportAnswerMode.REDACT : requestedAnswerMode; + return new ContentExportPolicy(effective, Set.of("stem"), Set.of("tenantId")); + } + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/contentexport/ContentExportPolicyServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/contentexport/ContentExportPolicyServiceImplTest.java new file mode 100644 index 00000000..e29519f1 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/contentexport/ContentExportPolicyServiceImplTest.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.education.service.contentexport; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ContentExportPolicyServiceImplTest { + + private ContentExportPolicyService service; + + @BeforeEach + void setUp() { + service = new ContentExportPolicyServiceImpl(); + } + + @Test + void defaultsToRedactingAnswers() { + ContentExportPolicy policy = service.evaluate(null); + + assertEquals(ContentExportAnswerMode.REDACT, policy.answerMode()); + assertFalse(policy.includedFields().contains("correctAnswer")); + assertFalse(policy.includedFields().contains("explanation")); + assertFalse(policy.includedFields().contains("analysis")); + assertFalse(policy.includedFields().contains("options.isCorrect")); + assertTrue(policy.excludedFields().contains("correctAnswer")); + assertTrue(policy.excludedFields().contains("options.isCorrect")); + } + + @Test + void includesAnswerFieldsOnlyWhenExplicitlyRequested() { + ContentExportPolicy policy = service.evaluate(ContentExportAnswerMode.INCLUDE); + + assertEquals(ContentExportAnswerMode.INCLUDE, policy.answerMode()); + assertTrue(policy.includedFields().contains("correctAnswer")); + assertTrue(policy.includedFields().contains("explanation")); + assertTrue(policy.includedFields().contains("analysis")); + assertTrue(policy.includedFields().contains("options.isCorrect")); + assertFalse(policy.excludedFields().contains("correctAnswer")); + } + + @Test + void alwaysExcludesPrivateAndAdministrativeFields() { + for (ContentExportAnswerMode answerMode : ContentExportAnswerMode.values()) { + ContentExportPolicy policy = service.evaluate(answerMode); + + assertTrue(policy.excludedFields().contains("tenantId")); + assertTrue(policy.excludedFields().contains("scope")); + assertTrue(policy.excludedFields().contains("metadata")); + assertTrue(policy.excludedFields().contains("creator")); + assertTrue(policy.excludedFields().contains("updater")); + assertTrue(policy.excludedFields().contains("createTime")); + assertTrue(policy.excludedFields().contains("updateTime")); + assertTrue(policy.excludedFields().contains("deleted")); + assertFalse(policy.includedFields().stream().anyMatch(policy.excludedFields()::contains)); + } + } +}