feat: 고도화 배치 7건 — 보안헤더·다운감시·로그회전·개인정보파기·문서정합성
[보안]
- Caddy 보안 응답헤더(HSTS·X-Frame-Options:DENY·X-Content-Type-Options·Referrer-Policy) +
Server 헤더 제거. :80 catch-all 제거로 HTTP→HTTPS 자동 리다이렉트 복원(평문 표면 제거)
[운영 안정성]
- 헬스체크 스크립트 + systemd 타이머(2분): 공개 URL 두드려 UP/DOWN 변화 시에만 텔레그램 알림
- Docker 로그 회전(json-file 10m×3, YAML 앵커 공통적용) — 디스크 풀로 인한 조용한 다운 방지
[개인정보]
- 학생 파기: DELETE /api/mentor/students/{id} (멘토 권한) → 자식6테이블 먼저·계정 나중,
@Transactional. 동의화면이 약속한 '파기' 이행. 멘토 화면에 이름입력 확인 파기 버튼
StudentPurgeService + 테스트3(삭제순서·멘토거부·404)
[문서 정합성 — 지금 쓰는 학생 대상]
- STUDY.md: 없는 파일 가리키던 코드지도 정정(controller/→web/, ChecklistPage→DashboardPage,
DocumentsPage→DocsPage, SubmissionsPage→AssignmentsPage)
- README: '6주'→8주, 학습센터/퀴즈/코딩 소개 추가, 폴더구조·초기계정(멘토만 시드·랜덤비번) 현행화
- 퀴즈 만점 안내: 없는 '코스 완료' 버튼 대신 '[← 코스로] 돌아가 완료' 로 정정
검증: 테스트29 통과, 운영에서 헤더4종·308리다이렉트·헬스체크·파기 E2E(무인증401·멘토200·
자식전체0건) 확인. 나머지 13건은 티켓 풀 확장감으로 남김.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
bb12d072b1
commit
0a4c769d00
26
README.md
26
README.md
@ -2,15 +2,16 @@
|
|||||||
|
|
||||||
> **이 저장소는 여러분(수습생)의 것입니다.**
|
> **이 저장소는 여러분(수습생)의 것입니다.**
|
||||||
> 미림마이스터고 수습생 4명이 직접 보고, 배우고, 고치는 학습용 웹 앱입니다.
|
> 미림마이스터고 수습생 4명이 직접 보고, 배우고, 고치는 학습용 웹 앱입니다.
|
||||||
> 처음에는 문서를 읽는 곳이지만, 5~6주차부터는 여러분이 직접 기능을 붙이고 버그를 고치는 실전 연습장이 됩니다.
|
> 처음에는 문서를 읽는 곳이지만, 5주차부터는 여러분이 직접 기능을 붙이고 버그를 고치는 실전 연습장이 됩니다.
|
||||||
> 코드를 읽는 법이 궁금하면 [STUDY.md](./STUDY.md)부터 열어 보세요.
|
> 코드를 읽는 법이 궁금하면 [STUDY.md](./STUDY.md)부터 열어 보세요.
|
||||||
|
|
||||||
## 무엇을 하는 앱인가?
|
## 무엇을 하는 앱인가?
|
||||||
|
|
||||||
- 수습 기간(6주) 동안 필요한 **학습 문서**(가이드·커리큘럼·수업자료·과제)를 한곳에서 봅니다.
|
- 수습 기간(**8주**, 9월~10월) 동안 필요한 **학습 문서**(가이드·커리큘럼·수업자료·과제·실습가이드)를 한곳에서 봅니다.
|
||||||
- 주차별 **체크리스트**로 오늘 할 일을 확인하고 체크합니다.
|
- **학습 센터**에서 컴퓨터 기초부터 디자인까지 **강좌 71개**를 순서대로 배우고, 각 코스 끝에서 **퀴즈**로 확인합니다.
|
||||||
- **과제를 제출**하고, 멘토에게 **피드백**을 받습니다.
|
- **코딩 문제 25개**(JavaScript·SQL·Java)를 브라우저에서 직접 풀고 자동 채점받습니다.
|
||||||
- 멘토는 전체 제출물을 한 화면에서 검토합니다.
|
- 주차별 **체크리스트**로 오늘 할 일을 확인하고, **과제를 제출**하면 멘토가 **피드백**을 남깁니다.
|
||||||
|
- 멘토는 학생별 진도·제출물·인적사항을 한 화면에서 관리합니다.
|
||||||
|
|
||||||
## 기술 스택
|
## 기술 스택
|
||||||
|
|
||||||
@ -36,17 +37,18 @@ mirim-app/
|
|||||||
│ │ ├── domain/ ← 엔티티(DB 테이블과 짝이 되는 클래스)
|
│ │ ├── domain/ ← 엔티티(DB 테이블과 짝이 되는 클래스)
|
||||||
│ │ ├── repository/ ← DB를 읽고 쓰는 계층
|
│ │ ├── repository/ ← DB를 읽고 쓰는 계층
|
||||||
│ │ ├── service/ ← 비즈니스 로직(규칙) 계층
|
│ │ ├── service/ ← 비즈니스 로직(규칙) 계층
|
||||||
│ │ ├── controller/ ← HTTP 요청을 받는 계층
|
│ │ ├── web/ ← HTTP 요청을 받는 계층(컨트롤러들)
|
||||||
│ │ └── config/ ← 보안·시드 등 설정
|
│ │ └── config/ ← 보안·시드 등 설정
|
||||||
│ └── resources/
|
│ └── resources/
|
||||||
│ ├── application.yml ← 서버 설정(DB 접속 정보 등)
|
│ ├── application.yml ← 서버 설정(DB 접속 정보 등)
|
||||||
│ └── seed/ ← 초기 데이터 JSON (문서·체크리스트·과제)
|
│ └── seed/ ← 초기 데이터 JSON (문서·체크리스트·과제·퀴즈·코딩문제)
|
||||||
└── frontend/ ← React 화면
|
└── frontend/ ← React 화면
|
||||||
├── package.json ← JS 의존성 목록
|
├── package.json ← JS 의존성 목록
|
||||||
├── index.html ← 브라우저가 처음 여는 파일
|
├── index.html ← 브라우저가 처음 여는 파일
|
||||||
├── public/docs/ ← 학습 문서 HTML 17개 (앱이 iframe으로 보여줌)
|
├── public/docs/ ← 학습 문서 HTML 19개 (앱이 iframe으로 보여줌)
|
||||||
└── src/
|
└── src/
|
||||||
├── pages/ ← 화면 단위 컴포넌트 (로그인, 대시보드, 문서, 과제…)
|
├── pages/ ← 화면 단위 컴포넌트 (로그인, 대시보드, 문서, 과제…)
|
||||||
|
│ └── courses/ ← 학습 센터의 강좌 페이지들 (코스 하나당 파일 하나)
|
||||||
├── components/ ← 여러 화면에서 재사용하는 조각 컴포넌트
|
├── components/ ← 여러 화면에서 재사용하는 조각 컴포넌트
|
||||||
└── api/ ← 백엔드 API를 부르는 함수 모음
|
└── api/ ← 백엔드 API를 부르는 함수 모음
|
||||||
```
|
```
|
||||||
@ -82,12 +84,14 @@ npm run dev
|
|||||||
|
|
||||||
## 초기 계정
|
## 초기 계정
|
||||||
|
|
||||||
|
시드로는 **멘토 계정(mentor1) 하나만** 만들어집니다. 학생은 첫날 각자 **회원가입**으로 등록해요
|
||||||
|
(개인정보를 본인이 입력하는 게 정확하고, "받은 비밀번호를 바꾸는 것이 첫 일"을 배우니까요).
|
||||||
|
|
||||||
| 아이디 | 비밀번호 | 역할 |
|
| 아이디 | 비밀번호 | 역할 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| student1 ~ student4 | `mirim2026!` | 수습생 (STUDENT) |
|
| mentor1 | `MENTOR_INITIAL_PASSWORD` 환경변수, 없으면 부팅 로그에 1회 출력되는 랜덤값 | 멘토 (MENTOR) |
|
||||||
| mentor1 | `mirim2026!` | 멘토 (MENTOR) |
|
|
||||||
|
|
||||||
> ⚠️ **실서비스로 쓰기 전에는 반드시 비밀번호를 변경하세요.** 이 계정들은 로컬 학습용입니다.
|
> ⚠️ 소스에 박힌 기본 비밀번호는 없습니다 — 첫 부팅 로그에서 멘토 비밀번호를 확인하고, **로그인 후 즉시 변경**하세요.
|
||||||
|
|
||||||
## 환경변수
|
## 환경변수
|
||||||
|
|
||||||
|
|||||||
20
STUDY.md
20
STUDY.md
@ -25,7 +25,7 @@ LoginPage.jsx → api/client.js → (HTTP) → AuthController → AuthService
|
|||||||
- **관찰 포인트**: 왜 페이지마다 fetch를 직접 쓰지 않고 한 파일에 모았을까? (주소가 바뀌면 한 곳만 고치면 됨, 에러 처리를 한 번만 작성하면 됨.)
|
- **관찰 포인트**: 왜 페이지마다 fetch를 직접 쓰지 않고 한 파일에 모았을까? (주소가 바뀌면 한 곳만 고치면 됨, 에러 처리를 한 번만 작성하면 됨.)
|
||||||
- 여기서 브라우저를 떠나 HTTP 요청이 네트워크를 타고 백엔드(8080)로 갑니다. 개발자도구(F12) → Network 탭에서 이 요청을 직접 눈으로 확인해 보세요.
|
- 여기서 브라우저를 떠나 HTTP 요청이 네트워크를 타고 백엔드(8080)로 갑니다. 개발자도구(F12) → Network 탭에서 이 요청을 직접 눈으로 확인해 보세요.
|
||||||
|
|
||||||
### ③ `backend/.../controller/AuthController.java` — 서버의 현관문
|
### ③ `backend/.../web/AuthController.java` — 서버의 현관문
|
||||||
- `@PostMapping("/api/auth/login")`이 붙은 메서드를 찾으세요.
|
- `@PostMapping("/api/auth/login")`이 붙은 메서드를 찾으세요.
|
||||||
- 컨트롤러는 얇습니다: JSON을 받아서 → 서비스에 넘기고 → 결과를 JSON으로 돌려줄 뿐. 판단하지 않습니다.
|
- 컨트롤러는 얇습니다: JSON을 받아서 → 서비스에 넘기고 → 결과를 JSON으로 돌려줄 뿐. 판단하지 않습니다.
|
||||||
- **관찰 포인트**: 요청 JSON이 어떻게 자바 객체로 변하는지(`@RequestBody`).
|
- **관찰 포인트**: 요청 JSON이 어떻게 자바 객체로 변하는지(`@RequestBody`).
|
||||||
@ -43,7 +43,7 @@ LoginPage.jsx → api/client.js → (HTTP) → AuthController → AuthService
|
|||||||
- 여기까지 오면 여행 끝. 응답은 왔던 길을 **거꾸로** 타고 브라우저까지 돌아가고, LoginPage는 받은 사용자 정보로 화면을 바꿉니다.
|
- 여기까지 오면 여행 끝. 응답은 왔던 길을 **거꾸로** 타고 브라우저까지 돌아가고, 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는 배달.**
|
한 문장으로: **pages는 조립, components는 부품, api는 배달.**
|
||||||
|
|
||||||
### 백엔드: controller / service / repository / domain
|
### 백엔드: web / service / repository / domain
|
||||||
- **controller/** — HTTP를 아는 유일한 계층. 요청을 받고 응답을 돌려주는 현관문.
|
- **web/** — HTTP를 아는 유일한 계층(컨트롤러들이 여기 있어요). 요청을 받고 응답을 돌려주는 현관문.
|
||||||
- **service/** — 규칙과 판단. "멘토만 피드백을 쓸 수 있다", "같은 과제를 다시 제출하면 덮어쓴다" 같은 우리 서비스의 법.
|
- **service/** — 규칙과 판단. "멘토만 피드백을 쓸 수 있다", "같은 과제를 다시 제출하면 덮어쓴다" 같은 우리 서비스의 법.
|
||||||
- **repository/** — DB를 읽고 쓰는 일만. SQL 걱정은 여기(와 JPA)가 다 합니다.
|
- **repository/** — DB를 읽고 쓰는 일만. SQL 걱정은 여기(와 JPA)가 다 합니다.
|
||||||
- **domain/** — 데이터의 모양 정의. User, Document, Assignment 같은 "명사"들.
|
- **domain/** — 데이터의 모양 정의. User, Document, Assignment 같은 "명사"들.
|
||||||
|
|
||||||
왜 나누냐면: **한 파일에 다 쓰면 처음엔 빠르지만, 고칠 때 지옥이 됩니다.** "비밀번호 규칙을 바꿔라"라는 요청이 오면 service만 보면 되고, "응답 JSON에 필드를 추가해라"면 controller 근처만 보면 됩니다. 계층은 "어디를 고쳐야 하는지"를 알려주는 주소 체계입니다.
|
왜 나누냐면: **한 파일에 다 쓰면 처음엔 빠르지만, 고칠 때 지옥이 됩니다.** "비밀번호 규칙을 바꿔라"라는 요청이 오면 service만 보면 되고, "응답 JSON에 필드를 추가해라"면 web(컨트롤러) 근처만 보면 됩니다. 계층은 "어디를 고쳐야 하는지"를 알려주는 주소 체계입니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -77,29 +77,29 @@ LoginPage.jsx → api/client.js → (HTTP) → AuthController → AuthService
|
|||||||
|
|
||||||
### 과제 2. 문서 카드에 즐겨찾기 버튼 달기 — 프론트만 (난이도 ★★)
|
### 과제 2. 문서 카드에 즐겨찾기 버튼 달기 — 프론트만 (난이도 ★★)
|
||||||
- **목표**: 문서 목록의 각 카드에 ☆ 버튼을 달고, 누르면 ★로 바뀐다. 새로고침하면 사라져도 됩니다(서버 저장 없음).
|
- **목표**: 문서 목록의 각 카드에 ☆ 버튼을 달고, 누르면 ★로 바뀐다. 새로고침하면 사라져도 됩니다(서버 저장 없음).
|
||||||
- **건드릴 파일**: `frontend/src/pages/DocumentsPage.jsx` (카드가 별도 컴포넌트라면 `frontend/src/components/` 안의 카드 파일)
|
- **건드릴 파일**: `frontend/src/pages/DocsPage.jsx` (카드는 `frontend/src/components/Card.jsx`)
|
||||||
- **힌트**: `useState`로 즐겨찾기된 문서 id 배열을 들고, 버튼 클릭 시 배열에 넣거나 빼면 됩니다. 도전 과제: `localStorage`에 저장해서 새로고침을 버텨 보세요.
|
- **힌트**: `useState`로 즐겨찾기된 문서 id 배열을 들고, 버튼 클릭 시 배열에 넣거나 빼면 됩니다. 도전 과제: `localStorage`에 저장해서 새로고침을 버텨 보세요.
|
||||||
|
|
||||||
### 과제 3. 체크리스트 항목 검색 (난이도 ★★)
|
### 과제 3. 체크리스트 항목 검색 (난이도 ★★)
|
||||||
- **목표**: 체크리스트 페이지에 검색창을 달아서, 입력한 글자가 포함된 항목만 보여준다.
|
- **목표**: 체크리스트 페이지에 검색창을 달아서, 입력한 글자가 포함된 항목만 보여준다.
|
||||||
- **건드릴 파일**: `frontend/src/pages/ChecklistPage.jsx`
|
- **건드릴 파일**: `frontend/src/pages/DashboardPage.jsx` (주차별 체크리스트가 여기 있어요)
|
||||||
- **힌트**: 검색어를 `useState`로 들고, 화면에 그리기 직전에 `items.filter(item => item.label.includes(검색어))`. 서버 API를 바꿀 필요가 없다는 것 자체가 학습 포인트입니다 — 이미 받아 온 데이터는 프론트에서 거를 수 있습니다.
|
- **힌트**: 검색어를 `useState`로 들고, 화면에 그리기 직전에 `items.filter(item => item.label.includes(검색어))`. 서버 API를 바꿀 필요가 없다는 것 자체가 학습 포인트입니다 — 이미 받아 온 데이터는 프론트에서 거를 수 있습니다.
|
||||||
|
|
||||||
### 과제 4. API 응답에 필드 추가해 보기 (난이도 ★★★, 백엔드 첫 수정)
|
### 과제 4. API 응답에 필드 추가해 보기 (난이도 ★★★, 백엔드 첫 수정)
|
||||||
- **목표**: `GET /api/auth/me` 응답에 `createdAt`(가입 시각)을 추가하고, 화면 어딘가에 "함께한 지 N일째"를 표시한다.
|
- **목표**: `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에 필드가 생겼는지 먼저 확인하고, 그다음 화면을 고치세요. **한 번에 한 층씩 확인하는 습관**이 이 과제의 진짜 목표입니다.
|
- **힌트**: User 엔티티에는 `createdAt`이 이미 있습니다. 응답으로 내보내는 record/DTO에 한 필드만 추가하면 됩니다. 백엔드를 재시작한 뒤 브라우저 Network 탭에서 응답 JSON에 필드가 생겼는지 먼저 확인하고, 그다음 화면을 고치세요. **한 번에 한 층씩 확인하는 습관**이 이 과제의 진짜 목표입니다.
|
||||||
|
|
||||||
### 과제 5. 빈 상태(empty state) 화면 넣기 (난이도 ★★)
|
### 과제 5. 빈 상태(empty state) 화면 넣기 (난이도 ★★)
|
||||||
- **목표**: 제출한 과제가 하나도 없을 때, 휑한 빈 목록 대신 "아직 제출한 과제가 없어요. 이번 주 과제부터 시작해 볼까요?" 같은 안내를 보여준다.
|
- **목표**: 제출한 과제가 하나도 없을 때, 휑한 빈 목록 대신 "아직 제출한 과제가 없어요. 이번 주 과제부터 시작해 볼까요?" 같은 안내를 보여준다.
|
||||||
- **건드릴 파일**: `frontend/src/pages/SubmissionsPage.jsx` (내 제출물 페이지)
|
- **건드릴 파일**: `frontend/src/pages/AssignmentsPage.jsx` (과제 목록·제출 화면)
|
||||||
- **힌트**: `submissions.length === 0`일 때 다른 JSX를 그리면 됩니다. 좋은 서비스는 "데이터가 없을 때"를 항상 설계합니다 — 실무에서 정말 자주 하는 일입니다.
|
- **힌트**: `submissions.length === 0`일 때 다른 JSX를 그리면 됩니다. 좋은 서비스는 "데이터가 없을 때"를 항상 설계합니다 — 실무에서 정말 자주 하는 일입니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 이 저장소는 여러분의 연습장입니다
|
## 4. 이 저장소는 여러분의 연습장입니다
|
||||||
|
|
||||||
5~6주차 실전 티켓 기간에는 이 앱 자체가 작업 대상이 됩니다. 지금 문서를 보고 과제를 제출하는 데 쓰는 바로 이 코드에, 여러분이 만든 기능이 붙습니다.
|
5주차부터 시작하는 실전 티켓 기간에는 이 앱 자체가 작업 대상이 됩니다. 지금 문서를 보고 과제를 제출하는 데 쓰는 바로 이 코드에, 여러분이 만든 기능이 붙습니다.
|
||||||
|
|
||||||
- 위 미니 과제 5개는 실전 티켓의 축소판입니다. 티켓도 결국 "어느 파일을, 왜, 어떻게 고칠지"를 찾는 일입니다.
|
- 위 미니 과제 5개는 실전 티켓의 축소판입니다. 티켓도 결국 "어느 파일을, 왜, 어떻게 고칠지"를 찾는 일입니다.
|
||||||
- 브랜치를 파서 마음껏 실험하세요. 망가뜨려도 `git checkout`으로 돌아올 수 있고, 망가뜨려 본 만큼 빨리 늡니다.
|
- 브랜치를 파서 마음껏 실험하세요. 망가뜨려도 `git checkout`으로 돌아올 수 있고, 망가뜨려 본 만큼 빨리 늡니다.
|
||||||
|
|||||||
@ -25,4 +25,7 @@ public interface CodingSubmissionRepository extends JpaRepository<CodingSubmissi
|
|||||||
|
|
||||||
/** 전체 제출물 — 멘토 화면용 (최신 제출 먼저) */
|
/** 전체 제출물 — 멘토 화면용 (최신 제출 먼저) */
|
||||||
List<CodingSubmission> findAllByOrderBySubmittedAtDesc();
|
List<CodingSubmission> findAllByOrderBySubmittedAtDesc();
|
||||||
|
|
||||||
|
/** 이 사용자의 모든 행을 지운다 (학생 계정 파기 시 자식 데이터부터 정리). */
|
||||||
|
void deleteByUser(User user);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,4 +20,7 @@ public interface CourseProgressRepository extends JpaRepository<CourseProgress,
|
|||||||
|
|
||||||
/** 이 사용자가 이 코스를 완료했는지 (있으면 완료) */
|
/** 이 사용자가 이 코스를 완료했는지 (있으면 완료) */
|
||||||
Optional<CourseProgress> findByUserAndCourseSlug(User user, String courseSlug);
|
Optional<CourseProgress> findByUserAndCourseSlug(User user, String courseSlug);
|
||||||
|
|
||||||
|
/** 이 사용자의 모든 행을 지운다 (학생 계정 파기 시 자식 데이터부터 정리). */
|
||||||
|
void deleteByUser(User user);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,4 +19,7 @@ public interface ProgressRepository extends JpaRepository<Progress, Long> {
|
|||||||
|
|
||||||
/** 특정 사용자의 전체 체크 기록 조회 */
|
/** 특정 사용자의 전체 체크 기록 조회 */
|
||||||
List<Progress> findByUser(User user);
|
List<Progress> findByUser(User user);
|
||||||
|
|
||||||
|
/** 이 사용자의 모든 행을 지운다 (학생 계정 파기 시 자식 데이터부터 정리). */
|
||||||
|
void deleteByUser(User user);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,4 +21,7 @@ public interface QuizResultRepository extends JpaRepository<QuizResult, Long> {
|
|||||||
* "순서가 필요하면 반드시 ORDER BY로 말한다" — 우연히 맞는 코드는 언젠가 틀린다.
|
* "순서가 필요하면 반드시 ORDER BY로 말한다" — 우연히 맞는 코드는 언젠가 틀린다.
|
||||||
*/
|
*/
|
||||||
List<QuizResult> findByUserOrderBySubmittedAtDesc(User user);
|
List<QuizResult> findByUserOrderBySubmittedAtDesc(User user);
|
||||||
|
|
||||||
|
/** 이 사용자의 모든 행을 지운다 (학생 계정 파기 시 자식 데이터부터 정리). */
|
||||||
|
void deleteByUser(User user);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,4 +33,7 @@ public interface StudentProfileRepository extends JpaRepository<StudentProfile,
|
|||||||
*/
|
*/
|
||||||
@Query("SELECT p FROM StudentProfile p JOIN FETCH p.user ORDER BY p.id ASC")
|
@Query("SELECT p FROM StudentProfile p JOIN FETCH p.user ORDER BY p.id ASC")
|
||||||
List<StudentProfile> findAllWithUser();
|
List<StudentProfile> findAllWithUser();
|
||||||
|
|
||||||
|
/** 이 사용자의 프로필을 지운다 (학생 계정 파기 시). */
|
||||||
|
void deleteByUser(User user);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,4 +26,7 @@ public interface SubmissionRepository extends JpaRepository<Submission, Long> {
|
|||||||
|
|
||||||
/** 전체 제출물을 최신 제출 순으로 조회 (멘토 리뷰 화면용) */
|
/** 전체 제출물을 최신 제출 순으로 조회 (멘토 리뷰 화면용) */
|
||||||
List<Submission> findAllByOrderBySubmittedAtDesc();
|
List<Submission> findAllByOrderBySubmittedAtDesc();
|
||||||
|
|
||||||
|
/** 이 사용자의 모든 행을 지운다 (학생 계정 파기 시 자식 데이터부터 정리). */
|
||||||
|
void deleteByUser(User user);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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제출·진도·퀴즈·프로필·계정을 모두 삭제했습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import dev.awesomedev.mirim.service.CourseProgressService;
|
|||||||
import dev.awesomedev.mirim.service.ProgressService;
|
import dev.awesomedev.mirim.service.ProgressService;
|
||||||
import dev.awesomedev.mirim.service.QuizService;
|
import dev.awesomedev.mirim.service.QuizService;
|
||||||
import dev.awesomedev.mirim.service.StudentProfileService;
|
import dev.awesomedev.mirim.service.StudentProfileService;
|
||||||
|
import dev.awesomedev.mirim.service.StudentPurgeService;
|
||||||
import dev.awesomedev.mirim.service.SubmissionService;
|
import dev.awesomedev.mirim.service.SubmissionService;
|
||||||
import dev.awesomedev.mirim.web.dto.ChecklistProgressOverviewResponse;
|
import dev.awesomedev.mirim.web.dto.ChecklistProgressOverviewResponse;
|
||||||
import dev.awesomedev.mirim.web.dto.FeedbackRequest;
|
import dev.awesomedev.mirim.web.dto.FeedbackRequest;
|
||||||
@ -48,6 +49,7 @@ public class MentorController {
|
|||||||
private final CodingService codingService;
|
private final CodingService codingService;
|
||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
private final StudentProfileService studentProfileService;
|
private final StudentProfileService studentProfileService;
|
||||||
|
private final StudentPurgeService studentPurgeService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
public MentorController(SubmissionService submissionService,
|
public MentorController(SubmissionService submissionService,
|
||||||
@ -57,6 +59,7 @@ public class MentorController {
|
|||||||
CodingService codingService,
|
CodingService codingService,
|
||||||
AuthService authService,
|
AuthService authService,
|
||||||
StudentProfileService studentProfileService,
|
StudentProfileService studentProfileService,
|
||||||
|
StudentPurgeService studentPurgeService,
|
||||||
UserRepository userRepository) {
|
UserRepository userRepository) {
|
||||||
this.submissionService = submissionService;
|
this.submissionService = submissionService;
|
||||||
this.courseProgressService = courseProgressService;
|
this.courseProgressService = courseProgressService;
|
||||||
@ -65,6 +68,7 @@ public class MentorController {
|
|||||||
this.codingService = codingService;
|
this.codingService = codingService;
|
||||||
this.authService = authService;
|
this.authService = authService;
|
||||||
this.studentProfileService = studentProfileService;
|
this.studentProfileService = studentProfileService;
|
||||||
|
this.studentPurgeService = studentPurgeService;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,6 +173,18 @@ public class MentorController {
|
|||||||
authService.resetPasswordByMentor(id, request.newPassword());
|
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로 변경 */
|
/** POST /api/mentor/submissions/{id}/feedback — 피드백 등록, 상태는 REVIEWED로 변경 */
|
||||||
@PostMapping("/submissions/{id}/feedback")
|
@PostMapping("/submissions/{id}/feedback")
|
||||||
public MentorSubmissionResponse giveFeedback(@PathVariable Long id,
|
public MentorSubmissionResponse giveFeedback(@PathVariable Long id,
|
||||||
|
|||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,8 +4,28 @@
|
|||||||
# - /api/* 요청은 백엔드 컨테이너(backend:8080)로 넘김 — 개발 때 vite 프록시와 같은 역할
|
# - /api/* 요청은 백엔드 컨테이너(backend:8080)로 넘김 — 개발 때 vite 프록시와 같은 역할
|
||||||
# - 도메인이 연결되면 HTTPS 인증서를 자동으로 받아요 (Let's Encrypt)
|
# - 도메인이 연결되면 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)
|
# 도메인 접속 (DNS 연결 후 자동 HTTPS)
|
||||||
edu.awesomedevapp.com {
|
edu.awesomedevapp.com {
|
||||||
|
import security
|
||||||
encode gzip
|
encode gzip
|
||||||
|
|
||||||
handle /api/* {
|
handle /api/* {
|
||||||
@ -19,17 +39,7 @@ edu.awesomedevapp.com {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# IP 직접 접속용 (DNS 연결 전 확인용, HTTP)
|
# 학습 포인트: 예전엔 여기에 `:80 { }`(모든 HTTP를 평문으로 서빙) 블록이 있었다.
|
||||||
:80 {
|
# 그런데 그게 Caddy의 자동 HTTP→HTTPS 리다이렉트를 "가로채" 평문 접속을 열어 뒀다.
|
||||||
encode gzip
|
# 이 블록을 지우면 Caddy가 edu.awesomedevapp.com의 80포트를 자동으로 443으로 돌려보낸다
|
||||||
|
# → 평문으로 로그인 정보가 오갈 표면 자체가 사라진다. (DNS가 붙었으니 IP 평문 접속은 불필요.)
|
||||||
handle /api/* {
|
|
||||||
reverse_proxy backend:8080
|
|
||||||
}
|
|
||||||
|
|
||||||
handle {
|
|
||||||
root * /srv
|
|
||||||
try_files {path} /index.html
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -5,10 +5,21 @@
|
|||||||
# - 비밀번호는 .env 파일로 주입 (코드/이미지에 안 넣음)
|
# - 비밀번호는 .env 파일로 주입 (코드/이미지에 안 넣음)
|
||||||
# - 재부팅해도 자동 시작(restart: unless-stopped)
|
# - 재부팅해도 자동 시작(restart: unless-stopped)
|
||||||
|
|
||||||
|
# 학습 포인트 — 로그도 자원이다(디스크). Docker 기본값은 로그를 "무제한"으로 쌓아서,
|
||||||
|
# 작은 EC2 디스크가 몇 달이면 꽉 차고 그 순간 스택 전체가 조용히 멈춘다("아무도 모르는 다운").
|
||||||
|
# 아래 앵커(&logging)로 각 서비스에 "파일당 10MB, 3개까지만" 회전 정책을 공통 적용한다.
|
||||||
|
# YAML 앵커(&이름)와 병합(<<: *이름)은 "같은 설정을 한 번 쓰고 여러 곳에 붙이는" 문법이다.
|
||||||
|
x-logging: &logging
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
container_name: mirim-postgres
|
container_name: mirim-postgres
|
||||||
|
logging: *logging
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: mirim
|
POSTGRES_DB: mirim
|
||||||
POSTGRES_USER: mirim
|
POSTGRES_USER: mirim
|
||||||
@ -25,6 +36,7 @@ services:
|
|||||||
backend:
|
backend:
|
||||||
build: ../backend
|
build: ../backend
|
||||||
container_name: mirim-backend
|
container_name: mirim-backend
|
||||||
|
logging: *logging
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: postgres # 학습 포인트: 컨테이너끼리는 서비스 이름이 곧 주소예요
|
DB_HOST: postgres # 학습 포인트: 컨테이너끼리는 서비스 이름이 곧 주소예요
|
||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
@ -54,6 +66,7 @@ services:
|
|||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
container_name: mirim-caddy
|
container_name: mirim-caddy
|
||||||
|
logging: *logging
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
@ -71,6 +84,7 @@ services:
|
|||||||
gitea:
|
gitea:
|
||||||
image: gitea/gitea:1.23
|
image: gitea/gitea:1.23
|
||||||
container_name: mirim-gitea
|
container_name: mirim-gitea
|
||||||
|
logging: *logging
|
||||||
environment:
|
environment:
|
||||||
GITEA__server__ROOT_URL: http://${GIT_HOST:-edu.awesomedevapp.com}:3000/
|
GITEA__server__ROOT_URL: http://${GIT_HOST:-edu.awesomedevapp.com}:3000/
|
||||||
GITEA__server__SSH_PORT: "222"
|
GITEA__server__SSH_PORT: "222"
|
||||||
|
|||||||
55
deploy/healthcheck.sh
Normal file
55
deploy/healthcheck.sh
Normal file
@ -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
|
||||||
8
deploy/mirim-health.service
Normal file
8
deploy/mirim-health.service
Normal file
@ -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
|
||||||
9
deploy/mirim-health.timer
Normal file
9
deploy/mirim-health.timer
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=mirim platform: run health check every 2 minutes
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=2min
|
||||||
|
OnUnitActiveSec=2min
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@ -191,11 +191,30 @@ function WeeklyChecklistTable({ overview }) {
|
|||||||
|
|
||||||
// 수습생 인적사항 명단 — 경리 담당자가 CSV로 내려받을 수 있다.
|
// 수습생 인적사항 명단 — 경리 담당자가 CSV로 내려받을 수 있다.
|
||||||
// 학습 포인트: 개인정보 화면이라 링크가 아니라 표로, 다운로드는 버튼 하나로 명확히 둔다.
|
// 학습 포인트: 개인정보 화면이라 링크가 아니라 표로, 다운로드는 버튼 하나로 명확히 둔다.
|
||||||
function StudentRoster({ students }) {
|
function StudentRoster({ students, onChanged }) {
|
||||||
// 학습 포인트: 파일 다운로드는 axios(JSON) 말고 브라우저의 링크 이동으로 처리한다.
|
// 학습 포인트: 파일 다운로드는 axios(JSON) 말고 브라우저의 링크 이동으로 처리한다.
|
||||||
// withCredentials 링크 이동이면 세션 쿠키도 함께 가서, 서버가 멘토인지 확인할 수 있다.
|
// withCredentials 링크 이동이면 세션 쿠키도 함께 가서, 서버가 멘토인지 확인할 수 있다.
|
||||||
const csvUrl = '/api/mentor/students/export.csv';
|
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 (
|
return (
|
||||||
<Card style={{ padding: 0, overflowX: 'auto' }}>
|
<Card style={{ padding: 0, overflowX: 'auto' }}>
|
||||||
<div style={{ padding: '12px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
<div style={{ padding: '12px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||||
@ -217,6 +236,7 @@ function StudentRoster({ students }) {
|
|||||||
<th>연락처</th>
|
<th>연락처</th>
|
||||||
<th>학교</th>
|
<th>학교</th>
|
||||||
<th>비상 연락</th>
|
<th>비상 연락</th>
|
||||||
|
<th>파기</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -231,10 +251,25 @@ function StudentRoster({ students }) {
|
|||||||
<td style={{ whiteSpace: 'nowrap' }} className="muted">
|
<td style={{ whiteSpace: 'nowrap' }} className="muted">
|
||||||
{s.emergencyName}({s.emergencyRelation}) {s.emergencyPhone}
|
{s.emergencyName}({s.emergencyRelation}) {s.emergencyPhone}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{/* 퇴직·수습종료 시 개인정보 파기. 동의 화면이 약속한 '파기'를 실제로 지키는 버튼 */}
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: 12.5, color: 'var(--rose)', padding: '2px 8px' }}
|
||||||
|
onClick={() => purge(s)}
|
||||||
|
title={`${s.name} 개인정보 파기`}
|
||||||
|
>
|
||||||
|
파기
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<p className="muted" style={{ fontSize: 12, padding: '0 16px 12px' }}>
|
||||||
|
※ [파기]는 학생의 계정·제출물·진도·퀴즈·인적사항을 <b>영구 삭제</b>합니다 —
|
||||||
|
수습 종료/퇴직 시 개인정보 파기 의무를 지키는 수단이에요. 되돌릴 수 없습니다.
|
||||||
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -394,7 +429,7 @@ export default function MentorPage() {
|
|||||||
{roster.length === 0 ? (
|
{roster.length === 0 ? (
|
||||||
<p className="empty">아직 가입한 수습생이 없어요. (학생이 회원가입하면 여기에 표시됩니다)</p>
|
<p className="empty">아직 가입한 수습생이 없어요. (학생이 회원가입하면 여기에 표시됩니다)</p>
|
||||||
) : (
|
) : (
|
||||||
<StudentRoster students={roster} />
|
<StudentRoster students={roster} onChanged={load} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 학생별 코스 진도 ── */}
|
{/* ── 학생별 코스 진도 ── */}
|
||||||
|
|||||||
@ -90,7 +90,7 @@ export default function QuizPage() {
|
|||||||
<>
|
<>
|
||||||
<p>
|
<p>
|
||||||
{result.score === result.total
|
{result.score === result.total
|
||||||
? '만점! 이 코스를 완전히 소화했어요 🎉 아래에서 코스 완료를 눌러주세요.'
|
? '만점! 이 코스를 완전히 소화했어요 🎉 [← 코스로] 돌아가 "이 코스 완료하기"를 눌러 진도를 남겨주세요.'
|
||||||
: result.score >= Math.ceil(result.total * 0.6)
|
: result.score >= Math.ceil(result.total * 0.6)
|
||||||
? '좋아요! 틀린 문제의 해설을 읽고 다시 도전해 보세요.'
|
? '좋아요! 틀린 문제의 해설을 읽고 다시 도전해 보세요.'
|
||||||
: '괜찮아요, 원래 처음엔 그래요. 코스를 한 번 더 읽고 다시 풀어봐요.'}
|
: '괜찮아요, 원래 처음엔 그래요. 코스를 한 번 더 읽고 다시 풀어봐요.'}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user