diff --git a/src/main/java/com/arom/with_travel/domain/community/Community.java b/src/main/java/com/arom/with_travel/domain/community/Community.java index d1343c0..31b0dd0 100644 --- a/src/main/java/com/arom/with_travel/domain/community/Community.java +++ b/src/main/java/com/arom/with_travel/domain/community/Community.java @@ -1,5 +1,6 @@ package com.arom.with_travel.domain.community; +import com.arom.with_travel.domain.community.enums.CommunityTag; import com.arom.with_travel.domain.community_reply.CommunityReply; import com.arom.with_travel.domain.image.Image; import com.arom.with_travel.domain.member.Member; @@ -18,12 +19,17 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @Builder @AllArgsConstructor -@SQLDelete(sql = "UPDATE community SET is_deleted = true, deleted_at = now() where id = ?") -@SQLRestriction("is_deleted is FALSE") +@SQLDelete(sql = "UPDATE community SET is_deleted = true, deleted_at = now() WHERE id = ?") +@SQLRestriction("is_deleted = FALSE") +@Table(indexes = { + @Index(name = "idx_community_tag", columnList = "tag"), + @Index(name = "idx_community_created_at", columnList = "created_at"), + @Index(name = "idx_community_like_count", columnList = "like_count"), + @Index(name = "idx_community_view_count", columnList = "view_count") +}) public class Community extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NotNull @Column(length = 120) @@ -32,6 +38,10 @@ public class Community extends BaseEntity { @NotNull @Lob private String content; + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false) + private CommunityTag tag; + @NotNull private String continent; @NotNull private String country; @NotNull private String city; @@ -43,18 +53,27 @@ public class Community extends BaseEntity { @OneToMany(mappedBy = "community", orphanRemoval = true) private List communityReplies = new ArrayList<>(); - @OneToMany(mappedBy = "community") + @OneToMany(mappedBy = "community", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default private List images = new ArrayList<>(); - @Column(nullable = false) + @Column(name = "view_count", nullable = false) private long viewCount = 0L; + @Column(name = "like_count", nullable = false) + private long likeCount = 0L; + + @Column(name = "reply_count", nullable = false) + private long replyCount = 0L; + public static Community create(Member writer, String title, String content, + CommunityTag tag, String continent, String country, String city) { Community c = Community.builder() .member(writer) .title(title) .content(content) + .tag(tag) .continent(continent) .country(country) .city(city) @@ -80,9 +99,16 @@ public void addReply(CommunityReply reply) { } } - public void update(String title, String content, String continent, String country, String city) { + public void addImage(Image image) { + images.add(image); + image.attachToCommunity(this); + } + + public void update(String title, String content, CommunityTag tag, + String continent, String country, String city) { this.title = title; this.content = content; + this.tag = tag; this.continent = continent; this.country = country; this.city = city; diff --git a/src/main/java/com/arom/with_travel/domain/community/CommunitySpecs.java b/src/main/java/com/arom/with_travel/domain/community/CommunitySpecs.java index cce5f61..e95f537 100644 --- a/src/main/java/com/arom/with_travel/domain/community/CommunitySpecs.java +++ b/src/main/java/com/arom/with_travel/domain/community/CommunitySpecs.java @@ -1,11 +1,16 @@ package com.arom.with_travel.domain.community; +import com.arom.with_travel.domain.community.enums.CommunityTag; import org.springframework.data.jpa.domain.Specification; import org.springframework.util.StringUtils; public class CommunitySpecs { private CommunitySpecs() {} + public static Specification tagEq(CommunityTag tag) { + return (root, q, cb) -> (tag != null) ? cb.equal(root.get("tag"), tag) : null; + } + public static Specification continentEq(String continent) { return (root, q, cb) -> StringUtils.hasText(continent) ? cb.equal(root.get("continent"), continent) : null; } diff --git a/src/main/java/com/arom/with_travel/domain/community/controller/CommunityController.java b/src/main/java/com/arom/with_travel/domain/community/controller/CommunityController.java index f49d2d1..b5dc171 100644 --- a/src/main/java/com/arom/with_travel/domain/community/controller/CommunityController.java +++ b/src/main/java/com/arom/with_travel/domain/community/controller/CommunityController.java @@ -4,18 +4,24 @@ import com.arom.with_travel.domain.community.dto.CommunityDetailResponse; import com.arom.with_travel.domain.community.dto.CommunityListItemResponse; import com.arom.with_travel.domain.community.dto.CommunityUpdateRequest; +import com.arom.with_travel.domain.community.enums.CommunityTag; import com.arom.with_travel.domain.community.service.CommunityService; import com.arom.with_travel.global.security.domain.PrincipalDetails; -import com.arom.with_travel.global.utils.SecurityUtils; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.util.List; +import java.util.Map; + +@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/communities") @@ -24,8 +30,10 @@ public class CommunityController { private final CommunityService communityService; @PostMapping - public Long create(@AuthenticationPrincipal PrincipalDetails principal, - @RequestBody @Valid CommunityCreateRequest req) { + public Long create( + @AuthenticationPrincipal PrincipalDetails principal, + @RequestBody @Valid CommunityCreateRequest req + ) { String me = principal.getAuthenticatedMember().getEmail(); return communityService.create(me, req); } @@ -41,22 +49,63 @@ public Page list( @RequestParam(required = false) String country, @RequestParam(required = false) String city, @RequestParam(required = false, name = "q") String keyword, + @RequestParam(required = false, name = "tag") CommunityTag tag, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); - return communityService.search(continent, country, city, keyword, pageable); + return communityService.search(continent, country, city, keyword, tag, pageable); + } + + @GetMapping("/top-liked") + public List topLiked() { + return communityService.topLiked(); + } + + @PostMapping("/{id}/like") + public Map toggleLike( + @PathVariable Long id, + @AuthenticationPrincipal PrincipalDetails principal + ) { + Long me = principal.getAuthenticatedMember().getMemberId(); + boolean liked = communityService.toggleLike(id, me); + return Map.of("liked", liked); } @PatchMapping("/{id}") - public void update(@PathVariable Long id, @RequestBody @Valid CommunityUpdateRequest req) { - Long me = SecurityUtils.currentMemberIdOrThrow(); - communityService.update(me, id, req); + public CommunityDetailResponse update( + @PathVariable Long id, + @RequestBody @Valid CommunityUpdateRequest req, + @AuthenticationPrincipal PrincipalDetails principal + ) { + Long me = principal.getAuthenticatedMember().getMemberId(); + return communityService.update(me, id, req); } @DeleteMapping("/{id}") - public void delete(@PathVariable Long id) { - Long me = SecurityUtils.currentMemberIdOrThrow(); + public ResponseEntity delete( + @PathVariable Long id, + @AuthenticationPrincipal PrincipalDetails principal + ) { + Long me = principal.getAuthenticatedMember().getMemberId(); communityService.delete(me, id); + return ResponseEntity.noContent().build(); } + + @GetMapping("/tags/{tag}") + public Page listByTag( + @PathVariable CommunityTag tag, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + int limitedSize = Math.min(Math.max(size, 1), 50); + + Pageable pageable = PageRequest.of( + page, + limitedSize, + Sort.by(Sort.Direction.DESC, "createdAt") + ); + return communityService.listByTag(tag, pageable); + } + } \ No newline at end of file diff --git a/src/main/java/com/arom/with_travel/domain/community/dto/CommunityCreateRequest.java b/src/main/java/com/arom/with_travel/domain/community/dto/CommunityCreateRequest.java index 49d5d84..7436d6c 100644 --- a/src/main/java/com/arom/with_travel/domain/community/dto/CommunityCreateRequest.java +++ b/src/main/java/com/arom/with_travel/domain/community/dto/CommunityCreateRequest.java @@ -1,5 +1,6 @@ package com.arom.with_travel.domain.community.dto; +import com.arom.with_travel.domain.community.enums.CommunityTag; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; @@ -17,6 +18,7 @@ public class CommunityCreateRequest { @NotEmpty String continent; @NotEmpty String country; @NotEmpty String city; + private CommunityTag tag; private List images; @Getter diff --git a/src/main/java/com/arom/with_travel/domain/community/dto/CommunityDetailResponse.java b/src/main/java/com/arom/with_travel/domain/community/dto/CommunityDetailResponse.java index 19a9bd1..bc33a7c 100644 --- a/src/main/java/com/arom/with_travel/domain/community/dto/CommunityDetailResponse.java +++ b/src/main/java/com/arom/with_travel/domain/community/dto/CommunityDetailResponse.java @@ -1,5 +1,6 @@ package com.arom.with_travel.domain.community.dto; +import com.arom.with_travel.domain.community.enums.CommunityTag; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -13,12 +14,15 @@ public class CommunityDetailResponse { Long id; String title; String content; + private CommunityTag tag; String continent; String country; String city; Long writerId; String writerNickname; long viewCount; + long likeCount; + long replyCount; List imageUrls; String createdAt; String updatedAt; diff --git a/src/main/java/com/arom/with_travel/domain/community/dto/CommunityDetailResponseMapper.java b/src/main/java/com/arom/with_travel/domain/community/dto/CommunityDetailResponseMapper.java new file mode 100644 index 0000000..7554c7a --- /dev/null +++ b/src/main/java/com/arom/with_travel/domain/community/dto/CommunityDetailResponseMapper.java @@ -0,0 +1,27 @@ +package com.arom.with_travel.domain.community.dto; + +import com.arom.with_travel.domain.community.Community; +import com.arom.with_travel.domain.image.Image; + +public class CommunityDetailResponseMapper { + + public static CommunityDetailResponse from(Community c) { + return new CommunityDetailResponse( + c.getId(), + c.getTitle(), + c.getContent(), + c.getTag(), + c.getContinent(), + c.getCountry(), + c.getCity(), + c.getMember().getId(), + c.getMember().getNickname(), + c.getViewCount(), + c.getLikeCount(), + c.getReplyCount(), + c.getImages().stream().map(Image::getImageUrl).toList(), + c.getCreatedAt().toString(), + c.getUpdatedAt().toString() + ); + } +} diff --git a/src/main/java/com/arom/with_travel/domain/community/dto/CommunityListItemResponse.java b/src/main/java/com/arom/with_travel/domain/community/dto/CommunityListItemResponse.java index 6958173..8f8fcfb 100644 --- a/src/main/java/com/arom/with_travel/domain/community/dto/CommunityListItemResponse.java +++ b/src/main/java/com/arom/with_travel/domain/community/dto/CommunityListItemResponse.java @@ -1,5 +1,6 @@ package com.arom.with_travel.domain.community.dto; +import com.arom.with_travel.domain.community.enums.CommunityTag; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -11,11 +12,14 @@ public class CommunityListItemResponse { Long id; String title; String snippet; + CommunityTag tag; String continent; String country; String city; Long writerId; String writerNickname; long viewCount; + long likeCount; + long replyCount; String createdAt; } diff --git a/src/main/java/com/arom/with_travel/domain/community/dto/CommunityUpdateRequest.java b/src/main/java/com/arom/with_travel/domain/community/dto/CommunityUpdateRequest.java index 2479f9f..0fe0d1f 100644 --- a/src/main/java/com/arom/with_travel/domain/community/dto/CommunityUpdateRequest.java +++ b/src/main/java/com/arom/with_travel/domain/community/dto/CommunityUpdateRequest.java @@ -1,5 +1,6 @@ package com.arom.with_travel.domain.community.dto; +import com.arom.with_travel.domain.community.enums.CommunityTag; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -15,6 +16,7 @@ public class CommunityUpdateRequest { private String continent; private String country; private String city; + private CommunityTag tag; private List images; @Getter diff --git a/src/main/java/com/arom/with_travel/domain/community/enums/CommunityTag.java b/src/main/java/com/arom/with_travel/domain/community/enums/CommunityTag.java new file mode 100644 index 0000000..292096c --- /dev/null +++ b/src/main/java/com/arom/with_travel/domain/community/enums/CommunityTag.java @@ -0,0 +1,40 @@ +package com.arom.with_travel.domain.community.enums; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.Arrays; + +public enum CommunityTag { + RESTAURANT("맛집추천"), + CAFE("카페탐방"), + INFO("정보공유"), + TIPS("꿀팁"); + + private final String labelKo; + + CommunityTag(String labelKo) { + this.labelKo = labelKo; + } + + public String getLabelKo() { + return labelKo; + } + + @JsonCreator + public static CommunityTag from(String v) { + if (v == null) return null; + String key = v.trim(); + String upper = key.toUpperCase(); + + return Arrays.stream(values()) + .filter(e -> e.name().equalsIgnoreCase(upper) || e.labelKo.equalsIgnoreCase(key)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown tag: " + v)); + } + + @JsonValue + public String toValue() { + return this.name(); + } +} \ No newline at end of file diff --git a/src/main/java/com/arom/with_travel/domain/community/repository/CommunityRepository.java b/src/main/java/com/arom/with_travel/domain/community/repository/CommunityRepository.java index d9a6c50..909feb2 100644 --- a/src/main/java/com/arom/with_travel/domain/community/repository/CommunityRepository.java +++ b/src/main/java/com/arom/with_travel/domain/community/repository/CommunityRepository.java @@ -1,11 +1,14 @@ package com.arom.with_travel.domain.community.repository; import com.arom.with_travel.domain.community.Community; +import com.arom.with_travel.domain.community.enums.CommunityTag; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.repository.*; +import java.util.List; + public interface CommunityRepository extends JpaRepository, JpaSpecificationExecutor { @@ -17,7 +20,15 @@ public interface CommunityRepository extends JpaRepository, @Query("update Community c set c.viewCount = c.viewCount + 1 where c.id = :id") int increaseViewCount(Long id); + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update Community c set c.likeCount = c.likeCount + :delta where c.id = :id") + int addLikeCount(Long id, long delta); + + Page findByTagOrderByCreatedAtDesc(CommunityTag tag, Pageable pageable); + + List findTop2ByOrderByLikeCountDescIdDesc(); + default Page search(Specification spec, Pageable pageable) { return this.findAll(spec, pageable); } -} +} \ No newline at end of file diff --git a/src/main/java/com/arom/with_travel/domain/community/service/CommunityService.java b/src/main/java/com/arom/with_travel/domain/community/service/CommunityService.java index 3bd4d76..f31cfe3 100644 --- a/src/main/java/com/arom/with_travel/domain/community/service/CommunityService.java +++ b/src/main/java/com/arom/with_travel/domain/community/service/CommunityService.java @@ -1,18 +1,20 @@ package com.arom.with_travel.domain.community.service; import com.arom.with_travel.domain.community.Community; -import com.arom.with_travel.domain.community.dto.CommunityCreateRequest; -import com.arom.with_travel.domain.community.dto.CommunityDetailResponse; -import com.arom.with_travel.domain.community.dto.CommunityListItemResponse; -import com.arom.with_travel.domain.community.dto.CommunityUpdateRequest; +import com.arom.with_travel.domain.community.CommunitySpecs; +import com.arom.with_travel.domain.community.dto.*; +import com.arom.with_travel.domain.community.enums.CommunityTag; import com.arom.with_travel.domain.community.repository.CommunityRepository; import com.arom.with_travel.domain.image.Image; import com.arom.with_travel.domain.image.repository.ImageRepository; +import com.arom.with_travel.domain.likes.Likes; +import com.arom.with_travel.domain.likes.repository.LikesRepository; import com.arom.with_travel.domain.member.Member; import com.arom.with_travel.domain.member.repository.MemberRepository; import com.arom.with_travel.global.exception.BaseException; import com.arom.with_travel.global.exception.error.ErrorCode; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; @@ -20,10 +22,10 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; -import java.util.stream.Collectors; import static com.arom.with_travel.domain.community.CommunitySpecs.*; +@Slf4j @Service @RequiredArgsConstructor public class CommunityService { @@ -31,17 +33,25 @@ public class CommunityService { private final CommunityRepository communityRepository; private final MemberRepository memberRepository; private final ImageRepository imageRepository; + private final LikesRepository likesRepository; @Transactional public Long create(String currentMemberEmail, CommunityCreateRequest req) { Member writer = memberRepository.findByEmail(currentMemberEmail) .orElseThrow(() -> BaseException.from(ErrorCode.MEMBER_NOT_FOUND)); - Community saved = communityRepository.save( - Community.create(writer, req.getTitle(), req.getContent(), - req.getContinent(), req.getCountry(), req.getCity()) + Community community = Community.create( + writer, + req.getTitle(), + req.getContent(), + req.getTag(), + req.getContinent(), + req.getCountry(), + req.getCity() ); + Community saved = communityRepository.save(community); + if (req.getImages() != null) { List newImages = req.getImages().stream() .map(img -> Image.fromCommunity( @@ -50,8 +60,15 @@ public Long create(String currentMemberEmail, CommunityCreateRequest req) { saved )) .toList(); - if (!newImages.isEmpty()) imageRepository.saveAll(newImages); + try { + if (!newImages.isEmpty()) { + imageRepository.saveAll(newImages); + } + } catch (Exception e) { + throw BaseException.from(ErrorCode.IMG_SAVE_FAIL); + } } + return saved.getId(); } @@ -74,43 +91,57 @@ public CommunityDetailResponse readAndIncreaseView(Long id) { throw BaseException.from(ErrorCode.COMMUNITY_NOT_FOUND); } - int updated = communityRepository.increaseViewCount(id); + communityRepository.increaseViewCount(id); Community c = communityRepository.findDetailById(id); - if (c == null) { throw BaseException.from(ErrorCode.COMMUNITY_NOT_FOUND);} + if (c == null) { + throw BaseException.from(ErrorCode.COMMUNITY_NOT_FOUND); + } List urls = c.getImages().stream().map(Image::getImageUrl).toList(); return new CommunityDetailResponse( - c.getId(), c.getTitle(), c.getContent(), + c.getId(), c.getTitle(), c.getContent(), c.getTag(), c.getContinent(), c.getCountry(), c.getCity(), c.getMember().getId(), c.getMember().getNickname(), - c.getViewCount(), urls, - c.getCreatedAt().toString(), c.getUpdatedAt().toString() + c.getViewCount(), c.getLikeCount(), c.getReplyCount(), + urls, c.getCreatedAt().toString(), c.getUpdatedAt().toString() ); } @Transactional - public void update(Long currentMemberId, Long communityId, CommunityUpdateRequest req) { + public CommunityDetailResponse update(Long currentMemberId, Long communityId, CommunityUpdateRequest req) { Community c = communityRepository.findById(communityId) .orElseThrow(() -> BaseException.from(ErrorCode.COMMUNITY_NOT_FOUND)); validateOwnership(currentMemberId, c.getMember().getId()); - c.update(req.getTitle(), req.getContent(), req.getContinent(), req.getCountry(), req.getCity()); + c.update( + req.getTitle(), + req.getContent(), + req.getTag(), + req.getContinent(), + req.getCountry(), + req.getCity() + ); if (req.getImages() != null) { - c.getImages().forEach(Image::detachFromCommunity); + c.getImages().clear(); List newImages = req.getImages().stream() - .map(img -> Image.fromCommunity( - defaultName(img.getImageName()), - requireUrl(img.getImageUrl()), + .map(imgReq -> Image.fromCommunity( + defaultName(imgReq.getImageName()), + requireUrl(imgReq.getImageUrl()), c )) .toList(); - if (!newImages.isEmpty()) imageRepository.saveAll(newImages); + if (!newImages.isEmpty()) { + imageRepository.saveAll(newImages); + } + c.getImages().addAll(newImages); } + + return CommunityDetailResponseMapper.from(c); } @Transactional @@ -121,11 +152,18 @@ public void delete(Long currentMemberId, Long communityId) { communityRepository.delete(c); } - @Transactional - public Page search(String continent, String country, String city, String q, - Pageable pageable) { + @Transactional(readOnly = true) + public Page search( + String continent, + String country, + String city, + String q, + CommunityTag tag, + Pageable pageable + ) { Specification spec = Specification - .where(continentEq(continent)) + .where(CommunitySpecs.tagEq(tag)) + .and(continentEq(continent)) .and(countryEq(country)) .and(cityEq(city)) .and(keywordLike(q)); @@ -135,17 +173,67 @@ public Page search(String continent, String country, c.getId(), c.getTitle(), c.getContent().length() > 30 ? c.getContent().substring(0, 30) + "..." : c.getContent(), + c.getTag(), c.getContinent(), c.getCountry(), c.getCity(), c.getMember().getId(), c.getMember().getNickname(), c.getViewCount(), + c.getLikeCount(), + c.getReplyCount(), + c.getCreatedAt().toString() + )); + } + + @Transactional(readOnly = true) + public Page listByTag(CommunityTag tag, Pageable pageable) { + return communityRepository.findByTagOrderByCreatedAtDesc(tag, pageable) + .map(c -> new CommunityListItemResponse( + c.getId(), c.getTitle(), + c.getContent().length() > 30 ? c.getContent().substring(0, 30) + "..." : c.getContent(), + c.getTag(), c.getContinent(), c.getCountry(), c.getCity(), + c.getMember().getId(), c.getMember().getNickname(), + c.getViewCount(), c.getLikeCount(), c.getReplyCount(), c.getCreatedAt().toString() )); } + @Transactional(readOnly = true) + public List topLiked() { + return communityRepository.findTop2ByOrderByLikeCountDescIdDesc().stream() + .map(c -> new CommunityListItemResponse( + c.getId(), c.getTitle(), + c.getContent().length() > 30 ? c.getContent().substring(0, 30) + "..." : c.getContent(), + c.getTag(), c.getContinent(), c.getCountry(), c.getCity(), + c.getMember().getId(), c.getMember().getNickname(), + c.getViewCount(), c.getLikeCount(), c.getReplyCount(), + c.getCreatedAt().toString() + )) + .toList(); + } + + @Transactional + public boolean toggleLike(Long communityId, Long currentMemberId) { + Community c = communityRepository.findById(communityId) + .orElseThrow(() -> BaseException.from(ErrorCode.COMMUNITY_NOT_FOUND)); + Member m = memberRepository.findById(currentMemberId) + .orElseThrow(() -> BaseException.from(ErrorCode.MEMBER_NOT_FOUND)); + + return likesRepository.findByCommunityIdAndMemberId(communityId, currentMemberId) + .map(existing -> { + likesRepository.delete(existing); + communityRepository.addLikeCount(communityId, -1); + return false; + }) + .orElseGet(() -> { + likesRepository.save(Likes.forCommunity(m, c)); + communityRepository.addLikeCount(communityId, +1); + return true; + }); + } + private void validateOwnership(Long currentMemberId, Long ownerId) { if (!ownerId.equals(currentMemberId)) { throw BaseException.from(ErrorCode.COMMUNITY_FORBIDDEN); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/arom/with_travel/domain/likes/Likes.java b/src/main/java/com/arom/with_travel/domain/likes/Likes.java index a21d719..9f7915f 100644 --- a/src/main/java/com/arom/with_travel/domain/likes/Likes.java +++ b/src/main/java/com/arom/with_travel/domain/likes/Likes.java @@ -1,6 +1,7 @@ package com.arom.with_travel.domain.likes; import com.arom.with_travel.domain.accompanies.model.Accompany; +import com.arom.with_travel.domain.community.Community; import com.arom.with_travel.domain.member.Member; import com.arom.with_travel.domain.shorts.Shorts; import com.arom.with_travel.global.entity.BaseEntity; @@ -35,6 +36,10 @@ public class Likes extends BaseEntity { @JoinColumn(name = "accompanies_id") private Accompany accompany; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "community_id") + private Community community; + @Builder public Likes(Member member, Accompany accompany) { this.member = member; @@ -51,4 +56,11 @@ public Likes(Member member, Shorts shorts) { public static Likes create(Member member, Accompany accompany){ return new Likes(member, accompany); } + + public static Likes forCommunity(Member member, Community community) { + Likes l = new Likes(); + l.member = member; + l.community = community; + return l; + } } diff --git a/src/main/java/com/arom/with_travel/domain/likes/repository/LikesRepository.java b/src/main/java/com/arom/with_travel/domain/likes/repository/LikesRepository.java index f3579b0..3c45446 100644 --- a/src/main/java/com/arom/with_travel/domain/likes/repository/LikesRepository.java +++ b/src/main/java/com/arom/with_travel/domain/likes/repository/LikesRepository.java @@ -16,4 +16,9 @@ public interface LikesRepository extends JpaRepository { @Query("SELECT COUNT(l) FROM Likes l WHERE l.accompany.id = :accompanyId") long countByAccompanyId(@Param("accompanyId") Long accompanyId); + + Optional findByCommunityIdAndMemberId(Long communityId, Long memberId); + boolean existsByCommunityIdAndMemberId(Long communityId, Long memberId); + @Query("SELECT COUNT(l) FROM Likes l WHERE l.community.id = :communityId") + long countByCommunityId(@Param("communityId") Long communityId); } diff --git a/src/main/java/com/arom/with_travel/domain/member/Member.java b/src/main/java/com/arom/with_travel/domain/member/Member.java index 854ece3..74ff0cf 100644 --- a/src/main/java/com/arom/with_travel/domain/member/Member.java +++ b/src/main/java/com/arom/with_travel/domain/member/Member.java @@ -38,7 +38,6 @@ public class Member extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(unique = true) private String oauthId; private String email; diff --git a/src/main/java/com/arom/with_travel/global/exception/error/ErrorCode.java b/src/main/java/com/arom/with_travel/global/exception/error/ErrorCode.java index bdec348..6f2f8a2 100644 --- a/src/main/java/com/arom/with_travel/global/exception/error/ErrorCode.java +++ b/src/main/java/com/arom/with_travel/global/exception/error/ErrorCode.java @@ -66,6 +66,7 @@ public enum ErrorCode { // image INVALID_IMG_TYPE("IMG-0000", "지원하지 않는 이미지 형식입니다.", ErrorDisplayType.POPUP), IMG_URL_MUST_FILLED("IMG-0001", "이미지 url이 존재해야합니다.", ErrorDisplayType.POPUP), + IMG_SAVE_FAIL("IMG-0002", "이미지 저장에 실패했습니다.", ErrorDisplayType.POPUP), // community CONTINENT_NOT_FOUND("COM-0000", "해당 대륙이 존재하지 않습니다.", ErrorDisplayType.POPUP), @@ -79,7 +80,10 @@ public enum ErrorCode { // login DUPLICATED_EMAIL("LOGIN-0001", "중복된 이메일이 존재합니다.", ErrorDisplayType.POPUP), INVALID_CREDENTIALS("LOGIN-0002", "비밀번호가 올바르지 않습니다.", ErrorDisplayType.POPUP), - LOGIN_FAIL("LOGIN-0003", "로그인 과정이 정상적으로 이루어지지 않았습니다.", ErrorDisplayType.POPUP) + LOGIN_FAIL("LOGIN-0003", "로그인 과정이 정상적으로 이루어지지 않았습니다.", ErrorDisplayType.POPUP), + + // Auth / Security + AUTH_UNSUPPORTED_PRINCIPAL("AUTH-0001", "지원하지 않는 인증 주체입니다.", ErrorDisplayType.POPUP) ; private final String code; diff --git a/src/main/java/com/arom/with_travel/global/security/domain/AuthenticatedMember.java b/src/main/java/com/arom/with_travel/global/security/domain/AuthenticatedMember.java index 73d2d05..e5585ac 100644 --- a/src/main/java/com/arom/with_travel/global/security/domain/AuthenticatedMember.java +++ b/src/main/java/com/arom/with_travel/global/security/domain/AuthenticatedMember.java @@ -9,11 +9,13 @@ public class AuthenticatedMember { private Long memberId; private String email; + private final String role; public static AuthenticatedMember from(Member member){ return new AuthenticatedMember( member.getId(), - member.getEmail() + member.getEmail(), + member.getRole().name() ); } } diff --git a/src/main/java/com/arom/with_travel/global/security/filter/JwtFilter.java b/src/main/java/com/arom/with_travel/global/security/filter/JwtFilter.java index bce15c2..e92b34c 100644 --- a/src/main/java/com/arom/with_travel/global/security/filter/JwtFilter.java +++ b/src/main/java/com/arom/with_travel/global/security/filter/JwtFilter.java @@ -40,23 +40,27 @@ protected void doFilterInternal(HttpServletRequest request, filterChain.doFilter(request, response); return; } + String token = resolveToken(request); - String aud = jwtProvider.parseAudience(token); - PrincipalDetails principalDetails = memberDetailsService.loadUserByUsername(aud); - Authentication authentication - = new UsernamePasswordAuthenticationToken( - principalDetails, + String email = jwtProvider.getEmailFromAccessToken(token); + PrincipalDetails pd = memberDetailsService.loadUserByUsername(email); + + Authentication authentication = new UsernamePasswordAuthenticationToken( + pd, null, - principalDetails.getAuthorities()); + pd.getAuthorities() + ); + SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(authentication); SecurityContextHolder.setContext(context); securityContextRepository.saveContext(context, request, response); + filterChain.doFilter(request, response); } - private String resolveToken(HttpServletRequest httpServletRequest) { - String authorization = httpServletRequest.getHeader(HEADER_AUTHORIZATION); + private String resolveToken(HttpServletRequest req) { + String authorization = req.getHeader(HEADER_AUTHORIZATION); if (authorization == null) { throw BaseException.from(EMPTY_TOKEN_PROVIDED); } diff --git a/src/main/java/com/arom/with_travel/global/security/token/provider/JwtProvider.java b/src/main/java/com/arom/with_travel/global/security/token/provider/JwtProvider.java index 0415005..cf5d71d 100644 --- a/src/main/java/com/arom/with_travel/global/security/token/provider/JwtProvider.java +++ b/src/main/java/com/arom/with_travel/global/security/token/provider/JwtProvider.java @@ -22,7 +22,7 @@ @Getter @Component @Slf4j -public class JwtProvider{ +public class JwtProvider { private final SecretKey SECRET_KEY; private final String ISS; @@ -38,59 +38,84 @@ public JwtProvider( } public String generateAccessToken(Member member) { - String token = Jwts.builder() + return Jwts.builder() .claim("type", "access") .issuedAt(new Date()) + .subject(member.getEmail()) .issuer(ISS) - .audience() - .add(member.getEmail()) - .add(String.valueOf(member.getId())) - .add(member.getRole().name()).and() - .expiration(new Date(new Date().getTime() + ACCESS_TOKEN_EXPIRE_TIME)) + .id(java.util.UUID.randomUUID().toString()) + .expiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRE_TIME)) .signWith(SECRET_KEY) .compact(); - log.info("[generateAccessToken] {}", token); - return token; } public String generateRefreshToken(Member member) { - String token = Jwts.builder() + return Jwts.builder() .claim("type", "refresh") .issuedAt(new Date()) + .subject(member.getEmail()) .issuer(ISS) .audience() .add(String.valueOf(member.getId())).and() - .expiration(new Date(new Date().getTime() + REFRESH_TOKEN_EXPIRE_TIME)) + .id(java.util.UUID.randomUUID().toString()) + .expiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRE_TIME)) .signWith(SECRET_KEY) .compact(); - log.info("[generateRefreshToken] {}", token); - return token; } - public String parseAudience(String token) { + public String getEmailFromAccessToken(String token) { + Jws claims = Jwts.parser().verifyWith(SECRET_KEY).build().parseSignedClaims(token); + Claims body = claims.getPayload(); + if (body.getExpiration().before(new Date())) { + throw BaseException.from(EXPIRED_ACCESS_TOKEN); + } + if (!"access".equals(body.get("type", String.class))) { + throw BaseException.from(INVALID_TOKEN); + } + return body.getSubject(); + } + + public void validateAudience(String token, String expectedAud) { try { - Jws claims = Jwts.parser() - .verifyWith(SECRET_KEY) - .build() - .parseSignedClaims(token); - if (claims.getPayload() - .getExpiration() - .before(new Date())) { + Jws claims = Jwts.parser().verifyWith(SECRET_KEY).build().parseSignedClaims(token); + Claims body = claims.getPayload(); + if (body.getExpiration().before(new Date())) { throw BaseException.from(EXPIRED_ACCESS_TOKEN); } - return claims.getPayload() - .getAudience() - .iterator() - .next(); - } catch (JwtException | IllegalArgumentException e) { - log.warn("[parseAudience] {} :{}", INVALID_TOKEN, token); - throw BaseException.from(INVALID_TOKEN); + var audiences = body.getAudience(); + if (audiences == null || !audiences.contains(expectedAud)) { + throw BaseException.from(INVALID_TOKEN); + } } catch (BaseException e) { - log.warn("[parseAudience] {} :{}", EXPIRED_ACCESS_TOKEN, token); + // 만료 등 우리 쪽 예외만 캐치해서 동일 코드로 재던짐(스크린샷 흐름 반영) throw BaseException.from(EXPIRED_ACCESS_TOKEN); } } +// public String parseAudience(String token) { +// try { +// Jws claims = Jwts.parser() +// .verifyWith(SECRET_KEY) +// .build() +// .parseSignedClaims(token); +// if (claims.getPayload() +// .getExpiration() +// .before(new Date())) { +// throw BaseException.from(EXPIRED_ACCESS_TOKEN); +// } +// return claims.getPayload() +// .getAudience() +// .iterator() +// .next(); +// } catch (JwtException | IllegalArgumentException e) { +// log.warn("[parseAudience] {} :{}", INVALID_TOKEN, token); +// throw BaseException.from(INVALID_TOKEN); +// } catch (BaseException e) { +// log.warn("[parseAudience] {} :{}", EXPIRED_ACCESS_TOKEN, token); +// throw BaseException.from(EXPIRED_ACCESS_TOKEN); +// } +// } + public boolean isRefreshTokenExpired(String token) { try { Jws claims = Jwts.parser() @@ -102,12 +127,12 @@ public boolean isRefreshTokenExpired(String token) { String type = body.get("type", String.class); if (!"refresh".equals(type)) { - throw BaseException.from(INVALID_TOKEN); // 타입이 refresh가 아닐 경우 + throw BaseException.from(INVALID_TOKEN); } return body.getExpiration().before(new Date()); } catch (JwtException e) { - throw BaseException.from(INVALID_TOKEN); // 구조가 잘못되었거나 서명 불일치 + throw BaseException.from(INVALID_TOKEN); } } } diff --git a/src/main/java/com/arom/with_travel/global/utils/SecurityUtils.java b/src/main/java/com/arom/with_travel/global/utils/SecurityUtils.java index 04a9035..27bd396 100644 --- a/src/main/java/com/arom/with_travel/global/utils/SecurityUtils.java +++ b/src/main/java/com/arom/with_travel/global/utils/SecurityUtils.java @@ -1,6 +1,9 @@ package com.arom.with_travel.global.utils; +import com.arom.with_travel.global.exception.BaseException; +import com.arom.with_travel.global.exception.error.ErrorCode; import com.arom.with_travel.global.security.domain.AuthenticatedMember; +import com.arom.with_travel.global.security.domain.PrincipalDetails; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -10,15 +13,19 @@ private SecurityUtils() {} public static Long currentMemberIdOrThrow() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { + if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getPrincipal())) { throw new IllegalStateException("Unauthenticated"); } + Object principal = auth.getPrincipal(); - if (principal instanceof AuthenticatedMember authenticatedMember) { - return authenticatedMember.getMemberId(); + if (principal instanceof PrincipalDetails pd) { + return pd.getAuthenticatedMember().getMemberId(); + } + if (principal instanceof AuthenticatedMember am) { + return am.getMemberId(); } - throw new IllegalStateException("Unsupported principal: " + principal); + throw BaseException.from(ErrorCode.AUTH_UNSUPPORTED_PRINCIPAL); } }