forked from wangziqi/ruoyi-vue-pro
feat(education): admit secure import assets
This commit is contained in:
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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, "导入文件内容与声明类型不匹配");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user