diff --git a/config b/config index f023fead..08d3d14f 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit f023feadeaafc655e81e4590afd55f1b70d59b8e +Subproject commit 08d3d14fe26a414623c6e3799b0d54961efdfc91 diff --git a/manifest b/manifest index f1bfe8d5..b79b095e 160000 --- a/manifest +++ b/manifest @@ -1 +1 @@ -Subproject commit f1bfe8d50363480d9fe259ac612bf3acce661133 +Subproject commit b79b095eaa6825abd46b4acb862231c6e7e9e4d5 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 22015933..65af4c26 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,18 +30,23 @@ 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; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.GeometryFactory; 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; import java.util.Optional; +@Slf4j @Service @RequiredArgsConstructor @Transactional @@ -64,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() @@ -122,7 +128,9 @@ public SignUpResponseDTO signupSsuStudent(StudentTokenSignUpRequestDTO req) { member.setProfile(student); // 6) 가입 시점 사용 가능 제휴 동기화 (자정 배치와 별개로 즉시 반영) - studentService.syncUserPapersForStudent(student.getId()); + // 커밋 전 Student는 REQUIRES_NEW 트랜잭션에서 조회되지 않으므로 커밋 이후 실행 + Long studentId = student.getId(); + runAfterCommit(() -> syncUserPapersSafely(studentId)); // 7) JWT 토큰 발급 TokensDTO tokens = jwtUtil.issueTokens( @@ -158,8 +166,9 @@ private SignUpResponseDTO restoreWithdrawnStudent( ); studentRepository.save(student); - // 탈퇴 기간 중 변동된 제휴를 반영한다 - studentService.syncUserPapersForStudent(student.getId()); + // 탈퇴 기간 중 변동된 제휴를 반영한다 (갱신된 학과 정보 기준으로 커밋 이후 실행) + Long studentId = student.getId(); + runAfterCommit(() -> syncUserPapersSafely(studentId)); TokensDTO tokens = jwtUtil.issueTokens( member.getId(), @@ -228,6 +237,8 @@ public SignUpResponseDTO signupPartner(PartnerSignUpRequestDTO req, MultipartFil linkStore(partner, info.name(), address, info.detailAddress(), lat, lng, point); + runAfterCommit(() -> discordNotifier.send("🔔 새 제휴업체 가입 승인 요청이 있습니다. 백오피스에서 확인해 주세요.")); + return SignUpResponseDTO.from(member, null); } @@ -413,6 +424,8 @@ public SignUpResponseDTO signupAdmin(AdminSignUpRequestDTO req, MultipartFile si .build()); member.setProfile(admin); + runAfterCommit(() -> discordNotifier.send("🔔 새 학생회 가입 승인 요청이 있습니다. 백오피스에서 확인해 주세요.")); + return SignUpResponseDTO.from(member, null); } @@ -493,4 +506,25 @@ private Point toPoint(Double lat, Double lng) { 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(); + 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/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/domain/backoffice/service/BackofficeMemberServiceImpl.java b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeMemberServiceImpl.java index 5cb8b434..191d6ba1 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.send("✅ " + 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.send("❌ " + roleLabel + " 회원가입이 거절되었습니다.")); + return BackofficeMemberSummaryDTO.from(member); } @@ -310,6 +321,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..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 @@ -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.send("📋 신고 상태가 변경되었습니다: " + statusLabel)); + return BackofficeReportResponseDTO.from(report); } @@ -81,6 +92,7 @@ public BackofficeReportDTO.SoftDeleteResponseDTO softDeleteReview(Long reviewId) } processRelatedReports(ReportTargetType.REVIEW, reviewId); + runAfterCommit(() -> discordNotifier.send("🗑️ 신고된 콘텐츠가 삭제 처리되었습니다.")); return BackofficeReportDTO.SoftDeleteResponseDTO.of(reviewId); } @@ -95,6 +107,7 @@ public BackofficeReportDTO.SoftDeleteResponseDTO softDeleteSuggestion(Long sugge } processRelatedReports(ReportTargetType.SUGGESTION, suggestionId); + runAfterCommit(() -> discordNotifier.send("🗑️ 신고된 콘텐츠가 삭제 처리되었습니다.")); 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.send("✅ 신고가 기각되었습니다.")); 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/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/BackofficeInquiryServiceImpl.java b/src/main/java/com/assu/server/domain/inquiry/service/BackofficeInquiryServiceImpl.java index 9188aecf..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 @@ -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.send("💬 문의 답변이 등록되었습니다.")); + } + + 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/inquiry/service/InquiryServiceImpl.java b/src/main/java/com/assu/server/domain/inquiry/service/InquiryServiceImpl.java index bbedcbb5..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 @@ -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; @@ -11,18 +12,25 @@ 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 @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 @@ -33,9 +41,37 @@ public Long create(InquiryCreateRequestDTO inquiryCreateRequestDTO, Long memberI Inquiry inquiry = Inquiry.create(member, inquiryCreateRequestDTO); inquiryRepository.save(inquiry); + incrementAfterCommit(meterRegistry.counter("inquiry.created")); + runAfterCommit(() -> discordNotifier.send("📬 새 문의가 접수되었습니다. 백오피스에서 확인해 주세요.")); 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(); + 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/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/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/report/service/ReportServiceImpl.java b/src/main/java/com/assu/server/domain/report/service/ReportServiceImpl.java index 38f818d3..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 @@ -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.send("🚨 새 신고가 접수되었습니다. 백오피스에서 확인해 주세요.")); + return ReportResponseDTO.CreateReportResponse.of(savedReport.getId()); } @@ -112,9 +118,24 @@ public ReportResponseDTO.CreateReportResponse reportStudent(Long reporterId, savedReport.getTargetId(), savedReport.getStatus())); + runAfterCommit(() -> discordNotifier.send("🚨 새 신고가 접수되었습니다. 백오피스에서 확인해 주세요.")); + 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/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" + 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..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 @@ -49,12 +49,15 @@ import com.assu.server.global.exception.DatabaseException; import com.assu.server.infra.s3.AmazonS3Manager; 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 { @@ -245,7 +248,7 @@ public List getRecommendCarouselPartner }).toList(); } - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) @Override public void syncUserPapersForStudent(Long studentId) { Student student = studentRepository.findById(studentId) @@ -287,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) { 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/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..d3f00152 --- /dev/null +++ b/src/main/java/com/assu/server/infra/discord/DiscordNotifier.java @@ -0,0 +1,39 @@ +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 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 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)); + } +} 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 9386d91d..6038e1ad 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,6 +9,7 @@ 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; @@ -16,11 +17,12 @@ import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.Point; import org.mockito.ArgumentCaptor; -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; @@ -56,6 +58,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) @@ -102,6 +105,9 @@ class SignUpServiceImplTest { @Mock private CommonAuthRepository commonAuthRepository; + @Mock + private DiscordNotifier discordNotifier; + @Mock private CommonAuth commonAuth; @@ -118,7 +124,14 @@ void setUp() { memberRepository, studentRepository, partnerRepository, adminRepository, List.of(realmAuthAdapter), amazonS3Manager, jwtUtil, new GeometryFactory(), storeRepository, ssuAuthService, ssuAuthRepository, - studentService, phoneAuthService, commonAuthRepository); + studentService, phoneAuthService, commonAuthRepository, discordNotifier); + } + + @AfterEach + void tearDown() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization(); + } } @Test @@ -151,39 +164,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 @@ -510,6 +543,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)); 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;