diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..c74c7aa --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +## #️⃣연관된 이슈 + +> ex) #이슈번호, #이슈번호 + +## 📝작업 내용 + +> 이번 PR에서 작업한 내용을 간략히 설명해주세요(이미지 첨부 가능) + +### 스크린샷 (선택) + +## 💬리뷰 요구사항(선택) + +> 리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요 +> +> ex) 메서드 XXX의 이름을 더 잘 짓고 싶은데 혹시 좋은 명칭이 있을까요? \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..302165e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + pull_request: + branches: [ develop ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'corretto' + + - name: Grant execute permission for gradlew + run: chmod +x backend/gradlew + + - name: Build with Gradle + working-directory: backend + run: ./gradlew bootJar -x test + env: + RDS_ENDPOINT: ${{ secrets.RDS_ENDPOINT }} + RDS_DB_NAME: ${{ secrets.RDS_DB_NAME }} + RDS_USERNAME: ${{ secrets.RDS_USERNAME }} + RDS_PASSWORD: ${{ secrets.RDS_PASSWORD }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} + JWT_EXPIRATION: ${{ secrets.JWT_EXPIRATION }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..2d5b3cd --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,71 @@ +name: Deploy to EC2 + +on: + push: + branches: [ develop ] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'corretto' + + - name: Grant execute permission for gradlew + run: chmod +x backend/gradlew + + - name: Build with Gradle + working-directory: backend + run: ./gradlew bootJar -x test + env: + RDS_ENDPOINT: ${{ secrets.RDS_ENDPOINT }} + RDS_DB_NAME: ${{ secrets.RDS_DB_NAME }} + RDS_USERNAME: ${{ secrets.RDS_USERNAME }} + RDS_PASSWORD: ${{ secrets.RDS_PASSWORD }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} + JWT_EXPIRATION: ${{ secrets.JWT_EXPIRATION }} + + - name: Log in to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Build and push Docker image + working-directory: backend + run: | + docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/piroin-backend:latest . + docker push ${{ secrets.DOCKERHUB_USERNAME }}/piroin-backend:latest + + - name: Deploy to EC2 + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/piroin-backend:latest + docker stop piroin-backend || true + docker rm piroin-backend || true + docker run -d \ + --name piroin-backend \ + --restart unless-stopped \ + -p 8080:8080 \ + --health-cmd="curl -f http://localhost:8080/actuator/health || exit 1" \ + --health-interval=30s \ + --health-timeout=10s \ + --health-retries=3 \ + -e RDS_ENDPOINT="${{ secrets.RDS_ENDPOINT }}" \ + -e RDS_DB_NAME="${{ secrets.RDS_DB_NAME }}" \ + -e RDS_USERNAME="${{ secrets.RDS_USERNAME }}" \ + -e RDS_PASSWORD="${{ secrets.RDS_PASSWORD }}" \ + -e JWT_SECRET="${{ secrets.JWT_SECRET }}" \ + -e JWT_EXPIRATION="${{ secrets.JWT_EXPIRATION }}" \ + ${{ secrets.DOCKERHUB_USERNAME }}/piroin-backend:latest diff --git a/backend/build.gradle b/backend/build.gradle index e12ebee..d7f248a 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -30,8 +30,24 @@ dependencies { testAnnotationProcessor 'org.projectlombok:lombok' // Swagger - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:2.5.0' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.0' + + // Actuator + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + + // Flyway + implementation 'org.springframework.boot:spring-boot-flyway' + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-database-postgresql' + + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + + // JJWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/controller/AssignmentController.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/controller/AssignmentController.java index 262c879..ec90cfa 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/assignment/controller/AssignmentController.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/assignment/controller/AssignmentController.java @@ -1,4 +1,78 @@ package com.example.Piroin.project.domain.assignment.controller; +import com.example.Piroin.project.domain.assignment.dto.*; +import com.example.Piroin.project.domain.assignment.entity.DeleteAssignmentResponse; +import com.example.Piroin.project.domain.assignment.service.AssignmentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/assignments") +@Tag(name = "피로체크 과제 관리", description = "피로체크 과제 관리 API") public class AssignmentController { + + private final AssignmentService assignmentService; + + // 1. 과제 생성 + @Operation(summary = "과제 생성", description = "새로운 과제를 운영진이 입력하여 생성합니다.") + @PostMapping("/create") + @ResponseStatus(HttpStatus.CREATED) + public CreateAssignmentResponse createAssignment( + @RequestBody CreateAssignmentRequest request + ) { + return assignmentService.createAssignment(request); + } + + // 2. 과제 수정 + @Operation(summary = "과제 수정", description = "과제에 대한 과제명/주차/날짜를 운영진이 수정합니다.") + @PatchMapping("/modify/{assignmentId}") + public ModifyAssignmentResponse modifyAssignment( + @PathVariable Integer assignmentId, + @RequestBody ModifyAssignmentRequest request + ) { + return assignmentService.modifyAssignment(assignmentId, request); + } + + + // 3. 과제 삭제 + @Operation(summary = "과제 삭제", description = "운영진이 과제를 삭제합니다.") + @DeleteMapping("/{assignmentId}") + @ResponseStatus(HttpStatus.OK) + public DeleteAssignmentResponse deleteAssignment( + @PathVariable Integer assignmentId + ) { + + return assignmentService.deleteAssignment(assignmentId); + } + + // 4. 나의 과제 상태 조회 (부원) + @Operation(summary = "나의 과제 조회", description = "부원이 본인의 과제 상태를 조회합니다.") + @GetMapping("/me/{week}") + public GetMyAssignmentsResponse getMyAssignments( + @PathVariable String week, + Authentication authentication + ) { + + Long userId = Long.valueOf(authentication.getName()); + + return assignmentService.getMyAssignments(userId, week); + } + + // 5. 생성한 과제 조회 (운영진) + @Operation(summary = "과제 목록 조회", description = "주차별로 화/목/토 과제 목록을 조회합니다.") + @GetMapping("/{week}/view") + public AssignmentWeekViewResponse getAssignmentView( + @PathVariable String week + ) { + return assignmentService.getAssignmentView(week); + } + + } + diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/AssignmentInfoResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/AssignmentInfoResponse.java new file mode 100644 index 0000000..61d3263 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/AssignmentInfoResponse.java @@ -0,0 +1,22 @@ +package com.example.Piroin.project.domain.assignment.dto; + +import com.example.Piroin.project.domain.assignment.enums.AssignmentStatus; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class AssignmentInfoResponse { + + private Integer assignmentId; + + private String title; + + private String week; + + private String sessionDate; + + private String day; + + private AssignmentStatus submitted; +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/AssignmentReqDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/AssignmentReqDTO.java deleted file mode 100644 index 1fe8227..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/AssignmentReqDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.assignment.dto; - -public class AssignmentReqDTO { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/AssignmentResDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/AssignmentResDTO.java deleted file mode 100644 index 23daab7..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/AssignmentResDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.assignment.dto; - -public class AssignmentResDTO { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/AssignmentWeekViewResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/AssignmentWeekViewResponse.java new file mode 100644 index 0000000..cf4754c --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/AssignmentWeekViewResponse.java @@ -0,0 +1,35 @@ +package com.example.Piroin.project.domain.assignment.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +public class AssignmentWeekViewResponse { + + private String week; + + private List days; + + @Getter + @Builder + @AllArgsConstructor + public static class DayAssignmentResponse { + private String day; + private LocalDate sessionDate; + private List assignments; + } + + @Getter + @Builder + @AllArgsConstructor + public static class AssignmentInfo { + private Integer assignmentId; + private String title; + } +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/CreateAssignmentRequest.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/CreateAssignmentRequest.java new file mode 100644 index 0000000..adec845 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/CreateAssignmentRequest.java @@ -0,0 +1,16 @@ +package com.example.Piroin.project.domain.assignment.dto; + +import lombok.Getter; + +import java.time.DayOfWeek; +import java.time.LocalDate; + +@Getter +public class CreateAssignmentRequest { + + private String title; + + private String week; + + private DayOfWeek day; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/CreateAssignmentResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/CreateAssignmentResponse.java new file mode 100644 index 0000000..97c7dc5 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/CreateAssignmentResponse.java @@ -0,0 +1,11 @@ +package com.example.Piroin.project.domain.assignment.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class CreateAssignmentResponse { + + private Integer assignmentId; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/GetMyAssignmentsResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/GetMyAssignmentsResponse.java new file mode 100644 index 0000000..015c6e4 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/GetMyAssignmentsResponse.java @@ -0,0 +1,17 @@ +package com.example.Piroin.project.domain.assignment.dto; + +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +@JsonPropertyOrder({"week", "assignments"}) +public class GetMyAssignmentsResponse { + + private String week; + + private List assignments; +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/ModifyAssignmentRequest.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/ModifyAssignmentRequest.java new file mode 100644 index 0000000..b13024a --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/ModifyAssignmentRequest.java @@ -0,0 +1,15 @@ +package com.example.Piroin.project.domain.assignment.dto; + +import lombok.Getter; + +import java.time.DayOfWeek; +import java.time.LocalDate; + +@Getter +public class ModifyAssignmentRequest { + private String title; + + private String week; + + private DayOfWeek day; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/ModifyAssignmentResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/ModifyAssignmentResponse.java new file mode 100644 index 0000000..6825a46 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/assignment/dto/ModifyAssignmentResponse.java @@ -0,0 +1,11 @@ +package com.example.Piroin.project.domain.assignment.dto; + + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ModifyAssignmentResponse { + private Integer assignmentId; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/entity/Assignment.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/entity/Assignment.java new file mode 100644 index 0000000..0145c62 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/assignment/entity/Assignment.java @@ -0,0 +1,42 @@ +package com.example.Piroin.project.domain.assignment.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDate; + +@Entity +@Table(name = "assignment") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Assignment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; // SERIAL 타입에 매칭 + + @Column(nullable = false) + private String title; + + @Column(length = 255) + private String week; + + @Column(name = "session_date") + private LocalDate sessionDate; // DATE 타입에 매칭 + + public void update(String title, String week, LocalDate sessionDate) { + + if (title != null) { + this.title = title; + } + + if (week != null) { + this.week = week; + } + + if (sessionDate != null) { + this.sessionDate = sessionDate; + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/entity/AssignmentItem.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/entity/AssignmentItem.java new file mode 100644 index 0000000..b81e9d3 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/assignment/entity/AssignmentItem.java @@ -0,0 +1,44 @@ +package com.example.Piroin.project.domain.assignment.entity; + +import com.example.Piroin.project.domain.assignment.enums.AssignmentStatus; +import com.example.Piroin.project.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table( + name = "assignment_item", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_assignment_item_user_assignment", + columnNames = {"user_id", "assignment_id"} + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class AssignmentItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "assignment_id", nullable = false) + private Assignment assignment; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private AssignmentStatus submitted; + + public void updateSubmitted(AssignmentStatus submitted) { + this.submitted = submitted; + } + +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/entity/DeleteAssignmentResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/entity/DeleteAssignmentResponse.java new file mode 100644 index 0000000..837ea68 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/assignment/entity/DeleteAssignmentResponse.java @@ -0,0 +1,11 @@ +package com.example.Piroin.project.domain.assignment.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class DeleteAssignmentResponse { + + private Integer assignmentId; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/entity/assignment.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/entity/assignment.java deleted file mode 100644 index 9870d12..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/assignment/entity/assignment.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.assignment.entity; - -public class assignment { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/entity/assignment_item.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/entity/assignment_item.java deleted file mode 100644 index aecf4b1..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/assignment/entity/assignment_item.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.assignment.entity; - -public class assignment_item { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/enums/AssignmentStatus.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/enums/AssignmentStatus.java new file mode 100644 index 0000000..e3902db --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/assignment/enums/AssignmentStatus.java @@ -0,0 +1,14 @@ +package com.example.Piroin.project.domain.assignment.enums; + +public enum AssignmentStatus { + + SUCCESS, // 정상 제출 (0원) + + INSUFFICIENT_MINOR, // 경미한 불충분 (-10000) + + INSUFFICIENT_MAJOR, // 심각한 불충분 (-20000) + + FAILURE, // 미제출 (-20000) + + PENDING // 아직 채점 안 됨 +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/enums/SubmitStatus.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/enums/SubmitStatus.java deleted file mode 100644 index 46f850f..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/assignment/enums/SubmitStatus.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.assignment.enums; - -public enum SubmitStatus { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/exception/AssignmentException.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/exception/AssignmentException.java index 6738b3f..1b1dc69 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/assignment/exception/AssignmentException.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/assignment/exception/AssignmentException.java @@ -1,7 +1,9 @@ package com.example.Piroin.project.domain.assignment.exception; +import com.example.Piroin.project.domain.assignment.exception.code.AssignmentErrorCode; + public class AssignmentException extends RuntimeException { - public AssignmentException(String message) { - super(message); + public AssignmentException(AssignmentErrorCode message) { + super(String.valueOf(message)); } } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/exception/code/AssignmentErrorCode.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/exception/code/AssignmentErrorCode.java index 24e856b..3221072 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/assignment/exception/code/AssignmentErrorCode.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/assignment/exception/code/AssignmentErrorCode.java @@ -1,4 +1,26 @@ package com.example.Piroin.project.domain.assignment.exception.code; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor public enum AssignmentErrorCode { -} + + ASSIGNMENT_CREATE_FAILED( + HttpStatus.BAD_REQUEST, + "ASSIGNMENT400", + "과제 생성에 실패했습니다." + ), + + ASSIGNMENT_NOT_FOUND( + HttpStatus.NOT_FOUND, + "ASSIGNMENT404", + "해당 과제를 찾을 수 없습니다." + ); + + private final HttpStatus status; + private final String code; + private final String message; +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/repository/AssignmentItemRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/repository/AssignmentItemRepository.java new file mode 100644 index 0000000..9b2be1e --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/assignment/repository/AssignmentItemRepository.java @@ -0,0 +1,23 @@ +package com.example.Piroin.project.domain.assignment.repository; + +import com.example.Piroin.project.domain.assignment.entity.AssignmentItem; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AssignmentItemRepository extends JpaRepository { + + void deleteAllByAssignmentId(Integer assignmentId); + + Optional findByUserIdAndAssignmentId(Long userId, Integer assignmentId); + + List findByUserIdAndAssignmentWeek( + Long userId, + String week + ); + + Optional findById(Integer id); + + List findByUserId(Long userId); +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/repository/AssignmentRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/repository/AssignmentRepository.java index 55408df..3f98cd5 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/assignment/repository/AssignmentRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/assignment/repository/AssignmentRepository.java @@ -1,4 +1,16 @@ package com.example.Piroin.project.domain.assignment.repository; -public interface AssignmentRepository { +import com.example.Piroin.project.domain.assignment.entity.Assignment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; + +public interface AssignmentRepository extends JpaRepository { + + List findByWeekOrderBySessionDateAsc(String week); + + List findBySessionDate(LocalDate sessionDate); + + List findBySessionDateOrderByIdAsc(LocalDate sessionDate); } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/service/AssignmentService.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/service/AssignmentService.java index fbd705a..73fe0c5 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/assignment/service/AssignmentService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/assignment/service/AssignmentService.java @@ -1,4 +1,381 @@ package com.example.Piroin.project.domain.assignment.service; +import com.example.Piroin.project.domain.assignment.dto.*; +import com.example.Piroin.project.domain.assignment.entity.Assignment; +import com.example.Piroin.project.domain.assignment.entity.AssignmentItem; +import com.example.Piroin.project.domain.assignment.entity.DeleteAssignmentResponse; +import com.example.Piroin.project.domain.assignment.enums.AssignmentStatus; +import com.example.Piroin.project.domain.assignment.exception.AssignmentException; +import com.example.Piroin.project.domain.assignment.exception.code.AssignmentErrorCode; +import com.example.Piroin.project.domain.assignment.repository.AssignmentItemRepository; +import com.example.Piroin.project.domain.assignment.repository.AssignmentRepository; +import com.example.Piroin.project.domain.attendance.entity.Attendance; +import com.example.Piroin.project.domain.attendance.entity.AttendanceCode; +import com.example.Piroin.project.domain.attendance.repository.AttendanceCodeRepository; +import com.example.Piroin.project.domain.attendance.repository.AttendanceRepository; +import com.example.Piroin.project.domain.curriculum.entity.StudySession; +import com.example.Piroin.project.domain.curriculum.exception.CurriculumException; +import com.example.Piroin.project.domain.curriculum.exception.code.CurriculumErrorCode; +import com.example.Piroin.project.domain.curriculum.repository.CurriculumRepository; +import com.example.Piroin.project.domain.curriculum.service.CurriculumService; +import com.example.Piroin.project.domain.user.dto.*; +import com.example.Piroin.project.domain.user.entity.User; +import com.example.Piroin.project.domain.user.enums.Role; +import com.example.Piroin.project.domain.user.repository.UserRepository; +import com.example.Piroin.project.domain.user.service.UserService; +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional public class AssignmentService { + + private final AssignmentRepository assignmentRepository; + private final AssignmentItemRepository assignmentItemRepository; + private final UserRepository userRepository; + private final AttendanceRepository attendanceRepository; + private final CurriculumRepository curriculumRepository; + private final AttendanceCodeRepository attendanceCodeRepository; + + // 1. 과제 생성 + @Transactional + public CreateAssignmentResponse createAssignment( + CreateAssignmentRequest request + ) { + + // week 문자열 -> Long 변환 + Long week = Long.valueOf(request.getWeek()); + + // 해당 주차 세션들 조회 + List sessions = + curriculumRepository.findByWeek(week); + + // 요청한 요일과 일치하는 세션 찾기 + StudySession matchedSession = sessions.stream() + .filter(session -> + session.getSessionDate().getDayOfWeek() + == request.getDay() + ) + .findFirst() + .orElseThrow(() -> + new CurriculumException( + CurriculumErrorCode.SESSION_DATE_NOT_FOUND + )); + + // 실제 날짜 추출 + LocalDate sessionDate = matchedSession.getSessionDate(); + + // Assignment 생성 + Assignment assignment = Assignment.builder() + .title(request.getTitle()) + .week(request.getWeek()) + .sessionDate(sessionDate) + .build(); + + assignmentRepository.save(assignment); + + + // 생성한 과제로 모든 부원에게 assignmentItem 생성하기 + // ADMIN 제외 MEMBER만 조회 추천 + List users = + userRepository.findByRole(Role.MEMBER); + + List assignmentItems = users.stream() + .map(user -> AssignmentItem.builder() + .user(user) + .assignment(assignment) + .submitted(AssignmentStatus.PENDING) + .build()) + .toList(); + + assignmentItemRepository.saveAll(assignmentItems); + + return new CreateAssignmentResponse(assignment.getId()); + } + + + // 2. 과제 수정 + @Transactional + public ModifyAssignmentResponse modifyAssignment( + Integer assignmentId, + ModifyAssignmentRequest request + ) { + + Assignment assignment = assignmentRepository.findById(assignmentId) + .orElseThrow(() -> new AssignmentException( + AssignmentErrorCode.ASSIGNMENT_NOT_FOUND + )); + + /* + 1. 최종적으로 사용할 week 결정 + - request에 있으면 그 값 사용 + - 없으면 기존 assignment 값 사용 + */ + String finalWeek = request.getWeek() != null + ? request.getWeek() + : assignment.getWeek(); + + /* + 2. 최종적으로 사용할 day 결정 + - request에 있으면 그 값 사용 + - 없으면 기존 sessionDate의 요일 사용 + */ + DayOfWeek finalDay = request.getDay() != null + ? request.getDay() + : assignment.getSessionDate().getDayOfWeek(); + + /* + 3. week/day 조합으로 StudySession 조회해서 + 새로운 sessionDate 계산 + */ + + // request에서 보낸 주차에 해당하는 세션들을 전부 찾아 리스트에 저장. + List weekSessions = curriculumRepository.findByWeek(Long.parseLong(finalWeek)); + + // request에서 보낸 요일에 해당하는 세션을 찾음. + StudySession studySession = weekSessions.stream() + .filter(s -> s.getSessionDate().getDayOfWeek() == finalDay) // 자바끼리 요일 비교 + .findFirst() + .orElseThrow(() -> new CurriculumException( + CurriculumErrorCode.STUDY_SESSION_NOT_FOUND + )); + + // 그 세션의 날짜를 추출. + LocalDate newSessionDate = studySession.getSessionDate(); + + /* + 4. 수정 적용 + */ + assignment.update( + request.getTitle(), + finalWeek, + newSessionDate + ); + + return new ModifyAssignmentResponse(assignment.getId()); + } + + // 3. 과제 삭제 + public DeleteAssignmentResponse deleteAssignment(Integer assignmentId) { + + Assignment assignment = assignmentRepository.findById(assignmentId) + .orElseThrow(() -> + new AssignmentException( + AssignmentErrorCode.ASSIGNMENT_NOT_FOUND + ) + ); + + // assignment_item 먼저 삭제 + assignmentItemRepository.deleteAllByAssignmentId(assignmentId); + + // assignment 삭제 + assignmentRepository.delete(assignment); + + return new DeleteAssignmentResponse(assignmentId); + } + + // 4-1. 나의 과제 조회 (부원) + public GetMyAssignmentsResponse getMyAssignments( + Long userId, + String week + ) { + + List assignments = + assignmentRepository.findByWeekOrderBySessionDateAsc(week); + + List responses = + assignments.stream() + .map(assignment -> { + + AssignmentStatus submittedStatus = + assignmentItemRepository + .findByUserIdAndAssignmentId( + userId, + assignment.getId() + ) + .map(AssignmentItem::getSubmitted) + .orElse(AssignmentStatus.PENDING); + + return AssignmentInfoResponse.builder() + .assignmentId(assignment.getId()) + .title(assignment.getTitle()) + .week(assignment.getWeek()) + .sessionDate(assignment.getSessionDate().toString()) + .day(convertDay( + assignment.getSessionDate().getDayOfWeek() + )) + .submitted(submittedStatus) + .build(); + }) + .toList(); + + return GetMyAssignmentsResponse.builder() + .week(week) + .assignments(responses) + .build(); + } + + // 4-2. 날짜를 요일로 전환 함수 + private String convertDay(DayOfWeek dayOfWeek) { + + return switch (dayOfWeek) { + case MONDAY -> "MONDAY"; + case TUESDAY -> "TUESDAY"; + case WEDNESDAY -> "WEDNESDAY"; + case THURSDAY -> "THURSDAY"; + case FRIDAY -> "FRIDAY"; + case SATURDAY -> "SATURDAY"; + case SUNDAY -> "SUNDAY"; + }; + } + + + // 5. (운영진) 학생들 과제 상태 열람 + @Transactional(readOnly = true) + public StudentWeeklyStatusResponse getStudentWeeklyStatus( + Long userId, + Long week + ) { + + List sessions = + curriculumRepository.findByWeek(week); + + List dayResponses = new ArrayList<>(); + + for (StudySession session : sessions) { + + LocalDate sessionDate = session.getSessionDate(); + + String day = + sessionDate.getDayOfWeek().toString(); + + /* + * 과제 조회 + */ + List assignments = + assignmentRepository.findBySessionDate(sessionDate); + + List assignmentResponses = + assignments.stream() + .map(assignment -> { + + AssignmentItem item = + assignmentItemRepository + .findByUserIdAndAssignmentId( + userId, + assignment.getId() + ) + .orElse(null); + + String submitted = + item == null + ? "PENDING" + : item.getSubmitted().name(); + + return AssignmentStatusResponse.builder() + .assignmentItemId( + item != null ? item.getId() : null + ) + .assignmentId(assignment.getId()) + .title(assignment.getTitle()) + .submitted(submitted) + .build(); + }) + .toList(); + + /* + * 출석 조회 + */ + List attendanceCodes = + attendanceCodeRepository.findByAttendanceDate(sessionDate); + + List attendanceResponses = + attendanceCodes.stream() + .map(code -> { + + Attendance attendance = + attendanceRepository + .findByUserIdAndAttendanceCodeId( + userId, + code.getId() + ) + .orElse(null); + + boolean attended = + attendance != null && + attendance.getStatus(); + + return AttendanceStatusResponse.builder() + .attendanceId( + attendance != null ? attendance.getId() : null + ) + .attendanceCodeId(code.getId()) + .attendanceOrder(code.getAttendanceOrder()) + .attended(attended) + .build(); + }) + .toList(); + + dayResponses.add( + DayStatusResponse.builder() + .day(day) + .sessionDate(sessionDate) + .assignments(assignmentResponses) + .attendances(attendanceResponses) + .build() + ); + } + + return StudentWeeklyStatusResponse.builder() + .week(week) + .days(dayResponses) + .build(); + } + + + // 6. 생성한 과제 조회 + @Transactional(readOnly = true) + public AssignmentWeekViewResponse getAssignmentView(String week) { + + Long weekValue = Long.valueOf(week); + + List sessions = + curriculumRepository.findByWeekOrderBySessionDateAsc(weekValue); + + List days = + sessions.stream() + .map(session -> { + LocalDate sessionDate = session.getSessionDate(); + + List assignments = + assignmentRepository.findBySessionDateOrderByIdAsc(sessionDate) + .stream() + .map(assignment -> + AssignmentWeekViewResponse.AssignmentInfo.builder() + .assignmentId(assignment.getId()) + .title(assignment.getTitle()) + .build() + ) + .toList(); + + return AssignmentWeekViewResponse.DayAssignmentResponse.builder() + .day(sessionDate.getDayOfWeek().toString()) + .sessionDate(sessionDate) + .assignments(assignments) + .build(); + }) + .toList(); + + return AssignmentWeekViewResponse.builder() + .week(week) + .days(days) + .build(); + } } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/controller/AdminAttendanceController.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/controller/AdminAttendanceController.java new file mode 100644 index 0000000..bb1f97c --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/controller/AdminAttendanceController.java @@ -0,0 +1,86 @@ +package com.example.Piroin.project.domain.attendance.controller; + +import com.example.Piroin.project.domain.attendance.dto.*; +import com.example.Piroin.project.domain.attendance.entity.AttendanceCode; +import com.example.Piroin.project.domain.attendance.service.AttendanceService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api") +@Tag(name = "관리자 출석관리", description = "관리자용 출석 관리 API") +public class AdminAttendanceController { + + private final AttendanceService attendanceService; + + // 1. 출석체크 시작 + @Operation(summary = "출석 체크 시작(출석코드 생성)", description = "새로운 출석 코드를 생성하고 출석 체크를 시작합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "출석 코드 생성 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청") + }) + @PostMapping("/admin/attendance/start") + public AttendanceCodeResponse startAttendance() { + + // 오늘 날짜 + LocalDate today = LocalDate.now(); + + // 서비스 호출 + AttendanceCode code = + attendanceService.generateCodeAndCreateAttendances(today); + + return AttendanceCodeResponse.from(code); + } + + + // 2. 현재 활성화된 출석코드 조회 API + @Operation(summary = "현재 활성화된 출석 코드 조회", description = "현재 활성화된 출석 코드 정보를 조회합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "활성화된 출석 코드 없음") + }) + @GetMapping("/admin/attendance/active-code") + public AttendanceCodeResponse getActiveCode() { + return attendanceService.getActiveAttendanceCode() // 1. 서비스 호출 + .map(AttendanceCodeResponse::from) // 2. 값이 있으면 DTO로 변환 + .orElseThrow(() -> new RuntimeException("현재 활성화된 출석코드가 없습니다")); // 3. 없으면 예외 발생 + } + + // 3. 출석체크 종료 새 url. + @Operation(summary = "현재 활성화된 출석 코드 만료", description = "현재 활성화된 최신 출석 코드를 만료 처리합니다.") + @PutMapping("/admin/attendance/active-code/expire") + public String expireActiveAttendance() { + return attendanceService.expireActiveAttendanceCode(); + } + +// // 4. 출석 상태 변경 (관리자 전용) +// // 현재는 출석만 변경되지만 나중에 출석 & 과제 변경으로 바꿀 예정 +// @Operation(summary = "출석 상태 변경", description = "관리자가 특정 사용자의 출석 상태를 변경합니다.") +// @ApiResponses(value = { +// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "출석 상태 변경 성공"), +// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), +// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "출석 기록을 찾을 수 없음") +// }) +// @PutMapping("/admin/users/{userId}/status") +// public boolean updateUserStatus( +// @Parameter(description = "사용자 ID", example = "1") +// @PathVariable Integer userId, +// @RequestBody UpdateUserStatusReq req) { +// return attendanceService.updateUserStatus(userId, req); +// } + +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/controller/AttendanceController.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/controller/AttendanceController.java index 5ed4b20..4ace20f 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/attendance/controller/AttendanceController.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/controller/AttendanceController.java @@ -1,4 +1,95 @@ package com.example.Piroin.project.domain.attendance.controller; +import com.example.Piroin.project.domain.attendance.dto.MarkAttendanceReq; +import com.example.Piroin.project.domain.attendance.dto.ApiResponse; +import com.example.Piroin.project.domain.attendance.dto.AttendanceMarkResponse; +import com.example.Piroin.project.domain.attendance.dto.AttendanceSlotRes; +import com.example.Piroin.project.domain.attendance.dto.AttendanceStatusRes; +import com.example.Piroin.project.domain.attendance.service.AttendanceService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +//import com.example.Piroin.project.global.util.SecurityUtil; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/attendance") +@Tag(name = "출석관리", description = "학생용 출석 관련 API") public class AttendanceController { + + private final AttendanceService attendanceService; + + + // 1. 출석코드 비교 + @PostMapping("/mark") + @Operation(summary = "출석코드 입력", description = "부원이 운영진이 알려준 출석코드를 입력하여 출석을 합니다.") + public ApiResponse markAttendance( + @RequestBody MarkAttendanceReq req, + Authentication authentication + ) { + + Long userId = Long.valueOf(authentication.getName()); + + AttendanceMarkResponse response = attendanceService.markAttendance( + userId, + req.getCode() + ); + + boolean isSuccess = + "SUCCESS".equals(response.getStatusCode()) || + "ALREADY_MARKED".equals(response.getStatusCode()); + + if (isSuccess) { + return ApiResponse.success(response); + } + + return ApiResponse.builder() + .success(false) + .message(response.getMessage()) + .data(response) + .build(); + } + + + + // 2. 특정 유저의 출석 정보 + @Operation(summary = "사용자 전체 출석 정보 조회", description = "특정 사용자의 전체 출석 정보를 조회합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음") + }) + @GetMapping("/user") + public ApiResponse> getAttendanceByUserId(@AuthenticationPrincipal Long userId) { + return ApiResponse.success(attendanceService.findByUserId(Math.toIntExact(userId))); + } + + // 3. 특정 유저의 특정 일자 출석 정보 + @Operation(summary = "특정 날짜 출석 정보 조회", description = "특정 사용자의 특정 날짜 출석 정보를 조회합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "사용자 또는 날짜 정보를 찾을 수 없음") + }) + @GetMapping("/user/date") + public ApiResponse> getAttendanceByUserIdAndDate( + @Parameter(description = "조회할 날짜 (YYYY-MM-DD)", required = true) + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @AuthenticationPrincipal Long userId + ) { + return ApiResponse.success(attendanceService.findByUserIdAndDate(Math.toIntExact(userId), date)); + } + + } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/ApiResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/ApiResponse.java new file mode 100644 index 0000000..944f205 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/ApiResponse.java @@ -0,0 +1,38 @@ +package com.example.Piroin.project.domain.attendance.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ApiResponse { + private boolean success; + private String message; + private T data; + + public static ApiResponse success(T data) { + return ApiResponse.builder() + .success(true) + .data(data) + .build(); + } + + public static ApiResponse success(String message, T data) { + return ApiResponse.builder() + .success(true) + .message(message) + .data(data) + .build(); + } + + public static ApiResponse error(String message) { + return ApiResponse.builder() + .success(false) + .message(message) + .build(); + } +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceCodeResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceCodeResponse.java new file mode 100644 index 0000000..3263d47 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceCodeResponse.java @@ -0,0 +1,36 @@ +package com.example.Piroin.project.domain.attendance.dto; + +import com.example.Piroin.project.domain.attendance.entity.AttendanceCode; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "출석 코드 응답") +public class AttendanceCodeResponse { + @Schema(description = "출석 코드", example = "1234") + private String code; + + @Schema(description = "출석 날짜", example = "2025-06-24") + private LocalDate date; + + @Schema(description = "출석 차시 (1, 2, 3)", example = "1") + private int order; + + @Schema(description = "만료 여부", example = "false") + private boolean isExpired; + + public static AttendanceCodeResponse from(AttendanceCode attendanceCode) { + return AttendanceCodeResponse.builder() + .code(attendanceCode.getCode()) + .isExpired(attendanceCode.getIsExpired()) + .build(); + } +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceMarkResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceMarkResponse.java new file mode 100644 index 0000000..d84a0ad --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceMarkResponse.java @@ -0,0 +1,68 @@ +package com.example.Piroin.project.domain.attendance.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "출석 체크 응답") +public class AttendanceMarkResponse { + @Schema(description = "응답 메시지", example = "출석이 성공적으로 처리되었습니다") + private String message; + + @Schema(description = "상태 코드 (SUCCESS, ALREADY_MARKED, INVALID_CODE, NO_ACTIVE_SESSION, CODE_EXPIRED, ERROR)", example = "SUCCESS") + private String statusCode; + + // 출석 성공 + public static AttendanceMarkResponse success() { + return AttendanceMarkResponse.builder() + .statusCode("SUCCESS") + .message("출석이 성공적으로 처리되었습니다") + .build(); + } + + // 이미 출석 완료 + public static AttendanceMarkResponse alreadyMarked() { + return AttendanceMarkResponse.builder() + .statusCode("ALREADY_MARKED") + .message("이미 출석처리가 완료되었습니다") + .build(); + } + + // 출석체크 진행중 아님 + public static AttendanceMarkResponse noActiveSession() { + return AttendanceMarkResponse.builder() + .statusCode("NO_ACTIVE_SESSION") + .message("출석 코드가 존재하지 않습니다. 현재 출석 체크가 진행중이 아닙니다") + .build(); + } + + // 잘못된 출석 코드 입력 + public static AttendanceMarkResponse invalidCode() { + return AttendanceMarkResponse.builder() + .statusCode("INVALID_CODE") + .message("잘못된 출석 코드입니다. 다시 확인해주세요") + .build(); + } + + // 출석 코드 만료 + public static AttendanceMarkResponse codeExpired() { + return AttendanceMarkResponse.builder() + .statusCode("CODE_EXPIRED") + .message("출석 코드가 만료되었습니다") + .build(); + } + + // 기타 오류 + public static AttendanceMarkResponse error(String message) { + return AttendanceMarkResponse.builder() + .statusCode("ERROR") + .message(message) + .build(); + } +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceSlotRes.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceSlotRes.java new file mode 100644 index 0000000..ee842b1 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceSlotRes.java @@ -0,0 +1,22 @@ +package com.example.Piroin.project.domain.attendance.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Schema(description = "출석 차시별 상태") +public class AttendanceSlotRes { + + @Schema(description = "출석 코드 ID") + private Integer attendanceCodeId; // 변수명을 의미에 맞게 변경! + + private Boolean status; + + // 생성자 파라미터와 주입부도 변경 + public AttendanceSlotRes(Integer attendanceCodeId, Boolean status) { + this.attendanceCodeId = attendanceCodeId; + this.status = status; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceStatusRes.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceStatusRes.java new file mode 100644 index 0000000..1bc1ed4 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceStatusRes.java @@ -0,0 +1,22 @@ +package com.example.Piroin.project.domain.attendance.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@Setter +@Schema(description = "사용자 출석 상태") +public class AttendanceStatusRes { + @Schema(description = "출석 날짜", example = "2025-06-24") + private LocalDate date; + + @Schema(description = "주차", example = "1") + private int week; + + @Schema(description = "출석 차시별 상태 목록") + private List slots; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/GetAttendanceByDateReq.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/GetAttendanceByDateReq.java new file mode 100644 index 0000000..53164f9 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/GetAttendanceByDateReq.java @@ -0,0 +1,11 @@ +package com.example.Piroin.project.domain.attendance.dto; + +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +public class GetAttendanceByDateReq { + private Long userId; + private LocalDate date; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/GetAttendanceByUserIdRes.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/GetAttendanceByUserIdRes.java new file mode 100644 index 0000000..3c95c33 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/GetAttendanceByUserIdRes.java @@ -0,0 +1,13 @@ +package com.example.Piroin.project.domain.attendance.dto; + +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +public class GetAttendanceByUserIdRes { + private Long userId; + private LocalDate date; + private int order; + private boolean status; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/MarkAttendanceReq.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/MarkAttendanceReq.java new file mode 100644 index 0000000..d777a57 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/MarkAttendanceReq.java @@ -0,0 +1,16 @@ +package com.example.Piroin.project.domain.attendance.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@Schema(description = "출석 체크 요청") +public class MarkAttendanceReq { +// @Schema(description = "스터디 세션 ID", example = "1") +// private Long studySessionId; + + @Schema(description = "출석 코드", example = "1234") + private String code; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/UpdateAttendanceStatusReq.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/UpdateAttendanceStatusReq.java new file mode 100644 index 0000000..01cb8b2 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/UpdateAttendanceStatusReq.java @@ -0,0 +1,17 @@ +package com.example.Piroin.project.domain.attendance.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "출석 상태 수정 요청") +public class UpdateAttendanceStatusReq { + @Schema(description = "변경할 출석 상태", example = "true") + private boolean status; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/UpdateUserStatusReq.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/UpdateUserStatusReq.java new file mode 100644 index 0000000..62856bd --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/UpdateUserStatusReq.java @@ -0,0 +1,18 @@ +package com.example.Piroin.project.domain.attendance.dto; + +import com.example.Piroin.project.domain.assignment.enums.AssignmentStatus; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class UpdateUserStatusReq { + + private Long attendanceId; + private Boolean attendanceStatus; + + private Long assignmentItemId; + private AssignmentStatus assignmentStatus; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/UserAttendanceStatusRes.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/UserAttendanceStatusRes.java new file mode 100644 index 0000000..5560b8b --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/UserAttendanceStatusRes.java @@ -0,0 +1,34 @@ +package com.example.Piroin.project.domain.attendance.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "사용자 출석 상태 응답") +public class UserAttendanceStatusRes { + @Schema(description = "출석 기록 ID", example = "1") + private Long attendanceId; + + @Schema(description = "사용자 ID", example = "1") + private Long userId; + + @Schema(description = "사용자 이름", example = "홍길동") + private String username; + + @Schema(description = "출석 날짜", example = "2023-10-20") + private LocalDate date; + + @Schema(description = "출석 차수", example = "1") + private int order; + + @Schema(description = "출석 상태", example = "true") + private boolean status; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/entity/Attendance.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/entity/Attendance.java new file mode 100644 index 0000000..683e7a2 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/entity/Attendance.java @@ -0,0 +1,37 @@ +package com.example.Piroin.project.domain.attendance.entity; + +import com.example.Piroin.project.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +import javax.xml.crypto.dsig.Manifest; + +@Entity +@Table(name = "attendance") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Attendance { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; // SERIAL 타입에 매칭 (Long -> Integer) + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "attendance_code_id", nullable = false) + private AttendanceCode attendanceCode; // attendance_code_id 매핑 + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; // user_id 매핑 + + @Column(nullable = false) + private Boolean status; // BOOLEAN 타입에 매칭 + + public void updateStatus(Boolean status) { + this.status = status; + } + + +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/entity/AttendanceCode.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/entity/AttendanceCode.java new file mode 100644 index 0000000..2986adb --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/entity/AttendanceCode.java @@ -0,0 +1,38 @@ +package com.example.Piroin.project.domain.attendance.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; + +@Entity +@Table(name = "attendance_code") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class AttendanceCode { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; // SERIAL 타입에 매칭 (Long -> Integer) + + @Column(name = "attendance_date") + private LocalDate attendanceDate; + + @Column(name = "attendance_order") + private String attendanceOrder; // '1, 2, 3' 코멘트 항목 + + @Column(nullable = false, length = 20) + private String code; + + @Column(name = "is_expired", nullable = false) + private Boolean isExpired; // BOOLEAN 타입에 매칭 + + @Column(name = "field3") + private String field3; + + public void expire() { + this.isExpired = true; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/entity/attendance.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/entity/attendance.java deleted file mode 100644 index d2dadef..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/attendance/entity/attendance.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.attendance.entity; - -public class attendance { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/entity/attendance_code.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/entity/attendance_code.java deleted file mode 100644 index 5decbc6..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/attendance/entity/attendance_code.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.attendance.entity; - -public class attendance_code { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceCodeRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceCodeRepository.java new file mode 100644 index 0000000..b407419 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceCodeRepository.java @@ -0,0 +1,37 @@ +package com.example.Piroin.project.domain.attendance.repository; + +import com.example.Piroin.project.domain.attendance.entity.AttendanceCode; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + + +public interface AttendanceCodeRepository extends JpaRepository { + + // [추가] 모든 활성화된 코드를 한 번에 만료 처리 (벌크 연산) + @Modifying + @Query("update AttendanceCode ac set ac.isExpired = true where ac.isExpired = false") + void expireAllActiveCodes(); + + Optional findFirstByIsExpiredFalseOrderByIdDesc(); + +// Optional findByCodeAndStudySessionId(String code, Long studySessionId); + + // 특정 날짜에 발급된 코드 개수 조회 + long countByAttendanceDate(LocalDate attendanceDate); + + // 만료되지 않은 코드 목록 조회 + List findByIsExpiredFalse(); + + Optional findByCode(String code); + + List findByAttendanceDate(LocalDate attendanceDate); +} + + diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceRepository.java index 675f75d..9b83ead 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceRepository.java @@ -1,4 +1,62 @@ package com.example.Piroin.project.domain.attendance.repository; -public interface AttendanceRepository { +import com.example.Piroin.project.domain.attendance.entity.AttendanceCode; +import com.example.Piroin.project.domain.user.entity.User; +import com.example.Piroin.project.domain.attendance.entity.Attendance; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface AttendanceRepository extends JpaRepository { + + Optional findById(Integer id); + + + // 연관관계 필드명이 attendanceCode 라면 내부 ID인 Id를 조합하여 명명 + Optional findByUserIdAndAttendanceCodeId(Long userId, Long attendanceCodeId); + + //List findByUserIdAndStudySessionSessionDate(Integer userId, LocalDate date); + + int countByUserAndStatusFalse(User user); + + // 1. 특정 출석 코드 ID에 해당하는 결석 데이터 조회 + List findByAttendanceCodeIdAndStatusFalse(Integer attendanceCodeId); + + // 이해도 체크 분모용 출석 인원. 세션 날짜 + 회차에서 실제 출석 완료(status=true)된 인원만 센다. + @Query(""" + SELECT COUNT(a) + FROM Attendance a + WHERE a.attendanceCode.attendanceDate = :attendanceDate + AND a.attendanceCode.attendanceOrder = :attendanceOrder + AND a.status = true + """) + long countAttendedByDateAndOrder( + @Param("attendanceDate") LocalDate attendanceDate, + @Param("attendanceOrder") String attendanceOrder + ); + + // 2. 특정 유저 ID와 출석 코드의 날짜 조건으로 조회 (엔티티 그래프 참조: attendanceCode.attendanceDate) + @Query("SELECT a FROM Attendance a WHERE a.user.id = :userId AND a.attendanceCode.attendanceDate = :attendanceDate") + List findByUserIdAndDate(@Param("userId") Integer userId, @Param("attendanceDate") LocalDate attendanceDate); + + // 3. 특정 유저의 모든 출석 데이터 조회 + List findByUserId(Long userId); + + Optional findByUserIdAndAttendanceCodeId( + Long userId, + Integer attendanceCodeId + ); + + List findByAttendanceCodeId(Integer id); + + // 특정 날짜에 발급된 출석 코드의 개수를 세는 메서드 + //long countByAttendanceDate(String attendanceDate); + + // 현재 만료되지 않은(활성화된) 출석 코드 목록을 가져오는 메서드 + //List findByIsExpiredFalse(); } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/service/AttendanceService.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/service/AttendanceService.java index c724a74..15c211e 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/attendance/service/AttendanceService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/service/AttendanceService.java @@ -1,4 +1,335 @@ package com.example.Piroin.project.domain.attendance.service; +import com.example.Piroin.project.domain.curriculum.entity.StudySession; +import com.example.Piroin.project.domain.curriculum.repository.CurriculumRepository; +import com.example.Piroin.project.domain.deposit.entity.Deposit; +import com.example.Piroin.project.domain.deposit.repository.DepositRepository; +import com.example.Piroin.project.domain.deposit.service.DepositService; +import com.example.Piroin.project.domain.user.enums.Role; +import com.example.Piroin.project.domain.user.entity.User; +import com.example.Piroin.project.domain.user.repository.UserRepository; +import com.example.Piroin.project.domain.attendance.dto.AttendanceMarkResponse; +import com.example.Piroin.project.domain.attendance.dto.AttendanceSlotRes; +import com.example.Piroin.project.domain.attendance.dto.AttendanceStatusRes; +import com.example.Piroin.project.domain.attendance.dto.UserAttendanceStatusRes; +import com.example.Piroin.project.domain.attendance.entity.Attendance; +import com.example.Piroin.project.domain.attendance.entity.AttendanceCode; +import com.example.Piroin.project.domain.attendance.repository.AttendanceCodeRepository; +import com.example.Piroin.project.domain.attendance.repository.AttendanceRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.example.Piroin.project.domain.assignment.entity.AssignmentItem; +import com.example.Piroin.project.domain.assignment.repository.AssignmentItemRepository; +import com.example.Piroin.project.domain.attendance.dto.UpdateUserStatusReq; +import com.example.Piroin.project.domain.curriculum.enums.SessionDayPart; + + +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor public class AttendanceService { + + private final AttendanceRepository attendanceRepository; + private final AttendanceCodeRepository attendanceCodeRepository; + private final UserRepository userRepository; + private final DepositService depositService; + + private final CurriculumRepository curriculumRepository; + + private final AssignmentItemRepository assignmentItemRepository; + + + + // 1. 출석 시작 코드 (출석코드 생성 함수) + @Transactional + public AttendanceCode generateCodeAndCreateAttendances(LocalDate date) { // [수정] 세션 ID 대신 날짜를 직접 받음 + + // 1. [삭제] 더 이상 세션을 조회해서 날짜를 파싱할 필요가 없습니다. (curriculumRepository 조회 제거) + + // 2. 해당 날짜에 생성된 출석 코드 개수 조회 + long codeCountOfDay = attendanceCodeRepository.countByAttendanceDate(date); + + if (codeCountOfDay >= 3) { + throw new IllegalStateException("하루에 최대 3회까지만 출석 코드를 생성할 수 있습니다."); + } + + // 3. 기존 활성화된 코드들 만료 처리 + List activeCodes = attendanceCodeRepository.findByIsExpiredFalse(); + for (AttendanceCode activeCode : activeCodes) { + activeCode.expire(); + } + + for (AttendanceCode activeCode : activeCodes) { + activeCode.expire(); + + List attendances = + attendanceRepository.findByAttendanceCodeId(activeCode.getId()); + + for (Attendance attendance : attendances) { + depositService.recalculateDeposit(attendance.getUser().getId()); + } + } + + + // 4. 4자리 랜덤 코드 생성 및 차수(Order) 계산 + String code = String.valueOf(ThreadLocalRandom.current().nextInt(1000, 10000)); + String attendanceOrder = String.valueOf(codeCountOfDay + 1); // 1회차, 2회차, 3회차 + + // 5. 새로운 AttendanceCode 생성 및 저장 + AttendanceCode attendanceCode = AttendanceCode.builder() + .attendanceDate(date) // [수정] 파라미터로 받은 날짜 주입 + .attendanceOrder(attendanceOrder) + .code(code) + .isExpired(false) + .build(); + + attendanceCodeRepository.save(attendanceCode); + + // 6. 모든 MEMBER 유저에 대해 '현재 생성된 출석 코드' 기준 초기 출석 데이터 생성 + List users = userRepository.findByRole(Role.MEMBER); + + for (User user : users) { + // [확인] 이미 완벽하게 studySession 대신 attendanceCode를 주입하도록 잘 짜두셨습니다! + Attendance attendance = Attendance.builder() + .user(user) + .attendanceCode(attendanceCode) + .status(false) + .build(); + + attendanceRepository.save(attendance); + } + + return attendanceCode; + } + + // 2. 현재 활성화된 출석코드 조회 함수 + public Optional getActiveAttendanceCode() { + // 기존: List로 받아서 0번째 꺼내기 (비어있을 시 위험) + // 수정: 레파지토리의 findFirst 기능을 사용하여 가장 최신 활성 코드 하나만 안전하게 조회 + return attendanceCodeRepository.findFirstByIsExpiredFalseOrderByIdDesc(); + } + + // Q&A 이해도 체크 화면의 분모(13/29 중 29)를 계산한다. + @Transactional(readOnly = true) + public int countAttendedBySession(StudySession session) { + if (session == null) { + throw new IllegalArgumentException("세션 정보는 필수입니다."); + } + + String attendanceOrder = resolveAttendanceOrder(session.getDayPart()); + long attendedCount = attendanceRepository.countAttendedByDateAndOrder( + session.getSessionDate(), + attendanceOrder + ); + + return Math.toIntExact(attendedCount); + } + + // 현재 정책: 오전 세션은 1회차, 오후 세션은 2회차 출석 인원을 이해도 체크 분모로 사용한다. + private String resolveAttendanceOrder(SessionDayPart dayPart) { + if (dayPart == null) { + throw new IllegalArgumentException("세션 오전/오후 정보는 필수입니다."); + } + + return switch (dayPart) { + case AM -> "1"; + case PM -> "2"; + }; + } + + // 3. 출석 체크 + @Transactional + public AttendanceMarkResponse markAttendance(Long userId, String inputCode) { + // 1. [수정] 오직 사용자가 입력한 코드를 기반으로 출석 코드 정보를 조회합니다. + AttendanceCode code = attendanceCodeRepository + .findByCode(inputCode) + .orElse(null); + + // 입력한 출석 코드가 DB에 존재하지 않는 경우 + if (code == null) { + return AttendanceMarkResponse.invalidCode(); + } + + // 출석 코드가 이미 만료된 경우 + if (Boolean.TRUE.equals(code.getIsExpired())) { + return AttendanceMarkResponse.codeExpired(); + } + + // 2. [수정] 이제 Attendance도 studySessionId 대신 AttendanceCode와의 연관관계(예: attendanceCodeId) + // 혹은 조회된 code의 날짜/차수 정보를 기반으로 기존 출석 기록을 찾아야 합니다. + // (여기서는 이전 답변 시나리오 1인 'attendanceCodeId'로 매핑했다고 가정했을 때의 예시입니다.) + Attendance attendance = attendanceRepository + .findByUserIdAndAttendanceCodeId(userId, Long.valueOf(code.getId())) + .orElse(null); + + // 해당 사용자와 출석 코드에 대한 출석 기록이 존재하지 않는 경우 + if (attendance == null) { + return AttendanceMarkResponse.error("출석 정보를 찾을 수 없습니다."); + } + + // 사용자가 이미 출석 체크를 완료한 경우 + if (Boolean.TRUE.equals(attendance.getStatus())) { + return AttendanceMarkResponse.alreadyMarked(); + } + + // 출석 상태를 출석 완료(true)로 변경 + attendance.updateStatus(true); + + // 출석 상태 변경 후 보증금 재계산 + depositService.recalculateDeposit(userId); // 아직 생성 안 하신 부분 오류 패스! + + return AttendanceMarkResponse.success(); + } + + + // 4. 출석 코드 만료시키기. + @Transactional + public String expireActiveAttendanceCode() { + // 1. 활성화된 최신 출석 코드 조회 + AttendanceCode activeCode = attendanceCodeRepository + .findFirstByIsExpiredFalseOrderByIdDesc() + .orElseThrow(() -> new IllegalStateException("현재 활성화된 출석 코드가 없습니다.")); + + // 2. 코드 만료 처리 + activeCode.expire(); + + // 3. 변경된 구조: 만료된 '출석 코드의 ID'를 기반으로 결석자(status = false) 조회 + Integer attendanceCodeId = activeCode.getId(); + List absents = + attendanceRepository.findByAttendanceCodeIdAndStatusFalse(attendanceCodeId); + + // 4. 결석자 대상 보증금 재계산 (User ID 타입 Integer 반영) + for (Attendance attendance : absents) { + depositService.recalculateDeposit(attendance.getUser().getId()); + } + + return "출석 코드가 성공적으로 만료되었습니다."; + } + + + // 5. 유저의 특정 날짜의 출석 현황을 조회하는 함수 + public List findByUserIdAndDate(Integer userId, LocalDate date) { // Long -> Integer + // DB의 VARCHAR(255) 날짜 포맷과 맞추기 위해 String으로 변환 (예: "2026-05-17") + String dateStr = date.toString(); + + // 변경된 구조: User ID와 AttendanceCode의 날짜 조건으로 조회 + List attendances = + attendanceRepository.findByUserIdAndDate(userId, date); + + return attendances.stream() + .map(attendance -> new AttendanceSlotRes( + attendance.getAttendanceCode().getId(), // 세션 ID 대신 출석 코드 ID를 슬롯 식별값으로 사용 + attendance.getStatus() + )) + .sorted(Comparator.comparing(AttendanceSlotRes::getAttendanceCodeId)) // 정렬 기준 변경 + .toList(); + } + + // 6. 유저의 전체 출석 현황을 날짜별로 묶어서 조회하는 함수 + public List findByUserId(Integer userId) { + + List attendances = + attendanceRepository.findByUserId(Long.valueOf(userId)); + + // LocalDate 기준으로 그룹화 + Map> grouped = attendances.stream() + .collect(Collectors.groupingBy( + attendance -> attendance.getAttendanceCode().getAttendanceDate() + )); + + return grouped.entrySet().stream() + .map(entry -> { + + LocalDate date = entry.getKey(); + + List slots = entry.getValue().stream() + .map(attendance -> new AttendanceSlotRes( + attendance.getAttendanceCode().getId(), + attendance.getStatus() + )) + .sorted(Comparator.comparing(AttendanceSlotRes::getAttendanceCodeId)) + .toList(); + + AttendanceStatusRes dto = new AttendanceStatusRes(); + dto.setDate(date); + dto.setSlots(slots); + + return dto; + }) + .sorted(Comparator.comparing(AttendanceStatusRes::getDate).reversed()) + .toList(); + } +// +// // 6. 유저 상태 변경 (관리자) +// // 컨트롤러 부분은 출석만 받는데 여기는 출석&과제 둘 다 받아서 추후에 수정 예정 +// @Transactional +// public boolean updateUserStatus(Integer userId, UpdateUserStatusReq req) { +// boolean updated = false; +// +// // 출석 상태 변경 코드 +// if (req.getAttendanceId() != null && req.getAttendanceStatus() != null) { +// Attendance attendance = attendanceRepository.findById(req.getAttendanceId()) +// .orElseThrow(() -> new IllegalArgumentException("출석 기록을 찾을 수 없습니다.")); +// +// if (!attendance.getUser().getId().equals(userId)) { +// throw new IllegalArgumentException("요청된 사용자와 출석 기록의 사용자가 일치하지 않습니다."); +// } +// +// attendance.updateStatus(req.getAttendanceStatus()); +// updated = true; +// } +// +// // 과제 상태 변경 코드 +// if (req.getAssignmentItemId() != null && req.getAssignmentStatus() != null) { +// AssignmentItem assignmentItem = assignmentItemRepository.findById(Math.toIntExact(req.getAssignmentItemId())) +// .orElseThrow(() -> new IllegalArgumentException("과제 기록을 찾을 수 없습니다.")); +// +// if (!assignmentItem.getUser().getId().equals(userId)) { +// throw new IllegalArgumentException("요청된 사용자와 과제 기록의 사용자가 일치하지 않습니다."); +// } +// +// assignmentItem.updateSubmitted(req.getAssignmentStatus()); +// updated = true; +// } +// +// // 출석 변경 → 보증금 재계산 (과제 변경도 포함이 되어 있나..?) +// if (updated) { +// depositService.recalculateDeposit(Long.valueOf(userId)); +// } +// +// return updated; +// } + + } + +/* + // 관리자가 유저의 출석 상태를 변경하는 함수(나중에 과제까지 같이 변경되도록 수정할 것) + @Transactional + public boolean updateAttendanceStatus(Long attendanceId, boolean status) { + Optional attendanceOpt = attendanceRepository.findById(attendanceId); + + if (attendanceOpt.isEmpty()) { + return false; + } + + // 출석 상태 변경 + Attendance attendance = attendanceOpt.get(); + attendance.setStatus(status); + attendanceRepository.save(attendance); + + // 출석 변경 → 보증금 재계산 + depositService.recalculateDeposit(attendance.getUser().getId()); + + return true; + } + + */ diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/controller/CurriculumController.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/controller/CurriculumController.java index c4acfd3..e53b441 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/controller/CurriculumController.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/controller/CurriculumController.java @@ -1,21 +1,45 @@ package com.example.Piroin.project.domain.curriculum.controller; +import com.example.Piroin.project.domain.curriculum.dto.CurriculumReqDTO; import com.example.Piroin.project.domain.curriculum.dto.CurriculumResDTO; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import com.example.Piroin.project.domain.curriculum.service.CurriculumService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; import java.util.List; +import java.util.Map; @RestController @RequestMapping("/api/curriculums") +@RequiredArgsConstructor public class CurriculumController { + private final CurriculumService curriculumService; + @GetMapping - public List getCurriculums() { - return List.of( - new CurriculumResDTO(1L, "1주차 - OT"), - new CurriculumResDTO(2L, "2주차 - Java 기초") - ); + public ResponseEntity> getAllDays() { + return ResponseEntity.ok(curriculumService.getAllDays()); + } + + @PostMapping + public ResponseEntity createDay( + @RequestBody CurriculumReqDTO.CreateDayReq req) { + return ResponseEntity.status(HttpStatus.CREATED).body(curriculumService.createDay(req)); + } + + @PatchMapping("/{sessionDate}") + public ResponseEntity updateDay( + @PathVariable LocalDate sessionDate, + @RequestBody CurriculumReqDTO.UpdateDayReq req) { + return ResponseEntity.ok(curriculumService.updateDay(sessionDate, req)); + } + + @DeleteMapping("/{sessionDate}") + public ResponseEntity> deleteDay(@PathVariable LocalDate sessionDate) { + curriculumService.deleteDay(sessionDate); + return ResponseEntity.ok(Map.of("message", "세션이 정상적으로 삭제되었습니다.")); } } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/controller/SessionController.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/controller/SessionController.java new file mode 100644 index 0000000..87bead2 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/controller/SessionController.java @@ -0,0 +1,27 @@ +package com.example.Piroin.project.domain.curriculum.controller; + +import com.example.Piroin.project.domain.curriculum.dto.CurriculumResDTO; +import com.example.Piroin.project.domain.curriculum.exception.code.CurriculumSuccessCode; +import com.example.Piroin.project.domain.curriculum.service.CurriculumService; +import com.example.Piroin.project.global.response.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/sessions") +public class SessionController { + private final CurriculumService curriculumService; + + @GetMapping + public ApiResponse getQnaSessions() { + CurriculumResDTO.QnaSessionsResponse response = curriculumService.getQnaSessions(); + + return ApiResponse.onSuccess( + CurriculumSuccessCode.QNA_SESSION_LIST_OK, + response + ); + } +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/converter/CurriculumConverter.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/converter/CurriculumConverter.java index f363fb6..e4de760 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/converter/CurriculumConverter.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/converter/CurriculumConverter.java @@ -1,4 +1,73 @@ package com.example.Piroin.project.domain.curriculum.converter; +import com.example.Piroin.project.domain.curriculum.dto.CurriculumReqDTO; +import com.example.Piroin.project.domain.curriculum.dto.CurriculumResDTO; +import com.example.Piroin.project.domain.curriculum.entity.StudySession; +import com.example.Piroin.project.domain.curriculum.enums.SessionDayPart; +import com.example.Piroin.project.domain.curriculum.enums.SessionStatus; +import com.example.Piroin.project.domain.user.entity.User; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + public class CurriculumConverter { + + public static StudySession toStudySession(CurriculumReqDTO.SessionReq sessionReq, + CurriculumReqDTO.CreateDayReq req, + User user) { + return StudySession.builder() + .createdBy(user) + .generation(req.getGeneration()) + .week(req.getWeek()) + .sessionDate(req.getSessionDate()) + .dayPart(sessionReq.getDayPart()) + .title(sessionReq.getTitle()) + .hostName(sessionReq.getHostName() != null ? sessionReq.getHostName() : "(미정)") + .status(SessionStatus.BEFORE_SESSION) + .sessionMaterialUrl(sessionReq.getSessionMaterialUrl()) + .sessionMaterialName(sessionReq.getSessionMaterialName()) + .recordingUrl(sessionReq.getRecordingUrl()) + .recordingPassword(sessionReq.getRecordingPassword()) + .assignmentUrl(sessionReq.getAssignmentUrl()) + .assignmentName(sessionReq.getAssignmentName()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + } + + public static CurriculumResDTO.CreateDayRes toCreateDayRes(List sessions) { + StudySession first = sessions.get(0); + + // assignment는 PM 세션 기준으로 저장/조회. 운영 기간 6주 고정 + StudySession pm = sessions.stream() + .filter(s -> s.getDayPart() == SessionDayPart.PM) + .findFirst() + .orElse(null); + + return new CurriculumResDTO.CreateDayRes( + first.getSessionDate(), + first.getGeneration(), + first.getWeek(), + pm != null ? pm.getAssignmentUrl() : null, + pm != null ? pm.getAssignmentName() : null, + sessions.stream().map(CurriculumConverter::toSessionInfo).collect(Collectors.toList()), + first.getCreatedAt() + ); + } + + public static CurriculumResDTO.SessionInfo toSessionInfo(StudySession session) { + return new CurriculumResDTO.SessionInfo( + session.getId(), + session.getDayPart(), + session.getTitle(), + session.getHostName(), + session.getStatus(), + session.getSessionMaterialUrl(), + session.getSessionMaterialName(), + session.getRecordingUrl(), + session.getRecordingPassword() + ); + } + } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumReqDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumReqDTO.java index d1b7ab6..ceabfe1 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumReqDTO.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumReqDTO.java @@ -1,4 +1,61 @@ package com.example.Piroin.project.domain.curriculum.dto; +import com.example.Piroin.project.domain.curriculum.enums.SessionDayPart; +import com.example.Piroin.project.domain.curriculum.enums.SessionStatus; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.List; + public class CurriculumReqDTO { + + @Getter + @NoArgsConstructor + public static class CreateDayReq { + private Integer generation; + private Long week; + private LocalDate sessionDate; + private List sessions; + } + + @Getter + @NoArgsConstructor + public static class SessionReq { + private SessionDayPart dayPart; + private String title; + private String hostName; + private String sessionMaterialUrl; + private String sessionMaterialName; + private String recordingUrl; + private String recordingPassword; + // PM에만 사용 + private String assignmentUrl; + private String assignmentName; + } + + @Getter + @NoArgsConstructor + public static class UpdateDayReq { + private Integer generation; + private Long week; + private LocalDate newSessionDate; + private List sessions; + } + + @Getter + @NoArgsConstructor + public static class UpdateSessionItemReq { + private SessionDayPart dayPart; + private SessionStatus status; + private String title; + private String hostName; + private String sessionMaterialUrl; + private String sessionMaterialName; + private String recordingUrl; + private String recordingPassword; + private String assignmentUrl; + private String assignmentName; + } + } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumResDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumResDTO.java index aede84f..e8ec7e2 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumResDTO.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumResDTO.java @@ -1,19 +1,57 @@ package com.example.Piroin.project.domain.curriculum.dto; +import com.example.Piroin.project.domain.curriculum.enums.SessionDayPart; +import com.example.Piroin.project.domain.curriculum.enums.SessionStatus; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + public class CurriculumResDTO { - private Long id; - private String title; - public CurriculumResDTO(Long id, String title) { - this.id = id; - this.title = title; + public record SessionInfo( + Long sessionId, + SessionDayPart dayPart, + String title, + String hostName, + SessionStatus status, + String sessionMaterialUrl, + String sessionMaterialName, + String recordingUrl, + String recordingPassword + ) {} + + public record CreateDayRes( + LocalDate sessionDate, + Integer generation, + Long week, + String assignmentUrl, + String assignmentName, + List sessions, + LocalDateTime createdAt + ) {} + + public record QnaSessionsResponse( + List activeSessions, + List pastSessions + ) { } - public Long getId() { - return id; + public record ActiveSessionResponse( + Long sessionId, + Integer week, + String dayOfWeek, + String dayPart, + String sessionDate, + String title + ) { } - public String getTitle() { - return title; + public record PastSessionResponse( + Long sessionId, + Integer week, + String dayOfWeek, + String dayPart, + String title + ) { } } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/entity/StudySession.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/entity/StudySession.java new file mode 100644 index 0000000..bef1b5b --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/entity/StudySession.java @@ -0,0 +1,99 @@ +package com.example.Piroin.project.domain.curriculum.entity; + +import com.example.Piroin.project.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import com.example.Piroin.project.domain.curriculum.enums.SessionDayPart; +import com.example.Piroin.project.domain.curriculum.enums.SessionStatus; + +@Entity +@Table(name = "study_session") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class StudySession { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by", nullable = false) + private User createdBy; + + @Column(nullable = false) + private Integer generation; + + @Column(nullable = false) + private Long week; + + @Column(nullable = false) + private LocalDate sessionDate; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SessionDayPart dayPart; + + @Column(nullable = false) + private String title; + + private String hostName; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SessionStatus status; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(columnDefinition = "TEXT") + private String sessionMaterialUrl; + + @Column(columnDefinition = "TEXT") + private String assignmentUrl; + + @Column(columnDefinition = "TEXT") + private String recordingUrl; + + + // 녹화본 비밀번호 여기에 추가했습니당. sql 파일에도 물론 반영했고요. + @Column(length = 60) + private String recordingPassword; + + + // 세션 자료 이름, 과제 자료 이름 추가. + @Column(length = 255) + private String sessionMaterialName; + + @Column(length = 255) + private String assignmentName; + + + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public void updateFull(Integer generation, Long week, LocalDate sessionDate, SessionStatus status, String title, String hostName, + String sessionMaterialUrl, String sessionMaterialName, + String recordingUrl, String recordingPassword, + String assignmentUrl, String assignmentName) { + if (generation != null) this.generation = generation; + if (week != null) this.week = week; + if (sessionDate != null) this.sessionDate = sessionDate; + if (status != null) this.status = status; + this.title = title; + this.hostName = (hostName != null && !hostName.isBlank()) ? hostName : "(미정)"; + this.sessionMaterialUrl = sessionMaterialUrl; + this.sessionMaterialName = sessionMaterialName; + this.recordingUrl = recordingUrl; + this.recordingPassword = recordingPassword; + this.assignmentUrl = assignmentUrl; + this.assignmentName = assignmentName; + this.updatedAt = LocalDateTime.now(); + } + +} + diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/entity/session.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/entity/session.java deleted file mode 100644 index 16ac421..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/entity/session.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.curriculum.entity; - -public class session { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/enums/SessionDayPart.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/enums/SessionDayPart.java index a987bb6..f70dcb3 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/enums/SessionDayPart.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/enums/SessionDayPart.java @@ -1,4 +1,5 @@ package com.example.Piroin.project.domain.curriculum.enums; public enum SessionDayPart { + AM, PM } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/enums/SessionStatus.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/enums/SessionStatus.java index fccff3d..e5765f6 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/enums/SessionStatus.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/enums/SessionStatus.java @@ -1,4 +1,8 @@ package com.example.Piroin.project.domain.curriculum.enums; public enum SessionStatus { + BEFORE_SESSION, + IN_SESSION, + AFTER_SESSION } + diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/exception/CurriculumException.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/exception/CurriculumException.java index e2d2772..6e7d7db 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/exception/CurriculumException.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/exception/CurriculumException.java @@ -1,7 +1,24 @@ package com.example.Piroin.project.domain.curriculum.exception; +import com.example.Piroin.project.domain.curriculum.exception.code.CurriculumErrorCode; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter public class CurriculumException extends RuntimeException { - public CurriculumException(String message) { + + private final HttpStatus status; + private final String code; + + public CurriculumException(HttpStatus status, String message) { super(message); + this.status = status; + this.code = null; + } + + public CurriculumException(CurriculumErrorCode curriculumErrorCode) { + super(curriculumErrorCode.getMessage()); + this.status = curriculumErrorCode.getStatus(); + this.code = curriculumErrorCode.getCode(); } -} +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/exception/code/CurriculumErrorCode.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/exception/code/CurriculumErrorCode.java index fb46cd2..07258ef 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/exception/code/CurriculumErrorCode.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/exception/code/CurriculumErrorCode.java @@ -1,4 +1,38 @@ package com.example.Piroin.project.domain.curriculum.exception.code; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor public enum CurriculumErrorCode { -} + + USER_NOT_FOUND( + HttpStatus.NOT_FOUND, + "CURRICULUM401", + "사용자를 찾을 수 없습니다." + ), + + SESSION_NOT_FOUND( + HttpStatus.NOT_FOUND, + "CURRICULUM402", + "세션을 찾을 수 없습니다." + ), + + STUDY_SESSION_NOT_FOUND( + HttpStatus.NOT_FOUND, + "CURRICULUM404", + "해당 스터디 세션이 존재하지 않습니다." + ), + + SESSION_DATE_NOT_FOUND( + HttpStatus.BAD_REQUEST, + "CURRICULUM405", + "해당 주차/요일의 세션이 존재하지 않습니다. 세션을 먼저 생성해주세요." + ); + + private final HttpStatus status; + private final String code; + private final String message; +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/exception/code/CurriculumSuccessCode.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/exception/code/CurriculumSuccessCode.java index c01f45f..7e4e1e6 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/exception/code/CurriculumSuccessCode.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/exception/code/CurriculumSuccessCode.java @@ -1,4 +1,17 @@ package com.example.Piroin.project.domain.curriculum.exception.code; -public enum CurriculumSuccessCode { +import com.example.Piroin.project.global.response.code.BaseCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum CurriculumSuccessCode implements BaseCode { + SESSION_CREATED(HttpStatus.CREATED, "SESSION201", "세션 생성에 성공했습니다."), + QNA_SESSION_LIST_OK(HttpStatus.OK, "SESSION200_1", "Q&A 세션 목록 조회에 성공했습니다."); + + private final HttpStatus status; + private final String code; + private final String message; } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/repository/CurriculumRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/repository/CurriculumRepository.java index 2a84678..35719a8 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/repository/CurriculumRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/repository/CurriculumRepository.java @@ -1,4 +1,38 @@ package com.example.Piroin.project.domain.curriculum.repository; -public interface CurriculumRepository { +import com.example.Piroin.project.domain.curriculum.entity.StudySession; +import com.example.Piroin.project.domain.curriculum.enums.SessionStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; + +/* +StudySession(세션) DB 접근 인터페이스 +Q&A 서비스에서 세션 존재 여부 확인 시 사용 +JpaRepository<엔티티 타입, PK 타입> 을 상속하면 findById, save, delete 등 기본 메서드가 자동으로 제공 +*/ +public interface CurriculumRepository extends JpaRepository { + List findByStatusOrderBySessionDateAscDayPartAsc(SessionStatus status); + + List findByStatusOrderBySessionDateDescDayPartDesc(SessionStatus status); + + List findByWeek(Long week); + + List findBySessionDate(LocalDate sessionDate); + + List findAllByOrderBySessionDateAscDayPartAsc(); + + List findByWeekOrderBySessionDateAsc(Long week); + +// @Query(""" +// SELECT s +// FROM StudySession s +// WHERE s.week = :week +// AND FUNCTION('DAY_OF_WEEK', s.sessionDate) = :dayValue +// """) +// Optional findByWeekAndDay( +// @Param("week") Long week, +// @Param("dayValue") DayOfWeek dayValue +// ); } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/service/CurriculumService.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/service/CurriculumService.java index 535140e..1f41745 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/service/CurriculumService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/service/CurriculumService.java @@ -1,4 +1,141 @@ package com.example.Piroin.project.domain.curriculum.service; +import com.example.Piroin.project.domain.curriculum.converter.CurriculumConverter; +import com.example.Piroin.project.domain.curriculum.dto.CurriculumReqDTO; +import com.example.Piroin.project.domain.curriculum.dto.CurriculumResDTO; +import com.example.Piroin.project.domain.curriculum.entity.StudySession; +import com.example.Piroin.project.domain.curriculum.enums.SessionStatus; +import com.example.Piroin.project.domain.curriculum.exception.CurriculumException; +import com.example.Piroin.project.domain.curriculum.repository.CurriculumRepository; +import com.example.Piroin.project.domain.user.entity.User; +import com.example.Piroin.project.domain.user.repository.UserRepository; +import com.example.Piroin.project.global.util.SecurityUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor public class CurriculumService { + + private final CurriculumRepository curriculumRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public List getAllDays() { + Map> grouped = curriculumRepository.findAllByOrderBySessionDateAscDayPartAsc() + .stream() + .collect(Collectors.groupingBy(StudySession::getSessionDate, Collectors.toList())); + + return grouped.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> CurriculumConverter.toCreateDayRes(entry.getValue())) + .collect(Collectors.toList()); + } + + @Transactional + public CurriculumResDTO.CreateDayRes createDay(CurriculumReqDTO.CreateDayReq req) { + if (req.getGeneration() == null) throw new CurriculumException(HttpStatus.BAD_REQUEST, "기수는 필수입니다."); + if (req.getWeek() == null) throw new CurriculumException(HttpStatus.BAD_REQUEST, "주차는 필수입니다."); + if (req.getSessionDate() == null) throw new CurriculumException(HttpStatus.BAD_REQUEST, "세션 날짜는 필수입니다."); + if (req.getSessions() == null || req.getSessions().size() != 2) + throw new CurriculumException(HttpStatus.BAD_REQUEST, "AM/PM 세션 2개를 함께 입력해야 합니다."); + + req.getSessions().forEach(s -> { + if (s.getDayPart() == null) throw new CurriculumException(HttpStatus.BAD_REQUEST, "dayPart는 필수입니다."); + if (s.getTitle() == null || s.getTitle().isBlank()) throw new CurriculumException(HttpStatus.BAD_REQUEST, "세션 제목은 필수입니다."); + }); + + if (!curriculumRepository.findBySessionDate(req.getSessionDate()).isEmpty()) + throw new CurriculumException(HttpStatus.CONFLICT, "해당 날짜에 이미 세션이 존재합니다."); + + User user = userRepository.findById(SecurityUtil.getCurrentUserId()) + .orElseThrow(() -> new CurriculumException(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + + List sessions = req.getSessions().stream() + .map(sessionReq -> CurriculumConverter.toStudySession(sessionReq, req, user)) + .collect(Collectors.toList()); + + return CurriculumConverter.toCreateDayRes(curriculumRepository.saveAll(sessions)); + } + + @Transactional + public CurriculumResDTO.CreateDayRes updateDay(LocalDate sessionDate, CurriculumReqDTO.UpdateDayReq req) { + List sessions = curriculumRepository.findBySessionDate(sessionDate); + if (sessions.isEmpty()) throw new CurriculumException(HttpStatus.NOT_FOUND, "해당 세션을 찾을 수 없습니다."); + + LocalDate newDate = req.getNewSessionDate(); + if (newDate != null && !newDate.equals(sessionDate) && !curriculumRepository.findBySessionDate(newDate).isEmpty()) + throw new CurriculumException(HttpStatus.CONFLICT, "해당 날짜에 이미 세션이 존재합니다."); + + req.getSessions().forEach(sessionReq -> { + if (sessionReq.getTitle() == null || sessionReq.getTitle().isBlank()) + throw new CurriculumException(HttpStatus.BAD_REQUEST, "세션 제목은 필수입니다."); + + sessions.stream() + .filter(s -> s.getDayPart() == sessionReq.getDayPart()) + .findFirst() + .ifPresent(s -> s.updateFull( + req.getGeneration(), req.getWeek(), newDate, sessionReq.getStatus(), + sessionReq.getTitle(), sessionReq.getHostName(), + sessionReq.getSessionMaterialUrl(), + sessionReq.getSessionMaterialName(), sessionReq.getRecordingUrl(), + sessionReq.getRecordingPassword(), sessionReq.getAssignmentUrl(), + sessionReq.getAssignmentName() + )); + }); + + return CurriculumConverter.toCreateDayRes(sessions); + } + + @Transactional + public void deleteDay(LocalDate sessionDate) { + List sessions = curriculumRepository.findBySessionDate(sessionDate); + if (sessions.isEmpty()) throw new CurriculumException(HttpStatus.NOT_FOUND, "해당 세션을 찾을 수 없습니다."); + curriculumRepository.deleteAll(sessions); + } + + @Transactional(readOnly = true) + public CurriculumResDTO.QnaSessionsResponse getQnaSessions() { + List activeSessions = curriculumRepository + .findByStatusOrderBySessionDateAscDayPartAsc(SessionStatus.IN_SESSION) + .stream() + .map(this::toActiveSessionResponse) + .toList(); + + List pastSessions = curriculumRepository + .findByStatusOrderBySessionDateDescDayPartDesc(SessionStatus.AFTER_SESSION) + .stream() + .map(this::toPastSessionResponse) + .toList(); + + return new CurriculumResDTO.QnaSessionsResponse(activeSessions, pastSessions); + } + + private CurriculumResDTO.ActiveSessionResponse toActiveSessionResponse(StudySession session) { + return new CurriculumResDTO.ActiveSessionResponse( + session.getId(), + session.getWeek().intValue(), + session.getSessionDate().getDayOfWeek().name(), + session.getDayPart().name(), + session.getSessionDate().toString(), + session.getTitle() + ); + } + + private CurriculumResDTO.PastSessionResponse toPastSessionResponse(StudySession session) { + return new CurriculumResDTO.PastSessionResponse( + session.getId(), + session.getWeek().intValue(), + session.getSessionDate().getDayOfWeek().name(), + session.getDayPart().name(), + session.getTitle() + ); + } } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/deposit/controller/DepositController.java b/backend/src/main/java/com/example/Piroin/project/domain/deposit/controller/DepositController.java index 046af4f..61e7ca5 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/deposit/controller/DepositController.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/deposit/controller/DepositController.java @@ -1,4 +1,54 @@ package com.example.Piroin.project.domain.deposit.controller; +import com.example.Piroin.project.domain.deposit.dto.AdminDepositViewResponse; +import com.example.Piroin.project.domain.deposit.dto.DepositResponse; +import com.example.Piroin.project.domain.deposit.dto.UpdateDefenceRequest; +import com.example.Piroin.project.domain.deposit.dto.UpdateDefenceResponse; +import com.example.Piroin.project.domain.deposit.service.DepositService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/deposit") +@Tag(name = "보증금", description = "보증금 관련 API") public class DepositController { -} + + private final DepositService depositService; + + + // 1. 나의 보증금 조회 + @Operation(summary = "(부원) 나의 보증금 조회", description = "사용자가 본인의 남은 보증금, 차감된 보증금, 방어권을 조회합니다.") + @GetMapping("/me") + public DepositResponse getMyDeposit( + Authentication authentication + ) { + + Long userId = Long.valueOf(authentication.getName()); + + return depositService.getMyDeposit(userId); + } + + + // 2. 운영진이 특정 부원의 보증금을 조회 + @Operation(summary = "(운영진) 특정 부원 보증금 조회", description = "운영진이 특정 부원의 보증금 상태를 조회합니다.") + @GetMapping("/{userId}/deposit/view") + public AdminDepositViewResponse getUserDeposit( + @PathVariable Long userId + ) { + return depositService.getUserDeposit(userId); + } + + // 3. 운영진이 특정 부원의 보증금 정보를 수정 + @Operation(summary = "(운영진) 특정 부원 보증금 방어권 수정", description = "운영진이 특정 부원의 보증금 방어권 금액을 수정합니다.") + @PatchMapping("/{userId}/deposit/defence") + public UpdateDefenceResponse updateUserDefence( + @PathVariable Long userId, + @RequestBody UpdateDefenceRequest request + ) { + return depositService.updateUserDefence(userId, request); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/deposit/dto/AdminDepositViewResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/deposit/dto/AdminDepositViewResponse.java new file mode 100644 index 0000000..1e6e41b --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/deposit/dto/AdminDepositViewResponse.java @@ -0,0 +1,21 @@ +package com.example.Piroin.project.domain.deposit.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class AdminDepositViewResponse { + + private Long userId; + + private String name; + + private Integer amount; + + private Integer descentAssignment; + + private Integer descentAttendance; + + private Integer ascentDefence; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/deposit/dto/DepositReqDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/deposit/dto/DepositReqDTO.java deleted file mode 100644 index def9c5f..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/deposit/dto/DepositReqDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.deposit.dto; - -public class DepositReqDTO { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/deposit/dto/DepositResDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/deposit/dto/DepositResDTO.java deleted file mode 100644 index 9f7af3e..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/deposit/dto/DepositResDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.deposit.dto; - -public class DepositResDTO { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/deposit/dto/DepositResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/deposit/dto/DepositResponse.java new file mode 100644 index 0000000..21fe989 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/deposit/dto/DepositResponse.java @@ -0,0 +1,21 @@ +package com.example.Piroin.project.domain.deposit.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class DepositResponse { + + // 현재 남은 보증금 + private Integer amount; + + // 과제 차감 누적 + private Integer descentAssignment; + + // 출석 차감 누적 + private Integer descentAttendance; + + // 방어권 누적 + private Integer ascentDefence; +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/deposit/dto/UpdateDefenceRequest.java b/backend/src/main/java/com/example/Piroin/project/domain/deposit/dto/UpdateDefenceRequest.java new file mode 100644 index 0000000..b63c63d --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/deposit/dto/UpdateDefenceRequest.java @@ -0,0 +1,11 @@ +package com.example.Piroin.project.domain.deposit.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class UpdateDefenceRequest { + + private Integer ascentDefence; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/deposit/dto/UpdateDefenceResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/deposit/dto/UpdateDefenceResponse.java new file mode 100644 index 0000000..bcf32d2 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/deposit/dto/UpdateDefenceResponse.java @@ -0,0 +1,15 @@ +package com.example.Piroin.project.domain.deposit.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UpdateDefenceResponse { + + private Long userId; + + private Integer amount; + + private Integer ascentDefence; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/deposit/entity/Deposit.java b/backend/src/main/java/com/example/Piroin/project/domain/deposit/entity/Deposit.java new file mode 100644 index 0000000..b3508b3 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/deposit/entity/Deposit.java @@ -0,0 +1,78 @@ +package com.example.Piroin.project.domain.deposit.entity; + +import com.example.Piroin.project.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table( + name = "deposit", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_deposit_user", + columnNames = "user_id" + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Deposit { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false) + private Integer amount; + + @Column(name = "descent_assignment", nullable = false) + private Integer descentAssignment; + + @Column(name = "descent_attendance", nullable = false) + private Integer descentAttendance; + + @Column(name = "ascent_defence", nullable = false) + private Integer ascentDefence; + + private static final int BASE_AMOUNT = 100_000; + + private int calculateAmount() { + int calculatedAmount = BASE_AMOUNT + - this.descentAssignment + - this.descentAttendance + + this.ascentDefence; + + return Math.min(BASE_AMOUNT, calculatedAmount); + } + + // 출석 차감액만 새로 계산할 때 사용 + public void updateAttendanceAmount(Integer descentAttendance) { + this.descentAttendance = descentAttendance; + this.amount = calculateAmount(); + } + + // 과제 차감 + 출석 차감을 한 번에 재계산할 때 사용 + public void updateDepositAmount( + Integer descentAssignment, + Integer descentAttendance + ) { + this.descentAssignment = descentAssignment; + this.descentAttendance = descentAttendance; + this.amount = calculateAmount(); + } + + // 보증금 방어권 수정 + public void updateDefenceAmount(Integer ascentDefence) { + this.ascentDefence = ascentDefence; + this.amount = calculateAmount(); + } + + +} + diff --git a/backend/src/main/java/com/example/Piroin/project/domain/deposit/entity/deposit.java b/backend/src/main/java/com/example/Piroin/project/domain/deposit/entity/deposit.java deleted file mode 100644 index ff893d4..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/deposit/entity/deposit.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.deposit.entity; - -public class deposit { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/deposit/exception/DepositException.java b/backend/src/main/java/com/example/Piroin/project/domain/deposit/exception/DepositException.java index 2a6b0b5..c741b86 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/deposit/exception/DepositException.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/deposit/exception/DepositException.java @@ -1,7 +1,15 @@ package com.example.Piroin.project.domain.deposit.exception; +import com.example.Piroin.project.domain.deposit.exception.code.DepositErrorCode; +import lombok.Getter; + +@Getter public class DepositException extends RuntimeException { - public DepositException(String message) { - super(message); + + private final DepositErrorCode errorCode; + + public DepositException(DepositErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; } -} +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/deposit/exception/code/DepositErrorCode.java b/backend/src/main/java/com/example/Piroin/project/domain/deposit/exception/code/DepositErrorCode.java index 95ae1df..853842a 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/deposit/exception/code/DepositErrorCode.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/deposit/exception/code/DepositErrorCode.java @@ -1,4 +1,20 @@ package com.example.Piroin.project.domain.deposit.exception.code; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor public enum DepositErrorCode { -} + + DEPOSIT_NOT_FOUND( + HttpStatus.BAD_REQUEST, + "DEPOSIT4001", + "보증금 정보가 존재하지 않습니다." + ); + + private final HttpStatus status; + private final String code; + private final String message; +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/deposit/repository/DepositRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/deposit/repository/DepositRepository.java index d8599d9..f3836bc 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/deposit/repository/DepositRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/deposit/repository/DepositRepository.java @@ -1,4 +1,14 @@ package com.example.Piroin.project.domain.deposit.repository; -public interface DepositRepository { +import com.example.Piroin.project.domain.deposit.entity.Deposit; +import com.example.Piroin.project.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface DepositRepository extends JpaRepository { + Optional findByUser(User user); + + Optional findByUserId(Long userId); + } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/deposit/service/DepositService.java b/backend/src/main/java/com/example/Piroin/project/domain/deposit/service/DepositService.java index a3b38a1..de461cd 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/deposit/service/DepositService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/deposit/service/DepositService.java @@ -1,4 +1,99 @@ package com.example.Piroin.project.domain.deposit.service; +import com.example.Piroin.project.domain.attendance.repository.AttendanceRepository; +import com.example.Piroin.project.domain.deposit.dto.AdminDepositViewResponse; +import com.example.Piroin.project.domain.deposit.dto.DepositResponse; +import com.example.Piroin.project.domain.deposit.dto.UpdateDefenceRequest; +import com.example.Piroin.project.domain.deposit.dto.UpdateDefenceResponse; +import com.example.Piroin.project.domain.deposit.entity.Deposit; +import com.example.Piroin.project.domain.deposit.exception.DepositException; +import com.example.Piroin.project.domain.deposit.exception.code.DepositErrorCode; +import com.example.Piroin.project.domain.deposit.repository.DepositRepository; +import com.example.Piroin.project.domain.user.entity.User; +import com.example.Piroin.project.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor public class DepositService { + + private static final int ATTENDANCE_PENALTY = 10_000; + + private final DepositRepository depositRepository; + private final UserRepository userRepository; + private final AttendanceRepository attendanceRepository; + + // 1. 보증금 재계산 로직 (운영진이 출석/과제 여부 수정 시, 출석코드 만료 시) + @Transactional + public void recalculateDeposit(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + + Deposit deposit = depositRepository.findByUser(user) + .orElseThrow(() -> new IllegalArgumentException("보증금 정보가 존재하지 않습니다.")); + + int failAttendanceCount = attendanceRepository.countByUserAndStatusFalse(user); + int descentAttendance = failAttendanceCount * ATTENDANCE_PENALTY; + + deposit.updateAttendanceAmount(descentAttendance); + } + + // 2. 보증금 조회 로직 + public DepositResponse getMyDeposit(Long userId) { + + Deposit deposit = depositRepository.findByUserId(userId) + .orElseThrow(() -> + new DepositException( + DepositErrorCode.DEPOSIT_NOT_FOUND + ) + ); + + return DepositResponse.builder() + .amount(deposit.getAmount()) + .descentAssignment(deposit.getDescentAssignment()) + .descentAttendance(deposit.getDescentAttendance()) + .ascentDefence(deposit.getAscentDefence()) + .build(); + } + + + // 3. 운영진이 특정 부원의 보증금 조회 로직 + @Transactional(readOnly = true) + public AdminDepositViewResponse getUserDeposit(Long userId) { + + Deposit deposit = depositRepository.findByUserId(userId) + .orElseThrow(() -> new RuntimeException("보증금 정보가 존재하지 않습니다.")); + + return AdminDepositViewResponse.builder() + .userId(deposit.getUser().getId()) + .name(deposit.getUser().getName()) + .amount(deposit.getAmount()) + .descentAssignment(deposit.getDescentAssignment()) + .descentAttendance(deposit.getDescentAttendance()) + .ascentDefence(deposit.getAscentDefence()) + .build(); + } + + // 4. 운영진이 특정 부원의 보증금을 수정 + @Transactional + public UpdateDefenceResponse updateUserDefence( + Long userId, + UpdateDefenceRequest request + ) { + + Deposit deposit = depositRepository.findByUserId(userId) + .orElseThrow(() -> new RuntimeException("보증금 정보가 존재하지 않습니다.")); + + if (request.getAscentDefence() != null) { + deposit.updateDefenceAmount(request.getAscentDefence()); + } + + return new UpdateDefenceResponse( + deposit.getUser().getId(), + deposit.getAmount(), + deposit.getAscentDefence() + ); + } } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/controller/ImageController.java b/backend/src/main/java/com/example/Piroin/project/domain/question/controller/ImageController.java new file mode 100644 index 0000000..1fdac22 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/controller/ImageController.java @@ -0,0 +1,74 @@ +package com.example.Piroin.project.domain.question.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/images") +public class ImageController { + + @Value("${file.upload-dir}") + private String uploadDir; + + // consumes 제거 + // Swagger용 어노테이션 추가 (파일 선택 버튼 표시용) + @PostMapping + @Operation( + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + schema = @Schema(type = "object", requiredProperties = {"file"})) + ) + ) + public ResponseEntity> uploadImage( + @RequestParam("file") MultipartFile file + ) throws IOException { + + // 절대 경로로 변환 + File dir = new File(uploadDir).getAbsoluteFile(); + if (!dir.exists()) { + dir.mkdirs(); + } + + // 파일명 중복 방지: UUID + 원본 확장자 + String originalName = file.getOriginalFilename(); + String extension = ""; + if (originalName != null && originalName.contains(".")) { + extension = originalName.substring(originalName.lastIndexOf(".")); + } + String savedName = UUID.randomUUID() + extension; + + // 절대 경로로 파일 저장 + File targetFile = new File(dir, savedName); + file.transferTo(targetFile); + + return ResponseEntity.ok(Map.of("imageUrl", "/api/images/" + savedName)); + } + + // 이미지 조회 + // GET /api/images/{filename} + @GetMapping("/{filename}") + public ResponseEntity getImage(@PathVariable String filename) throws IOException { + File file = new File(new File(uploadDir).getAbsoluteFile(), filename); // ← 절대 경로 + + if (!file.exists()) { + return ResponseEntity.notFound().build(); + } + + String contentType = filename.endsWith(".png") ? "image/png" : "image/jpeg"; + + return ResponseEntity.ok() + .header("Content-Type", contentType) + .body(java.nio.file.Files.readAllBytes(file.toPath())); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/controller/QuestionController.java b/backend/src/main/java/com/example/Piroin/project/domain/question/controller/QuestionController.java index b4b7828..3cd0b8a 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/controller/QuestionController.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/controller/QuestionController.java @@ -1,4 +1,170 @@ package com.example.Piroin.project.domain.question.controller; +import com.example.Piroin.project.domain.question.dto.QuestionReqDTO; +import com.example.Piroin.project.domain.question.dto.QuestionResDTO; +import com.example.Piroin.project.domain.question.exception.code.QuestionSuccessCode; +import com.example.Piroin.project.domain.question.service.QuestionService; +import com.example.Piroin.project.global.response.ApiResponse; +import com.example.Piroin.project.global.response.ResponseUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@RestController +@RequiredArgsConstructor public class QuestionController { + private final QuestionService questionService; + + // 질문 목록 + 이해도 조회 + // GET /api/sessions/{sessionId}/questions?understandingIndex=0 + @GetMapping("/api/sessions/{sessionId}/questions") + public ResponseEntity> getQuestionRoom( + @PathVariable Long sessionId, + @RequestParam(defaultValue = "0") int understandingIndex, + @AuthenticationPrincipal Long userId + ) { + return ResponseUtil.success(QuestionSuccessCode.QUESTION_ROOM_OK, + questionService.getQuestionRoom(sessionId, understandingIndex, userId)); // ← userId 추가 + } + + // 질문 목록 실시간 이벤트 구독 + // GET /api/sessions/{sessionId}/questions/events + // text/event-stream으로 연결을 유지하며, 댓글 생성 같은 목록 갱신 이벤트를 받는다. + // 인증 헤더가 필요하므로 프론트에서는 기본 EventSource 대신 fetch 기반 SSE 클라이언트로 구독한다. + @GetMapping(value = "/api/sessions/{sessionId}/questions/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter subscribeQuestionEvents(@PathVariable Long sessionId) { + return questionService.subscribeQuestionEvents(sessionId); + } + + // 질문 상세 조회 + // GET /api/questions/{questionId} + @GetMapping("/api/questions/{questionId}") + public ResponseEntity> getQuestionDetail( + @PathVariable Long questionId, + @AuthenticationPrincipal Long userId + ) { + return ResponseUtil.success(QuestionSuccessCode.QUESTION_DETAIL_OK, + questionService.getQuestionDetail(questionId, userId)); + } + + // 질문 등록 + // POST /api/sessions/{sessionId}/questions + @PostMapping("/api/sessions/{sessionId}/questions") + public ResponseEntity> createQuestion( + @PathVariable Long sessionId, + @RequestBody QuestionReqDTO.CreateReq request, + @AuthenticationPrincipal Long userId + ) { + return ResponseUtil.success(QuestionSuccessCode.QUESTION_CREATED, + questionService.createQuestion(sessionId, request, userId)); + } + + // 댓글/대댓글 등록 + // POST /api/questions/{questionId}/comments + @PostMapping("/api/questions/{questionId}/comments") + public ResponseEntity> createComment( + @PathVariable Long questionId, + @RequestBody QuestionReqDTO.CommentReq request, + @AuthenticationPrincipal Long userId + ) { + return ResponseUtil.success(QuestionSuccessCode.COMMENT_CREATED, + questionService.createComment(questionId, request, userId)); + } + + // 좋아요 토글 + // POST /api/questions/{questionId}/like + @PostMapping("/api/questions/{questionId}/like") + public ResponseEntity> toggleLike( + @PathVariable Long questionId, + @AuthenticationPrincipal Long userId + ) { + return ResponseUtil.success(QuestionSuccessCode.LIKE_TOGGLED, + questionService.toggleLike(questionId, userId)); + } + + // 질문 수정 + // PATCH /api/questions/{questionId}/modify + @PatchMapping("/api/questions/{questionId}/modify") + public ResponseEntity> updateQuestion( + @PathVariable Long questionId, + @RequestBody QuestionReqDTO.UpdateReq request, + @AuthenticationPrincipal Long userId + ) { + return ResponseUtil.success(QuestionSuccessCode.QUESTION_UPDATED, + questionService.updateQuestion(questionId, request, userId)); + } + + // 질문 삭제 + // DELETE /api/questions/{questionId} + @DeleteMapping("/api/questions/{questionId}") + public ResponseEntity> deleteQuestion( + @PathVariable Long questionId, + @AuthenticationPrincipal Long userId + ) { + return ResponseUtil.success(QuestionSuccessCode.QUESTION_DELETED, + questionService.deleteQuestion(questionId, userId)); + } + + // 질문 상태 완료 전환 (관리자 전용) + // 부원이 댓글을 달아 미해결로 자동 전환되는 흐름은 댓글 등록 서비스 내부에서만 처리한다. + // PATCH /api/questions/{questionId}/status + @PatchMapping("/api/questions/{questionId}/status") + public ResponseEntity> updateQuestionStatus( + @PathVariable Long questionId, + @AuthenticationPrincipal Long userId + ) { + return ResponseUtil.success(QuestionSuccessCode.QUESTION_STATUS_UPDATED, + questionService.updateQuestionStatus(questionId, userId)); + } + + // 댓글 수정 + // PATCH /api/comments/{commentId} + @PatchMapping("/api/comments/{commentId}") + public ResponseEntity> updateComment( + @PathVariable Long commentId, + @RequestBody QuestionReqDTO.CommentUpdateReq request, + @AuthenticationPrincipal Long userId + ) { + return ResponseUtil.success(QuestionSuccessCode.COMMENT_UPDATED, + questionService.updateComment(commentId, request, userId)); + } + + // 댓글 삭제 + // DELETE /api/comments/{commentId} + @DeleteMapping("/api/comments/{commentId}") + public ResponseEntity> deleteComment( + @PathVariable Long commentId, + @AuthenticationPrincipal Long userId + ) { + return ResponseUtil.success(QuestionSuccessCode.COMMENT_DELETED, + questionService.deleteComment(commentId, userId)); + } + + // 이해도 체크 생성 + // POST /api/sessions/{sessionId}/understanding-checks + @PostMapping("/api/sessions/{sessionId}/understanding-checks") + public ResponseEntity> createUnderstandingCheck( + @PathVariable Long sessionId, + @RequestBody QuestionReqDTO.UnderstandingCheckCreateReq request, + @AuthenticationPrincipal Long userId + ) { + return ResponseUtil.success(QuestionSuccessCode.UNDERSTANDING_CHECK_CREATED, + questionService.createUnderstandingCheck(sessionId, request, userId)); + } + + // 이해도 체크 응답 + // POST /api/sessions/{sessionId}/understanding-checks/{checkId}/responses + @PostMapping("/api/sessions/{sessionId}/understanding-checks/{checkId}/responses") + public ResponseEntity> respondUnderstandingCheck( + @PathVariable Long sessionId, + @PathVariable Long checkId, + @RequestBody QuestionReqDTO.UnderstandingResponseReq request, + @AuthenticationPrincipal Long userId + ) { + return ResponseUtil.success(QuestionSuccessCode.UNDERSTANDING_RESPONSE_OK, + questionService.respondUnderstandingCheck(sessionId, checkId, request, userId)); + } } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionReqDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionReqDTO.java index 9188c04..c49ab0c 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionReqDTO.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionReqDTO.java @@ -1,4 +1,54 @@ package com.example.Piroin.project.domain.question.dto; +import com.example.Piroin.project.domain.question.enums.UnderstandResChoice; +import lombok.Getter; +import lombok.NoArgsConstructor; + public class QuestionReqDTO { + + // 질문 등록 요청 + @Getter + @NoArgsConstructor + public static class CreateReq { + private String content; + private String imageUrl; + } + + // 질문 수정 요청 + @Getter + @NoArgsConstructor + public static class UpdateReq { + private String content; + } + + // 댓글/대댓글 등록 요청 + // parentCommentId가 null이면 일반 댓글, 값이 있으면 대댓글 + @Getter + @NoArgsConstructor + public static class CommentReq { + private String content; + private String imageUrl; + private Long parentCommentId; // 대댓글일 때만 값이 있음, 일반 댓글이면 null + } + + // 댓글 수정 요청 + @Getter + @NoArgsConstructor + public static class CommentUpdateReq { + private String content; + } + + // 이해도 체크 응답 요청 + @Getter + @NoArgsConstructor + public static class UnderstandingResponseReq { + private UnderstandResChoice choice; + } + + // 이해도 체크 생성 요청 + @Getter + @NoArgsConstructor + public static class UnderstandingCheckCreateReq { + private String content; + } } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java index ba827e4..0347795 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java @@ -1,4 +1,228 @@ package com.example.Piroin.project.domain.question.dto; +import com.example.Piroin.project.domain.question.entity.Question; +import com.example.Piroin.project.domain.question.enums.UnderstandResChoice; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + public class QuestionResDTO { + // 질문 등록 응답 + @Getter + @Builder + public static class CreateRes { + private Long id; + private String content; + private Boolean isSolved; + private Integer likeCount; + private LocalDateTime createdAt; + + public static CreateRes from(Question question) { + return CreateRes.builder() + .id(question.getId()) + .content(question.getContent()) + .isSolved(question.getIsResolved()) + .likeCount(question.getLikeCount()) + .createdAt(question.getCreatedAt()) + .build(); + } + } + + // 댓글 등록 응답 + public record CommentCreateRes( + Long commentId, + Long questionId, + String displayName, + String content, + Boolean isResolved, + LocalDateTime createdAt + ) { + } + + // 댓글 수정/삭제 응답 + public record CommentUpdateDeleteRes( + Long commentId, + String content, + LocalDateTime updatedAt, + LocalDateTime deletedAt + ) { + } + + // 좋아요 토글 응답 + // isLiked: true면 좋아요 추가된 상태, false면 취소된 상태 + public record LikeRes( + Long id, + Integer likeCount, + Boolean isLiked + ) { + } + + // 질문 수정/삭제 응답 (형태가 동일해서 하나로 공유) + // deletedAt에 값이 있으면 삭제된 상태 + public record UpdateDeleteRes( + Long id, + String content, + LocalDateTime updatedAt, + LocalDateTime deletedAt + ) { + } + + // 질문 상세 응답 + public record QuestionDetailResponse( + Long questionId, + String displayName, + String content, + String imageUrl, + Boolean isResolved, + Boolean isPopular, + Integer likeCount, + Boolean isLiked, + Boolean isMine, + LocalDateTime createdAt, + List comments + ) { + } + + // 댓글 조회 응답 (상세 페이지용) + public record CommentResponse( + Long commentId, + String displayName, + String content, + String imageUrl, + Boolean isMine, + LocalDateTime createdAt, + List replies + ) { + } + + // 질문 상태 변경 응답 + public record StatusUpdateRes( + Long id, + Boolean isResolved, + LocalDateTime updatedAt + ) { + } + + // 질문 방 전체 응답 + public record QuestionRoomResponse( + SessionResponse session, + UnderstandingSliceResponse understanding, + QuestionGroupsResponse questions + ) { + } + + public record SessionResponse( + Long sessionId, + Integer week, + String dayOfWeek, + String dayPart, + LocalDate sessionDate, + String title + ) { + } + + public record UnderstandingSliceResponse( + UnderstandingCheckResponse current, + Integer currentIndex, + Integer totalCount, + Boolean hasOlder, + Boolean hasNewer + ) { + } + + // 질문방 이해도 체크 바 응답. 프론트는 이 값들로 "이해했다 (13/29)"와 오른쪽 O/X 숫자를 그린다. + public record UnderstandingCheckResponse( + Long checkId, + String content, + // 화면의 "13/29" 중 13: O 응답 수 + X 응답 수 + Integer respondedCount, + // 화면의 "13/29" 중 29: 해당 세션에 대응되는 출석 회차의 출석 인원 + Integer attendanceCount, + // 오른쪽 O 뱃지 숫자 + Integer understoodCount, + // 오른쪽 X 뱃지 숫자 + Integer notUnderstoodCount, + LocalDateTime createdAt + ) { + } + + public record QuestionGroupsResponse( + List popularQuestions, + List unresolvedQuestions, + List resolvedQuestions + ) { + } + + public record QuestionSummaryResponse( + Long questionId, + String content, + String imageUrl, + Boolean isResolved, + Boolean isPopular, + Boolean isLiked, + Boolean isMine, + Integer likeCount, + Integer commentCount, + // 댓글이 없으면 빈 배열로 내려가며, 프론트는 빈 배열일 때 미리보기 영역을 숨긴다. + List previewComments, + LocalDateTime createdAt + ) { + } + + // 질문 목록용 댓글 미리보기 응답. 메인 목록에서는 대댓글 없이 최상위 댓글만 보여준다. + public record PreviewCommentResponse( + Long commentId, + String displayName, + String content, + // true면 프론트는 미리보기 댓글에 "사진 보기" 버튼을 노출하고 질문 상세 페이지로 이동시킨다. + Boolean hasImage, + LocalDateTime createdAt + ) { + } + + // 댓글 생성 시 SSE로 내려가는 목록 갱신 이벤트 응답 + public record CommentCreatedEvent( + String type, + Long sessionId, + Long questionId, + // 댓글 작성 후 서버 내부 규칙까지 반영된 최신 해결 상태 + Boolean isResolved, + Integer commentCount, + List previewComments + ) { + } + + // O/X 클릭 직후 응답. selectedChoice가 null이면 같은 선택지를 다시 눌러 취소된 상태다. + public record UnderstandingResponseResult( + Long checkId, + UnderstandResChoice selectedChoice, + // 화면의 "13/29" 중 13: O 응답 수 + X 응답 수 + Integer respondedCount, + // 화면의 "13/29" 중 29: 해당 세션에 대응되는 출석 회차의 출석 인원 + Integer attendanceCount, + // 오른쪽 O 뱃지 숫자 + Integer understoodCount, + // 오른쪽 X 뱃지 숫자 + Integer notUnderstoodCount + ) { + } + + // 운영진이 이해도 체크를 생성했을 때의 초기 응답. 생성 직후에는 O/X 응답자가 없어서 카운트가 0이다. + public record UnderstandingCheckCreateResponse( + Long checkId, + String content, + // 생성 직후에는 0 + Integer respondedCount, + // 생성 응답에서는 질문방 조회 맥락이 아니므로 null로 내려간다. + Integer attendanceCount, + // 생성 직후에는 0 + Integer understoodCount, + // 생성 직후에는 0 + Integer notUnderstoodCount, + LocalDateTime createdAt + ) { + } } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java new file mode 100644 index 0000000..a9a4a0d --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java @@ -0,0 +1,88 @@ +package com.example.Piroin.project.domain.question.entity; + +import com.example.Piroin.project.domain.curriculum.entity.StudySession; +import com.example.Piroin.project.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "question") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Question { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "session_id", nullable = false) + private StudySession session; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(name = "image_url", columnDefinition = "TEXT") + private String imageUrl; + + @Column(name = "is_resolved", nullable = false) + private Boolean isResolved; + + @Column(name = "like_count", nullable = false) + private Integer likeCount; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + // 댓글이 새로 달리면 미해결로 되돌리도록 + public void markUnresolved() { + this.isResolved = false; + this.updatedAt = LocalDateTime.now(); + } + + // 좋아요 추가 시 호출 + public void increaseLikeCount() { + this.likeCount++; + this.updatedAt = LocalDateTime.now(); + } + + // 좋아요 취소 시 호출 (0 아래로 내려가지 않도록 방어) + public void decreaseLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + this.updatedAt = LocalDateTime.now(); + } + + // 질문 내용 수정 + public void updateContent(String content) { + this.content = content; + this.updatedAt = LocalDateTime.now(); + } + + // 질문 소프트 삭제 (DB에서 실제로 지우지 않고 deleted_at에 시각 기록) + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + // 질문 상태를 해결 완료로 변경 (관리자만 호출) + public void markResolved() { + this.isResolved = true; + this.updatedAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionAnonymousIdentity.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionAnonymousIdentity.java new file mode 100644 index 0000000..94a617e --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionAnonymousIdentity.java @@ -0,0 +1,47 @@ +package com.example.Piroin.project.domain.question.entity; + +import com.example.Piroin.project.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "question_anonymous_identity", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_question_anon_question_user", + columnNames = {"question_id", "user_id"} + ), + @UniqueConstraint( + name = "uq_question_anon_question_no", + columnNames = {"question_id", "anonymous_no"} + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class QuestionAnonymousIdentity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "question_id", nullable = false) + private Question question; + + @Column(name = "anonymous_no", nullable = false) + private Integer anonymousNo; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; +} + diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionComment.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionComment.java new file mode 100644 index 0000000..c173dd9 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionComment.java @@ -0,0 +1,65 @@ +package com.example.Piroin.project.domain.question.entity; + +import com.example.Piroin.project.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "question_comment") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class QuestionComment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "question_id", nullable = false) + private Question question; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + /* + 대댓글을 위한 부모 댓글 참조 + null이면 → 일반 댓글(최상위) + 값이 있으면 → 대댓글(parentComment가 부모) + 같은 QuestionComment 테이블을 자기 자신이 참조하는 구조 + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_comment_id") + private QuestionComment parentComment; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(name = "image_url", columnDefinition = "TEXT") + private String imageUrl; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + // 댓글 내용 수정 + public void updateContent(String content) { + this.content = content; + this.updatedAt = LocalDateTime.now(); + } + + // 댓글 소프트 삭제 + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionLike.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionLike.java new file mode 100644 index 0000000..7e435df --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionLike.java @@ -0,0 +1,40 @@ +package com.example.Piroin.project.domain.question.entity; + +import com.example.Piroin.project.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "question_like", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_question_like_question_user", + columnNames = {"question_id", "user_id"} + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class QuestionLike { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "question_id", nullable = false) + private Question question; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; +} + diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/UnderstandingCheck.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/UnderstandingCheck.java new file mode 100644 index 0000000..6c0fc34 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/UnderstandingCheck.java @@ -0,0 +1,41 @@ +package com.example.Piroin.project.domain.question.entity; + +import com.example.Piroin.project.domain.curriculum.entity.StudySession; +import com.example.Piroin.project.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "understanding_check") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class UnderstandingCheck { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "session_id", nullable = false) + private StudySession session; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by", nullable = false) + private User createdBy; + + @Column(nullable = false) + private String title; + + private String description; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} + diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/UnderstandingResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/UnderstandingResponse.java new file mode 100644 index 0000000..980b7f8 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/UnderstandingResponse.java @@ -0,0 +1,56 @@ +package com.example.Piroin.project.domain.question.entity; + +import com.example.Piroin.project.domain.question.enums.UnderstandResChoice; +import com.example.Piroin.project.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "understanding_response", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_understanding_response_check_user", + columnNames = {"check_id", "user_id"} + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class UnderstandingResponse { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "check_id", nullable = false) + private UnderstandingCheck check; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private UnderstandResChoice choice; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public boolean hasChoice(UnderstandResChoice choice) { + return this.choice == choice; + } + + public void changeChoice(UnderstandResChoice choice) { + this.choice = choice; + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/question.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/question.java deleted file mode 100644 index 2227b41..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/question.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.question.entity; - -public class question { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/question_anonymous_identity.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/question_anonymous_identity.java deleted file mode 100644 index 380bd42..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/question_anonymous_identity.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.question.entity; - -public class question_anonymous_identity { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/question_comment.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/question_comment.java deleted file mode 100644 index d7e7dcf..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/question_comment.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.question.entity; - -public class question_comment { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/question_like.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/question_like.java deleted file mode 100644 index 598ed92..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/question_like.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.question.entity; - -public class question_like { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/understanding_check.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/understanding_check.java deleted file mode 100644 index 2b4993e..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/understanding_check.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.question.entity; - -public class understanding_check { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/understanding_response.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/understanding_response.java deleted file mode 100644 index a179494..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/understanding_response.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.question.entity; - -public class understanding_response { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/enums/UnderstandResChoice.java b/backend/src/main/java/com/example/Piroin/project/domain/question/enums/UnderstandResChoice.java index 1e5e9fa..7967339 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/enums/UnderstandResChoice.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/enums/UnderstandResChoice.java @@ -1,4 +1,6 @@ package com.example.Piroin.project.domain.question.enums; public enum UnderstandResChoice { + UNDERSTOOD, + NOT_UNDERSTOOD } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/exception/QuestionException.java b/backend/src/main/java/com/example/Piroin/project/domain/question/exception/QuestionException.java index c8ef2a4..5ee7832 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/exception/QuestionException.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/exception/QuestionException.java @@ -1,7 +1,21 @@ package com.example.Piroin.project.domain.question.exception; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +/* +Q&A 도메인 전용 커스텀 예외 클래스 +서비스 코드에서 문제가 생겼을 때 이 예외를 던짐 +GlobalExceptionHandler가 이 예외를 받아 HTTP 응답으로 변환 +사용 예시: throw new QuestionException(HttpStatus.NOT_FOUND, "질문을 찾을 수 없습니다."); +*/ +@Getter public class QuestionException extends RuntimeException { - public QuestionException(String message) { + + private final HttpStatus status; // 예: HttpStatus.NOT_FOUND(=404) + + public QuestionException(HttpStatus status, String message) { super(message); + this.status = status; } -} +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/exception/code/QuestionSuccessCode.java b/backend/src/main/java/com/example/Piroin/project/domain/question/exception/code/QuestionSuccessCode.java index 40be878..cf90b2d 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/exception/code/QuestionSuccessCode.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/exception/code/QuestionSuccessCode.java @@ -1,4 +1,28 @@ package com.example.Piroin.project.domain.question.exception.code; -public enum QuestionSuccessCode { +import com.example.Piroin.project.global.response.code.BaseCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum QuestionSuccessCode implements BaseCode { + + QUESTION_ROOM_OK(HttpStatus.OK, "QUESTION200_1", "질문 방 조회에 성공했습니다."), + QUESTION_DETAIL_OK(HttpStatus.OK, "QUESTION200_2", "질문 상세 조회에 성공했습니다."), + LIKE_TOGGLED(HttpStatus.OK, "QUESTION200_3", "좋아요가 처리되었습니다."), + UNDERSTANDING_RESPONSE_OK(HttpStatus.OK, "QUESTION200_4", "이해도 응답이 반영되었습니다."), + QUESTION_UPDATED(HttpStatus.OK, "QUESTION200_5", "질문이 수정되었습니다."), + QUESTION_DELETED(HttpStatus.OK, "QUESTION200_6", "질문이 삭제되었습니다."), + QUESTION_STATUS_UPDATED(HttpStatus.OK, "QUESTION200_7", "질문 상태가 변경되었습니다."), + QUESTION_CREATED(HttpStatus.CREATED, "QUESTION201_1", "질문이 등록되었습니다."), + COMMENT_CREATED(HttpStatus.CREATED, "QUESTION201_2", "댓글이 등록되었습니다."), + COMMENT_UPDATED(HttpStatus.OK, "QUESTION200_8", "댓글이 수정되었습니다."), + COMMENT_DELETED(HttpStatus.OK, "QUESTION200_9", "댓글이 삭제되었습니다."), + UNDERSTANDING_CHECK_CREATED(HttpStatus.CREATED, "QUESTION201_3", "이해도 체크가 생성되었습니다."); + + private final HttpStatus status; + private final String code; + private final String message; } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionAnonymousIdentityRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionAnonymousIdentityRepository.java new file mode 100644 index 0000000..cab03c1 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionAnonymousIdentityRepository.java @@ -0,0 +1,21 @@ +package com.example.Piroin.project.domain.question.repository; + +import com.example.Piroin.project.domain.question.entity.Question; +import com.example.Piroin.project.domain.question.entity.QuestionAnonymousIdentity; +import com.example.Piroin.project.domain.user.entity.User; +import com.example.Piroin.project.domain.user.enums.Role; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface QuestionAnonymousIdentityRepository extends JpaRepository { + + // 해당 질문에서 유저의 익명 번호 조회 + // 용도: 댓글 작성 시 이미 번호가 있는지 확인 + Optional findByQuestionAndUser(Question question, User user); + + // 해당 질문에서 특정 역할(MEMBER/ADMIN)의 익명 번호 수 조회 + // 용도: 새 익명 번호 발급 시 역할별로 따로 카운트 + // MEMBER → 익명1, 익명2... / ADMIN → 운영진1, 운영진2... + int countByQuestionAndUser_Role(Question question, Role role); +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionCommentRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionCommentRepository.java new file mode 100644 index 0000000..95da5bf --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionCommentRepository.java @@ -0,0 +1,96 @@ +package com.example.Piroin.project.domain.question.repository; + +import com.example.Piroin.project.domain.question.entity.Question; +import com.example.Piroin.project.domain.question.entity.QuestionComment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +public interface QuestionCommentRepository extends JpaRepository { + /* + 특정 질문의 삭제되지 않은 최상위 댓글 목록(등록순) + parentComment가 null인 것 = 대댓글이 아닌 최상위 댓글 + 용도: 질문 상세 페이지에서 댓글 목록 표시 시 + */ + List findByQuestionAndParentCommentIsNullAndDeletedAtIsNullOrderByCreatedAtAsc(Question question); + + /* + 질문 목록 미리보기용 최상위 댓글 3개를 질문별로 한 번에 조회한다. + row_number()로 각 질문의 오래된 댓글 3개만 남겨 N+1 조회를 피한다. + */ + @Query(value = """ + SELECT ranked.question_id AS "questionId", + ranked.id AS "commentId", + ranked.user_id AS "userId", + u.role AS "userRole", + ranked.content AS "content", + ranked.image_url AS "imageUrl", + ranked.created_at AS "createdAt", + qai.anonymous_no AS "anonymousNo" + FROM ( + SELECT qc.*, + ROW_NUMBER() OVER ( + PARTITION BY qc.question_id + ORDER BY qc.created_at ASC, qc.id ASC + ) AS rn + FROM question_comment qc + WHERE qc.question_id IN (:questionIds) + AND qc.parent_comment_id IS NULL + AND qc.deleted_at IS NULL + ) ranked + JOIN users u ON u.id = ranked.user_id + LEFT JOIN question_anonymous_identity qai + ON qai.question_id = ranked.question_id + AND qai.user_id = ranked.user_id + WHERE ranked.rn <= 3 + ORDER BY ranked.question_id ASC, ranked.created_at ASC, ranked.id ASC + """, nativeQuery = true) + List findPreviewCommentsByQuestionIds(@Param("questionIds") List questionIds); + + /* + 질문 목록의 댓글 수를 질문별로 한 번에 조회한다. + 대댓글도 댓글 수에 포함한다. + */ + @Query(""" + SELECT comment.question.id AS questionId, + COUNT(comment.id) AS commentCount + FROM QuestionComment comment + WHERE comment.question.id IN :questionIds + AND comment.deletedAt IS NULL + GROUP BY comment.question.id + """) + List countByQuestionIds(@Param("questionIds") List questionIds); + + /* + 특정 댓글의 대댓글 목록(등록순) + 용도: 댓글 아래 대댓글을 가져올 때 + */ + List findByParentCommentAndDeletedAtIsNullOrderByCreatedAtAsc(QuestionComment parentComment); + + interface PreviewCommentRow { + Long getQuestionId(); + + Long getCommentId(); + + Long getUserId(); + + String getUserRole(); + + String getContent(); + + String getImageUrl(); + + LocalDateTime getCreatedAt(); + + Integer getAnonymousNo(); + } + + interface CommentCountRow { + Long getQuestionId(); + + Long getCommentCount(); + } +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionLikeRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionLikeRepository.java new file mode 100644 index 0000000..9fb38f3 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionLikeRepository.java @@ -0,0 +1,22 @@ +package com.example.Piroin.project.domain.question.repository; + +import com.example.Piroin.project.domain.question.entity.Question; +import com.example.Piroin.project.domain.question.entity.QuestionLike; +import com.example.Piroin.project.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface QuestionLikeRepository extends JpaRepository { + /* + 특정 유저가 특정 질문에 좋아요를 눌렀는지 조회 + 용도: 좋아요 토글 시 이미 눌렀는지 확인 + */ + Optional findByQuestionAndUser(Question question, User user); + + /* + 특정 유저가 특정 질문에 좋아요를 눌렀는지 여부(boolean) + 용도: 질문 상세 응답에 is_liked 필드 포함 시 + */ + boolean existsByQuestionAndUser(Question question, User user); +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionRepository.java index c05f0b6..9f5a941 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionRepository.java @@ -1,4 +1,22 @@ package com.example.Piroin.project.domain.question.repository; -public interface QuestionRepository { -} +import com.example.Piroin.project.domain.curriculum.entity.StudySession; +import com.example.Piroin.project.domain.question.entity.Question; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface QuestionRepository extends JpaRepository { + /* + 특정 세션의 삭제되지 않은 질문 목록 조회 + 용도: 질문 목록 API에서 세션별 질문을 가져올 때 + */ + List findBySessionAndDeletedAtIsNull(StudySession session); + + /* + ID로 삭제되지 않은 질문 단건 조회 + 용도: 질문 상세 조회, 수정, 삭제, 좋아요 처리 시 + */ + Optional findByIdAndDeletedAtIsNull(Long id); +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/UnderstandingCheckRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/UnderstandingCheckRepository.java new file mode 100644 index 0000000..c341aed --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/UnderstandingCheckRepository.java @@ -0,0 +1,19 @@ +package com.example.Piroin.project.domain.question.repository; + +import com.example.Piroin.project.domain.curriculum.entity.StudySession; +import com.example.Piroin.project.domain.question.entity.UnderstandingCheck; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface UnderstandingCheckRepository extends JpaRepository { + /* + 특정 세션의 이해도 체크 목록(생성 최신순) + 용도: 세션 페이지에서 이해도 체크 목록 표시 시 + */ + List findBySessionOrderByCreatedAtDesc(StudySession session); + + Page findBySessionOrderByCreatedAtDesc(StudySession session, Pageable pageable); +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/UnderstandingResponseRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/UnderstandingResponseRepository.java new file mode 100644 index 0000000..46d532c --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/UnderstandingResponseRepository.java @@ -0,0 +1,25 @@ +package com.example.Piroin.project.domain.question.repository; + +import com.example.Piroin.project.domain.question.entity.UnderstandingCheck; +import com.example.Piroin.project.domain.question.entity.UnderstandingResponse; +import com.example.Piroin.project.domain.question.enums.UnderstandResChoice; +import com.example.Piroin.project.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UnderstandingResponseRepository extends JpaRepository { + Optional findByCheckAndUser(UnderstandingCheck check, User user); + + /* + 유저가 특정 이해도 체크에 이미 응답했는지 여부 + 용도: 중복 응답 방지 + */ + boolean existsByCheckAndUser(UnderstandingCheck check, User user); + + /* + 특정 이해도 체크에서 특정 선택지(O 또는 X)의 응답 수 + 용도: 운영진에게 O 몇 명 / X 몇 명 표시 시 + */ + int countByCheckAndChoice(UnderstandingCheck check, UnderstandResChoice choice); +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionEventService.java b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionEventService.java new file mode 100644 index 0000000..bec66f4 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionEventService.java @@ -0,0 +1,71 @@ +package com.example.Piroin.project.domain.question.service; + +import com.example.Piroin.project.domain.question.dto.QuestionResDTO; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +@Service +public class QuestionEventService { + private static final long SSE_TIMEOUT_MILLIS = 60L * 60L * 1000L; + + // sessionId별로 현재 질문방을 보고 있는 SSE 연결들을 보관한다. + private final Map> sessionEmitters = new ConcurrentHashMap<>(); + + // 클라이언트가 질문방에 들어오면 SSE 연결을 열고 해당 세션 구독자로 등록한다. + public SseEmitter subscribe(Long sessionId) { + SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS); + sessionEmitters.computeIfAbsent(sessionId, key -> new CopyOnWriteArrayList<>()).add(emitter); + + // 페이지 이탈, 타임아웃, 네트워크 오류 시 죽은 연결이 남지 않도록 제거한다. + emitter.onCompletion(() -> removeEmitter(sessionId, emitter)); + emitter.onTimeout(() -> removeEmitter(sessionId, emitter)); + emitter.onError(error -> removeEmitter(sessionId, emitter)); + + try { + // 최초 연결 확인용 이벤트. 프론트는 이 이벤트로 구독 성공을 확인할 수 있다. + emitter.send(SseEmitter.event() + .name("connected") + .data("connected")); + } catch (IOException | IllegalStateException e) { + removeEmitter(sessionId, emitter); + emitter.completeWithError(e); + } + + return emitter; + } + + // 댓글 생성 이벤트를 같은 세션 질문방을 구독 중인 모든 클라이언트에게 전파한다. + public void publishCommentCreated(Long sessionId, QuestionResDTO.CommentCreatedEvent event) { + List emitters = sessionEmitters.getOrDefault(sessionId, List.of()); + + for (SseEmitter emitter : emitters) { + try { + emitter.send(SseEmitter.event() + .name("comment-created") + .data(event)); + } catch (IOException | IllegalStateException e) { + removeEmitter(sessionId, emitter); + emitter.completeWithError(e); + } + } + } + + // 더 이상 사용하지 않는 연결을 제거하고, 세션에 남은 연결이 없으면 세션 키도 정리한다. + private void removeEmitter(Long sessionId, SseEmitter emitter) { + List emitters = sessionEmitters.get(sessionId); + if (emitters == null) { + return; + } + + emitters.remove(emitter); + if (emitters.isEmpty()) { + sessionEmitters.remove(sessionId); + } + } +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java index d7f8c86..0d47992 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java @@ -1,4 +1,699 @@ package com.example.Piroin.project.domain.question.service; +import com.example.Piroin.project.domain.attendance.service.AttendanceService; +import com.example.Piroin.project.domain.curriculum.entity.StudySession; +import com.example.Piroin.project.domain.curriculum.repository.CurriculumRepository; +import com.example.Piroin.project.domain.question.dto.QuestionReqDTO; +import com.example.Piroin.project.domain.question.dto.QuestionResDTO; +import com.example.Piroin.project.domain.question.entity.*; +import com.example.Piroin.project.domain.question.enums.UnderstandResChoice; +import com.example.Piroin.project.domain.question.exception.QuestionException; +import com.example.Piroin.project.domain.question.repository.*; +import com.example.Piroin.project.domain.user.entity.User; +import com.example.Piroin.project.domain.user.enums.Role; +import com.example.Piroin.project.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor public class QuestionService { + private static final int UNDERSTANDING_PAGE_SIZE = 1; + private static final int POPULAR_LIKE_THRESHOLD = 5; + + private final QuestionRepository questionRepository; + private final QuestionCommentRepository questionCommentRepository; + private final QuestionLikeRepository questionLikeRepository; + private final QuestionAnonymousIdentityRepository anonymousIdentityRepository; + private final UnderstandingCheckRepository understandingCheckRepository; + private final UnderstandingResponseRepository understandingResponseRepository; + private final CurriculumRepository curriculumRepository; + private final UserRepository userRepository; + private final QuestionEventService questionEventService; + private final AttendanceService attendanceService; + + // 질문 방 조회 + @Transactional(readOnly = true) + public QuestionResDTO.QuestionRoomResponse getQuestionRoom(Long sessionId, int understandingIndex, Long userId) { + if (understandingIndex < 0) { + throw new IllegalArgumentException("이해도 조회 인덱스는 0 이상이어야 합니다."); + } + StudySession session = findSession(sessionId); + User loginUser = findLoginUser(userId); + return new QuestionResDTO.QuestionRoomResponse( + toSessionResponse(session), + getUnderstandingSlice(session, understandingIndex), + getQuestionGroups(session, loginUser) + ); + } + + @Transactional(readOnly = true) + public SseEmitter subscribeQuestionEvents(Long sessionId) { + // 존재하는 세션에 대해서만 SSE 연결을 허용한다. + findSession(sessionId); + return questionEventService.subscribe(sessionId); + } + + // 질문 상세 조회 + @Transactional(readOnly = true) + public QuestionResDTO.QuestionDetailResponse getQuestionDetail(Long questionId, Long userId) { + User loginUser = findLoginUser(userId); + Question question = findQuestion(questionId); + return toDetailResponse(question, loginUser); + } + + private QuestionResDTO.QuestionDetailResponse toDetailResponse(Question question, User loginUser) { + boolean isLiked = questionLikeRepository.existsByQuestionAndUser(question, loginUser); + boolean isMine = question.getUser().getId().equals(loginUser.getId()); + boolean isPopular = !question.getIsResolved() && question.getLikeCount() >= POPULAR_LIKE_THRESHOLD; + + List topComments = + questionCommentRepository.findByQuestionAndParentCommentIsNullAndDeletedAtIsNullOrderByCreatedAtAsc(question); + + List commentResponses = topComments.stream() + .map(comment -> toCommentResponse(question, comment, loginUser)) + .toList(); + + return new QuestionResDTO.QuestionDetailResponse( + question.getId(), "작성자", question.getContent(), question.getImageUrl(), + question.getIsResolved(), isPopular, question.getLikeCount(), isLiked, + isMine, + question.getCreatedAt(), commentResponses + ); + } + + private QuestionResDTO.CommentResponse toCommentResponse(Question question, QuestionComment comment, User loginUser) { + List replies = + questionCommentRepository.findByParentCommentAndDeletedAtIsNullOrderByCreatedAtAsc(comment); + + List replyResponses = replies.stream() + .map(reply -> new QuestionResDTO.CommentResponse( + reply.getId(), getDisplayName(question, reply.getUser()), + reply.getContent(), reply.getImageUrl(), isCommentMine(reply, loginUser), + reply.getCreatedAt(), List.of() + )) + .toList(); + + return new QuestionResDTO.CommentResponse( + comment.getId(), getDisplayName(question, comment.getUser()), + comment.getContent(), comment.getImageUrl(), isCommentMine(comment, loginUser), + comment.getCreatedAt(), replyResponses + ); + } + + private boolean isCommentMine(QuestionComment comment, User loginUser) { + return comment.getUser().getId().equals(loginUser.getId()); + } + + // 댓글 등록 + // POST /api/questions/{questionId}/comments + @Transactional + public QuestionResDTO.CommentCreateRes createComment( + Long questionId, + QuestionReqDTO.CommentReq request, + Long userId + ) { + User loginUser = findLoginUser(userId); + Question question = findQuestion(questionId); + + // 1. 대댓글 여부 확인: parentCommentId가 있으면 부모 댓글 조회 + QuestionComment parentComment = resolveParentComment(request.getParentCommentId()); + + // 2. 댓글 엔티티 생성 및 저장 + LocalDateTime now = LocalDateTime.now(); + QuestionComment comment = QuestionComment.builder() + .question(question) + .user(loginUser) + .parentComment(parentComment) // 일반 댓글이면 null, 대댓글이면 부모 댓글 + .content(request.getContent()) + .imageUrl(request.getImageUrl()) + .createdAt(now) + .updatedAt(now) + .build(); + questionCommentRepository.save(comment); + + // 수동 해결/미해결 변경은 운영진 권한이 필요하지만, + // 댓글 작성으로 인한 미해결 전환은 권한 API가 아니라 서버 내부 도메인 규칙이다. + if (question.getIsResolved()) { + question.markUnresolved(); + } + + // 4. 표시명 결정 (질문 작성자가 아닌 경우 익명 번호 부여) + String displayName = assignAnonymousIdentity(question, loginUser); + + QuestionResDTO.CommentCreateRes response = new QuestionResDTO.CommentCreateRes( + comment.getId(), question.getId(), displayName, + comment.getContent(), question.getIsResolved(), comment.getCreatedAt() + ); + + // DB 반영이 끝난 뒤 같은 질문방 구독자들이 목록 댓글 미리보기를 갱신하도록 알린다. + publishCommentCreatedEventAfterCommit(question); + + return response; + } + + // parentCommentId가 있으면 해당 댓글 조회, 없으면 null 반환 + private QuestionComment resolveParentComment(Long parentCommentId) { + if (parentCommentId == null) { + return null; + } + return questionCommentRepository.findById(parentCommentId) + .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "부모 댓글을 찾을 수 없습니다.")); + } + + /* + 익명 번호 조회 또는 신규 부여 + + - 질문 작성자 본인 → "작성자" 반환, 번호 부여 안 함 + - 이미 번호가 있는 유저 → 기존 번호 재사용 + - 처음 댓글 다는 유저 → 역할별 카운트 + 1로 새 번호 부여 후 DB 저장 + */ + private String assignAnonymousIdentity(Question question, User commenter) { + // 질문 작성자 본인이면 번호 부여 없이 "작성자" 반환 + if (commenter.getId().equals(question.getUser().getId())) { + return "작성자"; + } + + // 이미 이 질문에서 익명 번호가 있는지 확인 + return anonymousIdentityRepository + .findByQuestionAndUser(question, commenter) + .map(identity -> buildDisplayName(commenter.getRole(), identity.getAnonymousNo())) + .orElseGet(() -> { + // 처음 댓글 다는 유저 → 역할별 카운트 기반으로 새 번호 부여 + int nextNo = anonymousIdentityRepository + .countByQuestionAndUser_Role(question, commenter.getRole()) + 1; + + anonymousIdentityRepository.save(QuestionAnonymousIdentity.builder() + .question(question) + .user(commenter) + .anonymousNo(nextNo) + .createdAt(LocalDateTime.now()) + .build()); + + return buildDisplayName(commenter.getRole(), nextNo); + }); + } + + // 역할 + 번호 → 표시명 변환 + private String buildDisplayName(Role role, int anonymousNo) { + return role == Role.ADMIN ? "운영진" + anonymousNo : "익명" + anonymousNo; + } + + // getDisplayName: 상세 조회 시 기존 익명 번호 읽기 (번호 부여 없음) + private String getDisplayName(Question question, User commenter) { + if (commenter.getId().equals(question.getUser().getId())) { + return "작성자"; + } + return anonymousIdentityRepository + .findByQuestionAndUser(question, commenter) + .map(identity -> buildDisplayName(commenter.getRole(), identity.getAnonymousNo())) + .orElse(commenter.getRole() == Role.ADMIN ? "운영진" : "익명"); + } + + // 질문 등록 + @Transactional + public QuestionResDTO.CreateRes createQuestion(Long sessionId, QuestionReqDTO.CreateReq request, Long userId) { + User loginUser = findLoginUser(userId); + StudySession session = findSession(sessionId); + + Question question = Question.builder() + .session(session) + .user(loginUser) + .content(request.getContent()) + .imageUrl(request.getImageUrl()) + .isResolved(false) + .likeCount(0) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + return QuestionResDTO.CreateRes.from(questionRepository.save(question)); + } + + // 좋아요 토글 + // POST /api/questions/{questionId}/like + @Transactional + public QuestionResDTO.LikeRes toggleLike(Long questionId, Long userId) { + User loginUser = findLoginUser(userId); + Question question = findQuestion(questionId); + + // 이미 좋아요를 눌렀는지 확인 + return questionLikeRepository.findByQuestionAndUser(question, loginUser) + .map(existingLike -> { + // 이미 눌렀으면 → 취소 (삭제 + likeCount -1) + questionLikeRepository.delete(existingLike); + question.decreaseLikeCount(); + return new QuestionResDTO.LikeRes(question.getId(), question.getLikeCount(), false); + }) + .orElseGet(() -> { + // 처음 누르면 → 추가 (저장 + likeCount +1) + questionLikeRepository.save(QuestionLike.builder() + .question(question) + .user(loginUser) + .createdAt(LocalDateTime.now()) + .build()); + question.increaseLikeCount(); + return new QuestionResDTO.LikeRes(question.getId(), question.getLikeCount(), true); + }); + } + + // 질문 수정 + @Transactional + public QuestionResDTO.UpdateDeleteRes updateQuestion( + Long questionId, + QuestionReqDTO.UpdateReq request, + Long userId + ) { + User loginUser = findLoginUser(userId); + Question question = findQuestion(questionId); + validateQuestionOwner(question, loginUser); + + question.updateContent(request.getContent()); + + return new QuestionResDTO.UpdateDeleteRes( + question.getId(), question.getContent(), + question.getUpdatedAt(), question.getDeletedAt() + ); + } + + // 질문 삭제 + @Transactional + public QuestionResDTO.UpdateDeleteRes deleteQuestion(Long questionId, Long userId) { + User loginUser = findLoginUser(userId); + Question question = findQuestion(questionId); + validateQuestionOwner(question, loginUser); + + question.softDelete(); + + return new QuestionResDTO.UpdateDeleteRes( + question.getId(), question.getContent(), + question.getUpdatedAt(), question.getDeletedAt() + ); + } + + // 댓글 수정 + @Transactional + public QuestionResDTO.CommentUpdateDeleteRes updateComment( + Long commentId, + QuestionReqDTO.CommentUpdateReq request, + Long userId + ) { + User loginUser = findLoginUser(userId); + QuestionComment comment = findComment(commentId); + validateCommentOwner(comment, loginUser); + + comment.updateContent(request.getContent()); + + return new QuestionResDTO.CommentUpdateDeleteRes( + comment.getId(), comment.getContent(), + comment.getUpdatedAt(), comment.getDeletedAt() + ); + } + + // 댓글 삭제 + @Transactional + public QuestionResDTO.CommentUpdateDeleteRes deleteComment(Long commentId, Long userId) { + User loginUser = findLoginUser(userId); + QuestionComment comment = findComment(commentId); + validateCommentOwner(comment, loginUser); + + comment.softDelete(); + + return new QuestionResDTO.CommentUpdateDeleteRes( + comment.getId(), comment.getContent(), + comment.getUpdatedAt(), comment.getDeletedAt() + ); + } + + // 질문 상태 완료 전환 + // PATCH /api/questions/{questionId}/status + @Transactional + public QuestionResDTO.StatusUpdateRes updateQuestionStatus(Long questionId, Long userId) { + User loginUser = findLoginUser(userId); + // 사용자가 직접 상태를 바꾸는 수동 해결 처리는 운영진만 가능하다. + validateAdmin(loginUser); + + Question question = findQuestion(questionId); + question.markResolved(); + + return new QuestionResDTO.StatusUpdateRes( + question.getId(), question.getIsResolved(), question.getUpdatedAt() + ); + } + + // 이해도 체크 생성 + @Transactional + public QuestionResDTO.UnderstandingCheckCreateResponse createUnderstandingCheck( + Long sessionId, QuestionReqDTO.UnderstandingCheckCreateReq request, Long userId + ) { + validateUnderstandingCheckCreateRequest(request); + User loginUser = findLoginUser(userId); + validateAdmin(loginUser); + StudySession session = findSession(sessionId); + + LocalDateTime now = LocalDateTime.now(); + UnderstandingCheck check = understandingCheckRepository.save(UnderstandingCheck.builder() + .session(session) + .createdBy(loginUser) + .title(request.getContent().trim()) + .createdAt(now) + .updatedAt(now) + .build()); + + return new QuestionResDTO.UnderstandingCheckCreateResponse( + check.getId(), check.getTitle(), 0, null, 0, 0, check.getCreatedAt() + ); + } + + // 이해도 체크 응답 + @Transactional + public QuestionResDTO.UnderstandingResponseResult respondUnderstandingCheck( + Long sessionId, Long checkId, QuestionReqDTO.UnderstandingResponseReq request, Long userId + ) { + if (request == null || request.getChoice() == null) { + throw new IllegalArgumentException("이해도 응답 선택지는 필수입니다."); + } + User loginUser = findLoginUser(userId); + StudySession session = findSession(sessionId); + UnderstandingCheck check = findUnderstandingCheck(checkId); + validateCheckBelongsToSession(check, session); + + UnderstandResChoice selectedChoice = applyUnderstandingResponse(check, loginUser, request.getChoice()); + // O/X 클릭 직후 프론트가 13/29와 O/X 뱃지를 바로 갱신할 수 있도록 최신 분모도 함께 내려준다. + int attendanceCount = attendanceService.countAttendedBySession(session); + return toUnderstandingResponseResult(check, selectedChoice, attendanceCount); + } + + // 공통 헬퍼 메서드 + private User findLoginUser(Long userId) { + if (userId == null) { + throw new IllegalStateException("로그인이 필요합니다."); + } + return userRepository.findById(userId) + .orElseThrow(() -> new QuestionException(HttpStatus.UNAUTHORIZED, "로그인 사용자를 찾을 수 없습니다.")); + } + + private void validateAdmin(User loginUser) { + if (loginUser.getRole() != Role.ADMIN) { + throw new QuestionException(HttpStatus.FORBIDDEN, "관리자만 사용할 수 있는 기능입니다."); + } + } + + private void validateUnderstandingCheckCreateRequest(QuestionReqDTO.UnderstandingCheckCreateReq request) { + if (request == null || request.getContent() == null || request.getContent().isBlank()) { + throw new IllegalArgumentException("이해도 체크 내용은 필수입니다."); + } + } + + private Question findQuestion(Long questionId) { + return questionRepository.findByIdAndDeletedAtIsNull(questionId) + .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "질문을 찾을 수 없습니다.")); + } + + private StudySession findSession(Long sessionId) { + return curriculumRepository.findById(sessionId) + .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "세션을 찾을 수 없습니다.")); + } + + private UnderstandingCheck findUnderstandingCheck(Long checkId) { + return understandingCheckRepository.findById(checkId) + .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "이해도 체크를 찾을 수 없습니다.")); + } + + private void validateCheckBelongsToSession(UnderstandingCheck check, StudySession session) { + if (!check.getSession().getId().equals(session.getId())) { + throw new IllegalArgumentException("해당 세션의 이해도 체크가 아닙니다."); + } + } + + private void validateQuestionOwner(Question question, User loginUser) { + if (!question.getUser().getId().equals(loginUser.getId())) { + throw new QuestionException(HttpStatus.FORBIDDEN, "본인의 질문만 수정/삭제할 수 있습니다."); + } + } + + private void validateCommentOwner(QuestionComment comment, User loginUser) { + if (!comment.getUser().getId().equals(loginUser.getId())) { + throw new QuestionException(HttpStatus.FORBIDDEN, "본인의 댓글만 수정/삭제할 수 있습니다."); + } + } + + private QuestionComment findComment(Long commentId) { + return questionCommentRepository.findById(commentId) + .filter(c -> c.getDeletedAt() == null) + .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다.")); + } + + private UnderstandResChoice applyUnderstandingResponse( + UnderstandingCheck check, User loginUser, UnderstandResChoice requestedChoice + ) { + UnderstandingResponse response = understandingResponseRepository + .findByCheckAndUser(check, loginUser).orElse(null); + + if (response == null) { + LocalDateTime now = LocalDateTime.now(); + understandingResponseRepository.save(UnderstandingResponse.builder() + .check(check).user(loginUser).choice(requestedChoice) + .createdAt(now).updatedAt(now).build()); + return requestedChoice; + } + + if (response.hasChoice(requestedChoice)) { + understandingResponseRepository.delete(response); + // 같은 O/X 버튼을 다시 누르면 인스타 좋아요 취소처럼 응답을 삭제하고 selectedChoice는 null로 내려간다. + return null; + } + + response.changeChoice(requestedChoice); + return requestedChoice; + } + + private QuestionResDTO.UnderstandingResponseResult toUnderstandingResponseResult( + UnderstandingCheck check, UnderstandResChoice selectedChoice, Integer attendanceCount + ) { + // respondedCount는 프론트 화면의 "13/29" 중 13에 해당한다. + int understoodCount = understandingResponseRepository.countByCheckAndChoice( + check, UnderstandResChoice.UNDERSTOOD + ); + int notUnderstoodCount = understandingResponseRepository.countByCheckAndChoice( + check, UnderstandResChoice.NOT_UNDERSTOOD + ); + + return new QuestionResDTO.UnderstandingResponseResult( + check.getId(), selectedChoice, + understoodCount + notUnderstoodCount, + attendanceCount, + understoodCount, + notUnderstoodCount + ); + } + + private QuestionResDTO.SessionResponse toSessionResponse(StudySession session) { + return new QuestionResDTO.SessionResponse( + session.getId(), session.getWeek().intValue(), + session.getSessionDate().getDayOfWeek().name(), + session.getDayPart().name(), session.getSessionDate(), session.getTitle() + ); + } + + private QuestionResDTO.UnderstandingSliceResponse getUnderstandingSlice(StudySession session, int understandingIndex) { + Page understandingPage = understandingCheckRepository + .findBySessionOrderByCreatedAtDesc(session, PageRequest.of(understandingIndex, UNDERSTANDING_PAGE_SIZE)); + + int totalCount = (int) understandingPage.getTotalElements(); + if (totalCount == 0) { + return new QuestionResDTO.UnderstandingSliceResponse(null, 0, 0, false, false); + } + if (understandingPage.getContent().isEmpty()) { + throw new IllegalArgumentException("존재하지 않는 이해도 조회 인덱스입니다."); + } + + UnderstandingCheck current = understandingPage.getContent().get(0); + // attendanceCount는 프론트 화면의 "13/29" 중 29에 해당한다. + int attendanceCount = attendanceService.countAttendedBySession(session); + return new QuestionResDTO.UnderstandingSliceResponse( + toUnderstandingCheckResponse(current, attendanceCount), understandingIndex, totalCount, + understandingIndex < totalCount - 1, understandingIndex > 0 + ); + } + + private QuestionResDTO.UnderstandingCheckResponse toUnderstandingCheckResponse(UnderstandingCheck check) { + return toUnderstandingCheckResponse(check, null); + } + + private QuestionResDTO.UnderstandingCheckResponse toUnderstandingCheckResponse( + UnderstandingCheck check, Integer attendanceCount + ) { + // understoodCount/notUnderstoodCount는 오른쪽 O/X 뱃지 숫자로 그대로 사용한다. + int understoodCount = understandingResponseRepository.countByCheckAndChoice( + check, UnderstandResChoice.UNDERSTOOD + ); + int notUnderstoodCount = understandingResponseRepository.countByCheckAndChoice( + check, UnderstandResChoice.NOT_UNDERSTOOD + ); + + return new QuestionResDTO.UnderstandingCheckResponse( + check.getId(), check.getTitle(), + understoodCount + notUnderstoodCount, + attendanceCount, + understoodCount, + notUnderstoodCount, + check.getCreatedAt() + ); + } + + private QuestionResDTO.QuestionGroupsResponse getQuestionGroups(StudySession session, User loginUser) { + List questions = questionRepository.findBySessionAndDeletedAtIsNull(session); + QuestionSummaryContext summaryContext = getQuestionSummaryContext(questions); + + List popularQuestions = questions.stream() + .filter(q -> !q.getIsResolved() && q.getLikeCount() >= POPULAR_LIKE_THRESHOLD) + .sorted(Comparator.comparing(Question::getLikeCount, Comparator.reverseOrder()) + .thenComparing(Question::getCreatedAt, Comparator.reverseOrder())) + .map(q -> toQuestionSummaryResponse(q, summaryContext, loginUser)).toList(); + + List unresolvedQuestions = questions.stream() + .filter(q -> !q.getIsResolved() && q.getLikeCount() < POPULAR_LIKE_THRESHOLD) + .sorted(Comparator.comparing(Question::getCreatedAt, Comparator.reverseOrder())) + .map(q -> toQuestionSummaryResponse(q, summaryContext, loginUser)).toList(); + + List resolvedQuestions = questions.stream() + .filter(Question::getIsResolved) + .sorted(Comparator.comparing(Question::getCreatedAt, Comparator.reverseOrder())) + .map(q -> toQuestionSummaryResponse(q, summaryContext, loginUser)).toList(); + + return new QuestionResDTO.QuestionGroupsResponse(popularQuestions, unresolvedQuestions, resolvedQuestions); + } + + private QuestionResDTO.QuestionSummaryResponse toQuestionSummaryResponse ( + Question question, + QuestionSummaryContext summaryContext, + User loginUser + ) { + Long questionId = question.getId(); + boolean isLiked = questionLikeRepository.existsByQuestionAndUser(question, loginUser); + boolean isMine = question.getUser().getId().equals(loginUser.getId()); + return new QuestionResDTO.QuestionSummaryResponse( + questionId, question.getContent(), question.getImageUrl(), + question.getIsResolved(), + !question.getIsResolved() && question.getLikeCount() >= POPULAR_LIKE_THRESHOLD, + isLiked, + isMine, + question.getLikeCount(), + summaryContext.commentCounts().getOrDefault(questionId, 0), + // 목록 화면은 최상위 댓글 중 먼저 달린 3개만 미리보기로 보여준다. + summaryContext.previewComments().getOrDefault(questionId, List.of()), + question.getCreatedAt() + ); + } + + private QuestionSummaryContext getQuestionSummaryContext(List questions) { + if (questions.isEmpty()) { + return new QuestionSummaryContext(Map.of(), Map.of()); + } + + List questionIds = questions.stream() + .map(Question::getId) + .toList(); + Map questionsById = questions.stream() + .collect(Collectors.toMap(Question::getId, question -> question)); + + Map commentCounts = new HashMap<>(); + questionCommentRepository.countByQuestionIds(questionIds) + .forEach(row -> commentCounts.put(row.getQuestionId(), Math.toIntExact(row.getCommentCount()))); + + Map> previewComments = new HashMap<>(); + questionCommentRepository.findPreviewCommentsByQuestionIds(questionIds) + .forEach(row -> { + Question question = questionsById.get(row.getQuestionId()); + if (question == null) { + return; + } + previewComments.computeIfAbsent(row.getQuestionId(), key -> new ArrayList<>()) + .add(toPreviewCommentResponse(question, row)); + }); + + return new QuestionSummaryContext(commentCounts, previewComments); + } + + private QuestionResDTO.PreviewCommentResponse toPreviewCommentResponse( + Question question, + QuestionCommentRepository.PreviewCommentRow row + ) { + return new QuestionResDTO.PreviewCommentResponse( + row.getCommentId(), + getPreviewDisplayName(question, row), + row.getContent(), + hasPreviewImage(row), + row.getCreatedAt() + ); + } + + private boolean hasPreviewImage(QuestionCommentRepository.PreviewCommentRow row) { + return row.getImageUrl() != null && !row.getImageUrl().isBlank(); + } + + private String getPreviewDisplayName(Question question, QuestionCommentRepository.PreviewCommentRow row) { + if (row.getUserId().equals(question.getUser().getId())) { + return "작성자"; + } + if (row.getAnonymousNo() == null) { + Role role = Role.valueOf(row.getUserRole()); + return role == Role.ADMIN ? "운영진" : "익명"; + } + return buildDisplayName(Role.valueOf(row.getUserRole()), row.getAnonymousNo()); + } + + private void publishCommentCreatedEventAfterCommit(Question question) { + Long sessionId = question.getSession().getId(); + Long questionId = question.getId(); + QuestionSummaryContext summaryContext = getQuestionSummaryContext(List.of(question)); + + // 프론트가 전체 목록을 다시 조회하지 않고 해당 질문만 갱신할 수 있는 최소 데이터만 보낸다. + QuestionResDTO.CommentCreatedEvent event = new QuestionResDTO.CommentCreatedEvent( + "COMMENT_CREATED", + sessionId, + questionId, + question.getIsResolved(), + summaryContext.commentCounts().getOrDefault(questionId, 0), + summaryContext.previewComments().getOrDefault(questionId, List.of()) + ); + + publishAfterCommit(() -> questionEventService.publishCommentCreated(sessionId, event)); + } + + // 롤백된 댓글이 실시간 화면에 먼저 보이지 않도록, 활성화된 트랜잭션 동기화 안에서만 커밋 이후 이벤트를 발행한다. + private void publishAfterCommit(Runnable action) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + throw new IllegalStateException("publishAfterCommit must be called within an active transaction synchronization"); + } + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + action.run(); + } + }); + } + + private record QuestionSummaryContext( + Map commentCounts, + Map> previewComments + ) { + } } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/controller/AdminUserController.java b/backend/src/main/java/com/example/Piroin/project/domain/user/controller/AdminUserController.java new file mode 100644 index 0000000..5d2dda7 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/controller/AdminUserController.java @@ -0,0 +1,80 @@ +package com.example.Piroin.project.domain.user.controller; + +import com.example.Piroin.project.domain.assignment.service.AssignmentService; +import com.example.Piroin.project.domain.attendance.dto.ApiResponse; +import com.example.Piroin.project.domain.user.dto.*; +import com.example.Piroin.project.domain.user.service.AdminUserService; +import com.example.Piroin.project.domain.user.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@Tag(name = "관리자용 user 정보", description = "관지라용 user 관련 API") +@RequestMapping("/api/admin") +public class AdminUserController { + + private final AdminUserService adminUserService; + private final AssignmentService assignmentService; + + // 1. 전체 부원 목록 조회 + @Operation(summary = "전체 부원 이름 목록 조회", description = "운영진이 전체 부원의 이름 목록을 조회합니다.") + @GetMapping("/studentlist") + public List getStudentList() { + return adminUserService.getStudentList(); + } + + + @Operation(summary = "부원 이름 검색", description = "운영진이 부원의 이름을 검색합니다(포함된 글자로 검색도 가능)") + @GetMapping("/studentlist/search") + public List searchStudents( + @RequestParam String name + ) { + return adminUserService.searchStudents(name); + } + + // 2. 특정 부원의 과제/출석 정보 조회 + @Operation( + summary = "특정 학생 주간 과제/출석 조회", + description = "운영진이 특정 학생의 주차별 과제 및 출석 상태를 조회합니다." + ) + @GetMapping("/admin/student/{userId}/status/{week}") + public ApiResponse getStudentWeeklyStatus( + @PathVariable Long userId, + @PathVariable Long week + ) { + + return ApiResponse.success( + assignmentService.getStudentWeeklyStatus( + userId, + week + ) + ); + } + + // 3. 특정 부원의 과제/출석 상태 수정 (운영진) + @Operation( + summary = "특정 학생 주간 과제/출석 수정", + description = "운영진이 특정 학생의 주차별 과제 및 출석 상태를 수정합니다." + ) + @PatchMapping("/users/{userId}/weeks/{week}") + public UpdateStudentStatusResponse updateStudentWeekStatus( + @PathVariable Long userId, + @PathVariable Long week, + @RequestBody UpdateStudentStatusRequest request + ) { + + return adminUserService.updateStudentWeekStatus( + userId, + week, + request + ); + } + + + +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/controller/UserController.java b/backend/src/main/java/com/example/Piroin/project/domain/user/controller/UserController.java index 2b84265..14422c3 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/user/controller/UserController.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/controller/UserController.java @@ -1,4 +1,36 @@ package com.example.Piroin.project.domain.user.controller; +import com.example.Piroin.project.domain.user.dto.LoginRequest; +import com.example.Piroin.project.domain.user.dto.LoginResponse; +import com.example.Piroin.project.domain.user.entity.User; +import com.example.Piroin.project.domain.user.service.UserService; +import com.example.Piroin.project.global.jwt.JwtUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "유저 인증", description = "로그인 / 로그아웃 API") +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor public class UserController { -} + + private final UserService userService; + private final JwtUtil jwtUtil; + + @Operation(summary = "로그인", description = "이름과 비밀번호로 로그인하고 JWT 토큰을 발급합니다.") + @PostMapping("/auth/login") + public ResponseEntity login(@RequestBody @Valid LoginRequest request) { + User user = userService.login(request.getName(), request.getPassword()); + String token = jwtUtil.generateToken(user.getId(), user.getRole().name()); + return ResponseEntity.ok(new LoginResponse(user, token)); + } + + +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/dto/AssignmentStatusResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/AssignmentStatusResponse.java new file mode 100644 index 0000000..22d6300 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/AssignmentStatusResponse.java @@ -0,0 +1,15 @@ +package com.example.Piroin.project.domain.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class AssignmentStatusResponse { + private Integer assignmentItemId; + private Integer assignmentId; + private String title; + private String submitted; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/dto/AttendanceStatusResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/AttendanceStatusResponse.java new file mode 100644 index 0000000..31c6f04 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/AttendanceStatusResponse.java @@ -0,0 +1,19 @@ +package com.example.Piroin.project.domain.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class AttendanceStatusResponse { + private Integer attendanceId; + + private Integer attendanceCodeId; + + private String attendanceOrder; + + private Boolean attended; + +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/dto/DayStatusResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/DayStatusResponse.java new file mode 100644 index 0000000..1d96a62 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/DayStatusResponse.java @@ -0,0 +1,22 @@ +package com.example.Piroin.project.domain.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@AllArgsConstructor +@Builder +public class DayStatusResponse { + + private String day; + + private LocalDate sessionDate; + + private List assignments; + + private List attendances; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/dto/LoginRequest.java b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/LoginRequest.java new file mode 100644 index 0000000..436a708 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/LoginRequest.java @@ -0,0 +1,19 @@ +package com.example.Piroin.project.domain.user.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class LoginRequest { + + @Schema(description = "사용자 이름", example = "최관리") + @NotBlank(message = "이름을 입력해주세요.") + private String name; + + @Schema(description = "비밀번호", example = "1234") + @NotBlank(message = "비밀번호를 입력해주세요.") + private String password; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/dto/LoginResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/LoginResponse.java new file mode 100644 index 0000000..ae3d4fe --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/LoginResponse.java @@ -0,0 +1,28 @@ +package com.example.Piroin.project.domain.user.dto; + +import com.example.Piroin.project.domain.user.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Getter +public class LoginResponse { + + @Schema(description = "유저 고유 ID", example = "1") + private Long id; + + @Schema(description = "유저 이름", example = "김피로") + private String name; + + @Schema(description = "유저 권한", example = "MEMBER") + private String role; + + @Schema(description = "JWT 액세스 토큰") + private String token; + + public LoginResponse(User user, String token) { + this.id = user.getId(); + this.name = user.getName(); + this.role = user.getRole().name(); + this.token = token; + } +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/dto/StudentAssignmentResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/StudentAssignmentResponse.java new file mode 100644 index 0000000..58f6d32 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/StudentAssignmentResponse.java @@ -0,0 +1,18 @@ +package com.example.Piroin.project.domain.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; + +// 학생이 본인의 과제 조회 +@Builder +@AllArgsConstructor +public class StudentAssignmentResponse { + + private Integer assignmentId; + + private String title; + + private String day; + + private String submitted; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/dto/StudentAttendanceResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/StudentAttendanceResponse.java new file mode 100644 index 0000000..61c6dfd --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/StudentAttendanceResponse.java @@ -0,0 +1,17 @@ +package com.example.Piroin.project.domain.user.dto; + + +import lombok.AllArgsConstructor; +import lombok.Builder; + +@Builder +@AllArgsConstructor +public class StudentAttendanceResponse { + private Integer attendanceId; + + private String day; + + private String attendanceOrder; + + private Boolean attended; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/dto/StudentListResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/StudentListResponse.java new file mode 100644 index 0000000..04c9ad6 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/StudentListResponse.java @@ -0,0 +1,12 @@ +package com.example.Piroin.project.domain.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class StudentListResponse { + private Long userId; + + private String name; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/dto/StudentStatusResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/StudentStatusResponse.java new file mode 100644 index 0000000..860d8a8 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/StudentStatusResponse.java @@ -0,0 +1,20 @@ +package com.example.Piroin.project.domain.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; + +import java.util.List; + + +@Builder +@AllArgsConstructor +public class StudentStatusResponse { + + private String week; + + private String studentName; + + private List assignments; + + private List attendances; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/dto/StudentWeeklyStatusResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/StudentWeeklyStatusResponse.java new file mode 100644 index 0000000..65a7711 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/StudentWeeklyStatusResponse.java @@ -0,0 +1,17 @@ +package com.example.Piroin.project.domain.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor +@Builder +public class StudentWeeklyStatusResponse { + + private Long week; + + private List days; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/dto/UpdateStudentStatusRequest.java b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/UpdateStudentStatusRequest.java new file mode 100644 index 0000000..08841a0 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/UpdateStudentStatusRequest.java @@ -0,0 +1,30 @@ +package com.example.Piroin.project.domain.user.dto; + +import com.example.Piroin.project.domain.assignment.enums.AssignmentStatus; +import lombok.Getter; + +import java.util.List; + +@Getter +public class UpdateStudentStatusRequest { + + private List assignments; + + private List attendances; + + @Getter + public static class AssignmentStatusRequest { + + private Integer assignmentItemId; + + private AssignmentStatus submitted; + } + + @Getter + public static class AttendanceStatusRequest { + + private Integer attendanceId; + + private Boolean status; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/dto/UpdateStudentStatusResponse.java b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/UpdateStudentStatusResponse.java new file mode 100644 index 0000000..f626a12 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/UpdateStudentStatusResponse.java @@ -0,0 +1,15 @@ +package com.example.Piroin.project.domain.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UpdateStudentStatusResponse { + + private Long userId; + + private Long week; + + private String message; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/dto/UserReqDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/UserReqDTO.java deleted file mode 100644 index 9bf3758..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/user/dto/UserReqDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.user.dto; - -public class UserReqDTO { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/dto/UserResDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/user/dto/UserResDTO.java deleted file mode 100644 index 2a420c9..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/user/dto/UserResDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.user.dto; - -public class UserResDTO { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/entity/User.java b/backend/src/main/java/com/example/Piroin/project/domain/user/entity/User.java new file mode 100644 index 0000000..376d6ea --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/entity/User.java @@ -0,0 +1,35 @@ +package com.example.Piroin.project.domain.user.entity; + +import com.example.Piroin.project.domain.user.enums.Role; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String password; + + @Column(nullable = false, length = 100) + private String name; + + private String email; + private String phone; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role; + + private Integer generation; +} + + diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/entity/user.java b/backend/src/main/java/com/example/Piroin/project/domain/user/entity/user.java deleted file mode 100644 index 47fa826..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/user/entity/user.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.user.entity; - -public class user { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/enums/Role.java b/backend/src/main/java/com/example/Piroin/project/domain/user/enums/Role.java index dd22f30..782e151 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/user/enums/Role.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/enums/Role.java @@ -1,4 +1,6 @@ package com.example.Piroin.project.domain.user.enums; public enum Role { + ADMIN, + MEMBER } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/exception/InvalidLoginException.java b/backend/src/main/java/com/example/Piroin/project/domain/user/exception/InvalidLoginException.java new file mode 100644 index 0000000..5090e52 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/exception/InvalidLoginException.java @@ -0,0 +1,7 @@ +package com.example.Piroin.project.domain.user.exception; + +public class InvalidLoginException extends RuntimeException{ + public InvalidLoginException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/exception/code/UserErrorCode.java b/backend/src/main/java/com/example/Piroin/project/domain/user/exception/code/UserErrorCode.java index dcc8ae4..f34ebe6 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/user/exception/code/UserErrorCode.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/exception/code/UserErrorCode.java @@ -1,4 +1,17 @@ package com.example.Piroin.project.domain.user.exception.code; -public enum UserErrorCode { +import com.example.Piroin.project.global.response.code.BaseCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum UserErrorCode implements BaseCode { + INVALID_LOGIN(HttpStatus.UNAUTHORIZED, "USER401", "로그인에 실패했습니다."), + BAD_REQUEST(HttpStatus.BAD_REQUEST, "USER400", "잘못된 사용자 요청입니다."); + + private final HttpStatus status; + private final String code; + private final String message; } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/filter/SessionCheckFilter.java b/backend/src/main/java/com/example/Piroin/project/domain/user/filter/SessionCheckFilter.java new file mode 100644 index 0000000..a80b338 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/filter/SessionCheckFilter.java @@ -0,0 +1,41 @@ +package com.example.Piroin.project.domain.user.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +public class SessionCheckFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + String path = request.getRequestURI(); + String method = request.getMethod(); + + // CORS preflight 요청(OPTIONS) 또는 로그인/로그아웃 요청은 세션 체크 제외 + if ("OPTIONS".equals(method) || path.startsWith("/api/login") || path.startsWith("/api/logout")) { + filterChain.doFilter(request, response); // 다음 필터나 컨트롤러로 넘기는 명령어 + return; // 세션 검사 안함 + } + + HttpSession session = request.getSession(false); // 세션이 없으면 새로 만들지 않고 null을 리턴 (true : 새로 생성) + + if (session == null || session.getAttribute("loginUser") == null) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 설정 + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"success\":false,\"message\":\"세션이 만료되었습니다.\",\"data\":null}"); + return; + } + + filterChain.doFilter(request, response); + + } +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/repository/UserRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/user/repository/UserRepository.java index 9d92e8e..6b755cb 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/user/repository/UserRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/repository/UserRepository.java @@ -1,4 +1,20 @@ package com.example.Piroin.project.domain.user.repository; -public interface UserRepository { +import com.example.Piroin.project.domain.user.enums.Role; +import com.example.Piroin.project.domain.user.entity.User; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByName(String name); + + List findByRole(Role role); + + List findAll(); + + // 학생 이름으로 검색기능 + List findByRoleAndNameContaining(Role role, String name); } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/service/AdminUserService.java b/backend/src/main/java/com/example/Piroin/project/domain/user/service/AdminUserService.java new file mode 100644 index 0000000..4e923a8 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/service/AdminUserService.java @@ -0,0 +1,175 @@ +package com.example.Piroin.project.domain.user.service; + +import com.example.Piroin.project.domain.assignment.entity.Assignment; +import com.example.Piroin.project.domain.assignment.entity.AssignmentItem; +import com.example.Piroin.project.domain.assignment.enums.AssignmentStatus; +import com.example.Piroin.project.domain.assignment.repository.AssignmentItemRepository; +import com.example.Piroin.project.domain.assignment.repository.AssignmentRepository; +import com.example.Piroin.project.domain.attendance.entity.Attendance; +import com.example.Piroin.project.domain.attendance.repository.AttendanceCodeRepository; +import com.example.Piroin.project.domain.attendance.repository.AttendanceRepository; +import com.example.Piroin.project.domain.curriculum.repository.CurriculumRepository; +import com.example.Piroin.project.domain.deposit.entity.Deposit; +import com.example.Piroin.project.domain.deposit.repository.DepositRepository; +import com.example.Piroin.project.domain.user.dto.*; +import com.example.Piroin.project.domain.user.entity.User; +import com.example.Piroin.project.domain.user.enums.Role; +import com.example.Piroin.project.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import com.example.Piroin.project.domain.user.dto.UpdateStudentStatusRequest; +import com.example.Piroin.project.domain.user.dto.UpdateStudentStatusResponse; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AdminUserService { + + private final UserRepository userRepository; + private final UserService userService; + private final AssignmentItemRepository assignmentItemRepository; + private final AttendanceRepository attendanceRepository; + private final DepositRepository depositRepository; + private final CurriculumRepository curriculumRepository; + private final AssignmentRepository assignmentRepository; + private final AttendanceCodeRepository attendanceCodeRepository; + + // 1. 전체 부원 목록 조회 + public List getStudentList() { + + List students = userRepository.findByRole(Role.MEMBER); + + return students.stream() + .map(user -> new StudentListResponse( + user.getId(), + user.getName() + )) + .toList(); + } + + // 2. 부원 이름 검색 + public List searchStudents(String name) { + + List students = + userRepository.findByRoleAndNameContaining(Role.MEMBER, name); + + return students.stream() + .map(user -> new StudentListResponse( + user.getId(), + user.getName() + )) + .toList(); + } + + // 3. 특정 부원의 과제/출석 상태 수정 (운영진) + @Transactional + public UpdateStudentStatusResponse updateStudentWeekStatus( + Long userId, + Long week, + UpdateStudentStatusRequest request + ) { + + User user = userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("사용자가 존재하지 않습니다.")); + + if (request.getAssignments() != null) { + for (UpdateStudentStatusRequest.AssignmentStatusRequest dto + : request.getAssignments()) { + + // 해당 assignmentItem 존재하지 않을 때 + AssignmentItem assignmentItem = + assignmentItemRepository.findById(dto.getAssignmentItemId()) + .orElseThrow(() -> + new RuntimeException("과제 정보가 존재하지 않습니다.") + ); + + // assignmentItem가 userId의 과제가 아닐 경우 + if (!assignmentItem.getUser().getId().equals(userId)) { + throw new RuntimeException("해당 유저의 과제가 아닙니다."); + } + + if (dto.getSubmitted() != null) { + assignmentItem.updateSubmitted(dto.getSubmitted()); + } + } + } + + if (request.getAttendances() != null) { + for (UpdateStudentStatusRequest.AttendanceStatusRequest dto + : request.getAttendances()) { + + Attendance attendance = + attendanceRepository.findById(dto.getAttendanceId()) + .orElseThrow(() -> + new RuntimeException("출석 정보가 존재하지 않습니다.") + ); + + // assignmentId가 userId의 것이 아닐 때 + if (!attendance.getUser().getId().equals(userId)) { + throw new RuntimeException("해당 유저의 출석이 아닙니다."); + } + + if (dto.getStatus() != null) { + attendance.updateStatus(dto.getStatus()); + } + } + } + + recalculateDeposit(userId); + + return new UpdateStudentStatusResponse( + userId, + week, + "부원의 과제/출석 상태가 수정되었습니다." + ); + } + + + // 4. 보증금 재계산 메소드(출석 & 과제 공통) + private void recalculateDeposit(Long userId) { + + Deposit deposit = depositRepository.findByUserId(userId) + .orElseThrow(() -> new RuntimeException("보증금 정보가 존재하지 않습니다.")); + + List assignmentItems = + assignmentItemRepository.findByUserId(userId); + + int assignmentPenalty = assignmentItems.stream() + .mapToInt(item -> calculateAssignmentPenalty(item.getSubmitted())) + .sum(); + + List attendances = + attendanceRepository.findByUserId(userId); + + int attendancePenalty = attendances.stream() + .mapToInt(attendance -> attendance.getStatus() ? 0 : 10_000) + .sum(); + + deposit.updateDepositAmount( + assignmentPenalty, + attendancePenalty + ); + } + + // 5. 과제에 대한 보증금 계산 로직 + private int calculateAssignmentPenalty(AssignmentStatus status) { + + return switch (status) { + + case SUCCESS, PENDING -> 0; + + case INSUFFICIENT_MINOR -> 10_000; + + case INSUFFICIENT_MAJOR -> 20_000; + + case FAILURE -> 30_000; + }; + } + + // 6. 출석에 대한 보증금 계산 로직 + // (AttendanceService에 있음!!) + +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/service/UserService.java b/backend/src/main/java/com/example/Piroin/project/domain/user/service/UserService.java index 8f6c80a..258539f 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/user/service/UserService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/service/UserService.java @@ -1,4 +1,29 @@ package com.example.Piroin.project.domain.user.service; +import com.example.Piroin.project.domain.user.entity.User; +import com.example.Piroin.project.domain.user.exception.InvalidLoginException; +import com.example.Piroin.project.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor public class UserService { + + private final UserRepository userRepository; + + // 로그인 + public User login(String name, String password) { + User user = userRepository.findByName(name) + .orElseThrow(() -> new InvalidLoginException("해당 사용자가 존재하지 않습니다.")); + + if (!user.getPassword().equals(password)) { + throw new InvalidLoginException("비밀번호가 일치하지 않습니다."); + } + + return user; + } + + + // 부원 목록 조회 } diff --git a/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java b/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java new file mode 100644 index 0000000..95e7579 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java @@ -0,0 +1,68 @@ +package com.example.Piroin.project.global.config; + +import com.example.Piroin.project.global.jwt.JwtAuthenticationEntryPoint; +import com.example.Piroin.project.global.jwt.JwtAuthenticationFilter; +import com.example.Piroin.project.global.jwt.JwtUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpMethod; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtUtil jwtUtil; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + + // 로그인 페이지는 로그인 안 된 상태에서 접근 가능 + .requestMatchers("/api/auth/login").permitAll() + + // curriculum: GET은 로그인한 누구나, POST/PATCH/DELETE는 ADMIN만 -> 이중 보안 느낌 + .requestMatchers(HttpMethod.GET, "/api/curriculums").authenticated() + .requestMatchers(HttpMethod.POST, "/api/curriculums").hasRole("ADMIN") + .requestMatchers(HttpMethod.PATCH, "/api/curriculums/{sessionDate}").hasRole("ADMIN") + .requestMatchers(HttpMethod.DELETE, "/api/curriculums/{sessionDate}").hasRole("ADMIN") + + // understanding check: 생성은 ADMIN만 가능 + .requestMatchers(HttpMethod.POST, "/api/sessions/{sessionId}/understanding-checks").hasRole("ADMIN") + + // Swagger + .requestMatchers( + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-ui.html" + ).permitAll() + + // Actuator health check + .requestMatchers("/actuator/health").permitAll() + + // 다른 도메인 권한 설정 필요 시 위 패턴 참고해서 추가 + // 단, 추가하지 않아도 무방함 + // 이유 1. anyRequest().authenticated()로 비로그인 접근 차단 + // 이유 2. 프론트에서 ADMIN 전용 버튼/기능을 UI 단에서 숨김 처리 + .anyRequest().authenticated() + + ) + .addFilterBefore(new JwtAuthenticationFilter(jwtUtil), + UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(ex -> ex.authenticationEntryPoint(jwtAuthenticationEntryPoint)); + + return http.build(); + } +} diff --git a/backend/src/main/java/com/example/Piroin/project/global/config/SwaggerConfig.java b/backend/src/main/java/com/example/Piroin/project/global/config/SwaggerConfig.java index 9000653..e79285d 100644 --- a/backend/src/main/java/com/example/Piroin/project/global/config/SwaggerConfig.java +++ b/backend/src/main/java/com/example/Piroin/project/global/config/SwaggerConfig.java @@ -1,20 +1,35 @@ package com.example.Piroin.project.global.config; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; 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.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; @Configuration public class SwaggerConfig { @Bean public OpenAPI openAPI() { + + String jwtSchemeName = "JWT TOKEN"; + + SecurityRequirement securityRequirement = new SecurityRequirement() + .addList(jwtSchemeName); + + SecurityScheme securityScheme = new SecurityScheme() + .name(jwtSchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"); + return new OpenAPI() .info(new Info() .title("Piro Project API") .description("API 명세서") - .version("1.0.0")); + .version("1.0.0")) + .addSecurityItem(securityRequirement) + .schemaRequirement(jwtSchemeName, securityScheme); } -} +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/global/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/example/Piroin/project/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..9119f33 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,51 @@ +package com.example.Piroin.project.global.exception; + +import com.example.Piroin.project.domain.curriculum.exception.CurriculumException; +import com.example.Piroin.project.domain.question.exception.QuestionException; +import com.example.Piroin.project.domain.user.exception.InvalidLoginException; +import com.example.Piroin.project.domain.user.exception.code.UserErrorCode; +import com.example.Piroin.project.global.response.ApiResponse; +import com.example.Piroin.project.global.response.ResponseUtil; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/* +전역 예외 처리 클래스 +서비스에서 예외가 발생하면 컨트롤러까지 올라오고, +이 클래스가 자동으로 받아서 HTTP 응답으로 바꿔줌 +*/ +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(QuestionException.class) + public ResponseEntity> handleQuestionException(QuestionException e) { + return ResponseUtil.failure(e.getStatus(), "QUESTION_ERROR", e.getMessage()); + } + + @ExceptionHandler(CurriculumException.class) + public ResponseEntity> handleCurriculumException(CurriculumException e) { + return ResponseUtil.failure(e.getStatus(), "CURRICULUM_ERROR", e.getMessage()); + } + + @ExceptionHandler(InvalidLoginException.class) + public ResponseEntity> handleInvalidLoginException(InvalidLoginException e) { + return ResponseUtil.failure(UserErrorCode.INVALID_LOGIN, e.getMessage()); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException e) { + return ResponseUtil.failure(HttpStatus.BAD_REQUEST, "BAD_REQUEST", e.getMessage()); + } + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity> handleIllegalStateException(IllegalStateException e) { + return ResponseUtil.failure(HttpStatus.BAD_REQUEST, "BAD_REQUEST", e.getMessage()); + } + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity> handleRuntimeException(RuntimeException e) { + return ResponseUtil.failure(HttpStatus.BAD_REQUEST, "RUNTIME_ERROR", e.getMessage()); + } +} diff --git a/backend/src/main/java/com/example/Piroin/project/global/jwt/JwtAuthenticationEntryPoint.java b/backend/src/main/java/com/example/Piroin/project/global/jwt/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..8222c78 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/global/jwt/JwtAuthenticationEntryPoint.java @@ -0,0 +1,25 @@ +package com.example.Piroin.project.global.jwt; + +import com.example.Piroin.project.global.response.ApiResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.onFailure(JwtErrorCode.TOKEN_MISSING))); + } +} diff --git a/backend/src/main/java/com/example/Piroin/project/global/jwt/JwtAuthenticationFilter.java b/backend/src/main/java/com/example/Piroin/project/global/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..ad881d2 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/global/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,66 @@ +package com.example.Piroin.project.global.jwt; + +import com.example.Piroin.project.global.response.ApiResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String header = request.getHeader("Authorization"); + + if (header == null || !header.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String token = header.substring(7); + + try { + jwtUtil.validateToken(token); + } catch (ExpiredJwtException e) { + sendErrorResponse(response, JwtErrorCode.TOKEN_EXPIRED); + return; + } catch (JwtException e) { + sendErrorResponse(response, JwtErrorCode.TOKEN_INVALID); + return; + } + + Long userId = jwtUtil.getUserId(token); + String role = jwtUtil.getRole(token); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userId, null, List.of(new SimpleGrantedAuthority("ROLE_" + role)) + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + + filterChain.doFilter(request, response); + } + + private void sendErrorResponse(HttpServletResponse response, JwtErrorCode errorCode) throws IOException { + response.setStatus(errorCode.getStatus().value()); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.onFailure(errorCode))); + } +} diff --git a/backend/src/main/java/com/example/Piroin/project/global/jwt/JwtErrorCode.java b/backend/src/main/java/com/example/Piroin/project/global/jwt/JwtErrorCode.java new file mode 100644 index 0000000..b168615 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/global/jwt/JwtErrorCode.java @@ -0,0 +1,18 @@ +package com.example.Piroin.project.global.jwt; + +import com.example.Piroin.project.global.response.code.BaseCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum JwtErrorCode implements BaseCode { + TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "JWT4011", "만료된 토큰입니다."), + TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "JWT4012", "유효하지 않은 토큰입니다."), + TOKEN_MISSING(HttpStatus.UNAUTHORIZED, "JWT4013", "토큰이 없습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/backend/src/main/java/com/example/Piroin/project/global/jwt/JwtUtil.java b/backend/src/main/java/com/example/Piroin/project/global/jwt/JwtUtil.java new file mode 100644 index 0000000..280accd --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/global/jwt/JwtUtil.java @@ -0,0 +1,62 @@ +package com.example.Piroin.project.global.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Component +public class JwtUtil { + + private final SecretKey secretKey; + private final long expiration; + + public JwtUtil(@Value("${jwt.secret}") String secret, + @Value("${jwt.expiration}") long expiration) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.expiration = expiration; + } + + public String generateToken(Long userId, String role) { + return Jwts.builder() + .subject(String.valueOf(userId)) + .claim("role", role) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(secretKey) + .compact(); + } + + public Long getUserId(String token) { + return Long.parseLong(getClaims(token).getSubject()); + } + + public String getRole(String token) { + return getClaims(token).get("role", String.class); + } + + public void validateToken(String token) { + try { + getClaims(token); + } catch (ExpiredJwtException e) { + throw new ExpiredJwtException(null, null, "만료된 토큰입니다."); + } catch (Exception e) { + throw new JwtException("유효하지 않은 토큰입니다."); + } + } + + private Claims getClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/backend/src/main/java/com/example/Piroin/project/global/response/ApiResponse.java b/backend/src/main/java/com/example/Piroin/project/global/response/ApiResponse.java new file mode 100644 index 0000000..f1f8242 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/global/response/ApiResponse.java @@ -0,0 +1,22 @@ +package com.example.Piroin.project.global.response; + +import com.example.Piroin.project.global.response.code.BaseCode; + +public record ApiResponse( + Boolean isSuccess, + String code, + String message, + T result +) { + public static ApiResponse onSuccess(BaseCode code, T result) { + return new ApiResponse<>(true, code.getCode(), code.getMessage(), result); + } + + public static ApiResponse onFailure(BaseCode code) { + return new ApiResponse<>(false, code.getCode(), code.getMessage(), null); + } + + public static ApiResponse onFailure(BaseCode code, String message) { + return new ApiResponse<>(false, code.getCode(), message, null); + } +} diff --git a/backend/src/main/java/com/example/Piroin/project/global/response/ResponseUtil.java b/backend/src/main/java/com/example/Piroin/project/global/response/ResponseUtil.java new file mode 100644 index 0000000..1a2bcaa --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/global/response/ResponseUtil.java @@ -0,0 +1,34 @@ +package com.example.Piroin.project.global.response; + +import com.example.Piroin.project.global.response.code.BaseCode; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +public final class ResponseUtil { + private ResponseUtil() { + } + + public static ResponseEntity> success(BaseCode code, T result) { + return ResponseEntity + .status(code.getStatus()) + .body(ApiResponse.onSuccess(code, result)); + } + + public static ResponseEntity> failure(BaseCode code) { + return ResponseEntity + .status(code.getStatus()) + .body(ApiResponse.onFailure(code)); + } + + public static ResponseEntity> failure(BaseCode code, String message) { + return ResponseEntity + .status(code.getStatus()) + .body(ApiResponse.onFailure(code, message)); + } + + public static ResponseEntity> failure(HttpStatus status, String code, String message) { + return ResponseEntity + .status(status) + .body(new ApiResponse<>(false, code, message, null)); + } +} diff --git a/backend/src/main/java/com/example/Piroin/project/global/response/code/BaseCode.java b/backend/src/main/java/com/example/Piroin/project/global/response/code/BaseCode.java new file mode 100644 index 0000000..cdb7ba0 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/global/response/code/BaseCode.java @@ -0,0 +1,11 @@ +package com.example.Piroin.project.global.response.code; + +import org.springframework.http.HttpStatus; + +public interface BaseCode { + HttpStatus getStatus(); + + String getCode(); + + String getMessage(); +} diff --git a/backend/src/main/java/com/example/Piroin/project/global/util/SecurityUtil.java b/backend/src/main/java/com/example/Piroin/project/global/util/SecurityUtil.java new file mode 100644 index 0000000..baffc2c --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/global/util/SecurityUtil.java @@ -0,0 +1,14 @@ +package com.example.Piroin.project.global.util; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +public class SecurityUtil { + + private SecurityUtil() {} + + public static Long getCurrentUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return (Long) authentication.getPrincipal(); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 505405c..11c99f9 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,17 +1,34 @@ spring: + config: + import: optional:file:.env[.properties] datasource: - url: ${DB_URL} - username: ${DB_USER} - password: ${DB_PASSWORD} + url: jdbc:postgresql://${RDS_ENDPOINT}:5432/${RDS_DB_NAME} + username: ${RDS_USERNAME} + password: ${RDS_PASSWORD} driver-class-name: org.postgresql.Driver + flyway: +# repair-on-migrate: true + enabled: true + jpa: hibernate: - # create: 실행할 때마다 테이블을 새로 만듦 (연습용) - # update: 변경된 부분만 수정해서 반영 (개발용 권장) - ddl-auto: update - show-sql: true # 콘솔에 SQL 문이 찍히게 설정 + ddl-auto: validate + show-sql: true properties: hibernate: format_sql: true - dialect: org.hibernate.dialect.PostgreSQLDialect + packagesToScan: com.example.Piroin.project.domain + +jwt: + secret: ${JWT_SECRET} + expiration: ${JWT_EXPIRATION} + +management: + endpoints: + web: + exposure: + include: health + +file: + upload-dir: uploads/ diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..00f9043 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,151 @@ +-- 1. 테이블 생성 (기본키 포함) + +CREATE TABLE users ( + id BIGSERIAL NOT NULL, + password VARCHAR(100) NOT NULL, + name VARCHAR(100) NOT NULL, + email VARCHAR(255) NULL, + phone VARCHAR(50) NULL, + role VARCHAR(20) NOT NULL DEFAULT 'MEMBER', -- ENUM 대신 VARCHAR + CHECK 제약 조건 활용 + generation INT NULL, + CONSTRAINT PK_USERS PRIMARY KEY (id), + CONSTRAINT CHK_USERS_ROLE CHECK (role IN ('ADMIN', 'MEMBER')) +); + +CREATE TABLE study_session ( + id BIGSERIAL NOT NULL, + created_by BIGINT NOT NULL, + generation INT NULL, + week BIGINT NOT NULL, + session_date DATE NOT NULL, + day_part VARCHAR(10) NOT NULL, + title VARCHAR(255) NOT NULL, + host_name VARCHAR(100) NOT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'BEFORE_SESSION', + description VARCHAR(1000) NULL, + session_material_name VARCHAR(255) NULL, + session_material_url VARCHAR(1000) NULL, + assignment_name VARCHAR(255) NULL, + assignment_url VARCHAR(1000) NULL, + recording_url VARCHAR(1000) NULL, + recording_password VARCHAR(60) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT PK_STUDY_SESSION PRIMARY KEY (id), + CONSTRAINT CHK_STUDY_SESSION_DAY_PART CHECK (day_part IN ('AM', 'PM')), + CONSTRAINT CHK_STUDY_SESSION_STATUS CHECK (status IN ('BEFORE_SESSION', 'IN_SESSION', 'AFTER_SESSION')) +); + +CREATE TABLE assignment ( + id SERIAL NOT NULL, + title VARCHAR(255) NOT NULL, + week VARCHAR(255) NULL, + session_date DATE NULL, + CONSTRAINT PK_ASSIGNMENT PRIMARY KEY (id) +); + +CREATE TABLE assignment_item ( + id SERIAL NOT NULL, + user_id BIGINT NOT NULL, -- FK 대상이므로 SERIAL에서 INT로 수정 + assignment_id INT NOT NULL, -- FK 대상이므로 SERIAL에서 INT로 수정 + submitted VARCHAR(20) NOT NULL DEFAULT 'SUCCESS', + CONSTRAINT PK_ASSIGNMENT_ITEM PRIMARY KEY (id), + CONSTRAINT CHK_ASSIGNMENT_ITEM_SUBMITTED CHECK (submitted IN ('SUCCESS', 'INSUFFICIENT', 'FAILURE')) +); + +CREATE TABLE attendance_code ( + id SERIAL NOT NULL, + attendance_date VARCHAR(255) NULL, + attendance_order VARCHAR(255) NULL, + code VARCHAR(20) NOT NULL, + is_expired BOOLEAN NOT NULL, -- TINYINT(1)에서 BOOLEAN으로 수정 + Field3 VARCHAR(255) NULL, + CONSTRAINT PK_ATTENDANCE_CODE PRIMARY KEY (id) +); + +CREATE TABLE attendance ( + id SERIAL NOT NULL, + attendance_code_id INT NOT NULL, + user_id BIGINT NOT NULL, + status BOOLEAN NOT NULL, -- TINYINT(1)에서 BOOLEAN으로 수정 + CONSTRAINT PK_ATTENDANCE PRIMARY KEY (id) +); + +CREATE TABLE question ( + id BIGSERIAL NOT NULL, + session_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + content VARCHAR(1000) NOT NULL, + image_url VARCHAR(1000) NULL, + is_resolved BOOLEAN NOT NULL, -- TINYINT(1)에서 BOOLEAN으로 수정 + like_count INT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + CONSTRAINT PK_QUESTION PRIMARY KEY (id) +); + +CREATE TABLE question_comment ( + id BIGSERIAL NOT NULL, + question_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + parent_comment_id BIGINT NULL, + content VARCHAR(1000) NOT NULL, + image_url VARCHAR(1000) NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + CONSTRAINT PK_QUESTION_COMMENT PRIMARY KEY (id) +); + +CREATE TABLE question_anonymous_identity ( + id BIGSERIAL NOT NULL, + user_id BIGINT NOT NULL, + question_id BIGINT NOT NULL, + anonymous_no INT NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL, + CONSTRAINT PK_QUESTION_ANONYMOUS_IDENTITY PRIMARY KEY (id) +); + +CREATE TABLE question_like ( + id BIGSERIAL NOT NULL, -- PK 타입 매칭을 위해 BIGSERIAL 수정 + question_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL, + CONSTRAINT PK_QUESTION_LIKE PRIMARY KEY (id) +); + +CREATE TABLE understanding_check ( + id BIGSERIAL NOT NULL, -- BIGINT PK용 BIGSERIAL 수정 + session_id BIGINT NOT NULL, + created_by BIGINT NOT NULL, + title VARCHAR(255) NOT NULL, + description VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT PK_UNDERSTANDING_CHECK PRIMARY KEY (id) +); + +CREATE TABLE understanding_response ( + id BIGSERIAL NOT NULL, + check_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + choice VARCHAR(20) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT PK_UNDERSTANDING_RESPONSE PRIMARY KEY (id), + CONSTRAINT CHK_UNDERSTANDING_RESPONSE_CHOICE CHECK (choice IN ('UNDERSTOOD', 'NOT_UNDERSTOOD')) +); + +CREATE TABLE deposit ( + id SERIAL NOT NULL, + user_id BIGINT NOT NULL, + amount INT NOT NULL, + descent_assignment INT NOT NULL, + descent_attendance INT NOT NULL, + ascent_defence INT NOT NULL, + CONSTRAINT PK_DEPOSIT PRIMARY KEY (id) +); + +-- 2. 코멘트(주석) 설정 +COMMENT ON COLUMN attendance_code.attendance_order IS '1, 2, 3'; \ No newline at end of file diff --git a/backend/src/main/resources/db/migration/V2__add_pending_to_assignment_status.sql b/backend/src/main/resources/db/migration/V2__add_pending_to_assignment_status.sql new file mode 100644 index 0000000..280766d --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__add_pending_to_assignment_status.sql @@ -0,0 +1,11 @@ +ALTER TABLE assignment_item + DROP CONSTRAINT chk_assignment_item_submitted; + +ALTER TABLE assignment_item + ADD CONSTRAINT chk_assignment_item_submitted + CHECK (submitted IN ( + 'SUCCESS', + 'INSUFFICIENT', + 'FAILURE', + 'PENDING' + )); \ No newline at end of file diff --git a/backend/src/main/resources/db/migration/V3__hange_attendance_date_to_date.sql b/backend/src/main/resources/db/migration/V3__hange_attendance_date_to_date.sql new file mode 100644 index 0000000..13d6bdb --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__hange_attendance_date_to_date.sql @@ -0,0 +1,3 @@ +ALTER TABLE attendance_code +ALTER COLUMN attendance_date TYPE DATE +USING attendance_date::DATE; \ No newline at end of file diff --git a/backend/src/main/resources/db/migration/V4__add_assignment_status.sql b/backend/src/main/resources/db/migration/V4__add_assignment_status.sql new file mode 100644 index 0000000..dc1b61a --- /dev/null +++ b/backend/src/main/resources/db/migration/V4__add_assignment_status.sql @@ -0,0 +1,14 @@ +ALTER TABLE assignment_item + DROP CONSTRAINT chk_assignment_item_submitted; + +ALTER TABLE assignment_item + ADD CONSTRAINT chk_assignment_item_submitted + CHECK ( + submitted IN ( + 'SUCCESS', + 'INSUFFICIENT_MINOR', + 'INSUFFICIENT_MAJOR', + 'FAILURE', + 'PENDING' + ) + ); \ No newline at end of file diff --git a/backend/uploads/3ba85e36-14a1-478f-aac7-9d0a1e0bc0a8.png b/backend/uploads/3ba85e36-14a1-478f-aac7-9d0a1e0bc0a8.png new file mode 100644 index 0000000..52a8826 Binary files /dev/null and b/backend/uploads/3ba85e36-14a1-478f-aac7-9d0a1e0bc0a8.png differ diff --git a/backend/uploads/83d5e181-fa7e-43a8-b28c-7d216b12b0bf.png b/backend/uploads/83d5e181-fa7e-43a8-b28c-7d216b12b0bf.png new file mode 100644 index 0000000..52a8826 Binary files /dev/null and b/backend/uploads/83d5e181-fa7e-43a8-b28c-7d216b12b0bf.png differ diff --git a/backend/uploads/cc5db130-976d-4098-8f2a-ecc538ca5a0c.png b/backend/uploads/cc5db130-976d-4098-8f2a-ecc538ca5a0c.png new file mode 100644 index 0000000..52a8826 Binary files /dev/null and b/backend/uploads/cc5db130-976d-4098-8f2a-ecc538ca5a0c.png differ diff --git a/backend/uploads/eb8c0be8-6fce-4cf4-92aa-758914f31699.png b/backend/uploads/eb8c0be8-6fce-4cf4-92aa-758914f31699.png new file mode 100644 index 0000000..52a8826 Binary files /dev/null and b/backend/uploads/eb8c0be8-6fce-4cf4-92aa-758914f31699.png differ diff --git a/docker-compose.yml b/docker-compose.yml index e84b9f7..0a33100 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,14 +11,16 @@ services: ports: - "5433:5432" # 내 컴퓨터 포트 : 컨테이너 포트 연결 - # 2. 백엔드 서버 설정 - backend: - build: ./backend # backend 폴더에 있는 Dockerfile을 읽어라 - container_name: piroin-backend - ports: - - "8080:8080" # 스프링 서버 포트 연결 - environment: - # DB 주소를 'localhost'가 아닌 서비스 이름인 'db'로 연결해야 합니다! - SPRING_DATASOURCE_URL: ${DB_URL} - depends_on: - - db # DB가 먼저 켜진 다음에 서버를 켜라 \ No newline at end of file + # # 2. 백엔드 서버 설정 + # backend: + # build: ./backend # backend 폴더에 있는 Dockerfile을 읽어라 + # container_name: piroin-backend + # ports: + # - "8080:8080" # 스프링 서버 포트 연결 + # environment: + # # DB 주소를 'localhost'가 아닌 서비스 이름인 'db'로 연결해야 합니다! + # SPRING_DATASOURCE_URL: ${DB_URL} + # SPRING_DATASOURCE_USERNAME: ${DB_USER} + # SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD} + # depends_on: + # - db # DB가 먼저 켜진 다음에 서버를 켜라 \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e468c5a..6e43dfa 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,9 +13,11 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^13.5.0", "axios": "^1.15.2", + "lucide-react": "^1.16.0", "react": "^19.2.5", "react-dom": "^19.2.5", - "react-router-dom": "^7.14.2", + "react-icons": "^5.6.0", + "react-router-dom": "^7.15.0", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" } @@ -66,7 +68,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -716,7 +717,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz", "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, @@ -1600,7 +1600,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-module-imports": "^7.28.6", @@ -3343,7 +3342,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3821,7 +3819,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.62.0", @@ -3875,7 +3872,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -4245,7 +4241,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4344,7 +4339,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5299,7 +5293,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -7133,7 +7126,6 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -9900,7 +9892,6 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^27.5.1", "import-local": "^3.0.2", @@ -10798,7 +10789,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -11181,6 +11171,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", + "integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -12157,7 +12156,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13292,7 +13290,6 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -13670,7 +13667,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13802,7 +13798,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13816,6 +13811,15 @@ "integrity": "sha512-SN/U6Ytxf1QGkw/9ve5Y+NxBbZM6Ht95tuXNMKs8EJyFa/Vy/+Co3stop3KBHARfn/giv+Lj1uUnTfOJ3moFEQ==", "license": "MIT" }, + "node_modules/react-icons": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -13827,15 +13831,14 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/react-router": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", - "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz", + "integrity": "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -13855,12 +13858,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz", - "integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==", + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.0.tgz", + "integrity": "sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==", "license": "MIT", "dependencies": { - "react-router": "7.14.2" + "react-router": "7.15.0" }, "engines": { "node": ">=20.0.0" @@ -14326,7 +14329,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -14569,7 +14571,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -15940,7 +15941,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16109,7 +16109,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { "node": ">=10" }, @@ -16539,7 +16538,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -16610,7 +16608,6 @@ "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz", "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", "license": "MIT", - "peer": true, "dependencies": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", @@ -17032,7 +17029,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/frontend/package.json b/frontend/package.json index d02b67d..63bac28 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,6 @@ { "name": "piroinfront", + "proxy": "http://13.209.73.127:8080", "version": "0.1.0", "private": true, "dependencies": { @@ -8,16 +9,17 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^13.5.0", "axios": "^1.15.2", + "lucide-react": "^1.16.0", "react": "^19.2.5", "react-dom": "^19.2.5", - "react-router-dom": "^7.14.2", + "react-icons": "^5.6.0", + "react-router-dom": "^7.15.0", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" }, - "proxy": "http://localhost:8080", "scripts": { "start": "react-scripts start", - "build": "react-scripts build", + "build": "CI=false react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" }, @@ -39,4 +41,4 @@ "last 1 safari version" ] } -} +} \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index 74b5e05..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,38 +0,0 @@ -.App { - text-align: center; -} - -.App-logo { - height: 40vmin; - pointer-events: none; -} - -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } -} - -.App-header { - background-color: #282c34; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; -} - -.App-link { - color: #61dafb; -} - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} diff --git a/frontend/src/App.js b/frontend/src/App.js index 3784575..bc1358b 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,25 +1,49 @@ -import logo from './logo.svg'; -import './App.css'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import Layout from './components/Layout'; +import LoginPage from './pages/login/LoginPage'; +import OnboardingPage from './pages/OnboardingPage'; +import QnAMainPage from './pages/qna/QnAMainPage'; +import QnAListPage from './pages/qna/QnAListPage'; +import QnADetailPage from './pages/qna/QnADetailPage'; +import CurriculumPage from './pages/curriculum/CurriculumPage'; +import PiroCheckMain from './pages/pirocheck/PIroCheckMain'; +import Attendance from './pages/pirocheck/attendance/Attendance' +import Assignment from './pages/pirocheck/assignment/Assignment'; +import Deposit from './pages/pirocheck/deposit/Deposit'; +import StudentList from './pages/pirocheck/students/StudentList'; +import StudentDetail from './pages/pirocheck/students/StudentDetail'; function App() { return ( -
-
- logo -

- Edit src/App.js and save to reload. -

- - Learn React - -
-
+ + + + {/* 헤더 없는 페이지 */} + } /> + } /> + } /> + + {/* 라이트 헤더 페이지 */} + }> + } /> + } /> + } /> + } /> + + + {/* 다크 헤더 페이지 */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); } -export default App; +export default App; \ No newline at end of file diff --git a/frontend/src/assets/fonts/GemunuLibre-VariableFont_wght.ttf b/frontend/src/assets/fonts/GemunuLibre-VariableFont_wght.ttf new file mode 100644 index 0000000..59d3f3c Binary files /dev/null and b/frontend/src/assets/fonts/GemunuLibre-VariableFont_wght.ttf differ diff --git a/frontend/src/assets/fonts/PretendardVariable.woff2 b/frontend/src/assets/fonts/PretendardVariable.woff2 new file mode 100644 index 0000000..49c54b5 Binary files /dev/null and b/frontend/src/assets/fonts/PretendardVariable.woff2 differ diff --git a/frontend/src/assets/images/AngryIcon.svg b/frontend/src/assets/images/AngryIcon.svg new file mode 100644 index 0000000..c3898a6 --- /dev/null +++ b/frontend/src/assets/images/AngryIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/images/CloverEmpty.svg b/frontend/src/assets/images/CloverEmpty.svg new file mode 100644 index 0000000..d531553 --- /dev/null +++ b/frontend/src/assets/images/CloverEmpty.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/src/assets/images/CloverGreen.svg b/frontend/src/assets/images/CloverGreen.svg new file mode 100644 index 0000000..a1e3f9b --- /dev/null +++ b/frontend/src/assets/images/CloverGreen.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/src/assets/images/CloverRed.svg b/frontend/src/assets/images/CloverRed.svg new file mode 100644 index 0000000..880d37f --- /dev/null +++ b/frontend/src/assets/images/CloverRed.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/src/assets/images/Coin1.svg b/frontend/src/assets/images/Coin1.svg new file mode 100644 index 0000000..9c0d880 --- /dev/null +++ b/frontend/src/assets/images/Coin1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/images/Coin2.svg b/frontend/src/assets/images/Coin2.svg new file mode 100644 index 0000000..4904651 --- /dev/null +++ b/frontend/src/assets/images/Coin2.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/src/assets/images/Coin3.svg b/frontend/src/assets/images/Coin3.svg new file mode 100644 index 0000000..02a0a2b --- /dev/null +++ b/frontend/src/assets/images/Coin3.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/src/assets/images/am.png b/frontend/src/assets/images/am.png new file mode 100644 index 0000000..8d1866b Binary files /dev/null and b/frontend/src/assets/images/am.png differ diff --git a/frontend/src/assets/images/icon_arrow_right.svg b/frontend/src/assets/images/icon_arrow_right.svg new file mode 100644 index 0000000..c5aa6f6 --- /dev/null +++ b/frontend/src/assets/images/icon_arrow_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/images/icon_delete.svg b/frontend/src/assets/images/icon_delete.svg new file mode 100644 index 0000000..da16964 --- /dev/null +++ b/frontend/src/assets/images/icon_delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/images/icon_edit.svg b/frontend/src/assets/images/icon_edit.svg new file mode 100644 index 0000000..a0788a2 --- /dev/null +++ b/frontend/src/assets/images/icon_edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/images/icon_status_o.svg b/frontend/src/assets/images/icon_status_o.svg new file mode 100644 index 0000000..87e1cf5 --- /dev/null +++ b/frontend/src/assets/images/icon_status_o.svg @@ -0,0 +1,16 @@ + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/images/icon_status_triangle.svg b/frontend/src/assets/images/icon_status_triangle.svg new file mode 100644 index 0000000..4764cf3 --- /dev/null +++ b/frontend/src/assets/images/icon_status_triangle.svg @@ -0,0 +1,16 @@ + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/images/icon_status_x.svg b/frontend/src/assets/images/icon_status_x.svg new file mode 100644 index 0000000..8152f92 --- /dev/null +++ b/frontend/src/assets/images/icon_status_x.svg @@ -0,0 +1,16 @@ + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/images/icon_togle1.svg b/frontend/src/assets/images/icon_togle1.svg new file mode 100644 index 0000000..3d85458 --- /dev/null +++ b/frontend/src/assets/images/icon_togle1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/images/icon_togle2.svg b/frontend/src/assets/images/icon_togle2.svg new file mode 100644 index 0000000..9aa266f --- /dev/null +++ b/frontend/src/assets/images/icon_togle2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/images/logo.png b/frontend/src/assets/images/logo.png new file mode 100644 index 0000000..2bea691 Binary files /dev/null and b/frontend/src/assets/images/logo.png differ diff --git a/frontend/src/assets/images/logo2.svg b/frontend/src/assets/images/logo2.svg new file mode 100644 index 0000000..d667deb --- /dev/null +++ b/frontend/src/assets/images/logo2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/images/pm.png b/frontend/src/assets/images/pm.png new file mode 100644 index 0000000..6d0e20f Binary files /dev/null and b/frontend/src/assets/images/pm.png differ diff --git a/frontend/src/assets/images/profile.png b/frontend/src/assets/images/profile.png new file mode 100644 index 0000000..0ac7d4c Binary files /dev/null and b/frontend/src/assets/images/profile.png differ diff --git a/frontend/src/assets/images/profile.svg b/frontend/src/assets/images/profile.svg new file mode 100644 index 0000000..84191cb --- /dev/null +++ b/frontend/src/assets/images/profile.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/images/week.png b/frontend/src/assets/images/week.png new file mode 100644 index 0000000..8301c66 Binary files /dev/null and b/frontend/src/assets/images/week.png differ diff --git a/frontend/src/assets/styles/global.css b/frontend/src/assets/styles/global.css new file mode 100644 index 0000000..667173f --- /dev/null +++ b/frontend/src/assets/styles/global.css @@ -0,0 +1,27 @@ +/* 포인트 폰트 */ +@font-face { + font-family: 'GemunuLibre'; + src: url('../fonts/GemunuLibre-VariableFont_wght.ttf') format('truetype'); +} + +/* 메인 폰트 */ +@font-face { + font-family: 'Pretendard'; + src: url('../fonts/PretendardVariable.woff2') format('woff2'); +} + +/* 색상 및 폰트 세팅 */ +:root { + --main: #0BEC12; + --dark: #09C410; + --light: #D6FCE0; + --pale: #F0FFF1; + --white: #ffffff; + --gray20: #F9F9F9; + --gray50: #F5F5F5; + --gray200: #E0E0E0; + --gray600: #555555; + --black: #111111; + --font-title: 'GemunuLibre', sans-serif; + --font-main: 'Pretendard', sans-serif; +} \ No newline at end of file diff --git a/frontend/src/components/Header.js b/frontend/src/components/Header.js new file mode 100644 index 0000000..d2c815c --- /dev/null +++ b/frontend/src/components/Header.js @@ -0,0 +1,24 @@ +import { NavLink } from 'react-router-dom'; +import styles from './Header.module.css'; + +function Header({ type }) { + return ( +
+ PIROIN + + +
+ ); +} + +export default Header; \ No newline at end of file diff --git a/frontend/src/components/Header.module.css b/frontend/src/components/Header.module.css new file mode 100644 index 0000000..fb275f0 --- /dev/null +++ b/frontend/src/components/Header.module.css @@ -0,0 +1,77 @@ +/* 라이트 헤더 */ +.light { + --header-bg: var(--white); + --header-color: var(--gray600); + --logo-color: var(--dark); +} + +/* 다크 헤더 */ +.dark { + --header-bg: var(--black); + --header-color: var(--white); + --logo-color: var(--main); + border-bottom: none; /* 추가 */ +} + +.header { + width: 100%; + height: 70px; + background: var(--header-bg); + display: flex; + align-items: center; + padding: 0 80px; + box-sizing: border-box; + position: relative; + position: sticky; + top: 0; + z-index: 100; +} + +.logo { + color: var(--logo-color); + font-family: var(--font-title); + font-size: 2.8rem; + font-weight: 800; + text-decoration: none; +} + +.nav { + display: flex; + gap: 6rem; + align-items: center; + position: absolute; + left: 50%; + transform: translateX(-50%); +} + +.nav a { + color: var(--header-color); + text-align: center; + font-family: var(--font-main); + font-size: 1.4rem; + font-weight: 500; + text-decoration: none; +} + +.nav a:hover { + color: var(--logo-color); +} + +.active { + color: var(--logo-color) !important; + font-weight: 700; +} + +.logoutBtn { + margin-left: auto; + background: transparent; + border: none; + color: var(--header-color); + font-family: var(--font-main); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + opacity: 0.7; +} + +.logoutBtn:hover { opacity: 1; transition: all ease-in-out 0.2s; } \ No newline at end of file diff --git a/frontend/src/components/Layout.js b/frontend/src/components/Layout.js new file mode 100644 index 0000000..7961f7a --- /dev/null +++ b/frontend/src/components/Layout.js @@ -0,0 +1,13 @@ +import Header from './Header'; +import { Outlet } from 'react-router-dom'; + +function Layout({ headerType }) { + return ( +
+
+ +
+ ); +} + +export default Layout; \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index ec2585e..e2bd8f3 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,5 +1,6 @@ body { margin: 0; + padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; diff --git a/frontend/src/index.js b/frontend/src/index.js index d563c0f..73e81e4 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'; import './index.css'; import App from './App'; import reportWebVitals from './reportWebVitals'; +import './assets/styles/global.css'; const root = ReactDOM.createRoot(document.getElementById('root')); root.render( diff --git a/frontend/src/logo.svg b/frontend/src/logo.svg deleted file mode 100644 index 9dfc1c0..0000000 --- a/frontend/src/logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/pages/OnboardingPage.js b/frontend/src/pages/OnboardingPage.js new file mode 100644 index 0000000..933057d --- /dev/null +++ b/frontend/src/pages/OnboardingPage.js @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import styles from './OnboardingPage.module.css'; +import logo from '../assets/images/logo.png'; + +function OnboardingPage() { + const navigate = useNavigate(); + + useEffect(() => { + const timer = setTimeout(() => { + const token = localStorage.getItem('token'); + if (token) { + navigate('/pirocheck'); + } else { + navigate('/login'); + } + }, 2000); + return () => clearTimeout(timer); + }, []); + + return ( +
+

PIROIN

+
+ 로고 +
+

"피로그래밍의 모든 것, 피로인에서"

+

피로인들을 위한, 세션 통합 관리 플랫폼

+
+ ); +} + +export default OnboardingPage; \ No newline at end of file diff --git a/frontend/src/pages/OnboardingPage.module.css b/frontend/src/pages/OnboardingPage.module.css new file mode 100644 index 0000000..890b008 --- /dev/null +++ b/frontend/src/pages/OnboardingPage.module.css @@ -0,0 +1,50 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + background-color: #fff; +} + +.title { + color: var(--dark); + font-size: 100px; + font-weight: 900; + font-family: var(--font-title); + margin: 0 0 48px 0; +} + +.logoWrap { + position: relative; + width: 300px; + height: 300px; + margin-bottom: 48px; +} + +.logoWrap img { + width: 300px; + height: 300px; +} + +.circle { + position: absolute; + width: 130px; + height: 130px; + border-radius: 50%; + background-color: #09C624; + opacity: 0.5; +} + +.circle:nth-child(1) { top: 0; left: 0; } +.circle:nth-child(2) { top: 0; right: 0; } +.circle:nth-child(3) { bottom: 0; left: 0; } +.circle:nth-child(4) { bottom: 0; right: 0; } + +.sub { + color: var(--dark); + font-family: var(--font-main); + font-size: 18px; + margin: 4px 0; + font-weight: 550; +} \ No newline at end of file diff --git a/frontend/src/pages/curriculum/CurriculumPage.js b/frontend/src/pages/curriculum/CurriculumPage.js new file mode 100644 index 0000000..656f529 --- /dev/null +++ b/frontend/src/pages/curriculum/CurriculumPage.js @@ -0,0 +1,372 @@ +import { useState, useEffect } from 'react'; +import styles from './CurriculumPage.module.css'; +import { authFetch } from '../../utils/Api'; +import LogoImg from '../../assets/images/logo.png'; +import AmImg from '../../assets/images/am.png'; +import PmImg from '../../assets/images/pm.png'; +import Toggle1 from '../../assets/images/icon_togle1.svg'; + +const role = localStorage.getItem('role') || 'MEMBER'; + +const DAY_LABEL = { TUESDAY: '화요일', THURSDAY: '목요일', SATURDAY: '토요일' }; +const STATUS_OPTIONS = ['BEFORE', 'ONGOING', 'AFTER']; +const STATUS_LABEL = { BEFORE: '세션 전', ONGOING: '세션 중', AFTER: '세션 후' }; + +// ── 세션 정보 렌더 (공통) ───────────────────────────── +function SessionInfo({ session, isAdmin }) { + const icon = session.dayPart === 'AM' ? AmImg : PmImg; + const label = session.dayPart === 'AM' ? '오전 세션' : '오후 세션'; + + return ( +
+
+ {label} + {session.title} + {session.hostName} +
+
+ 세션 자료 + {session.sessionMaterialUrl + ? {session.sessionMaterialName || '링크'} + : {session.sessionMaterialName || '-'} + } +
+
+ {session.recordingUrl + ? 녹화본 + : - + } + {session.recordingPassword && PW : {session.recordingPassword}} +
+
+ ); +} + +// ── 부원용 세션 카드 ────────────────────────────────── +function MemberSessionCard({ day }) { + const [isOpen, setIsOpen] = useState(false); + const amSession = day.sessions?.find(s => s.dayPart === 'AM'); + const pmSession = day.sessions?.find(s => s.dayPart === 'PM'); + const weekDay = DAY_LABEL[day.dayOfWeek] || ''; + + return ( +
+
setIsOpen(p => !p)}> +
+ {day.week}주차 {weekDay} 세션 + {day.sessionDate} +
+ toggle +
+
+ + {isOpen && ( +
+ {amSession && } + {pmSession && } + {(day.assignmentName || day.assignmentUrl) && ( +
+ 과제 + {day.assignmentUrl + ? {day.assignmentName || '링크'} + : {day.assignmentName} + } +
+ )} +
+ )} +
+ ); +} + +// ── 운영진용 세션 카드 ──────────────────────────────── +function AdminSessionCard({ day, onEdit, onDelete }) { + const [isOpen, setIsOpen] = useState(true); + const amSession = day.sessions?.find(s => s.dayPart === 'AM'); + const pmSession = day.sessions?.find(s => s.dayPart === 'PM'); + const weekDay = DAY_LABEL[day.dayOfWeek] || ''; + + return ( +
+
setIsOpen(p => !p)}> +
+ {day.week}주차 {weekDay} 세션 + {day.sessionDate} +
+ toggle +
+
+ + {isOpen && ( +
+ {amSession && } + {pmSession && } + {(day.assignmentName || day.assignmentUrl) && ( +
+ 과제 + {day.assignmentUrl + ? {day.assignmentName || '링크'} + : {day.assignmentName} + } +
+ )} +
+ + +
+
+ )} +
+ ); +} + +// ── 운영진 세션 생성/수정 폼 ────────────────────────── +function SessionForm({ day, week, onClose, onSave }) { + const isEdit = !!day; + const [form, setForm] = useState({ + week: day?.week || week || 1, + sessionDate: day?.sessionDate || '', + generation: day?.generation || 25, + amTitle: day?.sessions?.find(s => s.dayPart === 'AM')?.title || '', + amHost: day?.sessions?.find(s => s.dayPart === 'AM')?.hostName || '', + amMaterialUrl: day?.sessions?.find(s => s.dayPart === 'AM')?.sessionMaterialUrl || '', + amMaterialName: day?.sessions?.find(s => s.dayPart === 'AM')?.sessionMaterialName || '', + amRecordingUrl: day?.sessions?.find(s => s.dayPart === 'AM')?.recordingUrl || '', + amRecordingPw: day?.sessions?.find(s => s.dayPart === 'AM')?.recordingPassword || '', + amStatus: day?.sessions?.find(s => s.dayPart === 'AM')?.status || 'BEFORE', + pmTitle: day?.sessions?.find(s => s.dayPart === 'PM')?.title || '', + pmHost: day?.sessions?.find(s => s.dayPart === 'PM')?.hostName || '', + pmMaterialUrl: day?.sessions?.find(s => s.dayPart === 'PM')?.sessionMaterialUrl || '', + pmMaterialName: day?.sessions?.find(s => s.dayPart === 'PM')?.sessionMaterialName || '', + pmRecordingUrl: day?.sessions?.find(s => s.dayPart === 'PM')?.recordingUrl || '', + pmRecordingPw: day?.sessions?.find(s => s.dayPart === 'PM')?.recordingPassword || '', + pmStatus: day?.sessions?.find(s => s.dayPart === 'PM')?.status || 'BEFORE', + assignmentUrl: day?.assignmentUrl || '', + assignmentName: day?.assignmentName || '', + }); + + // sessionDate 변경 시 요일 자동 계산 + const getWeekDay = (dateStr) => { + if (!dateStr) return ''; + const [year, month, day] = dateStr.split('-').map(Number); + const date = new Date(year, month - 1, day); + const map = { 2: '화요일', 4: '목요일', 6: '토요일' }; + return map[date.getDay()] || ''; + }; + + const handleSave = async () => { + const body = { + generation: Number(form.generation), + week: Number(form.week), + sessionDate: form.sessionDate, + sessions: [ + { + dayPart: 'AM', + title: form.amTitle, + hostName: form.amHost, + sessionMaterialUrl: form.amMaterialUrl, + sessionMaterialName: form.amMaterialName, + recordingUrl: form.amRecordingUrl, + recordingPassword: form.amRecordingPw, + status: form.amStatus, + }, + { + dayPart: 'PM', + title: form.pmTitle, + hostName: form.pmHost, + sessionMaterialUrl: form.pmMaterialUrl, + sessionMaterialName: form.pmMaterialName, + recordingUrl: form.pmRecordingUrl, + recordingPassword: form.pmRecordingPw, + assignmentUrl: form.assignmentUrl, + assignmentName: form.assignmentName, + status: form.pmStatus, + }, + ], + }; + + if (isEdit) { + await authFetch(`/api/curriculums/${day.sessionDate}`, { + method: 'PATCH', + body: JSON.stringify({ + generation: body.generation, + week: body.week, + newSessionDate: form.sessionDate, + sessions: body.sessions, + }), + }); + } else { + await authFetch('/api/curriculums', { + method: 'POST', + body: JSON.stringify(body), + }); + } + onSave(); + onClose(); + }; + + const weeks = [1, 2, 3, 4, 5]; + + return ( +
+
+
+ + +
+ +
+
+ + +
+
+ + setForm({ ...form, sessionDate: e.target.value })} /> +
+
+ + {/* 오전 세션 */} +
+ AM + 오전 세션 +
+ {STATUS_OPTIONS.map(s => ( + + ))} +
+
+
+
setForm({ ...form, amTitle: e.target.value })} />
+
setForm({ ...form, amHost: e.target.value })} />
+
setForm({ ...form, amMaterialName: e.target.value })} />
+
setForm({ ...form, amMaterialUrl: e.target.value })} />
+
setForm({ ...form, amRecordingUrl: e.target.value })} />
+
setForm({ ...form, amRecordingPw: e.target.value })} />
+
+ + {/* 오후 세션 */} +
+ PM + 오후 세션 +
+ {STATUS_OPTIONS.map(s => ( + + ))} +
+
+
+
setForm({ ...form, pmTitle: e.target.value })} />
+
setForm({ ...form, pmHost: e.target.value })} />
+
setForm({ ...form, pmMaterialName: e.target.value })} />
+
setForm({ ...form, pmMaterialUrl: e.target.value })} />
+
setForm({ ...form, pmRecordingUrl: e.target.value })} />
+
setForm({ ...form, pmRecordingPw: e.target.value })} />
+
+ + {/* 과제 */} +
+ 과제 +
setForm({ ...form, assignmentName: e.target.value })} />
+
setForm({ ...form, assignmentUrl: e.target.value })} />
+
+ + + +
+
+ ); +} + +// ── 메인 컴포넌트 ───────────────────────────────────── +function CurriculumPage() { + const [days, setDays] = useState([]); + const [showForm, setShowForm] = useState(false); + const [editDay, setEditDay] = useState(null); + const [createWeek, setCreateWeek] = useState(null); + + const fetchDays = async () => { + try { + const res = await authFetch('/api/curriculums'); + const data = await res.json(); + setDays(Array.isArray(data) ? data : []); + } catch (e) {} + }; + + useEffect(() => { fetchDays(); }, []); + + const handleDelete = async (sessionDate) => { + if (!window.confirm('삭제하시겠습니까?')) return; + await authFetch(`/api/curriculums/${sessionDate}`, { method: 'DELETE' }); + fetchDays(); + }; + + // 주차별로 그룹화 + const grouped = days.reduce((acc, day) => { + const week = day.week; + if (!acc[week]) acc[week] = []; + acc[week].push(day); + return acc; + }, {}); + + return ( +
+ {role === 'ADMIN' && ( +
+ +
+ )} + {Object.entries(grouped).map(([week, weekDays]) => ( +
+
+
+ logo + WEEK {week} +
+
+ +
+ {weekDays.map((day, i) => ( + role === 'ADMIN' + ? { setEditDay(d); setCreateWeek(null); setShowForm(true); }} + onDelete={handleDelete} /> + : + ))} +
+
+ ))} + + {showForm && ( + + { setShowForm(false); setEditDay(null); setCreateWeek(null); }} + onSave={fetchDays} + /> + )} +
+ ); +} + +export default CurriculumPage; \ No newline at end of file diff --git a/frontend/src/pages/curriculum/CurriculumPage.module.css b/frontend/src/pages/curriculum/CurriculumPage.module.css new file mode 100644 index 0000000..c899b66 --- /dev/null +++ b/frontend/src/pages/curriculum/CurriculumPage.module.css @@ -0,0 +1,457 @@ +.container { + padding: 40px 60px; + background: var(--gray20); + min-height: calc(100vh - 100px); +} + +/* 주차 섹션 */ +.weekSection { + margin-bottom: 48px; +} + +.weekHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +} + +.weekLeft { + display: flex; + align-items: center; + gap: 12px; +} + +.logoIcon { + width: 40px; + height: 40px; + object-fit: contain; +} + +.weekTitle { + font-family: var(--font-main); + font-size: 1.6rem; + font-weight: 700; + color: var(--black); +} + +.topBar { + display: flex; + justify-content: flex-end; + margin-bottom: 24px; +} + +.createBtn { + padding: 8px 30px; + background: transparent; + border: 1.5px solid var(--dark); + border-radius: 10px; + color: var(--dark); + font-family: var(--font-main); + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.createBtn:hover { background: var(--dark); color: var(--white); } + +/* 카드 행 */ +.cardsRow { + margin: 0 auto; + display: flex; + gap: 20px; + flex-wrap: wrap; + align-items: flex-start; + justify-content: flex-start; +} + +/* 세션 카드 */ +.sessionCard { + background: var(--white); + border: 1px solid #eee; + border-radius: 20px; + padding: 30px; + min-width: 200px; + width: calc(28% - 14px); + box-shadow: 0 1px 4px rgba(0,0,0,0.06); +} + +.cardHeader { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + margin-bottom: 0; +} + +.cardHeaderLeft { + display: flex; + align-items: center; + gap: 10px; +} + +.cardTitle { + font-family: var(--font-main); + font-size: 1.3rem; + font-weight: 650; + color: var(--black); +} + +.cardDate { + font-family: var(--font-main); + font-size: 0.8rem; + color: #aaa; + margin-left: 10px; +} + +.cardToggle { + color: var(--dark); + font-size: 0.8rem; +} + +.cardBody { + display: flex; + flex-direction: column; + gap: 12px; +} + +.divider { + border: none; + border-top: 1px solid var(--gray200); + margin: 20px 0 20px 0; +} + +/* 세션 정보 */ +.sessionInfo { + display: flex; + flex-direction: column; + gap: 4px; +} + +.sessionInfoRow { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.sessionRow { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.sessionTitleRow { + margin: 5px 0; +} + +.sessionIcon { + width: 18px; + height: 18px; + object-fit: contain; + filter: brightness(0) saturate(100%) invert(44%) sepia(98%) saturate(500%) hue-rotate(90deg) brightness(95%) contrast(110%); +} + +.sessionTitle { + font-family: var(--font-main); + font-size: 1.1rem; + font-weight: 550; + color: var(--black); + padding: 5px 0; +} + +.sessionHost { + font-family: var(--font-main); + font-size: 0.9rem; + color: var(--gray600); + margin-left: auto; +} + +.sessionDetailRow { + display: flex; + justify-content: space-between; + padding: 3px 0; +} + +.sessionLink { + font-family: var(--font-main); + font-size: 0.9rem; + color: var(--black); + text-decoration: none; +} + +.sessionLink:hover { + color: var(--dark); + transition: all ease-in-out 0.2s; + cursor: pointer; +} + +.sessionLinkName { + font-family: var(--font-main); + font-size: 0.9rem; + color: var(--black); +} + +.sessionRecording { + font-family: var(--font-main); + font-size: 0.9rem; + color: var(--black); +} + +.sessionPw { + font-family: var(--font-main); + font-size: 0.9rem; + color: var(--black); +} + +/* 과제 */ +.assignmentRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding-top: 4px; + margin-left: 22px; +} + +.assignmentLabel { + font-family: var(--font-main); + font-size: 1.2rem; + font-weight: 700; + color: var(--dark); +} + +.assignmentSection { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; +} + +/* 운영진 버튼 */ +.adminBtns { + display: flex; + margin: 10px auto; + gap: 8px; +} + +.editBtn { + padding: 6px 20px; + background: transparent; + border: 1.5px solid var(--dark); + border-radius: 10px; + color: var(--dark); + font-family: var(--font-main); + font-size: 0.85rem; + cursor: pointer; +} + +.editBtn:hover { + background: var(--dark); + color: var(--white); + transition: all ease-in-out 0.2s; +} + +.deleteBtn { + padding: 6px 20px; + background: transparent; + border: 1.5px solid var(--dark); + border-radius: 10px; + color: var(--dark); + font-family: var(--font-main); + font-size: 0.85rem; + cursor: pointer; +} + +.deleteBtn:hover { + background: var(--dark); + color: var(--white); + transition: all ease-in-out 0.2s; +} + +/* 세션 생성/수정 폼 */ +.formOverlay { + position: fixed; + inset: 0; + background: var(--gray20); + display: flex; + align-items: flex-start; + justify-content: center; + overflow-y: auto; + padding: 40px 20px; + z-index: 200; +} + +.formCard { + background: var(--white); + border-radius: 16px; + padding: 40px; + width: 560px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.formTitle { + font-family: var(--font-main); + font-size: 1.3rem; + font-weight: 700; + color: var(--dark); + margin-bottom: 8px; +} + +.formSection { + display: flex; + flex-direction: column; + gap: 4px; +} + +.formSectionTitle { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +.amLabel { + font-family: var(--font-main); + font-size: 1rem; + font-weight: 700; + color: var(--dark); +} + +.pmLabel { + font-family: var(--font-main); + font-size: 1rem; + font-weight: 700; + color: var(--dark); +} + +.statusBtns { + display: flex; + gap: 6px; + margin-left: auto; +} + +.statusBtn { + padding: 4px 12px; + background: transparent; + border: 1.5px solid var(--dark); + border-radius: 7px; + color: var(--dark); + font-family: var(--font-main); + font-size: 0.8rem; + cursor: pointer; +} + +.statusBtn:hover { + background: var(--dark); + color: var(--white); + transition: all ease-in-out 0.2s; +} + +.statusActive { + background: var(--dark); + color: var(--white); +} + +.formGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.formLabel { + font-family: var(--font-main); + font-size: 0.85rem; + color: #666; + margin-bottom: 4px; + display: block; +} + +.formInput { + width: 100%; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 8px; + font-family: var(--font-main); + font-size: 0.9rem; + outline: none; + box-sizing: border-box; +} + +.formInput:focus { border-color: var(--dark); } + +.saveFormBtn { + width: 40%; + margin: 30px auto 0; + padding: 10px 0; + background: transparent; + border: 1.5px solid var(--dark); + border-radius: 10px; + color: var(--dark); + font-family: var(--font-main); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.saveFormBtn:hover { background: var(--dark); color: var(--white); } + +.cancelBtn { + padding: 10px 0; + background: transparent; + border: none; + color: #aaa; + font-family: var(--font-main); + font-size: 0.9rem; + cursor: pointer; + text-align: center; +} + +/* 추가 스타일 */ +.toggleIcon { + width: 14px; + height: 14px; + transition: transform 0.3s ease; + filter: brightness(0) saturate(100%) invert(44%) sepia(60%) saturate(1693%) hue-rotate(89deg) brightness(107%) contrast(95%); +} + +.toggleOpen { + transform: rotate(180deg); +} + +.sessionTitleRow { + display: flex; + align-items: center; + gap: 8px; +} + +.sessionDetailRow { + display: flex; + align-items: center; + gap: 8px; + padding-left: 26px; +} + +.sessionDetailLabel { + font-family: var(--font-main); + font-size: 0.9rem; + color: var(--black); + min-width: 50px; +} + +.sessionDetailVal { + font-family: var(--font-main); + font-size: 0.9rem; + color: var(--black); +} + +.formRow2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} \ No newline at end of file diff --git a/frontend/src/pages/login/LoginPage.js b/frontend/src/pages/login/LoginPage.js new file mode 100644 index 0000000..91a2eeb --- /dev/null +++ b/frontend/src/pages/login/LoginPage.js @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { authFetch } from '../../utils/Api'; +import styles from './LoginPage.module.css'; + +function LoginPage() { + const navigate = useNavigate(); + const [focused, setFocused] = useState(''); + const [form, setForm] = useState({ name: '', password: '' }); + + + const handleChange = (e) => { + setForm({ ...form, [e.target.name]: e.target.value }); + }; + + + const handleLogin = async () => { + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(form), + }); + + if (response.ok) { + const data = await response.json(); + localStorage.setItem('token', data.token); + localStorage.setItem('role', data.role); + localStorage.setItem('name', data.name); + navigate('/sessions'); // 로그인 성공 시 이동할 페이지 + } else { + const errData = await response.json(); + alert('이름 또는 비밀번호가 올바르지 않습니다.'); + } + } catch (error) { + alert('서버 오류가 발생했습니다.'); + } + }; + + return ( +
+

PIROIN

+
+ setFocused('name')} + onBlur={() => setFocused('')} + /> + setFocused('pw')} + onBlur={() => setFocused('')} + /> + +
+
+ ); +} + +export default LoginPage; \ No newline at end of file diff --git a/frontend/src/pages/login/LoginPage.module.css b/frontend/src/pages/login/LoginPage.module.css new file mode 100644 index 0000000..3a146cd --- /dev/null +++ b/frontend/src/pages/login/LoginPage.module.css @@ -0,0 +1,54 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 90vh; + background-color: #fff; +} + +.title { + color: var(--dark); + font-family: var(--font-main); + font-size: 56px; + font-weight: 900; + margin-bottom: 48px; +} + +.form { + display: flex; + flex-direction: column; + gap: 12px; + width: 360px; +} + +.input { + padding: 16px 20px; + border-radius: 12px; + border: 1.5px solid var(--gray50); + background-color: var(--gray20); + font-size: 16px; + outline: none; + transition: border 0.2s; +} + +.inputFocused { + border: 1.5px solid var(--dark); +} + +.button { + margin-top: 8px; + padding: 18px; + border-radius: 12px; + background-color: var(--dark); + color: #fff; + font-size: 18px; + font-weight: 700; + border: none; + cursor: pointer; +} + +.button:hover { + background-color: var(--main); + transition: all 0.2s ease; +} \ No newline at end of file diff --git a/frontend/src/pages/pirocheck/PIroCheckMain.js b/frontend/src/pages/pirocheck/PIroCheckMain.js new file mode 100644 index 0000000..8faf3a2 --- /dev/null +++ b/frontend/src/pages/pirocheck/PIroCheckMain.js @@ -0,0 +1,37 @@ +import { useNavigate } from 'react-router-dom'; +import styles from './PIroCheckMain.module.css'; + +function PIroCheckMain() { + const navigate = useNavigate(); + const role = localStorage.getItem('role') || 'MEMBER'; + + const adminMenus = [ + { label: '출석 관리', path: '/pirocheck/attendance' }, + { label: '과제 관리', path: '/pirocheck/assignment' }, + { label: '수강생 관리', path: '/pirocheck/students' }, + ]; + + const memberMenus = [ + { label: 'ATTENDANCE CHECK', path: '/pirocheck/attendance' }, + { label: 'ASSIGNMENT CHECK', path: '/pirocheck/assignment' }, + { label: 'DEPOSIT CHECK', path: '/pirocheck/deposit' }, + ]; + + const menus = role === 'ADMIN' ? adminMenus : memberMenus; + + return ( +
+ {menus.map((menu, i) => ( + + ))} +
+ ); +} + +export default PIroCheckMain; \ No newline at end of file diff --git a/frontend/src/pages/pirocheck/PIroCheckMain.module.css b/frontend/src/pages/pirocheck/PIroCheckMain.module.css new file mode 100644 index 0000000..9a0663a --- /dev/null +++ b/frontend/src/pages/pirocheck/PIroCheckMain.module.css @@ -0,0 +1,28 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 20px; + height: calc(100vh - 100px); + background: var(--black); +} + +.menuBtn { + width: 420px; + padding: 25px 0; + background: #3a3a3a; + border: none; + border-radius: 10px; + color: var(--main); + font-family: var(--font-title); + font-size: 2rem; + font-weight: 800; + cursor: pointer; + transition: background 0.2s; +} + +.menuBtn:hover { + background: var(--dark); + color: var(--white); +} \ No newline at end of file diff --git a/frontend/src/pages/pirocheck/assignment/Assignment.js b/frontend/src/pages/pirocheck/assignment/Assignment.js new file mode 100644 index 0000000..81a0f13 --- /dev/null +++ b/frontend/src/pages/pirocheck/assignment/Assignment.js @@ -0,0 +1,196 @@ +import { useState, useEffect } from 'react'; +import { authFetch } from '../../../utils/Api'; +import styles from './Assignment.module.css'; +import LogoImg from '../../../assets/images/logo.png'; +import EditIcon from '../../../assets/images/icon_edit.svg'; +import DeleteIcon from '../../../assets/images/icon_delete.svg'; +import StatusO from '../../../assets/images/icon_status_o.svg'; +import StatusT from '../../../assets/images/icon_status_triangle.svg'; +import StatusX from '../../../assets/images/icon_status_x.svg'; + +// 요일 변환 +const dayMap = { + TUESDAY: 'TUE', + THURSDAY: 'THU', + SATURDAY: 'SAT', +}; + +const IS_MOCK = false; + +// 제출 상태 아이콘 (부원용) +function StatusIcon({ status }) { + if (status === 'SUBMITTED') return 제출; + if (status === 'LATE') return 지각제출; + return 미제출; +} + +// 세션별 과제 묶기 +function groupByDay(assignments) { + const order = ['TUESDAY', 'THURSDAY', 'SATURDAY']; + const grouped = {}; + assignments.forEach(a => { + if (!grouped[a.day]) grouped[a.day] = { day: a.day, sessionDate: a.sessionDate, items: [] }; + grouped[a.day].items.push(a); + }); + return order.filter(d => grouped[d]).map(d => grouped[d]); +} + +// ── 과제 등록/수정 모달 ─────────────────────────────── +function AssignmentModal({ item, onClose, onSave }) { + const isEdit = !!item; + const [form, setForm] = useState({ + week: item?.week || '1', + day: item?.day || 'TUESDAY', + title: item?.title || '', + }); + const weeks = ['1', '2', '3', '4', '5']; + const days = ['TUESDAY', 'THURSDAY', 'SATURDAY']; + + const handleSave = async () => { + const url = isEdit + ? `/api/assignments/modify/${item.assignmentId}` + : '/api/assignments/create'; + const method = isEdit ? 'PATCH' : 'POST'; + await authFetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: form.title, week: form.week, day: form.day }), + }); + onSave(); + onClose(); + }; + + return ( +
+
+ logo +
ASSIGNMENT
+
+ + + 과제 +
+ setForm({ ...form, title: e.target.value })} + /> + +
+
+ ); +} + +// ── 주차 블록 (공통) ────────────────────────────────── +function WeekBlock({ weekData, role, onEdit, onDelete }) { + const [isOpen, setIsOpen] = useState(false); + const grouped = groupByDay(weekData.assignments || []); + + return ( +
+
setIsOpen(prev => !prev)}> +
+ logo + WEEK {weekData.week} +
+ {isOpen ? '▲' : '▼'} +
+ + {isOpen && ( +
+ {grouped.length === 0 && ( +
등록된 과제가 없습니다.
+ )} + {grouped.map((session, j) => ( +
+
+
+ {dayMap[session.day]} + {session.sessionDate && {session.sessionDate}} +
+ {role === 'ADMIN' && ( +
+ + +
+ )} +
+ {session.items.map((item, k) => ( +
+ {item.title} + {role === 'MEMBER' && } +
+ ))} + {j < grouped.length - 1 &&
} +
+ ))} +
+ )} +
+ ); +} + +// ── 메인 컴포넌트 ───────────────────────────────────── +function Assignment() { + const role = localStorage.getItem('role') || 'MEMBER'; + const [weeks, setWeeks] = useState([]); + const [modalItem, setModalItem] = useState(undefined); // undefined=닫힘, null=생성, object=수정 + + const fetchAll = async () => { + const results = await Promise.all( + ['1', '2', '3', '4', '5'].map(w => + authFetch(`/api/assignments/me/${w}`) + .then(r => r.json()) + .catch(() => ({ week: w, assignments: [] })) + ) + ); + setWeeks(results); + }; + + useEffect(() => { fetchAll(); }, []); + + const handleDelete = async (assignmentId) => { + if (!window.confirm('삭제하시겠습니까?')) return; + await authFetch(`/api/assignments/${assignmentId}`, { method: 'DELETE' }); + fetchAll(); + }; + + return ( +
+
ASSIGNMENT CHECK
+ + {weeks.map((w, i) => ( + setModalItem(item)} + onDelete={handleDelete} + /> + ))} + + {role === 'ADMIN' && ( + + )} + + {modalItem !== undefined && ( + setModalItem(undefined)} + onSave={fetchAll} + /> + )} +
+ ); +} + +export default Assignment; \ No newline at end of file diff --git a/frontend/src/pages/pirocheck/assignment/Assignment.module.css b/frontend/src/pages/pirocheck/assignment/Assignment.module.css new file mode 100644 index 0000000..5ca20b9 --- /dev/null +++ b/frontend/src/pages/pirocheck/assignment/Assignment.module.css @@ -0,0 +1,287 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + padding: 60px 20px; + min-height: calc(100vh - 100px); + background: var(--black); +} + +.mockBanner { + background: #5a3e00; + color: #ffd166; + font-family: var(--font-main); + font-size: 0.85rem; + padding: 10px 20px; + border-radius: 8px; + margin-bottom: 20px; + text-align: center; + width: 600px; + box-sizing: border-box; +} + +.title { + font-family: var(--font-title); + font-size: 3rem; + font-weight: 800; + color: var(--main); + margin-bottom: 40px; + letter-spacing: 0; +} + +/* 주차 블록 */ +.weekBlock { + width: 600px; + background: #3a3a3a; + border-radius: 16px; + margin-bottom: 20px; + overflow: hidden; +} + +.weekHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + cursor: pointer; + color: var(--white); +} + +.weekLeft { + display: flex; + align-items: center; + gap: 12px; +} + +.logoIcon { + width: 28px; + height: 28px; + object-fit: contain; +} + +.weekLabel { + font-family: var(--font-main); + font-size: 1.1rem; + font-weight: 600; + color: var(--white); +} + +.arrow { + color: var(--white); + font-size: 0.9rem; +} + +.weekBody { + padding: 0 24px 20px; +} + +.empty { + color: #aaa; + font-family: var(--font-main); + font-size: 0.9rem; + text-align: center; + padding: 16px 0; +} + +/* 세션 */ +.session { + margin-top: 12px; +} + +.sessionHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.sessionLeft { + display: flex; + align-items: center; + gap: 10px; +} + +.dayLabel { + color: var(--dark); + font-family: var(--font-main); + font-size: 1.2rem; + font-weight: 700; +} + +.sessionTitle { + color: var(--light); + font-family: var(--font-main); + font-size: 1rem; +} + +.sessionActions { + display: flex; + gap: 8px; +} + +.iconBtn { + background: none; + border: none; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; +} + +.actionIcon { + width: 20px; + height: 20px; + opacity: 0.7; +} + +.actionIcon:hover { opacity: 1; } + +/* 과제 항목 */ +.assignmentRow { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 0; +} + +.assignmentTitle { + color: var(--white); + font-family: var(--font-main); + font-size: 0.95rem; +} + +.statusIcon { + width: 20px; + height: 20px; +} + +.divider { + border: none; + border-top: 1px solid #555; + margin: 12px 0; +} + +/* + 추가 버튼 */ +.addBtn { + position: fixed; + bottom: 40px; + right: 40px; + width: 56px; + height: 56px; + border-radius: 50%; + background: #3a3a3a; + color: var(--main); + font-size: 2rem; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); + transition: background 0.2s; +} + +.addBtn:hover { background: #4a4a4a; } + +/* 모달 */ +.modalOverlay { + position: fixed; + inset: 0; + background: var(--black); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; +} + +.modal { + background: #3a3a3a; + border-radius: 20px; + padding: 40px 60px; + width: 420px; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.modalLogo { + width: 200px; + height: 200px; + object-fit: contain; +} + +.modalTitle { + font-family: var(--font-main); + font-size: 3rem; + font-weight: 800; + color: var(--main); + letter-spacing: 0; +} + +.modalRow { + display: flex; + align-items: center; + gap: 12px; + width: 85%; + margin-top: 10px; +} + +.select { + padding: 10px 36px 10px 20px; + background-color: var(--pale); + + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: right 16px center; + + border: none; + border-radius: 8px; + font-family: var(--font-main); + font-size: 1rem; + cursor: pointer; + flex: 1; +} + +.select::-ms-expand { + display: none; +} +.modalInput { + width: 85%; + padding: 12px 20px; + background: var(--pale); + border: none; + border-radius: 8px; + font-family: var(--font-main); + font-size: 1rem; + box-sizing: border-box; +} + +.modalLabel { + color: var(--white); + font-family: var(--font-main); + font-size: 1.2rem; +} + +.saveBtn { + margin-top: 15px; + padding: 10px 45px; + background: transparent; + border: 2px solid var(--main); + border-radius: 10px; + color: var(--main); + font-family: var(--font-main); + font-size: 1rem; + font-weight: 650; + cursor: pointer; + transition: all 0.2s; +} + +.saveBtn:hover { + background: var(--main); + color: var(--black); +} \ No newline at end of file diff --git a/frontend/src/pages/pirocheck/attendance/Attendance.js b/frontend/src/pages/pirocheck/attendance/Attendance.js new file mode 100644 index 0000000..fe8f350 --- /dev/null +++ b/frontend/src/pages/pirocheck/attendance/Attendance.js @@ -0,0 +1,193 @@ +import { useState, useEffect } from 'react'; +import { authFetch } from '../../../utils/Api'; +import styles from './Attendance.module.css'; +import CloverGreen from '../../../assets/images/CloverGreen.svg'; +import CloverRed from '../../../assets/images/CloverRed.svg'; +import CloverEmpty from '../../../assets/images/CloverEmpty.svg'; +import Coin1 from '../../../assets/images/Coin1.svg'; +import Coin2 from '../../../assets/images/Coin2.svg'; +import Coin3 from '../../../assets/images/Coin3.svg'; +import AngryIcon from '../../../assets/images/AngryIcon.svg'; + +function cloverForSlot(status) { + if (status === true) return 출석; + if (status === false) return 결석; + return 미정; +} + +function historyIcon(slots) { + const successCount = slots.filter(s => s.status === true).length; + if (successCount === 3) return 3회 출석; + if (successCount === 2) return 2회 출석; + if (successCount === 1) return 1회 출석; + return 결석; +} + +// ── ADMIN 뷰 ────────────────────────────────────────── +function AdminView() { + const [code, setCode] = useState(null); + const [hasCode, setHasCode] = useState(false); + + useEffect(() => { + const fetchActiveCode = async () => { + try { + const res = await authFetch('/api/admin/attendance/active-code'); + if (res.ok) { + const data = await res.json(); + if (!data.isExpired) { + setCode(data.code); + setHasCode(true); + } + } + } catch (e) {} + }; + fetchActiveCode(); + }, []); + + const handleGenerate = async () => { + const res = await authFetch('/api/admin/attendance/start', { method: 'POST' }); + const data = await res.json(); + setCode(data.code); + setHasCode(true); + }; + + const handleExpire = async () => { + await authFetch('/api/admin/attendance/active-code/expire', { method: 'PUT' }); + setCode(null); + setHasCode(false); + }; + + return ( + <> +
ATTENDANCE CHECK
+
+ {[0, 1, 2, 3].map((i) => ( +
+ {code ? code[i] : ''} +
+ ))} +
+
+ + {hasCode && ( + + )} + 출석 관리 +
+ + ); +} + +// ── MEMBER 뷰 ───────────────────────────────────────── +function MemberView() { + const [inputCode, setInputCode] = useState(''); + const [message, setMessage] = useState(''); + const [todaySlots, setTodaySlots] = useState([]); + const [history, setHistory] = useState([]); + + useEffect(() => { + // 1~5주차 기본값 세팅 + const defaultHistory = [1, 2, 3, 4, 5].map(week => ({ + week, + slots: [ + { status: false }, + { status: false }, + { status: false }, + ] + })); + + authFetch('/api/attendance/user') + .then(r => r.json()) + .then(data => { + const apiData = data.data || []; + // API 데이터로 해당 주차 덮어씌우기 + const merged = defaultHistory.map(def => { + const found = apiData.find(d => d.week === def.week); + return found || def; + }); + setHistory(merged); + }) + .catch(() => setHistory(defaultHistory)); +}, []); + + const handleSubmit = async () => { + if (!inputCode.trim()) return; + const res = await authFetch('/api/attendance/mark', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: inputCode }), + }); + const data = await res.json(); + const result = data.data; + + if (result.statusCode === 'SUCCESS') { + setMessage('출석 성공!'); + const today = new Date().toISOString().split('T')[0]; + authFetch(`/api/attendance/user/date?date=${today}`) + .then(r => r.json()) + .then(d => setTodaySlots(d.data || [])); + } else if (result.statusCode === 'INVALID_CODE') { + setMessage('출석 코드를 확인해주세요.'); + } else { + setMessage(result.message); + } + + setInputCode(''); + }; + + const displaySlots = [0, 1, 2].map(i => todaySlots[i] ?? { status: null }); + + return ( + <> +
ATTENDANCE CHECK
+ +
+ setInputCode(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSubmit()} + maxLength={4} + /> + +
+ + {message &&
{message}
} + +
+ {displaySlots.map((slot, i) => ( +
{cloverForSlot(slot.status)}
+ ))} +
+ +
+ {history.map((row, i) => ( +
+ {row.week}주차 +
+ {historyIcon(row.slots)} +
+
+ ))} +
+ + ); +} + +// ── 메인 컴포넌트 ───────────────────────────────────── +function Attendance() { + const role = localStorage.getItem('role') || 'MEMBER'; + + return ( +
+ {role === "ADMIN" ? : } +
+ ); +} + +export default Attendance; \ No newline at end of file diff --git a/frontend/src/pages/pirocheck/attendance/Attendance.module.css b/frontend/src/pages/pirocheck/attendance/Attendance.module.css new file mode 100644 index 0000000..7e72e9a --- /dev/null +++ b/frontend/src/pages/pirocheck/attendance/Attendance.module.css @@ -0,0 +1,167 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + padding: 60px 20px; + min-height: calc(80vh - 100px); + background: var(--black); + justify-content: center; +} + +.title { + font-family: var(--font-title); + font-size: 3.5rem; + font-weight: 800; + color: var(--main); + margin-bottom: 50px; + letter-spacing: 0.05em; +} + +/* ── ADMIN ── */ +.codebox { + display: flex; + gap: 16px; + margin-bottom: 40px; +} + +.code { + width: 90px; + height: 110px; + background: var(--gray600); + border-radius: 12px; + border: 1.5px solid var(--gray50); + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-title); + font-size: 3rem; + font-weight: 700; + color: var(--white); +} + +.manage { + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; +} + +.createBtn { + width: 220px; + padding: 14px 0; + background: var(--dark); + color: var(--white); + border: none; + border-radius: 10px; + font-family: var(--font-main); + font-size: 1.2rem; + font-weight: 550; + cursor: pointer; + transition: opacity 0.2s; +} + +.createBtn:hover { background: var(--main); transition: all ease-in-out 0.2s;} + +.manageLink { + color: var(--white); + font-family: var(--font-main); + font-size: 1rem; + text-decoration: none; + opacity: 0.7; + cursor: pointer; +} + +.manageLink:hover { opacity: 1; } + +/* ── MEMBER ── */ +.inputRow { + display: flex; + align-items: center; + background: var(--gray600); + border-radius: 10px; + overflow: hidden; + width: 480px; + margin-bottom: 16px; + padding: 3px 3px 3px 20px; +} + +.codeInput { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--white); + font-family: var(--font-main); + font-size: 1rem; +} + +.codeInput::placeholder { color: var(--gray200); } + +.submitBtn { + padding: 10px 20px; + background: var(--dark); + border: none; + border-radius: 10px; + color: var(--white); + font-family: var(--font-main); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: opacity 0.2s; +} + +.submitBtn:hover { background: var(--main); transition: all ease-in-out 0.2s; } + +.msg { + color: var(--main); + font-family: var(--font-main); + font-size: 0.95rem; + margin-bottom: 20px; + opacity: 0.9; +} + +.cloverSvg { + width: 70px; + height: 70px; +} + +.cloverRow { + display: flex; + gap: 50px; + margin-top: 40px; + margin-bottom: 40px; +} + +.weekLabel { + color: var(--white); + font-family: var(--font-main); + font-size: 1.1rem; + font-weight: 500; + min-width: 60px; +} + +.historyRow { + display: flex; + align-items: center; + justify-content: space-between; +} + +.histSvg { + width: 30px; + height: 30px; +} + +.historySlots { + display: flex; + gap: 30px; +} + +.historyBox { + background: var(--gray600); + border-radius: 16px; + padding: 28px 50px; + width: 250px; + display: flex; + flex-direction: column; + gap: 16px; +} \ No newline at end of file diff --git a/frontend/src/pages/pirocheck/deposit/Deposit.js b/frontend/src/pages/pirocheck/deposit/Deposit.js new file mode 100644 index 0000000..07a1ba2 --- /dev/null +++ b/frontend/src/pages/pirocheck/deposit/Deposit.js @@ -0,0 +1,46 @@ +import { useState, useEffect } from 'react'; +import { authFetch } from '../../../utils/Api'; +import styles from './Deposit.module.css'; + +const IS_MOCK = false + +function Deposit() { + const [deposit, setDeposit] = useState(null); + + useEffect(() => { + authFetch('/api/deposit/me') + .then(r => r.json()) + .then(data => setDeposit(data)) + .catch(() => {}); + }, []); + + if (!deposit) return null; + + return ( +
+
DEPOSIT CHECK
+ +
+
잔여 보증금
+
{deposit.amount.toLocaleString()}원
+
+ +
+
+ 과제 차감 + {deposit.descentAssignment.toLocaleString()}원 +
+
+ 출석 차감 + {deposit.descentAttendance.toLocaleString()}원 +
+
+ 보증금 방어권 + {deposit.ascentDefence.toLocaleString()}원 +
+
+
+ ); +} + +export default Deposit; \ No newline at end of file diff --git a/frontend/src/pages/pirocheck/deposit/Deposit.module.css b/frontend/src/pages/pirocheck/deposit/Deposit.module.css new file mode 100644 index 0000000..298bf8b --- /dev/null +++ b/frontend/src/pages/pirocheck/deposit/Deposit.module.css @@ -0,0 +1,88 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + padding: 60px 20px; + min-height: calc(100vh - 100px); + background: var(--black); +} + +.mockBanner { + background: #5a3e00; + color: #ffd166; + font-family: var(--font-main); + font-size: 0.85rem; + padding: 10px 20px; + border-radius: 8px; + margin-bottom: 20px; + text-align: center; + width: 400px; + box-sizing: border-box; +} + +.title { + font-family: var(--font-title); + font-size: 3rem; + font-weight: 800; + color: var(--main); + margin-bottom: 40px; + letter-spacing: 0; +} + +/* 잔여 보증금 박스 */ +.amountBox { + width: 400px; + background: #e8f5e9; + border-radius: 16px; + padding: 36px 20px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + margin-bottom: 24px; +} + +.amountLabel { + font-family: var(--font-main); + font-size: 1.5rem; + font-weight: 650; + color: var(--gray600); +} + +.amountValue { + font-family: var(--font-title); + font-size: 2rem; + font-weight: 800; + color: var(--main); +} + +/* 차감 목록 */ +.itemList { + width: 400px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.item { + background: var(--gray600); + border-radius: 12px; + padding: 20px 28px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.itemLabel { + font-family: var(--font-main); + font-size: 1.1rem; + font-weight: 500; + color: var(--white); +} + +.itemValue { + font-family: var(--font-main); + font-size: 1.1rem; + font-weight: 600; + color: var(--white); +} \ No newline at end of file diff --git a/frontend/src/pages/pirocheck/students/StudentDetail.js b/frontend/src/pages/pirocheck/students/StudentDetail.js new file mode 100644 index 0000000..37eb34a --- /dev/null +++ b/frontend/src/pages/pirocheck/students/StudentDetail.js @@ -0,0 +1,253 @@ +import { useState, useEffect } from 'react'; +import { useParams, useLocation } from 'react-router-dom'; +import { authFetch } from '../../../utils/Api'; +import styles from './StudentDetail.module.css'; +import ProfileImg from '../../../assets/images/profile.svg'; +import Logo2 from '../../../assets/images/logo2.svg'; +import Toggle1 from '../../../assets/images/icon_togle1.svg'; +import Toggle2 from '../../../assets/images/icon_togle2.svg'; + +const IS_MOCK = false; + +const dayLabel = { TUESDAY: 'TUE', THURSDAY: 'THU', SATURDAY: 'SAT' }; +const statusOptions = ['SUBMITTED', 'LATE', 'NOT_SUBMITTED']; +const statusLabel = { SUBMITTED: '성공', LATE: '미달', NOT_SUBMITTED: '실패' }; + +function WeekBlock({ weekData, onChange }) { + const [isOpen, setIsOpen] = useState(false); + const [openDays, setOpenDays] = useState({}); + + const toggleDay = (day) => { + setOpenDays(prev => ({ ...prev, [day]: !prev[day] })); + }; + + return ( +
+
setIsOpen(p => !p)}> +
+ logo + WEEK {weekData.week} +
+ toggle +
+ + {isOpen && ( +
+ {weekData.days.length === 0 && ( +
데이터가 없습니다.
+ )} + {weekData.days.map((day, i) => ( +
+
toggleDay(day.day)}> +
+ {dayLabel[day.day]} + {day.sessionTitles || day.sessionDate} +
+ toggle +
+ + {openDays[day.day] && ( +
+ {/* 출석 */} +
+ 출석 +
+ {day.attendances.map((att, j) => ( +
+ {att.attendanceOrder} + +
+ ))} +
+
+ + {/* 과제 */} + {day.assignments.length > 0 && ( +
+ 과제 +
+ {day.assignments.map((asg, j) => ( +
+ {asg.title} + +
+ ))} +
+
+ )} + + +
+ )} + + {i < weekData.days.length - 1 &&
} +
+ ))} +
+ )} +
+ ); +} + +function StudentDetail() { + const { userId } = useParams(); + const location = useLocation(); + const studentName = location.state?.name || '수강생'; + + const [data, setData] = useState(null); + const [defence, setDefence] = useState(''); + + useEffect(() => { + + const fetchData = async () => { + try { + // 보증금 조회 + const depositRes = await authFetch(`/api/deposit/${userId}/deposit/view`); + const depositData = await depositRes.json(); + setDefence(depositData.ascentDefence.toString()); + + // 주차별 출석/과제 조회 + const weekResults = await Promise.all( + [1, 2, 3, 4, 5].map(w => + authFetch(`/api/admin/admin/student/${userId}/status/${w}`) + .then(r => r.json()) + .catch(() => ({ week: w, days: [] })) + ) + ); + + setData({ + deposit: depositData, + weeks: weekResults, + }); + } catch (e) {} + }; + fetchData(); + }, [userId]); + + const handleSaveDefence = async () => { + await authFetch(`/api/deposit/${userId}/deposit/defence`, { + method: 'PATCH', + body: JSON.stringify({ ascentDefence: Number(defence) }), + }); + alert('저장됐습니다!'); + }; + + const handleStatusChange = (type, week, day, id, value) => { + setData(prev => { + const newWeeks = prev.weeks.map(w => { + if (w.week !== week) return w; + return { + ...w, + days: w.days.map(d => { + if (d.day !== day) return d; + if (type === 'attendance') { + return { + ...d, + attendances: d.attendances.map(a => + a.attendanceId === id ? { ...a, attended: value } : a + ), + }; + } else { + return { + ...d, + assignments: d.assignments.map(a => + a.assignmentItemId === id ? { ...a, submitted: value } : a + ), + }; + } + }), + }; + }); + return { ...prev, weeks: newWeeks }; + }); + }; + + const handleSaveAll = async () => { + await Promise.all( + data.weeks.map(w => + Promise.all( + w.days.map(d => { + const body = { + attendances: d.attendances.map(a => ({ + attendanceId: a.attendanceId, + status: a.attended, + })), + assignments: d.assignments.map(a => ({ + assignmentItemId: a.assignmentItemId, + submitted: a.submitted, + })), + }; + return authFetch(`/api/admin/users/${userId}/weeks/${w.week}`, { + method: 'PATCH', + body: JSON.stringify(body), + }); + }) + ) + ) + ); + alert('저장됐습니다!'); + }; + + if (!data) return null; + + return ( +
+
+
+ profile +
{studentName}
+
+ +
+
+
잔여 보증금
+
{data.deposit.amount.toLocaleString()}원
+
+
+
보증금 방어권
+
+ setDefence(e.target.value)} + /> + + +
+
+
+ + {data.weeks.map((w, i) => ( + + ))} + + +
+
+ ); +} + +export default StudentDetail; \ No newline at end of file diff --git a/frontend/src/pages/pirocheck/students/StudentDetail.module.css b/frontend/src/pages/pirocheck/students/StudentDetail.module.css new file mode 100644 index 0000000..e073df1 --- /dev/null +++ b/frontend/src/pages/pirocheck/students/StudentDetail.module.css @@ -0,0 +1,343 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + padding: 60px 20px; + min-height: calc(100vh - 100px); + background: var(--black); +} + +.mockBanner { + background: #5a3e00; + color: #ffd166; + font-family: var(--font-main); + font-size: 0.85rem; + padding: 10px 20px; + border-radius: 8px; + margin-bottom: 20px; + width: 480px; + box-sizing: border-box; + text-align: center; +} + +.card { + width: 480px; + background: var(--gray600); + border-radius: 20px; + padding: 40px 40px; + display: flex; + flex-direction: column; + gap: 20px; +} + +/* 프로필 */ +.profileArea { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.profileImg { + width: 100px; + height: 100px; + margin-left: 2px; + object-fit: cover; +} + +.profileName { + font-family: var(--font-main); + font-size: 1.8rem; + font-weight: 700; + color: var(--dark); +} + +/* 보증금 */ +.depositRow { + display: flex; + gap: 12px; +} + +.depositBoxGreen { + flex: 1; + background: var(--light); + border-radius: 10px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.depositBoxGray { + flex: 1; + background: var(--pale); + border-radius: 10px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.depositLabel { + font-family: var(--font-main); + font-size: 1.1rem; + font-weight: 600; + color: var(--gray600); +} + +.depositValue { + font-family: var(--font-main); + font-size: 1.2rem; + font-weight: 700; + color: var(--black); +} + +.depositEditRow { + display: flex; + align-items: center; + gap: 4px; +} + +.defenceInput { + width: 80px; + border: none; + background: transparent; + font-family: var(--font-main); + font-size: 1.2rem; + font-weight: 700; + color: var(--black); + outline: none; +} + +.won { + font-family: var(--font-main); + font-size: 1.2rem; + font-weight: 700; + color: var(--black); +} + +.saveBtn { + padding: 4px 10px; + background: var(--gray200); + border: none; + border-radius: 6px; + color: var(--gray600); + font-family: var(--font-main); + font-size: 0.75rem; + font-weight: 550; + cursor: pointer; + margin-left: 30px; +} + +.saveBtn:hover { background: var(--gray600); color: var(--white); transition: all ease-in-out 0.2s; } + +/* 주차 블록 */ +.weekBlock { + background: var(--gray600); + border-radius: 12px; + overflow: hidden; +} + +.weekHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + cursor: pointer; + color: var(--white); +} + +.weekLeft { + display: flex; + align-items: center; + gap: 10px; +} + +.weekLogo { + width: 30px; + height: 30px; + object-fit: contain; +} + +.weekLabel { + font-family: var(--font-main); + font-size: 1.3rem; + font-weight: 600; + color: var(--white); + margin-left: 5px; +} + +/* 토글 애니메이션 */ +.toggleIcon { + width: 15px; + height: 15px; + transition: transform 0.3s ease; +} + +.toggleIcon2 { + width: 20px; + height: 20px; + transition: transform 0.3s ease; + vertical-align: middle; +} + +.toggleOpen { + transform: rotate(180deg); +} + +.weekBody { + padding: 0 0 16px; + margin-left: 45px; + margin-right: 20px; +} + +.empty { + color: #aaa; + font-family: var(--font-main); + font-size: 1rem; + text-align: center; + padding: 12px 0; +} + +/* 요일 블록 */ + +.dayHeader { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + padding: 6px 0; +} + +.dayLeft { + display: flex; + align-items: center; + gap: 10px; +} + +.dayLabel { + color: var(--dark); + font-family: var(--font-main); + font-size: 1.2rem; + font-weight: 650; +} + +.sessionDate { + color: var(--light); + font-family: var(--font-main); + font-size: 1rem; + font-weight: 500; +} + +.dayBody { + padding: 5px 16px 0 5px; + display: flex; + flex-direction: column; + gap: 8px; +} + +/* 출석/과제 그룹 - 레이블 옆에 아이템들 */ +.statusGroup { + display: flex; + align-items: flex-start; + gap: 16px; +} + +.sectionLabel { + color: var(--white); + font-family: var(--font-main); + font-size: 1.1rem; + font-weight: 500; + min-width: 30px; + padding-top: 4px; +} + +.statusItems { + flex: 1; + display: flex; + flex-direction: column; + gap: 10px; +} + +.statusItem { + display: flex; + align-items: center; + justify-content: space-between; +} + +.itemLabel { + color: var(--white); + font-family: var(--font-main); + font-size: 1rem; + font-weight: 450; + padding-top: 3px; +} + +.select { + padding: 3px 28px 3px 12px; + width: 80px; + box-sizing: border-box; + + background: var(--gray600); + border: 1px solid var(--white); + border-radius: 6px; + color: var(--white); + font-family: var(--font-main); + font-size: 0.85rem; + cursor: pointer; + + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + + background-position: right 10px center; +} + +.select::-ms-expand { + display: none; +} + +.saveWeekBtn { + margin: 20px 0; + padding: 5px 30px; + background: transparent; + border: 1.5px solid var(--main); + border-radius: 10px; + color: var(--main); + font-family: var(--font-main); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + display: block; + margin-left: auto; + margin-right: auto; +} + +.saveWeekBtn:hover { background: var(--main); color: var(--white); transition: all ease-in-out 0.2s; } + +.divider { + border: none; + border-top: 1px solid #444; + margin: 10px 0; +} + +/* 전체 저장 */ +.saveAllBtn { + width: 60%; + margin: 0 auto; + padding: 10px 0; + background: var(--dark); + border: none; + border-radius: 10px; + color: var(--white); + font-family: var(--font-main); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; +} + +.saveAllBtn:hover { background: var(--main); transition: all ease-in-out 0.2s; } \ No newline at end of file diff --git a/frontend/src/pages/pirocheck/students/StudentList.js b/frontend/src/pages/pirocheck/students/StudentList.js new file mode 100644 index 0000000..802bfb4 --- /dev/null +++ b/frontend/src/pages/pirocheck/students/StudentList.js @@ -0,0 +1,61 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { authFetch } from '../../../utils/Api'; +import styles from './StudentList.module.css'; +import ArrowRight from '../../../assets/images/icon_arrow_right.svg'; + +const IS_MOCK = false; + +function StudentList() { + const navigate = useNavigate(); + const [students, setStudents] = useState([]); + const [search, setSearch] = useState(''); + + const fetchStudents = async (keyword = '') => { + try { + const url = keyword + ? `/api/admin/studentlist/search?name=${keyword}` + : '/api/admin/studentlist'; + const res = await authFetch(url); + const data = await res.json(); + setStudents(Array.isArray(data) ? data : data.data || []); + } catch (e) {} + }; + + useEffect(() => { fetchStudents(); }, []); + + const handleSearch = () => fetchStudents(search); + + return ( +
+ +
PIROGRAMMER
+ +
+ setSearch(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleSearch()} + /> + +
+ +
+ {students.map((s, i) => ( + + ))} +
+
+ ); +} + +export default StudentList; \ No newline at end of file diff --git a/frontend/src/pages/pirocheck/students/StudentList.module.css b/frontend/src/pages/pirocheck/students/StudentList.module.css new file mode 100644 index 0000000..4eea6e6 --- /dev/null +++ b/frontend/src/pages/pirocheck/students/StudentList.module.css @@ -0,0 +1,103 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + padding: 60px 20px; + min-height: calc(100vh - 100px); + background: var(--black); +} + +.mockBanner { + background: #5a3e00; + color: #ffd166; + font-family: var(--font-main); + font-size: 0.85rem; + padding: 10px 20px; + border-radius: 8px; + margin-bottom: 20px; + width: 480px; + box-sizing: border-box; + text-align: center; +} + +.title { + font-family: var(--font-title); + font-size: 3rem; + font-weight: 800; + color: var(--main); + margin-bottom: 36px; + letter-spacing: 0; +} + +.searchRow { + display: flex; + align-items: center; + background: var(--gray600); + border-radius: 10px; + overflow: hidden; + width: 480px; + margin-bottom: 30px; + padding: 6px 6px 6px 20px; +} + +.searchInput { + flex: 1; + background: transparent; + border: none; + outline: none; + font-family: var(--font-main); + font-size: 1rem; + color: var(--white); +} + +.searchInput::placeholder { color: var(--gray200); } + +.searchBtn { + padding: 7px 15px; + background: var(--dark); + border: none; + border-radius: 10px; + color: var(--white); + font-family: var(--font-main); + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; +} + +.searchBtn:hover { + background: var(--main); + transition: all ease-in-out 0.2s; +} + +.list { + width: 450px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.studentItem { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--gray600); + border: 1px solid var(--gray50); + border-radius: 10px; + padding: 7px 15px 7px 20px; + cursor: pointer; + transition: background 0.2s; + width: 100%; +} + +.studentItem:hover { background: #4a4a4a; } + +.studentName { + font-family: var(--font-main); + font-size: 1rem; + font-weight: 500; + color: var(--white); +} + +.arrow { + width: 25px; height: 25px; +} \ No newline at end of file diff --git a/frontend/src/pages/qna/QnADetailPage.js b/frontend/src/pages/qna/QnADetailPage.js new file mode 100644 index 0000000..a23ba91 --- /dev/null +++ b/frontend/src/pages/qna/QnADetailPage.js @@ -0,0 +1,507 @@ +import '../../assets/styles/global.css'; +import { useState, useEffect, useRef } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import styles from './QnADetailPage.module.css'; +import { FiMoreVertical, FiCornerDownRight } from 'react-icons/fi'; +import { + CommentImoji, + MeCuriousToo, + StaffCheck, + SumitBtn, + uploadImage, +} from '../../utils/qnaUtils'; +import profileImg from '../../assets/images/profile.png'; +import { authFetch } from '../../utils/Api'; + +// 시간만 표시하는 포맷 함수 (HH:MM) +const formatTime = (dateStr) => { + if (!dateStr) return ''; + const d = new Date(dateStr); + return d.toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); +}; + +function QnADetailPage() { + const { questionId } = useParams(); + const navigate = useNavigate(); + const isStaff = localStorage.getItem('role') === 'ADMIN'; + + // ── 질문 / 로딩 상태 ───────────────────────────── + const [question, setQuestion] = useState(null); + const [loading, setLoading] = useState(true); + + // ── 질문 수정 상태 ─────────────────────────────── + const [showMenu, setShowMenu] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editText, setEditText] = useState(''); + + // ── 댓글 입력 상태 ─────────────────────────────── + const [commentText, setCommentText] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [selectedImage, setSelectedImage] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + const fileInputRef = useRef(null); + + // ── 댓글 수정 상태 ─────────────────────────────── + const [commentMenuId, setCommentMenuId] = useState(null); + const [editingCommentId, setEditingCommentId] = useState(null); + const [editCommentText, setEditCommentText] = useState(''); + + // ── 질문 불러오기 ──────────────────────────────── + useEffect(() => { + const fetchQuestion = async () => { + try { + setLoading(true); + const res = await authFetch(`/api/questions/${questionId}`); + if (!res.ok) throw new Error(`서버 오류: ${res.status}`); + const json = await res.json(); + if (!json.isSuccess) throw new Error(json.message); + + const result = json.result; + + // 질문 이미지 blob 변환 + if (result.imageUrl) { + try { + const imgRes = await authFetch(result.imageUrl); + const blob = await imgRes.blob(); + result.imageUrl = URL.createObjectURL(blob); + } catch { + result.imageUrl = null; + } + } + + // 댓글 이미지 blob 변환 + if (result.comments) { + result.comments = await Promise.all( + result.comments.map(async (comment) => { + if (comment.imageUrl) { + try { + const imgRes = await authFetch(comment.imageUrl); + const blob = await imgRes.blob(); + return { ...comment, imageUrl: URL.createObjectURL(blob) }; + } catch { + return { ...comment, imageUrl: null }; + } + } + return comment; + }) + ); + } + setQuestion(result); + } catch (err) { + console.error('질문 불러오기 실패:', err); + } finally { + setLoading(false); + } + }; + if (questionId) fetchQuestion(); + }, [questionId]); + + // ── 메뉴 외부 클릭 시 닫기 ────────────────────── + useEffect(() => { + const handleClickOutside = () => { + setShowMenu(false); + setCommentMenuId(null); + }; + if (showMenu || commentMenuId) document.addEventListener('click', handleClickOutside); + return () => document.removeEventListener('click', handleClickOutside); + }, [showMenu, commentMenuId]); + + // ── 좋아요 토글 ────────────────────────────────── + const toggleLike = async () => { + try { + const res = await authFetch(`/api/questions/${questionId}/like`, { method: 'POST' }); + if (!res.ok) throw new Error(); + const json = await res.json(); + if (json.isSuccess) { + setQuestion(prev => ({ + ...prev, + likeCount: json.result.likeCount, + isLiked: json.result.isLiked, + })); + } + } catch (err) { + console.error('좋아요 실패:', err); + } + }; + + // ── 질문 수정 ──────────────────────────────────── + const handleEditStart = () => { + setEditText(question.content); + setIsEditing(true); + setShowMenu(false); + }; + + const handleEditSubmit = async () => { + const text = editText.trim(); + if (!text) return; + try { + const res = await authFetch(`/api/questions/${questionId}/modify`, { + method: 'PATCH', + body: JSON.stringify({ content: text }), + }); + if (!res.ok) throw new Error(); + const json = await res.json(); + if (json.isSuccess) { + setQuestion(prev => ({ ...prev, content: text })); + setIsEditing(false); + } + } catch (err) { + console.error('수정 실패:', err); + } + }; + + // ── 질문 삭제 ──────────────────────────────────── + const handleDelete = async () => { + if (!window.confirm('질문을 삭제할까요?')) return; + try { + const res = await authFetch(`/api/questions/${questionId}`, { method: 'DELETE' }); + if (!res.ok) throw new Error(); + navigate(-1); + } catch (err) { + console.error('삭제 실패:', err); + } + setShowMenu(false); + }; + + // ── 질문 해결됨 처리 (운영진 전용) ────────────── + const handleResolve = async () => { + try { + const res = await authFetch(`/api/questions/${questionId}/status`, { method: 'PATCH' }); + if (!res.ok) throw new Error(); + setQuestion(prev => ({ ...prev, isResolved: true })); + } catch (err) { + console.error('해결됨 처리 실패:', err); + } + setShowMenu(false); + }; + + // ── 댓글 이미지 선택 / 붙여넣기 ───────────────── + const handleImageSelect = (e) => { + const file = e.target.files[0]; + if (!file) return; + setSelectedImage(file); + setImagePreview(URL.createObjectURL(file)); + }; + + const handlePaste = (e) => { + const items = e.clipboardData?.items; + if (!items) return; + for (const item of items) { + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) { + setSelectedImage(file); + setImagePreview(URL.createObjectURL(file)); + } + break; + } + } + }; + + // ── 댓글 등록 ──────────────────────────────────── + const handleCommentSubmit = async () => { + const text = commentText.trim(); + if (!text) return; + setIsSubmitting(true); + try { + let imageUrl = null; + if (selectedImage) { + imageUrl = await uploadImage(selectedImage); + } + const res = await authFetch(`/api/questions/${questionId}/comments`, { + method: 'POST', + body: JSON.stringify({ content: text, parentCommentId: null, imageUrl }), + }); + if (!res.ok) throw new Error(); + const json = await res.json(); + if (json.isSuccess) { + // 댓글 등록 응답값으로 해결 상태 반영 + setQuestion(prev => ({ ...prev, isResolved: json.result.isResolved })); + + const newComment = { + commentId: json.result.commentId, + displayName: json.result.displayName, + content: json.result.content, + createdAt: json.result.createdAt, + imageUrl: imagePreview, + isMine: true, + }; + setQuestion(prev => ({ + ...prev, + comments: [...(prev.comments ?? []), newComment], + })); + setCommentText(''); + setSelectedImage(null); + setImagePreview(null); + } + } catch (err) { + console.error('댓글 등록 실패:', err); + } finally { + setIsSubmitting(false); + } + }; + + // ── 댓글 삭제 ──────────────────────────────────── + const handleCommentDelete = async (commentId) => { + if (!window.confirm('댓글을 삭제할까요?')) return; + try { + const res = await authFetch(`/api/comments/${commentId}`, { method: 'DELETE' }); + if (!res.ok) throw new Error(); + setQuestion(prev => ({ + ...prev, + comments: prev.comments.filter(c => c.commentId !== commentId), + })); + } catch (err) { + console.error('댓글 삭제 실패:', err); + } + setCommentMenuId(null); + }; + + // ── 댓글 수정 ──────────────────────────────────── + const handleCommentEditStart = (comment) => { + setEditingCommentId(comment.commentId); + setEditCommentText(comment.content); + setCommentMenuId(null); + }; + + const handleCommentEditSubmit = async (commentId) => { + const text = editCommentText.trim(); + if (!text) return; + try { + const res = await authFetch(`/api/comments/${commentId}`, { + method: 'PATCH', + body: JSON.stringify({ content: text }), + }); + if (!res.ok) throw new Error(); + const json = await res.json(); + if (json.isSuccess) { + setQuestion(prev => ({ + ...prev, + comments: prev.comments.map(c => + c.commentId === commentId ? { ...c, content: text } : c + ), + })); + setEditingCommentId(null); + } + } catch (err) { + console.error('댓글 수정 실패:', err); + } + }; + + if (loading) return
불러오는 중...
; + if (!question) return
질문을 찾을 수 없어요
; + + const isMyQuestion = question.isMine; + + return ( +
+ + {/* ── 작성자 행 ── */} +
+
+ {question.displayName} +
+
+ 익명 + {formatTime(question.createdAt)} +
+ {(isMyQuestion || isStaff) && ( +
+ + {showMenu && ( +
+ {isMyQuestion && ( + <> + + + + )} + {isStaff && !isMyQuestion && ( + <> + + {!question.isResolved && ( + + )} + + )} +
+ )} +
+ )} +
+ + {/* ── 해결 여부 뱃지 ── */} +
+ {question.isResolved ? ( + 해결 질문 + ) : ( + 미해결 질문 + )} +
+ + {/* ── 질문 본문 ── */} +
+ Q. + {isEditing ? ( +
+