feat: 학생 자율 가입 + 인적사항 수집 + 경리용 명단/CSV

- 테스트 계정(student1~4) 제거, 시드는 mentor1만 (MENTOR_INITIAL_PASSWORD 환경변수 지원)
- 학생은 회원가입으로 등록 — 첫날 첫 실습 겸 본인이 정보 입력
- StudentProfile 엔티티(users와 1:1 분리): 회사이메일(로컬만 저장+도메인 고정)·생년월일·
  연락처·주소·학교·전공·비상연락처. 주민번호·계좌 등 민감정보는 제외(종이 인사 절차)
- 회원가입 폼 확장 + 이메일 실시간 미리보기(hong → hong@awesomedev.dev)
- 멘토 페이지: 수습생 명단 표 + CSV 다운로드(BOM·이스케이프로 엑셀 한글 안전) — 경리 부담↓
- 이메일 아이디 중복·형식 검증(서버+프론트)

버그 수정: 명단 조회 LazyInitializationException(open-in-view:false) → JOIN FETCH로 해결
  (보안 리뷰가 경고했던 그 함정 — 주석으로 학습 포인트화)

검증: 가입 201 / 이메일중복 409 / 형식오류 400 / 명단 200 / CSV(헤더·BOM·이스케이프) / 학생→명단 403

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AWESOMEDEV 2026-07-17 09:00:03 +09:00
parent 387180dbfc
commit ecb1298d93
11 changed files with 619 additions and 159 deletions

View File

@ -78,26 +78,26 @@ public class SeedDataLoader implements ApplicationRunner {
}
/**
* 사용자 5명 생성.
* 초기 비밀번호는 전원 "mirim2026!" 로그인 변경을 권장한다.
* 학습 포인트: DB엔 BCrypt 해시만 저장된다. 원문 비밀번호는 어디에도 남지 않는다.
* 초기 사용자: 멘토 계정 하나만 만든다. 학생은 각자 회원가입한다.
*
* 학습 포인트: 학생 계정을 시드로 만드나?
* - 학생은 첫날 직접 가입하며 "받은 비밀번호를 바꾸는 것이 첫 일" 몸으로 배운다.
* - 개인정보(이름·이메일·연락처) 본인이 입력하는 것이 정확하다(대신 넣으면 오타·불일치).
* 멘토만 시드로 두는 이유: 학생을 관리할 사람이 처음부터 있어야 하기 때문이다.
*
* MENTOR_INITIAL_PASSWORD 환경변수로 초기 비밀번호를 넣을 있다(운영 권장).
* 없으면 개발용 기본값을 쓰되, 값은 소스에 보이므로 로그인 반드시 바꿔야 한다.
*/
private void seedUsers() {
if (userRepository.count() > 0) {
return; // 이미 사용자가 있으면 건너뛴다
}
// 학습 포인트: encode() 일부러 느린 연산(BCrypt)이라 번만 호출해서 재사용한다.
// 다섯 명이 같은 해시를 공유해도 문제없다 어차피 같은 초기 비밀번호이고,
// 각자 비밀번호를 바꾸는 순간 서로 다른 해시가 된다.
String initialPasswordHash = passwordEncoder.encode("mirim2026!");
String initialPassword = System.getenv().getOrDefault("MENTOR_INITIAL_PASSWORD", "mirim2026!");
String passwordHash = passwordEncoder.encode(initialPassword);
userRepository.save(new User("student1", initialPasswordHash, "수습생1", "STUDENT", "DEV"));
userRepository.save(new User("student2", initialPasswordHash, "수습생2", "STUDENT", "DEV"));
userRepository.save(new User("student3", initialPasswordHash, "수습생3", "STUDENT", "DEV"));
userRepository.save(new User("student4", initialPasswordHash, "수습생4", "STUDENT", "DESIGN"));
userRepository.save(new User("mentor1", initialPasswordHash, "멘토", "MENTOR", "NONE"));
userRepository.save(new User("mentor1", passwordHash, "멘토", "MENTOR", "NONE"));
log.info("초기 사용자 5명을 생성했습니다 (student1~4, mentor1).");
log.info("멘토 계정(mentor1)을 생성했습니다. 학생은 회원가입으로 등록합니다.");
}
private void seedDocuments() {

View File

@ -0,0 +1,144 @@
package dev.awesomedev.mirim.domain;
import jakarta.persistence.*;
import java.time.LocalDate;
/**
* 파일이 하는 :
* 수습생의 인적사항(개인정보) 담는 테이블. 로그인 정보(users) "1:1" 짝을 이룬다.
*
* 학습 포인트 users에 넣지 않고 테이블을 나누나?
* 1) 관심사 분리: users는 "로그인에 필요한 것", 여기는 "인사에 필요한 것" 담는다.
* 2) 개인정보 보호(privacy by design): SQL 입문 코스에서 학생들이 users 테이블을 실습한다.
* 개인정보를 users에 섞으면 실습하다 동료의 주소·연락처가 그대로 보인다.
* 테이블을 나누면 users에는 개인정보가 없어, 실습과 개인정보 보호가 부딪치지 않는다.
*
* 학습 포인트 여기에 주민등록번호·계좌번호는 없다(일부러).
* 그런 민감정보는 법적으로 암호화 저장 의무가 있고 유출 책임이 무겁다.
* 학습용 플랫폼에 두지 않고, 근로계약서(종이 인사 절차) 받는다.
* "받을 수 있는 것" "받아도 되는 것" 다르다 필요한 최소한만 모은다(최소 수집 원칙).
*/
@Entity
@Table(name = "student_profile")
public class StudentProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 개인정보가 누구 것인지. users와 1:1.
* 학습 포인트: @OneToOne도 DB에선 외래키(user_id) 컬럼 하나다. unique를 걸어
* "한 사용자에 프로필 하나" DB가 보장하게 한다.
*/
@OneToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", unique = true)
private User user;
/**
* 회사 이메일의 아이디 부분만 저장한다(: "hong").
* 학습 포인트: 도메인(@awesomedev.dev) 회사 공통이라 저장하지 않고 붙여서 만든다.
* 같은 값을 모든 행에 반복 저장하는 낭비이고, 회사 도메인이 바뀌면 곳만 고치면 된다.
* 전체 이메일이 필요할 fullEmail() 조립한다.
*/
@Column(name = "company_email_local", nullable = false, unique = true, length = 40)
private String companyEmailLocal;
@Column(nullable = false)
private LocalDate birthDate;
@Column(nullable = false, length = 20)
private String phone;
@Column(nullable = false, columnDefinition = "text")
private String address;
/** 출신 학교 */
@Column(nullable = false, length = 60)
private String school;
/** 전공/학과 */
@Column(nullable = false, length = 60)
private String major;
/** 비상 연락처 — 이름 / 관계 / 전화번호 */
@Column(nullable = false, length = 30)
private String emergencyName;
@Column(nullable = false, length = 20)
private String emergencyRelation;
@Column(nullable = false, length = 20)
private String emergencyPhone;
protected StudentProfile() {
}
public StudentProfile(User user, String companyEmailLocal, LocalDate birthDate, String phone,
String address, String school, String major,
String emergencyName, String emergencyRelation, String emergencyPhone) {
this.user = user;
this.companyEmailLocal = companyEmailLocal;
this.birthDate = birthDate;
this.phone = phone;
this.address = address;
this.school = school;
this.major = major;
this.emergencyName = emergencyName;
this.emergencyRelation = emergencyRelation;
this.emergencyPhone = emergencyPhone;
}
/** 회사 도메인은 여기 한 곳에서만 정의한다. 바뀌면 이 상수만 고친다. */
public static final String COMPANY_DOMAIN = "awesomedev.dev";
/** 저장된 아이디 부분에 회사 도메인을 붙여 전체 이메일을 만든다. */
public String fullEmail() {
return companyEmailLocal + "@" + COMPANY_DOMAIN;
}
public Long getId() {
return id;
}
public User getUser() {
return user;
}
public String getCompanyEmailLocal() {
return companyEmailLocal;
}
public LocalDate getBirthDate() {
return birthDate;
}
public String getPhone() {
return phone;
}
public String getAddress() {
return address;
}
public String getSchool() {
return school;
}
public String getMajor() {
return major;
}
public String getEmergencyName() {
return emergencyName;
}
public String getEmergencyRelation() {
return emergencyRelation;
}
public String getEmergencyPhone() {
return emergencyPhone;
}
}

View File

@ -0,0 +1,36 @@
package dev.awesomedev.mirim.repository;
import dev.awesomedev.mirim.domain.StudentProfile;
import dev.awesomedev.mirim.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
import java.util.Optional;
/**
* 파일이 하는 : student_profile 테이블의 DB 접근 창구.
*/
public interface StudentProfileRepository extends JpaRepository<StudentProfile, Long> {
/** 회사 이메일 아이디 중복 확인 (가입 시). */
boolean existsByCompanyEmailLocal(String companyEmailLocal);
/** 특정 사용자의 프로필. */
Optional<StudentProfile> findByUser(User user);
/**
* 전체 프로필을 User까지 함께 가져온다 (멘토 명단·CSV용).
*
* 학습 포인트 JOIN FETCH는 필요한가?
* StudentProfile.user는 LAZY(필요할 따로 조회). 그런데 명단을 만들
* 프로필마다 user의 이름·트랙이 필요하다. 그냥 findAll로 가져오면,
* 나중에 p.getUser() 부르는 순간 SELECT가 나가는데(N+1), 시점이
* 트랜잭션 밖이면 "no session" 에러로 터진다(open-in-view: false라서).
* JOIN FETCH는 "프로필 조회할 때 user도 한 번에 JOIN해서 가져와라" 지시다.
* 번의 쿼리로 채워오니 N+1도, LazyInitializationException도 없다.
* ( 함정은 Progress·Submission 엔티티 주석에서도 반복 등장한다 그만큼 흔하다.)
*/
@Query("SELECT p FROM StudentProfile p JOIN FETCH p.user ORDER BY p.id ASC")
List<StudentProfile> findAllWithUser();
}

View File

@ -1,7 +1,10 @@
package dev.awesomedev.mirim.service;
import dev.awesomedev.mirim.domain.StudentProfile;
import dev.awesomedev.mirim.domain.User;
import dev.awesomedev.mirim.repository.StudentProfileRepository;
import dev.awesomedev.mirim.repository.UserRepository;
import dev.awesomedev.mirim.web.dto.SignupRequest;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
@ -21,10 +24,14 @@ import java.util.List;
public class AuthService {
private final UserRepository userRepository;
private final StudentProfileRepository studentProfileRepository;
private final PasswordEncoder passwordEncoder;
public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
public AuthService(UserRepository userRepository,
StudentProfileRepository studentProfileRepository,
PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.studentProfileRepository = studentProfileRepository;
this.passwordEncoder = passwordEncoder;
}
@ -58,20 +65,35 @@ public class AuthService {
* 멘토 계정이 필요하면 관리자가 별도 절차(현재는 시드 데이터) 만든다.
*/
@Transactional
public User signup(String username, String rawPassword, String name, String track) {
public User signup(SignupRequest req) {
// 학습 포인트: 중복 검사와 저장 사이에 다른 요청이 끼어들 수도 있지만(경쟁 상태),
// username 컬럼에 unique 제약이 걸려 있어 최악의 경우에도 DB가 중복을 막아 준다.
// unique 제약이 걸려 있어 최악의 경우에도 DB가 중복을 막아 준다.
// 사전 검사는 사용자에게 친절한 에러 메시지를 주기 위한 것이다.
if (userRepository.existsByUsername(username)) {
if (userRepository.existsByUsername(req.username())) {
throw new ResponseStatusException(
HttpStatus.CONFLICT, "이미 사용 중인 아이디입니다: " + username);
HttpStatus.CONFLICT, "이미 사용 중인 아이디입니다.");
}
// 회사 이메일 아이디도 중복될 없다 hong@awesomedev.dev를 명이 없으니까.
if (studentProfileRepository.existsByCompanyEmailLocal(req.companyEmailLocal())) {
throw new ResponseStatusException(
HttpStatus.CONFLICT, "이미 사용 중인 회사 이메일 아이디입니다.");
}
// 비밀번호는 반드시 BCrypt로 해시해서 저장한다. 원문은 DB 어디에도 남지 않는다.
String passwordHash = passwordEncoder.encode(rawPassword);
String passwordHash = passwordEncoder.encode(req.password());
User user = new User(username, passwordHash, name, "STUDENT", track);
return userRepository.save(user);
// 학습 포인트: 트랜잭션 안에서 users와 student_profile을 함께 저장한다.
// 하나만 저장되고 끊기면 "계정은 있는데 개인정보가 없는" 반쪽 상태가 된다.
// @Transactional이 "둘 다 되거나, 둘 다 안 되거나" 보장한다(원자성).
User user = userRepository.save(
new User(req.username(), passwordHash, req.name(), "STUDENT", req.track()));
studentProfileRepository.save(new StudentProfile(
user, req.companyEmailLocal(), req.birthDate(), req.phone(), req.address(),
req.school(), req.major(),
req.emergencyName(), req.emergencyRelation(), req.emergencyPhone()));
return user;
}
/**

View File

@ -0,0 +1,77 @@
package dev.awesomedev.mirim.service;
import dev.awesomedev.mirim.domain.StudentProfile;
import dev.awesomedev.mirim.repository.StudentProfileRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* 파일이 하는 :
* 수습생 인적사항 조회와, 경리 담당자를 위한 CSV 명단 만들기.
*
* 학습 포인트: "화면에 표로 보여주기" "파일로 내려받기" 같은 데이터의 다른 출구다.
* 데이터를 꺼내는 (리포지토리) 공유하고, 표현(JSON이냐 CSV냐) 다르게 한다.
*/
@Service
public class StudentProfileService {
private final StudentProfileRepository studentProfileRepository;
public StudentProfileService(StudentProfileRepository studentProfileRepository) {
this.studentProfileRepository = studentProfileRepository;
}
@Transactional(readOnly = true)
public List<StudentProfile> allProfiles() {
// JOIN FETCH로 User까지 번에 트랜잭션 밖에서 getUser() 써도 안전하다.
return studentProfileRepository.findAllWithUser();
}
/**
* 전체 수습생 인적사항을 CSV 문자열로 만든다. 경리 담당자가 엑셀로 열어 쓴다.
*
* 학습 포인트 CSV 이스케이프.
* 주소에 쉼표(,) 들어가면 열이 밀린다. 그래서 값을 큰따옴표로 감싸고,
* 안의 큰따옴표는 ("") 바꾼다. 이게 CSV의 표준 규칙이다.
* "간단해 보이는 형식일수록 예외 처리를 빠뜨리기 쉽다."
*
* 학습 포인트 엑셀 한글 깨짐 방지.
* 앞에 BOM() 붙인다. 이게 없으면 엑셀이 UTF-8을 알아보고 한글이 깨진다.
* (컨트롤러에서 UTF-8로 인코딩해 내려보낸다.)
*/
@Transactional(readOnly = true)
public String exportCsv() {
DateTimeFormatter df = DateTimeFormatter.ofPattern("yyyy-MM-dd");
StringBuilder sb = new StringBuilder(""); // BOM
// 헤더
sb.append("이름,회사이메일,트랙,생년월일,연락처,주소,학교,전공,비상연락-이름,비상연락-관계,비상연락-전화,가입일\n");
for (StudentProfile p : allProfiles()) {
sb.append(csv(p.getUser().getName())).append(',')
.append(csv(p.fullEmail())).append(',')
.append(csv("DESIGN".equals(p.getUser().getTrack()) ? "디자인" : "개발")).append(',')
.append(csv(p.getBirthDate().toString())).append(',')
.append(csv(p.getPhone())).append(',')
.append(csv(p.getAddress())).append(',')
.append(csv(p.getSchool())).append(',')
.append(csv(p.getMajor())).append(',')
.append(csv(p.getEmergencyName())).append(',')
.append(csv(p.getEmergencyRelation())).append(',')
.append(csv(p.getEmergencyPhone())).append(',')
.append(csv(df.format(p.getUser().getCreatedAt().atZone(
java.time.ZoneId.of("Asia/Seoul")))))
.append('\n');
}
return sb.toString();
}
/** CSV 한 칸 이스케이프: 큰따옴표로 감싸고, 안의 큰따옴표는 두 개로. */
private String csv(String value) {
String v = value == null ? "" : value;
return "\"" + v.replace("\"", "\"\"") + "\"";
}
}

View File

@ -120,11 +120,7 @@ public class AuthController {
@PostMapping("/signup")
@ResponseStatus(HttpStatus.CREATED) // 자원(계정) 새로 생겼으므로 REST 관례대로 201
public SignupResponse signup(@Valid @RequestBody SignupRequest signupRequest) {
User user = authService.signup(
signupRequest.username(),
signupRequest.password(),
signupRequest.name(),
signupRequest.track());
User user = authService.signup(signupRequest);
return SignupResponse.from(user);
}

View File

@ -8,15 +8,23 @@ import dev.awesomedev.mirim.service.CodingService;
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.SubmissionService;
import dev.awesomedev.mirim.web.dto.ChecklistProgressOverviewResponse;
import dev.awesomedev.mirim.web.dto.FeedbackRequest;
import dev.awesomedev.mirim.web.dto.MentorResetPasswordRequest;
import dev.awesomedev.mirim.web.dto.MentorSubmissionResponse;
import dev.awesomedev.mirim.web.dto.StudentCourseProgressResponse;
import dev.awesomedev.mirim.web.dto.StudentProfileResponse;
import jakarta.validation.Valid;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
@ -39,6 +47,7 @@ public class MentorController {
private final QuizService quizService;
private final CodingService codingService;
private final AuthService authService;
private final StudentProfileService studentProfileService;
private final UserRepository userRepository;
public MentorController(SubmissionService submissionService,
@ -47,6 +56,7 @@ public class MentorController {
QuizService quizService,
CodingService codingService,
AuthService authService,
StudentProfileService studentProfileService,
UserRepository userRepository) {
this.submissionService = submissionService;
this.courseProgressService = courseProgressService;
@ -54,6 +64,7 @@ public class MentorController {
this.quizService = quizService;
this.codingService = codingService;
this.authService = authService;
this.studentProfileService = studentProfileService;
this.userRepository = userRepository;
}
@ -118,6 +129,32 @@ public class MentorController {
.toList();
}
/** GET /api/mentor/students — 수습생 인적사항 명단 (화면 표시용) */
@GetMapping("/students")
public List<StudentProfileResponse> students() {
return studentProfileService.allProfiles().stream()
.map(StudentProfileResponse::from)
.toList();
}
/**
* GET /api/mentor/students/export.csv 수습생 명단 CSV 다운로드 (경리 담당자용).
*
* 학습 포인트: JSON API와 달리 "파일 다운로드" 응답 헤더가 중요하다.
* - Content-Type: text/csv 브라우저가 화면에 그리지 않고 파일로 인식
* - Content-Disposition: attachment; filename=... "다운로드하라, 이 이름으로"
* 헤더가 없으면 브라우저는 CSV를 그냥 화면에 텍스트로 펼쳐 버린다.
*/
@GetMapping("/students/export.csv")
public ResponseEntity<Resource> exportCsv() {
byte[] bytes = studentProfileService.exportCsv().getBytes(StandardCharsets.UTF_8);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"mirim-students.csv\"")
.contentType(MediaType.parseMediaType("text/csv; charset=UTF-8"))
.body(new ByteArrayResource(bytes));
}
/**
* POST /api/mentor/users/{id}/reset-password 멘토가 학생의 비밀번호를 재설정한다.
*

View File

@ -1,17 +1,24 @@
package dev.awesomedev.mirim.web.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Past;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
/**
* 파일이 하는 :
* 회원가입 요청 본문 {username, password, name, track} 담는 DTO다.
* 회원가입 요청 본문(로그인 정보 + 인적사항) 담는 DTO다.
*
* 학습 포인트: 검증 애노테이션(@NotBlank, @Size, @Pattern) 필드에 붙여 두면,
* 컨트롤러에서 @Valid를 만나는 순간 스프링이 자동으로 검사해 준다.
* 검사에 실패하면 컨트롤러 코드가 실행되기도 전에 400 Bad Request가 반환된다.
* "검증 규칙은 데이터 옆에 선언한다" if문을 컨트롤러마다 반복해서 쓰지 않아도 되는 이유다.
*
* 학습 포인트 : 인적사항을 가입 번에 받는다. 개인정보는 필요한 최소한만
* 주민번호·계좌번호는 여기 없다(민감정보는 종이 인사 절차로). StudentProfile 주석 참고.
*/
public record SignupRequest(
@ -42,6 +49,43 @@ public record SignupRequest(
*/
@NotBlank(message = "트랙은 필수입니다.")
@Pattern(regexp = "^(DEV|DESIGN)$", message = "트랙은 DEV 또는 DESIGN만 선택할 수 있습니다.")
String track
String track,
// 회사 이메일 (아이디 부분만. 도메인 @awesomedev.dev는 서버가 붙인다)
// 학습 포인트: 정규식으로 이메일 아이디에 있는 글자만 허용한다.
// 소문자·숫자··밑줄·하이픈. 대문자나 한글, @ 같은 넣는다(도메인은 우리가 붙이니까).
@NotBlank(message = "회사 이메일 아이디는 필수입니다.")
@Size(min = 2, max = 40, message = "이메일 아이디는 2~40자입니다.")
@Pattern(regexp = "^[a-z0-9._-]+$", message = "이메일 아이디는 소문자·숫자·. _ - 만 쓸 수 있어요.")
String companyEmailLocal,
// 인적사항
@NotNull(message = "생년월일은 필수입니다.")
@Past(message = "생년월일은 오늘보다 이전이어야 합니다.")
LocalDate birthDate, // 프론트에서 "2007-03-15" 형태 문자열 Jackson이 LocalDate로 변환
@NotBlank(message = "연락처는 필수입니다.")
@Pattern(regexp = "^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$", message = "휴대폰 번호 형식이 올바르지 않아요.")
String phone,
@NotBlank(message = "주소는 필수입니다.")
String address,
@NotBlank(message = "학교는 필수입니다.")
String school,
@NotBlank(message = "전공(학과)은 필수입니다.")
String major,
// 비상 연락처
@NotBlank(message = "비상 연락처 이름은 필수입니다.")
String emergencyName,
@NotBlank(message = "비상 연락처 관계는 필수입니다.")
String emergencyRelation, // : , , 형제
@NotBlank(message = "비상 연락처 전화번호는 필수입니다.")
@Pattern(regexp = "^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$", message = "비상 연락처 전화번호 형식이 올바르지 않아요.")
String emergencyPhone
) {
}

View File

@ -0,0 +1,44 @@
package dev.awesomedev.mirim.web.dto;
import dev.awesomedev.mirim.domain.StudentProfile;
import java.time.LocalDate;
/**
* 파일이 하는 : 멘토 화면에 보여줄 수습생 인적사항 명분.
*
* 학습 포인트: 엔티티를 그대로 내보내지 않고 DTO로 옮긴다. 이메일은 아이디+도메인을
* 합친 완성형(fullEmail)으로 준다 화면은 조각이 아니라 완성된 값을 원한다.
*/
public record StudentProfileResponse(
Long userId,
String name,
String username,
String track,
String companyEmail,
LocalDate birthDate,
String phone,
String address,
String school,
String major,
String emergencyName,
String emergencyRelation,
String emergencyPhone
) {
public static StudentProfileResponse from(StudentProfile p) {
return new StudentProfileResponse(
p.getUser().getId(),
p.getUser().getName(),
p.getUser().getUsername(),
p.getUser().getTrack(),
p.fullEmail(),
p.getBirthDate(),
p.getPhone(),
p.getAddress(),
p.getSchool(),
p.getMajor(),
p.getEmergencyName(),
p.getEmergencyRelation(),
p.getEmergencyPhone());
}
}

View File

@ -188,6 +188,55 @@ function WeeklyChecklistTable({ overview }) {
);
}
// CSV .
// : , .
function StudentRoster({ students }) {
// : axios(JSON) .
// withCredentials , .
const csvUrl = '/api/mentor/students/export.csv';
return (
<Card style={{ padding: 0, overflowX: 'auto' }}>
<div style={{ padding: '12px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<span className="muted" style={{ fontSize: 13 }}>
{students.length} · 개인정보이니 취급에 주의하세요
</span>
<a href={csvUrl} className="btn btn-primary" style={{ fontSize: 13 }}>
CSV 내려받기 (경리용)
</a>
</div>
<table className="table table-static" style={{ minWidth: 720 }}>
<thead>
<tr>
<th>이름</th>
<th>회사 이메일</th>
<th>트랙</th>
<th>생년월일</th>
<th>연락처</th>
<th>학교</th>
<th>비상 연락</th>
</tr>
</thead>
<tbody>
{students.map((s) => (
<tr key={s.userId}>
<td style={{ fontWeight: 600, whiteSpace: 'nowrap' }}>{s.name}</td>
<td style={{ whiteSpace: 'nowrap' }}>{s.companyEmail}</td>
<td>{s.track === 'DESIGN' ? '디자인' : '개발'}</td>
<td style={{ whiteSpace: 'nowrap' }}>{s.birthDate}</td>
<td style={{ whiteSpace: 'nowrap' }}>{s.phone}</td>
<td style={{ whiteSpace: 'nowrap' }}>{s.school}</td>
<td style={{ whiteSpace: 'nowrap' }} className="muted">
{s.emergencyName}({s.emergencyRelation}) {s.emergencyPhone}
</td>
</tr>
))}
</tbody>
</table>
</Card>
);
}
// .
// : , ·· .
function QuizResultsTable({ quizResults }) {
@ -247,6 +296,7 @@ export default function MentorPage() {
const [students, setStudents] = useState([]); //
const [checklistOverview, setChecklistOverview] = useState(null); //
const [quizResults, setQuizResults] = useState([]); //
const [roster, setRoster] = useState([]); //
const [loadError, setLoadError] = useState(false); //
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState(null); // id
@ -264,13 +314,15 @@ export default function MentorPage() {
client.get('/mentor/course-progress'),
client.get('/mentor/checklist-progress'),
client.get('/mentor/quiz-results'),
client.get('/mentor/students'),
])
.then(([subsRes, progressRes, checklistRes, quizRes]) => {
.then(([subsRes, progressRes, checklistRes, quizRes, rosterRes]) => {
if (subsRes.status === 'fulfilled') setSubmissions(subsRes.value.data);
if (progressRes.status === 'fulfilled') setStudents(progressRes.value.data);
if (checklistRes.status === 'fulfilled') setChecklistOverview(checklistRes.value.data);
if (quizRes.status === 'fulfilled') setQuizResults(quizRes.value.data);
const anyFailed = [subsRes, progressRes, checklistRes, quizRes].some(
if (rosterRes.status === 'fulfilled') setRoster(rosterRes.value.data);
const anyFailed = [subsRes, progressRes, checklistRes, quizRes, rosterRes].some(
(r) => r.status === 'rejected',
);
setLoadError(anyFailed);
@ -335,8 +387,16 @@ export default function MentorPage() {
</p>
)}
{/* ── 수습생 명단 (인적사항) ── */}
<h2 className="section-title">수습생 명단</h2>
{roster.length === 0 ? (
<p className="empty">아직 가입한 수습생이 없어요. (학생이 회원가입하면 여기에 표시됩니다)</p>
) : (
<StudentRoster students={roster} />
)}
{/* ── 학생별 코스 진도 ── */}
<h2 className="section-title">학생별 코스 진도</h2>
<h2 className="section-title" style={{ marginTop: 24 }}>학생별 코스 진도</h2>
{students.length === 0 ? (
<p className="empty">아직 학생 계정이 없어요.</p>
) : (

View File

@ -1,4 +1,4 @@
// : . ···
// : . + ()
// POST /api/auth/signup , .
// Layout( ) .
import { useState } from 'react';
@ -6,86 +6,90 @@ import { useNavigate, Link, Navigate } from 'react-router-dom';
import { useAuth } from '../AuthContext';
import client from '../api/client';
// . (DEV/DESIGN) User track .
// :
// DB .
const TRACKS = [
{ value: 'DEV', label: '개발' },
{ value: 'DESIGN', label: '디자인' },
];
const COMPANY_DOMAIN = 'awesomedev.dev'; // ( StudentProfile )
// . useState .
// : 3~4 useState , 10 .
// "" " " .
const EMPTY = {
username: '', password: '', passwordConfirm: '', name: '', track: 'DEV',
companyEmailLocal: '', birthDate: '', phone: '', address: '', school: '', major: '',
emergencyName: '', emergencyRelation: '', emergencyPhone: '',
};
export default function SignupPage() {
const { user } = useAuth();
const navigate = useNavigate();
// . useState .
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
const [name, setName] = useState('');
const [track, setTrack] = useState('DEV');
const [form, setForm] = useState(EMPTY);
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
// . true " " .
const [done, setDone] = useState(false);
// .
if (user) {
return <Navigate to="/" replace />;
}
// : , ?
// - : " "
// . (UX) .
// - : .
// (: fetch API .)
// .
// . (... , )
function set(key, value) {
setForm((prev) => ({ ...prev, [key]: value }));
}
// : ( ), ( ). .
function validate() {
if (!username.trim()) {
return '아이디를 입력해 주세요.';
}
if (password.length < 8) {
return '비밀번호는 8자 이상이어야 해요.';
}
if (password !== passwordConfirm) {
return '비밀번호 확인이 일치하지 않아요.';
}
if (!name.trim()) {
return '이름을 입력해 주세요.';
}
return ''; // =
if (!form.username.trim()) return '아이디를 입력해 주세요.';
if (form.password.length < 8) return '비밀번호는 8자 이상이어야 해요.';
if (form.password !== form.passwordConfirm) return '비밀번호 확인이 일치하지 않아요.';
if (!form.name.trim()) return '이름을 입력해 주세요.';
if (!/^[a-z0-9._-]{2,40}$/.test(form.companyEmailLocal))
return '회사 이메일 아이디는 소문자·숫자·. _ - 만 쓸 수 있어요.';
if (!form.birthDate) return '생년월일을 입력해 주세요.';
if (!/^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$/.test(form.phone)) return '연락처 형식을 확인해 주세요.';
if (!form.address.trim()) return '주소를 입력해 주세요.';
if (!form.school.trim()) return '학교를 입력해 주세요.';
if (!form.major.trim()) return '전공을 입력해 주세요.';
if (!form.emergencyName.trim()) return '비상 연락처 이름을 입력해 주세요.';
if (!form.emergencyRelation.trim()) return '비상 연락처 관계를 입력해 주세요.';
if (!/^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$/.test(form.emergencyPhone))
return '비상 연락처 전화번호 형식을 확인해 주세요.';
return '';
}
async function handleSubmit(e) {
// ( ) LoginPage .
e.preventDefault();
setError('');
// .
const message = validate();
if (message) {
setError(message);
return;
}
setSubmitting(true);
try {
await client.post('/auth/signup', {
username: username.trim(),
password,
name: name.trim(),
track,
username: form.username.trim(),
password: form.password,
name: form.name.trim(),
track: form.track,
companyEmailLocal: form.companyEmailLocal.trim(),
birthDate: form.birthDate, // "2007-03-15" LocalDate
phone: form.phone.trim(),
address: form.address.trim(),
school: form.school.trim(),
major: form.major.trim(),
emergencyName: form.emergencyName.trim(),
emergencyRelation: form.emergencyRelation.trim(),
emergencyPhone: form.emergencyPhone.trim(),
});
// : = .
// , .
setDone(true);
} catch (err) {
if (err.response?.status === 409) {
// 409 Conflict: " " .
setError('이미 사용 중인 아이디예요. 다른 아이디를 골라 주세요.');
setError(err.response.data?.message || '이미 사용 중인 정보가 있어요.');
} else if (err.response?.status === 400) {
// 400 Bad Request: .
setError(err.response.data?.message || '입력 내용을 다시 확인해 주세요.');
} else {
setError('가입 중 문제가 생겼어요. 잠시 후 다시 시도해 주세요.');
@ -95,114 +99,110 @@ export default function SignupPage() {
}
}
// .
function Field({ label, k, type = 'text', placeholder, autoComplete }) {
return (
<div className="field">
<label htmlFor={`su-${k}`}>{label}</label>
<input
id={`su-${k}`}
className="input"
type={type}
value={form[k]}
onChange={(e) => set(k, e.target.value)}
placeholder={placeholder}
autoComplete={autoComplete}
/>
</div>
);
}
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 20,
}}
>
<div className="card" style={{ width: '100%', maxWidth: 380 }}>
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
<div className="card" style={{ width: '100%', maxWidth: 460 }}>
<div style={{ textAlign: 'center', marginBottom: 20 }}>
<div className="topnav-logo" style={{ fontSize: 18 }}>
AWESOMEDEV <span>수습 플랫폼</span>
</div>
<p className="muted" style={{ fontSize: 14, marginTop: 6 }}>
회원가입
</p>
<p className="muted" style={{ fontSize: 14, marginTop: 6 }}>수습생 회원가입</p>
</div>
{done ? (
// .
<div style={{ textAlign: 'center' }}>
<p style={{ fontWeight: 700, marginBottom: 6 }}>가입 완료!</p>
<p style={{ fontWeight: 700, marginBottom: 6 }}>가입 완료! 🎉</p>
<p className="muted" style={{ fontSize: 14, marginBottom: 16 }}>
이제 만든 계정으로 로그인해 주세요.
</p>
<button
className="btn btn-primary"
onClick={() => navigate('/login')}
style={{ width: '100%', justifyContent: 'center' }}
>
<button className="btn btn-primary" onClick={() => navigate('/login')}
style={{ width: '100%', justifyContent: 'center' }}>
로그인하러 가기
</button>
</div>
) : (
<form onSubmit={handleSubmit}>
<div className="field">
<label htmlFor="signup-username">아이디</label>
<input
id="signup-username"
className="input"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
autoFocus
required
/>
</div>
<div className="field">
<label htmlFor="signup-password">비밀번호 (8 이상)</label>
<input
id="signup-password"
className="input"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
required
/>
</div>
<div className="field">
<label htmlFor="signup-password-confirm">비밀번호 확인</label>
<input
id="signup-password-confirm"
className="input"
type="password"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
autoComplete="new-password"
required
/>
</div>
<div className="field">
<label htmlFor="signup-name">이름</label>
<input
id="signup-name"
className="input"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
required
/>
</div>
<div className="section-title" style={{ marginTop: 0 }}>계정</div>
<Field label="아이디 (영문·숫자 4자 이상)" k="username" autoComplete="username" />
<Field label="비밀번호 (8자 이상)" k="password" type="password" autoComplete="new-password" />
<Field label="비밀번호 확인" k="passwordConfirm" type="password" autoComplete="new-password" />
<div className="field">
<label>트랙</label>
{/* : " " UI
기존 (.tab) 스타일을 재사용하면 CSS 없이 해결된다. */}
<div className="tabs" style={{ marginBottom: 0 }}>
{TRACKS.map((t) => (
<button
key={t.value}
type="button" // : form button submit, .
className={`tab${track === t.value ? ' active' : ''}`}
onClick={() => setTrack(t.value)}
>
<button key={t.value} type="button"
className={`tab${form.track === t.value ? ' active' : ''}`}
onClick={() => set('track', t.value)}>
{t.label}
</button>
))}
</div>
</div>
{error && <p className="error-text">{error}</p>}
<button
className="btn btn-primary"
type="submit"
disabled={submitting}
style={{ width: '100%', justifyContent: 'center', marginTop: 8 }}
>
<div className="section-title">인적사항</div>
<Field label="이름" k="name" autoComplete="name" />
{/* 회사 이메일 — 아이디만 입력하고 도메인은 고정으로 붙여 보여준다 */}
<div className="field">
<label htmlFor="su-companyEmailLocal">회사 이메일</label>
<div style={{ display: 'flex', alignItems: 'stretch', gap: 6 }}>
<input
id="su-companyEmailLocal"
className="input"
value={form.companyEmailLocal}
onChange={(e) => set('companyEmailLocal', e.target.value.toLowerCase())}
placeholder="hong"
style={{ flex: 1 }}
/>
<span className="badge badge-primary" style={{ display: 'flex', alignItems: 'center', fontSize: 14 }}>
@{COMPANY_DOMAIN}
</span>
</div>
{form.companyEmailLocal && (
<p className="muted" style={{ fontSize: 12.5, marginTop: 4 }}>
{form.companyEmailLocal}@{COMPANY_DOMAIN}
</p>
)}
</div>
<Field label="생년월일" k="birthDate" type="date" />
<Field label="연락처" k="phone" placeholder="010-1234-5678" autoComplete="tel" />
<Field label="주소" k="address" placeholder="시/군/구 이하" />
<Field label="학교" k="school" placeholder="미림마이스터고등학교" />
<Field label="전공(학과)" k="major" />
<div className="section-title">비상 연락처</div>
<Field label="이름" k="emergencyName" />
<Field label="관계 (예: 부, 모)" k="emergencyRelation" />
<Field label="전화번호" k="emergencyPhone" placeholder="010-1234-5678" />
<div className="tip" style={{ marginTop: 4 }}>
여기서 받는 정보는 회사 인사·비상 연락용이에요. 주민등록번호·계좌번호 같은
민감정보는 <strong>여기 넣지 않아요</strong> 근로계약서 작성 따로 받습니다.
</div>
{error && <p className="error-text" style={{ marginTop: 10 }}>{error}</p>}
<button className="btn btn-primary" type="submit" disabled={submitting}
style={{ width: '100%', justifyContent: 'center', marginTop: 12 }}>
{submitting ? '가입 중...' : '가입하기'}
</button>
</form>