diff --git a/build.gradle b/build.gradle index 301e7b3..688e820 100644 --- a/build.gradle +++ b/build.gradle @@ -1,10 +1,10 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.5.0' + id 'org.springframework.boot' version '3.5.3' id 'io.spring.dependency-management' version '1.1.7' } -group = 'com.dasom' +group = 'DASOM' version = '0.0.1-SNAPSHOT' java { @@ -13,46 +13,31 @@ java { } } -configurations { - compileOnly { - extendsFrom annotationProcessor - } -} - repositories { mavenCentral() + maven { url 'https://jitpack.io' } // IPFS는 JitPack을 통해 배포됨. } dependencies { - // Security - implementation 'org.springframework.boot:spring-boot-starter-security' - testImplementation 'org.springframework.security:spring-security-test' - - //JWT - implementation 'io.jsonwebtoken:jjwt-api:0.12.5' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' - - + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-security' +// implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'mysql:mysql-connector-java:8.0.33' + + // lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' + + // IPFS + implementation 'com.github.ipfs:java-ipfs-http-client:1.4.4' } tasks.named('test') { useJUnitPlatform() } - -bootJar { - enabled = true -} - -jar { - enabled = false -} \ No newline at end of file diff --git a/src/main/java/com/dasom/MemoReal/domain/capsule/config/SwaggerConfig.java b/src/main/java/com/dasom/MemoReal/domain/capsule/config/SwaggerConfig.java new file mode 100644 index 0000000..5010677 --- /dev/null +++ b/src/main/java/com/dasom/MemoReal/domain/capsule/config/SwaggerConfig.java @@ -0,0 +1,31 @@ +package com.dasom.MemoReal.domain.capsule.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .components(new Components()) + .info(new Info() + .title("API 문서") + .description("Swagger를 활용한 API 문서입니다.") + .version("1.0.0") + ); + } + + @Bean + public GroupedOpenApi publicApi() { + return GroupedOpenApi.builder() + .group("v1-definition") + .pathsToMatch("/**") + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/dasom/MemoReal/domain/capsule/controller/CapsuleController.java b/src/main/java/com/dasom/MemoReal/domain/capsule/controller/CapsuleController.java new file mode 100644 index 0000000..85952a3 --- /dev/null +++ b/src/main/java/com/dasom/MemoReal/domain/capsule/controller/CapsuleController.java @@ -0,0 +1,74 @@ +package com.dasom.MemoReal.domain.capsule.controller; + +import com.dasom.MemoReal.domain.capsule.dto.CapsuleCreateRequestDto; +import com.dasom.MemoReal.domain.capsule.dto.CapsuleUpdateRequestDto; +import com.dasom.MemoReal.domain.capsule.entity.Capsule; +import com.dasom.MemoReal.domain.capsule.service.CapsuleService; +import com.dasom.MemoReal.domain.ipfs.service.IpfsService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/capsules") +@RequiredArgsConstructor +public class CapsuleController { + + private final CapsuleService capsuleService; + private final IpfsService ipfsService; + private final ObjectMapper objectMapper; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity createCapsule( + @RequestPart("data") String data, + // 수동 데이터 입력 + // {"capsuleType":"일반","title":"제목","content":"내용","openTime":"2025-07-20T14:03:42.989Z"} + @RequestPart(value = "media", required = false) MultipartFile mediaFile + ) throws JsonProcessingException { + CapsuleCreateRequestDto dto = objectMapper.readValue(data, CapsuleCreateRequestDto.class); + + String cid = (mediaFile != null && !mediaFile.isEmpty()) ? ipfsService.uploadFile(mediaFile) : null; + + Capsule saved = capsuleService.createCapsule(dto, cid); + return ResponseEntity.status(HttpStatus.CREATED).body(saved); + } + + @GetMapping("/{id}") + public ResponseEntity getCapsuleById(@PathVariable Long id) { + Capsule capsule = capsuleService.selectCapsuleById(id); + return (capsule != null) + ? ResponseEntity.ok(capsule) + : ResponseEntity.notFound().build(); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteCapsule(@PathVariable Long id) { + capsuleService.deleteCapsule(id); + return ResponseEntity.noContent().build(); + } + + @PatchMapping(value = "/{id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity updateCapsule( + @PathVariable("id") Long id, + @RequestPart("data") String data, + // 수동 데이터 입력 + // {"capsuleType":"일반","title":"제목","content":"내용","openTime":"2025-07-20T14:03:42.989Z"} + @RequestPart(value = "media", required = false) MultipartFile mediaFile + ) throws JsonProcessingException { + + CapsuleUpdateRequestDto dto = objectMapper.readValue(data, CapsuleUpdateRequestDto.class); + + String newCid = null; + if (mediaFile != null && !mediaFile.isEmpty()) { + newCid = ipfsService.uploadFile(mediaFile); + } + + capsuleService.updateCapsule(id, dto, newCid); + return ResponseEntity.ok().build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/dasom/MemoReal/domain/capsule/dto/CapsuleCreateRequestDto.java b/src/main/java/com/dasom/MemoReal/domain/capsule/dto/CapsuleCreateRequestDto.java new file mode 100644 index 0000000..631f274 --- /dev/null +++ b/src/main/java/com/dasom/MemoReal/domain/capsule/dto/CapsuleCreateRequestDto.java @@ -0,0 +1,16 @@ +package com.dasom.MemoReal.domain.capsule.dto; + +import lombok.Getter; +import lombok.Setter; + +import java.util.Date; + +@Getter +@Setter +public class CapsuleCreateRequestDto { + + private String capsuleType; + private String title; + private String content; + private Date openTime; +} diff --git a/src/main/java/com/dasom/MemoReal/domain/capsule/dto/CapsuleSelectResponseDto.java b/src/main/java/com/dasom/MemoReal/domain/capsule/dto/CapsuleSelectResponseDto.java new file mode 100644 index 0000000..a748476 --- /dev/null +++ b/src/main/java/com/dasom/MemoReal/domain/capsule/dto/CapsuleSelectResponseDto.java @@ -0,0 +1,20 @@ +package com.dasom.MemoReal.domain.capsule.dto; + +import lombok.Getter; + +import java.util.Date; + +public class CapsuleSelectResponseDto { + + @Getter + + private Long capsuleId; + private String capsuleType; + private String capsuleTitle; + private String capsuleContent; + private Date openTime; + + public CapsuleSelectResponseDto(Long capsuleId) { + this.capsuleId = capsuleId; + } +} diff --git a/src/main/java/com/dasom/MemoReal/domain/capsule/dto/CapsuleUpdateRequestDto.java b/src/main/java/com/dasom/MemoReal/domain/capsule/dto/CapsuleUpdateRequestDto.java new file mode 100644 index 0000000..b65f4c8 --- /dev/null +++ b/src/main/java/com/dasom/MemoReal/domain/capsule/dto/CapsuleUpdateRequestDto.java @@ -0,0 +1,23 @@ +package com.dasom.MemoReal.domain.capsule.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; + +import java.util.Date; + +@Getter +@Setter +@RequiredArgsConstructor +@AllArgsConstructor +public class CapsuleUpdateRequestDto { + + // 캡슐 ID는 따로 요청하지 않습니다. * URL을 통해 어떤 ID를 수정할지 알 수 있기 떄문에 + // private Long capsuleId; + + private String capsuleType; + private String title; + private String content; + private Date openTime; +} diff --git a/src/main/java/com/dasom/MemoReal/domain/capsule/entity/Capsule.java b/src/main/java/com/dasom/MemoReal/domain/capsule/entity/Capsule.java new file mode 100644 index 0000000..5d0bfc6 --- /dev/null +++ b/src/main/java/com/dasom/MemoReal/domain/capsule/entity/Capsule.java @@ -0,0 +1,48 @@ +package com.dasom.MemoReal.domain.capsule.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.Date; + +@Entity +@Getter // 테스트 시 필요 +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class Capsule { + + // 캡슐 아이디 + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long capsuleId; + + // 캡슐 종류 + private String capsuleType; + + // 제목 + private String title; + + // 내용 + private String content; + + // 캡슐 오픈일 + private Date openTime; + + // IPFS CID 필드 ( 이미지 / 동영상 ) + private String mediaCid; + + // 생성자 (id 제외) + public Capsule(String capsuleType, String title, String content, Date openTime) { + this.capsuleType = capsuleType; + this.title = title; + this.content = content; + this.openTime = openTime; + } +} diff --git a/src/main/java/com/dasom/MemoReal/domain/capsule/repository/CapsuleRepository.java b/src/main/java/com/dasom/MemoReal/domain/capsule/repository/CapsuleRepository.java new file mode 100644 index 0000000..274c0a2 --- /dev/null +++ b/src/main/java/com/dasom/MemoReal/domain/capsule/repository/CapsuleRepository.java @@ -0,0 +1,10 @@ +package com.dasom.MemoReal.domain.capsule.repository; + +import com.dasom.MemoReal.domain.capsule.entity.Capsule; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CapsuleRepository extends JpaRepository { + + // MySQL과 연동 + +} diff --git a/src/main/java/com/dasom/MemoReal/domain/capsule/service/CapsuleService.java b/src/main/java/com/dasom/MemoReal/domain/capsule/service/CapsuleService.java new file mode 100644 index 0000000..4198d88 --- /dev/null +++ b/src/main/java/com/dasom/MemoReal/domain/capsule/service/CapsuleService.java @@ -0,0 +1,60 @@ +package com.dasom.MemoReal.domain.capsule.service; + +import com.dasom.MemoReal.domain.capsule.dto.CapsuleCreateRequestDto; +import com.dasom.MemoReal.domain.capsule.dto.CapsuleUpdateRequestDto; +import com.dasom.MemoReal.domain.capsule.entity.Capsule; +import com.dasom.MemoReal.domain.capsule.repository.CapsuleRepository; +import com.dasom.MemoReal.global.exception.CustomException; +import com.dasom.MemoReal.global.exception.ErrorCode; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CapsuleService { + + private final CapsuleRepository capsuleRepository; + + public Capsule createCapsule(CapsuleCreateRequestDto dto, String mediaCid) { + Capsule capsule = new Capsule(); + capsule.setCapsuleType(dto.getCapsuleType()); + capsule.setTitle(dto.getTitle()); + capsule.setContent(dto.getContent()); + capsule.setOpenTime(dto.getOpenTime()); + capsule.setMediaCid(mediaCid); + + return capsuleRepository.save(capsule); + } + + + public Capsule selectCapsuleById(Long id) { + return capsuleRepository.findById(id).orElse(null); + } + + @Transactional + public void deleteCapsule(Long capsuleId) { + Capsule capsule = capsuleRepository.findById(capsuleId) + .orElseThrow(() -> new CustomException(ErrorCode.CAPSULE_NOT_FOUND)); + + capsuleRepository.delete(capsule); + } + + @Transactional + public void updateCapsule(Long id, CapsuleUpdateRequestDto dto, String newCid) { + Capsule capsule = capsuleRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("캡슐 없음")); + + capsule.setCapsuleType(dto.getCapsuleType()); + capsule.setTitle(dto.getTitle()); + capsule.setContent(dto.getContent()); + capsule.setOpenTime(dto.getOpenTime()); + + if (newCid != null) { + capsule.setMediaCid(newCid); // 새로운 이미지로 대체 + } + + capsuleRepository.save(capsule); + } +} diff --git a/src/main/java/com/dasom/MemoReal/domain/ipfs/config/IpfsConfig.java b/src/main/java/com/dasom/MemoReal/domain/ipfs/config/IpfsConfig.java new file mode 100644 index 0000000..0c5fe76 --- /dev/null +++ b/src/main/java/com/dasom/MemoReal/domain/ipfs/config/IpfsConfig.java @@ -0,0 +1,15 @@ +package com.dasom.MemoReal.domain.ipfs.config; + +import io.ipfs.api.IPFS; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class IpfsConfig { + + @Bean + public IPFS ipfs() { + // 로컬 IPFS 노드 (기본 포트 5001) + return new IPFS("/ip4/127.0.0.1/tcp/5001"); + } +} diff --git a/src/main/java/com/dasom/MemoReal/domain/ipfs/controller/IpfsController.java b/src/main/java/com/dasom/MemoReal/domain/ipfs/controller/IpfsController.java new file mode 100644 index 0000000..8227c9c --- /dev/null +++ b/src/main/java/com/dasom/MemoReal/domain/ipfs/controller/IpfsController.java @@ -0,0 +1,29 @@ +package com.dasom.MemoReal.domain.ipfs.controller; + +import com.dasom.MemoReal.domain.ipfs.service.IpfsService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/ipfs") +@RequiredArgsConstructor +public class IpfsController { + + private final IpfsService ipfsService; + + // 파일 업로드 API 예시 (이미지, 동영상 등) + @PostMapping("/upload/file") + public ResponseEntity uploadFile(@RequestParam("file") MultipartFile file) { + try { + String cid = ipfsService.uploadFile(file); + return ResponseEntity.ok(cid); + } catch (Exception e) { + return ResponseEntity.internalServerError().body("IPFS 파일 업로드 실패: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/dasom/MemoReal/domain/ipfs/service/IpfsService.java b/src/main/java/com/dasom/MemoReal/domain/ipfs/service/IpfsService.java new file mode 100644 index 0000000..589c45a --- /dev/null +++ b/src/main/java/com/dasom/MemoReal/domain/ipfs/service/IpfsService.java @@ -0,0 +1,30 @@ +package com.dasom.MemoReal.domain.ipfs.service; + +import io.ipfs.api.IPFS; +import io.ipfs.api.MerkleNode; +import io.ipfs.api.NamedStreamable; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +@Service +public class IpfsService { + + private final IPFS ipfs; + + public IpfsService() { + this.ipfs = new IPFS("/ip4/127.0.0.1/tcp/5001"); // IPFS 데몬 주소 + } + + public String uploadFile(MultipartFile file) { + try { + NamedStreamable.InputStreamWrapper inputStreamWrapper = + new NamedStreamable.InputStreamWrapper(file.getInputStream()); + MerkleNode addResult = ipfs.add(inputStreamWrapper).get(0); + return addResult.hash.toBase58(); + } catch (IOException e) { + throw new RuntimeException("IPFS 업로드 실패", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/dasom/MemoReal/domain/user/ipfs/config/IpfsConfig.java b/src/main/java/com/dasom/MemoReal/domain/user/ipfs/config/IpfsConfig.java new file mode 100644 index 0000000..12c6f57 --- /dev/null +++ b/src/main/java/com/dasom/MemoReal/domain/user/ipfs/config/IpfsConfig.java @@ -0,0 +1,15 @@ +package com.dasom.MemoReal.domain.user.ipfs.config; + +import io.ipfs.api.IPFS; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class IpfsConfig { + + @Bean + public IPFS ipfs() { + // 로컬 IPFS 노드 (기본 포트 5001) + return new IPFS("/ip4/127.0.0.1/tcp/5001"); + } +} diff --git a/src/main/java/com/dasom/MemoReal/domain/user/ipfs/controller/IpfsController.java b/src/main/java/com/dasom/MemoReal/domain/user/ipfs/controller/IpfsController.java new file mode 100644 index 0000000..d9d8838 --- /dev/null +++ b/src/main/java/com/dasom/MemoReal/domain/user/ipfs/controller/IpfsController.java @@ -0,0 +1,29 @@ +package com.dasom.MemoReal.domain.user.ipfs.controller; + +import com.dasom.MemoReal.domain.ipfs.service.IpfsService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/ipfs") +@RequiredArgsConstructor +public class IpfsController { + + private final IpfsService ipfsService; + + // 파일 업로드 API 예시 (이미지, 동영상 등) + @PostMapping("/upload/file") + public ResponseEntity uploadFile(@RequestParam("file") MultipartFile file) { + try { + String cid = ipfsService.uploadFile(file); + return ResponseEntity.ok(cid); + } catch (Exception e) { + return ResponseEntity.internalServerError().body("IPFS 파일 업로드 실패: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/dasom/MemoReal/domain/user/ipfs/service/IpfsService.java b/src/main/java/com/dasom/MemoReal/domain/user/ipfs/service/IpfsService.java new file mode 100644 index 0000000..ad45ec2 --- /dev/null +++ b/src/main/java/com/dasom/MemoReal/domain/user/ipfs/service/IpfsService.java @@ -0,0 +1,30 @@ +package com.dasom.MemoReal.domain.user.ipfs.service; + +import io.ipfs.api.IPFS; +import io.ipfs.api.MerkleNode; +import io.ipfs.api.NamedStreamable; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +@Service +public class IpfsService { + + private final IPFS ipfs; + + public IpfsService() { + this.ipfs = new IPFS("/ip4/127.0.0.1/tcp/5001"); // IPFS 데몬 주소 + } + + public String uploadFile(MultipartFile file) { + try { + NamedStreamable.InputStreamWrapper inputStreamWrapper = + new NamedStreamable.InputStreamWrapper(file.getInputStream()); + MerkleNode addResult = ipfs.add(inputStreamWrapper).get(0); + return addResult.hash.toBase58(); + } catch (IOException e) { + throw new RuntimeException("IPFS 업로드 실패", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/dasom/MemoReal/global/exception/ErrorCode.java b/src/main/java/com/dasom/MemoReal/global/exception/ErrorCode.java index cca25cc..8bfc44f 100644 --- a/src/main/java/com/dasom/MemoReal/global/exception/ErrorCode.java +++ b/src/main/java/com/dasom/MemoReal/global/exception/ErrorCode.java @@ -18,7 +18,10 @@ public enum ErrorCode { // 일반적인 에러(유효성 검사 등) INVALID_INPUT_VALUE("COMMON_001", HttpStatus.BAD_REQUEST, "유효하지 않은 입력 값입니다."), UNAUTHORIZED("COMMON_002", HttpStatus.UNAUTHORIZED, "인증되지 않은 접근입니다."), - INTERNAL_SERVER_ERROR("COMMON_999", HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."); + INTERNAL_SERVER_ERROR("COMMON_999", HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."), + + // 캡슐 관련 에러 + CAPSULE_NOT_FOUND("CAPSULE_001", HttpStatus.NOT_FOUND, "해당 ID의 캡슐을 찾을 수 없습니다."); private final String code; private final HttpStatus httpStatus; diff --git a/src/main/java/com/dasom/MemoReal/global/exception/ErrorResponseDto.java b/src/main/java/com/dasom/MemoReal/global/exception/ErrorResponseDto.java new file mode 100644 index 0000000..bc894af --- /dev/null +++ b/src/main/java/com/dasom/MemoReal/global/exception/ErrorResponseDto.java @@ -0,0 +1,19 @@ +package com.dasom.MemoReal.global.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ErrorResponseDto { + private String code; + private String message; + + public static ErrorResponseDto from(ErrorCode errorCode) { + return new ErrorResponseDto(errorCode.getCode(), errorCode.getMessage()); + } + + public static ErrorResponseDto from(CustomException ex) { + return new ErrorResponseDto(ex.getErrorCode().getCode(), ex.getMessage()); + } +}