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