diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..e74b4a8 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## #️⃣연관된 이슈 + +> ex) #이슈번호, #이슈번호 + +## 📝작업 내용 + +> 이번 PR에서 작업한 내용을 간략히 설명해주세요(이미지 첨부 가능) + +### 스크린샷 (선택) + + +> 스크린샷 너무 크니까 보기 불편해서 만든 템플릿 + +## 💬리뷰 요구사항(선택) + +> 리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요 +> +> ex) 메서드 XXX의 이름을 더 잘 짓고 싶은데 혹시 좋은 명칭이 있을까요? diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml new file mode 100644 index 0000000..eafaa3e --- /dev/null +++ b/.github/workflows/deploy-backend.yml @@ -0,0 +1,77 @@ +name: Deploy Spring Boot to EC2 + +on: + push: + branches: + - RefactoringBE + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up JDK + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Give gradlew execute permission + run: chmod +x ./gradlew + + - name: Build Spring Boot JAR + run: ./gradlew clean bootJar -x test + + - name: Prepare deployment directory + run: | + mkdir -p deploy + cp build/libs/*.jar deploy/app.jar + + + - name: Create .env file for prod + run: | + echo "SPRING_DATASOURCE_URL=${{ secrets.DEPLOY_DB_URL }}" >> deploy/.env + echo "SPRING_DATASOURCE_USERNAME=${{ secrets.DEPLOY_DB_USER }}" >> deploy/.env + echo "SPRING_DATASOURCE_PASSWORD=${{ secrets.DEPLOY_DB_PASS }}" >> deploy/.env + + echo "DB_ROOT_PASS=${{ secrets.DB_ROOT_PASS }}" >> deploy/.env + + echo "DB_URL=${{ secrets.DB_URL }}" >> deploy/.env + echo "DB_USER=${{ secrets.DB_USER }}" >> deploy/.env + echo "DB_PASS=${{ secrets.DB_PASS }}" >> deploy/.env + + echo "SPRING_DATASOURCE_DRIVER_CLASS_NAME=com.mysql.cj.jdbc.Driver" >> deploy/.env + echo "JWT_SECRET=${{ secrets.DEPLOY_JWT_SECRET }}" >> deploy/.env + echo "JWT_ACCESS_TOKEN_EXPIRATION=86400000" >> deploy/.env + echo "JWT_REFRESH_TOKEN_EXPIRATION=604800000" >> deploy/.env + + echo "MYSQL_ROOT_PASSWORD=${{ secrets.DB_ROOT_PASS }}" >> deploy/.env + + echo "SPRING_JPA_HIBERNATE_DDL_AUTO=update" >> deploy/.env + + + - name: Upload app.jar and .env to EC2 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.PEM_KEY }} + source: "deploy/app.jar,deploy/.env,docker-compose.yml" + target: "/home/ec2-user/ccgo" + strip_components: 1 + - name: SSH into EC2 and restart Docker container + uses: appleboy/ssh-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.PEM_KEY }} + script: | + cd ~/ccgo + docker stop ccgo-app || true + docker rm ccgo-app || true + docker rmi ccgo-app || true + docker compose build + docker compose up -d \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6cd1517..4c06862 100644 --- a/.gitignore +++ b/.gitignore @@ -203,6 +203,19 @@ application-*.properties /bin/ # resources -/src/main/resources/application.properties +.src/main/resources +src/main/resources -# End of https://www.toptal.com/developers/gitignore/api/windows,intellij,java,gradle \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/windows,intellij,java,gradle + +# pem 파일 (혹시나) +*.pem + +# .gitignore +.env + +# +.frontend + +# 프론트엔드 파일 제외 +CC_Maker_FE/ \ No newline at end of file diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml index 7a4f374..9283aae 100644 --- a/.idea/dataSources.xml +++ b/.idea/dataSources.xml @@ -1,11 +1,11 @@ - + mysql.8 true com.mysql.cj.jdbc.Driver - jdbc:mysql://localhost:3306 + jdbc:mysql://3.39.54.128:3306 diff --git a/.idea/modules/CC_Maker_BE.main.iml b/.idea/modules/CC_Maker_BE.main.iml index 5491e61..10d37db 100644 --- a/.idea/modules/CC_Maker_BE.main.iml +++ b/.idea/modules/CC_Maker_BE.main.iml @@ -14,7 +14,9 @@ - + + + @@ -35,8 +37,9 @@ - + + @@ -93,7 +96,6 @@ - diff --git a/.idea/modules/CC_Maker_BE.test.iml b/.idea/modules/CC_Maker_BE.test.iml index 3b1bc40..ae627e9 100644 --- a/.idea/modules/CC_Maker_BE.test.iml +++ b/.idea/modules/CC_Maker_BE.test.iml @@ -19,8 +19,9 @@ - + + @@ -104,7 +105,6 @@ - diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..c2d50dc --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 35eb1dd..8cbb340 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -2,5 +2,6 @@ + \ No newline at end of file diff --git a/CC_Maker_FE b/CC_Maker_FE new file mode 160000 index 0000000..26f55c2 --- /dev/null +++ b/CC_Maker_FE @@ -0,0 +1 @@ +Subproject commit 26f55c206bd6b58e59904eeaf2e21be9bdddf0bb diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..62416c9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM eclipse-temurin:17-jdk-jammy +COPY build/libs/*.jar app.jar +EXPOSE 8080 +CMD ["java", "-jar", "app.jar"] diff --git a/build.gradle b/build.gradle index ff67e6e..37c0eb3 100644 --- a/build.gradle +++ b/build.gradle @@ -27,19 +27,23 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + compileOnly 'org.projectlombok:lombok' - runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' + + implementation 'com.mysql:mysql-connector-j' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - implementation 'io.jsonwebtoken:jjwt-api:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' - implementation 'org.springframework.boot:spring-boot-starter-validation' - } + tasks.named('test') { useJUnitPlatform() } diff --git a/create_mission_history_table.sql b/create_mission_history_table.sql new file mode 100644 index 0000000..588dd60 --- /dev/null +++ b/create_mission_history_table.sql @@ -0,0 +1,17 @@ +-- mission_history 테이블 생성 +CREATE TABLE mission_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + sub_group_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + mission_template_id BIGINT NOT NULL, + completed_at DATETIME NOT NULL, + created_at DATETIME NOT NULL, + FOREIGN KEY (sub_group_id) REFERENCES sub_group(id), + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (mission_template_id) REFERENCES mission_template(id) +); + +-- 인덱스 추가 (성능 향상을 위해) +CREATE INDEX idx_mission_history_user_id ON mission_history(user_id); +CREATE INDEX idx_mission_history_sub_group_id ON mission_history(sub_group_id); +CREATE INDEX idx_mission_history_completed_at ON mission_history(completed_at); diff --git a/create_privacy_agreement_tables.sql b/create_privacy_agreement_tables.sql new file mode 100644 index 0000000..863687c --- /dev/null +++ b/create_privacy_agreement_tables.sql @@ -0,0 +1,58 @@ +-- 개인정보 동의서 테이블 생성 +CREATE TABLE privacy_agreements ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + version VARCHAR(20) NOT NULL UNIQUE, + content TEXT NOT NULL, + effective_date DATE NOT NULL, + created_at DATETIME NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE +); + +-- users 테이블에 개인정보 동의 관련 컬럼 추가 +ALTER TABLE users +ADD COLUMN privacy_agreement_version VARCHAR(20), +ADD COLUMN privacy_agreed BOOLEAN DEFAULT FALSE, +ADD COLUMN privacy_agreed_at DATETIME, +ADD COLUMN privacy_agreed_method VARCHAR(50), +ADD COLUMN privacy_agreed_environment VARCHAR(20); + +-- 초기 개인정보 동의서 데이터 삽입 (v1.0) +INSERT INTO privacy_agreements (version, content, effective_date, created_at, is_active) VALUES ( + 'v1.0', + '개인정보 수집 및 이용에 대한 안내 + +1. 수집하는 개인정보 항목 +- 필수항목: 이름, 생년월일, 이메일, 비밀번호, 성별 +- 선택항목: 없음 + +2. 개인정보의 수집 및 이용목적 +- 회원가입 및 서비스 이용 +- 서비스 제공 및 운영 +- 고객상담 및 문의응답 +- 서비스 개선 및 신규 서비스 개발 + +3. 개인정보의 보유 및 이용기간 +- 회원 탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당 기간까지) + +4. 개인정보의 파기절차 및 방법 +- 전자적 파일 형태로 저장된 개인정보는 복구 불가능한 방법으로 영구 삭제 +- 종이에 출력된 개인정보는 분쇄기로 분쇄하거나 소각을 통하여 파기 + +5. 동의 거부권 및 동의 거부에 따른 불이익 +- 개인정보 수집 및 이용에 대한 동의를 거부할 수 있습니다. +- 동의를 거부할 경우 회원가입 및 서비스 이용이 제한됩니다. + +위 내용에 동의하시면 체크박스를 선택해 주세요. + +───────────────────────────────────────── +시행일: 2025.08.20 / 버전: v1.0', + '2025-08-20', + NOW(), + TRUE +); + +-- 인덱스 생성 +CREATE INDEX idx_privacy_agreements_version ON privacy_agreements(version); +CREATE INDEX idx_privacy_agreements_active ON privacy_agreements(is_active); +CREATE INDEX idx_users_privacy_version ON users(privacy_agreement_version); +CREATE INDEX idx_users_privacy_agreed ON users(privacy_agreed); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a3acf45 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +version: '3.8' + +services: + app: + build: . + image: ccgo-app:latest + container_name: ccgo-app + ports: + - "8080:8080" + restart: always + env_file: + - .env + depends_on: + - mysql + environment: + TZ: Asia/Seoul + + mysql: + image: mysql:8 + container_name: mysql + restart: always + ports: + - "3306:3306" + environment: + MYSQL_ROOT_PASSWORD: ${DB_PASS} + MYSQL_DATABASE: ccmake + MYSQL_USER: ${DB_USER} + MYSQL_PASSWORD: ${DB_PASS} + + volumes: + - mysql_data:/var/lib/mysql + +volumes: + mysql_data: diff --git a/src/main/java/com/ccapp/ccgo/auth/controller/AuthController.java b/src/main/java/com/ccapp/ccgo/auth/controller/AuthController.java new file mode 100644 index 0000000..ebad563 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/auth/controller/AuthController.java @@ -0,0 +1,177 @@ +package com.ccapp.ccgo.auth.controller; + +import com.ccapp.ccgo.auth.dto.LoginRequestDto; +import com.ccapp.ccgo.auth.dto.LoginResponseDto; +import com.ccapp.ccgo.auth.dto.TokenResponseDto; +import com.ccapp.ccgo.auth.jwt.JwtProvider; +import com.ccapp.ccgo.auth.service.AuthService; +import com.ccapp.ccgo.auth.service.RefreshTokenService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@Slf4j +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + private final JwtProvider jwtProvider; + private final RefreshTokenService refreshTokenService; + + @Value("${app.cookie.secure:true}") + private boolean cookieSecure; + + @Value("${app.cookie.same-site:Lax}") + private String cookieSameSite; + + @Value("${jwt.access-token-expiration:3600}") // 초 단위 + private long accessTokenMaxAge; + + @Value("${jwt.refresh-token-expiration:604800}") // 초 단위 + private long refreshTokenMaxAge; + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequestDto requestDto) { + String maskedEmail = maskEmail(requestDto.getEmail()); + log.info("로그인 요청 받음: {}", maskedEmail); + + try { + LoginResponseDto response = authService.login(requestDto.getEmail(), requestDto.getPassword()); + + HttpHeaders headers = createTokenCookies(response.getAccessToken(), response.getRefreshToken()); + + log.info("발급된 쿠키: {}", headers.get(HttpHeaders.SET_COOKIE)); + + + // 응답 바디에 토큰도 포함해서 내려줌 (Expo Go용) + Map responseBody = Map.of( + "message", "로그인 성공", + "data", LoginResponseDto.builder() + .userId(response.getUserId()) + .email(response.getEmail()) + .name(response.getName()) + .teams(response.getTeams()) + .build(), + "accessToken", response.getAccessToken(), + "refreshToken", response.getRefreshToken() + ); + + return ResponseEntity.ok() + .headers(headers) + .body(responseBody); + + } catch (BadCredentialsException e) { + log.warn("로그인 실패 - 잘못된 이메일 또는 비밀번호: {}", maskedEmail); + return ResponseEntity.status(401).body(Map.of("message", "이메일 또는 비밀번호가 잘못되었습니다.")); + } catch (Exception e) { + log.error("❌ 로그인 중 오류 발생", e); + return ResponseEntity.status(500).body(Map.of("message", "서버 오류가 발생했습니다.")); + } + } + + @PostMapping("/refresh") + public ResponseEntity refreshToken( + @CookieValue(value = "accessToken", required = false) String accessToken, + @CookieValue(value = "refreshToken", required = false) String refreshToken) { + try { + if (accessToken == null || refreshToken == null) { + return ResponseEntity.status(401).body(Map.of("message", "토큰이 없습니다.")); + } + + TokenResponseDto tokenResponse = authService.refreshToken(accessToken, refreshToken); + HttpHeaders headers = createTokenCookies(tokenResponse.getAccessToken(), tokenResponse.getRefreshToken()); + + log.info("새로운 쿠키: {}", headers.get(HttpHeaders.SET_COOKIE)); + + return ResponseEntity.ok() + .headers(headers) + .body(Map.of( + "message", "토큰 갱신 성공", + "accessToken", tokenResponse.getAccessToken(), + "refreshToken", tokenResponse.getRefreshToken() + )); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(401).body(Map.of("message", e.getMessage())); + } catch (Exception e) { + log.error("❌ 토큰 갱신 중 오류 발생", e); + return ResponseEntity.status(500).body(Map.of("message", "서버 오류가 발생했습니다.")); + } + } + + + private HttpHeaders createTokenCookies(String accessToken, String refreshToken) { + ResponseCookie accessTokenCookie = ResponseCookie.from("accessToken", accessToken) + .httpOnly(true) + .path("/") + .maxAge(accessTokenMaxAge) + .secure(cookieSecure) + .sameSite(cookieSameSite) + .build(); + + ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", refreshToken) + .httpOnly(true) + .path("/") + .maxAge(refreshTokenMaxAge) + .secure(cookieSecure) + .sameSite(cookieSameSite) + .build(); + + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()); + headers.add(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); + return headers; + } + + private String maskEmail(String email) { + if (email == null || !email.contains("@")) return "unknown"; + String[] parts = email.split("@"); + return parts[0].charAt(0) + "***@" + parts[1]; + } + + @PostMapping("/logout") + public ResponseEntity logout(@CookieValue(value = "refreshToken", required = false) String refreshToken) { + if (refreshToken == null || !jwtProvider.validateToken(refreshToken)) { + return ResponseEntity.status(401).body(Map.of("message", "유효하지 않은 리프레시 토큰입니다.")); + } + + // Redis에서 Refresh Token 삭제 + String email = jwtProvider.getEmailFromToken(refreshToken); + refreshTokenService.deleteRefreshToken(email); + + // 쿠키 삭제 (maxAge=0) + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.SET_COOKIE, ResponseCookie.from("accessToken", "") + .path("/") + .maxAge(0) + .httpOnly(true) + .secure(cookieSecure) + .sameSite(cookieSameSite) + .build() + .toString()); + headers.add(HttpHeaders.SET_COOKIE, ResponseCookie.from("refreshToken", "") + .path("/") + .maxAge(0) + .httpOnly(true) + .secure(cookieSecure) + .sameSite(cookieSameSite) + .build() + .toString()); + + return ResponseEntity.ok() + .headers(headers) + .body(Map.of("message", "로그아웃 처리 완료")); + } + + + +} diff --git a/src/main/java/com/ccapp/ccgo/dto/LoginRequestDto.java b/src/main/java/com/ccapp/ccgo/auth/dto/LoginRequestDto.java similarity index 52% rename from src/main/java/com/ccapp/ccgo/dto/LoginRequestDto.java rename to src/main/java/com/ccapp/ccgo/auth/dto/LoginRequestDto.java index 7230285..96ac2d8 100644 --- a/src/main/java/com/ccapp/ccgo/dto/LoginRequestDto.java +++ b/src/main/java/com/ccapp/ccgo/auth/dto/LoginRequestDto.java @@ -1,18 +1,24 @@ -package com.ccapp.ccgo.dto; +package com.ccapp.ccgo.auth.dto; import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; +import lombok.Builder; import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; @Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder public class LoginRequestDto { - @NotNull(message = "이메일은 필수입니다.") + @NotBlank(message = "이메일은 필수입니다.") @Email(message = "올바른 이메일 형식이어야 합니다.") private String email; - @NotNull(message = "비밀번호는 필수입니다.") + @NotBlank(message = "비밀번호는 필수입니다.") @Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다.") private String password; } diff --git a/src/main/java/com/ccapp/ccgo/auth/dto/LoginResponseDto.java b/src/main/java/com/ccapp/ccgo/auth/dto/LoginResponseDto.java new file mode 100644 index 0000000..e0e8264 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/auth/dto/LoginResponseDto.java @@ -0,0 +1,33 @@ +package com.ccapp.ccgo.auth.dto; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LoginResponseDto { + private String grantType; // Bearer + private String accessToken; + private String refreshToken; + private Long userId; + private String email; + private String name; + private List teams; + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TeamInfo { + private Long teamId; + private String teamName; + private String role; // 팀 내 역할 (예: "LEADER", "MEMBER") + private boolean isSurveyCompleted; + } +} diff --git a/src/main/java/com/ccapp/ccgo/dto/TokenResponseDto.java b/src/main/java/com/ccapp/ccgo/auth/dto/TokenResponseDto.java similarity index 68% rename from src/main/java/com/ccapp/ccgo/dto/TokenResponseDto.java rename to src/main/java/com/ccapp/ccgo/auth/dto/TokenResponseDto.java index fd9e5cc..eba2601 100644 --- a/src/main/java/com/ccapp/ccgo/dto/TokenResponseDto.java +++ b/src/main/java/com/ccapp/ccgo/auth/dto/TokenResponseDto.java @@ -1,12 +1,14 @@ -package com.ccapp.ccgo.dto; +package com.ccapp.ccgo.auth.dto; -import lombok.AllArgsConstructor; -import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; +import lombok.Builder; -@Data +@Getter @NoArgsConstructor @AllArgsConstructor +@Builder public class TokenResponseDto { private String accessToken; private String refreshToken; diff --git a/src/main/java/com/ccapp/ccgo/auth/entity/RefreshToken.java b/src/main/java/com/ccapp/ccgo/auth/entity/RefreshToken.java new file mode 100644 index 0000000..4539266 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/auth/entity/RefreshToken.java @@ -0,0 +1,35 @@ +package com.ccapp.ccgo.auth.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "refresh_tokens") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RefreshToken { + + @Id + private String email; + + @Column(nullable = false) + private String refreshToken; + + @Column(nullable = false) + private LocalDateTime expiresAt; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/ccapp/ccgo/auth/jwt/CustomAuthenticationEntryPoint.java b/src/main/java/com/ccapp/ccgo/auth/jwt/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..1769f73 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/auth/jwt/CustomAuthenticationEntryPoint.java @@ -0,0 +1,21 @@ +package com.ccapp.ccgo.auth.jwt; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; // ✅ 추가 +import jakarta.servlet.ServletException; // ✅ 추가 + +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("{\"message\": \"인증이 필요합니다.\"}"); + } +} diff --git a/src/main/java/com/ccapp/ccgo/jwt/JwtAuthenticationFilter.java b/src/main/java/com/ccapp/ccgo/auth/jwt/JwtAuthenticationFilter.java similarity index 63% rename from src/main/java/com/ccapp/ccgo/jwt/JwtAuthenticationFilter.java rename to src/main/java/com/ccapp/ccgo/auth/jwt/JwtAuthenticationFilter.java index 693d646..c9c0d4d 100644 --- a/src/main/java/com/ccapp/ccgo/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/ccapp/ccgo/auth/jwt/JwtAuthenticationFilter.java @@ -1,8 +1,9 @@ -package com.ccapp.ccgo.jwt; +package com.ccapp.ccgo.auth.jwt; +import com.ccapp.ccgo.auth.service.LoginUserDetailsService; import jakarta.servlet.FilterChain; -import jakarta.servlet.http.Cookie; import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -29,26 +30,17 @@ protected void doFilterInternal(HttpServletRequest request, FilterChain filterChain) throws ServletException, IOException { - String token = null; - - if (request.getCookies() != null) { - for (Cookie cookie : request.getCookies()) { - if ("accessToken".equals(cookie.getName())) { - token = cookie.getValue(); - break; - } - } + // permitAll 경로는 JWT 필터를 건너뛰기 + String requestURI = request.getRequestURI(); + if (requestURI.equals("/api/user/register") || requestURI.startsWith("/api/auth/")) { + filterChain.doFilter(request, response); + return; } - if (token == null) { - String authHeader = request.getHeader("Authorization"); - if (authHeader != null && authHeader.startsWith("Bearer ")) { - token = authHeader.substring(7); - } - } + String token = extractToken(request); try { - if (token != null && jwtProvider.validateToken(token)) { + if (token != null && jwtProvider.validateAccessToken(token)) { String email = jwtProvider.getEmailFromToken(token); if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) { @@ -60,13 +52,36 @@ protected void doFilterInternal(HttpServletRequest request, authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); + log.debug("✅ JWT 인증 성공: {}", email); } } } catch (Exception e) { - // 로그 추가 가능 (필요 시) - log.error("JWT 인증 처리 중 오류 발생", e); + log.error("❌ JWT 인증 처리 중 오류 발생", e); } filterChain.doFilter(request, response); } -} \ No newline at end of file + + /** + * Authorization 헤더 → 쿠키 순서로 토큰 추출 + */ + private String extractToken(HttpServletRequest request) { + // 1. Authorization 헤더 우선 + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + log.debug("📌 Authorization 헤더에서 토큰 추출"); + return authHeader.substring(7); + } + + // 2. 쿠키 확인 + if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if ("accessToken".equals(cookie.getName())) { + log.debug("📌 쿠키에서 토큰 추출"); + return cookie.getValue(); + } + } + } + return null; + } +} diff --git a/src/main/java/com/ccapp/ccgo/auth/jwt/JwtProvider.java b/src/main/java/com/ccapp/ccgo/auth/jwt/JwtProvider.java new file mode 100644 index 0000000..ae6634a --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/auth/jwt/JwtProvider.java @@ -0,0 +1,147 @@ +package com.ccapp.ccgo.auth.jwt; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Date; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Component +public class JwtProvider { + + private Key key; + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.access-token-expiration}") + private long accessTokenExpiration; + + @Value("${jwt.refresh-token-expiration}") + private long refreshTokenExpiration; + + @PostConstruct + public void init() { + byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); + if (keyBytes.length < 32) { + log.error("JWT Secret 키가 너무 짧습니다. 32바이트 이상 권장!"); + } + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public String createAccessToken(Authentication authentication) { + String username = authentication.getName(); + String roles = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + return buildToken(username, Map.of("roles", roles, "type", "ACCESS"), accessTokenExpiration); + } + + public String createRefreshToken(Authentication authentication) { + return buildToken(authentication.getName(), Map.of("type", "REFRESH"), refreshTokenExpiration); + } + + private String buildToken(String subject, Map claims, long validityInMs) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + validityInMs); + JwtBuilder builder = Jwts.builder() + .setSubject(subject) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(key, SignatureAlgorithm.HS256); + + if (claims != null) { + builder.addClaims(claims); + } + + return builder.compact(); + } + + public boolean validateToken(String token) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + return true; + } catch (ExpiredJwtException e) { + log.warn("JWT 만료: {}", e.getMessage()); + } catch (UnsupportedJwtException e) { + log.warn("지원되지 않는 JWT: {}", e.getMessage()); + } catch (MalformedJwtException e) { + log.warn("잘못된 JWT 구조: {}", e.getMessage()); + } catch (SecurityException e) { + log.warn("JWT 서명 오류: {}", e.getMessage()); + } catch (IllegalArgumentException e) { + log.warn("JWT 토큰 값 없음: {}", e.getMessage()); + } + return false; + } + + public boolean validateAccessToken(String token) { + try { + Claims claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + + return "ACCESS".equals(claims.get("type")); + } catch (Exception e) { + log.warn("Access Token 검증 실패: {}", e.getMessage()); + return false; + } + } + + public boolean validateRefreshToken(String token) { + try { + Claims claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + + return "REFRESH".equals(claims.get("type")); + } catch (Exception e) { + log.warn("Refresh Token 검증 실패: {}", e.getMessage()); + return false; + } + } + + public boolean validateTokenStructure(String token) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + return true; + } catch (ExpiredJwtException e) { + // 만료는 허용 (구조는 유효) + return true; + } catch (Exception e) { + return false; + } + } + + public String getEmailFromToken(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject(); + } + + public long getAccessTokenExpiration() { + return accessTokenExpiration; + } + + public long getRefreshTokenExpiration() { + return refreshTokenExpiration; + } + +} diff --git a/src/main/java/com/ccapp/ccgo/jwt/JwtToken.java b/src/main/java/com/ccapp/ccgo/auth/jwt/JwtToken.java similarity index 89% rename from src/main/java/com/ccapp/ccgo/jwt/JwtToken.java rename to src/main/java/com/ccapp/ccgo/auth/jwt/JwtToken.java index 26fbaf1..1133e00 100644 --- a/src/main/java/com/ccapp/ccgo/jwt/JwtToken.java +++ b/src/main/java/com/ccapp/ccgo/auth/jwt/JwtToken.java @@ -1,4 +1,4 @@ -package com.ccapp.ccgo.jwt; +package com.ccapp.ccgo.auth.jwt; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/com/ccapp/ccgo/auth/jwt/LoginUserDetails.java b/src/main/java/com/ccapp/ccgo/auth/jwt/LoginUserDetails.java new file mode 100644 index 0000000..23a0e2a --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/auth/jwt/LoginUserDetails.java @@ -0,0 +1,48 @@ +package com.ccapp.ccgo.auth.jwt; + +import com.ccapp.ccgo.team.entity.TeamMember; +import com.ccapp.ccgo.user.entity.User; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import java.util.List; +import java.util.Collection; +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; + +@Getter +public class LoginUserDetails implements UserDetails { + + private final User user; + private final List teamMembers; + + public LoginUserDetails(User user, List teamMembers) { + this.user = user; + this.teamMembers = teamMembers; + } + + @Override + public Collection getAuthorities() { + if (teamMembers == null || teamMembers.isEmpty()) { + return Collections.emptyList(); + } + + Set authorities = teamMembers.stream() + .map(TeamMember::getRole) // Role enum 반환 가정 + .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name())) + .collect(Collectors.toSet()); + + return authorities; + } + + // 이하 기존 메서드 동일 + @Override public String getPassword() { return user.getPassword(); } + @Override public String getUsername() { return user.getEmail(); } + @Override public boolean isAccountNonExpired() { return true; } + @Override public boolean isAccountNonLocked() { return true; } + @Override public boolean isCredentialsNonExpired() { return true; } + @Override public boolean isEnabled() { return true; } +} + diff --git a/src/main/java/com/ccapp/ccgo/auth/repository/RefreshTokenRepository.java b/src/main/java/com/ccapp/ccgo/auth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..8ec172a --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,17 @@ +package com.ccapp.ccgo.auth.repository; + +import com.ccapp.ccgo.auth.entity.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + + Optional findByEmail(String email); + + void deleteByEmail(String email); + + boolean existsByEmail(String email); +} diff --git a/src/main/java/com/ccapp/ccgo/auth/service/AuthService.java b/src/main/java/com/ccapp/ccgo/auth/service/AuthService.java new file mode 100644 index 0000000..387de6a --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/auth/service/AuthService.java @@ -0,0 +1,93 @@ +package com.ccapp.ccgo.auth.service; + +import com.ccapp.ccgo.auth.dto.LoginResponseDto; +import com.ccapp.ccgo.auth.dto.TokenResponseDto; +import com.ccapp.ccgo.auth.jwt.JwtProvider; +import com.ccapp.ccgo.auth.jwt.LoginUserDetails; +import com.ccapp.ccgo.team.entity.TeamMember; +import com.ccapp.ccgo.team.repository.TeamMemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final AuthenticationManager authenticationManager; + private final JwtProvider jwtProvider; + private final TeamMemberRepository teamMemberRepository; + private final LoginUserDetailsService loginUserDetailsService; + private final RefreshTokenService refreshTokenService; + + public LoginResponseDto login(String email, String password) { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(email, password) + ); + + String accessToken = jwtProvider.createAccessToken(authentication); + String refreshToken = jwtProvider.createRefreshToken(authentication); + + LoginUserDetails userDetails = (LoginUserDetails) authentication.getPrincipal(); + var user = userDetails.getUser(); + + // Refresh Token DB 저장 + refreshTokenService.saveRefreshToken(email, refreshToken, jwtProvider.getRefreshTokenExpiration()); + + List teamMembers = teamMemberRepository.findAllByUserAndIsActiveTrue(user); + + List teams = teamMembers.stream() + .map(tm -> LoginResponseDto.TeamInfo.builder() + .teamId(tm.getTeam().getTeamId()) + .teamName(tm.getTeam().getTeamName()) + .role(tm.getRole().name()) + .isSurveyCompleted(tm.isSurveyCompleted()) + .build()) + .toList(); + + return LoginResponseDto.builder() + .userId(user.getId()) + .email(user.getEmail()) + .name(user.getName()) + .teams(teams) + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + public TokenResponseDto refreshToken(String accessToken, String refreshToken) { + // 1. Access Token 구조 검증 (만료는 허용) + if (!jwtProvider.validateTokenStructure(accessToken)) { + throw new IllegalArgumentException("유효하지 않은 Access Token 구조입니다."); + } + + // 2. Refresh Token 검증 + if (!jwtProvider.validateRefreshToken(refreshToken)) { + throw new IllegalArgumentException("유효하지 않은 Refresh Token입니다."); + } + + // 3. DB의 Refresh Token과 비교 + String email = jwtProvider.getEmailFromToken(refreshToken); + if (!refreshTokenService.validateRefreshToken(email, refreshToken)) { + throw new IllegalArgumentException("유효하지 않은 Refresh Token입니다."); + } + + // 4. 새로운 토큰 생성 + LoginUserDetails userDetails = (LoginUserDetails) loginUserDetailsService.loadUserByUsername(email); + Authentication authentication = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities() + ); + + String newAccessToken = jwtProvider.createAccessToken(authentication); + String newRefreshToken = jwtProvider.createRefreshToken(authentication); + + // 5. Refresh Token Rotation (기존 토큰 삭제, 새로운 토큰 저장) + refreshTokenService.updateRefreshToken(email, newRefreshToken, jwtProvider.getRefreshTokenExpiration()); + + return new TokenResponseDto(newAccessToken, newRefreshToken); + } +} diff --git a/src/main/java/com/ccapp/ccgo/auth/service/LoginUserDetailsService.java b/src/main/java/com/ccapp/ccgo/auth/service/LoginUserDetailsService.java new file mode 100644 index 0000000..773beea --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/auth/service/LoginUserDetailsService.java @@ -0,0 +1,37 @@ +package com.ccapp.ccgo.auth.service; + +import com.ccapp.ccgo.auth.jwt.LoginUserDetails; +import com.ccapp.ccgo.team.entity.TeamMember; +import com.ccapp.ccgo.team.repository.TeamMemberRepository; +import com.ccapp.ccgo.user.entity.User; +import com.ccapp.ccgo.user.repository.UserRepository; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class LoginUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + private final TeamMemberRepository teamMemberRepository; + + // teamMemberRepository도 생성자 인자로 추가 + public LoginUserDetailsService(UserRepository userRepository, + TeamMemberRepository teamMemberRepository) { + this.userRepository = userRepository; + this.teamMemberRepository = teamMemberRepository; + } + + // ✅ 이메일로 유저 조회 → LoginUserDetails 로 감싸서 리턴 + @Override + public UserDetails loadUserByUsername(String email) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다.")); + List teamMembers = teamMemberRepository.findAllByUserAndIsActiveTrue(user); + + return new LoginUserDetails(user, teamMembers); + } +} diff --git a/src/main/java/com/ccapp/ccgo/auth/service/RefreshTokenService.java b/src/main/java/com/ccapp/ccgo/auth/service/RefreshTokenService.java new file mode 100644 index 0000000..a1e56e2 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/auth/service/RefreshTokenService.java @@ -0,0 +1,65 @@ +package com.ccapp.ccgo.auth.service; + +import com.ccapp.ccgo.auth.entity.RefreshToken; +import com.ccapp.ccgo.auth.repository.RefreshTokenRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + + private final RefreshTokenRepository refreshTokenRepository; + + public void saveRefreshToken(String email, String refreshToken, long expirationMillis) { + LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(expirationMillis / 1000); + LocalDateTime createdAt = LocalDateTime.now(); + + RefreshToken token = RefreshToken.builder() + .email(email) + .refreshToken(refreshToken) + .expiresAt(expiresAt) + .createdAt(createdAt) + .build(); + + refreshTokenRepository.save(token); + log.info("Refresh Token 저장 완료: {}", email); + } + + public String getRefreshToken(String email) { + return refreshTokenRepository.findByEmail(email) + .map(RefreshToken::getRefreshToken) + .orElse(null); + } + + public boolean validateRefreshToken(String email, String refreshToken) { + return refreshTokenRepository.findByEmail(email) + .map(token -> token.getRefreshToken().equals(refreshToken) && + token.getExpiresAt().isAfter(LocalDateTime.now())) + .orElse(false); + } + + public void deleteRefreshToken(String email) { + refreshTokenRepository.deleteByEmail(email); + log.info("Refresh Token 삭제 완료: {}", email); + } + + public void updateRefreshToken(String email, String newRefreshToken, long expirationMillis) { + LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(expirationMillis / 1000); + LocalDateTime createdAt = LocalDateTime.now(); + + RefreshToken token = RefreshToken.builder() + .email(email) + .refreshToken(newRefreshToken) + .expiresAt(expiresAt) + .createdAt(createdAt) + .build(); + + refreshTokenRepository.save(token); + log.info("Refresh Token 업데이트 완료: {}", email); + } +} diff --git a/src/main/java/com/ccapp/ccgo/common/MissionStatus.java b/src/main/java/com/ccapp/ccgo/common/MissionStatus.java new file mode 100644 index 0000000..66777fb --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/common/MissionStatus.java @@ -0,0 +1,7 @@ +package com.ccapp.ccgo.common; + + +public enum MissionStatus { + PENDING, + COMPLETE +} \ No newline at end of file diff --git a/src/main/java/com/ccapp/ccgo/common/Role.java b/src/main/java/com/ccapp/ccgo/common/Role.java new file mode 100644 index 0000000..01e4d85 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/common/Role.java @@ -0,0 +1,6 @@ +package com.ccapp.ccgo.common; + +public enum Role { + LEADER, + MEMBER +} \ No newline at end of file diff --git a/src/main/java/com/ccapp/ccgo/common/SecurityConfig.java b/src/main/java/com/ccapp/ccgo/common/SecurityConfig.java new file mode 100644 index 0000000..8fbfb73 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/common/SecurityConfig.java @@ -0,0 +1,109 @@ +package com.ccapp.ccgo.common; + +import com.ccapp.ccgo.auth.jwt.CustomAuthenticationEntryPoint; +import com.ccapp.ccgo.auth.jwt.JwtAuthenticationFilter; +import com.ccapp.ccgo.auth.service.LoginUserDetailsService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter; +import java.util.List; + + +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final LoginUserDetailsService loginUserDetailsService; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + + @Bean + public BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setUserDetailsService(loginUserDetailsService); + provider.setPasswordEncoder(passwordEncoder()); + return provider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .headers(headers -> headers + // X-Frame-Options: 클릭재킹 공격 방지 + .frameOptions(frameOptions -> frameOptions.deny()) + // X-Content-Type-Options: MIME 타입 스니핑 방지 + .contentTypeOptions(contentTypeOptions -> {}) + // X-XSS-Protection: XSS 공격 방지 + .xssProtection(xssProtection -> {}) + // HSTS: HTTPS 강제 (2년으로 연장) + .httpStrictTransportSecurity(hstsConfig -> hstsConfig + .maxAgeInSeconds(63072000) // 2년 + ) + // Referrer Policy: 리퍼러 정보 제한 + .referrerPolicy(referrer -> referrer.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)) + ) + .authorizeHttpRequests(auth -> auth + // 인증 필요 없는 엔드포인트 + .requestMatchers("/api/auth/**", "/api/user/register", "/api/user/privacy-agreement/**").permitAll() + + // LEADER 전용 API (팀 관리) + .requestMatchers("/api/team/**").hasAnyRole("LEADER", "MEMBER") + + // MEMBER 이상 접근 가능 (예시) + .requestMatchers("/api/member/**").hasAnyRole("MEMBER", "LEADER") + + // 그 외 요청은 인증 필요 + .anyRequest().authenticated() + ) + .authenticationProvider(authenticationProvider()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(ex -> ex.authenticationEntryPoint(customAuthenticationEntryPoint)) + .build(); + } + + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(List.of( + "http://localhost:3000", + "http://localhost:5173", + "http://127.0.0.1:3000", + "http://127.0.0.1:5173", + "http://3.39.54.128:8080" // 실제 서버 IP로 변경 필요 + )); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); // 쿠키 허용 + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} diff --git a/src/main/java/com/ccapp/ccgo/exception/CustomException.java b/src/main/java/com/ccapp/ccgo/common/exception/CustomException.java similarity index 91% rename from src/main/java/com/ccapp/ccgo/exception/CustomException.java rename to src/main/java/com/ccapp/ccgo/common/exception/CustomException.java index 24b2320..89e4d37 100644 --- a/src/main/java/com/ccapp/ccgo/exception/CustomException.java +++ b/src/main/java/com/ccapp/ccgo/common/exception/CustomException.java @@ -1,4 +1,4 @@ -package com.ccapp.ccgo.exception; +package com.ccapp.ccgo.common.exception; import org.springframework.http.HttpStatus; import lombok.Getter; diff --git a/src/main/java/com/ccapp/ccgo/exception/GlobalExceptionHandler.java b/src/main/java/com/ccapp/ccgo/common/exception/GlobalExceptionHandler.java similarity index 67% rename from src/main/java/com/ccapp/ccgo/exception/GlobalExceptionHandler.java rename to src/main/java/com/ccapp/ccgo/common/exception/GlobalExceptionHandler.java index e6be98f..fefffcf 100644 --- a/src/main/java/com/ccapp/ccgo/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/ccapp/ccgo/common/exception/GlobalExceptionHandler.java @@ -1,4 +1,4 @@ -package com.ccapp.ccgo.exception; +package com.ccapp.ccgo.common.exception; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -7,8 +7,11 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.context.support.DefaultMessageSourceResolvable; + +import java.util.HashMap; import java.util.stream.Collectors; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.dao.InvalidDataAccessResourceUsageException; import java.util.Map; @Slf4j @@ -47,4 +50,19 @@ public ResponseEntity> handleBadCredentials(BadCredentialsEx .status(HttpStatus.UNAUTHORIZED) .body(Map.of("message", "이메일 또는 비밀번호가 잘못되었습니다.")); } + + + @ExceptionHandler(MatchingAlreadyCompletedException.class) + public ResponseEntity> handleMatchingAlreadyStarted(MatchingAlreadyCompletedException ex) { + Map errorResponse = new HashMap<>(); + errorResponse.put("error", ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + @ExceptionHandler(InvalidDataAccessResourceUsageException.class) + public ResponseEntity> handleTableNotExists(InvalidDataAccessResourceUsageException ex) { + log.warn("테이블이 존재하지 않는 오류: {}", ex.getMessage()); + // 테이블이 존재하지 않는 경우 200 OK와 빈 리스트를 반환하도록 프론트엔드에서 처리 + return ResponseEntity.ok(Map.of("message", "데이터를 조회할 수 없습니다.")); + } } diff --git a/src/main/java/com/ccapp/ccgo/common/exception/MatchingAlreadyCompletedException.java b/src/main/java/com/ccapp/ccgo/common/exception/MatchingAlreadyCompletedException.java new file mode 100644 index 0000000..373b14c --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/common/exception/MatchingAlreadyCompletedException.java @@ -0,0 +1,7 @@ +package com.ccapp.ccgo.common.exception; + +public class MatchingAlreadyCompletedException extends RuntimeException { + public MatchingAlreadyCompletedException(String message) { + super(message); + } +} diff --git a/src/main/java/com/ccapp/ccgo/common/test/BcryptTestController.java b/src/main/java/com/ccapp/ccgo/common/test/BcryptTestController.java new file mode 100644 index 0000000..743070e --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/common/test/BcryptTestController.java @@ -0,0 +1,25 @@ +package com.ccapp.ccgo.common.test; + +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class BcryptTestController { + + @GetMapping("/bcrypt-test") + public String test() { + String rawPw = "Test@1234"; + String hash = "$2a$10$l.Y.cfT9oRlzsiH7fSBIWearf3bijLIDR4ZSsTCnb7IsMvl8NWlnW"; + + boolean matches = new BCryptPasswordEncoder().matches(rawPw, hash); + return "비밀번호 일치 여부: " + matches; + } + @GetMapping("/bcrypt-config") + public String generate() { + BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + String rawPw = "Test@1234"; + String encodedPw = encoder.encode(rawPw); + return encodedPw; + } +} diff --git a/src/main/java/com/ccapp/ccgo/common/test/UserApiTestController.java b/src/main/java/com/ccapp/ccgo/common/test/UserApiTestController.java new file mode 100644 index 0000000..2f489f4 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/common/test/UserApiTestController.java @@ -0,0 +1,67 @@ +package com.ccapp.ccgo.common.test; + +import com.ccapp.ccgo.user.dto.UserRequestDto; +import com.ccapp.ccgo.user.dto.UserUpdateRequestDto; +import com.ccapp.ccgo.user.dto.PasswordChangeRequestDto; +import com.ccapp.ccgo.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/test/user") +@RequiredArgsConstructor +public class UserApiTestController { + + private final UserService userService; + + @GetMapping("/me") + public ResponseEntity testGetCurrentUser() { + log.info("🧪 테스트: 현재 사용자 정보 조회"); + try { + var result = userService.getCurrentUser(); + return ResponseEntity.ok(result); + } catch (Exception e) { + log.error("❌ 테스트 실패: {}", e.getMessage()); + return ResponseEntity.badRequest().body("테스트 실패: " + e.getMessage()); + } + } + + @PatchMapping("/me") + public ResponseEntity testUpdateCurrentUser(@RequestBody UserUpdateRequestDto dto) { + log.info("🧪 테스트: 사용자 정보 부분 업데이트 - {}", dto); + try { + var result = userService.updateCurrentUser(dto); + return ResponseEntity.ok(result); + } catch (Exception e) { + log.error("❌ 테스트 실패: {}", e.getMessage()); + return ResponseEntity.badRequest().body("테스트 실패: " + e.getMessage()); + } + } + + @PutMapping("/me") + public ResponseEntity testUpdateCurrentUserFull(@RequestBody UserRequestDto dto) { + log.info("🧪 테스트: 사용자 정보 전체 업데이트 - {}", dto); + try { + var result = userService.updateCurrentUserFull(dto); + return ResponseEntity.ok(result); + } catch (Exception e) { + log.error("❌ 테스트 실패: {}", e.getMessage()); + return ResponseEntity.badRequest().body("테스트 실패: " + e.getMessage()); + } + } + + @PostMapping("/change-password") + public ResponseEntity testChangePassword(@RequestBody PasswordChangeRequestDto dto) { + log.info("🧪 테스트: 비밀번호 변경"); + try { + userService.changePassword(dto); + return ResponseEntity.ok("비밀번호 변경 성공"); + } catch (Exception e) { + log.error("❌ 테스트 실패: {}", e.getMessage()); + return ResponseEntity.badRequest().body("테스트 실패: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/ccapp/ccgo/config/SchedulingConfig.java b/src/main/java/com/ccapp/ccgo/config/SchedulingConfig.java new file mode 100644 index 0000000..a39d8d7 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/config/SchedulingConfig.java @@ -0,0 +1,9 @@ +package com.ccapp.ccgo.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling // 💡 스케줄링 활성화! +public class SchedulingConfig { +} diff --git a/src/main/java/com/ccapp/ccgo/controller/AuthController.java b/src/main/java/com/ccapp/ccgo/controller/AuthController.java deleted file mode 100644 index 9867f2c..0000000 --- a/src/main/java/com/ccapp/ccgo/controller/AuthController.java +++ /dev/null @@ -1,168 +0,0 @@ -package com.ccapp.ccgo.controller; - -import com.ccapp.ccgo.dto.TokenResponseDto; -import com.ccapp.ccgo.jwt.LoginUserDetailsService; -import com.ccapp.ccgo.repository.TeamMemberRepository; -import com.ccapp.ccgo.team.Team; -import com.ccapp.ccgo.team.TeamMember; -import com.ccapp.ccgo.user.User; -import com.ccapp.ccgo.dto.LoginRequestDto; -import com.ccapp.ccgo.dto.LoginResponseDto; -import com.ccapp.ccgo.jwt.JwtProvider; -import com.ccapp.ccgo.jwt.LoginUserDetails; -import lombok.extern.slf4j.Slf4j; -import com.ccapp.ccgo.repository.TeamRepository; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseCookie; -import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; -import org.springframework.http.HttpHeaders; - -import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -@Slf4j -@RestController -@RequestMapping("/api/auth") -@RequiredArgsConstructor -public class AuthController { - - private final AuthenticationManager authenticationManager; - private final JwtProvider jwtProvider; - private final TeamMemberRepository teamMemberRepository; - private final LoginUserDetailsService loginUserDetailsService; - private final TeamRepository teamRepository; - - @PostMapping("/login") - public ResponseEntity login(@Valid @RequestBody LoginRequestDto requestDto) { - log.info("로그인 요청 받음: {}", requestDto.getEmail()); - log.info("로그인 요청 받음: {}", requestDto.getPassword()); - - try { - Authentication authentication = authenticationManager.authenticate( - new UsernamePasswordAuthenticationToken( - requestDto.getEmail(), requestDto.getPassword() - ) - ); - log.info("✅ 인증 성공: {}", authentication.getName()); - - String accessToken = jwtProvider.createAccessToken(authentication); - String refreshToken = jwtProvider.createRefreshToken(authentication); - LoginUserDetails userDetails = (LoginUserDetails) authentication.getPrincipal(); - User user = userDetails.getUser(); - log.info("🔍 로그인한 사용자: {}", user.getEmail()); - - // ✅ 팀이 없으면 팀 생성 + 팀장 등록 - Optional existingTeamMember = teamMemberRepository.findByUserAndIsActiveTrue(user); - if (existingTeamMember.isEmpty() && user.getRole().equals("TeamLeader")) { - - // 1. 새 팀 생성 - Team team = new Team(); - team.setTeamName(user.getName() + "의 팀"); // 원하는 네이밍 규칙 사용 - team.setCreatedAt(LocalDateTime.now()); - team.setCreatedBy(user.getId()); - teamRepository.save(team); - - // 2. 팀장 본인을 팀원으로 등록 - TeamMember teamMember = new TeamMember(); - teamMember.setUser(user); - teamMember.setTeam(team); - teamMember.setRole("TeamLeader"); // 또는 enum 등 - teamMember.setActive(true); - teamMember.setJoinedAt(LocalDateTime.now()); - teamMemberRepository.save(teamMember); - - log.info("🆕 새 팀 생성 및 팀장 등록 완료"); - } - - // ✅ 다시 조회 (혹은 Optional.get으로 바로 사용 가능) - TeamMember teamMember = teamMemberRepository.findByUserAndIsActiveTrue(user) - .orElseThrow(() -> new RuntimeException("소속된 팀이 없습니다.")); - - HttpHeaders headers = createTokenCookies(accessToken, refreshToken); - - LoginResponseDto response = LoginResponseDto.builder() - .userId(user.getId()) - .email(user.getEmail()) - .name(user.getName()) - .teamId(teamMember.getTeam().getTeamId()) - .teamName(teamMember.getTeam().getTeamName()) - .role(teamMember.getRole()) - .accessToken(accessToken) // 추가 - .refreshToken(refreshToken) - .build(); - - return ResponseEntity.ok() - .headers(headers) - .body(response); - - } catch (BadCredentialsException e) { - log.error("❌ 로그인 실패: 자격 증명 오류", e); - return ResponseEntity.status(401).body(Map.of("message", "이메일 또는 비밀번호가 잘못되었습니다.")); - } catch (RuntimeException e) { - log.error("❌ 로그인 중 런타임 예외", e); - return ResponseEntity.status(400).body(Map.of("message", e.getMessage())); - } catch (Exception e) { - log.error("❌ 로그인 중 알 수 없는 예외", e); - return ResponseEntity.status(500).body(Map.of("message", "서버 오류가 발생했습니다.")); - } - } - - @PostMapping("/refresh") - public ResponseEntity refreshToken(@CookieValue(value = "refreshToken", required = false) String refreshToken) { - if (refreshToken == null || !jwtProvider.validateToken(refreshToken)) { - return ResponseEntity.status(401).body("리프레시 토큰이 없거나 유효하지 않습니다."); - } - - String email = jwtProvider.getEmailFromToken(refreshToken); - LoginUserDetails userDetails = (LoginUserDetails) loginUserDetailsService.loadUserByUsername(email); - - Authentication authentication = new UsernamePasswordAuthenticationToken( - userDetails, null, userDetails.getAuthorities() - ); - - String newAccessToken = jwtProvider.createAccessToken(authentication); - String newRefreshToken = jwtProvider.createRefreshToken(authentication); - - HttpHeaders headers = createTokenCookies(newAccessToken, newRefreshToken); - - return ResponseEntity.ok() - .headers(headers) - .body(new TokenResponseDto(newAccessToken, newRefreshToken)); - } - - /** - * accessToken, refreshToken을 HttpOnly 쿠키로 설정하는 공통 메서드 - */ - private HttpHeaders createTokenCookies(String accessToken, String refreshToken) { - ResponseCookie accessTokenCookie = ResponseCookie.from("accessToken", accessToken) - .httpOnly(true) - .path("/") - .maxAge(60 * 60) // 1시간 - .secure(false) // 운영 배포 시 true로 - .sameSite("Lax") - .build(); - - ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", refreshToken) - .httpOnly(true) - .path("/") - .maxAge(7 * 24 * 60 * 60) // 7일 - .secure(false) - .sameSite("Lax") - .build(); - - HttpHeaders headers = new HttpHeaders(); - headers.add(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()); - headers.add(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); - return headers; - } -} - - diff --git a/src/main/java/com/ccapp/ccgo/controller/InviteCodeController.java b/src/main/java/com/ccapp/ccgo/controller/InviteCodeController.java deleted file mode 100644 index 51d3ece..0000000 --- a/src/main/java/com/ccapp/ccgo/controller/InviteCodeController.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.ccapp.ccgo.controller; - -import com.ccapp.ccgo.dto.InviteCodeCreateResponseDto; -import com.ccapp.ccgo.service.InviteCodeService; -import com.ccapp.ccgo.team.InviteCode; -import com.ccapp.ccgo.user.User; -import com.ccapp.ccgo.jwt.LoginUserDetails; - -import lombok.RequiredArgsConstructor; - -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/api/invitecode") -@RequiredArgsConstructor -public class InviteCodeController { - - private final InviteCodeService inviteCodeService; - - @PostMapping("/create") - public ResponseEntity createInviteCode( - @AuthenticationPrincipal LoginUserDetails userDetails) { - - User user = userDetails.getUser(); - - // 초대코드 생성 서비스 호출 - InviteCode inviteCode = inviteCodeService.createInviteCode(user); - - InviteCodeCreateResponseDto responseDto = InviteCodeCreateResponseDto.builder() - .code(inviteCode.getCode()) - .expiresAt(inviteCode.getExpiresAt()) - .build(); - - return ResponseEntity.ok(responseDto); - } -} diff --git a/src/main/java/com/ccapp/ccgo/controller/UserController.java b/src/main/java/com/ccapp/ccgo/controller/UserController.java deleted file mode 100644 index 4b32d25..0000000 --- a/src/main/java/com/ccapp/ccgo/controller/UserController.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.ccapp.ccgo.controller; - -import com.ccapp.ccgo.service.UserService; -import com.ccapp.ccgo.dto.UserRequestDto; -import com.ccapp.ccgo.dto.UserResponseDto; -import org.springframework.http.HttpStatus; -import jakarta.validation.Valid; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@RestController -// @CrossOrigin 제거, SecurityConfig에서 CORS 관리 권장 -public class UserController { - - private final UserService userService; - - public UserController(UserService userService) { - this.userService = userService; - } - - @PostMapping("/register") - public ResponseEntity register(@Valid @RequestBody UserRequestDto userRequestDto) { - log.info("✅ 회원가입 요청 들어옴: {}", userRequestDto); - UserResponseDto saved = userService.register(userRequestDto); - return ResponseEntity.status(HttpStatus.CREATED).body(saved); - } -} diff --git a/src/main/java/com/ccapp/ccgo/dto/InviteCodeJoinRequestDto.java b/src/main/java/com/ccapp/ccgo/dto/InviteCodeJoinRequestDto.java deleted file mode 100644 index 22093a5..0000000 --- a/src/main/java/com/ccapp/ccgo/dto/InviteCodeJoinRequestDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.ccapp.ccgo.dto; - -public class InviteCodeJoinRequestDto { - private String inviteCode; -} diff --git a/src/main/java/com/ccapp/ccgo/dto/InviteCodeJoinResponseDto.java b/src/main/java/com/ccapp/ccgo/dto/InviteCodeJoinResponseDto.java deleted file mode 100644 index 92d8c3a..0000000 --- a/src/main/java/com/ccapp/ccgo/dto/InviteCodeJoinResponseDto.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.ccapp.ccgo.dto; - -public class InviteCodeJoinResponseDto { - private Long teamId; - private String teamName; - private String role; // TEAM_MEMBER -} diff --git a/src/main/java/com/ccapp/ccgo/dto/LoginResponseDto.java b/src/main/java/com/ccapp/ccgo/dto/LoginResponseDto.java deleted file mode 100644 index 7eafe6b..0000000 --- a/src/main/java/com/ccapp/ccgo/dto/LoginResponseDto.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.ccapp.ccgo.dto; - -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class LoginResponseDto { - private String grantType; // Bearer - private String accessToken; - private String refreshToken; - private Long userId; - private String email; - private String name; - private String role; - - //이 정보가 필요할지는 고민해봐야한다. - private Long teamId; - private String teamName; -} diff --git a/src/main/java/com/ccapp/ccgo/dto/TeamRequestDto.java b/src/main/java/com/ccapp/ccgo/dto/TeamRequestDto.java deleted file mode 100644 index fa58a33..0000000 --- a/src/main/java/com/ccapp/ccgo/dto/TeamRequestDto.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.ccapp.ccgo.dto; - -public class TeamRequestDto { - private String teamName; -} - diff --git a/src/main/java/com/ccapp/ccgo/dto/TeamResponseDto.java b/src/main/java/com/ccapp/ccgo/dto/TeamResponseDto.java deleted file mode 100644 index 9d54809..0000000 --- a/src/main/java/com/ccapp/ccgo/dto/TeamResponseDto.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.ccapp.ccgo.dto; - -public class TeamResponseDto { - private Long teamId; - private String teamName; -} \ No newline at end of file diff --git a/src/main/java/com/ccapp/ccgo/dto/UserMapper.java b/src/main/java/com/ccapp/ccgo/dto/UserMapper.java deleted file mode 100644 index b76cfd6..0000000 --- a/src/main/java/com/ccapp/ccgo/dto/UserMapper.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.ccapp.ccgo.dto; - -import com.ccapp.ccgo.user.User; - -public class UserMapper { - - // RequestDto -> Entity - public static User toEntity(UserRequestDto dto, String encodedPassword) { - if (dto == null) return null; - - return User.builder() - .email(dto.getEmail()) - .password(encodedPassword) - .name(dto.getName()) - .gender(dto.getGender()) - .role(dto.getRole()) - .birthdate(dto.getBirthdate()) - .build(); - } - - // Entity -> ResponseDto - public static UserResponseDto toDto(User user) { - if (user == null) return null; - - return UserResponseDto.builder() - .id(user.getId()) - .email(user.getEmail()) - .name(user.getName()) - .gender(user.getGender()) - .birthdate(user.getBirthdate()) - .createdAt(user.getCreatedAt()) - .role(user.getRole()) - .build(); - } -} diff --git a/src/main/java/com/ccapp/ccgo/invitecode/controller/InviteCodeController.java b/src/main/java/com/ccapp/ccgo/invitecode/controller/InviteCodeController.java new file mode 100644 index 0000000..e600698 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/invitecode/controller/InviteCodeController.java @@ -0,0 +1,111 @@ +package com.ccapp.ccgo.invitecode.controller; + +import com.ccapp.ccgo.invitecode.dto.InviteCodeCreateRequestDto; +import com.ccapp.ccgo.invitecode.dto.InviteCodeCreateResponseDto; +import com.ccapp.ccgo.invitecode.dto.InviteCodeJoinRequestDto; +import com.ccapp.ccgo.invitecode.dto.InviteCodeJoinResponseDto; +import com.ccapp.ccgo.team.dto.TeamRequestDto; +import com.ccapp.ccgo.invitecode.repository.InviteCodeRepository; +import com.ccapp.ccgo.team.dto.TeamResponseDto; +import com.ccapp.ccgo.team.entity.Team; +import com.ccapp.ccgo.team.repository.TeamMemberRepository; +import com.ccapp.ccgo.team.repository.TeamRepository; +import com.ccapp.ccgo.invitecode.service.InviteCodeService; +import com.ccapp.ccgo.team.entity.InviteCode; +import com.ccapp.ccgo.user.entity.User; +import com.ccapp.ccgo.auth.jwt.LoginUserDetails; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequestMapping("/api/invitecode") +@RequiredArgsConstructor +@Slf4j +public class InviteCodeController { + + private final InviteCodeService inviteCodeService; + private final InviteCodeRepository inviteCodeRepository; + private final TeamMemberRepository teamMemberRepository; + private final TeamRepository teamRepository; + + /** + * 초대코드 생성 + */ + @PostMapping("/create") + public ResponseEntity createInviteCode( + @AuthenticationPrincipal LoginUserDetails userDetails, + @RequestBody InviteCodeCreateRequestDto requestDto) { + + log.info("[InviteCode] 초대코드 생성 요청 | user: {}", userDetails.getUsername()); + + User user = userDetails.getUser(); + Long teamId = requestDto.getTeamId(); + + InviteCode inviteCode = inviteCodeService.createInviteCode(user, teamId); + + InviteCodeCreateResponseDto responseDto = InviteCodeCreateResponseDto.builder() + .code(inviteCode.getCode()) + .expiresAt(inviteCode.getExpiresAt()) + .build(); + + log.info("[InviteCode] 초대코드 생성 완료 | code: {}", inviteCode.getCode()); + + return ResponseEntity.ok(responseDto); + } + + + /** + * 팀 생성 + */ + @PostMapping("/teamname") + public ResponseEntity saveTeamName( + @AuthenticationPrincipal LoginUserDetails userDetails, + @RequestBody TeamRequestDto requestDto) { + + if (userDetails == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + User user = userDetails.getUser(); + + Team team = inviteCodeService.createTeamWithLeader(user, requestDto.getTeamName()); + + TeamResponseDto responseDto = TeamResponseDto.builder() + .teamId(team.getTeamId()) + .teamName(team.getTeamName()) + .build(); + + log.info("[InviteCode] 팀 생성 완료 | teamId: {}", team.getTeamId()); + + return ResponseEntity.ok(responseDto); + } + + + /** + * 초대코드로 팀 가입 + */ + @PostMapping("/join") + public ResponseEntity joinByInviteCode( + @RequestBody InviteCodeJoinRequestDto requestDto, + @AuthenticationPrincipal LoginUserDetails userDetails) { + + if (userDetails == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("인증 정보가 없습니다."); + } + + User user = userDetails.getUser(); + log.info("[InviteCode] 팀 가입 요청 | user: {}, code: {}", user.getEmail(), requestDto.getInviteCode()); + String teamName = inviteCodeService.joinTeamByInviteCode(requestDto.getInviteCode(), user); + + log.info("[InviteCode] 팀 가입 완료 | user: {}, team: {}", user.getEmail(), teamName); + return ResponseEntity.ok(new InviteCodeJoinResponseDto(teamName)); + } + + +} diff --git a/src/main/java/com/ccapp/ccgo/dto/InviteCodeCreateRequestDto.java b/src/main/java/com/ccapp/ccgo/invitecode/dto/InviteCodeCreateRequestDto.java similarity index 50% rename from src/main/java/com/ccapp/ccgo/dto/InviteCodeCreateRequestDto.java rename to src/main/java/com/ccapp/ccgo/invitecode/dto/InviteCodeCreateRequestDto.java index afef848..7429fa0 100644 --- a/src/main/java/com/ccapp/ccgo/dto/InviteCodeCreateRequestDto.java +++ b/src/main/java/com/ccapp/ccgo/invitecode/dto/InviteCodeCreateRequestDto.java @@ -1,5 +1,8 @@ -package com.ccapp.ccgo.dto; +package com.ccapp.ccgo.invitecode.dto; +import lombok.Data; + +@Data public class InviteCodeCreateRequestDto { private Long teamId; -} \ No newline at end of file +} diff --git a/src/main/java/com/ccapp/ccgo/dto/InviteCodeCreateResponseDto.java b/src/main/java/com/ccapp/ccgo/invitecode/dto/InviteCodeCreateResponseDto.java similarity index 83% rename from src/main/java/com/ccapp/ccgo/dto/InviteCodeCreateResponseDto.java rename to src/main/java/com/ccapp/ccgo/invitecode/dto/InviteCodeCreateResponseDto.java index dfea86c..5faece4 100644 --- a/src/main/java/com/ccapp/ccgo/dto/InviteCodeCreateResponseDto.java +++ b/src/main/java/com/ccapp/ccgo/invitecode/dto/InviteCodeCreateResponseDto.java @@ -1,4 +1,4 @@ -package com.ccapp.ccgo.dto; +package com.ccapp.ccgo.invitecode.dto; import lombok.Builder; import lombok.Getter; diff --git a/src/main/java/com/ccapp/ccgo/invitecode/dto/InviteCodeJoinRequestDto.java b/src/main/java/com/ccapp/ccgo/invitecode/dto/InviteCodeJoinRequestDto.java new file mode 100644 index 0000000..a57a53a --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/invitecode/dto/InviteCodeJoinRequestDto.java @@ -0,0 +1,10 @@ +package com.ccapp.ccgo.invitecode.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class InviteCodeJoinRequestDto { + private String inviteCode; +} diff --git a/src/main/java/com/ccapp/ccgo/invitecode/dto/InviteCodeJoinResponseDto.java b/src/main/java/com/ccapp/ccgo/invitecode/dto/InviteCodeJoinResponseDto.java new file mode 100644 index 0000000..4baafe6 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/invitecode/dto/InviteCodeJoinResponseDto.java @@ -0,0 +1,14 @@ +package com.ccapp.ccgo.invitecode.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class InviteCodeJoinResponseDto { + private String teamName; +} diff --git a/src/main/java/com/ccapp/ccgo/repository/InviteCodeRepository.java b/src/main/java/com/ccapp/ccgo/invitecode/repository/InviteCodeRepository.java similarity index 72% rename from src/main/java/com/ccapp/ccgo/repository/InviteCodeRepository.java rename to src/main/java/com/ccapp/ccgo/invitecode/repository/InviteCodeRepository.java index f98c9d4..e197ea5 100644 --- a/src/main/java/com/ccapp/ccgo/repository/InviteCodeRepository.java +++ b/src/main/java/com/ccapp/ccgo/invitecode/repository/InviteCodeRepository.java @@ -1,6 +1,7 @@ -package com.ccapp.ccgo.repository; +package com.ccapp.ccgo.invitecode.repository; -import com.ccapp.ccgo.team.InviteCode; +import com.ccapp.ccgo.team.entity.InviteCode; +import com.ccapp.ccgo.team.entity.Team; import org.springframework.data.jpa.repository.JpaRepository; import java.time.LocalDateTime; @@ -9,7 +10,7 @@ /** * InviteCode 엔티티용 Repository */ -public interface InviteCodeRepository extends JpaRepository { +public interface InviteCodeRepository extends JpaRepository { // 코드 존재 여부 boolean existsByCode(String code); @@ -22,4 +23,8 @@ public interface InviteCodeRepository extends JpaRepository // 만료 코드 삭제 void deleteByExpiresAtBefore(LocalDateTime now); + + // 특정 팀의 기존 초대코드 삭제 + void deleteByTeam(Team team); + } diff --git a/src/main/java/com/ccapp/ccgo/invitecode/service/InviteCodeService.java b/src/main/java/com/ccapp/ccgo/invitecode/service/InviteCodeService.java new file mode 100644 index 0000000..1518052 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/invitecode/service/InviteCodeService.java @@ -0,0 +1,136 @@ +package com.ccapp.ccgo.invitecode.service; + +import com.ccapp.ccgo.common.Role; +import com.ccapp.ccgo.common.exception.CustomException; +import com.ccapp.ccgo.invitecode.repository.InviteCodeRepository; +import com.ccapp.ccgo.team.repository.TeamMemberRepository; +import com.ccapp.ccgo.team.repository.TeamRepository; +import com.ccapp.ccgo.team.entity.InviteCode; +import com.ccapp.ccgo.team.entity.Team; +import com.ccapp.ccgo.team.entity.TeamMember; +import org.springframework.scheduling.annotation.Scheduled; +import com.ccapp.ccgo.user.entity.User; +import org.springframework.http.HttpStatus; +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import java.time.LocalDateTime; + +import java.security.SecureRandom; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class InviteCodeService { + + private final InviteCodeRepository inviteCodeRepository; + private final TeamMemberRepository teamMemberRepository; + private final TeamRepository teamRepository; // 팀 저장소 추가 + + + private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + private static final int CODE_LENGTH = 8; + private static final SecureRandom random = new SecureRandom(); + + //코드 생성기 + private String generateRandomCode() { + StringBuilder sb = new StringBuilder(CODE_LENGTH); + for (int i = 0; i < CODE_LENGTH; i++) { + sb.append(CODE_CHARS.charAt(random.nextInt(CODE_CHARS.length()))); + } + return sb.toString(); + } + + + + //코드로팀가입 + @Transactional + public String joinTeamByInviteCode(String code, User user) { + + InviteCode inviteCode = inviteCodeRepository + .findByCodeAndExpiresAtAfter(code, LocalDateTime.now()) + .orElseThrow(() -> new CustomException("초대 코드가 없거나 만료되었습니다.", HttpStatus.BAD_REQUEST)); + + Team team = inviteCode.getTeam(); + + boolean alreadyMember = teamMemberRepository.existsByUserAndTeam(user, team); + if (alreadyMember) { + throw new CustomException("이미 이 팀에 가입되어 있습니다.", HttpStatus.BAD_REQUEST); + } + TeamMember newMember = TeamMember.builder() + .user(user) + .team(team) + .joinedAt(LocalDateTime.now()) + .isActive(true) + .role(Role.MEMBER) + .build(); + + teamMemberRepository.save(newMember); + + return team.getTeamName(); + } + + //초대코드생성 부분 + @Transactional + public InviteCode createInviteCode(User user, Long teamId) { + Team team = teamRepository.findById(teamId) + .orElseThrow(() -> new CustomException("존재하지 않는 팀입니다.", HttpStatus.NOT_FOUND)); + + // 팀장이 이 팀의 리더인지 검증 + List teamMembers = teamMemberRepository.findByUserAndTeamAndIsActiveTrue(user, team); + boolean isLeader = teamMembers.stream() + .anyMatch(tm -> tm.getRole() == Role.LEADER); + if (!isLeader) { + throw new CustomException("팀장만 초대코드를 생성할 수 있습니다.", HttpStatus.FORBIDDEN); + } + + // 기존 초대코드 삭제 + inviteCodeRepository.deleteByTeam(team); + + String code; + do { + code = generateRandomCode(); + } while (inviteCodeRepository.existsByCode(code)); + + InviteCode inviteCode = InviteCode.builder() + .code(code) + .team(team) + .build(); + + return inviteCodeRepository.save(inviteCode); + } + + + //현재 시각보다 이전인 초대코드를 삭제 + @Scheduled(fixedRate = 60 * 60 * 1000) // 1시간마다 실행 (ms 단위) + @Transactional + public void deleteExpiredInviteCodes() { + inviteCodeRepository.deleteByExpiresAtBefore(LocalDateTime.now()); + } + + + + @Transactional + public Team createTeamWithLeader(User user, String teamName) { + Team team = Team.builder() + .teamName(teamName) + .createdBy(user.getId()) + .createdAt(LocalDateTime.now()) + .build(); + + Team savedTeam = teamRepository.save(team); + + TeamMember teamLeader = TeamMember.builder() + .user(user) + .team(savedTeam) + .role(Role.LEADER) + .isActive(true) + .build(); + + teamMemberRepository.save(teamLeader); + + return savedTeam; + } + + +} diff --git a/src/main/java/com/ccapp/ccgo/jwt/JwtProvider.java b/src/main/java/com/ccapp/ccgo/jwt/JwtProvider.java deleted file mode 100644 index bd6b35b..0000000 --- a/src/main/java/com/ccapp/ccgo/jwt/JwtProvider.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.ccapp.ccgo.jwt; -import io.jsonwebtoken.*; -import io.jsonwebtoken.security.Keys; -import jakarta.annotation.PostConstruct; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.stereotype.Component; -import org.springframework.security.core.Authentication; -import java.nio.charset.StandardCharsets; -import java.security.Key; -import java.util.Date; -import java.util.stream.Collectors; -import lombok.extern.slf4j.Slf4j; -@Slf4j -@Component -public class JwtProvider { - - private Key key; - - private final String secret; - private final long accessTokenExpiration; - private final long refreshTokenExpiration; - - public JwtProvider(@Value("${jwt.secret}") String secret, - @Value("${jwt.access-token-expiration}") long accessTokenExpiration, - @Value("${jwt.refresh-token-expiration}") long refreshTokenExpiration) { - this.secret = secret; - this.accessTokenExpiration = accessTokenExpiration; - this.refreshTokenExpiration = refreshTokenExpiration; - } - - @PostConstruct - public void init() { - byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); - if (keyBytes.length < 32) { - log.warn("JWT 시크릿 키 길이가 너무 짧습니다. 최소 256비트(32바이트) 이상 권장합니다."); - } - this.key = Keys.hmacShaKeyFor(keyBytes); - } - - public String createAccessToken(Authentication authentication) { - String username = authentication.getName(); - String roles = authentication.getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.joining(",")); - Date now = new Date(); - Date expiryDate = new Date(now.getTime() + accessTokenExpiration); - return Jwts.builder() - .setSubject(username) - .claim("roles", roles) - .setIssuedAt(now) - .setExpiration(expiryDate) - .signWith(key, SignatureAlgorithm.HS256) - .compact(); - } - - public String createRefreshToken(Authentication authentication) { - Date now = new Date(); - Date expiryDate = new Date(now.getTime() + refreshTokenExpiration); - return Jwts.builder() - .setSubject(authentication.getName()) - .setIssuedAt(now) - .setExpiration(expiryDate) - .signWith(key, SignatureAlgorithm.HS256) - .compact(); - } - - public boolean validateToken(String token) { - try { - Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(token); - return true; - } catch (JwtException | IllegalArgumentException e) { - log.warn("JWT 유효성 검사 실패: {}", e.getMessage()); - return false; - } - } - - public String getEmailFromToken(String token) { - return Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(token) - .getBody() - .getSubject(); - } -} \ No newline at end of file diff --git a/src/main/java/com/ccapp/ccgo/jwt/LoginUserDetails.java b/src/main/java/com/ccapp/ccgo/jwt/LoginUserDetails.java deleted file mode 100644 index 5e7cedf..0000000 --- a/src/main/java/com/ccapp/ccgo/jwt/LoginUserDetails.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.ccapp.ccgo.jwt; - -import com.ccapp.ccgo.user.User; -import lombok.Getter; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; - -import java.util.Collection; -import java.util.Collections; - -@Getter -public class LoginUserDetails implements UserDetails { - - private final User user; - - public LoginUserDetails(User user) { - this.user = user; - } - - // ✅ 기본 권한 비워둠 (나중에 ROLE_ 추가 가능) - @Override - public Collection getAuthorities() { - return Collections.emptyList(); - } - - // ✅ 로그인 시 사용할 비밀번호 - @Override - public String getPassword() { - return user.getPassword(); - } - - // ✅ 로그인 시 사용할 식별자 (우린 이메일) - @Override - public String getUsername() { - return user.getEmail(); - } - - // ✅ 계정 상태 기본 활성화 (true) - @Override public boolean isAccountNonExpired() { return true; } - @Override public boolean isAccountNonLocked() { return true; } - @Override public boolean isCredentialsNonExpired() { return true; } - @Override public boolean isEnabled() { return true; } -} diff --git a/src/main/java/com/ccapp/ccgo/jwt/LoginUserDetailsService.java b/src/main/java/com/ccapp/ccgo/jwt/LoginUserDetailsService.java deleted file mode 100644 index 3d5ea1d..0000000 --- a/src/main/java/com/ccapp/ccgo/jwt/LoginUserDetailsService.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.ccapp.ccgo.jwt; - -import com.ccapp.ccgo.user.User; -import com.ccapp.ccgo.repository.UserRepository; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.stereotype.Service; - -@Service -public class LoginUserDetailsService implements UserDetailsService { - - private final UserRepository userRepository; - - public LoginUserDetailsService(UserRepository userRepository) { - this.userRepository = userRepository; - } - - // ✅ 이메일로 유저 조회 → LoginUserDetails 로 감싸서 리턴 - @Override - public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { - User user = userRepository.findByEmail(email) - .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + email)); - return new LoginUserDetails(user); - } -} diff --git a/src/main/java/com/ccapp/ccgo/jwt/SecurityConfig.java b/src/main/java/com/ccapp/ccgo/jwt/SecurityConfig.java deleted file mode 100644 index 313c30a..0000000 --- a/src/main/java/com/ccapp/ccgo/jwt/SecurityConfig.java +++ /dev/null @@ -1,77 +0,0 @@ -//package com.ccapp.ccgo.jwt; -// -//import lombok.RequiredArgsConstructor; -//import org.springframework.context.annotation.Bean; -//import org.springframework.context.annotation.Configuration; -//import org.springframework.security.authentication.AuthenticationManager; -//import org.springframework.security.authentication.AuthenticationProvider; -//import org.springframework.security.authentication.dao.DaoAuthenticationProvider; -//import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; -//import org.springframework.security.config.annotation.web.builders.HttpSecurity; -//import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -//import org.springframework.security.config.http.SessionCreationPolicy; -//import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -//import org.springframework.security.web.SecurityFilterChain; -//import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -//import org.springframework.web.cors.CorsConfiguration; -//import org.springframework.web.cors.CorsConfigurationSource; -//import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -//import java.util.List; -// -//@Configuration -//@RequiredArgsConstructor -//public class SecurityConfig { -// -// private final JwtAuthenticationFilter jwtAuthenticationFilter; -// private final LoginUserDetailsService loginUserDetailsService; -// -// // 🔐 비밀번호 인코더 등록 -// @Bean -// public BCryptPasswordEncoder passwordEncoder() { -// return new BCryptPasswordEncoder(); -// } -// -// // 🔐 로그인 시 사용할 인증 제공자 (UserDetailsService + PasswordEncoder) -// @Bean -// public AuthenticationProvider authenticationProvider() { -// DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); -// provider.setUserDetailsService(loginUserDetailsService); -// provider.setPasswordEncoder(passwordEncoder()); -// return provider; -// } -// -// // 🔐 인증 매니저 (로그인 인증 처리 시 필요) -// @Bean -// public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { -// return config.getAuthenticationManager(); -// } -// -// // 🔐 필터 체인 설정 -// @Bean -// public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { -// return http -// .cors(cors -> cors.configurationSource(corsConfigurationSource())) -// .csrf(AbstractHttpConfigurer::disable) -// .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) -// .authorizeHttpRequests(auth -> auth -// .requestMatchers("/api/auth/login", "/register").permitAll() // 로그인, 회원가입 허용 -// .anyRequest().authenticated() // 그 외는 인증 필요 -// ) -// .authenticationProvider(authenticationProvider()) -// .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) -// .build(); -// } -// //cors -// @Bean -// public CorsConfigurationSource corsConfigurationSource() { -// CorsConfiguration config = new CorsConfiguration(); -// config.setAllowedOriginPatterns(List.of("*")); // 변경된 부분 -// config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); -// config.setAllowedHeaders(List.of("*")); -// config.setAllowCredentials(true); -// -// UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); -// source.registerCorsConfiguration("/**", config); -// return source; -// } -//} diff --git a/src/main/java/com/ccapp/ccgo/matching/controller/MatchingController.java b/src/main/java/com/ccapp/ccgo/matching/controller/MatchingController.java new file mode 100644 index 0000000..d0f857d --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/matching/controller/MatchingController.java @@ -0,0 +1,137 @@ +package com.ccapp.ccgo.matching.controller; + +import com.ccapp.ccgo.auth.jwt.LoginUserDetails; +import com.ccapp.ccgo.matching.dto.MatchedNamesResponse; +import com.ccapp.ccgo.matching.repository.SubGroupMemberRepository; +import com.ccapp.ccgo.question.dto.AnswerRequestDto; +import com.ccapp.ccgo.matching.dto.MatchingResponseDto; +import com.ccapp.ccgo.question.dto.QuestionRequestDto; +import com.ccapp.ccgo.question.dto.QuestionResponseDto; +import com.ccapp.ccgo.question.dto.QuestionUpdateDto; +import com.ccapp.ccgo.matching.service.MatchingService; +import com.ccapp.ccgo.user.entity.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/matching") +@Slf4j +public class MatchingController { + + private final MatchingService matchingService; + private final SubGroupMemberRepository subGroupMemberRepository; + + /** + * 팀 매칭 시작 + * 팀장이 매칭 시작 버튼을 누를 때 호출 + */ + @PostMapping("/start/{teamId}") + public ResponseEntity startMatching(@PathVariable Long teamId) { + log.info("[Matching] 매칭 시작 요청 | teamId: {}", teamId); + return ResponseEntity.ok(matchingService.performMatching(teamId)); + } + + /** + * 설문 답변 저장 + */ + @PostMapping("/answer") + public ResponseEntity saveAnswers(@RequestBody AnswerRequestDto dto, + @AuthenticationPrincipal LoginUserDetails loginUserDetails) { + User currentUser = loginUserDetails.getUser(); + matchingService.saveAnswers(dto, currentUser); + return ResponseEntity.ok().build(); + } + + /** + * 설문 질문 생성 + */ + @PostMapping("/question") + public void createQuestions(@RequestBody QuestionRequestDto requestDto) { + matchingService.createQuestions(requestDto); + } + + /** + * 팀별 설문 질문 조회 + */ + @GetMapping("/question") + public List getQuestions(@RequestParam Long teamId) { + return matchingService.getQuestions(teamId); + } + + /** + * 설문 질문 수정 + */ + @PutMapping("/question/{questionId}") + public void updateQuestion(@PathVariable Long questionId, + @RequestBody QuestionUpdateDto dto) { + matchingService.updateQuestion(questionId, dto); + } + + /** + * 설문 질문 삭제 + */ + @DeleteMapping("/question/{questionId}") + public void deleteQuestion(@PathVariable Long questionId) { + matchingService.deleteQuestion(questionId); + } + + /** + * 매칭된 팀원 이름 조회 + * 매칭 완료 후 해당 사용자의 서브그룹 멤버들을 조회 + */ + @GetMapping("/matched-names/{teamId}") + public ResponseEntity getMatchedNames( + @RequestParam Long userId, + @PathVariable Long teamId) { + + log.info("[Matching] 매칭된 팀원 조회 | userId: {}, teamId: {}", userId, teamId); + + // 1) userId와 teamId로 subGroupId 조회 + Long subGroupId = matchingService.findSubGroupIdByTeamIdAndUserId(teamId, userId); + + if (subGroupId == null) { + // 서브그룹 미존재 (매칭 안된 상태) + return ResponseEntity.status(404).body(new MatchedNamesResponse(teamId, null, List.of())); + } + + // 2) 매칭된 멤버 이름 조회 (본인 제외) + List matchedNames = matchingService.getMatchedUserNames(userId, teamId); + + log.info("[Matching] 매칭된 팀원 수: {}", matchedNames.size()); + + MatchedNamesResponse response = new MatchedNamesResponse(teamId, subGroupId, matchedNames); + + return ResponseEntity.ok(response); + } + + /** + * 사용자의 서브그룹 ID 조회 + * 매칭 완료 후 사용자가 속한 서브그룹을 확인 + */ + @GetMapping("/subgroup/{teamId}") + public ResponseEntity> getSubGroupIdByTeamId( + @PathVariable Long teamId, + @RequestParam Long userId) { + Optional subGroupId = subGroupMemberRepository.findSubGroupIdByTeamIdAndUserId(teamId, userId); + Map response = new HashMap<>(); + response.put("subGroupId", subGroupId.orElse(null)); + return ResponseEntity.ok(response); + } + +} + + + + + + + diff --git a/src/main/java/com/ccapp/ccgo/matching/domain/MbtiScoreId.java b/src/main/java/com/ccapp/ccgo/matching/domain/MbtiScoreId.java new file mode 100644 index 0000000..3071a9b --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/matching/domain/MbtiScoreId.java @@ -0,0 +1,13 @@ +package com.ccapp.ccgo.matching.domain; + +import lombok.*; + +import java.io.Serializable; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class MbtiScoreId implements Serializable { + private String fromMbti; + private String toMbti; +} diff --git a/src/main/java/com/ccapp/ccgo/matching/domain/MbtiScoreProvider.java b/src/main/java/com/ccapp/ccgo/matching/domain/MbtiScoreProvider.java new file mode 100644 index 0000000..8f3d567 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/matching/domain/MbtiScoreProvider.java @@ -0,0 +1,38 @@ +package com.ccapp.ccgo.matching.domain; + +import com.ccapp.ccgo.matching.domain.entity.MbtiScore; +import com.ccapp.ccgo.matching.repository.MbtiScoreRepository; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +// Map 캐시 만들어주는 역할 + +@Component +@RequiredArgsConstructor +public class MbtiScoreProvider { + + private final MbtiScoreRepository mbtiScoreRepository; + + private final Map> scoreMap = new HashMap<>(); + + @PostConstruct + public void loadMbtiScores() { + List scores = mbtiScoreRepository.findAll(); + for (MbtiScore score : scores) { + scoreMap + .computeIfAbsent(score.getFromMbti(), k -> new HashMap<>()) + .put(score.getToMbti(), score.getScore()); + } + } + + public int getScore(String fromMbti, String toMbti) { + return scoreMap + .getOrDefault(fromMbti, new HashMap<>()) + .getOrDefault(toMbti, 0); + } +} diff --git a/src/main/java/com/ccapp/ccgo/matching/domain/PairMatch.java b/src/main/java/com/ccapp/ccgo/matching/domain/PairMatch.java new file mode 100644 index 0000000..72c8948 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/matching/domain/PairMatch.java @@ -0,0 +1,16 @@ +package com.ccapp.ccgo.matching.domain; + +import com.ccapp.ccgo.team.entity.TeamMember; +import lombok.*; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Getter + +public class PairMatch { + private TeamMember male; // 남자 멤버 + private TeamMember female; // 여자 멤버 + private double totalScore; // 매칭 점수(MBTI + 설문 등 종합) +} diff --git a/src/main/java/com/ccapp/ccgo/matching/domain/entity/Answer.java b/src/main/java/com/ccapp/ccgo/matching/domain/entity/Answer.java new file mode 100644 index 0000000..90280a5 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/matching/domain/entity/Answer.java @@ -0,0 +1,47 @@ +package com.ccapp.ccgo.matching.domain.entity; + +import com.ccapp.ccgo.team.entity.Team; +import com.ccapp.ccgo.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "answer") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Answer { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 답변한 사용자 + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + /** + * 어떤 질문에 대한 답변인지 (외래키 아님) + */ + @Column(name = "question_id", nullable = false) + private Long questionId; + + /** + * 유저가 선택한 점수 (1~5) + */ + @Column(nullable = false) + private Integer score; + + /** + * 어떤 팀에서 제출한 답변인지 + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_id", nullable = false) + private Team team; + +} diff --git a/src/main/java/com/ccapp/ccgo/matching/domain/entity/MbtiScore.java b/src/main/java/com/ccapp/ccgo/matching/domain/entity/MbtiScore.java new file mode 100644 index 0000000..adba3c6 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/matching/domain/entity/MbtiScore.java @@ -0,0 +1,27 @@ +package com.ccapp.ccgo.matching.domain.entity; + +import com.ccapp.ccgo.matching.domain.MbtiScoreId; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@IdClass(MbtiScoreId.class) +@Table(name = "mbti_score") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class MbtiScore { + + @Id + @Column(name = "from_mbti", length = 4) + private String fromMbti; + + @Id + @Column(name = "to_mbti", length = 4) + private String toMbti; + + @Column(nullable = false) + private Integer score; +} diff --git a/src/main/java/com/ccapp/ccgo/matching/domain/entity/Question.java b/src/main/java/com/ccapp/ccgo/matching/domain/entity/Question.java new file mode 100644 index 0000000..d7e35d8 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/matching/domain/entity/Question.java @@ -0,0 +1,33 @@ +package com.ccapp.ccgo.matching.domain.entity; + +import com.ccapp.ccgo.team.entity.Team; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "question") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Question { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 어떤 팀에서 작성한 질문인지 + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_id", nullable = false) + private Team team; + + /** + * 질문 텍스트 + * ex) "당신의 주말 취미는 무엇인가요?" + */ + @Column(nullable = false, length = 1000) + private String text; +} diff --git a/src/main/java/com/ccapp/ccgo/matching/domain/entity/SubGroup.java b/src/main/java/com/ccapp/ccgo/matching/domain/entity/SubGroup.java new file mode 100644 index 0000000..3d4c3ec --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/matching/domain/entity/SubGroup.java @@ -0,0 +1,40 @@ +package com.ccapp.ccgo.matching.domain.entity; + +import com.ccapp.ccgo.team.entity.Team; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "sub_group") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SubGroup { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 어떤 팀 안에서 만들어진 그룹인지 + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_id", nullable = false) + private Team team; + + /** + * 그룹 이름 + * 예) "A팀 매칭 그룹 1번" + */ + @Column(nullable = false, length = 255) + private String name; + + /** + * 그룹당 멤버 수 (고정) + */ + @Column(name = "member_count", nullable = false) + private int memberCount; + +} diff --git a/src/main/java/com/ccapp/ccgo/matching/domain/entity/SubGroupMember.java b/src/main/java/com/ccapp/ccgo/matching/domain/entity/SubGroupMember.java new file mode 100644 index 0000000..c3c784c --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/matching/domain/entity/SubGroupMember.java @@ -0,0 +1,36 @@ +package com.ccapp.ccgo.matching.domain.entity; + +import com.ccapp.ccgo.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "sub_group_member") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class +SubGroupMember { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 어떤 SubGroup 에 소속되었는지 + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sub_group_id", nullable = false) + private SubGroup subGroup; + + /** + * 소속된 사용자 + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + +} diff --git a/src/main/java/com/ccapp/ccgo/matching/dto/MatchedNamesResponse.java b/src/main/java/com/ccapp/ccgo/matching/dto/MatchedNamesResponse.java new file mode 100644 index 0000000..8c282cb --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/matching/dto/MatchedNamesResponse.java @@ -0,0 +1,14 @@ +package com.ccapp.ccgo.matching.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.List; + +@Data +@AllArgsConstructor +public class MatchedNamesResponse { + private Long teamId; + private Long subGroupId; + private List matchedNames; +} diff --git a/src/main/java/com/ccapp/ccgo/matching/dto/MatchingResponseDto.java b/src/main/java/com/ccapp/ccgo/matching/dto/MatchingResponseDto.java new file mode 100644 index 0000000..b02c865 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/matching/dto/MatchingResponseDto.java @@ -0,0 +1,16 @@ +package com.ccapp.ccgo.matching.dto; + +import lombok.*; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class MatchingResponseDto { + private Long teamId; + private String teamName; + private boolean matchingStarted; + private List subGroups; +} diff --git a/src/main/java/com/ccapp/ccgo/matching/dto/MatchingResultDto.java b/src/main/java/com/ccapp/ccgo/matching/dto/MatchingResultDto.java new file mode 100644 index 0000000..0f423b4 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/matching/dto/MatchingResultDto.java @@ -0,0 +1,16 @@ +package com.ccapp.ccgo.matching.dto; + +import com.ccapp.ccgo.user.dto.UserResponseDto; +import lombok.*; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class MatchingResultDto { + private Long subGroupId; // SubGroup PK + private String groupName; // SubGroup 이름 (팀이름+인덱스) + private List members; // 그룹 멤버 리스트 +} diff --git a/src/main/java/com/ccapp/ccgo/matching/repository/MbtiScoreRepository.java b/src/main/java/com/ccapp/ccgo/matching/repository/MbtiScoreRepository.java new file mode 100644 index 0000000..8e13654 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/matching/repository/MbtiScoreRepository.java @@ -0,0 +1,11 @@ +package com.ccapp.ccgo.matching.repository; + +import com.ccapp.ccgo.matching.domain.entity.MbtiScore; +import com.ccapp.ccgo.matching.domain.MbtiScoreId; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MbtiScoreRepository extends JpaRepository { + List findAll(); +} diff --git a/src/main/java/com/ccapp/ccgo/matching/repository/SubGroupMemberRepository.java b/src/main/java/com/ccapp/ccgo/matching/repository/SubGroupMemberRepository.java new file mode 100644 index 0000000..1e85944 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/matching/repository/SubGroupMemberRepository.java @@ -0,0 +1,36 @@ +package com.ccapp.ccgo.matching.repository; + +import com.ccapp.ccgo.matching.domain.entity.SubGroup; +import com.ccapp.ccgo.matching.domain.entity.SubGroupMember; +import com.ccapp.ccgo.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface SubGroupMemberRepository extends JpaRepository { + List findBySubGroup_Id(Long subGroupId); + + @Query(""" + SELECT m.user + FROM SubGroupMember m + WHERE m.subGroup = ( + SELECT sm.subGroup + FROM SubGroupMember sm + WHERE sm.user.id = :userId AND sm.subGroup.team.teamId = :teamId + ) + AND m.user.id <> :userId + """) + List findTeamMatchedMembersExcludingUser(@Param("userId") Long userId, @Param("teamId") Long teamId); + + + long countBySubGroup_Id(Long subGroupId); + + @Query("SELECT sgm.subGroup.id FROM SubGroupMember sgm WHERE sgm.subGroup.team.teamId = :teamId AND sgm.user.id = :userId") + Optional findSubGroupIdByTeamIdAndUserId(@Param("teamId") Long teamId, @Param("userId") Long userId); + + + +} diff --git a/src/main/java/com/ccapp/ccgo/matching/repository/SubGroupRepository.java b/src/main/java/com/ccapp/ccgo/matching/repository/SubGroupRepository.java new file mode 100644 index 0000000..a254348 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/matching/repository/SubGroupRepository.java @@ -0,0 +1,14 @@ +package com.ccapp.ccgo.matching.repository; + +import com.ccapp.ccgo.matching.domain.entity.SubGroup; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface SubGroupRepository extends JpaRepository { + List findByTeam_TeamId(Long teamId); + +} diff --git a/src/main/java/com/ccapp/ccgo/matching/service/MatchingService.java b/src/main/java/com/ccapp/ccgo/matching/service/MatchingService.java new file mode 100644 index 0000000..78b6c13 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/matching/service/MatchingService.java @@ -0,0 +1,555 @@ +package com.ccapp.ccgo.matching.service; + +import com.ccapp.ccgo.matching.domain.MbtiScoreProvider; +import com.ccapp.ccgo.matching.domain.PairMatch; +import com.ccapp.ccgo.matching.domain.entity.Answer; +import com.ccapp.ccgo.matching.domain.entity.Question; +import com.ccapp.ccgo.matching.domain.entity.SubGroup; +import com.ccapp.ccgo.matching.domain.entity.SubGroupMember; +import com.ccapp.ccgo.matching.dto.MatchingResponseDto; +import com.ccapp.ccgo.matching.dto.MatchingResultDto; +import com.ccapp.ccgo.matching.repository.SubGroupMemberRepository; +import com.ccapp.ccgo.matching.repository.SubGroupRepository; +import com.ccapp.ccgo.question.repository.AnswerRepository; +import com.ccapp.ccgo.question.repository.QuestionRepository; +import com.ccapp.ccgo.question.dto.AnswerRequestDto; +import com.ccapp.ccgo.question.dto.QuestionRequestDto; +import com.ccapp.ccgo.question.dto.QuestionResponseDto; +import com.ccapp.ccgo.question.dto.QuestionUpdateDto; +import com.ccapp.ccgo.team.entity.Team; +import com.ccapp.ccgo.team.entity.TeamMember; +import com.ccapp.ccgo.team.repository.TeamMemberRepository; +import com.ccapp.ccgo.team.repository.TeamRepository; +import com.ccapp.ccgo.user.dto.UserResponseDto; +import com.ccapp.ccgo.user.entity.User; +import com.ccapp.ccgo.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MatchingService { + + private final TeamMemberRepository teamMemberRepository; + private final QuestionRepository questionRepository; + private final AnswerRepository answerRepository; + private final SubGroupRepository subGroupRepository; + private final SubGroupMemberRepository subGroupMemberRepository; + private final TeamRepository teamRepository; + private final UserRepository userRepository; + private final MbtiScoreProvider mbtiScoreProvider; + + private static final int MAX_GROUP_SIZE = 4; + private static final double MBTI_WEIGHT = 0.5; + private static final double SIMILARITY_WEIGHT = 0.5; + + @Transactional + public MatchingResponseDto performMatching(Long teamId) { + List members = teamMemberRepository.findByTeam_TeamIdAndIsActiveTrue(teamId); + if (members.isEmpty()) { + return new MatchingResponseDto(teamId, "", true, Collections.emptyList()); + } + Team team = members.get(0).getTeam(); + + List males = members.stream() + .filter(m -> "MALE".equalsIgnoreCase(m.getUser().getGender())) + .collect(Collectors.toList()); + List females = members.stream() + .filter(m -> "FEMALE".equalsIgnoreCase(m.getUser().getGender())) + .collect(Collectors.toList()); + + List matchCandidates = createPairMatchCandidates(males, females, teamId); + matchCandidates.sort(Comparator.comparingDouble(PairMatch::getTotalScore).reversed()); + + Set usedMaleIds = new HashSet<>(); + Set usedFemaleIds = new HashSet<>(); + List groups = new ArrayList<>(); + int groupIndex = 1; + + // 1) 커플 우선 매칭 + for (PairMatch pair : matchCandidates) { + Long maleId = pair.getMale().getUser().getId(); + Long femaleId = pair.getFemale().getUser().getId(); + if (usedMaleIds.contains(maleId) || usedFemaleIds.contains(femaleId)) continue; + + String groupName = team.getTeamName() + groupIndex++; + SubGroup group = SubGroup.builder() + .team(team) + .name(groupName) + .memberCount(2) + .build(); + subGroupRepository.save(group); + + saveSubGroupMember(group, pair.getMale().getUser()); + saveSubGroupMember(group, pair.getFemale().getUser()); + + usedMaleIds.add(maleId); + usedFemaleIds.add(femaleId); + groups.add(group); + } + + // 커플로 먼저 만들어진 그룹들만을 "삽입 타깃"으로 고정 + List coupleGroups = new ArrayList<>(groups); + + List leftoverMales = males.stream() + .filter(m -> !usedMaleIds.contains(m.getUser().getId())) + .collect(Collectors.toList()); + List leftoverFemales = females.stream() + .filter(f -> !usedFemaleIds.contains(f.getUser().getId())) + .collect(Collectors.toList()); + + // 2) 잉여 인원 처리 + handleLeftovers(leftoverMales, leftoverFemales, groups, coupleGroups, team, groupIndex, teamId); + + return buildMatchingResponseDto(team, groups); + } + + private List createPairMatchCandidates(List males, List females, Long teamId) { + List result = new ArrayList<>(); + for (TeamMember m : males) { + for (TeamMember f : females) { + double mbti = calculateMbtiScore(m, f); + double sim = calculateSimilarity(m, f, teamId); + result.add(new PairMatch(m, f, mbti * MBTI_WEIGHT + sim * SIMILARITY_WEIGHT)); + } + } + return result; + } + + private double calculateSimilarity(TeamMember a, TeamMember b, Long teamId) { + List questions = questionRepository.findByTeam_TeamId(teamId); + int totalSim = 0; + + for (Question q : questions) { + int sa = answerRepository.findByUser_IdAndQuestionId(a.getUser().getId(), q.getId()) + .map(Answer::getScore).orElse(0); + int sb = answerRepository.findByUser_IdAndQuestionId(b.getUser().getId(), q.getId()) + .map(Answer::getScore).orElse(0); + totalSim += Math.max(0, 5 - Math.abs(sa - sb)); + } + + return questions.isEmpty() + ? 0 + : (double) totalSim / (5 * questions.size()) * 100; + } + + private double calculateMbtiScore(TeamMember a, TeamMember b) { + String mbtiA = a.getMbti(); + String mbtiB = b.getMbti(); + if (mbtiA == null || mbtiB == null) return 0; + return mbtiScoreProvider.getScore(mbtiA, mbtiB) + mbtiScoreProvider.getScore(mbtiB, mbtiA); + } + + // ★ 시그니처 변경: insertTargets = 커플 그룹들 + private void handleLeftovers( + List males, + List females, + List allGroups, + List insertTargets, // 커플로 성사된 그룹만 + Team team, + int groupIndex, + Long teamId + ) { + // 여자가 없고 남자만 남은 경우 + if (females.isEmpty() && !males.isEmpty()) { + handleMaleOnlyGroups(males, allGroups, insertTargets, team, groupIndex, teamId); + return; + } + + // 남자가 없고 여자만 남은 경우 + if (males.isEmpty() && !females.isEmpty()) { + handleFemaleOnlyGroups(females, allGroups, insertTargets, team, groupIndex, teamId); + return; + } + + // 남자 2명 남으면 남남 그룹 생성 (규칙) + if (males.size() == 2) { + SubGroup g = SubGroup.builder() + .team(team) + .name(team.getTeamName() + groupIndex++) + .memberCount(2) + .build(); + subGroupRepository.save(g); + saveSubGroupMember(g, males.get(0).getUser()); + saveSubGroupMember(g, males.get(1).getUser()); + allGroups.add(g); + males = Collections.emptyList(); // 소비 + } else if (males.size() == 1) { + // 남자 1명 남으면 커플 그룹에 삽입 + SubGroup best = findBestGroupToInsert(males.get(0), insertTargets, teamId); + if (best != null && best.getMemberCount() < MAX_GROUP_SIZE) { + saveSubGroupMember(best, males.get(0).getUser()); + best.setMemberCount(best.getMemberCount() + 1); + subGroupRepository.save(best); + } + males = Collections.emptyList(); + } else if (males.size() > 2) { + // 2명씩 남남 그룹으로 소진, 홀수 1명 남으면 커플 그룹에 삽입 + int i = 0; + while (i + 1 < males.size()) { + SubGroup g = SubGroup.builder() + .team(team) + .name(team.getTeamName() + groupIndex++) + .memberCount(2) + .build(); + subGroupRepository.save(g); + saveSubGroupMember(g, males.get(i).getUser()); + saveSubGroupMember(g, males.get(i + 1).getUser()); + allGroups.add(g); + i += 2; + } + if (i < males.size()) { // 홀수 1명 + TeamMember last = males.get(i); + SubGroup best = findBestGroupToInsert(last, insertTargets, teamId); + if (best != null && best.getMemberCount() < MAX_GROUP_SIZE) { + saveSubGroupMember(best, last.getUser()); + best.setMemberCount(best.getMemberCount() + 1); + subGroupRepository.save(best); + } + } + males = Collections.emptyList(); + } + + // 여자 잉여: 새 그룹 만들지 말고 커플 그룹에만 삽입 + for (TeamMember female : females) { + SubGroup best = findBestGroupToInsert(female, insertTargets, teamId); + if (best != null && best.getMemberCount() < MAX_GROUP_SIZE && isFemaleInsertable(best, female, females.size())) { + saveSubGroupMember(best, female.getUser()); + best.setMemberCount(best.getMemberCount() + 1); + subGroupRepository.save(best); + } + } + } + + // ★ 수정: 남자만 있는 경우도 규칙(남남 2인 그룹) 우선, 홀수는 커플 그룹에 삽입 + private void handleMaleOnlyGroups( + List males, + List allGroups, + List insertTargets, // 커플 그룹 + Team team, + int groupIndex, + Long teamId + ) { + int maleCount = males.size(); + if (maleCount == 1) { + SubGroup best = findBestGroupToInsert(males.get(0), insertTargets, teamId); + if (best != null && best.getMemberCount() < MAX_GROUP_SIZE) { + saveSubGroupMember(best, males.get(0).getUser()); + best.setMemberCount(best.getMemberCount() + 1); + subGroupRepository.save(best); + } + return; + } + // 2명씩 남남 그룹 생성 + int i = 0; + while (i + 1 < maleCount) { + SubGroup group = SubGroup.builder() + .team(team) + .name(team.getTeamName() + groupIndex++) + .memberCount(2) + .build(); + subGroupRepository.save(group); + saveSubGroupMember(group, males.get(i).getUser()); + saveSubGroupMember(group, males.get(i + 1).getUser()); + allGroups.add(group); + i += 2; + } + // 홀수 1명 남으면 커플 그룹에 삽입 + if (i < maleCount) { + TeamMember last = males.get(i); + SubGroup best = findBestGroupToInsert(last, insertTargets, teamId); + if (best != null && best.getMemberCount() < MAX_GROUP_SIZE) { + saveSubGroupMember(best, last.getUser()); + best.setMemberCount(best.getMemberCount() + 1); + subGroupRepository.save(best); + } + } + } + + // ★ 완전 변경: 여자만 있는 경우 기본은 커플 그룹 삽입만 허용 + // 단, 커플 그룹이 하나도 없다면(완전 무매칭) 예전 분할 방식 폴백 적용 + private void handleFemaleOnlyGroups( + List females, + List allGroups, + List insertTargets, + Team team, + int groupIndex, + Long teamId + ) { + if (insertTargets == null || insertTargets.isEmpty()) { + // 폴백: 커플 그룹이 없으면 이전 방식으로 안전 분할 + fallbackSplitFemaleOnly(females, allGroups, team, groupIndex, teamId); + return; + } + + for (TeamMember female : females) { + SubGroup best = findBestGroupToInsert(female, insertTargets, teamId); + if (best != null && best.getMemberCount() < MAX_GROUP_SIZE && isFemaleInsertable(best, female, females.size())) { + saveSubGroupMember(best, female.getUser()); + best.setMemberCount(best.getMemberCount() + 1); + subGroupRepository.save(best); + } + } + } + + // 폴백 로직: 커플 그룹이 0개인 경우에만 사용 (이전 구현 유지) + private void fallbackSplitFemaleOnly( + List females, + List groups, + Team team, + int groupIndex, + Long teamId + ) { + int femaleCount = females.size(); + + if (femaleCount <= 3) { + SubGroup group = SubGroup.builder() + .team(team) + .name(team.getTeamName() + groupIndex++) + .memberCount(femaleCount) + .build(); + subGroupRepository.save(group); + for (TeamMember member : females) { + saveSubGroupMember(group, member.getUser()); + } + groups.add(group); + } else if (femaleCount == 4) { + createFemaleGroup(females.subList(0, 2), groups, team, groupIndex++, teamId); + createFemaleGroup(females.subList(2, 4), groups, team, groupIndex++, teamId); + } else if (femaleCount == 5) { + createFemaleGroup(females.subList(0, 2), groups, team, groupIndex++, teamId); + createFemaleGroup(females.subList(2, 5), groups, team, groupIndex++, teamId); + } else if (femaleCount == 6) { + createFemaleGroup(females.subList(0, 2), groups, team, groupIndex++, teamId); + createFemaleGroup(females.subList(2, 4), groups, team, groupIndex++, teamId); + createFemaleGroup(females.subList(4, 6), groups, team, groupIndex++, teamId); + } else { + int groupCount = femaleCount / 2; + for (int i = 0; i < groupCount; i++) { + int startIndex = i * 2; + int endIndex = Math.min(startIndex + 2, femaleCount); + createFemaleGroup(females.subList(startIndex, endIndex), groups, team, groupIndex++, teamId); + } + if (femaleCount % 2 == 1) { + TeamMember remainingFemale = females.get(femaleCount - 1); + SubGroup bestGroup = findBestGroupToInsert(remainingFemale, groups, teamId); + if (bestGroup != null && bestGroup.getMemberCount() < MAX_GROUP_SIZE) { + saveSubGroupMember(bestGroup, remainingFemale.getUser()); + bestGroup.setMemberCount(bestGroup.getMemberCount() + 1); + subGroupRepository.save(bestGroup); + } + } + } + } + + // 여자 그룹 생성 헬퍼 (폴백에서만 사용) + private void createFemaleGroup(List groupMembers, List groups, Team team, int groupIndex, Long teamId) { + SubGroup group = SubGroup.builder() + .team(team) + .name(team.getTeamName() + groupIndex) + .memberCount(groupMembers.size()) + .build(); + subGroupRepository.save(group); + + for (TeamMember member : groupMembers) { + saveSubGroupMember(group, member.getUser()); + } + groups.add(group); + } + + private boolean isFemaleInsertable(SubGroup group, TeamMember female, int totalFemaleLeft) { + List members = subGroupMemberRepository.findBySubGroup_Id(group.getId()); + long femaleNum = members.stream().filter(m -> "FEMALE".equalsIgnoreCase(m.getUser().getGender())).count(); + long maleNum = members.size() - femaleNum; + if ("FEMALE".equalsIgnoreCase(female.getUser().getGender())) { + // 남자 2+ / 여자 0 구성에, 여자가 잔여가 1명 초과면 먼저 다른 커플부터 채우도록 억제 + if (maleNum > 1 && femaleNum == 0 && totalFemaleLeft > 1) return false; + } + return true; + } + + // ★ 시그니처 변경: 삽입 후보 그룹을 파라미터로 (커플 그룹만 넘기도록) + private SubGroup findBestGroupToInsert(TeamMember tm, List candidateGroups, Long teamId) { + if (candidateGroups == null || candidateGroups.isEmpty()) return null; + + // 1) 2명 그룹(커플) 우선 + Optional twoMemberGroup = candidateGroups.stream() + .filter(g -> g.getMemberCount() == 2) + .filter(g -> g.getMemberCount() < MAX_GROUP_SIZE) + .max(Comparator.comparingDouble(g -> calculateAverageSimilarity(tm, g, teamId))); + + if (twoMemberGroup.isPresent()) return twoMemberGroup.get(); + + // 2) 3명 그룹 중 최대 유사도 + Optional threeMemberGroup = candidateGroups.stream() + .filter(g -> g.getMemberCount() == 3) + .filter(g -> g.getMemberCount() < MAX_GROUP_SIZE) + .max(Comparator.comparingDouble(g -> calculateAverageSimilarity(tm, g, teamId))); + + if (threeMemberGroup.isPresent()) return threeMemberGroup.get(); + + // 3) 여유 있는 그룹 중 최대 유사도 + return candidateGroups.stream() + .filter(g -> g.getMemberCount() < MAX_GROUP_SIZE) + .max(Comparator.comparingDouble(g -> calculateAverageSimilarity(tm, g, teamId))) + .orElse(null); + } + + private double calculateAverageSimilarity(TeamMember user, SubGroup group, Long teamId) { + List members = subGroupMemberRepository.findBySubGroup_Id(group.getId()); + double total = 0; + for (SubGroupMember m : members) { + TeamMember tm = teamMemberRepository.findByUser_IdAndTeam_TeamId(m.getUser().getId(), teamId) + .orElseThrow(); + total += calculateSimilarity(user, tm, teamId); + } + return members.isEmpty() ? 0 : total / members.size(); + } + + private void saveSubGroupMember(SubGroup group, User user) { + SubGroupMember m = SubGroupMember.builder().subGroup(group).user(user).build(); + subGroupMemberRepository.save(m); + } + + private MatchingResponseDto buildMatchingResponseDto(Team team, List groups) { + List result = groups.stream().map(g -> { + List members = subGroupMemberRepository.findBySubGroup_Id(g.getId()); + List users = members.stream().map(m -> { + User u = m.getUser(); + TeamMember tm = teamMemberRepository.findByUser_IdAndTeam_TeamId(u.getId(), team.getTeamId()) + .orElseThrow(); + return UserResponseDto.builder() + .id(u.getId()) + .email(u.getEmail()) + .name(u.getName()) + .gender(u.getGender()) + .birthdate(u.getBirthdate()) + .createdAt(u.getCreatedAt()) + .mbti(tm.getMbti()) + .build(); + }).collect(Collectors.toList()); + return new MatchingResultDto(g.getId(), g.getName(), users); + }).collect(Collectors.toList()); + + return new MatchingResponseDto(team.getTeamId(), team.getTeamName(), true, result); + } + + // ====== 설문/질문 CRUD ====== + + @Transactional + public void saveAnswers(AnswerRequestDto dto, User user) { + Long userId = user.getId(); + Long teamId = dto.getTeamId(); + + List teamQuestions = questionRepository.findByTeam_TeamId(teamId); + List teamQuestionIds = teamQuestions.stream() + .map(Question::getId) + .collect(Collectors.toList()); + List existingAnswers = answerRepository.findByUser_Id(userId).stream() + .filter(ans -> teamQuestionIds.contains(ans.getQuestionId())) + .collect(Collectors.toList()); + answerRepository.deleteAll(existingAnswers); + + Team team = teamRepository.findById(teamId) + .orElseThrow(() -> new IllegalArgumentException("Team not found")); + List newAnswers = dto.getAnswers().stream() + .map(single -> Answer.builder() + .user(user) + .questionId(single.getQuestionId()) + .score(single.getScore()) + .team(team) + .build()) + .collect(Collectors.toList()); + + answerRepository.saveAll(newAnswers); + + if (dto.getMbti() != null && !dto.getMbti().isEmpty()) { + TeamMember teamMember = teamMemberRepository + .findByUser_IdAndTeam_TeamId(userId, teamId) + .orElseThrow(() -> new IllegalArgumentException("TeamMember not found")); + + teamMember.setMbti(dto.getMbti()); + teamMemberRepository.save(teamMember); + } + } + + @Transactional + public void createQuestions(QuestionRequestDto dto) { + Long teamId = dto.getTeamId(); + + List questions = dto.getQuestions().stream() + .map(q -> Question.builder() + .team(Team.builder().teamId(teamId).build()) + .text(q) + .build()) + .collect(Collectors.toList()); + + questionRepository.saveAll(questions); + } + + @Transactional + public void updateQuestion(Long questionId, QuestionUpdateDto dto) { + Question question = questionRepository.findById(questionId) + .orElseThrow(() -> new IllegalArgumentException("Question not found: " + questionId)); + + question.setText(dto.getText()); + } + + @Transactional(readOnly = true) + public List getQuestions(Long teamId) { + List questions = questionRepository.findByTeam_TeamId(teamId); + + return questions.stream() + .map(q -> QuestionResponseDto.builder() + .id(q.getId()) + .text(q.getText()) + .build()) + .collect(Collectors.toList()); + } + + @Transactional + public void deleteQuestion(Long questionId) { + List answers = answerRepository.findByQuestionId(questionId); + answerRepository.deleteAll(answers); + questionRepository.deleteById(questionId); + } + + /** + * 매칭된 팀원 이름 조회 + */ + @Transactional(readOnly = true) + public List getMatchedUserNames(Long userId, Long teamId) { + log.info("[Matching] 매칭된 팀원 조회 | userId: {}, teamId: {}", userId, teamId); + + userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("사용자가 존재하지 않습니다.")); + + boolean isMember = teamMemberRepository.existsByUser_IdAndTeam_TeamIdAndIsActiveTrue(userId, teamId); + if (!isMember) { + throw new RuntimeException("해당 팀에 소속되어 있지 않습니다."); + } + + List matchedUsers = subGroupMemberRepository.findTeamMatchedMembersExcludingUser(userId, teamId); + + List matchedNames = matchedUsers.stream() + .map(User::getName) + .collect(Collectors.toList()); + + log.info("[Matching] 매칭된 팀원 수: {}", matchedNames.size()); + return matchedNames; + } + + /** + * 사용자의 서브그룹 ID 조회 + */ + public Long findSubGroupIdByTeamIdAndUserId(Long teamId, Long userId) { + return subGroupMemberRepository.findSubGroupIdByTeamIdAndUserId(teamId, userId) + .orElse(null); + } +} diff --git a/src/main/java/com/ccapp/ccgo/mission/controller/MissionAssignmentController.java b/src/main/java/com/ccapp/ccgo/mission/controller/MissionAssignmentController.java new file mode 100644 index 0000000..e4c0200 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/mission/controller/MissionAssignmentController.java @@ -0,0 +1,66 @@ +package com.ccapp.ccgo.mission.controller; + +import com.ccapp.ccgo.mission.dto.MissionCompleteRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import com.ccapp.ccgo.mission.service.SubGroupMissionService; +import com.ccapp.ccgo.mission.dto.SubGroupMissionDto; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +@RestController +@RequestMapping("/api/missions") +@RequiredArgsConstructor +@Slf4j +public class MissionAssignmentController { + + private final SubGroupMissionService subGroupMissionService; + + // 서브그룹에 미션 부여 + @PostMapping("/assign/subgroup/{subGroupId}") + public ResponseEntity assignMissionsToSubGroup(@PathVariable Long subGroupId) { + subGroupMissionService.assignMissionsToSubGroup(subGroupId); + return ResponseEntity.ok("미션이 서브그룹에 성공적으로 부여되었습니다."); + } + + // 서브그룹에 부여된 미션 리스트 조회 + @GetMapping("/subgroup/{subGroupId}") + public ResponseEntity> getSubGroupMissions(@PathVariable Long subGroupId) { + List missions = subGroupMissionService.getMissions(subGroupId); + return ResponseEntity.ok(missions); + } + + // 미션 완료 처리 + @PostMapping("/complete") + public ResponseEntity completeMission(@RequestBody MissionCompleteRequest request) { + try { + subGroupMissionService.completeMission(request.getTeamId(), request.getSubGroupId(), request.getMissionId()); + return ResponseEntity.ok("미션 완료 처리 성공"); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("미션 완료 처리 실패: " + e.getMessage()); + } + } + + + /** + * 미션 새로고침 + */ + @PostMapping("/refresh/subgroup/{subGroupId}/{subGroupMissionId}/{score}") + public ResponseEntity refreshMission( + @PathVariable Long subGroupId, + @PathVariable Long subGroupMissionId, + @PathVariable Integer score) { + try { + log.info("[Mission] 미션 새로고침 요청 | subGroupId: {}, missionId: {}, score: {}", subGroupId, subGroupMissionId, score); + subGroupMissionService.refreshSingleMission(subGroupId, subGroupMissionId, score); + return ResponseEntity.ok("미션 새로고침 완료"); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("미션 새로고침 실패: " + e.getMessage()); + } + } + +} diff --git a/src/main/java/com/ccapp/ccgo/mission/controller/MissionHistoryController.java b/src/main/java/com/ccapp/ccgo/mission/controller/MissionHistoryController.java new file mode 100644 index 0000000..45fd5e5 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/mission/controller/MissionHistoryController.java @@ -0,0 +1,52 @@ +package com.ccapp.ccgo.mission.controller; + +import com.ccapp.ccgo.mission.dto.MissionHistoryDto; +import com.ccapp.ccgo.mission.service.SubGroupMissionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.ArrayList; + +@RestController +@RequestMapping("/api/mission/history") +@RequiredArgsConstructor +@Slf4j +public class MissionHistoryController { + + private final SubGroupMissionService subGroupMissionService; + + // 서브그룹의 미션 히스토리 조회 + @GetMapping("/subgroup/{subGroupId}") + public ResponseEntity> getMissionHistoryBySubGroup(@PathVariable Long subGroupId) { + log.info("서브그룹 미션 히스토리 조회 요청: subGroupId = {}", subGroupId); + List histories = subGroupMissionService.getMissionHistoryBySubGroup(subGroupId); + return ResponseEntity.ok(histories); + } + + // 사용자의 미션 히스토리 조회 (팀별) + @GetMapping("/user/{userId}") + public ResponseEntity> getMissionHistoryByUser( + @PathVariable Long userId, + @RequestParam Long teamId) { + log.info("사용자 미션 히스토리 조회 요청: userId = {}, teamId = {}", userId, teamId); + try { + List histories = subGroupMissionService.getMissionHistoryByUser(userId, teamId); + return ResponseEntity.ok(histories); + } catch (Exception e) { + log.error("미션 히스토리 조회 중 오류 발생: {}", e.getMessage()); + // 테이블이 존재하지 않는 경우 등 오류 발생 시 빈 리스트 반환 + return ResponseEntity.ok(new ArrayList<>()); + } + } + + // 팀의 미션 히스토리 조회 + @GetMapping("/team/{teamId}") + public ResponseEntity> getMissionHistoryByTeam(@PathVariable Long teamId) { + log.info("팀 미션 히스토리 조회 요청: teamId = {}", teamId); + List histories = subGroupMissionService.getMissionHistoryByTeam(teamId); + return ResponseEntity.ok(histories); + } +} diff --git a/src/main/java/com/ccapp/ccgo/mission/controller/ScoreboardController.java b/src/main/java/com/ccapp/ccgo/mission/controller/ScoreboardController.java new file mode 100644 index 0000000..50f33ad --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/mission/controller/ScoreboardController.java @@ -0,0 +1,32 @@ +package com.ccapp.ccgo.mission.controller; + +import com.ccapp.ccgo.mission.dto.ScoreboardResponseDto; +import com.ccapp.ccgo.mission.service.ScoreboardService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/teams/{teamId}/scoreboard") +@RequiredArgsConstructor +public class ScoreboardController { + + private final ScoreboardService scoreboardService; + + /** + * 특정 팀의 스코어보드를 조회합니다. + * @param teamId 팀 ID (경로 변수) + * @param userId 사용자 ID (쿼리 파라미터 혹은 인증 토큰에서 추출 가능) + * @return 스코어보드 정보 + */ + @GetMapping + public ResponseEntity getScoreboard( + @PathVariable Long teamId, + @RequestParam Long userId + ) { + ScoreboardResponseDto response = scoreboardService.getScoreboard(teamId, userId); + return ResponseEntity.ok(response); + } + + +} diff --git a/src/main/java/com/ccapp/ccgo/mission/dto/MissionCompleteRequest.java b/src/main/java/com/ccapp/ccgo/mission/dto/MissionCompleteRequest.java new file mode 100644 index 0000000..ec502b0 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/mission/dto/MissionCompleteRequest.java @@ -0,0 +1,10 @@ +package com.ccapp.ccgo.mission.dto; + +import lombok.Data; + +@Data +public class MissionCompleteRequest { + private Long teamId; + private Long subGroupId; // nullable + private Long missionId; +} diff --git a/src/main/java/com/ccapp/ccgo/mission/dto/MissionHistoryDto.java b/src/main/java/com/ccapp/ccgo/mission/dto/MissionHistoryDto.java new file mode 100644 index 0000000..b068944 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/mission/dto/MissionHistoryDto.java @@ -0,0 +1,31 @@ +package com.ccapp.ccgo.mission.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MissionHistoryDto { + + private Long id; + private Long subGroupId; + private String subGroupName; // 서브그룹 이름 (필요시) + private Long teamId; + private String teamName; // 팀 이름 + private Long userId; + private String userName; // 사용자 이름 + private List matchedNames; // 매칭된 상대방들의 이름 + private Long missionTemplateId; + private String missionTitle; // 미션 제목 + private String missionDescription; // 미션 설명 + private Integer missionScore; // 미션 점수 + private LocalDateTime completedAt; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/ccapp/ccgo/mission/dto/ScoreboardResponseDto.java b/src/main/java/com/ccapp/ccgo/mission/dto/ScoreboardResponseDto.java new file mode 100644 index 0000000..2b8bb05 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/mission/dto/ScoreboardResponseDto.java @@ -0,0 +1,17 @@ +package com.ccapp.ccgo.mission.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ScoreboardResponseDto { + private int minScore; // 팀 최소 학점 + private SubGroupScoreDto mySubGroup; // 현재 유저의 서브그룹 점수 + private List otherSubGroups; // 다른 서브그룹들 점수 + +} diff --git a/src/main/java/com/ccapp/ccgo/mission/dto/SubGroupMissionDto.java b/src/main/java/com/ccapp/ccgo/mission/dto/SubGroupMissionDto.java new file mode 100644 index 0000000..23d4a63 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/mission/dto/SubGroupMissionDto.java @@ -0,0 +1,17 @@ +package com.ccapp.ccgo.mission.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class SubGroupMissionDto { + + private Long subGroupMissionId; + private Long missionTemplateId; + private String title; + private String description; + private Integer score; + private boolean completed; + +} diff --git a/src/main/java/com/ccapp/ccgo/mission/dto/SubGroupScoreDto.java b/src/main/java/com/ccapp/ccgo/mission/dto/SubGroupScoreDto.java new file mode 100644 index 0000000..de59794 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/mission/dto/SubGroupScoreDto.java @@ -0,0 +1,17 @@ +package com.ccapp.ccgo.mission.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SubGroupScoreDto { + private Long subGroupId; + private String name; // "1조" 등 + private int score; // 현재 학점 + private List members; // ✅ 서브그룹 소속 유저 이름 +} diff --git a/src/main/java/com/ccapp/ccgo/mission/entity/MissionHistory.java b/src/main/java/com/ccapp/ccgo/mission/entity/MissionHistory.java new file mode 100644 index 0000000..07a1005 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/mission/entity/MissionHistory.java @@ -0,0 +1,55 @@ +package com.ccapp.ccgo.mission.entity; + +import com.ccapp.ccgo.matching.domain.entity.SubGroup; +import com.ccapp.ccgo.team.entity.Team; +import com.ccapp.ccgo.user.entity.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "mission_history") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MissionHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sub_group_id") + private SubGroup subGroup; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_id", referencedColumnName = "teamId") + private Team team; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mission_template_id") + private MissionTemplate missionTemplate; + + @Column(name = "completed_at", nullable = false) + private LocalDateTime completedAt; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + if (completedAt == null) { + completedAt = LocalDateTime.now(); + } + } +} diff --git a/src/main/java/com/ccapp/ccgo/mission/entity/MissionTemplate.java b/src/main/java/com/ccapp/ccgo/mission/entity/MissionTemplate.java new file mode 100644 index 0000000..9f2adfd --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/mission/entity/MissionTemplate.java @@ -0,0 +1,26 @@ +package com.ccapp.ccgo.mission.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.*; + + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class MissionTemplate { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + private String description; + + //1학점 3학점 5학점 10학점 + private Integer score; +} \ No newline at end of file diff --git a/src/main/java/com/ccapp/ccgo/mission/entity/SubGroupMission.java b/src/main/java/com/ccapp/ccgo/mission/entity/SubGroupMission.java new file mode 100644 index 0000000..c43bb55 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/mission/entity/SubGroupMission.java @@ -0,0 +1,31 @@ +package com.ccapp.ccgo.mission.entity; + +import com.ccapp.ccgo.matching.domain.entity.SubGroup; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "subgroup_mission") +public class SubGroupMission { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "subgroup_id") + private SubGroup subGroup; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "mission_template_id") + private MissionTemplate missionTemplate; + + private boolean completed; + + +} diff --git a/src/main/java/com/ccapp/ccgo/mission/repository/MissionHistoryRepository.java b/src/main/java/com/ccapp/ccgo/mission/repository/MissionHistoryRepository.java new file mode 100644 index 0000000..0f34e20 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/mission/repository/MissionHistoryRepository.java @@ -0,0 +1,27 @@ +package com.ccapp.ccgo.mission.repository; + +import com.ccapp.ccgo.mission.entity.MissionHistory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface MissionHistoryRepository extends JpaRepository { + + // 특정 서브그룹의 미션 히스토리 조회 + List findBySubGroup_IdOrderByCompletedAtDesc(Long subGroupId); + + // 특정 사용자의 미션 히스토리 조회 (팀별) + List findByUser_IdAndTeam_TeamIdOrderByCompletedAtDesc(Long userId, Long teamId); + + // 특정 팀의 미션 히스토리 조회 + @Query("SELECT mh FROM MissionHistory mh WHERE mh.team.teamId = :teamId ORDER BY mh.completedAt DESC") + List findByTeamIdOrderByCompletedAtDesc(@Param("teamId") Long teamId); + + // 특정 미션 템플릿의 완료 히스토리 조회 + List findByMissionTemplate_IdOrderByCompletedAtDesc(Long missionTemplateId); + + // 특정 서브그룹에서 특정 미션 템플릿이 완료되었는지 확인 + boolean existsBySubGroup_IdAndMissionTemplate_Id(Long subGroupId, Long missionTemplateId); +} diff --git a/src/main/java/com/ccapp/ccgo/mission/repository/MissionTemplateRepository.java b/src/main/java/com/ccapp/ccgo/mission/repository/MissionTemplateRepository.java new file mode 100644 index 0000000..647faf2 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/mission/repository/MissionTemplateRepository.java @@ -0,0 +1,11 @@ +package com.ccapp.ccgo.mission.repository; + +import com.ccapp.ccgo.mission.entity.MissionTemplate; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface MissionTemplateRepository extends JpaRepository { + + List findByScore(Integer score); + +} diff --git a/src/main/java/com/ccapp/ccgo/mission/repository/SubGroupMissionRepository.java b/src/main/java/com/ccapp/ccgo/mission/repository/SubGroupMissionRepository.java new file mode 100644 index 0000000..4477036 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/mission/repository/SubGroupMissionRepository.java @@ -0,0 +1,26 @@ +package com.ccapp.ccgo.mission.repository; + +import com.ccapp.ccgo.matching.domain.entity.SubGroup; +import com.ccapp.ccgo.mission.entity.SubGroupMission; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface SubGroupMissionRepository extends JpaRepository { + + List findBySubGroup(SubGroup subGroup); + + Optional findBySubGroupAndMissionTemplateId(SubGroup subGroup, Long missionTemplateId); + + // 특정 팀에서 특정 사용자가 이미 미션을 받았는지 확인 + @Query("SELECT COUNT(sgm) > 0 FROM SubGroupMission sgm " + + "JOIN sgm.subGroup sg " + + "JOIN sg.team t " + + "JOIN SubGroupMember sgm2 ON sgm2.subGroup = sg " + + "WHERE t.teamId = :teamId AND sgm2.user.id = :userId") + boolean existsByTeamIdAndUserId(@Param("teamId") Long teamId, @Param("userId") Long userId); + +} diff --git a/src/main/java/com/ccapp/ccgo/mission/service/ScoreboardService.java b/src/main/java/com/ccapp/ccgo/mission/service/ScoreboardService.java new file mode 100644 index 0000000..e700203 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/mission/service/ScoreboardService.java @@ -0,0 +1,85 @@ +package com.ccapp.ccgo.mission.service; + +import com.ccapp.ccgo.matching.domain.entity.SubGroup; +import com.ccapp.ccgo.matching.repository.SubGroupMemberRepository; +import com.ccapp.ccgo.matching.repository.SubGroupRepository; +import com.ccapp.ccgo.mission.dto.ScoreboardResponseDto; +import com.ccapp.ccgo.mission.dto.SubGroupScoreDto; +import com.ccapp.ccgo.mission.entity.SubGroupMission; +import com.ccapp.ccgo.mission.repository.SubGroupMissionRepository; +import com.ccapp.ccgo.team.service.TeamService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ScoreboardService { + + private final SubGroupMemberRepository subGroupMemberRepository; + private final SubGroupRepository subGroupRepository; + private final SubGroupMissionRepository subGroupMissionRepository; + private final TeamService teamService; // 팀 최소 학점 조회용 + + // 현재 유저가 속한 서브그룹 점수 + 멤버 이름 리스트 조회 + public SubGroupScoreDto getMySubGroupScore(Long teamId, Long userId) { + Long subGroupId = subGroupMemberRepository.findSubGroupIdByTeamIdAndUserId(teamId, userId) + .orElseThrow(() -> new IllegalArgumentException("서브그룹을 찾을 수 없습니다.")); + + SubGroup subGroup = subGroupRepository.findById(subGroupId) + .orElseThrow(() -> new IllegalArgumentException("서브그룹을 찾을 수 없습니다.")); + + int score = calculateSubGroupScore(subGroup); + List members = getSubGroupMemberNames(subGroupId); // ✅ 추가 + + return new SubGroupScoreDto(subGroup.getId(), subGroup.getName(), score, members); + } + + // 팀 내 다른 서브그룹 점수 + 멤버 이름 리스트 조회 + public List getOtherSubGroupScores(Long teamId, Long excludeSubGroupId) { + List subGroups = subGroupRepository.findByTeam_TeamId(teamId); + + return subGroups.stream() + .filter(sg -> !sg.getId().equals(excludeSubGroupId)) + .map(sg -> { + int score = calculateSubGroupScore(sg); + List members = getSubGroupMemberNames(sg.getId()); // ✅ 추가 + return new SubGroupScoreDto(sg.getId(), sg.getName(), score, members); + }) + .collect(Collectors.toList()); + } + + // ✅ 서브그룹 멤버 이름 리스트 조회 + private List getSubGroupMemberNames(Long subGroupId) { + return subGroupMemberRepository.findBySubGroup_Id(subGroupId).stream() + .map(member -> member.getUser().getName()) // User 엔티티에서 이름 가져오기 + .collect(Collectors.toList()); + } + + + // 서브그룹 점수 계산 (완료된 미션 점수 합) + private int calculateSubGroupScore(SubGroup subGroup) { + List missions = subGroupMissionRepository.findBySubGroup(subGroup); + + return missions.stream() + .filter(SubGroupMission::isCompleted) + .mapToInt(m -> m.getMissionTemplate().getScore()) + .sum(); + } + + // 팀 최소 학점 조회 + public int getTeamMinScore(Long teamId) { + return teamService.getMinScore(teamId); + } + + // 최종 스코어보드 반환 + public ScoreboardResponseDto getScoreboard(Long teamId, Long userId) { + int minScore = getTeamMinScore(teamId); + SubGroupScoreDto mySubGroup = getMySubGroupScore(teamId, userId); + List otherSubGroups = getOtherSubGroupScores(teamId, mySubGroup.getSubGroupId()); + + return new ScoreboardResponseDto(minScore, mySubGroup, otherSubGroups); + } +} diff --git a/src/main/java/com/ccapp/ccgo/mission/service/SubGroupMissionService.java b/src/main/java/com/ccapp/ccgo/mission/service/SubGroupMissionService.java new file mode 100644 index 0000000..d559974 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/mission/service/SubGroupMissionService.java @@ -0,0 +1,339 @@ +package com.ccapp.ccgo.mission.service; + +import com.ccapp.ccgo.matching.domain.entity.SubGroup; +import com.ccapp.ccgo.matching.domain.entity.SubGroupMember; +import com.ccapp.ccgo.matching.repository.SubGroupRepository; +import com.ccapp.ccgo.matching.repository.SubGroupMemberRepository; +import com.ccapp.ccgo.mission.dto.ScoreboardResponseDto; +import com.ccapp.ccgo.mission.dto.SubGroupMissionDto; +import com.ccapp.ccgo.mission.dto.SubGroupScoreDto; +import com.ccapp.ccgo.mission.dto.MissionHistoryDto; +import com.ccapp.ccgo.mission.entity.MissionTemplate; +import com.ccapp.ccgo.mission.entity.SubGroupMission; +import com.ccapp.ccgo.mission.entity.MissionHistory; +import com.ccapp.ccgo.mission.repository.MissionTemplateRepository; +import com.ccapp.ccgo.mission.repository.SubGroupMissionRepository; +import com.ccapp.ccgo.mission.repository.MissionHistoryRepository; +import lombok.RequiredArgsConstructor; +import java.util.concurrent.ThreadLocalRandom; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; + +import java.util.Collections; +import java.util.List; +import java.util.ArrayList; +import java.util.Optional; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SubGroupMissionService { + + private final SubGroupRepository subGroupRepository; + private final SubGroupMemberRepository subGroupMemberRepository; + private final MissionTemplateRepository missionTemplateRepository; + private final SubGroupMissionRepository subGroupMissionRepository; + private final MissionHistoryRepository missionHistoryRepository; + + // 서브그룹에 미션 부여 + @Transactional + public void assignMissionsToSubGroup(Long subGroupId) { + SubGroup subGroup = subGroupRepository.findById(subGroupId) + .orElseThrow(() -> new IllegalArgumentException("서브그룹을 찾을 수 없습니다.")); + + if (!subGroupMissionRepository.findBySubGroup(subGroup).isEmpty()) { + throw new IllegalStateException("이미 이 서브그룹에 미션이 부여되어 있습니다."); + } + + // 그룹장 중복 미션 방지: 이미 미션을 받은 사용자가 있는지 체크 + List groupMembers = subGroupMemberRepository.findBySubGroup_Id(subGroupId); + List groupMemberIds = groupMembers.stream() + .map(member -> member.getUser().getId()) + .collect(Collectors.toList()); + + for (Long memberId : groupMemberIds) { + boolean hasExistingMission = subGroupMissionRepository.existsByTeamIdAndUserId( + subGroup.getTeam().getTeamId(), memberId); + if (hasExistingMission) { + throw new IllegalStateException("사용자 ID " + memberId + "가 이미 다른 서브그룹에서 미션을 받았습니다."); + } + } + assignMissionsByScore(subGroup, 1, 6); + assignMissionsByScore(subGroup, 3, 6); + assignMissionsByScore(subGroup, 5, 6); + assignMissionsByScore(subGroup, 10, 6); + } + + private void assignMissionsByScore(SubGroup subGroup, Integer score, int count) { + List missions = missionTemplateRepository.findByScore(score); + if (missions.size() < count) { + throw new IllegalStateException(score + "점 미션이 최소 " + count + "개 이상 필요합니다."); + } + + Collections.shuffle(missions); + List selected = missions.subList(0, count); + + for (MissionTemplate missionTemplate : selected) { + SubGroupMission mission = SubGroupMission.builder() + .subGroup(subGroup) + .missionTemplate(missionTemplate) + .completed(false) + .build(); + subGroupMissionRepository.save(mission); + } + } + + // 미션 완료 처리 + @Transactional + public void completeMission(Long subGroupMissionId) { + SubGroupMission mission = subGroupMissionRepository.findById(subGroupMissionId) + .orElseThrow(() -> new IllegalArgumentException("미션을 찾을 수 없습니다.")); + mission.setCompleted(true); + } + + + + + // 서브그룹 미션 조회 + @Transactional(readOnly = true) + public List getMissions(Long subGroupId) { + SubGroup subGroup = subGroupRepository.findById(subGroupId) + .orElseThrow(() -> new IllegalArgumentException("서브그룹을 찾을 수 없습니다.")); + + List missions = subGroupMissionRepository.findBySubGroup(subGroup); + + return missions.stream() + .map(m -> new SubGroupMissionDto( + m.getId(), + m.getMissionTemplate().getId(), + m.getMissionTemplate().getTitle(), + m.getMissionTemplate().getDescription(), + m.getMissionTemplate().getScore(), + m.isCompleted() + )) + .toList(); + } + + /** + * 미션 새로고침 (완료 안 된 미션 삭제 후 새 할당) + */ + @Transactional + public void refreshSingleMission(Long subGroupId, Long subGroupMissionId, Integer score) { + log.info("[Mission] 미션 새로고침 시작 | subGroupId: {}, missionId: {}, score: {}", subGroupId, subGroupMissionId, score); + + // 1. 서브그룹 조회 + SubGroup subGroup = subGroupRepository.findById(subGroupId) + .orElseThrow(() -> { + log.error("[Mission] 서브그룹을 찾을 수 없음 | subGroupId: {}", subGroupId); + return new IllegalArgumentException("서브그룹을 찾을 수 없습니다."); + }); + + // 2. 교체할 기존 미션 조회 + SubGroupMission oldMission = subGroupMissionRepository.findById(subGroupMissionId) + .orElseThrow(() -> { + log.error("[Mission] 교체할 미션을 찾을 수 없음 | missionId: {}", subGroupMissionId); + return new IllegalArgumentException("교체할 미션을 찾을 수 없습니다."); + }); + + if (!oldMission.getSubGroup().getId().equals(subGroupId)) { + log.error("[Mission] 해당 미션이 서브그룹에 속하지 않음 | subGroupId: {}, missionSubGroupId: {}", subGroupId, oldMission.getSubGroup().getId()); + throw new IllegalArgumentException("해당 미션이 서브그룹에 속하지 않습니다."); + } + + // 3. 현재 서브그룹에 할당된 동일 학점 미션 ID + List assignedMissionTemplateIds = subGroupMissionRepository.findBySubGroup(subGroup).stream() + .filter(m -> m.getMissionTemplate().getScore().equals(score)) + .map(m -> m.getMissionTemplate().getId()) + .toList(); + + log.debug("[Mission] 현재 서브그룹에 할당된 동일 학점 미션 ID 목록: {}", assignedMissionTemplateIds); + + // 4. 교체 후보 미션 (현재 미션 제외) - 가변 리스트로 변환 + List candidates = missionTemplateRepository.findByScore(score).stream() + .filter(mt -> !assignedMissionTemplateIds.contains(mt.getId())) // 이미 할당된 것 제외 + .filter(mt -> !mt.getId().equals(oldMission.getMissionTemplate().getId())) // 기존 미션 제외 + .collect(Collectors.toList()); + + log.debug("[Mission] 교체 후보 미션 수: {}", candidates.size()); + + if (candidates.isEmpty()) { + throw new IllegalStateException("교체 가능한 미션이 없습니다."); + } + + // 5. 랜덤으로 새로운 미션 선택 + int randomIndex = ThreadLocalRandom.current().nextInt(candidates.size()); + MissionTemplate newMissionTemplate = candidates.get(randomIndex); + + // 6. 교체 처리 + oldMission.setMissionTemplate(newMissionTemplate); + oldMission.setCompleted(false); + + log.info("[Mission] 미션 교체 완료 | oldMissionId: {}, newMissionId: {}", oldMission.getMissionTemplate().getId(), newMissionTemplate.getId()); +} + + + + + //미션 완료 처리 + @Transactional + public void completeMission(Long teamId, Long subGroupId, Long missionTemplateId) { + // 우선 서브그룹 존재 확인 + SubGroup subGroup = subGroupRepository.findById(subGroupId) + .orElseThrow(() -> new IllegalArgumentException("서브그룹을 찾을 수 없습니다.")); + + // subGroup이 teamId에 속하는지 확인 (필요시, SubGroup 엔티티에 teamId 필드가 있다고 가정) + if (!subGroup.getTeam().getTeamId().equals(teamId)) { + throw new IllegalArgumentException("서브그룹이 해당 팀에 속하지 않습니다."); + } + + // 해당 서브그룹 미션 찾기 (missionTemplateId 기준) + SubGroupMission mission = subGroupMissionRepository.findBySubGroupAndMissionTemplateId(subGroup, missionTemplateId) + .orElseThrow(() -> new IllegalArgumentException("해당 미션이 서브그룹에 존재하지 않습니다.")); + + // 완료 처리 + mission.setCompleted(true); + + // 미션 히스토리에 저장 + saveMissionHistory(subGroup, mission.getMissionTemplate()); + } + + // 미션 히스토리 저장 + private void saveMissionHistory(SubGroup subGroup, MissionTemplate missionTemplate) { + // 이미 같은 서브그룹에서 같은 미션의 히스토리가 있는지 확인 + boolean exists = missionHistoryRepository.existsBySubGroup_IdAndMissionTemplate_Id(subGroup.getId(), missionTemplate.getId()); + + if (!exists) { + // 서브그룹의 첫 번째 멤버만 대표로 히스토리 저장 + List members = subGroupMemberRepository.findBySubGroup_Id(subGroup.getId()); + if (!members.isEmpty()) { + SubGroupMember representativeMember = members.get(0); + + MissionHistory history = MissionHistory.builder() + .subGroup(subGroup) + .team(subGroup.getTeam()) + .user(representativeMember.getUser()) + .missionTemplate(missionTemplate) + .completedAt(LocalDateTime.now()) + .build(); + + missionHistoryRepository.save(history); + } + } + } + + // 서브그룹의 미션 히스토리 조회 + @Transactional(readOnly = true) + public List getMissionHistoryBySubGroup(Long subGroupId) { + List histories = missionHistoryRepository.findBySubGroup_IdOrderByCompletedAtDesc(subGroupId); + + return histories.stream() + .map(this::convertToDto) + .collect(Collectors.toList()); + } + + // 사용자의 미션 히스토리 조회 (팀별) + @Transactional(readOnly = true) + public List getMissionHistoryByUser(Long userId, Long teamId) { + try { + List histories = missionHistoryRepository.findByUser_IdAndTeam_TeamIdOrderByCompletedAtDesc(userId, teamId); + + return histories.stream() + .map(this::convertToDto) + .collect(Collectors.toList()); + } catch (Exception e) { + // 테이블이 존재하지 않는 경우 등 오류 발생 시 빈 리스트 반환 + System.err.println("미션 히스토리 조회 중 오류 발생: " + e.getMessage()); + return new ArrayList<>(); + } + } + + // 팀의 미션 히스토리 조회 + @Transactional(readOnly = true) + public List getMissionHistoryByTeam(Long teamId) { + List histories = missionHistoryRepository.findByTeamIdOrderByCompletedAtDesc(teamId); + + // 중복 제거: 같은 서브그룹의 같은 미션은 하나만 유지 + Map uniqueMissions = new HashMap<>(); + + for (MissionHistory history : histories) { + String key = history.getSubGroup().getId() + "_" + history.getMissionTemplate().getId(); + if (!uniqueMissions.containsKey(key)) { + uniqueMissions.put(key, history); + } + } + + List uniqueHistories = new ArrayList<>(uniqueMissions.values()); + + // 완료 시간 기준으로 정렬 + uniqueHistories.sort((h1, h2) -> h2.getCompletedAt().compareTo(h1.getCompletedAt())); + + return uniqueHistories.stream() + .map(this::convertToDto) + .collect(Collectors.toList()); + } + + // MissionHistory를 DTO로 변환 + private MissionHistoryDto convertToDto(MissionHistory history) { + // 미션을 완료한 사용자와 매칭된 상대방들의 이름 조회 + List matchedNames = getMatchedUserNames(history.getUser().getId(), history.getTeam().getTeamId()); + + return MissionHistoryDto.builder() + .id(history.getId()) + .subGroupId(history.getSubGroup().getId()) + .subGroupName(history.getSubGroup().getName()) + .teamId(history.getTeam().getTeamId()) + .teamName(history.getTeam().getTeamName()) + .userId(history.getUser().getId()) + .userName(history.getUser().getName()) + .matchedNames(matchedNames) // 매칭된 상대방들의 이름 추가 + .missionTemplateId(history.getMissionTemplate().getId()) + .missionTitle(history.getMissionTemplate().getTitle()) + .missionDescription(history.getMissionTemplate().getDescription()) + .missionScore(history.getMissionTemplate().getScore()) + .completedAt(history.getCompletedAt()) + .createdAt(history.getCreatedAt()) + .build(); + } + + // 사용자와 매칭된 상대방들의 이름 조회 + private List getMatchedUserNames(Long userId, Long teamId) { + try { + System.out.println("[getMatchedUserNames] userId: " + userId + ", teamId: " + teamId); + + // 사용자가 속한 서브그룹 조회 + Optional subGroupIdOpt = subGroupMemberRepository.findSubGroupIdByTeamIdAndUserId(teamId, userId); + if (subGroupIdOpt.isEmpty()) { + System.out.println("[getMatchedUserNames] 서브그룹을 찾을 수 없음"); + return new ArrayList<>(); + } + + Long subGroupId = subGroupIdOpt.get(); + System.out.println("[getMatchedUserNames] subGroupId: " + subGroupId); + + // 같은 서브그룹의 다른 멤버들 조회 (본인 제외) + List members = subGroupMemberRepository.findBySubGroup_Id(subGroupId); + System.out.println("[getMatchedUserNames] 전체 멤버 수: " + members.size()); + + List matchedNames = members.stream() + .map(member -> member.getUser().getName()) + .filter(name -> !name.equals(members.stream() + .filter(m -> m.getUser().getId().equals(userId)) + .findFirst() + .map(m -> m.getUser().getName()) + .orElse(""))) + .collect(Collectors.toList()); + + System.out.println("[getMatchedUserNames] 매칭된 이름들: " + matchedNames); + return matchedNames; + } catch (Exception e) { + System.err.println("[getMatchedUserNames] 오류 발생: " + e.getMessage()); + e.printStackTrace(); + return new ArrayList<>(); + } + } +} diff --git a/src/main/java/com/ccapp/ccgo/question/dto/AnswerRequestDto.java b/src/main/java/com/ccapp/ccgo/question/dto/AnswerRequestDto.java new file mode 100644 index 0000000..288d628 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/question/dto/AnswerRequestDto.java @@ -0,0 +1,15 @@ +package com.ccapp.ccgo.question.dto; + +import lombok.*; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AnswerRequestDto { + private String mbti; + private Long teamId; + private List answers; +} diff --git a/src/main/java/com/ccapp/ccgo/question/dto/QuestionRequestDto.java b/src/main/java/com/ccapp/ccgo/question/dto/QuestionRequestDto.java new file mode 100644 index 0000000..69ab7d3 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/question/dto/QuestionRequestDto.java @@ -0,0 +1,14 @@ +package com.ccapp.ccgo.question.dto; + +import lombok.*; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class QuestionRequestDto { + private Long teamId; + private List questions; +} diff --git a/src/main/java/com/ccapp/ccgo/question/dto/QuestionResponseDto.java b/src/main/java/com/ccapp/ccgo/question/dto/QuestionResponseDto.java new file mode 100644 index 0000000..05b04e5 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/question/dto/QuestionResponseDto.java @@ -0,0 +1,12 @@ +package com.ccapp.ccgo.question.dto; + +import lombok.*; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class QuestionResponseDto { + private Long id; + private String text; +} diff --git a/src/main/java/com/ccapp/ccgo/question/dto/QuestionUpdateDto.java b/src/main/java/com/ccapp/ccgo/question/dto/QuestionUpdateDto.java new file mode 100644 index 0000000..8a2364e --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/question/dto/QuestionUpdateDto.java @@ -0,0 +1,11 @@ +package com.ccapp.ccgo.question.dto; + +import lombok.*; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class QuestionUpdateDto { + private String text; +} diff --git a/src/main/java/com/ccapp/ccgo/question/dto/SingleAnswerDto.java b/src/main/java/com/ccapp/ccgo/question/dto/SingleAnswerDto.java new file mode 100644 index 0000000..25a4003 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/question/dto/SingleAnswerDto.java @@ -0,0 +1,12 @@ +package com.ccapp.ccgo.question.dto; + +import lombok.*; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SingleAnswerDto { + private Long questionId; + private Integer score; +} diff --git a/src/main/java/com/ccapp/ccgo/question/dto/SurveyCompleteRequest.java b/src/main/java/com/ccapp/ccgo/question/dto/SurveyCompleteRequest.java new file mode 100644 index 0000000..3b7656f --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/question/dto/SurveyCompleteRequest.java @@ -0,0 +1,8 @@ +package com.ccapp.ccgo.question.dto; + +import lombok.Getter; + +@Getter +public class SurveyCompleteRequest { + private Long teamId; +} diff --git a/src/main/java/com/ccapp/ccgo/question/repository/AnswerRepository.java b/src/main/java/com/ccapp/ccgo/question/repository/AnswerRepository.java new file mode 100644 index 0000000..914fa92 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/question/repository/AnswerRepository.java @@ -0,0 +1,15 @@ +package com.ccapp.ccgo.question.repository; + +import com.ccapp.ccgo.matching.domain.entity.Answer; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.Optional; + +public interface AnswerRepository extends JpaRepository { + + List findByUser_Id(Long userId); + + List findByQuestionId(Long questionId); + + Optional findByUser_IdAndQuestionId(Long id, Long id1); +} diff --git a/src/main/java/com/ccapp/ccgo/question/repository/QuestionRepository.java b/src/main/java/com/ccapp/ccgo/question/repository/QuestionRepository.java new file mode 100644 index 0000000..3e845c2 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/question/repository/QuestionRepository.java @@ -0,0 +1,10 @@ +package com.ccapp.ccgo.question.repository; + +import com.ccapp.ccgo.matching.domain.entity.Question; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface QuestionRepository extends JpaRepository { + + List findByTeam_TeamId(Long teamId); +} diff --git a/src/main/java/com/ccapp/ccgo/repository/TeamMemberRepository.java b/src/main/java/com/ccapp/ccgo/repository/TeamMemberRepository.java deleted file mode 100644 index 4cae0e3..0000000 --- a/src/main/java/com/ccapp/ccgo/repository/TeamMemberRepository.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.ccapp.ccgo.repository; - -import com.ccapp.ccgo.team.TeamMember; -import com.ccapp.ccgo.user.User; -import com.ccapp.ccgo.team.Team; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; -import java.util.Optional; - -/** - * TeamMember 엔티티용 Repository - */ -public interface TeamMemberRepository extends JpaRepository { - - // 한 유저가 이미 어떤 팀에 속해있는지 검사 - boolean existsByUser(User user); - - // 현재 소속 중인 팀 찾기 (Soft Delete 고려) - Optional findByUserAndIsActiveTrue(User user); - - - // 팀별 멤버 목록 - List findAllByTeamAndIsActiveTrue(Team team); -} diff --git a/src/main/java/com/ccapp/ccgo/service/InviteCodeService.java b/src/main/java/com/ccapp/ccgo/service/InviteCodeService.java deleted file mode 100644 index acc94e9..0000000 --- a/src/main/java/com/ccapp/ccgo/service/InviteCodeService.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.ccapp.ccgo.service; - -import com.ccapp.ccgo.exception.CustomException; -import com.ccapp.ccgo.repository.InviteCodeRepository; -import com.ccapp.ccgo.repository.TeamMemberRepository; -import com.ccapp.ccgo.team.InviteCode; -import com.ccapp.ccgo.team.Team; -import com.ccapp.ccgo.team.TeamMember; -import com.ccapp.ccgo.user.User; -import org.springframework.http.HttpStatus; -import org.springframework.transaction.annotation.Transactional; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import java.time.LocalDateTime; - -import java.security.SecureRandom; - -@Service -@RequiredArgsConstructor -public class InviteCodeService { - - private final InviteCodeRepository inviteCodeRepository; - private final TeamMemberRepository teamMemberRepository; - - private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - private static final int CODE_LENGTH = 8; - private static final SecureRandom random = new SecureRandom(); - - //코드 생성기 - private String generateRandomCode() { - StringBuilder sb = new StringBuilder(CODE_LENGTH); - for (int i = 0; i < CODE_LENGTH; i++) { - sb.append(CODE_CHARS.charAt(random.nextInt(CODE_CHARS.length()))); - } - return sb.toString(); - } - - - - //코드로팀가입 - @Transactional - public void joinTeamByInviteCode(User user, String inviteCode) { - InviteCode code = inviteCodeRepository.findById(inviteCode) - .orElseThrow(() -> new CustomException("초대코드가 유효하지 않습니다.", HttpStatus.BAD_REQUEST)); - - if (code.isExpired()) { - inviteCodeRepository.delete(code); // 만료된 코드는 삭제 - throw new CustomException("초대코드가 만료되었습니다.", HttpStatus.BAD_REQUEST); - } - - if (teamMemberRepository.findByUserAndIsActiveTrue(user).isPresent()) { - throw new CustomException("이미 팀에 소속되어 있습니다.", HttpStatus.BAD_REQUEST); - } - - TeamMember newMember = TeamMember.builder() - .user(user) - .team(code.getTeam()) - .role("TEAM_MEMBER") - .isActive(true) - .joinedAt(LocalDateTime.now()) - .build(); - - teamMemberRepository.save(newMember); - } - - //보안상 냅둬 - @Transactional - public InviteCode createInviteCode(User user) { - var teamMember = teamMemberRepository.findByUserAndIsActiveTrue(user) - .orElseThrow(() -> new CustomException("팀 소속이 아닙니다.", HttpStatus.BAD_REQUEST)); - if (!"TEAM_LEADER".equals(teamMember.getRole())) { - throw new CustomException("팀장만 초대코드를 생성할 수 있습니다.", HttpStatus.FORBIDDEN); - } - - Team team = teamMember.getTeam(); - - String code; - do { - code = generateRandomCode(); - } while (inviteCodeRepository.existsById(code)); - - InviteCode inviteCode = InviteCode.builder() - .code(code) - .team(team) - .build(); - - return inviteCodeRepository.save(inviteCode); - } - - // -} diff --git a/src/main/java/com/ccapp/ccgo/service/UserService.java b/src/main/java/com/ccapp/ccgo/service/UserService.java deleted file mode 100644 index 49e6059..0000000 --- a/src/main/java/com/ccapp/ccgo/service/UserService.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.ccapp.ccgo.service; - -import com.ccapp.ccgo.dto.UserRequestDto; -import com.ccapp.ccgo.dto.UserResponseDto; -import com.ccapp.ccgo.dto.UserMapper; -import com.ccapp.ccgo.exception.CustomException; -import com.ccapp.ccgo.jwt.JwtProvider; -import com.ccapp.ccgo.repository.UserRepository; -import com.ccapp.ccgo.user.User; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -public class UserService { - - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - private final JwtProvider jwtProvider; - private final AuthenticationManager authenticationManager; - - // 1. 회원가입 - public UserResponseDto register(UserRequestDto dto) { - if (userRepository.findByEmail(dto.getEmail()).isPresent()) { - throw new CustomException("이미 가입된 이메일입니다.", HttpStatus.CONFLICT); - } - - String encodedPassword = passwordEncoder.encode(dto.getPassword()); - User user = UserMapper.toEntity(dto, encodedPassword); - userRepository.save(user); - - return UserMapper.toDto(user); - } - - // 2. 로그인: JWT 토큰 생성 반환 - public String loginAndGetToken(String email, String password) { - Authentication authentication = authenticationManager.authenticate( - new UsernamePasswordAuthenticationToken(email, password) - ); - - return jwtProvider.createAccessToken(authentication); - } - - // 3. 전체 사용자 조회 - public List getAllUsers() { - return userRepository.findAll().stream() - .map(UserMapper::toDto) - .collect(Collectors.toList()); - } - - // 4. 사용자 상세 조회 - public UserResponseDto getUserById(Long id) { - User user = userRepository.findById(id) - .orElseThrow(() -> new CustomException("해당 ID의 사용자가 없습니다.", HttpStatus.NOT_FOUND)); - return UserMapper.toDto(user); - } - - // 5. 사용자 정보 수정 - public UserResponseDto updateUser(Long id, UserRequestDto dto) { - User user = userRepository.findById(id) - .orElseThrow(() -> new CustomException("해당 ID의 사용자가 없습니다.", HttpStatus.NOT_FOUND)); - - user.setEmail(dto.getEmail()); - if (dto.getPassword() != null && !dto.getPassword().isEmpty()) { - user.setPassword(passwordEncoder.encode(dto.getPassword())); - } - user.setName(dto.getName()); - user.setRole(dto.getRole()); - user.setGender(dto.getGender()); - user.setBirthdate(dto.getBirthdate()); - - userRepository.save(user); - return UserMapper.toDto(user); - } - - // 6. 사용자 삭제 - public void deleteUser(Long id) { - if (!userRepository.existsById(id)) { - throw new CustomException("삭제할 사용자가 존재하지 않습니다.", HttpStatus.NOT_FOUND); - } - userRepository.deleteById(id); - } -} diff --git a/src/main/java/com/ccapp/ccgo/team/controller/TeamController.java b/src/main/java/com/ccapp/ccgo/team/controller/TeamController.java new file mode 100644 index 0000000..9751a8a --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/team/controller/TeamController.java @@ -0,0 +1,103 @@ +package com.ccapp.ccgo.team.controller; +import com.ccapp.ccgo.auth.jwt.LoginUserDetails; + + +import com.ccapp.ccgo.question.dto.SurveyCompleteRequest; +import com.ccapp.ccgo.team.dto.SetMinScoreRequest; +import com.ccapp.ccgo.team.dto.SurveyStatusDto; +import com.ccapp.ccgo.team.dto.TeamMatchingStatusDto; +import com.ccapp.ccgo.team.entity.Team; +import com.ccapp.ccgo.team.service.TeamMemberService; +import com.ccapp.ccgo.team.dto.TeamResponseDto; +import com.ccapp.ccgo.team.entity.TeamMember; +import com.ccapp.ccgo.team.repository.TeamMemberRepository; +import com.ccapp.ccgo.team.service.TeamService; +import com.ccapp.ccgo.user.entity.User; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/team") +public class TeamController { + + private final TeamMemberService teamMemberService; + private final TeamMemberRepository teamMemberRepository; + private final TeamService teamService; + + @GetMapping("/mine") + public ResponseEntity> getMyTeams( + @AuthenticationPrincipal LoginUserDetails userDetails) { + + User user = userDetails.getUser(); + + List result = teamMemberRepository + .findAllByUserAndIsActiveTrue(user) + .stream() + .map(tm -> new TeamResponseDto( + tm.getTeam().getTeamId(), + tm.getTeam().getTeamName(), + tm.getRole() // 팀 멤버 역할 필드 추가 + )) + .collect(Collectors.toList()); + + return ResponseEntity.ok(result); + } + + @PostMapping("/survey/complete") + public ResponseEntity completeSurvey(@RequestBody SurveyCompleteRequest request, + @AuthenticationPrincipal LoginUserDetails loginUserDetails) { + System.out.print("프로그램ㅅ ㅣ작"); + User currentUser = loginUserDetails.getUser(); + System.out.print("1"); + teamMemberService.markSurveyCompleted(currentUser.getId(), request.getTeamId()); + System.out.print("2"); + return ResponseEntity.ok().build(); + } + + + @GetMapping("/{teamId}/survey-status") + public ResponseEntity getSurveyStatus( + @PathVariable Long teamId, + @AuthenticationPrincipal LoginUserDetails loginUserDetails) { + + User currentUser = loginUserDetails.getUser(); + + boolean isCompleted = teamMemberService.isSurveyCompleted(currentUser.getId(), teamId); + + return ResponseEntity.ok().body(Map.of("issurveycompleted", isCompleted)); //map을 써서 보낼지 dto로 보낼지 + //gpt왈 map이 더 빠르고 간단함 ㅇㅇ 흠.... + } + + @GetMapping("/{teamId}/survey-status/all") + public ResponseEntity> getAllSurveyStatus(@PathVariable Long teamId) { + List result = teamMemberService.getAllSurveyStatus(teamId); + return ResponseEntity.ok(result); + } + + @GetMapping("/{teamId}") + public ResponseEntity getTeamInfo(@PathVariable Long teamId) { + return ResponseEntity.ok(teamMemberService.getTeamInfo(teamId)); + } + + + + //최소 학점 설정하는부분 + @PostMapping("/{teamId}/min-credit") + public ResponseEntity setMinCredit( + @PathVariable Long teamId, + @RequestBody SetMinScoreRequest request, + @AuthenticationPrincipal LoginUserDetails loginUserDetails) { + + Long userId = loginUserDetails.getUser().getId(); + teamService.updateMinScore(teamId, userId, request.getMinScore()); + return ResponseEntity.ok().build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/ccapp/ccgo/team/dto/SetMinScoreRequest.java b/src/main/java/com/ccapp/ccgo/team/dto/SetMinScoreRequest.java new file mode 100644 index 0000000..f225c9e --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/team/dto/SetMinScoreRequest.java @@ -0,0 +1,10 @@ +package com.ccapp.ccgo.team.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class SetMinScoreRequest { + private Integer minScore; +} diff --git a/src/main/java/com/ccapp/ccgo/team/dto/SurveyStatusDto.java b/src/main/java/com/ccapp/ccgo/team/dto/SurveyStatusDto.java new file mode 100644 index 0000000..b578370 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/team/dto/SurveyStatusDto.java @@ -0,0 +1,13 @@ +package com.ccapp.ccgo.team.dto; + +import com.ccapp.ccgo.common.Role; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class SurveyStatusDto { + private Long userId; + private String userName; + private boolean isSurveyCompleted; +} diff --git a/src/main/java/com/ccapp/ccgo/team/dto/TeamMatchingStatusDto.java b/src/main/java/com/ccapp/ccgo/team/dto/TeamMatchingStatusDto.java new file mode 100644 index 0000000..ab75ec7 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/team/dto/TeamMatchingStatusDto.java @@ -0,0 +1,12 @@ +package com.ccapp.ccgo.team.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class TeamMatchingStatusDto { + private Long teamId; + private String teamName; + private boolean matchingStarted; +} diff --git a/src/main/java/com/ccapp/ccgo/dto/TeamMemberResponseDto.java b/src/main/java/com/ccapp/ccgo/team/dto/TeamMemberResponseDto.java similarity index 84% rename from src/main/java/com/ccapp/ccgo/dto/TeamMemberResponseDto.java rename to src/main/java/com/ccapp/ccgo/team/dto/TeamMemberResponseDto.java index 4f3ff66..22fdf3c 100644 --- a/src/main/java/com/ccapp/ccgo/dto/TeamMemberResponseDto.java +++ b/src/main/java/com/ccapp/ccgo/team/dto/TeamMemberResponseDto.java @@ -1,4 +1,4 @@ -package com.ccapp.ccgo.dto; +package com.ccapp.ccgo.team.dto; import lombok.Builder; import lombok.Getter; diff --git a/src/main/java/com/ccapp/ccgo/team/dto/TeamRequestDto.java b/src/main/java/com/ccapp/ccgo/team/dto/TeamRequestDto.java new file mode 100644 index 0000000..060044f --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/team/dto/TeamRequestDto.java @@ -0,0 +1,10 @@ +package com.ccapp.ccgo.team.dto; + +import lombok.*; + +@Getter +@Setter +@Builder +public class TeamRequestDto { + private String teamName; +} diff --git a/src/main/java/com/ccapp/ccgo/team/dto/TeamResponseDto.java b/src/main/java/com/ccapp/ccgo/team/dto/TeamResponseDto.java new file mode 100644 index 0000000..00a3493 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/team/dto/TeamResponseDto.java @@ -0,0 +1,16 @@ +package com.ccapp.ccgo.team.dto; + +import com.ccapp.ccgo.common.Role; +import lombok.Builder; +import lombok.Getter; +import lombok.AllArgsConstructor; + +@Getter +@AllArgsConstructor +@Builder +public class TeamResponseDto { + private Long teamId; + private String teamName; + private Role role; + +} diff --git a/src/main/java/com/ccapp/ccgo/team/InviteCode.java b/src/main/java/com/ccapp/ccgo/team/entity/InviteCode.java similarity index 86% rename from src/main/java/com/ccapp/ccgo/team/InviteCode.java rename to src/main/java/com/ccapp/ccgo/team/entity/InviteCode.java index 570ba2c..1f417ae 100644 --- a/src/main/java/com/ccapp/ccgo/team/InviteCode.java +++ b/src/main/java/com/ccapp/ccgo/team/entity/InviteCode.java @@ -1,4 +1,4 @@ -package com.ccapp.ccgo.team; +package com.ccapp.ccgo.team.entity; import jakarta.persistence.*; import lombok.*; @@ -20,9 +20,13 @@ @Table(name = "invite_code") public class InviteCode { + // PK - 초대 코드 (랜덤 문자열, 8자리) @Id - @Column(length = 8) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 8, nullable = false, unique = true) private String code; // FK - 팀 ID diff --git a/src/main/java/com/ccapp/ccgo/team/Team.java b/src/main/java/com/ccapp/ccgo/team/entity/Team.java similarity index 60% rename from src/main/java/com/ccapp/ccgo/team/Team.java rename to src/main/java/com/ccapp/ccgo/team/entity/Team.java index e7543c6..a327c30 100644 --- a/src/main/java/com/ccapp/ccgo/team/Team.java +++ b/src/main/java/com/ccapp/ccgo/team/entity/Team.java @@ -1,6 +1,7 @@ -package com.ccapp.ccgo.team; +package com.ccapp.ccgo.team.entity; import jakarta.persistence.*; +import jakarta.persistence.criteria.CriteriaBuilder; import lombok.*; import java.time.LocalDateTime; @@ -17,12 +18,8 @@ @NoArgsConstructor @AllArgsConstructor @Builder -@Table( - name = "team", - uniqueConstraints = { - @UniqueConstraint(columnNames = "createdBy") - } -) +@Table(name = "team") + public class Team { // PK - 팀 ID @@ -34,12 +31,20 @@ public class Team { @Column(nullable = false) private String teamName; - // 팀장 유저 ID (User.id 참조), UNIQUE - @Column(nullable = false, unique = true, name="created_by") + // 팀장 유저 ID (User.id 참조) + @Column(nullable = false, name = "created_by") private Long createdBy; //생성된 시간 @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; + // 매칭 시작 여부 추가 + @Column(name = "matching_started", nullable = false) + @Builder.Default + private boolean matchingStarted = false; + + @Column(name = "min_score") + private Integer minScore; + } diff --git a/src/main/java/com/ccapp/ccgo/team/TeamMember.java b/src/main/java/com/ccapp/ccgo/team/entity/TeamMember.java similarity index 65% rename from src/main/java/com/ccapp/ccgo/team/TeamMember.java rename to src/main/java/com/ccapp/ccgo/team/entity/TeamMember.java index 9d0e65d..f40a7bb 100644 --- a/src/main/java/com/ccapp/ccgo/team/TeamMember.java +++ b/src/main/java/com/ccapp/ccgo/team/entity/TeamMember.java @@ -1,6 +1,7 @@ -package com.ccapp.ccgo.team; +package com.ccapp.ccgo.team.entity; -import com.ccapp.ccgo.user.User; +import com.ccapp.ccgo.common.Role; +import com.ccapp.ccgo.user.entity.User; import jakarta.persistence.*; import lombok.*; @@ -22,9 +23,10 @@ @Table( name = "team_member", uniqueConstraints = { - @UniqueConstraint(columnNames = "user_id") + @UniqueConstraint(columnNames = {"user_id", "team_id"}) // ✅ 한 유저가 한 팀에만 한 번 참여 가능하도록 제한 } ) + public class TeamMember { // PK - 팀 멤버 ID @@ -34,7 +36,7 @@ public class TeamMember { // FK - 팀 ID @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "team_id", nullable = false) + @JoinColumn(name = "team_id") private Team team; // FK - 유저 ID @@ -43,8 +45,9 @@ public class TeamMember { private User user; // 팀 내 역할 ("TEAM_LEADER" or "TEAM_MEMBER") + @Enumerated(EnumType.STRING) @Column(nullable = false) - private String role; + private Role role; // 팀 가입 일시 private LocalDateTime joinedAt; @@ -53,4 +56,14 @@ public class TeamMember { @Column(nullable = false) private boolean isActive; + //설문조사를 햇슴까 + @Column(nullable = false) + @Builder.Default + private boolean isSurveyCompleted = false; // 설문조사 기본값 false + + // MBTI 필드 + @Column(name = "mbti") + private String mbti; + + } diff --git a/src/main/java/com/ccapp/ccgo/team/repository/TeamMemberRepository.java b/src/main/java/com/ccapp/ccgo/team/repository/TeamMemberRepository.java new file mode 100644 index 0000000..63a4c5a --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/team/repository/TeamMemberRepository.java @@ -0,0 +1,44 @@ +package com.ccapp.ccgo.team.repository; + +import com.ccapp.ccgo.team.entity.TeamMember; +import com.ccapp.ccgo.user.entity.User; +import com.ccapp.ccgo.team.entity.Team; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +/** + * TeamMember 엔티티용 Repository + */ +public interface TeamMemberRepository extends JpaRepository { + + Optional findByUser_IdAndTeam_TeamId(Long userId, Long teamId); + + + // 한 유저가 이미 어떤 팀에 속해있는지 검사 + boolean existsByUser(User user); + + // 현재 소속 중인 팀 찾기 (Soft Delete 고려) + List findByUserAndIsActiveTrue(User user); + + //teammember에서 유저 조회 + Optional findByUser(User user); + + // 유저(user)에 대해 isActive가 true인 TeamMember 리스트 반환 + List findAllByUserAndIsActiveTrue(User user); + + + // 팀별 멤버 목록 + List findAllByTeamAndIsActiveTrue(Team team); + + // 이미 특정 유저가 특정 팀 인지 확인 + boolean existsByUserAndTeam(User user, Team team); + + List findByTeam_TeamIdAndIsActiveTrue(Long teamId); + + List findByUserAndTeamAndIsActiveTrue(User user, Team team); + + boolean existsByUser_IdAndTeam_TeamIdAndIsActiveTrue(Long userId, Long teamId); + +} diff --git a/src/main/java/com/ccapp/ccgo/repository/TeamRepository.java b/src/main/java/com/ccapp/ccgo/team/repository/TeamRepository.java similarity index 64% rename from src/main/java/com/ccapp/ccgo/repository/TeamRepository.java rename to src/main/java/com/ccapp/ccgo/team/repository/TeamRepository.java index b82f481..6863b95 100644 --- a/src/main/java/com/ccapp/ccgo/repository/TeamRepository.java +++ b/src/main/java/com/ccapp/ccgo/team/repository/TeamRepository.java @@ -1,8 +1,9 @@ -package com.ccapp.ccgo.repository; +package com.ccapp.ccgo.team.repository; -import com.ccapp.ccgo.team.Team; +import com.ccapp.ccgo.team.entity.Team; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; /** @@ -10,12 +11,12 @@ */ public interface TeamRepository extends JpaRepository { - // 특정 유저가 이미 팀을 만들었는지 확인 - boolean existsByCreatedBy(Long createdBy); - // 팀 ID로 조회 Optional findByTeamId(Long teamId); + // 유저 ID (팀장)로 팀 찾기 + List findAllByCreatedBy(Long createdBy); + // 팀 이름 중복 방지 boolean existsByTeamName(String teamName); diff --git a/src/main/java/com/ccapp/ccgo/team/service/TeamMemberService.java b/src/main/java/com/ccapp/ccgo/team/service/TeamMemberService.java new file mode 100644 index 0000000..889b86f --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/team/service/TeamMemberService.java @@ -0,0 +1,65 @@ +package com.ccapp.ccgo.team.service; + + +import com.ccapp.ccgo.team.dto.SurveyStatusDto; +import com.ccapp.ccgo.team.dto.TeamMatchingStatusDto; +import com.ccapp.ccgo.team.dto.TeamResponseDto; +import com.ccapp.ccgo.team.entity.Team; +import com.ccapp.ccgo.team.entity.TeamMember; +import com.ccapp.ccgo.team.repository.TeamMemberRepository; +import com.ccapp.ccgo.team.repository.TeamRepository; +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class TeamMemberService { + + private final TeamMemberRepository teamMemberRepository; + private final TeamRepository teamRepository; + + @Transactional + public void markSurveyCompleted(Long userId, Long teamId) { + TeamMember teamMember = teamMemberRepository + .findByUser_IdAndTeam_TeamId(userId, teamId) + .orElseThrow(() -> new RuntimeException("팀 멤버를 찾을 수 없습니다.")); + + teamMember.setSurveyCompleted(true); // ← 여기서 DB의 isSurveyCompleted를 true로 변경 + } + + @Transactional + public boolean isSurveyCompleted(Long userId, Long teamId) { + TeamMember teamMember = teamMemberRepository + .findByUser_IdAndTeam_TeamId(userId, teamId) + .orElseThrow(() -> new RuntimeException("팀 멤버를 찾을 수 없습니다.")); + return teamMember.isSurveyCompleted(); // ← DB 필드 반환 + } + + + @Transactional(readOnly = true) + public List getAllSurveyStatus(Long teamId) { + List members = teamMemberRepository.findByTeam_TeamIdAndIsActiveTrue(teamId); + + return members.stream() + .map(member -> new SurveyStatusDto( + member.getUser().getId(), + member.getUser().getName(), + member.isSurveyCompleted() + )) + .collect(Collectors.toList()); + } + + + @Transactional(readOnly = true) + public TeamMatchingStatusDto getTeamInfo(Long teamId) { + Team team = teamRepository.findById(teamId) + .orElseThrow(() -> new IllegalArgumentException("팀을 찾을 수 없습니다.")); + return new TeamMatchingStatusDto(team.getTeamId(), team.getTeamName(), team.isMatchingStarted()); + } + + +} diff --git a/src/main/java/com/ccapp/ccgo/team/service/TeamService.java b/src/main/java/com/ccapp/ccgo/team/service/TeamService.java new file mode 100644 index 0000000..f311392 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/team/service/TeamService.java @@ -0,0 +1,41 @@ +package com.ccapp.ccgo.team.service; + +import com.ccapp.ccgo.team.entity.Team; +import com.ccapp.ccgo.team.repository.TeamRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class TeamService { + + private final TeamRepository teamRepository; + + /** + * 팀 최소 학점 업데이트 + */ + @Transactional + public void updateMinScore(Long teamId, Long userId, Integer minScore) { + Team team = teamRepository.findById(teamId) + .orElseThrow(() -> new IllegalArgumentException("팀을 찾을 수 없습니다.")); + + // 팀장 권한 확인 + if (!team.getCreatedBy().equals(userId)) { + throw new IllegalArgumentException("팀장만 최소 학점을 설정할 수 있습니다."); + } + + team.setMinScore(minScore); + teamRepository.save(team); + } + + /** + * 팀 최소 학점 조회 + */ + @Transactional(readOnly = true) + public Integer getMinScore(Long teamId) { + Team team = teamRepository.findById(teamId) + .orElseThrow(() -> new IllegalArgumentException("팀을 찾을 수 없습니다.")); + return team.getMinScore(); + } +} diff --git a/src/main/java/com/ccapp/ccgo/user/controller/UserController.java b/src/main/java/com/ccapp/ccgo/user/controller/UserController.java new file mode 100644 index 0000000..6d0a339 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/user/controller/UserController.java @@ -0,0 +1,81 @@ +package com.ccapp.ccgo.user.controller; + +import com.ccapp.ccgo.user.service.UserService; +import com.ccapp.ccgo.user.dto.UserRequestDto; +import com.ccapp.ccgo.user.dto.UserResponseDto; +import com.ccapp.ccgo.user.dto.UserUpdateRequestDto; +import com.ccapp.ccgo.user.dto.PasswordChangeRequestDto; +import com.ccapp.ccgo.user.entity.PrivacyAgreement; +import org.springframework.http.HttpStatus; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequestMapping("/api/user") +// @CrossOrigin 제거, SecurityConfig에서 CORS 관리 권장 +public class UserController { + + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + @PostMapping("/register") + public ResponseEntity register(@Valid @RequestBody UserRequestDto userRequestDto) { + log.info("✅ 회원가입 요청 들어옴: {}", userRequestDto); + UserResponseDto saved = userService.register(userRequestDto); + return ResponseEntity.status(HttpStatus.CREATED).body(saved); + } + + // 현재 로그인한 사용자 정보 조회 + @GetMapping("/me") + public ResponseEntity getCurrentUser() { + log.info("✅ 현재 사용자 정보 조회 요청"); + UserResponseDto user = userService.getCurrentUser(); + return ResponseEntity.ok(user); + } + + // 사용자 정보 부분 업데이트 (PATCH) + @PatchMapping("/me") + public ResponseEntity updateCurrentUser(@Valid @RequestBody UserUpdateRequestDto userUpdateRequestDto) { + log.info("✅ 사용자 정보 부분 업데이트 요청: {}", userUpdateRequestDto); + UserResponseDto updated = userService.updateCurrentUser(userUpdateRequestDto); + return ResponseEntity.ok(updated); + } + + // 사용자 정보 전체 업데이트 (PUT) + @PutMapping("/me") + public ResponseEntity updateCurrentUserFull(@Valid @RequestBody UserRequestDto userRequestDto) { + log.info("✅ 사용자 정보 전체 업데이트 요청: {}", userRequestDto); + UserResponseDto updated = userService.updateCurrentUserFull(userRequestDto); + return ResponseEntity.ok(updated); + } + + // 비밀번호 변경 + @PatchMapping("/me/password") + public ResponseEntity changePassword(@Valid @RequestBody PasswordChangeRequestDto passwordChangeRequestDto) { + log.info("✅ 비밀번호 변경 요청"); + userService.changePassword(passwordChangeRequestDto); + return ResponseEntity.ok().build(); + } + + // 개인정보 동의서 조회 (현재 활성화된 버전) + @GetMapping("/privacy-agreement/current") + public ResponseEntity getCurrentPrivacyAgreement() { + log.info("✅ 현재 개인정보 동의서 조회 요청"); + PrivacyAgreement agreement = userService.getCurrentPrivacyAgreement(); + return ResponseEntity.ok(agreement); + } + + // 특정 버전의 개인정보 동의서 조회 + @GetMapping("/privacy-agreement/{version}") + public ResponseEntity getPrivacyAgreementByVersion(@PathVariable String version) { + log.info("✅ 개인정보 동의서 조회 요청 - 버전: {}", version); + PrivacyAgreement agreement = userService.getPrivacyAgreementByVersion(version); + return ResponseEntity.ok(agreement); + } +} diff --git a/src/main/java/com/ccapp/ccgo/user/dto/PasswordChangeRequestDto.java b/src/main/java/com/ccapp/ccgo/user/dto/PasswordChangeRequestDto.java new file mode 100644 index 0000000..7738b62 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/user/dto/PasswordChangeRequestDto.java @@ -0,0 +1,16 @@ +package com.ccapp.ccgo.user.dto; + +import lombok.Getter; +import lombok.Setter; +import jakarta.validation.constraints.*; + +@Getter +@Setter +public class PasswordChangeRequestDto { + @NotBlank(message = "현재 비밀번호는 필수입니다.") + private String currentPassword; + + @NotBlank(message = "새 비밀번호는 필수입니다.") + @Size(min = 8, message = "새 비밀번호는 최소 8자 이상이어야 합니다.") + private String newPassword; +} diff --git a/src/main/java/com/ccapp/ccgo/dto/UserRequestDto.java b/src/main/java/com/ccapp/ccgo/user/dto/UserRequestDto.java similarity index 58% rename from src/main/java/com/ccapp/ccgo/dto/UserRequestDto.java rename to src/main/java/com/ccapp/ccgo/user/dto/UserRequestDto.java index 8daccab..e495776 100644 --- a/src/main/java/com/ccapp/ccgo/dto/UserRequestDto.java +++ b/src/main/java/com/ccapp/ccgo/user/dto/UserRequestDto.java @@ -1,4 +1,4 @@ -package com.ccapp.ccgo.dto; +package com.ccapp.ccgo.user.dto; import lombok.Getter; import java.time.LocalDate; @@ -16,5 +16,11 @@ public class UserRequestDto { private String name; private String gender; private LocalDate birthdate; - private String role; + + // 개인정보 동의 관련 필드들 + private String privacyAgreementVersion; + private boolean privacyAgreed; + private String privacyAgreedAt; + private String privacyAgreedMethod; + private String privacyAgreedEnvironment; } diff --git a/src/main/java/com/ccapp/ccgo/dto/UserResponseDto.java b/src/main/java/com/ccapp/ccgo/user/dto/UserResponseDto.java similarity index 51% rename from src/main/java/com/ccapp/ccgo/dto/UserResponseDto.java rename to src/main/java/com/ccapp/ccgo/user/dto/UserResponseDto.java index 41d8190..896a1b3 100644 --- a/src/main/java/com/ccapp/ccgo/dto/UserResponseDto.java +++ b/src/main/java/com/ccapp/ccgo/user/dto/UserResponseDto.java @@ -1,4 +1,4 @@ -package com.ccapp.ccgo.dto; +package com.ccapp.ccgo.user.dto; import lombok.Builder; import lombok.Getter; @@ -15,5 +15,13 @@ public class UserResponseDto { private LocalDate birthdate; private LocalDateTime createdAt; private String role; + private String mbti; // mbti 값 + + // 개인정보 동의 관련 필드들 + private String privacyAgreementVersion; + private boolean privacyAgreed; + private LocalDateTime privacyAgreedAt; + private String privacyAgreedMethod; + private String privacyAgreedEnvironment; } diff --git a/src/main/java/com/ccapp/ccgo/user/dto/UserUpdateRequestDto.java b/src/main/java/com/ccapp/ccgo/user/dto/UserUpdateRequestDto.java new file mode 100644 index 0000000..ed58990 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/user/dto/UserUpdateRequestDto.java @@ -0,0 +1,35 @@ +package com.ccapp.ccgo.user.dto; + +import lombok.Getter; +import lombok.Setter; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import jakarta.validation.constraints.*; + +@Getter +@Setter +public class UserUpdateRequestDto { + @NotBlank(message = "이름은 필수입니다.") + private String name; + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + private String email; + + // 선택적 필드들 (프론트엔드에서 전송하지 않을 수 있음) + private String birthdate; // YYYY-MM-DD 형식의 문자열 (선택사항) + private String gender; // 선택사항 + + // birthdate를 LocalDate로 변환하는 메서드 + public LocalDate getBirthdateAsLocalDate() { + if (birthdate == null || birthdate.trim().isEmpty()) { + return null; + } + try { + return LocalDate.parse(birthdate, DateTimeFormatter.ISO_LOCAL_DATE); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("생년월일 형식이 올바르지 않습니다. YYYY-MM-DD 형식으로 입력해주세요."); + } + } +} diff --git a/src/main/java/com/ccapp/ccgo/user/entity/PrivacyAgreement.java b/src/main/java/com/ccapp/ccgo/user/entity/PrivacyAgreement.java new file mode 100644 index 0000000..f0265f8 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/user/entity/PrivacyAgreement.java @@ -0,0 +1,53 @@ +package com.ccapp.ccgo.user.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 개인정보 동의서 전문을 저장하는 엔티티 + * - 버전별로 동의서 내용을 관리 + * - 사용자가 어떤 버전의 동의서에 동의했는지 추적 가능 + */ +@Entity +@Table(name = "privacy_agreements") +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class PrivacyAgreement { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 동의서 버전 (예: "v1.0", "v1.1") + @Column(unique = true, nullable = false) + private String version; + + // 동의서 전문 내용 + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + // 시행일 + @Column(nullable = false) + private LocalDate effectiveDate; + + // 생성일시 + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + // 활성화 여부 (현재 사용 중인 버전인지) + @Column(nullable = false) + @Builder.Default + private boolean isActive = true; + + // 생성 시 자동으로 현재 시간 설정 + @PrePersist + public void prePersist() { + this.createdAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/ccapp/ccgo/user/User.java b/src/main/java/com/ccapp/ccgo/user/entity/User.java similarity index 68% rename from src/main/java/com/ccapp/ccgo/user/User.java rename to src/main/java/com/ccapp/ccgo/user/entity/User.java index 48cccf0..dca4a23 100644 --- a/src/main/java/com/ccapp/ccgo/user/User.java +++ b/src/main/java/com/ccapp/ccgo/user/entity/User.java @@ -1,9 +1,9 @@ -package com.ccapp.ccgo.user; +package com.ccapp.ccgo.user.entity; import jakarta.persistence.*; - import lombok.*; +import lombok.*; - import java.time.LocalDate; +import java.time.LocalDate; import java.time.LocalDateTime; /** @@ -48,15 +48,28 @@ public class User { @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; - //role - @Column(nullable = false) - private String role; + // 개인정보 동의 관련 필드들 + @Column(name = "privacy_agreement_version") + private String privacyAgreementVersion; + + @Column(name = "privacy_agreed") + private boolean privacyAgreed; + + @Column(name = "privacy_agreed_at") + private LocalDateTime privacyAgreedAt; + + @Column(name = "privacy_agreed_method") + private String privacyAgreedMethod; + + @Column(name = "privacy_agreed_environment") + private String privacyAgreedEnvironment; // 회원 가입 시 자동으로 현재 시간 설정 @PrePersist public void prePersist() { this.createdAt = LocalDateTime.now(); } + } diff --git a/src/main/java/com/ccapp/ccgo/user/mapper/UserMapper.java b/src/main/java/com/ccapp/ccgo/user/mapper/UserMapper.java new file mode 100644 index 0000000..42a11f9 --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/user/mapper/UserMapper.java @@ -0,0 +1,91 @@ +package com.ccapp.ccgo.user.mapper; + +import com.ccapp.ccgo.user.dto.UserRequestDto; +import com.ccapp.ccgo.user.dto.UserResponseDto; +import com.ccapp.ccgo.user.dto.UserUpdateRequestDto; +import com.ccapp.ccgo.user.entity.User; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class UserMapper { + + // RequestDto -> Entity + public static User toEntity(UserRequestDto dto, String encodedPassword) { + if (dto == null) return null; + + User user = User.builder() + .email(dto.getEmail()) + .password(encodedPassword) + .name(dto.getName()) + .gender(dto.getGender()) + .birthdate(dto.getBirthdate()) + .build(); + + // 개인정보 동의 관련 필드 설정 + if (dto.getPrivacyAgreementVersion() != null) { + user.setPrivacyAgreementVersion(dto.getPrivacyAgreementVersion()); + } + user.setPrivacyAgreed(dto.isPrivacyAgreed()); + + // agreedAt 문자열을 LocalDateTime으로 변환 + if (dto.getPrivacyAgreedAt() != null && !dto.getPrivacyAgreedAt().trim().isEmpty()) { + try { + LocalDateTime agreedAt = LocalDateTime.parse(dto.getPrivacyAgreedAt(), + DateTimeFormatter.ISO_DATE_TIME); + user.setPrivacyAgreedAt(agreedAt); + } catch (Exception e) { + // 파싱 실패 시 현재 시간으로 설정 + user.setPrivacyAgreedAt(LocalDateTime.now()); + } + } else { + user.setPrivacyAgreedAt(LocalDateTime.now()); + } + + if (dto.getPrivacyAgreedMethod() != null) { + user.setPrivacyAgreedMethod(dto.getPrivacyAgreedMethod()); + } + if (dto.getPrivacyAgreedEnvironment() != null) { + user.setPrivacyAgreedEnvironment(dto.getPrivacyAgreedEnvironment()); + } + + return user; + } + + // Entity -> ResponseDto + public static UserResponseDto toDto(User user) { + if (user == null) return null; + + return UserResponseDto.builder() + .id(user.getId()) + .email(user.getEmail()) + .name(user.getName()) + .gender(user.getGender()) + .birthdate(user.getBirthdate()) + .createdAt(user.getCreatedAt()) + .privacyAgreementVersion(user.getPrivacyAgreementVersion()) + .privacyAgreed(user.isPrivacyAgreed()) + .privacyAgreedAt(user.getPrivacyAgreedAt()) + .privacyAgreedMethod(user.getPrivacyAgreedMethod()) + .privacyAgreedEnvironment(user.getPrivacyAgreedEnvironment()) + .build(); + } + + // UpdateRequestDto -> Entity (부분 업데이트용) + public static void updateEntityFromDto(User user, UserUpdateRequestDto dto) { + if (user == null || dto == null) return; + + if (dto.getName() != null) { + user.setName(dto.getName()); + } + if (dto.getEmail() != null) { + user.setEmail(dto.getEmail()); + } + if (dto.getBirthdate() != null && !dto.getBirthdate().trim().isEmpty()) { + user.setBirthdate(dto.getBirthdateAsLocalDate()); + } + if (dto.getGender() != null) { + user.setGender(dto.getGender()); + } + } +} diff --git a/src/main/java/com/ccapp/ccgo/user/repository/PrivacyAgreementRepository.java b/src/main/java/com/ccapp/ccgo/user/repository/PrivacyAgreementRepository.java new file mode 100644 index 0000000..a192b6a --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/user/repository/PrivacyAgreementRepository.java @@ -0,0 +1,24 @@ +package com.ccapp.ccgo.user.repository; + +import com.ccapp.ccgo.user.entity.PrivacyAgreement; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface PrivacyAgreementRepository extends JpaRepository { + + // 버전으로 동의서 조회 + Optional findByVersion(String version); + + // 현재 활성화된 동의서 조회 + Optional findByIsActiveTrue(); + + // 특정 버전이 존재하는지 확인 + boolean existsByVersion(String version); + + // 최신 버전 조회 + @Query("SELECT pa FROM PrivacyAgreement pa WHERE pa.isActive = true ORDER BY pa.createdAt DESC") + Optional findLatestActive(); +} diff --git a/src/main/java/com/ccapp/ccgo/repository/UserRepository.java b/src/main/java/com/ccapp/ccgo/user/repository/UserRepository.java similarity index 72% rename from src/main/java/com/ccapp/ccgo/repository/UserRepository.java rename to src/main/java/com/ccapp/ccgo/user/repository/UserRepository.java index 5d79179..66c591f 100644 --- a/src/main/java/com/ccapp/ccgo/repository/UserRepository.java +++ b/src/main/java/com/ccapp/ccgo/user/repository/UserRepository.java @@ -1,6 +1,6 @@ -package com.ccapp.ccgo.repository; +package com.ccapp.ccgo.user.repository; -import com.ccapp.ccgo.user.User; +import com.ccapp.ccgo.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; diff --git a/src/main/java/com/ccapp/ccgo/user/service/UserService.java b/src/main/java/com/ccapp/ccgo/user/service/UserService.java new file mode 100644 index 0000000..f62e2fc --- /dev/null +++ b/src/main/java/com/ccapp/ccgo/user/service/UserService.java @@ -0,0 +1,189 @@ +package com.ccapp.ccgo.user.service; + +import com.ccapp.ccgo.user.dto.UserRequestDto; +import com.ccapp.ccgo.user.dto.UserResponseDto; +import com.ccapp.ccgo.user.dto.UserUpdateRequestDto; +import com.ccapp.ccgo.user.dto.PasswordChangeRequestDto; +import com.ccapp.ccgo.user.mapper.UserMapper; +import com.ccapp.ccgo.common.exception.CustomException; +import com.ccapp.ccgo.auth.jwt.JwtProvider; +import com.ccapp.ccgo.user.repository.UserRepository; +import com.ccapp.ccgo.user.repository.PrivacyAgreementRepository; +import com.ccapp.ccgo.user.entity.User; +import com.ccapp.ccgo.user.entity.PrivacyAgreement; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final PrivacyAgreementRepository privacyAgreementRepository; + private final PasswordEncoder passwordEncoder; + private final JwtProvider jwtProvider; + private final AuthenticationManager authenticationManager; + + // 1. 회원가입 + public UserResponseDto register(UserRequestDto dto) { + if (userRepository.findByEmail(dto.getEmail()).isPresent()) { + throw new CustomException("이미 가입된 이메일입니다.", HttpStatus.CONFLICT); + } + + // 개인정보 동의 검증 + if (dto.isPrivacyAgreed()) { + validatePrivacyAgreement(dto.getPrivacyAgreementVersion()); + } + + String encodedPassword = passwordEncoder.encode(dto.getPassword()); + User user = UserMapper.toEntity(dto, encodedPassword); + userRepository.save(user); + + return UserMapper.toDto(user); + } + + // 개인정보 동의서 버전 검증 + private void validatePrivacyAgreement(String version) { + if (version == null || version.trim().isEmpty()) { + throw new CustomException("개인정보 동의서 버전이 필요합니다.", HttpStatus.BAD_REQUEST); + } + + // 해당 버전의 동의서가 존재하는지 확인 + if (!privacyAgreementRepository.existsByVersion(version)) { + throw new CustomException("존재하지 않는 개인정보 동의서 버전입니다: " + version, HttpStatus.BAD_REQUEST); + } + } + + // 2. 로그인: JWT 토큰 생성 반환 + public String loginAndGetToken(String email, String password) { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(email, password) + ); + + return jwtProvider.createAccessToken(authentication); + } + + // 3. 전체 사용자 조회 + public List getAllUsers() { + return userRepository.findAll().stream() + .map(UserMapper::toDto) + .collect(Collectors.toList()); + } + + // 4. 사용자 상세 조회 + public UserResponseDto getUserById(Long id) { + User user =userRepository.findById(id) + .orElseThrow(() -> new CustomException("해당 ID의 사용자가 없습니다.", HttpStatus.NOT_FOUND)); + return UserMapper.toDto(user); + } + + // 5. 사용자 정보 수정 + public UserResponseDto updateUser(Long id, UserRequestDto dto) { + User user = userRepository.findById(id) + .orElseThrow(() -> new CustomException("해당 ID의 사용자가 없습니다.", HttpStatus.NOT_FOUND)); + + user.setEmail(dto.getEmail()); + if (dto.getPassword() != null && !dto.getPassword().isEmpty()) { + user.setPassword(passwordEncoder.encode(dto.getPassword())); + } + user.setName(dto.getName()); + user.setGender(dto.getGender()); + user.setBirthdate(dto.getBirthdate()); + + userRepository.save(user); + return UserMapper.toDto(user); + } + + // 6. 사용자 삭제 + public void deleteUser(Long id) { + if (!userRepository.existsById(id)) { + throw new CustomException("삭제할 사용자가 존재하지 않습니다.", HttpStatus.NOT_FOUND); + } + userRepository.deleteById(id); + } + + // 7. 현재 로그인한 사용자 정보 조회 + public UserResponseDto getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new CustomException("로그인한 사용자를 찾을 수 없습니다.", HttpStatus.NOT_FOUND)); + + return UserMapper.toDto(user); + } + + // 8. 현재 사용자 정보 부분 업데이트 (PATCH) + public UserResponseDto updateCurrentUser(UserUpdateRequestDto dto) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new CustomException("로그인한 사용자를 찾을 수 없습니다.", HttpStatus.NOT_FOUND)); + + UserMapper.updateEntityFromDto(user, dto); + userRepository.save(user); + + return UserMapper.toDto(user); + } + + // 9. 현재 사용자 정보 전체 업데이트 (PUT) + public UserResponseDto updateCurrentUserFull(UserRequestDto dto) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new CustomException("로그인한 사용자를 찾을 수 없습니다.", HttpStatus.NOT_FOUND)); + + user.setEmail(dto.getEmail()); + if (dto.getPassword() != null && !dto.getPassword().isEmpty()) { + user.setPassword(passwordEncoder.encode(dto.getPassword())); + } + user.setName(dto.getName()); + user.setGender(dto.getGender()); + user.setBirthdate(dto.getBirthdate()); + + userRepository.save(user); + return UserMapper.toDto(user); + } + + // 10. 비밀번호 변경 + public void changePassword(PasswordChangeRequestDto dto) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new CustomException("로그인한 사용자를 찾을 수 없습니다.", HttpStatus.NOT_FOUND)); + + // 현재 비밀번호 확인 + if (!passwordEncoder.matches(dto.getCurrentPassword(), user.getPassword())) { + throw new CustomException("현재 비밀번호가 일치하지 않습니다.", HttpStatus.BAD_REQUEST); + } + + // 새 비밀번호로 변경 + user.setPassword(passwordEncoder.encode(dto.getNewPassword())); + userRepository.save(user); + } + + // 11. 개인정보 동의서 조회 (현재 활성화된 버전) + public PrivacyAgreement getCurrentPrivacyAgreement() { + return privacyAgreementRepository.findByIsActiveTrue() + .orElseThrow(() -> new CustomException("활성화된 개인정보 동의서가 없습니다.", HttpStatus.NOT_FOUND)); + } + + // 12. 특정 버전의 개인정보 동의서 조회 + public PrivacyAgreement getPrivacyAgreementByVersion(String version) { + return privacyAgreementRepository.findByVersion(version) + .orElseThrow(() -> new CustomException("해당 버전의 개인정보 동의서가 없습니다: " + version, HttpStatus.NOT_FOUND)); + } +} diff --git a/src/main/resources/MBTIdata.sql b/src/main/resources/MBTIdata.sql new file mode 100644 index 0000000..6c393f5 --- /dev/null +++ b/src/main/resources/MBTIdata.sql @@ -0,0 +1,80 @@ +INSERT INTO mbti_score (from_mbti, to_mbti, score) VALUES + ('ENTJ','ISFP',16),('ENTJ','INFP',15),('ENTJ','ESFP',14),('ENTJ','ESTP',13), + ('ENTJ','ISTP',12),('ENTJ','INTP',11),('ENTJ','ENFP',10),('ENTJ','INFJ',9), + ('ENTJ','INTJ',8),('ENTJ','ENFJ',7),('ENTJ','ISTJ',6),('ENTJ','ENTP',5), + ('ENTJ','ESTJ',4),('ENTJ','ENTJ',3),('ENTJ','ESFJ',2),('ENTJ','ISFJ',1), + + ('ENTP','ISFJ',16),('ENTP','ISTJ',15),('ENTP','ENTP',14),('ENTP','ESTJ',13), + ('ENTP','ESFJ',12),('ENTP','INFJ',11),('ENTP','INTJ',10),('ENTP','INFP',9), + ('ENTP','ENFJ',8),('ENTP','INTP',7),('ENTP','ISTP',6),('ENTP','ENFP',5), + ('ENTP','ESTP',4),('ENTP','ENTJ',3),('ENTP','ESFP',2),('ENTP','ISFP',1), + + ('INTJ','ESFP',16),('INTJ','ESTP',15),('INTJ','ISFP',14),('INTJ','INFP',13), + ('INTJ','INFJ',12),('INTJ','ENFP',11),('INTJ','ENTP',10),('INTJ','ISTP',9), + ('INTJ','ENFJ',8),('INTJ','INTJ',7),('INTJ','ISTJ',6),('INTJ','ENTJ',5), + ('INTJ','INTP',4),('INTJ','ESTJ',3),('INTJ','ISFJ',2),('INTJ','ESFJ',1), + + ('INTP','ESFJ',16),('INTP','ENFJ',15),('INTP','ISFJ',14),('INTP','INFJ',13), + ('INTP','ESTJ',12),('INTP','ISTJ',11),('INTP','ENTJ',10),('INTP','ENFP',9), + ('INTP','ENTP',8),('INTP','INTP',7),('INTP','INTJ',6),('INTP','ISTP',5), + ('INTP','INFP',4),('INTP','ESTP',3),('INTP','ISFP',2),('INTP','ESFP',1), + + ('ESTJ','INFP',16),('ESTJ','ISFP',15),('ESTJ','INTP',14),('ESTJ','ENTP',13), + ('ESTJ','ISTP',12),('ESTJ','ESFP',11),('ESTJ','ENFP',10),('ESTJ','ISTJ',9), + ('ESTJ','ISFJ',8),('ESTJ','ESTJ',7),('ESTJ','ESFJ',6),('ESTJ','INTJ',5), + ('ESTJ','ENTJ',4),('ESTJ','ESTP',3),('ESTJ','ENFJ',2),('ESTJ','INFJ',1), + + ('ESFJ','INTP',16),('ESFJ','ISTP',15),('ESFJ','ENTP',14),('ESFJ','ENFP',13), + ('ESFJ','INFP',12),('ESFJ','ISTJ',11),('ESFJ','ESFJ',10),('ESFJ','ESTP',9), + ('ESFJ','ISFP',8),('ESFJ','ENFJ',7),('ESFJ','ISFJ',6),('ESFJ','INFJ',5), + ('ESFJ','ESTJ',4),('ESFJ','ESFP',3),('ESFJ','ENTJ',2),('ESFJ','INTJ',1), + + ('ISTJ','ENFP',16),('ISTJ','ENTP',15),('ISTJ','ISFP',14),('ISTJ','INFP',13), + ('ISTJ','ESTP',12),('ISTJ','ESFP',11),('ISTJ','INTP',10),('ISTJ','ESTJ',9), + ('ISTJ','ESFJ',8),('ISTJ','ISTJ',7),('ISTJ','INTJ',6),('ISTJ','ISFJ',5), + ('ISTJ','ISTP',4),('ISTJ','ENTJ',3),('ISTJ','INFJ',2),('ISTJ','ENFJ',1), + + ('ISFJ','ENTP',16),('ISFJ','ENFP',15),('ISFJ','INTP',14),('ISFJ','ISTP',13), + ('ISFJ','ESFP',12),('ISFJ','ESTP',11),('ISFJ','ESTJ',10),('ISFJ','INFP',9), + ('ISFJ','ESFJ',8),('ISFJ','ISTJ',7),('ISFJ','ISFJ',6),('ISFJ','ENFJ',5), + ('ISFJ','INFJ',4),('ISFJ','ISFP',3),('ISFJ','INTJ',2),('ISFJ','ENTJ',1), + + ('ENFJ','ISTP',16),('ENFJ','INTP',15),('ENFJ','ESTP',14),('ENFJ','ESFP',13), + ('ENFJ','ENFJ',12),('ENFJ','INFP',11),('ENFJ','ISFP',10),('ENFJ','ENTP',9), + ('ENFJ','INTJ',8),('ENFJ','ESFJ',7),('ENFJ','INFJ',6),('ENFJ','ENFP',5), + ('ENFJ','ENTJ',4),('ENFJ','ISFJ',3),('ENFJ','ESTJ',2),('ENFJ','ISTJ',1), + + ('ENFP','ISTJ',16),('ENFP','ISFJ',15),('ENFP','ESFJ',14),('ENFP','ESTJ',13), + ('ENFP','INFJ',12),('ENFP','INTJ',11),('ENFP','ENTJ',10),('ENFP','ISFP',9), + ('ENFP','ENFP',8),('ENFP','INTP',7),('ENFP','INFP',6),('ENFP','ENFJ',5), + ('ENFP','ENTP',4),('ENFP','ESFP',3),('ENFP','ESTP',2),('ENFP','ISTP',1), + + ('INFJ','ESTP',16),('INFJ','ESFP',15),('INFJ','ISTP',14),('INFJ','INTP',13), + ('INFJ','ENFP',12),('INFJ','ENTP',11),('INFJ','INTJ',10),('INFJ','ENTJ',9), + ('INFJ','INFJ',8),('INFJ','ISFP',7),('INFJ','ENFJ',6),('INFJ','ESFJ',5), + ('INFJ','ISFJ',4),('INFJ','INFP',3),('INFJ','ISTJ',2),('INFJ','ESTJ',1), + + ('INFP','ESTJ',16),('INFP','ENTJ',15),('INFP','INTJ',14),('INFP','ISTJ',13), + ('INFP','ENFJ',12),('INFP','ESFJ',11),('INFP','ENTP',10),('INFP','INFP',9), + ('INFP','ISFJ',8),('INFP','INTP',7),('INFP','ESFP',6),('INFP','ENFP',5), + ('INFP','ISFP',4),('INFP','INFJ',3),('INFP','ISTP',2),('INFP','ESTP',1), + + ('ESTP','INFJ',16),('ESTP','INTJ',15),('ESTP','ENFJ',14),('ESTP','ENTJ',13), + ('ESTP','ISFJ',12),('ESTP','ISTP',11),('ESTP','ISTJ',10),('ESTP','ESFJ',9), + ('ESTP','ESTP',8),('ESTP','ISFP',7),('ESTP','ESFP',6),('ESTP','INTP',5), + ('ESTP','ENTP',4),('ESTP','ESTJ',3),('ESTP','ENFP',2),('ESTP','INFP',1), + + ('ESFP','INTJ',16),('ESFP','INFJ',15),('ESFP','ENTJ',14),('ESFP','ENFJ',13), + ('ESFP','ESTJ',12),('ESFP','ISTJ',11),('ESFP','ISFJ',10),('ESFP','ISFP',9), + ('ESFP','ISTP',8),('ESFP','INFP',7),('ESFP','ESFP',6),('ESFP','ESTP',5), + ('ESFP','ESFJ',4),('ESFP','ENFP',3),('ESFP','ENTP',2),('ESFP','INTP',1), + + ('ISTP','ENFJ',16),('ISTP','ESFJ',15),('ISTP','INFJ',14),('ISTP','ISFJ',13), + ('ISTP','ENTJ',12),('ISTP','ESTJ',11),('ISTP','ESFP',10),('ISTP','ESTP',9), + ('ISTP','INTJ',8),('ISTP','ISTP',7),('ISTP','INTP',6),('ISTP','ENTP',5), + ('ISTP','ISTJ',4),('ISTP','ISFP',3),('ISTP','INFP',2),('ISTP','ENFP',1), + + ('ISFP','ENTJ',16),('ISFP','ESTJ',15),('ISFP','INTJ',14),('ISFP','ISTJ',13), + ('ISFP','ENFJ',12),('ISFP','ESFJ',11),('ISFP','INFJ',10),('ISFP','ESFP',9), + ('ISFP','ISFP',8),('ISFP','ESTP',7),('ISFP','ENFP',6),('ISFP','INFP',5), + ('ISFP','ISTP',4),('ISFP','ISFJ',3),('ISFP','INTP',2),('ISFP','ENTP',1); diff --git a/src/main/resources/MBTIschema.sql b/src/main/resources/MBTIschema.sql new file mode 100644 index 0000000..ac9f504 --- /dev/null +++ b/src/main/resources/MBTIschema.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS mbti_score ( + from_mbti VARCHAR(4) NOT NULL, + to_mbti VARCHAR(4) NOT NULL, + score INT NOT NULL, + PRIMARY KEY (from_mbti, to_mbti) +); diff --git a/src/main/resources/MissionInsert.sql b/src/main/resources/MissionInsert.sql new file mode 100644 index 0000000..deb6354 --- /dev/null +++ b/src/main/resources/MissionInsert.sql @@ -0,0 +1,103 @@ +USE ccmake; +INSERT INTO mission_template (title, description, score) VALUES + (' 미션 1', '서로의 첫인상 공유하기', 1), + (' 미션 2', '인스타 맞팔하기', 1), + (' 미션 3', '매점가서 서로 아이스크림 사주기', 1), + (' 미션 4', '기도제목 공유하기', 1), + (' 미션 5', '셀카 같이 찍기', 1), + (' 미션 6', '노래 추천해주기', 1), + (' 미션 7', '공통 관심사 하나 찾기', 1), + (' 미션 8', '간식 선물하기', 1), + (' 미션 9', '서로 MBTI 말해주기', 1), + (' 미션 10', '닮은꼴 캐릭터 찾기', 1), + (' 미션 11', '최애 유튜버 추천하기', 1), + (' 미션 12', '좋아하는 음식 추천하기', 1), + (' 미션 13', '서로의 TMI 하나씩 공유하기', 1), + (' 미션 14', '큐티 나눔하기', 1), + (' 미션 15', '서로 별명 지어주기', 1), + (' 미션 16', '서로 생일 말해주기', 1), + (' 미션 17', '좋아하는 성경구절/명언 공유하기', 1), + (' 미션 18', '서로 이름 3행시 짓기', 1), + (' 미션 19', '옛날 사진 공유하기', 1), + (' 미션 20', '짧은 응원 메시지 보내기', 1), + (' 미션 21', '최근 꾼 꿈 공유하기', 1), + (' 미션 22', '가위 바위 보 진사람이 딱밤맞기', 1), + (' 미션 23', '게시판에서 흥미로운 공지 찾아 사진 찍기', 1), + (' 미션 24', '에타 시간표 공유하기', 1), + (' 미션 25', '교내에서 처음 가보는 건물 탐방하기', 1), + (' 미션 26', '키우는 동물사진 보여주기', 1), + (' 미션 27', '자주 가는 장소 찍어오기', 1), + (' 미션 28', '같이 노래 한 곡 부르기', 1), + (' 미션 29', '팔씨름 하기', 1), + (' 미션 30', '상대방 카톡 별명으로 바꾸기', 1), + (' 미션 1', '초상화 그려주기', 3), + (' 미션 2', '교내 카페 음료 마시기', 3), + (' 미션 3', '매점가서 서로 아이스크림 사주기', 3), + (' 미션 4', '한한하기', 3), + (' 미션 5', '학식 같이 먹기', 3), + (' 미션 6', '모닝콜 해주기', 3), + (' 미션 7', '5분이상 통화하기', 3), + (' 미션 8', '커플 프사 하기', 3), + (' 미션 9', '평봉에서 누워 하늘 사진 찍기', 3), + (' 미션 10', '다른 CC랑 스몰토크 5분', 3), + (' 미션 11', '도서관에서 아무 책 들고 사진 찍기', 3), + (' 미션 12', '안 먹어본 음료 뽑아먹기', 3), + (' 미션 13', '같은 표정으로 셀카 3장 찍기', 3), + (' 미션 14', '손으로 하트 사진 찍기', 3), + (' 미션 15', '다른 CC랑 간식 나눠먹기', 3), + (' 미션 16', '모두 다른 포즈로 점프샷 3장 찍기', 3), + (' 미션 17', '영화 포스터 따라한 사진 찍기', 3), + (' 미션 18', '롤모델이 누구인지 물어보기', 3), + (' 미션 19', '교내 농구대에서 슛 3번 던져보기', 3), + (' 미션 20', '활주로 내려갔다가 돌아오기', 3), + (' 미션 21', '팀 구호 외치며 영상 찍기', 3), + (' 미션 22', '10초 내 자기소개 릴레이 찍기', 3), + (' 미션 23', '스터디룸 예약해서 30분 이상 사용하기', 3), + (' 미션 24', '학생식당 메뉴 중 랜덤으로 고르고 먹기', 3), + (' 미션 25', '손하트 영상 찍기', 3), + (' 미션 26', '캠퍼스 둘레길 돌아보고 풍경 영상 찍기', 3), + (' 미션 27', '교내 운동기구 사용해보기', 3), + (' 미션 28', '제일 큰 나무 찾아서 사진 찍기', 3), + (' 미션 29', '같이 공부하기', 3), + (' 미션 30', '옷 맞춰입기', 3), + (' 미션 1', '둘이서 야식먹기', 5), + (' 미션 2', '손편지 써주기', 5), + (' 미션 3', '3시간 이상 같이 있기', 5), + (' 미션 4', '둘이서 천마지 가기', 5), + (' 미션 5', '그레이스스쿨 별 보러 가기', 5), + (' 미션 6', 'SNS에 태그하기', 5), + (' 미션 7', '강물 예배가기', 5), + (' 미션 8', '서로 노래 불러주기', 5), + (' 미션 9', '다른 cc팀이랑 밥먹기', 5), + (' 미션 10', '엽사 찍기', 5), + (' 미션 11', '둘만의 단톡방 만들기', 5), + (' 미션 12', '1분 동안 눈 마주치기', 5), + (' 미션 13', '하루동안 서로 반말쓰기', 5), + (' 미션 14', '서로 눈 감고 서로 그리기', 5), + (' 미션 15', '같이 보드게임 하기', 5), + (' 미션 16', '볼에 스티커 붙이고 다니기', 5), + (' 미션 17', '같이 게임 하기', 5), + (' 미션 18', '당연하지 게임하기(총 10번)', 5), + (' 미션 19', '같이 운동하기', 5), + (' 미션 20', '교내 모든 건물 앞에서 사진 찍기', 5), + (' 미션 1', '인생네컷 찍기', 10), + (' 미션 2', '전화 10분이상하기', 10), + (' 미션 3', '노래방 가기', 10), + (' 미션 4', '단둘이 교내에서 영화 보기', 10), + (' 미션 5', '교외 카페 가기', 10), + (' 미션 6', '같이 바다가서 인증샷 찍기', 10), + (' 미션 7', '같이 릴스 찍기', 10), + (' 미션 8', '모든 팀사람과 사진 찍기', 10), + (' 미션 9', '영일대 놀러가기', 10), + (' 미션 10', '더블 데이트', 10), + (' 미션 11', '같이 새벽 예배가기', 10), + (' 미션 12', '우산 1개 쓰고 한한하기', 10), + (' 미션 13', '스페이스워크에서 사진찍기', 10), + (' 미션 14', '2인 브이로그 찍기', 10), + (' 미션 15', '둘이서 밤 산책하기', 10), + (' 미션 16', '서로 프로필 사진 만들어주기', 10), + (' 미션 17', '틱톡 챌린지 따라하기', 10), + (' 미션 18', '같이 드라마 보기', 10), + (' 미션 19', '영일대 불꽃놀이 하기', 10), + (' 미션 20', '두 사람만의 엽기 포즈 찍기', 10); + \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index e3dc7df..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1,20 +0,0 @@ -spring.datasource.url=jdbc:mysql://localhost:3306/newuser - -server.address=0.0.0.0 -server.port=8080 - -spring.datasource.username = root -spring.datasource.password = qkrwlsdn - -jwt.secret=????????????????????1234!@11111123451234432AVDSFUCKYOUSHITHOLY -jwt.access-token-expiration=86400000 -jwt.refresh-token-expiration=604800000 - -spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver - -spring.jpa.hibernate.ddl-auto=update - -#spring.jpa.show-sql=true -#spring.jpa.properties.hibernate.format_sql=true - -spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect \ No newline at end of file diff --git a/src/test/java/com/ccapp/ccgo/CCgoApplicationTests.java b/src/test/java/com/ccapp/ccgo/CCgoApplicationTests.java index 8971401..f9f1ad9 100644 --- a/src/test/java/com/ccapp/ccgo/CCgoApplicationTests.java +++ b/src/test/java/com/ccapp/ccgo/CCgoApplicationTests.java @@ -6,8 +6,8 @@ @SpringBootTest class CCgoApplicationTests { - @Test - void contextLoads() { - } +// @Test +// void contextLoads() { +// } }