From f7c02acc3d38a8c14d4545442198f833c43c86aa Mon Sep 17 00:00:00 2001 From: CSE-Shaco Date: Fri, 9 Jan 2026 04:01:51 +0900 Subject: [PATCH 01/49] Chore: Upgrade Gradle LTS, Java, and Spring Boot to address CVEs --- build.gradle | 92 ++++++++++++++++-------- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 62 insertions(+), 32 deletions(-) diff --git a/build.gradle b/build.gradle index a58d6793..2a1615c3 100644 --- a/build.gradle +++ b/build.gradle @@ -1,32 +1,51 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.3.6' + id 'org.springframework.boot' version '3.5.9' id 'io.spring.dependency-management' version '1.1.7' + id 'com.diffplug.spotless' version '6.25.0' } group = 'inha' version = '0.0.1-SNAPSHOT' -/* ===== Java Toolchain ===== */ java { toolchain { - languageVersion = JavaLanguageVersion.of(17) + languageVersion = JavaLanguageVersion.of(25) } } -/* ===== Configurations ===== */ configurations { compileOnly { extendsFrom annotationProcessor } } -/* ===== Repositories ===== */ repositories { mavenCentral() } -/* ===== Dependencies ===== */ +/* ===== Version Pins (필요한 것만) ===== */ +ext { + awspringBomVersion = '3.4.2' + springdocVersion = '2.8.15' + flywayVersion = '11.19.0' + postgresVersion = '42.7.3' + hibernateTypes60 = '2.21.1' + dotenvVersion = '5.2.2' + querydslVersion = '6.10.1' + jjwtVersion = '0.13.0' +} + +/* ===== Vulnerability Pins (Mend 경고 제거용) ===== */ +configurations.configureEach { + resolutionStrategy { + // CVE-2025-48924 대응 + force 'org.apache.commons:commons-lang3:3.18.0' + // CVE-2021-47621 대응 + force 'io.github.classgraph:classgraph:4.8.112' + } +} + dependencies { // --- Spring Boot Starters --- implementation 'org.springframework.boot:spring-boot-starter-web' @@ -35,32 +54,43 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-mail' + // --- Swagger / OpenAPI (springdoc) --- + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocVersion}") { + exclude group: "org.apache.commons", module: "commons-lang3" + } + implementation "org.apache.commons:commons-lang3:3.18.0" + // --- DB & JPA Utils --- - implementation 'com.vladmihalcea:hibernate-types-60:2.21.1' - implementation 'org.postgresql:postgresql:42.7.3' - runtimeOnly 'com.h2database:h2' // 테스트/로컬용 인메모리 DB + implementation "com.vladmihalcea:hibernate-types-60:${hibernateTypes60}" + implementation "org.postgresql:postgresql:${postgresVersion}" + runtimeOnly 'com.h2database:h2' + + // --- Querydsl (OpenFeign fork) --- + implementation "io.github.openfeign.querydsl:querydsl-jpa:${querydslVersion}" + annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:${querydslVersion}:jakarta" + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" - // --- QueryDSL --- - implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' - annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' - annotationProcessor 'jakarta.annotation:jakarta.annotation-api' - annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + // Querydsl APT helper APIs (컴파일 타임만) + compileOnly 'jakarta.annotation:jakarta.annotation-api' + compileOnly 'jakarta.persistence:jakarta.persistence-api' - // --- JWT (JJWT 0.9.x, 레거시 패키지) --- - implementation 'io.jsonwebtoken:jjwt:0.9.1' + // --- JWT --- + implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}" + runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}" + runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}" // --- AWS (S3) --- implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' - // --- Flyway (DB Migration) --- - implementation "org.flywaydb:flyway-core:10.21.0" - implementation "org.flywaydb:flyway-database-postgresql:10.21.0" + // --- Flyway --- + implementation "org.flywaydb:flyway-core:${flywayVersion}" + implementation "org.flywaydb:flyway-database-postgresql:${flywayVersion}" // --- 환경변수(.env) --- - implementation 'io.github.cdimascio:java-dotenv:5.2.2' + implementation "io.github.cdimascio:java-dotenv:${dotenvVersion}" // --- Lombok --- - compileOnly 'org.projectlombok:lombok' + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' // --- Test --- @@ -68,29 +98,29 @@ dependencies { testImplementation 'org.mockito:mockito-core:5.6.0' testImplementation 'org.mockito:mockito-junit-jupiter:5.6.0' testImplementation 'org.assertj:assertj-core:3.24.2' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - - // swagger - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } dependencyManagement { imports { - mavenBom "io.awspring.cloud:spring-cloud-aws-dependencies:3.1.1" - // ↑ 3.x 대 사용 (프로젝트에 맞는 최신 3.x 가능) + mavenBom "io.awspring.cloud:spring-cloud-aws-dependencies:${awspringBomVersion}" } } -/* ===== Tasks ===== */ +spotless { + java { + googleJavaFormat('1.22.0') + target 'src/**/*.java' + } +} -// 테스트: 프로필과 JUnit 플랫폼 한 곳에서 설정 tasks.test { useJUnitPlatform() systemProperty "spring.profiles.active", "test" } -// QueryDSL 생성물 경로 고정 tasks.withType(JavaCompile).configureEach { - options.annotationProcessorGeneratedSourcesDirectory = file("build/generated/sources/annotationProcessor/java/main") + options.generatedSourceOutputDirectory.set(file("build/generated/sources/annotationProcessor/java/main")) } + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e2847c82..2e111328 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 88c78814441abc8be155c49f110f25d6a8a111d4 Mon Sep 17 00:00:00 2001 From: CSE-Shaco Date: Fri, 9 Jan 2026 04:04:13 +0900 Subject: [PATCH 02/49] Refactor: Extract S3 configuration into properties and update env configs --- .../domain/resource/service/S3Service.java | 10 ++++----- .../gdgoc/global/config/s3/S3Properties.java | 14 ++++++++++++ src/main/resources/application-dev.yml | 22 ++++++++++--------- src/main/resources/application-local.yml | 20 +++++++++-------- src/main/resources/application-prod.yml | 20 +++++++++-------- src/test/resources/application-test.yml | 21 ++++++++++-------- 6 files changed, 64 insertions(+), 43 deletions(-) create mode 100644 src/main/java/inha/gdgoc/global/config/s3/S3Properties.java diff --git a/src/main/java/inha/gdgoc/domain/resource/service/S3Service.java b/src/main/java/inha/gdgoc/domain/resource/service/S3Service.java index 15464313..6f4a2b29 100644 --- a/src/main/java/inha/gdgoc/domain/resource/service/S3Service.java +++ b/src/main/java/inha/gdgoc/domain/resource/service/S3Service.java @@ -1,10 +1,10 @@ package inha.gdgoc.domain.resource.service; import inha.gdgoc.domain.resource.enums.S3KeyType; +import inha.gdgoc.global.config.s3.S3Properties; import java.io.IOException; import java.util.UUID; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import software.amazon.awssdk.core.sync.RequestBody; @@ -17,16 +17,14 @@ public class S3Service { private final S3Client s3Client; - - @Value("${cloud.aws.s3.bucket}") - private String bucketName; + private final S3Properties s3Properties; public String upload(Long userId, S3KeyType s3key, MultipartFile file) throws IOException { String fileName = UUID.randomUUID() + "-" + file.getOriginalFilename(); String key = "user/%d/%s/%s".formatted(userId, s3key.getValue(), fileName); PutObjectRequest putReq = PutObjectRequest.builder() - .bucket(bucketName) + .bucket(s3Properties.getBucket()) .key(key) .contentType(file.getContentType()) .build(); @@ -37,7 +35,7 @@ public String upload(Long userId, S3KeyType s3key, MultipartFile file) throws IO public String getS3FileUrl(String key) { return s3Client.utilities() - .getUrl(GetUrlRequest.builder().bucket(bucketName).key(key).build()) + .getUrl(GetUrlRequest.builder().bucket(s3Properties.getBucket()).key(key).build()) .toExternalForm(); } } diff --git a/src/main/java/inha/gdgoc/global/config/s3/S3Properties.java b/src/main/java/inha/gdgoc/global/config/s3/S3Properties.java new file mode 100644 index 00000000..a7e562c4 --- /dev/null +++ b/src/main/java/inha/gdgoc/global/config/s3/S3Properties.java @@ -0,0 +1,14 @@ +package inha.gdgoc.global.config.s3; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties("app.s3") +public class S3Properties { + private String bucket; +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 47d075ed..855549ca 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -46,6 +46,18 @@ spring: auth: true starttls: enable: true + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY_ID} + secret-key: ${AWS_SECRET_ACCESS_KEY} + region: + static: ${AWS_REGION} + + +app: + s3: + bucket: ${AWS_TEST_RESOURCE_BUCKET} logging: level: @@ -62,13 +74,3 @@ jwt: googleIssuer: ${GOOGLE_ISSUER} selfIssuer: ${SELF_ISSUER} secretKey: ${SECRET_KEY} - -cloud: - aws: - credentials: - access-key: ${AWS_ACCESS_KEY_ID} - secret-key: ${AWS_SECRET_ACCESS_KEY} - region: - static: ${AWS_REGION} - s3: - bucket: ${AWS_TEST_RESOURCE_BUCKET} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 5a665bf0..d43d8f17 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -45,6 +45,17 @@ spring: auth: true starttls: enable: true + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY_ID} + secret-key: ${AWS_SECRET_ACCESS_KEY} + region: + static: ${AWS_REGION} + +app: + s3: + bucket: ${AWS_TEST_RESOURCE_BUCKET} google: client-id: ${GOOGLE_CLIENT_ID} @@ -61,12 +72,3 @@ jwt: selfIssuer: ${SELF_ISSUER} secretKey: ${SECRET_KEY} -cloud: - aws: - credentials: - access-key: ${AWS_ACCESS_KEY_ID} - secret-key: ${AWS_SECRET_ACCESS_KEY} - region: - static: ${AWS_REGION} - s3: - bucket: ${AWS_TEST_RESOURCE_BUCKET} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index bd3381c6..e6bb01fd 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -46,6 +46,17 @@ spring: auth: true starttls: enable: true + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY_ID} + secret-key: ${AWS_SECRET_ACCESS_KEY} + region: + static: ${AWS_REGION} + +app: + s3: + bucket: ${AWS_RESOURCE_BUCKET} logging: level: @@ -64,12 +75,3 @@ jwt: selfIssuer: ${SELF_ISSUER} secretKey: ${SECRET_KEY} -cloud: - aws: - credentials: - access-key: ${AWS_ACCESS_KEY_ID} - secret-key: ${AWS_SECRET_ACCESS_KEY} - region: - static: ${AWS_REGION} - s3: - bucket: ${AWS_RESOURCE_BUCKET} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index f9473ec3..50c68664 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -46,6 +46,18 @@ spring: main: allow-bean-definition-overriding: true + cloud: + aws: + credentials: + access-key: test + secret-key: test + region: + static: ap-northeast-2 + +app: + s3: + bucket: test-bucket + logging: level: org.hibernate.SQL: warn @@ -61,12 +73,3 @@ jwt: selfIssuer: test-self-issuer secretKey: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= -cloud: - aws: - credentials: - access-key: test - secret-key: test - region: - static: ap-northeast-2 - s3: - bucket: test-bucket From 9ba085f840ca63fe3d52b27e30706c17b7a35d80 Mon Sep 17 00:00:00 2001 From: CSE-Shaco Date: Fri, 9 Jan 2026 05:06:23 +0900 Subject: [PATCH 03/49] Chore: Align Java version across Docker, Gradle, and build configuration --- Dockerfile | 4 ++-- build.gradle | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 215970ae..cf648143 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 # --- Build stage --- -FROM gradle:8.11.1-jdk17 AS build +FROM eclipse-temurin:21-jdk AS build WORKDIR /app # 루트 프로젝트 전체 복사 @@ -18,7 +18,7 @@ RUN ./gradlew clean bootJar -x test --no-daemon RUN cp "$(ls build/libs/*.jar | head -n 1)" build/libs/app.jar # --- Runtime stage --- -FROM eclipse-temurin:17-jre +FROM eclipse-temurin:21-jre WORKDIR /app COPY --from=build /app/build/libs/app.jar app.jar diff --git a/build.gradle b/build.gradle index 2a1615c3..6822e0bc 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ version = '0.0.1-SNAPSHOT' java { toolchain { - languageVersion = JavaLanguageVersion.of(25) + languageVersion = JavaLanguageVersion.of(21) } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2e111328..e2847c82 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From faf2d12872e6a15b8b3fad9f5f283a71eb57b9c6 Mon Sep 17 00:00:00 2001 From: CSE-Shaco Date: Fri, 9 Jan 2026 06:40:48 +0900 Subject: [PATCH 04/49] Refactor: Update JWT token handling and adjust S3 configuration --- .github/workflows/ci.yml | 2 +- .../global/config/jwt/TokenProvider.java | 3 ++- .../inha/gdgoc/global/config/s3/S3Config.java | 27 +++++++++---------- src/main/resources/application-local.yml | 4 +-- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbd455a6..66d4a3d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/setup-java@v4 with: distribution: temurin - java-version: 17 + java-version: 21 - uses: gradle/actions/setup-gradle@v3 if: ${{ !env.ACT }} diff --git a/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java b/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java index 124a9e8d..999ef790 100644 --- a/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java +++ b/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java @@ -106,6 +106,7 @@ private String makeToken(Date expiry, User user, LoginType loginType) { private Claims getClaims(String token) { return Jwts.parser() .setSigningKey(Base64.getEncoder().encodeToString(jwtProperties.getSecretKey().getBytes())) + .build() .parseClaimsJws(token) .getBody(); } @@ -124,4 +125,4 @@ public CustomUserDetails(Long userId, String username, String password, Collecti this.team = team; } } -} \ No newline at end of file +} diff --git a/src/main/java/inha/gdgoc/global/config/s3/S3Config.java b/src/main/java/inha/gdgoc/global/config/s3/S3Config.java index fef0a06b..14d27a0d 100644 --- a/src/main/java/inha/gdgoc/global/config/s3/S3Config.java +++ b/src/main/java/inha/gdgoc/global/config/s3/S3Config.java @@ -11,21 +11,18 @@ @Configuration public class S3Config { - @Bean - public Region awsRegion(@Value("${cloud.aws.region.static}") String region) { - return Region.of(region); - } + @Bean + public Region awsRegion(@Value("${spring.cloud.aws.region.static}") String region) { + return Region.of(region); + } - @Bean - public AwsCredentialsProvider awsCredentialsProvider() { - return DefaultCredentialsProvider.create(); - } + @Bean + public AwsCredentialsProvider awsCredentialsProvider() { + return DefaultCredentialsProvider.create(); + } - @Bean - public S3Client s3Client(Region region, AwsCredentialsProvider provider) { - return S3Client.builder() - .region(region) - .credentialsProvider(provider) - .build(); - } + @Bean + public S3Client s3Client(Region region, AwsCredentialsProvider provider) { + return S3Client.builder().region(region).credentialsProvider(provider).build(); + } } diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index d43d8f17..319903a3 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -10,8 +10,8 @@ spring: jackson: time-zone: Asia/Seoul datasource: - url: jdbc:postgresql://localhost:5432/gdgoc - username: postgres + url: "jdbc:postgresql://localhost:5432/gdgoc" + username: "postgres" password: servlet: multipart: From 634103c768f61cac8316257d138046d12cc0399d Mon Sep 17 00:00:00 2001 From: CSE-Shaco Date: Fri, 9 Jan 2026 07:38:19 +0900 Subject: [PATCH 05/49] =?UTF-8?q?Chore:=20local=20profile=20env=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-local.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 319903a3..331d2223 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -10,9 +10,9 @@ spring: jackson: time-zone: Asia/Seoul datasource: - url: "jdbc:postgresql://localhost:5432/gdgoc" - username: "postgres" - password: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} servlet: multipart: max-file-size: 10MB From 461a3e8cc3122684bbc352407c0b9d8d2d8e378e Mon Sep 17 00:00:00 2001 From: CSE-Shaco Date: Fri, 9 Jan 2026 07:51:09 +0900 Subject: [PATCH 06/49] =?UTF-8?q?Chore:=20=EB=A1=9C=EC=BB=AC=20=EB=94=94?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EA=B0=80=EB=8A=A5=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20prod=20=EB=A1=9C=EA=B7=B8=20=EB=85=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=EC=A1=B0=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-local.yml | 2 +- src/main/resources/application-prod.yml | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 331d2223..96971d34 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -65,7 +65,7 @@ google: logging: level: org.hibernate.SQL: debug - org.hibername.type: trace + org.hibernate.type: trace jwt: googleIssuer: ${GOOGLE_ISSUER} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index e6bb01fd..d71a1dda 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -61,8 +61,7 @@ app: logging: level: org.hibernate.SQL: debug - org.hibernate.orm.jdbc.bind: trace - org.hibernate.type: trace + org.hibernate.type: off google: From 11ee30f1bde868c0afa3f56b5134b4170b442731 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Sat, 10 Jan 2026 16:32:02 +0900 Subject: [PATCH 07/49] =?UTF-8?q?Chore:=20redis=20=EB=8F=84=EC=9E=85?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 17 +++++++++-------- src/main/resources/application.yml | 8 +++++++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/build.gradle b/build.gradle index 6822e0bc..039a1911 100644 --- a/build.gradle +++ b/build.gradle @@ -36,13 +36,13 @@ ext { jjwtVersion = '0.13.0' } -/* ===== Vulnerability Pins (Mend 경고 제거용) ===== */ +/* ===== Vulnerability Pins (정확한 CVE 기반 대응) ===== */ configurations.configureEach { resolutionStrategy { - // CVE-2025-48924 대응 + // CVE-2024-47554 대응 (commons-compress 체인 영향 → commons-lang3 3.18.0 필요) force 'org.apache.commons:commons-lang3:3.18.0' - // CVE-2021-47621 대응 - force 'io.github.classgraph:classgraph:4.8.112' + // springdoc transitive classgraph 취약점 대응 버전으로 상향 + force 'io.github.classgraph:classgraph:4.8.179' } } @@ -60,10 +60,13 @@ dependencies { } implementation "org.apache.commons:commons-lang3:3.18.0" - // --- DB & JPA Utils --- + // --- DB & JPA Utils (Boot 관리 버전과 동기화 필요) --- implementation "com.vladmihalcea:hibernate-types-60:${hibernateTypes60}" implementation "org.postgresql:postgresql:${postgresVersion}" - runtimeOnly 'com.h2database:h2' + testRuntimeOnly 'com.h2database:h2' // 테스트 환경 전용, 운영 패키지에서는 제외 + + // --- Cache / Redis --- + implementation 'org.springframework.boot:spring-boot-starter-data-redis' // --- Querydsl (OpenFeign fork) --- implementation "io.github.openfeign.querydsl:querydsl-jpa:${querydslVersion}" @@ -122,5 +125,3 @@ tasks.test { tasks.withType(JavaCompile).configureEach { options.generatedSourceOutputDirectory.set(file("build/generated/sources/annotationProcessor/java/main")) } - - diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ef46c2ad..d2b0275f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,9 @@ spring: profiles: - active: local \ No newline at end of file + active: local + data: + redis: + host: 127.0.0.1 + port: 6379 + password: ${REDIS_PASSWORD} + timeout: 2s \ No newline at end of file From 749fb53f15210bac6d0dfed90b46686db8fe07e4 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 15 Jan 2026 02:02:56 +0900 Subject: [PATCH 08/49] feat: add core recruit application workflows --- .../RecruitCoreAdminController.java | 91 +++++++++ .../RecruitCoreApplicationAcceptRequest.java | 10 + .../RecruitCoreApplicationRejectRequest.java | 8 + .../RecruitCoreApplicantSummaryResponse.java | 30 +++ ...ecruitCoreApplicationDecisionResponse.java | 44 +++++ .../RecruitCoreApplicationPageResponse.java | 31 ++++ .../core/service/RecruitCoreAdminService.java | 114 ++++++++++++ .../core/config/RecruitCoreProperties.java | 25 +++ .../controller/RecruitCoreController.java | 90 +++++++++ .../RecruitCoreApplicationMessage.java | 7 + .../RecruitCoreApplicationCreateRequest.java | 28 +++ .../RecruitCoreApplicantDetailResponse.java | 41 +++++ .../RecruitCoreApplicationCreateResponse.java | 22 +++ .../RecruitCoreApplicationErrorResponse.java | 29 +++ .../RecruitCoreApplicationReviewResponse.java | 26 +++ ...ecruitCoreApplicationSnapshotResponse.java | 22 +++ .../RecruitCoreEligibilityResponse.java | 20 ++ .../RecruitCoreMyApplicationResponse.java | 26 +++ .../response/RecruitCorePrefillResponse.java | 22 +++ .../core/entity/RecruitCoreApplication.java | 122 ++++++++++++ .../core/enums/RecruitCoreResultStatus.java | 8 + .../RecruitCoreAlreadyAppliedException.java | 18 ++ .../RecruitCoreApplicationErrorCode.java | 20 ++ ...cruitCoreApplicationNotFoundException.java | 13 ++ ...RecruitCoreControllerExceptionHandler.java | 42 +++++ .../RecruitCoreApplicationRepository.java | 17 ++ .../RecruitCoreApplicationService.java | 119 ++++++++++++ ...ecruit_applications_session_and_status.sql | 32 ++++ .../service/RecruitCoreAdminServiceTest.java | 173 ++++++++++++++++++ 29 files changed, 1250 insertions(+) create mode 100644 src/main/java/inha/gdgoc/domain/admin/recruit/core/controller/RecruitCoreAdminController.java create mode 100644 src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/request/RecruitCoreApplicationAcceptRequest.java create mode 100644 src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/request/RecruitCoreApplicationRejectRequest.java create mode 100644 src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicantSummaryResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicationDecisionResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicationPageResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminService.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/config/RecruitCoreProperties.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/controller/RecruitCoreController.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/controller/message/RecruitCoreApplicationMessage.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/dto/request/RecruitCoreApplicationCreateRequest.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicantDetailResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationCreateResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationErrorResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationReviewResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationSnapshotResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreEligibilityResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreMyApplicationResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCorePrefillResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/entity/RecruitCoreApplication.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/enums/RecruitCoreResultStatus.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreAlreadyAppliedException.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreApplicationErrorCode.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreApplicationNotFoundException.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreControllerExceptionHandler.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/repository/RecruitCoreApplicationRepository.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java create mode 100644 src/main/resources/db/migration/V20260114__core_recruit_applications_session_and_status.sql create mode 100644 src/test/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminServiceTest.java diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/core/controller/RecruitCoreAdminController.java b/src/main/java/inha/gdgoc/domain/admin/recruit/core/controller/RecruitCoreAdminController.java new file mode 100644 index 00000000..8f9e2985 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/core/controller/RecruitCoreAdminController.java @@ -0,0 +1,91 @@ +package inha.gdgoc.domain.admin.recruit.core.controller; + +import inha.gdgoc.domain.admin.recruit.core.dto.request.RecruitCoreApplicationAcceptRequest; +import inha.gdgoc.domain.admin.recruit.core.dto.request.RecruitCoreApplicationRejectRequest; +import inha.gdgoc.domain.admin.recruit.core.dto.response.RecruitCoreApplicantSummaryResponse; +import inha.gdgoc.domain.admin.recruit.core.dto.response.RecruitCoreApplicationDecisionResponse; +import inha.gdgoc.domain.admin.recruit.core.dto.response.RecruitCoreApplicationPageResponse; +import inha.gdgoc.domain.admin.recruit.core.service.RecruitCoreAdminService; +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import inha.gdgoc.domain.recruit.core.enums.RecruitCoreResultStatus; +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.global.config.jwt.TokenProvider.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/recruit/core/applications") +@RequiredArgsConstructor +public class RecruitCoreAdminController { + + private static final String ORGANIZER_OR_HR_LEAD_RULE = + "@accessGuard.check(authentication," + + " T(inha.gdgoc.global.security.AccessGuard.AccessCondition).atLeast(" + + "T(inha.gdgoc.domain.user.enums.UserRole).ORGANIZER)," + + " T(inha.gdgoc.global.security.AccessGuard.AccessCondition).of(" + + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD," + + " T(inha.gdgoc.domain.user.enums.TeamType).HR))"; + + private final RecruitCoreAdminService adminService; + + @PreAuthorize("hasAnyRole('ADMIN','ORGANIZER')") + @GetMapping + public RecruitCoreApplicationPageResponse list( + @RequestParam String session, + @RequestParam(required = false) RecruitCoreResultStatus status, + @RequestParam(required = false) TeamType team, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Pageable pageable = PageRequest.of(page, size); + Page result = adminService.searchApplications(session, status, team, pageable); + java.util.List content = result + .map(RecruitCoreApplicantSummaryResponse::from) + .getContent(); + return RecruitCoreApplicationPageResponse.from( + content, + result.getNumber(), + result.getSize(), + result.getTotalElements(), + result.getTotalPages(), + result.isLast() + ); + } + + @PreAuthorize(ORGANIZER_OR_HR_LEAD_RULE) + @PostMapping("/{applicationId}/accept") + public ResponseEntity accept( + @AuthenticationPrincipal CustomUserDetails reviewer, + @PathVariable Long applicationId, + @Valid @RequestBody RecruitCoreApplicationAcceptRequest request + ) { + RecruitCoreApplicationDecisionResponse response = + adminService.accept(applicationId, reviewer.getUserId(), request); + return ResponseEntity.ok(response); + } + + @PreAuthorize(ORGANIZER_OR_HR_LEAD_RULE) + @PostMapping("/{applicationId}/reject") + public ResponseEntity reject( + @AuthenticationPrincipal CustomUserDetails reviewer, + @PathVariable Long applicationId, + @Valid @RequestBody RecruitCoreApplicationRejectRequest request + ) { + RecruitCoreApplicationDecisionResponse response = + adminService.reject(applicationId, reviewer.getUserId(), request); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/request/RecruitCoreApplicationAcceptRequest.java b/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/request/RecruitCoreApplicationAcceptRequest.java new file mode 100644 index 00000000..276fb13a --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/request/RecruitCoreApplicationAcceptRequest.java @@ -0,0 +1,10 @@ +package inha.gdgoc.domain.admin.recruit.core.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record RecruitCoreApplicationAcceptRequest( + @NotBlank String resultNote, + @NotNull Boolean overwriteTeamIfExists +) { +} diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/request/RecruitCoreApplicationRejectRequest.java b/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/request/RecruitCoreApplicationRejectRequest.java new file mode 100644 index 00000000..656f6ac9 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/request/RecruitCoreApplicationRejectRequest.java @@ -0,0 +1,8 @@ +package inha.gdgoc.domain.admin.recruit.core.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record RecruitCoreApplicationRejectRequest( + @NotBlank String resultNote +) { +} diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicantSummaryResponse.java b/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicantSummaryResponse.java new file mode 100644 index 00000000..8babc286 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicantSummaryResponse.java @@ -0,0 +1,30 @@ +package inha.gdgoc.domain.admin.recruit.core.dto.response; + +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import inha.gdgoc.domain.recruit.core.enums.RecruitCoreResultStatus; +import java.time.Instant; + +public record RecruitCoreApplicantSummaryResponse( + Long applicationId, + String name, + String studentId, + String major, + String team, + RecruitCoreResultStatus resultStatus, + String session, + Instant createdAt +) { + + public static RecruitCoreApplicantSummaryResponse from(RecruitCoreApplication entity) { + return new RecruitCoreApplicantSummaryResponse( + entity.getId(), + entity.getName(), + entity.getStudentId(), + entity.getMajor(), + entity.getTeam(), + entity.getResultStatus(), + entity.getSession(), + entity.getCreatedAt() + ); + } +} diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicationDecisionResponse.java b/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicationDecisionResponse.java new file mode 100644 index 00000000..6d47f6e2 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicationDecisionResponse.java @@ -0,0 +1,44 @@ +package inha.gdgoc.domain.admin.recruit.core.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import inha.gdgoc.domain.recruit.core.enums.RecruitCoreResultStatus; +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; +import java.time.Instant; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record RecruitCoreApplicationDecisionResponse( + Long applicationId, + RecruitCoreResultStatus resultStatus, + Instant reviewedAt, + Long reviewedBy, + UserUpdated userUpdated +) { + + public static RecruitCoreApplicationDecisionResponse accepted( + RecruitCoreApplication application, + UserRole userRole, + TeamType team + ) { + return new RecruitCoreApplicationDecisionResponse( + application.getId(), + application.getResultStatus(), + application.getReviewedAt(), + application.getReviewedBy(), + new UserUpdated(userRole, team) + ); + } + + public static RecruitCoreApplicationDecisionResponse rejected(RecruitCoreApplication application) { + return new RecruitCoreApplicationDecisionResponse( + application.getId(), + application.getResultStatus(), + application.getReviewedAt(), + application.getReviewedBy(), + null + ); + } + + public record UserUpdated(UserRole userRole, TeamType team) {} +} diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicationPageResponse.java b/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicationPageResponse.java new file mode 100644 index 00000000..02ce05cc --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicationPageResponse.java @@ -0,0 +1,31 @@ +package inha.gdgoc.domain.admin.recruit.core.dto.response; + +import java.util.List; + +public record RecruitCoreApplicationPageResponse( + List content, + Pageable pageable, + long totalElements, + int totalPages, + boolean last +) { + + public static RecruitCoreApplicationPageResponse from( + List items, + int pageNumber, + int pageSize, + long totalElements, + int totalPages, + boolean last + ) { + return new RecruitCoreApplicationPageResponse( + items, + new Pageable(pageNumber, pageSize), + totalElements, + totalPages, + last + ); + } + + public record Pageable(int pageNumber, int pageSize) {} +} diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminService.java b/src/main/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminService.java new file mode 100644 index 00000000..2b6a41f4 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminService.java @@ -0,0 +1,114 @@ +package inha.gdgoc.domain.admin.recruit.core.service; + +import inha.gdgoc.domain.admin.recruit.core.dto.request.RecruitCoreApplicationAcceptRequest; +import inha.gdgoc.domain.admin.recruit.core.dto.request.RecruitCoreApplicationRejectRequest; +import inha.gdgoc.domain.admin.recruit.core.dto.response.RecruitCoreApplicationDecisionResponse; +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import inha.gdgoc.domain.recruit.core.enums.RecruitCoreResultStatus; +import inha.gdgoc.domain.recruit.core.repository.RecruitCoreApplicationRepository; +import inha.gdgoc.domain.user.entity.User; +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; +import inha.gdgoc.global.exception.BusinessException; +import inha.gdgoc.global.exception.GlobalErrorCode; +import java.time.Instant; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class RecruitCoreAdminService { + + private final RecruitCoreApplicationRepository repository; + + @Transactional(readOnly = true) + public Page searchApplications( + String session, + RecruitCoreResultStatus status, + TeamType team, + Pageable pageable + ) { + Specification spec = Specification.where(bySession(session)); + if (status != null) { + spec = spec.and((root, query, builder) -> builder.equal(root.get("resultStatus"), status)); + } + if (team != null) { + spec = spec.and((root, query, builder) -> builder.equal(root.get("team"), team.name())); + } + return repository.findAll(spec, pageable); + } + + @Transactional + public RecruitCoreApplicationDecisionResponse accept( + Long applicationId, + Long reviewerId, + RecruitCoreApplicationAcceptRequest request + ) { + RecruitCoreApplication application = getApplication(applicationId); + ensureDecidable(application); + Instant now = Instant.now(); + application.accept(reviewerId, request.resultNote(), now); + + User applicant = application.getUser(); + if (!UserRole.hasAtLeast(applicant.getUserRole(), UserRole.CORE)) { + applicant.changeRole(UserRole.CORE); + } + TeamType applicantTeam = applicant.getTeam(); + TeamType applicationTeam = teamTypeOf(application.getTeam()); + if (applicationTeam != null && (Boolean.TRUE.equals(request.overwriteTeamIfExists()) || applicantTeam == null)) { + applicant.changeTeam(applicationTeam); + applicantTeam = applicationTeam; + } + + return RecruitCoreApplicationDecisionResponse.accepted( + application, + applicant.getUserRole(), + applicantTeam + ); + } + + @Transactional + public RecruitCoreApplicationDecisionResponse reject( + Long applicationId, + Long reviewerId, + RecruitCoreApplicationRejectRequest request + ) { + RecruitCoreApplication application = getApplication(applicationId); + ensureDecidable(application); + Instant now = Instant.now(); + application.reject(reviewerId, request.resultNote(), now); + return RecruitCoreApplicationDecisionResponse.rejected(application); + } + + private Specification bySession(String session) { + return (root, query, builder) -> builder.equal(root.get("session"), Objects.requireNonNull(session)); + } + + private RecruitCoreApplication getApplication(Long id) { + return repository.findById(id) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND)); + } + + private void ensureDecidable(RecruitCoreApplication application) { + if (application.getResultStatus() == RecruitCoreResultStatus.ACCEPTED + || application.getResultStatus() == RecruitCoreResultStatus.REJECTED) { + throw new BusinessException(GlobalErrorCode.BAD_REQUEST, "이미 처리된 지원서입니다."); + } + } + + private TeamType teamTypeOf(String team) { + if (team == null) { + return null; + } + try { + return TeamType.valueOf(team); + } catch (IllegalArgumentException ex) { + return null; + } + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/config/RecruitCoreProperties.java b/src/main/java/inha/gdgoc/domain/recruit/core/config/RecruitCoreProperties.java new file mode 100644 index 00000000..2462bb51 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/config/RecruitCoreProperties.java @@ -0,0 +1,25 @@ +package inha.gdgoc.domain.recruit.core.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties("recruit.core") +public class RecruitCoreProperties { + + /** + * 현재 모집 회차 (예: 2026-1). 환경 설정에서 주입된다. + */ + private String session; + + public String currentSession() { + if (session == null || session.isBlank()) { + throw new IllegalStateException("recruit.core.session 값이 설정되지 않았습니다."); + } + return session; + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/controller/RecruitCoreController.java b/src/main/java/inha/gdgoc/domain/recruit/core/controller/RecruitCoreController.java new file mode 100644 index 00000000..50c1fbcf --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/controller/RecruitCoreController.java @@ -0,0 +1,90 @@ +package inha.gdgoc.domain.recruit.core.controller; + +import inha.gdgoc.domain.recruit.core.dto.request.RecruitCoreApplicationCreateRequest; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreApplicantDetailResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreApplicationCreateResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreEligibilityResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreMyApplicationResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCorePrefillResponse; +import inha.gdgoc.domain.recruit.core.service.RecruitCoreApplicationService; +import inha.gdgoc.global.config.jwt.TokenProvider.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Recruit Core - Guest", description = "운영진 리크루팅 지원 API") +@RestController +@RequestMapping("/api/v1/recruit/core") +@RequiredArgsConstructor +public class RecruitCoreController { + + private final RecruitCoreApplicationService service; + + @Operation(summary = "지원 가능 여부 확인", security = {@SecurityRequirement(name = "BearerAuth")}) + @PreAuthorize("isAuthenticated()") + @GetMapping("/eligibility") + public ResponseEntity eligibility( + @AuthenticationPrincipal CustomUserDetails me + ) { + RecruitCoreEligibilityResponse response = service.checkEligibility(me.getUserId()); + return ResponseEntity.ok(response); + } + + @Operation(summary = "지원서 기본 정보 자동 채움", security = {@SecurityRequirement(name = "BearerAuth")}) + @PreAuthorize("isAuthenticated()") + @GetMapping("/prefill") + public ResponseEntity prefill( + @AuthenticationPrincipal CustomUserDetails me + ) { + RecruitCorePrefillResponse response = service.prefill(me.getUserId()); + return ResponseEntity.ok(response); + } + + @Operation(summary = "운영진 지원서 제출", security = {@SecurityRequirement(name = "BearerAuth")}) + @PreAuthorize("isAuthenticated()") + @PostMapping("/applications") + public ResponseEntity submit( + @AuthenticationPrincipal CustomUserDetails me, + @Valid @RequestBody RecruitCoreApplicationCreateRequest request + ) { + RecruitCoreApplicationCreateResponse response = service.submit(me.getUserId(), request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @Operation(summary = "나의 지원서 조회", security = {@SecurityRequirement(name = "BearerAuth")}) + @PreAuthorize("isAuthenticated()") + @GetMapping("/applications/me") + public ResponseEntity myApplication( + @AuthenticationPrincipal CustomUserDetails me + ) { + RecruitCoreMyApplicationResponse response = service.getMyApplication(me.getUserId()); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "지원서 상세 조회 (본인/운영진)", + security = {@SecurityRequirement(name = "BearerAuth")} + ) + @PreAuthorize("isAuthenticated()") + @GetMapping("/applications/{applicationId}") + public ResponseEntity getApplication( + @AuthenticationPrincipal CustomUserDetails me, + @PathVariable Long applicationId + ) { + RecruitCoreApplicantDetailResponse response = + service.getApplicantDetailForViewer(applicationId, me.getUserId(), me.getRole()); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/controller/message/RecruitCoreApplicationMessage.java b/src/main/java/inha/gdgoc/domain/recruit/core/controller/message/RecruitCoreApplicationMessage.java new file mode 100644 index 00000000..e94ae515 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/controller/message/RecruitCoreApplicationMessage.java @@ -0,0 +1,7 @@ +package inha.gdgoc.domain.recruit.core.controller.message; + +public class RecruitCoreApplicationMessage { + public static final String APPLICANT_LIST_RETRIEVED_SUCCESS = "성공적으로 코어 리쿠르트 지원자 목록을 조회했습니다."; + public static final String APPLICANT_RETRIEVED_SUCCESS = "성공적으로 코어 리쿠르트 지원자 상세를 조회했습니다."; +} + diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/dto/request/RecruitCoreApplicationCreateRequest.java b/src/main/java/inha/gdgoc/domain/recruit/core/dto/request/RecruitCoreApplicationCreateRequest.java new file mode 100644 index 00000000..dc946b04 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/dto/request/RecruitCoreApplicationCreateRequest.java @@ -0,0 +1,28 @@ +package inha.gdgoc.domain.recruit.core.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; + +public record RecruitCoreApplicationCreateRequest( + @Valid @NotNull RecruitCoreApplicationSnapshotRequest snapshot, + @NotBlank String team, + @NotBlank String motivation, + @NotBlank String wish, + @NotBlank String strengths, + @NotBlank String pledge, + @NotNull @Size(min = 0) List<@NotBlank String> fileUrls +) { + + public record RecruitCoreApplicationSnapshotRequest( + @NotBlank String name, + @NotBlank String studentId, + @NotBlank String phone, + @NotBlank String major, + @NotBlank @Email String email + ) { + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicantDetailResponse.java b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicantDetailResponse.java new file mode 100644 index 00000000..55bbcda1 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicantDetailResponse.java @@ -0,0 +1,41 @@ +package inha.gdgoc.domain.recruit.core.dto.response; + +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import inha.gdgoc.domain.recruit.core.enums.RecruitCoreResultStatus; +import java.time.Instant; +import java.util.List; + +public record RecruitCoreApplicantDetailResponse( + Long applicationId, + String session, + RecruitCoreApplicationSnapshotResponse snapshot, + String team, + String motivation, + String wish, + String strengths, + String pledge, + List fileUrls, + RecruitCoreResultStatus resultStatus, + RecruitCoreApplicationReviewResponse review, + Instant createdAt, + Instant updatedAt +) { + + public static RecruitCoreApplicantDetailResponse from(RecruitCoreApplication entity) { + return new RecruitCoreApplicantDetailResponse( + entity.getId(), + entity.getSession(), + RecruitCoreApplicationSnapshotResponse.from(entity), + entity.getTeam(), + entity.getMotivation(), + entity.getWish(), + entity.getStrengths(), + entity.getPledge(), + entity.getFileUrls(), + entity.getResultStatus(), + RecruitCoreApplicationReviewResponse.from(entity), + entity.getCreatedAt(), + entity.getUpdatedAt() + ); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationCreateResponse.java b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationCreateResponse.java new file mode 100644 index 00000000..f61236e2 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationCreateResponse.java @@ -0,0 +1,22 @@ +package inha.gdgoc.domain.recruit.core.dto.response; + +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import inha.gdgoc.domain.recruit.core.enums.RecruitCoreResultStatus; +import java.time.Instant; + +public record RecruitCoreApplicationCreateResponse( + Long applicationId, + String session, + RecruitCoreResultStatus resultStatus, + Instant submittedAt +) { + + public static RecruitCoreApplicationCreateResponse from(RecruitCoreApplication application) { + return new RecruitCoreApplicationCreateResponse( + application.getId(), + application.getSession(), + application.getResultStatus(), + application.getCreatedAt() + ); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationErrorResponse.java b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationErrorResponse.java new file mode 100644 index 00000000..fe5b5167 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationErrorResponse.java @@ -0,0 +1,29 @@ +package inha.gdgoc.domain.recruit.core.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record RecruitCoreApplicationErrorResponse( + String code, + String message, + Details details +) { + + public static RecruitCoreApplicationErrorResponse of( + String code, + String message + ) { + return new RecruitCoreApplicationErrorResponse(code, message, null); + } + + public static RecruitCoreApplicationErrorResponse of( + String code, + String message, + String session, + Long applicationId + ) { + return new RecruitCoreApplicationErrorResponse(code, message, new Details(session, applicationId)); + } + + public record Details(String session, Long applicationId) {} +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationReviewResponse.java b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationReviewResponse.java new file mode 100644 index 00000000..bfa101a0 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationReviewResponse.java @@ -0,0 +1,26 @@ +package inha.gdgoc.domain.recruit.core.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import java.time.Instant; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record RecruitCoreApplicationReviewResponse( + Instant reviewedAt, + Long reviewedBy, + String resultNote +) { + + public static RecruitCoreApplicationReviewResponse from(RecruitCoreApplication application) { + if (application.getReviewedAt() == null + && application.getReviewedBy() == null + && application.getResultNote() == null) { + return new RecruitCoreApplicationReviewResponse(null, null, null); + } + return new RecruitCoreApplicationReviewResponse( + application.getReviewedAt(), + application.getReviewedBy(), + application.getResultNote() + ); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationSnapshotResponse.java b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationSnapshotResponse.java new file mode 100644 index 00000000..3a583eb7 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationSnapshotResponse.java @@ -0,0 +1,22 @@ +package inha.gdgoc.domain.recruit.core.dto.response; + +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; + +public record RecruitCoreApplicationSnapshotResponse( + String name, + String studentId, + String phone, + String major, + String email +) { + + public static RecruitCoreApplicationSnapshotResponse from(RecruitCoreApplication application) { + return new RecruitCoreApplicationSnapshotResponse( + application.getName(), + application.getStudentId(), + application.getPhone(), + application.getMajor(), + application.getEmail() + ); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreEligibilityResponse.java b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreEligibilityResponse.java new file mode 100644 index 00000000..81eae377 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreEligibilityResponse.java @@ -0,0 +1,20 @@ +package inha.gdgoc.domain.recruit.core.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record RecruitCoreEligibilityResponse( + boolean eligible, + String session, + String reason, + Long applicationId +) { + + public static RecruitCoreEligibilityResponse eligible(String session) { + return new RecruitCoreEligibilityResponse(true, session, null, null); + } + + public static RecruitCoreEligibilityResponse ineligible(String session, String reason, Long applicationId) { + return new RecruitCoreEligibilityResponse(false, session, reason, applicationId); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreMyApplicationResponse.java b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreMyApplicationResponse.java new file mode 100644 index 00000000..51becba4 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreMyApplicationResponse.java @@ -0,0 +1,26 @@ +package inha.gdgoc.domain.recruit.core.dto.response; + +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import inha.gdgoc.domain.recruit.core.enums.RecruitCoreResultStatus; +import java.time.Instant; + +public record RecruitCoreMyApplicationResponse( + Long applicationId, + String session, + String team, + RecruitCoreResultStatus resultStatus, + Instant createdAt, + Instant updatedAt +) { + + public static RecruitCoreMyApplicationResponse from(RecruitCoreApplication application) { + return new RecruitCoreMyApplicationResponse( + application.getId(), + application.getSession(), + application.getTeam(), + application.getResultStatus(), + application.getCreatedAt(), + application.getUpdatedAt() + ); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCorePrefillResponse.java b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCorePrefillResponse.java new file mode 100644 index 00000000..dae4cd5b --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCorePrefillResponse.java @@ -0,0 +1,22 @@ +package inha.gdgoc.domain.recruit.core.dto.response; + +import inha.gdgoc.domain.user.entity.User; + +public record RecruitCorePrefillResponse( + String name, + String studentId, + String phone, + String major, + String email +) { + + public static RecruitCorePrefillResponse from(User user) { + return new RecruitCorePrefillResponse( + user.getName(), + user.getStudentId(), + user.getPhoneNumber(), + user.getMajor(), + user.getEmail() + ); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/entity/RecruitCoreApplication.java b/src/main/java/inha/gdgoc/domain/recruit/core/entity/RecruitCoreApplication.java new file mode 100644 index 00000000..85d50c31 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/entity/RecruitCoreApplication.java @@ -0,0 +1,122 @@ +package inha.gdgoc.domain.recruit.core.entity; + +import com.vladmihalcea.hibernate.type.json.JsonType; +import inha.gdgoc.domain.recruit.core.enums.RecruitCoreResultStatus; +import inha.gdgoc.domain.user.entity.User; +import inha.gdgoc.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Type; + +@Entity +@Table(name = "core_recruit_applications") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class RecruitCoreApplication extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "session", nullable = false, length = 32) + private String session; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "student_id", nullable = false) + private String studentId; + + @Column(name = "phone", nullable = false) + private String phone; + + @Column(name = "major", nullable = false) + private String major; + + @Column(name = "email", nullable = false) + private String email; + + @Column(name = "team", nullable = false) + private String team; + + @Column(name = "motivation", nullable = false, columnDefinition = "text") + private String motivation; + + @Column(name = "wish", nullable = false, columnDefinition = "text") + private String wish; + + @Column(name = "strengths", nullable = false, columnDefinition = "text") + private String strengths; + + @Column(name = "pledge", nullable = false, columnDefinition = "text") + private String pledge; + + @Type(JsonType.class) + @Column(name = "file_urls", nullable = false, columnDefinition = "jsonb") + private List fileUrls; + + @Builder.Default + @Enumerated(EnumType.STRING) + @Column(name = "result_status", nullable = false, length = 32) + private RecruitCoreResultStatus resultStatus = RecruitCoreResultStatus.SUBMITTED; + + @Column(name = "reviewed_at") + private Instant reviewedAt; + + @Column(name = "reviewed_by") + private Long reviewedBy; + + @Column(name = "result_note", columnDefinition = "text") + private String resultNote; + + public Long getId() { + return id; + } + + public boolean isOwnedBy(Long userId) { + return userId != null && user != null && userId.equals(user.getId()); + } + + public void accept(Long reviewerId, String note, Instant reviewedAt) { + this.resultStatus = RecruitCoreResultStatus.ACCEPTED; + this.reviewedAt = reviewedAt; + this.reviewedBy = reviewerId; + this.resultNote = note; + } + + public void reject(Long reviewerId, String note, Instant reviewedAt) { + this.resultStatus = RecruitCoreResultStatus.REJECTED; + this.reviewedAt = reviewedAt; + this.reviewedBy = reviewerId; + this.resultNote = note; + } + + public void moveToReview(Long reviewerId, Instant reviewedAt) { + this.resultStatus = RecruitCoreResultStatus.IN_REVIEW; + this.reviewedAt = reviewedAt; + this.reviewedBy = reviewerId; + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/enums/RecruitCoreResultStatus.java b/src/main/java/inha/gdgoc/domain/recruit/core/enums/RecruitCoreResultStatus.java new file mode 100644 index 00000000..33f56f9c --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/enums/RecruitCoreResultStatus.java @@ -0,0 +1,8 @@ +package inha.gdgoc.domain.recruit.core.enums; + +public enum RecruitCoreResultStatus { + SUBMITTED, + IN_REVIEW, + ACCEPTED, + REJECTED +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreAlreadyAppliedException.java b/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreAlreadyAppliedException.java new file mode 100644 index 00000000..ef91aeb2 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreAlreadyAppliedException.java @@ -0,0 +1,18 @@ +package inha.gdgoc.domain.recruit.core.exception; + +import lombok.Getter; + +@Getter +public class RecruitCoreAlreadyAppliedException extends RuntimeException { + + private final RecruitCoreApplicationErrorCode errorCode; + private final String session; + private final Long applicationId; + + public RecruitCoreAlreadyAppliedException(String session, Long applicationId) { + super(RecruitCoreApplicationErrorCode.ALREADY_APPLIED.getMessage()); + this.errorCode = RecruitCoreApplicationErrorCode.ALREADY_APPLIED; + this.session = session; + this.applicationId = applicationId; + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreApplicationErrorCode.java b/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreApplicationErrorCode.java new file mode 100644 index 00000000..5287f021 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreApplicationErrorCode.java @@ -0,0 +1,20 @@ +package inha.gdgoc.domain.recruit.core.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum RecruitCoreApplicationErrorCode { + ALREADY_APPLIED("ALREADY_APPLIED", "이미 지원이 완료되었습니다.", HttpStatus.CONFLICT), + APPLICATION_NOT_FOUND("APPLICATION_NOT_FOUND", "제출된 운영진 지원서가 없습니다.", HttpStatus.NOT_FOUND); + + private final String code; + private final String message; + private final HttpStatus status; + + RecruitCoreApplicationErrorCode(String code, String message, HttpStatus status) { + this.code = code; + this.message = message; + this.status = status; + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreApplicationNotFoundException.java b/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreApplicationNotFoundException.java new file mode 100644 index 00000000..1b779323 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreApplicationNotFoundException.java @@ -0,0 +1,13 @@ +package inha.gdgoc.domain.recruit.core.exception; + +import lombok.Getter; + +@Getter +public class RecruitCoreApplicationNotFoundException extends RuntimeException { + + private final RecruitCoreApplicationErrorCode errorCode = RecruitCoreApplicationErrorCode.APPLICATION_NOT_FOUND; + + public RecruitCoreApplicationNotFoundException() { + super(RecruitCoreApplicationErrorCode.APPLICATION_NOT_FOUND.getMessage()); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreControllerExceptionHandler.java b/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreControllerExceptionHandler.java new file mode 100644 index 00000000..0c4a9b76 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreControllerExceptionHandler.java @@ -0,0 +1,42 @@ +package inha.gdgoc.domain.recruit.core.exception; + +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreApplicationErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import inha.gdgoc.domain.recruit.core.controller.RecruitCoreController; + +@Slf4j +@RestControllerAdvice(assignableTypes = RecruitCoreController.class) +public class RecruitCoreControllerExceptionHandler { + + @ExceptionHandler(RecruitCoreAlreadyAppliedException.class) + public ResponseEntity handleAlreadyApplied( + RecruitCoreAlreadyAppliedException ex + ) { + log.debug("RecruitCoreAlreadyAppliedException: {}", ex.getMessage()); + var code = ex.getErrorCode(); + RecruitCoreApplicationErrorResponse body = RecruitCoreApplicationErrorResponse.of( + code.getCode(), + code.getMessage(), + ex.getSession(), + ex.getApplicationId() + ); + return ResponseEntity.status(code.getStatus()).body(body); + } + + @ExceptionHandler(RecruitCoreApplicationNotFoundException.class) + public ResponseEntity handleNotFound( + RecruitCoreApplicationNotFoundException ex + ) { + log.debug("RecruitCoreApplicationNotFoundException: {}", ex.getMessage()); + var code = ex.getErrorCode(); + RecruitCoreApplicationErrorResponse body = RecruitCoreApplicationErrorResponse.of( + code.getCode(), + code.getMessage() + ); + return ResponseEntity.status(code.getStatus()).body(body); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/repository/RecruitCoreApplicationRepository.java b/src/main/java/inha/gdgoc/domain/recruit/core/repository/RecruitCoreApplicationRepository.java new file mode 100644 index 00000000..1ae4b873 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/repository/RecruitCoreApplicationRepository.java @@ -0,0 +1,17 @@ +package inha.gdgoc.domain.recruit.core.repository; + +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface RecruitCoreApplicationRepository extends JpaRepository, + JpaSpecificationExecutor { + Page findByNameContainingIgnoreCase(String name, Pageable pageable); + + java.util.Optional findByUser_IdAndSession(Long userId, String session); + + java.util.Optional findByIdAndUser_Id(Long id, Long userId); +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java b/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java new file mode 100644 index 00000000..a0e41835 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java @@ -0,0 +1,119 @@ +package inha.gdgoc.domain.recruit.core.service; + +import inha.gdgoc.domain.recruit.core.config.RecruitCoreProperties; +import inha.gdgoc.domain.recruit.core.dto.request.RecruitCoreApplicationCreateRequest; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreApplicantDetailResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreApplicationCreateResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreEligibilityResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreMyApplicationResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCorePrefillResponse; +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import inha.gdgoc.domain.recruit.core.enums.RecruitCoreResultStatus; +import inha.gdgoc.domain.recruit.core.exception.RecruitCoreAlreadyAppliedException; +import inha.gdgoc.domain.recruit.core.exception.RecruitCoreApplicationNotFoundException; +import inha.gdgoc.domain.recruit.core.repository.RecruitCoreApplicationRepository; +import inha.gdgoc.domain.user.entity.User; +import inha.gdgoc.domain.user.enums.UserRole; +import inha.gdgoc.domain.user.repository.UserRepository; +import inha.gdgoc.global.exception.BusinessException; +import inha.gdgoc.global.exception.GlobalErrorCode; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class RecruitCoreApplicationService { + + private final RecruitCoreApplicationRepository repository; + private final UserRepository userRepository; + private final RecruitCoreProperties recruitCoreProperties; + + @Transactional(readOnly = true) + public RecruitCoreApplicantDetailResponse getApplicantDetail(Long id) { + RecruitCoreApplication app = getApplication(id); + return RecruitCoreApplicantDetailResponse.from(app); + } + + @Transactional(readOnly = true) + public RecruitCoreEligibilityResponse checkEligibility(Long userId) { + String session = recruitCoreProperties.currentSession(); + return repository.findByUser_IdAndSession(userId, session) + .map(app -> RecruitCoreEligibilityResponse.ineligible(session, "ALREADY_APPLIED", app.getId())) + .orElseGet(() -> RecruitCoreEligibilityResponse.eligible(session)); + } + + @Transactional(readOnly = true) + public RecruitCorePrefillResponse prefill(Long userId) { + User user = getUser(userId); + return RecruitCorePrefillResponse.from(user); + } + + @Transactional + public RecruitCoreApplicationCreateResponse submit(Long userId, RecruitCoreApplicationCreateRequest request) { + String session = recruitCoreProperties.currentSession(); + repository.findByUser_IdAndSession(userId, session) + .ifPresent(existing -> { + throw new RecruitCoreAlreadyAppliedException(session, existing.getId()); + }); + + User user = getUser(userId); + List fileUrls = request.fileUrls() == null + ? List.of() + : List.copyOf(request.fileUrls()); + RecruitCoreApplication application = RecruitCoreApplication.builder() + .user(user) + .session(session) + .name(request.snapshot().name()) + .studentId(request.snapshot().studentId()) + .phone(request.snapshot().phone()) + .major(request.snapshot().major()) + .email(request.snapshot().email()) + .team(request.team()) + .motivation(request.motivation()) + .wish(request.wish()) + .strengths(request.strengths()) + .pledge(request.pledge()) + .fileUrls(fileUrls) + .resultStatus(RecruitCoreResultStatus.SUBMITTED) + .build(); + + RecruitCoreApplication saved = repository.save(application); + return RecruitCoreApplicationCreateResponse.from(saved); + } + + @Transactional(readOnly = true) + public RecruitCoreMyApplicationResponse getMyApplication(Long userId) { + String session = recruitCoreProperties.currentSession(); + RecruitCoreApplication application = repository.findByUser_IdAndSession(userId, session) + .orElseThrow(RecruitCoreApplicationNotFoundException::new); + return RecruitCoreMyApplicationResponse.from(application); + } + + @Transactional(readOnly = true) + public RecruitCoreApplicantDetailResponse getApplicantDetailForViewer( + Long applicationId, + Long viewerId, + UserRole viewerRole + ) { + RecruitCoreApplication application = repository.findById(applicationId) + .orElseThrow(RecruitCoreApplicationNotFoundException::new); + boolean privileged = UserRole.hasAtLeast(viewerRole, UserRole.LEAD); + if (!privileged && !application.isOwnedBy(viewerId)) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER); + } + return RecruitCoreApplicantDetailResponse.from(application); + } + + private RecruitCoreApplication getApplication(Long id) { + return repository.findById(id) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND)); + } + + private User getUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND)); + } + +} diff --git a/src/main/resources/db/migration/V20260114__core_recruit_applications_session_and_status.sql b/src/main/resources/db/migration/V20260114__core_recruit_applications_session_and_status.sql new file mode 100644 index 00000000..91e8c17c --- /dev/null +++ b/src/main/resources/db/migration/V20260114__core_recruit_applications_session_and_status.sql @@ -0,0 +1,32 @@ +alter table core_recruit_applications + add column if not exists user_id bigint, + add column if not exists session varchar(32), + add column if not exists result_status varchar(32) default 'SUBMITTED', + add column if not exists reviewed_at timestamptz, + add column if not exists reviewed_by bigint, + add column if not exists result_note text; + +update core_recruit_applications +set session = coalesce(session, 'UNKNOWN'); + +alter table core_recruit_applications + alter column session set not null; + +alter table core_recruit_applications + alter column result_status set not null; + +update core_recruit_applications cra +set user_id = u.id +from users u +where cra.user_id is null + and u.email = cra.email; + +alter table core_recruit_applications + alter column user_id set not null; + +alter table core_recruit_applications + add constraint fk_core_recruit_applications_user + foreign key (user_id) references users (id) on delete cascade; + +create unique index if not exists uq_core_recruit_user_session + on core_recruit_applications (user_id, session); diff --git a/src/test/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminServiceTest.java b/src/test/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminServiceTest.java new file mode 100644 index 00000000..541f8e41 --- /dev/null +++ b/src/test/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminServiceTest.java @@ -0,0 +1,173 @@ +package inha.gdgoc.domain.admin.recruit.core.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import inha.gdgoc.domain.admin.recruit.core.dto.request.RecruitCoreApplicationAcceptRequest; +import inha.gdgoc.domain.admin.recruit.core.dto.request.RecruitCoreApplicationRejectRequest; +import inha.gdgoc.domain.admin.recruit.core.dto.response.RecruitCoreApplicationDecisionResponse; +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import inha.gdgoc.domain.recruit.core.enums.RecruitCoreResultStatus; +import inha.gdgoc.domain.recruit.core.repository.RecruitCoreApplicationRepository; +import inha.gdgoc.domain.user.entity.User; +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; +import inha.gdgoc.global.exception.BusinessException; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.domain.Specification; + +@ExtendWith(MockitoExtension.class) +class RecruitCoreAdminServiceTest { + + @Mock + private RecruitCoreApplicationRepository repository; + + @InjectMocks + private RecruitCoreAdminService adminService; + + @Test + void searchApplications_buildsSpecificationAndDelegates() { + RecruitCoreApplication app = createApplication(1L, createUser(1L)); + Page page = new PageImpl<>(List.of(app)); + when(repository.findAll(any(Specification.class), any(PageRequest.class))).thenReturn(page); + + Page result = adminService.searchApplications( + "2026-1", + RecruitCoreResultStatus.SUBMITTED, + TeamType.TECH, + PageRequest.of(0, 20) + ); + + assertThat(result.getContent()).hasSize(1); + verify(repository).findAll(any(Specification.class), any(PageRequest.class)); + } + + @Test + void accept_setsReviewerInfoAndUpdatesUser() { + User user = createUser(5L); + RecruitCoreApplication application = createApplication(100L, user); + when(repository.findById(100L)).thenReturn(Optional.of(application)); + + RecruitCoreApplicationDecisionResponse response = adminService.accept( + 100L, + 9L, + new RecruitCoreApplicationAcceptRequest("함께 하시죠", true) + ); + + assertThat(response.resultStatus()).isEqualTo(RecruitCoreResultStatus.ACCEPTED); + assertThat(application.getResultStatus()).isEqualTo(RecruitCoreResultStatus.ACCEPTED); + assertThat(application.getReviewedBy()).isEqualTo(9L); + assertThat(application.getReviewedAt()).isNotNull(); + assertThat(user.getUserRole()).isEqualTo(UserRole.CORE); + assertThat(user.getTeam()).isEqualTo(TeamType.TECH); + } + + @Test + void reject_setsRejectedStatus() { + User user = createUser(5L); + RecruitCoreApplication application = createApplication(200L, user); + when(repository.findById(200L)).thenReturn(Optional.of(application)); + + RecruitCoreApplicationDecisionResponse response = adminService.reject( + 200L, + 8L, + new RecruitCoreApplicationRejectRequest("죄송합니다.") + ); + + assertThat(response.resultStatus()).isEqualTo(RecruitCoreResultStatus.REJECTED); + assertThat(application.getReviewedBy()).isEqualTo(8L); + assertThat(application.getResultStatus()).isEqualTo(RecruitCoreResultStatus.REJECTED); + } + + @Test + void accept_afterDecision_throwsException() { + User user = createUser(1L); + RecruitCoreApplication application = createApplication(1L, user); + application.accept(3L, "이미 처리", Instant.now()); + when(repository.findById(1L)).thenReturn(Optional.of(application)); + + assertThatThrownBy(() -> adminService.accept( + 1L, + 2L, + new RecruitCoreApplicationAcceptRequest("다시", true) + )).isInstanceOf(BusinessException.class); + } + + private RecruitCoreApplication createApplication(Long id, User user) { + RecruitCoreApplication application = RecruitCoreApplication.builder() + .user(user) + .session("2026-1") + .name("홍길동") + .studentId("12201234") + .phone("01012345678") + .major("컴퓨터공학과") + .email("hong@inha.edu") + .team("TECH") + .motivation("motivation") + .wish("wish") + .strengths("strengths") + .pledge("pledge") + .fileUrls(List.of()) + .resultStatus(RecruitCoreResultStatus.SUBMITTED) + .build(); + setId(application, id); + setTimeStamps(application); + return application; + } + + private User createUser(Long id) { + User user = User.builder() + .name("홍길동") + .major("컴퓨터공학과") + .studentId("12201234") + .phoneNumber("01012345678") + .email("hong@inha.edu") + .password("encoded") + .userRole(UserRole.GUEST) + .team(null) + .salt(new byte[]{1}) + .image(null) + .social(null) + .careers(null) + .build(); + setId(user, id); + return user; + } + + private void setId(Object target, Long id) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField("id"); + field.setAccessible(true); + field.set(target, id); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + + private void setTimeStamps(RecruitCoreApplication application) { + try { + java.lang.reflect.Field created = application.getClass().getSuperclass().getDeclaredField("createdAt"); + java.lang.reflect.Field updated = application.getClass().getSuperclass().getDeclaredField("updatedAt"); + created.setAccessible(true); + updated.setAccessible(true); + Instant now = Instant.now(); + created.set(application, now); + updated.set(application, now); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } +} From 441cdea2459786b3872ba95c6178145c210b228f Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 15 Jan 2026 02:03:42 +0900 Subject: [PATCH 09/49] feat: add AccessGuard for reusable authorization checks --- .../auth/controller/AuthController.java | 39 ++-- .../controller/CoreAttendanceController.java | 17 +- .../controller/GuestbookController.java | 4 +- .../controller/RecruitMemberController.java | 166 ++++++++++++++++++ .../user/controller/UserAdminController.java | 22 ++- .../gdgoc/global/security/AccessGuard.java | 90 ++++++++++ 6 files changed, 311 insertions(+), 27 deletions(-) create mode 100644 src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java create mode 100644 src/main/java/inha/gdgoc/global/security/AccessGuard.java diff --git a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java index 52ca638c..67bc054b 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java @@ -20,6 +20,7 @@ import inha.gdgoc.global.config.jwt.TokenProvider; import inha.gdgoc.global.dto.response.ApiResponse; import inha.gdgoc.global.exception.GlobalErrorCode; +import inha.gdgoc.global.security.AccessGuard; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -52,6 +53,7 @@ public class AuthController { private final RefreshTokenService refreshTokenService; private final MailService mailService; private final AuthCodeService authCodeService; + private final AccessGuard accessGuard; @GetMapping("/oauth2/google/callback") public ResponseEntity, Void>> handleGoogleCallback(@RequestParam String code, HttpServletResponse response) { @@ -166,34 +168,37 @@ public ResponseEntity> resetPassword(@RequestBody Passwo * 예) /api/v1/auth/LEAD, /api/v1/auth/ORGANIZER, /api/v1/auth/ADMIN */ @GetMapping("/{role}") - public ResponseEntity> checkRoleOrTeam(@AuthenticationPrincipal TokenProvider.CustomUserDetails me, @PathVariable UserRole role, @RequestParam(value = "team", required = false) TeamType requiredTeam) { - // 1) 인증 체크 + public ResponseEntity> checkRoleOrTeam( + @AuthenticationPrincipal TokenProvider.CustomUserDetails me, + @PathVariable UserRole role, + @RequestParam(value = "team", required = false) TeamType requiredTeam + ) { if (me == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(ApiResponse.error(GlobalErrorCode.UNAUTHORIZED_USER.getStatus() - .value(), GlobalErrorCode.UNAUTHORIZED_USER.getMessage(), null)); + .body(ApiResponse.error( + GlobalErrorCode.UNAUTHORIZED_USER.getStatus().value(), + GlobalErrorCode.UNAUTHORIZED_USER.getMessage(), + null + )); } - // 2) role check - final boolean roleOk = UserRole.hasAtLeast(me.getRole(), role); + var conditions = new java.util.ArrayList(); + conditions.add(AccessGuard.AccessCondition.atLeast(role)); - // 3) team check if team parameter exists - boolean teamOk = false; if (requiredTeam != null) { - if (UserRole.hasAtLeast(me.getRole(), UserRole.ORGANIZER)) { - teamOk = true; - } else { - teamOk = (me.getTeam() != null && me.getTeam() == requiredTeam); - } + conditions.add(AccessGuard.AccessCondition.atLeast(UserRole.ORGANIZER)); + conditions.add(AccessGuard.AccessCondition.of(UserRole.GUEST, requiredTeam)); } - // 4) OR 조건으로 최종 판정 - if (roleOk || teamOk) { + if (accessGuard.check(me, conditions.toArray(AccessGuard.AccessCondition[]::new))) { return ResponseEntity.ok(ApiResponse.ok("ROLE_OR_TEAM_CHECK_PASSED", null)); } return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(ApiResponse.error(GlobalErrorCode.FORBIDDEN_USER.getStatus() - .value(), GlobalErrorCode.FORBIDDEN_USER.getMessage(), null)); + .body(ApiResponse.error( + GlobalErrorCode.FORBIDDEN_USER.getStatus().value(), + GlobalErrorCode.FORBIDDEN_USER.getMessage(), + null + )); } } diff --git a/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java b/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java index 552ce67d..eb8a9bd3 100644 --- a/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java +++ b/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java @@ -30,9 +30,18 @@ @RestController @RequestMapping("/api/v1/core-attendance/meetings") @RequiredArgsConstructor -@PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN')") +@PreAuthorize(CoreAttendanceController.LEAD_OR_HIGHER_RULE) public class CoreAttendanceController { + private static final String LEAD_OR_HIGHER_RULE = + "@accessGuard.check(authentication," + + " T(inha.gdgoc.global.security.AccessGuard.AccessCondition).atLeast(" + + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD))"; + private static final String ORGANIZER_OR_HIGHER_RULE = + "@accessGuard.check(authentication," + + " T(inha.gdgoc.global.security.AccessGuard.AccessCondition).atLeast(" + + "T(inha.gdgoc.domain.user.enums.UserRole).ORGANIZER))"; + private final CoreAttendanceService service; /* ===== helpers ===== */ @@ -51,14 +60,14 @@ public ResponseEntity> listDates() { return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.DATE_LIST_RETRIEVED_SUCCESS, new DateListResponse(service.getDates()))); } - @PreAuthorize("hasAnyRole('ORGANIZER', 'ADMIN')") + @PreAuthorize(ORGANIZER_OR_HIGHER_RULE) @PostMapping public ResponseEntity> createDate(@Valid @RequestBody CreateDateRequest request) { service.addDate(request.getDate()); return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.DATE_CREATED_SUCCESS, new DateListResponse(service.getDates()))); } - @PreAuthorize("hasAnyRole('ORGANIZER', 'ADMIN')") + @PreAuthorize(ORGANIZER_OR_HIGHER_RULE) @DeleteMapping("/{date}") public ResponseEntity> deleteDate(@PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { service.deleteDate(date.toString()); @@ -140,4 +149,4 @@ public ResponseEntity summaryCsvAll( .body(csv); } -} \ No newline at end of file +} diff --git a/src/main/java/inha/gdgoc/domain/guestbook/controller/GuestbookController.java b/src/main/java/inha/gdgoc/domain/guestbook/controller/GuestbookController.java index 673495a9..9d2c3e6a 100644 --- a/src/main/java/inha/gdgoc/domain/guestbook/controller/GuestbookController.java +++ b/src/main/java/inha/gdgoc/domain/guestbook/controller/GuestbookController.java @@ -19,7 +19,9 @@ @RestController @RequestMapping("/api/v1/guestbook") @RequiredArgsConstructor -@PreAuthorize("hasAnyRole('ORGANIZER','ADMIN')") +@PreAuthorize("@accessGuard.check(authentication," + + " T(inha.gdgoc.global.security.AccessGuard.AccessCondition).atLeast(" + + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD))") public class GuestbookController { private final GuestbookService service; diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java b/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java new file mode 100644 index 00000000..440b7af9 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java @@ -0,0 +1,166 @@ +package inha.gdgoc.domain.recruit.member.controller; + +import static inha.gdgoc.domain.recruit.member.controller.message.RecruitMemberMessage.MEMBER_LIST_RETRIEVED_SUCCESS; +import static inha.gdgoc.domain.recruit.member.controller.message.RecruitMemberMessage.MEMBER_RETRIEVED_SUCCESS; +import static inha.gdgoc.domain.recruit.member.controller.message.RecruitMemberMessage.MEMBER_SAVE_SUCCESS; +import static inha.gdgoc.domain.recruit.member.controller.message.RecruitMemberMessage.PAYMENT_MARKED_COMPLETE_SUCCESS; +import static inha.gdgoc.domain.recruit.member.controller.message.RecruitMemberMessage.PAYMENT_MARKED_INCOMPLETE_SUCCESS; +import static inha.gdgoc.domain.recruit.member.controller.message.RecruitMemberMessage.PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS; +import static inha.gdgoc.domain.recruit.member.controller.message.RecruitMemberMessage.STUDENT_ID_DUPLICATION_CHECK_SUCCESS; + +import inha.gdgoc.domain.recruit.member.dto.request.ApplicationRequest; +import inha.gdgoc.domain.recruit.member.dto.request.PaymentUpdateRequest; +import inha.gdgoc.domain.recruit.member.dto.response.CheckPhoneNumberResponse; +import inha.gdgoc.domain.recruit.member.dto.response.CheckStudentIdResponse; +import inha.gdgoc.domain.recruit.member.dto.response.RecruitMemberSummaryResponse; +import inha.gdgoc.domain.recruit.member.dto.response.SpecifiedMemberResponse; +import inha.gdgoc.domain.recruit.member.entity.RecruitMember; +import inha.gdgoc.domain.recruit.member.service.RecruitMemberService; +import inha.gdgoc.global.dto.response.ApiResponse; +import inha.gdgoc.global.dto.response.PageMeta; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Recruit - Members", description = "리크루팅 지원자 관리 API") +@RequestMapping("/api/v1") +@RequiredArgsConstructor +@RestController +public class RecruitMemberController { + + private static final String LEAD_OR_HR_RULE = + "@accessGuard.check(authentication," + + " T(inha.gdgoc.global.security.AccessGuard.AccessCondition).atLeast(" + + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD)," + + " T(inha.gdgoc.global.security.AccessGuard.AccessCondition).atLeast(" + + "T(inha.gdgoc.domain.user.enums.UserRole).CORE," + + " T(inha.gdgoc.domain.user.enums.TeamType).HR))"; + + private final RecruitMemberService recruitMemberService; + + @PostMapping("/apply") + public ResponseEntity> recruitMemberAdd( + @RequestBody ApplicationRequest applicationRequest + ) { + recruitMemberService.addRecruitMember(applicationRequest); + + return ResponseEntity.ok(ApiResponse.ok(MEMBER_SAVE_SUCCESS)); + } + + @GetMapping("/check/student-id") + public ResponseEntity> duplicatedStudentIdDetails( + @RequestParam + @NotBlank(message = "학번은 필수 입력 값입니다.") + @Pattern(regexp = "^12[0-9]{6}$", message = "유효하지 않은 학번 값입니다.") + String studentId + ) { + CheckStudentIdResponse response = recruitMemberService.isRegisteredStudentId(studentId); + + return ResponseEntity.ok(ApiResponse.ok(STUDENT_ID_DUPLICATION_CHECK_SUCCESS, response)); + } + + @GetMapping("/check/phone-number") + public ResponseEntity> duplicatedPhoneNumberDetails( + @RequestParam + @NotBlank(message = "전화번호는 필수 입력 값입니다.") + @Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "전화번호 형식은 010-XXXX-XXXX 이어야 합니다.") + String phoneNumber + ) { + CheckPhoneNumberResponse response = recruitMemberService + .isRegisteredPhoneNumber(phoneNumber); + + return ResponseEntity.ok(ApiResponse.ok(PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS, response)); + } + + @Operation(summary = "특정 멤버 가입 신청서 조회", security = {@SecurityRequirement(name = "BearerAuth")}) + @PreAuthorize(LEAD_OR_HR_RULE) + @GetMapping("/recruit/members/{memberId}") + public ResponseEntity> getSpecifiedMember( + @PathVariable Long memberId + ) { + SpecifiedMemberResponse response = recruitMemberService.findSpecifiedMember(memberId); + + return ResponseEntity.ok(ApiResponse.ok(MEMBER_RETRIEVED_SUCCESS, response)); + } + + @Operation( + summary = "입금 상태 변경", + description = "설정하려는 상태(NOT 현재 상태)를 body에 보내주세요. true=입금 완료, false=입금 미완료", + security = { @SecurityRequirement(name = "BearerAuth") } + ) + @PreAuthorize(LEAD_OR_HR_RULE) + @PatchMapping("/recruit/members/{memberId}/payment") + public ResponseEntity> updatePayment( + @PathVariable Long memberId, + @RequestBody PaymentUpdateRequest paymentUpdateRequest + ) { + recruitMemberService.updatePayment(memberId, paymentUpdateRequest.isPayed()); + + return ResponseEntity.ok( + ApiResponse.ok( + paymentUpdateRequest.isPayed() + ? PAYMENT_MARKED_COMPLETE_SUCCESS + : PAYMENT_MARKED_INCOMPLETE_SUCCESS + ) + ); + } + + @Operation( + summary = "지원자 목록 조회", + description = "전체 목록 또는 이름 검색 결과를 반환합니다. 검색어(question)를 주면 이름 포함 검색, 없으면 전체 조회. sort랑 dir은 example 값 그대로 코딩하는 것 추천...", + security = { @SecurityRequirement(name = "BearerAuth") } + ) + @PreAuthorize(LEAD_OR_HR_RULE) + @GetMapping("/recruit/members") + public ResponseEntity, PageMeta>> getMembers( + @Parameter(description = "검색어(이름 부분 일치). 없으면 전체 조회", example = "소연") + @RequestParam(required = false) String question, + + @Parameter(description = "페이지(0부터 시작)", example = "0") + @RequestParam(defaultValue = "0") int page, + + @Parameter(description = "페이지 크기", example = "20") + @RequestParam(defaultValue = "20") int size, + + @Parameter(description = "정렬 필드", example = "createdAt") + @RequestParam(defaultValue = "createdAt") String sort, + + @Parameter(description = "정렬 방향 ASC/DESC", example = "DESC") + @RequestParam(defaultValue = "DESC") String dir + ) { + Direction direction = "ASC".equalsIgnoreCase(dir) ? Direction.ASC : Direction.DESC; + Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sort)); + + Page memberPage = (question == null || question.isBlank()) + ? recruitMemberService.findAllMembersPage(pageable) + : recruitMemberService.searchMembersByNamePage(question, pageable); + + List list = memberPage + .map(RecruitMemberSummaryResponse::from) + .getContent(); + PageMeta meta = PageMeta.of(memberPage); + + return ResponseEntity.ok(ApiResponse.ok(MEMBER_LIST_RETRIEVED_SUCCESS, list, meta)); + } + +} diff --git a/src/main/java/inha/gdgoc/domain/user/controller/UserAdminController.java b/src/main/java/inha/gdgoc/domain/user/controller/UserAdminController.java index 56135d94..e2c4f3b1 100644 --- a/src/main/java/inha/gdgoc/domain/user/controller/UserAdminController.java +++ b/src/main/java/inha/gdgoc/domain/user/controller/UserAdminController.java @@ -24,11 +24,23 @@ @RequestMapping("/api/v1/admin/users") public class UserAdminController { + private static final String LEAD_OR_HR_RULE = + "@accessGuard.check(authentication," + + " T(inha.gdgoc.global.security.AccessGuard.AccessCondition).atLeast(" + + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD)," + + " T(inha.gdgoc.global.security.AccessGuard.AccessCondition).atLeast(" + + "T(inha.gdgoc.domain.user.enums.UserRole).CORE," + + " T(inha.gdgoc.domain.user.enums.TeamType).HR))"; + private static final String LEAD_OR_HIGHER_RULE = + "@accessGuard.check(authentication," + + " T(inha.gdgoc.global.security.AccessGuard.AccessCondition).atLeast(" + + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD))"; + private final UserAdminService userAdminService; // q(검색) + role/team(필터) + pageable @Operation(summary = "사용자 요약 목록 조회", security = {@SecurityRequirement(name = "BearerAuth")}) - @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team") + @PreAuthorize(LEAD_OR_HR_RULE) @GetMapping public ResponseEntity, PageMeta>> list( @RequestParam(required = false) String q, @@ -44,7 +56,7 @@ public ResponseEntity, PageMeta>> list( } @Operation(summary = "사용자 역할/팀 수정", security = {@SecurityRequirement(name = "BearerAuth")}) - @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN')") + @PreAuthorize(LEAD_OR_HIGHER_RULE) @PatchMapping("/{userId}/role-team") public ResponseEntity> updateRoleTeam( @AuthenticationPrincipal CustomUserDetails me, @@ -56,7 +68,7 @@ public ResponseEntity> updateRoleTeam( } @Operation(summary = "사용자 역할 수정", security = {@SecurityRequirement(name = "BearerAuth")}) - @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team") + @PreAuthorize(LEAD_OR_HR_RULE) @PatchMapping("/{userId}/role") public ResponseEntity> updateUserRole( @AuthenticationPrincipal CustomUserDetails me, @@ -68,7 +80,7 @@ public ResponseEntity> updateUserRole( } @Operation(summary = "사용자 삭제", security = {@SecurityRequirement(name = "BearerAuth")}) - @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN')") + @PreAuthorize(LEAD_OR_HIGHER_RULE) @DeleteMapping("/{userId}") public ResponseEntity> deleteUser( @AuthenticationPrincipal CustomUserDetails me, @@ -77,4 +89,4 @@ public ResponseEntity> deleteUser( userAdminService.deleteUserWithRules(me, userId); return ResponseEntity.ok(ApiResponse.ok("USER_DELETED")); } -} \ No newline at end of file +} diff --git a/src/main/java/inha/gdgoc/global/security/AccessGuard.java b/src/main/java/inha/gdgoc/global/security/AccessGuard.java new file mode 100644 index 00000000..6341332e --- /dev/null +++ b/src/main/java/inha/gdgoc/global/security/AccessGuard.java @@ -0,0 +1,90 @@ +package inha.gdgoc.global.security; + +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; +import inha.gdgoc.global.config.jwt.TokenProvider.CustomUserDetails; +import java.util.Arrays; +import java.util.List; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +/** + * 중앙 집중 권한 검사기. + * - {@link #check(Authentication, AccessCondition...)}는 SpEL @PreAuthorize에서 사용. + * - {@link #require(CustomUserDetails, AccessCondition...)}는 서비스/컨트롤러에서 명시적으로 사용. + */ +@Component("accessGuard") +public class AccessGuard { + + public boolean check(Authentication authentication, AccessCondition... anyOf) { + CustomUserDetails user = extract(authentication); + return matches(user, anyOf); + } + + public boolean check(CustomUserDetails user, AccessCondition... anyOf) { + return matches(user, anyOf); + } + + public void require(CustomUserDetails user, AccessCondition... anyOf) { + if (!matches(user, anyOf)) { + throw new AccessDeniedException("FORBIDDEN_USER"); + } + } + + private boolean matches(CustomUserDetails user, AccessCondition... anyOf) { + if (user == null || anyOf == null || anyOf.length == 0) { + return false; + } + + for (AccessCondition condition : anyOf) { + if (condition != null && condition.matches(user.getRole(), user.getTeam())) { + return true; + } + } + + return false; + } + + private CustomUserDetails extract(Authentication authentication) { + if (authentication == null) { + return null; + } + Object principal = authentication.getPrincipal(); + if (principal instanceof CustomUserDetails user) { + return user; + } + return null; + } + + public record AccessCondition(UserRole minRole, List teams) { + + public static AccessCondition of(UserRole minRole, List teams) { + List list = (teams == null || teams.isEmpty()) + ? List.of() + : List.copyOf(teams); + return new AccessCondition(minRole, list); + } + + public static AccessCondition of(UserRole minRole, TeamType... teams) { + if (teams == null || teams.length == 0) { + return new AccessCondition(minRole, List.of()); + } + return new AccessCondition(minRole, List.copyOf(Arrays.asList(teams))); + } + + public static AccessCondition atLeast(UserRole minRole) { + return of(minRole); + } + + private boolean matches(UserRole currentRole, TeamType currentTeam) { + if (minRole != null && !UserRole.hasAtLeast(currentRole, minRole)) { + return false; + } + if (teams.isEmpty()) { + return true; + } + return currentTeam != null && teams.contains(currentTeam); + } + } +} From 56c20644c5cd9ae6b8fb5039abfd871b7ee14f5c Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 15 Jan 2026 02:03:53 +0900 Subject: [PATCH 10/49] refactor: inject SemesterCalculator instead of env-driven semester --- .../dto/request/RecruitMemberRequest.java | 48 +++++++++ .../member/service/RecruitMemberService.java | 97 +++++++++++++++++++ .../gdgoc/global/util/SemesterCalculator.java | 29 ++++-- 3 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 src/main/java/inha/gdgoc/domain/recruit/member/dto/request/RecruitMemberRequest.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/RecruitMemberRequest.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/RecruitMemberRequest.java new file mode 100644 index 00000000..695ab49a --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/RecruitMemberRequest.java @@ -0,0 +1,48 @@ +package inha.gdgoc.domain.recruit.member.dto.request; + +import inha.gdgoc.domain.recruit.member.entity.RecruitMember; +import inha.gdgoc.domain.recruit.member.enums.AdmissionSemester; +import inha.gdgoc.domain.recruit.member.enums.EnrolledClassification; +import inha.gdgoc.domain.recruit.member.enums.Gender; +import java.time.LocalDate; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +public class RecruitMemberRequest { + private String name; + private String grade; + private String studentId; + private String enrolledClassification; + private String phoneNumber; + private String nationality; + private String email; + private String gender; + private LocalDate birth; + private String major; + private String doubleMajor; + private Boolean isPayed; + + public RecruitMember toEntity(AdmissionSemester admissionSemester) { + return RecruitMember.builder() + .name(name) + .grade(grade) + .studentId(studentId) + .enrolledClassification(EnrolledClassification.fromStatus(enrolledClassification)) + .phoneNumber(phoneNumber) + .nationality(nationality) + .email(email) + .gender(Gender.fromType(gender)) + .birth(birth) + .major(major) + .doubleMajor(doubleMajor) + .isPayed(false) + .admissionSemester(admissionSemester) + .build(); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java b/src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java new file mode 100644 index 00000000..24ff9545 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java @@ -0,0 +1,97 @@ +package inha.gdgoc.domain.recruit.member.service; + +import static inha.gdgoc.domain.recruit.member.exception.RecruitMemberErrorCode.RECRUIT_MEMBER_NOT_FOUND; + +import com.fasterxml.jackson.databind.ObjectMapper; +import inha.gdgoc.domain.recruit.member.dto.request.ApplicationRequest; +import inha.gdgoc.domain.recruit.member.dto.response.CheckPhoneNumberResponse; +import inha.gdgoc.domain.recruit.member.dto.response.CheckStudentIdResponse; +import inha.gdgoc.domain.recruit.member.dto.response.SpecifiedMemberResponse; +import inha.gdgoc.domain.recruit.member.entity.Answer; +import inha.gdgoc.domain.recruit.member.entity.RecruitMember; +import inha.gdgoc.domain.recruit.member.enums.InputType; +import inha.gdgoc.domain.recruit.member.enums.SurveyType; +import inha.gdgoc.domain.recruit.member.exception.RecruitMemberException; +import inha.gdgoc.domain.recruit.member.repository.AnswerRepository; +import inha.gdgoc.domain.recruit.member.repository.RecruitMemberRepository; +import inha.gdgoc.global.util.SemesterCalculator; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class RecruitMemberService { + private final RecruitMemberRepository recruitMemberRepository; + private final AnswerRepository answerRepository; + private final ObjectMapper objectMapper; + private final SemesterCalculator semesterCalculator; + + @Transactional + public void addRecruitMember(ApplicationRequest applicationRequest) { + RecruitMember member = applicationRequest.getMember() + .toEntity(semesterCalculator.currentSemester()); + recruitMemberRepository.save(member); + + List answers = applicationRequest.getAnswers().entrySet().stream() + .map(entry -> { + try { + // Object → JSON String 변환 + String jsonValue = objectMapper.writeValueAsString(entry.getValue()); + return new Answer(member, SurveyType.fromType("recruit form"), InputType.fromQuestion( + entry.getKey()), jsonValue); + } catch (Exception e) { + throw new RuntimeException("JSON 변환 오류", e); + } + }) + .toList(); + + answerRepository.saveAll(answers); + } + + public CheckStudentIdResponse isRegisteredStudentId(String studentId) { + boolean exists = recruitMemberRepository.existsByStudentId(studentId); + + return new CheckStudentIdResponse(exists); + } + + public CheckPhoneNumberResponse isRegisteredPhoneNumber(String phoneNumber) { + boolean exists = recruitMemberRepository.existsByPhoneNumber(phoneNumber); + + return new CheckPhoneNumberResponse(exists); + } + + public SpecifiedMemberResponse findSpecifiedMember(Long id) { + RecruitMember member = recruitMemberRepository.findById(id) + .orElseThrow(() -> new RecruitMemberException(RECRUIT_MEMBER_NOT_FOUND)); + List answers = answerRepository + .findByRecruitMemberAndSurveyType(member, SurveyType.RECRUIT); + + return SpecifiedMemberResponse.from(member, answers, objectMapper); + } + + @Transactional + public void updatePayment(Long memberId, boolean isPayed) { + RecruitMember m = recruitMemberRepository.findById(memberId) + .orElseThrow(() -> new RecruitMemberException(RECRUIT_MEMBER_NOT_FOUND)); + + if (Boolean.TRUE.equals(m.getIsPayed()) == isPayed) return; + + if (isPayed) m.markPaid(); + else m.markUnpaid(); + } + + @Transactional(readOnly = true) + public Page findAllMembersPage(Pageable pageable) { + return recruitMemberRepository.findAll(pageable); + } + + @Transactional(readOnly = true) + public Page searchMembersByNamePage(String name, Pageable pageable) { + return recruitMemberRepository.findByNameContainingIgnoreCase(name, pageable); + } + +} diff --git a/src/main/java/inha/gdgoc/global/util/SemesterCalculator.java b/src/main/java/inha/gdgoc/global/util/SemesterCalculator.java index fdbc60b9..1d196596 100644 --- a/src/main/java/inha/gdgoc/global/util/SemesterCalculator.java +++ b/src/main/java/inha/gdgoc/global/util/SemesterCalculator.java @@ -1,20 +1,33 @@ package inha.gdgoc.global.util; -import inha.gdgoc.domain.recruit.enums.AdmissionSemester; - +import inha.gdgoc.domain.recruit.member.enums.AdmissionSemester; +import java.time.Clock; import java.time.LocalDate; import java.time.ZoneId; +import org.springframework.stereotype.Component; + +/** + * 현재 날짜를 기반으로 학기를 계산하는 컴포넌트. + * env 값 대신 서버 시간이 기준이 되도록 고정. + */ +@Component +public class SemesterCalculator { -public final class SemesterCalculator { - private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private final Clock clock; - private SemesterCalculator() {} + public SemesterCalculator() { + this(Clock.system(ZoneId.of("Asia/Seoul"))); + } + + public SemesterCalculator(Clock clock) { + this.clock = clock; + } - public static AdmissionSemester currentSemester() { - return of(LocalDate.now(KST)); + public AdmissionSemester currentSemester() { + return of(LocalDate.now(clock)); } - public static AdmissionSemester of(LocalDate date) { + public AdmissionSemester of(LocalDate date) { int year = date.getYear(); int month = date.getMonthValue(); From f234af59fee8a2c792621a8cb66f5f3316494aef Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 15 Jan 2026 02:19:24 +0900 Subject: [PATCH 11/49] feat: add core recruit application workflows --- .../controller/CoreRecruitController.java | 104 ------ .../CoreRecruitApplicationMessage.java | 8 - .../CoreRecruitApplicationRequest.java | 65 ---- .../CoreRecruitApplicantDetailResponse.java | 44 --- .../CoreRecruitApplicantSummaryResponse.java | 31 -- .../entity/CoreRecruitApplication.java | 71 ---- .../CoreRecruitApplicationRepository.java | 12 - .../CoreRecruitApplicationService.java | 57 --- .../core/config/RecruitCoreProperties.java | 25 -- .../config/RecruitCoreSessionResolver.java | 30 ++ .../RecruitCoreApplicationService.java | 10 +- .../test/controller/TestController.java | 31 -- .../gdgoc/global/security/SecurityConfig.java | 2 - src/main/resources/application-dev.yml | 68 ++-- src/main/resources/application-local.yml | 65 ++-- src/main/resources/application-prod.yml | 66 ++-- .../RecruitCoreApplicationServiceTest.java | 224 +++++++++++ .../service/StudyAttendeeServiceTest.java | 351 ------------------ .../study/service/StudyServiceTest.java | 322 ---------------- src/test/resources/application-test.yml | 55 ++- 20 files changed, 380 insertions(+), 1261 deletions(-) delete mode 100644 src/main/java/inha/gdgoc/domain/core/recruit/controller/CoreRecruitController.java delete mode 100644 src/main/java/inha/gdgoc/domain/core/recruit/controller/message/CoreRecruitApplicationMessage.java delete mode 100644 src/main/java/inha/gdgoc/domain/core/recruit/dto/request/CoreRecruitApplicationRequest.java delete mode 100644 src/main/java/inha/gdgoc/domain/core/recruit/dto/response/CoreRecruitApplicantDetailResponse.java delete mode 100644 src/main/java/inha/gdgoc/domain/core/recruit/dto/response/CoreRecruitApplicantSummaryResponse.java delete mode 100644 src/main/java/inha/gdgoc/domain/core/recruit/entity/CoreRecruitApplication.java delete mode 100644 src/main/java/inha/gdgoc/domain/core/recruit/repository/CoreRecruitApplicationRepository.java delete mode 100644 src/main/java/inha/gdgoc/domain/core/recruit/service/CoreRecruitApplicationService.java delete mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/config/RecruitCoreProperties.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/core/config/RecruitCoreSessionResolver.java delete mode 100644 src/main/java/inha/gdgoc/domain/test/controller/TestController.java create mode 100644 src/test/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationServiceTest.java delete mode 100644 src/test/java/inha/gdgoc/domain/study/service/StudyAttendeeServiceTest.java delete mode 100644 src/test/java/inha/gdgoc/domain/study/service/StudyServiceTest.java diff --git a/src/main/java/inha/gdgoc/domain/core/recruit/controller/CoreRecruitController.java b/src/main/java/inha/gdgoc/domain/core/recruit/controller/CoreRecruitController.java deleted file mode 100644 index 7efdd337..00000000 --- a/src/main/java/inha/gdgoc/domain/core/recruit/controller/CoreRecruitController.java +++ /dev/null @@ -1,104 +0,0 @@ -package inha.gdgoc.domain.core.recruit.controller; - -import inha.gdgoc.domain.core.recruit.dto.request.CoreRecruitApplicationRequest; -import inha.gdgoc.domain.core.recruit.dto.response.CoreRecruitApplicantDetailResponse; -import inha.gdgoc.domain.core.recruit.dto.response.CoreRecruitApplicantSummaryResponse; -import inha.gdgoc.domain.core.recruit.service.CoreRecruitApplicationService; -import inha.gdgoc.domain.core.recruit.entity.CoreRecruitApplication; -import inha.gdgoc.domain.core.recruit.controller.message.CoreRecruitApplicationMessage; -import inha.gdgoc.global.dto.response.ApiResponse; -import inha.gdgoc.global.dto.response.PageMeta; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; -import org.springframework.data.domain.Sort.Direction; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Core Recruit - Applicants", description = "코어 리쿠르트 지원자 조회 API") -@RestController -@RequestMapping("/api/v1/core-recruit") -@RequiredArgsConstructor -public class CoreRecruitController { - - private final CoreRecruitApplicationService service; - - private record CreateResponse(Long id, String status) {} - - @PostMapping - public ResponseEntity> create( - @Valid @RequestBody CoreRecruitApplicationRequest request - ) { - Long id = service.create(request); - return ResponseEntity.ok(ApiResponse.ok("OK", new CreateResponse(id, "OK"))); - } - - @Operation( - summary = "코어 리쿠르트 지원자 목록 조회", - description = "전체 목록 또는 이름 검색 결과를 반환합니다.", - security = { @SecurityRequirement(name = "BearerAuth") } - ) - @PreAuthorize("hasAnyRole('LEAD', 'ORGANIZER', 'ADMIN')") - @GetMapping("/applicants") - public ResponseEntity, PageMeta>> getApplicants( - @Parameter(description = "검색어(이름 부분 일치). 없으면 전체 조회", example = "홍길동") - @RequestParam(required = false) String question, - - @Parameter(description = "페이지(0부터 시작)", example = "0") - @RequestParam(defaultValue = "0") int page, - - @Parameter(description = "페이지 크기", example = "20") - @RequestParam(defaultValue = "20") int size, - - @Parameter(description = "정렬 필드", example = "createdAt") - @RequestParam(defaultValue = "createdAt") String sort, - - @Parameter(description = "정렬 방향 ASC/DESC", example = "DESC") - @RequestParam(defaultValue = "DESC") String dir - ) { - Direction direction = "ASC".equalsIgnoreCase(dir) ? Direction.ASC : Direction.DESC; - Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sort)); - - Page pageResult = service.findApplicantsPage(question, pageable); - - java.util.List list = pageResult - .map(CoreRecruitApplicantSummaryResponse::from) - .getContent(); - PageMeta meta = PageMeta.of(pageResult); - - return ResponseEntity.ok( - ApiResponse.ok(CoreRecruitApplicationMessage.APPLICANT_LIST_RETRIEVED_SUCCESS, list, meta) - ); - } - - @Operation( - summary = "코어 리쿠르트 지원자 상세 조회", - security = { @SecurityRequirement(name = "BearerAuth") } - ) - @PreAuthorize("hasAnyRole('LEAD', 'ORGANIZER', 'ADMIN')") - @GetMapping("/applicants/{id}") - public ResponseEntity> getApplicant( - @PathVariable Long id - ) { - CoreRecruitApplicantDetailResponse response = service.getApplicantDetail(id); - return ResponseEntity.ok( - ApiResponse.ok(CoreRecruitApplicationMessage.APPLICANT_RETRIEVED_SUCCESS, response) - ); - } -} - - diff --git a/src/main/java/inha/gdgoc/domain/core/recruit/controller/message/CoreRecruitApplicationMessage.java b/src/main/java/inha/gdgoc/domain/core/recruit/controller/message/CoreRecruitApplicationMessage.java deleted file mode 100644 index daad2a55..00000000 --- a/src/main/java/inha/gdgoc/domain/core/recruit/controller/message/CoreRecruitApplicationMessage.java +++ /dev/null @@ -1,8 +0,0 @@ -package inha.gdgoc.domain.core.recruit.controller.message; - -public class CoreRecruitApplicationMessage { - public static final String APPLICANT_LIST_RETRIEVED_SUCCESS = "성공적으로 코어 리쿠르트 지원자 목록을 조회했습니다."; - public static final String APPLICANT_RETRIEVED_SUCCESS = "성공적으로 코어 리쿠르트 지원자 상세를 조회했습니다."; -} - - diff --git a/src/main/java/inha/gdgoc/domain/core/recruit/dto/request/CoreRecruitApplicationRequest.java b/src/main/java/inha/gdgoc/domain/core/recruit/dto/request/CoreRecruitApplicationRequest.java deleted file mode 100644 index eb46f35b..00000000 --- a/src/main/java/inha/gdgoc/domain/core/recruit/dto/request/CoreRecruitApplicationRequest.java +++ /dev/null @@ -1,65 +0,0 @@ -package inha.gdgoc.domain.core.recruit.dto.request; - -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import java.util.List; -import lombok.Builder; -import lombok.Getter; - -@Getter -public class CoreRecruitApplicationRequest { - - @NotBlank - private String name; - - @NotBlank - private String studentId; - - @NotBlank - private String phone; - - @NotBlank - private String major; - - @Email - @NotBlank - private String email; - - @NotBlank - private String team; - - @NotBlank - private String motivation; - - @NotBlank - private String wish; - - @NotBlank - private String strengths; - - @NotBlank - private String pledge; - - @NotNull - private List fileUrls; - - @Builder - public CoreRecruitApplicationRequest(String name, String studentId, String phone, String major, - String email, String team, String motivation, String wish, String strengths, String pledge, - List fileUrls) { - this.name = name; - this.studentId = studentId; - this.phone = phone; - this.major = major; - this.email = email; - this.team = team; - this.motivation = motivation; - this.wish = wish; - this.strengths = strengths; - this.pledge = pledge; - this.fileUrls = fileUrls; - } -} - - diff --git a/src/main/java/inha/gdgoc/domain/core/recruit/dto/response/CoreRecruitApplicantDetailResponse.java b/src/main/java/inha/gdgoc/domain/core/recruit/dto/response/CoreRecruitApplicantDetailResponse.java deleted file mode 100644 index efbd4d0f..00000000 --- a/src/main/java/inha/gdgoc/domain/core/recruit/dto/response/CoreRecruitApplicantDetailResponse.java +++ /dev/null @@ -1,44 +0,0 @@ -package inha.gdgoc.domain.core.recruit.dto.response; - -import inha.gdgoc.domain.core.recruit.entity.CoreRecruitApplication; -import java.time.Instant; -import java.util.List; - -public record CoreRecruitApplicantDetailResponse( - Long id, - String name, - String studentId, - String phone, - String major, - String email, - String team, - String motivation, - String wish, - String strengths, - String pledge, - List fileUrls, - Instant createdAt, - Instant updatedAt -) { - - public static CoreRecruitApplicantDetailResponse from(CoreRecruitApplication entity) { - return new CoreRecruitApplicantDetailResponse( - entity.getId(), - entity.getName(), - entity.getStudentId(), - entity.getPhone(), - entity.getMajor(), - entity.getEmail(), - entity.getTeam(), - entity.getMotivation(), - entity.getWish(), - entity.getStrengths(), - entity.getPledge(), - entity.getFileUrls(), - entity.getCreatedAt(), - entity.getUpdatedAt() - ); - } -} - - diff --git a/src/main/java/inha/gdgoc/domain/core/recruit/dto/response/CoreRecruitApplicantSummaryResponse.java b/src/main/java/inha/gdgoc/domain/core/recruit/dto/response/CoreRecruitApplicantSummaryResponse.java deleted file mode 100644 index e000398c..00000000 --- a/src/main/java/inha/gdgoc/domain/core/recruit/dto/response/CoreRecruitApplicantSummaryResponse.java +++ /dev/null @@ -1,31 +0,0 @@ -package inha.gdgoc.domain.core.recruit.dto.response; - -import inha.gdgoc.domain.core.recruit.entity.CoreRecruitApplication; -import java.time.Instant; - -public record CoreRecruitApplicantSummaryResponse( - Long id, - String name, - String studentId, - String major, - String email, - String phone, - String team, - Instant createdAt -) { - - public static CoreRecruitApplicantSummaryResponse from(CoreRecruitApplication entity) { - return new CoreRecruitApplicantSummaryResponse( - entity.getId(), - entity.getName(), - entity.getStudentId(), - entity.getMajor(), - entity.getEmail(), - entity.getPhone(), - entity.getTeam(), - entity.getCreatedAt() - ); - } -} - - diff --git a/src/main/java/inha/gdgoc/domain/core/recruit/entity/CoreRecruitApplication.java b/src/main/java/inha/gdgoc/domain/core/recruit/entity/CoreRecruitApplication.java deleted file mode 100644 index e5611f85..00000000 --- a/src/main/java/inha/gdgoc/domain/core/recruit/entity/CoreRecruitApplication.java +++ /dev/null @@ -1,71 +0,0 @@ -package inha.gdgoc.domain.core.recruit.entity; - -import com.vladmihalcea.hibernate.type.json.JsonType; -import inha.gdgoc.global.entity.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import java.util.List; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.hibernate.annotations.Type; - -@Entity -@Table(name = "core_recruit_applications") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -public class CoreRecruitApplication extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id", nullable = false) - private Long id; - - @Column(name = "name", nullable = false) - private String name; - - @Column(name = "student_id", nullable = false) - private String studentId; - - @Column(name = "phone", nullable = false) - private String phone; - - @Column(name = "major", nullable = false) - private String major; - - @Column(name = "email", nullable = false) - private String email; - - @Column(name = "team", nullable = false) - private String team; - - @Column(name = "motivation", nullable = false, columnDefinition = "text") - private String motivation; - - @Column(name = "wish", nullable = false, columnDefinition = "text") - private String wish; - - @Column(name = "strengths", nullable = false, columnDefinition = "text") - private String strengths; - - @Column(name = "pledge", nullable = false, columnDefinition = "text") - private String pledge; - - @Type(JsonType.class) - @Column(name = "file_urls", nullable = false, columnDefinition = "jsonb") - private List fileUrls; - - public Long getId() { - return id; - } -} - - diff --git a/src/main/java/inha/gdgoc/domain/core/recruit/repository/CoreRecruitApplicationRepository.java b/src/main/java/inha/gdgoc/domain/core/recruit/repository/CoreRecruitApplicationRepository.java deleted file mode 100644 index f6a62818..00000000 --- a/src/main/java/inha/gdgoc/domain/core/recruit/repository/CoreRecruitApplicationRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package inha.gdgoc.domain.core.recruit.repository; - -import inha.gdgoc.domain.core.recruit.entity.CoreRecruitApplication; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface CoreRecruitApplicationRepository extends JpaRepository { - Page findByNameContainingIgnoreCase(String name, Pageable pageable); -} - - diff --git a/src/main/java/inha/gdgoc/domain/core/recruit/service/CoreRecruitApplicationService.java b/src/main/java/inha/gdgoc/domain/core/recruit/service/CoreRecruitApplicationService.java deleted file mode 100644 index b251e91a..00000000 --- a/src/main/java/inha/gdgoc/domain/core/recruit/service/CoreRecruitApplicationService.java +++ /dev/null @@ -1,57 +0,0 @@ -package inha.gdgoc.domain.core.recruit.service; - -import inha.gdgoc.domain.core.recruit.dto.request.CoreRecruitApplicationRequest; -import inha.gdgoc.domain.core.recruit.dto.response.CoreRecruitApplicantDetailResponse; -import inha.gdgoc.domain.core.recruit.dto.response.CoreRecruitApplicantSummaryResponse; -import inha.gdgoc.domain.core.recruit.entity.CoreRecruitApplication; -import inha.gdgoc.domain.core.recruit.repository.CoreRecruitApplicationRepository; -import inha.gdgoc.global.exception.BusinessException; -import inha.gdgoc.global.exception.GlobalErrorCode; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class CoreRecruitApplicationService { - - private final CoreRecruitApplicationRepository repository; - - @Transactional - public Long create(CoreRecruitApplicationRequest request) { - CoreRecruitApplication entity = CoreRecruitApplication.builder() - .name(request.getName()) - .studentId(request.getStudentId()) - .phone(request.getPhone()) - .major(request.getMajor()) - .email(request.getEmail()) - .team(request.getTeam()) - .motivation(request.getMotivation()) - .wish(request.getWish()) - .strengths(request.getStrengths()) - .pledge(request.getPledge()) - .fileUrls(request.getFileUrls()) - .build(); - - return repository.save(entity).getId(); - } - - @Transactional(readOnly = true) - public Page findApplicantsPage(String question, Pageable pageable) { - if (question == null || question.isBlank()) { - return repository.findAll(pageable); - } - return repository.findByNameContainingIgnoreCase(question, pageable); - } - - @Transactional(readOnly = true) - public CoreRecruitApplicantDetailResponse getApplicantDetail(Long id) { - CoreRecruitApplication app = repository.findById(id) - .orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND)); - return CoreRecruitApplicantDetailResponse.from(app); - } -} - - diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/config/RecruitCoreProperties.java b/src/main/java/inha/gdgoc/domain/recruit/core/config/RecruitCoreProperties.java deleted file mode 100644 index 2462bb51..00000000 --- a/src/main/java/inha/gdgoc/domain/recruit/core/config/RecruitCoreProperties.java +++ /dev/null @@ -1,25 +0,0 @@ -package inha.gdgoc.domain.recruit.core.config; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -@Getter -@Setter -@Component -@ConfigurationProperties("recruit.core") -public class RecruitCoreProperties { - - /** - * 현재 모집 회차 (예: 2026-1). 환경 설정에서 주입된다. - */ - private String session; - - public String currentSession() { - if (session == null || session.isBlank()) { - throw new IllegalStateException("recruit.core.session 값이 설정되지 않았습니다."); - } - return session; - } -} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/config/RecruitCoreSessionResolver.java b/src/main/java/inha/gdgoc/domain/recruit/core/config/RecruitCoreSessionResolver.java new file mode 100644 index 00000000..6f3cade3 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/config/RecruitCoreSessionResolver.java @@ -0,0 +1,30 @@ +package inha.gdgoc.domain.recruit.core.config; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; +import org.springframework.stereotype.Component; + +/** + * 운영진 리크루팅 회차(예: 2026-1)를 현재 날짜 기준으로 계산한다. + * 1~6월은 1학기, 7~12월은 2학기로 본다. + */ +@Component +public class RecruitCoreSessionResolver { + + private final Clock clock; + + public RecruitCoreSessionResolver() { + this(Clock.system(ZoneId.of("Asia/Seoul"))); + } + + public RecruitCoreSessionResolver(Clock clock) { + this.clock = clock; + } + + public String currentSession() { + LocalDate today = LocalDate.now(clock); + int semester = (today.getMonthValue() <= 6) ? 1 : 2; + return today.getYear() + "-" + semester; + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java b/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java index a0e41835..b38b5f91 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java +++ b/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java @@ -1,6 +1,6 @@ package inha.gdgoc.domain.recruit.core.service; -import inha.gdgoc.domain.recruit.core.config.RecruitCoreProperties; +import inha.gdgoc.domain.recruit.core.config.RecruitCoreSessionResolver; import inha.gdgoc.domain.recruit.core.dto.request.RecruitCoreApplicationCreateRequest; import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreApplicantDetailResponse; import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreApplicationCreateResponse; @@ -28,7 +28,7 @@ public class RecruitCoreApplicationService { private final RecruitCoreApplicationRepository repository; private final UserRepository userRepository; - private final RecruitCoreProperties recruitCoreProperties; + private final RecruitCoreSessionResolver recruitCoreSessionResolver; @Transactional(readOnly = true) public RecruitCoreApplicantDetailResponse getApplicantDetail(Long id) { @@ -38,7 +38,7 @@ public RecruitCoreApplicantDetailResponse getApplicantDetail(Long id) { @Transactional(readOnly = true) public RecruitCoreEligibilityResponse checkEligibility(Long userId) { - String session = recruitCoreProperties.currentSession(); + String session = recruitCoreSessionResolver.currentSession(); return repository.findByUser_IdAndSession(userId, session) .map(app -> RecruitCoreEligibilityResponse.ineligible(session, "ALREADY_APPLIED", app.getId())) .orElseGet(() -> RecruitCoreEligibilityResponse.eligible(session)); @@ -52,7 +52,7 @@ public RecruitCorePrefillResponse prefill(Long userId) { @Transactional public RecruitCoreApplicationCreateResponse submit(Long userId, RecruitCoreApplicationCreateRequest request) { - String session = recruitCoreProperties.currentSession(); + String session = recruitCoreSessionResolver.currentSession(); repository.findByUser_IdAndSession(userId, session) .ifPresent(existing -> { throw new RecruitCoreAlreadyAppliedException(session, existing.getId()); @@ -85,7 +85,7 @@ public RecruitCoreApplicationCreateResponse submit(Long userId, RecruitCoreAppli @Transactional(readOnly = true) public RecruitCoreMyApplicationResponse getMyApplication(Long userId) { - String session = recruitCoreProperties.currentSession(); + String session = recruitCoreSessionResolver.currentSession(); RecruitCoreApplication application = repository.findByUser_IdAndSession(userId, session) .orElseThrow(RecruitCoreApplicationNotFoundException::new); return RecruitCoreMyApplicationResponse.from(application); diff --git a/src/main/java/inha/gdgoc/domain/test/controller/TestController.java b/src/main/java/inha/gdgoc/domain/test/controller/TestController.java deleted file mode 100644 index ae5b1214..00000000 --- a/src/main/java/inha/gdgoc/domain/test/controller/TestController.java +++ /dev/null @@ -1,31 +0,0 @@ -package inha.gdgoc.domain.test.controller; - -import inha.gdgoc.global.dto.response.ApiResponse; -import java.util.Map; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.CookieValue; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/v1/test") -public class TestController { - - @GetMapping("/login_test") - public ResponseEntity, Void>> loginTest( - @CookieValue(value = "refresh_token", required = false) String refreshToken, - @RequestHeader(value = "Authorization", required = false) String authorization - ) { - boolean hasRefreshToken = refreshToken != null && !refreshToken.isBlank(); - boolean hasAuthorization = authorization != null && !authorization.isBlank(); - - Map data = Map.of( - "has_refresh_token", hasRefreshToken, - "has_authorization", hasAuthorization - ); - - return ResponseEntity.ok(ApiResponse.ok("LOGIN_TEST_OK", data)); - } -} diff --git a/src/main/java/inha/gdgoc/global/security/SecurityConfig.java b/src/main/java/inha/gdgoc/global/security/SecurityConfig.java index 4ef5b2eb..775ccb35 100644 --- a/src/main/java/inha/gdgoc/global/security/SecurityConfig.java +++ b/src/main/java/inha/gdgoc/global/security/SecurityConfig.java @@ -49,7 +49,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/v1/game/**", "/api/v1/apply/**", "/api/v1/check/**", - "/api/v1/core-recruit", "/api/v1/fileupload", "/api/v1/manito/verify") .permitAll() @@ -118,4 +117,3 @@ public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } } - diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 855549ca..e7cdf2e6 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -2,24 +2,31 @@ server: forward-headers-strategy: framework spring: - web: - resources: - add-mappings: false + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY_ID} + secret-key: ${AWS_SECRET_ACCESS_KEY} + region: + static: ${AWS_REGION} config: import: optional:file:.env[.properties] - jackson: - time-zone: Asia/Seoul datasource: + driver-class-name: org.postgresql.Driver + password: ${SPRING_DATASOURCE_PASSWORD} url: ${SPRING_DATASOURCE_URL} username: ${SPRING_DATASOURCE_USERNAME} - password: ${SPRING_DATASOURCE_PASSWORD} - driver-class-name: org.postgresql.Driver - servlet: - multipart: - max-file-size: 10MB - max-request-size: 12MB + flyway: + baseline-on-migrate: false + clean-disabled: true + enabled: true + locations: classpath:db/migration + validate-migration-naming: true + jackson: + time-zone: Asia/Seoul jpa: database: postgresql + database-platform: org.hibernate.dialect.PostgreSQLDialect hibernate: ddl-auto: none properties: @@ -28,43 +35,29 @@ spring: jdbc: time_zone: UTC show-sql: false - database-platform: org.hibernate.dialect.PostgreSQLDialect - flyway: - enabled: true - baseline-on-migrate: false - clean-disabled: true - validate-migration-naming: true - locations: classpath:db/migration mail: host: smtp.gmail.com - port: 587 - username: ${GMAIL} password: ${GMAIL_PASSWORD} + port: 587 properties: mail: smtp: auth: true starttls: enable: true - cloud: - aws: - credentials: - access-key: ${AWS_ACCESS_KEY_ID} - secret-key: ${AWS_SECRET_ACCESS_KEY} - region: - static: ${AWS_REGION} - + username: ${GMAIL} + servlet: + multipart: + max-file-size: 10MB + max-request-size: 12MB + web: + resources: + add-mappings: false app: s3: bucket: ${AWS_TEST_RESOURCE_BUCKET} -logging: - level: - org.hibernate.SQL: debug - org.hibernate.type: off - - google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} @@ -72,5 +65,10 @@ google: jwt: googleIssuer: ${GOOGLE_ISSUER} - selfIssuer: ${SELF_ISSUER} secretKey: ${SECRET_KEY} + selfIssuer: ${SELF_ISSUER} + +logging: + level: + org.hibernate.SQL: debug + org.hibernate.type: off diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 96971d34..524a8a81 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -2,21 +2,29 @@ server: forward-headers-strategy: framework spring: - web: - resources: - add-mappings: false + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY_ID} + secret-key: ${AWS_SECRET_ACCESS_KEY} + region: + static: ${AWS_REGION} config: import: optional:file:.env[.properties] - jackson: - time-zone: Asia/Seoul datasource: + driver-class-name: org.postgresql.Driver + password: ${DB_PASSWORD} url: ${DB_URL} username: ${DB_USERNAME} - password: ${DB_PASSWORD} - servlet: - multipart: - max-file-size: 10MB - max-request-size: 12MB + flyway: + baseline-description: "Baseline existing schema" + baseline-on-migrate: false + baseline-version: 1 + enabled: true + locations: classpath:db/migration + schemas: public + jackson: + time-zone: Asia/Seoul jpa: database: postgresql hibernate: @@ -26,32 +34,24 @@ spring: default_batch_fetch_size: 100 jdbc: time_zone: UTC - flyway: - enabled: true - locations: classpath:db/migration - schemas: public - baseline-on-migrate: false - baseline-version: 1 - baseline-description: "Baseline existing schema" - mail: host: smtp.gmail.com - port: 587 - username: ${GMAIL} password: ${GMAIL_PASSWORD} + port: 587 properties: mail: smtp: auth: true starttls: enable: true - cloud: - aws: - credentials: - access-key: ${AWS_ACCESS_KEY_ID} - secret-key: ${AWS_SECRET_ACCESS_KEY} - region: - static: ${AWS_REGION} + username: ${GMAIL} + servlet: + multipart: + max-file-size: 10MB + max-request-size: 12MB + web: + resources: + add-mappings: false app: s3: @@ -62,13 +62,12 @@ google: client-secret: ${GOOGLE_CLIENT_SECRET} redirect-uri: ${GOOGLE_REDIRECT_URI} -logging: - level: - org.hibernate.SQL: debug - org.hibernate.type: trace - jwt: googleIssuer: ${GOOGLE_ISSUER} - selfIssuer: ${SELF_ISSUER} secretKey: ${SECRET_KEY} + selfIssuer: ${SELF_ISSUER} +logging: + level: + org.hibernate.SQL: debug + org.hibernate.type: trace diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index d71a1dda..1f5f5c6b 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -2,24 +2,31 @@ server: forward-headers-strategy: framework spring: - web: - resources: - add-mappings: false + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY_ID} + secret-key: ${AWS_SECRET_ACCESS_KEY} + region: + static: ${AWS_REGION} config: import: optional:file:.env[.properties] - jackson: - time-zone: Asia/Seoul datasource: + driver-class-name: org.postgresql.Driver + password: ${SPRING_DATASOURCE_PASSWORD} url: ${SPRING_DATASOURCE_URL} username: ${SPRING_DATASOURCE_USERNAME} - password: ${SPRING_DATASOURCE_PASSWORD} - driver-class-name: org.postgresql.Driver - servlet: - multipart: - max-file-size: 10MB - max-request-size: 12MB + flyway: + baseline-on-migrate: false + clean-disabled: true + enabled: true + locations: classpath:db/migration + validate-migration-naming: true + jackson: + time-zone: Asia/Seoul jpa: database: postgresql + database-platform: org.hibernate.dialect.PostgreSQLDialect hibernate: ddl-auto: none properties: @@ -28,42 +35,29 @@ spring: jdbc: time_zone: UTC show-sql: false - database-platform: org.hibernate.dialect.PostgreSQLDialect - flyway: - enabled: true - baseline-on-migrate: false - clean-disabled: true - validate-migration-naming: true - locations: classpath:db/migration mail: host: smtp.gmail.com - port: 587 - username: ${GMAIL} password: ${GMAIL_PASSWORD} + port: 587 properties: mail: smtp: auth: true starttls: enable: true - cloud: - aws: - credentials: - access-key: ${AWS_ACCESS_KEY_ID} - secret-key: ${AWS_SECRET_ACCESS_KEY} - region: - static: ${AWS_REGION} + username: ${GMAIL} + servlet: + multipart: + max-file-size: 10MB + max-request-size: 12MB + web: + resources: + add-mappings: false app: s3: bucket: ${AWS_RESOURCE_BUCKET} -logging: - level: - org.hibernate.SQL: debug - org.hibernate.type: off - - google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} @@ -71,6 +65,10 @@ google: jwt: googleIssuer: ${GOOGLE_ISSUER} - selfIssuer: ${SELF_ISSUER} secretKey: ${SECRET_KEY} + selfIssuer: ${SELF_ISSUER} +logging: + level: + org.hibernate.SQL: debug + org.hibernate.type: off diff --git a/src/test/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationServiceTest.java b/src/test/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationServiceTest.java new file mode 100644 index 00000000..36fe58c4 --- /dev/null +++ b/src/test/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationServiceTest.java @@ -0,0 +1,224 @@ +package inha.gdgoc.domain.recruit.core.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import inha.gdgoc.domain.recruit.core.config.RecruitCoreSessionResolver; +import inha.gdgoc.domain.recruit.core.dto.request.RecruitCoreApplicationCreateRequest; +import inha.gdgoc.domain.recruit.core.dto.request.RecruitCoreApplicationCreateRequest.RecruitCoreApplicationSnapshotRequest; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreApplicantDetailResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreEligibilityResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreApplicationCreateResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreMyApplicationResponse; +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import inha.gdgoc.domain.recruit.core.exception.RecruitCoreAlreadyAppliedException; +import inha.gdgoc.domain.recruit.core.exception.RecruitCoreApplicationNotFoundException; +import inha.gdgoc.domain.recruit.core.repository.RecruitCoreApplicationRepository; +import inha.gdgoc.domain.recruit.core.enums.RecruitCoreResultStatus; +import inha.gdgoc.domain.user.entity.User; +import inha.gdgoc.domain.user.enums.UserRole; +import inha.gdgoc.domain.user.repository.UserRepository; +import inha.gdgoc.global.exception.BusinessException; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class RecruitCoreApplicationServiceTest { + + private static final String SESSION = "2026-1"; + + @Mock + private RecruitCoreApplicationRepository repository; + + @Mock + private UserRepository userRepository; + + @Mock + private RecruitCoreSessionResolver recruitCoreSessionResolver; + + @InjectMocks + private RecruitCoreApplicationService service; + + @BeforeEach + void setUp() { + lenient().when(recruitCoreSessionResolver.currentSession()).thenReturn(SESSION); + } + + @Test + void checkEligibility_whenNoApplication_returnsEligible() { + when(repository.findByUser_IdAndSession(1L, SESSION)).thenReturn(Optional.empty()); + + RecruitCoreEligibilityResponse response = service.checkEligibility(1L); + + assertThat(response.eligible()).isTrue(); + assertThat(response.session()).isEqualTo(SESSION); + assertThat(response.applicationId()).isNull(); + } + + @Test + void checkEligibility_whenApplicationExists_returnsIneligible() { + RecruitCoreApplication existing = createApplication(10L, createUser(1L), SESSION); + when(repository.findByUser_IdAndSession(1L, SESSION)).thenReturn(Optional.of(existing)); + + RecruitCoreEligibilityResponse response = service.checkEligibility(1L); + + assertThat(response.eligible()).isFalse(); + assertThat(response.reason()).isEqualTo("ALREADY_APPLIED"); + assertThat(response.applicationId()).isEqualTo(10L); + } + + @Test + void submit_whenEligible_savesApplication() { + RecruitCoreApplicationCreateRequest request = sampleRequest(); + User user = createUser(1L); + RecruitCoreApplication saved = createApplication(55L, user, SESSION); + when(repository.findByUser_IdAndSession(1L, SESSION)).thenReturn(Optional.empty()); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(repository.save(any())).thenReturn(saved); + + RecruitCoreApplicationCreateResponse response = service.submit(1L, request); + + assertThat(response.applicationId()).isEqualTo(55L); + assertThat(response.session()).isEqualTo(SESSION); + assertThat(response.resultStatus()).isEqualTo(RecruitCoreResultStatus.SUBMITTED); + assertThat(response.submittedAt()).isNotNull(); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(RecruitCoreApplication.class); + verify(repository).save(captor.capture()); + RecruitCoreApplication toSave = captor.getValue(); + assertThat(toSave.getUser()).isEqualTo(user); + assertThat(toSave.getSession()).isEqualTo(SESSION); + assertThat(toSave.getTeam()).isEqualTo("TECH"); + assertThat(toSave.getFileUrls()).containsExactly("https://file"); + } + + @Test + void submit_whenAlreadyApplied_throwsException() { + RecruitCoreApplication existing = createApplication(77L, createUser(1L), SESSION); + when(repository.findByUser_IdAndSession(1L, SESSION)).thenReturn(Optional.of(existing)); + + assertThatThrownBy(() -> service.submit(1L, sampleRequest())) + .isInstanceOf(RecruitCoreAlreadyAppliedException.class); + } + + @Test + void getMyApplication_whenExists_returnsResponse() { + RecruitCoreApplication existing = createApplication(33L, createUser(1L), SESSION); + when(repository.findByUser_IdAndSession(1L, SESSION)).thenReturn(Optional.of(existing)); + + RecruitCoreMyApplicationResponse response = service.getMyApplication(1L); + + assertThat(response.applicationId()).isEqualTo(33L); + assertThat(response.session()).isEqualTo(SESSION); + assertThat(response.team()).isEqualTo("TECH"); + } + + @Test + void getMyApplication_whenMissing_throwsException() { + when(repository.findByUser_IdAndSession(1L, SESSION)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getMyApplication(1L)) + .isInstanceOf(RecruitCoreApplicationNotFoundException.class); + } + + @Test + void getApplicantDetailForViewer_whenOwnerAlllowed() { + RecruitCoreApplication application = createApplication(99L, createUser(1L), SESSION); + when(repository.findById(99L)).thenReturn(Optional.of(application)); + + RecruitCoreApplicantDetailResponse detail = + service.getApplicantDetailForViewer(99L, 1L, UserRole.MEMBER); + + assertThat(detail.applicationId()).isEqualTo(99L); + } + + @Test + void getApplicantDetailForViewer_whenUnauthorized_throwsException() { + RecruitCoreApplication application = createApplication(99L, createUser(2L), SESSION); + when(repository.findById(99L)).thenReturn(Optional.of(application)); + + assertThatThrownBy(() -> service.getApplicantDetailForViewer(99L, 1L, UserRole.MEMBER)) + .isInstanceOf(BusinessException.class); + } + + @Test + void prefill_returnsUserSnapshot() { + User user = createUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + var response = service.prefill(1L); + + assertThat(response.name()).isEqualTo("홍길동"); + assertThat(response.email()).isEqualTo("hong@inha.edu"); + } + + private RecruitCoreApplicationCreateRequest sampleRequest() { + RecruitCoreApplicationSnapshotRequest snapshot = + new RecruitCoreApplicationSnapshotRequest( + "홍길동", "12201234", "01012345678", "컴퓨터공학과", "hong@inha.edu"); + return new RecruitCoreApplicationCreateRequest( + snapshot, + "TECH", + "motivation", + "wish", + "strengths", + "pledge", + List.of("https://file")); + } + + private User createUser(Long id) { + User user = User.builder() + .name("홍길동") + .major("컴퓨터공학과") + .studentId("12201234") + .phoneNumber("01012345678") + .email("hong@inha.edu") + .password("encoded") + .userRole(UserRole.GUEST) + .team(null) + .salt(new byte[]{1}) + .image(null) + .social(null) + .careers(null) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private RecruitCoreApplication createApplication(Long id, User user, String session) { + RecruitCoreApplication application = RecruitCoreApplication.builder() + .user(user) + .session(session) + .name("홍길동") + .studentId("12201234") + .phone("01012345678") + .major("컴퓨터공학과") + .email(user.getEmail()) + .team("TECH") + .motivation("motivation") + .wish("wish") + .strengths("strengths") + .pledge("pledge") + .fileUrls(List.of()) + .resultStatus(RecruitCoreResultStatus.SUBMITTED) + .build(); + ReflectionTestUtils.setField(application, "id", id); + ReflectionTestUtils.setField(application, "createdAt", Instant.now()); + ReflectionTestUtils.setField(application, "updatedAt", Instant.now()); + return application; + } +} diff --git a/src/test/java/inha/gdgoc/domain/study/service/StudyAttendeeServiceTest.java b/src/test/java/inha/gdgoc/domain/study/service/StudyAttendeeServiceTest.java deleted file mode 100644 index 1d1a01ee..00000000 --- a/src/test/java/inha/gdgoc/domain/study/service/StudyAttendeeServiceTest.java +++ /dev/null @@ -1,351 +0,0 @@ -package inha.gdgoc.domain.study.service; - -import inha.gdgoc.domain.study.dto.AttendeeUpdateDto; -import inha.gdgoc.domain.study.dto.StudyAttendeeListWithMetaDto; -import inha.gdgoc.domain.study.dto.request.AttendeeCreateRequest; -import inha.gdgoc.domain.study.dto.request.AttendeeUpdateRequest; -import inha.gdgoc.domain.study.dto.response.GetStudyAttendeeResponse; -import inha.gdgoc.domain.study.entity.Study; -import inha.gdgoc.domain.study.entity.StudyAttendee; -import inha.gdgoc.domain.study.enums.AttendeeStatus; -import inha.gdgoc.domain.study.enums.CreatorType; -import inha.gdgoc.domain.study.enums.StudyStatus; -import inha.gdgoc.domain.study.repository.StudyAttendeeRepository; -import inha.gdgoc.domain.study.repository.StudyRepository; -import inha.gdgoc.domain.user.entity.User; -import inha.gdgoc.domain.user.enums.UserRole; -import inha.gdgoc.domain.user.repository.UserRepository; -import jakarta.transaction.Transactional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import java.security.SecureRandom; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; - -@SpringBootTest -@Transactional -class StudyAttendeeServiceTest { - - @Autowired - private StudyAttendeeService studyAttendeeService; - - @Autowired - private StudyRepository studyRepository; - - @Autowired - private StudyAttendeeRepository studyAttendeeRepository; - - @Autowired - private UserRepository userRepository; - - private User user; - - @BeforeEach - void setUp() { - user = createUser(UserRole.GUEST); - userRepository.save(user); - } - - @DisplayName("스터디 참석자 목록을 페이징하여 조회한다.") - @Test - void getAttendeeListPaging() { - // given - Study study = createStudy("페이징 참석자 테스트", user); - studyRepository.save(study); - - for (int i = 0; i < 15; i++) { - User attendeeUser = createUser(UserRole.GUEST); - userRepository.save(attendeeUser); - - StudyAttendee attendee = StudyAttendee.builder() - .study(study) - .user(attendeeUser) - .status(AttendeeStatus.APPROVED) - .introduce("소개 " + i) - .activityTime("시간 " + i) - .build(); - - studyAttendeeRepository.save(attendee); - } - - // when - StudyAttendeeListWithMetaDto pageOneResult = studyAttendeeService.getStudyAttendeeList( - study.getId(), - Optional.of(1L) - ); - - StudyAttendeeListWithMetaDto pageTwoResult = studyAttendeeService.getStudyAttendeeList( - study.getId(), - Optional.of(2L) - ); - - // then - assertThat(pageOneResult).isNotNull(); - assertThat(pageOneResult.getAttendees()).hasSize(10); - assertThat(pageOneResult.getPage()).isEqualTo(1); - assertThat(pageOneResult.getPageCount()).isGreaterThanOrEqualTo(15); - - assertThat(pageTwoResult).isNotNull(); - assertThat(pageTwoResult.getAttendees()).hasSize(5); - assertThat(pageTwoResult.getPage()).isEqualTo(2); - assertThat(pageTwoResult.getPageCount()).isGreaterThanOrEqualTo(15); - } - - @DisplayName("스터디 지원자의 상세 정보를 조회한다.") - @Test - void getStudyAttendeeDetail() { - // given - Study study = createStudy("상세 정보 테스트 스터디", user); - studyRepository.save(study); - - String findName = "테스트"; - String findPhoneNumber = "010-1234-5678"; - String findMajor = "컴퓨터공학과"; - String findStudentId = "12212444"; - - String findIntroduce = "저는 사실 엄청 멋있는 사람입니다!"; - String findActivityTime = "수요일만 아니면 다 5시 이후로 가능!"; - - User attendeeUser = User.builder() - .name(findName) - .phoneNumber(findPhoneNumber) - .major(findMajor) - .studentId(findStudentId) - .email("email@example.com") - .password("pass") - .salt(new byte[16]) - .userRole(UserRole.GUEST) - .build(); - userRepository.save(attendeeUser); - - StudyAttendee attendee = StudyAttendee.builder() - .study(study) - .user(attendeeUser) - .status(AttendeeStatus.REQUESTED) - .introduce(findIntroduce) - .activityTime(findActivityTime) - .build(); - studyAttendeeRepository.save(attendee); - - // when - GetStudyAttendeeResponse response = studyAttendeeService.getStudyAttendee(user.getId(), study.getId(), - attendeeUser.getId()); - - // then - assertThat(response).isNotNull(); - assertThat(response.getName()).isEqualTo(findName); - assertThat(response.getPhone()).isEqualTo(findPhoneNumber); - assertThat(response.getMajor()).isEqualTo(findMajor); - assertThat(response.getStudentId()).isEqualTo(findStudentId); - assertThat(response.getIntroduce()).isEqualTo(findIntroduce); - assertThat(response.getActivityTime()).isEqualTo(findActivityTime); - } - - - @DisplayName("스터디에 정상적으로 지원자를 등록한다.") - @Test - void createAttendee() { - // given - Study study = createStudy("정상 지원 스터디", user); - studyRepository.save(study); - - User attendeeUser = createUser(UserRole.MEMBER); - userRepository.save(attendeeUser); - - String findIntroduce = "저는 열정 가득한 사람입니다."; - String findActivityTime = "주말 오후"; - - AttendeeCreateRequest request = AttendeeCreateRequest.builder() - .introduce(findIntroduce) - .activityTime(findActivityTime) - .build(); - - // when - GetStudyAttendeeResponse response = studyAttendeeService.createAttendee(attendeeUser.getId(), study.getId(), request); - - // then - assertThat(response).isNotNull(); - assertThat(response.getName()).isEqualTo(attendeeUser.getName()); - assertThat(response.getIntroduce()).isEqualTo(findIntroduce); - assertThat(response.getActivityTime()).isEqualTo(findActivityTime); - } - - - @DisplayName("GUEST 유저는 스터디에 지원할 수 없다.") - @Test - void createAttendee_guestUserForbidden() { - // given - Study study = createStudy("게스트 예외 스터디", user); - studyRepository.save(study); - - User guestUser = createUser(UserRole.GUEST); - userRepository.save(guestUser); - - AttendeeCreateRequest request = AttendeeCreateRequest.builder() - .introduce("참여하고 싶어요.") - .activityTime("평일 오후") - .build(); - - // when & then - assertThatThrownBy(() -> studyAttendeeService.createAttendee(guestUser.getId(), study.getId(), request)) - .isInstanceOf(RuntimeException.class) - .hasMessageContaining("사용 권한이 없는 유저입니다."); - } - - @DisplayName("스터디 참석자들의 상태를 일괄 수정한다.") - @Test - void updateAttendeeStatusBulk() { - // given - Study study = createStudy("상태 수정 테스트용 스터디", user); - studyRepository.save(study); - - User user1 = createUser(UserRole.GUEST); - User user2 = createUser(UserRole.GUEST); - userRepository.saveAll(List.of(user1, user2)); - - StudyAttendee attendee1 = StudyAttendee.builder() - .study(study) - .user(user1) - .status(AttendeeStatus.REQUESTED) - .introduce("참석자1") - .activityTime("월요일") - .build(); - - StudyAttendee attendee2 = StudyAttendee.builder() - .study(study) - .user(user2) - .status(AttendeeStatus.REQUESTED) - .introduce("참석자2") - .activityTime("화요일") - .build(); - - studyAttendeeRepository.saveAll(List.of(attendee1, attendee2)); - - // when - AttendeeStatus findStatus_1 = AttendeeStatus.APPROVED; - AttendeeStatus findStatus_2 = AttendeeStatus.REJECTED; - - AttendeeUpdateRequest updateRequest = AttendeeUpdateRequest.builder() - .attendees(List.of( - AttendeeUpdateDto.builder() - .attendeeId(attendee1.getId()) - .status(findStatus_1) - .build(), - AttendeeUpdateDto.builder() - .attendeeId(attendee2.getId()) - .status(findStatus_2) - .build() - )) - .build(); - - studyAttendeeService.updateAttendee(user.getId(), study.getId(), updateRequest); - - // then - StudyAttendee updated1 = studyAttendeeRepository.findById(attendee1.getId()).orElseThrow(); - StudyAttendee updated2 = studyAttendeeRepository.findById(attendee2.getId()).orElseThrow(); - - assertThat(updated1.getStatus()).isEqualTo(findStatus_1); - assertThat(updated2.getStatus()).isEqualTo(findStatus_2); - } - - @DisplayName("스터디 참석자들의 상태를 일괄 수정한다. 단, 생성자만 수정할 수 있다.") - @Test - void updateAttendeeStatusBulkOnlyCreatorUser() { - // given - Study study = createStudy("상태 수정 테스트용 스터디", user); - studyRepository.save(study); - - User user1 = createUser(UserRole.GUEST); - User user2 = createUser(UserRole.GUEST); - userRepository.saveAll(List.of(user1, user2)); - - StudyAttendee attendee1 = StudyAttendee.builder() - .study(study) - .user(user1) - .status(AttendeeStatus.REQUESTED) - .introduce("참석자1") - .activityTime("월요일") - .build(); - - StudyAttendee attendee2 = StudyAttendee.builder() - .study(study) - .user(user2) - .status(AttendeeStatus.REQUESTED) - .introduce("참석자2") - .activityTime("화요일") - .build(); - - studyAttendeeRepository.saveAll(List.of(attendee1, attendee2)); - - // when - AttendeeStatus findStatus_1 = AttendeeStatus.APPROVED; - AttendeeStatus findStatus_2 = AttendeeStatus.REJECTED; - - AttendeeUpdateRequest updateRequest = AttendeeUpdateRequest.builder() - .attendees(List.of( - AttendeeUpdateDto.builder() - .attendeeId(attendee1.getId()) - .status(findStatus_1) - .build(), - AttendeeUpdateDto.builder() - .attendeeId(attendee2.getId()) - .status(findStatus_2) - .build() - )) - .build(); - - assertThatThrownBy(() -> studyAttendeeService.updateAttendee(user1.getId(), study.getId(), updateRequest)) - .isInstanceOf(IllegalArgumentException.class); - } - - private User createUser( - UserRole userRole - ) { - byte[] salt = new byte[16]; - SecureRandom random = new SecureRandom(); - random.nextBytes(salt); - - return User.builder() - .name("name") - .major("major") - .studentId("studentId") - .phoneNumber("phoneNumber") - .email("email") - .password("hashedPassword") - .salt(salt) - .userRole(userRole) - .studies(new ArrayList<>()) - .studyAttendees(new ArrayList<>()) - .build(); - } - - private Study createStudy( - String title, - User user - ) { - return Study.builder() - .title(title) - .simpleIntroduce("간단한 소개") - .activityIntroduce("활동 소개") - .imagePath("test url") - .creatorType(CreatorType.PERSONAL) - .status(StudyStatus.RECRUITED) - .expectedTime("매일매일") - .expectedPlace("인하대정문") - .recruitStartDate(LocalDateTime.now()) - .recruitEndDate(LocalDateTime.now()) - .activityStartDate(LocalDateTime.now()) - .activityEndDate(LocalDateTime.now()) - .user(user) - .build(); - } -} \ No newline at end of file diff --git a/src/test/java/inha/gdgoc/domain/study/service/StudyServiceTest.java b/src/test/java/inha/gdgoc/domain/study/service/StudyServiceTest.java deleted file mode 100644 index 3680ebed..00000000 --- a/src/test/java/inha/gdgoc/domain/study/service/StudyServiceTest.java +++ /dev/null @@ -1,322 +0,0 @@ -package inha.gdgoc.domain.study.service; - -import inha.gdgoc.domain.resource.service.S3Service; -import inha.gdgoc.domain.study.dto.StudyAttendeeResultDto; -import inha.gdgoc.domain.study.dto.StudyDto; -import inha.gdgoc.domain.study.dto.StudyListWithMetaDto; -import inha.gdgoc.domain.study.dto.request.StudyCreateRequest; -import inha.gdgoc.domain.study.dto.response.GetCreatorResponse; -import inha.gdgoc.domain.study.dto.response.GetDetailedStudyResponse; -import inha.gdgoc.domain.study.dto.response.MyStudyRecruitResponse; -import inha.gdgoc.domain.study.entity.Study; -import inha.gdgoc.domain.study.entity.StudyAttendee; -import inha.gdgoc.domain.study.enums.AttendeeStatus; -import inha.gdgoc.domain.study.enums.CreatorType; -import inha.gdgoc.domain.study.enums.StudyStatus; -import inha.gdgoc.domain.study.repository.StudyAttendeeRepository; -import inha.gdgoc.domain.study.repository.StudyRepository; -import inha.gdgoc.domain.user.entity.User; -import inha.gdgoc.domain.user.enums.UserRole; -import inha.gdgoc.domain.user.repository.UserRepository; -import jakarta.transaction.Transactional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.bean.override.mockito.MockitoBean; - -import java.security.SecureRandom; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.when; - - -@SpringBootTest -@Transactional -class StudyServiceTest { - - @MockitoBean - private S3Service s3Service; - - @Autowired - private StudyService studyService; - - @Autowired - private StudyAttendeeService studyAttendeeService; - - @Autowired - private StudyRepository studyRepository; - - @Autowired - private StudyAttendeeRepository studyAttendeeRepository; - - @Autowired - private UserRepository userRepository; - - private User user; - - @BeforeEach - void setUp() { - user = createUser(); - userRepository.save(user); - when(s3Service.getS3FileUrl(anyString())).thenReturn("http://test.image"); - } - - - @DisplayName("해당 스터디 정보를 id로 조회한다.") - @Test - void getStudyById() { - // given - String findTitle = "테스트제목"; - Study findStudy = createStudy(findTitle, user); - studyRepository.save(findStudy); - - // when - GetDetailedStudyResponse resultStudy = studyService.getStudyById(findStudy.getId()); - - // then - assertThat(resultStudy).isNotNull(); - assertThat(resultStudy.creator()).isEqualTo(GetCreatorResponse.from(user)); - assertThat(resultStudy.title()).isEqualTo(findTitle); - } - - @DisplayName("해당 스터디 id가 없다면 에러가 발생한다.") - @Test - void getStudyByIdNotFound() { - // then - assertThatThrownBy(() -> { - studyService.getStudyById(99999L); - }).isInstanceOf(RuntimeException.class); - } - - @DisplayName("스터디 목록을 페이징하여 조회한다.") - @Test - void getStudyList() { - // given - for (int i = 0; i < 15; i++) { - studyRepository.save(createStudy("스터디" + i, user)); - } - - // when - StudyListWithMetaDto page_ONE_Result = studyService.getStudyList( - Optional.of(1L), - Optional.empty(), - Optional.empty() - ); - - StudyListWithMetaDto page_TWO_Result = studyService.getStudyList( - Optional.of(2L), - Optional.empty(), - Optional.empty() - ); - - // then - assertThat(page_ONE_Result).isNotNull(); - assertThat(page_ONE_Result.getStudyList()).hasSize(10); - assertThat(page_ONE_Result.getPage()).isEqualTo(1L); - assertThat(page_ONE_Result.getPageCount()).isGreaterThanOrEqualTo(15); - - assertThat(page_TWO_Result).isNotNull(); - assertThat(page_TWO_Result.getStudyList()).hasSize(5); - assertThat(page_TWO_Result.getPage()).isEqualTo(2L); - assertThat(page_TWO_Result.getPageCount()).isGreaterThanOrEqualTo(15); - } - - @DisplayName("page가 1보다 작으면 예외가 발생한다.") - @Test - void getStudyListInvalidPage() { - // then - assertThatThrownBy(() -> { - studyService.getStudyList( - Optional.of(0L), - Optional.empty(), - Optional.empty() - ); - }).isInstanceOf(RuntimeException.class) - .hasMessageContaining("page가 1보다 작을 수 없습니다"); - } - - - @DisplayName("스터디를 생성한다.") - @Test - void createStudy() { - // given - String findTitle = "스터디 제목"; - StudyCreateRequest request = StudyCreateRequest.builder() - .title(findTitle) - .simpleIntroduce("간단한 소개") - .activityIntroduce("활동 소개") - .creatorType(CreatorType.PERSONAL) - .expectedTime("오후 2시") - .expectedPlace("인하대학교 도서관") - .recruitStartDate(LocalDateTime.of(2025, 5, 10, 12, 0)) - .recruitEndDate(LocalDateTime.of(2025, 5, 15, 18, 0)) - .activityStartDate(LocalDateTime.of(2025, 5, 20, 14, 0)) - .activityEndDate(LocalDateTime.of(2025, 6, 20, 16, 0)) - .build(); - - // when - StudyDto result = studyService.createStudy(user.getId(), request); - - // then - assertThat(result).isNotNull(); - assertThat(result.getCreatorId()).isEqualTo(user.getId()); - assertThat(result.getTitle()).isEqualTo(findTitle); - - Study saved = studyRepository.findById(result.getId()).orElseThrow(); - assertThat(saved.getUser().getId()).isEqualTo(user.getId()); - assertThat(saved.getTitle()).isEqualTo(findTitle); - } - - @DisplayName("특정 지원자의 스터디 결과 리스트를 조회한다.") - @Test - void getStudyAttendeeResultListByUserId() { - // given - String resultTitle_1 = "AI 스터디"; - String resultIntroduce_1 = "AI에 관심 많습니다."; - String resultActivityTime_1 = "저녁"; - AttendeeStatus resultStatus_1 = AttendeeStatus.APPROVED; - - String resultTitle_2 = "블록체인 스터디"; - String resultIntroduce_2 = "블록체인도 배우고 싶어요."; - String resultActivityTime_2 = "주말"; - AttendeeStatus resultStatus_2 = AttendeeStatus.REQUESTED; - - Study study1 = createStudy(resultTitle_1, user); - Study study2 = createStudy(resultTitle_2, user); - studyRepository.saveAll(List.of(study1, study2)); - - - StudyAttendee attendee1 = StudyAttendee.builder() - .study(study1) - .user(user) - .status(resultStatus_1) - .introduce(resultIntroduce_1) - .activityTime(resultActivityTime_1) - .build(); - - StudyAttendee attendee2 = StudyAttendee.builder() - .study(study2) - .user(user) - .status(resultStatus_2) - .introduce(resultIntroduce_2) - .activityTime(resultActivityTime_2) - .build(); - - studyAttendeeRepository.saveAll(List.of(attendee1, attendee2)); - - // when - List result = studyAttendeeService.getStudyAttendeeResultListByUserId(user.getId()); - - // then - StudyAttendeeResultDto dto1 = result.get(1); - StudyAttendeeResultDto dto2 = result.get(0); - - assertThat(result).hasSize(2); - assertThat(dto1.getStudyId()).isEqualTo(study1.getId()); - assertThat(dto1.getTitle()).isEqualTo(resultTitle_1); - assertThat(dto1.getStatus()).isEqualTo(resultStatus_1); - - assertThat(dto2.getStudyId()).isEqualTo(study2.getId()); - assertThat(dto2.getTitle()).isEqualTo(resultTitle_2); - assertThat(dto2.getStatus()).isEqualTo(resultStatus_2); - } - - @DisplayName("내가 만든 스터디 목록을 모집 상태별로 조회한다.") - @Test - void getMyStudyList() { - // given - User creator = createUser(); - userRepository.save(creator); - - String find_recruiting_title = "AI 스터디"; - String find_recruited_title = "블록체인 스터디"; - - Study recruitingStudy1 = createRecruitStudy( - find_recruiting_title, - LocalDateTime.of(2025, 4, 10, 0, 0), - LocalDateTime.of(2025, 6, 10, 0, 0), - StudyStatus.RECRUITING, - creator - ); - - Study recruitedStudy1 = createRecruitStudy( - find_recruited_title, - LocalDateTime.of(2025, 3, 1, 0, 0), - LocalDateTime.of(2025, 4, 30, 0, 0), - StudyStatus.RECRUITED, - creator - ); - - studyRepository.saveAll(List.of(recruitingStudy1, recruitedStudy1)); - - // when - MyStudyRecruitResponse response = studyService.getMyStudyList(creator.getId()); - - // then - assertThat(response).isNotNull(); - assertThat(response.getRecruiting()).hasSize(1); - assertThat(response.getRecruiting().get(0).getTitle()).isEqualTo(find_recruiting_title); - - assertThat(response.getRecruited()).hasSize(1); - assertThat(response.getRecruited().get(0).getTitle()).isEqualTo(find_recruited_title); - } - - - private User createUser() { - byte[] salt = new byte[16]; - SecureRandom random = new SecureRandom(); - random.nextBytes(salt); - - return User.builder() - .name("name") - .major("major") - .studentId("studentId") - .phoneNumber("phoneNumber") - .email("email") - .password("hashedPassword") - .salt(salt) - .userRole(UserRole.GUEST) - .studies(new ArrayList<>()) - .studyAttendees(new ArrayList<>()) - .build(); - } - - private Study createStudy( - String title, - User user - ) { - return this.createRecruitStudy(title, LocalDateTime.now(), LocalDateTime.now(), StudyStatus.RECRUITED, user); - } - - private Study createRecruitStudy( - String title, - LocalDateTime activityStartDate, - LocalDateTime activityEndDate, - StudyStatus status, - User user - ) { - return Study.builder() - .title(title) - .simpleIntroduce("간단한 소개") - .activityIntroduce("활동 소개") - .imagePath("test url") - .creatorType(CreatorType.PERSONAL) - .status(status) - .expectedTime("매일매일") - .expectedPlace("인하대정문") - .recruitStartDate(LocalDateTime.now()) - .recruitEndDate(LocalDateTime.now()) - .activityStartDate(activityStartDate) - .activityEndDate(activityEndDate) - .user(user) - .build(); - } -} \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 50c68664..e42b4eee 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -2,22 +2,25 @@ server: forward-headers-strategy: none spring: - jackson: - time-zone: Asia/Seoul - + cloud: + aws: + credentials: + access-key: test + secret-key: test + region: + static: ap-northeast-2 datasource: driver-class-name: org.h2.Driver + password: url: jdbc:h2:mem:gdgoc-test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE username: sa - password: - - servlet: - multipart: - max-file-size: 10MB - max-request-size: 12MB - + flyway: + enabled: false + jackson: + time-zone: Asia/Seoul jpa: database: h2 + database-platform: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: create-drop properties: @@ -26,43 +29,29 @@ spring: format_sql: true show_sql: false time_zone: Asia/Seoul - database-platform: org.hibernate.dialect.H2Dialect show-sql: false - - flyway: - enabled: false - mail: host: localhost - port: 2525 - username: test password: test + port: 2525 properties: mail: smtp: auth: false starttls: enable: false + username: test main: allow-bean-definition-overriding: true - - cloud: - aws: - credentials: - access-key: test - secret-key: test - region: - static: ap-northeast-2 + servlet: + multipart: + max-file-size: 10MB + max-request-size: 12MB app: s3: bucket: test-bucket -logging: - level: - org.hibernate.SQL: warn - org.hibernate.type: warn - google: client-id: test-client-id client-secret: test-client-secret @@ -70,6 +59,10 @@ google: jwt: googleIssuer: test-google-issuer - selfIssuer: test-self-issuer secretKey: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= + selfIssuer: test-self-issuer +logging: + level: + org.hibernate.SQL: warn + org.hibernate.type: warn From d476cc5fdd3beadfa691e6813d78f6c62b497b98 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 15 Jan 2026 02:19:38 +0900 Subject: [PATCH 12/49] refactor: move recruit member module under dedicated namespace --- .../controller/RecruitMemberController.java | 158 ------------------ .../dto/request/RecruitMemberRequest.java | 48 ------ .../recruit/enums/AdmissionSemester.java | 5 - .../message/RecruitMemberMessage.java | 2 +- .../dto/request/ApplicationRequest.java | 2 +- .../dto/request/CheckPhoneNumberRequest.java | 2 +- .../dto/request/PaymentUpdateRequest.java | 2 +- .../dto/response/AnswerResponse.java | 6 +- .../dto/response/AnswersResponse.java | 4 +- .../response/CheckPhoneNumberResponse.java | 2 +- .../dto/response/CheckStudentIdResponse.java | 2 +- .../RecruitMemberSummaryResponse.java | 4 +- .../dto/response/SpecifiedMemberResponse.java | 6 +- .../recruit/{ => member}/entity/Answer.java | 6 +- .../{ => member}/entity/RecruitMember.java | 8 +- .../member/enums/AdmissionSemester.java | 5 + .../enums/EnrolledClassification.java | 2 +- .../recruit/{ => member}/enums/Gender.java | 2 +- .../recruit/{ => member}/enums/InputType.java | 2 +- .../{ => member}/enums/SurveyType.java | 2 +- .../exception/RecruitMemberErrorCode.java | 2 +- .../exception/RecruitMemberException.java | 2 +- .../repository/AnswerRepository.java | 8 +- .../repository/RecruitMemberRepository.java | 4 +- .../recruit/service/RecruitMemberService.java | 94 ----------- .../service/RecruitMemberServiceTest.java | 19 +-- 26 files changed, 46 insertions(+), 353 deletions(-) delete mode 100644 src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java delete mode 100644 src/main/java/inha/gdgoc/domain/recruit/dto/request/RecruitMemberRequest.java delete mode 100644 src/main/java/inha/gdgoc/domain/recruit/enums/AdmissionSemester.java rename src/main/java/inha/gdgoc/domain/recruit/{ => member}/controller/message/RecruitMemberMessage.java (93%) rename src/main/java/inha/gdgoc/domain/recruit/{ => member}/dto/request/ApplicationRequest.java (83%) rename src/main/java/inha/gdgoc/domain/recruit/{ => member}/dto/request/CheckPhoneNumberRequest.java (88%) rename src/main/java/inha/gdgoc/domain/recruit/{ => member}/dto/request/PaymentUpdateRequest.java (55%) rename src/main/java/inha/gdgoc/domain/recruit/{ => member}/dto/response/AnswerResponse.java (89%) rename src/main/java/inha/gdgoc/domain/recruit/{ => member}/dto/response/AnswersResponse.java (79%) rename src/main/java/inha/gdgoc/domain/recruit/{ => member}/dto/response/CheckPhoneNumberResponse.java (53%) rename src/main/java/inha/gdgoc/domain/recruit/{ => member}/dto/response/CheckStudentIdResponse.java (52%) rename src/main/java/inha/gdgoc/domain/recruit/{ => member}/dto/response/RecruitMemberSummaryResponse.java (88%) rename src/main/java/inha/gdgoc/domain/recruit/{ => member}/dto/response/SpecifiedMemberResponse.java (80%) rename src/main/java/inha/gdgoc/domain/recruit/{ => member}/entity/Answer.java (91%) rename src/main/java/inha/gdgoc/domain/recruit/{ => member}/entity/RecruitMember.java (91%) create mode 100644 src/main/java/inha/gdgoc/domain/recruit/member/enums/AdmissionSemester.java rename src/main/java/inha/gdgoc/domain/recruit/{ => member}/enums/EnrolledClassification.java (93%) rename src/main/java/inha/gdgoc/domain/recruit/{ => member}/enums/Gender.java (90%) rename src/main/java/inha/gdgoc/domain/recruit/{ => member}/enums/InputType.java (94%) rename src/main/java/inha/gdgoc/domain/recruit/{ => member}/enums/SurveyType.java (92%) rename src/main/java/inha/gdgoc/domain/recruit/{ => member}/exception/RecruitMemberErrorCode.java (91%) rename src/main/java/inha/gdgoc/domain/recruit/{ => member}/exception/RecruitMemberException.java (83%) rename src/main/java/inha/gdgoc/domain/recruit/{ => member}/repository/AnswerRepository.java (56%) rename src/main/java/inha/gdgoc/domain/recruit/{ => member}/repository/RecruitMemberRepository.java (79%) delete mode 100644 src/main/java/inha/gdgoc/domain/recruit/service/RecruitMemberService.java diff --git a/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java b/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java deleted file mode 100644 index 9cd59b23..00000000 --- a/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java +++ /dev/null @@ -1,158 +0,0 @@ -package inha.gdgoc.domain.recruit.controller; - -import static inha.gdgoc.domain.recruit.controller.message.RecruitMemberMessage.MEMBER_LIST_RETRIEVED_SUCCESS; -import static inha.gdgoc.domain.recruit.controller.message.RecruitMemberMessage.MEMBER_RETRIEVED_SUCCESS; -import static inha.gdgoc.domain.recruit.controller.message.RecruitMemberMessage.MEMBER_SAVE_SUCCESS; -import static inha.gdgoc.domain.recruit.controller.message.RecruitMemberMessage.PAYMENT_MARKED_COMPLETE_SUCCESS; -import static inha.gdgoc.domain.recruit.controller.message.RecruitMemberMessage.PAYMENT_MARKED_INCOMPLETE_SUCCESS; -import static inha.gdgoc.domain.recruit.controller.message.RecruitMemberMessage.PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS; -import static inha.gdgoc.domain.recruit.controller.message.RecruitMemberMessage.STUDENT_ID_DUPLICATION_CHECK_SUCCESS; - -import inha.gdgoc.domain.recruit.dto.request.ApplicationRequest; -import inha.gdgoc.domain.recruit.dto.request.PaymentUpdateRequest; -import inha.gdgoc.domain.recruit.dto.response.CheckPhoneNumberResponse; -import inha.gdgoc.domain.recruit.dto.response.CheckStudentIdResponse; -import inha.gdgoc.domain.recruit.dto.response.RecruitMemberSummaryResponse; -import inha.gdgoc.domain.recruit.dto.response.SpecifiedMemberResponse; -import inha.gdgoc.domain.recruit.entity.RecruitMember; -import inha.gdgoc.domain.recruit.service.RecruitMemberService; -import inha.gdgoc.global.dto.response.ApiResponse; -import inha.gdgoc.global.dto.response.PageMeta; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.data.domain.Sort.Direction; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@Tag(name = "Recruit - Members", description = "리크루팅 지원자 관리 API") -@RequestMapping("/api/v1") -@RequiredArgsConstructor -@RestController -public class RecruitMemberController { - - private final RecruitMemberService recruitMemberService; - - @PostMapping("/apply") - public ResponseEntity> recruitMemberAdd( - @RequestBody ApplicationRequest applicationRequest - ) { - recruitMemberService.addRecruitMember(applicationRequest); - - return ResponseEntity.ok(ApiResponse.ok(MEMBER_SAVE_SUCCESS)); - } - - @GetMapping("/check/student-id") - public ResponseEntity> duplicatedStudentIdDetails( - @RequestParam - @NotBlank(message = "학번은 필수 입력 값입니다.") - @Pattern(regexp = "^12[0-9]{6}$", message = "유효하지 않은 학번 값입니다.") - String studentId - ) { - CheckStudentIdResponse response = recruitMemberService.isRegisteredStudentId(studentId); - - return ResponseEntity.ok(ApiResponse.ok(STUDENT_ID_DUPLICATION_CHECK_SUCCESS, response)); - } - - @GetMapping("/check/phone-number") - public ResponseEntity> duplicatedPhoneNumberDetails( - @RequestParam - @NotBlank(message = "전화번호는 필수 입력 값입니다.") - @Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "전화번호 형식은 010-XXXX-XXXX 이어야 합니다.") - String phoneNumber - ) { - CheckPhoneNumberResponse response = recruitMemberService - .isRegisteredPhoneNumber(phoneNumber); - - return ResponseEntity.ok(ApiResponse.ok(PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS, response)); - } - - @Operation(summary = "특정 멤버 가입 신청서 조회", security = {@SecurityRequirement(name = "BearerAuth")}) - @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team") - @GetMapping("/recruit/members/{memberId}") - public ResponseEntity> getSpecifiedMember( - @PathVariable Long memberId - ) { - SpecifiedMemberResponse response = recruitMemberService.findSpecifiedMember(memberId); - - return ResponseEntity.ok(ApiResponse.ok(MEMBER_RETRIEVED_SUCCESS, response)); - } - - @Operation( - summary = "입금 상태 변경", - description = "설정하려는 상태(NOT 현재 상태)를 body에 보내주세요. true=입금 완료, false=입금 미완료", - security = { @SecurityRequirement(name = "BearerAuth") } - ) - @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team") - @PatchMapping("/recruit/members/{memberId}/payment") - public ResponseEntity> updatePayment( - @PathVariable Long memberId, - @RequestBody PaymentUpdateRequest paymentUpdateRequest - ) { - recruitMemberService.updatePayment(memberId, paymentUpdateRequest.isPayed()); - - return ResponseEntity.ok( - ApiResponse.ok( - paymentUpdateRequest.isPayed() - ? PAYMENT_MARKED_COMPLETE_SUCCESS - : PAYMENT_MARKED_INCOMPLETE_SUCCESS - ) - ); - } - - @Operation( - summary = "지원자 목록 조회", - description = "전체 목록 또는 이름 검색 결과를 반환합니다. 검색어(question)를 주면 이름 포함 검색, 없으면 전체 조회. sort랑 dir은 example 값 그대로 코딩하는 것 추천...", - security = { @SecurityRequirement(name = "BearerAuth") } - ) - @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team") - @GetMapping("/recruit/members") - public ResponseEntity, PageMeta>> getMembers( - @Parameter(description = "검색어(이름 부분 일치). 없으면 전체 조회", example = "소연") - @RequestParam(required = false) String question, - - @Parameter(description = "페이지(0부터 시작)", example = "0") - @RequestParam(defaultValue = "0") int page, - - @Parameter(description = "페이지 크기", example = "20") - @RequestParam(defaultValue = "20") int size, - - @Parameter(description = "정렬 필드", example = "createdAt") - @RequestParam(defaultValue = "createdAt") String sort, - - @Parameter(description = "정렬 방향 ASC/DESC", example = "DESC") - @RequestParam(defaultValue = "DESC") String dir - ) { - Direction direction = "ASC".equalsIgnoreCase(dir) ? Direction.ASC : Direction.DESC; - Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sort)); - - Page memberPage = (question == null || question.isBlank()) - ? recruitMemberService.findAllMembersPage(pageable) - : recruitMemberService.searchMembersByNamePage(question, pageable); - - List list = memberPage - .map(RecruitMemberSummaryResponse::from) - .getContent(); - PageMeta meta = PageMeta.of(memberPage); - - return ResponseEntity.ok(ApiResponse.ok(MEMBER_LIST_RETRIEVED_SUCCESS, list, meta)); - } - -} diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/request/RecruitMemberRequest.java b/src/main/java/inha/gdgoc/domain/recruit/dto/request/RecruitMemberRequest.java deleted file mode 100644 index 6f0886cf..00000000 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/request/RecruitMemberRequest.java +++ /dev/null @@ -1,48 +0,0 @@ -package inha.gdgoc.domain.recruit.dto.request; - -import inha.gdgoc.domain.recruit.entity.RecruitMember; -import inha.gdgoc.domain.recruit.enums.EnrolledClassification; -import inha.gdgoc.domain.recruit.enums.Gender; -import inha.gdgoc.global.util.SemesterCalculator; -import java.time.LocalDate; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Getter -public class RecruitMemberRequest { - private String name; - private String grade; - private String studentId; - private String enrolledClassification; - private String phoneNumber; - private String nationality; - private String email; - private String gender; - private LocalDate birth; - private String major; - private String doubleMajor; - private Boolean isPayed; - - public RecruitMember toEntity() { - return RecruitMember.builder() - .name(name) - .grade(grade) - .studentId(studentId) - .enrolledClassification(EnrolledClassification.fromStatus(enrolledClassification)) - .phoneNumber(phoneNumber) - .nationality(nationality) - .email(email) - .gender(Gender.fromType(gender)) - .birth(birth) - .major(major) - .doubleMajor(doubleMajor) - .isPayed(false) - .admissionSemester(SemesterCalculator.currentSemester()) - .build(); - } -} diff --git a/src/main/java/inha/gdgoc/domain/recruit/enums/AdmissionSemester.java b/src/main/java/inha/gdgoc/domain/recruit/enums/AdmissionSemester.java deleted file mode 100644 index 4485f2a2..00000000 --- a/src/main/java/inha/gdgoc/domain/recruit/enums/AdmissionSemester.java +++ /dev/null @@ -1,5 +0,0 @@ -package inha.gdgoc.domain.recruit.enums; - -public enum AdmissionSemester { - Y25_1, Y25_2, Y26_1, Y26_2 -} diff --git a/src/main/java/inha/gdgoc/domain/recruit/controller/message/RecruitMemberMessage.java b/src/main/java/inha/gdgoc/domain/recruit/member/controller/message/RecruitMemberMessage.java similarity index 93% rename from src/main/java/inha/gdgoc/domain/recruit/controller/message/RecruitMemberMessage.java rename to src/main/java/inha/gdgoc/domain/recruit/member/controller/message/RecruitMemberMessage.java index 8e8a3ea8..813abafd 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/controller/message/RecruitMemberMessage.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/controller/message/RecruitMemberMessage.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.controller.message; +package inha.gdgoc.domain.recruit.member.controller.message; public class RecruitMemberMessage { public static final String MEMBER_SAVE_SUCCESS = "성공적으로 해당 학기 멤버 가입을 완료했습니다."; diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/request/ApplicationRequest.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/ApplicationRequest.java similarity index 83% rename from src/main/java/inha/gdgoc/domain/recruit/dto/request/ApplicationRequest.java rename to src/main/java/inha/gdgoc/domain/recruit/member/dto/request/ApplicationRequest.java index 53a001d2..2f0eb459 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/request/ApplicationRequest.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/ApplicationRequest.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.dto.request; +package inha.gdgoc.domain.recruit.member.dto.request; import java.util.Map; import lombok.AllArgsConstructor; diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/request/CheckPhoneNumberRequest.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckPhoneNumberRequest.java similarity index 88% rename from src/main/java/inha/gdgoc/domain/recruit/dto/request/CheckPhoneNumberRequest.java rename to src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckPhoneNumberRequest.java index a448edd7..15e35411 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/request/CheckPhoneNumberRequest.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckPhoneNumberRequest.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.dto.request; +package inha.gdgoc.domain.recruit.member.dto.request; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/request/PaymentUpdateRequest.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/PaymentUpdateRequest.java similarity index 55% rename from src/main/java/inha/gdgoc/domain/recruit/dto/request/PaymentUpdateRequest.java rename to src/main/java/inha/gdgoc/domain/recruit/member/dto/request/PaymentUpdateRequest.java index 3a6f1765..816e4900 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/request/PaymentUpdateRequest.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/PaymentUpdateRequest.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.dto.request; +package inha.gdgoc.domain.recruit.member.dto.request; public record PaymentUpdateRequest( boolean isPayed diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/response/AnswerResponse.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/AnswerResponse.java similarity index 89% rename from src/main/java/inha/gdgoc/domain/recruit/dto/response/AnswerResponse.java rename to src/main/java/inha/gdgoc/domain/recruit/member/dto/response/AnswerResponse.java index 3ac3d171..e8688e84 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/response/AnswerResponse.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/AnswerResponse.java @@ -1,10 +1,10 @@ -package inha.gdgoc.domain.recruit.dto.response; +package inha.gdgoc.domain.recruit.member.dto.response; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import inha.gdgoc.domain.recruit.entity.Answer; -import inha.gdgoc.domain.recruit.enums.InputType; +import inha.gdgoc.domain.recruit.member.entity.Answer; +import inha.gdgoc.domain.recruit.member.enums.InputType; import java.util.List; import java.util.Map; diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/response/AnswersResponse.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/AnswersResponse.java similarity index 79% rename from src/main/java/inha/gdgoc/domain/recruit/dto/response/AnswersResponse.java rename to src/main/java/inha/gdgoc/domain/recruit/member/dto/response/AnswersResponse.java index 896c8cc8..d3f9bfca 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/response/AnswersResponse.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/AnswersResponse.java @@ -1,7 +1,7 @@ -package inha.gdgoc.domain.recruit.dto.response; +package inha.gdgoc.domain.recruit.member.dto.response; import com.fasterxml.jackson.databind.ObjectMapper; -import inha.gdgoc.domain.recruit.entity.Answer; +import inha.gdgoc.domain.recruit.member.entity.Answer; import java.util.List; public record AnswersResponse( diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/response/CheckPhoneNumberResponse.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckPhoneNumberResponse.java similarity index 53% rename from src/main/java/inha/gdgoc/domain/recruit/dto/response/CheckPhoneNumberResponse.java rename to src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckPhoneNumberResponse.java index 759f42f1..8ad4cdba 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/response/CheckPhoneNumberResponse.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckPhoneNumberResponse.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.dto.response; +package inha.gdgoc.domain.recruit.member.dto.response; public record CheckPhoneNumberResponse(boolean isExists) { diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/response/CheckStudentIdResponse.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckStudentIdResponse.java similarity index 52% rename from src/main/java/inha/gdgoc/domain/recruit/dto/response/CheckStudentIdResponse.java rename to src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckStudentIdResponse.java index 8537486b..77c4de04 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/response/CheckStudentIdResponse.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckStudentIdResponse.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.dto.response; +package inha.gdgoc.domain.recruit.member.dto.response; public record CheckStudentIdResponse(boolean isExists) { diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/response/RecruitMemberSummaryResponse.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/RecruitMemberSummaryResponse.java similarity index 88% rename from src/main/java/inha/gdgoc/domain/recruit/dto/response/RecruitMemberSummaryResponse.java rename to src/main/java/inha/gdgoc/domain/recruit/member/dto/response/RecruitMemberSummaryResponse.java index 1e6618ca..8b078eda 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/response/RecruitMemberSummaryResponse.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/RecruitMemberSummaryResponse.java @@ -1,6 +1,6 @@ -package inha.gdgoc.domain.recruit.dto.response; +package inha.gdgoc.domain.recruit.member.dto.response; -import inha.gdgoc.domain.recruit.entity.RecruitMember; +import inha.gdgoc.domain.recruit.member.entity.RecruitMember; public record RecruitMemberSummaryResponse( Long id, diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/response/SpecifiedMemberResponse.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/SpecifiedMemberResponse.java similarity index 80% rename from src/main/java/inha/gdgoc/domain/recruit/dto/response/SpecifiedMemberResponse.java rename to src/main/java/inha/gdgoc/domain/recruit/member/dto/response/SpecifiedMemberResponse.java index 838bd30e..11e912aa 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/response/SpecifiedMemberResponse.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/SpecifiedMemberResponse.java @@ -1,8 +1,8 @@ -package inha.gdgoc.domain.recruit.dto.response; +package inha.gdgoc.domain.recruit.member.dto.response; import com.fasterxml.jackson.databind.ObjectMapper; -import inha.gdgoc.domain.recruit.entity.Answer; -import inha.gdgoc.domain.recruit.entity.RecruitMember; +import inha.gdgoc.domain.recruit.member.entity.Answer; +import inha.gdgoc.domain.recruit.member.entity.RecruitMember; import java.util.List; public record SpecifiedMemberResponse( diff --git a/src/main/java/inha/gdgoc/domain/recruit/entity/Answer.java b/src/main/java/inha/gdgoc/domain/recruit/member/entity/Answer.java similarity index 91% rename from src/main/java/inha/gdgoc/domain/recruit/entity/Answer.java rename to src/main/java/inha/gdgoc/domain/recruit/member/entity/Answer.java index 924089c6..763b67a3 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/entity/Answer.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/entity/Answer.java @@ -1,7 +1,7 @@ -package inha.gdgoc.domain.recruit.entity; +package inha.gdgoc.domain.recruit.member.entity; -import inha.gdgoc.domain.recruit.enums.InputType; -import inha.gdgoc.domain.recruit.enums.SurveyType; +import inha.gdgoc.domain.recruit.member.enums.InputType; +import inha.gdgoc.domain.recruit.member.enums.SurveyType; import inha.gdgoc.global.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/src/main/java/inha/gdgoc/domain/recruit/entity/RecruitMember.java b/src/main/java/inha/gdgoc/domain/recruit/member/entity/RecruitMember.java similarity index 91% rename from src/main/java/inha/gdgoc/domain/recruit/entity/RecruitMember.java rename to src/main/java/inha/gdgoc/domain/recruit/member/entity/RecruitMember.java index b09d9789..28292aff 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/entity/RecruitMember.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/entity/RecruitMember.java @@ -1,9 +1,9 @@ -package inha.gdgoc.domain.recruit.entity; +package inha.gdgoc.domain.recruit.member.entity; import com.fasterxml.jackson.annotation.JsonFormat; -import inha.gdgoc.domain.recruit.enums.AdmissionSemester; -import inha.gdgoc.domain.recruit.enums.EnrolledClassification; -import inha.gdgoc.domain.recruit.enums.Gender; +import inha.gdgoc.domain.recruit.member.enums.AdmissionSemester; +import inha.gdgoc.domain.recruit.member.enums.EnrolledClassification; +import inha.gdgoc.domain.recruit.member.enums.Gender; import inha.gdgoc.global.entity.BaseEntity; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/enums/AdmissionSemester.java b/src/main/java/inha/gdgoc/domain/recruit/member/enums/AdmissionSemester.java new file mode 100644 index 00000000..1bb68b57 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/enums/AdmissionSemester.java @@ -0,0 +1,5 @@ +package inha.gdgoc.domain.recruit.member.enums; + +public enum AdmissionSemester { + Y21_2, Y22_1, Y22_2, Y23_1, Y23_2, Y24_1, Y24_2, Y25_1, Y25_2, Y26_1 +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/enums/EnrolledClassification.java b/src/main/java/inha/gdgoc/domain/recruit/member/enums/EnrolledClassification.java similarity index 93% rename from src/main/java/inha/gdgoc/domain/recruit/enums/EnrolledClassification.java rename to src/main/java/inha/gdgoc/domain/recruit/member/enums/EnrolledClassification.java index 3e0089d7..f5ba541d 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/enums/EnrolledClassification.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/enums/EnrolledClassification.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.enums; +package inha.gdgoc.domain.recruit.member.enums; import lombok.Getter; diff --git a/src/main/java/inha/gdgoc/domain/recruit/enums/Gender.java b/src/main/java/inha/gdgoc/domain/recruit/member/enums/Gender.java similarity index 90% rename from src/main/java/inha/gdgoc/domain/recruit/enums/Gender.java rename to src/main/java/inha/gdgoc/domain/recruit/member/enums/Gender.java index 9a5c6ac9..a7170fe7 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/enums/Gender.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/enums/Gender.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.enums; +package inha.gdgoc.domain.recruit.member.enums; import lombok.Getter; diff --git a/src/main/java/inha/gdgoc/domain/recruit/enums/InputType.java b/src/main/java/inha/gdgoc/domain/recruit/member/enums/InputType.java similarity index 94% rename from src/main/java/inha/gdgoc/domain/recruit/enums/InputType.java rename to src/main/java/inha/gdgoc/domain/recruit/member/enums/InputType.java index ed9f5cc3..40db4f2c 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/enums/InputType.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/enums/InputType.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.enums; +package inha.gdgoc.domain.recruit.member.enums; import lombok.Getter; diff --git a/src/main/java/inha/gdgoc/domain/recruit/enums/SurveyType.java b/src/main/java/inha/gdgoc/domain/recruit/member/enums/SurveyType.java similarity index 92% rename from src/main/java/inha/gdgoc/domain/recruit/enums/SurveyType.java rename to src/main/java/inha/gdgoc/domain/recruit/member/enums/SurveyType.java index 5c4c9014..667942f8 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/enums/SurveyType.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/enums/SurveyType.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.enums; +package inha.gdgoc.domain.recruit.member.enums; import lombok.Getter; diff --git a/src/main/java/inha/gdgoc/domain/recruit/exception/RecruitMemberErrorCode.java b/src/main/java/inha/gdgoc/domain/recruit/member/exception/RecruitMemberErrorCode.java similarity index 91% rename from src/main/java/inha/gdgoc/domain/recruit/exception/RecruitMemberErrorCode.java rename to src/main/java/inha/gdgoc/domain/recruit/member/exception/RecruitMemberErrorCode.java index e78520aa..f1b0c431 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/exception/RecruitMemberErrorCode.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/exception/RecruitMemberErrorCode.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.exception; +package inha.gdgoc.domain.recruit.member.exception; import inha.gdgoc.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/inha/gdgoc/domain/recruit/exception/RecruitMemberException.java b/src/main/java/inha/gdgoc/domain/recruit/member/exception/RecruitMemberException.java similarity index 83% rename from src/main/java/inha/gdgoc/domain/recruit/exception/RecruitMemberException.java rename to src/main/java/inha/gdgoc/domain/recruit/member/exception/RecruitMemberException.java index 8d07d430..2c5800c4 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/exception/RecruitMemberException.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/exception/RecruitMemberException.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.exception; +package inha.gdgoc.domain.recruit.member.exception; import inha.gdgoc.global.exception.BusinessException; import inha.gdgoc.global.exception.ErrorCode; diff --git a/src/main/java/inha/gdgoc/domain/recruit/repository/AnswerRepository.java b/src/main/java/inha/gdgoc/domain/recruit/member/repository/AnswerRepository.java similarity index 56% rename from src/main/java/inha/gdgoc/domain/recruit/repository/AnswerRepository.java rename to src/main/java/inha/gdgoc/domain/recruit/member/repository/AnswerRepository.java index 3ae036d5..ad00ce90 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/repository/AnswerRepository.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/repository/AnswerRepository.java @@ -1,8 +1,8 @@ -package inha.gdgoc.domain.recruit.repository; +package inha.gdgoc.domain.recruit.member.repository; -import inha.gdgoc.domain.recruit.entity.Answer; -import inha.gdgoc.domain.recruit.entity.RecruitMember; -import inha.gdgoc.domain.recruit.enums.SurveyType; +import inha.gdgoc.domain.recruit.member.entity.Answer; +import inha.gdgoc.domain.recruit.member.entity.RecruitMember; +import inha.gdgoc.domain.recruit.member.enums.SurveyType; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/inha/gdgoc/domain/recruit/repository/RecruitMemberRepository.java b/src/main/java/inha/gdgoc/domain/recruit/member/repository/RecruitMemberRepository.java similarity index 79% rename from src/main/java/inha/gdgoc/domain/recruit/repository/RecruitMemberRepository.java rename to src/main/java/inha/gdgoc/domain/recruit/member/repository/RecruitMemberRepository.java index 04c0c089..88a66695 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/repository/RecruitMemberRepository.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/repository/RecruitMemberRepository.java @@ -1,6 +1,6 @@ -package inha.gdgoc.domain.recruit.repository; +package inha.gdgoc.domain.recruit.member.repository; -import inha.gdgoc.domain.recruit.entity.RecruitMember; +import inha.gdgoc.domain.recruit.member.entity.RecruitMember; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; diff --git a/src/main/java/inha/gdgoc/domain/recruit/service/RecruitMemberService.java b/src/main/java/inha/gdgoc/domain/recruit/service/RecruitMemberService.java deleted file mode 100644 index 3d7a7f5d..00000000 --- a/src/main/java/inha/gdgoc/domain/recruit/service/RecruitMemberService.java +++ /dev/null @@ -1,94 +0,0 @@ -package inha.gdgoc.domain.recruit.service; - -import static inha.gdgoc.domain.recruit.exception.RecruitMemberErrorCode.RECRUIT_MEMBER_NOT_FOUND; - -import com.fasterxml.jackson.databind.ObjectMapper; -import inha.gdgoc.domain.recruit.dto.request.ApplicationRequest; -import inha.gdgoc.domain.recruit.dto.response.CheckPhoneNumberResponse; -import inha.gdgoc.domain.recruit.dto.response.CheckStudentIdResponse; -import inha.gdgoc.domain.recruit.dto.response.SpecifiedMemberResponse; -import inha.gdgoc.domain.recruit.entity.Answer; -import inha.gdgoc.domain.recruit.entity.RecruitMember; -import inha.gdgoc.domain.recruit.enums.InputType; -import inha.gdgoc.domain.recruit.enums.SurveyType; -import inha.gdgoc.domain.recruit.exception.RecruitMemberException; -import inha.gdgoc.domain.recruit.repository.AnswerRepository; -import inha.gdgoc.domain.recruit.repository.RecruitMemberRepository; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Service -public class RecruitMemberService { - private final RecruitMemberRepository recruitMemberRepository; - private final AnswerRepository answerRepository; - private final ObjectMapper objectMapper; - - @Transactional - public void addRecruitMember(ApplicationRequest applicationRequest) { - RecruitMember member = applicationRequest.getMember().toEntity(); - recruitMemberRepository.save(member); - - List answers = applicationRequest.getAnswers().entrySet().stream() - .map(entry -> { - try { - // Object → JSON String 변환 - String jsonValue = objectMapper.writeValueAsString(entry.getValue()); - return new Answer(member, SurveyType.fromType("recruit form"), InputType.fromQuestion( - entry.getKey()), jsonValue); - } catch (Exception e) { - throw new RuntimeException("JSON 변환 오류", e); - } - }) - .toList(); - - answerRepository.saveAll(answers); - } - - public CheckStudentIdResponse isRegisteredStudentId(String studentId) { - boolean exists = recruitMemberRepository.existsByStudentId(studentId); - - return new CheckStudentIdResponse(exists); - } - - public CheckPhoneNumberResponse isRegisteredPhoneNumber(String phoneNumber) { - boolean exists = recruitMemberRepository.existsByPhoneNumber(phoneNumber); - - return new CheckPhoneNumberResponse(exists); - } - - public SpecifiedMemberResponse findSpecifiedMember(Long id) { - RecruitMember member = recruitMemberRepository.findById(id) - .orElseThrow(() -> new RecruitMemberException(RECRUIT_MEMBER_NOT_FOUND)); - List answers = answerRepository - .findByRecruitMemberAndSurveyType(member, SurveyType.RECRUIT); - - return SpecifiedMemberResponse.from(member, answers, objectMapper); - } - - @Transactional - public void updatePayment(Long memberId, boolean isPayed) { - RecruitMember m = recruitMemberRepository.findById(memberId) - .orElseThrow(() -> new RecruitMemberException(RECRUIT_MEMBER_NOT_FOUND)); - - if (Boolean.TRUE.equals(m.getIsPayed()) == isPayed) return; - - if (isPayed) m.markPaid(); - else m.markUnpaid(); - } - - @Transactional(readOnly = true) - public Page findAllMembersPage(Pageable pageable) { - return recruitMemberRepository.findAll(pageable); - } - - @Transactional(readOnly = true) - public Page searchMembersByNamePage(String name, Pageable pageable) { - return recruitMemberRepository.findByNameContainingIgnoreCase(name, pageable); - } - -} diff --git a/src/test/java/inha/gdgoc/domain/recruit/service/RecruitMemberServiceTest.java b/src/test/java/inha/gdgoc/domain/recruit/service/RecruitMemberServiceTest.java index 49dfd53b..5e88882c 100644 --- a/src/test/java/inha/gdgoc/domain/recruit/service/RecruitMemberServiceTest.java +++ b/src/test/java/inha/gdgoc/domain/recruit/service/RecruitMemberServiceTest.java @@ -1,22 +1,15 @@ package inha.gdgoc.domain.recruit.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; -import com.fasterxml.jackson.databind.ObjectMapper; -import inha.gdgoc.domain.recruit.dto.request.ApplicationRequest; -import inha.gdgoc.domain.recruit.dto.request.RecruitMemberRequest; -import inha.gdgoc.domain.recruit.entity.RecruitMember; -import inha.gdgoc.domain.recruit.enums.EnrolledClassification; -import inha.gdgoc.domain.recruit.enums.Gender; -import inha.gdgoc.domain.recruit.repository.AnswerRepository; -import inha.gdgoc.domain.recruit.repository.RecruitMemberRepository; +import inha.gdgoc.domain.recruit.member.dto.request.RecruitMemberRequest; +import inha.gdgoc.domain.recruit.member.entity.RecruitMember; +import inha.gdgoc.domain.recruit.member.enums.EnrolledClassification; +import inha.gdgoc.domain.recruit.member.enums.Gender; + import java.time.LocalDate; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; + import java.util.List; import java.util.Map; From 16698f6b956244510da922ba2e98b538794a7114 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 15 Jan 2026 02:40:20 +0900 Subject: [PATCH 13/49] fix: correct AccessGuard SpEL references and expose attendance rules --- .../core/controller/RecruitCoreAdminController.java | 4 ++-- .../attendance/controller/CoreAttendanceController.java | 8 ++++---- .../domain/guestbook/controller/GuestbookController.java | 2 +- .../member/controller/RecruitMemberController.java | 4 ++-- .../gdgoc/domain/user/controller/UserAdminController.java | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/core/controller/RecruitCoreAdminController.java b/src/main/java/inha/gdgoc/domain/admin/recruit/core/controller/RecruitCoreAdminController.java index 8f9e2985..0a129b85 100644 --- a/src/main/java/inha/gdgoc/domain/admin/recruit/core/controller/RecruitCoreAdminController.java +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/core/controller/RecruitCoreAdminController.java @@ -33,9 +33,9 @@ public class RecruitCoreAdminController { private static final String ORGANIZER_OR_HR_LEAD_RULE = "@accessGuard.check(authentication," - + " T(inha.gdgoc.global.security.AccessGuard.AccessCondition).atLeast(" + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" + "T(inha.gdgoc.domain.user.enums.UserRole).ORGANIZER)," - + " T(inha.gdgoc.global.security.AccessGuard.AccessCondition).of(" + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).of(" + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD," + " T(inha.gdgoc.domain.user.enums.TeamType).HR))"; diff --git a/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java b/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java index eb8a9bd3..c97d4c99 100644 --- a/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java +++ b/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java @@ -33,13 +33,13 @@ @PreAuthorize(CoreAttendanceController.LEAD_OR_HIGHER_RULE) public class CoreAttendanceController { - private static final String LEAD_OR_HIGHER_RULE = + public static final String LEAD_OR_HIGHER_RULE = "@accessGuard.check(authentication," - + " T(inha.gdgoc.global.security.AccessGuard.AccessCondition).atLeast(" + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD))"; - private static final String ORGANIZER_OR_HIGHER_RULE = + public static final String ORGANIZER_OR_HIGHER_RULE = "@accessGuard.check(authentication," - + " T(inha.gdgoc.global.security.AccessGuard.AccessCondition).atLeast(" + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" + "T(inha.gdgoc.domain.user.enums.UserRole).ORGANIZER))"; private final CoreAttendanceService service; diff --git a/src/main/java/inha/gdgoc/domain/guestbook/controller/GuestbookController.java b/src/main/java/inha/gdgoc/domain/guestbook/controller/GuestbookController.java index 9d2c3e6a..1767ebba 100644 --- a/src/main/java/inha/gdgoc/domain/guestbook/controller/GuestbookController.java +++ b/src/main/java/inha/gdgoc/domain/guestbook/controller/GuestbookController.java @@ -20,7 +20,7 @@ @RequestMapping("/api/v1/guestbook") @RequiredArgsConstructor @PreAuthorize("@accessGuard.check(authentication," - + " T(inha.gdgoc.global.security.AccessGuard.AccessCondition).atLeast(" + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD))") public class GuestbookController { diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java b/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java index 440b7af9..7b368c45 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java @@ -50,9 +50,9 @@ public class RecruitMemberController { private static final String LEAD_OR_HR_RULE = "@accessGuard.check(authentication," - + " T(inha.gdgoc.global.security.AccessGuard.AccessCondition).atLeast(" + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD)," - + " T(inha.gdgoc.global.security.AccessGuard.AccessCondition).atLeast(" + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" + "T(inha.gdgoc.domain.user.enums.UserRole).CORE," + " T(inha.gdgoc.domain.user.enums.TeamType).HR))"; diff --git a/src/main/java/inha/gdgoc/domain/user/controller/UserAdminController.java b/src/main/java/inha/gdgoc/domain/user/controller/UserAdminController.java index e2c4f3b1..3bb20d73 100644 --- a/src/main/java/inha/gdgoc/domain/user/controller/UserAdminController.java +++ b/src/main/java/inha/gdgoc/domain/user/controller/UserAdminController.java @@ -26,14 +26,14 @@ public class UserAdminController { private static final String LEAD_OR_HR_RULE = "@accessGuard.check(authentication," - + " T(inha.gdgoc.global.security.AccessGuard.AccessCondition).atLeast(" + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD)," - + " T(inha.gdgoc.global.security.AccessGuard.AccessCondition).atLeast(" + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" + "T(inha.gdgoc.domain.user.enums.UserRole).CORE," + " T(inha.gdgoc.domain.user.enums.TeamType).HR))"; private static final String LEAD_OR_HIGHER_RULE = "@accessGuard.check(authentication," - + " T(inha.gdgoc.global.security.AccessGuard.AccessCondition).atLeast(" + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD))"; private final UserAdminService userAdminService; From a861d2eae752ead8928b7f70eb4eb039e13899ec Mon Sep 17 00:00:00 2001 From: Tae ho Date: Fri, 16 Jan 2026 12:33:59 +0900 Subject: [PATCH 14/49] google auth --- build.gradle | 3 + .../auth/controller/AuthController.java | 308 ++++++++++++------ .../domain/auth/dto/request/LoginRequest.java | 3 + .../dto/request/LoginSuccessResponse.java | 22 ++ .../auth/dto/request/SignupRequest.java | 10 + .../domain/auth/service/AuthService.java | 194 ++++++----- .../inha/gdgoc/domain/user/entity/User.java | 42 ++- .../domain/user/enums/MembershipStatus.java | 7 + .../user/repository/UserRepository.java | 3 + .../global/config/jwt/TokenProvider.java | 63 ++-- .../gdgoc/global/security/SecurityConfig.java | 2 +- 11 files changed, 422 insertions(+), 235 deletions(-) create mode 100644 src/main/java/inha/gdgoc/domain/auth/dto/request/LoginRequest.java create mode 100644 src/main/java/inha/gdgoc/domain/auth/dto/request/LoginSuccessResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/auth/dto/request/SignupRequest.java create mode 100644 src/main/java/inha/gdgoc/domain/user/enums/MembershipStatus.java diff --git a/build.gradle b/build.gradle index 039a1911..a5f92af1 100644 --- a/build.gradle +++ b/build.gradle @@ -102,6 +102,9 @@ dependencies { testImplementation 'org.mockito:mockito-junit-jupiter:5.6.0' testImplementation 'org.assertj:assertj-core:3.24.2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + //Google API Client + implementation 'com.google.api-client:google-api-client:2.2.0' } dependencyManagement { diff --git a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java index 52ca638c..ebde8b25 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java @@ -45,139 +45,88 @@ @RequestMapping("/api/v1/auth") @RestController @RequiredArgsConstructor + public class AuthController { - private final UserRepository userRepository; private final AuthService authService; - private final RefreshTokenService refreshTokenService; - private final MailService mailService; - private final AuthCodeService authCodeService; - @GetMapping("/oauth2/google/callback") - public ResponseEntity, Void>> handleGoogleCallback(@RequestParam String code, HttpServletResponse response) { - Map data = authService.processOAuthLogin(code, response); + /** + * 구글 로그인 (ID Token 검증) + */ + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest request) { + try { + // AuthService에서 로그인 or 회원가입 필요 응답 분기 처리 결과 반환 + Object response = authService.login(request.getIdToken()); + return ResponseEntity.ok(ApiResponse.ok(LOGIN_SUCCESS, response)); // LOGIN_SUCCESS 메시지 필요 (없으면 기존 것 사용) + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(AuthErrorCode.INVALID_TOKEN.getStatus().value(), e.getMessage(), null)); + } + } - return ResponseEntity.ok(ApiResponse.ok(OAUTH_LOGIN_SIGNUP_SUCCESS, data)); + /** + * 회원가입 (추가 정보 입력) + */ + @PostMapping("/signup") + public ResponseEntity signup(@Valid @RequestBody SignupRequest request) { + try { + Object response = authService.signup(request); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.ok(SIGNUP_SUCCESS, response)); // SIGNUP_SUCCESS 메시지 필요 + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), e.getMessage(), null)); + } } + /** + * 토큰 재발급 (Refresh) + */ @PostMapping("/refresh") public ResponseEntity refreshAccessToken(@CookieValue(value = "refresh_token", required = false) String refreshToken) { - log.info("리프레시 토큰 요청 받음. 토큰 존재 여부: {}", refreshToken != null); - if (refreshToken == null) { throw new AuthException(AuthErrorCode.INVALID_COOKIE); } try { - String newAccessToken = refreshTokenService.refreshAccessToken(refreshToken); - AccessTokenResponse accessTokenResponse = new AccessTokenResponse(newAccessToken); - - return ResponseEntity.ok(ApiResponse.ok(ACCESS_TOKEN_REFRESH_SUCCESS, accessTokenResponse, null)); + String newAccessToken = authService.refresh(refreshToken); + return ResponseEntity.ok(ApiResponse.ok(ACCESS_TOKEN_REFRESH_SUCCESS, new AccessTokenResponse(newAccessToken))); } catch (Exception e) { - log.error("리프레시 토큰 처리 중 오류: {}", e.getMessage(), e); + log.error("Token refresh failed", e); throw new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); } } - @PostMapping("/login") - public ResponseEntity> login(@Valid @RequestBody UserLoginRequest req, HttpServletResponse response) throws NoSuchAlgorithmException, InvalidKeyException { - String email = req.email().trim(); - LoginResponse loginResponse = authService.loginWithPassword(email, req.password(), response); - return ResponseEntity.ok(ApiResponse.ok(LOGIN_WITH_PASSWORD_SUCCESS, loginResponse)); - } - + /** + * 로그아웃 + */ @PostMapping("/logout") - @PreAuthorize("isAuthenticated()") - public ResponseEntity> logout() { - // TODO 서비스로 넘기기 - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - - // 1) 익명 방어 - if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getName())) { - throw new AuthException(UNAUTHORIZED_USER); - } - - // 2) principal 캐스팅해서 확정적으로 userId/email 사용 - Object principal = authentication.getPrincipal(); - if (!(principal instanceof TokenProvider.CustomUserDetails userDetails)) { - throw new AuthException(UNAUTHORIZED_USER); + public ResponseEntity logout(@CookieValue(value = "refresh_token", required = false) String refreshToken) { + // 리프레시 토큰이 없으면 그냥 성공 처리 (이미 로그아웃된 상태로 간주) + if (refreshToken != null) { + authService.logout(refreshToken); } - - Long userId = userDetails.getUserId(); - String email = userDetails.getUsername(); - - log.info("로그아웃 시도: 사용자 ID: {}, 이메일: {}", userId, email); - - if (userId != null) { - boolean deleted = refreshTokenService.logout(userId); - - if (!deleted) { - log.warn("사용자 ID: {}의 리프레시 토큰 삭제에 실패했습니다.", userId); - } else { - log.info("사용자 ID: {}의 리프레시 토큰이 성공적으로 삭제되었습니다.", userId); - } - } else { - log.warn("사용자를 찾을 수 없습니다."); - } - return ResponseEntity.ok(ApiResponse.ok(LOGOUT_SUCCESS)); } - @PostMapping("/password-reset/request") - public ResponseEntity> responseResponseEntity(@RequestBody SendingCodeRequest sendingCodeRequest) { - // TODO 서비스로 넘기기 - if (userRepository.existsByNameAndEmail(sendingCodeRequest.name(), sendingCodeRequest.email())) { - String code = mailService.sendAuthCode(sendingCodeRequest.email()); - authCodeService.saveAuthCode(sendingCodeRequest.email(), code); - - return ResponseEntity.ok(ApiResponse.ok(CODE_CREATION_SUCCESS)); - } - throw new AuthException(USER_NOT_FOUND); - } - - @PostMapping("/password-reset/verify") - public ResponseEntity> verifyCode(@RequestBody CodeVerificationRequest request) { - // TODO 서비스 단 DTO 추가 - boolean verified = authCodeService.verify(request.email(), request.code()); - CodeVerificationResponse response = new CodeVerificationResponse(verified); - - return ResponseEntity.ok(ApiResponse.ok(PASSWORD_RESET_VERIFICATION_SUCCESS, response)); - } - - @PostMapping("/password-reset/confirm") - public ResponseEntity> resetPassword(@RequestBody PasswordResetRequest passwordResetRequest) throws NoSuchAlgorithmException, InvalidKeyException { - // TODO 서비스 단으로 - Optional user = userRepository.findByEmail(passwordResetRequest.email()); - if (user.isEmpty()) { - throw new AuthException(USER_NOT_FOUND); - } - - User foundUser = user.get(); - foundUser.updatePassword(passwordResetRequest.password()); - userRepository.save(foundUser); - - return ResponseEntity.ok(ApiResponse.ok(PASSWORD_CHANGE_SUCCESS)); - } - /** - * 요구 권한(role) 이상이면 200, 아니면 403 - * 미인증이면 401 - - * 예) /api/v1/auth/LEAD, /api/v1/auth/ORGANIZER, /api/v1/auth/ADMIN + * 권한 체크 (Role or Team) */ @GetMapping("/{role}") - public ResponseEntity> checkRoleOrTeam(@AuthenticationPrincipal TokenProvider.CustomUserDetails me, @PathVariable UserRole role, @RequestParam(value = "team", required = false) TeamType requiredTeam) { - // 1) 인증 체크 + public ResponseEntity> checkRoleOrTeam(@AuthenticationPrincipal TokenProvider.CustomUserDetails me, + @PathVariable UserRole role, + @RequestParam(value = "team", required = false) TeamType requiredTeam) { if (me == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(ApiResponse.error(GlobalErrorCode.UNAUTHORIZED_USER.getStatus() - .value(), GlobalErrorCode.UNAUTHORIZED_USER.getMessage(), null)); + .body(ApiResponse.error(GlobalErrorCode.UNAUTHORIZED_USER.getStatus().value(), + GlobalErrorCode.UNAUTHORIZED_USER.getMessage(), null)); } - // 2) role check - final boolean roleOk = UserRole.hasAtLeast(me.getRole(), role); + // Role Check + boolean roleOk = UserRole.hasAtLeast(me.getRole(), role); - // 3) team check if team parameter exists + // Team Check boolean teamOk = false; if (requiredTeam != null) { if (UserRole.hasAtLeast(me.getRole(), UserRole.ORGANIZER)) { @@ -187,13 +136,164 @@ public ResponseEntity> resetPassword(@RequestBody Passwo } } - // 4) OR 조건으로 최종 판정 if (roleOk || teamOk) { return ResponseEntity.ok(ApiResponse.ok("ROLE_OR_TEAM_CHECK_PASSED", null)); } return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(ApiResponse.error(GlobalErrorCode.FORBIDDEN_USER.getStatus() - .value(), GlobalErrorCode.FORBIDDEN_USER.getMessage(), null)); + .body(ApiResponse.error(GlobalErrorCode.FORBIDDEN_USER.getStatus().value(), + GlobalErrorCode.FORBIDDEN_USER.getMessage(), null)); } } +// public class AuthController { + +// private final UserRepository userRepository; +// private final AuthService authService; +// private final RefreshTokenService refreshTokenService; +// private final MailService mailService; +// private final AuthCodeService authCodeService; + +// @GetMapping("/oauth2/google/callback") +// public ResponseEntity, Void>> handleGoogleCallback(@RequestParam String code, HttpServletResponse response) { +// Map data = authService.processOAuthLogin(code, response); + +// return ResponseEntity.ok(ApiResponse.ok(OAUTH_LOGIN_SIGNUP_SUCCESS, data)); +// } + +// @PostMapping("/refresh") +// public ResponseEntity refreshAccessToken(@CookieValue(value = "refresh_token", required = false) String refreshToken) { +// log.info("리프레시 토큰 요청 받음. 토큰 존재 여부: {}", refreshToken != null); + +// if (refreshToken == null) { +// throw new AuthException(AuthErrorCode.INVALID_COOKIE); +// } + +// try { +// String newAccessToken = refreshTokenService.refreshAccessToken(refreshToken); +// AccessTokenResponse accessTokenResponse = new AccessTokenResponse(newAccessToken); + +// return ResponseEntity.ok(ApiResponse.ok(ACCESS_TOKEN_REFRESH_SUCCESS, accessTokenResponse, null)); +// } catch (Exception e) { +// log.error("리프레시 토큰 처리 중 오류: {}", e.getMessage(), e); +// throw new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); +// } +// } + +// @PostMapping("/login") +// public ResponseEntity> login(@Valid @RequestBody UserLoginRequest req, HttpServletResponse response) throws NoSuchAlgorithmException, InvalidKeyException { +// String email = req.email().trim(); +// LoginResponse loginResponse = authService.loginWithPassword(email, req.password(), response); +// return ResponseEntity.ok(ApiResponse.ok(LOGIN_WITH_PASSWORD_SUCCESS, loginResponse)); +// } + +// @PostMapping("/logout") +// @PreAuthorize("isAuthenticated()") +// public ResponseEntity> logout() { +// // TODO 서비스로 넘기기 +// Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + +// // 1) 익명 방어 +// if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getName())) { +// throw new AuthException(UNAUTHORIZED_USER); +// } + +// // 2) principal 캐스팅해서 확정적으로 userId/email 사용 +// Object principal = authentication.getPrincipal(); +// if (!(principal instanceof TokenProvider.CustomUserDetails userDetails)) { +// throw new AuthException(UNAUTHORIZED_USER); +// } + +// Long userId = userDetails.getUserId(); +// String email = userDetails.getUsername(); + +// log.info("로그아웃 시도: 사용자 ID: {}, 이메일: {}", userId, email); + +// if (userId != null) { +// boolean deleted = refreshTokenService.logout(userId); + +// if (!deleted) { +// log.warn("사용자 ID: {}의 리프레시 토큰 삭제에 실패했습니다.", userId); +// } else { +// log.info("사용자 ID: {}의 리프레시 토큰이 성공적으로 삭제되었습니다.", userId); +// } +// } else { +// log.warn("사용자를 찾을 수 없습니다."); +// } + +// return ResponseEntity.ok(ApiResponse.ok(LOGOUT_SUCCESS)); +// } + +// @PostMapping("/password-reset/request") +// public ResponseEntity> responseResponseEntity(@RequestBody SendingCodeRequest sendingCodeRequest) { +// // TODO 서비스로 넘기기 +// if (userRepository.existsByNameAndEmail(sendingCodeRequest.name(), sendingCodeRequest.email())) { +// String code = mailService.sendAuthCode(sendingCodeRequest.email()); +// authCodeService.saveAuthCode(sendingCodeRequest.email(), code); + +// return ResponseEntity.ok(ApiResponse.ok(CODE_CREATION_SUCCESS)); +// } +// throw new AuthException(USER_NOT_FOUND); +// } + +// @PostMapping("/password-reset/verify") +// public ResponseEntity> verifyCode(@RequestBody CodeVerificationRequest request) { +// // TODO 서비스 단 DTO 추가 +// boolean verified = authCodeService.verify(request.email(), request.code()); +// CodeVerificationResponse response = new CodeVerificationResponse(verified); + +// return ResponseEntity.ok(ApiResponse.ok(PASSWORD_RESET_VERIFICATION_SUCCESS, response)); +// } + +// @PostMapping("/password-reset/confirm") +// public ResponseEntity> resetPassword(@RequestBody PasswordResetRequest passwordResetRequest) throws NoSuchAlgorithmException, InvalidKeyException { +// // TODO 서비스 단으로 +// Optional user = userRepository.findByEmail(passwordResetRequest.email()); +// if (user.isEmpty()) { +// throw new AuthException(USER_NOT_FOUND); +// } + +// User foundUser = user.get(); +// foundUser.updatePassword(passwordResetRequest.password()); +// userRepository.save(foundUser); + +// return ResponseEntity.ok(ApiResponse.ok(PASSWORD_CHANGE_SUCCESS)); +// } + +// /** +// * 요구 권한(role) 이상이면 200, 아니면 403 +// * 미인증이면 401 + +// * 예) /api/v1/auth/LEAD, /api/v1/auth/ORGANIZER, /api/v1/auth/ADMIN +// */ +// @GetMapping("/{role}") +// public ResponseEntity> checkRoleOrTeam(@AuthenticationPrincipal TokenProvider.CustomUserDetails me, @PathVariable UserRole role, @RequestParam(value = "team", required = false) TeamType requiredTeam) { +// // 1) 인증 체크 +// if (me == null) { +// return ResponseEntity.status(HttpStatus.UNAUTHORIZED) +// .body(ApiResponse.error(GlobalErrorCode.UNAUTHORIZED_USER.getStatus() +// .value(), GlobalErrorCode.UNAUTHORIZED_USER.getMessage(), null)); +// } + +// // 2) role check +// final boolean roleOk = UserRole.hasAtLeast(me.getRole(), role); + +// // 3) team check if team parameter exists +// boolean teamOk = false; +// if (requiredTeam != null) { +// if (UserRole.hasAtLeast(me.getRole(), UserRole.ORGANIZER)) { +// teamOk = true; +// } else { +// teamOk = (me.getTeam() != null && me.getTeam() == requiredTeam); +// } +// } + +// // 4) OR 조건으로 최종 판정 +// if (roleOk || teamOk) { +// return ResponseEntity.ok(ApiResponse.ok("ROLE_OR_TEAM_CHECK_PASSED", null)); +// } + +// return ResponseEntity.status(HttpStatus.FORBIDDEN) +// .body(ApiResponse.error(GlobalErrorCode.FORBIDDEN_USER.getStatus() +// .value(), GlobalErrorCode.FORBIDDEN_USER.getMessage(), null)); +// } +// } diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginRequest.java b/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginRequest.java new file mode 100644 index 00000000..e5de4798 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginRequest.java @@ -0,0 +1,3 @@ +package inha.gdgoc.domain.auth.dto.request; +import lombok.Data; +@Data public class LoginRequest { private String idToken; } \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginSuccessResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginSuccessResponse.java new file mode 100644 index 00000000..3e1ef692 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginSuccessResponse.java @@ -0,0 +1,22 @@ +package inha.gdgoc.domain.auth.dto.response; +import inha.gdgoc.domain.user.entity.User; +import inha.gdgoc.domain.user.dto.response.UserSummaryResponse; // 기존 것 재사용 또는 새로 생성 +import lombok.Builder; +import lombok.Data; + +@Data @Builder +public class LoginSuccessResponse { + private boolean isNewUser; + private String accessToken; + private String refreshToken; + private UserResponse user; // 간단한 유저 정보 DTO + + public static LoginSuccessResponse of(User user, TokenDto tokens) { + return LoginSuccessResponse.builder() + .isNewUser(false) + .accessToken(tokens.getAccessToken()) + .refreshToken(tokens.getRefreshToken()) + .user(UserResponse.from(user)) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/request/SignupRequest.java b/src/main/java/inha/gdgoc/domain/auth/dto/request/SignupRequest.java new file mode 100644 index 00000000..c27ad650 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/request/SignupRequest.java @@ -0,0 +1,10 @@ +package inha.gdgoc.domain.auth.dto.request; +import lombok.Data; +@Data public class SignupRequest { + private String oauthSubject; + private String email; + private String name; + private String studentId; + private String phoneNumber; + private String major; +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java index a8aa0842..344ff485 100644 --- a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java +++ b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java @@ -15,6 +15,7 @@ import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; @@ -29,110 +30,139 @@ @RequiredArgsConstructor public class AuthService { - private final RefreshTokenService refreshTokenService; private final UserRepository userRepository; - private final RestTemplate restTemplate = new RestTemplate(); private final TokenProvider tokenProvider; + private final StringRedisTemplate redisTemplate; + - @Value("${google.client-id}") - private String clientId; - @Value("${google.client-secret}") - private String clientSecret; + @Value("${app.google.client-id}") + private String googleClientId; - @Value("${google.redirect-uri}") - private String redirectUri; + //로그인 + @Transactional + public Object login(String idToken) { + // Google ID Token 검증 + GoogleUserInfo googleUser = verifyGoogleToken(idToken); - public Map processOAuthLogin(String code, HttpServletResponse response) { - // 1. code → access token 요청 - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - - MultiValueMap params = new LinkedMultiValueMap<>(); - params.add("code", code); - params.add("client_id", clientId); - params.add("client_secret", clientSecret); - params.add("redirect_uri", redirectUri); - params.add("grant_type", "authorization_code"); - - HttpEntity> tokenRequest = new HttpEntity<>(params, headers); - ResponseEntity tokenResponse = restTemplate.postForEntity("https://oauth2.googleapis.com/token", tokenRequest, Map.class); - - String googleAccessToken = (String) tokenResponse.getBody().get("access_token"); - - // 2. access token → 사용자 정보 요청 - HttpHeaders userInfoHeaders = new HttpHeaders(); - userInfoHeaders.setBearerAuth(googleAccessToken); - HttpEntity userInfoRequest = new HttpEntity<>(userInfoHeaders); - - ResponseEntity userInfoResponse = restTemplate.exchange("https://www.googleapis.com/oauth2/v2/userinfo", HttpMethod.GET, userInfoRequest, Map.class); - - // 3. Google에서 가져온 이름, 이메일로 가입된 정보가 없으면 회원가입, 있으면 로그인 - Map userInfo = userInfoResponse.getBody(); - String email = (String) userInfo.get("email"); - String name = (String) userInfo.get("name"); + // 도메인 검증 (인하대 메일만 허용) + if (!googleUser.getEmail().endsWith("@inha.edu")) { + throw new IllegalArgumentException("인하대학교(@inha.edu) 계정만 이용 가능합니다."); + } - Optional foundUser = userRepository.findByEmail(email); - if (foundUser.isEmpty()) { - return Map.of("isExists", false, "email", email, "name", name); + // DB에서 유저 조회 (OAuth Subject 기준) + User user = userRepository.findByOauthSubject(googleUser.getSub()).orElse(null); + + // 신규 유저 -> 회원가입 필요 응답 (202 or 200 with isNewUser=true) + if (user == null) { + return SignupNeededResponse.builder() + .isNewUser(true) + .oauthSubject(googleUser.getSub()) + .email(googleUser.getEmail()) + .name(googleUser.getName()) + .build(); } - User user = foundUser.get(); + // 기존 유저 -> 토큰 발급 및 로그인 성공 응답 + TokenDto tokens = generateTokens(user); + return LoginSuccessResponse.of(user, tokens); + } - String jwtAccessToken = tokenProvider.generateGoogleLoginToken(user, Duration.ofHours(1)); - String refreshToken = refreshTokenService.getOrCreateRefreshToken(user, Duration.ofDays(1), LoginType.GOOGLE_LOGIN); + //회원가입 + @Transactional + public LoginSuccessResponse signup(SignupRequest request) { + // 학번 중복 체크 + if (userRepository.existsByStudentId(request.getStudentId())) { + throw new IllegalArgumentException("이미 존재하는 학번입니다."); + } - ResponseCookie refreshCookie = ResponseCookie.from("refresh_token", refreshToken) - .httpOnly(true) - .secure(true) - .sameSite("None") - .domain(".gdgocinha.com") - .path("/") - .maxAge(Duration.ofDays(1)) + // 전화번호 정규화 (숫자만 남김) + String cleanPhone = request.getPhoneNumber().replaceAll("[^0-9]", ""); + + // 유저 엔티티 생성 및 저장 + User newUser = User.builder() + .oauthSubject(request.getOauthSubject()) // 구글 sub + .email(request.getEmail()) + .name(request.getName()) + .studentId(request.getStudentId()) + .major(request.getMajor()) + .phoneNumber(cleanPhone) + // Role(GUEST), Status(PENDING) 등은 User 엔티티 생성자에서 기본값 처리됨 .build(); - // Set-Cookie 헤더로 추가 - log.info("Response Cookie에 저장된 Refresh Token: {}", refreshCookie); - response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString()); + userRepository.save(newUser); - return Map.of("isExists", true, "access_token", jwtAccessToken); + // 토큰 발급 + TokenDto tokens = generateTokens(newUser); + return LoginSuccessResponse.of(newUser, tokens); } + public String refresh(String refreshToken) { + // Redis에서 Refresh Token 확인 + String redisKey = "RT:" + refreshToken; + String subject = redisTemplate.opsForValue().get(redisKey); - public LoginResponse loginWithPassword(String email, String password, HttpServletResponse response) throws NoSuchAlgorithmException, InvalidKeyException { - Optional user = userRepository.findByEmail(email); - if (user.isEmpty()) { - return new LoginResponse(false, null); + if (subject == null) { + throw new IllegalArgumentException("유효하지 않거나 만료된 리프레시 토큰입니다."); } - User foundUser = user.get(); - String hashedInputPassword = encrypt(password, foundUser.getSalt()); - if (!foundUser.getPassword().equals(hashedInputPassword)) { - return new LoginResponse(false, null); - } - - String accessToken = tokenProvider.generateSelfSignupToken(foundUser, Duration.ofHours(1)); - String refreshToken = refreshTokenService.getOrCreateRefreshToken(foundUser, Duration.ofDays(1), LoginType.SELF_SIGNUP); - - ResponseCookie refreshCookie = ResponseCookie.from("refresh_token", refreshToken) - .httpOnly(true) - .secure(true) - .sameSite("None") - .path("/") - .maxAge(Duration.ofDays(1)) - .build(); + // DB에서 유저 조회 (권한 변경 등이 있었을 수 있으므로 다시 조회) + User user = userRepository.findByOauthSubject(subject) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); - log.info("Response Cookie에 저장된 Refresh Token: {}", refreshCookie); - response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString()); - - return new LoginResponse(true, accessToken); + // Access Token만 새로 발급 (Refresh Token은 그대로 유지하거나, 정책에 따라 재발급 가능) + return tokenProvider.createAccessToken(user); + } + public void logout(String refreshToken) { + // Redis에서 Refresh Token 삭제 + String redisKey = "RT:" + refreshToken; + redisTemplate.delete(redisKey); } - public Long getAuthenticationUserId(Authentication authentication) { - Object principal = authentication.getPrincipal(); + + //토큰 발급 및 Redis 저장 + + private TokenDto generateTokens(User user) { + // Access Token 생성 (JWT) + String accessToken = tokenProvider.createAccessToken(user); + + // Refresh Token 생성 (Random UUID) + String refreshToken = tokenProvider.createRefreshToken(); + + // Redis 저장 (Key: "RT:{refreshToken}", Value: oauthSubject, 유효기간: 14일) + redisTemplate.opsForValue().set( + "RT:" + refreshToken, + user.getOauthSubject(), + 14, + TimeUnit.DAYS + ); + + return new TokenDto(accessToken, refreshToken); + } - if (principal instanceof TokenProvider.CustomUserDetails user) { - return user.getUserId(); + + // Google ID Token 검증 + private GoogleUserInfo verifyGoogleToken(String idTokenString) { + try { + GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new GsonFactory()) + .setAudience(Collections.singletonList(googleClientId)) + .build(); + + GoogleIdToken idToken = verifier.verify(idTokenString); + + if (idToken != null) { + GoogleIdToken.Payload payload = idToken.getPayload(); + return new GoogleUserInfo( + payload.getSubject(), // sub + payload.getEmail(), // email + (String) payload.get("name") // name + ); + } else { + throw new IllegalArgumentException("유효하지 않은 토큰입니다."); + } + } catch (GeneralSecurityException | IOException e) { + log.error("Google Token Verification Failed", e); + throw new IllegalArgumentException("토큰 검증 실패", e); } - throw new IllegalArgumentException("user Id is null"); } } + diff --git a/src/main/java/inha/gdgoc/domain/user/entity/User.java b/src/main/java/inha/gdgoc/domain/user/entity/User.java index dcff2f1d..eac69e8e 100644 --- a/src/main/java/inha/gdgoc/domain/user/entity/User.java +++ b/src/main/java/inha/gdgoc/domain/user/entity/User.java @@ -46,6 +46,9 @@ public class User extends BaseEntity { @Column(name = "name", nullable = false) private String name; + @Column(name = "oauthSubject", nullable = false, unique = true) + private String oauthSubject; + @Column(name = "major", nullable = false) private String major; @@ -58,8 +61,8 @@ public class User extends BaseEntity { @Column(name = "email", nullable = false) private String email; - @Column(name = "password", nullable = false) - private String password; + // @Column(name = "password", nullable = false) + // private String password; @Enumerated(EnumType.STRING) @Column(name = "user_role", nullable = false) @@ -69,8 +72,11 @@ public class User extends BaseEntity { @Column(name = "team") private TeamType team; - @Column(name = "salt", nullable = false) - private byte[] salt; + @Enumerated(EnumType.STRING) + private MembershipStatus membershipStatus; + + // @Column(name = "salt", nullable = false) + // private byte[] salt; @Column(name = "image") private String image; @@ -91,20 +97,21 @@ public class User extends BaseEntity { @Builder public User( - String name, String major, String studentId, String phoneNumber, - String email, String password, UserRole userRole, + String name, String oauthSubject, String major, String studentId, String phoneNumber, + String email, UserRole userRole, TeamType team, - byte[] salt, String image, SocialUrls social, Careers careers + String image, SocialUrls social, Careers careers ) { + this.oauthSubject = oauthSubject; this.name = name; this.major = major; this.studentId = studentId; this.phoneNumber = phoneNumber; this.email = email; - this.password = password; + //this.password = password; this.userRole = userRole; this.team = team; - this.salt = salt; + //this.salt = salt; this.image = image; this.social = (social != null ? social : new SocialUrls()); this.careers = (careers != null ? careers : new Careers()); @@ -124,10 +131,21 @@ public void addStudyAttendee(StudyAttendee studyAttendee) { } } - public void updatePassword(String password) throws NoSuchAlgorithmException, InvalidKeyException { - this.password = EncryptUtil.encrypt(password, this.salt); - } + // public void updatePassword(String password) throws NoSuchAlgorithmException, InvalidKeyException { + // this.password = EncryptUtil.encrypt(password, this.salt); + // } + public void approve() { + this.membershipStatus = MembershipStatus.APPROVED; + if (this.userRole == UserRole.GUEST) { + this.userRole = UserRole.MEMBER; + } + } + public void reject() { + this.membershipStatus = MembershipStatus.REJECTED; + } + public enum MembershipStatus { PENDING, APPROVED, REJECTED } + public boolean isGuest() { return this.userRole == UserRole.GUEST; } diff --git a/src/main/java/inha/gdgoc/domain/user/enums/MembershipStatus.java b/src/main/java/inha/gdgoc/domain/user/enums/MembershipStatus.java new file mode 100644 index 00000000..02f50d6c --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/user/enums/MembershipStatus.java @@ -0,0 +1,7 @@ +package inha.gdgoc.domain.user.enums; + +public enum MembershipStatus { + PENDING, // 승인 대기 + APPROVED, // 승인 완료 + REJECTED // 승인 거절 +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java b/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java index 3836883c..c749ed76 100644 --- a/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java +++ b/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java @@ -19,6 +19,9 @@ @Repository public interface UserRepository extends JpaRepository, UserRepositoryCustom { + Optional findByOauthSubject(String oauthSubject); + + boolean existsByStudentId(String studentId); boolean existsByNameAndEmail(String name, String email); boolean existsByEmail(String email); diff --git a/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java b/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java index 999ef790..c40d31d7 100644 --- a/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java +++ b/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java @@ -25,35 +25,49 @@ public class TokenProvider { private final JwtProperties jwtProperties; - // 자체 로그인용 토큰 생성 - public String generateSelfSignupToken(User user, Duration expiredAt) { + // Access Token 생성 (JWT) + public String createAccessToken(User user){ Date now = new Date(); - return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user, LoginType.SELF_SIGNUP); - } + Date validity = new Date(now.getTime() + jwtProperties.getAccessTokenValidity()); // application.properties에서 시간 가져옴 - // 구글 로그인용 토큰 생성 - public String generateGoogleLoginToken(User user, Duration expiredAt) { - Date now = new Date(); - return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user, LoginType.GOOGLE_LOGIN); + String teamName = (user.getTeam() != null) ? user.getTeam().name() : null; + + return Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setIssuer(jwtProperties.getGoogleIssuer()) // Issuer는 하나로 통일 (또는 제거 가능) + .setIssuedAt(now) + .setExpiration(validity) + .setSubject(user.getEmail()) // sub: 이메일 + .claim("id", user.getId()) // claim: 유저 PK (DB 조회용) + .claim("role", user.getUserRole().name()) // claim: 권한 + .claim("team", teamName) // claim: 팀 (없으면 null) + .signWith(SignatureAlgorithm.HS256, Base64.getEncoder() + .encodeToString(jwtProperties.getSecretKey().getBytes())) + .compact(); } - public String generateRefreshToken(User user, Duration expiredAt, LoginType loginType) { - Date now = new Date(); - return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user, loginType); + + // Refresh Token 생성 (Random UUID) + // JWT가 아니라, 단순 랜덤 문자열로 생성하여 Redis 저장용으로 씁니다. + public String createRefreshToken() { + return UUID.randomUUID().toString(); } + // 토큰 유효성 검증 public Claims validToken(String token) throws ExpiredJwtException, UnsupportedJwtException, MalformedJwtException, SignatureException, IllegalArgumentException { return getClaims(token); } + // Authentication 객체 생성 (Spring Security용) public Authentication getAuthentication(String token) { Claims claims = getClaims(token); + // ID 추출 Number idNum = claims.get("id", Number.class); if (idNum == null) throw new BusinessException(INVALID_JWT_REQUEST); Long userId = idNum.longValue(); - String username = claims.getSubject(); + String email = claims.getSubject(); // role (필수) String roleStr = claims.get("role", String.class); @@ -76,33 +90,10 @@ public Authentication getAuthentication(String token) { } } - CustomUserDetails userDetails = new CustomUserDetails(userId, username, "", authorities, userRole, team); + CustomUserDetails userDetails = new CustomUserDetails(userId, email, "", authorities, userRole, team); return new UsernamePasswordAuthenticationToken(userDetails, null, authorities); } - - private String makeToken(Date expiry, User user, LoginType loginType) { - Date now = new Date(); - String issuer = (loginType == LoginType.SELF_SIGNUP) ? jwtProperties.getSelfIssuer() : jwtProperties.getGoogleIssuer(); - - // team: enum name 저장(예: "PR_DESIGN"), 없으면 null - String teamEnumName = (user.getTeam() == null) ? null : user.getTeam().name(); - - return Jwts.builder() - .setHeaderParam(Header.TYPE, Header.JWT_TYPE) - .setIssuer(issuer) - .setIssuedAt(now) - .setExpiration(expiry) - .setSubject(user.getEmail()) - .claim("id", user.getId()) - .claim("loginType", loginType.name()) - .claim("role", user.getUserRole().name()) - .claim("team", teamEnumName) - .signWith(SignatureAlgorithm.HS256, Base64.getEncoder() - .encodeToString(jwtProperties.getSecretKey().getBytes())) - .compact(); - } - private Claims getClaims(String token) { return Jwts.parser() .setSigningKey(Base64.getEncoder().encodeToString(jwtProperties.getSecretKey().getBytes())) diff --git a/src/main/java/inha/gdgoc/global/security/SecurityConfig.java b/src/main/java/inha/gdgoc/global/security/SecurityConfig.java index 4ef5b2eb..887ed149 100644 --- a/src/main/java/inha/gdgoc/global/security/SecurityConfig.java +++ b/src/main/java/inha/gdgoc/global/security/SecurityConfig.java @@ -39,7 +39,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .httpBasic(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .requestMatchers("/api/v1/auth/logout").authenticated() + .requestMatchers("/api/v1/auth/logout").permitAll() .requestMatchers( "/swagger-ui/**", "/v3/api-docs/**", From 84fc0aaa5399a87de86cfc1210dc2a2b74dbf16d Mon Sep 17 00:00:00 2001 From: Tae ho Date: Fri, 16 Jan 2026 12:53:15 +0900 Subject: [PATCH 15/49] auth update --- .../auth/controller/AuthController.java | 2 ++ .../gdgoc/domain/auth/dto/GoogleUserInfo.java | 12 +++++++ .../domain/auth/dto/request/LoginRequest.java | 9 ++++- .../dto/request/LoginSuccessResponse.java | 33 +++++++++++-------- .../auth/dto/request/SignupRequest.java | 14 +++++++- .../dto/response/LoginSuccessResponse.java | 24 ++++++++++++++ .../dto/response/SignupNeededResponse.java | 13 ++++++++ .../domain/auth/dto/response/TokenDto.java | 13 ++++++++ .../auth/dto/response/UserResponse.java | 27 +++++++++++++++ .../domain/auth/service/AuthService.java | 7 ++++ 10 files changed, 138 insertions(+), 16 deletions(-) create mode 100644 src/main/java/inha/gdgoc/domain/auth/dto/GoogleUserInfo.java create mode 100644 src/main/java/inha/gdgoc/domain/auth/dto/response/LoginSuccessResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/auth/dto/response/SignupNeededResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/auth/dto/response/TokenDto.java create mode 100644 src/main/java/inha/gdgoc/domain/auth/dto/response/UserResponse.java diff --git a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java index f0618712..4440fe3d 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java @@ -7,6 +7,8 @@ import inha.gdgoc.domain.auth.dto.response.AccessTokenResponse; import inha.gdgoc.domain.auth.dto.response.CodeVerificationResponse; import inha.gdgoc.domain.auth.dto.response.LoginResponse; +import inha.gdgoc.domain.auth.dto.request.LoginRequest; +import inha.gdgoc.domain.auth.dto.request.SignupRequest; import inha.gdgoc.domain.auth.exception.AuthErrorCode; import inha.gdgoc.domain.auth.exception.AuthException; import inha.gdgoc.domain.auth.service.AuthCodeService; diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/GoogleUserInfo.java b/src/main/java/inha/gdgoc/domain/auth/dto/GoogleUserInfo.java new file mode 100644 index 00000000..519c70aa --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/GoogleUserInfo.java @@ -0,0 +1,12 @@ +package inha.gdgoc.domain.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class GoogleUserInfo { + private String sub; + private String email; + private String name; +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginRequest.java b/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginRequest.java index e5de4798..750b0408 100644 --- a/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginRequest.java +++ b/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginRequest.java @@ -1,3 +1,10 @@ package inha.gdgoc.domain.auth.dto.request; + import lombok.Data; -@Data public class LoginRequest { private String idToken; } \ No newline at end of file +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class LoginRequest { + private String idToken; +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginSuccessResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginSuccessResponse.java index 3e1ef692..68367b19 100644 --- a/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginSuccessResponse.java +++ b/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginSuccessResponse.java @@ -1,22 +1,27 @@ -package inha.gdgoc.domain.auth.dto.response; +package inha.gdgoc.domain.user.dto.response; + import inha.gdgoc.domain.user.entity.User; -import inha.gdgoc.domain.user.dto.response.UserSummaryResponse; // 기존 것 재사용 또는 새로 생성 +import inha.gdgoc.domain.user.enums.UserRole; +import inha.gdgoc.domain.user.enums.TeamType; import lombok.Builder; import lombok.Data; -@Data @Builder -public class LoginSuccessResponse { - private boolean isNewUser; - private String accessToken; - private String refreshToken; - private UserResponse user; // 간단한 유저 정보 DTO +@Data +@Builder +public class UserResponse { + private Long id; + private String email; + private String name; + private UserRole role; + private TeamType team; - public static LoginSuccessResponse of(User user, TokenDto tokens) { - return LoginSuccessResponse.builder() - .isNewUser(false) - .accessToken(tokens.getAccessToken()) - .refreshToken(tokens.getRefreshToken()) - .user(UserResponse.from(user)) + public static UserResponse from(User user) { + return UserResponse.builder() + .id(user.getId()) + .email(user.getEmail()) + .name(user.getName()) + .role(user.getUserRole()) + .team(user.getTeam()) .build(); } } \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/request/SignupRequest.java b/src/main/java/inha/gdgoc/domain/auth/dto/request/SignupRequest.java index c27ad650..9fc86210 100644 --- a/src/main/java/inha/gdgoc/domain/auth/dto/request/SignupRequest.java +++ b/src/main/java/inha/gdgoc/domain/auth/dto/request/SignupRequest.java @@ -1,10 +1,22 @@ package inha.gdgoc.domain.auth.dto.request; + +import jakarta.validation.constraints.NotBlank; import lombok.Data; -@Data public class SignupRequest { +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class SignupRequest { + @NotBlank private String oauthSubject; + @NotBlank private String email; + @NotBlank private String name; + @NotBlank private String studentId; + @NotBlank private String phoneNumber; + @NotBlank private String major; } \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/response/LoginSuccessResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/LoginSuccessResponse.java new file mode 100644 index 00000000..df83109a --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/LoginSuccessResponse.java @@ -0,0 +1,24 @@ +package inha.gdgoc.domain.auth.dto.response; + +import inha.gdgoc.domain.user.entity.User; +import inha.gdgoc.domain.user.dto.response.UserResponse; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class LoginSuccessResponse { + private boolean isNewUser; + private String accessToken; + private String refreshToken; + private UserResponse user; + + public static LoginSuccessResponse of(User user, TokenDto tokens) { + return LoginSuccessResponse.builder() + .isNewUser(false) + .accessToken(tokens.getAccessToken()) + .refreshToken(tokens.getRefreshToken()) + .user(UserResponse.from(user)) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/response/SignupNeededResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/SignupNeededResponse.java new file mode 100644 index 00000000..2d279c5d --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/SignupNeededResponse.java @@ -0,0 +1,13 @@ +package inha.gdgoc.domain.auth.dto.response; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class SignupNeededResponse { + private boolean isNewUser; + private String oauthSubject; + private String email; + private String name; +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/response/TokenDto.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/TokenDto.java new file mode 100644 index 00000000..5cc9754f --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/TokenDto.java @@ -0,0 +1,13 @@ +package inha.gdgoc.domain.auth.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@AllArgsConstructor +public class TokenDto { + private String accessToken; + private String refreshToken; +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/response/UserResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/UserResponse.java new file mode 100644 index 00000000..68367b19 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/UserResponse.java @@ -0,0 +1,27 @@ +package inha.gdgoc.domain.user.dto.response; + +import inha.gdgoc.domain.user.entity.User; +import inha.gdgoc.domain.user.enums.UserRole; +import inha.gdgoc.domain.user.enums.TeamType; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class UserResponse { + private Long id; + private String email; + private String name; + private UserRole role; + private TeamType team; + + public static UserResponse from(User user) { + return UserResponse.builder() + .id(user.getId()) + .email(user.getEmail()) + .name(user.getName()) + .role(user.getUserRole()) + .team(user.getTeam()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java index 344ff485..4928f61c 100644 --- a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java +++ b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java @@ -1,5 +1,12 @@ package inha.gdgoc.domain.auth.service; +import inha.gdgoc.domain.auth.dto.GoogleUserInfo; +import inha.gdgoc.domain.auth.dto.request.SignupRequest; +import inha.gdgoc.domain.auth.dto.response.LoginSuccessResponse; +import inha.gdgoc.domain.auth.dto.response.SignupNeededResponse; +import inha.gdgoc.domain.auth.dto.response.TokenDto; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.data.redis.core.RedisTemplate; import inha.gdgoc.domain.auth.dto.response.LoginResponse; import inha.gdgoc.domain.auth.enums.LoginType; import inha.gdgoc.domain.user.entity.User; From b9e18fbf1a83ea4f4e2f18a8b0c1be5b776facf2 Mon Sep 17 00:00:00 2001 From: Tae ho Date: Fri, 16 Jan 2026 13:04:02 +0900 Subject: [PATCH 16/49] google auth update --- .../gdgoc/domain/auth/dto/response/LoginSuccessResponse.java | 3 +-- .../java/inha/gdgoc/domain/auth/dto/response/UserResponse.java | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/response/LoginSuccessResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/LoginSuccessResponse.java index df83109a..34242bc6 100644 --- a/src/main/java/inha/gdgoc/domain/auth/dto/response/LoginSuccessResponse.java +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/LoginSuccessResponse.java @@ -1,7 +1,6 @@ package inha.gdgoc.domain.auth.dto.response; import inha.gdgoc.domain.user.entity.User; -import inha.gdgoc.domain.user.dto.response.UserResponse; import lombok.Builder; import lombok.Data; @@ -11,7 +10,7 @@ public class LoginSuccessResponse { private boolean isNewUser; private String accessToken; private String refreshToken; - private UserResponse user; + private UserResponse user; public static LoginSuccessResponse of(User user, TokenDto tokens) { return LoginSuccessResponse.builder() diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/response/UserResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/UserResponse.java index 68367b19..133ee35a 100644 --- a/src/main/java/inha/gdgoc/domain/auth/dto/response/UserResponse.java +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/UserResponse.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.user.dto.response; +package inha.gdgoc.domain.auth.dto.response; import inha.gdgoc.domain.user.entity.User; import inha.gdgoc.domain.user.enums.UserRole; From 745f4493a7b76b907b42a8fe8e3f1ae9c96ede35 Mon Sep 17 00:00:00 2001 From: Tae ho Date: Fri, 16 Jan 2026 13:10:45 +0900 Subject: [PATCH 17/49] google auth error update --- .../dto/request/LoginSuccessResponse.java | 27 ------------------- 1 file changed, 27 deletions(-) delete mode 100644 src/main/java/inha/gdgoc/domain/auth/dto/request/LoginSuccessResponse.java diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginSuccessResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginSuccessResponse.java deleted file mode 100644 index 68367b19..00000000 --- a/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginSuccessResponse.java +++ /dev/null @@ -1,27 +0,0 @@ -package inha.gdgoc.domain.user.dto.response; - -import inha.gdgoc.domain.user.entity.User; -import inha.gdgoc.domain.user.enums.UserRole; -import inha.gdgoc.domain.user.enums.TeamType; -import lombok.Builder; -import lombok.Data; - -@Data -@Builder -public class UserResponse { - private Long id; - private String email; - private String name; - private UserRole role; - private TeamType team; - - public static UserResponse from(User user) { - return UserResponse.builder() - .id(user.getId()) - .email(user.getEmail()) - .name(user.getName()) - .role(user.getUserRole()) - .team(user.getTeam()) - .build(); - } -} \ No newline at end of file From ecee8810142ffb827186cb99b07c7d212accc9a5 Mon Sep 17 00:00:00 2001 From: Tae ho Date: Fri, 16 Jan 2026 13:24:40 +0900 Subject: [PATCH 18/49] google auth error update --- .../auth/controller/AuthController.java | 3 + .../auth/controller/message/AuthMessage.java | 1 + .../domain/auth/service/AuthService.java | 15 +- .../auth/service/RefreshTokenService.java | 146 ------------------ 4 files changed, 17 insertions(+), 148 deletions(-) delete mode 100644 src/main/java/inha/gdgoc/domain/auth/service/RefreshTokenService.java diff --git a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java index 4440fe3d..1a595341 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java @@ -1,5 +1,7 @@ package inha.gdgoc.domain.auth.controller; + + import inha.gdgoc.domain.auth.dto.request.CodeVerificationRequest; import inha.gdgoc.domain.auth.dto.request.PasswordResetRequest; import inha.gdgoc.domain.auth.dto.request.SendingCodeRequest; @@ -40,6 +42,7 @@ import java.util.Map; import java.util.Optional; +import inha.gdgoc.domain.auth.exception.AuthErrorCode; import static inha.gdgoc.domain.auth.controller.message.AuthMessage.*; import static inha.gdgoc.domain.auth.exception.AuthErrorCode.UNAUTHORIZED_USER; import static inha.gdgoc.domain.auth.exception.AuthErrorCode.USER_NOT_FOUND; diff --git a/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java b/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java index 6d900266..f20924ff 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java @@ -5,6 +5,7 @@ public class AuthMessage { public static final String ACCESS_TOKEN_REFRESH_SUCCESS = "액세스 토큰이 성공적으로 재발급되었습니다."; public static final String LOGIN_WITH_PASSWORD_SUCCESS = "성공적으로 비밀번호를 사용하여 로그인했습니다."; public static final String LOGOUT_SUCCESS = "성공적으로 로그아웃했습니다."; + public static final String SIGNUP_SUCCESS = "성공적으로 회원가입했습니다"; public static final String CODE_CREATION_SUCCESS = "성공적으로 인증 코드를 발급했습니다."; public static final String PASSWORD_RESET_VERIFICATION_SUCCESS = "성공적으로 비밀번호 변경을 위한 인증 코드 검증이 완료되었습니다."; public static final String PASSWORD_CHANGE_SUCCESS = "성공적으로 비밀번호를 변경했습니다."; diff --git a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java index 4928f61c..b9d23727 100644 --- a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java +++ b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java @@ -24,11 +24,13 @@ import org.springframework.web.client.RestTemplate; import org.springframework.data.redis.core.StringRedisTemplate; + import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.util.Map; import java.util.Optional; +import java.util.concurrent.TimeUnit; import static inha.gdgoc.global.util.EncryptUtil.encrypt; @@ -39,7 +41,7 @@ public class AuthService { private final UserRepository userRepository; private final TokenProvider tokenProvider; - private final StringRedisTemplate redisTemplate; + private final StringRedisTemplate redisTemplate; @@ -119,12 +121,21 @@ public String refresh(String refreshToken) { // Access Token만 새로 발급 (Refresh Token은 그대로 유지하거나, 정책에 따라 재발급 가능) return tokenProvider.createAccessToken(user); } + //로그아웃 public void logout(String refreshToken) { // Redis에서 Refresh Token 삭제 String redisKey = "RT:" + refreshToken; redisTemplate.delete(redisKey); } + public Long getAuthenticationUserId(Authentication authentication) { + Object principal = authentication.getPrincipal(); + if (principal instanceof TokenProvider.CustomUserDetails user) { + return user.getUserId(); + } + throw new IllegalArgumentException("User ID not found in authentication"); + } + //토큰 발급 및 Redis 저장 @@ -167,7 +178,7 @@ private GoogleUserInfo verifyGoogleToken(String idTokenString) { throw new IllegalArgumentException("유효하지 않은 토큰입니다."); } } catch (GeneralSecurityException | IOException e) { - log.error("Google Token Verification Failed", e); + log.error("Google Token Verification Failed", (Throwable) e); throw new IllegalArgumentException("토큰 검증 실패", e); } } diff --git a/src/main/java/inha/gdgoc/domain/auth/service/RefreshTokenService.java b/src/main/java/inha/gdgoc/domain/auth/service/RefreshTokenService.java deleted file mode 100644 index df4297d2..00000000 --- a/src/main/java/inha/gdgoc/domain/auth/service/RefreshTokenService.java +++ /dev/null @@ -1,146 +0,0 @@ -package inha.gdgoc.domain.auth.service; - -import inha.gdgoc.global.config.jwt.TokenProvider; -import inha.gdgoc.domain.auth.entity.RefreshToken; -import inha.gdgoc.domain.auth.enums.LoginType; -import inha.gdgoc.domain.auth.repository.RefreshTokenRepository; -import inha.gdgoc.domain.user.entity.User; -import inha.gdgoc.domain.user.repository.UserRepository; -import io.jsonwebtoken.Claims; -import jakarta.transaction.Transactional; -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Slf4j -@Service -@RequiredArgsConstructor -@Transactional -public class RefreshTokenService { - - private final UserRepository userRepository; - private final TokenProvider tokenProvider; - private final RefreshTokenRepository refreshTokenRepository; - - @Transactional - public String getOrCreateRefreshToken(User user, Duration duration, LoginType loginType) { - Optional existingToken = refreshTokenRepository.findByUser(user); - - // 1. 유효한 토큰이 있으면 재사용 - if (existingToken.isPresent()) { - RefreshToken refreshToken = existingToken.get(); - - // 로컬 시간 기준으로 만료 시간 체크 - if (refreshToken.getExpiryDate().isAfter(LocalDateTime.now())) { - log.info("유효한 Refresh Token이 존재합니다. 재사용합니다: {}", refreshToken.getToken()); - return refreshToken.getToken(); - } - } - - // 2. 없거나 만료되었으면 새로 생성 - String newToken = tokenProvider.generateRefreshToken(user, duration, loginType); - log.info("새로운 Refresh Token 생성됨: {}", newToken); - - // 3. 토큰 저장 (Private 메서드 활용) - saveRefreshToken(newToken, user, duration); - - return newToken; - } - - @Transactional - public String refreshAccessToken(String refreshToken) { - log.info("리프레시 토큰 서비스 호출됨. 토큰: {}", refreshToken); - - // 1. JWT 파싱하여 이메일 추출 - Claims claims = tokenProvider.validToken(refreshToken); - if (claims == null) { - throw new RuntimeException("유효하지 않은 리프레시 토큰입니다."); - } - - String email = claims.getSubject(); - Optional optionalUser = userRepository.findByEmail(email); - - if (optionalUser.isEmpty()) { - throw new RuntimeException("해당 이메일로 등록된 유저를 찾을 수 없습니다."); - } - - User user = optionalUser.get(); - - // 2. DB에서 RefreshToken 조회 - RefreshToken storedToken = refreshTokenRepository.findByUser(user) - .orElseThrow(() -> new RuntimeException("DB에 저장된 리프레시 토큰이 없습니다.")); - - // 만료 시간 체크 (로컬 시간 기준) - if (storedToken.getExpiryDate().isBefore(LocalDateTime.now())) { - throw new RuntimeException("리프레시 토큰이 만료되었습니다."); - } - - if (!storedToken.getToken().equals(refreshToken)) { - log.info("DB에 저장된 토큰: {}", storedToken.getToken()); - throw new RuntimeException("리프레시 토큰이 일치하지 않습니다."); - } - - // 3. AccessToken 새로 발급 - String loginTypeStr = claims.get("loginType", String.class); - LoginType loginType = LoginType.valueOf(loginTypeStr); - - return (loginType == LoginType.SELF_SIGNUP) - ? tokenProvider.generateSelfSignupToken(user, Duration.ofHours(1)) - : tokenProvider.generateGoogleLoginToken(user, Duration.ofHours(1)); - } - - @Transactional - public boolean logout(Long userId) { - try { - Optional tokenEntity = refreshTokenRepository.findByUserId(userId); - if (tokenEntity.isPresent()) { - log.info("사용자 ID: {}에 대한 토큰을 DB에서 찾았습니다. 토큰 ID: {}", userId, tokenEntity.get().getId()); - } else { - log.warn("사용자 ID: {}에 대한 토큰이 DB에 존재하지 않습니다.", userId); - return false; - } - - // 토큰 삭제 실행 및 삭제된 행 수 확인 - refreshTokenRepository.deleteByUserId(userId); - log.info("사용자 ID: {} 로그아웃 처리", userId); - return true; - } catch (Exception e) { - log.error("사용자 ID: {} 로그아웃 중 오류 발생: {}", userId, e.getMessage(), e); - return false; - } - } - - private void saveRefreshToken(String refreshToken, User user, Duration expiredAt) { - // 1. 만료 시간 로컬 시간으로 설정 (KST) - LocalDateTime expiryDate = LocalDateTime.now().plus(expiredAt); - - // 2. 기존 토큰이 있는지 조회 - Optional existingToken = refreshTokenRepository.findByUser(user); - - if (existingToken.isPresent()) { - RefreshToken tokenEntity = existingToken.get(); - log.info("Before update: {}", tokenEntity.getToken()); - - // 기존 엔티티 업데이트 - tokenEntity.update(refreshToken, expiryDate); - - log.info("After update: {}", tokenEntity.getToken()); - refreshTokenRepository.save(tokenEntity); - return; - } - - // 3. 없으면 새로운 엔티티 생성 - RefreshToken tokenEntity = RefreshToken.builder() - .token(refreshToken) - .user(user) - .expiryDate(expiryDate) - .build(); - - log.info("새로운 Refresh Token 생성: {}", tokenEntity.getToken()); - - refreshTokenRepository.save(tokenEntity); - } -} From bd00b467564cb4dcea70c7f27ec5793831fb2188 Mon Sep 17 00:00:00 2001 From: Tae ho Date: Fri, 16 Jan 2026 13:32:31 +0900 Subject: [PATCH 19/49] google auth update --- .../auth/controller/AuthController.java | 54 ++++--------------- 1 file changed, 9 insertions(+), 45 deletions(-) diff --git a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java index 1a595341..7b4d8c14 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java @@ -1,94 +1,62 @@ package inha.gdgoc.domain.auth.controller; - - -import inha.gdgoc.domain.auth.dto.request.CodeVerificationRequest; -import inha.gdgoc.domain.auth.dto.request.PasswordResetRequest; -import inha.gdgoc.domain.auth.dto.request.SendingCodeRequest; -import inha.gdgoc.domain.auth.dto.request.UserLoginRequest; -import inha.gdgoc.domain.auth.dto.response.AccessTokenResponse; -import inha.gdgoc.domain.auth.dto.response.CodeVerificationResponse; -import inha.gdgoc.domain.auth.dto.response.LoginResponse; import inha.gdgoc.domain.auth.dto.request.LoginRequest; import inha.gdgoc.domain.auth.dto.request.SignupRequest; +import inha.gdgoc.domain.auth.dto.response.AccessTokenResponse; import inha.gdgoc.domain.auth.exception.AuthErrorCode; import inha.gdgoc.domain.auth.exception.AuthException; -import inha.gdgoc.domain.auth.service.AuthCodeService; import inha.gdgoc.domain.auth.service.AuthService; -import inha.gdgoc.domain.auth.service.MailService; -import inha.gdgoc.domain.auth.service.RefreshTokenService; -import inha.gdgoc.domain.user.entity.User; import inha.gdgoc.domain.user.enums.TeamType; import inha.gdgoc.domain.user.enums.UserRole; -import inha.gdgoc.domain.user.repository.UserRepository; import inha.gdgoc.global.config.jwt.TokenProvider; import inha.gdgoc.global.dto.response.ApiResponse; import inha.gdgoc.global.exception.GlobalErrorCode; import inha.gdgoc.global.security.AccessGuard; -import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.util.Map; -import java.util.Optional; - -import inha.gdgoc.domain.auth.exception.AuthErrorCode; import static inha.gdgoc.domain.auth.controller.message.AuthMessage.*; -import static inha.gdgoc.domain.auth.exception.AuthErrorCode.UNAUTHORIZED_USER; -import static inha.gdgoc.domain.auth.exception.AuthErrorCode.USER_NOT_FOUND; @Slf4j @RequestMapping("/api/v1/auth") @RestController @RequiredArgsConstructor - public class AuthController { private final AuthService authService; private final AccessGuard accessGuard; - - //구글 로그인 (ID Token 검증) - + // 1. 구글 로그인 (ID Token 검증) @PostMapping("/login") public ResponseEntity login(@RequestBody LoginRequest request) { try { - // AuthService에서 로그인 or 회원가입 필요 응답 분기 처리 결과 반환 Object response = authService.login(request.getIdToken()); - return ResponseEntity.ok(ApiResponse.ok(LOGIN_SUCCESS, response)); // LOGIN_SUCCESS 메시지 필요 (없으면 기존 것 사용) + return ResponseEntity.ok(ApiResponse.ok(LOGIN_SUCCESS, response)); } catch (IllegalArgumentException e) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(ApiResponse.error(AuthErrorCode.INVALID_TOKEN.getStatus().value(), e.getMessage(), null)); } } - - // 회원가입 (추가 정보 입력) + // 2. 회원가입 (추가 정보 입력) @PostMapping("/signup") public ResponseEntity signup(@Valid @RequestBody SignupRequest request) { try { Object response = authService.signup(request); return ResponseEntity.status(HttpStatus.CREATED) - .body(ApiResponse.ok(SIGNUP_SUCCESS, response)); // SIGNUP_SUCCESS 메시지 필요 + .body(ApiResponse.ok(SIGNUP_SUCCESS, response)); } catch (IllegalArgumentException e) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), e.getMessage(), null)); } } - - // 토큰 재발급 (Refresh) - + // 3. 토큰 재발급 (Refresh) @PostMapping("/refresh") public ResponseEntity refreshAccessToken(@CookieValue(value = "refresh_token", required = false) String refreshToken) { if (refreshToken == null) { @@ -104,20 +72,16 @@ public ResponseEntity refreshAccessToken(@CookieValue(value = "refresh_token" } } - - // 로그아웃 - + // 4. 로그아웃 @PostMapping("/logout") public ResponseEntity logout(@CookieValue(value = "refresh_token", required = false) String refreshToken) { - // 리프레시 토큰이 없으면 그냥 성공 처리 (이미 로그아웃된 상태로 간주) if (refreshToken != null) { authService.logout(refreshToken); } return ResponseEntity.ok(ApiResponse.ok(LOGOUT_SUCCESS)); } - - // 권한 체크 (Role or Team) + // 5. 권한 체크 (Role or Team) @GetMapping("/{role}") public ResponseEntity> checkRoleOrTeam( @AuthenticationPrincipal TokenProvider.CustomUserDetails me, @@ -152,4 +116,4 @@ public ResponseEntity logout(@CookieValue(value = "refresh_token", required = null )); } -} +} \ No newline at end of file From 97f174f0568e6fa68c481908b019fdf45d7671af Mon Sep 17 00:00:00 2001 From: Tae ho Date: Fri, 16 Jan 2026 13:38:09 +0900 Subject: [PATCH 20/49] goolge auth update --- .../gdgoc/domain/auth/dto/request/UserLoginRequest.java | 4 ---- .../java/inha/gdgoc/domain/auth/service/AuthService.java | 6 +++++- 2 files changed, 5 insertions(+), 5 deletions(-) delete mode 100644 src/main/java/inha/gdgoc/domain/auth/dto/request/UserLoginRequest.java diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/request/UserLoginRequest.java b/src/main/java/inha/gdgoc/domain/auth/dto/request/UserLoginRequest.java deleted file mode 100644 index b7f95638..00000000 --- a/src/main/java/inha/gdgoc/domain/auth/dto/request/UserLoginRequest.java +++ /dev/null @@ -1,4 +0,0 @@ -package inha.gdgoc.domain.auth.dto.request; - -public record UserLoginRequest(String email, String password) { -} diff --git a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java index b9d23727..95969d98 100644 --- a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java +++ b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java @@ -23,7 +23,11 @@ import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestTemplate; import org.springframework.data.redis.core.StringRedisTemplate; - +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import java.util.Collections; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; From cf8241deffdd80b3179e4d77f52eb498e48c48bd Mon Sep 17 00:00:00 2001 From: Tae ho Date: Fri, 16 Jan 2026 13:46:53 +0900 Subject: [PATCH 21/49] google auth update --- .../auth/controller/message/AuthMessage.java | 8 ++--- .../dto/request/PasswordResetRequest.java | 4 --- .../domain/auth/exception/AuthErrorCode.java | 2 ++ .../domain/auth/service/AuthService.java | 2 ++ .../user/dto/request/UserSignupRequest.java | 30 ------------------- .../global/config/jwt/JwtProperties.java | 17 ++++++----- 6 files changed, 17 insertions(+), 46 deletions(-) delete mode 100644 src/main/java/inha/gdgoc/domain/auth/dto/request/PasswordResetRequest.java delete mode 100644 src/main/java/inha/gdgoc/domain/user/dto/request/UserSignupRequest.java diff --git a/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java b/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java index f20924ff..a5f998be 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java @@ -3,10 +3,10 @@ public class AuthMessage { public static final String OAUTH_LOGIN_SIGNUP_SUCCESS = "로그인/회원가입 요청이 성공적으로 실행됐습니다."; public static final String ACCESS_TOKEN_REFRESH_SUCCESS = "액세스 토큰이 성공적으로 재발급되었습니다."; - public static final String LOGIN_WITH_PASSWORD_SUCCESS = "성공적으로 비밀번호를 사용하여 로그인했습니다."; + //public static final String LOGIN_WITH_PASSWORD_SUCCESS = "성공적으로 비밀번호를 사용하여 로그인했습니다."; public static final String LOGOUT_SUCCESS = "성공적으로 로그아웃했습니다."; public static final String SIGNUP_SUCCESS = "성공적으로 회원가입했습니다"; - public static final String CODE_CREATION_SUCCESS = "성공적으로 인증 코드를 발급했습니다."; - public static final String PASSWORD_RESET_VERIFICATION_SUCCESS = "성공적으로 비밀번호 변경을 위한 인증 코드 검증이 완료되었습니다."; - public static final String PASSWORD_CHANGE_SUCCESS = "성공적으로 비밀번호를 변경했습니다."; + //public static final String CODE_CREATION_SUCCESS = "성공적으로 인증 코드를 발급했습니다."; + //public static final String PASSWORD_RESET_VERIFICATION_SUCCESS = "성공적으로 비밀번호 변경을 위한 인증 코드 검증이 완료되었습니다."; + //public static final String PASSWORD_CHANGE_SUCCESS = "성공적으로 비밀번호를 변경했습니다."; } diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/request/PasswordResetRequest.java b/src/main/java/inha/gdgoc/domain/auth/dto/request/PasswordResetRequest.java deleted file mode 100644 index 8efe10f0..00000000 --- a/src/main/java/inha/gdgoc/domain/auth/dto/request/PasswordResetRequest.java +++ /dev/null @@ -1,4 +0,0 @@ -package inha.gdgoc.domain.auth.dto.request; - -public record PasswordResetRequest(String email, String password) { -} diff --git a/src/main/java/inha/gdgoc/domain/auth/exception/AuthErrorCode.java b/src/main/java/inha/gdgoc/domain/auth/exception/AuthErrorCode.java index f054597c..ab8817f0 100644 --- a/src/main/java/inha/gdgoc/domain/auth/exception/AuthErrorCode.java +++ b/src/main/java/inha/gdgoc/domain/auth/exception/AuthErrorCode.java @@ -12,6 +12,8 @@ public enum AuthErrorCode implements ErrorCode { INVALID_COOKIE(HttpStatus.FORBIDDEN, "Refresh Token 이 비어있습니다."), INVALID_REFRESH_TOKEN(HttpStatus.FORBIDDEN, "잘못된 Refresh Token 값입니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), + // 404 Not Found USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다"); diff --git a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java index 95969d98..9df5b98f 100644 --- a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java +++ b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java @@ -28,6 +28,8 @@ import com.google.api.client.http.javanet.NetHttpTransport; import com.google.api.client.json.gson.GsonFactory; import java.util.Collections; +import java.security.GeneralSecurityException; +import java.io.IOException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; diff --git a/src/main/java/inha/gdgoc/domain/user/dto/request/UserSignupRequest.java b/src/main/java/inha/gdgoc/domain/user/dto/request/UserSignupRequest.java deleted file mode 100644 index 13907d09..00000000 --- a/src/main/java/inha/gdgoc/domain/user/dto/request/UserSignupRequest.java +++ /dev/null @@ -1,30 +0,0 @@ -package inha.gdgoc.domain.user.dto.request; - -import inha.gdgoc.domain.user.entity.User; -import inha.gdgoc.domain.user.enums.UserRole; -import lombok.Getter; -import lombok.Setter; - -@Getter -@Setter -public class UserSignupRequest { - private String name; - private String major; - private String studentId; - private String phoneNumber; - private String email; - private String password; - - public User toEntity(String hashedPassword, byte[] salt) { - return User.builder() - .name(name) - .major(major) - .studentId(studentId) - .phoneNumber(phoneNumber) - .email(email) - .password(hashedPassword) - .salt(salt) - .userRole(UserRole.GUEST) - .build(); - } -} diff --git a/src/main/java/inha/gdgoc/global/config/jwt/JwtProperties.java b/src/main/java/inha/gdgoc/global/config/jwt/JwtProperties.java index fec214af..175853ef 100644 --- a/src/main/java/inha/gdgoc/global/config/jwt/JwtProperties.java +++ b/src/main/java/inha/gdgoc/global/config/jwt/JwtProperties.java @@ -1,16 +1,17 @@ package inha.gdgoc.global.config.jwt; -import lombok.Getter; -import lombok.Setter; +import lombok.Getter; +import lombok.Setter; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; -@Setter -@Getter +@Getter +@Setter @Component -@ConfigurationProperties("jwt") +@ConfigurationProperties(prefix = "jwt") public class JwtProperties { - private String selfIssuer; // 자체 로그인 발급자 - private String googleIssuer; // 구글 로그인 발급자 private String secretKey; -} + private long accessTokenValidity; + private String googleIssuer; + private String selfIssuer; +} \ No newline at end of file From e23d7e72cad5a5e13fc9702a07c0f9f6d7060b5b Mon Sep 17 00:00:00 2001 From: Tae ho Date: Fri, 16 Jan 2026 13:55:29 +0900 Subject: [PATCH 22/49] google auth update --- .../domain/user/controller/UserController.java | 17 +++-------------- .../gdgoc/domain/user/service/UserService.java | 16 ++-------------- 2 files changed, 5 insertions(+), 28 deletions(-) diff --git a/src/main/java/inha/gdgoc/domain/user/controller/UserController.java b/src/main/java/inha/gdgoc/domain/user/controller/UserController.java index 56b6003c..d8af2aa7 100644 --- a/src/main/java/inha/gdgoc/domain/user/controller/UserController.java +++ b/src/main/java/inha/gdgoc/domain/user/controller/UserController.java @@ -1,18 +1,14 @@ package inha.gdgoc.domain.user.controller; -import static inha.gdgoc.domain.user.controller.message.UserMessage.USER_CREATE_SUCCESS; import static inha.gdgoc.domain.user.controller.message.UserMessage.USER_EMAIL_DUPLICATION_RETRIEVED_SUCCESS; import static inha.gdgoc.domain.user.controller.message.UserMessage.USER_EMAIL_RETRIEVED_SUCCESS; import inha.gdgoc.domain.auth.dto.request.FindIdRequest; import inha.gdgoc.domain.user.dto.request.CheckDuplicatedEmailRequest; -import inha.gdgoc.domain.user.dto.request.UserSignupRequest; import inha.gdgoc.domain.auth.dto.response.FindIdResponse; import inha.gdgoc.domain.user.dto.response.CheckDuplicatedEmailResponse; import inha.gdgoc.domain.user.service.UserService; import inha.gdgoc.global.dto.response.ApiResponse; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -29,7 +25,7 @@ public class UserController { private final UserService userService; - // TODO 진짜 돌았냐? POST로 바꿔라 + // 이메일 중복 체크 @GetMapping("/auth/check") public ResponseEntity> checkDuplicatedEmail( @RequestParam String email @@ -40,15 +36,8 @@ public ResponseEntity> checkDupl return ResponseEntity.ok(ApiResponse.ok(USER_EMAIL_DUPLICATION_RETRIEVED_SUCCESS, response)); } - @PostMapping("/auth/signup") - public ResponseEntity> userSignup( - @RequestBody UserSignupRequest userSignupRequest - ) throws NoSuchAlgorithmException, InvalidKeyException { - userService.saveUser(userSignupRequest); - - return ResponseEntity.ok(ApiResponse.ok(USER_CREATE_SUCCESS)); - } + // 아이디(이메일) 찾기 @PostMapping("/auth/findId") public ResponseEntity> findEmail( @RequestBody FindIdRequest findIdRequest @@ -57,4 +46,4 @@ public ResponseEntity> findEmail( return ResponseEntity.ok(ApiResponse.ok(USER_EMAIL_RETRIEVED_SUCCESS, response)); } -} +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/user/service/UserService.java b/src/main/java/inha/gdgoc/domain/user/service/UserService.java index b7fcc048..70b363a1 100644 --- a/src/main/java/inha/gdgoc/domain/user/service/UserService.java +++ b/src/main/java/inha/gdgoc/domain/user/service/UserService.java @@ -1,19 +1,14 @@ package inha.gdgoc.domain.user.service; import static inha.gdgoc.domain.user.exception.UserErrorCode.USER_NOT_FOUND; -import static inha.gdgoc.global.util.EncryptUtil.encrypt; -import static inha.gdgoc.global.util.EncryptUtil.generateSalt; import inha.gdgoc.domain.auth.dto.request.FindIdRequest; import inha.gdgoc.domain.auth.dto.response.FindIdResponse; import inha.gdgoc.domain.user.dto.request.CheckDuplicatedEmailRequest; -import inha.gdgoc.domain.user.dto.request.UserSignupRequest; import inha.gdgoc.domain.user.dto.response.CheckDuplicatedEmailResponse; import inha.gdgoc.domain.user.entity.User; import inha.gdgoc.domain.user.exception.UserException; import inha.gdgoc.domain.user.repository.UserRepository; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -36,7 +31,7 @@ public User findUserById(Long userId) { return userRepository.findByUserId(userId) .orElseThrow(() -> new UserException(USER_NOT_FOUND)); } - + public FindIdResponse findId(FindIdRequest findIdRequest) { Optional user = userRepository.findByNameAndMajorAndPhoneNumber( findIdRequest.getName(), @@ -54,13 +49,6 @@ public FindIdResponse findId(FindIdRequest findIdRequest) { return new FindIdResponse(maskedEmail); } - public void saveUser(UserSignupRequest userSignupRequest) throws NoSuchAlgorithmException, InvalidKeyException { - byte[] salt = generateSalt(); - String hashedPassword = encrypt(userSignupRequest.getPassword(), salt); - - User user = userSignupRequest.toEntity(hashedPassword, salt); - userRepository.save(user); - } private String maskEmail(String email) { int atIndex = email.indexOf("@"); @@ -80,4 +68,4 @@ private String maskEmail(String email) { + localPart.substring(localPart.length() - endLen) + domainPart; } -} +} \ No newline at end of file From 398bddcdf269407919afa77aee25a67bd59d0f27 Mon Sep 17 00:00:00 2001 From: Tae ho Date: Fri, 16 Jan 2026 14:00:50 +0900 Subject: [PATCH 23/49] google auth update --- .../auth/controller/message/AuthMessage.java | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java b/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java index a5f998be..c2bf4238 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java @@ -1,12 +1,13 @@ package inha.gdgoc.domain.auth.controller.message; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) public class AuthMessage { - public static final String OAUTH_LOGIN_SIGNUP_SUCCESS = "로그인/회원가입 요청이 성공적으로 실행됐습니다."; - public static final String ACCESS_TOKEN_REFRESH_SUCCESS = "액세스 토큰이 성공적으로 재발급되었습니다."; - //public static final String LOGIN_WITH_PASSWORD_SUCCESS = "성공적으로 비밀번호를 사용하여 로그인했습니다."; - public static final String LOGOUT_SUCCESS = "성공적으로 로그아웃했습니다."; - public static final String SIGNUP_SUCCESS = "성공적으로 회원가입했습니다"; - //public static final String CODE_CREATION_SUCCESS = "성공적으로 인증 코드를 발급했습니다."; - //public static final String PASSWORD_RESET_VERIFICATION_SUCCESS = "성공적으로 비밀번호 변경을 위한 인증 코드 검증이 완료되었습니다."; - //public static final String PASSWORD_CHANGE_SUCCESS = "성공적으로 비밀번호를 변경했습니다."; -} + public static final String LOGIN_SUCCESS = "로그인에 성공하였습니다."; + public static final String SIGNUP_SUCCESS = "회원가입에 성공하였습니다."; + public static final String ACCESS_TOKEN_REFRESH_SUCCESS = "토큰 재발급에 성공하였습니다."; + public static final String LOGOUT_SUCCESS = "로그아웃에 성공하였습니다."; + public static final String OAUTH_LOGIN_SIGNUP_SUCCESS = "OAuth 로그인/회원가입에 성공하였습니다."; +} \ No newline at end of file From 248dcb92b3d2201f002fcad07419181e3f94ea33 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Tue, 10 Feb 2026 05:50:03 +0900 Subject: [PATCH 24/49] refactor(server): update auth, user, and recruit member domains with new requirements --- .github/workflows/ci.yml | 9 +- .github/workflows/deploy-dev.yml | 6 +- .github/workflows/deploy-prod.yml | 6 +- build.gradle | 3 +- .../user/controller/UserAdminController.java | 29 +- .../user/dto/request/UpdateRoleRequest.java | 4 +- .../request/UpdateUserRoleTeamRequest.java | 9 + .../dto/response/UserSummaryResponse.java | 4 +- .../user/service/UserAdminService.java | 52 ++- .../auth/controller/AuthController.java | 154 ++++++- .../auth/controller/message/AuthMessage.java | 4 +- .../gdgoc/domain/auth/dto/GoogleUserInfo.java | 6 +- .../dto/response/AccessTokenResponse.java | 4 +- ...serResponse.java => AuthUserResponse.java} | 25 +- .../response/CheckPhoneNumberResponse.java | 4 + .../dto/response/CheckStudentIdResponse.java | 4 + .../dto/response/LoginSuccessResponse.java | 13 +- .../dto/response/SignupNeededResponse.java | 4 +- .../domain/auth/service/AuthService.java | 392 +++++++++++------- .../controller/RecruitMemberController.java | 39 +- .../message/RecruitMemberMessage.java | 1 + .../dto/response/CheckEmailResponse.java | 4 + .../repository/RecruitMemberRepository.java | 1 + .../member/service/RecruitMemberService.java | 7 + .../request/UpdateUserRoleTeamRequest.java | 9 - .../inha/gdgoc/domain/user/entity/User.java | 12 +- .../user/repository/UserRepository.java | 7 +- .../global/config/jwt/JwtProperties.java | 3 +- .../global/config/jwt/TokenProvider.java | 165 +++++--- .../gdgoc/global/security/SecurityConfig.java | 8 +- .../security/TokenAuthenticationFilter.java | 28 +- src/main/resources/application-dev.yml | 15 +- src/main/resources/application-local.yml | 15 +- src/main/resources/application-prod.yml | 15 +- ...260117__add_membership_status_to_users.sql | 13 + ...V20260118__ensure_oauth_subject_column.sql | 59 +++ 36 files changed, 826 insertions(+), 307 deletions(-) rename src/main/java/inha/gdgoc/domain/{ => admin}/user/controller/UserAdminController.java (79%) rename src/main/java/inha/gdgoc/domain/{ => admin}/user/dto/request/UpdateRoleRequest.java (74%) create mode 100644 src/main/java/inha/gdgoc/domain/admin/user/dto/request/UpdateUserRoleTeamRequest.java rename src/main/java/inha/gdgoc/domain/{ => admin}/user/dto/response/UserSummaryResponse.java (83%) rename src/main/java/inha/gdgoc/domain/{ => admin}/user/service/UserAdminService.java (91%) rename src/main/java/inha/gdgoc/domain/auth/dto/response/{UserResponse.java => AuthUserResponse.java} (53%) create mode 100644 src/main/java/inha/gdgoc/domain/auth/dto/response/CheckPhoneNumberResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/auth/dto/response/CheckStudentIdResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckEmailResponse.java delete mode 100644 src/main/java/inha/gdgoc/domain/user/dto/request/UpdateUserRoleTeamRequest.java create mode 100644 src/main/resources/db/migration/V20260117__add_membership_status_to_users.sql create mode 100644 src/main/resources/db/migration/V20260118__ensure_oauth_subject_column.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66d4a3d8..a63a5a0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,14 @@ jobs: gradle-home-cache-cleanup: true - name: Create dummy .env for CI - run: echo "# ci dummy" > .env + env: + AUDIENCE_SECRET: ${{ secrets.JWT_AUDIENCE }} + run: | + AUDIENCE_VALUE="${AUDIENCE_SECRET:-ci-audience}" + cat > .env < listUsers(String q, Pageable pageable) { Pageable fixed = rewriteSort(pageable); @@ -36,7 +34,9 @@ public Page listUsers(String q, Pageable pageable) { private Pageable rewriteSort(Pageable pageable) { Sort original = pageable.getSort(); - if (original.isUnsorted()) return pageable; + if (original.isUnsorted()) { + return pageable; + } Sort composed = Sort.unsorted(); boolean hasUserRoleOrder = false; @@ -50,6 +50,7 @@ private Pageable rewriteSort(Pageable pageable) { " WHEN u.userRole = 'ORGANIZER' THEN 4 " + " WHEN u.userRole = 'ADMIN' THEN 5 " + " ELSE -1 END)"; + for (Sort.Order o : original) { String prop = o.getProperty(); Sort.Direction dir = o.getDirection(); @@ -73,11 +74,10 @@ private Pageable rewriteSort(Pageable pageable) { composed = composed.and(JpaSort.unsafe(Sort.Direction.DESC, roleRankCase)); composed = composed.and(Sort.by("name").ascending()); } + return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), composed); } - /* ======================= 수정 ======================= */ - @Transactional public void updateRoleAndTeam(CustomUserDetails editor, Long targetUserId, UpdateUserRoleTeamRequest req) { User editorUser = getEditor(editor); @@ -90,10 +90,8 @@ public void updateRoleAndTeam(CustomUserDetails editor, Long targetUserId, Updat UserRole newRole = (req.role() != null ? req.role() : targetCurrentRole); TeamType requestedTeam = (req.team() != null ? req.team() : target.getTeam()); - // 팀 보유 가능한 역할만 팀 허용 (CORE, LEAD) TeamType newTeam = isTeamAssignableRole(newRole) ? requestedTeam : null; - // 공통: 에디터는 대상의 현재/신규 role보다 엄격히 높아야 함 if (!(editorRole.rank() > targetCurrentRole.rank())) { throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "동급/상위 사용자의 정보는 변경할 수 없습니다."); } @@ -124,7 +122,6 @@ public void updateRoleAndTeam(CustomUserDetails editor, Long targetUserId, Updat } if (editor.getTeam() == TeamType.HR) { - // HR-LEAD: 본인 제외 타인지원 팀 변경 가능 if (editorUser.getId().equals(target.getId())) { if (req.team() != null && !Objects.equals(req.team(), target.getTeam())) { throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "HR-LEAD도 자기 자신의 팀은 변경할 수 없습니다."); @@ -159,12 +156,13 @@ public void updateUserRoleWithRules(CustomUserDetails me, Long targetUserId, Use UserRole current = target.getUserRole(); - // HR-CORE 특례: GUEST -> MEMBER boolean isHrCore = (meRole == UserRole.CORE) && (meTeam == TeamType.HR); if (isHrCore) { if (current == UserRole.GUEST && newRole == UserRole.MEMBER) { target.changeRole(UserRole.MEMBER); - if (!isTeamAssignableRole(UserRole.MEMBER)) target.changeTeam(null); + if (!isTeamAssignableRole(UserRole.MEMBER)) { + target.changeTeam(null); + } userRepository.save(target); return; } @@ -178,7 +176,9 @@ public void updateUserRoleWithRules(CustomUserDetails me, Long targetUserId, Use } target.changeRole(newRole); - if (!isTeamAssignableRole(newRole)) target.changeTeam(null); + if (!isTeamAssignableRole(newRole)) { + target.changeTeam(null); + } userRepository.save(target); } @@ -203,18 +203,22 @@ public void deleteUserWithRules(CustomUserDetails me, Long targetUserId) { } switch (editorRole) { - case ADMIN -> {} + case ADMIN -> { + } case ORGANIZER -> { if (targetRole == UserRole.ADMIN) { throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "ADMIN 사용자는 삭제할 수 없습니다."); } } case LEAD -> { + if (editorTeam == null) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD 토큰에 팀 정보가 없습니다."); + } if (!(targetRole == UserRole.MEMBER || targetRole == UserRole.CORE)) { throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 MEMBER/CORE만 삭제할 수 있습니다."); } if (editorTeam != TeamType.HR) { - if (editorTeam == null || targetTeam != editorTeam) { + if (targetTeam != editorTeam) { throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "다른 팀 사용자는 삭제할 수 없습니다."); } } @@ -225,19 +229,21 @@ public void deleteUserWithRules(CustomUserDetails me, Long targetUserId) { userRepository.delete(target); } + private User getEditor(CustomUserDetails editor) { + return userRepository.findById(editor.getUserId()) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.UNAUTHORIZED_USER)); + } + private void targetChange(User target, UserRole newRole, TeamType newTeam) { target.changeRole(newRole); - if (!isTeamAssignableRole(newRole)) newTeam = null; + if (!isTeamAssignableRole(newRole)) { + newTeam = null; + } target.changeTeam(newTeam); userRepository.save(target); } - private User getEditor(CustomUserDetails editor) { - return userRepository.findById(editor.getUserId()) - .orElseThrow(() -> new BusinessException(GlobalErrorCode.UNAUTHORIZED_USER)); - } - private boolean isTeamAssignableRole(UserRole role) { return role == UserRole.CORE || role == UserRole.LEAD; } -} \ No newline at end of file +} diff --git a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java index 7b4d8c14..ff0a3580 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java @@ -3,22 +3,35 @@ import inha.gdgoc.domain.auth.dto.request.LoginRequest; import inha.gdgoc.domain.auth.dto.request.SignupRequest; import inha.gdgoc.domain.auth.dto.response.AccessTokenResponse; +import inha.gdgoc.domain.auth.dto.response.AuthUserResponse; +import inha.gdgoc.domain.auth.dto.response.CheckPhoneNumberResponse; +import inha.gdgoc.domain.auth.dto.response.CheckStudentIdResponse; +import inha.gdgoc.domain.auth.dto.response.LoginSuccessResponse; import inha.gdgoc.domain.auth.exception.AuthErrorCode; import inha.gdgoc.domain.auth.exception.AuthException; import inha.gdgoc.domain.auth.service.AuthService; import inha.gdgoc.domain.user.enums.TeamType; import inha.gdgoc.domain.user.enums.UserRole; +import inha.gdgoc.global.config.jwt.JwtProperties; import inha.gdgoc.global.config.jwt.TokenProvider; import inha.gdgoc.global.dto.response.ApiResponse; import inha.gdgoc.global.exception.GlobalErrorCode; import inha.gdgoc.global.security.AccessGuard; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.util.StringUtils; + +import java.time.Duration; import static inha.gdgoc.domain.auth.controller.message.AuthMessage.*; @@ -30,13 +43,44 @@ public class AuthController { private final AuthService authService; private final AccessGuard accessGuard; + private final JwtProperties jwtProperties; + @Value("${app.auth.refresh-cookie.domain:}") + private String refreshCookieDomain; + + @Value("${app.auth.refresh-cookie.path:/}") + private String refreshCookiePath; + + @Value("${app.auth.refresh-cookie.same-site:Lax}") + private String refreshCookieSameSite; + + @Value("${app.auth.refresh-cookie.secure:false}") + private boolean refreshCookieSecure; + + @Value("${app.auth.access-cookie.domain:}") + private String accessCookieDomain; + + @Value("${app.auth.access-cookie.path:/}") + private String accessCookiePath; + + @Value("${app.auth.access-cookie.same-site:Lax}") + private String accessCookieSameSite; + + @Value("${app.auth.access-cookie.secure:false}") + private boolean accessCookieSecure; // 1. 구글 로그인 (ID Token 검증) @PostMapping("/login") public ResponseEntity login(@RequestBody LoginRequest request) { try { Object response = authService.login(request.getIdToken()); - return ResponseEntity.ok(ApiResponse.ok(LOGIN_SUCCESS, response)); + ResponseEntity.BodyBuilder builder = ResponseEntity.ok(); + if (response instanceof LoginSuccessResponse successResponse) { + ResponseCookie cookie = buildRefreshTokenCookie(successResponse.getRefreshToken()); + builder.header(HttpHeaders.SET_COOKIE, cookie.toString()); + ResponseCookie accessCookie = buildAccessTokenCookie(successResponse.getAccessToken()); + builder.header(HttpHeaders.SET_COOKIE, accessCookie.toString()); + } + return builder.body(ApiResponse.ok(LOGIN_SUCCESS, response)); } catch (IllegalArgumentException e) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(ApiResponse.error(AuthErrorCode.INVALID_TOKEN.getStatus().value(), e.getMessage(), null)); @@ -48,14 +92,42 @@ public ResponseEntity login(@RequestBody LoginRequest request) { public ResponseEntity signup(@Valid @RequestBody SignupRequest request) { try { Object response = authService.signup(request); - return ResponseEntity.status(HttpStatus.CREATED) - .body(ApiResponse.ok(SIGNUP_SUCCESS, response)); + ResponseEntity.BodyBuilder builder = ResponseEntity.status(HttpStatus.CREATED); + if (response instanceof LoginSuccessResponse successResponse) { + ResponseCookie cookie = buildRefreshTokenCookie(successResponse.getRefreshToken()); + builder.header(HttpHeaders.SET_COOKIE, cookie.toString()); + ResponseCookie accessCookie = buildAccessTokenCookie(successResponse.getAccessToken()); + builder.header(HttpHeaders.SET_COOKIE, accessCookie.toString()); + } + return builder.body(ApiResponse.ok(SIGNUP_SUCCESS, response)); } catch (IllegalArgumentException e) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), e.getMessage(), null)); } } + @GetMapping("/check/student-id") + public ResponseEntity> duplicatedStudentIdDetails( + @RequestParam + @NotBlank(message = "학번은 필수 입력 값입니다.") + @Pattern(regexp = "^12[0-9]{6}$", message = "유효하지 않은 학번 값입니다.") + String studentId + ) { + CheckStudentIdResponse response = authService.isRegisteredStudentId(studentId); + return ResponseEntity.ok(ApiResponse.ok(STUDENT_ID_DUPLICATION_CHECK_SUCCESS, response)); + } + + @GetMapping("/check/phone-number") + public ResponseEntity> duplicatedPhoneNumberDetails( + @RequestParam + @NotBlank(message = "전화번호는 필수 입력 값입니다.") + @Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "전화번호 형식은 010-XXXX-XXXX 이어야 합니다.") + String phoneNumber + ) { + CheckPhoneNumberResponse response = authService.isRegisteredPhoneNumber(phoneNumber); + return ResponseEntity.ok(ApiResponse.ok(PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS, response)); + } + // 3. 토큰 재발급 (Refresh) @PostMapping("/refresh") public ResponseEntity refreshAccessToken(@CookieValue(value = "refresh_token", required = false) String refreshToken) { @@ -64,8 +136,14 @@ public ResponseEntity refreshAccessToken(@CookieValue(value = "refresh_token" } try { - String newAccessToken = authService.refresh(refreshToken); - return ResponseEntity.ok(ApiResponse.ok(ACCESS_TOKEN_REFRESH_SUCCESS, new AccessTokenResponse(newAccessToken))); + AuthService.RefreshResult result = authService.refresh(refreshToken); + ResponseCookie accessCookie = buildAccessTokenCookie(result.accessToken()); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, accessCookie.toString()) + .body(ApiResponse.ok( + ACCESS_TOKEN_REFRESH_SUCCESS, + new AccessTokenResponse(result.accessToken(), AuthUserResponse.from(result.user())) + )); } catch (Exception e) { log.error("Token refresh failed", e); throw new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); @@ -75,10 +153,13 @@ public ResponseEntity refreshAccessToken(@CookieValue(value = "refresh_token" // 4. 로그아웃 @PostMapping("/logout") public ResponseEntity logout(@CookieValue(value = "refresh_token", required = false) String refreshToken) { + ResponseEntity.BodyBuilder builder = ResponseEntity.ok(); if (refreshToken != null) { authService.logout(refreshToken); } - return ResponseEntity.ok(ApiResponse.ok(LOGOUT_SUCCESS)); + builder.header(HttpHeaders.SET_COOKIE, deleteRefreshTokenCookie().toString()); + builder.header(HttpHeaders.SET_COOKIE, deleteAccessTokenCookie().toString()); + return builder.body(ApiResponse.ok(LOGOUT_SUCCESS)); } // 5. 권한 체크 (Role or Team) @@ -115,5 +196,64 @@ public ResponseEntity logout(@CookieValue(value = "refresh_token", required = GlobalErrorCode.FORBIDDEN_USER.getMessage(), null )); +} + + private ResponseCookie buildRefreshTokenCookie(String refreshToken) { + if (!StringUtils.hasText(refreshToken)) { + return deleteRefreshTokenCookie(); + } + return baseCookieBuilder(refreshToken) + .maxAge(AuthService.REFRESH_TOKEN_TTL) + .build(); + } + + private ResponseCookie deleteRefreshTokenCookie() { + return baseCookieBuilder("") + .maxAge(Duration.ZERO) + .build(); + } + + private ResponseCookie.ResponseCookieBuilder baseCookieBuilder(String value) { + ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from("refresh_token", value) + .httpOnly(true) + .secure(refreshCookieSecure) + .sameSite(refreshCookieSameSite) + .path(refreshCookiePath); + + if (StringUtils.hasText(refreshCookieDomain)) { + builder.domain(refreshCookieDomain); + } + return builder; + } + + private ResponseCookie buildAccessTokenCookie(String accessToken) { + if (!StringUtils.hasText(accessToken)) { + return deleteAccessTokenCookie(); + } + ResponseCookie.ResponseCookieBuilder builder = baseAccessCookieBuilder(accessToken); + long accessTokenValidity = jwtProperties.getAccessTokenValidity(); + if (accessTokenValidity > 0) { + builder.maxAge(Duration.ofMillis(accessTokenValidity)); + } + return builder.build(); + } + + private ResponseCookie deleteAccessTokenCookie() { + return baseAccessCookieBuilder("") + .maxAge(Duration.ZERO) + .build(); + } + + private ResponseCookie.ResponseCookieBuilder baseAccessCookieBuilder(String value) { + ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from("access_token", value) + .httpOnly(true) + .secure(accessCookieSecure) + .sameSite(accessCookieSameSite) + .path(accessCookiePath); + + if (StringUtils.hasText(accessCookieDomain)) { + builder.domain(accessCookieDomain); + } + return builder; } -} \ No newline at end of file +} diff --git a/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java b/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java index c2bf4238..6a6ef8f3 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java @@ -10,4 +10,6 @@ public class AuthMessage { public static final String ACCESS_TOKEN_REFRESH_SUCCESS = "토큰 재발급에 성공하였습니다."; public static final String LOGOUT_SUCCESS = "로그아웃에 성공하였습니다."; public static final String OAUTH_LOGIN_SIGNUP_SUCCESS = "OAuth 로그인/회원가입에 성공하였습니다."; -} \ No newline at end of file + public static final String STUDENT_ID_DUPLICATION_CHECK_SUCCESS = "학번 중복 확인에 성공하였습니다."; + public static final String PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS = "전화번호 중복 확인에 성공하였습니다."; +} diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/GoogleUserInfo.java b/src/main/java/inha/gdgoc/domain/auth/dto/GoogleUserInfo.java index 519c70aa..9f01a0d3 100644 --- a/src/main/java/inha/gdgoc/domain/auth/dto/GoogleUserInfo.java +++ b/src/main/java/inha/gdgoc/domain/auth/dto/GoogleUserInfo.java @@ -1,12 +1,16 @@ package inha.gdgoc.domain.auth.dto; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; @Data +@Builder @AllArgsConstructor public class GoogleUserInfo { private String sub; private String email; private String name; -} \ No newline at end of file + private String givenName; + private String familyName; +} diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/response/AccessTokenResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/AccessTokenResponse.java index f8cc949d..37b4c3c2 100644 --- a/src/main/java/inha/gdgoc/domain/auth/dto/response/AccessTokenResponse.java +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/AccessTokenResponse.java @@ -6,8 +6,10 @@ @Getter public class AccessTokenResponse extends BaseEntity { private final String access_token; + private final AuthUserResponse user; - public AccessTokenResponse(String accessToken) { + public AccessTokenResponse(String accessToken, AuthUserResponse user) { this.access_token = accessToken; + this.user = user; } } diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/response/UserResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/AuthUserResponse.java similarity index 53% rename from src/main/java/inha/gdgoc/domain/auth/dto/response/UserResponse.java rename to src/main/java/inha/gdgoc/domain/auth/dto/response/AuthUserResponse.java index 133ee35a..0349293b 100644 --- a/src/main/java/inha/gdgoc/domain/auth/dto/response/UserResponse.java +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/AuthUserResponse.java @@ -1,27 +1,34 @@ package inha.gdgoc.domain.auth.dto.response; import inha.gdgoc.domain.user.entity.User; -import inha.gdgoc.domain.user.enums.UserRole; import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; import lombok.Builder; import lombok.Data; @Data @Builder -public class UserResponse { +public class AuthUserResponse { private Long id; - private String email; private String name; - private UserRole role; + private String email; + private UserRole userRole; private TeamType team; + private User.MembershipStatus membershipStatus; + private String image; - public static UserResponse from(User user) { - return UserResponse.builder() + public static AuthUserResponse from(User user) { + if (user == null) { + return null; + } + return AuthUserResponse.builder() .id(user.getId()) - .email(user.getEmail()) .name(user.getName()) - .role(user.getUserRole()) + .email(user.getEmail()) + .userRole(user.getUserRole()) .team(user.getTeam()) + .membershipStatus(user.getMembershipStatus()) + .image(user.getImage()) .build(); } -} \ No newline at end of file +} diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/response/CheckPhoneNumberResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/CheckPhoneNumberResponse.java new file mode 100644 index 00000000..9d1612d8 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/CheckPhoneNumberResponse.java @@ -0,0 +1,4 @@ +package inha.gdgoc.domain.auth.dto.response; + +public record CheckPhoneNumberResponse(boolean isExists) { +} diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/response/CheckStudentIdResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/CheckStudentIdResponse.java new file mode 100644 index 00000000..6c88a262 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/CheckStudentIdResponse.java @@ -0,0 +1,4 @@ +package inha.gdgoc.domain.auth.dto.response; + +public record CheckStudentIdResponse(boolean isExists) { +} diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/response/LoginSuccessResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/LoginSuccessResponse.java index 34242bc6..e98de39a 100644 --- a/src/main/java/inha/gdgoc/domain/auth/dto/response/LoginSuccessResponse.java +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/LoginSuccessResponse.java @@ -1,23 +1,26 @@ package inha.gdgoc.domain.auth.dto.response; -import inha.gdgoc.domain.user.entity.User; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Builder; import lombok.Data; @Data @Builder public class LoginSuccessResponse { + @JsonProperty("isNewUser") private boolean isNewUser; private String accessToken; + private AuthUserResponse user; + @JsonIgnore private String refreshToken; - private UserResponse user; - public static LoginSuccessResponse of(User user, TokenDto tokens) { + public static LoginSuccessResponse of(TokenDto tokens, AuthUserResponse user) { return LoginSuccessResponse.builder() .isNewUser(false) .accessToken(tokens.getAccessToken()) .refreshToken(tokens.getRefreshToken()) - .user(UserResponse.from(user)) + .user(user) .build(); } -} \ No newline at end of file +} diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/response/SignupNeededResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/SignupNeededResponse.java index 2d279c5d..a9c8b545 100644 --- a/src/main/java/inha/gdgoc/domain/auth/dto/response/SignupNeededResponse.java +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/SignupNeededResponse.java @@ -1,13 +1,15 @@ package inha.gdgoc.domain.auth.dto.response; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Builder; import lombok.Data; @Data @Builder public class SignupNeededResponse { + @JsonProperty("isNewUser") private boolean isNewUser; private String oauthSubject; private String email; private String name; -} \ No newline at end of file +} diff --git a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java index 9df5b98f..c39d24a2 100644 --- a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java +++ b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java @@ -1,192 +1,288 @@ package inha.gdgoc.domain.auth.service; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; import inha.gdgoc.domain.auth.dto.GoogleUserInfo; import inha.gdgoc.domain.auth.dto.request.SignupRequest; +import inha.gdgoc.domain.auth.dto.response.AuthUserResponse; +import inha.gdgoc.domain.auth.dto.response.CheckPhoneNumberResponse; +import inha.gdgoc.domain.auth.dto.response.CheckStudentIdResponse; import inha.gdgoc.domain.auth.dto.response.LoginSuccessResponse; import inha.gdgoc.domain.auth.dto.response.SignupNeededResponse; import inha.gdgoc.domain.auth.dto.response.TokenDto; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.data.redis.core.RedisTemplate; -import inha.gdgoc.domain.auth.dto.response.LoginResponse; -import inha.gdgoc.domain.auth.enums.LoginType; import inha.gdgoc.domain.user.entity.User; import inha.gdgoc.domain.user.repository.UserRepository; import inha.gdgoc.global.config.jwt.TokenProvider; -import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.time.Duration; +import java.util.Collections; +import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.http.*; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestTemplate; -import org.springframework.data.redis.core.StringRedisTemplate; -import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; -import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; -import com.google.api.client.http.javanet.NetHttpTransport; -import com.google.api.client.json.gson.GsonFactory; -import java.util.Collections; -import java.security.GeneralSecurityException; -import java.io.IOException; - -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.time.Duration; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.TimeUnit; - -import static inha.gdgoc.global.util.EncryptUtil.encrypt; +import org.springframework.transaction.annotation.Transactional; @Slf4j @Service @RequiredArgsConstructor public class AuthService { - private final UserRepository userRepository; - private final TokenProvider tokenProvider; - private final StringRedisTemplate redisTemplate; - + public static final Duration REFRESH_TOKEN_TTL = Duration.ofDays(14); + private static final String REFRESH_TOKEN_PREFIX = "RT:"; + private static final String SESSION_VALUE_DELIMITER = "::"; + private final UserRepository userRepository; + private final TokenProvider tokenProvider; + private final StringRedisTemplate redisTemplate; - @Value("${app.google.client-id}") - private String googleClientId; + @Value("${google.client-id}") + private String googleClientId; - //로그인 - @Transactional - public Object login(String idToken) { - // Google ID Token 검증 - GoogleUserInfo googleUser = verifyGoogleToken(idToken); + // 로그인 + @Transactional + public Object login(String idToken) { + // Google ID Token 검증 + GoogleUserInfo googleUser = verifyGoogleToken(idToken); - // 도메인 검증 (인하대 메일만 허용) - if (!googleUser.getEmail().endsWith("@inha.edu")) { - throw new IllegalArgumentException("인하대학교(@inha.edu) 계정만 이용 가능합니다."); - } + // 도메인 검증 (인하대 메일만 허용) + if (!googleUser.getEmail().endsWith("@inha.edu")) { + throw new IllegalArgumentException("인하대학교(@inha.edu) 계정만 이용 가능합니다."); + } - // DB에서 유저 조회 (OAuth Subject 기준) - User user = userRepository.findByOauthSubject(googleUser.getSub()).orElse(null); + // DB에서 유저 조회 (OAuth Subject 기준) + User user = userRepository.findByOauthSubject(googleUser.getSub()).orElse(null); - // 신규 유저 -> 회원가입 필요 응답 (202 or 200 with isNewUser=true) + // 신규 유저 -> 회원가입 필요 응답 (202 or 200 with isNewUser=true) if (user == null) { - return SignupNeededResponse.builder() - .isNewUser(true) - .oauthSubject(googleUser.getSub()) - .email(googleUser.getEmail()) - .name(googleUser.getName()) - .build(); - } - - // 기존 유저 -> 토큰 발급 및 로그인 성공 응답 - TokenDto tokens = generateTokens(user); - return LoginSuccessResponse.of(user, tokens); + String preferredName = + hasText(googleUser.getFamilyName()) ? googleUser.getFamilyName() : googleUser.getName(); + return SignupNeededResponse.builder() + .isNewUser(true) + .oauthSubject(googleUser.getSub()) + .email(googleUser.getEmail()) + .name(preferredName) + .build(); } - //회원가입 - @Transactional - public LoginSuccessResponse signup(SignupRequest request) { - // 학번 중복 체크 - if (userRepository.existsByStudentId(request.getStudentId())) { - throw new IllegalArgumentException("이미 존재하는 학번입니다."); - } - - // 전화번호 정규화 (숫자만 남김) - String cleanPhone = request.getPhoneNumber().replaceAll("[^0-9]", ""); - - // 유저 엔티티 생성 및 저장 - User newUser = User.builder() - .oauthSubject(request.getOauthSubject()) // 구글 sub - .email(request.getEmail()) - .name(request.getName()) - .studentId(request.getStudentId()) - .major(request.getMajor()) - .phoneNumber(cleanPhone) - // Role(GUEST), Status(PENDING) 등은 User 엔티티 생성자에서 기본값 처리됨 - .build(); - - userRepository.save(newUser); - - // 토큰 발급 - TokenDto tokens = generateTokens(newUser); - return LoginSuccessResponse.of(newUser, tokens); + // 기존 유저 -> 토큰 발급 및 로그인 성공 응답 + TokenDto tokens = generateTokens(user); + return LoginSuccessResponse.of(tokens, AuthUserResponse.from(user)); + } + + // 회원가입 + @Transactional + public LoginSuccessResponse signup(SignupRequest request) { + // 학번 중복 체크 + if (userRepository.existsByStudentId(request.getStudentId())) { + throw new IllegalArgumentException("이미 존재하는 학번입니다."); } - public String refresh(String refreshToken) { - // Redis에서 Refresh Token 확인 - String redisKey = "RT:" + refreshToken; - String subject = redisTemplate.opsForValue().get(redisKey); - if (subject == null) { - throw new IllegalArgumentException("유효하지 않거나 만료된 리프레시 토큰입니다."); - } + // 전화번호 정규화 (숫자만 남김) + String cleanPhone = request.getPhoneNumber().replaceAll("[^0-9]", ""); + + // 유저 엔티티 생성 및 저장 + User newUser = + User.builder() + .oauthSubject(request.getOauthSubject()) // 구글 sub + .email(request.getEmail()) + .name(request.getName()) + .studentId(request.getStudentId()) + .major(request.getMajor()) + .phoneNumber(cleanPhone) + // Role(GUEST), Status(PENDING) 등은 User 엔티티 생성자에서 기본값 처리됨 + .build(); + + userRepository.save(newUser); + + // 토큰 발급 + TokenDto tokens = generateTokens(newUser); + return LoginSuccessResponse.of(tokens, AuthUserResponse.from(newUser)); + } - // DB에서 유저 조회 (권한 변경 등이 있었을 수 있으므로 다시 조회) - User user = userRepository.findByOauthSubject(subject) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + @Transactional(readOnly = true) + public CheckStudentIdResponse isRegisteredStudentId(String studentId) { + boolean exists = userRepository.existsByStudentId(studentId); + return new CheckStudentIdResponse(exists); + } - // Access Token만 새로 발급 (Refresh Token은 그대로 유지하거나, 정책에 따라 재발급 가능) - return tokenProvider.createAccessToken(user); + @Transactional(readOnly = true) + public CheckPhoneNumberResponse isRegisteredPhoneNumber(String phoneNumber) { + String cleanPhone = phoneNumber.replaceAll("[^0-9]", ""); + boolean exists = userRepository.existsByPhoneNumber(cleanPhone); + return new CheckPhoneNumberResponse(exists); + } + + public RefreshResult refresh(String refreshToken) { + RefreshSession session = resolveRefreshSession(refreshToken); + + User user = + userRepository + .findById(session.userId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + + // Access Token만 새로 발급 (Refresh Token은 그대로 유지하거나, 정책에 따라 재발급 가능) + String accessToken = tokenProvider.createAccessToken(user, session.sessionId()); + return new RefreshResult(accessToken, user); + } + + // 로그아웃 + public void logout(String refreshToken) { + // Redis에서 Refresh Token 삭제 + String redisKey = refreshTokenKey(refreshToken); + redisTemplate.delete(redisKey); + } + + public Long getAuthenticationUserId(Authentication authentication) { + Object principal = authentication.getPrincipal(); + if (principal instanceof TokenProvider.CustomUserDetails user) { + return user.getUserId(); } - //로그아웃 - public void logout(String refreshToken) { - // Redis에서 Refresh Token 삭제 - String redisKey = "RT:" + refreshToken; - redisTemplate.delete(redisKey); + throw new IllegalArgumentException("User ID not found in authentication"); + } + + // 토큰 발급 및 Redis 저장 + + private TokenDto generateTokens(User user) { + String sessionId = UUID.randomUUID().toString(); + // Access Token 생성 (JWT) + String accessToken = tokenProvider.createAccessToken(user, sessionId); + + // Refresh Token 생성 (Random UUID) + String refreshToken = tokenProvider.createRefreshToken(); + + storeRefreshSession(refreshToken, new RefreshSession(sessionId, user.getId())); + + return new TokenDto(accessToken, refreshToken); + } + + // Google ID Token 검증 + private GoogleUserInfo verifyGoogleToken(String idTokenString) { + try { + GoogleIdTokenVerifier verifier = + new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new GsonFactory()) + .setAudience(Collections.singletonList(googleClientId)) + .build(); + + GoogleIdToken idToken = verifier.verify(idTokenString); + + if (idToken != null) { + GoogleIdToken.Payload payload = idToken.getPayload(); + return buildGoogleUserInfo(payload); + } else { + throw new IllegalArgumentException("유효하지 않은 토큰입니다."); + } + } catch (GeneralSecurityException | IOException e) { + log.error("Google Token Verification Failed", e); + throw new IllegalArgumentException("토큰 검증 실패", e); } + } + + private void storeRefreshSession(String refreshToken, RefreshSession session) { + redisTemplate + .opsForValue() + .set(refreshTokenKey(refreshToken), encodeSessionValue(session), REFRESH_TOKEN_TTL); + } + + private RefreshSession resolveRefreshSession(String refreshToken) { + String redisKey = refreshTokenKey(refreshToken); + String storedValue = redisTemplate.opsForValue().get(redisKey); - public Long getAuthenticationUserId(Authentication authentication) { - Object principal = authentication.getPrincipal(); - if (principal instanceof TokenProvider.CustomUserDetails user) { - return user.getUserId(); - } - throw new IllegalArgumentException("User ID not found in authentication"); + if (storedValue == null) { + throw new IllegalArgumentException("유효하지 않거나 만료된 리프레시 토큰입니다."); } - - //토큰 발급 및 Redis 저장 - - private TokenDto generateTokens(User user) { - // Access Token 생성 (JWT) - String accessToken = tokenProvider.createAccessToken(user); - - // Refresh Token 생성 (Random UUID) - String refreshToken = tokenProvider.createRefreshToken(); - - // Redis 저장 (Key: "RT:{refreshToken}", Value: oauthSubject, 유효기간: 14일) - redisTemplate.opsForValue().set( - "RT:" + refreshToken, - user.getOauthSubject(), - 14, - TimeUnit.DAYS - ); - - return new TokenDto(accessToken, refreshToken); + if (storedValue.contains(SESSION_VALUE_DELIMITER)) { + return decodeSessionValue(storedValue); } - - // Google ID Token 검증 - private GoogleUserInfo verifyGoogleToken(String idTokenString) { - try { - GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new GsonFactory()) - .setAudience(Collections.singletonList(googleClientId)) - .build(); - - GoogleIdToken idToken = verifier.verify(idTokenString); - - if (idToken != null) { - GoogleIdToken.Payload payload = idToken.getPayload(); - return new GoogleUserInfo( - payload.getSubject(), // sub - payload.getEmail(), // email - (String) payload.get("name") // name - ); - } else { - throw new IllegalArgumentException("유효하지 않은 토큰입니다."); - } - } catch (GeneralSecurityException | IOException e) { - log.error("Google Token Verification Failed", (Throwable) e); - throw new IllegalArgumentException("토큰 검증 실패", e); - } + // 레거시 포맷(oauthSubject만 저장) 호환 처리 + User user = + userRepository + .findByOauthSubject(storedValue) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + + RefreshSession upgraded = new RefreshSession(UUID.randomUUID().toString(), user.getId()); + storeRefreshSession(refreshToken, upgraded); + return upgraded; + } + + private RefreshSession decodeSessionValue(String storedValue) { + String[] parts = storedValue.split(SESSION_VALUE_DELIMITER, 2); + if (parts.length != 2) { + throw new IllegalArgumentException("잘못된 세션 정보입니다."); } -} + try { + Long userId = Long.parseLong(parts[1]); + return new RefreshSession(parts[0], userId); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("잘못된 세션 정보입니다.", e); + } + } + + private String encodeSessionValue(RefreshSession session) { + return session.sessionId() + SESSION_VALUE_DELIMITER + session.userId(); + } + + private String refreshTokenKey(String refreshToken) { + return REFRESH_TOKEN_PREFIX + refreshToken; + } + + private record RefreshSession(String sessionId, Long userId) {} + public record RefreshResult(String accessToken, User user) {} + + private GoogleUserInfo buildGoogleUserInfo(GoogleIdToken.Payload payload) { + String fullName = (String) payload.get("name"); + String givenName = (String) payload.get("given_name"); + String familyName = (String) payload.get("family_name"); + + NameParts parts = deriveNameParts(fullName); + + String resolvedGiven = hasText(givenName) ? givenName : parts.givenName(); + String resolvedFamily = hasText(familyName) ? familyName : parts.familyName(); + + return GoogleUserInfo.builder() + .sub(payload.getSubject()) + .email(payload.getEmail()) + .name(fullName) + .givenName(resolvedGiven) + .familyName(resolvedFamily) + .build(); + } + + private NameParts deriveNameParts(String rawName) { + if (!hasText(rawName)) { + return new NameParts("", ""); + } + String trimmed = rawName.trim(); + + if (trimmed.contains(" ")) { + String[] tokens = trimmed.split("\\s+"); + if (tokens.length == 1) { + return new NameParts("", tokens[0]); + } + String given = tokens[tokens.length - 1]; + String family = String.join(" ", java.util.Arrays.copyOf(tokens, tokens.length - 1)).trim(); + return new NameParts(family, given); + } + + if (trimmed.length() >= 2) { + return new NameParts(trimmed.substring(0, 1), trimmed.substring(1)); + } + + return new NameParts(trimmed, ""); + } + + private boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + + private record NameParts(String familyName, String givenName) {} +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java b/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java index 7b368c45..1699b1bb 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java @@ -1,5 +1,6 @@ package inha.gdgoc.domain.recruit.member.controller; +import static inha.gdgoc.domain.recruit.member.controller.message.RecruitMemberMessage.EMAIL_DUPLICATION_CHECK_SUCCESS; import static inha.gdgoc.domain.recruit.member.controller.message.RecruitMemberMessage.MEMBER_LIST_RETRIEVED_SUCCESS; import static inha.gdgoc.domain.recruit.member.controller.message.RecruitMemberMessage.MEMBER_RETRIEVED_SUCCESS; import static inha.gdgoc.domain.recruit.member.controller.message.RecruitMemberMessage.MEMBER_SAVE_SUCCESS; @@ -10,6 +11,7 @@ import inha.gdgoc.domain.recruit.member.dto.request.ApplicationRequest; import inha.gdgoc.domain.recruit.member.dto.request.PaymentUpdateRequest; +import inha.gdgoc.domain.recruit.member.dto.response.CheckEmailResponse; import inha.gdgoc.domain.recruit.member.dto.response.CheckPhoneNumberResponse; import inha.gdgoc.domain.recruit.member.dto.response.CheckStudentIdResponse; import inha.gdgoc.domain.recruit.member.dto.response.RecruitMemberSummaryResponse; @@ -31,6 +33,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Direction; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; @@ -40,10 +43,12 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; @Tag(name = "Recruit - Members", description = "리크루팅 지원자 관리 API") -@RequestMapping("/api/v1") +@RequestMapping("/api/v1/recruit/member") @RequiredArgsConstructor @RestController public class RecruitMemberController { @@ -52,13 +57,13 @@ public class RecruitMemberController { "@accessGuard.check(authentication," + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD)," - + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).of(" + "T(inha.gdgoc.domain.user.enums.UserRole).CORE," + " T(inha.gdgoc.domain.user.enums.TeamType).HR))"; private final RecruitMemberService recruitMemberService; - @PostMapping("/apply") + @PostMapping(value = "/apply", consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity> recruitMemberAdd( @RequestBody ApplicationRequest applicationRequest ) { @@ -67,6 +72,16 @@ public ResponseEntity> recruitMemberAdd( return ResponseEntity.ok(ApiResponse.ok(MEMBER_SAVE_SUCCESS)); } + @PostMapping(value = "/apply", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> recruitMemberAddMultipart( + @RequestPart("request") ApplicationRequest applicationRequest, + @RequestPart(value = "file", required = false) MultipartFile file + ) { + recruitMemberService.addRecruitMember(applicationRequest); + + return ResponseEntity.ok(ApiResponse.ok(MEMBER_SAVE_SUCCESS)); + } + @GetMapping("/check/student-id") public ResponseEntity> duplicatedStudentIdDetails( @RequestParam @@ -92,9 +107,21 @@ public ResponseEntity> duplicatedPho return ResponseEntity.ok(ApiResponse.ok(PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS, response)); } + @GetMapping("/check/email") + public ResponseEntity> duplicatedEmailDetails( + @RequestParam + @NotBlank(message = "이메일은 필수 입력 값입니다.") + @Pattern(regexp = "^[a-zA-Z0-9+-\\_.]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", message = "유효하지 않은 이메일 형식입니다.") + String email + ) { + CheckEmailResponse response = recruitMemberService.isRegisteredEmail(email); + + return ResponseEntity.ok(ApiResponse.ok(EMAIL_DUPLICATION_CHECK_SUCCESS, response)); + } + @Operation(summary = "특정 멤버 가입 신청서 조회", security = {@SecurityRequirement(name = "BearerAuth")}) @PreAuthorize(LEAD_OR_HR_RULE) - @GetMapping("/recruit/members/{memberId}") + @GetMapping("/{memberId}") public ResponseEntity> getSpecifiedMember( @PathVariable Long memberId ) { @@ -109,7 +136,7 @@ public ResponseEntity> getSpecifiedMe security = { @SecurityRequirement(name = "BearerAuth") } ) @PreAuthorize(LEAD_OR_HR_RULE) - @PatchMapping("/recruit/members/{memberId}/payment") + @PatchMapping("/{memberId}/payment") public ResponseEntity> updatePayment( @PathVariable Long memberId, @RequestBody PaymentUpdateRequest paymentUpdateRequest @@ -131,7 +158,7 @@ public ResponseEntity> updatePayment( security = { @SecurityRequirement(name = "BearerAuth") } ) @PreAuthorize(LEAD_OR_HR_RULE) - @GetMapping("/recruit/members") + @GetMapping("") public ResponseEntity, PageMeta>> getMembers( @Parameter(description = "검색어(이름 부분 일치). 없으면 전체 조회", example = "소연") @RequestParam(required = false) String question, diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/controller/message/RecruitMemberMessage.java b/src/main/java/inha/gdgoc/domain/recruit/member/controller/message/RecruitMemberMessage.java index 813abafd..c883a59f 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/controller/message/RecruitMemberMessage.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/controller/message/RecruitMemberMessage.java @@ -4,6 +4,7 @@ public class RecruitMemberMessage { public static final String MEMBER_SAVE_SUCCESS = "성공적으로 해당 학기 멤버 가입을 완료했습니다."; public static final String STUDENT_ID_DUPLICATION_CHECK_SUCCESS = "성공적으로 학번 중복 조회를 완료했습니다."; public static final String PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS = "성공적으로 전화번호 중복 조회를 완료했습니다."; + public static final String EMAIL_DUPLICATION_CHECK_SUCCESS = "성공적으로 이메일 중복 조회를 완료했습니다."; public static final String MEMBER_RETRIEVED_SUCCESS = "성공적으로 특정 멤버의 지원서를 조회했습니다."; public static final String PAYMENT_MARKED_COMPLETE_SUCCESS = "성공적으로 입금 완료로 변경했습니다."; public static final String PAYMENT_MARKED_INCOMPLETE_SUCCESS = "성공적으로 입금 미완료로 변경했습니다."; diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckEmailResponse.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckEmailResponse.java new file mode 100644 index 00000000..0bb9cc45 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckEmailResponse.java @@ -0,0 +1,4 @@ +package inha.gdgoc.domain.recruit.member.dto.response; + +public record CheckEmailResponse(boolean isExists) { +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/repository/RecruitMemberRepository.java b/src/main/java/inha/gdgoc/domain/recruit/member/repository/RecruitMemberRepository.java index 88a66695..296c463f 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/repository/RecruitMemberRepository.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/repository/RecruitMemberRepository.java @@ -8,5 +8,6 @@ public interface RecruitMemberRepository extends JpaRepository { boolean existsByStudentId(String studentId); boolean existsByPhoneNumber(String phoneNumber); + boolean existsByEmail(String email); Page findByNameContainingIgnoreCase(String name, Pageable pageable); } diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java b/src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java index 24ff9545..2595679a 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import inha.gdgoc.domain.recruit.member.dto.request.ApplicationRequest; +import inha.gdgoc.domain.recruit.member.dto.response.CheckEmailResponse; import inha.gdgoc.domain.recruit.member.dto.response.CheckPhoneNumberResponse; import inha.gdgoc.domain.recruit.member.dto.response.CheckStudentIdResponse; import inha.gdgoc.domain.recruit.member.dto.response.SpecifiedMemberResponse; @@ -64,6 +65,12 @@ public CheckPhoneNumberResponse isRegisteredPhoneNumber(String phoneNumber) { return new CheckPhoneNumberResponse(exists); } + public CheckEmailResponse isRegisteredEmail(String email) { + boolean exists = recruitMemberRepository.existsByEmail(email); + + return new CheckEmailResponse(exists); + } + public SpecifiedMemberResponse findSpecifiedMember(Long id) { RecruitMember member = recruitMemberRepository.findById(id) .orElseThrow(() -> new RecruitMemberException(RECRUIT_MEMBER_NOT_FOUND)); diff --git a/src/main/java/inha/gdgoc/domain/user/dto/request/UpdateUserRoleTeamRequest.java b/src/main/java/inha/gdgoc/domain/user/dto/request/UpdateUserRoleTeamRequest.java deleted file mode 100644 index 3b942e61..00000000 --- a/src/main/java/inha/gdgoc/domain/user/dto/request/UpdateUserRoleTeamRequest.java +++ /dev/null @@ -1,9 +0,0 @@ -package inha.gdgoc.domain.user.dto.request; - -import inha.gdgoc.domain.user.enums.TeamType; -import inha.gdgoc.domain.user.enums.UserRole; - -public record UpdateUserRoleTeamRequest( - UserRole role, // null 이면 변경 안 함 - TeamType team // null 이면 변경 안 함 -) {} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/user/entity/User.java b/src/main/java/inha/gdgoc/domain/user/entity/User.java index eac69e8e..d78d7b82 100644 --- a/src/main/java/inha/gdgoc/domain/user/entity/User.java +++ b/src/main/java/inha/gdgoc/domain/user/entity/User.java @@ -24,6 +24,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Builder.Default; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; @@ -46,7 +47,7 @@ public class User extends BaseEntity { @Column(name = "name", nullable = false) private String name; - @Column(name = "oauthSubject", nullable = false, unique = true) + @Column(name = "oauth_subject", nullable = false, unique = true) private String oauthSubject; @Column(name = "major", nullable = false) @@ -66,14 +67,17 @@ public class User extends BaseEntity { @Enumerated(EnumType.STRING) @Column(name = "user_role", nullable = false) - private UserRole userRole; + @Default + private UserRole userRole = UserRole.GUEST; @Enumerated(EnumType.STRING) @Column(name = "team") private TeamType team; @Enumerated(EnumType.STRING) - private MembershipStatus membershipStatus; + @Column(name = "membership_status", nullable = false) + @Default + private MembershipStatus membershipStatus = MembershipStatus.PENDING; // @Column(name = "salt", nullable = false) // private byte[] salt; @@ -151,4 +155,4 @@ public boolean isGuest() { } public void changeRole(UserRole role) { this.userRole = role; } public void changeTeam(TeamType team) { this.team = team; } -} \ No newline at end of file +} diff --git a/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java b/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java index c749ed76..bacf83ae 100644 --- a/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java +++ b/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java @@ -1,6 +1,6 @@ package inha.gdgoc.domain.user.repository; -import inha.gdgoc.domain.user.dto.response.UserSummaryResponse; +import inha.gdgoc.domain.admin.user.dto.response.UserSummaryResponse; import inha.gdgoc.domain.user.entity.User; import inha.gdgoc.domain.user.enums.TeamType; import inha.gdgoc.domain.user.enums.UserRole; @@ -22,6 +22,7 @@ public interface UserRepository extends JpaRepository, UserRepositor Optional findByOauthSubject(String oauthSubject); boolean existsByStudentId(String studentId); + boolean existsByPhoneNumber(String phoneNumber); boolean existsByNameAndEmail(String name, String email); boolean existsByEmail(String email); @@ -40,7 +41,7 @@ public interface UserRepository extends JpaRepository, UserRepositor List findByTeam(TeamType team); @Query(""" - select new inha.gdgoc.domain.user.dto.response.UserSummaryResponse( + select new inha.gdgoc.domain.admin.user.dto.response.UserSummaryResponse( u.id, u.name, u.major, u.studentId, u.email, u.userRole, u.team ) from User u @@ -49,4 +50,4 @@ public interface UserRepository extends JpaRepository, UserRepositor Page findSummaries(@Param("q") String q, Pageable pageable); @NotNull Optional findById(@NotNull Long id); -} \ No newline at end of file +} diff --git a/src/main/java/inha/gdgoc/global/config/jwt/JwtProperties.java b/src/main/java/inha/gdgoc/global/config/jwt/JwtProperties.java index 175853ef..3d832433 100644 --- a/src/main/java/inha/gdgoc/global/config/jwt/JwtProperties.java +++ b/src/main/java/inha/gdgoc/global/config/jwt/JwtProperties.java @@ -14,4 +14,5 @@ public class JwtProperties { private long accessTokenValidity; private String googleIssuer; private String selfIssuer; -} \ No newline at end of file + private String audience; +} diff --git a/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java b/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java index c40d31d7..06c58332 100644 --- a/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java +++ b/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java @@ -1,11 +1,13 @@ package inha.gdgoc.global.config.jwt; -import inha.gdgoc.domain.auth.enums.LoginType; import inha.gdgoc.domain.user.entity.User; import inha.gdgoc.domain.user.enums.TeamType; import inha.gdgoc.domain.user.enums.UserRole; +import inha.gdgoc.domain.user.repository.UserRepository; import inha.gdgoc.global.exception.BusinessException; import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -14,8 +16,12 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Service; -import java.time.Duration; +import jakarta.annotation.PostConstruct; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.*; +import javax.crypto.SecretKey; import static inha.gdgoc.global.exception.GlobalErrorCode.INVALID_JWT_REQUEST; @@ -23,26 +29,36 @@ @Service public class TokenProvider { + private static final long ALLOWED_CLOCK_SKEW_SECONDS = 5L; + private final JwtProperties jwtProperties; + private final UserRepository userRepository; + private static final String CLAIM_USER_ID = "uid"; + private static final String CLAIM_SESSION_ID = "sid"; + private SecretKey cachedSigningKey; + + @PostConstruct + void initSigningKey() { + this.cachedSigningKey = buildSigningKey(jwtProperties.getSecretKey()); + } // Access Token 생성 (JWT) - public String createAccessToken(User user){ + public String createAccessToken(User user, String sessionId) { Date now = new Date(); - Date validity = new Date(now.getTime() + jwtProperties.getAccessTokenValidity()); // application.properties에서 시간 가져옴 - - String teamName = (user.getTeam() != null) ? user.getTeam().name() : null; - - return Jwts.builder() - .setHeaderParam(Header.TYPE, Header.JWT_TYPE) - .setIssuer(jwtProperties.getGoogleIssuer()) // Issuer는 하나로 통일 (또는 제거 가능) - .setIssuedAt(now) - .setExpiration(validity) - .setSubject(user.getEmail()) // sub: 이메일 - .claim("id", user.getId()) // claim: 유저 PK (DB 조회용) - .claim("role", user.getUserRole().name()) // claim: 권한 - .claim("team", teamName) // claim: 팀 (없으면 null) - .signWith(SignatureAlgorithm.HS256, Base64.getEncoder() - .encodeToString(jwtProperties.getSecretKey().getBytes())) + Date validity = new Date(now.getTime() + jwtProperties.getAccessTokenValidity()); + + var builder = Jwts.builder() + .issuer(jwtProperties.getSelfIssuer()) + .audience().add(jwtProperties.getAudience()).and() + .issuedAt(now) + .expiration(validity) + .subject(String.valueOf(user.getId())) + .id(UUID.randomUUID().toString()) + .claim(CLAIM_USER_ID, user.getId()) + .claim(CLAIM_SESSION_ID, sessionId); + + return builder + .signWith(signingKey()) .compact(); } @@ -53,65 +69,116 @@ public String createRefreshToken() { return UUID.randomUUID().toString(); } - // 토큰 유효성 검증 - public Claims validToken(String token) throws ExpiredJwtException, UnsupportedJwtException, MalformedJwtException, SignatureException, IllegalArgumentException { - return getClaims(token); - } - // Authentication 객체 생성 (Spring Security용) public Authentication getAuthentication(String token) { Claims claims = getClaims(token); - // ID 추출 - Number idNum = claims.get("id", Number.class); - if (idNum == null) throw new BusinessException(INVALID_JWT_REQUEST); - Long userId = idNum.longValue(); + Long userId = extractUserId(claims); + String sessionId = claims.get(CLAIM_SESSION_ID, String.class); + if (sessionId == null || sessionId.isBlank()) { + throw new BusinessException(INVALID_JWT_REQUEST); + } - String email = claims.getSubject(); + validateAudienceClaim(claims.get(Claims.AUDIENCE)); - // role (필수) - String roleStr = claims.get("role", String.class); - if (roleStr == null) throw new BusinessException(INVALID_JWT_REQUEST); - UserRole userRole = UserRole.valueOf(roleStr); + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(INVALID_JWT_REQUEST)); - // 권한 세트 구성 + UserRole userRole = user.getUserRole(); Set authorities = new HashSet<>(); - // 1) 역할 권한 authorities.add(new SimpleGrantedAuthority("ROLE_" + userRole.name())); - // 2) 팀 권한 (선택) - TeamType team = null; - String teamStr = claims.get("team", String.class); - if (teamStr != null && !teamStr.isBlank()) { - try { - team = TeamType.valueOf(teamStr); - authorities.add(new SimpleGrantedAuthority("TEAM_" + team.name())); - } catch (IllegalArgumentException ignored) { - } + TeamType team = user.getTeam(); + if (team != null) { + authorities.add(new SimpleGrantedAuthority("TEAM_" + team.name())); } - CustomUserDetails userDetails = new CustomUserDetails(userId, email, "", authorities, userRole, team); + CustomUserDetails userDetails = + new CustomUserDetails(userId, user.getEmail(), sessionId, authorities, userRole, team); return new UsernamePasswordAuthenticationToken(userDetails, null, authorities); } + private Claims getClaims(String token) { return Jwts.parser() - .setSigningKey(Base64.getEncoder().encodeToString(jwtProperties.getSecretKey().getBytes())) + .clockSkewSeconds(ALLOWED_CLOCK_SKEW_SECONDS) + .verifyWith(signingKey()) .build() - .parseClaimsJws(token) - .getBody(); + .parseSignedClaims(token) + .getPayload(); + } + + private SecretKey signingKey() { + return cachedSigningKey; + } + + private SecretKey buildSigningKey(String rawSecret) { + byte[] candidateKey; + try { + candidateKey = Decoders.BASE64.decode(rawSecret); + } catch (IllegalArgumentException ignore) { + candidateKey = rawSecret.getBytes(StandardCharsets.UTF_8); + } + + if (candidateKey.length < 32) { + try { + candidateKey = MessageDigest.getInstance("SHA-256").digest(candidateKey); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 algorithm not available", e); + } + } + + return Keys.hmacShaKeyFor(candidateKey); + } + + private Long extractUserId(Claims claims) { + Number idNum = claims.get(CLAIM_USER_ID, Number.class); + if (idNum == null) { + throw new BusinessException(INVALID_JWT_REQUEST); + } + return idNum.longValue(); + } + + private void validateAudienceClaim(Object audienceClaim) { + if (audienceClaim == null) { + throw new BusinessException(INVALID_JWT_REQUEST); + } + + if (audienceClaim instanceof Collection collection) { + boolean matches = collection.stream() + .filter(Objects::nonNull) + .map(Object::toString) + .anyMatch(jwtProperties.getAudience()::equals); + if (!matches) { + throw new BusinessException(INVALID_JWT_REQUEST); + } + return; + } + + if (!jwtProperties.getAudience().equals(audienceClaim.toString())) { + throw new BusinessException(INVALID_JWT_REQUEST); + } } @Getter public static class CustomUserDetails extends org.springframework.security.core.userdetails.User { private final Long userId; + private final String sessionId; private final UserRole role; private final TeamType team; - public CustomUserDetails(Long userId, String username, String password, Collection authorities, UserRole role, TeamType team) { - super(username, password, authorities); + public CustomUserDetails( + Long userId, + String username, + String sessionId, + Collection authorities, + UserRole role, + TeamType team + ) { + super(username, "", authorities); this.userId = userId; + this.sessionId = sessionId; this.role = role; this.team = team; } diff --git a/src/main/java/inha/gdgoc/global/security/SecurityConfig.java b/src/main/java/inha/gdgoc/global/security/SecurityConfig.java index 09f06291..56e7da24 100644 --- a/src/main/java/inha/gdgoc/global/security/SecurityConfig.java +++ b/src/main/java/inha/gdgoc/global/security/SecurityConfig.java @@ -47,8 +47,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/v1/auth/**", "/api/v1/test/**", "/api/v1/game/**", - "/api/v1/apply/**", - "/api/v1/check/**", + "/api/v1/recruit/member/apply/**", + "/api/v1/recruit/member/check/**", "/api/v1/fileupload", "/api/v1/manito/verify") .permitAll() @@ -102,8 +102,8 @@ public CorsConfigurationSource corsConfigurationSource() { "https://*.gdgocinha.com" )); config.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS","PATCH")); - config.setAllowedHeaders(List.of("Origin","X-Requested-With","Content-Type","Accept","Authorization")); - config.setExposedHeaders(List.of("Authorization","Set-Cookie")); // 필요시 노출 + config.setAllowedHeaders(List.of("Origin","X-Requested-With","Content-Type","Accept")); + config.setExposedHeaders(List.of("Set-Cookie")); // 필요시 노출 config.setAllowCredentials(true); config.setMaxAge(3600L); // 프리플라이트 캐시 diff --git a/src/main/java/inha/gdgoc/global/security/TokenAuthenticationFilter.java b/src/main/java/inha/gdgoc/global/security/TokenAuthenticationFilter.java index 21965c7d..17286146 100644 --- a/src/main/java/inha/gdgoc/global/security/TokenAuthenticationFilter.java +++ b/src/main/java/inha/gdgoc/global/security/TokenAuthenticationFilter.java @@ -3,6 +3,7 @@ import inha.gdgoc.global.config.jwt.TokenProvider; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; @@ -65,7 +66,7 @@ protected void doFilterInternal( log.warn("JWT 인증 실패: {}", e.getMessage()); } } else { - log.info("Authorization 헤더 없음 → 인증 시도 안함"); + log.info("access token 없음 → 인증 시도 안함"); } filterChain.doFilter(request, response); @@ -75,23 +76,34 @@ private String getAccessToken(HttpServletRequest request) { final String HEADER_AUTHORIZATION = "Authorization"; final String TOKEN_PREFIX = "Bearer "; - String bearerToken = request.getHeader(HEADER_AUTHORIZATION); - - if (bearerToken == null || !bearerToken.startsWith(TOKEN_PREFIX)) { - return null; + String cookieToken = readCookieToken(request, "access_token"); + if (cookieToken != null) { + return sanitizeToken(cookieToken.trim()); } - String token = bearerToken.substring(TOKEN_PREFIX.length()); + return null; + } - token = token.trim(); + private String readCookieToken(HttpServletRequest request, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return null; + } + for (Cookie cookie : cookies) { + if (name.equals(cookie.getName())) { + return cookie.getValue(); + } + } + return null; + } + private String sanitizeToken(String token) { for (char c : token.toCharArray()) { if (c < 32) { log.info("토큰에 유효하지 않은 제어 문자가 포함되어 있습니다."); throw new IllegalArgumentException("토큰에 유효하지 않은 제어 문자가 포함되어 있습니다."); } } - return token; } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index e7cdf2e6..3b3b8490 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -57,16 +57,27 @@ spring: app: s3: bucket: ${AWS_TEST_RESOURCE_BUCKET} + auth: + refresh-cookie: + secure: ${REFRESH_COOKIE_SECURE:false} + same-site: ${REFRESH_COOKIE_SAME_SITE:Lax} + domain: ${REFRESH_COOKIE_DOMAIN:} + path: / + access-cookie: + secure: ${ACCESS_COOKIE_SECURE:false} + same-site: ${ACCESS_COOKIE_SAME_SITE:Lax} + domain: ${ACCESS_COOKIE_DOMAIN:} + path: / google: client-id: ${GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET} - redirect-uri: ${GOOGLE_REDIRECT_URI} jwt: googleIssuer: ${GOOGLE_ISSUER} secretKey: ${SECRET_KEY} selfIssuer: ${SELF_ISSUER} + audience: ${JWT_AUDIENCE} + accessTokenValidity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000} logging: level: diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 524a8a81..21c7e61f 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -56,16 +56,27 @@ spring: app: s3: bucket: ${AWS_TEST_RESOURCE_BUCKET} + auth: + refresh-cookie: + secure: ${REFRESH_COOKIE_SECURE:false} + same-site: ${REFRESH_COOKIE_SAME_SITE:Lax} + domain: ${REFRESH_COOKIE_DOMAIN:} + path: / + access-cookie: + secure: ${ACCESS_COOKIE_SECURE:false} + same-site: ${ACCESS_COOKIE_SAME_SITE:Lax} + domain: ${ACCESS_COOKIE_DOMAIN:} + path: / google: client-id: ${GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET} - redirect-uri: ${GOOGLE_REDIRECT_URI} jwt: googleIssuer: ${GOOGLE_ISSUER} secretKey: ${SECRET_KEY} selfIssuer: ${SELF_ISSUER} + audience: ${JWT_AUDIENCE} + accessTokenValidity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000} logging: level: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 1f5f5c6b..125b62af 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -57,16 +57,27 @@ spring: app: s3: bucket: ${AWS_RESOURCE_BUCKET} + auth: + refresh-cookie: + secure: ${REFRESH_COOKIE_SECURE:false} + same-site: ${REFRESH_COOKIE_SAME_SITE:Lax} + domain: ${REFRESH_COOKIE_DOMAIN:} + path: / + access-cookie: + secure: ${ACCESS_COOKIE_SECURE:false} + same-site: ${ACCESS_COOKIE_SAME_SITE:Lax} + domain: ${ACCESS_COOKIE_DOMAIN:} + path: / google: client-id: ${GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET} - redirect-uri: ${GOOGLE_REDIRECT_URI} jwt: googleIssuer: ${GOOGLE_ISSUER} secretKey: ${SECRET_KEY} selfIssuer: ${SELF_ISSUER} + audience: ${JWT_AUDIENCE} + accessTokenValidity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000} logging: level: diff --git a/src/main/resources/db/migration/V20260117__add_membership_status_to_users.sql b/src/main/resources/db/migration/V20260117__add_membership_status_to_users.sql new file mode 100644 index 00000000..26a1e8fe --- /dev/null +++ b/src/main/resources/db/migration/V20260117__add_membership_status_to_users.sql @@ -0,0 +1,13 @@ +-- Ensure users table contains membership_status column for new enum responses +ALTER TABLE users + ADD COLUMN IF NOT EXISTS membership_status VARCHAR(32); + +ALTER TABLE users + ALTER COLUMN membership_status SET DEFAULT 'PENDING'; + +UPDATE users +SET membership_status = 'PENDING' +WHERE membership_status IS NULL; + +ALTER TABLE users + ALTER COLUMN membership_status SET NOT NULL; diff --git a/src/main/resources/db/migration/V20260118__ensure_oauth_subject_column.sql b/src/main/resources/db/migration/V20260118__ensure_oauth_subject_column.sql new file mode 100644 index 00000000..76119ed2 --- /dev/null +++ b/src/main/resources/db/migration/V20260118__ensure_oauth_subject_column.sql @@ -0,0 +1,59 @@ +DO $$ +DECLARE + has_snake BOOLEAN; + has_lower BOOLEAN; + has_camel BOOLEAN; +BEGIN + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'users' + AND column_name = 'oauth_subject' + ) + INTO has_snake; + + IF NOT has_snake THEN + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'users' + AND column_name = 'oauthsubject' + ) + INTO has_lower; + + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'users' + AND column_name = 'oauthSubject' + ) + INTO has_camel; + + IF has_lower THEN + EXECUTE 'ALTER TABLE users RENAME COLUMN oauthsubject TO oauth_subject'; + ELSIF has_camel THEN + EXECUTE 'ALTER TABLE users RENAME COLUMN "oauthSubject" TO oauth_subject'; + ELSE + ALTER TABLE users + ADD COLUMN oauth_subject VARCHAR(255); + END IF; + END IF; + + -- Fallback for any NULL values (legacy data without OAuth subject) + UPDATE users + SET oauth_subject = CONCAT('legacy-', id) + WHERE oauth_subject IS NULL; + + ALTER TABLE users + ALTER COLUMN oauth_subject SET NOT NULL; + + IF NOT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'users' + AND indexname = 'uk_users_oauth_subject' + ) THEN + EXECUTE 'ALTER TABLE users ADD CONSTRAINT uk_users_oauth_subject UNIQUE (oauth_subject)'; + END IF; +END; +$$; From 03267d5a49be6656a81718c02098e44373322f25 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Wed, 11 Feb 2026 03:29:09 +0900 Subject: [PATCH 25/49] fix(backend): sync EnrolledClassification enum with frontend status --- .../domain/recruit/member/enums/EnrolledClassification.java | 3 ++- .../gdgoc/domain/recruit/service/RecruitMemberServiceTest.java | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/enums/EnrolledClassification.java b/src/main/java/inha/gdgoc/domain/recruit/member/enums/EnrolledClassification.java index f5ba541d..133747ae 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/enums/EnrolledClassification.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/enums/EnrolledClassification.java @@ -4,8 +4,9 @@ @Getter public enum EnrolledClassification { - FULL_REGISTRATION("정등록"), + FULL_REGISTRATION("재학"), LEAVE_OF_ABSENCE("휴학"), + MILITARY_LEAVE("군휴학"), GRADUATION("졸업"), PARTIAL_REGISTRATION("부분등록"), COMPLETION("수료"); diff --git a/src/test/java/inha/gdgoc/domain/recruit/service/RecruitMemberServiceTest.java b/src/test/java/inha/gdgoc/domain/recruit/service/RecruitMemberServiceTest.java index 5e88882c..12b034da 100644 --- a/src/test/java/inha/gdgoc/domain/recruit/service/RecruitMemberServiceTest.java +++ b/src/test/java/inha/gdgoc/domain/recruit/service/RecruitMemberServiceTest.java @@ -22,7 +22,7 @@ void addMember_ShouldSaveRecruitMemberAndAnswers() { .name("김소연") .grade("4") .studentId("122123388") - .enrolledClassification("정등록") + .enrolledClassification("재학") .phoneNumber("010-1111-2332") .nationality("대한민국") .email("abc@gmail.com") From effe6833780e11dee47027c5dba2e461a80e8b9b Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:33:14 +0900 Subject: [PATCH 26/49] fix(backend): sync Gender enum with frontend terminology and add PRIVATE option --- .../domain/recruit/member/enums/Gender.java | 5 +- ...V20250823__user_role_ordinal_to_string.sql | 38 ------------ ...convert_created_updated_to_timestamptz.sql | 32 ---------- ...dmission_semester_add_backfill_enforce.sql | 24 -------- .../V20250907__core_recruit_applications.sql | 18 ------ ...ecruit_applications_session_and_status.sql | 32 ---------- ...260117__add_membership_status_to_users.sql | 13 ---- ...V20260118__ensure_oauth_subject_column.sql | 59 ------------------- .../service/RecruitMemberServiceTest.java | 2 +- 9 files changed, 4 insertions(+), 219 deletions(-) delete mode 100644 src/main/resources/db/migration/V20250823__user_role_ordinal_to_string.sql delete mode 100644 src/main/resources/db/migration/V20250825__convert_created_updated_to_timestamptz.sql delete mode 100644 src/main/resources/db/migration/V20250826__admission_semester_add_backfill_enforce.sql delete mode 100644 src/main/resources/db/migration/V20250907__core_recruit_applications.sql delete mode 100644 src/main/resources/db/migration/V20260114__core_recruit_applications_session_and_status.sql delete mode 100644 src/main/resources/db/migration/V20260117__add_membership_status_to_users.sql delete mode 100644 src/main/resources/db/migration/V20260118__ensure_oauth_subject_column.sql diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/enums/Gender.java b/src/main/java/inha/gdgoc/domain/recruit/member/enums/Gender.java index a7170fe7..f4d15a56 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/enums/Gender.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/enums/Gender.java @@ -4,8 +4,9 @@ @Getter public enum Gender { - MALE("남자"), - FEMALE("여자"); + MALE("남성"), + FEMALE("여성"), + PRIVATE("비공개"); private final String type; diff --git a/src/main/resources/db/migration/V20250823__user_role_ordinal_to_string.sql b/src/main/resources/db/migration/V20250823__user_role_ordinal_to_string.sql deleted file mode 100644 index 8dff9689..00000000 --- a/src/main/resources/db/migration/V20250823__user_role_ordinal_to_string.sql +++ /dev/null @@ -1,38 +0,0 @@ -DO $$ -DECLARE r record; -BEGIN - FOR r IN - SELECT conname - FROM pg_constraint c - JOIN pg_class t ON t.oid = c.conrelid - JOIN pg_namespace n ON n.oid = t.relnamespace - WHERE t.relname = 'users' - AND n.nspname = 'public' - AND c.contype = 'c' - AND pg_get_constraintdef(c.oid) ILIKE '%user_role%' -- user_role 관련 CHECK - LOOP - EXECUTE format('ALTER TABLE public.users DROP CONSTRAINT %I', r.conname); - END LOOP; -END $$; - -ALTER TABLE public.users ALTER COLUMN user_role DROP DEFAULT; - -ALTER TABLE public.users - ALTER COLUMN user_role TYPE varchar(32) - USING user_role::text; - - -UPDATE public.users -SET user_role = CASE user_role - WHEN '0' THEN 'GUEST' - WHEN '1' THEN 'MEMBER' - WHEN '2' THEN 'ADMIN' - ELSE user_role - END; - --- 5) 새 디폴트/체크 제약조건 설정 -ALTER TABLE public.users ALTER COLUMN user_role SET DEFAULT 'GUEST'; - -ALTER TABLE public.users - ADD CONSTRAINT users_user_role_check - CHECK (user_role IN ('GUEST','MEMBER','ADMIN')); diff --git a/src/main/resources/db/migration/V20250825__convert_created_updated_to_timestamptz.sql b/src/main/resources/db/migration/V20250825__convert_created_updated_to_timestamptz.sql deleted file mode 100644 index 6ae8dfa6..00000000 --- a/src/main/resources/db/migration/V20250825__convert_created_updated_to_timestamptz.sql +++ /dev/null @@ -1,32 +0,0 @@ -DO $$ -DECLARE - r RECORD; -BEGIN - FOR r IN - WITH target_cols AS ( - SELECT - n.nspname AS schema_name, - c.relname AS table_name, - a.attname AS column_name - FROM pg_catalog.pg_attribute a - JOIN pg_catalog.pg_class c ON c.oid = a.attrelid - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE a.attnum > 0 - AND NOT a.attisdropped - AND c.relkind IN ('r','p') - AND n.nspname NOT IN ('pg_catalog','information_schema') - AND a.attname IN ('created_at','updated_at') - AND pg_catalog.format_type(a.atttypid, a.atttypmod) ILIKE 'timestamp% without time zone%' - ) - SELECT - format( - 'ALTER TABLE %I.%I ALTER COLUMN %I TYPE timestamptz(6) USING (%I AT TIME ZONE %L);', - schema_name, table_name, column_name, column_name, 'Asia/Seoul' - ) AS alter_sql - FROM target_cols - ORDER BY schema_name, table_name, column_name - LOOP - RAISE NOTICE 'Executing: %', r.alter_sql; - EXECUTE r.alter_sql; - END LOOP; -END $$; diff --git a/src/main/resources/db/migration/V20250826__admission_semester_add_backfill_enforce.sql b/src/main/resources/db/migration/V20250826__admission_semester_add_backfill_enforce.sql deleted file mode 100644 index e56653ca..00000000 --- a/src/main/resources/db/migration/V20250826__admission_semester_add_backfill_enforce.sql +++ /dev/null @@ -1,24 +0,0 @@ --- 1) 컬럼 추가 (처음엔 NULL 허용) -ALTER TABLE recruit_member - ADD COLUMN admission_semester VARCHAR(10); - --- 2) 기존 데이터 백필 --- 학기 규칙: 2~7월 = YYY_1, 8~12월 = YYY_2, 1월 = (전년도) YYY_2 -UPDATE recruit_member -SET admission_semester = CASE - WHEN EXTRACT(MONTH FROM created_at) BETWEEN 8 AND 12 - THEN 'Y' || to_char(created_at, 'YY') || '_2' - WHEN EXTRACT(MONTH FROM created_at) BETWEEN 2 AND 7 - THEN 'Y' || to_char(created_at, 'YY') || '_1' - WHEN EXTRACT(MONTH FROM created_at) = 1 - THEN 'Y' || to_char(created_at - INTERVAL '1 year', 'YY') || '_2' - END -WHERE admission_semester IS NULL; - --- 3) NOT NULL 전환 (형식 제약은 생략 가능) -ALTER TABLE recruit_member - ALTER COLUMN admission_semester SET NOT NULL; - --- (선택) 인덱스 -CREATE INDEX idx_recruit_member_admission_semester - ON recruit_member (admission_semester); diff --git a/src/main/resources/db/migration/V20250907__core_recruit_applications.sql b/src/main/resources/db/migration/V20250907__core_recruit_applications.sql deleted file mode 100644 index 6209566b..00000000 --- a/src/main/resources/db/migration/V20250907__core_recruit_applications.sql +++ /dev/null @@ -1,18 +0,0 @@ -create table if not exists core_recruit_applications ( - id bigserial primary key, - name varchar(255) not null, - student_id varchar(64) not null, - phone varchar(64) not null, - major varchar(255) not null, - email varchar(255) not null, - team varchar(64) not null, - motivation text not null, - wish text not null, - strengths text not null, - pledge text not null, - file_urls jsonb not null default '[]'::jsonb, - created_at timestamptz not null default (now()), - updated_at timestamptz not null default (now()) -); - - diff --git a/src/main/resources/db/migration/V20260114__core_recruit_applications_session_and_status.sql b/src/main/resources/db/migration/V20260114__core_recruit_applications_session_and_status.sql deleted file mode 100644 index 91e8c17c..00000000 --- a/src/main/resources/db/migration/V20260114__core_recruit_applications_session_and_status.sql +++ /dev/null @@ -1,32 +0,0 @@ -alter table core_recruit_applications - add column if not exists user_id bigint, - add column if not exists session varchar(32), - add column if not exists result_status varchar(32) default 'SUBMITTED', - add column if not exists reviewed_at timestamptz, - add column if not exists reviewed_by bigint, - add column if not exists result_note text; - -update core_recruit_applications -set session = coalesce(session, 'UNKNOWN'); - -alter table core_recruit_applications - alter column session set not null; - -alter table core_recruit_applications - alter column result_status set not null; - -update core_recruit_applications cra -set user_id = u.id -from users u -where cra.user_id is null - and u.email = cra.email; - -alter table core_recruit_applications - alter column user_id set not null; - -alter table core_recruit_applications - add constraint fk_core_recruit_applications_user - foreign key (user_id) references users (id) on delete cascade; - -create unique index if not exists uq_core_recruit_user_session - on core_recruit_applications (user_id, session); diff --git a/src/main/resources/db/migration/V20260117__add_membership_status_to_users.sql b/src/main/resources/db/migration/V20260117__add_membership_status_to_users.sql deleted file mode 100644 index 26a1e8fe..00000000 --- a/src/main/resources/db/migration/V20260117__add_membership_status_to_users.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Ensure users table contains membership_status column for new enum responses -ALTER TABLE users - ADD COLUMN IF NOT EXISTS membership_status VARCHAR(32); - -ALTER TABLE users - ALTER COLUMN membership_status SET DEFAULT 'PENDING'; - -UPDATE users -SET membership_status = 'PENDING' -WHERE membership_status IS NULL; - -ALTER TABLE users - ALTER COLUMN membership_status SET NOT NULL; diff --git a/src/main/resources/db/migration/V20260118__ensure_oauth_subject_column.sql b/src/main/resources/db/migration/V20260118__ensure_oauth_subject_column.sql deleted file mode 100644 index 76119ed2..00000000 --- a/src/main/resources/db/migration/V20260118__ensure_oauth_subject_column.sql +++ /dev/null @@ -1,59 +0,0 @@ -DO $$ -DECLARE - has_snake BOOLEAN; - has_lower BOOLEAN; - has_camel BOOLEAN; -BEGIN - SELECT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'users' - AND column_name = 'oauth_subject' - ) - INTO has_snake; - - IF NOT has_snake THEN - SELECT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'users' - AND column_name = 'oauthsubject' - ) - INTO has_lower; - - SELECT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'users' - AND column_name = 'oauthSubject' - ) - INTO has_camel; - - IF has_lower THEN - EXECUTE 'ALTER TABLE users RENAME COLUMN oauthsubject TO oauth_subject'; - ELSIF has_camel THEN - EXECUTE 'ALTER TABLE users RENAME COLUMN "oauthSubject" TO oauth_subject'; - ELSE - ALTER TABLE users - ADD COLUMN oauth_subject VARCHAR(255); - END IF; - END IF; - - -- Fallback for any NULL values (legacy data without OAuth subject) - UPDATE users - SET oauth_subject = CONCAT('legacy-', id) - WHERE oauth_subject IS NULL; - - ALTER TABLE users - ALTER COLUMN oauth_subject SET NOT NULL; - - IF NOT EXISTS ( - SELECT 1 FROM pg_indexes - WHERE schemaname = 'public' - AND tablename = 'users' - AND indexname = 'uk_users_oauth_subject' - ) THEN - EXECUTE 'ALTER TABLE users ADD CONSTRAINT uk_users_oauth_subject UNIQUE (oauth_subject)'; - END IF; -END; -$$; diff --git a/src/test/java/inha/gdgoc/domain/recruit/service/RecruitMemberServiceTest.java b/src/test/java/inha/gdgoc/domain/recruit/service/RecruitMemberServiceTest.java index 12b034da..f9b57d5c 100644 --- a/src/test/java/inha/gdgoc/domain/recruit/service/RecruitMemberServiceTest.java +++ b/src/test/java/inha/gdgoc/domain/recruit/service/RecruitMemberServiceTest.java @@ -26,7 +26,7 @@ void addMember_ShouldSaveRecruitMemberAndAnswers() { .phoneNumber("010-1111-2332") .nationality("대한민국") .email("abc@gmail.com") - .gender("여자") + .gender("여성") .birth(LocalDate.of(2002, 8, 18)) .major("컴퓨터공학과") .doubleMajor("복수전공 인공지능공학과") From f01f160fdc524bcba78ac152901fb3747ae22b75 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:33:48 +0900 Subject: [PATCH 27/49] cleanup(user): remove unused password and salt fields --- .../java/inha/gdgoc/domain/user/entity/User.java | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/main/java/inha/gdgoc/domain/user/entity/User.java b/src/main/java/inha/gdgoc/domain/user/entity/User.java index d78d7b82..66102c8e 100644 --- a/src/main/java/inha/gdgoc/domain/user/entity/User.java +++ b/src/main/java/inha/gdgoc/domain/user/entity/User.java @@ -62,9 +62,6 @@ public class User extends BaseEntity { @Column(name = "email", nullable = false) private String email; - // @Column(name = "password", nullable = false) - // private String password; - @Enumerated(EnumType.STRING) @Column(name = "user_role", nullable = false) @Default @@ -79,9 +76,6 @@ public class User extends BaseEntity { @Default private MembershipStatus membershipStatus = MembershipStatus.PENDING; - // @Column(name = "salt", nullable = false) - // private byte[] salt; - @Column(name = "image") private String image; @@ -112,10 +106,8 @@ public User( this.studentId = studentId; this.phoneNumber = phoneNumber; this.email = email; - //this.password = password; this.userRole = userRole; this.team = team; - //this.salt = salt; this.image = image; this.social = (social != null ? social : new SocialUrls()); this.careers = (careers != null ? careers : new Careers()); @@ -135,10 +127,6 @@ public void addStudyAttendee(StudyAttendee studyAttendee) { } } - // public void updatePassword(String password) throws NoSuchAlgorithmException, InvalidKeyException { - // this.password = EncryptUtil.encrypt(password, this.salt); - // } - public void approve() { this.membershipStatus = MembershipStatus.APPROVED; if (this.userRole == UserRole.GUEST) { From 883d14dd9e340cb8607f137c73530a8c810e6b99 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:34:12 +0900 Subject: [PATCH 28/49] docs(db): add initial database schema SQL --- database_schema.sql | 201 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 database_schema.sql diff --git a/database_schema.sql b/database_schema.sql new file mode 100644 index 00000000..f19377ce --- /dev/null +++ b/database_schema.sql @@ -0,0 +1,201 @@ +-- GDGoC Inha Univ. Database Schema +-- DBMS: PostgreSQL + +-- 1. 유저 (users) +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + oauth_subject VARCHAR(255) NOT NULL UNIQUE, + major VARCHAR(255) NOT NULL, + student_id VARCHAR(255) NOT NULL, + phone_number VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + user_role VARCHAR(50) NOT NULL DEFAULT 'GUEST', + team VARCHAR(50), + membership_status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + image TEXT, + socials JSONB, + careers JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 2. 리크루팅 멤버 (recruit_member) +CREATE TABLE IF NOT EXISTS recruit_member ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + grade VARCHAR(50) NOT NULL, + student_id VARCHAR(255) NOT NULL UNIQUE, + enrolled_classification VARCHAR(50) NOT NULL, + phone_number VARCHAR(255) NOT NULL UNIQUE, + nationality VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + gender VARCHAR(20) NOT NULL, + birth DATE NOT NULL, + major VARCHAR(255) NOT NULL, + double_major VARCHAR(255), + is_payed BOOLEAN NOT NULL DEFAULT FALSE, + admission_semester VARCHAR(10) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 3. 답변 (answer) - recruit_member와 1:N +CREATE TABLE IF NOT EXISTS answer ( + id BIGSERIAL PRIMARY KEY, + recruit_member BIGINT REFERENCES recruit_member(id) ON DELETE CASCADE, + survey_type VARCHAR(50) NOT NULL, + input_type VARCHAR(50) NOT NULL, + response_value JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 4. 코어 멤버 지원 (core_recruit_applications) +CREATE TABLE IF NOT EXISTS core_recruit_applications ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + session VARCHAR(32) NOT NULL, + name VARCHAR(255) NOT NULL, + student_id VARCHAR(255) NOT NULL, + phone VARCHAR(255) NOT NULL, + major VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + team VARCHAR(255) NOT NULL, + motivation TEXT NOT NULL, + wish TEXT NOT NULL, + strengths TEXT NOT NULL, + pledge TEXT NOT NULL, + file_urls JSONB NOT NULL DEFAULT '[]', + result_status VARCHAR(32) NOT NULL DEFAULT 'SUBMITTED', + reviewed_at TIMESTAMPTZ, + reviewed_by BIGINT, + result_note TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 5. 스터디 (study) +CREATE TABLE IF NOT EXISTS study ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(256) NOT NULL, + simple_introduce VARCHAR(512), + activity_introduce TEXT, + image_path VARCHAR(256), + creator_type VARCHAR(10) NOT NULL, + status VARCHAR(20) NOT NULL, + expected_time VARCHAR(100), + expected_place VARCHAR(100), + recruit_start_date TIMESTAMP, + recruit_end_date TIMESTAMP, + activity_start_date TIMESTAMP, + activity_end_date TIMESTAMP, + user_id BIGINT REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 6. 스터디 참여자 (study_attendee) +CREATE TABLE IF NOT EXISTS study_attendee ( + id BIGSERIAL PRIMARY KEY, + study_id BIGINT REFERENCES study(id) ON DELETE CASCADE, + user_id BIGINT REFERENCES users(id), + status VARCHAR(20) NOT NULL, + introduce TEXT, + activity_time VARCHAR(100), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 7. 정기 회의 (meetings) +CREATE TABLE IF NOT EXISTS meetings ( + id BIGSERIAL PRIMARY KEY, + meeting_date DATE NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_meeting_date ON meetings(meeting_date); + +-- 8. 출석 기록 (attendance_records) +CREATE TABLE IF NOT EXISTS attendance_records ( + id BIGSERIAL PRIMARY KEY, + meeting_id BIGINT NOT NULL REFERENCES meetings(id), + user_id BIGINT NOT NULL REFERENCES users(id), + present BOOLEAN NOT NULL DEFAULT FALSE, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT REFERENCES users(id), + UNIQUE (meeting_id, user_id) +); +CREATE INDEX IF NOT EXISTS idx_attendance_user_meeting ON attendance_records(user_id, meeting_id); + +-- 9. 인증 코드 (auth_code) +CREATE TABLE IF NOT EXISTS auth_code ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + code VARCHAR(255) NOT NULL, + issued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 10. 리프레시 토큰 (refresh_token) +CREATE TABLE IF NOT EXISTS refresh_token ( + id BIGSERIAL PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + user_id BIGINT NOT NULL REFERENCES users(id), + expiry_date TIMESTAMP, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 11. 마니또 세션 (manito_sessions) +CREATE TABLE IF NOT EXISTS manito_sessions ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(64) NOT NULL UNIQUE, + title VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_manito_sessions_created_at ON manito_sessions(created_at DESC); + +-- 12. 마니또 배정 (manito_assignments) +CREATE TABLE IF NOT EXISTS manito_assignments ( + id BIGSERIAL PRIMARY KEY, + session_id BIGINT NOT NULL REFERENCES manito_sessions(id), + student_id VARCHAR(32) NOT NULL, + name VARCHAR(64) NOT NULL, + encrypted_manitto TEXT, + pin_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (session_id, student_id) +); +CREATE INDEX IF NOT EXISTS idx_manito_assignments_session ON manito_assignments(session_id); +CREATE INDEX IF NOT EXISTS idx_manito_assignments_student ON manito_assignments(student_id); + +-- 13. 게임 문제 (game_question) +CREATE TABLE IF NOT EXISTS game_question ( + id BIGSERIAL PRIMARY KEY, + language VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + result VARCHAR(255) NOT NULL +); + +-- 14. 게임 유저 (game_user) +CREATE TABLE IF NOT EXISTS game_user ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + major VARCHAR(255) NOT NULL, + student_id VARCHAR(255) NOT NULL, + phone_number VARCHAR(255) NOT NULL, + typing_speed FLOAT8 NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 15. 방명록 (guestbook_entry) +CREATE TABLE IF NOT EXISTS guestbook_entry ( + id BIGSERIAL PRIMARY KEY, + wristband_serial VARCHAR(32) NOT NULL UNIQUE, + name VARCHAR(50) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + won_at TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_guestbook_created_at ON guestbook_entry(created_at); From b000b2f94b096ded532e533a8901e1a8a47935d3 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:43:56 +0900 Subject: [PATCH 29/49] chore(infra): add Redis configuration to GitHub workflows and application settings --- .github/workflows/ci.yml | 13 +++++++++++++ .github/workflows/deploy-dev.yml | 3 +++ .github/workflows/deploy-prod.yml | 3 +++ src/main/resources/application.yml | 6 +++--- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a63a5a0e..9f88ea5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,17 @@ jobs: build: runs-on: ubuntu-latest + services: + redis: + image: redis + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - uses: actions/checkout@v4 @@ -64,6 +75,8 @@ jobs: SPRING_JPA_DATABASE_PLATFORM: org.hibernate.dialect.H2Dialect SPRING_JPA_HIBERNATE_DDL_AUTO: create-drop SPRING_FLYWAY_ENABLED: "false" + SPRING_DATA_REDIS_HOST: localhost + SPRING_DATA_REDIS_PORT: 6379 run: ./gradlew test --no-daemon --stacktrace --info --no-watch-fs | tee test.log - name: Publish unit-test results (check UI) diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 08d479b4..3366a9bd 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -36,6 +36,9 @@ jobs: DB_NAME_DEV=${{ secrets.DB_NAME_DEV }} DB_USERNAME=${{ secrets.DB_USERNAME }} DB_PASSWORD=${{ secrets.DB_PASSWORD }} + REDIS_HOST=${{ secrets.REDIS_HOST || 'localhost' }} + REDIS_PORT=${{ secrets.REDIS_PORT || '6379' }} + REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }} GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_ISSUER=${{ secrets.GOOGLE_ISSUER }} SELF_ISSUER=${{ secrets.SELF_ISSUER }} diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 57f78314..a2276859 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -39,6 +39,9 @@ jobs: DB_NAME=${{ secrets.DB_NAME }} DB_USERNAME=${{ secrets.DB_USERNAME }} DB_PASSWORD=${{ secrets.DB_PASSWORD }} + REDIS_HOST=${{ secrets.REDIS_HOST }} + REDIS_PORT=${{ secrets.REDIS_PORT }} + REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }} GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_ISSUER=${{ secrets.GOOGLE_ISSUER }} SELF_ISSUER=${{ secrets.SELF_ISSUER }} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d2b0275f..4f2496ab 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,7 +3,7 @@ spring: active: local data: redis: - host: 127.0.0.1 - port: 6379 - password: ${REDIS_PASSWORD} + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} timeout: 2s \ No newline at end of file From 20f1011ae2ea82524241613255dccc05c1e28c79 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:48:09 +0900 Subject: [PATCH 30/49] chore(infra): include Redis service in Docker Compose for dev and prod --- docker-compose-dev.yml | 13 +++++++++++++ docker-compose-prod.yml | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index a1af226c..3b391b20 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -12,10 +12,23 @@ services: SPRING_DATASOURCE_URL: "jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME_DEV}" SPRING_DATASOURCE_USERNAME: "${DB_USERNAME}" SPRING_DATASOURCE_PASSWORD: "${DB_PASSWORD}" + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: "${REDIS_PASSWORD}" volumes: - /home/ubuntu/gdgoc-be-app-dev/.env:/app/.env env_file: - .env + depends_on: + - redis + + redis: + image: redis:7-alpine + container_name: gdgoc-redis-dev + restart: always + command: redis-server --requirepass ${REDIS_PASSWORD} + env_file: + - .env dozzle: container_name: dozzle diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index 4b06ce07..3b937a24 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -12,10 +12,23 @@ services: SPRING_DATASOURCE_URL: "jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}" SPRING_DATASOURCE_USERNAME: "${DB_USERNAME}" SPRING_DATASOURCE_PASSWORD: "${DB_PASSWORD}" + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: "${REDIS_PASSWORD}" volumes: - /home/ubuntu/gdgoc-be-app/.env:/app/.env env_file: - .env + depends_on: + - redis + + redis: + image: redis:7-alpine + container_name: gdgoc-redis + restart: always + command: redis-server --requirepass ${REDIS_PASSWORD} + env_file: + - .env dozzle: container_name: dozzle From 4ea4b66b4a45372069b9177a8cfd25c2628beaa0 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:59:28 +0900 Subject: [PATCH 31/49] fix(infra): enable Flyway baseline-on-migrate to handle existing database --- src/main/resources/application-dev.yml | 2 +- src/main/resources/application-local.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 3b3b8490..3a3f9c55 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -17,7 +17,7 @@ spring: url: ${SPRING_DATASOURCE_URL} username: ${SPRING_DATASOURCE_USERNAME} flyway: - baseline-on-migrate: false + baseline-on-migrate: true clean-disabled: true enabled: true locations: classpath:db/migration diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 21c7e61f..e2144cb6 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -18,7 +18,7 @@ spring: username: ${DB_USERNAME} flyway: baseline-description: "Baseline existing schema" - baseline-on-migrate: false + baseline-on-migrate: true baseline-version: 1 enabled: true locations: classpath:db/migration From 0bbd38736fd4d9edc9add88978044a1372df15ea Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Wed, 11 Feb 2026 05:08:57 +0900 Subject: [PATCH 32/49] debug(jwt): enhance logging for JWT validation failures --- .../global/config/jwt/TokenProvider.java | 44 +++++++++++++++---- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java b/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java index 06c58332..6c9f6830 100644 --- a/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java +++ b/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java @@ -76,13 +76,17 @@ public Authentication getAuthentication(String token) { Long userId = extractUserId(claims); String sessionId = claims.get(CLAIM_SESSION_ID, String.class); if (sessionId == null || sessionId.isBlank()) { + log.warn("JWT 검증 실패: sessionId(sid) 클레임이 누락되었습니다."); throw new BusinessException(INVALID_JWT_REQUEST); } validateAudienceClaim(claims.get(Claims.AUDIENCE)); User user = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(INVALID_JWT_REQUEST)); + .orElseThrow(() -> { + log.warn("JWT 검증 실패: ID가 {}인 유저를 찾을 수 없습니다.", userId); + return new BusinessException(INVALID_JWT_REQUEST); + }); UserRole userRole = user.getUserRole(); Set authorities = new HashSet<>(); @@ -100,12 +104,29 @@ public Authentication getAuthentication(String token) { } private Claims getClaims(String token) { - return Jwts.parser() - .clockSkewSeconds(ALLOWED_CLOCK_SKEW_SECONDS) - .verifyWith(signingKey()) - .build() - .parseSignedClaims(token) - .getPayload(); + try { + return Jwts.parser() + .clockSkewSeconds(ALLOWED_CLOCK_SKEW_SECONDS) + .verifyWith(signingKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (ExpiredJwtException e) { + log.warn("JWT 검증 실패: 만료된 토큰입니다."); + throw e; + } catch (UnsupportedJwtException e) { + log.warn("JWT 검증 실패: 지원되지 않는 토큰 형식입니다."); + throw e; + } catch (MalformedJwtException e) { + log.warn("JWT 검증 실패: 잘못된 구조의 토큰입니다."); + throw e; + } catch (SignatureException e) { + log.warn("JWT 검증 실패: 서명이 일치하지 않습니다."); + throw e; + } catch (Exception e) { + log.warn("JWT 검증 실패: 알 수 없는 오류 발생 - {}", e.getMessage()); + throw e; + } } private SecretKey signingKey() { @@ -134,6 +155,7 @@ private SecretKey buildSigningKey(String rawSecret) { private Long extractUserId(Claims claims) { Number idNum = claims.get(CLAIM_USER_ID, Number.class); if (idNum == null) { + log.warn("JWT 검증 실패: userId(uid) 클레임이 누락되었습니다."); throw new BusinessException(INVALID_JWT_REQUEST); } return idNum.longValue(); @@ -141,21 +163,25 @@ private Long extractUserId(Claims claims) { private void validateAudienceClaim(Object audienceClaim) { if (audienceClaim == null) { + log.warn("JWT 검증 실패: audience(aud) 클레임이 누락되었습니다."); throw new BusinessException(INVALID_JWT_REQUEST); } + String expectedAudience = jwtProperties.getAudience(); if (audienceClaim instanceof Collection collection) { boolean matches = collection.stream() .filter(Objects::nonNull) .map(Object::toString) - .anyMatch(jwtProperties.getAudience()::equals); + .anyMatch(expectedAudience::equals); if (!matches) { + log.warn("JWT 검증 실패: audience 불일치. (기대값: {}, 실제값: {})", expectedAudience, collection); throw new BusinessException(INVALID_JWT_REQUEST); } return; } - if (!jwtProperties.getAudience().equals(audienceClaim.toString())) { + if (!expectedAudience.equals(audienceClaim.toString())) { + log.warn("JWT 검증 실패: audience 불일치. (기대값: {}, 실제값: {})", expectedAudience, audienceClaim); throw new BusinessException(INVALID_JWT_REQUEST); } } From 58f3b70e3a5673f2ed4225ffa6a03c04e9c2eaa0 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 12 Feb 2026 01:11:26 +0900 Subject: [PATCH 33/49] feat(auth): integrate google profile picture and add issuer validation --- .../gdgoc/domain/auth/dto/GoogleUserInfo.java | 1 + .../auth/dto/request/SignupRequest.java | 1 + .../dto/response/SignupNeededResponse.java | 1 + .../domain/auth/service/AuthService.java | 66 +++++++++++-------- .../global/config/jwt/TokenProvider.java | 3 + 5 files changed, 43 insertions(+), 29 deletions(-) diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/GoogleUserInfo.java b/src/main/java/inha/gdgoc/domain/auth/dto/GoogleUserInfo.java index 9f01a0d3..04e930ac 100644 --- a/src/main/java/inha/gdgoc/domain/auth/dto/GoogleUserInfo.java +++ b/src/main/java/inha/gdgoc/domain/auth/dto/GoogleUserInfo.java @@ -13,4 +13,5 @@ public class GoogleUserInfo { private String name; private String givenName; private String familyName; + private String picture; } diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/request/SignupRequest.java b/src/main/java/inha/gdgoc/domain/auth/dto/request/SignupRequest.java index 9fc86210..01fc5f90 100644 --- a/src/main/java/inha/gdgoc/domain/auth/dto/request/SignupRequest.java +++ b/src/main/java/inha/gdgoc/domain/auth/dto/request/SignupRequest.java @@ -19,4 +19,5 @@ public class SignupRequest { private String phoneNumber; @NotBlank private String major; + private String image; } \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/response/SignupNeededResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/SignupNeededResponse.java index a9c8b545..60bcb7e2 100644 --- a/src/main/java/inha/gdgoc/domain/auth/dto/response/SignupNeededResponse.java +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/SignupNeededResponse.java @@ -12,4 +12,5 @@ public class SignupNeededResponse { private String oauthSubject; private String email; private String name; + private String picture; } diff --git a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java index c39d24a2..36b14136 100644 --- a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java +++ b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java @@ -46,36 +46,41 @@ public class AuthService { private String googleClientId; // 로그인 - @Transactional - public Object login(String idToken) { - // Google ID Token 검증 - GoogleUserInfo googleUser = verifyGoogleToken(idToken); - - // 도메인 검증 (인하대 메일만 허용) - if (!googleUser.getEmail().endsWith("@inha.edu")) { - throw new IllegalArgumentException("인하대학교(@inha.edu) 계정만 이용 가능합니다."); - } - - // DB에서 유저 조회 (OAuth Subject 기준) - User user = userRepository.findByOauthSubject(googleUser.getSub()).orElse(null); - - // 신규 유저 -> 회원가입 필요 응답 (202 or 200 with isNewUser=true) - if (user == null) { - String preferredName = - hasText(googleUser.getFamilyName()) ? googleUser.getFamilyName() : googleUser.getName(); - return SignupNeededResponse.builder() - .isNewUser(true) - .oauthSubject(googleUser.getSub()) - .email(googleUser.getEmail()) - .name(preferredName) - .build(); + @Transactional + public Object login(String idToken) { + log.info("로그인 시도 - ID Token 존재 여부: {}", (idToken != null && !idToken.isBlank())); + // Google ID Token 검증 + GoogleUserInfo googleUser = verifyGoogleToken(idToken); + log.info("Google 토큰 검증 성공 - Email: {}, Sub: {}", googleUser.getEmail(), googleUser.getSub()); + + // 도메인 검증 (인하대 메일만 허용) + if (!googleUser.getEmail().endsWith("@inha.edu")) { + log.warn("허용되지 않은 도메인 로그인 시도: {}", googleUser.getEmail()); + throw new IllegalArgumentException("인하대학교(@inha.edu) 계정만 이용 가능합니다."); + } + + // DB에서 유저 조회 (OAuth Subject 기준) + User user = userRepository.findByOauthSubject(googleUser.getSub()).orElse(null); + + // 신규 유저 -> 회원가입 필요 응답 (202 or 200 with isNewUser=true) + if (user == null) { + log.info("신규 유저 감지 - Email: {}", googleUser.getEmail()); + String preferredName = + hasText(googleUser.getFamilyName()) ? googleUser.getFamilyName() : googleUser.getName(); + return SignupNeededResponse.builder() + .isNewUser(true) + .oauthSubject(googleUser.getSub()) + .email(googleUser.getEmail()) + .name(preferredName) + .picture(googleUser.getPicture()) + .build(); + } + + log.info("기존 유저 로그인 - UserID: {}, Email: {}", user.getId(), user.getEmail()); + // 기존 유저 -> 토큰 발급 및 로그인 성공 응답 + TokenDto tokens = generateTokens(user); + return LoginSuccessResponse.of(tokens, AuthUserResponse.from(user)); } - - // 기존 유저 -> 토큰 발급 및 로그인 성공 응답 - TokenDto tokens = generateTokens(user); - return LoginSuccessResponse.of(tokens, AuthUserResponse.from(user)); - } - // 회원가입 @Transactional public LoginSuccessResponse signup(SignupRequest request) { @@ -96,6 +101,7 @@ public LoginSuccessResponse signup(SignupRequest request) { .studentId(request.getStudentId()) .major(request.getMajor()) .phoneNumber(cleanPhone) + .image(request.getImage()) // Role(GUEST), Status(PENDING) 등은 User 엔티티 생성자에서 기본값 처리됨 .build(); @@ -168,6 +174,7 @@ private GoogleUserInfo verifyGoogleToken(String idTokenString) { GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new GsonFactory()) .setAudience(Collections.singletonList(googleClientId)) + .setIssuers(java.util.Arrays.asList("https://accounts.google.com", "accounts.google.com")) .build(); GoogleIdToken idToken = verifier.verify(idTokenString); @@ -254,6 +261,7 @@ private GoogleUserInfo buildGoogleUserInfo(GoogleIdToken.Payload payload) { .name(fullName) .givenName(resolvedGiven) .familyName(resolvedFamily) + .picture((String) payload.get("picture")) .build(); } diff --git a/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java b/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java index 6c9f6830..72955383 100644 --- a/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java +++ b/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java @@ -10,6 +10,7 @@ import io.jsonwebtoken.security.Keys; import lombok.Getter; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; @@ -25,6 +26,7 @@ import static inha.gdgoc.global.exception.GlobalErrorCode.INVALID_JWT_REQUEST; +@Slf4j @RequiredArgsConstructor @Service public class TokenProvider { @@ -107,6 +109,7 @@ private Claims getClaims(String token) { try { return Jwts.parser() .clockSkewSeconds(ALLOWED_CLOCK_SKEW_SECONDS) + .requireIssuer(jwtProperties.getSelfIssuer()) .verifyWith(signingKey()) .build() .parseSignedClaims(token) From c4d8cb12815731a3524a8688d2954665165c5f38 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 12 Feb 2026 01:11:41 +0900 Subject: [PATCH 34/49] fix(auth): strengthen cookie security and update prod domains --- src/main/resources/application-prod.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 125b62af..5ab73b2d 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -59,13 +59,13 @@ app: bucket: ${AWS_RESOURCE_BUCKET} auth: refresh-cookie: - secure: ${REFRESH_COOKIE_SECURE:false} - same-site: ${REFRESH_COOKIE_SAME_SITE:Lax} + secure: ${REFRESH_COOKIE_SECURE:true} + same-site: ${REFRESH_COOKIE_SAME_SITE:None} domain: ${REFRESH_COOKIE_DOMAIN:} path: / access-cookie: - secure: ${ACCESS_COOKIE_SECURE:false} - same-site: ${ACCESS_COOKIE_SAME_SITE:Lax} + secure: ${ACCESS_COOKIE_SECURE:true} + same-site: ${ACCESS_COOKIE_SAME_SITE:None} domain: ${ACCESS_COOKIE_DOMAIN:} path: / From 651eda92d5be81257fb443995a51558d2ce21ef2 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 12 Feb 2026 01:11:41 +0900 Subject: [PATCH 35/49] feat(recruit): implement duplicate application check and fix date parsing --- .../recruit/core/service/RecruitCoreApplicationService.java | 6 ++++++ .../recruit/member/dto/request/RecruitMemberRequest.java | 2 ++ 2 files changed, 8 insertions(+) diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java b/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java index b38b5f91..5e6240fc 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java +++ b/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java @@ -46,6 +46,12 @@ public RecruitCoreEligibilityResponse checkEligibility(Long userId) { @Transactional(readOnly = true) public RecruitCorePrefillResponse prefill(Long userId) { + String session = recruitCoreSessionResolver.currentSession(); + repository.findByUser_IdAndSession(userId, session) + .ifPresent(existing -> { + throw new RecruitCoreAlreadyAppliedException(session, existing.getId()); + }); + User user = getUser(userId); return RecruitCorePrefillResponse.from(user); } diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/RecruitMemberRequest.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/RecruitMemberRequest.java index 695ab49a..fcb38363 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/RecruitMemberRequest.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/RecruitMemberRequest.java @@ -1,5 +1,6 @@ package inha.gdgoc.domain.recruit.member.dto.request; +import com.fasterxml.jackson.annotation.JsonFormat; import inha.gdgoc.domain.recruit.member.entity.RecruitMember; import inha.gdgoc.domain.recruit.member.enums.AdmissionSemester; import inha.gdgoc.domain.recruit.member.enums.EnrolledClassification; @@ -23,6 +24,7 @@ public class RecruitMemberRequest { private String nationality; private String email; private String gender; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy.MM.dd") private LocalDate birth; private String major; private String doubleMajor; From 470dc4a09f33008f84b6e62870a0efb9d1ab5a7e Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 12 Feb 2026 02:30:18 +0900 Subject: [PATCH 36/49] feat(auth): migrate to header-based Bearer token authentication --- .../auth/controller/AuthController.java | 122 ++---------------- .../auth/dto/request/TokenRefreshRequest.java | 12 ++ .../dto/response/LoginSuccessResponse.java | 1 - .../security/TokenAuthenticationFilter.java | 6 +- 4 files changed, 24 insertions(+), 117 deletions(-) create mode 100644 src/main/java/inha/gdgoc/domain/auth/dto/request/TokenRefreshRequest.java diff --git a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java index ff0a3580..5538c8b0 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java @@ -2,6 +2,7 @@ import inha.gdgoc.domain.auth.dto.request.LoginRequest; import inha.gdgoc.domain.auth.dto.request.SignupRequest; +import inha.gdgoc.domain.auth.dto.request.TokenRefreshRequest; import inha.gdgoc.domain.auth.dto.response.AccessTokenResponse; import inha.gdgoc.domain.auth.dto.response.AuthUserResponse; import inha.gdgoc.domain.auth.dto.response.CheckPhoneNumberResponse; @@ -44,43 +45,13 @@ public class AuthController { private final AuthService authService; private final AccessGuard accessGuard; private final JwtProperties jwtProperties; - @Value("${app.auth.refresh-cookie.domain:}") - private String refreshCookieDomain; - - @Value("${app.auth.refresh-cookie.path:/}") - private String refreshCookiePath; - - @Value("${app.auth.refresh-cookie.same-site:Lax}") - private String refreshCookieSameSite; - - @Value("${app.auth.refresh-cookie.secure:false}") - private boolean refreshCookieSecure; - - @Value("${app.auth.access-cookie.domain:}") - private String accessCookieDomain; - - @Value("${app.auth.access-cookie.path:/}") - private String accessCookiePath; - - @Value("${app.auth.access-cookie.same-site:Lax}") - private String accessCookieSameSite; - - @Value("${app.auth.access-cookie.secure:false}") - private boolean accessCookieSecure; // 1. 구글 로그인 (ID Token 검증) @PostMapping("/login") public ResponseEntity login(@RequestBody LoginRequest request) { try { Object response = authService.login(request.getIdToken()); - ResponseEntity.BodyBuilder builder = ResponseEntity.ok(); - if (response instanceof LoginSuccessResponse successResponse) { - ResponseCookie cookie = buildRefreshTokenCookie(successResponse.getRefreshToken()); - builder.header(HttpHeaders.SET_COOKIE, cookie.toString()); - ResponseCookie accessCookie = buildAccessTokenCookie(successResponse.getAccessToken()); - builder.header(HttpHeaders.SET_COOKIE, accessCookie.toString()); - } - return builder.body(ApiResponse.ok(LOGIN_SUCCESS, response)); + return ResponseEntity.ok().body(ApiResponse.ok(LOGIN_SUCCESS, response)); } catch (IllegalArgumentException e) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(ApiResponse.error(AuthErrorCode.INVALID_TOKEN.getStatus().value(), e.getMessage(), null)); @@ -92,14 +63,7 @@ public ResponseEntity login(@RequestBody LoginRequest request) { public ResponseEntity signup(@Valid @RequestBody SignupRequest request) { try { Object response = authService.signup(request); - ResponseEntity.BodyBuilder builder = ResponseEntity.status(HttpStatus.CREATED); - if (response instanceof LoginSuccessResponse successResponse) { - ResponseCookie cookie = buildRefreshTokenCookie(successResponse.getRefreshToken()); - builder.header(HttpHeaders.SET_COOKIE, cookie.toString()); - ResponseCookie accessCookie = buildAccessTokenCookie(successResponse.getAccessToken()); - builder.header(HttpHeaders.SET_COOKIE, accessCookie.toString()); - } - return builder.body(ApiResponse.ok(SIGNUP_SUCCESS, response)); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(SIGNUP_SUCCESS, response)); } catch (IllegalArgumentException e) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), e.getMessage(), null)); @@ -130,16 +94,10 @@ public ResponseEntity> duplicatedPho // 3. 토큰 재발급 (Refresh) @PostMapping("/refresh") - public ResponseEntity refreshAccessToken(@CookieValue(value = "refresh_token", required = false) String refreshToken) { - if (refreshToken == null) { - throw new AuthException(AuthErrorCode.INVALID_COOKIE); - } - + public ResponseEntity refreshAccessToken(@Valid @RequestBody TokenRefreshRequest request) { try { - AuthService.RefreshResult result = authService.refresh(refreshToken); - ResponseCookie accessCookie = buildAccessTokenCookie(result.accessToken()); + AuthService.RefreshResult result = authService.refresh(request.getRefreshToken()); return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, accessCookie.toString()) .body(ApiResponse.ok( ACCESS_TOKEN_REFRESH_SUCCESS, new AccessTokenResponse(result.accessToken(), AuthUserResponse.from(result.user())) @@ -152,14 +110,11 @@ public ResponseEntity refreshAccessToken(@CookieValue(value = "refresh_token" // 4. 로그아웃 @PostMapping("/logout") - public ResponseEntity logout(@CookieValue(value = "refresh_token", required = false) String refreshToken) { - ResponseEntity.BodyBuilder builder = ResponseEntity.ok(); - if (refreshToken != null) { - authService.logout(refreshToken); + public ResponseEntity logout(@RequestBody(required = false) TokenRefreshRequest request) { + if (request != null && StringUtils.hasText(request.getRefreshToken())) { + authService.logout(request.getRefreshToken()); } - builder.header(HttpHeaders.SET_COOKIE, deleteRefreshTokenCookie().toString()); - builder.header(HttpHeaders.SET_COOKIE, deleteAccessTokenCookie().toString()); - return builder.body(ApiResponse.ok(LOGOUT_SUCCESS)); + return ResponseEntity.ok().body(ApiResponse.ok(LOGOUT_SUCCESS)); } // 5. 권한 체크 (Role or Team) @@ -197,63 +152,4 @@ public ResponseEntity logout(@CookieValue(value = "refresh_token", required = null )); } - - private ResponseCookie buildRefreshTokenCookie(String refreshToken) { - if (!StringUtils.hasText(refreshToken)) { - return deleteRefreshTokenCookie(); - } - return baseCookieBuilder(refreshToken) - .maxAge(AuthService.REFRESH_TOKEN_TTL) - .build(); - } - - private ResponseCookie deleteRefreshTokenCookie() { - return baseCookieBuilder("") - .maxAge(Duration.ZERO) - .build(); - } - - private ResponseCookie.ResponseCookieBuilder baseCookieBuilder(String value) { - ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from("refresh_token", value) - .httpOnly(true) - .secure(refreshCookieSecure) - .sameSite(refreshCookieSameSite) - .path(refreshCookiePath); - - if (StringUtils.hasText(refreshCookieDomain)) { - builder.domain(refreshCookieDomain); - } - return builder; - } - - private ResponseCookie buildAccessTokenCookie(String accessToken) { - if (!StringUtils.hasText(accessToken)) { - return deleteAccessTokenCookie(); - } - ResponseCookie.ResponseCookieBuilder builder = baseAccessCookieBuilder(accessToken); - long accessTokenValidity = jwtProperties.getAccessTokenValidity(); - if (accessTokenValidity > 0) { - builder.maxAge(Duration.ofMillis(accessTokenValidity)); - } - return builder.build(); - } - - private ResponseCookie deleteAccessTokenCookie() { - return baseAccessCookieBuilder("") - .maxAge(Duration.ZERO) - .build(); - } - - private ResponseCookie.ResponseCookieBuilder baseAccessCookieBuilder(String value) { - ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from("access_token", value) - .httpOnly(true) - .secure(accessCookieSecure) - .sameSite(accessCookieSameSite) - .path(accessCookiePath); - - if (StringUtils.hasText(accessCookieDomain)) { - builder.domain(accessCookieDomain); - } - return builder; - } } diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/request/TokenRefreshRequest.java b/src/main/java/inha/gdgoc/domain/auth/dto/request/TokenRefreshRequest.java new file mode 100644 index 00000000..f0b3286d --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/request/TokenRefreshRequest.java @@ -0,0 +1,12 @@ +package inha.gdgoc.domain.auth.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class TokenRefreshRequest { + @NotBlank + private String refreshToken; +} diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/response/LoginSuccessResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/LoginSuccessResponse.java index e98de39a..f6da3d6a 100644 --- a/src/main/java/inha/gdgoc/domain/auth/dto/response/LoginSuccessResponse.java +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/LoginSuccessResponse.java @@ -12,7 +12,6 @@ public class LoginSuccessResponse { private boolean isNewUser; private String accessToken; private AuthUserResponse user; - @JsonIgnore private String refreshToken; public static LoginSuccessResponse of(TokenDto tokens, AuthUserResponse user) { diff --git a/src/main/java/inha/gdgoc/global/security/TokenAuthenticationFilter.java b/src/main/java/inha/gdgoc/global/security/TokenAuthenticationFilter.java index 17286146..f356c68c 100644 --- a/src/main/java/inha/gdgoc/global/security/TokenAuthenticationFilter.java +++ b/src/main/java/inha/gdgoc/global/security/TokenAuthenticationFilter.java @@ -76,9 +76,9 @@ private String getAccessToken(HttpServletRequest request) { final String HEADER_AUTHORIZATION = "Authorization"; final String TOKEN_PREFIX = "Bearer "; - String cookieToken = readCookieToken(request, "access_token"); - if (cookieToken != null) { - return sanitizeToken(cookieToken.trim()); + String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION); + if (authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX)) { + return sanitizeToken(authorizationHeader.substring(TOKEN_PREFIX.length()).trim()); } return null; From e6b7546cd0d66fa3935e585eea7043ab3cd896e2 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 12 Feb 2026 02:30:18 +0900 Subject: [PATCH 37/49] fix(recruit): unify date format in RecruitMember entity --- .../inha/gdgoc/domain/recruit/member/entity/RecruitMember.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/entity/RecruitMember.java b/src/main/java/inha/gdgoc/domain/recruit/member/entity/RecruitMember.java index 28292aff..29cfb34b 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/entity/RecruitMember.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/entity/RecruitMember.java @@ -61,7 +61,7 @@ public class RecruitMember extends BaseEntity { @Column(name = "gender", nullable = false) private Gender gender; - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy.MM.dd") @Column(name = "birth", nullable = false) private LocalDate birth; From 014bd47c9c3049d07765657320ae76891a02db03 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 12 Feb 2026 02:30:18 +0900 Subject: [PATCH 38/49] refactor(config): cleanup JPA and SpringDoc configuration to resolve warnings --- src/main/resources/application-dev.yml | 20 +++++++------------- src/main/resources/application-local.yml | 19 +++++++------------ src/main/resources/application-prod.yml | 19 ++++++------------- src/main/resources/application.yml | 2 ++ src/main/resources/db/migration/.gitkeep | 0 5 files changed, 22 insertions(+), 38 deletions(-) create mode 100644 src/main/resources/db/migration/.gitkeep diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 3a3f9c55..4f4437b5 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -22,11 +22,10 @@ spring: enabled: true locations: classpath:db/migration validate-migration-naming: true + ignore-migration-patterns: jackson: time-zone: Asia/Seoul jpa: - database: postgresql - database-platform: org.hibernate.dialect.PostgreSQLDialect hibernate: ddl-auto: none properties: @@ -54,20 +53,15 @@ spring: resources: add-mappings: false +springdoc: + api-docs: + enabled: false + swagger-ui: + enabled: false + app: s3: bucket: ${AWS_TEST_RESOURCE_BUCKET} - auth: - refresh-cookie: - secure: ${REFRESH_COOKIE_SECURE:false} - same-site: ${REFRESH_COOKIE_SAME_SITE:Lax} - domain: ${REFRESH_COOKIE_DOMAIN:} - path: / - access-cookie: - secure: ${ACCESS_COOKIE_SECURE:false} - same-site: ${ACCESS_COOKIE_SAME_SITE:Lax} - domain: ${ACCESS_COOKIE_DOMAIN:} - path: / google: client-id: ${GOOGLE_CLIENT_ID} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index e2144cb6..57fc3cdc 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -23,10 +23,10 @@ spring: enabled: true locations: classpath:db/migration schemas: public + ignore-migration-patterns: jackson: time-zone: Asia/Seoul jpa: - database: postgresql hibernate: ddl-auto: none properties: @@ -53,20 +53,15 @@ spring: resources: add-mappings: false +springdoc: + api-docs: + enabled: false + swagger-ui: + enabled: false + app: s3: bucket: ${AWS_TEST_RESOURCE_BUCKET} - auth: - refresh-cookie: - secure: ${REFRESH_COOKIE_SECURE:false} - same-site: ${REFRESH_COOKIE_SAME_SITE:Lax} - domain: ${REFRESH_COOKIE_DOMAIN:} - path: / - access-cookie: - secure: ${ACCESS_COOKIE_SECURE:false} - same-site: ${ACCESS_COOKIE_SAME_SITE:Lax} - domain: ${ACCESS_COOKIE_DOMAIN:} - path: / google: client-id: ${GOOGLE_CLIENT_ID} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 5ab73b2d..510846a4 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -25,8 +25,6 @@ spring: jackson: time-zone: Asia/Seoul jpa: - database: postgresql - database-platform: org.hibernate.dialect.PostgreSQLDialect hibernate: ddl-auto: none properties: @@ -54,20 +52,15 @@ spring: resources: add-mappings: false +springdoc: + api-docs: + enabled: false + swagger-ui: + enabled: false + app: s3: bucket: ${AWS_RESOURCE_BUCKET} - auth: - refresh-cookie: - secure: ${REFRESH_COOKIE_SECURE:true} - same-site: ${REFRESH_COOKIE_SAME_SITE:None} - domain: ${REFRESH_COOKIE_DOMAIN:} - path: / - access-cookie: - secure: ${ACCESS_COOKIE_SECURE:true} - same-site: ${ACCESS_COOKIE_SAME_SITE:None} - domain: ${ACCESS_COOKIE_DOMAIN:} - path: / google: client-id: ${GOOGLE_CLIENT_ID} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4f2496ab..73c22704 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,8 @@ spring: profiles: active: local + jpa: + open-in-view: false data: redis: host: ${REDIS_HOST:127.0.0.1} diff --git a/src/main/resources/db/migration/.gitkeep b/src/main/resources/db/migration/.gitkeep new file mode 100644 index 00000000..e69de29b From 1bde7e4022b2d4f3aaef83e07309bc37d59b4162 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 12 Feb 2026 02:42:10 +0900 Subject: [PATCH 39/49] fix(security): update CORS configuration to allow Authorization header --- src/main/java/inha/gdgoc/global/security/SecurityConfig.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/inha/gdgoc/global/security/SecurityConfig.java b/src/main/java/inha/gdgoc/global/security/SecurityConfig.java index 56e7da24..e5eb10f1 100644 --- a/src/main/java/inha/gdgoc/global/security/SecurityConfig.java +++ b/src/main/java/inha/gdgoc/global/security/SecurityConfig.java @@ -102,8 +102,8 @@ public CorsConfigurationSource corsConfigurationSource() { "https://*.gdgocinha.com" )); config.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS","PATCH")); - config.setAllowedHeaders(List.of("Origin","X-Requested-With","Content-Type","Accept")); - config.setExposedHeaders(List.of("Set-Cookie")); // 필요시 노출 + config.setAllowedHeaders(List.of("Origin","X-Requested-With","Content-Type","Accept","Authorization")); + config.setExposedHeaders(List.of()); // 필요시 노출 config.setAllowCredentials(true); config.setMaxAge(3600L); // 프리플라이트 캐시 From c498cc507169db1d39601f05379e0e554c3f0712 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 12 Feb 2026 02:42:10 +0900 Subject: [PATCH 40/49] fix(auth): require refresh token for logout and return 400 if missing --- .../inha/gdgoc/domain/auth/controller/AuthController.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java index 5538c8b0..0dec1741 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java @@ -111,9 +111,12 @@ public ResponseEntity refreshAccessToken(@Valid @RequestBody TokenRefreshRequ // 4. 로그아웃 @PostMapping("/logout") public ResponseEntity logout(@RequestBody(required = false) TokenRefreshRequest request) { - if (request != null && StringUtils.hasText(request.getRefreshToken())) { - authService.logout(request.getRefreshToken()); + if (request == null || !StringUtils.hasText(request.getRefreshToken())) { + log.warn("로그아웃 실패: 요청 바디에 리프레시 토큰이 누락되었습니다."); + return ResponseEntity.badRequest() + .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "리프레시 토큰은 필수입니다.", null)); } + authService.logout(request.getRefreshToken()); return ResponseEntity.ok().body(ApiResponse.ok(LOGOUT_SUCCESS)); } From 35f845a86c30908cc73434d0e2048245cb66a28c Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 12 Feb 2026 02:42:10 +0900 Subject: [PATCH 41/49] refactor(config): cleanup springdoc and jpa settings in dev/local profiles --- src/main/resources/application-dev.yml | 2 +- src/main/resources/application-local.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 4f4437b5..2f65e04c 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -22,7 +22,7 @@ spring: enabled: true locations: classpath:db/migration validate-migration-naming: true - ignore-migration-patterns: + ignore-migration-patterns: future jackson: time-zone: Asia/Seoul jpa: diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 57fc3cdc..4ead099d 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -23,7 +23,7 @@ spring: enabled: true locations: classpath:db/migration schemas: public - ignore-migration-patterns: + ignore-migration-patterns: future jackson: time-zone: Asia/Seoul jpa: From cfb3f41192fc91906f09949559261bf6f33538d0 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 12 Feb 2026 02:48:46 +0900 Subject: [PATCH 42/49] refactor(config): cleanup springdoc and jpa settings in dev/local profiles --- src/main/resources/application-dev.yml | 2 +- src/main/resources/application-local.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 2f65e04c..692be625 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -22,7 +22,7 @@ spring: enabled: true locations: classpath:db/migration validate-migration-naming: true - ignore-migration-patterns: future + ignore-migration-patterns: *:future jackson: time-zone: Asia/Seoul jpa: diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 4ead099d..de68752d 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -23,7 +23,7 @@ spring: enabled: true locations: classpath:db/migration schemas: public - ignore-migration-patterns: future + ignore-migration-patterns: *:future jackson: time-zone: Asia/Seoul jpa: From fd7803e56a301806b631ce3729bc4b2b439e2cb3 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 12 Feb 2026 03:01:21 +0900 Subject: [PATCH 43/49] refactor(config): cleanup springdoc and jpa settings in dev/local profiles --- src/main/resources/application-dev.yml | 2 +- src/main/resources/application-local.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 692be625..9a9ab2db 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -22,7 +22,7 @@ spring: enabled: true locations: classpath:db/migration validate-migration-naming: true - ignore-migration-patterns: *:future + ignore-migration-patterns: "*:missing,*:future" jackson: time-zone: Asia/Seoul jpa: diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index de68752d..64b37826 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -23,7 +23,7 @@ spring: enabled: true locations: classpath:db/migration schemas: public - ignore-migration-patterns: *:future + ignore-migration-patterns: "*:missing,*:future" jackson: time-zone: Asia/Seoul jpa: From 2a748eedc58661fb09ee28ad2090547140905920 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 12 Feb 2026 04:10:01 +0900 Subject: [PATCH 44/49] fix(auth): rename access_token to accessToken for consistency --- .../gdgoc/domain/auth/dto/response/AccessTokenResponse.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/response/AccessTokenResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/AccessTokenResponse.java index 37b4c3c2..b445b765 100644 --- a/src/main/java/inha/gdgoc/domain/auth/dto/response/AccessTokenResponse.java +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/AccessTokenResponse.java @@ -5,11 +5,11 @@ @Getter public class AccessTokenResponse extends BaseEntity { - private final String access_token; + private final String accessToken; private final AuthUserResponse user; public AccessTokenResponse(String accessToken, AuthUserResponse user) { - this.access_token = accessToken; + this.accessToken = accessToken; this.user = user; } } From e347e75ec0721ccc3c4c2aba5081bde44302db77 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 12 Feb 2026 04:33:11 +0900 Subject: [PATCH 45/49] fix(recruit): normalize phone numbers by removing hyphens before DB check and save --- .../recruit/member/dto/request/RecruitMemberRequest.java | 3 ++- .../domain/recruit/member/service/RecruitMemberService.java | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/RecruitMemberRequest.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/RecruitMemberRequest.java index fcb38363..b11ba7b0 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/RecruitMemberRequest.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/RecruitMemberRequest.java @@ -31,12 +31,13 @@ public class RecruitMemberRequest { private Boolean isPayed; public RecruitMember toEntity(AdmissionSemester admissionSemester) { + String cleanPhone = phoneNumber.replaceAll("[^0-9]", ""); return RecruitMember.builder() .name(name) .grade(grade) .studentId(studentId) .enrolledClassification(EnrolledClassification.fromStatus(enrolledClassification)) - .phoneNumber(phoneNumber) + .phoneNumber(cleanPhone) .nationality(nationality) .email(email) .gender(Gender.fromType(gender)) diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java b/src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java index 2595679a..f1ee9c06 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java @@ -60,7 +60,8 @@ public CheckStudentIdResponse isRegisteredStudentId(String studentId) { } public CheckPhoneNumberResponse isRegisteredPhoneNumber(String phoneNumber) { - boolean exists = recruitMemberRepository.existsByPhoneNumber(phoneNumber); + String cleanPhone = phoneNumber.replaceAll("[^0-9]", ""); + boolean exists = recruitMemberRepository.existsByPhoneNumber(cleanPhone); return new CheckPhoneNumberResponse(exists); } From 5641d870feb1bda7ed469ef1edf6564e42df02b4 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 12 Feb 2026 04:45:36 +0900 Subject: [PATCH 46/49] =?UTF-8?q?fix:=20=EC=A0=84=ED=99=94=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/inha/gdgoc/domain/auth/controller/AuthController.java | 2 +- .../recruit/member/controller/RecruitMemberController.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java index 0dec1741..29d82fa5 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java @@ -85,7 +85,7 @@ public ResponseEntity> duplicatedStude public ResponseEntity> duplicatedPhoneNumberDetails( @RequestParam @NotBlank(message = "전화번호는 필수 입력 값입니다.") - @Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "전화번호 형식은 010-XXXX-XXXX 이어야 합니다.") + @Pattern(regexp = "^010-?\\d{4}-?\\d{4}$", message = "전화번호 형식은 010-XXXX-XXXX 또는 010XXXXXXXX 이어야 합니다.") String phoneNumber ) { CheckPhoneNumberResponse response = authService.isRegisteredPhoneNumber(phoneNumber); diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java b/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java index 1699b1bb..6acee986 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java @@ -98,7 +98,7 @@ public ResponseEntity> duplicatedStude public ResponseEntity> duplicatedPhoneNumberDetails( @RequestParam @NotBlank(message = "전화번호는 필수 입력 값입니다.") - @Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "전화번호 형식은 010-XXXX-XXXX 이어야 합니다.") + @Pattern(regexp = "^010-?\\d{4}-?\\d{4}$", message = "전화번호 형식은 010-XXXX-XXXX 또는 010XXXXXXXX 이어야 합니다.") String phoneNumber ) { CheckPhoneNumberResponse response = recruitMemberService From b7703375ba7f8b3358d6cc9280267c05dd5c5d1c Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:14:20 +0900 Subject: [PATCH 47/49] refactor(recruit-member): remove unused doubleMajor field --- .../recruit/member/dto/request/RecruitMemberRequest.java | 2 -- .../inha/gdgoc/domain/recruit/member/entity/RecruitMember.java | 3 --- .../gdgoc/domain/recruit/service/RecruitMemberServiceTest.java | 2 -- 3 files changed, 7 deletions(-) diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/RecruitMemberRequest.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/RecruitMemberRequest.java index b11ba7b0..6b53560e 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/RecruitMemberRequest.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/RecruitMemberRequest.java @@ -27,7 +27,6 @@ public class RecruitMemberRequest { @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy.MM.dd") private LocalDate birth; private String major; - private String doubleMajor; private Boolean isPayed; public RecruitMember toEntity(AdmissionSemester admissionSemester) { @@ -43,7 +42,6 @@ public RecruitMember toEntity(AdmissionSemester admissionSemester) { .gender(Gender.fromType(gender)) .birth(birth) .major(major) - .doubleMajor(doubleMajor) .isPayed(false) .admissionSemester(admissionSemester) .build(); diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/entity/RecruitMember.java b/src/main/java/inha/gdgoc/domain/recruit/member/entity/RecruitMember.java index 29cfb34b..6b8c9031 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/entity/RecruitMember.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/entity/RecruitMember.java @@ -68,9 +68,6 @@ public class RecruitMember extends BaseEntity { @Column(name = "major", nullable = false) private String major; - @Column(name = "double_major") - private String doubleMajor; - @Column(name = "is_payed", nullable = false) private Boolean isPayed; diff --git a/src/test/java/inha/gdgoc/domain/recruit/service/RecruitMemberServiceTest.java b/src/test/java/inha/gdgoc/domain/recruit/service/RecruitMemberServiceTest.java index f9b57d5c..4d984dd0 100644 --- a/src/test/java/inha/gdgoc/domain/recruit/service/RecruitMemberServiceTest.java +++ b/src/test/java/inha/gdgoc/domain/recruit/service/RecruitMemberServiceTest.java @@ -29,7 +29,6 @@ void addMember_ShouldSaveRecruitMemberAndAnswers() { .gender("여성") .birth(LocalDate.of(2002, 8, 18)) .major("컴퓨터공학과") - .doubleMajor("복수전공 인공지능공학과") .isPayed(true) .build(); @@ -57,7 +56,6 @@ void addMember_ShouldSaveRecruitMemberAndAnswers() { .gender(Gender.FEMALE) .birth(LocalDate.of(2002, 8, 18)) .major("컴퓨터공학과") - .doubleMajor("복수전공 인공지능공학과") .isPayed(true) .build(); From ab541326243feefbc9165eef9aadd9ade87d67cd Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:14:24 +0900 Subject: [PATCH 48/49] feat(recruit): align repository queries and add postgres indexes --- database_schema.sql | 13 ++++++++++- .../RecruitCoreApplicationRepository.java | 4 ++-- .../RecruitCoreApplicationService.java | 11 +++++----- .../repository/RecruitMemberRepository.java | 2 +- .../member/service/RecruitMemberService.java | 2 +- .../V2__recruit_auth_api_indexes.sql | 22 +++++++++++++++++++ .../service/RecruitCoreAdminServiceTest.java | 2 -- .../RecruitCoreApplicationServiceTest.java | 14 +++++------- 8 files changed, 50 insertions(+), 20 deletions(-) create mode 100644 src/main/resources/db/migration/V2__recruit_auth_api_indexes.sql diff --git a/database_schema.sql b/database_schema.sql index f19377ce..b7d3a764 100644 --- a/database_schema.sql +++ b/database_schema.sql @@ -19,6 +19,9 @@ CREATE TABLE IF NOT EXISTS users ( created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE INDEX IF NOT EXISTS idx_users_student_id ON users(student_id); +CREATE INDEX IF NOT EXISTS idx_users_phone_number ON users(phone_number); +CREATE INDEX IF NOT EXISTS idx_users_email_lower ON users((lower(email))); -- 2. 리크루팅 멤버 (recruit_member) CREATE TABLE IF NOT EXISTS recruit_member ( @@ -33,12 +36,14 @@ CREATE TABLE IF NOT EXISTS recruit_member ( gender VARCHAR(20) NOT NULL, birth DATE NOT NULL, major VARCHAR(255) NOT NULL, - double_major VARCHAR(255), is_payed BOOLEAN NOT NULL DEFAULT FALSE, admission_semester VARCHAR(10) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE INDEX IF NOT EXISTS idx_recruit_member_email_lower ON recruit_member((lower(email))); +CREATE INDEX IF NOT EXISTS idx_recruit_member_created_at ON recruit_member(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_recruit_member_name_lower ON recruit_member((lower(name))); -- 3. 답변 (answer) - recruit_member와 1:N CREATE TABLE IF NOT EXISTS answer ( @@ -50,6 +55,8 @@ CREATE TABLE IF NOT EXISTS answer ( created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE INDEX IF NOT EXISTS idx_answer_recruit_member_survey_type + ON answer(recruit_member, survey_type); -- 4. 코어 멤버 지원 (core_recruit_applications) CREATE TABLE IF NOT EXISTS core_recruit_applications ( @@ -74,6 +81,10 @@ CREATE TABLE IF NOT EXISTS core_recruit_applications ( created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE INDEX IF NOT EXISTS idx_core_recruit_user_session + ON core_recruit_applications(user_id, session); +CREATE INDEX IF NOT EXISTS idx_core_recruit_session_status_team_created + ON core_recruit_applications(session, result_status, team, created_at DESC); -- 5. 스터디 (study) CREATE TABLE IF NOT EXISTS study ( diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/repository/RecruitCoreApplicationRepository.java b/src/main/java/inha/gdgoc/domain/recruit/core/repository/RecruitCoreApplicationRepository.java index 1ae4b873..cab8f3fe 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/core/repository/RecruitCoreApplicationRepository.java +++ b/src/main/java/inha/gdgoc/domain/recruit/core/repository/RecruitCoreApplicationRepository.java @@ -11,7 +11,7 @@ public interface RecruitCoreApplicationRepository extends JpaRepository { Page findByNameContainingIgnoreCase(String name, Pageable pageable); - java.util.Optional findByUser_IdAndSession(Long userId, String session); + java.util.Optional findByUserIdAndSession(Long userId, String session); - java.util.Optional findByIdAndUser_Id(Long id, Long userId); + java.util.Optional findByIdAndUserId(Long id, Long userId); } diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java b/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java index 5e6240fc..ee71ba88 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java +++ b/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java @@ -39,7 +39,7 @@ public RecruitCoreApplicantDetailResponse getApplicantDetail(Long id) { @Transactional(readOnly = true) public RecruitCoreEligibilityResponse checkEligibility(Long userId) { String session = recruitCoreSessionResolver.currentSession(); - return repository.findByUser_IdAndSession(userId, session) + return repository.findByUserIdAndSession(userId, session) .map(app -> RecruitCoreEligibilityResponse.ineligible(session, "ALREADY_APPLIED", app.getId())) .orElseGet(() -> RecruitCoreEligibilityResponse.eligible(session)); } @@ -47,7 +47,7 @@ public RecruitCoreEligibilityResponse checkEligibility(Long userId) { @Transactional(readOnly = true) public RecruitCorePrefillResponse prefill(Long userId) { String session = recruitCoreSessionResolver.currentSession(); - repository.findByUser_IdAndSession(userId, session) + repository.findByUserIdAndSession(userId, session) .ifPresent(existing -> { throw new RecruitCoreAlreadyAppliedException(session, existing.getId()); }); @@ -59,7 +59,7 @@ public RecruitCorePrefillResponse prefill(Long userId) { @Transactional public RecruitCoreApplicationCreateResponse submit(Long userId, RecruitCoreApplicationCreateRequest request) { String session = recruitCoreSessionResolver.currentSession(); - repository.findByUser_IdAndSession(userId, session) + repository.findByUserIdAndSession(userId, session) .ifPresent(existing -> { throw new RecruitCoreAlreadyAppliedException(session, existing.getId()); }); @@ -68,12 +68,13 @@ public RecruitCoreApplicationCreateResponse submit(Long userId, RecruitCoreAppli List fileUrls = request.fileUrls() == null ? List.of() : List.copyOf(request.fileUrls()); + String cleanPhone = request.snapshot().phone().replaceAll("[^0-9]", ""); RecruitCoreApplication application = RecruitCoreApplication.builder() .user(user) .session(session) .name(request.snapshot().name()) .studentId(request.snapshot().studentId()) - .phone(request.snapshot().phone()) + .phone(cleanPhone) .major(request.snapshot().major()) .email(request.snapshot().email()) .team(request.team()) @@ -92,7 +93,7 @@ public RecruitCoreApplicationCreateResponse submit(Long userId, RecruitCoreAppli @Transactional(readOnly = true) public RecruitCoreMyApplicationResponse getMyApplication(Long userId) { String session = recruitCoreSessionResolver.currentSession(); - RecruitCoreApplication application = repository.findByUser_IdAndSession(userId, session) + RecruitCoreApplication application = repository.findByUserIdAndSession(userId, session) .orElseThrow(RecruitCoreApplicationNotFoundException::new); return RecruitCoreMyApplicationResponse.from(application); } diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/repository/RecruitMemberRepository.java b/src/main/java/inha/gdgoc/domain/recruit/member/repository/RecruitMemberRepository.java index 296c463f..b6d013c2 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/repository/RecruitMemberRepository.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/repository/RecruitMemberRepository.java @@ -8,6 +8,6 @@ public interface RecruitMemberRepository extends JpaRepository { boolean existsByStudentId(String studentId); boolean existsByPhoneNumber(String phoneNumber); - boolean existsByEmail(String email); + boolean existsByEmailIgnoreCase(String email); Page findByNameContainingIgnoreCase(String name, Pageable pageable); } diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java b/src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java index f1ee9c06..cefa022b 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java @@ -67,7 +67,7 @@ public CheckPhoneNumberResponse isRegisteredPhoneNumber(String phoneNumber) { } public CheckEmailResponse isRegisteredEmail(String email) { - boolean exists = recruitMemberRepository.existsByEmail(email); + boolean exists = recruitMemberRepository.existsByEmailIgnoreCase(email.trim()); return new CheckEmailResponse(exists); } diff --git a/src/main/resources/db/migration/V2__recruit_auth_api_indexes.sql b/src/main/resources/db/migration/V2__recruit_auth_api_indexes.sql new file mode 100644 index 00000000..a20c6d9b --- /dev/null +++ b/src/main/resources/db/migration/V2__recruit_auth_api_indexes.sql @@ -0,0 +1,22 @@ +-- recruit/core, recruit/member, login, signup API hot-path indexes +-- Safe additions only (no destructive DDL). + +-- auth/login, signup duplicate checks +CREATE INDEX IF NOT EXISTS idx_users_student_id ON users(student_id); +CREATE INDEX IF NOT EXISTS idx_users_phone_number ON users(phone_number); +CREATE INDEX IF NOT EXISTS idx_users_email_lower ON users((lower(email))); + +-- recruit/member duplicate checks + admin list/search +CREATE INDEX IF NOT EXISTS idx_recruit_member_email_lower ON recruit_member((lower(email))); +CREATE INDEX IF NOT EXISTS idx_recruit_member_created_at ON recruit_member(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_recruit_member_name_lower ON recruit_member((lower(name))); + +-- recruit/member detail answers lookup +CREATE INDEX IF NOT EXISTS idx_answer_recruit_member_survey_type + ON answer(recruit_member, survey_type); + +-- recruit/core user/session lookup + admin filtering +CREATE INDEX IF NOT EXISTS idx_core_recruit_user_session + ON core_recruit_applications(user_id, session); +CREATE INDEX IF NOT EXISTS idx_core_recruit_session_status_team_created + ON core_recruit_applications(session, result_status, team, created_at DESC); diff --git a/src/test/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminServiceTest.java b/src/test/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminServiceTest.java index 541f8e41..853ebf97 100644 --- a/src/test/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminServiceTest.java +++ b/src/test/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminServiceTest.java @@ -135,10 +135,8 @@ private User createUser(Long id) { .studentId("12201234") .phoneNumber("01012345678") .email("hong@inha.edu") - .password("encoded") .userRole(UserRole.GUEST) .team(null) - .salt(new byte[]{1}) .image(null) .social(null) .careers(null) diff --git a/src/test/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationServiceTest.java b/src/test/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationServiceTest.java index 36fe58c4..b42a7a9d 100644 --- a/src/test/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationServiceTest.java +++ b/src/test/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationServiceTest.java @@ -59,7 +59,7 @@ void setUp() { @Test void checkEligibility_whenNoApplication_returnsEligible() { - when(repository.findByUser_IdAndSession(1L, SESSION)).thenReturn(Optional.empty()); + when(repository.findByUserIdAndSession(1L, SESSION)).thenReturn(Optional.empty()); RecruitCoreEligibilityResponse response = service.checkEligibility(1L); @@ -71,7 +71,7 @@ void checkEligibility_whenNoApplication_returnsEligible() { @Test void checkEligibility_whenApplicationExists_returnsIneligible() { RecruitCoreApplication existing = createApplication(10L, createUser(1L), SESSION); - when(repository.findByUser_IdAndSession(1L, SESSION)).thenReturn(Optional.of(existing)); + when(repository.findByUserIdAndSession(1L, SESSION)).thenReturn(Optional.of(existing)); RecruitCoreEligibilityResponse response = service.checkEligibility(1L); @@ -85,7 +85,7 @@ void submit_whenEligible_savesApplication() { RecruitCoreApplicationCreateRequest request = sampleRequest(); User user = createUser(1L); RecruitCoreApplication saved = createApplication(55L, user, SESSION); - when(repository.findByUser_IdAndSession(1L, SESSION)).thenReturn(Optional.empty()); + when(repository.findByUserIdAndSession(1L, SESSION)).thenReturn(Optional.empty()); when(userRepository.findById(1L)).thenReturn(Optional.of(user)); when(repository.save(any())).thenReturn(saved); @@ -109,7 +109,7 @@ void submit_whenEligible_savesApplication() { @Test void submit_whenAlreadyApplied_throwsException() { RecruitCoreApplication existing = createApplication(77L, createUser(1L), SESSION); - when(repository.findByUser_IdAndSession(1L, SESSION)).thenReturn(Optional.of(existing)); + when(repository.findByUserIdAndSession(1L, SESSION)).thenReturn(Optional.of(existing)); assertThatThrownBy(() -> service.submit(1L, sampleRequest())) .isInstanceOf(RecruitCoreAlreadyAppliedException.class); @@ -118,7 +118,7 @@ void submit_whenAlreadyApplied_throwsException() { @Test void getMyApplication_whenExists_returnsResponse() { RecruitCoreApplication existing = createApplication(33L, createUser(1L), SESSION); - when(repository.findByUser_IdAndSession(1L, SESSION)).thenReturn(Optional.of(existing)); + when(repository.findByUserIdAndSession(1L, SESSION)).thenReturn(Optional.of(existing)); RecruitCoreMyApplicationResponse response = service.getMyApplication(1L); @@ -129,7 +129,7 @@ void getMyApplication_whenExists_returnsResponse() { @Test void getMyApplication_whenMissing_throwsException() { - when(repository.findByUser_IdAndSession(1L, SESSION)).thenReturn(Optional.empty()); + when(repository.findByUserIdAndSession(1L, SESSION)).thenReturn(Optional.empty()); assertThatThrownBy(() -> service.getMyApplication(1L)) .isInstanceOf(RecruitCoreApplicationNotFoundException.class); @@ -187,10 +187,8 @@ private User createUser(Long id) { .studentId("12201234") .phoneNumber("01012345678") .email("hong@inha.edu") - .password("encoded") .userRole(UserRole.GUEST) .team(null) - .salt(new byte[]{1}) .image(null) .social(null) .careers(null) From 4f5d49d35715382508b2dedfa2cdaa78d0a261e1 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:16:20 +0900 Subject: [PATCH 49/49] chore: commit all remaining backend changes --- .../auth/controller/AuthController.java | 51 +++++-------------- .../dto/request/CheckPhoneNumberRequest.java | 15 ++++++ .../dto/request/CheckStudentIdRequest.java | 15 ++++++ .../domain/auth/service/AuthService.java | 20 ++++++++ .../controller/CoreAttendanceController.java | 46 ++++++----------- .../service/CoreAttendanceService.java | 43 +++++++++++++++- .../controller/RecruitMemberController.java | 33 +++++------- .../member/dto/request/CheckEmailRequest.java | 15 ++++++ .../dto/request/CheckPhoneNumberRequest.java | 2 +- .../dto/request/CheckStudentIdRequest.java | 15 ++++++ .../controller/ResourceController.java | 27 ++-------- .../resource/exception/ResourceErrorCode.java | 3 +- .../resource/service/ResourceService.java | 38 ++++++++++++++ 13 files changed, 209 insertions(+), 114 deletions(-) create mode 100644 src/main/java/inha/gdgoc/domain/auth/dto/request/CheckPhoneNumberRequest.java create mode 100644 src/main/java/inha/gdgoc/domain/auth/dto/request/CheckStudentIdRequest.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckEmailRequest.java create mode 100644 src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckStudentIdRequest.java create mode 100644 src/main/java/inha/gdgoc/domain/resource/service/ResourceService.java diff --git a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java index 29d82fa5..05f2c3d8 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java @@ -1,5 +1,9 @@ package inha.gdgoc.domain.auth.controller; +import static inha.gdgoc.domain.auth.controller.message.AuthMessage.*; + +import inha.gdgoc.domain.auth.dto.request.CheckPhoneNumberRequest; +import inha.gdgoc.domain.auth.dto.request.CheckStudentIdRequest; import inha.gdgoc.domain.auth.dto.request.LoginRequest; import inha.gdgoc.domain.auth.dto.request.SignupRequest; import inha.gdgoc.domain.auth.dto.request.TokenRefreshRequest; @@ -7,34 +11,22 @@ import inha.gdgoc.domain.auth.dto.response.AuthUserResponse; import inha.gdgoc.domain.auth.dto.response.CheckPhoneNumberResponse; import inha.gdgoc.domain.auth.dto.response.CheckStudentIdResponse; -import inha.gdgoc.domain.auth.dto.response.LoginSuccessResponse; import inha.gdgoc.domain.auth.exception.AuthErrorCode; import inha.gdgoc.domain.auth.exception.AuthException; import inha.gdgoc.domain.auth.service.AuthService; import inha.gdgoc.domain.user.enums.TeamType; import inha.gdgoc.domain.user.enums.UserRole; -import inha.gdgoc.global.config.jwt.JwtProperties; import inha.gdgoc.global.config.jwt.TokenProvider; import inha.gdgoc.global.dto.response.ApiResponse; import inha.gdgoc.global.exception.GlobalErrorCode; -import inha.gdgoc.global.security.AccessGuard; import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; import org.springframework.util.StringUtils; - -import java.time.Duration; - -import static inha.gdgoc.domain.auth.controller.message.AuthMessage.*; +import org.springframework.web.bind.annotation.*; @Slf4j @RequestMapping("/api/v1/auth") @@ -43,8 +35,6 @@ public class AuthController { private final AuthService authService; - private final AccessGuard accessGuard; - private final JwtProperties jwtProperties; // 1. 구글 로그인 (ID Token 검증) @PostMapping("/login") @@ -70,25 +60,19 @@ public ResponseEntity signup(@Valid @RequestBody SignupRequest request) { } } - @GetMapping("/check/student-id") + @PostMapping("/check/student-id") public ResponseEntity> duplicatedStudentIdDetails( - @RequestParam - @NotBlank(message = "학번은 필수 입력 값입니다.") - @Pattern(regexp = "^12[0-9]{6}$", message = "유효하지 않은 학번 값입니다.") - String studentId + @Valid @RequestBody CheckStudentIdRequest request ) { - CheckStudentIdResponse response = authService.isRegisteredStudentId(studentId); + CheckStudentIdResponse response = authService.isRegisteredStudentId(request.getStudentId()); return ResponseEntity.ok(ApiResponse.ok(STUDENT_ID_DUPLICATION_CHECK_SUCCESS, response)); } - @GetMapping("/check/phone-number") + @PostMapping("/check/phone-number") public ResponseEntity> duplicatedPhoneNumberDetails( - @RequestParam - @NotBlank(message = "전화번호는 필수 입력 값입니다.") - @Pattern(regexp = "^010-?\\d{4}-?\\d{4}$", message = "전화번호 형식은 010-XXXX-XXXX 또는 010XXXXXXXX 이어야 합니다.") - String phoneNumber + @Valid @RequestBody CheckPhoneNumberRequest request ) { - CheckPhoneNumberResponse response = authService.isRegisteredPhoneNumber(phoneNumber); + CheckPhoneNumberResponse response = authService.isRegisteredPhoneNumber(request.getPhoneNumber()); return ResponseEntity.ok(ApiResponse.ok(PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS, response)); } @@ -135,16 +119,7 @@ public ResponseEntity logout(@RequestBody(required = false) TokenRefreshReque null )); } - - var conditions = new java.util.ArrayList(); - conditions.add(AccessGuard.AccessCondition.atLeast(role)); - - if (requiredTeam != null) { - conditions.add(AccessGuard.AccessCondition.atLeast(UserRole.ORGANIZER)); - conditions.add(AccessGuard.AccessCondition.of(UserRole.GUEST, requiredTeam)); - } - - if (accessGuard.check(me, conditions.toArray(AccessGuard.AccessCondition[]::new))) { + if (authService.hasRequiredAccess(me, role, requiredTeam)) { return ResponseEntity.ok(ApiResponse.ok("ROLE_OR_TEAM_CHECK_PASSED", null)); } @@ -154,5 +129,5 @@ public ResponseEntity logout(@RequestBody(required = false) TokenRefreshReque GlobalErrorCode.FORBIDDEN_USER.getMessage(), null )); -} + } } diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/request/CheckPhoneNumberRequest.java b/src/main/java/inha/gdgoc/domain/auth/dto/request/CheckPhoneNumberRequest.java new file mode 100644 index 00000000..8cc0a1e8 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/request/CheckPhoneNumberRequest.java @@ -0,0 +1,15 @@ +package inha.gdgoc.domain.auth.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CheckPhoneNumberRequest { + + @NotBlank(message = "전화번호는 필수 입력 값입니다.") + @Pattern(regexp = "^010-?\\d{4}-?\\d{4}$", message = "전화번호 형식은 010-XXXX-XXXX 또는 010XXXXXXXX 이어야 합니다.") + private String phoneNumber; +} diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/request/CheckStudentIdRequest.java b/src/main/java/inha/gdgoc/domain/auth/dto/request/CheckStudentIdRequest.java new file mode 100644 index 00000000..afea5191 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/request/CheckStudentIdRequest.java @@ -0,0 +1,15 @@ +package inha.gdgoc.domain.auth.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CheckStudentIdRequest { + + @NotBlank(message = "학번은 필수 입력 값입니다.") + @Pattern(regexp = "^12[0-9]{6}$", message = "유효하지 않은 학번 값입니다.") + private String studentId; +} diff --git a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java index 36b14136..3cdded81 100644 --- a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java +++ b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java @@ -13,8 +13,12 @@ import inha.gdgoc.domain.auth.dto.response.SignupNeededResponse; import inha.gdgoc.domain.auth.dto.response.TokenDto; import inha.gdgoc.domain.user.entity.User; +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; import inha.gdgoc.domain.user.repository.UserRepository; import inha.gdgoc.global.config.jwt.TokenProvider; +import inha.gdgoc.global.config.jwt.TokenProvider.CustomUserDetails; +import inha.gdgoc.global.security.AccessGuard; import java.io.IOException; import java.security.GeneralSecurityException; import java.time.Duration; @@ -41,6 +45,7 @@ public class AuthService { private final UserRepository userRepository; private final TokenProvider tokenProvider; private final StringRedisTemplate redisTemplate; + private final AccessGuard accessGuard; @Value("${google.client-id}") private String googleClientId; @@ -91,6 +96,9 @@ public LoginSuccessResponse signup(SignupRequest request) { // 전화번호 정규화 (숫자만 남김) String cleanPhone = request.getPhoneNumber().replaceAll("[^0-9]", ""); + if (userRepository.existsByPhoneNumber(cleanPhone)) { + throw new IllegalArgumentException("이미 존재하는 전화번호입니다."); + } // 유저 엔티티 생성 및 저장 User newUser = @@ -125,6 +133,18 @@ public CheckPhoneNumberResponse isRegisteredPhoneNumber(String phoneNumber) { return new CheckPhoneNumberResponse(exists); } + public boolean hasRequiredAccess(CustomUserDetails me, UserRole role, TeamType requiredTeam) { + var conditions = new java.util.ArrayList(); + conditions.add(AccessGuard.AccessCondition.atLeast(role)); + + if (requiredTeam != null) { + conditions.add(AccessGuard.AccessCondition.atLeast(UserRole.ORGANIZER)); + conditions.add(AccessGuard.AccessCondition.of(UserRole.GUEST, requiredTeam)); + } + + return accessGuard.check(me, conditions.toArray(AccessGuard.AccessCondition[]::new)); + } + public RefreshResult refresh(String refreshToken) { RefreshSession session = resolveRefreshSession(refreshToken); diff --git a/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java b/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java index c97d4c99..aee3fe5d 100644 --- a/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java +++ b/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java @@ -8,7 +8,6 @@ import inha.gdgoc.domain.core.attendance.dto.response.TeamResponse; import inha.gdgoc.domain.core.attendance.service.CoreAttendanceService; import inha.gdgoc.domain.user.enums.TeamType; -import inha.gdgoc.domain.user.enums.UserRole; import inha.gdgoc.global.config.jwt.TokenProvider.CustomUserDetails; import inha.gdgoc.global.dto.response.ApiResponse; import inha.gdgoc.global.dto.response.PageMeta; @@ -44,12 +43,6 @@ public class CoreAttendanceController { private final CoreAttendanceService service; - /* ===== helpers ===== */ - private static TeamType requiredTeamFrom(CustomUserDetails me) { - if (me.getTeam() == null) throw new IllegalArgumentException("LEAD 권한 토큰에 team 정보가 없습니다."); - return me.getTeam(); - } - private static ResponseEntity, Void>> okUpdated(long updated, List ignored) { return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.ATTENDANCE_ALL_SET_SUCCESS, Map.of("updated", updated, "ignoredUserIds", ignored))); } @@ -77,7 +70,9 @@ public ResponseEntity> deleteDate(@PathVaria /* ===== 팀 목록 (리드=본인 팀만 / 관리자=전체) ===== */ @GetMapping("/teams") public ResponseEntity, PageMeta>> getTeams(@AuthenticationPrincipal CustomUserDetails me) { - List list = (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) ? service.getTeamsForLead(requiredTeamFrom(me)) : service.getTeamsForOrganizerOrAdmin(); + List list = service.isLeadScoped(me.getRole(), me.getTeam()) + ? service.getTeamsForLead(service.resolveEffectiveTeam(me.getRole(), me.getTeam(), null)) + : service.getTeamsForOrganizerOrAdmin(); var page = new PageImpl<>(list, PageRequest.of(0, Math.max(1, list.size()), Sort.by(Sort.Direction.DESC, "createdAt")), list.size()); return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.TEAM_LIST_RETRIEVED_SUCCESS, list, PageMeta.of(page))); @@ -88,7 +83,7 @@ public ResponseEntity, PageMeta>> getTeams(@Authe @GetMapping("/{date}/members") public ResponseEntity>, Void>> membersOfMeeting(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team // 관리자만 사용, 리드는 무시 ) { - TeamType effectiveTeam = (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) ? requiredTeamFrom(me) : team; + TeamType effectiveTeam = service.resolveEffectiveTeam(me.getRole(), me.getTeam(), team); var list = service.getMembersWithPresence(date.toString(), effectiveTeam); // list 원소 예시: { "userId": "123", "name": "홍길동", "present": true, "lastModifiedAt": "..." } return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.TEAM_LIST_RETRIEVED_SUCCESS, list)); @@ -99,34 +94,28 @@ public ResponseEntity>, Void>> membersOfMee @PutMapping("/{date}/attendance") public ResponseEntity, Void>> saveAttendanceSnapshot(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestBody @Valid SetAttendanceRequest req) { var userIds = req.safeUserIds(); - - // LEAD → 본인 팀 검증 - if (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) { - TeamType myTeam = requiredTeamFrom(me); - var validation = service.filterUserIdsNotInTeam(myTeam, userIds); - if (validation.validIds().isEmpty()) { - return okUpdated(0L, validation.invalidIds()); - } - long updated = service.setAttendance(date.toString(), validation.validIds(), req.presentValue()); - return okUpdated(updated, validation.invalidIds()); - } - - // ORGANIZER / ADMIN → 팀 추론/검증 없이 바로 업서트 - long updated = service.setAttendance(date.toString(), userIds, req.presentValue()); - return okUpdated(updated, List.of()); + CoreAttendanceService.AttendanceUpdateResult result = service.saveAttendanceSnapshot( + date.toString(), + userIds, + req.presentValue(), + me.getRole(), + me.getTeam() + ); + return okUpdated(result.updatedCount(), result.ignoredUserIds()); } /* ===== 날짜 요약(JSON) ===== */ @GetMapping("/{date}/summary") public ResponseEntity> summary(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team) { - DaySummaryResponse body = (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) ? service.summary(date.toString(), requiredTeamFrom(me)) : service.summary(date.toString(), team); + TeamType effectiveTeam = service.resolveEffectiveTeam(me.getRole(), me.getTeam(), team); + DaySummaryResponse body = service.summary(date.toString(), effectiveTeam); return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.SUMMARY_RETRIEVED_SUCCESS, body)); } /* ===== 날짜 요약(CSV) ===== */ @GetMapping(value = "/{date}/summary.csv", produces = "text/csv; charset=UTF-8") public ResponseEntity summaryCsv(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team) { - TeamType effective = (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) ? requiredTeamFrom(me) : team; + TeamType effective = service.resolveEffectiveTeam(me.getRole(), me.getTeam(), team); String csv = service.buildSummaryCsv(date.toString(), effective); return ResponseEntity.ok() .header("Content-Disposition", "attachment; filename=\"attendance-" + date + ".csv\"") @@ -138,10 +127,7 @@ public ResponseEntity summaryCsvAll( @AuthenticationPrincipal CustomUserDetails me, @RequestParam(required = false) TeamType team ) { - // LEAD & not HR → 자신의 팀만 - TeamType effective = (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) - ? requiredTeamFrom(me) - : team; + TeamType effective = service.resolveEffectiveTeam(me.getRole(), me.getTeam(), team); String csv = service.buildFullMatrixCsv(effective); return ResponseEntity.ok() diff --git a/src/main/java/inha/gdgoc/domain/core/attendance/service/CoreAttendanceService.java b/src/main/java/inha/gdgoc/domain/core/attendance/service/CoreAttendanceService.java index f0a14283..fbeaf12a 100644 --- a/src/main/java/inha/gdgoc/domain/core/attendance/service/CoreAttendanceService.java +++ b/src/main/java/inha/gdgoc/domain/core/attendance/service/CoreAttendanceService.java @@ -78,6 +78,14 @@ public List getTeamsForOrganizerOrAdmin() { return toTeamResponsesGrouped(users); } + public boolean isLeadScoped(UserRole role, TeamType team) { + return role == UserRole.LEAD && team != TeamType.HR; + } + + public TeamType resolveEffectiveTeam(UserRole role, TeamType principalTeam, TeamType requestedTeam) { + return isLeadScoped(role, principalTeam) ? requireTeam(principalTeam) : requestedTeam; + } + /* ===================== Attendance ===================== */ private List toTeamResponsesGrouped(List users) { @@ -129,6 +137,28 @@ public long setAttendance(String date, List userIds, boolean present) { return Math.max(affected, 0); } + @Transactional + public AttendanceUpdateResult saveAttendanceSnapshot( + String date, + List userIds, + boolean present, + UserRole role, + TeamType principalTeam + ) { + if (isLeadScoped(role, principalTeam)) { + TeamType myTeam = requireTeam(principalTeam); + UserIdValidationResult validation = filterUserIdsNotInTeam(myTeam, userIds); + if (validation.validIds().isEmpty()) { + return new AttendanceUpdateResult(0L, validation.invalidIds()); + } + long updated = setAttendance(date, validation.validIds(), present); + return new AttendanceUpdateResult(updated, validation.invalidIds()); + } + + long updated = setAttendance(date, userIds, present); + return new AttendanceUpdateResult(updated, List.of()); + } + /** * 특정 날짜에 대해 팀원 + 현재 출석 여부 목록 */ @@ -334,4 +364,15 @@ protected Map getPresenceMap(LocalDate date) { public record UserIdValidationResult(List validIds, List invalidIds) { } -} \ No newline at end of file + + public record AttendanceUpdateResult(long updatedCount, List ignoredUserIds) { + + } + + private TeamType requireTeam(TeamType team) { + if (team == null) { + throw new IllegalArgumentException("LEAD 권한 토큰에 team 정보가 없습니다."); + } + return team; + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java b/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java index 6acee986..320ba171 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java @@ -10,6 +10,9 @@ import static inha.gdgoc.domain.recruit.member.controller.message.RecruitMemberMessage.STUDENT_ID_DUPLICATION_CHECK_SUCCESS; import inha.gdgoc.domain.recruit.member.dto.request.ApplicationRequest; +import inha.gdgoc.domain.recruit.member.dto.request.CheckEmailRequest; +import inha.gdgoc.domain.recruit.member.dto.request.CheckPhoneNumberRequest; +import inha.gdgoc.domain.recruit.member.dto.request.CheckStudentIdRequest; import inha.gdgoc.domain.recruit.member.dto.request.PaymentUpdateRequest; import inha.gdgoc.domain.recruit.member.dto.response.CheckEmailResponse; import inha.gdgoc.domain.recruit.member.dto.response.CheckPhoneNumberResponse; @@ -24,8 +27,7 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; +import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -82,39 +84,30 @@ public ResponseEntity> recruitMemberAddMultipart( return ResponseEntity.ok(ApiResponse.ok(MEMBER_SAVE_SUCCESS)); } - @GetMapping("/check/student-id") + @PostMapping("/check/student-id") public ResponseEntity> duplicatedStudentIdDetails( - @RequestParam - @NotBlank(message = "학번은 필수 입력 값입니다.") - @Pattern(regexp = "^12[0-9]{6}$", message = "유효하지 않은 학번 값입니다.") - String studentId + @Valid @RequestBody CheckStudentIdRequest request ) { - CheckStudentIdResponse response = recruitMemberService.isRegisteredStudentId(studentId); + CheckStudentIdResponse response = recruitMemberService.isRegisteredStudentId(request.getStudentId()); return ResponseEntity.ok(ApiResponse.ok(STUDENT_ID_DUPLICATION_CHECK_SUCCESS, response)); } - @GetMapping("/check/phone-number") + @PostMapping("/check/phone-number") public ResponseEntity> duplicatedPhoneNumberDetails( - @RequestParam - @NotBlank(message = "전화번호는 필수 입력 값입니다.") - @Pattern(regexp = "^010-?\\d{4}-?\\d{4}$", message = "전화번호 형식은 010-XXXX-XXXX 또는 010XXXXXXXX 이어야 합니다.") - String phoneNumber + @Valid @RequestBody CheckPhoneNumberRequest request ) { CheckPhoneNumberResponse response = recruitMemberService - .isRegisteredPhoneNumber(phoneNumber); + .isRegisteredPhoneNumber(request.getPhoneNumber()); return ResponseEntity.ok(ApiResponse.ok(PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS, response)); } - @GetMapping("/check/email") + @PostMapping("/check/email") public ResponseEntity> duplicatedEmailDetails( - @RequestParam - @NotBlank(message = "이메일은 필수 입력 값입니다.") - @Pattern(regexp = "^[a-zA-Z0-9+-\\_.]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", message = "유효하지 않은 이메일 형식입니다.") - String email + @Valid @RequestBody CheckEmailRequest request ) { - CheckEmailResponse response = recruitMemberService.isRegisteredEmail(email); + CheckEmailResponse response = recruitMemberService.isRegisteredEmail(request.getEmail()); return ResponseEntity.ok(ApiResponse.ok(EMAIL_DUPLICATION_CHECK_SUCCESS, response)); } diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckEmailRequest.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckEmailRequest.java new file mode 100644 index 00000000..3d1fe236 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckEmailRequest.java @@ -0,0 +1,15 @@ +package inha.gdgoc.domain.recruit.member.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CheckEmailRequest { + + @NotBlank(message = "이메일은 필수 입력 값입니다.") + @Pattern(regexp = "^[a-zA-Z0-9+-\\_.]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", message = "유효하지 않은 이메일 형식입니다.") + private String email; +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckPhoneNumberRequest.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckPhoneNumberRequest.java index 15e35411..e79d0128 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckPhoneNumberRequest.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckPhoneNumberRequest.java @@ -9,6 +9,6 @@ @Setter public class CheckPhoneNumberRequest { @NotBlank(message = "전화번호는 필수 입력 값입니다.") - @Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "전화번호 형식은 010-XXXX-XXXX 이어야 합니다.") + @Pattern(regexp = "^010-?\\d{4}-?\\d{4}$", message = "전화번호 형식은 010-XXXX-XXXX 또는 010XXXXXXXX 이어야 합니다.") private String phoneNumber; } diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckStudentIdRequest.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckStudentIdRequest.java new file mode 100644 index 00000000..ccd0eb68 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckStudentIdRequest.java @@ -0,0 +1,15 @@ +package inha.gdgoc.domain.recruit.member.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CheckStudentIdRequest { + + @NotBlank(message = "학번은 필수 입력 값입니다.") + @Pattern(regexp = "^12[0-9]{6}$", message = "유효하지 않은 학번 값입니다.") + private String studentId; +} diff --git a/src/main/java/inha/gdgoc/domain/resource/controller/ResourceController.java b/src/main/java/inha/gdgoc/domain/resource/controller/ResourceController.java index 208a6290..da2d07d0 100644 --- a/src/main/java/inha/gdgoc/domain/resource/controller/ResourceController.java +++ b/src/main/java/inha/gdgoc/domain/resource/controller/ResourceController.java @@ -2,14 +2,10 @@ import static inha.gdgoc.domain.resource.controller.message.ResourceMessage.IMAGE_SAVE_SUCCESS; -import inha.gdgoc.domain.auth.service.AuthService; import inha.gdgoc.domain.resource.dto.response.S3ResultResponse; import inha.gdgoc.domain.resource.enums.S3KeyType; -import inha.gdgoc.domain.resource.exception.ResourceErrorCode; -import inha.gdgoc.domain.resource.exception.ResourceException; -import inha.gdgoc.domain.resource.service.S3Service; +import inha.gdgoc.domain.resource.service.ResourceService; import inha.gdgoc.global.dto.response.ApiResponse; -import java.io.IOException; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; @@ -24,30 +20,15 @@ @RequiredArgsConstructor public class ResourceController { - private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; + private final ResourceService resourceService; - private final S3Service s3Service; - private final AuthService authService; - - // TODO 책임 분리 @PostMapping("/image") public ResponseEntity> uploadImage( Authentication authentication, @RequestParam("file") MultipartFile file, @RequestParam("s3key") S3KeyType s3key ) { - if (file.getSize() > MAX_FILE_SIZE) { - throw new ResourceException(ResourceErrorCode.INVALID_BIG_FILE); - } - - Long userId = authService.getAuthenticationUserId(authentication); - try { - String result_s3Key = s3Service.upload(userId, s3key, file); - S3ResultResponse response = new S3ResultResponse(result_s3Key); - - return ResponseEntity.ok(ApiResponse.ok(IMAGE_SAVE_SUCCESS, response)); - } catch (IOException e) { - throw new RuntimeException("s3 upload fail" + e); - } + S3ResultResponse response = resourceService.uploadImage(authentication, file, s3key); + return ResponseEntity.ok(ApiResponse.ok(IMAGE_SAVE_SUCCESS, response)); } } diff --git a/src/main/java/inha/gdgoc/domain/resource/exception/ResourceErrorCode.java b/src/main/java/inha/gdgoc/domain/resource/exception/ResourceErrorCode.java index 00a93d6f..8b103428 100644 --- a/src/main/java/inha/gdgoc/domain/resource/exception/ResourceErrorCode.java +++ b/src/main/java/inha/gdgoc/domain/resource/exception/ResourceErrorCode.java @@ -6,7 +6,8 @@ public enum ResourceErrorCode implements ErrorCode { // 413 - INVALID_BIG_FILE(HttpStatus.PAYLOAD_TOO_LARGE, "파일 크기는 10Mb를 넘을 수 없습니다."); + INVALID_BIG_FILE(HttpStatus.PAYLOAD_TOO_LARGE, "파일 크기는 10Mb를 넘을 수 없습니다."), + RESOURCE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."); private final HttpStatus status; private final String message; diff --git a/src/main/java/inha/gdgoc/domain/resource/service/ResourceService.java b/src/main/java/inha/gdgoc/domain/resource/service/ResourceService.java new file mode 100644 index 00000000..4ce1d077 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/resource/service/ResourceService.java @@ -0,0 +1,38 @@ +package inha.gdgoc.domain.resource.service; + +import inha.gdgoc.domain.auth.service.AuthService; +import inha.gdgoc.domain.resource.dto.response.S3ResultResponse; +import inha.gdgoc.domain.resource.enums.S3KeyType; +import inha.gdgoc.domain.resource.exception.ResourceErrorCode; +import inha.gdgoc.domain.resource.exception.ResourceException; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class ResourceService { + + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; + + private final S3Service s3Service; + private final AuthService authService; + + @Transactional + public S3ResultResponse uploadImage(Authentication authentication, MultipartFile file, S3KeyType s3KeyType) { + if (file.getSize() > MAX_FILE_SIZE) { + throw new ResourceException(ResourceErrorCode.INVALID_BIG_FILE); + } + + Long userId = authService.getAuthenticationUserId(authentication); + try { + String savedS3Key = s3Service.upload(userId, s3KeyType, file); + return new S3ResultResponse(savedS3Key); + } catch (IOException e) { + throw new ResourceException(ResourceErrorCode.RESOURCE_UPLOAD_FAILED); + } + } +}