From 48f59d7d62eb554361a61a0c2dac7e967006c402 Mon Sep 17 00:00:00 2001 From: sdw086 Date: Mon, 9 Mar 2026 17:02:58 +0900 Subject: [PATCH] =?UTF-8?q?[=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C]=20tusHook=20=EC=9E=91=EC=97=85=20-=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=9D=B4=EB=8F=99=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/modules/file/FileController.java | 25 +++- .../modules/file/dto/FileMoveTaskDto.java | 26 ++++ .../modules/file/mapper/FileMapper.java | 6 + .../modules/file/service/FileService.java | 95 +++++++++++++ src/main/resources/application-local.yaml | 2 + .../mapper/common/file/FileMapper.xml | 128 ++++++++++++------ 6 files changed, 235 insertions(+), 47 deletions(-) create mode 100644 src/main/java/com/alist/api/common/modules/file/dto/FileMoveTaskDto.java diff --git a/src/main/java/com/alist/api/common/modules/file/FileController.java b/src/main/java/com/alist/api/common/modules/file/FileController.java index c84064e..5f9b2c7 100644 --- a/src/main/java/com/alist/api/common/modules/file/FileController.java +++ b/src/main/java/com/alist/api/common/modules/file/FileController.java @@ -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> uploadInit( @@ -59,8 +62,8 @@ public class FileController { } @Operation( - summary = "업로드 권한 체크", - description = "업로드 토큰 발급 후 실제 업로드 권한이 있는지 확인 과정입니다." + summary = "업로드 권한 검증", + description = "Bearer 업로드 토큰과 fileUuid(및 TUS 메타데이터)를 검증해 업로드 가능 여부를 확인합니다." ) @GetMapping("/uploadAuth") public ResponseEntity> uploadAuth( @@ -93,6 +96,10 @@ public class FileController { return ApiResponse.entity("", ApiResponseCode.CODE_200); } + @Operation( + summary = "업로드 상태 조회", + description = "fileUuid 기준으로 Redis/DB 업로드 상태(진행률, 상태값)를 조회합니다." + ) @PostMapping("/uploadStatus") public ResponseEntity> 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> 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> uploadCancel( @Valid @RequestBody UploadCancelForm uploadCancelForm diff --git a/src/main/java/com/alist/api/common/modules/file/dto/FileMoveTaskDto.java b/src/main/java/com/alist/api/common/modules/file/dto/FileMoveTaskDto.java new file mode 100644 index 0000000..6307e60 --- /dev/null +++ b/src/main/java/com/alist/api/common/modules/file/dto/FileMoveTaskDto.java @@ -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; +} diff --git a/src/main/java/com/alist/api/common/modules/file/mapper/FileMapper.java b/src/main/java/com/alist/api/common/modules/file/mapper/FileMapper.java index b4fc774..e176087 100644 --- a/src/main/java/com/alist/api/common/modules/file/mapper/FileMapper.java +++ b/src/main/java/com/alist/api/common/modules/file/mapper/FileMapper.java @@ -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); } diff --git a/src/main/java/com/alist/api/common/modules/file/service/FileService.java b/src/main/java/com/alist/api/common/modules/file/service/FileService.java index 76b9020..7360a53 100644 --- a/src/main/java/com/alist/api/common/modules/file/service/FileService.java +++ b/src/main/java/com/alist/api/common/modules/file/service/FileService.java @@ -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()) { diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 39cb6b0..8e1fcfd 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -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 diff --git a/src/main/resources/mapper/common/file/FileMapper.xml b/src/main/resources/mapper/common/file/FileMapper.xml index 6dcb735..77cd76c 100644 --- a/src/main/resources/mapper/common/file/FileMapper.xml +++ b/src/main/resources/mapper/common/file/FileMapper.xml @@ -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() /*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 /*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) /*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) @@ -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) + + + + + + /*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' + + + + /*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 + \ No newline at end of file