From c86afbec678b20a112ea6f38ba55dd48283885ab Mon Sep 17 00:00:00 2001 From: fervovita Date: Thu, 21 May 2026 19:01:30 +0900 Subject: [PATCH 01/90] =?UTF-8?q?Chore:=20PR=20=ED=85=9C=ED=94=8C=EB=A6=BF?= =?UTF-8?q?=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=ED=8F=AC=EB=A7=B7=ED=8C=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 410d875..54b3576 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,4 +1,4 @@ -## **πŸš€Β Related issue** +## **πŸš€ Related issue** Closes #이슈번호 @@ -6,7 +6,7 @@ Closes #이슈번호 - PR ν•œμ€„ μš”μ•½ -## **πŸ”§ Changes* +## **πŸ”§ Changes** - [ ] μž‘μ—… λ‚΄μš© - [ ] μž‘μ—… λ‚΄μš© @@ -15,6 +15,6 @@ Closes #이슈번호 - swaggerλ‚˜ postman κ²°κ³Όλ₯Ό μΊ‘μ³ν•˜μ—¬ 첨뢀 -## **πŸ’¬Β Reviewer Notes** +## **πŸ’¬ Reviewer Notes** - λ¦¬λ·°μ–΄λ‚˜ 같이 μž‘μ—…ν•˜λŠ” μ‚¬λžŒλ“€μ—κ²Œ 남길 μ½”λ©˜νŠΈ \ No newline at end of file From 93b98e714ee60a5392499cc17557cad1795e0ebc Mon Sep 17 00:00:00 2001 From: fervovita Date: Thu, 21 May 2026 19:14:20 +0900 Subject: [PATCH 02/90] =?UTF-8?q?Chore:=20CheckStyle=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EA=B7=9C=EC=B9=99=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 11 + config/checkstyle/checkstyle-rules.xml | 439 ++++++++++++++++++ config/checkstyle/checkstyle-suppressions.xml | 7 + config/intellij/intellij-formatter.xml | 62 +++ 4 files changed, 519 insertions(+) create mode 100644 config/checkstyle/checkstyle-rules.xml create mode 100644 config/checkstyle/checkstyle-suppressions.xml create mode 100644 config/intellij/intellij-formatter.xml diff --git a/build.gradle b/build.gradle index 1a06d0b..7040703 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '4.0.6' id 'io.spring.dependency-management' version '1.1.7' + id 'checkstyle' } group = 'ditda' @@ -34,6 +35,16 @@ dependencies { testAnnotationProcessor 'org.projectlombok:lombok' } +// CheckStyle +checkstyle { + toolVersion = '13.4.1' + configFile = file("${rootDir}/config/checkstyle/checkstyle-rules.xml") + configProperties = [ + 'suppressionFile': "${rootDir}/config/checkstyle/checkstyle-suppressions.xml" + ] + maxWarnings = 0 +} + tasks.named('test') { useJUnitPlatform() } diff --git a/config/checkstyle/checkstyle-rules.xml b/config/checkstyle/checkstyle-rules.xml new file mode 100644 index 0000000..5eefb78 --- /dev/null +++ b/config/checkstyle/checkstyle-rules.xml @@ -0,0 +1,439 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/config/checkstyle/checkstyle-suppressions.xml b/config/checkstyle/checkstyle-suppressions.xml new file mode 100644 index 0000000..efd97d8 --- /dev/null +++ b/config/checkstyle/checkstyle-suppressions.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/config/intellij/intellij-formatter.xml b/config/intellij/intellij-formatter.xml new file mode 100644 index 0000000..e4bb0b0 --- /dev/null +++ b/config/intellij/intellij-formatter.xml @@ -0,0 +1,62 @@ + + + \ No newline at end of file From 2511616f48b90755e5ed4a5a59686fa08dfa2a47 Mon Sep 17 00:00:00 2001 From: fervovita Date: Thu, 21 May 2026 22:09:12 +0900 Subject: [PATCH 03/90] =?UTF-8?q?Feat:=20MySQL=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=20=ED=99=98=EA=B2=BD=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 9 ++++++++ .gitignore | 10 +++++++++ docker-compose.yaml | 25 +++++++++++++++++++++++ src/main/resources/application-local.yaml | 10 +++++++++ src/main/resources/application.yaml | 13 +++++++++++- 5 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 docker-compose.yaml create mode 100644 src/main/resources/application-local.yaml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..77cf50a --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Spring Profile +SPRING_PROFILES_ACTIVE=local + +# Database +DB_URL=jdbc:mysql://localhost:3306/your_db_name?allowPublicKeyRetrieval=true&useSSL=false&characterEncoding=UTF-8 +DB_NAME=your_db_name +DB_USERNAME=your_username +DB_PASSWORD=your_password +DB_ROOT_PASSWORD=your_root_password \ No newline at end of file diff --git a/.gitignore b/.gitignore index c2065bc..58fc357 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,13 @@ out/ ### VS Code ### .vscode/ + +### Spring Boot ### +*.pid +*.log.* +logs/ + +### Environment ### +.env +.env.* +!.env.example diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..0114817 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,25 @@ +services: + mysql: + image: mysql:8.4.9 + container_name: ditda-mysql + ports: + - "127.0.0.1:3306:3306" + environment: + MYSQL_DATABASE: ${DB_NAME} + MYSQL_USER: ${DB_USERNAME} + MYSQL_PASSWORD: ${DB_PASSWORD} + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + healthcheck: + test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-p${DB_ROOT_PASSWORD}" ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 60s + volumes: + - mysql-data:/var/lib/mysql + +volumes: + mysql-data: \ No newline at end of file diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml new file mode 100644 index 0000000..a43f26b --- /dev/null +++ b/src/main/resources/application-local.yaml @@ -0,0 +1,10 @@ +spring: + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + default_batch_fetch_size: 100 + show-sql: true + open-in-view: false \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index a5c1568..e2e7262 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,3 +1,14 @@ spring: application: - name: backend + name: ditda + profiles: + active: ${SPRING_PROFILES_ACTIVE:local} + + config: + import: optional:file:.env[.properties] + + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver From 592e0ff6279c242e56318f68247fd97dc7a5d200 Mon Sep 17 00:00:00 2001 From: fervovita Date: Thu, 21 May 2026 22:45:15 +0900 Subject: [PATCH 04/90] =?UTF-8?q?Feat:=20Redis=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=20=ED=99=98=EA=B2=BD=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 7 ++++++- build.gradle | 30 ++++++++++++++++++++--------- docker-compose.yaml | 23 +++++++++++++++++++++- src/main/resources/application.yaml | 7 +++++++ 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 77cf50a..1c5810c 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,9 @@ DB_URL=jdbc:mysql://localhost:3306/your_db_name?allowPublicKeyRetrieval=true&use DB_NAME=your_db_name DB_USERNAME=your_username DB_PASSWORD=your_password -DB_ROOT_PASSWORD=your_root_password \ No newline at end of file +DB_ROOT_PASSWORD=your_root_password + +# Redis +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD=your_redis_password \ No newline at end of file diff --git a/build.gradle b/build.gradle index 7040703..80297bd 100644 --- a/build.gradle +++ b/build.gradle @@ -19,20 +19,32 @@ repositories { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + // web + implementation 'org.springframework.boot:spring-boot-starter-web' + + // security implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-webmvc' - compileOnly 'org.projectlombok:lombok' + + // mysql + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'com.mysql:mysql-connector-j' + + // redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // devtools + developmentOnly 'org.springframework.boot:spring-boot-devtools' + + // lombok + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' - testImplementation 'org.springframework.boot:spring-boot-starter-security-test' - testImplementation 'org.springframework.boot:spring-boot-starter-validation-test' - testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' testCompileOnly 'org.projectlombok:lombok' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' + + // test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } // CheckStyle diff --git a/docker-compose.yaml b/docker-compose.yaml index 0114817..99c0bd5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -21,5 +21,26 @@ services: volumes: - mysql-data:/var/lib/mysql + redis: + image: redis:8.6-alpine + container_name: ditda-redis + ports: + - "127.0.0.1:6379:6379" + command: + - redis-server + - --requirepass + - ${REDIS_PASSWORD} + - --appendonly + - "yes" + healthcheck: + test: [ "CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping" ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s + volumes: + - redis-data:/data + volumes: - mysql-data: \ No newline at end of file + mysql-data: + redis-data: \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e2e7262..cee6857 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -12,3 +12,10 @@ spring: username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver + + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD} + From 8bc4a00cda116c11d1aab7e706e033d7eef9246e Mon Sep 17 00:00:00 2001 From: fervovita Date: Fri, 22 May 2026 01:26:48 +0900 Subject: [PATCH 05/90] =?UTF-8?q?Feat:=20MinIO=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=20=ED=99=98=EA=B2=BD=20=EB=B0=8F=20S3=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 9 +++++++- build.gradle | 4 ++++ docker-compose.yaml | 26 ++++++++++++++++++++++- src/main/resources/application-local.yaml | 17 ++++++++++++++- 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 1c5810c..b4cb0d5 100644 --- a/.env.example +++ b/.env.example @@ -11,4 +11,11 @@ DB_ROOT_PASSWORD=your_root_password # Redis REDIS_HOST=localhost REDIS_PORT=6379 -REDIS_PASSWORD=your_redis_password \ No newline at end of file +REDIS_PASSWORD=your_redis_password + +# S3 +S3_ENDPOINT=http://localhost:9000 +S3_REGION=ap-northeast-2 +S3_ACCESS_KEY=your_access_key +S3_SECRET_KEY=your_secret_key +S3_BUCKET=your_bucket_name \ No newline at end of file diff --git a/build.gradle b/build.gradle index 80297bd..8765c79 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'com.mysql:mysql-connector-j' + // S3 + implementation platform('io.awspring.cloud:spring-cloud-aws-dependencies:4.0.2') + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' + // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' diff --git a/docker-compose.yaml b/docker-compose.yaml index 99c0bd5..7b34757 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -41,6 +41,30 @@ services: volumes: - redis-data:/data + minio: + image: minio/minio@sha256:a1ea29fa28355559ef137d71fc570e508a214ec84ff8083e39bc5428980b015e #RELEASE.2025-04-22T22-12-26Z + container_name: ditda-minio + ports: + - "127.0.0.1:9000:9000" + - "127.0.0.1:9001:9001" + environment: + MINIO_ROOT_USER: ${S3_ACCESS_KEY} + MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} + command: + - server + - /data + - --console-address + - ":9001" + healthcheck: + test: [ "CMD", "mc", "ready", "local" ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s + volumes: + - minio-data:/data + volumes: mysql-data: - redis-data: \ No newline at end of file + redis-data: + minio-data: \ No newline at end of file diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index a43f26b..8362373 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -7,4 +7,19 @@ spring: format_sql: true default_batch_fetch_size: 100 show-sql: true - open-in-view: false \ No newline at end of file + open-in-view: false + + cloud: + aws: + credentials: + access-key: ${S3_ACCESS_KEY} + secret-key: ${S3_SECRET_KEY} + region: + static: ${S3_REGION} + s3: + endpoint: ${S3_ENDPOINT} + path-style-access-enabled: true + +app: + s3: + bucket: ${S3_BUCKET} \ No newline at end of file From 9b60cd0e83d23049e1f132918df02c8b0553f3ad Mon Sep 17 00:00:00 2001 From: fervovita Date: Fri, 22 May 2026 02:04:25 +0900 Subject: [PATCH 06/90] =?UTF-8?q?Feat:=20Swagger=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 + .../backend/global/config/SecurityConfig.java | 26 +++++++++ .../backend/global/config/SwaggerConfig.java | 55 +++++++++++++++++++ src/main/resources/application-local.yaml | 6 +- src/main/resources/application.yaml | 10 ++++ 5 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 src/main/java/ditda/backend/global/config/SecurityConfig.java create mode 100644 src/main/java/ditda/backend/global/config/SwaggerConfig.java diff --git a/build.gradle b/build.gradle index 8765c79..e8085b5 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,9 @@ dependencies { // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3' + // devtools developmentOnly 'org.springframework.boot:spring-boot-devtools' diff --git a/src/main/java/ditda/backend/global/config/SecurityConfig.java b/src/main/java/ditda/backend/global/config/SecurityConfig.java new file mode 100644 index 0000000..5dabbc0 --- /dev/null +++ b/src/main/java/ditda/backend/global/config/SecurityConfig.java @@ -0,0 +1,26 @@ +package ditda.backend.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + // csrf λΉ„ν™œμ„±ν™” + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() + .anyRequest().authenticated() + ); + + return http.build(); + } + +} + diff --git a/src/main/java/ditda/backend/global/config/SwaggerConfig.java b/src/main/java/ditda/backend/global/config/SwaggerConfig.java new file mode 100644 index 0000000..0e84386 --- /dev/null +++ b/src/main/java/ditda/backend/global/config/SwaggerConfig.java @@ -0,0 +1,55 @@ +package ditda.backend.global.config; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; + +@Configuration +public class SwaggerConfig { + + @Value("${swagger.server-url}") + private String serverUrl; + + @Value("${swagger.server-description}") + private String serverDescription; + + @Bean + public OpenAPI openApi() { + String jwtSchemeName = "JWT Authorization"; + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName); + + Components components = new Components() + .addSecuritySchemes(jwtSchemeName, new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("JWT 토큰을 μž…λ ₯ν•΄μ£Όμ„Έμš”.")); + + Server server = new Server() + .url(serverUrl) + .description(serverDescription); + + return new OpenAPI() + .info(apiInfo()) + .addSecurityItem(securityRequirement) + .components(components) + .servers(List.of(server)); + } + + private Info apiInfo() { + return new Info() + .title("Ditda API Documentation") + .description("Ditda Swagger λ¬Έμ„œμž…λ‹ˆλ‹€.") + .version("1.0.0"); + } + +} diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 8362373..669eaf0 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -22,4 +22,8 @@ spring: app: s3: - bucket: ${S3_BUCKET} \ No newline at end of file + bucket: ${S3_BUCKET} + +swagger: + server-url: http://localhost:8080 + server-description: Local Server diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index cee6857..5299cbd 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -19,3 +19,13 @@ spring: port: ${REDIS_PORT} password: ${REDIS_PASSWORD} +springdoc: + swagger-ui: + path: /swagger-ui.html + operations-sorter: method + tags-sorter: alpha + display-request-duration: true + api-docs: + path: /v3/api-docs + default-produces-media-type: application/json + default-consumes-media-type: application/json From b9abdf2233b1f1fa93a897483aacfffedc11d358 Mon Sep 17 00:00:00 2001 From: fervovita Date: Fri, 22 May 2026 02:16:46 +0900 Subject: [PATCH 07/90] =?UTF-8?q?Test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EB=B0=8F=20H2=20=EC=9D=B8?= =?UTF-8?q?=EB=A9=94=EB=AA=A8=EB=A6=AC=20DB=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 79 ++++++++++--------- .../backend/BackendApplicationTests.java | 2 + src/test/resources/application-test.yaml | 35 ++++++++ 3 files changed, 77 insertions(+), 39 deletions(-) create mode 100644 src/test/resources/application-test.yaml diff --git a/build.gradle b/build.gradle index e8085b5..6a5b762 100644 --- a/build.gradle +++ b/build.gradle @@ -1,69 +1,70 @@ plugins { - id 'java' - id 'org.springframework.boot' version '4.0.6' - id 'io.spring.dependency-management' version '1.1.7' - id 'checkstyle' + id 'java' + id 'org.springframework.boot' version '4.0.6' + id 'io.spring.dependency-management' version '1.1.7' + id 'checkstyle' } group = 'ditda' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - // web - implementation 'org.springframework.boot:spring-boot-starter-web' + // web + implementation 'org.springframework.boot:spring-boot-starter-web' - // security - implementation 'org.springframework.boot:spring-boot-starter-security' + // security + implementation 'org.springframework.boot:spring-boot-starter-security' - // mysql - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - runtimeOnly 'com.mysql:mysql-connector-j' + // mysql + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + runtimeOnly 'com.mysql:mysql-connector-j' // S3 - implementation platform('io.awspring.cloud:spring-cloud-aws-dependencies:4.0.2') - implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' + implementation platform('io.awspring.cloud:spring-cloud-aws-dependencies:4.0.2') + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' - // redis - implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' - // swagger - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3' + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3' - // devtools - developmentOnly 'org.springframework.boot:spring-boot-devtools' + // devtools + developmentOnly 'org.springframework.boot:spring-boot-devtools' - // lombok - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - testCompileOnly 'org.projectlombok:lombok' - testAnnotationProcessor 'org.projectlombok:lombok' + // lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' - // test - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'com.h2database:h2' } // CheckStyle checkstyle { - toolVersion = '13.4.1' - configFile = file("${rootDir}/config/checkstyle/checkstyle-rules.xml") - configProperties = [ - 'suppressionFile': "${rootDir}/config/checkstyle/checkstyle-suppressions.xml" - ] - maxWarnings = 0 + toolVersion = '13.4.1' + configFile = file("${rootDir}/config/checkstyle/checkstyle-rules.xml") + configProperties = [ + 'suppressionFile': "${rootDir}/config/checkstyle/checkstyle-suppressions.xml" + ] + maxWarnings = 0 } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/src/test/java/ditda/backend/BackendApplicationTests.java b/src/test/java/ditda/backend/BackendApplicationTests.java index a8ce0af..d79be6c 100644 --- a/src/test/java/ditda/backend/BackendApplicationTests.java +++ b/src/test/java/ditda/backend/BackendApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class BackendApplicationTests { @Test diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 0000000..c9a0ed7 --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1,35 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;MODE=MYSQL;DB_CLOSE_DELAY=-1 + username: sa + password: + driver-class-name: org.h2.Driver + + jpa: + hibernate: + ddl-auto: create-drop + + data: + redis: + host: localhost + port: 6370 + password: + + cloud: + aws: + credentials: + access-key: test + secret-key: test + region: + static: ap-northeast-2 + s3: + endpoint: http://localhost:9000 + path-style-access-enabled: true + +app: + s3: + bucket: test-bucket + +swagger: + server-url: http://localhost:8080 + server-description: Test Server \ No newline at end of file From 74fc0235422dcbdd0cd17b0149e9eaa5ac2bd2ca Mon Sep 17 00:00:00 2001 From: fervovita Date: Fri, 22 May 2026 03:14:23 +0900 Subject: [PATCH 08/90] =?UTF-8?q?Feat:=20=EA=B3=B5=ED=86=B5=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=ED=8F=AC=EB=A7=B7=20=EB=B0=8F=20=EC=A0=84=EC=97=AD?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ditda/backend/BackendApplication.java | 2 + .../global/apipayload/code/BaseErrorCode.java | 11 + .../apipayload/code/BaseSuccessCode.java | 11 + .../apipayload/code/GeneralErrorCode.java | 40 ++++ .../apipayload/code/GeneralSuccessCode.java | 17 ++ .../exception/GeneralException.java | 20 ++ .../exception/handler/ExceptionAdvice.java | 209 ++++++++++++++++++ .../apipayload/response/ApiResponse.java | 51 +++++ .../backend/global/entity/BaseEntity.java | 26 +++ 9 files changed, 387 insertions(+) create mode 100644 src/main/java/ditda/backend/global/apipayload/code/BaseErrorCode.java create mode 100644 src/main/java/ditda/backend/global/apipayload/code/BaseSuccessCode.java create mode 100644 src/main/java/ditda/backend/global/apipayload/code/GeneralErrorCode.java create mode 100644 src/main/java/ditda/backend/global/apipayload/code/GeneralSuccessCode.java create mode 100644 src/main/java/ditda/backend/global/apipayload/exception/GeneralException.java create mode 100644 src/main/java/ditda/backend/global/apipayload/exception/handler/ExceptionAdvice.java create mode 100644 src/main/java/ditda/backend/global/apipayload/response/ApiResponse.java create mode 100644 src/main/java/ditda/backend/global/entity/BaseEntity.java diff --git a/src/main/java/ditda/backend/BackendApplication.java b/src/main/java/ditda/backend/BackendApplication.java index e7002df..bccd436 100644 --- a/src/main/java/ditda/backend/BackendApplication.java +++ b/src/main/java/ditda/backend/BackendApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @SpringBootApplication public class BackendApplication { diff --git a/src/main/java/ditda/backend/global/apipayload/code/BaseErrorCode.java b/src/main/java/ditda/backend/global/apipayload/code/BaseErrorCode.java new file mode 100644 index 0000000..8ef432a --- /dev/null +++ b/src/main/java/ditda/backend/global/apipayload/code/BaseErrorCode.java @@ -0,0 +1,11 @@ +package ditda.backend.global.apipayload.code; + +import org.springframework.http.HttpStatus; + +public interface BaseErrorCode { + HttpStatus getHttpStatus(); + + String getCode(); + + String getMessage(); +} diff --git a/src/main/java/ditda/backend/global/apipayload/code/BaseSuccessCode.java b/src/main/java/ditda/backend/global/apipayload/code/BaseSuccessCode.java new file mode 100644 index 0000000..604fac3 --- /dev/null +++ b/src/main/java/ditda/backend/global/apipayload/code/BaseSuccessCode.java @@ -0,0 +1,11 @@ +package ditda.backend.global.apipayload.code; + +import org.springframework.http.HttpStatus; + +public interface BaseSuccessCode { + HttpStatus getHttpStatus(); + + String getCode(); + + String getMessage(); +} diff --git a/src/main/java/ditda/backend/global/apipayload/code/GeneralErrorCode.java b/src/main/java/ditda/backend/global/apipayload/code/GeneralErrorCode.java new file mode 100644 index 0000000..a99fe0c --- /dev/null +++ b/src/main/java/ditda/backend/global/apipayload/code/GeneralErrorCode.java @@ -0,0 +1,40 @@ +package ditda.backend.global.apipayload.code; + +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum GeneralErrorCode implements BaseErrorCode { + + // 인증 μ—λŸ¬ + MISSING_AUTH_INFO(HttpStatus.UNAUTHORIZED, "AUTH_401_01", "인증 정보가 λˆ„λ½λ˜μ—ˆμŠ΅λ‹ˆλ‹€."), + INVALID_LOGIN(HttpStatus.UNAUTHORIZED, "AUTH_401_02", "μ˜¬λ°”λ₯΄μ§€ μ•Šμ€ 아이디, ν˜Ήμ€ λΉ„λ°€λ²ˆν˜Έμž…λ‹ˆλ‹€."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_401_03", "μœ νš¨ν•˜μ§€ μ•Šμ€ ν† ν°μž…λ‹ˆλ‹€."), + TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH_401_04", "토큰이 λ§Œλ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."), + FORBIDDEN(HttpStatus.FORBIDDEN, "AUTH_403_01", "μ ‘κ·Ό κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."), + + // μš”μ²­/νŒŒλΌλ―Έν„° μ—λŸ¬ + MISSING_PARAMETER(HttpStatus.BAD_REQUEST, "REQ_400_01", "ν•„μˆ˜ νŒŒλΌλ―Έν„°κ°€ λˆ„λ½λ˜μ—ˆμŠ΅λ‹ˆλ‹€."), + INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "REQ_400_02", "νŒŒλΌλ―Έν„° ν˜•μ‹μ΄ 잘λͺ»λ˜μ—ˆμŠ΅λ‹ˆλ‹€."), + INVALID_BODY_TYPE(HttpStatus.BAD_REQUEST, "REQ_400_03", "μš”μ²­ 본문의 ν˜•μ‹μ΄ 잘λͺ»λ˜μ—ˆκ±°λ‚˜, ν—ˆμš©λ˜μ§€ μ•Šμ€ 값이 ν¬ν•¨λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€."), + UNSUPPORTED_CONTENT_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "REQ_415_01", "μ§€μ›ν•˜μ§€ μ•ŠλŠ” Content-Typeμž…λ‹ˆλ‹€."), + DUPLICATE_RESOURCE(HttpStatus.CONFLICT, "REQ_409_01", "이미 μ‘΄μž¬ν•˜λŠ” λ°μ΄ν„°μž…λ‹ˆλ‹€."), + FILE_SIZE_EXCEEDED(HttpStatus.CONTENT_TOO_LARGE, "REQ_413_01", "파일 크기가 μ œν•œμ„ μ΄ˆκ³Όν–ˆμŠ΅λ‹ˆλ‹€."), + + // API/λΌμš°νŒ… μ—λŸ¬ + API_NOT_FOUND(HttpStatus.NOT_FOUND, "API_404_01", "μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” APIμž…λ‹ˆλ‹€."), + RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "REQ_404_02", "μš”μ²­ν•œ λ¦¬μ†ŒμŠ€λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "API_405_01", "μ§€μ›ν•˜μ§€ μ•ŠλŠ” HTTP λ©”μ„œλ“œμž…λ‹ˆλ‹€."), + + // μ„œλ²„ λ‚΄λΆ€ μ—λŸ¬ + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SERVER_500_01", "μ„œλ²„ λ‚΄λΆ€ 였λ₯˜μž…λ‹ˆλ‹€."), + SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "SERVER_503_01", "μ„œλ²„κ°€ μΌμ‹œμ μœΌλ‘œ λΆˆμ•ˆμ •ν•©λ‹ˆλ‹€."), + EXTERNAL_SERVICE_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "SERVER_504_01", "μ™ΈλΆ€ μ„œλΉ„μŠ€ 응닡 μ§€μ—°"); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/ditda/backend/global/apipayload/code/GeneralSuccessCode.java b/src/main/java/ditda/backend/global/apipayload/code/GeneralSuccessCode.java new file mode 100644 index 0000000..7015bf0 --- /dev/null +++ b/src/main/java/ditda/backend/global/apipayload/code/GeneralSuccessCode.java @@ -0,0 +1,17 @@ +package ditda.backend.global.apipayload.code; + +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum GeneralSuccessCode implements BaseSuccessCode { + + OK(HttpStatus.OK, "SUCCESS", "μ„±κ³΅μ μœΌλ‘œ μ²˜λ¦¬λμŠ΅λ‹ˆλ‹€."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/ditda/backend/global/apipayload/exception/GeneralException.java b/src/main/java/ditda/backend/global/apipayload/exception/GeneralException.java new file mode 100644 index 0000000..b02c99f --- /dev/null +++ b/src/main/java/ditda/backend/global/apipayload/exception/GeneralException.java @@ -0,0 +1,20 @@ +package ditda.backend.global.apipayload.exception; + +import ditda.backend.global.apipayload.code.BaseErrorCode; +import lombok.Getter; + +@Getter +public class GeneralException extends RuntimeException { + + private final BaseErrorCode errorCode; + + public GeneralException(BaseErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public GeneralException(BaseErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/ditda/backend/global/apipayload/exception/handler/ExceptionAdvice.java b/src/main/java/ditda/backend/global/apipayload/exception/handler/ExceptionAdvice.java new file mode 100644 index 0000000..7ed80e2 --- /dev/null +++ b/src/main/java/ditda/backend/global/apipayload/exception/handler/ExceptionAdvice.java @@ -0,0 +1,209 @@ +package ditda.backend.global.apipayload.exception.handler; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.core.PropertyReferenceException; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +import ditda.backend.global.apipayload.code.BaseErrorCode; +import ditda.backend.global.apipayload.code.GeneralErrorCode; +import ditda.backend.global.apipayload.exception.GeneralException; +import ditda.backend.global.apipayload.response.ApiResponse; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestControllerAdvice +public class ExceptionAdvice { + + /** + * [μ»€μŠ€ν…€ μ˜ˆμ™Έ] 직접 μ •μ˜ν•œ μ˜ˆμ™Έ(GeneralException)을 처리 + */ + @ExceptionHandler(GeneralException.class) + public ResponseEntity> handleCustomException(GeneralException exception) { + BaseErrorCode code = exception.getErrorCode(); + + log.warn("CustomException: {}", exception.getErrorCode().getMessage()); + + return ResponseEntity + .status(code.getHttpStatus()) + .body(ApiResponse.onFailure(code, exception.getMessage())); + } + + /** + * [μœ νš¨μ„± 검사 μ˜ˆμ™Έ] @Validλ₯Ό ν†΅ν•œ μœ νš¨μ„± 검증 μ‹€νŒ¨ 처리 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationException(MethodArgumentNotValidException exception) { + var errors = exception.getBindingResult().getFieldErrors() + .stream() + .map(fld -> String.format("[%s] %s", fld.getField(), fld.getDefaultMessage())) + .toList(); + + log.warn("MethodArgumentNotValidException: {}", errors); + + BaseErrorCode code = GeneralErrorCode.INVALID_PARAMETER; + + return ResponseEntity + .status(code.getHttpStatus()) + .body(ApiResponse.onFailure(code, errors)); + } + + /** + * [νŒŒλΌλ―Έν„° νƒ€μž… 뢈일치 μ˜ˆμ™Έ] 쿼리 νŒŒλΌλ―Έν„°λ‚˜ PathVariable의 νƒ€μž… 뢈일치 처리 + */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity> handleTypeMismatchException( + MethodArgumentTypeMismatchException exception) { + String message = String.format("%s ν•„λ“œμ˜ νƒ€μž…μ΄ 잘λͺ»λ˜μ—ˆμŠ΅λ‹ˆλ‹€.", exception.getName()); + + log.warn("TypeMismatchException: {}", exception.getMessage()); + + BaseErrorCode code = GeneralErrorCode.INVALID_PARAMETER; + + return ResponseEntity + .status(code.getHttpStatus()) + .body(ApiResponse.onFailure(code, message)); + } + + /** + * [Bean Validation μ˜ˆμ™Έ] @NotNull, @Min λ“± Bean Validation μ œμ•½ 쑰건 μœ„λ°˜ 처리 + */ + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolationException( + ConstraintViolationException exception) { + var errors = exception.getConstraintViolations().stream() + .map(v -> String.format("[%s] %s", v.getPropertyPath().toString(), v.getMessage())) + .toList(); + + BaseErrorCode code = GeneralErrorCode.INVALID_PARAMETER; + + log.warn("ConstraintViolationException: {}", exception.getMessage()); + + return ResponseEntity + .status(code.getHttpStatus()) + .body(ApiResponse.onFailure(code, errors)); + } + + /** + * [λ©”μ„Έμ§€ 읽기 μ‹€νŒ¨ μ˜ˆμ™Έ] json λ°”λ”” ν˜•μ‹μ˜ 뢈일치 처리 + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleHttpMessageNotReadableException( + HttpMessageNotReadableException exception) { + BaseErrorCode code = GeneralErrorCode.INVALID_BODY_TYPE; + + log.warn("HttpMessageNotReadableException: {}", exception.getMessage()); + + return ResponseEntity + .status(code.getHttpStatus()) + .body(ApiResponse.onFailure(code, code.getMessage())); + } + + /** + * [API 경둜 μ—†μŒ μ˜ˆμ™Έ] μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” API 경둜 μš”μ²­ 처리 + */ + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity> handleNoResourceFoundException(NoResourceFoundException exception) { + BaseErrorCode code = GeneralErrorCode.API_NOT_FOUND; + + log.warn("NoResourceFoundException: {}", exception.getMessage()); + + return ResponseEntity + .status(code.getHttpStatus()) + .body(ApiResponse.onFailure(code, code.getMessage())); + } + + /** + * [HTTP λ©”μ„œλ“œ 뢈일치 μ˜ˆμ™Έ] μ§€μ›ν•˜μ§€ μ•ŠλŠ” HTTP λ©”μ„œλ“œ μš”μ²­ 처리 + */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity> handleHttpRequestMethodNotSupportedException( + HttpRequestMethodNotSupportedException exception) { + BaseErrorCode code = GeneralErrorCode.METHOD_NOT_ALLOWED; + + log.warn("HttpRequestMethodNotSupportedException: {}", exception.getMessage()); + + return ResponseEntity + .status(code.getHttpStatus()) + .body(ApiResponse.onFailure(code, code.getMessage())); + } + + /** + * [Content-Type 미지원 μ˜ˆμ™Έ] μš”μ²­μ˜ Content-Type이 μ»¨νŠΈλ‘€λŸ¬κ°€ λ°›μ§€ λͺ»ν•˜λŠ” νƒ€μž…μΌ λ•Œ 처리 + */ + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + public ResponseEntity> handleMediaTypeNotSupported( + HttpMediaTypeNotSupportedException exception) { + BaseErrorCode code = GeneralErrorCode.UNSUPPORTED_CONTENT_TYPE; + + log.warn("HttpMediaTypeNotSupportedException: {}", exception.getMessage()); + + return ResponseEntity.status(code.getHttpStatus()) + .body(ApiResponse.onFailure(code, code.getMessage())); + } + + /** + * [파일 크기 초과 μ˜ˆμ™Έ] multipart μ—…λ‘œλ“œ μ‹œ μ„€μ •λœ μ΅œλŒ€ 크기λ₯Ό μ΄ˆκ³Όν•œ 경우 처리 + */ + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity> handleMaxUploadSize(MaxUploadSizeExceededException exception) { + BaseErrorCode code = GeneralErrorCode.FILE_SIZE_EXCEEDED; + + log.warn("MaxUploadSizeExceededException: {}", exception.getMessage()); + + return ResponseEntity.status(code.getHttpStatus()) + .body(ApiResponse.onFailure(code, code.getMessage())); + } + + /** + * [JPA ν•„λ“œ μ°Έμ‘° μ˜ˆμ™Έ] JPAμ—μ„œ sortλ‚˜ κ²€μƒ‰μ‹œ μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” ν•„λ“œλͺ… 처리 + */ + @ExceptionHandler(PropertyReferenceException.class) + public ResponseEntity> handlePropertyReferenceException(PropertyReferenceException exception) { + BaseErrorCode code = GeneralErrorCode.INVALID_PARAMETER; + + log.warn("PropertyReferenceException: {}", exception.getMessage()); + + return ResponseEntity + .status(code.getHttpStatus()) + .body(ApiResponse.onFailure(code, code.getMessage())); + } + + /** + * [데이터 무결성 μœ„λ°˜ μ˜ˆμ™Έ] DB μœ λ‹ˆν¬ μ œμ•½ μ‘°κ±΄μ΄λ‚˜ μ™Έλž˜ν‚€ μ œμ•½ μœ„λ°˜ 처리 + */ + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity> handleDataIntegrityViolationException( + DataIntegrityViolationException exception) { + BaseErrorCode code = GeneralErrorCode.DUPLICATE_RESOURCE; + + log.warn("DataIntegrityViolationException: {}", exception.getMessage()); + + return ResponseEntity + .status(code.getHttpStatus()) + .body(ApiResponse.onFailure(code, code.getMessage())); + } + + /** + * [μ΅œμƒμœ„ μ˜ˆμ™Έ] μœ„μ˜ μ˜ˆμ™Έλ₯Ό μ œμ™Έν•œ λͺ¨λ“  μ˜ˆμ™Έλ₯Ό 처리 + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception exception) { + BaseErrorCode code = GeneralErrorCode.INTERNAL_SERVER_ERROR; + + log.error("Unhandled Exception", exception); + + return ResponseEntity + .status(code.getHttpStatus()) + .body(ApiResponse.onFailure(code, code.getMessage())); + } +} diff --git a/src/main/java/ditda/backend/global/apipayload/response/ApiResponse.java b/src/main/java/ditda/backend/global/apipayload/response/ApiResponse.java new file mode 100644 index 0000000..58815e6 --- /dev/null +++ b/src/main/java/ditda/backend/global/apipayload/response/ApiResponse.java @@ -0,0 +1,51 @@ +package ditda.backend.global.apipayload.response; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +import ditda.backend.global.apipayload.code.BaseErrorCode; +import ditda.backend.global.apipayload.code.GeneralSuccessCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@JsonPropertyOrder({"success", "code", "message", "result", "error", "timestamp"}) +public class ApiResponse { + + @JsonProperty("success") + private final boolean success; + + @JsonProperty("code") + private final String code; + + @JsonProperty("message") + private final String message; + + @JsonProperty("result") + private final T result; + + @JsonProperty("error") + private final Object error; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul") + private final LocalDateTime timestamp; + + // resultκ°€ μžˆλŠ” 성곡 응닡 + public static ApiResponse onSuccess(String message, T result) { + return new ApiResponse<>(true, GeneralSuccessCode.OK.getCode(), message, result, null, LocalDateTime.now()); + } + + // resultκ°€ μ—†λŠ” 성곡 응닡 + public static ApiResponse onSuccess(String message) { + return new ApiResponse<>(true, GeneralSuccessCode.OK.getCode(), message, null, null, LocalDateTime.now()); + } + + // μ‹€νŒ¨ 응닡 + public static ApiResponse onFailure(BaseErrorCode errorCode, Object error) { + return new ApiResponse<>(false, errorCode.getCode(), errorCode.getMessage(), null, error, LocalDateTime.now()); + } +} diff --git a/src/main/java/ditda/backend/global/entity/BaseEntity.java b/src/main/java/ditda/backend/global/entity/BaseEntity.java new file mode 100644 index 0000000..116324d --- /dev/null +++ b/src/main/java/ditda/backend/global/entity/BaseEntity.java @@ -0,0 +1,26 @@ +package ditda.backend.global.entity; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} From a57f8eb32b6e34ad85187318598f97ca0c28bbee Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Fri, 22 May 2026 21:06:29 +0900 Subject: [PATCH 09/90] =?UTF-8?q?Chore(jwt):=20JWT=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B4=80=EB=A0=A8=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 | 5 + .../backend/global/jwt/JwtTokenProvider.java | 130 ++++++++++++++++++ .../global/jwt/exceptions/JwtErrorType.java | 15 ++ .../global/jwt/filter/JwtTokenFilter.java | 76 ++++++++++ .../jwt/handler/JwtAccessDeniedHandler.java | 36 +++++ .../handler/JwtAuthenticationEntryPoint.java | 41 ++++++ .../backend/global/jwt/utils/CookieUtils.java | 40 ++++++ src/main/resources/application.yaml | 7 + 8 files changed, 350 insertions(+) create mode 100644 src/main/java/ditda/backend/global/jwt/JwtTokenProvider.java create mode 100644 src/main/java/ditda/backend/global/jwt/exceptions/JwtErrorType.java create mode 100644 src/main/java/ditda/backend/global/jwt/filter/JwtTokenFilter.java create mode 100644 src/main/java/ditda/backend/global/jwt/handler/JwtAccessDeniedHandler.java create mode 100644 src/main/java/ditda/backend/global/jwt/handler/JwtAuthenticationEntryPoint.java create mode 100644 src/main/java/ditda/backend/global/jwt/utils/CookieUtils.java diff --git a/build.gradle b/build.gradle index 6a5b762..99e1916 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,11 @@ dependencies { // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.13.0' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0' + // swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3' diff --git a/src/main/java/ditda/backend/global/jwt/JwtTokenProvider.java b/src/main/java/ditda/backend/global/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..ee88295 --- /dev/null +++ b/src/main/java/ditda/backend/global/jwt/JwtTokenProvider.java @@ -0,0 +1,130 @@ +package ditda.backend.global.jwt; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class JwtTokenProvider { + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.access-token-expiration}") + private Long accessTokenExpiration; + + @Value("${jwt.refresh-token-expiration}") + private Long refreshTokenExpiration; + + private SecretKey key; + + private static final String TOKEN_TYPE_CLAIM = "type"; + private static final String ACCESS_TOKEN_TYPE = "access_token"; + private static final String REFRESH_TOKEN_TYPE = "refresh_token"; + + @PostConstruct + public void init() { + byte[] keyBytes = Decoders.BASE64URL.decode(secret); + key = Keys.hmacShaKeyFor(keyBytes); + } + + public String generateAccessToken(Long userId) { + Date now = new Date(); + Date expiryTime = new Date(now.getTime() + accessTokenExpiration); + + return Jwts.builder() + .subject(userId.toString()) + .claim(TOKEN_TYPE_CLAIM, ACCESS_TOKEN_TYPE) + .issuedAt(now) + .expiration(expiryTime) + .signWith(key, Jwts.SIG.HS256) + .compact(); + } + + public String generateRefreshToken(Long userId) { + Date now = new Date(); + Date expiryTime = new Date(now.getTime() + refreshTokenExpiration); + + return Jwts.builder() + .subject(userId.toString()) + .claim(TOKEN_TYPE_CLAIM, REFRESH_TOKEN_TYPE) + .issuedAt(now) + .expiration(expiryTime) + .signWith(key, Jwts.SIG.HS256) + .compact(); + } + + public Claims validateAccessToken(String token) { + + Claims claims = validateToken(token); + if (!ACCESS_TOKEN_TYPE.equals(claims.get(TOKEN_TYPE_CLAIM, String.class))) { + throw new JwtException("Access Token이 μ•„λ‹™λ‹ˆλ‹€."); + } + + return claims; + } + + public Claims validateRefreshToken(String token) { + + Claims claims = validateToken(token); + if (!REFRESH_TOKEN_TYPE.equals(claims.get(TOKEN_TYPE_CLAIM, String.class))) { + throw new JwtException("Refresh Token이 μ•„λ‹™λ‹ˆλ‹€."); + } + + return claims; + } + + public LocalDateTime getExpiration(String token) { + return getClaims(token).getExpiration() + .toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + } + + private Claims getClaims(String token) { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + private Claims validateToken(String token) { + try { + return getClaims(token); + } catch (ExpiredJwtException e) { + log.warn("만료된 JWT 토큰: {}", e.getMessage()); + throw e; + } catch (UnsupportedJwtException e) { + log.warn("μ§€μ›λ˜μ§€ μ•ŠλŠ” JWT 토큰: {}", e.getMessage()); + throw e; + } catch (MalformedJwtException e) { + log.warn("잘λͺ»λœ ν˜•μ‹μ˜ JWT 토큰: {}", e.getMessage()); + throw e; + } catch (SignatureException e) { + log.warn("JWT μ„œλͺ… 검증 μ‹€νŒ¨: {}", e.getMessage()); + throw e; + } catch (IllegalArgumentException e) { + log.warn("JWT 토큰이 λΉ„μ–΄μžˆμŒ: {}", e.getMessage()); + throw e; + } + } +} diff --git a/src/main/java/ditda/backend/global/jwt/exceptions/JwtErrorType.java b/src/main/java/ditda/backend/global/jwt/exceptions/JwtErrorType.java new file mode 100644 index 0000000..bfff879 --- /dev/null +++ b/src/main/java/ditda/backend/global/jwt/exceptions/JwtErrorType.java @@ -0,0 +1,15 @@ +package ditda.backend.global.jwt.exceptions; + + +import ditda.backend.global.apipayload.code.GeneralErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum JwtErrorType { + TOKEN_EXPIRED(GeneralErrorCode.TOKEN_EXPIRED), + INVALID_TOKEN(GeneralErrorCode.INVALID_TOKEN); + + private final GeneralErrorCode errorCode; +} diff --git a/src/main/java/ditda/backend/global/jwt/filter/JwtTokenFilter.java b/src/main/java/ditda/backend/global/jwt/filter/JwtTokenFilter.java new file mode 100644 index 0000000..1a617dc --- /dev/null +++ b/src/main/java/ditda/backend/global/jwt/filter/JwtTokenFilter.java @@ -0,0 +1,76 @@ +package ditda.backend.global.jwt.filter; + +import java.io.IOException; +import java.util.List; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import ditda.backend.global.jwt.JwtTokenProvider; +import ditda.backend.global.jwt.exceptions.JwtErrorType; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtTokenFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String token = resolveToken(request); + + if (StringUtils.hasText(token)) { + try { + Claims claims = jwtTokenProvider.validateAccessToken(token); + Long memberId = Long.parseLong(claims.getSubject()); + + Authentication authentication = + new UsernamePasswordAuthenticationToken( + memberId, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (ExpiredJwtException e) { + request.setAttribute("exception", JwtErrorType.TOKEN_EXPIRED); + } catch (JwtException | IllegalArgumentException e) { + request.setAttribute("exception", JwtErrorType.INVALID_TOKEN); + } + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + + String bearerToken = request.getHeader("Authorization"); + + // Swagger & Postman μ „μš© + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + + return null; + } +} diff --git a/src/main/java/ditda/backend/global/jwt/handler/JwtAccessDeniedHandler.java b/src/main/java/ditda/backend/global/jwt/handler/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..22913e5 --- /dev/null +++ b/src/main/java/ditda/backend/global/jwt/handler/JwtAccessDeniedHandler.java @@ -0,0 +1,36 @@ +package ditda.backend.global.jwt.handler; + +import java.io.IOException; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import ditda.backend.global.apipayload.code.GeneralErrorCode; +import ditda.backend.global.apipayload.response.ApiResponse; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import tools.jackson.databind.ObjectMapper; + +@Component +@RequiredArgsConstructor +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException, ServletException { + + GeneralErrorCode errorCode = GeneralErrorCode.FORBIDDEN; + + response.setStatus(errorCode.getHttpStatus().value()); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.onFailure(errorCode, null))); + } +} diff --git a/src/main/java/ditda/backend/global/jwt/handler/JwtAuthenticationEntryPoint.java b/src/main/java/ditda/backend/global/jwt/handler/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..6db64ee --- /dev/null +++ b/src/main/java/ditda/backend/global/jwt/handler/JwtAuthenticationEntryPoint.java @@ -0,0 +1,41 @@ +package ditda.backend.global.jwt.handler; + +import java.io.IOException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import ditda.backend.global.apipayload.code.GeneralErrorCode; +import ditda.backend.global.apipayload.response.ApiResponse; +import ditda.backend.global.jwt.exceptions.JwtErrorType; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import tools.jackson.databind.ObjectMapper; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException, ServletException { + + JwtErrorType errorType = (JwtErrorType)request.getAttribute("exception"); + + GeneralErrorCode errorCode = (errorType != null) + ? errorType.getErrorCode() + : GeneralErrorCode.MISSING_AUTH_INFO; + + response.setStatus(errorCode.getHttpStatus().value()); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.onFailure(errorCode, null))); + } +} diff --git a/src/main/java/ditda/backend/global/jwt/utils/CookieUtils.java b/src/main/java/ditda/backend/global/jwt/utils/CookieUtils.java new file mode 100644 index 0000000..2317185 --- /dev/null +++ b/src/main/java/ditda/backend/global/jwt/utils/CookieUtils.java @@ -0,0 +1,40 @@ +package ditda.backend.global.jwt.utils; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +public class CookieUtils { + + public static final String REFRESH_TOKEN_COOKIE = "refresh_token"; + + @Value("${jwt.refresh-token-expiration}") + private Long refreshTokenExpiration; + + @Value("${jwt.refresh-cookie-path}") + private String refreshCookiePath; + + @Value("${jwt.cookie-secure}") + private boolean secure; + + public ResponseCookie createRefreshTokenCookie(String token) { + return ResponseCookie.from(REFRESH_TOKEN_COOKIE, token) + .httpOnly(true) + .secure(secure) + .sameSite("Lax") + .path(refreshCookiePath) + .maxAge(refreshTokenExpiration / 1000) + .build(); + } + + public ResponseCookie deleteRefreshTokenCookie() { + return ResponseCookie.from(REFRESH_TOKEN_COOKIE, "") + .httpOnly(true) + .secure(secure) + .sameSite("Lax") + .path(refreshCookiePath) + .maxAge(0) + .build(); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 5299cbd..06a7410 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -19,6 +19,13 @@ spring: port: ${REDIS_PORT} password: ${REDIS_PASSWORD} +jwt: + secret: ${JWT_SECRET_KEY} + access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION} + refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION} + cookie-secure: ${JWT_COOKIE_SECURE} + refresh-cookie-path: ${JWT_REFRESH_COOKIE_PATH} + springdoc: swagger-ui: path: /swagger-ui.html From 3ced52d03959557186a21dd1be5632ec00b23088 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Fri, 22 May 2026 21:10:59 +0900 Subject: [PATCH 10/90] =?UTF-8?q?Chore(jwt):=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=EC=97=90=20JWT=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EB=B3=80=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/application-test.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index c9a0ed7..da70263 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -26,6 +26,13 @@ spring: endpoint: http://localhost:9000 path-style-access-enabled: true +jwt: + secret: ${JWT_SECRET_KEY} + access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION} + refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION} + cookie-secure: ${JWT_COOKIE_SECURE} + refresh-cookie-path: ${JWT_REFRESH_COOKIE_PATH} + app: s3: bucket: test-bucket From 1260bbbc49fe3fd1c1999aeaef332c0a1c7e70c2 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Fri, 22 May 2026 21:14:47 +0900 Subject: [PATCH 11/90] =?UTF-8?q?Chore(jwt):=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=EC=97=90=20JWT=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EB=B3=80=EC=88=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/application-test.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index da70263..8bdf291 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -27,11 +27,11 @@ spring: path-style-access-enabled: true jwt: - secret: ${JWT_SECRET_KEY} - access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION} - refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION} - cookie-secure: ${JWT_COOKIE_SECURE} - refresh-cookie-path: ${JWT_REFRESH_COOKIE_PATH} + secret: application-test-jwt-secret-key-only-for-ci + access-token-expiration: 3600000 + refresh-token-expiration: 86400000 + cookie-secure: false + refresh-cookie-path: /api/v1/auth app: s3: From d20fc75aabb90bcaee85422539c7dd6eb867709d Mon Sep 17 00:00:00 2001 From: fervovita Date: Fri, 22 May 2026 22:04:46 +0900 Subject: [PATCH 12/90] =?UTF-8?q?Feat(user):=20UserEntity=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/common/user/entity/UserEntity.java | 58 +++++++++++++++++++ .../common/user/entity/enums/UserRole.java | 6 ++ 2 files changed, 64 insertions(+) create mode 100644 src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java create mode 100644 src/main/java/ditda/backend/domain/common/user/entity/enums/UserRole.java diff --git a/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java b/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java new file mode 100644 index 0000000..7db9898 --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java @@ -0,0 +1,58 @@ +package ditda.backend.domain.common.user.entity; + +import java.time.LocalDateTime; + +import ditda.backend.domain.common.user.entity.enums.UserRole; +import ditda.backend.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 lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "users") +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserEntity extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long id; + + @Column(name = "username", nullable = false, unique = true, length = 20) + private String username; + + @Column(name = "password", nullable = false) + private String password; + + @Column(name = "name", nullable = false, length = 50) + private String name; + + @Column(name = "email", nullable = false, unique = true, length = 100) + private String email; + + @Column(name = "profile_image_url", nullable = false) + private String profileImage; + + @Column(name = "phone", nullable = false, length = 20) + private String phone; + + @Column(name = "role", nullable = false) + private UserRole role; + + @Column(name = "address", nullable = false) + private String address; + + @Column(name = "email_verified_at", nullable = true) + private LocalDateTime emailVerifiedAt; +} diff --git a/src/main/java/ditda/backend/domain/common/user/entity/enums/UserRole.java b/src/main/java/ditda/backend/domain/common/user/entity/enums/UserRole.java new file mode 100644 index 0000000..c98040b --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/user/entity/enums/UserRole.java @@ -0,0 +1,6 @@ +package ditda.backend.domain.common.user.entity.enums; + +public enum UserRole { + DESIGNER, + INSTRUCTOR +} From 73a2ac01c941f9cb04889b41fb8abe7dfc7adb26 Mon Sep 17 00:00:00 2001 From: fervovita Date: Fri, 22 May 2026 22:10:13 +0900 Subject: [PATCH 13/90] =?UTF-8?q?Fix(user):=20UserEntity=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20address=20nullable=20=EC=84=A4=EC=A0=95=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ditda/backend/domain/common/user/entity/UserEntity.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java b/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java index 7db9898..6f14cbc 100644 --- a/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java +++ b/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java @@ -50,7 +50,7 @@ public class UserEntity extends BaseEntity { @Column(name = "role", nullable = false) private UserRole role; - @Column(name = "address", nullable = false) + @Column(name = "address") private String address; @Column(name = "email_verified_at", nullable = true) From 08c0836b59291abd5d395dcdca9949a71c5e5a4e Mon Sep 17 00:00:00 2001 From: fervovita Date: Fri, 22 May 2026 22:11:36 +0900 Subject: [PATCH 14/90] =?UTF-8?q?Feat(user):=20UserEntity=EC=97=90=20role?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20EnumType.STRING=20=EB=A7=A4=ED=95=91=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ditda/backend/domain/common/user/entity/UserEntity.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java b/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java index 6f14cbc..d8f901b 100644 --- a/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java +++ b/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java @@ -6,6 +6,8 @@ import ditda.backend.global.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -47,6 +49,7 @@ public class UserEntity extends BaseEntity { @Column(name = "phone", nullable = false, length = 20) private String phone; + @Enumerated(EnumType.STRING) @Column(name = "role", nullable = false) private UserRole role; From 1adbea2e7e11bf40845f543de5b68343708e5a6d Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Fri, 22 May 2026 22:18:49 +0900 Subject: [PATCH 15/90] =?UTF-8?q?Chore(term):=20UserTerm=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20TermType=20enum=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 --- .../domain/common/term/entity/UserTerm.java | 58 +++++++++++++++++++ .../common/term/entity/enums/TermType.java | 9 +++ 2 files changed, 67 insertions(+) create mode 100644 src/main/java/ditda/backend/domain/common/term/entity/UserTerm.java create mode 100644 src/main/java/ditda/backend/domain/common/term/entity/enums/TermType.java diff --git a/src/main/java/ditda/backend/domain/common/term/entity/UserTerm.java b/src/main/java/ditda/backend/domain/common/term/entity/UserTerm.java new file mode 100644 index 0000000..97f0042 --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/term/entity/UserTerm.java @@ -0,0 +1,58 @@ +package ditda.backend.domain.common.term.entity; + +import ditda.backend.domain.common.term.entity.enums.TermType; +import ditda.backend.domain.common.user.entity.UserEntity; +import ditda.backend.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 lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "user_terms") +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserTerm extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_term_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserEntity user; + + @Enumerated(EnumType.STRING) + @Column(name = "term_type", nullable = false, length = 50) + private TermType termType; + + @Column(name = "version", nullable = false, length = 20) + private String version; + + @Column(name = "is_agreed", nullable = false) + private boolean isAgreed; + + public static UserTerm createTerm(UserEntity user, TermType type, String version, boolean isAgreed) { + return UserTerm.builder() + .user(user) + .termType(type) + .version(version) + .isAgreed(isAgreed) + .build(); + } +} diff --git a/src/main/java/ditda/backend/domain/common/term/entity/enums/TermType.java b/src/main/java/ditda/backend/domain/common/term/entity/enums/TermType.java new file mode 100644 index 0000000..974f45a --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/term/entity/enums/TermType.java @@ -0,0 +1,9 @@ +package ditda.backend.domain.common.term.entity.enums; + +public enum TermType { + SERVICE, + USERINFO, + SETTLEMENT, + // νƒˆμ€‘κ°œν™” + DISINTERMEDIATION +} From 0e5c13dcb09656d8621aeefca69c8768941bf957 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Fri, 22 May 2026 23:49:24 +0900 Subject: [PATCH 16/90] =?UTF-8?q?Chore(security):=20SecurityConfig=20@Enab?= =?UTF-8?q?leWebSecurity=20annotation=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/ditda/backend/global/config/SecurityConfig.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/ditda/backend/global/config/SecurityConfig.java b/src/main/java/ditda/backend/global/config/SecurityConfig.java index 5dabbc0..da4b22c 100644 --- a/src/main/java/ditda/backend/global/config/SecurityConfig.java +++ b/src/main/java/ditda/backend/global/config/SecurityConfig.java @@ -3,10 +3,12 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.web.SecurityFilterChain; @Configuration +@EnableWebSecurity public class SecurityConfig { @Bean From b24a1900a53992241dd7b9acb03c63a07fb36bfd Mon Sep 17 00:00:00 2001 From: fervovita Date: Sat, 23 May 2026 03:10:30 +0900 Subject: [PATCH 17/90] =?UTF-8?q?Feat(auth):=20=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EB=B0=8F=20?= =?UTF-8?q?SMTP=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 +++++++- build.gradle | 6 ++++++ src/main/resources/application.yaml | 12 ++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index b4cb0d5..7fc8688 100644 --- a/.env.example +++ b/.env.example @@ -18,4 +18,10 @@ S3_ENDPOINT=http://localhost:9000 S3_REGION=ap-northeast-2 S3_ACCESS_KEY=your_access_key S3_SECRET_KEY=your_secret_key -S3_BUCKET=your_bucket_name \ No newline at end of file +S3_BUCKET=your_bucket_name + +# Mail +MAIL_HOST=smtp.gmail.com +MAIL_PORT=587 +MAIL_USERNAME=your_email@gmail.com +MAIL_PASSWORD=your_app_password \ No newline at end of file diff --git a/build.gradle b/build.gradle index 99e1916..0f7fc86 100644 --- a/build.gradle +++ b/build.gradle @@ -41,6 +41,12 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0' + // email + implementation 'org.springframework.boot:spring-boot-starter-mail' + + // thymeleaf + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + // swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3' diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 06a7410..095ee29 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -19,6 +19,18 @@ spring: port: ${REDIS_PORT} password: ${REDIS_PASSWORD} + mail: + host: ${MAIL_HOST} + port: ${MAIL_PORT} + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + jwt: secret: ${JWT_SECRET_KEY} access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION} From 253d3170739a53ddb1e2a6c3a6983c067d88ed44 Mon Sep 17 00:00:00 2001 From: fervovita Date: Sat, 23 May 2026 03:11:54 +0900 Subject: [PATCH 18/90] =?UTF-8?q?Feat(auth):=20=EB=B9=84=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=20=EB=A9=94=EC=9D=BC=20=EB=B0=9C=EC=86=A1=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ditda/backend/BackendApplication.java | 2 + .../backend/global/email/EmailSender.java | 48 +++++++++++++++++++ .../templates/email/verificationCode.html | 40 ++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 src/main/java/ditda/backend/global/email/EmailSender.java create mode 100644 src/main/resources/templates/email/verificationCode.html diff --git a/src/main/java/ditda/backend/BackendApplication.java b/src/main/java/ditda/backend/BackendApplication.java index bccd436..303201f 100644 --- a/src/main/java/ditda/backend/BackendApplication.java +++ b/src/main/java/ditda/backend/BackendApplication.java @@ -3,7 +3,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; +@EnableAsync @EnableJpaAuditing @SpringBootApplication public class BackendApplication { diff --git a/src/main/java/ditda/backend/global/email/EmailSender.java b/src/main/java/ditda/backend/global/email/EmailSender.java new file mode 100644 index 0000000..c9401a8 --- /dev/null +++ b/src/main/java/ditda/backend/global/email/EmailSender.java @@ -0,0 +1,48 @@ +package ditda.backend.global.email; + +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; + +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class EmailSender { + + private static final String VERIFICATION_SUBJECT = "[Ditda] 이메일 인증 μ½”λ“œ"; + private static final String VERIFICATION_TEMPLATE = "email/verificationCode"; + + private final JavaMailSender mailSender; + private final SpringTemplateEngine templateEngine; + + @Async + public void sendVerificationEmail(String email, String code) { + try { + MimeMessage mimeMessage = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); + + helper.setTo(email); + helper.setSubject(VERIFICATION_SUBJECT); + helper.setText(buildHtml(code), true); + + mailSender.send(mimeMessage); + + log.info("Verification email sent. to={}", email); + } catch (Exception e) { + log.error("Verification email send failed. to={}", email, e); + } + } + + private String buildHtml(String code) { + Context context = new Context(); + context.setVariable("code", code); + return templateEngine.process(VERIFICATION_TEMPLATE, context); + } +} \ No newline at end of file diff --git a/src/main/resources/templates/email/verificationCode.html b/src/main/resources/templates/email/verificationCode.html new file mode 100644 index 0000000..2e7dd1a --- /dev/null +++ b/src/main/resources/templates/email/verificationCode.html @@ -0,0 +1,40 @@ + + + + + + + + + + +
+ + + + + + + + + + + + + +
+

Ditda

+
+

이메일 인증을 μœ„ν•΄ μ•„λž˜ μ½”λ“œλ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”.

+
+
+ +
+
+

이 μ½”λ“œλŠ” 5λΆ„κ°„ μœ νš¨ν•©λ‹ˆλ‹€.

+
+
+ + \ No newline at end of file From 2c4266b940db6b7ba4f1dc35d8cc906d0817aebb Mon Sep 17 00:00:00 2001 From: fervovita Date: Sat, 23 May 2026 03:12:10 +0900 Subject: [PATCH 19/90] =?UTF-8?q?Feat(auth):=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=EB=B2=88=ED=98=B8=20=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 35 ++++++++ .../dto/request/EmailVerificationRequest.java | 17 ++++ .../common/auth/exception/AuthErrorCode.java | 23 ++++++ .../domain/common/auth/facade/AuthFacade.java | 20 +++++ .../service/EmailVerificationService.java | 82 +++++++++++++++++++ .../backend/global/config/SecurityConfig.java | 2 + 6 files changed, 179 insertions(+) create mode 100644 src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java create mode 100644 src/main/java/ditda/backend/domain/common/auth/dto/request/EmailVerificationRequest.java create mode 100644 src/main/java/ditda/backend/domain/common/auth/exception/AuthErrorCode.java create mode 100644 src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java create mode 100644 src/main/java/ditda/backend/domain/common/auth/service/EmailVerificationService.java diff --git a/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java b/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java new file mode 100644 index 0000000..7f67eea --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java @@ -0,0 +1,35 @@ +package ditda.backend.domain.common.auth.controller; + +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; + +import ditda.backend.domain.common.auth.dto.request.EmailVerificationRequest; +import ditda.backend.domain.common.auth.facade.AuthFacade; +import ditda.backend.global.apipayload.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/auth") +@Tag(name = "Auth", description = "인증 κ΄€λ ¨ API") +public class AuthController { + + private final AuthFacade authFacade; + + @Operation(summary = "이메일 인증번호 μš”μ²­", description = "이메일 인증번호λ₯Ό μš”μ²­ν•©λ‹ˆλ‹€.") + @PostMapping("/emails/verification-request") + public ApiResponse requestEmailVerification( + @Valid @RequestBody EmailVerificationRequest request + ) { + authFacade.requestEmailVerification(request.email()); + + return ApiResponse.onSuccess("μΈμ¦λ²ˆν˜Έκ°€ λ°œμ†‘λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } +} diff --git a/src/main/java/ditda/backend/domain/common/auth/dto/request/EmailVerificationRequest.java b/src/main/java/ditda/backend/domain/common/auth/dto/request/EmailVerificationRequest.java new file mode 100644 index 0000000..7a28862 --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/auth/dto/request/EmailVerificationRequest.java @@ -0,0 +1,17 @@ +package ditda.backend.domain.common.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@Schema(description = "인증번호 λ°œμ†‘ μš”μ²­") +public record EmailVerificationRequest( + + @Schema(description = "μœ μ € 이메일", example = "test@gmail.com") + @NotBlank(message = "이메일은 ν•„μˆ˜μž…λ‹ˆλ‹€.") + @Email(message = "μ˜¬λ°”λ₯Έ 이메일 ν˜•μ‹μ΄ μ•„λ‹™λ‹ˆλ‹€.") + @Size(max = 100, message = "이메일은 100자 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€.") + String email +) { +} diff --git a/src/main/java/ditda/backend/domain/common/auth/exception/AuthErrorCode.java b/src/main/java/ditda/backend/domain/common/auth/exception/AuthErrorCode.java new file mode 100644 index 0000000..dae6d8a --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/auth/exception/AuthErrorCode.java @@ -0,0 +1,23 @@ +package ditda.backend.domain.common.auth.exception; + +import org.springframework.http.HttpStatus; + +import ditda.backend.global.apipayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum AuthErrorCode implements BaseErrorCode { + + EMAIL_VERIFICATION_COOLDOWN(HttpStatus.TOO_MANY_REQUESTS, "AUTH_429_01", + "μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”."), + EMAIL_CODE_EXPIRED(HttpStatus.BAD_REQUEST, "AUTH_400_01", + "μΈμ¦λ²ˆν˜Έκ°€ λ§Œλ£Œλ˜μ—ˆκ±°λ‚˜ λ°œκΈ‰λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€."), + EMAIL_CODE_INVALID(HttpStatus.BAD_REQUEST, "AUTH_400_02", + "μΈμ¦λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java b/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java new file mode 100644 index 0000000..71db16b --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java @@ -0,0 +1,20 @@ +package ditda.backend.domain.common.auth.facade; + +import org.springframework.stereotype.Component; + +import ditda.backend.domain.common.auth.service.EmailVerificationService; +import ditda.backend.global.email.EmailSender; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class AuthFacade { + + private final EmailVerificationService emailVerificationService; + private final EmailSender emailSender; + + public void requestEmailVerification(String email) { + String code = emailVerificationService.issueCode(email); + emailSender.sendVerificationEmail(email, code); + } +} diff --git a/src/main/java/ditda/backend/domain/common/auth/service/EmailVerificationService.java b/src/main/java/ditda/backend/domain/common/auth/service/EmailVerificationService.java new file mode 100644 index 0000000..b39f7b6 --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/auth/service/EmailVerificationService.java @@ -0,0 +1,82 @@ +package ditda.backend.domain.common.auth.service; + +import java.security.SecureRandom; +import java.time.Duration; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import ditda.backend.domain.common.auth.exception.AuthErrorCode; +import ditda.backend.global.apipayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EmailVerificationService { + + private static final String CODE_KEY_FORMAT = "auth:email:code:%s"; + private static final String VERIFIED_KEY_FORMAT = "auth:email:verified:%s"; + private static final String RESEND_LOCK_KEY_FORMAT = "auth:email:resend-lock:%s"; + + private static final Duration CODE_TTL = Duration.ofMinutes(5); // 인증 μ½”λ“œ μœ νš¨μ‹œκ°„ 5λΆ„ + private static final Duration VERIFIED_TTL = Duration.ofMinutes(30); // 인증 μ™„λ£Œ μœ νš¨μ‹œκ°„ 30λΆ„ + private static final Duration RESEND_LOCK_TTL = Duration.ofSeconds(60); // μ½”λ“œ μž¬λ°œμ†‘ μ œν•œμ‹œκ°„ 1λΆ„ + + private static final int CODE_BOUND = 10_000; + private static final SecureRandom RANDOM = new SecureRandom(); + + private final StringRedisTemplate redisTemplate; + + public String issueCode(String email) { + String lockKey = RESEND_LOCK_KEY_FORMAT.formatted(email); + + if (Boolean.TRUE.equals(redisTemplate.hasKey(lockKey))) { + throw new GeneralException(AuthErrorCode.EMAIL_VERIFICATION_COOLDOWN); + } + + String code = generateCode(); + redisTemplate.opsForValue().set(codeKey(email), code, CODE_TTL); + redisTemplate.opsForValue().set(lockKey, "1", RESEND_LOCK_TTL); + + log.info("Email verification code issued. email={}", email); + + return code; + } + + public void verifyCode(String email, String code) { + String savedCode = redisTemplate.opsForValue().get(codeKey(email)); + + if (savedCode == null) { + throw new GeneralException(AuthErrorCode.EMAIL_CODE_EXPIRED); + } + if (!savedCode.equals(code)) { + throw new GeneralException(AuthErrorCode.EMAIL_CODE_INVALID); + } + + redisTemplate.delete(codeKey(email)); + redisTemplate.opsForValue().set(verifiedKey(email), "true", VERIFIED_TTL); + } + + public boolean isVerified(String email) { + return "true".equals(redisTemplate.opsForValue().get(verifiedKey(email))); + } + + public void deleteVerified(String email) { + redisTemplate.delete(verifiedKey(email)); + } + + private String generateCode() { + return String.format("%04d", RANDOM.nextInt(CODE_BOUND)); + } + + private String codeKey(String email) { + return CODE_KEY_FORMAT.formatted(email); + } + + private String verifiedKey(String email) { + return VERIFIED_KEY_FORMAT.formatted(email); + } +} + diff --git a/src/main/java/ditda/backend/global/config/SecurityConfig.java b/src/main/java/ditda/backend/global/config/SecurityConfig.java index 5dabbc0..d13bef7 100644 --- a/src/main/java/ditda/backend/global/config/SecurityConfig.java +++ b/src/main/java/ditda/backend/global/config/SecurityConfig.java @@ -16,6 +16,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers("/api/v1/auth/reissue").authenticated() + .requestMatchers("/api/v1/auth/**").permitAll() .anyRequest().authenticated() ); From 2445e524eba0fcdc3f1db9c9b2c2b43b9037c847 Mon Sep 17 00:00:00 2001 From: fervovita Date: Sat, 23 May 2026 03:14:03 +0900 Subject: [PATCH 20/90] =?UTF-8?q?Style:=20=ED=8C=8C=EC=9D=BC=20=EB=81=9D?= =?UTF-8?q?=20=EA=B0=9C=ED=96=89=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/ditda/backend/global/email/EmailSender.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/ditda/backend/global/email/EmailSender.java b/src/main/java/ditda/backend/global/email/EmailSender.java index c9401a8..314ae1f 100644 --- a/src/main/java/ditda/backend/global/email/EmailSender.java +++ b/src/main/java/ditda/backend/global/email/EmailSender.java @@ -45,4 +45,4 @@ private String buildHtml(String code) { context.setVariable("code", code); return templateEngine.process(VERIFICATION_TEMPLATE, context); } -} \ No newline at end of file +} From dd962810e5c9b7eb71461a47ca039ded54c17447 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Sat, 23 May 2026 03:14:07 +0900 Subject: [PATCH 21/90] =?UTF-8?q?Feat(user):=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EC=98=88=EC=99=B8=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EB=B0=8F=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../term/repository/UserTermRepository.java | 8 +++++++ .../domain/common/user/entity/UserEntity.java | 24 ++++++++++++++++++- .../common/user/exceptions/UserErrorCode.java | 21 ++++++++++++++++ .../user/repository/UserEntityRepository.java | 11 +++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 src/main/java/ditda/backend/domain/common/term/repository/UserTermRepository.java create mode 100644 src/main/java/ditda/backend/domain/common/user/exceptions/UserErrorCode.java create mode 100644 src/main/java/ditda/backend/domain/common/user/repository/UserEntityRepository.java diff --git a/src/main/java/ditda/backend/domain/common/term/repository/UserTermRepository.java b/src/main/java/ditda/backend/domain/common/term/repository/UserTermRepository.java new file mode 100644 index 0000000..4084c46 --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/term/repository/UserTermRepository.java @@ -0,0 +1,8 @@ +package ditda.backend.domain.common.term.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import ditda.backend.domain.common.term.entity.UserTerm; + +public interface UserTermRepository extends JpaRepository { +} diff --git a/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java b/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java index d8f901b..79edf73 100644 --- a/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java +++ b/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java @@ -56,6 +56,28 @@ public class UserEntity extends BaseEntity { @Column(name = "address") private String address; - @Column(name = "email_verified_at", nullable = true) + @Column(name = "email_verified_at") private LocalDateTime emailVerifiedAt; + + public static UserEntity createUser( + String username, + String password, + String name, + String email, + String profileImage, + String phone, + UserRole role, + String address + ) { + return UserEntity.builder() + .username(username) + .password(password) + .name(name) + .email(email) + .profileImage(profileImage) + .phone(phone) + .role(role) + .address(address) + .build(); + } } diff --git a/src/main/java/ditda/backend/domain/common/user/exceptions/UserErrorCode.java b/src/main/java/ditda/backend/domain/common/user/exceptions/UserErrorCode.java new file mode 100644 index 0000000..8d78482 --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/user/exceptions/UserErrorCode.java @@ -0,0 +1,21 @@ +package ditda.backend.domain.common.user.exceptions; + +import org.springframework.http.HttpStatus; + +import ditda.backend.global.apipayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum UserErrorCode implements BaseErrorCode { + + USER_NOT_AGREED_TERMS(HttpStatus.BAD_REQUEST, "USER_400_01", "ν•„μˆ˜ 약관에 λ™μ˜ν•΄μ•Ό ν•©λ‹ˆλ‹€."), + USERNAME_ALREADY_EXISTS(HttpStatus.CONFLICT, "USER_409_01", "이미 μ‚¬μš© 쀑인 μ•„μ΄λ””μž…λ‹ˆλ‹€."), + EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "USER_409_02", "이미 μ‚¬μš© 쀑인 μ΄λ©”μΌμž…λ‹ˆλ‹€."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_01", "μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μœ μ €μž…λ‹ˆλ‹€."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/ditda/backend/domain/common/user/repository/UserEntityRepository.java b/src/main/java/ditda/backend/domain/common/user/repository/UserEntityRepository.java new file mode 100644 index 0000000..3b4978a --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/user/repository/UserEntityRepository.java @@ -0,0 +1,11 @@ +package ditda.backend.domain.common.user.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import ditda.backend.domain.common.user.entity.UserEntity; + +public interface UserEntityRepository extends JpaRepository { + boolean existsByUsername(String username); + + boolean existsByEmail(String email); +} From c5e7c02497048cb5c6326342d24a67c13a89e179 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Sat, 23 May 2026 03:14:33 +0900 Subject: [PATCH 22/90] =?UTF-8?q?Feat(instructor):=20Instructor=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20=EB=A0=88=ED=8F=AC?= =?UTF-8?q?=EC=A7=80=ED=86=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../instructor/auth/entity/Instructor.java | 40 +++++++++++++++++++ .../auth/repository/InstructorRepository.java | 8 ++++ 2 files changed, 48 insertions(+) create mode 100644 src/main/java/ditda/backend/domain/instructor/auth/entity/Instructor.java create mode 100644 src/main/java/ditda/backend/domain/instructor/auth/repository/InstructorRepository.java diff --git a/src/main/java/ditda/backend/domain/instructor/auth/entity/Instructor.java b/src/main/java/ditda/backend/domain/instructor/auth/entity/Instructor.java new file mode 100644 index 0000000..5ad837d --- /dev/null +++ b/src/main/java/ditda/backend/domain/instructor/auth/entity/Instructor.java @@ -0,0 +1,40 @@ +package ditda.backend.domain.instructor.auth.entity; + +import ditda.backend.domain.common.user.entity.UserEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "instructors") +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Instructor { + + @Id + @Column(name = "instructor_id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @MapsId + @JoinColumn(name = "instructor_id") + private UserEntity user; + + public static Instructor createInstructor(UserEntity user) { + return Instructor.builder() + .user(user) + .build(); + } +} diff --git a/src/main/java/ditda/backend/domain/instructor/auth/repository/InstructorRepository.java b/src/main/java/ditda/backend/domain/instructor/auth/repository/InstructorRepository.java new file mode 100644 index 0000000..80148b2 --- /dev/null +++ b/src/main/java/ditda/backend/domain/instructor/auth/repository/InstructorRepository.java @@ -0,0 +1,8 @@ +package ditda.backend.domain.instructor.auth.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import ditda.backend.domain.instructor.auth.entity.Instructor; + +public interface InstructorRepository extends JpaRepository { +} From 9bc1588a6be56c5a03aa8c7d81599e677a224857 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Sat, 23 May 2026 03:14:44 +0900 Subject: [PATCH 23/90] =?UTF-8?q?Feat(auth):=20=EA=B3=B5=ED=86=B5=20Refres?= =?UTF-8?q?hToken=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20=EB=A0=88?= =?UTF-8?q?=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/auth/entity/RefreshToken.java | 54 +++++++++++++++++++ .../repository/RefreshTokenRepository.java | 11 ++++ 2 files changed, 65 insertions(+) create mode 100644 src/main/java/ditda/backend/domain/common/auth/entity/RefreshToken.java create mode 100644 src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java diff --git a/src/main/java/ditda/backend/domain/common/auth/entity/RefreshToken.java b/src/main/java/ditda/backend/domain/common/auth/entity/RefreshToken.java new file mode 100644 index 0000000..5442d2b --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/auth/entity/RefreshToken.java @@ -0,0 +1,54 @@ +package ditda.backend.domain.common.auth.entity; + +import java.time.LocalDateTime; + +import ditda.backend.domain.common.user.entity.UserEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "refresh_tokens") +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RefreshToken { + + @Id + private Long userId; + + @OneToOne(fetch = FetchType.LAZY) + @MapsId + @JoinColumn(name = "user_id") + private UserEntity user; + + @Column(name = "refresh_token", nullable = false, length = 512) + private String token; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + public static RefreshToken createRefreshToken(UserEntity user, String token, LocalDateTime expiresAt) { + return RefreshToken.builder() + .user(user) + .token(token) + .expiresAt(expiresAt) + .build(); + } + + public void rotate(String newToken, LocalDateTime newExpiresAt) { + this.token = newToken; + this.expiresAt = newExpiresAt; + } +} diff --git a/src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java b/src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..49f5357 --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,11 @@ +package ditda.backend.domain.common.auth.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import ditda.backend.domain.common.auth.entity.RefreshToken; + +public interface RefreshTokenRepository extends JpaRepository { + Optional findByUserId(Long userId); +} From 12de170d3ab23392494fa38798f6469491e6952d Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Sat, 23 May 2026 03:15:00 +0900 Subject: [PATCH 24/90] =?UTF-8?q?Feat(auth):=20=EA=B0=95=EC=82=AC=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=EB=B0=8F=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EB=94=94=20=EC=A4=91=EB=B3=B5=20=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/InstructorAuthController.java | 53 +++++++ .../auth/dto/InstructorAuthResult.java | 13 ++ .../dto/request/CheckUsernameRequest.java | 15 ++ .../dto/request/InstructorSignupRequest.java | 100 +++++++++++++ .../response/InstructorSignupResponse.java | 14 ++ .../auth/service/InstructorAuthService.java | 136 ++++++++++++++++++ 6 files changed, 331 insertions(+) create mode 100644 src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java create mode 100644 src/main/java/ditda/backend/domain/instructor/auth/dto/InstructorAuthResult.java create mode 100644 src/main/java/ditda/backend/domain/instructor/auth/dto/request/CheckUsernameRequest.java create mode 100644 src/main/java/ditda/backend/domain/instructor/auth/dto/request/InstructorSignupRequest.java create mode 100644 src/main/java/ditda/backend/domain/instructor/auth/dto/response/InstructorSignupResponse.java create mode 100644 src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java diff --git a/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java b/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java new file mode 100644 index 0000000..0e45411 --- /dev/null +++ b/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java @@ -0,0 +1,53 @@ +package ditda.backend.domain.instructor.auth.controller; + +import org.springframework.http.HttpHeaders; +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; + +import ditda.backend.domain.instructor.auth.dto.InstructorAuthResult; +import ditda.backend.domain.instructor.auth.dto.request.CheckUsernameRequest; +import ditda.backend.domain.instructor.auth.dto.request.InstructorSignupRequest; +import ditda.backend.domain.instructor.auth.dto.response.InstructorSignupResponse; +import ditda.backend.domain.instructor.auth.service.InstructorAuthService; +import ditda.backend.global.apipayload.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/instructor") +@RequiredArgsConstructor +@Tag(name = "Instructor Auth", description = "강사 νšŒμ›κ°€μž… API") +public class InstructorAuthController { + + private final InstructorAuthService instructorAuthService; + + @Operation(summary = "강사 아이디 쀑볡 확인", description = "**[νšŒμ›κ°€μž…]** μ‚¬μš© κ°€λŠ₯ν•œ 아이디인지 ν™•μΈν•©λ‹ˆλ‹€.") + @PostMapping("/check-username") + public ApiResponse checkUsername( + @Valid @RequestBody CheckUsernameRequest request + ) { + + instructorAuthService.validateUsernameAvailable(request.username()); + return ApiResponse.onSuccess("아이디 μ‚¬μš© κ°€λŠ₯ μ—¬λΆ€ 쑰회 성곡"); + } + + @Operation(summary = "강사 νšŒμ›κ°€μž…", description = "**[νšŒμ›κ°€μž…]** νšŒμ›κ°€μž… ν›„ μžλ™ 둜그인 μ²˜λ¦¬λ©λ‹ˆλ‹€.") + @PostMapping("/signup") + public ApiResponse signup( + @Valid @RequestBody InstructorSignupRequest request, + HttpServletResponse response + ) { + + InstructorAuthResult result = instructorAuthService.signup(request); + response.addHeader(HttpHeaders.SET_COOKIE, result.refreshTokenCookie().toString()); + + return ApiResponse.onSuccess("강사 νšŒμ›κ°€μž… 성곡", + new InstructorSignupResponse(result.userId(), result.accessToken())); + } + +} diff --git a/src/main/java/ditda/backend/domain/instructor/auth/dto/InstructorAuthResult.java b/src/main/java/ditda/backend/domain/instructor/auth/dto/InstructorAuthResult.java new file mode 100644 index 0000000..939e47c --- /dev/null +++ b/src/main/java/ditda/backend/domain/instructor/auth/dto/InstructorAuthResult.java @@ -0,0 +1,13 @@ +package ditda.backend.domain.instructor.auth.dto; + +import org.springframework.http.ResponseCookie; + +public record InstructorAuthResult( + + Long userId, + + String accessToken, + + ResponseCookie refreshTokenCookie +) { +} diff --git a/src/main/java/ditda/backend/domain/instructor/auth/dto/request/CheckUsernameRequest.java b/src/main/java/ditda/backend/domain/instructor/auth/dto/request/CheckUsernameRequest.java new file mode 100644 index 0000000..db5fc1d --- /dev/null +++ b/src/main/java/ditda/backend/domain/instructor/auth/dto/request/CheckUsernameRequest.java @@ -0,0 +1,15 @@ +package ditda.backend.domain.instructor.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@Schema(description = "μœ μ € 아이디 쀑볡 확인 μš”μ²­") +public record CheckUsernameRequest( + + @Schema(description = "μœ μ € ID (6 ~ 20자 이내)", example = "testid123") + @NotBlank(message = "μ•„μ΄λ””λŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") + @Size(min = 6, max = 20, message = "μ•„μ΄λ””λŠ” 6자 이상 20자 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€.") + String username +) { +} diff --git a/src/main/java/ditda/backend/domain/instructor/auth/dto/request/InstructorSignupRequest.java b/src/main/java/ditda/backend/domain/instructor/auth/dto/request/InstructorSignupRequest.java new file mode 100644 index 0000000..4e0c8fd --- /dev/null +++ b/src/main/java/ditda/backend/domain/instructor/auth/dto/request/InstructorSignupRequest.java @@ -0,0 +1,100 @@ +package ditda.backend.domain.instructor.auth.dto.request; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import ditda.backend.domain.common.term.entity.enums.TermType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +@Schema(description = "강사 νšŒμ›κ°€μž… μš”μ²­") +public record InstructorSignupRequest( + + @Schema( + description = "μ•½κ΄€", + example = """ + [ + { + "type": "SERVICE", + "version": "V1.0", + "isAgreed": true + }, + { + "type": "USERINFO", + "version": "V1.0", + "isAgreed": true + }, + { + "type": "SETTLEMENT", + "version": "V1.0", + "isAgreed": true + }, + { + "type": "DISINTERMEDIATION", + "version": "V1.0", + "isAgreed": true + } + ] + """ + ) + @NotEmpty(message = "μ•½κ΄€ λ™μ˜ μ—¬λΆ€ λ‚΄μš©μ€ ν•„μˆ˜μž…λ‹ˆλ‹€") + @Valid + List<@NotNull(message = "μ•½κ΄€ ν•­λͺ©μ€ ν•„μˆ˜μž…λ‹ˆλ‹€.") @Valid TermRequest> terms, + + @Schema(description = "이름", example = "홍길동") + @NotBlank(message = "이름은 ν•„μˆ˜μž…λ‹ˆλ‹€.") + @Size(max = 50, message = "μ˜¬λ°”λ₯΄μ§€ μ•Šμ€ μ΄λ¦„μž…λ‹ˆλ‹€.") + String name, + + @Schema(description = "μ „ν™”λ²ˆν˜Έ", example = "01012345678") + @NotBlank(message = "μ „ν™”λ²ˆν˜Έκ°€ μ—†μŠ΅λ‹ˆλ‹€.") + @Pattern( + regexp = "^010\\d{7,8}$", + message = "μ „ν™”λ²ˆν˜Έ ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€." + ) + String phone, + + @Schema(description = "μ£Όμ†Œ", example = "μ„œμšΈμ‹œ 마포ꡬ ν™μ΅λŒ€ν•™κ΅") + String address, + + @Schema(description = "아이디 (6 ~ 20자 이내)", example = "testid123") + @NotBlank(message = "μ•„μ΄λ””λŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") + @Size(min = 6, max = 20, message = "μ•„μ΄λ””λŠ” 6자 이상 20자 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€.") + String username, + + @Schema(description = "λΉ„λ°€λ²ˆν˜Έ", example = "password1234") + @NotBlank(message = "λΉ„λ°€λ²ˆν˜ΈλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") + @Size(min = 8, max = 20, message = "λΉ„λ°€λ²ˆν˜ΈλŠ” 8자 이상 20자 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€.") + @Pattern( + regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", + message = "λΉ„λ°€λ²ˆν˜ΈλŠ” 영문, 숫자 1개 이상 포함해야 ν•©λ‹ˆλ‹€." + ) + String password, + + @Schema(description = "이메일", example = "testid@gmail.com") + @NotBlank(message = "이메일은 ν•„μˆ˜μž…λ‹ˆλ‹€.") + @Email(message = "μ˜¬λ°”λ₯Έ 이메일 ν˜•μ‹μ΄ μ•„λ‹™λ‹ˆλ‹€.") + @Size(max = 100, message = "이메일은 100자 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€.") + String email +) { + + public record TermRequest( + + @NotNull(message = "μ•½κ΄€ μ’…λ₯˜ ν•„μˆ˜μž…λ‹ˆλ‹€.") + TermType type, + + @NotBlank(message = "μ•½κ΄€ 버전 ν•„μˆ˜μž…λ‹ˆλ‹€.") + String version, + + @JsonProperty("isAgreed") + boolean isAgreed + ) { + } +} diff --git a/src/main/java/ditda/backend/domain/instructor/auth/dto/response/InstructorSignupResponse.java b/src/main/java/ditda/backend/domain/instructor/auth/dto/response/InstructorSignupResponse.java new file mode 100644 index 0000000..cbbd396 --- /dev/null +++ b/src/main/java/ditda/backend/domain/instructor/auth/dto/response/InstructorSignupResponse.java @@ -0,0 +1,14 @@ +package ditda.backend.domain.instructor.auth.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "강사 νšŒμ›κ°€μž… 응닡") +public record InstructorSignupResponse( + + @Schema(description = "User ID") + Long userId, + + @Schema(description = "Access Token") + String accessToken +) { +} diff --git a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java new file mode 100644 index 0000000..3046792 --- /dev/null +++ b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java @@ -0,0 +1,136 @@ +package ditda.backend.domain.instructor.auth.service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import org.springframework.http.ResponseCookie; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ditda.backend.domain.common.term.entity.UserTerm; +import ditda.backend.domain.common.term.entity.enums.TermType; +import ditda.backend.domain.common.term.repository.UserTermRepository; +import ditda.backend.domain.common.user.entity.UserEntity; +import ditda.backend.domain.common.user.entity.enums.UserRole; +import ditda.backend.domain.common.user.exceptions.UserErrorCode; +import ditda.backend.domain.common.user.repository.UserEntityRepository; +import ditda.backend.domain.instructor.auth.dto.InstructorAuthResult; +import ditda.backend.domain.instructor.auth.dto.request.InstructorSignupRequest; +import ditda.backend.domain.instructor.auth.entity.Instructor; +import ditda.backend.domain.instructor.auth.repository.InstructorRepository; +import ditda.backend.global.apipayload.exception.GeneralException; +import ditda.backend.global.jwt.JwtTokenProvider; +import ditda.backend.domain.common.auth.entity.RefreshToken; +import ditda.backend.domain.common.auth.repository.RefreshTokenRepository; +import ditda.backend.global.jwt.utils.CookieUtils; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class InstructorAuthService { + + private static final String DEFAULT_PROFILE_IMAGE = ""; + private static final Set REQUIRED_TERMS = Set.of( + TermType.SERVICE, + TermType.USERINFO, + TermType.SETTLEMENT, + TermType.DISINTERMEDIATION + ); + + private final UserEntityRepository userEntityRepository; + private final InstructorRepository instructorRepository; + private final UserTermRepository userTermRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + private final CookieUtils cookieUtils; + + @Transactional(readOnly = true) + public void validateUsernameAvailable(String username) { + + if (userEntityRepository.existsByUsername(username)) { + throw new GeneralException(UserErrorCode.USERNAME_ALREADY_EXISTS); + } + } + + @Transactional + public InstructorAuthResult signup(InstructorSignupRequest request) { + + validateUsernameAvailable(request.username()); + validateEmailAvailable(request); + validateRequiredTerms(request.terms()); + + UserEntity user = UserEntity.createUser( + request.username(), + passwordEncoder.encode(request.password()), + request.name(), + request.email(), + DEFAULT_PROFILE_IMAGE, + request.phone(), + UserRole.INSTRUCTOR, + request.address() + ); + + userEntityRepository.save(user); + + List terms = request.terms().stream() + .map(term -> UserTerm.createTerm( + user, + term.type(), + term.version(), + term.isAgreed() + )) + .toList(); + + userTermRepository.saveAll(terms); + + Instructor instructor = instructorRepository.save( + Instructor.createInstructor(user) + ); + + return issueTokens(instructor.getId()); + } + + private void validateEmailAvailable(InstructorSignupRequest request) { + + if (userEntityRepository.existsByEmail(request.email())) { + throw new GeneralException(UserErrorCode.EMAIL_ALREADY_EXISTS); + } + } + + private void validateRequiredTerms(List terms) { + + for (TermType requiredTerm : REQUIRED_TERMS) { + boolean isAgreed = terms.stream() + .anyMatch(term -> term.type() == requiredTerm && term.isAgreed()); + + if (!isAgreed) { + throw new GeneralException(UserErrorCode.USER_NOT_AGREED_TERMS); + } + } + } + + private InstructorAuthResult issueTokens(Long userId) { + + String accessToken = jwtTokenProvider.generateAccessToken(userId); + String refreshToken = jwtTokenProvider.generateRefreshToken(userId); + + LocalDateTime expiresAt = jwtTokenProvider.getExpiration(refreshToken); + + refreshTokenRepository.findByUserId(userId) + .ifPresentOrElse( + rt -> rt.rotate((refreshToken), expiresAt), + () -> refreshTokenRepository.save( + RefreshToken.createRefreshToken( + userEntityRepository.getReferenceById(userId), + refreshToken, + expiresAt + ))); + + ResponseCookie cookie = cookieUtils.createRefreshTokenCookie(refreshToken); + + return new InstructorAuthResult(userId, accessToken, cookie); + } +} From d8ee0de223777b0f64ac9000086a84f0b265dded Mon Sep 17 00:00:00 2001 From: fervovita Date: Sat, 23 May 2026 03:22:38 +0900 Subject: [PATCH 25/90] =?UTF-8?q?Chore:=20SecurityConfig=20=EB=B3=B4?= =?UTF-8?q?=EC=95=88=20=EC=A0=95=EC=B1=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/config/SecurityConfig.java | 60 ++++++++++++++++++- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/src/main/java/ditda/backend/global/config/SecurityConfig.java b/src/main/java/ditda/backend/global/config/SecurityConfig.java index d13bef7..708ddda 100644 --- a/src/main/java/ditda/backend/global/config/SecurityConfig.java +++ b/src/main/java/ditda/backend/global/config/SecurityConfig.java @@ -1,28 +1,82 @@ package ditda.backend.global.config; +import java.util.List; + import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import ditda.backend.global.jwt.filter.JwtTokenFilter; +import ditda.backend.global.jwt.handler.JwtAccessDeniedHandler; +import ditda.backend.global.jwt.handler.JwtAuthenticationEntryPoint; +import lombok.RequiredArgsConstructor; @Configuration +@EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final JwtTokenFilter jwtTokenFilter; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http // csrf λΉ„ν™œμ„±ν™” .csrf(AbstractHttpConfigurer::disable) + // form login λΉ„ν™œμ„±ν™” + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + // cors μ„€μ • + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + // μ„Έμ…˜ stateless + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // 인증/인가 μ˜ˆμ™Έ 처리 + .exceptionHandling(exception -> exception + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler) + ) + // api μ ‘κ·Ό κΆŒν•œ μ„€μ • .authorizeHttpRequests(auth -> auth - .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() - .requestMatchers("/api/v1/auth/reissue").authenticated() + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() + .requestMatchers(HttpMethod.POST, "/api/v1/auth/reissue").authenticated() .requestMatchers("/api/v1/auth/**").permitAll() .anyRequest().authenticated() - ); + ) + .addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } + private CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowCredentials(true); + config.setAllowedOriginPatterns(List.of("http://localhost:3000", "http://localhost:5173")); + config.setAllowedHeaders(List.of("*")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } } From c002576c8d90de62d71d302d4ca52adc6f46c471 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Sat, 23 May 2026 03:27:11 +0900 Subject: [PATCH 26/90] =?UTF-8?q?Fix(auth):=20=EA=B0=95=EC=82=AC=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=A3=BC=EC=86=8C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/common/user/entity/UserEntity.java | 7 +------ .../auth/dto/request/InstructorSignupRequest.java | 3 --- .../instructor/auth/service/InstructorAuthService.java | 7 +++---- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java b/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java index 79edf73..ea4aec2 100644 --- a/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java +++ b/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java @@ -53,9 +53,6 @@ public class UserEntity extends BaseEntity { @Column(name = "role", nullable = false) private UserRole role; - @Column(name = "address") - private String address; - @Column(name = "email_verified_at") private LocalDateTime emailVerifiedAt; @@ -66,8 +63,7 @@ public static UserEntity createUser( String email, String profileImage, String phone, - UserRole role, - String address + UserRole role ) { return UserEntity.builder() .username(username) @@ -77,7 +73,6 @@ public static UserEntity createUser( .profileImage(profileImage) .phone(phone) .role(role) - .address(address) .build(); } } diff --git a/src/main/java/ditda/backend/domain/instructor/auth/dto/request/InstructorSignupRequest.java b/src/main/java/ditda/backend/domain/instructor/auth/dto/request/InstructorSignupRequest.java index 4e0c8fd..ee9165b 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/dto/request/InstructorSignupRequest.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/dto/request/InstructorSignupRequest.java @@ -61,9 +61,6 @@ public record InstructorSignupRequest( ) String phone, - @Schema(description = "μ£Όμ†Œ", example = "μ„œμšΈμ‹œ 마포ꡬ ν™μ΅λŒ€ν•™κ΅") - String address, - @Schema(description = "아이디 (6 ~ 20자 이내)", example = "testid123") @NotBlank(message = "μ•„μ΄λ””λŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") @Size(min = 6, max = 20, message = "μ•„μ΄λ””λŠ” 6자 이상 20자 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€.") diff --git a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java index 3046792..724f5fa 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java @@ -9,6 +9,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import ditda.backend.domain.common.auth.entity.RefreshToken; +import ditda.backend.domain.common.auth.repository.RefreshTokenRepository; import ditda.backend.domain.common.term.entity.UserTerm; import ditda.backend.domain.common.term.entity.enums.TermType; import ditda.backend.domain.common.term.repository.UserTermRepository; @@ -22,8 +24,6 @@ import ditda.backend.domain.instructor.auth.repository.InstructorRepository; import ditda.backend.global.apipayload.exception.GeneralException; import ditda.backend.global.jwt.JwtTokenProvider; -import ditda.backend.domain.common.auth.entity.RefreshToken; -import ditda.backend.domain.common.auth.repository.RefreshTokenRepository; import ditda.backend.global.jwt.utils.CookieUtils; import lombok.RequiredArgsConstructor; @@ -69,8 +69,7 @@ public InstructorAuthResult signup(InstructorSignupRequest request) { request.email(), DEFAULT_PROFILE_IMAGE, request.phone(), - UserRole.INSTRUCTOR, - request.address() + UserRole.INSTRUCTOR ); userEntityRepository.save(user); From ca4218599f67b59e4ffd4a2cc885dadb7ceab279 Mon Sep 17 00:00:00 2001 From: fervovita Date: Sat, 23 May 2026 03:33:19 +0900 Subject: [PATCH 27/90] =?UTF-8?q?Feat(auth):=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=EB=B2=88=ED=98=B8=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 10 ++++++++ .../request/EmailCodeVerificationRequest.java | 23 +++++++++++++++++++ .../domain/common/auth/facade/AuthFacade.java | 6 +++++ 3 files changed, 39 insertions(+) create mode 100644 src/main/java/ditda/backend/domain/common/auth/dto/request/EmailCodeVerificationRequest.java diff --git a/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java b/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java index 7f67eea..d59052c 100644 --- a/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java +++ b/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java @@ -5,6 +5,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import ditda.backend.domain.common.auth.dto.request.EmailCodeVerificationRequest; import ditda.backend.domain.common.auth.dto.request.EmailVerificationRequest; import ditda.backend.domain.common.auth.facade.AuthFacade; import ditda.backend.global.apipayload.response.ApiResponse; @@ -32,4 +33,13 @@ public ApiResponse requestEmailVerification( return ApiResponse.onSuccess("μΈμ¦λ²ˆν˜Έκ°€ λ°œμ†‘λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); } + + @Operation(summary = "이메일 인증번호 검증", description = "이메일 인증번호λ₯Ό κ²€μ¦ν•©λ‹ˆλ‹€.") + @PostMapping("/emails/verification") + public ApiResponse verifyEmailCode( + @Valid @RequestBody EmailCodeVerificationRequest request + ) { + authFacade.verifyEmailCode(request.email(), request.code()); + return ApiResponse.onSuccess("이메일 인증이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } } diff --git a/src/main/java/ditda/backend/domain/common/auth/dto/request/EmailCodeVerificationRequest.java b/src/main/java/ditda/backend/domain/common/auth/dto/request/EmailCodeVerificationRequest.java new file mode 100644 index 0000000..4300346 --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/auth/dto/request/EmailCodeVerificationRequest.java @@ -0,0 +1,23 @@ +package ditda.backend.domain.common.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +@Schema(description = "인증번호 검증 μš”μ²­") +public record EmailCodeVerificationRequest( + + @Schema(description = "μœ μ € 이메일", example = "test@gmail.com") + @NotBlank(message = "이메일은 ν•„μˆ˜μž…λ‹ˆλ‹€.") + @Email(message = "μ˜¬λ°”λ₯Έ 이메일 ν˜•μ‹μ΄ μ•„λ‹™λ‹ˆλ‹€.") + @Size(max = 100, message = "이메일은 100자 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€.") + String email, + + @Schema(description = "인증번호", example = "1234") + @NotBlank(message = "μΈμ¦λ²ˆν˜ΈλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") + @Pattern(regexp = "\\d{4}", message = "μΈμ¦λ²ˆν˜ΈλŠ” 4자리 μˆ«μžμž…λ‹ˆλ‹€.") + String code +) { +} diff --git a/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java b/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java index 71db16b..4da0542 100644 --- a/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java +++ b/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java @@ -13,8 +13,14 @@ public class AuthFacade { private final EmailVerificationService emailVerificationService; private final EmailSender emailSender; + // 이메일 인증번호 λ°œμ†‘ public void requestEmailVerification(String email) { String code = emailVerificationService.issueCode(email); emailSender.sendVerificationEmail(email, code); } + + // 이메일 인증번호 검증 + public void verifyEmailCode(String email, String code) { + emailVerificationService.verifyCode(email, code); + } } From acbd4f04017c1032c434aae1208626c4d0f3a1ed Mon Sep 17 00:00:00 2001 From: fervovita Date: Sat, 23 May 2026 03:40:54 +0900 Subject: [PATCH 28/90] =?UTF-8?q?Chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EC=97=90=20Spring=20Mail=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/application-test.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 8bdf291..846396f 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -15,6 +15,19 @@ spring: port: 6370 password: + spring: + mail: + host: 127.0.0.1 + port: 3025 + username: test + password: test + properties: + mail: + smtp: + auth: true + starttls: + enable: false + cloud: aws: credentials: From de6ed75b9037f567bc4138862662412338759bd7 Mon Sep 17 00:00:00 2001 From: fervovita Date: Sat, 23 May 2026 03:44:56 +0900 Subject: [PATCH 29/90] =?UTF-8?q?Fix:=20application-test.yaml=EC=9D=98=20s?= =?UTF-8?q?pring.mail=20=EB=93=A4=EC=97=AC=EC=93=B0=EA=B8=B0=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/application-test.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 846396f..28d5d12 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -15,7 +15,6 @@ spring: port: 6370 password: - spring: mail: host: 127.0.0.1 port: 3025 From 8995d143ff5c82671bfd39752e1c5a7567b4396a Mon Sep 17 00:00:00 2001 From: fervovita Date: Sat, 23 May 2026 03:48:23 +0900 Subject: [PATCH 30/90] =?UTF-8?q?Fix:=20application-test.yaml=EC=9D=98=20s?= =?UTF-8?q?pring.mail=20=EB=93=A4=EC=97=AC=EC=93=B0=EA=B8=B0=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/application-test.yaml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 28d5d12..c0daf4f 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -15,17 +15,17 @@ spring: port: 6370 password: - mail: - host: 127.0.0.1 - port: 3025 - username: test - password: test - properties: - mail: - smtp: - auth: true - starttls: - enable: false + mail: + host: 127.0.0.1 + port: 3025 + username: test + password: test + properties: + mail: + smtp: + auth: true + starttls: + enable: false cloud: aws: From 12bcfcbbfc8fca47e4b4abc09574a372633f1516 Mon Sep 17 00:00:00 2001 From: fervovita Date: Sat, 23 May 2026 03:50:00 +0900 Subject: [PATCH 31/90] =?UTF-8?q?Fix(auth):=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20API=20=EA=B2=BD=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/common/auth/controller/AuthController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java b/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java index d59052c..f5c6c8a 100644 --- a/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java +++ b/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java @@ -25,7 +25,7 @@ public class AuthController { private final AuthFacade authFacade; @Operation(summary = "이메일 인증번호 μš”μ²­", description = "이메일 인증번호λ₯Ό μš”μ²­ν•©λ‹ˆλ‹€.") - @PostMapping("/emails/verification-request") + @PostMapping("/emails/verification-requests") public ApiResponse requestEmailVerification( @Valid @RequestBody EmailVerificationRequest request ) { @@ -35,7 +35,7 @@ public ApiResponse requestEmailVerification( } @Operation(summary = "이메일 인증번호 검증", description = "이메일 인증번호λ₯Ό κ²€μ¦ν•©λ‹ˆλ‹€.") - @PostMapping("/emails/verification") + @PostMapping("/emails/verifications") public ApiResponse verifyEmailCode( @Valid @RequestBody EmailCodeVerificationRequest request ) { From da5cfb5b415d275b17278932d971fec1f566eae4 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Sat, 23 May 2026 05:04:29 +0900 Subject: [PATCH 32/90] =?UTF-8?q?Feat(auth):=20=EA=B0=95=EC=82=AC=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=9D=B4=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20InstructorAuthFaca?= =?UTF-8?q?de=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/InstructorAuthController.java | 10 +++--- .../auth/facade/InstructorAuthFacade.java | 32 +++++++++++++++++++ .../auth/service/InstructorAuthService.java | 1 + .../backend/global/config/SecurityConfig.java | 6 ++-- 4 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java diff --git a/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java b/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java index 0e45411..b4efa0b 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java @@ -10,7 +10,7 @@ import ditda.backend.domain.instructor.auth.dto.request.CheckUsernameRequest; import ditda.backend.domain.instructor.auth.dto.request.InstructorSignupRequest; import ditda.backend.domain.instructor.auth.dto.response.InstructorSignupResponse; -import ditda.backend.domain.instructor.auth.service.InstructorAuthService; +import ditda.backend.domain.instructor.auth.facade.InstructorAuthFacade; import ditda.backend.global.apipayload.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -24,7 +24,7 @@ @Tag(name = "Instructor Auth", description = "강사 νšŒμ›κ°€μž… API") public class InstructorAuthController { - private final InstructorAuthService instructorAuthService; + private final InstructorAuthFacade instructorAuthFacade; @Operation(summary = "강사 아이디 쀑볡 확인", description = "**[νšŒμ›κ°€μž…]** μ‚¬μš© κ°€λŠ₯ν•œ 아이디인지 ν™•μΈν•©λ‹ˆλ‹€.") @PostMapping("/check-username") @@ -32,7 +32,7 @@ public ApiResponse checkUsername( @Valid @RequestBody CheckUsernameRequest request ) { - instructorAuthService.validateUsernameAvailable(request.username()); + instructorAuthFacade.validateUsernameAvailable(request.username()); return ApiResponse.onSuccess("아이디 μ‚¬μš© κ°€λŠ₯ μ—¬λΆ€ 쑰회 성곡"); } @@ -43,11 +43,11 @@ public ApiResponse signup( HttpServletResponse response ) { - InstructorAuthResult result = instructorAuthService.signup(request); + InstructorAuthResult result = instructorAuthFacade.signup(request); + response.addHeader(HttpHeaders.SET_COOKIE, result.refreshTokenCookie().toString()); return ApiResponse.onSuccess("강사 νšŒμ›κ°€μž… 성곡", new InstructorSignupResponse(result.userId(), result.accessToken())); } - } diff --git a/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java b/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java new file mode 100644 index 0000000..97c84f0 --- /dev/null +++ b/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java @@ -0,0 +1,32 @@ +package ditda.backend.domain.instructor.auth.facade; + +import org.springframework.stereotype.Component; + +import ditda.backend.domain.common.auth.service.EmailVerificationService; +import ditda.backend.domain.instructor.auth.dto.InstructorAuthResult; +import ditda.backend.domain.instructor.auth.dto.request.InstructorSignupRequest; +import ditda.backend.domain.instructor.auth.service.InstructorAuthService; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class InstructorAuthFacade { + + private final InstructorAuthService instructorAuthService; + private final EmailVerificationService emailVerificationService; + + public InstructorAuthResult signup(InstructorSignupRequest request) { + + emailVerificationService.isVerified(request.email()); + + InstructorAuthResult result = instructorAuthService.signup(request); + + emailVerificationService.deleteVerified(request.email()); + + return result; + } + + public void validateUsernameAvailable(String username) { + instructorAuthService.validateUsernameAvailable(username); + } +} diff --git a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java index 724f5fa..1879046 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java @@ -11,6 +11,7 @@ import ditda.backend.domain.common.auth.entity.RefreshToken; import ditda.backend.domain.common.auth.repository.RefreshTokenRepository; +import ditda.backend.domain.common.auth.service.EmailVerificationService; import ditda.backend.domain.common.term.entity.UserTerm; import ditda.backend.domain.common.term.entity.enums.TermType; import ditda.backend.domain.common.term.repository.UserTermRepository; diff --git a/src/main/java/ditda/backend/global/config/SecurityConfig.java b/src/main/java/ditda/backend/global/config/SecurityConfig.java index 708ddda..5d527d7 100644 --- a/src/main/java/ditda/backend/global/config/SecurityConfig.java +++ b/src/main/java/ditda/backend/global/config/SecurityConfig.java @@ -57,7 +57,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // api μ ‘κ·Ό κΆŒν•œ μ„€μ • .authorizeHttpRequests(auth -> auth .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() - .requestMatchers(HttpMethod.POST, "/api/v1/auth/reissue").authenticated() + .requestMatchers(HttpMethod.POST, + "/api/v1/instructor/check-username", + "/api/v1/instructor/signup" + ).permitAll() .requestMatchers("/api/v1/auth/**").permitAll() .anyRequest().authenticated() ) @@ -79,4 +82,3 @@ private CorsConfigurationSource corsConfigurationSource() { return source; } } - From fb28241f3a3b96fea12667b9f1319bfb5fed05bc Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Sat, 23 May 2026 05:26:56 +0900 Subject: [PATCH 33/90] =?UTF-8?q?Fix(auth):=20=EA=B0=95=EC=82=AC=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20endpoint=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=ED=86=A0=ED=81=B0=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/InstructorAuthController.java | 5 +++-- .../instructor/auth/service/InstructorAuthService.java | 7 ++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java b/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java index b4efa0b..bdf2a8e 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java @@ -1,6 +1,7 @@ package ditda.backend.domain.instructor.auth.controller; import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -19,7 +20,7 @@ import lombok.RequiredArgsConstructor; @RestController -@RequestMapping("/api/v1/instructor") +@RequestMapping("/api/v1/instructors") @RequiredArgsConstructor @Tag(name = "Instructor Auth", description = "강사 νšŒμ›κ°€μž… API") public class InstructorAuthController { @@ -27,7 +28,7 @@ public class InstructorAuthController { private final InstructorAuthFacade instructorAuthFacade; @Operation(summary = "강사 아이디 쀑볡 확인", description = "**[νšŒμ›κ°€μž…]** μ‚¬μš© κ°€λŠ₯ν•œ 아이디인지 ν™•μΈν•©λ‹ˆλ‹€.") - @PostMapping("/check-username") + @GetMapping("/check-username") public ApiResponse checkUsername( @Valid @RequestBody CheckUsernameRequest request ) { diff --git a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java index 1879046..fd129e7 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java @@ -11,7 +11,6 @@ import ditda.backend.domain.common.auth.entity.RefreshToken; import ditda.backend.domain.common.auth.repository.RefreshTokenRepository; -import ditda.backend.domain.common.auth.service.EmailVerificationService; import ditda.backend.domain.common.term.entity.UserTerm; import ditda.backend.domain.common.term.entity.enums.TermType; import ditda.backend.domain.common.term.repository.UserTermRepository; @@ -86,11 +85,9 @@ public InstructorAuthResult signup(InstructorSignupRequest request) { userTermRepository.saveAll(terms); - Instructor instructor = instructorRepository.save( - Instructor.createInstructor(user) - ); + instructorRepository.save(Instructor.createInstructor(user)); - return issueTokens(instructor.getId()); + return issueTokens(user.getId()); } private void validateEmailAvailable(InstructorSignupRequest request) { From a21c2b3654cc654a8e4239c8635c131c159d175e Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Sat, 23 May 2026 05:35:53 +0900 Subject: [PATCH 34/90] =?UTF-8?q?Fix(auth):=20=EA=B0=95=EC=82=AC=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EB=94=94=20=EC=A4=91=EB=B3=B5=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20API=20=EC=9A=94=EC=B2=AD=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EC=9D=84=20POST=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../instructor/auth/controller/InstructorAuthController.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java b/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java index bdf2a8e..1d904a5 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java @@ -1,7 +1,6 @@ package ditda.backend.domain.instructor.auth.controller; import org.springframework.http.HttpHeaders; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -28,7 +27,7 @@ public class InstructorAuthController { private final InstructorAuthFacade instructorAuthFacade; @Operation(summary = "강사 아이디 쀑볡 확인", description = "**[νšŒμ›κ°€μž…]** μ‚¬μš© κ°€λŠ₯ν•œ 아이디인지 ν™•μΈν•©λ‹ˆλ‹€.") - @GetMapping("/check-username") + @PostMapping("/check-username") public ApiResponse checkUsername( @Valid @RequestBody CheckUsernameRequest request ) { From 1fc2d0c024675feab0e08721b2133de053cd0189 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Sat, 23 May 2026 06:10:53 +0900 Subject: [PATCH 35/90] =?UTF-8?q?Feat(auth):=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=EB=B0=8F=20=ED=86=A0=ED=81=B0=20=EC=9E=AC=EB=B0=9C=EA=B8=89?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD/=EC=9D=91=EB=8B=B5=20DTO=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/common/auth/dto/AuthResult.java | 13 +++++++++++++ .../common/auth/dto/request/LoginRequest.java | 17 +++++++++++++++++ .../common/auth/dto/response/LoginResponse.java | 14 ++++++++++++++ .../auth/dto/response/ReissueResponse.java | 11 +++++++++++ .../auth/repository/RefreshTokenRepository.java | 2 ++ .../user/repository/UserEntityRepository.java | 4 ++++ 6 files changed, 61 insertions(+) create mode 100644 src/main/java/ditda/backend/domain/common/auth/dto/AuthResult.java create mode 100644 src/main/java/ditda/backend/domain/common/auth/dto/request/LoginRequest.java create mode 100644 src/main/java/ditda/backend/domain/common/auth/dto/response/LoginResponse.java create mode 100644 src/main/java/ditda/backend/domain/common/auth/dto/response/ReissueResponse.java diff --git a/src/main/java/ditda/backend/domain/common/auth/dto/AuthResult.java b/src/main/java/ditda/backend/domain/common/auth/dto/AuthResult.java new file mode 100644 index 0000000..afc675d --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/auth/dto/AuthResult.java @@ -0,0 +1,13 @@ +package ditda.backend.domain.common.auth.dto; + +import org.springframework.http.ResponseCookie; + +public record AuthResult( + + Long userId, + + String accessToken, + + ResponseCookie refreshTokenCookie +) { +} diff --git a/src/main/java/ditda/backend/domain/common/auth/dto/request/LoginRequest.java b/src/main/java/ditda/backend/domain/common/auth/dto/request/LoginRequest.java new file mode 100644 index 0000000..2fef033 --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/auth/dto/request/LoginRequest.java @@ -0,0 +1,17 @@ +package ditda.backend.domain.common.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "μœ μ € 둜그인 μš”μ²­") +public record LoginRequest( + + @Schema(description = "둜그인 ID", example = "testid123") + @NotBlank(message = "μ•„μ΄λ””λŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") + String username, + + @Schema(description = "λΉ„λ°€λ²ˆν˜Έ", example = "password1234") + @NotBlank(message = "λΉ„λ°€λ²ˆν˜ΈλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") + String password +) { +} diff --git a/src/main/java/ditda/backend/domain/common/auth/dto/response/LoginResponse.java b/src/main/java/ditda/backend/domain/common/auth/dto/response/LoginResponse.java new file mode 100644 index 0000000..e4641a1 --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/auth/dto/response/LoginResponse.java @@ -0,0 +1,14 @@ +package ditda.backend.domain.common.auth.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "둜그인 응닡") +public record LoginResponse( + + @Schema(description = "둜그인 μœ μ € ID", example = "1") + Long userId, + + @Schema(description = "Access Token") + String accessToken +) { +} diff --git a/src/main/java/ditda/backend/domain/common/auth/dto/response/ReissueResponse.java b/src/main/java/ditda/backend/domain/common/auth/dto/response/ReissueResponse.java new file mode 100644 index 0000000..997261a --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/auth/dto/response/ReissueResponse.java @@ -0,0 +1,11 @@ +package ditda.backend.domain.common.auth.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "토큰 μž¬λ°œκΈ‰ 응닡") +public record ReissueResponse( + + @Schema(description = "Access Token") + String accessToken +) { +} diff --git a/src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java b/src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java index 49f5357..1db816e 100644 --- a/src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java +++ b/src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java @@ -8,4 +8,6 @@ public interface RefreshTokenRepository extends JpaRepository { Optional findByUserId(Long userId); + + void deleteByUserId(Long userId); } diff --git a/src/main/java/ditda/backend/domain/common/user/repository/UserEntityRepository.java b/src/main/java/ditda/backend/domain/common/user/repository/UserEntityRepository.java index 3b4978a..b972aac 100644 --- a/src/main/java/ditda/backend/domain/common/user/repository/UserEntityRepository.java +++ b/src/main/java/ditda/backend/domain/common/user/repository/UserEntityRepository.java @@ -1,5 +1,7 @@ package ditda.backend.domain.common.user.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import ditda.backend.domain.common.user.entity.UserEntity; @@ -8,4 +10,6 @@ public interface UserEntityRepository extends JpaRepository { boolean existsByUsername(String username); boolean existsByEmail(String email); + + Optional findByUsername(String username); } From 7e703e798d50f5a8ae9029b7d4f4e54cf72eca6f Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Sat, 23 May 2026 06:11:11 +0900 Subject: [PATCH 36/90] =?UTF-8?q?Feat(auth):=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?/=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83/=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/auth/service/AuthService.java | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src/main/java/ditda/backend/domain/common/auth/service/AuthService.java diff --git a/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java b/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java new file mode 100644 index 0000000..ddf4a64 --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java @@ -0,0 +1,99 @@ +package ditda.backend.domain.common.auth.service; + +import java.time.LocalDateTime; + +import org.springframework.http.ResponseCookie; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ditda.backend.domain.common.auth.dto.AuthResult; +import ditda.backend.domain.common.auth.dto.request.LoginRequest; +import ditda.backend.domain.common.auth.entity.RefreshToken; +import ditda.backend.domain.common.auth.repository.RefreshTokenRepository; +import ditda.backend.domain.common.user.entity.UserEntity; +import ditda.backend.domain.common.user.repository.UserEntityRepository; +import ditda.backend.global.apipayload.code.GeneralErrorCode; +import ditda.backend.global.apipayload.exception.GeneralException; +import ditda.backend.global.jwt.JwtTokenProvider; +import ditda.backend.global.jwt.utils.CookieUtils; +import io.jsonwebtoken.JwtException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserEntityRepository userEntityRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + private final CookieUtils cookieUtils; + + @Transactional + public AuthResult login(LoginRequest request) { + + UserEntity user = userEntityRepository.findByUsername(request.username()) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.INVALID_LOGIN)); + + if (!passwordEncoder.matches(request.password(), user.getPassword())) { + throw new GeneralException(GeneralErrorCode.INVALID_LOGIN); + } + + return issueTokens(user.getId()); + } + + @Transactional + public ResponseCookie logout(Long userId) { + + refreshTokenRepository.deleteByUserId(userId); + + return cookieUtils.deleteRefreshTokenCookie(); + } + + @Transactional + public AuthResult reissue(String refreshToken) { + + if (refreshToken == null || refreshToken.isBlank()) { + throw new GeneralException(GeneralErrorCode.INVALID_TOKEN); + } + + Long userId; + try { + userId = Long.parseLong( + jwtTokenProvider.validateRefreshToken(refreshToken).getSubject()); + } catch (JwtException | IllegalArgumentException exception) { + throw new GeneralException(GeneralErrorCode.INVALID_TOKEN); + } + + RefreshToken stored = refreshTokenRepository.findByUserId(userId) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.INVALID_TOKEN)); + + if (!stored.getToken().equals(refreshToken)) { + throw new GeneralException(GeneralErrorCode.INVALID_TOKEN); + } + + return issueTokens(userId); + } + + private AuthResult issueTokens(Long userId) { + + String accessToken = jwtTokenProvider.generateAccessToken(userId); + String refreshToken = jwtTokenProvider.generateRefreshToken(userId); + + LocalDateTime expiresAt = jwtTokenProvider.getExpiration(refreshToken); + + refreshTokenRepository.findByUserId(userId) + .ifPresentOrElse( + rt -> rt.rotate(refreshToken, expiresAt), + () -> refreshTokenRepository.save( + RefreshToken.createRefreshToken(userEntityRepository.getReferenceById(userId), refreshToken, + expiresAt) + ) + ); + + ResponseCookie cookie = cookieUtils.createRefreshTokenCookie(refreshToken); + + return new AuthResult(userId, accessToken, cookie); + } +} From 5ac261413e27a6e856f40524d645d72aa1e52fd7 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Sat, 23 May 2026 06:11:22 +0900 Subject: [PATCH 37/90] =?UTF-8?q?Feat(auth):=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?,=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83,=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20API=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20AuthFacade=20=EB=A1=9C=EC=A7=81=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 52 +++++++++++++++++++ .../domain/common/auth/facade/AuthFacade.java | 20 +++++++ 2 files changed, 72 insertions(+) diff --git a/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java b/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java index f5c6c8a..6b161a0 100644 --- a/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java +++ b/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java @@ -1,16 +1,27 @@ package ditda.backend.domain.common.auth.controller; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.CookieValue; 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; +import ditda.backend.domain.common.auth.dto.AuthResult; import ditda.backend.domain.common.auth.dto.request.EmailCodeVerificationRequest; import ditda.backend.domain.common.auth.dto.request.EmailVerificationRequest; +import ditda.backend.domain.common.auth.dto.request.LoginRequest; +import ditda.backend.domain.common.auth.dto.response.LoginResponse; +import ditda.backend.domain.common.auth.dto.response.ReissueResponse; import ditda.backend.domain.common.auth.facade.AuthFacade; import ditda.backend.global.apipayload.response.ApiResponse; +import ditda.backend.global.jwt.utils.CookieUtils; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -42,4 +53,45 @@ public ApiResponse verifyEmailCode( authFacade.verifyEmailCode(request.email(), request.code()); return ApiResponse.onSuccess("이메일 인증이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); } + + @Operation(summary = "둜그인", description = "**[둜그인]** 아이디/λΉ„λ°€λ²ˆν˜Έλ‘œ λ‘œκ·ΈμΈν•©λ‹ˆλ‹€.") + @PostMapping("/login") + public ApiResponse login( + @Valid @RequestBody LoginRequest request, + HttpServletResponse response + ) { + + AuthResult result = authFacade.login(request); + response.addHeader(HttpHeaders.SET_COOKIE, result.refreshTokenCookie().toString()); + + return ApiResponse.onSuccess("둜그인 성곡", + new LoginResponse(result.userId(), result.accessToken())); + } + + @Operation(summary = "λ‘œκ·Έμ•„μ›ƒ", description = "**[곡톡]** 인증된 μ‚¬μš©μžμ˜ 토큰을 λ¬΄νš¨ν™”ν•©λ‹ˆλ‹€.") + @PostMapping("/logout") + public ApiResponse logout( + @AuthenticationPrincipal Long userId, + HttpServletResponse response + ) { + + ResponseCookie deleted = authFacade.logout(userId); + response.addHeader(HttpHeaders.SET_COOKIE, deleted.toString()); + + return ApiResponse.onSuccess("λ‘œκ·Έμ•„μ›ƒ 성곡"); + } + + @Operation(summary = "토큰 μž¬λ°œκΈ‰", description = "**[곡톡]** refresh ν† ν°μœΌλ‘œ μƒˆ access 토큰을 λ°œκΈ‰λ°›μŠ΅λ‹ˆλ‹€.") + @PostMapping("/reissue") + public ApiResponse refresh( + @Parameter(hidden = true) + @CookieValue(value = CookieUtils.REFRESH_TOKEN_COOKIE, required = false) String refreshToken, + HttpServletResponse response + ) { + AuthResult result = authFacade.reissue(refreshToken); + response.addHeader(HttpHeaders.SET_COOKIE, result.refreshTokenCookie().toString()); + + return ApiResponse.onSuccess("토큰 μž¬λ°œκΈ‰ 성곡", + new ReissueResponse(result.accessToken())); + } } diff --git a/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java b/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java index 4da0542..bd0ac0e 100644 --- a/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java +++ b/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java @@ -1,7 +1,11 @@ package ditda.backend.domain.common.auth.facade; +import org.springframework.http.ResponseCookie; import org.springframework.stereotype.Component; +import ditda.backend.domain.common.auth.dto.AuthResult; +import ditda.backend.domain.common.auth.dto.request.LoginRequest; +import ditda.backend.domain.common.auth.service.AuthService; import ditda.backend.domain.common.auth.service.EmailVerificationService; import ditda.backend.global.email.EmailSender; import lombok.RequiredArgsConstructor; @@ -12,6 +16,7 @@ public class AuthFacade { private final EmailVerificationService emailVerificationService; private final EmailSender emailSender; + private final AuthService authService; // 이메일 인증번호 λ°œμ†‘ public void requestEmailVerification(String email) { @@ -23,4 +28,19 @@ public void requestEmailVerification(String email) { public void verifyEmailCode(String email, String code) { emailVerificationService.verifyCode(email, code); } + + // μœ μ € 둜그인 + public AuthResult login(LoginRequest request) { + return authService.login(request); + } + + // μœ μ € λ‘œκ·Έμ•„μ›ƒ + public ResponseCookie logout(Long userId) { + return authService.logout(userId); + } + + // 토큰 μž¬λ°œκΈ‰ + public AuthResult reissue(String refreshToken) { + return authService.reissue(refreshToken); + } } From 943af4dda6aca3d7bedd439a7930041ae1006044 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Sat, 23 May 2026 06:16:42 +0900 Subject: [PATCH 38/90] =?UTF-8?q?Fix(auth):=20SecurityConfig=20permit=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/ditda/backend/global/config/SecurityConfig.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/ditda/backend/global/config/SecurityConfig.java b/src/main/java/ditda/backend/global/config/SecurityConfig.java index 5d527d7..e419b35 100644 --- a/src/main/java/ditda/backend/global/config/SecurityConfig.java +++ b/src/main/java/ditda/backend/global/config/SecurityConfig.java @@ -59,7 +59,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() .requestMatchers(HttpMethod.POST, "/api/v1/instructor/check-username", - "/api/v1/instructor/signup" + "/api/v1/instructor/signup", + "/api/v1/auth/login", + "/api/v1/auth/reissue" ).permitAll() .requestMatchers("/api/v1/auth/**").permitAll() .anyRequest().authenticated() From eef4228a9cc6c0f6aede99558c90543166a8af1c Mon Sep 17 00:00:00 2001 From: fervovita Date: Sat, 23 May 2026 07:46:16 +0900 Subject: [PATCH 39/90] =?UTF-8?q?Feat:=20AES=20=EC=95=94=ED=98=B8=ED=99=94?= =?UTF-8?q?=20=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 +- .../apipayload/code/GeneralErrorCode.java | 5 + .../encryption/AesEncryptConverter.java | 33 +++++ .../global/encryption/AesEncryptor.java | 136 ++++++++++++++++++ src/main/resources/application.yaml | 4 + src/test/resources/application-test.yaml | 9 +- 6 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 src/main/java/ditda/backend/global/encryption/AesEncryptConverter.java create mode 100644 src/main/java/ditda/backend/global/encryption/AesEncryptor.java diff --git a/.env.example b/.env.example index 7fc8688..0a3a792 100644 --- a/.env.example +++ b/.env.example @@ -24,4 +24,8 @@ S3_BUCKET=your_bucket_name MAIL_HOST=smtp.gmail.com MAIL_PORT=587 MAIL_USERNAME=your_email@gmail.com -MAIL_PASSWORD=your_app_password \ No newline at end of file +MAIL_PASSWORD=your_app_password + +# Encryption +ENCRYPT_SECRET_KEY=your_encrypt_secret_key +ENCRYPT_HASH_KEY=your_encrypt_hash_key \ No newline at end of file diff --git a/src/main/java/ditda/backend/global/apipayload/code/GeneralErrorCode.java b/src/main/java/ditda/backend/global/apipayload/code/GeneralErrorCode.java index a99fe0c..9b035b0 100644 --- a/src/main/java/ditda/backend/global/apipayload/code/GeneralErrorCode.java +++ b/src/main/java/ditda/backend/global/apipayload/code/GeneralErrorCode.java @@ -29,6 +29,11 @@ public enum GeneralErrorCode implements BaseErrorCode { RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "REQ_404_02", "μš”μ²­ν•œ λ¦¬μ†ŒμŠ€λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."), METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "API_405_01", "μ§€μ›ν•˜μ§€ μ•ŠλŠ” HTTP λ©”μ„œλ“œμž…λ‹ˆλ‹€."), + // μ•”ν˜Έν™”/λ³΅ν˜Έν™” μ—λŸ¬ + ENCRYPTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "ENCRYPT_500_01", "데이터 μ•”ν˜Έν™”μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."), + DECRYPTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "ENCRYPT_500_02", "데이터 λ³΅ν˜Έν™”μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."), + HASHING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "ENCRYPT_500_03", "데이터 해싱에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."), + // μ„œλ²„ λ‚΄λΆ€ μ—λŸ¬ INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SERVER_500_01", "μ„œλ²„ λ‚΄λΆ€ 였λ₯˜μž…λ‹ˆλ‹€."), SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "SERVER_503_01", "μ„œλ²„κ°€ μΌμ‹œμ μœΌλ‘œ λΆˆμ•ˆμ •ν•©λ‹ˆλ‹€."), diff --git a/src/main/java/ditda/backend/global/encryption/AesEncryptConverter.java b/src/main/java/ditda/backend/global/encryption/AesEncryptConverter.java new file mode 100644 index 0000000..19d46d1 --- /dev/null +++ b/src/main/java/ditda/backend/global/encryption/AesEncryptConverter.java @@ -0,0 +1,33 @@ +package ditda.backend.global.encryption; + +import org.springframework.stereotype.Component; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import lombok.RequiredArgsConstructor; + +@Converter +@Component +@RequiredArgsConstructor +public class AesEncryptConverter implements AttributeConverter { + + private final AesEncryptor aesEncryptor; + + @Override + public String convertToDatabaseColumn(String attribute) { + if (attribute == null) { + return null; + } + + return aesEncryptor.encrypt(attribute); + } + + @Override + public String convertToEntityAttribute(String dbData) { + if (dbData == null) { + return null; + } + + return aesEncryptor.decrypt(dbData); + } +} diff --git a/src/main/java/ditda/backend/global/encryption/AesEncryptor.java b/src/main/java/ditda/backend/global/encryption/AesEncryptor.java new file mode 100644 index 0000000..6c5d1bc --- /dev/null +++ b/src/main/java/ditda/backend/global/encryption/AesEncryptor.java @@ -0,0 +1,136 @@ +package ditda.backend.global.encryption; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import ditda.backend.global.apipayload.code.GeneralErrorCode; +import ditda.backend.global.apipayload.exception.GeneralException; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class AesEncryptor { + + private static final String ALGORITHM = "AES/GCM/NoPadding"; + private static final int IV_LENGTH = 12; // IV 길이 + private static final int GCM_TAG_LENGTH = 128; // νƒœκ·Έ 길이 + + private static final SecureRandom RANDOM = new SecureRandom(); + + @Value("${encrypt.secret-key}") + private String secretKey; + + @Value("${encrypt.hash-key}") + private String hashKey; + + private SecretKeySpec secretKeySpec; + private SecretKeySpec hmacKeySpec; + + @PostConstruct + public void init() { + try { + byte[] keyBytes = MessageDigest.getInstance("SHA-256") + .digest(secretKey.getBytes(StandardCharsets.UTF_8)); + this.secretKeySpec = new SecretKeySpec(keyBytes, "AES"); + + byte[] hmacBytes = MessageDigest.getInstance("SHA-256") + .digest(hashKey.getBytes(StandardCharsets.UTF_8)); + this.hmacKeySpec = new SecretKeySpec(hmacBytes, "HmacSHA256"); + } catch (Exception e) { + throw new IllegalStateException("Failed to initialize AES encryption", e); + } + } + + public String encrypt(String plainText) { + if (plainText == null || plainText.isEmpty()) { + return null; + } + + try { + // λ¬΄μž‘μœ„ IV 생성 + byte[] iv = new byte[IV_LENGTH]; + RANDOM.nextBytes(iv); + GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + + // Cipher μ΄ˆκΈ°ν™” 및 μ•”ν˜Έν™” + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, gcmSpec); + byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + + // IV + μ•”ν˜Έλ¬Έ κ²°ν•© + byte[] combined = new byte[IV_LENGTH + encrypted.length]; + System.arraycopy(iv, 0, combined, 0, IV_LENGTH); + System.arraycopy(encrypted, 0, combined, IV_LENGTH, encrypted.length); + + // Base64 인코딩 + return Base64.getEncoder().encodeToString(combined); + } catch (Exception e) { + log.error("Failed to encrypt data", e); + throw new GeneralException(GeneralErrorCode.ENCRYPTION_FAILED); + } + } + + public String decrypt(String encryptedText) { + if (encryptedText == null || encryptedText.isEmpty()) { + return null; + } + + try { + // Base64 λ””μ½”λ”© + byte[] combined = Base64.getDecoder().decode(encryptedText); + + // IV μΆ”μΆœ + byte[] iv = new byte[IV_LENGTH]; + System.arraycopy(combined, 0, iv, 0, IV_LENGTH); + GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + + // μ•”ν˜Έλ¬Έ μΆ”μΆœ + byte[] encrypted = new byte[combined.length - IV_LENGTH]; + System.arraycopy(combined, IV_LENGTH, encrypted, 0, encrypted.length); + + // Cipher μ΄ˆκΈ°ν™” 및 λ³΅ν˜Έν™” + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, gcmSpec); + + return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8); + } catch (Exception e) { + log.error("Failed to decrypt data", e); + throw new GeneralException(GeneralErrorCode.DECRYPTION_FAILED); + } + } + + public String hash(String plainText) { + if (plainText == null || plainText.isEmpty()) { + return null; + } + + try { + // HMAC-SHA256 μ΄ˆκΈ°ν™” 및 ν•΄μ‹± + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(hmacKeySpec); + byte[] hashBytes = mac.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + + // 16μ§„μˆ˜ λ¬Έμžμ—΄λ‘œ λ³€ν™˜ + StringBuilder sb = new StringBuilder(); + for (byte b : hashBytes) { + sb.append(String.format("%02x", b)); + } + + return sb.toString(); + } catch (Exception e) { + log.error("Failed to hash data", e); + throw new GeneralException(GeneralErrorCode.HASHING_FAILED); + } + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 095ee29..6a49970 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -38,6 +38,10 @@ jwt: cookie-secure: ${JWT_COOKIE_SECURE} refresh-cookie-path: ${JWT_REFRESH_COOKIE_PATH} +encrypt: + secret-key: ${ENCRYPT_SECRET_KEY} + hash-key: ${ENCRYPT_HASH_KEY} + springdoc: swagger-ui: path: /swagger-ui.html diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index c0daf4f..9bd8a8c 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -37,6 +37,9 @@ spring: s3: endpoint: http://localhost:9000 path-style-access-enabled: true +app: + s3: + bucket: test-bucket jwt: secret: application-test-jwt-secret-key-only-for-ci @@ -45,9 +48,9 @@ jwt: cookie-secure: false refresh-cookie-path: /api/v1/auth -app: - s3: - bucket: test-bucket +encrypt: + secret-key: test-encrypt-secret-key-only-for-ci-environment + hash-key: test-encrypt-hash-key-only-for-ci-environment swagger: server-url: http://localhost:8080 From 2b301aba7715b51583af4eee4370679713e86e68 Mon Sep 17 00:00:00 2001 From: fervovita Date: Sat, 23 May 2026 09:15:57 +0900 Subject: [PATCH 40/90] =?UTF-8?q?Refactor(core):=20InstructorAuthService?= =?UTF-8?q?=EB=A5=BC=20=EB=8F=84=EB=A9=94=EC=9D=B8=20Service=EC=97=90=20?= =?UTF-8?q?=EC=9C=84=EC=9E=84=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 15 ++- .../domain/common/auth/dto/TokenPair.java | 9 ++ .../dto/request/CheckUsernameRequest.java | 2 +- .../common/auth/exception/AuthErrorCode.java | 10 +- .../domain/common/auth/facade/AuthFacade.java | 7 ++ .../service/EmailVerificationService.java | 7 +- .../common/auth/service/TokenService.java | 47 ++++++++ .../domain/common/term/dto/TermAgreement.java | 10 ++ .../common/term/exception/TermErrorCode.java | 18 +++ .../common/term/service/TermService.java | 51 +++++++++ .../UserErrorCode.java | 3 +- ...ityRepository.java => UserRepository.java} | 2 +- .../common/user/service/UserService.java | 56 ++++++++++ .../controller/InstructorAuthController.java | 11 -- .../auth/facade/InstructorAuthFacade.java | 5 +- .../auth/service/InstructorAuthService.java | 105 +++--------------- .../backend/global/config/SecurityConfig.java | 3 +- 17 files changed, 243 insertions(+), 118 deletions(-) create mode 100644 src/main/java/ditda/backend/domain/common/auth/dto/TokenPair.java rename src/main/java/ditda/backend/domain/{instructor => common}/auth/dto/request/CheckUsernameRequest.java (89%) create mode 100644 src/main/java/ditda/backend/domain/common/auth/service/TokenService.java create mode 100644 src/main/java/ditda/backend/domain/common/term/dto/TermAgreement.java create mode 100644 src/main/java/ditda/backend/domain/common/term/exception/TermErrorCode.java create mode 100644 src/main/java/ditda/backend/domain/common/term/service/TermService.java rename src/main/java/ditda/backend/domain/common/user/{exceptions => exception}/UserErrorCode.java (80%) rename src/main/java/ditda/backend/domain/common/user/repository/{UserEntityRepository.java => UserRepository.java} (76%) create mode 100644 src/main/java/ditda/backend/domain/common/user/service/UserService.java diff --git a/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java b/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java index f5c6c8a..ea21734 100644 --- a/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java +++ b/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java @@ -5,6 +5,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import ditda.backend.domain.common.auth.dto.request.CheckUsernameRequest; import ditda.backend.domain.common.auth.dto.request.EmailCodeVerificationRequest; import ditda.backend.domain.common.auth.dto.request.EmailVerificationRequest; import ditda.backend.domain.common.auth.facade.AuthFacade; @@ -24,7 +25,17 @@ public class AuthController { private final AuthFacade authFacade; - @Operation(summary = "이메일 인증번호 μš”μ²­", description = "이메일 인증번호λ₯Ό μš”μ²­ν•©λ‹ˆλ‹€.") + @Operation(summary = "아이디 쀑볡 확인", description = "**[νšŒμ›κ°€μž…]** μ‚¬μš© κ°€λŠ₯ν•œ 아이디인지 ν™•μΈν•©λ‹ˆλ‹€.") + @PostMapping("/check-username") + public ApiResponse checkUsername( + @Valid @RequestBody CheckUsernameRequest request + ) { + + authFacade.validateUsernameAvailable(request.username()); + return ApiResponse.onSuccess("아이디 μ‚¬μš© κ°€λŠ₯ μ—¬λΆ€ 쑰회 성곡"); + } + + @Operation(summary = "이메일 인증번호 μš”μ²­", description = "**[νšŒμ›κ°€μž…]** 이메일 인증번호λ₯Ό μš”μ²­ν•©λ‹ˆλ‹€.") @PostMapping("/emails/verification-requests") public ApiResponse requestEmailVerification( @Valid @RequestBody EmailVerificationRequest request @@ -34,7 +45,7 @@ public ApiResponse requestEmailVerification( return ApiResponse.onSuccess("μΈμ¦λ²ˆν˜Έκ°€ λ°œμ†‘λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); } - @Operation(summary = "이메일 인증번호 검증", description = "이메일 인증번호λ₯Ό κ²€μ¦ν•©λ‹ˆλ‹€.") + @Operation(summary = "이메일 인증번호 검증", description = "**[νšŒμ›κ°€μž…]** 이메일 인증번호λ₯Ό κ²€μ¦ν•©λ‹ˆλ‹€.") @PostMapping("/emails/verifications") public ApiResponse verifyEmailCode( @Valid @RequestBody EmailCodeVerificationRequest request diff --git a/src/main/java/ditda/backend/domain/common/auth/dto/TokenPair.java b/src/main/java/ditda/backend/domain/common/auth/dto/TokenPair.java new file mode 100644 index 0000000..e607e75 --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/auth/dto/TokenPair.java @@ -0,0 +1,9 @@ +package ditda.backend.domain.common.auth.dto; + +import org.springframework.http.ResponseCookie; + +public record TokenPair( + String accessToken, + ResponseCookie refreshCookie +) { +} diff --git a/src/main/java/ditda/backend/domain/instructor/auth/dto/request/CheckUsernameRequest.java b/src/main/java/ditda/backend/domain/common/auth/dto/request/CheckUsernameRequest.java similarity index 89% rename from src/main/java/ditda/backend/domain/instructor/auth/dto/request/CheckUsernameRequest.java rename to src/main/java/ditda/backend/domain/common/auth/dto/request/CheckUsernameRequest.java index db5fc1d..a66e83c 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/dto/request/CheckUsernameRequest.java +++ b/src/main/java/ditda/backend/domain/common/auth/dto/request/CheckUsernameRequest.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.instructor.auth.dto.request; +package ditda.backend.domain.common.auth.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/ditda/backend/domain/common/auth/exception/AuthErrorCode.java b/src/main/java/ditda/backend/domain/common/auth/exception/AuthErrorCode.java index dae6d8a..759ff80 100644 --- a/src/main/java/ditda/backend/domain/common/auth/exception/AuthErrorCode.java +++ b/src/main/java/ditda/backend/domain/common/auth/exception/AuthErrorCode.java @@ -10,12 +10,10 @@ @AllArgsConstructor public enum AuthErrorCode implements BaseErrorCode { - EMAIL_VERIFICATION_COOLDOWN(HttpStatus.TOO_MANY_REQUESTS, "AUTH_429_01", - "μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”."), - EMAIL_CODE_EXPIRED(HttpStatus.BAD_REQUEST, "AUTH_400_01", - "μΈμ¦λ²ˆν˜Έκ°€ λ§Œλ£Œλ˜μ—ˆκ±°λ‚˜ λ°œκΈ‰λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€."), - EMAIL_CODE_INVALID(HttpStatus.BAD_REQUEST, "AUTH_400_02", - "μΈμ¦λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + EMAIL_VERIFICATION_COOLDOWN(HttpStatus.TOO_MANY_REQUESTS, "AUTH_429_01", "μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”."), + EMAIL_CODE_EXPIRED(HttpStatus.BAD_REQUEST, "AUTH_400_01", "μΈμ¦λ²ˆν˜Έκ°€ λ§Œλ£Œλ˜μ—ˆκ±°λ‚˜ λ°œκΈ‰λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€."), + EMAIL_CODE_INVALID(HttpStatus.BAD_REQUEST, "AUTH_400_02", "μΈμ¦λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."), + EMAIL_NOT_VERIFIED(HttpStatus.BAD_REQUEST, "AUTH_400_03", "이메일 인증이 μ™„λ£Œλ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java b/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java index 4da0542..11cb147 100644 --- a/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java +++ b/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java @@ -3,6 +3,7 @@ import org.springframework.stereotype.Component; import ditda.backend.domain.common.auth.service.EmailVerificationService; +import ditda.backend.domain.common.user.service.UserService; import ditda.backend.global.email.EmailSender; import lombok.RequiredArgsConstructor; @@ -12,6 +13,12 @@ public class AuthFacade { private final EmailVerificationService emailVerificationService; private final EmailSender emailSender; + private final UserService userService; + + // 아이디 쀑볡 검증 + public void validateUsernameAvailable(String username) { + userService.validateUsernameAvailable(username); + } // 이메일 인증번호 λ°œμ†‘ public void requestEmailVerification(String email) { diff --git a/src/main/java/ditda/backend/domain/common/auth/service/EmailVerificationService.java b/src/main/java/ditda/backend/domain/common/auth/service/EmailVerificationService.java index b39f7b6..096b894 100644 --- a/src/main/java/ditda/backend/domain/common/auth/service/EmailVerificationService.java +++ b/src/main/java/ditda/backend/domain/common/auth/service/EmailVerificationService.java @@ -59,8 +59,11 @@ public void verifyCode(String email, String code) { redisTemplate.opsForValue().set(verifiedKey(email), "true", VERIFIED_TTL); } - public boolean isVerified(String email) { - return "true".equals(redisTemplate.opsForValue().get(verifiedKey(email))); + public void validateVerified(String email) { + String verified = redisTemplate.opsForValue().get(verifiedKey(email)); + if (!"true".equals(verified)) { + throw new GeneralException(AuthErrorCode.EMAIL_NOT_VERIFIED); + } } public void deleteVerified(String email) { diff --git a/src/main/java/ditda/backend/domain/common/auth/service/TokenService.java b/src/main/java/ditda/backend/domain/common/auth/service/TokenService.java new file mode 100644 index 0000000..c7062bd --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/auth/service/TokenService.java @@ -0,0 +1,47 @@ +package ditda.backend.domain.common.auth.service; + +import java.time.LocalDateTime; + +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ditda.backend.domain.common.auth.dto.TokenPair; +import ditda.backend.domain.common.auth.entity.RefreshToken; +import ditda.backend.domain.common.auth.repository.RefreshTokenRepository; +import ditda.backend.domain.common.user.service.UserService; +import ditda.backend.global.jwt.JwtTokenProvider; +import ditda.backend.global.jwt.utils.CookieUtils; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class TokenService { + + private final RefreshTokenRepository refreshTokenRepository; + private final UserService userService; + private final JwtTokenProvider jwtTokenProvider; + private final CookieUtils cookieUtils; + + @Transactional + public TokenPair issueTokens(Long userId) { + String accessToken = jwtTokenProvider.generateAccessToken(userId); + String refreshToken = jwtTokenProvider.generateRefreshToken(userId); + LocalDateTime expiresAt = jwtTokenProvider.getExpiration(refreshToken); + + refreshTokenRepository.findByUserId(userId) + .ifPresentOrElse( + rt -> rt.rotate(refreshToken, expiresAt), + () -> refreshTokenRepository.save( + RefreshToken.createRefreshToken( + userService.getReferenceById(userId), + refreshToken, + expiresAt + ) + ) + ); + + ResponseCookie cookie = cookieUtils.createRefreshTokenCookie(refreshToken); + return new TokenPair(accessToken, cookie); + } +} diff --git a/src/main/java/ditda/backend/domain/common/term/dto/TermAgreement.java b/src/main/java/ditda/backend/domain/common/term/dto/TermAgreement.java new file mode 100644 index 0000000..f86f53b --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/term/dto/TermAgreement.java @@ -0,0 +1,10 @@ +package ditda.backend.domain.common.term.dto; + +import ditda.backend.domain.common.term.entity.enums.TermType; + +public record TermAgreement( + TermType type, + String version, + boolean isAgreed +) { +} diff --git a/src/main/java/ditda/backend/domain/common/term/exception/TermErrorCode.java b/src/main/java/ditda/backend/domain/common/term/exception/TermErrorCode.java new file mode 100644 index 0000000..d1fe6bb --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/term/exception/TermErrorCode.java @@ -0,0 +1,18 @@ +package ditda.backend.domain.common.term.exception; + +import org.springframework.http.HttpStatus; + +import ditda.backend.global.apipayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum TermErrorCode implements BaseErrorCode { + + REQUIRED_TERMS_NOT_AGREED(HttpStatus.BAD_REQUEST, "TERM_400_01", "ν•„μˆ˜ 약관에 λ™μ˜ν•΄μ•Ό ν•©λ‹ˆλ‹€."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/ditda/backend/domain/common/term/service/TermService.java b/src/main/java/ditda/backend/domain/common/term/service/TermService.java new file mode 100644 index 0000000..727ead4 --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/term/service/TermService.java @@ -0,0 +1,51 @@ +package ditda.backend.domain.common.term.service; + +import java.util.List; +import java.util.Set; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ditda.backend.domain.common.term.dto.TermAgreement; +import ditda.backend.domain.common.term.entity.UserTerm; +import ditda.backend.domain.common.term.entity.enums.TermType; +import ditda.backend.domain.common.term.exception.TermErrorCode; +import ditda.backend.domain.common.term.repository.UserTermRepository; +import ditda.backend.domain.common.user.entity.UserEntity; +import ditda.backend.global.apipayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class TermService { + + private static final Set REQUIRED_TERMS = Set.of( + TermType.SERVICE, + TermType.USERINFO, + TermType.SETTLEMENT, + TermType.DISINTERMEDIATION + ); + private final UserTermRepository userTermRepository; + + @Transactional + public void saveTerms(UserEntity user, List agreements) { + validateRequiredAgreements(agreements); + + List terms = agreements.stream() + .map(a -> UserTerm.createTerm(user, a.type(), a.version(), a.isAgreed())) + .toList(); + + userTermRepository.saveAll(terms); + } + + private void validateRequiredAgreements(List agreements) { + for (TermType required : REQUIRED_TERMS) { + boolean agreed = agreements.stream() + .anyMatch(a -> a.type() == required && a.isAgreed()); + if (!agreed) { + throw new GeneralException(TermErrorCode.REQUIRED_TERMS_NOT_AGREED); + } + } + } +} + diff --git a/src/main/java/ditda/backend/domain/common/user/exceptions/UserErrorCode.java b/src/main/java/ditda/backend/domain/common/user/exception/UserErrorCode.java similarity index 80% rename from src/main/java/ditda/backend/domain/common/user/exceptions/UserErrorCode.java rename to src/main/java/ditda/backend/domain/common/user/exception/UserErrorCode.java index 8d78482..94031e1 100644 --- a/src/main/java/ditda/backend/domain/common/user/exceptions/UserErrorCode.java +++ b/src/main/java/ditda/backend/domain/common/user/exception/UserErrorCode.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.common.user.exceptions; +package ditda.backend.domain.common.user.exception; import org.springframework.http.HttpStatus; @@ -10,7 +10,6 @@ @AllArgsConstructor public enum UserErrorCode implements BaseErrorCode { - USER_NOT_AGREED_TERMS(HttpStatus.BAD_REQUEST, "USER_400_01", "ν•„μˆ˜ 약관에 λ™μ˜ν•΄μ•Ό ν•©λ‹ˆλ‹€."), USERNAME_ALREADY_EXISTS(HttpStatus.CONFLICT, "USER_409_01", "이미 μ‚¬μš© 쀑인 μ•„μ΄λ””μž…λ‹ˆλ‹€."), EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "USER_409_02", "이미 μ‚¬μš© 쀑인 μ΄λ©”μΌμž…λ‹ˆλ‹€."), USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_01", "μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μœ μ €μž…λ‹ˆλ‹€."); diff --git a/src/main/java/ditda/backend/domain/common/user/repository/UserEntityRepository.java b/src/main/java/ditda/backend/domain/common/user/repository/UserRepository.java similarity index 76% rename from src/main/java/ditda/backend/domain/common/user/repository/UserEntityRepository.java rename to src/main/java/ditda/backend/domain/common/user/repository/UserRepository.java index 3b4978a..fc624e0 100644 --- a/src/main/java/ditda/backend/domain/common/user/repository/UserEntityRepository.java +++ b/src/main/java/ditda/backend/domain/common/user/repository/UserRepository.java @@ -4,7 +4,7 @@ import ditda.backend.domain.common.user.entity.UserEntity; -public interface UserEntityRepository extends JpaRepository { +public interface UserRepository extends JpaRepository { boolean existsByUsername(String username); boolean existsByEmail(String email); diff --git a/src/main/java/ditda/backend/domain/common/user/service/UserService.java b/src/main/java/ditda/backend/domain/common/user/service/UserService.java new file mode 100644 index 0000000..53e0e9e --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/user/service/UserService.java @@ -0,0 +1,56 @@ +package ditda.backend.domain.common.user.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ditda.backend.domain.common.user.entity.UserEntity; +import ditda.backend.domain.common.user.entity.enums.UserRole; +import ditda.backend.domain.common.user.exception.UserErrorCode; +import ditda.backend.domain.common.user.repository.UserRepository; +import ditda.backend.global.apipayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + + public UserEntity getReferenceById(Long userId) { + return userRepository.getReferenceById(userId); + } + + public boolean existsByUsername(String username) { + return userRepository.existsByUsername(username); + } + + public boolean existsByEmail(String email) { + return userRepository.existsByEmail(email); + } + + public void validateUsernameAvailable(String username) { + if (existsByUsername(username)) { + throw new GeneralException(UserErrorCode.USERNAME_ALREADY_EXISTS); + } + } + + public void validateEmailAvailable(String email) { + if (existsByEmail(email)) { + throw new GeneralException(UserErrorCode.EMAIL_ALREADY_EXISTS); + } + } + + // μœ μ € 생성 + @Transactional + public UserEntity createUser( + String username, String encodedPassword, String name, + String email, String profileImage, String phone, UserRole role + ) { + UserEntity user = UserEntity.createUser( + username, encodedPassword, name, email, profileImage, phone, role + ); + return userRepository.save(user); + } + +} diff --git a/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java b/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java index 1d904a5..231209a 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java @@ -7,7 +7,6 @@ import org.springframework.web.bind.annotation.RestController; import ditda.backend.domain.instructor.auth.dto.InstructorAuthResult; -import ditda.backend.domain.instructor.auth.dto.request.CheckUsernameRequest; import ditda.backend.domain.instructor.auth.dto.request.InstructorSignupRequest; import ditda.backend.domain.instructor.auth.dto.response.InstructorSignupResponse; import ditda.backend.domain.instructor.auth.facade.InstructorAuthFacade; @@ -26,16 +25,6 @@ public class InstructorAuthController { private final InstructorAuthFacade instructorAuthFacade; - @Operation(summary = "강사 아이디 쀑볡 확인", description = "**[νšŒμ›κ°€μž…]** μ‚¬μš© κ°€λŠ₯ν•œ 아이디인지 ν™•μΈν•©λ‹ˆλ‹€.") - @PostMapping("/check-username") - public ApiResponse checkUsername( - @Valid @RequestBody CheckUsernameRequest request - ) { - - instructorAuthFacade.validateUsernameAvailable(request.username()); - return ApiResponse.onSuccess("아이디 μ‚¬μš© κ°€λŠ₯ μ—¬λΆ€ 쑰회 성곡"); - } - @Operation(summary = "강사 νšŒμ›κ°€μž…", description = "**[νšŒμ›κ°€μž…]** νšŒμ›κ°€μž… ν›„ μžλ™ 둜그인 μ²˜λ¦¬λ©λ‹ˆλ‹€.") @PostMapping("/signup") public ApiResponse signup( diff --git a/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java b/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java index 97c84f0..43d7f08 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java @@ -17,7 +17,7 @@ public class InstructorAuthFacade { public InstructorAuthResult signup(InstructorSignupRequest request) { - emailVerificationService.isVerified(request.email()); + emailVerificationService.validateVerified(request.email()); InstructorAuthResult result = instructorAuthService.signup(request); @@ -26,7 +26,4 @@ public InstructorAuthResult signup(InstructorSignupRequest request) { return result; } - public void validateUsernameAvailable(String username) { - instructorAuthService.validateUsernameAvailable(username); - } } diff --git a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java index fd129e7..b5fb3df 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java @@ -1,30 +1,22 @@ package ditda.backend.domain.instructor.auth.service; -import java.time.LocalDateTime; import java.util.List; -import java.util.Set; -import org.springframework.http.ResponseCookie; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import ditda.backend.domain.common.auth.entity.RefreshToken; -import ditda.backend.domain.common.auth.repository.RefreshTokenRepository; -import ditda.backend.domain.common.term.entity.UserTerm; -import ditda.backend.domain.common.term.entity.enums.TermType; -import ditda.backend.domain.common.term.repository.UserTermRepository; +import ditda.backend.domain.common.auth.dto.TokenPair; +import ditda.backend.domain.common.auth.service.TokenService; +import ditda.backend.domain.common.term.dto.TermAgreement; +import ditda.backend.domain.common.term.service.TermService; import ditda.backend.domain.common.user.entity.UserEntity; import ditda.backend.domain.common.user.entity.enums.UserRole; -import ditda.backend.domain.common.user.exceptions.UserErrorCode; -import ditda.backend.domain.common.user.repository.UserEntityRepository; +import ditda.backend.domain.common.user.service.UserService; import ditda.backend.domain.instructor.auth.dto.InstructorAuthResult; import ditda.backend.domain.instructor.auth.dto.request.InstructorSignupRequest; import ditda.backend.domain.instructor.auth.entity.Instructor; import ditda.backend.domain.instructor.auth.repository.InstructorRepository; -import ditda.backend.global.apipayload.exception.GeneralException; -import ditda.backend.global.jwt.JwtTokenProvider; -import ditda.backend.global.jwt.utils.CookieUtils; import lombok.RequiredArgsConstructor; @Service @@ -32,37 +24,20 @@ public class InstructorAuthService { private static final String DEFAULT_PROFILE_IMAGE = ""; - private static final Set REQUIRED_TERMS = Set.of( - TermType.SERVICE, - TermType.USERINFO, - TermType.SETTLEMENT, - TermType.DISINTERMEDIATION - ); - private final UserEntityRepository userEntityRepository; private final InstructorRepository instructorRepository; - private final UserTermRepository userTermRepository; - private final RefreshTokenRepository refreshTokenRepository; + private final UserService userService; + private final TermService termService; + private final TokenService tokenService; private final PasswordEncoder passwordEncoder; - private final JwtTokenProvider jwtTokenProvider; - private final CookieUtils cookieUtils; - - @Transactional(readOnly = true) - public void validateUsernameAvailable(String username) { - - if (userEntityRepository.existsByUsername(username)) { - throw new GeneralException(UserErrorCode.USERNAME_ALREADY_EXISTS); - } - } @Transactional public InstructorAuthResult signup(InstructorSignupRequest request) { - validateUsernameAvailable(request.username()); - validateEmailAvailable(request); - validateRequiredTerms(request.terms()); + userService.validateUsernameAvailable(request.username()); + userService.validateEmailAvailable(request.email()); - UserEntity user = UserEntity.createUser( + UserEntity user = userService.createUser( request.username(), passwordEncoder.encode(request.password()), request.name(), @@ -72,62 +47,18 @@ public InstructorAuthResult signup(InstructorSignupRequest request) { UserRole.INSTRUCTOR ); - userEntityRepository.save(user); - - List terms = request.terms().stream() - .map(term -> UserTerm.createTerm( - user, - term.type(), - term.version(), - term.isAgreed() - )) - .toList(); - - userTermRepository.saveAll(terms); + termService.saveTerms(user, toAgreements(request.terms())); instructorRepository.save(Instructor.createInstructor(user)); - return issueTokens(user.getId()); - } - - private void validateEmailAvailable(InstructorSignupRequest request) { - - if (userEntityRepository.existsByEmail(request.email())) { - throw new GeneralException(UserErrorCode.EMAIL_ALREADY_EXISTS); - } - } - - private void validateRequiredTerms(List terms) { - - for (TermType requiredTerm : REQUIRED_TERMS) { - boolean isAgreed = terms.stream() - .anyMatch(term -> term.type() == requiredTerm && term.isAgreed()); + TokenPair tokens = tokenService.issueTokens(user.getId()); - if (!isAgreed) { - throw new GeneralException(UserErrorCode.USER_NOT_AGREED_TERMS); - } - } + return new InstructorAuthResult(user.getId(), tokens.accessToken(), tokens.refreshCookie()); } - private InstructorAuthResult issueTokens(Long userId) { - - String accessToken = jwtTokenProvider.generateAccessToken(userId); - String refreshToken = jwtTokenProvider.generateRefreshToken(userId); - - LocalDateTime expiresAt = jwtTokenProvider.getExpiration(refreshToken); - - refreshTokenRepository.findByUserId(userId) - .ifPresentOrElse( - rt -> rt.rotate((refreshToken), expiresAt), - () -> refreshTokenRepository.save( - RefreshToken.createRefreshToken( - userEntityRepository.getReferenceById(userId), - refreshToken, - expiresAt - ))); - - ResponseCookie cookie = cookieUtils.createRefreshTokenCookie(refreshToken); - - return new InstructorAuthResult(userId, accessToken, cookie); + private List toAgreements(List terms) { + return terms.stream() + .map(t -> new TermAgreement(t.type(), t.version(), t.isAgreed())) + .toList(); } } diff --git a/src/main/java/ditda/backend/global/config/SecurityConfig.java b/src/main/java/ditda/backend/global/config/SecurityConfig.java index 5d527d7..119cc73 100644 --- a/src/main/java/ditda/backend/global/config/SecurityConfig.java +++ b/src/main/java/ditda/backend/global/config/SecurityConfig.java @@ -58,8 +58,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(auth -> auth .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() .requestMatchers(HttpMethod.POST, - "/api/v1/instructor/check-username", - "/api/v1/instructor/signup" + "/api/v1/instructors/signup" ).permitAll() .requestMatchers("/api/v1/auth/**").permitAll() .anyRequest().authenticated() From bef3eacc492f06fff8bae1101fa7007766841674 Mon Sep 17 00:00:00 2001 From: fervovita Date: Sat, 23 May 2026 09:37:02 +0900 Subject: [PATCH 41/90] =?UTF-8?q?Refactor(user):=20UserEntity=EB=A5=BC=20U?= =?UTF-8?q?ser=EB=A1=9C=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=AA=85=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/common/auth/entity/RefreshToken.java | 6 +++--- .../ditda/backend/domain/common/term/entity/UserTerm.java | 6 +++--- .../backend/domain/common/term/service/TermService.java | 4 ++-- .../common/user/entity/{UserEntity.java => User.java} | 6 +++--- .../domain/common/user/repository/UserRepository.java | 4 ++-- .../backend/domain/common/user/service/UserService.java | 8 ++++---- .../backend/domain/instructor/auth/entity/Instructor.java | 6 +++--- .../instructor/auth/service/InstructorAuthService.java | 4 ++-- 8 files changed, 22 insertions(+), 22 deletions(-) rename src/main/java/ditda/backend/domain/common/user/entity/{UserEntity.java => User.java} (94%) diff --git a/src/main/java/ditda/backend/domain/common/auth/entity/RefreshToken.java b/src/main/java/ditda/backend/domain/common/auth/entity/RefreshToken.java index 5442d2b..d246f21 100644 --- a/src/main/java/ditda/backend/domain/common/auth/entity/RefreshToken.java +++ b/src/main/java/ditda/backend/domain/common/auth/entity/RefreshToken.java @@ -2,7 +2,7 @@ import java.time.LocalDateTime; -import ditda.backend.domain.common.user.entity.UserEntity; +import ditda.backend.domain.common.user.entity.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -31,7 +31,7 @@ public class RefreshToken { @OneToOne(fetch = FetchType.LAZY) @MapsId @JoinColumn(name = "user_id") - private UserEntity user; + private User user; @Column(name = "refresh_token", nullable = false, length = 512) private String token; @@ -39,7 +39,7 @@ public class RefreshToken { @Column(name = "expires_at", nullable = false) private LocalDateTime expiresAt; - public static RefreshToken createRefreshToken(UserEntity user, String token, LocalDateTime expiresAt) { + public static RefreshToken createRefreshToken(User user, String token, LocalDateTime expiresAt) { return RefreshToken.builder() .user(user) .token(token) diff --git a/src/main/java/ditda/backend/domain/common/term/entity/UserTerm.java b/src/main/java/ditda/backend/domain/common/term/entity/UserTerm.java index 97f0042..a1aba2c 100644 --- a/src/main/java/ditda/backend/domain/common/term/entity/UserTerm.java +++ b/src/main/java/ditda/backend/domain/common/term/entity/UserTerm.java @@ -1,7 +1,7 @@ package ditda.backend.domain.common.term.entity; import ditda.backend.domain.common.term.entity.enums.TermType; -import ditda.backend.domain.common.user.entity.UserEntity; +import ditda.backend.domain.common.user.entity.User; import ditda.backend.global.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -35,7 +35,7 @@ public class UserTerm extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) - private UserEntity user; + private User user; @Enumerated(EnumType.STRING) @Column(name = "term_type", nullable = false, length = 50) @@ -47,7 +47,7 @@ public class UserTerm extends BaseEntity { @Column(name = "is_agreed", nullable = false) private boolean isAgreed; - public static UserTerm createTerm(UserEntity user, TermType type, String version, boolean isAgreed) { + public static UserTerm createTerm(User user, TermType type, String version, boolean isAgreed) { return UserTerm.builder() .user(user) .termType(type) diff --git a/src/main/java/ditda/backend/domain/common/term/service/TermService.java b/src/main/java/ditda/backend/domain/common/term/service/TermService.java index 727ead4..00aabfb 100644 --- a/src/main/java/ditda/backend/domain/common/term/service/TermService.java +++ b/src/main/java/ditda/backend/domain/common/term/service/TermService.java @@ -11,7 +11,7 @@ import ditda.backend.domain.common.term.entity.enums.TermType; import ditda.backend.domain.common.term.exception.TermErrorCode; import ditda.backend.domain.common.term.repository.UserTermRepository; -import ditda.backend.domain.common.user.entity.UserEntity; +import ditda.backend.domain.common.user.entity.User; import ditda.backend.global.apipayload.exception.GeneralException; import lombok.RequiredArgsConstructor; @@ -28,7 +28,7 @@ public class TermService { private final UserTermRepository userTermRepository; @Transactional - public void saveTerms(UserEntity user, List agreements) { + public void saveTerms(User user, List agreements) { validateRequiredAgreements(agreements); List terms = agreements.stream() diff --git a/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java b/src/main/java/ditda/backend/domain/common/user/entity/User.java similarity index 94% rename from src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java rename to src/main/java/ditda/backend/domain/common/user/entity/User.java index ea4aec2..b7e71f0 100644 --- a/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java +++ b/src/main/java/ditda/backend/domain/common/user/entity/User.java @@ -24,7 +24,7 @@ @Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class UserEntity extends BaseEntity { +public class User extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -56,7 +56,7 @@ public class UserEntity extends BaseEntity { @Column(name = "email_verified_at") private LocalDateTime emailVerifiedAt; - public static UserEntity createUser( + public static User createUser( String username, String password, String name, @@ -65,7 +65,7 @@ public static UserEntity createUser( String phone, UserRole role ) { - return UserEntity.builder() + return User.builder() .username(username) .password(password) .name(name) diff --git a/src/main/java/ditda/backend/domain/common/user/repository/UserRepository.java b/src/main/java/ditda/backend/domain/common/user/repository/UserRepository.java index fc624e0..7af97bf 100644 --- a/src/main/java/ditda/backend/domain/common/user/repository/UserRepository.java +++ b/src/main/java/ditda/backend/domain/common/user/repository/UserRepository.java @@ -2,9 +2,9 @@ import org.springframework.data.jpa.repository.JpaRepository; -import ditda.backend.domain.common.user.entity.UserEntity; +import ditda.backend.domain.common.user.entity.User; -public interface UserRepository extends JpaRepository { +public interface UserRepository extends JpaRepository { boolean existsByUsername(String username); boolean existsByEmail(String email); diff --git a/src/main/java/ditda/backend/domain/common/user/service/UserService.java b/src/main/java/ditda/backend/domain/common/user/service/UserService.java index 53e0e9e..d2e4837 100644 --- a/src/main/java/ditda/backend/domain/common/user/service/UserService.java +++ b/src/main/java/ditda/backend/domain/common/user/service/UserService.java @@ -3,7 +3,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import ditda.backend.domain.common.user.entity.UserEntity; +import ditda.backend.domain.common.user.entity.User; import ditda.backend.domain.common.user.entity.enums.UserRole; import ditda.backend.domain.common.user.exception.UserErrorCode; import ditda.backend.domain.common.user.repository.UserRepository; @@ -17,7 +17,7 @@ public class UserService { private final UserRepository userRepository; - public UserEntity getReferenceById(Long userId) { + public User getReferenceById(Long userId) { return userRepository.getReferenceById(userId); } @@ -43,11 +43,11 @@ public void validateEmailAvailable(String email) { // μœ μ € 생성 @Transactional - public UserEntity createUser( + public User createUser( String username, String encodedPassword, String name, String email, String profileImage, String phone, UserRole role ) { - UserEntity user = UserEntity.createUser( + User user = User.createUser( username, encodedPassword, name, email, profileImage, phone, role ); return userRepository.save(user); diff --git a/src/main/java/ditda/backend/domain/instructor/auth/entity/Instructor.java b/src/main/java/ditda/backend/domain/instructor/auth/entity/Instructor.java index 5ad837d..9f26a08 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/entity/Instructor.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/entity/Instructor.java @@ -1,6 +1,6 @@ package ditda.backend.domain.instructor.auth.entity; -import ditda.backend.domain.common.user.entity.UserEntity; +import ditda.backend.domain.common.user.entity.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -30,9 +30,9 @@ public class Instructor { @OneToOne(fetch = FetchType.LAZY) @MapsId @JoinColumn(name = "instructor_id") - private UserEntity user; + private User user; - public static Instructor createInstructor(UserEntity user) { + public static Instructor createInstructor(User user) { return Instructor.builder() .user(user) .build(); diff --git a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java index b5fb3df..a3cb501 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java @@ -10,7 +10,7 @@ import ditda.backend.domain.common.auth.service.TokenService; import ditda.backend.domain.common.term.dto.TermAgreement; import ditda.backend.domain.common.term.service.TermService; -import ditda.backend.domain.common.user.entity.UserEntity; +import ditda.backend.domain.common.user.entity.User; import ditda.backend.domain.common.user.entity.enums.UserRole; import ditda.backend.domain.common.user.service.UserService; import ditda.backend.domain.instructor.auth.dto.InstructorAuthResult; @@ -37,7 +37,7 @@ public InstructorAuthResult signup(InstructorSignupRequest request) { userService.validateUsernameAvailable(request.username()); userService.validateEmailAvailable(request.email()); - UserEntity user = userService.createUser( + User user = userService.createUser( request.username(), passwordEncoder.encode(request.password()), request.name(), From a110f74af77fd1bcae7b14bb10506e8579ce4fd1 Mon Sep 17 00:00:00 2001 From: fervovita Date: Sun, 24 May 2026 23:50:39 +0900 Subject: [PATCH 42/90] =?UTF-8?q?Feat(designer):=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B4=EB=84=88=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/designer/auth/entity/Designer.java | 68 +++++++++++++++++++ .../designer/auth/entity/Portfolio.java | 46 +++++++++++++ .../designer/auth/entity/enums/BankName.java | 26 +++++++ .../auth/exception/DesignerErrorCode.java | 20 ++++++ .../auth/repository/DesignerRepository.java | 8 +++ .../auth/repository/PortfolioRepository.java | 8 +++ 6 files changed, 176 insertions(+) create mode 100644 src/main/java/ditda/backend/domain/designer/auth/entity/Designer.java create mode 100644 src/main/java/ditda/backend/domain/designer/auth/entity/Portfolio.java create mode 100644 src/main/java/ditda/backend/domain/designer/auth/entity/enums/BankName.java create mode 100644 src/main/java/ditda/backend/domain/designer/auth/exception/DesignerErrorCode.java create mode 100644 src/main/java/ditda/backend/domain/designer/auth/repository/DesignerRepository.java create mode 100644 src/main/java/ditda/backend/domain/designer/auth/repository/PortfolioRepository.java diff --git a/src/main/java/ditda/backend/domain/designer/auth/entity/Designer.java b/src/main/java/ditda/backend/domain/designer/auth/entity/Designer.java new file mode 100644 index 0000000..376e683 --- /dev/null +++ b/src/main/java/ditda/backend/domain/designer/auth/entity/Designer.java @@ -0,0 +1,68 @@ +package ditda.backend.domain.designer.auth.entity; + +import ditda.backend.domain.common.user.entity.User; +import ditda.backend.domain.designer.auth.entity.enums.BankName; +import ditda.backend.global.encryption.AesEncryptConverter; +import ditda.backend.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "designers") +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Designer extends BaseEntity { + + @Id + @Column(name = "user_id") + private Long id; + + @MapsId + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Builder.Default + @Column(name = "level", nullable = false) + private int level = 1; + + @Builder.Default + @Column(name = "exp", nullable = false) + private int exp = 0; + + @Enumerated(EnumType.STRING) + @Column(name = "bank_name", length = 50, nullable = false) + private BankName bankName; + + @Convert(converter = AesEncryptConverter.class) + @Column(name = "account_number", nullable = false) + private String accountNumber; + + @Column(name = "account_holder", length = 50, nullable = false) + private String accountHolder; + + public static Designer createDesigner(User user, BankName bankName, String accountNumber, String accountHolder) { + return Designer.builder() + .user(user) + .bankName(bankName) + .accountNumber(accountNumber) + .accountHolder(accountHolder) + .build(); + } +} diff --git a/src/main/java/ditda/backend/domain/designer/auth/entity/Portfolio.java b/src/main/java/ditda/backend/domain/designer/auth/entity/Portfolio.java new file mode 100644 index 0000000..8c19fbd --- /dev/null +++ b/src/main/java/ditda/backend/domain/designer/auth/entity/Portfolio.java @@ -0,0 +1,46 @@ +package ditda.backend.domain.designer.auth.entity; + +import ditda.backend.domain.common.user.entity.User; +import ditda.backend.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +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 lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "portfolios") +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Portfolio extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "portfolio_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "portfolio_url", nullable = false) + private String portfolioUrl; + + public static Portfolio createPortfolio(User user, String portfolioUrl) { + return Portfolio.builder() + .user(user) + .portfolioUrl(portfolioUrl) + .build(); + } +} diff --git a/src/main/java/ditda/backend/domain/designer/auth/entity/enums/BankName.java b/src/main/java/ditda/backend/domain/designer/auth/entity/enums/BankName.java new file mode 100644 index 0000000..358729a --- /dev/null +++ b/src/main/java/ditda/backend/domain/designer/auth/entity/enums/BankName.java @@ -0,0 +1,26 @@ +package ditda.backend.domain.designer.auth.entity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum BankName { + + TOSS("ν† μŠ€λ±…ν¬"), + KAKAO("μΉ΄μΉ΄μ˜€λ±…ν¬"), + KOOKMIN("ꡭ민은행"), + IBK("기업은행"), + NH("λ†ν˜‘μ€ν–‰"), + SHINHAN("μ‹ ν•œμ€ν–‰"), + IM("iM뱅크"), + WOORI("μš°λ¦¬μ€ν–‰"), + CITI("ν•œκ΅­μ‹œν‹°μ€ν–‰"), + HANA("ν•˜λ‚˜μ€ν–‰"), + SAVINGS("저좕은행"), + SAEMAUL("μƒˆλ§ˆμ„κΈˆκ³ "), + SHINHYUP("μ‹ ν˜‘"), + POST_OFFICE("우체ꡭ"); + + private final String koreanName; +} diff --git a/src/main/java/ditda/backend/domain/designer/auth/exception/DesignerErrorCode.java b/src/main/java/ditda/backend/domain/designer/auth/exception/DesignerErrorCode.java new file mode 100644 index 0000000..9a3d813 --- /dev/null +++ b/src/main/java/ditda/backend/domain/designer/auth/exception/DesignerErrorCode.java @@ -0,0 +1,20 @@ +package ditda.backend.domain.designer.auth.exception; + +import org.springframework.http.HttpStatus; + +import ditda.backend.global.apipayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum DesignerErrorCode implements BaseErrorCode { + + PORTFOLIO_FILE_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "DESIGNER_400_01", "포트폴리였 νŒŒμΌμ€ μ΅œλŒ€ 3κ°œκΉŒμ§€ μ—…λ‘œλ“œ κ°€λŠ₯ν•©λ‹ˆλ‹€."), + INVALID_PORTFOLIO_FILE(HttpStatus.BAD_REQUEST, "DESIGNER_400_02", "μ§€μ›ν•˜μ§€ μ•ŠλŠ” 파일 ν˜•μ‹μž…λ‹ˆλ‹€."), + PORTFOLIO_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "DESIGNER_500_01", "포트폴리였 파일 μ—…λ‘œλ“œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/ditda/backend/domain/designer/auth/repository/DesignerRepository.java b/src/main/java/ditda/backend/domain/designer/auth/repository/DesignerRepository.java new file mode 100644 index 0000000..ac23d38 --- /dev/null +++ b/src/main/java/ditda/backend/domain/designer/auth/repository/DesignerRepository.java @@ -0,0 +1,8 @@ +package ditda.backend.domain.designer.auth.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import ditda.backend.domain.designer.auth.entity.Designer; + +public interface DesignerRepository extends JpaRepository { +} diff --git a/src/main/java/ditda/backend/domain/designer/auth/repository/PortfolioRepository.java b/src/main/java/ditda/backend/domain/designer/auth/repository/PortfolioRepository.java new file mode 100644 index 0000000..d1c784c --- /dev/null +++ b/src/main/java/ditda/backend/domain/designer/auth/repository/PortfolioRepository.java @@ -0,0 +1,8 @@ +package ditda.backend.domain.designer.auth.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import ditda.backend.domain.designer.auth.entity.Portfolio; + +public interface PortfolioRepository extends JpaRepository { +} From 660ca860c104229eabbfad5d6b578cb17a860e1e Mon Sep 17 00:00:00 2001 From: fervovita Date: Sun, 24 May 2026 23:51:57 +0900 Subject: [PATCH 43/90] =?UTF-8?q?Feat(designer):=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B4=EB=84=88=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20API?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/DesignerAuthController.java | 58 ++++++++ .../designer/auth/dto/DesignerAuthResult.java | 13 ++ .../dto/request/DesignerSignupRequest.java | 122 +++++++++++++++++ .../dto/response/DesignerSignupResponse.java | 14 ++ .../auth/facade/DesignerAuthFacade.java | 46 +++++++ .../auth/service/DesignerAuthService.java | 78 +++++++++++ .../auth/service/PortfolioService.java | 129 ++++++++++++++++++ 7 files changed, 460 insertions(+) create mode 100644 src/main/java/ditda/backend/domain/designer/auth/controller/DesignerAuthController.java create mode 100644 src/main/java/ditda/backend/domain/designer/auth/dto/DesignerAuthResult.java create mode 100644 src/main/java/ditda/backend/domain/designer/auth/dto/request/DesignerSignupRequest.java create mode 100644 src/main/java/ditda/backend/domain/designer/auth/dto/response/DesignerSignupResponse.java create mode 100644 src/main/java/ditda/backend/domain/designer/auth/facade/DesignerAuthFacade.java create mode 100644 src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java create mode 100644 src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java diff --git a/src/main/java/ditda/backend/domain/designer/auth/controller/DesignerAuthController.java b/src/main/java/ditda/backend/domain/designer/auth/controller/DesignerAuthController.java new file mode 100644 index 0000000..aee323f --- /dev/null +++ b/src/main/java/ditda/backend/domain/designer/auth/controller/DesignerAuthController.java @@ -0,0 +1,58 @@ +package ditda.backend.domain.designer.auth.controller; + +import java.util.List; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import ditda.backend.domain.designer.auth.dto.DesignerAuthResult; +import ditda.backend.domain.designer.auth.dto.request.DesignerSignupRequest; +import ditda.backend.domain.designer.auth.dto.response.DesignerSignupResponse; +import ditda.backend.domain.designer.auth.facade.DesignerAuthFacade; +import ditda.backend.global.apipayload.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Encoding; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/designers/auth") +@RequiredArgsConstructor +@Tag(name = "Designer Auth", description = "λ””μžμ΄λ„ˆ νšŒμ›κ°€μž… API") +public class DesignerAuthController { + + private final DesignerAuthFacade designerAuthFacade; + + @Operation( + summary = "λ””μžμ΄λ„ˆ νšŒμ›κ°€μž…", + description = "**[νšŒμ›κ°€μž…]** νšŒμ›κ°€μž… ν›„ μžλ™ 둜그인 μ²˜λ¦¬λ©λ‹ˆλ‹€. `data`(JSON) + `portfolioFiles`(파일 μ΅œλŒ€ 3개, 각 30MB)" + ) + @RequestBody( + content = @Content( + mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + encoding = @Encoding(name = "data", contentType = MediaType.APPLICATION_JSON_VALUE) + ) + ) + @PostMapping(value = "/signup", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse signup( + @Valid @RequestPart("data") DesignerSignupRequest request, + @RequestPart(value = "portfolioFiles", required = false) List portfolioFiles, + HttpServletResponse response + ) { + + DesignerAuthResult result = designerAuthFacade.signup(request, portfolioFiles); + + response.addHeader(HttpHeaders.SET_COOKIE, result.refreshTokenCookie().toString()); + + return ApiResponse.onSuccess("λ””μžμ΄λ„ˆ νšŒμ›κ°€μž… 성곡", new DesignerSignupResponse(result.userId(), result.accessToken())); + } +} diff --git a/src/main/java/ditda/backend/domain/designer/auth/dto/DesignerAuthResult.java b/src/main/java/ditda/backend/domain/designer/auth/dto/DesignerAuthResult.java new file mode 100644 index 0000000..c58462d --- /dev/null +++ b/src/main/java/ditda/backend/domain/designer/auth/dto/DesignerAuthResult.java @@ -0,0 +1,13 @@ +package ditda.backend.domain.designer.auth.dto; + +import org.springframework.http.ResponseCookie; + +public record DesignerAuthResult( + + Long userId, + + String accessToken, + + ResponseCookie refreshTokenCookie +) { +} diff --git a/src/main/java/ditda/backend/domain/designer/auth/dto/request/DesignerSignupRequest.java b/src/main/java/ditda/backend/domain/designer/auth/dto/request/DesignerSignupRequest.java new file mode 100644 index 0000000..d629604 --- /dev/null +++ b/src/main/java/ditda/backend/domain/designer/auth/dto/request/DesignerSignupRequest.java @@ -0,0 +1,122 @@ +package ditda.backend.domain.designer.auth.dto.request; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import ditda.backend.domain.common.term.entity.enums.TermType; +import ditda.backend.domain.designer.auth.entity.enums.BankName; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +@Schema(description = "λ””μžμ΄λ„ˆ νšŒμ›κ°€μž… μš”μ²­") +public record DesignerSignupRequest( + + @Schema( + description = "μ•½κ΄€", + example = """ + [ + { + "type": "SERVICE", + "version": "V1.0", + "isAgreed": true + }, + { + "type": "USERINFO", + "version": "V1.0", + "isAgreed": true + }, + { + "type": "SETTLEMENT", + "version": "V1.0", + "isAgreed": true + }, + { + "type": "DISINTERMEDIATION", + "version": "V1.0", + "isAgreed": true + } + ] + """ + ) + @NotEmpty(message = "μ•½κ΄€ λ™μ˜ μ—¬λΆ€ λ‚΄μš©μ€ ν•„μˆ˜μž…λ‹ˆλ‹€") + @Valid + List<@NotNull(message = "μ•½κ΄€ ν•­λͺ©μ€ ν•„μˆ˜μž…λ‹ˆλ‹€.") @Valid TermRequest> terms, + + @Schema(description = "이름", example = "홍길동") + @NotBlank(message = "이름은 ν•„μˆ˜μž…λ‹ˆλ‹€.") + @Size(max = 50, message = "μ˜¬λ°”λ₯΄μ§€ μ•Šμ€ μ΄λ¦„μž…λ‹ˆλ‹€.") + String name, + + @Schema(description = "μ „ν™”λ²ˆν˜Έ", example = "01012345678") + @NotBlank(message = "μ „ν™”λ²ˆν˜Έκ°€ μ—†μŠ΅λ‹ˆλ‹€.") + @Pattern( + regexp = "^010\\d{7,8}$", + message = "μ „ν™”λ²ˆν˜Έ ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€." + ) + String phone, + + @Schema(description = "아이디 (6 ~ 20자 이내)", example = "testid123") + @NotBlank(message = "μ•„μ΄λ””λŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") + @Size(min = 6, max = 20, message = "μ•„μ΄λ””λŠ” 6자 이상 20자 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€.") + String username, + + @Schema(description = "λΉ„λ°€λ²ˆν˜Έ", example = "password1234") + @NotBlank(message = "λΉ„λ°€λ²ˆν˜ΈλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") + @Size(min = 8, max = 20, message = "λΉ„λ°€λ²ˆν˜ΈλŠ” 8자 이상 20자 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€.") + @Pattern( + regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", + message = "λΉ„λ°€λ²ˆν˜ΈλŠ” 영문, 숫자 1개 이상 포함해야 ν•©λ‹ˆλ‹€." + ) + String password, + + @Schema(description = "이메일", example = "testid@gmail.com") + @NotBlank(message = "이메일은 ν•„μˆ˜μž…λ‹ˆλ‹€.") + @Email(message = "μ˜¬λ°”λ₯Έ 이메일 ν˜•μ‹μ΄ μ•„λ‹™λ‹ˆλ‹€.") + @Size(max = 100, message = "이메일은 100자 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€.") + String email, + + @NotNull(message = "κ³„μ’Œ μ •λ³΄λŠ” ν•„μˆ˜μž…λ‹ˆλ‹€") + @Valid + BankAccountRequest bankAccount +) { + + public record TermRequest( + + @Schema(description = "μ•½κ΄€ μ’…λ₯˜", example = "SERVICE") + @NotNull(message = "μ•½κ΄€ μ’…λ₯˜ ν•„μˆ˜μž…λ‹ˆλ‹€.") + TermType type, + + @Schema(description = "μ•½κ΄€ 버전", example = "V1.0") + @NotBlank(message = "μ•½κ΄€ 버전 ν•„μˆ˜μž…λ‹ˆλ‹€.") + String version, + + @Schema(description = "μ•½κ΄€ λ™μ˜ μ—¬λΆ€", example = "true") + @JsonProperty("isAgreed") + boolean isAgreed + ) { + } + + public record BankAccountRequest( + + @Schema(description = "은행λͺ…", example = "TOSS") + @NotNull(message = "은행λͺ…은 ν•„μˆ˜μž…λ‹ˆλ‹€.") + BankName bankName, + + @Schema(description = "κ³„μ’Œλ²ˆν˜Έ", example = "123456789012") + @NotBlank(message = "κ³„μ’Œλ²ˆν˜ΈλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") + @Pattern(regexp = "^[0-9]{10,15}$", message = "μ˜¬λ°”λ₯Έ κ³„μ’Œλ²ˆν˜Έ ν˜•μ‹μ΄ μ•„λ‹™λ‹ˆλ‹€. (숫자 10~15자리)") + String accountNumber, + + @Schema(description = "예금주", example = "홍길동") + @NotBlank(message = "μ˜ˆκΈˆμ£ΌλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") + String accountHolder + ) { + } +} diff --git a/src/main/java/ditda/backend/domain/designer/auth/dto/response/DesignerSignupResponse.java b/src/main/java/ditda/backend/domain/designer/auth/dto/response/DesignerSignupResponse.java new file mode 100644 index 0000000..9e2e3bc --- /dev/null +++ b/src/main/java/ditda/backend/domain/designer/auth/dto/response/DesignerSignupResponse.java @@ -0,0 +1,14 @@ +package ditda.backend.domain.designer.auth.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "λ””μžμ΄λ„ˆ νšŒμ›κ°€μž… 응닡") +public record DesignerSignupResponse( + + @Schema(description = "User ID") + Long userId, + + @Schema(description = "Access Token") + String accessToken +) { +} diff --git a/src/main/java/ditda/backend/domain/designer/auth/facade/DesignerAuthFacade.java b/src/main/java/ditda/backend/domain/designer/auth/facade/DesignerAuthFacade.java new file mode 100644 index 0000000..18a7782 --- /dev/null +++ b/src/main/java/ditda/backend/domain/designer/auth/facade/DesignerAuthFacade.java @@ -0,0 +1,46 @@ +package ditda.backend.domain.designer.auth.facade; + +import java.util.List; + +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import ditda.backend.domain.common.auth.service.EmailVerificationService; +import ditda.backend.domain.designer.auth.dto.DesignerAuthResult; +import ditda.backend.domain.designer.auth.dto.request.DesignerSignupRequest; +import ditda.backend.domain.designer.auth.service.DesignerAuthService; +import ditda.backend.domain.designer.auth.service.PortfolioService; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class DesignerAuthFacade { + + private final DesignerAuthService designerAuthService; + private final EmailVerificationService emailVerificationService; + private final PortfolioService portfolioService; + + public DesignerAuthResult signup(DesignerSignupRequest request, List portfolioFiles) { + + // 이메일 인증 μ—¬λΆ€ 확인 + emailVerificationService.validateVerified(request.email()); + + // 포트폴리였 파일 검증 및 S3 μ—…λ‘œλ“œ + portfolioService.validateFiles(portfolioFiles); + List portfolioKeys = portfolioService.uploadFiles(portfolioFiles); + + // νšŒμ›κ°€μž… + DesignerAuthResult result; + try { + result = designerAuthService.signup(request, portfolioKeys); + } catch (Exception e) { + portfolioService.deleteFiles(portfolioKeys); + throw e; + } + + // 이메일 인증 마크 정리 + emailVerificationService.deleteVerified(request.email()); + + return result; + } +} diff --git a/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java b/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java new file mode 100644 index 0000000..5ed9eb0 --- /dev/null +++ b/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java @@ -0,0 +1,78 @@ +package ditda.backend.domain.designer.auth.service; + +import java.util.List; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ditda.backend.domain.common.auth.dto.TokenPair; +import ditda.backend.domain.common.auth.service.TokenService; +import ditda.backend.domain.common.term.dto.TermAgreement; +import ditda.backend.domain.common.term.service.TermService; +import ditda.backend.domain.common.user.entity.User; +import ditda.backend.domain.common.user.entity.enums.UserRole; +import ditda.backend.domain.common.user.service.UserService; +import ditda.backend.domain.designer.auth.dto.DesignerAuthResult; +import ditda.backend.domain.designer.auth.dto.request.DesignerSignupRequest; +import ditda.backend.domain.designer.auth.entity.Designer; +import ditda.backend.domain.designer.auth.repository.DesignerRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class DesignerAuthService { + + private static final String DEFAULT_PROFILE_IMAGE = ""; + + private final DesignerRepository designerRepository; + private final UserService userService; + private final TermService termService; + private final TokenService tokenService; + private final PortfolioService portfolioService; + private final PasswordEncoder passwordEncoder; + + @Transactional + public DesignerAuthResult signup(DesignerSignupRequest request, List portfolioKeys) { + + // 아이디 및 이메일 검증 + userService.validateUsernameAvailable(request.username()); + userService.validateEmailAvailable(request.email()); + + // μœ μ € 생성 + User user = userService.createUser( + request.username(), + passwordEncoder.encode(request.password()), + request.name(), + request.email(), + DEFAULT_PROFILE_IMAGE, + request.phone(), + UserRole.DESIGNER + ); + + // Designer 생성 및 DB μ €μž₯ + Designer designer = Designer.createDesigner( + user, + request.bankAccount().bankName(), + request.bankAccount().accountNumber(), + request.bankAccount().accountHolder()); + designerRepository.save(designer); + + // μ•½κ΄€ λ™μ˜ μ—¬λΆ€ DB μ €μž₯ + termService.saveTerms(user, toAgreements(request.terms())); + + // 포트폴리였 S3 keyλ₯Ό DB에 일괄 μ €μž₯ + portfolioService.savePortfolios(user, portfolioKeys); + + // accessToken&refreshToken λ°œκΈ‰ + TokenPair tokens = tokenService.issueTokens(user.getId()); + + return new DesignerAuthResult(user.getId(), tokens.accessToken(), tokens.refreshCookie()); + } + + private List toAgreements(List terms) { + return terms.stream() + .map(t -> new TermAgreement(t.type(), t.version(), t.isAgreed())) + .toList(); + } +} diff --git a/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java b/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java new file mode 100644 index 0000000..556598d --- /dev/null +++ b/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java @@ -0,0 +1,129 @@ +package ditda.backend.domain.designer.auth.service; + +import java.io.IOException; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import ditda.backend.domain.common.user.entity.User; +import ditda.backend.domain.designer.auth.entity.Portfolio; +import ditda.backend.domain.designer.auth.exception.DesignerErrorCode; +import ditda.backend.domain.designer.auth.repository.PortfolioRepository; +import ditda.backend.global.apipayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PortfolioService { + + private static final int MAX_PORTFOLIO_COUNT = 3; + private static final String S3_KEY_PREFIX = "portfolio"; + private static final Set ALLOWED_CONTENT_TYPES = Set.of( + "application/pdf", + "image/png" + ); + + private final S3Client s3Client; + private final PortfolioRepository portfolioRepository; + + @Value("${app.s3.bucket}") + private String bucket; + + public void validateFiles(List files) { + if (files == null || files.isEmpty()) { + return; + } + + // 파일 μ΅œλŒ€ 개수 검증 + long realCount = files.stream().filter(f -> !f.isEmpty()).count(); + if (realCount > MAX_PORTFOLIO_COUNT) { + throw new GeneralException(DesignerErrorCode.PORTFOLIO_FILE_LIMIT_EXCEEDED); + } + + // 파일 νƒ€μž… 검증 + for (MultipartFile file : files) { + if (file.isEmpty()) { + continue; + } + if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) { + throw new GeneralException(DesignerErrorCode.INVALID_PORTFOLIO_FILE); + } + } + } + + public List uploadFiles(List files) { + if (files == null || files.isEmpty()) { + return List.of(); + } + + // νŒŒμΌμ„ S3에 μ—…λ‘œλ“œ + return files.stream() + .filter(f -> !f.isEmpty()) + .map(this::uploadToS3) + .toList(); + } + + @Transactional + public void savePortfolios(User user, List portfolioKeys) { + if (portfolioKeys.isEmpty()) { + return; + } + + // νŒŒμΌμ„ DB에 일괄 μ €μž₯ + List portfolios = portfolioKeys.stream() + .map(key -> Portfolio.createPortfolio(user, key)) + .toList(); + portfolioRepository.saveAll(portfolios); + } + + public void deleteFiles(List portfolioKeys) { + if (portfolioKeys == null || portfolioKeys.isEmpty()) { + return; + } + + for (String key : portfolioKeys) { + try { + s3Client.deleteObject(req -> req.bucket(bucket).key(key)); + } catch (Exception e) { + log.warn("Failed to delete S3 portfolio file. key={}", key, e); + } + } + } + + private String uploadToS3(MultipartFile file) { + String key = generateKey(file); + try { + s3Client.putObject( + req -> req.bucket(bucket).key(key).contentType(file.getContentType()), + RequestBody.fromInputStream(file.getInputStream(), file.getSize()) + ); + return key; + } catch (IOException | SdkException e) { + log.error("Failed to upload portfolio file. originalName={}", file.getOriginalFilename(), e); + throw new GeneralException(DesignerErrorCode.PORTFOLIO_UPLOAD_FAILED); + } + } + + private String generateKey(MultipartFile file) { + String extension = extractExtension(file.getOriginalFilename()); + return "%s/%s%s".formatted(S3_KEY_PREFIX, UUID.randomUUID(), extension); + } + + // 파일λͺ…에 ν™•μž₯자 μΆ”μΆœ + private String extractExtension(String filename) { + if (filename == null || !filename.contains(".")) { + return ""; + } + return filename.substring(filename.lastIndexOf('.')); + } +} From c1037e21f1c3c1875bd499fda03fc40bf61f8919 Mon Sep 17 00:00:00 2001 From: fervovita Date: Sun, 24 May 2026 23:58:30 +0900 Subject: [PATCH 44/90] =?UTF-8?q?Chore(config):=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B4=EB=84=88=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/ditda/backend/global/config/SecurityConfig.java | 5 ++--- src/main/resources/application.yaml | 6 ++++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/java/ditda/backend/global/config/SecurityConfig.java b/src/main/java/ditda/backend/global/config/SecurityConfig.java index 119cc73..8a3fb0f 100644 --- a/src/main/java/ditda/backend/global/config/SecurityConfig.java +++ b/src/main/java/ditda/backend/global/config/SecurityConfig.java @@ -57,9 +57,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // api μ ‘κ·Ό κΆŒν•œ μ„€μ • .authorizeHttpRequests(auth -> auth .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() - .requestMatchers(HttpMethod.POST, - "/api/v1/instructors/signup" - ).permitAll() + .requestMatchers(HttpMethod.POST, "/api/v1/instructors/signup").permitAll() + .requestMatchers(HttpMethod.POST, "/api/v1/designers/auth/signup").permitAll() .requestMatchers("/api/v1/auth/**").permitAll() .anyRequest().authenticated() ) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 6a49970..9121810 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -31,6 +31,12 @@ spring: starttls: enable: true + servlet: + multipart: + max-file-size: 30MB + max-request-size: 100MB + enabled: true + jwt: secret: ${JWT_SECRET_KEY} access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION} From cad2e71fab51dbdedc090ed879b16e12316210e1 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Mon, 25 May 2026 00:20:19 +0900 Subject: [PATCH 45/90] =?UTF-8?q?Fix(auth):=20API=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/ditda/backend/global/config/SecurityConfig.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/ditda/backend/global/config/SecurityConfig.java b/src/main/java/ditda/backend/global/config/SecurityConfig.java index e419b35..32dc7f1 100644 --- a/src/main/java/ditda/backend/global/config/SecurityConfig.java +++ b/src/main/java/ditda/backend/global/config/SecurityConfig.java @@ -58,12 +58,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(auth -> auth .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() .requestMatchers(HttpMethod.POST, - "/api/v1/instructor/check-username", - "/api/v1/instructor/signup", + "/api/v1/instructors/check-username", + "/api/v1/instructors/signup", + "/api/v1/auth/emails/verification-requests", + "/api/v1/auth/emails/verifications", "/api/v1/auth/login", "/api/v1/auth/reissue" ).permitAll() - .requestMatchers("/api/v1/auth/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class); From 50b80beaf31bc03e371cb2f614106b508b19f03a Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Mon, 25 May 2026 00:22:21 +0900 Subject: [PATCH 46/90] =?UTF-8?q?Feat(auth):=20refresh=20token=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=20=EA=B8=B0=EA=B8=B0=20+=20HMAC=20=ED=95=B4=EC=8B=9C?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EA=B5=AC=EC=A1=B0=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 +++ .../auth/controller/AuthController.java | 3 +- .../common/auth/entity/RefreshToken.java | 46 +++++++++---- .../domain/common/auth/facade/AuthFacade.java | 4 +- .../repository/RefreshTokenRepository.java | 15 +++- .../common/auth/service/AuthService.java | 68 ++++++++++++++----- .../auth/service/RefreshTokenHasher.java | 43 ++++++++++++ .../backend/global/jwt/JwtTokenProvider.java | 21 +++++- src/main/resources/application.yaml | 1 + src/test/resources/application-test.yaml | 1 + 10 files changed, 174 insertions(+), 36 deletions(-) create mode 100644 src/main/java/ditda/backend/domain/common/auth/service/RefreshTokenHasher.java diff --git a/.env.example b/.env.example index 7fc8688..3babc2b 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,14 @@ S3_ACCESS_KEY=your_access_key S3_SECRET_KEY=your_secret_key S3_BUCKET=your_bucket_name +# JWT +JWT_SECRET_KEY=2JEY7VPtpzgzWt_FaOtY0sXatcXetnAwX29xq2gcexI +JWT_ACCESS_TOKEN_EXPIRATION=3600000 +JWT_REFRESH_TOKEN_EXPIRATION=1209600000 +JWT_REFRESH_TOKEN_HASH_SECRET=o_CIMj9rb4U4R7aY_gHnEefufnl0DojcrAP1DxArzQciMyafSkPbb0E2oXMeUTmJ +JWT_COOKIE_SECURE=false +JWT_REFRESH_COOKIE_PATH=/api/v1/auth + # Mail MAIL_HOST=smtp.gmail.com MAIL_PORT=587 diff --git a/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java b/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java index 6b161a0..f4eaf97 100644 --- a/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java +++ b/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java @@ -72,10 +72,11 @@ public ApiResponse login( @PostMapping("/logout") public ApiResponse logout( @AuthenticationPrincipal Long userId, + @CookieValue(value = CookieUtils.REFRESH_TOKEN_COOKIE, required = false) String refreshToken, HttpServletResponse response ) { - ResponseCookie deleted = authFacade.logout(userId); + ResponseCookie deleted = authFacade.logout(userId, refreshToken); response.addHeader(HttpHeaders.SET_COOKIE, deleted.toString()); return ApiResponse.onSuccess("λ‘œκ·Έμ•„μ›ƒ 성곡"); diff --git a/src/main/java/ditda/backend/domain/common/auth/entity/RefreshToken.java b/src/main/java/ditda/backend/domain/common/auth/entity/RefreshToken.java index 5442d2b..14b10bf 100644 --- a/src/main/java/ditda/backend/domain/common/auth/entity/RefreshToken.java +++ b/src/main/java/ditda/backend/domain/common/auth/entity/RefreshToken.java @@ -1,5 +1,7 @@ package ditda.backend.domain.common.auth.entity; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.time.LocalDateTime; import ditda.backend.domain.common.user.entity.UserEntity; @@ -8,8 +10,7 @@ import jakarta.persistence.FetchType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; -import jakarta.persistence.MapsId; -import jakarta.persistence.OneToOne; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -26,29 +27,50 @@ public class RefreshToken { @Id - private Long userId; + @Column(name = "session_id", nullable = false, length = 36) + private String sessionId; - @OneToOne(fetch = FetchType.LAZY) - @MapsId - @JoinColumn(name = "user_id") + // 닀쀑 κΈ°κΈ° ν—ˆμš©μ„ μœ„ν•΄ N:1 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) private UserEntity user; - @Column(name = "refresh_token", nullable = false, length = 512) - private String token; + @Column(name = "refresh_token_hash", nullable = false, length = 128) + private String tokenHash; @Column(name = "expires_at", nullable = false) private LocalDateTime expiresAt; - public static RefreshToken createRefreshToken(UserEntity user, String token, LocalDateTime expiresAt) { + public static RefreshToken createRefreshToken( + UserEntity user, + String sessionId, + String tokenHash, + LocalDateTime expiresAt + ) { return RefreshToken.builder() .user(user) - .token(token) + .sessionId(sessionId) + .tokenHash(tokenHash) .expiresAt(expiresAt) .build(); } - public void rotate(String newToken, LocalDateTime newExpiresAt) { - this.token = newToken; + public void rotate(String newTokenHash, LocalDateTime newExpiresAt) { + this.tokenHash = newTokenHash; this.expiresAt = newExpiresAt; } + + public boolean belongsTo(Long userId) { + return this.user.getId().equals(userId); + } + + public boolean matchesHash(String tokenHash) { + if (tokenHash == null) { + return false; + } + return MessageDigest.isEqual( + this.tokenHash.getBytes(StandardCharsets.UTF_8), + tokenHash.getBytes(StandardCharsets.UTF_8) + ); + } } diff --git a/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java b/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java index bd0ac0e..830b934 100644 --- a/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java +++ b/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java @@ -35,8 +35,8 @@ public AuthResult login(LoginRequest request) { } // μœ μ € λ‘œκ·Έμ•„μ›ƒ - public ResponseCookie logout(Long userId) { - return authService.logout(userId); + public ResponseCookie logout(Long userId, String refreshToken) { + return authService.logout(userId, refreshToken); } // 토큰 μž¬λ°œκΈ‰ diff --git a/src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java b/src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java index 1db816e..b192be4 100644 --- a/src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java +++ b/src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java @@ -1,13 +1,22 @@ package ditda.backend.domain.common.auth.repository; +import java.time.LocalDateTime; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import ditda.backend.domain.common.auth.entity.RefreshToken; -public interface RefreshTokenRepository extends JpaRepository { - Optional findByUserId(Long userId); +public interface RefreshTokenRepository extends JpaRepository { - void deleteByUserId(Long userId); + Optional findBySessionId(String sessionId); + + void deleteBySessionId(String sessionId); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM RefreshToken rt WHERE rt.user.id = :userId AND rt.expiresAt < :now") + void deleteExpiredByUserId(@Param("userId") Long userId, @Param("now") LocalDateTime expiresAtBefore); } diff --git a/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java b/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java index ddf4a64..38105ba 100644 --- a/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java +++ b/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java @@ -1,6 +1,7 @@ package ditda.backend.domain.common.auth.service; import java.time.LocalDateTime; +import java.util.UUID; import org.springframework.http.ResponseCookie; import org.springframework.security.crypto.password.PasswordEncoder; @@ -17,6 +18,7 @@ import ditda.backend.global.apipayload.exception.GeneralException; import ditda.backend.global.jwt.JwtTokenProvider; import ditda.backend.global.jwt.utils.CookieUtils; +import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; import lombok.RequiredArgsConstructor; @@ -26,6 +28,7 @@ public class AuthService { private final UserEntityRepository userEntityRepository; private final PasswordEncoder passwordEncoder; + private final RefreshTokenHasher refreshTokenHasher; private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenRepository refreshTokenRepository; private final CookieUtils cookieUtils; @@ -44,9 +47,21 @@ public AuthResult login(LoginRequest request) { } @Transactional - public ResponseCookie logout(Long userId) { - - refreshTokenRepository.deleteByUserId(userId); + public ResponseCookie logout(Long userId, String refreshToken) { + + if (refreshToken != null && !refreshToken.isBlank()) { + try { + Claims claims = jwtTokenProvider.validateRefreshToken(refreshToken); + Long tokenUserId = Long.parseLong(claims.getSubject()); + String sessionId = jwtTokenProvider.getSessionId(claims); + + if (userId.equals(tokenUserId)) { + refreshTokenRepository.deleteBySessionId(sessionId); + } + } catch (JwtException | IllegalArgumentException exception) { + // μΏ ν‚€ μ‚­μ œλŠ” μ§„ν–‰. + } + } return cookieUtils.deleteRefreshTokenCookie(); } @@ -59,38 +74,57 @@ public AuthResult reissue(String refreshToken) { } Long userId; + String sessionId; try { - userId = Long.parseLong( - jwtTokenProvider.validateRefreshToken(refreshToken).getSubject()); + Claims claims = jwtTokenProvider.validateRefreshToken(refreshToken); + userId = Long.parseLong(claims.getSubject()); + sessionId = jwtTokenProvider.getSessionId(claims); } catch (JwtException | IllegalArgumentException exception) { throw new GeneralException(GeneralErrorCode.INVALID_TOKEN); } - RefreshToken stored = refreshTokenRepository.findByUserId(userId) + RefreshToken stored = refreshTokenRepository.findBySessionId(sessionId) .orElseThrow(() -> new GeneralException(GeneralErrorCode.INVALID_TOKEN)); - if (!stored.getToken().equals(refreshToken)) { + String refreshTokenHash = refreshTokenHasher.hash(refreshToken); + + if (!stored.belongsTo(userId) || !stored.matchesHash(refreshTokenHash)) { throw new GeneralException(GeneralErrorCode.INVALID_TOKEN); } - return issueTokens(userId); + String newAccessToken = jwtTokenProvider.generateAccessToken(userId); + String newRefreshToken = jwtTokenProvider.generateRefreshToken(userId, sessionId); + String newRefreshTokenHash = refreshTokenHasher.hash(newRefreshToken); + LocalDateTime newExpiresAt = jwtTokenProvider.getExpiration(newRefreshToken); + + stored.rotate(newRefreshTokenHash, newExpiresAt); + + ResponseCookie cookie = cookieUtils.createRefreshTokenCookie(newRefreshToken); + + return new AuthResult(userId, newAccessToken, cookie); } private AuthResult issueTokens(Long userId) { + // 만료된 토큰 μ‚­μ œ + refreshTokenRepository.deleteExpiredByUserId(userId, LocalDateTime.now()); + + String sessionId = UUID.randomUUID().toString(); + String accessToken = jwtTokenProvider.generateAccessToken(userId); - String refreshToken = jwtTokenProvider.generateRefreshToken(userId); + String refreshToken = jwtTokenProvider.generateRefreshToken(userId, sessionId); + String refreshTokenHash = refreshTokenHasher.hash(refreshToken); LocalDateTime expiresAt = jwtTokenProvider.getExpiration(refreshToken); - refreshTokenRepository.findByUserId(userId) - .ifPresentOrElse( - rt -> rt.rotate(refreshToken, expiresAt), - () -> refreshTokenRepository.save( - RefreshToken.createRefreshToken(userEntityRepository.getReferenceById(userId), refreshToken, - expiresAt) - ) - ); + refreshTokenRepository.save( + RefreshToken.createRefreshToken( + userEntityRepository.getReferenceById(userId), + sessionId, + refreshTokenHash, + expiresAt + ) + ); ResponseCookie cookie = cookieUtils.createRefreshTokenCookie(refreshToken); diff --git a/src/main/java/ditda/backend/domain/common/auth/service/RefreshTokenHasher.java b/src/main/java/ditda/backend/domain/common/auth/service/RefreshTokenHasher.java new file mode 100644 index 0000000..7a1a8cb --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/auth/service/RefreshTokenHasher.java @@ -0,0 +1,43 @@ +package ditda.backend.domain.common.auth.service; + +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class RefreshTokenHasher { + + private final SecretKeySpec secretKeySpec; + + public RefreshTokenHasher( + @Value("${jwt.refresh-token-hash-secret}") String secret + ) { + this.secretKeySpec = new SecretKeySpec( + secret.getBytes(StandardCharsets.UTF_8), + "HmacSHA256" + ); + } + + public String hash(String refreshToken) { + + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(secretKeySpec); + + byte[] digest = mac.doFinal(refreshToken.getBytes(StandardCharsets.UTF_8)); + + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(digest); + } catch (NoSuchAlgorithmException | InvalidKeyException exception) { + throw new IllegalStateException("Refresh token hash 생성 μ‹€νŒ¨", exception); + } + } +} diff --git a/src/main/java/ditda/backend/global/jwt/JwtTokenProvider.java b/src/main/java/ditda/backend/global/jwt/JwtTokenProvider.java index ee88295..823b743 100644 --- a/src/main/java/ditda/backend/global/jwt/JwtTokenProvider.java +++ b/src/main/java/ditda/backend/global/jwt/JwtTokenProvider.java @@ -36,17 +36,20 @@ public class JwtTokenProvider { private SecretKey key; + private static final String SESSION_ID_CLAIM = "sid"; private static final String TOKEN_TYPE_CLAIM = "type"; private static final String ACCESS_TOKEN_TYPE = "access_token"; private static final String REFRESH_TOKEN_TYPE = "refresh_token"; @PostConstruct public void init() { + byte[] keyBytes = Decoders.BASE64URL.decode(secret); key = Keys.hmacShaKeyFor(keyBytes); } public String generateAccessToken(Long userId) { + Date now = new Date(); Date expiryTime = new Date(now.getTime() + accessTokenExpiration); @@ -59,19 +62,32 @@ public String generateAccessToken(Long userId) { .compact(); } - public String generateRefreshToken(Long userId) { + public String generateRefreshToken(Long userId, String sessionId) { + Date now = new Date(); Date expiryTime = new Date(now.getTime() + refreshTokenExpiration); return Jwts.builder() .subject(userId.toString()) .claim(TOKEN_TYPE_CLAIM, REFRESH_TOKEN_TYPE) + .claim(SESSION_ID_CLAIM, sessionId) .issuedAt(now) .expiration(expiryTime) .signWith(key, Jwts.SIG.HS256) .compact(); } + public String getSessionId(Claims claims) { + + String sessionId = claims.get(SESSION_ID_CLAIM, String.class); + + if (sessionId == null || sessionId.isBlank()) { + throw new JwtException("Refresh Token에 μ„Έμ…˜ 정보가 μ—†μŠ΅λ‹ˆλ‹€."); + } + + return sessionId; + } + public Claims validateAccessToken(String token) { Claims claims = validateToken(token); @@ -93,6 +109,7 @@ public Claims validateRefreshToken(String token) { } public LocalDateTime getExpiration(String token) { + return getClaims(token).getExpiration() .toInstant() .atZone(ZoneId.systemDefault()) @@ -100,6 +117,7 @@ public LocalDateTime getExpiration(String token) { } private Claims getClaims(String token) { + return Jwts.parser() .verifyWith(key) .build() @@ -108,6 +126,7 @@ private Claims getClaims(String token) { } private Claims validateToken(String token) { + try { return getClaims(token); } catch (ExpiredJwtException e) { diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 095ee29..c7df01d 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -35,6 +35,7 @@ jwt: secret: ${JWT_SECRET_KEY} access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION} refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION} + refresh-token-hash-secret: ${JWT_REFRESH_TOKEN_HASH_SECRET} cookie-secure: ${JWT_COOKIE_SECURE} refresh-cookie-path: ${JWT_REFRESH_COOKIE_PATH} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index c0daf4f..c9c6993 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -42,6 +42,7 @@ jwt: secret: application-test-jwt-secret-key-only-for-ci access-token-expiration: 3600000 refresh-token-expiration: 86400000 + refresh-token-hash-secret: test-refresh-token-hash-secret cookie-secure: false refresh-cookie-path: /api/v1/auth From e3a309391b1e0c09a4943959f050cf9edbe56553 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Mon, 25 May 2026 00:22:38 +0900 Subject: [PATCH 47/90] =?UTF-8?q?Feat(auth):=20=EA=B0=95=EC=82=AC=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=8B=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EC=9D=B8=EC=A6=9D=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/auth/exception/AuthErrorCode.java | 4 ++- .../domain/common/user/entity/UserEntity.java | 4 ++- .../auth/facade/InstructorAuthFacade.java | 6 +++- .../auth/service/InstructorAuthService.java | 30 ++++++++++++------- 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/main/java/ditda/backend/domain/common/auth/exception/AuthErrorCode.java b/src/main/java/ditda/backend/domain/common/auth/exception/AuthErrorCode.java index dae6d8a..495ea40 100644 --- a/src/main/java/ditda/backend/domain/common/auth/exception/AuthErrorCode.java +++ b/src/main/java/ditda/backend/domain/common/auth/exception/AuthErrorCode.java @@ -15,7 +15,9 @@ public enum AuthErrorCode implements BaseErrorCode { EMAIL_CODE_EXPIRED(HttpStatus.BAD_REQUEST, "AUTH_400_01", "μΈμ¦λ²ˆν˜Έκ°€ λ§Œλ£Œλ˜μ—ˆκ±°λ‚˜ λ°œκΈ‰λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€."), EMAIL_CODE_INVALID(HttpStatus.BAD_REQUEST, "AUTH_400_02", - "μΈμ¦λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + "μΈμ¦λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."), + EMAIL_NOT_VERIFIED(HttpStatus.BAD_REQUEST, "AUTH_400_03", + "이메일 인증이 ν•„μš”ν•©λ‹ˆλ‹€."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java b/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java index ea4aec2..8e636c1 100644 --- a/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java +++ b/src/main/java/ditda/backend/domain/common/user/entity/UserEntity.java @@ -63,7 +63,8 @@ public static UserEntity createUser( String email, String profileImage, String phone, - UserRole role + UserRole role, + LocalDateTime emailVerifiedAt ) { return UserEntity.builder() .username(username) @@ -73,6 +74,7 @@ public static UserEntity createUser( .profileImage(profileImage) .phone(phone) .role(role) + .emailVerifiedAt(emailVerifiedAt) .build(); } } diff --git a/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java b/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java index 97c84f0..89b9e7e 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java @@ -2,10 +2,12 @@ import org.springframework.stereotype.Component; +import ditda.backend.domain.common.auth.exception.AuthErrorCode; import ditda.backend.domain.common.auth.service.EmailVerificationService; import ditda.backend.domain.instructor.auth.dto.InstructorAuthResult; import ditda.backend.domain.instructor.auth.dto.request.InstructorSignupRequest; import ditda.backend.domain.instructor.auth.service.InstructorAuthService; +import ditda.backend.global.apipayload.exception.GeneralException; import lombok.RequiredArgsConstructor; @Component @@ -17,7 +19,9 @@ public class InstructorAuthFacade { public InstructorAuthResult signup(InstructorSignupRequest request) { - emailVerificationService.isVerified(request.email()); + if (!emailVerificationService.isVerified(request.email())) { + throw new GeneralException(AuthErrorCode.EMAIL_NOT_VERIFIED); + } InstructorAuthResult result = instructorAuthService.signup(request); diff --git a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java index fd129e7..cbf8ffa 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java @@ -3,6 +3,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Set; +import java.util.UUID; import org.springframework.http.ResponseCookie; import org.springframework.security.crypto.password.PasswordEncoder; @@ -11,6 +12,7 @@ import ditda.backend.domain.common.auth.entity.RefreshToken; import ditda.backend.domain.common.auth.repository.RefreshTokenRepository; +import ditda.backend.domain.common.auth.service.RefreshTokenHasher; import ditda.backend.domain.common.term.entity.UserTerm; import ditda.backend.domain.common.term.entity.enums.TermType; import ditda.backend.domain.common.term.repository.UserTermRepository; @@ -43,6 +45,7 @@ public class InstructorAuthService { private final InstructorRepository instructorRepository; private final UserTermRepository userTermRepository; private final RefreshTokenRepository refreshTokenRepository; + private final RefreshTokenHasher refreshTokenHasher; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; private final CookieUtils cookieUtils; @@ -69,7 +72,8 @@ public InstructorAuthResult signup(InstructorSignupRequest request) { request.email(), DEFAULT_PROFILE_IMAGE, request.phone(), - UserRole.INSTRUCTOR + UserRole.INSTRUCTOR, + LocalDateTime.now() ); userEntityRepository.save(user); @@ -111,20 +115,24 @@ private void validateRequiredTerms(List ter private InstructorAuthResult issueTokens(Long userId) { + refreshTokenRepository.deleteExpiredByUserId(userId, LocalDateTime.now()); + + String sessionId = UUID.randomUUID().toString(); + String accessToken = jwtTokenProvider.generateAccessToken(userId); - String refreshToken = jwtTokenProvider.generateRefreshToken(userId); + String refreshToken = jwtTokenProvider.generateRefreshToken(userId, sessionId); + String refreshTokenHash = refreshTokenHasher.hash(refreshToken); LocalDateTime expiresAt = jwtTokenProvider.getExpiration(refreshToken); - refreshTokenRepository.findByUserId(userId) - .ifPresentOrElse( - rt -> rt.rotate((refreshToken), expiresAt), - () -> refreshTokenRepository.save( - RefreshToken.createRefreshToken( - userEntityRepository.getReferenceById(userId), - refreshToken, - expiresAt - ))); + refreshTokenRepository.save( + RefreshToken.createRefreshToken( + userEntityRepository.getReferenceById(userId), + sessionId, + refreshTokenHash, + expiresAt + ) + ); ResponseCookie cookie = cookieUtils.createRefreshTokenCookie(refreshToken); From d5e5512576ca2ba3b0e6ef6897cfb2402e08590c Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Mon, 25 May 2026 00:36:52 +0900 Subject: [PATCH 48/90] =?UTF-8?q?Docs(auth):=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/auth/service/AuthService.java | 19 +++++++++++++++---- .../auth/facade/InstructorAuthFacade.java | 1 + 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java b/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java index 38105ba..d5ebe58 100644 --- a/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java +++ b/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java @@ -36,13 +36,16 @@ public class AuthService { @Transactional public AuthResult login(LoginRequest request) { + // 1. μœ μ € 쑰회 UserEntity user = userEntityRepository.findByUsername(request.username()) .orElseThrow(() -> new GeneralException(GeneralErrorCode.INVALID_LOGIN)); + // 2. λΉ„λ°€λ²ˆν˜Έ 검증 if (!passwordEncoder.matches(request.password(), user.getPassword())) { throw new GeneralException(GeneralErrorCode.INVALID_LOGIN); } + // 3. Access / Refresh 토큰 λ°œκΈ‰ return issueTokens(user.getId()); } @@ -51,15 +54,17 @@ public ResponseCookie logout(Long userId, String refreshToken) { if (refreshToken != null && !refreshToken.isBlank()) { try { + // 1. JWT 검증 ν›„ Claimμ—μ„œ userId, sessionId μΆ”μΆœ Claims claims = jwtTokenProvider.validateRefreshToken(refreshToken); Long tokenUserId = Long.parseLong(claims.getSubject()); String sessionId = jwtTokenProvider.getSessionId(claims); + // 2. 인증된 μœ μ €μ™€ ν† ν°μ˜ μœ μ €κ°€ μΌμΉ˜ν•  λ•Œλ§Œ μ‚­μ œ if (userId.equals(tokenUserId)) { refreshTokenRepository.deleteBySessionId(sessionId); } } catch (JwtException | IllegalArgumentException exception) { - // μΏ ν‚€ μ‚­μ œλŠ” μ§„ν–‰. + // 토큰이 만료 λ˜λŠ” μœ„μ‘°μ—¬λ„ μΏ ν‚€λŠ” 제거 } } @@ -69,10 +74,12 @@ public ResponseCookie logout(Long userId, String refreshToken) { @Transactional public AuthResult reissue(String refreshToken) { + // 1. μΏ ν‚€ 쑴재 μ—¬λΆ€ 확인 if (refreshToken == null || refreshToken.isBlank()) { throw new GeneralException(GeneralErrorCode.INVALID_TOKEN); } + // 2. JWT μ„œλͺ…/만료 검증 ν›„ userId, sessionId μΆ”μΆœ Long userId; String sessionId; try { @@ -83,15 +90,18 @@ public AuthResult reissue(String refreshToken) { throw new GeneralException(GeneralErrorCode.INVALID_TOKEN); } + // 3. sessionId둜 DB의 토큰 쑰회 RefreshToken stored = refreshTokenRepository.findBySessionId(sessionId) .orElseThrow(() -> new GeneralException(GeneralErrorCode.INVALID_TOKEN)); + // 4. μ†Œμœ μž 일치 + ν•΄μ‹œ 일치 검증 String refreshTokenHash = refreshTokenHasher.hash(refreshToken); if (!stored.belongsTo(userId) || !stored.matchesHash(refreshTokenHash)) { throw new GeneralException(GeneralErrorCode.INVALID_TOKEN); } + // 5. μƒˆ Access / Refresh 토큰 λ°œκΈ‰. (sessionIdλŠ” μœ μ§€) String newAccessToken = jwtTokenProvider.generateAccessToken(userId); String newRefreshToken = jwtTokenProvider.generateRefreshToken(userId, sessionId); String newRefreshTokenHash = refreshTokenHasher.hash(newRefreshToken); @@ -106,15 +116,16 @@ public AuthResult reissue(String refreshToken) { private AuthResult issueTokens(Long userId) { - // 만료된 토큰 μ‚­μ œ + // 1. 만료된 토큰 μ‚­μ œ refreshTokenRepository.deleteExpiredByUserId(userId, LocalDateTime.now()); + // 2. sessionId(둜그인 κΈ°κΈ° 식별) 및 JWT 토큰 λ°œκΈ‰ String sessionId = UUID.randomUUID().toString(); - String accessToken = jwtTokenProvider.generateAccessToken(userId); String refreshToken = jwtTokenProvider.generateRefreshToken(userId, sessionId); - String refreshTokenHash = refreshTokenHasher.hash(refreshToken); + // 3. DBμ—λŠ” ν•΄μ‹œ κ°’μœΌλ‘œ μ €μž₯ + String refreshTokenHash = refreshTokenHasher.hash(refreshToken); LocalDateTime expiresAt = jwtTokenProvider.getExpiration(refreshToken); refreshTokenRepository.save( diff --git a/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java b/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java index 89b9e7e..1c341de 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java @@ -19,6 +19,7 @@ public class InstructorAuthFacade { public InstructorAuthResult signup(InstructorSignupRequest request) { + // 이메일 검증 μ—¬λΆ€ 확인 if (!emailVerificationService.isVerified(request.email())) { throw new GeneralException(AuthErrorCode.EMAIL_NOT_VERIFIED); } From a0071ff1c594b1193b477857699b18db0bdb7b96 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Mon, 25 May 2026 00:39:25 +0900 Subject: [PATCH 49/90] =?UTF-8?q?Docs:=20.env.example=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 3babc2b..96078ef 100644 --- a/.env.example +++ b/.env.example @@ -21,10 +21,10 @@ S3_SECRET_KEY=your_secret_key S3_BUCKET=your_bucket_name # JWT -JWT_SECRET_KEY=2JEY7VPtpzgzWt_FaOtY0sXatcXetnAwX29xq2gcexI +JWT_SECRET_KEY=your_jwt_secret_key_here_base64url_encoded JWT_ACCESS_TOKEN_EXPIRATION=3600000 JWT_REFRESH_TOKEN_EXPIRATION=1209600000 -JWT_REFRESH_TOKEN_HASH_SECRET=o_CIMj9rb4U4R7aY_gHnEefufnl0DojcrAP1DxArzQciMyafSkPbb0E2oXMeUTmJ +JWT_REFRESH_TOKEN_HASH_SECRET=your_refresh_token_hash_secret_here JWT_COOKIE_SECURE=false JWT_REFRESH_COOKIE_PATH=/api/v1/auth From c76d69fc6fbf7ec288c03c76be144a4230160d98 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Mon, 25 May 2026 13:25:26 +0900 Subject: [PATCH 50/90] =?UTF-8?q?Refactor(auth):=20refresh=20token=20paylo?= =?UTF-8?q?ad=20=EC=B6=94=EC=B6=9C=20=EC=B1=85=EC=9E=84=EC=9D=84=20JwtToke?= =?UTF-8?q?nProvider=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/auth/service/AuthService.java | 23 ++++++++----------- .../backend/global/jwt/JwtTokenProvider.java | 11 +++++++++ .../global/jwt/dto/RefreshTokenPayload.java | 9 ++++++++ 3 files changed, 29 insertions(+), 14 deletions(-) create mode 100644 src/main/java/ditda/backend/global/jwt/dto/RefreshTokenPayload.java diff --git a/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java b/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java index d5ebe58..7b8052d 100644 --- a/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java +++ b/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java @@ -17,8 +17,8 @@ import ditda.backend.global.apipayload.code.GeneralErrorCode; import ditda.backend.global.apipayload.exception.GeneralException; import ditda.backend.global.jwt.JwtTokenProvider; +import ditda.backend.global.jwt.dto.RefreshTokenPayload; import ditda.backend.global.jwt.utils.CookieUtils; -import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; import lombok.RequiredArgsConstructor; @@ -50,19 +50,14 @@ public AuthResult login(LoginRequest request) { } @Transactional - public ResponseCookie logout(Long userId, String refreshToken) { + public ResponseCookie logout(String refreshToken) { if (refreshToken != null && !refreshToken.isBlank()) { try { - // 1. JWT 검증 ν›„ Claimμ—μ„œ userId, sessionId μΆ”μΆœ - Claims claims = jwtTokenProvider.validateRefreshToken(refreshToken); - Long tokenUserId = Long.parseLong(claims.getSubject()); - String sessionId = jwtTokenProvider.getSessionId(claims); - - // 2. 인증된 μœ μ €μ™€ ν† ν°μ˜ μœ μ €κ°€ μΌμΉ˜ν•  λ•Œλ§Œ μ‚­μ œ - if (userId.equals(tokenUserId)) { - refreshTokenRepository.deleteBySessionId(sessionId); - } + // 1. JWT 토큰 payload + RefreshTokenPayload payload = jwtTokenProvider.getRefreshTokenPayload(refreshToken); + + refreshTokenRepository.deleteBySessionId(payload.sessionId()); } catch (JwtException | IllegalArgumentException exception) { // 토큰이 만료 λ˜λŠ” μœ„μ‘°μ—¬λ„ μΏ ν‚€λŠ” 제거 } @@ -83,9 +78,9 @@ public AuthResult reissue(String refreshToken) { Long userId; String sessionId; try { - Claims claims = jwtTokenProvider.validateRefreshToken(refreshToken); - userId = Long.parseLong(claims.getSubject()); - sessionId = jwtTokenProvider.getSessionId(claims); + RefreshTokenPayload payload = jwtTokenProvider.getRefreshTokenPayload(refreshToken); + userId = payload.userId(); + sessionId = payload.sessionId(); } catch (JwtException | IllegalArgumentException exception) { throw new GeneralException(GeneralErrorCode.INVALID_TOKEN); } diff --git a/src/main/java/ditda/backend/global/jwt/JwtTokenProvider.java b/src/main/java/ditda/backend/global/jwt/JwtTokenProvider.java index 823b743..183d1b3 100644 --- a/src/main/java/ditda/backend/global/jwt/JwtTokenProvider.java +++ b/src/main/java/ditda/backend/global/jwt/JwtTokenProvider.java @@ -9,6 +9,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import ditda.backend.global.jwt.dto.RefreshTokenPayload; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.JwtException; @@ -125,6 +126,16 @@ private Claims getClaims(String token) { .getPayload(); } + public RefreshTokenPayload getRefreshTokenPayload(String token) { + + Claims claims = validateRefreshToken(token); + + Long userId = Long.parseLong(claims.getSubject()); + String sessionId = getSessionId(claims); + + return new RefreshTokenPayload(userId, sessionId); + } + private Claims validateToken(String token) { try { diff --git a/src/main/java/ditda/backend/global/jwt/dto/RefreshTokenPayload.java b/src/main/java/ditda/backend/global/jwt/dto/RefreshTokenPayload.java new file mode 100644 index 0000000..70648cb --- /dev/null +++ b/src/main/java/ditda/backend/global/jwt/dto/RefreshTokenPayload.java @@ -0,0 +1,9 @@ +package ditda.backend.global.jwt.dto; + +public record RefreshTokenPayload( + + Long userId, + + String sessionId +) { +} From 83a5d64e4a357f8181cb7cc9be23b5ecd4b47f6a Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Mon, 25 May 2026 13:25:50 +0900 Subject: [PATCH 51/90] =?UTF-8?q?Refactor(auth):=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83=EC=9D=84=20Refresh=20Token=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/common/auth/controller/AuthController.java | 11 +++++------ .../backend/domain/common/auth/facade/AuthFacade.java | 4 ++-- .../ditda/backend/global/config/SecurityConfig.java | 3 ++- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java b/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java index f4eaf97..c56930a 100644 --- a/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java +++ b/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java @@ -2,7 +2,6 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -35,7 +34,7 @@ public class AuthController { private final AuthFacade authFacade; - @Operation(summary = "이메일 인증번호 μš”μ²­", description = "이메일 인증번호λ₯Ό μš”μ²­ν•©λ‹ˆλ‹€.") + @Operation(summary = "이메일 인증번호 μš”μ²­", description = "**[νšŒμ›κ°€μž…]** 이메일 인증번호λ₯Ό μš”μ²­ν•©λ‹ˆλ‹€.") @PostMapping("/emails/verification-requests") public ApiResponse requestEmailVerification( @Valid @RequestBody EmailVerificationRequest request @@ -45,7 +44,7 @@ public ApiResponse requestEmailVerification( return ApiResponse.onSuccess("μΈμ¦λ²ˆν˜Έκ°€ λ°œμ†‘λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); } - @Operation(summary = "이메일 인증번호 검증", description = "이메일 인증번호λ₯Ό κ²€μ¦ν•©λ‹ˆλ‹€.") + @Operation(summary = "이메일 인증번호 검증", description = "**[νšŒμ›κ°€μž…]** 이메일 인증번호λ₯Ό κ²€μ¦ν•©λ‹ˆλ‹€.") @PostMapping("/emails/verifications") public ApiResponse verifyEmailCode( @Valid @RequestBody EmailCodeVerificationRequest request @@ -68,15 +67,15 @@ public ApiResponse login( new LoginResponse(result.userId(), result.accessToken())); } - @Operation(summary = "λ‘œκ·Έμ•„μ›ƒ", description = "**[곡톡]** 인증된 μ‚¬μš©μžμ˜ 토큰을 λ¬΄νš¨ν™”ν•©λ‹ˆλ‹€.") + @Operation(summary = "λ‘œκ·Έμ•„μ›ƒ", description = "**[곡톡]** ν˜„μž¬ μ„Έμ…˜μ˜ refresh token을 λ¬΄νš¨ν™”ν•©λ‹ˆλ‹€.") @PostMapping("/logout") public ApiResponse logout( - @AuthenticationPrincipal Long userId, + @Parameter(hidden = true) @CookieValue(value = CookieUtils.REFRESH_TOKEN_COOKIE, required = false) String refreshToken, HttpServletResponse response ) { - ResponseCookie deleted = authFacade.logout(userId, refreshToken); + ResponseCookie deleted = authFacade.logout(refreshToken); response.addHeader(HttpHeaders.SET_COOKIE, deleted.toString()); return ApiResponse.onSuccess("λ‘œκ·Έμ•„μ›ƒ 성곡"); diff --git a/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java b/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java index 830b934..923f05c 100644 --- a/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java +++ b/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java @@ -35,8 +35,8 @@ public AuthResult login(LoginRequest request) { } // μœ μ € λ‘œκ·Έμ•„μ›ƒ - public ResponseCookie logout(Long userId, String refreshToken) { - return authService.logout(userId, refreshToken); + public ResponseCookie logout(String refreshToken) { + return authService.logout(refreshToken); } // 토큰 μž¬λ°œκΈ‰ diff --git a/src/main/java/ditda/backend/global/config/SecurityConfig.java b/src/main/java/ditda/backend/global/config/SecurityConfig.java index 32dc7f1..173a4ee 100644 --- a/src/main/java/ditda/backend/global/config/SecurityConfig.java +++ b/src/main/java/ditda/backend/global/config/SecurityConfig.java @@ -63,7 +63,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/v1/auth/emails/verification-requests", "/api/v1/auth/emails/verifications", "/api/v1/auth/login", - "/api/v1/auth/reissue" + "/api/v1/auth/reissue", + "/api/v1/auth/logout" ).permitAll() .anyRequest().authenticated() ) From 3fc2d8f121005e3ce108d08fc75a23fa47146795 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Mon, 25 May 2026 13:26:13 +0900 Subject: [PATCH 52/90] =?UTF-8?q?Refactor(auth):=20=EC=9D=B4=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EA=B2=80=EC=A6=9D=20=EC=97=AC=EB=B6=80=20=EB=A1=A4?= =?UTF-8?q?=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../instructor/auth/facade/InstructorAuthFacade.java | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java b/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java index 1c341de..97c84f0 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java @@ -2,12 +2,10 @@ import org.springframework.stereotype.Component; -import ditda.backend.domain.common.auth.exception.AuthErrorCode; import ditda.backend.domain.common.auth.service.EmailVerificationService; import ditda.backend.domain.instructor.auth.dto.InstructorAuthResult; import ditda.backend.domain.instructor.auth.dto.request.InstructorSignupRequest; import ditda.backend.domain.instructor.auth.service.InstructorAuthService; -import ditda.backend.global.apipayload.exception.GeneralException; import lombok.RequiredArgsConstructor; @Component @@ -19,10 +17,7 @@ public class InstructorAuthFacade { public InstructorAuthResult signup(InstructorSignupRequest request) { - // 이메일 검증 μ—¬λΆ€ 확인 - if (!emailVerificationService.isVerified(request.email())) { - throw new GeneralException(AuthErrorCode.EMAIL_NOT_VERIFIED); - } + emailVerificationService.isVerified(request.email()); InstructorAuthResult result = instructorAuthService.signup(request); From c80d2f2e97c747007c37e595e9b156e217ac2725 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Mon, 25 May 2026 13:38:28 +0900 Subject: [PATCH 53/90] =?UTF-8?q?Rename(jwt):=20hash=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=9D=B4=EB=8F=99=20(common=20->=20glo?= =?UTF-8?q?bal)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/instructor/auth/service/InstructorAuthService.java | 2 +- .../common/auth/service => global/hash}/RefreshTokenHasher.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/main/java/ditda/backend/{domain/common/auth/service => global/hash}/RefreshTokenHasher.java (95%) diff --git a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java index cbf8ffa..348305e 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java @@ -12,7 +12,7 @@ import ditda.backend.domain.common.auth.entity.RefreshToken; import ditda.backend.domain.common.auth.repository.RefreshTokenRepository; -import ditda.backend.domain.common.auth.service.RefreshTokenHasher; +import ditda.backend.global.hash.RefreshTokenHasher; import ditda.backend.domain.common.term.entity.UserTerm; import ditda.backend.domain.common.term.entity.enums.TermType; import ditda.backend.domain.common.term.repository.UserTermRepository; diff --git a/src/main/java/ditda/backend/domain/common/auth/service/RefreshTokenHasher.java b/src/main/java/ditda/backend/global/hash/RefreshTokenHasher.java similarity index 95% rename from src/main/java/ditda/backend/domain/common/auth/service/RefreshTokenHasher.java rename to src/main/java/ditda/backend/global/hash/RefreshTokenHasher.java index 7a1a8cb..bc204f3 100644 --- a/src/main/java/ditda/backend/domain/common/auth/service/RefreshTokenHasher.java +++ b/src/main/java/ditda/backend/global/hash/RefreshTokenHasher.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.common.auth.service; +package ditda.backend.global.hash; import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; From 95a266dde78e0a255271d7f6bab050e0265baea3 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Mon, 25 May 2026 13:42:36 +0900 Subject: [PATCH 54/90] =?UTF-8?q?Feat(auth):=20Refresh=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=8A=A4=EC=BC=80=EC=A5=B4=EB=9F=AC=EB=A5=BC=20?= =?UTF-8?q?=ED=86=B5=ED=95=B4=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ditda/backend/BackendApplication.java | 2 ++ .../repository/RefreshTokenRepository.java | 4 +++ .../auth/scheduler/RefreshTokenScheduler.java | 25 +++++++++++++++++++ .../common/auth/service/AuthService.java | 5 ++++ 4 files changed, 36 insertions(+) create mode 100644 src/main/java/ditda/backend/domain/common/auth/scheduler/RefreshTokenScheduler.java diff --git a/src/main/java/ditda/backend/BackendApplication.java b/src/main/java/ditda/backend/BackendApplication.java index 303201f..b8f5e52 100644 --- a/src/main/java/ditda/backend/BackendApplication.java +++ b/src/main/java/ditda/backend/BackendApplication.java @@ -4,7 +4,9 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @EnableAsync @EnableJpaAuditing @SpringBootApplication diff --git a/src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java b/src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java index b192be4..fa037ed 100644 --- a/src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java +++ b/src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java @@ -19,4 +19,8 @@ public interface RefreshTokenRepository extends JpaRepository Date: Mon, 25 May 2026 13:47:29 +0900 Subject: [PATCH 55/90] =?UTF-8?q?Chore(auth):=20Checkstyle=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20(import?= =?UTF-8?q?=20=EB=B0=8F=20=EA=B3=B5=EB=B0=B1=20=EC=B6=94=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ditda/backend/domain/common/auth/service/AuthService.java | 1 + .../domain/instructor/auth/service/InstructorAuthService.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java b/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java index 249a3a9..62f26e6 100644 --- a/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java +++ b/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java @@ -109,6 +109,7 @@ public AuthResult reissue(String refreshToken) { return new AuthResult(userId, newAccessToken, cookie); } + @Transactional public int deleteExpiredRefreshTokens() { return refreshTokenRepository.deleteExpired(LocalDateTime.now()); diff --git a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java index 348305e..c761895 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java @@ -12,7 +12,6 @@ import ditda.backend.domain.common.auth.entity.RefreshToken; import ditda.backend.domain.common.auth.repository.RefreshTokenRepository; -import ditda.backend.global.hash.RefreshTokenHasher; import ditda.backend.domain.common.term.entity.UserTerm; import ditda.backend.domain.common.term.entity.enums.TermType; import ditda.backend.domain.common.term.repository.UserTermRepository; @@ -25,6 +24,7 @@ import ditda.backend.domain.instructor.auth.entity.Instructor; import ditda.backend.domain.instructor.auth.repository.InstructorRepository; import ditda.backend.global.apipayload.exception.GeneralException; +import ditda.backend.global.hash.RefreshTokenHasher; import ditda.backend.global.jwt.JwtTokenProvider; import ditda.backend.global.jwt.utils.CookieUtils; import lombok.RequiredArgsConstructor; From 2b65d6cbeb8a5e32d6002ef624122f8c0853592e Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Mon, 25 May 2026 13:56:46 +0900 Subject: [PATCH 56/90] =?UTF-8?q?Fix(auth):=20Refresh=20=EB=A7=8C=EB=A3=8C?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=20=EC=82=AD=EC=A0=9C=20=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/common/auth/repository/RefreshTokenRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java b/src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java index fa037ed..faa28db 100644 --- a/src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java +++ b/src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java @@ -21,6 +21,6 @@ public interface RefreshTokenRepository extends JpaRepository Date: Mon, 25 May 2026 13:57:32 +0900 Subject: [PATCH 57/90] =?UTF-8?q?Refactor(auth):=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83=20sessionId=20=EA=B8=B0=EB=B0=98=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20->=20sessionId=20+=20=ED=95=B4=EC=8B=9C=20=EA=B0=92?= =?UTF-8?q?=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/common/auth/service/AuthService.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java b/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java index 62f26e6..99d2a09 100644 --- a/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java +++ b/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java @@ -55,10 +55,16 @@ public ResponseCookie logout(String refreshToken) { if (refreshToken != null && !refreshToken.isBlank()) { try { - // 1. JWT 토큰 payload + // 1. Refresh Token 검증 ν›„ payload μΆ”μΆœ RefreshTokenPayload payload = jwtTokenProvider.getRefreshTokenPayload(refreshToken); - refreshTokenRepository.deleteBySessionId(payload.sessionId()); + // 2. 전달받은 Refresh Token을 μ €μž₯ ν˜•μ‹κ³Ό λ™μΌν•˜κ²Œ ν•΄μ‹± + String refreshTokenHash = refreshTokenHasher.hash(refreshToken); + + // 3. sessionId둜 μ €μž₯된 토큰을 μ‘°νšŒν•˜κ³ , ν•΄μ‹œκ°€ μΌμΉ˜ν•˜λŠ” κ²½μš°μ—λ§Œ μ‚­μ œ + refreshTokenRepository.findBySessionId(payload.sessionId()) + .filter(stored -> stored.matchesHash(refreshTokenHash)) + .ifPresent(refreshTokenRepository::delete); } catch (JwtException | IllegalArgumentException exception) { // 토큰이 만료 λ˜λŠ” μœ„μ‘°μ—¬λ„ μΏ ν‚€λŠ” 제거 } From b4f5e8207d3ef20b9df19b41c7be25873a7fd835 Mon Sep 17 00:00:00 2001 From: fervovita Date: Mon, 25 May 2026 17:13:17 +0900 Subject: [PATCH 58/90] =?UTF-8?q?Feat(instructor):=20=EA=B0=95=EC=82=AC=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20API=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../instructor/auth/controller/InstructorAuthController.java | 2 +- src/main/java/ditda/backend/global/config/SecurityConfig.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java b/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java index 231209a..b70e015 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java @@ -18,7 +18,7 @@ import lombok.RequiredArgsConstructor; @RestController -@RequestMapping("/api/v1/instructors") +@RequestMapping("/api/v1/instructors/auth") @RequiredArgsConstructor @Tag(name = "Instructor Auth", description = "강사 νšŒμ›κ°€μž… API") public class InstructorAuthController { diff --git a/src/main/java/ditda/backend/global/config/SecurityConfig.java b/src/main/java/ditda/backend/global/config/SecurityConfig.java index fc41f71..cf286a6 100644 --- a/src/main/java/ditda/backend/global/config/SecurityConfig.java +++ b/src/main/java/ditda/backend/global/config/SecurityConfig.java @@ -58,7 +58,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(auth -> auth .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() .requestMatchers(HttpMethod.POST, "/api/v1/auth/**").permitAll() - .requestMatchers(HttpMethod.POST, "/api/v1/instructors/signup").permitAll() + .requestMatchers(HttpMethod.POST, "/api/v1/instructors/auth/signup").permitAll() .requestMatchers(HttpMethod.POST, "/api/v1/designers/auth/signup").permitAll() .anyRequest().authenticated() ) From bef7653329920930f4678f39d0e3bfb2be64cfdc Mon Sep 17 00:00:00 2001 From: fervovita Date: Mon, 25 May 2026 19:58:25 +0900 Subject: [PATCH 59/90] =?UTF-8?q?Feat(designer):=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B4=EB=84=88=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EC=96=B4=EB=93=9C=EB=AF=BC=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 ++- .../ditda/backend/BackendApplication.java | 2 + .../auth/event/DesignerSignedUpEvent.java | 11 ++++ .../notification/DesignerSignupNotifier.java | 45 +++++++++++++++++ .../auth/service/DesignerAuthService.java | 11 ++++ .../global/config/AdminProperties.java | 16 ++++++ .../backend/global/email/EmailSender.java | 46 ++++++++++++++++- .../ditda/backend/global/s3/S3Properties.java | 18 +++++++ .../backend/global/s3/S3UrlResolver.java | 19 +++++++ src/main/resources/application-local.yaml | 4 -- src/main/resources/application.yaml | 8 +++ src/main/resources/email-images/logo.png | Bin 0 -> 12521 bytes .../email/designer-signup-notification.html | 47 ++++++++++++++++++ src/test/resources/application-test.yaml | 4 ++ 14 files changed, 231 insertions(+), 6 deletions(-) create mode 100644 src/main/java/ditda/backend/domain/designer/auth/event/DesignerSignedUpEvent.java create mode 100644 src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupNotifier.java create mode 100644 src/main/java/ditda/backend/global/config/AdminProperties.java create mode 100644 src/main/java/ditda/backend/global/s3/S3Properties.java create mode 100644 src/main/java/ditda/backend/global/s3/S3UrlResolver.java create mode 100644 src/main/resources/email-images/logo.png create mode 100644 src/main/resources/templates/email/designer-signup-notification.html diff --git a/.env.example b/.env.example index f40b89f..1870b63 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,7 @@ S3_REGION=ap-northeast-2 S3_ACCESS_KEY=your_access_key S3_SECRET_KEY=your_secret_key S3_BUCKET=your_bucket_name +S3_PUBLIC_BASE_URL=http://localhost:9000/your_bucket_name # JWT JWT_SECRET_KEY=your_jwt_secret_key_here_base64url_encoded @@ -36,4 +37,7 @@ MAIL_PASSWORD=your_app_password # Encryption ENCRYPT_SECRET_KEY=your_encrypt_secret_key -ENCRYPT_HASH_KEY=your_encrypt_hash_key \ No newline at end of file +ENCRYPT_HASH_KEY=your_encrypt_hash_key + +# Admin +ADMIN_NOTIFICATION_EMAIL=admin@example.com \ No newline at end of file diff --git a/src/main/java/ditda/backend/BackendApplication.java b/src/main/java/ditda/backend/BackendApplication.java index b8f5e52..8232421 100644 --- a/src/main/java/ditda/backend/BackendApplication.java +++ b/src/main/java/ditda/backend/BackendApplication.java @@ -2,6 +2,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; @@ -9,6 +10,7 @@ @EnableScheduling @EnableAsync @EnableJpaAuditing +@ConfigurationPropertiesScan @SpringBootApplication public class BackendApplication { diff --git a/src/main/java/ditda/backend/domain/designer/auth/event/DesignerSignedUpEvent.java b/src/main/java/ditda/backend/domain/designer/auth/event/DesignerSignedUpEvent.java new file mode 100644 index 0000000..0874f19 --- /dev/null +++ b/src/main/java/ditda/backend/domain/designer/auth/event/DesignerSignedUpEvent.java @@ -0,0 +1,11 @@ +package ditda.backend.domain.designer.auth.event; + +import java.util.List; + +public record DesignerSignedUpEvent( + Long userId, + String name, + String email, + List portfolioKeys +) { +} diff --git a/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupNotifier.java b/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupNotifier.java new file mode 100644 index 0000000..007481c --- /dev/null +++ b/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupNotifier.java @@ -0,0 +1,45 @@ +package ditda.backend.domain.designer.auth.notification; + +import java.util.List; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import ditda.backend.domain.designer.auth.event.DesignerSignedUpEvent; +import ditda.backend.global.config.AdminProperties; +import ditda.backend.global.email.EmailSender; +import ditda.backend.global.s3.S3UrlResolver; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DesignerSignupNotifier { + + private final EmailSender emailSender; + private final AdminProperties adminProperties; + private final S3UrlResolver s3UrlResolver; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onDesignerSignupNotification(DesignerSignedUpEvent event) { + + try { + List portfolioUrls = event.portfolioKeys().stream() + .map(s3UrlResolver::toPublicS3Url) + .toList(); + + emailSender.sendDesignerSignupNotification( + adminProperties.getNotificationEmail(), + event.userId(), + event.name(), + event.email(), + portfolioUrls + ); + } catch (Exception e) { + log.error("Failed to dispatch designer signup notification. userId={}", event.userId(), e); + // TODO : λ””μŠ€μ½”λ“œ μ›Ήν›… + } + } +} diff --git a/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java b/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java index 9941ff7..a26d0fe 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java +++ b/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java @@ -3,6 +3,7 @@ import java.time.LocalDateTime; import java.util.List; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +18,7 @@ import ditda.backend.domain.designer.auth.dto.DesignerAuthResult; import ditda.backend.domain.designer.auth.dto.request.DesignerSignupRequest; import ditda.backend.domain.designer.auth.entity.Designer; +import ditda.backend.domain.designer.auth.event.DesignerSignedUpEvent; import ditda.backend.domain.designer.auth.repository.DesignerRepository; import lombok.RequiredArgsConstructor; @@ -32,6 +34,7 @@ public class DesignerAuthService { private final AuthService authService; private final PortfolioService portfolioService; private final PasswordEncoder passwordEncoder; + private final ApplicationEventPublisher eventPublisher; @Transactional public DesignerAuthResult signup(DesignerSignupRequest request, List portfolioKeys) { @@ -69,6 +72,14 @@ public DesignerAuthResult signup(DesignerSignupRequest request, List por // accessToken&refreshToken λ°œκΈ‰ AuthResult tokens = authService.issueTokens(user.getId()); + // κ΄€λ¦¬μž μ•Œλ¦Ό 이벀트 λ°œν–‰ + eventPublisher.publishEvent(new DesignerSignedUpEvent( + user.getId(), + user.getName(), + user.getEmail(), + portfolioKeys + )); + return new DesignerAuthResult(user.getId(), tokens.accessToken(), tokens.refreshTokenCookie()); } diff --git a/src/main/java/ditda/backend/global/config/AdminProperties.java b/src/main/java/ditda/backend/global/config/AdminProperties.java new file mode 100644 index 0000000..3acf2dc --- /dev/null +++ b/src/main/java/ditda/backend/global/config/AdminProperties.java @@ -0,0 +1,16 @@ +package ditda.backend.global.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import lombok.Getter; +import lombok.Setter; + +@Component +@Getter +@Setter +@ConfigurationProperties(prefix = "admin") +public class AdminProperties { + + private String notificationEmail; +} diff --git a/src/main/java/ditda/backend/global/email/EmailSender.java b/src/main/java/ditda/backend/global/email/EmailSender.java index 314ae1f..571ea11 100644 --- a/src/main/java/ditda/backend/global/email/EmailSender.java +++ b/src/main/java/ditda/backend/global/email/EmailSender.java @@ -1,5 +1,8 @@ package ditda.backend.global.email; +import java.util.List; + +import org.springframework.core.io.ClassPathResource; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.scheduling.annotation.Async; @@ -16,9 +19,15 @@ @Slf4j public class EmailSender { - private static final String VERIFICATION_SUBJECT = "[Ditda] 이메일 인증 μ½”λ“œ"; + private static final String VERIFICATION_SUBJECT = "[DITDA] 이메일 인증 μ½”λ“œ"; private static final String VERIFICATION_TEMPLATE = "email/verificationCode"; + private static final String DESIGNER_SIGNUP_SUBJECT = "[DITDA] μƒˆ λ””μžμ΄λ„ˆ κ°€μž… κ²€ν†  μš”μ²­"; + private static final String DESIGNER_SIGNUP_TEMPLATE = "email/designer-signup-notification"; + + private static final String LOGO_CID = "logoImage"; + private static final String LOGO_PATH = "email-images/logo.png"; + private final JavaMailSender mailSender; private final SpringTemplateEngine templateEngine; @@ -40,9 +49,44 @@ public void sendVerificationEmail(String email, String code) { } } + @Async + public void sendDesignerSignupNotification( + String toEmail, + Long userId, + String designerName, + String designerEmail, + List portfolioUrls + ) { + try { + MimeMessage mimeMessage = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); + + helper.setTo(toEmail); + helper.setSubject(DESIGNER_SIGNUP_SUBJECT); + helper.setText(buildDesignerSignupHtml(userId, designerName, designerEmail, portfolioUrls), true); + + helper.addInline(LOGO_CID, new ClassPathResource(LOGO_PATH)); + + mailSender.send(mimeMessage); + + log.info("Designer signup notification email sent. designerEmail={}", designerEmail); + } catch (Exception e) { + log.error("Designer signup notification email send failed. designerEmail={}", designerEmail, e); + } + } + private String buildHtml(String code) { Context context = new Context(); context.setVariable("code", code); return templateEngine.process(VERIFICATION_TEMPLATE, context); } + + private String buildDesignerSignupHtml(Long userId, String name, String email, List portfolioUrls) { + Context context = new Context(); + context.setVariable("userId", userId); + context.setVariable("name", name); + context.setVariable("email", email); + context.setVariable("portfolioUrls", portfolioUrls); + return templateEngine.process(DESIGNER_SIGNUP_TEMPLATE, context); + } } diff --git a/src/main/java/ditda/backend/global/s3/S3Properties.java b/src/main/java/ditda/backend/global/s3/S3Properties.java new file mode 100644 index 0000000..fc1859d --- /dev/null +++ b/src/main/java/ditda/backend/global/s3/S3Properties.java @@ -0,0 +1,18 @@ +package ditda.backend.global.s3; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import lombok.Getter; +import lombok.Setter; + +@Component +@Getter +@Setter +@ConfigurationProperties(prefix = "app.s3") +public class S3Properties { + + private String bucket; + + private String publicBaseUrl; +} diff --git a/src/main/java/ditda/backend/global/s3/S3UrlResolver.java b/src/main/java/ditda/backend/global/s3/S3UrlResolver.java new file mode 100644 index 0000000..b4f9ed9 --- /dev/null +++ b/src/main/java/ditda/backend/global/s3/S3UrlResolver.java @@ -0,0 +1,19 @@ +package ditda.backend.global.s3; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class S3UrlResolver { + + private final S3Properties s3Properties; + + public String toPublicS3Url(String key) { + String baseUrl = s3Properties.getPublicBaseUrl(); + String trimmedBase = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + String trimmedKey = key.startsWith("/") ? key.substring(1) : key; + return trimmedBase + "/" + trimmedKey; + } +} diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 669eaf0..de9b2cf 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -20,10 +20,6 @@ spring: endpoint: ${S3_ENDPOINT} path-style-access-enabled: true -app: - s3: - bucket: ${S3_BUCKET} - swagger: server-url: http://localhost:8080 server-description: Local Server diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index fe5e818..15381a1 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -49,6 +49,14 @@ encrypt: secret-key: ${ENCRYPT_SECRET_KEY} hash-key: ${ENCRYPT_HASH_KEY} +admin: + notification-email: ${ADMIN_NOTIFICATION_EMAIL} + +app: + s3: + bucket: ${S3_BUCKET} + public-base-url: ${S3_PUBLIC_BASE_URL} + springdoc: swagger-ui: path: /swagger-ui.html diff --git a/src/main/resources/email-images/logo.png b/src/main/resources/email-images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..355f7f9ce6a6a92b62e341a04aeb44cba5de0178 GIT binary patch literal 12521 zcmc&))k9lNus=X4?h>Sw;%+TYf#NR3-KCV`?!~>h6fN%VPH}gF6)1&b!68_Xn|=@X z;r;{nA=#Yl?9S}W+1Z)-ZKSHQ3>G>WIsgDza%40 z=P!s=Oz=<+9Ifxra8-a`T)k*f3?JSj3=+H<_WVlVBDRvcAJImtG(iF}w#+f;D7T=x zYI(9cd3?@S`HbRTq~ZRWy$M%W%bfIk=BKOzEO&pF|thq)p+ec!r3M0kuj6 zxEA|WEjKyyFEH2=vvIN5xlG&y1pr{EkPqSZcBIsoQ86iHHYRX60jf%*@1Yd|75C0} zk&-_b?58?=Sl~09!AVuQo}_AgN^pXC>N~j$Qv3lx)gr`=aAWd2^zTP@dSMm^d6j)* z6yV1)Duj{SE}+h=Ckd65OZ_iK%9cF^8UW-=>&WD3R>F9P%DvO-E+`h=03a&5_M6$I zl^C_O8wK#yjHa@nHr0xE*SxyiL)W&O2+l(m&LfpT&ax%t@Q-Na(hC3}#5Sb}wH)+i zi#)UZ4u1nx`QapU;>YV6=xm&Z(i2&5efKe8sD1!lkMS@Za1Qx0MRo{W`mc~t4o>lc zj~6IAv+dEp2I^!P)`aGZ6IO*zwGC~z1bjw~CxFkSHl9);-AP->9QjT0` z=;5{B`s$?<(R!5?CE&K+!UccSVPlx4Ls+eO3fMF-`p5g94!|2b?uPid8-*|6a-c`S zN0rwoIgp*tYA@+eiTtDbr_7AHlN`wjAFsRH<2|f@93|mq5xzZF!HR61EZk`U{Zlb9 zTukfiJCPx~SFyK(C8>yj>KFLfoRc>D&JG$U8>5HX%PzQ{#c3*$e)jWepLjXl&>?P& z6uYf0o$!D9Yo19EJ1E_7eAGKTuJ?~q+TnV2g?G}t_x1A{jP9T!%MW*e z&V?V>yzc6*r-%2Vz$xbg+_BQbH-HTMt!9|9qoC|=>%62Xxf>v*d_$iMqah5WLf;nHkkX{%&${>c#Q zc4!=DYUFd9rIt_tF#z9t^Q9jOQUAA*?u&Q`hilRWE+h%syL3Aa1b~sS53UUW zm+AkfDXRxYezo6MGbb#sCQknIk6|Wx*Y=;COPp_7H$65qEK#%4G7|VnAgAn8os?sc z%C+E^_n7|LxG1D2J-h-wkaoO)b2Yze9gIxnE>Unq4H;gyZm8pt654Om_xz zyt;wWr%DP-Zs}LbA#_h)zTJ2-y#1hq;#e4Z*ijZ48ClNMQO@Ts?~-)mKSp*b+F|U{ z`rAC#!Ce&R`$5UI%;wb;pVIi3lamu(^9fl#>kW?ZwE@1jR>}{L;-OI1^tH$~PWjKK zx4xwIU>=6Ime|}#UXS4`&%-5cg6-8F0H+st`!tyqqkDVmnE|P--twJRoPvCCG23l0sqWzl zi_+umjhzUc-sK+9?u#p0iEZNSU5arKX6Qr$zB-LC@21i3Hl>sH-7Ha6EVTD5VXgJY zDEC;?3N2096jjcA|4VynOy(4uy{Q+Q@6NQtUb)$MKW;$s>2hePARyJO8DApjsDo>ZWE6+rP;eME^YS?Ujlf;8ppM)E%$V?~=T zi7PI#GSBXoO6b%eQ@?;--G&Dkp_2F2B>od@kJ(BM)Wb8-QPNSr_NEmstG^pYhS^aI z({7Qyav~ZF{5Z@s`gLj8&3WkBw>0=GVria4kMCpBk-u|dcu~dx zs@&^r<~M}~!UvGC`lW?9oOGK((jp@%(K!3ScN%LBLZ=X)`b%z=#cPYc`@Y2o?fP@kZ_0!_~p+p*N{KRz=iFEUPKC5ciWEszw<~Fw{)Djo(W@#(1}P5j*>y z>spR@_2!q`sySfNDYAaZzIIXNeP-+G)3Moddu-TYQiO4OWledSPR?tDH$*5N^D{0&?G4@jG5Vih z{i0u9ojYdj1-YWc=If*z)+l-Uq{aJl+e#FztFW^g8LBkXP>t2THkt`NJKSLV+%fKT zFuxsAHL>|H3>Audrj!Xq>g9B;*rT)US!@teYm=d93JyDx%l@6y{5XY_V5w{&G8p(R zgrdift?7@IhH2M&+DY!2tEWFzt;+YrHu9H$JeaU0fru2X8Jw3_mDy7Giz{Lh^gA7_ ztv(*%Y(eubE>?a5Au3phggnzUG0#!oeM_pi;x>)CX(A)&D_c-4H(ja`^gS}S&= z8(jjIPt_ZaF91h+J*g!hwZC&a0sB~8vidKyk9LF}H`cmtBVvhp7%Bs#R4H+*>chDx zqcX?w+v>ugMHP_Ew`XC)WV+P`S-wp(E5jhbvC&?(rTHp<QqY;+tR%MyyZ$-bw&KNmWQ`C)zk||32^cJCXMJXySEo ztqc%mEHPi26<BSfEE`i;UyUKx!2A*<3-m_V)XA=KL^pPfb6Qq$<=wY(U%vBO zKuUI_G^pgUW~YTmB+{}K$+|*fjKs740w$|6c4O17&EhkB zt07K_d0|l2(r8^gI8kX=wDe!(Oj1m3_tuG{Ar_7k3^5gdoI_UH!PziygR&|L5F_DE z-MqX;^)Pes7Qfhv<};}*niz#OS8{DNNIpmqFiEZ-^h#s)Xe!bBI)b<0q=ke7Q3$y6 zGFrAr1vtfFKV?uwA}GZu_PAzp+>Y_C{$9ZI8WTy^mNYbo!26eSGo(MFoTf=?_$vZK zp+`)!Yt~<)kpU7SyABImlR`=Tre!Gd9#?$rhQGXpAPT@K*q(q;@}8Pb&`MW5@RCec zr^Xk zHFWSTM##oh6S*Sq?LZAG0MPsp%jQKaEN)Q8gD}q|<@DeC=fXn;nh&ZnQK?oNo0GN` zwV*pDU!8^x9(^!xEMI2v4sTUUWXg*`~ zZ;LKE9P+vCeDyX1&x9f?gr}J@0kPl5+^JEKrlRlqJacmJS~!&*Mn>*B&X1P2PCe_C ze#*qOe$p=#6h6d2q@{(ZPit44DQvH2aAXb@J+IpQvc)d0auy)`+VbR>x0{uTae8?s z(M@~=VXB1WGj{8+o}CJurbpfcVM=n%cZk24A| zO2gC7^6Q~a2yxr{vVFcbO&&yBn@-3yr)6sXU?;b!U~K`#>9JozA)3O0Jha75#j(ox z-BPBssb=Sb%)3*9ggkVXu0gi)ZTz>46&XHvR2rm^T)$;KiiJ1tx%W>FYS^Zpd?quy z1R(vr+mU#{RLGBS8Y!{*Xkrb_XrK3J0-oo>vwix}_Zbcz0;&(=aDSx*ba8Vz3>*fl zXrk}`GVO9MyT~qlHp6;kIU#mnSaiK$JG4R+24szbI}39te0-hz~pkK!K zwU3%dVvldwyg$36XW8CYpnT!iyIl4qpa|zxqPewwFxL=$ zL%wbz+1e75vLHV|Hr{=DwsmdC$em+Nf8+RAmE$n-V%}g}e9N^vQHTW!!2UZM&;%Ymsh2Sx1Vd!YSqLmrndaL}x2IK2MkngoKH`^W zH4tg!?!^q8tw-{_)_1(RnuhsC`0K1q%{7A?#J}REe0IDy+6k1gAY~eh*_Uu#b#+n; z5atl4rCEV9&A!+>SO8H9)2B1YMTw%OVN0SdQ(z$~Ge$vjhW$9ps&1coJ^I!-tp`oF z9;@{!=zA1saH9~_PVr>tPO*BwH@c?V-ug}4X7N(4|7)C0gEkQJ5fg=>l3Tb_7#i9Q(d~eu#9SHJQu<{vV z1+K4llWMdGx{W|HTu0KWZewdk=zqW^3C=3!04RzU(b2kTS{{n zs8y=9*XafK_AgbJ4|@Y@T*2N;1ug9qMIF0RiBC@|n2@};xNfIh#hvN)6xsE$I1$IPe=<2{H(oLu&@A5%YO-FXLF5m*;MHJh6QPP zf(N;`xX0JJs|7}lKQ@X$MjR*JhbmD(lwIE~15-=S{%xYTE*Dob zY3Prf)4{^Mguha`_9mjdm8VTdL6XeV(Q?zwwvlyJ5JUiNPW^Rkf`FVFtXFuwL~yXY z=rwK92B4u4OrR_u~zFDQcTB)fgSe|CH1%0b7R~EbEUL#n`pvg>(r|EBJTVbp? z+!FT+x5-vH)^gVl!C?FP;uB0E$nUTsP2@{J3Y*4wo-zlFJ2dw)vdWNujW;=Hx9re% zJ`>z*FDovB1`!lK@bmQxk@huY0fJQad*_i$5CJ@ex<@H}bn$q$fp28qfN{BS*Ea52 zpW5cd4b!Kb*k-(vE5~~|9S(9)j&mEbmVV@&7spXJAYI^GzUq-qXt=n9Ju77*noST{ zQF|bqsg$KIf6Pf43cV(@boHs=vk4H$B!txhJDYt+-fU&ppwnt1o>C9IWffv|j2O`K z!&c^aMUcz9dpeAa4SZP)*`ZX>R2z5DkjFFhdPl%t{ioJl(#Y{F(>V%`>@eb zZiF2uJ#kH!_-Z0u>q_9Vp!W3^JEz8?Pki6MK`GHmqm4a;cNRz6M@m*=wM@%P&h#0? zB&UDr?D`(%@<#4H>UfA$6O)u5cICBWzj+CdJ#;}^bRVJH7Dok^$0<_=m(*>WSbLQ* z%bt2BzUAT3gLK!_d;U60HWV~(>CS(YZ=jSzfFUM*Bu(bc6k5ml2{~6v_id(}vJvn# z4`t8a!a1>su8}rMaCUMN}&lB>r2q%h2@#xEyK?T@9=#YllC z!9Szg{*lK<+PXZ~A1xsJor7J-D844>ceZz%#717w9`!Gi-5x29|6=qOisAEDrvB*M zf%UVu5l24C#rN4rH?Gt;8<`-uPJK1f{Wro0gB=0q&_?PD$G6vaGuQ+wJ?%G70s=N? zeLPfS)YimckoA)7M`#7PHt0$T^fGjqb2So(QItRC$cpjvU&SU6A@s5x=SR;RYN)x# z)v<=)=do6jJCx(&6}!JIvG9^WB8g32H6A$iQ?ab)12?04=~*@Rc+}w*`)rl)Zi6%~muMGv`gMZ<|)1R)6=d;ONG$CQ;!iln{u5d<52DR$2>QxRk)U`ni=oxkLS!QYFk4I z>ZDM&Jxe|6tt>m#mt*8c>OlX0S&N3=9xVdc&Y0k;Dq<1(2|cr0V}}Px0%R^D-eGOH zgAw_H`~hdf-ZHwx5s*kwgGJ}4$!Grk-IzuA8O)n%csU|hfUC_CvzTo{i@zF9N5a2O zME{=di5B2AA=);Ubiapk@fW$yT+_Eeq;v_IRDJk}`uQ0cV#98VsqpIPAT;eGg2nD( z1D;?+fTR*$8dzhxD)XVq%u*Bd)I((+%P5V^jx3i9%;HA>tHx5kv@%rRt{cj5RcykT zMY8c5cqzSREKX`7rS_X*jT<|XjcD5-S*N*`o*{Nf%ZsDB)?P_6#f?}AGnQzR5Fo*| z3rKk3D1YEcWa;x;^{iYTb3~2?Paozu;&`rTnrd0WM1l@sb8rNZ669gga6(zaJR|-)sb&+fl{`1gddO!jS$0_mTk;~O3ORg|%!>h-DJc4W z;~`K)7*1rm3x*ehEGOW)wTy|hQXJOM9gg?f^D02vADyh|nSgtENnkg5&M5+d`&|q} zOdx2iy#(1RMBcrIDPg8ofF@wqbybKk76J{7F@XjVr7g=l9vuNmxjB;}RxnfeSoc1Vo>N z6))Knw-XU0k9H^Sozaguw!E9+0c8+;b2NU5=1o^2=_lBZuVaTp7jTz+J~jJ*F>h-rpxC|Kiio}mOaWqPuZku ztJU6ZLUt^SI_IE8koJ9?g|N6C;34NxF`sJE;ZMPSf~aUlxTT{wuG1CsPWhb z;5?3+^ds$CyT0e~LMG zK?G0;iyr^(E*kG%!3y7SOX5LqytO}%eH~0pA?Yv3tLo>f!m9a+)bB& zzPMR-flMrpt!LTs7QR_#?JyEvT+Kh#!_(2N-!_$|dL7bYpU&#t%OhSt98MnmFLQaV z?t}3$trY-a2qs;_LX9zwNI-UyE#;{+UvYuc{-f(z@}0rAlF@) zQ}rWJ$O9)1M-&Z9XqF^`eT4lZG_7qJ`^RAjRE;~uO;{|BDd*>`SlUP|sQ_r!X0#nk zi>ihzd`;X8o}@U{47-PAzJrVpXAjB4mtw|@8rpM1wfkMCUhO?BVDIDj;092<4(!%d+BBk*M7XIw5{Axeo~E@8+^mgXSUaU71y z;eQtFx9p59H#W31ef*c68U1NXh-#73@K^hQftim7mq+UTJXO(k+O+?gVZ!SJ_v8{K z(=-lcR@9f&%|Y?&$n@ZR4_2jwmKs} zW$LJ>AkH?&TdW%Yx^6OBGoFG6#tFB~)@4yuZ?5(?t1)@T2mTXht4lW(^*9`wnzV%%LM6!?THoi$=aAe%*KFTMR!6vrfG-hc2^*%8~P~uT%EJs{Bxi75IA0YDbmuNyOjB zOUm1IV_s+FV?jVOq5gO|`8`ju#bdc-Q2wsT;Psi;jK5C6*>*13M`%?zC}vFNwEjus z4YgBtD(hP=u8%2M3j4`(m5);13-dRm=;9jmJ|J_kkw}}7c)OGvu-T~m5PHKNl0{|g zHMEiPkv6~E%~FvmvTSX+5l7h63ehR@pwZ&*_+5^dpo;!TYi#-B-yheJEAVNE%*9Hf zYswN~iZ$lvS5=uzQ5nGr$De7PC_-FkWK4ydyWQ^5eIzs~&ZG7kcRD#{1ReB8NT9!- z@>9@NHD~`oo%{?Lr3?B)V1Z7oYUypF34enO}c& zEtrRvF31+5=AP8r_*a&0zH2T)r+eEZb#X83_oEd$Vw|udUr0?p*=P!1^Y~MSx@B*m z1)O6=Mqm$A9#C2*s`6kVTRfck$WO}$>%GP?vE6LF+$5YWxbS_m9`_VQPVq@yzw-ibDJ ze8e%^K-m4E(s!?HxtB9Q-x;r{nZsJtmHg45j_vK_8lf|DNwPOh=3`?I<~?j;=%;9P z!}1kjw(iNkcplznJYKX7#&Gk6VN}LmlA8TJZRPMXdO>>mdtGrKrh?xwl#j7c+M(To z_G&Ta6e#mXTh5ws7}Reyv8&@22Qqu|lFOb8B{Q`_#d1J!%}_?&b0jL`$ljH`jF2x` z_z+~iMpUd&;^F1|pdlYU3OIa`pf(#|Wi{~AP6?soZMeQT&twsv{EZERaa+P-d?se; z8z1SWU=t*ICadC7J?waO;9t9emtWMF?MvY~(Fr=iB7&UZmiWgu{_palyRsI3hmoxG zNDKCsEcFN5xRQ#iODSmExwIF(8;$lpV&2?W@+3FuG&W)*lFLu|L$iqUMCNOpI9VkM zb#}KSwbfOe)b=~`^44Y+PrHj)-VMQ_D^N1OLs_ND4&|a%t2AV$q#*)T-dd^E)gW&UC%?Cqp0F4Zw?f7t z!xFVG4YnM&3g%Wy`Y++|r&`);i;f;0p?X8vtJe}HDrO#ebxpmlr1R}G39~%@Qq!`- zx*1-Swn9sh@I<)XlX@t$7*<&Ya{FmRyg=al_Cr}q_OV|1ffmJZnE@N^eXkT)SsVgYB~l8sMCCO=PXjylCju7Uh1g7(rJ` z!&lkh7O7cUI>c|A_IpCl_?TnmhAc4us6%r6DJX8*@@~KFIA^ZO zlc|U!s;fyvJv42mPc8S>Zd`QJoaxQ+hK!4qgt-y~ZuR+@`HfHpN>`M^!Ihh7wQ`Xm4b9W2 z_V#MQz%EqC!>YOWwp`BC4*_+Zr9%+#K>bVj=rF03|+S)O- zxvBo+l-QP|+mN5K*V#1a8l6Q|yK8^_d%{%s-ft-mx6i)HjA65glkrBvyVX-oNhU$k zIQ9@duh?Ru!PHnq4O?wYAfTjYtVGV*qeeN!5JQ3~3zqkeM^9794a~a>zjb0XO&jRu zuje(Tps@7B^KMv5*(Hago5(0WwR@DnkIUDy;54k{!due8y{ng8U4FOqObmUol9B2l94poDsDQpM`3y_Is*O&<*cyB;9?^8|yFZU22J`$7fNaV|b#ix| zV_}{6==<1OcNI4}aR22rwC%y3pTpQnD^F|{>ncfeHQq^X(VyqqTF-V`H*a6X`D$9+ zx9p&f^RCR0;CcGl`Wy}S)#6omVf4g3mVUP8IKZ8IC#-_LN!AE9*MV zl$n2V-J~6h;fv$#1b{qVg@vm7}>HHyIIcdIqv`GTe zy%-sIe6C=9>_pzu`fZxZIZ6{sBwBFsqf5X-hC#zK4&XNf85@|g*y5OCI-go61xwug zL%+8Ws#7;n_|-95zGa+zHE++l?RXRoElpO^z=8LN*%}kigH|+V9t2dDl%rg z{A`^^=-#W@zGOZjzNJa1e@2^J5yYo;6;HfN3pvDw^ZX9 z;n@Z)Lc+c-UMZy1Sxdcr?Q=G>iFSq{fCR7$$5W|srp*8!$2{IoDcXx1_&=_e_&dBP z>HL;Cg*&?&eo694M_ae1%tfOgzzktGu;w|dt*#IK@Q-}tdVn`&;XIPL)MM8BJ0<&0s1v1)D0UqD5*aEb`i409C_AE0`-QW(jc6k-9wLlK@6i#CX>0p z!3?@T<*t3c?0GsT6F%afn(1$!Bma`#_axsMkGQYSYc69#SzH+84KKkGXBP+l@Naa- z5nAH){Y-0W=tPCg$@tV#?+3DD1pnLnxFu3@rxEP;^}Bir9)1EG zhNs%jVb@!D(NI19i`tDvf9I>C?DwwzN+nHe7`0y?sA_LYy4*JYhQ7~vB^UY@eNv5O zV)^0LOTv*IW@pIyo+2{6Ce$1Qa`>-4o&6+U%o5(~<6)eO8|}*cDk{>>Y+9uvyIoy- zV-3Gl^_xnE{C9ubMSdHTfRVoWyg6IbBQ`yAEC@4sgrK1u)ZC<P!BPx(xM~cEG8}etj(b12*u~Lcunz*Vi99sQ& zh%zty=&f6r$?1sOp`2YUysb6y*_woM9NbJo*`Kl=ystvpc_P7}dHr+W2t)l7!8iKK ziW66rQvZD_d8MD<-B?JsV&4xZ(?rEEAI{cO;m#MlOaX{I>c~`sl@;HK^+tSN6&t+D9p_#L;mHNP*wiL?;Z!u8a$lMhwo6a zxeYe@iu!piPDYJ+8P+x3Q|Mo>YNx#h(T4QppgqQZZ|%K1X8jec7LhEaYOu>z^8-BBk*F+Vzv{D*+fes0oh#a zTAPHBc!1zddMKxK`BZ!8+1MdTnS32jb^c%MytP_1Dn1E&HoiNtr8C(; zM^u)@a!bOHquhs1)S=6l;jXQ;8xu6F$^NbPHwCH%3sd;@W4Kq3Ph7l+PBvG4zG!`~ z`Gm9Q&z@wnQOh%?#9)Ckm#X<0qm);(kRRtpIDwsMOP$HCI;Z!ot52sqG#!_D6WqCT zp-sZeJ1B^PoPsD4upBg;2l`l)r<2Kp2IET~mnb>DGm9HgPe;~p@4RNnZ*|Fa+iZV- zNKHVdOBJVW@)dzr!8qw&oO5~)@(XN8`X0rv>;eMg2rjo2iB1;>(i==3-3d5+VKeyA{c2GP z9Q{C8PK0GWskf5egz?p8PjIESovTuh(ZtPnE&+U~kGSbkEW+vQpL@Nn4 zpupZ+-gMSj|JO{s$$R@tAI^> zrifN)t$*?qas?IO7!(|+=;mOy9{x~?zv$H~dbnleMtRnOa|7WX`>@kfyYi-F^SlNdCd zD68jot@^nyZF5$mO}qpoXG` zPay6A9SIn1yqP))d!z20+08TiaMWL;^!oa%@BK4GtPz)5m_ieNKNFCXQkJY1HwpS5 DV%(+% literal 0 HcmV?d00001 diff --git a/src/main/resources/templates/email/designer-signup-notification.html b/src/main/resources/templates/email/designer-signup-notification.html new file mode 100644 index 0000000..bc4d49c --- /dev/null +++ b/src/main/resources/templates/email/designer-signup-notification.html @@ -0,0 +1,47 @@ + + + + + + +
+ +
+ ditda +
+ +
+

μƒˆ λ””μžμ΄λ„ˆ κ°€μž… κ²€ν†  μš”μ²­

+

μƒˆλ‘œμš΄ λ””μžμ΄λ„ˆκ°€ νšŒμ›κ°€μž…μ„ μ™„λ£Œν–ˆμŠ΅λ‹ˆλ‹€.
포트폴리였 κ²€ν†  ν›„ 승인 처리 λΆ€νƒλ“œλ¦½λ‹ˆλ‹€.

+
+ +
+
+ User ID + +
+
+ 이름 + +
+
+ 이메일 + +
+
+ +
+
포트폴리였
+
    +
  • + +
  • +
+
+ +

+ λ³Έ 메일은 μžλ™ λ°œμ†‘λœ μ•Œλ¦Όμž…λ‹ˆλ‹€. +

+
+ + \ No newline at end of file diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 86d6db8..8b44b91 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -40,6 +40,10 @@ spring: app: s3: bucket: test-bucket + public-base-url: http://localhost:9000/test-bucket + +admin: + notification-email: admin-test@exmaple.com jwt: secret: application-test-jwt-secret-key-only-for-ci From 92a0d73e6cdd0b130413d503a6e2556825998144 Mon Sep 17 00:00:00 2001 From: fervovita Date: Mon, 25 May 2026 19:59:10 +0900 Subject: [PATCH 60/90] =?UTF-8?q?Feat(designer):=20=EC=9D=B4=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EC=9D=B8=EC=A6=9D=20=ED=85=9C=ED=94=8C=EB=A6=BF=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EB=A1=9C=EA=B3=A0=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/email/EmailSender.java | 2 + .../templates/email/verification-code.html | 27 +++++++++++++ .../templates/email/verificationCode.html | 40 ------------------- 3 files changed, 29 insertions(+), 40 deletions(-) create mode 100644 src/main/resources/templates/email/verification-code.html delete mode 100644 src/main/resources/templates/email/verificationCode.html diff --git a/src/main/java/ditda/backend/global/email/EmailSender.java b/src/main/java/ditda/backend/global/email/EmailSender.java index 571ea11..9c0b01c 100644 --- a/src/main/java/ditda/backend/global/email/EmailSender.java +++ b/src/main/java/ditda/backend/global/email/EmailSender.java @@ -41,6 +41,8 @@ public void sendVerificationEmail(String email, String code) { helper.setSubject(VERIFICATION_SUBJECT); helper.setText(buildHtml(code), true); + helper.addInline(LOGO_CID, new ClassPathResource(LOGO_PATH)); + mailSender.send(mimeMessage); log.info("Verification email sent. to={}", email); diff --git a/src/main/resources/templates/email/verification-code.html b/src/main/resources/templates/email/verification-code.html new file mode 100644 index 0000000..120e327 --- /dev/null +++ b/src/main/resources/templates/email/verification-code.html @@ -0,0 +1,27 @@ + + + + + Ditda 이메일 인증 + + +
+ + +
+ ditda +
+ +
+

ditda 이메일 인증번호

+

μ•„λž˜ 인증번호λ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”.

+
+ 0000 +
+

+ μΈμ¦λ²ˆν˜ΈλŠ” 5λΆ„κ°„ μœ νš¨ν•©λ‹ˆλ‹€. +

+
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/email/verificationCode.html b/src/main/resources/templates/email/verificationCode.html deleted file mode 100644 index 2e7dd1a..0000000 --- a/src/main/resources/templates/email/verificationCode.html +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - -
- - - - - - - - - - - - - -
-

Ditda

-
-

이메일 인증을 μœ„ν•΄ μ•„λž˜ μ½”λ“œλ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”.

-
-
- -
-
-

이 μ½”λ“œλŠ” 5λΆ„κ°„ μœ νš¨ν•©λ‹ˆλ‹€.

-
-
- - \ No newline at end of file From 6fdb508c9b59c3cbd428a2f1019fee936ee4d2b7 Mon Sep 17 00:00:00 2001 From: fervovita Date: Mon, 25 May 2026 20:36:56 +0900 Subject: [PATCH 61/90] =?UTF-8?q?Feat(designer):=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B4=EB=84=88=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20DTO=EC=97=90=20=ED=95=84=EC=88=98=EA=B0=92?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/designer/auth/dto/request/DesignerSignupRequest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/ditda/backend/domain/designer/auth/dto/request/DesignerSignupRequest.java b/src/main/java/ditda/backend/domain/designer/auth/dto/request/DesignerSignupRequest.java index d629604..00ad560 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/dto/request/DesignerSignupRequest.java +++ b/src/main/java/ditda/backend/domain/designer/auth/dto/request/DesignerSignupRequest.java @@ -98,6 +98,7 @@ public record TermRequest( String version, @Schema(description = "μ•½κ΄€ λ™μ˜ μ—¬λΆ€", example = "true") + @NotNull(message = "μ•½κ΄€ λ™μ˜ μ—¬λΆ€λŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") @JsonProperty("isAgreed") boolean isAgreed ) { From 0212cf94bf09175f48979188e3b5bcddbe740e48 Mon Sep 17 00:00:00 2001 From: fervovita Date: Mon, 25 May 2026 20:54:25 +0900 Subject: [PATCH 62/90] =?UTF-8?q?Feat(designer):=20=ED=8F=AC=ED=8A=B8?= =?UTF-8?q?=ED=8F=B4=EB=A6=AC=EC=98=A4=20=ED=8C=8C=EC=9D=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EC=A6=88=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/designer/auth/service/PortfolioService.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java b/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java index 556598d..7d0fb77 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java +++ b/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java @@ -8,6 +8,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.unit.DataSize; import org.springframework.web.multipart.MultipartFile; import ditda.backend.domain.common.user.entity.User; @@ -39,6 +40,9 @@ public class PortfolioService { @Value("${app.s3.bucket}") private String bucket; + @Value("${spring.servlet.multipart.max-file-size}") + private DataSize maxFileSize; + public void validateFiles(List files) { if (files == null || files.isEmpty()) { return; @@ -50,11 +54,15 @@ public void validateFiles(List files) { throw new GeneralException(DesignerErrorCode.PORTFOLIO_FILE_LIMIT_EXCEEDED); } - // 파일 νƒ€μž… 검증 + // 파일 μ‚¬μ΄μ¦ˆ 및 νƒ€μž… 검증 for (MultipartFile file : files) { if (file.isEmpty()) { continue; } + + if (file.getSize() > maxFileSize.toBytes()) { + throw new GeneralException(DesignerErrorCode.PORTFOLIO_FILE_LIMIT_EXCEEDED); + } if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) { throw new GeneralException(DesignerErrorCode.INVALID_PORTFOLIO_FILE); } From 85954a6b010feea47b60f72cc2c6a14f1321853d Mon Sep 17 00:00:00 2001 From: fervovita Date: Mon, 25 May 2026 20:54:53 +0900 Subject: [PATCH 63/90] =?UTF-8?q?Feat(designer):=20=ED=8F=AC=ED=8A=B8?= =?UTF-8?q?=ED=8F=B4=EB=A6=AC=EC=98=A4=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EB=B6=80=EB=B6=84=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/PortfolioService.java | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java b/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java index 7d0fb77..1768e1d 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java +++ b/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java @@ -1,6 +1,7 @@ package ditda.backend.domain.designer.auth.service; import java.io.IOException; +import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.UUID; @@ -75,10 +76,21 @@ public List uploadFiles(List files) { } // νŒŒμΌμ„ S3에 μ—…λ‘œλ“œ - return files.stream() - .filter(f -> !f.isEmpty()) - .map(this::uploadToS3) - .toList(); + List uploadedKeys = new ArrayList<>(); + try { + for (MultipartFile file : files) { + if (file.isEmpty()) { + continue; + } + uploadedKeys.add(uploadToS3(file)); + } + + return uploadedKeys; + } catch (Exception e) { + // λΆ€λΆ„ μ—…λ‘œλ“œλœ 파일 보상 μ‚­μ œ + deleteFiles(uploadedKeys); + throw e; + } } @Transactional From b13beab4e8654cadbf1852771af1a53b579334a0 Mon Sep 17 00:00:00 2001 From: fervovita Date: Mon, 25 May 2026 20:55:13 +0900 Subject: [PATCH 64/90] =?UTF-8?q?Chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EC=84=A4=EC=A0=95=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/application-test.yaml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 8b44b91..8d7e82c 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -37,13 +37,9 @@ spring: s3: endpoint: http://localhost:9000 path-style-access-enabled: true -app: - s3: - bucket: test-bucket - public-base-url: http://localhost:9000/test-bucket admin: - notification-email: admin-test@exmaple.com + notification-email: admin-test@example.com jwt: secret: application-test-jwt-secret-key-only-for-ci From 1db9fb95c823d7b7dd5c02b828cd900a8af93046 Mon Sep 17 00:00:00 2001 From: fervovita Date: Mon, 25 May 2026 21:00:10 +0900 Subject: [PATCH 65/90] =?UTF-8?q?Chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EC=97=90=20S3=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/application-test.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 8d7e82c..b6164cd 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -38,6 +38,11 @@ spring: endpoint: http://localhost:9000 path-style-access-enabled: true +app: + s3: + bucket: test-bucket + public-base-url: http://localhost:9000/test-bucket + admin: notification-email: admin-test@example.com @@ -55,4 +60,4 @@ encrypt: swagger: server-url: http://localhost:8080 - server-description: Test Server \ No newline at end of file + server-description: Test Server From 48834986eed2386dc9f4c95508171875a5c241e8 Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Mon, 25 May 2026 22:25:18 +0900 Subject: [PATCH 66/90] =?UTF-8?q?Docs(auth):=20InstructorSignupRequest=20S?= =?UTF-8?q?chema=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../instructor/auth/dto/request/InstructorSignupRequest.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/ditda/backend/domain/instructor/auth/dto/request/InstructorSignupRequest.java b/src/main/java/ditda/backend/domain/instructor/auth/dto/request/InstructorSignupRequest.java index ee9165b..865435e 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/dto/request/InstructorSignupRequest.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/dto/request/InstructorSignupRequest.java @@ -84,12 +84,16 @@ public record InstructorSignupRequest( public record TermRequest( + @Schema(description = "μ•½κ΄€ μ’…λ₯˜", example = "SERVICE") @NotNull(message = "μ•½κ΄€ μ’…λ₯˜ ν•„μˆ˜μž…λ‹ˆλ‹€.") TermType type, + @Schema(description = "μ•½κ΄€ 버전", example = "V1.0") @NotBlank(message = "μ•½κ΄€ 버전 ν•„μˆ˜μž…λ‹ˆλ‹€.") String version, + @Schema(description = "μ•½κ΄€ λ™μ˜ μ—¬λΆ€", example = "true") + @NotNull(message = "μ•½κ΄€ λ™μ˜ μ—¬λΆ€λŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") @JsonProperty("isAgreed") boolean isAgreed ) { From 5fe0702029818ff234d3707814cb4d37a6c459bc Mon Sep 17 00:00:00 2001 From: fervovita Date: Mon, 25 May 2026 23:53:20 +0900 Subject: [PATCH 67/90] =?UTF-8?q?Fix:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/ditda/backend/global/email/EmailSender.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/ditda/backend/global/email/EmailSender.java b/src/main/java/ditda/backend/global/email/EmailSender.java index 9c0b01c..7ff586f 100644 --- a/src/main/java/ditda/backend/global/email/EmailSender.java +++ b/src/main/java/ditda/backend/global/email/EmailSender.java @@ -20,7 +20,7 @@ public class EmailSender { private static final String VERIFICATION_SUBJECT = "[DITDA] 이메일 인증 μ½”λ“œ"; - private static final String VERIFICATION_TEMPLATE = "email/verificationCode"; + private static final String VERIFICATION_TEMPLATE = "email/verification-code"; private static final String DESIGNER_SIGNUP_SUBJECT = "[DITDA] μƒˆ λ””μžμ΄λ„ˆ κ°€μž… κ²€ν†  μš”μ²­"; private static final String DESIGNER_SIGNUP_TEMPLATE = "email/designer-signup-notification"; From 9ee2b384b9cb39f6665bce928d73dc899e96fa9e Mon Sep 17 00:00:00 2001 From: fervovita Date: Mon, 25 May 2026 23:57:38 +0900 Subject: [PATCH 68/90] =?UTF-8?q?Feat(designer):=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B4=EB=84=88=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85?= =?UTF-8?q?=EC=8B=9C=20=EC=95=84=EC=9D=B4=EB=94=94=20=EB=B0=8F=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/designer/auth/facade/DesignerAuthFacade.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/ditda/backend/domain/designer/auth/facade/DesignerAuthFacade.java b/src/main/java/ditda/backend/domain/designer/auth/facade/DesignerAuthFacade.java index 18a7782..0e379e2 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/facade/DesignerAuthFacade.java +++ b/src/main/java/ditda/backend/domain/designer/auth/facade/DesignerAuthFacade.java @@ -6,6 +6,7 @@ import org.springframework.web.multipart.MultipartFile; import ditda.backend.domain.common.auth.service.EmailVerificationService; +import ditda.backend.domain.common.user.service.UserService; import ditda.backend.domain.designer.auth.dto.DesignerAuthResult; import ditda.backend.domain.designer.auth.dto.request.DesignerSignupRequest; import ditda.backend.domain.designer.auth.service.DesignerAuthService; @@ -19,12 +20,17 @@ public class DesignerAuthFacade { private final DesignerAuthService designerAuthService; private final EmailVerificationService emailVerificationService; private final PortfolioService portfolioService; + private final UserService userService; public DesignerAuthResult signup(DesignerSignupRequest request, List portfolioFiles) { // 이메일 인증 μ—¬λΆ€ 확인 emailVerificationService.validateVerified(request.email()); + // 아이디 및 이메일 검증 + userService.validateUsernameAvailable(request.username()); + userService.validateEmailAvailable(request.email()); + // 포트폴리였 파일 검증 및 S3 μ—…λ‘œλ“œ portfolioService.validateFiles(portfolioFiles); List portfolioKeys = portfolioService.uploadFiles(portfolioFiles); From 28c3879a906898725858071fea6429c19d1e0fae Mon Sep 17 00:00:00 2001 From: fervovita Date: Tue, 26 May 2026 00:04:23 +0900 Subject: [PATCH 69/90] =?UTF-8?q?Refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20hash=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/encryption/AesEncryptor.java | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/src/main/java/ditda/backend/global/encryption/AesEncryptor.java b/src/main/java/ditda/backend/global/encryption/AesEncryptor.java index 6c5d1bc..d3f5b13 100644 --- a/src/main/java/ditda/backend/global/encryption/AesEncryptor.java +++ b/src/main/java/ditda/backend/global/encryption/AesEncryptor.java @@ -6,7 +6,6 @@ import java.util.Base64; import javax.crypto.Cipher; -import javax.crypto.Mac; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; @@ -109,28 +108,4 @@ public String decrypt(String encryptedText) { throw new GeneralException(GeneralErrorCode.DECRYPTION_FAILED); } } - - public String hash(String plainText) { - if (plainText == null || plainText.isEmpty()) { - return null; - } - - try { - // HMAC-SHA256 μ΄ˆκΈ°ν™” 및 ν•΄μ‹± - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(hmacKeySpec); - byte[] hashBytes = mac.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); - - // 16μ§„μˆ˜ λ¬Έμžμ—΄λ‘œ λ³€ν™˜ - StringBuilder sb = new StringBuilder(); - for (byte b : hashBytes) { - sb.append(String.format("%02x", b)); - } - - return sb.toString(); - } catch (Exception e) { - log.error("Failed to hash data", e); - throw new GeneralException(GeneralErrorCode.HASHING_FAILED); - } - } } From ce4d7d90fdb668185dd4a854090b346ba70070d6 Mon Sep 17 00:00:00 2001 From: fervovita Date: Tue, 26 May 2026 01:14:14 +0900 Subject: [PATCH 70/90] =?UTF-8?q?Refactor:=20S3=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EB=A1=9C=EC=A7=81=EC=9D=84=20S3FileUploader?= =?UTF-8?q?=EB=A1=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/PortfolioService.java | 75 ++-------------- .../backend/global/s3/S3FileUploader.java | 89 +++++++++++++++++++ .../s3/exception/S3UploadException.java | 8 ++ 3 files changed, 104 insertions(+), 68 deletions(-) create mode 100644 src/main/java/ditda/backend/global/s3/S3FileUploader.java create mode 100644 src/main/java/ditda/backend/global/s3/exception/S3UploadException.java diff --git a/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java b/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java index 1768e1d..ca2957f 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java +++ b/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java @@ -1,10 +1,7 @@ package ditda.backend.domain.designer.auth.service; -import java.io.IOException; -import java.util.ArrayList; import java.util.List; import java.util.Set; -import java.util.UUID; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -17,13 +14,10 @@ import ditda.backend.domain.designer.auth.exception.DesignerErrorCode; import ditda.backend.domain.designer.auth.repository.PortfolioRepository; import ditda.backend.global.apipayload.exception.GeneralException; +import ditda.backend.global.s3.S3FileUploader; +import ditda.backend.global.s3.exception.S3UploadException; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import software.amazon.awssdk.core.exception.SdkException; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.s3.S3Client; -@Slf4j @Service @RequiredArgsConstructor public class PortfolioService { @@ -35,12 +29,9 @@ public class PortfolioService { "image/png" ); - private final S3Client s3Client; + private final S3FileUploader s3FileUploader; private final PortfolioRepository portfolioRepository; - @Value("${app.s3.bucket}") - private String bucket; - @Value("${spring.servlet.multipart.max-file-size}") private DataSize maxFileSize; @@ -71,25 +62,10 @@ public void validateFiles(List files) { } public List uploadFiles(List files) { - if (files == null || files.isEmpty()) { - return List.of(); - } - - // νŒŒμΌμ„ S3에 μ—…λ‘œλ“œ - List uploadedKeys = new ArrayList<>(); try { - for (MultipartFile file : files) { - if (file.isEmpty()) { - continue; - } - uploadedKeys.add(uploadToS3(file)); - } - - return uploadedKeys; - } catch (Exception e) { - // λΆ€λΆ„ μ—…λ‘œλ“œλœ 파일 보상 μ‚­μ œ - deleteFiles(uploadedKeys); - throw e; + return s3FileUploader.uploadAll(S3_KEY_PREFIX, files); + } catch (S3UploadException e) { + throw new GeneralException(DesignerErrorCode.PORTFOLIO_UPLOAD_FAILED); } } @@ -107,43 +83,6 @@ public void savePortfolios(User user, List portfolioKeys) { } public void deleteFiles(List portfolioKeys) { - if (portfolioKeys == null || portfolioKeys.isEmpty()) { - return; - } - - for (String key : portfolioKeys) { - try { - s3Client.deleteObject(req -> req.bucket(bucket).key(key)); - } catch (Exception e) { - log.warn("Failed to delete S3 portfolio file. key={}", key, e); - } - } - } - - private String uploadToS3(MultipartFile file) { - String key = generateKey(file); - try { - s3Client.putObject( - req -> req.bucket(bucket).key(key).contentType(file.getContentType()), - RequestBody.fromInputStream(file.getInputStream(), file.getSize()) - ); - return key; - } catch (IOException | SdkException e) { - log.error("Failed to upload portfolio file. originalName={}", file.getOriginalFilename(), e); - throw new GeneralException(DesignerErrorCode.PORTFOLIO_UPLOAD_FAILED); - } - } - - private String generateKey(MultipartFile file) { - String extension = extractExtension(file.getOriginalFilename()); - return "%s/%s%s".formatted(S3_KEY_PREFIX, UUID.randomUUID(), extension); - } - - // 파일λͺ…에 ν™•μž₯자 μΆ”μΆœ - private String extractExtension(String filename) { - if (filename == null || !filename.contains(".")) { - return ""; - } - return filename.substring(filename.lastIndexOf('.')); + s3FileUploader.deleteAll(portfolioKeys); } } diff --git a/src/main/java/ditda/backend/global/s3/S3FileUploader.java b/src/main/java/ditda/backend/global/s3/S3FileUploader.java new file mode 100644 index 0000000..812f812 --- /dev/null +++ b/src/main/java/ditda/backend/global/s3/S3FileUploader.java @@ -0,0 +1,89 @@ +package ditda.backend.global.s3; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import ditda.backend.global.s3.exception.S3UploadException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; + +@Slf4j +@Component +@RequiredArgsConstructor +public class S3FileUploader { + + private final S3Client s3Client; + private final S3Properties s3Properties; + + public String upload(String prefix, MultipartFile file) { + String key = generateKey(prefix, file); + + try { + s3Client.putObject( + req -> req.bucket(s3Properties.getBucket()) + .key(key) + .contentType(file.getContentType()), + RequestBody.fromInputStream(file.getInputStream(), file.getSize()) + ); + return key; + } catch (IOException | SdkException e) { + log.error("Failed to upload file to S3. originalName={}", file.getOriginalFilename(), e); + throw new S3UploadException(file.getOriginalFilename(), e); + } + } + + public List uploadAll(String prefix, List files) { + if (files == null || files.isEmpty()) { + return List.of(); + } + + List uploadedKeys = new ArrayList<>(); + try { + for (MultipartFile file : files) { + if (file.isEmpty()) { + continue; + } + uploadedKeys.add(upload(prefix, file)); + } + return uploadedKeys; + } catch (Exception e) { + // λΆ€λΆ„ μ—…λ‘œλ“œλœ 파일 보상 μ‚­μ œ + deleteAll(uploadedKeys); + throw e; + } + } + + public void deleteAll(List keys) { + if (keys == null || keys.isEmpty()) { + return; + } + for (String key : keys) { + try { + s3Client.deleteObject(req -> req.bucket(s3Properties.getBucket()).key(key)); + } catch (Exception e) { + log.error("S3 delete failed. key={}", key, e); + } + } + } + + private String generateKey(String prefix, MultipartFile file) { + String extension = extractExtension(file.getOriginalFilename()); + return "%s/%s%s".formatted(prefix, UUID.randomUUID(), extension); + } + + // 파일λͺ…μ—μ„œ ν™•μž₯자 μΆ”μΆœ + private String extractExtension(String filename) { + if (filename == null || !filename.contains(".")) { + return ""; + } + return filename.substring(filename.lastIndexOf('.')); + } +} diff --git a/src/main/java/ditda/backend/global/s3/exception/S3UploadException.java b/src/main/java/ditda/backend/global/s3/exception/S3UploadException.java new file mode 100644 index 0000000..94e2bf0 --- /dev/null +++ b/src/main/java/ditda/backend/global/s3/exception/S3UploadException.java @@ -0,0 +1,8 @@ +package ditda.backend.global.s3.exception; + +public class S3UploadException extends RuntimeException { + + public S3UploadException(String filename, Throwable cause) { + super("Failed to upload file to S3: " + filename, cause); + } +} From e3f5ff9cd086f04cf3302ce5ab8323db9c43b896 Mon Sep 17 00:00:00 2001 From: fervovita Date: Tue, 26 May 2026 01:17:31 +0900 Subject: [PATCH 71/90] =?UTF-8?q?Feat(designer):=20=ED=8F=AC=ED=8A=B8?= =?UTF-8?q?=ED=8F=B4=EB=A6=AC=EC=98=A4=20=ED=8C=8C=EC=9D=BC=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=20=EC=B4=88=EA=B3=BC=20=EC=98=88=EC=99=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/designer/auth/exception/DesignerErrorCode.java | 1 + .../backend/domain/designer/auth/service/PortfolioService.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/ditda/backend/domain/designer/auth/exception/DesignerErrorCode.java b/src/main/java/ditda/backend/domain/designer/auth/exception/DesignerErrorCode.java index 9a3d813..717bf26 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/exception/DesignerErrorCode.java +++ b/src/main/java/ditda/backend/domain/designer/auth/exception/DesignerErrorCode.java @@ -12,6 +12,7 @@ public enum DesignerErrorCode implements BaseErrorCode { PORTFOLIO_FILE_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "DESIGNER_400_01", "포트폴리였 νŒŒμΌμ€ μ΅œλŒ€ 3κ°œκΉŒμ§€ μ—…λ‘œλ“œ κ°€λŠ₯ν•©λ‹ˆλ‹€."), INVALID_PORTFOLIO_FILE(HttpStatus.BAD_REQUEST, "DESIGNER_400_02", "μ§€μ›ν•˜μ§€ μ•ŠλŠ” 파일 ν˜•μ‹μž…λ‹ˆλ‹€."), + PORTFOLIO_FILE_SIZE_EXCEEDED(HttpStatus.CONTENT_TOO_LARGE, "DESIGNER_413_01", "포트폴리였 파일 크기가 μ œν•œμ„ μ΄ˆκ³Όν–ˆμŠ΅λ‹ˆλ‹€."), PORTFOLIO_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "DESIGNER_500_01", "포트폴리였 파일 μ—…λ‘œλ“œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."); private final HttpStatus httpStatus; diff --git a/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java b/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java index ca2957f..5b9cfba 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java +++ b/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java @@ -53,7 +53,7 @@ public void validateFiles(List files) { } if (file.getSize() > maxFileSize.toBytes()) { - throw new GeneralException(DesignerErrorCode.PORTFOLIO_FILE_LIMIT_EXCEEDED); + throw new GeneralException(DesignerErrorCode.PORTFOLIO_FILE_SIZE_EXCEEDED); } if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) { throw new GeneralException(DesignerErrorCode.INVALID_PORTFOLIO_FILE); From 0cf50fae2b17f33fac0a0eae24b8a4503f2865fc Mon Sep 17 00:00:00 2001 From: fervovita Date: Tue, 26 May 2026 01:32:17 +0900 Subject: [PATCH 72/90] =?UTF-8?q?Refactor:=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EB=B0=9C=EC=86=A1=20=EB=A1=9C=EC=A7=81=EC=9D=84=20=EA=B0=81?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8=20Mailer=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/common/auth/facade/AuthFacade.java | 6 +- .../notification/EmailVerificationMailer.java | 22 +++++++ .../notification/DesignerSignupMailer.java | 34 +++++++++++ .../notification/DesignerSignupNotifier.java | 7 +-- .../backend/global/email/EmailSender.java | 61 +++---------------- 5 files changed, 72 insertions(+), 58 deletions(-) create mode 100644 src/main/java/ditda/backend/domain/common/auth/notification/EmailVerificationMailer.java create mode 100644 src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupMailer.java diff --git a/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java b/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java index b21db71..f9a6208 100644 --- a/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java +++ b/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java @@ -5,10 +5,10 @@ import ditda.backend.domain.common.auth.dto.AuthResult; import ditda.backend.domain.common.auth.dto.request.LoginRequest; +import ditda.backend.domain.common.auth.notification.EmailVerificationMailer; import ditda.backend.domain.common.auth.service.AuthService; import ditda.backend.domain.common.auth.service.EmailVerificationService; import ditda.backend.domain.common.user.service.UserService; -import ditda.backend.global.email.EmailSender; import lombok.RequiredArgsConstructor; @Component @@ -16,7 +16,7 @@ public class AuthFacade { private final EmailVerificationService emailVerificationService; - private final EmailSender emailSender; + private final EmailVerificationMailer emailVerificationMailer; private final UserService userService; private final AuthService authService; @@ -28,7 +28,7 @@ public void validateUsernameAvailable(String username) { // 이메일 인증번호 λ°œμ†‘ public void requestEmailVerification(String email) { String code = emailVerificationService.issueCode(email); - emailSender.sendVerificationEmail(email, code); + emailVerificationMailer.sendVerificationCode(email, code); } // 이메일 인증번호 검증 diff --git a/src/main/java/ditda/backend/domain/common/auth/notification/EmailVerificationMailer.java b/src/main/java/ditda/backend/domain/common/auth/notification/EmailVerificationMailer.java new file mode 100644 index 0000000..1ff6bd6 --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/auth/notification/EmailVerificationMailer.java @@ -0,0 +1,22 @@ +package ditda.backend.domain.common.auth.notification; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import ditda.backend.global.email.EmailSender; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class EmailVerificationMailer { + + private static final String SUBJECT = "[DITDA] 이메일 인증 μ½”λ“œ"; + private static final String TEMPLATE = "email/verification-code"; + + private final EmailSender emailSender; + + public void sendVerificationCode(String to, String code) { + emailSender.send(to, SUBJECT, TEMPLATE, Map.of("code", code)); + } +} diff --git a/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupMailer.java b/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupMailer.java new file mode 100644 index 0000000..5378d6f --- /dev/null +++ b/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupMailer.java @@ -0,0 +1,34 @@ +package ditda.backend.domain.designer.auth.notification; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Component; + +import ditda.backend.global.email.EmailSender; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class DesignerSignupMailer { + + private static final String SUBJECT = "[DITDA] μƒˆ λ””μžμ΄λ„ˆ κ°€μž… κ²€ν†  μš”μ²­"; + private static final String TEMPLATE = "email/designer-signup-notification"; + + private final EmailSender emailSender; + + public void sendAdminNotification( + String adminEmail, + Long userId, + String designerName, + String designerEmail, + List portfolioUrls + ) { + emailSender.send(adminEmail, SUBJECT, TEMPLATE, Map.of( + "userId", userId, + "name", designerName, + "email", designerEmail, + "portfolioUrls", portfolioUrls + )); + } +} diff --git a/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupNotifier.java b/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupNotifier.java index 007481c..66dbc68 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupNotifier.java +++ b/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupNotifier.java @@ -8,7 +8,6 @@ import ditda.backend.domain.designer.auth.event.DesignerSignedUpEvent; import ditda.backend.global.config.AdminProperties; -import ditda.backend.global.email.EmailSender; import ditda.backend.global.s3.S3UrlResolver; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,19 +17,19 @@ @RequiredArgsConstructor public class DesignerSignupNotifier { - private final EmailSender emailSender; + private final DesignerSignupMailer designerSignupMailer; private final AdminProperties adminProperties; private final S3UrlResolver s3UrlResolver; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void onDesignerSignupNotification(DesignerSignedUpEvent event) { + public void onDesignerSignedUp(DesignerSignedUpEvent event) { try { List portfolioUrls = event.portfolioKeys().stream() .map(s3UrlResolver::toPublicS3Url) .toList(); - emailSender.sendDesignerSignupNotification( + designerSignupMailer.sendAdminNotification( adminProperties.getNotificationEmail(), event.userId(), event.name(), diff --git a/src/main/java/ditda/backend/global/email/EmailSender.java b/src/main/java/ditda/backend/global/email/EmailSender.java index 7ff586f..1d02652 100644 --- a/src/main/java/ditda/backend/global/email/EmailSender.java +++ b/src/main/java/ditda/backend/global/email/EmailSender.java @@ -1,6 +1,6 @@ package ditda.backend.global.email; -import java.util.List; +import java.util.Map; import org.springframework.core.io.ClassPathResource; import org.springframework.mail.javamail.JavaMailSender; @@ -19,12 +19,6 @@ @Slf4j public class EmailSender { - private static final String VERIFICATION_SUBJECT = "[DITDA] 이메일 인증 μ½”λ“œ"; - private static final String VERIFICATION_TEMPLATE = "email/verification-code"; - - private static final String DESIGNER_SIGNUP_SUBJECT = "[DITDA] μƒˆ λ””μžμ΄λ„ˆ κ°€μž… κ²€ν†  μš”μ²­"; - private static final String DESIGNER_SIGNUP_TEMPLATE = "email/designer-signup-notification"; - private static final String LOGO_CID = "logoImage"; private static final String LOGO_PATH = "email-images/logo.png"; @@ -32,63 +26,28 @@ public class EmailSender { private final SpringTemplateEngine templateEngine; @Async - public void sendVerificationEmail(String email, String code) { + public void send(String to, String subject, String templateName, Map variables) { try { MimeMessage mimeMessage = mailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); - helper.setTo(email); - helper.setSubject(VERIFICATION_SUBJECT); - helper.setText(buildHtml(code), true); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(renderTemplate(templateName, variables), true); helper.addInline(LOGO_CID, new ClassPathResource(LOGO_PATH)); mailSender.send(mimeMessage); - log.info("Verification email sent. to={}", email); + log.info("Email sent. to={}, template={}", to, templateName); } catch (Exception e) { - log.error("Verification email send failed. to={}", email, e); + log.error("Email send failed. to={}, template={}", to, templateName, e); } } - @Async - public void sendDesignerSignupNotification( - String toEmail, - Long userId, - String designerName, - String designerEmail, - List portfolioUrls - ) { - try { - MimeMessage mimeMessage = mailSender.createMimeMessage(); - MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); - - helper.setTo(toEmail); - helper.setSubject(DESIGNER_SIGNUP_SUBJECT); - helper.setText(buildDesignerSignupHtml(userId, designerName, designerEmail, portfolioUrls), true); - - helper.addInline(LOGO_CID, new ClassPathResource(LOGO_PATH)); - - mailSender.send(mimeMessage); - - log.info("Designer signup notification email sent. designerEmail={}", designerEmail); - } catch (Exception e) { - log.error("Designer signup notification email send failed. designerEmail={}", designerEmail, e); - } - } - - private String buildHtml(String code) { - Context context = new Context(); - context.setVariable("code", code); - return templateEngine.process(VERIFICATION_TEMPLATE, context); - } - - private String buildDesignerSignupHtml(Long userId, String name, String email, List portfolioUrls) { + private String renderTemplate(String templateName, Map variables) { Context context = new Context(); - context.setVariable("userId", userId); - context.setVariable("name", name); - context.setVariable("email", email); - context.setVariable("portfolioUrls", portfolioUrls); - return templateEngine.process(DESIGNER_SIGNUP_TEMPLATE, context); + variables.forEach(context::setVariable); + return templateEngine.process(templateName, context); } } From 752f3b57aeb3a6cc53ccb69b57d1c26fd6b340d6 Mon Sep 17 00:00:00 2001 From: fervovita Date: Tue, 26 May 2026 01:36:01 +0900 Subject: [PATCH 73/90] =?UTF-8?q?Feat(designer):=20=ED=8F=AC=ED=8A=B8?= =?UTF-8?q?=ED=8F=B4=EB=A6=AC=EC=98=A4=20=EC=97=86=EB=8A=94=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EB=B0=9C=EC=86=A1=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../designer/auth/notification/DesignerSignupNotifier.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupNotifier.java b/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupNotifier.java index 66dbc68..be790b3 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupNotifier.java +++ b/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupNotifier.java @@ -24,6 +24,11 @@ public class DesignerSignupNotifier { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onDesignerSignedUp(DesignerSignedUpEvent event) { + // ν¬νŠΈν΄λ¦¬μ˜€κ°€ μ—†μœΌλ©΄ 메일 전솑 X + if (event.portfolioKeys().isEmpty()) { + return; + } + try { List portfolioUrls = event.portfolioKeys().stream() .map(s3UrlResolver::toPublicS3Url) From 1bcfbf67d71e435607d4cb732940f598ed51aae3 Mon Sep 17 00:00:00 2001 From: fervovita Date: Tue, 26 May 2026 01:40:17 +0900 Subject: [PATCH 74/90] =?UTF-8?q?Refactor(designer):=20isAgreed=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EC=9D=84=20Boolean=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/designer/auth/dto/request/DesignerSignupRequest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/ditda/backend/domain/designer/auth/dto/request/DesignerSignupRequest.java b/src/main/java/ditda/backend/domain/designer/auth/dto/request/DesignerSignupRequest.java index 00ad560..11339de 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/dto/request/DesignerSignupRequest.java +++ b/src/main/java/ditda/backend/domain/designer/auth/dto/request/DesignerSignupRequest.java @@ -100,7 +100,7 @@ public record TermRequest( @Schema(description = "μ•½κ΄€ λ™μ˜ μ—¬λΆ€", example = "true") @NotNull(message = "μ•½κ΄€ λ™μ˜ μ—¬λΆ€λŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") @JsonProperty("isAgreed") - boolean isAgreed + Boolean isAgreed ) { } From 628ef9ee4065f579472921d400f0ff6e0a6c5f44 Mon Sep 17 00:00:00 2001 From: fervovita Date: Tue, 26 May 2026 01:43:13 +0900 Subject: [PATCH 75/90] =?UTF-8?q?Refactor(instructor):=20isAgreed=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EC=9D=84=20Boolean=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InstructorSignupRequest의 isAgreed ν•„λ“œλ₯Ό booleanμ—μ„œ Boolean으둜 λ³€κ²½ - μ•½κ΄€ λ™μ˜ μ—¬λΆ€ 검증 둜직과 일관성 μœ μ§€ - Nullable 처리 ν•„μš”μ— λ”°λ₯Έ νƒ€μž… λ³€κ²½ 적용 --- .../dto/request/InstructorSignupRequest.java | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/main/java/ditda/backend/domain/instructor/auth/dto/request/InstructorSignupRequest.java b/src/main/java/ditda/backend/domain/instructor/auth/dto/request/InstructorSignupRequest.java index 865435e..89c288f 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/dto/request/InstructorSignupRequest.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/dto/request/InstructorSignupRequest.java @@ -20,29 +20,29 @@ public record InstructorSignupRequest( @Schema( description = "μ•½κ΄€", example = """ - [ - { - "type": "SERVICE", - "version": "V1.0", - "isAgreed": true - }, - { - "type": "USERINFO", - "version": "V1.0", - "isAgreed": true - }, - { - "type": "SETTLEMENT", - "version": "V1.0", - "isAgreed": true - }, - { - "type": "DISINTERMEDIATION", - "version": "V1.0", - "isAgreed": true - } - ] - """ + [ + { + "type": "SERVICE", + "version": "V1.0", + "isAgreed": true + }, + { + "type": "USERINFO", + "version": "V1.0", + "isAgreed": true + }, + { + "type": "SETTLEMENT", + "version": "V1.0", + "isAgreed": true + }, + { + "type": "DISINTERMEDIATION", + "version": "V1.0", + "isAgreed": true + } + ] + """ ) @NotEmpty(message = "μ•½κ΄€ λ™μ˜ μ—¬λΆ€ λ‚΄μš©μ€ ν•„μˆ˜μž…λ‹ˆλ‹€") @Valid @@ -95,7 +95,7 @@ public record TermRequest( @Schema(description = "μ•½κ΄€ λ™μ˜ μ—¬λΆ€", example = "true") @NotNull(message = "μ•½κ΄€ λ™μ˜ μ—¬λΆ€λŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") @JsonProperty("isAgreed") - boolean isAgreed + Boolean isAgreed ) { } } From 5428730c1bad479db5f8a2d0271427a9313ce208 Mon Sep 17 00:00:00 2001 From: fervovita Date: Tue, 26 May 2026 02:02:59 +0900 Subject: [PATCH 76/90] =?UTF-8?q?Refactor:=20S3UrlResolver=EC=9D=98=20URL?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ditda/backend/global/s3/S3UrlResolver.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/java/ditda/backend/global/s3/S3UrlResolver.java b/src/main/java/ditda/backend/global/s3/S3UrlResolver.java index b4f9ed9..498cb86 100644 --- a/src/main/java/ditda/backend/global/s3/S3UrlResolver.java +++ b/src/main/java/ditda/backend/global/s3/S3UrlResolver.java @@ -1,6 +1,9 @@ package ditda.backend.global.s3; +import java.util.Arrays; + import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; import lombok.RequiredArgsConstructor; @@ -11,9 +14,13 @@ public class S3UrlResolver { private final S3Properties s3Properties; public String toPublicS3Url(String key) { - String baseUrl = s3Properties.getPublicBaseUrl(); - String trimmedBase = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; - String trimmedKey = key.startsWith("/") ? key.substring(1) : key; - return trimmedBase + "/" + trimmedKey; + String[] segments = Arrays.stream(key.split("/")) + .filter(s -> !s.isEmpty()) + .toArray(String[]::new); + + return UriComponentsBuilder.fromUriString(s3Properties.getPublicBaseUrl()) + .pathSegment(segments) + .build() + .toUriString(); } } From 9a9304f2ffaee3da75cad488a1f5780fa417265e Mon Sep 17 00:00:00 2001 From: fervovita Date: Tue, 26 May 2026 21:23:25 +0900 Subject: [PATCH 77/90] =?UTF-8?q?Refactor(designer):=20Portfolio=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=97=90=EC=84=9C=20User=EB=A5=BC=20Designer?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/designer/auth/entity/Designer.java | 6 +++--- .../backend/domain/designer/auth/entity/Portfolio.java | 9 ++++----- .../designer/auth/service/DesignerAuthService.java | 2 +- .../domain/designer/auth/service/PortfolioService.java | 6 +++--- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/main/java/ditda/backend/domain/designer/auth/entity/Designer.java b/src/main/java/ditda/backend/domain/designer/auth/entity/Designer.java index 376e683..47eaf49 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/entity/Designer.java +++ b/src/main/java/ditda/backend/domain/designer/auth/entity/Designer.java @@ -30,12 +30,12 @@ public class Designer extends BaseEntity { @Id - @Column(name = "user_id") + @Column(name = "designer_id") private Long id; - @MapsId @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") + @MapsId + @JoinColumn(name = "designer_id") private User user; @Builder.Default diff --git a/src/main/java/ditda/backend/domain/designer/auth/entity/Portfolio.java b/src/main/java/ditda/backend/domain/designer/auth/entity/Portfolio.java index 8c19fbd..5063d5c 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/entity/Portfolio.java +++ b/src/main/java/ditda/backend/domain/designer/auth/entity/Portfolio.java @@ -1,6 +1,5 @@ package ditda.backend.domain.designer.auth.entity; -import ditda.backend.domain.common.user.entity.User; import ditda.backend.global.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -31,15 +30,15 @@ public class Portfolio extends BaseEntity { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private User user; + @JoinColumn(name = "designer_id", nullable = false) + private Designer designer; @Column(name = "portfolio_url", nullable = false) private String portfolioUrl; - public static Portfolio createPortfolio(User user, String portfolioUrl) { + public static Portfolio createPortfolio(Designer designer, String portfolioUrl) { return Portfolio.builder() - .user(user) + .designer(designer) .portfolioUrl(portfolioUrl) .build(); } diff --git a/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java b/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java index a26d0fe..752e2ce 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java +++ b/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java @@ -67,7 +67,7 @@ public DesignerAuthResult signup(DesignerSignupRequest request, List por designerRepository.save(designer); // 포트폴리였 S3 keyλ₯Ό DB에 일괄 μ €μž₯ - portfolioService.savePortfolios(user, portfolioKeys); + portfolioService.savePortfolios(designer, portfolioKeys); // accessToken&refreshToken λ°œκΈ‰ AuthResult tokens = authService.issueTokens(user.getId()); diff --git a/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java b/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java index 5b9cfba..0808a5e 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java +++ b/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java @@ -9,7 +9,7 @@ import org.springframework.util.unit.DataSize; import org.springframework.web.multipart.MultipartFile; -import ditda.backend.domain.common.user.entity.User; +import ditda.backend.domain.designer.auth.entity.Designer; import ditda.backend.domain.designer.auth.entity.Portfolio; import ditda.backend.domain.designer.auth.exception.DesignerErrorCode; import ditda.backend.domain.designer.auth.repository.PortfolioRepository; @@ -70,14 +70,14 @@ public List uploadFiles(List files) { } @Transactional - public void savePortfolios(User user, List portfolioKeys) { + public void savePortfolios(Designer designer, List portfolioKeys) { if (portfolioKeys.isEmpty()) { return; } // νŒŒμΌμ„ DB에 일괄 μ €μž₯ List portfolios = portfolioKeys.stream() - .map(key -> Portfolio.createPortfolio(user, key)) + .map(key -> Portfolio.createPortfolio(designer, key)) .toList(); portfolioRepository.saveAll(portfolios); } From e79fe33125b163cd6344d2e636c4a4c8740983b9 Mon Sep 17 00:00:00 2001 From: fervovita Date: Tue, 26 May 2026 21:24:14 +0900 Subject: [PATCH 78/90] =?UTF-8?q?Refactor(instructor):=20Instructor=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=EC=97=90=20BaseEntity=20=EC=83=81?= =?UTF-8?q?=EC=86=8D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/instructor/auth/entity/Instructor.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/ditda/backend/domain/instructor/auth/entity/Instructor.java b/src/main/java/ditda/backend/domain/instructor/auth/entity/Instructor.java index 9f26a08..cca971c 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/entity/Instructor.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/entity/Instructor.java @@ -1,6 +1,7 @@ package ditda.backend.domain.instructor.auth.entity; import ditda.backend.domain.common.user.entity.User; +import ditda.backend.global.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -21,7 +22,7 @@ @Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Instructor { +public class Instructor extends BaseEntity { @Id @Column(name = "instructor_id") From 3f0f28e806a2e10102c537c2083183d27bf542ac Mon Sep 17 00:00:00 2001 From: fervovita Date: Tue, 26 May 2026 21:29:55 +0900 Subject: [PATCH 79/90] =?UTF-8?q?Refactor:=20AES=20=EC=95=94=ED=98=B8?= =?UTF-8?q?=ED=99=94=EC=97=90=EC=84=9C=20hashKey=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 - .../java/ditda/backend/global/encryption/AesEncryptor.java | 7 ------- src/main/resources/application.yaml | 1 - src/test/resources/application-test.yaml | 1 - 4 files changed, 10 deletions(-) diff --git a/.env.example b/.env.example index 1870b63..27f33a9 100644 --- a/.env.example +++ b/.env.example @@ -25,7 +25,6 @@ S3_PUBLIC_BASE_URL=http://localhost:9000/your_bucket_name JWT_SECRET_KEY=your_jwt_secret_key_here_base64url_encoded JWT_ACCESS_TOKEN_EXPIRATION=3600000 JWT_REFRESH_TOKEN_EXPIRATION=1209600000 -JWT_REFRESH_TOKEN_HASH_SECRET=your_refresh_token_hash_secret_here JWT_COOKIE_SECURE=false JWT_REFRESH_COOKIE_PATH=/api/v1/auth diff --git a/src/main/java/ditda/backend/global/encryption/AesEncryptor.java b/src/main/java/ditda/backend/global/encryption/AesEncryptor.java index d3f5b13..abf89c0 100644 --- a/src/main/java/ditda/backend/global/encryption/AesEncryptor.java +++ b/src/main/java/ditda/backend/global/encryption/AesEncryptor.java @@ -30,11 +30,7 @@ public class AesEncryptor { @Value("${encrypt.secret-key}") private String secretKey; - @Value("${encrypt.hash-key}") - private String hashKey; - private SecretKeySpec secretKeySpec; - private SecretKeySpec hmacKeySpec; @PostConstruct public void init() { @@ -43,9 +39,6 @@ public void init() { .digest(secretKey.getBytes(StandardCharsets.UTF_8)); this.secretKeySpec = new SecretKeySpec(keyBytes, "AES"); - byte[] hmacBytes = MessageDigest.getInstance("SHA-256") - .digest(hashKey.getBytes(StandardCharsets.UTF_8)); - this.hmacKeySpec = new SecretKeySpec(hmacBytes, "HmacSHA256"); } catch (Exception e) { throw new IllegalStateException("Failed to initialize AES encryption", e); } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 15381a1..081218c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -47,7 +47,6 @@ jwt: encrypt: secret-key: ${ENCRYPT_SECRET_KEY} - hash-key: ${ENCRYPT_HASH_KEY} admin: notification-email: ${ADMIN_NOTIFICATION_EMAIL} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index b6164cd..50a6324 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -56,7 +56,6 @@ jwt: encrypt: secret-key: test-encrypt-secret-key-only-for-ci-environment - hash-key: test-encrypt-hash-key-only-for-ci-environment swagger: server-url: http://localhost:8080 From d9794dc82b7a8c8b3d493ead3480575b5cb0a848 Mon Sep 17 00:00:00 2001 From: fervovita Date: Wed, 27 May 2026 13:18:14 +0900 Subject: [PATCH 80/90] =?UTF-8?q?Feat:=20=EB=B2=84=ED=82=B7=EC=9D=84=20pub?= =?UTF-8?q?lic/private=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=ED=8F=B4=EB=A6=AC=EC=98=A4=EC=97=90=20presigned=20URL?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 +- .../notification/DesignerSignupNotifier.java | 9 +++-- .../auth/service/PortfolioService.java | 5 ++- .../backend/global/s3/S3FileUploader.java | 29 +++++++++----- .../global/s3/S3PresignedUrlGenerator.java | 39 +++++++++++++++++++ .../ditda/backend/global/s3/S3Properties.java | 6 ++- .../backend/global/s3/S3UrlResolver.java | 19 ++++----- .../backend/global/s3/enums/BucketType.java | 6 +++ src/main/resources/application.yaml | 5 ++- .../email/designer-signup-notification.html | 11 +++++- src/test/resources/application-test.yaml | 5 ++- 11 files changed, 103 insertions(+), 35 deletions(-) create mode 100644 src/main/java/ditda/backend/global/s3/S3PresignedUrlGenerator.java create mode 100644 src/main/java/ditda/backend/global/s3/enums/BucketType.java diff --git a/.env.example b/.env.example index 27f33a9..6d36d95 100644 --- a/.env.example +++ b/.env.example @@ -18,8 +18,8 @@ S3_ENDPOINT=http://localhost:9000 S3_REGION=ap-northeast-2 S3_ACCESS_KEY=your_access_key S3_SECRET_KEY=your_secret_key -S3_BUCKET=your_bucket_name -S3_PUBLIC_BASE_URL=http://localhost:9000/your_bucket_name +S3_PUBLIC_BUCKET=your_public_bucket_name +S3_PRIVATE_BUCKET=your_private_bucket_name # JWT JWT_SECRET_KEY=your_jwt_secret_key_here_base64url_encoded diff --git a/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupNotifier.java b/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupNotifier.java index be790b3..5f7a102 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupNotifier.java +++ b/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupNotifier.java @@ -1,5 +1,6 @@ package ditda.backend.domain.designer.auth.notification; +import java.time.Duration; import java.util.List; import org.springframework.stereotype.Component; @@ -8,7 +9,7 @@ import ditda.backend.domain.designer.auth.event.DesignerSignedUpEvent; import ditda.backend.global.config.AdminProperties; -import ditda.backend.global.s3.S3UrlResolver; +import ditda.backend.global.s3.S3PresignedUrlGenerator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -17,9 +18,11 @@ @RequiredArgsConstructor public class DesignerSignupNotifier { + private static final Duration ADMIN_DOWNLOAD_URL_TTL = Duration.ofHours(6); // URL μ΅œλŒ€ μœ νš¨κΈ°κ°„ 6μ‹œκ°„ + private final DesignerSignupMailer designerSignupMailer; private final AdminProperties adminProperties; - private final S3UrlResolver s3UrlResolver; + private final S3PresignedUrlGenerator presignedUrlGenerator; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onDesignerSignedUp(DesignerSignedUpEvent event) { @@ -31,7 +34,7 @@ public void onDesignerSignedUp(DesignerSignedUpEvent event) { try { List portfolioUrls = event.portfolioKeys().stream() - .map(s3UrlResolver::toPublicS3Url) + .map(key -> presignedUrlGenerator.generatePrivateGetUrl(key, ADMIN_DOWNLOAD_URL_TTL)) .toList(); designerSignupMailer.sendAdminNotification( diff --git a/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java b/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java index 0808a5e..be4fefe 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java +++ b/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java @@ -15,6 +15,7 @@ import ditda.backend.domain.designer.auth.repository.PortfolioRepository; import ditda.backend.global.apipayload.exception.GeneralException; import ditda.backend.global.s3.S3FileUploader; +import ditda.backend.global.s3.enums.BucketType; import ditda.backend.global.s3.exception.S3UploadException; import lombok.RequiredArgsConstructor; @@ -63,7 +64,7 @@ public void validateFiles(List files) { public List uploadFiles(List files) { try { - return s3FileUploader.uploadAll(S3_KEY_PREFIX, files); + return s3FileUploader.uploadAll(BucketType.PRIVATE, S3_KEY_PREFIX, files); } catch (S3UploadException e) { throw new GeneralException(DesignerErrorCode.PORTFOLIO_UPLOAD_FAILED); } @@ -83,6 +84,6 @@ public void savePortfolios(Designer designer, List portfolioKeys) { } public void deleteFiles(List portfolioKeys) { - s3FileUploader.deleteAll(portfolioKeys); + s3FileUploader.deleteAll(BucketType.PRIVATE, portfolioKeys); } } diff --git a/src/main/java/ditda/backend/global/s3/S3FileUploader.java b/src/main/java/ditda/backend/global/s3/S3FileUploader.java index 812f812..6b11c15 100644 --- a/src/main/java/ditda/backend/global/s3/S3FileUploader.java +++ b/src/main/java/ditda/backend/global/s3/S3FileUploader.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; +import ditda.backend.global.s3.enums.BucketType; import ditda.backend.global.s3.exception.S3UploadException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -23,24 +24,25 @@ public class S3FileUploader { private final S3Client s3Client; private final S3Properties s3Properties; - public String upload(String prefix, MultipartFile file) { + public String upload(BucketType bucketType, String prefix, MultipartFile file) { + String bucket = resolveBucket(bucketType); String key = generateKey(prefix, file); try { s3Client.putObject( - req -> req.bucket(s3Properties.getBucket()) + req -> req.bucket(bucket) .key(key) .contentType(file.getContentType()), RequestBody.fromInputStream(file.getInputStream(), file.getSize()) ); return key; } catch (IOException | SdkException e) { - log.error("Failed to upload file to S3. originalName={}", file.getOriginalFilename(), e); + log.error("Failed to upload file to S3. bucket={}, originalName={}", bucket, file.getOriginalFilename(), e); throw new S3UploadException(file.getOriginalFilename(), e); } } - public List uploadAll(String prefix, List files) { + public List uploadAll(BucketType bucketType, String prefix, List files) { if (files == null || files.isEmpty()) { return List.of(); } @@ -51,29 +53,38 @@ public List uploadAll(String prefix, List files) { if (file.isEmpty()) { continue; } - uploadedKeys.add(upload(prefix, file)); + uploadedKeys.add(upload(bucketType, prefix, file)); } return uploadedKeys; } catch (Exception e) { // λΆ€λΆ„ μ—…λ‘œλ“œλœ 파일 보상 μ‚­μ œ - deleteAll(uploadedKeys); + deleteAll(bucketType, uploadedKeys); throw e; } } - public void deleteAll(List keys) { + public void deleteAll(BucketType bucketType, List keys) { if (keys == null || keys.isEmpty()) { return; } + + String bucket = resolveBucket(bucketType); for (String key : keys) { try { - s3Client.deleteObject(req -> req.bucket(s3Properties.getBucket()).key(key)); + s3Client.deleteObject(req -> req.bucket(bucket).key(key)); } catch (Exception e) { - log.error("S3 delete failed. key={}", key, e); + log.error("S3 delete failed. buket={}, key={}", bucket, key, e); } } } + private String resolveBucket(BucketType bucketType) { + return switch (bucketType) { + case PUBLIC -> s3Properties.getPublicBucket(); + case PRIVATE -> s3Properties.getPrivateBucket(); + }; + } + private String generateKey(String prefix, MultipartFile file) { String extension = extractExtension(file.getOriginalFilename()); return "%s/%s%s".formatted(prefix, UUID.randomUUID(), extension); diff --git a/src/main/java/ditda/backend/global/s3/S3PresignedUrlGenerator.java b/src/main/java/ditda/backend/global/s3/S3PresignedUrlGenerator.java new file mode 100644 index 0000000..f2d6025 --- /dev/null +++ b/src/main/java/ditda/backend/global/s3/S3PresignedUrlGenerator.java @@ -0,0 +1,39 @@ +package ditda.backend.global.s3; + +import java.time.Duration; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; + +@Component +@RequiredArgsConstructor +public class S3PresignedUrlGenerator { + + private final S3Presigner s3Presigner; + private final S3Properties s3Properties; + + public String generatePrivateGetUrl(String key) { + Duration ttl = Duration.ofMinutes(s3Properties.getPresignedUrlTtlMinutes()); + return generatePrivateGetUrl(key, ttl); + } + + public String generatePrivateGetUrl(String key, Duration ttl) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(s3Properties.getPrivateBucket()) + .key(key) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(ttl) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); + return presignedRequest.url().toString(); + } +} diff --git a/src/main/java/ditda/backend/global/s3/S3Properties.java b/src/main/java/ditda/backend/global/s3/S3Properties.java index fc1859d..05d79cb 100644 --- a/src/main/java/ditda/backend/global/s3/S3Properties.java +++ b/src/main/java/ditda/backend/global/s3/S3Properties.java @@ -12,7 +12,9 @@ @ConfigurationProperties(prefix = "app.s3") public class S3Properties { - private String bucket; + private String publicBucket; - private String publicBaseUrl; + private String privateBucket; + + private int presignedUrlTtlMinutes = 10; } diff --git a/src/main/java/ditda/backend/global/s3/S3UrlResolver.java b/src/main/java/ditda/backend/global/s3/S3UrlResolver.java index 498cb86..f77b17d 100644 --- a/src/main/java/ditda/backend/global/s3/S3UrlResolver.java +++ b/src/main/java/ditda/backend/global/s3/S3UrlResolver.java @@ -1,26 +1,23 @@ package ditda.backend.global.s3; -import java.util.Arrays; - import org.springframework.stereotype.Component; -import org.springframework.web.util.UriComponentsBuilder; import lombok.RequiredArgsConstructor; +import software.amazon.awssdk.services.s3.S3Client; @Component @RequiredArgsConstructor public class S3UrlResolver { + private final S3Client s3Client; private final S3Properties s3Properties; public String toPublicS3Url(String key) { - String[] segments = Arrays.stream(key.split("/")) - .filter(s -> !s.isEmpty()) - .toArray(String[]::new); - - return UriComponentsBuilder.fromUriString(s3Properties.getPublicBaseUrl()) - .pathSegment(segments) - .build() - .toUriString(); + return s3Client.utilities() + .getUrl(builder -> builder + .bucket(s3Properties.getPublicBucket()) + .key(key) + ) + .toString(); } } diff --git a/src/main/java/ditda/backend/global/s3/enums/BucketType.java b/src/main/java/ditda/backend/global/s3/enums/BucketType.java new file mode 100644 index 0000000..b046e5b --- /dev/null +++ b/src/main/java/ditda/backend/global/s3/enums/BucketType.java @@ -0,0 +1,6 @@ +package ditda.backend.global.s3.enums; + +public enum BucketType { + PUBLIC, + PRIVATE +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 081218c..36f09ec 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -53,8 +53,9 @@ admin: app: s3: - bucket: ${S3_BUCKET} - public-base-url: ${S3_PUBLIC_BASE_URL} + public-bucket: ${S3_PUBLIC_BUCKET} + private-bucket: ${S3_PRIVATE_BUCKET} + presigned-url-ttl-minutes: ${S3_PRESIGNED_URL_TTL_MINUTES:10} springdoc: swagger-ui: diff --git a/src/main/resources/templates/email/designer-signup-notification.html b/src/main/resources/templates/email/designer-signup-notification.html index bc4d49c..a81035d 100644 --- a/src/main/resources/templates/email/designer-signup-notification.html +++ b/src/main/resources/templates/email/designer-signup-notification.html @@ -33,10 +33,17 @@

μƒˆ λ””μžμ΄λ„ˆ κ°€μž… κ²€ν†  μš”μ²­

포트폴리였
    -
  • - +
  • +
+
+ λ‹€μš΄λ‘œλ“œ λ§ν¬λŠ” μ΅œλŒ€ 6μ‹œκ°„ λ™μ•ˆ μœ νš¨ν•©λ‹ˆλ‹€.
+ 만료된 경우 κ°œλ°œνŒ€μ— λ¬Έμ˜ν•΄μ£Όμ„Έμš”. +

diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 50a6324..a1a3782 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -40,8 +40,9 @@ spring: app: s3: - bucket: test-bucket - public-base-url: http://localhost:9000/test-bucket + public-bucket: test-public-bucket + private-bucket: test-private-bucket + presigned-url-ttl-minutes: 10 admin: notification-email: admin-test@example.com From c95d5ed3f96dec9f6731fc8de54a9dd32cab5f04 Mon Sep 17 00:00:00 2001 From: fervovita Date: Thu, 28 May 2026 00:40:37 +0900 Subject: [PATCH 81/90] =?UTF-8?q?Feat:=20Nginx=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nginx/conf.d/nginx-blue.conf.template | 48 ++++++++++++++++++++++++++ nginx/conf.d/nginx-green.conf.template | 48 ++++++++++++++++++++++++++ nginx/nginx.conf | 26 ++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 nginx/conf.d/nginx-blue.conf.template create mode 100644 nginx/conf.d/nginx-green.conf.template create mode 100644 nginx/nginx.conf diff --git a/nginx/conf.d/nginx-blue.conf.template b/nginx/conf.d/nginx-blue.conf.template new file mode 100644 index 0000000..5d2f4d0 --- /dev/null +++ b/nginx/conf.d/nginx-blue.conf.template @@ -0,0 +1,48 @@ +upstream app { + server blue:8080; +} + +server { + listen 80; + server_name api.ditda.kr; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 308 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + http2 on; + server_name api.ditda.kr; + + ssl_certificate /etc/letsencrypt/live/api.ditda.kr/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api.ditda.kr/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + client_max_body_size 30M; + + location /actuator/health { + proxy_pass http://app/actuator/health; + access_log off; + } + + location / { + proxy_pass http://app; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } +} \ No newline at end of file diff --git a/nginx/conf.d/nginx-green.conf.template b/nginx/conf.d/nginx-green.conf.template new file mode 100644 index 0000000..f1ccb99 --- /dev/null +++ b/nginx/conf.d/nginx-green.conf.template @@ -0,0 +1,48 @@ +upstream app { + server green:8080; +} + +server { + listen 80; + server_name api.ditda.kr; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 308 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + http2 on; + server_name api.ditda.kr; + + ssl_certificate /etc/letsencrypt/live/api.ditda.kr/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api.ditda.kr/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + client_max_body_size 30M; + + location /actuator/health { + proxy_pass http://app/actuator/health; + access_log off; + } + + location / { + proxy_pass http://app; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } +} \ No newline at end of file diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..efd0e51 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,26 @@ +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + tcp_nopush on; + keepalive_timeout 65; + + gzip on; + gzip_min_length 1024; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log warn; + + include /etc/nginx/conf.d/*.conf; +} \ No newline at end of file From b0eae2a049bf003aa6d9470693f1daeba55cfe01 Mon Sep 17 00:00:00 2001 From: fervovita Date: Thu, 28 May 2026 00:41:45 +0900 Subject: [PATCH 82/90] =?UTF-8?q?Feat:=20=EB=B0=B0=ED=8F=AC=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EB=B0=8F=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 8 +- .github/workflows/deploy.yml | 137 ++++++++++++ build.gradle | 27 +++ docker-compose.prod.yaml | 88 ++++++++ scripts/deploy.sh | 198 ++++++++++++++++++ .../backend/global/config/SecurityConfig.java | 1 + src/main/resources/application-prod.yaml | 21 ++ src/main/resources/application.yaml | 9 + 8 files changed, 486 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 docker-compose.prod.yaml create mode 100644 scripts/deploy.sh create mode 100644 src/main/resources/application-prod.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b05d96f..5cd54f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,8 @@ name: CI on: pull_request: branches: [ main, dev ] + push: + branches: [ main ] concurrency: group: ci-${{ github.event.pull_request.number || github.ref }} @@ -14,16 +16,16 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: '21' distribution: 'temurin' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..3cddfa7 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,137 @@ +name: deploy + +on: + workflow_run: + workflows: + - CI + types: + - completed + branches: + - main + +permissions: + id-token: write + contents: read + +env: + AWS_REGION: ap-northeast-2 + ECR_REPOSITORY: ditda-api + +concurrency: + group: deploy-prod + cancel-in-progress: false + +jobs: + build-and-deploy: + if: > + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' + + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.workflow_run.head_sha }} + + - name: Set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + java-version: "21" + distribution: "temurin" + cache: "gradle" + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@acca2b1b2070338fb9fd1ca27ecee81d687e58e5 # v6.1.2 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/ditda-github-actions-role + aws-region: ${{ env.AWS_REGION }} + + - name: Login to ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2.1.5 + + - name: Grant gradlew permission + run: chmod +x gradlew + + - name: Build & push image with Jib + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }} + run: | + ./gradlew jib \ + -Djib.to.image=${ECR_REGISTRY} \ + -Djib.to.tags=latest,${{ github.event.workflow_run.head_sha }} + + - name: Deploy to EC2 via SSM + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }} + IMAGE_TAG: ${{ github.event.workflow_run.head_sha }} + run: | + COMMAND_ID=$(aws ssm send-command \ + --instance-ids "${{ secrets.EC2_INSTANCE_ID }}" \ + --document-name "AWS-RunShellScript" \ + --parameters "commands=[ + 'curl -fsSL https://raw.githubusercontent.com/${{ github.repository }}/${{ github.event.workflow_run.head_sha }}/scripts/deploy.sh -o /tmp/deploy.sh', + 'chmod +x /tmp/deploy.sh', + 'export AWS_REGION=${{ env.AWS_REGION }}', + 'export ECR_REGISTRY=${ECR_REGISTRY}', + 'export IMAGE_TAG=${IMAGE_TAG}', + 'export GITHUB_REPO=${{ github.repository }}', + 'export COMMIT_SHA=${{ github.event.workflow_run.head_sha }}', + 'bash /tmp/deploy.sh' + ]" \ + --timeout-seconds 600 \ + --query "Command.CommandId" \ + --output text) + + echo "Command ID: ${COMMAND_ID}" + + for i in {1..120}; do + STATUS=$(aws ssm get-command-invocation \ + --command-id "${COMMAND_ID}" \ + --instance-id "${{ secrets.EC2_INSTANCE_ID }}" \ + --query "Status" --output text 2>/dev/null || echo "Pending") + echo "Status: ${STATUS}" + + if [ "${STATUS}" = "Success" ]; then + aws ssm get-command-invocation \ + --command-id "${COMMAND_ID}" \ + --instance-id "${{ secrets.EC2_INSTANCE_ID }}" \ + --query "StandardOutputContent" --output text + exit 0 + elif [ "${STATUS}" = "Failed" ] || [ "${STATUS}" = "Cancelled" ] || [ "${STATUS}" = "TimedOut" ]; then + echo "=== STDOUT ===" + aws ssm get-command-invocation \ + --command-id "${COMMAND_ID}" \ + --instance-id "${{ secrets.EC2_INSTANCE_ID }}" \ + --query "StandardOutputContent" --output text + echo "=== STDERR ===" + aws ssm get-command-invocation \ + --command-id "${COMMAND_ID}" \ + --instance-id "${{ secrets.EC2_INSTANCE_ID }}" \ + --query "StandardErrorContent" --output text + exit 1 + fi + sleep 5 + done + + echo "❌ νƒ€μž„μ•„μ›ƒ" + exit 1 + + - name: Send Discord notification on success + if: success() + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }} + run: | + curl -H "Content-Type: application/json" \ + -d "{\"embeds\": [{\"title\": \"πŸš€ DITDA 배포 성곡\", \"description\": \"https://api.ditda.kr\", \"color\": 3066993, \"fields\": [{\"name\": \"Branch\", \"value\": \"${{ github.ref_name }}\", \"inline\": true}, {\"name\": \"Commit\", \"value\": \"\`${GITHUB_SHA:0:7}\`\", \"inline\": true}, {\"name\": \"Author\", \"value\": \"${{ github.actor }}\", \"inline\": true}], \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)\"}]}" \ + $DISCORD_WEBHOOK || true + + - name: Send Discord notification on failure + if: failure() + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }} + run: | + curl -H "Content-Type: application/json" \ + -d "{\"embeds\": [{\"title\": \"❌ DITDA 배포 μ‹€νŒ¨\", \"description\": \"GitHub Actions 둜그 확인 ν•„μš”\", \"color\": 15158332, \"fields\": [{\"name\": \"Branch\", \"value\": \"${{ github.ref_name }}\", \"inline\": true}, {\"name\": \"Commit\", \"value\": \"\`${GITHUB_SHA:0:7}\`\", \"inline\": true}, {\"name\": \"Author\", \"value\": \"${{ github.actor }}\", \"inline\": true}, {\"name\": \"Run\", \"value\": \"[둜그 보기](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})\", \"inline\": false}], \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)\"}]}" \ + $DISCORD_WEBHOOK || true diff --git a/build.gradle b/build.gradle index 0f7fc86..c202c14 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '4.0.6' id 'io.spring.dependency-management' version '1.1.7' + id 'com.google.cloud.tools.jib' version '3.5.3' id 'checkstyle' } @@ -59,6 +60,9 @@ dependencies { testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' + // actuator + implementation 'org.springframework.boot:spring-boot-starter-actuator' + // test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' @@ -76,6 +80,29 @@ checkstyle { maxWarnings = 0 } + +// Jib +jib { + from { + image = 'eclipse-temurin:21-jre-alpine' + platforms { + platform { + architecture = 'amd64' + os = 'linux' + } + } + } + container { + ports = ['8080'] + environment = [ + 'TZ': 'Asia/Seoul' + ] + jvmFlags = ['-Xms256m', '-Xmx512m', '-XX:+UseG1GC'] + creationTime = 'USE_CURRENT_TIMESTAMP' + } +} + + tasks.named('test') { useJUnitPlatform() } diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml new file mode 100644 index 0000000..d1c3458 --- /dev/null +++ b/docker-compose.prod.yaml @@ -0,0 +1,88 @@ +services: + blue: + image: ${ECR_REGISTRY}:${IMAGE_TAG:-latest} + container_name: ditda-blue + restart: unless-stopped + ports: + - "127.0.0.1:8081:8080" + env_file: .env + depends_on: + redis: + condition: service_healthy + networks: + - ditda-net + healthcheck: + test: [ "CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health || exit 1" ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s + + green: + image: ${ECR_REGISTRY}:${IMAGE_TAG:-latest} + container_name: ditda-green + restart: unless-stopped + ports: + - "127.0.0.1:8082:8080" + env_file: .env + depends_on: + redis: + condition: service_healthy + networks: + - ditda-net + healthcheck: + test: [ "CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health || exit 1" ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s + + redis: + image: redis:8.6-alpine + container_name: ditda-redis + restart: unless-stopped + command: + - redis-server + - --requirepass + - ${REDIS_PASSWORD} + - --appendonly + - "yes" + volumes: + - redis-data:/data + networks: + - ditda-net + healthcheck: + test: [ "CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping" ] + interval: 10s + timeout: 3s + retries: 5 + + nginx: + image: nginx:1.27-alpine + container_name: ditda-nginx + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/conf.d:/etc/nginx/conf.d + - ./nginx/certbot/conf:/etc/letsencrypt + - ./nginx/certbot/www:/var/www/certbot + networks: + - ditda-net + + certbot: + image: certbot/certbot:latest + container_name: ditda-certbot + volumes: + - ./nginx/certbot/conf:/etc/letsencrypt + - ./nginx/certbot/www:/var/www/certbot + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" + +networks: + ditda-net: + driver: bridge + +volumes: + redis-data: \ No newline at end of file diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..ba4c674 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,198 @@ +#!/bin/bash +set -euo pipefail + +# === ν•„μˆ˜ ν™˜κ²½λ³€μˆ˜ === +AWS_REGION="${AWS_REGION:-ap-northeast-2}" +ECR_REGISTRY="${ECR_REGISTRY:?ERROR: ECR_REGISTRY must be set}" +IMAGE_TAG="${IMAGE_TAG:-latest}" +GITHUB_REPO="${GITHUB_REPO:?ERROR: GITHUB_REPO must be set}" +COMMIT_SHA="${COMMIT_SHA:-main}" + +APP_DIR="/home/ubuntu/app" +mkdir -p "$APP_DIR/nginx/conf.d" +mkdir -p "$APP_DIR/nginx/certbot/conf" "$APP_DIR/nginx/certbot/www" +cd "$APP_DIR" + +echo "=== Ditda 배포 ===" +echo "Image: ${ECR_REGISTRY}:${IMAGE_TAG}" + +# === μ΅œμ‹  μ„€μ • 파일 fetch (GitHub raw) === +echo "[0/6] μ„€μ • 파일 κ°€μ Έμ˜€κΈ°" +RAW_BASE="https://raw.githubusercontent.com/${GITHUB_REPO}/${COMMIT_SHA}" + +curl -fsSL "${RAW_BASE}/docker-compose.prod.yaml" -o docker-compose.prod.yaml +curl -fsSL "${RAW_BASE}/nginx/nginx.conf" -o nginx/nginx.conf +curl -fsSL "${RAW_BASE}/nginx/conf.d/nginx-blue.conf.template" -o nginx/conf.d/nginx-blue.conf.template +curl -fsSL "${RAW_BASE}/nginx/conf.d/nginx-green.conf.template" -o nginx/conf.d/nginx-green.conf.template + +# === ECR 둜그인 === +echo "[1/6] ECR 둜그인" +REGISTRY_HOST="${ECR_REGISTRY%%/*}" +aws ecr get-login-password --region "${AWS_REGION}" \ + | docker login --username AWS --password-stdin "${REGISTRY_HOST}" + +# === μƒˆ 이미지 pull === +echo "[2/6] 이미지 pull" +docker pull "${ECR_REGISTRY}:${IMAGE_TAG}" + +# === Parameter Storeμ—μ„œ ν™˜κ²½λ³€μˆ˜ fetch === +echo "[3/6] ν™˜κ²½λ³€μˆ˜ λ‘œλ“œ" +> .env + +cat >> .env << EOF +SPRING_PROFILES_ACTIVE=prod +ECR_REGISTRY=${ECR_REGISTRY} +IMAGE_TAG=${IMAGE_TAG} +EOF + +aws ssm get-parameters-by-path \ + --path "/ditda/prod/" \ + --with-decryption \ + --region "${AWS_REGION}" \ + --query "Parameters[*].[Name,Value]" \ + --output text \ + | while IFS=$'\t' read -r name value; do + key="${name##*/}" + echo "${key}=${value}" >> .env + done + +PARAM_COUNT=$(grep -c '=' .env || true) +echo " λ‘œλ“œλœ ν™˜κ²½λ³€μˆ˜: ${PARAM_COUNT}개" + +# === ν•„μˆ˜ ν™˜κ²½λ³€μˆ˜ 검증 === +REQUIRED_VARS=( + # DB + "DB_URL" + "DB_USERNAME" + "DB_PASSWORD" + # Redis + "REDIS_HOST" + "REDIS_PORT" + "REDIS_PASSWORD" + # JWT + "JWT_SECRET_KEY" + "JWT_ACCESS_TOKEN_EXPIRATION" + "JWT_REFRESH_TOKEN_EXPIRATION" + "JWT_REFRESH_TOKEN_HASH_SECRET" + "JWT_COOKIE_SECURE" + "JWT_REFRESH_COOKIE_PATH" + # Mail + "MAIL_HOST" + "MAIL_PORT" + "MAIL_USERNAME" + "MAIL_PASSWORD" + # S3 + "S3_PUBLIC_BUCKET" + "S3_PRIVATE_BUCKET" + # Encrypt + "ENCRYPT_SECRET_KEY" + # Admin + "ADMIN_NOTIFICATION_EMAIL" +) + +MISSING=() +for var in "${REQUIRED_VARS[@]}"; do + if ! grep -qE "^${var}=.+" .env; then + MISSING+=("$var") + fi +done + +if [ ${#MISSING[@]} -gt 0 ]; then + echo "❌ ν•„μˆ˜ ν™˜κ²½λ³€μˆ˜ λˆ„λ½ λ˜λŠ” κ°’ λΉ„μ–΄μžˆμŒ:" + for var in "${MISSING[@]}"; do + echo " - ${var}" + done + exit 1 +fi + +echo " ν•„μˆ˜ ν™˜κ²½λ³€μˆ˜ ${#REQUIRED_VARS[@]}개 λͺ¨λ‘ 쑴재 확인" + +# === 초기 배포 λΆ„κΈ° === +if [ ! -f nginx/conf.d/default.conf ]; then + echo "[4/6] 초기 배포 β€” blue둜 μ‹œμž‘" + cp nginx/conf.d/nginx-blue.conf.template nginx/conf.d/default.conf + docker compose -f docker-compose.prod.yaml up -d redis nginx blue + + echo "[5/6] blue health check" + for i in $(seq 1 60); do + if curl -fs http://localhost:8081/actuator/health > /dev/null 2>&1; then + echo " [$i/60] health check 성곡" + docker compose -f docker-compose.prod.yaml up -d certbot + echo "βœ… 첫 배포 μ™„λ£Œ" + exit 0 + fi + echo " [$i/60] λŒ€κΈ°μ€‘..." + sleep 3 + done + + echo "❌ blue λΆ€νŒ… μ‹€νŒ¨" + docker logs ditda-blue --tail 50 + exit 1 +fi + +# === λ””μŠ€ν¬ 정리 === +docker image prune -f > /dev/null + +# === ν˜„μž¬ active νŒλ‹¨ === +ACTIVE=$(grep -oE 'server (blue|green):8080;' nginx/conf.d/default.conf \ + | head -n 1 | grep -oE 'blue|green') + +if [ "$ACTIVE" = "blue" ]; then + NEW="green" + PORT=8082 +else + NEW="blue" + PORT=8081 +fi + +echo "[4/6] ν˜„μž¬ active: $ACTIVE β†’ μƒˆ 배포: $NEW" + +# === μƒˆ μ»¨ν…Œμ΄λ„ˆ 기동 === +docker compose -f docker-compose.prod.yaml up -d "$NEW" + +# === Health Check === +echo "[5/6] Health Check" +HEALTH_OK=false +for i in $(seq 1 60); do + if curl -fs "http://localhost:${PORT}/actuator/health" > /dev/null 2>&1; then + echo " [$i/60] Health Check 성곡" + HEALTH_OK=true + break + fi + echo " [$i/60] λŒ€κΈ°μ€‘..." + sleep 3 +done + +if [ "$HEALTH_OK" = false ]; then + echo "❌ Health Check μ‹€νŒ¨. μ»¨ν…Œμ΄λ„ˆ 둜그:" + docker logs "ditda-${NEW}" --tail 50 + docker compose -f docker-compose.prod.yaml stop "$NEW" + exit 1 +fi + +# === Nginx ν…œν”Œλ¦Ώ ꡐ체 === +echo "[6/6] νŠΈλž˜ν”½ μ „ν™˜ ($ACTIVE β†’ $NEW)" +cp "nginx/conf.d/nginx-${NEW}.conf.template" "nginx/conf.d/default.conf" + +if ! docker exec ditda-nginx nginx -t 2>/dev/null; then + echo "❌ Nginx μ„€μ • 검증 μ‹€νŒ¨. λ‘€λ°±" + cp "nginx/conf.d/nginx-${ACTIVE}.conf.template" "nginx/conf.d/default.conf" + docker compose -f docker-compose.prod.yaml stop "$NEW" + exit 1 +fi + +if ! docker exec ditda-nginx nginx -s reload; then + echo "❌ Nginx reload μ‹€νŒ¨. λ‘€λ°±" + cp "nginx/conf.d/nginx-${ACTIVE}.conf.template" "nginx/conf.d/default.conf" + docker exec ditda-nginx nginx -s reload + docker compose -f docker-compose.prod.yaml stop "$NEW" + exit 1 +fi + +# === 이전 μ»¨ν…Œμ΄λ„ˆ μ’…λ£Œ === +sleep 5 +docker compose -f docker-compose.prod.yaml stop "$ACTIVE" + +docker system prune -f > /dev/null + +echo "βœ… 배포 μ™„λ£Œ ($ACTIVE β†’ $NEW)" \ No newline at end of file diff --git a/src/main/java/ditda/backend/global/config/SecurityConfig.java b/src/main/java/ditda/backend/global/config/SecurityConfig.java index cf286a6..7015d3b 100644 --- a/src/main/java/ditda/backend/global/config/SecurityConfig.java +++ b/src/main/java/ditda/backend/global/config/SecurityConfig.java @@ -60,6 +60,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.POST, "/api/v1/auth/**").permitAll() .requestMatchers(HttpMethod.POST, "/api/v1/instructors/auth/signup").permitAll() .requestMatchers(HttpMethod.POST, "/api/v1/designers/auth/signup").permitAll() + .requestMatchers(HttpMethod.GET, "/actuator/health").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml new file mode 100644 index 0000000..489af44 --- /dev/null +++ b/src/main/resources/application-prod.yaml @@ -0,0 +1,21 @@ +spring: + jpa: + hibernate: + ddl-auto: update # TODO: validate둜 λ³€κ²½ + properties: + hibernate: + format_sql: false + default_batch_fetch_size: 100 + show-sql: false + open-in-view: false + + cloud: + aws: + region: + static: ap-northeast-2 + s3: + path-style-access-enabled: false + +swagger: + server-url: https://api.ditda.kr + server-description: Production Server \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 36f09ec..324f3c6 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -57,6 +57,15 @@ app: private-bucket: ${S3_PRIVATE_BUCKET} presigned-url-ttl-minutes: ${S3_PRESIGNED_URL_TTL_MINUTES:10} +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + show-details: never + springdoc: swagger-ui: path: /swagger-ui.html From 3781ce238101cca66bf2609646c5d4bbe18ce96e Mon Sep 17 00:00:00 2001 From: fervovita Date: Thu, 28 May 2026 01:13:16 +0900 Subject: [PATCH 83/90] =?UTF-8?q?Chore:=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=97=90=EC=84=9C=20persist-credentials=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 2 ++ .github/workflows/deploy.yml | 1 + 2 files changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cd54f3..85d30e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Set up JDK 21 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3cddfa7..1cb63d1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -33,6 +33,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.workflow_run.head_sha }} + persist-credentials: false - name: Set up JDK 21 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 From 9cf0cfd89007cb546d1169c376f826e8829394cb Mon Sep 17 00:00:00 2001 From: fervovita Date: Thu, 28 May 2026 01:17:41 +0900 Subject: [PATCH 84/90] =?UTF-8?q?Chore:=20=EB=B0=B0=ED=8F=AC=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=EC=97=90=20.env=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EA=B6=8C=ED=95=9C=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 --- scripts/deploy.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/deploy.sh b/scripts/deploy.sh index ba4c674..c06bb0b 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -38,6 +38,7 @@ docker pull "${ECR_REGISTRY}:${IMAGE_TAG}" # === Parameter Storeμ—μ„œ ν™˜κ²½λ³€μˆ˜ fetch === echo "[3/6] ν™˜κ²½λ³€μˆ˜ λ‘œλ“œ" > .env +chmod 600 .env cat >> .env << EOF SPRING_PROFILES_ACTIVE=prod From 62f84b832cdb69343ec83de70f3511cdb3b4a72f Mon Sep 17 00:00:00 2001 From: fervovita Date: Thu, 28 May 2026 01:41:07 +0900 Subject: [PATCH 85/90] =?UTF-8?q?Chore:=20=EB=A1=9C=EA=B7=B8=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/ditda/backend/global/s3/S3FileUploader.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/ditda/backend/global/s3/S3FileUploader.java b/src/main/java/ditda/backend/global/s3/S3FileUploader.java index 6b11c15..12a1629 100644 --- a/src/main/java/ditda/backend/global/s3/S3FileUploader.java +++ b/src/main/java/ditda/backend/global/s3/S3FileUploader.java @@ -73,7 +73,7 @@ public void deleteAll(BucketType bucketType, List keys) { try { s3Client.deleteObject(req -> req.bucket(bucket).key(key)); } catch (Exception e) { - log.error("S3 delete failed. buket={}, key={}", bucket, key, e); + log.error("S3 delete failed. bucket={}, key={}", bucket, key, e); } } } From d21c2b0aba4dbc8b85b17e720c64e5af9a26c9ee Mon Sep 17 00:00:00 2001 From: fervovita Date: Thu, 28 May 2026 13:42:34 +0900 Subject: [PATCH 86/90] =?UTF-8?q?Feat:=20=EB=94=94=EC=9E=90=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=97=90=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EB=B0=8F=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/DesignerAuthController.java | 10 +++++++++- .../domain/designer/auth/dto/DesignerAuthResult.java | 4 ++++ .../auth/dto/response/DesignerSignupResponse.java | 6 ++++++ .../designer/auth/service/DesignerAuthService.java | 9 +++++++-- .../java/ditda/backend/global/s3/S3UrlResolver.java | 4 ++++ 5 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/main/java/ditda/backend/domain/designer/auth/controller/DesignerAuthController.java b/src/main/java/ditda/backend/domain/designer/auth/controller/DesignerAuthController.java index aee323f..9cbc9c6 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/controller/DesignerAuthController.java +++ b/src/main/java/ditda/backend/domain/designer/auth/controller/DesignerAuthController.java @@ -15,6 +15,7 @@ import ditda.backend.domain.designer.auth.dto.response.DesignerSignupResponse; import ditda.backend.domain.designer.auth.facade.DesignerAuthFacade; import ditda.backend.global.apipayload.response.ApiResponse; +import ditda.backend.global.s3.S3UrlResolver; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Encoding; @@ -31,6 +32,7 @@ public class DesignerAuthController { private final DesignerAuthFacade designerAuthFacade; + private final S3UrlResolver s3UrlResolver; @Operation( summary = "λ””μžμ΄λ„ˆ νšŒμ›κ°€μž…", @@ -53,6 +55,12 @@ public ApiResponse signup( response.addHeader(HttpHeaders.SET_COOKIE, result.refreshTokenCookie().toString()); - return ApiResponse.onSuccess("λ””μžμ΄λ„ˆ νšŒμ›κ°€μž… 성곡", new DesignerSignupResponse(result.userId(), result.accessToken())); + DesignerSignupResponse signupResponse = new DesignerSignupResponse( + result.userId(), + result.name(), + s3UrlResolver.toPublicS3Url(result.profileImage()), + result.accessToken()); + + return ApiResponse.onSuccess("λ””μžμ΄λ„ˆ νšŒμ›κ°€μž… 성곡", signupResponse); } } diff --git a/src/main/java/ditda/backend/domain/designer/auth/dto/DesignerAuthResult.java b/src/main/java/ditda/backend/domain/designer/auth/dto/DesignerAuthResult.java index c58462d..e8310b0 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/dto/DesignerAuthResult.java +++ b/src/main/java/ditda/backend/domain/designer/auth/dto/DesignerAuthResult.java @@ -6,6 +6,10 @@ public record DesignerAuthResult( Long userId, + String name, + + String profileImage, + String accessToken, ResponseCookie refreshTokenCookie diff --git a/src/main/java/ditda/backend/domain/designer/auth/dto/response/DesignerSignupResponse.java b/src/main/java/ditda/backend/domain/designer/auth/dto/response/DesignerSignupResponse.java index 9e2e3bc..c9dcfa3 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/dto/response/DesignerSignupResponse.java +++ b/src/main/java/ditda/backend/domain/designer/auth/dto/response/DesignerSignupResponse.java @@ -8,6 +8,12 @@ public record DesignerSignupResponse( @Schema(description = "User ID") Long userId, + @Schema(description = "μ‚¬μš©μž 이름", example = "홍길동") + String name, + + @Schema(description = "ν”„λ‘œν•„ 이미지", example = "https://example.com/profile/default.png") + String profileImageUrl, + @Schema(description = "Access Token") String accessToken ) { diff --git a/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java b/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java index 752e2ce..3166ace 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java +++ b/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java @@ -26,7 +26,7 @@ @RequiredArgsConstructor public class DesignerAuthService { - private static final String DEFAULT_PROFILE_IMAGE = ""; + private static final String DEFAULT_PROFILE_IMAGE = "profile/default.png"; private final DesignerRepository designerRepository; private final UserService userService; @@ -80,7 +80,12 @@ public DesignerAuthResult signup(DesignerSignupRequest request, List por portfolioKeys )); - return new DesignerAuthResult(user.getId(), tokens.accessToken(), tokens.refreshTokenCookie()); + return new DesignerAuthResult( + user.getId(), + user.getName(), + user.getProfileImage(), + tokens.accessToken(), + tokens.refreshTokenCookie()); } private List toAgreements(List terms) { diff --git a/src/main/java/ditda/backend/global/s3/S3UrlResolver.java b/src/main/java/ditda/backend/global/s3/S3UrlResolver.java index f77b17d..f8d53d6 100644 --- a/src/main/java/ditda/backend/global/s3/S3UrlResolver.java +++ b/src/main/java/ditda/backend/global/s3/S3UrlResolver.java @@ -13,6 +13,10 @@ public class S3UrlResolver { private final S3Properties s3Properties; public String toPublicS3Url(String key) { + if (key == null || key.isEmpty()) { + return key; + } + return s3Client.utilities() .getUrl(builder -> builder .bucket(s3Properties.getPublicBucket()) From 863d90571edf4e4a8bcd5df90e8b4b46c48b7def Mon Sep 17 00:00:00 2001 From: fervovita Date: Thu, 28 May 2026 14:40:18 +0900 Subject: [PATCH 87/90] =?UTF-8?q?Refactor:=20ResponseCookie=EB=A5=BC=20Ser?= =?UTF-8?q?vice=EC=97=90=EC=84=9C=20Controller=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 31 ++++++++++++++----- .../domain/common/auth/dto/AuthResult.java | 4 +-- .../domain/common/auth/dto/TokenResult.java | 7 +++++ .../domain/common/auth/facade/AuthFacade.java | 8 ++--- .../common/auth/service/AuthService.java | 28 ++++++++--------- .../controller/DesignerAuthController.java | 8 ++++- .../designer/auth/dto/DesignerAuthResult.java | 4 +-- .../auth/service/DesignerAuthService.java | 6 ++-- .../controller/InstructorAuthController.java | 8 ++++- .../auth/dto/InstructorAuthResult.java | 4 +-- .../auth/service/InstructorAuthService.java | 6 ++-- 11 files changed, 70 insertions(+), 44 deletions(-) create mode 100644 src/main/java/ditda/backend/domain/common/auth/dto/TokenResult.java diff --git a/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java b/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java index 5a05e74..c089423 100644 --- a/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java +++ b/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java @@ -1,7 +1,6 @@ package ditda.backend.domain.common.auth.controller; import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseCookie; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -9,6 +8,7 @@ import org.springframework.web.bind.annotation.RestController; import ditda.backend.domain.common.auth.dto.AuthResult; +import ditda.backend.domain.common.auth.dto.TokenResult; import ditda.backend.domain.common.auth.dto.request.CheckUsernameRequest; import ditda.backend.domain.common.auth.dto.request.EmailCodeVerificationRequest; import ditda.backend.domain.common.auth.dto.request.EmailVerificationRequest; @@ -34,6 +34,7 @@ public class AuthController { private final AuthFacade authFacade; + private final CookieUtils cookieUtils; @Operation(summary = "아이디 쀑볡 확인", description = "**[νšŒμ›κ°€μž…]** μ‚¬μš© κ°€λŠ₯ν•œ 아이디인지 ν™•μΈν•©λ‹ˆλ‹€.") @PostMapping("/check-username") @@ -72,10 +73,14 @@ public ApiResponse login( ) { AuthResult result = authFacade.login(request); - response.addHeader(HttpHeaders.SET_COOKIE, result.refreshTokenCookie().toString()); - return ApiResponse.onSuccess("둜그인 성곡", - new LoginResponse(result.userId(), result.accessToken())); + addRefreshTokenCookie(response, result.refreshToken()); + + LoginResponse loginResponse = new LoginResponse( + result.userId(), + result.accessToken()); + + return ApiResponse.onSuccess("둜그인 성곡", loginResponse); } @Operation(summary = "λ‘œκ·Έμ•„μ›ƒ", description = "**[곡톡]** ν˜„μž¬ μ„Έμ…˜μ˜ refresh token을 λ¬΄νš¨ν™”ν•©λ‹ˆλ‹€.") @@ -86,8 +91,9 @@ public ApiResponse logout( HttpServletResponse response ) { - ResponseCookie deleted = authFacade.logout(refreshToken); - response.addHeader(HttpHeaders.SET_COOKIE, deleted.toString()); + authFacade.logout(refreshToken); + + clearRefreshTokenCookie(response); return ApiResponse.onSuccess("λ‘œκ·Έμ•„μ›ƒ 성곡"); } @@ -99,10 +105,19 @@ public ApiResponse refresh( @CookieValue(value = CookieUtils.REFRESH_TOKEN_COOKIE, required = false) String refreshToken, HttpServletResponse response ) { - AuthResult result = authFacade.reissue(refreshToken); - response.addHeader(HttpHeaders.SET_COOKIE, result.refreshTokenCookie().toString()); + TokenResult result = authFacade.reissue(refreshToken); + + addRefreshTokenCookie(response, result.refreshToken()); return ApiResponse.onSuccess("토큰 μž¬λ°œκΈ‰ 성곡", new ReissueResponse(result.accessToken())); } + + private void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) { + response.addHeader(HttpHeaders.SET_COOKIE, cookieUtils.createRefreshTokenCookie(refreshToken).toString()); + } + + private void clearRefreshTokenCookie(HttpServletResponse response) { + response.addHeader(HttpHeaders.SET_COOKIE, cookieUtils.deleteRefreshTokenCookie().toString()); + } } diff --git a/src/main/java/ditda/backend/domain/common/auth/dto/AuthResult.java b/src/main/java/ditda/backend/domain/common/auth/dto/AuthResult.java index afc675d..ef8c5a6 100644 --- a/src/main/java/ditda/backend/domain/common/auth/dto/AuthResult.java +++ b/src/main/java/ditda/backend/domain/common/auth/dto/AuthResult.java @@ -1,13 +1,11 @@ package ditda.backend.domain.common.auth.dto; -import org.springframework.http.ResponseCookie; - public record AuthResult( Long userId, String accessToken, - ResponseCookie refreshTokenCookie + String refreshToken ) { } diff --git a/src/main/java/ditda/backend/domain/common/auth/dto/TokenResult.java b/src/main/java/ditda/backend/domain/common/auth/dto/TokenResult.java new file mode 100644 index 0000000..4fc92fa --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/auth/dto/TokenResult.java @@ -0,0 +1,7 @@ +package ditda.backend.domain.common.auth.dto; + +public record TokenResult( + String accessToken, + String refreshToken +) { +} \ No newline at end of file diff --git a/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java b/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java index f9a6208..d861ffe 100644 --- a/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java +++ b/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java @@ -1,9 +1,9 @@ package ditda.backend.domain.common.auth.facade; -import org.springframework.http.ResponseCookie; import org.springframework.stereotype.Component; import ditda.backend.domain.common.auth.dto.AuthResult; +import ditda.backend.domain.common.auth.dto.TokenResult; import ditda.backend.domain.common.auth.dto.request.LoginRequest; import ditda.backend.domain.common.auth.notification.EmailVerificationMailer; import ditda.backend.domain.common.auth.service.AuthService; @@ -42,12 +42,12 @@ public AuthResult login(LoginRequest request) { } // μœ μ € λ‘œκ·Έμ•„μ›ƒ - public ResponseCookie logout(String refreshToken) { - return authService.logout(refreshToken); + public void logout(String refreshToken) { + authService.logout(refreshToken); } // 토큰 μž¬λ°œκΈ‰ - public AuthResult reissue(String refreshToken) { + public TokenResult reissue(String refreshToken) { return authService.reissue(refreshToken); } } diff --git a/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java b/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java index 2248b31..9afb8c8 100644 --- a/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java +++ b/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java @@ -3,12 +3,12 @@ import java.time.LocalDateTime; import java.util.UUID; -import org.springframework.http.ResponseCookie; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import ditda.backend.domain.common.auth.dto.AuthResult; +import ditda.backend.domain.common.auth.dto.TokenResult; import ditda.backend.domain.common.auth.dto.request.LoginRequest; import ditda.backend.domain.common.auth.entity.RefreshToken; import ditda.backend.domain.common.auth.repository.RefreshTokenRepository; @@ -19,7 +19,6 @@ import ditda.backend.global.hash.RefreshTokenHasher; import ditda.backend.global.jwt.JwtTokenProvider; import ditda.backend.global.jwt.dto.RefreshTokenPayload; -import ditda.backend.global.jwt.utils.CookieUtils; import io.jsonwebtoken.JwtException; import lombok.RequiredArgsConstructor; @@ -32,10 +31,9 @@ public class AuthService { private final RefreshTokenHasher refreshTokenHasher; private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenRepository refreshTokenRepository; - private final CookieUtils cookieUtils; @Transactional - public AuthResult issueTokens(Long userId) { + public TokenResult issueTokens(Long userId) { // 1. 만료된 토큰 μ‚­μ œ refreshTokenRepository.deleteExpiredByUserId(userId, LocalDateTime.now()); @@ -58,9 +56,7 @@ public AuthResult issueTokens(Long userId) { ) ); - ResponseCookie cookie = cookieUtils.createRefreshTokenCookie(refreshToken); - - return new AuthResult(userId, accessToken, cookie); + return new TokenResult(accessToken, refreshToken); } @Transactional @@ -75,11 +71,17 @@ public AuthResult login(LoginRequest request) { } // 3. Access / Refresh 토큰 λ°œκΈ‰ - return issueTokens(user.getId()); + TokenResult tokens = issueTokens(user.getId()); + + return new AuthResult( + user.getId(), + tokens.accessToken(), + tokens.refreshToken() + ); } @Transactional - public ResponseCookie logout(String refreshToken) { + public void logout(String refreshToken) { if (refreshToken != null && !refreshToken.isBlank()) { try { @@ -97,12 +99,10 @@ public ResponseCookie logout(String refreshToken) { // 토큰이 만료 λ˜λŠ” μœ„μ‘°μ—¬λ„ μΏ ν‚€λŠ” 제거 } } - - return cookieUtils.deleteRefreshTokenCookie(); } @Transactional - public AuthResult reissue(String refreshToken) { + public TokenResult reissue(String refreshToken) { // 1. μΏ ν‚€ 쑴재 μ—¬λΆ€ 확인 if (refreshToken == null || refreshToken.isBlank()) { @@ -139,9 +139,7 @@ public AuthResult reissue(String refreshToken) { stored.rotate(newRefreshTokenHash, newExpiresAt); - ResponseCookie cookie = cookieUtils.createRefreshTokenCookie(newRefreshToken); - - return new AuthResult(userId, newAccessToken, cookie); + return new TokenResult(newAccessToken, newRefreshToken); } @Transactional diff --git a/src/main/java/ditda/backend/domain/designer/auth/controller/DesignerAuthController.java b/src/main/java/ditda/backend/domain/designer/auth/controller/DesignerAuthController.java index 9cbc9c6..dc63c7b 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/controller/DesignerAuthController.java +++ b/src/main/java/ditda/backend/domain/designer/auth/controller/DesignerAuthController.java @@ -15,6 +15,7 @@ import ditda.backend.domain.designer.auth.dto.response.DesignerSignupResponse; import ditda.backend.domain.designer.auth.facade.DesignerAuthFacade; import ditda.backend.global.apipayload.response.ApiResponse; +import ditda.backend.global.jwt.utils.CookieUtils; import ditda.backend.global.s3.S3UrlResolver; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -33,6 +34,7 @@ public class DesignerAuthController { private final DesignerAuthFacade designerAuthFacade; private final S3UrlResolver s3UrlResolver; + private final CookieUtils cookieUtils; @Operation( summary = "λ””μžμ΄λ„ˆ νšŒμ›κ°€μž…", @@ -53,7 +55,7 @@ public ApiResponse signup( DesignerAuthResult result = designerAuthFacade.signup(request, portfolioFiles); - response.addHeader(HttpHeaders.SET_COOKIE, result.refreshTokenCookie().toString()); + addRefreshTokenCookie(response, result.refreshToken()); DesignerSignupResponse signupResponse = new DesignerSignupResponse( result.userId(), @@ -63,4 +65,8 @@ public ApiResponse signup( return ApiResponse.onSuccess("λ””μžμ΄λ„ˆ νšŒμ›κ°€μž… 성곡", signupResponse); } + + private void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) { + response.addHeader(HttpHeaders.SET_COOKIE, cookieUtils.createRefreshTokenCookie(refreshToken).toString()); + } } diff --git a/src/main/java/ditda/backend/domain/designer/auth/dto/DesignerAuthResult.java b/src/main/java/ditda/backend/domain/designer/auth/dto/DesignerAuthResult.java index e8310b0..c50df81 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/dto/DesignerAuthResult.java +++ b/src/main/java/ditda/backend/domain/designer/auth/dto/DesignerAuthResult.java @@ -1,7 +1,5 @@ package ditda.backend.domain.designer.auth.dto; -import org.springframework.http.ResponseCookie; - public record DesignerAuthResult( Long userId, @@ -12,6 +10,6 @@ public record DesignerAuthResult( String accessToken, - ResponseCookie refreshTokenCookie + String refreshToken ) { } diff --git a/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java b/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java index 3166ace..961203d 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java +++ b/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java @@ -8,7 +8,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import ditda.backend.domain.common.auth.dto.AuthResult; +import ditda.backend.domain.common.auth.dto.TokenResult; import ditda.backend.domain.common.auth.service.AuthService; import ditda.backend.domain.common.term.dto.TermAgreement; import ditda.backend.domain.common.term.service.TermService; @@ -70,7 +70,7 @@ public DesignerAuthResult signup(DesignerSignupRequest request, List por portfolioService.savePortfolios(designer, portfolioKeys); // accessToken&refreshToken λ°œκΈ‰ - AuthResult tokens = authService.issueTokens(user.getId()); + TokenResult tokens = authService.issueTokens(user.getId()); // κ΄€λ¦¬μž μ•Œλ¦Ό 이벀트 λ°œν–‰ eventPublisher.publishEvent(new DesignerSignedUpEvent( @@ -85,7 +85,7 @@ public DesignerAuthResult signup(DesignerSignupRequest request, List por user.getName(), user.getProfileImage(), tokens.accessToken(), - tokens.refreshTokenCookie()); + tokens.refreshToken()); } private List toAgreements(List terms) { diff --git a/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java b/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java index b70e015..b1f4ff2 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java @@ -11,6 +11,7 @@ import ditda.backend.domain.instructor.auth.dto.response.InstructorSignupResponse; import ditda.backend.domain.instructor.auth.facade.InstructorAuthFacade; import ditda.backend.global.apipayload.response.ApiResponse; +import ditda.backend.global.jwt.utils.CookieUtils; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; @@ -24,6 +25,7 @@ public class InstructorAuthController { private final InstructorAuthFacade instructorAuthFacade; + private final CookieUtils cookieUtils; @Operation(summary = "강사 νšŒμ›κ°€μž…", description = "**[νšŒμ›κ°€μž…]** νšŒμ›κ°€μž… ν›„ μžλ™ 둜그인 μ²˜λ¦¬λ©λ‹ˆλ‹€.") @PostMapping("/signup") @@ -34,9 +36,13 @@ public ApiResponse signup( InstructorAuthResult result = instructorAuthFacade.signup(request); - response.addHeader(HttpHeaders.SET_COOKIE, result.refreshTokenCookie().toString()); + addRefreshTokenCookie(response, result.refreshToken()); return ApiResponse.onSuccess("강사 νšŒμ›κ°€μž… 성곡", new InstructorSignupResponse(result.userId(), result.accessToken())); } + + private void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) { + response.addHeader(HttpHeaders.SET_COOKIE, cookieUtils.createRefreshTokenCookie(refreshToken).toString()); + } } diff --git a/src/main/java/ditda/backend/domain/instructor/auth/dto/InstructorAuthResult.java b/src/main/java/ditda/backend/domain/instructor/auth/dto/InstructorAuthResult.java index 939e47c..a0d7762 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/dto/InstructorAuthResult.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/dto/InstructorAuthResult.java @@ -1,13 +1,11 @@ package ditda.backend.domain.instructor.auth.dto; -import org.springframework.http.ResponseCookie; - public record InstructorAuthResult( Long userId, String accessToken, - ResponseCookie refreshTokenCookie + String refreshToken ) { } diff --git a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java index 7c63908..acefc46 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java @@ -7,7 +7,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import ditda.backend.domain.common.auth.dto.AuthResult; +import ditda.backend.domain.common.auth.dto.TokenResult; import ditda.backend.domain.common.auth.service.AuthService; import ditda.backend.domain.common.term.dto.TermAgreement; import ditda.backend.domain.common.term.service.TermService; @@ -53,9 +53,9 @@ public InstructorAuthResult signup(InstructorSignupRequest request) { instructorRepository.save(Instructor.createInstructor(user)); - AuthResult tokens = authService.issueTokens(user.getId()); + TokenResult tokens = authService.issueTokens(user.getId()); - return new InstructorAuthResult(user.getId(), tokens.accessToken(), tokens.refreshTokenCookie()); + return new InstructorAuthResult(user.getId(), tokens.accessToken(), tokens.refreshToken()); } private List toAgreements(List terms) { From 3d488644e69594d61d9e870629c22a19956ee6a2 Mon Sep 17 00:00:00 2001 From: fervovita Date: Thu, 28 May 2026 14:56:44 +0900 Subject: [PATCH 88/90] =?UTF-8?q?Feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=B0=8F=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 8 +++---- .../domain/common/auth/dto/AuthResult.java | 4 ++++ .../domain/common/auth/dto/TokenResult.java | 2 +- .../auth/dto/response/LoginResponse.java | 6 +++++ .../auth/mapper/AuthResponseMapper.java | 24 +++++++++++++++++++ .../common/auth/service/AuthService.java | 2 ++ .../controller/DesignerAuthController.java | 12 +++------- .../mapper/DesignerAuthResponseMapper.java | 24 +++++++++++++++++++ 8 files changed, 67 insertions(+), 15 deletions(-) create mode 100644 src/main/java/ditda/backend/domain/common/auth/mapper/AuthResponseMapper.java create mode 100644 src/main/java/ditda/backend/domain/designer/auth/mapper/DesignerAuthResponseMapper.java diff --git a/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java b/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java index c089423..bf3bf81 100644 --- a/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java +++ b/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java @@ -16,6 +16,7 @@ import ditda.backend.domain.common.auth.dto.response.LoginResponse; import ditda.backend.domain.common.auth.dto.response.ReissueResponse; import ditda.backend.domain.common.auth.facade.AuthFacade; +import ditda.backend.domain.common.auth.mapper.AuthResponseMapper; import ditda.backend.global.apipayload.response.ApiResponse; import ditda.backend.global.jwt.utils.CookieUtils; import io.swagger.v3.oas.annotations.Operation; @@ -35,6 +36,7 @@ public class AuthController { private final AuthFacade authFacade; private final CookieUtils cookieUtils; + private final AuthResponseMapper authResponseMapper; @Operation(summary = "아이디 쀑볡 확인", description = "**[νšŒμ›κ°€μž…]** μ‚¬μš© κ°€λŠ₯ν•œ 아이디인지 ν™•μΈν•©λ‹ˆλ‹€.") @PostMapping("/check-username") @@ -76,11 +78,7 @@ public ApiResponse login( addRefreshTokenCookie(response, result.refreshToken()); - LoginResponse loginResponse = new LoginResponse( - result.userId(), - result.accessToken()); - - return ApiResponse.onSuccess("둜그인 성곡", loginResponse); + return ApiResponse.onSuccess("둜그인 성곡", authResponseMapper.toLoginResponse(result)); } @Operation(summary = "λ‘œκ·Έμ•„μ›ƒ", description = "**[곡톡]** ν˜„μž¬ μ„Έμ…˜μ˜ refresh token을 λ¬΄νš¨ν™”ν•©λ‹ˆλ‹€.") diff --git a/src/main/java/ditda/backend/domain/common/auth/dto/AuthResult.java b/src/main/java/ditda/backend/domain/common/auth/dto/AuthResult.java index ef8c5a6..004e2aa 100644 --- a/src/main/java/ditda/backend/domain/common/auth/dto/AuthResult.java +++ b/src/main/java/ditda/backend/domain/common/auth/dto/AuthResult.java @@ -4,6 +4,10 @@ public record AuthResult( Long userId, + String name, + + String profileImage, + String accessToken, String refreshToken diff --git a/src/main/java/ditda/backend/domain/common/auth/dto/TokenResult.java b/src/main/java/ditda/backend/domain/common/auth/dto/TokenResult.java index 4fc92fa..b64e7c7 100644 --- a/src/main/java/ditda/backend/domain/common/auth/dto/TokenResult.java +++ b/src/main/java/ditda/backend/domain/common/auth/dto/TokenResult.java @@ -4,4 +4,4 @@ public record TokenResult( String accessToken, String refreshToken ) { -} \ No newline at end of file +} diff --git a/src/main/java/ditda/backend/domain/common/auth/dto/response/LoginResponse.java b/src/main/java/ditda/backend/domain/common/auth/dto/response/LoginResponse.java index e4641a1..806abff 100644 --- a/src/main/java/ditda/backend/domain/common/auth/dto/response/LoginResponse.java +++ b/src/main/java/ditda/backend/domain/common/auth/dto/response/LoginResponse.java @@ -8,6 +8,12 @@ public record LoginResponse( @Schema(description = "둜그인 μœ μ € ID", example = "1") Long userId, + @Schema(description = "μ‚¬μš©μž 이름", example = "홍길동") + String name, + + @Schema(description = "ν”„λ‘œν•„ 이미지", example = "https://example.com/profile/default.png") + String profileImageUrl, + @Schema(description = "Access Token") String accessToken ) { diff --git a/src/main/java/ditda/backend/domain/common/auth/mapper/AuthResponseMapper.java b/src/main/java/ditda/backend/domain/common/auth/mapper/AuthResponseMapper.java new file mode 100644 index 0000000..f65e694 --- /dev/null +++ b/src/main/java/ditda/backend/domain/common/auth/mapper/AuthResponseMapper.java @@ -0,0 +1,24 @@ +package ditda.backend.domain.common.auth.mapper; + +import org.springframework.stereotype.Component; + +import ditda.backend.domain.common.auth.dto.AuthResult; +import ditda.backend.domain.common.auth.dto.response.LoginResponse; +import ditda.backend.global.s3.S3UrlResolver; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class AuthResponseMapper { + + private final S3UrlResolver s3UrlResolver; + + public LoginResponse toLoginResponse(AuthResult result) { + return new LoginResponse( + result.userId(), + result.name(), + s3UrlResolver.toPublicS3Url(result.profileImage()), + result.accessToken() + ); + } +} diff --git a/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java b/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java index 9afb8c8..ee487b2 100644 --- a/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java +++ b/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java @@ -75,6 +75,8 @@ public AuthResult login(LoginRequest request) { return new AuthResult( user.getId(), + user.getName(), + user.getProfileImage(), tokens.accessToken(), tokens.refreshToken() ); diff --git a/src/main/java/ditda/backend/domain/designer/auth/controller/DesignerAuthController.java b/src/main/java/ditda/backend/domain/designer/auth/controller/DesignerAuthController.java index dc63c7b..d697861 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/controller/DesignerAuthController.java +++ b/src/main/java/ditda/backend/domain/designer/auth/controller/DesignerAuthController.java @@ -14,9 +14,9 @@ import ditda.backend.domain.designer.auth.dto.request.DesignerSignupRequest; import ditda.backend.domain.designer.auth.dto.response.DesignerSignupResponse; import ditda.backend.domain.designer.auth.facade.DesignerAuthFacade; +import ditda.backend.domain.designer.auth.mapper.DesignerAuthResponseMapper; import ditda.backend.global.apipayload.response.ApiResponse; import ditda.backend.global.jwt.utils.CookieUtils; -import ditda.backend.global.s3.S3UrlResolver; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Encoding; @@ -33,8 +33,8 @@ public class DesignerAuthController { private final DesignerAuthFacade designerAuthFacade; - private final S3UrlResolver s3UrlResolver; private final CookieUtils cookieUtils; + private final DesignerAuthResponseMapper authResponseMapper; @Operation( summary = "λ””μžμ΄λ„ˆ νšŒμ›κ°€μž…", @@ -57,13 +57,7 @@ public ApiResponse signup( addRefreshTokenCookie(response, result.refreshToken()); - DesignerSignupResponse signupResponse = new DesignerSignupResponse( - result.userId(), - result.name(), - s3UrlResolver.toPublicS3Url(result.profileImage()), - result.accessToken()); - - return ApiResponse.onSuccess("λ””μžμ΄λ„ˆ νšŒμ›κ°€μž… 성곡", signupResponse); + return ApiResponse.onSuccess("λ””μžμ΄λ„ˆ νšŒμ›κ°€μž… 성곡", authResponseMapper.toSignupResponse(result)); } private void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) { diff --git a/src/main/java/ditda/backend/domain/designer/auth/mapper/DesignerAuthResponseMapper.java b/src/main/java/ditda/backend/domain/designer/auth/mapper/DesignerAuthResponseMapper.java new file mode 100644 index 0000000..466fc5b --- /dev/null +++ b/src/main/java/ditda/backend/domain/designer/auth/mapper/DesignerAuthResponseMapper.java @@ -0,0 +1,24 @@ +package ditda.backend.domain.designer.auth.mapper; + +import org.springframework.stereotype.Component; + +import ditda.backend.domain.designer.auth.dto.DesignerAuthResult; +import ditda.backend.domain.designer.auth.dto.response.DesignerSignupResponse; +import ditda.backend.global.s3.S3UrlResolver; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class DesignerAuthResponseMapper { + + private final S3UrlResolver s3UrlResolver; + + public DesignerSignupResponse toSignupResponse(DesignerAuthResult result) { + return new DesignerSignupResponse( + result.userId(), + result.name(), + s3UrlResolver.toPublicS3Url(result.profileImage()), + result.accessToken() + ); + } +} From 18752f7ae4cfa89a7627540b0fde07f8b2db1c1a Mon Sep 17 00:00:00 2001 From: fervovita Date: Thu, 28 May 2026 15:02:30 +0900 Subject: [PATCH 89/90] =?UTF-8?q?Feat:=20=EA=B0=95=EC=82=AC=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=9D=91=EB=8B=B5=EC=97=90=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9D=B4=EB=A6=84=20=EB=B0=8F=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/InstructorAuthController.java | 5 ++-- .../auth/dto/InstructorAuthResult.java | 4 ++++ .../response/InstructorSignupResponse.java | 6 +++++ .../mapper/InstructorAuthResponseMapper.java | 24 +++++++++++++++++++ .../auth/service/InstructorAuthService.java | 9 +++++-- 5 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 src/main/java/ditda/backend/domain/instructor/auth/mapper/InstructorAuthResponseMapper.java diff --git a/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java b/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java index b1f4ff2..6314c3d 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java @@ -10,6 +10,7 @@ import ditda.backend.domain.instructor.auth.dto.request.InstructorSignupRequest; import ditda.backend.domain.instructor.auth.dto.response.InstructorSignupResponse; import ditda.backend.domain.instructor.auth.facade.InstructorAuthFacade; +import ditda.backend.domain.instructor.auth.mapper.InstructorAuthResponseMapper; import ditda.backend.global.apipayload.response.ApiResponse; import ditda.backend.global.jwt.utils.CookieUtils; import io.swagger.v3.oas.annotations.Operation; @@ -26,6 +27,7 @@ public class InstructorAuthController { private final InstructorAuthFacade instructorAuthFacade; private final CookieUtils cookieUtils; + private final InstructorAuthResponseMapper authResponseMapper; @Operation(summary = "강사 νšŒμ›κ°€μž…", description = "**[νšŒμ›κ°€μž…]** νšŒμ›κ°€μž… ν›„ μžλ™ 둜그인 μ²˜λ¦¬λ©λ‹ˆλ‹€.") @PostMapping("/signup") @@ -38,8 +40,7 @@ public ApiResponse signup( addRefreshTokenCookie(response, result.refreshToken()); - return ApiResponse.onSuccess("강사 νšŒμ›κ°€μž… 성곡", - new InstructorSignupResponse(result.userId(), result.accessToken())); + return ApiResponse.onSuccess("강사 νšŒμ›κ°€μž… 성곡", authResponseMapper.toSignupResponse(result)); } private void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) { diff --git a/src/main/java/ditda/backend/domain/instructor/auth/dto/InstructorAuthResult.java b/src/main/java/ditda/backend/domain/instructor/auth/dto/InstructorAuthResult.java index a0d7762..1bc94b7 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/dto/InstructorAuthResult.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/dto/InstructorAuthResult.java @@ -4,6 +4,10 @@ public record InstructorAuthResult( Long userId, + String name, + + String profileImage, + String accessToken, String refreshToken diff --git a/src/main/java/ditda/backend/domain/instructor/auth/dto/response/InstructorSignupResponse.java b/src/main/java/ditda/backend/domain/instructor/auth/dto/response/InstructorSignupResponse.java index cbbd396..f989f47 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/dto/response/InstructorSignupResponse.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/dto/response/InstructorSignupResponse.java @@ -8,6 +8,12 @@ public record InstructorSignupResponse( @Schema(description = "User ID") Long userId, + @Schema(description = "μ‚¬μš©μž 이름", example = "홍길동") + String name, + + @Schema(description = "ν”„λ‘œν•„ 이미지", example = "https://example.com/profile/default.png") + String profileImageUrl, + @Schema(description = "Access Token") String accessToken ) { diff --git a/src/main/java/ditda/backend/domain/instructor/auth/mapper/InstructorAuthResponseMapper.java b/src/main/java/ditda/backend/domain/instructor/auth/mapper/InstructorAuthResponseMapper.java new file mode 100644 index 0000000..f368efd --- /dev/null +++ b/src/main/java/ditda/backend/domain/instructor/auth/mapper/InstructorAuthResponseMapper.java @@ -0,0 +1,24 @@ +package ditda.backend.domain.instructor.auth.mapper; + +import org.springframework.stereotype.Component; + +import ditda.backend.domain.instructor.auth.dto.InstructorAuthResult; +import ditda.backend.domain.instructor.auth.dto.response.InstructorSignupResponse; +import ditda.backend.global.s3.S3UrlResolver; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class InstructorAuthResponseMapper { + + private final S3UrlResolver s3UrlResolver; + + public InstructorSignupResponse toSignupResponse(InstructorAuthResult result) { + return new InstructorSignupResponse( + result.userId(), + result.name(), + s3UrlResolver.toPublicS3Url(result.profileImage()), + result.accessToken() + ); + } +} diff --git a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java index acefc46..9646f90 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java @@ -24,7 +24,7 @@ @RequiredArgsConstructor public class InstructorAuthService { - private static final String DEFAULT_PROFILE_IMAGE = ""; + private static final String DEFAULT_PROFILE_IMAGE = "profile/default.png"; private final InstructorRepository instructorRepository; private final UserService userService; @@ -55,7 +55,12 @@ public InstructorAuthResult signup(InstructorSignupRequest request) { TokenResult tokens = authService.issueTokens(user.getId()); - return new InstructorAuthResult(user.getId(), tokens.accessToken(), tokens.refreshToken()); + return new InstructorAuthResult( + user.getId(), + user.getName(), + user.getProfileImage(), + tokens.accessToken(), + tokens.refreshToken()); } private List toAgreements(List terms) { From d072efbcb14bcf7d5803290f6987e154e418a8cc Mon Sep 17 00:00:00 2001 From: Jonghun Lim Date: Thu, 28 May 2026 21:07:56 +0900 Subject: [PATCH 90/90] =?UTF-8?q?Chore(auth):=20=EB=93=A4=EC=97=AC?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/designer/auth/service/DesignerAuthService.java | 3 ++- .../domain/instructor/auth/service/InstructorAuthService.java | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java b/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java index 961203d..75c229b 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java +++ b/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java @@ -63,7 +63,8 @@ public DesignerAuthResult signup(DesignerSignupRequest request, List por user, request.bankAccount().bankName(), request.bankAccount().accountNumber(), - request.bankAccount().accountHolder()); + request.bankAccount().accountHolder() + ); designerRepository.save(designer); // 포트폴리였 S3 keyλ₯Ό DB에 일괄 μ €μž₯ diff --git a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java index 9646f90..a60b14e 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java +++ b/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java @@ -60,7 +60,8 @@ public InstructorAuthResult signup(InstructorSignupRequest request) { user.getName(), user.getProfileImage(), tokens.accessToken(), - tokens.refreshToken()); + tokens.refreshToken() + ); } private List toAgreements(List terms) {