diff --git a/build.gradle b/build.gradle index f03a35f..2e62949 100644 --- a/build.gradle +++ b/build.gradle @@ -19,6 +19,10 @@ configurations { } } +configurations.all { + exclude group: 'commons-logging', module: 'commons-logging' +} //추가(yum): Standard Commons Logging discovery in action with spring-jcl: please remove commons-logging.jar from classpath in order to avoid potential conflicts + repositories { mavenCentral() // SpringAI 관련 maven 설정 @@ -31,7 +35,7 @@ repositories { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' + //implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' @@ -54,7 +58,7 @@ dependencies { annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' annotationProcessor 'org.projectlombok:lombok' - // Adds JTS Core and Hibernate Spatial for handling spatial data types and queries + // Adds JTS Core and Hibernate Spatial for handling spatial data types and queries //추가(yum) Info: [petty] [ restartedMain] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface io.github.petty.community.repository.CommentRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. implementation 'org.locationtech.jts:jts-core:1.20.0' implementation 'org.hibernate.orm:hibernate-spatial:6.6.13.Final' @@ -68,6 +72,8 @@ dependencies { implementation 'software.amazon.awssdk:rekognition' // ✅ SMTP implementation 'org.springframework.boot:spring-boot-starter-mail' + // ✅ Oauth2 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' // SpringAI API implementation 'io.grpc:grpc-xds:1.62.2' diff --git a/src/main/java/io/github/petty/community/Comments.java b/src/main/java/io/github/petty/community/Comments.java deleted file mode 100644 index 83ce39c..0000000 --- a/src/main/java/io/github/petty/community/Comments.java +++ /dev/null @@ -1,4 +0,0 @@ -package io.github.petty.community; - -public class Comments { -} diff --git a/src/main/java/io/github/petty/community/CommunityController.java b/src/main/java/io/github/petty/community/CommunityController.java deleted file mode 100644 index 8dba077..0000000 --- a/src/main/java/io/github/petty/community/CommunityController.java +++ /dev/null @@ -1,4 +0,0 @@ -package io.github.petty.community; - -public class CommunityController { -} diff --git a/src/main/java/io/github/petty/community/CommunityRepository.java b/src/main/java/io/github/petty/community/CommunityRepository.java deleted file mode 100644 index 5283571..0000000 --- a/src/main/java/io/github/petty/community/CommunityRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package io.github.petty.community; - -public interface CommunityRepository { -} diff --git a/src/main/java/io/github/petty/community/CommunityService.java b/src/main/java/io/github/petty/community/CommunityService.java deleted file mode 100644 index 9adfebb..0000000 --- a/src/main/java/io/github/petty/community/CommunityService.java +++ /dev/null @@ -1,4 +0,0 @@ -package io.github.petty.community; - -public class CommunityService { -} diff --git a/src/main/java/io/github/petty/community/PostLike.java b/src/main/java/io/github/petty/community/PostLike.java deleted file mode 100644 index facacc0..0000000 --- a/src/main/java/io/github/petty/community/PostLike.java +++ /dev/null @@ -1,4 +0,0 @@ -package io.github.petty.community; - -public class PostLike { -} diff --git a/src/main/java/io/github/petty/community/PostViews.java b/src/main/java/io/github/petty/community/PostViews.java deleted file mode 100644 index a548313..0000000 --- a/src/main/java/io/github/petty/community/PostViews.java +++ /dev/null @@ -1,4 +0,0 @@ -package io.github.petty.community; - -public class PostViews { -} diff --git a/src/main/java/io/github/petty/community/Posts.java b/src/main/java/io/github/petty/community/Posts.java deleted file mode 100644 index 0b35085..0000000 --- a/src/main/java/io/github/petty/community/Posts.java +++ /dev/null @@ -1,4 +0,0 @@ -package io.github.petty.community; - -public class Posts { -} diff --git a/src/main/java/io/github/petty/community/controller/CommentController.java b/src/main/java/io/github/petty/community/controller/CommentController.java new file mode 100644 index 0000000..eea029e --- /dev/null +++ b/src/main/java/io/github/petty/community/controller/CommentController.java @@ -0,0 +1,56 @@ +package io.github.petty.community.controller; + +import io.github.petty.community.dto.CommentRequest; +import io.github.petty.community.dto.CommentResponse; +import io.github.petty.community.service.CommentService; +import io.github.petty.users.dto.CustomUserDetails; +import io.github.petty.users.entity.Users; +import io.github.petty.users.repository.UsersRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class CommentController { + + private final CommentService commentService; + private final UsersRepository usersRepository; + + @GetMapping("/api/posts/{postId}/comments") + public ResponseEntity> getComments(@PathVariable Long postId) { + return ResponseEntity.ok(commentService.getComments(postId)); + } + + @PostMapping("/api/posts/{postId}/comments") + public ResponseEntity addComment(@PathVariable Long postId, + @RequestBody CommentRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + String username = userDetails.getUsername(); + Users user = usersRepository.findByUsername(username); + Long commentId = commentService.addComment(postId, request, user); + return ResponseEntity.ok().body(commentId); + } + + @PutMapping("/api/comments/{commentId}") + public ResponseEntity updateComment(@PathVariable Long commentId, + @RequestBody CommentRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + String username = userDetails.getUsername(); + Users user = usersRepository.findByUsername(username); + commentService.updateComment(commentId, request, user); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/api/comments/{commentId}") + public ResponseEntity deleteComment(@PathVariable Long commentId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + String username = userDetails.getUsername(); + Users user = usersRepository.findByUsername(username); + commentService.deleteComment(commentId, user); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/io/github/petty/community/controller/PostController.java b/src/main/java/io/github/petty/community/controller/PostController.java new file mode 100644 index 0000000..36a20c1 --- /dev/null +++ b/src/main/java/io/github/petty/community/controller/PostController.java @@ -0,0 +1,78 @@ +package io.github.petty.community.controller; + +import io.github.petty.community.dto.PostDetailResponse; +import io.github.petty.community.dto.PostRequest; +import io.github.petty.community.entity.Post; +import io.github.petty.community.service.PostService; +import io.github.petty.users.dto.CustomUserDetails; +import io.github.petty.users.entity.Users; +import io.github.petty.users.repository.UsersRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/posts") +@RequiredArgsConstructor +public class PostController { + + private final PostService postService; + private final UsersRepository usersRepository; + + @PostMapping + public ResponseEntity create(@RequestBody PostRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + String username = userDetails.getUsername(); + Users user = usersRepository.findByUsername(username); + Long id = postService.save(request, user); + return ResponseEntity.ok(Map.of("id", id)); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable Long id, + @RequestBody PostRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + String username = userDetails.getUsername(); + Users user = usersRepository.findByUsername(username); + postService.update(id, request, user); + System.out.println("📥 요청 들어옴: " + request); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id, + @AuthenticationPrincipal CustomUserDetails userDetails) { + String username = userDetails.getUsername(); + Users user = usersRepository.findByUsername(username); + postService.delete(id, user); + return ResponseEntity.ok().build(); + } + + @GetMapping + public ResponseEntity> getAllByType(@RequestParam("type") Post.PostType type, + @PageableDefault(size = 9) Pageable pageable) { + Page posts = postService.findAllByType(String.valueOf(type), pageable); + return ResponseEntity.ok(posts); + } + + @GetMapping("/{id}") + public ResponseEntity getPost(@PathVariable Long id) { + PostDetailResponse post = postService.findById(id); + return ResponseEntity.ok(post); + } + + @PostMapping("/{id}/like") + public ResponseEntity likePost(@PathVariable Long id, + @AuthenticationPrincipal CustomUserDetails userDetails) { + String username = userDetails.getUsername(); + Users user = usersRepository.findByUsername(username); + int newCount = postService.toggleLike(id, user); // 좋아요 또는 취소 + return ResponseEntity.ok(Map.of("likeCount", newCount)); + } +} diff --git a/src/main/java/io/github/petty/community/controller/PostImageUploadController.java b/src/main/java/io/github/petty/community/controller/PostImageUploadController.java new file mode 100644 index 0000000..44cadcf --- /dev/null +++ b/src/main/java/io/github/petty/community/controller/PostImageUploadController.java @@ -0,0 +1,121 @@ +package io.github.petty.community.controller; + +import io.github.petty.community.util.SupabaseUploader; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +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; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/images") +@RequiredArgsConstructor +@Slf4j +public class PostImageUploadController { + + private final SupabaseUploader supabaseUploader; + private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + private static final List ALLOWED_EXTENSIONS = List.of("jpg", "jpeg", "png", "gif"); + + // ✅ 단일 이미지 업로드 + @PostMapping("/upload") + public ResponseEntity uploadImage( + @RequestParam("file") MultipartFile file, + @AuthenticationPrincipal UserDetails userDetails) { + + if (userDetails == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", "로그인이 필요합니다")); + } + + // 파일 유효성 검사 + if (!isValidImage(file)) { + return ResponseEntity.badRequest().body(Map.of( + "error", "유효하지 않은 파일", + "message", "5MB 이하의 jpg, jpeg, png, gif 파일만 업로드 가능합니다" + )); + } + + try { + String imageUrl = supabaseUploader.upload(file); + return ResponseEntity.ok(Map.of("url", imageUrl)); + } catch (IOException e) { + log.error("이미지 업로드 실패", e); + return ResponseEntity.internalServerError().body(Map.of("error", "업로드 실패", "message", e.getMessage())); + } + } + + // ✅ 다중 이미지 업로드 + @PostMapping("/upload/multi") + public ResponseEntity uploadMultipleImages( + @RequestParam("files") List files, + @AuthenticationPrincipal UserDetails userDetails) { + + if (userDetails == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", "로그인이 필요합니다")); + } + + // 파일 수 제한 + if (files.size() > 5) { + return ResponseEntity.badRequest().body(Map.of( + "error", "이미지 제한 초과", + "message", "최대 5개의 이미지만 업로드 가능합니다" + )); + } + + // 모든 파일의 유효성 검사 + for (MultipartFile file : files) { + if (!isValidImage(file)) { + return ResponseEntity.badRequest().body(Map.of( + "error", "유효하지 않은 파일", + "message", "5MB 이하의 jpg, jpeg, png, gif 파일만 업로드 가능합니다" + )); + } + } + + List> imageResponses = new ArrayList<>(); + + try { + int order = 0; + for (MultipartFile file : files) { + String url = supabaseUploader.upload(file); + + Map imageMap = new HashMap<>(); + imageMap.put("imageUrl", url); + imageMap.put("ordering", order++); + imageMap.put("isDeleted", false); + + imageResponses.add(imageMap); + } + return ResponseEntity.ok(Map.of("images", imageResponses)); + } catch (IOException e) { + log.error("이미지 업로드 실패", e); + return ResponseEntity.internalServerError().body(Map.of("error", "이미지 업로드 실패", "message", e.getMessage())); + } + } + + private boolean isValidImage(MultipartFile file) { + if (file.isEmpty() || file.getSize() > MAX_FILE_SIZE) { + return false; + } + + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null) { + return false; + } + + String extension = originalFilename.substring(originalFilename.lastIndexOf('.') + 1).toLowerCase(); + return ALLOWED_EXTENSIONS.contains(extension); + } +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/community/controller/PostViewController.java b/src/main/java/io/github/petty/community/controller/PostViewController.java new file mode 100644 index 0000000..49e8794 --- /dev/null +++ b/src/main/java/io/github/petty/community/controller/PostViewController.java @@ -0,0 +1,63 @@ +package io.github.petty.community.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class PostViewController { + + // 📌 후기 게시판 + @GetMapping("/posts/review") + public String reviewListPage() { + return "post-review-list"; + } + + @GetMapping("/posts/review/new") + public String reviewFormPage() { + return "post-review-form"; + } + + @GetMapping("/posts/review/edit") + public String reviewEditPage() { + return "edit-review"; + } + + // 📌 자랑 게시판 + @GetMapping("/posts/showoff") + public String showoffListPage() { + return "post-showoff-list"; + } + + @GetMapping("/posts/showoff/new") + public String showoffFormPage() { + return "post-showoff-form"; + } + + @GetMapping("/posts/showoff/edit") + public String showoffEditPage() { + return "edit-showoff"; + } + + // 📌 질문 게시판 + @GetMapping("/posts/qna") + public String qnaListPage() { + return "post-qna-list"; + } + + @GetMapping("/posts/qna/new") + public String qnaFormPage() { + return "post-qna-form"; + } + + @GetMapping("/posts/qna/edit") + public String qnaEditPage() { + return "edit-qna"; + } + + // 📌 상세페이지 (공통) + @GetMapping("/posts/detail") + public String detailPage() { + return "post-detail"; + } +} + diff --git a/src/main/java/io/github/petty/community/dto/CommentRequest.java b/src/main/java/io/github/petty/community/dto/CommentRequest.java new file mode 100644 index 0000000..3ecc6ca --- /dev/null +++ b/src/main/java/io/github/petty/community/dto/CommentRequest.java @@ -0,0 +1,10 @@ +package io.github.petty.community.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CommentRequest { + private String content; +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/community/dto/CommentResponse.java b/src/main/java/io/github/petty/community/dto/CommentResponse.java new file mode 100644 index 0000000..13493b4 --- /dev/null +++ b/src/main/java/io/github/petty/community/dto/CommentResponse.java @@ -0,0 +1,15 @@ +package io.github.petty.community.dto; + +import lombok.Getter; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class CommentResponse { + private Long id; + private String writer; + private String content; + private LocalDateTime createdAt; +} diff --git a/src/main/java/io/github/petty/community/dto/PostDetailResponse.java b/src/main/java/io/github/petty/community/dto/PostDetailResponse.java new file mode 100644 index 0000000..55f1a47 --- /dev/null +++ b/src/main/java/io/github/petty/community/dto/PostDetailResponse.java @@ -0,0 +1,26 @@ +package io.github.petty.community.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +public class PostDetailResponse { + + private Long id; // 게시글 ID + private String title; // 제목 + private String content; // 내용 + private String writer; // 작성자 닉네임 + private String postType; // 게시물 유형 + private String petType; // 반려동물 종류 (한글 라벨) + private String petName; // 반려동물 이름 (REVIEW에서만 사용) + private String region; // 지역 정보 (REVIEW에서만 사용) + private Boolean isResolved; // QnA 게시판 여부 (QNA 전용) + private int likeCount; // 좋아요 수 + private int commentCount; // 댓글 수 + private List images; + private LocalDateTime createdAt; // 생성일자 +} diff --git a/src/main/java/io/github/petty/community/dto/PostImageRequest.java b/src/main/java/io/github/petty/community/dto/PostImageRequest.java new file mode 100644 index 0000000..09fa063 --- /dev/null +++ b/src/main/java/io/github/petty/community/dto/PostImageRequest.java @@ -0,0 +1,13 @@ +package io.github.petty.community.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class PostImageRequest { + private Long id; // 기존 이미지 수정/삭제할 때 필요 (신규는 null) + private String imageUrl; + private Integer ordering; // 0~4 중 위치 지정 + private Boolean isDeleted; // 삭제 요청인지 표시 +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/community/dto/PostImageResponse.java b/src/main/java/io/github/petty/community/dto/PostImageResponse.java new file mode 100644 index 0000000..cc9f8da --- /dev/null +++ b/src/main/java/io/github/petty/community/dto/PostImageResponse.java @@ -0,0 +1,16 @@ +package io.github.petty.community.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class PostImageResponse { + private Long id; + private String imageUrl; + private Integer ordering; +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/community/dto/PostQnaListResponse.java b/src/main/java/io/github/petty/community/dto/PostQnaListResponse.java new file mode 100644 index 0000000..3b136e1 --- /dev/null +++ b/src/main/java/io/github/petty/community/dto/PostQnaListResponse.java @@ -0,0 +1,19 @@ +package io.github.petty.community.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class PostQnaListResponse { + private Long id; + private String title; + private String petType; // 한글 라벨 (예: "강아지") + private boolean isResolved; // 해결 여부 + private String writer; // 작성자 닉네임 + private int likeCount; + private int commentCount; + private LocalDateTime createdAt; +} diff --git a/src/main/java/io/github/petty/community/dto/PostRequest.java b/src/main/java/io/github/petty/community/dto/PostRequest.java new file mode 100644 index 0000000..caff658 --- /dev/null +++ b/src/main/java/io/github/petty/community/dto/PostRequest.java @@ -0,0 +1,20 @@ +package io.github.petty.community.dto; + +import io.github.petty.community.entity.Post; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class PostRequest { + private String title; + private String content; + private Post.PostType postType; + private String petName; + private String petType; + private String region; + private Boolean isResolved; + private List images; +} diff --git a/src/main/java/io/github/petty/community/dto/PostReviewListResponse.java b/src/main/java/io/github/petty/community/dto/PostReviewListResponse.java new file mode 100644 index 0000000..527d131 --- /dev/null +++ b/src/main/java/io/github/petty/community/dto/PostReviewListResponse.java @@ -0,0 +1,21 @@ +package io.github.petty.community.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class PostReviewListResponse { + private Long id; + private String title; + private String petName; + private String petType; // enum의 label 값 + private String region; + private String writer; // 작성자 닉네임 + private String imageUrl; // 대표 이미지 URL + private int likeCount; + private int commentCount; + private LocalDateTime createdAt; +} diff --git a/src/main/java/io/github/petty/community/dto/PostShowoffListResponse.java b/src/main/java/io/github/petty/community/dto/PostShowoffListResponse.java new file mode 100644 index 0000000..1536d7f --- /dev/null +++ b/src/main/java/io/github/petty/community/dto/PostShowoffListResponse.java @@ -0,0 +1,19 @@ +package io.github.petty.community.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class PostShowoffListResponse { + private Long id; + private String title; + private String petType; // 한글 라벨 + private String writer; // 작성자 닉네임 + private String imageUrl; // 대표 이미지 URL + private int likeCount; + private int commentCount; + private LocalDateTime createdAt; +} diff --git a/src/main/java/io/github/petty/community/entity/Comment.java b/src/main/java/io/github/petty/community/entity/Comment.java new file mode 100644 index 0000000..e5d5a90 --- /dev/null +++ b/src/main/java/io/github/petty/community/entity/Comment.java @@ -0,0 +1,38 @@ +package io.github.petty.community.entity; + +import io.github.petty.users.entity.Users; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "comments") +@Getter +@Setter +@EqualsAndHashCode(of = "id") +@ToString(exclude = {"post", "user"}) +public class Comment { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Users user; + + @Column(nullable = false, length = 1000) + private String content; + + @CreationTimestamp + private LocalDateTime createdAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; +} diff --git a/src/main/java/io/github/petty/community/entity/Post.java b/src/main/java/io/github/petty/community/entity/Post.java new file mode 100644 index 0000000..7d5a556 --- /dev/null +++ b/src/main/java/io/github/petty/community/entity/Post.java @@ -0,0 +1,76 @@ +package io.github.petty.community.entity; + +import io.github.petty.community.enums.PetType; +import io.github.petty.users.entity.Users; +import jakarta.persistence.*; +import lombok.*; +import io.github.petty.community.entity.PostImage; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.util.ArrayList; +import java.util.List; +import java.time.LocalDateTime; + +@Entity +@Table(name = "posts") +@Getter +@Setter +@ToString(exclude = "user") +@EqualsAndHashCode(exclude = "user") +public class Post { + + public enum PostType { + QNA, REVIEW, SHOWOFF + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 작성자 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Users user; + + // 공통 필드 + @Column(nullable = false, length = 50) + private String title; + + @Column(nullable = false, length = 2000) + private String content; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PostType postType = PostType.REVIEW; + + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private List images = new ArrayList<>(); + + @CreationTimestamp + private LocalDateTime createdAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; + + private int likeCount = 0; + private int commentCount = 0; + + // 게시판 타입별 확장 필드 + + // 후기 전용 + @Column(length = 100) + private String region; + + @Column(length = 20) + private String petName; + + // 공통 + @Enumerated(EnumType.STRING) + @Column(name = "pet_type", length = 20) + private PetType petType; + + // QnA 전용 + @Column(name = "is_resolved") + private Boolean isResolved = false; +} diff --git a/src/main/java/io/github/petty/community/entity/PostImage.java b/src/main/java/io/github/petty/community/entity/PostImage.java new file mode 100644 index 0000000..58a5fda --- /dev/null +++ b/src/main/java/io/github/petty/community/entity/PostImage.java @@ -0,0 +1,41 @@ +package io.github.petty.community.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "post_images") +@ToString(exclude = "post") +@EqualsAndHashCode(of = "id") +public class PostImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 게시글 연관관계 (필수) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @Column(name = "image_url", nullable = false) + private String imageUrl; + + // 이미지 순서 (0~4) + @Builder.Default + @Column(nullable = false) + private Integer ordering = 0; + + // 유틸 메서드 (양방향 연관관계 설정) + public void setPost(Post post) { + this.post = post; + if (!post.getImages().contains(this)) { + post.getImages().add(this); + } + } +} diff --git a/src/main/java/io/github/petty/community/entity/PostLike.java b/src/main/java/io/github/petty/community/entity/PostLike.java new file mode 100644 index 0000000..2d7a93f --- /dev/null +++ b/src/main/java/io/github/petty/community/entity/PostLike.java @@ -0,0 +1,38 @@ +package io.github.petty.community.entity; + +import io.github.petty.users.entity.Users; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "post_likes") // ✅ 테이블 명 명확히 +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) // ✅ JPA용 기본 생성자 +@EqualsAndHashCode(of = "id") +@ToString(exclude = {"post", "user"}) +public class PostLike { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) // ✅ 명시적 join column + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Users user; + + @CreationTimestamp + private LocalDateTime createdAt; + + public PostLike(Post post, Users user) { + this.post = post; + this.user = user; + } +} diff --git a/src/main/java/io/github/petty/community/enums/PetType.java b/src/main/java/io/github/petty/community/enums/PetType.java new file mode 100644 index 0000000..9c8ff18 --- /dev/null +++ b/src/main/java/io/github/petty/community/enums/PetType.java @@ -0,0 +1,21 @@ +package io.github.petty.community.enums; + +public enum PetType { + DOG("강아지"), + CAT("고양이"), + RABBIT("토끼"), + HAMSTER("햄스터"), + PARROT("앵무새"), + REPTILE("파충류"), + OTHER("기타"); + + private final String label; + + PetType(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } +} diff --git a/src/main/java/io/github/petty/community/repository/CommentRepository.java b/src/main/java/io/github/petty/community/repository/CommentRepository.java new file mode 100644 index 0000000..c609cc0 --- /dev/null +++ b/src/main/java/io/github/petty/community/repository/CommentRepository.java @@ -0,0 +1,17 @@ +package io.github.petty.community.repository; + +import io.github.petty.community.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CommentRepository extends JpaRepository { + + @Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.post.id = :postId ORDER BY c.createdAt ASC") + List findAllByPostIdWithUser(@Param("postId") Long postId); + +} diff --git a/src/main/java/io/github/petty/community/repository/PostImageRepository.java b/src/main/java/io/github/petty/community/repository/PostImageRepository.java new file mode 100644 index 0000000..31fe3b0 --- /dev/null +++ b/src/main/java/io/github/petty/community/repository/PostImageRepository.java @@ -0,0 +1,17 @@ +package io.github.petty.community.repository; + +import io.github.petty.community.entity.PostImage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PostImageRepository extends JpaRepository { + + // ✅ 게시글 ID로 모든 이미지 조회 (리스트/상세 출력 시 필요) + List findByPostIdOrderByOrderingAsc(Long postId); + + // ✅ 게시글 ID로 한 번에 삭제하고 싶을 때 (예: 게시글 삭제 시) + void deleteByPostId(Long postId); +} diff --git a/src/main/java/io/github/petty/community/repository/PostLikeRepository.java b/src/main/java/io/github/petty/community/repository/PostLikeRepository.java new file mode 100644 index 0000000..6726334 --- /dev/null +++ b/src/main/java/io/github/petty/community/repository/PostLikeRepository.java @@ -0,0 +1,17 @@ +package io.github.petty.community.repository; + +import io.github.petty.community.entity.Post; +import io.github.petty.community.entity.PostLike; +import io.github.petty.users.entity.Users; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PostLikeRepository extends JpaRepository { + + // ✅ 사용자가 특정 게시글에 좋아요를 눌렀는지 확인 + Optional findByPostAndUser(Post post, Users user); + + // ✅ 게시글 삭제 시 좋아요도 함께 삭제할 수 있도록 + void deleteAllByPost(Post post); +} diff --git a/src/main/java/io/github/petty/community/repository/PostRepository.java b/src/main/java/io/github/petty/community/repository/PostRepository.java new file mode 100644 index 0000000..6653585 --- /dev/null +++ b/src/main/java/io/github/petty/community/repository/PostRepository.java @@ -0,0 +1,25 @@ +package io.github.petty.community.repository; + +import io.github.petty.community.entity.Post; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface PostRepository extends JpaRepository { + @EntityGraph(attributePaths = {"user", "images"}) + Page findAllByPostTypeOrderByCreatedAtDesc(Post.PostType postType, Pageable pageable); + + @EntityGraph(attributePaths = "images") + Optional findById(Long id); + + @EntityGraph(attributePaths = {"user", "images"}) + Optional findWithUserAndImagesById(Long id); +} + diff --git a/src/main/java/io/github/petty/community/service/CommentService.java b/src/main/java/io/github/petty/community/service/CommentService.java new file mode 100644 index 0000000..6f3b2de --- /dev/null +++ b/src/main/java/io/github/petty/community/service/CommentService.java @@ -0,0 +1,25 @@ +package io.github.petty.community.service; + +import io.github.petty.community.dto.CommentRequest; +import io.github.petty.community.dto.CommentResponse; +import io.github.petty.users.entity.Users; +import org.springframework.context.annotation.Bean; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public interface CommentService { + + // 댓글 목록 조회 + List getComments(Long postId); + + // 댓글 작성 + Long addComment(Long postId, CommentRequest request, Users user); + + // 댓글 삭제 + void deleteComment(Long commentId, Users user); + + // 댓글 수정 + void updateComment(Long commentId, CommentRequest request, Users user); +} diff --git a/src/main/java/io/github/petty/community/service/CommentServiceImpl.java b/src/main/java/io/github/petty/community/service/CommentServiceImpl.java new file mode 100644 index 0000000..5681108 --- /dev/null +++ b/src/main/java/io/github/petty/community/service/CommentServiceImpl.java @@ -0,0 +1,82 @@ +package io.github.petty.community.service; + +import io.github.petty.community.dto.CommentRequest; +import io.github.petty.community.dto.CommentResponse; +import io.github.petty.community.entity.Comment; +import io.github.petty.community.entity.Post; +import io.github.petty.community.repository.CommentRepository; +import io.github.petty.community.repository.PostRepository; +import io.github.petty.users.entity.Users; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class CommentServiceImpl implements CommentService { + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + + @Override + public List getComments(Long postId) { + List comments = commentRepository.findAllByPostIdWithUser(postId); + return comments.stream() + .map(comment -> CommentResponse.builder() + .id(comment.getId()) + .writer(comment.getUser().getDisplayName()) // 안전하게 호출됨 + .content(comment.getContent()) + .createdAt(comment.getCreatedAt()) + .build()) + .collect(Collectors.toList()); + } + + @Override + public Long addComment(Long postId, CommentRequest request, Users user) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다.")); + + Comment comment = new Comment(); + comment.setPost(post); + comment.setUser(user); + comment.setContent(request.getContent()); + + post.setCommentCount(post.getCommentCount() + 1); // 댓글 수 증가 + + return commentRepository.save(comment).getId(); + } + + @Override + public void deleteComment(Long commentId, Users user) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new IllegalArgumentException("해당 댓글이 존재하지 않습니다.")); + + if (!comment.getUser().getId().equals(user.getId())) { + throw new IllegalArgumentException("삭제 권한이 없습니다."); + } + + Post post = comment.getPost(); + post.setCommentCount(Math.max(0, post.getCommentCount() - 1)); // 댓글 수 감소 + + commentRepository.delete(comment); + } + + @Override + public void updateComment(Long commentId, CommentRequest request, Users user) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new IllegalArgumentException("해당 댓글이 존재하지 않습니다.")); + + if (!comment.getUser().getId().equals(user.getId())) { + throw new IllegalArgumentException("수정 권한이 없습니다."); + } + + comment.setContent(request.getContent()); + commentRepository.save(comment); + } +} diff --git a/src/main/java/io/github/petty/community/service/PostImageService.java b/src/main/java/io/github/petty/community/service/PostImageService.java new file mode 100644 index 0000000..8b4748b --- /dev/null +++ b/src/main/java/io/github/petty/community/service/PostImageService.java @@ -0,0 +1,28 @@ +package io.github.petty.community.service; + +import io.github.petty.community.dto.PostImageRequest; +import io.github.petty.community.dto.PostImageResponse; +import io.github.petty.community.entity.Post; + +import java.util.List; + +public interface PostImageService { + + // ✅ 이미지 저장 (신규 게시글 생성 시) + void saveImages(Post post, List imageRequests); + + // ✅ 이미지 삭제 (게시글 삭제 시) + void deleteImagesByPostId(Long postId); + + // ✅ 이미지 하나 삭제 + void deleteImage(Long imageId); + + // ✅ 이미지 목록 조회 (상세 조회용) + List findImageResponsesByPostId(Long postId); + + // ✅ 이미지 순서 변경 + void reorderImages(List orderedImageIds); + + // ✅ 수정 요청 처리 (isDeleted, ordering 포함) + void updateImagesFromRequest(Post post, List imageRequests); +} diff --git a/src/main/java/io/github/petty/community/service/PostImageServiceImpl.java b/src/main/java/io/github/petty/community/service/PostImageServiceImpl.java new file mode 100644 index 0000000..e4da1cd --- /dev/null +++ b/src/main/java/io/github/petty/community/service/PostImageServiceImpl.java @@ -0,0 +1,90 @@ +package io.github.petty.community.service; + +import io.github.petty.community.dto.PostImageRequest; +import io.github.petty.community.dto.PostImageResponse; +import io.github.petty.community.entity.Post; +import io.github.petty.community.entity.PostImage; +import io.github.petty.community.repository.PostImageRepository; +import io.github.petty.community.util.SupabaseUploader; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class PostImageServiceImpl implements PostImageService { + + private final PostImageRepository postImageRepository; + private final SupabaseUploader supabaseUploader; + + @Override + public void saveImages(Post post, List imageRequests) { + if (imageRequests == null || imageRequests.isEmpty()) return; + + for (PostImageRequest dto : imageRequests) { + PostImage image = PostImage.builder() + .imageUrl(dto.getImageUrl()) + .ordering(dto.getOrdering()) + .post(post) + .build(); + postImageRepository.save(image); + } + } + + @Override + public void deleteImagesByPostId(Long postId) { + postImageRepository.deleteByPostId(postId); + } + + @Override + public void deleteImage(Long imageId) { + postImageRepository.deleteById(imageId); + } + + @Override + public List findImageResponsesByPostId(Long postId) { + return postImageRepository.findByPostIdOrderByOrderingAsc(postId).stream() + .map(img -> new PostImageResponse(img.getId(), img.getImageUrl(), img.getOrdering())) + .toList(); + } + + @Override + @Transactional + public void reorderImages(List orderedImageIds) { + for (int i = 0; i < orderedImageIds.size(); i++) { + Long imageId = orderedImageIds.get(i); + PostImage image = postImageRepository.findById(imageId) + .orElseThrow(() -> new IllegalArgumentException("이미지 ID가 잘못되었습니다.")); + image.setOrdering(i); // 새로운 순서로 업데이트 + } + } + + @Override + @Transactional + public void updateImagesFromRequest(Post post, List imageRequests) { + for (PostImageRequest dto : imageRequests) { + if (Boolean.TRUE.equals(dto.getIsDeleted())) { + if (dto.getId() != null) { + PostImage image = postImageRepository.findById(dto.getId()) + .orElseThrow(() -> new IllegalArgumentException("이미지를 찾을 수 없습니다.")); + postImageRepository.deleteById(dto.getId()); + supabaseUploader.delete(image.getImageUrl()); // ✅ Supabase에서 삭제 + } + } else if (dto.getId() != null) { + PostImage image = postImageRepository.findById(dto.getId()) + .orElseThrow(() -> new IllegalArgumentException("이미지를 찾을 수 없습니다.")); + image.setImageUrl(dto.getImageUrl()); + image.setOrdering(dto.getOrdering()); + } else { + PostImage newImage = new PostImage(); + newImage.setImageUrl(dto.getImageUrl()); + newImage.setOrdering(dto.getOrdering()); + newImage.setPost(post); + postImageRepository.save(newImage); + } + } + } +} + diff --git a/src/main/java/io/github/petty/community/service/PostService.java b/src/main/java/io/github/petty/community/service/PostService.java new file mode 100644 index 0000000..ce21fda --- /dev/null +++ b/src/main/java/io/github/petty/community/service/PostService.java @@ -0,0 +1,15 @@ +package io.github.petty.community.service; + +import io.github.petty.community.dto.*; +import io.github.petty.users.entity.Users; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface PostService { + Long save(PostRequest request, Users user); + void update(Long id, PostRequest request, Users user); + void delete(Long id, Users user); + Page findAllByType(String type, Pageable pageable); + PostDetailResponse findById(Long id); + int toggleLike(Long postId, Users user); +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/community/service/PostServiceImpl.java b/src/main/java/io/github/petty/community/service/PostServiceImpl.java new file mode 100644 index 0000000..54aec60 --- /dev/null +++ b/src/main/java/io/github/petty/community/service/PostServiceImpl.java @@ -0,0 +1,175 @@ +package io.github.petty.community.service; + +import io.github.petty.community.dto.*; +import io.github.petty.community.entity.Post; +import io.github.petty.community.entity.PostLike; +import io.github.petty.community.enums.PetType; +import io.github.petty.community.repository.PostLikeRepository; +import io.github.petty.community.repository.PostRepository; +import io.github.petty.users.entity.Users; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class PostServiceImpl implements PostService { + + private final PostRepository postRepository; + private final PostImageService postImageService; + private final PostLikeRepository postLikeRepository; + + @Override + public Long save(PostRequest request, Users user) { + Post post = new Post(); + post.setUser(user); + post.setTitle(request.getTitle()); + post.setContent(request.getContent()); + post.setPostType(request.getPostType()); + post.setPetName(request.getPetName()); + if (request.getPetType() != null && !request.getPetType().isBlank()) { + post.setPetType(PetType.valueOf(request.getPetType())); + } else { + post.setPetType(null); + } + post.setRegion(request.getRegion()); + post.setIsResolved(request.getIsResolved()); + post.setLikeCount(0); + postRepository.save(post); + + postImageService.saveImages(post, request.getImages()); + + return post.getId(); + } + + @Override + @Transactional + public void update(Long id, PostRequest request, Users user) { + Post post = postRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다.")); + System.out.println("🛠 기존 Post: " + post); + if (!post.getUser().getId().equals(user.getId())) { + throw new IllegalArgumentException("수정 권한이 없습니다."); + } + + post.setTitle(request.getTitle()); + post.setContent(request.getContent()); + post.setPetName(request.getPetName()); + if (request.getPetType() != null && !request.getPetType().isBlank()) { + post.setPetType(PetType.valueOf(request.getPetType())); + } else { + post.setPetType(null); // 또는 PetType.OTHER 로 기본값 지정도 가능 + } + post.setRegion(request.getRegion()); + post.setIsResolved(request.getIsResolved()); + + if (request.getImages() != null) { + postImageService.updateImagesFromRequest(post, request.getImages()); + } + System.out.println("🔧 수정 후 Post: " + post); + postRepository.save(post); + } + + @Override + @Transactional + public int toggleLike(Long postId, Users user) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다.")); + + Optional existing = postLikeRepository.findByPostAndUser(post, user); + if (existing.isPresent()) { + postLikeRepository.delete(existing.get()); + post.setLikeCount(post.getLikeCount() - 1); + } else { + PostLike like = new PostLike(post, user); + postLikeRepository.save(like); + post.setLikeCount(post.getLikeCount() + 1); + } + + return post.getLikeCount(); + } + + @Override + public void delete(Long id, Users user) { + Post post = postRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다.")); + + if (!post.getUser().getId().equals(user.getId())) { + throw new IllegalArgumentException("삭제 권한이 없습니다."); + } + postLikeRepository.deleteAllByPost(post); + postRepository.delete(post); + } + + @Override + public Page findAllByType(String type, Pageable pageable) { + Post.PostType postType = Post.PostType.valueOf(type.toUpperCase()); + Page posts = postRepository.findAllByPostTypeOrderByCreatedAtDesc(postType, pageable); + + return switch (postType) { + case REVIEW -> posts.map(post -> PostReviewListResponse.builder() + .id(post.getId()) + .title(post.getTitle()) + .petName(post.getPetName()) + .petType(post.getPetType() != null ? post.getPetType().getLabel() : null) + .region(post.getRegion()) + .writer(post.getUser().getDisplayName()) + .imageUrl(post.getImages().isEmpty() ? null : post.getImages().get(0).getImageUrl()) + .likeCount(post.getLikeCount()) + .commentCount(post.getCommentCount()) + .createdAt(post.getCreatedAt()) + .build()); + case QNA -> posts.map(post -> PostQnaListResponse.builder() + .id(post.getId()) + .title(post.getTitle()) + .petType(post.getPetType() != null ? post.getPetType().getLabel() : null) + .isResolved(Boolean.TRUE.equals(post.getIsResolved())) + .writer(post.getUser().getDisplayName()) + .likeCount(post.getLikeCount()) + .commentCount(post.getCommentCount()) + .createdAt(post.getCreatedAt()) + .build()); + case SHOWOFF -> posts.map(post -> PostShowoffListResponse.builder() + .id(post.getId()) + .title(post.getTitle()) + .petType(post.getPetType() != null ? post.getPetType().getLabel() : null) + .writer(post.getUser().getDisplayName()) + .imageUrl(post.getImages().isEmpty() ? null : post.getImages().get(0).getImageUrl()) + .likeCount(post.getLikeCount()) + .commentCount(post.getCommentCount()) + .createdAt(post.getCreatedAt()) + .build()); + }; + } + + @Override + public PostDetailResponse findById(Long id) { + Post post = postRepository.findWithUserAndImagesById(id) + .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다.")); + + List imageResponses = post.getImages().stream() + .map(img -> new PostImageResponse(img.getId(), img.getImageUrl(), img.getOrdering())) + .toList(); + + return PostDetailResponse.builder() + .id(post.getId()) + .title(post.getTitle()) + .content(post.getContent()) + .writer(post.getUser().getDisplayName()) + .petType(post.getPetType() != null ? post.getPetType().getLabel() : null) + .petName(post.getPetName()) + .region(post.getRegion()) + .isResolved(post.getIsResolved()) + .likeCount(post.getLikeCount()) + .postType(post.getPostType().name()) + .commentCount(post.getCommentCount()) + .images(imageResponses) + .createdAt(post.getCreatedAt()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/community/util/SupabaseUploader.java b/src/main/java/io/github/petty/community/util/SupabaseUploader.java new file mode 100644 index 0000000..d513b83 --- /dev/null +++ b/src/main/java/io/github/petty/community/util/SupabaseUploader.java @@ -0,0 +1,104 @@ +package io.github.petty.community.util; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +@Slf4j +public class SupabaseUploader { + + private final RestTemplate restTemplate = new RestTemplate(); + + @Value("${supabase.url}") + private String supabaseUrl; + + @Value("${supabase.key}") + private String supabaseKey; + + @Value("${supabase.bucket}") + private String bucketName; + + public String upload(MultipartFile file) throws IOException { + log.info("Uploading file: {}", file.getOriginalFilename()); + log.info("Supabase URL: {}", supabaseUrl); + log.info("Bucket name: {}", bucketName); + + // 1. 고유한 파일 이름 생성 + String rawFilename = UUID.randomUUID() + "." + getExtension(file.getOriginalFilename()); + String encodedFilename = URLEncoder.encode(rawFilename, StandardCharsets.UTF_8); + + // 실제 업로드 시엔 인코딩된 이름 사용 + String uploadUrl = supabaseUrl + "/storage/v1/object/" + bucketName + "/" + encodedFilename; + + // 업로드 성공 후 접근용 URL - 인코딩된 파일명 그대로 사용해야 함 + String publicUrl = supabaseUrl + "/storage/v1/object/public/" + bucketName + "/" + encodedFilename; + + // 2. 요청 헤더 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + headers.set("Authorization", "Bearer " + supabaseKey); + + // 3. 요청 본문 + HttpEntity requestEntity = new HttpEntity<>(file.getBytes(), headers); + + try { + // 4. 업로드 요청 + ResponseEntity response = restTemplate.exchange( + uploadUrl, + HttpMethod.PUT, + requestEntity, + String.class + ); + + if (response.getStatusCode() == HttpStatus.OK || response.getStatusCode() == HttpStatus.CREATED) { + log.info("Upload successful: {}", publicUrl); + return publicUrl; + } else { + log.error("Upload failed with status: {}, response: {}", response.getStatusCode(), response.getBody()); + throw new RuntimeException("이미지 업로드 실패: " + response.getStatusCode()); + } + } catch (Exception e) { + log.error("Error uploading to Supabase: ", e); + throw new IOException("Supabase 업로드 중 오류 발생: " + e.getMessage()); + } + } + + private String getExtension(String originalFilename) { + return originalFilename.substring(originalFilename.lastIndexOf('.') + 1); + } + + public void delete(String imageUrl) { + String filename = extractFilename(imageUrl); + String deleteUrl = supabaseUrl + "/storage/v1/object/" + bucketName + "/" + filename; + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + supabaseKey); + + HttpEntity request = new HttpEntity<>(headers); + try { + ResponseEntity response = restTemplate.exchange(deleteUrl, HttpMethod.DELETE, request, String.class); + + if (!response.getStatusCode().is2xxSuccessful()) { + log.error("Supabase 삭제 실패: {} - {}", response.getStatusCode(), response.getBody()); + } + } catch (Exception e) { + log.error("Error deleting from Supabase: ", e); + } + } + + private String extractFilename(String imageUrl) { + // https://teuocihdergygykvrqck.supabase.co/storage/v1/object/public/post-images/파일명.png + return imageUrl.substring(imageUrl.lastIndexOf('/') + 1); + } +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/config/JpaConfig.java b/src/main/java/io/github/petty/config/JpaConfig.java index 517d1d2..f6000ea 100644 --- a/src/main/java/io/github/petty/config/JpaConfig.java +++ b/src/main/java/io/github/petty/config/JpaConfig.java @@ -17,4 +17,4 @@ public EntityManagerFactoryBuilder entityManagerFactoryBuilder() { return new EntityManagerFactoryBuilder(vendorAdapter, new HashMap<>(), null); } -} \ No newline at end of file +} diff --git a/src/main/java/io/github/petty/config/SecurityConfig.java b/src/main/java/io/github/petty/config/SecurityConfig.java index cdef7eb..18dc2d7 100644 --- a/src/main/java/io/github/petty/config/SecurityConfig.java +++ b/src/main/java/io/github/petty/config/SecurityConfig.java @@ -3,6 +3,8 @@ import io.github.petty.users.jwt.JWTFilter; import io.github.petty.users.jwt.JWTUtil; import io.github.petty.users.jwt.LoginFilter; +import io.github.petty.users.oauth2.CustomOAuth2UserService; +import io.github.petty.users.oauth2.OAuth2SuccessHandler; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; @@ -18,16 +20,22 @@ @EnableWebSecurity public class SecurityConfig { - //AuthenticationManager가 인자로 받을 AuthenticationConfiguraion 객체 생성자 주입 private final AuthenticationConfiguration authenticationConfiguration; private final JWTUtil jwtUtil; + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2SuccessHandler oAuth2SuccessHandler; - public SecurityConfig(AuthenticationConfiguration authenticationConfiguration, JWTUtil jwtUtil) { + public SecurityConfig( + AuthenticationConfiguration authenticationConfiguration, + JWTUtil jwtUtil, + CustomOAuth2UserService customOAuth2UserService, + OAuth2SuccessHandler oAuth2SuccessHandler) { this.authenticationConfiguration = authenticationConfiguration; this.jwtUtil = jwtUtil; + this.customOAuth2UserService = customOAuth2UserService; + this.oAuth2SuccessHandler = oAuth2SuccessHandler; } - //AuthenticationManager Bean 등록 @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); @@ -41,13 +49,28 @@ public BCryptPasswordEncoder bCryptPasswordEncoder() { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http - .csrf(csrf -> csrf.disable()) // CSRF 비활성화 - .formLogin(form -> form.disable()) // 로그인 비활성화 - .httpBasic(basic -> basic.disable()) // HTTP Basic 인증 비활성화 + .csrf(csrf -> csrf.disable()) + .formLogin(form -> form.disable()) + .httpBasic(basic -> basic.disable()) .authorizeHttpRequests((auth) -> auth .requestMatchers("/admin").hasRole("ADMIN") - .requestMatchers("/user").authenticated() // 로그인이 필요한 페이지 + .requestMatchers("/user").authenticated() + .requestMatchers("/login/**", "/oauth2/**").permitAll() .anyRequest().permitAll()) + .oauth2Login(oauth2 -> oauth2 + .loginPage("/login") + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService) + ) + .successHandler(oAuth2SuccessHandler) + ) + .logout(logout -> logout + .logoutUrl("/logout") // 클라이언트 측 로그아웃 요청 URL과 일치 + .logoutSuccessUrl("/") // 로그아웃 성공 후 리다이렉트 URL + .deleteCookies("jwt") // 로그아웃 시 jwt 쿠키 삭제 + .invalidateHttpSession(true) + .clearAuthentication(true) + ) .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class) .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class) .sessionManagement((session) -> session @@ -55,4 +78,4 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http.build(); } -} +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/config/SupabaseDataSourceConfig.java b/src/main/java/io/github/petty/config/SupabaseDataSourceConfig.java index 290a46b..546369a 100644 --- a/src/main/java/io/github/petty/config/SupabaseDataSourceConfig.java +++ b/src/main/java/io/github/petty/config/SupabaseDataSourceConfig.java @@ -2,6 +2,7 @@ import jakarta.persistence.EntityManagerFactory; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.jdbc.DataSourceBuilder; import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder; @@ -37,7 +38,7 @@ @EnableJpaRepositories( basePackages = { "io.github.petty.users.repository", -// "io.github.petty.community.repository", + "io.github.petty.community.repository", }, entityManagerFactoryRef = "supabaseEntityManagerFactory", transactionManagerRef = "supabaseTransactionManager" @@ -62,18 +63,18 @@ public DataSource supabaseDataSource() { public LocalContainerEntityManagerFactoryBean supabaseEntityManagerFactory( // datasource 2개 이상일 경우 명시 @Qualifier("supabaseDataSource") DataSource dataSource, - EntityManagerFactoryBuilder builder - ) { + EntityManagerFactoryBuilder builder, + @Value("${spring.jpa.hibernate.ddl-auto}") String ddlAuto) { // ddl-auto 주입 Map jpaProperties = new HashMap<>(); jpaProperties.put("hibernate.physical_naming_strategy", "org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy"); // ddl.auto - jpaProperties.put("hibernate.hbm2ddl.auto", "update"); + jpaProperties.put("hibernate.hbm2ddl.auto", ddlAuto); return builder.dataSource(dataSource) .packages( - "io.github.petty.users.entity" -// "io.github.petty.community.entity" + "io.github.petty.users.entity", + "io.github.petty.community.entity" ).persistenceUnit("supabase") // 중복 X .properties(jpaProperties) // 대소문자 구분 X .build(); diff --git a/src/main/java/io/github/petty/llm/controller/RecommendController.java b/src/main/java/io/github/petty/llm/controller/RecommendController.java index fb4d717..1bd6644 100644 --- a/src/main/java/io/github/petty/llm/controller/RecommendController.java +++ b/src/main/java/io/github/petty/llm/controller/RecommendController.java @@ -28,4 +28,4 @@ public ResponseEntity recommend(@RequestBody Map promptM return ResponseEntity.ok(result); } -} \ No newline at end of file +} diff --git a/src/main/java/io/github/petty/llm/service/VectorStoreService.java b/src/main/java/io/github/petty/llm/service/VectorStoreService.java index 1abdf66..4f1ab63 100644 --- a/src/main/java/io/github/petty/llm/service/VectorStoreService.java +++ b/src/main/java/io/github/petty/llm/service/VectorStoreService.java @@ -1,6 +1,6 @@ package io.github.petty.llm.service; -import groovy.util.logging.Slf4j; +import lombok.extern.slf4j.Slf4j; import io.github.petty.tour.entity.Content; import lombok.RequiredArgsConstructor; import org.springframework.ai.document.Document; @@ -15,7 +15,6 @@ import java.util.Map; import java.util.stream.Collectors; -@lombok.extern.slf4j.Slf4j @Slf4j @Service @RequiredArgsConstructor @@ -71,4 +70,4 @@ public List findSimilarWithFilter(String query, int k, String filterEx public void deleteByIds(List ids) { vectorStore.delete(ids); } -} \ No newline at end of file +} diff --git a/src/main/java/io/github/petty/pipeline/controller/PipelineController.java b/src/main/java/io/github/petty/pipeline/controller/PipelineController.java index 9418b27..1a2f8a2 100644 --- a/src/main/java/io/github/petty/pipeline/controller/PipelineController.java +++ b/src/main/java/io/github/petty/pipeline/controller/PipelineController.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.github.petty.llm.service.RecommendService; import io.github.petty.pipeline.support.TogetherPromptBuilder; +import io.github.petty.vision.service.VisionServiceImpl; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Controller; @@ -11,6 +12,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; import java.util.Map; @@ -21,6 +23,7 @@ public class PipelineController { private final TogetherPromptBuilder togetherPromptBuilder; private final RecommendService recommendService; + private final VisionServiceImpl visionService; @GetMapping("/pipeline") public String showPipelineForm() { @@ -30,14 +33,21 @@ public String showPipelineForm() { @PostMapping("/pipeline") public String processPipeline( // form 으로 받은 임시 Vision 보고서 - @RequestParam("visionReport") String visionReport, +// @RequestParam("visionReport") String visionReport, // form 으로 받은 사용자 위치 @RequestParam("location") String location, + // 실제 VisionServiceImpl 에서 사용하는 요소 + @RequestParam("file") MultipartFile file, + @RequestParam("petName") String pet, Model model + ) { try { // String prompt = togetherPromptBuilder.buildPrompt(visionReport, location); + String visionReport = visionService.analyze(file, pet); + String jsonPrompt = togetherPromptBuilder.buildPrompt(visionReport, location); + log.info(jsonPrompt); ObjectMapper objectMapper = new ObjectMapper(); Map promptMapper = objectMapper.readValue(jsonPrompt, new TypeReference<>() {}); diff --git a/src/main/java/io/github/petty/pipeline/controller/UnifiedFlowController.java b/src/main/java/io/github/petty/pipeline/controller/UnifiedFlowController.java new file mode 100644 index 0000000..ed5c39d --- /dev/null +++ b/src/main/java/io/github/petty/pipeline/controller/UnifiedFlowController.java @@ -0,0 +1,68 @@ +package io.github.petty.pipeline.controller; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.petty.llm.service.RecommendService; +import io.github.petty.pipeline.support.TogetherPromptBuilder; +import io.github.petty.vision.port.in.VisionUseCase; +import io.github.petty.vision.service.VisionServiceImpl; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Map; + +@Slf4j +@Controller +@RequestMapping("/flow") // 기존 controller들과 충돌 방지 +@RequiredArgsConstructor +public class UnifiedFlowController { + + private final VisionUseCase visionUseCase; + private final VisionServiceImpl visionService; + private final TogetherPromptBuilder togetherPromptBuilder; + private final RecommendService recommendService; + + @GetMapping + public String page() { + return "unifiedFlow"; + } + + @PostMapping("/analyze") + public String analyze( + @RequestParam("file") MultipartFile file, + @RequestParam("petName") String petName, + @RequestParam("location") String location, + Model model + ) { + try { + // 1. 중간 종 추론 결과 + String interim = visionUseCase.interim(file.getBytes(), petName); + + // 2. Vision 보고서 생성 + String visionReport = visionService.analyze(file, petName); + log.info("📄 Vision Report: {}", visionReport); + log.info("📌 location = {}", location); + + // 3. 프롬프트 생성 및 추천 요청 + String jsonPrompt = togetherPromptBuilder.buildPrompt(visionReport, location); + log.info("📌 location = {}", location); + Map promptMapper = new ObjectMapper().readValue(jsonPrompt, new TypeReference<>() {}); + String recommendation = recommendService.recommend(promptMapper); + + // 4. 화면에 전달 + model.addAttribute("interim", interim); + model.addAttribute("visionReport", visionReport); + model.addAttribute("recommendation", recommendation); + + } catch (Exception e) { + log.error("❌ 분석 중 오류 발생", e); + model.addAttribute("error", "분석 및 추천 중 오류가 발생했습니다."); + } + + return "unifiedFlow"; + } +} diff --git a/src/main/java/io/github/petty/pipeline/service/PromptGeneratorService.java b/src/main/java/io/github/petty/pipeline/service/PromptGeneratorService.java index 2c6861c..65f2fae 100644 --- a/src/main/java/io/github/petty/pipeline/service/PromptGeneratorService.java +++ b/src/main/java/io/github/petty/pipeline/service/PromptGeneratorService.java @@ -1,5 +1,7 @@ package io.github.petty.pipeline.service; +import com.fasterxml.jackson.core.JsonProcessingException; + public interface PromptGeneratorService { - String generatePrompt(String extractedPetInfoJson, String location); + String generatePrompt(String extractedPetInfoJson, String location) throws JsonProcessingException; } diff --git a/src/main/java/io/github/petty/pipeline/service/PromptGeneratorServiceImpl.java b/src/main/java/io/github/petty/pipeline/service/PromptGeneratorServiceImpl.java index d93484e..59f0a6f 100644 --- a/src/main/java/io/github/petty/pipeline/service/PromptGeneratorServiceImpl.java +++ b/src/main/java/io/github/petty/pipeline/service/PromptGeneratorServiceImpl.java @@ -1,22 +1,48 @@ package io.github.petty.pipeline.service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.java.Log; import org.springframework.stereotype.Service; +import java.util.Map; + @Service +@Log public class PromptGeneratorServiceImpl implements PromptGeneratorService { + ObjectMapper mapper = new ObjectMapper(); + @Override - public String generatePrompt(String extractedPetInfoJson, String location) { + public String generatePrompt(String extractedPetInfoJson, String location) throws JsonProcessingException { if (extractedPetInfoJson.isEmpty()) { throw new IllegalArgumentException("에러! 필수 정보가 없습니다."); } else if (location.isEmpty()) { throw new IllegalArgumentException("에러! 사용자 위치 정보가 없습니다."); } - return String.format(""" + + // 기본 문자열 + String petInfoString = """ { - %s, - "location": "%s" + %s } - """, extractedPetInfoJson, location); + """.formatted(extractedPetInfoJson); + // JSON 으로 변환 + Map petInfoMap = mapper.readValue(petInfoString, new TypeReference<>() {}); + // location 항목 추가 + petInfoMap.put("location", "%s".formatted(location)); + // JSON 문자열로 변환 + String finalString = mapper.writeValueAsString(petInfoMap); + log.info(finalString); + + return finalString; + +// return String.format(""" +// { +// %s, +// "location": "%s" +// } +// """, extractedPetInfoJson, location); } } diff --git a/src/main/java/io/github/petty/pipeline/support/TogetherPromptBuilder.java b/src/main/java/io/github/petty/pipeline/support/TogetherPromptBuilder.java index 635c681..3aff791 100644 --- a/src/main/java/io/github/petty/pipeline/support/TogetherPromptBuilder.java +++ b/src/main/java/io/github/petty/pipeline/support/TogetherPromptBuilder.java @@ -2,10 +2,13 @@ import io.github.petty.pipeline.service.PromptGeneratorService; import io.github.petty.pipeline.service.TogetherService; +import io.grpc.lb.v1.LoadBalancerGrpc; import lombok.RequiredArgsConstructor; +import lombok.extern.java.Log; import org.springframework.stereotype.Component; @Component +@Log @RequiredArgsConstructor public class TogetherPromptBuilder { @@ -15,16 +18,23 @@ public class TogetherPromptBuilder { public String buildPrompt(String visionReport, String location) throws Exception { try { if (visionReport.isEmpty()) { + log.info("Vision API 에서 필수 정보를 받아오지 못했습니다."); throw new IllegalStateException("Vision API 에서 필수 정보를 받아오지 못했습니다."); } String extractedPetInfoJson = togetherService.answer( visionReport + " -> 이 문장에서 반려동물의 이름(name), 종(species), 무게(weight), 맹수 여부(is_danger(only true or false))를 JSON 형식으로 작성 + " + - "만약 반려동물의 종과 무게를 보았을 때, 입마개가 필요할 것 같다면 맹수 여부를 'true'로 작성 + " + - "무게는 kg 단위를 반드시 포함" + "no markdown, only JSON" + "최종 결과에 {}는 제거" + "만약 반려동물의 종과 무게를 보았을 때, 입마개가 필요할 것 같다면 맹수 여부를 'true'로 작성 + " + "고양이는 맹수 여부를 false로 작성 " + + "무게는 kg 단위를 반드시 포함 " + "no markdown " + "-> 양식에 맞춰서 작성 " + """ + "name": "???", + "species": "???", + "weight": "???", + "is_danger": "???" + """ + " -> 부가 설명이나 그 어떠한 텍스트 없이 양식의 빈 항목을 채워서 답변할 것." ); return promptGeneratorService.generatePrompt(extractedPetInfoJson, location); } catch (RuntimeException e) { + log.info("TogetherPromptBuilder 내부 오류."); throw new RuntimeException("프롬프트 빌드 중 예기치 못한 오류가 발생했습니다."); } } diff --git a/src/main/java/io/github/petty/static/css/styles.css b/src/main/java/io/github/petty/static/css/styles.css new file mode 100644 index 0000000..f2e9e29 --- /dev/null +++ b/src/main/java/io/github/petty/static/css/styles.css @@ -0,0 +1,366 @@ +/* Keep your existing base styles (body, container, h1) */ +body { + font-family: "Noto Sans KR", sans-serif; /* Keep font */ + max-width: 900px; /* Adjust max-width if needed */ + margin: 20px auto; + padding: 0 15px; /* Add horizontal padding */ + background-color: #fdf7e4; + color: #333; +} + +.container { + background-color: white; + padding: 25px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +h1 { + text-align: center; + margin-bottom: 30px; + color: #800000; /* Example color */ + font-weight: 600; +} + +h1 i { /* Style icon */ + margin-right: 10px; + color: #007bff; +} + + +/* Search Section & Tabs */ +.search-section { + margin-bottom: 30px; + padding: 20px; + background-color: #f8f9fa; + border-radius: 8px; + border: 1px solid #e9ecef; +} + +.tabs { + display: flex; + gap: 5px; /* Reduced gap */ + margin-bottom: 20px; + border-bottom: 2px solid #dee2e6; +} + +.tab { + padding: 10px 18px; + background-color: #e9ecef; + border: 1px solid transparent; /* Keep structure */ + border-bottom: none; /* Remove bottom border here */ + border-radius: 6px 6px 0 0; /* Round top corners */ + cursor: pointer; + color: #495057; + font-weight: 500; + transition: background-color 0.2s ease, color 0.2s ease; + display: flex; /* Align icon and text */ + align-items: center; + gap: 8px; /* Space between icon and text */ +} + +.tab:hover { + background-color: #d8dde2; +} + +.tab.active { + background-color: #ffffff; /* Make active tab background white */ + color: #007bff; /* Active tab text color */ + border-color: #dee2e6 #dee2e6 #ffffff; /* Connect border */ + border-bottom: 2px solid #ffffff; /* Cover the bottom border line */ + position: relative; /* Position relative for margin adjustment */ + bottom: -2px; /* Pull tab slightly down to overlap border */ + font-weight: 600; +} + +.tab i { /* Icon styling */ + font-size: 0.9em; +} + +/* Search Forms */ +.search-box { + display: none; /* Hidden by default */ + padding-top: 15px; +} + +.search-box.active { + display: block; /* Show active form */ +} + +.form-row { + display: flex; + flex-wrap: wrap; /* Allow wrapping on smaller screens */ + gap: 15px; /* Space between columns */ + margin-bottom: 15px; +} + +.form-group { + flex: 1; /* Each group tries to take equal space */ + min-width: 180px; /* Minimum width before wrapping */ +} + +.form-group.button-group { + display: flex; + align-items: flex-end; /* Align button to bottom */ +} + +label { + display: block; + margin-bottom: 5px; + font-weight: 500; + font-size: 0.9em; + color: #555; +} + +input[type="text"], +input[type="number"], +select { + width: 100%; + padding: 10px 12px; /* Slightly larger padding */ + border: 1px solid #ced4da; + border-radius: 4px; + box-sizing: border-box; + font-size: 1em; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +input:focus, +select:focus { + border-color: #80bdff; + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +select[disabled] { + background-color: #e9ecef; + cursor: not-allowed; +} + + +/* Buttons */ +button { + padding: 10px 15px; + font-size: 1em; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s ease, transform 0.1s ease; + display: inline-flex; /* Align icon and text */ + align-items: center; + gap: 8px; /* Space between icon and text */ + justify-content: center; /* Center content */ +} + +button:hover { + opacity: 0.9; +} +button:active { + transform: translateY(1px); /* Simple press effect */ +} + +button.search-button { /* Main search button */ + background-color: #007bff; + color: white; + width: 100%; /* Make main search button full width */ + font-weight: 500; +} +button.search-button:hover { + background-color: #0056b3; +} + + +#getLocationBtn { + background-color: #6c757d; /* Secondary color */ + color: white; + width: 100%; /* Fill group width */ +} +#getLocationBtn:hover { + background-color: #5a6268; +} + +/* Loading Indicator */ +#loading { + display: none; /* Hidden initially */ + text-align: center; + padding: 30px 0; + color: #555; +} + +.spinner { /* Simple CSS spinner */ + border: 4px solid #f3f3f3; + border-top: 4px solid #007bff; + border-radius: 50%; + width: 30px; + height: 30px; + animation: spin 1s linear infinite; + display: inline-block; + margin-right: 10px; + vertical-align: middle; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + + +/* Results Area & Items */ +.results-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); /* Responsive grid */ + gap: 20px; /* Space between cards */ + margin-top: 20px; +} + +.result-item.card { /* Styling result items as cards */ + border: 1px solid #e1e4e8; + border-radius: 6px; + background-color: #fff; + overflow: hidden; /* Clip image corners */ + box-shadow: 0 1px 3px rgba(0,0,0,0.04); + transition: box-shadow 0.2s ease-in-out; +} +.result-item.card:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.08); +} + + +.card-img-top { + width: 100%; + height: 180px; /* Fixed height for images */ + object-fit: cover; /* Cover the area, cropping if needed */ + background-color: #eee; /* Placeholder color */ +} +.img-placeholder { + width: 100%; + height: 180px; + display: flex; + align-items: center; + justify-content: center; + background-color: #f8f9fa; + color: #adb5bd; + font-size: 0.9em; +} + +.card-body { + padding: 15px; +} + +.card-title { + font-size: 1.1em; + font-weight: 600; + margin-top: 0; + margin-bottom: 10px; + color: #333; +} + +.card-text { + font-size: 0.9em; + color: #555; + margin-bottom: 8px; + display: flex; /* Align icon and text */ + align-items: center; + gap: 6px; +} +.card-text i { /* Style icons in card text */ + color: #007bff; + width: 14px; /* Fixed width for alignment */ + text-align: center; +} + +.detail-button { /* Detail button within card */ + margin-top: 10px; + padding: 6px 12px; + font-size: 0.9em; + background-color: #17a2b8; /* Info color */ + color: white; +} +.detail-button:hover { + background-color: #138496; +} + + +.detail-section { + margin-top: 15px; + padding: 15px; + background-color: #f8f9fa; + border-radius: 4px; + border-top: 1px solid #e9ecef; + font-size: 0.85em; /* Smaller font for details */ +} +.detail-section h6 { + font-weight: 600; + margin-top: 10px; + margin-bottom: 8px; + border-bottom: 1px solid #ddd; + padding-bottom: 4px; + display: flex; + align-items: center; + gap: 6px; +} +.detail-section h6 i { + color: #007bff; +} +.detail-section p { + margin-bottom: 6px; + line-height: 1.5; +} +.detail-section strong { + color: #333; +} + + +/* Pagination */ +#pagination { + margin-top: 30px; /* More space before pagination */ + padding-bottom: 20px; +} + +#loadMoreBtn { + background-color: #28a745; /* Success color */ + color: white; + padding: 10px 25px; + font-weight: 500; +} +#loadMoreBtn:hover { + background-color: #218838; +} + +/* Error Messages */ +.error-message, .no-results { + text-align: center; + padding: 20px; + color: #721c24; /* Danger text color */ + background-color: #f8d7da; /* Danger background */ + border: 1px solid #f5c6cb; + border-radius: 4px; + margin: 20px 0; +} +.no-results { /* Different style for no results */ + color: #0c5460; + background-color: #d1ecf1; + border-color: #bee5eb; +} + + +/* Responsive Adjustments (Example) */ +@media (max-width: 768px) { + .results-grid { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + } + h1 { font-size: 1.8em;} +} + +@media (max-width: 576px) { + body { padding: 0 10px;} + .container { padding: 15px; } + h1 { font-size: 1.5em; margin-bottom: 20px;} + .tabs { flex-wrap: wrap; } /* Allow tabs to wrap */ + .tab { flex-grow: 1; text-align: center; } /* Make tabs fill width */ + .form-row { flex-direction: column; gap: 10px;} /* Stack form elements */ + .results-grid { + grid-template-columns: 1fr; /* Single column layout */ + gap: 15px; + } + .card-img-top, .img-placeholder { height: 160px; } +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/static/images/no-images.jpg b/src/main/java/io/github/petty/static/images/no-images.jpg new file mode 100644 index 0000000..d84a9b4 Binary files /dev/null and b/src/main/java/io/github/petty/static/images/no-images.jpg differ diff --git a/src/main/java/io/github/petty/static/js/common/edit-qna.js b/src/main/java/io/github/petty/static/js/common/edit-qna.js new file mode 100644 index 0000000..13140c5 --- /dev/null +++ b/src/main/java/io/github/petty/static/js/common/edit-qna.js @@ -0,0 +1,137 @@ +let originalImages = []; +let postType = "QNA"; + +const postId = new URLSearchParams(location.search).get("id"); + +document.addEventListener("DOMContentLoaded", () => { + fetchPostForEdit(); + + document.getElementById("imageFiles").addEventListener("change", handleImageUpload); + + document.getElementById("postForm").addEventListener("submit", async (e) => { + e.preventDefault(); + const token = localStorage.getItem("jwt"); + + const payload = { + title: document.getElementById("title").value, + content: document.getElementById("content").value, + petType: document.getElementById("petType").value || "OTHER", + postType: postType, + isResolved: getIsResolvedValue(), + images: originalImages + }; + + + + const res = await fetch(`/api/posts/${postId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + body: JSON.stringify(payload) + }); + + if (res.ok) { + alert("수정 완료!"); + location.href = `/posts/detail?id=${postId}`; + } else { + alert("수정 실패"); + } + }); +}); + +async function fetchPostForEdit() { + const res = await fetch(`/api/posts/${postId}`); + const post = await res.json(); + + document.getElementById("title").value = post.title; + document.getElementById("content").value = post.content; + + const petTypeInputs = document.querySelectorAll('input[name="petType"]'); + petTypeInputs.forEach(input => { + if (input.value === post.petType) input.checked = true; + }); + + const resolvedSelect = document.getElementById("isResolved"); + if (resolvedSelect) { + resolvedSelect.value = String(post.isResolved); + } + + const previewBox = document.getElementById("imagePreview"); + (post.images || []).forEach((img, index) => { + const imgWrapper = document.createElement("div"); + imgWrapper.innerHTML = ` + + + `; + previewBox.appendChild(imgWrapper); + + originalImages.push({ + id: img.id, + imageUrl: img.imageUrl, + ordering: img.ordering, + isDeleted: false + }); + }); +} + +function getIsResolvedValue() { + const select = document.getElementById("isResolved"); + return select ? select.value === "true" : false; +} + +async function handleImageUpload(e) { + const files = Array.from(e.target.files); + if (!files.length) return; + + const currentCount = originalImages.filter(img => !img.isDeleted).length; + const maxCount = 5; + if (currentCount >= maxCount) { + alert("최대 5개의 이미지를 업로드할 수 있습니다."); + return; + } + + const availableSlots = maxCount - currentCount; + const filesToUpload = files.slice(0, availableSlots); + + const formData = new FormData(); + for (const file of filesToUpload) { + formData.append("files", file); + } + + const token = localStorage.getItem("jwt"); + const res = await fetch('/api/images/upload/multi', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + + const json = await res.json(); + const previewBox = document.getElementById("imagePreview"); + + json.images.forEach((img) => { + if (originalImages.some(existing => existing.imageUrl === img.imageUrl)) return; + + originalImages.push(img); + + const imgWrapper = document.createElement("div"); + imgWrapper.innerHTML = ` + + + `; + previewBox.appendChild(imgWrapper); + }); +} + +function removeImage(url) { + const img = originalImages.find(img => img.imageUrl === url); + if (img) { + img.isDeleted = true; + } + + const wrapper = document.querySelector(`img[data-url='${url}']`)?.parentElement; + if (wrapper) wrapper.remove(); +} diff --git a/src/main/java/io/github/petty/static/js/common/edit-review.js b/src/main/java/io/github/petty/static/js/common/edit-review.js new file mode 100644 index 0000000..8cf00c3 --- /dev/null +++ b/src/main/java/io/github/petty/static/js/common/edit-review.js @@ -0,0 +1,139 @@ +let originalImages = []; +let postType = "REVIEW"; + +const postId = new URLSearchParams(location.search).get("id"); + +document.addEventListener("DOMContentLoaded", () => { + fetchPostForEdit(); + + document.getElementById("imageFiles").addEventListener("change", handleImageUpload); + + document.getElementById("postForm").addEventListener("submit", async (e) => { + e.preventDefault(); + + const token = localStorage.getItem("jwt"); + + const payload = { + title: document.getElementById("title").value, + content: document.getElementById("content").value, + petType: getRadioValue("petType") || "OTHER", + petName: document.getElementById("petName").value, + region: document.getElementById("region").value, + postType: postType, + images: originalImages + }; + + const res = await fetch(`/api/posts/${postId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + body: JSON.stringify(payload) + }); + + if (res.ok) { + alert("수정 완료!"); + location.href = `/posts/detail?id=${postId}`; + } else { + alert("수정 실패"); + } + }); +}); + +async function fetchPostForEdit() { + const res = await fetch(`/api/posts/${postId}`); + const post = await res.json(); + + document.getElementById("title").value = post.title; + document.getElementById("content").value = post.content; + document.getElementById("region").value = post.region || ""; + document.getElementById("petName").value = post.petName || ""; + + const petTypeInputs = document.querySelectorAll('input[name="petType"]'); + petTypeInputs.forEach(input => { + if (input.value === post.petType) { + input.checked = true; + } + }); + + const previewBox = document.getElementById("imagePreview"); + (post.images || []).forEach((img) => { + const imgWrapper = document.createElement("div"); + imgWrapper.innerHTML = ` + + + `; + previewBox.appendChild(imgWrapper); + + originalImages.push({ + id: img.id, + imageUrl: img.imageUrl, + ordering: img.ordering, + isDeleted: false + }); + }); +} + +function getRadioValue(name) { + const radios = document.querySelectorAll(`input[name="${name}"]`); + for (const radio of radios) { + if (radio.checked) return radio.value; + } + return null; +} + +async function handleImageUpload(e) { + const files = Array.from(e.target.files); + if (!files.length) return; + + const currentCount = originalImages.filter(img => !img.isDeleted).length; + const maxCount = 5; + if (currentCount >= maxCount) { + alert("최대 5개의 이미지를 업로드할 수 있습니다."); + return; + } + + const availableSlots = maxCount - currentCount; + const filesToUpload = files.slice(0, availableSlots); + + const formData = new FormData(); + for (const file of filesToUpload) { + formData.append("files", file); + } + + const token = localStorage.getItem("jwt"); + const res = await fetch('/api/images/upload/multi', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + + const json = await res.json(); + const previewBox = document.getElementById("imagePreview"); + + json.images.forEach((img) => { + if (originalImages.some(existing => existing.imageUrl === img.imageUrl)) return; + + originalImages.push(img); // ✅ Supabase에서 PostImageRequest 형태로 응답 + + const imgWrapper = document.createElement("div"); + imgWrapper.innerHTML = ` + + + `; + previewBox.appendChild(imgWrapper); + }); +} + +function removeImage(url) { + const img = originalImages.find(img => img.imageUrl === url); + if (img) { + img.isDeleted = true; + } + + const wrapper = document.querySelector(`img[data-url='${url}']`)?.parentElement; + if (wrapper) wrapper.remove(); +} diff --git a/src/main/java/io/github/petty/static/js/common/edit-showoff.js b/src/main/java/io/github/petty/static/js/common/edit-showoff.js new file mode 100644 index 0000000..33c6044 --- /dev/null +++ b/src/main/java/io/github/petty/static/js/common/edit-showoff.js @@ -0,0 +1,126 @@ +let originalImages = []; +let postType = "SHOWOFF"; + +const postId = new URLSearchParams(location.search).get("id"); + +document.addEventListener("DOMContentLoaded", () => { + fetchPostForEdit(); + + document.getElementById("imageFiles").addEventListener("change", handleImageUpload); + + document.getElementById("postForm").addEventListener("submit", async (e) => { + e.preventDefault(); + const token = localStorage.getItem("jwt"); + + const payload = { + title: document.getElementById("title").value, + content: document.getElementById("content").value, + petType: document.getElementById("petType").value || "OTHER", + postType: postType, + images: originalImages // ✅ 바뀐 구조 + }; + + const res = await fetch(`/api/posts/${postId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + body: JSON.stringify(payload) + }); + + if (res.ok) { + alert("수정 완료!"); + location.href = `/posts/detail?id=${postId}`; + } else { + alert("수정 실패"); + } + }); +}); + +async function fetchPostForEdit() { + const res = await fetch(`/api/posts/${postId}`); + const post = await res.json(); + + document.getElementById("title").value = post.title; + document.getElementById("content").value = post.content; + + const petTypeInputs = document.querySelectorAll('input[name="petType"]'); + petTypeInputs.forEach(input => { + if (input.value === post.petType) { + input.checked = true; + } + }); + + const previewBox = document.getElementById("imagePreview"); + (post.images || []).forEach((img, index) => { + const imgWrapper = document.createElement("div"); + imgWrapper.innerHTML = ` + + + `; + previewBox.appendChild(imgWrapper); + + originalImages.push({ + id: img.id, // 수정/삭제에 필요 + imageUrl: img.imageUrl, + ordering: img.ordering, + isDeleted: false + }); + }); +} + +async function handleImageUpload(e) { + const files = Array.from(e.target.files); + if (!files.length) return; + + const currentCount = originalImages.filter(img => !img.isDeleted).length; + const maxCount = 5; + if (currentCount >= maxCount) { + alert("최대 5개의 이미지를 업로드할 수 있습니다."); + return; + } + + const availableSlots = maxCount - currentCount; + const filesToUpload = files.slice(0, availableSlots); + + const formData = new FormData(); + for (const file of filesToUpload) { + formData.append("files", file); + } + + const token = localStorage.getItem("jwt"); + const res = await fetch('/api/images/upload/multi', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + + const json = await res.json(); + const previewBox = document.getElementById("imagePreview"); + + json.images.forEach((img) => { + if (originalImages.some(existing => existing.imageUrl === img.imageUrl)) return; + + originalImages.push(img); // ✅ PostImageRequest 형태로 바로 push + + const imgWrapper = document.createElement("div"); + imgWrapper.innerHTML = ` + + + `; + previewBox.appendChild(imgWrapper); + }); +} + +function removeImage(url) { + const img = originalImages.find(img => img.imageUrl === url); + if (img) { + img.isDeleted = true; // ✅ 실제 삭제 플래그만 처리 + } + + const wrapper = document.querySelector(`img[data-url='${url}']`)?.parentElement; + if (wrapper) wrapper.remove(); +} diff --git a/src/main/java/io/github/petty/static/js/common/form.js b/src/main/java/io/github/petty/static/js/common/form.js new file mode 100644 index 0000000..43c263d --- /dev/null +++ b/src/main/java/io/github/petty/static/js/common/form.js @@ -0,0 +1,133 @@ +let uploadedImages = []; + +document.getElementById('imageFiles').addEventListener('change', async (e) => { + const MAX_FILE_COUNT = 5; + const MAX_FILE_SIZE_MB = 5; + + const files = Array.from(e.target.files); + + if (files.length > MAX_FILE_COUNT) { + alert(`이미지는 최대 ${MAX_FILE_COUNT}장까지만 업로드할 수 있습니다.`); + e.target.value = ''; // 파일 선택 초기화 + return; + } + + for (const file of files) { + if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) { + alert(`파일 ${file.name}은(는) 5MB를 초과합니다.`); + e.target.value = ''; + return; + } + } + + const formData = new FormData(); + for (const file of files) { + formData.append('files', file); + } + + const token = localStorage.getItem('jwt'); + const res = await fetch('/api/images/upload/multi', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + + const json = await res.json(); + + if (!res.ok) { + alert("이미지 업로드 실패: " + json.message); + return; + } + + if (!json.images || !Array.isArray(json.images)) { + alert("이미지 응답이 잘못되었습니다"); + return; + } + + for (let img of json.images) { + console.log("업로드된 이미지:", img.imageUrl); + } + + uploadedImages.push(...json.images); // imageUrl, ordering, isDeleted 포함 + // 알림 제거함 + + const previewContainer = document.querySelector(".image-upload"); + + // 기존 이미지 미리보기 제거 + const oldPreview = document.getElementById("imagePreview"); + if (oldPreview) oldPreview.remove(); + + // 새로 미리보기 컨테이너 생성 + const previewDiv = document.createElement("div"); + previewDiv.id = "imagePreview"; + previewDiv.style.display = "flex"; + previewDiv.style.flexWrap = "wrap"; + previewDiv.style.gap = "10px"; + previewDiv.style.marginTop = "10px"; + + // 이미지들 추가 + for (const img of json.images) { + const imageEl = document.createElement("img"); + imageEl.src = img.imageUrl; + imageEl.alt = "미리보기 이미지"; + imageEl.style.width = "100px"; + imageEl.style.borderRadius = "6px"; + imageEl.style.objectFit = "cover"; + previewDiv.appendChild(imageEl); + } + + previewContainer.appendChild(previewDiv); +}); + +// 🔸 게시글 form 제출 +document.getElementById('postForm').addEventListener('submit', async (e) => { + e.preventDefault(); + + const postData = { + title: document.getElementById('title').value, + content: document.getElementById('content').value, + petType: document.getElementById('petType')?.value || getRadioValue('petType'), + petName: document.getElementById('petName')?.value || null, + region: document.getElementById('region')?.value || null, + postType: detectPostType(), + isResolved: false, + images: uploadedImages // ✅ 새 필드! + }; + + const token = localStorage.getItem('jwt'); + const res = await fetch('/api/posts', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(postData) + }); + + if (res.ok) { + const { id } = await res.json(); + alert('등록 완료!'); + location.href = `/posts/detail?id=${id}`; + } else { + alert('등록 실패 😢'); + } +}); + +// 🔸 라디오 버튼 값 추출 유틸 +function getRadioValue(name) { + const radios = document.querySelectorAll(`input[name="${name}"]`); + for (const radio of radios) { + if (radio.checked) return radio.value; + } + return null; +} + +// 🔸 postType 자동 감지 +function detectPostType() { + if (location.pathname.includes('review')) return 'REVIEW'; + if (location.pathname.includes('qna')) return 'QNA'; + if (location.pathname.includes('showoff')) return 'SHOWOFF'; + return 'REVIEW'; // 기본값 +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/static/js/search.js b/src/main/java/io/github/petty/static/js/search.js new file mode 100644 index 0000000..49381bc --- /dev/null +++ b/src/main/java/io/github/petty/static/js/search.js @@ -0,0 +1,337 @@ +const API_BASE_URL = '/api/v1/contents'; // Your backend API base path +const resultsDiv = document.getElementById('results'); +const loadingDiv = document.getElementById('loading'); +const loadMoreBtn = document.getElementById('loadMoreBtn'); +const areaSelect = document.getElementById('areaCode'); +const sigunguSelect = document.getElementById('sigunguCode'); + +let currentPage = 0; +let totalPages = 0; +let currentSearchType = 'area'; // 'area' or 'location' +let isLoading = false; +let currentSearchParameters = {}; // Store parameters for loadMore + +// --- Initialization --- +document.addEventListener('DOMContentLoaded', () => { + // Set initial active tab + switchSearchType('area'); + + // Add event listener for area code changes to potentially load sigungu codes + // (Requires a backend endpoint for sigungu codes) + areaSelect.addEventListener('change', (e) => { + const areaCode = e.target.value; + sigunguSelect.innerHTML = ''; // Clear previous options + sigunguSelect.disabled = true; + if (areaCode) { + loadSigunguCodes(areaCode); // Uncomment if you implement this + } + }); +}); + +// --- Tab Switching --- +function switchSearchType(type) { + currentSearchType = type; + document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active')); + document.querySelectorAll('.search-box').forEach(box => box.classList.remove('active')); + + document.getElementById(`tab-${type}`).classList.add('active'); + document.getElementById(`${type}Search`).classList.add('active'); + + // Reset results when switching tabs + resultsDiv.innerHTML = ''; + loadMoreBtn.style.display = 'none'; + currentPage = 0; + totalPages = 0; +} + +// --- Loading Indicator --- +function showLoading(show) { + isLoading = show; + loadingDiv.style.display = show ? 'block' : 'none'; +} + +// --- Get Current Location --- +function getCurrentLocation() { + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (position) => { + document.getElementById('mapY').value = position.coords.latitude.toFixed(6); + document.getElementById('mapX').value = position.coords.longitude.toFixed(6); + }, + (error) => { + console.error("Error getting current location:", error); + alert("현재 위치를 가져올 수 없습니다. 직접 입력하거나 권한을 확인해주세요."); + }, + { enableHighAccuracy: true } // Optional: Improve accuracy + ); + } else { + alert("이 브라우저에서는 위치 정보 기능을 지원하지 않습니다."); + } +} + +// --- Fetch Data (Generic Helper) --- +async function fetchData(url) { + showLoading(true); + try { + const response = await fetch(url); + if (!response.ok) { + // Try to get error message from backend response body + let errorMsg = `Error: ${response.status} ${response.statusText}`; + try { + const errorData = await response.json(); + errorMsg = errorData.message || errorMsg; // Use backend message if available + } catch(e) { /* Ignore if response is not JSON */ } + throw new Error(errorMsg); + } + return await response.json(); + } catch (error) { + console.error("API Call Failed:", error); + displayError(`데이터 로딩 실패: ${error.message}`); + return null; // Indicate failure + } finally { + showLoading(false); + } +} + +// --- Search Functions --- +async function searchByArea(page = 0) { + const areaCode = areaSelect.value; + const sigunguCode = sigunguSelect.value; + // const contentTypeId = document.getElementById('areaContentTypeId')?.value; // Optional + + if (!areaCode) { + alert("지역을 선택해주세요."); + return; + } + + // Store parameters for 'Load More' + currentSearchParameters = { areaCode, sigunguCode }; // Add contentTypeId if used + currentSearchType = 'area'; // Ensure type is set + + let url = `${API_BASE_URL}/search/area?areaCode=${areaCode}`; + if (sigunguCode) { + url += `&sigunguCode=${sigunguCode}`; + } + url += `&page=${page}&size=10`; + // if (contentTypeId) { + // url += `&contentTypeId=${contentTypeId}`; + // } + + const data = await fetchData(url); + if (data) { + displayResults(data, page > 0); + } else { + if (page === 0) resultsDiv.innerHTML = ''; // Clear if initial search failed + loadMoreBtn.style.display = 'none'; + } +} + +async function searchByLocation(page = 0) { + const mapY = document.getElementById('mapY').value; + const mapX = document.getElementById('mapX').value; + const radius = document.getElementById('radius').value; + // const contentTypeId = document.getElementById('locContentTypeId')?.value; // Optional + + if (!mapY || !mapX) { + alert("위도와 경도를 입력하거나 현재 위치를 사용해주세요."); + return; + } + if (!radius) { + alert("검색 반경을 선택해주세요."); + return; + } + + // Store parameters for 'Load More' + currentSearchParameters = { mapX, mapY, radius }; // Add contentTypeId if used + currentSearchType = 'location'; // Ensure type is set + + let url = `${API_BASE_URL}/search/location?mapY=${mapY}&mapX=${mapX}&radius=${radius}&page=${page}&size=10`; + // if (contentTypeId) { + // url += `&contentTypeId=${contentTypeId}`; + // } + + const data = await fetchData(url); + if (data) { + displayResults(data, page > 0); + } else { + if (page === 0) resultsDiv.innerHTML = ''; // Clear if initial search failed + loadMoreBtn.style.display = 'none'; + } +} + +// --- Display Results --- +function displayResults(data, append = false) { + if (!append) { + resultsDiv.innerHTML = ''; // Clear previous results for a new search + } + + if (!data || !data.content || data.content.length === 0) { + if (!append) { // Only show 'no results' on the first page + resultsDiv.innerHTML = '

검색 결과가 없습니다.

'; + } + loadMoreBtn.style.display = 'none'; + return; + } + + // Update pagination info + currentPage = data.page.number; // Spring Pageable is 0-indexed + totalPages = data.page.totalPages; + + // Append new items + data.content.forEach(item => { + const itemDiv = document.createElement('div'); + itemDiv.className = 'result-item card'; // Use card class for better styling + itemDiv.innerHTML = ` + ${item.firstImage ? `${item.title || '이미지'}` : '
이미지 없음
'} +
+
${item.title || '이름 없음'}
+

${item.addr1 || '주소 정보 없음'}

+ ${item.distanceMeters ? `

약 ${Math.round(item.distanceMeters / 100) / 10}km

` : ''} + + +
+ `; + resultsDiv.appendChild(itemDiv); + + // Add event listener for the detail button + const detailButton = itemDiv.querySelector('.detail-button'); + detailButton.addEventListener('click', () => { + showDetail(item.contentId); + }); + }); + + // Show or hide 'Load More' button + if (currentPage < totalPages - 1) { + loadMoreBtn.style.display = 'block'; + } else { + loadMoreBtn.style.display = 'none'; + } +} + +// --- Load More Results --- +function loadMore() { + if (isLoading || currentPage >= totalPages - 1) { + return; // Prevent multiple clicks or loading beyond the last page + } + const nextPage = currentPage + 1; + if (currentSearchType === 'area') { + searchByArea(nextPage); + } else if (currentSearchType === 'location') { + searchByLocation(nextPage); + } +} + +// --- Show/Hide Detail --- +async function showDetail(contentId) { + const detailDiv = document.getElementById(`detail-${contentId}`); + const detailButton = detailDiv.previousElementSibling; // Get the button + + if (!detailDiv) return; + + // Toggle visibility + if (detailDiv.style.display === 'block') { + detailDiv.style.display = 'none'; + detailButton.textContent = '상세 정보'; // Reset button text + } else { + // Show loading in detail section + detailDiv.innerHTML = '

상세 정보 로딩 중...

'; + detailDiv.style.display = 'block'; + detailButton.textContent = '상세 정보 닫기'; // Change button text + + const url = `${API_BASE_URL}/${contentId}`; + const detailData = await fetchData(url); // Use fetchData for consistency + + if (detailData) { + renderDetail(detailDiv, detailData); + } else { + detailDiv.innerHTML = '

상세 정보를 불러오는데 실패했습니다.

'; + // Keep the div open to show the error + } + } +} + +// --- Render Detail Information --- +function renderDetail(detailDiv, detailInfo) { + if (!detailInfo) { + detailDiv.innerHTML = "

상세 정보가 없습니다.

"; + return; + } + + // Customize based on your DetailCommonDto structure + let detailHtml = `
상세 정보
`; + if (detailInfo.overview) + detailHtml += `

소개: ${detailInfo.overview}

`; // Assuming 'overview' field + + if (detailInfo.homepage) + detailHtml += `

홈페이지:

`; + + if (detailInfo.tel) + detailHtml += `

전화번호: ${detailInfo.tel}

`; // Assuming 'tel' field + if (detailInfo.telname) + detailHtml += `

담당자: ${detailInfo.telname}

`; // Assuming 'tel' field + + detailHtml += `
반려동물 정보
`; + // Add fields from your old UI.renderDetail, checking if they exist in detailInfo + const petFields = [ + { key: "acmpyPsblCpam", label: "동반 가능 동물" }, + { key: "acmpyTypeCd", label: "동반 유형" }, // Map code to text if needed + { key: "acmpyNeedMtr", label: "동반 필요 조건" }, + { key: "relaPosesFclty", label: "관련 편의 시설" }, + { key: "relaFrnshPrdlst", label: "관련 비품 목록" }, + { key: "relaRntlPrdlst", label: "관련 대여 상품" }, + { key: "relaPurcPrdlst", label: "관련 구매 상품" }, + { key: "relaAcdntRiskMtr", label: "사고 예방 사항" }, + { key: "etcAcmpyInfo", label: "기타 동반 정보" }, + ]; + + + petFields.forEach(field => { + const petValue = detailInfo.petTourInfo[field.key]; + if (petValue && petValue.trim() !== '') { + detailHtml += `

${field.label}: ${petValue}

`; + } + }); + + detailDiv.innerHTML = detailHtml; +} + +// --- Display Error --- +function displayError(message) { + // Display error in the results area or a dedicated error div + resultsDiv.innerHTML = `

${message}

`; + loadMoreBtn.style.display = 'none'; // Hide load more on error +} + + +// --- (Optional) Load Sigungu Codes --- +async function loadSigunguCodes(areaCode) { + // IMPORTANT: Requires a backend endpoint like /api/v1/contents/codes/sigungu?areaCode={areaCode} + const url = `${API_BASE_URL}/codes?areaCode=${areaCode}`; + showLoading(true); // Indicate loading sigungu + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to load sigungu codes: ${response.status}`); + } + const sigunguData = await response.json(); // Assuming backend returns List with 'code' and 'name' fields + + sigunguSelect.innerHTML = ''; // Clear previous + if (sigunguData && sigunguData.length > 0) { + sigunguData.forEach(item => { + const option = document.createElement('option'); + option.value = item.code; // Use the correct field name from your DTO + option.textContent = item.name; // Use the correct field name from your DTO + sigunguSelect.appendChild(option); + }); + sigunguSelect.disabled = false; + } else { + sigunguSelect.disabled = true; + } + } catch (error) { + console.error("Failed to load Sigungu codes:", error); + sigunguSelect.disabled = true; + // Optionally display an error message to the user + } finally { + showLoading(false); // Hide loading indicator used for sigungu + } +} diff --git a/src/main/java/io/github/petty/templates/edit-qna.html b/src/main/java/io/github/petty/templates/edit-qna.html new file mode 100644 index 0000000..a522a21 --- /dev/null +++ b/src/main/java/io/github/petty/templates/edit-qna.html @@ -0,0 +1,133 @@ + + + + + + + + + 질문글 수정 + + + +
+

질문글 수정하기

+
+ + + + + + + + + + + + + +
+

🖼 이미지를 여기에 끌어다 놓거나

+ + +
+
+ +
+ + +
+
+
+ + + \ No newline at end of file diff --git a/src/main/java/io/github/petty/templates/edit-review.html b/src/main/java/io/github/petty/templates/edit-review.html new file mode 100644 index 0000000..ccf9852 --- /dev/null +++ b/src/main/java/io/github/petty/templates/edit-review.html @@ -0,0 +1,126 @@ + + + + + + + + 여행 후기 수정 + + + +
+

여행 후기 수정하기

+
+ + + + + + + + + + +
+ + + + + + + +
+ + + + +
+

📸 이미지 추가

+ +
+

최대 5개의 이미지를 업로드할 수 있습니다.

+
+ +
+ + +
+
+
+ + + diff --git a/src/main/java/io/github/petty/templates/edit-showoff.html b/src/main/java/io/github/petty/templates/edit-showoff.html new file mode 100644 index 0000000..ddf0b01 --- /dev/null +++ b/src/main/java/io/github/petty/templates/edit-showoff.html @@ -0,0 +1,124 @@ + + + + + + 자랑글 수정 + + + +
+

자랑글 수정하기

+
+ + + + + + + + + + +
+

🖼 이미지를 여기에 드래그하거나

+ + +
+
+ +
+ + +
+
+
+ + + diff --git a/src/main/java/io/github/petty/templates/index.html b/src/main/java/io/github/petty/templates/index.html new file mode 100644 index 0000000..d168429 --- /dev/null +++ b/src/main/java/io/github/petty/templates/index.html @@ -0,0 +1,137 @@ + + + + + 🐶 PETTY + + + + +

PETTY에 오신 것을 환영합니다

+

회원가입 에러 확인용

+ +
+
+ +
+
+ +
+ +

index message

+ + + + + + + \ No newline at end of file diff --git a/src/main/java/io/github/petty/templates/join.html b/src/main/java/io/github/petty/templates/join.html new file mode 100644 index 0000000..2c843ce --- /dev/null +++ b/src/main/java/io/github/petty/templates/join.html @@ -0,0 +1,562 @@ + + + + + + PETTY - 회원가입 + + + +
+ + +
+

이용약관 동의

+
+

제1조 (목적)

+

이 약관은 "PETTY" (이하 "서비스")의 회원가입과 관련된 이용 조건 및 절차, 회원의 권리와 의무를 규정하는 것을 목적으로 합니다.

+ +

제2조 (정의)

+

1. "서비스"란 반려동물 동반 가능 숙소 및 관광지 정보를 제공하고, 위치 기반 추천 및 자연어 처리 기능을 통해 사용자가 반려동물과 함께 여행할 수 있는 장소를 추천하는 온라인 플랫폼을 의미합니다.

+

2. "회원"이란 본 약관에 동의하고, 서비스에 가입한 사용자를 의미합니다.

+

3. "아이디(ID)"란 회원이 서비스를 이용하기 위해 등록한 고유의 문자와 숫자 조합을 의미합니다.

+

4. "비밀번호"란 회원의 아이디와 일치하는 서비스 이용을 보호하기 위한 비밀 번호를 의미합니다.

+

5. "개인정보"란 서비스 제공을 위해 수집된 회원의 개인 정보로, 이름, 연락처, 이메일, 반려동물 정보 등을 포함합니다.

+ +

제3조 (약관의 동의 및 변경)

+

1. 회원은 서비스 이용을 위해 본 약관에 동의해야 합니다.

+

2. 본 약관은 서비스 화면에 게시함으로써 효력을 발생합니다.

+

3. 회사는 사전 공지 없이 약관을 변경할 수 있으며, 변경된 약관은 공지한 날로부터 7일 이후 효력이 발생합니다.

+ +

제4조 (회원 가입)

+

1. 회원 가입은 서비스 이용을 원하는 사람이 본 약관에 동의하고, 회원 가입 절차를 통해 가입 신청을 완료한 후 이루어집니다.

+

2. 가입 신청자는 반드시 본인의 정보(이름, 이메일, 반려동물 정보 등)를 정확하게 입력해야 하며, 잘못된 정보를 입력한 경우 서비스 이용에 제한을 받을 수 있습니다.

+

3. 서비스는 회원 가입 신청에 대해 승낙을 거부할 수 있으며, 이 경우 가입 신청자에게 그 사유를 알려야 합니다.

+ +

제5조 (회원 정보의 보호)

+

1. 서비스는 회원의 개인정보를 보호하기 위해 최선을 다하며, 개인정보는 관련 법령에 따라 처리됩니다.

+

2. 회원은 본인의 개인정보가 변경된 경우 이를 즉시 서비스에 통지해야 하며, 이를 통지하지 않아 발생하는 문제에 대해서는 회원이 책임을 집니다.

+ +

제6조 (서비스 이용)

+

1. 회원은 서비스 제공에 따라 다양한 기능을 이용할 수 있습니다.

+

2. 회원은 반려동물 정보(종, 크기, 맹견 여부 등)를 정확하게 입력해야 하며, 해당 정보는 추천 기능 및 장소 검색에 활용됩니다.

+

3. 서비스는 자연어 처리 및 위치 기반 추천 기능을 제공하며, 회원은 이를 통해 반려동물 동반 가능 장소를 추천받을 수 있습니다.

+

4. 서비스의 추천 기능은 Vision API, 공공데이터포털 API 등을 통해 제공되는 데이터를 기반으로 하며, 그 정확성은 보장되지 않습니다.

+ +

제7조 (회원의 의무)

+

1. 회원은 서비스를 이용할 때 법적 책임을 준수해야 하며, 타인의 권리를 침해하는 행동을 하지 않아야 합니다.

+

2. 회원은 서비스 이용 시 다음의 행위를 해서는 안 됩니다:

+
    +
  • 불법적인 목적을 위해 서비스를 이용하는 행위
  • +
  • 타인의 개인정보를 수집하거나 유출하는 행위
  • +
  • 서비스의 정상적인 운영을 방해하는 행위
  • +
+ +

제8조 (서비스의 제공과 변경)

+

1. 서비스는 제공하는 기능을 변경하거나 종료할 수 있습니다.

+

2. 서비스가 제공하는 기능 변경 시, 회원에게 사전 통지를 할 수 있으며, 회원은 통지 후 변경된 내용을 확인하고 이용할 수 있습니다.

+ +

제9조 (서비스의 이용 제한)

+

1. 서비스는 다음의 사유로 회원의 서비스 이용을 제한할 수 있습니다:

+
    +
  • 본 약관을 위반한 경우
  • +
  • 불법적인 행위가 의심되는 경우
  • +
  • 서비스 운영에 방해가 되는 행위를 한 경우
  • +
+

2. 회원은 서비스의 이용 제한에 대해 이의 제기할 수 없으며, 서비스는 이를 사전 통지 없이 진행할 수 있습니다.

+ +

제10조 (회원 탈퇴)

+

1. 회원은 언제든지 서비스에서 탈퇴할 수 있습니다. 탈퇴 시, 서비스에 등록된 회원의 모든 정보는 비활성화 또는 삭제 처리될 수 있습니다. 단, 법적인 의무나 서비스 운영상 필요에 따라 일부 정보는 일정 기간 동안 보유될 수 있습니다.

+

2. 회원 탈퇴는 서비스의 탈퇴 절차를 통해 이루어집니다. 탈퇴 처리된 회원은 서비스 이용이 제한되며, 비활성화된 정보는 복구가 불가능할 수 있습니다.

+ +

제11조 (책임의 제한)

+

1. 서비스는 회원에게 제공되는 콘텐츠 및 서비스에 대한 정확성, 신뢰성에 대해 책임을 지지 않습니다.

+

2. 서비스는 기술적 문제나 시스템 오류로 인한 피해에 대해서는 책임을 지지 않습니다.

+

3. 서비스는 공공데이터 및 외부 API를 기반으로 추천 기능을 제공하므로, 해당 데이터의 정확성 및 최신성에 대한 보장은 하지 않습니다.

+ +

제12조 (분쟁 해결)

+

1. 본 약관과 관련된 분쟁에 대해서는 서비스의 본사가 위치한 지역의 법원을 제1심 법원으로 합니다.

+

2. 서비스와 회원 간에 분쟁이 발생한 경우, 양측은 상호 협의하여 해결하려 노력해야 합니다.

+
+ +
+ + +
+ + +
+ + +
+ + + + \ No newline at end of file diff --git a/src/main/java/io/github/petty/templates/login.html b/src/main/java/io/github/petty/templates/login.html new file mode 100644 index 0000000..5ae3b8d --- /dev/null +++ b/src/main/java/io/github/petty/templates/login.html @@ -0,0 +1,145 @@ + + + + + 로그인 + + + +

로그인

+ + + + + \ No newline at end of file diff --git a/src/main/java/io/github/petty/templates/pipeline.html b/src/main/java/io/github/petty/templates/pipeline.html new file mode 100644 index 0000000..86c1df6 --- /dev/null +++ b/src/main/java/io/github/petty/templates/pipeline.html @@ -0,0 +1,211 @@ + + + + + Pipeline Recommendation + + + +

프롬프트 제작소

+
+ + + + + + + +
+ +
+

제작 결과

+

+
+ + + + diff --git a/src/main/java/io/github/petty/templates/post-detail.html b/src/main/java/io/github/petty/templates/post-detail.html new file mode 100644 index 0000000..a4632ab --- /dev/null +++ b/src/main/java/io/github/petty/templates/post-detail.html @@ -0,0 +1,305 @@ + + + + + PETTY 게시글 상세 + + + + +
+
로딩 중...
+
+ + + + + + diff --git a/src/main/java/io/github/petty/templates/post-qna-form.html b/src/main/java/io/github/petty/templates/post-qna-form.html new file mode 100644 index 0000000..50b4202 --- /dev/null +++ b/src/main/java/io/github/petty/templates/post-qna-form.html @@ -0,0 +1,123 @@ + + + + + 질문글 작성 + + + +
+

새 질문 작성하기

+
+ + + + + + + + + + +
+

🖼 이미지를 여기에 끌어다 놓거나

+ + +

PNG, JPG, GIF 최대 5MB (최대 5장)

+
+ +
+ + +
+
+
+ + + \ No newline at end of file diff --git a/src/main/java/io/github/petty/templates/post-qna-list.html b/src/main/java/io/github/petty/templates/post-qna-list.html new file mode 100644 index 0000000..b9ba675 --- /dev/null +++ b/src/main/java/io/github/petty/templates/post-qna-list.html @@ -0,0 +1,142 @@ + + + + + PETTY Q&A 게시판 + + + +

궁금해요! 질문답변

+ +
+ + + + + diff --git a/src/main/java/io/github/petty/templates/post-review-form.html b/src/main/java/io/github/petty/templates/post-review-form.html new file mode 100644 index 0000000..51e9faf --- /dev/null +++ b/src/main/java/io/github/petty/templates/post-review-form.html @@ -0,0 +1,122 @@ + + + + + 여행 후기 작성 + + + +
+

여행 후기 작성

+
+ + + + + + + + + + +
+ + + + + + + +
+ + + + +
+

📸 이미지 추가

+ +

최대 5개의 이미지를 업로드할 수 있습니다.

+
+ +
+ + +
+
+
+ + + \ No newline at end of file diff --git a/src/main/java/io/github/petty/templates/post-review-list.html b/src/main/java/io/github/petty/templates/post-review-list.html new file mode 100644 index 0000000..29e3f84 --- /dev/null +++ b/src/main/java/io/github/petty/templates/post-review-list.html @@ -0,0 +1,145 @@ + + + + + PETTY 후기 게시판 + + + +

꿈개가 생겼다! 후기를 보여주세요

+ +
+ + + + + diff --git a/src/main/java/io/github/petty/templates/post-showoff-form.html b/src/main/java/io/github/petty/templates/post-showoff-form.html new file mode 100644 index 0000000..8adf905 --- /dev/null +++ b/src/main/java/io/github/petty/templates/post-showoff-form.html @@ -0,0 +1,123 @@ + + + + + 자랑글 작성 + + + +
+

우리 아이 자랑글 작성하기

+
+ + + + + + + + + + +
+

🖼 이미지를 여기에 끌어다 놓거나

+ + +

PNG, JPG, GIF 최대 5MB (최대 5장)

+
+ +
+ + +
+
+
+ + + \ No newline at end of file diff --git a/src/main/java/io/github/petty/templates/post-showoff-list.html b/src/main/java/io/github/petty/templates/post-showoff-list.html new file mode 100644 index 0000000..2e167de --- /dev/null +++ b/src/main/java/io/github/petty/templates/post-showoff-list.html @@ -0,0 +1,142 @@ + + + + + 우리 아이 자랑해요 + + + +

우리 아이 자랑해요

+ +
+ + + + + diff --git a/src/main/java/io/github/petty/templates/profile_edit.html b/src/main/java/io/github/petty/templates/profile_edit.html new file mode 100644 index 0000000..eb10f10 --- /dev/null +++ b/src/main/java/io/github/petty/templates/profile_edit.html @@ -0,0 +1,107 @@ + + + + + 프로필 수정 + + + + +

프로필 수정

+
+
+
+ + +
+
+ + +
유효한 전화번호를 입력해주세요. (예: 01012345678)
+
+ +
+
+ + + + + diff --git a/src/main/java/io/github/petty/templates/recommend.html b/src/main/java/io/github/petty/templates/recommend.html new file mode 100644 index 0000000..d41aa07 --- /dev/null +++ b/src/main/java/io/github/petty/templates/recommend.html @@ -0,0 +1,157 @@ + + + + + + 🐶 PETTY + + + + +

여행지 추천

+ + + + + + +
+
+
+
+
+
+
+ +
+ +
+ + + + diff --git a/src/main/java/io/github/petty/templates/search.html b/src/main/java/io/github/petty/templates/search.html new file mode 100644 index 0000000..0b3e8a1 --- /dev/null +++ b/src/main/java/io/github/petty/templates/search.html @@ -0,0 +1,109 @@ + + + + + + 반려동물 동반여행 정보 검색 + + + +
+

반려동물 동반여행 정보

+
+ + +
+ + + + +
+ + + +
+
+ + + +
+ + + + + \ No newline at end of file diff --git a/src/main/java/io/github/petty/templates/syncTest.html b/src/main/java/io/github/petty/templates/syncTest.html new file mode 100644 index 0000000..27c7af2 --- /dev/null +++ b/src/main/java/io/github/petty/templates/syncTest.html @@ -0,0 +1,12 @@ + + + + + 🐶 PETTY + + +
+ +
+ + \ No newline at end of file diff --git a/src/main/java/io/github/petty/templates/unifiedFlow.html b/src/main/java/io/github/petty/templates/unifiedFlow.html new file mode 100644 index 0000000..85dadac --- /dev/null +++ b/src/main/java/io/github/petty/templates/unifiedFlow.html @@ -0,0 +1,162 @@ + + + + + 통합 여행 추천 파이프라인 + + + +

🐾 반려동물 여행지 추천

+ +
+ + + + + + + + + + +
+ +
+

중간 분석 결과

+

+
+ +
+

최종 Vision 분석 보고서

+

+
+ +
+

추천 여행지

+

+
+ +
+

오류 발생

+

+
+ + + + diff --git a/src/main/java/io/github/petty/templates/visionUpload.html b/src/main/java/io/github/petty/templates/visionUpload.html new file mode 100644 index 0000000..aebb55f --- /dev/null +++ b/src/main/java/io/github/petty/templates/visionUpload.html @@ -0,0 +1,111 @@ + + + + + + PETTY | 동물 이미지 분석 + + + + +
+
+
+ +
+
+
+
+
+
+

반려 동물 정보를 입력하세요

+
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/src/main/java/io/github/petty/users/controller/UsersController.java b/src/main/java/io/github/petty/users/controller/UsersController.java index 185e8e3..da1c881 100644 --- a/src/main/java/io/github/petty/users/controller/UsersController.java +++ b/src/main/java/io/github/petty/users/controller/UsersController.java @@ -1,23 +1,37 @@ package io.github.petty.users.controller; +import io.github.petty.users.dto.CustomUserDetails; import io.github.petty.users.dto.JoinDTO; +import io.github.petty.users.dto.UserProfileEditDTO; +import io.github.petty.users.entity.Users; import io.github.petty.users.service.JoinService; +import io.github.petty.users.service.UserService; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.servlet.mvc.support.RedirectAttributes; import org.springframework.security.core.Authentication; +import java.util.UUID; + @Controller public class UsersController { private final JoinService joinService; + private final UserService userService; + - public UsersController(JoinService joinService) { + public UsersController(JoinService joinService, UserService userService) { this.joinService = joinService; + this.userService = userService; } @GetMapping("/join") @@ -27,9 +41,9 @@ public String joinForm(Model model) { } @PostMapping("/join") - public String joinProcess(JoinDTO joinDTO, RedirectAttributes redirectAttributes) { + public String joinProcess(JoinDTO JoinDTO, RedirectAttributes redirectAttributes) { - Boolean joinResult = joinService.joinProcess(joinDTO); + Boolean joinResult = joinService.joinProcess(JoinDTO); if (!joinResult) { redirectAttributes.addFlashAttribute("error", "이미 존재하는 계정입니다!"); return "redirect:/"; @@ -47,6 +61,49 @@ public String loginForm() { } return "login"; } + + @GetMapping("/profile/edit") + public String editProfileForm(Model model) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated() + || authentication instanceof AnonymousAuthenticationToken) { + return "redirect:/login"; + } + + Object principal = authentication.getPrincipal(); + UUID currentUserId = userService.getCurrentUserId(principal); + UserProfileEditDTO userProfile = userService.getUserById(currentUserId); + + model.addAttribute("userProfile", userProfile); + + return "profile_edit"; + } + + @PostMapping("/profile/update") + public String updateProfile(@ModelAttribute UserProfileEditDTO userProfileEditDTO, + RedirectAttributes redirectAttributes) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated() + || authentication instanceof AnonymousAuthenticationToken) { + return "redirect:/login"; + } + + Object principal = authentication.getPrincipal(); + UUID currentUserId = userService.getCurrentUserId(principal); + + try { + // 사용자 정보 수정 + userService.updateUserProfile(currentUserId, userProfileEditDTO); + redirectAttributes.addFlashAttribute("successMessage", "프로필이 성공적으로 수정되었습니다."); + } catch (Exception e) { + redirectAttributes.addFlashAttribute("errorMessage", "프로필 수정 중 오류가 발생했습니다: " + e.getMessage()); + } + + // 수정 완료 후 메인 페이지로 + return "redirect:/"; + } } diff --git a/src/main/java/io/github/petty/users/dto/UserProfileEditDTO.java b/src/main/java/io/github/petty/users/dto/UserProfileEditDTO.java new file mode 100644 index 0000000..98056a1 --- /dev/null +++ b/src/main/java/io/github/petty/users/dto/UserProfileEditDTO.java @@ -0,0 +1,15 @@ +package io.github.petty.users.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class UserProfileEditDTO { + private String displayName; // 사용자 이름 + private String phone; // 전화번호 +} diff --git a/src/main/java/io/github/petty/users/entity/EmailVerification.java b/src/main/java/io/github/petty/users/entity/EmailVerification.java index 23a5479..5836aec 100644 --- a/src/main/java/io/github/petty/users/entity/EmailVerification.java +++ b/src/main/java/io/github/petty/users/entity/EmailVerification.java @@ -5,6 +5,7 @@ import lombok.Setter; import org.hibernate.annotations.CreationTimestamp; +import javax.validation.constraints.Size; import java.time.LocalDateTime; @Entity @@ -15,9 +16,11 @@ public class EmailVerification { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(nullable = false) + @Column(nullable = false, length = 255) private String email; + @Column(nullable = false) + @Size(min = 6, max = 6) private String code; @CreationTimestamp diff --git a/src/main/java/io/github/petty/users/entity/Users.java b/src/main/java/io/github/petty/users/entity/Users.java index 609d57d..d08081d 100644 --- a/src/main/java/io/github/petty/users/entity/Users.java +++ b/src/main/java/io/github/petty/users/entity/Users.java @@ -1,13 +1,8 @@ package io.github.petty.users.entity; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.RequiredArgsConstructor; -import lombok.Setter; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import lombok.*; import org.hibernate.annotations.CreationTimestamp; import java.time.LocalDateTime; @@ -20,12 +15,32 @@ public class Users { @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; + + @Column(nullable = false, unique = true, length = 255) private String username; // email + + @Column(nullable = false, length = 60) // BCrypt로 해시된 비밀번호는 보통 60자의 길이를 가짐 + @ToString.Exclude // toString()에서 비밀번호가 출력되지 않도록 제외 + @JsonIgnore // 응답에서 비밀번호가 직렬화되지 않도록 제외 private String password; + + @Column(nullable = false, length = 50) private String displayName; + + @Column(length = 20) private String phone; + + @Column(nullable = false, length = 20) private String role; + + // Oauth2 관련 + @Column(nullable = false, length = 20) + private String provider; // oauth2 provider + + @Column(length = 100) + private String providerId; // oauth2 provider id + @CreationTimestamp private LocalDateTime createdAt; } diff --git a/src/main/java/io/github/petty/users/jwt/JWTFilter.java b/src/main/java/io/github/petty/users/jwt/JWTFilter.java index b2a7b8a..ac4a2ae 100644 --- a/src/main/java/io/github/petty/users/jwt/JWTFilter.java +++ b/src/main/java/io/github/petty/users/jwt/JWTFilter.java @@ -4,8 +4,11 @@ import io.github.petty.users.entity.Users; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -16,6 +19,8 @@ public class JWTFilter extends OncePerRequestFilter { private final JWTUtil jwtUtil; + private static final Logger logger = LoggerFactory.getLogger(JWTFilter.class); + public JWTFilter(JWTUtil jwtUtil) { this.jwtUtil = jwtUtil; @@ -23,28 +28,39 @@ public JWTFilter(JWTUtil jwtUtil) { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String token = null; + + // 1. 쿠키에서 토큰 확인 + if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if ("jwt".equals(cookie.getName())) { + token = cookie.getValue(); + logger.debug("Token found in Cookie"); + break; + } + } + } - //request에서 Authorization 헤더를 찾음 - String authorization= request.getHeader("Authorization"); - - //Authorization 헤더 검증 - if (authorization == null || !authorization.startsWith("Bearer ")) { + // 2. 쿠키에 토큰이 없으면 Authorization 헤더에서 확인 + if (token == null) { + String authorization = request.getHeader("Authorization"); + if (authorization != null && authorization.startsWith("Bearer ")) { + token = authorization.split(" ")[1]; + logger.debug("Token found in Authorization header"); + } + } - System.out.println("token null"); + // 토큰이 없는 경우 필터 체인 계속 진행 + if (token == null) { + logger.debug("Token not found in either Cookie or Authorization header"); filterChain.doFilter(request, response); - - //조건이 해당되면 메소드 종료 (필수) return; } - System.out.println("authorization now"); - //Bearer 부분 제거 후 순수 토큰만 획득 - String token = authorization.split(" ")[1]; - - //토큰 소멸 시간 검증 + // 토큰 소멸 시간 검증 if (jwtUtil.isExpired(token)) { - - System.out.println("token expired"); + logger.debug("Token expired"); + SecurityContextHolder.clearContext(); // 인증정보 삭제 // 토큰이 만료되었을 때 401 Unauthorized 응답을 반환 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 상태 코드 response.getWriter().write("{\"error\": \"Token has expired\"}"); // 에러 메시지 diff --git a/src/main/java/io/github/petty/users/jwt/JWTUtil.java b/src/main/java/io/github/petty/users/jwt/JWTUtil.java index 7b84f50..4f9f75a 100644 --- a/src/main/java/io/github/petty/users/jwt/JWTUtil.java +++ b/src/main/java/io/github/petty/users/jwt/JWTUtil.java @@ -1,5 +1,6 @@ package io.github.petty.users.jwt; +import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; import org.springframework.stereotype.Component; @@ -29,7 +30,14 @@ public String getRole(String token) { } public Boolean isExpired(String token) { - return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date()); + try { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date()); + } catch (ExpiredJwtException e) { + return true; // 토큰 만료됨 + } catch (Exception e) { + // 다른 예외 처리 (토큰 형식 오류 등) + return false; + } } public String createJwt(String username, String role, Long expiredMs) { @@ -38,7 +46,7 @@ public String createJwt(String username, String role, Long expiredMs) { .claim("username", username) .claim("role", role) .issuedAt(new Date(System.currentTimeMillis())) - .expiration(new Date(System.currentTimeMillis() + expiredMs * 1000)) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) .signWith(secretKey) .compact(); } diff --git a/src/main/java/io/github/petty/users/jwt/LoginFilter.java b/src/main/java/io/github/petty/users/jwt/LoginFilter.java index 974f0c1..5cf0329 100644 --- a/src/main/java/io/github/petty/users/jwt/LoginFilter.java +++ b/src/main/java/io/github/petty/users/jwt/LoginFilter.java @@ -4,8 +4,10 @@ import io.github.petty.users.dto.CustomUserDetails; import io.github.petty.users.dto.LoginDTO; import jakarta.servlet.FilterChain; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -22,6 +24,10 @@ public class LoginFilter extends UsernamePasswordAuthenticationFilter { private final AuthenticationManager authenticationManager; private final JWTUtil jwtUtil; + // expirationTime 주입 + @Value("${jwt.expiration-time}") + private long expirationTime; + public LoginFilter(AuthenticationManager authenticationManager, JWTUtil jwtUtil) { this.authenticationManager = authenticationManager; @@ -63,9 +69,18 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR GrantedAuthority auth = iterator.next(); String role = auth.getAuthority(); - String token = jwtUtil.createJwt(username, role, 60*60L); - - response.addHeader("Authorization", "Bearer " + token); + String token = jwtUtil.createJwt(username, role, 3600000L); // expirationTime + + // JWT 토큰을 쿠키에 저장 + Cookie jwtCookie = new Cookie("jwt", token); + jwtCookie.setHttpOnly(true); // JavaScript 접근 방지 (XSS 방어) + jwtCookie.setPath("/"); // 쿠키의 유효 경로 + // jwtCookie.setSecure(true); // HTTPS 환경에서만 전송 (로컬호스트에서는 생략) + int maxAgeSeconds = (int) (3600000L / 1000); // 만료 시간을 초 단위로 변환 + jwtCookie.setMaxAge(maxAgeSeconds); // 쿠키의 만료 시간 설정 + response.addCookie(jwtCookie); + +// response.addHeader("Authorization", "Bearer " + token); } //로그인 실패시 diff --git a/src/main/java/io/github/petty/users/oauth2/CustomOAuth2User.java b/src/main/java/io/github/petty/users/oauth2/CustomOAuth2User.java new file mode 100644 index 0000000..2bc2fd6 --- /dev/null +++ b/src/main/java/io/github/petty/users/oauth2/CustomOAuth2User.java @@ -0,0 +1,28 @@ +package io.github.petty.users.oauth2; + +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; + +import java.util.Collection; +import java.util.Map; + +@Getter +public class CustomOAuth2User extends DefaultOAuth2User { + + private String email; + private String provider; + private String providerId; + + public CustomOAuth2User(Collection authorities, + Map attributes, + String nameAttributeKey, + String email, + String provider, + String providerId) { + super(authorities, attributes, nameAttributeKey); + this.email = email; + this.provider = provider; + this.providerId = providerId; + } +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/users/oauth2/CustomOAuth2UserService.java b/src/main/java/io/github/petty/users/oauth2/CustomOAuth2UserService.java new file mode 100644 index 0000000..479eac3 --- /dev/null +++ b/src/main/java/io/github/petty/users/oauth2/CustomOAuth2UserService.java @@ -0,0 +1,96 @@ +package io.github.petty.users.oauth2; + +import io.github.petty.users.Role; +import io.github.petty.users.entity.Users; +import io.github.petty.users.repository.UsersRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService implements OAuth2UserService { + + private final UsersRepository usersRepository; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2UserService delegate = new DefaultOAuth2UserService(); + OAuth2User oAuth2User = delegate.loadUser(userRequest); + + // OAuth2 서비스 ID (github, google, naver 등) + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + + // OAuth2 로그인 진행 시 키가 되는 필드 값(PK) + String userNameAttributeName = userRequest.getClientRegistration() + .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); + + // Github 유저 정보 추출 + Map attributes = oAuth2User.getAttributes(); + + // GitHub 이메일 및 ID 추출 + String email = extractEmail(registrationId, attributes); + String providerId = attributes.get(userNameAttributeName).toString(); + + // 기존 회원인지 확인하고 없으면 회원가입 진행 + Users user = saveOrUpdate(email, registrationId, providerId, attributes); + + return new CustomOAuth2User( + Collections.singleton(new SimpleGrantedAuthority(user.getRole())), + attributes, + userNameAttributeName, + user.getUsername(), + registrationId, + providerId + ); + } + + private String extractEmail(String registrationId, Map attributes) { + if ("github".equals(registrationId)) { + if (attributes.containsKey("email") && attributes.get("email") != null) { + return (String) attributes.get("email"); + } + // 이메일이 null인 경우 대체 로직 (GitHub ID + @github.com) + return attributes.get("login") + "@github.com"; + } + return null; // 다른 OAuth2 제공자 추가 시 확장 + } + + // 유저 정보 저장 또는 업데이트 + private Users saveOrUpdate(String email, String provider, String providerId, Map attributes) { + Users user = usersRepository.findByUsername(email); + + if (user == null) { + // 새 사용자 생성 + user = new Users(); + user.setUsername(email); + user.setProvider(provider); + user.setProviderId(providerId); + user.setPassword(UUID.randomUUID().toString()); // 임의의 패스워드 설정 + user.setRole(Role.ROLE_USER.name()); + + // GitHub 사용자명을 displayName으로 사용 + if (attributes.containsKey("name") && attributes.get("name") != null) { + user.setDisplayName((String) attributes.get("name")); + } else if (attributes.containsKey("login")) { + user.setDisplayName((String) attributes.get("login")); + } else { + user.setDisplayName(email.split("@")[0]); + } + + return usersRepository.save(user); + } + + // 기존 사용자 업데이트 로직 (필요한 경우) + return user; + } +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/users/oauth2/OAuth2SuccessHandler.java b/src/main/java/io/github/petty/users/oauth2/OAuth2SuccessHandler.java new file mode 100644 index 0000000..bfd48cf --- /dev/null +++ b/src/main/java/io/github/petty/users/oauth2/OAuth2SuccessHandler.java @@ -0,0 +1,43 @@ +package io.github.petty.users.oauth2; + +import io.github.petty.users.jwt.JWTUtil; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JWTUtil jwtUtil; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + + CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal(); + + // JWT 발급 + String token = jwtUtil.createJwt(oAuth2User.getEmail(), oAuth2User.getAuthorities().iterator().next().getAuthority(), 3600000L); + + // JWT 토큰을 쿠키에 저장 + Cookie jwtCookie = new Cookie("jwt", token); + jwtCookie.setHttpOnly(true); // JavaScript 접근 방지 (XSS 방어) + jwtCookie.setPath("/"); // 쿠키의 유효 경로 + // jwtCookie.setSecure(true); // HTTPS 환경에서만 전송 (로컬호스트에서는 생략) + int maxAgeSeconds = (int) (3600000L / 1000); // 만료 시간을 초 단위로 변환 + jwtCookie.setMaxAge(maxAgeSeconds); // 쿠키의 만료 시간 설정 + response.addCookie(jwtCookie); + + String targetUrl = "/"; + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/users/service/EmailService.java b/src/main/java/io/github/petty/users/service/EmailService.java index 435894d..f3a7a3a 100644 --- a/src/main/java/io/github/petty/users/service/EmailService.java +++ b/src/main/java/io/github/petty/users/service/EmailService.java @@ -43,7 +43,7 @@ public boolean sendVerificationCode(String email) { // 4자리 랜덤 코드 생성 private String generateCode() { - return String.format("%04d", random.nextInt(10000)); // 0000 ~ 9999 + return String.format("%06d", random.nextInt(1000000)); // 000000 ~ 999999 } private void sendEmail(String toEmail, String code) throws MessagingException { @@ -53,7 +53,29 @@ private void sendEmail(String toEmail, String code) throws MessagingException { helper.setFrom("krpetty54@gmail.com"); // 보내는 사람 이메일 (본인의 실제 이메일 주소로 변경) helper.setTo(toEmail); helper.setSubject("[Petty] 이메일 인증 코드입니다."); - helper.setText(String.format("안녕하세요.\n\n요청하신 이메일 인증 코드는 다음과 같습니다.\n\n%s\n\n감사합니다.", code)); + + // HTML 형식의 이메일 본문 + String htmlContent = "
" + + "" + + "

Petty

" + + "
" + + "
" + + "

인증 번호

" + + "
" + + "
" + + "

" + code + "

" + + "
" + + "

" + + "Petty의 더 많은 서비스를 이용하려면 이메일 인증이 필요해요.
" + + "인증번호를 입력하고 인증을 완료해 주세요!
" + + "(스팸함에 있을 수도 있으니 한 번 확인 부탁드려요!)" + + "

" + + "
" + + "이 메일은 5분간 유효합니다." + + "
" + + "
"; + + helper.setText(htmlContent, true); javaMailSender.send(mimeMessage); log.info("이메일 ({})로 인증 코드 ({})를 성공적으로 전송했습니다.", toEmail, code); diff --git a/src/main/java/io/github/petty/users/service/JoinService.java b/src/main/java/io/github/petty/users/service/JoinService.java index 0c70b07..95dde10 100644 --- a/src/main/java/io/github/petty/users/service/JoinService.java +++ b/src/main/java/io/github/petty/users/service/JoinService.java @@ -33,6 +33,7 @@ public boolean joinProcess(JoinDTO joinDTO) { users.setDisplayName(displayName); users.setPhone(phone); users.setRole(Role.ROLE_USER.name()); + users.setProvider("local"); userRepository.save(users); return true; diff --git a/src/main/java/io/github/petty/users/service/UserService.java b/src/main/java/io/github/petty/users/service/UserService.java new file mode 100644 index 0000000..96b39d9 --- /dev/null +++ b/src/main/java/io/github/petty/users/service/UserService.java @@ -0,0 +1,74 @@ +package io.github.petty.users.service; + +import io.github.petty.users.dto.CustomUserDetails; +import io.github.petty.users.dto.UserProfileEditDTO; +import io.github.petty.users.entity.Users; +import io.github.petty.users.oauth2.CustomOAuth2User; +import io.github.petty.users.repository.UsersRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UsersRepository usersRepository; + + public UUID getCurrentUserId(Object principal) { + if (principal == null) { + return null; + } + + // CustomUserDetails 처리 (일반 로그인) + if (principal instanceof CustomUserDetails) { + // CustomUserDetails에서 username 얻기 + String username = ((CustomUserDetails) principal).getUsername(); + // username으로 사용자 찾기 + Users user = usersRepository.findByUsername(username); + return user != null ? user.getId() : null; + } + + // CustomOAuth2User 처리 (소셜 로그인) + if (principal instanceof CustomOAuth2User) { + // OAuth2에서 username 얻기 + String username = ((CustomOAuth2User) principal).getName(); + // username으로 사용자 찾기 + Users user = usersRepository.findByUsername(username); + return user != null ? user.getId() : null; + } + + // Users 객체 직접 처리 + if (principal instanceof Users) { + return ((Users) principal).getId(); + } + + return null; + } + + + + + + + // 사용자 정보 가져오기 + public UserProfileEditDTO getUserById(UUID userId) { + Users user = usersRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다.")); + return new UserProfileEditDTO(user.getDisplayName(), user.getPhone()); + + } + + // 사용자 정보 수정 + public Users updateUserProfile(UUID userId, UserProfileEditDTO userProfileEditDTO) { + Users user = usersRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다.")); + + user.setDisplayName(userProfileEditDTO.getDisplayName()); + user.setPhone(userProfileEditDTO.getPhone()); + + return usersRepository.save(user); + } +} diff --git a/src/main/resources/static/images/no-images.jpg b/src/main/resources/static/images/no-images.jpg new file mode 100644 index 0000000..d84a9b4 Binary files /dev/null and b/src/main/resources/static/images/no-images.jpg differ diff --git a/src/main/resources/static/js/common/edit-qna.js b/src/main/resources/static/js/common/edit-qna.js new file mode 100644 index 0000000..13140c5 --- /dev/null +++ b/src/main/resources/static/js/common/edit-qna.js @@ -0,0 +1,137 @@ +let originalImages = []; +let postType = "QNA"; + +const postId = new URLSearchParams(location.search).get("id"); + +document.addEventListener("DOMContentLoaded", () => { + fetchPostForEdit(); + + document.getElementById("imageFiles").addEventListener("change", handleImageUpload); + + document.getElementById("postForm").addEventListener("submit", async (e) => { + e.preventDefault(); + const token = localStorage.getItem("jwt"); + + const payload = { + title: document.getElementById("title").value, + content: document.getElementById("content").value, + petType: document.getElementById("petType").value || "OTHER", + postType: postType, + isResolved: getIsResolvedValue(), + images: originalImages + }; + + + + const res = await fetch(`/api/posts/${postId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + body: JSON.stringify(payload) + }); + + if (res.ok) { + alert("수정 완료!"); + location.href = `/posts/detail?id=${postId}`; + } else { + alert("수정 실패"); + } + }); +}); + +async function fetchPostForEdit() { + const res = await fetch(`/api/posts/${postId}`); + const post = await res.json(); + + document.getElementById("title").value = post.title; + document.getElementById("content").value = post.content; + + const petTypeInputs = document.querySelectorAll('input[name="petType"]'); + petTypeInputs.forEach(input => { + if (input.value === post.petType) input.checked = true; + }); + + const resolvedSelect = document.getElementById("isResolved"); + if (resolvedSelect) { + resolvedSelect.value = String(post.isResolved); + } + + const previewBox = document.getElementById("imagePreview"); + (post.images || []).forEach((img, index) => { + const imgWrapper = document.createElement("div"); + imgWrapper.innerHTML = ` + + + `; + previewBox.appendChild(imgWrapper); + + originalImages.push({ + id: img.id, + imageUrl: img.imageUrl, + ordering: img.ordering, + isDeleted: false + }); + }); +} + +function getIsResolvedValue() { + const select = document.getElementById("isResolved"); + return select ? select.value === "true" : false; +} + +async function handleImageUpload(e) { + const files = Array.from(e.target.files); + if (!files.length) return; + + const currentCount = originalImages.filter(img => !img.isDeleted).length; + const maxCount = 5; + if (currentCount >= maxCount) { + alert("최대 5개의 이미지를 업로드할 수 있습니다."); + return; + } + + const availableSlots = maxCount - currentCount; + const filesToUpload = files.slice(0, availableSlots); + + const formData = new FormData(); + for (const file of filesToUpload) { + formData.append("files", file); + } + + const token = localStorage.getItem("jwt"); + const res = await fetch('/api/images/upload/multi', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + + const json = await res.json(); + const previewBox = document.getElementById("imagePreview"); + + json.images.forEach((img) => { + if (originalImages.some(existing => existing.imageUrl === img.imageUrl)) return; + + originalImages.push(img); + + const imgWrapper = document.createElement("div"); + imgWrapper.innerHTML = ` + + + `; + previewBox.appendChild(imgWrapper); + }); +} + +function removeImage(url) { + const img = originalImages.find(img => img.imageUrl === url); + if (img) { + img.isDeleted = true; + } + + const wrapper = document.querySelector(`img[data-url='${url}']`)?.parentElement; + if (wrapper) wrapper.remove(); +} diff --git a/src/main/resources/static/js/common/edit-review.js b/src/main/resources/static/js/common/edit-review.js new file mode 100644 index 0000000..8cf00c3 --- /dev/null +++ b/src/main/resources/static/js/common/edit-review.js @@ -0,0 +1,139 @@ +let originalImages = []; +let postType = "REVIEW"; + +const postId = new URLSearchParams(location.search).get("id"); + +document.addEventListener("DOMContentLoaded", () => { + fetchPostForEdit(); + + document.getElementById("imageFiles").addEventListener("change", handleImageUpload); + + document.getElementById("postForm").addEventListener("submit", async (e) => { + e.preventDefault(); + + const token = localStorage.getItem("jwt"); + + const payload = { + title: document.getElementById("title").value, + content: document.getElementById("content").value, + petType: getRadioValue("petType") || "OTHER", + petName: document.getElementById("petName").value, + region: document.getElementById("region").value, + postType: postType, + images: originalImages + }; + + const res = await fetch(`/api/posts/${postId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + body: JSON.stringify(payload) + }); + + if (res.ok) { + alert("수정 완료!"); + location.href = `/posts/detail?id=${postId}`; + } else { + alert("수정 실패"); + } + }); +}); + +async function fetchPostForEdit() { + const res = await fetch(`/api/posts/${postId}`); + const post = await res.json(); + + document.getElementById("title").value = post.title; + document.getElementById("content").value = post.content; + document.getElementById("region").value = post.region || ""; + document.getElementById("petName").value = post.petName || ""; + + const petTypeInputs = document.querySelectorAll('input[name="petType"]'); + petTypeInputs.forEach(input => { + if (input.value === post.petType) { + input.checked = true; + } + }); + + const previewBox = document.getElementById("imagePreview"); + (post.images || []).forEach((img) => { + const imgWrapper = document.createElement("div"); + imgWrapper.innerHTML = ` + + + `; + previewBox.appendChild(imgWrapper); + + originalImages.push({ + id: img.id, + imageUrl: img.imageUrl, + ordering: img.ordering, + isDeleted: false + }); + }); +} + +function getRadioValue(name) { + const radios = document.querySelectorAll(`input[name="${name}"]`); + for (const radio of radios) { + if (radio.checked) return radio.value; + } + return null; +} + +async function handleImageUpload(e) { + const files = Array.from(e.target.files); + if (!files.length) return; + + const currentCount = originalImages.filter(img => !img.isDeleted).length; + const maxCount = 5; + if (currentCount >= maxCount) { + alert("최대 5개의 이미지를 업로드할 수 있습니다."); + return; + } + + const availableSlots = maxCount - currentCount; + const filesToUpload = files.slice(0, availableSlots); + + const formData = new FormData(); + for (const file of filesToUpload) { + formData.append("files", file); + } + + const token = localStorage.getItem("jwt"); + const res = await fetch('/api/images/upload/multi', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + + const json = await res.json(); + const previewBox = document.getElementById("imagePreview"); + + json.images.forEach((img) => { + if (originalImages.some(existing => existing.imageUrl === img.imageUrl)) return; + + originalImages.push(img); // ✅ Supabase에서 PostImageRequest 형태로 응답 + + const imgWrapper = document.createElement("div"); + imgWrapper.innerHTML = ` + + + `; + previewBox.appendChild(imgWrapper); + }); +} + +function removeImage(url) { + const img = originalImages.find(img => img.imageUrl === url); + if (img) { + img.isDeleted = true; + } + + const wrapper = document.querySelector(`img[data-url='${url}']`)?.parentElement; + if (wrapper) wrapper.remove(); +} diff --git a/src/main/resources/static/js/common/edit-showoff.js b/src/main/resources/static/js/common/edit-showoff.js new file mode 100644 index 0000000..33c6044 --- /dev/null +++ b/src/main/resources/static/js/common/edit-showoff.js @@ -0,0 +1,126 @@ +let originalImages = []; +let postType = "SHOWOFF"; + +const postId = new URLSearchParams(location.search).get("id"); + +document.addEventListener("DOMContentLoaded", () => { + fetchPostForEdit(); + + document.getElementById("imageFiles").addEventListener("change", handleImageUpload); + + document.getElementById("postForm").addEventListener("submit", async (e) => { + e.preventDefault(); + const token = localStorage.getItem("jwt"); + + const payload = { + title: document.getElementById("title").value, + content: document.getElementById("content").value, + petType: document.getElementById("petType").value || "OTHER", + postType: postType, + images: originalImages // ✅ 바뀐 구조 + }; + + const res = await fetch(`/api/posts/${postId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + body: JSON.stringify(payload) + }); + + if (res.ok) { + alert("수정 완료!"); + location.href = `/posts/detail?id=${postId}`; + } else { + alert("수정 실패"); + } + }); +}); + +async function fetchPostForEdit() { + const res = await fetch(`/api/posts/${postId}`); + const post = await res.json(); + + document.getElementById("title").value = post.title; + document.getElementById("content").value = post.content; + + const petTypeInputs = document.querySelectorAll('input[name="petType"]'); + petTypeInputs.forEach(input => { + if (input.value === post.petType) { + input.checked = true; + } + }); + + const previewBox = document.getElementById("imagePreview"); + (post.images || []).forEach((img, index) => { + const imgWrapper = document.createElement("div"); + imgWrapper.innerHTML = ` + + + `; + previewBox.appendChild(imgWrapper); + + originalImages.push({ + id: img.id, // 수정/삭제에 필요 + imageUrl: img.imageUrl, + ordering: img.ordering, + isDeleted: false + }); + }); +} + +async function handleImageUpload(e) { + const files = Array.from(e.target.files); + if (!files.length) return; + + const currentCount = originalImages.filter(img => !img.isDeleted).length; + const maxCount = 5; + if (currentCount >= maxCount) { + alert("최대 5개의 이미지를 업로드할 수 있습니다."); + return; + } + + const availableSlots = maxCount - currentCount; + const filesToUpload = files.slice(0, availableSlots); + + const formData = new FormData(); + for (const file of filesToUpload) { + formData.append("files", file); + } + + const token = localStorage.getItem("jwt"); + const res = await fetch('/api/images/upload/multi', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + + const json = await res.json(); + const previewBox = document.getElementById("imagePreview"); + + json.images.forEach((img) => { + if (originalImages.some(existing => existing.imageUrl === img.imageUrl)) return; + + originalImages.push(img); // ✅ PostImageRequest 형태로 바로 push + + const imgWrapper = document.createElement("div"); + imgWrapper.innerHTML = ` + + + `; + previewBox.appendChild(imgWrapper); + }); +} + +function removeImage(url) { + const img = originalImages.find(img => img.imageUrl === url); + if (img) { + img.isDeleted = true; // ✅ 실제 삭제 플래그만 처리 + } + + const wrapper = document.querySelector(`img[data-url='${url}']`)?.parentElement; + if (wrapper) wrapper.remove(); +} diff --git a/src/main/resources/static/js/common/form.js b/src/main/resources/static/js/common/form.js new file mode 100644 index 0000000..43c263d --- /dev/null +++ b/src/main/resources/static/js/common/form.js @@ -0,0 +1,133 @@ +let uploadedImages = []; + +document.getElementById('imageFiles').addEventListener('change', async (e) => { + const MAX_FILE_COUNT = 5; + const MAX_FILE_SIZE_MB = 5; + + const files = Array.from(e.target.files); + + if (files.length > MAX_FILE_COUNT) { + alert(`이미지는 최대 ${MAX_FILE_COUNT}장까지만 업로드할 수 있습니다.`); + e.target.value = ''; // 파일 선택 초기화 + return; + } + + for (const file of files) { + if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) { + alert(`파일 ${file.name}은(는) 5MB를 초과합니다.`); + e.target.value = ''; + return; + } + } + + const formData = new FormData(); + for (const file of files) { + formData.append('files', file); + } + + const token = localStorage.getItem('jwt'); + const res = await fetch('/api/images/upload/multi', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + + const json = await res.json(); + + if (!res.ok) { + alert("이미지 업로드 실패: " + json.message); + return; + } + + if (!json.images || !Array.isArray(json.images)) { + alert("이미지 응답이 잘못되었습니다"); + return; + } + + for (let img of json.images) { + console.log("업로드된 이미지:", img.imageUrl); + } + + uploadedImages.push(...json.images); // imageUrl, ordering, isDeleted 포함 + // 알림 제거함 + + const previewContainer = document.querySelector(".image-upload"); + + // 기존 이미지 미리보기 제거 + const oldPreview = document.getElementById("imagePreview"); + if (oldPreview) oldPreview.remove(); + + // 새로 미리보기 컨테이너 생성 + const previewDiv = document.createElement("div"); + previewDiv.id = "imagePreview"; + previewDiv.style.display = "flex"; + previewDiv.style.flexWrap = "wrap"; + previewDiv.style.gap = "10px"; + previewDiv.style.marginTop = "10px"; + + // 이미지들 추가 + for (const img of json.images) { + const imageEl = document.createElement("img"); + imageEl.src = img.imageUrl; + imageEl.alt = "미리보기 이미지"; + imageEl.style.width = "100px"; + imageEl.style.borderRadius = "6px"; + imageEl.style.objectFit = "cover"; + previewDiv.appendChild(imageEl); + } + + previewContainer.appendChild(previewDiv); +}); + +// 🔸 게시글 form 제출 +document.getElementById('postForm').addEventListener('submit', async (e) => { + e.preventDefault(); + + const postData = { + title: document.getElementById('title').value, + content: document.getElementById('content').value, + petType: document.getElementById('petType')?.value || getRadioValue('petType'), + petName: document.getElementById('petName')?.value || null, + region: document.getElementById('region')?.value || null, + postType: detectPostType(), + isResolved: false, + images: uploadedImages // ✅ 새 필드! + }; + + const token = localStorage.getItem('jwt'); + const res = await fetch('/api/posts', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(postData) + }); + + if (res.ok) { + const { id } = await res.json(); + alert('등록 완료!'); + location.href = `/posts/detail?id=${id}`; + } else { + alert('등록 실패 😢'); + } +}); + +// 🔸 라디오 버튼 값 추출 유틸 +function getRadioValue(name) { + const radios = document.querySelectorAll(`input[name="${name}"]`); + for (const radio of radios) { + if (radio.checked) return radio.value; + } + return null; +} + +// 🔸 postType 자동 감지 +function detectPostType() { + if (location.pathname.includes('review')) return 'REVIEW'; + if (location.pathname.includes('qna')) return 'QNA'; + if (location.pathname.includes('showoff')) return 'SHOWOFF'; + return 'REVIEW'; // 기본값 +} \ No newline at end of file diff --git a/src/main/resources/templates/edit-qna.html b/src/main/resources/templates/edit-qna.html new file mode 100644 index 0000000..a522a21 --- /dev/null +++ b/src/main/resources/templates/edit-qna.html @@ -0,0 +1,133 @@ + + + + + + + + + 질문글 수정 + + + +
+

질문글 수정하기

+
+ + + + + + + + + + + + + +
+

🖼 이미지를 여기에 끌어다 놓거나

+ + +
+
+ +
+ + +
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/edit-review.html b/src/main/resources/templates/edit-review.html new file mode 100644 index 0000000..ccf9852 --- /dev/null +++ b/src/main/resources/templates/edit-review.html @@ -0,0 +1,126 @@ + + + + + + + + 여행 후기 수정 + + + +
+

여행 후기 수정하기

+
+ + + + + + + + + + +
+ + + + + + + +
+ + + + +
+

📸 이미지 추가

+ +
+

최대 5개의 이미지를 업로드할 수 있습니다.

+
+ +
+ + +
+
+
+ + + diff --git a/src/main/resources/templates/edit-showoff.html b/src/main/resources/templates/edit-showoff.html new file mode 100644 index 0000000..ddf0b01 --- /dev/null +++ b/src/main/resources/templates/edit-showoff.html @@ -0,0 +1,124 @@ + + + + + + 자랑글 수정 + + + +
+

자랑글 수정하기

+
+ + + + + + + + + + +
+

🖼 이미지를 여기에 드래그하거나

+ + +
+
+ +
+ + +
+
+
+ + + diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index 292e2cd..d168429 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -63,7 +63,8 @@

회원가입 에러 확인용

회원가입 @@ -76,21 +77,14 @@

사용자 정보

권한:

- - + \ No newline at end of file diff --git a/src/main/resources/templates/join.html b/src/main/resources/templates/join.html index 5e873bc..2c843ce 100644 --- a/src/main/resources/templates/join.html +++ b/src/main/resources/templates/join.html @@ -1,558 +1,562 @@ - - - - - - PETTY - 회원가입 - - - -
-