Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f389d652d6 | |||
| 23efdf4b9a |
@@ -1,58 +1,191 @@
|
||||
# AGENTS.md
|
||||
# AGENTS.md
|
||||
|
||||
## 목적
|
||||
- 이 저장소에서 작업하는 사람과 코딩 에이전트가 같은 기준으로 개발하도록 돕는 운영 가이드다.
|
||||
- 불필요한 구조 변경보다 작은 단위의 안전한 수정, 빠른 검증, 명확한 보고를 우선한다.
|
||||
|
||||
## 우선 확인할 규칙
|
||||
- 기존 구조와 네이밍을 우선 존중하고, 요청 범위 안에서 필요한 만큼만 수정한다.
|
||||
- 소스코드 변경 요청에서는 바로 구현하지 말고 `코드로 보여주기` 또는 `직접 작성하기` 중 원하는 방식을 먼저 확인한다.
|
||||
- Controller 는 요청/응답 조립과 인증 주체 확인에 집중하고, 복잡한 로직과 DB 처리는 Service 로 넘긴다.
|
||||
- 응답은 `ApiResponse<T>` 와 `ApiResponseCode` 조합을 우선 사용한다.
|
||||
- 신규/수정되는 Controller, Form, VO에는 Swagger 테스트 편의성을 위해 `@Tag`, `@Operation`, `@Schema` 설명과 예시를 남긴다.
|
||||
- 변경 후에는 가능하면 테스트 또는 최소 실행 검증 결과를 남기고, 미실행 항목은 사유를 분명히 적는다.
|
||||
|
||||
## 문서 인덱스
|
||||
| 구분 | 문서 | 경로(URL) |
|
||||
|------|------|-----------|
|
||||
| 기본 | [프로젝트 개요](docs/project-overview.md) | `docs/project-overview.md` |
|
||||
| 기본 | [코드 컨벤션](docs/code-conventions.md) | `docs/code-conventions.md` |
|
||||
| 기본 | [보안 및 응답 규칙](docs/security-and-response.md) | `docs/security-and-response.md` |
|
||||
| 기본 | [설정 및 실행 가이드](docs/runtime-config.md) | `docs/runtime-config.md` |
|
||||
| 기본 | [검증 및 체크리스트](docs/verification-checklist.md) | `docs/verification-checklist.md` |
|
||||
| 기본 | [Codex 작업 규칙](docs/agent-workflow.md) | `docs/agent-workflow.md` |
|
||||
| 기본 | [현재 코드베이스 메모](docs/codebase-notes.md) | `docs/codebase-notes.md` |
|
||||
| 정책 | [정책서 요약 인덱스](docs/policy/index.md) | `docs/policy/index.md` |
|
||||
| 정책 | [정책서: 서비스 개요](docs/policy/overview.md) | `docs/policy/overview.md` |
|
||||
| 정책 | [정책서: 회원/권한/인증](docs/policy/membership-and-auth.md) | `docs/policy/membership-and-auth.md` |
|
||||
| 정책 | [정책서: 캠퍼스/강좌/학습 흐름](docs/policy/campus-and-learning.md) | `docs/policy/campus-and-learning.md` |
|
||||
| 정책 | [정책서: 콘텐츠/LMS](docs/policy/content-and-lms.md) | `docs/policy/content-and-lms.md` |
|
||||
| 정책 | [정책서: 인터페이스/도메인/운영](docs/policy/interfaces-and-operations.md) | `docs/policy/interfaces-and-operations.md` |
|
||||
|
||||
## 빠른 판단 가이드
|
||||
- 구조, 네이밍, DTO/VO/Form, Mapper 작성 방식은 `docs/code-conventions.md` 를 우선 본다.
|
||||
- 인증/인가, 공통 응답, 예외 처리, 응답 코드는 `docs/security-and-response.md` 를 우선 본다.
|
||||
- 프로파일, 설정 파일, 로깅, Swagger, 실행 명령은 `docs/runtime-config.md` 를 우선 본다.
|
||||
- 테스트 범위, 배포 전 점검, 파일/권한/설정 변경 확인사항은 `docs/verification-checklist.md` 를 우선 본다.
|
||||
- Codex 협업 방식과 응답 원칙은 `docs/agent-workflow.md` 를 우선 본다.
|
||||
- 도메인 정책, 역할, 강좌/콘텐츠/LMS 규칙은 `docs/policy/` 하위 문서를 우선 본다.
|
||||
|
||||
## 정책서 핵심 요약
|
||||
- 서비스는 `alist.co.kr` 메인 서비스, `a-campus.co.kr` 캠퍼스 메인, `class.a-campus.co.kr` 교사용, `student.a-campus.co.kr` 학생용, `admin.alist.co.kr` 백오피스/CMS로 분리 운영한다.
|
||||
- 회원 유형은 관리자, 교사, 학생, 수강생으로 구분하며, 수강생은 캠퍼스 초대 기반 준회원이고 학생 전환 및 ID 병합 정책이 존재한다.
|
||||
- 교사만 캠퍼스를 생성할 수 있고 캠퍼스는 `단일 캠퍼스형`과 `복합 캠퍼스형`으로 나뉜다. 복합 캠퍼스형은 관리자 승인 후 운영한다.
|
||||
- 캠퍼스 계층은 `캠퍼스 -> 클래스 -> 강좌(Lecture)` 구조이며, 강좌는 `교재 1종`과 `1:1 매칭`되고 학생/수강생 초대, 과제/평가, 학습 관리의 기준 단위다.
|
||||
- 교사 회원은 Live 상태에서 단수 캠퍼스만 소속 가능하고, 수강생도 Live 상태에서 단수 캠퍼스만 소속 가능하다. 본인 인증을 마친 학생은 복수 캠퍼스 소속이 가능하다.
|
||||
- 강좌 운영 중 학생 중도 초대와 상태 변경이 가능하며, 탈퇴/종료 시 교사 화면에서는 학습 이력과 산출물이 숨김 처리된다.
|
||||
- 콘텐츠는 교재 자료, 스마트 콘텐츠, 평가 문항, 온라인 학습/과제/평가로 구성되며 접근 권한과 학습 관리 범위가 회원 유형별로 다르다.
|
||||
- 학습 관리는 진도, 수행 여부, 정오답, 성취도, 변화 추이, 오답 노트, 포트폴리오까지 포함한다. 과제/평가/온라인 학습 데이터가 주요 관리 대상이다.
|
||||
- 캠퍼스 개인화는 캠퍼스 명칭, 직접 접속 도메인, 로고, GNB 색상 기준으로 제공하며, 정책서상 해외 임대/제휴 확장을 고려한 도메인 구조를 가진다.
|
||||
- 기존 서비스 회원 DB와 학습 이력은 완전 마이그레이션 대상이 아니므로 최초 로그인 연동, 레거시 병행 운영, 데이터 재구조화 정책을 함께 고려해야 한다.
|
||||
|
||||
## 프로젝트 핵심 정보
|
||||
## 프로젝트 개요
|
||||
- 프로젝트 유형: Gradle 기반 Spring Boot 애플리케이션
|
||||
- Java 버전: 21
|
||||
- Spring Boot 버전: 3.5.10
|
||||
- 기본 애플리케이션 이름: `api`
|
||||
- 기본 포트: `8106`
|
||||
- 실행 진입점: `src/main/java/com/alist/api/ApiApplication.java`
|
||||
|
||||
## 기술 스택
|
||||
- **Java**: 21
|
||||
- **Framework**: Spring Boot 3.5.10
|
||||
- **빌드 도구**: Gradle
|
||||
- **DB**: MariaDB
|
||||
- **ORM**: MyBatis (mapper XML: `classpath:mapper/**/*.xml`)
|
||||
- **인증**: JWT (jjwt 0.11.5) + Spring Security
|
||||
- **API 문서**: Swagger (springdoc-openapi 2.8.0)
|
||||
- **기타**: Lombok, Validation, Actuator, log4jdbc
|
||||
|
||||
## 디렉터리 가이드
|
||||
- `src/main/java/com/alist/api`: 애플리케이션 시작점과 업무 코드를 둔다.
|
||||
- `src/main/java/com/alist/api/modules`: 기능별 모듈 패키지를 둔다.
|
||||
- `src/main/resources`: 설정 파일과 로깅 설정을 관리한다.
|
||||
- `deploy`: 배포 관련 리소스가 있으면 이 경로를 우선 확인한다.
|
||||
|
||||
## 현재 확인된 구조
|
||||
- 현재 기준 메인 흐름은 `Controller -> Form -> Dto -> Service -> Mapper(XML) -> vo -> Service -> Controller` 순서로 연결된다.
|
||||
- API 에서 request 받을 때 POST 는 주로 JSON을 사용한다. Controller 는 `form` 객체로 요청을 받은 뒤 DTO 로 변환해서 Service 에 전달한다.
|
||||
- MyBatis는 인터페이스와 XML을 함께 사용한다.
|
||||
- Mapper 인터페이스는 `src/main/java/.../mapper`, SQL XML은 `src/main/resources/mapper/...` 경로를 짝으로 맞춘다.
|
||||
- 공통 응답은 `common/response`, 보안은 `config/security, jwt`, 전역 예외 처리는 `config/exception` 아래에 둔다.
|
||||
- 모듈 패키지는 현재 `auth`, `file`, `main`, `user` 형태로 구성되어 있고, 필요한 모듈만 `dto`, `form`, `mapper`, `service`, `vo`를 둔다.
|
||||
|
||||
## 파일 생성 규칙
|
||||
- 새 기능은 가능하면 `modules/{도메인명}Controller` 단위로 패키지를 만들고 그 아래에 `service`, `mapper`, `dto`, `vo`를 필요한 만큼만 추가한다.
|
||||
- 요청 검증이나 JSON 바인딩이 필요하면 `form` 패키지를 함께 만든다.
|
||||
- 클래스명은 역할이 바로 드러나게 `도메인명 + 역할` 형식을 유지한다. 예: `ApiService`, `ApiMapper`, `ApiVo`
|
||||
- Mapper 인터페이스를 추가하면 같은 이름의 XML을 `src/main/resources/mapper/{도메인경로}` 아래 함께 만든다.
|
||||
- 단순 예시 코드와 운영 코드는 섞지 말고 패키지로 분리한다.
|
||||
- 설정성 클래스는 `config` 하위 역할별 패키지에 둔다. 예: `config.jwt`, `config.exception`, `config.properties`
|
||||
|
||||
## DTO/VO/Mapper 규칙
|
||||
- `Dto`는 저장, 수정, 로그 적재처럼 내부 상태 변경이나 DB update/insert에 사용하는 값 객체로 본다.
|
||||
- `Vo`는 조회 결과나 스케줄 실행 판단에 필요한 읽기 전용 성격의 값으로 본다.
|
||||
- `Form`은 Controller 입력 검증과 요청 바인딩 전용으로 두고, `@Valid` 와 Jakarta Validation 어노테이션을 우선 사용한다.
|
||||
- 현재 코드처럼 DTO/VO는 Lombok `@Getter`, 필요한 경우에만 `@Setter`를 사용한다.
|
||||
- Form 안에는 DTO 변환 메서드를 둘 수 있다. 예: `userDto()`, `fileUploadDto()`
|
||||
- DTO 안에 연관된 다른 DTO 변환이 꼭 필요할 때만 최소한의 보조 메서드를 둔다.
|
||||
- 외부 응답에 노출되면 안 되는 내부 필드는 DTO 에 `@JsonIgnore`로 숨긴다.
|
||||
- Mapper 메서드명은 SQL 동작이 드러나도록 `select`, `insert`, `update` 접두어를 사용한다.
|
||||
- 삭제가 물리 삭제가 아니라 상태 변경이면 `delete` 대신 목적이 드러나는 `update...Canceled`, `update...DelYn` 같은 이름을 우선한다.
|
||||
- Mapper XML `namespace`는 인터페이스의 전체 경로와 정확히 일치시킨다.
|
||||
- XML의 `id`는 Mapper 메서드명과 동일하게 맞춘다.
|
||||
- 조회 결과 타입은 `resultType`, 저장/수정 파라미터는 DTO/VO 필드명과 매핑되는 프로퍼티명을 그대로 사용한다.
|
||||
- Mapper XML 안 SQL 블록 시작부 주석은 현재 코드처럼 `/*Mapper.method*/` 형식을 유지한다.
|
||||
|
||||
## 작업 원칙
|
||||
- 기존 구조와 네이밍을 우선 존중한다.
|
||||
- 한 번에 큰 리팩터링을 하지 말고, 요청 범위 안에서 필요한 만큼만 수정한다.
|
||||
- 인코딩 문제가 보이는 문자열은 무심코 대량 수정하지 말고 원인과 영향 범위를 먼저 확인한다.
|
||||
- 설정 파일 수정 시 `local`, `pjt`, 공통 설정 간 차이를 함께 확인한다.
|
||||
- 스케줄러 코드는 실행 주기, 중복 실행 가능성, 로그량을 반드시 점검한다.
|
||||
- 인증 방식이 섞여 있으므로 세션 기반 처리와 JWT `SecurityContext` 사용 위치를 먼저 구분하고 수정한다.
|
||||
- 파일 경로를 다루는 기능은 상대경로 탈출, 루트 이탈 방지 같은 검증을 같이 본다.
|
||||
|
||||
## 코드 스타일
|
||||
- Java 코드는 현재 프로젝트 스타일에 맞춰 탭/들여쓰기와 import 정렬을 유지한다.
|
||||
- Lombok은 반복 보일러플레이트 제거에만 절제해서 사용한다.
|
||||
- 로그는 `Slf4j`를 사용하고 반복문 내부 대량 출력은 지양한다.
|
||||
- 새로운 기능은 가능하면 역할이 드러나는 패키지로 분리한다.
|
||||
- 생성자 주입을 기본으로 하고 필드 주입은 추가하지 않는다.
|
||||
- Service 에서 DB 상태를 바꾸는 메서드는 필요한 범위에서 `@Transactional`을 사용하고, 조회 전용은 `readOnly = true`를 우선 검토한다.
|
||||
- 문자열 입력값은 현재 코드처럼 필요한 지점에서 `trim()` 처리하고, null 가능성 여부를 먼저 확인한다.
|
||||
|
||||
## Controller/Service 규칙
|
||||
- Controller 는 요청/응답 조립과 인증 주체 확인에 집중하고, DB 처리나 복잡한 계산은 Service 로 넘긴다.
|
||||
- Controller 응답은 `ResponseEntity<ApiResponse<T>>` 를 기본으로 사용한다. 파일 다운로드처럼 바이너리 응답이 필요한 경우만 예외로 둔다.
|
||||
- Controller 에서는 `@RequestBody`, `@PathVariable`, `@RequestHeader`, `@CookieValue` 를 명시적으로 선언해 요청 출처를 드러낸다.
|
||||
- Service 는 resultCode 를 DTO 에 담아 반환하는 패턴이 일부 있으므로, 기존 모듈 흐름에 맞춰 유지한다.
|
||||
- 인증 사용자 확인은 모듈 구현에 따라 `HttpSession` 또는 `SecurityContextHolder` 를 사용하므로 기존 방식을 먼저 맞춘다.
|
||||
|
||||
## 예외/응답 규칙
|
||||
- 공통 예외 응답은 `GlobalExceptionHandler` 에서 처리하므로 Controller 별 개별 예외 처리를 중복해서 늘리지 않는다.
|
||||
- `@Valid`, 바인딩 실패, JSON 파싱 실패, 타입 오류는 `CODE_4001` 로 통일하고 필드 오류가 있으면 `Map<String, String>` 형태로 반환한다.
|
||||
- 단순 성공/실패 문자열을 직접 내려주기보다 `ApiResponse.entity(...)` 와 `ApiResponseCode` 조합을 우선 사용한다.
|
||||
- 404/405/500 같은 공통 HTTP 오류도 가능하면 `ApiResponseCode` enum 으로 맞춘다.
|
||||
|
||||
## 설정 규칙
|
||||
- 공통 설정은 `application.yaml`, 환경별 차이는 `application-local.yaml`, `application-pjt.yaml` 에 둔다.
|
||||
- `pjt` 프로파일은 DB/Redis/JWT/Swagger 계정을 환경변수 치환으로 받으므로 새 민감정보는 하드코딩하지 않는다.
|
||||
- 로깅 설정은 프로파일별 `logback-local.xml`, `logback-pjt.xml` 을 사용하므로 로그 정책 변경 시 함께 본다.
|
||||
- MyBatis 설정은 `application.yaml` 기준으로 관리하므로 mapper location, alias package, camel case 옵션을 중복 정의하지 않는다.
|
||||
- Redis 는 세션 저장소와 업로드 상태 캐시 용도를 함께 가지므로 키 prefix 충돌 여부를 확인한다.
|
||||
|
||||
## 실행 및 검증
|
||||
- 로컬 실행: `./gradlew bootRun`
|
||||
- 테스트 실행: `./gradlew test`
|
||||
- jar 생성: `./gradlew bootJar`
|
||||
- Windows 명령: `.\gradlew.bat bootRun`, `.\gradlew.bat test`, `.\gradlew.bat bootJar`
|
||||
- 현재 테스트 코드는 최소 수준이므로 기능 수정 시 단위 테스트 또는 최소 통합 검증 범위를 직접 보강하는 쪽을 우선한다.
|
||||
|
||||
## 변경 시 체크리스트
|
||||
- 변경한 코드와 직접 관련된 파일만 수정했는지 확인한다.
|
||||
- 새 API, 스케줄러, 설정 추가 시 관련 설정 파일과 테스트를 함께 검토한다.
|
||||
- 로그 레벨과 로그량이 운영 환경에서 감당 가능한지 확인한다.
|
||||
- 기동 실패 가능성이 있는 설정 변경은 실행 또는 테스트로 검증한다.
|
||||
- Mapper 인터페이스 추가/변경 시 XML namespace, id, parameter/result 매핑이 같이 맞는지 확인한다.
|
||||
- 공개 경로나 권한 정책을 바꿨다면 SecurityConfig 와 Swagger 노출 범위를 같이 확인한다.
|
||||
- 파일 업로드/다운로드 기능 수정 시 DB 상태, Redis 상태, 실제 파일 시스템 경로가 같이 맞는지 확인한다.
|
||||
|
||||
## 에이전트 응답 원칙
|
||||
- 무엇을 바꿨는지보다 왜 그렇게 바꿨는지를 짧고 분명하게 설명한다.
|
||||
- 파일 수정 후 가능하면 테스트 또는 최소 실행 검증 결과를 함께 남긴다.
|
||||
- 검증하지 못한 내용은 추정으로 말하지 않고 미실행 사유를 적는다.
|
||||
- 요청 범위를 벗어나는 개선점은 강제로 반영하지 말고 제안으로 분리한다.
|
||||
|
||||
## 사용자 선호 규칙
|
||||
- 소스코드 변경이 필요한 요청에서는 바로 구현하지 말고 먼저 다음 중 무엇을 원하는지 확인한다.
|
||||
- `코드로 보여주기`: 사용자가 직접 프로젝트에 반영할 수 있도록 예시 코드나 패치를 제공한다.
|
||||
- `직접 작성하기`: 에이전트가 저장소에 직접 수정한다.
|
||||
- 사용자는 코드 흐름을 먼저 파악하고 코드 컨벤션에 맞게 직접 반영하는 방식을 선호하므로, 선택이 명시되지 않았다면 기본적으로 `코드로 보여주기`를 우선 제안한다.
|
||||
- 이 규칙은 이후 작업에서도 반복 확인 대상이며, 에이전트는 이를 임의로 생략하지 않는다.
|
||||
|
||||
## 현재 코드베이스에서 특히 주의할 점
|
||||
- `README.md`는 아직 템플릿 상태이므로 실제 동작 방식은 코드와 설정 파일을 기준으로 판단한다.
|
||||
- 예시 스케줄러 코드에는 깨진 문자열과 과도한 반복 로그가 보일 수 있으니 관련 수정 시 인코딩과 로그 정책을 함께 점검한다.
|
||||
- 테스트 코드가 충분하지 않을 수 있으므로 기능 변경 시 필요한 테스트를 보강한다.
|
||||
- 일부 Java 소스와 주석, Swagger 설명에 인코딩이 깨진 문자열이 있으므로 표시 문자열 수정은 영향 범위를 보고 묶어서 처리한다.
|
||||
- `application-local.yaml` 은 로컬 실행값이 직접 들어가 있으므로 공유하거나 커밋할 때 민감정보 노출 여부를 한 번 더 확인한다.
|
||||
|
||||
## 프로파일
|
||||
| 프로파일 | 설명 |
|
||||
|---------|------|
|
||||
| `local` | 로컬 개발 환경 |
|
||||
| `pjt` | 프로젝트(개발) 환경 |
|
||||
|
||||
## 보안 구조
|
||||
- Swagger: `/v3/api-docs/**`, `/swagger-ui/**` → HTTP Basic 인증 (InMemory)
|
||||
- API: JWT Bearer 토큰 인증 (Stateless)
|
||||
- 세션/쿠키: Redis Session 저장소 사용, 쿠키 속성은 프로파일별 `cookie.*` 설정으로 제어
|
||||
- 공개 경로: `/`, `/actuator/health`, `/sso/**`, `/auth/**`, `/user/signup`, `/files/tusHook`
|
||||
- Swagger 인증과 API 인증은 `SecurityFilterChain` 을 분리해서 관리한다.
|
||||
|
||||
## 응답 코드 규칙
|
||||
|
||||
`ApiResponseCode` enum으로 관리. 주요 코드:
|
||||
|
||||
| 코드 | 메시지 | HTTP Status | 용도 |
|
||||
|------|--------|-------------|------|
|
||||
| `CODE_200` | 성공 | 200 OK | 일반 성공 |
|
||||
| `CODE_400` | 잘못된 요청 | 400 Bad Request | 일반 클라이언트 오류 |
|
||||
| `CODE_401` | 인증 필요 합니다. | 401 Unauthorized | 인증 없음 |
|
||||
| `CODE_403` | 접근 권한 필요 합니다. | 403 Forbidden | 권한 없음 |
|
||||
| `CODE_404` | 페이지를 찾을 수 없습니다. | 404 Not Found | 리소스 없음 |
|
||||
| `CODE_405` | 잘못된 요청입니다. 요청 방식을 확인해 주세요. | 405 Method Not Allowed | 메서드 불일치 |
|
||||
| `CODE_500` | 요청을 처리하는 중 오류가 발생했습니다. | 500 Internal Server Error | 서버 오류 |
|
||||
| `CODE_2001` | {0} 정보 조회에 성공하였습니다. | 200 OK | 단건 조회 성공 |
|
||||
| `CODE_2002` | {0} 등록 되었습니다. | 201 Created | 등록 성공 |
|
||||
| `CODE_2003` | 조회된 정보가 없습니다. | 200 OK | 조회 결과 없음 |
|
||||
| `CODE_2004` | 중복된 {0} 정보 입니다. | 409 Conflict | 중복 데이터 |
|
||||
| `CODE_4001` | 입력값을 확인해주세요. | 400 Bad Request | `@Valid` / 바인딩 / 타입오류 / JSON 파싱 실패 |
|
||||
| `CODE_4003` | 필수 요청 파라미터가 누락되었습니다. | 400 Bad Request | 필수 파라미터 누락 |
|
||||
|
||||
- `{0}` 자리에 대상명 삽입 (예: `CODE_2001` → "회원 정보 조회에 성공하였습니다.")
|
||||
|
||||
## Swagger 접속
|
||||
- URL: `http://localhost:8106/swagger-ui.html`
|
||||
- 인증: `swagger.login.id` / `swagger.login.password` (환경별 yaml에 설정)
|
||||
|
||||
## 코드 작성 규칙
|
||||
- 응답은 `ApiResponse<T>` 래퍼 사용
|
||||
- 응답 코드는 `ApiResponseCode` enum 사용
|
||||
- MyBatis Mapper XML은 `src/main/resources/mapper/` 하위에 작성
|
||||
- 카멜케이스 자동 변환 활성화 (`map-underscore-to-camel-case: true`)
|
||||
- 새 모듈 추가 시: `modules/{moduleName}/` 하위에 Controller, Service, Mapper, dto/, vo/, 필요 시 form/ 구조로 생성
|
||||
- Mapper XML은 `resources/mapper/{moduleName}/` 에 위치
|
||||
- Form 에서 DTO 로 변환할 때는 검증과 trim, 기본값 치환까지 같이 처리하는 현재 패턴을 우선 따른다.
|
||||
- 파일/인증처럼 상태값을 내려주는 DTO 는 `resultCode` 필드를 활용하는 기존 흐름을 해치지 않도록 한다.
|
||||
|
||||
## MyBatis 규칙
|
||||
- Mapper XML 위치: `src/main/resources/mapper/**/*.xml`
|
||||
- `map-underscore-to-camel-case: true` 설정 → DB 컬럼 `user_idx` → Java 필드 `userIdx` 자동 매핑
|
||||
- Mapper 인터페이스와 XML의 namespace, id 반드시 일치시킬 것
|
||||
- VO: DB 조회 결과 매핑용 / DTO: 서비스 레이어 간 데이터 전달용 / Form: 컨트롤러 입력 검증용
|
||||
- `useGeneratedKeys`, `keyProperty` 를 사용하는 insert 가 있으므로 신규 PK 생성 테이블은 현재 패턴을 먼저 확인한다.
|
||||
- 상태 집계나 이력성 데이터는 단건 update 외에 이벤트 로그 insert 가 같이 필요한지 확인한다.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# alist API
|
||||
# alist API
|
||||
|
||||
Spring Boot 3 기반의 `alist` 백엔드 API 서버입니다.
|
||||
|
||||
@@ -7,8 +7,7 @@ Spring Boot 3 기반의 `alist` 백엔드 API 서버입니다.
|
||||
- 그룹: `com.alist`
|
||||
- Java: `21`
|
||||
- Spring Boot: `3.5.10`
|
||||
- 기본 포트: `8106`
|
||||
- 실행 진입점: `src/main/java/com/alist/api/ApiApplication.java`
|
||||
- 포트: `8106`
|
||||
- 빌드 결과물: `build/libs/api.jar`
|
||||
|
||||
## 기술 스택
|
||||
@@ -27,14 +26,12 @@ Spring Boot 3 기반의 `alist` 백엔드 API 서버입니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
- 사용자/관리자 JWT 발급 및 검증
|
||||
- Redis 기반 SSO 세션 관리와 쿠키 설정
|
||||
- JWT 발급 및 검증
|
||||
- Redis 기반 세션 관리와 쿠키 설정
|
||||
- Swagger UI Basic 인증 보호
|
||||
- DB 기반 허용 Origin 캐시를 사용하는 동적 CORS
|
||||
- TUS 기반 대용량 파일 업로드 초기화, 권한 검증, 상태 조회, hook 처리, 취소 처리
|
||||
- DB 기록 없는 단순 파일 업로드와 uploadPath 반환
|
||||
- SunEditor 이미지 업로드와 file-domain URL 반환
|
||||
- 파일 view/download API
|
||||
- TUS 업로드 초기화, 권한 검증, 상태 조회, 완료 처리, 취소 처리
|
||||
- 파일 조회/다운로드 API
|
||||
- 공통 응답 래퍼 `ApiResponse<T>` 및 `ApiResponseCode` 사용
|
||||
|
||||
## 디렉터리 구조
|
||||
@@ -42,6 +39,7 @@ Spring Boot 3 기반의 `alist` 백엔드 API 서버입니다.
|
||||
```text
|
||||
src/main/java/com/alist/api
|
||||
├── common
|
||||
│ ├── modules/file # 공통 파일 업로드/다운로드 모듈
|
||||
│ ├── response # ApiResponse, ApiResponseCode
|
||||
│ └── utils # 공통 유틸리티
|
||||
├── config
|
||||
@@ -49,16 +47,10 @@ src/main/java/com/alist/api
|
||||
│ ├── exception # 전역 예외 처리
|
||||
│ ├── filter # DynamicCorsFilter
|
||||
│ ├── jwt # JWT 인증 관련 구성
|
||||
│ ├── migration # 마이그레이션 DB 설정
|
||||
│ └── properties # 설정 프로퍼티
|
||||
└── modules
|
||||
├── admin # 관리자 인증/회원/스케줄/SSO 클라이언트 관리 API
|
||||
├── cors # DB 기반 허용 Origin 관리와 캐시 갱신 API
|
||||
├── file # 단순 업로드, SunEditor 업로드, path 기반 view/download
|
||||
├── front # 사용자 인증/SSO API와 사용자 관리 API
|
||||
├── main # 루트 응답
|
||||
├── migration # 레거시 사용자 조회
|
||||
├── tusFile # TUS 업로드 DB 기록, hook, 상태, 파일 목록/view/download/delete
|
||||
├── auth # 인증/세션 관련 API
|
||||
├── main # 루트 리다이렉트
|
||||
```
|
||||
|
||||
리소스 파일은 아래 위치를 사용합니다.
|
||||
@@ -66,7 +58,6 @@ src/main/java/com/alist/api
|
||||
- 설정: `src/main/resources/application*.yaml`
|
||||
- Mapper XML: `src/main/resources/mapper/**/*.xml`
|
||||
- 로그 설정: `src/main/resources/logback-*.xml`
|
||||
- 상세 문서: `docs/*.md`
|
||||
|
||||
## 실행 방법
|
||||
|
||||
@@ -88,12 +79,6 @@ src/main/java/com/alist/api
|
||||
java -jar build/libs/api.jar --spring.profiles.active=local
|
||||
```
|
||||
|
||||
Windows:
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat bootRun --args='--spring.profiles.active=local'
|
||||
```
|
||||
|
||||
## 프로파일
|
||||
|
||||
| 프로파일 | 설명 | 설정 파일 |
|
||||
@@ -105,6 +90,8 @@ Windows:
|
||||
|
||||
## 필수 설정 항목
|
||||
|
||||
실행 전 아래 설정들이 환경에 맞게 준비되어 있어야 합니다.
|
||||
|
||||
### 데이터 저장소
|
||||
|
||||
- `spring.datasource.*`
|
||||
@@ -123,22 +110,14 @@ Windows:
|
||||
- `swagger.login.id`
|
||||
- `swagger.login.password`
|
||||
|
||||
### TUS 업로드
|
||||
### 파일 업로드
|
||||
|
||||
- `tus-file.upload.tus-endpoint`
|
||||
- `tus-file.upload.public-base-url`
|
||||
- `tus-file.upload.tmp-root`
|
||||
- `tus-file.upload.final-root`
|
||||
- `tus-file.upload.interrupt-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.*`
|
||||
- `file.upload.tus-endpoint`
|
||||
- `file.upload.public-base-url`
|
||||
- `file.upload.tmp-root`
|
||||
- `file.upload.final-root`
|
||||
- `file.upload.interrupt-seconds`
|
||||
- `file.upload.auth-cache.ttl-seconds`
|
||||
|
||||
`pjt` 프로파일은 DB/Redis/JWT/Swagger 값을 환경변수로 주입받도록 작성되어 있습니다.
|
||||
|
||||
@@ -150,135 +129,224 @@ Windows:
|
||||
- 인증 방식: HTTP Basic
|
||||
- 계정 정보: `swagger.login.id`, `swagger.login.password`
|
||||
|
||||
### Admin API
|
||||
|
||||
- `/admin/**` 는 별도 SecurityFilterChain을 사용합니다.
|
||||
- `/admin/auth/**`, `/admin/user/add` 는 공개하고, 그 외 `/admin/**` 는 `ADMIN` 권한을 요구합니다.
|
||||
- `GET /admin/auth/loginChecked` 는 토큰 재발급 없이 현재 관리자 로그인 상태만 확인합니다.
|
||||
|
||||
### API
|
||||
|
||||
- 기본 인증 방식: JWT Bearer 또는 HttpOnly 쿠키 fallback
|
||||
- 기본 인증 방식: JWT Bearer
|
||||
- 세션 저장소: Redis
|
||||
- 쿠키 속성: `cookie.*` 설정으로 제어
|
||||
- 세션 쿠키: `RedisSessionConfig`에서 도메인, Secure, SameSite 제어
|
||||
|
||||
### 공개 경로
|
||||
|
||||
- `/`
|
||||
- `/actuator/health`
|
||||
- `/sso/**`
|
||||
- `/auth/**`
|
||||
- `/user/add`
|
||||
- `/user/migration/list`
|
||||
- `/tus/file/upload/auth`
|
||||
- `/tus/file/hook`
|
||||
- `/file/**`
|
||||
- `/files/tusHook`
|
||||
|
||||
TUS 업로드 토큰은 일반 access token이 아니므로 `/tus/file/upload/auth`, `/tus/file/hook` 은 file-domain nginx `auth_request` 설정과 Spring Security 공개 경로를 함께 맞춰야 합니다.
|
||||
루트 `/` 요청은 `/swagger-ui/index.html`로 리다이렉트됩니다.
|
||||
|
||||
## CORS
|
||||
|
||||
`DynamicCorsFilter`가 최우선 필터로 동작하며, 허용 Origin 목록은 `CorsCache`에서 조회합니다.
|
||||
`DynamicCorsFilter`가 최우선 필터로 동작하며, 허용 Origin 목록은 `CorsAllowedOriginsCache`에서 조회합니다.
|
||||
|
||||
- 허용된 Origin에만 `Access-Control-Allow-Origin` 설정
|
||||
- Credential 허용
|
||||
- `OPTIONS` preflight 요청은 `200 OK`로 즉시 응답
|
||||
- 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에 기록하지 않고 파일만 저장한 뒤 업무 테이블에 저장하기 좋은 값을 반환합니다.
|
||||
### 파일 조회 엔드포인트
|
||||
|
||||
- `POST /file/upload`
|
||||
- `GET /file/view?path=/uploads/...`
|
||||
- `GET /file/download?path=/uploads/...`
|
||||
- `GET /files/list/{fileMasterIdx}`
|
||||
- `GET /files/view/{fileUuid}`
|
||||
- `GET /files/download/{fileUuid}`
|
||||
|
||||
응답 데이터 예시:
|
||||
### 운영 메모
|
||||
|
||||
- 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
|
||||
{
|
||||
"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
|
||||
"id": "test"
|
||||
}
|
||||
```
|
||||
|
||||
### SunEditor 이미지 업로드
|
||||
|
||||
SunEditor 업로드는 API로 저장하되, 에디터 본문에는 token이 필요 없는 file-domain URL을 저장합니다.
|
||||
|
||||
- `POST /file/suneditor/upload`
|
||||
|
||||
응답 예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": [
|
||||
"data": {
|
||||
"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": [
|
||||
{
|
||||
"url": "https://file-alist.pjt.kr/uploads/editor/2026/05/08/abc.png",
|
||||
"name": "sample.png",
|
||||
"size": 12345
|
||||
"originName": "guide.pdf",
|
||||
"sizeBytes": 102400,
|
||||
"contentType": "application/pdf"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### TUS 업로드
|
||||
응답 예시:
|
||||
|
||||
대용량 업로드는 tusd와 연동하며 API는 DB 기록, 토큰 발급, 권한 검증, hook 반영을 담당합니다.
|
||||
|
||||
- `POST /tus/file/upload/init`
|
||||
- `GET /tus/file/upload/auth`
|
||||
- `POST /tus/file/upload/status`
|
||||
- `POST /tus/file/hook`
|
||||
- `POST /tus/file/upload/cancel`
|
||||
- `GET /tus/file/list/{fileMasterIdx}`
|
||||
- `GET /tus/file/view/{fileUuid}`
|
||||
- `GET /tus/file/download/{fileUuid}`
|
||||
- `POST /tus/file/delete`
|
||||
|
||||
운영 TUS 엔드포인트:
|
||||
|
||||
```text
|
||||
https://file-alist.pjt.kr/tus/file/
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"fileMasterIdx": 55,
|
||||
"tusEndpoint": "https://file-alist.pjt.kr/tus/files/",
|
||||
"itemList": [
|
||||
{
|
||||
"fileSeq": 1,
|
||||
"fileUuid": "2f5f3ef1-8a4e-4b2d-84da-1c1111111111",
|
||||
"uploadToken": "upload-token-sample",
|
||||
"originName": "guide.pdf",
|
||||
"sizeBytes": 102400,
|
||||
"contentType": "application/pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
"code": "CODE_200",
|
||||
"message": "성공"
|
||||
}
|
||||
```
|
||||
|
||||
## file-domain 운영 메모
|
||||
### 4. 업로드 상태 조회
|
||||
|
||||
- 업로드 최종 경로: `/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/file/` 는 tusd로 프록시하고 `auth_request /_upload_auth` 로 `/tus/file/upload/auth` 를 호출합니다.
|
||||
요청:
|
||||
|
||||
## 주요 인증 엔드포인트
|
||||
```http
|
||||
POST /files/uploadStatus
|
||||
Content-Type: application/json
|
||||
|
||||
- `POST /auth/token`
|
||||
- `POST /auth/access`
|
||||
- `POST /auth/refresh`
|
||||
- `POST /auth/apiKeyLogin`
|
||||
- `GET /sso/loginChecked`
|
||||
- `POST /sso/login`
|
||||
- `POST /sso/exchange`
|
||||
- `POST /sso/logout`
|
||||
- `POST /admin/auth/login`
|
||||
- `POST /admin/auth/refresh`
|
||||
- `GET /admin/auth/loginChecked`
|
||||
- `POST /admin/auth/logout`
|
||||
- `POST /user/add`
|
||||
- `POST /user/migration/list`
|
||||
{
|
||||
"fileUuid": "2f5f3ef1-8a4e-4b2d-84da-1c1111111111"
|
||||
}
|
||||
```
|
||||
|
||||
응답 예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"fileUuid": "2f5f3ef1-8a4e-4b2d-84da-1c1111111111",
|
||||
"status": "UPLOADING",
|
||||
"uploadedBytes": 51200,
|
||||
"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
|
||||
|
||||
@@ -294,14 +362,14 @@ https://file-alist.pjt.kr/tus/file/
|
||||
|
||||
## 응답 규칙
|
||||
|
||||
모든 API 응답은 `ApiResponse<T>` 래퍼를 우선 사용하며, 상태/메시지는 `ApiResponseCode` enum으로 관리합니다.
|
||||
모든 API 응답은 `ApiResponse<T>` 래퍼를 사용하며, 상태/메시지는 `ApiResponseCode` enum으로 관리합니다.
|
||||
|
||||
자주 사용하는 코드 예시는 아래와 같습니다.
|
||||
|
||||
| 코드 | HTTP Status | 의미 |
|
||||
| --- | --- | --- |
|
||||
| `CODE_200` | `200 OK` | 일반 성공 |
|
||||
| `CODE_204` | `204 No Content` | 일반 성공 |
|
||||
| `CODE_204` | `204 No Contnet` | 일반 성공 |
|
||||
| `CODE_2001` | `200 OK` | 단건 조회 성공 |
|
||||
| `CODE_2002` | `201 Created` | 등록 성공 |
|
||||
| `CODE_2003` | `200 OK` | 조회 결과 없음 |
|
||||
@@ -312,8 +380,6 @@ https://file-alist.pjt.kr/tus/file/
|
||||
| `CODE_403` | `403 Forbidden` | 권한 없음 |
|
||||
| `CODE_500` | `500 Internal Server Error` | 서버 오류 |
|
||||
|
||||
파일 binary view/download 응답은 `ResponseEntity<Resource>` 로 직접 내려줄 수 있습니다.
|
||||
|
||||
## 배포 관련
|
||||
|
||||
- Dockerfile: `Dockerfile`
|
||||
@@ -323,13 +389,10 @@ https://file-alist.pjt.kr/tus/file/
|
||||
- 파일 도메인: `https://file-alist.pjt.kr`
|
||||
- Swagger: `https://api-alist.pjt.kr/swagger-ui/index.html`
|
||||
|
||||
## 상세 문서
|
||||
## 참고
|
||||
|
||||
- [프로젝트 개요](docs/project-overview.md)
|
||||
- [보안 및 응답 규칙](docs/security-and-response.md)
|
||||
- [설정 및 실행 가이드](docs/runtime-config.md)
|
||||
- [검증 및 체크리스트](docs/verification-checklist.md)
|
||||
- [현재 코드베이스 메모](docs/codebase-notes.md)
|
||||
- Swagger 로컬 접속: [http://localhost:8106/swagger-ui.html](http://localhost:8106/swagger-ui.html)
|
||||
- 운영 환경에서는 `application-pjt.yaml`의 환경변수 주입 방식 사용을 권장합니다.
|
||||
|
||||
## 라이선스
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Codex 작업 규칙
|
||||
|
||||
## 작업 원칙
|
||||
- 기존 구조와 네이밍을 우선 존중한다.
|
||||
- 한 번에 큰 리팩터링을 하지 말고, 요청 범위 안에서 필요한 만큼만 수정한다.
|
||||
- 인코딩 문제가 보이는 문자열은 무심코 대량 수정하지 말고 원인과 영향 범위를 먼저 확인한다.
|
||||
- 요청 범위를 벗어나는 개선점은 강제로 반영하지 말고 제안으로 분리한다.
|
||||
|
||||
## 에이전트 응답 원칙
|
||||
- 무엇을 바꿨는지보다 왜 그렇게 바꿨는지를 짧고 분명하게 설명한다.
|
||||
- 파일 수정 후 가능하면 테스트 또는 최소 실행 검증 결과를 함께 남긴다.
|
||||
- 사용자가 IDE 에서 파일을 수정하거나 새 파일을 만든 뒤 질문할 수 있으므로, 관련 답변 전에는 저장된 최신 파일 상태를 다시 확인하는 것을 우선한다.
|
||||
- 이전에 읽은 세션 문맥만으로 최신 파일 상태를 단정하지 말고, 저장되지 않은 편집 내용은 확인할 수 없음을 전제로 설명한다.
|
||||
|
||||
## 사용자 선호 규칙
|
||||
- 소스코드 변경이 필요한 요청에서는 바로 구현하지 말고 먼저 다음 중 무엇을 원하는지 확인한다.
|
||||
- `코드로 보여주기`: 사용자가 직접 프로젝트에 반영할 수 있도록 예시 코드나 패치를 제공한다.
|
||||
- `직접 작성하기`: 에이전트가 저장소에 직접 수정한다.
|
||||
- 사용자는 코드 흐름을 먼저 파악하고 코드 컨벤션에 맞게 직접 반영하는 방식을 선호하므로, 선택이 명시되지 않았다면 기본적으로 `코드로 보여주기`를 우선 제안한다.
|
||||
- 이 규칙은 이후 작업에서도 반복 확인 대상이며, 에이전트는 이를 임의로 생략하지 않는다.
|
||||
@@ -1,82 +0,0 @@
|
||||
# 코드 컨벤션
|
||||
|
||||
## 파일 생성 규칙
|
||||
- 새 기능은 가능하면 `modules/{도메인명}Controller` 단위로 패키지를 만들고 그 아래에 `service`, `mapper`, `dto`, `vo`를 필요한 만큼만 추가한다.
|
||||
- 요청 검증이나 JSON 바인딩이 필요하면 `form` 패키지를 함께 만든다.
|
||||
- 클래스명은 역할이 바로 드러나게 `도메인명 + 역할` 형식을 유지한다. 예: `ApiService`, `ApiMapper`, `ApiVo`
|
||||
- Mapper 인터페이스를 추가하면 같은 이름의 XML을 `src/main/resources/mapper/{도메인경로}` 아래 함께 만든다.
|
||||
- 단순 예시 코드와 운영 코드는 섞지 말고 패키지로 분리한다.
|
||||
- 설정성 클래스는 `config` 하위 역할별 패키지에 둔다. 예: `config.jwt`, `config.exception`, `config.properties`
|
||||
|
||||
## DTO/VO/Form 규칙
|
||||
- `Dto`는 각 Controller 에서 Service, Mapper 로 전달하는 파라미터 성격의 값 객체로 정의한다.
|
||||
- `Dto`는 요청 처리에 필요한 저장, 수정, 로그 적재 같은 작업 파라미터를 담는 용도로 우선 사용한다.
|
||||
- `Vo`는 Mapper 에서 Service, Controller 로 반환하는 값 객체로 정의한다.
|
||||
- `Vo`는 Mapper 조회 결과를 담을 수 있고, Service 에서 비즈니스 로직 처리 후 필요한 데이터를 가공해서 Controller 로 반환하는 용도로 사용한다.
|
||||
- `Form`은 Controller 입력 검증과 요청 바인딩 전용으로 두고, `@Valid` 와 Jakarta Validation 어노테이션을 우선 사용한다.
|
||||
- Form/Dto/Vo 클래스명은 가능한 한 Controller 메서드명을 기준으로 맞춘다. 예: Controller 메서드가 `adminMemberList` 이면 `AdminMemberListForm`, `AdminMemberListDto`, `AdminMemberListVo` 를 사용한다.
|
||||
- 신규 API 에서는 path 동작명과 다른 표현을 지양한다. 예: 목록은 `Search` 보다 `List`, 상세는 `Detail` 보다 `View`, 수정은 `Update` 보다 `Modify` 를 우선 사용한다.
|
||||
- 현재 코드처럼 DTO/VO는 Lombok `@Getter`, 필요한 경우에만 `@Setter`를 사용한다.
|
||||
- Form 안에는 DTO 변환 메서드를 둘 수 있으며, 짝이 되는 Dto 로 변환하는 메서드는 `toDto()` 를 기본으로 사용한다. 예: `AdminMemberListForm#toDto()` 는 `AdminMemberListDto` 를 반환한다.
|
||||
- 하나의 Form 이 여러 Dto 로 변환되어야 하는 예외 상황에서만 `toAdminMemberListDto()` 처럼 대상 Dto 이름을 명시한다.
|
||||
- Form 에서 DTO 로 변환할 때는 검증과 `trim()`, 기본값 치환까지 같이 처리하는 현재 패턴을 우선 따른다.
|
||||
- DTO 안에 연관된 다른 DTO 변환이 꼭 필요할 때만 최소한의 보조 메서드를 둔다.
|
||||
- 외부 응답에 노출되면 안 되는 내부 필드는 응답에 사용될 수 있는 객체에서 `@JsonIgnore`로 숨긴다.
|
||||
- 등록, 수정, 삭제, 상태 변경처럼 처리 여부만 반환하는 VO 의 boolean 필드는 `processed` 로 통일한다. 예: `private boolean processed;`
|
||||
- Controller 요청/응답 타입은 가능한 한 Form/Dto/Vo 로 명시한다. `Map<String, Object>` 는 응답 구조가 고정되지 않는 임시 디버깅, 외부 라이브러리 passthrough 같은 예외 상황에서만 제한적으로 사용한다.
|
||||
- 응답 필드가 단순 boolean 몇 개뿐이어도 운영 API 에서는 VO 를 만든다. 예: 로그인 상태 확인 응답은 `Map<String, Object>` 보다 `AdminAuthLoginCheckedVo` 를 사용한다.
|
||||
- 목록 조회 API 는 특별한 사유가 없으면 기본적으로 페이징을 적용하고, 목록 조회 Form/Dto 는 `PageRequest`, 목록 응답 Vo 는 `PageResponse` 를 상속한다.
|
||||
|
||||
## Swagger/OpenAPI 문서화 규칙
|
||||
- 신규 또는 수정되는 Controller, Form, VO에는 Swagger 테스트 편의성을 위한 설명을 반드시 남긴다.
|
||||
- Controller 클래스에는 `@Tag`를 사용해 API 그룹명과 설명을 작성한다.
|
||||
- Controller 메서드에는 `@Operation`으로 `summary`와 `description`을 작성한다.
|
||||
- `@RequestBody` Form 클래스에는 클래스 레벨 `@Schema(description = "...")`를 작성한다.
|
||||
- `@ModelAttribute` 검색 Form 필드에도 `@Schema`를 작성해 Swagger query parameter 설명과 예시가 보이게 한다.
|
||||
- Form 필드에는 `@Schema`로 `description`, `example`을 작성한다.
|
||||
- 필수 입력값은 Jakarta Validation 어노테이션과 `@Schema(requiredMode = Schema.RequiredMode.REQUIRED)`를 함께 사용한다.
|
||||
- 코드값 필드는 허용 값을 `description`에 명시한다. 예: `userType: A 관리자, T 교사, S 학생`, `withdrawStatus: N 정상, P 탈퇴대기, Y 탈퇴완료`.
|
||||
- VO 클래스에는 클래스 레벨 `@Schema(description = "...")`를 작성한다.
|
||||
- VO 필드에는 `@Schema`로 응답 값의 의미를 작성한다.
|
||||
- 토큰, 임시 비밀번호처럼 Swagger 테스트에 필요한 응답 필드는 VO에 명시하되 `description`에 용도를 적는다.
|
||||
- 비밀번호, DB 조회용 내부 식별자, `resultCode`처럼 외부 응답에 노출하지 않는 필드는 `@JsonIgnore`를 사용하고 필요하면 `@Schema(hidden = true)`도 함께 사용한다.
|
||||
- 클라이언트가 직접 전달하지 않는 내부 계산 필드나 서버 전용 getter는 `@Schema(hidden = true)`로 숨긴다.
|
||||
- 페이징 요청에서 클라이언트는 `page`, `size`만 전달하고, `limit`, `offset`은 Swagger에 노출하지 않는다.
|
||||
- 페이징 응답의 `rowStartNum`처럼 계산 방식이 필요한 값은 프론트 사용 방법까지 `description`에 남긴다.
|
||||
|
||||
## Mapper/MyBatis 규칙
|
||||
- Service/Mapper 메서드명은 주된 DB 동작이 드러나도록 `select`, `insert`, `update` 접두어를 먼저 붙이고 Controller 메서드명을 이어서 만든다. 예: Controller 메서드가 `adminSsoClientList` 이면 Service/Mapper 조회 메서드는 `selectAdminSsoClientList`.
|
||||
- 삭제 API도 기본적으로 물리 삭제가 아니라 상태 변경으로 처리하므로 실제 SQL 동작에 맞춰 `update...Delete` 형태를 우선 사용한다. 예: Controller 메서드가 `adminSsoClientDelete` 이면 Service/Mapper 메서드는 `updateAdminSsoClientDelete`.
|
||||
- 물리 삭제가 정책상 명확히 필요한 예외 상황에서만 `delete` SQL과 `delete...` 메서드명을 사용한다.
|
||||
- SQL 을 여러 줄로 작성할 때 콤마는 다음 줄 앞에 둔다. 예: `SELECT COL1`, 다음 줄 `, COL2`.
|
||||
- Mapper XML `namespace`는 인터페이스의 전체 경로와 정확히 일치시킨다.
|
||||
- XML의 `id`는 Mapper 메서드명과 동일하게 맞춘다.
|
||||
- 조회 결과 타입은 `resultType`, 저장/수정 파라미터는 DTO 필드명과 매핑되는 프로퍼티명을 그대로 사용한다.
|
||||
- Mapper XML 안 SQL 블록 시작부 주석은 현재 코드처럼 `/*Mapper.method*/` 형식을 유지한다.
|
||||
- Mapper XML 위치는 `src/main/resources/mapper/**/*.xml` 이다.
|
||||
- `map-underscore-to-camel-case: true` 설정으로 DB 컬럼 `user_idx` 는 Java 필드 `userIdx` 로 자동 매핑된다.
|
||||
- `useGeneratedKeys`, `keyProperty` 를 사용하는 insert 가 있으므로 신규 PK 생성 테이블은 현재 패턴을 먼저 확인한다.
|
||||
- 상태 집계나 이력성 데이터는 단건 update 외에 이벤트 로그 insert 가 같이 필요한지 확인한다.
|
||||
|
||||
## Java/Spring 코드 스타일
|
||||
- Java 코드는 현재 프로젝트 스타일에 맞춰 탭/들여쓰기와 import 정렬을 유지한다.
|
||||
- 코드는 초급 개발자가 읽어도 흐름을 따라갈 수 있도록 직관적으로 작성한다. 과한 축약, 기교적인 표현, 불필요하게 복잡한 체이닝보다 명확한 변수명과 단계적인 흐름을 우선한다.
|
||||
- Java 코드에서 메서드 호출, 어노테이션 인자, 생성자 인자 등을 여러 줄로 작성할 때 콤마는 다음 줄 앞에 둔다. 예: `summary = "회원 목록 조회"` 다음 줄 `, description = "..."`
|
||||
- 기능 수정/추가 시 가능하면 Red-Green-Refactor 흐름을 따른다. 먼저 실패하는 테스트나 재현 가능한 검증 조건을 만들고(Red), 최소 구현으로 통과시키며(Green), 이후 컨벤션과 가독성에 맞게 정리한다(Refactor). 테스트 작성이 어려운 경우에는 최소 실행 검증 절차를 먼저 정하고 결과를 보고한다.
|
||||
- Lombok은 반복 보일러플레이트 제거에만 절제해서 사용한다.
|
||||
- 로그는 `Slf4j`를 사용하고 반복문 내부 대량 출력은 지양한다.
|
||||
- 새로운 기능은 가능하면 역할이 드러나는 패키지로 분리한다.
|
||||
- 생성자 주입을 기본으로 하고 필드 주입은 추가하지 않는다.
|
||||
- Service 에서 DB 상태를 바꾸는 메서드는 필요한 범위에서 `@Transactional`을 사용하고, 조회 전용은 `readOnly = true`를 우선 검토한다.
|
||||
- 문자열 입력값은 현재 코드처럼 필요한 지점에서 `trim()` 처리하고, null 가능성 여부를 먼저 확인한다.
|
||||
- 지역 변수명은 `result`, `list`, `vo`, `dto` 처럼 범용적인 이름보다 도메인과 객체 종류가 드러나게 작성한다. 예: `List<AdminMemberVo> adminMemberList`, `AdminMemberListVo adminMemberListVo`, `AdminMemberListDto adminMemberListDto`.
|
||||
- 같은 타입 또는 같은 의미의 변수가 여러 개 필요해도 객체 종류를 유지하고, 구분이 필요한 경우 숫자 suffix 를 제한적으로 사용한다. 예: `AdminMemberVo adminMemberVo1`, `AdminMemberVo adminMemberVo2`.
|
||||
|
||||
## Controller/Service 규칙
|
||||
- Controller 는 요청/응답 조립과 인증 주체 확인에 집중하고, DB 처리나 복잡한 계산은 Service 로 넘긴다.
|
||||
- Controller 응답은 `ResponseEntity<ApiResponse<T>>` 를 기본으로 사용한다. 파일 다운로드처럼 바이너리 응답이 필요한 경우만 예외로 둔다.
|
||||
- Controller 에서는 `@RequestBody`, `@PathVariable`, `@RequestHeader`, `@CookieValue` 를 명시적으로 선언해 요청 출처를 드러낸다.
|
||||
- 인증 사용자 확인은 모듈 구현에 따라 `HttpSession` 또는 `SecurityContextHolder` 를 사용하므로 기존 방식을 먼저 맞춘다.
|
||||
- 목록 조회는 Controller 에서 `@ModelAttribute` 검색 Form 을 받고, Service 에서 `totalCount` 와 목록을 조회한 뒤 `PageResponse#setPaging(...)` 으로 페이징 정보를 채운다.
|
||||
- 신규 API path 의 동작명은 CRUD 성격에 맞춰 통일한다. 목록은 `list`, 상세 조회는 `view`, 등록은 `add`, 수정은 `modify`, 삭제는 `delete` 를 사용한다.
|
||||
- Controller 메서드명은 가능한 한 path 조합을 기준으로 만든다. 클래스 레벨 path 와 메서드 레벨 path 를 이어 붙인 의미가 드러나게 작성한다. 예: 클래스 path 가 `/admin/member`, 메서드 path 가 `/list` 이면 `adminMemberList`, 클래스 path 가 `/admin/sso/client`, 메서드 path 가 `/view` 이면 `adminSsoClientView`.
|
||||
@@ -1,15 +0,0 @@
|
||||
# 현재 코드베이스 메모
|
||||
|
||||
## 현재 특히 주의할 점
|
||||
- `README.md`와 `docs/*.md`는 코드 변경 후 같이 갱신해야 한다. 의심되는 항목은 Controller, `SecurityConfig`, 설정 파일을 기준으로 다시 확인한다.
|
||||
- 예시 스케줄러 코드에는 깨진 문자열과 과도한 반복 로그가 보일 수 있으니 관련 수정 시 인코딩과 로그 정책을 함께 점검한다.
|
||||
- 테스트 코드가 충분하지 않을 수 있으므로 기능 변경 시 필요한 테스트를 보강한다.
|
||||
- 일부 Java 소스와 주석, Swagger 설명에 인코딩이 깨진 문자열이 있으므로 표시 문자열 수정은 영향 범위를 보고 묶어서 처리한다.
|
||||
- `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 인증 경로는 `/tus/file/upload/auth`, hook 경로는 `/tus/file/hook` 이다. nginx `/_upload_auth`, Spring Security 공개 경로, `tus-file.upload.tus-endpoint` 를 함께 맞춘다.
|
||||
- 단순 파일 API는 `/file/upload`, `/file/suneditor/upload`, `/file/view`, `/file/download` 를 사용한다. `/admin/files/...` 또는 `/files/...` 예전 표기가 남아 있으면 최신 경로로 정리한다.
|
||||
- file-domain nginx 는 `/uploads/` 를 공개 정적 파일로 열고 `/uploads/tmp/` 는 404로 막는다. TUS 임시 디렉터리는 파일 시스템에서는 사용하지만 URL로 직접 노출하지 않는다.
|
||||
- Admin 로그인 상태 확인은 `/admin/auth/loginChecked` 를 사용한다. `/admin/auth/refresh` 는 토큰을 재발급하므로 새로고침 상태 확인용으로 쓰지 않는다.
|
||||
@@ -1,112 +0,0 @@
|
||||
# 정책서: 캠퍼스/강좌/학습 흐름
|
||||
|
||||
## 캠퍼스 개요
|
||||
- A★캠퍼스는 A*List 교재를 활용한 상호 교수·학습 활동을 위한 온라인 공간이다.
|
||||
- 서비스 대상은 A*List 회원인 교사와 학생이며, 운영자는 캠퍼스 인터페이스와 관련 지원 기능을 제공한다.
|
||||
- 캠퍼스 개인화 요소는 캠퍼스 명칭, 직접 접속 도메인, 로고, GNB 색상이다.
|
||||
|
||||
## 캠퍼스 생성 정책
|
||||
- 캠퍼스 생성 주체는 교사 회원이다.
|
||||
- 단일 캠퍼스형은 생성 즉시 운영 가능하다.
|
||||
- 복합 캠퍼스형은 교사 생성 후 관리자 승인 절차를 거친 뒤 운영 가능하다.
|
||||
- 관리자(Back Office)도 캠퍼스를 직접 생성할 수 있다.
|
||||
- 관리자 생성 시 캠퍼스 마스터 역할의 교사는 회원 가입이 완료된 상태여야 하며, 필요하면 관리자 화면에서 회원 등록을 병행한다.
|
||||
- 관리자 생성 시 등록 정보는 캠퍼스 명칭, 직접 유입 개인화 도메인, 로고, 캠퍼스 사용 교재를 포함한다.
|
||||
|
||||
## 캠퍼스 유형
|
||||
- 단일 캠퍼스형: 1인 교사 공부방 또는 단일 학원형
|
||||
- 복합 캠퍼스형: 동일 브랜드 하위 가맹 캠퍼스를 갖는 학원형
|
||||
|
||||
## 캠퍼스 계층 구조
|
||||
- 캠퍼스
|
||||
- 클래스
|
||||
- 강좌(Lecture)
|
||||
|
||||
## 계층별 의미
|
||||
- 캠퍼스: 운영 단위, 교재/교사/학생/설정 관리의 상위 개념
|
||||
- 클래스: 교사 1인 기준의 운영 단위
|
||||
- 강좌: 교재 + 복수 학생 기준의 실제 교수·학습 수행 단위
|
||||
|
||||
## 강좌 정책
|
||||
- 강좌와 교재는 1:1 관계다
|
||||
- 교재 1종은 단일 ISBN 한 권이 아니라 학습 연관성을 가진 교재 묶음일 수 있다
|
||||
- 동일 학생이어도 교재가 바뀌면 새 강좌 생성
|
||||
- 동일 교재여도 학생 구성이 바뀌면 새 강좌 생성
|
||||
- 강좌 복사 기능을 제공하며 수업 중 강좌도 복사 가능하다
|
||||
- 동일 캠퍼스 소속 교사는 본인이 개설한 강좌 외에 타 교사의 강좌도 검색·복사할 수 있다
|
||||
- 동일 강좌 ID 안에서는 교재 교체, 교재 추가, 전체 학생 변경이 불가하며, 교재 또는 학생 구성이 크게 바뀌면 신규 강좌 ID를 생성하는 흐름으로 본다
|
||||
- 개별 학생/수강생 상태 변경과 운영 중 학생 추가 초대는 가능하다
|
||||
- Live 상태 강좌 수는 제한될 수 있고 종료 강좌를 포함한 총 강좌 수는 무제한이다
|
||||
|
||||
## 강좌 생명주기
|
||||
- 강의 준비중
|
||||
- 강의 중
|
||||
- 강의 종료
|
||||
- 학생 기준 상태로는 수강 대기중, 수강 중, 수강 종료가 별도로 존재한다
|
||||
|
||||
## 강좌 운영 규칙
|
||||
- 강좌 생성 시 교재 선택, 학생 초대, 수업 코스 설계, 과제/평가 생성이 함께 이뤄진다
|
||||
- 운영 중 학생 중도 초대가 가능하다
|
||||
- 운영 중 학생 상태를 이용 중지 또는 수강 종료로 변경할 수 있다
|
||||
- 학생이 운영 중간에 가입하면 이전 과제/평가도 미수행 상태로 제시한다
|
||||
- 상태 변경 시 학생과 학부모에게 알림이 발송된다
|
||||
- 수업 코스는 교재 Unit 기반으로 정렬하고, 수업 자료, 온라인 콘텐츠, 과제, 평가를 선별 연동한다
|
||||
- 수업 일정과 과제/평가 출제 일정은 타임라인 기반으로 관리한다
|
||||
|
||||
## 캠퍼스/강좌 상태 관리
|
||||
- 온라인 캠퍼스, 가맹 캠퍼스, 클래스, 강좌, 회원의 캠퍼스 가입 상태는 별도 상태 체계로 관리한다
|
||||
- 온라인 캠퍼스에는 승인 대기, 운영 중, 운영 일시 정지, 운영 종료 상태가 있다
|
||||
- 클래스/가맹 캠퍼스에는 운영 중, 운영 일시 정지, 운영 종료 상태가 있다
|
||||
- 강좌에는 강의 준비 중, 강의 중, 강의 일시 정지, 강의 종료 상태가 있다
|
||||
- 교사 가입 상태에는 정상, 권한 일시 중지, 종료(탈퇴)가 있다
|
||||
- 학생/수강생의 강좌 가입 상태에는 수강 대기 중, 수강 중, 권한 일시 중지, 수강 종료, 종료(탈퇴)가 있다
|
||||
- 상위 조직이 일시 정지되면 하위 조직 상태에도 반영된다
|
||||
|
||||
## 교사 업무 흐름
|
||||
- 캠퍼스 생성 또는 초대 가입
|
||||
- 캠퍼스 개인화 설정
|
||||
- 교재 선택 및 클래스/강좌 생성
|
||||
- 학생/수강생 초대
|
||||
- 타임라인 기반 수업 코스 설계
|
||||
- 과제/평가 출제
|
||||
- 제출 관리, 채점, 피드백
|
||||
- 학습 현황 및 보고서 생성
|
||||
|
||||
## 학생/수강생 학습 흐름
|
||||
- 초대 URL/초대 코드 기반 강좌 가입 또는 학생 회원 자기주도학습 시작
|
||||
- 예습 패키지 수행: 음원, 동영상, 단어 학습, 온라인 플래시 카드, 워드 리스트
|
||||
- 수업 참여
|
||||
- 온라인 학습 콘텐츠 수행
|
||||
- 과제 수행 및 제출
|
||||
- 평가 수행 및 제출
|
||||
- 복습 및 자기주도학습
|
||||
- 학습 결과/성취도 확인
|
||||
|
||||
## 과제/평가 정책
|
||||
- 과제/평가는 강좌 단위로 출제한다
|
||||
- 학생은 온라인 과제 뷰어 또는 온라인 학습 콘텐츠 뷰어에서 수행한다
|
||||
- 제출 기한과 수행률 관리가 필요하다
|
||||
- 교사는 채점 및 피드백을 입력한다
|
||||
- 알림장, 이메일, 앱 푸시, SMS가 보조 수단으로 사용된다
|
||||
|
||||
## SMS 정책
|
||||
- 캠퍼스 초대, 학습 알림, 보고서 발송 등에 SMS를 병행 사용할 수 있다
|
||||
- 초대 시 이메일 발송은 필수, 휴대폰 SMS 발송은 선택으로 본다
|
||||
- SMS 발송은 개발 후 서비스 오픈 시 프로세스 Hidden 처리될 수 있다
|
||||
|
||||
## 초대 코드 정책
|
||||
- 초대 코드는 캠퍼스에 초대되는 회원의 가입 일치 여부를 판단하고, 캠퍼스별 교재 단위 이용자 수를 관리하는 수단이다
|
||||
- 교사용 초대 코드와 학생용 초대 코드는 생성·사용 로직을 이원화한다
|
||||
- 학생 초대 코드는 교재별 수량 관리 대상이며, 강좌에 학생을 초대할 때 잔여 수량 내에서 생성한다
|
||||
- 학생 초대 코드 상태는 미사용, 발송, 발송 실패, 재발송, 강좌 가입, 수업 시작으로 관리한다
|
||||
- 초대 코드 수량은 전체 수량, 대기 수량, 사용 수량, 잔여 수량으로 표현한다
|
||||
- 대기 수량은 발송/재발송 상태의 합계이고, 사용 수량은 강좌 가입/수업 시작 상태의 합계다
|
||||
- 학생이 초대 코드를 분실하면 교사가 동일 초대 코드를 재발송할 수 있어야 한다
|
||||
- 강좌 생성 후 학생별 초대 코드 목록은 Excel 목록형 또는 PDF 인쇄형으로 다운로드할 수 있다
|
||||
- 해외 임대 서비스에서는 유통업체 또는 관리자 화면을 통해 캠퍼스별·교재별 초대 코드 수량을 생성/증감 관리한다
|
||||
|
||||
## 개발 해석 포인트
|
||||
- 캠퍼스, 클래스, 강좌는 이름이 비슷하지만 역할이 다르므로 테이블/DTO/응답 모델을 명확히 분리해야 한다
|
||||
- 강좌는 실제 학습 데이터와 과제/평가의 기준 단위이므로, API 설계 시 강좌 식별자를 중심으로 묶는 것이 자연스럽다
|
||||
- 초대, 가입, 탈퇴, 종료, 상태 변경은 회원 상태와 강좌 소속 상태를 구분해 처리해야 한다
|
||||
- 초대 코드는 단순 인증 토큰이 아니라 교재별 이용 수량과 연결된 운영 데이터이므로 상태 이력과 수량 집계를 함께 고려해야 한다
|
||||
@@ -1,134 +0,0 @@
|
||||
# 정책서: 콘텐츠/LMS
|
||||
|
||||
## 콘텐츠 범주
|
||||
- 교사 전용 자료: Lesson Plan, Syllabus, Scope & Sequence, 강의 계획서, Teacher Guide, 수업 PPT, 정답 및 해설, 워크시트
|
||||
- 교재 부가 자료: 본문 스크립트, 한글 해석/번역, 단어 리스트, 플래시 카드
|
||||
- 평가 자료: Word Test, Test Sheet, 단원 평가, 중간/최종 평가, 온라인 평가, 온라인 OMR 평가지
|
||||
- 멀티미디어: MP3, MP4
|
||||
- 온라인 학습 자료: 온라인 학습 콘텐츠, 온라인 플래시 카드, 온라인 클래스, 클래스 카드
|
||||
- 솔루션형 콘텐츠: 교사용 e-Book, 온라인 수업 PPT, 문항 출제 마법사, 레벨 테스트, 내신 수행 평가 자료실, Voca*List
|
||||
- 교사 보유 자료: PDF, MP3, MP4 업로드 자료
|
||||
|
||||
## 콘텐츠 생산과 활용
|
||||
- CMS에서 콘텐츠 분류 체계, 메타데이터, 자료 등록, 대량 등록, 미리 보기를 관리한다
|
||||
- 교사는 수업 뷰어에서 문서형 자료, URL, 온라인 콘텐츠, 과제, 평가를 통합적으로 사용한다
|
||||
- 학생은 온라인 학습 콘텐츠 뷰어와 과제/평가 뷰어에서 콘텐츠를 수행한다
|
||||
- 평가지는 별도 생성 솔루션(Test Generator)으로 출제, 편집, 출력한다
|
||||
- CMS는 교재 카테고리(SKILL), 시리즈, 교재, 목차(Unit), 문서 자료, 멀티미디어, 온라인 학습 DB, 평가 문항 DB, eBook, 온라인 OMR, 온라인 평가, 온라인 플래시 카드, 제휴 자료를 통합 관리한다
|
||||
- 콘텐츠는 원천 콘텐츠 생산, 2차 콘텐츠 저작, 사용자 활용 지원 서비스의 흐름으로 관리한다
|
||||
- A*List에서는 다운로드, 단독 웹 뷰어, 온라인 응시 기능을 중심으로 제공하고, A★캠퍼스에서는 수업 뷰어/과제 뷰어 연동, 강좌 단위 배포, LMS 저장을 중심으로 제공한다
|
||||
- 온라인 응시 기능은 A*List에서 교사 편의와 A★캠퍼스 체험을 위한 기능으로 제공될 수 있으며, 동일 URL로 복수 학생이 접속하되 DB 저장을 하지 않는 흐름이 있다
|
||||
- A★캠퍼스의 온라인 평가/온라인 OMR/레벨테스트는 강좌 또는 학생 단위로 배포·응시·채점·피드백·LMS 저장을 수행한다
|
||||
|
||||
## 콘텐츠 메타데이터
|
||||
- 콘텐츠는 교재, 시리즈, 레벨, 학년, 난이도, CEFR, Lexile, AR 등 학습 레벨 메타데이터를 가진다
|
||||
- 교재 레벨 메타는 백오피스/CMS에서 별도로 관리한다
|
||||
- 메타데이터는 검색, 교재 노출, 학습 추천, 보고서 분류에 영향을 줄 수 있다
|
||||
- 교재-콘텐츠 관리 위계는 교재 카테고리(SKILL), 시리즈, 레벨, 유닛(Unit), 레슨(Lesson), 콘텐츠 분류 카테고리, 콘텐츠로 구성한다
|
||||
- 부속 교재는 Workbook, Teacher's Book, 책 속의 책 등 물리적으로 분책된 교재를 관리하며, ISBN이 다른 경우 연관 교재 세트 개념으로 구성할 수 있다
|
||||
- 유닛에는 권장 수업 차시 수를 둘 수 있으며, 기본 커리큘럼과 수업 코스 설계에 활용한다
|
||||
- 개별 파일 기준 속성은 콘텐츠 타입, 파일 포맷, 교재 범위, 작성 언어, 제공 권한 범위를 포함한다
|
||||
- 문항/활동형 콘텐츠는 개별 활동 또는 개별 문항 단위로 SKILL, Sub SKILL, 난이도, 활동 유형, 문항 유형, 구조 메타를 적용한다
|
||||
|
||||
## 콘텐츠 접근 권한
|
||||
- 교사: 대부분의 수업 자료와 교사용 자료, 수업 뷰어 기능 접근 가능
|
||||
- 학생: 제한된 수업 자료와 멀티미디어, 온라인 학습 콘텐츠 접근 가능
|
||||
- 수강생: 강좌 기반 학습 활동 위주로 제한된 권한을 가진다
|
||||
- 학생용 I/F에서는 기본 권한 자료와 교사가 추가 허용한 자료를 함께 노출할 수 있다
|
||||
- 접근 권한 표기는 `●`, `▲`, `☆`, `X` 기준으로 해석한다
|
||||
- `●`: 해당 회원 유형이 기본 접근/사용 가능
|
||||
- `▲`: 수강생이 초대받은 강좌 교재 범위 안에서 접근/사용 가능
|
||||
- `☆`: 학생/수강생이 강좌 소속이고 교사가 수업 설계 중 허용 또는 전송한 경우 접근/사용 가능
|
||||
- `X`: 해당 인터페이스에서 직접 제공하지 않거나 구버전/별도 경로로 취급
|
||||
- 학생은 본문 스크립트, 한글 해석/번역, 단어 리스트, 멀티미디어, 온라인 학습 콘텐츠, 온라인 플래시 카드 등에 기본 접근할 수 있다
|
||||
- 수강생은 초대받은 강좌 교재 범위 안에서 본문 스크립트, 번역, 단어 리스트, 온라인 학습, 온라인 플래시 카드 등에 접근한다
|
||||
- 교사용 자료와 평가 자료 상당수는 교사가 공유하거나 강좌 과제/평가로 전송한 경우에만 학생/수강생에게 노출된다
|
||||
|
||||
## 주요 솔루션과 기능
|
||||
- 교수자용 수업 뷰어
|
||||
- E-Book, PDF, PPSX, Audio, Video, URL, 온라인 콘텐츠 연동
|
||||
- 확대/축소, 그리기, 북마크, 메모, 타이머, 주사위, 스포트라이트 등 보조 기능
|
||||
- 온라인 학습 콘텐츠 뷰어
|
||||
- 문제 풀이, 게임형 활동, Audio/Video 재생, Drawing, Recording, 타이머
|
||||
- 학습자용 온라인 과제 뷰어
|
||||
- 답안 입력, 자동 채점, 제출
|
||||
- 입력, 그림, 녹음, 이미지/음원/영상 업로드
|
||||
- OMR/온라인 평가 수행
|
||||
- 평가지 생성 솔루션
|
||||
- 문항 검색, 문항 수/유형 설정, 사용자 문항 추가, PDF 생성, 출력
|
||||
- 학습 분석 보고서
|
||||
- 레벨테스트, 교재별, Unit별 보고서 생성
|
||||
- 온라인 OMR 평가지
|
||||
- 문서 포맷 시험지를 OMR 타입 온라인 평가 콘텐츠로 2차 가공
|
||||
- 문항 타입, 정답, SKILL 메타를 입력하고 온라인 평가 뷰어로 응시
|
||||
- 온라인 수업 PPT
|
||||
- 수업 PPT를 HTML 포맷으로 변환하여 온라인 수업 PPT 뷰어와 통합 수업 뷰어에 연동
|
||||
- 교사용 eBook
|
||||
- PDF 기반 eBook 생성, 복수 책 구성, Audio/Video, 정답, 온라인 PPT, URL 연동
|
||||
- 레벨 테스트
|
||||
- 시험지 분류, 시험지 레벨, 문항 구성, 분석 가이드, 추천 교재, 시험 시간 설정, LMS 연동
|
||||
- 온라인 학습 콘텐츠
|
||||
- Unit 단위로 SKILL 영역을 구성하고, SKILL별 모듈을 선택·순서화한다
|
||||
- 모듈은 하나 이상의 Activity 또는 Test 문항으로 구성한다
|
||||
- 온라인 플래시 카드
|
||||
- 단어, 뜻, 예문, 음원, 이미지를 관리하고 수동/자동 단어 보기, 암기/미암기 관리, 내 단어장 등록을 제공한다
|
||||
|
||||
## 학습 관리 대상
|
||||
- 레벨테스트
|
||||
- 예습 패키지
|
||||
- 온라인 학습 콘텐츠
|
||||
- 온라인 평가
|
||||
- 온라인 과제
|
||||
- 클래스 과제
|
||||
- 셀프 스터디(더 공부하기)
|
||||
|
||||
## 학습 관리 영역
|
||||
- 정량 관리
|
||||
- 학습 횟수
|
||||
- 회차별 학습 시간
|
||||
- 전체/평균 학습 시간
|
||||
- 단위 학습 수행 여부
|
||||
- 성취 관리
|
||||
- 정오답
|
||||
- 학습 진도
|
||||
- 전체 성취도
|
||||
- SKILL별 성취도
|
||||
- Sub SKILL별 성취도
|
||||
- 단위 학습 성취도
|
||||
- 성취도 변화 추이
|
||||
- 산출물 관리
|
||||
- 오답 노트
|
||||
- 학습 산출물 데이터
|
||||
- 유사 문항 학습
|
||||
- 포트폴리오
|
||||
- 정성 관리
|
||||
- 교사 평가 텍스트
|
||||
- 평가 항목별 5점 척도 평가
|
||||
- SKILL별 정성 평가
|
||||
- 학습 태도 및 성장도 평가
|
||||
|
||||
## 학습 관리 데이터 구조
|
||||
- 정량적 학습 관리는 학습 뷰어와 LMS DB 데이터로 자동 축적한다
|
||||
- 정성적 학습 관리는 교사가 대시보드 통계나 과제 산출물을 보고 입력한 평가로 관리한다
|
||||
- 학습 수행 데이터는 단위 학습 일자, 시작 시간, 종료 시간, 수행 여부, 학습 항목, 학습 시간, 수행률, 빈도 등을 포함한다
|
||||
- 학습 성취 데이터는 문항 제출 답안, 정오 판정, 파일 산출물, 단위 성취도, SKILL별 성취도, 문항 유형별 성취도, 취약 SKILL 등을 포함한다
|
||||
- 학생 개인 기록 외에 강좌, 클래스, 캠퍼스 단위 평균과 순위 데이터를 생성할 수 있다
|
||||
- 레벨테스트 및 타 교재 학습 이력에 기반해 성취도 추이와 학습 포트폴리오를 구성한다
|
||||
- 학습 관리 구조는 교재, Unit, Lesson, 학습 모듈, Activity/Test 문항으로 이어진다
|
||||
- 단일형 콘텐츠는 온라인 평가/과제처럼 하나의 콘텐츠 수행으로 학습이 완료되는 단위다
|
||||
- 과정형 콘텐츠는 온라인 학습/온라인 클래스처럼 복수 모듈이 순차 학습 Flow를 이루는 콘텐츠다
|
||||
|
||||
## 시각화 및 보고
|
||||
- 대시보드 I/F와 학습 분석 보고서가 주요 표현 수단이다
|
||||
- 학생별/강좌별 현황, 성취도, 수행률, 레벨테스트 결과 시각화가 중요하다
|
||||
- 교사 화면에서는 강좌별 관리와 학생별 관리가 모두 필요하다
|
||||
- 레벨테스트 분석 보고서는 총점, SKILL/Sub SKILL별 성취율, 강점/취약 스킬, 추천 교재, 문항별 채점표를 포함한다
|
||||
- 단원/중간/기말 평가 보고서는 평가 성취도, 문항별 채점표, SKILL별 성취도, 학습 시간 그래프, 온라인 학습/과제 성취도 추이를 포함한다
|
||||
- 교재 학습 분석 보고서는 강의 종료 또는 교재 학습 완료 시 생성하며, 학습 수행 분석, 학습 성취도 분석, 교사 정성 평가를 포함한다
|
||||
|
||||
## 개발 해석 포인트
|
||||
- 콘텐츠 자체 메타데이터와 학습 수행 데이터는 분리 저장하는 편이 좋다
|
||||
- 학습 관리 영역은 단순 진도율이 아니라 성취도·산출물·포트폴리오까지 포함하므로 조회 API가 넓어질 가능성이 높다
|
||||
- 학생용 권한은 `회원 유형`, `강좌 소속 여부`, `교사 공유 여부`의 조합으로 판단해야 한다
|
||||
- 콘텐츠 접근 권한은 콘텐츠 타입별로 다르므로 공통 boolean 하나보다 콘텐츠-회원유형-강좌소속-공유여부 조합 정책으로 모델링하는 편이 안전하다
|
||||
- 학습 데이터는 개인, 강좌, 클래스, 캠퍼스 단위 집계가 필요하므로 원천 이벤트/결과 데이터와 집계 데이터를 분리하는 설계가 유리하다
|
||||
@@ -1,23 +0,0 @@
|
||||
# 정책서 요약 인덱스
|
||||
|
||||
원본 정책서: `D:/work/project/202601_alist/document/00_정책서/이퍼블릭_AList 교수학습지원 통합 플랫폼 구축_정책 설계_Ver 0.32.pdf`
|
||||
|
||||
## 문서 구성
|
||||
- [서비스 개요](overview.md)
|
||||
- [회원/권한/인증](membership-and-auth.md)
|
||||
- [캠퍼스/강좌/학습 흐름](campus-and-learning.md)
|
||||
- [콘텐츠/LMS](content-and-lms.md)
|
||||
- [인터페이스/도메인/운영](interfaces-and-operations.md)
|
||||
|
||||
## 빠른 요약
|
||||
- 플랫폼은 메인 서비스, 캠퍼스 메인, 교사용 캠퍼스, 학생용 캠퍼스, 백오피스/CMS로 분리된다.
|
||||
- 핵심 도메인은 회원, 캠퍼스, 클래스, 강좌, 콘텐츠, 과제/평가, 학습 관리다.
|
||||
- 정책서상 주요 제약은 회원 유형별 권한 차등, 캠퍼스 소속 제한, 강좌-교재 1:1 관계, 초대 코드 상태/수량 관리, 캠퍼스 개인화 도메인, 레거시 병행 운영이다.
|
||||
- `Ver 0.32`에서는 콘텐츠 생산/활용 구조, 콘텐츠 접근 권한, 학습 관리 데이터, 학습 분석 보고서, 인터페이스 메뉴 범위가 더 구체화되었다.
|
||||
- 실제 개발 판단에서는 이 문서 묶음을 기능 정의서보다 `도메인 정책서`로 취급하는 것이 적합하다.
|
||||
|
||||
## 개발 시 우선 참고 포인트
|
||||
- 회원 가입, 초대, 상태값, 로그인, 권한 분기는 `membership-and-auth.md`
|
||||
- 캠퍼스 생성, 계층 구조, 강좌 정책, 과제/평가 흐름은 `campus-and-learning.md`
|
||||
- 콘텐츠 유형, 접근 권한, 뷰어/솔루션, LMS 지표는 `content-and-lms.md`
|
||||
- 화면 경계, 도메인 구조, 메뉴 범위, 서버/이관 정책은 `interfaces-and-operations.md`
|
||||
@@ -1,108 +0,0 @@
|
||||
# 정책서: 인터페이스/도메인/운영
|
||||
|
||||
## 인터페이스별 역할
|
||||
- 메인 서비스(`alist.co.kr`)
|
||||
- 교재/자료, 스마트 콘텐츠, 지원 센터, 마이페이지, 교사 지원 서비스 중심
|
||||
- 캠퍼스 메인(`a-campus.co.kr`)
|
||||
- 서비스 소개, 이용 방법, 체험하기, 캠퍼스 진입
|
||||
- 캠퍼스 교사용(`class.a-campus.co.kr`)
|
||||
- 강좌/학생/학습/알림/수업준비/캠퍼스 관리 중심
|
||||
- 캠퍼스 학생용(`student.a-campus.co.kr`)
|
||||
- 나의 학습, 과제·평가, 자습하기, 복습하기, 학습 통계·리포트, 소통방 중심
|
||||
- 백오피스/CMS(`admin.alist.co.kr`)
|
||||
- 운영 관리, 교재/콘텐츠/CMS, 파트너, 홍보, 문의, 통계 관리
|
||||
|
||||
## 교사용 I/F 핵심 메뉴
|
||||
- 수업(강좌) 관리
|
||||
- 학생·학습 관리
|
||||
- 교재 수업 준비
|
||||
- 소통방: 알림장, 1:1 메시지
|
||||
- 마이페이지
|
||||
- 역할에 따라 가맹 캠퍼스 관리, 캠퍼스 관리, 공지 관리 등 추가 메뉴 노출
|
||||
- 캠퍼스 마스터 전용 메뉴와 가맹 캠퍼스장 전용 메뉴는 타 역할에서 Hidden 처리한다
|
||||
- 교사용 I/F는 강좌 생성, 강좌 복사, 학생 초대, 수업 요일 지정, 수업 하기, 과제 출제, 평가 출제, 레벨테스트 보내기 등을 CTA로 제공한다
|
||||
- 학생·학습 관리는 학습 기간, 누적/일자별 학습 시간, 예습 패키지 수행률, 온라인 학습 수행률/성취도, 과제/평가 수행률, 학습 분석 보고서 생성을 포함한다
|
||||
|
||||
## 학생용 I/F 핵심 메뉴
|
||||
- 나의 학습
|
||||
- 과제·평가
|
||||
- 자습하기
|
||||
- 더 공부하기
|
||||
- 복습하기
|
||||
- 학습 통계·리포트
|
||||
- 알림방
|
||||
- 마이페이지
|
||||
- 학생만 `학습 교재 추가`, `교재 추가` 같은 자기주도학습 확장 기능 사용 가능
|
||||
- 수강생은 학생 정회원 전용 자기주도학습 메뉴가 Hidden 처리된다
|
||||
- 학생용 I/F는 오늘 학습, 수업 캘린더, 내 학습 시간 기록, 온라인 학습 결과, 공지·알림, 1:1 메시지를 Dashboard로 제공한다
|
||||
- 과제·평가 메뉴는 수업(강좌) 목록, 과제 목록, 평가 목록, 레벨테스트 목록과 과제/평가/레벨테스트 수행 CTA를 포함한다
|
||||
- 학습 통계·리포트는 교재별 학습 기간, 총 학습 시간, 일자별 학습 시간, 온라인 학습/과제/평가 점수, SKILL별 평균, 교사 정성 평가, 동일 교재 학습자 비교를 포함한다
|
||||
|
||||
## 백오피스/CMS 핵심 메뉴
|
||||
- 관리자/권한 관리
|
||||
- 메뉴 관리
|
||||
- 파트너/유통업체 관리
|
||||
- A*List/A★캠퍼스 메인 관리
|
||||
- 팝업, 이벤트, FAQ, 세미나, 게시판, 1:1 문의
|
||||
- 교재 분류/레벨 메타 관리
|
||||
- 교재 관리, 캠퍼스 서비스 교재 관리
|
||||
- 콘텐츠 저작/관리, 온라인 콘텐츠 관리, 제휴사 콘텐츠 관리
|
||||
- 학습 관리, 통계 관리
|
||||
- 캠퍼스 초대 코드 관리와 교재별 초대 코드 수량 관리를 포함한다
|
||||
- 해외 임대 서비스 전용 메뉴는 국내 서비스에서 Hidden 처리될 수 있다
|
||||
|
||||
## 메인 서비스 메뉴 정책
|
||||
- 메인 서비스는 `교재/수업 자료`, `스마트 수업 지원`, `지원 센터`, `마이페이지`를 중심으로 구성한다
|
||||
- `교재/수업 자료`는 Coursebook, Phonics, Readers, Reading, Listening, Speaking, Writing, Grammar, Vocabulary, Adult, Oxford 등 SKILL/브랜드 기준 메뉴를 포함한다
|
||||
- `스마트 수업 지원`은 A★캠퍼스, Apps, A★List Player, Voca*List, 문항 출제 마법사, 온라인 클래스 등으로 구성한다
|
||||
- `지원 센터`는 세미나, Oxford 파트너 서비스, 교재 활용 동영상, 이벤트, 커뮤니티 게시판, FAQ, 공지사항, 1:1 문의 등을 포함한다
|
||||
- `마이페이지`는 나의 정보, 나의 교재/자료, 시험지 보관함, 나의 테스트 등을 포함한다
|
||||
- 시험지 보관함은 교사 회원에게만 제공한다
|
||||
- 2Depth 메뉴의 가감은 Back Office에서 운영 가능해야 한다
|
||||
|
||||
## 캠퍼스 메인 메뉴 정책
|
||||
- 캠퍼스 메인은 서비스 소개, 이용방법, 체험하기, 수업교재, 고객센터로 구성한다
|
||||
- 수업교재는 교재 소개와 제공 자료 목록을 보여주되 Viewing/Download는 제공하지 않고, 캠퍼스 마스터가 생성한 온라인 캠퍼스 교재로 등록할 수 있게 한다
|
||||
- 체험하기는 선생님 수업 뷰어, 과제 출제, 평가 출제, 학생 온라인 학습 체험을 제공한다
|
||||
- 서비스 문의하기는 비회원도 작성할 수 있으며, 비로그인 작성 시 임시 비밀번호, 이름, 이메일을 입력한다
|
||||
|
||||
## 도메인 정책
|
||||
- 메인 서비스와 캠퍼스 서비스 도메인을 분리한다
|
||||
- 캠퍼스 생성 시 개인화 도메인을 제공한다
|
||||
- 기본 URL 구조는 `/home/...`, 임대/개인화 URL 구조는 `/{campusId}/...` 이다
|
||||
- 학생용 기본 자기주도학습 화면은 `student.a-campus.co.kr/home` 이다
|
||||
- 개인화 도메인은 서버 부하 분산, 확장성, 운영 편의성을 고려한 정책이다
|
||||
|
||||
## 캠퍼스 개인화 도메인 개발 정책
|
||||
- 정책서에는 Next.js 자동 라우팅 + 미들웨어 + 템플릿 파일 자동 생성 방식이 예시로 제시된다
|
||||
- 핵심 요구사항은 다음과 같다
|
||||
- 관리자에서 캠퍼스 ID 등록 시 개인화 경로가 반영될 것
|
||||
- 캠퍼스 ID별 로고/헤더/GNB 스타일을 동적으로 적용할 것
|
||||
- 허용된 캠퍼스 ID만 접근 가능하도록 제어할 것
|
||||
- 기본 사용자와 임대/브랜드 사용자의 URL 흐름을 분리할 것
|
||||
|
||||
## 서버/소프트웨어 구성
|
||||
- Web / WAS / DB / File Storage / Legacy / 관리 서버를 분리한 구성을 기본으로 본다
|
||||
- 정책서 권장 스택
|
||||
- Web: Linux, Apache 2.4, Node.js / Next.js
|
||||
- WAS: Linux, Apache/Tomcat 9.x, JDK/OpenJDK, Spring Boot, Gradle, MyBatis, Swagger
|
||||
- DB: Linux, MariaDB 10.6 이상
|
||||
- Legacy 서버는 기존 회원 로그인 연계와 앱 유지 목적상 일정 기간 병행 운영한다
|
||||
|
||||
## 데이터 이관 정책
|
||||
- 교재 정보, 수업 자료, e-Book, 온라인 PPT, 레벨테스트, 문항, 내신 수행평가, 세미나, 교재 소개 등은 재구조화 후 이관 대상이다
|
||||
- 기존 회원 DB는 품질 이슈와 암호화 방식 차이로 직접 이관이 어렵다
|
||||
- 기존 앱 화면, 일부 ASP 기반 화면, 구 LMS 학습 이력은 신규 구조와 차이가 커서 병행 운영 또는 별도 연계가 필요하다
|
||||
- 온라인 PPT는 변환기(PPT to HTML)를 거쳐 탑재한다
|
||||
|
||||
## 서비스 오픈 정책
|
||||
- 개발
|
||||
- 알파 테스트
|
||||
- 베타 테스트
|
||||
- 서비스 오픈 및 운영
|
||||
- 일정 기간 기존 서비스 시스템 병행 운영
|
||||
|
||||
## 개발 해석 포인트
|
||||
- 정책서의 화면 메뉴는 단순 네비게이션이 아니라 권한/역할/도메인 경계 정의에 가깝다
|
||||
- 도메인과 URL 구조는 운영 정책과 강하게 연결돼 있으므로 하드코딩보다 설정/데이터 기반 관리가 적합하다
|
||||
- 레거시 병행 운영과 최초 로그인 연동은 회원 기능 수정 시 항상 영향 범위를 같이 봐야 한다
|
||||
@@ -1,86 +0,0 @@
|
||||
# 정책서: 회원/권한/인증
|
||||
|
||||
## 회원 유형
|
||||
- 관리자
|
||||
- 일반 관리자
|
||||
- 마스터 관리자
|
||||
- 교사
|
||||
- 학생
|
||||
- 수강생
|
||||
|
||||
## 회원 유형 정의
|
||||
- 교사: 정회원 사용자. 본인 인증과 이메일 인증을 거치며 메인 서비스와 교사용 캠퍼스 I/F 사용
|
||||
- 학생: 정회원 사용자. 본인 인증 또는 부모 동의 인증과 이메일 인증을 거치며 메인 서비스와 학생용 캠퍼스 I/F 사용
|
||||
- 수강생: 준회원 사용자. 캠퍼스 초대 기반 가입이며 본인 인증 없이 학생용 캠퍼스 I/F 중심으로 사용
|
||||
- 학부모: 독립 회원이 아니라 학생/수강생의 부가 정보 항목이다
|
||||
|
||||
## 인증 및 가입 정책
|
||||
- 교사 가입: 서비스 직접 방문, 휴대폰 본인 인증, 이메일 인증
|
||||
- 학생 가입: 서비스 직접 방문, 부모 동의 인증 포함 휴대폰 인증, 이메일 인증
|
||||
- 수강생 가입: 캠퍼스 초대 URL + 초대 코드 기반 가입, 학생용 I/F 접근 계정 생성
|
||||
- 관리자 계정: 개발 또는 마스터 관리자 등록 방식
|
||||
- 해외 임대 서비스는 휴대폰 인증 프로세스를 생략하고 이메일 인증을 필수 프로세스로 둘 수 있다
|
||||
|
||||
## 인터페이스 접근 권한
|
||||
- 관리자: 백오피스/CMS 접근, 메뉴별 권한 차등
|
||||
- 교사: 메인 서비스 + 캠퍼스 교사용 I/F 접근
|
||||
- 학생: 메인 서비스 + 학생용 I/F 접근, 일부 메뉴 제한
|
||||
- 수강생: 학생용 I/F 중심 접근, 메인 서비스 권한 없음 또는 제한
|
||||
- 학생/수강생은 같은 학생용 I/F를 사용하지만 권한 범위는 다르다
|
||||
|
||||
## 회원 유형별 서비스 활용
|
||||
- 교사: A*List 전체 자료 접근, 다운로드/Web Viewing, 교사용 eBook, 온라인 PPT, 문항 출제 마법사, 레벨테스트, 세미나 등 교사 지원 서비스 이용
|
||||
- 교사: A★캠퍼스에서 캠퍼스 생성 또는 초대 가입 후 캠퍼스 운영, 강좌 생성, 수업 코스 설계, 과제/평가 출제, 학생별 학습 관리 수행
|
||||
- 학생: A*List의 학생 접근 허용 자료 Viewing, 멀티미디어, 일부 온라인 콘텐츠 이용
|
||||
- 학생: `student.a-campus.co.kr/home`에서 교재 선택 기반 자기주도학습 가능
|
||||
- 학생: 강좌에 소속되면 예습 패키지, 온라인 학습, 과제/평가, 복습, 레벨테스트 응시 등 강좌 기반 학습 활동 가능
|
||||
- 수강생: 초대받은 강좌 기반 학습 활동만 이용 가능하며, 학생처럼 교재를 자유 선택하는 자기주도학습 권한은 제한된다
|
||||
|
||||
## 캠퍼스 소속 정책
|
||||
- 교사 회원은 온라인 캠퍼스 역할과 무관하게 Live 상태에서 단수의 캠퍼스에만 소속 가능
|
||||
- 수강생은 Live 상태에서 단수의 캠퍼스에만 소속 가능
|
||||
- 본인 인증을 마친 학생은 복수 캠퍼스 소속 가능
|
||||
- 교사 비회원은 캠퍼스 생성 또는 선생님 역할 소속이 불가하며, 초대 가입 시에도 A*List 회원 가입이 필요하다
|
||||
|
||||
## 캠퍼스 내 역할
|
||||
- 캠퍼스 마스터: 캠퍼스 생성자, 운영/관리 권한 보유
|
||||
- 가맹 캠퍼스장: 복합 캠퍼스 하위 지점 운영 역할
|
||||
- 선생님: 클래스 및 강좌 운영 주체
|
||||
- 학생: 학습 수행 주체
|
||||
|
||||
## 회원 상태값
|
||||
- 정상(Live)
|
||||
- 휴면
|
||||
- 병합
|
||||
- 탈퇴 요청
|
||||
- 탈퇴
|
||||
|
||||
## 상태 변경 정책
|
||||
- 1년 이상 접속 이력 없으면 휴면 전환
|
||||
- 휴면 후 2년간 해제 없으면 탈퇴 처리
|
||||
- 탈퇴 요청 후 30일 경과 시 데이터 삭제
|
||||
- 병합 상태는 ID 통합 후 미사용 계정 처리 상태
|
||||
|
||||
## 탈퇴/복구 정책
|
||||
- 교사는 하위 강좌가 모두 종료 상태일 때만 탈퇴 요청 가능
|
||||
- 학생/수강생은 수강 중 강좌가 있어도 탈퇴 가능하지만 안내가 필요하다
|
||||
- 탈퇴 시 강좌 가입 상태는 종료(탈퇴)로 바뀌고 교사 화면에서는 학습 이력/산출물이 숨김 처리된다
|
||||
- 휴면 해제와 탈퇴 번복은 로그인 후 이메일/휴대폰 인증으로 복구한다
|
||||
|
||||
## 회원 전환 및 ID 병합
|
||||
- 수강생은 모바일 본인 인증 또는 부모 인증 후 학생으로 전환 가능
|
||||
- 전환 시 클래스/강좌 정보와 학습 이력은 유지한다
|
||||
- 해외 임대 및 국내 제휴 등 분리 독립 시스템 이용자는 수강생에서 학생으로 전환할 수 없는 예외가 있을 수 있다
|
||||
- 학생/수강생은 ID 병합 기능 제공 대상이다
|
||||
- ID 병합 시 병합한 ID의 강좌 정보와 학습 이력을 승계하고, 병합 대상 ID의 상태값을 변경한다
|
||||
- ID 병합 대상 확인에는 비밀번호, 이름, 휴대폰 인증 또는 이메일 인증 정보가 사용된다
|
||||
- 교사는 ID 병합 대상이 아니다
|
||||
|
||||
## 레거시 회원 정책
|
||||
- 기존 A*List, eLearning Town 회원은 최초 로그인 시 레거시 DB 조회 후 신규 시스템 기준으로 보완 입력 및 저장한다
|
||||
- 레거시 비밀번호 체계상 DB 직접 마이그레이션이 어려워 최초 로그인 연동이 중요하다
|
||||
|
||||
## 개발 해석 포인트
|
||||
- `회원 유형`, `인증 여부`, `캠퍼스 역할`, `캠퍼스 소속 상태`는 분리된 개념으로 모델링하는 편이 안전하다
|
||||
- 학생과 수강생은 UI는 유사하지만 권한과 소속 정책이 다르므로 같은 enum 하나로 단순화하면 후속 정책 충돌 가능성이 높다
|
||||
- 탈퇴와 캠퍼스 소속 종료, 강좌 종료는 별도 상태 전이로 다뤄야 한다
|
||||
@@ -1,40 +0,0 @@
|
||||
# 정책서: 서비스 개요
|
||||
|
||||
## 서비스 목적
|
||||
- A*List는 교재 기반 교수·학습 지원 통합 플랫폼이다.
|
||||
- 핵심 제공 가치는 교사용 수업 자료 제공, 스마트 콘텐츠/솔루션 제공, 교사 지원 서비스, 온라인 캠퍼스 기반 교수·학습 활동 지원이다.
|
||||
- 운영 주체는 A*List 서비스 운영자이며, 실사용자는 교사와 학생이다.
|
||||
|
||||
## 서비스 구성 축
|
||||
- 메인 서비스: 교재/자료 소개, 스마트 콘텐츠, 교사 지원 서비스 제공
|
||||
- A★캠퍼스: 교사·학생이 실제 교수·학습 활동을 수행하는 온라인 캠퍼스
|
||||
- 백오피스/CMS: 플랫폼 운영, 콘텐츠 관리, 파트너/교재/회원/통계 관리
|
||||
- 해외 확장: 영어 홍보 사이트, 해외 임대(이식) 서비스, 제휴/유통업체 운영
|
||||
- 통합 솔루션: 교사용 통합 수업 뷰어, 학생용 통합 과제 뷰어, 온라인 학습 솔루션, 학습 분석 보고서 제공
|
||||
|
||||
## 주요 인터페이스
|
||||
- `www.alist.co.kr`: 메인 서비스
|
||||
- `www.a-campus.co.kr`: 캠퍼스 서비스 메인
|
||||
- `class.a-campus.co.kr/{campusId}`: 캠퍼스 교사용 I/F
|
||||
- `student.a-campus.co.kr/{campusId}`: 캠퍼스 학생용 I/F
|
||||
- `student.a-campus.co.kr/home`: 캠퍼스 미소속 학생의 자기주도학습 I/F
|
||||
- `admin.alist.co.kr`: 관리자(Back Office / CMS) I/F
|
||||
- `eng.alist.co.kr`: 영어 홍보 I/F
|
||||
|
||||
## 역할별 큰 흐름
|
||||
- 교사: 회원 가입 후 캠퍼스 생성 또는 캠퍼스 초대 가입, 교재 선택, 수업 설계, 학생 초대, 과제/평가 관리, 학습 관리 수행
|
||||
- 학생: 본인 인증 기반 정회원으로 가입 가능, 일부 A*List 콘텐츠 이용, 자기주도학습과 캠퍼스 강좌 활동 수행
|
||||
- 수강생: 캠퍼스 초대 기반 준회원으로 가입하며 강좌 기반 학습 활동 위주로 이용하고, 교재 자유 선택은 제한된다
|
||||
- 관리자/유통업체: 캠퍼스 생성 승인, 교재/콘텐츠 관리, 파트너 및 운영 정책 관리
|
||||
|
||||
## 서비스 환경 정책
|
||||
- 메인 서비스와 캠퍼스 메인은 PC Web 중심이며 태블릿/모바일은 보기 위주로 제한되는 화면이 있다.
|
||||
- 캠퍼스 교사용 I/F는 PC 중심, 캠퍼스 학생용 I/F는 Web + Hybrid App 배포를 전제한다.
|
||||
- 학생용 앱은 국내 기준 Google Play, Apple Store 배포를 고려한다.
|
||||
- 해외 임대 서비스는 국가별 서비스 환경에 따라 App 배포 여부를 별도 판단한다.
|
||||
|
||||
## 개발 해석 포인트
|
||||
- 이 플랫폼은 단순 콘텐츠 사이트가 아니라 `회원 + 캠퍼스 + 강좌 + 학습 데이터`를 중심으로 동작하는 LMS 성격이 강하다.
|
||||
- 교사용과 학생용 인터페이스가 명확히 분리되어 있으므로 메뉴, 권한, 응답 데이터도 같은 기준으로 나눠 설계해야 한다.
|
||||
- 해외 임대/제휴 확장을 전제로 하므로 도메인, 캠퍼스 개인화, 메뉴 노출, 권한 모델에 고정값을 박아 넣지 않는 편이 안전하다.
|
||||
- A*List와 A★캠퍼스는 같은 콘텐츠를 공유하더라도 다운로드, 온라인 뷰어, 강좌 단위 배포, LMS 저장 여부가 다를 수 있으므로 I/F별 활용 정책을 분리해야 한다.
|
||||
@@ -1,38 +0,0 @@
|
||||
# 프로젝트 개요
|
||||
|
||||
## 프로젝트 기본 정보
|
||||
- 프로젝트 유형: Gradle 기반 Spring Boot 애플리케이션
|
||||
- Java 버전: 21
|
||||
- Spring Boot 버전: 3.5.10
|
||||
- 기본 애플리케이션 이름: `api`
|
||||
- 기본 포트: `8106`
|
||||
- 실행 진입점: `src/main/java/com/alist/api/ApiApplication.java`
|
||||
|
||||
## 기술 스택
|
||||
- **Java**: 21
|
||||
- **Framework**: Spring Boot 3.5.10
|
||||
- **빌드 도구**: Gradle
|
||||
- **DB**: MariaDB
|
||||
- **ORM**: MyBatis (mapper XML: `classpath:mapper/**/*.xml`)
|
||||
- **인증**: JWT (jjwt 0.11.5) + Spring Security
|
||||
- **API 문서**: Swagger (springdoc-openapi 2.8.0)
|
||||
- **기타**: Lombok, Validation, Actuator, log4jdbc
|
||||
|
||||
## 디렉터리 가이드
|
||||
- `src/main/java/com/alist/api`: 애플리케이션 시작점과 업무 코드를 둔다.
|
||||
- `src/main/java/com/alist/api/modules`: 기능별 모듈 패키지를 둔다.
|
||||
- `src/main/resources`: 설정 파일과 로깅 설정을 관리한다.
|
||||
- `deploy`: 배포 관련 리소스가 있으면 이 경로를 우선 확인한다.
|
||||
|
||||
## 현재 확인된 구조
|
||||
- 현재 기준 메인 흐름은 `Controller -> Form -> Dto -> Service -> Mapper(XML) -> Vo -> Service -> Controller` 순서로 연결된다.
|
||||
- API 에서 request 받을 때 POST 는 주로 JSON을 사용한다. Controller 는 `form` 객체로 요청을 받은 뒤 DTO 로 변환해서 Service 에 전달한다.
|
||||
- MyBatis는 인터페이스와 XML을 함께 사용한다.
|
||||
- Mapper 인터페이스는 `src/main/java/.../mapper`, SQL XML은 `src/main/resources/mapper/...` 경로를 짝으로 맞춘다.
|
||||
- 공통 응답은 `common/response`, 보안은 `config/security, jwt`, 전역 예외 처리는 `config/exception` 아래에 둔다.
|
||||
- 모듈 패키지는 현재 `admin`, `cors`, `file`, `front`, `main`, `migration`, `tusFile` 형태로 구성되어 있고, 필요한 모듈만 `dto`, `form`, `mapper`, `service`, `vo`를 둔다.
|
||||
- `front` 하위에는 사용자 인증/SSO(`auth`)와 사용자 관리(`user`) 흐름을 둔다.
|
||||
- `admin` 하위에는 관리자 인증, 회원, 사용자, 스케줄, SSO 클라이언트 관리 흐름을 둔다.
|
||||
- `cors` 모듈은 DB 기반 허용 Origin 관리와 캐시 갱신 API를 담당한다.
|
||||
- `file` 모듈은 DB 기록 없는 단순 업로드, SunEditor 이미지 업로드, uploadPath 기반 view/download를 담당한다.
|
||||
- `tusFile` 모듈은 DB 기록이 필요한 TUS 기반 대용량 업로드 초기화, 업로드 토큰 검증, tusd hook, 상태 조회, 파일 목록/view/download/delete 흐름을 담당한다.
|
||||
@@ -1,43 +0,0 @@
|
||||
# 설정 및 실행 가이드
|
||||
|
||||
## 설정 규칙
|
||||
- 공통 설정은 `application.yaml`, 환경별 차이는 `application-local.yaml`, `application-pjt.yaml` 에 둔다.
|
||||
- `pjt` 프로파일은 DB/Redis/JWT/Swagger 계정을 환경변수 치환으로 받으므로 새 민감정보는 하드코딩하지 않는다.
|
||||
- 로깅 설정은 프로파일별 `logback-local.xml`, `logback-pjt.xml` 을 사용하므로 로그 정책 변경 시 함께 본다.
|
||||
- MyBatis 설정은 `application.yaml` 기준으로 관리하므로 mapper location, alias package, camel case 옵션을 중복 정의하지 않는다.
|
||||
- Redis 는 세션 저장소와 업로드 상태 캐시 용도를 함께 가지므로 키 prefix 충돌 여부를 확인한다.
|
||||
|
||||
## 프로파일
|
||||
| 프로파일 | 설명 |
|
||||
|---------|------|
|
||||
| `local` | 로컬 개발 환경 |
|
||||
| `pjt` | 프로젝트(개발) 환경 |
|
||||
|
||||
## Swagger 접속
|
||||
- URL: `http://localhost:8106/swagger-ui.html`
|
||||
- 인증: `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/file/` 는 tusd 로 프록시하며 `auth_request /_upload_auth` 로 API의 `/tus/file/upload/auth` 를 호출한다.
|
||||
- `/_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 test`
|
||||
- jar 생성: `./gradlew bootJar`
|
||||
- Windows 명령: `.\gradlew.bat bootRun`, `.\gradlew.bat test`, `.\gradlew.bat bootJar`
|
||||
@@ -1,39 +0,0 @@
|
||||
# 보안 및 응답 규칙
|
||||
|
||||
## 예외/응답 규칙
|
||||
- 공통 예외 응답은 `GlobalExceptionHandler` 에서 처리하므로 Controller 별 개별 예외 처리를 중복해서 늘리지 않는다.
|
||||
- `@Valid`, 바인딩 실패, JSON 파싱 실패, 타입 오류는 `CODE_4001` 로 통일하고 필드 오류가 있으면 `Map<String, String>` 형태로 반환한다.
|
||||
- 단순 성공/실패 문자열을 직접 내려주기보다 `ApiResponse.entity(...)` 와 `ApiResponseCode` 조합을 우선 사용한다.
|
||||
- 404/405/500 같은 공통 HTTP 오류도 가능하면 `ApiResponseCode` enum 으로 맞춘다.
|
||||
|
||||
## 보안 구조
|
||||
- Swagger: `/v3/api-docs/**`, `/swagger-ui/**` → HTTP Basic 인증 (InMemory)
|
||||
- API: JWT Bearer 토큰 인증 (Stateless)
|
||||
- 세션/쿠키: Redis Session 저장소 사용, 쿠키 속성은 프로파일별 `cookie.*` 설정으로 제어
|
||||
- Admin API: `/admin/**` 는 별도 `SecurityFilterChain` 으로 분리하며 `/admin/auth/**`, `/admin/user/add` 만 공개하고 나머지는 `ADMIN` 권한을 요구한다.
|
||||
- 공개 경로: `/`, `/actuator/health`, `/sso/**`, `/auth/**`, `/user/add`, `/user/migration/list`, `/tus/file/hook`, `/tus/file/upload/auth`, `/file/**`
|
||||
- TUS 업로드 토큰은 일반 access token 이 아니므로 `/tus/file/upload/auth`, `/tus/file/hook` 은 공개 경로와 file-domain nginx `auth_request` 설정을 함께 맞춘다.
|
||||
- `JwtAuthenticationFilter` 는 shared API(`/tus/file/**`, `/file/**`, `/cors/**`)에서 user access token 또는 admin access token 모두 인증 주체로 받을 수 있다.
|
||||
- `/admin/auth/loginChecked` 는 admin access token 쿠키의 현재 로그인 상태 확인용이다. 토큰을 재발급하지 않으며 `isAdminAccessToken`, `isAdminRefreshToken`, `loggedIn`, `userId`, `userIdx`, `userTokenIdx`, `userRole` 형태의 값을 반환한다.
|
||||
- Swagger 인증과 API 인증은 `SecurityFilterChain` 을 분리해서 관리한다.
|
||||
|
||||
## 응답 코드 규칙
|
||||
- 응답 코드는 `ApiResponseCode` enum 으로 관리한다.
|
||||
|
||||
| 코드 | 메시지 | HTTP Status | 용도 |
|
||||
|------|--------|-------------|------|
|
||||
| `CODE_200` | 성공 | 200 OK | 일반 성공 |
|
||||
| `CODE_400` | 잘못된 요청 | 400 Bad Request | 일반 클라이언트 오류 |
|
||||
| `CODE_401` | 인증 필요 합니다. | 401 Unauthorized | 인증 없음 |
|
||||
| `CODE_403` | 접근 권한 필요 합니다. | 403 Forbidden | 권한 없음 |
|
||||
| `CODE_404` | 페이지를 찾을 수 없습니다. | 404 Not Found | 리소스 없음 |
|
||||
| `CODE_405` | 잘못된 요청입니다. 요청 방식을 확인해 주세요. | 405 Method Not Allowed | 메서드 불일치 |
|
||||
| `CODE_500` | 요청을 처리하는 중 오류가 발생했습니다. | 500 Internal Server Error | 서버 오류 |
|
||||
| `CODE_2001` | {0} 정보 조회에 성공하였습니다. | 200 OK | 단건 조회 성공 |
|
||||
| `CODE_2002` | {0} 등록 되었습니다. | 201 Created | 등록 성공 |
|
||||
| `CODE_2003` | 조회된 정보가 없습니다. | 200 OK | 조회 결과 없음 |
|
||||
| `CODE_2004` | 중복된 {0} 정보 입니다. | 409 Conflict | 중복 데이터 |
|
||||
| `CODE_4001` | 입력값을 확인해주세요. | 400 Bad Request | `@Valid` / 바인딩 / 타입오류 / JSON 파싱 실패 |
|
||||
| `CODE_4003` | 필수 요청 파라미터가 누락되었습니다. | 400 Bad Request | 필수 파라미터 누락 |
|
||||
|
||||
- `{0}` 자리에 대상명 삽입 (예: `CODE_2001` → "회원 정보 조회에 성공하였습니다.")
|
||||
@@ -0,0 +1,220 @@
|
||||
# fix/sso-refresh-session-restore 설명 문서
|
||||
|
||||
## 1. 문서 목적
|
||||
|
||||
이 문서는 backend 프로젝트 `api`의 `fix/sso-refresh-session-restore` 브랜치에서 반영한 **SSO 세션 복원 및 토큰 응답 보강** 변경을 설명합니다.
|
||||
|
||||
핵심 목적은 다음 두 가지입니다.
|
||||
|
||||
- `ALIST_SSO` 쿠키가 없더라도, 유효한 `refreshToken`이 있으면 SSO 세션을 복원할 수 있게 한다.
|
||||
- frontend direct API 흐름에서 필요한 `accessToken` 값을 `/auth/access`, `/auth/refresh` 응답 body로도 내려준다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 배경 문제
|
||||
|
||||
기존 흐름에서는 SSO 관련 엔드포인트가 주로 `ALIST_SSO` 쿠키를 기준으로 로그인 상태를 판단했습니다.
|
||||
|
||||
이 구조에서는 다음 문제가 있었습니다.
|
||||
|
||||
1. 브라우저에 `refreshToken`은 남아 있지만 `ALIST_SSO` 쿠키가 사라진 경우
|
||||
- `/sso/loginChecked`
|
||||
- `/sso/authorize`
|
||||
- `/auth/access`
|
||||
에서 로그인 상태를 복원하지 못했습니다.
|
||||
|
||||
2. frontend가 direct API 흐름으로 변경되면서
|
||||
- `POST /auth/access`
|
||||
- `POST /auth/refresh`
|
||||
응답 body의 `accessToken`을 직접 사용하도록 바뀌었는데,
|
||||
backend 응답은 cookie만 세팅하고 body에는 토큰을 충분히 내려주지 않았습니다.
|
||||
|
||||
즉, **refreshToken 기반 세션 복원**과 **frontend 계약(body accessToken)** 이 동시에 필요했습니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 변경 대상 파일
|
||||
|
||||
이 브랜치의 핵심 변경 파일은 아래 7개입니다.
|
||||
|
||||
- `src/main/java/com/alist/api/modules/auth/AuthController.java`
|
||||
- `src/main/java/com/alist/api/modules/auth/SsoController.java`
|
||||
- `src/main/java/com/alist/api/modules/auth/dto/SsoAuthorizeDto.java`
|
||||
- `src/main/java/com/alist/api/modules/auth/service/AuthService.java`
|
||||
- `src/main/java/com/alist/api/modules/auth/service/SsoService.java`
|
||||
- `src/main/java/com/alist/api/modules/auth/vo/LoginTokenVo.java`
|
||||
- `src/main/resources/mapper/auth/LoginMapper.xml`
|
||||
|
||||
---
|
||||
|
||||
## 4. 핵심 변경 요약
|
||||
|
||||
### 4-1. `refreshToken` 기반 SSO 세션 복원 추가
|
||||
|
||||
`SsoService`에 아래 역할이 추가되었습니다.
|
||||
|
||||
- `loginChecked(String ssoSessionId, String refreshToken)`
|
||||
- `resolveOrRestoreSsoSessionId(String ssoSessionId, String refreshToken)`
|
||||
- `restoreSsoSessionIfNeeded(String ssoSessionId, String refreshToken)`
|
||||
- `loadSsoSession(String ssoSessionId)`
|
||||
|
||||
동작 방식은 아래와 같습니다.
|
||||
|
||||
1. 먼저 기존 `ALIST_SSO` 쿠키 값(`ssoSessionId`)이 있으면 그대로 사용합니다.
|
||||
2. `ssoSessionId`가 없으면 `refreshToken`으로 사용자 토큰 정보를 조회합니다.
|
||||
3. 유효한 사용자가 확인되면
|
||||
- 기존 최신 SSO 세션이 있으면 TTL을 연장해서 재사용하고
|
||||
- 없으면 Redis에 새로운 SSO 세션을 다시 생성합니다.
|
||||
4. 이후 `loginChecked`, `authorize`, `access`는 복원된 SSO 세션 기준으로 정상 동작합니다.
|
||||
|
||||
즉, **SSO 쿠키가 비어 있어도 refreshToken만 유효하면 공통 로그인 상태를 다시 살릴 수 있게 변경**되었습니다.
|
||||
|
||||
---
|
||||
|
||||
### 4-2. `/auth/access` 응답 강화
|
||||
|
||||
`AuthController.access()` 변경 내용:
|
||||
|
||||
- `@CookieValue(name = "refreshToken", required = false)`를 추가하여 refreshToken을 입력으로 받습니다.
|
||||
- `ssoService.loginChecked(ssoSessionId, refreshToken)`을 사용해 SSO 세션을 복원 가능하게 했습니다.
|
||||
- 세션 복원 결과에 `ssoSessionId`가 있으면 `ALIST_SSO` 쿠키를 다시 발급합니다.
|
||||
- 응답 body에 아래 값을 포함합니다.
|
||||
- `isAccessToken`
|
||||
- `userIdx`
|
||||
- `userId`
|
||||
- `userRole`
|
||||
- `accessToken`
|
||||
|
||||
즉, `/auth/access`는 이제 **토큰 쿠키 발급 + frontend용 JSON body accessToken 제공**을 같이 수행합니다.
|
||||
|
||||
---
|
||||
|
||||
### 4-3. `/auth/refresh` 응답 강화
|
||||
|
||||
`AuthController.refresh()` 변경 내용:
|
||||
|
||||
- 기존처럼 `accessToken`, `refreshToken` 쿠키는 재발급합니다.
|
||||
- 추가로 응답 body에 아래 값을 내려줍니다.
|
||||
- `refreshed: true`
|
||||
- `accessToken`
|
||||
|
||||
이 변경으로 frontend는 refresh 성공 후 cookie에만 의존하지 않고, **응답 body의 accessToken으로 즉시 service-local 토큰을 다시 기록**할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
### 4-4. `/sso/loginChecked` 개선
|
||||
|
||||
`SsoController.loginChecked()` 변경 내용:
|
||||
|
||||
- `refreshToken` cookie를 함께 입력으로 받습니다.
|
||||
- `ssoService.loginChecked(ssoSessionId, refreshToken)`을 사용합니다.
|
||||
- access/refresh token 존재 여부를 기존처럼 함께 반환합니다.
|
||||
- SSO 세션이 복원되면 `ALIST_SSO` 쿠키를 다시 써줍니다.
|
||||
|
||||
즉, `/sso/loginChecked`는 더 이상 단순 조회가 아니라 **필요 시 세션 복원까지 수행하는 확인 엔드포인트**가 되었습니다.
|
||||
|
||||
---
|
||||
|
||||
### 4-5. `/sso/authorize` 개선
|
||||
|
||||
`SsoController.authorize()`와 `SsoService.authorize()` 변경 내용:
|
||||
|
||||
- `refreshToken` cookie를 함께 전달받아 세션 복원에 사용합니다.
|
||||
- authorize 성공 시 `SsoAuthorizeDto`에 `ssoSessionId`를 실어주고,
|
||||
- controller에서 해당 값을 기준으로 `ALIST_SSO` 쿠키를 재발급합니다.
|
||||
|
||||
즉, 사용자가 이미 refreshToken을 가지고 있다면 **SSO 쿠키가 비어 있어도 authorize 진입 시 다시 공통 로그인 상태를 회복**할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
### 4-6. refreshToken 조회용 Auth 계층 보강
|
||||
|
||||
`AuthService`와 DB 조회 쪽에는 아래 보강이 들어갔습니다.
|
||||
|
||||
- `findUserTokenByRefreshToken(String refreshToken)` 추가
|
||||
- `LoginTokenVo`에 `userIdx`, `userId` 필드 추가
|
||||
- `LoginMapper.xml`의 `selectUserTokenByUserTokenIdx`가 `user_idx`, `user_id`, `user_role`, `refresh_token`을 함께 조회
|
||||
|
||||
이 변경은 SSO 세션 복원 시, 단순 token 유효성 확인을 넘어서 **어떤 사용자의 세션을 복구해야 하는지 식별하기 위해 필요**합니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 변경 후 기대 동작
|
||||
|
||||
### 시나리오 A. `ALIST_SSO` 없음 + `refreshToken` 유효
|
||||
|
||||
- `POST /auth/access`
|
||||
- SSO 세션 복원 성공
|
||||
- `ALIST_SSO` 재발급
|
||||
- `accessToken` / `refreshToken` 재발급
|
||||
- body에 `accessToken` 포함
|
||||
|
||||
### 시나리오 B. `GET /sso/loginChecked`
|
||||
|
||||
- 기존에는 로그인 false로 끝날 수 있었던 상황에서
|
||||
- 이제 refreshToken이 유효하면 loggedIn true로 복원 가능
|
||||
|
||||
### 시나리오 C. `GET /sso/authorize`
|
||||
|
||||
- 기존에는 `ALIST_SSO` 없으면 `/login`으로 fallback
|
||||
- 이제 refreshToken으로 세션 복원 가능하면 정상 authorize 진행
|
||||
|
||||
### 시나리오 D. `POST /auth/refresh`
|
||||
|
||||
- cookie 재발급뿐 아니라 body에도 `accessToken` 제공
|
||||
- frontend가 바로 service-local accessToken을 다시 쓸 수 있음
|
||||
|
||||
---
|
||||
|
||||
## 6. frontend와의 계약 변화
|
||||
|
||||
이 브랜치는 frontend의 direct API 흐름과 맞물려 있습니다.
|
||||
|
||||
frontend는 현재 아래 계약을 기대합니다.
|
||||
|
||||
- `POST /auth/access` 응답 body에 `accessToken`
|
||||
- `POST /auth/refresh` 응답 body에 `accessToken`
|
||||
|
||||
따라서 이 브랜치 변경은 단순 backend 내부 개선이 아니라,
|
||||
**frontend의 direct login / callback / refresh 흐름을 안정적으로 지원하는 계약 변경**이기도 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 운영상 의미
|
||||
|
||||
이 변경을 적용하면 다음이 좋아집니다.
|
||||
|
||||
- SSO 쿠키 유실 상황에서 세션 복원력이 높아짐
|
||||
- cross-domain authorize 진입 시 로그인 유지가 더 안정적임
|
||||
- frontend가 refresh 후 토큰 재반영을 더 단순하게 처리할 수 있음
|
||||
- `ALIST_SSO`와 `refreshToken`을 각각의 역할에 맞게 유지하면서도 사용자 체감 로그인 끊김을 줄일 수 있음
|
||||
|
||||
---
|
||||
|
||||
## 8. 검증 포인트
|
||||
|
||||
배포 또는 MR 검토 시 아래를 확인하는 것이 좋습니다.
|
||||
|
||||
1. `POST /auth/access`
|
||||
- `ALIST_SSO` 없이 `refreshToken`만 있을 때 200 또는 정상 복원되는지
|
||||
- response body에 `accessToken`이 포함되는지
|
||||
|
||||
2. `POST /auth/refresh`
|
||||
- response body에 `accessToken`이 포함되는지
|
||||
|
||||
3. `GET /sso/loginChecked`
|
||||
- `refreshToken`만으로 `loggedIn=true`가 가능한지
|
||||
|
||||
4. `GET /sso/authorize`
|
||||
- `ALIST_SSO`가 없더라도 refreshToken 기반으로 redirect가 이어지는지
|
||||
|
||||
5. Redis 상태
|
||||
- `alist:sso:userIdx:*`
|
||||
- `alist:sso:session:*`
|
||||
키가 복원/갱신되는지
|
||||
|
||||
---
|
||||
|
||||
## 9. 한 줄 요약
|
||||
|
||||
`fix/sso-refresh-session-restore` 브랜치는 **refreshToken만 남아 있는 상황에서도 SSO 세션을 복원하고, `/auth/access` 및 `/auth/refresh`가 frontend가 바로 사용할 수 있는 `accessToken`을 응답 body로 반환하도록 보강한 변경**입니다.
|
||||
@@ -1,26 +0,0 @@
|
||||
# 검증 및 체크리스트
|
||||
|
||||
## 기본 검증 원칙
|
||||
- 현재 테스트 코드는 최소 수준이므로 기능 수정 시 단위 테스트 또는 최소 통합 검증 범위를 직접 보강하는 쪽을 우선한다.
|
||||
- 기동 실패 가능성이 있는 설정 변경은 실행 또는 테스트로 검증한다.
|
||||
- 검증하지 못한 내용은 추정으로 말하지 않고 미실행 사유를 적는다.
|
||||
|
||||
## 변경 시 체크리스트
|
||||
- 변경한 코드와 직접 관련된 파일만 수정했는지 확인한다.
|
||||
- 새 API, 스케줄러, 설정 추가 시 관련 설정 파일과 테스트를 함께 검토한다.
|
||||
- 로그 레벨과 로그량이 운영 환경에서 감당 가능한지 확인한다.
|
||||
- Mapper 인터페이스 추가/변경 시 XML namespace, id, parameter/result 매핑이 같이 맞는지 확인한다.
|
||||
- 공개 경로나 권한 정책을 바꿨다면 SecurityConfig 와 Swagger 노출 범위를 같이 확인한다.
|
||||
- 파일 업로드/다운로드 기능 수정 시 DB 상태, Redis 상태, 실제 파일 시스템 경로가 같이 맞는지 확인한다.
|
||||
- TUS 경로를 바꾸면 nginx `/_upload_auth`, tusd hook URL, `SecurityConfig` 공개 경로, `tus-file.upload.tus-endpoint` 를 함께 확인한다.
|
||||
- `/file/**`, `/tus/file/**`, `/cors/**` 같은 shared API 권한을 바꾸면 user/admin 토큰 쿠키와 Bearer 인증이 모두 의도대로 동작하는지 확인한다.
|
||||
- SunEditor 또는 단순 업로드 설정을 바꾸면 `file.upload.root-path`, `file.upload.view.file-domain`, nginx `/uploads/` alias 경로가 같은 저장 루트를 가리키는지 확인한다.
|
||||
|
||||
## 기능 특성별 점검 포인트
|
||||
- 스케줄러 코드는 실행 주기, 중복 실행 가능성, 로그량을 반드시 점검한다.
|
||||
- 인증 방식이 섞여 있으므로 세션 기반 처리와 JWT `SecurityContext` 사용 위치를 먼저 구분하고 수정한다.
|
||||
- 파일 경로를 다루는 기능은 상대경로 탈출, 루트 이탈 방지 같은 검증을 같이 본다.
|
||||
- 설정 파일 수정 시 `local`, `pjt`, 공통 설정 간 차이를 함께 확인한다.
|
||||
- file-domain 정적 파일은 `/uploads/editor/...` 같은 최종 파일 URL이 브라우저에서 직접 열리는지 확인한다.
|
||||
- `/uploads/tmp/...` 는 404로 막히는지 확인한다.
|
||||
- SunEditor 업로드는 응답 JSON의 `result[].url` 이 file-domain 절대 URL인지 확인하고, 에디터 본문에 이미지가 실제 삽입되는지 확인한다.
|
||||
@@ -1,32 +0,0 @@
|
||||
package com.alist.api.common.paging;
|
||||
|
||||
import com.alist.api.common.utils.PagingUtil;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Setter;
|
||||
|
||||
@Setter
|
||||
public class PageRequest {
|
||||
@Schema(description = "페이지 번호", example = "1")
|
||||
private Integer page;
|
||||
|
||||
@Schema(description = "페이지 크기", example = "20")
|
||||
private Integer size;
|
||||
|
||||
public Integer getPage() {
|
||||
return PagingUtil.getPage(page);
|
||||
}
|
||||
|
||||
public Integer getSize() {
|
||||
return PagingUtil.getSize(size);
|
||||
}
|
||||
|
||||
@Schema(hidden = true)
|
||||
public Integer getLimit() {
|
||||
return PagingUtil.getLimit(size);
|
||||
}
|
||||
|
||||
@Schema(hidden = true)
|
||||
public Integer getOffset() {
|
||||
return PagingUtil.getOffset(page, size);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package com.alist.api.common.paging;
|
||||
|
||||
import com.alist.api.common.utils.PagingUtil;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class PageResponse {
|
||||
@Schema(description = "현재 페이지 번호", example = "1")
|
||||
private int page;
|
||||
|
||||
@Schema(description = "페이지당 조회 개수", example = "20")
|
||||
private int size;
|
||||
|
||||
@Schema(description = "전체 데이터 개수", example = "100")
|
||||
private int totalCount;
|
||||
|
||||
@Schema(description = "전체 페이지 수", example = "5")
|
||||
private int totalPage;
|
||||
|
||||
@Schema(
|
||||
description = "현재 페이지 첫 번째 row 번호. 각 row 번호는 rowStartNum - memberList index로 계산합니다.",
|
||||
example = "100"
|
||||
)
|
||||
private int rowStartNum;
|
||||
|
||||
public void setPaging(PageRequest pageRequest, int totalCount) {
|
||||
this.page = pageRequest.getPage();
|
||||
this.size = pageRequest.getSize();
|
||||
this.totalCount = totalCount;
|
||||
this.totalPage = PagingUtil.getTotalPage(totalCount, pageRequest.getSize());
|
||||
this.rowStartNum = PagingUtil.getRowStartNum(totalCount, pageRequest.getPage(), pageRequest.getSize());
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package com.alist.api.common.utils;
|
||||
|
||||
public final class PagingUtil {
|
||||
private static final int DEFAULT_PAGE = 1;
|
||||
private static final int DEFAULT_SIZE = 10;
|
||||
private static final int MAX_SIZE = 100;
|
||||
|
||||
private PagingUtil() {
|
||||
}
|
||||
|
||||
public static int getPage(Integer page) {
|
||||
return page == null || page < 1 ? DEFAULT_PAGE : page;
|
||||
}
|
||||
|
||||
public static int getSize(Integer size) {
|
||||
if (size == null || size < 1) {
|
||||
return DEFAULT_SIZE;
|
||||
}
|
||||
return Math.min(size, MAX_SIZE);
|
||||
}
|
||||
|
||||
public static int getLimit(Integer size) {
|
||||
return getSize(size);
|
||||
}
|
||||
|
||||
public static int getOffset(Integer page, Integer size) {
|
||||
return (getPage(page) - 1) * getSize(size);
|
||||
}
|
||||
|
||||
public static int getTotalPage(int totalCount, Integer size) {
|
||||
return totalCount <= 0 ? 0 : (int) Math.ceil((double) totalCount / getSize(size));
|
||||
}
|
||||
|
||||
public static int getRowStartNum(int totalCount, Integer page, Integer size) {
|
||||
if (totalCount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int offset = getOffset(page, size);
|
||||
int rowStartNum = totalCount - offset;
|
||||
|
||||
return Math.max(rowStartNum, 0);
|
||||
}
|
||||
}
|
||||
@@ -14,13 +14,9 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Configuration
|
||||
@MapperScan(
|
||||
basePackages = "com.alist.api.modules",
|
||||
@@ -55,19 +51,10 @@ public class MainDataSourceConfig {
|
||||
factoryBean.setDataSource(mainDataSource);
|
||||
factoryBean.setTypeAliasesPackage("com.alist.api");
|
||||
factoryBean.setConfiguration(mybatisConfiguration());
|
||||
|
||||
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
|
||||
|
||||
Resource[] commonMappers = resolver.getResources("classpath:mapper/*/*.xml");
|
||||
Resource[] adminMappers = resolver.getResources("classpath:mapper/admin/**/*.xml");
|
||||
Resource[] frontMappers = resolver.getResources("classpath:mapper/front/**/*.xml");
|
||||
Resource[] mapperLocations = Stream.of(
|
||||
Arrays.stream(commonMappers)
|
||||
, Arrays.stream(adminMappers)
|
||||
, Arrays.stream(frontMappers)
|
||||
).flatMap(stream -> stream).toArray(Resource[]::new);
|
||||
|
||||
factoryBean.setMapperLocations(mapperLocations);
|
||||
factoryBean.setMapperLocations(
|
||||
new PathMatchingResourcePatternResolver()
|
||||
.getResources("classpath:mapper/*/*.xml")
|
||||
);
|
||||
return factoryBean.getObject();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
package com.alist.api.config;
|
||||
|
||||
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
|
||||
import io.swagger.v3.oas.annotations.info.Info;
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||
import org.springdoc.core.models.GroupedOpenApi;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@OpenAPIDefinition(
|
||||
info = @Info(
|
||||
title = "A*List Api App",
|
||||
version = "v1",
|
||||
description = "A*List Api App 입니다.!"
|
||||
)
|
||||
)
|
||||
public class OpenApiConfig {
|
||||
private static final String SECURITY_SCHEME_NAME = "bearerAuth";
|
||||
|
||||
@Value("${api.version}")
|
||||
private String apiVersion;
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
return new OpenAPI()
|
||||
.info(new Info()
|
||||
.title("A*List Api App")
|
||||
.version(apiVersion)
|
||||
.description("A*List Api App 입니다.!"))
|
||||
.addSecurityItem(new SecurityRequirement().addList(SECURITY_SCHEME_NAME))
|
||||
.components(new Components()
|
||||
.addSecuritySchemes(SECURITY_SCHEME_NAME,
|
||||
@@ -35,40 +34,4 @@ public class OpenApiConfig {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public GroupedOpenApi frontApi() {
|
||||
return GroupedOpenApi.builder()
|
||||
.group("100. front")
|
||||
.pathsToMatch("/**")
|
||||
.pathsToExclude(
|
||||
"/admin/**"
|
||||
, "/cors/**"
|
||||
, "/file/**"
|
||||
, "/tus/file/**"
|
||||
, "/error"
|
||||
, "/actuator/**"
|
||||
)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public GroupedOpenApi adminApi() {
|
||||
return GroupedOpenApi.builder()
|
||||
.group("500. admin")
|
||||
.pathsToMatch("/admin/**")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public GroupedOpenApi commonApi() {
|
||||
return GroupedOpenApi.builder()
|
||||
.group("900. common")
|
||||
.pathsToMatch(
|
||||
"/cors/**"
|
||||
, "/file/**"
|
||||
, "/tus/file/**"
|
||||
)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,28 +50,6 @@ public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
@Order(2)
|
||||
public SecurityFilterChain adminFilterChain(
|
||||
HttpSecurity http,
|
||||
JwtAuthenticationFilter jwtAuthenticationFilter
|
||||
) throws Exception {
|
||||
http
|
||||
.securityMatcher("/admin/**")
|
||||
.cors(Customizer.withDefaults())
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(
|
||||
"/admin/auth/**"
|
||||
, "/admin/user/add"
|
||||
).permitAll()
|
||||
.anyRequest().hasRole("ADMIN")
|
||||
)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(3)
|
||||
public SecurityFilterChain apiFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
|
||||
http
|
||||
.cors(Customizer.withDefaults())
|
||||
@@ -81,17 +59,7 @@ public class SecurityConfig {
|
||||
.accessDeniedHandler(new JwtAccessDeniedHandler())
|
||||
)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(
|
||||
"/"
|
||||
, "/actuator/health"
|
||||
, "/sso/**"
|
||||
, "/auth/**"
|
||||
, "/user/add"
|
||||
, "/user/migration/list"
|
||||
, "/tus/file/hook"
|
||||
, "/tus/file/upload/auth"
|
||||
, "/file/**"
|
||||
).permitAll()
|
||||
.requestMatchers("/", "/actuator/health", "/sso/**", "/auth/**", "/user/signup", "/user/migrationUserList", "/files/tusHook").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
+13
-15
@@ -1,8 +1,9 @@
|
||||
package com.alist.api.config.cache;
|
||||
|
||||
import com.alist.api.modules.cors.mapper.CorsMapper;
|
||||
import com.alist.api.modules.cors.vo.CorsCacheVo;
|
||||
import com.alist.api.modules.auth.vo.CorsOriginVo;
|
||||
import com.alist.api.modules.auth.mapper.CorsMapper;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -13,16 +14,13 @@ import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class CorsCache {
|
||||
@RequiredArgsConstructor
|
||||
public class CorsAllowedOriginsCache {
|
||||
|
||||
private final CorsMapper corsMapper;
|
||||
|
||||
// Thread-safe List (volatile로 가시성 보장)
|
||||
private volatile List<String> corsOriginList = new ArrayList<>();
|
||||
|
||||
public CorsCache(CorsMapper corsMapper) {
|
||||
this.corsMapper = corsMapper;
|
||||
}
|
||||
private volatile List<String> allowedOrigins = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 서버 시작 시 DB에서 전체 CORS 목록 로딩
|
||||
@@ -30,7 +28,7 @@ public class CorsCache {
|
||||
@PostConstruct
|
||||
public void loadFromDatabase() {
|
||||
refresh();
|
||||
log.info("CORS allowed origins loaded...");
|
||||
log.info("CORS allowed origins loaded from database");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,19 +36,19 @@ public class CorsCache {
|
||||
*/
|
||||
public synchronized void refresh() {
|
||||
try {
|
||||
List<String> corsOriginList = corsMapper.selectCorsCacheList()
|
||||
List<String> origins = corsMapper.selectCorsAllowedList()
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(CorsCacheVo::getAllowedOrigin)
|
||||
.map(CorsOriginVo::getAllowedOrigin)
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(origin -> !origin.isEmpty())
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
this.corsOriginList = corsOriginList;
|
||||
this.allowedOrigins = origins;
|
||||
|
||||
log.info("CORS cache refreshed. Total origins: {}", corsOriginList.size());
|
||||
log.info("CORS cache refreshed. Total origins: {}", origins.size());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to refresh CORS cache", e);
|
||||
@@ -62,7 +60,7 @@ public class CorsCache {
|
||||
* @return 허용 도메인 리스트
|
||||
*/
|
||||
public List<String> getAllowedOrigins() {
|
||||
return new ArrayList<>(corsOriginList);
|
||||
return new ArrayList<>(allowedOrigins);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +68,6 @@ public class CorsCache {
|
||||
* @return 캐시 상태 정보
|
||||
*/
|
||||
public List<String> getCacheStatus() {
|
||||
return new ArrayList<>(corsOriginList);
|
||||
return new ArrayList<>(allowedOrigins);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.alist.api.config.filter;
|
||||
|
||||
import com.alist.api.config.cache.CorsCache;
|
||||
import com.alist.api.config.cache.CorsAllowedOriginsCache;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -21,7 +21,7 @@ import java.util.List;
|
||||
@RequiredArgsConstructor
|
||||
public class DynamicCorsFilter extends OncePerRequestFilter {
|
||||
|
||||
private final CorsCache corsCache;
|
||||
private final CorsAllowedOriginsCache corsCache;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
@@ -7,13 +7,12 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Collections;
|
||||
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
@@ -26,46 +25,26 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
try {
|
||||
String requestUri = request.getRequestURI();
|
||||
boolean adminRequest = requestUri.startsWith("/admin/");
|
||||
boolean sharedRequest = requestUri.startsWith("/tus/file/") || requestUri.startsWith("/file/") || requestUri.startsWith("/cors/");
|
||||
String token = resolveToken(request, adminRequest, sharedRequest);
|
||||
String token = resolveToken(request);
|
||||
|
||||
if (token != null && jwtTokenProvider.validateToken(token)) {
|
||||
String principal = jwtTokenProvider.getUserTokenIdx(token);
|
||||
String role = jwtTokenProvider.getRole(token);
|
||||
String scope = jwtTokenProvider.getScope(token);
|
||||
String tokenType = jwtTokenProvider.getTokenType(token);
|
||||
String userId = jwtTokenProvider.getUserTokenIdx(token);
|
||||
|
||||
boolean userAccessToken = "USER".equals(scope) && role != null && "ACCESS".equals(tokenType);
|
||||
|
||||
boolean adminAccessToken = "ADMIN".equals(scope) && "ADMIN".equals(role) && "ACCESS".equals(tokenType);
|
||||
|
||||
boolean authenticatable = adminRequest ? adminAccessToken : sharedRequest ? userAccessToken || adminAccessToken : userAccessToken;
|
||||
|
||||
if (!authenticatable) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
principal,
|
||||
null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_" + role))
|
||||
);
|
||||
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList());
|
||||
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
SecurityContextHolder.clearContext();
|
||||
// 로깅은 여기서 해도 됨 (SLF4J)
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private String resolveToken(HttpServletRequest request, boolean adminRequest, boolean sharedRequest) {
|
||||
private String resolveToken(HttpServletRequest request) {
|
||||
// 1) Authorization: Bearer xxx 우선
|
||||
String bearer = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
if (bearer != null && bearer.startsWith("Bearer ")) {
|
||||
String token = bearer.substring(7).trim();
|
||||
@@ -74,35 +53,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 쿠키 fallback (브라우저용)
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (adminRequest) {
|
||||
return resolveCookie(cookies, "adminAccessToken");
|
||||
}
|
||||
|
||||
if (sharedRequest) {
|
||||
String accessToken = resolveCookie(cookies, "accessToken");
|
||||
if (accessToken != null) {
|
||||
return accessToken;
|
||||
}
|
||||
return resolveCookie(cookies, "adminAccessToken");
|
||||
}
|
||||
|
||||
return resolveCookie(cookies, "accessToken");
|
||||
}
|
||||
|
||||
private String resolveCookie(Cookie[] cookies, String cookieName) {
|
||||
for (Cookie cookie : cookies) {
|
||||
if (cookieName.equals(cookie.getName())) {
|
||||
String token = cookie.getValue();
|
||||
if (token != null && !token.isBlank()) {
|
||||
return token.trim();
|
||||
if (cookies != null) {
|
||||
for (Cookie cookie : cookies) {
|
||||
if ("accessToken".equals(cookie.getName())) {
|
||||
String token = cookie.getValue();
|
||||
if (token != null && !token.isBlank()) {
|
||||
return token.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,6 @@ public class JwtTokenProvider {
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(userId)
|
||||
.claim("role", "TEST")
|
||||
.claim("scope", "USER")
|
||||
.claim("tokenType", "ACCESS")
|
||||
.setIssuedAt(now)
|
||||
.setExpiration(expiry)
|
||||
.signWith(secretKey, SignatureAlgorithm.HS256)
|
||||
@@ -51,8 +48,6 @@ public class JwtTokenProvider {
|
||||
return Jwts.builder()
|
||||
.setSubject(String.valueOf(userTokenIdx))
|
||||
.claim("role", role)
|
||||
.claim("scope", "USER")
|
||||
.claim("tokenType", "ACCESS")
|
||||
.setIssuedAt(Date.from(now))
|
||||
.setExpiration(Date.from(expiry))
|
||||
.signWith(secretKey, SignatureAlgorithm.HS256)
|
||||
@@ -66,8 +61,6 @@ public class JwtTokenProvider {
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(String.valueOf(userTokenIdx))
|
||||
.claim("scope", "USER")
|
||||
.claim("tokenType", "REFRESH")
|
||||
.setIssuedAt(Date.from(now))
|
||||
.setExpiration(Date.from(expiry))
|
||||
.signWith(secretKey, SignatureAlgorithm.HS256)
|
||||
@@ -97,6 +90,7 @@ public class JwtTokenProvider {
|
||||
.getBody();
|
||||
}
|
||||
|
||||
|
||||
/* 업로드 전용 토큰*/
|
||||
public String createUploadToken(long userTokenIdx) {
|
||||
Instant now = Instant.now();
|
||||
@@ -120,49 +114,4 @@ public class JwtTokenProvider {
|
||||
}
|
||||
}
|
||||
|
||||
public String createAdminAccessToken(long userTokenIdx) {
|
||||
Instant now = Instant.now();
|
||||
Instant expiry = now.plusSeconds(jwtProperties.getAccessTokenValiditySeconds());
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(String.valueOf(userTokenIdx))
|
||||
.claim("role", "ADMIN")
|
||||
.claim("scope", "ADMIN")
|
||||
.claim("tokenType", "ACCESS")
|
||||
.setIssuedAt(Date.from(now))
|
||||
.setExpiration(Date.from(expiry))
|
||||
.signWith(secretKey, SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public String createAdminRefreshToken(long userTokenIdx) {
|
||||
Instant now = Instant.now();
|
||||
Instant expiry = now.plusSeconds(jwtProperties.getRefreshTokenValiditySeconds());
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(String.valueOf(userTokenIdx))
|
||||
.claim("role", "ADMIN")
|
||||
.claim("scope", "ADMIN")
|
||||
.claim("tokenType", "REFRESH")
|
||||
.setIssuedAt(Date.from(now))
|
||||
.setExpiration(Date.from(expiry))
|
||||
.signWith(secretKey, SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims getClaims(String token) {
|
||||
return parseClaims(token);
|
||||
}
|
||||
|
||||
public String getRole(String token) {
|
||||
return parseClaims(token).get("role", String.class);
|
||||
}
|
||||
|
||||
public String getScope(String token) {
|
||||
return parseClaims(token).get("scope", String.class);
|
||||
}
|
||||
|
||||
public String getTokenType(String token) {
|
||||
return parseClaims(token).get("tokenType", String.class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
package com.alist.api.modules.admin.auth;
|
||||
|
||||
import com.alist.api.common.response.ApiResponse;
|
||||
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.AdminAuthApiKeyLoginForm;
|
||||
import com.alist.api.modules.admin.auth.form.AdminAuthLoginForm;
|
||||
import com.alist.api.modules.admin.auth.service.AdminAuthService;
|
||||
import com.alist.api.modules.admin.auth.vo.*;
|
||||
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.validation.Valid;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@Tag(
|
||||
name = "501. Admin 로그인"
|
||||
, description = "ADMIN 사이트 로그인입니다. 로그인시 토큰 발급이 함께됩니다."
|
||||
)
|
||||
@RestController
|
||||
@RequestMapping("/admin/auth")
|
||||
public class AdminAuthController {
|
||||
|
||||
private final AdminAuthService adminAuthService;
|
||||
|
||||
public AdminAuthController(AdminAuthService adminAuthService) {
|
||||
this.adminAuthService = adminAuthService;
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "admin 로그인"
|
||||
, description = "admin로그인 sso와 다르게 로그인시 토큰이 함께 발급됩니다."
|
||||
)
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<ApiResponse<AdminAuthLoginVo>> adminAuthLogin(
|
||||
@Valid @RequestBody AdminAuthLoginForm adminAuthLoginForm
|
||||
, HttpServletResponse response
|
||||
) {
|
||||
AdminAuthLoginVo adminAuthLoginVo = adminAuthService.selectAdminAuthLogin(adminAuthLoginForm.toDto(), response);
|
||||
|
||||
if (adminAuthLoginVo.getResultCode() == 2003) {
|
||||
return ApiResponse.entity(adminAuthLoginVo, ApiResponseCode.CODE_2003);
|
||||
}
|
||||
|
||||
return ApiResponse.entity(adminAuthLoginVo, ApiResponseCode.CODE_2001, "관리자 로그인");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "admin API Key 로그인"
|
||||
, description = "Admin API Key로 인증하고 JSON으로 토큰을 반환합니다. Swagger 테스트용입니다."
|
||||
)
|
||||
@PostMapping("/apiKeyLogin")
|
||||
public ResponseEntity<ApiResponse<AdminAuthApiKeyLoginVo>> adminAuthApiKeyLogin(
|
||||
@Valid @RequestBody AdminAuthApiKeyLoginForm adminAuthApiKeyLoginForm
|
||||
) {
|
||||
AdminAuthApiKeyLoginVo adminAuthApiKeyLoginVo = adminAuthService.selectAdminAuthApiKeyLogin(adminAuthApiKeyLoginForm.toDto());
|
||||
|
||||
if (adminAuthApiKeyLoginVo.getResultCode() == 2003) {
|
||||
return ApiResponse.entity(adminAuthApiKeyLoginVo, ApiResponseCode.CODE_2003);
|
||||
}
|
||||
|
||||
return ApiResponse.entity(adminAuthApiKeyLoginVo, ApiResponseCode.CODE_2001, "관리자 엑세스 토큰");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "admin 리프레시토큰 발급"
|
||||
, description = "엑세스 토큰 만료시 리프레시 토큰 발급 용도 api 입니다."
|
||||
)
|
||||
@PostMapping("/refresh")
|
||||
public ResponseEntity<ApiResponse<AdminAuthRefreshVo>> adminAuthRefresh(
|
||||
@CookieValue(name = "adminRefreshToken", required = false) String refreshToken
|
||||
, HttpServletResponse response
|
||||
) {
|
||||
AdminAuthLoginVo adminAuthLoginVo = adminAuthService.selectAdminAuthRefresh(refreshToken, response);
|
||||
|
||||
AdminAuthRefreshVo adminAuthRefreshVo = new AdminAuthRefreshVo();
|
||||
adminAuthRefreshVo.setRefreshed(adminAuthLoginVo != null);
|
||||
|
||||
if (adminAuthLoginVo == null) {
|
||||
return ApiResponse.entity(adminAuthRefreshVo, ApiResponseCode.CODE_401);
|
||||
}
|
||||
|
||||
return ApiResponse.entity(adminAuthRefreshVo, ApiResponseCode.CODE_200);
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "admin 로그인 상태 확인"
|
||||
, description = "admin 쿠키 기준으로 현재 공통 로그인 상태가 유효한지 확인하고, access/refresh 토큰 존재 여부도 함께 반환합니다."
|
||||
)
|
||||
@GetMapping("/loginChecked")
|
||||
public ResponseEntity<ApiResponse<AdminAuthLoginCheckedVo>> adminAuthLoginChecked(
|
||||
HttpServletRequest request
|
||||
) {
|
||||
AdminAuthLoginCheckedVo adminAuthLoginCheckedVo = new AdminAuthLoginCheckedVo();
|
||||
|
||||
String accessToken = SessionUtil.resolveSsoCookieValue(request, "adminAccessToken");
|
||||
String refreshToken = SessionUtil.resolveSsoCookieValue(request, "adminRefreshToken");
|
||||
|
||||
adminAuthLoginCheckedVo.setAdminAccessToken(accessToken != null && !accessToken.trim().isEmpty());
|
||||
adminAuthLoginCheckedVo.setAdminRefreshToken(refreshToken != null && !refreshToken.trim().isEmpty());
|
||||
|
||||
Integer userTokenIdx = SecurityUtil.getLoginUserTokenIdx();
|
||||
|
||||
if (userTokenIdx == null) {
|
||||
adminAuthLoginCheckedVo.setLoggedIn(false);
|
||||
return ApiResponse.entity(adminAuthLoginCheckedVo, ApiResponseCode.CODE_200);
|
||||
}
|
||||
|
||||
AdminAuthLoginVo adminAuthLoginVo = adminAuthService.selectAdminAuthLoginChecked(userTokenIdx);
|
||||
|
||||
if (adminAuthLoginVo == null) {
|
||||
adminAuthLoginCheckedVo.setLoggedIn(false);
|
||||
return ApiResponse.entity(adminAuthLoginCheckedVo, ApiResponseCode.CODE_200);
|
||||
}
|
||||
|
||||
adminAuthLoginCheckedVo.setLoggedIn(true);
|
||||
adminAuthLoginCheckedVo.setUserId(adminAuthLoginVo.getId());
|
||||
adminAuthLoginCheckedVo.setUserIdx(adminAuthLoginVo.getUserIdx());
|
||||
adminAuthLoginCheckedVo.setUserTokenIdx(userTokenIdx);
|
||||
adminAuthLoginCheckedVo.setUserRole(adminAuthLoginVo.getUserRole());
|
||||
adminAuthLoginCheckedVo.setUserType(adminAuthLoginVo.getUserType());
|
||||
|
||||
return ApiResponse.entity(adminAuthLoginCheckedVo, ApiResponseCode.CODE_200);
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "admin 로그아웃"
|
||||
, description = "admin 로그아웃"
|
||||
)
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<ApiResponse<AdminAuthLogoutVo>> adminAuthLogout(
|
||||
@CookieValue(name = "adminRefreshToken", required = false) String refreshToken
|
||||
, HttpServletResponse response
|
||||
) {
|
||||
adminAuthService.updateAdminAuthLogout(refreshToken, response);
|
||||
|
||||
AdminAuthLogoutVo adminAuthLogoutVo = new AdminAuthLogoutVo();
|
||||
adminAuthLogoutVo.setLoggedOut(true);
|
||||
|
||||
return ApiResponse.entity(adminAuthLogoutVo, ApiResponseCode.CODE_200);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.alist.api.modules.admin.auth.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminAuthApiKeyLoginDto {
|
||||
private String userApiKey;
|
||||
private Integer userIdx;
|
||||
private Integer userTokenIdx;
|
||||
private String refreshToken;
|
||||
private Instant expiresAt;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.alist.api.modules.admin.auth.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Setter
|
||||
@Getter
|
||||
public class AdminAuthLoginDto {
|
||||
private String id;
|
||||
private String password;
|
||||
|
||||
private Integer userIdx;
|
||||
private Integer userTokenIdx;
|
||||
private String refreshToken;
|
||||
private Instant expiresAt;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package com.alist.api.modules.admin.auth.form;
|
||||
|
||||
import com.alist.api.modules.admin.auth.dto.AdminAuthApiKeyLoginDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "Admin API Key 로그인 요청 폼")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminAuthApiKeyLoginForm {
|
||||
@Schema(
|
||||
description = "Admin API Key",
|
||||
example = "ypdz9hl7WAp2D03HC5h9koEC49R6LAkLSuoSdgEwVQA",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED
|
||||
)
|
||||
@NotBlank(message = "apiKey를 입력해주세요.")
|
||||
private String userApiKey;
|
||||
|
||||
public AdminAuthApiKeyLoginDto toDto() {
|
||||
AdminAuthApiKeyLoginDto adminAuthApiKeyLoginDto = new AdminAuthApiKeyLoginDto();
|
||||
adminAuthApiKeyLoginDto.setUserApiKey(userApiKey.trim());
|
||||
return adminAuthApiKeyLoginDto;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package com.alist.api.modules.admin.auth.form;
|
||||
|
||||
import com.alist.api.modules.admin.auth.dto.AdminAuthLoginDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "Admin 로그인 요청 폼")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminAuthLoginForm {
|
||||
@Schema(
|
||||
description = "관리자 아이디",
|
||||
example = "admin",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED
|
||||
)
|
||||
@NotBlank(message = "아이디는 필수입니다.")
|
||||
private String id;
|
||||
|
||||
@Schema(
|
||||
description = "관리자 비밀번호",
|
||||
example = "password",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED
|
||||
)
|
||||
@NotBlank(message = "비밀번호는 필수입니다.")
|
||||
private String password;
|
||||
|
||||
public AdminAuthLoginDto toDto() {
|
||||
AdminAuthLoginDto adminAuthLoginDto = new AdminAuthLoginDto();
|
||||
adminAuthLoginDto.setId(id.trim());
|
||||
adminAuthLoginDto.setPassword(password);
|
||||
|
||||
return adminAuthLoginDto;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.alist.api.modules.admin.auth.mapper;
|
||||
|
||||
import com.alist.api.modules.admin.auth.dto.AdminAuthApiKeyLoginDto;
|
||||
import com.alist.api.modules.admin.auth.dto.AdminAuthLoginDto;
|
||||
import com.alist.api.modules.admin.auth.vo.AdminAuthApiKeyLoginVo;
|
||||
import com.alist.api.modules.admin.auth.vo.AdminAuthLoginVo;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface AdminAuthMapper {
|
||||
AdminAuthLoginVo selectAdminAuthLogin(String id);
|
||||
|
||||
void updateAdminRefreshToken(AdminAuthLoginDto adminAuthLoginDto);
|
||||
|
||||
void updateAdminLastLoginAt(Integer userIdx);
|
||||
|
||||
AdminAuthLoginVo selectAdminTokenByUserTokenIdx(Integer userTokenIdx);
|
||||
|
||||
void updateClearAdminRefreshToken(Integer userTokenIdx);
|
||||
|
||||
AdminAuthApiKeyLoginVo selectAdminAuthApiKeyLogin(AdminAuthApiKeyLoginDto adminAuthApiKeyLoginDto);
|
||||
|
||||
void updateAdminApiKeyLoginRefreshToken(AdminAuthApiKeyLoginDto adminAuthApiKeyLoginDto);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
package com.alist.api.modules.admin.auth.service;
|
||||
|
||||
import com.alist.api.common.utils.SessionUtil;
|
||||
import com.alist.api.config.jwt.JwtTokenProvider;
|
||||
import com.alist.api.modules.admin.auth.dto.AdminAuthApiKeyLoginDto;
|
||||
import com.alist.api.modules.admin.auth.dto.AdminAuthLoginDto;
|
||||
import com.alist.api.modules.admin.auth.mapper.AdminAuthMapper;
|
||||
import com.alist.api.modules.admin.auth.vo.AdminAuthApiKeyLoginVo;
|
||||
import com.alist.api.modules.admin.auth.vo.AdminAuthLoginVo;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Service
|
||||
public class AdminAuthService {
|
||||
@Value("${cookie.secure}")
|
||||
private boolean cookieSecure;
|
||||
|
||||
@Value("${cookie.domain:}")
|
||||
private String cookieDomain;
|
||||
|
||||
@Value("${cookie.same-site:Lax}")
|
||||
private String cookieSameSite;
|
||||
|
||||
@Value("${jwt.access-token-validity-seconds}")
|
||||
private long accessTokenValiditySeconds;
|
||||
|
||||
@Value("${jwt.refresh-token-validity-seconds}")
|
||||
private long refreshTokenValiditySeconds;
|
||||
|
||||
private final AdminAuthMapper adminAuthMapper;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public AdminAuthService(AdminAuthMapper adminAuthMapper, JwtTokenProvider jwtTokenProvider, PasswordEncoder passwordEncoder) {
|
||||
this.adminAuthMapper = adminAuthMapper;
|
||||
this.jwtTokenProvider = jwtTokenProvider;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminAuthLoginVo selectAdminAuthLogin(AdminAuthLoginDto adminAuthLoginDto, HttpServletResponse response) {
|
||||
AdminAuthLoginVo adminAuthLoginVo = adminAuthMapper.selectAdminAuthLogin(adminAuthLoginDto.getId());
|
||||
|
||||
if (adminAuthLoginVo == null) {
|
||||
AdminAuthLoginVo adminAuthLoginVo1 = new AdminAuthLoginVo();
|
||||
adminAuthLoginVo1.setResultCode(2003);
|
||||
adminAuthLoginVo1.setLogin(false);
|
||||
return adminAuthLoginVo1;
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(adminAuthLoginDto.getPassword(), adminAuthLoginVo.getPassword())) {
|
||||
AdminAuthLoginVo adminAuthLoginVo1 = new AdminAuthLoginVo();
|
||||
adminAuthLoginVo1.setResultCode(2003);
|
||||
adminAuthLoginVo1.setLogin(false);
|
||||
return adminAuthLoginVo1;
|
||||
}
|
||||
|
||||
String accessToken = jwtTokenProvider.createAdminAccessToken(adminAuthLoginVo.getUserTokenIdx());
|
||||
String refreshToken = jwtTokenProvider.createAdminRefreshToken(adminAuthLoginVo.getUserTokenIdx());
|
||||
|
||||
adminAuthLoginDto.setUserIdx(adminAuthLoginVo.getUserIdx());
|
||||
adminAuthLoginDto.setUserTokenIdx(adminAuthLoginVo.getUserTokenIdx());
|
||||
adminAuthLoginDto.setRefreshToken(refreshToken);
|
||||
adminAuthLoginDto.setExpiresAt(Instant.now().plusSeconds(refreshTokenValiditySeconds));
|
||||
|
||||
adminAuthMapper.updateAdminRefreshToken(adminAuthLoginDto);
|
||||
adminAuthMapper.updateAdminLastLoginAt(adminAuthLoginDto.getUserIdx());
|
||||
|
||||
SessionUtil.addTokenCookie(response, "adminAccessToken", accessToken, cookieDomain, cookieSecure, cookieSameSite, accessTokenValiditySeconds);
|
||||
SessionUtil.addTokenCookie(response, "adminRefreshToken", refreshToken, cookieDomain, cookieSecure, cookieSameSite, refreshTokenValiditySeconds);
|
||||
|
||||
adminAuthLoginVo.setResultCode(2001);
|
||||
adminAuthLoginVo.setLogin(true);
|
||||
return adminAuthLoginVo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminAuthApiKeyLoginVo selectAdminAuthApiKeyLogin(AdminAuthApiKeyLoginDto adminAuthApiKeyLoginDto) {
|
||||
AdminAuthApiKeyLoginVo adminAuthApiKeyLoginVo = adminAuthMapper.selectAdminAuthApiKeyLogin(adminAuthApiKeyLoginDto);
|
||||
|
||||
if (adminAuthApiKeyLoginVo == null) {
|
||||
AdminAuthApiKeyLoginVo adminAuthApiKeyLoginVo1 = new AdminAuthApiKeyLoginVo();
|
||||
adminAuthApiKeyLoginVo1.setResultCode(2003);
|
||||
adminAuthApiKeyLoginVo1.setLogin(false);
|
||||
return adminAuthApiKeyLoginVo1;
|
||||
}
|
||||
|
||||
String accessToken = jwtTokenProvider.createAdminAccessToken(adminAuthApiKeyLoginVo.getUserTokenIdx());
|
||||
String refreshToken = jwtTokenProvider.createAdminRefreshToken(adminAuthApiKeyLoginVo.getUserTokenIdx());
|
||||
|
||||
adminAuthApiKeyLoginDto.setUserTokenIdx(adminAuthApiKeyLoginVo.getUserTokenIdx());
|
||||
adminAuthApiKeyLoginDto.setRefreshToken(refreshToken);
|
||||
adminAuthApiKeyLoginDto.setExpiresAt(Instant.now().plusSeconds(refreshTokenValiditySeconds));
|
||||
|
||||
adminAuthMapper.updateAdminApiKeyLoginRefreshToken(adminAuthApiKeyLoginDto);
|
||||
adminAuthMapper.updateAdminLastLoginAt(adminAuthApiKeyLoginVo.getUserIdx());
|
||||
|
||||
adminAuthApiKeyLoginVo.setAccessToken(accessToken);
|
||||
adminAuthApiKeyLoginVo.setRefreshToken(refreshToken);
|
||||
adminAuthApiKeyLoginVo.setResultCode(2001);
|
||||
adminAuthApiKeyLoginVo.setLogin(true);
|
||||
|
||||
return adminAuthApiKeyLoginVo;
|
||||
}
|
||||
|
||||
public AdminAuthLoginVo selectAdminAuthRefresh(String refreshToken, HttpServletResponse response) {
|
||||
if (refreshToken == null || !jwtTokenProvider.validateToken(refreshToken)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String role = jwtTokenProvider.getRole(refreshToken);
|
||||
String scope = jwtTokenProvider.getScope(refreshToken);
|
||||
String tokenType = jwtTokenProvider.getTokenType(refreshToken);
|
||||
|
||||
if (!"ADMIN".equals(role) || !"ADMIN".equals(scope) || !"REFRESH".equals(tokenType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Integer userTokenIdx;
|
||||
|
||||
try {
|
||||
userTokenIdx = Integer.parseInt(jwtTokenProvider.getUserTokenIdx(refreshToken));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
AdminAuthLoginVo adminAuthLoginVo = adminAuthMapper.selectAdminTokenByUserTokenIdx(userTokenIdx);
|
||||
if (adminAuthLoginVo == null || adminAuthLoginVo.getRefreshToken() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!refreshToken.equals(adminAuthLoginVo.getRefreshToken())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String newAccessToken = jwtTokenProvider.createAdminAccessToken(userTokenIdx);
|
||||
String newRefreshToken = jwtTokenProvider.createAdminRefreshToken(userTokenIdx);
|
||||
|
||||
AdminAuthLoginDto adminAuthLoginDto = new AdminAuthLoginDto();
|
||||
|
||||
adminAuthLoginDto.setUserTokenIdx(userTokenIdx);
|
||||
adminAuthLoginDto.setRefreshToken(newRefreshToken);
|
||||
adminAuthLoginDto.setExpiresAt(Instant.now().plusSeconds(refreshTokenValiditySeconds));
|
||||
|
||||
adminAuthMapper.updateAdminRefreshToken(adminAuthLoginDto);
|
||||
|
||||
SessionUtil.addTokenCookie(response, "adminAccessToken", newAccessToken, cookieDomain, cookieSecure, cookieSameSite, accessTokenValiditySeconds);
|
||||
SessionUtil.addTokenCookie(response, "adminRefreshToken", newRefreshToken, cookieDomain, cookieSecure, cookieSameSite, refreshTokenValiditySeconds);
|
||||
|
||||
adminAuthLoginVo.setResultCode(2001);
|
||||
return adminAuthLoginVo;
|
||||
}
|
||||
|
||||
public void updateAdminAuthLogout(String refreshToken, HttpServletResponse response) {
|
||||
updateClearAdminRefreshToken(refreshToken);
|
||||
|
||||
SessionUtil.expireCookie(response, "adminAccessToken", cookieDomain, cookieSecure, cookieSameSite);
|
||||
SessionUtil.expireCookie(response, "adminRefreshToken", cookieDomain, cookieSecure, cookieSameSite);
|
||||
}
|
||||
|
||||
private void updateClearAdminRefreshToken(String refreshToken) {
|
||||
if (refreshToken == null || !jwtTokenProvider.validateToken(refreshToken)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String role = jwtTokenProvider.getRole(refreshToken);
|
||||
String scope = jwtTokenProvider.getScope(refreshToken);
|
||||
String tokenType = jwtTokenProvider.getTokenType(refreshToken);
|
||||
|
||||
if (!"ADMIN".equals(role) || !"ADMIN".equals(scope) || !"REFRESH".equals(tokenType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Integer userTokenIdx = Integer.parseInt(jwtTokenProvider.getUserTokenIdx(refreshToken));
|
||||
adminAuthMapper.updateClearAdminRefreshToken(userTokenIdx);
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public AdminAuthLoginVo selectAdminAuthLoginChecked(Integer userTokenIdx) {
|
||||
if (userTokenIdx == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return adminAuthMapper.selectAdminTokenByUserTokenIdx(userTokenIdx);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package com.alist.api.modules.admin.auth.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "Admin API Key 로그인 응답")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminAuthApiKeyLoginVo {
|
||||
@Schema(description = "로그인 성공 여부", example = "true")
|
||||
private boolean login;
|
||||
|
||||
@Schema(description = "회원 PK", example = "14")
|
||||
private Integer userIdx;
|
||||
|
||||
@Schema(description = "관리자 아이디", example = "admin")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "관리자 권한 코드", example = "A")
|
||||
private String userRole;
|
||||
|
||||
@Schema(description = "회원 유형 코드. A: 관리자", example = "A")
|
||||
private String userType;
|
||||
|
||||
@Schema(description = "관리자 API 테스트용 Access Token")
|
||||
private String accessToken;
|
||||
|
||||
@Schema(description = "관리자 API 테스트용 Refresh Token")
|
||||
private String refreshToken;
|
||||
|
||||
@JsonIgnore
|
||||
@Schema(hidden = true)
|
||||
private Integer userTokenIdx;
|
||||
|
||||
@JsonIgnore
|
||||
@Schema(hidden = true)
|
||||
private int resultCode;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package com.alist.api.modules.admin.auth.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "Admin 로그인 상태 확인 응답")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminAuthLoginCheckedVo {
|
||||
@Schema(description = "Admin Access Token 쿠키 존재 여부", example = "true")
|
||||
private boolean adminAccessToken;
|
||||
|
||||
@Schema(description = "Admin Refresh Token 쿠키 존재 여부", example = "true")
|
||||
private boolean adminRefreshToken;
|
||||
|
||||
@Schema(description = "로그인 상태 여부", example = "true")
|
||||
private boolean loggedIn;
|
||||
|
||||
@Schema(description = "관리자 아이디", example = "admin")
|
||||
private String userId;
|
||||
|
||||
@Schema(description = "회원 PK", example = "14")
|
||||
private Integer userIdx;
|
||||
|
||||
@Schema(description = "회원 토큰 PK", example = "14")
|
||||
private Integer userTokenIdx;
|
||||
|
||||
@Schema(description = "관리자 권한 코드", example = "A")
|
||||
private String userRole;
|
||||
|
||||
@Schema(description = "회원 유형 코드. A: 관리자", example = "A")
|
||||
private String userType;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package com.alist.api.modules.admin.auth.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "Admin 로그인 응답")
|
||||
@Setter
|
||||
@Getter
|
||||
public class AdminAuthLoginVo {
|
||||
@Schema(description = "로그인 성공 여부", example = "true")
|
||||
private boolean login;
|
||||
|
||||
@Schema(description = "회원 PK", example = "14")
|
||||
private Integer userIdx;
|
||||
|
||||
@Schema(description = "관리자 아이디", example = "admin")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "관리자 권한 코드", example = "A")
|
||||
private String userRole;
|
||||
|
||||
@Schema(description = "회원 유형 코드. A: 관리자", example = "A")
|
||||
private String userType;
|
||||
|
||||
@JsonIgnore
|
||||
@Schema(hidden = true)
|
||||
private String password;
|
||||
|
||||
@JsonIgnore
|
||||
@Schema(hidden = true)
|
||||
private Integer userTokenIdx;
|
||||
|
||||
@JsonIgnore
|
||||
@Schema(hidden = true)
|
||||
private String refreshToken;
|
||||
|
||||
@JsonIgnore
|
||||
@Schema(hidden = true)
|
||||
private int resultCode;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.alist.api.modules.admin.auth.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "Admin 로그아웃 응답")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminAuthLogoutVo {
|
||||
@Schema(description = "로그아웃 처리 여부", example = "true")
|
||||
private boolean loggedOut;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.alist.api.modules.admin.auth.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "Admin 리프레시 토큰 발급 응답")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminAuthRefreshVo {
|
||||
@Schema(description = "리프레시 처리 여부", example = "true")
|
||||
private boolean refreshed;
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package com.alist.api.modules.admin.member;
|
||||
|
||||
import com.alist.api.common.response.ApiResponse;
|
||||
import com.alist.api.common.response.ApiResponseCode;
|
||||
import com.alist.api.modules.admin.member.form.*;
|
||||
import com.alist.api.modules.admin.member.service.AdminMemberService;
|
||||
import com.alist.api.modules.admin.member.vo.*;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@Tag(name = "502. 회원 관리", description = "관리자 회원 관리 API")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/admin/member")
|
||||
public class AdminMemberController {
|
||||
private final AdminMemberService adminMemberService;
|
||||
|
||||
public AdminMemberController(AdminMemberService adminMemberService) {
|
||||
this.adminMemberService = adminMemberService;
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "회원 목록 조회"
|
||||
, description = "관리자가 회원 목록을 검색 조건과 페이징으로 조회합니다."
|
||||
)
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<ApiResponse<AdminMemberListVo>> adminMemberList(
|
||||
@ModelAttribute AdminMemberListForm adminMemberListForm
|
||||
) {
|
||||
AdminMemberListVo adminMemberListVo = adminMemberService.selectAdminMemberList(adminMemberListForm.toDto());
|
||||
|
||||
if (adminMemberListVo.getMemberList() == null || adminMemberListVo.getMemberList().isEmpty()) {
|
||||
return ApiResponse.entity(adminMemberListVo, ApiResponseCode.CODE_2003);
|
||||
}
|
||||
|
||||
return ApiResponse.entity(adminMemberListVo, ApiResponseCode.CODE_2001, "회원");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "회원 상세 조회"
|
||||
, description = "관리자가 회원 PK 기준으로 회원 상세 정보를 조회합니다."
|
||||
)
|
||||
@GetMapping("/view")
|
||||
public ResponseEntity<ApiResponse<AdminMemberViewVo>> adminMemberView(
|
||||
@RequestParam Integer userIdx
|
||||
) {
|
||||
AdminMemberViewVo adminMemberViewVo = adminMemberService.selectAdminMemberView(userIdx);
|
||||
|
||||
if (adminMemberViewVo == null) {
|
||||
return ApiResponse.entity((AdminMemberViewVo) null, ApiResponseCode.CODE_2003);
|
||||
}
|
||||
|
||||
return ApiResponse.entity(adminMemberViewVo, ApiResponseCode.CODE_2001, "회원");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "회원 등록"
|
||||
, description = "관리자가 교사 또는 학생 회원을 등록합니다. 아이디 중복 시 오류를 반환합니다."
|
||||
)
|
||||
@PostMapping("/add")
|
||||
public ResponseEntity<ApiResponse<AdminMemberAddVo>> adminMemberAdd(
|
||||
@Valid @RequestBody AdminMemberAddForm adminMemberAddForm
|
||||
) {
|
||||
AdminMemberAddVo adminMemberAddVo = adminMemberService.insertAdminMemberAdd(adminMemberAddForm.toDto());
|
||||
|
||||
if (adminMemberAddVo.getResultCode() == 2004) {
|
||||
return ApiResponse.entity(adminMemberAddVo, ApiResponseCode.CODE_2004, "아이디");
|
||||
}
|
||||
|
||||
return ApiResponse.entity(adminMemberAddVo, ApiResponseCode.CODE_2002, "회원");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "회원 정보 수정"
|
||||
, description = "관리자가 회원 PK 기준으로 회원 기본 정보와 권한 정보를 수정합니다."
|
||||
)
|
||||
@PutMapping("/modify")
|
||||
public ResponseEntity<ApiResponse<AdminMemberModifyVo>> adminMemberModify(
|
||||
@Valid @RequestBody AdminMemberModifyForm adminMemberModifyForm
|
||||
) {
|
||||
AdminMemberModifyVo adminMemberModifyVo = adminMemberService.updateAdminMemberModify(adminMemberModifyForm.toDto());
|
||||
|
||||
if (!adminMemberModifyVo.isProcessed()) {
|
||||
return ApiResponse.entity(adminMemberModifyVo, ApiResponseCode.CODE_2003);
|
||||
}
|
||||
|
||||
return ApiResponse.entity(adminMemberModifyVo, ApiResponseCode.CODE_2005, "회원 수정");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "회원 계정 삭제"
|
||||
, description = "관리자가 회원 PK 기준으로 계정을 삭제 처리합니다. 실제 삭제가 아닌 del_yn 변경 방식입니다."
|
||||
)
|
||||
@PatchMapping("/delete")
|
||||
public ResponseEntity<ApiResponse<AdminMemberDeleteVo>> adminMemberDelete(
|
||||
@Valid @RequestBody AdminMemberDeleteForm adminMemberDeleteForm
|
||||
) {
|
||||
AdminMemberDeleteVo adminMemberDeleteVo = adminMemberService.updateAdminMemberDelete(adminMemberDeleteForm.toDto());
|
||||
|
||||
if (!adminMemberDeleteVo.isProcessed()) {
|
||||
return ApiResponse.entity(adminMemberDeleteVo, ApiResponseCode.CODE_2003);
|
||||
}
|
||||
|
||||
return ApiResponse.entity(adminMemberDeleteVo, ApiResponseCode.CODE_2005, "회원 계정 삭제");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "회원 휴면 상태 변경"
|
||||
, description = "관리자가 회원 PK 기준으로 휴면 상태를 변경합니다. Y: 휴면, N: 정상"
|
||||
)
|
||||
@PatchMapping("/dormant/modify")
|
||||
public ResponseEntity<ApiResponse<AdminMemberDormantModifyVo>> adminMemberDormantModify(
|
||||
@Valid @RequestBody AdminMemberDormantModifyForm adminMemberDormantModifyForm
|
||||
) {
|
||||
AdminMemberDormantModifyVo adminMemberDormantModifyVo = adminMemberService.updateAdminMemberDormantModify(adminMemberDormantModifyForm.toDto());
|
||||
|
||||
if (!adminMemberDormantModifyVo.isProcessed()) {
|
||||
return ApiResponse.entity(adminMemberDormantModifyVo, ApiResponseCode.CODE_2003);
|
||||
}
|
||||
|
||||
return ApiResponse.entity(adminMemberDormantModifyVo, ApiResponseCode.CODE_2005, "회원 휴면 상태 변경");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "회원 탈퇴 상태 변경"
|
||||
, description = "관리자가 회원 PK 기준으로 탈퇴 상태를 변경합니다. N: 정상, P: 탈퇴대기, Y: 탈퇴완료"
|
||||
)
|
||||
@PatchMapping("/withdraw/modify")
|
||||
public ResponseEntity<ApiResponse<AdminMemberWithdrawModifyVo>> adminMemberWithdrawModify(
|
||||
@Valid @RequestBody AdminMemberWithdrawModifyForm adminMemberWithdrawModifyForm
|
||||
) {
|
||||
AdminMemberWithdrawModifyVo adminMemberWithdrawModifyVo = adminMemberService.updateAdminMemberWithdrawModify(adminMemberWithdrawModifyForm.toDto());
|
||||
|
||||
if (!adminMemberWithdrawModifyVo.isProcessed()) {
|
||||
return ApiResponse.entity(adminMemberWithdrawModifyVo, ApiResponseCode.CODE_2003);
|
||||
}
|
||||
|
||||
return ApiResponse.entity(adminMemberWithdrawModifyVo, ApiResponseCode.CODE_2005, "회원 탈퇴 상태 변경");
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminMemberAddDto {
|
||||
private String id;
|
||||
private String password;
|
||||
private String userRole;
|
||||
private String userType;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminMemberDeleteDto {
|
||||
private List<Integer> userIdxList;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminMemberDormantModifyDto {
|
||||
private Integer userIdx;
|
||||
private String dormantYn;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.dto;
|
||||
|
||||
import com.alist.api.common.paging.PageRequest;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminMemberListDto extends PageRequest {
|
||||
private String keyword;
|
||||
private String userRole;
|
||||
private String userType;
|
||||
private String dormantYn;
|
||||
private String withdrawStatus;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminMemberModifyDto {
|
||||
private Integer userIdx;
|
||||
private String email;
|
||||
private String hp;
|
||||
private String userRole;
|
||||
private String userType;
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminMemberWithdrawModifyDto {
|
||||
private Integer userIdx;
|
||||
private String withdrawStatus;
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.form;
|
||||
|
||||
import com.alist.api.modules.admin.member.dto.AdminMemberAddDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 회원 등록 요청 폼")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminMemberAddForm {
|
||||
|
||||
@Schema(
|
||||
description = "회원 아이디"
|
||||
, example = "teacher01"
|
||||
, requiredMode = Schema.RequiredMode.REQUIRED
|
||||
)
|
||||
@NotBlank(message = "아이디를 입력해주세요.")
|
||||
private String id;
|
||||
|
||||
@Schema(
|
||||
description = "비밀번호(8~64자, 영문과 숫자 포함, 공백 불가)"
|
||||
, example = "pass1234"
|
||||
, requiredMode = Schema.RequiredMode.REQUIRED
|
||||
)
|
||||
@NotBlank(message = "비밀번호를 입력해주세요.")
|
||||
@Size(min = 8, max = 64, message = "비밀번호는 8~64자여야 합니다.")
|
||||
@Pattern(
|
||||
regexp = "^(?=.*[A-Za-z])(?=.*\\d)\\S+$"
|
||||
, message = "비밀번호는 영문과 숫자를 포함하고 공백이 없어야 합니다."
|
||||
)
|
||||
private String password;
|
||||
|
||||
@Schema(
|
||||
description = "회원 권한 코드. 교사: CCM/SCM/I, 학생: R/E 관리자: A"
|
||||
, example = "I"
|
||||
, requiredMode = Schema.RequiredMode.REQUIRED
|
||||
)
|
||||
@NotBlank(message = "회원 권한을 입력해주세요.")
|
||||
@Pattern(
|
||||
regexp = "^(CCM|SCM|I|R|E|A)$"
|
||||
, message = "회원 권한은 CCM, SCM, I, R, E만 가능합니다."
|
||||
)
|
||||
private String userRole;
|
||||
|
||||
@Schema(
|
||||
description = "회원 유형 코드. T: 교사, S: 학생 A: 관리자"
|
||||
, example = "T"
|
||||
, requiredMode = Schema.RequiredMode.REQUIRED
|
||||
)
|
||||
@NotBlank(message = "회원 유형을 입력해주세요.")
|
||||
@Pattern(
|
||||
regexp = "^(T|S|A)$"
|
||||
, message = "회원 유형은 T 또는 S만 가능합니다."
|
||||
)
|
||||
private String userType;
|
||||
|
||||
@AssertTrue(message = "회원 유형과 권한 조합을 확인해주세요.")
|
||||
@Schema(hidden = true)
|
||||
public boolean isValidUserTypeAndRole() {
|
||||
if (userType == null || userRole == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ("A".equals(userType)) {
|
||||
return "A".equals(userRole);
|
||||
}
|
||||
|
||||
if ("T".equals(userType)) {
|
||||
return "CCM".equals(userRole)
|
||||
|| "SCM".equals(userRole)
|
||||
|| "I".equals(userRole);
|
||||
}
|
||||
|
||||
if ("S".equals(userType)) {
|
||||
return "R".equals(userRole)
|
||||
|| "E".equals(userRole);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public AdminMemberAddDto toDto() {
|
||||
AdminMemberAddDto adminMemberAddDto = new AdminMemberAddDto();
|
||||
adminMemberAddDto.setId(id.trim());
|
||||
adminMemberAddDto.setPassword(password);
|
||||
adminMemberAddDto.setUserRole(userRole);
|
||||
adminMemberAddDto.setUserType(userType);
|
||||
return adminMemberAddDto;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.form;
|
||||
|
||||
import com.alist.api.modules.admin.member.dto.AdminMemberDeleteDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
@Schema(description = "관리자 회원 계정 삭제 요청 폼")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminMemberDeleteForm {
|
||||
@Schema(description = "회원 PK 목록. 콤마 구분 문자열", example = "1,2,3", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "삭제할 회원 PK를 입력해주세요.")
|
||||
@Pattern(regexp = "^\\d+(,\\d+)*$", message = "회원 PK 목록은 1,2,3 형식으로 입력해주세요.")
|
||||
private String userIdxList;
|
||||
|
||||
public AdminMemberDeleteDto toDto() {
|
||||
AdminMemberDeleteDto adminMemberDeleteDto = new AdminMemberDeleteDto();
|
||||
adminMemberDeleteDto.setUserIdxList(
|
||||
Arrays.stream(userIdxList.split(","))
|
||||
.map(String::trim)
|
||||
.map(Integer::valueOf)
|
||||
.distinct()
|
||||
.toList()
|
||||
);
|
||||
return adminMemberDeleteDto;
|
||||
}
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.form;
|
||||
|
||||
import com.alist.api.modules.admin.member.dto.AdminMemberDormantModifyDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 회원 휴면 상태 변경 요청 폼")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminMemberDormantModifyForm {
|
||||
@Schema(description = "회원 PK", example = "15", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull(message = "회원 PK는 필수입니다.")
|
||||
private Integer userIdx;
|
||||
|
||||
@Schema(description = "휴면 여부. Y: 휴면, N: 정상", example = "Y", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "휴면 여부는 필수입니다.")
|
||||
@Pattern(regexp = "^[YN]$", message = "휴면 여부는 Y 또는 N만 가능합니다.")
|
||||
private String dormantYn;
|
||||
|
||||
public AdminMemberDormantModifyDto toDto() {
|
||||
AdminMemberDormantModifyDto adminMemberDormantModifyDto = new AdminMemberDormantModifyDto();
|
||||
adminMemberDormantModifyDto.setUserIdx(userIdx);
|
||||
adminMemberDormantModifyDto.setDormantYn(dormantYn);
|
||||
return adminMemberDormantModifyDto;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.form;
|
||||
|
||||
import com.alist.api.common.paging.PageRequest;
|
||||
import com.alist.api.modules.admin.member.dto.AdminMemberListDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 회원 목록 검색 조건")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminMemberListForm extends PageRequest {
|
||||
@Schema(description = "검색어. 아이디, 이메일, 휴대폰 번호를 대상으로 검색합니다.", example = "admin")
|
||||
private String keyword;
|
||||
|
||||
@Schema(description = "회원 권한 코드(CCD/SCM/I/R/E)", example = "SCM")
|
||||
private String userRole;
|
||||
|
||||
@Schema(description = "회원 유형 코드. A: 관리자, T: 교사, S: 학생", example = "T")
|
||||
private String userType;
|
||||
|
||||
@Schema(description = "휴면 여부. N: 정상, Y: 휴면", example = "N")
|
||||
private String dormantYn;
|
||||
|
||||
@Schema(description = "탈퇴 상태. N: 정상, P: 탈퇴대기, Y: 탈퇴완료", example = "N")
|
||||
private String withdrawStatus;
|
||||
|
||||
public AdminMemberListDto toDto() {
|
||||
AdminMemberListDto adminMemberListDto = new AdminMemberListDto();
|
||||
adminMemberListDto.setPage(getPage());
|
||||
adminMemberListDto.setSize(getSize());
|
||||
adminMemberListDto.setKeyword(keyword);
|
||||
adminMemberListDto.setUserRole(userRole);
|
||||
adminMemberListDto.setUserType(userType);
|
||||
adminMemberListDto.setDormantYn(dormantYn);
|
||||
adminMemberListDto.setWithdrawStatus(withdrawStatus);
|
||||
return adminMemberListDto;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.form;
|
||||
|
||||
import com.alist.api.modules.admin.member.dto.AdminMemberModifyDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 회원 수정 요청 폼")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminMemberModifyForm {
|
||||
@Schema(description = "회원 PK", example = "15", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull(message = "회원 PK는 필수입니다.")
|
||||
private Integer userIdx;
|
||||
|
||||
@Schema(description = "이메일", example = "user@example.com")
|
||||
@Email(message = "이메일 형식을 확인해주세요.")
|
||||
private String email;
|
||||
|
||||
@Schema(description = "휴대폰 번호", example = "01012345678")
|
||||
private String hp;
|
||||
|
||||
@Schema(description = "회원 권한 코드(CCD/SCM/I/R/E)", example = "CCM")
|
||||
private String userRole;
|
||||
|
||||
@Schema(description = "회원 유형 코드. A: 관리자, T: 교사, S: 학생", example = "T")
|
||||
private String userType;
|
||||
|
||||
public AdminMemberModifyDto toDto() {
|
||||
AdminMemberModifyDto adminMemberModifyDto = new AdminMemberModifyDto();
|
||||
adminMemberModifyDto.setUserIdx(userIdx);
|
||||
adminMemberModifyDto.setEmail(email);
|
||||
adminMemberModifyDto.setHp(hp);
|
||||
adminMemberModifyDto.setUserRole(userRole);
|
||||
adminMemberModifyDto.setUserType(userType);
|
||||
return adminMemberModifyDto;
|
||||
}
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.form;
|
||||
|
||||
import com.alist.api.modules.admin.member.dto.AdminMemberWithdrawModifyDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 회원 탈퇴 상태 변경 요청 폼")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminMemberWithdrawModifyForm {
|
||||
@Schema(description = "회원 PK", example = "15", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull(message = "회원 PK는 필수입니다.")
|
||||
private Integer userIdx;
|
||||
|
||||
@Schema(description = "탈퇴 상태. N: 정상, P: 탈퇴대기, Y: 탈퇴완료", example = "P", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "탈퇴 상태는 필수입니다.")
|
||||
@Pattern(regexp = "^[NPY]$", message = "탈퇴 상태는 N, P, Y만 가능합니다.")
|
||||
private String withdrawStatus;
|
||||
|
||||
public AdminMemberWithdrawModifyDto toDto() {
|
||||
AdminMemberWithdrawModifyDto adminMemberWithdrawModifyDto = new AdminMemberWithdrawModifyDto();
|
||||
adminMemberWithdrawModifyDto.setUserIdx(userIdx);
|
||||
adminMemberWithdrawModifyDto.setWithdrawStatus(withdrawStatus);
|
||||
return adminMemberWithdrawModifyDto;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.mapper;
|
||||
|
||||
import com.alist.api.modules.admin.member.dto.*;
|
||||
import com.alist.api.modules.admin.member.vo.AdminMemberViewVo;
|
||||
import com.alist.api.modules.admin.member.vo.AdminMemberVo;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface AdminMemberMapper {
|
||||
int selectAdminMemberCount(AdminMemberListDto adminMemberListDto);
|
||||
|
||||
List<AdminMemberVo> selectAdminMemberList(AdminMemberListDto adminMemberListDto);
|
||||
|
||||
AdminMemberViewVo selectAdminMemberView(Integer userIdx);
|
||||
|
||||
int updateAdminMemberModify(AdminMemberModifyDto adminMemberModifyDto);
|
||||
|
||||
int updateAdminMemberDormantModify(AdminMemberDormantModifyDto adminMemberDormantModifyDto);
|
||||
|
||||
int updateAdminMemberWithdrawModify(AdminMemberWithdrawModifyDto adminMemberWithdrawModifyDto);
|
||||
|
||||
int updateAdminMemberDelete(AdminMemberDeleteDto adminMemberDeleteDto);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.service;
|
||||
|
||||
import com.alist.api.modules.admin.member.dto.*;
|
||||
import com.alist.api.modules.admin.member.mapper.AdminMemberMapper;
|
||||
import com.alist.api.modules.admin.member.vo.*;
|
||||
import com.alist.api.modules.front.user.dto.UserDto;
|
||||
import com.alist.api.modules.front.user.service.UserService;
|
||||
import com.alist.api.modules.front.user.vo.UserVo;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class AdminMemberService {
|
||||
private final AdminMemberMapper adminMemberMapper;
|
||||
private final UserService userService;
|
||||
|
||||
public AdminMemberService(AdminMemberMapper adminMemberMapper, UserService userService) {
|
||||
this.adminMemberMapper = adminMemberMapper;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AdminMemberListVo selectAdminMemberList(AdminMemberListDto adminMemberListDto) {
|
||||
int totalCount = adminMemberMapper.selectAdminMemberCount(adminMemberListDto);
|
||||
List<AdminMemberVo> adminMemberList = adminMemberMapper.selectAdminMemberList(adminMemberListDto);
|
||||
|
||||
AdminMemberListVo adminMemberListVo = new AdminMemberListVo();
|
||||
adminMemberListVo.setMemberList(adminMemberList);
|
||||
adminMemberListVo.setPaging(adminMemberListDto, totalCount);
|
||||
|
||||
return adminMemberListVo;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AdminMemberViewVo selectAdminMemberView(Integer userIdx) {
|
||||
return adminMemberMapper.selectAdminMemberView(userIdx);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminMemberAddVo insertAdminMemberAdd(
|
||||
AdminMemberAddDto adminMemberAddDto
|
||||
) {
|
||||
UserDto userDto = new UserDto();
|
||||
userDto.setId(adminMemberAddDto.getId());
|
||||
userDto.setPassword(adminMemberAddDto.getPassword());
|
||||
userDto.setUserRole(adminMemberAddDto.getUserRole());
|
||||
userDto.setUserType(adminMemberAddDto.getUserType());
|
||||
|
||||
UserVo userVo = userService.insertUserAdd(userDto);
|
||||
|
||||
AdminMemberAddVo adminMemberAddVo = new AdminMemberAddVo();
|
||||
adminMemberAddVo.setUserIdx(userVo.getUserIdx());
|
||||
adminMemberAddVo.setId(userVo.getId());
|
||||
adminMemberAddVo.setUserRole(userVo.getUserRole());
|
||||
adminMemberAddVo.setUserType(userVo.getUserType());
|
||||
adminMemberAddVo.setResultCode(userVo.getResultCode());
|
||||
|
||||
return adminMemberAddVo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminMemberModifyVo updateAdminMemberModify(AdminMemberModifyDto adminMemberModifyDto) {
|
||||
int updateCount = adminMemberMapper.updateAdminMemberModify(adminMemberModifyDto);
|
||||
|
||||
AdminMemberModifyVo adminMemberModifyVo = new AdminMemberModifyVo();
|
||||
adminMemberModifyVo.setProcessed(updateCount > 0);
|
||||
return adminMemberModifyVo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminMemberDormantModifyVo updateAdminMemberDormantModify(AdminMemberDormantModifyDto adminMemberDormantModifyDto) {
|
||||
int updateCount = adminMemberMapper.updateAdminMemberDormantModify(adminMemberDormantModifyDto);
|
||||
|
||||
AdminMemberDormantModifyVo adminMemberDormantModifyVo = new AdminMemberDormantModifyVo();
|
||||
adminMemberDormantModifyVo.setProcessed(updateCount > 0);
|
||||
return adminMemberDormantModifyVo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminMemberWithdrawModifyVo updateAdminMemberWithdrawModify(AdminMemberWithdrawModifyDto adminMemberWithdrawModifyDto) {
|
||||
int updateCount = adminMemberMapper.updateAdminMemberWithdrawModify(adminMemberWithdrawModifyDto);
|
||||
|
||||
AdminMemberWithdrawModifyVo adminMemberWithdrawModifyVo = new AdminMemberWithdrawModifyVo();
|
||||
adminMemberWithdrawModifyVo.setProcessed(updateCount > 0);
|
||||
return adminMemberWithdrawModifyVo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminMemberDeleteVo updateAdminMemberDelete(AdminMemberDeleteDto adminMemberDeleteDto) {
|
||||
int updateCount = adminMemberMapper.updateAdminMemberDelete(adminMemberDeleteDto);
|
||||
|
||||
AdminMemberDeleteVo adminMemberDeleteVo = new AdminMemberDeleteVo();
|
||||
adminMemberDeleteVo.setProcessed(updateCount > 0);
|
||||
adminMemberDeleteVo.setRequestedCount(adminMemberDeleteDto.getUserIdxList().size());
|
||||
adminMemberDeleteVo.setDeletedCount(updateCount);
|
||||
return adminMemberDeleteVo;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 회원 등록 응답")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminMemberAddVo {
|
||||
|
||||
@Schema(description = "회원 PK", example = "15")
|
||||
private Integer userIdx;
|
||||
|
||||
@Schema(description = "회원 아이디", example = "teacher01")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "회원 권한 코드", example = "I")
|
||||
private String userRole;
|
||||
|
||||
@Schema(description = "회원 유형 코드. T: 교사, S: 학생", example = "T")
|
||||
private String userType;
|
||||
|
||||
@JsonIgnore
|
||||
@Schema(hidden = true)
|
||||
private int resultCode;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 회원 계정 삭제 응답")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminMemberDeleteVo {
|
||||
@Schema(description = "회원 계정 삭제 여부", example = "true")
|
||||
private boolean processed;
|
||||
|
||||
@Schema(description = "삭제 요청 회원 수", example = "3")
|
||||
private int requestedCount;
|
||||
|
||||
@Schema(description = "실제 삭제 처리 회원 수", example = "3")
|
||||
private int deletedCount;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 회원 휴면 상태 변경 응답")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminMemberDormantModifyVo {
|
||||
@Schema(description = "휴면 상태 변경 여부", example = "true")
|
||||
private boolean processed;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.vo;
|
||||
|
||||
import com.alist.api.common.paging.PageResponse;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "관리자 회원 목록 조회 응답")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminMemberListVo extends PageResponse {
|
||||
@Schema(description = "회원 목록")
|
||||
private List<AdminMemberVo> memberList;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 회원 수정 응답")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminMemberModifyVo {
|
||||
@Schema(description = "회원 수정 여부", example = "true")
|
||||
private boolean processed;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Schema(description = "관리자 회원 상세 조회 응답")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminMemberViewVo {
|
||||
@Schema(description = "회원 PK", example = "15")
|
||||
private Integer userIdx;
|
||||
|
||||
@Schema(description = "회원 토큰 PK", example = "15")
|
||||
private Integer userTokenIdx;
|
||||
|
||||
@Schema(description = "회원 아이디", example = "admin")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "이메일", example = "user@example.com")
|
||||
private String email;
|
||||
|
||||
@Schema(description = "휴대폰 번호", example = "01012345678")
|
||||
private String hp;
|
||||
|
||||
@Schema(description = "회원 권한 코드(CCD/SCM/I/R/E)", example = "SCM")
|
||||
private String userRole;
|
||||
|
||||
@Schema(description = "회원 유형 코드. A: 관리자, T: 교사, S: 학생", example = "T")
|
||||
private String userType;
|
||||
|
||||
@Schema(description = "휴면 여부. N: 정상, Y: 휴면", example = "N")
|
||||
private String dormantYn;
|
||||
|
||||
@Schema(description = "휴면 처리일")
|
||||
private LocalDateTime dormantAt;
|
||||
|
||||
@Schema(description = "탈퇴 상태. N: 정상, P: 탈퇴대기, Y: 탈퇴완료", example = "N")
|
||||
private String withdrawStatus;
|
||||
|
||||
@Schema(description = "탈퇴 신청일")
|
||||
private LocalDateTime withdrawAt;
|
||||
|
||||
@Schema(description = "가입일")
|
||||
private LocalDateTime createAt;
|
||||
|
||||
@Schema(description = "수정일")
|
||||
private LocalDateTime updateAt;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Schema(description = "관리자 회원 목록 항목")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminMemberVo {
|
||||
@Schema(description = "회원 PK", example = "15")
|
||||
private Integer userIdx;
|
||||
|
||||
@Schema(description = "회원 토큰 PK", example = "15")
|
||||
private Integer userTokenIdx;
|
||||
|
||||
@Schema(description = "회원 아이디", example = "admin")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "이메일", example = "user@example.com")
|
||||
private String email;
|
||||
|
||||
@Schema(description = "휴대폰 번호", example = "01012345678")
|
||||
private String hp;
|
||||
|
||||
@Schema(description = "회원 권한 코드(CCD/SCM/I/R/E)", example = "SCM")
|
||||
private String userRole;
|
||||
|
||||
@Schema(description = "회원 유형 코드. A: 관리자, T: 교사, S: 학생", example = "T")
|
||||
private String userType;
|
||||
|
||||
@Schema(description = "휴면 여부. N: 정상, Y: 휴면", example = "N")
|
||||
private String dormantYn;
|
||||
|
||||
@Schema(description = "휴면 처리일")
|
||||
private LocalDateTime dormantAt;
|
||||
|
||||
@Schema(description = "탈퇴 상태. N: 정상, P: 탈퇴대기, Y: 탈퇴완료", example = "N")
|
||||
private String withdrawStatus;
|
||||
|
||||
@Schema(description = "탈퇴 신청일")
|
||||
private LocalDateTime withdrawAt;
|
||||
|
||||
@Schema(description = "가입일")
|
||||
private LocalDateTime createAt;
|
||||
|
||||
@Schema(description = "수정일")
|
||||
private LocalDateTime updateAt;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.alist.api.modules.admin.member.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 회원 탈퇴 상태 변경 응답")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminMemberWithdrawModifyVo {
|
||||
@Schema(description = "탈퇴 상태 변경 여부", example = "true")
|
||||
private boolean processed;
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package com.alist.api.modules.admin.notice;
|
||||
|
||||
import com.alist.api.common.response.ApiResponse;
|
||||
import com.alist.api.common.response.ApiResponseCode;
|
||||
import com.alist.api.modules.admin.notice.form.AdminNoticeAddForm;
|
||||
import com.alist.api.modules.admin.notice.form.AdminNoticeDeleteForm;
|
||||
import com.alist.api.modules.admin.notice.form.AdminNoticeListForm;
|
||||
import com.alist.api.modules.admin.notice.form.AdminNoticeModifyForm;
|
||||
import com.alist.api.modules.admin.notice.service.AdminNoticeService;
|
||||
import com.alist.api.modules.admin.notice.vo.AdminNoticeAddVo;
|
||||
import com.alist.api.modules.admin.notice.vo.AdminNoticeDeleteVo;
|
||||
import com.alist.api.modules.admin.notice.vo.AdminNoticeListVo;
|
||||
import com.alist.api.modules.admin.notice.vo.AdminNoticeModifyVo;
|
||||
import com.alist.api.modules.admin.notice.vo.AdminNoticeViewVo;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@Tag(name = "505. 공지사항 관리", description = "관리자 공지사항 관리 API")
|
||||
@RestController
|
||||
@RequestMapping("/admin/notice")
|
||||
public class AdminNoticeController {
|
||||
private final AdminNoticeService adminNoticeService;
|
||||
|
||||
public AdminNoticeController(AdminNoticeService adminNoticeService) {
|
||||
this.adminNoticeService = adminNoticeService;
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "공지사항 목록 조회"
|
||||
, description = "관리자가 공지사항 목록을 검색 조건과 페이징으로 조회합니다."
|
||||
)
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<ApiResponse<AdminNoticeListVo>> adminNoticeList(
|
||||
@ModelAttribute AdminNoticeListForm adminNoticeListForm
|
||||
) {
|
||||
AdminNoticeListVo adminNoticeListVo = adminNoticeService.selectAdminNoticeList(adminNoticeListForm.toDto());
|
||||
|
||||
if (adminNoticeListVo.getNoticeList() == null || adminNoticeListVo.getNoticeList().isEmpty()) {
|
||||
return ApiResponse.entity(adminNoticeListVo, ApiResponseCode.CODE_2003);
|
||||
}
|
||||
|
||||
return ApiResponse.entity(adminNoticeListVo, ApiResponseCode.CODE_2001, "공지사항");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "공지사항 상세 조회"
|
||||
, description = "관리자가 공지사항 PK 기준으로 상세 정보를 조회합니다."
|
||||
)
|
||||
@GetMapping("/view")
|
||||
public ResponseEntity<ApiResponse<AdminNoticeViewVo>> adminNoticeView(
|
||||
@RequestParam Long noticeIdx
|
||||
) {
|
||||
AdminNoticeViewVo adminNoticeViewVo = adminNoticeService.selectAdminNoticeView(noticeIdx);
|
||||
|
||||
if (adminNoticeViewVo == null) {
|
||||
return ApiResponse.entity((AdminNoticeViewVo) null, ApiResponseCode.CODE_2003);
|
||||
}
|
||||
|
||||
return ApiResponse.entity(adminNoticeViewVo, ApiResponseCode.CODE_2001, "공지사항");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "공지사항 등록"
|
||||
, description = "관리자가 공지사항을 등록합니다. 캠퍼스 대상 공지는 campusIdxList를 함께 저장합니다."
|
||||
)
|
||||
@PostMapping("/add")
|
||||
public ResponseEntity<ApiResponse<AdminNoticeAddVo>> adminNoticeAdd(
|
||||
@Valid @RequestBody AdminNoticeAddForm adminNoticeAddForm
|
||||
) {
|
||||
AdminNoticeAddVo adminNoticeAddVo = adminNoticeService.insertAdminNoticeAdd(adminNoticeAddForm.toDto());
|
||||
|
||||
if (adminNoticeAddVo.getResultCode() == 401) {
|
||||
return ApiResponse.entity(adminNoticeAddVo, ApiResponseCode.CODE_401);
|
||||
}
|
||||
|
||||
if (adminNoticeAddVo.getResultCode() == 4001) {
|
||||
return ApiResponse.entity(adminNoticeAddVo, ApiResponseCode.CODE_4001);
|
||||
}
|
||||
|
||||
return ApiResponse.entity(adminNoticeAddVo, ApiResponseCode.CODE_2002, "공지사항");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "공지사항 수정"
|
||||
, description = "관리자가 공지사항 PK 기준으로 제목, 내용, 노출 범위, 첨부파일 정보를 수정합니다."
|
||||
)
|
||||
@PutMapping("/modify")
|
||||
public ResponseEntity<ApiResponse<AdminNoticeModifyVo>> adminNoticeModify(
|
||||
@Valid @RequestBody AdminNoticeModifyForm adminNoticeModifyForm
|
||||
) {
|
||||
AdminNoticeModifyVo adminNoticeModifyVo = adminNoticeService.updateAdminNoticeModify(adminNoticeModifyForm.toDto());
|
||||
|
||||
if (adminNoticeModifyVo.getResultCode() == 401) {
|
||||
return ApiResponse.entity(adminNoticeModifyVo, ApiResponseCode.CODE_401);
|
||||
}
|
||||
|
||||
if (adminNoticeModifyVo.getResultCode() == 4001) {
|
||||
return ApiResponse.entity(adminNoticeModifyVo, ApiResponseCode.CODE_4001);
|
||||
}
|
||||
|
||||
if (!adminNoticeModifyVo.isProcessed()) {
|
||||
return ApiResponse.entity(adminNoticeModifyVo, ApiResponseCode.CODE_2003);
|
||||
}
|
||||
|
||||
return ApiResponse.entity(adminNoticeModifyVo, ApiResponseCode.CODE_2005, "공지사항 수정");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "공지사항 삭제"
|
||||
, description = "관리자가 공지사항 PK 기준으로 삭제 처리합니다. 실제 삭제가 아닌 DEL_YN 변경 방식입니다."
|
||||
)
|
||||
@PatchMapping("/delete")
|
||||
public ResponseEntity<ApiResponse<AdminNoticeDeleteVo>> adminNoticeDelete(
|
||||
@Valid @RequestBody AdminNoticeDeleteForm adminNoticeDeleteForm
|
||||
) {
|
||||
AdminNoticeDeleteVo adminNoticeDeleteVo = adminNoticeService.updateAdminNoticeDelete(adminNoticeDeleteForm.toDto());
|
||||
|
||||
if (adminNoticeDeleteVo.getResultCode() == 401) {
|
||||
return ApiResponse.entity(adminNoticeDeleteVo, ApiResponseCode.CODE_401);
|
||||
}
|
||||
|
||||
if (!adminNoticeDeleteVo.isProcessed()) {
|
||||
return ApiResponse.entity(adminNoticeDeleteVo, ApiResponseCode.CODE_2003);
|
||||
}
|
||||
|
||||
return ApiResponse.entity(adminNoticeDeleteVo, ApiResponseCode.CODE_2005, "공지사항 삭제");
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.alist.api.modules.admin.notice.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminNoticeAddDto {
|
||||
private Long noticeIdx;
|
||||
private String noticeType;
|
||||
private String targetScope;
|
||||
private List<Long> campusIdxList;
|
||||
private String title;
|
||||
private String content;
|
||||
private String pinYn;
|
||||
private String startDate;
|
||||
private String endDate;
|
||||
private String useYn;
|
||||
private List<AdminNoticeFileDto> fileList;
|
||||
private Integer createMember;
|
||||
private Integer updateMember;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.alist.api.modules.admin.notice.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminNoticeCampusDto {
|
||||
private Long noticeIdx;
|
||||
private Long campusIdx;
|
||||
private Integer createMember;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.alist.api.modules.admin.notice.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminNoticeDeleteDto {
|
||||
private Long noticeIdx;
|
||||
private Integer updateMember;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.alist.api.modules.admin.notice.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminNoticeFileDto {
|
||||
private Long noticeIdx;
|
||||
private String fileOriginalName;
|
||||
private String fileSaveName;
|
||||
private String filePath;
|
||||
private Long fileSize;
|
||||
private String fileExt;
|
||||
private Integer sortOrder;
|
||||
private Integer createMember;
|
||||
private Integer updateMember;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.alist.api.modules.admin.notice.dto;
|
||||
|
||||
import com.alist.api.common.paging.PageRequest;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminNoticeListDto extends PageRequest {
|
||||
private String keyword;
|
||||
private String noticeType;
|
||||
private String targetScope;
|
||||
private Long campusIdx;
|
||||
private String pinYn;
|
||||
private String useYn;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.alist.api.modules.admin.notice.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminNoticeModifyDto {
|
||||
private Long noticeIdx;
|
||||
private String noticeType;
|
||||
private String targetScope;
|
||||
private List<Long> campusIdxList;
|
||||
private String title;
|
||||
private String content;
|
||||
private String pinYn;
|
||||
private String startDate;
|
||||
private String endDate;
|
||||
private String useYn;
|
||||
private List<AdminNoticeFileDto> fileList;
|
||||
private List<Long> deleteFileIdxList;
|
||||
private Integer updateMember;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package com.alist.api.modules.admin.notice.form;
|
||||
|
||||
import com.alist.api.modules.admin.notice.dto.AdminNoticeAddDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "관리자 공지사항 등록 요청")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminNoticeAddForm {
|
||||
@NotBlank
|
||||
@Size(max = 30)
|
||||
@Schema(description = "공지 유형. GENERAL 일반, SYSTEM 시스템, EVENT 이벤트", example = "GENERAL", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String noticeType;
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 30)
|
||||
@Schema(description = "노출 범위. ALL 전체, ADMIN 관리자, TEACHER 교사, STUDENT 학생, CAMPUS 캠퍼스", example = "CAMPUS", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String targetScope;
|
||||
|
||||
@Schema(description = "캠퍼스 IDX 목록. targetScope가 CAMPUS일 때 사용", example = "[1, 2, 3]")
|
||||
private List<Long> campusIdxList;
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 200)
|
||||
@Schema(description = "제목", example = "서비스 점검 안내", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String title;
|
||||
|
||||
@NotBlank
|
||||
@Schema(description = "내용", example = "서비스 점검이 예정되어 있습니다.", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String content;
|
||||
|
||||
@Pattern(regexp = "Y|N")
|
||||
@Schema(description = "상단 고정 여부. Y: 고정, N: 미고정. 미입력 시 N", example = "N")
|
||||
private String pinYn;
|
||||
|
||||
@Schema(description = "노출 시작일", example = "2026-07-01 00:00:00")
|
||||
private String startDate;
|
||||
|
||||
@Schema(description = "노출 종료일", example = "2026-07-31 23:59:59")
|
||||
private String endDate;
|
||||
|
||||
@Pattern(regexp = "Y|N")
|
||||
@Schema(description = "사용 여부. Y: 사용, N: 미사용. 미입력 시 Y", example = "Y")
|
||||
private String useYn;
|
||||
|
||||
@Valid
|
||||
@Schema(description = "첨부파일 목록")
|
||||
private List<AdminNoticeFileForm> fileList;
|
||||
|
||||
public AdminNoticeAddDto toDto() {
|
||||
AdminNoticeAddDto adminNoticeAddDto = new AdminNoticeAddDto();
|
||||
adminNoticeAddDto.setNoticeType(noticeType.trim());
|
||||
adminNoticeAddDto.setTargetScope(targetScope.trim());
|
||||
adminNoticeAddDto.setCampusIdxList(campusIdxList);
|
||||
adminNoticeAddDto.setTitle(title.trim());
|
||||
adminNoticeAddDto.setContent(content.trim());
|
||||
adminNoticeAddDto.setPinYn(pinYn == null || pinYn.trim().isEmpty() ? "N" : pinYn.trim());
|
||||
adminNoticeAddDto.setStartDate(startDate == null || startDate.trim().isEmpty() ? null : startDate.trim());
|
||||
adminNoticeAddDto.setEndDate(endDate == null || endDate.trim().isEmpty() ? null : endDate.trim());
|
||||
adminNoticeAddDto.setUseYn(useYn == null || useYn.trim().isEmpty() ? "Y" : useYn.trim());
|
||||
adminNoticeAddDto.setFileList(fileList == null ? null : fileList.stream().map(AdminNoticeFileForm::toDto).toList());
|
||||
return adminNoticeAddDto;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.alist.api.modules.admin.notice.form;
|
||||
|
||||
import com.alist.api.modules.admin.notice.dto.AdminNoticeDeleteDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 공지사항 삭제 요청")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminNoticeDeleteForm {
|
||||
@NotNull
|
||||
@Schema(description = "공지사항 PK", example = "1", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long noticeIdx;
|
||||
|
||||
public AdminNoticeDeleteDto toDto() {
|
||||
AdminNoticeDeleteDto adminNoticeDeleteDto = new AdminNoticeDeleteDto();
|
||||
adminNoticeDeleteDto.setNoticeIdx(noticeIdx);
|
||||
return adminNoticeDeleteDto;
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package com.alist.api.modules.admin.notice.form;
|
||||
|
||||
import com.alist.api.modules.admin.notice.dto.AdminNoticeFileDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 공지사항 첨부파일 요청")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminNoticeFileForm {
|
||||
@NotBlank
|
||||
@Size(max = 255)
|
||||
@Schema(description = "원본 파일명", example = "notice.pdf", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String fileOriginalName;
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 255)
|
||||
@Schema(description = "저장 파일명", example = "20260701120000_notice.pdf", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String fileSaveName;
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 500)
|
||||
@Schema(description = "파일 경로", example = "/uploads/notice/2026/07/01/20260701120000_notice.pdf", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String filePath;
|
||||
|
||||
@Min(0)
|
||||
@Schema(description = "파일 크기", example = "2048")
|
||||
private Long fileSize;
|
||||
|
||||
@Size(max = 20)
|
||||
@Schema(description = "파일 확장자", example = "pdf")
|
||||
private String fileExt;
|
||||
|
||||
@Min(0)
|
||||
@Schema(description = "정렬 순서. 미입력 시 0", example = "0")
|
||||
private Integer sortOrder;
|
||||
|
||||
public AdminNoticeFileDto toDto() {
|
||||
AdminNoticeFileDto adminNoticeFileDto = new AdminNoticeFileDto();
|
||||
adminNoticeFileDto.setFileOriginalName(fileOriginalName.trim());
|
||||
adminNoticeFileDto.setFileSaveName(fileSaveName.trim());
|
||||
adminNoticeFileDto.setFilePath(filePath.trim());
|
||||
adminNoticeFileDto.setFileSize(fileSize);
|
||||
adminNoticeFileDto.setFileExt(fileExt == null ? null : fileExt.trim());
|
||||
adminNoticeFileDto.setSortOrder(sortOrder == null ? 0 : sortOrder);
|
||||
return adminNoticeFileDto;
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package com.alist.api.modules.admin.notice.form;
|
||||
|
||||
import com.alist.api.common.paging.PageRequest;
|
||||
import com.alist.api.modules.admin.notice.dto.AdminNoticeListDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 공지사항 목록 검색 조건")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminNoticeListForm extends PageRequest {
|
||||
@Schema(description = "검색어. 제목, 내용을 대상으로 검색합니다.", example = "점검")
|
||||
private String keyword;
|
||||
|
||||
@Schema(description = "공지 유형. GENERAL 일반, SYSTEM 시스템, EVENT 이벤트", example = "GENERAL")
|
||||
private String noticeType;
|
||||
|
||||
@Schema(description = "노출 범위. ALL 전체, ADMIN 관리자, TEACHER 교사, STUDENT 학생, CAMPUS 캠퍼스", example = "CAMPUS")
|
||||
private String targetScope;
|
||||
|
||||
@Schema(description = "캠퍼스 IDX. 특정 캠퍼스 대상 공지를 검색할 때 사용", example = "1")
|
||||
private Long campusIdx;
|
||||
|
||||
@Schema(description = "상단 고정 여부. Y: 고정, N: 미고정", example = "Y")
|
||||
private String pinYn;
|
||||
|
||||
@Schema(description = "사용 여부. Y: 사용, N: 미사용", example = "Y")
|
||||
private String useYn;
|
||||
|
||||
public AdminNoticeListDto toDto() {
|
||||
AdminNoticeListDto adminNoticeListDto = new AdminNoticeListDto();
|
||||
adminNoticeListDto.setPage(getPage());
|
||||
adminNoticeListDto.setSize(getSize());
|
||||
adminNoticeListDto.setKeyword(keyword == null ? null : keyword.trim());
|
||||
adminNoticeListDto.setNoticeType(noticeType == null ? null : noticeType.trim());
|
||||
adminNoticeListDto.setTargetScope(targetScope == null ? null : targetScope.trim());
|
||||
adminNoticeListDto.setCampusIdx(campusIdx);
|
||||
adminNoticeListDto.setPinYn(pinYn == null ? null : pinYn.trim());
|
||||
adminNoticeListDto.setUseYn(useYn == null ? null : useYn.trim());
|
||||
return adminNoticeListDto;
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package com.alist.api.modules.admin.notice.form;
|
||||
|
||||
import com.alist.api.modules.admin.notice.dto.AdminNoticeModifyDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "관리자 공지사항 수정 요청")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminNoticeModifyForm {
|
||||
@NotNull
|
||||
@Schema(description = "공지사항 PK", example = "1", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long noticeIdx;
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 30)
|
||||
@Schema(description = "공지 유형. GENERAL 일반, SYSTEM 시스템, EVENT 이벤트", example = "GENERAL", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String noticeType;
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 30)
|
||||
@Schema(description = "노출 범위. ALL 전체, ADMIN 관리자, TEACHER 교사, STUDENT 학생, CAMPUS 캠퍼스", example = "CAMPUS", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String targetScope;
|
||||
|
||||
@Schema(description = "캠퍼스 IDX 목록. targetScope가 CAMPUS일 때 사용", example = "[1, 2, 3]")
|
||||
private List<Long> campusIdxList;
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 200)
|
||||
@Schema(description = "제목", example = "서비스 점검 안내", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String title;
|
||||
|
||||
@NotBlank
|
||||
@Schema(description = "내용", example = "서비스 점검이 예정되어 있습니다.", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String content;
|
||||
|
||||
@Pattern(regexp = "Y|N")
|
||||
@Schema(description = "상단 고정 여부. Y: 고정, N: 미고정. 미입력 시 N", example = "N")
|
||||
private String pinYn;
|
||||
|
||||
@Schema(description = "노출 시작일", example = "2026-07-01 00:00:00")
|
||||
private String startDate;
|
||||
|
||||
@Schema(description = "노출 종료일", example = "2026-07-31 23:59:59")
|
||||
private String endDate;
|
||||
|
||||
@Pattern(regexp = "Y|N")
|
||||
@Schema(description = "사용 여부. Y: 사용, N: 미사용. 미입력 시 Y", example = "Y")
|
||||
private String useYn;
|
||||
|
||||
@Valid
|
||||
@Schema(description = "신규 첨부파일 목록. 기존 첨부파일은 유지되고 전달된 파일만 추가됩니다.")
|
||||
private List<AdminNoticeFileForm> fileList;
|
||||
|
||||
@Schema(description = "삭제할 기존 첨부파일 PK 목록", example = "[1, 2]")
|
||||
private List<Long> deleteFileIdxList;
|
||||
|
||||
public AdminNoticeModifyDto toDto() {
|
||||
AdminNoticeModifyDto adminNoticeModifyDto = new AdminNoticeModifyDto();
|
||||
adminNoticeModifyDto.setNoticeIdx(noticeIdx);
|
||||
adminNoticeModifyDto.setNoticeType(noticeType.trim());
|
||||
adminNoticeModifyDto.setTargetScope(targetScope.trim());
|
||||
adminNoticeModifyDto.setCampusIdxList(campusIdxList);
|
||||
adminNoticeModifyDto.setTitle(title.trim());
|
||||
adminNoticeModifyDto.setContent(content.trim());
|
||||
adminNoticeModifyDto.setPinYn(pinYn == null || pinYn.trim().isEmpty() ? "N" : pinYn.trim());
|
||||
adminNoticeModifyDto.setStartDate(startDate == null || startDate.trim().isEmpty() ? null : startDate.trim());
|
||||
adminNoticeModifyDto.setEndDate(endDate == null || endDate.trim().isEmpty() ? null : endDate.trim());
|
||||
adminNoticeModifyDto.setUseYn(useYn == null || useYn.trim().isEmpty() ? "Y" : useYn.trim());
|
||||
adminNoticeModifyDto.setFileList(fileList == null ? null : fileList.stream().map(AdminNoticeFileForm::toDto).toList());
|
||||
adminNoticeModifyDto.setDeleteFileIdxList(deleteFileIdxList);
|
||||
return adminNoticeModifyDto;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package com.alist.api.modules.admin.notice.mapper;
|
||||
|
||||
import com.alist.api.modules.admin.notice.dto.AdminNoticeAddDto;
|
||||
import com.alist.api.modules.admin.notice.dto.AdminNoticeCampusDto;
|
||||
import com.alist.api.modules.admin.notice.dto.AdminNoticeDeleteDto;
|
||||
import com.alist.api.modules.admin.notice.dto.AdminNoticeFileDto;
|
||||
import com.alist.api.modules.admin.notice.dto.AdminNoticeListDto;
|
||||
import com.alist.api.modules.admin.notice.dto.AdminNoticeModifyDto;
|
||||
import com.alist.api.modules.admin.notice.vo.AdminNoticeFileVo;
|
||||
import com.alist.api.modules.admin.notice.vo.AdminNoticeViewVo;
|
||||
import com.alist.api.modules.admin.notice.vo.AdminNoticeVo;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface AdminNoticeMapper {
|
||||
int selectAdminNoticeCount(AdminNoticeListDto adminNoticeListDto);
|
||||
|
||||
List<AdminNoticeVo> selectAdminNoticeList(AdminNoticeListDto adminNoticeListDto);
|
||||
|
||||
AdminNoticeViewVo selectAdminNoticeView(Long noticeIdx);
|
||||
|
||||
List<Long> selectAdminNoticeCampusIdxList(Long noticeIdx);
|
||||
|
||||
List<AdminNoticeFileVo> selectAdminNoticeFileList(Long noticeIdx);
|
||||
|
||||
int insertAdminNoticeAdd(AdminNoticeAddDto adminNoticeAddDto);
|
||||
|
||||
int insertAdminNoticeCampusAdd(AdminNoticeCampusDto adminNoticeCampusDto);
|
||||
|
||||
int insertAdminNoticeFileAdd(AdminNoticeFileDto adminNoticeFileDto);
|
||||
|
||||
int updateAdminNoticeModify(AdminNoticeModifyDto adminNoticeModifyDto);
|
||||
|
||||
int updateAdminNoticeDelete(AdminNoticeDeleteDto adminNoticeDeleteDto);
|
||||
|
||||
int deleteAdminNoticeCampusByNoticeIdx(Long noticeIdx);
|
||||
|
||||
int updateAdminNoticeFileDeleteByNoticeIdx(@Param("noticeIdx") Long noticeIdx, @Param("updateMember") Integer updateMember);
|
||||
|
||||
int updateAdminNoticeFileDeleteByNoticeFileIdxList(
|
||||
@Param("noticeIdx") Long noticeIdx
|
||||
, @Param("noticeFileIdxList") List<Long> noticeFileIdxList
|
||||
, @Param("updateMember") Integer updateMember
|
||||
);
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
package com.alist.api.modules.admin.notice.service;
|
||||
|
||||
import com.alist.api.common.utils.SecurityUtil;
|
||||
import com.alist.api.modules.admin.auth.mapper.AdminAuthMapper;
|
||||
import com.alist.api.modules.admin.auth.vo.AdminAuthLoginVo;
|
||||
import com.alist.api.modules.admin.notice.dto.AdminNoticeAddDto;
|
||||
import com.alist.api.modules.admin.notice.dto.AdminNoticeCampusDto;
|
||||
import com.alist.api.modules.admin.notice.dto.AdminNoticeDeleteDto;
|
||||
import com.alist.api.modules.admin.notice.dto.AdminNoticeFileDto;
|
||||
import com.alist.api.modules.admin.notice.dto.AdminNoticeListDto;
|
||||
import com.alist.api.modules.admin.notice.dto.AdminNoticeModifyDto;
|
||||
import com.alist.api.modules.admin.notice.mapper.AdminNoticeMapper;
|
||||
import com.alist.api.modules.admin.notice.vo.AdminNoticeAddVo;
|
||||
import com.alist.api.modules.admin.notice.vo.AdminNoticeDeleteVo;
|
||||
import com.alist.api.modules.admin.notice.vo.AdminNoticeFileVo;
|
||||
import com.alist.api.modules.admin.notice.vo.AdminNoticeListVo;
|
||||
import com.alist.api.modules.admin.notice.vo.AdminNoticeModifyVo;
|
||||
import com.alist.api.modules.admin.notice.vo.AdminNoticeViewVo;
|
||||
import com.alist.api.modules.admin.notice.vo.AdminNoticeVo;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class AdminNoticeService {
|
||||
private final AdminNoticeMapper adminNoticeMapper;
|
||||
private final AdminAuthMapper adminAuthMapper;
|
||||
|
||||
public AdminNoticeService(AdminNoticeMapper adminNoticeMapper, AdminAuthMapper adminAuthMapper) {
|
||||
this.adminNoticeMapper = adminNoticeMapper;
|
||||
this.adminAuthMapper = adminAuthMapper;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AdminNoticeListVo selectAdminNoticeList(AdminNoticeListDto adminNoticeListDto) {
|
||||
int totalCount = adminNoticeMapper.selectAdminNoticeCount(adminNoticeListDto);
|
||||
List<AdminNoticeVo> noticeList = adminNoticeMapper.selectAdminNoticeList(adminNoticeListDto);
|
||||
|
||||
AdminNoticeListVo adminNoticeListVo = new AdminNoticeListVo();
|
||||
adminNoticeListVo.setNoticeList(noticeList);
|
||||
adminNoticeListVo.setPaging(adminNoticeListDto, totalCount);
|
||||
|
||||
return adminNoticeListVo;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AdminNoticeViewVo selectAdminNoticeView(Long noticeIdx) {
|
||||
AdminNoticeViewVo adminNoticeViewVo = adminNoticeMapper.selectAdminNoticeView(noticeIdx);
|
||||
|
||||
if (adminNoticeViewVo == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Long> campusIdxList = adminNoticeMapper.selectAdminNoticeCampusIdxList(noticeIdx);
|
||||
List<AdminNoticeFileVo> fileList = adminNoticeMapper.selectAdminNoticeFileList(noticeIdx);
|
||||
adminNoticeViewVo.setCampusIdxList(campusIdxList);
|
||||
adminNoticeViewVo.setFileList(fileList);
|
||||
|
||||
return adminNoticeViewVo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminNoticeAddVo insertAdminNoticeAdd(AdminNoticeAddDto adminNoticeAddDto) {
|
||||
AdminNoticeAddVo adminNoticeAddVo = new AdminNoticeAddVo();
|
||||
Integer adminUserIdx = getLoginAdminUserIdx();
|
||||
|
||||
if (adminUserIdx == null) {
|
||||
adminNoticeAddVo.setResultCode(401);
|
||||
adminNoticeAddVo.setProcessed(false);
|
||||
return adminNoticeAddVo;
|
||||
}
|
||||
|
||||
if (!isValidCampusScope(adminNoticeAddDto.getTargetScope(), adminNoticeAddDto.getCampusIdxList())) {
|
||||
adminNoticeAddVo.setResultCode(4001);
|
||||
adminNoticeAddVo.setProcessed(false);
|
||||
return adminNoticeAddVo;
|
||||
}
|
||||
|
||||
adminNoticeAddDto.setCreateMember(adminUserIdx);
|
||||
adminNoticeAddDto.setUpdateMember(adminUserIdx);
|
||||
|
||||
int insertCount = adminNoticeMapper.insertAdminNoticeAdd(adminNoticeAddDto);
|
||||
|
||||
insertCampusList(adminNoticeAddDto.getNoticeIdx(), adminNoticeAddDto.getTargetScope(), adminNoticeAddDto.getCampusIdxList(), adminUserIdx);
|
||||
insertFileList(adminNoticeAddDto.getNoticeIdx(), adminNoticeAddDto.getFileList(), adminUserIdx);
|
||||
|
||||
adminNoticeAddVo.setResultCode(2002);
|
||||
adminNoticeAddVo.setProcessed(insertCount > 0);
|
||||
adminNoticeAddVo.setNoticeIdx(adminNoticeAddDto.getNoticeIdx());
|
||||
|
||||
return adminNoticeAddVo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminNoticeModifyVo updateAdminNoticeModify(AdminNoticeModifyDto adminNoticeModifyDto) {
|
||||
AdminNoticeModifyVo adminNoticeModifyVo = new AdminNoticeModifyVo();
|
||||
Integer adminUserIdx = getLoginAdminUserIdx();
|
||||
|
||||
if (adminUserIdx == null) {
|
||||
adminNoticeModifyVo.setResultCode(401);
|
||||
adminNoticeModifyVo.setProcessed(false);
|
||||
return adminNoticeModifyVo;
|
||||
}
|
||||
|
||||
if (!isValidCampusScope(adminNoticeModifyDto.getTargetScope(), adminNoticeModifyDto.getCampusIdxList())) {
|
||||
adminNoticeModifyVo.setResultCode(4001);
|
||||
adminNoticeModifyVo.setProcessed(false);
|
||||
return adminNoticeModifyVo;
|
||||
}
|
||||
|
||||
adminNoticeModifyDto.setUpdateMember(adminUserIdx);
|
||||
|
||||
int updateCount = adminNoticeMapper.updateAdminNoticeModify(adminNoticeModifyDto);
|
||||
|
||||
if (updateCount > 0) {
|
||||
adminNoticeMapper.deleteAdminNoticeCampusByNoticeIdx(adminNoticeModifyDto.getNoticeIdx());
|
||||
deleteFileList(adminNoticeModifyDto.getNoticeIdx(), adminNoticeModifyDto.getDeleteFileIdxList(), adminUserIdx);
|
||||
|
||||
insertCampusList(adminNoticeModifyDto.getNoticeIdx(), adminNoticeModifyDto.getTargetScope(), adminNoticeModifyDto.getCampusIdxList(), adminUserIdx);
|
||||
insertFileList(adminNoticeModifyDto.getNoticeIdx(), adminNoticeModifyDto.getFileList(), adminUserIdx);
|
||||
}
|
||||
|
||||
adminNoticeModifyVo.setResultCode(2005);
|
||||
adminNoticeModifyVo.setProcessed(updateCount > 0);
|
||||
|
||||
return adminNoticeModifyVo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminNoticeDeleteVo updateAdminNoticeDelete(AdminNoticeDeleteDto adminNoticeDeleteDto) {
|
||||
AdminNoticeDeleteVo adminNoticeDeleteVo = new AdminNoticeDeleteVo();
|
||||
Integer adminUserIdx = getLoginAdminUserIdx();
|
||||
|
||||
if (adminUserIdx == null) {
|
||||
adminNoticeDeleteVo.setResultCode(401);
|
||||
adminNoticeDeleteVo.setProcessed(false);
|
||||
return adminNoticeDeleteVo;
|
||||
}
|
||||
|
||||
adminNoticeDeleteDto.setUpdateMember(adminUserIdx);
|
||||
|
||||
int updateCount = adminNoticeMapper.updateAdminNoticeDelete(adminNoticeDeleteDto);
|
||||
|
||||
if (updateCount > 0) {
|
||||
adminNoticeMapper.updateAdminNoticeFileDeleteByNoticeIdx(adminNoticeDeleteDto.getNoticeIdx(), adminUserIdx);
|
||||
}
|
||||
|
||||
adminNoticeDeleteVo.setResultCode(2005);
|
||||
adminNoticeDeleteVo.setProcessed(updateCount > 0);
|
||||
|
||||
return adminNoticeDeleteVo;
|
||||
}
|
||||
|
||||
private Integer getLoginAdminUserIdx() {
|
||||
Integer loginUserTokenIdx = SecurityUtil.getLoginUserTokenIdx();
|
||||
|
||||
if (loginUserTokenIdx == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
AdminAuthLoginVo adminAuthLoginVo = adminAuthMapper.selectAdminTokenByUserTokenIdx(loginUserTokenIdx);
|
||||
|
||||
if (adminAuthLoginVo == null || adminAuthLoginVo.getUserIdx() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return adminAuthLoginVo.getUserIdx();
|
||||
}
|
||||
|
||||
private boolean isValidCampusScope(String targetScope, List<Long> campusIdxList) {
|
||||
if (!"CAMPUS".equals(targetScope)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return campusIdxList != null && !campusIdxList.isEmpty();
|
||||
}
|
||||
|
||||
private void insertCampusList(Long noticeIdx, String targetScope, List<Long> campusIdxList, Integer adminUserIdx) {
|
||||
if (!"CAMPUS".equals(targetScope) || campusIdxList == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (Long campusIdx : campusIdxList) {
|
||||
AdminNoticeCampusDto adminNoticeCampusDto = new AdminNoticeCampusDto();
|
||||
adminNoticeCampusDto.setNoticeIdx(noticeIdx);
|
||||
adminNoticeCampusDto.setCampusIdx(campusIdx);
|
||||
adminNoticeCampusDto.setCreateMember(adminUserIdx);
|
||||
adminNoticeMapper.insertAdminNoticeCampusAdd(adminNoticeCampusDto);
|
||||
}
|
||||
}
|
||||
|
||||
private void insertFileList(Long noticeIdx, List<AdminNoticeFileDto> fileList, Integer adminUserIdx) {
|
||||
if (fileList == null || fileList.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (AdminNoticeFileDto adminNoticeFileDto : fileList) {
|
||||
adminNoticeFileDto.setNoticeIdx(noticeIdx);
|
||||
adminNoticeFileDto.setCreateMember(adminUserIdx);
|
||||
adminNoticeFileDto.setUpdateMember(adminUserIdx);
|
||||
adminNoticeMapper.insertAdminNoticeFileAdd(adminNoticeFileDto);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteFileList(Long noticeIdx, List<Long> deleteFileIdxList, Integer adminUserIdx) {
|
||||
if (deleteFileIdxList == null || deleteFileIdxList.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
adminNoticeMapper.updateAdminNoticeFileDeleteByNoticeFileIdxList(noticeIdx, deleteFileIdxList, adminUserIdx);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package com.alist.api.modules.admin.notice.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 공지사항 등록 응답")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminNoticeAddVo {
|
||||
@Schema(description = "공지사항 등록 여부", example = "true")
|
||||
private boolean processed;
|
||||
|
||||
@Schema(description = "등록된 공지사항 PK", example = "1")
|
||||
private Long noticeIdx;
|
||||
|
||||
@JsonIgnore
|
||||
@Schema(hidden = true)
|
||||
private int resultCode;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.alist.api.modules.admin.notice.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 공지사항 삭제 응답")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminNoticeDeleteVo {
|
||||
@Schema(description = "공지사항 삭제 처리 여부", example = "true")
|
||||
private boolean processed;
|
||||
|
||||
@JsonIgnore
|
||||
@Schema(hidden = true)
|
||||
private int resultCode;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package com.alist.api.modules.admin.notice.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Schema(description = "관리자 공지사항 첨부파일 응답")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminNoticeFileVo {
|
||||
@Schema(description = "공지사항 첨부파일 PK", example = "1")
|
||||
private Long noticeFileIdx;
|
||||
|
||||
@Schema(description = "공지사항 PK", example = "1")
|
||||
private Long noticeIdx;
|
||||
|
||||
@Schema(description = "원본 파일명", example = "notice.pdf")
|
||||
private String fileOriginalName;
|
||||
|
||||
@Schema(description = "저장 파일명", example = "20260701120000_notice.pdf")
|
||||
private String fileSaveName;
|
||||
|
||||
@Schema(description = "파일 경로", example = "/uploads/notice/2026/07/01/20260701120000_notice.pdf")
|
||||
private String filePath;
|
||||
|
||||
@Schema(description = "파일 크기", example = "2048")
|
||||
private Long fileSize;
|
||||
|
||||
@Schema(description = "파일 확장자", example = "pdf")
|
||||
private String fileExt;
|
||||
|
||||
@Schema(description = "정렬 순서", example = "0")
|
||||
private Integer sortOrder;
|
||||
|
||||
@Schema(description = "생성일", example = "2026-07-01 10:00:00")
|
||||
private LocalDateTime createDate;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.alist.api.modules.admin.notice.vo;
|
||||
|
||||
import com.alist.api.common.paging.PageResponse;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "관리자 공지사항 목록 조회 응답")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminNoticeListVo extends PageResponse {
|
||||
@Schema(description = "공지사항 목록")
|
||||
private List<AdminNoticeVo> noticeList;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.alist.api.modules.admin.notice.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 공지사항 수정 응답")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminNoticeModifyVo {
|
||||
@Schema(description = "공지사항 수정 여부", example = "true")
|
||||
private boolean processed;
|
||||
|
||||
@JsonIgnore
|
||||
@Schema(hidden = true)
|
||||
private int resultCode;
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package com.alist.api.modules.admin.notice.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "관리자 공지사항 상세 조회 응답")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminNoticeViewVo {
|
||||
@Schema(description = "공지사항 PK", example = "1")
|
||||
private Long noticeIdx;
|
||||
|
||||
@Schema(description = "공지 유형", example = "GENERAL")
|
||||
private String noticeType;
|
||||
|
||||
@Schema(description = "노출 범위", example = "CAMPUS")
|
||||
private String targetScope;
|
||||
|
||||
@Schema(description = "캠퍼스 IDX 목록", example = "[1, 2, 3]")
|
||||
private List<Long> campusIdxList;
|
||||
|
||||
@Schema(description = "제목", example = "서비스 점검 안내")
|
||||
private String title;
|
||||
|
||||
@Schema(description = "내용", example = "서비스 점검이 예정되어 있습니다.")
|
||||
private String content;
|
||||
|
||||
@Schema(description = "상단 고정 여부", example = "N")
|
||||
private String pinYn;
|
||||
|
||||
@Schema(description = "노출 시작일", example = "2026-07-01 00:00:00")
|
||||
private LocalDateTime startDate;
|
||||
|
||||
@Schema(description = "노출 종료일", example = "2026-07-31 23:59:59")
|
||||
private LocalDateTime endDate;
|
||||
|
||||
@Schema(description = "조회수", example = "0")
|
||||
private Integer viewCount;
|
||||
|
||||
@Schema(description = "사용 여부", example = "Y")
|
||||
private String useYn;
|
||||
|
||||
@Schema(description = "삭제 여부", example = "N")
|
||||
private String delYn;
|
||||
|
||||
@Schema(description = "첨부파일 목록")
|
||||
private List<AdminNoticeFileVo> fileList;
|
||||
|
||||
@Schema(description = "생성일", example = "2026-07-01 10:00:00")
|
||||
private LocalDateTime createDate;
|
||||
|
||||
@Schema(description = "생성자 회원 PK", example = "1")
|
||||
private Integer createMember;
|
||||
|
||||
@Schema(description = "수정일", example = "2026-07-01 10:00:00")
|
||||
private LocalDateTime updateDate;
|
||||
|
||||
@Schema(description = "수정자 회원 PK", example = "1")
|
||||
private Integer updateMember;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package com.alist.api.modules.admin.notice.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Schema(description = "관리자 공지사항 목록 항목")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminNoticeVo {
|
||||
@Schema(description = "공지사항 PK", example = "1")
|
||||
private Long noticeIdx;
|
||||
|
||||
@Schema(description = "공지 유형", example = "GENERAL")
|
||||
private String noticeType;
|
||||
|
||||
@Schema(description = "노출 범위", example = "CAMPUS")
|
||||
private String targetScope;
|
||||
|
||||
@Schema(description = "제목", example = "서비스 점검 안내")
|
||||
private String title;
|
||||
|
||||
@Schema(description = "상단 고정 여부", example = "N")
|
||||
private String pinYn;
|
||||
|
||||
@Schema(description = "노출 시작일", example = "2026-07-01 00:00:00")
|
||||
private LocalDateTime startDate;
|
||||
|
||||
@Schema(description = "노출 종료일", example = "2026-07-31 23:59:59")
|
||||
private LocalDateTime endDate;
|
||||
|
||||
@Schema(description = "조회수", example = "0")
|
||||
private Integer viewCount;
|
||||
|
||||
@Schema(description = "사용 여부", example = "Y")
|
||||
private String useYn;
|
||||
|
||||
@Schema(description = "생성일", example = "2026-07-01 10:00:00")
|
||||
private LocalDateTime createDate;
|
||||
|
||||
@Schema(description = "수정일", example = "2026-07-01 10:00:00")
|
||||
private LocalDateTime updateDate;
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
package com.alist.api.modules.admin.schedule;
|
||||
|
||||
import com.alist.api.common.response.ApiResponse;
|
||||
import com.alist.api.common.response.ApiResponseCode;
|
||||
import com.alist.api.modules.admin.schedule.form.*;
|
||||
import com.alist.api.modules.admin.schedule.service.AdminScheduleService;
|
||||
import com.alist.api.modules.admin.schedule.vo.*;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@Tag(name = "504. 스케줄 관리", description = "관리자 스케줄 작업 관리 API")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/admin/schedule")
|
||||
public class AdminScheduleController {
|
||||
private final AdminScheduleService adminScheduleService;
|
||||
|
||||
public AdminScheduleController(AdminScheduleService adminScheduleService) {
|
||||
this.adminScheduleService = adminScheduleService;
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "스케줄 작업 목록 조회"
|
||||
, description = "관리자가 스케줄 작업 목록을 검색 조건과 페이징으로 조회합니다."
|
||||
)
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<ApiResponse<AdminScheduleListVo>> adminScheduleList(
|
||||
@ModelAttribute AdminScheduleListForm adminScheduleListForm
|
||||
) {
|
||||
AdminScheduleListVo adminScheduleListVo = adminScheduleService.selectAdminScheduleList(adminScheduleListForm.toDto());
|
||||
|
||||
if (adminScheduleListVo.getScheduleList() == null || adminScheduleListVo.getScheduleList().isEmpty()) {
|
||||
return ApiResponse.entity(adminScheduleListVo, ApiResponseCode.CODE_2003);
|
||||
}
|
||||
|
||||
return ApiResponse.entity(adminScheduleListVo, ApiResponseCode.CODE_2001, "스케줄 작업");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "스케줄 작업 상세 조회"
|
||||
, description = "관리자가 스케줄 작업 PK 기준으로 상세 정보를 조회합니다."
|
||||
)
|
||||
@GetMapping("/view")
|
||||
public ResponseEntity<ApiResponse<AdminScheduleViewVo>> adminScheduleView(
|
||||
@RequestParam Long scheduleJobIdx
|
||||
) {
|
||||
AdminScheduleViewVo adminScheduleViewVo = adminScheduleService.selectAdminScheduleView(scheduleJobIdx);
|
||||
|
||||
if (adminScheduleViewVo == null) {
|
||||
return ApiResponse.entity((AdminScheduleViewVo) null, ApiResponseCode.CODE_2003);
|
||||
}
|
||||
|
||||
return ApiResponse.entity(adminScheduleViewVo, ApiResponseCode.CODE_2001, "스케줄 작업");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "스케줄 작업 등록"
|
||||
, description = "관리자가 스케줄 작업을 등록합니다. JOB_NAME과 JOB_GROUP이 중복되면 등록하지 않습니다."
|
||||
)
|
||||
@PostMapping("/add")
|
||||
public ResponseEntity<ApiResponse<AdminScheduleAddVo>> adminScheduleAdd(
|
||||
@Valid @RequestBody AdminScheduleAddForm adminScheduleAddForm
|
||||
) {
|
||||
AdminScheduleAddVo adminScheduleAddVo = adminScheduleService.insertAdminScheduleAdd(adminScheduleAddForm.toDto());
|
||||
|
||||
if (adminScheduleAddVo.getResultCode() == 401) {
|
||||
return ApiResponse.entity(adminScheduleAddVo, ApiResponseCode.CODE_401);
|
||||
}
|
||||
|
||||
if (!adminScheduleAddVo.isProcessed()) {
|
||||
return ApiResponse.entity(adminScheduleAddVo, ApiResponseCode.CODE_2004, "스케줄 작업");
|
||||
}
|
||||
|
||||
return ApiResponse.entity(adminScheduleAddVo, ApiResponseCode.CODE_2002, "스케줄 작업");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "스케줄 작업 수정"
|
||||
, description = "관리자가 스케줄 작업 PK 기준으로 작업 코드, 크론식, 사용 여부 등 기본 정보를 수정합니다."
|
||||
)
|
||||
@PutMapping("/modify")
|
||||
public ResponseEntity<ApiResponse<AdminScheduleModifyVo>> adminScheduleModify(
|
||||
@Valid @RequestBody AdminScheduleModifyForm adminScheduleModifyForm
|
||||
) {
|
||||
AdminScheduleModifyVo adminScheduleModifyVo = adminScheduleService.updateAdminScheduleModify(adminScheduleModifyForm.toDto());
|
||||
|
||||
if (adminScheduleModifyVo.getResultCode() == 401) {
|
||||
return ApiResponse.entity(adminScheduleModifyVo, ApiResponseCode.CODE_401);
|
||||
}
|
||||
|
||||
if (adminScheduleModifyVo.getResultCode() == 2004) {
|
||||
return ApiResponse.entity(adminScheduleModifyVo, ApiResponseCode.CODE_2004, "스케줄 작업");
|
||||
}
|
||||
|
||||
if (!adminScheduleModifyVo.isProcessed()) {
|
||||
return ApiResponse.entity(adminScheduleModifyVo, ApiResponseCode.CODE_2003);
|
||||
}
|
||||
|
||||
return ApiResponse.entity(adminScheduleModifyVo, ApiResponseCode.CODE_2005, "스케줄 작업 수정");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "스케줄 작업 삭제"
|
||||
, description = "관리자가 스케줄 작업 PK 기준으로 삭제 처리합니다. 실제 삭제가 아닌 DEL_YN 변경 방식입니다."
|
||||
)
|
||||
@PatchMapping("/delete")
|
||||
public ResponseEntity<ApiResponse<AdminScheduleDeleteVo>> adminScheduleDelete(
|
||||
@Valid @RequestBody AdminScheduleDeleteForm adminScheduleDeleteForm
|
||||
) {
|
||||
AdminScheduleDeleteVo adminScheduleDeleteVo = adminScheduleService.updateAdminScheduleDelete(adminScheduleDeleteForm.toDto());
|
||||
|
||||
if (adminScheduleDeleteVo.getResultCode() == 401) {
|
||||
return ApiResponse.entity(adminScheduleDeleteVo, ApiResponseCode.CODE_401);
|
||||
}
|
||||
|
||||
if (!adminScheduleDeleteVo.isProcessed()) {
|
||||
return ApiResponse.entity(adminScheduleDeleteVo, ApiResponseCode.CODE_2003);
|
||||
}
|
||||
|
||||
return ApiResponse.entity(adminScheduleDeleteVo, ApiResponseCode.CODE_2005, "스케줄 작업 삭제");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "스케줄 작업 실행 로그 목록 조회"
|
||||
, description = "관리자가 스케줄 작업 실행 로그를 검색 조건과 페이징으로 조회합니다."
|
||||
)
|
||||
@GetMapping("/log/list")
|
||||
public ResponseEntity<ApiResponse<AdminScheduleLogListVo>> adminScheduleLogList(
|
||||
@ModelAttribute AdminScheduleLogListForm adminScheduleLogListForm
|
||||
) {
|
||||
AdminScheduleLogListVo adminScheduleLogListVo = adminScheduleService.selectAdminScheduleLogList(adminScheduleLogListForm.toDto());
|
||||
|
||||
if (adminScheduleLogListVo.getScheduleLogList() == null || adminScheduleLogListVo.getScheduleLogList().isEmpty()) {
|
||||
return ApiResponse.entity(adminScheduleLogListVo, ApiResponseCode.CODE_2003);
|
||||
}
|
||||
|
||||
return ApiResponse.entity(adminScheduleLogListVo, ApiResponseCode.CODE_2001, "스케줄 작업 실행 로그");
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package com.alist.api.modules.admin.schedule.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminScheduleAddDto {
|
||||
private Long scheduleJobIdx;
|
||||
private String jobName;
|
||||
private String jobGroup;
|
||||
private String jobDescription;
|
||||
private String cronExpr;
|
||||
private String timezoneId;
|
||||
private Integer maxRunningSeconds;
|
||||
private String useYn;
|
||||
private Integer createMember;
|
||||
private Integer updateMember;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.alist.api.modules.admin.schedule.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminScheduleDeleteDto {
|
||||
private Long scheduleJobIdx;
|
||||
private Integer updateMember;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package com.alist.api.modules.admin.schedule.dto;
|
||||
|
||||
import com.alist.api.common.paging.PageRequest;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminScheduleListDto extends PageRequest {
|
||||
private String keyword;
|
||||
private String jobGroup;
|
||||
private Integer lastStatus;
|
||||
private String useYn;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package com.alist.api.modules.admin.schedule.dto;
|
||||
|
||||
import com.alist.api.common.paging.PageRequest;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminScheduleLogListDto extends PageRequest {
|
||||
private Long scheduleJobIdx;
|
||||
private Integer status;
|
||||
private String startDateFrom;
|
||||
private String startDateTo;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.alist.api.modules.admin.schedule.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminScheduleModifyDto {
|
||||
private Long scheduleJobIdx;
|
||||
private String jobName;
|
||||
private String jobGroup;
|
||||
private String jobDescription;
|
||||
private String cronExpr;
|
||||
private String timezoneId;
|
||||
private Integer maxRunningSeconds;
|
||||
private String useYn;
|
||||
private Integer updateMember;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package com.alist.api.modules.admin.schedule.form;
|
||||
|
||||
import com.alist.api.modules.admin.schedule.dto.AdminScheduleAddDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 스케줄 작업 등록 요청")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminScheduleAddForm {
|
||||
@NotBlank
|
||||
@Size(max = 100)
|
||||
@Schema(description = "작업 코드", example = "DAILY_LEARNING_SUMMARY", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String jobName;
|
||||
|
||||
@Size(max = 50)
|
||||
@Schema(description = "작업 그룹. 미입력 시 DEFAULT", example = "DEFAULT")
|
||||
private String jobGroup;
|
||||
|
||||
@Size(max = 255)
|
||||
@Schema(description = "작업 설명", example = "일별 학습 통계 집계")
|
||||
private String jobDescription;
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 100)
|
||||
@Schema(description = "크론식", example = "0 0 2 * * *", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String cronExpr;
|
||||
|
||||
@Size(max = 50)
|
||||
@Schema(description = "타임존. 미입력 시 Asia/Seoul", example = "Asia/Seoul")
|
||||
private String timezoneId;
|
||||
|
||||
@Min(1)
|
||||
@Schema(description = "최대 실행 허용 시간(초). 미입력 시 1800", example = "1800")
|
||||
private Integer maxRunningSeconds;
|
||||
|
||||
@Pattern(regexp = "Y|N")
|
||||
@Schema(description = "사용 여부. Y: 사용, N: 미사용. 미입력 시 Y", example = "Y")
|
||||
private String useYn;
|
||||
|
||||
public AdminScheduleAddDto toDto() {
|
||||
AdminScheduleAddDto adminScheduleAddDto = new AdminScheduleAddDto();
|
||||
adminScheduleAddDto.setJobName(jobName.trim());
|
||||
adminScheduleAddDto.setJobGroup(jobGroup == null || jobGroup.trim().isEmpty() ? "DEFAULT" : jobGroup.trim());
|
||||
adminScheduleAddDto.setJobDescription(jobDescription == null ? null : jobDescription.trim());
|
||||
adminScheduleAddDto.setCronExpr(cronExpr.trim());
|
||||
adminScheduleAddDto.setTimezoneId(timezoneId == null || timezoneId.trim().isEmpty() ? "Asia/Seoul" : timezoneId.trim());
|
||||
adminScheduleAddDto.setMaxRunningSeconds(maxRunningSeconds == null ? 1800 : maxRunningSeconds);
|
||||
adminScheduleAddDto.setUseYn(useYn == null || useYn.trim().isEmpty() ? "Y" : useYn.trim());
|
||||
return adminScheduleAddDto;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.alist.api.modules.admin.schedule.form;
|
||||
|
||||
import com.alist.api.modules.admin.schedule.dto.AdminScheduleDeleteDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 스케줄 작업 삭제 요청")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminScheduleDeleteForm {
|
||||
@NotNull
|
||||
@Schema(description = "스케줄 작업 PK", example = "9", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long scheduleJobIdx;
|
||||
|
||||
public AdminScheduleDeleteDto toDto() {
|
||||
AdminScheduleDeleteDto adminScheduleDeleteDto = new AdminScheduleDeleteDto();
|
||||
adminScheduleDeleteDto.setScheduleJobIdx(scheduleJobIdx);
|
||||
return adminScheduleDeleteDto;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package com.alist.api.modules.admin.schedule.form;
|
||||
|
||||
import com.alist.api.common.paging.PageRequest;
|
||||
import com.alist.api.modules.admin.schedule.dto.AdminScheduleListDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 스케줄 작업 목록 검색 조건")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminScheduleListForm extends PageRequest {
|
||||
@Schema(description = "검색어. 작업 코드, 작업 설명을 대상으로 검색합니다.", example = "DAILY")
|
||||
private String keyword;
|
||||
|
||||
@Schema(description = "작업 그룹", example = "DEFAULT")
|
||||
private String jobGroup;
|
||||
|
||||
@Schema(description = "마지막 실행 상태. 0: IDLE, 1: RUNNING, 2: SUCCESS, 3: SKIP, 4: FAIL", example = "2")
|
||||
private Integer lastStatus;
|
||||
|
||||
@Schema(description = "사용 여부. Y: 사용, N: 미사용", example = "Y")
|
||||
private String useYn;
|
||||
|
||||
public AdminScheduleListDto toDto() {
|
||||
AdminScheduleListDto adminScheduleListDto = new AdminScheduleListDto();
|
||||
adminScheduleListDto.setPage(getPage());
|
||||
adminScheduleListDto.setSize(getSize());
|
||||
adminScheduleListDto.setKeyword(keyword == null ? null : keyword.trim());
|
||||
adminScheduleListDto.setJobGroup(jobGroup == null ? null : jobGroup.trim());
|
||||
adminScheduleListDto.setLastStatus(lastStatus);
|
||||
adminScheduleListDto.setUseYn(useYn == null ? null : useYn.trim());
|
||||
return adminScheduleListDto;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package com.alist.api.modules.admin.schedule.form;
|
||||
|
||||
import com.alist.api.common.paging.PageRequest;
|
||||
import com.alist.api.modules.admin.schedule.dto.AdminScheduleLogListDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 스케줄 작업 실행 로그 목록 검색 조건")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminScheduleLogListForm extends PageRequest {
|
||||
@Schema(description = "스케줄 작업 PK", example = "9", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long scheduleJobIdx;
|
||||
|
||||
@Schema(description = "실행 상태. 1: RUNNING, 2: SUCCESS, 3: SKIP, 4: FAIL", example = "2")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "실행 시작일 검색 시작값. yyyy-MM-dd 또는 yyyy-MM-dd HH:mm:ss", example = "2026-05-01")
|
||||
private String startDateFrom;
|
||||
|
||||
@Schema(description = "실행 시작일 검색 종료값. yyyy-MM-dd 또는 yyyy-MM-dd HH:mm:ss", example = "2026-05-28")
|
||||
private String startDateTo;
|
||||
|
||||
public AdminScheduleLogListDto toDto() {
|
||||
AdminScheduleLogListDto adminScheduleLogListDto = new AdminScheduleLogListDto();
|
||||
adminScheduleLogListDto.setPage(getPage());
|
||||
adminScheduleLogListDto.setSize(getSize());
|
||||
adminScheduleLogListDto.setScheduleJobIdx(scheduleJobIdx);
|
||||
adminScheduleLogListDto.setStatus(status);
|
||||
adminScheduleLogListDto.setStartDateFrom(startDateFrom == null ? null : startDateFrom.trim());
|
||||
adminScheduleLogListDto.setStartDateTo(startDateTo == null ? null : startDateTo.trim());
|
||||
return adminScheduleLogListDto;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package com.alist.api.modules.admin.schedule.form;
|
||||
|
||||
import com.alist.api.modules.admin.schedule.dto.AdminScheduleModifyDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 스케줄 작업 수정 요청")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminScheduleModifyForm {
|
||||
@NotNull
|
||||
@Schema(description = "스케줄 작업 PK", example = "9", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long scheduleJobIdx;
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 100)
|
||||
@Schema(description = "작업 코드", example = "DAILY_LEARNING_SUMMARY", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String jobName;
|
||||
|
||||
@Size(max = 50)
|
||||
@Schema(description = "작업 그룹. 미입력 시 DEFAULT", example = "DEFAULT")
|
||||
private String jobGroup;
|
||||
|
||||
@Size(max = 255)
|
||||
@Schema(description = "작업 설명", example = "일별 학습 통계 집계")
|
||||
private String jobDescription;
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 100)
|
||||
@Schema(description = "크론식", example = "0 0 2 * * *", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String cronExpr;
|
||||
|
||||
@Size(max = 50)
|
||||
@Schema(description = "타임존. 미입력 시 Asia/Seoul", example = "Asia/Seoul")
|
||||
private String timezoneId;
|
||||
|
||||
@Min(1)
|
||||
@Schema(description = "최대 실행 허용 시간(초). 미입력 시 1800", example = "1800")
|
||||
private Integer maxRunningSeconds;
|
||||
|
||||
@Pattern(regexp = "Y|N")
|
||||
@Schema(description = "사용 여부. Y: 사용, N: 미사용. 미입력 시 Y", example = "Y")
|
||||
private String useYn;
|
||||
|
||||
public AdminScheduleModifyDto toDto() {
|
||||
AdminScheduleModifyDto adminScheduleModifyDto = new AdminScheduleModifyDto();
|
||||
adminScheduleModifyDto.setScheduleJobIdx(scheduleJobIdx);
|
||||
adminScheduleModifyDto.setJobName(jobName.trim());
|
||||
adminScheduleModifyDto.setJobGroup(jobGroup == null || jobGroup.trim().isEmpty() ? "DEFAULT" : jobGroup.trim());
|
||||
adminScheduleModifyDto.setJobDescription(jobDescription == null ? null : jobDescription.trim());
|
||||
adminScheduleModifyDto.setCronExpr(cronExpr.trim());
|
||||
adminScheduleModifyDto.setTimezoneId(timezoneId == null || timezoneId.trim().isEmpty() ? "Asia/Seoul" : timezoneId.trim());
|
||||
adminScheduleModifyDto.setMaxRunningSeconds(maxRunningSeconds == null ? 1800 : maxRunningSeconds);
|
||||
adminScheduleModifyDto.setUseYn(useYn == null || useYn.trim().isEmpty() ? "Y" : useYn.trim());
|
||||
return adminScheduleModifyDto;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package com.alist.api.modules.admin.schedule.mapper;
|
||||
|
||||
import com.alist.api.modules.admin.schedule.dto.*;
|
||||
import com.alist.api.modules.admin.schedule.vo.AdminScheduleLogVo;
|
||||
import com.alist.api.modules.admin.schedule.vo.AdminScheduleViewVo;
|
||||
import com.alist.api.modules.admin.schedule.vo.AdminScheduleVo;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface AdminScheduleMapper {
|
||||
int selectAdminScheduleDuplicateCount(AdminScheduleAddDto adminScheduleAddDto);
|
||||
|
||||
int insertAdminScheduleAdd(AdminScheduleAddDto adminScheduleAddDto);
|
||||
|
||||
int selectAdminScheduleCount(AdminScheduleListDto adminScheduleListDto);
|
||||
|
||||
List<AdminScheduleVo> selectAdminScheduleList(AdminScheduleListDto adminScheduleListDto);
|
||||
|
||||
AdminScheduleViewVo selectAdminScheduleView(Long scheduleJobIdx);
|
||||
|
||||
int selectAdminScheduleModifyDuplicateCount(AdminScheduleModifyDto adminScheduleModifyDto);
|
||||
|
||||
int updateAdminScheduleModify(AdminScheduleModifyDto adminScheduleModifyDto);
|
||||
|
||||
int updateAdminScheduleDelete(AdminScheduleDeleteDto adminScheduleDeleteDto);
|
||||
|
||||
int selectAdminScheduleLogCount(AdminScheduleLogListDto adminScheduleLogListDto);
|
||||
|
||||
List<AdminScheduleLogVo> selectAdminScheduleLogList(AdminScheduleLogListDto adminScheduleLogListDto);
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
package com.alist.api.modules.admin.schedule.service;
|
||||
|
||||
import com.alist.api.common.utils.SecurityUtil;
|
||||
import com.alist.api.modules.admin.auth.mapper.AdminAuthMapper;
|
||||
import com.alist.api.modules.admin.auth.vo.AdminAuthLoginVo;
|
||||
import com.alist.api.modules.admin.schedule.dto.*;
|
||||
import com.alist.api.modules.admin.schedule.mapper.AdminScheduleMapper;
|
||||
import com.alist.api.modules.admin.schedule.vo.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class AdminScheduleService {
|
||||
private final AdminScheduleMapper adminScheduleMapper;
|
||||
private final AdminAuthMapper adminAuthMapper;
|
||||
|
||||
public AdminScheduleService(AdminScheduleMapper adminScheduleMapper, AdminAuthMapper adminAuthMapper) {
|
||||
this.adminScheduleMapper = adminScheduleMapper;
|
||||
this.adminAuthMapper = adminAuthMapper;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AdminScheduleListVo selectAdminScheduleList(AdminScheduleListDto adminScheduleListDto) {
|
||||
int totalCount = adminScheduleMapper.selectAdminScheduleCount(adminScheduleListDto);
|
||||
List<AdminScheduleVo> scheduleList = adminScheduleMapper.selectAdminScheduleList(adminScheduleListDto);
|
||||
|
||||
AdminScheduleListVo adminScheduleListVo = new AdminScheduleListVo();
|
||||
adminScheduleListVo.setScheduleList(scheduleList);
|
||||
adminScheduleListVo.setPaging(adminScheduleListDto, totalCount);
|
||||
|
||||
return adminScheduleListVo;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AdminScheduleViewVo selectAdminScheduleView(Long scheduleJobIdx) {
|
||||
return adminScheduleMapper.selectAdminScheduleView(scheduleJobIdx);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminScheduleAddVo insertAdminScheduleAdd(AdminScheduleAddDto adminScheduleAddDto) {
|
||||
AdminScheduleAddVo adminScheduleAddVo = new AdminScheduleAddVo();
|
||||
|
||||
Integer loginUserTokenIdx = SecurityUtil.getLoginUserTokenIdx();
|
||||
|
||||
if (loginUserTokenIdx == null) {
|
||||
adminScheduleAddVo.setResultCode(401);
|
||||
adminScheduleAddVo.setProcessed(false);
|
||||
return adminScheduleAddVo;
|
||||
}
|
||||
|
||||
AdminAuthLoginVo adminAuthLoginVo = adminAuthMapper.selectAdminTokenByUserTokenIdx(loginUserTokenIdx);
|
||||
|
||||
if (adminAuthLoginVo == null || adminAuthLoginVo.getUserIdx() == null) {
|
||||
adminScheduleAddVo.setResultCode(401);
|
||||
adminScheduleAddVo.setProcessed(false);
|
||||
return adminScheduleAddVo;
|
||||
}
|
||||
|
||||
adminScheduleAddDto.setCreateMember(adminAuthLoginVo.getUserIdx());
|
||||
adminScheduleAddDto.setUpdateMember(adminAuthLoginVo.getUserIdx());
|
||||
|
||||
int duplicateCount = adminScheduleMapper.selectAdminScheduleDuplicateCount(adminScheduleAddDto);
|
||||
|
||||
if (duplicateCount > 0) {
|
||||
adminScheduleAddVo.setResultCode(2004);
|
||||
adminScheduleAddVo.setProcessed(false);
|
||||
return adminScheduleAddVo;
|
||||
}
|
||||
|
||||
int insertCount = adminScheduleMapper.insertAdminScheduleAdd(adminScheduleAddDto);
|
||||
|
||||
adminScheduleAddVo.setResultCode(2002);
|
||||
adminScheduleAddVo.setProcessed(insertCount > 0);
|
||||
adminScheduleAddVo.setScheduleJobIdx(adminScheduleAddDto.getScheduleJobIdx());
|
||||
|
||||
return adminScheduleAddVo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminScheduleModifyVo updateAdminScheduleModify(AdminScheduleModifyDto adminScheduleModifyDto) {
|
||||
AdminScheduleModifyVo adminScheduleModifyVo = new AdminScheduleModifyVo();
|
||||
|
||||
Integer loginUserTokenIdx = SecurityUtil.getLoginUserTokenIdx();
|
||||
|
||||
if (loginUserTokenIdx == null) {
|
||||
adminScheduleModifyVo.setResultCode(401);
|
||||
adminScheduleModifyVo.setProcessed(false);
|
||||
return adminScheduleModifyVo;
|
||||
}
|
||||
|
||||
AdminAuthLoginVo adminAuthLoginVo = adminAuthMapper.selectAdminTokenByUserTokenIdx(loginUserTokenIdx);
|
||||
|
||||
if (adminAuthLoginVo == null || adminAuthLoginVo.getUserIdx() == null) {
|
||||
adminScheduleModifyVo.setResultCode(401);
|
||||
adminScheduleModifyVo.setProcessed(false);
|
||||
return adminScheduleModifyVo;
|
||||
}
|
||||
|
||||
adminScheduleModifyDto.setUpdateMember(adminAuthLoginVo.getUserIdx());
|
||||
|
||||
int duplicateCount = adminScheduleMapper.selectAdminScheduleModifyDuplicateCount(adminScheduleModifyDto);
|
||||
|
||||
if (duplicateCount > 0) {
|
||||
adminScheduleModifyVo.setResultCode(2004);
|
||||
adminScheduleModifyVo.setProcessed(false);
|
||||
return adminScheduleModifyVo;
|
||||
}
|
||||
|
||||
int updateCount = adminScheduleMapper.updateAdminScheduleModify(adminScheduleModifyDto);
|
||||
|
||||
adminScheduleModifyVo.setResultCode(2005);
|
||||
adminScheduleModifyVo.setProcessed(updateCount > 0);
|
||||
|
||||
return adminScheduleModifyVo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminScheduleDeleteVo updateAdminScheduleDelete(AdminScheduleDeleteDto adminScheduleDeleteDto) {
|
||||
AdminScheduleDeleteVo adminScheduleDeleteVo = new AdminScheduleDeleteVo();
|
||||
|
||||
Integer loginUserTokenIdx = SecurityUtil.getLoginUserTokenIdx();
|
||||
|
||||
if (loginUserTokenIdx == null) {
|
||||
adminScheduleDeleteVo.setResultCode(401);
|
||||
adminScheduleDeleteVo.setProcessed(false);
|
||||
return adminScheduleDeleteVo;
|
||||
}
|
||||
|
||||
AdminAuthLoginVo adminAuthLoginVo = adminAuthMapper.selectAdminTokenByUserTokenIdx(loginUserTokenIdx);
|
||||
|
||||
if (adminAuthLoginVo == null || adminAuthLoginVo.getUserIdx() == null) {
|
||||
adminScheduleDeleteVo.setResultCode(401);
|
||||
adminScheduleDeleteVo.setProcessed(false);
|
||||
return adminScheduleDeleteVo;
|
||||
}
|
||||
|
||||
adminScheduleDeleteDto.setUpdateMember(adminAuthLoginVo.getUserIdx());
|
||||
|
||||
int updateCount = adminScheduleMapper.updateAdminScheduleDelete(adminScheduleDeleteDto);
|
||||
|
||||
adminScheduleDeleteVo.setResultCode(2005);
|
||||
adminScheduleDeleteVo.setProcessed(updateCount > 0);
|
||||
|
||||
return adminScheduleDeleteVo;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AdminScheduleLogListVo selectAdminScheduleLogList(AdminScheduleLogListDto adminScheduleLogListDto) {
|
||||
int totalCount = adminScheduleMapper.selectAdminScheduleLogCount(adminScheduleLogListDto);
|
||||
List<AdminScheduleLogVo> scheduleLogList = adminScheduleMapper.selectAdminScheduleLogList(adminScheduleLogListDto);
|
||||
|
||||
AdminScheduleLogListVo adminScheduleLogListVo = new AdminScheduleLogListVo();
|
||||
adminScheduleLogListVo.setScheduleLogList(scheduleLogList);
|
||||
adminScheduleLogListVo.setPaging(adminScheduleLogListDto, totalCount);
|
||||
|
||||
return adminScheduleLogListVo;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package com.alist.api.modules.admin.schedule.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 스케줄 작업 등록 응답")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminScheduleAddVo {
|
||||
@Schema(description = "스케줄 작업 등록 여부", example = "true")
|
||||
private boolean processed;
|
||||
|
||||
@Schema(description = "등록된 스케줄 작업 PK", example = "9")
|
||||
private Long scheduleJobIdx;
|
||||
|
||||
@JsonIgnore
|
||||
@Schema(hidden = true)
|
||||
private int resultCode;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.alist.api.modules.admin.schedule.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Schema(description = "관리자 스케줄 작업 삭제 응답")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminScheduleDeleteVo {
|
||||
@Schema(description = "스케줄 작업 삭제 처리 여부", example = "true")
|
||||
private boolean processed;
|
||||
|
||||
@JsonIgnore
|
||||
@Schema(hidden = true)
|
||||
private int resultCode;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.alist.api.modules.admin.schedule.vo;
|
||||
|
||||
import com.alist.api.common.paging.PageResponse;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "관리자 스케줄 작업 목록 조회 응답")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminScheduleListVo extends PageResponse {
|
||||
@Schema(description = "스케줄 작업 목록")
|
||||
private List<AdminScheduleVo> scheduleList;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.alist.api.modules.admin.schedule.vo;
|
||||
|
||||
import com.alist.api.common.paging.PageResponse;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "관리자 스케줄 작업 실행 로그 목록 조회 응답")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminScheduleLogListVo extends PageResponse {
|
||||
@Schema(description = "스케줄 작업 실행 로그 목록")
|
||||
private List<AdminScheduleLogVo> scheduleLogList;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package com.alist.api.modules.admin.schedule.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Schema(description = "관리자 스케줄 작업 실행 로그 목록 항목")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AdminScheduleLogVo {
|
||||
@Schema(description = "스케줄 작업 로그 PK", example = "20185")
|
||||
private Long scheduleJobLogIdx;
|
||||
|
||||
@Schema(description = "스케줄 작업 PK", example = "9")
|
||||
private Long scheduleJobIdx;
|
||||
|
||||
@Schema(description = "1회 실행 식별자", example = "20260528093000-abc123")
|
||||
private String runId;
|
||||
|
||||
@Schema(description = "작업 코드", example = "DAILY_LEARNING_SUMMARY")
|
||||
private String jobName;
|
||||
|
||||
@Schema(description = "실행 시작 시각", example = "2026-05-28 09:30:00")
|
||||
private LocalDateTime startDate;
|
||||
|
||||
@Schema(description = "실행 종료 시각", example = "2026-05-28 09:30:12")
|
||||
private LocalDateTime endDate;
|
||||
|
||||
@Schema(description = "대상 건수", example = "100")
|
||||
private Integer targetCount;
|
||||
|
||||
@Schema(description = "성공 건수", example = "98")
|
||||
private Integer successCount;
|
||||
|
||||
@Schema(description = "실패 건수", example = "2")
|
||||
private Integer failCount;
|
||||
|
||||
@Schema(description = "요약 메시지", example = "일별 학습 통계 집계 완료")
|
||||
private String message;
|
||||
|
||||
@Schema(description = "오류 메시지", example = "timeout")
|
||||
private String errorMessage;
|
||||
|
||||
@Schema(description = "실행 서버", example = "api-01")
|
||||
private String lockOwner;
|
||||
|
||||
@Schema(description = "실행 상태. 1: RUNNING, 2: SUCCESS, 3: SKIP, 4: FAIL", example = "2")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "기록 시각", example = "2026-05-28 09:30:00")
|
||||
private LocalDateTime createDate;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user