diff --git a/README.md b/README.md index 04f20d2..89c1a2b 100644 --- a/README.md +++ b/README.md @@ -1 +1,83 @@ -# ClubApplicationPlatform_Server \ No newline at end of file +[파일 역할 정리] +📌 공통 모듈 + +ApiPath.java → 모든 API 엔드포인트 경로를 상수로 정의 (공지/FAQ, 동아리 신청 등). +Response.java → 공통 응답 객체(상태 코드, 데이터, 메시지, 타임스탬프 포함). +NotFoundException.java → 데이터 미존재 시 예외 정의. +GlobalExceptionHandler.java → 예외를 공통적으로 처리(404/500 반환). +JwtProvider.java → JWT 스텁, "Bearer 이메일" 형식에서 이메일 추출. +SecurityConfiguration.java → Spring Security 설정 (CSRF, FormLogin 비활성화, 세션 정책). + +📌 동아리 신청 (ClubApplication) 도메인 + +ClubApplication.java → 신청 엔티티 (신청자, 클럽, 학과, 질문답변, 상태 등). +ClubStatus.java → 신청 상태(PENDING, PASSED, FAILED, CONFIRMED, DECLINED). +QuestionAnswer.java → 지원자가 입력한 질문/답변 구조. +ClubApplicationDto.java → DTO (엔티티 ↔ 응답 변환). +ClubSummary.java → 학과별 클럽 요약 DTO (id, 이름). +ClubApplicationRepository.java → 인메모리 저장소(Map), CRUD, 클럽/학과별 조회 지원. +ClubApplicationService.java → 비즈니스 로직 (신청 등록/조회/수정/취소, 합격 여부, 확정/철회, 추가 합격). +ClubApplicationController.java → REST API 제공 (POST/GET/PUT/PATCH/DELETE). + +📌 공지 / FAQ (Notice) 도메인 + +Notice.java → 공지 엔티티 (id, clubId, 제목, 내용, 작성일, 타입(FAQ/CLUB_NOTICE)). + +NoticeResponse.java → 응답 DTO (엔티티 → 클라이언트 응답 변환). +NoticeCreateRequest.java → 공지/FAQ 등록 요청 DTO. +NoticeRepository.java → 인메모리 저장소(List), 샘플 데이터 4건 포함, FAQ/공지 CRUD. +NoticeService.java → 공지/FAQ 비즈니스 로직 (FAQ 목록, 상세조회, 등록, 클럽 공지). +NoticeController.java → REST API 제공 (FAQ 목록 조회, 공지 상세 조회, FAQ 등록). + +📌 부트스트랩 +DpApplication.java → Spring Boot 메인 실행 클래스. + +📌 END POINT 기능 정리 +🔹 동아리 신청 (ClubApplicationController) +기본 CRUD + +POST /api/applications +→ 신규 동아리 신청 등록 + +GET /api/applications +→ 전체 신청 목록 조회 + +GET /api/applications/{applicationId} +→ 특정 신청 상세 조회 + +DELETE /api/applications/{applicationId} +→ 신청 취소(삭제) + +PUT /api/applications +→ 신청 정보 전체 수정 + +PATCH /api/applications/{applicationId} +→ 신청 정보 일부 수정 + +추가 기능 + +GET /api/clubs/{clubId}/applications +→ 특정 클럽의 신청자 목록 조회 + +PATCH /api/applications/{applicationId}/result +→ 합격 여부 설정 (result=pass|fail, message 포함, JSON Body 방식) + +PATCH /api/applications/{applicationId}/confirm +→ 합격자 본인의 확정(confirm) / 철회(decline) + +PATCH /api/applications/{applicationId}/additional-offer +→ 불합격자 추가 합격 통보 (FAILED → PASSED) + +GET /api/departments/{departmentId}/clubs +→ 학과별 클럽 목록 조회 + +🔹 공지 / FAQ (NoticeController) + +GET /api/notices +→ FAQ 목록 조회 + +GET /api/notices/{noticeId} +→ 공지 상세 조회 (FAQ/클럽공지 공통) + +POST /api/notices +→ FAQ 등록 diff --git a/build.gradle b/build.gradle index f261f5d..2748399 100644 --- a/build.gradle +++ b/build.gradle @@ -40,8 +40,32 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // Spring Boot Starters + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-websocket' + + // ✅ Lombok (버전 명시 + annotationProcessor) + compileOnly 'org.projectlombok:lombok:1.18.32' + annotationProcessor 'org.projectlombok:lombok:1.18.32' + + testCompileOnly 'org.projectlombok:lombok:1.18.32' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.32' + + developmentOnly 'org.springframework.boot:spring-boot-devtools' + + runtimeOnly 'com.h2database:h2' + // runtimeOnly 'com.mysql:mysql-connector-j' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } + + tasks.named('test') { useJUnitPlatform() } diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/com/JoinUs/dp/DpApplication.java b/src/main/java/com/JoinUs/dp/DpApplication.java index 8045089..46bf413 100644 --- a/src/main/java/com/JoinUs/dp/DpApplication.java +++ b/src/main/java/com/JoinUs/dp/DpApplication.java @@ -1,5 +1,7 @@ package com.JoinUs.dp; +// git 강의 sdf + import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; diff --git a/src/main/java/com/JoinUs/dp/common/api/ApiPath.java b/src/main/java/com/JoinUs/dp/common/api/ApiPath.java new file mode 100644 index 0000000..b3235b1 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/common/api/ApiPath.java @@ -0,0 +1,15 @@ +// src/main/java/com/JoinUs/dp/global/common/api/ApiPath.java +package com.JoinUs.dp.common.api; + +public final class ApiPath { + private ApiPath() {} + + // 공지/FAQ + public static final String NOTICE_PATH = "/api/notices"; + public static final String CLUB_NOTICE_PATH = "/api/clubs/{clubId}/notice"; + + // ✅ 동아리 신청 + public static final String APPLICATIONS = "/api/applications"; + public static final String CLUB_APPLICATIONS = "/api/clubs/{clubId}/applications"; + public static final String DEPARTMENT_CLUBS = "/api/departments/{departmentId}/clubs"; +} diff --git a/src/main/java/com/JoinUs/dp/common/exception/GlobalExceptionHandler.java b/src/main/java/com/JoinUs/dp/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..46fbe82 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,24 @@ +package com.JoinUs.dp.common.exception; + + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import com.JoinUs.dp.common.response.Response; + +@ControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(NotFoundException.class) + public ResponseEntity> handleNotFound(NotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new Response<>(HttpStatus.NOT_FOUND.value(), null, ex.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleOther(Exception ex) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new Response<>(HttpStatus.INTERNAL_SERVER_ERROR.value(), null, "서버 오류가 발생했습니다.")); + } +} \ No newline at end of file diff --git a/src/main/java/com/JoinUs/dp/common/exception/NotFoundException.java b/src/main/java/com/JoinUs/dp/common/exception/NotFoundException.java new file mode 100644 index 0000000..f4ef337 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/common/exception/NotFoundException.java @@ -0,0 +1,6 @@ +package com.JoinUs.dp.common.exception; + + +public class NotFoundException extends RuntimeException { + public NotFoundException(String msg) { super(msg); } +} diff --git a/src/main/java/com/JoinUs/dp/common/response/Response.java b/src/main/java/com/JoinUs/dp/common/response/Response.java new file mode 100644 index 0000000..aff3354 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/common/response/Response.java @@ -0,0 +1,27 @@ +package com.JoinUs.dp.common.response; + + +import java.time.LocalDateTime; + +public class Response { + private int status; + private T data; + private String message; + private LocalDateTime timestamp = LocalDateTime.now(); + + public Response(int status, T data, String message) { + this.status = status; + this.data = data; + this.message = message; + } + + public int getStatus() { return status; } + public T getData() { return data; } + public String getMessage() { return message; } + public LocalDateTime getTimestamp() { return timestamp; } + + public void setStatus(int status) { this.status = status; } + public void setData(T data) { this.data = data; } + public void setMessage(String message) { this.message = message; } + public void setTimestamp(LocalDateTime timestamp) { this.timestamp = timestamp; } +} \ No newline at end of file diff --git a/src/main/java/com/JoinUs/dp/common/security/JwtProvider.java b/src/main/java/com/JoinUs/dp/common/security/JwtProvider.java new file mode 100644 index 0000000..03972fe --- /dev/null +++ b/src/main/java/com/JoinUs/dp/common/security/JwtProvider.java @@ -0,0 +1,18 @@ +package com.JoinUs.dp.common.security; + + +import org.springframework.stereotype.Component; + +// 실제 JWT 파싱 대신 "Bearer 이메일" 형식에서 이메일만 잘라내는 스텁 예시 +@Component +public class JwtProvider { + public String extractEmail(String tokenHeader) { + if (tokenHeader == null) return null; + // 예: "Bearer user@example.com" + String prefix = "Bearer "; + if (tokenHeader.startsWith(prefix)) { + return tokenHeader.substring(prefix.length()).trim(); + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/JoinUs/dp/controller/ClubApplicationController.java b/src/main/java/com/JoinUs/dp/controller/ClubApplicationController.java new file mode 100644 index 0000000..002ab40 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/controller/ClubApplicationController.java @@ -0,0 +1,116 @@ +// src/main/java/com/JoinUs/dp/global/domain/application/controller/ClubApplicationController.java +package com.JoinUs.dp.controller; + +import java.util.List; +import java.util.Map; + +import org.springframework.http.*; +import org.springframework.web.bind.annotation.*; + +import com.JoinUs.dp.common.api.ApiPath; +import com.JoinUs.dp.common.response.Response; +import com.JoinUs.dp.dto.ClubApplicationDto; +import com.JoinUs.dp.dto.ClubSummary; +import com.JoinUs.dp.service.ClubApplicationService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class ClubApplicationController { + + private final ClubApplicationService service; + + + + // 동아리 신청 등록: POST /api/applications + @PostMapping(ApiPath.APPLICATIONS) + public ResponseEntity> apply(@RequestBody ClubApplicationDto req) { + ClubApplicationDto saved = service.apply(req); + return ResponseEntity.status(HttpStatus.CREATED) + .body(new Response<>(HttpStatus.CREATED.value(), saved, "동아리 신청 등록 완료")); + } + + // 신청 목록 전체 조회: GET /api/applications + @GetMapping(ApiPath.APPLICATIONS) + public ResponseEntity>> findAll() { + List list = service.findAll(); + return ResponseEntity.ok(new Response<>(HttpStatus.OK.value(), list, "신청 목록 조회 성공")); + } + + // 신청 상세 조회: GET /api/applications/{applicationId} + @GetMapping(ApiPath.APPLICATIONS + "/{applicationId}") + public ResponseEntity> findById(@PathVariable("applicationId") Long applicationId) { + ClubApplicationDto dto = service.findById(applicationId); + return ResponseEntity.ok(new Response<>(HttpStatus.OK.value(), dto, "신청 상세 조회 성공")); + } + + // 신청 취소(삭제): DELETE /api/applications/{applicationId} + @DeleteMapping(ApiPath.APPLICATIONS + "/{applicationId}") + public ResponseEntity> cancel(@PathVariable("applicationId") Long applicationId) { + service.cancel(applicationId); + return ResponseEntity.ok(new Response<>(HttpStatus.OK.value(), null, "신청 취소(삭제) 완료")); + } + + // 신청 정보 수정(전체): PUT /api/applications + @PutMapping(ApiPath.APPLICATIONS) + public ResponseEntity> update(@RequestBody ClubApplicationDto req) { + ClubApplicationDto updated = service.update(req); + return ResponseEntity.ok(new Response<>(HttpStatus.OK.value(), updated, "신청 정보 수정 완료")); + } + + // 신청 정보 일부 수정: PATCH /api/applications/{applicationId} + @PatchMapping(ApiPath.APPLICATIONS + "/{applicationId}") + public ResponseEntity> partialUpdate( + @PathVariable("applicationId") Long applicationId, + @RequestBody Map updates) { + ClubApplicationDto updated = service.partialUpdate(applicationId, updates); + return ResponseEntity.ok(new Response<>(HttpStatus.OK.value(), updated, "신청 정보 일부 수정 완료")); + } + + // 특정 클럽의 신청자 목록 조회: GET /api/clubs/{clubId}/applications + @GetMapping(ApiPath.CLUB_APPLICATIONS) + public ResponseEntity>> findByClubId(@PathVariable("clubId") Long clubId) { + List list = service.findByClubId(clubId); + return ResponseEntity.ok(new Response<>(HttpStatus.OK.value(), list, "클럽별 신청자 목록 조회 성공")); + } + + // 합격 여부 설정(동아리장 전용): PATCH /api/applications/{applicationId}/result + @PatchMapping(ApiPath.APPLICATIONS + "/{applicationId}/result") + public ResponseEntity> setResult( + @PathVariable("applicationId") Long applicationId, + @RequestBody Map body) { + + String result = body.get("result"); + String message = body.get("message"); + ClubApplicationDto updated = service.setResult(applicationId, result, message); + + return ResponseEntity.ok(new Response<>(HttpStatus.OK.value(), updated, "합격 여부 설정 완료")); + } + + // 합격자 최종 확정/철회(본인): PATCH /api/applications/{applicationId}/confirm + @PatchMapping(ApiPath.APPLICATIONS + "/{applicationId}/confirm") + public ResponseEntity> confirmOrDecline( + @PathVariable("applicationId") Long applicationId, + @RequestBody Map body) { + + String action = body.get("action"); + ClubApplicationDto updated = service.confirmOrDecline(applicationId, action); + + return ResponseEntity.ok(new Response<>(HttpStatus.OK.value(), updated, "확정/철회 처리 완료")); + } + + // 불합격자 추가 합격 통보(동아리장 전용): PATCH /api/applications/{applicationId}/additional-offer + @PatchMapping(ApiPath.APPLICATIONS + "/{applicationId}/additional-offer") + public ResponseEntity> additionalOffer(@PathVariable("applicationId") Long applicationId) { + ClubApplicationDto updated = service.additionalOffer(applicationId); + return ResponseEntity.ok(new Response<>(HttpStatus.OK.value(), updated, "추가 합격 통보 완료")); + } + + // 학과별 동아리 목록 조회: GET /api/departments/{departmentId}/clubs + @GetMapping(ApiPath.DEPARTMENT_CLUBS) + public ResponseEntity>> getClubsByDepartment(@PathVariable("departmentId") Long departmentId) { + List list = service.getClubsByDepartment(departmentId); + return ResponseEntity.ok(new Response<>(HttpStatus.OK.value(), list, "학과별 클럽 목록 조회 성공")); + } +} diff --git a/src/main/java/com/JoinUs/dp/controller/NoticeController.java b/src/main/java/com/JoinUs/dp/controller/NoticeController.java new file mode 100644 index 0000000..7548b11 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/controller/NoticeController.java @@ -0,0 +1,44 @@ +// src/main/java/com/JoinUs/dp/global/domain/notice/controller/NoticeController.java +package com.JoinUs.dp.controller; + +import com.JoinUs.dp.common.api.ApiPath; +import com.JoinUs.dp.common.response.Response; +import com.JoinUs.dp.dto.NoticeCreateRequest; +import com.JoinUs.dp.dto.NoticeResponse; +import com.JoinUs.dp.service.NoticeService; + +import lombok.RequiredArgsConstructor; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping(ApiPath.NOTICE_PATH) +@RequiredArgsConstructor +public class NoticeController { + + private final NoticeService noticeService; // ✅ new 금지, DI 받기 + + + @GetMapping + public ResponseEntity>> getFaqs() { + List list = noticeService.getFaqList(); + return ResponseEntity.ok(new Response<>(HttpStatus.OK.value(), list, "FAQ 목록 조회 성공")); + } + + @GetMapping("/{noticeId}") + public ResponseEntity> getDetail(@PathVariable("noticeId") Long noticeId) { + NoticeResponse dto = noticeService.getNoticeDetail(noticeId); + return ResponseEntity.ok(new Response<>(HttpStatus.OK.value(), dto, "공지 상세 조회 성공")); + } + // ✅ FAQ 등록 (중복 경로 제거) + @PostMapping + public ResponseEntity> createFaq(@RequestBody NoticeCreateRequest req) { + NoticeResponse saved = noticeService.createFaq(req.getTitle(), req.getContent()); + return ResponseEntity.ok(new Response<>(HttpStatus.OK.value(), saved, "FAQ 등록 성공")); + } + +} diff --git a/src/main/java/com/JoinUs/dp/controller/Snippet.java b/src/main/java/com/JoinUs/dp/controller/Snippet.java new file mode 100644 index 0000000..b9336fc --- /dev/null +++ b/src/main/java/com/JoinUs/dp/controller/Snippet.java @@ -0,0 +1,7 @@ +package com.JoinUs.dp.controller; + +public class Snippet { + public static void main(String[] args) { + } +} + diff --git a/src/main/java/com/JoinUs/dp/dto/ClubApplicationDto.java b/src/main/java/com/JoinUs/dp/dto/ClubApplicationDto.java new file mode 100644 index 0000000..e46e8de --- /dev/null +++ b/src/main/java/com/JoinUs/dp/dto/ClubApplicationDto.java @@ -0,0 +1,65 @@ +// src/main/java/com/JoinUs/dp/global/domain/application/dto/ClubApplicationDto.java +package com.JoinUs.dp.dto; + +import java.time.LocalDate; +import java.util.List; + +import com.JoinUs.dp.entity.ClubApplication; +import com.JoinUs.dp.entity.ClubStatus; +import com.JoinUs.dp.entity.QuestionAnswer; + +public class ClubApplicationDto { + private Long id; + private Long clubId; + private Long departmentId; + private String applicantName; + private String clubName; + private LocalDate appliedDate; + private List questionAnswers; + private ClubStatus status; + private String resultMessage; + + public ClubApplicationDto() {} + + public ClubApplicationDto(Long id, Long clubId, Long departmentId, String applicantName, String clubName, + LocalDate appliedDate, List questionAnswers, + ClubStatus status, String resultMessage) { + this.id = id; + this.clubId = clubId; + this.departmentId = departmentId; + this.applicantName = applicantName; + this.clubName = clubName; + this.appliedDate = appliedDate; + this.questionAnswers = questionAnswers; + this.status = status; + this.resultMessage = resultMessage; + } + + public static ClubApplicationDto from(ClubApplication e) { + return new ClubApplicationDto( + e.getId(), e.getClubId(), e.getDepartmentId(), e.getApplicantName(), e.getClubName(), + e.getAppliedDate(), e.getQuestionAnswers(), e.getStatus(), e.getResultMessage() + ); + } + + // getters/setters + public Long getId() { return id; } + public Long getClubId() { return clubId; } + public Long getDepartmentId() { return departmentId; } + public String getApplicantName() { return applicantName; } + public String getClubName() { return clubName; } + public LocalDate getAppliedDate() { return appliedDate; } + public List getQuestionAnswers() { return questionAnswers; } + public ClubStatus getStatus() { return status; } + public String getResultMessage() { return resultMessage; } + + public void setId(Long id) { this.id = id; } + public void setClubId(Long clubId) { this.clubId = clubId; } + public void setDepartmentId(Long departmentId) { this.departmentId = departmentId; } + public void setApplicantName(String applicantName) { this.applicantName = applicantName; } + public void setClubName(String clubName) { this.clubName = clubName; } + public void setAppliedDate(LocalDate appliedDate) { this.appliedDate = appliedDate; } + public void setQuestionAnswers(List questionAnswers) { this.questionAnswers = questionAnswers; } + public void setStatus(ClubStatus status) { this.status = status; } + public void setResultMessage(String resultMessage) { this.resultMessage = resultMessage; } +} diff --git a/src/main/java/com/JoinUs/dp/dto/ClubSummary.java b/src/main/java/com/JoinUs/dp/dto/ClubSummary.java new file mode 100644 index 0000000..5118d61 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/dto/ClubSummary.java @@ -0,0 +1,18 @@ +// (선택) 부가 응답: 학과별 클럽 목록 요약 +// src/main/java/com/JoinUs/dp/global/domain/application/dto/ClubSummary.java +package com.JoinUs.dp.dto; + +public class ClubSummary { + private Long clubId; + private String clubName; + + public ClubSummary() {} + public ClubSummary(Long clubId, String clubName) { + this.clubId = clubId; + this.clubName = clubName; + } + public Long getClubId() { return clubId; } + public String getClubName() { return clubName; } + public void setClubId(Long clubId) { this.clubId = clubId; } + public void setClubName(String clubName) { this.clubName = clubName; } +} diff --git a/src/main/java/com/JoinUs/dp/dto/NoticeCreateRequest.java b/src/main/java/com/JoinUs/dp/dto/NoticeCreateRequest.java new file mode 100644 index 0000000..1f4469d --- /dev/null +++ b/src/main/java/com/JoinUs/dp/dto/NoticeCreateRequest.java @@ -0,0 +1,20 @@ +package com.JoinUs.dp.dto; + + +public class NoticeCreateRequest { + private String title; + private String content; + + public NoticeCreateRequest() {} + + public NoticeCreateRequest(String title, String content) { + this.title = title; + this.content = content; + } + + public String getTitle() { return title; } + public String getContent() { return content; } + + public void setTitle(String title) { this.title = title; } + public void setContent(String content) { this.content = content; } +} diff --git a/src/main/java/com/JoinUs/dp/dto/NoticeResponse.java b/src/main/java/com/JoinUs/dp/dto/NoticeResponse.java new file mode 100644 index 0000000..9266e31 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/dto/NoticeResponse.java @@ -0,0 +1,34 @@ +package com.JoinUs.dp.dto; + +import java.time.LocalDateTime; + +import com.JoinUs.dp.entity.Notice; + +public class NoticeResponse { + private Long id; + private Long clubId; + private String title; + private String content; + private LocalDateTime createdAt; + private Notice.Type type; + + public NoticeResponse(Long id, Long clubId, String title, String content, LocalDateTime createdAt, Notice.Type type) { + this.id = id; + this.clubId = clubId; + this.title = title; + this.content = content; + this.createdAt = createdAt; + this.type = type; + } + + public Long getId() { return id; } + public Long getClubId() { return clubId; } + public String getTitle() { return title; } + public String getContent() { return content; } + public LocalDateTime getCreatedAt() { return createdAt; } + public Notice.Type getType() { return type; } + + public static NoticeResponse from(Notice n) { + return new NoticeResponse(n.getId(), n.getClubId(), n.getTitle(), n.getContent(), n.getCreatedAt(), n.getType()); + } +} \ No newline at end of file diff --git a/src/main/java/com/JoinUs/dp/dto/QuestionAnswerDto.java b/src/main/java/com/JoinUs/dp/dto/QuestionAnswerDto.java new file mode 100644 index 0000000..9b40ed8 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/dto/QuestionAnswerDto.java @@ -0,0 +1,31 @@ +// src/main/java/com/JoinUs/dp/global/domain/application/dto/QuestionAnswerDto.java +package com.JoinUs.dp.dto; + +import com.JoinUs.dp.entity.QuestionAnswer; + +public class QuestionAnswerDto { + private String question; + private String answer; + + public QuestionAnswerDto() {} + + public QuestionAnswerDto(String question, String answer) { + this.question = question; + this.answer = answer; + } + + public String getQuestion() { return question; } + public String getAnswer() { return answer; } + public void setQuestion(String question) { this.question = question; } + public void setAnswer(String answer) { this.answer = answer; } + + // Entity → DTO 변환 + public static QuestionAnswerDto from(QuestionAnswer qa) { + return new QuestionAnswerDto(qa.getQuestion(), qa.getAnswer()); + } + + // DTO → Entity 변환 + public QuestionAnswer toEntity() { + return new QuestionAnswer(this.question, this.answer); + } +} diff --git a/src/main/java/com/JoinUs/dp/entity/ClubApplication.java b/src/main/java/com/JoinUs/dp/entity/ClubApplication.java new file mode 100644 index 0000000..ad744a8 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/entity/ClubApplication.java @@ -0,0 +1,39 @@ +// src/main/java/com/JoinUs/dp/global/domain/application/entity/ClubApplication.java +package com.JoinUs.dp.entity; + +import java.time.LocalDate; +import java.util.List; + +public class ClubApplication { + private Long id; // 신청 ID + private Long clubId; // 클럽 ID + private Long departmentId; // 학과 ID + private String applicantName; // 신청자 이름 + private String clubName; // 클럽 이름 + private LocalDate appliedDate; // 신청 날짜 + private List questionAnswers; // 질문-답변 목록 + private ClubStatus status; // 신청 상태 + private String resultMessage; // 심사 결과 안내 문구 + + public ClubApplication() {} + + public Long getId() { return id; } + public Long getClubId() { return clubId; } + public Long getDepartmentId() { return departmentId; } + public String getApplicantName() { return applicantName; } + public String getClubName() { return clubName; } + public LocalDate getAppliedDate() { return appliedDate; } + public List getQuestionAnswers() { return questionAnswers; } + public ClubStatus getStatus() { return status; } + public String getResultMessage() { return resultMessage; } + + public void setId(Long id) { this.id = id; } + public void setClubId(Long clubId) { this.clubId = clubId; } + public void setDepartmentId(Long departmentId) { this.departmentId = departmentId; } + public void setApplicantName(String applicantName) { this.applicantName = applicantName; } + public void setClubName(String clubName) { this.clubName = clubName; } + public void setAppliedDate(LocalDate appliedDate) { this.appliedDate = appliedDate; } + public void setQuestionAnswers(List questionAnswers) { this.questionAnswers = questionAnswers; } + public void setStatus(ClubStatus status) { this.status = status; } + public void setResultMessage(String resultMessage) { this.resultMessage = resultMessage; } +} diff --git a/src/main/java/com/JoinUs/dp/entity/ClubStatus.java b/src/main/java/com/JoinUs/dp/entity/ClubStatus.java new file mode 100644 index 0000000..6fe3cb7 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/entity/ClubStatus.java @@ -0,0 +1,11 @@ +// src/main/java/com/JoinUs/dp/global/domain/application/entity/ClubStatus.java +package com.JoinUs.dp.entity; + +public enum ClubStatus { + PENDING, // 심사중(신규) + PASSED, // 합격 (확정 대기) + FAILED, // 불합격 + CONFIRMED, // 합격자 최종 확정 + DECLINED // 합격자 철회 +} + diff --git a/src/main/java/com/JoinUs/dp/entity/Notice.java b/src/main/java/com/JoinUs/dp/entity/Notice.java new file mode 100644 index 0000000..79004d7 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/entity/Notice.java @@ -0,0 +1,39 @@ +package com.JoinUs.dp.entity; + +import java.time.LocalDateTime; + +public class Notice { + public enum Type { + FAQ, CLUB_NOTICE + } + + private Long id; + private Long clubId; // 클럽 공지일 때만 세팅 (FAQ는 null) + private String title; + private String content; + private LocalDateTime createdAt; + private Type type; + + public Notice(Long id, Long clubId, String title, String content, LocalDateTime createdAt, Type type) { + this.id = id; + this.clubId = clubId; + this.title = title; + this.content = content; + this.createdAt = createdAt; + this.type = type; + } + + public Long getId() { return id; } + public Long getClubId() { return clubId; } + public String getTitle() { return title; } + public String getContent() { return content; } + public LocalDateTime getCreatedAt() { return createdAt; } + public Type getType() { return type; } + + public void setId(Long id) { this.id = id; } + public void setClubId(Long clubId) { this.clubId = clubId; } + public void setTitle(String title) { this.title = title; } + public void setContent(String content) { this.content = content; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public void setType(Type type) { this.type = type; } +} \ No newline at end of file diff --git a/src/main/java/com/JoinUs/dp/entity/QuestionAnswer.java b/src/main/java/com/JoinUs/dp/entity/QuestionAnswer.java new file mode 100644 index 0000000..21a7f60 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/entity/QuestionAnswer.java @@ -0,0 +1,17 @@ +// src/main/java/com/JoinUs/dp/global/domain/application/entity/QuestionAnswer.java +package com.JoinUs.dp.entity; + +public class QuestionAnswer { + private String question; + private String answer; + + public QuestionAnswer() {} + public QuestionAnswer(String question, String answer) { + this.question = question; + this.answer = answer; + } + public String getQuestion() { return question; } + public String getAnswer() { return answer; } + public void setQuestion(String question) { this.question = question; } + public void setAnswer(String answer) { this.answer = answer; } +} diff --git a/src/main/java/com/JoinUs/dp/global/common/ApiPath.java b/src/main/java/com/JoinUs/dp/global/common/ApiPath.java deleted file mode 100644 index 146dc83..0000000 --- a/src/main/java/com/JoinUs/dp/global/common/ApiPath.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.JoinUs.dp.global.common; - -import jakarta.annotation.PostConstruct; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -@Component -public class ApiPath { - public static final String API_ROOT = "/api/v1"; - - - // 도메인별 API_PATH 예시 - // public static final String USER_PATH = API_ROOT + "/user"; - - public static String H2_PATH; - - @Value("${spring.h2.console.path}") - private String h2Path; - - @PostConstruct - public void init() { - H2_PATH = h2Path; - } -} diff --git a/src/main/java/com/JoinUs/dp/repository/ClubApplicationRepository.java b/src/main/java/com/JoinUs/dp/repository/ClubApplicationRepository.java new file mode 100644 index 0000000..d2077a2 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/repository/ClubApplicationRepository.java @@ -0,0 +1,92 @@ +// src/main/java/com/JoinUs/dp/global/domain/application/repository/ClubApplicationRepository.java +package com.JoinUs.dp.repository; + +import java.time.LocalDate; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Repository; + +import com.JoinUs.dp.dto.ClubSummary; +import com.JoinUs.dp.entity.ClubApplication; +import com.JoinUs.dp.entity.ClubStatus; +import com.JoinUs.dp.entity.QuestionAnswer; + +@Repository +public class ClubApplicationRepository { + + private final Map storage = Collections.synchronizedMap(new LinkedHashMap<>()); + private final AtomicLong seq = new AtomicLong(1); + + public ClubApplicationRepository() { + // 샘플 데이터 + ClubApplication a = new ClubApplication(); + a.setId(seq.getAndIncrement()); + a.setClubId(101L); + a.setDepartmentId(11L); + a.setClubName("개발동아리"); + a.setApplicantName("홍길동"); + a.setAppliedDate(LocalDate.now().minusDays(2)); + a.setQuestionAnswers(List.of(new QuestionAnswer("지원 동기", "경험을 쌓고 싶어서"))); + a.setStatus(ClubStatus.PENDING); + storage.put(a.getId(), a); + + ClubApplication b = new ClubApplication(); + b.setId(seq.getAndIncrement()); + b.setClubId(202L); + b.setDepartmentId(22L); + b.setClubName("사진동아리"); + b.setApplicantName("이몽룡"); + b.setAppliedDate(LocalDate.now().minusDays(1)); + b.setQuestionAnswers(List.of(new QuestionAnswer("특기", "사진 보정"))); + b.setStatus(ClubStatus.PENDING); + storage.put(b.getId(), b); + } + + public ClubApplication save(ClubApplication app) { + if (app.getId() == null) { + app.setId(seq.getAndIncrement()); + } + if (app.getStatus() == null) { + app.setStatus(ClubStatus.PENDING); + } + if (app.getAppliedDate() == null) { + app.setAppliedDate(LocalDate.now()); + } + storage.put(app.getId(), app); + return app; + } + + public Optional findById(Long id) { + return Optional.ofNullable(storage.get(id)); + } + + public List findAll() { + return new ArrayList<>(storage.values()); + } + + public void deleteById(Long id) { + storage.remove(id); + } + + public List findByClubId(Long clubId) { + return storage.values().stream() + .filter(a -> Objects.equals(a.getClubId(), clubId)) + .collect(Collectors.toList()); + } + + public List findClubsByDepartmentId(Long departmentId) { + // 신청 데이터 기반으로 학과 내 "존재하는" 클럽 목록 요약 + return storage.values().stream() + .filter(a -> Objects.equals(a.getDepartmentId(), departmentId)) + .collect(Collectors.toMap( + ClubApplication::getClubId, + ClubApplication::getClubName, + (v1, v2) -> v1 + )) + .entrySet().stream() + .map(e -> new ClubSummary(e.getKey(), e.getValue())) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/JoinUs/dp/repository/NoticeRepository.java b/src/main/java/com/JoinUs/dp/repository/NoticeRepository.java new file mode 100644 index 0000000..8f3f2a0 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/repository/NoticeRepository.java @@ -0,0 +1,75 @@ +package com.JoinUs.dp.repository; + +import org.springframework.stereotype.Repository; + +import com.JoinUs.dp.entity.Notice; +import com.JoinUs.dp.entity.Notice.Type; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +@Repository +public class NoticeRepository { + + private final List store = Collections.synchronizedList(new ArrayList<>()); + private final AtomicLong seq = new AtomicLong(1); + + public NoticeRepository() { + // 샘플 데이터 (FAQ 2개, 클럽공지 2개) + store.add(new Notice(seq.getAndIncrement(), null, "가입/로그인 안내", "회원가입 및 로그인 방법을 안내합니다.", LocalDateTime.now().minusDays(5), Type.FAQ)); + store.add(new Notice(seq.getAndIncrement(), null, "문의 처리시간", "문의는 평일 10:00~18:00 처리됩니다.", LocalDateTime.now().minusDays(3), Type.FAQ)); + store.add(new Notice(seq.getAndIncrement(), 101L, "개발동아리 세미나 공지", "8/25(월) 18:00, 3층 세미나실", LocalDateTime.now().minusDays(2), Type.CLUB_NOTICE)); + store.add(new Notice(seq.getAndIncrement(), 202L, "사진동아리 출사 공지", "8/28(목) 10:00, 정문 집결", LocalDateTime.now().minusDays(1), Type.CLUB_NOTICE)); + } + + /** FAQ 전체 조회 */ + public List findAllFaq() { + return store.stream() + .filter(n -> n.getType() == Type.FAQ) + .sorted(Comparator.comparing(Notice::getCreatedAt).reversed()) + .collect(Collectors.toList()); + } + + /** 단건 조회 */ + public Optional findById(Long id) { + return store.stream().filter(n -> Objects.equals(n.getId(), id)).findFirst(); + } + + /** 특정 클럽 공지 목록 */ + public List findByClubId(Long clubId) { + return store.stream() + .filter(n -> n.getType() == Type.CLUB_NOTICE && Objects.equals(n.getClubId(), clubId)) + .sorted(Comparator.comparing(Notice::getCreatedAt).reversed()) + .collect(Collectors.toList()); + } + + /** 클럽 공지 등록 */ + public Notice saveClubNotice(Long clubId, String title, String content) { + Notice n = new Notice( + seq.getAndIncrement(), + clubId, + title, + content, + LocalDateTime.now(), + Type.CLUB_NOTICE + ); + store.add(n); + return n; + } + + /** ✅ FAQ 등록 (요청하신 메서드 추가) */ + public Notice saveFaq(String title, String content) { + Notice n = new Notice( + seq.getAndIncrement(), + null, // clubId 없음 + title, + content, + LocalDateTime.now(), + Type.FAQ + ); + store.add(n); + return n; + } +} diff --git a/src/main/java/com/JoinUs/dp/service/ClubApplicationService.java b/src/main/java/com/JoinUs/dp/service/ClubApplicationService.java new file mode 100644 index 0000000..207611d --- /dev/null +++ b/src/main/java/com/JoinUs/dp/service/ClubApplicationService.java @@ -0,0 +1,167 @@ +// src/main/java/com/JoinUs/dp/global/domain/application/service/ClubApplicationService.java +package com.JoinUs.dp.service; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; +// import org.springframework.transaction.annotation.Transactional; // ← 트랜잭션이 필요하면 주석 해제 + +import com.JoinUs.dp.common.exception.NotFoundException; +import com.JoinUs.dp.dto.ClubApplicationDto; +import com.JoinUs.dp.dto.ClubSummary; +import com.JoinUs.dp.entity.ClubApplication; +import com.JoinUs.dp.entity.ClubStatus; +import com.JoinUs.dp.repository.ClubApplicationRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ClubApplicationService { + + // ✅ 잘못된 자기 주입 제거, Repository 주입으로 수정 + private final ClubApplicationRepository repository; + + // -------------------- 기본 CRUD -------------------- + + // @Transactional + public ClubApplicationDto apply(ClubApplicationDto dto) { + ClubApplication e = toEntity(dto); + e.setId(null); + e.setStatus(ClubStatus.PENDING); + e.setAppliedDate(e.getAppliedDate() == null ? LocalDate.now() : e.getAppliedDate()); + return toDto(repository.save(e)); + } + + public List findAll() { + return repository.findAll().stream().map(this::toDto).toList(); + } + + public ClubApplicationDto findById(Long id) { + ClubApplication e = repository.findById(id) + .orElseThrow(() -> new NotFoundException("신청을 찾을 수 없습니다. id=" + id)); + return toDto(e); + } + + // @Transactional + public void cancel(Long id) { + if (repository.findById(id).isEmpty()) { + throw new NotFoundException("신청을 찾을 수 없습니다. id=" + id); + } + repository.deleteById(id); + } + + // @Transactional + public ClubApplicationDto update(ClubApplicationDto dto) { + if (dto.getId() == null) throw new IllegalArgumentException("id는 필수입니다."); + ClubApplication e = repository.findById(dto.getId()) + .orElseThrow(() -> new NotFoundException("신청을 찾을 수 없습니다. id=" + dto.getId())); + + e.setClubId(dto.getClubId()); + e.setDepartmentId(dto.getDepartmentId()); + e.setApplicantName(dto.getApplicantName()); + e.setClubName(dto.getClubName()); + e.setAppliedDate(dto.getAppliedDate()); + e.setQuestionAnswers(dto.getQuestionAnswers()); + e.setStatus(dto.getStatus()); + e.setResultMessage(dto.getResultMessage()); + + return toDto(repository.save(e)); + } + + // @Transactional + public ClubApplicationDto partialUpdate(Long id, Map updates) { + ClubApplication e = repository.findById(id) + .orElseThrow(() -> new NotFoundException("신청을 찾을 수 없습니다. id=" + id)); + + if (updates.containsKey("clubId")) e.setClubId(Long.valueOf(updates.get("clubId").toString())); + if (updates.containsKey("departmentId")) e.setDepartmentId(Long.valueOf(updates.get("departmentId").toString())); + if (updates.containsKey("applicantName")) e.setApplicantName((String) updates.get("applicantName")); + if (updates.containsKey("clubName")) e.setClubName((String) updates.get("clubName")); + if (updates.containsKey("appliedDate")) e.setAppliedDate(LocalDate.parse((String) updates.get("appliedDate"))); + if (updates.containsKey("resultMessage")) e.setResultMessage((String) updates.get("resultMessage")); + // NOTE: questionAnswers / status는 별도 DTO/검증을 권장 + + return toDto(repository.save(e)); + } + + // -------------------- 추가 기능 -------------------- + + public List findByClubId(Long clubId) { + return repository.findByClubId(clubId).stream().map(this::toDto).toList(); + } + + // @Transactional + public ClubApplicationDto setResult(Long id, String result, String message) { + ClubApplication e = repository.findById(id) + .orElseThrow(() -> new NotFoundException("신청을 찾을 수 없습니다. id=" + id)); + + if ("pass".equalsIgnoreCase(result)) { + e.setStatus(ClubStatus.PASSED); + } else if ("fail".equalsIgnoreCase(result)) { + e.setStatus(ClubStatus.FAILED); + } else { + throw new IllegalArgumentException("result 값은 pass 또는 fail 이어야 합니다."); + } + e.setResultMessage(message); + return toDto(repository.save(e)); + } + + // @Transactional + public ClubApplicationDto confirmOrDecline(Long id, String action) { + ClubApplication e = repository.findById(id) + .orElseThrow(() -> new NotFoundException("신청을 찾을 수 없습니다. id=" + id)); + + // 기본 정책: PASSED 상태에서 확정/철회 가능, (옵션) 확정 후 철회도 허용 + if (!(e.getStatus() == ClubStatus.PASSED || e.getStatus() == ClubStatus.CONFIRMED)) { + throw new IllegalStateException("현재 상태에서 확정/철회할 수 없습니다. status=" + e.getStatus()); + } + + if ("confirm".equalsIgnoreCase(action)) { + e.setStatus(ClubStatus.CONFIRMED); + } else if ("decline".equalsIgnoreCase(action)) { + e.setStatus(ClubStatus.DECLINED); + } else { + throw new IllegalArgumentException("action 값은 confirm 또는 decline 이어야 합니다."); + } + return toDto(repository.save(e)); + } + + // @Transactional + public ClubApplicationDto additionalOffer(Long id) { + ClubApplication e = repository.findById(id) + .orElseThrow(() -> new NotFoundException("신청을 찾을 수 없습니다. id=" + id)); + if (e.getStatus() != ClubStatus.FAILED) { + throw new IllegalStateException("FAILED 상태인 지원자에게만 추가 합격 통보가 가능합니다."); + } + e.setStatus(ClubStatus.PASSED); + e.setResultMessage("추가 합격 통보"); + return toDto(repository.save(e)); + } + + public List getClubsByDepartment(Long departmentId) { + return repository.findClubsByDepartmentId(departmentId); + } + + // -------------------- 내부 변환 -------------------- + + private ClubApplicationDto toDto(ClubApplication e) { + return ClubApplicationDto.from(e); + } + + private ClubApplication toEntity(ClubApplicationDto dto) { + ClubApplication e = new ClubApplication(); + e.setId(dto.getId()); + e.setClubId(dto.getClubId()); + e.setDepartmentId(dto.getDepartmentId()); + e.setApplicantName(dto.getApplicantName()); + e.setClubName(dto.getClubName()); + e.setAppliedDate(dto.getAppliedDate()); + e.setQuestionAnswers(dto.getQuestionAnswers()); + e.setStatus(dto.getStatus()); + e.setResultMessage(dto.getResultMessage()); + return e; + } +} diff --git a/src/main/java/com/JoinUs/dp/service/NoticeService.java b/src/main/java/com/JoinUs/dp/service/NoticeService.java new file mode 100644 index 0000000..e465b10 --- /dev/null +++ b/src/main/java/com/JoinUs/dp/service/NoticeService.java @@ -0,0 +1,51 @@ +package com.JoinUs.dp.service; + +import com.JoinUs.dp.common.exception.NotFoundException; +import com.JoinUs.dp.dto.NoticeResponse; +import com.JoinUs.dp.entity.Notice; +import com.JoinUs.dp.repository.NoticeRepository; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class NoticeService { + + private final NoticeRepository noticeRepository; + + // FAQ 목록 + public List getFaqList() { + return noticeRepository.findAllFaq().stream() + .map(NoticeResponse::from) + .toList(); + } + + // 공지 상세 (FAQ/클럽공지 공통) + public NoticeResponse getNoticeDetail(Long id) { + Notice n = noticeRepository.findById(id) + .orElseThrow(() -> new NotFoundException("해당 공지를 찾을 수 없습니다. id=" + id)); + return NoticeResponse.from(n); + } + + // 특정 클럽 공지 목록 + public List getClubNotices(Long clubId) { + return noticeRepository.findByClubId(clubId).stream() + .map(NoticeResponse::from) + .toList(); + } + + // 특정 클럽 공지 등록 + public NoticeResponse createClubNotice(Long clubId, String title, String content) { + Notice saved = noticeRepository.saveClubNotice(clubId, title, content); + return NoticeResponse.from(saved); + } + + // ✅ FAQ 등록 (Repository.saveFaq 사용) + public NoticeResponse createFaq(String title, String content) { + Notice saved = noticeRepository.saveFaq(title, content); + return NoticeResponse.from(saved); + } +} diff --git a/src/test/java/com/JoinUs/dp/DpApplicationTests.java b/src/test/java/com/JoinUs/dp/DpApplicationTests.java deleted file mode 100644 index 6db4fc6..0000000 --- a/src/test/java/com/JoinUs/dp/DpApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.JoinUs.dp; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class DpApplicationTests { - - @Test - void contextLoads() { - } - -}