[파일 업로드] 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.*;
@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>