From 01d786b80dbeff00578a542888802f5009665ea4 Mon Sep 17 00:00:00 2001 From: jaekwan Date: Tue, 10 Mar 2026 23:15:52 +0900 Subject: [PATCH 01/50] =?UTF-8?q?feat:=20=EB=AC=B8=ED=95=AD=20=EC=88=98?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=94=84=EB=A1=9C=EB=AA=A8?= =?UTF-8?q?=EC=85=98=20=EA=B0=80=EA=B2=A9=20=EB=B6=84=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../question/QuestionJpaRepository.java | 2 + .../question/QuestionRepository.java | 1 + .../question/QuestionRepositoryImpl.java | 5 ++ .../question/service/QuestionQuery.java | 1 + .../service/QuestionQueryService.java | 5 ++ .../domain/survey/entity/SurveyInfo.java | 10 ++- .../model/dto/SurveyWithEligibility.java | 1 + .../response/SurveyParticipationResponse.java | 4 + .../repository/SurveyRepositoryImpl.java | 1 + .../service/command/SurveyCommandService.java | 23 +++++- .../survey/service/query/SurveyQuery.java | 1 + .../service/query/SurveyQueryService.java | 7 ++ .../application/PromotionFacade.java | 74 +++++++++++++------ 13 files changed, 106 insertions(+), 29 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionJpaRepository.java b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionJpaRepository.java index a1f2b475..89a90211 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionJpaRepository.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionJpaRepository.java @@ -12,4 +12,6 @@ public interface QuestionJpaRepository extends JpaRepository { List getQuestionsBySurveyIdAndSectionOrderByOrder(Long surveyId, Integer section); void deleteAllBySurveyIdEqualsAndSectionNotIn(Long surveyId, Collection sections); + + int countBySurveyId(Long surveyId); } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepository.java b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepository.java index b91c2a6c..d929a878 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepository.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepository.java @@ -15,4 +15,5 @@ public interface QuestionRepository { Long getSurveyId(Long questionId); void deleteAll(Set idList); void deleteBySurveyIdAndNotInOrder(Long surveyId, Collection order); + int countBySurveyId(Long surveyId); } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepositoryImpl.java index 99d46da3..ca7acc94 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepositoryImpl.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepositoryImpl.java @@ -55,4 +55,9 @@ public void deleteAll(Set idList) { public void deleteBySurveyIdAndNotInOrder(Long surveyId, Collection orders) { questionJpaRepository.deleteAllBySurveyIdEqualsAndSectionNotIn(surveyId, orders); } + + @Override + public int countBySurveyId(Long surveyId) { + return questionJpaRepository.countBySurveyId(surveyId); + } } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQuery.java b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQuery.java index d97e3cb8..e399c7fe 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQuery.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQuery.java @@ -9,4 +9,5 @@ public interface QuestionQuery { List getOptionsByQuestionIdList(List questionIdList); List getQuestionDtoListBySurveyId(Long surveyId); List getQuestionDtoListBySurveyIdAndSection(Long surveyId, Integer section); + int countQuestionsBySurveyId(Long surveyId); } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQueryService.java b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQueryService.java index cc95a7d8..9a5188ec 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQueryService.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQueryService.java @@ -49,6 +49,11 @@ public List getQuestionDtoListBySurveyIdAndSection(Long surv return fillChoiceOptions(questionList); } + @Override + public int countQuestionsBySurveyId(Long surveyId) { + return questionRepository.countBySurveyId(surveyId); + } + private List fillChoiceOptions(List questionList) { Set choiceIdSet = questionList.stream() diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/entity/SurveyInfo.java b/src/main/java/OneQ/OnSurvey/domain/survey/entity/SurveyInfo.java index dc313d30..85030ffe 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/entity/SurveyInfo.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/entity/SurveyInfo.java @@ -49,6 +49,8 @@ public class SurveyInfo { private Integer residencePrice; private Integer dueCountPrice; + private Integer promotionAmount; + @Builder.Default @Column(nullable = false) private boolean refundable = true; @@ -62,7 +64,8 @@ public static SurveyInfo createSurveyInfo( Integer genderPrice, Integer agePrice, Integer residencePrice, - Integer dueCountPrice + Integer dueCountPrice, + Integer promotionAmount ) { return SurveyInfo.builder() .surveyId(surveyId) @@ -75,6 +78,7 @@ public static SurveyInfo createSurveyInfo( .agePrice(agePrice) .residencePrice(residencePrice) .dueCountPrice(dueCountPrice) + .promotionAmount(promotionAmount) .refundable(true) .build(); } @@ -87,7 +91,8 @@ public void updateSurveyInfo( Integer genderPrice, Integer agePrice, Integer residencePrice, - Integer dueCountPrice + Integer dueCountPrice, + Integer promotionAmount ) { this.dueCount = dueCount; this.gender = gender; @@ -97,6 +102,7 @@ public void updateSurveyInfo( this.agePrice = agePrice; this.residencePrice = residencePrice; this.dueCountPrice = dueCountPrice; + this.promotionAmount = promotionAmount; } public void markNonRefundable() { diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/dto/SurveyWithEligibility.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/dto/SurveyWithEligibility.java index f5c211aa..9b6adfb7 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/dto/SurveyWithEligibility.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/dto/SurveyWithEligibility.java @@ -16,6 +16,7 @@ public class SurveyWithEligibility { private String title; private String description; private Boolean isFree; + private Integer promotionAmount; private Set interests; private LocalDateTime deadline; private Boolean isEligible; diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/response/SurveyParticipationResponse.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/response/SurveyParticipationResponse.java index 2f1f4849..b025eabe 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/response/SurveyParticipationResponse.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/response/SurveyParticipationResponse.java @@ -35,6 +35,7 @@ public static class SurveyData { private String title; private String description; private Boolean isFree; + private Integer price; private Set interests; private LocalDateTime deadline; @@ -43,12 +44,15 @@ public static class SurveyData { } public static SurveyData from(SurveyWithEligibility surveyWithEligibility) { + Integer promotionAmount = surveyWithEligibility.getPromotionAmount(); + int price = Boolean.TRUE.equals(surveyWithEligibility.getIsFree()) ? 0 : promotionAmount; return SurveyData.builder() .surveyId(surveyWithEligibility.getSurveyId()) .memberId(surveyWithEligibility.getMemberId()) .title(surveyWithEligibility.getTitle()) .description(surveyWithEligibility.getDescription()) .isFree(surveyWithEligibility.getIsFree()) + .price(price) .interests(surveyWithEligibility.getInterests()) .deadline(surveyWithEligibility.getDeadline()) .isEligible(surveyWithEligibility.getIsEligible()) diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepositoryImpl.java index 0c968e2d..649d62ec 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepositoryImpl.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepositoryImpl.java @@ -103,6 +103,7 @@ public Slice getSurveyListWithEligibility( survey.title, survey.description, survey.isFree, + surveyInfo.promotionAmount, set(interestAlias).as("interests"), survey.deadline, isEligible diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java index 79d24079..77442fc7 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java @@ -4,6 +4,7 @@ import OneQ.OnSurvey.domain.member.MemberErrorCode; import OneQ.OnSurvey.domain.member.repository.MemberRepository; import OneQ.OnSurvey.domain.member.value.Interest; +import OneQ.OnSurvey.domain.question.service.QuestionQueryService; import OneQ.OnSurvey.domain.survey.SurveyErrorCode; import OneQ.OnSurvey.domain.survey.entity.Screening; import OneQ.OnSurvey.domain.survey.entity.Survey; @@ -41,7 +42,6 @@ import java.time.LocalDateTime; import java.util.HashSet; import java.util.List; -import java.util.Objects; import java.util.Set; import java.util.stream.Stream; @@ -60,10 +60,20 @@ public class SurveyCommandService implements SurveyCommand { private final SurveyRefundPolicy surveyRefundPolicy; private final SurveyGlobalStatsService surveyGlobalStatsService; private final RedisAgent redisAgent; + private final QuestionQueryService questionQueryService; private final AlertNotifier alertNotifier; private final AfterCommitExecutor afterCommitExecutor; + @Value("${toss.api.promotion.amount}") + private int promotionAmount; + + @Value("${toss.api.promotion.amount500}") + private int promotionAmount500; + + private static final int TIER2_MIN_QUESTIONS = 30; + private static final int TIER2_MAX_QUESTIONS = 49; + @Value("${redis.survey-key-prefix.potential-count}") private String potentialKey; @Value("${redis.survey-key-prefix.completed-count}") @@ -124,6 +134,10 @@ public SurveyFormResponse submitSurvey(Long userKey, Long surveyId, SurveyFormRe survey.updateSurvey(survey.getTitle(), survey.getDescription(), request.deadline(), request.totalCoin()); + int questionCount = questionQueryService.countQuestionsBySurveyId(surveyId); + int resolvedPromotionAmount = (questionCount >= TIER2_MIN_QUESTIONS && questionCount <= TIER2_MAX_QUESTIONS) + ? promotionAmount500 : promotionAmount; + SurveyInfo info = upsertSurveyInfo( surveyId, request.dueCount(), @@ -134,6 +148,7 @@ public SurveyFormResponse submitSurvey(Long userKey, Long surveyId, SurveyFormRe request.agePrice(), request.residencePrice(), request.dueCountPrice(), + resolvedPromotionAmount, true ); @@ -158,6 +173,7 @@ public SurveyFormResponse submitFreeSurvey(Long userKey, Long surveyId, FreeSurv Set.of(AgeRange.ALL), Residence.ALL, 0, 0, 0, 0, + 0, false ); @@ -291,15 +307,16 @@ private SurveyInfo upsertSurveyInfo( Integer agePrice, Integer residencePrice, Integer dueCountPrice, + Integer promotionAmount, boolean refundable ) { SurveyInfo info = surveyInfoRepository.findBySurveyId(surveyId) .orElseGet(() -> SurveyInfo.createSurveyInfo( surveyId, dueCount, gender, ages, residence, - genderPrice, agePrice, residencePrice, dueCountPrice + genderPrice, agePrice, residencePrice, dueCountPrice, promotionAmount )); - info.updateSurveyInfo(dueCount, gender, ages, residence, genderPrice, agePrice, residencePrice, dueCountPrice); + info.updateSurveyInfo(dueCount, gender, ages, residence, genderPrice, agePrice, residencePrice, dueCountPrice, promotionAmount); if (!refundable) info.markNonRefundable(); diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQuery.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQuery.java index 19e24a11..004e5064 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQuery.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQuery.java @@ -41,6 +41,7 @@ ParticipationScreeningListResponse getScreeningList( boolean checkValidSegmentation(Long surveyId, Long userKey); Survey getSurveyById(Long surveyId); + Integer getPromotionAmountBySurveyId(Long surveyId); // 외부 PORT Page getPagedSurveyListViewByQuery(Pageable pageable, SurveySearchQuery query); diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java index 17f8f3c7..23228f37 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java @@ -498,6 +498,13 @@ public Survey getSurveyById(Long surveyId) { .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_NOT_FOUND)); } + @Override + public Integer getPromotionAmountBySurveyId(Long surveyId) { + return surveyInfoRepository.findBySurveyId(surveyId) + .map(info -> info.getPromotionAmount()) + .orElse(null); + } + // 외부 PORT @Override public Page getPagedSurveyListViewByQuery(Pageable pageable, SurveySearchQuery query) { diff --git a/src/main/java/OneQ/OnSurvey/global/promotion/application/PromotionFacade.java b/src/main/java/OneQ/OnSurvey/global/promotion/application/PromotionFacade.java index cb0ec6da..fea6cdab 100644 --- a/src/main/java/OneQ/OnSurvey/global/promotion/application/PromotionFacade.java +++ b/src/main/java/OneQ/OnSurvey/global/promotion/application/PromotionFacade.java @@ -49,6 +49,12 @@ public class PromotionFacade implements PromotionUseCase { @Value("${toss.api.promotion.code}") private String promotionCode; + @Value("${toss.api.promotion.amount500}") + private int promotionAmount500; + + @Value("${toss.api.promotion.code500}") + private String promotionCode500; + @Value("${toss.api.promotion.confirm-wait-ms}") private long confirmWaitMs; @@ -67,6 +73,22 @@ void initSsl() throws Exception { this.tossSslContext = tossPromotionPort.createSSLContext(publicCrt, privateKey); } + private record PromoTier(String code, int amount) {} + + private PromoTier resolvePromoTier(long surveyId) { + Integer storedAmount = surveyQueryService.getPromotionAmountBySurveyId(surveyId); + int amount = (storedAmount != null) ? storedAmount : promotionAmount; + String code = (amount == promotionAmount500) ? promotionCode500 : promotionCode; + return new PromoTier(code, amount); + } + + private PromoTier derivePromoTierFromCode(String code) { + if (promotionCode500.equals(code)) { + return new PromoTier(promotionCode500, promotionAmount500); + } + return new PromoTier(promotionCode, promotionAmount); + } + @Override @Transactional public ExecutionResultResponse issueAndConfirm(long userKey, long surveyId) { @@ -79,10 +101,12 @@ public ExecutionResultResponse issueAndConfirm(long userKey, long surveyId) { throw new CustomException(SurveyErrorCode.SURVEY_FREE_PROMOTION_NOT_ALLOWED); } + PromoTier tier = resolvePromoTier(surveyId); + // 최초 실행 / 재시도 실행 경로 - Long grantId = upsertGrantId(userKey, surveyId, promotionCode); + Long grantId = upsertGrantId(userKey, surveyId, tier.code()); - String lockKey = buildLockKey(userKey, surveyId); + String lockKey = buildLockKey(userKey, surveyId, tier.code()); if (!tokenStore.acquireLock(lockKey, LOCK_TTL)) { return ExecutionResultResponse.pending(); } @@ -92,12 +116,12 @@ public ExecutionResultResponse issueAndConfirm(long userKey, long surveyId) { .orElseThrow(() -> new CustomException(TossErrorCode.TOSS_PROMOTION_NOT_FOUND)); if (grant.isSuccess()) { - grantPromotionPointIfNeeded(grantId, userKey); + grantPromotionPointIfNeeded(grantId, userKey, tier.amount()); return ExecutionResultResponse.success(); } if (grant.isPending() && grant.getExecKey() != null) { - return pollWithRecoveryAndPersist(grant, userKey, grant.getExecKey()); + return pollWithRecoveryAndPersist(grant, userKey, grant.getExecKey(), tier.code(), tier.amount()); } try { @@ -109,23 +133,23 @@ public ExecutionResultResponse issueAndConfirm(long userKey, long surveyId) { } ExecutePromotionResponse execResp = tossPromotionPort.executePromotionWithRetry( - userKey, promotionCode, execKey, promotionAmount, 2, tossSslContext); + userKey, tier.code(), execKey, tier.amount(), 2, tossSslContext); grantTx.saveExecKey(grant.getId(), execResp.key()); ExecutionResultResponse finalRes = waitResultUntilFinalWithRecovery( - grant, userKey, promotionCode, execResp.key(), confirmWaitMs); + grant, userKey, tier.code(), execResp.key(), tier.amount(), confirmWaitMs); switch (finalRes.status()) { case "SUCCESS" -> { grantTx.markSuccess(grant.getId()); - grantPromotionPointIfNeeded(grantId, userKey); + grantPromotionPointIfNeeded(grantId, userKey, tier.amount()); } case "PENDING" -> grantTx.markPending(grant.getId(), execResp.key()); default -> grantTx.markFail(grant.getId()); } log.info("[PROMO] userKey={} surveyId={} code={} amount={} execKey={} status={}", - userKey, surveyId, maskKey(promotionCode), promotionAmount, maskKey(execResp.key()), finalRes.status()); + userKey, surveyId, maskKey(tier.code()), tier.amount(), maskKey(execResp.key()), finalRes.status()); if ("FAILED".equals(finalRes.status())) { throw new CustomException(TossErrorCode.TOSS_PROMOTION_API_ERROR); @@ -159,25 +183,26 @@ private Long upsertGrantId(long userKey, long surveyId, String promotionCode) { } } - protected void grantPromotionPointIfNeeded(Long grantId, long userKey) { + protected void grantPromotionPointIfNeeded(Long grantId, long userKey, int amount) { int updated = promotionGrantRepository.markPointGrantedIfFalse(grantId); if (updated == 0) return; Member member = memberRepository.findMemberByUserKey(userKey) .orElseThrow(() -> new CustomException(MemberErrorCode.MEMBER_NOT_FOUND)); - member.increasePromotionPoint(promotionAmount); + member.increasePromotionPoint(amount); surveyGlobalStatsService.addPromotionCount(1); } - private ExecutionResultResponse pollWithRecoveryAndPersist(PromotionGrant grant, long userKey, String execKey) { + private ExecutionResultResponse pollWithRecoveryAndPersist( + PromotionGrant grant, long userKey, String execKey, String promoCode, int amount) { try { ExecutionResultResponse res = waitResultUntilFinalWithRecovery( - grant, userKey, promotionCode, execKey, confirmWaitMs); + grant, userKey, promoCode, execKey, amount, confirmWaitMs); switch (res.status()) { case "SUCCESS" -> { grantTx.markSuccess(grant.getId()); - grantPromotionPointIfNeeded(grant.getId(), userKey); + grantPromotionPointIfNeeded(grant.getId(), userKey, amount); } case "PENDING" -> grantTx.markPending(grant.getId(), execKey); default -> grantTx.markFail(grant.getId()); @@ -196,10 +221,10 @@ private ExecutionResultResponse pollWithRecoveryAndPersist(PromotionGrant grant, /** execution-result를 성공/실패가 나오거나 타임아웃될 때까지 백오프 폴링 */ private ExecutionResultResponse waitResultUntilFinalWithRecovery( - PromotionGrant grant, long userKey, String promoCode, String execKey, long waitMs) throws Exception { + PromotionGrant grant, long userKey, String promoCode, String execKey, int amount, long waitMs) throws Exception { if (waitMs <= 0) { - return getResultOrRecoverOnce(grant, userKey, promoCode, execKey); + return getResultOrRecoverOnce(grant, userKey, promoCode, execKey, amount); } long deadline = System.currentTimeMillis() + waitMs; @@ -214,7 +239,7 @@ private ExecutionResultResponse waitResultUntilFinalWithRecovery( if (te.getCode() == 4111) { // 아직 execute가 반영 안 됐다고 판단 → 1회 보강 ExecutePromotionResponse execResp = - tossPromotionPort.executePromotionWithRetry(userKey, promoCode, execKey, promotionAmount, 1, tossSslContext); + tossPromotionPort.executePromotionWithRetry(userKey, promoCode, execKey, amount, 1, tossSslContext); execKey = execResp.key(); grantTx.saveExecKey(grant.getId(), execKey); // 다음 루프에서 다시 조회 @@ -231,13 +256,13 @@ private ExecutionResultResponse waitResultUntilFinalWithRecovery( } private ExecutionResultResponse getResultOrRecoverOnce( - PromotionGrant grant, long userKey, String promoCode, String execKey) throws Exception { + PromotionGrant grant, long userKey, String promoCode, String execKey, int amount) throws Exception { try { return tossPromotionPort.getPromotionResult(userKey, promoCode, execKey, tossSslContext); } catch (TossApiException te) { if (te.getCode() == 4111) { ExecutePromotionResponse execResp = - tossPromotionPort.executePromotionWithRetry(userKey, promoCode, execKey, promotionAmount, 1, tossSslContext); + tossPromotionPort.executePromotionWithRetry(userKey, promoCode, execKey, amount, 1, tossSslContext); grantTx.saveExecKey(grant.getId(), execResp.key()); return tossPromotionPort.getPromotionResult(userKey, promoCode, execResp.key(), tossSslContext); } @@ -250,8 +275,8 @@ private boolean isKeyExpired(PromotionGrant g) { Duration.between(g.getExecKeyIssuedAt(), Instant.now()).compareTo(KEY_TTL) > 0; } - private String buildLockKey(long userKey, long surveyId) { - return "promo:lock:" + promotionCode + ":user:" + userKey + ":survey:" + surveyId; + private String buildLockKey(long userKey, long surveyId, String promoCode) { + return "promo:lock:" + promoCode + ":user:" + userKey + ":survey:" + surveyId; } /** 민감 정보 마스킹 */ @@ -277,7 +302,8 @@ public void recheckPendingGrant(long grantId) { // 이미 성공이면 포인트만 보정하고 종료 if (grant.isSuccess()) { - grantPromotionPointIfNeeded(grantId, grant.getUserKey()); + PromoTier tier = derivePromoTierFromCode(grant.getPromotionCode()); + grantPromotionPointIfNeeded(grantId, grant.getUserKey(), tier.amount()); return; } @@ -285,17 +311,17 @@ public void recheckPendingGrant(long grantId) { return; } + PromoTier tier = derivePromoTierFromCode(grant.getPromotionCode()); ExecutionResultResponse res = - pollWithRecoveryAndPersist(grant, grant.getUserKey(), grant.getExecKey()); + pollWithRecoveryAndPersist(grant, grant.getUserKey(), grant.getExecKey(), tier.code(), tier.amount()); switch (res.status()) { case "SUCCESS" -> { grantTx.markSuccess(grantId); - grantPromotionPointIfNeeded(grantId, grant.getUserKey()); + grantPromotionPointIfNeeded(grantId, grant.getUserKey(), tier.amount()); } case "PENDING" -> grantTx.markPending(grantId, grant.getExecKey()); default -> grantTx.markFail(grantId); } - } } From 21fe7b06e8debb6b54209cdce9a29754ec09cd1a Mon Sep 17 00:00:00 2001 From: jaekwan Date: Tue, 10 Mar 2026 23:18:25 +0900 Subject: [PATCH 02/50] =?UTF-8?q?refactor:=20promotion=20tier=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=20=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../promotion/application/PromoTier.java | 3 ++ .../application/PromotionFacade.java | 35 ++--------------- .../application/PromotionTierResolver.java | 39 +++++++++++++++++++ 3 files changed, 46 insertions(+), 31 deletions(-) create mode 100644 src/main/java/OneQ/OnSurvey/global/promotion/application/PromoTier.java create mode 100644 src/main/java/OneQ/OnSurvey/global/promotion/application/PromotionTierResolver.java diff --git a/src/main/java/OneQ/OnSurvey/global/promotion/application/PromoTier.java b/src/main/java/OneQ/OnSurvey/global/promotion/application/PromoTier.java new file mode 100644 index 00000000..05eaa88e --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/promotion/application/PromoTier.java @@ -0,0 +1,3 @@ +package OneQ.OnSurvey.global.promotion.application; + +public record PromoTier(String code, int amount) {} diff --git a/src/main/java/OneQ/OnSurvey/global/promotion/application/PromotionFacade.java b/src/main/java/OneQ/OnSurvey/global/promotion/application/PromotionFacade.java index fea6cdab..d925d3f0 100644 --- a/src/main/java/OneQ/OnSurvey/global/promotion/application/PromotionFacade.java +++ b/src/main/java/OneQ/OnSurvey/global/promotion/application/PromotionFacade.java @@ -43,18 +43,6 @@ public class PromotionFacade implements PromotionUseCase { @Value("${toss.secret.public-crt}") private String publicCrt; - @Value("${toss.api.promotion.amount}") - private int promotionAmount; - - @Value("${toss.api.promotion.code}") - private String promotionCode; - - @Value("${toss.api.promotion.amount500}") - private int promotionAmount500; - - @Value("${toss.api.promotion.code500}") - private String promotionCode500; - @Value("${toss.api.promotion.confirm-wait-ms}") private long confirmWaitMs; @@ -65,6 +53,7 @@ public class PromotionFacade implements PromotionUseCase { private final MemberRepository memberRepository; private final SurveyGlobalStatsService surveyGlobalStatsService; private final SurveyQueryService surveyQueryService; + private final PromotionTierResolver promotionTierResolver; private SSLContext tossSslContext; @@ -73,22 +62,6 @@ void initSsl() throws Exception { this.tossSslContext = tossPromotionPort.createSSLContext(publicCrt, privateKey); } - private record PromoTier(String code, int amount) {} - - private PromoTier resolvePromoTier(long surveyId) { - Integer storedAmount = surveyQueryService.getPromotionAmountBySurveyId(surveyId); - int amount = (storedAmount != null) ? storedAmount : promotionAmount; - String code = (amount == promotionAmount500) ? promotionCode500 : promotionCode; - return new PromoTier(code, amount); - } - - private PromoTier derivePromoTierFromCode(String code) { - if (promotionCode500.equals(code)) { - return new PromoTier(promotionCode500, promotionAmount500); - } - return new PromoTier(promotionCode, promotionAmount); - } - @Override @Transactional public ExecutionResultResponse issueAndConfirm(long userKey, long surveyId) { @@ -101,7 +74,7 @@ public ExecutionResultResponse issueAndConfirm(long userKey, long surveyId) { throw new CustomException(SurveyErrorCode.SURVEY_FREE_PROMOTION_NOT_ALLOWED); } - PromoTier tier = resolvePromoTier(surveyId); + PromoTier tier = promotionTierResolver.resolveBysurveyId(surveyId); // 최초 실행 / 재시도 실행 경로 Long grantId = upsertGrantId(userKey, surveyId, tier.code()); @@ -302,7 +275,7 @@ public void recheckPendingGrant(long grantId) { // 이미 성공이면 포인트만 보정하고 종료 if (grant.isSuccess()) { - PromoTier tier = derivePromoTierFromCode(grant.getPromotionCode()); + PromoTier tier = promotionTierResolver.resolveByCode(grant.getPromotionCode()); grantPromotionPointIfNeeded(grantId, grant.getUserKey(), tier.amount()); return; } @@ -311,7 +284,7 @@ public void recheckPendingGrant(long grantId) { return; } - PromoTier tier = derivePromoTierFromCode(grant.getPromotionCode()); + PromoTier tier = promotionTierResolver.resolveByCode(grant.getPromotionCode()); ExecutionResultResponse res = pollWithRecoveryAndPersist(grant, grant.getUserKey(), grant.getExecKey(), tier.code(), tier.amount()); diff --git a/src/main/java/OneQ/OnSurvey/global/promotion/application/PromotionTierResolver.java b/src/main/java/OneQ/OnSurvey/global/promotion/application/PromotionTierResolver.java new file mode 100644 index 00000000..03fb9fd5 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/promotion/application/PromotionTierResolver.java @@ -0,0 +1,39 @@ +package OneQ.OnSurvey.global.promotion.application; + +import OneQ.OnSurvey.domain.survey.service.query.SurveyQueryService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PromotionTierResolver { + + @Value("${toss.api.promotion.amount}") + private int promotionAmount; + + @Value("${toss.api.promotion.code}") + private String promotionCode; + + @Value("${toss.api.promotion.amount500}") + private int promotionAmount500; + + @Value("${toss.api.promotion.code500}") + private String promotionCode500; + + private final SurveyQueryService surveyQueryService; + + public PromoTier resolveBysurveyId(long surveyId) { + Integer storedAmount = surveyQueryService.getPromotionAmountBySurveyId(surveyId); + int amount = (storedAmount != null) ? storedAmount : promotionAmount; + String code = (amount == promotionAmount500) ? promotionCode500 : promotionCode; + return new PromoTier(code, amount); + } + + public PromoTier resolveByCode(String code) { + if (promotionCode500.equals(code)) { + return new PromoTier(promotionCode500, promotionAmount500); + } + return new PromoTier(promotionCode, promotionAmount); + } +} From 472e1b06eae2b9f91c88e2a63013d4885cc49b7a Mon Sep 17 00:00:00 2001 From: jaekwan Date: Tue, 10 Mar 2026 23:45:32 +0900 Subject: [PATCH 03/50] =?UTF-8?q?refactor:=20promotion=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=20=EA=B2=B0=EC=A0=95=ED=95=98=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=ED=95=AD=20=EC=88=98=20min,=20max=20yml=EB=A1=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/command/SurveyCommandService.java | 14 +++----------- .../promotion/application/PromotionFacade.java | 2 +- .../application/PromotionTierResolver.java | 18 +++++++++++++++++- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java index 77442fc7..a7133789 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java @@ -5,6 +5,7 @@ import OneQ.OnSurvey.domain.member.repository.MemberRepository; import OneQ.OnSurvey.domain.member.value.Interest; import OneQ.OnSurvey.domain.question.service.QuestionQueryService; +import OneQ.OnSurvey.global.promotion.application.PromotionTierResolver; import OneQ.OnSurvey.domain.survey.SurveyErrorCode; import OneQ.OnSurvey.domain.survey.entity.Screening; import OneQ.OnSurvey.domain.survey.entity.Survey; @@ -61,19 +62,11 @@ public class SurveyCommandService implements SurveyCommand { private final SurveyGlobalStatsService surveyGlobalStatsService; private final RedisAgent redisAgent; private final QuestionQueryService questionQueryService; + private final PromotionTierResolver promotionTierResolver; private final AlertNotifier alertNotifier; private final AfterCommitExecutor afterCommitExecutor; - @Value("${toss.api.promotion.amount}") - private int promotionAmount; - - @Value("${toss.api.promotion.amount500}") - private int promotionAmount500; - - private static final int TIER2_MIN_QUESTIONS = 30; - private static final int TIER2_MAX_QUESTIONS = 49; - @Value("${redis.survey-key-prefix.potential-count}") private String potentialKey; @Value("${redis.survey-key-prefix.completed-count}") @@ -135,8 +128,7 @@ public SurveyFormResponse submitSurvey(Long userKey, Long surveyId, SurveyFormRe survey.updateSurvey(survey.getTitle(), survey.getDescription(), request.deadline(), request.totalCoin()); int questionCount = questionQueryService.countQuestionsBySurveyId(surveyId); - int resolvedPromotionAmount = (questionCount >= TIER2_MIN_QUESTIONS && questionCount <= TIER2_MAX_QUESTIONS) - ? promotionAmount500 : promotionAmount; + int resolvedPromotionAmount = promotionTierResolver.resolveAmountByQuestionCount(questionCount); SurveyInfo info = upsertSurveyInfo( surveyId, diff --git a/src/main/java/OneQ/OnSurvey/global/promotion/application/PromotionFacade.java b/src/main/java/OneQ/OnSurvey/global/promotion/application/PromotionFacade.java index d925d3f0..3b7232ca 100644 --- a/src/main/java/OneQ/OnSurvey/global/promotion/application/PromotionFacade.java +++ b/src/main/java/OneQ/OnSurvey/global/promotion/application/PromotionFacade.java @@ -74,7 +74,7 @@ public ExecutionResultResponse issueAndConfirm(long userKey, long surveyId) { throw new CustomException(SurveyErrorCode.SURVEY_FREE_PROMOTION_NOT_ALLOWED); } - PromoTier tier = promotionTierResolver.resolveBysurveyId(surveyId); + PromoTier tier = promotionTierResolver.resolveBySurveyId(surveyId); // 최초 실행 / 재시도 실행 경로 Long grantId = upsertGrantId(userKey, surveyId, tier.code()); diff --git a/src/main/java/OneQ/OnSurvey/global/promotion/application/PromotionTierResolver.java b/src/main/java/OneQ/OnSurvey/global/promotion/application/PromotionTierResolver.java index 03fb9fd5..5186bd93 100644 --- a/src/main/java/OneQ/OnSurvey/global/promotion/application/PromotionTierResolver.java +++ b/src/main/java/OneQ/OnSurvey/global/promotion/application/PromotionTierResolver.java @@ -9,6 +9,12 @@ @RequiredArgsConstructor public class PromotionTierResolver { + @Value("${toss.api.promotion.amount500-question-min}") + private int amount500QuestionMin; + + @Value("${toss.api.promotion.amount500-question-max}") + private int amount500QuestionMax; + @Value("${toss.api.promotion.amount}") private int promotionAmount; @@ -23,17 +29,27 @@ public class PromotionTierResolver { private final SurveyQueryService surveyQueryService; - public PromoTier resolveBysurveyId(long surveyId) { + /** SurveyInfo에 저장된 promotionAmount 기반으로 티어 결정 */ + public PromoTier resolveBySurveyId(long surveyId) { Integer storedAmount = surveyQueryService.getPromotionAmountBySurveyId(surveyId); int amount = (storedAmount != null) ? storedAmount : promotionAmount; String code = (amount == promotionAmount500) ? promotionCode500 : promotionCode; return new PromoTier(code, amount); } + /** 저장된 promotionCode 기반으로 티어 역추적 (recheckPendingGrant용) */ public PromoTier resolveByCode(String code) { if (promotionCode500.equals(code)) { return new PromoTier(promotionCode500, promotionAmount500); } return new PromoTier(promotionCode, promotionAmount); } + + /** 설문 제출 시 문항 수 기반으로 지급 금액 결정 */ + public int resolveAmountByQuestionCount(int questionCount) { + if (questionCount >= amount500QuestionMin && questionCount <= amount500QuestionMax) { + return promotionAmount500; + } + return promotionAmount; + } } From 5a1443d3523dff83872e7cf540617a19bcff019c Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Wed, 11 Mar 2026 17:09:48 +0900 Subject: [PATCH 04/50] =?UTF-8?q?fix:=20yml=ED=8C=8C=EC=9D=BC=20=EC=9D=B8?= =?UTF-8?q?=EB=8D=B4=ED=8A=B8=EB=A1=9C=20=EC=9D=B8=ED=95=B4=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=9D=B4=20=EB=88=84=EB=9D=BD=EB=90=9C=20=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .coderabbit.yml | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/.coderabbit.yml b/.coderabbit.yml index 93b75ecc..1b11da7c 100644 --- a/.coderabbit.yml +++ b/.coderabbit.yml @@ -47,26 +47,26 @@ reviews: "M2R" ] -tools: - # Backend - pmd: # Java - enabled: true - sqlfluff: # SQL - enabled: true + tools: + # Backend + pmd: # Java + enabled: true + sqlfluff: # SQL + enabled: true - # Security - gitleaks: - enabled: true - semgrep: - enabled: true + # Security + gitleaks: + enabled: true + semgrep: + enabled: true - # Infrastructure - actionlint: # GitHub Actions - enabled: true - hadolint: # Dockerfile - enabled: true - yamllint: # YAML - enabled: true + # Infrastructure + actionlint: # GitHub Actions + enabled: true + hadolint: # Dockerfile + enabled: true + yamllint: # YAML + enabled: true chat: auto_reply: true From b3bb2b426a938da622ceffb6f8f26095e7e01c1c Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Wed, 11 Mar 2026 17:11:45 +0900 Subject: [PATCH 05/50] =?UTF-8?q?chore:=20=EC=98=A4=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=8A=A4=ED=82=B5=20=ED=83=80=EC=9D=B4=ED=8B=80=20?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=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 --- .coderabbit.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.coderabbit.yml b/.coderabbit.yml index 1b11da7c..ca0ceda5 100644 --- a/.coderabbit.yml +++ b/.coderabbit.yml @@ -34,17 +34,15 @@ reviews: "Docs", "Documentation", "Style", - "Refactor", "Test", "Tests", "Typo", "Merge branch", "Revert", "의존성", - "D2R", - "R2D", - "R2M", - "M2R" + "D2R", "D2M", + "R2D", "R2M", + "M2D", "M2R" ] tools: From b210d20046ff0cf154cae52558d9ab8b6422aab6 Mon Sep 17 00:00:00 2001 From: jaekwan Date: Sun, 15 Mar 2026 16:22:48 +0900 Subject: [PATCH 06/50] =?UTF-8?q?feat:=20=ED=95=99=ED=9A=8C=EB=B3=84=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=BD=94=EB=93=9C=20=EA=B4=80=EB=A6=AC,?= =?UTF-8?q?=20=EC=84=A4=EB=AC=B8=20=EB=93=B1=EB=A1=9D=EC=8B=9C=20=EC=BD=94?= =?UTF-8?q?=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 --- .../discount/DiscountCodeErrorCode.java | 17 ++++++++ .../controller/DiscountCodeController.java | 43 +++++++++++++++++++ .../domain/discount/entity/DiscountCode.java | 32 ++++++++++++++ .../request/CreateDiscountCodeRequest.java | 11 +++++ .../model/response/DiscountCodeResponse.java | 21 +++++++++ .../ValidateDiscountCodeResponse.java | 6 +++ .../repository/DiscountCodeJpaRepository.java | 11 +++++ .../repository/DiscountCodeRepository.java | 13 ++++++ .../DiscountCodeRepositoryImpl.java | 35 +++++++++++++++ .../service/DiscountCodeCommandService.java | 31 +++++++++++++ .../service/DiscountCodeQueryService.java | 42 ++++++++++++++++++ .../domain/survey/entity/SurveyInfo.java | 11 ++++- .../model/request/SurveyFormRequest.java | 5 ++- .../service/command/SurveyCommandService.java | 18 +++++++- 14 files changed, 291 insertions(+), 5 deletions(-) create mode 100644 src/main/java/OneQ/OnSurvey/domain/discount/DiscountCodeErrorCode.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/discount/controller/DiscountCodeController.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/discount/entity/DiscountCode.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/discount/model/request/CreateDiscountCodeRequest.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/discount/model/response/DiscountCodeResponse.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/discount/model/response/ValidateDiscountCodeResponse.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/discount/repository/DiscountCodeJpaRepository.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/discount/repository/DiscountCodeRepository.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/discount/repository/DiscountCodeRepositoryImpl.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeCommandService.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeQueryService.java diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/DiscountCodeErrorCode.java b/src/main/java/OneQ/OnSurvey/domain/discount/DiscountCodeErrorCode.java new file mode 100644 index 00000000..8d0f8ed8 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/DiscountCodeErrorCode.java @@ -0,0 +1,17 @@ +package OneQ.OnSurvey.domain.discount; + +import OneQ.OnSurvey.global.common.exception.ApiErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum DiscountCodeErrorCode implements ApiErrorCode { + + DISCOUNT_CODE_NOT_FOUND("DISCOUNT_404", "유효하지 않은 할인 코드입니다.", HttpStatus.NOT_FOUND); + + private final String errorCode; + private final String message; + private final HttpStatus status; +} diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/controller/DiscountCodeController.java b/src/main/java/OneQ/OnSurvey/domain/discount/controller/DiscountCodeController.java new file mode 100644 index 00000000..4d99b6ce --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/controller/DiscountCodeController.java @@ -0,0 +1,43 @@ +package OneQ.OnSurvey.domain.discount.controller; + +import OneQ.OnSurvey.global.common.response.SuccessResponse; +import OneQ.OnSurvey.domain.discount.model.request.CreateDiscountCodeRequest; +import OneQ.OnSurvey.domain.discount.model.response.DiscountCodeResponse; +import OneQ.OnSurvey.domain.discount.model.response.ValidateDiscountCodeResponse; +import OneQ.OnSurvey.domain.discount.service.DiscountCodeCommandService; +import OneQ.OnSurvey.domain.discount.service.DiscountCodeQueryService; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/v1/discount-codes") +@RequiredArgsConstructor +public class DiscountCodeController { + + private final DiscountCodeQueryService discountCodeQueryService; + private final DiscountCodeCommandService discountCodeCommandService; + + @GetMapping("/{code}") + @Operation(summary = "할인 코드를 검증하고 할인 정보를 반환합니다.") + public SuccessResponse validate(@PathVariable String code) { + return SuccessResponse.ok(discountCodeQueryService.validate(code)); + } + + @PostMapping + @Operation(summary = "할인 코드를 생성합니다.") + public SuccessResponse create( + @RequestBody @Valid CreateDiscountCodeRequest request + ) { + return SuccessResponse.ok(discountCodeCommandService.create(request)); + } + + @GetMapping + @Operation(summary = "전체 할인 코드 목록을 조회합니다.") + public SuccessResponse> findAll() { + return SuccessResponse.ok(discountCodeQueryService.findAll()); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/entity/DiscountCode.java b/src/main/java/OneQ/OnSurvey/domain/discount/entity/DiscountCode.java new file mode 100644 index 00000000..fa54ff60 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/entity/DiscountCode.java @@ -0,0 +1,32 @@ +package OneQ.OnSurvey.domain.discount.entity; + +import OneQ.OnSurvey.global.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Entity +@Table(name = "discount_code") +public class DiscountCode extends BaseEntity { + + @Id + @Column(name = "discount_code_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "organization_name", nullable = false) + private String organizationName; + + @Column(name = "code", nullable = false, unique = true, length = 16) + private String code; + + public static DiscountCode of(String organizationName, String code) { + return DiscountCode.builder() + .organizationName(organizationName) + .code(code) + .build(); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/model/request/CreateDiscountCodeRequest.java b/src/main/java/OneQ/OnSurvey/domain/discount/model/request/CreateDiscountCodeRequest.java new file mode 100644 index 00000000..10b9f642 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/model/request/CreateDiscountCodeRequest.java @@ -0,0 +1,11 @@ +package OneQ.OnSurvey.domain.discount.model.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record CreateDiscountCodeRequest( + @NotBlank + @Schema(description = "학회(기관) 이름", example = "onsurvey") + String organizationName +) { +} diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/model/response/DiscountCodeResponse.java b/src/main/java/OneQ/OnSurvey/domain/discount/model/response/DiscountCodeResponse.java new file mode 100644 index 00000000..470511eb --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/model/response/DiscountCodeResponse.java @@ -0,0 +1,21 @@ +package OneQ.OnSurvey.domain.discount.model.response; + +import OneQ.OnSurvey.domain.discount.entity.DiscountCode; + +import java.time.LocalDateTime; + +public record DiscountCodeResponse( + Long id, + String organizationName, + String code, + LocalDateTime createdAt +) { + public static DiscountCodeResponse from(DiscountCode entity) { + return new DiscountCodeResponse( + entity.getId(), + entity.getOrganizationName(), + entity.getCode(), + entity.getCreatedAt() + ); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/model/response/ValidateDiscountCodeResponse.java b/src/main/java/OneQ/OnSurvey/domain/discount/model/response/ValidateDiscountCodeResponse.java new file mode 100644 index 00000000..4673a6ea --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/model/response/ValidateDiscountCodeResponse.java @@ -0,0 +1,6 @@ +package OneQ.OnSurvey.domain.discount.model.response; + +public record ValidateDiscountCodeResponse( + boolean eligible +) { +} diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/repository/DiscountCodeJpaRepository.java b/src/main/java/OneQ/OnSurvey/domain/discount/repository/DiscountCodeJpaRepository.java new file mode 100644 index 00000000..812fd03d --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/repository/DiscountCodeJpaRepository.java @@ -0,0 +1,11 @@ +package OneQ.OnSurvey.domain.discount.repository; + +import OneQ.OnSurvey.domain.discount.entity.DiscountCode; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface DiscountCodeJpaRepository extends JpaRepository { + Optional findByCode(String code); + boolean existsByCode(String code); +} diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/repository/DiscountCodeRepository.java b/src/main/java/OneQ/OnSurvey/domain/discount/repository/DiscountCodeRepository.java new file mode 100644 index 00000000..1698afc2 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/repository/DiscountCodeRepository.java @@ -0,0 +1,13 @@ +package OneQ.OnSurvey.domain.discount.repository; + +import OneQ.OnSurvey.domain.discount.entity.DiscountCode; + +import java.util.List; +import java.util.Optional; + +public interface DiscountCodeRepository { + DiscountCode save(DiscountCode discountCode); + Optional findByCode(String code); + boolean existsByCode(String code); + List findAll(); +} diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/repository/DiscountCodeRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/discount/repository/DiscountCodeRepositoryImpl.java new file mode 100644 index 00000000..95794027 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/repository/DiscountCodeRepositoryImpl.java @@ -0,0 +1,35 @@ +package OneQ.OnSurvey.domain.discount.repository; + +import OneQ.OnSurvey.domain.discount.entity.DiscountCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class DiscountCodeRepositoryImpl implements DiscountCodeRepository { + + private final DiscountCodeJpaRepository jpaRepository; + + @Override + public DiscountCode save(DiscountCode discountCode) { + return jpaRepository.save(discountCode); + } + + @Override + public Optional findByCode(String code) { + return jpaRepository.findByCode(code); + } + + @Override + public boolean existsByCode(String code) { + return jpaRepository.existsByCode(code); + } + + @Override + public List findAll() { + return jpaRepository.findAll(); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeCommandService.java b/src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeCommandService.java new file mode 100644 index 00000000..53a67338 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeCommandService.java @@ -0,0 +1,31 @@ +package OneQ.OnSurvey.domain.discount.service; + +import OneQ.OnSurvey.domain.discount.entity.DiscountCode; +import OneQ.OnSurvey.domain.discount.model.request.CreateDiscountCodeRequest; +import OneQ.OnSurvey.domain.discount.model.response.DiscountCodeResponse; +import OneQ.OnSurvey.domain.discount.repository.DiscountCodeRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class DiscountCodeCommandService { + + private final DiscountCodeRepository discountCodeRepository; + + public DiscountCodeResponse create(CreateDiscountCodeRequest request) { + String code = UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(); + DiscountCode discountCode = DiscountCode.of(request.organizationName(), code); + discountCode = discountCodeRepository.save(discountCode); + + log.info("[DiscountCode:create] 할인 코드 생성 - org={}, code={}", request.organizationName(), code); + + return DiscountCodeResponse.from(discountCode); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeQueryService.java b/src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeQueryService.java new file mode 100644 index 00000000..a070d63d --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeQueryService.java @@ -0,0 +1,42 @@ +package OneQ.OnSurvey.domain.discount.service; + +import OneQ.OnSurvey.global.common.exception.CustomException; +import OneQ.OnSurvey.domain.discount.DiscountCodeErrorCode; +import OneQ.OnSurvey.domain.discount.entity.DiscountCode; +import OneQ.OnSurvey.domain.discount.model.response.DiscountCodeResponse; +import OneQ.OnSurvey.domain.discount.model.response.ValidateDiscountCodeResponse; +import OneQ.OnSurvey.domain.discount.repository.DiscountCodeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DiscountCodeQueryService { + + private final DiscountCodeRepository discountCodeRepository; + + /** 코드 존재 여부만 확인 */ + public ValidateDiscountCodeResponse validate(String code) { + boolean eligible = discountCodeRepository.existsByCode(code); + if (!eligible) { + throw new CustomException(DiscountCodeErrorCode.DISCOUNT_CODE_NOT_FOUND); + } + return new ValidateDiscountCodeResponse(true); + } + + /** 설문 등록 시 코드 검증 후 엔티티 반환 */ + public DiscountCode getByCode(String code) { + return discountCodeRepository.findByCode(code) + .orElseThrow(() -> new CustomException(DiscountCodeErrorCode.DISCOUNT_CODE_NOT_FOUND)); + } + + public List findAll() { + return discountCodeRepository.findAll().stream() + .map(DiscountCodeResponse::from) + .toList(); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/entity/SurveyInfo.java b/src/main/java/OneQ/OnSurvey/domain/survey/entity/SurveyInfo.java index 85030ffe..faee7e84 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/entity/SurveyInfo.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/entity/SurveyInfo.java @@ -51,6 +51,9 @@ public class SurveyInfo { private Integer promotionAmount; + @Column(name = "discount_code_id") + private Long discountCodeId; + @Builder.Default @Column(nullable = false) private boolean refundable = true; @@ -65,7 +68,8 @@ public static SurveyInfo createSurveyInfo( Integer agePrice, Integer residencePrice, Integer dueCountPrice, - Integer promotionAmount + Integer promotionAmount, + Long discountCodeId ) { return SurveyInfo.builder() .surveyId(surveyId) @@ -79,6 +83,7 @@ public static SurveyInfo createSurveyInfo( .residencePrice(residencePrice) .dueCountPrice(dueCountPrice) .promotionAmount(promotionAmount) + .discountCodeId(discountCodeId) .refundable(true) .build(); } @@ -92,7 +97,8 @@ public void updateSurveyInfo( Integer agePrice, Integer residencePrice, Integer dueCountPrice, - Integer promotionAmount + Integer promotionAmount, + Long discountCodeId ) { this.dueCount = dueCount; this.gender = gender; @@ -103,6 +109,7 @@ public void updateSurveyInfo( this.residencePrice = residencePrice; this.dueCountPrice = dueCountPrice; this.promotionAmount = promotionAmount; + this.discountCodeId = discountCodeId; } public void markNonRefundable() { diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/request/SurveyFormRequest.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/request/SurveyFormRequest.java index a2729b5a..5fa4186e 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/request/SurveyFormRequest.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/request/SurveyFormRequest.java @@ -35,6 +35,9 @@ public record SurveyFormRequest( Integer dueCountPrice, @Schema(description = "총 코인", example = "10000") - Integer totalCoin + Integer totalCoin, + + @Schema(description = "할인 코드 (선택)", example = "A1B2C3D4E5F6G7H8") + String discountCode ) { } \ No newline at end of file diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java index a7133789..0250e681 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java @@ -5,6 +5,8 @@ import OneQ.OnSurvey.domain.member.repository.MemberRepository; import OneQ.OnSurvey.domain.member.value.Interest; import OneQ.OnSurvey.domain.question.service.QuestionQueryService; +import OneQ.OnSurvey.domain.discount.entity.DiscountCode; +import OneQ.OnSurvey.domain.discount.service.DiscountCodeQueryService; import OneQ.OnSurvey.global.promotion.application.PromotionTierResolver; import OneQ.OnSurvey.domain.survey.SurveyErrorCode; import OneQ.OnSurvey.domain.survey.entity.Screening; @@ -63,6 +65,7 @@ public class SurveyCommandService implements SurveyCommand { private final RedisAgent redisAgent; private final QuestionQueryService questionQueryService; private final PromotionTierResolver promotionTierResolver; + private final DiscountCodeQueryService discountCodeQueryService; private final AlertNotifier alertNotifier; private final AfterCommitExecutor afterCommitExecutor; @@ -125,6 +128,14 @@ public SurveyFormResponse submitSurvey(Long userKey, Long surveyId, SurveyFormRe Set ages = (request.ages() == null) ? Set.of() : new HashSet<>(request.ages()); + // 할인 코드 저장 (존재 여부만 확인 후 ID 기록) + Long discountCodeId = null; + if (request.discountCode() != null && !request.discountCode().isBlank()) { + DiscountCode discountCode = discountCodeQueryService.getByCode(request.discountCode()); + discountCodeId = discountCode.getId(); + log.info("[SurveySubmit] 할인 코드 저장 - surveyId={}, org={}", surveyId, discountCode.getOrganizationName()); + } + survey.updateSurvey(survey.getTitle(), survey.getDescription(), request.deadline(), request.totalCoin()); int questionCount = questionQueryService.countQuestionsBySurveyId(surveyId); @@ -141,6 +152,7 @@ public SurveyFormResponse submitSurvey(Long userKey, Long surveyId, SurveyFormRe request.residencePrice(), request.dueCountPrice(), resolvedPromotionAmount, + discountCodeId, true ); @@ -166,6 +178,7 @@ public SurveyFormResponse submitFreeSurvey(Long userKey, Long surveyId, FreeSurv Residence.ALL, 0, 0, 0, 0, 0, + null, false ); @@ -300,15 +313,16 @@ private SurveyInfo upsertSurveyInfo( Integer residencePrice, Integer dueCountPrice, Integer promotionAmount, + Long discountCodeId, boolean refundable ) { SurveyInfo info = surveyInfoRepository.findBySurveyId(surveyId) .orElseGet(() -> SurveyInfo.createSurveyInfo( surveyId, dueCount, gender, ages, residence, - genderPrice, agePrice, residencePrice, dueCountPrice, promotionAmount + genderPrice, agePrice, residencePrice, dueCountPrice, promotionAmount, discountCodeId )); - info.updateSurveyInfo(dueCount, gender, ages, residence, genderPrice, agePrice, residencePrice, dueCountPrice, promotionAmount); + info.updateSurveyInfo(dueCount, gender, ages, residence, genderPrice, agePrice, residencePrice, dueCountPrice, promotionAmount, discountCodeId); if (!refundable) info.markNonRefundable(); From caa7a8612fff62e21cd8966d328be9c5f7988ad2 Mon Sep 17 00:00:00 2001 From: shash0423 Date: Mon, 16 Mar 2026 14:00:41 +0900 Subject: [PATCH 07/50] =?UTF-8?q?fix:=20isResponded=EA=B0=80=20true?= =?UTF-8?q?=EC=9D=B8=20response=EB=A7=8C=20=EC=A1=B0=ED=9A=8C=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../answer/QuestionAnswerRepositoryImpl.java | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java index 26abdb05..e5bf6e43 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java @@ -19,6 +19,8 @@ import static OneQ.OnSurvey.domain.member.QMember.member; import static OneQ.OnSurvey.domain.participation.entity.QQuestionAnswer.questionAnswer; +import static OneQ.OnSurvey.domain.participation.entity.QResponse.response; +import static OneQ.OnSurvey.domain.question.entity.QQuestion.question; @Repository public class QuestionAnswerRepositoryImpl extends AbstractAnswerRepository { @@ -50,7 +52,13 @@ public List getAggregatedAnswersByQuestionIds(List questionId questionAnswer.answerId.count() )) .from(questionAnswer) - .where(questionAnswer.questionId.in(questionIdList)) + .join(question).on(question.questionId.eq(questionAnswer.questionId)) + .join(response).on(response.surveyId.eq(question.surveyId) + .and(response.memberId.eq(questionAnswer.memberId))) + .where( + questionAnswer.questionId.in(questionIdList), + response.isResponded.isTrue() + ) .groupBy(questionAnswer.questionId, questionAnswer.content) .orderBy(questionAnswer.questionId.asc()) .fetch(); @@ -63,7 +71,13 @@ public List getAnswersByQuestionIds(List questionIdList) { questionAnswer.content )) .from(questionAnswer) - .where(questionAnswer.questionId.in(questionIdList)) + .join(question).on(question.questionId.eq(questionAnswer.questionId)) + .join(response).on(response.surveyId.eq(question.surveyId) + .and(response.memberId.eq(questionAnswer.memberId))) + .where( + questionAnswer.questionId.in(questionIdList), + response.isResponded.isTrue() + ) .orderBy(questionAnswer.questionId.asc()) .fetch(); } @@ -84,9 +98,13 @@ public List getAggregatedAnswersByQuestionIds( questionAnswer.answerId.count() )) .from(questionAnswer) + .join(question).on(question.questionId.eq(questionAnswer.questionId)) + .join(response).on(response.surveyId.eq(question.surveyId) + .and(response.memberId.eq(questionAnswer.memberId))) .join(member).on(member.id.eq(questionAnswer.memberId)) .where( questionAnswer.questionId.in(questionIds), + response.isResponded.isTrue(), buildAgeCondition(member.birthDay, effective.ages()), buildGenderCondition(member.gender, effective.genders()), buildResidenceCondition(member.residence, effective.residences()) @@ -116,9 +134,13 @@ public List getAnswersByQuestionIds( questionAnswer.content )) .from(questionAnswer) + .join(question).on(question.questionId.eq(questionAnswer.questionId)) + .join(response).on(response.surveyId.eq(question.surveyId) + .and(response.memberId.eq(questionAnswer.memberId))) .join(member).on(questionAnswer.memberId.eq(member.id)) .where( questionAnswer.questionId.in(questionIds), + response.isResponded.isTrue(), buildAgeCondition(member.birthDay, effective.ages()), buildGenderCondition(member.gender, effective.genders()), buildResidenceCondition(member.residence, effective.residences()) @@ -146,9 +168,13 @@ public List getRespondentCountsByQuestionIds( questionAnswer.memberId.countDistinct() )) .from(questionAnswer) + .join(question).on(question.questionId.eq(questionAnswer.questionId)) + .join(response).on(response.surveyId.eq(question.surveyId) + .and(response.memberId.eq(questionAnswer.memberId))) .join(member).on(member.id.eq(questionAnswer.memberId)) .where( questionAnswer.questionId.in(questionIds), + response.isResponded.isTrue(), buildAgeCondition(member.birthDay, effective.ages()), buildGenderCondition(member.gender, effective.genders()), buildResidenceCondition(member.residence, effective.residences()) From 7aba3e6db527d4635096998bfa6c4a4a2f7e1294 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Thu, 19 Mar 2026 23:33:44 +0900 Subject: [PATCH 08/50] =?UTF-8?q?chore:=20=EC=A3=BC=EC=84=9D=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../question/service/QuestionCommandService.java | 13 ++++++------- .../survey/service/form/SurveyFormFacade.java | 1 - .../service/formRequest/FormRequestLambda.java | 10 ---------- 3 files changed, 6 insertions(+), 18 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java index ded5dd8b..65b9106c 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java @@ -36,7 +36,7 @@ public class QuestionCommandService implements QuestionCommand { @Override public QuestionUpsertDto upsertQuestionList(QuestionUpsertDto upsertDto) { - log.info("[QUESTION:COMMAND:upsertQuestionList] 문항 UPSERT - surveyId: {}, upsertInfoList: {}", upsertDto.getSurveyId(), upsertDto.getUpsertInfoList().toString()); + log.info("[QUESTION:COMMAND:upsertQuestionList] 문항 UPSERT - surveyId: {}", upsertDto.getSurveyId()); Long surveyId = upsertDto.getSurveyId(); List upsertInfoList = upsertDto.getUpsertInfoList(); @@ -314,7 +314,7 @@ private Question createQuestion(Long surveyId, QuestionUpsertDto.UpsertInfo upse @Override public List upsertChoiceOptionList(List upsertDtoList) { - log.info("[QUESTION:COMMAND:upsertChoiceOptionList] 보기 UPSERT - upsertDtoList : {}", upsertDtoList.toString()); + log.info("[QUESTION:COMMAND:upsertChoiceOptionList] 보기 UPSERT"); List finalList = new ArrayList<>(); @@ -345,11 +345,10 @@ public List upsertChoiceOptionList(List upsert .map(ChoiceOption::getChoiceOptionId) .filter(optionId -> !updateIdSet.contains(optionId)) .collect(Collectors.toSet()); - log.info("[QUESTION:COMMAND:upsertChoiceOptionList] 삭제되는 문항: {}, 보기 IDs: {}", questionId, deleteIdSet); - - choiceOptionRepository.deleteAll(deleteIdSet); - - log.info("[QUESTION:COMMAND:upsertChoiceOptionList] DELETE 진행"); + if (!deleteIdSet.isEmpty()) { + log.info("[QUESTION:COMMAND:upsertChoiceOptionList] 삭제되는 문항: {}, 보기 IDs: {}", questionId, deleteIdSet); + choiceOptionRepository.deleteAll(deleteIdSet); + } // 5. Update 대상 수정 Map updateInfoMap = updateInfoList.stream().collect(Collectors.toMap( diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/form/SurveyFormFacade.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/form/SurveyFormFacade.java index a3db544d..7f0f29f3 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/form/SurveyFormFacade.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/form/SurveyFormFacade.java @@ -79,7 +79,6 @@ public UpdateQuestionResponse upsertQuestions(Long surveyId, QuestionRequest req List optionUpsertDtoList = buildOptionUpsertDtosFromSavedQuestions(savedQuestionUpsertDto, requestQuestionUpsertDto); - log.info("[FORM:updateSurvey] 문항 별 보기 리스트: {}", optionUpsertDtoList); optionUpsertDtoList = questionCommand.upsertChoiceOptionList(optionUpsertDtoList); Map optionDtoMap = mapOptionsByQuestionId(optionUpsertDtoList); diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java index 20c8a044..f1fc2142 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java @@ -109,7 +109,6 @@ public void convertGoogleFormIntoSurvey(FormRequestConversionEvent event) { formUpdater.markAsRegistered(event.requestId(), surveyId); log.info("[FormRequestLambda] 구글폼 변환 성공 - requestId: {}, surveyId: {}", event.requestId(), surveyId); - if (result.unsupportedQuestions() != null && !result.unsupportedQuestions().isEmpty()) { log.warn("[FormRequestLambda] 지원하지 않는 문항 존재 - requestId: {}, count: {}", event.requestId(), result.unsupportedQuestions().size()); @@ -158,8 +157,6 @@ private Long createSurveyFromConversionResult(FormConversionResponse.Result resu SurveyFormResponse surveyResponse = surveyCommand.upsertSurvey(memberId, null, surveyRequest); Long surveyId = surveyResponse.surveyId(); - log.info("[FormRequestLambda] 설문 생성 완료 - surveyId: {}, title: {}", surveyId, survey.title()); - // 2. 섹션 생성 if (survey.sections() != null && !survey.sections().isEmpty()) { List sectionDtoList = survey.sections().stream() @@ -173,7 +170,6 @@ private Long createSurveyFromConversionResult(FormConversionResponse.Result resu .toList(); questionCommand.upsertSections(surveyId, sectionDtoList); - log.info("[FormRequestLambda] 섹션 생성 완료 - surveyId: {}, sectionCount: {}", surveyId, sectionDtoList.size()); } // 3. 문항 생성 @@ -186,8 +182,6 @@ private Long createSurveyFromConversionResult(FormConversionResponse.Result resu for (FormConversionResponse.Question question : section.questions()) { QuestionType questionType = mapQuestionType(question.type()); if (questionType == null) { - log.warn("[FormRequestLambda] 지원하지 않는 문항 타입 - type: {}, title: {}", - question.type(), question.title()); continue; } @@ -240,8 +234,6 @@ private Long createSurveyFromConversionResult(FormConversionResponse.Result resu .build(); QuestionUpsertDto savedQuestions = questionCommand.upsertQuestionList(questionUpsertDto); - log.info("[FormRequestLambda] 문항 생성 완료 - surveyId: {}, questionCount: {}", - surveyId, savedQuestions.getUpsertInfoList().size()); // 4. Choice 문항의 옵션 저장 List optionUpsertDtoList = new ArrayList<>(); @@ -271,8 +263,6 @@ private Long createSurveyFromConversionResult(FormConversionResponse.Result resu if (!optionUpsertDtoList.isEmpty()) { questionCommand.upsertChoiceOptionList(optionUpsertDtoList); - log.info("[FormRequestLambda] 옵션 생성 완료 - surveyId: {}, choiceQuestionCount: {}", - surveyId, optionUpsertDtoList.size()); } } From e60552af06ae36b078b26dcc8db88ede2a7cea75 Mon Sep 17 00:00:00 2001 From: jaekwan Date: Sat, 21 Mar 2026 12:29:01 +0900 Subject: [PATCH 09/50] =?UTF-8?q?refactor:=20=ED=8F=BC=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89=20=ED=9D=90=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/survey/SurveyErrorCode.java | 4 +- .../controller/FormRequestController.java | 16 ++++++- .../domain/survey/entity/FormRequest.java | 33 ++++---------- .../model/formRequest/FormPublishRequest.java | 19 ++++++++ .../model/formRequest/FormRequestDto.java | 25 +---------- .../formRequest/FormRequestResponse.java | 7 +-- .../formRequest/FormCommandService.java | 45 ++++++++++++++++--- .../service/formRequest/FormPublisher.java | 8 ++++ .../formRequest/FormRequestLambda.java | 17 ++++--- .../service/formRequest/FormUpdater.java | 2 +- 10 files changed, 109 insertions(+), 67 deletions(-) create mode 100644 src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormPublishRequest.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormPublisher.java diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java b/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java index 0d489c19..08bb415e 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java @@ -31,7 +31,9 @@ public enum SurveyErrorCode implements ApiErrorCode { SURVEY_FREE_PROMOTION_NOT_ALLOWED("SURVEY_PROMOTION_400", "무료 설문은 프로모션 지급 대상이 아닙니다.", HttpStatus.BAD_REQUEST), FORM_REQUEST_NOT_FOUND("FORM_REQUEST_404", "구글 폼 신청을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), - FORM_CONVERSION_FAILED("FORM_REQUEST_001", "구글 폼 변환에 실패했습니다.", HttpStatus.BAD_REQUEST); + FORM_CONVERSION_FAILED("FORM_REQUEST_001", "구글 폼 변환에 실패했습니다.", HttpStatus.BAD_REQUEST), + FORM_REQUEST_NOT_YET_REGISTERED("FORM_REQUEST_409", "아직 설문 변환이 완료되지 않았습니다.", HttpStatus.CONFLICT), + FORM_REQUEST_MEMBER_NOT_FOUND("FORM_REQUEST_MEMBER_404", "폼 신청자에 해당하는 회원을 찾을 수 없습니다.", HttpStatus.NOT_FOUND); private final String errorCode; private final String message; diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java b/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java index 263ce7c5..c656e4f1 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java @@ -1,15 +1,19 @@ package OneQ.OnSurvey.domain.survey.controller; import OneQ.OnSurvey.domain.survey.model.formRequest.FormListResponse; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormPublishRequest; import OneQ.OnSurvey.domain.survey.model.formRequest.FormRequestDto; import OneQ.OnSurvey.domain.survey.model.formRequest.FormRequestResponse; +import OneQ.OnSurvey.domain.survey.model.response.SurveyFormResponse; import OneQ.OnSurvey.domain.survey.service.formRequest.FormCreator; import OneQ.OnSurvey.domain.survey.service.formRequest.FormFinder; +import OneQ.OnSurvey.domain.survey.service.formRequest.FormPublisher; import OneQ.OnSurvey.domain.survey.service.formRequest.FormUpdater; import OneQ.OnSurvey.global.common.response.PageResponse; import OneQ.OnSurvey.global.common.response.SuccessResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; @@ -25,6 +29,7 @@ public class FormRequestController { private final FormCreator formCreator; private final FormFinder formFinder; private final FormUpdater formUpdater; + private final FormPublisher formPublisher; @PostMapping @Operation(summary = "폼 등록 신청", description = "폼을 등록하기 위한 신청을 생성합니다.") @@ -64,7 +69,16 @@ public SuccessResponse markAsRegistered( @PathVariable Long requestId, @RequestParam Long surveyId ) { - formUpdater.markAsRegistered(requestId, surveyId); + formUpdater.markAsRegistered(requestId, surveyId, null); return SuccessResponse.ok("폼이 온서베이에 등록되었습니다."); } + + @PatchMapping("/{requestId}/publish") + @Operation(summary = "폼 설문 발행", description = "변환 완료된 설문에 스크리닝 및 세그먼트 정보를 적용하고 발행합니다.") + public SuccessResponse publishFormRequest( + @PathVariable Long requestId, + @RequestBody @Valid FormPublishRequest request + ) { + return SuccessResponse.ok(formPublisher.publishFormRequest(requestId, request)); + } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/entity/FormRequest.java b/src/main/java/OneQ/OnSurvey/domain/survey/entity/FormRequest.java index 8a34318f..1af3fa9f 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/entity/FormRequest.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/entity/FormRequest.java @@ -5,8 +5,6 @@ import lombok.*; import org.hibernate.annotations.ColumnDefault; -import java.time.LocalDate; - @Getter @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -22,19 +20,16 @@ public class FormRequest extends BaseEntity { @Column(columnDefinition = "TEXT", nullable = false) private String formLink; - @Column(nullable = false) + @Column(nullable = false, length = 100) + private String requesterEmail; + + @Column private Integer questionCount; - @Column(nullable = false) + @Column private Integer targetResponseCount; - @Column(nullable = false) - private LocalDate deadline; - - @Column(nullable = false, length = 100) - private String requesterEmail; - - @Column(nullable = false) + @Column private Integer price; @Column(name = "is_registered", nullable = false) @@ -45,27 +40,17 @@ public class FormRequest extends BaseEntity { @Column(name = "registered_survey_id") private Long registeredSurveyId; - public static FormRequest createRequest( - String formLink, - Integer questionCount, - Integer targetResponseCount, - LocalDate deadline, - String requesterEmail, - Integer price - ) { + public static FormRequest createRequest(String formLink, String requesterEmail) { return FormRequest.builder() .formLink(formLink) - .questionCount(questionCount) - .targetResponseCount(targetResponseCount) - .deadline(deadline) .requesterEmail(requesterEmail) - .price(price) .isRegistered(false) .build(); } - public void markAsRegistered(Long surveyId) { + public void markAsRegistered(Long surveyId, Integer questionCount) { this.isRegistered = true; this.registeredSurveyId = surveyId; + this.questionCount = questionCount; } } \ No newline at end of file diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormPublishRequest.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormPublishRequest.java new file mode 100644 index 00000000..de39a6dc --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormPublishRequest.java @@ -0,0 +1,19 @@ +package OneQ.OnSurvey.domain.survey.model.formRequest; + +import OneQ.OnSurvey.domain.survey.model.request.ScreeningRequest; +import OneQ.OnSurvey.domain.survey.model.request.SurveyFormRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +public record FormPublishRequest( + @Schema(description = "스크리닝 문항 (선택)") + @Valid + ScreeningRequest screening, + + @Schema(description = "세그먼트 및 가격 정보") + @NotNull + @Valid + SurveyFormRequest surveyForm +) { +} diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestDto.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestDto.java index 6f9acd6e..8e7b6fe2 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestDto.java @@ -3,35 +3,14 @@ import OneQ.OnSurvey.domain.survey.entity.FormRequest; import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDate; - public record FormRequestDto( @Schema(description = "폼 링크", example = "https://docs.google.com/forms/d/e/1FAIpQLSfD.../viewform") String formLink, - @Schema(description = "질문 수", example = "15") - Integer questionCount, - - @Schema(description = "목표 응답 수", example = "100") - Integer targetResponseCount, - - @Schema(description = "마감일", example = "2026-12-31") - LocalDate deadline, - @Schema(description = "신청자 이메일", example = "test@gmail.com") - String requesterEmail, - - @Schema(description = "가격", example = "9900") - Integer price + String requesterEmail ) { public FormRequest toEntity() { - return FormRequest.createRequest( - formLink, - questionCount, - targetResponseCount, - deadline, - requesterEmail, - price - ); + return FormRequest.createRequest(formLink, requesterEmail); } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestResponse.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestResponse.java index 3208e711..05a5f2ae 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestResponse.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestResponse.java @@ -2,16 +2,14 @@ import OneQ.OnSurvey.domain.survey.entity.FormRequest; -import java.time.LocalDate; import java.time.LocalDateTime; public record FormRequestResponse( Long id, String formLink, + String requesterEmail, Integer questionCount, Integer targetResponseCount, - LocalDate deadline, - String requesterEmail, Integer price, Boolean isRegistered, Long registeredSurveyId, @@ -21,10 +19,9 @@ public static FormRequestResponse of(FormRequest request) { return new FormRequestResponse( request.getId(), request.getFormLink(), + request.getRequesterEmail(), request.getQuestionCount(), request.getTargetResponseCount(), - request.getDeadline(), - request.getRequesterEmail(), request.getPrice(), request.getIsRegistered(), request.getRegisteredSurveyId(), diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCommandService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCommandService.java index a910aafe..a9116964 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCommandService.java @@ -1,9 +1,14 @@ package OneQ.OnSurvey.domain.survey.service.formRequest; +import OneQ.OnSurvey.domain.member.dto.MemberSearchResult; +import OneQ.OnSurvey.domain.member.service.MemberFinder; import OneQ.OnSurvey.domain.survey.entity.FormRequest; -import OneQ.OnSurvey.domain.survey.model.formRequest.event.FormRequestConversionEvent; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormPublishRequest; import OneQ.OnSurvey.domain.survey.model.formRequest.FormRequestDto; +import OneQ.OnSurvey.domain.survey.model.formRequest.event.FormRequestConversionEvent; +import OneQ.OnSurvey.domain.survey.model.response.SurveyFormResponse; import OneQ.OnSurvey.domain.survey.repository.formRequest.FormRequestRepository; +import OneQ.OnSurvey.domain.survey.service.command.SurveyCommand; import OneQ.OnSurvey.domain.survey.service.query.SurveyQueryService; import OneQ.OnSurvey.global.common.exception.CustomException; import lombok.RequiredArgsConstructor; @@ -13,16 +18,18 @@ import java.util.List; -import static OneQ.OnSurvey.domain.survey.SurveyErrorCode.FORM_REQUEST_NOT_FOUND; +import static OneQ.OnSurvey.domain.survey.SurveyErrorCode.*; @Service @Transactional @RequiredArgsConstructor -public class FormCommandService implements FormCreator, FormUpdater { +public class FormCommandService implements FormCreator, FormUpdater, FormPublisher { private final ApplicationEventPublisher eventPublisher; private final FormRequestRepository formRequestRepository; private final SurveyQueryService surveyQueryService; + private final MemberFinder memberFinder; + private final SurveyCommand surveyCommand; @Override public Long createFormRequest(FormRequestDto dto) { @@ -38,12 +45,40 @@ public Long createFormRequest(FormRequestDto dto) { } @Override - public void markAsRegistered(Long requestId, Long surveyId) { + public void markAsRegistered(Long requestId, Long surveyId, Integer questionCount) { surveyQueryService.getSurveyById(surveyId); FormRequest request = formRequestRepository.findById(requestId) .orElseThrow(() -> new CustomException(FORM_REQUEST_NOT_FOUND)); - request.markAsRegistered(surveyId); + request.markAsRegistered(surveyId, questionCount); + } + + @Override + public SurveyFormResponse publishFormRequest(Long requestId, FormPublishRequest publishRequest) { + FormRequest formRequest = formRequestRepository.findById(requestId) + .orElseThrow(() -> new CustomException(FORM_REQUEST_NOT_FOUND)); + + if (!formRequest.getIsRegistered() || formRequest.getRegisteredSurveyId() == null) { + throw new CustomException(FORM_REQUEST_NOT_YET_REGISTERED); + } + + Long surveyId = formRequest.getRegisteredSurveyId(); + + List members = memberFinder.searchMembers(formRequest.getRequesterEmail(), null, null, null); + if (members.size() != 1) { + throw new CustomException(FORM_REQUEST_MEMBER_NOT_FOUND); + } + Long userKey = members.getFirst().userKey(); + + if (publishRequest.screening() != null) { + surveyCommand.upsertScreening( + surveyId, + publishRequest.screening().content(), + publishRequest.screening().answer() + ); + } + + return surveyCommand.submitSurvey(userKey, surveyId, publishRequest.surveyForm()); } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormPublisher.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormPublisher.java new file mode 100644 index 00000000..939b350c --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormPublisher.java @@ -0,0 +1,8 @@ +package OneQ.OnSurvey.domain.survey.service.formRequest; + +import OneQ.OnSurvey.domain.survey.model.formRequest.FormPublishRequest; +import OneQ.OnSurvey.domain.survey.model.response.SurveyFormResponse; + +public interface FormPublisher { + SurveyFormResponse publishFormRequest(Long requestId, FormPublishRequest request); +} diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java index 20c8a044..2edb20d4 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java @@ -106,9 +106,16 @@ public void convertGoogleFormIntoSurvey(FormRequestConversionEvent event) { detailList.add( transactionHandler.runInTransaction(() -> { Long surveyId = createSurveyFromConversionResult(result, memberId); - formUpdater.markAsRegistered(event.requestId(), surveyId); - log.info("[FormRequestLambda] 구글폼 변환 성공 - requestId: {}, surveyId: {}", event.requestId(), surveyId); + int questionCount = result.survey().sections() != null + ? result.survey().sections().stream() + .mapToInt(s -> s.questions() != null ? s.questions().size() : 0) + .sum() + : 0; + + formUpdater.markAsRegistered(event.requestId(), surveyId, questionCount); + + log.info("[FormRequestLambda] 구글폼 변환 성공 - requestId: {}, surveyId: {}, questionCount: {}", event.requestId(), surveyId, questionCount); if (result.unsupportedQuestions() != null && !result.unsupportedQuestions().isEmpty()) { log.warn("[FormRequestLambda] 지원하지 않는 문항 존재 - requestId: {}, count: {}", @@ -120,11 +127,7 @@ public void convertGoogleFormIntoSurvey(FormRequestConversionEvent event) { result.survey().title(), surveyId, memberId, - result.survey().sections() != null - ? result.survey().sections().stream().mapToInt(s -> s.questions() != null - ? s.questions().size() - : 0).sum() - : 0, + questionCount, result.unsupportedQuestions() != null ? result.unsupportedQuestions().stream() .map(q -> new SurveyConversionAlert.SurveyDetails.UnsupportedQuestion(q.order(), q.type(), q.reason())) .toList() : List.of() diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormUpdater.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormUpdater.java index 4c1bafbb..b4361083 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormUpdater.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormUpdater.java @@ -1,5 +1,5 @@ package OneQ.OnSurvey.domain.survey.service.formRequest; public interface FormUpdater { - void markAsRegistered(Long requestId, Long surveyId); + void markAsRegistered(Long requestId, Long surveyId, Integer questionCount); } From 8cd73833c9b7e5f1639c4f90e07029c4f6eb939d Mon Sep 17 00:00:00 2001 From: jaekwan Date: Sat, 21 Mar 2026 13:19:19 +0900 Subject: [PATCH 10/50] =?UTF-8?q?refactor:=20=ED=95=A0=EC=9D=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=A7=8C=EB=A3=8C=EC=8B=9C=EA=B0=84=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 --- .../discount/DiscountCodeErrorCode.java | 3 ++- .../domain/discount/entity/DiscountCode.java | 10 ++++++-- .../request/CreateDiscountCodeRequest.java | 8 ++++++- .../model/response/DiscountCodeResponse.java | 3 +++ .../service/DiscountCodeCommandService.java | 24 +++++++++++++++---- .../service/DiscountCodeQueryService.java | 18 ++++++++++---- 6 files changed, 53 insertions(+), 13 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/DiscountCodeErrorCode.java b/src/main/java/OneQ/OnSurvey/domain/discount/DiscountCodeErrorCode.java index 8d0f8ed8..24fac2de 100644 --- a/src/main/java/OneQ/OnSurvey/domain/discount/DiscountCodeErrorCode.java +++ b/src/main/java/OneQ/OnSurvey/domain/discount/DiscountCodeErrorCode.java @@ -9,7 +9,8 @@ @AllArgsConstructor public enum DiscountCodeErrorCode implements ApiErrorCode { - DISCOUNT_CODE_NOT_FOUND("DISCOUNT_404", "유효하지 않은 할인 코드입니다.", HttpStatus.NOT_FOUND); + DISCOUNT_CODE_NOT_FOUND("DISCOUNT_404", "유효하지 않은 할인 코드입니다.", HttpStatus.NOT_FOUND), + DISCOUNT_CODE_EXPIRED("DISCOUNT_410", "만료된 할인 코드입니다.", HttpStatus.GONE); private final String errorCode; private final String message; diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/entity/DiscountCode.java b/src/main/java/OneQ/OnSurvey/domain/discount/entity/DiscountCode.java index fa54ff60..fcd200d5 100644 --- a/src/main/java/OneQ/OnSurvey/domain/discount/entity/DiscountCode.java +++ b/src/main/java/OneQ/OnSurvey/domain/discount/entity/DiscountCode.java @@ -4,6 +4,8 @@ import jakarta.persistence.*; import lombok.*; +import java.time.LocalDate; + @Getter @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -20,13 +22,17 @@ public class DiscountCode extends BaseEntity { @Column(name = "organization_name", nullable = false) private String organizationName; - @Column(name = "code", nullable = false, unique = true, length = 16) + @Column(name = "code", nullable = false, unique = true, length = 6) private String code; - public static DiscountCode of(String organizationName, String code) { + @Column(name = "expired_at", nullable = false) + private LocalDate expiredAt; + + public static DiscountCode of(String organizationName, String code, LocalDate expiredAt) { return DiscountCode.builder() .organizationName(organizationName) .code(code) + .expiredAt(expiredAt) .build(); } } diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/model/request/CreateDiscountCodeRequest.java b/src/main/java/OneQ/OnSurvey/domain/discount/model/request/CreateDiscountCodeRequest.java index 10b9f642..10790ca8 100644 --- a/src/main/java/OneQ/OnSurvey/domain/discount/model/request/CreateDiscountCodeRequest.java +++ b/src/main/java/OneQ/OnSurvey/domain/discount/model/request/CreateDiscountCodeRequest.java @@ -2,10 +2,16 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; public record CreateDiscountCodeRequest( @NotBlank @Schema(description = "학회(기관) 이름", example = "onsurvey") - String organizationName + String organizationName, + @NotNull + @Schema(description = "코드 만료 기한", example = "2026-12-31") + LocalDate expiredAt ) { } diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/model/response/DiscountCodeResponse.java b/src/main/java/OneQ/OnSurvey/domain/discount/model/response/DiscountCodeResponse.java index 470511eb..0d99989d 100644 --- a/src/main/java/OneQ/OnSurvey/domain/discount/model/response/DiscountCodeResponse.java +++ b/src/main/java/OneQ/OnSurvey/domain/discount/model/response/DiscountCodeResponse.java @@ -2,12 +2,14 @@ import OneQ.OnSurvey.domain.discount.entity.DiscountCode; +import java.time.LocalDate; import java.time.LocalDateTime; public record DiscountCodeResponse( Long id, String organizationName, String code, + LocalDate expiredAt, LocalDateTime createdAt ) { public static DiscountCodeResponse from(DiscountCode entity) { @@ -15,6 +17,7 @@ public static DiscountCodeResponse from(DiscountCode entity) { entity.getId(), entity.getOrganizationName(), entity.getCode(), + entity.getExpiredAt(), entity.getCreatedAt() ); } diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeCommandService.java b/src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeCommandService.java index 53a67338..8c2fece2 100644 --- a/src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeCommandService.java @@ -9,7 +9,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.UUID; +import java.security.SecureRandom; @Slf4j @Service @@ -17,15 +17,31 @@ @Transactional public class DiscountCodeCommandService { + private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + private static final int CODE_LENGTH = 6; + private static final SecureRandom RANDOM = new SecureRandom(); + private final DiscountCodeRepository discountCodeRepository; public DiscountCodeResponse create(CreateDiscountCodeRequest request) { - String code = UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(); - DiscountCode discountCode = DiscountCode.of(request.organizationName(), code); + String code = generateUniqueCode(); + DiscountCode discountCode = DiscountCode.of(request.organizationName(), code, request.expiredAt()); discountCode = discountCodeRepository.save(discountCode); - log.info("[DiscountCode:create] 할인 코드 생성 - org={}, code={}", request.organizationName(), code); + log.info("[DiscountCode:create] 할인 코드 생성 - org={}, code={}, expiredAt={}", request.organizationName(), code, request.expiredAt()); return DiscountCodeResponse.from(discountCode); } + + private String generateUniqueCode() { + String code; + do { + StringBuilder sb = new StringBuilder(CODE_LENGTH); + for (int i = 0; i < CODE_LENGTH; i++) { + sb.append(CHARACTERS.charAt(RANDOM.nextInt(CHARACTERS.length()))); + } + code = sb.toString(); + } while (discountCodeRepository.existsByCode(code)); + return code; + } } diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeQueryService.java b/src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeQueryService.java index a070d63d..3b688ddf 100644 --- a/src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeQueryService.java +++ b/src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeQueryService.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; import java.util.List; @Service @@ -21,17 +22,18 @@ public class DiscountCodeQueryService { /** 코드 존재 여부만 확인 */ public ValidateDiscountCodeResponse validate(String code) { - boolean eligible = discountCodeRepository.existsByCode(code); - if (!eligible) { - throw new CustomException(DiscountCodeErrorCode.DISCOUNT_CODE_NOT_FOUND); - } + DiscountCode discountCode = discountCodeRepository.findByCode(code) + .orElseThrow(() -> new CustomException(DiscountCodeErrorCode.DISCOUNT_CODE_NOT_FOUND)); + validateNotExpired(discountCode); return new ValidateDiscountCodeResponse(true); } /** 설문 등록 시 코드 검증 후 엔티티 반환 */ public DiscountCode getByCode(String code) { - return discountCodeRepository.findByCode(code) + DiscountCode discountCode = discountCodeRepository.findByCode(code) .orElseThrow(() -> new CustomException(DiscountCodeErrorCode.DISCOUNT_CODE_NOT_FOUND)); + validateNotExpired(discountCode); + return discountCode; } public List findAll() { @@ -39,4 +41,10 @@ public List findAll() { .map(DiscountCodeResponse::from) .toList(); } + + private void validateNotExpired(DiscountCode discountCode) { + if (discountCode.getExpiredAt().isBefore(LocalDate.now())) { + throw new CustomException(DiscountCodeErrorCode.DISCOUNT_CODE_EXPIRED); + } + } } From 4fb603359469ad57fe808140a121cc69f5cb58dc Mon Sep 17 00:00:00 2001 From: jaekwan Date: Sat, 21 Mar 2026 13:57:53 +0900 Subject: [PATCH 11/50] =?UTF-8?q?fix:=20dto=EC=97=90=20validation=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 --- .../discount/model/request/CreateDiscountCodeRequest.java | 3 +++ .../domain/survey/model/formRequest/FormRequestDto.java | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/model/request/CreateDiscountCodeRequest.java b/src/main/java/OneQ/OnSurvey/domain/discount/model/request/CreateDiscountCodeRequest.java index 10790ca8..d9db48aa 100644 --- a/src/main/java/OneQ/OnSurvey/domain/discount/model/request/CreateDiscountCodeRequest.java +++ b/src/main/java/OneQ/OnSurvey/domain/discount/model/request/CreateDiscountCodeRequest.java @@ -1,6 +1,7 @@ package OneQ.OnSurvey.domain.discount.model.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.FutureOrPresent; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -10,7 +11,9 @@ public record CreateDiscountCodeRequest( @NotBlank @Schema(description = "학회(기관) 이름", example = "onsurvey") String organizationName, + @NotNull + @FutureOrPresent @Schema(description = "코드 만료 기한", example = "2026-12-31") LocalDate expiredAt ) { diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestDto.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestDto.java index 8e7b6fe2..6ef96cdb 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestDto.java @@ -2,12 +2,17 @@ import OneQ.OnSurvey.domain.survey.entity.FormRequest; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; public record FormRequestDto( @Schema(description = "폼 링크", example = "https://docs.google.com/forms/d/e/1FAIpQLSfD.../viewform") + @NotBlank String formLink, @Schema(description = "신청자 이메일", example = "test@gmail.com") + @Email + @NotBlank String requesterEmail ) { public FormRequest toEntity() { From 74d896004a21046100c5cad5727abce6c7d30458 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Sun, 22 Mar 2026 04:31:36 +0900 Subject: [PATCH 12/50] =?UTF-8?q?feat:=20=EA=B5=AC=EA=B8=80=ED=8F=BC=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/survey/SurveyErrorCode.java | 4 +- .../controller/FormRequestController.java | 13 ++++++ .../FormValidationAndStashResponse.java | 39 ++++++++++++++++ .../formRequest/FormValidationPayload.java | 8 ++++ .../formRequest/FormValidationRequestDto.java | 11 +++++ .../formRequest/FormValidationResponse.java | 44 +++++++++++++++++++ .../formRequest/FormCommandService.java | 18 ++++++++ .../service/formRequest/FormCreator.java | 3 ++ .../formRequest/FormRequestLambda.java | 43 ++++++++++++++++-- 9 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationAndStashResponse.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationPayload.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationRequestDto.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationResponse.java diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java b/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java index 0d489c19..7f154057 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java @@ -31,7 +31,9 @@ public enum SurveyErrorCode implements ApiErrorCode { SURVEY_FREE_PROMOTION_NOT_ALLOWED("SURVEY_PROMOTION_400", "무료 설문은 프로모션 지급 대상이 아닙니다.", HttpStatus.BAD_REQUEST), FORM_REQUEST_NOT_FOUND("FORM_REQUEST_404", "구글 폼 신청을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), - FORM_CONVERSION_FAILED("FORM_REQUEST_001", "구글 폼 변환에 실패했습니다.", HttpStatus.BAD_REQUEST); + FORM_CONVERSION_FAILED("FORM_REQUEST_001", "구글 폼 변환에 실패했습니다.", HttpStatus.BAD_REQUEST), + FORM_VALIDATION_FAILED("FORM_REQUEST_002", "구글 폼 링크 유효성 검사에 실패했습니다.", HttpStatus.BAD_REQUEST), + FORM_INVALID("FORM_REQUEST_003", "구글 폼 링크가 유효하지 않습니다.", HttpStatus.BAD_REQUEST); private final String errorCode; private final String message; diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java b/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java index 263ce7c5..370191e1 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java @@ -1,6 +1,8 @@ package OneQ.OnSurvey.domain.survey.controller; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationResponse; import OneQ.OnSurvey.domain.survey.model.formRequest.FormListResponse; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationRequestDto; import OneQ.OnSurvey.domain.survey.model.formRequest.FormRequestDto; import OneQ.OnSurvey.domain.survey.model.formRequest.FormRequestResponse; import OneQ.OnSurvey.domain.survey.service.formRequest.FormCreator; @@ -67,4 +69,15 @@ public SuccessResponse markAsRegistered( formUpdater.markAsRegistered(requestId, surveyId); return SuccessResponse.ok("폼이 온서베이에 등록되었습니다."); } + + @PostMapping("/validation") + @Operation(summary = "폼 링크 유효성 검사", description = "구글 폼 편집 URL로부터 전체 문항 수 중 변환 가능한 문항 수를 리턴합니다. 변환 불가능한 문항 존재 시 관련 정보를 추가로 반환합니다.") + public SuccessResponse getConvertableCounts( + @RequestBody FormValidationRequestDto request + ) { + log.info("[FormRequest] 폼 링크 유효성 검사 - URL: {}, requester: {}", request.formLink(), request.requesterEmail()); + + FormValidationResponse response = formCreator.validationFormRequestLink(request); + return SuccessResponse.ok(response); + } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationAndStashResponse.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationAndStashResponse.java new file mode 100644 index 00000000..1c258596 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationAndStashResponse.java @@ -0,0 +1,39 @@ +package OneQ.OnSurvey.domain.survey.model.formRequest; + +import java.util.List; + +public record FormValidationAndStashResponse( + int totalUrls, + int successCount, + List results +) { + + public record Result( + String url, + String status, // "SUCCESS", "FAIL" + + // SUCCESS + Count counts, + List unconvertibleDetails, + + // FAIL + String message + ) { + public boolean isSuccess() { + return "SUCCESS".equals(status); + } + } + + public record Count( + int total, + int convertible, + int unconvertible + ) { } + + public record Unconvertible( + String title, + String type, + int order, + String reason + ) { } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationPayload.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationPayload.java new file mode 100644 index 00000000..eea52608 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationPayload.java @@ -0,0 +1,8 @@ +package OneQ.OnSurvey.domain.survey.model.formRequest; + +import java.util.List; + +public record FormValidationPayload( + List urls, + String requesterEmail +) { } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationRequestDto.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationRequestDto.java new file mode 100644 index 00000000..142010bd --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationRequestDto.java @@ -0,0 +1,11 @@ +package OneQ.OnSurvey.domain.survey.model.formRequest; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record FormValidationRequestDto( + @Schema(description = "폼 편집 링크", example = "https://docs.google.com/forms/d/1FAIpQLSfD.../edit") + String formLink, + @Schema(description = "신청자 이메일", example = "test@gmail.com") + String requesterEmail +) { +} diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationResponse.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationResponse.java new file mode 100644 index 00000000..60a378b0 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationResponse.java @@ -0,0 +1,44 @@ +package OneQ.OnSurvey.domain.survey.model.formRequest; + +import java.util.List; + +public record FormValidationResponse( + List results +) { + + public record Result( + int totalCount, + int convertible, + int unconvertible, + List details + ) { } + + public record Unconvertible( + String title, + String type, + int order, + String reason + ) { } + + public static FormValidationResponse from(FormValidationAndStashResponse dto) { + List results = dto.results().stream() + .map(r -> r.isSuccess() + ? new Result( + r.counts().total(), + r.counts().convertible(), + r.counts().unconvertible(), + r.unconvertibleDetails().stream() + .map(u -> new Unconvertible( + u.title(), + u.type(), + u.order(), + u.reason() + )) + .toList() + ) + : null + ).toList(); + + return new FormValidationResponse(results); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCommandService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCommandService.java index a910aafe..a85a5a93 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCommandService.java @@ -1,6 +1,9 @@ package OneQ.OnSurvey.domain.survey.service.formRequest; import OneQ.OnSurvey.domain.survey.entity.FormRequest; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationAndStashResponse; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationRequestDto; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationResponse; import OneQ.OnSurvey.domain.survey.model.formRequest.event.FormRequestConversionEvent; import OneQ.OnSurvey.domain.survey.model.formRequest.FormRequestDto; import OneQ.OnSurvey.domain.survey.repository.formRequest.FormRequestRepository; @@ -21,6 +24,7 @@ public class FormCommandService implements FormCreator, FormUpdater { private final ApplicationEventPublisher eventPublisher; + private final FormRequestLambda formRequestLambda; private final FormRequestRepository formRequestRepository; private final SurveyQueryService surveyQueryService; @@ -46,4 +50,18 @@ public void markAsRegistered(Long requestId, Long surveyId) { request.markAsRegistered(surveyId); } + + /** + * 구글폼 링크 유효성을 검사한 뒤, 변환 가능/불가능한 문항 수를 각각 반환하고 반환 불가능한 문항에 대해서는 그 사유를 반환 + * 유효성 검사가 이루어진 데이터를 s3에 stash한다. + * + * @param dto 구글폼 링크 유효성 검사를 진행할 formLink는 필수로 가지고 있는 DTO + * @return 변환된 문항 수 / 변환되지 않은 문항 및 사유 + */ + @Override + public FormValidationResponse validationFormRequestLink(FormValidationRequestDto dto) { + FormValidationAndStashResponse formCount = formRequestLambda.validateAndStashFormRequest(dto.formLink(), dto.requesterEmail()); + + return FormValidationResponse.from(formCount); + } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCreator.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCreator.java index 2c00693a..e20830f0 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCreator.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCreator.java @@ -1,7 +1,10 @@ package OneQ.OnSurvey.domain.survey.service.formRequest; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationRequestDto; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationResponse; import OneQ.OnSurvey.domain.survey.model.formRequest.FormRequestDto; public interface FormCreator { Long createFormRequest(FormRequestDto dto); + FormValidationResponse validationFormRequestLink(FormValidationRequestDto dto); } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java index f1fc2142..23456be0 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java @@ -9,11 +9,15 @@ import OneQ.OnSurvey.domain.question.model.dto.SectionDto; import OneQ.OnSurvey.domain.question.service.QuestionCommand; import OneQ.OnSurvey.domain.survey.SurveyErrorCode; +import OneQ.OnSurvey.domain.survey.entity.FormRequest; import OneQ.OnSurvey.domain.survey.model.formRequest.FormConversionPayload; import OneQ.OnSurvey.domain.survey.model.formRequest.FormConversionResponse; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationAndStashResponse; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationPayload; import OneQ.OnSurvey.domain.survey.model.formRequest.event.FormRequestConversionEvent; import OneQ.OnSurvey.domain.survey.model.request.SurveyFormCreateRequest; import OneQ.OnSurvey.domain.survey.model.response.SurveyFormResponse; +import OneQ.OnSurvey.domain.survey.repository.formRequest.FormRequestRepository; import OneQ.OnSurvey.domain.survey.service.command.SurveyCommand; import OneQ.OnSurvey.global.common.exception.CustomException; import OneQ.OnSurvey.global.infra.discord.notifier.AlertNotifier; @@ -38,6 +42,8 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; +import static OneQ.OnSurvey.domain.survey.SurveyErrorCode.FORM_REQUEST_NOT_FOUND; + @Slf4j @Component @RequiredArgsConstructor @@ -46,7 +52,9 @@ public class FormRequestLambda { private static final long FORM_CONVERSION_REQUEST_TIMEOUT = 20L; @Value("${external.lambda.survey-conversion.url:}") - private String lambdaUrl; + private String conversionUrl; + @Value("${external.lambda.google-form-validation.url:}") + private String validationUrl; private final AlertNotifier alertNotifier; private final TransactionHandler transactionHandler; @@ -54,10 +62,11 @@ public class FormRequestLambda { private final WebClient webClient; private final MemberFinder memberFinder; - private final FormUpdater formUpdater; private final SurveyCommand surveyCommand; private final QuestionCommand questionCommand; + private final FormRequestRepository formRequestRepository; + @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void convertGoogleFormIntoSurvey(FormRequestConversionEvent event) { @@ -66,7 +75,7 @@ public void convertGoogleFormIntoSurvey(FormRequestConversionEvent event) { FormConversionPayload payload = new FormConversionPayload(event.formUrls()); FormConversionResponse response = webClient.post() - .uri(lambdaUrl) + .uri(conversionUrl) .contentType(MediaType.APPLICATION_JSON) .bodyValue(payload) .retrieve() @@ -106,7 +115,9 @@ public void convertGoogleFormIntoSurvey(FormRequestConversionEvent event) { detailList.add( transactionHandler.runInTransaction(() -> { Long surveyId = createSurveyFromConversionResult(result, memberId); - formUpdater.markAsRegistered(event.requestId(), surveyId); + FormRequest request = formRequestRepository.findById(event.requestId()) + .orElseThrow(() -> new CustomException(FORM_REQUEST_NOT_FOUND)); + request.markAsRegistered(surveyId); log.info("[FormRequestLambda] 구글폼 변환 성공 - requestId: {}, surveyId: {}", event.requestId(), surveyId); if (result.unsupportedQuestions() != null && !result.unsupportedQuestions().isEmpty()) { @@ -146,6 +157,30 @@ public void convertGoogleFormIntoSurvey(FormRequestConversionEvent event) { ); } + public FormValidationAndStashResponse validateAndStashFormRequest(String formLink, String requesterEmail) { + FormValidationPayload payload = new FormValidationPayload(List.of(formLink), requesterEmail); + + FormValidationAndStashResponse response = webClient.post() + .uri(validationUrl) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(payload) + .retrieve() + .bodyToMono(FormValidationAndStashResponse.class) + .timeout(Duration.ofSeconds(FORM_CONVERSION_REQUEST_TIMEOUT)) + .retryWhen(Retry.backoff(3, Duration.ofSeconds(3))) + .onErrorMap(e -> { + log.error("[FormRequestLambda:validateAndStashFormRequest] 구글폼 링크 유효성 검사 실패 - URLs: {}, error: {}", payload.urls(), e.getMessage(), e); + throw new CustomException(SurveyErrorCode.FORM_VALIDATION_FAILED); + }) + .block(); + + if (response == null || response.successCount() == 0) { + log.warn("[FormRequestLambda:validateAndStashFormRequest] 구글폼 링크가 유효하지 않음 - URLs: {}", payload.urls()); + throw new CustomException(SurveyErrorCode.FORM_INVALID); + } + return response; + } + private Long createSurveyFromConversionResult(FormConversionResponse.Result result, Long memberId) { FormConversionResponse.Survey survey = result.survey(); From f03b8bc8a5dc1ce5661a890316b90108c08fbcf2 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Sun, 22 Mar 2026 09:08:41 +0900 Subject: [PATCH 13/50] =?UTF-8?q?feat:=20=EB=B3=80=ED=99=98=EB=90=9C=20?= =?UTF-8?q?=EC=84=A4=EB=AC=B8=20=EC=9E=90=EB=8F=99=20=EB=B0=9C=ED=96=89=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/FormRequestController.java | 11 +- .../domain/survey/entity/FormRequest.java | 14 +- .../formRequest/FormConversionPayload.java | 1 + .../model/formRequest/FormRequestDto.java | 31 +- .../FormValidationAndStashResponse.java | 1 - .../formRequest/FormValidationRequestDto.java | 6 + .../formRequest/FormValidationResponse.java | 2 - .../event/FormRequestConversionEvent.java | 12 +- .../formRequest/FormCommandService.java | 26 +- .../service/formRequest/FormCreator.java | 2 +- .../formRequest/FormEventListener.java | 265 ++++++++++++++++++ .../formRequest/FormRequestLambda.java | 253 +---------------- 12 files changed, 344 insertions(+), 280 deletions(-) create mode 100644 src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormEventListener.java diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java b/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java index 94a6702f..03a0a7b4 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java @@ -11,6 +11,7 @@ import OneQ.OnSurvey.domain.survey.service.formRequest.FormFinder; import OneQ.OnSurvey.domain.survey.service.formRequest.FormPublisher; import OneQ.OnSurvey.domain.survey.service.formRequest.FormUpdater; +import OneQ.OnSurvey.global.auth.custom.Authenticatable; import OneQ.OnSurvey.global.common.response.PageResponse; import OneQ.OnSurvey.global.common.response.SuccessResponse; import io.swagger.v3.oas.annotations.Operation; @@ -20,6 +21,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @Slf4j @@ -34,11 +36,12 @@ public class FormRequestController { private final FormPublisher formPublisher; @PostMapping - @Operation(summary = "폼 등록 신청", description = "폼을 등록하기 위한 신청을 생성합니다.") + @Operation(summary = "폼 등록 신청 및 설문 발행", description = "폼을 등록하기 위한 신청을 생성한 뒤 설문변환 및 발행을 진행합니다.") public SuccessResponse createGoogleFormRequest( - @RequestBody FormRequestDto request + @AuthenticationPrincipal Authenticatable principal, + @RequestBody @Valid FormRequestDto request ) { - return SuccessResponse.ok(formCreator.createFormRequest(request)); + return SuccessResponse.ok(formCreator.createFormRequest(principal.getUserKey(), principal.getMemberId(), request)); } @GetMapping @@ -78,7 +81,7 @@ public SuccessResponse markAsRegistered( @PostMapping("/validation") @Operation(summary = "폼 링크 유효성 검사", description = "구글 폼 편집 URL로부터 전체 문항 수 중 변환 가능한 문항 수를 리턴합니다. 변환 불가능한 문항 존재 시 관련 정보를 추가로 반환합니다.") public SuccessResponse getConvertableCounts( - @RequestBody FormValidationRequestDto request + @RequestBody @Valid FormValidationRequestDto request ) { log.info("[FormRequest] 폼 링크 유효성 검사 - URL: {}, requester: {}", request.formLink(), request.requesterEmail()); diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/entity/FormRequest.java b/src/main/java/OneQ/OnSurvey/domain/survey/entity/FormRequest.java index 1af3fa9f..fb7f6b79 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/entity/FormRequest.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/entity/FormRequest.java @@ -20,6 +20,9 @@ public class FormRequest extends BaseEntity { @Column(columnDefinition = "TEXT", nullable = false) private String formLink; + @Column(name = "user_key", nullable = false) + private Long userKey; + @Column(nullable = false, length = 100) private String requesterEmail; @@ -40,12 +43,13 @@ public class FormRequest extends BaseEntity { @Column(name = "registered_survey_id") private Long registeredSurveyId; - public static FormRequest createRequest(String formLink, String requesterEmail) { + public static FormRequest createRequest(String formLink, String requesterEmail, Long userKey) { return FormRequest.builder() - .formLink(formLink) - .requesterEmail(requesterEmail) - .isRegistered(false) - .build(); + .formLink(formLink) + .userKey(userKey) + .requesterEmail(requesterEmail) + .isRegistered(false) + .build(); } public void markAsRegistered(Long surveyId, Integer questionCount) { diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormConversionPayload.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormConversionPayload.java index 6127c00f..4fb347b4 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormConversionPayload.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormConversionPayload.java @@ -3,6 +3,7 @@ import java.util.List; public record FormConversionPayload ( + Long requestId, List urls ) { } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestDto.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestDto.java index 6ef96cdb..f63d0adb 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestDto.java @@ -1,21 +1,34 @@ package OneQ.OnSurvey.domain.survey.model.formRequest; import OneQ.OnSurvey.domain.survey.entity.FormRequest; +import OneQ.OnSurvey.domain.survey.model.request.ScreeningRequest; +import OneQ.OnSurvey.domain.survey.model.request.SurveyFormRequest; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.hibernate.validator.constraints.URL; public record FormRequestDto( - @Schema(description = "폼 링크", example = "https://docs.google.com/forms/d/e/1FAIpQLSfD.../viewform") - @NotBlank - String formLink, + @Schema(description = "폼 링크", example = "https://docs.google.com/forms/d/e/1FAIpQLSfD.../viewform") + @URL @NotBlank + String formLink, - @Schema(description = "신청자 이메일", example = "test@gmail.com") - @Email - @NotBlank - String requesterEmail + @Schema(description = "신청자 이메일", example = "test@gmail.com") + @Email @NotBlank + String requesterEmail, + + @Schema(description = "스크리닝 문항 (선택)") + @Valid + ScreeningRequest screening, + + @Schema(description = "세그먼트 및 가격 정보") + @NotNull + @Valid + SurveyFormRequest surveyForm ) { - public FormRequest toEntity() { - return FormRequest.createRequest(formLink, requesterEmail); + public FormRequest toEntity(long userKey) { + return FormRequest.createRequest(formLink, requesterEmail, userKey); } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationAndStashResponse.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationAndStashResponse.java index 1c258596..521de5b2 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationAndStashResponse.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationAndStashResponse.java @@ -33,7 +33,6 @@ public record Count( public record Unconvertible( String title, String type, - int order, String reason ) { } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationRequestDto.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationRequestDto.java index 142010bd..9953016d 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationRequestDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationRequestDto.java @@ -1,11 +1,17 @@ package OneQ.OnSurvey.domain.survey.model.formRequest; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import org.hibernate.validator.constraints.URL; public record FormValidationRequestDto( @Schema(description = "폼 편집 링크", example = "https://docs.google.com/forms/d/1FAIpQLSfD.../edit") + @URL @NotBlank String formLink, + @Schema(description = "신청자 이메일", example = "test@gmail.com") + @Email @NotBlank String requesterEmail ) { } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationResponse.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationResponse.java index 60a378b0..7166c049 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationResponse.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationResponse.java @@ -16,7 +16,6 @@ public record Result( public record Unconvertible( String title, String type, - int order, String reason ) { } @@ -31,7 +30,6 @@ public static FormValidationResponse from(FormValidationAndStashResponse dto) { .map(u -> new Unconvertible( u.title(), u.type(), - u.order(), u.reason() )) .toList() diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/event/FormRequestConversionEvent.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/event/FormRequestConversionEvent.java index 692ab6dc..a3abec83 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/event/FormRequestConversionEvent.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/event/FormRequestConversionEvent.java @@ -1,10 +1,18 @@ package OneQ.OnSurvey.domain.survey.model.formRequest.event; +import OneQ.OnSurvey.domain.survey.model.request.ScreeningRequest; +import OneQ.OnSurvey.domain.survey.model.request.SurveyFormRequest; + import java.util.List; public record FormRequestConversionEvent ( Long requestId, - String email, - List formUrls + Long userKey, + Long memberId, + List formUrls, + + // TODO 무거운 이벤트를 requestId, surveyId, userKey, formUrls만 보내도록 설문을 선생성 후 데이터를 추가하도록 로직 수정 필요 + ScreeningRequest screening, + SurveyFormRequest surveyForm ) { } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCommandService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCommandService.java index c6ed6bf1..00143ae0 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCommandService.java @@ -2,9 +2,11 @@ import OneQ.OnSurvey.domain.member.dto.MemberSearchResult; import OneQ.OnSurvey.domain.member.service.MemberFinder; +import OneQ.OnSurvey.domain.survey.SurveyErrorCode; import OneQ.OnSurvey.domain.survey.entity.FormRequest; import OneQ.OnSurvey.domain.survey.model.formRequest.FormPublishRequest; import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationAndStashResponse; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationPayload; import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationRequestDto; import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationResponse; import OneQ.OnSurvey.domain.survey.model.formRequest.event.FormRequestConversionEvent; @@ -15,6 +17,7 @@ import OneQ.OnSurvey.domain.survey.service.query.SurveyQueryService; import OneQ.OnSurvey.global.common.exception.CustomException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,6 +26,7 @@ import static OneQ.OnSurvey.domain.survey.SurveyErrorCode.*; +@Slf4j @Service @Transactional @RequiredArgsConstructor @@ -36,15 +40,18 @@ public class FormCommandService implements FormCreator, FormUpdater, FormPublish private final SurveyCommand surveyCommand; @Override - public Long createFormRequest(FormRequestDto dto) { - FormRequest request = dto.toEntity(); + public Long createFormRequest(Long userKey, Long memberId, FormRequestDto dto) { + FormRequest request = dto.toEntity(userKey); FormRequest savedRequest = formRequestRepository.save(request); eventPublisher.publishEvent(new FormRequestConversionEvent( savedRequest.getId(), - savedRequest.getRequesterEmail(), - List.of(savedRequest.getFormLink())) - ); + userKey, + memberId, + List.of(savedRequest.getFormLink()), + dto.screening(), + dto.surveyForm() + )); return savedRequest.getId(); } @@ -95,8 +102,13 @@ public SurveyFormResponse publishFormRequest(Long requestId, FormPublishRequest */ @Override public FormValidationResponse validationFormRequestLink(FormValidationRequestDto dto) { - FormValidationAndStashResponse formCount = formRequestLambda.validateAndStashFormRequest(dto.formLink(), dto.requesterEmail()); + FormValidationPayload payload = new FormValidationPayload(List.of(dto.formLink()), dto.requesterEmail()); + FormValidationAndStashResponse validationResult = formRequestLambda.validateAndStashFormRequest(payload); - return FormValidationResponse.from(formCount); + if (validationResult == null || validationResult.successCount() == 0) { + log.warn("[FormCommandService:validationFormRequestLink] 구글폼 링크가 유효하지 않음 - URL: {}", dto.formLink()); + throw new CustomException(SurveyErrorCode.FORM_INVALID); + } + return FormValidationResponse.from(validationResult); } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCreator.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCreator.java index e20830f0..3836c555 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCreator.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCreator.java @@ -5,6 +5,6 @@ import OneQ.OnSurvey.domain.survey.model.formRequest.FormRequestDto; public interface FormCreator { - Long createFormRequest(FormRequestDto dto); + Long createFormRequest(Long userKey, Long memberId, FormRequestDto dto); FormValidationResponse validationFormRequestLink(FormValidationRequestDto dto); } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormEventListener.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormEventListener.java new file mode 100644 index 00000000..d5d17593 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormEventListener.java @@ -0,0 +1,265 @@ +package OneQ.OnSurvey.domain.survey.service.formRequest; + +import OneQ.OnSurvey.domain.question.model.QuestionType; +import OneQ.OnSurvey.domain.question.model.dto.OptionDto; +import OneQ.OnSurvey.domain.question.model.dto.OptionUpsertDto; +import OneQ.OnSurvey.domain.question.model.dto.QuestionUpsertDto; +import OneQ.OnSurvey.domain.question.model.dto.SectionDto; +import OneQ.OnSurvey.domain.question.service.QuestionCommand; +import OneQ.OnSurvey.domain.survey.SurveyErrorCode; +import OneQ.OnSurvey.domain.survey.entity.FormRequest; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormConversionPayload; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormConversionResponse; +import OneQ.OnSurvey.domain.survey.model.formRequest.event.FormRequestConversionEvent; +import OneQ.OnSurvey.domain.survey.model.request.SurveyFormCreateRequest; +import OneQ.OnSurvey.domain.survey.model.response.SurveyFormResponse; +import OneQ.OnSurvey.domain.survey.repository.formRequest.FormRequestRepository; +import OneQ.OnSurvey.domain.survey.service.command.SurveyCommand; +import OneQ.OnSurvey.global.common.exception.CustomException; +import OneQ.OnSurvey.global.infra.discord.notifier.AlertNotifier; +import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveyConversionAlert; +import OneQ.OnSurvey.global.infra.transaction.TransactionHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FormEventListener { + + private final AlertNotifier alertNotifier; + private final TransactionHandler transactionHandler; + + private final FormRequestLambda formRequestLambda; + private final SurveyCommand surveyCommand; + private final QuestionCommand questionCommand; + + private final FormRequestRepository formRequestRepository; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void convertGoogleFormIntoSurvey(FormRequestConversionEvent event) { + log.info("[FormEventListener] 구글폼 변환 시작 - requestId: {}, formUrl: {}", event.requestId(), event.formUrls()); + + FormConversionPayload payload = new FormConversionPayload(event.requestId(), event.formUrls()); + FormConversionResponse response = formRequestLambda.convertGoogleFormIntoSurvey(payload); + + if (response == null || response.results() == null) { + log.error("[FormRequestLambda] 구글폼 변환 실패 - 응답이 null입니다. requestId: {}", event.requestId()); + alertNotifier.sendSurveyConversionAsync( + response == null + ? SurveyConversionAlert.error(1, 0, "변환 요청에 대한 응답이 null입니다.") + : SurveyConversionAlert.error(response.totalCount(), response.successCount(), response.error()) + ); + throw new CustomException(SurveyErrorCode.FORM_CONVERSION_FAILED); + } + + List detailList = new ArrayList<>(); + response.results().forEach(result -> { + if (result.isSuccess()) { + try { + detailList.add( + transactionHandler.runInTransaction(() -> { + Long surveyId = createSurveyFromConversionResult(result, event.memberId()); + int questionCount = result.survey().sections() != null + ? result.survey().sections().stream() + .mapToInt(s -> s.questions() != null ? s.questions().size() : 0) + .sum() + : 0; + FormRequest request = formRequestRepository.findById(event.requestId()) + .orElseThrow(() -> new CustomException(SurveyErrorCode.FORM_REQUEST_NOT_FOUND)); + request.markAsRegistered(surveyId, questionCount); + + log.info("[FormRequestLambda] 구글폼 변환 성공 - requestId: {}, surveyId: {}, questionCount: {}", event.requestId(), surveyId, questionCount); + if (result.unsupportedQuestions() != null && !result.unsupportedQuestions().isEmpty()) { + log.warn("[FormRequestLambda] 지원하지 않는 문항 존재 - requestId: {}, count: {}", + event.requestId(), result.unsupportedQuestions().size()); + } + + if (event.screening() != null) { + surveyCommand.upsertScreening( + surveyId, + event.screening().content(), + event.screening().answer() + ); + } + surveyCommand.submitSurvey(event.userKey(), surveyId, event.surveyForm()); + + return SurveyConversionAlert.SurveyDetails.success( + result.url(), + result.survey().title(), + surveyId, + event.memberId(), + questionCount, + result.unsupportedQuestions() != null ? result.unsupportedQuestions().stream() + .map(q -> new SurveyConversionAlert.SurveyDetails.UnsupportedQuestion(q.order(), q.type(), q.reason())) + .toList() : List.of() + ); + }) + ); + } catch (Exception e) { + log.error("[FormRequestLambda] 설문 생성 중 오류 발생 - requestId: {}, error: {}", + event.requestId(), e.getMessage(), e); + detailList.add(SurveyConversionAlert.SurveyDetails.failure(result.url(), "설문 생성 중 오류가 발생했습니다.")); + } + } else { + log.error("[FormRequestLambda] 구글폼 변환 실패 - requestId: {}, url: {}, message: {}", + event.requestId(), result.url(), result.message()); + detailList.add(SurveyConversionAlert.SurveyDetails.failure(result.url(), result.message())); + } + }); + alertNotifier.sendSurveyConversionAsync( + SurveyConversionAlert.success(response.totalCount(), response.successCount(), detailList) + ); + } + + private Long createSurveyFromConversionResult(FormConversionResponse.Result result, Long memberId) { + FormConversionResponse.Survey survey = result.survey(); + + // 1. 설문 생성 + SurveyFormCreateRequest surveyRequest = new SurveyFormCreateRequest( + survey.title(), + survey.description() + ); + SurveyFormResponse surveyResponse = surveyCommand.upsertSurvey(memberId, null, surveyRequest); + Long surveyId = surveyResponse.surveyId(); + + // 2. 섹션 생성 + if (survey.sections() != null && !survey.sections().isEmpty()) { + List sectionDtoList = survey.sections().stream() + .map(section -> new SectionDto( + null, + section.title(), + section.description(), + section.order(), + section.nextSectionOrder() != null ? section.nextSectionOrder() : 0 + )) + .toList(); + + questionCommand.upsertSections(surveyId, sectionDtoList); + } + + // 3. 문항 생성 + List questionUpsertInfoList = new ArrayList<>(); + AtomicInteger questionOrder = new AtomicInteger(0); + + if (survey.sections() != null) { + for (FormConversionResponse.Section section : survey.sections()) { + if (section.questions() != null) { + for (FormConversionResponse.Question question : section.questions()) { + QuestionType questionType = mapQuestionType(question.type()); + if (questionType == null) { + continue; + } + + QuestionUpsertDto.UpsertInfo.UpsertInfoBuilder builder = QuestionUpsertDto.UpsertInfo.builder() + .questionId(null) + .title(question.title()) + .description(question.description()) + .isRequired(question.required()) + .questionType(questionType) + .questionOrder(questionOrder.getAndIncrement()) + .imageUrl(question.imageUrl()) + .section(section.order()); + + // Choice 타입인 경우 옵션 설정 + if (questionType == QuestionType.CHOICE && question.options() != null) { + boolean hasOtherOption = question.options().stream() + .anyMatch(FormConversionResponse.Option::isOther); + boolean isSectionDecidable = question.options().stream() + .anyMatch(opt -> opt.goToSectionOrder() != null); + int maxChoice = switch (question.type().toUpperCase()) { + case "CHECKBOX" -> question.options().size(); // 체크박스는 여러 개 선택 가능 + case "DROPDOWN", "MULTIPLE_CHOICE" -> 1; // 드롭다운, 객관식은 하나만 선택 가능 + default -> 1; // 기본적으로 하나만 선택 가능하도록 설정 + }; + + builder.maxChoice(maxChoice) + .hasNoneOption(false) + .hasCustomInput(hasOtherOption) + .isSectionDecidable(isSectionDecidable) + .options(question.options().stream() + .filter(opt -> !opt.isOther()) + .map(opt -> OptionDto.builder() + .content(opt.text()) + .nextSection(opt.goToSectionOrder()) + .imageUrl(opt.imageUrl()) + .build()) + .toList()); + } + + questionUpsertInfoList.add(builder.build()); + } + } + } + } + + if (!questionUpsertInfoList.isEmpty()) { + QuestionUpsertDto questionUpsertDto = QuestionUpsertDto.builder() + .surveyId(surveyId) + .upsertInfoList(questionUpsertInfoList) + .build(); + + QuestionUpsertDto savedQuestions = questionCommand.upsertQuestionList(questionUpsertDto); + + // 4. Choice 문항의 옵션 저장 + List optionUpsertDtoList = new ArrayList<>(); + List savedInfoList = savedQuestions.getUpsertInfoList(); + Map originalInfoMap = questionUpsertInfoList.stream() + .collect(java.util.stream.Collectors.toMap(QuestionUpsertDto.UpsertInfo::getQuestionOrder, Function.identity())); + + for (QuestionUpsertDto.UpsertInfo savedInfo : savedInfoList) { + QuestionUpsertDto.UpsertInfo originalInfo = originalInfoMap.get(savedInfo.getQuestionOrder()); + + if (savedInfo.getQuestionType() == QuestionType.CHOICE && originalInfo.getOptions() != null) { + List options = originalInfo.getOptions().stream() + .map(opt -> OptionDto.builder() + .questionId(savedInfo.getQuestionId()) + .content(opt.getContent()) + .nextSection(opt.getNextSection()) + .imageUrl(opt.getImageUrl()) + .build()) + .toList(); + + optionUpsertDtoList.add(OptionUpsertDto.builder() + .questionId(savedInfo.getQuestionId()) + .optionInfoList(options) + .build()); + } + } + + if (!optionUpsertDtoList.isEmpty()) { + questionCommand.upsertChoiceOptionList(optionUpsertDtoList); + } + } + + return surveyId; + } + + private QuestionType mapQuestionType(String googleFormType) { + if (googleFormType == null) { + return null; + } + + return switch (googleFormType.toUpperCase()) { + case "MULTIPLE_CHOICE", "CHECKBOX" -> QuestionType.CHOICE; + case "SHORT_ANSWER", "SHORT" -> QuestionType.SHORT; + case "PARAGRAPH", "LONG" -> QuestionType.LONG; + case "LINEAR_SCALE", "SCALE" -> QuestionType.RATING; + case "DATE" -> QuestionType.DATE; + case "NUMBER" -> QuestionType.NUMBER; + case "IMAGE" -> QuestionType.IMAGE; + default -> null; + }; + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java index 3288aab3..fc45b716 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java @@ -1,46 +1,23 @@ package OneQ.OnSurvey.domain.survey.service.formRequest; -import OneQ.OnSurvey.domain.member.dto.MemberSearchResult; -import OneQ.OnSurvey.domain.member.service.MemberFinder; -import OneQ.OnSurvey.domain.question.model.QuestionType; -import OneQ.OnSurvey.domain.question.model.dto.OptionDto; -import OneQ.OnSurvey.domain.question.model.dto.OptionUpsertDto; -import OneQ.OnSurvey.domain.question.model.dto.QuestionUpsertDto; -import OneQ.OnSurvey.domain.question.model.dto.SectionDto; -import OneQ.OnSurvey.domain.question.service.QuestionCommand; import OneQ.OnSurvey.domain.survey.SurveyErrorCode; -import OneQ.OnSurvey.domain.survey.entity.FormRequest; import OneQ.OnSurvey.domain.survey.model.formRequest.FormConversionPayload; import OneQ.OnSurvey.domain.survey.model.formRequest.FormConversionResponse; import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationAndStashResponse; import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationPayload; -import OneQ.OnSurvey.domain.survey.model.formRequest.event.FormRequestConversionEvent; -import OneQ.OnSurvey.domain.survey.model.request.SurveyFormCreateRequest; -import OneQ.OnSurvey.domain.survey.model.response.SurveyFormResponse; -import OneQ.OnSurvey.domain.survey.repository.formRequest.FormRequestRepository; -import OneQ.OnSurvey.domain.survey.service.command.SurveyCommand; import OneQ.OnSurvey.global.common.exception.CustomException; import OneQ.OnSurvey.global.infra.discord.notifier.AlertNotifier; import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveyConversionAlert; -import OneQ.OnSurvey.global.infra.transaction.TransactionHandler; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; import org.springframework.web.reactive.function.client.WebClient; import reactor.util.retry.Retry; import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Function; @Slf4j @Component @@ -55,23 +32,10 @@ public class FormRequestLambda { private String validationUrl; private final AlertNotifier alertNotifier; - private final TransactionHandler transactionHandler; @Qualifier("lambdaWebClient") private final WebClient webClient; - private final MemberFinder memberFinder; - private final SurveyCommand surveyCommand; - private final QuestionCommand questionCommand; - - private final FormRequestRepository formRequestRepository; - - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void convertGoogleFormIntoSurvey(FormRequestConversionEvent event) { - log.info("[FormRequestLambda] 구글폼 변환 시작 - requestId: {}, formUrl: {}", event.requestId(), event.formUrls()); - - FormConversionPayload payload = new FormConversionPayload(event.formUrls()); - + public FormConversionResponse convertGoogleFormIntoSurvey(FormConversionPayload payload) { FormConversionResponse response = webClient.post() .uri(conversionUrl) .contentType(MediaType.APPLICATION_JSON) @@ -81,7 +45,7 @@ public void convertGoogleFormIntoSurvey(FormRequestConversionEvent event) { .timeout(Duration.ofSeconds(FORM_CONVERSION_REQUEST_TIMEOUT)) .retryWhen(Retry.backoff(3, Duration.ofSeconds(3))) .onErrorMap(e -> { - log.error("[FormRequestLambda] 구글폼 변환 중 오류 발생 - requestId: {}, error: {}", event.requestId(), e.getMessage(), e); + log.error("[FormRequestLambda] 구글폼 변환 중 오류 발생 - requestId: {}, error: {}", payload.requestId(), e.getMessage(), e); alertNotifier.sendSurveyConversionAsync( SurveyConversionAlert.error(1, 0, "구글폼 변환 중 오류가 발생했습니다. error: " + e.getMessage()) ); @@ -89,75 +53,10 @@ public void convertGoogleFormIntoSurvey(FormRequestConversionEvent event) { }) .block(); - if (response == null || response.results() == null) { - log.error("[FormRequestLambda] 구글폼 변환 실패 - 응답이 null입니다. requestId: {}", event.requestId()); - alertNotifier.sendSurveyConversionAsync( - response == null - ? SurveyConversionAlert.error(1, 0, "변환 요청에 대한 응답이 null입니다.") - : SurveyConversionAlert.error(response.totalCount(), response.successCount(), response.error()) - ); - throw new CustomException(SurveyErrorCode.FORM_CONVERSION_FAILED); - } - - List memberResultList = memberFinder.searchMembers(event.email(), null, null, null); - if (memberResultList.size() != 1) { - log.warn("[FormRequestLambda] 설문 생성 실패 - 요청자 이메일로 회원을 찾을 수 없습니다. email: {}", event.email()); - throw new CustomException(SurveyErrorCode.FORM_REQUEST_NOT_FOUND); - } - Long memberId = memberResultList.getFirst().id(); - - List detailList = new ArrayList<>(); - response.results().forEach(result -> { - if (result.isSuccess()) { - try { - detailList.add( - transactionHandler.runInTransaction(() -> { - Long surveyId = createSurveyFromConversionResult(result, memberId); - int questionCount = result.survey().sections() != null - ? result.survey().sections().stream() - .mapToInt(s -> s.questions() != null ? s.questions().size() : 0) - .sum() - : 0; - FormRequest request = formRequestRepository.findById(event.requestId()) - .orElseThrow(() -> new CustomException(SurveyErrorCode.FORM_REQUEST_NOT_FOUND)); - request.markAsRegistered(surveyId, questionCount); - - log.info("[FormRequestLambda] 구글폼 변환 성공 - requestId: {}, surveyId: {}, questionCount: {}", event.requestId(), surveyId, questionCount); - if (result.unsupportedQuestions() != null && !result.unsupportedQuestions().isEmpty()) { - log.warn("[FormRequestLambda] 지원하지 않는 문항 존재 - requestId: {}, count: {}", - event.requestId(), result.unsupportedQuestions().size()); - } - - return SurveyConversionAlert.SurveyDetails.success( - result.url(), - result.survey().title(), - surveyId, - memberId, - questionCount, - result.unsupportedQuestions() != null ? result.unsupportedQuestions().stream() - .map(q -> new SurveyConversionAlert.SurveyDetails.UnsupportedQuestion(q.order(), q.type(), q.reason())) - .toList() : List.of() - ); - }) - ); - } catch (Exception e) { - log.error("[FormRequestLambda] 설문 생성 중 오류 발생 - requestId: {}, error: {}", - event.requestId(), e.getMessage(), e); - detailList.add(SurveyConversionAlert.SurveyDetails.failure(result.url(), "설문 생성 중 오류가 발생했습니다.")); - } - } else { - log.error("[FormRequestLambda] 구글폼 변환 실패 - requestId: {}, url: {}, message: {}", - event.requestId(), result.url(), result.message()); - detailList.add(SurveyConversionAlert.SurveyDetails.failure(result.url(), result.message())); - } - }); - alertNotifier.sendSurveyConversionAsync( - SurveyConversionAlert.success(response.totalCount(), response.successCount(), detailList) - ); + return response; } - public FormValidationAndStashResponse validateAndStashFormRequest(String formLink, String requesterEmail) { - FormValidationPayload payload = new FormValidationPayload(List.of(formLink), requesterEmail); + public FormValidationAndStashResponse validateAndStashFormRequest(FormValidationPayload payload) { FormValidationAndStashResponse response = webClient.post() .uri(validationUrl) @@ -173,150 +72,6 @@ public FormValidationAndStashResponse validateAndStashFormRequest(String formLin }) .block(); - if (response == null || response.successCount() == 0) { - log.warn("[FormRequestLambda:validateAndStashFormRequest] 구글폼 링크가 유효하지 않음 - URLs: {}", payload.urls()); - throw new CustomException(SurveyErrorCode.FORM_INVALID); - } return response; } - - private Long createSurveyFromConversionResult(FormConversionResponse.Result result, Long memberId) { - FormConversionResponse.Survey survey = result.survey(); - - // 1. 설문 생성 - SurveyFormCreateRequest surveyRequest = new SurveyFormCreateRequest( - survey.title(), - survey.description() - ); - SurveyFormResponse surveyResponse = surveyCommand.upsertSurvey(memberId, null, surveyRequest); - Long surveyId = surveyResponse.surveyId(); - - // 2. 섹션 생성 - if (survey.sections() != null && !survey.sections().isEmpty()) { - List sectionDtoList = survey.sections().stream() - .map(section -> new SectionDto( - null, - section.title(), - section.description(), - section.order(), - section.nextSectionOrder() != null ? section.nextSectionOrder() : 0 - )) - .toList(); - - questionCommand.upsertSections(surveyId, sectionDtoList); - } - - // 3. 문항 생성 - List questionUpsertInfoList = new ArrayList<>(); - AtomicInteger questionOrder = new AtomicInteger(0); - - if (survey.sections() != null) { - for (FormConversionResponse.Section section : survey.sections()) { - if (section.questions() != null) { - for (FormConversionResponse.Question question : section.questions()) { - QuestionType questionType = mapQuestionType(question.type()); - if (questionType == null) { - continue; - } - - QuestionUpsertDto.UpsertInfo.UpsertInfoBuilder builder = QuestionUpsertDto.UpsertInfo.builder() - .questionId(null) - .title(question.title()) - .description(question.description()) - .isRequired(question.required()) - .questionType(questionType) - .questionOrder(questionOrder.getAndIncrement()) - .imageUrl(question.imageUrl()) - .section(section.order()); - - // Choice 타입인 경우 옵션 설정 - if (questionType == QuestionType.CHOICE && question.options() != null) { - boolean hasOtherOption = question.options().stream() - .anyMatch(FormConversionResponse.Option::isOther); - boolean isSectionDecidable = question.options().stream() - .anyMatch(opt -> opt.goToSectionOrder() != null); - int maxChoice = switch (question.type().toUpperCase()) { - case "CHECKBOX" -> question.options().size(); // 체크박스는 여러 개 선택 가능 - case "DROPDOWN", "MULTIPLE_CHOICE" -> 1; // 드롭다운, 객관식은 하나만 선택 가능 - default -> 1; // 기본적으로 하나만 선택 가능하도록 설정 - }; - - builder.maxChoice(maxChoice) - .hasNoneOption(false) - .hasCustomInput(hasOtherOption) - .isSectionDecidable(isSectionDecidable) - .options(question.options().stream() - .filter(opt -> !opt.isOther()) - .map(opt -> OptionDto.builder() - .content(opt.text()) - .nextSection(opt.goToSectionOrder()) - .imageUrl(opt.imageUrl()) - .build()) - .toList()); - } - - questionUpsertInfoList.add(builder.build()); - } - } - } - } - - if (!questionUpsertInfoList.isEmpty()) { - QuestionUpsertDto questionUpsertDto = QuestionUpsertDto.builder() - .surveyId(surveyId) - .upsertInfoList(questionUpsertInfoList) - .build(); - - QuestionUpsertDto savedQuestions = questionCommand.upsertQuestionList(questionUpsertDto); - - // 4. Choice 문항의 옵션 저장 - List optionUpsertDtoList = new ArrayList<>(); - List savedInfoList = savedQuestions.getUpsertInfoList(); - Map originalInfoMap = questionUpsertInfoList.stream() - .collect(java.util.stream.Collectors.toMap(QuestionUpsertDto.UpsertInfo::getQuestionOrder, Function.identity())); - - for (QuestionUpsertDto.UpsertInfo savedInfo : savedInfoList) { - QuestionUpsertDto.UpsertInfo originalInfo = originalInfoMap.get(savedInfo.getQuestionOrder()); - - if (savedInfo.getQuestionType() == QuestionType.CHOICE && originalInfo.getOptions() != null) { - List options = originalInfo.getOptions().stream() - .map(opt -> OptionDto.builder() - .questionId(savedInfo.getQuestionId()) - .content(opt.getContent()) - .nextSection(opt.getNextSection()) - .imageUrl(opt.getImageUrl()) - .build()) - .toList(); - - optionUpsertDtoList.add(OptionUpsertDto.builder() - .questionId(savedInfo.getQuestionId()) - .optionInfoList(options) - .build()); - } - } - - if (!optionUpsertDtoList.isEmpty()) { - questionCommand.upsertChoiceOptionList(optionUpsertDtoList); - } - } - - return surveyId; - } - - private QuestionType mapQuestionType(String googleFormType) { - if (googleFormType == null) { - return null; - } - - return switch (googleFormType.toUpperCase()) { - case "MULTIPLE_CHOICE", "CHECKBOX" -> QuestionType.CHOICE; - case "SHORT_ANSWER", "SHORT" -> QuestionType.SHORT; - case "PARAGRAPH", "LONG" -> QuestionType.LONG; - case "LINEAR_SCALE", "SCALE" -> QuestionType.RATING; - case "DATE" -> QuestionType.DATE; - case "NUMBER" -> QuestionType.NUMBER; - case "IMAGE" -> QuestionType.IMAGE; - default -> null; - }; - } } From 4f6da1e94895dde8e9186d643d555fd42556badf Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Sun, 22 Mar 2026 09:36:02 +0900 Subject: [PATCH 14/50] =?UTF-8?q?feat:=20=EC=A0=9C=EB=AA=A9=20=EB=B0=8F=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=20=EB=AC=B8=ED=95=AD=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../question/entity/question/Image.java | 6 +-- .../question/entity/question/Title.java | 47 +++++++++++++++++++ .../domain/question/model/QuestionType.java | 2 + .../service/QuestionCommandService.java | 18 ++++++- .../formRequest/FormEventListener.java | 1 + 5 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 src/main/java/OneQ/OnSurvey/domain/question/entity/question/Title.java diff --git a/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Image.java b/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Image.java index a4b7a1d5..b916384f 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Image.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Image.java @@ -20,7 +20,6 @@ public static Image of( Integer order, String title, String description, - Boolean isRequired, Integer section, QuestionType type, String imageUrl @@ -30,7 +29,7 @@ public static Image of( .order(order) .title(title) .description(description) - .isRequired(isRequired) + .isRequired(false) .type(type.name()) .section(section) .imageUrl(imageUrl) @@ -40,11 +39,10 @@ public static Image of( public void updateQuestion( String title, String description, - Boolean isRequired, Integer order, Integer section, String imageUrl ) { - super.updateQuestion(title, description, isRequired, order, section, imageUrl); + super.updateQuestion(title, description, false, order, section, imageUrl); } } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Title.java b/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Title.java new file mode 100644 index 00000000..3f4bed87 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Title.java @@ -0,0 +1,47 @@ +package OneQ.OnSurvey.domain.question.entity.question; + +import OneQ.OnSurvey.domain.question.entity.Question; +import OneQ.OnSurvey.domain.question.model.QuestionType; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@DiscriminatorValue(value = QuestionType.Values.TITLE) +public class Title extends Question { + + public static Title of( + Long surveyId, + Integer order, + String title, + String description, + Integer section, + QuestionType type + ) { + return Title.builder() + .surveyId(surveyId) + .order(order) + .title(title) + .description(description) + .isRequired(false) + .type(type.name()) + .section(section) + .imageUrl(null) + .build(); + } + + public void updateQuestion( + String title, + String description, + Integer order, + Integer section + ) { + super.updateQuestion(title, description, false, order, section, null); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/question/model/QuestionType.java b/src/main/java/OneQ/OnSurvey/domain/question/model/QuestionType.java index c144f852..0b5bafab 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/model/QuestionType.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/model/QuestionType.java @@ -14,6 +14,7 @@ public enum QuestionType { NUMBER("숫자형", Values.NUMBER), DATE("날짜형", Values.DATE), IMAGE("이미지형", Values.IMAGE), + TITLE("제목형", Values.TITLE), TEXT("주관식", Values.TEXT); private final String description; private final String value; @@ -28,6 +29,7 @@ public static class Values { public static final String DATE = "DATE"; public static final String TEXT = "TEXT"; public static final String IMAGE = "IMAGE"; + public static final String TITLE = "TITLE"; } public boolean isText() { diff --git a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java index 65b9106c..76fca6da 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java @@ -200,11 +200,17 @@ private void updateQuestion(QuestionUpsertDto.UpsertInfo upsertInfo, Question qu image.updateQuestion( upsertInfo.getTitle(), upsertInfo.getDescription(), - upsertInfo.getIsRequired(), upsertInfo.getQuestionOrder(), upsertInfo.getSection(), upsertInfo.getImageUrl() ); + } else if (question instanceof Title title) { + title.updateQuestion( + upsertInfo.getTitle(), + upsertInfo.getDescription(), + upsertInfo.getQuestionOrder(), + upsertInfo.getSection() + ); } } @@ -302,11 +308,19 @@ private Question createQuestion(Long surveyId, QuestionUpsertDto.UpsertInfo upse upsertInfo.getQuestionOrder(), upsertInfo.getTitle(), upsertInfo.getDescription(), - upsertInfo.getIsRequired(), upsertInfo.getSection(), type, upsertInfo.getImageUrl() ); + } else if (QuestionType.TITLE.equals(type)) { + return Title.of( + surveyId, + upsertInfo.getQuestionOrder(), + upsertInfo.getTitle(), + upsertInfo.getDescription(), + upsertInfo.getSection(), + type + ); } else { throw new CustomException(ErrorCode.INVALID_REQUEST); } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormEventListener.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormEventListener.java index d5d17593..90197002 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormEventListener.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormEventListener.java @@ -259,6 +259,7 @@ private QuestionType mapQuestionType(String googleFormType) { case "DATE" -> QuestionType.DATE; case "NUMBER" -> QuestionType.NUMBER; case "IMAGE" -> QuestionType.IMAGE; + case "TITLE_DESCRIPTION" -> QuestionType.TITLE; default -> null; }; } From 0156a574e3264d1a7ec722f263832991a9462876 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Sun, 22 Mar 2026 12:28:25 +0900 Subject: [PATCH 15/50] =?UTF-8?q?mod:=20=EC=84=A4=EB=AC=B8=20=EA=B4=80?= =?UTF-8?q?=EC=8B=AC=EC=82=AC=20=EC=84=A4=EC=A0=95=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../survey/model/formRequest/FormRequestDto.java | 12 +++++++++++- .../event/FormRequestConversionEvent.java | 5 ++++- .../service/formRequest/FormCommandService.java | 3 ++- .../service/formRequest/FormEventListener.java | 7 +++++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestDto.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestDto.java index f63d0adb..708ca002 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestDto.java @@ -1,5 +1,6 @@ package OneQ.OnSurvey.domain.survey.model.formRequest; +import OneQ.OnSurvey.domain.member.value.Interest; import OneQ.OnSurvey.domain.survey.entity.FormRequest; import OneQ.OnSurvey.domain.survey.model.request.ScreeningRequest; import OneQ.OnSurvey.domain.survey.model.request.SurveyFormRequest; @@ -10,6 +11,8 @@ import jakarta.validation.constraints.NotNull; import org.hibernate.validator.constraints.URL; +import java.util.Set; + public record FormRequestDto( @Schema(description = "폼 링크", example = "https://docs.google.com/forms/d/e/1FAIpQLSfD.../viewform") @URL @NotBlank @@ -26,7 +29,14 @@ public record FormRequestDto( @Schema(description = "세그먼트 및 가격 정보") @NotNull @Valid - SurveyFormRequest surveyForm + SurveyFormRequest surveyForm, + + @Schema( + description = "관심사 목록", + example = "[\"CAREER\", \"BUSINESS\", \"FINANCE\"]", + implementation = Interest.class + ) + Set interests ) { public FormRequest toEntity(long userKey) { return FormRequest.createRequest(formLink, requesterEmail, userKey); diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/event/FormRequestConversionEvent.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/event/FormRequestConversionEvent.java index a3abec83..e27ba558 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/event/FormRequestConversionEvent.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/event/FormRequestConversionEvent.java @@ -1,9 +1,11 @@ package OneQ.OnSurvey.domain.survey.model.formRequest.event; +import OneQ.OnSurvey.domain.member.value.Interest; import OneQ.OnSurvey.domain.survey.model.request.ScreeningRequest; import OneQ.OnSurvey.domain.survey.model.request.SurveyFormRequest; import java.util.List; +import java.util.Set; public record FormRequestConversionEvent ( Long requestId, @@ -13,6 +15,7 @@ public record FormRequestConversionEvent ( // TODO 무거운 이벤트를 requestId, surveyId, userKey, formUrls만 보내도록 설문을 선생성 후 데이터를 추가하도록 로직 수정 필요 ScreeningRequest screening, - SurveyFormRequest surveyForm + SurveyFormRequest surveyForm, + Set interests ) { } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCommandService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCommandService.java index 00143ae0..98f83cf1 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCommandService.java @@ -50,7 +50,8 @@ public Long createFormRequest(Long userKey, Long memberId, FormRequestDto dto) { memberId, List.of(savedRequest.getFormLink()), dto.screening(), - dto.surveyForm() + dto.surveyForm(), + dto.interests() )); return savedRequest.getId(); } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormEventListener.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormEventListener.java index 90197002..0d594cd9 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormEventListener.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormEventListener.java @@ -1,5 +1,6 @@ package OneQ.OnSurvey.domain.survey.service.formRequest; +import OneQ.OnSurvey.domain.member.value.Interest; import OneQ.OnSurvey.domain.question.model.QuestionType; import OneQ.OnSurvey.domain.question.model.dto.OptionDto; import OneQ.OnSurvey.domain.question.model.dto.OptionUpsertDto; @@ -29,6 +30,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; @@ -93,6 +95,11 @@ public void convertGoogleFormIntoSurvey(FormRequestConversionEvent event) { event.screening().answer() ); } + if (event.interests() != null && !event.interests().isEmpty()) { + surveyCommand.upsertInterest(surveyId, event.interests()); + } else { + surveyCommand.upsertInterest(surveyId, Set.of(Interest.BUSINESS)); + } surveyCommand.submitSurvey(event.userKey(), surveyId, event.surveyForm()); return SurveyConversionAlert.SurveyDetails.success( From 20a42bd30c88bd2d7f46baecd3e9ca713e879adf Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Sun, 22 Mar 2026 12:28:57 +0900 Subject: [PATCH 16/50] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/QuestionCommandService.java | 2 +- .../controller/FormRequestController.java | 2 +- .../formRequest/FormValidationResponse.java | 20 +++++++++++-------- .../formRequest/FormRequestLambda.java | 2 +- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java index 76fca6da..3f45d293 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java @@ -312,7 +312,7 @@ private Question createQuestion(Long surveyId, QuestionUpsertDto.UpsertInfo upse type, upsertInfo.getImageUrl() ); - } else if (QuestionType.TITLE.equals(type)) { + } else if (QuestionType.TITLE.equals(type)) { return Title.of( surveyId, upsertInfo.getQuestionOrder(), diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java b/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java index 03a0a7b4..e5e97c1b 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java @@ -83,7 +83,7 @@ public SuccessResponse markAsRegistered( public SuccessResponse getConvertableCounts( @RequestBody @Valid FormValidationRequestDto request ) { - log.info("[FormRequest] 폼 링크 유효성 검사 - URL: {}, requester: {}", request.formLink(), request.requesterEmail()); + log.info("[FormRequest] 폼 링크 유효성 검사 - URL: {}", request.formLink()); FormValidationResponse response = formCreator.validationFormRequestLink(request); return SuccessResponse.ok(response); diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationResponse.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationResponse.java index 7166c049..8b8925a8 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationResponse.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationResponse.java @@ -20,19 +20,23 @@ public record Unconvertible( ) { } public static FormValidationResponse from(FormValidationAndStashResponse dto) { - List results = dto.results().stream() + List sourceResults = dto.results() == null ? List.of() : dto.results(); + + List results = sourceResults.stream() .map(r -> r.isSuccess() ? new Result( r.counts().total(), r.counts().convertible(), r.counts().unconvertible(), - r.unconvertibleDetails().stream() - .map(u -> new Unconvertible( - u.title(), - u.type(), - u.reason() - )) - .toList() + r.unconvertibleDetails() != null + ? r.unconvertibleDetails().stream() + .map(u -> new Unconvertible( + u.title(), + u.type(), + u.reason() + )) + .toList() + : List.of() ) : null ).toList(); diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java index fc45b716..0fb38137 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java @@ -65,7 +65,7 @@ public FormValidationAndStashResponse validateAndStashFormRequest(FormValidation .retrieve() .bodyToMono(FormValidationAndStashResponse.class) .timeout(Duration.ofSeconds(FORM_CONVERSION_REQUEST_TIMEOUT)) - .retryWhen(Retry.backoff(3, Duration.ofSeconds(3))) + .retryWhen(Retry.backoff(2, Duration.ofSeconds(3))) .onErrorMap(e -> { log.error("[FormRequestLambda:validateAndStashFormRequest] 구글폼 링크 유효성 검사 실패 - URLs: {}, error: {}", payload.urls(), e.getMessage(), e); throw new CustomException(SurveyErrorCode.FORM_VALIDATION_FAILED); From e8b719c80317a86bfefece0ef83d7277220c50dc Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Sun, 22 Mar 2026 12:31:23 +0900 Subject: [PATCH 17/50] =?UTF-8?q?chore:=20=ED=8F=BC=EB=A7=81=ED=81=AC=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=20=EC=98=88=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/survey/model/formRequest/FormRequestDto.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestDto.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestDto.java index 708ca002..1a4b4cff 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormRequestDto.java @@ -14,7 +14,7 @@ import java.util.Set; public record FormRequestDto( - @Schema(description = "폼 링크", example = "https://docs.google.com/forms/d/e/1FAIpQLSfD.../viewform") + @Schema(description = "폼 링크", example = "https://docs.google.com/forms/d/1FAIpQLSfD.../edit") @URL @NotBlank String formLink, From b814ae85cf79947115859d012cca4d4f6b175627 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Sun, 22 Mar 2026 13:16:37 +0900 Subject: [PATCH 18/50] =?UTF-8?q?fix:=20=EA=B4=80=EC=8B=AC=EC=82=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=8B=9C,=20Set=20=EA=B0=9D=EC=B2=B4=20?= =?UTF-8?q?=EB=82=B4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A7=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/OneQ/OnSurvey/domain/survey/entity/Survey.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/entity/Survey.java b/src/main/java/OneQ/OnSurvey/domain/survey/entity/Survey.java index d1f09adb..3df9bd10 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/entity/Survey.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/entity/Survey.java @@ -93,7 +93,8 @@ public void updateSurvey( } public void updateInterests(Set interests) { - this.interests = interests; + this.interests.clear(); + this.interests.addAll(interests); } public void markFree() { From 88cc245f1ad72cc370def0ec1ad324956ff1fc29 Mon Sep 17 00:00:00 2001 From: jaekwan Date: Sun, 22 Mar 2026 15:18:31 +0900 Subject: [PATCH 19/50] =?UTF-8?q?fix;=20swagger=20dev=20=EC=A3=BC=EC=86=8C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/OneQ/OnSurvey/global/common/config/SwaggerConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/OneQ/OnSurvey/global/common/config/SwaggerConfig.java b/src/main/java/OneQ/OnSurvey/global/common/config/SwaggerConfig.java index de9d58b9..b0ddeb70 100644 --- a/src/main/java/OneQ/OnSurvey/global/common/config/SwaggerConfig.java +++ b/src/main/java/OneQ/OnSurvey/global/common/config/SwaggerConfig.java @@ -18,7 +18,7 @@ public OpenAPI openAPI() { return new OpenAPI() .servers(List.of( new Server().url("https://api.onsurvey.co.kr").description("Prod Server"), - new Server().url("https://dev-api.onsurvey.co.kr").description("Dev Server"), + new Server().url("https://dev.api.onsurvey.co.kr").description("Dev Server"), new Server().url("http://localhost:8080").description("Local Server") )) .components(components()) From 80994c65f71395134cdb1485c1fea5ba73497797 Mon Sep 17 00:00:00 2001 From: jaekwan Date: Sun, 22 Mar 2026 15:27:13 +0900 Subject: [PATCH 20/50] =?UTF-8?q?fix:=20surveyInfo=EC=97=90=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EA=B0=80=EA=B2=A9=20request=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=EB=B0=8F=20response=20=EA=B8=B0=EB=B3=B8=20=EA=B0=92=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../survey/model/request/SurveyFormRequest.java | 8 -------- .../survey/model/response/SurveyInfoResponse.java | 8 ++++---- .../service/command/SurveyCommandService.java | 13 ++----------- 3 files changed, 6 insertions(+), 23 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/request/SurveyFormRequest.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/request/SurveyFormRequest.java index 5fa4186e..3fabbbb1 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/request/SurveyFormRequest.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/request/SurveyFormRequest.java @@ -16,23 +16,15 @@ public record SurveyFormRequest( @Schema(description = "성별", example = "ALL") Gender gender, - @Schema(description = "성별 가격", example = "100") - Integer genderPrice, @Schema(description = "연령대 목록", example = "[\"TEN\",\"TWENTY\",\"THIRTY\"]") List ages, - @Schema(description = "연령대 가격", example = "100") - Integer agePrice, @Schema(description = "거주지", example = "SEOUL") Residence residence, - @Schema(description = "거주지 가격", example = "100") - Integer residencePrice, @Schema(description = "응답자 수", example = "50") Integer dueCount, - @Schema(description = "응답자 수 가격", example = "5000") - Integer dueCountPrice, @Schema(description = "총 코인", example = "10000") Integer totalCoin, diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/response/SurveyInfoResponse.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/response/SurveyInfoResponse.java index 831aaa50..6ed1bd2f 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/response/SurveyInfoResponse.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/response/SurveyInfoResponse.java @@ -25,13 +25,13 @@ public static SurveyInfoResponse from(SurveyInfo info) { return new SurveyInfoResponse( info.getDueCount(), - info.getDueCountPrice(), + info.getDueCountPrice() != null ? info.getDueCountPrice() : 0, info.getGender(), - info.getGenderPrice(), + info.getGenderPrice() != null ? info.getGenderPrice() : 0, ages, - info.getAgePrice(), + info.getAgePrice() != null ? info.getAgePrice() : 0, info.getResidence(), - info.getResidencePrice() + info.getResidencePrice() != null ? info.getResidencePrice() : 0 ); } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java index 0250e681..adc7db22 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java @@ -147,10 +147,6 @@ public SurveyFormResponse submitSurvey(Long userKey, Long surveyId, SurveyFormRe request.gender(), ages, request.residence(), - request.genderPrice(), - request.agePrice(), - request.residencePrice(), - request.dueCountPrice(), resolvedPromotionAmount, discountCodeId, true @@ -176,7 +172,6 @@ public SurveyFormResponse submitFreeSurvey(Long userKey, Long surveyId, FreeSurv Gender.ALL, Set.of(AgeRange.ALL), Residence.ALL, - 0, 0, 0, 0, 0, null, false @@ -308,10 +303,6 @@ private SurveyInfo upsertSurveyInfo( Gender gender, Set ages, Residence residence, - Integer genderPrice, - Integer agePrice, - Integer residencePrice, - Integer dueCountPrice, Integer promotionAmount, Long discountCodeId, boolean refundable @@ -319,10 +310,10 @@ private SurveyInfo upsertSurveyInfo( SurveyInfo info = surveyInfoRepository.findBySurveyId(surveyId) .orElseGet(() -> SurveyInfo.createSurveyInfo( surveyId, dueCount, gender, ages, residence, - genderPrice, agePrice, residencePrice, dueCountPrice, promotionAmount, discountCodeId + 0, 0, 0, 0, promotionAmount, discountCodeId )); - info.updateSurveyInfo(dueCount, gender, ages, residence, genderPrice, agePrice, residencePrice, dueCountPrice, promotionAmount, discountCodeId); + info.updateSurveyInfo(dueCount, gender, ages, residence, 0, 0, 0, 0, promotionAmount, discountCodeId); if (!refundable) info.markNonRefundable(); From 24713e9f4f09ddca35265b2dcf8b49c59fd146c6 Mon Sep 17 00:00:00 2001 From: shash0423 Date: Wed, 25 Mar 2026 15:04:32 +0900 Subject: [PATCH 21/50] =?UTF-8?q?fix:=20=ED=95=84=EC=88=98=20=EB=AC=B8?= =?UTF-8?q?=ED=95=AD=EC=97=90=20=EB=8C=80=ED=95=9C=20=EB=8B=B5=EB=B3=80=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/ResponseCommandService.java | 19 +++++++++++++++++++ .../question/QuestionRepository.java | 1 + .../question/QuestionRepositoryImpl.java | 11 +++++++++++ .../domain/survey/SurveyErrorCode.java | 1 + 4 files changed, 32 insertions(+) diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java index 9603fe20..fcf9497b 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java @@ -1,8 +1,11 @@ package OneQ.OnSurvey.domain.participation.service.response; +import OneQ.OnSurvey.domain.participation.entity.QuestionAnswer; import OneQ.OnSurvey.domain.participation.entity.Response; import OneQ.OnSurvey.domain.participation.model.event.SurveyCompletedEvent; +import OneQ.OnSurvey.domain.participation.repository.answer.AnswerRepository; import OneQ.OnSurvey.domain.participation.repository.response.ResponseRepository; +import OneQ.OnSurvey.domain.question.repository.question.QuestionRepository; import OneQ.OnSurvey.domain.survey.SurveyErrorCode; import OneQ.OnSurvey.domain.survey.entity.Survey; import OneQ.OnSurvey.domain.survey.model.SurveyStatus; @@ -23,6 +26,7 @@ import java.util.List; import java.util.Map; +import java.util.Set; @Slf4j @Service @@ -33,6 +37,8 @@ public class ResponseCommandService implements ResponseCommand { private final SurveyRepository surveyRepository; private final SurveyInfoRepository surveyInfoRepository; private final SurveyGlobalStatsService surveyGlobalStatsService; + private final QuestionRepository questionRepository; + private final AnswerRepository questionAnswerRepository; private final AfterCommitExecutor afterCommitExecutor; private final AfterRollbackExecutor afterRollbackExecutor; @@ -62,6 +68,19 @@ public Boolean createResponse(Long surveyId, Long memberId, Long userKey) { throw new CustomException(SurveyErrorCode.SURVEY_ALREADY_PARTICIPATED); } + List requiredIds = questionRepository.getRequiredQuestionIdsBySurveyId(surveyId); + if (!requiredIds.isEmpty()) { + Set answeredIds = questionAnswerRepository + .getAnswerListByQuestionIdsAndMemberId(requiredIds, memberId) + .stream() + .map(QuestionAnswer::getQuestionId) + .collect(java.util.stream.Collectors.toSet()); + boolean allAnswered = answeredIds.containsAll(requiredIds); + if (!allAnswered) { + throw new CustomException(SurveyErrorCode.SURVEY_ANSWER_INCOMPLETE); + } + } + response.markResponded(); responseRepository.save(response); surveyGlobalStatsService.addCompletedCount(1); diff --git a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepository.java b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepository.java index d929a878..6ac41d41 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepository.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepository.java @@ -12,6 +12,7 @@ public interface QuestionRepository { Question save(Question question); List saveAll(Collection questions); + List getRequiredQuestionIdsBySurveyId(Long surveyId); Long getSurveyId(Long questionId); void deleteAll(Set idList); void deleteBySurveyIdAndNotInOrder(Long surveyId, Collection order); diff --git a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepositoryImpl.java index ca7acc94..5b09b4eb 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepositoryImpl.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepositoryImpl.java @@ -38,6 +38,17 @@ public List saveAll(Collection questions) { return questionJpaRepository.saveAllAndFlush(questions); } + @Override + public List getRequiredQuestionIdsBySurveyId(Long surveyId) { + return jpaQueryFactory.select(question.questionId) + .from(question) + .where( + question.surveyId.eq(surveyId), + question.isRequired.isTrue() + ) + .fetch(); + } + @Override public Long getSurveyId(Long questionId) { return jpaQueryFactory.select(question.surveyId) diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java b/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java index 92858958..a4bf5c83 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java @@ -28,6 +28,7 @@ public enum SurveyErrorCode implements ApiErrorCode { SURVEY_FORBIDDEN("SURVEY_403", "설문에 대한 권한이 없습니다.", HttpStatus.FORBIDDEN), SURVEY_ANSWER_INVALID("SURVEY_ANSWER_400", "설문 답변이 올바르지 않습니다.", HttpStatus.BAD_REQUEST), + SURVEY_ANSWER_INCOMPLETE("SURVEY_ANSWER_INCOMPLETE_400", "모든 필수 문항에 응답하지 않았습니다.", HttpStatus.BAD_REQUEST), SURVEY_FREE_PROMOTION_NOT_ALLOWED("SURVEY_PROMOTION_400", "무료 설문은 프로모션 지급 대상이 아닙니다.", HttpStatus.BAD_REQUEST), FORM_REQUEST_NOT_FOUND("FORM_REQUEST_404", "구글 폼 신청을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), From c9428ae6560516d92d94de7c0722e9afa0e94489 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Wed, 25 Mar 2026 22:19:54 +0900 Subject: [PATCH 22/50] =?UTF-8?q?mod:=20=EB=B3=80=ED=99=98=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20DTO=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/formRequest/ConversionResultDto.java | 13 +++++++++++++ .../formRequest/FormValidationAndStashResponse.java | 1 + 2 files changed, 14 insertions(+) create mode 100644 src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/ConversionResultDto.java diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/ConversionResultDto.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/ConversionResultDto.java new file mode 100644 index 00000000..118482c4 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/ConversionResultDto.java @@ -0,0 +1,13 @@ +package OneQ.OnSurvey.domain.survey.model.formRequest; + +import OneQ.OnSurvey.domain.question.model.dto.SectionDto; +import OneQ.OnSurvey.domain.question.model.dto.type.DefaultQuestionDto; + +import java.util.List; + +public record ConversionResultDto ( + String title, + String description, + List sections, + List questions +) { } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationAndStashResponse.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationAndStashResponse.java index 521de5b2..0ab051c6 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationAndStashResponse.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationAndStashResponse.java @@ -15,6 +15,7 @@ public record Result( // SUCCESS Count counts, List unconvertibleDetails, + ConversionResultDto convertibleDetails, // FAIL String message From ed9c7a2b716a80ea01bdd09e24e2bb9d6cc9c51c Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Thu, 26 Mar 2026 19:41:23 +0900 Subject: [PATCH 23/50] =?UTF-8?q?feat:=20=EB=B3=80=ED=99=98=EB=90=9C=20?= =?UTF-8?q?=EC=84=A4=EB=AC=B8=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EB=8C=80=EC=9D=91=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...e.java => FormValidationPostResponse.java} | 18 +- .../formRequest/FormValidationResponse.java | 148 +++++++++-- .../formRequest/FormCommandService.java | 7 +- .../service/formRequest/FormConverter.java | 234 ++++++++++++++++++ .../formRequest/FormEventListener.java | 1 + .../formRequest/FormRequestLambda.java | 8 +- 6 files changed, 379 insertions(+), 37 deletions(-) rename src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/{FormValidationAndStashResponse.java => FormValidationPostResponse.java} (56%) create mode 100644 src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormConverter.java diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationAndStashResponse.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationPostResponse.java similarity index 56% rename from src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationAndStashResponse.java rename to src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationPostResponse.java index 0ab051c6..4ea1f613 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationAndStashResponse.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationPostResponse.java @@ -1,8 +1,11 @@ package OneQ.OnSurvey.domain.survey.model.formRequest; +import OneQ.OnSurvey.domain.question.model.dto.SectionDto; +import OneQ.OnSurvey.domain.question.model.dto.type.DefaultQuestionDto; + import java.util.List; -public record FormValidationAndStashResponse( +public record FormValidationPostResponse( int totalUrls, int successCount, List results @@ -14,8 +17,8 @@ public record Result( // SUCCESS Count counts, - List unconvertibleDetails, - ConversionResultDto convertibleDetails, + List inconvertibleDetails, + Convertible convertibleDetails, // FAIL String message @@ -31,9 +34,16 @@ public record Count( int unconvertible ) { } - public record Unconvertible( + public record Inconvertible( String title, String type, String reason ) { } + + public record Convertible( + String title, + String description, + List sections, + List questions + ) { } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationResponse.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationResponse.java index 8b8925a8..d45b0283 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationResponse.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormValidationResponse.java @@ -1,46 +1,142 @@ package OneQ.OnSurvey.domain.survey.model.formRequest; +import OneQ.OnSurvey.domain.question.model.dto.type.DefaultQuestionDto; +import io.swagger.v3.oas.annotations.media.Schema; + import java.util.List; public record FormValidationResponse( + @Schema( + description = "URL 검증이 이루어진 응답 결과", + example = """ + [ + { + "url": "https://docs.google.com/forms/d/1Eq41ykgka_.../edit", + "message": null, + "totalCount": 30, + "convertible": 22, + "inconvertible": 8, + "inconvertibleDetails": [ + { + "title": "비디오 문항 제목", + "type": "VIDEO", + "reason": "비디오 문항 미지원" + }, { + "title": "시간 문항 제목", + "type": "TIME", + "reason": "시간 문항 미지원" + } + ], + "convertibleDetails": [ + { + "sectionTitle": "1. 기본 정보", + "sectionDescription": "1번 섹션", + "currSection": 1, + "nextSection": 2, + "info": [ + { + "questionType": "SHORT", + "title": "단답형 문항 제목", + "description": null, + "isRequired": true, + "questionOrder": 1, + "section": 1, + "imageUrl": null + } + ] + }, { + "sectionTitle": "2. 중간 정보", + "sectionDescription": "2번 섹션", + "currSection": 2, + "nextSection": 3, + "info": [ + { + "questionType": "DATE", + "title": "날짜형 문항 제목", + "description": null, + "isRequired": true, + "questionOrder": 2, + "section": 2, + "imageUrl": null + } + ] + }, { + "sectionTitle": "3. 마지막 정보", + "sectionDescription": "3번 섹션", + "currSection": 3, + "nextSection": 0, + "info": [ + { + "questionType": "RATING", + "title": "평가형 문항 제목", + "description": null, + "isRequired": true, + "questionOrder": 3, + "section": 3, + "imageUrl": null, + "minValue": "최소라벨값", + "maxValue": "최대라벨값", + "rate": 6 + } + ] + } + ] + }, { + "url": "https://docs.google.com/forms/d/e/1Eq41ykgka_.../viewform", + "message": "유효하지 않은 구글폼 Edit 링크입니다." + }, { + "url": "https://docs.google.com/forms/d/1Eq4gka_.../edit", + "message": "설문이 게시되지 않았습니다." + }, { + "url": "https://docs.google.com/forms/d/1Eq4gka_.../edit", + "message": "변환된 설문 저장 중 에러가 발생했습니다. 재시도 해주세요." + } + ] + """ + ) List results ) { public record Result( + String url, + String message, + int totalCount, int convertible, - int unconvertible, - List details + int inconvertible, + List inconvertibleDetails, + List convertibleDetails ) { } - public record Unconvertible( + public record Inconvertible( String title, String type, String reason ) { } - public static FormValidationResponse from(FormValidationAndStashResponse dto) { - List sourceResults = dto.results() == null ? List.of() : dto.results(); - - List results = sourceResults.stream() - .map(r -> r.isSuccess() - ? new Result( - r.counts().total(), - r.counts().convertible(), - r.counts().unconvertible(), - r.unconvertibleDetails() != null - ? r.unconvertibleDetails().stream() - .map(u -> new Unconvertible( - u.title(), - u.type(), - u.reason() - )) - .toList() - : List.of() - ) - : null - ).toList(); - - return new FormValidationResponse(results); + public record Convertible( + String sectionTitle, + String sectionDescription, + Integer currSection, + Integer nextSection, + List info + ) { } + + public static FormValidationResponse.Result success( + String url, + int count, + int convertible, + int inconvertible, + List inconvertibleDetails, + List convertibleDetails + ) { + return new FormValidationResponse.Result(url, null, count, convertible, inconvertible, inconvertibleDetails, convertibleDetails); + } + + public static FormValidationResponse.Result fail( + String url, + String message + ) { + return new FormValidationResponse.Result(url, message, 0, 0, 0, List.of(), List.of()); } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCommandService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCommandService.java index 98f83cf1..37f5e5ff 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormCommandService.java @@ -5,7 +5,7 @@ import OneQ.OnSurvey.domain.survey.SurveyErrorCode; import OneQ.OnSurvey.domain.survey.entity.FormRequest; import OneQ.OnSurvey.domain.survey.model.formRequest.FormPublishRequest; -import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationAndStashResponse; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationPostResponse; import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationPayload; import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationRequestDto; import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationResponse; @@ -33,6 +33,7 @@ public class FormCommandService implements FormCreator, FormUpdater, FormPublisher { private final ApplicationEventPublisher eventPublisher; + private final FormConverter formConverter; private final FormRequestLambda formRequestLambda; private final FormRequestRepository formRequestRepository; private final SurveyQueryService surveyQueryService; @@ -104,12 +105,12 @@ public SurveyFormResponse publishFormRequest(Long requestId, FormPublishRequest @Override public FormValidationResponse validationFormRequestLink(FormValidationRequestDto dto) { FormValidationPayload payload = new FormValidationPayload(List.of(dto.formLink()), dto.requesterEmail()); - FormValidationAndStashResponse validationResult = formRequestLambda.validateAndStashFormRequest(payload); + FormValidationPostResponse validationResult = formRequestLambda.validateAndStashFormRequest(payload); if (validationResult == null || validationResult.successCount() == 0) { log.warn("[FormCommandService:validationFormRequestLink] 구글폼 링크가 유효하지 않음 - URL: {}", dto.formLink()); throw new CustomException(SurveyErrorCode.FORM_INVALID); } - return FormValidationResponse.from(validationResult); + return formConverter.toResponse(validationResult); } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormConverter.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormConverter.java new file mode 100644 index 00000000..82343d77 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormConverter.java @@ -0,0 +1,234 @@ +package OneQ.OnSurvey.domain.survey.service.formRequest; + +import OneQ.OnSurvey.domain.question.model.QuestionType; +import OneQ.OnSurvey.domain.question.model.dto.OptionDto; +import OneQ.OnSurvey.domain.question.model.dto.OptionUpsertDto; +import OneQ.OnSurvey.domain.question.model.dto.QuestionUpsertDto; +import OneQ.OnSurvey.domain.question.model.dto.SectionDto; +import OneQ.OnSurvey.domain.question.model.dto.type.DefaultQuestionDto; +import OneQ.OnSurvey.domain.question.service.QuestionCommand; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormConversionResponse; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationPostResponse; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationResponse; +import OneQ.OnSurvey.domain.survey.model.request.SurveyFormCreateRequest; +import OneQ.OnSurvey.domain.survey.model.response.SurveyFormResponse; +import OneQ.OnSurvey.domain.survey.service.command.SurveyCommand; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class FormConverter { + + private final SurveyCommand surveyCommand; + private final QuestionCommand questionCommand; + + public Long createSurveyFromConversionResult(FormConversionResponse.Result result, Long memberId) { + FormConversionResponse.Survey survey = result.survey(); + + // 1. 설문 생성 + SurveyFormCreateRequest surveyRequest = new SurveyFormCreateRequest( + survey.title(), + survey.description() + ); + SurveyFormResponse surveyResponse = surveyCommand.upsertSurvey(memberId, null, surveyRequest); + Long surveyId = surveyResponse.surveyId(); + + // 2. 섹션 생성 + if (survey.sections() != null && !survey.sections().isEmpty()) { + List sectionDtoList = survey.sections().stream() + .map(section -> new SectionDto( + null, + section.title(), + section.description(), + section.order(), + section.nextSectionOrder() != null ? section.nextSectionOrder() : 0 + )) + .toList(); + + questionCommand.upsertSections(surveyId, sectionDtoList); + } + + // 3. 문항 생성 + List questionUpsertInfoList = new ArrayList<>(); + AtomicInteger questionOrder = new AtomicInteger(0); + + if (survey.sections() != null) { + for (FormConversionResponse.Section section : survey.sections()) { + if (section.questions() != null) { + for (FormConversionResponse.Question question : section.questions()) { + QuestionType questionType = mapQuestionType(question.type()); + if (questionType == null) { + continue; + } + + QuestionUpsertDto.UpsertInfo.UpsertInfoBuilder builder = QuestionUpsertDto.UpsertInfo.builder() + .questionId(null) + .title(question.title()) + .description(question.description()) + .isRequired(question.required()) + .questionType(questionType) + .questionOrder(questionOrder.getAndIncrement()) + .imageUrl(question.imageUrl()) + .section(section.order()); + + // Choice 타입인 경우 옵션 설정 + if (questionType == QuestionType.CHOICE && question.options() != null) { + boolean hasOtherOption = question.options().stream() + .anyMatch(FormConversionResponse.Option::isOther); + boolean isSectionDecidable = question.options().stream() + .anyMatch(opt -> opt.goToSectionOrder() != null); + int maxChoice = switch (question.type().toUpperCase()) { + case "CHECKBOX" -> question.maxChoice() != null ? question.maxChoice() : question.options().size(); // 체크박스는 여러 개 선택 가능 + case "DROPDOWN", "MULTIPLE_CHOICE" -> 1; // 드롭다운, 객관식은 하나만 선택 가능 + default -> 1; // 기본적으로 하나만 선택 가능하도록 설정 + }; + + builder.maxChoice(maxChoice) + .hasNoneOption(false) + .hasCustomInput(hasOtherOption) + .isSectionDecidable(isSectionDecidable) + .options(question.options().stream() + .filter(opt -> !opt.isOther()) + .map(opt -> OptionDto.builder() + .content(opt.text()) + .nextSection(opt.goToSectionOrder()) + .imageUrl(opt.imageUrl()) + .build()) + .toList()); + } + + questionUpsertInfoList.add(builder.build()); + } + } + } + } + + if (!questionUpsertInfoList.isEmpty()) { + QuestionUpsertDto questionUpsertDto = QuestionUpsertDto.builder() + .surveyId(surveyId) + .upsertInfoList(questionUpsertInfoList) + .build(); + + QuestionUpsertDto savedQuestions = questionCommand.upsertQuestionList(questionUpsertDto); + + // 4. Choice 문항의 옵션 저장 + List optionUpsertDtoList = new ArrayList<>(); + List savedInfoList = savedQuestions.getUpsertInfoList(); + Map originalInfoMap = questionUpsertInfoList.stream() + .collect(java.util.stream.Collectors.toMap(QuestionUpsertDto.UpsertInfo::getQuestionOrder, Function.identity())); + + for (QuestionUpsertDto.UpsertInfo savedInfo : savedInfoList) { + QuestionUpsertDto.UpsertInfo originalInfo = originalInfoMap.get(savedInfo.getQuestionOrder()); + + if (savedInfo.getQuestionType() == QuestionType.CHOICE && originalInfo.getOptions() != null) { + List options = originalInfo.getOptions().stream() + .map(opt -> OptionDto.builder() + .questionId(savedInfo.getQuestionId()) + .content(opt.getContent()) + .nextSection(opt.getNextSection()) + .imageUrl(opt.getImageUrl()) + .build()) + .toList(); + + optionUpsertDtoList.add(OptionUpsertDto.builder() + .questionId(savedInfo.getQuestionId()) + .optionInfoList(options) + .build()); + } + } + + if (!optionUpsertDtoList.isEmpty()) { + questionCommand.upsertChoiceOptionList(optionUpsertDtoList); + } + } + + return surveyId; + } + + public FormValidationResponse toResponse(FormValidationPostResponse dto) { + if (dto == null || dto.results() == null) { + return null; + } + + List results = dto.results().stream() + .map(this::mapToResult) + .toList(); + + return new FormValidationResponse(results); + } + + private FormValidationResponse.Result mapToResult(FormValidationPostResponse.Result r) { + List inconvertibles = mapInconvertible(r.inconvertibleDetails()); + + if (r.isSuccess()) { + return FormValidationResponse.success( + r.url(), + r.counts().total(), + r.counts().convertible(), + r.counts().unconvertible(), + inconvertibles, + mapConvertible(r.convertibleDetails()) + ); + } + + // 실패하거나 convertibleDetails가 없는 경우 + return FormValidationResponse.fail( + r.url(), + r.message() + ); + } + + private List mapInconvertible( + List details + ) { + if (details == null || details.isEmpty()) return List.of(); + + return details.stream() + .map(u -> new FormValidationResponse.Inconvertible(u.title(), u.type(), u.reason())) + .toList(); + } + + private List mapConvertible( + FormValidationPostResponse.Convertible details + ) { + if (details == null || details.sections().isEmpty()) return List.of(); + + // 문항들을 섹션별로 그룹화 + Map> sectionOrderQuestionMap = details.questions().stream() + .collect(Collectors.groupingBy(DefaultQuestionDto::getSection, Collectors.toList())); + + // 섹션 순회하며 변환 + return details.sections().stream() + .map(c -> new FormValidationResponse.Convertible( + c.title(), c.description(), c.order(), c.nextSection(), + sectionOrderQuestionMap.getOrDefault(c.order(), List.of()) + )) + .toList(); + } + + private QuestionType mapQuestionType(String googleFormType) { + if (googleFormType == null) { + return null; + } + + return switch (googleFormType.toUpperCase()) { + case "MULTIPLE_CHOICE", "CHECKBOX" -> QuestionType.CHOICE; + case "SHORT_ANSWER", "SHORT" -> QuestionType.SHORT; + case "PARAGRAPH", "LONG" -> QuestionType.LONG; + case "LINEAR_SCALE", "SCALE" -> QuestionType.RATING; + case "DATE" -> QuestionType.DATE; + case "NUMBER" -> QuestionType.NUMBER; + case "IMAGE" -> QuestionType.IMAGE; + case "TITLE_DESCRIPTION" -> QuestionType.TITLE; + default -> null; + }; + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormEventListener.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormEventListener.java index 0d594cd9..485e96b4 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormEventListener.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormEventListener.java @@ -42,6 +42,7 @@ public class FormEventListener { private final AlertNotifier alertNotifier; private final TransactionHandler transactionHandler; + private final FormConverter formConverter; private final FormRequestLambda formRequestLambda; private final SurveyCommand surveyCommand; private final QuestionCommand questionCommand; diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java index 0fb38137..eec25ccf 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormRequestLambda.java @@ -3,7 +3,7 @@ import OneQ.OnSurvey.domain.survey.SurveyErrorCode; import OneQ.OnSurvey.domain.survey.model.formRequest.FormConversionPayload; import OneQ.OnSurvey.domain.survey.model.formRequest.FormConversionResponse; -import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationAndStashResponse; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationPostResponse; import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationPayload; import OneQ.OnSurvey.global.common.exception.CustomException; import OneQ.OnSurvey.global.infra.discord.notifier.AlertNotifier; @@ -56,14 +56,14 @@ public FormConversionResponse convertGoogleFormIntoSurvey(FormConversionPayload return response; } - public FormValidationAndStashResponse validateAndStashFormRequest(FormValidationPayload payload) { + public FormValidationPostResponse validateAndStashFormRequest(FormValidationPayload payload) { - FormValidationAndStashResponse response = webClient.post() + FormValidationPostResponse response = webClient.post() .uri(validationUrl) .contentType(MediaType.APPLICATION_JSON) .bodyValue(payload) .retrieve() - .bodyToMono(FormValidationAndStashResponse.class) + .bodyToMono(FormValidationPostResponse.class) .timeout(Duration.ofSeconds(FORM_CONVERSION_REQUEST_TIMEOUT)) .retryWhen(Retry.backoff(2, Duration.ofSeconds(3))) .onErrorMap(e -> { From 8d7f3c6c80d38f3cfdd3e0824cbce5757e96d00c Mon Sep 17 00:00:00 2001 From: jaekwan Date: Thu, 26 Mar 2026 23:12:30 +0900 Subject: [PATCH 24/50] =?UTF-8?q?feat:=20=EC=84=A4=EB=AC=B8=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20=EC=8B=A4=ED=8C=A8=20=ED=9B=84,=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=ED=8C=80=EC=97=90=20=EB=8F=84=EC=9B=80=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EC=95=8C=EB=A6=BC=20=EC=A0=84=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SurveyHelpRequestController.java | 48 +++++++++++++++++++ .../discord/DiscordAlarmAsyncFacade.java | 6 +++ .../infra/discord/DiscordAlarmService.java | 28 +++++++++++ .../infra/discord/notifier/AlertNotifier.java | 2 + .../notifier/DiscordAlertNotifier.java | 6 +++ .../discord/notifier/NoOpAlertNotifier.java | 4 ++ .../notifier/dto/SurveyHelpRequestAlert.java | 10 ++++ 7 files changed, 104 insertions(+) create mode 100644 src/main/java/OneQ/OnSurvey/domain/survey/controller/SurveyHelpRequestController.java create mode 100644 src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/dto/SurveyHelpRequestAlert.java diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/controller/SurveyHelpRequestController.java b/src/main/java/OneQ/OnSurvey/domain/survey/controller/SurveyHelpRequestController.java new file mode 100644 index 00000000..5bc8bf0a --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/survey/controller/SurveyHelpRequestController.java @@ -0,0 +1,48 @@ +package OneQ.OnSurvey.domain.survey.controller; + +import OneQ.OnSurvey.global.auth.custom.CustomUserDetails; +import OneQ.OnSurvey.global.common.response.SuccessResponse; +import OneQ.OnSurvey.global.infra.discord.notifier.AlertNotifier; +import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveyHelpRequestAlert; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/v1/surveys/help-requests") +@RequiredArgsConstructor +public class SurveyHelpRequestController { + + private final AlertNotifier alertNotifier; + + @PostMapping + @Operation(summary = "설문 반려 도움 요청", description = "설문이 반려된 경우 운영팀에 도움을 요청합니다.") + public SuccessResponse requestHelp( + @AuthenticationPrincipal CustomUserDetails principal, + @RequestBody @Valid HelpRequest request + ) { + alertNotifier.sendSurveyHelpRequestAsync(new SurveyHelpRequestAlert( + request.email(), + request.name(), + request.rejectionReasons(), + request.content() + )); + return SuccessResponse.ok(null); + } + + public record HelpRequest( + @NotBlank String email, + @NotBlank String name, + @NotEmpty List rejectionReasons, + @NotBlank String content + ) {} +} diff --git a/src/main/java/OneQ/OnSurvey/global/infra/discord/DiscordAlarmAsyncFacade.java b/src/main/java/OneQ/OnSurvey/global/infra/discord/DiscordAlarmAsyncFacade.java index 3d483f29..1bb99995 100644 --- a/src/main/java/OneQ/OnSurvey/global/infra/discord/DiscordAlarmAsyncFacade.java +++ b/src/main/java/OneQ/OnSurvey/global/infra/discord/DiscordAlarmAsyncFacade.java @@ -2,6 +2,7 @@ import OneQ.OnSurvey.global.infra.discord.notifier.dto.PaymentCompletedAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveyConversionAlert; +import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveyHelpRequestAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveySubmittedAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.TossAccessTokenAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.PushAlimAlert; @@ -43,4 +44,9 @@ public void sendSurveyConversionAsync(SurveyConversionAlert alert) { public void sendPushAlimAsync(PushAlimAlert alert) { service.sendPushAlimAsync(alert); } + + @Async("discordAlarmExecutor") + public void sendSurveyHelpRequestAsync(SurveyHelpRequestAlert alert) { + service.sendSurveyHelpRequestAlert(alert); + } } diff --git a/src/main/java/OneQ/OnSurvey/global/infra/discord/DiscordAlarmService.java b/src/main/java/OneQ/OnSurvey/global/infra/discord/DiscordAlarmService.java index 5fbe1efe..01d7bbc7 100644 --- a/src/main/java/OneQ/OnSurvey/global/infra/discord/DiscordAlarmService.java +++ b/src/main/java/OneQ/OnSurvey/global/infra/discord/DiscordAlarmService.java @@ -4,6 +4,7 @@ import OneQ.OnSurvey.global.infra.discord.client.DiscordWebhookClient; import OneQ.OnSurvey.global.infra.discord.notifier.dto.PaymentCompletedAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveyConversionAlert; +import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveyHelpRequestAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveySubmittedAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.TossAccessTokenAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.PushAlimAlert; @@ -48,6 +49,9 @@ public class DiscordAlarmService { @Value("${discord.push-alim-alert-url:}") private String pushAlimWebhookUrl; + @Value("${discord.survey-help-request-url:}") + private String surveyHelpRequestWebhookUrl; + public void sendErrorAlert(Throwable e, String method, String path, String query) { if (!enabled || errorWebhookUrl == null || errorWebhookUrl.isBlank()) return; @@ -194,6 +198,30 @@ public void sendPushAlimAsync(PushAlimAlert alert) { post(url, title, description); } + public void sendSurveyHelpRequestAlert(SurveyHelpRequestAlert alert) { + if (!enabled) return; + + String url = (surveyHelpRequestWebhookUrl != null && !surveyHelpRequestWebhookUrl.isBlank()) + ? surveyHelpRequestWebhookUrl + : errorWebhookUrl; + if (url == null || url.isBlank()) return; + + StringBuilder desc = new StringBuilder(); + desc.append("• 이름: `").append(safe(alert.name())).append("`\n") + .append("• 이메일: `").append(safe(alert.email())).append("`\n") + .append("• 반려 사유:\n"); + for (String reason : alert.rejectionReasons()) { + desc.append("- ").append(safe(reason)).append("\n"); + } + desc.append("• 문의 내용:\n```\n").append(safe(alert.content())).append("\n```"); + + String descStr = desc.toString(); + if (descStr.length() > MAX_EMBED_DESC) { + descStr = descStr.substring(0, MAX_EMBED_DESC - TRUNC_SUFFIX.length()) + TRUNC_SUFFIX; + } + post(url, "🆘 설문 변환 실패. 도움 요청", descStr); + } + private String maskKey(String key) { return JwtDecodeUtils.maskToken(key); } diff --git a/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/AlertNotifier.java b/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/AlertNotifier.java index b99466fa..a1d7b6df 100644 --- a/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/AlertNotifier.java +++ b/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/AlertNotifier.java @@ -2,6 +2,7 @@ import OneQ.OnSurvey.global.infra.discord.notifier.dto.PaymentCompletedAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveyConversionAlert; +import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveyHelpRequestAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveySubmittedAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.TossAccessTokenAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.PushAlimAlert; @@ -13,4 +14,5 @@ public interface AlertNotifier { void sendTossAccessTokenAsync(TossAccessTokenAlert alert); void sendSurveyConversionAsync(SurveyConversionAlert alert); void sendPushAlimAsync(PushAlimAlert alert); + void sendSurveyHelpRequestAsync(SurveyHelpRequestAlert alert); } diff --git a/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/DiscordAlertNotifier.java b/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/DiscordAlertNotifier.java index d9899537..809bc45a 100644 --- a/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/DiscordAlertNotifier.java +++ b/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/DiscordAlertNotifier.java @@ -3,6 +3,7 @@ import OneQ.OnSurvey.global.infra.discord.DiscordAlarmAsyncFacade; import OneQ.OnSurvey.global.infra.discord.notifier.dto.PaymentCompletedAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveyConversionAlert; +import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveyHelpRequestAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveySubmittedAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.TossAccessTokenAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.PushAlimAlert; @@ -46,4 +47,9 @@ public void sendSurveyConversionAsync(SurveyConversionAlert alert) { public void sendPushAlimAsync(PushAlimAlert alert) { discord.sendPushAlimAsync(alert); } + + @Override + public void sendSurveyHelpRequestAsync(SurveyHelpRequestAlert alert) { + discord.sendSurveyHelpRequestAsync(alert); + } } diff --git a/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/NoOpAlertNotifier.java b/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/NoOpAlertNotifier.java index df709bd2..ebdd1892 100644 --- a/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/NoOpAlertNotifier.java +++ b/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/NoOpAlertNotifier.java @@ -3,6 +3,7 @@ import OneQ.OnSurvey.global.infra.discord.notifier.dto.PaymentCompletedAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.PushAlimAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveyConversionAlert; +import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveyHelpRequestAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveySubmittedAlert; import OneQ.OnSurvey.global.infra.discord.notifier.dto.TossAccessTokenAlert; import org.springframework.context.annotation.Profile; @@ -29,4 +30,7 @@ public void sendSurveyConversionAsync(SurveyConversionAlert alert) {} @Override public void sendPushAlimAsync(PushAlimAlert alert) {} + + @Override + public void sendSurveyHelpRequestAsync(SurveyHelpRequestAlert alert) {} } diff --git a/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/dto/SurveyHelpRequestAlert.java b/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/dto/SurveyHelpRequestAlert.java new file mode 100644 index 00000000..923ce269 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/infra/discord/notifier/dto/SurveyHelpRequestAlert.java @@ -0,0 +1,10 @@ +package OneQ.OnSurvey.global.infra.discord.notifier.dto; + +import java.util.List; + +public record SurveyHelpRequestAlert( + String email, + String name, + List rejectionReasons, + String content +) {} From 1c50a89e31ee8683b89ed66f8b4b2fdffe3e63e9 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Thu, 26 Mar 2026 23:28:03 +0900 Subject: [PATCH 25/50] =?UTF-8?q?mod:=20=EA=B2=B0=EC=A0=9C=20=ED=9B=84=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20=EC=84=A4=EB=AC=B8=ED=8F=BC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=A1=9C=EC=A7=81=20=ED=94=8C=EB=A1=9C=EC=9A=B0?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=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 --- .../controller/FormRequestController.java | 2 +- ...rsionResultDto.java => ConversionDto.java} | 2 +- .../formRequest/FormConversionPayload.java | 9 - .../formRequest/FormConversionResponse.java | 76 ----- .../service/formRequest/FormConverter.java | 174 ++++------- .../formRequest/FormEventListener.java | 291 ++++-------------- .../formRequest/FormRequestLambda.java | 26 -- 7 files changed, 127 insertions(+), 453 deletions(-) rename src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/{ConversionResultDto.java => ConversionDto.java} (90%) delete mode 100644 src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormConversionPayload.java delete mode 100644 src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormConversionResponse.java diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java b/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java index e5e97c1b..6db755b3 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java @@ -79,7 +79,7 @@ public SuccessResponse markAsRegistered( } @PostMapping("/validation") - @Operation(summary = "폼 링크 유효성 검사", description = "구글 폼 편집 URL로부터 전체 문항 수 중 변환 가능한 문항 수를 리턴합니다. 변환 불가능한 문항 존재 시 관련 정보를 추가로 반환합니다.") + @Operation(summary = "폼 링크 유효성 검사 및 미리보기 반환", description = "구글 폼 편집 URL 유효성 검사를 진행하여 변환 가능한 문항 수, 변환 불가능 사유, 미리보기 데이터 등을 반환합니다.") public SuccessResponse getConvertableCounts( @RequestBody @Valid FormValidationRequestDto request ) { diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/ConversionResultDto.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/ConversionDto.java similarity index 90% rename from src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/ConversionResultDto.java rename to src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/ConversionDto.java index 118482c4..d2631dae 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/ConversionResultDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/ConversionDto.java @@ -5,7 +5,7 @@ import java.util.List; -public record ConversionResultDto ( +public record ConversionDto( String title, String description, List sections, diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormConversionPayload.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormConversionPayload.java deleted file mode 100644 index 4fb347b4..00000000 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormConversionPayload.java +++ /dev/null @@ -1,9 +0,0 @@ -package OneQ.OnSurvey.domain.survey.model.formRequest; - -import java.util.List; - -public record FormConversionPayload ( - Long requestId, - List urls -) { -} diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormConversionResponse.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormConversionResponse.java deleted file mode 100644 index b1012944..00000000 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormConversionResponse.java +++ /dev/null @@ -1,76 +0,0 @@ -package OneQ.OnSurvey.domain.survey.model.formRequest; - -import com.fasterxml.jackson.databind.PropertyNamingStrategies; -import com.fasterxml.jackson.databind.annotation.JsonNaming; -import java.util.List; - -@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) -public record FormConversionResponse( - int totalCount, - int successCount, - List results, - String error -) { - - @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) - public record Result( - String url, - String status, // "SUCCESS", "FAIL" - - // SUCCESS - Survey survey, - List unsupportedQuestions, - - // FAIL - String message - - ) { - public boolean isSuccess() { - return "SUCCESS".equals(status); - } - } - - @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) - public record Survey( - String title, - String description, - List
sections - ) { } - - @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) - public record Section( - String id, - int order, - String title, - String description, - Integer nextSectionOrder, - List questions - ) { } - - @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) - public record Question( - String id, - String title, - String description, - String type, - boolean required, - List