feat(education): define content export redaction contract

This commit is contained in:
2026-07-31 12:47:06 +08:00
parent 2a53e8e2c0
commit 3744c8c3be
8 changed files with 357 additions and 0 deletions

View File

@@ -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<ContentExportPolicy> 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()));
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,6 @@
package cn.iocoder.yudao.module.education.service.contentexport;
public enum ContentExportAnswerMode {
REDACT,
INCLUDE
}

View File

@@ -0,0 +1,8 @@
package cn.iocoder.yudao.module.education.service.contentexport;
import java.util.Set;
public record ContentExportPolicy(ContentExportAnswerMode answerMode,
Set<String> includedFields,
Set<String> excludedFields) {
}

View File

@@ -0,0 +1,6 @@
package cn.iocoder.yudao.module.education.service.contentexport;
public interface ContentExportPolicyService {
ContentExportPolicy evaluate(ContentExportAnswerMode requestedAnswerMode);
}

View File

@@ -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<String> 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<String> ANSWER_FIELDS = Set.of(
"correctAnswer", "explanation", "analysis", "options.isCorrect");
private static final Set<String> 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<String> includedFields = new LinkedHashSet<>(CONTENT_FIELDS);
Set<String> 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));
}
}

View File

@@ -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<String> 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"));
}
}
}

View File

@@ -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));
}
}
}