[파일 업로드] tusHook 작업

- 멀티업로드 상태 업로드
This commit is contained in:
2026-03-09 10:45:00 +09:00
parent 47a5e85789
commit 673635ffeb
6 changed files with 153 additions and 17 deletions
@@ -8,8 +8,6 @@ import com.alist.api.common.modules.file.form.TusHookForm;
import com.alist.api.common.modules.file.form.UploadStatusForm; import com.alist.api.common.modules.file.form.UploadStatusForm;
import com.alist.api.common.response.ApiResponse; import com.alist.api.common.response.ApiResponse;
import com.alist.api.common.response.ApiResponseCode; import com.alist.api.common.response.ApiResponseCode;
import com.alist.api.common.modules.file.dto.UploadStartDto;
import com.alist.api.common.modules.file.form.UploadStartForm;
import com.alist.api.common.modules.file.service.FileService; import com.alist.api.common.modules.file.service.FileService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@@ -102,9 +100,11 @@ public class FileController {
@PostMapping("/tusHook") @PostMapping("/tusHook")
public ResponseEntity<ApiResponse<String>> tusHook(@RequestBody TusHookForm tusHookForm) { public ResponseEntity<ApiResponse<String>> tusHook(@RequestBody TusHookForm tusHookForm) {
boolean accepted = fileService.updateFileUploadStatus(tusHookForm.tusHookDto());
fileService.updateFileUploadStatus(tusHookForm.tusHookDto()); if (accepted) {
fileService.saveUploadStatusRedisTusHook(tusHookForm.tusHookDto()); fileService.saveUploadStatusRedisTusHook(tusHookForm.tusHookDto());
}
return ApiResponse.entity("", ApiResponseCode.CODE_200); return ApiResponse.entity("", ApiResponseCode.CODE_200);
} }
@@ -20,4 +20,10 @@ public interface FileMapper {
void updateFileDetailStatus(TusHookDto tusHookDto); void updateFileDetailStatus(TusHookDto tusHookDto);
void updateFileMasterAggregateByFileUuid(String fileUuid); void updateFileMasterAggregateByFileUuid(String fileUuid);
FileUploadMetaDto selectFileUploadMetaByFileUuid(String fileUuid);
void updateTusUploadIdIfNull(FileUploadMetaDto fileUploadMetaDto);
void updateFileDetailCanceledByFileUuid(String fileUuid);
} }
@@ -31,6 +31,9 @@ public class FileService {
@Value("${file.upload.auth-cache.ttl-seconds:20}") @Value("${file.upload.auth-cache.ttl-seconds:20}")
private long uploadAuthCacheTtlSeconds; private long uploadAuthCacheTtlSeconds;
@Value("${file.upload.interrupt-seconds:30}")
private long uploadInterruptSeconds;
private final FileMapper fileMapper; private final FileMapper fileMapper;
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
private final StringRedisTemplate stringRedisTemplate; private final StringRedisTemplate stringRedisTemplate;
@@ -127,29 +130,96 @@ public class FileService {
return null; return null;
} }
uploadStatusDto.setFileUuid((String) map.getOrDefault("fileUuid", uploadStatusDto.getFileUuid())); String fileUuid = (String) map.getOrDefault("fileUuid", uploadStatusDto.getFileUuid());
uploadStatusDto.setStatus((String) map.getOrDefault("status", "PENDING")); String status = (String) map.getOrDefault("status", "PENDING");
uploadStatusDto.setUploadedBytes(parseLong(map.get("uploadedBytes"))); long uploadedBytes = parseLong(map.get("uploadedBytes"));
uploadStatusDto.setTotalBytes(parseLong(map.get("totalBytes"))); long totalBytes = parseLong(map.get("totalBytes"));
uploadStatusDto.setPercent((int) parseLong(map.get("percent"))); int percent = (int) parseLong(map.get("percent"));
uploadStatusDto.setUpdatedAt((String) map.getOrDefault("updatedAt", "")); String updatedAt = (String) map.getOrDefault("updatedAt", "");
if (isStaleUploading(status, updatedAt)) {
status = "CANCELED";
updatedAt = Instant.now().toString();
saveCanceledStatusRedis(fileUuid, uploadedBytes, totalBytes);
fileMapper.updateFileDetailCanceledByFileUuid(fileUuid);
fileMapper.updateFileMasterAggregateByFileUuid(fileUuid);
}
uploadStatusDto.setFileUuid(fileUuid);
uploadStatusDto.setStatus(status);
uploadStatusDto.setUploadedBytes(uploadedBytes);
uploadStatusDto.setTotalBytes(totalBytes);
uploadStatusDto.setPercent(percent);
uploadStatusDto.setUpdatedAt(updatedAt);
return uploadStatusDto; return uploadStatusDto;
} }
/*db에 상태값 저장*/ private boolean isStaleUploading(String status, String updatedAt) {
@Transactional if (!"UPLOADING".equalsIgnoreCase(status)) {
public void updateFileUploadStatus(TusHookDto tusHookDto) { return false;
if (tusHookDto == null || tusHookDto.getFileUuid() == null || tusHookDto.getFileUuid().isBlank()) { }
return; if (updatedAt == null || updatedAt.isBlank()) {
return false;
}
try {
Instant lastUpdatedAt = Instant.parse(updatedAt);
long elapsedSeconds = Duration.between(lastUpdatedAt, Instant.now()).getSeconds();
return elapsedSeconds >= safeInterruptSeconds();
} catch (Exception e) {
log.warn("upload status updatedAt parse fail. updatedAt={}", updatedAt, e);
return false;
}
} }
private long safeInterruptSeconds() {
return (uploadInterruptSeconds > 0) ? uploadInterruptSeconds : 30L;
}
/*db에 상태값 저장*/
@Transactional
public boolean updateFileUploadStatus(TusHookDto tusHookDto) {
if (tusHookDto == null || tusHookDto.getFileUuid() == null || tusHookDto.getFileUuid().isBlank()) {
return true;
}
String fileUuid = tusHookDto.getFileUuid().trim();
String type = tusHookDto.getType() == null ? "" : tusHookDto.getType().trim().toLowerCase(); String type = tusHookDto.getType() == null ? "" : tusHookDto.getType().trim().toLowerCase();
String uploadId = tusHookDto.getUploadId() == null ? "" : tusHookDto.getUploadId().trim();
long offset = tusHookDto.getOffset() == null ? 0L : tusHookDto.getOffset(); long offset = tusHookDto.getOffset() == null ? 0L : tusHookDto.getOffset();
long size = tusHookDto.getSize() == null ? 0L : tusHookDto.getSize(); long size = tusHookDto.getSize() == null ? 0L : tusHookDto.getSize();
fileMapper.insertFileUploadEventLog(tusHookDto); fileMapper.insertFileUploadEventLog(tusHookDto);
FileUploadMetaDto fileUploadMetaInfo = fileMapper.selectFileUploadMetaByFileUuid(fileUuid);
if (fileUploadMetaInfo == null) return true;
if (isMetadataMismatch(fileUploadMetaInfo, tusHookDto)) {
fileMapper.updateFileDetailCanceledByFileUuid(fileUuid);
fileMapper.updateFileMasterAggregateByFileUuid(fileUuid);
saveCanceledStatusRedis(fileUuid, offset, size);
return false;
}
if (!uploadId.isBlank()) {
if (fileUploadMetaInfo.getTusUploadId() == null || fileUploadMetaInfo.getTusUploadId().isBlank()) {
FileUploadMetaDto fileUploadMetaDto = new FileUploadMetaDto();
fileUploadMetaDto.setFileUuid(fileUuid);
fileUploadMetaDto.setTusUploadId(uploadId);
fileMapper.updateTusUploadIdIfNull(fileUploadMetaDto);
fileUploadMetaInfo = fileMapper.selectFileUploadMetaByFileUuid(fileUuid);
}
if (fileUploadMetaInfo.getTusUploadId() == null || fileUploadMetaInfo.getTusUploadId().isBlank()
|| !fileUploadMetaInfo.getTusUploadId().equals(uploadId)) {
fileMapper.updateFileDetailCanceledByFileUuid(fileUuid);
fileMapper.updateFileMasterAggregateByFileUuid(fileUuid);
saveCanceledStatusRedis(fileUuid, offset, size);
return false;
}
}
tusHookDto.setUploadedBytes(offset); tusHookDto.setUploadedBytes(offset);
switch (type) { switch (type) {
@@ -164,7 +234,7 @@ public class FileService {
tusHookDto.setStatus(5); // CANCELED (정책에 따라 FAILED=4) tusHookDto.setStatus(5); // CANCELED (정책에 따라 FAILED=4)
break; break;
default: default:
return; return false;
} }
fileMapper.updateFileDetailStatus(tusHookDto); fileMapper.updateFileDetailStatus(tusHookDto);
@@ -172,6 +242,8 @@ public class FileService {
if ("post-finish".equals(type) || "post-terminate".equals(type)) { if ("post-finish".equals(type) || "post-terminate".equals(type)) {
fileMapper.updateFileMasterAggregateByFileUuid(tusHookDto.getFileUuid()); fileMapper.updateFileMasterAggregateByFileUuid(tusHookDto.getFileUuid());
} }
return true;
} }
/*레이디스에 상태값 저장*/ /*레이디스에 상태값 저장*/
@@ -209,6 +281,20 @@ public class FileService {
stringRedisTemplate.expire(key, Duration.ofHours(24)); stringRedisTemplate.expire(key, Duration.ofHours(24));
} }
private void saveCanceledStatusRedis(String fileUuid, long uploadedBytes, long totalBytes) {
String key = buildUploadStatusKey(fileUuid);
int percent = (totalBytes > 0) ? (int) Math.min(100, (uploadedBytes * 100) / totalBytes) : 0;
HashOperations<String, Object, Object> ops = stringRedisTemplate.opsForHash();
ops.put(key, "fileUuid", fileUuid);
ops.put(key, "status", "CANCELED");
ops.put(key, "uploadedBytes", String.valueOf(Math.max(0, uploadedBytes)));
ops.put(key, "totalBytes", String.valueOf(Math.max(0, totalBytes)));
ops.put(key, "percent", String.valueOf(percent));
ops.put(key, "updatedAt", Instant.now().toString());
stringRedisTemplate.expire(key, Duration.ofHours(24));
}
private String buildUploadStatusKey(String fileUuid) { private String buildUploadStatusKey(String fileUuid) {
return "upload:alist:status:" + fileUuid; return "upload:alist:status:" + fileUuid;
} }
@@ -252,4 +338,20 @@ public class FileService {
// 설정 실수 방어: 0/음수면 기본값 20초 사용 // 설정 실수 방어: 0/음수면 기본값 20초 사용
return (uploadAuthCacheTtlSeconds > 0) ? uploadAuthCacheTtlSeconds : 20L; return (uploadAuthCacheTtlSeconds > 0) ? uploadAuthCacheTtlSeconds : 20L;
} }
private boolean isMetadataMismatch(FileUploadMetaDto fileUploadMetaDto, TusHookDto tusHookDto) {
if (tusHookDto.getSize() != null && fileUploadMetaDto.getSizeBytes() != null && !tusHookDto.getSize().equals(fileUploadMetaDto.getSizeBytes())) {
return true;
}
if (tusHookDto.getFilename() != null && !tusHookDto.getFilename().isBlank()
&& fileUploadMetaDto.getOriginName() != null && !fileUploadMetaDto.getOriginName().equals(tusHookDto.getFilename())) {
return true;
}
if (tusHookDto.getFiletype() != null && !tusHookDto.getFiletype().isBlank()
&& fileUploadMetaDto.getContentType() != null && !fileUploadMetaDto.getContentType().isBlank()
&& !fileUploadMetaDto.getContentType().equalsIgnoreCase(tusHookDto.getFiletype())) {
return true;
}
return false;
}
} }
@@ -46,6 +46,7 @@ file:
upload: upload:
tus-endpoint: https://file-alist.pjt.kr/tus/files/ tus-endpoint: https://file-alist.pjt.kr/tus/files/
public-base-url: https://file-alist.pjt.kr public-base-url: https://file-alist.pjt.kr
interrupt-seconds: 30
auth-cache: auth-cache:
ttl-seconds: 20 ttl-seconds: 20
+1
View File
@@ -48,6 +48,7 @@ file:
upload: upload:
tus-endpoint: https://file-alist.pjt.kr/tus/files/ tus-endpoint: https://file-alist.pjt.kr/tus/files/
public-base-url: https://file-alist.pjt.kr public-base-url: https://file-alist.pjt.kr
interrupt-seconds: 30
auth-cache: auth-cache:
ttl-seconds: 20 ttl-seconds: 20
@@ -96,6 +96,24 @@
END, END,
LFM.UPDATE_DATE = NOW() LFM.UPDATE_DATE = NOW()
</update> </update>
<update id="updateTusUploadIdIfNull" parameterType="com.alist.api.common.modules.file.dto.FileUploadMetaDto">
/*FileMapper.updateTusUploadIdIfNull*/
UPDATE ALISTLMS.FILE_DETAIL
SET TUS_UPLOAD_ID = #{tusUploadId}
, UPDATE_DATE = NOW()
WHERE FILE_UUID = #{fileUuid}
AND DEL_YN = 'N'
AND TUS_UPLOAD_ID IS NULL
</update>
<update id="updateFileDetailCanceledByFileUuid">
/*FileMapper.updateFileDetailCanceledByFileUuid*/
UPDATE ALISTLMS.FILE_DETAIL
SET STATUS = 5,
UPDATE_DATE = NOW()
WHERE FILE_UUID = #{fileUuid}
AND DEL_YN = 'N'
AND STATUS IN (0,1,2)
</update>
<select id="selectFileMasterCountByFileMasterKeyAndDelYnAndStatus" resultType="int"> <select id="selectFileMasterCountByFileMasterKeyAndDelYnAndStatus" resultType="int">
@@ -119,4 +137,12 @@
AND LFD.STATUS IN (0,1,2,3) <!-- 정책에 맞게 조정 --> AND LFD.STATUS IN (0,1,2,3) <!-- 정책에 맞게 조정 -->
AND LTUT.user_token_idx = #{userTokenIdx} AND LTUT.user_token_idx = #{userTokenIdx}
</select> </select>
<select id="selectFileUploadMetaByFileUuid" resultType="com.alist.api.common.modules.file.dto.FileUploadMetaDto">
/*FileMapper.selectFileUploadMetaByFileUuid*/
SELECT FILE_UUID, ORIGIN_NAME, SIZE_BYTES, CONTENT_TYPE, TUS_UPLOAD_ID, STATUS
FROM ALISTLMS.FILE_DETAIL
WHERE FILE_UUID = #{fileUuid}
AND DEL_YN = 'N'
LIMIT 1
</select>
</mapper> </mapper>