[파일 업로드] tusHook 작업

- 파일 이동작업
This commit is contained in:
2026-03-09 17:02:58 +09:00
parent 25bad9c5fc
commit 48f59d7d62
6 changed files with 235 additions and 47 deletions
@@ -21,7 +21,10 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@Slf4j @Slf4j
@Tag(name = "99. 파일 관리", description = "파일 관리") @Tag(
name = "99. TUS 업로드",
description = "TUS 기반 파일 업로드 초기화, 권한 검증, 상태 조회, 훅 처리, 취소 API"
)
@RestController @RestController
@RequestMapping("/files") @RequestMapping("/files")
public class FileController { public class FileController {
@@ -32,8 +35,8 @@ public class FileController {
} }
@Operation( @Operation(
summary = "파일 업로드 준비(db 입력 및 업로드 토큰 생성)", summary = "파일 업로드 초기화",
description = "업로드 토큰 생성 및 파일 업로드 준비" description = "업로드 대상 파일 정보를 DB에 등록하고 fileUuid/uploadToken을 발급합니다."
) )
@PostMapping("/uploadInit") @PostMapping("/uploadInit")
public ResponseEntity<ApiResponse<FileUploadDto>> uploadInit( public ResponseEntity<ApiResponse<FileUploadDto>> uploadInit(
@@ -59,8 +62,8 @@ public class FileController {
} }
@Operation( @Operation(
summary = "업로드 권한 체크", summary = "업로드 권한 검증",
description = "업로드 토큰 발급 후 실제 업로드 권한이 있는지 확인 과정입니다." description = "Bearer 업로드 토큰과 fileUuid(및 TUS 메타데이터)를 검증해 업로드 가능 여부를 확인합니다."
) )
@GetMapping("/uploadAuth") @GetMapping("/uploadAuth")
public ResponseEntity<ApiResponse<String>> uploadAuth( public ResponseEntity<ApiResponse<String>> uploadAuth(
@@ -93,6 +96,10 @@ public class FileController {
return ApiResponse.entity("", ApiResponseCode.CODE_200); return ApiResponse.entity("", ApiResponseCode.CODE_200);
} }
@Operation(
summary = "업로드 상태 조회",
description = "fileUuid 기준으로 Redis/DB 업로드 상태(진행률, 상태값)를 조회합니다."
)
@PostMapping("/uploadStatus") @PostMapping("/uploadStatus")
public ResponseEntity<ApiResponse<UploadStatusDto>> uploadStatus( public ResponseEntity<ApiResponse<UploadStatusDto>> uploadStatus(
@Valid @RequestBody UploadStatusForm uploadStatusForm @Valid @RequestBody UploadStatusForm uploadStatusForm
@@ -104,6 +111,10 @@ public class FileController {
return ApiResponse.entity(status, ApiResponseCode.CODE_200); return ApiResponse.entity(status, ApiResponseCode.CODE_200);
} }
@Operation(
summary = "TUS 훅 수신",
description = "tusd에서 전달되는 pre-create/post-receive/post-finish/post-terminate 이벤트를 반영합니다."
)
@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()); boolean accepted = fileService.updateFileUploadStatus(tusHookForm.tusHookDto());
@@ -115,6 +126,10 @@ public class FileController {
return ApiResponse.entity("", ApiResponseCode.CODE_200); return ApiResponse.entity("", ApiResponseCode.CODE_200);
} }
@Operation(
summary = "업로드 취소",
description = "현재 로그인 사용자의 fileUuid 업로드를 취소(CANCELED) 처리합니다."
)
@PostMapping("/uploadCancel") @PostMapping("/uploadCancel")
public ResponseEntity<ApiResponse<String>> uploadCancel( public ResponseEntity<ApiResponse<String>> uploadCancel(
@Valid @RequestBody UploadCancelForm uploadCancelForm @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); int updateFileDetailCanceledByFileUuidAndUserTokenIdx(UploadCancelDto uploadCancelDto);
UploadCancelDto selectUploadProgressByFileUuid(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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
@@ -34,6 +37,12 @@ public class FileService {
@Value("${file.upload.interrupt-seconds:30}") @Value("${file.upload.interrupt-seconds:30}")
private long uploadInterruptSeconds; 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 FileMapper fileMapper;
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
private final StringRedisTemplate stringRedisTemplate; private final StringRedisTemplate stringRedisTemplate;
@@ -306,9 +315,95 @@ public class FileService {
fileMapper.updateFileMasterAggregateByFileUuid(tusHookDto.getFileUuid()); 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; 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) { public void saveUploadStatusRedisTusHook(TusHookDto tusHookDto) {
if (tusHookDto == null || tusHookDto.getFileUuid() == null || tusHookDto.getFileUuid().isBlank()) { if (tusHookDto == null || tusHookDto.getFileUuid() == null || tusHookDto.getFileUuid().isBlank()) {
@@ -46,6 +46,8 @@ 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
tmp-root: /srv/project/alist/uploads/tmp
final-root: /srv/project/alist/uploads
interrupt-seconds: 30 interrupt-seconds: 30
auth-cache: auth-cache:
ttl-seconds: 20 ttl-seconds: 20
@@ -85,16 +85,16 @@
) )
AND LLFD.DEL_YN = 'N' AND LLFD.DEL_YN = 'N'
) A ON A.FILE_MASTER_IDX = LFM.FILE_MASTER_IDX ) A ON A.FILE_MASTER_IDX = LFM.FILE_MASTER_IDX
SET LFM.TOTAL_COUNT = A.total_count, SET LFM.TOTAL_COUNT = A.total_count
LFM.DONE_COUNT = A.done_count, , LFM.DONE_COUNT = A.done_count
LFM.FAIL_COUNT = A.fail_count, , LFM.FAIL_COUNT = A.fail_count
LFM.STATUS = CASE , LFM.STATUS = CASE
WHEN A.done_count = A.total_count THEN 3 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 = A.total_count THEN 4
WHEN A.done_count > 0 AND A.fail_count > 0 THEN 5 WHEN A.done_count > 0 AND A.fail_count > 0 THEN 5
ELSE 1 ELSE 1
END, END
LFM.UPDATE_DATE = NOW() , LFM.UPDATE_DATE = NOW()
</update> </update>
<update id="updateTusUploadIdIfNull" parameterType="com.alist.api.common.modules.file.dto.FileUploadMetaDto"> <update id="updateTusUploadIdIfNull" parameterType="com.alist.api.common.modules.file.dto.FileUploadMetaDto">
/*FileMapper.updateTusUploadIdIfNull*/ /*FileMapper.updateTusUploadIdIfNull*/
@@ -108,8 +108,8 @@
<update id="updateFileDetailCanceledByFileUuid"> <update id="updateFileDetailCanceledByFileUuid">
/*FileMapper.updateFileDetailCanceledByFileUuid*/ /*FileMapper.updateFileDetailCanceledByFileUuid*/
UPDATE ALISTLMS.FILE_DETAIL UPDATE ALISTLMS.FILE_DETAIL
SET STATUS = 5, SET STATUS = 5
UPDATE_DATE = NOW() , UPDATE_DATE = NOW()
WHERE FILE_UUID = #{fileUuid} WHERE FILE_UUID = #{fileUuid}
AND DEL_YN = 'N' AND DEL_YN = 'N'
AND STATUS IN (0,1,2) AND STATUS IN (0,1,2)
@@ -117,9 +117,10 @@
<update id="updateFileDetailCanceledByFileUuidAndUserTokenIdx"> <update id="updateFileDetailCanceledByFileUuidAndUserTokenIdx">
/*FileMapper.updateFileDetailCanceledByFileUuidAndUserTokenIdx*/ /*FileMapper.updateFileDetailCanceledByFileUuidAndUserTokenIdx*/
UPDATE ALISTLMS.FILE_DETAIL LFD UPDATE 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
SET LFD.STATUS = 5, ON LTUT.user_idx = LFD.CREATE_MEMBER
LFD.UPDATE_DATE = NOW() SET LFD.STATUS = 5
, LFD.UPDATE_DATE = NOW()
WHERE LFD.DEL_YN = 'N' WHERE LFD.DEL_YN = 'N'
AND LFD.FILE_UUID = #{fileUuid} AND LFD.FILE_UUID = #{fileUuid}
AND LTUT.user_token_idx = #{userTokenIdx} AND LTUT.user_token_idx = #{userTokenIdx}
@@ -130,7 +131,8 @@
/*FileMapper.selectFileMasterCountByFileMasterKeyAndDelYnAndStatus*/ /*FileMapper.selectFileMasterCountByFileMasterKeyAndDelYnAndStatus*/
SELECT COUNT(*) SELECT COUNT(*)
FROM ALISTLMS.FILE_MASTER2 LFM 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' WHERE LFM.DEL_YN = 'N'
AND LFM.FILE_MASTER_KEY = #{fileMasterKey} AND LFM.FILE_MASTER_KEY = #{fileMasterKey}
AND LFM.STATUS = 1 AND LFM.STATUS = 1
@@ -141,7 +143,8 @@
/*FileMapper.selectFileDetailCountByFileUuidUserToKenIdx*/ /*FileMapper.selectFileDetailCountByFileUuidUserToKenIdx*/
SELECT COUNT(*) SELECT COUNT(*)
FROM ALISTLMS.FILE_DETAIL LFD 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' WHERE LFD.DEL_YN = 'N'
AND LFD.FILE_UUID = #{fileUuid} AND LFD.FILE_UUID = #{fileUuid}
AND LTUT.user_token_idx = #{userTokenIdx} AND LTUT.user_token_idx = #{userTokenIdx}
@@ -168,12 +171,14 @@
/*FileMapper.selectFileDetailCountByFileUuidAndUserToKenIdx*/ /*FileMapper.selectFileDetailCountByFileUuidAndUserToKenIdx*/
SELECT COUNT(*) SELECT COUNT(*)
FROM ALISTLMS.FILE_DETAIL LFD 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' WHERE LFD.DEL_YN = 'N'
AND LFD.FILE_UUID = #{fileUuid} AND LFD.FILE_UUID = #{fileUuid}
AND LTUT.user_token_idx = #{userTokenIdx} AND LTUT.user_token_idx = #{userTokenIdx}
AND LFD.STATUS IN (0,1,2) AND LFD.STATUS IN (0,1,2)
</select> </select>
<select id="selectUploadProgressByFileUuid" resultType="com.alist.api.common.modules.file.dto.UploadCancelDto"> <select id="selectUploadProgressByFileUuid" resultType="com.alist.api.common.modules.file.dto.UploadCancelDto">
/*FileMapper.selectUploadProgressByFileUuid*/ /*FileMapper.selectUploadProgressByFileUuid*/
SELECT LFD.FILE_UUID SELECT LFD.FILE_UUID
@@ -187,4 +192,43 @@
AND LFD.DEL_YN = 'N' AND LFD.DEL_YN = 'N'
LIMIT 1 LIMIT 1
</select> </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> </mapper>