[파일 업로드] tusHook 작업
- 파일 이동작업
This commit is contained in:
@@ -21,7 +21,10 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@Slf4j
|
||||
@Tag(name = "99. 파일 관리", description = "파일 관리")
|
||||
@Tag(
|
||||
name = "99. TUS 업로드",
|
||||
description = "TUS 기반 파일 업로드 초기화, 권한 검증, 상태 조회, 훅 처리, 취소 API"
|
||||
)
|
||||
@RestController
|
||||
@RequestMapping("/files")
|
||||
public class FileController {
|
||||
@@ -32,8 +35,8 @@ public class FileController {
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "파일 업로드 준비(db 입력 및 업로드 토큰 생성)",
|
||||
description = "업로드 토큰 생성 및 파일 업로드 준비"
|
||||
summary = "파일 업로드 초기화",
|
||||
description = "업로드 대상 파일 정보를 DB에 등록하고 fileUuid/uploadToken을 발급합니다."
|
||||
)
|
||||
@PostMapping("/uploadInit")
|
||||
public ResponseEntity<ApiResponse<FileUploadDto>> uploadInit(
|
||||
@@ -59,8 +62,8 @@ public class FileController {
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "업로드 권한 체크",
|
||||
description = "업로드 토큰 발급 후 실제 업로드 권한이 있는지 확인 과정입니다."
|
||||
summary = "업로드 권한 검증",
|
||||
description = "Bearer 업로드 토큰과 fileUuid(및 TUS 메타데이터)를 검증해 업로드 가능 여부를 확인합니다."
|
||||
)
|
||||
@GetMapping("/uploadAuth")
|
||||
public ResponseEntity<ApiResponse<String>> uploadAuth(
|
||||
@@ -93,6 +96,10 @@ public class FileController {
|
||||
return ApiResponse.entity("", ApiResponseCode.CODE_200);
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "업로드 상태 조회",
|
||||
description = "fileUuid 기준으로 Redis/DB 업로드 상태(진행률, 상태값)를 조회합니다."
|
||||
)
|
||||
@PostMapping("/uploadStatus")
|
||||
public ResponseEntity<ApiResponse<UploadStatusDto>> uploadStatus(
|
||||
@Valid @RequestBody UploadStatusForm uploadStatusForm
|
||||
@@ -104,6 +111,10 @@ public class FileController {
|
||||
return ApiResponse.entity(status, ApiResponseCode.CODE_200);
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "TUS 훅 수신",
|
||||
description = "tusd에서 전달되는 pre-create/post-receive/post-finish/post-terminate 이벤트를 반영합니다."
|
||||
)
|
||||
@PostMapping("/tusHook")
|
||||
public ResponseEntity<ApiResponse<String>> tusHook(@RequestBody TusHookForm tusHookForm) {
|
||||
boolean accepted = fileService.updateFileUploadStatus(tusHookForm.tusHookDto());
|
||||
@@ -115,6 +126,10 @@ public class FileController {
|
||||
return ApiResponse.entity("", ApiResponseCode.CODE_200);
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "업로드 취소",
|
||||
description = "현재 로그인 사용자의 fileUuid 업로드를 취소(CANCELED) 처리합니다."
|
||||
)
|
||||
@PostMapping("/uploadCancel")
|
||||
public ResponseEntity<ApiResponse<String>> uploadCancel(
|
||||
@Valid @RequestBody UploadCancelForm uploadCancelForm
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.alist.api.common.modules.file.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class FileMoveTaskDto {
|
||||
private Long fileDetailIdx;
|
||||
private Long fileMasterIdx;
|
||||
private String fileUuid;
|
||||
private String tusUploadId;
|
||||
private String originName;
|
||||
private String contentType;
|
||||
private Long sizeBytes;
|
||||
private Long uploadedBytes;
|
||||
|
||||
private String saveName;
|
||||
private String savePath;
|
||||
private String ext;
|
||||
|
||||
private String moveYn;
|
||||
private Integer moveTryCount;
|
||||
private String moveLastError;
|
||||
private String moveLockOwner;
|
||||
}
|
||||
@@ -32,4 +32,10 @@ public interface FileMapper {
|
||||
int updateFileDetailCanceledByFileUuidAndUserTokenIdx(UploadCancelDto uploadCancelDto);
|
||||
|
||||
UploadCancelDto selectUploadProgressByFileUuid(UploadCancelDto uploadCancelDto);
|
||||
|
||||
FileMoveTaskDto selectMoveTaskByFileUuid(String fileUuid);
|
||||
|
||||
int updateFileMoveSuccess(FileMoveTaskDto task);
|
||||
|
||||
int updateFileMovePendingByFileUuid(FileMoveTaskDto task);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,14 @@ import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
@@ -34,6 +37,12 @@ public class FileService {
|
||||
@Value("${file.upload.interrupt-seconds:30}")
|
||||
private long uploadInterruptSeconds;
|
||||
|
||||
@Value("${file.upload.tmp-root}")
|
||||
private String uploadTmpRoot;
|
||||
|
||||
@Value("${file.upload.final-root}")
|
||||
private String uploadFinalRoot;
|
||||
|
||||
private final FileMapper fileMapper;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
@@ -306,9 +315,95 @@ public class FileService {
|
||||
fileMapper.updateFileMasterAggregateByFileUuid(tusHookDto.getFileUuid());
|
||||
}
|
||||
|
||||
if ("post-finish".equals(type)) {
|
||||
try {
|
||||
// 1회 빠른 이동 시도 (성공하면 MOVE_YN=Y)
|
||||
tryMoveNowByFileUuid(tusHookDto.getFileUuid());
|
||||
} catch (Exception moveEx) {
|
||||
// 실패하면 큐 처리 대상으로 남기기 (MOVE_YN=N, 에러 기록)
|
||||
markMovePending(tusHookDto.getFileUuid(), truncateErr(moveEx.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void tryMoveNowByFileUuid(String fileUuid) throws IOException {
|
||||
FileMoveTaskDto task = fileMapper.selectMoveTaskByFileUuid(fileUuid);
|
||||
if (task == null) return;
|
||||
if (!"N".equalsIgnoreCase(task.getMoveYn())) return;
|
||||
|
||||
// 임시 경로 계산 (운영 경로 규칙에 맞게 구현)
|
||||
Path tmpPath = resolveTmpPath(task); // ex) /uploads/tmp/{tusUploadId}
|
||||
Path finalPath = buildFinalPath(task); // ex) /uploads/{yyyy}/{MM}/{uuid}.{ext}
|
||||
|
||||
Files.createDirectories(finalPath.getParent());
|
||||
|
||||
try {
|
||||
Files.move(tmpPath, finalPath, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException ex) {
|
||||
// cross-device 대비 fallback
|
||||
Files.copy(tmpPath, finalPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
Files.deleteIfExists(tmpPath);
|
||||
}
|
||||
|
||||
task.setSavePath(toSavePath(finalPath)); // DB 저장용 상대경로
|
||||
task.setSaveName(finalPath.getFileName().toString());
|
||||
task.setExt(extractExt(task.getSaveName()));
|
||||
|
||||
fileMapper.updateFileMoveSuccess(task);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void markMovePending(String fileUuid, String errMsg) {
|
||||
FileMoveTaskDto task = new FileMoveTaskDto();
|
||||
task.setFileUuid(fileUuid);
|
||||
task.setMoveLastError(errMsg == null ? "move failed" : errMsg);
|
||||
fileMapper.updateFileMovePendingByFileUuid(task); // MOVE_YN='N', TRY_COUNT+1, LAST_ERROR
|
||||
}
|
||||
|
||||
private String truncateErr(String s) {
|
||||
if (s == null) return "unknown";
|
||||
return s.length() > 500 ? s.substring(0, 500) : s;
|
||||
}
|
||||
|
||||
private Path resolveTmpPath(FileMoveTaskDto task) {
|
||||
// 예: /srv/project/alist/uploads/tmp/{tusUploadId}
|
||||
if (task.getTusUploadId() == null || task.getTusUploadId().isBlank()) {
|
||||
throw new IllegalStateException("tusUploadId is empty");
|
||||
}
|
||||
return Paths.get(uploadTmpRoot, task.getTusUploadId());
|
||||
}
|
||||
|
||||
private Path buildFinalPath(FileMoveTaskDto task) {
|
||||
String ext = extractExt(task.getOriginName());
|
||||
String fileName = (task.getFileUuid() == null ? UUID.randomUUID().toString() : task.getFileUuid())
|
||||
+ (ext.isBlank() ? "" : "." + ext);
|
||||
|
||||
LocalDate now = LocalDate.now();
|
||||
return Paths.get(uploadFinalRoot,
|
||||
String.valueOf(now.getYear()),
|
||||
String.format("%02d", now.getMonthValue()),
|
||||
String.format("%02d", now.getDayOfMonth()),
|
||||
fileName);
|
||||
}
|
||||
|
||||
private String toSavePath(Path finalPath) {
|
||||
// DB 저장용 상대경로(운영 규칙에 맞게 조정)
|
||||
// 예: /2026/03/09/xxx.zip
|
||||
Path root = Paths.get(uploadFinalRoot);
|
||||
Path rel = root.relativize(finalPath);
|
||||
return "/" + rel.toString().replace("\\", "/");
|
||||
}
|
||||
|
||||
private String extractExt(String name) {
|
||||
if (name == null) return "";
|
||||
int idx = name.lastIndexOf('.');
|
||||
if (idx < 0 || idx == name.length() - 1) return "";
|
||||
return name.substring(idx + 1).trim().toLowerCase();
|
||||
}
|
||||
|
||||
/*레이디스에 상태값 저장*/
|
||||
public void saveUploadStatusRedisTusHook(TusHookDto tusHookDto) {
|
||||
if (tusHookDto == null || tusHookDto.getFileUuid() == null || tusHookDto.getFileUuid().isBlank()) {
|
||||
|
||||
@@ -46,6 +46,8 @@ file:
|
||||
upload:
|
||||
tus-endpoint: https://file-alist.pjt.kr/tus/files/
|
||||
public-base-url: https://file-alist.pjt.kr
|
||||
tmp-root: /srv/project/alist/uploads/tmp
|
||||
final-root: /srv/project/alist/uploads
|
||||
interrupt-seconds: 30
|
||||
auth-cache:
|
||||
ttl-seconds: 20
|
||||
|
||||
@@ -85,74 +85,77 @@
|
||||
)
|
||||
AND LLFD.DEL_YN = 'N'
|
||||
) A ON A.FILE_MASTER_IDX = LFM.FILE_MASTER_IDX
|
||||
SET LFM.TOTAL_COUNT = A.total_count,
|
||||
LFM.DONE_COUNT = A.done_count,
|
||||
LFM.FAIL_COUNT = A.fail_count,
|
||||
LFM.STATUS = CASE
|
||||
WHEN A.done_count = A.total_count THEN 3
|
||||
WHEN A.done_count = 0 AND A.fail_count = A.total_count THEN 4
|
||||
WHEN A.done_count > 0 AND A.fail_count > 0 THEN 5
|
||||
ELSE 1
|
||||
END,
|
||||
LFM.UPDATE_DATE = NOW()
|
||||
SET LFM.TOTAL_COUNT = A.total_count
|
||||
, LFM.DONE_COUNT = A.done_count
|
||||
, LFM.FAIL_COUNT = A.fail_count
|
||||
, LFM.STATUS = CASE
|
||||
WHEN A.done_count = A.total_count THEN 3
|
||||
WHEN A.done_count = 0 AND A.fail_count = A.total_count THEN 4
|
||||
WHEN A.done_count > 0 AND A.fail_count > 0 THEN 5
|
||||
ELSE 1
|
||||
END
|
||||
, LFM.UPDATE_DATE = NOW()
|
||||
</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()
|
||||
, UPDATE_DATE = NOW()
|
||||
WHERE FILE_UUID = #{fileUuid}
|
||||
AND DEL_YN = 'N'
|
||||
AND TUS_UPLOAD_ID IS NULL
|
||||
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()
|
||||
SET STATUS = 5
|
||||
, UPDATE_DATE = NOW()
|
||||
WHERE FILE_UUID = #{fileUuid}
|
||||
AND DEL_YN = 'N'
|
||||
AND STATUS IN (0,1,2)
|
||||
AND DEL_YN = 'N'
|
||||
AND STATUS IN (0,1,2)
|
||||
</update>
|
||||
<update id="updateFileDetailCanceledByFileUuidAndUserTokenIdx">
|
||||
/*FileMapper.updateFileDetailCanceledByFileUuidAndUserTokenIdx*/
|
||||
UPDATE ALISTLMS.FILE_DETAIL LFD
|
||||
INNER JOIN ALISTLMS.test_user_token LTUT ON LTUT.user_idx = LFD.CREATE_MEMBER
|
||||
SET LFD.STATUS = 5,
|
||||
LFD.UPDATE_DATE = NOW()
|
||||
INNER JOIN ALISTLMS.test_user_token LTUT
|
||||
ON LTUT.user_idx = LFD.CREATE_MEMBER
|
||||
SET LFD.STATUS = 5
|
||||
, LFD.UPDATE_DATE = NOW()
|
||||
WHERE LFD.DEL_YN = 'N'
|
||||
AND LFD.FILE_UUID = #{fileUuid}
|
||||
AND LTUT.user_token_idx = #{userTokenIdx}
|
||||
AND LFD.STATUS IN (0,1,2)
|
||||
AND LFD.FILE_UUID = #{fileUuid}
|
||||
AND LTUT.user_token_idx = #{userTokenIdx}
|
||||
AND LFD.STATUS IN (0,1,2)
|
||||
</update>
|
||||
|
||||
<select id="selectFileMasterCountByFileMasterKeyAndDelYnAndStatus" resultType="int">
|
||||
/*FileMapper.selectFileMasterCountByFileMasterKeyAndDelYnAndStatus*/
|
||||
SELECT COUNT(*)
|
||||
FROM ALISTLMS.FILE_MASTER2 LFM
|
||||
INNER JOIN ALISTLMS.test_user_token LTUT ON LTUT.user_idx = LFM.USER_IDX
|
||||
INNER JOIN ALISTLMS.test_user_token LTUT
|
||||
ON LTUT.user_idx = LFM.USER_IDX
|
||||
WHERE LFM.DEL_YN = 'N'
|
||||
AND LFM.FILE_MASTER_KEY = #{fileMasterKey}
|
||||
AND LFM.STATUS = 1
|
||||
AND LTUT.user_token_idx = #{userTokenIdx}
|
||||
AND LFM.FILE_MASTER_KEY = #{fileMasterKey}
|
||||
AND LFM.STATUS = 1
|
||||
AND LTUT.user_token_idx = #{userTokenIdx}
|
||||
</select>
|
||||
|
||||
<select id="selectFileDetailCountByFileUuidUserToKenIdx" resultType="java.lang.Integer">
|
||||
/*FileMapper.selectFileDetailCountByFileUuidUserToKenIdx*/
|
||||
SELECT COUNT(*)
|
||||
FROM ALISTLMS.FILE_DETAIL LFD
|
||||
INNER JOIN ALISTLMS.test_user_token LTUT ON LTUT.user_idx = LFD.CREATE_MEMBER
|
||||
INNER JOIN ALISTLMS.test_user_token LTUT
|
||||
ON LTUT.user_idx = LFD.CREATE_MEMBER
|
||||
WHERE LFD.DEL_YN = 'N'
|
||||
AND LFD.FILE_UUID = #{fileUuid}
|
||||
AND LTUT.user_token_idx = #{userTokenIdx}
|
||||
AND LFD.STATUS IN (0,1,2,3)
|
||||
AND (#{sizeBytes} IS NULL OR LFD.SIZE_BYTES = #{sizeBytes})
|
||||
AND (#{originName} IS NULL OR #{originName} = '' OR LFD.ORIGIN_NAME = #{originName})
|
||||
AND (
|
||||
AND LFD.FILE_UUID = #{fileUuid}
|
||||
AND LTUT.user_token_idx = #{userTokenIdx}
|
||||
AND LFD.STATUS IN (0,1,2,3)
|
||||
AND (#{sizeBytes} IS NULL OR LFD.SIZE_BYTES = #{sizeBytes})
|
||||
AND (#{originName} IS NULL OR #{originName} = '' OR LFD.ORIGIN_NAME = #{originName})
|
||||
AND (
|
||||
#{contentType} IS NULL OR #{contentType} = ''
|
||||
OR LFD.CONTENT_TYPE IS NULL OR LFD.CONTENT_TYPE = ''
|
||||
OR LOWER(LFD.CONTENT_TYPE) = LOWER(#{contentType})
|
||||
)
|
||||
)
|
||||
</select>
|
||||
|
||||
<select id="selectFileUploadMetaByFileUuid" resultType="com.alist.api.common.modules.file.dto.FileUploadMetaDto">
|
||||
@@ -160,7 +163,7 @@
|
||||
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'
|
||||
AND DEL_YN = 'N'
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
@@ -168,12 +171,14 @@
|
||||
/*FileMapper.selectFileDetailCountByFileUuidAndUserToKenIdx*/
|
||||
SELECT COUNT(*)
|
||||
FROM ALISTLMS.FILE_DETAIL LFD
|
||||
INNER JOIN ALISTLMS.test_user_token LTUT ON LTUT.user_idx = LFD.CREATE_MEMBER
|
||||
INNER JOIN ALISTLMS.test_user_token LTUT
|
||||
ON LTUT.user_idx = LFD.CREATE_MEMBER
|
||||
WHERE LFD.DEL_YN = 'N'
|
||||
AND LFD.FILE_UUID = #{fileUuid}
|
||||
AND LTUT.user_token_idx = #{userTokenIdx}
|
||||
AND LFD.STATUS IN (0,1,2)
|
||||
AND LFD.FILE_UUID = #{fileUuid}
|
||||
AND LTUT.user_token_idx = #{userTokenIdx}
|
||||
AND LFD.STATUS IN (0,1,2)
|
||||
</select>
|
||||
|
||||
<select id="selectUploadProgressByFileUuid" resultType="com.alist.api.common.modules.file.dto.UploadCancelDto">
|
||||
/*FileMapper.selectUploadProgressByFileUuid*/
|
||||
SELECT LFD.FILE_UUID
|
||||
@@ -183,8 +188,47 @@
|
||||
INNER JOIN ALISTLMS.test_user_token LTUT
|
||||
ON LTUT.user_idx = LFD.CREATE_MEMBER
|
||||
WHERE LFD.FILE_UUID = #{fileUuid}
|
||||
AND LTUT.user_token_idx = #{userTokenIdx}
|
||||
AND LFD.DEL_YN = 'N'
|
||||
AND LTUT.user_token_idx = #{userTokenIdx}
|
||||
AND LFD.DEL_YN = 'N'
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectMoveTaskByFileUuid" resultType="com.alist.api.common.modules.file.dto.FileMoveTaskDto">
|
||||
/*FileMapper.selectMoveTaskByFileUuid*/
|
||||
SELECT FILE_DETAIL_IDX, FILE_MASTER_IDX, FILE_UUID, TUS_UPLOAD_ID,
|
||||
ORIGIN_NAME, CONTENT_TYPE, SIZE_BYTES, UPLOADED_BYTES,
|
||||
SAVE_NAME, SAVE_PATH, `EXT`, MOVE_YN, MOVE_TRY_COUNT, MOVE_LAST_ERROR
|
||||
FROM ALISTLMS.FILE_DETAIL
|
||||
WHERE FILE_UUID = #{fileUuid}
|
||||
AND DEL_YN = 'N'
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<update id="updateFileMoveSuccess" parameterType="com.alist.api.common.modules.file.dto.FileMoveTaskDto">
|
||||
/*FileMapper.updateFileMoveSuccess*/
|
||||
UPDATE ALISTLMS.FILE_DETAIL
|
||||
SET SAVE_NAME = #{saveName}
|
||||
, SAVE_PATH = #{savePath}
|
||||
, `EXT` = #{ext}
|
||||
, MOVE_YN = 'Y'
|
||||
, MOVE_DATE = NOW()
|
||||
, MOVE_LAST_ERROR = NULL
|
||||
, MOVE_LOCK_AT = NULL
|
||||
, MOVE_LOCK_OWNER = NULL
|
||||
, UPDATE_DATE = NOW()
|
||||
WHERE FILE_DETAIL_IDX = #{fileDetailIdx}
|
||||
AND DEL_YN = 'N'
|
||||
</update>
|
||||
|
||||
<update id="updateFileMovePendingByFileUuid" parameterType="com.alist.api.common.modules.file.dto.FileMoveTaskDto">
|
||||
/*FileMapper.updateFileMovePendingByFileUuid*/
|
||||
UPDATE ALISTLMS.FILE_DETAIL
|
||||
SET MOVE_YN = 'N'
|
||||
, MOVE_TRY_COUNT = COALESCE(MOVE_TRY_COUNT, 0) + 1
|
||||
, MOVE_LAST_ERROR = #{moveLastError}
|
||||
, UPDATE_DATE = NOW()
|
||||
WHERE FILE_UUID = #{fileUuid}
|
||||
AND DEL_YN = 'N'
|
||||
AND STATUS = 3
|
||||
</update>
|
||||
</mapper>
|
||||
Reference in New Issue
Block a user