diff --git a/README.md b/README.md index 96cbc03..edf88a6 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,16 @@ > **이 저장소는 여러분(수습생)의 것입니다.** > 미림마이스터고 수습생 4명이 직접 보고, 배우고, 고치는 학습용 웹 앱입니다. -> 처음에는 문서를 읽는 곳이지만, 5~6주차부터는 여러분이 직접 기능을 붙이고 버그를 고치는 실전 연습장이 됩니다. +> 처음에는 문서를 읽는 곳이지만, 5주차부터는 여러분이 직접 기능을 붙이고 버그를 고치는 실전 연습장이 됩니다. > 코드를 읽는 법이 궁금하면 [STUDY.md](./STUDY.md)부터 열어 보세요. ## 무엇을 하는 앱인가? -- 수습 기간(6주) 동안 필요한 **학습 문서**(가이드·커리큘럼·수업자료·과제)를 한곳에서 봅니다. -- 주차별 **체크리스트**로 오늘 할 일을 확인하고 체크합니다. -- **과제를 제출**하고, 멘토에게 **피드백**을 받습니다. -- 멘토는 전체 제출물을 한 화면에서 검토합니다. +- 수습 기간(**8주**, 9월~10월) 동안 필요한 **학습 문서**(가이드·커리큘럼·수업자료·과제·실습가이드)를 한곳에서 봅니다. +- **학습 센터**에서 컴퓨터 기초부터 디자인까지 **강좌 71개**를 순서대로 배우고, 각 코스 끝에서 **퀴즈**로 확인합니다. +- **코딩 문제 25개**(JavaScript·SQL·Java)를 브라우저에서 직접 풀고 자동 채점받습니다. +- 주차별 **체크리스트**로 오늘 할 일을 확인하고, **과제를 제출**하면 멘토가 **피드백**을 남깁니다. +- 멘토는 학생별 진도·제출물·인적사항을 한 화면에서 관리합니다. ## 기술 스택 @@ -36,17 +37,18 @@ mirim-app/ │ │ ├── domain/ ← 엔티티(DB 테이블과 짝이 되는 클래스) │ │ ├── repository/ ← DB를 읽고 쓰는 계층 │ │ ├── service/ ← 비즈니스 로직(규칙) 계층 -│ │ ├── controller/ ← HTTP 요청을 받는 계층 +│ │ ├── web/ ← HTTP 요청을 받는 계층(컨트롤러들) │ │ └── config/ ← 보안·시드 등 설정 │ └── resources/ │ ├── application.yml ← 서버 설정(DB 접속 정보 등) -│ └── seed/ ← 초기 데이터 JSON (문서·체크리스트·과제) +│ └── seed/ ← 초기 데이터 JSON (문서·체크리스트·과제·퀴즈·코딩문제) └── frontend/ ← React 화면 ├── package.json ← JS 의존성 목록 ├── index.html ← 브라우저가 처음 여는 파일 - ├── public/docs/ ← 학습 문서 HTML 17개 (앱이 iframe으로 보여줌) + ├── public/docs/ ← 학습 문서 HTML 19개 (앱이 iframe으로 보여줌) └── src/ ├── pages/ ← 화면 단위 컴포넌트 (로그인, 대시보드, 문서, 과제…) + │ └── courses/ ← 학습 센터의 강좌 페이지들 (코스 하나당 파일 하나) ├── components/ ← 여러 화면에서 재사용하는 조각 컴포넌트 └── api/ ← 백엔드 API를 부르는 함수 모음 ``` @@ -82,12 +84,14 @@ npm run dev ## 초기 계정 +시드로는 **멘토 계정(mentor1) 하나만** 만들어집니다. 학생은 첫날 각자 **회원가입**으로 등록해요 +(개인정보를 본인이 입력하는 게 정확하고, "받은 비밀번호를 바꾸는 것이 첫 일"을 배우니까요). + | 아이디 | 비밀번호 | 역할 | |---|---|---| -| student1 ~ student4 | `mirim2026!` | 수습생 (STUDENT) | -| mentor1 | `mirim2026!` | 멘토 (MENTOR) | +| mentor1 | `MENTOR_INITIAL_PASSWORD` 환경변수, 없으면 부팅 로그에 1회 출력되는 랜덤값 | 멘토 (MENTOR) | -> ⚠️ **실서비스로 쓰기 전에는 반드시 비밀번호를 변경하세요.** 이 계정들은 로컬 학습용입니다. +> ⚠️ 소스에 박힌 기본 비밀번호는 없습니다 — 첫 부팅 로그에서 멘토 비밀번호를 확인하고, **로그인 후 즉시 변경**하세요. ## 환경변수 diff --git a/STUDY.md b/STUDY.md index a57d770..7587a20 100644 --- a/STUDY.md +++ b/STUDY.md @@ -25,7 +25,7 @@ LoginPage.jsx → api/client.js → (HTTP) → AuthController → AuthService - **관찰 포인트**: 왜 페이지마다 fetch를 직접 쓰지 않고 한 파일에 모았을까? (주소가 바뀌면 한 곳만 고치면 됨, 에러 처리를 한 번만 작성하면 됨.) - 여기서 브라우저를 떠나 HTTP 요청이 네트워크를 타고 백엔드(8080)로 갑니다. 개발자도구(F12) → Network 탭에서 이 요청을 직접 눈으로 확인해 보세요. -### ③ `backend/.../controller/AuthController.java` — 서버의 현관문 +### ③ `backend/.../web/AuthController.java` — 서버의 현관문 - `@PostMapping("/api/auth/login")`이 붙은 메서드를 찾으세요. - 컨트롤러는 얇습니다: JSON을 받아서 → 서비스에 넘기고 → 결과를 JSON으로 돌려줄 뿐. 판단하지 않습니다. - **관찰 포인트**: 요청 JSON이 어떻게 자바 객체로 변하는지(`@RequestBody`). @@ -43,7 +43,7 @@ LoginPage.jsx → api/client.js → (HTTP) → AuthController → AuthService - 여기까지 오면 여행 끝. 응답은 왔던 길을 **거꾸로** 타고 브라우저까지 돌아가고, LoginPage는 받은 사용자 정보로 화면을 바꿉니다. > ✅ **확인 과제**: 같은 방식으로 "체크리스트에 체크했을 때"의 여행 경로를 종이에 그려 보세요. -> 힌트: `POST /api/progress/{itemId}/toggle`에서 출발해서, ChecklistPage → client.js → ProgressController → ProgressService → ProgressRepository 순서로 찾으면 됩니다. +> 힌트: `POST /api/progress/{itemId}/toggle`에서 출발해서, DashboardPage(주차별 체크리스트가 여기 있어요) → client.js → ProgressController → ProgressService → ProgressRepository 순서로 찾으면 됩니다. --- @@ -56,13 +56,13 @@ LoginPage.jsx → api/client.js → (HTTP) → AuthController → AuthService 한 문장으로: **pages는 조립, components는 부품, api는 배달.** -### 백엔드: controller / service / repository / domain -- **controller/** — HTTP를 아는 유일한 계층. 요청을 받고 응답을 돌려주는 현관문. +### 백엔드: web / service / repository / domain +- **web/** — HTTP를 아는 유일한 계층(컨트롤러들이 여기 있어요). 요청을 받고 응답을 돌려주는 현관문. - **service/** — 규칙과 판단. "멘토만 피드백을 쓸 수 있다", "같은 과제를 다시 제출하면 덮어쓴다" 같은 우리 서비스의 법. - **repository/** — DB를 읽고 쓰는 일만. SQL 걱정은 여기(와 JPA)가 다 합니다. - **domain/** — 데이터의 모양 정의. User, Document, Assignment 같은 "명사"들. -왜 나누냐면: **한 파일에 다 쓰면 처음엔 빠르지만, 고칠 때 지옥이 됩니다.** "비밀번호 규칙을 바꿔라"라는 요청이 오면 service만 보면 되고, "응답 JSON에 필드를 추가해라"면 controller 근처만 보면 됩니다. 계층은 "어디를 고쳐야 하는지"를 알려주는 주소 체계입니다. +왜 나누냐면: **한 파일에 다 쓰면 처음엔 빠르지만, 고칠 때 지옥이 됩니다.** "비밀번호 규칙을 바꿔라"라는 요청이 오면 service만 보면 되고, "응답 JSON에 필드를 추가해라"면 web(컨트롤러) 근처만 보면 됩니다. 계층은 "어디를 고쳐야 하는지"를 알려주는 주소 체계입니다. --- @@ -77,29 +77,29 @@ LoginPage.jsx → api/client.js → (HTTP) → AuthController → AuthService ### 과제 2. 문서 카드에 즐겨찾기 버튼 달기 — 프론트만 (난이도 ★★) - **목표**: 문서 목록의 각 카드에 ☆ 버튼을 달고, 누르면 ★로 바뀐다. 새로고침하면 사라져도 됩니다(서버 저장 없음). -- **건드릴 파일**: `frontend/src/pages/DocumentsPage.jsx` (카드가 별도 컴포넌트라면 `frontend/src/components/` 안의 카드 파일) +- **건드릴 파일**: `frontend/src/pages/DocsPage.jsx` (카드는 `frontend/src/components/Card.jsx`) - **힌트**: `useState`로 즐겨찾기된 문서 id 배열을 들고, 버튼 클릭 시 배열에 넣거나 빼면 됩니다. 도전 과제: `localStorage`에 저장해서 새로고침을 버텨 보세요. ### 과제 3. 체크리스트 항목 검색 (난이도 ★★) - **목표**: 체크리스트 페이지에 검색창을 달아서, 입력한 글자가 포함된 항목만 보여준다. -- **건드릴 파일**: `frontend/src/pages/ChecklistPage.jsx` +- **건드릴 파일**: `frontend/src/pages/DashboardPage.jsx` (주차별 체크리스트가 여기 있어요) - **힌트**: 검색어를 `useState`로 들고, 화면에 그리기 직전에 `items.filter(item => item.label.includes(검색어))`. 서버 API를 바꿀 필요가 없다는 것 자체가 학습 포인트입니다 — 이미 받아 온 데이터는 프론트에서 거를 수 있습니다. ### 과제 4. API 응답에 필드 추가해 보기 (난이도 ★★★, 백엔드 첫 수정) - **목표**: `GET /api/auth/me` 응답에 `createdAt`(가입 시각)을 추가하고, 화면 어딘가에 "함께한 지 N일째"를 표시한다. -- **건드릴 파일**: `backend/.../controller/AuthController.java`(응답 DTO에 필드 추가) → `frontend/src/api/client.js`는 그대로 → 표시할 페이지 컴포넌트 +- **건드릴 파일**: `backend/.../web/AuthController.java`(응답 DTO에 필드 추가) → `frontend/src/api/client.js`는 그대로 → 표시할 페이지 컴포넌트 - **힌트**: User 엔티티에는 `createdAt`이 이미 있습니다. 응답으로 내보내는 record/DTO에 한 필드만 추가하면 됩니다. 백엔드를 재시작한 뒤 브라우저 Network 탭에서 응답 JSON에 필드가 생겼는지 먼저 확인하고, 그다음 화면을 고치세요. **한 번에 한 층씩 확인하는 습관**이 이 과제의 진짜 목표입니다. ### 과제 5. 빈 상태(empty state) 화면 넣기 (난이도 ★★) - **목표**: 제출한 과제가 하나도 없을 때, 휑한 빈 목록 대신 "아직 제출한 과제가 없어요. 이번 주 과제부터 시작해 볼까요?" 같은 안내를 보여준다. -- **건드릴 파일**: `frontend/src/pages/SubmissionsPage.jsx` (내 제출물 페이지) +- **건드릴 파일**: `frontend/src/pages/AssignmentsPage.jsx` (과제 목록·제출 화면) - **힌트**: `submissions.length === 0`일 때 다른 JSX를 그리면 됩니다. 좋은 서비스는 "데이터가 없을 때"를 항상 설계합니다 — 실무에서 정말 자주 하는 일입니다. --- ## 4. 이 저장소는 여러분의 연습장입니다 -5~6주차 실전 티켓 기간에는 이 앱 자체가 작업 대상이 됩니다. 지금 문서를 보고 과제를 제출하는 데 쓰는 바로 이 코드에, 여러분이 만든 기능이 붙습니다. +5주차부터 시작하는 실전 티켓 기간에는 이 앱 자체가 작업 대상이 됩니다. 지금 문서를 보고 과제를 제출하는 데 쓰는 바로 이 코드에, 여러분이 만든 기능이 붙습니다. - 위 미니 과제 5개는 실전 티켓의 축소판입니다. 티켓도 결국 "어느 파일을, 왜, 어떻게 고칠지"를 찾는 일입니다. - 브랜치를 파서 마음껏 실험하세요. 망가뜨려도 `git checkout`으로 돌아올 수 있고, 망가뜨려 본 만큼 빨리 늡니다. diff --git a/backend/src/main/java/dev/awesomedev/mirim/repository/CodingSubmissionRepository.java b/backend/src/main/java/dev/awesomedev/mirim/repository/CodingSubmissionRepository.java index 260b3be..02c1b61 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/repository/CodingSubmissionRepository.java +++ b/backend/src/main/java/dev/awesomedev/mirim/repository/CodingSubmissionRepository.java @@ -25,4 +25,7 @@ public interface CodingSubmissionRepository extends JpaRepository findAllByOrderBySubmittedAtDesc(); + + /** 이 사용자의 모든 행을 지운다 (학생 계정 파기 시 자식 데이터부터 정리). */ + void deleteByUser(User user); } diff --git a/backend/src/main/java/dev/awesomedev/mirim/repository/CourseProgressRepository.java b/backend/src/main/java/dev/awesomedev/mirim/repository/CourseProgressRepository.java index 6f12fde..95021a5 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/repository/CourseProgressRepository.java +++ b/backend/src/main/java/dev/awesomedev/mirim/repository/CourseProgressRepository.java @@ -20,4 +20,7 @@ public interface CourseProgressRepository extends JpaRepository findByUserAndCourseSlug(User user, String courseSlug); + + /** 이 사용자의 모든 행을 지운다 (학생 계정 파기 시 자식 데이터부터 정리). */ + void deleteByUser(User user); } diff --git a/backend/src/main/java/dev/awesomedev/mirim/repository/ProgressRepository.java b/backend/src/main/java/dev/awesomedev/mirim/repository/ProgressRepository.java index 968c3f5..5b59974 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/repository/ProgressRepository.java +++ b/backend/src/main/java/dev/awesomedev/mirim/repository/ProgressRepository.java @@ -19,4 +19,7 @@ public interface ProgressRepository extends JpaRepository { /** 특정 사용자의 전체 체크 기록 조회 */ List findByUser(User user); + + /** 이 사용자의 모든 행을 지운다 (학생 계정 파기 시 자식 데이터부터 정리). */ + void deleteByUser(User user); } diff --git a/backend/src/main/java/dev/awesomedev/mirim/repository/QuizResultRepository.java b/backend/src/main/java/dev/awesomedev/mirim/repository/QuizResultRepository.java index 11dbf82..7db3900 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/repository/QuizResultRepository.java +++ b/backend/src/main/java/dev/awesomedev/mirim/repository/QuizResultRepository.java @@ -21,4 +21,7 @@ public interface QuizResultRepository extends JpaRepository { * "순서가 필요하면 반드시 ORDER BY로 말한다" — 우연히 맞는 코드는 언젠가 틀린다. */ List findByUserOrderBySubmittedAtDesc(User user); + + /** 이 사용자의 모든 행을 지운다 (학생 계정 파기 시 자식 데이터부터 정리). */ + void deleteByUser(User user); } diff --git a/backend/src/main/java/dev/awesomedev/mirim/repository/StudentProfileRepository.java b/backend/src/main/java/dev/awesomedev/mirim/repository/StudentProfileRepository.java index eab05f8..4b2de4d 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/repository/StudentProfileRepository.java +++ b/backend/src/main/java/dev/awesomedev/mirim/repository/StudentProfileRepository.java @@ -33,4 +33,7 @@ public interface StudentProfileRepository extends JpaRepository findAllWithUser(); + + /** 이 사용자의 프로필을 지운다 (학생 계정 파기 시). */ + void deleteByUser(User user); } diff --git a/backend/src/main/java/dev/awesomedev/mirim/repository/SubmissionRepository.java b/backend/src/main/java/dev/awesomedev/mirim/repository/SubmissionRepository.java index 5159f65..3cecc35 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/repository/SubmissionRepository.java +++ b/backend/src/main/java/dev/awesomedev/mirim/repository/SubmissionRepository.java @@ -26,4 +26,7 @@ public interface SubmissionRepository extends JpaRepository { /** 전체 제출물을 최신 제출 순으로 조회 (멘토 리뷰 화면용) */ List findAllByOrderBySubmittedAtDesc(); + + /** 이 사용자의 모든 행을 지운다 (학생 계정 파기 시 자식 데이터부터 정리). */ + void deleteByUser(User user); } diff --git a/backend/src/main/java/dev/awesomedev/mirim/service/StudentPurgeService.java b/backend/src/main/java/dev/awesomedev/mirim/service/StudentPurgeService.java new file mode 100644 index 0000000..751ec93 --- /dev/null +++ b/backend/src/main/java/dev/awesomedev/mirim/service/StudentPurgeService.java @@ -0,0 +1,90 @@ +package dev.awesomedev.mirim.service; + +import dev.awesomedev.mirim.domain.User; +import dev.awesomedev.mirim.repository.CodingSubmissionRepository; +import dev.awesomedev.mirim.repository.CourseProgressRepository; +import dev.awesomedev.mirim.repository.ProgressRepository; +import dev.awesomedev.mirim.repository.QuizResultRepository; +import dev.awesomedev.mirim.repository.StudentProfileRepository; +import dev.awesomedev.mirim.repository.SubmissionRepository; +import dev.awesomedev.mirim.repository.UserRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +/** + * 이 파일이 하는 일: + * 학생 계정과 그 학생의 모든 개인정보·학습기록을 "완전히" 지운다(파기). + * + * 학습 포인트 ① — 왜 이 기능이 필요한가(법적 의무). + * 가입 화면에서 "퇴직 시 관계 법령이 정한 기간이 지나면 지체 없이 파기한다"고 약속했다. + * 약속했으면 실제로 지울 수단이 있어야 한다 — 없으면 그 동의문은 거짓말이 된다. + * 개인정보보호법상 목적이 끝난 개인정보는 파기해야 하고, 특히 미성년 정보라 더 엄격하다. + * + * 학습 포인트 ② — 삭제 순서(외래키 제약). + * users 테이블은 여러 자식 테이블(제출·진도·퀴즈결과·프로필…)이 user_id로 참조한다. + * 부모(users)를 먼저 지우려 하면 "아직 널 가리키는 자식이 있어"라며 DB가 거부한다(FK 제약). + * 그래서 반드시 "자식 먼저, 부모 나중" 순서로 지운다. 이 순서 자체가 데이터 관계의 지도다. + * + * 학습 포인트 ③ — @Transactional로 "전부 아니면 전무". + * 여러 테이블을 지우다 중간에 실패하면 "제출만 지워지고 프로필은 남은" 반쪽 상태가 된다. + * 한 트랜잭션으로 묶어, 하나라도 실패하면 전부 되돌린다(원자성). + */ +@Service +public class StudentPurgeService { + + private final UserRepository userRepository; + private final StudentProfileRepository studentProfileRepository; + private final SubmissionRepository submissionRepository; + private final CodingSubmissionRepository codingSubmissionRepository; + private final ProgressRepository progressRepository; + private final CourseProgressRepository courseProgressRepository; + private final QuizResultRepository quizResultRepository; + private final TelegramNotifier telegramNotifier; + + public StudentPurgeService(UserRepository userRepository, + StudentProfileRepository studentProfileRepository, + SubmissionRepository submissionRepository, + CodingSubmissionRepository codingSubmissionRepository, + ProgressRepository progressRepository, + CourseProgressRepository courseProgressRepository, + QuizResultRepository quizResultRepository, + TelegramNotifier telegramNotifier) { + this.userRepository = userRepository; + this.studentProfileRepository = studentProfileRepository; + this.submissionRepository = submissionRepository; + this.codingSubmissionRepository = codingSubmissionRepository; + this.progressRepository = progressRepository; + this.courseProgressRepository = courseProgressRepository; + this.quizResultRepository = quizResultRepository; + this.telegramNotifier = telegramNotifier; + } + + /** + * 한 학생의 모든 것을 파기한다. 멘토만 호출할 수 있다(컨트롤러에서 권한 보장). + * 되돌릴 수 없는 작업이라, 대상이 STUDENT가 아니면(멘토 등) 막는다. + */ + @Transactional + public void purge(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "학생을 찾을 수 없습니다.")); + if (!"STUDENT".equals(user.getRole())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "학생 계정만 파기할 수 있습니다."); + } + + // 1) 자식 데이터부터 (참조하는 쪽 먼저) + submissionRepository.deleteByUser(user); + codingSubmissionRepository.deleteByUser(user); + progressRepository.deleteByUser(user); + courseProgressRepository.deleteByUser(user); + quizResultRepository.deleteByUser(user); + studentProfileRepository.deleteByUser(user); + // 2) 마지막으로 계정 본체 + userRepository.delete(user); + + // 멘토에게 파기 사실을 알린다 — 개인정보 파기는 기록으로 남겨야 하는 "사건"이다. + telegramNotifier.send("🗑️ 학생 계정 파기 완료: " + user.getName() + " (" + user.getUsername() + ")" + + "\n제출·진도·퀴즈·프로필·계정을 모두 삭제했습니다."); + } +} diff --git a/backend/src/main/java/dev/awesomedev/mirim/web/MentorController.java b/backend/src/main/java/dev/awesomedev/mirim/web/MentorController.java index 5f531dc..ca5192b 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/web/MentorController.java +++ b/backend/src/main/java/dev/awesomedev/mirim/web/MentorController.java @@ -9,6 +9,7 @@ import dev.awesomedev.mirim.service.CourseProgressService; import dev.awesomedev.mirim.service.ProgressService; import dev.awesomedev.mirim.service.QuizService; import dev.awesomedev.mirim.service.StudentProfileService; +import dev.awesomedev.mirim.service.StudentPurgeService; import dev.awesomedev.mirim.service.SubmissionService; import dev.awesomedev.mirim.web.dto.ChecklistProgressOverviewResponse; import dev.awesomedev.mirim.web.dto.FeedbackRequest; @@ -48,6 +49,7 @@ public class MentorController { private final CodingService codingService; private final AuthService authService; private final StudentProfileService studentProfileService; + private final StudentPurgeService studentPurgeService; private final UserRepository userRepository; public MentorController(SubmissionService submissionService, @@ -57,6 +59,7 @@ public class MentorController { CodingService codingService, AuthService authService, StudentProfileService studentProfileService, + StudentPurgeService studentPurgeService, UserRepository userRepository) { this.submissionService = submissionService; this.courseProgressService = courseProgressService; @@ -65,6 +68,7 @@ public class MentorController { this.codingService = codingService; this.authService = authService; this.studentProfileService = studentProfileService; + this.studentPurgeService = studentPurgeService; this.userRepository = userRepository; } @@ -169,6 +173,18 @@ public class MentorController { authService.resetPasswordByMentor(id, request.newPassword()); } + /** + * DELETE /api/mentor/students/{id} — 학생 계정과 모든 개인정보·학습기록을 파기한다. + * + * 학습 포인트: 되돌릴 수 없는 작업이라 (1) 멘토 권한(/api/mentor/**)으로 보호하고, + * (2) 서비스가 대상이 정말 STUDENT인지 다시 확인하며, (3) DELETE 메서드로 "삭제"라는 + * 의도를 HTTP 동사로 분명히 한다. 동의 화면에서 약속한 "파기"를 실제로 지키는 손이다. + */ + @DeleteMapping("/students/{id}") + public void purgeStudent(@PathVariable Long id) { + studentPurgeService.purge(id); + } + /** POST /api/mentor/submissions/{id}/feedback — 피드백 등록, 상태는 REVIEWED로 변경 */ @PostMapping("/submissions/{id}/feedback") public MentorSubmissionResponse giveFeedback(@PathVariable Long id, diff --git a/backend/src/test/java/dev/awesomedev/mirim/service/StudentPurgeServiceTest.java b/backend/src/test/java/dev/awesomedev/mirim/service/StudentPurgeServiceTest.java new file mode 100644 index 0000000..bd9c362 --- /dev/null +++ b/backend/src/test/java/dev/awesomedev/mirim/service/StudentPurgeServiceTest.java @@ -0,0 +1,91 @@ +package dev.awesomedev.mirim.service; + +import dev.awesomedev.mirim.domain.User; +import dev.awesomedev.mirim.repository.CodingSubmissionRepository; +import dev.awesomedev.mirim.repository.CourseProgressRepository; +import dev.awesomedev.mirim.repository.ProgressRepository; +import dev.awesomedev.mirim.repository.QuizResultRepository; +import dev.awesomedev.mirim.repository.StudentProfileRepository; +import dev.awesomedev.mirim.repository.SubmissionRepository; +import dev.awesomedev.mirim.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * 이 파일이 하는 일: + * 학생 파기의 두 핵심 규칙을 검증한다 — (1) 자식 데이터를 먼저, 계정을 나중에 지우는 순서, + * (2) 멘토 계정 등 STUDENT가 아닌 대상은 파기하지 못하게 막는 것. + */ +class StudentPurgeServiceTest { + + private UserRepository userRepository; + private StudentProfileRepository studentProfileRepository; + private SubmissionRepository submissionRepository; + private CodingSubmissionRepository codingSubmissionRepository; + private ProgressRepository progressRepository; + private CourseProgressRepository courseProgressRepository; + private QuizResultRepository quizResultRepository; + private StudentPurgeService service; + + @BeforeEach + void setUp() { + userRepository = mock(UserRepository.class); + studentProfileRepository = mock(StudentProfileRepository.class); + submissionRepository = mock(SubmissionRepository.class); + codingSubmissionRepository = mock(CodingSubmissionRepository.class); + progressRepository = mock(ProgressRepository.class); + courseProgressRepository = mock(CourseProgressRepository.class); + quizResultRepository = mock(QuizResultRepository.class); + service = new StudentPurgeService(userRepository, studentProfileRepository, + submissionRepository, codingSubmissionRepository, progressRepository, + courseProgressRepository, quizResultRepository, mock(TelegramNotifier.class)); + } + + @Test + @DisplayName("학생 파기: 자식 데이터를 먼저 지우고 계정을 마지막에 지운다 (외래키 순서)") + void purge_deletes_children_before_user() { + User student = new User("s1", "해시", "임학생", "STUDENT", "DEV"); + when(userRepository.findById(1L)).thenReturn(Optional.of(student)); + + service.purge(1L); + + // 계정 삭제(userRepository.delete)는 반드시 자식 삭제들 "뒤에" 일어나야 한다. + InOrder order = inOrder(submissionRepository, studentProfileRepository, userRepository); + order.verify(submissionRepository).deleteByUser(student); + order.verify(studentProfileRepository).deleteByUser(student); + order.verify(userRepository).delete(student); // 마지막 + } + + @Test + @DisplayName("학생 파기: 대상이 멘토면 403을 던지고 아무것도 지우지 않는다") + void purge_rejects_non_student() { + User mentor = new User("mentor1", "해시", "멘토", "MENTOR", "NONE"); + when(userRepository.findById(9L)).thenReturn(Optional.of(mentor)); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> service.purge(9L)); + + assertEquals(403, ex.getStatusCode().value()); + verify(userRepository, never()).delete(any()); + verify(submissionRepository, never()).deleteByUser(any()); + } + + @Test + @DisplayName("학생 파기: 없는 사용자면 404") + void purge_throws_404_when_missing() { + when(userRepository.findById(99L)).thenReturn(Optional.empty()); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> service.purge(99L)); + + assertEquals(404, ex.getStatusCode().value()); + } +} diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 077562e..52ac2e5 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -4,8 +4,28 @@ # - /api/* 요청은 백엔드 컨테이너(backend:8080)로 넘김 — 개발 때 vite 프록시와 같은 역할 # - 도메인이 연결되면 HTTPS 인증서를 자동으로 받아요 (Let's Encrypt) +# ── 공통 보안 응답 헤더 (snippet) ── +# 학습 포인트: 브라우저는 서버가 주는 헤더를 보고 "이 사이트를 어떻게 다룰지" 정한다. +# 아래 헤더 몇 줄이 흔한 공격을 브라우저 단에서 막아 준다. 학생 세션쿠키·개인정보를 +# 다루는 사이트라 특히 중요하다. (import security 로 각 사이트 블록에서 끌어 쓴다) +(security) { + header { + # HTTPS만 쓰라고 브라우저에 각인 (1년) — 평문 접속·다운그레이드 공격 차단 + Strict-Transport-Security "max-age=31536000; includeSubDomains" + # 다른 사이트가 우리 화면을 iframe에 넣는 것 금지 → 클릭재킹 방어 + X-Frame-Options "DENY" + # 브라우저가 파일 종류를 멋대로 추측(MIME 스니핑)하지 못하게 + X-Content-Type-Options "nosniff" + # 다른 사이트로 이동할 때 우리 주소(경로)를 흘리지 않음 + Referrer-Policy "no-referrer" + # 서버 종류를 굳이 알려주지 않음 + -Server + } +} + # 도메인 접속 (DNS 연결 후 자동 HTTPS) edu.awesomedevapp.com { + import security encode gzip handle /api/* { @@ -19,17 +39,7 @@ edu.awesomedevapp.com { } } -# IP 직접 접속용 (DNS 연결 전 확인용, HTTP) -:80 { - encode gzip - - handle /api/* { - reverse_proxy backend:8080 - } - - handle { - root * /srv - try_files {path} /index.html - file_server - } -} +# 학습 포인트: 예전엔 여기에 `:80 { }`(모든 HTTP를 평문으로 서빙) 블록이 있었다. +# 그런데 그게 Caddy의 자동 HTTP→HTTPS 리다이렉트를 "가로채" 평문 접속을 열어 뒀다. +# 이 블록을 지우면 Caddy가 edu.awesomedevapp.com의 80포트를 자동으로 443으로 돌려보낸다 +# → 평문으로 로그인 정보가 오갈 표면 자체가 사라진다. (DNS가 붙었으니 IP 평문 접속은 불필요.) diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 6895d92..12c4ae5 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -5,10 +5,21 @@ # - 비밀번호는 .env 파일로 주입 (코드/이미지에 안 넣음) # - 재부팅해도 자동 시작(restart: unless-stopped) +# 학습 포인트 — 로그도 자원이다(디스크). Docker 기본값은 로그를 "무제한"으로 쌓아서, +# 작은 EC2 디스크가 몇 달이면 꽉 차고 그 순간 스택 전체가 조용히 멈춘다("아무도 모르는 다운"). +# 아래 앵커(&logging)로 각 서비스에 "파일당 10MB, 3개까지만" 회전 정책을 공통 적용한다. +# YAML 앵커(&이름)와 병합(<<: *이름)은 "같은 설정을 한 번 쓰고 여러 곳에 붙이는" 문법이다. +x-logging: &logging + driver: json-file + options: + max-size: "10m" + max-file: "3" + services: postgres: image: postgres:17-alpine container_name: mirim-postgres + logging: *logging environment: POSTGRES_DB: mirim POSTGRES_USER: mirim @@ -25,6 +36,7 @@ services: backend: build: ../backend container_name: mirim-backend + logging: *logging environment: DB_HOST: postgres # 학습 포인트: 컨테이너끼리는 서비스 이름이 곧 주소예요 DB_PORT: 5432 @@ -54,6 +66,7 @@ services: caddy: image: caddy:2-alpine container_name: mirim-caddy + logging: *logging ports: - "80:80" - "443:443" @@ -71,6 +84,7 @@ services: gitea: image: gitea/gitea:1.23 container_name: mirim-gitea + logging: *logging environment: GITEA__server__ROOT_URL: http://${GIT_HOST:-edu.awesomedevapp.com}:3000/ GITEA__server__SSH_PORT: "222" diff --git a/deploy/healthcheck.sh b/deploy/healthcheck.sh new file mode 100644 index 0000000..3bcb7f5 --- /dev/null +++ b/deploy/healthcheck.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# 이 파일이 하는 일: 학습 플랫폼이 살아 있는지 밖에서 확인하고, 상태가 "바뀔 때만" 텔레그램으로 알린다. +# systemd 타이머가 2분마다 실행한다(mirim-health.timer). +# +# 학습 포인트 ① — 왜 "앱 밖에서" 감시하나? +# 백엔드가 죽으면 백엔드 스스로는 "나 죽었어요"를 못 보낸다(죽었으니까!). +# 그래서 감시는 반드시 앱 바깥의 별도 프로세스가 해야 한다. 여기선 호스트의 이 스크립트가 +# 실제 사용자와 똑같이 공개 URL(HTTPS)을 두드려 본다 — 이게 되면 Caddy·백엔드·인증서가 다 정상. +# +# 학습 포인트 ② — "상태 변화"에만 알린다. +# 2분마다 "정상입니다"를 보내면 알림이 스팸이 돼 아무도 안 본다. +# 그래서 직전 상태를 파일에 적어 두고, UP→DOWN(장애 발생)과 DOWN→UP(복구)의 +# "바뀌는 순간"에만 한 번씩 보낸다. 좋은 알림은 조용하다가 필요할 때만 운다. +set -uo pipefail # -e는 안 쓴다: curl 실패(=사이트 다운)는 "에러"가 아니라 우리가 감지하려는 "정상 흐름"이다. + +# ── 설정 ── +URL="https://edu.awesomedevapp.com/" +STATE_FILE="/home/ec2-user/.mirim-health-state" # 직전 상태(UP/DOWN)를 기억 +FAIL_THRESHOLD=2 # 연속 N회 실패해야 DOWN으로 본다(일시적 깜빡임 무시) +TIMEOUT=10 + +# 텔레그램 설정은 백엔드 .env에서 그대로 가져온다(같은 봇·같은 방). +ENV_FILE="/home/ec2-user/mirim-app/deploy/.env" +BOT_TOKEN="$(grep -E '^TELEGRAM_BOT_TOKEN=' "$ENV_FILE" 2>/dev/null | cut -d= -f2- || true)" +CHAT_ID="$(grep -E '^TELEGRAM_CHAT_ID=' "$ENV_FILE" 2>/dev/null | cut -d= -f2- || true)" + +notify() { + # 봇 설정이 없으면 조용히 넘어간다(알림만 꺼지고 감시 자체는 계속). + [ -z "$BOT_TOKEN" ] || [ -z "$CHAT_ID" ] && return 0 + curl -s -m "$TIMEOUT" -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \ + -H "Content-Type: application/json; charset=utf-8" \ + --data-binary "$(printf '{"chat_id":%s,"text":%s}' "$CHAT_ID" "$(printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')")" >/dev/null || true +} + +# ── 현재 상태 확인 ── +# 연속 FAIL_THRESHOLD회 실패해야 DOWN. 한 번이라도 성공하면 즉시 UP. +now="DOWN" +for i in $(seq 1 "$FAIL_THRESHOLD"); do + if code="$(curl -s -o /dev/null -m "$TIMEOUT" -w '%{http_code}' "$URL")" && [ "$code" = "200" ]; then + now="UP" + break + fi + sleep 3 +done + +# ── 직전 상태와 비교, 바뀌었을 때만 알림 ── +prev="$(cat "$STATE_FILE" 2>/dev/null || echo "UP")" # 첫 실행은 UP으로 가정(정상 시작 전제) +if [ "$now" != "$prev" ]; then + if [ "$now" = "DOWN" ]; then + notify "🔴 학습 플랫폼 응답 없음 — $URL 이 열리지 않습니다. 서버를 확인해 주세요. (연속 ${FAIL_THRESHOLD}회 실패)" + else + notify "🟢 학습 플랫폼 정상 복구 — $URL 이 다시 열립니다." + fi + echo "$now" > "$STATE_FILE" +fi diff --git a/deploy/mirim-health.service b/deploy/mirim-health.service new file mode 100644 index 0000000..45845a7 --- /dev/null +++ b/deploy/mirim-health.service @@ -0,0 +1,8 @@ +[Unit] +Description=mirim platform: uptime health check + telegram alert +After=docker.service + +[Service] +Type=oneshot +User=ec2-user +ExecStart=/home/ec2-user/mirim-app/deploy/healthcheck.sh diff --git a/deploy/mirim-health.timer b/deploy/mirim-health.timer new file mode 100644 index 0000000..7f4e5a5 --- /dev/null +++ b/deploy/mirim-health.timer @@ -0,0 +1,9 @@ +[Unit] +Description=mirim platform: run health check every 2 minutes + +[Timer] +OnBootSec=2min +OnUnitActiveSec=2min + +[Install] +WantedBy=timers.target diff --git a/frontend/src/pages/MentorPage.jsx b/frontend/src/pages/MentorPage.jsx index 867b4a6..a572856 100644 --- a/frontend/src/pages/MentorPage.jsx +++ b/frontend/src/pages/MentorPage.jsx @@ -191,11 +191,30 @@ function WeeklyChecklistTable({ overview }) { // 수습생 인적사항 명단 — 경리 담당자가 CSV로 내려받을 수 있다. // 학습 포인트: 개인정보 화면이라 링크가 아니라 표로, 다운로드는 버튼 하나로 명확히 둔다. -function StudentRoster({ students }) { +function StudentRoster({ students, onChanged }) { // 학습 포인트: 파일 다운로드는 axios(JSON) 말고 브라우저의 링크 이동으로 처리한다. // withCredentials 링크 이동이면 세션 쿠키도 함께 가서, 서버가 멘토인지 확인할 수 있다. const csvUrl = '/api/mentor/students/export.csv'; + // 개인정보 파기 — 되돌릴 수 없는 작업이라 "이름을 그대로 입력"해야만 진행한다(오삭제 방지). + async function purge(s) { + const typed = window.prompt( + `⚠️ ${s.name} 님의 계정과 모든 개인정보·학습기록을 영구히 삭제합니다. 되돌릴 수 없어요.\n` + + `확인하려면 학생 이름을 그대로 입력하세요: ${s.name}`, + ); + if (typed === null) return; // 취소 + if (typed.trim() !== s.name) { + window.alert('이름이 일치하지 않아 취소했어요.'); + return; + } + try { + await client.delete(`/mentor/students/${s.userId}`); + onChanged(); // 목록 새로고침 + } catch { + window.alert('삭제 중 문제가 생겼어요. 잠시 후 다시 시도해 주세요.'); + } + } + return (
@@ -217,6 +236,7 @@ function StudentRoster({ students }) { 연락처 학교 비상 연락 + 파기 @@ -231,10 +251,25 @@ function StudentRoster({ students }) { {s.emergencyName}({s.emergencyRelation}) {s.emergencyPhone} + + {/* 퇴직·수습종료 시 개인정보 파기. 동의 화면이 약속한 '파기'를 실제로 지키는 버튼 */} + + ))} +

+ ※ [파기]는 학생의 계정·제출물·진도·퀴즈·인적사항을 영구 삭제합니다 — + 수습 종료/퇴직 시 개인정보 파기 의무를 지키는 수단이에요. 되돌릴 수 없습니다. +

); } @@ -394,7 +429,7 @@ export default function MentorPage() { {roster.length === 0 ? (

아직 가입한 수습생이 없어요. (학생이 회원가입하면 여기에 표시됩니다)

) : ( - + )} {/* ── 학생별 코스 진도 ── */} diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 30f9ba4..7e43bd4 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -90,7 +90,7 @@ export default function QuizPage() { <>

{result.score === result.total - ? '만점! 이 코스를 완전히 소화했어요 🎉 아래에서 코스 완료를 눌러주세요.' + ? '만점! 이 코스를 완전히 소화했어요 🎉 [← 코스로] 돌아가 "이 코스 완료하기"를 눌러 진도를 남겨주세요.' : result.score >= Math.ceil(result.total * 0.6) ? '좋아요! 틀린 문제의 해설을 읽고 다시 도전해 보세요.' : '괜찮아요, 원래 처음엔 그래요. 코스를 한 번 더 읽고 다시 풀어봐요.'}