From d664ec29ae5d141b890864e36ce99936c626d1bf Mon Sep 17 00:00:00 2001 From: eeeeeaaan Date: Sun, 4 Oct 2026 14:36:26 +0900 Subject: [PATCH 01/15] =?UTF-8?q?[#484]=20sync=20=EA=B0=B1=EC=8B=A0?= =?UTF-8?q?=EB=90=9C=20=ED=8C=8C=EB=93=9C=EB=A7=8C=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EA=B0=80=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manifest | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest b/manifest index f1bfe8d5..b79b095e 160000 --- a/manifest +++ b/manifest @@ -1 +1 @@ -Subproject commit f1bfe8d50363480d9fe259ac612bf3acce661133 +Subproject commit b79b095eaa6825abd46b4acb862231c6e7e9e4d5 From 48996e0f237850dc3c6ab9747ff25f87c44662c2 Mon Sep 17 00:00:00 2001 From: BAEK0111 Date: Sun, 4 Oct 2026 16:39:55 +0900 Subject: [PATCH 02/15] =?UTF-8?q?[FIX/#488]=20config=20=EC=BB=A4=EB=B0=8B?= =?UTF-8?q?=20=ED=8F=AC=EC=9D=B8=ED=84=B0=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dev 환경 JWT secret을 prod와 분리한 config 커밋(418b384)으로 포인터 갱신 - #469 config 변경(f023fea) 포함 Co-Authored-By: Claude Opus 5.5 --- config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config b/config index 683254a3..418b3849 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 683254a3637288ec6ee365e7d03780333e562500 +Subproject commit 418b3849707af71faecda371a90787d9add3bf0b From f69f52c372d3b06c5950ccbae64a8afe8b744118 Mon Sep 17 00:00:00 2001 From: BAEK0111 Date: Mon, 5 Oct 2026 17:28:50 +0900 Subject: [PATCH 03/15] =?UTF-8?q?[FEAT/#443]=20Prod=20DB=20=E2=86=92=20Dev?= =?UTF-8?q?=20DB=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=B0=B1=EC=98=A4?= =?UTF-8?q?=ED=94=BC=EC=8A=A4=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /backoffice/dev-sync 추가 (@BackofficeAudited) - 현재 스키마를 dev-sync.target-schema로 테이블/시퀀스/FK/인덱스 포함 통째로 복사 - 대상이 비어 있거나 현재 스키마와 같으면 INVALID_DEV_SYNC_TARGET 예외 - 복사 후 device_token, notification_outbox 비움 - config 포인터 갱신 (prod dev-sync.target-schema 설정) Co-Authored-By: Claude Opus 5.5 --- config | 2 +- .../BackofficeDevSyncController.java | 47 +++++++++ .../dto/BackofficeDevSyncResponseDTO.java | 18 ++++ .../service/BackofficeDevSyncService.java | 99 +++++++++++++++++++ .../apiPayload/code/status/ErrorStatus.java | 1 + .../BackofficeDevSyncServiceTest.java | 45 +++++++++ 6 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/assu/server/domain/backoffice/controller/BackofficeDevSyncController.java create mode 100644 src/main/java/com/assu/server/domain/backoffice/dto/BackofficeDevSyncResponseDTO.java create mode 100644 src/main/java/com/assu/server/domain/backoffice/service/BackofficeDevSyncService.java create mode 100644 src/test/java/com/assu/server/backoffice/BackofficeDevSyncServiceTest.java diff --git a/config b/config index 418b3849..3c79e0fb 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 418b3849707af71faecda371a90787d9add3bf0b +Subproject commit 3c79e0fb47b554890814383fce34fb95ddae0ea9 diff --git a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeDevSyncController.java b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeDevSyncController.java new file mode 100644 index 00000000..a6f4bf7d --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeDevSyncController.java @@ -0,0 +1,47 @@ +package com.assu.server.domain.backoffice.controller; + +import com.assu.server.domain.backoffice.annotation.BackofficeAudited; +import com.assu.server.domain.backoffice.dto.BackofficeDevSyncResponseDTO; +import com.assu.server.domain.backoffice.service.BackofficeDevSyncService; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Backoffice", description = "백오피스 운영 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/backoffice/dev-sync") +@PreAuthorize("hasRole('BACKOFFICE')") +public class BackofficeDevSyncController { + + private final BackofficeDevSyncService backofficeDevSyncService; + + @BackofficeAudited(action = "DEV_SYNC") + @Operation( + summary = "Prod DB → Dev DB 동기화 API", + description = "# [v1.0 (2026-10-04)]\n" + + "- 현재 서버의 DB 스키마를 `dev-sync.target-schema` 스키마로 통째로 복사합니다.\n" + + "- 대상 스키마의 기존 테이블은 모두 삭제되고, 원본과 동일한 구조/데이터로 다시 생성됩니다.\n" + + "- 복사 후 대상 스키마의 `device_token`, `notification_outbox`는 비웁니다.\n" + + "- **완료 후 dev 서버를 재시작해야** develop에만 있는 컬럼이 `ddl-auto`로 다시 생성됩니다.\n" + + "- `BACKOFFICE` 역할 및 `aud=backoffice` JWT가 필요합니다.\n\n" + + "**Response:**\n" + + "- 성공 시 200(OK)과 `BackofficeDevSyncResponseDTO` 반환\n" + + " - `sourceSchema` (String): 복사 원본 스키마\n" + + " - `targetSchema` (String): 복사 대상 스키마\n" + + " - `copiedTables` (List): 복사한 테이블 목록\n" + + "- 401(UNAUTHORIZED): 인증되지 않았거나 audience 불일치\n" + + "- 403(FORBIDDEN): BACKOFFICE 권한 없음\n" + + "- 500(INTERNAL_SERVER_ERROR): 대상 스키마 미설정 또는 원본과 동일" + ) + @PostMapping + public BaseResponse syncToDev() { + return BaseResponse.onSuccess(SuccessStatus._OK, backofficeDevSyncService.syncToDev()); + } +} diff --git a/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeDevSyncResponseDTO.java b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeDevSyncResponseDTO.java new file mode 100644 index 00000000..a29c4830 --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeDevSyncResponseDTO.java @@ -0,0 +1,18 @@ +package com.assu.server.domain.backoffice.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +public record BackofficeDevSyncResponseDTO( + + @Schema(description = "복사 원본 스키마", example = "assu_db") + String sourceSchema, + + @Schema(description = "복사 대상 스키마", example = "assu_dev_db") + String targetSchema, + + @Schema(description = "복사한 테이블 목록", example = "[\"member\", \"student\"]") + List copiedTables +) { +} diff --git a/src/main/java/com/assu/server/domain/backoffice/service/BackofficeDevSyncService.java b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeDevSyncService.java new file mode 100644 index 00000000..0200cd04 --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeDevSyncService.java @@ -0,0 +1,99 @@ +package com.assu.server.domain.backoffice.service; + +import com.assu.server.domain.backoffice.dto.BackofficeDevSyncResponseDTO; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.ConnectionCallback; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +// DDL은 암묵적 커밋이 발생해 트랜잭션으로 묶을 수 없으므로 @Transactional을 적용하지 않는다. +@Service +@RequiredArgsConstructor +public class BackofficeDevSyncService { + + private static final List TABLES_TO_CLEAR = List.of("device_token", "notification_outbox"); + + private final JdbcTemplate jdbcTemplate; + + @Value("${dev-sync.target-schema:}") + private String targetSchema; + + public BackofficeDevSyncResponseDTO syncToDev() { + String sourceSchema = jdbcTemplate.queryForObject("SELECT DATABASE()", String.class); + if (targetSchema.isBlank() || targetSchema.equals(sourceSchema)) { + throw new GeneralException(ErrorStatus.INVALID_DEV_SYNC_TARGET); + } + + List sourceTables = findObjects(sourceSchema, "BASE TABLE"); + List sourceSequences = findObjects(sourceSchema, "SEQUENCE"); + List targetObjects = new ArrayList<>(findObjects(targetSchema, "BASE TABLE")); + targetObjects.addAll(findObjects(targetSchema, "SEQUENCE")); + + jdbcTemplate.execute((ConnectionCallback) connection -> { + try (Statement statement = connection.createStatement()) { + statement.execute("SET FOREIGN_KEY_CHECKS = 0"); + try { + for (String object : targetObjects) { + statement.execute("DROP TABLE " + qualify(targetSchema, object)); + } + for (String table : sourceTables) { + statement.execute(toTargetCreateTable(statement, sourceSchema, table)); + statement.execute("INSERT INTO " + qualify(targetSchema, table) + " SELECT * FROM " + qualify(sourceSchema, table)); + } + for (String sequence : sourceSequences) { + statement.execute("CREATE TABLE " + qualify(targetSchema, sequence) + " LIKE " + qualify(sourceSchema, sequence)); + statement.execute("INSERT INTO " + qualify(targetSchema, sequence) + " SELECT * FROM " + qualify(sourceSchema, sequence)); + } + for (String table : TABLES_TO_CLEAR) { + if (sourceTables.contains(table)) { + statement.execute("TRUNCATE TABLE " + qualify(targetSchema, table)); + } + } + } finally { + statement.execute("SET FOREIGN_KEY_CHECKS = 1"); + } + } + return null; + }); + + List copiedObjects = new ArrayList<>(sourceTables); + copiedObjects.addAll(sourceSequences); + return new BackofficeDevSyncResponseDTO(sourceSchema, targetSchema, copiedObjects); + } + + private List findObjects(String schema, String tableType) { + return jdbcTemplate.queryForList( + "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = ?", + String.class, + schema, + tableType + ); + } + + // CREATE TABLE LIKE는 FK를 복사하지 않으므로 원본 DDL을 사용한다. 스키마 없는 FK 참조는 생성되는 테이블의 스키마로 해석된다. + private String toTargetCreateTable(Statement statement, String sourceSchema, String table) throws SQLException { + String ddl; + try (ResultSet resultSet = statement.executeQuery("SHOW CREATE TABLE " + qualify(sourceSchema, table))) { + resultSet.next(); + ddl = resultSet.getString(2); + } + String header = "CREATE TABLE `" + table + "`"; + if (!ddl.startsWith(header)) { + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + return "CREATE TABLE " + qualify(targetSchema, table) + ddl.substring(header.length()); + } + + private String qualify(String schema, String table) { + return "`" + schema + "`.`" + table + "`"; + } +} diff --git a/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java index 75de1f7a..3a36c204 100644 --- a/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java @@ -50,6 +50,7 @@ public enum ErrorStatus implements BaseErrorCode { BACKOFFICE_BOOTSTRAP_DISABLED(HttpStatus.BAD_REQUEST, "BACKOFFICE4001", "백오피스 bootstrap이 비활성화되어 있습니다."), LAST_BACKOFFICE_OPERATOR(HttpStatus.BAD_REQUEST, "BACKOFFICE4002", "마지막 백오피스 운영자는 비활성화할 수 없습니다."), BACKOFFICE_USE_DEDICATED_LOGIN(HttpStatus.FORBIDDEN, "BACKOFFICE4003", "백오피스 계정은 /auth/backoffice/login을 사용해야 합니다."), + INVALID_DEV_SYNC_TARGET(HttpStatus.INTERNAL_SERVER_ERROR, "BACKOFFICE5001", "dev 동기화 대상 스키마가 설정되지 않았거나 현재 스키마와 같습니다."), RECEIVER_ID_REQUIRED(HttpStatus.BAD_REQUEST, "PUSH_4001", "INDIVIDUAL 타입일 경우 receiverId는 필수입니다."), NO_SUCH_ADMIN(HttpStatus.NOT_FOUND,"MEMBER_4002","존재하지 않는 admin ID 입니다."), diff --git a/src/test/java/com/assu/server/backoffice/BackofficeDevSyncServiceTest.java b/src/test/java/com/assu/server/backoffice/BackofficeDevSyncServiceTest.java new file mode 100644 index 00000000..d1d263b9 --- /dev/null +++ b/src/test/java/com/assu/server/backoffice/BackofficeDevSyncServiceTest.java @@ -0,0 +1,45 @@ +package com.assu.server.backoffice; + +import com.assu.server.domain.backoffice.service.BackofficeDevSyncService; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.GeneralException; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.ConnectionCallback; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BackofficeDevSyncServiceTest { + + @InjectMocks + private BackofficeDevSyncService backofficeDevSyncService; + + @Mock + private JdbcTemplate jdbcTemplate; + + @ParameterizedTest + @ValueSource(strings = {"", "assu_db"}) + void syncToDev_whenTargetIsBlankOrSameAsSource_thenThrowsWithoutTouchingSchema(String targetSchema) { + // given + ReflectionTestUtils.setField(backofficeDevSyncService, "targetSchema", targetSchema); + when(jdbcTemplate.queryForObject("SELECT DATABASE()", String.class)).thenReturn("assu_db"); + + // when & then + assertThatThrownBy(() -> backofficeDevSyncService.syncToDev()) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(ErrorStatus.INVALID_DEV_SYNC_TARGET); + verify(jdbcTemplate, never()).execute(any(ConnectionCallback.class)); + } +} From a19d85a85d4de506de5fc5b763bd5887b166a7bf Mon Sep 17 00:00:00 2001 From: eeeeeaaan Date: Mon, 5 Oct 2026 18:24:41 +0900 Subject: [PATCH 04/15] =?UTF-8?q?[#484]=20=EB=B0=B1=EC=98=A4=ED=94=BC?= =?UTF-8?q?=EC=8A=A4=20=ED=8C=8C=ED=8A=B8=EB=84=88=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20profileUrl=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BackofficeMemberController.java | 2 +- .../backoffice/dto/BackofficeMemberDetailDTO.java | 4 ++-- .../dto/BackofficePartnerProfileDetailDTO.java | 8 +++++--- .../service/BackofficeMemberServiceImpl.java | 10 +++++++++- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeMemberController.java b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeMemberController.java index 34cb4a3c..6567a0a7 100644 --- a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeMemberController.java +++ b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeMemberController.java @@ -95,7 +95,7 @@ public BaseResponse> listDeletedMemb " - `base` (BackofficeMemberBaseDetailDTO): 공통 회원 정보\n" + " - `student` (BackofficeStudentProfileDetailDTO): STUDENT일 때만\n" + " - `admin` (BackofficeAdminProfileDetailDTO): ADMIN일 때만\n" + - " - `partner` (BackofficePartnerProfileDetailDTO): PARTNER일 때만\n" + + " - `partner` (BackofficePartnerProfileDetailDTO): PARTNER일 때만 (프로필 이미지 presigned URL `profileImageUrl` 포함, 없으면 null)\n" + "- 401(UNAUTHORIZED): 인증되지 않았거나 audience 불일치\n" + "- 403(FORBIDDEN): BACKOFFICE 권한 없음\n" + "- 404(NOT_FOUND): 존재하지 않는 회원 ID" diff --git a/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeMemberDetailDTO.java b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeMemberDetailDTO.java index e11a651c..670dab87 100644 --- a/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeMemberDetailDTO.java +++ b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeMemberDetailDTO.java @@ -30,12 +30,12 @@ public static BackofficeMemberDetailDTO fromAdmin(Member member) { ); } - public static BackofficeMemberDetailDTO fromPartner(Member member) { + public static BackofficeMemberDetailDTO fromPartner(Member member, String profileImageUrl) { return new BackofficeMemberDetailDTO( BackofficeMemberBaseDetailDTO.from(member), null, null, - BackofficePartnerProfileDetailDTO.from(member) + BackofficePartnerProfileDetailDTO.from(member, profileImageUrl) ); } } diff --git a/src/main/java/com/assu/server/domain/backoffice/dto/BackofficePartnerProfileDetailDTO.java b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficePartnerProfileDetailDTO.java index b9c606fa..14b54909 100644 --- a/src/main/java/com/assu/server/domain/backoffice/dto/BackofficePartnerProfileDetailDTO.java +++ b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficePartnerProfileDetailDTO.java @@ -14,9 +14,10 @@ public record BackofficePartnerProfileDetailDTO( @Schema(description = "주소") String address, @Schema(description = "상세 주소") String detailAddress, @Schema(description = "사업자등록증 검증 여부") Boolean isLicenseVerified, - @Schema(description = "사업자등록증 검증 시각") LocalDateTime licenseVerifiedAt + @Schema(description = "사업자등록증 검증 시각") LocalDateTime licenseVerifiedAt, + @Schema(description = "프로필 이미지 presigned URL (없으면 null)", example = "https://bucket.s3.amazonaws.com/members/1/profile/image.png?X-Amz-Signature=...") String profileImageUrl ) { - public static BackofficePartnerProfileDetailDTO from(Member member) { + public static BackofficePartnerProfileDetailDTO from(Member member, String profileImageUrl) { Partner partner = member.getPartnerProfile(); if (partner == null) { return null; @@ -31,7 +32,8 @@ public static BackofficePartnerProfileDetailDTO from(Member member) { partner.getAddress(), partner.getDetailAddress(), partner.getIsLicenseVerified(), - partner.getLicenseVerifiedAt() + partner.getLicenseVerifiedAt(), + profileImageUrl ); } } diff --git a/src/main/java/com/assu/server/domain/backoffice/service/BackofficeMemberServiceImpl.java b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeMemberServiceImpl.java index c3bb8d90..5cb8b434 100644 --- a/src/main/java/com/assu/server/domain/backoffice/service/BackofficeMemberServiceImpl.java +++ b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeMemberServiceImpl.java @@ -247,11 +247,19 @@ private BackofficeMemberDetailDTO toMemberDetail(Member member) { return switch (member.getRole()) { case STUDENT -> BackofficeMemberDetailDTO.fromStudent(member); case ADMIN -> BackofficeMemberDetailDTO.fromAdmin(member); - case PARTNER -> BackofficeMemberDetailDTO.fromPartner(member); + case PARTNER -> BackofficeMemberDetailDTO.fromPartner(member, resolveProfileImageUrl(member)); case BACKOFFICE -> throw new CustomAuthException(ErrorStatus.NO_SUCH_MEMBER); }; } + private String resolveProfileImageUrl(Member member) { + String key = member.getProfileUrl(); + if (key == null || key.isBlank()) { + return null; + } + return amazonS3Manager.generatePresignedUrl(key); + } + private Partner findPartner(Long memberId) { Member member = memberRepository.findPartnerWithProfileById(memberId) .orElseThrow(() -> new CustomAuthException(ErrorStatus.NO_SUCH_PARTNER)); From 21add68c0089a277d58c7296ee506dc9e005733a Mon Sep 17 00:00:00 2001 From: MiN <81948815+leesumin0526@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:38:01 +0900 Subject: [PATCH 05/15] =?UTF-8?q?[FEAT/#487]=20Inquiry=20=C2=B7=20Student?= =?UTF-8?q?=20usable=20=C2=B7=20Device=20Token=20=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=ED=85=80=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=A9=94=ED=8A=B8?= =?UTF-8?q?=EB=A6=AD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InquiryServiceImpl에 inquiry.created Counter 추가 (문의 생성 시 증가) - StudentServiceImpl에 student.usable.queried Counter 추가 (사용 가능 제휴 조회 시 증가) - DeviceTokenServiceImpl에 device.token.registered Counter 추가 (신규 토큰 등록 시 증가) - DeviceTokenServiceImpl에 device.token.deleted Counter 추가 (토큰 비활성화 시 증가) - 쓰기 트랜잭션 내 카운터는 afterCommit 패턴 적용으로 롤백된 작업 미집계 --- .../service/DeviceTokenServiceImpl.java | 21 ++++++++++++++++++- .../inquiry/service/InquiryServiceImpl.java | 19 +++++++++++++++++ .../student/service/StudentServiceImpl.java | 3 +++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/assu/server/domain/deviceToken/service/DeviceTokenServiceImpl.java b/src/main/java/com/assu/server/domain/deviceToken/service/DeviceTokenServiceImpl.java index d37b3d17..379c9e28 100644 --- a/src/main/java/com/assu/server/domain/deviceToken/service/DeviceTokenServiceImpl.java +++ b/src/main/java/com/assu/server/domain/deviceToken/service/DeviceTokenServiceImpl.java @@ -7,10 +7,13 @@ import com.assu.server.global.apiPayload.code.status.ErrorStatus; import com.assu.server.global.exception.DatabaseException; import com.assu.server.global.exception.GeneralException; - +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.util.List; import java.util.Optional; @@ -21,6 +24,7 @@ public class DeviceTokenServiceImpl implements DeviceTokenService { private final DeviceTokenRepository deviceTokenRepository; private final MemberRepository memberRepository; + private final MeterRegistry meterRegistry; @Override public Long register(String token, Long memberId) { @@ -52,6 +56,7 @@ public Long register(String token, Long memberId) { .build(); deviceTokenRepository.save(newToken); + incrementAfterCommit(meterRegistry.counter("device.token.registered")); return newToken.getId(); } @@ -63,11 +68,25 @@ public void unregister(Long tokenId, Long memberId) { throw new DatabaseException(ErrorStatus.DEVICE_TOKEN_NOT_OWNED); } deviceToken.setActive(false); + incrementAfterCommit(meterRegistry.counter("device.token.deleted")); }, () -> { throw new DatabaseException(ErrorStatus.DEVICE_TOKEN_NOT_FOUND); }); } + private void incrementAfterCommit(Counter counter) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + counter.increment(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + counter.increment(); + } + }); + } + @Override public void deactivateTokens(List invalidTokens) { List invalidEntities = deviceTokenRepository.findAllByTokenIn(invalidTokens); diff --git a/src/main/java/com/assu/server/domain/inquiry/service/InquiryServiceImpl.java b/src/main/java/com/assu/server/domain/inquiry/service/InquiryServiceImpl.java index bbedcbb5..d7e8eddc 100644 --- a/src/main/java/com/assu/server/domain/inquiry/service/InquiryServiceImpl.java +++ b/src/main/java/com/assu/server/domain/inquiry/service/InquiryServiceImpl.java @@ -11,10 +11,14 @@ import com.assu.server.global.apiPayload.code.status.ErrorStatus; import com.assu.server.global.exception.DatabaseException; import com.assu.server.global.exception.GeneralException; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; @Service @Transactional @@ -23,6 +27,7 @@ public class InquiryServiceImpl implements InquiryService { private final InquiryRepository inquiryRepository; private final MemberRepository memberRepository; + private final MeterRegistry meterRegistry; /** 문의 등록 */ @Override @@ -33,9 +38,23 @@ public Long create(InquiryCreateRequestDTO inquiryCreateRequestDTO, Long memberI Inquiry inquiry = Inquiry.create(member, inquiryCreateRequestDTO); inquiryRepository.save(inquiry); + incrementAfterCommit(meterRegistry.counter("inquiry.created")); return inquiry.getId(); } + private void incrementAfterCommit(Counter counter) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + counter.increment(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + counter.increment(); + } + }); + } + /** 문의 내역 조회 (status=all|waiting|answered) */ @Override @Transactional(readOnly = true) diff --git a/src/main/java/com/assu/server/domain/student/service/StudentServiceImpl.java b/src/main/java/com/assu/server/domain/student/service/StudentServiceImpl.java index d7082999..db4582c1 100644 --- a/src/main/java/com/assu/server/domain/student/service/StudentServiceImpl.java +++ b/src/main/java/com/assu/server/domain/student/service/StudentServiceImpl.java @@ -48,6 +48,7 @@ import com.assu.server.global.apiPayload.code.status.ErrorStatus; import com.assu.server.global.exception.DatabaseException; import com.assu.server.infra.s3.AmazonS3Manager; +import io.micrometer.core.instrument.MeterRegistry; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -72,6 +73,7 @@ public class StudentServiceImpl implements StudentService { private final HomeCurationItemRepository homeCurationItemRepository; private final StoreRepository storeRepository; private final AmazonS3Manager amazonS3Manager; + private final MeterRegistry meterRegistry; @Override @Transactional public StudentResponseDTO.CheckStampResponseDTO getStamp(Long memberId) { @@ -211,6 +213,7 @@ public List getUsablePartnership(Long m .build(); }).toList(); + meterRegistry.counter("student.usable.queried").increment(); return Boolean.FALSE.equals(all) ? result.stream().limit(2).toList() : result; } From 800faecc1d2f3454a9275d06a7a085261c97b742 Mon Sep 17 00:00:00 2001 From: MiN <81948815+leesumin0526@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:51:51 +0900 Subject: [PATCH 06/15] =?UTF-8?q?[FEAT/#487]=20student.usable.queried=20?= =?UTF-8?q?=EB=A9=94=ED=8A=B8=EB=A6=AD=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20?= =?UTF-8?q?UserPaper=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - student.usable.queried Counter 제거 (조회 횟수 집계 불필요) - syncUserPapersForStudent에 동기화 결과 로그 추가 (studentId, synced 건수) - syncUserPapersForStudent를 REQUIRES_NEW 전파로 변경하여 실패 시 독립 롤백 --- .../domain/student/service/StudentServiceImpl.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/assu/server/domain/student/service/StudentServiceImpl.java b/src/main/java/com/assu/server/domain/student/service/StudentServiceImpl.java index db4582c1..79380bd7 100644 --- a/src/main/java/com/assu/server/domain/student/service/StudentServiceImpl.java +++ b/src/main/java/com/assu/server/domain/student/service/StudentServiceImpl.java @@ -48,14 +48,16 @@ import com.assu.server.global.apiPayload.code.status.ErrorStatus; import com.assu.server.global.exception.DatabaseException; import com.assu.server.infra.s3.AmazonS3Manager; -import io.micrometer.core.instrument.MeterRegistry; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor public class StudentServiceImpl implements StudentService { @@ -73,7 +75,6 @@ public class StudentServiceImpl implements StudentService { private final HomeCurationItemRepository homeCurationItemRepository; private final StoreRepository storeRepository; private final AmazonS3Manager amazonS3Manager; - private final MeterRegistry meterRegistry; @Override @Transactional public StudentResponseDTO.CheckStampResponseDTO getStamp(Long memberId) { @@ -213,7 +214,6 @@ public List getUsablePartnership(Long m .build(); }).toList(); - meterRegistry.counter("student.usable.queried").increment(); return Boolean.FALSE.equals(all) ? result.stream().limit(2).toList() : result; } @@ -248,7 +248,7 @@ public List getRecommendCarouselPartner }).toList(); } - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) @Override public void syncUserPapersForStudent(Long studentId) { Student student = studentRepository.findById(studentId) @@ -290,6 +290,7 @@ public void syncUserPapersForStudent(Long studentId) { if (!newUserPapers.isEmpty()) { userPaperRepository.saveAll(newUserPapers); } + log.info("[UserPaper] studentId={} synced={}", studentId, newUserPapers.size()); } @Transactional public StudentResponseDTO.CheckStampResponseDTO addStamp(Long memberId) { From 9278f3fdabdc39557f53cf296f7670bb29684256 Mon Sep 17 00:00:00 2001 From: MiN <81948815+leesumin0526@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:52:46 +0900 Subject: [PATCH 07/15] =?UTF-8?q?[FIX/#487]=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=A4=91=20UserPaper=20=EB=8F=99=EA=B8=B0=ED=99=94?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EB=A1=A4=EB=B0=B1=EB=90=98=EB=8A=94=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - syncUserPapersForStudent 호출부를 try-catch로 감싸 동기화 실패가 회원가입에 영향 없도록 처리 - 동기화 실패 시 log.error로 studentId와 예외 기록 --- .../domain/auth/service/SignUpServiceImpl.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java b/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java index 28588f9d..128cdc87 100644 --- a/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java +++ b/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java @@ -32,6 +32,7 @@ import com.assu.server.global.apiPayload.code.status.ErrorStatus; import com.assu.server.infra.s3.AmazonS3Manager; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.Point; @@ -42,6 +43,7 @@ import java.util.List; import java.util.Optional; +@Slf4j @Service @RequiredArgsConstructor @Transactional @@ -122,7 +124,11 @@ public SignUpResponseDTO signupSsuStudent(StudentTokenSignUpRequestDTO req) { member.setProfile(student); // 6) 가입 시점 사용 가능 제휴 동기화 (자정 배치와 별개로 즉시 반영) - studentService.syncUserPapersForStudent(student.getId()); + try { + studentService.syncUserPapersForStudent(student.getId()); + } catch (Exception e) { + log.error("[SignUp] UserPaper 동기화 실패 studentId={}", student.getId(), e); + } // 7) JWT 토큰 발급 TokensDTO tokens = jwtUtil.issueTokens( @@ -159,7 +165,11 @@ private SignUpResponseDTO restoreWithdrawnStudent( studentRepository.save(student); // 탈퇴 기간 중 변동된 제휴를 반영한다 - studentService.syncUserPapersForStudent(student.getId()); + try { + studentService.syncUserPapersForStudent(student.getId()); + } catch (Exception e) { + log.error("[SignUp] UserPaper 동기화 실패 studentId={}", student.getId(), e); + } TokensDTO tokens = jwtUtil.issueTokens( member.getId(), From 8ea1ba7461d8eaa060c047f0064f1aba53fd440a Mon Sep 17 00:00:00 2001 From: MiN <81948815+leesumin0526@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:17:51 +0900 Subject: [PATCH 08/15] =?UTF-8?q?[FEAT/#487]=20=EB=AC=B8=EC=9D=98=20?= =?UTF-8?q?=EC=A0=91=EC=88=98=20Discord=20=EC=95=8C=EB=A6=BC=20=EB=B0=8F?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?S3=20=EC=82=AD=EC=A0=9C=20=EC=8B=A4=ED=8C=A8=20=EB=A9=94?= =?UTF-8?q?=ED=8A=B8=EB=A6=AD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DiscordNotifier 컴포넌트 추가 (infra/discord) - InquiryServiceImpl: 문의 생성 후 afterCommit 시점에 Discord #서비스-알림 채널로 알림 전송 - ProfileImageServiceImpl: 이전 프로필 이미지 S3 삭제 실패 시 member.profile.image.s3.delete.failure 카운터 증가 - config/prod: discord.webhook.service-alert URL 추가 --- config | 2 +- .../inquiry/service/InquiryServiceImpl.java | 19 +++++++- .../service/ProfileImageServiceImpl.java | 7 ++- .../server/infra/discord/DiscordNotifier.java | 43 +++++++++++++++++++ 4 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/assu/server/infra/discord/DiscordNotifier.java diff --git a/config b/config index 3c79e0fb..08d3d14f 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 3c79e0fb47b554890814383fce34fb95ddae0ea9 +Subproject commit 08d3d14fe26a414623c6e3799b0d54961efdfc91 diff --git a/src/main/java/com/assu/server/domain/inquiry/service/InquiryServiceImpl.java b/src/main/java/com/assu/server/domain/inquiry/service/InquiryServiceImpl.java index d7e8eddc..dde06618 100644 --- a/src/main/java/com/assu/server/domain/inquiry/service/InquiryServiceImpl.java +++ b/src/main/java/com/assu/server/domain/inquiry/service/InquiryServiceImpl.java @@ -1,5 +1,6 @@ package com.assu.server.domain.inquiry.service; +import com.assu.server.infra.discord.DiscordNotifier; import com.assu.server.domain.common.dto.PageResponseDTO; import com.assu.server.domain.inquiry.dto.InquiryCreateRequestDTO; import com.assu.server.domain.inquiry.dto.InquiryResponseDTO; @@ -23,11 +24,13 @@ @Service @Transactional @RequiredArgsConstructor -public class InquiryServiceImpl implements InquiryService { +public class +InquiryServiceImpl implements InquiryService { private final InquiryRepository inquiryRepository; private final MemberRepository memberRepository; private final MeterRegistry meterRegistry; + private final DiscordNotifier discordNotifier; /** 문의 등록 */ @Override @@ -39,9 +42,23 @@ public Long create(InquiryCreateRequestDTO inquiryCreateRequestDTO, Long memberI inquiryRepository.save(inquiry); incrementAfterCommit(meterRegistry.counter("inquiry.created")); + runAfterCommit(discordNotifier::sendInquiryAlert); return inquiry.getId(); } + private void runAfterCommit(Runnable task) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + task.run(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + task.run(); + } + }); + } + private void incrementAfterCommit(Counter counter) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { counter.increment(); diff --git a/src/main/java/com/assu/server/domain/member/service/ProfileImageServiceImpl.java b/src/main/java/com/assu/server/domain/member/service/ProfileImageServiceImpl.java index b18e66b8..0b0d0d66 100644 --- a/src/main/java/com/assu/server/domain/member/service/ProfileImageServiceImpl.java +++ b/src/main/java/com/assu/server/domain/member/service/ProfileImageServiceImpl.java @@ -5,6 +5,7 @@ import com.assu.server.domain.member.repository.MemberRepository; import com.assu.server.global.apiPayload.code.status.ErrorStatus; import com.assu.server.infra.s3.AmazonS3Manager; +import io.micrometer.core.instrument.MeterRegistry; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -19,6 +20,7 @@ public class ProfileImageServiceImpl implements ProfileImageService{ private final MemberRepository memberRepository; private final AmazonS3Manager amazonS3Manager; + private final MeterRegistry meterRegistry; private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB private static final Set ALLOWED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "gif"); @@ -39,7 +41,10 @@ public String updateProfileImage(Long memberId, MultipartFile image) { String oldKey = member.getProfileUrl(); if (oldKey != null && !oldKey.isBlank()) { try { amazonS3Manager.deleteFile(oldKey); } - catch (Exception e) { log.warn("이전 프로필 삭제 실패 key={}", oldKey, e); } + catch (Exception e) { + log.warn("이전 프로필 삭제 실패 key={}", oldKey, e); + meterRegistry.counter("member.profile.image.s3.delete.failure").increment(); + } } member.setProfileUrl(uploadedKey); diff --git a/src/main/java/com/assu/server/infra/discord/DiscordNotifier.java b/src/main/java/com/assu/server/infra/discord/DiscordNotifier.java new file mode 100644 index 00000000..11f671de --- /dev/null +++ b/src/main/java/com/assu/server/infra/discord/DiscordNotifier.java @@ -0,0 +1,43 @@ +package com.assu.server.infra.discord; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.util.Map; + +@Slf4j +@Component +public class DiscordNotifier { + + private final RestClient restClient; + + @Value("${discord.webhook.service-alert:}") + private String serviceAlertWebhookUrl; + + public DiscordNotifier(RestClient.Builder builder) { + this.restClient = builder.build(); + } + + public void sendInquiryAlert() { + send("📬 새 문의가 접수되었습니다. 백오피스에서 확인해 주세요."); + } + + private void send(String message) { + if (serviceAlertWebhookUrl.isBlank()) { + return; + } + try { + restClient.post() + .uri(serviceAlertWebhookUrl) + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("content", message)) + .retrieve() + .toBodilessEntity(); + } catch (Exception e) { + log.warn("[Discord] 알림 전송 실패 message={}", message, e); + } + } +} \ No newline at end of file From ece84ec8784e6c53cbf980223624d591b4184daa Mon Sep 17 00:00:00 2001 From: MiN <81948815+leesumin0526@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:25:22 +0900 Subject: [PATCH 09/15] =?UTF-8?q?[FEAT/#487]=20=EC=8B=A0=EA=B3=A0=20?= =?UTF-8?q?=EC=A0=91=EC=88=98=C2=B7=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8=20=EB=8C=80=EA=B8=B0=20Discord=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DiscordNotifier에 sendReportAlert, sendSignupPendingAlert 메서드 추가 - ReportServiceImpl: reportContent/reportStudent 저장 후 afterCommit으로 Discord 알림 발송 - SignUpServiceImpl: signupPartner(제휴업체)/signupAdmin(학생회) 신규 가입 시 afterCommit으로 Discord 알림 발송 (복구 케이스 제외) --- .../auth/service/SignUpServiceImpl.java | 21 +++++++++++++++++++ .../report/service/ReportServiceImpl.java | 21 +++++++++++++++++++ .../server/infra/discord/DiscordNotifier.java | 8 +++++++ 3 files changed, 50 insertions(+) diff --git a/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java b/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java index 128cdc87..a6e8d4f1 100644 --- a/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java +++ b/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java @@ -30,6 +30,7 @@ import com.assu.server.domain.student.repository.StudentRepository; import com.assu.server.domain.student.service.StudentService; import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.infra.discord.DiscordNotifier; import com.assu.server.infra.s3.AmazonS3Manager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -38,6 +39,8 @@ import org.locationtech.jts.geom.Point; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -66,6 +69,7 @@ public class SignUpServiceImpl implements SignUpService { private final StudentService studentService; private final PhoneAuthService phoneAuthService; private final CommonAuthRepository commonAuthRepository; + private final DiscordNotifier discordNotifier; private RealmAuthAdapter pickAdapter(AuthRealm realm) { return realmAuthAdapters.stream() @@ -237,6 +241,8 @@ public SignUpResponseDTO signupPartner(PartnerSignUpRequestDTO req, MultipartFil linkStore(partner, info.name(), address, info.detailAddress(), lat, lng, point); + runAfterCommit(() -> discordNotifier.sendSignupPendingAlert("제휴업체")); + return SignUpResponseDTO.from(member, null); } @@ -421,6 +427,8 @@ public SignUpResponseDTO signupAdmin(AdminSignUpRequestDTO req, MultipartFile si .build()); member.setProfile(admin); + runAfterCommit(() -> discordNotifier.sendSignupPendingAlert("학생회")); + return SignUpResponseDTO.from(member, null); } @@ -495,4 +503,17 @@ private Point toPoint(Double lat, Double lng) { private String pickDisplayAddress(String road, String jibun) { return (road != null && !road.isBlank()) ? road : jibun; } + + private void runAfterCommit(Runnable task) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + task.run(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + task.run(); + } + }); + } } \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/report/service/ReportServiceImpl.java b/src/main/java/com/assu/server/domain/report/service/ReportServiceImpl.java index 38f818d3..08cc6771 100644 --- a/src/main/java/com/assu/server/domain/report/service/ReportServiceImpl.java +++ b/src/main/java/com/assu/server/domain/report/service/ReportServiceImpl.java @@ -16,11 +16,14 @@ import com.assu.server.domain.report.event.ReportProcessedEvent; import com.assu.server.domain.student.entity.Student; import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.infra.discord.DiscordNotifier; import org.springframework.context.ApplicationEventPublisher; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; @Slf4j @Service @@ -32,6 +35,7 @@ public class ReportServiceImpl implements ReportService { private final ReviewRepository reviewRepository; private final SuggestionRepository suggestionRepository; private final ApplicationEventPublisher eventPublisher; + private final DiscordNotifier discordNotifier; @Override @Transactional @@ -69,6 +73,8 @@ public ReportResponseDTO.CreateReportResponse reportContent(Long reporterId, savedReport.getTargetId(), savedReport.getStatus())); + runAfterCommit(discordNotifier::sendReportAlert); + return ReportResponseDTO.CreateReportResponse.of(savedReport.getId()); } @@ -112,9 +118,24 @@ public ReportResponseDTO.CreateReportResponse reportStudent(Long reporterId, savedReport.getTargetId(), savedReport.getStatus())); + runAfterCommit(discordNotifier::sendReportAlert); + return ReportResponseDTO.CreateReportResponse.of(savedReport.getId()); } + private void runAfterCommit(Runnable task) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + task.run(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + task.run(); + } + }); + } + // 콘텐츠 신고 대상 검증 메서드 private void validateContentReportTarget(Long reporterId, ReportTargetType targetType, Long targetId) { switch (targetType) { diff --git a/src/main/java/com/assu/server/infra/discord/DiscordNotifier.java b/src/main/java/com/assu/server/infra/discord/DiscordNotifier.java index 11f671de..e3fef3fb 100644 --- a/src/main/java/com/assu/server/infra/discord/DiscordNotifier.java +++ b/src/main/java/com/assu/server/infra/discord/DiscordNotifier.java @@ -25,6 +25,14 @@ public void sendInquiryAlert() { send("📬 새 문의가 접수되었습니다. 백오피스에서 확인해 주세요."); } + public void sendReportAlert() { + send("🚨 새 신고가 접수되었습니다. 백오피스에서 확인해 주세요."); + } + + public void sendSignupPendingAlert(String role) { + send("🔔 새 " + role + " 가입 승인 요청이 있습니다. 백오피스에서 확인해 주세요."); + } + private void send(String message) { if (serviceAlertWebhookUrl.isBlank()) { return; From 3f1ad465350acaf58922ee04475c3e2f39bd2cbe Mon Sep 17 00:00:00 2001 From: MiN <81948815+leesumin0526@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:37:23 +0900 Subject: [PATCH 10/15] =?UTF-8?q?[FEAT/#487]=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20Discord=20=EC=95=8C=EB=A6=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(=EB=AC=B8=EC=9D=98=20=EB=8B=B5=EB=B3=80=C2=B7?= =?UTF-8?q?=EC=8B=A0=EA=B3=A0=C2=B7=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DiscordNotifier에 처리 완료 알림 메서드 6종 추가 - BackofficeInquiryServiceImpl: 문의 답변 완료 시 Discord 알림 - BackofficeReportServiceImpl: 신고 상태 변경·콘텐츠 삭제·기각 시 Discord 알림 - BackofficeMemberServiceImpl: 제휴업체/학생회 가입 승인·거절 시 Discord 알림 - 모두 afterCommit 패턴 적용 --- .../service/BackofficeMemberServiceImpl.java | 24 +++++++++++++++++ .../service/BackofficeReportServiceImpl.java | 27 +++++++++++++++++++ .../service/BackofficeInquiryServiceImpl.java | 18 +++++++++++++ .../server/infra/discord/DiscordNotifier.java | 24 +++++++++++++++++ 4 files changed, 93 insertions(+) diff --git a/src/main/java/com/assu/server/domain/backoffice/service/BackofficeMemberServiceImpl.java b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeMemberServiceImpl.java index c3bb8d90..b2993a1a 100644 --- a/src/main/java/com/assu/server/domain/backoffice/service/BackofficeMemberServiceImpl.java +++ b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeMemberServiceImpl.java @@ -17,6 +17,7 @@ import com.assu.server.domain.store.entity.Store; import com.assu.server.domain.store.repository.StoreRepository; import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.infra.discord.DiscordNotifier; import com.assu.server.infra.s3.AmazonS3Manager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -24,6 +25,8 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; @@ -44,6 +47,7 @@ public class BackofficeMemberServiceImpl implements BackofficeMemberService { private final WithdrawalService withdrawalService; private final AmazonS3Manager amazonS3Manager; private final ProfileImageService profileImageService; + private final DiscordNotifier discordNotifier; @Override @Transactional(readOnly = true) @@ -93,6 +97,9 @@ public BackofficeMemberSummaryDTO approveMember(Long memberId) { admin.setSignVerifiedAt(now); } + String roleLabel = member.getRole() == UserRole.PARTNER ? "제휴업체" : "학생회"; + runAfterCommit(() -> discordNotifier.sendMemberApprovedAlert(roleLabel)); + return BackofficeMemberSummaryDTO.from(member); } @@ -103,6 +110,10 @@ public BackofficeMemberSummaryDTO rejectMember(Long memberId) { assertPendingApproval(member); member.setIsActivated(ActivationStatus.INACTIVE); + + String roleLabel = member.getRole() == UserRole.PARTNER ? "제휴업체" : "학생회"; + runAfterCommit(() -> discordNotifier.sendMemberRejectedAlert(roleLabel)); + return BackofficeMemberSummaryDTO.from(member); } @@ -302,6 +313,19 @@ private void assertApprovable(Member member) { } } + private void runAfterCommit(Runnable task) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + task.run(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + task.run(); + } + }); + } + private void activatePartnerStore(Partner partner) { Store store = storeRepository.findByPartner(partner) .orElseThrow(() -> { diff --git a/src/main/java/com/assu/server/domain/backoffice/service/BackofficeReportServiceImpl.java b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeReportServiceImpl.java index a2290351..93ae7232 100644 --- a/src/main/java/com/assu/server/domain/backoffice/service/BackofficeReportServiceImpl.java +++ b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeReportServiceImpl.java @@ -24,8 +24,11 @@ import com.assu.server.domain.suggestion.repository.SuggestionRepository; import com.assu.server.global.apiPayload.code.status.ErrorStatus; import com.assu.server.global.exception.GeneralException; +import com.assu.server.infra.discord.DiscordNotifier; import lombok.RequiredArgsConstructor; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; @Service @RequiredArgsConstructor @@ -36,6 +39,7 @@ public class BackofficeReportServiceImpl implements BackofficeReportService { private final SuggestionRepository suggestionRepository; private final ReportRepository reportRepository; private final ApplicationEventPublisher eventPublisher; + private final DiscordNotifier discordNotifier; @Override @Transactional(readOnly = true) @@ -68,6 +72,13 @@ public BackofficeReportResponseDTO updateReportStatus(Long reportId, BackofficeR reportRepository.save(report); + String statusLabel = switch (nextStatus) { + case PENDING -> "대기"; + case PROCESSED -> "처리 완료"; + case REJECTED -> "기각"; + }; + runAfterCommit(() -> discordNotifier.sendReportStatusChangedAlert(statusLabel)); + return BackofficeReportResponseDTO.from(report); } @@ -81,6 +92,7 @@ public BackofficeReportDTO.SoftDeleteResponseDTO softDeleteReview(Long reviewId) } processRelatedReports(ReportTargetType.REVIEW, reviewId); + runAfterCommit(discordNotifier::sendReportContentDeletedAlert); return BackofficeReportDTO.SoftDeleteResponseDTO.of(reviewId); } @@ -95,6 +107,7 @@ public BackofficeReportDTO.SoftDeleteResponseDTO softDeleteSuggestion(Long sugge } processRelatedReports(ReportTargetType.SUGGESTION, suggestionId); + runAfterCommit(discordNotifier::sendReportContentDeletedAlert); return BackofficeReportDTO.SoftDeleteResponseDTO.of(suggestionId); } @@ -111,6 +124,7 @@ public BackofficeReportDTO.RejectReportResponseDTO rejectReport(Long reportId) { report.updateStatus(ReportStatus.REJECTED); eventPublisher.publishEvent(new ReportProcessedEvent( report.getId(), report.getTargetType(), report.getTargetId(), ReportStatus.REJECTED)); + runAfterCommit(discordNotifier::sendReportRejectedAlert); return BackofficeReportDTO.RejectReportResponseDTO.of(reportId); } @@ -133,6 +147,19 @@ public List getReports(boolean pending, b ); } + private void runAfterCommit(Runnable task) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + task.run(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + task.run(); + } + }); + } + private void processRelatedReports(ReportTargetType targetType, Long targetId) { List reports = reportRepository.findAllByTargetTypeAndTargetId(targetType, targetId); reports.forEach(report -> { diff --git a/src/main/java/com/assu/server/domain/inquiry/service/BackofficeInquiryServiceImpl.java b/src/main/java/com/assu/server/domain/inquiry/service/BackofficeInquiryServiceImpl.java index 9188aecf..4356a9a3 100644 --- a/src/main/java/com/assu/server/domain/inquiry/service/BackofficeInquiryServiceImpl.java +++ b/src/main/java/com/assu/server/domain/inquiry/service/BackofficeInquiryServiceImpl.java @@ -8,6 +8,7 @@ import com.assu.server.global.apiPayload.code.status.ErrorStatus; import com.assu.server.global.exception.DatabaseException; import com.assu.server.global.exception.GeneralException; +import com.assu.server.infra.discord.DiscordNotifier; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -15,6 +16,8 @@ import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; @Service @Transactional @@ -22,6 +25,7 @@ public class BackofficeInquiryServiceImpl implements BackofficeInquiryService { private final InquiryRepository inquiryRepository; + private final DiscordNotifier discordNotifier; @Override @Transactional(readOnly = true) @@ -67,5 +71,19 @@ public void answer(Long inquiryId, String answerText) { } inquiry.answer(answerText); + runAfterCommit(discordNotifier::sendInquiryAnsweredAlert); + } + + private void runAfterCommit(Runnable task) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + task.run(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + task.run(); + } + }); } } \ No newline at end of file diff --git a/src/main/java/com/assu/server/infra/discord/DiscordNotifier.java b/src/main/java/com/assu/server/infra/discord/DiscordNotifier.java index e3fef3fb..7168f6aa 100644 --- a/src/main/java/com/assu/server/infra/discord/DiscordNotifier.java +++ b/src/main/java/com/assu/server/infra/discord/DiscordNotifier.java @@ -33,6 +33,30 @@ public void sendSignupPendingAlert(String role) { send("🔔 새 " + role + " 가입 승인 요청이 있습니다. 백오피스에서 확인해 주세요."); } + public void sendInquiryAnsweredAlert() { + send("💬 문의 답변이 등록되었습니다."); + } + + public void sendReportStatusChangedAlert(String statusLabel) { + send("📋 신고 상태가 변경되었습니다: " + statusLabel); + } + + public void sendReportContentDeletedAlert() { + send("🗑️ 신고된 콘텐츠가 삭제 처리되었습니다."); + } + + public void sendReportRejectedAlert() { + send("✅ 신고가 기각되었습니다."); + } + + public void sendMemberApprovedAlert(String role) { + send("✅ " + role + " 회원가입이 승인되었습니다."); + } + + public void sendMemberRejectedAlert(String role) { + send("❌ " + role + " 회원가입이 거절되었습니다."); + } + private void send(String message) { if (serviceAlertWebhookUrl.isBlank()) { return; From 493bcd01a6f3f7a37556c8a08118b3367efd77cb Mon Sep 17 00:00:00 2001 From: MiN <81948815+leesumin0526@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:04:08 +0900 Subject: [PATCH 11/15] =?UTF-8?q?[FIX/#487]=20SignUpServiceImplTest=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9E=90=EC=97=90=20DiscordNotifier=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/auth/service/SignUpServiceImplTest.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/assu/server/domain/auth/service/SignUpServiceImplTest.java b/src/test/java/com/assu/server/domain/auth/service/SignUpServiceImplTest.java index 8d484284..01facd89 100644 --- a/src/test/java/com/assu/server/domain/auth/service/SignUpServiceImplTest.java +++ b/src/test/java/com/assu/server/domain/auth/service/SignUpServiceImplTest.java @@ -55,6 +55,7 @@ import com.assu.server.domain.student.repository.StudentRepository; import com.assu.server.domain.student.service.StudentService; import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.infra.discord.DiscordNotifier; import com.assu.server.infra.s3.AmazonS3Manager; @ExtendWith(MockitoExtension.class) @@ -101,6 +102,9 @@ class SignUpServiceImplTest { @Mock private CommonAuthRepository commonAuthRepository; + @Mock + private DiscordNotifier discordNotifier; + @Mock private CommonAuth commonAuth; @@ -116,7 +120,7 @@ void setUp() { memberRepository, studentRepository, partnerRepository, adminRepository, List.of(realmAuthAdapter), amazonS3Manager, jwtUtil, new GeometryFactory(), storeRepository, ssuAuthService, ssuAuthRepository, - studentService, phoneAuthService, commonAuthRepository); + studentService, phoneAuthService, commonAuthRepository, discordNotifier); } @Test From 9e50aae9194696a822a3714c1d829d4c2edce7f4 Mon Sep 17 00:00:00 2001 From: MiN <81948815+leesumin0526@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:23:28 +0900 Subject: [PATCH 12/15] =?UTF-8?q?[FIX/#487]=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EB=88=84=EB=9D=BD=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(DiscordNotifier,=20MeterRegistry)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/DeviceTokenServiceImplTest.java | 6 ++++++ .../service/BackofficeInquiryServiceImplTest.java | 4 ++++ .../domain/inquiry/service/InquiryServiceImplTest.java | 10 ++++++++++ .../domain/report/service/ReportServiceImplTest.java | 4 ++++ 4 files changed, 24 insertions(+) diff --git a/src/test/java/com/assu/server/domain/deviceToken/service/DeviceTokenServiceImplTest.java b/src/test/java/com/assu/server/domain/deviceToken/service/DeviceTokenServiceImplTest.java index ccc42493..b3528f14 100644 --- a/src/test/java/com/assu/server/domain/deviceToken/service/DeviceTokenServiceImplTest.java +++ b/src/test/java/com/assu/server/domain/deviceToken/service/DeviceTokenServiceImplTest.java @@ -13,6 +13,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import com.assu.server.domain.deviceToken.entity.DeviceToken; @@ -22,6 +23,8 @@ import com.assu.server.global.apiPayload.code.status.ErrorStatus; import com.assu.server.global.exception.DatabaseException; import com.assu.server.global.exception.GeneralException; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; @ExtendWith(MockitoExtension.class) class DeviceTokenServiceImplTest { @@ -35,6 +38,9 @@ class DeviceTokenServiceImplTest { @Mock private MemberRepository memberRepository; + @Spy + private MeterRegistry meterRegistry = new SimpleMeterRegistry(); + private static final Long MEMBER_ID = 1L; private static final String TOKEN = "fcm-token-abc"; diff --git a/src/test/java/com/assu/server/domain/inquiry/service/BackofficeInquiryServiceImplTest.java b/src/test/java/com/assu/server/domain/inquiry/service/BackofficeInquiryServiceImplTest.java index 3ecaed38..fe654dd2 100644 --- a/src/test/java/com/assu/server/domain/inquiry/service/BackofficeInquiryServiceImplTest.java +++ b/src/test/java/com/assu/server/domain/inquiry/service/BackofficeInquiryServiceImplTest.java @@ -19,6 +19,7 @@ import com.assu.server.global.apiPayload.code.status.ErrorStatus; import com.assu.server.global.exception.DatabaseException; import com.assu.server.global.exception.GeneralException; +import com.assu.server.infra.discord.DiscordNotifier; @ExtendWith(MockitoExtension.class) class BackofficeInquiryServiceImplTest { @@ -29,6 +30,9 @@ class BackofficeInquiryServiceImplTest { @Mock private InquiryRepository inquiryRepository; + @Mock + private DiscordNotifier discordNotifier; + @Test @DisplayName("페이지 번호가 1 미만이면 PAGE_UNDER_ONE 예외가 발생한다") void getInquiries_PageUnderOne_ThrowsException() { diff --git a/src/test/java/com/assu/server/domain/inquiry/service/InquiryServiceImplTest.java b/src/test/java/com/assu/server/domain/inquiry/service/InquiryServiceImplTest.java index 8c32bc41..e89c7996 100644 --- a/src/test/java/com/assu/server/domain/inquiry/service/InquiryServiceImplTest.java +++ b/src/test/java/com/assu/server/domain/inquiry/service/InquiryServiceImplTest.java @@ -13,6 +13,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -29,6 +30,9 @@ import com.assu.server.global.apiPayload.code.status.ErrorStatus; import com.assu.server.global.exception.DatabaseException; import com.assu.server.global.exception.GeneralException; +import com.assu.server.infra.discord.DiscordNotifier; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; @ExtendWith(MockitoExtension.class) class InquiryServiceImplTest { @@ -42,6 +46,12 @@ class InquiryServiceImplTest { @Mock private MemberRepository memberRepository; + @Spy + private MeterRegistry meterRegistry = new SimpleMeterRegistry(); + + @Mock + private DiscordNotifier discordNotifier; + private static final Long MEMBER_ID = 1L; @Test diff --git a/src/test/java/com/assu/server/domain/report/service/ReportServiceImplTest.java b/src/test/java/com/assu/server/domain/report/service/ReportServiceImplTest.java index fef82aea..683a367d 100644 --- a/src/test/java/com/assu/server/domain/report/service/ReportServiceImplTest.java +++ b/src/test/java/com/assu/server/domain/report/service/ReportServiceImplTest.java @@ -33,6 +33,7 @@ import com.assu.server.domain.suggestion.entity.Suggestion; import com.assu.server.domain.suggestion.repository.SuggestionRepository; import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.infra.discord.DiscordNotifier; @ExtendWith(MockitoExtension.class) class ReportServiceImplTest { @@ -55,6 +56,9 @@ class ReportServiceImplTest { @Mock private ApplicationEventPublisher eventPublisher; + @Mock + private DiscordNotifier discordNotifier; + private static final Long REPORTER_ID = 1L; private static final Long REVIEW_ID = 100L; From 55ed7383b4ed9ad0e811100fed05906ab25073cf Mon Sep 17 00:00:00 2001 From: MiN <81948815+leesumin0526@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:09:37 +0900 Subject: [PATCH 13/15] =?UTF-8?q?[REFACTOR/#487]=20DiscordNotifier=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=A9=94=EC=84=9C=EB=93=9C=EB=A5=BC=20sen?= =?UTF-8?q?d(message)=20=EB=8B=A8=EC=9D=BC=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 알림 종류별 메서드 제거 후 send(String)을 public으로 변경 - 각 서비스에서 알림 메시지를 직접 전달하도록 수정 --- .../auth/service/SignUpServiceImpl.java | 4 +- .../service/BackofficeMemberServiceImpl.java | 4 +- .../service/BackofficeReportServiceImpl.java | 8 ++-- .../service/BackofficeInquiryServiceImpl.java | 2 +- .../inquiry/service/InquiryServiceImpl.java | 2 +- .../report/service/ReportServiceImpl.java | 4 +- .../server/infra/discord/DiscordNotifier.java | 38 +------------------ 7 files changed, 13 insertions(+), 49 deletions(-) diff --git a/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java b/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java index a6e8d4f1..a5c092de 100644 --- a/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java +++ b/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java @@ -241,7 +241,7 @@ public SignUpResponseDTO signupPartner(PartnerSignUpRequestDTO req, MultipartFil linkStore(partner, info.name(), address, info.detailAddress(), lat, lng, point); - runAfterCommit(() -> discordNotifier.sendSignupPendingAlert("제휴업체")); + runAfterCommit(() -> discordNotifier.send("🔔 새 제휴업체 가입 승인 요청이 있습니다. 백오피스에서 확인해 주세요.")); return SignUpResponseDTO.from(member, null); } @@ -427,7 +427,7 @@ public SignUpResponseDTO signupAdmin(AdminSignUpRequestDTO req, MultipartFile si .build()); member.setProfile(admin); - runAfterCommit(() -> discordNotifier.sendSignupPendingAlert("학생회")); + runAfterCommit(() -> discordNotifier.send("🔔 새 학생회 가입 승인 요청이 있습니다. 백오피스에서 확인해 주세요.")); return SignUpResponseDTO.from(member, null); } diff --git a/src/main/java/com/assu/server/domain/backoffice/service/BackofficeMemberServiceImpl.java b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeMemberServiceImpl.java index b2993a1a..aff2e9ec 100644 --- a/src/main/java/com/assu/server/domain/backoffice/service/BackofficeMemberServiceImpl.java +++ b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeMemberServiceImpl.java @@ -98,7 +98,7 @@ public BackofficeMemberSummaryDTO approveMember(Long memberId) { } String roleLabel = member.getRole() == UserRole.PARTNER ? "제휴업체" : "학생회"; - runAfterCommit(() -> discordNotifier.sendMemberApprovedAlert(roleLabel)); + runAfterCommit(() -> discordNotifier.send("✅ " + roleLabel + " 회원가입이 승인되었습니다.")); return BackofficeMemberSummaryDTO.from(member); } @@ -112,7 +112,7 @@ public BackofficeMemberSummaryDTO rejectMember(Long memberId) { member.setIsActivated(ActivationStatus.INACTIVE); String roleLabel = member.getRole() == UserRole.PARTNER ? "제휴업체" : "학생회"; - runAfterCommit(() -> discordNotifier.sendMemberRejectedAlert(roleLabel)); + runAfterCommit(() -> discordNotifier.send("❌ " + roleLabel + " 회원가입이 거절되었습니다.")); return BackofficeMemberSummaryDTO.from(member); } diff --git a/src/main/java/com/assu/server/domain/backoffice/service/BackofficeReportServiceImpl.java b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeReportServiceImpl.java index 93ae7232..4e86c045 100644 --- a/src/main/java/com/assu/server/domain/backoffice/service/BackofficeReportServiceImpl.java +++ b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeReportServiceImpl.java @@ -77,7 +77,7 @@ public BackofficeReportResponseDTO updateReportStatus(Long reportId, BackofficeR case PROCESSED -> "처리 완료"; case REJECTED -> "기각"; }; - runAfterCommit(() -> discordNotifier.sendReportStatusChangedAlert(statusLabel)); + runAfterCommit(() -> discordNotifier.send("📋 신고 상태가 변경되었습니다: " + statusLabel)); return BackofficeReportResponseDTO.from(report); } @@ -92,7 +92,7 @@ public BackofficeReportDTO.SoftDeleteResponseDTO softDeleteReview(Long reviewId) } processRelatedReports(ReportTargetType.REVIEW, reviewId); - runAfterCommit(discordNotifier::sendReportContentDeletedAlert); + runAfterCommit(() -> discordNotifier.send("🗑️ 신고된 콘텐츠가 삭제 처리되었습니다.")); return BackofficeReportDTO.SoftDeleteResponseDTO.of(reviewId); } @@ -107,7 +107,7 @@ public BackofficeReportDTO.SoftDeleteResponseDTO softDeleteSuggestion(Long sugge } processRelatedReports(ReportTargetType.SUGGESTION, suggestionId); - runAfterCommit(discordNotifier::sendReportContentDeletedAlert); + runAfterCommit(() -> discordNotifier.send("🗑️ 신고된 콘텐츠가 삭제 처리되었습니다.")); return BackofficeReportDTO.SoftDeleteResponseDTO.of(suggestionId); } @@ -124,7 +124,7 @@ public BackofficeReportDTO.RejectReportResponseDTO rejectReport(Long reportId) { report.updateStatus(ReportStatus.REJECTED); eventPublisher.publishEvent(new ReportProcessedEvent( report.getId(), report.getTargetType(), report.getTargetId(), ReportStatus.REJECTED)); - runAfterCommit(discordNotifier::sendReportRejectedAlert); + runAfterCommit(() -> discordNotifier.send("✅ 신고가 기각되었습니다.")); return BackofficeReportDTO.RejectReportResponseDTO.of(reportId); } diff --git a/src/main/java/com/assu/server/domain/inquiry/service/BackofficeInquiryServiceImpl.java b/src/main/java/com/assu/server/domain/inquiry/service/BackofficeInquiryServiceImpl.java index 4356a9a3..4e86c8a0 100644 --- a/src/main/java/com/assu/server/domain/inquiry/service/BackofficeInquiryServiceImpl.java +++ b/src/main/java/com/assu/server/domain/inquiry/service/BackofficeInquiryServiceImpl.java @@ -71,7 +71,7 @@ public void answer(Long inquiryId, String answerText) { } inquiry.answer(answerText); - runAfterCommit(discordNotifier::sendInquiryAnsweredAlert); + runAfterCommit(() -> discordNotifier.send("💬 문의 답변이 등록되었습니다.")); } private void runAfterCommit(Runnable task) { diff --git a/src/main/java/com/assu/server/domain/inquiry/service/InquiryServiceImpl.java b/src/main/java/com/assu/server/domain/inquiry/service/InquiryServiceImpl.java index dde06618..61375455 100644 --- a/src/main/java/com/assu/server/domain/inquiry/service/InquiryServiceImpl.java +++ b/src/main/java/com/assu/server/domain/inquiry/service/InquiryServiceImpl.java @@ -42,7 +42,7 @@ public Long create(InquiryCreateRequestDTO inquiryCreateRequestDTO, Long memberI inquiryRepository.save(inquiry); incrementAfterCommit(meterRegistry.counter("inquiry.created")); - runAfterCommit(discordNotifier::sendInquiryAlert); + runAfterCommit(() -> discordNotifier.send("📬 새 문의가 접수되었습니다. 백오피스에서 확인해 주세요.")); return inquiry.getId(); } diff --git a/src/main/java/com/assu/server/domain/report/service/ReportServiceImpl.java b/src/main/java/com/assu/server/domain/report/service/ReportServiceImpl.java index 08cc6771..f3b3e9b0 100644 --- a/src/main/java/com/assu/server/domain/report/service/ReportServiceImpl.java +++ b/src/main/java/com/assu/server/domain/report/service/ReportServiceImpl.java @@ -73,7 +73,7 @@ public ReportResponseDTO.CreateReportResponse reportContent(Long reporterId, savedReport.getTargetId(), savedReport.getStatus())); - runAfterCommit(discordNotifier::sendReportAlert); + runAfterCommit(() -> discordNotifier.send("🚨 새 신고가 접수되었습니다. 백오피스에서 확인해 주세요.")); return ReportResponseDTO.CreateReportResponse.of(savedReport.getId()); } @@ -118,7 +118,7 @@ public ReportResponseDTO.CreateReportResponse reportStudent(Long reporterId, savedReport.getTargetId(), savedReport.getStatus())); - runAfterCommit(discordNotifier::sendReportAlert); + runAfterCommit(() -> discordNotifier.send("🚨 새 신고가 접수되었습니다. 백오피스에서 확인해 주세요.")); return ReportResponseDTO.CreateReportResponse.of(savedReport.getId()); } diff --git a/src/main/java/com/assu/server/infra/discord/DiscordNotifier.java b/src/main/java/com/assu/server/infra/discord/DiscordNotifier.java index 7168f6aa..d3f00152 100644 --- a/src/main/java/com/assu/server/infra/discord/DiscordNotifier.java +++ b/src/main/java/com/assu/server/infra/discord/DiscordNotifier.java @@ -21,43 +21,7 @@ public DiscordNotifier(RestClient.Builder builder) { this.restClient = builder.build(); } - public void sendInquiryAlert() { - send("📬 새 문의가 접수되었습니다. 백오피스에서 확인해 주세요."); - } - - public void sendReportAlert() { - send("🚨 새 신고가 접수되었습니다. 백오피스에서 확인해 주세요."); - } - - public void sendSignupPendingAlert(String role) { - send("🔔 새 " + role + " 가입 승인 요청이 있습니다. 백오피스에서 확인해 주세요."); - } - - public void sendInquiryAnsweredAlert() { - send("💬 문의 답변이 등록되었습니다."); - } - - public void sendReportStatusChangedAlert(String statusLabel) { - send("📋 신고 상태가 변경되었습니다: " + statusLabel); - } - - public void sendReportContentDeletedAlert() { - send("🗑️ 신고된 콘텐츠가 삭제 처리되었습니다."); - } - - public void sendReportRejectedAlert() { - send("✅ 신고가 기각되었습니다."); - } - - public void sendMemberApprovedAlert(String role) { - send("✅ " + role + " 회원가입이 승인되었습니다."); - } - - public void sendMemberRejectedAlert(String role) { - send("❌ " + role + " 회원가입이 거절되었습니다."); - } - - private void send(String message) { + public void send(String message) { if (serviceAlertWebhookUrl.isBlank()) { return; } From 92bd51cc7fba9401962753d79e2b3625d7e591cc Mon Sep 17 00:00:00 2001 From: MiN <81948815+leesumin0526@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:23:38 +0900 Subject: [PATCH 14/15] =?UTF-8?q?[FEAT/#507]=20=EC=A0=9C=ED=9C=B4=20?= =?UTF-8?q?=EC=BB=A8=ED=85=90=EC=B8=A0=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=20=EC=A0=9C=ED=9C=B4=EA=B8=B0?= =?UTF-8?q?=EA=B0=84=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PaperContentResponseDTO에 partnershipPeriod 필드 추가 - Paper 시작일/종료일을 '제휴기간 yyyy.MM.dd ~ yyyy.MM.dd' 형식 문자열로 변환 - 시작일 또는 종료일이 없으면 null 반환 - GET /store/{storeId}/papers Swagger 설명에 partnershipPeriod 추가 --- .../dto/PaperContentResponseDTO.java | 18 ++++++++++++++++-- .../store/controller/StoreController.java | 3 ++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/assu/server/domain/partnership/dto/PaperContentResponseDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/PaperContentResponseDTO.java index fd8f0f7a..e467ac54 100644 --- a/src/main/java/com/assu/server/domain/partnership/dto/PaperContentResponseDTO.java +++ b/src/main/java/com/assu/server/domain/partnership/dto/PaperContentResponseDTO.java @@ -1,8 +1,10 @@ package com.assu.server.domain.partnership.dto; +import java.time.format.DateTimeFormatter; import java.util.List; import com.assu.server.domain.partnership.entity.Goods; +import com.assu.server.domain.partnership.entity.Paper; import com.assu.server.domain.partnership.entity.PaperContent; import com.assu.server.domain.partnership.entity.enums.CriterionType; import com.assu.server.domain.partnership.entity.enums.OptionType; @@ -14,8 +16,11 @@ public record PaperContentResponseDTO( Long contentId, List goods, Integer people, - Long cost + Long cost, + String partnershipPeriod ) { + private static final DateTimeFormatter PERIOD_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy.MM.dd"); + public static List toContentResponseList(List contents) { return contents.stream() .map(PaperContentResponseDTO::toContentResponse) @@ -41,12 +46,21 @@ public static PaperContentResponseDTO toContentResponse(PaperContent content) { content.getId(), goodsList, peopleValue, - content.getCost() + content.getCost(), + buildPartnershipPeriod(content.getPaper()) ); } + private static String buildPartnershipPeriod(Paper paper) { + if (paper.getPartnershipPeriodStart() == null || paper.getPartnershipPeriodEnd() == null) { + return null; + } + return "제휴기간 " + paper.getPartnershipPeriodStart().format(PERIOD_DATE_FORMAT) + + " ~ " + paper.getPartnershipPeriodEnd().format(PERIOD_DATE_FORMAT); + } + private static List extractGoods(PaperContent content) { if (content.getOptionType() == OptionType.SERVICE ) { return content.getGoods().stream() diff --git a/src/main/java/com/assu/server/domain/store/controller/StoreController.java b/src/main/java/com/assu/server/domain/store/controller/StoreController.java index 7a43e91f..d88c1550 100644 --- a/src/main/java/com/assu/server/domain/store/controller/StoreController.java +++ b/src/main/java/com/assu/server/domain/store/controller/StoreController.java @@ -103,7 +103,8 @@ public ResponseEntity> getTodayBestStore() { " - `contentId` (Long): 컨텐츠 고유 ID\n" + " - `goods` (List): 제공되는 상품/서비스 리스트\n" + " - `people` (Integer): 사용 가능 인원\n" + - " - `cost` (Long): 유저 부담 금액 (0이면 무료)\n\n" + + " - `cost` (Long): 유저 부담 금액 (0이면 무료)\n" + + " - `partnershipPeriod` (String): 제휴기간 (예: \"제휴기간 2026.07.21 ~ 2026.12.31\", 기간 정보가 없으면 null)\n\n" + "**Error Cases:**\n" + " - 성공: 200 OK, `isSuccess=true`, `result=PaperResponseDTO` (제휴 컨텐츠 상세 정보)\n" + " - 실패: \n" + From 5ed7d5c76cd481615276bc89dcab0fcb7ca10c1a Mon Sep 17 00:00:00 2001 From: MiN <81948815+leesumin0526@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:33:40 +0900 Subject: [PATCH 15/15] =?UTF-8?q?[FIX/#506]=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20UserPaper=20=EB=8F=99=EA=B8=B0=ED=99=94=EB=A5=BC=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=BB=A4=EB=B0=8B=20?= =?UTF-8?q?=EC=9D=B4=ED=9B=84=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 신규 가입·탈퇴 회원 복구 시 syncUserPapersForStudent를 runAfterCommit으로 실행 - REQUIRES_NEW 트랜잭션에서 커밋 전 Student를 조회하지 못해 동기화가 실패하던 문제 수정 - 탈퇴 회원 복구 시 커밋 전 학과 정보 기준으로 동기화되던 문제 함께 수정 - 동기화 실패가 회원가입 응답에 영향을 주지 않도록 syncUserPapersSafely로 예외 격리 - 커밋 이후 동기화 호출 및 실패 격리 테스트 추가 --- .../auth/service/SignUpServiceImpl.java | 25 +++-- .../auth/service/SignUpServiceImplTest.java | 103 ++++++++++++++---- 2 files changed, 94 insertions(+), 34 deletions(-) diff --git a/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java b/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java index a5c092de..782e9d74 100644 --- a/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java +++ b/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java @@ -128,11 +128,9 @@ public SignUpResponseDTO signupSsuStudent(StudentTokenSignUpRequestDTO req) { member.setProfile(student); // 6) 가입 시점 사용 가능 제휴 동기화 (자정 배치와 별개로 즉시 반영) - try { - studentService.syncUserPapersForStudent(student.getId()); - } catch (Exception e) { - log.error("[SignUp] UserPaper 동기화 실패 studentId={}", student.getId(), e); - } + // 커밋 전 Student는 REQUIRES_NEW 트랜잭션에서 조회되지 않으므로 커밋 이후 실행 + Long studentId = student.getId(); + runAfterCommit(() -> syncUserPapersSafely(studentId)); // 7) JWT 토큰 발급 TokensDTO tokens = jwtUtil.issueTokens( @@ -168,12 +166,9 @@ private SignUpResponseDTO restoreWithdrawnStudent( ); studentRepository.save(student); - // 탈퇴 기간 중 변동된 제휴를 반영한다 - try { - studentService.syncUserPapersForStudent(student.getId()); - } catch (Exception e) { - log.error("[SignUp] UserPaper 동기화 실패 studentId={}", student.getId(), e); - } + // 탈퇴 기간 중 변동된 제휴를 반영한다 (갱신된 학과 정보 기준으로 커밋 이후 실행) + Long studentId = student.getId(); + runAfterCommit(() -> syncUserPapersSafely(studentId)); TokensDTO tokens = jwtUtil.issueTokens( member.getId(), @@ -504,6 +499,14 @@ private String pickDisplayAddress(String road, String jibun) { return (road != null && !road.isBlank()) ? road : jibun; } + private void syncUserPapersSafely(Long studentId) { + try { + studentService.syncUserPapersForStudent(studentId); + } catch (Exception e) { + log.error("[SignUp] UserPaper 동기화 실패 studentId={}", studentId, e); + } + } + private void runAfterCommit(Runnable task) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { task.run(); diff --git a/src/test/java/com/assu/server/domain/auth/service/SignUpServiceImplTest.java b/src/test/java/com/assu/server/domain/auth/service/SignUpServiceImplTest.java index 01facd89..c77b5456 100644 --- a/src/test/java/com/assu/server/domain/auth/service/SignUpServiceImplTest.java +++ b/src/test/java/com/assu/server/domain/auth/service/SignUpServiceImplTest.java @@ -9,17 +9,19 @@ import java.util.List; import java.util.Optional; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.Point; -import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import com.assu.server.domain.admin.repository.AdminRepository; import com.assu.server.domain.auth.dto.common.TokensDTO; @@ -123,6 +125,13 @@ void setUp() { studentService, phoneAuthService, commonAuthRepository, discordNotifier); } + @AfterEach + void tearDown() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization(); + } + } + @Test @DisplayName("이미 가입된 학번으로 학생 회원가입을 시도하면 EXISTED_STUDENT 예외가 발생한다") void signupSsuStudent_ExistingStudent_ThrowsException() { @@ -153,39 +162,59 @@ void signupSsuStudent_ExistingStudent_ThrowsException() { } @Test - @DisplayName("정상적인 학생 회원가입 시 저장된 학생 ID로 제휴 동기화를 호출한 뒤 JWT를 발급한다") - void signupSsuStudent_Success_SyncsUserPapersBeforeIssuingTokens() { + @DisplayName("정상적인 학생 회원가입 시 제휴 동기화는 커밋 전에 호출되지 않고 커밋 이후 저장된 학생 ID로 호출된다") + void signupSsuStudent_Success_SyncsUserPapersOnlyAfterCommit() { // 1. Given - StudentTokenSignUpRequestDTO request = new StudentTokenSignUpRequestDTO( - true, true, new StudentTokenAuthPayloadDTO("sToken", "20211438", University.SSU)); + StudentTokenSignUpRequestDTO request = stubNewStudentSignUp(); + TransactionSynchronizationManager.initSynchronization(); - USaintAuthResponseDTO authResponse = - USaintAuthResponseDTO.of("20211438", "홍길동", "재학", "4학년 1학기", "컴퓨터학부"); - when(ssuAuthService.uSaintAuth(any(USaintAuthRequestDTO.class))).thenReturn(authResponse); - when(ssuAuthRepository.findByStudentNumber("20211438")).thenReturn(Optional.empty()); - when(realmAuthAdapter.supports(AuthRealm.SSU)).thenReturn(true); + // 2. When + signUpService.signupSsuStudent(request); - Member member = mock(Member.class); - when(member.getId()).thenReturn(1L); - when(member.getRole()).thenReturn(UserRole.STUDENT); - when(memberRepository.save(any())).thenReturn(member); + // 3. Then + verify(jwtUtil).issueTokens(1L, "20211438", UserRole.STUDENT, "SSU"); + verify(studentService, never()).syncUserPapersForStudent(any()); - Student student = mock(Student.class); - when(student.getId()).thenReturn(99L); - when(studentRepository.save(any())).thenReturn(student); + triggerAfterCommit(); - when(jwtUtil.issueTokens(1L, "20211438", UserRole.STUDENT, "SSU")) - .thenReturn(TokensDTO.of("access-token", "refresh-token")); + verify(studentService, times(1)).syncUserPapersForStudent(99L); + } + + @Test + @DisplayName("커밋 이후 제휴 동기화가 실패해도 예외가 전파되지 않는다") + void signupSsuStudent_SyncFailsAfterCommit_DoesNotPropagateException() { + // 1. Given + StudentTokenSignUpRequestDTO request = stubNewStudentSignUp(); + doThrow(new RuntimeException("sync failed")).when(studentService).syncUserPapersForStudent(99L); + TransactionSynchronizationManager.initSynchronization(); + + // 2. When + SignUpResponseDTO response = signUpService.signupSsuStudent(request); + + // 3. Then + assertDoesNotThrow(this::triggerAfterCommit); + verify(studentService, times(1)).syncUserPapersForStudent(99L); + assertEquals("access-token", response.tokens().accessToken()); + } + + @Test + @DisplayName("탈퇴한 학생 재가입 시 제휴 동기화는 커밋 이후 호출된다") + void signupSsuStudent_WithdrawnStudent_SyncsUserPapersOnlyAfterCommit() { + // 1. Given + StudentTokenSignUpRequestDTO request = new StudentTokenSignUpRequestDTO( + true, true, new StudentTokenAuthPayloadDTO("sToken", "20211438", University.SSU)); + stubWithdrawnStudentAccount(); + TransactionSynchronizationManager.initSynchronization(); // 2. When signUpService.signupSsuStudent(request); // 3. Then - verify(studentService).syncUserPapersForStudent(99L); + verify(studentService, never()).syncUserPapersForStudent(any()); + + triggerAfterCommit(); - InOrder inOrder = inOrder(studentService, jwtUtil); - inOrder.verify(studentService).syncUserPapersForStudent(99L); - inOrder.verify(jwtUtil).issueTokens(1L, "20211438", UserRole.STUDENT, "SSU"); + verify(studentService, times(1)).syncUserPapersForStudent(99L); } @Test @@ -422,6 +451,34 @@ private PartnerSignUpRequestDTO partnerSignUpRequest() { new CommonInfoPayloadDTO("숭실카페", "101호", place)); } + private StudentTokenSignUpRequestDTO stubNewStudentSignUp() { + USaintAuthResponseDTO authResponse = + USaintAuthResponseDTO.of("20211438", "홍길동", "재학", "4학년 1학기", "컴퓨터학부"); + when(ssuAuthService.uSaintAuth(any(USaintAuthRequestDTO.class))).thenReturn(authResponse); + when(ssuAuthRepository.findByStudentNumber("20211438")).thenReturn(Optional.empty()); + when(realmAuthAdapter.supports(AuthRealm.SSU)).thenReturn(true); + + Member member = mock(Member.class); + when(member.getId()).thenReturn(1L); + when(member.getRole()).thenReturn(UserRole.STUDENT); + when(memberRepository.save(any())).thenReturn(member); + + Student student = mock(Student.class); + when(student.getId()).thenReturn(99L); + when(studentRepository.save(any())).thenReturn(student); + + when(jwtUtil.issueTokens(1L, "20211438", UserRole.STUDENT, "SSU")) + .thenReturn(TokensDTO.of("access-token", "refresh-token")); + + return new StudentTokenSignUpRequestDTO( + true, true, new StudentTokenAuthPayloadDTO("sToken", "20211438", University.SSU)); + } + + private void triggerAfterCommit() { + TransactionSynchronizationManager.getSynchronizations() + .forEach(TransactionSynchronization::afterCommit); + } + private void stubCommonAuthLookup(Member member) { when(commonAuth.getMember()).thenReturn(member); when(commonAuthRepository.findByEmail(EMAIL)).thenReturn(Optional.of(commonAuth));