Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/main/java/com/opus/opus/docs/asciidoc/opus.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ endif::[]
link:./member.html[회원 API]

== 팀 관련 API

link:./team.html[팀 API]

link:./team-comment.html[팀 댓글 API]

link:./team-member.html[팀원 API]

link:./team-like.html[팀 좋아요 API]

== 공지 관련 API
link:./notice.html[공지 API]

Expand Down
159 changes: 159 additions & 0 deletions src/main/java/com/opus/opus/docs/asciidoc/team-like.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
ifndef::snippets[]
:snippets: ./build/generated-snippets
endif::[]

= TEAM LIKE API 문서
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 3
:sectnums:

== API 목록

link:./opus.html[API 목록으로 돌아가기]

== `PUT`: 팀 좋아요 토글

해당 팀에 대해 좋아요(찜) 상태를 토글합니다.

* `isLiked: true` → 좋아요 등록
* `isLiked: false` → 좋아요 취소


NOTE: 좋아요를 통해 팀을 찜할 수 있습니다.

NOTE: 투표 기간에는 투표만 가능하고, 투표 기간이 아닐 때만 좋아요가 가능합니다.

.Path Parameters
include::{snippets}/toggle-team-like/path-parameters.adoc[]

.HTTP Request Headers
include::{snippets}/toggle-team-like/request-headers.adoc[]

.Request Fields
include::{snippets}/toggle-team-like/request-fields.adoc[]

.Response Fields
include::{snippets}/toggle-team-like/response-fields.adoc[]

=== 시나리오 1: TeamLike 데이터가 없는 경우

특정 멤버가 특정 팀에 대해 좋아요 API를 처음 호출하면, TeamLike 테이블에 데이터가 새로 생성됩니다.

[cols="1,2,1"]
|===
|Request isLiked |응답 메시지 |HTTP 상태 코드

|true
|좋아요가 등록되었습니다.
|200 OK

|false
|아직 좋아요하지 않은 팀입니다.
|400 Bad Request
|===

isLiked: true → 좋아요 등록

include::{snippets}/toggle-team-like/http-request.adoc[]

include::{snippets}/toggle-team-like/http-response.adoc[]

=== 시나리오 2: TeamLike 데이터가 있는 경우

이미 해당 팀에 대한 좋아요 기록이 있는 경우, 상태에 따라 토글됩니다.

[cols="1,1,2,1"]
|===
|현재 isLiked |Request isLiked |응답 메시지 |HTTP 상태 코드

|true
|true
|이미 좋아요한 팀입니다.
|400 Bad Request
Comment on lines +72 to +75
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API의 이름이 좋아요 토글인데 이미 좋아요 한 팀에게 해당 API를 다시 요청할 경우 Bad Request가 뜨는 것이 조금 어색하게 느껴집니다.

만약 좋아요 한 팀에 재요청이 들어갔다면, 좋아요 취소 처리가 되는 것은 어떤가요??
같은 흐름으로 이미 좋아요를 취소한 팀경우도 같은 것 같습니다!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 이 부분을 생각해봤는데 현재 구현에서는isLiked=True 상태인 팀에 다시 isLiked=True를 하면 예외를 던지는게 맞는게 아닐까 생각합니다.

토글만 하려면 Body가 없는게 낫지 않을까요?


|true
|false
|좋아요가 취소되었습니다.
|200 OK

|false
|true
|좋아요가 등록되었습니다.
|200 OK

|false
|false
|이미 좋아요를 취소한 팀입니다.
|400 Bad Request
|===

좋아요 취소 (isLiked: true → false)

include::{snippets}/cancel-team-like/http-request.adoc[]

include::{snippets}/cancel-team-like/http-response.adoc[]

=== ⚠️ 실패 케이스

Comment on lines +98 to +100
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ApiDocsTest를 보면 pathParametersrequestFields 등 잘 적어주셨는데 adoc에도 해두면 좋을 것 같아요!

.❌ Case 1: 존재하지 않는 팀

[%collapsible]

====

include::{snippets}/toggle-team-like-fail-not-found/http-request.adoc[]

include::{snippets}/toggle-team-like-fail-not-found/http-response.adoc[]

====

.❌ Case 2: 이미 좋아요한 팀

[%collapsible]

====

include::{snippets}/toggle-team-like-fail-already-liked/http-request.adoc[]

include::{snippets}/toggle-team-like-fail-already-liked/http-response.adoc[]

====

.❌ Case 3: 이미 좋아요 취소한 팀

[%collapsible]

====

include::{snippets}/toggle-team-like-fail-already-unliked/http-request.adoc[]

include::{snippets}/toggle-team-like-fail-already-unliked/http-response.adoc[]

====

.❌ Case 4: 좋아요한 적 없는 팀에 취소 요청

[%collapsible]

====

include::{snippets}/toggle-team-like-fail-not-liked-yet/http-request.adoc[]

include::{snippets}/toggle-team-like-fail-not-liked-yet/http-response.adoc[]

====

.❌ Case 5: 투표 기간 중 좋아요 요청

[%collapsible]

====

include::{snippets}/toggle-team-like-fail-voting-period/http-request.adoc[]

include::{snippets}/toggle-team-like-fail-voting-period/http-response.adoc[]

====
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import static com.opus.opus.modules.contest.exception.ContestExceptionType.*;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.ALREADY_CURRENT_CONTEST;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.ALREADY_NOT_CURRENT_CONTEST;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.CURRENT_CONTEST_LIMIT_EXCEEDED;
import static com.opus.opus.modules.file.domain.FileImageType.BANNER;
import static com.opus.opus.modules.file.domain.ReferenceDomainType.CONTEST;
Expand All @@ -17,12 +16,10 @@
import com.opus.opus.modules.contest.application.dto.request.VoteUpdateRequest;
import com.opus.opus.modules.contest.application.dto.response.ContestCurrentToggleResponse;
import com.opus.opus.modules.contest.application.dto.response.ContestResponse;
import com.opus.opus.modules.contest.application.dto.response.VotePeriodResponse;
import com.opus.opus.modules.contest.domain.Contest;
import com.opus.opus.modules.contest.domain.ContestCategory;
import com.opus.opus.modules.contest.domain.dao.ContestRepository;
import com.opus.opus.modules.contest.exception.ContestException;
import com.opus.opus.modules.contest.exception.ContestExceptionType;
import com.opus.opus.modules.file.domain.File;
import com.opus.opus.modules.file.domain.dao.FileRepository;
import com.opus.opus.modules.file.exception.FileException;
Expand Down Expand Up @@ -122,17 +119,11 @@ private void checkVoteRange(final VoteUpdateRequest voteRequest) {
public void updateMaxVotesLimit(final Long contestId, final Integer maxVotesLimit) {
final Contest contest = contestConvenience.getValidateExistContest(contestId);

validateNotInVotingPeriod(contest);
contestConvenience.validateNotInVotingPeriod(contest);

contest.updateMaxVotesLimit(maxVotesLimit);
}

private void validateNotInVotingPeriod(final Contest contest) {
if (contest.isVotingPeriod()) {
throw new ContestException(CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD);
}
}

private void checkWebpConverted(File existingFile) {
if (!existingFile.getIsWebpConverted()) {
throw new FileException(NOT_WEBP_CONVERTED);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.opus.opus.modules.contest.application.convenience;


import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_ALLOWED_DURING_VOTING_PERIOD;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.CATEGORY_HAS_CONTEST;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.CONTEST_NAME_ALREADY_EXIST;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_FOUND_CONTEST;
Expand Down Expand Up @@ -47,4 +48,10 @@ public long countCurrentContests() {
public List<Contest> getCurrentContests() {
return contestRepository.findAllByIsCurrentTrue();
}

public void validateNotInVotingPeriod(final Contest contest) {
if (contest.isVotingPeriod()) {
throw new ContestException(NOT_ALLOWED_DURING_VOTING_PERIOD);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public enum ContestExceptionType implements BaseExceptionType {
CATEGORY_HAS_CONTEST(HttpStatus.CONFLICT, "해당 카테고리에 속한 대회가 존재합니다."),
CONTEST_NAME_ALREADY_EXIST(HttpStatus.CONFLICT, "동일한 대회명이 있습니다."),
VOTE_END_PRECEDE_VOTE_START(HttpStatus.BAD_REQUEST, "투표 종료가 투표 시작보다 빠를 수 없습니다."),
CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD(HttpStatus.BAD_REQUEST, "투표 진행중에는 최대 투표 개수를 변경할 수 없습니다.")
NOT_ALLOWED_DURING_VOTING_PERIOD(HttpStatus.BAD_REQUEST, "현재 투표 기간이므로 해당 작업을 수행할 수 없습니다.")
;

private final HttpStatus httpStatus;
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/com/opus/opus/modules/team/api/TeamController.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package com.opus.opus.modules.team.api;

import com.opus.opus.global.security.annotation.LoginMember;
import com.opus.opus.modules.member.domain.Member;
import com.opus.opus.modules.team.application.TeamCommandService;
import com.opus.opus.modules.team.application.TeamLikeCommandService;
import com.opus.opus.modules.team.application.TeamQueryService;
import com.opus.opus.modules.team.application.dto.ImageResponse;
import com.opus.opus.modules.team.application.dto.request.PreviewDeleteRequest;
import com.opus.opus.modules.team.application.dto.request.TeamLikeToggleRequest;
import com.opus.opus.modules.team.application.dto.response.TeamLikeToggleResponse;
import jakarta.validation.Valid;
import java.util.List;
import lombok.RequiredArgsConstructor;
Expand All @@ -16,6 +21,7 @@
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
Expand All @@ -30,6 +36,7 @@ public class TeamController {

private final TeamQueryService teamQueryService;
private final TeamCommandService teamCommandService;
private final TeamLikeCommandService teamLikeCommandService;

@GetMapping("/{teamId}/image/{imageId}")
public ResponseEntity<Resource> getPreviewImage(@PathVariable final Long teamId, @PathVariable final Long imageId) {
Expand Down Expand Up @@ -103,4 +110,13 @@ public ResponseEntity<Void> deletePosterImage(@PathVariable final Long teamId) {
teamCommandService.deletePosterImage(teamId);
return ResponseEntity.noContent().build();
}

@Secured({"ROLE_회원", "ROLE_관리자"})
@PutMapping("/{teamId}/likes")
public ResponseEntity<TeamLikeToggleResponse> toggleLike(@PathVariable Long teamId,
@RequestBody @Valid TeamLikeToggleRequest request,
@LoginMember Member member) {
TeamLikeToggleResponse response = teamLikeCommandService.toggleLike(member.getId(), teamId, request.isLiked());
return ResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.opus.opus.modules.team.application;

import static com.opus.opus.modules.team.exception.TeamLikeExceptionType.ALREADY_LIKED;
import static com.opus.opus.modules.team.exception.TeamLikeExceptionType.ALREADY_UNLIKED;
import static com.opus.opus.modules.team.exception.TeamLikeExceptionType.DUPLICATE_LIKE_REQUEST;
import static com.opus.opus.modules.team.exception.TeamLikeExceptionType.NOT_LIKED_YET;

import com.opus.opus.modules.contest.application.convenience.ContestConvenience;
import com.opus.opus.modules.contest.domain.Contest;
import com.opus.opus.modules.team.application.convenience.TeamConvenience;
import com.opus.opus.modules.team.application.dto.response.TeamLikeToggleResponse;
import com.opus.opus.modules.team.domain.Team;
import com.opus.opus.modules.team.domain.TeamLike;
import com.opus.opus.modules.team.domain.dao.TeamLikeRepository;
import com.opus.opus.modules.team.exception.TeamLikeException;
import java.util.Objects;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional
public class TeamLikeCommandService {

private final TeamConvenience teamConvenience;
private final ContestConvenience contestConvenience;

private final TeamLikeRepository teamLikeRepository;

public TeamLikeToggleResponse toggleLike(Long memberId, Long teamId, Boolean isLiked) {
Team team = teamConvenience.getValidateExistTeam(teamId);
Contest contest = contestConvenience.getValidateExistContest(team.getContestId());

contestConvenience.validateNotInVotingPeriod(contest);

Optional<TeamLike> teamLikeOptional = teamLikeRepository.findByMemberIdAndTeam(memberId, team);
return teamLikeOptional
.map(teamLike -> handleExistingLike(teamLike, isLiked))
.orElseGet(() -> handleFirstTimeLike(memberId, team, isLiked));
}

private TeamLikeToggleResponse handleFirstTimeLike(Long memberId, Team team, Boolean isLiked) {
if (!isLiked) {
throw new TeamLikeException(NOT_LIKED_YET);
}

saveTeamLike(memberId, team, true);
return TeamLikeToggleResponse.of(team.getId(), true, "좋아요가 등록되었습니다.");
}

private TeamLikeToggleResponse handleExistingLike(final TeamLike teamLike, final Boolean isLiked) {
if (Objects.equals(teamLike.getIsLiked(), isLiked)) {
throw new TeamLikeException(isLiked ? ALREADY_LIKED : ALREADY_UNLIKED);
}

teamLike.updateIsLiked(isLiked);

return TeamLikeToggleResponse.of(teamLike.getTeam().getId(), isLiked, isLiked ? "좋아요가 등록되었습니다." : "좋아요가 취소되었습니다.");
}

private void saveTeamLike(Long memberId, Team team, Boolean isLiked) {
try {
teamLikeRepository.save(TeamLike.builder()
.memberId(memberId)
.team(team)
.isLiked(isLiked)
.build());
teamLikeRepository.flush();
} catch (DataIntegrityViolationException e) {
throw new TeamLikeException(DUPLICATE_LIKE_REQUEST);
}
}
Comment on lines +64 to +75
Copy link
Collaborator

@JJimini JJimini Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

투표와 비슷한 흐름으로 설계하신 것 같아요!

근데 제 생각에는 투표와 달리 반복해서 좋아요-좋아요 취소가 가능하고 (투표는 취소는 가능하나, 재투표는 불가) 한 transaction 내에서 flush가 발생하는 것도 예상하지 못한 side effect 발생 가능성 있다고 생각해서 DB hit를 강제할 필요는 없다고 생각하는데, 어떻게 생각하시나요??

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.opus.opus.modules.team.application.dto.request;

import jakarta.validation.constraints.NotNull;

public record TeamLikeToggleRequest(
@NotNull(message = "isLiked 값은 필수입니다.")
Boolean isLiked
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.opus.opus.modules.team.application.dto.response;

public record TeamLikeToggleResponse(
Long teamId,
Boolean isLiked,
String message
) {
public static TeamLikeToggleResponse of(Long teamId, Boolean isLiked, String message) {
return new TeamLikeToggleResponse(teamId, isLiked, message);
}
}
Loading
Loading