diff --git a/yudao-module-education/pom.xml b/yudao-module-education/pom.xml index 8f1f935f..0aad0b9a 100644 --- a/yudao-module-education/pom.xml +++ b/yudao-module-education/pom.xml @@ -26,13 +26,9 @@ cn.iocoder.boot - yudao-module-member + yudao-module-infra ${revision} - - cn.iocoder.boot - yudao-spring-boot-starter-job - diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/asset/EducationAssetAdmissionController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/asset/EducationAssetAdmissionController.java new file mode 100644 index 00000000..d5d9ea63 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/asset/EducationAssetAdmissionController.java @@ -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 admit(@RequestPart("file") MultipartFile file) { + return success(service.admit(file, getLoginUserId())); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/asset/vo/EducationImportAssetRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/asset/vo/EducationImportAssetRespVO.java new file mode 100644 index 00000000..993913a6 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/asset/vo/EducationImportAssetRespVO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/EducationImportAssetDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/EducationImportAssetDO.java new file mode 100644 index 00000000..66b39cb9 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/EducationImportAssetDO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/EducationImportAssetMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/EducationImportAssetMapper.java new file mode 100644 index 00000000..85d2f9fe --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/EducationImportAssetMapper.java @@ -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 { +} 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 f0b47743..82e77379 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 @@ -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, "导入文件内容与声明类型不匹配"); } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/asset/EducationAssetAdmissionService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/asset/EducationAssetAdmissionService.java new file mode 100644 index 00000000..5b57337b --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/asset/EducationAssetAdmissionService.java @@ -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); +} 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 new file mode 100644 index 00000000..aa229439 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/asset/EducationAssetAdmissionServiceImpl.java @@ -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 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)); + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/asset/EducationAssetAdmissionControllerContractTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/asset/EducationAssetAdmissionControllerContractTest.java new file mode 100644 index 00000000..e6ead107 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/asset/EducationAssetAdmissionControllerContractTest.java @@ -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 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"); + } + } +} 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 new file mode 100644 index 00000000..4a09df0d --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/asset/EducationAssetAdmissionServiceImplTest.java @@ -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); + } +} diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java index 9c973c81..0a6f6445 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java @@ -42,6 +42,21 @@ public interface FileApi { String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content, String name, String directory, String type); + /** + * 保存有界文件内容,返回不泄露存储实现的公开描述。 + */ + default FileDescriptor createFile(FileContent content) { + String reference = createFile(content.bytes(), content.name(), content.directory(), content.contentType()); + return new FileDescriptor(reference, content.name(), content.contentType(), content.bytes().length); + } + + /** + * 获取文件恶意内容扫描状态。扫描能力未配置或不可用时必须返回 UNAVAILABLE。 + */ + default FileScanStatus scan(FileDescriptor file) { + return FileScanStatus.UNAVAILABLE; + } + /** * 生成文件预签名地址,用于读取 * diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java index 2514e448..f5d8da82 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java @@ -1,10 +1,11 @@ package cn.iocoder.yudao.module.infra.api.file; import cn.iocoder.yudao.module.infra.service.file.FileService; -import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; +import java.util.Objects; + /** * 文件 API 实现类 * @@ -14,8 +15,11 @@ import org.springframework.validation.annotation.Validated; @Validated public class FileApiImpl implements FileApi { - @Resource - private FileService fileService; + private final FileService fileService; + + public FileApiImpl(FileService fileService) { + this.fileService = Objects.requireNonNull(fileService); + } @Override public String createFile(byte[] content, String name, String directory, String type) { diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileContent.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileContent.java new file mode 100644 index 00000000..a42b712b --- /dev/null +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileContent.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.infra.api.file; + +import java.util.Arrays; + +/** Bounded file content accepted by the public Infra file boundary. */ +public record FileContent(byte[] bytes, String name, String directory, String contentType) { + + public static final int MAX_CONTENT_BYTES = 20 * 1024 * 1024; + + public FileContent { + if (bytes == null || bytes.length == 0) { + throw new IllegalArgumentException("file content must not be empty"); + } + if (bytes.length > MAX_CONTENT_BYTES) { + throw new IllegalArgumentException("file content exceeds public contract limit"); + } + bytes = Arrays.copyOf(bytes, bytes.length); + } + + @Override + public byte[] bytes() { + return Arrays.copyOf(bytes, bytes.length); + } +} diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileDescriptor.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileDescriptor.java new file mode 100644 index 00000000..0eb575df --- /dev/null +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileDescriptor.java @@ -0,0 +1,14 @@ +package cn.iocoder.yudao.module.infra.api.file; + +/** Public, storage-agnostic description of an accepted file. */ +public record FileDescriptor(String reference, String name, String contentType, long size) { + + public FileDescriptor { + if (reference == null || reference.isBlank()) { + throw new IllegalArgumentException("file reference must not be blank"); + } + if (size < 0 || size > FileContent.MAX_CONTENT_BYTES) { + throw new IllegalArgumentException("file size is outside public contract bounds"); + } + } +} diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileScanStatus.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileScanStatus.java new file mode 100644 index 00000000..5538130f --- /dev/null +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileScanStatus.java @@ -0,0 +1,8 @@ +package cn.iocoder.yudao.module.infra.api.file; + +/** Malware scanning outcome exposed without scanner implementation details. */ +public enum FileScanStatus { + CLEAN, + INFECTED, + UNAVAILABLE +} diff --git a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/api/file/FileApiImplTest.java b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/api/file/FileApiImplTest.java new file mode 100644 index 00000000..753054de --- /dev/null +++ b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/api/file/FileApiImplTest.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.infra.api.file; + +import cn.iocoder.yudao.module.infra.service.file.FileService; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class FileApiImplTest { + + @Test + void storesBoundedContentAsPublicDescriptor() { + FileService fileService = mock(FileService.class); + when(fileService.createFile(new byte[]{1, 2, 3}, "questions.csv", "education/imports", "text/csv")) + .thenReturn("https://storage.example/internal/object"); + FileApiImpl api = new FileApiImpl(fileService); + + FileDescriptor descriptor = api.createFile(new FileContent( + new byte[]{1, 2, 3}, "questions.csv", "education/imports", "text/csv")); + + assertEquals("https://storage.example/internal/object", descriptor.reference()); + assertEquals("questions.csv", descriptor.name()); + assertEquals("text/csv", descriptor.contentType()); + assertEquals(3, descriptor.size()); + verify(fileService).createFile(new byte[]{1, 2, 3}, "questions.csv", "education/imports", "text/csv"); + } + + @Test + void scannerUnavailableNeverReportsClean() { + FileApiImpl api = new FileApiImpl(mock(FileService.class)); + + assertEquals(FileScanStatus.UNAVAILABLE, + api.scan(new FileDescriptor("opaque", "questions.csv", "text/csv", 3))); + } + + @Test + void rejectsContentOutsidePublicContractBound() { + assertThrows(IllegalArgumentException.class, + () -> new FileContent(new byte[FileContent.MAX_CONTENT_BYTES + 1], "large.csv", null, "text/csv")); + } +}