Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.project.dorumdorum.domain.notice.application.usecase;

import com.project.dorumdorum.domain.notice.application.dto.response.NoticeResponse;
import com.project.dorumdorum.domain.notice.domain.entity.Notice;
import com.project.dorumdorum.domain.notice.domain.repository.NoticeRepository;
import com.project.dorumdorum.global.exception.RestApiException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import static com.project.dorumdorum.global.exception.code.status.CommonErrorStatus._NOT_FOUND;

@Service
@RequiredArgsConstructor
public class LoadNoticeDetailUseCase {

private final NoticeRepository noticeRepository;

@Transactional(readOnly = true)
public NoticeResponse execute(String noticeNo) {
Notice notice = noticeRepository.findById(noticeNo)
.orElseThrow(() -> new RestApiException(_NOT_FOUND));

return new NoticeResponse(
notice.getNoticeNo(),
notice.getTitle(),
notice.getContent(),
notice.getWrittenDate(),
notice.getOriginalLink()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.project.dorumdorum.domain.notice.ui;

import com.project.dorumdorum.domain.notice.application.dto.response.NoticeResponse;
import com.project.dorumdorum.domain.notice.application.usecase.LoadNoticeDetailUseCase;
import com.project.dorumdorum.domain.notice.ui.spec.LoadNoticeDetailApiSpec;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class LoadNoticeDetailController implements LoadNoticeDetailApiSpec {

private final LoadNoticeDetailUseCase loadNoticeDetailUseCase;

@Override
public ResponseEntity<NoticeResponse> loadNotice(@PathVariable String noticeNo) {
return ResponseEntity.ok(loadNoticeDetailUseCase.execute(noticeNo));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.project.dorumdorum.domain.notice.ui.spec;

import com.project.dorumdorum.domain.notice.application.dto.response.NoticeResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@Tag(name = "Notice", description = "공지사항 API")
public interface LoadNoticeDetailApiSpec {

@Operation(summary = "공지사항 상세 조회", description = "공지사항을 단건 조회합니다.")
@GetMapping("/api/notices/{noticeNo}")
ResponseEntity<NoticeResponse> loadNotice(@PathVariable String noticeNo);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.project.dorumdorum.domain.notification.application.dto.request;

public record NotificationSettingRequest(
boolean enabled,
boolean applicants,
boolean applicantResult,
boolean chat,
boolean notice,
boolean schedule
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.project.dorumdorum.domain.notification.application.dto.response;

import com.project.dorumdorum.domain.notification.domain.entity.NotificationSetting;

public record NotificationSettingResponse(
boolean enabled,
boolean applicants,
boolean applicantResult,
boolean chat,
boolean notice,
boolean schedule
) {
public static NotificationSettingResponse from(NotificationSetting setting) {
return new NotificationSettingResponse(
setting.isEnabled(),
setting.isApplicants(),
setting.isApplicantResult(),
setting.isChat(),
setting.isNotice(),
setting.isSchedule()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.project.dorumdorum.domain.notification.application.usecase;

import com.project.dorumdorum.domain.notification.application.dto.response.NotificationSettingResponse;
import com.project.dorumdorum.domain.notification.domain.entity.NotificationSetting;
import com.project.dorumdorum.domain.notification.domain.repository.NotificationSettingRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class LoadNotificationSettingUseCase {

private final NotificationSettingRepository notificationSettingRepository;

@Transactional(readOnly = true)
public NotificationSettingResponse execute(String userNo) {
NotificationSetting setting = notificationSettingRepository.findByUserNo(userNo)
.orElseGet(() -> NotificationSetting.defaultFor(userNo));
return NotificationSettingResponse.from(setting);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.project.dorumdorum.domain.notification.application.usecase;

import com.project.dorumdorum.domain.notification.application.dto.request.NotificationSettingRequest;
import com.project.dorumdorum.domain.notification.application.dto.response.NotificationSettingResponse;
import com.project.dorumdorum.domain.notification.domain.entity.NotificationSetting;
import com.project.dorumdorum.domain.notification.domain.repository.NotificationSettingRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class UpdateNotificationSettingUseCase {

private final NotificationSettingRepository notificationSettingRepository;

@Transactional
public NotificationSettingResponse execute(String userNo, NotificationSettingRequest request) {
NotificationSetting setting = notificationSettingRepository.findByUserNo(userNo)
.orElseGet(() -> NotificationSetting.defaultFor(userNo));
setting.update(request);
NotificationSetting saved = notificationSettingRepository.save(setting);
return NotificationSettingResponse.from(saved);
}
Comment on lines +17 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

최초 설정 저장 시 경합 조건(TOCTOU)이 발생할 수 있습니다.

findByUserNo 조회와 save 사이에 동기화 장치가 없습니다. user_nonotification_settings 테이블에서 UNIQUE 제약을 가집니다.

사용자가 설정을 아직 저장하지 않은 상태에서 두 개의 PUT 요청이 거의 동시에 들어오면, 두 요청 모두 findByUserNo에서 빈 결과를 받습니다. 그 결과 두 요청이 각각 신규 엔티티를 생성해 저장을 시도하고, 유니크 제약 위반으로 한쪽 요청이 예외와 함께 실패합니다.

저장 시 제약 위반 예외를 잡아 재조회 후 재적용하는 방식으로 이 경합을 방지하는 것을 제안합니다.

🔧 제안하는 수정
+import org.springframework.dao.DataIntegrityViolationException;
+
 `@Transactional`
 public NotificationSettingResponse execute(String userNo, NotificationSettingRequest request) {
     NotificationSetting setting = notificationSettingRepository.findByUserNo(userNo)
             .orElseGet(() -> NotificationSetting.defaultFor(userNo));
     setting.update(request);
-    NotificationSetting saved = notificationSettingRepository.save(setting);
-    return NotificationSettingResponse.from(saved);
+    try {
+        NotificationSetting saved = notificationSettingRepository.saveAndFlush(setting);
+        return NotificationSettingResponse.from(saved);
+    } catch (DataIntegrityViolationException e) {
+        NotificationSetting existing = notificationSettingRepository.findByUserNo(userNo)
+                .orElseThrow(() -> e);
+        existing.update(request);
+        return NotificationSettingResponse.from(notificationSettingRepository.save(existing));
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/project/dorumdorum/domain/notification/application/usecase/UpdateNotificationSettingUseCase.java`
around lines 17 - 24, Update UpdateNotificationSettingUseCase.execute to handle
concurrent first-time creation: retain the existing lookup and update flow, but
catch the unique-constraint save failure, re-fetch the setting by userNo,
reapply the request, and save the existing entity before creating the response.
Ensure unrelated persistence exceptions still propagate.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.project.dorumdorum.domain.notification.domain.entity;

import com.project.dorumdorum.domain.notification.application.dto.request.NotificationSettingRequest;
import com.project.dorumdorum.global.common.BaseEntity;
import io.hypersistence.utils.hibernate.id.Tsid;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@Table(name = "notification_settings")
public class NotificationSetting extends BaseEntity {

@Id
@Tsid
private String notificationSettingNo;

@Column(nullable = false, unique = true)
private String userNo;

@Column(nullable = false)
private boolean enabled;

@Column(nullable = false)
private boolean applicants;

@Column(nullable = false)
private boolean applicantResult;

@Column(nullable = false)
private boolean chat;

@Column(nullable = false)
private boolean notice;

@Column(nullable = false)
private boolean schedule;

public static NotificationSetting defaultFor(String userNo) {
return NotificationSetting.builder()
.userNo(userNo)
.enabled(true)
.applicants(true)
.applicantResult(true)
.chat(true)
.notice(true)
.schedule(false)
.build();
}

public void update(NotificationSettingRequest request) {
this.enabled = request.enabled();
this.applicants = request.applicants();
this.applicantResult = request.applicantResult();
this.chat = request.chat();
this.notice = request.notice();
this.schedule = request.schedule();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import com.project.dorumdorum.domain.notification.domain.entity.Device;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;
Expand All @@ -13,5 +16,8 @@ public interface NotificationDeviceRepository extends JpaRepository<Device, Stri
List<Device> findByUserNo(String userNo);

List<Device> findByFcmTokenIn(List<String> fcmTokens);
}

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE Device d SET d.deletedAt = CURRENT_TIMESTAMP WHERE d.userNo = :userNo AND d.deletedAt IS NULL")
void deleteAllByUserNo(@Param("userNo") String userNo);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@

import com.project.dorumdorum.domain.notification.domain.entity.Notification;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Optional;

public interface NotificationRepository extends JpaRepository<Notification, String>, NotificationQueryRepository {

Optional<Notification> findByNotificationNoAndRecipientNo(String notificationNo, String recipientNo);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE Notification n SET n.deletedAt = CURRENT_TIMESTAMP WHERE n.recipientNo = :userNo AND n.deletedAt IS NULL")
void deleteAllByRecipientNo(@Param("userNo") String userNo);
Comment on lines +15 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 8 \
  'NotificationSettingRepository|notification_settings|deleteAllByUserNo|deleteAllByRecipientNo' \
  src/main/java src/main/resources

Repository: DorumDorum/BE

Length of output: 22575


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- schema notification_settings ---\n'
sed -n '63,90p' src/main/resources/schema.sql

printf '\n--- entity NotificationSetting ---\n'
cat -n src/main/java/com/project/dorumdorum/domain/notification/domain/entity/NotificationSetting.java

printf '\n--- repository NotificationSettingRepository ---\n'
cat -n src/main/java/com/project/dorumdorum/domain/notification/domain/repository/NotificationSettingRepository.java

printf '\n--- DeleteAccountUseCase imports and body ---\n'
cat -n src/main/java/com/project/dorumdorum/domain/user/application/usecase/DeleteAccountUseCase.java | sed -n '1,70p'

printf '\n--- search for foreign key/cascade in JPA config and mapping files ---\n'
rg -n "`@JoinColumn`|`@ManyToOne`|onDelete|foreignKey|cascade" src/main/java src/main/resources --glob '!**/*.jar' | sed -n '1,220p'

Repository: DorumDorum/BE

Length of output: 242


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- schema notification_settings ---'
sed -n '63,90p' src/main/resources/schema.sql

printf '%s\n' ''
printf '%s\n' '--- entity NotificationSetting ---'
cat -n src/main/java/com/project/dorumdorum/domain/notification/domain/entity/NotificationSetting.java

printf '%s\n' ''
printf '%s\n' '--- repository NotificationSettingRepository ---'
cat -n src/main/java/com/project/dorumdorum/domain/notification/domain/repository/NotificationSettingRepository.java

printf '%s\n' ''
printf '%s\n' '--- DeleteAccountUseCase imports and body ---'
cat -n src/main/java/com/project/dorumdorum/domain/user/application/usecase/DeleteAccountUseCase.java | sed -n '1,70p'

printf '%s\n' ''
printf '%s\n' '--- relational mapping/cascade clues ---'
rg -n -C 3 '`@JoinColumn`|`@ManyToOne`|onDelete|`@JoinColumnOptions`|cascade|`@FieldDefault`|DEFAULT|notification_settings|user_no' src/main/java src/main/resources --glob '!**/*.jar' | sed -n '1,240p'

printf '%s\n' ''
printf '%s\n' '--- deterministic check: delete handlers for notification user owner column ---'
python3 - <<'PY'
from pathlib import Path
import re
files = {
    'schema': Path('src/main/resources/schema.sql').read_text(),
    'entity': Path('src/main/java/com/project/dorumdorum/domain/notification/domain/entity/NotificationSetting.java').read_text(),
    'repo': Path('src/main/java/com/project/dorumdorum/domain/notification/domain/repository/NotificationSettingRepository.java').read_text(),
    'delete': Path('src/main/java/com/project/dorumdorum/domain/user/application/usecase/DeleteAccountUseCase.java').read_text(),
}
checks = {
    'notices_table_is_user_owned': bool(re.search(r'CREATE TABLE IF NOT EXISTS notification_settings[\s\S]*?user_no', files['schema'])),
    'no_db_fk_with_on_delete_cascade_in_schema': not re.search(r'FOREIGN KEY[\s\S]*?notification_settings|ON DELETE CASCADE', files['schema']),
    'setting_has_deletion_field_like_deleted_at': bool(re.search(r'(`@Column`|deleted_at|deletedAt)', files['entity'], re.I)),
    'repository_has_soft_delete_or_delete_all_by_userNo': bool(re.search(r'deleteAll|DELETE|DELETE_ALL|deletedAt', files['repo'], re.I)),
    'delete_account_calls_setting_delete': bool(re.search(r'NotificationSettingRepository|notificationSettingRepository|notification_settings', files['delete'], re.I)),
    'jpa_reference_to_notification_setting_entity': bool(re.search(r'Table\(name = "notification_settings"\)|class NotificationSetting', files['entity'])),
}
for name, value in checks.items():
    print(f'{name}: {value}')
PY

Repository: DorumDorum/BE

Length of output: 26625


notification_settings 탈퇴 처리 계약을 명시해 주세요.

notification_settingsuser_no를 가진 사용자 소유 설정처럼 보이지만, 현재 DeleteAccountUseCase는 이 테이블의 soft delete 또는 cascade 처리를 하지 않습니다. NotificationSettingRepository에 삭제/soft delete 메서드를 추가하고 DeleteAccountUseCase에서 호출하거나, 해당 사용자 설정을 보존하도록 정책과 테스트를 명시해 주세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/project/dorumdorum/domain/notification/domain/repository/NotificationRepository.java`
around lines 15 - 17, DeleteAccountUseCase의 회원 탈퇴 흐름에서 notification_settings의 처리
정책을 명시하세요. NotificationSettingRepository에 사용자별 삭제 또는 soft delete 메서드를 추가해
DeleteAccountUseCase에서 호출하고, 해당 정책을 검증하는 테스트를 추가하거나, 설정을 보존하는 정책이라면 그 계약과 테스트를
명확히 반영하세요.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.project.dorumdorum.domain.notification.domain.repository;

import com.project.dorumdorum.domain.notification.domain.entity.NotificationSetting;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface NotificationSettingRepository extends JpaRepository<NotificationSetting, String> {
Optional<NotificationSetting> findByUserNo(String userNo);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.project.dorumdorum.domain.notification.ui;

import com.project.dorumdorum.domain.notification.application.dto.request.NotificationSettingRequest;
import com.project.dorumdorum.domain.notification.application.dto.response.NotificationSettingResponse;
import com.project.dorumdorum.domain.notification.application.usecase.LoadNotificationSettingUseCase;
import com.project.dorumdorum.domain.notification.application.usecase.UpdateNotificationSettingUseCase;
import com.project.dorumdorum.domain.notification.ui.spec.NotificationSettingApiSpec;
import com.project.dorumdorum.global.annotation.CurrentUser;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class NotificationSettingController implements NotificationSettingApiSpec {

private final LoadNotificationSettingUseCase loadNotificationSettingUseCase;
private final UpdateNotificationSettingUseCase updateNotificationSettingUseCase;

@Override
public ResponseEntity<NotificationSettingResponse> load(@CurrentUser String userNo) {
return ResponseEntity.ok(loadNotificationSettingUseCase.execute(userNo));
}

@Override
public ResponseEntity<NotificationSettingResponse> update(
@CurrentUser String userNo,
@RequestBody NotificationSettingRequest request
) {
return ResponseEntity.ok(updateNotificationSettingUseCase.execute(userNo, request));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.project.dorumdorum.domain.notification.ui.spec;

import com.project.dorumdorum.domain.notification.application.dto.request.NotificationSettingRequest;
import com.project.dorumdorum.domain.notification.application.dto.response.NotificationSettingResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;

@Tag(name = "Notification")
public interface NotificationSettingApiSpec {

@Operation(summary = "내 알림 설정 조회 API", description = "사용자별 알림 수신 설정을 조회합니다. 저장된 설정이 없으면 기본값을 반환합니다.")
@GetMapping("/api/users/me/notification-settings")
ResponseEntity<NotificationSettingResponse> load(@Parameter(hidden = true) String userNo);

@Operation(summary = "내 알림 설정 저장 API", description = "사용자별 알림 수신 설정을 저장합니다.")
@PutMapping("/api/users/me/notification-settings")
ResponseEntity<NotificationSettingResponse> update(
@Parameter(hidden = true) String userNo,
@RequestBody NotificationSettingRequest request
);
}
Loading
Loading