feat(education): add production file scanning

This commit is contained in:
2026-07-31 22:42:20 +08:00
parent c8a221acbc
commit 453193e857
20 changed files with 371 additions and 13 deletions

View File

@@ -63,7 +63,7 @@ public class EducationAssetAdmissionServiceImpl implements EducationAssetAdmissi
asset.setOriginalName(name);
asset.setContentType(contentType);
asset.setSize((long) bytes.length);
asset.setSha256(DigestUtil.sha256Hex(bytes));
asset.setSha256(descriptor.checksumSha256() != null ? descriptor.checksumSha256() : DigestUtil.sha256Hex(bytes));
asset.setFileReference(descriptor.reference());
asset.setScanStatus(scanStatus.name());
assetMapper.insert(asset);

View File

@@ -1,6 +1,6 @@
package cn.iocoder.yudao.module.education.service.importjob;
public interface ImportObjectScanGateway {
enum ScanResult { CLEAN, INFECTED, ERROR }
ScanResult scan(String objectKey);
enum ScanResult { CLEAN, INFECTED, ERROR, UNAVAILABLE }
ScanResult scan(String reference, String name, String mimeType, long size, String checksumSha256);
}

View File

@@ -0,0 +1,27 @@
package cn.iocoder.yudao.module.education.service.importjob;
import cn.iocoder.yudao.module.infra.api.file.FileApi;
import cn.iocoder.yudao.module.infra.api.file.FileDescriptor;
import cn.iocoder.yudao.module.infra.api.file.FileScanStatus;
import org.springframework.stereotype.Service;
@Service
public class InfraFileImportObjectScanGateway implements ImportObjectScanGateway {
private final FileApi fileApi;
public InfraFileImportObjectScanGateway(FileApi fileApi) {
this.fileApi = fileApi;
}
@Override
public ScanResult scan(String reference, String name, String mimeType, long size, String checksumSha256) {
FileScanStatus status;
try {
status = fileApi.scan(new FileDescriptor(reference, name, mimeType, size, checksumSha256));
} catch (RuntimeException ex) {
return ScanResult.ERROR;
}
return status == null ? ScanResult.UNAVAILABLE : ScanResult.valueOf(status.name());
}
}

View File

@@ -133,7 +133,8 @@ public class QuestionImportJobServiceImpl implements QuestionImportJobService {
finishFailed(job, token, "SCAN_UNAVAILABLE");
throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN);
}
ImportObjectScanGateway.ScanResult scanResult = gateway.scan(asset.getObjectKey());
ImportObjectScanGateway.ScanResult scanResult = gateway.scan(asset.getObjectKey(), asset.getFileName(),
asset.getMimeType(), asset.getFileSizeBytes(), asset.getChecksumSha256());
if (scanResult != ImportObjectScanGateway.ScanResult.CLEAN) {
finishFailed(job, token, "SCAN_" + scanResult.name());
throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN);

View File

@@ -0,0 +1,39 @@
package cn.iocoder.yudao.module.education.service.importjob;
import cn.iocoder.yudao.module.infra.api.file.FileApi;
import cn.iocoder.yudao.module.infra.api.file.FileDescriptor;
import cn.iocoder.yudao.module.infra.api.file.FileScanStatus;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;
class InfraFileImportObjectScanGatewayTest {
@Test
void passesCompleteDescriptorAndMapsEveryStatus() {
FileApi fileApi = mock(FileApi.class);
InfraFileImportObjectScanGateway gateway = new InfraFileImportObjectScanGateway(fileApi);
FileDescriptor descriptor = new FileDescriptor("ref", "questions.csv", "text/csv", 3,
"039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81");
for (FileScanStatus status : FileScanStatus.values()) {
when(fileApi.scan(descriptor)).thenReturn(status);
assertEquals(ImportObjectScanGateway.ScanResult.valueOf(status.name()),
gateway.scan(descriptor.reference(), descriptor.name(), descriptor.contentType(),
descriptor.size(), descriptor.checksumSha256()));
}
verify(fileApi, times(FileScanStatus.values().length)).scan(descriptor);
}
@Test
void nullAndExceptionsFailClosed() {
FileApi fileApi = mock(FileApi.class);
InfraFileImportObjectScanGateway gateway = new InfraFileImportObjectScanGateway(fileApi);
when(fileApi.scan(any())).thenReturn(null).thenThrow(new IllegalStateException("down"));
assertEquals(ImportObjectScanGateway.ScanResult.UNAVAILABLE,
gateway.scan("ref", "x", null, 1, null));
assertEquals(ImportObjectScanGateway.ScanResult.ERROR,
gateway.scan("ref", "x", null, 1, null));
}
}

View File

@@ -79,7 +79,8 @@ class QuestionImportJobServiceImplTest {
when(jobMapper.claimById(eq(101L), eq("PREVIEW_PENDING"), anyString(), anyString(), anyLong()))
.thenReturn(claimed);
when(assetMapper.selectTenantAsset(10L, 88L)).thenReturn(asset(88L));
when(scanGateway.scan("object/10/questions.csv")).thenReturn(ImportObjectScanGateway.ScanResult.CLEAN);
when(scanGateway.scan("object/10/questions.csv", "questions.csv", "text/csv", 128L, "checksum"))
.thenReturn(ImportObjectScanGateway.ScanResult.CLEAN);
when(jobMapper.finishClaim(eq(10L), eq(101L), eq("PREVIEW_PENDING"), eq("PREVIEW_READY"),
anyString(), eq("CLEAN"), eq("UNAVAILABLE"), contains("METADATA_ONLY"), isNull(), isNull(),
isNull(), isNull(), isNull(), isNull())).thenReturn(1);
@@ -106,6 +107,28 @@ class QuestionImportJobServiceImplTest {
verifyNoInteractions(parser, lifecycleService);
}
@Test
void nonCleanStatusesHaveStableFailureCodesAndNeverParse() {
for (ImportObjectScanGateway.ScanResult result : List.of(
ImportObjectScanGateway.ScanResult.INFECTED,
ImportObjectScanGateway.ScanResult.ERROR,
ImportObjectScanGateway.ScanResult.UNAVAILABLE)) {
reset(jobMapper, assetMapper, scanGateway, parser, lifecycleService);
when(jobMapper.claimById(eq(101L), eq("PREVIEW_PENDING"), anyString(), anyString(), anyLong()))
.thenReturn(job(101L, "PREVIEW_PENDING"));
when(assetMapper.selectTenantAsset(10L, 88L)).thenReturn(asset(88L));
when(scanGateway.scan("object/10/questions.csv", "questions.csv", "text/csv", 128L, "checksum"))
.thenReturn(result);
assertThrows(ServiceException.class, () -> service.preview(101L));
verify(jobMapper).finishClaim(eq(10L), eq(101L), eq("PREVIEW_PENDING"), eq("FAILED"),
anyString(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(),
eq("SCAN_" + result.name()), eq("SCAN_" + result.name()));
verifyNoInteractions(parser, lifecycleService);
}
}
@Test
void executePropagatesClaimedTenantAndCreatesDrafts() {
TenantContextHolder.clear();
@@ -131,7 +154,7 @@ class QuestionImportJobServiceImplTest {
private ContentImportAssetDO asset(Long id) {
ContentImportAssetDO asset = new ContentImportAssetDO(); asset.setId(id); asset.setTenantId(10L);
asset.setObjectKey("object/10/questions.csv"); asset.setFileName("questions.csv");
asset.setMimeType("text/csv"); asset.setFileSizeBytes(128L); return asset;
asset.setMimeType("text/csv"); asset.setFileSizeBytes(128L); asset.setChecksumSha256("checksum"); return asset;
}
private ContentImportJobDO job(Long id, String status) {