diff --git a/src/main/java/org/sopt/makers/internal/external/pushNotification/message/member/AnswerNotificationMessage.java b/src/main/java/org/sopt/makers/internal/external/pushNotification/message/member/AnswerNotificationMessage.java new file mode 100644 index 00000000..373ac205 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/external/pushNotification/message/member/AnswerNotificationMessage.java @@ -0,0 +1,51 @@ +package org.sopt.makers.internal.external.pushNotification.message.member; + +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.sopt.makers.internal.external.pushNotification.message.PushNotificationMessageBuilder; + +import static org.sopt.makers.internal.external.pushNotification.message.member.MemberPushConstants.*; + +@RequiredArgsConstructor +public class AnswerNotificationMessage implements PushNotificationMessageBuilder { + + private final Long questionAskerId; + private final String answerWriterName; + private final String answerContent; + private final String webLink; + + @Override + public String buildTitle() { + return ANSWER_NOTIFICATION_TITLE; + } + + @Override + public String buildContent() { + String abbreviatedContent = StringUtils.abbreviate(answerContent, CONTENT_MAX_LENGTH); + return String.format(ANSWER_CONTENT_FORMAT, answerWriterName, abbreviatedContent); + } + + @Override + public Long[] getRecipientIds() { + return new Long[]{questionAskerId}; + } + + @Override + public String getWebLink() { + return webLink; + } + + public static AnswerNotificationMessage of( + Long questionAskerId, + String answerWriterName, + String answerContent, + String webLink + ) { + return new AnswerNotificationMessage( + questionAskerId, + answerWriterName, + answerContent, + webLink + ); + } +} diff --git a/src/main/java/org/sopt/makers/internal/external/pushNotification/message/member/MemberPushConstants.java b/src/main/java/org/sopt/makers/internal/external/pushNotification/message/member/MemberPushConstants.java new file mode 100644 index 00000000..38db4911 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/external/pushNotification/message/member/MemberPushConstants.java @@ -0,0 +1,15 @@ +package org.sopt.makers.internal.external.pushNotification.message.member; + +public final class MemberPushConstants { + + private MemberPushConstants() { + throw new UnsupportedOperationException("유틸 클래스는 인스턴스화 할 수 없습니다."); + } + + // 에스크 답변 알림 + public static final String ANSWER_NOTIFICATION_TITLE = "💬나의 에스크에 답변이 달렸어요."; + public static final String ANSWER_CONTENT_FORMAT = "[%s의 댓글] : \"%s\""; + + // 답변 내용 최대 길이 + public static final int CONTENT_MAX_LENGTH = 100; +} diff --git a/src/main/java/org/sopt/makers/internal/member/controller/MemberQuestionController.java b/src/main/java/org/sopt/makers/internal/member/controller/MemberQuestionController.java new file mode 100644 index 00000000..aae92314 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/controller/MemberQuestionController.java @@ -0,0 +1,183 @@ +package org.sopt.makers.internal.member.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.sopt.makers.internal.member.domain.QuestionTab; +import org.sopt.makers.internal.member.dto.request.*; +import org.sopt.makers.internal.member.dto.response.*; +import org.sopt.makers.internal.member.service.MemberQuestionService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/members") +@SecurityRequirement(name = "Authorization") +@Tag(name = "Member Question 관련 API", description = "회원 질문/답변 관련 API List") +public class MemberQuestionController { + + private final MemberQuestionService memberQuestionService; + + @Operation( + summary = "질문 작성 API", + description = """ + 다른 사용자에게 질문을 작성합니다. + 익명으로 작성할 경우 asker 정보가 숨겨집니다. + """ + ) + @PostMapping("/questions/{receiverId}") + public ResponseEntity> createQuestion( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId, + @PathVariable Long receiverId, + @RequestBody @Valid QuestionSaveRequest request + ) { + Long questionId = memberQuestionService.createQuestion(userId, receiverId, request); + return ResponseEntity.status(HttpStatus.CREATED).body(Map.of("questionId", questionId)); + } + + @Operation(summary = "질문 수정 API", description = "답변이 달리기 전에만 수정 가능합니다.") + @PutMapping("/questions/{questionId}") + public ResponseEntity> updateQuestion( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId, + @PathVariable Long questionId, + @RequestBody @Valid QuestionUpdateRequest request + ) { + memberQuestionService.updateQuestion(userId, questionId, request); + return ResponseEntity.status(HttpStatus.OK).body(Map.of("success", true)); + } + + @Operation( + summary = "질문 삭제 API", + description = """ + 질문 삭제 규칙: + - 답변 전: 질문 작성자만 삭제 가능 + - 항상: 질문 받은 사람은 삭제 가능 + """ + ) + @DeleteMapping("/questions/{questionId}") + public ResponseEntity> deleteQuestion( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId, + @PathVariable Long questionId + ) { + memberQuestionService.deleteQuestion(userId, questionId); + return ResponseEntity.status(HttpStatus.OK).body(Map.of("success", true)); + } + + @Operation(summary = "답변 작성 API", description = "질문을 받은 사람만 답변을 작성할 수 있습니다.") + @PostMapping("/questions/{questionId}/answer") + public ResponseEntity> createAnswer( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId, + @PathVariable Long questionId, + @RequestBody @Valid AnswerSaveRequest request + ) { + Long answerId = memberQuestionService.createAnswer(userId, questionId, request); + return ResponseEntity.status(HttpStatus.CREATED).body(Map.of("answerId", answerId)); + } + + @Operation(summary = "답변 수정 API", description = "답변 작성자만 수정 가능합니다.") + @PutMapping("/answers/{answerId}") + public ResponseEntity> updateAnswer( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId, + @PathVariable Long answerId, + @RequestBody @Valid AnswerUpdateRequest request + ) { + memberQuestionService.updateAnswer(userId, answerId, request); + return ResponseEntity.status(HttpStatus.OK).body(Map.of("success", true)); + } + + @Operation(summary = "답변 삭제 API", description = "답변 작성자만 삭제 가능합니다.") + @DeleteMapping("/answers/{answerId}") + public ResponseEntity> deleteAnswer( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId, + @PathVariable Long answerId + ) { + memberQuestionService.deleteAnswer(userId, answerId); + return ResponseEntity.status(HttpStatus.OK).body(Map.of("success", true)); + } + + @Operation( + summary = "나도 궁금해요 토글 API", + description = """ + 답변이 달리기 전 질문에 '나도 궁금해요' 반응을 토글합니다. + 이미 반응을 누른 경우 취소되고, 누르지 않은 경우 반응이 추가됩니다. + 질문을 받은 사람은 반응을 누를 수 없습니다. + """ + ) + @PostMapping("/questions/{questionId}/reactions") + public ResponseEntity> toggleQuestionReaction( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId, + @PathVariable Long questionId + ) { + memberQuestionService.toggleQuestionReaction(userId, questionId); + return ResponseEntity.status(HttpStatus.OK).body(Map.of("success", true)); + } + + @Operation( + summary = "도움돼요 토글 API", + description = """ + 답변에 '도움돼요' 반응을 토글합니다. + 이미 반응을 누른 경우 취소되고, 누르지 않은 경우 반응이 추가됩니다. + 답변 작성자는 반응을 누를 수 없습니다. + """ + ) + @PostMapping("/answers/{answerId}/reactions") + public ResponseEntity> toggleAnswerReaction( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId, + @PathVariable Long answerId + ) { + memberQuestionService.toggleAnswerReaction(userId, answerId); + return ResponseEntity.status(HttpStatus.OK).body(Map.of("success", true)); + } + + @Operation(summary = "질문 신고 API") + @PostMapping("/questions/{questionId}/report") + public ResponseEntity> reportQuestion( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId, + @PathVariable Long questionId, + @RequestBody QuestionReportRequest request + ) { + memberQuestionService.reportQuestion(userId, questionId, request); + return ResponseEntity.status(HttpStatus.CREATED).body(Map.of("success", true)); + } + + @Operation( + summary = "질문 목록 조회 API", + description = """ + 특정 사용자의 질문 목록을 조회합니다. + tab: answered (답변 완료), unanswered (새질문) + page: 페이지 번호 (0부터 시작, 기본값 0) + size: 페이지 크기 (기본 10, 최대 100) + """ + ) + @GetMapping("/{memberId}/questions") + public ResponseEntity getQuestions( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId, + @PathVariable Long memberId, + @RequestParam(value = "tab", defaultValue = "answered") QuestionTab tab, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "size", required = false) Integer size + ) { + QuestionsResponse response = memberQuestionService.getQuestions(userId, memberId, tab, page, size); + return ResponseEntity.status(HttpStatus.OK).body(response); + } + + @Operation( + summary = "답변 대기 중인 질문 개수 조회 API", + description = "현재 로그인한 사용자에게 달린 답변 대기 중인 질문의 개수를 조회합니다." + ) + @GetMapping("/me/questions/unanswered-count") + public ResponseEntity getUnansweredCount( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId + ) { + UnansweredCountResponse response = memberQuestionService.getUnansweredCount(userId); + return ResponseEntity.status(HttpStatus.OK).body(response); + } +} diff --git a/src/main/java/org/sopt/makers/internal/member/converter/StringToQuestionTabConverter.java b/src/main/java/org/sopt/makers/internal/member/converter/StringToQuestionTabConverter.java new file mode 100644 index 00000000..a303874c --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/converter/StringToQuestionTabConverter.java @@ -0,0 +1,14 @@ +package org.sopt.makers.internal.member.converter; + +import org.sopt.makers.internal.member.domain.QuestionTab; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +@Component +public class StringToQuestionTabConverter implements Converter { + + @Override + public QuestionTab convert(String source) { + return QuestionTab.from(source); + } +} diff --git a/src/main/java/org/sopt/makers/internal/member/domain/AnswerReaction.java b/src/main/java/org/sopt/makers/internal/member/domain/AnswerReaction.java new file mode 100644 index 00000000..8df55b9e --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/domain/AnswerReaction.java @@ -0,0 +1,42 @@ +package org.sopt.makers.internal.member.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.sopt.makers.internal.common.AuditingTimeEntity; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "answer_reaction", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_answer_reaction_answer_member", + columnNames = {"answer_id", "member_id"} + ) + } +) +public class AnswerReaction extends AuditingTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "reaction_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "answer_id", nullable = false) + private MemberAnswer answer; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Builder + private AnswerReaction(MemberAnswer answer, Member member) { + this.answer = answer; + this.member = member; + } +} diff --git a/src/main/java/org/sopt/makers/internal/member/domain/MemberAnswer.java b/src/main/java/org/sopt/makers/internal/member/domain/MemberAnswer.java new file mode 100644 index 00000000..80938966 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/domain/MemberAnswer.java @@ -0,0 +1,41 @@ +package org.sopt.makers.internal.member.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.sopt.makers.internal.common.AuditingTimeEntity; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@Table(name = "member_answer") +public class MemberAnswer extends AuditingTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "answer_id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "question_id", nullable = false, unique = true) + private MemberQuestion question; + + @Column(columnDefinition = "TEXT", nullable = false, length = 2000) + private String content; + + @Builder.Default + @OneToMany(mappedBy = "answer", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List reactions = new ArrayList<>(); + + public void updateContent(String content) { + this.content = content; + } +} diff --git a/src/main/java/org/sopt/makers/internal/member/domain/MemberQuestion.java b/src/main/java/org/sopt/makers/internal/member/domain/MemberQuestion.java new file mode 100644 index 00000000..f02bc422 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/domain/MemberQuestion.java @@ -0,0 +1,73 @@ +package org.sopt.makers.internal.member.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.sopt.makers.internal.common.AuditingTimeEntity; +import org.sopt.makers.internal.community.domain.anonymous.AnonymousNickname; +import org.sopt.makers.internal.community.domain.anonymous.AnonymousProfileImage; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@Table(name = "member_question") +public class MemberQuestion extends AuditingTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "question_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver_id", nullable = false) + private Member receiver; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "asker_id") + private Member asker; + + @Column(columnDefinition = "TEXT", nullable = false, length = 2000) + private String content; + + @Column(nullable = false) + private Boolean isAnonymous; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "anonymous_nickname_id") + private AnonymousNickname anonymousNickname; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "anonymous_profile_image_id") + private AnonymousProfileImage anonymousProfileImage; + + @Builder.Default + @Column(nullable = false) + private Boolean isReported = false; + + @OneToOne(mappedBy = "question", cascade = CascadeType.REMOVE, orphanRemoval = true) + private MemberAnswer answer; + + @Builder.Default + @OneToMany(mappedBy = "question", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List reactions = new ArrayList<>(); + + public void updateContent(String content) { + this.content = content; + } + + public void markAsReported() { + this.isReported = true; + } + + public boolean hasAnswer() { + return this.answer != null; + } +} diff --git a/src/main/java/org/sopt/makers/internal/member/domain/QuestionReaction.java b/src/main/java/org/sopt/makers/internal/member/domain/QuestionReaction.java new file mode 100644 index 00000000..d1f3ab67 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/domain/QuestionReaction.java @@ -0,0 +1,42 @@ +package org.sopt.makers.internal.member.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.sopt.makers.internal.common.AuditingTimeEntity; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "question_reaction", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_question_reaction_question_member", + columnNames = {"question_id", "member_id"} + ) + } +) +public class QuestionReaction extends AuditingTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "reaction_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "question_id", nullable = false) + private MemberQuestion question; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Builder + private QuestionReaction(MemberQuestion question, Member member) { + this.question = question; + this.member = member; + } +} diff --git a/src/main/java/org/sopt/makers/internal/member/domain/QuestionReport.java b/src/main/java/org/sopt/makers/internal/member/domain/QuestionReport.java new file mode 100644 index 00000000..f88f4c5a --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/domain/QuestionReport.java @@ -0,0 +1,41 @@ +package org.sopt.makers.internal.member.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@Table(name = "question_report") +public class QuestionReport { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "report_id") + private Long id; + + @Column(nullable = false) + private Long questionId; + + @Column(nullable = false) + private Long reporterId; + + @Column + private String reason; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/org/sopt/makers/internal/member/domain/QuestionTab.java b/src/main/java/org/sopt/makers/internal/member/domain/QuestionTab.java new file mode 100644 index 00000000..02b63429 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/domain/QuestionTab.java @@ -0,0 +1,34 @@ +package org.sopt.makers.internal.member.domain; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum QuestionTab { + ANSWERED("answered", "답변 완료"), + UNANSWERED("unanswered", "새질문"); + + private final String value; + private final String description; + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static QuestionTab from(String value) { + if (value == null) { + return ANSWERED; + } + for (QuestionTab tab : QuestionTab.values()) { + if (tab.value.equalsIgnoreCase(value)) { + return tab; + } + } + return ANSWERED; + } +} diff --git a/src/main/java/org/sopt/makers/internal/member/dto/request/AnswerSaveRequest.java b/src/main/java/org/sopt/makers/internal/member/dto/request/AnswerSaveRequest.java new file mode 100644 index 00000000..34b95c26 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/dto/request/AnswerSaveRequest.java @@ -0,0 +1,15 @@ +package org.sopt.makers.internal.member.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +public record AnswerSaveRequest( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "답변 내용 (최대 2,000자)") + @NotBlank(message = "답변 내용은 공백일 수 없습니다.") + @Size(max = 2000, message = "답변은 최대 2,000자까지 입력 가능합니다.") + String content +) { +} diff --git a/src/main/java/org/sopt/makers/internal/member/dto/request/AnswerUpdateRequest.java b/src/main/java/org/sopt/makers/internal/member/dto/request/AnswerUpdateRequest.java new file mode 100644 index 00000000..46dc52e9 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/dto/request/AnswerUpdateRequest.java @@ -0,0 +1,17 @@ +package org.sopt.makers.internal.member.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +public record AnswerUpdateRequest( + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "수정할 답변 내용 (최대 2,000자)") + @NotBlank(message = "답변 내용은 공백일 수 없습니다.") + @Size(max = 2000, message = "답변은 최대 2,000자까지 입력 가능합니다.") + String content +) { +} diff --git a/src/main/java/org/sopt/makers/internal/member/dto/request/QuestionReportRequest.java b/src/main/java/org/sopt/makers/internal/member/dto/request/QuestionReportRequest.java new file mode 100644 index 00000000..0e65a126 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/dto/request/QuestionReportRequest.java @@ -0,0 +1,11 @@ +package org.sopt.makers.internal.member.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +public record QuestionReportRequest( + @Schema(description = "신고 사유") + String reason +) { +} diff --git a/src/main/java/org/sopt/makers/internal/member/dto/request/QuestionSaveRequest.java b/src/main/java/org/sopt/makers/internal/member/dto/request/QuestionSaveRequest.java new file mode 100644 index 00000000..29a6a941 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/dto/request/QuestionSaveRequest.java @@ -0,0 +1,21 @@ +package org.sopt.makers.internal.member.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +public record QuestionSaveRequest( + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "질문 내용 (최대 2,000자)") + @NotBlank(message = "질문 내용은 공백일 수 없습니다.") + @Size(max = 2000, message = "질문은 최대 2,000자까지 입력 가능합니다.") + String content, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "익명 여부") + @NotNull(message = "익명 여부는 필수입니다.") + Boolean isAnonymous +) { +} diff --git a/src/main/java/org/sopt/makers/internal/member/dto/request/QuestionUpdateRequest.java b/src/main/java/org/sopt/makers/internal/member/dto/request/QuestionUpdateRequest.java new file mode 100644 index 00000000..dc3348f5 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/dto/request/QuestionUpdateRequest.java @@ -0,0 +1,17 @@ +package org.sopt.makers.internal.member.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +public record QuestionUpdateRequest( + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "수정할 질문 내용 (최대 2,000자)") + @NotBlank(message = "질문 내용은 공백일 수 없습니다.") + @Size(max = 2000, message = "질문은 최대 2,000자까지 입력 가능합니다.") + String content +) { +} diff --git a/src/main/java/org/sopt/makers/internal/member/dto/response/AnswerResponse.java b/src/main/java/org/sopt/makers/internal/member/dto/response/AnswerResponse.java new file mode 100644 index 00000000..ab8bea98 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/dto/response/AnswerResponse.java @@ -0,0 +1,21 @@ +package org.sopt.makers.internal.member.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AnswerResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "답변 ID") + Long answerId, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "답변 내용") + String content, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "도움돼요 반응 수") + Long reactionCount, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "현재 사용자가 도움돼요를 눌렀는지 여부") + Boolean isReacted, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "생성일시") + String createdAt +) { +} diff --git a/src/main/java/org/sopt/makers/internal/member/dto/response/QuestionResponse.java b/src/main/java/org/sopt/makers/internal/member/dto/response/QuestionResponse.java new file mode 100644 index 00000000..ffbe896a --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/dto/response/QuestionResponse.java @@ -0,0 +1,55 @@ +package org.sopt.makers.internal.member.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.sopt.makers.internal.community.dto.AnonymousProfileVo; + +public record QuestionResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "질문 ID") + Long questionId, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "질문 내용") + String content, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "질문자 ID") + Long askerId, + + @Schema(description = "질문자 이름 (익명일 경우 null)") + String askerName, + + @Schema(description = "질문자 프로필 이미지 (익명일 경우 null)") + String askerProfileImage, + + @Schema(description = "질문자 최신 기수 정보 (예: '36기 서버')") + String askerLatestGeneration, + + @Schema(description = "익명 프로필 정보 (익명일 경우)") + AnonymousProfileVo anonymousProfile, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "익명 여부") + Boolean isAnonymous, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "나도 궁금해요 반응 수") + Long reactionCount, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "현재 사용자가 나도 궁금해요를 눌렀는지 여부") + Boolean isReacted, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "답변 여부") + Boolean isAnswered, + + @Schema(description = "답변 정보 (답변이 있을 경우)") + AnswerResponse answer, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "생성일시") + String createdAt, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "질문이 일주일 이내에 작성되었는지 여부") + Boolean isNew, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "본인의 질문인지 여부") + Boolean isMine, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "본인이 받은 질문인지 여부") + Boolean isReceived +) { +} diff --git a/src/main/java/org/sopt/makers/internal/member/dto/response/QuestionsResponse.java b/src/main/java/org/sopt/makers/internal/member/dto/response/QuestionsResponse.java new file mode 100644 index 00000000..08262762 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/dto/response/QuestionsResponse.java @@ -0,0 +1,29 @@ +package org.sopt.makers.internal.member.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +public record QuestionsResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "질문 목록") + List questions, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "현재 페이지 번호 (0부터 시작)") + Integer currentPage, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "페이지 크기") + Integer pageSize, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "전체 질문 개수") + Long totalElements, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "전체 페이지 수") + Integer totalPages, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "다음 페이지 존재 여부") + Boolean hasNext, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "이전 페이지 존재 여부") + Boolean hasPrevious +) { +} diff --git a/src/main/java/org/sopt/makers/internal/member/dto/response/UnansweredCountResponse.java b/src/main/java/org/sopt/makers/internal/member/dto/response/UnansweredCountResponse.java new file mode 100644 index 00000000..9b31b42b --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/dto/response/UnansweredCountResponse.java @@ -0,0 +1,9 @@ +package org.sopt.makers.internal.member.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record UnansweredCountResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "답변 대기 중인 질문 개수") + Long count +) { +} diff --git a/src/main/java/org/sopt/makers/internal/member/repository/AnswerReactionRepository.java b/src/main/java/org/sopt/makers/internal/member/repository/AnswerReactionRepository.java new file mode 100644 index 00000000..4c397465 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/repository/AnswerReactionRepository.java @@ -0,0 +1,22 @@ +package org.sopt.makers.internal.member.repository; + +import org.sopt.makers.internal.member.domain.AnswerReaction; +import org.sopt.makers.internal.member.domain.Member; +import org.sopt.makers.internal.member.domain.MemberAnswer; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface AnswerReactionRepository extends JpaRepository { + + Optional findByAnswerAndMember(MemberAnswer answer, Member member); + + boolean existsByAnswerAndMember(MemberAnswer answer, Member member); + + @Query("SELECT COUNT(ar) FROM AnswerReaction ar WHERE ar.answer.id = :answerId") + long countByAnswerId(@Param("answerId") Long answerId); + + void deleteByAnswerAndMember(MemberAnswer answer, Member member); +} diff --git a/src/main/java/org/sopt/makers/internal/member/repository/MemberAnswerRepository.java b/src/main/java/org/sopt/makers/internal/member/repository/MemberAnswerRepository.java new file mode 100644 index 00000000..7a981f2f --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/repository/MemberAnswerRepository.java @@ -0,0 +1,14 @@ +package org.sopt.makers.internal.member.repository; + +import org.sopt.makers.internal.member.domain.MemberAnswer; +import org.sopt.makers.internal.member.domain.MemberQuestion; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberAnswerRepository extends JpaRepository { + + Optional findByQuestion(MemberQuestion question); + + boolean existsByQuestion(MemberQuestion question); +} diff --git a/src/main/java/org/sopt/makers/internal/member/repository/MemberQuestionRepository.java b/src/main/java/org/sopt/makers/internal/member/repository/MemberQuestionRepository.java new file mode 100644 index 00000000..3ca9d043 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/repository/MemberQuestionRepository.java @@ -0,0 +1,51 @@ +package org.sopt.makers.internal.member.repository; + +import org.sopt.makers.internal.member.domain.Member; +import org.sopt.makers.internal.member.domain.MemberQuestion; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface MemberQuestionRepository extends JpaRepository { + + @Query("SELECT q FROM MemberQuestion q " + + "WHERE q.receiver.id = :receiverId " + + "AND EXISTS (SELECT 1 FROM MemberAnswer a WHERE a.question.id = q.id) " + + "ORDER BY q.createdAt DESC") + List findAnsweredQuestions( + @Param("receiverId") Long receiverId, + Pageable pageable + ); + + @Query("SELECT q FROM MemberQuestion q " + + "WHERE q.receiver.id = :receiverId " + + "AND NOT EXISTS (SELECT 1 FROM MemberAnswer a WHERE a.question.id = q.id) " + + "ORDER BY q.createdAt DESC") + List findUnansweredQuestions( + @Param("receiverId") Long receiverId, + Pageable pageable + ); + + @Query("SELECT COUNT(q) FROM MemberQuestion q " + + "WHERE q.receiver.id = :receiverId " + + "AND EXISTS (SELECT 1 FROM MemberAnswer a WHERE a.question.id = q.id)") + long countAnsweredQuestions(@Param("receiverId") Long receiverId); + + @Query("SELECT COUNT(q) FROM MemberQuestion q " + + "WHERE q.receiver.id = :receiverId " + + "AND NOT EXISTS (SELECT 1 FROM MemberAnswer a WHERE a.question.id = q.id)") + long countUnansweredQuestions(@Param("receiverId") Long receiverId); + + boolean existsByIdAndAsker(Long questionId, Member asker); + + boolean existsByIdAndReceiver(Long questionId, Member receiver); + + @Query("SELECT q FROM MemberQuestion q ORDER BY q.id DESC") + List findTopByOrderByIdDesc(Pageable pageable); + + @Query("SELECT q FROM MemberQuestion q WHERE q.receiver.id = :receiverId") + List findByReceiverId(@Param("receiverId") Long receiverId); +} diff --git a/src/main/java/org/sopt/makers/internal/member/repository/QuestionReactionRepository.java b/src/main/java/org/sopt/makers/internal/member/repository/QuestionReactionRepository.java new file mode 100644 index 00000000..20ff1fc3 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/repository/QuestionReactionRepository.java @@ -0,0 +1,22 @@ +package org.sopt.makers.internal.member.repository; + +import org.sopt.makers.internal.member.domain.Member; +import org.sopt.makers.internal.member.domain.MemberQuestion; +import org.sopt.makers.internal.member.domain.QuestionReaction; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface QuestionReactionRepository extends JpaRepository { + + Optional findByQuestionAndMember(MemberQuestion question, Member member); + + boolean existsByQuestionAndMember(MemberQuestion question, Member member); + + @Query("SELECT COUNT(qr) FROM QuestionReaction qr WHERE qr.question.id = :questionId") + long countByQuestionId(@Param("questionId") Long questionId); + + void deleteByQuestionAndMember(MemberQuestion question, Member member); +} diff --git a/src/main/java/org/sopt/makers/internal/member/repository/QuestionReportRepository.java b/src/main/java/org/sopt/makers/internal/member/repository/QuestionReportRepository.java new file mode 100644 index 00000000..b0654b93 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/repository/QuestionReportRepository.java @@ -0,0 +1,9 @@ +package org.sopt.makers.internal.member.repository; + +import org.sopt.makers.internal.member.domain.QuestionReport; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface QuestionReportRepository extends JpaRepository { + + boolean existsByQuestionIdAndReporterId(Long questionId, Long reporterId); +} diff --git a/src/main/java/org/sopt/makers/internal/member/service/AnswerReactionModifier.java b/src/main/java/org/sopt/makers/internal/member/service/AnswerReactionModifier.java new file mode 100644 index 00000000..308d838c --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/service/AnswerReactionModifier.java @@ -0,0 +1,28 @@ +package org.sopt.makers.internal.member.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.makers.internal.member.domain.AnswerReaction; +import org.sopt.makers.internal.member.domain.Member; +import org.sopt.makers.internal.member.domain.MemberAnswer; +import org.sopt.makers.internal.member.repository.AnswerReactionRepository; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class AnswerReactionModifier { + + private final AnswerReactionRepository answerReactionRepository; + + public AnswerReaction createReaction(MemberAnswer answer, Member member) { + return answerReactionRepository.save(AnswerReaction.builder() + .answer(answer) + .member(member) + .build()); + } + + @Transactional + public void deleteReaction(MemberAnswer answer, Member member) { + answerReactionRepository.deleteByAnswerAndMember(answer, member); + } +} diff --git a/src/main/java/org/sopt/makers/internal/member/service/AnswerReactionRetriever.java b/src/main/java/org/sopt/makers/internal/member/service/AnswerReactionRetriever.java new file mode 100644 index 00000000..ee16227a --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/service/AnswerReactionRetriever.java @@ -0,0 +1,29 @@ +package org.sopt.makers.internal.member.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.makers.internal.member.domain.AnswerReaction; +import org.sopt.makers.internal.member.domain.Member; +import org.sopt.makers.internal.member.domain.MemberAnswer; +import org.sopt.makers.internal.member.repository.AnswerReactionRepository; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class AnswerReactionRetriever { + + private final AnswerReactionRepository answerReactionRepository; + + public Optional findByAnswerAndMember(MemberAnswer answer, Member member) { + return answerReactionRepository.findByAnswerAndMember(answer, member); + } + + public boolean existsByAnswerAndMember(MemberAnswer answer, Member member) { + return answerReactionRepository.existsByAnswerAndMember(answer, member); + } + + public long countByAnswerId(Long answerId) { + return answerReactionRepository.countByAnswerId(answerId); + } +} diff --git a/src/main/java/org/sopt/makers/internal/member/service/MemberAnswerModifier.java b/src/main/java/org/sopt/makers/internal/member/service/MemberAnswerModifier.java new file mode 100644 index 00000000..997248fe --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/service/MemberAnswerModifier.java @@ -0,0 +1,30 @@ +package org.sopt.makers.internal.member.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.makers.internal.member.domain.MemberAnswer; +import org.sopt.makers.internal.member.domain.MemberQuestion; +import org.sopt.makers.internal.member.repository.MemberAnswerRepository; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MemberAnswerModifier { + + private final MemberAnswerRepository memberAnswerRepository; + + public MemberAnswer createAnswer(MemberQuestion question, String content) { + return memberAnswerRepository.save(MemberAnswer.builder() + .question(question) + .content(content) + .build()); + } + + public void updateAnswer(MemberAnswer answer, String content) { + answer.updateContent(content); + memberAnswerRepository.save(answer); + } + + public void deleteAnswer(MemberAnswer answer) { + memberAnswerRepository.delete(answer); + } +} diff --git a/src/main/java/org/sopt/makers/internal/member/service/MemberAnswerRetriever.java b/src/main/java/org/sopt/makers/internal/member/service/MemberAnswerRetriever.java new file mode 100644 index 00000000..19723bc2 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/service/MemberAnswerRetriever.java @@ -0,0 +1,30 @@ +package org.sopt.makers.internal.member.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.makers.internal.exception.NotFoundException; +import org.sopt.makers.internal.member.domain.MemberAnswer; +import org.sopt.makers.internal.member.domain.MemberQuestion; +import org.sopt.makers.internal.member.repository.MemberAnswerRepository; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class MemberAnswerRetriever { + + private final MemberAnswerRepository memberAnswerRepository; + + public MemberAnswer findById(Long answerId) { + return memberAnswerRepository.findById(answerId) + .orElseThrow(() -> new NotFoundException("존재하지 않는 답변입니다. id: [" + answerId + "]")); + } + + public Optional findByQuestion(MemberQuestion question) { + return memberAnswerRepository.findByQuestion(question); + } + + public boolean existsByQuestion(MemberQuestion question) { + return memberAnswerRepository.existsByQuestion(question); + } +} diff --git a/src/main/java/org/sopt/makers/internal/member/service/MemberQuestionModifier.java b/src/main/java/org/sopt/makers/internal/member/service/MemberQuestionModifier.java new file mode 100644 index 00000000..8235ab02 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/service/MemberQuestionModifier.java @@ -0,0 +1,48 @@ +package org.sopt.makers.internal.member.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.makers.internal.community.domain.anonymous.AnonymousNickname; +import org.sopt.makers.internal.community.domain.anonymous.AnonymousProfileImage; +import org.sopt.makers.internal.member.domain.Member; +import org.sopt.makers.internal.member.domain.MemberQuestion; +import org.sopt.makers.internal.member.repository.MemberQuestionRepository; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MemberQuestionModifier { + + private final MemberQuestionRepository memberQuestionRepository; + + public MemberQuestion createQuestion( + Member receiver, + Member asker, + String content, + Boolean isAnonymous, + AnonymousNickname anonymousNickname, + AnonymousProfileImage anonymousProfileImage + ) { + return memberQuestionRepository.save(MemberQuestion.builder() + .receiver(receiver) + .asker(asker) + .content(content) + .isAnonymous(isAnonymous) + .anonymousNickname(anonymousNickname) + .anonymousProfileImage(anonymousProfileImage) + .build()); + } + + public void updateQuestion(MemberQuestion question, String content) { + question.updateContent(content); + memberQuestionRepository.save(question); + } + + public void deleteQuestion(MemberQuestion question) { + memberQuestionRepository.delete(question); + } + + public void markAsReported(MemberQuestion question) { + question.markAsReported(); + memberQuestionRepository.save(question); + } +} diff --git a/src/main/java/org/sopt/makers/internal/member/service/MemberQuestionRetriever.java b/src/main/java/org/sopt/makers/internal/member/service/MemberQuestionRetriever.java new file mode 100644 index 00000000..414e9ac8 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/service/MemberQuestionRetriever.java @@ -0,0 +1,51 @@ +package org.sopt.makers.internal.member.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.makers.internal.exception.NotFoundException; +import org.sopt.makers.internal.member.domain.Member; +import org.sopt.makers.internal.member.domain.MemberQuestion; +import org.sopt.makers.internal.member.repository.MemberQuestionRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class MemberQuestionRetriever { + + private final MemberQuestionRepository memberQuestionRepository; + + public MemberQuestion findById(Long questionId) { + return memberQuestionRepository.findById(questionId) + .orElseThrow(() -> new NotFoundException("존재하지 않는 질문입니다. id: [" + questionId + "]")); + } + + public List findAnsweredQuestions(Long receiverId, int page, int size) { + return memberQuestionRepository.findAnsweredQuestions(receiverId, PageRequest.of(page, size)); + } + + public List findUnansweredQuestions(Long receiverId, int page, int size) { + return memberQuestionRepository.findUnansweredQuestions(receiverId, PageRequest.of(page, size)); + } + + public long countAnsweredQuestions(Long receiverId) { + return memberQuestionRepository.countAnsweredQuestions(receiverId); + } + + public long countUnansweredQuestions(Long receiverId) { + return memberQuestionRepository.countUnansweredQuestions(receiverId); + } + + public boolean isAsker(Long questionId, Member asker) { + return memberQuestionRepository.existsByIdAndAsker(questionId, asker); + } + + public boolean isReceiver(Long questionId, Member receiver) { + return memberQuestionRepository.existsByIdAndReceiver(questionId, receiver); + } + + public List findByReceiverId(Long receiverId) { + return memberQuestionRepository.findByReceiverId(receiverId); + } +} diff --git a/src/main/java/org/sopt/makers/internal/member/service/MemberQuestionService.java b/src/main/java/org/sopt/makers/internal/member/service/MemberQuestionService.java new file mode 100644 index 00000000..3f01836d --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/service/MemberQuestionService.java @@ -0,0 +1,349 @@ +package org.sopt.makers.internal.member.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sopt.makers.internal.exception.BadRequestException; +import org.sopt.makers.internal.exception.ForbiddenException; +import org.sopt.makers.internal.external.platform.InternalUserDetails; +import org.sopt.makers.internal.external.platform.PlatformService; +import org.sopt.makers.internal.member.domain.Member; +import org.sopt.makers.internal.member.domain.MemberAnswer; +import org.sopt.makers.internal.member.domain.MemberQuestion; +import org.sopt.makers.internal.member.domain.QuestionTab; +import org.sopt.makers.internal.member.dto.request.*; +import org.sopt.makers.internal.member.dto.response.*; +import org.sopt.makers.internal.community.dto.AnonymousProfileVo; +import org.sopt.makers.internal.community.domain.anonymous.AnonymousNickname; +import org.sopt.makers.internal.community.domain.anonymous.AnonymousProfileImage; +import org.sopt.makers.internal.community.service.anonymous.AnonymousNicknameRetriever; +import org.sopt.makers.internal.community.service.anonymous.AnonymousProfileImageRetriever; +import org.sopt.makers.internal.external.pushNotification.PushNotificationService; +import org.sopt.makers.internal.external.pushNotification.message.member.AnswerNotificationMessage; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MemberQuestionService { + + private final MemberRetriever memberRetriever; + private final MemberQuestionRetriever memberQuestionRetriever; + private final MemberQuestionModifier memberQuestionModifier; + private final MemberAnswerRetriever memberAnswerRetriever; + private final MemberAnswerModifier memberAnswerModifier; + private final QuestionReactionRetriever questionReactionRetriever; + private final QuestionReactionModifier questionReactionModifier; + private final AnswerReactionRetriever answerReactionRetriever; + private final AnswerReactionModifier answerReactionModifier; + private final QuestionReportRetriever questionReportRetriever; + private final QuestionReportModifier questionReportModifier; + private final PlatformService platformService; + private final PushNotificationService pushNotificationService; + private final AnonymousNicknameRetriever anonymousNicknameRetriever; + private final AnonymousProfileImageRetriever anonymousProfileImageRetriever; + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ISO_DATE_TIME; + private static final int NEW_QUESTION_DAYS = 7; + + @Transactional + public Long createQuestion(Long askerId, Long receiverId, QuestionSaveRequest request) { + Member asker = memberRetriever.findMemberById(askerId); + Member receiver = memberRetriever.findMemberById(receiverId); + + if (askerId.equals(receiverId)) { + throw new BadRequestException("자기 자신에게 질문할 수 없습니다."); + } + + AnonymousNickname anonymousNickname = null; + AnonymousProfileImage anonymousProfileImage = null; + + if (request.isAnonymous()) { + List receiverQuestions = memberQuestionRetriever.findByReceiverId(receiverId); + List excludeNicknames = receiverQuestions.stream() + .map(MemberQuestion::getAnonymousNickname) + .filter(Objects::nonNull) + .distinct() + .toList(); + + anonymousNickname = anonymousNicknameRetriever.findRandomAnonymousNickname(excludeNicknames); + anonymousProfileImage = anonymousProfileImageRetriever.getAnonymousProfileImage(); + } + + MemberQuestion question = memberQuestionModifier.createQuestion( + receiver, + asker, + request.content(), + request.isAnonymous(), + anonymousNickname, + anonymousProfileImage + ); + + return question.getId(); + } + + @Transactional + public void updateQuestion(Long userId, Long questionId, QuestionUpdateRequest request) { + MemberQuestion question = memberQuestionRetriever.findById(questionId); + memberRetriever.checkExistsMemberById(userId); + + validateQuestionOwner(question, userId); + + if (question.hasAnswer()) { + throw new BadRequestException("답변이 달린 질문은 수정할 수 없습니다."); + } + + memberQuestionModifier.updateQuestion(question, request.content()); + } + + @Transactional + public void deleteQuestion(Long userId, Long questionId) { + MemberQuestion question = memberQuestionRetriever.findById(questionId); + memberRetriever.checkExistsMemberById(userId); + + boolean isAsker = question.getAsker().getId().equals(userId); + boolean isReceiver = question.getReceiver().getId().equals(userId); + + if (!isAsker && !isReceiver) { + throw new ForbiddenException("질문을 삭제할 권한이 없습니다."); + } + + if (isAsker && question.hasAnswer()) { + throw new BadRequestException("답변이 달린 질문은 작성자가 삭제할 수 없습니다."); + } + + memberQuestionModifier.deleteQuestion(question); + } + + @Transactional + public Long createAnswer(Long userId, Long questionId, AnswerSaveRequest request) { + MemberQuestion question = memberQuestionRetriever.findById(questionId); + memberRetriever.checkExistsMemberById(userId); + + if (!question.getReceiver().getId().equals(userId)) { + throw new ForbiddenException("질문을 받은 사람만 답변할 수 있습니다."); + } + + if (memberAnswerRetriever.existsByQuestion(question)) { + throw new BadRequestException("이미 답변이 작성된 질문입니다."); + } + + MemberAnswer answer = memberAnswerModifier.createAnswer(question, request.content()); + + sendAnswerNotification(question, answer, userId); + + return answer.getId(); + } + + @Transactional + public void updateAnswer(Long userId, Long answerId, AnswerUpdateRequest request) { + MemberAnswer answer = memberAnswerRetriever.findById(answerId); + memberRetriever.checkExistsMemberById(userId); + + if (!answer.getQuestion().getReceiver().getId().equals(userId)) { + throw new ForbiddenException("답변 작성자만 수정할 수 있습니다."); + } + + memberAnswerModifier.updateAnswer(answer, request.content()); + } + + @Transactional + public void deleteAnswer(Long userId, Long answerId) { + MemberAnswer answer = memberAnswerRetriever.findById(answerId); + memberRetriever.checkExistsMemberById(userId); + + if (!answer.getQuestion().getReceiver().getId().equals(userId)) { + throw new ForbiddenException("답변 작성자만 삭제할 수 있습니다."); + } + + memberAnswerModifier.deleteAnswer(answer); + } + + @Transactional + public void toggleQuestionReaction(Long userId, Long questionId) { + MemberQuestion question = memberQuestionRetriever.findById(questionId); + Member user = memberRetriever.findMemberById(userId); + + if (question.hasAnswer()) { + throw new BadRequestException("답변이 달린 질문에는 '나도 궁금해요'를 누를 수 없습니다."); + } + + if (question.getReceiver().getId().equals(userId)) { + throw new BadRequestException("본인에게 달린 질문에는 '나도 궁금해요'를 누를 수 없습니다."); + } + + if (questionReactionRetriever.existsByQuestionAndMember(question, user)) { + questionReactionModifier.deleteReaction(question, user); + } else { + questionReactionModifier.createReaction(question, user); + } + } + + @Transactional + public void toggleAnswerReaction(Long userId, Long answerId) { + MemberAnswer answer = memberAnswerRetriever.findById(answerId); + Member user = memberRetriever.findMemberById(userId); + + if (answer.getQuestion().getReceiver().getId().equals(userId)) { + throw new BadRequestException("본인의 답변에는 '도움돼요'를 누를 수 없습니다."); + } + + if (answerReactionRetriever.existsByAnswerAndMember(answer, user)) { + answerReactionModifier.deleteReaction(answer, user); + } else { + answerReactionModifier.createReaction(answer, user); + } + } + + @Transactional + public void reportQuestion(Long userId, Long questionId, QuestionReportRequest request) { + MemberQuestion question = memberQuestionRetriever.findById(questionId); + + if (questionReportRetriever.existsByQuestionIdAndReporterId(questionId, userId)) { + throw new BadRequestException("이미 신고한 질문입니다."); + } + + questionReportModifier.createReport(questionId, userId, request.reason()); + memberQuestionModifier.markAsReported(question); + } + + @Transactional(readOnly = true) + public QuestionsResponse getQuestions(Long userId, Long receiverId, QuestionTab tab, Integer page, Integer size) { + int pageNumber = page != null ? page : 0; + int pageSize = (size != null && size > 0 && size <= 100) ? size : 10; + + List questions; + long totalElements; + + if (QuestionTab.ANSWERED == tab) { + questions = memberQuestionRetriever.findAnsweredQuestions(receiverId, pageNumber, pageSize); + totalElements = memberQuestionRetriever.countAnsweredQuestions(receiverId); + } else { + questions = memberQuestionRetriever.findUnansweredQuestions(receiverId, pageNumber, pageSize); + totalElements = memberQuestionRetriever.countUnansweredQuestions(receiverId); + } + + List questionResponses = questions.stream() + .map(q -> toQuestionResponse(q, userId)) + .collect(Collectors.toList()); + + int totalPages = (int) Math.ceil((double) totalElements / pageSize); + boolean hasNext = pageNumber < totalPages - 1; + boolean hasPrevious = pageNumber > 0; + + return new QuestionsResponse( + questionResponses, + pageNumber, + pageSize, + totalElements, + totalPages, + hasNext, + hasPrevious + ); + } + + @Transactional(readOnly = true) + public UnansweredCountResponse getUnansweredCount(Long userId) { + long count = memberQuestionRetriever.countUnansweredQuestions(userId); + return new UnansweredCountResponse(count); + } + + private void validateQuestionOwner(MemberQuestion question, Long userId) { + if (question.getAsker() == null || !question.getAsker().getId().equals(userId)) { + throw new ForbiddenException("질문 작성자만 수정할 수 있습니다."); + } + } + + private void sendAnswerNotification(MemberQuestion question, MemberAnswer answer, Long answerWriterId) { + try { + InternalUserDetails answerWriter = platformService.getInternalUser(answerWriterId); + + AnswerNotificationMessage message = AnswerNotificationMessage.of( + question.getAsker().getId(), + answerWriter.name(), + answer.getContent(), + null + ); + + pushNotificationService.sendPushNotification(message); + } catch (Exception e) { + log.error("답변 푸시 알림 발송 실패: questionId={}, error={}", question.getId(), e.getMessage(), e); + } + } + + private QuestionResponse toQuestionResponse(MemberQuestion question, Long currentUserId) { + Long reactionCount = questionReactionRetriever.countByQuestionId(question.getId()); + Boolean isReacted = questionReactionRetriever.existsByQuestionAndMember( + question, + memberRetriever.findMemberById(currentUserId) + ); + + AnswerResponse answerResponse = null; + if (question.getAnswer() != null) { + MemberAnswer answer = question.getAnswer(); + Long answerReactionCount = answerReactionRetriever.countByAnswerId(answer.getId()); + Boolean isAnswerReacted = answerReactionRetriever.existsByAnswerAndMember( + answer, + memberRetriever.findMemberById(currentUserId) + ); + + answerResponse = new AnswerResponse( + answer.getId(), + answer.getContent(), + answerReactionCount, + isAnswerReacted, + answer.getCreatedAt().format(DATE_TIME_FORMATTER) + ); + } + + boolean isNew = question.getCreatedAt().isAfter(LocalDateTime.now().minusDays(NEW_QUESTION_DAYS)); + + InternalUserDetails askerInfo = platformService.getInternalUser(question.getAsker().getId()); + + Long askerId = question.getIsAnonymous() ? null : question.getAsker().getId(); + String askerName = question.getIsAnonymous() ? null : askerInfo.name(); + String askerProfileImage = question.getIsAnonymous() ? null : askerInfo.profileImage(); + + String askerLatestGeneration = askerInfo.soptActivities().stream() + .max((a1, a2) -> Integer.compare(a1.generation(), a2.generation())) + .map(activity -> activity.generation() + "기 " + activity.part()) + .orElse(null); + + AnonymousProfileVo anonymousProfile = null; + if (question.getIsAnonymous() && question.getAnonymousNickname() != null) { + String nickname = question.getAnonymousNickname().getNickname(); + String profileImgUrl = question.getAnonymousProfileImage() != null + ? question.getAnonymousProfileImage().getImageUrl() + : null; + anonymousProfile = new AnonymousProfileVo(nickname, profileImgUrl); + } + + boolean isMine = question.getAsker().getId().equals(currentUserId); + boolean isReceived = question.getReceiver().getId().equals(currentUserId); + + return new QuestionResponse( + question.getId(), + question.getContent(), + askerId, + askerName, + askerProfileImage, + askerLatestGeneration, + anonymousProfile, + question.getIsAnonymous(), + reactionCount, + isReacted, + question.hasAnswer(), + answerResponse, + question.getCreatedAt().format(DATE_TIME_FORMATTER), + isNew, + isMine, + isReceived + ); + } +} diff --git a/src/main/java/org/sopt/makers/internal/member/service/QuestionReactionModifier.java b/src/main/java/org/sopt/makers/internal/member/service/QuestionReactionModifier.java new file mode 100644 index 00000000..da4f81ec --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/service/QuestionReactionModifier.java @@ -0,0 +1,28 @@ +package org.sopt.makers.internal.member.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.makers.internal.member.domain.Member; +import org.sopt.makers.internal.member.domain.MemberQuestion; +import org.sopt.makers.internal.member.domain.QuestionReaction; +import org.sopt.makers.internal.member.repository.QuestionReactionRepository; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class QuestionReactionModifier { + + private final QuestionReactionRepository questionReactionRepository; + + public QuestionReaction createReaction(MemberQuestion question, Member member) { + return questionReactionRepository.save(QuestionReaction.builder() + .question(question) + .member(member) + .build()); + } + + @Transactional + public void deleteReaction(MemberQuestion question, Member member) { + questionReactionRepository.deleteByQuestionAndMember(question, member); + } +} diff --git a/src/main/java/org/sopt/makers/internal/member/service/QuestionReactionRetriever.java b/src/main/java/org/sopt/makers/internal/member/service/QuestionReactionRetriever.java new file mode 100644 index 00000000..889bd452 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/service/QuestionReactionRetriever.java @@ -0,0 +1,29 @@ +package org.sopt.makers.internal.member.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.makers.internal.member.domain.Member; +import org.sopt.makers.internal.member.domain.MemberQuestion; +import org.sopt.makers.internal.member.domain.QuestionReaction; +import org.sopt.makers.internal.member.repository.QuestionReactionRepository; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class QuestionReactionRetriever { + + private final QuestionReactionRepository questionReactionRepository; + + public Optional findByQuestionAndMember(MemberQuestion question, Member member) { + return questionReactionRepository.findByQuestionAndMember(question, member); + } + + public boolean existsByQuestionAndMember(MemberQuestion question, Member member) { + return questionReactionRepository.existsByQuestionAndMember(question, member); + } + + public long countByQuestionId(Long questionId) { + return questionReactionRepository.countByQuestionId(questionId); + } +} diff --git a/src/main/java/org/sopt/makers/internal/member/service/QuestionReportModifier.java b/src/main/java/org/sopt/makers/internal/member/service/QuestionReportModifier.java new file mode 100644 index 00000000..ee1b25bf --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/service/QuestionReportModifier.java @@ -0,0 +1,21 @@ +package org.sopt.makers.internal.member.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.makers.internal.member.domain.QuestionReport; +import org.sopt.makers.internal.member.repository.QuestionReportRepository; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class QuestionReportModifier { + + private final QuestionReportRepository questionReportRepository; + + public QuestionReport createReport(Long questionId, Long reporterId, String reason) { + return questionReportRepository.save(QuestionReport.builder() + .questionId(questionId) + .reporterId(reporterId) + .reason(reason) + .build()); + } +} diff --git a/src/main/java/org/sopt/makers/internal/member/service/QuestionReportRetriever.java b/src/main/java/org/sopt/makers/internal/member/service/QuestionReportRetriever.java new file mode 100644 index 00000000..49d7b560 --- /dev/null +++ b/src/main/java/org/sopt/makers/internal/member/service/QuestionReportRetriever.java @@ -0,0 +1,16 @@ +package org.sopt.makers.internal.member.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.makers.internal.member.repository.QuestionReportRepository; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class QuestionReportRetriever { + + private final QuestionReportRepository questionReportRepository; + + public boolean existsByQuestionIdAndReporterId(Long questionId, Long reporterId) { + return questionReportRepository.existsByQuestionIdAndReporterId(questionId, reporterId); + } +}