feat(education): admit secure import assets

This commit is contained in:
2026-07-31 12:50:42 +08:00
parent b6a30e0b7c
commit 7f12dd35c8
16 changed files with 504 additions and 8 deletions

View File

@@ -0,0 +1,33 @@
package cn.iocoder.yudao.module.education.controller.admin.asset;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.education.controller.admin.asset.vo.EducationImportAssetRespVO;
import cn.iocoder.yudao.module.education.service.asset.EducationAssetAdmissionService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@RestController
@RequestMapping("/education/import-assets")
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class EducationAssetAdmissionController {
private final EducationAssetAdmissionService service;
public EducationAssetAdmissionController(EducationAssetAdmissionService service) {
this.service = service;
}
@PostMapping
@PreAuthorize("@ss.hasPermission('education:import-asset:create')")
public CommonResult<EducationImportAssetRespVO> admit(@RequestPart("file") MultipartFile file) {
return success(service.admit(file, getLoginUserId()));
}
}

View File

@@ -0,0 +1,15 @@
package cn.iocoder.yudao.module.education.controller.admin.asset.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class EducationImportAssetRespVO {
private Long id;
private String originalName;
private String contentType;
private Long size;
private String sha256;
private String scanStatus;
}

View File

@@ -0,0 +1,24 @@
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;
@TableName("education_import_asset")
@KeySequence("education_import_asset_seq")
@Data
@EqualsAndHashCode(callSuper = true)
public class EducationImportAssetDO extends TenantBaseDO {
@TableId
private Long id;
private Long ownerUserId;
private String originalName;
private String contentType;
private Long size;
private String sha256;
private String fileReference;
private String scanStatus;
}

View File

@@ -0,0 +1,9 @@
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.EducationImportAssetDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface EducationImportAssetMapper extends BaseMapperX<EducationImportAssetDO> {
}

View File

@@ -140,4 +140,10 @@ public interface ErrorCodeConstants {
ErrorCode CONTENT_IMPORT_JOB_NOT_READY = new ErrorCode(1_005_002_041, "内容导入任务当前状态不可执行");
ErrorCode CONTENT_IMPORT_PREVIEW_DUPLICATE = new ErrorCode(1_005_002_042, "内容导入预览键已存在");
ErrorCode CONTENT_IMPORT_LEASE_LOST = new ErrorCode(1_005_002_043, "内容导入任务租约已失效");
// ========== 导入资产准入 1-005-004-000 ~ 1-005-004-009 ==========
ErrorCode EDUCATION_IMPORT_ASSET_NAME_INVALID = new ErrorCode(1_005_004_000, "导入文件名无效");
ErrorCode EDUCATION_IMPORT_ASSET_SIZE_INVALID = new ErrorCode(1_005_004_001, "导入文件为空或超过大小限制");
ErrorCode EDUCATION_IMPORT_ASSET_TYPE_INVALID = new ErrorCode(1_005_004_002, "仅支持 CSV 或 XLSX 导入文件");
ErrorCode EDUCATION_IMPORT_ASSET_CONTENT_INVALID = new ErrorCode(1_005_004_003, "导入文件内容与声明类型不匹配");
}

View File

@@ -0,0 +1,8 @@
package cn.iocoder.yudao.module.education.service.asset;
import cn.iocoder.yudao.module.education.controller.admin.asset.vo.EducationImportAssetRespVO;
import org.springframework.web.multipart.MultipartFile;
public interface EducationAssetAdmissionService {
EducationImportAssetRespVO admit(MultipartFile file, Long ownerUserId);
}

View File

@@ -0,0 +1,103 @@
package cn.iocoder.yudao.module.education.service.asset;
import cn.hutool.crypto.digest.DigestUtil;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.module.education.controller.admin.asset.vo.EducationImportAssetRespVO;
import cn.iocoder.yudao.module.education.dal.dataobject.EducationImportAssetDO;
import cn.iocoder.yudao.module.education.dal.mysql.EducationImportAssetMapper;
import cn.iocoder.yudao.module.infra.api.file.*;
import lombok.SneakyThrows;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.util.Locale;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
@Service
public class EducationAssetAdmissionServiceImpl implements EducationAssetAdmissionService {
public static final int MAX_ASSET_BYTES = 10 * 1024 * 1024;
private static final int MAX_NAME_LENGTH = 255;
private static final String CSV_MIME = "text/csv";
private static final String XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private static final Set<String> CSV_MIMES = Set.of(CSV_MIME, "application/csv", "text/plain");
private final FileApi fileApi;
private final EducationImportAssetMapper assetMapper;
public EducationAssetAdmissionServiceImpl(FileApi fileApi, EducationImportAssetMapper assetMapper) {
this.fileApi = fileApi;
this.assetMapper = assetMapper;
}
@Override
@Transactional
@SneakyThrows
public EducationImportAssetRespVO admit(MultipartFile file, Long ownerUserId) {
if (file == null || file.isEmpty() || file.getSize() > MAX_ASSET_BYTES) {
throw exception(EDUCATION_IMPORT_ASSET_SIZE_INVALID);
}
String name = file.getOriginalFilename();
validateName(name);
String extension = extension(name);
String contentType = normalize(file.getContentType());
validateType(extension, contentType);
byte[] bytes = file.getBytes();
if ("xlsx".equals(extension) && !isZip(bytes)) {
throw exception(EDUCATION_IMPORT_ASSET_CONTENT_INVALID);
}
Long tenantId = TenantContextHolder.getRequiredTenantId();
FileDescriptor descriptor = fileApi.createFile(new FileContent(bytes, name,
"education/import-assets/" + tenantId, contentType));
FileScanStatus scanStatus = fileApi.scan(descriptor);
if (scanStatus == null) scanStatus = FileScanStatus.UNAVAILABLE;
EducationImportAssetDO asset = new EducationImportAssetDO();
asset.setTenantId(tenantId);
asset.setOwnerUserId(ownerUserId);
asset.setOriginalName(name);
asset.setContentType(contentType);
asset.setSize((long) bytes.length);
asset.setSha256(DigestUtil.sha256Hex(bytes));
asset.setFileReference(descriptor.reference());
asset.setScanStatus(scanStatus.name());
assetMapper.insert(asset);
return new EducationImportAssetRespVO(asset.getId(), name, contentType, asset.getSize(),
asset.getSha256(), asset.getScanStatus());
}
private void validateName(String name) {
if (name == null || name.isBlank() || name.length() > MAX_NAME_LENGTH || name.contains("/")
|| name.contains("\\") || name.indexOf('\0') >= 0) {
throw exception(EDUCATION_IMPORT_ASSET_NAME_INVALID);
}
}
private void validateType(String extension, String contentType) {
if ("csv".equals(extension) && CSV_MIMES.contains(contentType)) return;
if ("xlsx".equals(extension) && XLSX_MIME.equals(contentType)) return;
throw exception(EDUCATION_IMPORT_ASSET_TYPE_INVALID);
}
private String extension(String name) {
int dot = name.lastIndexOf('.');
return dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT);
}
private String normalize(String contentType) {
if (contentType == null) return "";
int semicolon = contentType.indexOf(';');
return (semicolon < 0 ? contentType : contentType.substring(0, semicolon)).trim().toLowerCase(Locale.ROOT);
}
private boolean isZip(byte[] bytes) {
return bytes.length >= 4 && bytes[0] == 'P' && bytes[1] == 'K'
&& ((bytes[2] == 3 && bytes[3] == 4) || (bytes[2] == 5 && bytes[3] == 6)
|| (bytes[2] == 7 && bytes[3] == 8));
}
}

View File

@@ -0,0 +1,85 @@
package cn.iocoder.yudao.module.education.controller.admin.asset;
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.asset.vo.EducationImportAssetRespVO;
import cn.iocoder.yudao.module.education.service.asset.EducationAssetAdmissionService;
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.mock.web.MockMultipartFile;
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 org.springframework.web.multipart.MultipartFile;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = EducationAssetAdmissionControllerContractTest.Config.class)
class EducationAssetAdmissionControllerContractTest {
@org.springframework.beans.factory.annotation.Autowired EducationAssetAdmissionController controller;
@org.springframework.beans.factory.annotation.Autowired RecordingService service;
@org.springframework.beans.factory.annotation.Autowired MutableSecurity security;
@BeforeEach
void setUp() {
security.permissions.clear();
LoginUser user = new LoginUser(); user.setId(7L); user.setTenantId(42L);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(user, null, List.of()));
}
@AfterEach void clear() { SecurityContextHolder.clearContext(); }
@Test
void requiresPermissionAndDerivesOwnerFromAuthenticatedPrincipal() {
MockMultipartFile file = new MockMultipartFile("file", "questions.csv", "text/csv", new byte[]{1});
assertThrows(AccessDeniedException.class, () -> controller.admit(file));
security.permissions.add("education:import-asset:create");
assertEquals(99L, controller.admit(file).getData().getId());
assertEquals(7L, service.ownerUserId);
assertSame(file, service.file);
}
@Configuration(proxyBeanMethods = false)
@EnableMethodSecurity
static class Config {
@Bean("ss") MutableSecurity security() { return new MutableSecurity(); }
@Bean RecordingService service() { return new RecordingService(); }
@Bean EducationAssetAdmissionController controller(EducationAssetAdmissionService service) {
return new EducationAssetAdmissionController(service);
}
}
static class MutableSecurity implements SecurityFrameworkService {
final Set<String> permissions = new HashSet<>();
public boolean hasPermission(String p) { return permissions.contains(p); }
public boolean hasAnyPermissions(String... p) { return false; }
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 EducationAssetAdmissionService {
MultipartFile file;
Long ownerUserId;
public EducationImportAssetRespVO admit(MultipartFile file, Long ownerUserId) {
this.file = file; this.ownerUserId = ownerUserId;
return new EducationImportAssetRespVO(99L, file.getOriginalFilename(), file.getContentType(),
file.getSize(), "hash", "UNAVAILABLE");
}
}
}

View File

@@ -0,0 +1,108 @@
package cn.iocoder.yudao.module.education.service.asset;
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.asset.vo.EducationImportAssetRespVO;
import cn.iocoder.yudao.module.education.dal.dataobject.EducationImportAssetDO;
import cn.iocoder.yudao.module.education.dal.mysql.EducationImportAssetMapper;
import cn.iocoder.yudao.module.infra.api.file.*;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockMultipartFile;
import java.nio.charset.StandardCharsets;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class EducationAssetAdmissionServiceImplTest {
private final FileApi fileApi = mock(FileApi.class);
private final EducationImportAssetMapper mapper = mock(EducationImportAssetMapper.class);
private final EducationAssetAdmissionServiceImpl service = new EducationAssetAdmissionServiceImpl(fileApi, mapper);
@BeforeEach
void setUp() {
TenantContextHolder.setTenantId(42L);
when(mapper.insert(any())).thenAnswer(invocation -> {
EducationImportAssetDO asset = invocation.getArgument(0);
asset.setId(99L);
return 1;
});
}
@AfterEach
void tearDown() {
TenantContextHolder.clear();
}
@Test
void admitsCsvWithTenantOwnershipHashAndPublicFileContractOnly() {
byte[] bytes = "id,stem\n1,Question".getBytes(StandardCharsets.UTF_8);
when(fileApi.createFile(any(FileContent.class)))
.thenReturn(new FileDescriptor("opaque-ref", "questions.csv", "text/csv", bytes.length));
when(fileApi.scan(any())).thenReturn(FileScanStatus.CLEAN);
EducationImportAssetRespVO result = service.admit(
new MockMultipartFile("file", "questions.csv", "text/csv", bytes), 7L);
assertEquals(99L, result.getId());
assertEquals("CLEAN", result.getScanStatus());
var contentCaptor = org.mockito.ArgumentCaptor.forClass(FileContent.class);
verify(fileApi).createFile(contentCaptor.capture());
assertEquals("education/import-assets/42", contentCaptor.getValue().directory());
var assetCaptor = org.mockito.ArgumentCaptor.forClass(EducationImportAssetDO.class);
verify(mapper).insert(assetCaptor.capture());
assertEquals(42L, assetCaptor.getValue().getTenantId());
assertEquals(7L, assetCaptor.getValue().getOwnerUserId());
assertEquals("873e6856c9c089e2328393ae2b96b63d34eaac80de0be8b27e4a67f219609a08",
assetCaptor.getValue().getSha256());
}
@Test
void scannerUnavailableIsPersistedAndNeverPromotedToClean() {
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());
}
@Test
void rejectsInvalidExtensionMimeAndXlsxSignatureBeforeStorage() {
assertServiceException(() -> service.admit(
new MockMultipartFile("file", "questions.exe", "text/csv", new byte[]{1}), 7L),
EDUCATION_IMPORT_ASSET_TYPE_INVALID);
assertServiceException(() -> service.admit(
new MockMultipartFile("file", "questions.csv", "application/octet-stream", new byte[]{1}), 7L),
EDUCATION_IMPORT_ASSET_TYPE_INVALID);
assertServiceException(() -> service.admit(
new MockMultipartFile("file", "questions.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{1, 2, 3}), 7L),
EDUCATION_IMPORT_ASSET_CONTENT_INVALID);
verifyNoInteractions(fileApi);
}
@Test
void rejectsUnsafeNameAndOversizeBeforeStorage() {
assertServiceException(() -> service.admit(
new MockMultipartFile("file", "../questions.csv", "text/csv", new byte[]{1}), 7L),
EDUCATION_IMPORT_ASSET_NAME_INVALID);
assertServiceException(() -> service.admit(
new MockMultipartFile("file", "questions.csv", "text/csv",
new byte[EducationAssetAdmissionServiceImpl.MAX_ASSET_BYTES + 1]), 7L),
EDUCATION_IMPORT_ASSET_SIZE_INVALID);
verifyNoInteractions(fileApi);
}
}