[api] 파일업로드, tus파일 주소변경, md 파일 변경
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# alist API
|
# alist API
|
||||||
|
|
||||||
Spring Boot 3 기반의 `alist` 백엔드 API 서버입니다.
|
Spring Boot 3 기반의 `alist` 백엔드 API 서버입니다.
|
||||||
|
|
||||||
@@ -7,7 +7,8 @@ Spring Boot 3 기반의 `alist` 백엔드 API 서버입니다.
|
|||||||
- 그룹: `com.alist`
|
- 그룹: `com.alist`
|
||||||
- Java: `21`
|
- Java: `21`
|
||||||
- Spring Boot: `3.5.10`
|
- Spring Boot: `3.5.10`
|
||||||
- 포트: `8106`
|
- 기본 포트: `8106`
|
||||||
|
- 실행 진입점: `src/main/java/com/alist/api/ApiApplication.java`
|
||||||
- 빌드 결과물: `build/libs/api.jar`
|
- 빌드 결과물: `build/libs/api.jar`
|
||||||
|
|
||||||
## 기술 스택
|
## 기술 스택
|
||||||
@@ -26,12 +27,14 @@ Spring Boot 3 기반의 `alist` 백엔드 API 서버입니다.
|
|||||||
|
|
||||||
## 주요 기능
|
## 주요 기능
|
||||||
|
|
||||||
- JWT 발급 및 검증
|
- 사용자/관리자 JWT 발급 및 검증
|
||||||
- Redis 기반 세션 관리와 쿠키 설정
|
- Redis 기반 SSO 세션 관리와 쿠키 설정
|
||||||
- Swagger UI Basic 인증 보호
|
- Swagger UI Basic 인증 보호
|
||||||
- DB 기반 허용 Origin 캐시를 사용하는 동적 CORS
|
- DB 기반 허용 Origin 캐시를 사용하는 동적 CORS
|
||||||
- TUS 업로드 초기화, 권한 검증, 상태 조회, 완료 처리, 취소 처리
|
- TUS 기반 대용량 파일 업로드 초기화, 권한 검증, 상태 조회, hook 처리, 취소 처리
|
||||||
- 파일 조회/다운로드 API
|
- DB 기록 없는 단순 파일 업로드와 uploadPath 반환
|
||||||
|
- SunEditor 이미지 업로드와 file-domain URL 반환
|
||||||
|
- 파일 view/download API
|
||||||
- 공통 응답 래퍼 `ApiResponse<T>` 및 `ApiResponseCode` 사용
|
- 공통 응답 래퍼 `ApiResponse<T>` 및 `ApiResponseCode` 사용
|
||||||
|
|
||||||
## 디렉터리 구조
|
## 디렉터리 구조
|
||||||
@@ -39,7 +42,6 @@ Spring Boot 3 기반의 `alist` 백엔드 API 서버입니다.
|
|||||||
```text
|
```text
|
||||||
src/main/java/com/alist/api
|
src/main/java/com/alist/api
|
||||||
├── common
|
├── common
|
||||||
│ ├── modules/file # 공통 파일 업로드/다운로드 모듈
|
|
||||||
│ ├── response # ApiResponse, ApiResponseCode
|
│ ├── response # ApiResponse, ApiResponseCode
|
||||||
│ └── utils # 공통 유틸리티
|
│ └── utils # 공통 유틸리티
|
||||||
├── config
|
├── config
|
||||||
@@ -47,10 +49,16 @@ src/main/java/com/alist/api
|
|||||||
│ ├── exception # 전역 예외 처리
|
│ ├── exception # 전역 예외 처리
|
||||||
│ ├── filter # DynamicCorsFilter
|
│ ├── filter # DynamicCorsFilter
|
||||||
│ ├── jwt # JWT 인증 관련 구성
|
│ ├── jwt # JWT 인증 관련 구성
|
||||||
|
│ ├── migration # 마이그레이션 DB 설정
|
||||||
│ └── properties # 설정 프로퍼티
|
│ └── properties # 설정 프로퍼티
|
||||||
└── modules
|
└── modules
|
||||||
├── auth # 인증/세션 관련 API
|
├── admin # 관리자 인증 API
|
||||||
├── main # 루트 리다이렉트
|
├── auth # 사용자 인증/SSO API
|
||||||
|
├── file # 단순 업로드, SunEditor 업로드, path 기반 view/download
|
||||||
|
├── main # 루트 응답
|
||||||
|
├── migration # 레거시 사용자 조회
|
||||||
|
├── tusFile # TUS 업로드 DB 기록, hook, 상태, 파일 목록/view/download/delete
|
||||||
|
└── user # 사용자 가입/마이그레이션 조회
|
||||||
```
|
```
|
||||||
|
|
||||||
리소스 파일은 아래 위치를 사용합니다.
|
리소스 파일은 아래 위치를 사용합니다.
|
||||||
@@ -58,6 +66,7 @@ src/main/java/com/alist/api
|
|||||||
- 설정: `src/main/resources/application*.yaml`
|
- 설정: `src/main/resources/application*.yaml`
|
||||||
- Mapper XML: `src/main/resources/mapper/**/*.xml`
|
- Mapper XML: `src/main/resources/mapper/**/*.xml`
|
||||||
- 로그 설정: `src/main/resources/logback-*.xml`
|
- 로그 설정: `src/main/resources/logback-*.xml`
|
||||||
|
- 상세 문서: `docs/*.md`
|
||||||
|
|
||||||
## 실행 방법
|
## 실행 방법
|
||||||
|
|
||||||
@@ -79,6 +88,12 @@ src/main/java/com/alist/api
|
|||||||
java -jar build/libs/api.jar --spring.profiles.active=local
|
java -jar build/libs/api.jar --spring.profiles.active=local
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\gradlew.bat bootRun --args='--spring.profiles.active=local'
|
||||||
|
```
|
||||||
|
|
||||||
## 프로파일
|
## 프로파일
|
||||||
|
|
||||||
| 프로파일 | 설명 | 설정 파일 |
|
| 프로파일 | 설명 | 설정 파일 |
|
||||||
@@ -90,8 +105,6 @@ java -jar build/libs/api.jar --spring.profiles.active=local
|
|||||||
|
|
||||||
## 필수 설정 항목
|
## 필수 설정 항목
|
||||||
|
|
||||||
실행 전 아래 설정들이 환경에 맞게 준비되어 있어야 합니다.
|
|
||||||
|
|
||||||
### 데이터 저장소
|
### 데이터 저장소
|
||||||
|
|
||||||
- `spring.datasource.*`
|
- `spring.datasource.*`
|
||||||
@@ -110,14 +123,22 @@ java -jar build/libs/api.jar --spring.profiles.active=local
|
|||||||
- `swagger.login.id`
|
- `swagger.login.id`
|
||||||
- `swagger.login.password`
|
- `swagger.login.password`
|
||||||
|
|
||||||
### 파일 업로드
|
### TUS 업로드
|
||||||
|
|
||||||
- `file.upload.tus-endpoint`
|
- `tus-file.upload.tus-endpoint`
|
||||||
- `file.upload.public-base-url`
|
- `tus-file.upload.public-base-url`
|
||||||
- `file.upload.tmp-root`
|
- `tus-file.upload.tmp-root`
|
||||||
- `file.upload.final-root`
|
- `tus-file.upload.final-root`
|
||||||
- `file.upload.interrupt-seconds`
|
- `tus-file.upload.interrupt-seconds`
|
||||||
- `file.upload.auth-cache.ttl-seconds`
|
- `tus-file.upload.auth-cache.ttl-seconds`
|
||||||
|
|
||||||
|
### 단순 업로드 / 에디터 이미지
|
||||||
|
|
||||||
|
- `file.upload.root-path`
|
||||||
|
- `file.upload.view.file-domain`
|
||||||
|
- `file.upload.max-size`
|
||||||
|
- `file.upload.allowed-extensions`
|
||||||
|
- `file.upload.types.*`
|
||||||
|
|
||||||
`pjt` 프로파일은 DB/Redis/JWT/Swagger 값을 환경변수로 주입받도록 작성되어 있습니다.
|
`pjt` 프로파일은 DB/Redis/JWT/Swagger 값을 환경변수로 주입받도록 작성되어 있습니다.
|
||||||
|
|
||||||
@@ -129,20 +150,30 @@ java -jar build/libs/api.jar --spring.profiles.active=local
|
|||||||
- 인증 방식: HTTP Basic
|
- 인증 방식: HTTP Basic
|
||||||
- 계정 정보: `swagger.login.id`, `swagger.login.password`
|
- 계정 정보: `swagger.login.id`, `swagger.login.password`
|
||||||
|
|
||||||
|
### Admin API
|
||||||
|
|
||||||
|
- `/admin/**` 는 별도 SecurityFilterChain을 사용합니다.
|
||||||
|
- `/admin/auth/**` 는 공개하고, 그 외 `/admin/**` 는 `ADMIN` 권한을 요구합니다.
|
||||||
|
- `GET /admin/auth/loginChecked` 는 토큰 재발급 없이 현재 관리자 로그인 상태만 확인합니다.
|
||||||
|
|
||||||
### API
|
### API
|
||||||
|
|
||||||
- 기본 인증 방식: JWT Bearer
|
- 기본 인증 방식: JWT Bearer 또는 HttpOnly 쿠키 fallback
|
||||||
- 세션 저장소: Redis
|
- 세션 저장소: Redis
|
||||||
- 세션 쿠키: `RedisSessionConfig`에서 도메인, Secure, SameSite 제어
|
- 쿠키 속성: `cookie.*` 설정으로 제어
|
||||||
|
|
||||||
### 공개 경로
|
### 공개 경로
|
||||||
|
|
||||||
- `/`
|
- `/`
|
||||||
- `/actuator/health`
|
- `/actuator/health`
|
||||||
|
- `/sso/**`
|
||||||
- `/auth/**`
|
- `/auth/**`
|
||||||
- `/files/tusHook`
|
- `/user/signup`
|
||||||
|
- `/user/migrationUserList`
|
||||||
|
- `/tusFiles/uploadAuth`
|
||||||
|
- `/tusFiles/tusHook`
|
||||||
|
|
||||||
루트 `/` 요청은 `/swagger-ui/index.html`로 리다이렉트됩니다.
|
TUS 업로드 토큰은 일반 access token이 아니므로 `/tusFiles/uploadAuth`, `/tusFiles/tusHook` 은 JWT 필터 제외 경로에도 포함되어야 합니다.
|
||||||
|
|
||||||
## CORS
|
## CORS
|
||||||
|
|
||||||
@@ -153,200 +184,103 @@ java -jar build/libs/api.jar --spring.profiles.active=local
|
|||||||
- `OPTIONS` preflight 요청은 `200 OK`로 즉시 응답
|
- `OPTIONS` preflight 요청은 `200 OK`로 즉시 응답
|
||||||
- DB를 직접 매 요청마다 조회하지 않고 캐시된 목록을 사용
|
- DB를 직접 매 요청마다 조회하지 않고 캐시된 목록을 사용
|
||||||
|
|
||||||
## 파일 업로드/다운로드
|
file-domain nginx 의 TUS 업로드 경로는 별도 `map $http_origin $cors_allow_origin` 설정으로 허용 Origin을 제한합니다.
|
||||||
|
|
||||||
이 프로젝트는 대용량 업로드를 위해 TUS 서버(`tusd`)와 연동합니다. API는 업로드 메타데이터 관리, 업로드 권한 검증, 상태 저장, 완료/취소 처리를 담당합니다.
|
## 파일 업로드
|
||||||
|
|
||||||
### 주요 업로드 엔드포인트
|
파일 업로드는 두 흐름으로 분리되어 있습니다.
|
||||||
|
|
||||||
- `POST /files/uploadInit`
|
### 단순 업로드
|
||||||
- `GET /files/uploadAuth`
|
|
||||||
- `POST /files/uploadStatus`
|
|
||||||
- `POST /files/tusHook`
|
|
||||||
- `POST /files/uploadCancel`
|
|
||||||
|
|
||||||
### 파일 조회 엔드포인트
|
DB에 기록하지 않고 파일만 저장한 뒤 업무 테이블에 저장하기 좋은 값을 반환합니다.
|
||||||
|
|
||||||
- `GET /files/list/{fileMasterIdx}`
|
- `POST /files/upload`
|
||||||
- `GET /files/view/{fileUuid}`
|
- `POST /admin/files/upload`
|
||||||
- `GET /files/download/{fileUuid}`
|
- `GET /files/view?path=/uploads/...`
|
||||||
|
- `GET /admin/files/view?path=/uploads/...`
|
||||||
|
- `GET /files/download?path=/uploads/...`
|
||||||
|
- `GET /admin/files/download?path=/uploads/...`
|
||||||
|
|
||||||
### 운영 메모
|
응답 데이터 예시:
|
||||||
|
|
||||||
- TUS 업로드 엔드포인트: `https://file-alist.pjt.kr/tus/files/`
|
|
||||||
- 업로드 임시 경로: `/srv/project/alist/uploads/tmp`
|
|
||||||
- 업로드 최종 경로: `/srv/project/alist/uploads`
|
|
||||||
- 정적 파일 도메인: `https://file-alist.pjt.kr`
|
|
||||||
|
|
||||||
## 주요 인증 엔드포인트
|
|
||||||
|
|
||||||
- `POST /auth/token`
|
|
||||||
- `POST /auth/refresh`
|
|
||||||
- `GET /auth/loginChecked`
|
|
||||||
- `POST /auth/logout`
|
|
||||||
- `POST /user/signup`
|
|
||||||
|
|
||||||
## API 예시 요청/응답
|
|
||||||
|
|
||||||
아래 예시는 실제 컨트롤러의 요청 필드와 `ApiResponse<T>` 응답 구조를 기준으로 정리했습니다.
|
|
||||||
|
|
||||||
### 1. 토큰 발급
|
|
||||||
|
|
||||||
요청:
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /auth/token
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
|
```json
|
||||||
{
|
{
|
||||||
"id": "test"
|
"uploadPath": "/uploads/notice/2026/05/08/abc.png",
|
||||||
|
"originalFileName": "sample.png",
|
||||||
|
"storedFileName": "abc.png",
|
||||||
|
"fileExtension": "png",
|
||||||
|
"contentType": "image/png",
|
||||||
|
"fileSize": 12345,
|
||||||
|
"width": 800,
|
||||||
|
"height": 600
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### SunEditor 이미지 업로드
|
||||||
|
|
||||||
|
SunEditor 업로드는 API로 저장하되, 에디터 본문에는 token이 필요 없는 file-domain URL을 저장합니다.
|
||||||
|
|
||||||
|
- `POST /files/suneditor/upload`
|
||||||
|
- `POST /admin/files/suneditor/upload`
|
||||||
|
|
||||||
응답 예시:
|
응답 예시:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"data": {
|
"result": [
|
||||||
"id": null,
|
|
||||||
"accessToken": "eyJhbGciOiJI..."
|
|
||||||
},
|
|
||||||
"code": "CODE_2001",
|
|
||||||
"message": "임시 토큰 정보 조회에 성공하였습니다."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 테스트 회원가입
|
|
||||||
|
|
||||||
요청:
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /test/testSignup
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"id": "testuser01",
|
|
||||||
"password": "pass1234"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
응답 예시:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"userIdx": 101,
|
|
||||||
"id": "testuser01"
|
|
||||||
},
|
|
||||||
"code": "CODE_2002",
|
|
||||||
"message": "아이디 등록 되었습니다."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
중복일 경우 예시:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"userIdx": null,
|
|
||||||
"id": null
|
|
||||||
},
|
|
||||||
"code": "CODE_2004",
|
|
||||||
"message": "중복된 아이디 정보 입니다."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 파일 업로드 초기화
|
|
||||||
|
|
||||||
요청:
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /files/uploadInit
|
|
||||||
Content-Type: application/json
|
|
||||||
Cookie: ALIST_SESSION=...
|
|
||||||
|
|
||||||
{
|
|
||||||
"fileCategory": "notice",
|
|
||||||
"folderPath": "/2026/03",
|
|
||||||
"itemList": [
|
|
||||||
{
|
{
|
||||||
"originName": "guide.pdf",
|
"url": "https://file-alist.pjt.kr/uploads/editor/2026/05/08/abc.png",
|
||||||
"sizeBytes": 102400,
|
"name": "sample.png",
|
||||||
"contentType": "application/pdf"
|
"size": 12345
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
응답 예시:
|
### TUS 업로드
|
||||||
|
|
||||||
```json
|
대용량 업로드는 tusd와 연동하며 API는 DB 기록, 토큰 발급, 권한 검증, hook 반영을 담당합니다.
|
||||||
{
|
|
||||||
"data": {
|
- `POST /tusFiles/uploadInit`
|
||||||
"fileMasterIdx": 55,
|
- `GET /tusFiles/uploadAuth`
|
||||||
"tusEndpoint": "https://file-alist.pjt.kr/tus/files/",
|
- `POST /tusFiles/uploadStatus`
|
||||||
"itemList": [
|
- `POST /tusFiles/tusHook`
|
||||||
{
|
- `POST /tusFiles/uploadCancel`
|
||||||
"fileSeq": 1,
|
- `GET /tusFiles/list/{fileMasterIdx}`
|
||||||
"fileUuid": "2f5f3ef1-8a4e-4b2d-84da-1c1111111111",
|
- `GET /tusFiles/view/{fileUuid}`
|
||||||
"uploadToken": "upload-token-sample",
|
- `GET /tusFiles/download/{fileUuid}`
|
||||||
"originName": "guide.pdf",
|
- `POST /tusFiles/delete`
|
||||||
"sizeBytes": 102400,
|
|
||||||
"contentType": "application/pdf"
|
운영 TUS 엔드포인트:
|
||||||
}
|
|
||||||
]
|
```text
|
||||||
},
|
https://file-alist.pjt.kr/tus/files/
|
||||||
"code": "CODE_200",
|
|
||||||
"message": "성공"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. 업로드 상태 조회
|
## file-domain 운영 메모
|
||||||
|
|
||||||
요청:
|
- 업로드 최종 경로: `/srv/project/alist/uploads`
|
||||||
|
- tusd 임시 경로: `/srv/project/alist/uploads/tmp`
|
||||||
|
- 파일 도메인: `https://file-alist.pjt.kr`
|
||||||
|
- nginx `/uploads/` 는 `/srv/project/alist/uploads/` 를 정적 파일로 제공합니다.
|
||||||
|
- nginx `/uploads/tmp/` 는 임시 파일 노출 방지를 위해 404 처리합니다.
|
||||||
|
- nginx `/tus/files/` 는 tusd로 프록시하고 `auth_request /_upload_auth` 로 `/tusFiles/uploadAuth` 를 호출합니다.
|
||||||
|
|
||||||
```http
|
## 주요 인증 엔드포인트
|
||||||
POST /files/uploadStatus
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
- `POST /auth/token`
|
||||||
"fileUuid": "2f5f3ef1-8a4e-4b2d-84da-1c1111111111"
|
- `POST /auth/access`
|
||||||
}
|
- `POST /auth/refresh`
|
||||||
```
|
- `POST /auth/apiKeyLogin`
|
||||||
|
- `GET /sso/loginChecked`
|
||||||
응답 예시:
|
- `POST /sso/login`
|
||||||
|
- `POST /sso/exchange`
|
||||||
```json
|
- `POST /sso/logout`
|
||||||
{
|
- `POST /admin/auth/login`
|
||||||
"data": {
|
- `POST /admin/auth/refresh`
|
||||||
"fileUuid": "2f5f3ef1-8a4e-4b2d-84da-1c1111111111",
|
- `GET /admin/auth/loginChecked`
|
||||||
"status": "UPLOADING",
|
- `POST /admin/auth/logout`
|
||||||
"uploadedBytes": 51200,
|
- `POST /user/signup`
|
||||||
"totalBytes": 102400,
|
|
||||||
"percent": 50,
|
|
||||||
"updatedAt": "2026-03-11T09:30:00"
|
|
||||||
},
|
|
||||||
"code": "CODE_200",
|
|
||||||
"message": "성공"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
조회 결과가 없을 경우 예시:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"fileUuid": null,
|
|
||||||
"status": null,
|
|
||||||
"uploadedBytes": null,
|
|
||||||
"totalBytes": null,
|
|
||||||
"percent": null,
|
|
||||||
"updatedAt": null
|
|
||||||
},
|
|
||||||
"code": "CODE_2003",
|
|
||||||
"message": "조회된 정보가 없습니다."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Actuator
|
## Actuator
|
||||||
|
|
||||||
@@ -362,14 +296,14 @@ Content-Type: application/json
|
|||||||
|
|
||||||
## 응답 규칙
|
## 응답 규칙
|
||||||
|
|
||||||
모든 API 응답은 `ApiResponse<T>` 래퍼를 사용하며, 상태/메시지는 `ApiResponseCode` enum으로 관리합니다.
|
모든 API 응답은 `ApiResponse<T>` 래퍼를 우선 사용하며, 상태/메시지는 `ApiResponseCode` enum으로 관리합니다.
|
||||||
|
|
||||||
자주 사용하는 코드 예시는 아래와 같습니다.
|
자주 사용하는 코드 예시는 아래와 같습니다.
|
||||||
|
|
||||||
| 코드 | HTTP Status | 의미 |
|
| 코드 | HTTP Status | 의미 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `CODE_200` | `200 OK` | 일반 성공 |
|
| `CODE_200` | `200 OK` | 일반 성공 |
|
||||||
| `CODE_204` | `204 No Contnet` | 일반 성공 |
|
| `CODE_204` | `204 No Content` | 일반 성공 |
|
||||||
| `CODE_2001` | `200 OK` | 단건 조회 성공 |
|
| `CODE_2001` | `200 OK` | 단건 조회 성공 |
|
||||||
| `CODE_2002` | `201 Created` | 등록 성공 |
|
| `CODE_2002` | `201 Created` | 등록 성공 |
|
||||||
| `CODE_2003` | `200 OK` | 조회 결과 없음 |
|
| `CODE_2003` | `200 OK` | 조회 결과 없음 |
|
||||||
@@ -380,6 +314,8 @@ Content-Type: application/json
|
|||||||
| `CODE_403` | `403 Forbidden` | 권한 없음 |
|
| `CODE_403` | `403 Forbidden` | 권한 없음 |
|
||||||
| `CODE_500` | `500 Internal Server Error` | 서버 오류 |
|
| `CODE_500` | `500 Internal Server Error` | 서버 오류 |
|
||||||
|
|
||||||
|
파일 binary view/download 응답은 `ResponseEntity<Resource>` 로 직접 내려줄 수 있습니다.
|
||||||
|
|
||||||
## 배포 관련
|
## 배포 관련
|
||||||
|
|
||||||
- Dockerfile: `Dockerfile`
|
- Dockerfile: `Dockerfile`
|
||||||
@@ -389,10 +325,13 @@ Content-Type: application/json
|
|||||||
- 파일 도메인: `https://file-alist.pjt.kr`
|
- 파일 도메인: `https://file-alist.pjt.kr`
|
||||||
- Swagger: `https://api-alist.pjt.kr/swagger-ui/index.html`
|
- Swagger: `https://api-alist.pjt.kr/swagger-ui/index.html`
|
||||||
|
|
||||||
## 참고
|
## 상세 문서
|
||||||
|
|
||||||
- Swagger 로컬 접속: [http://localhost:8106/swagger-ui.html](http://localhost:8106/swagger-ui.html)
|
- [프로젝트 개요](docs/project-overview.md)
|
||||||
- 운영 환경에서는 `application-pjt.yaml`의 환경변수 주입 방식 사용을 권장합니다.
|
- [보안 및 응답 규칙](docs/security-and-response.md)
|
||||||
|
- [설정 및 실행 가이드](docs/runtime-config.md)
|
||||||
|
- [검증 및 체크리스트](docs/verification-checklist.md)
|
||||||
|
- [현재 코드베이스 메모](docs/codebase-notes.md)
|
||||||
|
|
||||||
## 라이선스
|
## 라이선스
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,9 @@
|
|||||||
- 테스트 코드가 충분하지 않을 수 있으므로 기능 변경 시 필요한 테스트를 보강한다.
|
- 테스트 코드가 충분하지 않을 수 있으므로 기능 변경 시 필요한 테스트를 보강한다.
|
||||||
- 일부 Java 소스와 주석, Swagger 설명에 인코딩이 깨진 문자열이 있으므로 표시 문자열 수정은 영향 범위를 보고 묶어서 처리한다.
|
- 일부 Java 소스와 주석, Swagger 설명에 인코딩이 깨진 문자열이 있으므로 표시 문자열 수정은 영향 범위를 보고 묶어서 처리한다.
|
||||||
- `application-local.yaml` 은 로컬 실행값이 직접 들어가 있으므로 공유하거나 커밋할 때 민감정보 노출 여부를 한 번 더 확인한다.
|
- `application-local.yaml` 은 로컬 실행값이 직접 들어가 있으므로 공유하거나 커밋할 때 민감정보 노출 여부를 한 번 더 확인한다.
|
||||||
|
- 파일 업로드는 두 흐름으로 분리되어 있다. `file` 모듈의 단순 업로드는 DB에 기록하지 않고 `uploadPath`, 파일명, 확장자, contentType, size, image width/height 정도만 반환한다. 각 업무 테이블 저장은 호출 측에서 처리한다.
|
||||||
|
- SunEditor 이미지 업로드는 API로 파일을 저장하되 응답 URL은 `file.upload.view.file-domain + uploadPath` 로 만든다. 에디터 본문에 저장되는 URL은 admin/user 토큰에 의존하지 않는 file-domain URL이어야 한다.
|
||||||
|
- TUS 업로드는 `tusFile` 모듈에서 DB master/detail 기록, upload token 발급, tusd hook 반영, 상태 조회, view/download/delete를 담당한다.
|
||||||
|
- TUS 인증 경로는 `/tusFiles/uploadAuth`, hook 경로는 `/tusFiles/tusHook` 이다. nginx `/_upload_auth` 와 Spring Security 공개 경로, JWT filter 제외 경로를 함께 맞춘다.
|
||||||
|
- file-domain nginx 는 `/uploads/` 를 공개 정적 파일로 열고 `/uploads/tmp/` 는 404로 막는다. TUS 임시 디렉터리는 파일 시스템에서는 사용하지만 URL로 직접 노출하지 않는다.
|
||||||
|
- Admin 로그인 상태 확인은 `/admin/auth/loginChecked` 를 사용한다. `/admin/auth/refresh` 는 토큰을 재발급하므로 새로고침 상태 확인용으로 쓰지 않는다.
|
||||||
|
|||||||
@@ -30,4 +30,6 @@
|
|||||||
- MyBatis는 인터페이스와 XML을 함께 사용한다.
|
- MyBatis는 인터페이스와 XML을 함께 사용한다.
|
||||||
- Mapper 인터페이스는 `src/main/java/.../mapper`, SQL XML은 `src/main/resources/mapper/...` 경로를 짝으로 맞춘다.
|
- Mapper 인터페이스는 `src/main/java/.../mapper`, SQL XML은 `src/main/resources/mapper/...` 경로를 짝으로 맞춘다.
|
||||||
- 공통 응답은 `common/response`, 보안은 `config/security, jwt`, 전역 예외 처리는 `config/exception` 아래에 둔다.
|
- 공통 응답은 `common/response`, 보안은 `config/security, jwt`, 전역 예외 처리는 `config/exception` 아래에 둔다.
|
||||||
- 모듈 패키지는 현재 `auth`, `file`, `main`, `user` 형태로 구성되어 있고, 필요한 모듈만 `dto`, `form`, `mapper`, `service`, `vo`를 둔다.
|
- 모듈 패키지는 현재 `admin`, `auth`, `file`, `main`, `tusFile`, `user` 형태로 구성되어 있고, 필요한 모듈만 `dto`, `form`, `mapper`, `service`, `vo`를 둔다.
|
||||||
|
- `file` 모듈은 DB 기록 없는 단순 업로드, SunEditor 이미지 업로드, uploadPath 기반 view/download를 담당한다.
|
||||||
|
- `tusFile` 모듈은 DB 기록이 필요한 TUS 기반 대용량 업로드 초기화, 업로드 토큰 검증, tusd hook, 상태 조회, 파일 목록/view/download/delete 흐름을 담당한다.
|
||||||
|
|||||||
@@ -17,6 +17,25 @@
|
|||||||
- URL: `http://localhost:8106/swagger-ui.html`
|
- URL: `http://localhost:8106/swagger-ui.html`
|
||||||
- 인증: `swagger.login.id` / `swagger.login.password` (환경별 yaml에 설정)
|
- 인증: `swagger.login.id` / `swagger.login.password` (환경별 yaml에 설정)
|
||||||
|
|
||||||
|
## 파일 업로드 설정
|
||||||
|
- `tus-file.upload.final-root` 는 TUS 완료 파일과 단순 업로드 파일이 공유하는 최종 저장 루트다.
|
||||||
|
- `tus-file.upload.tmp-root` 는 tusd 임시 업로드 루트다. file-domain nginx 에서는 `/uploads/tmp/` 접근을 404로 막는다.
|
||||||
|
- `file.upload.root-path` 는 보통 `${tus-file.upload.final-root}` 를 사용해 단순 업로드와 TUS 완료 파일의 마운트 루트를 맞춘다.
|
||||||
|
- `file.upload.view.file-domain` 은 SunEditor 이미지 응답 URL 생성에 사용한다. 예: `https://file-alist.pjt.kr`.
|
||||||
|
- `file.upload.max-size` 는 단순 업로드 전역 최대 용량이다. `file.upload.types.{folder}.max-size` 가 있으면 폴더별 설정이 우선한다.
|
||||||
|
- `file.upload.allowed-extensions` 는 단순 업로드 전역 확장자 허용 목록이다.
|
||||||
|
- `file.upload.types.{key}.folder` 는 실제 저장 폴더명이다. 설정되지 않은 folder 값도 전역 정책을 통과하면 동적 폴더로 저장할 수 있다.
|
||||||
|
- `file.upload.types.{key}.image-only` 가 `true` 이면 이미지 확장자만 허용한다.
|
||||||
|
- `file.upload.types.{key}.resize.enabled` 가 `true` 이고 업로드 파일이 이미지이면 resize 함수를 거친다. `width` 와 `height` 가 모두 있으면 중앙 crop 후 고정 크기로 저장하고, `max-width` 만 있으면 비율을 유지해 축소한다.
|
||||||
|
- `spring.servlet.multipart.max-file-size` 와 `max-request-size` 는 `-1` 로 두고, 실제 제한은 업로드 서비스 정책에서 처리한다.
|
||||||
|
|
||||||
|
## file-domain nginx 기준
|
||||||
|
- `/tus/files/` 는 tusd 로 프록시하며 `auth_request /_upload_auth` 로 `/tusFiles/uploadAuth` 를 호출한다.
|
||||||
|
- `/_upload_auth` 는 내부 location 으로만 열고 `Authorization`, `X-File-Uuid`, 필요 시 `Upload-Metadata`, `Upload-Length` 헤더를 API 로 전달한다.
|
||||||
|
- `/uploads/` 는 `/srv/project/alist/uploads/` 를 정적 파일로 제공한다.
|
||||||
|
- `/uploads/tmp/` 는 tusd 임시 파일 노출을 막기 위해 404 처리한다.
|
||||||
|
- 그 외 경로는 `location /` fallback 에서 차단한다.
|
||||||
|
|
||||||
## 실행 명령
|
## 실행 명령
|
||||||
- 로컬 실행: `./gradlew bootRun`
|
- 로컬 실행: `./gradlew bootRun`
|
||||||
- 테스트 실행: `./gradlew test`
|
- 테스트 실행: `./gradlew test`
|
||||||
|
|||||||
@@ -10,7 +10,10 @@
|
|||||||
- Swagger: `/v3/api-docs/**`, `/swagger-ui/**` → HTTP Basic 인증 (InMemory)
|
- Swagger: `/v3/api-docs/**`, `/swagger-ui/**` → HTTP Basic 인증 (InMemory)
|
||||||
- API: JWT Bearer 토큰 인증 (Stateless)
|
- API: JWT Bearer 토큰 인증 (Stateless)
|
||||||
- 세션/쿠키: Redis Session 저장소 사용, 쿠키 속성은 프로파일별 `cookie.*` 설정으로 제어
|
- 세션/쿠키: Redis Session 저장소 사용, 쿠키 속성은 프로파일별 `cookie.*` 설정으로 제어
|
||||||
- 공개 경로: `/`, `/actuator/health`, `/sso/**`, `/auth/**`, `/user/signup`, `/files/tusHook`
|
- Admin API: `/admin/**` 는 별도 `SecurityFilterChain` 으로 분리하며 `/admin/auth/**` 만 공개하고 나머지는 `ADMIN` 권한을 요구한다.
|
||||||
|
- 공개 경로: `/`, `/actuator/health`, `/sso/**`, `/auth/**`, `/user/signup`, `/user/migrationUserList`, `/tusFiles/tusHook`, `/tusFiles/uploadAuth`
|
||||||
|
- TUS 업로드 토큰은 일반 access token 이 아니므로 `/tusFiles/uploadAuth`, `/tusFiles/tusHook` 은 `JwtAuthenticationFilter.shouldNotFilter(...)` 에서도 제외한다.
|
||||||
|
- `/admin/auth/loginChecked` 는 admin access token 쿠키의 현재 로그인 상태 확인용이다. 토큰을 재발급하지 않으며 `isAdminAccessToken`, `isAdminRefreshToken`, `loggedIn`, `userId`, `userIdx`, `userTokenIdx`, `userRole` 형태의 값을 반환한다.
|
||||||
- Swagger 인증과 API 인증은 `SecurityFilterChain` 을 분리해서 관리한다.
|
- Swagger 인증과 API 인증은 `SecurityFilterChain` 을 분리해서 관리한다.
|
||||||
|
|
||||||
## 응답 코드 규칙
|
## 응답 코드 규칙
|
||||||
|
|||||||
@@ -12,9 +12,14 @@
|
|||||||
- Mapper 인터페이스 추가/변경 시 XML namespace, id, parameter/result 매핑이 같이 맞는지 확인한다.
|
- Mapper 인터페이스 추가/변경 시 XML namespace, id, parameter/result 매핑이 같이 맞는지 확인한다.
|
||||||
- 공개 경로나 권한 정책을 바꿨다면 SecurityConfig 와 Swagger 노출 범위를 같이 확인한다.
|
- 공개 경로나 권한 정책을 바꿨다면 SecurityConfig 와 Swagger 노출 범위를 같이 확인한다.
|
||||||
- 파일 업로드/다운로드 기능 수정 시 DB 상태, Redis 상태, 실제 파일 시스템 경로가 같이 맞는지 확인한다.
|
- 파일 업로드/다운로드 기능 수정 시 DB 상태, Redis 상태, 실제 파일 시스템 경로가 같이 맞는지 확인한다.
|
||||||
|
- TUS 경로를 바꾸면 nginx `/_upload_auth`, tusd hook URL, `SecurityConfig` 공개 경로, `JwtAuthenticationFilter.shouldNotFilter(...)` 를 함께 확인한다.
|
||||||
|
- SunEditor 또는 단순 업로드 설정을 바꾸면 `file.upload.root-path`, `file.upload.view.file-domain`, nginx `/uploads/` alias 경로가 같은 저장 루트를 가리키는지 확인한다.
|
||||||
|
|
||||||
## 기능 특성별 점검 포인트
|
## 기능 특성별 점검 포인트
|
||||||
- 스케줄러 코드는 실행 주기, 중복 실행 가능성, 로그량을 반드시 점검한다.
|
- 스케줄러 코드는 실행 주기, 중복 실행 가능성, 로그량을 반드시 점검한다.
|
||||||
- 인증 방식이 섞여 있으므로 세션 기반 처리와 JWT `SecurityContext` 사용 위치를 먼저 구분하고 수정한다.
|
- 인증 방식이 섞여 있으므로 세션 기반 처리와 JWT `SecurityContext` 사용 위치를 먼저 구분하고 수정한다.
|
||||||
- 파일 경로를 다루는 기능은 상대경로 탈출, 루트 이탈 방지 같은 검증을 같이 본다.
|
- 파일 경로를 다루는 기능은 상대경로 탈출, 루트 이탈 방지 같은 검증을 같이 본다.
|
||||||
- 설정 파일 수정 시 `local`, `pjt`, 공통 설정 간 차이를 함께 확인한다.
|
- 설정 파일 수정 시 `local`, `pjt`, 공통 설정 간 차이를 함께 확인한다.
|
||||||
|
- file-domain 정적 파일은 `/uploads/editor/...` 같은 최종 파일 URL이 브라우저에서 직접 열리는지 확인한다.
|
||||||
|
- `/uploads/tmp/...` 는 404로 막히는지 확인한다.
|
||||||
|
- SunEditor 업로드는 응답 JSON의 `result[].url` 이 file-domain 절대 URL인지 확인하고, 에디터 본문에 이미지가 실제 삽입되는지 확인한다.
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ public class SecurityConfig {
|
|||||||
.accessDeniedHandler(new JwtAccessDeniedHandler())
|
.accessDeniedHandler(new JwtAccessDeniedHandler())
|
||||||
)
|
)
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/", "/actuator/health", "/sso/**", "/auth/**", "/user/signup", "/user/migrationUserList", "/files/tusHook").permitAll()
|
.requestMatchers("/", "/actuator/health", "/sso/**", "/auth/**", "/user/signup", "/user/migrationUserList", "/tusFiles/tusHook", "/tusFiles/uploadAuth").permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|||||||
@@ -98,4 +98,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||||
|
String uri = request.getRequestURI();
|
||||||
|
|
||||||
|
return "/tusFiles/uploadAuth".equals(uri)
|
||||||
|
|| "/tusFiles/tusHook".equals(uri);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,26 @@ package com.alist.api.modules.admin.auth;
|
|||||||
|
|
||||||
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.utils.SecurityUtil;
|
||||||
|
import com.alist.api.common.utils.SessionUtil;
|
||||||
import com.alist.api.modules.admin.auth.form.AdminLoginForm;
|
import com.alist.api.modules.admin.auth.form.AdminLoginForm;
|
||||||
import com.alist.api.modules.admin.auth.service.AdminAuthService;
|
import com.alist.api.modules.admin.auth.service.AdminAuthService;
|
||||||
import com.alist.api.modules.admin.auth.vo.AdminLoginVo;
|
import com.alist.api.modules.admin.auth.vo.AdminLoginVo;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Tag(
|
||||||
|
name = "03. Admin 로그인"
|
||||||
|
, description = "ADMIN 사이트 로그인입니다. 로그인시 토큰 발급이 함께됩니다."
|
||||||
|
)
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/auth")
|
@RequestMapping("/admin/auth")
|
||||||
public class AdminAuthController {
|
public class AdminAuthController {
|
||||||
@@ -22,6 +32,10 @@ public class AdminAuthController {
|
|||||||
this.adminAuthService = adminAuthService;
|
this.adminAuthService = adminAuthService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "admin 로그인"
|
||||||
|
, description = "admin로그인 sso와 다르게 로그인시 토큰이 함께 발급됩니다."
|
||||||
|
)
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<ApiResponse<AdminLoginVo>> adminLogin(
|
public ResponseEntity<ApiResponse<AdminLoginVo>> adminLogin(
|
||||||
@Valid @RequestBody AdminLoginForm adminLoginForm
|
@Valid @RequestBody AdminLoginForm adminLoginForm
|
||||||
@@ -36,10 +50,14 @@ public class AdminAuthController {
|
|||||||
return ApiResponse.entity(result, ApiResponseCode.CODE_2001, "관리자 로그인");
|
return ApiResponse.entity(result, ApiResponseCode.CODE_2001, "관리자 로그인");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "admin 리프레시토큰 발급"
|
||||||
|
, description = "엑세스 토큰 만료시 리프레시 토큰 발급 용도 api 입니다."
|
||||||
|
)
|
||||||
@PostMapping("/refresh")
|
@PostMapping("/refresh")
|
||||||
public ResponseEntity<ApiResponse<Map<String, Object>>> refresh(
|
public ResponseEntity<ApiResponse<Map<String, Object>>> refresh(
|
||||||
@CookieValue(name = "adminRefreshToken", required = false) String refreshToken,
|
@CookieValue(name = "adminRefreshToken", required = false) String refreshToken
|
||||||
HttpServletResponse response
|
, HttpServletResponse response
|
||||||
) {
|
) {
|
||||||
AdminLoginVo result = adminAuthService.adminRefresh(refreshToken, response);
|
AdminLoginVo result = adminAuthService.adminRefresh(refreshToken, response);
|
||||||
|
|
||||||
@@ -50,10 +68,53 @@ public class AdminAuthController {
|
|||||||
return ApiResponse.entity(Map.of("refreshed", true), ApiResponseCode.CODE_200);
|
return ApiResponse.entity(Map.of("refreshed", true), ApiResponseCode.CODE_200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "admin 로그인 상태 확인",
|
||||||
|
description = "admin 쿠키 기준으로 현재 공통 로그인 상태가 유효한지 확인하고, access/refresh 토큰 존재 여부도 함께 반환합니다."
|
||||||
|
)
|
||||||
|
@GetMapping("/loginChecked")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> loginChecked(
|
||||||
|
HttpServletRequest request
|
||||||
|
) {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
|
||||||
|
String accessToken = SessionUtil.resolveSsoCookieValue(request, "adminAccessToken");
|
||||||
|
String refreshToken = SessionUtil.resolveSsoCookieValue(request, "adminRefreshToken");
|
||||||
|
|
||||||
|
result.put("isAdminAccessToken", accessToken != null && !accessToken.trim().isEmpty());
|
||||||
|
result.put("isAdminRefreshToken", refreshToken != null && !refreshToken.trim().isEmpty());
|
||||||
|
|
||||||
|
Integer userTokenIdx = SecurityUtil.getLoginUserTokenIdx();
|
||||||
|
|
||||||
|
if (userTokenIdx == null) {
|
||||||
|
result.put("loggedIn", false);
|
||||||
|
return ApiResponse.entity(result, ApiResponseCode.CODE_200);
|
||||||
|
}
|
||||||
|
|
||||||
|
AdminLoginVo admin = adminAuthService.selectAdminTokenByUserTokenIdx(userTokenIdx);
|
||||||
|
|
||||||
|
if (admin == null) {
|
||||||
|
result.put("loggedIn", false);
|
||||||
|
return ApiResponse.entity(result, ApiResponseCode.CODE_200);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.put("loggedIn", true);
|
||||||
|
result.put("userId", admin.getId());
|
||||||
|
result.put("userIdx", admin.getUserIdx());
|
||||||
|
result.put("userTokenIdx", userTokenIdx);
|
||||||
|
result.put("userRole", "ADMIN");
|
||||||
|
|
||||||
|
return ApiResponse.entity(result, ApiResponseCode.CODE_200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "admin 로그아웃"
|
||||||
|
, description = "admin 로그아웃"
|
||||||
|
)
|
||||||
@PostMapping("/logout")
|
@PostMapping("/logout")
|
||||||
public ResponseEntity<ApiResponse<Map<String, Object>>> logout(
|
public ResponseEntity<ApiResponse<Map<String, Object>>> logout(
|
||||||
@CookieValue(name = "adminRefreshToken", required = false) String refreshToken,
|
@CookieValue(name = "adminRefreshToken", required = false) String refreshToken
|
||||||
HttpServletResponse response
|
, HttpServletResponse response
|
||||||
) {
|
) {
|
||||||
adminAuthService.adminLogout(refreshToken, response);
|
adminAuthService.adminLogout(refreshToken, response);
|
||||||
|
|
||||||
|
|||||||
@@ -150,4 +150,12 @@ public class AdminAuthService {
|
|||||||
} catch (NumberFormatException ignored) {
|
} catch (NumberFormatException ignored) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public AdminLoginVo selectAdminTokenByUserTokenIdx(Integer userTokenIdx) {
|
||||||
|
if (userTokenIdx == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return adminAuthMapper.selectAdminTokenByUserTokenIdx(userTokenIdx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.alist.api.modules.file;
|
||||||
|
|
||||||
|
import com.alist.api.common.response.ApiResponse;
|
||||||
|
import com.alist.api.common.response.ApiResponseCode;
|
||||||
|
import com.alist.api.modules.file.service.FileService;
|
||||||
|
import com.alist.api.modules.file.vo.FileUploadVo;
|
||||||
|
import com.alist.api.modules.file.vo.SunEditorUploadVo;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Tag(
|
||||||
|
name = "98. 파일 업로드",
|
||||||
|
description = "DB 기록 없이 파일을 저장하고 uploadPath를 반환하는 단순 업로드 API입니다. SunEditor 이미지는 file-domain URL을 반환합니다."
|
||||||
|
)
|
||||||
|
@RestController
|
||||||
|
@RequestMapping({"/files", "/admin/files"})
|
||||||
|
public class FileController {
|
||||||
|
private final FileService fileService;
|
||||||
|
|
||||||
|
public FileController(FileService fileService) {
|
||||||
|
this.fileService = fileService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "단순 파일 업로드",
|
||||||
|
description = """
|
||||||
|
DB에 기록하지 않고 파일만 저장합니다.
|
||||||
|
반환된 uploadPath는 이후 각 업무 테이블에 저장해서 view/download 경로로 사용할 수 있습니다.
|
||||||
|
folder 값은 저장 폴더 키이며, 설정된 타입이면 해당 정책을 사용하고 설정되지 않은 값이면 전역 정책만 적용합니다.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
@PostMapping("/upload")
|
||||||
|
public ResponseEntity<ApiResponse<FileUploadVo>> upload(
|
||||||
|
@RequestParam String folder,
|
||||||
|
@RequestPart MultipartFile file
|
||||||
|
) {
|
||||||
|
FileUploadVo result = fileService.upload(folder, file);
|
||||||
|
return ApiResponse.entity(result, ApiResponseCode.CODE_200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "SunEditor 이미지 업로드",
|
||||||
|
description = """
|
||||||
|
SunEditor 이미지 업로드 전용 API입니다.
|
||||||
|
업로드 파일을 저장한 뒤 SunEditor가 요구하는 result 배열 형태로 응답합니다.
|
||||||
|
응답 URL은 file.upload.view.file-domain 값과 uploadPath를 조합한 공개 file-domain URL입니다.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
@PostMapping("/suneditor/upload")
|
||||||
|
public ResponseEntity<SunEditorUploadVo> sunEditorUpload(
|
||||||
|
@RequestParam(value = "folder", defaultValue = "editor") String folder,
|
||||||
|
MultipartHttpServletRequest request
|
||||||
|
) {
|
||||||
|
List<SunEditorUploadVo.Item> result = new ArrayList<>();
|
||||||
|
|
||||||
|
for (MultipartFile file : request.getFileMap().values()) {
|
||||||
|
FileUploadVo uploaded = fileService.upload(folder, file);
|
||||||
|
|
||||||
|
String imageUrl = fileService.buildImageUrl(uploaded.getUploadPath());
|
||||||
|
|
||||||
|
result.add(new SunEditorUploadVo.Item(
|
||||||
|
imageUrl,
|
||||||
|
uploaded.getOriginalFileName(),
|
||||||
|
uploaded.getFileSize()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(new SunEditorUploadVo(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "파일 보기",
|
||||||
|
description = """
|
||||||
|
uploadPath 기준으로 파일을 inline 응답합니다.
|
||||||
|
예: /uploads/notice/2026/05/08/sample.png
|
||||||
|
단순 업로드 결과의 uploadPath를 그대로 path 파라미터에 전달합니다.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
@GetMapping("/view")
|
||||||
|
public ResponseEntity<Resource> view(@RequestParam String path) {
|
||||||
|
return fileService.resource(path, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "파일 다운로드",
|
||||||
|
description = """
|
||||||
|
uploadPath 기준으로 파일을 attachment 응답합니다.
|
||||||
|
예: /uploads/notice/2026/05/08/sample.pdf
|
||||||
|
단순 업로드 결과의 uploadPath를 그대로 path 파라미터에 전달합니다.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
@GetMapping("/download")
|
||||||
|
public ResponseEntity<Resource> download(@RequestParam String path) {
|
||||||
|
return fileService.resource(path, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.alist.api.modules.file.config;
|
||||||
|
|
||||||
|
import com.alist.api.modules.file.properties.FileUploadProperties;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@EnableConfigurationProperties(FileUploadProperties.class)
|
||||||
|
@Configuration
|
||||||
|
public class FileUploadConfig {
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.alist.api.modules.file.properties;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.util.unit.DataSize;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@ConfigurationProperties(prefix = "file.upload")
|
||||||
|
public class FileUploadProperties {
|
||||||
|
private String rootPath;
|
||||||
|
private DataSize maxSize;
|
||||||
|
private List<String> allowedExtensions;
|
||||||
|
private Map<String, UploadType> types;
|
||||||
|
private View view = new View();
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class UploadType {
|
||||||
|
private String folder;
|
||||||
|
private DataSize maxSize;
|
||||||
|
private boolean imageOnly;
|
||||||
|
private Resize resize = new Resize();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class Resize {
|
||||||
|
private boolean enabled;
|
||||||
|
private Integer width;
|
||||||
|
private Integer height;
|
||||||
|
private Integer maxWidth;
|
||||||
|
private Float quality = 0.9f;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class View {
|
||||||
|
private String fileDomain;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package com.alist.api.modules.file.service;
|
||||||
|
|
||||||
|
import com.alist.api.modules.file.properties.FileUploadProperties;
|
||||||
|
import com.alist.api.modules.file.vo.FileUploadVo;
|
||||||
|
import org.springframework.core.io.InputStreamResource;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.http.*;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import org.springframework.web.util.UriComponentsBuilder;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class FileService {
|
||||||
|
private static final String PUBLIC_PREFIX = "/uploads";
|
||||||
|
|
||||||
|
private final FileUploadProperties properties;
|
||||||
|
private final FileUploadImageService fileUploadImageService;
|
||||||
|
|
||||||
|
public FileService(FileUploadProperties properties, FileUploadImageService fileUploadImageService) {
|
||||||
|
this.properties = properties;
|
||||||
|
this.fileUploadImageService = fileUploadImageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FileUploadVo upload(String folder, MultipartFile file) {
|
||||||
|
if (folder == null || folder.isBlank()) throw new IllegalArgumentException("invalid folder");
|
||||||
|
if (file == null || file.isEmpty()) throw new IllegalArgumentException("empty file");
|
||||||
|
|
||||||
|
FileUploadProperties.UploadType uploadType = resolveUploadType(folder);
|
||||||
|
|
||||||
|
String originalFileName = cleanFileName(file.getOriginalFilename());
|
||||||
|
String ext = extractExt(originalFileName);
|
||||||
|
validateExtension(ext);
|
||||||
|
validateSize(file.getSize(), uploadType);
|
||||||
|
|
||||||
|
boolean image = isImageExtension(ext);
|
||||||
|
if (uploadType.isImageOnly() && !image) throw new IllegalArgumentException("image only");
|
||||||
|
|
||||||
|
LocalDate now = LocalDate.now();
|
||||||
|
String storedFileName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
|
||||||
|
|
||||||
|
Path savePath = Paths.get(
|
||||||
|
properties.getRootPath(),
|
||||||
|
uploadType.getFolder(),
|
||||||
|
String.valueOf(now.getYear()),
|
||||||
|
"%02d".formatted(now.getMonthValue()),
|
||||||
|
"%02d".formatted(now.getDayOfMonth()),
|
||||||
|
storedFileName
|
||||||
|
).normalize().toAbsolutePath();
|
||||||
|
|
||||||
|
ensureUnderRoot(savePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Files.createDirectories(savePath.getParent());
|
||||||
|
|
||||||
|
FileUploadImageService.ImageSize imageSize = null;
|
||||||
|
if (image && uploadType.getResize() != null && uploadType.getResize().isEnabled()) {
|
||||||
|
imageSize = fileUploadImageService.resizeAndSave(file, savePath, uploadType.getResize(), ext);
|
||||||
|
} else {
|
||||||
|
file.transferTo(savePath);
|
||||||
|
if (image) imageSize = fileUploadImageService.readSize(savePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
FileUploadVo vo = new FileUploadVo();
|
||||||
|
vo.setUploadPath(toUploadPath(savePath));
|
||||||
|
vo.setOriginalFileName(originalFileName);
|
||||||
|
vo.setStoredFileName(storedFileName);
|
||||||
|
vo.setFileExtension(ext);
|
||||||
|
vo.setContentType(file.getContentType());
|
||||||
|
vo.setFileSize(Files.size(savePath));
|
||||||
|
vo.setWidth(imageSize == null ? null : imageSize.width());
|
||||||
|
vo.setHeight(imageSize == null ? null : imageSize.height());
|
||||||
|
|
||||||
|
return vo;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("file save failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ResponseEntity<Resource> resource(String uploadPath, boolean inline) {
|
||||||
|
try {
|
||||||
|
Path path = resolveUploadPath(uploadPath);
|
||||||
|
|
||||||
|
if (!Files.exists(path) || !Files.isRegularFile(path)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
String contentType = Files.probeContentType(path);
|
||||||
|
if (contentType == null || contentType.isBlank()) {
|
||||||
|
contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
ContentDisposition disposition = inline
|
||||||
|
? ContentDisposition.inline().filename(path.getFileName().toString(), StandardCharsets.UTF_8).build()
|
||||||
|
: ContentDisposition.attachment().filename(path.getFileName().toString(), StandardCharsets.UTF_8).build();
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.parseMediaType(contentType))
|
||||||
|
.contentLength(Files.size(path))
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
|
||||||
|
.body(new InputStreamResource(Files.newInputStream(path)));
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("file read failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path resolveUploadPath(String uploadPath) {
|
||||||
|
if (uploadPath == null || uploadPath.isBlank()) throw new IllegalArgumentException("empty path");
|
||||||
|
|
||||||
|
String path = uploadPath.trim().replace("\\", "/");
|
||||||
|
if (!path.startsWith(PUBLIC_PREFIX + "/")) throw new IllegalArgumentException("invalid path");
|
||||||
|
|
||||||
|
String relative = path.substring((PUBLIC_PREFIX + "/").length());
|
||||||
|
if (relative.contains("..") || relative.startsWith("/") || relative.contains(":")) {
|
||||||
|
throw new IllegalArgumentException("invalid path");
|
||||||
|
}
|
||||||
|
|
||||||
|
Path root = rootPath();
|
||||||
|
Path resolved = root.resolve(relative).normalize().toAbsolutePath();
|
||||||
|
|
||||||
|
if (!resolved.startsWith(root)) throw new IllegalArgumentException("invalid path");
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toUploadPath(Path savePath) {
|
||||||
|
Path root = rootPath();
|
||||||
|
Path absolutePath = savePath.normalize().toAbsolutePath();
|
||||||
|
|
||||||
|
if (!absolutePath.startsWith(root)) throw new IllegalArgumentException("invalid path");
|
||||||
|
|
||||||
|
return PUBLIC_PREFIX + "/" + root.relativize(absolutePath).toString().replace("\\", "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureUnderRoot(Path path) {
|
||||||
|
if (!path.startsWith(rootPath())) throw new IllegalArgumentException("invalid save path");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path rootPath() {
|
||||||
|
return Paths.get(properties.getRootPath()).normalize().toAbsolutePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateSize(long fileSize, FileUploadProperties.UploadType uploadType) {
|
||||||
|
long limit = uploadType.getMaxSize() == null
|
||||||
|
? properties.getMaxSize().toBytes()
|
||||||
|
: uploadType.getMaxSize().toBytes();
|
||||||
|
|
||||||
|
if (fileSize > limit) throw new IllegalArgumentException("file size exceeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateExtension(String ext) {
|
||||||
|
if (ext.isBlank() || properties.getAllowedExtensions().stream().noneMatch(ext::equalsIgnoreCase)) {
|
||||||
|
throw new IllegalArgumentException("invalid extension");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String cleanFileName(String fileName) {
|
||||||
|
if (fileName == null || fileName.isBlank()) throw new IllegalArgumentException("empty file name");
|
||||||
|
return Paths.get(fileName).getFileName().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractExt(String fileName) {
|
||||||
|
int idx = fileName.lastIndexOf('.');
|
||||||
|
if (idx < 0 || idx == fileName.length() - 1) return "";
|
||||||
|
return fileName.substring(idx + 1).trim().toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isImageExtension(String ext) {
|
||||||
|
return "jpg".equals(ext) || "jpeg".equals(ext) || "png".equals(ext) || "gif".equals(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileUploadProperties.UploadType resolveUploadType(String folder) {
|
||||||
|
String key = normalizeFolder(folder);
|
||||||
|
|
||||||
|
FileUploadProperties.UploadType configured = properties.getTypes() == null
|
||||||
|
? null
|
||||||
|
: properties.getTypes().get(key);
|
||||||
|
|
||||||
|
if (configured != null) {
|
||||||
|
configured.setFolder(normalizeFolder(configured.getFolder()));
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
FileUploadProperties.UploadType fallback = new FileUploadProperties.UploadType();
|
||||||
|
fallback.setFolder(key);
|
||||||
|
fallback.setMaxSize(null);
|
||||||
|
fallback.setImageOnly(false);
|
||||||
|
fallback.setResize(new FileUploadProperties.Resize());
|
||||||
|
fallback.getResize().setEnabled(false);
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeFolder(String folder) {
|
||||||
|
if (folder == null || folder.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("invalid folder");
|
||||||
|
}
|
||||||
|
|
||||||
|
String key = folder.trim().replace("\\", "/");
|
||||||
|
|
||||||
|
while (key.startsWith("/")) key = key.substring(1);
|
||||||
|
while (key.endsWith("/")) key = key.substring(0, key.length() - 1);
|
||||||
|
|
||||||
|
if (key.isBlank()
|
||||||
|
|| key.contains("..")
|
||||||
|
|| key.contains(":")
|
||||||
|
|| key.startsWith("http://")
|
||||||
|
|| key.startsWith("https://")) {
|
||||||
|
throw new IllegalArgumentException("invalid folder");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!key.matches("^[a-zA-Z0-9/_-]+$")) {
|
||||||
|
throw new IllegalArgumentException("invalid folder");
|
||||||
|
}
|
||||||
|
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String buildImageUrl(String uploadPath) {
|
||||||
|
return joinUrl(properties.getView().getFileDomain(), uploadPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String joinUrl(String domain, String path) {
|
||||||
|
if (domain == null || domain.isBlank()) {
|
||||||
|
throw new IllegalStateException("file.upload.view.file-domain is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
String base = domain.endsWith("/") ? domain.substring(0, domain.length() - 1) : domain;
|
||||||
|
String p = path.startsWith("/") ? path : "/" + path;
|
||||||
|
|
||||||
|
return base + p;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package com.alist.api.modules.file.service;
|
||||||
|
|
||||||
|
import com.alist.api.modules.file.properties.FileUploadProperties;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import javax.imageio.IIOImage;
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import javax.imageio.ImageWriteParam;
|
||||||
|
import javax.imageio.ImageWriter;
|
||||||
|
import javax.imageio.stream.ImageOutputStream;
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Iterator;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class FileUploadImageService {
|
||||||
|
public record ImageSize(int width, int height) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public ImageSize resizeAndSave(
|
||||||
|
MultipartFile file,
|
||||||
|
Path savePath,
|
||||||
|
FileUploadProperties.Resize resize,
|
||||||
|
String ext
|
||||||
|
) throws IOException {
|
||||||
|
BufferedImage source = ImageIO.read(file.getInputStream());
|
||||||
|
if (source == null) throw new IllegalArgumentException("invalid image");
|
||||||
|
|
||||||
|
BufferedImage target;
|
||||||
|
|
||||||
|
if (resize.getWidth() != null && resize.getHeight() != null) {
|
||||||
|
target = cropAndResize(source, resize.getWidth(), resize.getHeight());
|
||||||
|
} else if (resize.getMaxWidth() != null && source.getWidth() > resize.getMaxWidth()) {
|
||||||
|
target = resizeByMaxWidth(source, resize.getMaxWidth());
|
||||||
|
} else {
|
||||||
|
target = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeImage(target, imageFormat(ext), savePath, resize.getQuality());
|
||||||
|
|
||||||
|
return new ImageSize(target.getWidth(), target.getHeight());
|
||||||
|
}
|
||||||
|
|
||||||
|
public ImageSize readSize(Path path) throws IOException {
|
||||||
|
BufferedImage image = ImageIO.read(path.toFile());
|
||||||
|
if (image == null) return null;
|
||||||
|
return new ImageSize(image.getWidth(), image.getHeight());
|
||||||
|
}
|
||||||
|
|
||||||
|
private BufferedImage cropAndResize(BufferedImage source, int targetWidth, int targetHeight) {
|
||||||
|
double targetRatio = (double) targetWidth / targetHeight;
|
||||||
|
|
||||||
|
int sourceWidth = source.getWidth();
|
||||||
|
int sourceHeight = source.getHeight();
|
||||||
|
double sourceRatio = (double) sourceWidth / sourceHeight;
|
||||||
|
|
||||||
|
int cropWidth = sourceWidth;
|
||||||
|
int cropHeight = sourceHeight;
|
||||||
|
|
||||||
|
if (sourceRatio > targetRatio) {
|
||||||
|
cropWidth = (int) Math.round(sourceHeight * targetRatio);
|
||||||
|
} else {
|
||||||
|
cropHeight = (int) Math.round(sourceWidth / targetRatio);
|
||||||
|
}
|
||||||
|
|
||||||
|
BufferedImage cropped = source.getSubimage(
|
||||||
|
(sourceWidth - cropWidth) / 2,
|
||||||
|
(sourceHeight - cropHeight) / 2,
|
||||||
|
cropWidth,
|
||||||
|
cropHeight
|
||||||
|
);
|
||||||
|
|
||||||
|
return resize(cropped, targetWidth, targetHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BufferedImage resizeByMaxWidth(BufferedImage source, int maxWidth) {
|
||||||
|
int targetWidth = maxWidth;
|
||||||
|
int targetHeight = (int) Math.round((double) source.getHeight() * targetWidth / source.getWidth());
|
||||||
|
|
||||||
|
return resize(source, targetWidth, targetHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BufferedImage resize(BufferedImage source, int width, int height) {
|
||||||
|
BufferedImage target = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||||
|
Graphics2D graphics = target.createGraphics();
|
||||||
|
|
||||||
|
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
|
||||||
|
graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||||
|
|
||||||
|
graphics.drawImage(source, 0, 0, width, height, null);
|
||||||
|
graphics.dispose();
|
||||||
|
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeImage(BufferedImage image, String format, Path savePath, Float quality) throws IOException {
|
||||||
|
if (!"jpeg".equals(format)) {
|
||||||
|
boolean written = ImageIO.write(image, format, savePath.toFile());
|
||||||
|
if (!written) throw new IllegalArgumentException("unsupported image format");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpeg");
|
||||||
|
if (!writers.hasNext()) {
|
||||||
|
throw new IllegalArgumentException("unsupported image format");
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageWriter writer = writers.next();
|
||||||
|
ImageWriteParam param = writer.getDefaultWriteParam();
|
||||||
|
|
||||||
|
if (param.canWriteCompressed()) {
|
||||||
|
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
|
||||||
|
param.setCompressionQuality(normalizeQuality(quality));
|
||||||
|
}
|
||||||
|
|
||||||
|
try (ImageOutputStream output = ImageIO.createImageOutputStream(savePath.toFile())) {
|
||||||
|
writer.setOutput(output);
|
||||||
|
writer.write(null, new IIOImage(image, null, null), param);
|
||||||
|
} finally {
|
||||||
|
writer.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private float normalizeQuality(Float quality) {
|
||||||
|
if (quality == null) return 0.9f;
|
||||||
|
if (quality < 0.0f) return 0.0f;
|
||||||
|
if (quality > 1.0f) return 1.0f;
|
||||||
|
return quality;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String imageFormat(String ext) {
|
||||||
|
if ("jpg".equalsIgnoreCase(ext)) return "jpeg";
|
||||||
|
return ext.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.alist.api.modules.file.vo;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class FileUploadVo {
|
||||||
|
private String uploadPath;
|
||||||
|
private String originalFileName;
|
||||||
|
private String storedFileName;
|
||||||
|
private String fileExtension;
|
||||||
|
private String contentType;
|
||||||
|
private Long fileSize;
|
||||||
|
private Integer width;
|
||||||
|
private Integer height;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.alist.api.modules.file.vo;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class SunEditorUploadVo {
|
||||||
|
private List<Item> result;
|
||||||
|
|
||||||
|
public SunEditorUploadVo(List<Item> result) {
|
||||||
|
this.result = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class Item {
|
||||||
|
private String url;
|
||||||
|
private String name;
|
||||||
|
private Long size;
|
||||||
|
|
||||||
|
public Item(String url, String name, Long size) {
|
||||||
|
this.url = url;
|
||||||
|
this.name = name;
|
||||||
|
this.size = size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-19
@@ -5,7 +5,7 @@ import com.alist.api.common.response.ApiResponseCode;
|
|||||||
import com.alist.api.common.utils.SecurityUtil;
|
import com.alist.api.common.utils.SecurityUtil;
|
||||||
import com.alist.api.modules.tusFile.dto.*;
|
import com.alist.api.modules.tusFile.dto.*;
|
||||||
import com.alist.api.modules.tusFile.form.*;
|
import com.alist.api.modules.tusFile.form.*;
|
||||||
import com.alist.api.modules.tusFile.service.FileService;
|
import com.alist.api.modules.tusFile.service.TusFileService;
|
||||||
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;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
@@ -30,12 +30,12 @@ import java.nio.file.Path;
|
|||||||
, description = "TUS 기반 파일 업로드 초기화, 권한 검증, 상태 조회, 훅 처리, 취소 API"
|
, description = "TUS 기반 파일 업로드 초기화, 권한 검증, 상태 조회, 훅 처리, 취소 API"
|
||||||
)
|
)
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/files")
|
@RequestMapping("/tusFiles")
|
||||||
public class FileController {
|
public class TusFileController {
|
||||||
private final FileService fileService;
|
private final TusFileService tusFileService;
|
||||||
|
|
||||||
public FileController(FileService fileService) {
|
public TusFileController(TusFileService tusFileService) {
|
||||||
this.fileService = fileService;
|
this.tusFileService = tusFileService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(
|
@Operation(
|
||||||
@@ -54,7 +54,7 @@ public class FileController {
|
|||||||
return ApiResponse.entity(new FileUploadDto(), ApiResponseCode.CODE_401);
|
return ApiResponse.entity(new FileUploadDto(), ApiResponseCode.CODE_401);
|
||||||
}
|
}
|
||||||
|
|
||||||
Integer userIdx = fileService.selectUserIdxByUserTokenIdx(userTokenIdx);
|
Integer userIdx = tusFileService.selectUserIdxByUserTokenIdx(userTokenIdx);
|
||||||
|
|
||||||
if (userIdx == null) {
|
if (userIdx == null) {
|
||||||
return ApiResponse.entity(new FileUploadDto(), ApiResponseCode.CODE_401);
|
return ApiResponse.entity(new FileUploadDto(), ApiResponseCode.CODE_401);
|
||||||
@@ -63,7 +63,7 @@ public class FileController {
|
|||||||
fileUploadDto.setUserIdx(userIdx);
|
fileUploadDto.setUserIdx(userIdx);
|
||||||
fileUploadDto.setUserTokenIdx(userTokenIdx);
|
fileUploadDto.setUserTokenIdx(userTokenIdx);
|
||||||
|
|
||||||
FileUploadDto fileUploadResult = fileService.insertFileInit(fileUploadDto);
|
FileUploadDto fileUploadResult = tusFileService.insertFileInit(fileUploadDto);
|
||||||
|
|
||||||
return ApiResponse.entity(fileUploadResult, ApiResponseCode.CODE_200);
|
return ApiResponse.entity(fileUploadResult, ApiResponseCode.CODE_200);
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@ public class FileController {
|
|||||||
uploadTokenDto.setUploadMetadataRaw(uploadMetadata);
|
uploadTokenDto.setUploadMetadataRaw(uploadMetadata);
|
||||||
uploadTokenDto.setUploadLengthRaw(uploadLength);
|
uploadTokenDto.setUploadLengthRaw(uploadLength);
|
||||||
|
|
||||||
UploadTokenDto uploadTokenCheck = fileService.isUploadTokenCheck(uploadTokenDto);
|
UploadTokenDto uploadTokenCheck = tusFileService.isUploadTokenCheck(uploadTokenDto);
|
||||||
|
|
||||||
if (uploadTokenCheck.getResultCode() == 401) {
|
if (uploadTokenCheck.getResultCode() == 401) {
|
||||||
return ApiResponse.entity("", ApiResponseCode.CODE_401);
|
return ApiResponse.entity("", ApiResponseCode.CODE_401);
|
||||||
@@ -111,7 +111,7 @@ public class FileController {
|
|||||||
public ResponseEntity<ApiResponse<UploadStatusDto>> uploadStatus(
|
public ResponseEntity<ApiResponse<UploadStatusDto>> uploadStatus(
|
||||||
@Valid @RequestBody UploadStatusForm uploadStatusForm
|
@Valid @RequestBody UploadStatusForm uploadStatusForm
|
||||||
) {
|
) {
|
||||||
UploadStatusDto status = fileService.getUploadStatus(uploadStatusForm.toUploadStatusDto());
|
UploadStatusDto status = tusFileService.getUploadStatus(uploadStatusForm.toUploadStatusDto());
|
||||||
if (status == null) {
|
if (status == null) {
|
||||||
return ApiResponse.entity(new UploadStatusDto(), ApiResponseCode.CODE_2003);
|
return ApiResponse.entity(new UploadStatusDto(), ApiResponseCode.CODE_2003);
|
||||||
}
|
}
|
||||||
@@ -124,10 +124,10 @@ 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.toTusHookDto());
|
boolean accepted = tusFileService.updateFileUploadStatus(tusHookForm.toTusHookDto());
|
||||||
|
|
||||||
if (accepted) {
|
if (accepted) {
|
||||||
fileService.saveUploadStatusRedisTusHook(tusHookForm.toTusHookDto());
|
tusFileService.saveUploadStatusRedisTusHook(tusHookForm.toTusHookDto());
|
||||||
}
|
}
|
||||||
|
|
||||||
return ApiResponse.entity("", ApiResponseCode.CODE_200);
|
return ApiResponse.entity("", ApiResponseCode.CODE_200);
|
||||||
@@ -152,7 +152,7 @@ public class FileController {
|
|||||||
uploadCancelDto.setFileUuid(uploadCancelForm.getFileUuid().trim());
|
uploadCancelDto.setFileUuid(uploadCancelForm.getFileUuid().trim());
|
||||||
uploadCancelDto.setUserTokenIdx(userTokenIdx);
|
uploadCancelDto.setUserTokenIdx(userTokenIdx);
|
||||||
|
|
||||||
boolean canceled = fileService.updateUploadCancel(uploadCancelDto);
|
boolean canceled = tusFileService.updateUploadCancel(uploadCancelDto);
|
||||||
|
|
||||||
if (!canceled) {
|
if (!canceled) {
|
||||||
return ApiResponse.entity("", ApiResponseCode.CODE_2005, "업로드 취소");
|
return ApiResponse.entity("", ApiResponseCode.CODE_2005, "업로드 취소");
|
||||||
@@ -172,7 +172,7 @@ public class FileController {
|
|||||||
FileDownloadDto fileDownloadDto = new FileDownloadDto();
|
FileDownloadDto fileDownloadDto = new FileDownloadDto();
|
||||||
fileDownloadDto.setFileMasterIdx(fileMasterIdx);
|
fileDownloadDto.setFileMasterIdx(fileMasterIdx);
|
||||||
|
|
||||||
FileDownloadListDto fileDownloadList = fileService.selectFileDownloadListByFileMasterIdx(fileDownloadDto);
|
FileDownloadListDto fileDownloadList = tusFileService.selectFileDownloadListByFileMasterIdx(fileDownloadDto);
|
||||||
|
|
||||||
if (fileDownloadList.getResultCode() == 401) {
|
if (fileDownloadList.getResultCode() == 401) {
|
||||||
return ApiResponse.entity(fileDownloadList, ApiResponseCode.CODE_401);
|
return ApiResponse.entity(fileDownloadList, ApiResponseCode.CODE_401);
|
||||||
@@ -196,13 +196,13 @@ public class FileController {
|
|||||||
fileDownloadDto.setUserAgent(request.getHeader("User-Agent"));
|
fileDownloadDto.setUserAgent(request.getHeader("User-Agent"));
|
||||||
fileDownloadDto.setReferer(request.getHeader("Referer"));
|
fileDownloadDto.setReferer(request.getHeader("Referer"));
|
||||||
|
|
||||||
FileDownloadDto target = fileService.selectFileViewOrDownload(fileDownloadDto);
|
FileDownloadDto target = tusFileService.selectFileViewOrDownload(fileDownloadDto);
|
||||||
|
|
||||||
if (target == null) {
|
if (target == null) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
Path filePath = fileService.resolveStoredFilePath(target.getSavePath());
|
Path filePath = tusFileService.resolveStoredFilePath(target.getSavePath());
|
||||||
|
|
||||||
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
|
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
@@ -239,13 +239,13 @@ public class FileController {
|
|||||||
fileDownloadDto.setUserAgent(request.getHeader("User-Agent"));
|
fileDownloadDto.setUserAgent(request.getHeader("User-Agent"));
|
||||||
fileDownloadDto.setReferer(request.getHeader("Referer"));
|
fileDownloadDto.setReferer(request.getHeader("Referer"));
|
||||||
|
|
||||||
FileDownloadDto target = fileService.selectFileViewOrDownload(fileDownloadDto);
|
FileDownloadDto target = tusFileService.selectFileViewOrDownload(fileDownloadDto);
|
||||||
|
|
||||||
if (target == null) {
|
if (target == null) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
Path filePath = fileService.resolveStoredFilePath(target.getSavePath());
|
Path filePath = tusFileService.resolveStoredFilePath(target.getSavePath());
|
||||||
|
|
||||||
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
|
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
@@ -275,7 +275,7 @@ public class FileController {
|
|||||||
public ResponseEntity<ApiResponse<String>> fileDelete(
|
public ResponseEntity<ApiResponse<String>> fileDelete(
|
||||||
@Valid @RequestBody FileDeleteForm fileDeleteForm
|
@Valid @RequestBody FileDeleteForm fileDeleteForm
|
||||||
) {
|
) {
|
||||||
boolean deleted = fileService.updateFileDelete(fileDeleteForm.toFileDeleteDto());
|
boolean deleted = tusFileService.updateFileDelete(fileDeleteForm.toFileDeleteDto());
|
||||||
|
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
return ApiResponse.entity("", ApiResponseCode.CODE_2005, "파일 삭제");
|
return ApiResponse.entity("", ApiResponseCode.CODE_2005, "파일 삭제");
|
||||||
+1
-1
@@ -6,7 +6,7 @@ import org.apache.ibatis.annotations.Mapper;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface FileMapper {
|
public interface TusFileMapper {
|
||||||
void insertFileMasterInit(FileUploadDto fileUploadDto);
|
void insertFileMasterInit(FileUploadDto fileUploadDto);
|
||||||
|
|
||||||
void insertFileDetailInit(FileUploadItemDto item);
|
void insertFileDetailInit(FileUploadItemDto item);
|
||||||
+38
-38
@@ -3,7 +3,7 @@ package com.alist.api.modules.tusFile.service;
|
|||||||
import com.alist.api.common.utils.SecurityUtil;
|
import com.alist.api.common.utils.SecurityUtil;
|
||||||
import com.alist.api.config.jwt.JwtTokenProvider;
|
import com.alist.api.config.jwt.JwtTokenProvider;
|
||||||
import com.alist.api.modules.tusFile.dto.*;
|
import com.alist.api.modules.tusFile.dto.*;
|
||||||
import com.alist.api.modules.tusFile.mapper.FileMapper;
|
import com.alist.api.modules.tusFile.mapper.TusFileMapper;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.redis.core.HashOperations;
|
import org.springframework.data.redis.core.HashOperations;
|
||||||
@@ -26,31 +26,31 @@ import java.util.UUID;
|
|||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
public class FileService {
|
public class TusFileService {
|
||||||
@Value("${file.upload.tus-endpoint}")
|
@Value("${tus-file.upload.tus-endpoint}")
|
||||||
private String tusEndpoint;
|
private String tusEndpoint;
|
||||||
|
|
||||||
@Value("${file.upload.public-base-url}")
|
@Value("${tus-file.upload.public-base-url}")
|
||||||
private String publicBaseUrl;
|
private String publicBaseUrl;
|
||||||
|
|
||||||
@Value("${file.upload.auth-cache.ttl-seconds:20}")
|
@Value("${tus-file.upload.auth-cache.ttl-seconds:20}")
|
||||||
private long uploadAuthCacheTtlSeconds;
|
private long uploadAuthCacheTtlSeconds;
|
||||||
|
|
||||||
@Value("${file.upload.interrupt-seconds:30}")
|
@Value("${tus-file.upload.interrupt-seconds:30}")
|
||||||
private long uploadInterruptSeconds;
|
private long uploadInterruptSeconds;
|
||||||
|
|
||||||
@Value("${file.upload.tmp-root}")
|
@Value("${tus-file.upload.tmp-root}")
|
||||||
private String uploadTmpRoot;
|
private String uploadTmpRoot;
|
||||||
|
|
||||||
@Value("${file.upload.final-root}")
|
@Value("${tus-file.upload.final-root}")
|
||||||
private String uploadFinalRoot;
|
private String uploadFinalRoot;
|
||||||
|
|
||||||
private final FileMapper fileMapper;
|
private final TusFileMapper tusFileMapper;
|
||||||
private final JwtTokenProvider jwtTokenProvider;
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
private final StringRedisTemplate stringRedisTemplate;
|
private final StringRedisTemplate stringRedisTemplate;
|
||||||
|
|
||||||
public FileService(FileMapper fileMapper, JwtTokenProvider jwtTokenProvider, StringRedisTemplate stringRedisTemplate) {
|
public TusFileService(TusFileMapper tusFileMapper, JwtTokenProvider jwtTokenProvider, StringRedisTemplate stringRedisTemplate) {
|
||||||
this.fileMapper = fileMapper;
|
this.tusFileMapper = tusFileMapper;
|
||||||
this.jwtTokenProvider = jwtTokenProvider;
|
this.jwtTokenProvider = jwtTokenProvider;
|
||||||
this.stringRedisTemplate = stringRedisTemplate;
|
this.stringRedisTemplate = stringRedisTemplate;
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ public class FileService {
|
|||||||
fileUploadDto.setStatus(0);
|
fileUploadDto.setStatus(0);
|
||||||
fileUploadDto.setTotalCount(fileUploadDto.getItemList().size());
|
fileUploadDto.setTotalCount(fileUploadDto.getItemList().size());
|
||||||
|
|
||||||
fileMapper.insertFileMasterInit(fileUploadDto);
|
tusFileMapper.insertFileMasterInit(fileUploadDto);
|
||||||
fileUploadDto.setTusEndpoint(tusEndpoint);
|
fileUploadDto.setTusEndpoint(tusEndpoint);
|
||||||
|
|
||||||
if (fileUploadDto.getItemList() == null || fileUploadDto.getItemList().isEmpty()) {
|
if (fileUploadDto.getItemList() == null || fileUploadDto.getItemList().isEmpty()) {
|
||||||
@@ -74,7 +74,7 @@ public class FileService {
|
|||||||
item.setUploadToken(jwtTokenProvider.createUploadToken(fileUploadDto.getUserTokenIdx()));
|
item.setUploadToken(jwtTokenProvider.createUploadToken(fileUploadDto.getUserTokenIdx()));
|
||||||
item.setUserIdx(fileUploadDto.getUserIdx());
|
item.setUserIdx(fileUploadDto.getUserIdx());
|
||||||
item.setStatus(0); // PENDING
|
item.setStatus(0); // PENDING
|
||||||
fileMapper.insertFileDetailInit(item);
|
tusFileMapper.insertFileDetailInit(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
return fileUploadDto;
|
return fileUploadDto;
|
||||||
@@ -118,7 +118,7 @@ public class FileService {
|
|||||||
log.warn("uploadAuth cache get fail. fallback DB. key={}", cacheKey, redisGetEx);
|
log.warn("uploadAuth cache get fail. fallback DB. key={}", cacheKey, redisGetEx);
|
||||||
}
|
}
|
||||||
|
|
||||||
int isOk = fileMapper.selectFileDetailCountByFileUuidUserToKenIdx(uploadTokenDto);
|
int isOk = tusFileMapper.selectFileDetailCountByFileUuidUserToKenIdx(uploadTokenDto);
|
||||||
int resultCode = (isOk > 0) ? 200 : 403;
|
int resultCode = (isOk > 0) ? 200 : 403;
|
||||||
uploadTokenDto.setResultCode(resultCode);
|
uploadTokenDto.setResultCode(resultCode);
|
||||||
|
|
||||||
@@ -233,14 +233,14 @@ public class FileService {
|
|||||||
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);
|
tusFileMapper.insertFileUploadEventLog(tusHookDto);
|
||||||
|
|
||||||
FileUploadMetaDto fileUploadMetaInfo = fileMapper.selectFileUploadMetaByFileUuid(fileUuid);
|
FileUploadMetaDto fileUploadMetaInfo = tusFileMapper.selectFileUploadMetaByFileUuid(fileUuid);
|
||||||
if (fileUploadMetaInfo == null) return true;
|
if (fileUploadMetaInfo == null) return true;
|
||||||
|
|
||||||
if (isMetadataMismatch(fileUploadMetaInfo, tusHookDto)) {
|
if (isMetadataMismatch(fileUploadMetaInfo, tusHookDto)) {
|
||||||
fileMapper.updateFileDetailCanceledByFileUuid(fileUuid);
|
tusFileMapper.updateFileDetailCanceledByFileUuid(fileUuid);
|
||||||
fileMapper.updateFileMasterAggregateByFileUuid(fileUuid);
|
tusFileMapper.updateFileMasterAggregateByFileUuid(fileUuid);
|
||||||
saveCanceledStatusRedis(fileUuid, offset, size);
|
saveCanceledStatusRedis(fileUuid, offset, size);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -250,14 +250,14 @@ public class FileService {
|
|||||||
FileUploadMetaDto fileUploadMetaDto = new FileUploadMetaDto();
|
FileUploadMetaDto fileUploadMetaDto = new FileUploadMetaDto();
|
||||||
fileUploadMetaDto.setFileUuid(fileUuid);
|
fileUploadMetaDto.setFileUuid(fileUuid);
|
||||||
fileUploadMetaDto.setTusUploadId(uploadId);
|
fileUploadMetaDto.setTusUploadId(uploadId);
|
||||||
fileMapper.updateTusUploadIdIfNull(fileUploadMetaDto);
|
tusFileMapper.updateTusUploadIdIfNull(fileUploadMetaDto);
|
||||||
|
|
||||||
fileUploadMetaInfo = fileMapper.selectFileUploadMetaByFileUuid(fileUuid);
|
fileUploadMetaInfo = tusFileMapper.selectFileUploadMetaByFileUuid(fileUuid);
|
||||||
}
|
}
|
||||||
if (fileUploadMetaInfo.getTusUploadId() == null || fileUploadMetaInfo.getTusUploadId().isBlank()
|
if (fileUploadMetaInfo.getTusUploadId() == null || fileUploadMetaInfo.getTusUploadId().isBlank()
|
||||||
|| !fileUploadMetaInfo.getTusUploadId().equals(uploadId)) {
|
|| !fileUploadMetaInfo.getTusUploadId().equals(uploadId)) {
|
||||||
fileMapper.updateFileDetailCanceledByFileUuid(fileUuid);
|
tusFileMapper.updateFileDetailCanceledByFileUuid(fileUuid);
|
||||||
fileMapper.updateFileMasterAggregateByFileUuid(fileUuid);
|
tusFileMapper.updateFileMasterAggregateByFileUuid(fileUuid);
|
||||||
saveCanceledStatusRedis(fileUuid, offset, size);
|
saveCanceledStatusRedis(fileUuid, offset, size);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -319,11 +319,11 @@ public class FileService {
|
|||||||
boolean shouldUpdateDetail = !"post-receive".equals(type);
|
boolean shouldUpdateDetail = !"post-receive".equals(type);
|
||||||
|
|
||||||
if (shouldUpdateDetail) {
|
if (shouldUpdateDetail) {
|
||||||
fileMapper.updateFileDetailStatus(tusHookDto);
|
tusFileMapper.updateFileDetailStatus(tusHookDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("post-finish".equals(type) || "post-terminate".equals(type)) {
|
if ("post-finish".equals(type) || "post-terminate".equals(type)) {
|
||||||
fileMapper.updateFileMasterAggregateByFileUuid(tusHookDto.getFileUuid());
|
tusFileMapper.updateFileMasterAggregateByFileUuid(tusHookDto.getFileUuid());
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("post-finish".equals(type)) {
|
if ("post-finish".equals(type)) {
|
||||||
@@ -352,7 +352,7 @@ public class FileService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void tryMoveNowByFileUuid(String fileUuid) throws IOException {
|
public void tryMoveNowByFileUuid(String fileUuid) throws IOException {
|
||||||
FileMoveTaskDto task = fileMapper.selectMoveTaskByFileUuid(fileUuid);
|
FileMoveTaskDto task = tusFileMapper.selectMoveTaskByFileUuid(fileUuid);
|
||||||
if (task == null) return;
|
if (task == null) return;
|
||||||
if (task.getMoveYn() != null && !"N".equalsIgnoreCase(task.getMoveYn())) return;
|
if (task.getMoveYn() != null && !"N".equalsIgnoreCase(task.getMoveYn())) return;
|
||||||
|
|
||||||
@@ -373,7 +373,7 @@ public class FileService {
|
|||||||
task.setSavePath(toSavePath(finalPath));
|
task.setSavePath(toSavePath(finalPath));
|
||||||
task.setSaveName(finalPath.getFileName().toString());
|
task.setSaveName(finalPath.getFileName().toString());
|
||||||
task.setExt(extractExt(task.getSaveName()));
|
task.setExt(extractExt(task.getSaveName()));
|
||||||
fileMapper.updateFileMoveSuccess(task);
|
tusFileMapper.updateFileMoveSuccess(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -381,7 +381,7 @@ public class FileService {
|
|||||||
FileMoveTaskDto task = new FileMoveTaskDto();
|
FileMoveTaskDto task = new FileMoveTaskDto();
|
||||||
task.setFileUuid(fileUuid);
|
task.setFileUuid(fileUuid);
|
||||||
task.setMoveLastError(errMsg == null ? "move failed" : errMsg);
|
task.setMoveLastError(errMsg == null ? "move failed" : errMsg);
|
||||||
fileMapper.updateFileMovePendingByFileUuid(task); // MOVE_YN='N', TRY_COUNT+1, LAST_ERROR
|
tusFileMapper.updateFileMovePendingByFileUuid(task); // MOVE_YN='N', TRY_COUNT+1, LAST_ERROR
|
||||||
}
|
}
|
||||||
|
|
||||||
private String truncateErr(String s) {
|
private String truncateErr(String s) {
|
||||||
@@ -567,21 +567,21 @@ public class FileService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
int allowed = fileMapper.selectFileDetailCountByFileUuidAndUserToKenIdx(uploadCancelDto);
|
int allowed = tusFileMapper.selectFileDetailCountByFileUuidAndUserToKenIdx(uploadCancelDto);
|
||||||
if (allowed <= 0) {
|
if (allowed <= 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
UploadCancelDto progress = fileMapper.selectUploadProgressByFileUuid(uploadCancelDto);
|
UploadCancelDto progress = tusFileMapper.selectUploadProgressByFileUuid(uploadCancelDto);
|
||||||
long uploaded = (progress != null && progress.getUploadedBytes() != null) ? progress.getUploadedBytes() : 0L;
|
long uploaded = (progress != null && progress.getUploadedBytes() != null) ? progress.getUploadedBytes() : 0L;
|
||||||
long total = (progress != null && progress.getSizeBytes() != null) ? progress.getSizeBytes() : 0L;
|
long total = (progress != null && progress.getSizeBytes() != null) ? progress.getSizeBytes() : 0L;
|
||||||
|
|
||||||
int updated = fileMapper.updateFileDetailCanceledByFileUuidAndUserTokenIdx(uploadCancelDto);
|
int updated = tusFileMapper.updateFileDetailCanceledByFileUuidAndUserTokenIdx(uploadCancelDto);
|
||||||
if (updated <= 0) {
|
if (updated <= 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
fileMapper.updateFileMasterAggregateByFileUuid(uploadCancelDto.getFileUuid());
|
tusFileMapper.updateFileMasterAggregateByFileUuid(uploadCancelDto.getFileUuid());
|
||||||
saveCanceledStatusRedis(uploadCancelDto.getFileUuid(), uploaded, total); // 필요하면 기존 bytes 조회해서 넣어도 됨
|
saveCanceledStatusRedis(uploadCancelDto.getFileUuid(), uploaded, total); // 필요하면 기존 bytes 조회해서 넣어도 됨
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -600,7 +600,7 @@ public class FileService {
|
|||||||
|
|
||||||
fileDownloadList.setFileMasterIdx(fileDownloadDto.getFileMasterIdx());
|
fileDownloadList.setFileMasterIdx(fileDownloadDto.getFileMasterIdx());
|
||||||
|
|
||||||
List<FileDownloadItemDto> itemList = fileMapper.selectFileDownloadListByFileMasterIdx(fileDownloadDto);
|
List<FileDownloadItemDto> itemList = tusFileMapper.selectFileDownloadListByFileMasterIdx(fileDownloadDto);
|
||||||
|
|
||||||
for (FileDownloadItemDto item : itemList) {
|
for (FileDownloadItemDto item : itemList) {
|
||||||
item.setViewUrl("/files/view/" + item.getFileUuid());
|
item.setViewUrl("/files/view/" + item.getFileUuid());
|
||||||
@@ -621,7 +621,7 @@ public class FileService {
|
|||||||
|
|
||||||
fileDownloadDto.setUserTokenIdx(userTokenIdx);
|
fileDownloadDto.setUserTokenIdx(userTokenIdx);
|
||||||
|
|
||||||
FileDownloadDto fileDetailInfo = fileMapper.selectFileDetailByFileUuid(fileDownloadDto);
|
FileDownloadDto fileDetailInfo = tusFileMapper.selectFileDetailByFileUuid(fileDownloadDto);
|
||||||
|
|
||||||
if (fileDetailInfo == null) {
|
if (fileDetailInfo == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -633,7 +633,7 @@ public class FileService {
|
|||||||
fileDetailInfo.setUserAgent(fileDownloadDto.getUserAgent());
|
fileDetailInfo.setUserAgent(fileDownloadDto.getUserAgent());
|
||||||
fileDetailInfo.setReferer(fileDownloadDto.getReferer());
|
fileDetailInfo.setReferer(fileDownloadDto.getReferer());
|
||||||
|
|
||||||
fileMapper.insertFileDownloadEventLog(fileDetailInfo);
|
tusFileMapper.insertFileDownloadEventLog(fileDetailInfo);
|
||||||
return fileDetailInfo;
|
return fileDetailInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,17 +668,17 @@ public class FileService {
|
|||||||
fileDeleteDto.setUserTokenIdx(userTokenIdx);
|
fileDeleteDto.setUserTokenIdx(userTokenIdx);
|
||||||
fileDeleteDto.setFileUuid(fileDeleteDto.getFileUuid().trim());
|
fileDeleteDto.setFileUuid(fileDeleteDto.getFileUuid().trim());
|
||||||
|
|
||||||
int allowed = fileMapper.selectFileDeleteTargetCount(fileDeleteDto);
|
int allowed = tusFileMapper.selectFileDeleteTargetCount(fileDeleteDto);
|
||||||
if (allowed <= 0) {
|
if (allowed <= 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
int detailUpdated = fileMapper.updateFileDetailDeleteByFileUuid(fileDeleteDto);
|
int detailUpdated = tusFileMapper.updateFileDetailDeleteByFileUuid(fileDeleteDto);
|
||||||
if (detailUpdated <= 0) {
|
if (detailUpdated <= 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
fileMapper.updateFileMasterDeleteByFileUuid(fileDeleteDto);
|
tusFileMapper.updateFileMasterDeleteByFileUuid(fileDeleteDto);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -687,6 +687,6 @@ public class FileService {
|
|||||||
if (userTokenIdx == null) {
|
if (userTokenIdx == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return fileMapper.selectUserIdxByUserTokenIdx(userTokenIdx);
|
return tusFileMapper.selectUserIdxByUserTokenIdx(userTokenIdx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,15 +7,19 @@ spring:
|
|||||||
password: 1qaz2wsx!@
|
password: 1qaz2wsx!@
|
||||||
driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
|
driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
|
||||||
hikari:
|
hikari:
|
||||||
maximum-pool-size: 10 # 줄이기
|
maximum-pool-size: 10
|
||||||
minimum-idle: 1 # 최소로
|
minimum-idle: 5
|
||||||
connection-timeout: 10000
|
connection-timeout: 10000
|
||||||
idle-timeout: 30000
|
idle-timeout: 600000
|
||||||
data:
|
data:
|
||||||
redis:
|
redis:
|
||||||
host: 121.160.234.222
|
host: 121.160.234.222
|
||||||
port: 3001
|
port: 3001
|
||||||
password: 1qaz2wsx!@
|
password: 1qaz2wsx!@
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: -1
|
||||||
|
max-request-size: -1
|
||||||
|
|
||||||
migration:
|
migration:
|
||||||
datasource:
|
datasource:
|
||||||
@@ -55,7 +59,7 @@ swagger:
|
|||||||
id: alist
|
id: alist
|
||||||
password: "1qaz2wsx!@"
|
password: "1qaz2wsx!@"
|
||||||
|
|
||||||
file:
|
tus-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
|
||||||
@@ -65,6 +69,41 @@ file:
|
|||||||
auth-cache:
|
auth-cache:
|
||||||
ttl-seconds: 20
|
ttl-seconds: 20
|
||||||
|
|
||||||
|
# types:
|
||||||
|
# notice:
|
||||||
|
# folder: notice 저장폴더
|
||||||
|
# max-size: 20MB 최대 용량
|
||||||
|
# image-only: false
|
||||||
|
# resize:
|
||||||
|
# enabled: true
|
||||||
|
# width: 800 고정넓이
|
||||||
|
# height: 600 고정높이
|
||||||
|
# max-width: 1200 최대 넓이
|
||||||
|
|
||||||
|
file:
|
||||||
|
upload:
|
||||||
|
view:
|
||||||
|
file-domain: http://localhost:8110
|
||||||
|
root-path: ${tus-file.upload.final-root}
|
||||||
|
max-size: 10MB
|
||||||
|
allowed-extensions: [jpg, jpeg, png, gif, pdf, hwp, hwpx, doc, docx, xls, xlsx, ppt, pptx, txt, csv, zip]
|
||||||
|
types:
|
||||||
|
profile:
|
||||||
|
folder: profile
|
||||||
|
max-size: 5MB
|
||||||
|
image-only: true
|
||||||
|
resize:
|
||||||
|
enabled: true
|
||||||
|
width: 400
|
||||||
|
height: 400
|
||||||
|
notice:
|
||||||
|
folder: notice
|
||||||
|
max-size: 20MB
|
||||||
|
image-only: false
|
||||||
|
resize:
|
||||||
|
enabled: true
|
||||||
|
max-width: 1200
|
||||||
|
|
||||||
springdoc:
|
springdoc:
|
||||||
api-docs:
|
api-docs:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -9,15 +9,19 @@ spring:
|
|||||||
password: ${DB_PASSWORD}
|
password: ${DB_PASSWORD}
|
||||||
driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
|
driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
|
||||||
hikari:
|
hikari:
|
||||||
maximum-pool-size: 10 # 줄이기
|
maximum-pool-size: 10
|
||||||
minimum-idle: 1 # 최소로
|
minimum-idle: 5
|
||||||
connection-timeout: 10000
|
connection-timeout: 10000
|
||||||
idle-timeout: 30000
|
idle-timeout: 600000
|
||||||
data:
|
data:
|
||||||
redis:
|
redis:
|
||||||
host: ${REDIS_HOST}
|
host: ${REDIS_HOST}
|
||||||
port: ${REDIS_PORT}
|
port: ${REDIS_PORT}
|
||||||
password: ${REDIS_PASSWORD}
|
password: ${REDIS_PASSWORD}
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: -1
|
||||||
|
max-request-size: -1
|
||||||
|
|
||||||
migration:
|
migration:
|
||||||
datasource:
|
datasource:
|
||||||
@@ -57,7 +61,7 @@ swagger:
|
|||||||
id: ${SWAGGER_ID}
|
id: ${SWAGGER_ID}
|
||||||
password: ${SWAGGER_PASSWORD}
|
password: ${SWAGGER_PASSWORD}
|
||||||
|
|
||||||
file:
|
tus-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
|
||||||
@@ -67,6 +71,41 @@ file:
|
|||||||
auth-cache:
|
auth-cache:
|
||||||
ttl-seconds: 20
|
ttl-seconds: 20
|
||||||
|
|
||||||
|
# types:
|
||||||
|
# notice:
|
||||||
|
# folder: notice 저장폴더
|
||||||
|
# max-size: 20MB 최대 용량
|
||||||
|
# image-only: false
|
||||||
|
# resize:
|
||||||
|
# enabled: true
|
||||||
|
# width: 800 고정넓이
|
||||||
|
# height: 600 고정높이
|
||||||
|
# max-width: 1200 최대 넓이
|
||||||
|
|
||||||
|
file:
|
||||||
|
upload:
|
||||||
|
view:
|
||||||
|
file-domain: https://file-alist.pjt.kr
|
||||||
|
root-path: ${tus-file.upload.final-root}
|
||||||
|
max-size: 10MB
|
||||||
|
allowed-extensions: [jpg, jpeg, png, gif, pdf, hwp, hwpx, doc, docx, xls, xlsx, ppt, pptx, txt, csv, zip]
|
||||||
|
types:
|
||||||
|
profile:
|
||||||
|
folder: profile
|
||||||
|
max-size: 5MB
|
||||||
|
image-only: true
|
||||||
|
resize:
|
||||||
|
enabled: true
|
||||||
|
width: 400
|
||||||
|
height: 400
|
||||||
|
notice:
|
||||||
|
folder: notice
|
||||||
|
max-size: 20MB
|
||||||
|
image-only: false
|
||||||
|
resize:
|
||||||
|
enabled: true
|
||||||
|
max-width: 1200
|
||||||
|
|
||||||
springdoc:
|
springdoc:
|
||||||
api-docs:
|
api-docs:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
|
||||||
<mapper namespace="com.alist.api.modules.file.mapper.FileMapper">
|
<mapper namespace="com.alist.api.modules.tusFile.mapper.TusFileMapper">
|
||||||
|
|
||||||
<insert id="insertFileMasterInit" useGeneratedKeys="true" keyProperty="fileMasterIdx">
|
<insert id="insertFileMasterInit" useGeneratedKeys="true" keyProperty="fileMasterIdx">
|
||||||
/*FileMapper.insertFileMasterInit*/
|
/*FileMapper.insertFileMasterInit*/
|
||||||
Reference in New Issue
Block a user