1
0

feat(web): 增加文件上传接口

This commit is contained in:
2024-11-21 12:25:19 +08:00
parent 6d63c20b7f
commit d68f8a27ee
10 changed files with 338 additions and 6 deletions

View File

@@ -83,6 +83,14 @@ export function paginationTemplate(perPage = 20, maxButtons = 5) {
}
}
export function inputFileFormItemCommonOptions(accept = '*', maxSize = 5242880) {
return {
accept: accept,
maxSize: maxSize,
autoUpload: false,
}
}
export function copyField(field, tips = '复制', ignoreLength = 0, extra = undefined) {
let tpl = ignoreLength === 0 ? `\${${field}}` : `\${TRUNCATE(${field}, ${ignoreLength})}`
let content = extra

View File

@@ -1,5 +1,5 @@
import './dialog-resource.css'
import {apiPost, horizontalFormOptions} from "../constants.js";
import {apiPost, horizontalFormOptions, inputFileFormItemCommonOptions} from "../constants.js";
const clearable = {
clearable: true,
@@ -99,8 +99,8 @@ export function resourceAddDialog() {
{
type: 'input-file',
name: 'filePath',
accept: '.zip',
description: '只适合小于2M的资源文件使用大文件请使用其他资源类型',
...inputFileFormItemCommonOptions('.zip', 2097152),
},
]
},
@@ -148,13 +148,13 @@ export function resourceAddDialog() {
type: 'input-file',
description: 'core-site.xml',
name: 'coreSiteFile',
accept: '.xml',
...inputFileFormItemCommonOptions('.xml', 1048576),
},
{
type: 'input-file',
description: 'hdfs-site.xml',
name: 'hdfsSiteFile',
accept: '.xml',
...inputFileFormItemCommonOptions('.xml', 1048576),
},
]
},
@@ -257,7 +257,7 @@ export function resourceAddDialog() {
label: '资源示例',
description: '可以上传用于作为格式示范的样例数据',
name: 'example',
accept: '*',
...inputFileFormItemCommonOptions(),
},
]
}

View File

@@ -1,4 +1,5 @@
import {resourceAddDialog} from "../../components/resource/dialog-resource.js";
import {apiPost, inputFileFormItemCommonOptions} from "../../components/constants.js";
export function tabData() {
return {
@@ -10,6 +11,21 @@ export function tabData() {
label: '',
icon: 'fa fa-plus',
...resourceAddDialog()
},
{
type: 'form',
body: [
{
type: 'input-file',
name: 'file',
label: '测试文件上传',
...inputFileFormItemCommonOptions(undefined, 1073741824),
useChunk: true,
startChunkApi: apiPost('${base}/upload/start'),
chunkApi: apiPost('${base}/upload/slice'),
finishChunkApi: apiPost('${base}/upload/finish')
}
]
}
]
}

View File

@@ -0,0 +1,18 @@
package com.eshore.gringotts.web.configuration;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* 上传文件相关配置
*
* @author lanyuanxiaoyao
* @date 2024-11-21
*/
@Data
@ConfigurationProperties(prefix = "gringotts.upload")
@Configuration
public class UploadConfiguration {
private String uploadPath = "./upload";
}

View File

@@ -0,0 +1,29 @@
package com.eshore.gringotts.web.domain.entity;
import java.time.LocalDateTime;
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
/**
* 实体类公共字段
*
* @author lanyuanxiaoyao
* @date 2024-11-20
*/
@Getter
@Setter
@ToString
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class DatetimeOnlyEntity extends IdOnlyEntity {
@CreatedDate
private LocalDateTime createdTime;
@LastModifiedDate
private LocalDateTime modifiedTime;
}

View File

@@ -0,0 +1,162 @@
package com.eshore.gringotts.web.domain.upload.controller;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import com.eshore.gringotts.web.configuration.UploadConfiguration;
import com.eshore.gringotts.web.configuration.amis.AmisResponse;
import com.eshore.gringotts.web.domain.upload.service.UploadService;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.channels.FileChannel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.eclipse.collections.api.list.ImmutableList;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* 文件上传接口
*
* @author lanyuanxiaoyao
* @date 2024-11-21
*/
@RestController
@RequestMapping("/upload")
public class UploadController {
private static final Logger logger = LoggerFactory.getLogger(UploadController.class);
private final UploadService uploadService;
private final String uploadFolderPath;
private final String cacheFolderPath;
private final String sliceFolderPath;
public UploadController(UploadConfiguration uploadConfiguration, UploadService uploadService) {
this.uploadService = uploadService;
this.uploadFolderPath = uploadConfiguration.getUploadPath();
this.cacheFolderPath = StrUtil.format("{}/cache", uploadFolderPath);
this.sliceFolderPath = StrUtil.format("{}/slice", uploadFolderPath);
}
@PostMapping("/start")
public AmisResponse<StartResponse> start(@RequestBody StartRequest request) {
logger.info("Request: {}", request);
Long id = uploadService.initialDataFile(request.filename);
return AmisResponse.responseSuccess(new StartResponse(id.toString()));
}
@PostMapping("/slice")
public AmisResponse<SliceResponse> slice(
@RequestParam("uploadId")
Long uploadId,
@RequestParam("partNumber")
Integer sequence,
@RequestParam("partSize")
Long size,
@RequestParam("file")
MultipartFile file
) throws IOException {
logger.info("UploadId: {}, sequence: {}, size: {}, file: {}", uploadId, sequence, size, file.getName());
byte[] bytes = file.getBytes();
String md5 = SecureUtil.md5(new ByteArrayInputStream(bytes));
String targetFilename = StrUtil.format("{}-{}", sequence, md5);
String targetFilePath = sliceFilePath(uploadId, targetFilename);
FileUtil.mkParentDirs(targetFilePath);
FileUtil.writeBytes(bytes, targetFilePath);
return AmisResponse.responseSuccess(new SliceResponse(targetFilename));
}
private String sliceFilePath(Long uploadId, String sliceFilename) {
return StrUtil.format("{}/{}/{}", sliceFolderPath, uploadId, sliceFilename);
}
@PostMapping("finish")
public AmisResponse<FinishResponse> finish(@RequestBody FinishRequest request) {
logger.info("Request: {}", request);
if (request.partList.anySatisfy(part -> !FileUtil.exist(sliceFilePath(request.uploadId, part.eTag)))) {
throw new RuntimeException("文件校验失败,请重新上传");
}
try {
File cacheFile = new File(StrUtil.format("{}/{}", cacheFolderPath, request.uploadId));
FileUtil.mkParentDirs(cacheFile);
if (cacheFile.createNewFile()) {
try (FileOutputStream fos = new FileOutputStream(cacheFile)) {
try (FileChannel fosChannel = fos.getChannel()) {
for (FinishRequest.Part part : request.partList) {
File sliceFile = new File(sliceFilePath(request.uploadId, part.eTag));
try (FileInputStream fis = new FileInputStream(sliceFile)) {
try (FileChannel fisChannel = fis.getChannel()) {
fisChannel.transferTo(0, fisChannel.size(), fosChannel);
}
}
}
}
}
File targetFile = new File(StrUtil.format("{}/{}", uploadFolderPath, request.uploadId));
FileUtil.move(cacheFile, targetFile, true);
uploadService.updateDataFile(request.uploadId, FileUtil.getAbsolutePath(targetFile), FileUtil.size(targetFile), SecureUtil.md5(targetFile));
return AmisResponse.responseSuccess(new FinishResponse(request.uploadId.toString()));
} else {
throw new RuntimeException("合并文件失败");
}
} catch (Throwable throwable) {
throw new RuntimeException(throwable);
} finally {
FileUtil.del(StrUtil.format("{}/{}", sliceFolderPath, request.uploadId));
}
}
@Data
public static final class StartRequest {
private String name;
private String filename;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static final class StartResponse {
private String uploadId;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static final class SliceResponse {
@JsonProperty("eTag")
private String eTag;
}
@Data
public static final class FinishRequest {
private String filename;
private Long uploadId;
private ImmutableList<Part> partList;
@Data
public static final class Part {
private Integer partNumber;
@JsonProperty("eTag")
private String eTag;
}
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static final class FinishResponse {
private String value;
}
}

View File

@@ -0,0 +1,26 @@
package com.eshore.gringotts.web.domain.upload.entity;
import com.eshore.gringotts.web.domain.entity.SimpleEntity;
import javax.persistence.Entity;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.annotations.DynamicUpdate;
/**
* 上传文件
*
* @author lanyuanxiaoyao
* @date 2024-11-21
*/
@Getter
@Setter
@ToString
@Entity
@DynamicUpdate
public class DataFile extends SimpleEntity {
private String filename;
private Long size;
private String md5;
private String path;
}

View File

@@ -0,0 +1,13 @@
package com.eshore.gringotts.web.domain.upload.repository;
import com.blinkfox.fenix.jpa.FenixJpaRepository;
import com.blinkfox.fenix.specification.FenixJpaSpecificationExecutor;
import com.eshore.gringotts.web.domain.upload.entity.DataFile;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Repository
public interface DataFileRepository extends FenixJpaRepository<DataFile, Long>, FenixJpaSpecificationExecutor<DataFile> {
}

View File

@@ -0,0 +1,53 @@
package com.eshore.gringotts.web.domain.upload.service;
import com.eshore.gringotts.web.domain.upload.entity.DataFile;
import com.eshore.gringotts.web.domain.upload.repository.DataFileRepository;
import com.eshore.gringotts.web.domain.user.entity.User;
import com.eshore.gringotts.web.domain.user.service.UserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/**
* 上传服务
*
* @author lanyuanxiaoyao
* @date 2024-11-21
*/
@Service
public class UploadService {
private static final Logger logger = LoggerFactory.getLogger(UploadService.class);
private final DataFileRepository dataFileRepository;
private final UserService userService;
public UploadService(DataFileRepository dataFileRepository, UserService userService) {
this.dataFileRepository = dataFileRepository;
this.userService = userService;
}
public Long initialDataFile(String filename) {
DataFile dataFile = new DataFile();
dataFile.setFilename(filename);
User loginUser = userService.currentLoginUser();
dataFile.setCreatedUser(loginUser);
dataFile.setModifiedUser(loginUser);
return dataFileRepository.save(dataFile).getId();
}
public void updateDataFile(Long id, String path, Long size, String md5) {
DataFile dataFile = dataFileRepository.findById(id).orElseThrow(UpdateDataFileFailedException::new);
dataFile.setSize(size);
dataFile.setMd5(md5);
dataFile.setPath(path);
User loginUser = userService.currentLoginUser();
dataFile.setModifiedUser(loginUser);
dataFileRepository.save(dataFile);
}
public static final class UpdateDataFileFailedException extends RuntimeException {
public UpdateDataFileFailedException() {
super("更新文件信息失败,请重新上传");
}
}
}

View File

@@ -13,6 +13,10 @@ spring:
jpa:
generate-ddl: true
show-sql: true
servlet:
multipart:
max-file-size: 10MB
max-request-size: 20MB
fenix:
print-banner: false
print-sql: false
@@ -27,4 +31,7 @@ logging:
bcos:
sdk:
eventsub:
EventSubscribeImp: error
EventSubscribeImp: error
gringotts:
upload:
upload-path: /Users/lanyuanxiaoyao/Project/IdeaProjects/gringotts/gringotts-web/target/upload