diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3ad06d2..0095ac5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,11 +1,103 @@ -name: UMC Dev CI/CD // 여러분들 맘대로 이름 지으세요 +name: CI/CD Pipeline on: - pull_request: - types: [closed] - workflow_dispatch: # (2).수동 실행도 가능하도록 + push: + branches: [ develop, main ] jobs: build: - runs-on: ubuntu-latest # (3).OS환경 - if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'develop' \ No newline at end of file + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + + # application.yml 생성 방식 변경 + - name: Make application.yml + run: | + cd ./src/main/resources + # EOF에 따옴표(')를 붙여서 쉘이 ${...}를 건드리지 못하게 막습니다. + cat << 'EOF' > ./application.yml + ${{ secrets.APPLICATION_YML }} + EOF + shell: bash + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + uses: gradle/gradle-build-action@v2 + with: + arguments: build -x test + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: digitalTok + path: build/libs/*.jar + + deploy: + needs: build + runs-on: ubuntu-latest + + steps: + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: digitalTok + path: build/libs/ + + # env 추가 및 run.sh 생성 + - name: Deploy to EC2 + env: + EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }} + EC2_USERNAME: ${{ secrets.EC2_USERNAME }} + EC2_HOST: ${{ secrets.EC2_HOST }} + run: | + echo "$EC2_SSH_KEY" > private_key.pem + chmod 600 private_key.pem + jar_file=$(find build/libs -name '*.jar' ! -name '*plain.jar' | head -n 1) + + # 파일 전송 + scp -i private_key.pem -o StrictHostKeyChecking=no "$jar_file" $EC2_USERNAME@$EC2_HOST:/home/$EC2_USERNAME/digitalTok.jar + + # 실행 스크립트 생성 (run.sh) + cat < run.sh + #!/bin/bash + + # 1. 기존 자바 프로세스 강제 종료 + pgrep java | xargs -r kill -9 + sleep 5 + + # DB 및 AWS 환경변수 설정 + export DB_URL='${{ secrets.DB_URL }}' + export DB_USERNAME='${{ secrets.DB_USERNAME }}' + export DB_PASSWORD='${{ secrets.DB_PASSWORD }}' + export AWS_ACCESS_KEY='${{ secrets.AWS_ACCESS_KEY }}' + export AWS_SECRET_KEY='${{ secrets.AWS_SECRET_KEY }}' + export SPRING_CLOUD_AWS_REGION_STATIC=ap-northeast-2 + + # 이메일 환경변수 설정 + export MAIL_USERNAME='${{ secrets.MAIL_USERNAME }}' + export MAIL_PASSWORD='${{ secrets.MAIL_PASSWORD }}' + + # 서버 실행 명령어 + # -Djwt.secret 옵션으로 스프링이 찾는 이름에 맞춰서 값을 직접 넣어줌 + nohup java -Duser.timezone=Asia/Seoul \ + -Dapp.server-url='https://diring.site' \ + -Djwt.secret='${{ secrets.JWT_SECRET_KEY }}' \ + -Djwt.access-token-validity-in-seconds=1800 \ + -Djwt.refresh-token-validity-in-seconds=604800 \ + -jar /home/$EC2_USERNAME/digitalTok.jar > app.log 2>&1 & + EOF + + # 스크립트 전송 및 실행 + scp -i private_key.pem -o StrictHostKeyChecking=no run.sh $EC2_USERNAME@$EC2_HOST:/home/$EC2_USERNAME/run.sh + ssh -i private_key.pem -o StrictHostKeyChecking=no $EC2_USERNAME@$EC2_HOST "chmod +x /home/$EC2_USERNAME/run.sh && sh /home/$EC2_USERNAME/run.sh" + + rm -f private_key.pem run.sh \ No newline at end of file diff --git a/.gitignore b/.gitignore index fb5a29f..7d95361 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,23 @@ Thumbs.db ### [Custom Settings] ### # 나중에 DB 정보나 API Key가 들어있는 설정 파일을 숨길 때 사용하세요. -# application-secret.properties \ No newline at end of file +# application-secret.properties + +### [Security & AWS] ### +# EC2 접속을 위한 키 파일 (해킹 위험 1순위, 절대 금지) +*.pem + +# 환경변수 설정 파일 +.env + +### [Spring Boot Config - Safety] ### +# 우리가 만들기로 했던 배포용 설정 파일 (실수 방지용) +src/main/resources/application-prod.yml +src/main/resources/application-secret.yml + +# 혹시 모를 로컬 테스트용 설정 파일들 +application-prod.yml +application-secret.yml + +#debug용 파일은 업로드 되지 않게 +debug-output \ No newline at end of file diff --git a/README.md b/README.md index ec91194..91d3f0d 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,103 @@ -> **프로젝트 한 줄 소개:** 나만의 전자 잉크 키링 서비 +# 🏷️ 나만의 전자 잉크 키링 서비스, DiRing (디링) ![Java](https://img.shields.io/badge/Java-17-007396?style=flat&logo=java&logoColor=white) ![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5.9-6DB33F?style=flat&logo=springboot&logoColor=white) ![MySQL](https://img.shields.io/badge/MySQL-8.0-4479A1?style=flat&logo=mysql&logoColor=white) +![AWS](https://img.shields.io/badge/AWS-EC2%20%2F%20S3%20%2F%20RDS-232F3E?style=flat&logo=amazon-aws&logoColor=white) + +> **"당신의 일상을 키링에 담다."**
+> 사용자가 원하는 템플릿이나 이미지를 선택하면 전자 잉크(E-ink) 디스플레이에 맞는 이미지로 변환하여 전자 잉크(E-ink)키링 디바이스로 전송해주는 서비스입니다. + +
+ +## 🌐 Swagger API Docs +- **URL:** [http://3.37.213.174:8080/swagger-ui/index.html](http://3.37.213.174:8080/swagger-ui/index.html)
## 1. 🚩 프로젝트 개요 - **개발 기간:** 2025.12.12 ~ 2026.02.19 -- **개발 인원:** Backend 3 -- **프로젝트 목적:** +- **개발 인원:** 4명 (Backend Team) + +### 👨‍💻 팀원 및 담당 역할 (R&R) + +| 이름 | 포지션 | 담당 도메인 및 핵심 기여 | +| :---: | :---: | :--- | +| [**이정헌**](https://github.com/LeeJeongHeon02) | **Team Leader** | • **Template & CI/CD**
• 지하철 템플릿 이미지 생성 로직 구현
• GitHub Actions 파이프라인 구축 및 서버 관리 | +| [**송정은**](https://github.com/jeun66) | **Sub Leader** | • **Image Processing**
• E-ink 전용 4-Color Quantization(양자화) 알고리즘 구현
• 이미지 ↔ 바이너리 데이터 인코딩/디코딩 로직 개발 | +| [**이승주**](https://github.com/dudulic) | **Member** | • **Auth & Security**
• JWT 기반 인증/인가 시스템 및 Refresh Token 로테이션
• SMTP 기반 이메일 인증 기능 구현 | +| [**조성하**](https://github.com/cdth12345) | **Member** | • **Device & User**
• 사용자 프로필 관리 및 디바이스 연동 API 개발 |
## 2. 📚 사용 기술 (Tech Stack) -| 구분 | 기술 | -| :-: | - | -| **Language** | Java 17 | -| **Framework** | Spring Boot 3.5.9, Spring Security, Spring Data JPA | -| **Database** | MySQL, Redis | -| **Infra** | AWS EC2, RDS, Docker | -| **Collaboration** | Git, Notion, Discord | +| 구분 | 기술 | 상세 내용 | +| :-: | :-: | - | +| **Language** | Java 17 | | +| **Framework** | Spring Boot 3.5.9 | Spring Security, Spring Data JPA | +| **Database** | MySQL 8.0 | AWS RDS (Prod), H2 (Test) | +| **Infra** | AWS | EC2, S3 (Image Storage), RDS | +| **CI/CD** | GitHub Actions | Gradle Build -> SCP Transfer -> Deploy Script | +| **Utils** | ImageIO, Java AWT | 이미지 생성 및 픽셀 처리 |
-## 3.🤝 Team Convention (협업 규칙) - -### 3-1. Branch Strategy - Git Flow 전략 +## 3. ✨ 주요 기능 (Key Features) -* **main**: 배포 가능한 상태의 코드가 모이는 브랜치 -* **develop**: 다음 버전을 위한 개발이 진행되는 주 브랜치 -* **feature**: 기능을 개발하는 브랜치 (develop에서 분기) - * 브랜치 명: `feature/이슈번호-기능요약` - * 예시: `feature/12-login-api` -* **hotfix**: 배포된 버전에서 발생한 버그를 긴급 수정 (main에서 분기) +### 🔐 1. 인증 및 보안 (Auth & Security) +- **JWT 기반 로그인:** Access/Refresh Token을 활용한 무상태(Stateless) 인증. +- **이메일 인증:** Google SMTP를 연동하여 비밀번호 찾기 시 새로운 비밀번호 자동 발송. +### 🖼️ 2. 템플릿 이미지 생성 (Template Engine) +- **동적 이미지 드로잉:** Java AWT/Graphics2D를 활용해 실시간으로 이미지를 그려냅니다. +- **커스텀 폰트 적용:** `Pretendard` 폰트를 서버 리소스에 내장하여 일관된 타이포그래피 제공. -### 3-2. Commit Message Convention -- 제목은 50글자 이내로 작성한다. -- 첫글자는 대문자로 작성한다. -- 마침표 및 특수기호는 사용하지 않는다. -- 영문으로 작성하는 경우 동사(원형)을 가장 앞에 명령어로 작성한다. -- 과거시제는 사용하지 않는다. -- 간결하고 요점적으로 즉, 개조식 구문으로 작성한다. -### 구조 및 작성예시 -```text -Feat: 회원가입 시 이메일 중복 검사 로직 추가 +### 🎨 3. 전자 잉크 이미지 처리 (E-ink Processing) +- **4-Color Quantization:** 일반 이미지를 E-ink 디스플레이가 표현 가능한 4색(Black, White, Red, Yellow)으로 최적화하여 변환합니다. +- **Binary Encoding:** 변환된 픽셀 데이터를 하드웨어(키링)로 전송하기 위해 경량화된 바이너리 포맷으로 인코딩합니다. -기존에 아이디만 중복 검사하던 로직에 이메일 검사 추가함. -DB 쿼리 최적화 포함. +### ☁️ 4. 스토리지 및 데이터 관리 +- **AWS S3 연동:** 생성된 템플릿 이미지와 사용자 업로드 이미지를 S3 버킷에 안전하게 저장합니다. +- **트랜잭션 관리:** 이미지 업로드 실패 시 S3 파일 삭제 등 롤백 처리 이벤트 핸들링 로직 구현. -Resolves: #123 +
-``` +## 4. 🏗️ 아키텍처 및 설계 (Architecture) -## 4. 🏗️ 아키텍처 및 설계 (Architecture & Design) ### 4-1. 시스템 아키텍처 -digitaltok 아키텍처구조 +최종 시스템 아키텍처 ### 4-2. ERD (Entity Relationship Diagram) -*(여기에 ERD 이미지를 넣으세요)* -스크린샷 2026-01-14 205456 - - -
- -## 5. ✨ 주요 기능 (Key Features) -* **회원가입/로그인:** JWT 기반 인증, OAuth2 (Kakao, Google) -* **이미지 생성:** S3를 이용한 이미지 업로드 +ERD
+## 5. 📂 디렉토리 구조 (Directory Structure) +도메인형 디렉토리 구조(Domain-driven)를 채택하여 응집도를 높였습니다. -## 6. 📂 디렉토리 구조 (Directory Structure) ```bash -src -├── main -│ ├── java -│ │ └── com.example.project -│ │ ├── common # 공통 처리 (Exception, DTO) -│ │ ├── config # 설정 파일 -│ │ ├── controller # 컨트롤러 -│ │ ├── domain # 엔티티 -│ │ ├── repository # 리포지토리 -│ │ └── service # 비즈니스 로직 -│ └── resources -└── test - +digitaltok-server/ +├── .github/ # GitHub 관련 설정 (CI/CD 워크플로우, 이슈/PR 템플릿) +├── gradle/ # Gradle Wrapper 관련 설정 +├── src/ +│ ├── main/ +│ │ ├── java/com/digital_tok/ +│ │ │ ├── global/ # 공통 설정 (Security, Exception, API 응답 규격 등) +│ │ │ ├── auth/ # 인증 및 인가 (Login, JWT 등) +│ │ │ │ ├── controller # 컨트롤러 +│ │ │ │ ├── convertor # Entity, DTO 간의 변환 +│ │ │ │ ├── domain # 엔티티 +│ │ │ │ ├── dto # DTO 클래스 +│ │ │ │ ├── repository # 리포지토리 +│ │ │ │ └── service # 비즈니스 로직 +│ │ │ ├── device/ # 디바이스 관리 도메인 +│ │ │ ├── image/ # 이미지 처리 및 스토리지(S3) 관련 +│ │ │ ├── template/ # 템플릿 관리 및 이미지 생성 로직 +│ │ │ ├── user/ # 사용자 관리 도메인 +│ │ │ └── DigitalTokApplication.java # 애플리케이션 시작점 +│ │ └── resources/ # 설정 파일(yml), 폰트 등의 리소스 +│ └── test/ # 테스트 코드 (JUnit5 등) +├── build.gradle # 프로젝트 의존성 및 빌드 설정 +├── gradlew # Gradle 실행 스크립트 +└── README.md # 프로젝트 설명 문서 diff --git a/build.gradle b/build.gradle index 5898a29..578b482 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'com.digital-tok' -version = '0.0.1-SNAPSHOT' +version = '1.0.0' description = 'E-Ink digital-tok service' java { @@ -24,6 +24,11 @@ repositories { mavenCentral() } +// [추가할 부분 1] 빌드된 jar 파일 이름을 버전 없이 고정 +bootJar { + archiveFileName = 'digitalTok.jar' +} + dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' @@ -35,6 +40,28 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // AWS SDK + implementation 'software.amazon.awssdk:s3:2.29.0' + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.13' + + // AWS S3 (Spring Cloud AWS 3.x) + implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:3.1.0") + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' + + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + + // JWT 라이브러리 (JJWT 0.12.3 버전) + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + + // 이메일 발송 라이브러리 + implementation 'org.springframework.boot:spring-boot-starter-mail' } tasks.named('test') { diff --git a/src/main/java/com/digital_tok/DigitalTokApplication.java b/src/main/java/com/digital_tok/DigitalTokApplication.java index 56957ba..24e554e 100644 --- a/src/main/java/com/digital_tok/DigitalTokApplication.java +++ b/src/main/java/com/digital_tok/DigitalTokApplication.java @@ -2,8 +2,12 @@ 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; @SpringBootApplication +@EnableJpaAuditing +@EnableAsync // 비동기 처리 활성화 public class DigitalTokApplication { public static void main(String[] args) { diff --git a/src/main/java/com/digital_tok/auth/controller/AuthController.java b/src/main/java/com/digital_tok/auth/controller/AuthController.java new file mode 100644 index 0000000..82c03f9 --- /dev/null +++ b/src/main/java/com/digital_tok/auth/controller/AuthController.java @@ -0,0 +1,90 @@ +package com.digital_tok.auth.controller; + +import com.digital_tok.auth.dto.AuthRequestDTO; +import com.digital_tok.auth.dto.AuthResponseDTO; +import com.digital_tok.auth.service.AuthService; +import com.digital_tok.global.apiPayload.ApiResponse; +import com.digital_tok.global.apiPayload.code.ApiErrorCodes; +import com.digital_tok.global.apiPayload.code.ErrorCode; +import com.digital_tok.global.apiPayload.code.SuccessCode; +import com.digital_tok.auth.service.AuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/auth") +public class AuthController implements AuthControllerDocs { + + private final AuthService authService; + + /** + * 1. 회원가입 API + */ + @Override + @PostMapping("/signup") + @ApiErrorCodes({ + ErrorCode.MEMBER_ALREADY_REGISTERED // 이미 가입된 이메일인 경우 + }) + public ApiResponse join(@RequestBody @Valid AuthRequestDTO.JoinDto request) { + AuthResponseDTO.JoinResultDto result = authService.join(request); + return ApiResponse.onSuccess(SuccessCode.OK, result); + } + + /** + * 2. 로그인 API + */ + @Override + @PostMapping("/login") + @ApiErrorCodes({ + ErrorCode.MEMBER_NOT_FOUND, // 가입되지 않은 이메일 + ErrorCode.BAD_REQUEST // 비밀번호 불일치 + }) + public ApiResponse login(@RequestBody @Valid AuthRequestDTO.LoginDto request) { + AuthResponseDTO.LoginResultDto result = authService.login(request); + return ApiResponse.onSuccess(SuccessCode.OK, result); + } + + /** + * 3. 로그아웃 API + */ + @Override + @DeleteMapping("/logout") + @ApiErrorCodes({ + ErrorCode.BAD_REQUEST // 유효하지 않은 Refresh Token + }) + public ApiResponse logout(@RequestBody @Valid AuthRequestDTO.LogoutDto request) { + authService.logout(request); + return ApiResponse.onSuccess(SuccessCode.OK, "로그아웃에 성공했습니다."); + } + + /** + * 4. 이메일 중복 확인 API + */ + @Override + @PostMapping("/duplicate-check") + @ApiErrorCodes({ + ErrorCode.MEMBER_ALREADY_REGISTERED // 이미 존재하는 이메일 (409) + }) + public ApiResponse checkEmail(@RequestBody @Valid AuthRequestDTO.CheckEmailDto request) { + authService.checkEmailDuplicate(request.getEmail()); + return ApiResponse.onSuccess(SuccessCode.OK, "사용 가능한 이메일입니다."); + } + + /** + * 5. 비밀번호 재설정 API + */ + @Override + @PostMapping("/password/reset") + @ApiErrorCodes({ + ErrorCode.MEMBER_NOT_FOUND, // 가입되지 않은 이메일 + ErrorCode._INTERNAL_SERVER_ERROR // 메일 전송 실패 + }) + public ApiResponse resetPassword(@RequestBody @Valid AuthRequestDTO.ResetPasswordDto request) { + authService.resetPassword(request); + return ApiResponse.onSuccess(SuccessCode.OK, "임시 비밀번호가 이메일로 전송되었습니다."); + } +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/auth/controller/AuthControllerDocs.java b/src/main/java/com/digital_tok/auth/controller/AuthControllerDocs.java new file mode 100644 index 0000000..d4be6e8 --- /dev/null +++ b/src/main/java/com/digital_tok/auth/controller/AuthControllerDocs.java @@ -0,0 +1,62 @@ +package com.digital_tok.auth.controller; + +import com.digital_tok.auth.dto.AuthRequestDTO; +import com.digital_tok.auth.dto.AuthResponseDTO; +import com.digital_tok.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "Auth", description = "인증 관련 API (회원가입, 로그인, 로그아웃)") +public interface AuthControllerDocs { + + @Operation(summary = "회원가입 API By 이승주 (개발 완료)", description = "이메일, 비밀번호, 닉네임 등을 받아 회원을 생성합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "회원가입 성공", + content = @Content(schema = @Schema(implementation = AuthResponseDTO.JoinResultDto.class)) + ) + }) + ApiResponse join(@RequestBody AuthRequestDTO.JoinDto request); + + @Operation(summary = "로그인 API By 이승주 (개발 완료)", description = "이메일과 비밀번호로 로그인하여 JWT(Access/Refresh Token)를 발급받습니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "로그인 성공", + content = @Content(schema = @Schema(implementation = AuthResponseDTO.LoginResultDto.class)) + ) + }) + ApiResponse login(@RequestBody AuthRequestDTO.LoginDto request); + + @Operation(summary = "로그아웃 API By 이승주 (개발 완료)", description = "서버 DB 또는 Redis에 저장된 Refresh Token을 삭제합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "로그아웃 성공" + ) + }) + ApiResponse logout(@RequestBody AuthRequestDTO.LogoutDto request); + + @Operation(summary = "이메일 중복 확인 API By 이승주 (개발 완료)", description = "이메일을 받아 중복 여부를 확인합니다. (사용 가능: 200, 중복: 409)") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "사용 가능한 이메일" + ) + }) + ApiResponse checkEmail(@RequestBody AuthRequestDTO.CheckEmailDto request); + + @Operation(summary = "비밀번호 재설정 By 이승주 (개발 완료)", description = "비밀번호를 랜덤한 숫자로 재설정하고 이메일로 발송합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "임시 비밀번호 발송 성공" + ) + }) + ApiResponse resetPassword(@RequestBody AuthRequestDTO.ResetPasswordDto request); +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/auth/converter/AuthConverter.java b/src/main/java/com/digital_tok/auth/converter/AuthConverter.java new file mode 100644 index 0000000..43d42c0 --- /dev/null +++ b/src/main/java/com/digital_tok/auth/converter/AuthConverter.java @@ -0,0 +1,26 @@ +package com.digital_tok.auth.converter; + +import com.digital_tok.auth.dto.AuthResponseDTO; +import com.digital_tok.user.domain.User; +import org.springframework.stereotype.Component; + +@Component +public class AuthConverter { + + public AuthResponseDTO.JoinResultDto toJoinResultDto(User user) { + return AuthResponseDTO.JoinResultDto.builder() + .userId(user.getId()) + .email(user.getEmail()) + .nickname(user.getNickname()) + .build(); + } + + public AuthResponseDTO.LoginResultDto toLoginResultDto(String accessToken, String refreshToken) { + return AuthResponseDTO.LoginResultDto.builder() + .grantType("Bearer") + .accessToken(accessToken) + .refreshToken(refreshToken) + .accessTokenExpiresIn(3600L) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/auth/domain/RefreshToken.java b/src/main/java/com/digital_tok/auth/domain/RefreshToken.java new file mode 100644 index 0000000..d9314a5 --- /dev/null +++ b/src/main/java/com/digital_tok/auth/domain/RefreshToken.java @@ -0,0 +1,34 @@ +package com.digital_tok.auth.domain; + +import com.digital_tok.global.entity.BaseEntity; +import com.digital_tok.user.domain.User; +import jakarta.persistence.*; +import lombok.*; +import lombok.experimental.SuperBuilder; + +@Entity +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table(name = "refresh_token") +public class RefreshToken extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "rt_key") + private Long id; + + // User와 1:1 관계 (한 유저는 하나의 리프레시 토큰만 가짐) + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "token_value", nullable = false) + private String token; + + // 토큰 갱신 메서드 + public void updateToken(String token) { + this.token = token; + } +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/auth/dto/AuthRequestDTO.java b/src/main/java/com/digital_tok/auth/dto/AuthRequestDTO.java new file mode 100644 index 0000000..b55a2a4 --- /dev/null +++ b/src/main/java/com/digital_tok/auth/dto/AuthRequestDTO.java @@ -0,0 +1,57 @@ +package com.digital_tok.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Getter; + +public class AuthRequestDTO { + // 1. 회원가입 요청 + @Getter + public static class JoinDto { + @NotBlank(message = "이메일은 필수 입력 값입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + private String email; // 필수 + + @NotBlank(message = "비밀번호는 필수 입력 값입니다.") + @Size(min = 6, max = 20, message = "비밀번호는 6자 이상 20자 이하로 입력해주세요.") + private String password; // 필수 + + @Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", message = "핸드폰 번호의 양식과 맞지 않습니다. 010-xxxx-xxxx") + private String phoneNumber; // 선택 + } + + // 2. 로그인 요청 + @Getter + public static class LoginDto { + @NotBlank(message = "이메일을 입력해주세요.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + private String email; // 필수 + + @NotBlank(message = "비밀번호를 입력해주세요.") + @Size(min = 6, max = 20, message = "비밀번호는 6자 이상 20자 이하로 입력해주세요.") + private String password; // 필수 + } + + // 3. 로그아웃 요청 (Body에 있는 Refresh Token 값) + @Getter + public static class LogoutDto { + @NotBlank + private String refreshToken; // 필수 (DB/Redis 삭제용) + } + + // 4. 이메일 중복 확인 요청 + @Getter + public static class CheckEmailDto { + @NotBlank(message = "이메일을 입력해주세요.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + private String email; + } + + // 5. 비밀번호 재설정 + @Getter + public static class ResetPasswordDto { + @NotBlank(message = "이메일을 입력해주세요.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + private String email; + } +} diff --git a/src/main/java/com/digital_tok/auth/dto/AuthResponseDTO.java b/src/main/java/com/digital_tok/auth/dto/AuthResponseDTO.java new file mode 100644 index 0000000..54a8675 --- /dev/null +++ b/src/main/java/com/digital_tok/auth/dto/AuthResponseDTO.java @@ -0,0 +1,42 @@ +package com.digital_tok.auth.dto; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +public class AuthResponseDTO { + // 1. 회원가입 성공 응답 + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class JoinResultDto { + private Long userId; // 생성된 유저 PK + private String email; // 가입된 이메일 + private String nickname; // 가입된 닉네임 + private String accessToken; // (선택) 가입 즉시 로그인 처리 시 필요 + private String refreshToken;// (선택) + } + + // 2. 로그인 성공 응답 + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class LoginResultDto { + + /** + * grantType 예시 및 특징 + * authorization_code: 가장 많이 사용되며, 사용자 로그인 후 받은 인가 코드(code)를 이용해 토큰을 교환하는 방식 (보안 우수). + * client_credentials: 사용자가 아닌 클라이언트(서비스) 자체의 자격 증명으로 토큰을 받는 방식 (서비스 간 통신). + * password: 사용자 ID와 비밀번호를 직접 입력하여 토큰을 받는 방식 (자체 서비스 내에서만 권장). + * refresh_token: 만료된 Access Token 대신 Refresh Token을 사용하여 새로운 Access Token을 발급받는 방식. + * implicit: 브라우저 기반 클라이언트(JavaScript)에서 코드 없이 바로 토큰을 받는 방식 (보안 문제로 권장되지 않음). + * */ + private String grantType; // Bearer + private String accessToken; + private String refreshToken; + private Long accessTokenExpiresIn; // 토큰 만료 시간 + } +} diff --git a/src/main/java/com/digital_tok/auth/repository/RefreshTokenRepository.java b/src/main/java/com/digital_tok/auth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..9b62fff --- /dev/null +++ b/src/main/java/com/digital_tok/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,18 @@ +package com.digital_tok.auth.repository; + +import com.digital_tok.auth.domain.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends JpaRepository { + + // 토큰 값으로 조회 (재발급 검증 시 사용) + Optional findByToken(String token); + + // 유저 ID로 조회 (이미 존재하는 토큰이 있는지 확인용) + Optional findByUserId(Long userId); + + // 유저 ID로 토큰 삭제 (로그아웃 시 사용) + void deleteByUserId(Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/auth/service/AuthService.java b/src/main/java/com/digital_tok/auth/service/AuthService.java new file mode 100644 index 0000000..8de6783 --- /dev/null +++ b/src/main/java/com/digital_tok/auth/service/AuthService.java @@ -0,0 +1,206 @@ +package com.digital_tok.auth.service; + +import com.digital_tok.auth.converter.AuthConverter; +import com.digital_tok.auth.domain.RefreshToken; +import com.digital_tok.auth.dto.AuthRequestDTO; +import com.digital_tok.auth.dto.AuthResponseDTO; +import com.digital_tok.global.apiPayload.code.ErrorCode; +import com.digital_tok.global.apiPayload.exception.GeneralException; +import com.digital_tok.global.security.JwtTokenProvider; +import com.digital_tok.auth.repository.RefreshTokenRepository; +import com.digital_tok.user.domain.Role; +import com.digital_tok.user.domain.User; +import com.digital_tok.user.domain.UserStatus; +import com.digital_tok.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; // 로그 확인용 +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; +import java.util.UUID; + + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthService { + + private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + private final JavaMailSender javaMailSender; // 메일 전송 객체 주입 + private final AuthConverter authConverter; + + /** + * 1. 회원가입 + */ + @Transactional + public AuthResponseDTO.JoinResultDto join(AuthRequestDTO.JoinDto request) { + // 1-1. 이메일로 유저 조회 (existsByEmail 대신 findByEmail 사용) + Optional existingUser = userRepository.findByEmail(request.getEmail()); + + if (existingUser.isPresent()) { + User user = existingUser.get(); + + // 활동 중인 유저면 -> "이미 가입된 유저" 에러 + if (user.getStatus() == UserStatus.ACTIVE) { + throw new GeneralException(ErrorCode.MEMBER_ALREADY_REGISTERED); + } + + // 탈퇴한(INACTIVE) 유저면 -> "재가입(복구) 진행" + // 닉네임 생성 등 필요한 로직 수행 + String randomNickname = "User_" + UUID.randomUUID().toString().substring(0, 8); + + // User 엔티티의 reactivate 메서드 호출 (비밀번호 암호화 필수!) + user.reactivate(passwordEncoder.encode(request.getPassword()), randomNickname); + + // Converter + return authConverter.toJoinResultDto(user); + } + + // 1-2. 닉네임 랜덤 생성 (예: User_a1b2c3d4) + String randomNickname = "User_" + UUID.randomUUID().toString().substring(0, 8); + + // 혹시 모를 중복 방지를 위해 while문으로 체크 가능하나, UUID 8자리 충돌 확률은 매우 낮으므로 생략 가능. + // 필요하다면 아래와 같이 추가 + while (userRepository.existsByNickname(randomNickname)) { + randomNickname = "User_" + UUID.randomUUID().toString().substring(0, 8); + } + + // 1-3. User 엔티티 생성 및 비밀번호 암호화 + User newUser = User.builder() + .email(request.getEmail()) + .password(passwordEncoder.encode(request.getPassword())) // 암호화 필수 + .nickname(randomNickname) //랜덤 닉네임 적용 + //.name(request.getName()) + .phone(request.getPhoneNumber()) + .role(Role.ROLE_USER) + .status(UserStatus.ACTIVE) + .build(); + + // 1-4. DB 저장 + User savedUser = userRepository.save(newUser); + + // Converter + return authConverter.toJoinResultDto(savedUser); + } + + /** + * 2. 로그인 + */ + @Transactional + public AuthResponseDTO.LoginResultDto login(AuthRequestDTO.LoginDto request) { + // 2-1. 이메일로 유저 조회 + User user = userRepository.findByEmail(request.getEmail()) + .orElseThrow(() -> new GeneralException(ErrorCode.MEMBER_NOT_FOUND)); + + // 2-2. 탈퇴(INACTIVE) 또는 정지(BLOCKED)된 유저인지 확인 + if (user.getStatus() != UserStatus.ACTIVE) { + // 적절한 에러 코드(예: MEMBER_NOT_FOUND 또는 커스텀 에러)를 던짐 + throw new GeneralException(ErrorCode.MEMBER_NOT_FOUND); + } + + // 2-3. 비밀번호 검증 + if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + // 보안상 "비밀번호가 틀렸습니다"보다는 "정보가 일치하지 않습니다"가 좋으나, 편의상 명확한 에러 사용 가능 + throw new GeneralException(ErrorCode.BAD_REQUEST); + } + + // 2-4. 토큰 생성 (Access + Refresh) + String accessToken = jwtTokenProvider.createAccessToken(user.getId(), user.getEmail(), user.getRole()); + String refreshToken = jwtTokenProvider.createRefreshToken(user.getId()); + + // 2-5. Refresh Token DB 저장 (기존 토큰이 있으면 업데이트, 없으면 생성) + RefreshToken rt = refreshTokenRepository.findByUserId(user.getId()) + .orElseGet(() -> RefreshToken.builder() + .user(user) + .token(refreshToken) + .build()); + + rt.updateToken(refreshToken); // 더티 체킹으로 업데이트 or 새로 생성된 객체면 값 설정 + refreshTokenRepository.save(rt); + + return authConverter.toLoginResultDto(accessToken, refreshToken); + } + + /** + * 3. 로그아웃 + * 설명: DB에 저장된 Refresh Token을 삭제하여 더 이상 토큰 재발급이 불가능하게 함. + */ + @Transactional + public void logout(AuthRequestDTO.LogoutDto request) { + // 3-1. 요청된 Refresh Token 유효성 검증 (옵션) + if (!jwtTokenProvider.validateToken(request.getRefreshToken())) { + throw new GeneralException(ErrorCode.BAD_REQUEST); + } + + // 3-2. 토큰에서 User ID 추출 + Long userId = jwtTokenProvider.getUserId(request.getRefreshToken()); + + // 3-3. DB에서 해당 유저의 Refresh Token 삭제 + refreshTokenRepository.deleteByUserId(userId); + } + + /** + * 4. 이메일 중복 검사 (API용) + */ + public void checkEmailDuplicate(String email) { + // 1. 이메일로 유저 조회 (User 객체를 가져옴) + Optional user = userRepository.findByEmail(email); + + // 2. 유저가 존재하고(isPresent) && 상태가 ACTIVE인 경우에만 예외 발생 + if (user.isPresent() && user.get().getStatus() == UserStatus.ACTIVE) { + throw new GeneralException(ErrorCode.MEMBER_ALREADY_REGISTERED); + } + + // 3. 유저가 없거나(신규), 존재하더라도 상태가 INACTIVE(탈퇴)라면 + // 예외를 던지지 않고 그냥 메서드가 종료됨 -> "통과(OK)"로 간주 + } + + /** + * 비밀번호 재설정 (임시 비밀번호 발급 및 이메일 전송) + */ + @Transactional + public void resetPassword(AuthRequestDTO.ResetPasswordDto request) { + // 1. 이메일로 유저 조회 + User user = userRepository.findByEmail(request.getEmail()) + .orElseThrow(() -> new GeneralException(ErrorCode.MEMBER_NOT_FOUND)); + + // 2. 임시 비밀번호 생성 (랜덤 UUID 10자리) + String tempPassword = UUID.randomUUID().toString().replace("-", "").substring(0, 10); + + // 3. 비밀번호 암호화 및 DB 업데이트 + user.encodePassword(passwordEncoder.encode(tempPassword)); + + // 4. 실제 이메일 전송 + sendTempPasswordEmail(user.getEmail(), tempPassword); + } + + /** + * [내부 메서드] 이메일 발송 로직 + */ + private void sendTempPasswordEmail(String to, String tempPassword) { + try { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(to); + message.setSubject("[DigitalTok] 임시 비밀번호 발급 안내"); + message.setText("안녕하세요. DigitalTok 서비스입니다.\n\n" + + "요청하신 임시 비밀번호는 아래와 같습니다.\n" + + "[" + tempPassword + "]\n\n" + + "로그인 후 반드시 비밀번호를 변경해 주세요."); + + javaMailSender.send(message); // 전송 + log.info("임시 비밀번호 메일 발송 성공: {}", to); + + } catch (Exception e) { + log.error("메일 발송 실패: {}", e.getMessage()); + throw new GeneralException(ErrorCode._INTERNAL_SERVER_ERROR); // 메일 서버 에러 처리 + } + } +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/device/controller/DeviceController.java b/src/main/java/com/digital_tok/device/controller/DeviceController.java new file mode 100644 index 0000000..5bca596 --- /dev/null +++ b/src/main/java/com/digital_tok/device/controller/DeviceController.java @@ -0,0 +1,79 @@ +package com.digital_tok.device.controller; + +import com.digital_tok.device.dto.DeviceRequestDTO; +import com.digital_tok.device.dto.DeviceResponseDTO; +import com.digital_tok.global.apiPayload.ApiResponse; +import com.digital_tok.global.apiPayload.code.SuccessCode; +import com.digital_tok.device.service.DeviceService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import com.digital_tok.global.apiPayload.code.ApiErrorCodes; +import com.digital_tok.global.apiPayload.code.ErrorCode; + +import jakarta.validation.Valid; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/devices") +@Tag(name = "Device", description = "기기 관련 API (상태 조회, 연결, 해제)") +public class DeviceController implements DeviceControllerDocs { + + private final DeviceService deviceService; + + /** + * 기기 연결 API + */ + @PostMapping + @ApiErrorCodes({ + ErrorCode.UNAUTHORIZED, + ErrorCode.MEMBER_NOT_FOUND, + ErrorCode.DEVICE_NOT_FOUND, + ErrorCode.DEVICE_ALREADY_CONNECTED + }) + @Override + public ApiResponse connectDevice( + @RequestBody @Valid DeviceRequestDTO request + ) { + DeviceResponseDTO.Result result = deviceService.connectDevice(request); + return ApiResponse.onSuccess(SuccessCode.DEVICE_CONNECT_SUCCESS, result); + } + + /** + * 기기 연결 해제 API + */ + @DeleteMapping("/{nfcUid}") + @ApiErrorCodes({ + ErrorCode.UNAUTHORIZED, + ErrorCode.MEMBER_NOT_FOUND, + ErrorCode.DEVICE_NOT_FOUND, + ErrorCode.DEVICE_ALREADY_DISCONNECTED + }) + @Override + public ApiResponse disconnectDevice( + @PathVariable String nfcUid + ) { + DeviceResponseDTO.Result result = deviceService.disconnectDevice(nfcUid); + return ApiResponse.onSuccess(SuccessCode.DEVICE_DISCONNECT_SUCCESS, result); + } + + /** + * 기기 상태 조회 API + */ + @GetMapping("/{nfcUid}") + @ApiErrorCodes({ + ErrorCode.UNAUTHORIZED, + ErrorCode.MEMBER_NOT_FOUND, + ErrorCode.DEVICE_NOT_FOUND + }) + @Override + public ApiResponse getDeviceStatus( + @PathVariable String nfcUid + ) { + DeviceResponseDTO.Result result = deviceService.getDeviceStatus(nfcUid); + return ApiResponse.onSuccess(SuccessCode.DEVICE_STATUS_SUCCESS, result); + } +} diff --git a/src/main/java/com/digital_tok/device/controller/DeviceControllerDocs.java b/src/main/java/com/digital_tok/device/controller/DeviceControllerDocs.java new file mode 100644 index 0000000..2c83a34 --- /dev/null +++ b/src/main/java/com/digital_tok/device/controller/DeviceControllerDocs.java @@ -0,0 +1,48 @@ +package com.digital_tok.device.controller; + +import com.digital_tok.device.dto.DeviceRequestDTO; +import com.digital_tok.device.dto.DeviceResponseDTO; +import com.digital_tok.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import jakarta.validation.Valid; + +@Tag(name = "Device", description = "기기 관련 API (상태 조회, 연결, 해제)") +public interface DeviceControllerDocs { + + @Operation(summary = "기기 연결 API By 조성하(개발 완료)", description = "기기를 연결합니다 (NFC UID 기반).") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "연결 성공", + content = @Content(schema = @Schema(implementation = DeviceResponseDTO.Result.class)) + ) + }) + ApiResponse connectDevice(@RequestBody @Valid DeviceRequestDTO request); + + @Operation(summary = "기기 연결 해제 API By 조성하(개발 완료)", description = "기기와의 연결을 해제합니다 (NFC UID 기반).") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "연결 해제 성공", + content = @Content(schema = @Schema(implementation = DeviceResponseDTO.Result.class)) + ) + }) + ApiResponse disconnectDevice(@PathVariable String nfcUid); + + @Operation(summary = "기기 상태 조회 API By 조성하(개발 완료)", description = "기기의 연결 상태를 조회합니다 (NFC UID 기반).") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "상태 조회 성공", + content = @Content(schema = @Schema(implementation = DeviceResponseDTO.Result.class)) + ) + }) + ApiResponse getDeviceStatus(@PathVariable String nfcUid); +} diff --git a/src/main/java/com/digital_tok/device/converter/DeviceConverter.java b/src/main/java/com/digital_tok/device/converter/DeviceConverter.java new file mode 100644 index 0000000..35e77d9 --- /dev/null +++ b/src/main/java/com/digital_tok/device/converter/DeviceConverter.java @@ -0,0 +1,17 @@ +package com.digital_tok.device.converter; + +import com.digital_tok.device.domain.Device; +import com.digital_tok.device.dto.DeviceResponseDTO; +import org.springframework.stereotype.Component; + +@Component +public class DeviceConverter { + public DeviceResponseDTO.Result toResult(Device device) { + return DeviceResponseDTO.Result.builder() + .deviceId(device.getId()) + .status(device.getStatus().name()) + .registeredAt(device.getRegisteredAt()) + .unregisteredAt(device.getUnregisteredAt()) + .build(); + } +} diff --git a/src/main/java/com/digital_tok/device/domain/Device.java b/src/main/java/com/digital_tok/device/domain/Device.java new file mode 100644 index 0000000..89cb2e7 --- /dev/null +++ b/src/main/java/com/digital_tok/device/domain/Device.java @@ -0,0 +1,95 @@ +package com.digital_tok.device.domain; + +import com.digital_tok.global.apiPayload.exception.GeneralException; +import com.digital_tok.global.apiPayload.code.ErrorCode; +import com.digital_tok.user.domain.User; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA 필수 기본 생성자 +@Table(name = "device") +public class Device { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) // 기본 키에 자동 증가 설정 + @Column(name = "device_id") + private Long id; + + + @Column(name = "nfc_uid", length = 50, unique = true) + private String nfcUid; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private DeviceStatus status; // ACTIVE / INACTIVE + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", referencedColumnName = "user_id", nullable = true) // 외래 키 수정 + private User user; + + @Column(name = "registered_at") + private LocalDateTime registeredAt; + + @Column(name = "unregistered_at") + private LocalDateTime unregisteredAt; // 기기 연결 해제 일시 + + @Column(name = "created_at", nullable = false, updatable = false, insertable = false) + private LocalDateTime createdAt; //기기 등록 일시 + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + + /** + * 기기 연결 + */ + public void connect(User user) { + validateConnectable(); + this.user = user; + this.status = DeviceStatus.ACTIVE; + this.registeredAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + /** + * 기기 연결 해제 (소유자만 가능) + */ + public void disconnect(User user) { + validateOwner(user); + validateDisconnectable(); + this.user = null; + this.status = DeviceStatus.INACTIVE; + this.unregisteredAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + /* ======================= + 검증 로직 + ======================= */ + + private void validateConnectable() { + if (this.status == DeviceStatus.ACTIVE) { + throw new GeneralException(ErrorCode.DEVICE_ALREADY_CONNECTED); + } + } + + private void validateDisconnectable() { + if (this.status == DeviceStatus.INACTIVE) { + throw new GeneralException(ErrorCode.DEVICE_ALREADY_DISCONNECTED); + } + } + + private void validateOwner(User user) { + if (this.user == null || !this.user.getId().equals(user.getId())) { + throw new GeneralException(ErrorCode.DEVICE_NOT_FOUND); + } + } + +} diff --git a/src/main/java/com/digital_tok/device/domain/DeviceStatus.java b/src/main/java/com/digital_tok/device/domain/DeviceStatus.java new file mode 100644 index 0000000..9e7f9e9 --- /dev/null +++ b/src/main/java/com/digital_tok/device/domain/DeviceStatus.java @@ -0,0 +1,6 @@ +package com.digital_tok.device.domain; + +public enum DeviceStatus { + ACTIVE, + INACTIVE +} diff --git a/src/main/java/com/digital_tok/device/dto/DeviceRequestDTO.java b/src/main/java/com/digital_tok/device/dto/DeviceRequestDTO.java new file mode 100644 index 0000000..0f8efde --- /dev/null +++ b/src/main/java/com/digital_tok/device/dto/DeviceRequestDTO.java @@ -0,0 +1,11 @@ +package com.digital_tok.device.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; + +@Getter +public class DeviceRequestDTO { + @NotBlank(message = "NFC UID는 필수입니다.") + // TODO: 우리 기기의 UID 형식이 맞는지 Pattern 어노테이션 통해서 검증하면 좋을듯 + private String nfcUid; // NFC UID +} diff --git a/src/main/java/com/digital_tok/device/dto/DeviceResponseDTO.java b/src/main/java/com/digital_tok/device/dto/DeviceResponseDTO.java new file mode 100644 index 0000000..a876963 --- /dev/null +++ b/src/main/java/com/digital_tok/device/dto/DeviceResponseDTO.java @@ -0,0 +1,20 @@ +package com.digital_tok.device.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class DeviceResponseDTO { + + @Getter + @Builder + public static class Result { + private Long deviceId; // 기기 ID + private LocalDateTime registeredAt; // 등록 시점 + private LocalDateTime unregisteredAt; // 해제 시점 + private String status; // ACTIVE / INACTIVE + } +} diff --git a/src/main/java/com/digital_tok/device/repository/DeviceRepository.java b/src/main/java/com/digital_tok/device/repository/DeviceRepository.java new file mode 100644 index 0000000..d28ec69 --- /dev/null +++ b/src/main/java/com/digital_tok/device/repository/DeviceRepository.java @@ -0,0 +1,12 @@ +package com.digital_tok.device.repository; + +import com.digital_tok.device.domain.Device; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface DeviceRepository extends JpaRepository { + + // NFC UID로 기기 정보를 조회 + Optional findByNfcUid(String nfcUid); +} diff --git a/src/main/java/com/digital_tok/device/service/DeviceService.java b/src/main/java/com/digital_tok/device/service/DeviceService.java new file mode 100644 index 0000000..571ca75 --- /dev/null +++ b/src/main/java/com/digital_tok/device/service/DeviceService.java @@ -0,0 +1,98 @@ +package com.digital_tok.device.service; + +import com.digital_tok.device.converter.DeviceConverter; +import com.digital_tok.device.domain.Device; +import com.digital_tok.device.dto.DeviceRequestDTO; +import com.digital_tok.device.dto.DeviceResponseDTO; +import com.digital_tok.global.apiPayload.code.ErrorCode; +import com.digital_tok.global.apiPayload.exception.GeneralException; +import com.digital_tok.user.domain.User; +import com.digital_tok.user.repository.UserRepository; +import com.digital_tok.device.repository.DeviceRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class DeviceService { + + private final DeviceConverter deviceConverter; + private final DeviceRepository deviceRepository; + private final UserRepository userRepository; + + /** + * 기기 연결 메서드 + */ + public DeviceResponseDTO.Result connectDevice(DeviceRequestDTO request) { + User user = getCurrentUser(); + Device device = findDevice(request.getNfcUid()); + + device.connect(user); + + return deviceConverter.toResult(device); + } + + /** + * 기기 연결 해제 메서드 + */ + public DeviceResponseDTO.Result disconnectDevice(String nfcUid) { + User user = getCurrentUser(); + Device device = findDevice(nfcUid); + + device.disconnect(user); + + return deviceConverter.toResult(device); + } + + /** + * 기기 상태 조회 + */ + public DeviceResponseDTO.Result getDeviceStatus(String nfcUid) { + User user = getCurrentUser(); + Device device = findDevice(nfcUid); + + // 조회 권한 검증 (조회 정책) + if (device.getUser() == null || !device.getUser().getId().equals(user.getId())) { + throw new GeneralException(ErrorCode.DEVICE_NOT_FOUND); + } + + return deviceConverter.toResult(device); + } + + /** + * NFC 고유 Id로 기기 조회 + */ + + private Device findDevice(String nfcUid) { + return deviceRepository.findByNfcUid(nfcUid) + .orElseThrow(() -> new GeneralException(ErrorCode.DEVICE_NOT_FOUND)); + } + + /** + * 현재 인증된 사용자 조회 + */ + private User getCurrentUser() { + + // 인증 정보 가져오기 + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + + if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getName())) { + throw new GeneralException(ErrorCode.UNAUTHORIZED); + } + + String email = SecurityContextHolder.getContext() + .getAuthentication() + .getName(); + + return userRepository.findByEmail(email) + .orElseThrow(() -> new GeneralException(ErrorCode.MEMBER_NOT_FOUND)); + } + +} diff --git a/src/main/java/com/digital_tok/global/AmazonS3Manager.java b/src/main/java/com/digital_tok/global/AmazonS3Manager.java new file mode 100644 index 0000000..eff42b9 --- /dev/null +++ b/src/main/java/com/digital_tok/global/AmazonS3Manager.java @@ -0,0 +1,78 @@ +package com.digital_tok.global; + +import com.digital_tok.global.apiPayload.code.ErrorCode; +import com.digital_tok.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetUrlRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.util.UUID; + +@Slf4j +@RequiredArgsConstructor +@ConditionalOnProperty(name = "storage.type", havingValue = "s3", matchIfMissing = true) +@Service +public class AmazonS3Manager { + + private final S3Client s3Client; + + @Value("${spring.cloud.aws.s3.bucket}") + private String bucket; + + public String uploadFile(String keyName, MultipartFile file) { + String fileName = file.getOriginalFilename(); + String extension = (fileName != null && fileName.contains(".")) + ? fileName.substring(fileName.lastIndexOf(".")) + : ""; + + String s3Key = keyName + "/" + UUID.randomUUID() + extension; + + try { + PutObjectRequest req = PutObjectRequest.builder() + .bucket(bucket) + .key(s3Key) + .contentType(file.getContentType()) + .build(); + + s3Client.putObject(req, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + + return s3Client.utilities().getUrl(GetUrlRequest.builder() + .bucket(bucket) + .key(s3Key) + .build()).toExternalForm(); + + } catch (IOException e) { + log.error("이미지 업로드 중 에러 발생: {}", e.getMessage()); + throw new GeneralException(ErrorCode.IMAGE_UPLOAD_FAIL); + } + } + // preview/bin 파생 업로드용 (새로 추가) + public String uploadBytes(String s3Key, byte[] bytes, String contentType) { + try { + PutObjectRequest req = PutObjectRequest.builder() + .bucket(bucket) + .key(s3Key) + .contentType(contentType) + .build(); + + s3Client.putObject(req, RequestBody.fromBytes(bytes)); + + return s3Client.utilities().getUrl(GetUrlRequest.builder() + .bucket(bucket) + .key(s3Key) + .build()).toExternalForm(); + + } catch (Exception e) { + log.error("바이너리 업로드 중 에러 발생: {}", e.getMessage()); + throw new GeneralException(ErrorCode.IMAGE_UPLOAD_FAIL); + } +} +} diff --git a/src/main/java/com/digital_tok/global/apiPayload/ApiResponse.java b/src/main/java/com/digital_tok/global/apiPayload/ApiResponse.java new file mode 100644 index 0000000..29213a1 --- /dev/null +++ b/src/main/java/com/digital_tok/global/apiPayload/ApiResponse.java @@ -0,0 +1,42 @@ +package com.digital_tok.global.apiPayload; + +import com.digital_tok.global.apiPayload.code.BaseErrorCode; +import com.digital_tok.global.apiPayload.code.BaseSuccessCode; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@JsonPropertyOrder({"isSuccess", "code", "message", "result"}) //JSON 데이터의 순서를 고정 +public class ApiResponse { + + @JsonProperty("isSuccess") + private final Boolean isSuccess; + + @JsonProperty("code") + @JsonInclude(JsonInclude.Include.NON_NULL) + private final String code; + + @JsonProperty("message") + @JsonInclude(JsonInclude.Include.NON_NULL) + private final String message; + + @JsonProperty("result") + @JsonInclude(JsonInclude.Include.NON_NULL) // result가 null이면 JSON에 포함시키지 않음 + private final T result; + + public static ApiResponse onSuccess(BaseSuccessCode code, T result){ + return new ApiResponse<>(true, code.getCode(), code.getMessage(), result); + } + + public static ApiResponse onFailure(BaseErrorCode code, T result) { + return new ApiResponse<>(false, code.getCode(), code.getMessage(), result); + } + + public static ApiResponse onSuccessResultOnly(T result){ + return new ApiResponse<>(true, null, null, result); + } +} diff --git a/src/main/java/com/digital_tok/global/apiPayload/code/ApiErrorCodes.java b/src/main/java/com/digital_tok/global/apiPayload/code/ApiErrorCodes.java new file mode 100644 index 0000000..7941346 --- /dev/null +++ b/src/main/java/com/digital_tok/global/apiPayload/code/ApiErrorCodes.java @@ -0,0 +1,9 @@ +package com.digital_tok.global.apiPayload.code; + +import java.lang.annotation.*; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiErrorCodes { + ErrorCode[] value(); +} diff --git a/src/main/java/com/digital_tok/global/apiPayload/code/BaseErrorCode.java b/src/main/java/com/digital_tok/global/apiPayload/code/BaseErrorCode.java new file mode 100644 index 0000000..fa88a7c --- /dev/null +++ b/src/main/java/com/digital_tok/global/apiPayload/code/BaseErrorCode.java @@ -0,0 +1,9 @@ +package com.digital_tok.global.apiPayload.code; + +import org.springframework.http.HttpStatus; + +public interface BaseErrorCode { + HttpStatus getStatus(); + String getCode(); + String getMessage(); +} diff --git a/src/main/java/com/digital_tok/global/apiPayload/code/BaseSuccessCode.java b/src/main/java/com/digital_tok/global/apiPayload/code/BaseSuccessCode.java new file mode 100644 index 0000000..04ef1bc --- /dev/null +++ b/src/main/java/com/digital_tok/global/apiPayload/code/BaseSuccessCode.java @@ -0,0 +1,10 @@ +package com.digital_tok.global.apiPayload.code; + +import org.springframework.http.HttpStatus; + +public interface BaseSuccessCode { + + HttpStatus getStatus(); + String getCode(); + String getMessage(); +} diff --git a/src/main/java/com/digital_tok/global/apiPayload/code/ErrorCode.java b/src/main/java/com/digital_tok/global/apiPayload/code/ErrorCode.java new file mode 100644 index 0000000..1ae4473 --- /dev/null +++ b/src/main/java/com/digital_tok/global/apiPayload/code/ErrorCode.java @@ -0,0 +1,46 @@ +package com.digital_tok.global.apiPayload.code; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorCode implements BaseErrorCode{ + + // 공통 에러 + BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400_1", "잘못된 입력 값입니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON405_1", "허용되지 않은 메서드입니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON404_1", "요청한 리소스를 찾을 수 없습니다."), + _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 에러, 관리자에게 문의하세요."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증되어있지 않습니다."), + + // Member 관련 에러 + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "사용자를 찾을 수 없습니다."), + MEMBER_ALREADY_REGISTERED(HttpStatus.CONFLICT, "MEMBER409_1", "이미 가입된 사용자입니다."), + MEMBER_INACTIVE(HttpStatus.BAD_REQUEST, "MEMBER400_2", "기존에 가입 후 탈퇴한 회원입니다. 재로그인시 회원정보가 복구됩니다."), + + // Device 관련 에러 + DEVICE_NOT_FOUND(HttpStatus.NOT_FOUND, "DEVICE404", "요청한 기기를 찾을 수 없습니다."), + DEVICE_ALREADY_CONNECTED(HttpStatus.BAD_REQUEST, "DEVICE400", "기기가 이미 연결되어 있습니다."), + DEVICE_ALREADY_DISCONNECTED(HttpStatus.BAD_REQUEST, "DEVICE401", "기기가 연결 해제되어 있습니다."), + + // Image 관련 에러 + IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "IMAGE404_1", "이미지를 찾을 수 없습니다."), + IMAGE_MAPPING_NOT_FOUND(HttpStatus.NOT_FOUND, "IMAGE404_2", "이미지 매핑 정보를 찾을 수 없습니다."), + IMAGE_BAD_REQUEST(HttpStatus.BAD_REQUEST, "IMAGE400_1", "이미지 요청 값이 올바르지 않습니다."), + IMAGE_UPLOAD_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "IMAGE500_1", "이미지 업로드에 실패했습니다."), // 세미콜론(;) 주의 + IMAGE_TO_BINARY_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "IMAGE500_2", "이미지를 바이너리 데이터로 변환시키기에 실패했습니다."), + IMAGE_DERIVE_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "IMAGE500_3", "E-ink 파생 이미지 생성에 실패했습니다."), + + + // Template 관련 에러 + TEMPLATE_NOT_FOUND(HttpStatus.NOT_FOUND, "TEMPLATE404_1", "해당 템플릿을 찾을 수 없습니다."), + TEMPLATE_ALREADY_EXISTS(HttpStatus.CONFLICT, "TEMPLATE409_1", "이미 생성된 지하철 역 템플릿입니다."), + TEMPLATE_DELETE_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "TEMPLATE500_1", "롤백 중 파일 삭제에 실패했습니다."); + + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/digital_tok/global/apiPayload/code/SuccessCode.java b/src/main/java/com/digital_tok/global/apiPayload/code/SuccessCode.java new file mode 100644 index 0000000..8d14f08 --- /dev/null +++ b/src/main/java/com/digital_tok/global/apiPayload/code/SuccessCode.java @@ -0,0 +1,20 @@ +package com.digital_tok.global.apiPayload.code; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum SuccessCode implements BaseSuccessCode { + + OK(HttpStatus.OK, "COMMON200_1", "요청이 정상적으로 처리되었습니다."), + + DEVICE_STATUS_SUCCESS(HttpStatus.OK, "DEVICE200", "기기 상태 조회 성공"), + DEVICE_CONNECT_SUCCESS(HttpStatus.OK, "DEVICE201", "기기 연결 성공"), + DEVICE_DISCONNECT_SUCCESS(HttpStatus.OK, "DEVICE202", "기기 연결 해제 성공"); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/digital_tok/global/apiPayload/exception/GeneralException.java b/src/main/java/com/digital_tok/global/apiPayload/exception/GeneralException.java new file mode 100644 index 0000000..81d5941 --- /dev/null +++ b/src/main/java/com/digital_tok/global/apiPayload/exception/GeneralException.java @@ -0,0 +1,17 @@ +package com.digital_tok.global.apiPayload.exception; + +import com.digital_tok.global.apiPayload.code.BaseErrorCode; +import com.digital_tok.global.apiPayload.code.ErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +public class GeneralException extends RuntimeException { + + private final BaseErrorCode errorCode; + + public GeneralException(BaseErrorCode errorCode) { + super(errorCode.getMessage()); // 부모(RuntimeException)에게 에러 메시지를 넘겨줌 + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/digital_tok/global/apiPayload/handler/GeneralExceptionHandler.java b/src/main/java/com/digital_tok/global/apiPayload/handler/GeneralExceptionHandler.java new file mode 100644 index 0000000..9ad5ee2 --- /dev/null +++ b/src/main/java/com/digital_tok/global/apiPayload/handler/GeneralExceptionHandler.java @@ -0,0 +1,68 @@ +package com.digital_tok.global.apiPayload.handler; + +import com.digital_tok.global.apiPayload.ApiResponse; +import com.digital_tok.global.apiPayload.code.BaseErrorCode; +import com.digital_tok.global.apiPayload.code.ErrorCode; +import com.digital_tok.global.apiPayload.exception.GeneralException; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +@RequiredArgsConstructor +@Slf4j +public class GeneralExceptionHandler { + + // 커스텀 예외 처리 + @ExceptionHandler(GeneralException.class) + public ResponseEntity> handleGeneralException(GeneralException ex) { + + return ResponseEntity + .status(ex.getErrorCode().getStatus()) + .body(ApiResponse.onFailure(ex.getErrorCode(), null)); + } + + // @Valid 검증 실패 시 발생하는 예외 처리 + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleMethodArgumentNotValid( + MethodArgumentNotValidException ex + ) { + Map errors = new HashMap<>(); + + // 에러가 발생한 필드명과 메시지를 Map에 담음 + ex.getBindingResult().getFieldErrors().forEach(error -> + errors.put(error.getField(), error.getDefaultMessage()) + ); + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.onFailure(ErrorCode.BAD_REQUEST, errors)); + } + + // 커스텀 예외에 포함되지 않는 모든 예외 처리 + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneralException(Exception ex, HttpServletRequest request) { + + // 오류난 url과 httpMethod 가져오고 + String url = request.getRequestURI(); + String method = request.getMethod(); + + // 로그 출력 + log.error("[500 ERROR] {} {} - {}", method, url, ex.getMessage(), ex); + + BaseErrorCode code = ErrorCode._INTERNAL_SERVER_ERROR; + return ResponseEntity + .status(code.getStatus()) + .body(ApiResponse.onFailure(code, null)); + + } + +} diff --git a/src/main/java/com/digital_tok/global/config/AmazonConfig.java b/src/main/java/com/digital_tok/global/config/AmazonConfig.java new file mode 100644 index 0000000..328484c --- /dev/null +++ b/src/main/java/com/digital_tok/global/config/AmazonConfig.java @@ -0,0 +1,42 @@ +package com.digital_tok.global.config; + + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.*; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import io.awspring.cloud.s3.S3Template; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; + + +@Configuration +public class AmazonConfig { + + @Value("${spring.cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${spring.cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${spring.cloud.aws.region.static}") + private String region; + + @Bean + public AwsCredentialsProvider awsCredentialsProvider() { + AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey); + return StaticCredentialsProvider.create(credentials); + } + + @Bean + public S3Client s3Client(AwsCredentialsProvider credentialsProvider) { + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(credentialsProvider) + .build(); + } +} + diff --git a/src/main/java/com/digital_tok/global/config/SecurityConfig.java b/src/main/java/com/digital_tok/global/config/SecurityConfig.java new file mode 100644 index 0000000..8c29417 --- /dev/null +++ b/src/main/java/com/digital_tok/global/config/SecurityConfig.java @@ -0,0 +1,107 @@ +package com.digital_tok.global.config; + +import com.digital_tok.global.security.JwtAuthenticationFilter; +import com.digital_tok.global.security.JwtTokenProvider; +import com.digital_tok.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +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 java.util.List; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + + // 1. 비밀번호 암호화 Bean 등록 (BCrypt 방식) + // 회원가입 시 비밀번호를 암호화하거나, 로그인 시 비밀번호를 확인할 때 사용합니다. + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + // 2. Security Filter Chain 설정 + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + + // ORS 설정 연결 + .cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource())) + + // CSRF 비활성화 (REST API 서버는 세션을 사용하지 않으므로 보통 비활성화) + .csrf(AbstractHttpConfigurer::disable) + + // Form Login 비활성화 (JWT 방식을 쓸 것이므로) + .formLogin(AbstractHttpConfigurer::disable) + + // HTTP Basic 인증 비활성화 + .httpBasic(AbstractHttpConfigurer::disable) + + // 세션 관리 설정: STATELESS (서버에 세션을 저장하지 않음) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + // URL별 접근 권한 설정 + .authorizeHttpRequests(auth -> auth + + // ★ 1. Preflight(OPTIONS) 요청은 인증 없이 무조건 허용 (CORS 해결 핵심) + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers("/health").permitAll() + + // 인증 없이 접근 가능한 경로 (로그인, 회원가입, 스웨거 등) + .requestMatchers("/api/v1/auth/**", "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**").permitAll() + + // ADMIN 전용 - 템플릿 이미지 생성은 관리자만 가능 + .requestMatchers("/api/v1/templates/subway/generate").hasRole("ADMIN") + + // 그 외 모든 요청은 인증(로그인) 필요 + .anyRequest().authenticated() + ) + + // 필터 등록 (UsernamePasswordAuthenticationFilter 앞에 실행) + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, userRepository), + UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + // CROS 설정 + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + // 1. 허용할 프론트엔드 도메인 (운영 서버 + 로컬 개발용) + configuration.setAllowedOrigins(List.of("https://diring.site", "https://www.diring.site", "http://localhost:8080")); + + // 2. 허용할 HTTP 메서드 + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + + // 3. 허용할 헤더 + configuration.setAllowedHeaders(List.of("*")); + + // 4. 자격 증명(쿠키/토큰) 허용 + configuration.setAllowCredentials(true); + + // 5. 클라이언트가 읽을 수 있는 헤더 (JWT 토큰 반환 시 필요할 수 있음) + configuration.setExposedHeaders(List.of("Authorization")); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/global/config/SwaggerConfig.java b/src/main/java/com/digital_tok/global/config/SwaggerConfig.java new file mode 100644 index 0000000..c75e7dc --- /dev/null +++ b/src/main/java/com/digital_tok/global/config/SwaggerConfig.java @@ -0,0 +1,139 @@ +package com.digital_tok.global.config; + +import com.digital_tok.global.apiPayload.code.ApiErrorCodes; +import com.digital_tok.global.apiPayload.code.ErrorCode; +import io.swagger.v3.oas.models.servers.Server; // 추가됨 +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import io.swagger.v3.oas.models.examples.Example; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +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 org.springframework.beans.factory.annotation.Value; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Configuration +public class SwaggerConfig { + + @Value("${app.server-url}") + private String serverUrl; + + @Bean + public OpenAPI openAPI() { + // 1. Security 스키마 설정 (JWT 토큰 방식) + String jwt = "JWT"; + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwt); + + // 2. Components 설정 (헤더에 토큰을 어떻게 담을지 정의) + Components components = new Components().addSecuritySchemes(jwt, new SecurityScheme() + .name(jwt) + .type(SecurityScheme.Type.HTTP) // HTTP 방식 + .scheme("bearer") // Bearer 접두사를 사용 + .bearerFormat("JWT") // 포맷은 JWT + ); + + return new OpenAPI() + .addServersItem(new Server().url(serverUrl).description("DiRing API Server")) + .components(components) + .addSecurityItem(securityRequirement) + .info(apiInfo()); + } + + private Info apiInfo() { + return new Info() + .title("환영합니다. DiRing API 명세서입니다!") // API 문서 제목 + .description("DiRing 서버 API 문서입니다!" + + "\n 회원가입 및 로그인 해서 AccessToken 발급받고, " + + "Authorize에 AccessToken입력하시면 모든 API 동작 확인 가능합니다!") // 설명 + .version("1.0.0"); // 버전 + } + + // 예외처리 문서화 관련 커스텀 어노테이션 + @Bean + public OperationCustomizer customize() { + return (operation, handlerMethod) -> { + ApiErrorCodes apiErrorCodes = handlerMethod.getMethodAnnotation(ApiErrorCodes.class); + + // 어노테이션이 없으면 그냥 넘어감 + if (apiErrorCodes == null) { + return operation; + } + + // 1. 에러 코드들을 HTTP 상태 코드별로 그룹화 (예: 400끼리, 404끼리) + Map> statusWithExampleHolders = Arrays.stream(apiErrorCodes.value()) + .map(this::generateExampleHolder) + .collect(Collectors.groupingBy(ExampleHolder::getStatusCode)); + + // 2. ApiResponses에 등록 + addExamplesToResponses(operation.getResponses(), statusWithExampleHolders); + + return operation; + }; + } + + // ErrorCode를 받아서 내부용 ExampleHolder 객체로 변환 + private ExampleHolder generateExampleHolder(ErrorCode errorCode) { + return ExampleHolder.builder() + .statusCode(errorCode.getStatus().value()) + .holderName(errorCode.name()) // 예: MEMBER_NOT_FOUND + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .build(); + } + + // Swagger Response에 예시 추가하는 로직 + private void addExamplesToResponses(ApiResponses responses, Map> statusWithExampleHolders) { + statusWithExampleHolders.forEach((status, examples) -> { + Content content = new Content(); + MediaType mediaType = new MediaType(); + + // 하나의 상태 코드에 여러 예시가 있을 경우 (예: 같은 400인데 비번틀림, 파라미터오류 등) + examples.forEach(example -> { + mediaType.addExamples(example.getHolderName(), new Example() + .value(generateErrorResponse(example.getCode(), example.getMessage())) // JSON 생성 + .description(example.getMessage())); // 설명 추가 + }); + + content.addMediaType("application/json", mediaType); + + // 기존에 200 응답만 있고 에러 응답이 없으면 새로 추가 + ApiResponse apiResponse = new ApiResponse(); + apiResponse.setContent(content); + apiResponse.setDescription("에러 응답 (상세 예시는 Example Value의 message 확인)"); + + responses.addApiResponse(String.valueOf(status), apiResponse); + }); + } + + // 실제 JSON 형태 만들기 + private Map generateErrorResponse(String code, String message) { + Map map = new java.util.LinkedHashMap<>(); + map.put("isSuccess", false); + map.put("code", code); + map.put("message", message); + map.put("result", null); // 이제 null도 안전하게 들어감 + return map; + } + + // 내부에서 사용할 DTO + @lombok.Builder + @lombok.Getter + private static class ExampleHolder { + private int statusCode; + private String holderName; + private String code; + private String message; + } +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/global/controller/HealthController.java b/src/main/java/com/digital_tok/global/controller/HealthController.java new file mode 100644 index 0000000..ad07af2 --- /dev/null +++ b/src/main/java/com/digital_tok/global/controller/HealthController.java @@ -0,0 +1,14 @@ +package com.digital_tok.global.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HealthController { + + @GetMapping("/health") + public String healthCheck() { + // 나중에 현재 실행 중인 프로필(dev/prod) 등을 반환하게 확장할 수도 있습니다. + return "잘 작동중입니다~~"; + } +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/global/entity/BaseEntity.java b/src/main/java/com/digital_tok/global/entity/BaseEntity.java new file mode 100644 index 0000000..fc2b481 --- /dev/null +++ b/src/main/java/com/digital_tok/global/entity/BaseEntity.java @@ -0,0 +1,34 @@ +package com.digital_tok.global.entity; + + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter +@SuperBuilder +@NoArgsConstructor +public class BaseEntity { + + @CreatedDate + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + +} diff --git a/src/main/java/com/digital_tok/global/event/S3ImageRollbackEvent.java b/src/main/java/com/digital_tok/global/event/S3ImageRollbackEvent.java new file mode 100644 index 0000000..686492b --- /dev/null +++ b/src/main/java/com/digital_tok/global/event/S3ImageRollbackEvent.java @@ -0,0 +1,4 @@ +package com.digital_tok.global.event; + +public record S3ImageRollbackEvent(String fileUrl) { +} diff --git a/src/main/java/com/digital_tok/global/event/S3RollbackEventHandler.java b/src/main/java/com/digital_tok/global/event/S3RollbackEventHandler.java new file mode 100644 index 0000000..0c1cd3c --- /dev/null +++ b/src/main/java/com/digital_tok/global/event/S3RollbackEventHandler.java @@ -0,0 +1,34 @@ +package com.digital_tok.global.event; + +import com.digital_tok.template.service.makeSubwayImage.S3UploadService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class S3RollbackEventHandler { + + private final S3UploadService s3UploadService; + + @Async // 별도 스레드에서 실행 + @EventListener + public void handleRollback(S3ImageRollbackEvent event) { + String fileUrl = event.fileUrl(); + if (fileUrl == null || fileUrl.isBlank()) { + return; + } + + try { + log.info("[Rollback] S3 파일 삭제 시도: {}", fileUrl); + s3UploadService.deleteFile(fileUrl); + log.info("[Rollback] S3 파일 삭제 성공"); + } catch (Exception e) { + // 삭제 실패 시 로그를 남겨서 나중에 수동으로라도 지울 수 있게 해야 함 + log.error("[Rollback] S3 파일 삭제 실패 (수동 확인 필요). URL: {}, Error: {}", fileUrl, e.getMessage()); + } + } +} diff --git a/src/main/java/com/digital_tok/global/security/JwtAuthenticationFilter.java b/src/main/java/com/digital_tok/global/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..be3f484 --- /dev/null +++ b/src/main/java/com/digital_tok/global/security/JwtAuthenticationFilter.java @@ -0,0 +1,76 @@ +package com.digital_tok.global.security; + +import com.digital_tok.user.domain.User; +import com.digital_tok.user.repository.UserRepository; +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; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; +import com.digital_tok.user.domain.UserStatus; + +import java.io.IOException; + +// (패키지, 임포트 동일) + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + // 1. Request Header에서 토큰 추출 + String token = resolveToken(request); + + // 2. 토큰 유효성 검사 + try { + if (token != null && jwtTokenProvider.validateToken(token)) { + // 토큰이 유효할 경우만 Authentication 객체 생성 + Long userId = jwtTokenProvider.getUserId(token); + + User user = userRepository.findById(userId).orElse(null); + + + + // 유저가 존재하고, 상태가 ACTIVE(활동 중)인 경우에만 인증 처리 + if (user != null && user.getStatus() == UserStatus.ACTIVE) { + PrincipalDetails principalDetails = new PrincipalDetails(user); + Authentication authentication = new UsernamePasswordAuthenticationToken( + principalDetails, + null, + principalDetails.getAuthorities() + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + } else if (user != null && user.getStatus() != UserStatus.ACTIVE) { + log.warn("탈퇴하거나 정지된 계정의 접근 시도입니다. userId: {}", userId); + // 여기서 바로 에러 응답을 보내거나, SecurityContext를 비워두어 뒤쪽 필터에서 401/403이 뜸 + } + } + } catch (Exception e) { + // 🚨 중요: 토큰 검증 중 에러가 나도(만료, 위조 등) SecurityContext에 저장만 안 할 뿐, + // 필터 체인은 계속 진행시켜야 합니다. 그래야 permitAll 경로(Swagger 등)로 들어갈 수 있습니다. + log.error("토큰 검증 실패 (로그인 정보 없이 요청 처리): {}", e.getMessage()); + } + + // 3. 다음 필터로 진행 (이게 없으면 하얀 화면만 뜸) + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/global/security/JwtTokenProvider.java b/src/main/java/com/digital_tok/global/security/JwtTokenProvider.java new file mode 100644 index 0000000..7ddbb2a --- /dev/null +++ b/src/main/java/com/digital_tok/global/security/JwtTokenProvider.java @@ -0,0 +1,109 @@ +package com.digital_tok.global.security; + +import com.digital_tok.user.domain.Role; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; + +@Slf4j +@Component +public class JwtTokenProvider { + + @Value("${jwt.secret}") + private String secretKey; + + @Value("${jwt.access-token-validity-in-seconds}") + private long accessTokenValidityInSeconds; + + @Value("${jwt.refresh-token-validity-in-seconds}") + private long refreshTokenValidityInSeconds; + + private SecretKey key; + + // 객체 초기화 후 비밀키를 Base64 디코딩하여 Key 객체로 변환 + @PostConstruct + public void init() { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + // 1. Access Token 생성 + public String createAccessToken(Long userId, String email, Role role) { + Date now = new Date(); + Date validity = new Date(now.getTime() + (accessTokenValidityInSeconds * 1000)); + + return Jwts.builder() + .subject(email) // 토큰 제목 (Subject) -> 이메일 + .claim("userId", userId) // 비공개 클레임 -> 유저 ID + .claim("role", role.name()) // 비공개 클레임 -> 권한 정보 + .issuedAt(now) // 토큰 발행 시간 + .expiration(validity) // 토큰 만료 시간 + .signWith(key) // 암호화 알고리즘 및 비밀키 + .compact(); + } + + // 2. Refresh Token 생성 (보안상 클레임을 최소화) + public String createRefreshToken(Long userId) { + Date now = new Date(); + Date validity = new Date(now.getTime() + (refreshTokenValidityInSeconds * 1000)); + + return Jwts.builder() + .subject(String.valueOf(userId)) // 리프레시 토큰은 유저 ID만 담음 + .claim("userId", userId) // Refresh Token도 userId를 claim 으로 받도록 수정 + .issuedAt(now) + .expiration(validity) + .signWith(key) + .compact(); + } + + // 3. 토큰에서 User ID 추출 + // 안전한 방법 (Number로 받아서 Long으로 변환) 수정 + public Long getUserId(String token) { + // 1. 우선 Number 타입으로 가져옴 (Integer든 Long이든 다 받을 수 있음) + Number userId = parseClaims(token).get("userId", Number.class); + + // 2. 값이 없으면 에러, 있으면 longValue()로 안전하게 변환 + if (userId == null) { + throw new IllegalArgumentException("토큰에 userId가 없습니다."); + } + return userId.longValue(); + } + + // 4. 토큰에서 Email 추출 + public String getEmail(String token) { + return parseClaims(token).getSubject(); + } + + // 5. 토큰 유효성 검증 + public boolean validateToken(String token) { + try { + parseClaims(token); + return true; + } catch (SecurityException | MalformedJwtException e) { + log.error("잘못된 JWT 서명입니다."); + } catch (ExpiredJwtException e) { + log.error("만료된 JWT 토큰입니다."); + } catch (UnsupportedJwtException e) { + log.error("지원되지 않는 JWT 토큰입니다."); + } catch (IllegalArgumentException e) { + log.error("JWT 토큰이 잘못되었습니다."); + } + return false; + } + + // 내부 메서드: 토큰 파싱 (Claims 추출) + private Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/global/security/PrincipalDetails.java b/src/main/java/com/digital_tok/global/security/PrincipalDetails.java new file mode 100644 index 0000000..854d488 --- /dev/null +++ b/src/main/java/com/digital_tok/global/security/PrincipalDetails.java @@ -0,0 +1,49 @@ +package com.digital_tok.global.security; + +import com.digital_tok.user.domain.User; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Collections; + +@Getter +@RequiredArgsConstructor +public class PrincipalDetails implements UserDetails { + + private final User user; // 우리의 User 엔티티를 품고 있음 + + // 권한 반환 + @Override + public Collection getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority(user.getRole().name())); + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return user.getEmail(); // 보통 식별자로 이메일을 사용 + } + + // 계정 만료/잠금 여부 등 (true로 설정해야 로그인 가능) + @Override + public boolean isAccountNonExpired() { return true; } + @Override + public boolean isAccountNonLocked() { return true; } + @Override + public boolean isCredentialsNonExpired() { return true; } + @Override + public boolean isEnabled() { return true; } + + // 편의 메서드: User ID 바로 꺼내기 + public Long getUserId() { + return user.getId(); + } +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/image/controller/ImageController.java b/src/main/java/com/digital_tok/image/controller/ImageController.java new file mode 100644 index 0000000..e570028 --- /dev/null +++ b/src/main/java/com/digital_tok/image/controller/ImageController.java @@ -0,0 +1,154 @@ +package com.digital_tok.image.controller; + +import com.digital_tok.global.apiPayload.ApiResponse; +import com.digital_tok.global.apiPayload.code.ApiErrorCodes; +import com.digital_tok.global.apiPayload.code.ErrorCode; +import com.digital_tok.global.apiPayload.code.SuccessCode; +import com.digital_tok.global.apiPayload.exception.GeneralException; +import com.digital_tok.global.security.PrincipalDetails; +import com.digital_tok.image.converter.ImageConverter; +import com.digital_tok.image.dto.ImageRequestDTO; +import com.digital_tok.image.dto.ImageResponseDTO; +import com.digital_tok.image.service.ImageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/images") +public class ImageController implements ImageControllerDocs { + + private final ImageService imageService; + private final ImageConverter imageConverter; + + private Long requireUserId(PrincipalDetails principal) { + if (principal == null || principal.getUser() == null || principal.getUserId() == null) { + throw new GeneralException(ErrorCode.UNAUTHORIZED); + } + return principal.getUserId(); + } + + /** + * 최근 사용한 사진 조회 API (토큰 기반) + */ + @Override + @GetMapping("/recent") + @ApiErrorCodes({ + ErrorCode.UNAUTHORIZED, + ErrorCode.IMAGE_BAD_REQUEST + }) + public ApiResponse getRecentImages( + @AuthenticationPrincipal PrincipalDetails principal + ) { + Long userId = requireUserId(principal); + + var mappings = imageService.getRecentImageMappings(userId); + var result = imageConverter.toRecentImageListDto(mappings); + + return ApiResponse.onSuccess(SuccessCode.OK, result); + } + + /** + * 이미지 업로드 API (토큰 기반) + */ + @Override + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @ApiErrorCodes({ + ErrorCode.UNAUTHORIZED, + ErrorCode.IMAGE_BAD_REQUEST, + ErrorCode.IMAGE_UPLOAD_FAIL + // NOTE: IMAGE_DERIVE_FAIL은 현재 정책상 throw 하지 않으므로 문서화에서 제외 + }) + public ApiResponse uploadImage( + @AuthenticationPrincipal PrincipalDetails principal, + @RequestPart("file") MultipartFile file, + @RequestParam("imageName") String imageName + ) { + Long userId = requireUserId(principal); + + var r = imageService.uploadImage(file, imageName, userId); + var result = imageConverter.toUploadResultDto(r); + + return ApiResponse.onSuccess(SuccessCode.OK, result); + } + + /** + * 이미지 미리보기 조회 API + * (유저 구분이 필요 없으면 토큰 없이도 가능) + */ + @Override + @GetMapping("/{imageId}/preview") + @ApiErrorCodes({ + ErrorCode.IMAGE_BAD_REQUEST, + ErrorCode.IMAGE_NOT_FOUND + }) + public ApiResponse getImagePreview( + @PathVariable Long imageId + ) { + var r = imageService.getPreview(imageId); + var result = imageConverter.toPreviewResultDto(r); + + return ApiResponse.onSuccess(SuccessCode.OK, result); + } + + /** + * 이미지 바이너리 데이터 URL 조회 API (토큰 기반) + */ + @Override + @GetMapping("/{imageId}/binary") + @ApiErrorCodes({ + ErrorCode.UNAUTHORIZED, + ErrorCode.IMAGE_BAD_REQUEST, + ErrorCode.IMAGE_NOT_FOUND, + ErrorCode._INTERNAL_SERVER_ERROR + }) + public ApiResponse getImageBinary( + @AuthenticationPrincipal PrincipalDetails principal, + @PathVariable Long imageId + ) { + Long userId = requireUserId(principal); + + var r = imageService.getBinary(userId, imageId); + var result = imageConverter.toBinaryResultDto(r); + + return ApiResponse.onSuccessResultOnly(result); + } + + /** + * 이미지 즐겨찾기 등록/해제 API (토큰 기반) + */ + @Override + @PatchMapping("/{imageId}/favorite") + @ApiErrorCodes({ + ErrorCode.UNAUTHORIZED, + ErrorCode.IMAGE_BAD_REQUEST, + ErrorCode.IMAGE_MAPPING_NOT_FOUND, + ErrorCode._INTERNAL_SERVER_ERROR + }) + public ApiResponse toggleFavorite( + @AuthenticationPrincipal PrincipalDetails principal, + @PathVariable Long imageId, + @RequestBody @Valid ImageRequestDTO.FavoriteDto request + ) { + Long userId = requireUserId(principal); + + var r = imageService.updateFavorite(userId, imageId, request.getIsFavorite()); + + var result = imageConverter.toFavoriteResultDto( + userId, + imageId, + r.isFavorite(), + r.updatedAt() + ); + + return ApiResponse.onSuccess(SuccessCode.OK, result); + } +} diff --git a/src/main/java/com/digital_tok/image/controller/ImageControllerDocs.java b/src/main/java/com/digital_tok/image/controller/ImageControllerDocs.java new file mode 100644 index 0000000..9693b8f --- /dev/null +++ b/src/main/java/com/digital_tok/image/controller/ImageControllerDocs.java @@ -0,0 +1,105 @@ +package com.digital_tok.image.controller; + +import com.digital_tok.global.apiPayload.ApiResponse; +import com.digital_tok.global.security.PrincipalDetails; +import com.digital_tok.image.dto.ImageRequestDTO; +import com.digital_tok.image.dto.ImageResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "Image", description = "이미지 관련 API") +public interface ImageControllerDocs { + + @Operation(summary = "최근 사용한 사진 조회 API By 송정은 (개발 완료)", description = "사용자가 최근에 사용한 사진 목록을 조회합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = ImageResponseDTO.RecentImageListDto.class)) + ) + }) + ApiResponse getRecentImages( + @Parameter(hidden = true) + @AuthenticationPrincipal PrincipalDetails principal + ); + + @Operation(summary = "이미지 업로드 API By 송정은 (개발완료) ", description = "이미지 파일과 이름을 받아 서버에 업로드합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "업로드 성공", + content = @Content(schema = @Schema(implementation = ImageResponseDTO.UploadResultDto.class)) + ) + }) + ApiResponse uploadImage( + @Parameter(hidden = true) + @AuthenticationPrincipal PrincipalDetails principal, + + @Parameter( + description = "업로드할 이미지 파일", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) + ) + @RequestPart("file") MultipartFile file, + + @Parameter(description = "이미지 이름", example = "myphoto_001") + @RequestParam("imageName") String imageName + ); + + @Operation(summary = "이미지 미리보기 조회 API By 송정은 (개발 완료)", description = "이미지 ID로 미리보기 URL을 조회합니다. previewUrl이 없으면 originalUrl로 fallback합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = ImageResponseDTO.PreviewResultDto.class)) + ) + }) + ApiResponse getImagePreview( + @Parameter(description = "이미지 ID", example = "55") + @PathVariable Long imageId + ); + + @Operation(summary = "이미지 바이너리 데이터 조회 API By 송정은 (개발 완료)", description = "기기로 전송할 변환된 E-ink 바이너리 파일(.bin)의 URL을 조회합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = ImageResponseDTO.BinaryResultDto.class)) + ) + }) + ApiResponse getImageBinary( + @Parameter(hidden = true) + @AuthenticationPrincipal PrincipalDetails principal, + + @Parameter(description = "이미지 ID", example = "55") + @PathVariable Long imageId + ); + + @Operation(summary = "이미지 즐겨찾기 등록/해제 API By 송정은 (개발완료)", description = "이미지의 즐겨찾기 상태를 변경합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "변경 성공", + content = @Content(schema = @Schema(implementation = ImageResponseDTO.FavoriteResultDto.class)) + ) + }) + ApiResponse toggleFavorite( + @Parameter(hidden = true) + @AuthenticationPrincipal PrincipalDetails principal, + + @Parameter(description = "이미지 ID", example = "55") + @PathVariable Long imageId, + + @RequestBody ImageRequestDTO.FavoriteDto request + ); +} diff --git a/src/main/java/com/digital_tok/image/converter/ImageConverter.java b/src/main/java/com/digital_tok/image/converter/ImageConverter.java new file mode 100644 index 0000000..7275b92 --- /dev/null +++ b/src/main/java/com/digital_tok/image/converter/ImageConverter.java @@ -0,0 +1,115 @@ +package com.digital_tok.image.converter; + +import com.digital_tok.image.domain.ImageMapping; +import com.digital_tok.image.dto.ImageResponseDTO; +import com.digital_tok.image.service.ImageService; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; + +@Component +public class ImageConverter { + + /** + * uploadImage 응답 변환 + */ + public ImageResponseDTO.UploadResultDto toUploadResultDto(ImageService.UploadResult r) { + + ImageResponseDTO.UploadedImageDto imageDto = ImageResponseDTO.UploadedImageDto.builder() + .imageId(r.image().getImageId()) + .originalUrl(r.image().getOriginalUrl()) + .previewUrl(r.image().getPreviewUrl()) + .einkDataUrl(r.image().getEinkDataUrl()) + .imageName(r.image().getImageName()) + .createdAt(r.image().getCreatedAt()) + .deletedAt(r.image().getDeletedAt()) + .build(); + + ImageResponseDTO.UploadedImageMappingDto mappingDto = ImageResponseDTO.UploadedImageMappingDto.builder() + .userImageId(r.mapping().getUserImageId()) + .userId(r.mapping().getUserId()) + .imageId(r.image().getImageId()) + .isFavorite(r.mapping().getIsFavorite()) + .savedAt(r.mapping().getSavedAt()) + .lastUsedAt(r.mapping().getLastUsedAt()) + .build(); + + return ImageResponseDTO.UploadResultDto.builder() + .image(imageDto) + .imageMapping(mappingDto) + .build(); + } + + /** + * preview 응답 변환 + */ + public ImageResponseDTO.PreviewResultDto toPreviewResultDto(ImageService.PreviewResult r) { + return ImageResponseDTO.PreviewResultDto.builder() + .imageId(r.imageId()) + .previewUrl(r.previewUrl()) + .updatedAt(r.updatedAt()) + .build(); + } + + /** + * binary 응답 변환 (meta 포함) + */ + public ImageResponseDTO.BinaryResultDto toBinaryResultDto(ImageService.BinaryResult r) { + return ImageResponseDTO.BinaryResultDto.builder() + .imageId(r.imageId()) + .einkDataUrl(r.einkDataUrl()) + .lastUsedAt(r.lastUsedAt()) + .meta(defaultBinaryMeta()) + .build(); + } + + private ImageResponseDTO.BinaryResultDto.MetaDto defaultBinaryMeta() { + return ImageResponseDTO.BinaryResultDto.MetaDto.builder() + .width(200) + .height(200) + .bpp(2) + .palette("Black=0, White=1, Yellow=2, Red=3") + .packing("MSB-first") + .scan("row-major") + .payloadBytes(10000) + .hasHeader(false) + .build(); + } + + /** + * recent 목록 응답 변환 (Entity/List -> DTO) + */ + public ImageResponseDTO.RecentImageListDto toRecentImageListDto(List mappings) { + + List items = mappings.stream() + .map(m -> ImageResponseDTO.RecentImageDto.builder() + .imageId(m.getImage().getImageId()) + .previewUrl(m.getImage().getPreviewUrl()) + .imageName(m.getImage().getImageName()) + .isFavorite(m.getIsFavorite()) + .lastUsedAt(m.getLastUsedAt()) + .build()) + .toList(); + + return ImageResponseDTO.RecentImageListDto.builder() + .count(items.size()) + .items(items) + .build(); + } + + /** + * favorite 응답 변환 + */ + public ImageResponseDTO.FavoriteResultDto toFavoriteResultDto( + Long userId, Long imageId, Boolean isFavorite, LocalDateTime updatedAt + ) { + return ImageResponseDTO.FavoriteResultDto.builder() + .userId(userId) + .imageId(imageId) + .isFavorite(isFavorite) + .favoriteCount(null) // 아직 계산 로직 없으면 null 유지 + .updatedAt(updatedAt) + .build(); + } +} diff --git a/src/main/java/com/digital_tok/image/domain/Image.java b/src/main/java/com/digital_tok/image/domain/Image.java new file mode 100644 index 0000000..87947c8 --- /dev/null +++ b/src/main/java/com/digital_tok/image/domain/Image.java @@ -0,0 +1,60 @@ +package com.digital_tok.image.domain; + +import jakarta.persistence.*; +import lombok.*; + + +import java.time.LocalDateTime; + +@Entity +@Table(name = "image") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Image { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "image_id") + private Long imageId; + + @Column(name = "original_url", length = 2048, nullable = false) + private String originalUrl; + + @Column(name = "preview_url", length = 2048) + private String previewUrl; + + @Column(name = "eink_data_url", length = 2048) + private String einkDataUrl; + + //@Column(name = "category", length = 50, nullable = false) + //private String category; + + @Column(name = "image_name", length = 255, nullable = false) + private String imageName; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + // 지하철 템플릿 ID (FK)-삭제 + + + public void updatePreviewUrl(String previewUrl) { + this.previewUrl = previewUrl; + } + + public void updateEinkDataUrl(String einkDataUrl) { + this.einkDataUrl = einkDataUrl; + } + + //notnull일때 저장 실패 막음 + @PrePersist + public void prePersist() { + if (this.createdAt == null) this.createdAt = LocalDateTime.now(); + } + +} diff --git a/src/main/java/com/digital_tok/image/domain/ImageMapping.java b/src/main/java/com/digital_tok/image/domain/ImageMapping.java new file mode 100644 index 0000000..f17799e --- /dev/null +++ b/src/main/java/com/digital_tok/image/domain/ImageMapping.java @@ -0,0 +1,52 @@ +package com.digital_tok.image.domain; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "image_mapping") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class ImageMapping { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_image_id") + private Long userImageId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "image_id", nullable = false) + private Image image; + + @Column(name = "is_favorite", nullable = false) + private Boolean isFavorite; + + @Column(name = "last_used_at") + private LocalDateTime lastUsedAt; + + @Column(name = "saved_at", nullable = false) + private LocalDateTime savedAt; + + public void touchLastUsedAt(LocalDateTime now) { + this.lastUsedAt = now; + } + public void updateFavorite(Boolean isFavorite) { + this.isFavorite = isFavorite; + } + + + //notnull 일때 저장실패 막음 + @PrePersist + public void prePersist() { + if (this.savedAt == null) this.savedAt = LocalDateTime.now(); + if (this.isFavorite == null) this.isFavorite = false; + } + +} diff --git a/src/main/java/com/digital_tok/image/dto/ImageRequestDTO.java b/src/main/java/com/digital_tok/image/dto/ImageRequestDTO.java new file mode 100644 index 0000000..4c13f28 --- /dev/null +++ b/src/main/java/com/digital_tok/image/dto/ImageRequestDTO.java @@ -0,0 +1,15 @@ +package com.digital_tok.image.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +public class ImageRequestDTO { + + @Getter + public static class FavoriteDto { + @Schema(description = "즐겨찾기 설정 여부 (true: 등록, false: 해제)", example = "true") + @NotNull(message = "즐겨찾기 설정 여부는 필수입니다(NotNull).") + private Boolean isFavorite; + } +} diff --git a/src/main/java/com/digital_tok/image/dto/ImageResponseDTO.java b/src/main/java/com/digital_tok/image/dto/ImageResponseDTO.java new file mode 100644 index 0000000..219e13b --- /dev/null +++ b/src/main/java/com/digital_tok/image/dto/ImageResponseDTO.java @@ -0,0 +1,207 @@ +package com.digital_tok.image.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + + +public class ImageResponseDTO { + + /** + * 최근 사용한 사진 조회 API + */ + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class RecentImageListDto { + @Schema(description = "리스트에 포함된 이미지 개수", example = "3") + private Integer count; + + @Schema(description = "최근 사용한 이미지 리스트") + private List items; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class RecentImageDto { + @Schema(description = "이미지 ID", example = "301") + private Long imageId; + + @Schema(description = "이미지 미리보기 URL", example = "https://cdn.dirring.com/images/preview/301.png") + private String previewUrl; + + //@Schema(description = "이미지 카테고리 (SUBWAY_PRESET / USER_PHOTO)", example = "SUBWAY_PRESET") + //private String category; + + @Schema(description = "이미지 원본 파일명", example = "강남_2호선") + private String imageName; + + @Schema(description = "즐겨찾기 여부", example = "true") + private Boolean isFavorite; + + @Schema(description = "마지막 사용 시각", example = "2026-01-12T12:34:56") + private LocalDateTime lastUsedAt; + + //@Schema(description = "지하철 템플릿 ID (없으면 null)", example = "12") + //private Long subwayTemplateId; + } + + /** + * 이미지 업로드 응답 DTO + */ + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class UploadResultDto { + private UploadedImageDto image; + private UploadedImageMappingDto imageMapping; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class UploadedImageDto { + @Schema(description = "이미지 ID", example = "55") + private Long imageId; + + @Schema(description = "원본 이미지 URL", example = "/images/original/55.jpg") + private String originalUrl; + + @Schema(description = "미리보기 URL", example = "null") + private String previewUrl; + + @Schema(description = "E-ink 데이터 URL", example = "null") + private String einkDataUrl; + + //@Schema(description = "카테고리", example = "USER_PHOTO") + //private String category; + + @Schema(description = "이미지 이름", example = "myphoto_001") + private String imageName; + + @Schema(description = "생성 일시") + private LocalDateTime createdAt; + + @Schema(description = "삭제 일시") + private LocalDateTime deletedAt; + + //@Schema(description = "지하철 템플릿 ID") + //private Long subwayTemplateId; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class UploadedImageMappingDto { + @Schema(description = "매핑 ID", example = "102") + private Long userImageId; + + @Schema(description = "유저 ID", example = "7") + private Long userId; + + @Schema(description = "이미지 ID", example = "55") + private Long imageId; + + @Schema(description = "즐겨찾기 여부", example = "false") + private Boolean isFavorite; + + @Schema(description = "저장 일시") + private LocalDateTime savedAt; + + @Schema(description = "최근 사용 일시") + private LocalDateTime lastUsedAt; + } + + /** + * 이미지 미리보기 응답 DTO + */ + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class PreviewResultDto { + @Schema(description = "이미지 ID", example = "55") + private Long imageId; + + @Schema(description = "미리보기 이미지 URL", example = "https://cdn.diring.com/images/preview/55.png") + private String previewUrl; + + @Schema(description = "미리보기 갱신 일시", example = "2026-01-12T21:40:11") + private LocalDateTime updatedAt; + } + + /** + * 기기로 보낼 바이너리 데이터 요청 응답 DTO + */ + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class BinaryResultDto { + @Schema(description = "이미지 ID", example = "55") + private Long imageId; + + @Schema(description = "E-ink 바이너리 데이터 다운로드 URL", example = "https://cdn.diring.com/eink/55.bin") + private String einkDataUrl; + + @Schema(description = "마지막 사용 일시", example = "2026-01-12T22:10:20") + private LocalDateTime lastUsedAt; + + @Schema(description = "메타데이터", example = "meta{...}") + private MetaDto meta; + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class MetaDto { + private Integer width; + private Integer height; + private Integer bpp; + private String palette; + private String packing; + private String scan; + private Integer payloadBytes; + private Boolean hasHeader; + } + + } + + // ImageResponseDTO.java 내부에 추가 + + /** + * 즐겨찾기 등록/해체 응답 DTO + */ + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class FavoriteResultDto { + @Schema(description = "유저 ID", example = "10") + private Long userId; + + @Schema(description = "이미지 ID", example = "55") + private Long imageId; + + @Schema(description = "즐겨찾기 여부", example = "true") + private Boolean isFavorite; + + @Schema(description = "현재 즐겨찾기 수", example = "1") + private Integer favoriteCount; + + @Schema(description = "갱신 일시", example = "2026-01-12T22:20:10") + private LocalDateTime updatedAt; + } + +} diff --git a/src/main/java/com/digital_tok/image/repository/ImageMappingRepository.java b/src/main/java/com/digital_tok/image/repository/ImageMappingRepository.java new file mode 100644 index 0000000..b95e394 --- /dev/null +++ b/src/main/java/com/digital_tok/image/repository/ImageMappingRepository.java @@ -0,0 +1,24 @@ +package com.digital_tok.image.repository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + + +import com.digital_tok.image.domain.Image; +import com.digital_tok.image.domain.ImageMapping; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ImageMappingRepository extends JpaRepository { + + //Optional findByUserIdAndImage(Long userId, Long imageId); + + Optional findByUserIdAndImage_ImageId(Long userId, Long imageId); + Page + findByUserIdAndLastUsedAtIsNotNullAndImage_DeletedAtIsNullOrderByLastUsedAtDesc( + Long userId, + Pageable pageable + ); + +} diff --git a/src/main/java/com/digital_tok/image/repository/ImageRepository.java b/src/main/java/com/digital_tok/image/repository/ImageRepository.java new file mode 100644 index 0000000..9fd6ef4 --- /dev/null +++ b/src/main/java/com/digital_tok/image/repository/ImageRepository.java @@ -0,0 +1,7 @@ +package com.digital_tok.image.repository; + +import com.digital_tok.image.domain.Image; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ImageRepository extends JpaRepository { +} diff --git a/src/main/java/com/digital_tok/image/service/ImageDerivationService.java b/src/main/java/com/digital_tok/image/service/ImageDerivationService.java new file mode 100644 index 0000000..8330e2a --- /dev/null +++ b/src/main/java/com/digital_tok/image/service/ImageDerivationService.java @@ -0,0 +1,86 @@ +package com.digital_tok.image.service; + +import com.digital_tok.image.service.processing.*; +import com.digital_tok.image.service.storage.StorageService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; +import java.util.UUID; + +@Slf4j +@Service +public class ImageDerivationService { + + private final StorageService storageService; + + private final EinkEncodingOption option = new EinkEncodingOption(); + private final EinkImagePreprocessor preprocessor = new EinkImagePreprocessor(option); + private final EinkQuantizer quantizer = new EinkQuantizer(); + private final EinkPreviewRenderer previewRenderer = new EinkPreviewRenderer(); + private final EinkBinaryEncoder binaryEncoder = new EinkBinaryEncoder(option); + + public ImageDerivationService(StorageService storageService) { + this.storageService = storageService; + } + + /** + * 원본 이미지 스트림을 받아 + * - e-ink preview PNG + * - e-ink binary (.bin) + * 를 생성하고 각각 업로드한 URL을 반환한다. + */ + public Result derive(InputStream originalImageStream) { + log.debug("Start derive. storageService={}", storageService.getClass().getSimpleName()); + + try { + // 1) 원본 로드 + BufferedImage original = ImageIO.read(originalImageStream); + if (original == null) { + throw new IllegalArgumentException("Unsupported image format"); + } + + // 2) 200x200 맞추기 + BufferedImage resized = preprocessor.to200x200(original); + + // 3) 4색 양자화 + BufferedImage quantized = + quantizer.quantizeTo4Colors(resized, EinkQuantizer.DitherMode.FLOYD_STEINBERG); + + // 4) Preview PNG 생성 + byte[] previewPng = previewRenderer.renderPng(quantized); + + // 5) Eink binary 생성 + byte[] einkBinary = binaryEncoder.encode(quantized); + log.debug("Eink binary encoded. bytes={}", einkBinary.length); + + // 6) 업로드 + String baseKey = "images/eink/" + UUID.randomUUID(); + + String previewUrl = storageService.uploadPreview( + previewPng, + baseKey + "_preview.png", + "image/png" + ); + + String einkDataUrl = storageService.uploadEink( + einkBinary, + baseKey + ".bin", + "application/octet-stream" + ); + + log.info("Derive uploaded. previewUrl={}, einkDataUrl={}", previewUrl, einkDataUrl); + + return new Result(previewUrl, einkDataUrl); + + } catch (IOException e) { + log.error("Failed to derive eink images (IO error)", e); + throw new IllegalStateException("Failed to derive eink images", e); + } + } + + public record Result(String previewUrl, String einkDataUrl) {} +} diff --git a/src/main/java/com/digital_tok/image/service/ImageService.java b/src/main/java/com/digital_tok/image/service/ImageService.java new file mode 100644 index 0000000..f883569 --- /dev/null +++ b/src/main/java/com/digital_tok/image/service/ImageService.java @@ -0,0 +1,207 @@ +package com.digital_tok.image.service; + +import com.digital_tok.global.AmazonS3Manager; +import com.digital_tok.global.apiPayload.code.ErrorCode; +import com.digital_tok.global.apiPayload.exception.GeneralException; +import com.digital_tok.image.domain.Image; +import com.digital_tok.image.domain.ImageMapping; +import com.digital_tok.image.repository.ImageMappingRepository; +import com.digital_tok.image.repository.ImageRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class ImageService { + + private final ImageRepository imageRepository; + private final ImageMappingRepository imageMappingRepository; + private final AmazonS3Manager s3Manager; + private final ImageDerivationService imageDerivationService; + + /** + * 이미지 업로드: S3 업로드 + DB(image, image_mapping) 저장 + */ + public UploadResult uploadImage(MultipartFile file, String imageName, Long userId) { + + // param validation + if (file == null || file.isEmpty() || imageName == null || imageName.isBlank() || userId == null) { + throw new GeneralException(ErrorCode.IMAGE_BAD_REQUEST); + } + + byte[] originalBytes; + try { + originalBytes = file.getBytes(); + } catch (Exception e) { + log.warn("Failed to read uploaded file bytes. imageName={}, userId={}", imageName, userId, e); + throw new GeneralException(ErrorCode.IMAGE_UPLOAD_FAIL); + } + + // 1) S3 업로드 (원본) + String originalUrl; + try { + originalUrl = s3Manager.uploadFile("images", file); + } catch (Exception e) { + log.error("S3 upload failed. imageName={}, userId={}", imageName, userId, e); + throw new GeneralException(ErrorCode.IMAGE_UPLOAD_FAIL); + } + + // 2) e-ink 파생 생성 (실패해도 업로드 성공 유지: fallback 정책) + ImageDerivationService.Result derived; + try { + derived = imageDerivationService.derive(new ByteArrayInputStream(originalBytes)); + log.info("Derived eink assets done. previewUrl={}, einkDataUrl={}", derived.previewUrl(), derived.einkDataUrl()); + } catch (Exception e) { + // 정책: 파생 실패해도 업로드 자체는 성공시키고 fallback + log.warn("Derive eink assets failed. fallback to originalUrl. imageName={}, userId={}", imageName, userId, e); + derived = new ImageDerivationService.Result(null, null); + // strict 정책으로 가려면 아래 주석 해제 + // throw new GeneralException(ErrorCode.IMAGE_DERIVE_FAIL); + } + + LocalDateTime now = LocalDateTime.now(); + + Image image = Image.builder() + .originalUrl(originalUrl) + .previewUrl(derived.previewUrl() != null ? derived.previewUrl() : originalUrl) + .einkDataUrl(derived.einkDataUrl()) + .imageName(imageName) + .createdAt(now) + .deletedAt(null) + .build(); + + imageRepository.save(image); + + ImageMapping mapping = ImageMapping.builder() + .userId(userId) + .image(image) + .isFavorite(false) + .savedAt(now) + .lastUsedAt(now) + .build(); + + imageMappingRepository.save(mapping); + + log.info("Saved image & mapping. imageId={}, userImageId={}, userId={}", + image.getImageId(), mapping.getUserImageId(), userId); + + return new UploadResult(image, mapping); + } + + @Transactional(readOnly = true) + public PreviewResult getPreview(Long imageId) { + if (imageId == null) { + throw new GeneralException(ErrorCode.IMAGE_BAD_REQUEST); + } + + Image image = imageRepository.findById(imageId) + .orElseThrow(() -> new GeneralException(ErrorCode.IMAGE_NOT_FOUND)); + + // previewUrl 없으면 fallback으로 originalUrl + String url = (image.getPreviewUrl() != null && !image.getPreviewUrl().isBlank()) + ? image.getPreviewUrl() + : image.getOriginalUrl(); + + log.debug("Preview requested. imageId={}, url={}", imageId, url); + + return new PreviewResult(image.getImageId(), url, LocalDateTime.now()); + } + + /** + * binary 조회는 lastUsedAt 갱신/매핑 생성이 들어가서 readOnly 금지 + */ + @Transactional + public BinaryResult getBinary(Long userId, Long imageId) { + if (userId == null || imageId == null) { + throw new GeneralException(ErrorCode.IMAGE_BAD_REQUEST); + } + + Image image = imageRepository.findById(imageId) + .orElseThrow(() -> new GeneralException(ErrorCode.IMAGE_NOT_FOUND)); + + LocalDateTime now = LocalDateTime.now(); + + ImageMapping mapping = imageMappingRepository.findByUserIdAndImage_ImageId(userId, imageId) + .orElseGet(() -> { + log.info("ImageMapping not found. create new mapping. userId={}, imageId={}", userId, imageId); + return ImageMapping.builder() + .userId(userId) + .image(image) + .isFavorite(false) + .savedAt(now) + .build(); + }); + + try { + mapping.touchLastUsedAt(now); + imageMappingRepository.save(mapping); + } catch (Exception e) { + log.error("Failed to upsert image_mapping. userId={}, imageId={}", userId, imageId, e); + throw new GeneralException(ErrorCode._INTERNAL_SERVER_ERROR); + } + + String einkDataUrl = image.getEinkDataUrl(); + boolean hasEink = einkDataUrl != null && !einkDataUrl.isBlank(); + log.info("Binary requested. userId={}, imageId={}, hasEinkDataUrl={}", userId, imageId, hasEink); + + // 정책: einkDataUrl 없으면 null로 내려줌(현재 로직 유지) + return new BinaryResult( + image.getImageId(), + hasEink ? einkDataUrl : null, + now + ); + } + + @Transactional(readOnly = true) + public List getRecentImageMappings(Long userId) { + if (userId == null) { + throw new GeneralException(ErrorCode.IMAGE_BAD_REQUEST); + } + + var list = imageMappingRepository + .findByUserIdAndLastUsedAtIsNotNullAndImage_DeletedAtIsNullOrderByLastUsedAtDesc( + userId, + org.springframework.data.domain.PageRequest.of(0, 14) + ) + .getContent(); + + log.debug("Recent images requested. userId={}, count={}", userId, list.size()); + return list; + } + + @Transactional + public FavoriteUpdateResult updateFavorite(Long userId, Long imageId, Boolean isFavorite) { + if (userId == null || imageId == null || isFavorite == null) { + throw new GeneralException(ErrorCode.IMAGE_BAD_REQUEST); + } + + ImageMapping mapping = imageMappingRepository.findByUserIdAndImage_ImageId(userId, imageId) + .orElseThrow(() -> new GeneralException(ErrorCode.IMAGE_MAPPING_NOT_FOUND)); + + try { + mapping.updateFavorite(isFavorite); + imageMappingRepository.save(mapping); + } catch (Exception e) { + log.error("Failed to update favorite. userId={}, imageId={}, isFavorite={}", userId, imageId, isFavorite, e); + throw new GeneralException(ErrorCode._INTERNAL_SERVER_ERROR); + } + + log.info("Favorite updated. userId={}, imageId={}, isFavorite={}", userId, imageId, isFavorite); + return new FavoriteUpdateResult(mapping.getIsFavorite(), LocalDateTime.now()); + } + + // 반환용 record + public record UploadResult(Image image, ImageMapping mapping) {} + public record PreviewResult(Long imageId, String previewUrl, LocalDateTime updatedAt) {} + public record BinaryResult(Long imageId, String einkDataUrl, LocalDateTime lastUsedAt) {} + public record FavoriteUpdateResult(Boolean isFavorite, LocalDateTime updatedAt) {} +} diff --git a/src/main/java/com/digital_tok/image/service/processing/EinkBinaryEncoder.java b/src/main/java/com/digital_tok/image/service/processing/EinkBinaryEncoder.java new file mode 100644 index 0000000..58a8c67 --- /dev/null +++ b/src/main/java/com/digital_tok/image/service/processing/EinkBinaryEncoder.java @@ -0,0 +1,133 @@ +//binary 파일 생성 +package com.digital_tok.image.service.processing; + +import java.awt.image.BufferedImage; + +public class EinkBinaryEncoder { + + private final EinkEncodingOption option; + + public EinkBinaryEncoder(EinkEncodingOption option) { + this.option = option; + } + + /** + * 입력 이미지(반드시 200x200, 4색으로 양자화된 상태 권장)를 + * e-ink용 2bpp packed binary로 변환한다. + * + * @param quantized200x200 200x200 이미지 (픽셀은 BLACK/WHITE/YELLOW/RED에 준하는 값이어야 함) + * @return 10000 bytes (200*200*2bit) + */ + public byte[] encode(BufferedImage quantized200x200) { + int width = option.getWidth(); + int height = option.getHeight(); + + if (quantized200x200.getWidth() != width || quantized200x200.getHeight() != height) { + throw new IllegalArgumentException( + "Image must be " + width + "x" + height + " but was " + + quantized200x200.getWidth() + "x" + quantized200x200.getHeight() + ); + } + + int totalPixels = width * height; + int expectedBytes = (totalPixels * 2) / 8; // 2bpp + if ((totalPixels * 2) % 8 != 0) { + throw new IllegalStateException("Unexpected: pixel bits not aligned to byte boundary."); + } + + byte[] out = new byte[expectedBytes]; + + // scanDirection에 따라 픽셀 스캔 순서를 결정한다. + // - HORIZONTAL: row-major (y=0.., x=0..) + // - VERTICAL : column-major (x=0.., y=0..) + // 4픽셀(2bit*4=8bit)을 1바이트로 패킹: + // byte = (p0<<6) | (p1<<4) | (p2<<2) | (p3) + int outIndex = 0; + + if (option.getScanDirection() == EinkEncodingOption.ScanDirection.VERTICAL) { + for (int x = 0; x < width; x++) { + for (int y = 0; y < height; y += 4) { + int p0 = to2BitColorCode(quantized200x200.getRGB(x, y)); + int p1 = to2BitColorCode(quantized200x200.getRGB(x, y + 1)); + int p2 = to2BitColorCode(quantized200x200.getRGB(x, y + 2)); + int p3 = to2BitColorCode(quantized200x200.getRGB(x, y + 3)); + + out[outIndex++] = pack4(p0, p1, p2, p3); + } + } + } else { + // 가로 스캔: y=0..height-1, x=0..width-1 + for (int y = 0; y < height; y++) { + // 좌우 반전 보정: x를 오른쪽→왼쪽으로 스캔, 패킹 밀림 현상 해결 + for (int x = width - 4; x >= 0; x -= 4) { + int p0 = to2BitColorCode(quantized200x200.getRGB(x + 3, y)); + int p1 = to2BitColorCode(quantized200x200.getRGB(x + 2, y)); + int p2 = to2BitColorCode(quantized200x200.getRGB(x + 1, y)); + int p3 = to2BitColorCode(quantized200x200.getRGB(x, y)); + out[outIndex++] = pack4(p0, p1, p2, p3); + } + } + } + + if (outIndex != out.length) { + throw new IllegalStateException("Encoded length mismatch. wrote=" + outIndex + ", expected=" + out.length); + } + + return out; + } + + private static byte pack4(int p0, int p1, int p2, int p3) { + // 각 픽셀은 0..3 (2bit) + int b = ((p0 & 0b11) << 6) + | ((p1 & 0b11) << 4) + | ((p2 & 0b11) << 2) + | (p3 & 0b11); + return (byte) (b & 0xFF); + } + + /** + * 양자화된 픽셀 RGB를 2bit 코드로 변환. + * 지금 단계에서는 "정확히 4색으로 양자화된 이미지"가 들어온다는 전제 하에 + * 색에 가장 가까운 코드로 매핑한다. + * + * 코드 매핑(공급사 앱 기준): + * 0: BLACK + * 1: WHITE + * 2: YELLOW + * 3: RED + */ + private static int to2BitColorCode(int argb) { + int r = (argb >> 16) & 0xFF; + int g = (argb >> 8) & 0xFF; + int b = (argb) & 0xFF; + + // 완전한 RGB 매칭을 기대하면 실무에서 미세 차이로 깨질 수 있어서, + // "가장 가까운 팔레트"로 결정한다. + // 팔레트(대략): + // BLACK = (0,0,0) + // WHITE = (255,255,255) + // YELLOW = (255,255,0) + // RED = (255,0,0) + + int dBlack = dist2(r, g, b, 0, 0, 0); + int dWhite = dist2(r, g, b, 255, 255, 255); + int dYellow = dist2(r, g, b, 255, 255, 0); + int dRed = dist2(r, g, b, 255, 0, 0); + + int min = dBlack; + int code = EinkColor.BLACK.getCode(); + + if (dWhite < min) { min = dWhite; code = EinkColor.WHITE.getCode(); } + if (dYellow < min) { min = dYellow; code = EinkColor.YELLOW.getCode(); } + if (dRed < min) { /*min = dRed;*/ code = EinkColor.RED.getCode(); } + + return code; + } + + private static int dist2(int r1, int g1, int b1, int r2, int g2, int b2) { + int dr = r1 - r2; + int dg = g1 - g2; + int db = b1 - b2; + return dr * dr + dg * dg + db * db; + } +} diff --git a/src/main/java/com/digital_tok/image/service/processing/EinkColor.java b/src/main/java/com/digital_tok/image/service/processing/EinkColor.java new file mode 100644 index 0000000..87a9162 --- /dev/null +++ b/src/main/java/com/digital_tok/image/service/processing/EinkColor.java @@ -0,0 +1,19 @@ +package com.digital_tok.image.service.processing; + +public enum EinkColor { + + BLACK(0), + WHITE(1), + YELLOW(2), + RED(3); + + private final int code; + + EinkColor(int code) { + this.code = code; + } + + public int getCode() { + return code; + } +} diff --git a/src/main/java/com/digital_tok/image/service/processing/EinkEncodingOption.java b/src/main/java/com/digital_tok/image/service/processing/EinkEncodingOption.java new file mode 100644 index 0000000..52f2395 --- /dev/null +++ b/src/main/java/com/digital_tok/image/service/processing/EinkEncodingOption.java @@ -0,0 +1,36 @@ +//기기 스펙 +package com.digital_tok.image.service.processing; + +public class EinkEncodingOption { + + public static final int DEFAULT_WIDTH = 200; + public static final int DEFAULT_HEIGHT = 200; + + public enum ScanDirection { + VERTICAL, + HORIZONTAL + } + + private final int width; + private final int height; + private final ScanDirection scanDirection; + + public EinkEncodingOption() { + this.width = DEFAULT_WIDTH; + this.height = DEFAULT_HEIGHT; + this.scanDirection = ScanDirection.HORIZONTAL; + + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public ScanDirection getScanDirection() { + return scanDirection; + } +} diff --git a/src/main/java/com/digital_tok/image/service/processing/EinkImagePreprocessor.java b/src/main/java/com/digital_tok/image/service/processing/EinkImagePreprocessor.java new file mode 100644 index 0000000..0bf3a02 --- /dev/null +++ b/src/main/java/com/digital_tok/image/service/processing/EinkImagePreprocessor.java @@ -0,0 +1,57 @@ +//원본->200*200 사이즈 변환 +package com.digital_tok.image.service.processing; + +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; + +public class EinkImagePreprocessor { + + private final EinkEncodingOption option; + + public EinkImagePreprocessor(EinkEncodingOption option) { + this.option = option; + } + + /** + * 원본 이미지를 "비율 유지 + 센터 크롭"으로 정사각형에 맞춘 뒤, + * 최종적으로 200x200으로 리사이즈한다. + */ + public BufferedImage to200x200(BufferedImage src) { + int targetW = option.getWidth(); + int targetH = option.getHeight(); + + if (src == null) { + throw new IllegalArgumentException("src image is null"); + } + + // 1) 센터 크롭 영역 계산 (원본에서 정사각형 crop) + int srcW = src.getWidth(); + int srcH = src.getHeight(); + if (srcW <= 0 || srcH <= 0) { + throw new IllegalArgumentException("Invalid image size: " + srcW + "x" + srcH); + } + + int cropSize = Math.min(srcW, srcH); + int cropX = (srcW - cropSize) / 2; + int cropY = (srcH - cropSize) / 2; + + BufferedImage cropped = src.getSubimage(cropX, cropY, cropSize, cropSize); + + // 2) 크롭된 정사각형을 200x200으로 고품질 리사이즈 + // e-ink에서는 경계/계단이 중요해서 렌더링 힌트를 확실히 줌. + BufferedImage resized = new BufferedImage(targetW, targetH, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = resized.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + g.drawImage(cropped, 0, 0, targetW, targetH, null); + } finally { + g.dispose(); + } + + return resized; + } +} diff --git a/src/main/java/com/digital_tok/image/service/processing/EinkPreviewRenderer.java b/src/main/java/com/digital_tok/image/service/processing/EinkPreviewRenderer.java new file mode 100644 index 0000000..ed4c064 --- /dev/null +++ b/src/main/java/com/digital_tok/image/service/processing/EinkPreviewRenderer.java @@ -0,0 +1,34 @@ +//png preview 생성 +package com.digital_tok.image.service.processing; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +public class EinkPreviewRenderer { + + /** + * e-ink 출력 시뮬레이션 이미지를 PNG 바이트로 변환한다. + * 입력 이미지는 반드시 200x200, 4색(검/흰/노/빨)로 양자화된 상태여야 한다. + */ + public byte[] renderPng(BufferedImage einkPreviewImage) { + if (einkPreviewImage == null) { + throw new IllegalArgumentException("preview image is null"); + } + + if (einkPreviewImage.getWidth() != 200 || einkPreviewImage.getHeight() != 200) { + throw new IllegalArgumentException( + "Preview image must be 200x200 but was " + + einkPreviewImage.getWidth() + "x" + einkPreviewImage.getHeight() + ); + } + + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + ImageIO.write(einkPreviewImage, "png", baos); + return baos.toByteArray(); + } catch (IOException e) { + throw new IllegalStateException("Failed to render preview PNG", e); + } + } +} diff --git a/src/main/java/com/digital_tok/image/service/processing/EinkQuantizer.java b/src/main/java/com/digital_tok/image/service/processing/EinkQuantizer.java new file mode 100644 index 0000000..f7ed981 --- /dev/null +++ b/src/main/java/com/digital_tok/image/service/processing/EinkQuantizer.java @@ -0,0 +1,164 @@ +//원본->4색 변환 +package com.digital_tok.image.service.processing; + +import java.awt.image.BufferedImage; + +public class EinkQuantizer { + + /** + * 디더링 사용 여부. + * - OFF: 단순 최근접색(빠르고 안정적) + * - ON : Floyd–Steinberg 디더링(사진에서 더 'e-ink 느낌', 대신 노이즈 감) + */ + public enum DitherMode { + OFF, + FLOYD_STEINBERG + } + + // 팔레트 RGB (기기 4색 느낌에 맞춘 기본값) + private static final int[] BLACK = {0, 0, 0}; + private static final int[] WHITE = {255, 255, 255}; + private static final int[] YELLOW = {255, 255, 0}; + private static final int[] RED = {255, 0, 0}; + + /** + * 입력(200x200 권장)을 4색으로 양자화한 결과를 반환한다. + * 출력 픽셀은 팔레트 중 하나의 RGB로 "정확히" 떨어진다. + */ + public BufferedImage quantizeTo4Colors(BufferedImage src, DitherMode ditherMode) { + if (src == null) throw new IllegalArgumentException("src image is null"); + + int w = src.getWidth(); + int h = src.getHeight(); + + // 디더링을 위해 float 버퍼로 작업(오차 누적) + float[][][] buf = new float[h][w][3]; + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int argb = src.getRGB(x, y); + buf[y][x][0] = (argb >> 16) & 0xFF; + buf[y][x][1] = (argb >> 8) & 0xFF; + buf[y][x][2] = (argb) & 0xFF; + } + } + + BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + + int oldR = clamp(Math.round(buf[y][x][0])); + int oldG = clamp(Math.round(buf[y][x][1])); + int oldB = clamp(Math.round(buf[y][x][2])); + + // Blue 보정 적용 (팔레트 고르기 전) + boolean blue = isBlueHue(oldR, oldG, oldB); + if (blue) { + int boosted = boostBlueValue(oldR, oldG, oldB); // 0xFFRRGGBB 형태 + oldR = (boosted >> 16) & 0xFF; + oldG = (boosted >> 8) & 0xFF; + oldB = boosted & 0xFF; + } + + int[] nearest = nearestPalette(oldR, oldG, oldB, blue); + + // 결과 픽셀 고정(팔레트 색으로) + int newR = nearest[0]; + int newG = nearest[1]; + int newB = nearest[2]; + int outArgb = (0xFF << 24) | (newR << 16) | (newG << 8) | newB; + out.setRGB(x, y, outArgb); + + if (ditherMode == DitherMode.FLOYD_STEINBERG) { + // 양자화 오차 계산 + float errR = oldR - newR; + float errG = oldG - newG; + float errB = oldB - newB; + + // Floyd–Steinberg 확산 + // x 7/16 + // 3/16 5/16 1/16 (다음 줄) + diffuse(buf, x + 1, y, errR, errG, errB, 7f / 16f); + diffuse(buf, x - 1, y + 1, errR, errG, errB, 3f / 16f); + diffuse(buf, x, y + 1, errR, errG, errB, 5f / 16f); + diffuse(buf, x + 1, y + 1, errR, errG, errB, 1f / 16f); + } + } + } + + return out; + } + + private static void diffuse(float[][][] buf, int x, int y, float errR, float errG, float errB, float factor) { + int h = buf.length; + int w = buf[0].length; + if (x < 0 || y < 0 || x >= w || y >= h) return; + + buf[y][x][0] = clampFloat(buf[y][x][0] + errR * factor); + buf[y][x][1] = clampFloat(buf[y][x][1] + errG * factor); + buf[y][x][2] = clampFloat(buf[y][x][2] + errB * factor); + } + + private static int[] nearestPalette(int r, int g, int b, boolean blue) { + int dBlack = dist2(r, g, b, BLACK[0], BLACK[1], BLACK[2]); + int dWhite = dist2(r, g, b, WHITE[0], WHITE[1], WHITE[2]); + int dYellow = dist2(r, g, b, YELLOW[0], YELLOW[1], YELLOW[2]); + int dRed = dist2(r, g, b, RED[0], RED[1], RED[2]); + + // Blue 계열이 RED/YELLOW로 점이 튀는게 가장 어색함 → 거리 penalty로 억제 + if (blue) { + dRed = (int)(dRed * 1.35); + dYellow = (int)(dYellow * 1.20); + } + + int min = dBlack; + int[] best = BLACK; + + if (dWhite < min) { min = dWhite; best = WHITE; } + if (dYellow < min) { min = dYellow; best = YELLOW; } + if (dRed < min) { min = dRed; best = RED; } + + return best; + } + + + private static int dist2(int r1, int g1, int b1, int r2, int g2, int b2) { + int dr = r1 - r2; + int dg = g1 - g2; + int db = b1 - b2; + return dr * dr + dg * dg + db * db; + } + + private static int clamp(int v) { + return Math.max(0, Math.min(255, v)); + } + + private static float clampFloat(float v) { + return Math.max(0f, Math.min(255f, v)); + } + // EinkQuantizer.java 내부에 추가 + private static boolean isBlueHue(int r, int g, int b) { + float[] hsb = java.awt.Color.RGBtoHSB(r, g, b, null); + float h = hsb[0] * 360f; // hue: 0~360 + float s = hsb[1]; // saturation: 0~1 + float v = hsb[2]; // value/brightness: 0~1 + + // 파랑/청록 계열(대략 180~260도), 채도 어느 정도 있는 경우만 + return (h >= 180f && h <= 260f) && (s >= 0.25f) && (v >= 0.10f); + } + + private static int boostBlueValue(int r, int g, int b) { + float[] hsb = java.awt.Color.RGBtoHSB(r, g, b, null); + float h = hsb[0]; + float s = hsb[1]; + float v = hsb[2]; + + // Blue 영역은 밝기(v)를 살짝 올려서 "죽은 회색 덩어리"를 방지 + // 너무 올리면 하얗게 날아가니까 1.15~1.35 사이에서 시작 추천 + float boostedV = Math.min(1.0f, v * 1.35f); + + int rgb = java.awt.Color.HSBtoRGB(h, s, boostedV); + return rgb; // int packed RGB + } + +} diff --git a/src/main/java/com/digital_tok/image/service/storage/ByteArrayMultipartFile.java b/src/main/java/com/digital_tok/image/service/storage/ByteArrayMultipartFile.java new file mode 100644 index 0000000..2758da9 --- /dev/null +++ b/src/main/java/com/digital_tok/image/service/storage/ByteArrayMultipartFile.java @@ -0,0 +1,32 @@ +//바이트 배열을 multipartfile로 감싸는 래퍼 +package com.digital_tok.image.service.storage; + +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +public class ByteArrayMultipartFile implements MultipartFile { + + private final byte[] content; + private final String name; + private final String contentType; + + public ByteArrayMultipartFile(byte[] content, String name, String contentType) { + this.content = content; + this.name = name; + this.contentType = contentType; + } + + @Override public String getName() { return name; } + @Override public String getOriginalFilename() { return name; } + @Override public String getContentType() { return contentType; } + @Override public boolean isEmpty() { return content == null || content.length == 0; } + @Override public long getSize() { return content.length; } + @Override public byte[] getBytes() { return content; } + @Override public InputStream getInputStream() throws IOException { return new ByteArrayInputStream(content); } + @Override public void transferTo(java.io.File dest) throws IOException { + throw new UnsupportedOperationException("Not supported"); + } +} diff --git a/src/main/java/com/digital_tok/image/service/storage/MockStorageService.java b/src/main/java/com/digital_tok/image/service/storage/MockStorageService.java new file mode 100644 index 0000000..8d07019 --- /dev/null +++ b/src/main/java/com/digital_tok/image/service/storage/MockStorageService.java @@ -0,0 +1,21 @@ +package com.digital_tok.image.service.storage; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +//@Profile("dev") +@ConditionalOnProperty(name = "storage.type", havingValue = "mock") +@Service +public class MockStorageService implements StorageService { + + @Override + public String uploadPreview(byte[] bytes, String key, String contentType) { + // 실제 업로드 없이, URL처럼 보이는 문자열만 반환 + return "https://mock-storage.local/" + key; + } + + @Override + public String uploadEink(byte[] bytes, String key, String contentType) { + return "https://mock-storage.local/" + key; + } +} diff --git a/src/main/java/com/digital_tok/image/service/storage/S3StorageService.java b/src/main/java/com/digital_tok/image/service/storage/S3StorageService.java new file mode 100644 index 0000000..305c491 --- /dev/null +++ b/src/main/java/com/digital_tok/image/service/storage/S3StorageService.java @@ -0,0 +1,28 @@ +package com.digital_tok.image.service.storage; + +import com.digital_tok.global.AmazonS3Manager; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Service; + +//@Profile("prod") +@Primary +@ConditionalOnProperty(name = "storage.type", havingValue = "s3", matchIfMissing = true) +@Service +@RequiredArgsConstructor +public class S3StorageService implements StorageService { + + private final AmazonS3Manager amazonS3Manager; + + @Override + public String uploadPreview(byte[] bytes, String key, String contentType) { + return amazonS3Manager.uploadBytes(key, bytes, contentType); + } + + @Override + public String uploadEink(byte[] bytes, String key, String contentType) { + return amazonS3Manager.uploadBytes(key, bytes, contentType); + } +} + diff --git a/src/main/java/com/digital_tok/image/service/storage/StorageService.java b/src/main/java/com/digital_tok/image/service/storage/StorageService.java new file mode 100644 index 0000000..3a0798d --- /dev/null +++ b/src/main/java/com/digital_tok/image/service/storage/StorageService.java @@ -0,0 +1,15 @@ +//ImageService랑 S3 연결 +package com.digital_tok.image.service.storage; + +public interface StorageService { + + /** + * 미리보기 이미지 업로드 후 접근 가능한 URL 반환 + */ + String uploadPreview(byte[] bytes, String key, String contentType); + + /** + * E-ink 바이너리 업로드 후 접근 가능한 URL 반환 + */ + String uploadEink(byte[] bytes, String key, String contentType); +} diff --git a/src/main/java/com/digital_tok/template/controller/PriorityController.java b/src/main/java/com/digital_tok/template/controller/PriorityController.java new file mode 100644 index 0000000..283aaf7 --- /dev/null +++ b/src/main/java/com/digital_tok/template/controller/PriorityController.java @@ -0,0 +1,55 @@ +package com.digital_tok.template.controller; + + +import com.digital_tok.global.apiPayload.ApiResponse; +import com.digital_tok.global.apiPayload.code.ApiErrorCodes; +import com.digital_tok.global.apiPayload.code.ErrorCode; +import com.digital_tok.global.apiPayload.code.SuccessCode; +import com.digital_tok.template.convertor.TemplateConverter; +import com.digital_tok.template.domain.PriorityTemplate; +import com.digital_tok.template.dto.PriorityResponseDTO; +import com.digital_tok.template.service.PriorityService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/templates") +@RequiredArgsConstructor +public class PriorityController implements PriorityControllerDocs { + + private final PriorityService priorityService; + private final TemplateConverter templateConverter; + + @Override + @GetMapping("/priority") + public ApiResponse getPriorityTemplates() { + + List templates = priorityService.getPrioirtyTemplates(); + + PriorityResponseDTO.PriorityListDto result = templateConverter.toPriorityListDto(templates); + + return ApiResponse.onSuccess(SuccessCode.OK, result); + } + + @Override + @GetMapping("/priority/{templateId}") + @ApiErrorCodes({ + ErrorCode.TEMPLATE_NOT_FOUND // 404 (템플릿 없음) + }) + public ApiResponse getPriorityTemplateDetail( + @PathVariable Long templateId + ) { + + PriorityTemplate prioirtyTemplate = priorityService.getPrioirtyTemplate(templateId); + PriorityResponseDTO.PriorityDetailDto result = templateConverter.toPriorityDetailDto(prioirtyTemplate); + + return ApiResponse.onSuccess(SuccessCode.OK, result); + } +} diff --git a/src/main/java/com/digital_tok/template/controller/PriorityControllerDocs.java b/src/main/java/com/digital_tok/template/controller/PriorityControllerDocs.java new file mode 100644 index 0000000..275f3d4 --- /dev/null +++ b/src/main/java/com/digital_tok/template/controller/PriorityControllerDocs.java @@ -0,0 +1,35 @@ +package com.digital_tok.template.controller; + +import com.digital_tok.global.apiPayload.ApiResponse; +import com.digital_tok.template.dto.PriorityResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.PathVariable; + +@Tag(name = "Priority", description = "교통약자 템플릿 관련 API") +public interface PriorityControllerDocs { + + @Operation(summary = "전체 교통약자 템플릿 목록 조회 API By 이정헌 (개발 완료)", description = "전체 교통약자 템플릿 목록을 반환합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = PriorityResponseDTO.PriorityListDto.class)) + ) + }) + ApiResponse getPriorityTemplates(); + + + @Operation(summary = "단일 교통약자 템플릿 조회 API By 이정헌 (개발 완료)", description = "단일 교통약자 템플릿의 상세정보를 반환합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = PriorityResponseDTO.PriorityDetailDto.class)) + ) + }) + ApiResponse getPriorityTemplateDetail(@PathVariable Long templateId); +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/template/controller/SubwayController.java b/src/main/java/com/digital_tok/template/controller/SubwayController.java new file mode 100644 index 0000000..8ea29ba --- /dev/null +++ b/src/main/java/com/digital_tok/template/controller/SubwayController.java @@ -0,0 +1,89 @@ +package com.digital_tok.template.controller; + +import com.digital_tok.global.apiPayload.code.ApiErrorCodes; +import com.digital_tok.global.apiPayload.code.ErrorCode; +import com.digital_tok.template.convertor.TemplateConverter; +import com.digital_tok.template.domain.SubwayTemplate; +import com.digital_tok.template.dto.SubwayCreateRequestDTO; +import com.digital_tok.template.dto.SubwayResponseDTO; +import com.digital_tok.global.apiPayload.ApiResponse; +import com.digital_tok.global.apiPayload.code.SuccessCode; +import com.digital_tok.template.service.SubwayService; +import com.digital_tok.template.service.makeSubwayImage.SubwayTemplateUploadService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/templates") +@RequiredArgsConstructor +public class SubwayController implements SubwayControllerDocs{ + + private final SubwayService subwayService; + private final TemplateConverter templateConverter; + private final SubwayTemplateUploadService subwayTemplateUploadService; + + @Override + @GetMapping("/subway") + public ApiResponse getSubwayTemplates() { + + List templates = subwayService.getSubwayTemplates(); + + SubwayResponseDTO.SubwayListDto result = templateConverter.toSubwayListDto(templates); + + return ApiResponse.onSuccess(SuccessCode.OK, result); + } + + @Override + @GetMapping("/subway/{templateId}") + @ApiErrorCodes({ + ErrorCode.TEMPLATE_NOT_FOUND // 404 (템플릿 없음) + }) + public ApiResponse getSubwayTemplateDetail( + @PathVariable Long templateId) { + + SubwayTemplate subwayTemplate = subwayService.getSubwayTemplate(templateId); + + SubwayResponseDTO.SubwayDetailDto result = templateConverter.toSubwayDetailDto(subwayTemplate); + + return ApiResponse.onSuccess(SuccessCode.OK, result); + } + + @Override + @GetMapping("/subway/search") + public ApiResponse searchSubwayTemplates( + @Parameter(description = "검색할 키워드 (예: 강남, Gang)", example = "강남") + @RequestParam(name = "keyword", required = false) String keyword + ) { + List searchResults = subwayService.searchSubwayTemplates(keyword); + + SubwayResponseDTO.SubwayListDto response = templateConverter.toSubwayListDto(searchResults); + + return ApiResponse.onSuccess(SuccessCode.OK, response); + } + + /** + * + * application.yml: access-key, secret-key, region, bucket 정보가 올바르게 들어있는지 확인 + * Headless 모드: 서버 배포 시 JVM 옵션 -Djava.awt.headless=true 해야함 + */ + @Override + @PostMapping("/subway/generate") + @ApiErrorCodes({ + ErrorCode.TEMPLATE_ALREADY_EXISTS, // 409 (해당 지하철역 템플릿이 이미 존재) + ErrorCode.TEMPLATE_DELETE_FAIL, // 500 롤백 중 파일 삭제에 실패했을 때 + ErrorCode.IMAGE_UPLOAD_FAIL, // 500 (S3 업로드 실패) + ErrorCode.IMAGE_TO_BINARY_ERROR // 500 (바이너리 변환 실패) + }) + public ApiResponse createSubwayTemplate(@RequestBody @Valid SubwayCreateRequestDTO request) { + + Long templateId = subwayTemplateUploadService.createAndSaveSubwayTemplate(request); + + return ApiResponse.onSuccess(SuccessCode.OK, "성공적으로 생성되었습니다. templateId: " + templateId); + } +} diff --git a/src/main/java/com/digital_tok/template/controller/SubwayControllerDocs.java b/src/main/java/com/digital_tok/template/controller/SubwayControllerDocs.java new file mode 100644 index 0000000..98b3b29 --- /dev/null +++ b/src/main/java/com/digital_tok/template/controller/SubwayControllerDocs.java @@ -0,0 +1,63 @@ +package com.digital_tok.template.controller; + +import com.digital_tok.global.apiPayload.ApiResponse; +import com.digital_tok.template.dto.SubwayCreateRequestDTO; +import com.digital_tok.template.dto.SubwayResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "Subway", description = "지하철 템플릿 관련 API") +public interface SubwayControllerDocs { + + @Operation(summary = "전체 지하철 역 목록 조회 API By 이정헌 (개발 완료)", description = "가나다 순으로 정렬된 지하철 역 템플릿 목록을 반환합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = SubwayResponseDTO.SubwayListDto.class)) + ) + }) + ApiResponse getSubwayTemplates(); + + + @Operation(summary = "단일 지하철 역 조회 API By 이정헌 (개발 완료)", description = "특정 지하철 역 정보를 반환합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = SubwayResponseDTO.SubwayDetailDto.class)) + ) + }) + ApiResponse getSubwayTemplateDetail(@PathVariable Long templateId); + + + @Operation(summary = "지하철 템플릿 검색 API By 이정헌 (개발 완료)", description = "역 이름(한글/영어)으로 템플릿을 검색합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "검색 성공 (결과가 없으면 빈 리스트 반환함)", + content = @Content(schema = @Schema(implementation = SubwayResponseDTO.SubwayListDto.class)) + ) + }) + ApiResponse searchSubwayTemplates( + @Parameter(description = "검색할 키워드 ", example = "강남, Gang") + @RequestParam(name = "keyword", required = false) String keyword + ); + + + @Operation(summary = "지하철 역 이미지 생성 및 저장 By 이정헌 (개발 완료)", description = "지하철 역 정보를 입력하면 이미지를 생성하고 DB에 저장합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "이미지 생성 성공" + ) + }) + ApiResponse createSubwayTemplate(@RequestBody SubwayCreateRequestDTO request); +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/template/convertor/TemplateConverter.java b/src/main/java/com/digital_tok/template/convertor/TemplateConverter.java new file mode 100644 index 0000000..196bc05 --- /dev/null +++ b/src/main/java/com/digital_tok/template/convertor/TemplateConverter.java @@ -0,0 +1,78 @@ +package com.digital_tok.template.convertor; + +import com.digital_tok.template.domain.PriorityTemplate; +import com.digital_tok.template.domain.SubwayTemplate; +import com.digital_tok.template.dto.PriorityResponseDTO; +import com.digital_tok.template.dto.SubwayResponseDTO; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +public class TemplateConverter { + + // 단일 역 Entity to DTO 변환 + public SubwayResponseDTO.SubwayItemDto toSubwayItemDto(SubwayTemplate entity) { + return SubwayResponseDTO.SubwayItemDto.builder() + .templateId(entity.getId()) + .stationName(entity.getStationName()) + .lineName(entity.getLineName()) + .templateImageUrl(entity.getTemplateImageUrl()) + .build(); + } + + // 리스트 Entity to DTO 변환 + public SubwayResponseDTO.SubwayListDto toSubwayListDto(List entities) { + List items = entities.stream() + .map(this::toSubwayItemDto) + .collect(Collectors.toList()); + + return SubwayResponseDTO.SubwayListDto.builder() + .count(items.size()) + .items(items) + .build(); + } + + public SubwayResponseDTO.SubwayDetailDto toSubwayDetailDto(SubwayTemplate entity) { + return SubwayResponseDTO.SubwayDetailDto.builder() + .templateId(entity.getId()) + .stationName(entity.getStationName()) + .stationNameEng(entity.getStationNameEng()) + .lineName(entity.getLineName()) + .templateImageUrl(entity.getTemplateImageUrl()) + .templateDataUrl(entity.getTemplateDataUrl()) + .build(); + } + + + // 교통약자 템플릿 Entity to DTO 변환 + public PriorityResponseDTO.PriorityItemDto toPriorityItemDto(PriorityTemplate entity) { + return PriorityResponseDTO.PriorityItemDto.builder() + .templateId(entity.getId()) + .priorityType(entity.getPriorityType()) + .templateImageUrl(entity.getTemplateImageUrl()) + .build(); + } + + public PriorityResponseDTO.PriorityListDto toPriorityListDto(List entities) { + List items = entities.stream() + .map(this::toPriorityItemDto) + .toList(); + + return PriorityResponseDTO.PriorityListDto.builder() + .count(entities.size()) + .items(items) + .build(); + } + + // 교통약자 템플릿 상세 정보 Entity to DTO 변환 + public PriorityResponseDTO.PriorityDetailDto toPriorityDetailDto(PriorityTemplate entity) { + return PriorityResponseDTO.PriorityDetailDto.builder() + .templateId(entity.getId()) + .priority_type(entity.getPriorityType()) + .templateImageUrl(entity.getTemplateImageUrl()) + .templateDataUrl(entity.getTemplateDataUrl()) + .build(); + } +} diff --git a/src/main/java/com/digital_tok/template/domain/PriorityTemplate.java b/src/main/java/com/digital_tok/template/domain/PriorityTemplate.java new file mode 100644 index 0000000..6325451 --- /dev/null +++ b/src/main/java/com/digital_tok/template/domain/PriorityTemplate.java @@ -0,0 +1,19 @@ +package com.digital_tok.template.domain; + +import jakarta.persistence.*; +import lombok.*; +import lombok.experimental.SuperBuilder; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@SuperBuilder +@DiscriminatorValue("VULNERABLE") +@PrimaryKeyJoinColumn(name = "template_id") +@Table(name = "priority_template") +public class PriorityTemplate extends Template { + + @Column(name = "priority_type", length = 50) + private String priorityType; +} diff --git a/src/main/java/com/digital_tok/template/domain/SubwayTemplate.java b/src/main/java/com/digital_tok/template/domain/SubwayTemplate.java new file mode 100644 index 0000000..165934d --- /dev/null +++ b/src/main/java/com/digital_tok/template/domain/SubwayTemplate.java @@ -0,0 +1,25 @@ +package com.digital_tok.template.domain; + +import jakarta.persistence.*; +import lombok.*; +import lombok.experimental.SuperBuilder; + +@Entity +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@DiscriminatorValue("SUBWAY") // dtype 으로 구분되는 값 +@PrimaryKeyJoinColumn(name = "template_id") // template_id는 PK이자 FK임을 지정 +@Table(name = "subway_template") +public class SubwayTemplate extends Template { + + @Column(name = "station_name", length = 10) + private String stationName; + + @Column(name = "station_name_eng", length = 20) + private String stationNameEng; + + @Column(name = "line_name", length = 10) + private String lineName; +} diff --git a/src/main/java/com/digital_tok/template/domain/Template.java b/src/main/java/com/digital_tok/template/domain/Template.java new file mode 100644 index 0000000..b45d5c1 --- /dev/null +++ b/src/main/java/com/digital_tok/template/domain/Template.java @@ -0,0 +1,31 @@ +package com.digital_tok.template.domain; + + +import com.digital_tok.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Entity +@Getter +@AllArgsConstructor +@SuperBuilder // 서브타입에서 슈퍼타입을 Builder로 사용가능하게해줌 +@NoArgsConstructor +@Inheritance(strategy = InheritanceType.JOINED) +@DiscriminatorColumn(name = "dtype") +@Table(name = "Template") +public class Template extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "template_id") + private Long id; + + @Column(name = "template_image_url") + private String templateImageUrl; + + @Column(name = "template_data_url") + private String templateDataUrl; +} diff --git a/src/main/java/com/digital_tok/template/dto/PriorityResponseDTO.java b/src/main/java/com/digital_tok/template/dto/PriorityResponseDTO.java new file mode 100644 index 0000000..134e8af --- /dev/null +++ b/src/main/java/com/digital_tok/template/dto/PriorityResponseDTO.java @@ -0,0 +1,57 @@ +package com.digital_tok.template.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +import java.util.List; + +public class PriorityResponseDTO { + + // 교통약자 템플릿 관련 DTO + + @Builder + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + public static class PriorityListDto { + @Schema(description = "조회된 템플릿 개수", example = "2") + private Integer count; + + @Schema(description = "교통약자 템플릿 리스트") + private List items; + } + + @Builder + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + public static class PriorityItemDto { + @Schema(description = "템플릿 ID", example = "405") + private Long templateId; + + @Schema(description = "교통약자 유형", example = "임산부") + private String priorityType; + + @Schema(description = "미리보기 이미지 URL") + private String templateImageUrl; + } + + @Builder + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + public static class PriorityDetailDto { + + @Schema(description = "교통약자 템플릿 ID") + private Long templateId; + + @Schema(description = "교통약자 유형") + private String priority_type; + + @Schema(description = "미리보기 이미지 URL") + private String templateImageUrl; + + @Schema(description = "이미지 바이너리 데이터 URL") + private String templateDataUrl; + } +} diff --git a/src/main/java/com/digital_tok/template/dto/SubwayCreateRequestDTO.java b/src/main/java/com/digital_tok/template/dto/SubwayCreateRequestDTO.java new file mode 100644 index 0000000..8b502e4 --- /dev/null +++ b/src/main/java/com/digital_tok/template/dto/SubwayCreateRequestDTO.java @@ -0,0 +1,32 @@ +package com.digital_tok.template.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SubwayCreateRequestDTO { + + @NotBlank(message = "역 이름은 필수입니다.") + @Size(max = 10, message = "역 이름은 50자를 초과할 수 없습니다.") + @Pattern(regexp = "^(?!.*역$).*$", message = "역 이름 뒤에 '역'을 붙이지 말아주세요. (예: 강남역 -> 강남)") + private String stationName; + + @NotBlank(message = "영어 역 이름은 필수입니다.") + @Size(max = 20, message = "영어 역 이름은 50자를 초과할 수 없습니다.") // 추가 + @Pattern(regexp = "^[a-zA-Z\\s]+$", message = "영어 역 이름은 영문만 입력 가능합니다.") + private String stationNameEng; + + @NotBlank(message = "호선 이름은 필수입니다.") + @Size(max = 10, message = "호선 이름은 10자를 초과할 수 없습니다.") + @Pattern(regexp = "^(?!.*호선$).*$", message = "호선 이름 뒤에 '호선'을 붙이지 말아주세요. (예: 2호선 -> 2)") + private String lineName; + +} diff --git a/src/main/java/com/digital_tok/template/dto/SubwayResponseDTO.java b/src/main/java/com/digital_tok/template/dto/SubwayResponseDTO.java new file mode 100644 index 0000000..e238068 --- /dev/null +++ b/src/main/java/com/digital_tok/template/dto/SubwayResponseDTO.java @@ -0,0 +1,65 @@ +package com.digital_tok.template.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +import java.util.List; + +public class SubwayResponseDTO { + + @Builder + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + public static class SubwayListDto { + @Schema(description = "지하철 역 템플릿 개수", example = "3") + private Integer count; + + @Schema(description = "지하철 역 템플릿 리스트") + private List items; + } + + @Builder + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + public static class SubwayItemDto { + + @Schema(description = "지하철 템플릿 ID", example = "301") + private Long templateId; + + @Schema(description = "역 이름", example = "을지로3가") + private String stationName; + + @Schema(description = "호선 이름", example = "2호선") + private String lineName; + + @Schema(description = "미리보기 이미지 URL", example = "https://cdn.dirring.com/images/preview/301.png") + private String templateImageUrl; + } + + @Builder + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + public static class SubwayDetailDto { + @Schema(description = "지하철 템플릿 ID", example = "1") + private Long templateId; + + @Schema(description = "역 이름", example = "강남") + private String stationName; + + @Schema(description = "영어 역 이름", example = "강남") + private String stationNameEng; + + @Schema(description = "호선 이름", example = "2호선") + private String lineName; + + @Schema(description = "미리보기 이미지 URL") + private String templateImageUrl; + + @Schema(description = "이미지 바이너리 데이터 URL") + private String templateDataUrl; + } + +} diff --git a/src/main/java/com/digital_tok/template/repository/PriorityTemplateRepository.java b/src/main/java/com/digital_tok/template/repository/PriorityTemplateRepository.java new file mode 100644 index 0000000..49f7522 --- /dev/null +++ b/src/main/java/com/digital_tok/template/repository/PriorityTemplateRepository.java @@ -0,0 +1,9 @@ +package com.digital_tok.template.repository; + +import com.digital_tok.template.domain.PriorityTemplate; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PriorityTemplateRepository extends JpaRepository { +} diff --git a/src/main/java/com/digital_tok/template/repository/SubwayTemplateRepository.java b/src/main/java/com/digital_tok/template/repository/SubwayTemplateRepository.java new file mode 100644 index 0000000..a244ed9 --- /dev/null +++ b/src/main/java/com/digital_tok/template/repository/SubwayTemplateRepository.java @@ -0,0 +1,21 @@ +package com.digital_tok.template.repository; + +import com.digital_tok.template.domain.SubwayTemplate; +import com.digital_tok.template.dto.SubwayCreateRequestDTO; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface SubwayTemplateRepository extends JpaRepository { + + // 오름차순으로 정렬된 지하철역 정보를 반환 + // 추후에 페이징 기능 추가 필요할수도 + List findAllByOrderByStationNameAsc(); + + // 한글이랑 영어 둘 다 검색가능 + List findByStationNameContainingOrStationNameEngContainingIgnoreCase(String stationName, String stationNameEng); + + boolean existsByStationNameAndLineName(String stationName, String lineName); +} diff --git a/src/main/java/com/digital_tok/template/repository/TemplateRepository.java b/src/main/java/com/digital_tok/template/repository/TemplateRepository.java new file mode 100644 index 0000000..9c9230c --- /dev/null +++ b/src/main/java/com/digital_tok/template/repository/TemplateRepository.java @@ -0,0 +1,9 @@ +package com.digital_tok.template.repository; + +import com.digital_tok.template.domain.Template; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TemplateRepository extends JpaRepository { +} diff --git a/src/main/java/com/digital_tok/template/service/PriorityService.java b/src/main/java/com/digital_tok/template/service/PriorityService.java new file mode 100644 index 0000000..38ff5bc --- /dev/null +++ b/src/main/java/com/digital_tok/template/service/PriorityService.java @@ -0,0 +1,32 @@ +package com.digital_tok.template.service; + +import com.digital_tok.global.apiPayload.code.ErrorCode; +import com.digital_tok.global.apiPayload.exception.GeneralException; +import com.digital_tok.template.domain.PriorityTemplate; +import com.digital_tok.template.repository.PriorityTemplateRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PriorityService { + + private final PriorityTemplateRepository priorityTemplateRepository; + + // 교통약자 템플릿 목록 조회 + public List getPrioirtyTemplates() { + return priorityTemplateRepository.findAll(); + } + + // 교통약자 템플릿 상세 조회 + public PriorityTemplate getPrioirtyTemplate(Long id) { + return priorityTemplateRepository.findById(id) + .orElseThrow(() -> new GeneralException(ErrorCode.TEMPLATE_NOT_FOUND)); + } + + // 교통약자 템플릿 검색 기능 -> 보류 +} diff --git a/src/main/java/com/digital_tok/template/service/SubwayService.java b/src/main/java/com/digital_tok/template/service/SubwayService.java new file mode 100644 index 0000000..32604bd --- /dev/null +++ b/src/main/java/com/digital_tok/template/service/SubwayService.java @@ -0,0 +1,40 @@ +package com.digital_tok.template.service; + +import com.digital_tok.template.domain.SubwayTemplate; +import com.digital_tok.global.apiPayload.code.ErrorCode; +import com.digital_tok.global.apiPayload.exception.GeneralException; +import com.digital_tok.template.repository.SubwayTemplateRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SubwayService { // 지하철 관련 API 호출 서비스 + + private final SubwayTemplateRepository subwayTemplateRepository; + + // 지하철역 목록을 오름차순으로 반환 + public List getSubwayTemplates() { + return subwayTemplateRepository.findAllByOrderByStationNameAsc(); + } + + public SubwayTemplate getSubwayTemplate(Long templateId) { + return subwayTemplateRepository.findById(templateId) + .orElseThrow(() -> new GeneralException(ErrorCode.TEMPLATE_NOT_FOUND)); + } + + // 지하철 역 검색 서비스 + public List searchSubwayTemplates(String keyword) { + // 1. 키워드가 없거나 공백이면 -> 전체 목록 반환 + if (keyword == null || keyword.trim().isEmpty()) { + return subwayTemplateRepository.findAllByOrderByStationNameAsc(); + } + + // 2. 키워드가 있으면 검색 (한글 OR 영어) + return subwayTemplateRepository.findByStationNameContainingOrStationNameEngContainingIgnoreCase(keyword, keyword); + } +} diff --git a/src/main/java/com/digital_tok/template/service/makeSubwayImage/Eink4ColorService.java b/src/main/java/com/digital_tok/template/service/makeSubwayImage/Eink4ColorService.java new file mode 100644 index 0000000..fe7a2f7 --- /dev/null +++ b/src/main/java/com/digital_tok/template/service/makeSubwayImage/Eink4ColorService.java @@ -0,0 +1,179 @@ +package com.digital_tok.template.service.makeSubwayImage; + +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.geom.Ellipse2D; +import java.awt.geom.RoundRectangle2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +@Service +public class Eink4ColorService { + + // 4색 전자잉크 기본 색상 정의 + private static final Color C_BLACK = Color.BLACK; + private static final Color C_WHITE = Color.WHITE; + private static final Color C_RED = Color.RED; + private static final Color C_YELLOW = Color.YELLOW; + + // 캔버스 설정 + private static final int CANVAS_SIZE = 200; + private static final int PADDING = 10; // 테두리 여백 + private static final int BORDER_THICKNESS = 9; // 테두리 두께 + private static final int CIRCLE_DIAMETER = 48; // 동그라미 크기 + + public byte[] generatePatternImage(String nameKor, String nameEng, String lineName) throws IOException { + + BufferedImage image = new BufferedImage(CANVAS_SIZE, CANVAS_SIZE, BufferedImage.TYPE_INT_RGB); + Graphics2D g2d = image.createGraphics(); + + // 1. 그래픽 품질 설정 (안티앨리어싱) + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + + // 2. 배경 채우기 (흰색) + g2d.setColor(C_WHITE); + g2d.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); + + // 3. 호선별 색상(Paint) 결정 로직 + Paint linePaint = getPaintByLineName(lineName); + + // 4. 테두리 그리기 (정중앙 정렬) + g2d.setPaint(linePaint); + g2d.setStroke(new BasicStroke(BORDER_THICKNESS)); + + // x, y, w, h + float borderSize = CANVAS_SIZE - (PADDING * 2); + g2d.draw(new RoundRectangle2D.Float(PADDING, PADDING, borderSize, borderSize, 25, 25)); + + // 5. 레이아웃 좌표 계산 (시각적 중앙 정렬) + int centerX = CANVAS_SIZE / 2; + + // 동그라미 위치 (상단에서 적당히 떨어뜨림) + int circleY = 30; + int circleX = centerX - (CIRCLE_DIAMETER / 2); + + // 6. 동그라미 그리기 + Ellipse2D.Double circle = new Ellipse2D.Double(circleX, circleY, CIRCLE_DIAMETER, CIRCLE_DIAMETER); + g2d.fill(circle); + + // 7. 호선 번호 그리기 (동그라미 안) + g2d.setColor(C_WHITE); // 숫자는 흰색 + g2d.setFont(new Font("Arial", Font.BOLD, 22)); + // 숫자를 동그라미 정중앙에 위치시키기 위한 보정값 + drawCenteredText(g2d, lineName, centerX, circleY + (CIRCLE_DIAMETER / 2) + 8); + + // 8. 한글 역명 그리기 + g2d.setColor(C_BLACK); // 글씨는 검정색 + try { + // 리소스 폴더에서 폰트 파일 스트림 읽기 (경로 주의: /fonts/파일이름.ttf) + InputStream fontStream = getClass().getResourceAsStream("/fonts/Pretendard-Bold.otf"); + + Font korFont; + if (fontStream != null) { + // 1. 폰트 생성 (TRUETYPE_FONT) + korFont = Font.createFont(Font.TRUETYPE_FONT, fontStream).deriveFont(Font.BOLD, 35f); + } else { + // 파일 로드 실패 시 + korFont = new Font("SansSerif", Font.BOLD, 35); + } + g2d.setFont(korFont); + + } catch (FontFormatException e) { + // 폰트 형식이 잘못되었을 경우 안전장치 + g2d.setFont(new Font("SansSerif", Font.BOLD, 35)); + } + int korY = circleY + CIRCLE_DIAMETER + 45; // 동그라미 아래로 50px 띄움 + drawCenteredText(g2d, nameKor, centerX, korY); + + // 9. 영어 역명 그리기 + g2d.setFont(new Font("Arial", Font.PLAIN, 18)); + int engY = korY + 30; // 한글 아래로 30px 띄움 + drawCenteredText(g2d, nameEng, centerX, engY); + + g2d.dispose(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(image, "png", baos); + return baos.toByteArray(); + } + + /** + * 호선 이름에 따라 4색 전자잉크에 최적화된 Paint(단색 또는 패턴)를 반환 + */ + private Paint getPaintByLineName(String lineName) { + if (lineName == null) return C_BLACK; + String line = lineName.trim(); + + // 2호선 & 7호선 (초록/국방색 계열) -> 검정 + 노랑 패턴 (올리브색 효과) + if (line.contains("2") || line.contains("7")) { + return createPattern(C_BLACK, C_YELLOW); + } + + // 3호선 (주황색) -> 빨강 + 노랑 패턴 + if (line.contains("3")) { + return createPattern(C_RED, C_YELLOW); + } + + // 4호선 (하늘색) -> 파랑 표현, C_BLACK + C_WHITE 패턴 + if (line.contains("4")) { + return createPattern(C_BLACK, C_WHITE); + } + + // 6호선 (황토색/갈색) -> 빨강 + 노랑 + 검정 섞으면 좋지만 2색 패턴만 가능하므로 빨강+노랑(주황) 사용 + if (line.contains("6")) { + return createPattern(C_RED, C_YELLOW); + } + + // 8호선 (분홍색) -> 빨강 + 흰색 패턴 + if (line.contains("8")) { + return createPattern(C_RED, C_WHITE); + } + + // 9호선 (황금색/베이지) -> 노랑 단색 (가장 비슷함) + if (line.contains("9")) { + return C_YELLOW; + } + + // 1, 5호선 포함, 그리고 그 외 -> 검정 + return C_BLACK; + } + + /** + * 2x2 픽셀의 체크무늬 패턴 생성 (2가지 색 섞어서 다른 색 만들기) + */ + private TexturePaint createPattern(Color c1, Color c2) { + BufferedImage patternImg = new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB); + Graphics2D g = patternImg.createGraphics(); + + // (0,0) 색상1 + g.setColor(c1); + g.fillRect(0, 0, 1, 1); + + // (1,1) 색상1 + g.fillRect(1, 1, 1, 1); + + // (1,0) 색상2 + g.setColor(c2); + g.fillRect(1, 0, 1, 1); + + // (0,1) 색상2 + g.fillRect(0, 1, 1, 1); + + g.dispose(); + + return new TexturePaint(patternImg, new Rectangle(0, 0, 2, 2)); + } + + private void drawCenteredText(Graphics g, String text, int x, int y) { + if (text == null) return; + FontMetrics metrics = g.getFontMetrics(g.getFont()); + int textWidth = metrics.stringWidth(text); + // x좌표를 중심으로 텍스트 시작점 계산 + g.drawString(text, x - (textWidth / 2), y); + } +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/template/service/makeSubwayImage/S3UploadService.java b/src/main/java/com/digital_tok/template/service/makeSubwayImage/S3UploadService.java new file mode 100644 index 0000000..19595c5 --- /dev/null +++ b/src/main/java/com/digital_tok/template/service/makeSubwayImage/S3UploadService.java @@ -0,0 +1,95 @@ +package com.digital_tok.template.service.makeSubwayImage; + +import com.digital_tok.global.apiPayload.code.ErrorCode; +import com.digital_tok.global.apiPayload.exception.GeneralException; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetUrlRequest; + +import java.net.URI; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +@Slf4j +public class S3UploadService { + + private final S3Client s3Client; + + @Value("${spring.cloud.aws.s3.bucket}") + private String bucket; + +// // 기존 메서드 (내부에서 범용 메서드 호출하도록 변경) +// public String upload(byte[] imageBytes, String directoryPath) { +// return upload(imageBytes, directoryPath, "png", "image/png"); +// } + + // 확장자와 Content-Type을 지정할 수 있는 범용 업로드 메서드 + public String upload(byte[] fileBytes, String directoryPath, String extension, String contentType) { + // 1. 파일명 생성 + String fileName = directoryPath + "/" + UUID.randomUUID() + "." + extension; + + // 2. 업로드 요청 생성 + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(fileName) + .contentType(contentType) + .contentLength((long) fileBytes.length) + .build(); + + // 3. 업로드 실행 + try { + s3Client.putObject(putObjectRequest, RequestBody.fromBytes(fileBytes)); + } catch (Exception e) { + throw new RuntimeException("S3 업로드 실패: " + fileName, e); + } + + // 4. URL 반환 + return s3Client.utilities().getUrl(GetUrlRequest.builder() + .bucket(bucket) + .key(fileName) + .build()).toExternalForm(); + } + + /** + * S3 파일 삭제 메서드 (롤백용) + * @param fileUrl S3 전체 URL (예: https://bucket.s3.region.amazonaws.com/folder/file.png) + */ + public void deleteFile(String fileUrl) { + if (fileUrl == null || fileUrl.isBlank()) { + return; + } + + try { + // 1. URL에서 Key 추출 ,도메인 제외하고 경로만 가져옴 + URI uri = new URI(fileUrl); + String key = uri.getPath(); + + // Key 앞의 '/' 제거 (예: /folder/file.png -> folder/file.png) + if (key.startsWith("/")) { + key = key.substring(1); + } + + // 2. 삭제 요청 생성 + DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() + .bucket(bucket) + .key(key) + .build(); + + // 3. 삭제 실행 + s3Client.deleteObject(deleteObjectRequest); + log.info("S3 파일 삭제 완료: key={}", key); + + } catch (Exception e) { + // 삭제 실패 시 에러를 던져서 리스너가 알 수 있게 함 (로그는 리스너에서도 찍음) + log.error("S3 파일 삭제 실패: url={}", fileUrl, e); + throw new GeneralException(ErrorCode.TEMPLATE_DELETE_FAIL); + } + } +} diff --git a/src/main/java/com/digital_tok/template/service/makeSubwayImage/SubwayTemplateService.java b/src/main/java/com/digital_tok/template/service/makeSubwayImage/SubwayTemplateService.java new file mode 100644 index 0000000..2a4a7d5 --- /dev/null +++ b/src/main/java/com/digital_tok/template/service/makeSubwayImage/SubwayTemplateService.java @@ -0,0 +1,37 @@ +package com.digital_tok.template.service.makeSubwayImage; + +import com.digital_tok.global.apiPayload.code.ErrorCode; +import com.digital_tok.global.apiPayload.exception.GeneralException; +import com.digital_tok.template.domain.SubwayTemplate; +import com.digital_tok.template.repository.SubwayTemplateRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + + +@Service +@RequiredArgsConstructor +@Transactional +public class SubwayTemplateService { // 생성된 지하철 이미지를 받아서 DB에 저장 + private final SubwayTemplateRepository subwayTemplateRepository; + public Long saveToDatabase(String nameKor, String nameEng, String lineName, String imageUrl, String dataUrl) { + + // 중복 검사 로직 추가 + if (subwayTemplateRepository.existsByStationNameAndLineName(nameKor, lineName)) { + throw new GeneralException(ErrorCode.TEMPLATE_ALREADY_EXISTS); + } + + SubwayTemplate subwayTemplate = SubwayTemplate.builder() + // 부모(Template) 필드 + .templateImageUrl(imageUrl) + .templateDataUrl(dataUrl) + + // 자식(SubwayTemplate) 필드 + .stationName(nameKor) + .stationNameEng(nameEng) + .lineName(lineName + "호선") + .build(); + + return subwayTemplateRepository.save(subwayTemplate).getId(); + } +} diff --git a/src/main/java/com/digital_tok/template/service/makeSubwayImage/SubwayTemplateUploadService.java b/src/main/java/com/digital_tok/template/service/makeSubwayImage/SubwayTemplateUploadService.java new file mode 100644 index 0000000..063af98 --- /dev/null +++ b/src/main/java/com/digital_tok/template/service/makeSubwayImage/SubwayTemplateUploadService.java @@ -0,0 +1,95 @@ +package com.digital_tok.template.service.makeSubwayImage; + +import com.digital_tok.global.apiPayload.code.ErrorCode; +import com.digital_tok.global.apiPayload.exception.GeneralException; +import com.digital_tok.global.event.S3ImageRollbackEvent; +import com.digital_tok.image.service.processing.EinkBinaryEncoder; +import com.digital_tok.image.service.processing.EinkEncodingOption; +import com.digital_tok.image.service.processing.EinkQuantizer; +import com.digital_tok.template.dto.SubwayCreateRequestDTO; +import com.digital_tok.template.repository.SubwayTemplateRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SubwayTemplateUploadService { // 이미지 생성 후 S3에 업로드 + + private final Eink4ColorService imageGenerator; + private final S3UploadService s3Uploader; + private final SubwayTemplateService subwayTemplateService; + private final SubwayTemplateRepository subwayTemplateRepository; + private final ApplicationEventPublisher eventPublisher; + + // 이미지 처리용 객체 생성 + private final EinkEncodingOption encodingOption = new EinkEncodingOption(); + private final EinkQuantizer quantizer = new EinkQuantizer(); + private final EinkBinaryEncoder binaryEncoder = new EinkBinaryEncoder(encodingOption); + + public Long createAndSaveSubwayTemplate(SubwayCreateRequestDTO request) { + + String nameKor = request.getStationName().trim(); + String nameEng = request.getStationNameEng().trim(); + String lineName = request.getLineName().trim(); + + if (subwayTemplateRepository.existsByStationNameAndLineName(nameKor, lineName + "호선")) { + throw new GeneralException(ErrorCode.TEMPLATE_ALREADY_EXISTS); + } + + byte[] imageBytes = generateImage(nameKor, nameEng, lineName); // 1. 이미지 생성 + byte[] binaryBytes = generateBinaryData(imageBytes); // 2. 바이너리 데이터 변환 + + // 3. S3 업로드 + String uploadedImageUrl = s3Uploader.upload(imageBytes, "template/subway", "png", "image/png"); + String uploadedDataUrl = s3Uploader.upload(binaryBytes, "template/subway/binary", "bin", "application/octet-stream"); + + // 4. DB 저장 (DB 작업 -> 트랜잭션 -> 별도 호출) + try { + return subwayTemplateService.saveToDatabase(nameKor, nameEng, lineName, uploadedImageUrl, uploadedDataUrl); + } catch (Exception e) { + log.error("DB 저장 실패. S3 롤백 이벤트 발행: {}", uploadedImageUrl); + eventPublisher.publishEvent(new S3ImageRollbackEvent(uploadedImageUrl)); + eventPublisher.publishEvent(new S3ImageRollbackEvent(uploadedDataUrl)); + + throw e; + } + } + + private byte[] generateImage(String nameKor, String nameEng, String lineName) { + + try { + return imageGenerator.generatePatternImage(nameKor, nameEng, lineName); + } catch (IOException e) { + log.error("지하철 템플릿 이미지 생성 중 오류 발생: {}", e.getMessage(), e); + throw new GeneralException(ErrorCode.IMAGE_UPLOAD_FAIL); + } + } + private byte[] generateBinaryData(byte[] imageBytes) { + byte[] binaryBytes; + try { + // 2-1. byte[] -> BufferedImage 변환 + ByteArrayInputStream bais = new ByteArrayInputStream(imageBytes); + BufferedImage originalImage = ImageIO.read(bais); + + // 2-2. 4색 양자화 (Quantization) + // 텍스트/도형 위주의 템플릿이므로 Dithering은 OFF로 설정하여 깔끔하게 처리 + BufferedImage quantizedImage = quantizer.quantizeTo4Colors(originalImage, EinkQuantizer.DitherMode.OFF); + + // 2-3. 바이너리 인코딩 (.bin) + binaryBytes = binaryEncoder.encode(quantizedImage); + + } catch (IOException e) { + log.error("바이너리 데이터 변환 중 오류 발생: {}", e.getMessage(), e); + throw new GeneralException(ErrorCode.IMAGE_TO_BINARY_ERROR); + } + return binaryBytes; + } +} diff --git a/src/main/java/com/digital_tok/user/controller/UserController.java b/src/main/java/com/digital_tok/user/controller/UserController.java new file mode 100644 index 0000000..d91720a --- /dev/null +++ b/src/main/java/com/digital_tok/user/controller/UserController.java @@ -0,0 +1,81 @@ +package com.digital_tok.user.controller; + +import com.digital_tok.global.apiPayload.ApiResponse; +import com.digital_tok.global.apiPayload.code.ApiErrorCodes; +import com.digital_tok.global.apiPayload.code.ErrorCode; +import com.digital_tok.global.apiPayload.code.SuccessCode; +import com.digital_tok.global.security.PrincipalDetails; +import com.digital_tok.user.dto.UserRequestDTO; +import com.digital_tok.user.dto.UserResponseDTO; +import com.digital_tok.user.service.UserService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/users") +public class UserController implements UserControllerDocs{ + + private final UserService userService; + + /** + * 1. 회원 탈퇴 API + */ + @Override + @DeleteMapping("/me") + @ApiErrorCodes({ + ErrorCode.MEMBER_NOT_FOUND + }) + public ApiResponse withdraw(@AuthenticationPrincipal PrincipalDetails principal) { + // principal.getUserId()로 실제 토큰의 주인 ID를 가져옴 [삭제] + userService.withdraw(principal.getUserId()); + return ApiResponse.onSuccess(SuccessCode.OK, "회원 탈퇴가 완료되었습니다."); + } + + /** + * 2. 비밀번호 변경 API + */ + @Override + @PatchMapping("/password") + @ApiErrorCodes({ + ErrorCode.BAD_REQUEST, + ErrorCode.MEMBER_NOT_FOUND + }) + public ApiResponse changePassword(@AuthenticationPrincipal PrincipalDetails principal, + @RequestBody @Valid UserRequestDTO.ChangePasswordDto request) { + userService.changePassword(principal.getUserId(), request); + return ApiResponse.onSuccess(SuccessCode.OK, "비밀번호가 성공적으로 변경되었습니다."); + } + + /** + * 3. 이메일 주소 변경 API + */ + @Override + @PatchMapping("/email") + @ApiErrorCodes({ + ErrorCode.BAD_REQUEST, // 400 + ErrorCode.MEMBER_NOT_FOUND, // 404 + ErrorCode.MEMBER_ALREADY_REGISTERED // 409 (이미 등록된 이메일) + }) + public ApiResponse changeEmail(@AuthenticationPrincipal PrincipalDetails principal, + @RequestBody @Valid UserRequestDTO.ChangeEmailDto request) { + userService.changeEmail(principal.getUserId(), request); + return ApiResponse.onSuccess(SuccessCode.OK, request.getNewEmail()); + } + + /** + * 4. 내 프로필 조회 API + */ + @Override + @GetMapping("/me") + @ApiErrorCodes({ + ErrorCode.MEMBER_NOT_FOUND // 404 + }) + public ApiResponse getMyProfile(@AuthenticationPrincipal PrincipalDetails principal) { + // Service 호출로 변경됨 + UserResponseDTO.MyProfileDto profile = userService.getProfile(principal.getUserId()); + return ApiResponse.onSuccess(SuccessCode.OK, profile); + } +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/user/controller/UserControllerDocs.java b/src/main/java/com/digital_tok/user/controller/UserControllerDocs.java new file mode 100644 index 0000000..ad378f1 --- /dev/null +++ b/src/main/java/com/digital_tok/user/controller/UserControllerDocs.java @@ -0,0 +1,55 @@ +package com.digital_tok.user.controller; + +import com.digital_tok.global.apiPayload.ApiResponse; +import com.digital_tok.global.security.PrincipalDetails; +import com.digital_tok.user.dto.UserRequestDTO; +import com.digital_tok.user.dto.UserResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "User", description = "회원 관련 API (로그인, 회원가입, 정보 수정 등)") +public interface UserControllerDocs { + + @Operation(summary = "회원 탈퇴 API By 이승주 (개발 완료)", description = "비밀번호 검증 후 회원 정보를 삭제(또는 비활성화)합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "회원 탈퇴 성공" + ) + }) + ApiResponse withdraw(PrincipalDetails principal); + + @Operation(summary = "비밀번호 변경 API By 이승주 (개발 완료)", description = "기존 비밀번호 확인 후 새로운 비밀번호로 변경합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "비밀번호 변경 성공" + ) + }) + ApiResponse changePassword(PrincipalDetails principal, + @RequestBody UserRequestDTO.ChangePasswordDto request); + + @Operation(summary = "이메일 변경 API By 이승주 (개발 완료)", description = "기존 비밀번호 확인 후 새로운 이메일로 변경합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "이메일 변경 성공" + ) + }) + ApiResponse changeEmail(PrincipalDetails principal, + @RequestBody UserRequestDTO.ChangeEmailDto request); + + @Operation(summary = "내 프로필 조회 API By 이승주, 조성하 (개발 완료)", description = "로그인한 유저의 프로필 정보를 반환합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = UserResponseDTO.MyProfileDto.class)) + ) + }) + ApiResponse getMyProfile(PrincipalDetails principal); +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/user/converter/UserConverter.java b/src/main/java/com/digital_tok/user/converter/UserConverter.java new file mode 100644 index 0000000..c38df6e --- /dev/null +++ b/src/main/java/com/digital_tok/user/converter/UserConverter.java @@ -0,0 +1,17 @@ +package com.digital_tok.user.converter; + +import com.digital_tok.user.domain.User; +import com.digital_tok.user.dto.UserResponseDTO; +import org.springframework.stereotype.Component; + +@Component +public class UserConverter { + + public UserResponseDTO.MyProfileDto toMyProfileDto(User user) { + return UserResponseDTO.MyProfileDto.builder() + .userId(user.getId()) + .nickname(user.getNickname()) + .email(user.getEmail()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/user/domain/Role.java b/src/main/java/com/digital_tok/user/domain/Role.java new file mode 100644 index 0000000..c4bdd95 --- /dev/null +++ b/src/main/java/com/digital_tok/user/domain/Role.java @@ -0,0 +1,6 @@ +package com.digital_tok.user.domain; + +public enum Role { + ROLE_USER, + ROLE_ADMIN +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/user/domain/User.java b/src/main/java/com/digital_tok/user/domain/User.java new file mode 100644 index 0000000..323af41 --- /dev/null +++ b/src/main/java/com/digital_tok/user/domain/User.java @@ -0,0 +1,79 @@ +package com.digital_tok.user.domain; + +import com.digital_tok.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +@Entity +@Getter +@DynamicUpdate // 변경된 필드만 update 쿼리 날림 +@DynamicInsert // null인 필드 제외하고 insert 쿼리 날림 +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table(name = "users") // MySQL 예약어 'user' 충돌 방지 +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long id; + + @Column(nullable = false, length = 50, unique = true) + private String email; + + @Column(nullable = false, length = 255) + private String password; + + @Column(nullable = false, length = 50) + private String nickname; + + //@Column(length = 50) + //private String name; + + @Column(length = 20) + private String phone; + + @Column(name = "profile_image_url", length = 255) + private String profileImageUrl; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(15) DEFAULT 'ACTIVE'") + private UserStatus status; // ACTIVE, INACTIVE, BLOCKED + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(15) DEFAULT 'ROLE_USER'") + private Role role; // ROLE_USER, ROLE_ADMIN + + // 비즈니스 로직 (Setter 대신 메서드 사용) + + // 비밀번호 암호화 후 저장 + public void encodePassword(String password) { + this.password = password; + } + + // 정보 수정 +// public void updateProfile(String nickname, String profileImageUrl) { +// this.nickname = nickname; +// this.profileImageUrl = profileImageUrl; +// } + + // 회원 탈퇴 (Soft Delete) + public void withdraw() { + this.status = UserStatus.INACTIVE; + } + + // 이메일 변경 + public void updateEmail(String email) { + this.email = email;} + + // 재가입(계정 활성화) 메서드 + public void reactivate(String password, String nickname) { + this.password = password; // 새 비밀번호로 변경 + this.nickname = nickname; // 새 닉네임으로 변경 + this.status = UserStatus.ACTIVE; // 상태를 다시 ACTIVE로 + } +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/user/domain/UserStatus.java b/src/main/java/com/digital_tok/user/domain/UserStatus.java new file mode 100644 index 0000000..8fe367f --- /dev/null +++ b/src/main/java/com/digital_tok/user/domain/UserStatus.java @@ -0,0 +1,7 @@ +package com.digital_tok.user.domain; + +public enum UserStatus { + ACTIVE, // 활성 + INACTIVE, // 탈퇴 (비활성) + BLOCKED // 정지 +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/user/dto/UserRequestDTO.java b/src/main/java/com/digital_tok/user/dto/UserRequestDTO.java new file mode 100644 index 0000000..c37a96d --- /dev/null +++ b/src/main/java/com/digital_tok/user/dto/UserRequestDTO.java @@ -0,0 +1,47 @@ +package com.digital_tok.user.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +public class UserRequestDTO { + + // 1. 회원 탈퇴 요청 (본인 확인용 비밀번호) [삭제] +// @Getter +// public static class WithdrawDto { +// private String password; // 필수 +// } + + // 2. 비밀번호 변경 요청 + @Getter + public static class ChangePasswordDto { + @NotBlank(message = "현재 비밀번호를 입력해주세요.") + @Size(min = 6, max = 20, message = "비밀번호는 6자 이상 20자 이하로 입력해주세요.") + private String oldPassword; // 필수 (현재 비밀번호) + + @NotBlank(message = "새로운 비밀번호를 입력해주세요.") + @Size(min = 6, max = 20, message = "비밀번호는 6자 이상 20자 이하로 입력해주세요.") + private String newPassword; // 필수 (새로운 비밀번호) + } + + // 3. 이메일 변경 요청 + @Getter + public static class ChangeEmailDto { + @NotBlank(message = "비밀번호를 입력해주세요.") + @Size(min = 6, max = 20, message = "비밀번호는 6자 이상 20자 이하로 입력해주세요.") + private String password; // 필수 (본인 확인용) + + @NotBlank(message = "새로운 이메일을 입력해주세요.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + private String newEmail; // 필수 (변경할 새 이메일) + } + + // 4. 닉네임 변경 요청 [삭제] +// @Getter +// public static class NicknameUpdateDto { +// @Schema(description = "변경하려는 새로운 닉네임", example = "새로운 닉네임") +// private String nickname; // 유저 별명 +// } +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/user/dto/UserResponseDTO.java b/src/main/java/com/digital_tok/user/dto/UserResponseDTO.java new file mode 100644 index 0000000..aa016aa --- /dev/null +++ b/src/main/java/com/digital_tok/user/dto/UserResponseDTO.java @@ -0,0 +1,66 @@ +package com.digital_tok.user.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +import java.time.LocalDateTime; + +public class UserResponseDTO { + + // 추가: 회원가입, 수정, 탈퇴 등 CUD 작업 후 공통으로 쓸 결과 DTO + // 프론트엔드 개발자분들이 필요하다고 하면 추가하면 될듯. + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class UserResultDto { + @Schema(description = "관련된 회원 ID", example = "1") + Long userId; + + @Schema(description = "작업 완료 시각", example = "2026-01-15T14:30:00") + LocalDateTime updatedAt; + } + + // 내 프로필 조회 Response + @Builder + @Getter + public static class MyProfileDto { + @Schema(description = "유저 ID", example = "1") + private Long userId; + + //@Schema(description = "유저 이름", example = "조성하") + //private String name; + + @Schema(description = "유저 닉네임", example = "조성하하하") + private String nickname; + + @Schema(description = "유저 이메일", example = "example@example.com") + private String email; + } + + // 닉네임 수정 Response (변경된 프로필 정보) [삭제] +// @Builder +// @Getter +// public static class NicknameResultDto { +// @Schema(description = "유저 ID", example = "1") +// private Long userId; +// +// //@Schema(description = "유저 이름", example = "조성하") +// //private String name; +// +// @Schema(description = "변경된 닉네임", example = "새로운 닉네임") +// private String nickname; +// +// @Schema(description = "유저 이메일", example = "example@example.com") +// private String email; +// +// @Schema(description = "유저 핸드폰 번호", example = "010-1234-5678") +// private String phone; +// +// @Schema(description = "닉네임 변경 일시", example = "2026-01-15T14:30:00") +// private LocalDateTime updatedAt; +// } +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/user/repository/UserRepository.java b/src/main/java/com/digital_tok/user/repository/UserRepository.java new file mode 100644 index 0000000..971e225 --- /dev/null +++ b/src/main/java/com/digital_tok/user/repository/UserRepository.java @@ -0,0 +1,19 @@ +package com.digital_tok.user.repository; + + +import com.digital_tok.user.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + + // 이메일로 유저 조회 (로그인 시 사용) + Optional findByEmail(String email); + + // 이메일 중복 검사 (회원가입 시 사용) + boolean existsByEmail(String email); + + // 닉네임 중복 검사 (회원가입/정보수정 시 사용) + boolean existsByNickname(String nickname); +} \ No newline at end of file diff --git a/src/main/java/com/digital_tok/user/service/UserService.java b/src/main/java/com/digital_tok/user/service/UserService.java new file mode 100644 index 0000000..cf3fc5a --- /dev/null +++ b/src/main/java/com/digital_tok/user/service/UserService.java @@ -0,0 +1,112 @@ +package com.digital_tok.user.service; + +import com.digital_tok.auth.repository.RefreshTokenRepository; +import com.digital_tok.global.apiPayload.code.ErrorCode; +import com.digital_tok.global.apiPayload.exception.GeneralException; +import com.digital_tok.user.converter.UserConverter; +import com.digital_tok.user.domain.User; +import com.digital_tok.user.dto.UserRequestDTO; +import com.digital_tok.user.dto.UserResponseDTO; +import com.digital_tok.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final RefreshTokenRepository refreshTokenRepository; + private final UserConverter userConverter; + + /** + * 1. 회원 탈퇴 + * 설명: 상태를 INACTIVE로 변경 (Soft Delete) + */ + @Transactional + public void withdraw(Long userId) { + User user = getUserById(userId); + + // 회원 탈퇴 처리 (Soft Delete) + user.withdraw(); + + // Refresh Token 삭제 (재로그인 방지) + refreshTokenRepository.deleteByUserId(userId); + } + + /** + * 2. 비밀번호 변경 + * 설명: 기존 비밀번호 확인 -> 새 비밀번호 암호화 후 저장 + */ + @Transactional + public void changePassword(Long userId, UserRequestDTO.ChangePasswordDto request) { + User user = getUserById(userId); + + // 기존 비밀번호 확인 + if (!passwordEncoder.matches(request.getOldPassword(), user.getPassword())) { + throw new GeneralException(ErrorCode.BAD_REQUEST); + } + + // 새 비밀번호 암호화 및 변경 + user.encodePassword(passwordEncoder.encode(request.getNewPassword())); + } + + /** + * 3. 이메일 주소 변경 + * 설명: 비밀번호 확인 -> 이메일 중복 검사 -> 변경 + */ + @Transactional + public void changeEmail(Long userId, UserRequestDTO.ChangeEmailDto request) { + User user = getUserById(userId); + + // 비밀번호 확인 (본인 인증) + if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + throw new GeneralException(ErrorCode.BAD_REQUEST); + } + + // 새로운 이메일 중복 검사 + if (userRepository.existsByEmail(request.getNewEmail())) { + throw new GeneralException(ErrorCode.MEMBER_ALREADY_REGISTERED); + } + + // 이메일 변경 + user.updateEmail(request.getNewEmail()); + } + + /** + * 4. 닉네임 변경 + * 설명: 닉네임 중복 검사 -> 변경 [닉네임 변경 기능 삭제] + */ +// @Transactional +// public void updateNickname(Long userId, UserRequestDTO.NicknameUpdateDto request) { +// User user = getUserById(userId); +// +// // 닉네임 중복 검사 (기존 닉네임과 다를 경우에만) +// if (!user.getNickname().equals(request.getNickname()) && +// userRepository.existsByNickname(request.getNickname())) { +// throw new GeneralException(ErrorCode.BAD_REQUEST); // 중복된 닉네임 +// } +// +// // 닉네임 변경 (프로필 이미지는 유지) +// user.updateProfile(request.getNickname(), user.getProfileImageUrl()); +// } + + // 내부 헬퍼 메서드: ID로 유저 찾기 (반복 코드 제거) + private User getUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorCode.MEMBER_NOT_FOUND)); + } + + /** + * 5. 내 프로필 조회 + */ + public UserResponseDTO.MyProfileDto getProfile(Long userId) { + User user = getUserById(userId); + + return userConverter.toMyProfileDto(user); + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 11b156e..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=digital-tok diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..686180c --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,79 @@ +server: + forward-headers-strategy: framework # Nginx 헤더 신뢰 설정 + +app: + server-url: ${SERVER_URL:http://localhost:8080} + +spring: + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update # create는 매번 데이터를 날리므로 update 추천 + properties: + hibernate: + format_sql: true + show_sql: true + storage: + type: s3 + + # 최신 버전에서 cloud는 spring 밑에 있어야함 + cloud: + aws: + s3: + bucket: digitaltok-bucket + region: + static: ap-northeast-2 + stack: + auto: false + credentials: + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + #profiles: + # active: prod + # ... ?? ?? ... + # security: + # oauth2: + # client: + # registration: + # kakao: + # client-authentication-method: client_secret_post + # client-id: { ?? client-id } + # client-secret: { ?? client-secret } + # redirect-uri: http://{ ?? ec2 public ip ?? }:8080/login/oauth2/code/kakao + # authorization-grant-type: authorization_code + # scope: profile_nickname + # client-name: Kakao + # ... ?? ?? ... + + #이메일 서버 설정 (Gmail 예시) + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_USERNAME} # 발송할 이메일 주소 + password: ${MAIL_PASSWORD} # 구글 앱 비밀번호 + properties: + mail: + smtp: + auth: true + starttls: + enable: true + required: true + connectiontimeout: 5000 + timeout: 5000 + writetimeout: 5000 + + # JWT 설정 +jwt: + # 시스템 환경 변수 중 JWT_SECRET_KEY 값을 가져오고, 없으면 기본값(뒷부분)을 씁니다. + secret: ${JWT_SECRET_KEY} + access-token-validity-in-seconds: 3600 + refresh-token-validity-in-seconds: 1209600 \ No newline at end of file diff --git a/src/main/resources/fonts/Pretendard-Bold.otf b/src/main/resources/fonts/Pretendard-Bold.otf new file mode 100644 index 0000000..8e5e30a Binary files /dev/null and b/src/main/resources/fonts/Pretendard-Bold.otf differ diff --git a/src/test/java/com/digital_tok/template/service/makeSubwayImage/SubwayTemplateUploadServiceTest.java b/src/test/java/com/digital_tok/template/service/makeSubwayImage/SubwayTemplateUploadServiceTest.java new file mode 100644 index 0000000..f8dc51b --- /dev/null +++ b/src/test/java/com/digital_tok/template/service/makeSubwayImage/SubwayTemplateUploadServiceTest.java @@ -0,0 +1,73 @@ +package com.digital_tok.template.service.makeSubwayImage; + +import com.digital_tok.template.dto.SubwayCreateRequestDTO; +import com.digital_tok.template.repository.SubwayTemplateRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@SpringBootTest +class SubwayTemplateUploadServiceTest { + + @Autowired + private SubwayTemplateUploadService subwayTemplateUploadService; + + @MockitoBean + private S3UploadService s3UploadService; + @MockitoBean + private Eink4ColorService eink4ColorService; + @MockitoBean + private SubwayTemplateRepository subwayTemplateRepository; + @MockitoBean + private SubwayTemplateService subwayTemplateService; + + @Test + @DisplayName("이미지 생성 후 DB 저장 실패 시 S3 파일 삭제(롤백) 메서드가 호출되어야 한다") + void rollbackTest() throws Exception { + // given + String fakeUrl = "https://s3.aws.com/test.png"; + + // 1. 이미지 데이터 생성 + BufferedImage mockImage = new BufferedImage(200, 200, BufferedImage.TYPE_INT_ARGB); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(mockImage, "png", baos); + byte[] validImageBytes = baos.toByteArray(); // 유효한 PNG 바이트 배열 + + given(eink4ColorService.generatePatternImage(any(), any(), any())) + .willReturn(validImageBytes); + + given(s3UploadService.upload(any(byte[].class), any(), any(), any())) + .willReturn(fakeUrl); + + doThrow(new RuntimeException("DB 저장 실패")) + .when(subwayTemplateService) + .saveToDatabase(any(), any(), any(), any(), any()); + + // when + try { + SubwayCreateRequestDTO request = SubwayCreateRequestDTO.builder() + .stationName("강남") + .stationNameEng("gangnam") + .lineName("2") + .build(); + + subwayTemplateUploadService.createAndSaveSubwayTemplate(request); + } catch (Exception e) { + System.out.println("예상된 에러 발생: " + e.getMessage()); + } + + // then + verify(s3UploadService, timeout(2000).times(2)).deleteFile(fakeUrl); + } +} \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..7768112 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,61 @@ +spring: + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + + # 1. DB를 H2(인메모리)로 변경 + datasource: + url: jdbc:h2:mem:testdb;MODE=MySQL + driver-class-name: org.h2.Driver + username: sa + password: + + # 2. JPA 설정 + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + format_sql: true + show_sql: true + defer-datasource-initialization: true + + # 3. AWS S3 설정 (가짜 값) + cloud: + aws: + s3: + bucket: test-bucket + region: + static: ap-northeast-2 + stack: + auto: false + credentials: + access-key: TEST_ACCESS_KEY + secret-key: TEST_SECRET_KEY + + # 4. 이메일 설정 (가짜 값) + mail: + host: localhost + port: 587 + username: test@email.com + password: test_password + properties: + mail: + smtp: + auth: true + starttls: + enable: true + required: true + +# 5. 커스텀 설정 +storage: + type: s3 + +# 6. JWT 설정 (★수정됨★) +# 유효한 Base64 문자열로 변경 (밑줄_ 없음, +, / 사용) +jwt: + # "ThisIsATestSecretKeyForDigitalTokServerProject123"를 Base64로 인코딩한 값입니다. + secret: VGhpc0lzQVRlc3RTZWNyZXRLZXlGb3JEaWdpdGFsVG9rU2VydmVyUHJvamVjdDEyMw== + access-token-validity-in-seconds: 3600 + refresh-token-validity-in-seconds: 1209600 \ No newline at end of file