diff --git a/.github/workflows/deploy-to-dev-ec2.yml b/.github/workflows/deploy-to-dev-ec2.yml index 8d7af749..c91c5710 100644 --- a/.github/workflows/deploy-to-dev-ec2.yml +++ b/.github/workflows/deploy-to-dev-ec2.yml @@ -79,9 +79,13 @@ jobs: WEEKLY_PROMPT="$(printf '%s' "$WEEKLY_PROMPT_B64" | base64 -d | tr -d '\r')" export WEEKLY_PROMPT - MONTHLY_PROMPT_B64="${{ secrets.MONTHLY_PROMPT_B64 }}" - MONTHLY_PROMPT="$(printf '%s' "$MONTHLY_PROMPT_B64" | base64 -d | tr -d '\r')" - export MONTHLY_PROMPT + MONTHLY_V1_PROMPT_B64="${{ secrets.MONTHLY_V1_PROMPT_B64 }}" + MONTHLY_V1_PROMPT="$(printf '%s' "$MONTHLY_V1_PROMPT_B64" | base64 -d | tr -d '\r')" + export MONTHLY_V1_PROMPT + + MONTHLY_V2_BASELINE_PROMPT_B64="${{ secrets.MONTHLY_V2_BASELINE_PROMPT_B64 }}" + MONTHLY_V2_BASELINE_PROMPT="$(printf '%s' "$MONTHLY_V2_BASELINE_PROMPT_B64" | base64 -d | tr -d '\r')" + export MONTHLY_V2_BASELINE_PROMPT EVIDENCE_CARD_PROMPT_B64="${{ secrets.EVIDENCE_CARD_PROMPT_B64 }}" EVIDENCE_CARD_PROMPT="$(printf '%s' "$EVIDENCE_CARD_PROMPT_B64" | base64 -d | tr -d '\r')" @@ -143,7 +147,9 @@ jobs: ANTHROPIC_API_KEY="${{ secrets.ANTHROPIC_API_KEY }}" \ GOOGLE_GENAI_API_KEY="${{ secrets.GOOGLE_GENAI_API_KEY }}" \ FIREBASE_ADMIN_KEY="${{ secrets.FIREBASE_ADMIN_KEY }}" \ + ADMIN_PAGE_PASSWORD="${{ secrets.ADMIN_PAGE_PASSWORD }}" \ + DEV_TEST_ACCOUNT_PASSWORD="${{ secrets.DEV_TEST_ACCOUNT_PASSWORD }}" \ nohup java -jar "$JAR_PATH" \ --spring.profiles.active=dev > app.log 2>&1 & - echo "✅ 배포 완료!" \ No newline at end of file + echo "✅ 배포 완료!" diff --git a/.github/workflows/deploy-to-prod-ec2.yml b/.github/workflows/deploy-to-prod-ec2.yml index cafd8c57..bf2f3125 100644 --- a/.github/workflows/deploy-to-prod-ec2.yml +++ b/.github/workflows/deploy-to-prod-ec2.yml @@ -79,9 +79,13 @@ jobs: WEEKLY_PROMPT="$(printf '%s' "$WEEKLY_PROMPT_B64" | base64 -d | tr -d '\r')" export WEEKLY_PROMPT - MONTHLY_PROMPT_B64="${{ secrets.MONTHLY_PROMPT_B64 }}" - MONTHLY_PROMPT="$(printf '%s' "$MONTHLY_PROMPT_B64" | base64 -d | tr -d '\r')" - export MONTHLY_PROMPT + MONTHLY_V1_PROMPT_B64="${{ secrets.MONTHLY_V1_PROMPT_B64 }}" + MONTHLY_V1_PROMPT="$(printf '%s' "$MONTHLY_V1_PROMPT_B64" | base64 -d | tr -d '\r')" + export MONTHLY_V1_PROMPT + + MONTHLY_V2_BASELINE_PROMPT_B64="${{ secrets.MONTHLY_V2_BASELINE_PROMPT_B64 }}" + MONTHLY_V2_BASELINE_PROMPT="$(printf '%s' "$MONTHLY_V2_BASELINE_PROMPT_B64" | base64 -d | tr -d '\r')" + export MONTHLY_V2_BASELINE_PROMPT EVIDENCE_CARD_PROMPT_B64="${{ secrets.EVIDENCE_CARD_PROMPT_B64 }}" EVIDENCE_CARD_PROMPT="$(printf '%s' "$EVIDENCE_CARD_PROMPT_B64" | base64 -d | tr -d '\r')" @@ -140,6 +144,7 @@ jobs: ANTHROPIC_API_KEY="${{ secrets.ANTHROPIC_API_KEY }}" \ GOOGLE_GENAI_API_KEY="${{ secrets.GOOGLE_GENAI_API_KEY }}" \ FIREBASE_ADMIN_KEY="${{ secrets.FIREBASE_ADMIN_KEY }}" \ + ADMIN_PAGE_PASSWORD="${{ secrets.ADMIN_PAGE_PASSWORD }}" \ nohup java -jar "$JAR_PATH" \ --spring.profiles.active=prod > app-prod.log 2>&1 & diff --git a/.gitignore b/.gitignore index 6fa19711..66d84356 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,6 @@ out/ node_modules # 오늘의 리포트 프롬프트 -/src/main/resources/secret/ \ No newline at end of file +/src/main/resources/secret/ + +AGENTS.md \ No newline at end of file diff --git a/commitlint.config.js b/commitlint.config.js index a664aefd..47cc82aa 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -14,6 +14,7 @@ module.exports = { "revert" ]], "scope-enum": [2, "always", [ + "admin", "auth", "user", "ai", @@ -22,6 +23,7 @@ module.exports = { "search", "stats", "friend", + "social", "notify", "infra", "db", @@ -99,6 +101,9 @@ module.exports = { scope: { description: '[Scope] 이번 변경이 적용된 범위를 선택해주세요 (범위 생략하려면 empty 선택)', enum: { + admin: { + description: '🧑‍💻 어드민 도메인 (예: 어드민 페이지)' + }, auth: { description: '🔐 인증/인가 도메인 (예: OAuth2, JWT, 세션)' }, @@ -123,6 +128,9 @@ module.exports = { friend: { description: '👥 친구 도메인 (예: 친구 신청, 수락, 목록 관리)' }, + social: { + description: '💬 소셜 도메인 (예: 댓글, 좋아요, 피드 공유)' + }, notify: { description: '📧 알림/이메일 전송 (예: 질문 알림, 인증 이메일 전송)' }, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminAuthController.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminAuthController.java new file mode 100644 index 00000000..2787dd98 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminAuthController.java @@ -0,0 +1,54 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminLoginRequest; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminAuthStatusResponse; +import com.devkor.ifive.nadab.domain.admin.application.AdminPageAuthCommandService; +import com.devkor.ifive.nadab.domain.admin.infra.security.AdminPageAuthCookieService; +import com.devkor.ifive.nadab.domain.admin.infra.security.AdminPageAuthTokenService; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.annotation.security.PermitAll; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +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; + +@Hidden +@RestController +@RequestMapping("/admin/api") +@RequiredArgsConstructor +public class AdminAuthController { + + private final AdminPageAuthCommandService adminPageAuthCommandService; + private final AdminPageAuthTokenService adminPageAuthTokenService; + private final AdminPageAuthCookieService adminPageAuthCookieService; + + @PostMapping("/login") + @PermitAll + public ResponseEntity> login( + @RequestBody @Valid AdminLoginRequest request, + HttpServletResponse response + ) { + adminPageAuthCommandService.validatePassword(request.password()); + String token = adminPageAuthTokenService.issueToken(); + adminPageAuthCookieService.addCookie(response, token); + return ApiResponseEntity.noContent(); + } + + @PostMapping("/logout") + public ResponseEntity> logout(HttpServletResponse response) { + adminPageAuthCookieService.expireCookie(response); + return ApiResponseEntity.noContent(); + } + + @GetMapping("/auth-status") + public ResponseEntity> authStatus() { + return ApiResponseEntity.ok(new AdminAuthStatusResponse(true)); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageController.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageController.java new file mode 100644 index 00000000..54b8ad5b --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageController.java @@ -0,0 +1,23 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class AdminPageController { + + @GetMapping("/admin/login") + public String loginPage() { + return "admin/login"; + } + + @GetMapping("/admin") + public String adminRoot() { + return "redirect:/admin/tabs/app-versions"; + } + + @GetMapping("/admin/tabs/app-versions") + public String adminVersionPage() { + return "admin/version"; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminVersionController.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminVersionController.java new file mode 100644 index 00000000..7859ce12 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminVersionController.java @@ -0,0 +1,84 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminVersionCreateRequest; +import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminVersionItemUpsertRequest; +import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminVersionSummaryUpdateRequest; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLatestVersionsResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionCreateResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionItemCreateResponse; +import com.devkor.ifive.nadab.domain.admin.application.AdminVersionCommandService; +import com.devkor.ifive.nadab.domain.admin.application.AdminVersionItemCommandService; +import com.devkor.ifive.nadab.domain.admin.application.AdminVersionQueryService; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Hidden +@RestController +@RequestMapping("/admin/api/versions") +@RequiredArgsConstructor +public class AdminVersionController { + + private final AdminVersionQueryService adminVersionQueryService; + private final AdminVersionCommandService adminVersionCommandService; + private final AdminVersionItemCommandService adminVersionItemCommandService; + + @GetMapping("/latest") + public ResponseEntity> getLatestVersions() { + return ApiResponseEntity.ok(adminVersionQueryService.getLatestVersions()); + } + + @PostMapping + public ResponseEntity> createVersion( + @RequestBody @Valid AdminVersionCreateRequest request + ) { + Long appVersionId = adminVersionCommandService.createVersion(request); + return ApiResponseEntity.created(new AdminVersionCreateResponse(appVersionId)); + } + + @PutMapping("/{appVersionId}/summary") + public ResponseEntity> updateVersionSummary( + @PathVariable Long appVersionId, + @RequestBody @Valid AdminVersionSummaryUpdateRequest request + ) { + adminVersionCommandService.updateSummary(appVersionId, request.summary()); + return ApiResponseEntity.noContent(); + } + + @PostMapping("/{appVersionId}/items") + public ResponseEntity> createVersionItem( + @PathVariable Long appVersionId, + @RequestBody @Valid AdminVersionItemUpsertRequest request + ) { + Long appVersionItemId = adminVersionItemCommandService.createItem(appVersionId, request); + return ApiResponseEntity.created(new AdminVersionItemCreateResponse(appVersionItemId)); + } + + @PutMapping("/items/{appVersionItemId}") + public ResponseEntity> updateVersionItem( + @PathVariable Long appVersionItemId, + @RequestBody @Valid AdminVersionItemUpsertRequest request + ) { + adminVersionItemCommandService.updateItem(appVersionItemId, request); + return ApiResponseEntity.noContent(); + } + + @DeleteMapping("/items/{appVersionItemId}") + public ResponseEntity> deleteVersionItem( + @PathVariable Long appVersionItemId + ) { + adminVersionItemCommandService.deleteItem(appVersionItemId); + return ApiResponseEntity.noContent(); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminLoginRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminLoginRequest.java new file mode 100644 index 00000000..1fd2b85b --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminLoginRequest.java @@ -0,0 +1,9 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record AdminLoginRequest( + @NotBlank(message = "비밀번호는 필수입니다.") + String password +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionCreateRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionCreateRequest.java new file mode 100644 index 00000000..dad51c94 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionCreateRequest.java @@ -0,0 +1,20 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.request; + +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppPlatform; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record AdminVersionCreateRequest( + @NotNull(message = "플랫폼은 필수입니다.") + AppPlatform platform, + + @NotBlank(message = "버전은 필수입니다.") + @Size(max = 30, message = "버전은 30자 이하여야 합니다.") + String version, + + @NotNull(message = "요약은 null일 수 없습니다.") + @Size(max = 120, message = "요약은 120자 이하여야 합니다.") + String summary +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionItemUpsertRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionItemUpsertRequest.java new file mode 100644 index 00000000..6297f4d7 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionItemUpsertRequest.java @@ -0,0 +1,23 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record AdminVersionItemUpsertRequest( + @NotBlank(message = "업데이트명은 필수입니다.") + @Size(max = 100, message = "업데이트명은 100자 이하여야 합니다.") + String title, + + @NotBlank(message = "상세 내용은 필수입니다.") + @Size(max = 500, message = "상세 내용은 500자 이하여야 합니다.") + String description, + + @NotNull(message = "displayOrder는 필수입니다.") + @Min(value = 1, message = "displayOrder는 1 이상이어야 합니다.") + @Max(value = 9999, message = "displayOrder는 9999 이하여야 합니다.") + Integer displayOrder +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionSummaryUpdateRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionSummaryUpdateRequest.java new file mode 100644 index 00000000..bad6e1c2 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionSummaryUpdateRequest.java @@ -0,0 +1,11 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record AdminVersionSummaryUpdateRequest( + @NotNull(message = "요약은 null일 수 없습니다.") + @Size(max = 120, message = "요약은 120자 이하여야 합니다.") + String summary +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminAuthStatusResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminAuthStatusResponse.java new file mode 100644 index 00000000..ab53f152 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminAuthStatusResponse.java @@ -0,0 +1,6 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +public record AdminAuthStatusResponse( + boolean authenticated +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLatestVersionsResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLatestVersionsResponse.java new file mode 100644 index 00000000..cca38a31 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLatestVersionsResponse.java @@ -0,0 +1,8 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +import java.util.List; + +public record AdminLatestVersionsResponse( + List versions +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionCreateResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionCreateResponse.java new file mode 100644 index 00000000..aed9aa4a --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionCreateResponse.java @@ -0,0 +1,6 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +public record AdminVersionCreateResponse( + Long appVersionId +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionItemCreateResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionItemCreateResponse.java new file mode 100644 index 00000000..394facd5 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionItemCreateResponse.java @@ -0,0 +1,6 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +public record AdminVersionItemCreateResponse( + Long appVersionItemId +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionItemResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionItemResponse.java new file mode 100644 index 00000000..b90e938f --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionItemResponse.java @@ -0,0 +1,9 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +public record AdminVersionItemResponse( + Long id, + String title, + String description, + Integer displayOrder +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionResponse.java new file mode 100644 index 00000000..01bc69c9 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionResponse.java @@ -0,0 +1,14 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppPlatform; + +import java.util.List; + +public record AdminVersionResponse( + Long id, + AppPlatform platform, + String version, + String summary, + List items +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminPageAuthCommandService.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminPageAuthCommandService.java new file mode 100644 index 00000000..4e236b3e --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminPageAuthCommandService.java @@ -0,0 +1,26 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.core.properties.AdminPageProperties; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.UnauthorizedException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@Service +@RequiredArgsConstructor +public class AdminPageAuthCommandService { + + private final AdminPageProperties adminPageProperties; + + public void validatePassword(String rawPassword) { + byte[] input = rawPassword.getBytes(StandardCharsets.UTF_8); + byte[] expected = adminPageProperties.getPassword().getBytes(StandardCharsets.UTF_8); + + if (!MessageDigest.isEqual(input, expected)) { + throw new UnauthorizedException(ErrorCode.ADMIN_PAGE_INVALID_PASSWORD); + } + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionCommandService.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionCommandService.java new file mode 100644 index 00000000..6f760a7e --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionCommandService.java @@ -0,0 +1,48 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminVersionCreateRequest; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersion; +import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ConflictException; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class AdminVersionCommandService { + + private final AppVersionRepository appVersionRepository; + + public Long createVersion(AdminVersionCreateRequest request) { + if (appVersionRepository.existsByPlatformAndVersion(request.platform(), request.version())) { + throw new ConflictException(ErrorCode.APP_VERSION_ALREADY_EXISTS); + } + + appVersionRepository.findByPlatformAndIsLatestTrue(request.platform()) + .ifPresent(AppVersion::markAsNotLatest); + appVersionRepository.flush(); + + AppVersion appVersion = AppVersion.create( + request.platform(), + request.version(), + request.summary() + ); + try { + appVersionRepository.saveAndFlush(appVersion); + return appVersion.getId(); + } catch (DataIntegrityViolationException e) { + throw new ConflictException(ErrorCode.APP_VERSION_ALREADY_EXISTS); + } + } + + public void updateSummary(Long appVersionId, String summary) { + AppVersion appVersion = appVersionRepository.findById(appVersionId) + .orElseThrow(() -> new NotFoundException(ErrorCode.APP_VERSION_NOT_FOUND)); + appVersion.updateSummary(summary); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionItemCommandService.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionItemCommandService.java new file mode 100644 index 00000000..e5a67005 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionItemCommandService.java @@ -0,0 +1,71 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminVersionItemUpsertRequest; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersion; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersionItem; +import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionItemRepository; +import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ConflictException; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class AdminVersionItemCommandService { + + private final AppVersionRepository appVersionRepository; + private final AppVersionItemRepository appVersionItemRepository; + + public Long createItem(Long appVersionId, AdminVersionItemUpsertRequest request) { + AppVersion appVersion = appVersionRepository.findById(appVersionId) + .orElseThrow(() -> new NotFoundException(ErrorCode.APP_VERSION_NOT_FOUND)); + + if (appVersionItemRepository.existsByAppVersionIdAndDisplayOrder(appVersionId, request.displayOrder())) { + throw new ConflictException(ErrorCode.APP_VERSION_ITEM_DISPLAY_ORDER_DUPLICATED); + } + + AppVersionItem item = AppVersionItem.create( + appVersion, + request.title(), + request.description(), + request.displayOrder() + ); + + try { + appVersionItemRepository.saveAndFlush(item); + return item.getId(); + } catch (DataIntegrityViolationException e) { + throw new ConflictException(ErrorCode.APP_VERSION_ITEM_DISPLAY_ORDER_DUPLICATED); + } + } + + public void updateItem(Long appVersionItemId, AdminVersionItemUpsertRequest request) { + AppVersionItem item = appVersionItemRepository.findById(appVersionItemId) + .orElseThrow(() -> new NotFoundException(ErrorCode.APP_VERSION_ITEM_NOT_FOUND)); + + Long appVersionId = item.getAppVersion().getId(); + if (appVersionItemRepository.existsByAppVersionIdAndDisplayOrderAndIdNot( + appVersionId, request.displayOrder(), appVersionItemId + )) { + throw new ConflictException(ErrorCode.APP_VERSION_ITEM_DISPLAY_ORDER_DUPLICATED); + } + + try { + item.update(request.title(), request.description(), request.displayOrder()); + appVersionItemRepository.flush(); + } catch (DataIntegrityViolationException e) { + throw new ConflictException(ErrorCode.APP_VERSION_ITEM_DISPLAY_ORDER_DUPLICATED); + } + } + + public void deleteItem(Long appVersionItemId) { + AppVersionItem item = appVersionItemRepository.findById(appVersionItemId) + .orElseThrow(() -> new NotFoundException(ErrorCode.APP_VERSION_ITEM_NOT_FOUND)); + appVersionItemRepository.delete(item); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionQueryService.java new file mode 100644 index 00000000..6b470c35 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionQueryService.java @@ -0,0 +1,64 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLatestVersionsResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionItemResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionResponse; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersion; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersionItem; +import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionItemRepository; +import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminVersionQueryService { + + private final AppVersionRepository appVersionRepository; + private final AppVersionItemRepository appVersionItemRepository; + + public AdminLatestVersionsResponse getLatestVersions() { + List latestVersions = appVersionRepository.findByIsLatestTrue(); + List appVersionIds = latestVersions.stream() + .map(AppVersion::getId) + .toList(); + + List items = appVersionIds.isEmpty() + ? List.of() + : appVersionItemRepository.findByAppVersionIdInOrderByDisplayOrderAsc(appVersionIds); + + Map> itemsByVersionId = items.stream() + .collect(Collectors.groupingBy( + item -> item.getAppVersion().getId(), + Collectors.mapping( + item -> new AdminVersionItemResponse( + item.getId(), + item.getTitle(), + item.getDescription(), + item.getDisplayOrder() + ), + Collectors.toList() + ) + )); + + List versions = latestVersions.stream() + .sorted(Comparator.comparing(version -> version.getPlatform().name())) + .map(version -> new AdminVersionResponse( + version.getId(), + version.getPlatform(), + version.getVersion(), + version.getSummary(), + itemsByVersionId.getOrDefault(version.getId(), List.of()) + )) + .toList(); + + return new AdminLatestVersionsResponse(versions); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/core/properties/AdminPageProperties.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/core/properties/AdminPageProperties.java new file mode 100644 index 00000000..4db600df --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/core/properties/AdminPageProperties.java @@ -0,0 +1,26 @@ +package com.devkor.ifive.nadab.domain.admin.core.properties; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +@Component +@Getter +@Setter +@Validated +@ConfigurationProperties(prefix = "admin.page") +public class AdminPageProperties { + + @NotBlank + private String password; + + @Min(60) + private long tokenExpirationSeconds = 43200; + + @NotBlank + private String cookieName = "admin_auth"; +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageAuthCookieService.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageAuthCookieService.java new file mode 100644 index 00000000..3e73840f --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageAuthCookieService.java @@ -0,0 +1,43 @@ +package com.devkor.ifive.nadab.domain.admin.infra.security; + +import com.devkor.ifive.nadab.domain.admin.core.properties.AdminPageProperties; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AdminPageAuthCookieService { + + @Value("${app.cookie.secure}") + private boolean cookieSecure; + + @Value("${app.cookie.same-site}") + private String cookieSameSite; + + private final AdminPageProperties adminPageProperties; + + public void addCookie(HttpServletResponse response, String token) { + ResponseCookie cookie = ResponseCookie.from(adminPageProperties.getCookieName(), token) + .httpOnly(true) + .secure(cookieSecure) + .sameSite(cookieSameSite) + .path("/admin") + .maxAge(adminPageProperties.getTokenExpirationSeconds()) + .build(); + response.addHeader("Set-Cookie", cookie.toString()); + } + + public void expireCookie(HttpServletResponse response) { + ResponseCookie cookie = ResponseCookie.from(adminPageProperties.getCookieName(), "") + .httpOnly(true) + .secure(cookieSecure) + .sameSite(cookieSameSite) + .path("/admin") + .maxAge(0) + .build(); + response.addHeader("Set-Cookie", cookie.toString()); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageAuthInterceptor.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageAuthInterceptor.java new file mode 100644 index 00000000..76578edd --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageAuthInterceptor.java @@ -0,0 +1,74 @@ +package com.devkor.ifive.nadab.domain.admin.infra.security; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +@RequiredArgsConstructor +public class AdminPageAuthInterceptor implements HandlerInterceptor { + + private final AdminPageAuthTokenService adminPageAuthTokenService; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + if (HttpMethod.OPTIONS.matches(request.getMethod())) { + return true; + } + + String requestUri = request.getRequestURI(); + String token = extractCookieValue(request, adminPageAuthTokenService.getCookieName()); + boolean isTokenValid = adminPageAuthTokenService.isValid(token); + + if (isLoginApi(requestUri)) { + return true; + } + + if (isLoginPage(requestUri)) { + if (isTokenValid) { + response.sendRedirect("/admin"); + return false; + } + return true; + } + + if (isTokenValid) { + return true; + } + + if (requestUri.startsWith("/admin/api/")) { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + return false; + } + + response.sendRedirect("/admin/login"); + return false; + } + + private boolean isLoginApi(String requestUri) { + return "/admin/api/login".equals(requestUri); + } + + private boolean isLoginPage(String requestUri) { + return "/admin/login".equals(requestUri) || requestUri.startsWith("/admin/login/"); + } + + private String extractCookieValue(HttpServletRequest request, String cookieName) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return null; + } + + for (Cookie cookie : cookies) { + if (cookieName.equals(cookie.getName())) { + return cookie.getValue(); + } + } + return null; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageAuthTokenService.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageAuthTokenService.java new file mode 100644 index 00000000..9a31db42 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageAuthTokenService.java @@ -0,0 +1,100 @@ +package com.devkor.ifive.nadab.domain.admin.infra.security; + +import com.devkor.ifive.nadab.domain.admin.core.properties.AdminPageProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.Base64; + +@Component +@RequiredArgsConstructor +public class AdminPageAuthTokenService { + + private static final String HMAC_ALGORITHM = "HmacSHA256"; + + private final AdminPageProperties adminPageProperties; + + public String issueToken() { + long issuedAt = Instant.now().getEpochSecond(); + long expiresAt = issuedAt + adminPageProperties.getTokenExpirationSeconds(); + + String payload = issuedAt + ":" + expiresAt; + String encodedPayload = encode(payload.getBytes(StandardCharsets.UTF_8)); + String signature = sign(encodedPayload); + + return encodedPayload + "." + signature; + } + + public boolean isValid(String token) { + if (token == null || token.isBlank()) { + return false; + } + + String[] parts = token.split("\\."); + if (parts.length != 2) { + return false; + } + + String encodedPayload = parts[0]; + String signature = parts[1]; + String expectedSignature = sign(encodedPayload); + + if (!MessageDigest.isEqual( + signature.getBytes(StandardCharsets.UTF_8), + expectedSignature.getBytes(StandardCharsets.UTF_8) + )) { + return false; + } + + String decodedPayload; + try { + decodedPayload = new String( + Base64.getUrlDecoder().decode(encodedPayload), + StandardCharsets.UTF_8 + ); + } catch (IllegalArgumentException e) { + return false; + } + + String[] payloadParts = decodedPayload.split(":"); + if (payloadParts.length != 2) { + return false; + } + + long expiresAt; + try { + expiresAt = Long.parseLong(payloadParts[1]); + } catch (NumberFormatException e) { + return false; + } + + return Instant.now().getEpochSecond() < expiresAt; + } + + public String getCookieName() { + return adminPageProperties.getCookieName(); + } + + private String sign(String encodedPayload) { + try { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(new SecretKeySpec( + adminPageProperties.getPassword().getBytes(StandardCharsets.UTF_8), + HMAC_ALGORITHM + )); + byte[] signature = mac.doFinal(encodedPayload.getBytes(StandardCharsets.UTF_8)); + return encode(signature); + } catch (Exception e) { + throw new IllegalStateException("Admin token signing failed", e); + } + } + + private String encode(byte[] bytes) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageWebMvcConfig.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageWebMvcConfig.java new file mode 100644 index 00000000..c10d71b2 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageWebMvcConfig.java @@ -0,0 +1,19 @@ +package com.devkor.ifive.nadab.domain.admin.infra.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class AdminPageWebMvcConfig implements WebMvcConfigurer { + + private final AdminPageAuthInterceptor adminPageAuthInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(adminPageAuthInterceptor) + .addPathPatterns("/admin/**"); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/appversion/application/AppVersionDismissalCommandService.java b/src/main/java/com/devkor/ifive/nadab/domain/appversion/application/AppVersionDismissalCommandService.java new file mode 100644 index 00000000..2a8588aa --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/appversion/application/AppVersionDismissalCommandService.java @@ -0,0 +1,42 @@ +package com.devkor.ifive.nadab.domain.appversion.application; + +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersion; +import com.devkor.ifive.nadab.domain.appversion.core.entity.UserAppVersionDismissal; +import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionRepository; +import com.devkor.ifive.nadab.domain.appversion.core.repository.UserAppVersionDismissalRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class AppVersionDismissalCommandService { + + private final UserRepository userRepository; + private final AppVersionRepository appVersionRepository; + private final UserAppVersionDismissalRepository userAppVersionDismissalRepository; + + public void dismiss(Long userId, Long appVersionId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); + + AppVersion appVersion = appVersionRepository.findById(appVersionId) + .orElseThrow(() -> new NotFoundException(ErrorCode.APP_VERSION_NOT_FOUND)); + + if (userAppVersionDismissalRepository.existsByUserIdAndAppVersionId(userId, appVersionId)) { + return; + } + + try { + userAppVersionDismissalRepository.save(UserAppVersionDismissal.create(user, appVersion)); + } catch (DataIntegrityViolationException ignored) { + // Ignore duplicate insert race condition. + } + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/appversion/application/AppVersionQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/appversion/application/AppVersionQueryService.java new file mode 100644 index 00000000..d9e9272e --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/appversion/application/AppVersionQueryService.java @@ -0,0 +1,85 @@ +package com.devkor.ifive.nadab.domain.appversion.application; + +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppPlatform; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersion; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersionItem; +import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionItemRepository; +import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionRepository; +import com.devkor.ifive.nadab.domain.appversion.core.repository.UserAppVersionDismissalRepository; +import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.HomeLatestVersionResponse; +import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.HomePlatformVersionResponse; +import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.HomeVersionItemResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AppVersionQueryService { + + private final AppVersionRepository appVersionRepository; + private final AppVersionItemRepository appVersionItemRepository; + private final UserAppVersionDismissalRepository userAppVersionDismissalRepository; + + public HomeLatestVersionResponse getHomeLatestVersion(Long userId) { + List latestVersions = appVersionRepository.findByIsLatestTrue(); + + List appVersionIds = latestVersions.stream() + .map(AppVersion::getId) + .toList(); + + Set dismissedAppVersionIds = appVersionIds.isEmpty() + ? Set.of() + : userAppVersionDismissalRepository.findDismissedAppVersionIds(userId, appVersionIds).stream() + .collect(Collectors.toSet()); + + List versionItems = appVersionIds.isEmpty() + ? List.of() + : appVersionItemRepository.findByAppVersionIdInOrderByDisplayOrderAsc(appVersionIds); + + Map> itemResponsesByVersionId = versionItems.stream() + .collect(Collectors.groupingBy( + item -> item.getAppVersion().getId(), + Collectors.mapping( + item -> new HomeVersionItemResponse(item.getTitle(), item.getDescription()), + Collectors.toList() + ) + )); + + Map latestVersionByPlatform = latestVersions.stream() + .collect(Collectors.toMap( + AppVersion::getPlatform, + appVersion -> toPlatformResponse( + appVersion, + itemResponsesByVersionId.getOrDefault(appVersion.getId(), List.of()), + dismissedAppVersionIds.contains(appVersion.getId()) + ), + (left, right) -> right + )); + + return new HomeLatestVersionResponse( + latestVersionByPlatform.get(AppPlatform.IOS), + latestVersionByPlatform.get(AppPlatform.ANDROID) + ); + } + + private HomePlatformVersionResponse toPlatformResponse( + AppVersion appVersion, + List items, + boolean dismissed + ) { + return new HomePlatformVersionResponse( + appVersion.getId(), + appVersion.getVersion(), + appVersion.getSummary(), + items, + dismissed + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppPlatform.java b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppPlatform.java new file mode 100644 index 00000000..a7198067 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppPlatform.java @@ -0,0 +1,6 @@ +package com.devkor.ifive.nadab.domain.appversion.core.entity; + +public enum AppPlatform { + IOS, + ANDROID +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppVersion.java b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppVersion.java new file mode 100644 index 00000000..49a3d47b --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppVersion.java @@ -0,0 +1,59 @@ +package com.devkor.ifive.nadab.domain.appversion.core.entity; + +import com.devkor.ifive.nadab.global.shared.entity.AuditableEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "app_versions", + uniqueConstraints = { + @UniqueConstraint(name = "uk_app_versions_platform_version", columnNames = {"platform", "version"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AppVersion extends AuditableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "platform", nullable = false, length = 20) + private AppPlatform platform; + + @Column(name = "version", nullable = false, length = 30) + private String version; + + @Column(name = "is_latest", nullable = false) + private Boolean isLatest; + + @Column(name = "summary", nullable = false, length = 120) + private String summary; + + private AppVersion(AppPlatform platform, String version, boolean isLatest, String summary) { + this.platform = platform; + this.version = version; + this.isLatest = isLatest; + this.summary = summary; + } + + public static AppVersion create(AppPlatform platform, String version, String summary) { + return new AppVersion(platform, version, true, summary); + } + + public void markAsLatest() { + this.isLatest = true; + } + + public void markAsNotLatest() { + this.isLatest = false; + } + + public void updateSummary(String summary) { + this.summary = summary; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppVersionItem.java b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppVersionItem.java new file mode 100644 index 00000000..f63f6fe5 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppVersionItem.java @@ -0,0 +1,57 @@ +package com.devkor.ifive.nadab.domain.appversion.core.entity; + +import com.devkor.ifive.nadab.global.shared.entity.AuditableEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "app_version_items", + uniqueConstraints = { + @UniqueConstraint(name = "uk_app_version_items_order", columnNames = {"app_version_id", "display_order"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AppVersionItem extends AuditableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn( + name = "app_version_id", + nullable = false, + foreignKey = @ForeignKey(name = "fk_app_version_items_app_version") + ) + private AppVersion appVersion; + + @Column(name = "title", nullable = false, length = 100) + private String title; + + @Column(name = "description", nullable = false, length = 500) + private String description; + + @Column(name = "display_order", nullable = false) + private Integer displayOrder; + + private AppVersionItem(AppVersion appVersion, String title, String description, Integer displayOrder) { + this.appVersion = appVersion; + this.title = title; + this.description = description; + this.displayOrder = displayOrder; + } + + public static AppVersionItem create(AppVersion appVersion, String title, String description, Integer displayOrder) { + return new AppVersionItem(appVersion, title, description, displayOrder); + } + + public void update(String title, String description, Integer displayOrder) { + this.title = title; + this.description = description; + this.displayOrder = displayOrder; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/UserAppVersionDismissal.java b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/UserAppVersionDismissal.java new file mode 100644 index 00000000..277e3d43 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/UserAppVersionDismissal.java @@ -0,0 +1,47 @@ +package com.devkor.ifive.nadab.domain.appversion.core.entity; + +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.shared.entity.CreatableEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "user_app_version_dismissals", + uniqueConstraints = { + @UniqueConstraint(name = "uk_uavd_user_app_version", columnNames = {"user_id", "app_version_id"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserAppVersionDismissal extends CreatableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn( + name = "user_id", + nullable = false, + foreignKey = @ForeignKey(name = "fk_uavd_user") + ) + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn( + name = "app_version_id", + nullable = false, + foreignKey = @ForeignKey(name = "fk_uavd_app_version") + ) + private AppVersion appVersion; + + public static UserAppVersionDismissal create(User user, AppVersion appVersion) { + UserAppVersionDismissal dismissal = new UserAppVersionDismissal(); + dismissal.user = user; + dismissal.appVersion = appVersion; + return dismissal; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/AppVersionItemRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/AppVersionItemRepository.java new file mode 100644 index 00000000..664fc317 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/AppVersionItemRepository.java @@ -0,0 +1,14 @@ +package com.devkor.ifive.nadab.domain.appversion.core.repository; + +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersionItem; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface AppVersionItemRepository extends JpaRepository { + List findByAppVersionIdInOrderByDisplayOrderAsc(List appVersionIds); + + boolean existsByAppVersionIdAndDisplayOrder(Long appVersionId, Integer displayOrder); + + boolean existsByAppVersionIdAndDisplayOrderAndIdNot(Long appVersionId, Integer displayOrder, Long id); +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/AppVersionRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/AppVersionRepository.java new file mode 100644 index 00000000..dd437ffd --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/AppVersionRepository.java @@ -0,0 +1,16 @@ +package com.devkor.ifive.nadab.domain.appversion.core.repository; + +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersion; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppPlatform; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AppVersionRepository extends JpaRepository { + List findByIsLatestTrue(); + + Optional findByPlatformAndIsLatestTrue(AppPlatform platform); + + boolean existsByPlatformAndVersion(AppPlatform platform, String version); +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/UserAppVersionDismissalRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/UserAppVersionDismissalRepository.java new file mode 100644 index 00000000..2a506975 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/UserAppVersionDismissalRepository.java @@ -0,0 +1,24 @@ +package com.devkor.ifive.nadab.domain.appversion.core.repository; + +import com.devkor.ifive.nadab.domain.appversion.core.entity.UserAppVersionDismissal; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface UserAppVersionDismissalRepository extends JpaRepository { + + boolean existsByUserIdAndAppVersionId(Long userId, Long appVersionId); + + @Query(""" + select d.appVersion.id + from UserAppVersionDismissal d + where d.user.id = :userId + and d.appVersion.id in :appVersionIds + """) + List findDismissedAppVersionIds( + @Param("userId") Long userId, + @Param("appVersionIds") List appVersionIds + ); +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/api/AuthController.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/AuthController.java index 82a2197a..0f9f8063 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/auth/api/AuthController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/AuthController.java @@ -713,9 +713,11 @@ public ResponseEntity> changePassword( ); } + @Deprecated @PostMapping("/withdrawal") @PreAuthorize("isAuthenticated()") @Operation( + deprecated = true, summary = "회원 탈퇴", description = """ 회원 탈퇴를 진행합니다.
diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/api/AuthControllerV2.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/AuthControllerV2.java new file mode 100644 index 00000000..22143fb8 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/AuthControllerV2.java @@ -0,0 +1,94 @@ +package com.devkor.ifive.nadab.domain.auth.api; + +import com.devkor.ifive.nadab.domain.auth.api.dto.request.WithdrawalRequestV2; +import com.devkor.ifive.nadab.domain.auth.application.AuthServiceV2; +import com.devkor.ifive.nadab.domain.auth.infra.cookie.CookieManager; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; +import com.devkor.ifive.nadab.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +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 jakarta.servlet.http.HttpServletResponse; + +@Tag(name = "인증 API V2", description = "인증 관련 API V2") +@RestController +@RequestMapping("/api/v2/auth") +@RequiredArgsConstructor +public class AuthControllerV2 { + + private final AuthServiceV2 authServiceV2; + private final CookieManager cookieManager; + + @PostMapping("/withdrawal") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "회원 탈퇴 V2", + description = """ + 회원 탈퇴를 진행합니다. + - 탈퇴 후 14일 동안 복구 가능합니다.
+ - 모든 기기에서 자동 로그아웃됩니다.
+ - 14일 후 자동으로 완전 삭제됩니다.
+ - 탈퇴 사유를 함께 저장합니다.
+ 이때, reasons 필드에 OTHER가 포함된 경우 customReason 필드는 필수입니다.
+ + ****
+ DAILY_LOGGING_BURDEN, // 매일 기록이 부담
+ INSUFFICIENT_QUESTION_ANALYSIS, // 질문·분석 부족
+ LOSS_OF_INTEREST_IN_WRITING, // 글쓰기 흥미 상실
+ PRIVACY_RECORD_CONCERN, // 감정·기록 보안 우려
+ APP_ERROR_OR_SLOWNESS, // 오류·속도 문제
+ OTHER // 기타(직접 입력)
+ """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "탈퇴 성공"), + @ApiResponse( + responseCode = "400", + description = """ + - ErrorCode: AUTH_WITHDRAWAL_REASON_REQUIRED - 사유 미선택 + - ErrorCode: AUTH_WITHDRAWAL_REASON_DUPLICATED - 사유 중복 선택 + - ErrorCode: AUTH_WITHDRAWAL_OTHER_REASON_REQUIRED - OTHER 선택 후 기타 사유 미입력 + - ErrorCode: AUTH_WITHDRAWAL_OTHER_REASON_TOO_LONG - 기타 사유 200자 초과 + - ErrorCode: AUTH_WITHDRAWAL_OTHER_REASON_NOT_ALLOWED - OTHER 미선택인데 기타 사유 입력 + - ErrorCode: AUTH_ALREADY_WITHDRAWN - 이미 탈퇴된 계정 + """, + content = @Content + ), + @ApiResponse( + responseCode = "401", + description = """ + 인증 실패 (JWT 토큰 관련) + - ErrorCode: AUTH_TOKEN_EXPIRED - JWT Access Token 만료 + - ErrorCode: AUTH_TOKEN_SIGNATURE_INVALID - 토큰 서명 검증 실패 + - ErrorCode: AUTH_TOKEN_MALFORMED - 토큰 형식 오류 + - ErrorCode: AUTH_TOKEN_VERIFICATION_FAILED - 토큰 검증 실패 + - ErrorCode: AUTH_TOKEN_USERID_INVALID - 토큰의 유저 ID 형식 오류 + - ErrorCode: AUTH_TOKEN_ROLES_MISSING - 토큰에 권한 정보 없음 + """, + content = @Content + ) + } + ) + public ResponseEntity> withdrawUser( + @AuthenticationPrincipal UserPrincipal principal, + @RequestBody @Valid WithdrawalRequestV2 request, + HttpServletResponse response + ) { + authServiceV2.withdrawUser(principal.getId(), request.reasons(), request.customReason()); + cookieManager.removeRefreshTokenCookie(response); + return ApiResponseEntity.noContent(); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/api/dto/request/WithdrawalRequestV2.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/dto/request/WithdrawalRequestV2.java new file mode 100644 index 00000000..ce820b95 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/dto/request/WithdrawalRequestV2.java @@ -0,0 +1,25 @@ +package com.devkor.ifive.nadab.domain.auth.api.dto.request; + +import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalReasonType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record WithdrawalRequestV2( + @Schema( + description = "탈퇴 사유 목록 (다중 선택 가능)", + example = "[\"DAILY_LOGGING_BURDEN\", \"OTHER\"]" + ) + @NotEmpty(message = "탈퇴 사유는 최소 1개 이상 선택해야 합니다.") + List reasons, + + @Schema( + description = "기타 사유 직접 입력 (reasons에 OTHER가 포함된 경우 필수)", + example = "앱이 저에게 맞지 않았어요." + ) + @Size(max = 200, message = "기타 사유는 최대 200자까지 입력할 수 있습니다.") + String customReason +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java new file mode 100644 index 00000000..be4ba74d --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java @@ -0,0 +1,93 @@ +package com.devkor.ifive.nadab.domain.auth.application; + +import com.devkor.ifive.nadab.domain.auth.core.entity.UserWithdrawalReason; +import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalReasonType; +import com.devkor.ifive.nadab.domain.auth.core.repository.UserWithdrawalReasonRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Transactional +public class AuthServiceV2 { + + private static final int MAX_CUSTOM_REASON_LENGTH = 200; + + private final WithdrawalService withdrawalService; + private final UserRepository userRepository; + private final UserWithdrawalReasonRepository userWithdrawalReasonRepository; + + public void withdrawUser(Long userId, List reasons, String customReason) { + List validatedReasons = validateReasons(reasons); + String normalizedCustomReason = normalizeCustomReason(customReason); + validateCustomReason(validatedReasons, normalizedCustomReason); + + // 기존 탈퇴 처리(소프트 삭제/토큰 revoke/Apple revoke) + withdrawalService.withdrawUser(userId); + + // 탈퇴 사유 저장(집계용) + User user = userRepository.getReferenceById(userId); + OffsetDateTime effectiveWithdrawnAt = user.getDeletedAt() != null + ? user.getDeletedAt() + : OffsetDateTime.now(); + List entities = new ArrayList<>(validatedReasons.size()); + for (WithdrawalReasonType reason : validatedReasons) { + String detail = reason == WithdrawalReasonType.OTHER ? normalizedCustomReason : null; + entities.add(UserWithdrawalReason.create( + user, + reason, + detail, + effectiveWithdrawnAt + )); + } + userWithdrawalReasonRepository.saveAll(entities); + } + + private List validateReasons(List reasons) { + if (reasons == null || reasons.isEmpty()) { + throw new BadRequestException(ErrorCode.AUTH_WITHDRAWAL_REASON_REQUIRED); + } + + Set uniqueReasons = EnumSet.copyOf(reasons); + if (uniqueReasons.size() != reasons.size()) { + throw new BadRequestException(ErrorCode.AUTH_WITHDRAWAL_REASON_DUPLICATED); + } + return reasons; + } + + private void validateCustomReason(List reasons, String customReason) { + boolean hasOther = reasons.contains(WithdrawalReasonType.OTHER); + + if (hasOther) { + if (customReason == null || customReason.isEmpty()) { + throw new BadRequestException(ErrorCode.AUTH_WITHDRAWAL_OTHER_REASON_REQUIRED); + } + if (customReason.length() > MAX_CUSTOM_REASON_LENGTH) { + throw new BadRequestException(ErrorCode.AUTH_WITHDRAWAL_OTHER_REASON_TOO_LONG); + } + return; + } + + if (customReason != null && !customReason.isEmpty()) { + throw new BadRequestException(ErrorCode.AUTH_WITHDRAWAL_OTHER_REASON_NOT_ALLOWED); + } + } + + private String normalizeCustomReason(String customReason) { + if (customReason == null) { + return null; + } + return customReason.trim(); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/UserWithdrawalReason.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/UserWithdrawalReason.java new file mode 100644 index 00000000..72de20b9 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/UserWithdrawalReason.java @@ -0,0 +1,49 @@ +package com.devkor.ifive.nadab.domain.auth.core.entity; + +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.shared.entity.CreatableEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.OffsetDateTime; + +@Entity +@Table(name = "user_withdrawal_reasons") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserWithdrawalReason extends CreatableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "reason", nullable = false, length = 50) + private WithdrawalReasonType reason; + + @Column(name = "custom_reason", length = 200) + private String customReason; + + @Column(name = "withdrawn_at", nullable = false) + private OffsetDateTime withdrawnAt; + + public static UserWithdrawalReason create( + User user, + WithdrawalReasonType reason, + String customReason, + OffsetDateTime withdrawnAt + ) { + UserWithdrawalReason userWithdrawalReason = new UserWithdrawalReason(); + userWithdrawalReason.user = user; + userWithdrawalReason.reason = reason; + userWithdrawalReason.customReason = customReason; + userWithdrawalReason.withdrawnAt = withdrawnAt; + return userWithdrawalReason; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/WithdrawalReasonType.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/WithdrawalReasonType.java new file mode 100644 index 00000000..eabfff7e --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/WithdrawalReasonType.java @@ -0,0 +1,10 @@ +package com.devkor.ifive.nadab.domain.auth.core.entity; + +public enum WithdrawalReasonType { + DAILY_LOGGING_BURDEN, // 매일 기록이 부담 + INSUFFICIENT_QUESTION_ANALYSIS, // 질문·분석 부족 + LOSS_OF_INTEREST_IN_WRITING, // 글쓰기 흥미 상실 + PRIVACY_RECORD_CONCERN, // 감정·기록 보안 우려 + APP_ERROR_OR_SLOWNESS, // 오류·속도 문제 + OTHER // 기타(직접 입력) +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/UserWithdrawalReasonRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/UserWithdrawalReasonRepository.java new file mode 100644 index 00000000..745450ea --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/UserWithdrawalReasonRepository.java @@ -0,0 +1,7 @@ +package com.devkor.ifive.nadab.domain.auth.core.repository; + +import com.devkor.ifive.nadab.domain.auth.core.entity.UserWithdrawalReason; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserWithdrawalReasonRepository extends JpaRepository { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/api/CommentController.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/CommentController.java new file mode 100644 index 00000000..f61e2008 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/CommentController.java @@ -0,0 +1,294 @@ +package com.devkor.ifive.nadab.domain.comment.api; + +import com.devkor.ifive.nadab.domain.comment.api.dto.request.CreateCommentRequest; +import com.devkor.ifive.nadab.domain.comment.api.dto.request.CreateSubCommentRequest; +import com.devkor.ifive.nadab.domain.comment.api.dto.request.UpdateCommentRequest; +import com.devkor.ifive.nadab.domain.comment.api.dto.response.CommentListResponse; +import com.devkor.ifive.nadab.domain.comment.application.CommentCommandService; +import com.devkor.ifive.nadab.domain.comment.application.CommentQueryService; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; +import com.devkor.ifive.nadab.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "댓글 API", description = "댓글 및 대댓글 관련 API") +@RestController +@RequestMapping("${api_prefix}") +@RequiredArgsConstructor +public class CommentController { + + private final CommentCommandService commentCommandService; + private final CommentQueryService commentQueryService; + + @GetMapping("/comments") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "댓글 목록 조회", + description = """ + 피드 게시글의 댓글 목록을 커서 기반으로 최신순 조회합니다. + + 요청 파라미터: + - dailyReportId (필수): 댓글을 조회할 피드 게시글 ID + - cursor (선택): 이전 응답의 nextCursor, 첫 요청 시 생략 + + 커서 페이지네이션 (페이지당 10개): + - 첫 요청: cursor 없이 호출 → GET /api/v1/comments?dailyReportId=1 + - 다음 페이지: 응답의 nextCursor를 cursor로 전달 → GET /api/v1/comments?dailyReportId=1&cursor=42 + - hasNext=false이면 마지막 페이지입니다. + + 비밀 댓글 응답: + - 권한 있음 (작성자 본인, 게시글 작성자): canViewContent=true, 모든 필드 정상 반환 + - 권한 없음: canViewContent=false, authorProfileImageUrl·authorNickname·content·visibleSubCommentCount는 null 반환 + + 응답 필드 용도: + - commentId: 수정(PATCH)·삭제(DELETE)·대댓글 조회(GET)·대댓글 작성(POST) API 호출 시 path variable로 사용 + - authorProfileImageUrl·authorNickname: 댓글 작성자 프로필 표시 (canViewContent=false이면 null → 비공개 처리) + - content: 댓글 본문 (canViewContent=false이면 null → "비밀 댓글이에요." 등으로 대체 표시) + - createdAt: 작성 시각 ISO 8601 timestamp (예: 2026-05-11T10:30:00+09:00) — 프론트에서 현재 시각 기준으로 아래 규칙에 따라 변환하여 표시, 타이머로 실시간 갱신 가능 + · 60초 미만 → N초 전 + · 60분 미만 → N분 전 + · 24시간 미만 → N시간 전 + · 30일 미만 → N일 전 + · 30일 이상 → 오래 전 + - isLiked·hasLikes: 좋아요 아이콘 상태 제어 + · isMine=true & hasLikes=false → 아이콘 미표시 + · isMine=true & hasLikes=true → 채워진 아이콘 (단순 클릭 무반응, 길게 누르면 좋아요 리스트) + · isMine=false & isLiked=false → 빈 아이콘 + · isMine=false & isLiked=true → 채워진 아이콘 + · canViewContent=false (비밀 댓글 열람 권한 없음) → 아이콘 미표시 + - visibleSubCommentCount: "답글 N개 더보기" 버튼 표시용, null 또는 0이면 미표시 + - isSecret: 비밀 댓글 여부 (자물쇠 아이콘 표시, canViewContent=false이면 답글 버튼 미제공) + - canViewContent: false이면 authorProfileImageUrl·authorNickname·content 마스킹 처리 + - isMine: 수정 버튼 표시 여부, 좋아요 아이콘 동작 제어 (본인 댓글은 좋아요 불가) + - canDelete: 삭제 버튼 표시 여부 (본인 댓글 또는 내 피드 게시글에 달린 타인 댓글) + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = CommentListResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "ErrorCode: AUTH_ACCESS_DENIED - 본인 피드 게시글이 아니거나 친구의 공유 게시글이 아닌 경우", content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: DAILY_REPORT_NOT_FOUND", content = @Content) + } + ) + public ResponseEntity> getComments( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam Long dailyReportId, + @RequestParam(required = false) Long cursor + ) { + CommentListResponse response = commentQueryService.getComments(dailyReportId, principal.getId(), cursor); + return ApiResponseEntity.ok(response); + } + + @PostMapping("/comments") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "댓글 작성", + description = """ + 피드 게시글에 댓글을 작성합니다. + + - isSecret=true로 작성 시 비밀 댓글로 설정되며, 작성자 본인과 게시글 작성자만 내용을 확인할 수 있습니다. + - isSecret은 작성 후 변경할 수 없습니다. (수정 API에서 내용만 변경 가능) + - 댓글 작성 시 게시글 작성자에게 FCM 푸시 알림이 전송됩니다. (본인 피드 게시글에 작성 시 알림 미전송) + - 작성 성공 후 GET /api/v1/comments?dailyReportId={id}로 목록을 재조회해야 합니다. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "작성 성공", content = @Content), + @ApiResponse(responseCode = "400", description = """ + - ErrorCode: VALIDATION_FAILED - 내용 누락 또는 500자 초과 + - ErrorCode: SOCIAL_SUSPENDED - 소셜 정지 중 + """, content = @Content), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "ErrorCode: AUTH_ACCESS_DENIED - 본인 피드 게시글이 아니거나 친구의 공유 게시글이 아닌 경우", content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: DAILY_REPORT_NOT_FOUND", content = @Content) + } + ) + public ResponseEntity> createComment( + @AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody CreateCommentRequest request + ) { + commentCommandService.createComment( + request.dailyReportId(), principal.getId(), request.content(), request.isSecret()); + return ApiResponseEntity.noContent(); + } + + @GetMapping("/comments/{commentId}/sub-comments") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "대댓글 목록 조회", + description = """ + 특정 댓글의 대댓글 목록을 커서 기반으로 최신순 조회합니다. + + 요청 파라미터: + - commentId (path, 필수): 대댓글을 조회할 부모 댓글 ID + - cursor (선택): 이전 응답의 nextCursor, 첫 요청 시 생략 (페이지당 10개) + + 커서 페이지네이션: + - 첫 요청: cursor 없이 호출 → GET /api/v1/comments/42/sub-comments + - 다음 페이지: 응답의 nextCursor를 cursor로 전달 → GET /api/v1/comments/42/sub-comments?cursor=99 + - hasNext=false이면 마지막 페이지입니다. + + 비밀 대댓글 응답: + - 권한 있음 (작성자 본인, 게시글 작성자, 부모 댓글 작성자): canViewContent=true, 모든 필드 정상 반환 + - 권한 없음: canViewContent=false, authorProfileImageUrl·authorNickname·content는 null 반환 + + "N개 더보기" 버튼 카운트 계산: + - 첫 진입 시: 댓글 목록 조회(GET /comments) 응답의 visibleSubCommentCount 값을 사용 + - 대댓글 로드 후 hasNext=true이면: visibleSubCommentCount - 현재까지 로드한 대댓글 수 = 남은 개수로 갱신하여 표시 (프론트에서 직접 계산 및 갱신) + - 예) visibleSubCommentCount=12 → 10개 로드 후 "2개 더보기" 표시 + + 응답 필드 용도: + - commentId: 수정(PATCH)·삭제(DELETE) API 호출 시 path variable로 사용 + - authorProfileImageUrl·authorNickname: 대댓글 작성자 프로필 표시 (canViewContent=false이면 null → 비공개 처리) + - content: 대댓글 본문 (canViewContent=false이면 null → "비밀 댓글이에요." 등으로 대체 표시) + - createdAt: 작성 시각 ISO 8601 timestamp — 댓글 목록 조회와 동일한 변환 규칙 적용 + - isLiked·hasLikes: 좋아요 아이콘 상태 제어 + · isMine=true & hasLikes=false → 아이콘 미표시 + · isMine=true & hasLikes=true → 채워진 아이콘 (단순 클릭 무반응, 길게 누르면 좋아요 리스트) + · isMine=false & isLiked=false → 빈 아이콘 + · isMine=false & isLiked=true → 채워진 아이콘 + · canViewContent=false (비밀 댓글 열람 권한 없음) → 아이콘 미표시 + - visibleSubCommentCount: 대댓글에서는 항상 null + - isSecret: 비밀 댓글 여부 (자물쇠 아이콘 표시) + - canViewContent: false이면 authorProfileImageUrl·authorNickname·content 마스킹 처리 + · 대댓글 열람 권한: 작성자 본인, 게시글 작성자, 부모 댓글 작성자 + - isMine: 수정 버튼 표시 여부, 좋아요 아이콘 동작 제어 (본인 댓글은 좋아요 불가) + - canDelete: 삭제 버튼 표시 여부 (본인 대댓글 또는 내 피드 게시글에 달린 타인 대댓글) + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = CommentListResponse.class))), + @ApiResponse(responseCode = "400", description = "ErrorCode: COMMENT_NOT_TOP_LEVEL - commentId가 대댓글 ID인 경우 (대댓글의 대댓글 불가)", content = @Content), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = """ + - ErrorCode: AUTH_ACCESS_DENIED - 본인 피드 게시글이 아니거나 친구의 공유 게시글이 아닌 경우 + - ErrorCode: AUTH_ACCESS_DENIED - 비밀 댓글에 대한 열람 권한 없음 + """, content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: COMMENT_NOT_FOUND", content = @Content) + } + ) + public ResponseEntity> getSubComments( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long commentId, + @RequestParam(required = false) Long cursor + ) { + CommentListResponse response = commentQueryService.getSubComments(commentId, principal.getId(), cursor); + return ApiResponseEntity.ok(response); + } + + @PostMapping("/comments/{commentId}/sub-comments") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "대댓글 작성", + description = """ + 특정 댓글에 대댓글을 작성합니다. + + - 부모 댓글이 비밀 댓글인 경우, isSecret 값과 무관하게 대댓글도 강제로 비밀 처리됩니다. 이 경우 isSecret 토글을 사용하지 못하게 하고 isSecret=true로 고정 전송을 권장합니다. + - isSecret은 작성 후 변경할 수 없습니다. (수정 API에서 내용만 변경 가능) + - 대댓글에 대한 대댓글은 불가합니다. + - 대댓글 작성 시 부모 댓글 작성자에게 FCM 푸시 알림이 전송됩니다. + - 해당 댓글에 이미 대댓글을 단 다른 참여자들에게도 알림이 전송됩니다. + - 작성 성공 후 GET /api/v1/comments/{commentId}/sub-comments로 목록을 재조회해야 합니다. + - 대댓글 작성 성공 시 댓글 목록의 visibleSubCommentCount가 갱신되지 않습니다. "답글 N개 더보기" 카운트를 최신화하려면 성공 시 로컬에서 +1 업데이트하거나 GET /api/v1/comments?dailyReportId={id}로 댓글 목록도 재조회해야 합니다. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "작성 성공", content = @Content), + @ApiResponse(responseCode = "400", description = """ + - ErrorCode: VALIDATION_FAILED - 내용 누락 또는 500자 초과 + - ErrorCode: COMMENT_NOT_TOP_LEVEL - 대댓글에 대댓글 시도 + - ErrorCode: SOCIAL_SUSPENDED - 소셜 정지 중 + """, content = @Content), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "ErrorCode: AUTH_ACCESS_DENIED - 본인 피드 게시글이 아니거나 친구의 공유 게시글이 아닌 경우", content = @Content), + @ApiResponse(responseCode = "409", description = "ErrorCode: COMMENT_DELETED - 이미 삭제된 댓글에 대댓글 시도", content = @Content) + } + ) + public ResponseEntity> createSubComment( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long commentId, + @Valid @RequestBody CreateSubCommentRequest request + ) { + commentCommandService.createSubComment( + commentId, principal.getId(), request.content(), request.isSecret()); + return ApiResponseEntity.noContent(); + } + + @PatchMapping("/comments/{commentId}") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "댓글 수정", + description = """ + 본인이 작성한 댓글/대댓글의 내용을 수정합니다. + + - 작성자 본인만 수정 가능합니다. (게시글 작성자도 타인 댓글 수정 불가) + - 내용(content)만 변경 가능하며, 비밀 여부(isSecret)는 변경할 수 없습니다. + - 1자 이상 500자 이하로 입력해야 합니다. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "수정 성공", content = @Content), + @ApiResponse(responseCode = "400", description = """ + - ErrorCode: VALIDATION_FAILED - 내용 누락 또는 500자 초과 + - ErrorCode: SOCIAL_SUSPENDED - 소셜 정지 중 + """, content = @Content), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "ErrorCode: AUTH_ACCESS_DENIED - 본인 댓글만 수정 가능", content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: COMMENT_NOT_FOUND", content = @Content) + } + ) + public ResponseEntity> updateComment( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long commentId, + @Valid @RequestBody UpdateCommentRequest request + ) { + commentCommandService.updateComment(commentId, principal.getId(), request.content()); + return ApiResponseEntity.noContent(); + } + + @DeleteMapping("/comments/{commentId}") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "댓글 삭제", + description = """ + 댓글/대댓글을 삭제합니다. + + 삭제 권한: + - 본인이 작성한 댓글/대댓글: 작성자 본인 + - 내 피드 게시글에 달린 타인의 댓글/대댓글: 게시글 작성자 + - 타인 피드 게시글의 타인 댓글: 삭제 불가 + + - 댓글 삭제 시 하위 대댓글도 함께 삭제됩니다. + - 신고 이력이 있는 댓글도 삭제 가능합니다. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "삭제 성공", content = @Content), + @ApiResponse(responseCode = "400", description = "ErrorCode: SOCIAL_SUSPENDED - 소셜 정지 중", content = @Content), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "ErrorCode: AUTH_ACCESS_DENIED - 삭제 권한 없음", content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: COMMENT_NOT_FOUND", content = @Content), + @ApiResponse(responseCode = "409", description = "ErrorCode: COMMENT_DELETED - 이미 삭제된 댓글", content = @Content) + } + ) + public ResponseEntity> deleteComment( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long commentId + ) { + commentCommandService.deleteComment(commentId, principal.getId()); + return ApiResponseEntity.noContent(); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/request/CreateCommentRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/request/CreateCommentRequest.java new file mode 100644 index 00000000..a577f5f7 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/request/CreateCommentRequest.java @@ -0,0 +1,23 @@ +package com.devkor.ifive.nadab.domain.comment.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@Schema(description = "댓글 작성 요청") +public record CreateCommentRequest( + + @Schema(description = "리포트 ID") + @NotNull(message = "리포트 ID를 입력해주세요") + Long dailyReportId, + + @Schema(description = "댓글 내용 (1~500자)", example = "공감해요!") + @NotBlank(message = "댓글 내용을 입력해주세요") + @Size(max = 500, message = "댓글은 500자 이하로 입력해주세요") + String content, + + @Schema(description = "비밀 댓글 여부", example = "false") + boolean isSecret +) { +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/request/CreateSubCommentRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/request/CreateSubCommentRequest.java new file mode 100644 index 00000000..7368153c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/request/CreateSubCommentRequest.java @@ -0,0 +1,18 @@ +package com.devkor.ifive.nadab.domain.comment.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@Schema(description = "대댓글 작성 요청") +public record CreateSubCommentRequest( + + @Schema(description = "댓글 내용 (1~500자)", example = "공감해요!") + @NotBlank(message = "댓글 내용을 입력해주세요") + @Size(max = 500, message = "댓글은 500자 이하로 입력해주세요") + String content, + + @Schema(description = "비밀 댓글 여부", example = "false") + boolean isSecret +) { +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/request/UpdateCommentRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/request/UpdateCommentRequest.java new file mode 100644 index 00000000..6ffc6cca --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/request/UpdateCommentRequest.java @@ -0,0 +1,15 @@ +package com.devkor.ifive.nadab.domain.comment.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@Schema(description = "댓글 수정 요청") +public record UpdateCommentRequest( + + @Schema(description = "수정할 댓글 내용 (1~500자)", example = "수정된 내용이에요") + @NotBlank(message = "댓글 내용을 입력해주세요") + @Size(max = 500, message = "댓글은 500자 이하로 입력해주세요") + String content +) { +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/response/CommentListResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/response/CommentListResponse.java new file mode 100644 index 00000000..a3ee4e0c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/response/CommentListResponse.java @@ -0,0 +1,19 @@ +package com.devkor.ifive.nadab.domain.comment.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "댓글/대댓글 목록 응답") +public record CommentListResponse( + + @Schema(description = "댓글 목록") + List comments, + + @Schema(description = "다음 페이지 커서 (없으면 null)") + Long nextCursor, + + @Schema(description = "다음 페이지 존재 여부") + boolean hasNext +) { +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/response/CommentResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/response/CommentResponse.java new file mode 100644 index 00000000..9caba13c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/response/CommentResponse.java @@ -0,0 +1,75 @@ +package com.devkor.ifive.nadab.domain.comment.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.OffsetDateTime; + +@Schema(description = "댓글/대댓글 응답") +public record CommentResponse( + + @Schema(description = "댓글 ID") + Long commentId, + + @Schema(description = "작성자 프로필 이미지 URL (canViewContent=false이면 null)") + String authorProfileImageUrl, + + @Schema(description = "작성자 닉네임 (canViewContent=false이면 null)") + String authorNickname, + + @Schema(description = "댓글 내용 (canViewContent=false이면 null)") + String content, + + @Schema(description = "작성 시각 (ISO 8601, 예: 2024-05-11T10:30:00+09:00) — 프론트에서 현재 시각 기준으로 '3분 전' 등으로 변환하여 표시") + OffsetDateTime createdAt, + + @Schema(description = "내가 좋아요 눌렀는지 여부") + boolean isLiked, + + @Schema(description = "좋아요가 1개 이상인지 여부") + boolean hasLikes, + + @Schema(description = "보이는 대댓글 수 (canViewContent=false이거나 대댓글에서는 null)") + Integer visibleSubCommentCount, + + @Schema(description = "비밀 댓글 여부") + boolean isSecret, + + @Schema(description = "비밀 댓글 열람 권한 여부 (false이면 authorProfileImageUrl·authorNickname·content가 null)") + boolean canViewContent, + + @Schema(description = "내 댓글 여부") + boolean isMine, + + @Schema(description = "삭제 가능 여부 (본인 또는 리포트 당사자)") + boolean canDelete +) { + public static CommentResponse from( + Long commentId, + String authorProfileImageUrl, + String authorNickname, + String content, + OffsetDateTime createdAt, + boolean isLiked, + boolean hasLikes, + Integer visibleSubCommentCount, + boolean isSecret, + boolean canViewContent, + boolean isMine, + boolean canDelete + ) { + return new CommentResponse( + commentId, + authorProfileImageUrl, + authorNickname, + content, + createdAt, + isLiked, + hasLikes, + visibleSubCommentCount, + isSecret, + canViewContent, + isMine, + canDelete + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/application/CommentCommandService.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/application/CommentCommandService.java new file mode 100644 index 00000000..1d1d7ff2 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/application/CommentCommandService.java @@ -0,0 +1,153 @@ +package com.devkor.ifive.nadab.domain.comment.application; + +import com.devkor.ifive.nadab.domain.comment.application.event.CommentCreatedEvent; +import com.devkor.ifive.nadab.domain.comment.application.event.SubCommentCreatedEvent; +import com.devkor.ifive.nadab.domain.comment.core.entity.Comment; +import com.devkor.ifive.nadab.domain.comment.core.repository.CommentRepository; +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReport; +import com.devkor.ifive.nadab.domain.dailyreport.core.repository.DailyReportRepository; +import com.devkor.ifive.nadab.domain.friend.core.repository.FriendshipRepository; +import com.devkor.ifive.nadab.domain.moderation.application.SharingSuspensionService; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import com.devkor.ifive.nadab.global.exception.ConflictException; +import com.devkor.ifive.nadab.global.exception.ForbiddenException; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; + +@Service +@RequiredArgsConstructor +@Transactional +public class CommentCommandService { + + private final CommentRepository commentRepository; + private final DailyReportRepository dailyReportRepository; + private final FriendshipRepository friendshipRepository; + private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; + private final SharingSuspensionService sharingSuspensionService; + + public Long createComment(Long dailyReportId, Long authorId, String content, boolean isSecret) { + checkNotSuspended(authorId); + Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(dailyReportId) + .orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND)); + checkCommentWriteAccess(dailyReportId, reportOwnerId, authorId); + + DailyReport dailyReport = dailyReportRepository.getReferenceById(dailyReportId); + User author = userRepository.getReferenceById(authorId); + + Comment comment = Comment.createTopLevel(dailyReport, author, content, isSecret); + commentRepository.save(comment); + + eventPublisher.publishEvent( + new CommentCreatedEvent(comment.getId(), dailyReportId, authorId, reportOwnerId, content)); + + return comment.getId(); + } + + public Long createSubComment(Long parentCommentId, Long authorId, String content, boolean isSecret) { + checkNotSuspended(authorId); + Comment parentComment = findActiveCommentOrThrow(parentCommentId); + + if (!parentComment.isTopLevel()) { + throw new BadRequestException(ErrorCode.COMMENT_NOT_TOP_LEVEL); + } + + // 비밀 댓글의 하위 대댓글은 강제 비밀 처리 + boolean finalIsSecret = parentComment.isSecret() || isSecret; + + Long dailyReportId = parentComment.getDailyReport().getId(); + Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(dailyReportId) + .orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND)); + checkCommentWriteAccess(dailyReportId, reportOwnerId, authorId); + + if (parentComment.isSecret()) { + boolean canViewParent = parentComment.getAuthor().getId().equals(authorId) + || reportOwnerId.equals(authorId); + if (!canViewParent) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); + } + } + + User author = userRepository.getReferenceById(authorId); + + Comment subComment = Comment.createSubComment(author, parentComment, content, finalIsSecret); + commentRepository.save(subComment); + eventPublisher.publishEvent(new SubCommentCreatedEvent( + subComment.getId(), + dailyReportId, + authorId, + parentCommentId, + parentComment.getAuthor().getId(), + reportOwnerId, + content, + finalIsSecret, + parentComment.isSecret() + )); + + return subComment.getId(); + } + + private void checkNotSuspended(Long userId) { + if (sharingSuspensionService.isSharingSuspended(userId)) { + throw new BadRequestException(ErrorCode.SOCIAL_SUSPENDED); + } + } + + private void checkCommentWriteAccess(Long dailyReportId, Long reportOwnerId, Long currentUserId) { + if (currentUserId.equals(reportOwnerId)) return; + if (!dailyReportRepository.existsByIdAndIsSharedTrueAndDate(dailyReportId, TodayDateTimeProvider.getTodayDate())) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); + } + long smallerId = Math.min(currentUserId, reportOwnerId); + long largerId = Math.max(currentUserId, reportOwnerId); + if (!friendshipRepository.existsAcceptedByUserIds(smallerId, largerId)) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); + } + } + + public void updateComment(Long commentId, Long userId, String content) { + checkNotSuspended(userId); + Comment comment = findActiveCommentOrThrow(commentId); + + if (!comment.getAuthor().getId().equals(userId)) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); + } + + comment.updateContent(content); + } + + public void deleteComment(Long commentId, Long userId) { + checkNotSuspended(userId); + Comment comment = findActiveCommentOrThrow(commentId); + + Long authorId = comment.getAuthor().getId(); + Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(comment.getDailyReport().getId()) + .orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND)); + + if (!userId.equals(authorId) && !userId.equals(reportOwnerId)) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); + } + + OffsetDateTime now = OffsetDateTime.now(); + if (comment.isTopLevel()) { + commentRepository.softDeleteSubCommentsByParentId(commentId, now); + } + comment.softDelete(); + } + + private Comment findActiveCommentOrThrow(Long commentId) { + return commentRepository.findByIdWithAuthorAndDailyReport(commentId) + .orElseThrow(() -> commentRepository.existsById(commentId) + ? new ConflictException(ErrorCode.COMMENT_DELETED) + : new NotFoundException(ErrorCode.COMMENT_NOT_FOUND)); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/application/CommentQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/application/CommentQueryService.java new file mode 100644 index 00000000..a5d7b328 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/application/CommentQueryService.java @@ -0,0 +1,195 @@ +package com.devkor.ifive.nadab.domain.comment.application; + +import com.devkor.ifive.nadab.domain.comment.api.dto.response.CommentListResponse; +import com.devkor.ifive.nadab.domain.comment.api.dto.response.CommentResponse; +import com.devkor.ifive.nadab.domain.comment.core.dto.SubCommentCountDto; +import com.devkor.ifive.nadab.domain.comment.core.entity.Comment; +import com.devkor.ifive.nadab.domain.comment.core.repository.CommentRepository; +import com.devkor.ifive.nadab.domain.dailyreport.core.repository.DailyReportRepository; +import com.devkor.ifive.nadab.domain.friend.core.repository.FriendshipRepository; +import com.devkor.ifive.nadab.domain.like.core.repository.CommentLikeRepository; +import com.devkor.ifive.nadab.domain.moderation.application.SharingSuspensionService; +import com.devkor.ifive.nadab.domain.moderation.core.repository.UserBlockRepository; +import com.devkor.ifive.nadab.domain.user.infra.ProfileImageUrlBuilder; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import com.devkor.ifive.nadab.global.exception.ConflictException; +import com.devkor.ifive.nadab.global.exception.ForbiddenException; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentQueryService { + + private static final int DEFAULT_PAGE_SIZE = 10; + + private final CommentRepository commentRepository; + private final DailyReportRepository dailyReportRepository; + private final FriendshipRepository friendshipRepository; + private final UserBlockRepository userBlockRepository; + private final CommentLikeRepository commentLikeRepository; + private final ProfileImageUrlBuilder profileImageUrlBuilder; + private final SharingSuspensionService sharingSuspensionService; + + public CommentListResponse getComments(Long dailyReportId, Long currentUserId, Long cursor) { + Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(dailyReportId) + .orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND)); + checkCommentViewAccess(dailyReportId, reportOwnerId, currentUserId); + List excludedUserIds = getExcludedUserIds(currentUserId); + + List comments = commentRepository.findTopLevelComments( + dailyReportId, cursor, excludedUserIds, currentUserId, PageRequest.of(0, DEFAULT_PAGE_SIZE + 1)); + + boolean hasNext = comments.size() > DEFAULT_PAGE_SIZE; + if (hasNext) { + comments = comments.subList(0, DEFAULT_PAGE_SIZE); + } + Long nextCursor = hasNext ? comments.get(comments.size() - 1).getId() : null; + + Map subCountMap = buildSubCountMap(comments, excludedUserIds, currentUserId); + + List commentIds = comments.stream().map(Comment::getId).toList(); + Set likedCommentIds = commentIds.isEmpty() ? Set.of() + : new HashSet<>(commentLikeRepository.findLikedCommentIds(commentIds, currentUserId)); + Set commentIdsWithLikes = commentIds.isEmpty() ? Set.of() + : new HashSet<>(commentLikeRepository.findCommentIdsWithLikes(commentIds, excludedUserIds)); + + List responses = comments.stream() + .map(c -> { + boolean isMine = c.getAuthor().getId().equals(currentUserId); + boolean canViewContent = !c.isSecret() || isMine || currentUserId.equals(reportOwnerId); + boolean canDelete = isMine || currentUserId.equals(reportOwnerId); + return CommentResponse.from( + c.getId(), + canViewContent ? profileImageUrlBuilder.buildUserProfileUrl(c.getAuthor()) : null, + canViewContent ? c.getAuthor().getNickname() : null, + canViewContent ? c.getContent() : null, + c.getCreatedAt(), + canViewContent && likedCommentIds.contains(c.getId()), + canViewContent && commentIdsWithLikes.contains(c.getId()), + canViewContent ? subCountMap.getOrDefault(c.getId(), 0L).intValue() : null, + c.isSecret(), + canViewContent, + isMine, + canDelete + ); + }) + .collect(Collectors.toList()); + + return new CommentListResponse(responses, nextCursor, hasNext); + } + + public CommentListResponse getSubComments(Long parentCommentId, Long currentUserId, Long cursor) { + Comment parentComment = commentRepository.findByIdWithAuthorAndDailyReport(parentCommentId) + .orElseThrow(() -> commentRepository.existsById(parentCommentId) + ? new ConflictException(ErrorCode.COMMENT_DELETED) + : new NotFoundException(ErrorCode.COMMENT_NOT_FOUND)); + + if (!parentComment.isTopLevel()) { + throw new BadRequestException(ErrorCode.COMMENT_NOT_TOP_LEVEL); + } + + Long dailyReportId = parentComment.getDailyReport().getId(); + Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(dailyReportId) + .orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND)); + checkCommentViewAccess(dailyReportId, reportOwnerId, currentUserId); + + if (parentComment.isSecret()) { + boolean canViewParent = parentComment.getAuthor().getId().equals(currentUserId) + || reportOwnerId.equals(currentUserId); + if (!canViewParent) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); + } + } + + Long parentCommentAuthorId = parentComment.getAuthor().getId(); + List excludedUserIds = getExcludedUserIds(currentUserId); + + List subComments = commentRepository.findSubComments( + parentCommentId, cursor, excludedUserIds, currentUserId, PageRequest.of(0, DEFAULT_PAGE_SIZE + 1)); + + boolean hasNext = subComments.size() > DEFAULT_PAGE_SIZE; + if (hasNext) { + subComments = subComments.subList(0, DEFAULT_PAGE_SIZE); + } + Long nextCursor = hasNext ? subComments.get(subComments.size() - 1).getId() : null; + + List subCommentIds = subComments.stream().map(Comment::getId).toList(); + Set likedSubCommentIds = subCommentIds.isEmpty() ? Set.of() + : new HashSet<>(commentLikeRepository.findLikedCommentIds(subCommentIds, currentUserId)); + Set subCommentIdsWithLikes = subCommentIds.isEmpty() ? Set.of() + : new HashSet<>(commentLikeRepository.findCommentIdsWithLikes(subCommentIds, excludedUserIds)); + + List responses = subComments.stream() + .map(c -> { + boolean isMine = c.getAuthor().getId().equals(currentUserId); + boolean canViewContent = !c.isSecret() + || isMine + || currentUserId.equals(reportOwnerId) + || currentUserId.equals(parentCommentAuthorId); + boolean canDelete = isMine || currentUserId.equals(reportOwnerId); + return CommentResponse.from( + c.getId(), + canViewContent ? profileImageUrlBuilder.buildUserProfileUrl(c.getAuthor()) : null, + canViewContent ? c.getAuthor().getNickname() : null, + canViewContent ? c.getContent() : null, + c.getCreatedAt(), + canViewContent && likedSubCommentIds.contains(c.getId()), + canViewContent && subCommentIdsWithLikes.contains(c.getId()), + null, + c.isSecret(), + canViewContent, + isMine, + canDelete + ); + }) + .collect(Collectors.toList()); + + return new CommentListResponse(responses, nextCursor, hasNext); + } + + private Map buildSubCountMap(List comments, List excludedUserIds, Long currentUserId) { + if (comments.isEmpty()) { + return Map.of(); + } + List parentIds = comments.stream().map(Comment::getId).toList(); + + return commentRepository.countVisibleSubCommentsByParentIds( + parentIds, excludedUserIds, currentUserId) + .stream() + .collect(Collectors.toMap(SubCommentCountDto::parentCommentId, SubCommentCountDto::count)); + } + + private void checkCommentViewAccess(Long dailyReportId, Long reportOwnerId, Long currentUserId) { + if (currentUserId.equals(reportOwnerId)) return; + if (!dailyReportRepository.existsByIdAndIsSharedTrueAndDate(dailyReportId, TodayDateTimeProvider.getTodayDate())) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); + } + long smallerId = Math.min(currentUserId, reportOwnerId); + long largerId = Math.max(currentUserId, reportOwnerId); + if (!friendshipRepository.existsAcceptedByUserIds(smallerId, largerId)) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); + } + } + + private List getExcludedUserIds(Long userId) { + List blocked = userBlockRepository.findBlockedUserIdsBidirectional(userId); + List suspended = sharingSuspensionService.getAllActiveSuspendedUserIds(); + + Set combined = new HashSet<>(blocked); + combined.addAll(suspended); + combined.remove(userId); + + return combined.isEmpty() ? List.of(-1L) : new ArrayList<>(combined); + } + +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/application/event/CommentCreatedEvent.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/application/event/CommentCreatedEvent.java new file mode 100644 index 00000000..bcf06ea3 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/application/event/CommentCreatedEvent.java @@ -0,0 +1,15 @@ +package com.devkor.ifive.nadab.domain.comment.application.event; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class CommentCreatedEvent { + + private final Long commentId; + private final Long dailyReportId; + private final Long authorId; + private final Long reportOwnerId; + private final String content; +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/application/event/SubCommentCreatedEvent.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/application/event/SubCommentCreatedEvent.java new file mode 100644 index 00000000..e657a82c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/application/event/SubCommentCreatedEvent.java @@ -0,0 +1,19 @@ +package com.devkor.ifive.nadab.domain.comment.application.event; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class SubCommentCreatedEvent { + + private final Long subCommentId; + private final Long dailyReportId; + private final Long authorId; + private final Long parentCommentId; + private final Long parentCommentAuthorId; + private final Long reportOwnerId; + private final String content; + private final boolean secret; + private final boolean parentSecret; +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/core/dto/SubCommentCountDto.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/core/dto/SubCommentCountDto.java new file mode 100644 index 00000000..9ac5f48b --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/core/dto/SubCommentCountDto.java @@ -0,0 +1,4 @@ +package com.devkor.ifive.nadab.domain.comment.core.dto; + +public record SubCommentCountDto(Long parentCommentId, Long count) { +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/core/entity/Comment.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/core/entity/Comment.java new file mode 100644 index 00000000..0e23ab7d --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/core/entity/Comment.java @@ -0,0 +1,66 @@ +package com.devkor.ifive.nadab.domain.comment.core.entity; + +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReport; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.shared.entity.SoftDeletableEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "comments") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Comment extends SoftDeletableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "daily_report_id", nullable = false) + private DailyReport dailyReport; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "author_id", nullable = false) + private User author; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_comment_id") + private Comment parentComment; + + @Column(name = "content", nullable = false, length = 500) + private String content; + + @Column(name = "is_secret", nullable = false) + private boolean secret; + + public static Comment createTopLevel(DailyReport dailyReport, User author, String content, boolean isSecret) { + Comment comment = new Comment(); + comment.dailyReport = dailyReport; + comment.author = author; + comment.content = content; + comment.secret = isSecret; + return comment; + } + + public static Comment createSubComment( + User author, Comment parentComment, String content, boolean isSecret) { + Comment comment = new Comment(); + comment.dailyReport = parentComment.dailyReport; + comment.author = author; + comment.parentComment = parentComment; + comment.content = content; + comment.secret = isSecret; + return comment; + } + + public void updateContent(String content) { + this.content = content; + } + + public boolean isTopLevel() { + return parentComment == null; + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/core/repository/CommentRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/core/repository/CommentRepository.java new file mode 100644 index 00000000..124cc6a4 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/core/repository/CommentRepository.java @@ -0,0 +1,119 @@ +package com.devkor.ifive.nadab.domain.comment.core.repository; + +import com.devkor.ifive.nadab.domain.comment.core.dto.SubCommentCountDto; +import com.devkor.ifive.nadab.domain.comment.core.entity.Comment; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; + +public interface CommentRepository extends JpaRepository { + + @Query(""" + select c from Comment c + join fetch c.author + join fetch c.dailyReport + where c.id = :id + and c.deletedAt is null + """) + Optional findByIdWithAuthorAndDailyReport(@Param("id") Long id); + + @Query(""" + select c.parentComment.author.id from Comment c + where c.id = :id + and c.parentComment is not null + """) + Optional findParentAuthorIdById(@Param("id") Long id); + + @Query(""" + select c from Comment c + join fetch c.author a + where c.dailyReport.id = :dailyReportId + and c.parentComment is null + and c.deletedAt is null + and a.deletedAt is null + and (:cursor is null or c.id < :cursor) + and a.id not in :excludedUserIds + and not exists ( + select 1 from ContentReport cr + where cr.reporter.id = :currentUserId and cr.comment = c + ) + order by c.id desc + """) + List findTopLevelComments( + @Param("dailyReportId") Long dailyReportId, + @Param("cursor") Long cursor, + @Param("excludedUserIds") List excludedUserIds, + @Param("currentUserId") Long currentUserId, + Pageable pageable + ); + + @Query(""" + select c from Comment c + join fetch c.author a + where c.parentComment.id = :parentCommentId + and c.deletedAt is null + and a.deletedAt is null + and (:cursor is null or c.id < :cursor) + and a.id not in :excludedUserIds + and not exists ( + select 1 from ContentReport cr + where cr.reporter.id = :currentUserId and cr.comment = c + ) + order by c.id desc + """) + List findSubComments( + @Param("parentCommentId") Long parentCommentId, + @Param("cursor") Long cursor, + @Param("excludedUserIds") List excludedUserIds, + @Param("currentUserId") Long currentUserId, + Pageable pageable + ); + + @Query(""" + select new com.devkor.ifive.nadab.domain.comment.core.dto.SubCommentCountDto( + c.parentComment.id, count(c) + ) + from Comment c + where c.parentComment.id in :parentIds + and c.deletedAt is null + and c.author.deletedAt is null + and c.author.id not in :excludedUserIds + and not exists ( + select 1 from ContentReport cr + where cr.reporter.id = :currentUserId and cr.comment = c + ) + group by c.parentComment.id + """) + List countVisibleSubCommentsByParentIds( + @Param("parentIds") List parentIds, + @Param("excludedUserIds") List excludedUserIds, + @Param("currentUserId") Long currentUserId + ); + + @Query(""" + select distinct c.author.id + from Comment c + where c.parentComment.id = :parentCommentId + and c.deletedAt is null + and c.author.id not in :excludeUserIds + and c.author.deletedAt is null + """) + List findDistinctSubCommentAuthorIds( + @Param("parentCommentId") Long parentCommentId, + @Param("excludeUserIds") List excludeUserIds + ); + + @Modifying + @Query(""" + update Comment c + set c.deletedAt = :now, c.updatedAt = :now + where c.parentComment.id = :parentId and c.deletedAt is null + """) + void softDeleteSubCommentsByParentId(@Param("parentId") Long parentId, @Param("now") OffsetDateTime now); +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/AnswerController.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/AnswerController.java index 8d0f9575..f0991cdb 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/AnswerController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/AnswerController.java @@ -99,6 +99,7 @@ public ResponseEntity> searchAnswers( description = """ 답변 ID로 해당 답변의 상세 정보와 리포트를 조회합니다. + - 일간 리포트 ID (dailyReportId, 리포트 없으면 null) - 질문 내용 (questionText) - 질문 카테고리 (interestCode) - 답변 작성일 (answerDate) @@ -206,6 +207,7 @@ public ResponseEntity> getCalendarRecent 특정 날짜의 답변 전체 정보를 조회합니다. 응답 데이터: + - 일간 리포트 ID (dailyReportId, 리포트 없으면 null) - 질문 내용 (questionText) - 질문 카테고리 (interestCode) - 답변 작성일 (answerDate) diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/HomeController.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/HomeController.java index 89405f59..c32aa6ef 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/HomeController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/HomeController.java @@ -1,5 +1,7 @@ package com.devkor.ifive.nadab.domain.dailyreport.api; +import com.devkor.ifive.nadab.domain.appversion.application.AppVersionDismissalCommandService; +import com.devkor.ifive.nadab.domain.dailyreport.api.dto.request.HomeVersionDismissRequest; import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.HomeResponse; import com.devkor.ifive.nadab.domain.dailyreport.application.HomeQueryService; import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; @@ -11,11 +13,14 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; +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; @@ -26,6 +31,7 @@ public class HomeController { private final HomeQueryService homeQueryService; + private final AppVersionDismissalCommandService appVersionDismissalCommandService; @GetMapping @PreAuthorize("isAuthenticated()") @@ -40,6 +46,14 @@ public class HomeController { 3. 총 기록 일수: 실제 답변한 날짜의 총 개수 4. 친구 프로필: 나와 같은 오늘의 질문에 답변한 친구들의 프로필 사진 URL (최대 5개) 5. 친구 답변 수: 나와 같은 오늘의 질문에 답변한 친구의 총 수 + 6. 플랫폼별 최신 버전 정보 (android, ios) + - 앱 버전 ID + - 최신 버전 문자열 + - 업데이트 요약 문장 + - 업데이트 항목 목록 + - 업데이트 항목명 + - 업데이트 상세 설명 + - 다시 보지 않기 여부 ### 친구 프로필 조회 기준 - 나의 오늘의 질문과 동일한 질문에 답변한 친구만 표시 @@ -98,4 +112,32 @@ public ResponseEntity> getHomeData( HomeResponse response = homeQueryService.getHomeData(principal.getId()); return ApiResponseEntity.ok(response); } -} \ No newline at end of file + + @PostMapping("/version-dismissals") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "홈 업데이트 다시 보지 않기 저장", + description = """ + 사용자가 홈화면에 표시되는 앱 버전 업데이트 알림을 다시 보지 않도록 설정합니다. + + ### 요청 정보 + - appVersionId: 숨김 처리할 앱 버전 ID (홈화면 정보 조회 api에서 제공되는 앱 버전 ID 사용) + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "저장 성공", content = @Content), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "404", description = """ + - ErrorCode: USER_NOT_FOUND - 사용자를 찾을 수 없음 + - ErrorCode: APP_VERSION_NOT_FOUND - 앱 버전을 찾을 수 없음 + """, content = @Content) + } + ) + public ResponseEntity> dismissHomeVersion( + @AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody HomeVersionDismissRequest request + ) { + appVersionDismissalCommandService.dismiss(principal.getId(), request.appVersionId()); + return ApiResponseEntity.noContent(); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/request/HomeVersionDismissRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/request/HomeVersionDismissRequest.java new file mode 100644 index 00000000..f1981bf4 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/request/HomeVersionDismissRequest.java @@ -0,0 +1,12 @@ +package com.devkor.ifive.nadab.domain.dailyreport.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "홈 버전 업데이트 다시 보지 않기 요청") +public record HomeVersionDismissRequest( + @NotNull + @Schema(description = "숨김 처리할 앱 버전 ID", example = "1") + Long appVersionId +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/AnswerDetailResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/AnswerDetailResponse.java index 366468e2..abfe837d 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/AnswerDetailResponse.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/AnswerDetailResponse.java @@ -8,6 +8,9 @@ @Schema(description = "답변 상세 조회 응답") public record AnswerDetailResponse( + @Schema(description = "일간 리포트 ID (댓글·좋아요 조회 시 사용)", example = "42") + Long dailyReportId, + @Schema(description = "질문 내용", example = "오늘 가장 기뻤던 순간은?") String questionText, @@ -31,6 +34,7 @@ public record AnswerDetailResponse( ) { public static AnswerDetailResponse from(AnswerDetailDto dto, String imageUrl) { return new AnswerDetailResponse( + dto.dailyReportId(), dto.questionText(), dto.interestCode() != null ? dto.interestCode().name() : null, dto.answerDate(), diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/FeedListResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/FeedListResponse.java index 0122402e..5dbefc84 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/FeedListResponse.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/FeedListResponse.java @@ -6,7 +6,10 @@ @Schema(description = "피드 목록 응답") public record FeedListResponse( - @Schema(description = "피드 목록") + @Schema(description = "오늘 내 공유 리포트 (미공유 시 null)") + FeedResponse myReport, + + @Schema(description = "친구 피드 목록") List feeds ) { } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/FeedResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/FeedResponse.java index 5b07cc5c..6b8bace3 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/FeedResponse.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/FeedResponse.java @@ -27,6 +27,12 @@ public record FeedResponse( String emotionCode, @Schema(description = "이미지 URL") - String imageUrl + String imageUrl, + + @Schema(description = "내가 좋아요 눌렀는지 여부") + boolean isLiked, + + @Schema(description = "좋아요가 1개 이상인지 여부") + boolean hasLikes ) { } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomeLatestVersionResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomeLatestVersionResponse.java new file mode 100644 index 00000000..b9f7de89 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomeLatestVersionResponse.java @@ -0,0 +1,11 @@ +package com.devkor.ifive.nadab.domain.dailyreport.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "플랫폼별 최신 앱 버전") +public record HomeLatestVersionResponse( + HomePlatformVersionResponse ios, + + HomePlatformVersionResponse android +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomePlatformVersionResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomePlatformVersionResponse.java new file mode 100644 index 00000000..34653ac1 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomePlatformVersionResponse.java @@ -0,0 +1,24 @@ +package com.devkor.ifive.nadab.domain.dailyreport.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "플랫폼별 최신 버전 정보") +public record HomePlatformVersionResponse( + @Schema(description = "앱 버전 ID", example = "1") + Long appVersionId, + + @Schema(description = "최신 앱 버전", example = "1.2.0") + String version, + + @Schema(description = "업데이트 요약 문장", example = "좋아요와 댓글로 마음을 전해요.") + String summary, + + @Schema(description = "업데이트 항목 목록") + List items, + + @Schema(description = "다시 보지 않기 여부", example = "false") + boolean dismissed +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomeResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomeResponse.java index 3c59b143..4f75b7e9 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomeResponse.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomeResponse.java @@ -20,6 +20,9 @@ public record HomeResponse( List answeredFriendProfiles, @Schema(description = "나와 같은 오늘의 질문에 답변한 친구의 총 수", example = "8") - int answeredFriendCount + int answeredFriendCount, + + @Schema(description = "플랫폼별 최신 앱 버전") + HomeLatestVersionResponse latestVersion ) { -} \ No newline at end of file +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomeVersionItemResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomeVersionItemResponse.java new file mode 100644 index 00000000..cf06f421 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomeVersionItemResponse.java @@ -0,0 +1,13 @@ +package com.devkor.ifive.nadab.domain.dailyreport.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "업데이트 항목") +public record HomeVersionItemResponse( + @Schema(description = "업데이트 항목명", example = "월간 리포트") + String title, + + @Schema(description = "업데이트 상세 설명", example = "한 달의 기록을 한눈에 돌아볼 수 있어요.") + String description +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/FeedQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/FeedQueryService.java index d1e39e0e..2f19efdc 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/FeedQueryService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/FeedQueryService.java @@ -8,8 +8,10 @@ import com.devkor.ifive.nadab.domain.friend.core.entity.Friendship; import com.devkor.ifive.nadab.domain.friend.core.entity.FriendshipStatus; import com.devkor.ifive.nadab.domain.friend.core.repository.FriendshipRepository; +import com.devkor.ifive.nadab.domain.like.core.repository.DailyReportLikeRepository; import com.devkor.ifive.nadab.domain.moderation.application.SharingSuspensionService; import com.devkor.ifive.nadab.domain.moderation.core.repository.ContentReportRepository; +import com.devkor.ifive.nadab.domain.moderation.core.repository.UserBlockRepository; import com.devkor.ifive.nadab.domain.user.core.entity.DefaultProfileType; import com.devkor.ifive.nadab.domain.user.infra.ProfileImageUrlBuilder; import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; @@ -18,9 +20,12 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; +import java.util.stream.Stream; @Service @RequiredArgsConstructor @@ -29,26 +34,33 @@ public class FeedQueryService { private final FriendshipRepository friendshipRepository; private final DailyReportRepository dailyReportRepository; + private final DailyReportLikeRepository dailyReportLikeRepository; private final ProfileImageUrlBuilder profileImageUrlBuilder; private final ContentReportRepository contentReportRepository; private final SharingSuspensionService sharingSuspensionService; + private final UserBlockRepository userBlockRepository; public FeedListResponse getFeeds(Long userId) { - // 1. ACCEPTED 상태의 친구 관계 조회 + LocalDate today = TodayDateTimeProvider.getTodayDate(); + + // 1. 내 공유 리포트 조회 + Optional myFeedDto = dailyReportRepository.findMySharedFeedByDate(userId, today); + + // 2. ACCEPTED 상태의 친구 관계 조회 List friendships = friendshipRepository .findByUserIdAndStatusWithUsers(userId, FriendshipStatus.ACCEPTED); - // 2. 친구 ID 리스트 추출 + // 3. 친구 ID 리스트 추출 List friendIds = friendships.stream() .map(f -> f.getOtherUserId(userId)) .toList(); - // 3. 친구가 없으면 빈 리스트 반환 + // 4. 친구가 없으면 myReport만 매핑 후 반환 if (friendIds.isEmpty()) { - return new FeedListResponse(List.of()); + return toFeedListResponse(userId, myFeedDto, List.of()); } - // 4. 공유 활동 중지된 유저 제외 + // 5. 공유 활동 중지된 유저 제외 Set suspendedUserIds = new HashSet<>( sharingSuspensionService.getSharingSuspendedUserIds(friendIds) ); @@ -56,45 +68,51 @@ public FeedListResponse getFeeds(Long userId) { .filter(id -> !suspendedUserIds.contains(id)) .toList(); - // 5. 공유 가능한 친구가 없으면 빈 리스트 반환 + // 6. 공유 가능한 친구가 없으면 myReport만 반환 if (activeFriendIds.isEmpty()) { - return new FeedListResponse(List.of()); + return toFeedListResponse(userId, myFeedDto, List.of()); } - // 6. 당일 공유된 피드 조회 - LocalDate today = TodayDateTimeProvider.getTodayDate(); + // 7. 당일 공유된 피드 조회 List feedDtos = dailyReportRepository.findSharedFeedsByFriendIds(today, activeFriendIds); - // 7. 내가 신고한 글 제외 - List dailyReportIds = feedDtos.stream() + // 8. 내가 신고한 글 제외 + List friendReportIds = feedDtos.stream().map(FeedDto::dailyReportId).toList(); + Set reportedIds = friendReportIds.isEmpty() ? Set.of() + : new HashSet<>(contentReportRepository.findReportedDailyReportIdsByReporter(userId, friendReportIds)); + List filteredFeedDtos = feedDtos.stream() + .filter(dto -> !reportedIds.contains(dto.dailyReportId())) + .toList(); + + return toFeedListResponse(userId, myFeedDto, filteredFeedDtos); + } + + private FeedListResponse toFeedListResponse(Long userId, Optional myFeedDto, List feedDtos) { + // 전체 reportId 수집 후 좋아요 정보 벌크 조회 + List allReportIds = Stream.concat(myFeedDto.stream(), feedDtos.stream()) .map(FeedDto::dailyReportId) .toList(); - Set reportedIds = new HashSet<>( - contentReportRepository.findReportedDailyReportIdsByReporter(userId, dailyReportIds) - ); + Set likedReportIds; + Set reportIdsWithLikes; + if (allReportIds.isEmpty()) { + likedReportIds = Set.of(); + reportIdsWithLikes = Set.of(); + } else { + likedReportIds = new HashSet<>(dailyReportLikeRepository.findLikedReportIds(allReportIds, userId)); + reportIdsWithLikes = new HashSet<>( + dailyReportLikeRepository.findReportIdsWithLikes(allReportIds, getExcludedUserIds(userId))); + } + + FeedResponse myReport = myFeedDto + .map(dto -> toFeedResponse(dto, likedReportIds, reportIdsWithLikes)) + .orElse(null); - // 8. 필터링 및 응답 DTO 변환 List feeds = feedDtos.stream() - .filter(dto -> !reportedIds.contains(dto.dailyReportId())) - .map(dto -> { - String profileUrl = buildProfileUrl(dto.profileImageKey(), dto.defaultProfileType()); - String imageUrl = dto.imageKey() != null ? profileImageUrlBuilder.buildUrl(dto.imageKey()) : null; - - return new FeedResponse( - dto.dailyReportId(), - dto.nickname(), - profileUrl, - dto.interestCode() != null ? dto.interestCode().name() : null, - dto.questionText(), - dto.answerContent(), - dto.emotionCode() != null ? dto.emotionCode().name() : null, - imageUrl - ); - }) + .map(dto -> toFeedResponse(dto, likedReportIds, reportIdsWithLikes)) .toList(); - return new FeedListResponse(feeds); + return new FeedListResponse(myReport, feeds); } public ShareStatusResponse getShareStatus(Long userId) { @@ -104,6 +122,34 @@ public ShareStatusResponse getShareStatus(Long userId) { .orElse(new ShareStatusResponse(false)); } + private FeedResponse toFeedResponse(FeedDto dto, Set likedReportIds, Set reportIdsWithLikes) { + String profileUrl = buildProfileUrl(dto.profileImageKey(), dto.defaultProfileType()); + String imageUrl = dto.imageKey() != null ? profileImageUrlBuilder.buildUrl(dto.imageKey()) : null; + return new FeedResponse( + dto.dailyReportId(), + dto.nickname(), + profileUrl, + dto.interestCode() != null ? dto.interestCode().name() : null, + dto.questionText(), + dto.answerContent(), + dto.emotionCode() != null ? dto.emotionCode().name() : null, + imageUrl, + likedReportIds.contains(dto.dailyReportId()), + reportIdsWithLikes.contains(dto.dailyReportId()) + ); + } + + private List getExcludedUserIds(Long userId) { + List blocked = userBlockRepository.findBlockedUserIdsBidirectional(userId); + List suspended = sharingSuspensionService.getAllActiveSuspendedUserIds(); + + Set combined = new HashSet<>(blocked); + combined.addAll(suspended); + combined.remove(userId); + + return combined.isEmpty() ? List.of(-1L) : new ArrayList<>(combined); + } + private String buildProfileUrl(String profileImageKey, DefaultProfileType defaultProfileType) { if (profileImageKey != null) { return profileImageUrlBuilder.buildUrl(profileImageKey); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/HomeQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/HomeQueryService.java index a1d05f01..4f8324ae 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/HomeQueryService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/HomeQueryService.java @@ -1,5 +1,7 @@ package com.devkor.ifive.nadab.domain.dailyreport.application; +import com.devkor.ifive.nadab.domain.appversion.application.AppVersionQueryService; +import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.HomeLatestVersionResponse; import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.HomeResponse; import com.devkor.ifive.nadab.domain.dailyreport.core.repository.AnswerEntryQueryRepository; import com.devkor.ifive.nadab.domain.question.core.entity.UserDailyQuestion; @@ -27,6 +29,7 @@ public class HomeQueryService { private final AnswerEntryQueryRepository answerEntryQueryRepository; private final UserDailyQuestionRepository userDailyQuestionRepository; private final ProfileImageUrlBuilder profileImageUrlBuilder; + private final AppVersionQueryService appVersionQueryService; public HomeResponse getHomeData(Long userId) { LocalDate today = TodayDateTimeProvider.getTodayDate(); @@ -71,12 +74,15 @@ public HomeResponse getHomeData(Long userId) { } // 7. 응답 생성 + HomeLatestVersionResponse latestVersion = appVersionQueryService.getHomeLatestVersion(userId); + return new HomeResponse( weeklyAnsweredDates, currentStreak, totalAnsweredDays, answeredFriendProfiles, - answeredFriendCount + answeredFriendCount, + latestVersion ); } @@ -105,4 +111,4 @@ private int calculateCurrentStreak(LocalDate today, List answerDates) return streak; } -} \ No newline at end of file +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/dto/AnswerDetailDto.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/dto/AnswerDetailDto.java index 8b6eb94f..354aaa31 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/dto/AnswerDetailDto.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/dto/AnswerDetailDto.java @@ -10,6 +10,7 @@ * Repository 쿼리 결과를 담는 DTO */ public record AnswerDetailDto( + Long dailyReportId, String questionText, InterestCode interestCode, LocalDate answerDate, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/AnswerEntryQueryRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/AnswerEntryQueryRepository.java index c0c89904..f65a0e2f 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/AnswerEntryQueryRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/AnswerEntryQueryRepository.java @@ -108,7 +108,7 @@ List findRecentAnswers( */ @Query(""" select new com.devkor.ifive.nadab.domain.dailyreport.core.dto.AnswerDetailDto( - ae.question.questionText, ae.question.interest.code, ae.date, ae.content, dr.content, e.code, ae.imageKey + dr.id, ae.question.questionText, ae.question.interest.code, ae.date, ae.content, dr.content, e.code, ae.imageKey ) from AnswerEntry ae left join DailyReport dr on dr.answerEntry = ae and dr.status = com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus.COMPLETED @@ -126,7 +126,7 @@ Optional findDetailByAnswerId( */ @Query(""" select new com.devkor.ifive.nadab.domain.dailyreport.core.dto.AnswerDetailDto( - ae.question.questionText, ae.question.interest.code, ae.date, ae.content, dr.content, e.code, ae.imageKey + dr.id, ae.question.questionText, ae.question.interest.code, ae.date, ae.content, dr.content, e.code, ae.imageKey ) from AnswerEntry ae left join DailyReport dr on dr.answerEntry = ae and dr.status = com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus.COMPLETED diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/DailyReportRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/DailyReportRepository.java index 9c78c010..710907b9 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/DailyReportRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/DailyReportRepository.java @@ -44,6 +44,7 @@ public interface DailyReportRepository extends JpaRepository */ @Query(""" select new com.devkor.ifive.nadab.domain.dailyreport.core.dto.AnswerDetailDto( + dr.id, q.questionText, i.code, ae.date, @@ -160,6 +161,35 @@ Optional findByUserIdAndDate( @Param("date") LocalDate date ); + @Query("select ae.user.id from DailyReport dr join dr.answerEntry ae where dr.id = :reportId") + Optional findReportOwnerIdById(@Param("reportId") Long reportId); + + boolean existsByIdAndIsSharedTrueAndDate(Long id, LocalDate date); + + @Query(""" + select new com.devkor.ifive.nadab.domain.dailyreport.core.dto.FeedDto( + dr.id, + ae.user.nickname, + ae.user.profileImageKey, + ae.user.defaultProfileType, + ae.question.interest.code, + ae.question.questionText, + ae.content, + dr.emotion.code, + ae.imageKey + ) + from DailyReport dr + join dr.answerEntry ae + where ae.user.id = :userId + and dr.date = :date + and dr.isShared = true + and dr.status = com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus.COMPLETED + """) + Optional findMySharedFeedByDate( + @Param("userId") Long userId, + @Param("date") LocalDate date + ); + @Query(""" select new com.devkor.ifive.nadab.domain.dailyreport.core.dto.InterestCompletedCountDto( i.code, @@ -177,4 +207,8 @@ List countCompletedByInterest( @Param("userId") Long userId, @Param("status") DailyReportStatus status ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE DailyReport r SET r.isShared = false WHERE r.answerEntry.user.id = :userId AND r.date = :date AND r.isShared = true") + int stopSharingByUserIdAndDate(@Param("userId") Long userId, @Param("date") LocalDate date); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/friend/core/repository/FriendshipRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/friend/core/repository/FriendshipRepository.java index 4d489c27..9e2a0a03 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/friend/core/repository/FriendshipRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/friend/core/repository/FriendshipRepository.java @@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.Collection; import java.util.List; public interface FriendshipRepository extends JpaRepository { @@ -20,6 +21,16 @@ select case when exists ( """) boolean existsByUserIds(@Param("userId1") Long userId1, @Param("userId2") Long userId2); + @Query(""" + select case when exists ( + select 1 from Friendship f + where f.user1.id = :userId1 and f.user2.id = :userId2 + and f.status = 'ACCEPTED' + and f.user1.deletedAt is null and f.user2.deletedAt is null + ) then true else false end + """) + boolean existsAcceptedByUserIds(@Param("userId1") Long userId1, @Param("userId2") Long userId2); + @Query(""" select count(f) from Friendship f where (f.user1.id = :userId or f.user2.id = :userId) @@ -67,6 +78,16 @@ List findReceivedPendingRequestsByKeyword( @Param("keyword") String keyword ); + @Query(""" + select f from Friendship f + join fetch f.user1 + join fetch f.user2 + where ((f.user1.id = :userId and f.user2.id in :otherIds) + or (f.user2.id = :userId and f.user1.id in :otherIds)) + and f.user1.deletedAt is null and f.user2.deletedAt is null + """) + List findByUserIdAndOtherUserIds(@Param("userId") Long userId, @Param("otherIds") Collection otherIds); + @Modifying @Query(""" delete from Friendship f diff --git a/src/main/java/com/devkor/ifive/nadab/domain/like/api/LikeController.java b/src/main/java/com/devkor/ifive/nadab/domain/like/api/LikeController.java new file mode 100644 index 00000000..82e40c9d --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/like/api/LikeController.java @@ -0,0 +1,198 @@ +package com.devkor.ifive.nadab.domain.like.api; + +import com.devkor.ifive.nadab.domain.like.api.dto.response.LikeListResponse; +import com.devkor.ifive.nadab.domain.like.application.LikeCommandService; +import com.devkor.ifive.nadab.domain.like.application.LikeQueryService; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; +import com.devkor.ifive.nadab.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "좋아요 API", description = "게시글 및 댓글 좋아요 관련 API") +@RestController +@RequestMapping("${api_prefix}") +@RequiredArgsConstructor +public class LikeController { + + private final LikeCommandService likeCommandService; + private final LikeQueryService likeQueryService; + + @PostMapping("/feed/{dailyReportId}/likes") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "게시글 좋아요", + description = """ + 친구의 공유 게시글에 좋아요를 누릅니다. + + - 본인의 게시글에는 좋아요 불가 (400) + - 이미 좋아요한 경우 204 반환 (멱등) + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "좋아요 성공", content = @Content), + @ApiResponse(responseCode = "400", description = """ + - ErrorCode: CANNOT_LIKE_OWN_CONTENT - 본인 게시글 좋아요 불가 + - ErrorCode: SOCIAL_SUSPENDED - 소셜 정지 중 + """, content = @Content), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "ErrorCode: AUTH_ACCESS_DENIED - 공유되지 않은 게시글이거나 친구가 아닌 경우", content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: DAILY_REPORT_NOT_FOUND", content = @Content) + } + ) + public ResponseEntity> likeReport( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long dailyReportId + ) { + likeCommandService.likeReport(dailyReportId, principal.getId()); + return ApiResponseEntity.noContent(); + } + + @DeleteMapping("/feed/{dailyReportId}/likes") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "게시글 좋아요 취소", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "취소 성공", content = @Content), + @ApiResponse(responseCode = "400", description = "ErrorCode: SOCIAL_SUSPENDED - 소셜 정지 중", content = @Content), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: LIKE_NOT_FOUND", content = @Content) + } + ) + public ResponseEntity> unlikeReport( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long dailyReportId + ) { + likeCommandService.unlikeReport(dailyReportId, principal.getId()); + return ApiResponseEntity.noContent(); + } + + @GetMapping("/feed/{dailyReportId}/likes") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "게시글 좋아요 리스트", + description = """ + 게시글에 좋아요를 누른 사용자 목록을 조회합니다. + + - 리포트 당사자(본인 게시글)만 조회 가능합니다. + - 최신순 정렬, 차단 관계 양방향 제외합니다. + - relationshipStatus + - FRIEND: 이미 친구, 친구 삭제·차단 버튼 표시 + - REQUEST_SENT: 내가 신청 보낸 상태, 신청 취소 버튼 표시 + - REQUEST_RECEIVED: 상대가 나에게 신청 보낸 상태, 수락·거절 버튼 표시 + - NONE: 아무 관계 없음, 친구 신청 버튼 표시 + - friendshipId: NONE 상태일 땐 null, 그 외 친구 관계 ID + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = LikeListResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "ErrorCode: DAILY_REPORT_LIKE_LIST_FORBIDDEN - 본인 게시글 아님", content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: DAILY_REPORT_NOT_FOUND", content = @Content) + } + ) + public ResponseEntity> getReportLikers( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long dailyReportId + ) { + LikeListResponse response = likeQueryService.getReportLikers(dailyReportId, principal.getId()); + return ApiResponseEntity.ok(response); + } + + @PostMapping("/comments/{commentId}/likes") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "댓글/대댓글 좋아요", + description = """ + 댓글 또는 대댓글에 좋아요를 누릅니다. + + - 본인의 댓글에는 좋아요 불가 (400) + - 열람 권한 없는 비밀 댓글에는 좋아요 불가 (403) + - 이미 좋아요한 경우 204 반환 (멱등) + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "좋아요 성공", content = @Content), + @ApiResponse(responseCode = "400", description = """ + - ErrorCode: CANNOT_LIKE_OWN_CONTENT - 본인 댓글 좋아요 불가 + - ErrorCode: SOCIAL_SUSPENDED - 소셜 정지 중 + """, content = @Content), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "ErrorCode: AUTH_ACCESS_DENIED", content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: COMMENT_NOT_FOUND", content = @Content), + @ApiResponse(responseCode = "409", description = "ErrorCode: COMMENT_DELETED", content = @Content) + } + ) + public ResponseEntity> likeComment( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long commentId + ) { + likeCommandService.likeComment(commentId, principal.getId()); + return ApiResponseEntity.noContent(); + } + + @DeleteMapping("/comments/{commentId}/likes") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "댓글/대댓글 좋아요 취소", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "취소 성공", content = @Content), + @ApiResponse(responseCode = "400", description = "ErrorCode: SOCIAL_SUSPENDED - 소셜 정지 중", content = @Content), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: LIKE_NOT_FOUND", content = @Content) + } + ) + public ResponseEntity> unlikeComment( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long commentId + ) { + likeCommandService.unlikeComment(commentId, principal.getId()); + return ApiResponseEntity.noContent(); + } + + @GetMapping("/comments/{commentId}/likes") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "댓글/대댓글 좋아요 리스트", + description = """ + 댓글 또는 대댓글에 좋아요를 누른 사용자 목록을 조회합니다. + + - 최신순 정렬, 차단 관계 양방향 제외합니다. + - relationshipStatus + - FRIEND: 이미 친구, 친구 삭제·차단 버튼 표시 + - REQUEST_SENT: 내가 신청 보낸 상태, 신청 취소 버튼 표시 + - REQUEST_RECEIVED: 상대가 나에게 신청 보낸 상태, 수락·거절 버튼 표시 + - NONE: 아무 관계 없음, 친구 신청 버튼 표시 + - friendshipId: NONE 상태일 땐 null, 그 외 친구 관계 ID + - 비밀 댓글은 열람 권한자(작성자·리포트 당사자)만 조회 가능합니다. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = LikeListResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "ErrorCode: AUTH_ACCESS_DENIED - 비밀 댓글 열람 권한 없음 또는 피드 접근 권한 없음", content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: COMMENT_NOT_FOUND", content = @Content), + @ApiResponse(responseCode = "409", description = "ErrorCode: COMMENT_DELETED", content = @Content) + } + ) + public ResponseEntity> getCommentLikers( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long commentId + ) { + LikeListResponse response = likeQueryService.getCommentLikers(commentId, principal.getId()); + return ApiResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/like/api/dto/response/LikeListResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/like/api/dto/response/LikeListResponse.java new file mode 100644 index 00000000..fd24ea5e --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/like/api/dto/response/LikeListResponse.java @@ -0,0 +1,13 @@ +package com.devkor.ifive.nadab.domain.like.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "좋아요 리스트 응답") +public record LikeListResponse( + + @Schema(description = "좋아요 누른 사용자 목록 (최신순, 차단 관계 제외)") + List likers +) { +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/like/api/dto/response/LikerResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/like/api/dto/response/LikerResponse.java new file mode 100644 index 00000000..e76a6165 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/like/api/dto/response/LikerResponse.java @@ -0,0 +1,24 @@ +package com.devkor.ifive.nadab.domain.like.api.dto.response; + +import com.devkor.ifive.nadab.domain.friend.api.dto.response.RelationshipStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "좋아요 누른 사용자") +public record LikerResponse( + + @Schema(description = "사용자 ID", example = "42") + Long userId, + + @Schema(description = "프로필 이미지 URL") + String profileImageUrl, + + @Schema(description = "닉네임", example = "모래") + String nickname, + + @Schema(description = "친구 관계 ID (친구 신청 취소·삭제 시 사용, NONE 상태일 땐 null)", example = "123") + Long friendshipId, + + @Schema(description = "친구 관계 상태 (FRIEND: 친구 삭제·차단 버튼 표시, REQUEST_SENT: 신청 취소 버튼 표시, REQUEST_RECEIVED: 수락·거절 버튼 표시, NONE: 친구 신청 버튼 표시)") + RelationshipStatus relationshipStatus +) { +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/like/application/LikeCommandService.java b/src/main/java/com/devkor/ifive/nadab/domain/like/application/LikeCommandService.java new file mode 100644 index 00000000..34f2ad06 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/like/application/LikeCommandService.java @@ -0,0 +1,137 @@ +package com.devkor.ifive.nadab.domain.like.application; + +import com.devkor.ifive.nadab.domain.comment.core.entity.Comment; +import com.devkor.ifive.nadab.domain.comment.core.repository.CommentRepository; +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReport; +import com.devkor.ifive.nadab.domain.dailyreport.core.repository.DailyReportRepository; +import com.devkor.ifive.nadab.domain.friend.core.repository.FriendshipRepository; +import com.devkor.ifive.nadab.domain.moderation.application.SharingSuspensionService; +import com.devkor.ifive.nadab.domain.like.core.entity.CommentLike; +import com.devkor.ifive.nadab.domain.like.core.entity.DailyReportLike; +import com.devkor.ifive.nadab.domain.like.core.repository.CommentLikeRepository; +import com.devkor.ifive.nadab.domain.like.core.repository.DailyReportLikeRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import com.devkor.ifive.nadab.global.exception.ConflictException; +import com.devkor.ifive.nadab.global.exception.ForbiddenException; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class LikeCommandService { + + private final DailyReportLikeRepository dailyReportLikeRepository; + private final CommentLikeRepository commentLikeRepository; + private final DailyReportRepository dailyReportRepository; + private final CommentRepository commentRepository; + private final FriendshipRepository friendshipRepository; + private final UserRepository userRepository; + private final SharingSuspensionService sharingSuspensionService; + + public void likeReport(Long dailyReportId, Long userId) { + checkNotSuspended(userId); + Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(dailyReportId) + .orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND)); + + if (userId.equals(reportOwnerId)) { + throw new BadRequestException(ErrorCode.CANNOT_LIKE_OWN_CONTENT); + } + + checkReportLikeAccess(dailyReportId, reportOwnerId, userId); + + if (dailyReportLikeRepository.existsByUserIdAndDailyReportId(userId, dailyReportId)) { + return; // 이미 좋아요 — 멱등 처리 + } + + User user = userRepository.getReferenceById(userId); + DailyReport dailyReport = dailyReportRepository.getReferenceById(dailyReportId); + dailyReportLikeRepository.save(DailyReportLike.create(user, dailyReport)); + } + + public void unlikeReport(Long dailyReportId, Long userId) { + checkNotSuspended(userId); + DailyReportLike like = dailyReportLikeRepository.findByUserIdAndDailyReportId(userId, dailyReportId) + .orElseThrow(() -> new NotFoundException(ErrorCode.LIKE_NOT_FOUND)); + dailyReportLikeRepository.delete(like); + } + + public void likeComment(Long commentId, Long userId) { + checkNotSuspended(userId); + Comment comment = commentRepository.findByIdWithAuthorAndDailyReport(commentId) + .orElseThrow(() -> commentRepository.existsById(commentId) + ? new ConflictException(ErrorCode.COMMENT_DELETED) + : new NotFoundException(ErrorCode.COMMENT_NOT_FOUND)); + + if (userId.equals(comment.getAuthor().getId())) { + throw new BadRequestException(ErrorCode.CANNOT_LIKE_OWN_CONTENT); + } + + Long dailyReportId = comment.getDailyReport().getId(); + Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(dailyReportId) + .orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND)); + checkCommentLikeAccess(dailyReportId, reportOwnerId, userId); + + if (comment.isSecret()) { + boolean isParentAuthor = !comment.isTopLevel() && + commentRepository.findParentAuthorIdById(commentId) + .map(id -> id.equals(userId)) + .orElse(false); + boolean canView = comment.getAuthor().getId().equals(userId) + || reportOwnerId.equals(userId) + || isParentAuthor; + if (!canView) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); + } + } + + if (commentLikeRepository.existsByUserIdAndCommentId(userId, commentId)) { + return; // 이미 좋아요 — 멱등 처리 + } + + User user = userRepository.getReferenceById(userId); + commentLikeRepository.save(CommentLike.create(user, comment)); + } + + public void unlikeComment(Long commentId, Long userId) { + checkNotSuspended(userId); + CommentLike like = commentLikeRepository.findByUserIdAndCommentId(userId, commentId) + .orElseThrow(() -> new NotFoundException(ErrorCode.LIKE_NOT_FOUND)); + commentLikeRepository.delete(like); + } + + private void checkNotSuspended(Long userId) { + if (sharingSuspensionService.isSharingSuspended(userId)) { + throw new BadRequestException(ErrorCode.SOCIAL_SUSPENDED); + } + } + + private void checkReportLikeAccess(Long dailyReportId, Long reportOwnerId, Long currentUserId) { + if (!dailyReportRepository.existsByIdAndIsSharedTrueAndDate(dailyReportId, TodayDateTimeProvider.getTodayDate())) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); + } + long smallerId = Math.min(currentUserId, reportOwnerId); + long largerId = Math.max(currentUserId, reportOwnerId); + if (!friendshipRepository.existsAcceptedByUserIds(smallerId, largerId)) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); + } + } + + private void checkCommentLikeAccess(Long dailyReportId, Long reportOwnerId, Long currentUserId) { + if (currentUserId.equals(reportOwnerId)) return; + if (!dailyReportRepository.existsByIdAndIsSharedTrueAndDate(dailyReportId, TodayDateTimeProvider.getTodayDate())) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); + } + long smallerId = Math.min(currentUserId, reportOwnerId); + long largerId = Math.max(currentUserId, reportOwnerId); + if (!friendshipRepository.existsAcceptedByUserIds(smallerId, largerId)) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/like/application/LikeQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/like/application/LikeQueryService.java new file mode 100644 index 00000000..f51a3fe6 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/like/application/LikeQueryService.java @@ -0,0 +1,152 @@ +package com.devkor.ifive.nadab.domain.like.application; + +import com.devkor.ifive.nadab.domain.comment.core.entity.Comment; +import com.devkor.ifive.nadab.domain.comment.core.repository.CommentRepository; +import com.devkor.ifive.nadab.domain.dailyreport.core.repository.DailyReportRepository; +import com.devkor.ifive.nadab.domain.friend.api.dto.response.RelationshipStatus; +import com.devkor.ifive.nadab.domain.friend.core.entity.Friendship; +import com.devkor.ifive.nadab.domain.friend.core.entity.FriendshipStatus; +import com.devkor.ifive.nadab.domain.friend.core.repository.FriendshipRepository; +import com.devkor.ifive.nadab.domain.like.api.dto.response.LikeListResponse; +import com.devkor.ifive.nadab.domain.like.api.dto.response.LikerResponse; +import com.devkor.ifive.nadab.domain.like.core.repository.CommentLikeRepository; +import com.devkor.ifive.nadab.domain.like.core.repository.DailyReportLikeRepository; +import com.devkor.ifive.nadab.domain.moderation.application.SharingSuspensionService; +import com.devkor.ifive.nadab.domain.moderation.core.repository.UserBlockRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.infra.ProfileImageUrlBuilder; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ConflictException; +import com.devkor.ifive.nadab.global.exception.ForbiddenException; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LikeQueryService { + + private final DailyReportLikeRepository dailyReportLikeRepository; + private final CommentLikeRepository commentLikeRepository; + private final DailyReportRepository dailyReportRepository; + private final CommentRepository commentRepository; + private final FriendshipRepository friendshipRepository; + private final UserBlockRepository userBlockRepository; + private final ProfileImageUrlBuilder profileImageUrlBuilder; + private final SharingSuspensionService sharingSuspensionService; + + public LikeListResponse getReportLikers(Long dailyReportId, Long currentUserId) { + Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(dailyReportId) + .orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND)); + + if (!currentUserId.equals(reportOwnerId)) { + throw new ForbiddenException(ErrorCode.DAILY_REPORT_LIKE_LIST_FORBIDDEN); + } + + List excludedUserIds = getExcludedUserIds(currentUserId); + List likers = dailyReportLikeRepository.findLikersByReportId(dailyReportId, excludedUserIds); + + Map friendshipInfoMap = buildFriendshipInfoMap(likers, currentUserId); + List responses = likers.stream() + .map(u -> toLikerResponse(u, friendshipInfoMap)) + .toList(); + + return new LikeListResponse(responses); + } + + public LikeListResponse getCommentLikers(Long commentId, Long currentUserId) { + Comment comment = commentRepository.findByIdWithAuthorAndDailyReport(commentId) + .orElseThrow(() -> commentRepository.existsById(commentId) + ? new ConflictException(ErrorCode.COMMENT_DELETED) + : new NotFoundException(ErrorCode.COMMENT_NOT_FOUND)); + + Long dailyReportId = comment.getDailyReport().getId(); + Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(dailyReportId) + .orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND)); + checkCommentViewAccess(dailyReportId, reportOwnerId, currentUserId); + + if (comment.isSecret()) { + boolean isParentAuthor = !comment.isTopLevel() && + commentRepository.findParentAuthorIdById(commentId) + .map(id -> id.equals(currentUserId)) + .orElse(false); + boolean canView = comment.getAuthor().getId().equals(currentUserId) + || reportOwnerId.equals(currentUserId) + || isParentAuthor; + if (!canView) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); + } + } + + List excludedUserIds = getExcludedUserIds(currentUserId); + List likers = commentLikeRepository.findLikersByCommentId(commentId, excludedUserIds); + + Map friendshipInfoMap = buildFriendshipInfoMap(likers, currentUserId); + List responses = likers.stream() + .map(u -> toLikerResponse(u, friendshipInfoMap)) + .toList(); + + return new LikeListResponse(responses); + } + + private void checkCommentViewAccess(Long dailyReportId, Long reportOwnerId, Long currentUserId) { + if (currentUserId.equals(reportOwnerId)) return; + if (!dailyReportRepository.existsByIdAndIsSharedTrueAndDate(dailyReportId, TodayDateTimeProvider.getTodayDate())) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); + } + long smallerId = Math.min(currentUserId, reportOwnerId); + long largerId = Math.max(currentUserId, reportOwnerId); + if (!friendshipRepository.existsAcceptedByUserIds(smallerId, largerId)) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); + } + } + + private record FriendshipInfo(Long friendshipId, RelationshipStatus status) {} + + private Map buildFriendshipInfoMap(List likers, Long currentUserId) { + if (likers.isEmpty()) return Map.of(); + List likerIds = likers.stream().map(User::getId).toList(); + List friendships = friendshipRepository.findByUserIdAndOtherUserIds(currentUserId, likerIds); + return friendships.stream().collect(Collectors.toMap( + f -> f.getOtherUserId(currentUserId), + f -> new FriendshipInfo( + f.getId(), + f.getStatus() == FriendshipStatus.ACCEPTED ? RelationshipStatus.FRIEND + : f.isRequester(currentUserId) ? RelationshipStatus.REQUEST_SENT + : RelationshipStatus.REQUEST_RECEIVED + ) + )); + } + + private LikerResponse toLikerResponse(User user, Map friendshipInfoMap) { + FriendshipInfo info = friendshipInfoMap.get(user.getId()); + return new LikerResponse( + user.getId(), + profileImageUrlBuilder.buildUserProfileUrl(user), + user.getNickname(), + info != null ? info.friendshipId() : null, + info != null ? info.status() : RelationshipStatus.NONE + ); + } + + private List getExcludedUserIds(Long userId) { + List blocked = userBlockRepository.findBlockedUserIdsBidirectional(userId); + List suspended = sharingSuspensionService.getAllActiveSuspendedUserIds(); + + Set combined = new HashSet<>(blocked); + combined.addAll(suspended); + combined.remove(userId); + + return combined.isEmpty() ? List.of(-1L) : new ArrayList<>(combined); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/like/core/entity/CommentLike.java b/src/main/java/com/devkor/ifive/nadab/domain/like/core/entity/CommentLike.java new file mode 100644 index 00000000..b7e7f336 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/like/core/entity/CommentLike.java @@ -0,0 +1,35 @@ +package com.devkor.ifive.nadab.domain.like.core.entity; + +import com.devkor.ifive.nadab.domain.comment.core.entity.Comment; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.shared.entity.CreatableEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "comment_likes") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CommentLike extends CreatableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "comment_id", nullable = false) + private Comment comment; + + public static CommentLike create(User user, Comment comment) { + CommentLike like = new CommentLike(); + like.user = user; + like.comment = comment; + return like; + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/like/core/entity/DailyReportLike.java b/src/main/java/com/devkor/ifive/nadab/domain/like/core/entity/DailyReportLike.java new file mode 100644 index 00000000..37733ab5 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/like/core/entity/DailyReportLike.java @@ -0,0 +1,35 @@ +package com.devkor.ifive.nadab.domain.like.core.entity; + +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReport; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.shared.entity.CreatableEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "daily_report_likes") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DailyReportLike extends CreatableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "daily_report_id", nullable = false) + private DailyReport dailyReport; + + public static DailyReportLike create(User user, DailyReport dailyReport) { + DailyReportLike like = new DailyReportLike(); + like.user = user; + like.dailyReport = dailyReport; + return like; + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/like/core/repository/CommentLikeRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/like/core/repository/CommentLikeRepository.java new file mode 100644 index 00000000..079f1198 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/like/core/repository/CommentLikeRepository.java @@ -0,0 +1,47 @@ +package com.devkor.ifive.nadab.domain.like.core.repository; + +import com.devkor.ifive.nadab.domain.like.core.entity.CommentLike; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface CommentLikeRepository extends JpaRepository { + + Optional findByUserIdAndCommentId(Long userId, Long commentId); + + boolean existsByUserIdAndCommentId(Long userId, Long commentId); + + @Query(""" + select l.comment.id + from CommentLike l + where l.comment.id in :commentIds + and l.user.id = :userId + """) + List findLikedCommentIds(@Param("commentIds") List commentIds, @Param("userId") Long userId); + + @Query(""" + select distinct l.comment.id + from CommentLike l + where l.comment.id in :commentIds + and l.user.id not in :excludedUserIds + and l.user.deletedAt is null + """) + List findCommentIdsWithLikes( + @Param("commentIds") List commentIds, + @Param("excludedUserIds") List excludedUserIds + ); + + @Query(""" + select l.user + from CommentLike l + where l.comment.id = :commentId + and l.user.id not in :excludedUserIds + and l.user.deletedAt is null + order by l.createdAt desc + """) + List findLikersByCommentId(@Param("commentId") Long commentId, @Param("excludedUserIds") List excludedUserIds); +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/like/core/repository/DailyReportLikeRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/like/core/repository/DailyReportLikeRepository.java new file mode 100644 index 00000000..634df2a5 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/like/core/repository/DailyReportLikeRepository.java @@ -0,0 +1,47 @@ +package com.devkor.ifive.nadab.domain.like.core.repository; + +import com.devkor.ifive.nadab.domain.like.core.entity.DailyReportLike; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface DailyReportLikeRepository extends JpaRepository { + + Optional findByUserIdAndDailyReportId(Long userId, Long dailyReportId); + + boolean existsByUserIdAndDailyReportId(Long userId, Long dailyReportId); + + @Query(""" + select l.dailyReport.id + from DailyReportLike l + where l.dailyReport.id in :reportIds + and l.user.id = :userId + """) + List findLikedReportIds(@Param("reportIds") List reportIds, @Param("userId") Long userId); + + @Query(""" + select distinct l.dailyReport.id + from DailyReportLike l + where l.dailyReport.id in :reportIds + and l.user.id not in :excludedUserIds + and l.user.deletedAt is null + """) + List findReportIdsWithLikes( + @Param("reportIds") List reportIds, + @Param("excludedUserIds") List excludedUserIds + ); + + @Query(""" + select l.user + from DailyReportLike l + where l.dailyReport.id = :reportId + and l.user.id not in :excludedUserIds + and l.user.deletedAt is null + order by l.createdAt desc + """) + List findLikersByReportId(@Param("reportId") Long reportId, @Param("excludedUserIds") List excludedUserIds); +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/ModerationController.java b/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/ModerationController.java index 8785045a..f64a5d0b 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/ModerationController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/ModerationController.java @@ -3,7 +3,9 @@ import com.devkor.ifive.nadab.domain.moderation.api.dto.request.BlockUserRequest; import com.devkor.ifive.nadab.domain.moderation.api.dto.request.ReportContentRequest; import com.devkor.ifive.nadab.domain.moderation.api.dto.response.BlockedUserListResponse; +import com.devkor.ifive.nadab.domain.moderation.api.dto.response.SuspensionStatusResponse; import com.devkor.ifive.nadab.domain.moderation.application.ContentReportCommandService; +import com.devkor.ifive.nadab.domain.moderation.application.SharingSuspensionService; import com.devkor.ifive.nadab.domain.moderation.application.UserBlockCommandService; import com.devkor.ifive.nadab.domain.moderation.application.UserBlockQueryService; import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; @@ -22,7 +24,8 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "신고 및 차단 API", description = "공유글 신고, 사용자 차단 관련 API") + +@Tag(name = "신고 및 차단 API", description = "공유글/댓글 신고, 사용자 차단, 소셜 정지 관련 API") @RestController @RequestMapping("${api_prefix}/moderation") @RequiredArgsConstructor @@ -31,16 +34,17 @@ public class ModerationController { private final ContentReportCommandService contentReportCommandService; private final UserBlockCommandService userBlockCommandService; private final UserBlockQueryService userBlockQueryService; + private final SharingSuspensionService sharingSuspensionService; @PostMapping("/reports") @PreAuthorize("isAuthenticated()") @Operation( - summary = "공유글 신고 API", + summary = "신고 API", description = """ - 공유된 DailyReport를 신고합니다. + 공유글 또는 댓글/대댓글을 신고합니다. 요청 필드: - - dailyReportId (필수): 신고할 공유글의 DailyReport ID(GET /api/v1/feed 호출 시 필드에서 확인 가능) + - dailyReportId / commentId: 둘 중 하나만 필수 (동시 입력 불가) - reason (필수): 신고 사유 - PROFANITY_HATE_SPEECH: 욕설 / 혐오 표현 - SEXUAL_CONTENT: 성적으로 부적절한 언행 @@ -49,35 +53,43 @@ public class ModerationController { - customReason: reason이 OTHER일 때만 필수, 200자 이하 신고 후 동작: - - 동일 공유글 중복 신고 불가 + - 동일 대상 중복 신고 불가 - 신고한 공유글은 신고자의 피드에서 숨겨짐 - - 누적 신고 10건 이상 & 신고자 2명 이상 시 작성자의 소셜 활동 자동 중지(공유하기 시도 시 status로 SUSPENDED 반환) + - 누적 신고 20건 이상 & 신고자 3명 이상 시 소셜 활동 자동 정지 (720시간) """, security = @SecurityRequirement(name = "bearerAuth"), responses = { - @ApiResponse( - responseCode = "204", - description = "신고 성공", - content = @Content - ), + @ApiResponse(responseCode = "204", description = "신고 성공", content = @Content), @ApiResponse( responseCode = "400", - description = "ErrorCode: CONTENT_REPORT_INVALID - 잘못된 신고 요청 (기타 사유 미입력 또는 200자 초과)", + description = """ + - ErrorCode: CONTENT_REPORT_INVALID - 잘못된 신고 요청 (대상 미입력/중복 입력, 기타 사유 미입력 또는 200자 초과) + - ErrorCode: CONTENT_REPORT_SELF_REPORT_FORBIDDEN - 본인 신고 불가 + """, content = @Content ), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), @ApiResponse( - responseCode = "401", - description = "인증 실패", + responseCode = "403", + description = """ + - ErrorCode: AUTH_ACCESS_DENIED - 신고 권한 없음 (친구가 아니거나, 오늘 공유된 게시글이 아님, 비밀 댓글 열람 권한 없음) + """, content = @Content ), @ApiResponse( responseCode = "404", - description = "ErrorCode: DAILY_REPORT_NOT_FOUND - 공유글을 찾을 수 없음", + description = """ + - ErrorCode: DAILY_REPORT_NOT_FOUND - 공유글을 찾을 수 없음 + - ErrorCode: COMMENT_NOT_FOUND - 댓글을 찾을 수 없음 + """, content = @Content ), @ApiResponse( responseCode = "409", - description = "ErrorCode: CONTENT_REPORT_ALREADY_EXISTS - 이미 신고한 공유글", + description = """ + - ErrorCode: CONTENT_REPORT_ALREADY_EXISTS - 이미 신고한 대상 + - ErrorCode: COMMENT_DELETED - 이미 삭제된 댓글 + """, content = @Content ) } @@ -89,12 +101,40 @@ public ResponseEntity> reportContent( contentReportCommandService.reportContent( principal.getId(), request.dailyReportId(), + request.commentId(), request.reason(), request.customReason() ); return ApiResponseEntity.noContent(); } + @GetMapping("/suspension/status") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "소셜 정지 상태 조회", + description = """ + 내 소셜 정지 여부와 해제 시각을 반환합니다. (팝업 표시용) + + - isSuspended: 정지 중이면 true + - expiresAt: 정지 중일 때만 반환 (정지 해제 시각, ISO 8601) + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = SuspensionStatusResponse.class)) + ), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content) + } + ) + public ResponseEntity> getSuspensionStatus( + @AuthenticationPrincipal UserPrincipal principal + ) { + SuspensionStatusResponse response = sharingSuspensionService.getSuspensionStatus(principal.getId()); + return ApiResponseEntity.ok(response); + } + @PostMapping("/blocks") @PreAuthorize("isAuthenticated()") @Operation( diff --git a/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/dto/request/ReportContentRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/dto/request/ReportContentRequest.java index e0c13465..6dad1833 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/dto/request/ReportContentRequest.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/dto/request/ReportContentRequest.java @@ -4,13 +4,15 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; -@Schema(description = "공유글 신고 요청") +@Schema(description = "신고 요청 — dailyReportId / commentId 중 하나만 필수") public record ReportContentRequest( - @NotNull(message = "신고할 공유글 ID는 필수입니다") - @Schema(description = "신고할 DailyReport ID", example = "123") + @Schema(description = "신고할 DailyReport ID (게시글 신고 시 필수)", example = "123", nullable = true) Long dailyReportId, + @Schema(description = "신고할 Comment ID (댓글/대댓글 신고 시 필수)", example = "456", nullable = true) + Long commentId, + @NotNull(message = "신고 사유는 필수입니다") @Schema( description = "신고 사유 (PROFANITY_HATE_SPEECH, SEXUAL_CONTENT, SELF_HARM, OTHER)", diff --git a/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/dto/response/SuspensionStatusResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/dto/response/SuspensionStatusResponse.java new file mode 100644 index 00000000..4d0a29a7 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/dto/response/SuspensionStatusResponse.java @@ -0,0 +1,23 @@ +package com.devkor.ifive.nadab.domain.moderation.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.OffsetDateTime; + +@Schema(description = "소셜 정지 상태 응답") +public record SuspensionStatusResponse( + + @Schema(description = "소셜 정지 여부") + boolean isSuspended, + + @Schema(description = "정지 해제 시각 (정지 중일 때만 반환)", nullable = true) + OffsetDateTime expiresAt +) { + public static SuspensionStatusResponse notSuspended() { + return new SuspensionStatusResponse(false, null); + } + + public static SuspensionStatusResponse suspended(OffsetDateTime expiresAt) { + return new SuspensionStatusResponse(true, expiresAt); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/moderation/application/ContentReportCommandService.java b/src/main/java/com/devkor/ifive/nadab/domain/moderation/application/ContentReportCommandService.java index c867f14b..545a5ee8 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/moderation/application/ContentReportCommandService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/moderation/application/ContentReportCommandService.java @@ -1,7 +1,10 @@ package com.devkor.ifive.nadab.domain.moderation.application; +import com.devkor.ifive.nadab.domain.comment.core.entity.Comment; +import com.devkor.ifive.nadab.domain.comment.core.repository.CommentRepository; import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReport; import com.devkor.ifive.nadab.domain.dailyreport.core.repository.DailyReportRepository; +import com.devkor.ifive.nadab.domain.friend.core.repository.FriendshipRepository; import com.devkor.ifive.nadab.domain.moderation.core.entity.ContentReport; import com.devkor.ifive.nadab.domain.moderation.core.entity.ReportReason; import com.devkor.ifive.nadab.domain.moderation.core.repository.ContentReportRepository; @@ -10,7 +13,9 @@ import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.BadRequestException; import com.devkor.ifive.nadab.global.exception.ConflictException; +import com.devkor.ifive.nadab.global.exception.ForbiddenException; import com.devkor.ifive.nadab.global.exception.NotFoundException; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @@ -23,46 +28,121 @@ public class ContentReportCommandService { private final ContentReportRepository contentReportRepository; private final DailyReportRepository dailyReportRepository; + private final CommentRepository commentRepository; private final UserRepository userRepository; + private final FriendshipRepository friendshipRepository; + private final SharingSuspensionService sharingSuspensionService; - public void reportContent(Long reporterId, Long dailyReportId, ReportReason reason, String customReason) { - // 1. customReason 검증 + public void reportContent(Long reporterId, Long dailyReportId, Long commentId, + ReportReason reason, String customReason) { + // 1. 입력값 검증 validateCustomReason(reason, customReason); + validateTarget(dailyReportId, commentId); - // 2. 중복 신고 검증 + // 2. 신고 생성 + User reporter = userRepository.getReferenceById(reporterId); + ContentReport report = commentId != null + ? buildCommentReport(reporter, reporterId, commentId, reason, customReason) + : buildDailyReportReport(reporter, reporterId, dailyReportId, reason, customReason); + + // 3. 신고 저장 (동시성 예외 처리) + try { + contentReportRepository.save(report); + } catch (DataIntegrityViolationException e) { + boolean isDuplicate = commentId != null + ? contentReportRepository.existsByReporterIdAndCommentId(reporterId, commentId) + : contentReportRepository.existsByReporterIdAndDailyReportId(reporterId, dailyReportId); + if (isDuplicate) { + throw new ConflictException(ErrorCode.CONTENT_REPORT_ALREADY_EXISTS); + } + throw e; + } + + // 4. 자동 정지 조건 체크 + sharingSuspensionService.checkAndTriggerSuspension(report.getReportedUser().getId()); + } + + private ContentReport buildDailyReportReport(User reporter, Long reporterId, Long dailyReportId, + ReportReason reason, String customReason) { + // 중복 신고 검증 if (contentReportRepository.existsByReporterIdAndDailyReportId(reporterId, dailyReportId)) { throw new ConflictException(ErrorCode.CONTENT_REPORT_ALREADY_EXISTS); } - // 3. DailyReport 조회 + // DailyReport 조회 DailyReport dailyReport = dailyReportRepository.findById(dailyReportId) .orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND)); - // 4. 자기 신고 방지 검증 + // 자기 신고 방지 검증 User reportedUser = dailyReport.getAnswerEntry().getUser(); if (reporterId.equals(reportedUser.getId())) { throw new BadRequestException(ErrorCode.CONTENT_REPORT_SELF_REPORT_FORBIDDEN); } - // 5. 신고 저장 (동시성 예외 처리) - User reporter = userRepository.getReferenceById(reporterId); + // 접근 권한 검증 (오늘 공유된 친구 리포트만 신고 가능, 본인 글은 early return) + checkReportAccess(dailyReportId, reportedUser.getId(), reporterId); - ContentReport report = ContentReport.create( - reporter, dailyReport, reportedUser, reason, customReason - ); + return ContentReport.createForDailyReport(reporter, dailyReport, reportedUser, reason, customReason); + } - try { - contentReportRepository.save(report); - } catch (DataIntegrityViolationException e) { - // UNIQUE 제약 위반인지 재조회로 확인 - boolean isDuplicate = contentReportRepository - .existsByReporterIdAndDailyReportId(reporterId, dailyReportId); + private ContentReport buildCommentReport(User reporter, Long reporterId, Long commentId, + ReportReason reason, String customReason) { + // 중복 신고 검증 + if (contentReportRepository.existsByReporterIdAndCommentId(reporterId, commentId)) { + throw new ConflictException(ErrorCode.CONTENT_REPORT_ALREADY_EXISTS); + } - if (isDuplicate) { - throw new ConflictException(ErrorCode.CONTENT_REPORT_ALREADY_EXISTS); + // 댓글 조회 + Comment comment = commentRepository.findByIdWithAuthorAndDailyReport(commentId) + .orElseThrow(() -> commentRepository.existsById(commentId) + ? new ConflictException(ErrorCode.COMMENT_DELETED) + : new NotFoundException(ErrorCode.COMMENT_NOT_FOUND)); + + // 자기 신고 방지 검증 + User reportedUser = comment.getAuthor(); + if (reporterId.equals(reportedUser.getId())) { + throw new BadRequestException(ErrorCode.CONTENT_REPORT_SELF_REPORT_FORBIDDEN); + } + + // 게시글 접근 권한 검증 (오늘 공유된 친구 리포트만 신고 가능, 본인 글은 early return) + Long dailyReportId = comment.getDailyReport().getId(); + Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(dailyReportId) + .orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND)); + checkReportAccess(dailyReportId, reportOwnerId, reporterId); + + // 비밀 댓글은 열람 권한자만 신고 가능 (작성자는 자기 신고 방지로 이미 차단됨) + if (comment.isSecret()) { + boolean isParentAuthor = !comment.isTopLevel() && + commentRepository.findParentAuthorIdById(commentId) + .map(id -> id.equals(reporterId)) + .orElse(false); + boolean canView = reportOwnerId.equals(reporterId) || isParentAuthor; + if (!canView) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); } + } - throw e; + return ContentReport.createForComment(reporter, comment, reportedUser, reason, customReason); + } + + private void checkReportAccess(Long dailyReportId, Long reportOwnerId, Long reporterId) { + if (reporterId.equals(reportOwnerId)) return; + if (!dailyReportRepository.existsByIdAndIsSharedTrueAndDate(dailyReportId, TodayDateTimeProvider.getTodayDate())) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); + } + long smallerId = Math.min(reporterId, reportOwnerId); + long largerId = Math.max(reporterId, reportOwnerId); + if (!friendshipRepository.existsAcceptedByUserIds(smallerId, largerId)) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); + } + } + + private void validateTarget(Long dailyReportId, Long commentId) { + if (dailyReportId == null && commentId == null) { + throw new BadRequestException(ErrorCode.CONTENT_REPORT_INVALID); + } + if (dailyReportId != null && commentId != null) { + throw new BadRequestException(ErrorCode.CONTENT_REPORT_INVALID); } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/moderation/application/SharingSuspensionService.java b/src/main/java/com/devkor/ifive/nadab/domain/moderation/application/SharingSuspensionService.java index 55b56d23..fb43c116 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/moderation/application/SharingSuspensionService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/moderation/application/SharingSuspensionService.java @@ -1,45 +1,91 @@ package com.devkor.ifive.nadab.domain.moderation.application; +import com.devkor.ifive.nadab.domain.dailyreport.core.repository.DailyReportRepository; +import com.devkor.ifive.nadab.domain.moderation.api.dto.response.SuspensionStatusResponse; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; +import com.devkor.ifive.nadab.domain.moderation.core.entity.SocialSuspension; import com.devkor.ifive.nadab.domain.moderation.core.repository.ContentReportRepository; +import com.devkor.ifive.nadab.domain.moderation.core.repository.SocialSuspensionRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.OffsetDateTime; import java.util.List; -/** - * 공유 활동 중지 판단 서비스 - */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class SharingSuspensionService { - private static final long REPORT_COUNT_THRESHOLD = 10L; - private static final long REPORTER_COUNT_THRESHOLD = 2L; + private static final long REPORT_COUNT_THRESHOLD = 20L; + private static final long REPORTER_COUNT_THRESHOLD = 3L; + static final long SUSPENSION_HOURS = 720L; private final ContentReportRepository contentReportRepository; + private final SocialSuspensionRepository socialSuspensionRepository; + private final DailyReportRepository dailyReportRepository; + private final UserRepository userRepository; - /** - * 특정 유저가 공유 활동 중지 상태인지 확인 (신고 10건 이상 && 신고자 2명 이상) - */ public boolean isSharingSuspended(Long userId) { - long reportCount = contentReportRepository.countByReportedUserId(userId); - if (reportCount < REPORT_COUNT_THRESHOLD) { - return false; + return socialSuspensionRepository.existsByUserIdAndExpiresAtAfter(userId, OffsetDateTime.now()); + } + + public List getSharingSuspendedUserIds(List userIds) { + if (userIds == null || userIds.isEmpty()) { + return List.of(); } + return socialSuspensionRepository.findActiveSuspendedUserIds(userIds, OffsetDateTime.now()); + } + + public SuspensionStatusResponse getSuspensionStatus(Long userId) { + OffsetDateTime now = OffsetDateTime.now(); + return socialSuspensionRepository.findFirstByUserIdOrderByStartedAtDesc(userId) + .filter(s -> s.getExpiresAt().isAfter(now)) + .map(s -> SuspensionStatusResponse.suspended(s.getExpiresAt())) + .orElse(SuspensionStatusResponse.notSuspended()); + } - long reporterCount = contentReportRepository.countDistinctReportersByReportedUserId(userId); - return reporterCount >= REPORTER_COUNT_THRESHOLD; + public List getAllActiveSuspendedUserIds() { + return socialSuspensionRepository.findAllActiveSuspendedUserIds(OffsetDateTime.now()); } /** - * 여러 유저 중 공유 활동 중지된 유저 ID 조회 + * 신고 저장 후 호출. 정지 조건(친구 신고 20건 이상 + 신고자 3명 이상) 충족 시 정지 발동. + * 정지 중이면 조건 체크를 건너뜀. */ - public List getSharingSuspendedUserIds(List userIds) { - if (userIds == null || userIds.isEmpty()) { - return List.of(); + @Transactional + public void checkAndTriggerSuspension(Long reportedUserId) { + if (isSharingSuspended(reportedUserId)) { + return; + } + + // 가장 최근 정지의 expires_at을 기준점으로 사용 (없으면 전체 누적) + OffsetDateTime since = socialSuspensionRepository + .findFirstByUserIdOrderByStartedAtDesc(reportedUserId) + .map(SocialSuspension::getExpiresAt) + .orElse(null); + + long reportCount = since == null + ? contentReportRepository.countAllReports(reportedUserId) + : contentReportRepository.countReportsSince(reportedUserId, since); + if (reportCount < REPORT_COUNT_THRESHOLD) { + return; } - return contentReportRepository.findSharingSuspendedUserIds(userIds); + + long reporterCount = since == null + ? contentReportRepository.countAllDistinctReporters(reportedUserId) + : contentReportRepository.countDistinctReportersSince(reportedUserId, since); + if (reporterCount < REPORTER_COUNT_THRESHOLD) { + return; + } + + // 정지 발동 + OffsetDateTime now = OffsetDateTime.now(); + User user = userRepository.getReferenceById(reportedUserId); + socialSuspensionRepository.save(SocialSuspension.create(user, now, now.plusHours(SUSPENSION_HOURS))); + dailyReportRepository.stopSharingByUserIdAndDate(reportedUserId, TodayDateTimeProvider.getTodayDate()); } } \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/entity/ContentReport.java b/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/entity/ContentReport.java index 31aec8f7..3455e79d 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/entity/ContentReport.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/entity/ContentReport.java @@ -1,5 +1,6 @@ package com.devkor.ifive.nadab.domain.moderation.core.entity; +import com.devkor.ifive.nadab.domain.comment.core.entity.Comment; import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReport; import com.devkor.ifive.nadab.domain.user.core.entity.User; import com.devkor.ifive.nadab.global.shared.entity.CreatableEntity; @@ -26,10 +27,14 @@ public class ContentReport extends CreatableEntity { @JoinColumn(name = "reported_user_id", nullable = false) private User reportedUser; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "daily_report_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "daily_report_id") private DailyReport dailyReport; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id") + private Comment comment; + @Enumerated(EnumType.STRING) @Column(name = "reason", nullable = false, length = 50) private ReportReason reason; @@ -37,7 +42,7 @@ public class ContentReport extends CreatableEntity { @Column(name = "custom_reason", length = 200) private String customReason; - public static ContentReport create( + public static ContentReport createForDailyReport( User reporter, DailyReport dailyReport, User reportedUser, @@ -49,7 +54,23 @@ public static ContentReport create( report.dailyReport = dailyReport; report.reportedUser = reportedUser; report.reason = reason; - report.customReason = customReason; // 기타 사유 (OTHER일 때만) + report.customReason = customReason; + return report; + } + + public static ContentReport createForComment( + User reporter, + Comment comment, + User reportedUser, + ReportReason reason, + String customReason + ) { + ContentReport report = new ContentReport(); + report.reporter = reporter; + report.comment = comment; + report.reportedUser = reportedUser; + report.reason = reason; + report.customReason = customReason; return report; } } \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/entity/SocialSuspension.java b/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/entity/SocialSuspension.java new file mode 100644 index 00000000..decd1149 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/entity/SocialSuspension.java @@ -0,0 +1,39 @@ +package com.devkor.ifive.nadab.domain.moderation.core.entity; + +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.OffsetDateTime; + +@Entity +@Table(name = "social_suspensions") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SocialSuspension { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "started_at", nullable = false) + private OffsetDateTime startedAt; + + @Column(name = "expires_at", nullable = false) + private OffsetDateTime expiresAt; + + public static SocialSuspension create(User user, OffsetDateTime startedAt, OffsetDateTime expiresAt) { + SocialSuspension s = new SocialSuspension(); + s.user = user; + s.startedAt = startedAt; + s.expiresAt = expiresAt; + return s; + } + +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/ContentReportRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/ContentReportRepository.java index 7385b1c7..2c84429f 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/ContentReportRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/ContentReportRepository.java @@ -5,42 +5,58 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.OffsetDateTime; import java.util.List; public interface ContentReportRepository extends JpaRepository { - /** - * 중복 신고 체크 - */ boolean existsByReporterIdAndDailyReportId(Long reporterId, Long dailyReportId); + boolean existsByReporterIdAndCommentId(Long reporterId, Long commentId); + /** - * 특정 유저에 대한 신고 건수 조회 + * reportedUser를 신고한 전체 누적 건수 */ - long countByReportedUserId(Long reportedUserId); + @Query(""" + SELECT COUNT(cr.id) + FROM ContentReport cr + WHERE cr.reportedUser.id = :reportedUserId + """) + long countAllReports(@Param("reportedUserId") Long reportedUserId); /** - * 특정 유저를 신고한 사람 수 (중복 제거) + * reportedUser를 신고한 전체 누적 distinct 신고자 수 */ @Query(""" SELECT COUNT(DISTINCT cr.reporter.id) FROM ContentReport cr WHERE cr.reportedUser.id = :reportedUserId """) - long countDistinctReportersByReportedUserId(@Param("reportedUserId") Long reportedUserId); + long countAllDistinctReporters(@Param("reportedUserId") Long reportedUserId); + + /** + * reportedUser를 신고한 건수 (since 이후 누적) + */ + @Query(""" + SELECT COUNT(cr.id) + FROM ContentReport cr + WHERE cr.reportedUser.id = :reportedUserId + AND cr.createdAt > :since + """) + long countReportsSince(@Param("reportedUserId") Long reportedUserId, + @Param("since") OffsetDateTime since); /** - * 공유 활동 중지 대상 유저 ID 조회 (신고 10건 이상 && 신고자 2명 이상) + * reportedUser를 신고한 distinct 신고자 수 (since 이후 누적) */ @Query(""" - SELECT cr.reportedUser.id + SELECT COUNT(DISTINCT cr.reporter.id) FROM ContentReport cr - WHERE cr.reportedUser.id IN :userIds - GROUP BY cr.reportedUser.id - HAVING COUNT(cr.id) >= 10 - AND COUNT(DISTINCT cr.reporter.id) >= 2 + WHERE cr.reportedUser.id = :reportedUserId + AND cr.createdAt > :since """) - List findSharingSuspendedUserIds(@Param("userIds") List userIds); + long countDistinctReportersSince(@Param("reportedUserId") Long reportedUserId, + @Param("since") OffsetDateTime since); /** * 내가 신고한 DailyReport ID 목록 조회 diff --git a/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/SocialSuspensionRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/SocialSuspensionRepository.java new file mode 100644 index 00000000..2dff853b --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/SocialSuspensionRepository.java @@ -0,0 +1,36 @@ +package com.devkor.ifive.nadab.domain.moderation.core.repository; + +import com.devkor.ifive.nadab.domain.moderation.core.entity.SocialSuspension; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; + +public interface SocialSuspensionRepository extends JpaRepository { + + boolean existsByUserIdAndExpiresAtAfter(Long userId, OffsetDateTime now); + + @Query(""" + SELECT DISTINCT ss.user.id + FROM SocialSuspension ss + WHERE ss.user.id IN :userIds + AND ss.expiresAt > :now + """) + List findActiveSuspendedUserIds(@Param("userIds") List userIds, + @Param("now") OffsetDateTime now); + + @Query(""" + SELECT DISTINCT ss.user.id + FROM SocialSuspension ss + WHERE ss.expiresAt > :now + """) + List findAllActiveSuspendedUserIds(@Param("now") OffsetDateTime now); + + /** + * 가장 최근 정지 레코드 조회 — 신고 누적 기준점(expires_at)으로 사용 + */ + Optional findFirstByUserIdOrderByStartedAtDesc(Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/UserBlockRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/UserBlockRepository.java index 4902dd82..774f4a59 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/UserBlockRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/UserBlockRepository.java @@ -33,4 +33,12 @@ select case when exists ( ) then true else false end """) boolean existsAnyBlockBetweenUsers(@Param("userId") Long userId, @Param("otherUserId") Long otherUserId); + + @Query(""" + select case when ub.blocker.id = :userId then ub.blocked.id + else ub.blocker.id end + from UserBlock ub + where ub.blocker.id = :userId or ub.blocked.id = :userId + """) + List findBlockedUserIdsBidirectional(@Param("userId") Long userId); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportController.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportController.java index d1c32bb7..3c485cb4 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportController.java @@ -30,9 +30,11 @@ public class MonthlyReportController { private final MonthlyReportService monthlyReportService; private final MonthlyReportQueryService monthlyReportQueryService; + @Deprecated(since = "2026-05-15", forRemoval = true) @PostMapping("/start") @PreAuthorize("isAuthenticated()") @Operation( + deprecated = true, summary = "월간 리포트 생성 시작", description = """ 사용자의 (지난 달에 대한) 월간 리포트 생성을 시작합니다.
@@ -83,9 +85,11 @@ public ResponseEntity> startMonthlyRe return ApiResponseEntity.ok(response); } + @Deprecated(since = "2026-05-15", forRemoval = true) @GetMapping @PreAuthorize("isAuthenticated()") @Operation( + deprecated = true, summary = "나의 월간 리포트 조회", description = """ 사용자의 (지난 달에 대한) 월간 리포트와 이전 월간 리포트를 조회합니다.
diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportControllerV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportControllerV2.java new file mode 100644 index 00000000..f229dc6f --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportControllerV2.java @@ -0,0 +1,237 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.api; + +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.AllReportItemResponseV2; +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MyMonthlyReportLookupResponseV2; +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportResponseV2; +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportStartResponse; +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.ReportListTypeV2; +import com.devkor.ifive.nadab.domain.monthlyreport.application.MonthlyReportQueryServiceV2; +import com.devkor.ifive.nadab.domain.monthlyreport.application.MonthlyReportServiceV2; +import com.devkor.ifive.nadab.domain.weeklyreport.api.dto.response.CompletedCountResponse; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; +import com.devkor.ifive.nadab.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "월간 리포트 API V2", description = "월간 리포트 생성 및 조회 관련 API V2") +@RestController +@RequestMapping("/api/v2/monthly-report") +@RequiredArgsConstructor +public class MonthlyReportControllerV2 { + + private final MonthlyReportServiceV2 monthlyReportServiceV2; + private final MonthlyReportQueryServiceV2 monthlyReportQueryServiceV2; + + @PostMapping("/start") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "월간 리포트 생성 시작", + description = """ + 사용자의 (지난 달에 대한) 월간 리포트 생성을 시작합니다.
+ 비동기로 처리되기 때문에, id로 월간 리포트 조회 API를 폴링하여 상태를 확인할 수 있습니다. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "월간 리포트 생성 시작 성공", + content = @Content(schema = @Schema(implementation = MonthlyReportStartResponse.class), mediaType = "application/json") + ), + @ApiResponse( + responseCode = "400", + description = """ + - ErrorCode: MONTHLY_REPORT_NOT_ENOUGH_REPORTS - 월간 리포트 작성 자격 미달 **(이 경우 data의 completedCount 필드에 지난 주에 작성된 오늘의 리포트 수가 포함됩니다.)** + - ErrorCode: WALLET_INSUFFICIENT_BALANCE - 크리스탈 잔액 부족 + """, + content = @Content(schema = @Schema(implementation = CompletedCountResponse.class), mediaType = "application/json") + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content + ), + @ApiResponse( + responseCode = "404", + description = """ + - ErrorCode: USER_NOT_FOUND - 사용자를 찾을 수 없음 + - ErrorCode: WALLET_NOT_FOUND - 지갑을 찾을 수 없음 + """, + content = @Content + ), + @ApiResponse( + responseCode = "409", + description = """ + - ErrorCode: MONTHLY_REPORT_ALREADY_COMPLETED - 이미 작성된 월간 리포트가 존재함 + - ErrorCode: MONTHLY_REPORT_IN_PROGRESS - 현재 월간 리포트를 생성 중임 + """, + content = @Content + ) + } + ) + public ResponseEntity> startMonthlyReport( + @AuthenticationPrincipal UserPrincipal principal + ) { + MonthlyReportStartResponse response = monthlyReportServiceV2.startMonthlyReport(principal.getId()); + return ApiResponseEntity.ok(response); + } + + @GetMapping("/all") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "이전 리포트 목록 조회", + description = """ + 주간/월간 이전 리포트를 통합 조회합니다. + + type: ALL | MONTHLY | WEEKLY + + 정렬 순서: + 1) 년-월 내림차순 + 2) 동일 월에서는 월간 먼저, 그 다음 주간(주차 내림차순) + + version 규칙: + - weekly: 값과 상관없이 GET /api/v1/weekly-report/{id} 로 조회 + - monthly_reports - 1인 경우 : GET /api/v1/monthly-report/{id}로 조회 (기존의 레거시 버전) + - monthly_reports - 2인 경우 : GET /api/v2/monthly-report/{id}로 조회 (새로운 V2 버전) + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "전체 리포트 목록 조회 성공", + content = @Content(schema = @Schema(implementation = AllReportItemResponseV2.class), mediaType = "application/json") + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content + ), + @ApiResponse( + responseCode = "404", + description = "- ErrorCode: USER_NOT_FOUND - 사용자를 찾을 수 없음", + content = @Content + ) + } + ) + public ResponseEntity>> getAllReports( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam(defaultValue = "ALL") ReportListTypeV2 type + ) { + List response = monthlyReportQueryServiceV2.getAllReports(principal.getId(), type); + return ApiResponseEntity.ok(response); + } + + @GetMapping + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "나의 월간 리포트 조회 V2", + description = """ + 사용자의 (지난 달에 대한) 월간 리포트를 조회합니다.
+ + report: 존재하지 않으면 null, 존재하면 id와 version을 반환합니다.
+ + version 규칙: + - monthly_reports - 1인 경우 : GET /api/v1/monthly-report/{id}로 조회 (기존의 레거시 버전) + - monthly_reports - 2인 경우 : GET /api/v2/monthly-report/{id}로 조회 (새로운 V2 버전) + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "나의 월간 리포트 조회 성공", + content = @Content(schema = @Schema(implementation = MyMonthlyReportLookupResponseV2.class), mediaType = "application/json") + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content + ), + @ApiResponse( + responseCode = "404", + description = """ + - ErrorCode: USER_NOT_FOUND - 사용자를 찾을 수 없음 + """, + content = @Content + ) + } + ) + public ResponseEntity> getMyMonthlyReport( + @AuthenticationPrincipal UserPrincipal principal + ) { + MyMonthlyReportLookupResponseV2 response = monthlyReportQueryServiceV2.getMyMonthlyReport(principal.getId()); + return ApiResponseEntity.ok(response); + } + + @GetMapping("/{id}") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "id로 월간 리포트 조회", + description = """ + 월간 리포트 V2를 id로 조회합니다.
+ 생성 대기 중인 경우 ```status = "PENDING"``` 으로 반환됩니다.
+ 생성 진행 중인 경우 ```status = "IN_PROGRESS"``` 로 반환됩니다.
+ 텍스트만 생성 완료한 경우 ```status = "TEXT_COMPLETED"```로 반환됩니다.
+ 생성에 성공한 경우 ```status = "COMPLETED"``` 로 반환됩니다.
+ 생성에 실패한 경우 ```status = "FAILED"``` 로 반환됩니다. 이때 크리스탈이 환불되기 때문에 잔액 조회를 해야합니다. + + **<텍스트 스타일(styled) 지원>**
+ 월간 리포트 본문은 강조 표현을 위해 해당 필드에 구조화된 형태로 함께 제공됩니다.
+ 각 필드는 ```segments``` 배열을 가지며,
+ 각 segment는 ```text```와 ```marks```를 포함합니다.
+ ```marks```에는 ```BOLD```, ```HIGHLIGHT```만 포함될 수 있습니다.
+ 클라이언트는 ```segments```를 순서대로 이어 붙여 렌더링하고, ```marks```에 따라 볼드/하이라이트를 적용하면 됩니다.
+ + 다음은 각 페이지에서 활용되는 필드의 값에 대한 설명입니다.
+ comparisonType: 최초 생성인지 이전 리포트가 존재하는지 여부입니다.
+ 현재는 모두 최초 생성이기 때문에 "BASELINE"으로 고정되어 있고, 이전 리포트가 존재하는 경우에는 "COMPARISON"으로 반환될 예정입니다.
+ + **<페이지 1>**
+ summary : 월간 기록 요약
+ imageUrl : AI 생성 이미지
+ discovered.segments : 월간 분석 텍스트
+ + **<페이지 2>**
+ dominantKeyword : 이번 달 요약 단어
+ emotionStats.emotions : 감정에 대한 통계가 빈도 기준 내림차순으로 정렬되어 있습니다.
+ emotionSummaryContent.styledText.segments : 감정 분석 텍스트
+ + emotionTrend : "NOT_SUPPORTED" (현재는 고정. 변동 양상은 최초 생성 월간 리포트에서는 지원되지 않음. 이후 업데이트 예정)
+ + **<페이지 3>**
+ commentSummary : 나답의 한 마디 요약
+ comment.segments : 나답의 한 마디 텍스트
+ interestStats.interests : 카테고리(관심사)에 대한 통계가 빈도 기준 내림차순으로 정렬되어 있습니다.
+ """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "월간 리포트 조회 성공", + content = @Content(schema = @Schema(implementation = MonthlyReportResponseV2.class), mediaType = "application/json") + ), + @ApiResponse( + responseCode = "404", + description = "- ErrorCode: MONTHLY_REPORT_NOT_FOUND - 월간 리포트를 찾을 수 없음", + content = @Content + ) + } + ) + public ResponseEntity> getMonthlyReportById( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long id + ) { + MonthlyReportResponseV2 response = monthlyReportQueryServiceV2.getMonthlyReportById(principal.getId(), id); + return ApiResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/AllReportItemResponseV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/AllReportItemResponseV2.java new file mode 100644 index 00000000..2d1382a2 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/AllReportItemResponseV2.java @@ -0,0 +1,18 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "전체 리포트 목록 아이템") +public record AllReportItemResponseV2( + @Schema(description = "리포트 ID") + Long id, + @Schema(description = "리포트 타입", example = "MONTHLY") + String type, + @Schema(description = "기간 문자열", example = "1월 4주차") + String period, + @Schema(description = "요약") + String summary, + @Schema(description = "리포트 버전", example = "2") + int version +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportResponseV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportResponseV2.java new file mode 100644 index 00000000..934a97d4 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportResponseV2.java @@ -0,0 +1,53 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.InterestStatsContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportComparisonType; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeTextContent; +import com.devkor.ifive.nadab.global.shared.reportcontent.StyledText; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "월간 리포트 V2 조회 응답") +public record MonthlyReportResponseV2( + + @Schema(description = "리포트 대상 월") + int month, + + @Schema(description = "리포트 상태", example = "PENDING") + MonthlyReportStatus status, + + @Schema(description = "비교 타입", example = "BASELINE") + MonthlyReportComparisonType comparisonType, + + @Schema(description = "요약") + String summary, + + @Schema(description = "이미지 URL", nullable = true) + String imageUrl, + + @Schema(description = "발견한 점(styled)") + StyledText discovered, + + @Schema(description = "핵심 키워드") + String dominantKeyword, + + @Schema(description = "감정 통계") + TypeEmotionStatsContent emotionStats, + + @Schema(description = "감정 요약(styled)") + TypeTextContent emotionSummaryContent, + + @Schema(description = "감정 흐름 요약") + String emotionTrend, + + @Schema(description = "코멘트(styled)") + StyledText comment, + + @Schema(description = "코멘트 요약") + String commentSummary, + + @Schema(description = "관심사 통계") + InterestStatsContent interestStats +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MyMonthlyReportLookupItemV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MyMonthlyReportLookupItemV2.java new file mode 100644 index 00000000..f5df4979 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MyMonthlyReportLookupItemV2.java @@ -0,0 +1,17 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "나의 월간 리포트 라우팅 정보") +public record MyMonthlyReportLookupItemV2( + @Schema(description = "리포트 ID") + Long id, + @Schema(description = "리포트 버전", example = "2") + int version, + @Schema(description = "리포트 대상 월") + int month, + @Schema(description = "리포트 상태", example = "COMPLETED") + MonthlyReportStatus status +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MyMonthlyReportLookupResponseV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MyMonthlyReportLookupResponseV2.java new file mode 100644 index 00000000..00328b06 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MyMonthlyReportLookupResponseV2.java @@ -0,0 +1,10 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "나의 월간 리포트 단건 조회 응답") +public record MyMonthlyReportLookupResponseV2( + @Schema(description = "월간 리포트 라우팅 정보. 리포트가 없으면 null", nullable = true) + MyMonthlyReportLookupItemV2 report +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/ReportListTypeV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/ReportListTypeV2.java new file mode 100644 index 00000000..af9cd5aa --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/ReportListTypeV2.java @@ -0,0 +1,7 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response; + +public enum ReportListTypeV2 { + ALL, + MONTHLY, + WEEKLY +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryServiceV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryServiceV2.java new file mode 100644 index 00000000..c2037c8a --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryServiceV2.java @@ -0,0 +1,196 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application; + +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.AllReportItemResponseV2; +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MyMonthlyReportLookupItemV2; +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MyMonthlyReportLookupResponseV2; +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportResponseV2; +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.ReportListTypeV2; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyContentFactory; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReport; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2Content; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportRepository; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeContentFactory; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.domain.user.infra.ProfileImageUrlBuilder; +import com.devkor.ifive.nadab.domain.weeklyreport.core.entity.WeeklyReport; +import com.devkor.ifive.nadab.domain.weeklyreport.core.entity.WeeklyReportStatus; +import com.devkor.ifive.nadab.domain.weeklyreport.core.repository.WeeklyReportRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ForbiddenException; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; +import com.devkor.ifive.nadab.global.shared.util.WeekRangeCalculator; +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MonthlyReportQueryServiceV2 { + + private final MonthlyReportRepository monthlyReportRepository; + private final MonthlyReportV2Repository monthlyReportV2Repository; + private final WeeklyReportRepository weeklyReportRepository; + private final UserRepository userRepository; + private final ProfileImageUrlBuilder profileImageUrlBuilder; + + public List getAllReports(Long userId, ReportListTypeV2 type) { + if (!userRepository.existsById(userId)) { + throw new NotFoundException(ErrorCode.USER_NOT_FOUND); + } + + List rows = new ArrayList<>(); + + if (type == ReportListTypeV2.ALL || type == ReportListTypeV2.MONTHLY) { + List monthlyV1 = monthlyReportRepository.findAllByUserIdAndStatus(userId, MonthlyReportStatus.COMPLETED); + for (MonthlyReport report : monthlyV1) { + LocalDate month = report.getMonthStartDate(); + rows.add(new ReportListRow( + report.getId(), + "MONTHLY", + month.getYear(), + month.getMonthValue(), + 99, + month.getMonthValue() + "월", + report.getSummary(), + 1 + )); + } + + List monthlyV2 = monthlyReportV2Repository.findAllByUserIdAndStatus(userId, MonthlyReportStatus.COMPLETED); + for (MonthlyReportV2 report : monthlyV2) { + LocalDate month = report.getMonthStartDate(); + rows.add(new ReportListRow( + report.getId(), + "MONTHLY", + month.getYear(), + month.getMonthValue(), + 99, + month.getMonthValue() + "월", + report.getSummary(), + 2 + )); + } + } + + if (type == ReportListTypeV2.ALL || type == ReportListTypeV2.WEEKLY) { + List weekly = weeklyReportRepository.findAllByUserIdAndStatus(userId, WeeklyReportStatus.COMPLETED); + for (WeeklyReport report : weekly) { + LocalDate weekStart = report.getWeekStartDate(); + int weekOfMonth = WeekRangeCalculator.getWeekOfMonth(WeekRangeCalculator.weekRangeOf(weekStart)); + rows.add(new ReportListRow( + report.getId(), + "WEEKLY", + weekStart.getYear(), + weekStart.getMonthValue(), + weekOfMonth, + weekStart.getMonthValue() + "월 " + weekOfMonth + "주차", + report.getSummary(), + 1 + )); + } + } + + rows.sort( + Comparator.comparingInt(ReportListRow::year).reversed() + .thenComparing(Comparator.comparingInt(ReportListRow::month).reversed()) + .thenComparing(Comparator.comparingInt(ReportListRow::weekOrder).reversed()) + .thenComparing(Comparator.comparingInt(ReportListRow::version).reversed()) + .thenComparing(Comparator.comparingLong(ReportListRow::id).reversed()) + ); + + return rows.stream() + .map(r -> new AllReportItemResponseV2(r.id(), r.type(), r.period(), r.summary(), r.version())) + .toList(); + } + + public MyMonthlyReportLookupResponseV2 getMyMonthlyReport(Long userId) { + if (!userRepository.existsById(userId)) { + throw new NotFoundException(ErrorCode.USER_NOT_FOUND); + } + + MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); + return monthlyReportV2Repository.findByUserIdAndMonthStartDate(userId, range.monthStartDate()) + .map(this::toLookupResponse) + .or(() -> monthlyReportRepository.findByUserIdAndMonthStartDate(userId, range.monthStartDate()) + .map(this::toLookupResponse)) + .orElseGet(() -> new MyMonthlyReportLookupResponseV2(null)); + } + + public MonthlyReportResponseV2 getMonthlyReportById(Long userId, Long id) { + MonthlyReportV2 report = monthlyReportV2Repository.findById(id) + .orElseThrow(() -> new NotFoundException(ErrorCode.MONTHLY_REPORT_NOT_FOUND)); + if (report.getUser() == null || report.getUser().getId() == null || !report.getUser().getId().equals(userId)) { + throw new ForbiddenException(ErrorCode.MONTHLY_REPORT_ACCESS_FORBIDDEN); + } + return toResponse(report); + } + + private MonthlyReportResponseV2 toResponse(MonthlyReportV2 report) { + MonthlyReportV2Content content = report.getContent() == null + ? new MonthlyReportV2Content("", "", "", "", TypeContentFactory.emptyText().styledText(), TypeContentFactory.emptyText().styledText()) + : report.getContent().normalized(); + + String imageUrl = report.getImageKey() == null ? null : profileImageUrlBuilder.buildUrl(report.getImageKey()); + + return new MonthlyReportResponseV2( + report.getMonthStartDate().getMonthValue(), + report.getStatus() == null ? MonthlyReportStatus.PENDING : report.getStatus(), + report.getComparisonType(), + content.summary(), + imageUrl, + content.discovered(), + content.dominantKeyword(), + report.getEmotionStats() == null ? TypeContentFactory.emptyEmotionStats() : report.getEmotionStats().normalized(), + report.getEmotionSummaryContent() == null ? TypeContentFactory.emptyText() : report.getEmotionSummaryContent().normalized(), + content.emotionTrend(), + content.comment(), + content.commentSummary(), + report.getInterestStats() == null ? MonthlyContentFactory.emptyInterestStats() : report.getInterestStats().normalized() + ); + } + + private MyMonthlyReportLookupResponseV2 toLookupResponse(MonthlyReportV2 report) { + return new MyMonthlyReportLookupResponseV2( + new MyMonthlyReportLookupItemV2( + report.getId(), + 2, + report.getMonthStartDate().getMonthValue(), + report.getStatus() == null ? MonthlyReportStatus.PENDING : report.getStatus() + ) + ); + } + + private MyMonthlyReportLookupResponseV2 toLookupResponse(MonthlyReport report) { + return new MyMonthlyReportLookupResponseV2( + new MyMonthlyReportLookupItemV2( + report.getId(), + 1, + report.getMonthStartDate().getMonthValue(), + report.getStatus() == null ? MonthlyReportStatus.PENDING : report.getStatus() + ) + ); + } + + private record ReportListRow( + Long id, + String type, + int year, + int month, + int weekOrder, + String period, + String summary, + int version + ) { + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportServiceV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportServiceV2.java new file mode 100644 index 00000000..2296a93d --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportServiceV2.java @@ -0,0 +1,49 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application; + +import com.devkor.ifive.nadab.domain.dailyreport.core.repository.DailyReportRepository; +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportStartResponse; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReserveResultDto; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.domain.weeklyreport.api.dto.response.CompletedCountResponse; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import com.devkor.ifive.nadab.global.exception.report.MonthlyReportNotEligibleException; +import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MonthlyReportServiceV2 { + + private final UserRepository userRepository; + private final DailyReportRepository dailyReportRepository; + + private final MonthlyReportTxServiceV2 monthlyReportTxServiceV2; + + /** + * 비동기 시작 API: 즉시 reportId 반환 + */ + public MonthlyReportStartResponse startMonthlyReport(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); + + // 월간 리포트 작성 자격 확인 (저번 달에 15회 이상 완료) + MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); + + long completedCount = dailyReportRepository.countCompletedInMonth(userId, range.monthStartDate(), range.monthEndDate()); + boolean eligible = completedCount >= 15; + + if (!eligible) { + CompletedCountResponse response = new CompletedCountResponse(completedCount); + throw new MonthlyReportNotEligibleException(ErrorCode.MONTHLY_REPORT_NOT_ENOUGH_REPORTS, response); + } + + // (Tx) Report(PENDING) + reserve consume + log(PENDING) + MonthlyReserveResultDto reserve = monthlyReportTxServiceV2.reserveMonthlyAndPublish(user); + + return new MonthlyReportStartResponse(reserve.reportId(), "PENDING", reserve.balanceAfter()); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxService.java index e407309b..cd6c31db 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxService.java @@ -125,4 +125,4 @@ public void failAndRefundMonthly(Long userId, Long reportId, Long logId) { // log를 REFUNDED로 crystalLogRepository.markRefunded(logId); } -} +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxServiceV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxServiceV2.java new file mode 100644 index 00000000..be32ae0c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxServiceV2.java @@ -0,0 +1,178 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application; + + +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.InterestStatsContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportGenerationRequestedEventDtoV2; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReserveResultDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.*; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository; +import com.devkor.ifive.nadab.domain.monthlyreport.core.service.PendingMonthlyReportServiceV2; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeTextContent; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLog; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogReason; +import com.devkor.ifive.nadab.domain.wallet.core.entity.UserWallet; +import com.devkor.ifive.nadab.domain.wallet.core.repository.CrystalLogRepository; +import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.NotEnoughCrystalException; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class MonthlyReportTxServiceV2 { + + private final PendingMonthlyReportServiceV2 pendingMonthlyReportServiceV2; + + private final MonthlyReportV2Repository monthlyReportV2Repository; + private final UserWalletRepository userWalletRepository; + private final CrystalLogRepository crystalLogRepository; + + private final ApplicationEventPublisher eventPublisher; + private final ObjectMapper objectMapper; + + + private static final long MONTHLY_REPORT_COST = 40L; + + /** + * (Tx) MonthlyReport(PENDING) + reserve consume + CrystalLog(PENDING) + * 커밋되면 리포트 생성 작업을 시작할 준비가 완료됨 + */ + public MonthlyReserveResultDto reserveMonthly(User user, boolean exists) { + + // Report: 있으면 기존 사용, 없으면 새로 PENDING 생성 + MonthlyReportV2 report = pendingMonthlyReportServiceV2.getOrCreatePendingMonthlyReport(user, exists); + + // 선차감(원자적) + balanceAfter 확보 + int updated = userWalletRepository.tryConsume(user.getId(), MONTHLY_REPORT_COST); + if (updated == 0) { + throw new NotEnoughCrystalException(ErrorCode.WALLET_INSUFFICIENT_BALANCE); + } + + UserWallet wallet = userWalletRepository.findByUserId(user.getId()) + .orElseThrow(() -> new NotFoundException(ErrorCode.WALLET_NOT_FOUND)); + long balanceAfter = wallet.getCrystalBalance(); + + + // 로그(PENDING) + CrystalLog log = crystalLogRepository.save( + CrystalLog.createPending( + user, + -MONTHLY_REPORT_COST, + balanceAfter, + CrystalLogReason.REPORT_GENERATE_MONTHLY, + "MONTHLY_REPORT_V2", + report.getId() + ) + ); + + return new MonthlyReserveResultDto(report.getId(), log.getId(), user.getId(), balanceAfter); + } + + public MonthlyReserveResultDto reserveMonthlyAndPublish(User user) { + + boolean exists = monthlyReportV2Repository.existsByUserIdAndStatus(user.getId(), MonthlyReportStatus.COMPLETED); + + MonthlyReserveResultDto reserve = this.reserveMonthly(user, exists); + + monthlyReportV2Repository.updateStatus(reserve.reportId(), MonthlyReportStatus.IN_PROGRESS); + + // 트랜잭션 안에서 publish (AFTER_COMMIT 트리거 보장) + eventPublisher.publishEvent(new MonthlyReportGenerationRequestedEventDtoV2( + reserve.reportId(), + user.getId(), + reserve.crystalLogId(), + exists + )); + + return reserve; + } + + public void confirmMonthly(Long reportId, Long logId, String imageKey) { + monthlyReportV2Repository.completeWithImage( + reportId, + imageKey, + MonthlyReportImageStatus.COMPLETED, + MonthlyReportStatus.COMPLETED + ); + + // log를 CONFIRMED로 + crystalLogRepository.markConfirmed(logId); + } + + public void confirmMonthlyText( + Long reportId, + MonthlyReportV2Content content, + TypeTextContent emotionSummaryContent, + TypeEmotionStatsContent emotionStats, + InterestStatsContent interestStats + ) { + MonthlyReportV2Content contentNormalized = content.normalized(); + TypeTextContent emotionSummaryContentNormalized = emotionSummaryContent.normalized(); + InterestStatsContent interestStatsNormalized = interestStats.normalized(); + + String summary = contentNormalized.summary(); + String commentSummary = contentNormalized.commentSummary(); + String dominantKeyword = contentNormalized.dominantKeyword(); + + String contentJson; + String emotionSummaryContentJson; + String emotionStatsJson; + String interestStatsJson; + + try { + contentJson = objectMapper.writeValueAsString(contentNormalized); + emotionSummaryContentJson = objectMapper.writeValueAsString(emotionSummaryContentNormalized); + emotionStatsJson = objectMapper.writeValueAsString(emotionStats.normalized()); + interestStatsJson = objectMapper.writeValueAsString(interestStatsNormalized); + } catch (Exception e) { + throw new AiResponseParseException(ErrorCode.AI_RESPONSE_PARSE_FAILED); + } + monthlyReportV2Repository.updateContent( + reportId, + contentJson, + emotionSummaryContentJson, + summary, + commentSummary, + dominantKeyword, + emotionStatsJson, + interestStatsJson, + MonthlyReportStatus.TEXT_COMPLETED.name() + ); + } + + public void failAndRefundMonthly(Long userId, Long reportId, Long logId) { + monthlyReportV2Repository.markFailed(reportId, MonthlyReportStatus.FAILED); + + // 환불(+cost) + int updated = userWalletRepository.refund(userId, MONTHLY_REPORT_COST); + if (updated == 0) { + // wallet이 없을 수 있는 상황 + throw new NotFoundException(ErrorCode.WALLET_NOT_FOUND); + } + + // log를 REFUNDED로 + crystalLogRepository.markRefunded(logId); + } + + public void markMonthlyImageProcessing(Long reportId) { + monthlyReportV2Repository.updateImageStatus(reportId, MonthlyReportImageStatus.PROCESSING); + } + + public void failMonthlyImage(Long reportId) { + monthlyReportV2Repository.updateImageStatus(reportId, MonthlyReportImageStatus.FAILED); + } + + public void failAndRefundMonthlyWithImage(Long userId, Long reportId, Long logId) { + this.failAndRefundMonthly(userId, reportId, logId); + this.failMonthlyImage(reportId); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyInterestStatsCalculator.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyInterestStatsCalculator.java new file mode 100644 index 00000000..725819d4 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyInterestStatsCalculator.java @@ -0,0 +1,42 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application.helper; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.InterestStatsContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.InterestStatsCountDto; + +import java.util.Comparator; +import java.util.List; + +public final class MonthlyInterestStatsCalculator { + + private MonthlyInterestStatsCalculator() { + } + + public static InterestStatsContent calculate(List rows) { + if (rows == null || rows.isEmpty()) { + return new InterestStatsContent(List.of()).normalized(); + } + + List interests = rows.stream() + .filter(r -> r != null && r.interestCode() != null) + .sorted(Comparator + .comparingLong((InterestStatsCountDto r) -> safeLong(r.count())).reversed() + .thenComparing(r -> r.interestCode().name())) + .map(r -> new InterestStatsContent.InterestStat( + r.interestCode().name(), + r.interestName(), + safeInt(r.count()) + )) + .toList(); + + return new InterestStatsContent(interests).normalized(); + } + + private static long safeLong(Long value) { + return value == null ? 0L : Math.max(0L, value); + } + + private static int safeInt(Long value) { + long v = safeLong(value); + return v > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) v; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java index f2c55114..ea1ab71d 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java @@ -79,7 +79,7 @@ public void handle(MonthlyReportGenerationRequestedEventDto event) { // 월간 리포트 완성 이벤트 발행 eventPublisher.publishEvent( - new MonthlyReportCompletedEvent(event.reportId(), event.userId()) + new MonthlyReportCompletedEvent(event.reportId(), event.userId()) ); } catch (Exception e) { @@ -103,4 +103,3 @@ private String cut(String s) { return (s.length() <= MAX_LEN) ? s : s.substring(0, MAX_LEN); } } - diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListenerV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListenerV2.java new file mode 100644 index 00000000..16ff49d6 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListenerV2.java @@ -0,0 +1,197 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application.listener; + +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.application.MonthlyReportTxServiceV2; +import com.devkor.ifive.nadab.domain.monthlyreport.application.event.MonthlyReportCompletedEvent; +import com.devkor.ifive.nadab.domain.monthlyreport.application.helper.MonthlyInterestStatsCalculator; +import com.devkor.ifive.nadab.domain.monthlyreport.application.helper.MonthlyRepresentativePicker; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.InterestStatsContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.AiMonthlyReportResultDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportGenerationRequestedEventDtoV2; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyQueryRepository; +import com.devkor.ifive.nadab.domain.monthlyreport.core.service.MonthlyWeeklySummariesService; +import com.devkor.ifive.nadab.domain.monthlyreport.infra.MonthlyReportImageStorage; +import com.devkor.ifive.nadab.domain.monthlyreport.infra.MonthlyReportLlmClientV2; +import com.devkor.ifive.nadab.domain.monthlyreport.infra.OpenAiImageClient; +import com.devkor.ifive.nadab.domain.typereport.application.helper.TypeEmotionStatsCalculator; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; +import com.devkor.ifive.nadab.domain.weeklyreport.application.helper.WeeklyEntriesAssembler; +import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; +import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +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.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class MonthlyReportGenerationListenerV2 { + + private final MonthlyQueryRepository monthlyQueryRepository; + + private final MonthlyReportLlmClientV2 monthlyReportLlmClientV2; + private final OpenAiImageClient openAiImageClient; + private final MonthlyReportImageStorage monthlyReportImageStorage; + + private final MonthlyReportTxServiceV2 monthlyReportTxServiceV2; + private final MonthlyWeeklySummariesService monthlyWeeklySummariesService; + private final ApplicationEventPublisher eventPublisher; + + @Async("monthlyReportTaskExecutor") + @TransactionalEventListener(phase = + TransactionPhase.AFTER_COMMIT) + public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) { + + MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); + + // 1. 일간 리포트 대표 항목 선택 + List rows = monthlyQueryRepository.findMonthlyInputs(event.userId(), range.monthStartDate(), range.monthEndDate()); + List entries = MonthlyRepresentativePicker.pick(rows, 6); + String representativeEntries = WeeklyEntriesAssembler.assemble(entries); + + // 2. 주간 리포트 선택 + String weeklySummaries = monthlyWeeklySummariesService.buildWeeklySummaries(event.userId(), range); + + // 3. 해당 월(COMPLETED DailyReport) 감정 통계 집계 + TypeEmotionStatsContent emotionStats; + try { + emotionStats = TypeEmotionStatsCalculator.calculate( + monthlyQueryRepository.countCompletedEmotionStatsByRange( + event.userId(), + DailyReportStatus.COMPLETED, + range.monthStartDate(), + range.monthEndDate() + ) + ); + } catch (Exception e) { + log.error("[MONTHLY_REPORT][EMOTION_STATS_FAILED] userId={}, reportId={}", + event.userId(), event.reportId(), e); + monthlyReportTxServiceV2.failAndRefundMonthly( + event.userId(), + event.reportId(), + event.crystalLogId() + ); + return; + } + + InterestStatsContent interestStats; + try { + interestStats = MonthlyInterestStatsCalculator.calculate( + monthlyQueryRepository.countCompletedInterestStatsByRange( + event.userId(), + DailyReportStatus.COMPLETED, + range.monthStartDate(), + range.monthEndDate() + ) + ); + } catch (Exception e) { + log.error("[MONTHLY_REPORT][INTEREST_STATS_FAILED] userId={}, reportId={}", + event.userId(), event.reportId(), e); + monthlyReportTxServiceV2.failAndRefundMonthly( + event.userId(), + event.reportId(), + event.crystalLogId() + ); + return; + } + + AiMonthlyReportResultDto dto; + try { + // 트랜잭션 밖(백그라운드)에서 LLM 호출 + dto = monthlyReportLlmClientV2.generate( + range.monthStartDate().toString(), + range.monthEndDate().toString(), + weeklySummaries, + representativeEntries, + emotionStats, + event.exists()); + } catch (Exception e) { + log.error("[MONTHLY_REPORT][LLM_FAILED] userId={}, reportId={}", + event.userId(), event.reportId(), e); + + // 실패 확정 + 환불은 별도 트랜잭션에서 + monthlyReportTxServiceV2.failAndRefundMonthly( + event.userId(), + event.reportId(), + event.crystalLogId() + ); + return; + } + + // 텍스트 생성 성공 확정(별도 트랜잭션) + try { + monthlyReportTxServiceV2.confirmMonthlyText( + event.reportId(), + dto.content(), + dto.emotionSummaryContent(), + emotionStats, + interestStats + ); + + } catch (Exception e) { + log.error("[MONTHLY_REPORT][TEXT_CONFIRM_FAILED] userId={}, reportId={}, crystalLogId={}", + event.userId(), event.reportId(), event.crystalLogId(), e); + + // 저장 실패면 결과를 못 주는 거니까 "실패 확정 + 환불"로 처리 + monthlyReportTxServiceV2.failAndRefundMonthly( + event.userId(), + event.reportId(), + event.crystalLogId() + ); + return; + } + + String imageKey = ""; + try { + String base64Image = openAiImageClient.generateBase64Image(event.userId(), dto, range); + imageKey = monthlyReportImageStorage.uploadBase64Webp( + event.userId(), + event.reportId(), + base64Image + ); + + } catch (Exception e) { + log.error("[MONTHLY_REPORT][IMAGE_FAILED] userId={}, reportId={}, crystalLogId={}", + event.userId(), event.reportId(), event.crystalLogId(), e); + + monthlyReportTxServiceV2.failAndRefundMonthlyWithImage( + event.userId(), + event.reportId(), + event.crystalLogId() + ); + return; + } + + try { + monthlyReportTxServiceV2.confirmMonthly( + event.reportId(), + event.crystalLogId(), + imageKey + ); + + // 월간 리포트 완성 이벤트 발행 + eventPublisher.publishEvent( + new MonthlyReportCompletedEvent(event.reportId(), event.userId()) + ); + + } catch (Exception e) { + log.error("[MONTHLY_REPORT][CONFIRM_FAILED] userId={}, reportId={}, crystalLogId={}", + event.userId(), event.reportId(), event.crystalLogId(), e); + + // 저장 실패면 결과를 못 주는 거니까 "실패 확정 + 환불"로 처리 + monthlyReportTxServiceV2.failAndRefundMonthly( + event.userId(), + event.reportId(), + event.crystalLogId() + ); + } + } +} + diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/InterestStatsContent.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/InterestStatsContent.java new file mode 100644 index 00000000..9cfa6b90 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/InterestStatsContent.java @@ -0,0 +1,53 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.content; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +public record InterestStatsContent( + List interests +) { + public InterestStatsContent normalized() { + return new InterestStatsContent(List.copyOf(normalizeInterests(interests))); + } + + private static List normalizeInterests(List source) { + if (source == null || source.isEmpty()) { + return List.of(); + } + + List out = new ArrayList<>(); + for (InterestStat stat : source) { + if (stat == null) { + continue; + } + out.add(new InterestStat( + trimToNull(stat.interestCode()), + trimToNull(stat.interestName()), + normalizeNonNegative(stat.count()) + )); + } + + out.sort(Comparator.comparingInt((InterestStat stat) -> stat.count() == null ? 0 : stat.count()).reversed()); + return out; + } + + private static int normalizeNonNegative(Integer value) { + return value == null ? 0 : Math.max(0, value); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + public record InterestStat( + String interestCode, + String interestName, + Integer count + ) { + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlyContentFactory.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlyContentFactory.java new file mode 100644 index 00000000..22292640 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlyContentFactory.java @@ -0,0 +1,13 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.content; + +import java.util.List; + +public final class MonthlyContentFactory { + + private MonthlyContentFactory() { + } + + public static InterestStatsContent emptyInterestStats() { + return new InterestStatsContent(List.of()).normalized(); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlyReportV2ContentFactory.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlyReportV2ContentFactory.java new file mode 100644 index 00000000..11bf2986 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlyReportV2ContentFactory.java @@ -0,0 +1,23 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.content; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2Content; +import com.devkor.ifive.nadab.global.shared.reportcontent.Segment; +import com.devkor.ifive.nadab.global.shared.reportcontent.StyledText; + +import java.util.List; + +public final class MonthlyReportV2ContentFactory { + + private MonthlyReportV2ContentFactory() {} + + public static MonthlyReportV2Content empty() { + StyledText oneEmpty = new StyledText( + List.of(new Segment("", List.of())) + ); + return new MonthlyReportV2Content("", "", "", "", oneEmpty, oneEmpty); + } + + public static StyledText fromPlain(String text) { + return new StyledText(List.of(new Segment(text == null ? "" : text, List.of()))); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/AiMonthlyReportResultDto.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/AiMonthlyReportResultDto.java new file mode 100644 index 00000000..59a03722 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/AiMonthlyReportResultDto.java @@ -0,0 +1,10 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.dto; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2Content; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeTextContent; + +public record AiMonthlyReportResultDto( + MonthlyReportV2Content content, + TypeTextContent emotionSummaryContent +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/InterestStatsCountDto.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/InterestStatsCountDto.java new file mode 100644 index 00000000..976362d3 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/InterestStatsCountDto.java @@ -0,0 +1,10 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.dto; + +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; + +public record InterestStatsCountDto( + InterestCode interestCode, + String interestName, + Long count +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportGenerationRequestedEventDtoV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportGenerationRequestedEventDtoV2.java new file mode 100644 index 00000000..a93f3358 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportGenerationRequestedEventDtoV2.java @@ -0,0 +1,9 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.dto; + +public record MonthlyReportGenerationRequestedEventDtoV2( + Long reportId, + Long userId, + Long crystalLogId, + boolean exists +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/OpenAiImageGenerateRequestDto.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/OpenAiImageGenerateRequestDto.java new file mode 100644 index 00000000..b7f07b4c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/OpenAiImageGenerateRequestDto.java @@ -0,0 +1,12 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.dto; + +public record OpenAiImageGenerateRequestDto( + String model, + String prompt, + String size, + String quality, + String output_format, + Integer n, + String user +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/OpenAiImageGenerateResponseDto.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/OpenAiImageGenerateResponseDto.java new file mode 100644 index 00000000..05edb753 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/OpenAiImageGenerateResponseDto.java @@ -0,0 +1,27 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.dto; + +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; + +import java.util.List; + +public record OpenAiImageGenerateResponseDto( + Long created, + List data +) { + public record ImageData( + String b64_json + ) { + } + + public String firstBase64Image() { + if (data == null || data.isEmpty()) { + throw new AiResponseParseException(ErrorCode.AI_RESPONSE_PARSE_FAILED); + } + String base64 = data.get(0).b64_json(); + if (base64 == null || base64.isBlank()) { + throw new AiResponseParseException(ErrorCode.AI_RESPONSE_PARSE_FAILED); + } + return base64; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportComparisonType.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportComparisonType.java new file mode 100644 index 00000000..65c6a1d7 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportComparisonType.java @@ -0,0 +1,6 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.entity; + +public enum MonthlyReportComparisonType { + COMPARISON, + BASELINE +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportImageStatus.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportImageStatus.java new file mode 100644 index 00000000..46185885 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportImageStatus.java @@ -0,0 +1,8 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.entity; + +public enum MonthlyReportImageStatus { + PENDING, + PROCESSING, + COMPLETED, + FAILED +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportStatus.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportStatus.java index ed88e74a..90aac030 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportStatus.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportStatus.java @@ -2,6 +2,7 @@ public enum MonthlyReportStatus { PENDING, + TEXT_COMPLETED, COMPLETED, FAILED, IN_PROGRESS diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportV2.java new file mode 100644 index 00000000..ae717af1 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportV2.java @@ -0,0 +1,131 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.entity; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.InterestStatsContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyContentFactory; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyReportV2ContentFactory; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeContentFactory; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeTextContent; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.shared.entity.CreatableEntity; +import io.hypersistence.utils.hibernate.type.json.JsonType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Type; + +import java.time.LocalDate; +import java.time.OffsetDateTime; + +@Entity +@Table( + name = "monthly_reports_v2", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_monthly_reports_v2_user_month", + columnNames = {"user_id", "month_start_date"} + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MonthlyReportV2 extends CreatableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "month_start_date", nullable = false) + private LocalDate monthStartDate; + + @Column(name = "month_end_date", nullable = false) + private LocalDate monthEndDate; + + @Column(name = "date", nullable = false) + private LocalDate date; + + @Column(name = "image_key", length = 255) + private String imageKey; + + @Enumerated(EnumType.STRING) + @Column(name = "image_status", nullable = false, length = 16) + private MonthlyReportImageStatus imageStatus; + + @Type(JsonType.class) + @Column(name = "content", columnDefinition = "jsonb", nullable = false) + private MonthlyReportV2Content content; + + @Type(JsonType.class) + @Column(name = "emotion_summary_content", columnDefinition = "jsonb", nullable = false) + private TypeTextContent emotionSummaryContent; + + @Type(JsonType.class) + @Column(name = "emotion_stats", columnDefinition = "jsonb", nullable = false) + private TypeEmotionStatsContent emotionStats; + + @Type(JsonType.class) + @Column(name = "interest_stats", columnDefinition = "jsonb", nullable = false) + private InterestStatsContent interestStats; + + @Column(name = "summary", nullable = false, length = 80) + private String summary; + + @Column(name = "comment_summary", nullable = false, length = 80) + private String commentSummary; + + @Column(name = "dominant_keyword", nullable = false, length = 30) + private String dominantKeyword; + + @Enumerated(EnumType.STRING) + @Column(name = "comparison_type", nullable = false, length = 20) + private MonthlyReportComparisonType comparisonType; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 16) + private MonthlyReportStatus status; + + @Column(name = "analyzed_at") + private OffsetDateTime analyzedAt; + + public static MonthlyReportV2 create( + User user, LocalDate monthStartDate, LocalDate monthEndDate, + MonthlyReportV2Content content, + LocalDate date, MonthlyReportStatus status, + MonthlyReportImageStatus imageStatus, + MonthlyReportComparisonType comparisonType + ) { + MonthlyReportV2 mr = new MonthlyReportV2(); + mr.user = user; + mr.monthStartDate = monthStartDate; + mr.monthEndDate = monthEndDate; + + MonthlyReportV2Content normalized = (content == null) ? MonthlyReportV2ContentFactory.empty() : content.normalized(); + mr.content = normalized; + + mr.summary = normalized.summary(); + mr.commentSummary = normalized.commentSummary(); + mr.dominantKeyword = normalized.dominantKeyword(); + + mr.emotionSummaryContent = TypeContentFactory.emptyText().normalized(); + mr.emotionStats = TypeContentFactory.emptyEmotionStats(); + mr.interestStats = MonthlyContentFactory.emptyInterestStats(); + + mr.comparisonType = comparisonType; + mr.date = date; + mr.imageStatus = imageStatus; + mr.status = status; + return mr; + } + + public static MonthlyReportV2 createPending( + User user, LocalDate monthStartDate, LocalDate monthEndDate, LocalDate date, MonthlyReportComparisonType comparisonType + ) { + return create(user, monthStartDate, monthEndDate, MonthlyReportV2ContentFactory.empty(), date, + MonthlyReportStatus.PENDING, MonthlyReportImageStatus.PENDING, comparisonType); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportV2Content.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportV2Content.java new file mode 100644 index 00000000..a8aca698 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportV2Content.java @@ -0,0 +1,22 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.entity; + +import com.devkor.ifive.nadab.global.shared.reportcontent.StyledText; + +public record MonthlyReportV2Content( + String summary, + String commentSummary, + String dominantKeyword, + String emotionTrend, + StyledText discovered, + StyledText comment +) { + public MonthlyReportV2Content normalized() { + String s = (summary == null) ? "" : summary.trim(); + String cs = (commentSummary == null) ? "" : commentSummary.trim(); + String dk = (dominantKeyword == null) ? "" : dominantKeyword.trim(); + String et = (emotionTrend == null) ? "" : emotionTrend.trim(); + StyledText d = discovered == null ? new StyledText(java.util.List.of()) : discovered.normalized(); + StyledText c = comment == null ? new StyledText(java.util.List.of()) : comment.normalized(); + return new MonthlyReportV2Content(s, cs, dk, et, d, c); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyQueryRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyQueryRepository.java index cc82e6f4..cb5b38e3 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyQueryRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyQueryRepository.java @@ -1,6 +1,9 @@ package com.devkor.ifive.nadab.domain.monthlyreport.core.repository; +import com.devkor.ifive.nadab.domain.dailyreport.core.dto.EmotionStatsCountDto; import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry; +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.InterestStatsCountDto; import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -37,4 +40,51 @@ List findMonthlyInputs( @Param("monthStart") LocalDate monthStart, @Param("monthEnd") LocalDate monthEnd ); + + @Query(""" + select new com.devkor.ifive.nadab.domain.dailyreport.core.dto.EmotionStatsCountDto( + e.code, + e.name, + count(dr.id) + ) + from Emotion e + left join DailyReport dr + on dr.emotion = e + and dr.answerEntry.user.id = :userId + and dr.status = :status + and dr.date between :monthStart and :monthEnd + group by e.code, e.name + """) + List countCompletedEmotionStatsByRange( + @Param("userId") Long userId, + @Param("status") DailyReportStatus status, + @Param("monthStart") LocalDate monthStart, + @Param("monthEnd") LocalDate monthEnd + ); + + @Query(""" + select new com.devkor.ifive.nadab.domain.monthlyreport.core.dto.InterestStatsCountDto( + i.code, + i.name, + count(dr.id) + ) + from Interest i + left join DailyQuestion dq + on dq.interest = i + left join AnswerEntry ae + on ae.question = dq + and ae.user.id = :userId + and ae.date between :monthStart and :monthEnd + left join DailyReport dr + on dr.answerEntry = ae + and dr.status = :status + and dr.date between :monthStart and :monthEnd + group by i.code, i.name + """) + List countCompletedInterestStatsByRange( + @Param("userId") Long userId, + @Param("status") DailyReportStatus status, + @Param("monthStart") LocalDate monthStart, + @Param("monthEnd") LocalDate monthEnd + ); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyReportRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyReportRepository.java index d1309369..8b581790 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyReportRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyReportRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.repository.query.Param; import java.time.LocalDate; +import java.util.List; import java.util.Optional; public interface MonthlyReportRepository extends JpaRepository { @@ -20,6 +21,8 @@ Optional findByUserIdAndMonthStartDateAndStatus( MonthlyReportStatus status ); + List findAllByUserIdAndStatus(Long userId, MonthlyReportStatus status); + /** * PENDING -> COMPLETED 확정 * - 분석 결과(discovered/improve) 저장 diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyReportV2Repository.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyReportV2Repository.java new file mode 100644 index 00000000..9b7e6508 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyReportV2Repository.java @@ -0,0 +1,102 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.repository; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportImageStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface MonthlyReportV2Repository extends JpaRepository { + + boolean existsByUserIdAndStatus(Long userId, MonthlyReportStatus status); + + Optional findByUserIdAndMonthStartDate(Long userId, LocalDate monthStartDate); + + Optional findByUserIdAndMonthStartDateAndStatus( + Long userId, + LocalDate monthStartDate, + MonthlyReportStatus status + ); + + List findAllByUserIdAndStatus(Long userId, MonthlyReportStatus status); + + /** + * PENDING -> FAILED 확정 + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE MonthlyReportV2 mr + SET mr.status = :status, + mr.analyzedAt = CURRENT_TIMESTAMP + WHERE mr.id = :reportId +""") + int markFailed( + @Param("reportId") Long reportId, + @Param("status") MonthlyReportStatus status + ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE MonthlyReportV2 m SET m.status = :status WHERE m.id = :id") + int updateStatus( + @Param("id") Long id, + @Param("status") MonthlyReportStatus status + ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + UPDATE monthly_reports_v2 + SET content = CAST(:contentJson AS jsonb), + emotion_summary_content = CAST(:emotionSummaryContentJson AS jsonb), + emotion_stats = CAST(:emotionStatsJson AS jsonb), + interest_stats = CAST(:interestStatsJson AS jsonb), + summary = :summary, + comment_summary = :commentSummary, + dominant_keyword = :dominantKeyword, + status = :status, + analyzed_at = CURRENT_TIMESTAMP + WHERE id = :reportId +""", nativeQuery = true) + int updateContent( + @Param("reportId") Long reportId, + @Param("contentJson") String contentJson, + @Param("emotionSummaryContentJson") String emotionSummaryContentJson, + @Param("summary") String summary, + @Param("commentSummary") String commentSummary, + @Param("dominantKeyword") String dominantKeyword, + @Param("emotionStatsJson") String emotionStatsJson, + @Param("interestStatsJson") String interestStatsJson, + @Param("status") String status + ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE MonthlyReportV2 mr + SET mr.imageStatus = :imageStatus + WHERE mr.id = :reportId +""") + int updateImageStatus( + @Param("reportId") Long reportId, + @Param("imageStatus") MonthlyReportImageStatus imageStatus + ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE MonthlyReportV2 mr + SET mr.imageKey = :imageKey, + mr.imageStatus = :imageStatus, + mr.status = :status + WHERE mr.id = :reportId +""") + int completeWithImage( + @Param("reportId") Long reportId, + @Param("imageKey") String imageKey, + @Param("imageStatus") MonthlyReportImageStatus imageStatus, + @Param("status") MonthlyReportStatus status + ); +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlyReportCrossVersionGuardService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlyReportCrossVersionGuardService.java new file mode 100644 index 00000000..461e3076 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlyReportCrossVersionGuardService.java @@ -0,0 +1,45 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.service; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportRepository; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ConflictException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; + +@Service +@RequiredArgsConstructor +public class MonthlyReportCrossVersionGuardService { + + private final MonthlyReportRepository monthlyReportRepository; + private final MonthlyReportV2Repository monthlyReportV2Repository; + + public void validateCreatableForV1(Long userId, LocalDate monthStartDate) { + monthlyReportV2Repository.findByUserIdAndMonthStartDate(userId, monthStartDate) + .ifPresent(reportV2 -> { + if (reportV2.getStatus() == MonthlyReportStatus.COMPLETED) { + throw new ConflictException(ErrorCode.MONTHLY_REPORT_ALREADY_COMPLETED); + } + + if (reportV2.getStatus() != MonthlyReportStatus.FAILED) { + throw new ConflictException(ErrorCode.MONTHLY_REPORT_IN_PROGRESS); + } + }); + } + + public void validateCreatableForV2(Long userId, LocalDate monthStartDate) { + monthlyReportRepository.findByUserIdAndMonthStartDate(userId, monthStartDate) + .ifPresent(report -> { + if (report.getStatus() == MonthlyReportStatus.COMPLETED) { + throw new ConflictException(ErrorCode.MONTHLY_REPORT_ALREADY_COMPLETED); + } + + if (report.getStatus() != MonthlyReportStatus.FAILED) { + throw new ConflictException(ErrorCode.MONTHLY_REPORT_IN_PROGRESS); + } + }); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportService.java index 7d6707a0..eab8e963 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportService.java @@ -20,6 +20,7 @@ public class PendingMonthlyReportService { private final MonthlyReportRepository monthlyReportRepository; + private final MonthlyReportCrossVersionGuardService monthlyReportCrossVersionGuardService; @Transactional public MonthlyReport getOrCreatePendingMonthlyReport(User user) { @@ -27,6 +28,8 @@ public MonthlyReport getOrCreatePendingMonthlyReport(User user) { MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); LocalDate today = TodayDateTimeProvider.getTodayDate(); + monthlyReportCrossVersionGuardService.validateCreatableForV1(user.getId(), range.monthStartDate()); + MonthlyReport report = monthlyReportRepository.findByUserIdAndMonthStartDate(user.getId(), range.monthStartDate()) .orElseGet(() -> monthlyReportRepository.save(MonthlyReport.createPending( user, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportServiceV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportServiceV2.java new file mode 100644 index 00000000..7e6bf9de --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportServiceV2.java @@ -0,0 +1,60 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.service; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportComparisonType; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ConflictException; +import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@Service +@RequiredArgsConstructor +public class PendingMonthlyReportServiceV2 { + + private final MonthlyReportV2Repository monthlyReportV2Repository; + private final MonthlyReportCrossVersionGuardService monthlyReportCrossVersionGuardService; + + @Transactional + public MonthlyReportV2 getOrCreatePendingMonthlyReport(User user, boolean exists) { + + MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); + LocalDate today = TodayDateTimeProvider.getTodayDate(); + + monthlyReportCrossVersionGuardService.validateCreatableForV2(user.getId(), range.monthStartDate()); + + MonthlyReportComparisonType comparisonType = exists ? MonthlyReportComparisonType.COMPARISON : MonthlyReportComparisonType.BASELINE; + + MonthlyReportV2 report = monthlyReportV2Repository.findByUserIdAndMonthStartDate(user.getId(), range.monthStartDate()) + .orElseGet(() -> monthlyReportV2Repository.save(MonthlyReportV2.createPending( + user, + range.monthStartDate(), + range.monthEndDate(), + today, + comparisonType + ))); + + if (report.getStatus() == MonthlyReportStatus.COMPLETED) { + throw new ConflictException(ErrorCode.MONTHLY_REPORT_ALREADY_COMPLETED); + } + + if (report.getStatus() == MonthlyReportStatus.IN_PROGRESS + || report.getStatus() == MonthlyReportStatus.TEXT_COMPLETED) { + throw new ConflictException(ErrorCode.MONTHLY_REPORT_IN_PROGRESS); + } + + if (report.getStatus() == MonthlyReportStatus.FAILED) { + monthlyReportV2Repository.updateStatus(report.getId(), MonthlyReportStatus.PENDING); + } + + return report; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportImageStorage.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportImageStorage.java new file mode 100644 index 00000000..82e09e52 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportImageStorage.java @@ -0,0 +1,49 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.infra; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.util.Base64; + +@Component +@RequiredArgsConstructor + +public class MonthlyReportImageStorage { + + private final S3Client s3Client; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + @Value("${profile-image.env}") + private String env; + + public String uploadBase64Webp(Long userId, Long reportId, String base64Image) { + byte[] imageBytes = Base64.getDecoder().decode(base64Image); + + String key = buildKey(userId, reportId); + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType("image/webp") + .cacheControl("public, max-age=31536000, immutable") + .build(); + + s3Client.putObject( + putObjectRequest, + RequestBody.fromBytes(imageBytes) + ); + + return key; + } + + private String buildKey(Long userId, Long reportId) { + return "%s/reports/monthly/images/%d/%d.webp" + .formatted(env, userId, reportId); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClient.java index ff4e6386..50f66e8c 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClient.java @@ -48,7 +48,7 @@ public class MonthlyReportLlmClient { public AiReportResultDto generate( String monthStartDate, String monthEndDate, String weeklySummaries, String representativeEntries) { - String prompt = monthlyReportPromptLoader.loadPrompt() + String prompt = monthlyReportPromptLoader.loadV1Prompt() .replace("{monthStartDate}", monthStartDate) .replace("{monthEndDate}", monthEndDate) .replace("{weeklySummaries}", weeklySummaries) diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClientV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClientV2.java new file mode 100644 index 00000000..8df31110 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClientV2.java @@ -0,0 +1,332 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.infra; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.AiMonthlyReportResultDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2Content; +import com.devkor.ifive.nadab.domain.typereport.application.helper.TypeReportInputAssembler; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeTextContent; +import com.devkor.ifive.nadab.global.core.prompt.monthly.MonthlyReportPromptLoader; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; +import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; +import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; +import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; +import com.devkor.ifive.nadab.global.shared.reportcontent.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.anthropic.AnthropicChatOptions; +import org.springframework.ai.anthropic.api.AnthropicApi; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.google.genai.GoogleGenAiChatModel; +import org.springframework.ai.google.genai.GoogleGenAiChatOptions; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.stereotype.Component; + +import java.util.EnumSet; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class MonthlyReportLlmClientV2 { + + private final MonthlyReportPromptLoader monthlyReportPromptLoader; + private final ObjectMapper objectMapper; + private final LlmRouter llmRouter; + + private final LlmProvider provider = LlmProvider.GEMINI; + private static final LlmProvider REWRITE_PROVIDER = LlmProvider.GEMINI; + + private static final int MAX_DISCOVERED = 400; + private static final int MIN_DISCOVERED = 150; + + private static final int MAX_EMOTION = 200; + private static final int MIN_EMOTION = 50; + + private static final int MIN_SUMMARY = 8; + private static final int MAX_SUMMARY = 30; + + public AiMonthlyReportResultDto generate( + String monthStartDate, + String monthEndDate, + String weeklySummaries, + String representativeEntries, + TypeEmotionStatsContent emotionStats, + boolean exists) { + String prompt = monthlyReportPromptLoader.loadV2BaselinePrompt() + .replace("{monthStartDate}", monthStartDate) + .replace("{monthEndDate}", monthEndDate) + .replace("{weeklySummaries}", weeklySummaries) + .replace("{representativeEntries}", representativeEntries == null ? "" : representativeEntries) + .replace("{emotionStats}", TypeReportInputAssembler.assembleEmotionStats(emotionStats)); + + ChatClient client = llmRouter.route(provider); + + String content = switch (provider) { + case OPENAI -> callOpenAi(client, prompt); + case CLAUDE -> callClaude(client, prompt); + case GEMINI -> callGemini(client, prompt); + }; + + if (content == null || content.trim().isEmpty()) { + throw new AiServiceUnavailableException(ErrorCode.AI_NO_RESPONSE); + } + + try { + AiMonthlyReportResultDto result; + try { + result = objectMapper.readValue(content, AiMonthlyReportResultDto.class); + } catch (Exception e) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_JSON_MAPPING_FAILED); + } + + MonthlyReportV2Content monthlyReportContent = result.content(); + + StyledText discoveredStyled = monthlyReportContent.discovered(); + StyledText commentStyled = monthlyReportContent.comment(); + String summary = monthlyReportContent.summary(); + String commentSummary = monthlyReportContent.commentSummary(); + String dominantKeyword = monthlyReportContent.dominantKeyword(); + String emotionTrend = monthlyReportContent.emotionTrend(); + + TypeTextContent emotionStatsContent = result.emotionSummaryContent(); + StyledText styledText = emotionStatsContent.styledText(); + + if (discoveredStyled == null || commentStyled == null || styledText == null + || isBlank(summary) || isBlank(commentSummary) || isBlank(dominantKeyword) || isBlank(emotionTrend)) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_JSON_MISSING_FIELDS); + } + + validateSummary(summary); + validateSummary(commentSummary); + + validateStyledText(discoveredStyled, true); + validateStyledText(commentStyled, true); + validateStyledText(styledText, false); + + String discovered = monthlyReportContent.discovered().plainText(); + String comment = monthlyReportContent.comment().plainText(); + String emotion = emotionStatsContent.styledText().plainText(); + + if (isBlank(discovered) || isBlank(comment) || isBlank(emotion)) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_JSON_MISSING_FIELDS); + } + + validateLength(discovered, comment, emotion); + + return result; + + } catch (AiResponseParseException e) { + throw e; + } catch (Exception e) { + throw new AiResponseParseException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + private String callOpenAi(ChatClient client, String prompt) { + OpenAiChatOptions options = OpenAiChatOptions.builder() + .model(OpenAiApi.ChatModel.GPT_5_MINI) + .reasoningEffort("medium") + .temperature(1.0) + .build(); + + return client.prompt() + .user(prompt) + .options(options) + .call() + .content(); + } + + private String callClaude(ChatClient client, String prompt) { + AnthropicChatOptions options = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_3_HAIKU) + .temperature(0.3) + .build(); + + return client.prompt() + .user(prompt) + .options(options) + .call() + .content(); + } + + private String callGemini(ChatClient client, String prompt) { + GoogleGenAiChatOptions options = GoogleGenAiChatOptions.builder() + .model(GoogleGenAiChatModel.ChatModel.GEMINI_2_5_FLASH) + .responseMimeType("application/json") + .temperature(0.3) + .build(); + + return client.prompt() + .user(prompt) + .options(options) + .call() + .content(); + } + + /* + private AiReportResultDto enforceLength(AiReportResultDto dto) { + ReportContent c = dto.content(); + + int dLen = dto.discovered().length(); + int iLen = dto.improve().length(); + + boolean badD = dLen < MIN_DISCOVERED || dLen > MAX_DISCOVERED; + boolean badI = iLen < MIN_IMPROVE || iLen > MAX_IMPROVE; + + if (!badD && !badI) return dto; + + ChatClient rewriteClient = llmRouter.route(REWRITE_PROVIDER); + + StyledText d = c.discovered(); + StyledText i = c.improve(); + + if (badD) d = rewriteStyled(rewriteClient, d, true); + if (badI) i = rewriteStyled(rewriteClient, i, false); + + ReportContent newContent = new ReportContent(c.summary(), d, i); + String newD = newContent.discovered().plainText(); + String newI = newContent.improve().plainText(); + + try { + validateStyledText(newContent.discovered(), true); + validateStyledText(newContent.improve(), false); + } catch (AiResponseParseException e) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_REWRITE_FORMAT_INVALID); + } + + return new AiReportResultDto(newContent, newD, newI); + } + + private StyledText rewriteStyled(ChatClient client, StyledText in, boolean isDiscovered) { + int min = isDiscovered ? MIN_DISCOVERED : MIN_IMPROVE; + int max = isDiscovered ? MAX_DISCOVERED : MAX_IMPROVE; + int maxHl = isDiscovered ? MAX_HL_DISCOVERED : MAX_HL_IMPROVE; + + try { + String jsonInput = objectMapper.writeValueAsString(in); + + String prompt = """ + 아래 JSON의 segments[].text를 이어 붙인 '순수 텍스트'의 의미는 유지하면서, + 글자수(공백 포함)를 최소 %d자 ~ 최대 %d자로 맞춰 같은 구조로 다시 만들어 주세요. + + [반드시 지킬 것] + - 출력은 JSON 1개만: {"segments":[{"text":"...","marks":[...]} ... ]} + - marks는 "BOLD", "HIGHLIGHT"만 사용 + - "HIGHLIGHT"가 있으면 같은 segment에 "BOLD"도 반드시 포함 + - HIGHLIGHT segment 개수는 최대 %d개 + - 줄바꿈 금지(\\n, \\r 금지) + - 숫자/시간/빈도 표현 금지 + - 해요체 유지 + - 문학적 비유/감정 과잉/훈수/응원 나열 금지 + - 세그먼트는 3~8개 정도로 자연스럽게 구성 + + [입력 JSON] + %s + """.formatted(min + 10, max - 10, maxHl, jsonInput); + + GoogleGenAiChatOptions options = GoogleGenAiChatOptions.builder() + .model(GoogleGenAiChatModel.ChatModel.GEMINI_2_5_FLASH) + .responseMimeType("application/json") + .temperature(0.0) + .build(); + + String out = client.prompt().user(prompt).options(options).call().content(); + + if (out == null || out.isBlank()) { + throw new AiServiceUnavailableException(ErrorCode.AI_REWRITE_NO_RESPONSE); + } + + try { + return objectMapper.readValue(out, StyledText.class); + } catch (Exception e) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_REWRITE_JSON_MAPPING_FAILED); + } + + } catch (AiServiceUnavailableException e) { + throw e; + } catch (JsonProcessingException e) { + throw new AiResponseParseException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + */ + + private boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } + + private void validateStyledText(StyledText st, boolean isDiscovered) { + if (st.segments() == null || st.segments().isEmpty()) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_JSON_MISSING_FIELDS); + } + + for (Segment seg : st.segments()) { + if (seg == null || seg.text() == null || seg.text().isBlank()) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_SEGMENT_INVALID); + } + String t = seg.text(); + if (t.isBlank()) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_SEGMENT_INVALID); + } + if (t.contains("\n") || t.contains("\r")) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_SEGMENT_INVALID); + } + + List marks = seg.marks() == null ? List.of() : seg.marks(); + EnumSet set = EnumSet.noneOf(Mark.class); + for (Mark m : marks) { + if (m == null) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_SEGMENT_INVALID); + } + set.add(m); + } + + if (set.contains(Mark.HIGHLIGHT)) { + if (!set.contains(Mark.BOLD)) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_HIGHLIGHT_WITHOUT_BOLD); + } + } + } + } + + private void validateLength(String discovered, String comment, String emotion) { + int dLen = discovered.length(); + int cLen = comment.length(); + int eLen = emotion.length(); + + if (dLen < MIN_DISCOVERED || dLen > MAX_DISCOVERED) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_DISCOVERED_LENGTH_INVALID); + } + if (cLen < MIN_DISCOVERED || cLen > MAX_DISCOVERED) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_DISCOVERED_LENGTH_INVALID); + } + if (eLen < MIN_EMOTION || eLen > MAX_EMOTION) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_IMPROVE_LENGTH_INVALID); + } + } + + private void validateSummary(String summary) { + if (summary == null) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_SUMMARY_INVALID); + } + String s = summary.trim(); + if (s.length() < MIN_SUMMARY || s.length() > MAX_SUMMARY) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_SUMMARY_INVALID); + } + // 문장부호로 끝내지 않기 + if (s.endsWith(".") || s.endsWith("!") || s.endsWith("?")) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_SUMMARY_INVALID); + } + // 따옴표 금지(프론트가 처리) + if (s.contains("\"") || s.contains("'")) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_SUMMARY_INVALID); + } + // 숫자 금지(시간/빈도 방지) + for (int k = 0; k < s.length(); k++) { + if (Character.isDigit(s.charAt(k))) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_SUMMARY_INVALID); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/OpenAiImageClient.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/OpenAiImageClient.java new file mode 100644 index 00000000..57a9d551 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/OpenAiImageClient.java @@ -0,0 +1,123 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.infra; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.AiMonthlyReportResultDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.OpenAiImageGenerateRequestDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.OpenAiImageGenerateResponseDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2Content; +import com.devkor.ifive.nadab.global.core.prompt.monthly.MonthlyReportPromptLoader; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; +import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import org.springframework.web.reactive.function.client.WebClient; + +@Component +@RequiredArgsConstructor +@Slf4j +public class OpenAiImageClient { + + private final WebClient.Builder webClientBuilder; + private final MonthlyReportPromptLoader monthlyReportPromptLoader; + + @Value("${openai.api-key}") + private String apiKey; + + @Value("${openai.image.model}") + private String model; + + @Value("${openai.image.size}") + private String size; + + @Value("${openai.image.quality}") + private String quality; + + @Value("${openai.image.output-format}") + private String outputFormat; + + public String generateBase64Image(Long userId, AiMonthlyReportResultDto dto, MonthRangeDto range) { + MonthlyReportV2Content content = dto.content(); + + String prompt = monthlyReportPromptLoader.loadImagePrompt() + .formatted( + safe(content.summary()), + safe(content.commentSummary()), + safe(content.dominantKeyword()), + range.monthStartDate(), + range.monthEndDate() + ); + + OpenAiImageGenerateRequestDto request = new OpenAiImageGenerateRequestDto( + model, + prompt, + size, + quality, + outputFormat, + 1, + String.valueOf(userId) + ); + + ExchangeStrategies strategies = ExchangeStrategies.builder() + .codecs(configurer -> + configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024) + ) + .build(); + + OpenAiImageGenerateResponseDto response; + try { + response = webClientBuilder + .baseUrl("https://api.openai.com") + .exchangeStrategies(strategies) + .build() + .post() + .uri("/v1/images/generations") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .bodyValue(request) + .retrieve() + .bodyToMono(OpenAiImageGenerateResponseDto.class) + .block(); + } catch (WebClientResponseException e) { + log.error("[OPENAI_IMAGE][HTTP_ERROR] userId={}, status={}, responseBody={}", + userId, e.getStatusCode().value(), truncate(e.getResponseBodyAsString()), e); + throw new AiServiceUnavailableException(ErrorCode.AI_NO_RESPONSE); + } catch (Exception e) { + log.error("[OPENAI_IMAGE][CALL_FAILED] userId={}, message={}", userId, e.getMessage(), e); + throw new AiServiceUnavailableException(ErrorCode.AI_NO_RESPONSE); + } + + if (response == null) { + log.error("[OPENAI_IMAGE][EMPTY_RESPONSE] userId={}", userId); + throw new AiServiceUnavailableException(ErrorCode.AI_NO_RESPONSE); + } + + try { + return response.firstBase64Image(); + } catch (AiResponseParseException e) { + log.error("[OPENAI_IMAGE][PARSE_FAILED] userId={}, reason=invalid_data_shape", userId, e); + throw e; + } catch (Exception e) { + log.error("[OPENAI_IMAGE][PARSE_FAILED] userId={}, reason=unexpected_exception, message={}", + userId, e.getMessage(), e); + throw new AiResponseParseException(ErrorCode.AI_RESPONSE_PARSE_FAILED); + } + } + + private String safe(String value) { + return value == null || value.isBlank() ? "Not provided" : value; + } + + private String truncate(String value) { + if (value == null) { + return ""; + } + int max = 1200; + return value.length() <= max ? value : value.substring(0, max) + "...(truncated)"; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/social/CommentNotificationEventListener.java b/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/social/CommentNotificationEventListener.java new file mode 100644 index 00000000..2f0bc389 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/social/CommentNotificationEventListener.java @@ -0,0 +1,206 @@ +package com.devkor.ifive.nadab.domain.notification.application.event.social; + +import com.devkor.ifive.nadab.domain.comment.application.event.CommentCreatedEvent; +import com.devkor.ifive.nadab.domain.comment.application.event.SubCommentCreatedEvent; +import com.devkor.ifive.nadab.domain.comment.core.repository.CommentRepository; +import com.devkor.ifive.nadab.domain.moderation.core.repository.UserBlockRepository; +import com.devkor.ifive.nadab.domain.notification.application.NotificationCommandService; +import com.devkor.ifive.nadab.domain.notification.core.entity.NotificationType; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.global.core.notification.message.NotificationContent; +import com.devkor.ifive.nadab.global.core.notification.message.NotificationMessageFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CommentNotificationEventListener { + + private final UserRepository userRepository; + private final CommentRepository commentRepository; + private final UserBlockRepository userBlockRepository; + private final NotificationMessageFactory messageFactory; + private final NotificationCommandService notificationCommandService; + + @Async("notificationTaskExecutor") + @EventListener + public void handleCommentCreated(CommentCreatedEvent event) { + try { + if (event.getAuthorId().equals(event.getReportOwnerId())) { + log.debug("Self-comment, skip notification: commentId={}", event.getCommentId()); + return; + } + + User author = userRepository.findById(event.getAuthorId()).orElse(null); + if (author == null || author.getDeletedAt() != null) { + log.debug("Author not found or deleted, skip notification: authorId={}", event.getAuthorId()); + return; + } + + User reportOwner = userRepository.findById(event.getReportOwnerId()).orElse(null); + if (reportOwner == null || reportOwner.getDeletedAt() != null) { + log.debug("Report owner not found or deleted, skip notification: reportOwnerId={}", event.getReportOwnerId()); + return; + } + + Map params = Map.of( + "senderName", author.getNickname(), + "commentContent", truncate(event.getContent()) + ); + NotificationContent content = messageFactory.createMessage(NotificationType.COMMENT_ON_MY_REPORT, params); + + String idempotencyKey = String.format("COMMENT_%d_REPORT_OWNER", event.getCommentId()); + notificationCommandService.sendNotification( + event.getReportOwnerId(), + NotificationType.COMMENT_ON_MY_REPORT, + content.title(), + content.body(), + content.inboxMessage(), + event.getDailyReportId().toString(), + idempotencyKey + ); + log.debug("Comment notification sent: commentId={}, reportOwnerId={}", event.getCommentId(), event.getReportOwnerId()); + } catch (Exception e) { + log.error("Failed to handle CommentCreatedEvent: commentId={}, error={}", + event.getCommentId(), e.getMessage(), e); + } + } + + @Async("notificationTaskExecutor") + @EventListener + public void handleSubCommentCreated(SubCommentCreatedEvent event) { + try { + User author = userRepository.findById(event.getAuthorId()).orElse(null); + if (author == null || author.getDeletedAt() != null) { + log.debug("Author not found or deleted, skip notification: authorId={}", event.getAuthorId()); + return; + } + + Map params = Map.of( + "senderName", author.getNickname(), + "commentContent", truncate(event.getContent()) + ); + + Set blockedByAuthor = Set.copyOf( + userBlockRepository.findBlockedUserIdsBidirectional(event.getAuthorId())); + + // 1. 부모 댓글 작성자 알림 (author 제외, 역할 무관 최우선,차단 관계이면 skip) + if (!event.getAuthorId().equals(event.getParentCommentAuthorId()) + && !blockedByAuthor.contains(event.getParentCommentAuthorId())) { + User parentCommentAuthor = userRepository.findById(event.getParentCommentAuthorId()).orElse(null); + if (parentCommentAuthor == null || parentCommentAuthor.getDeletedAt() != null) { + log.debug("Parent comment author not found or deleted, skip notification: parentCommentAuthorId={}", event.getParentCommentAuthorId()); + } else { + NotificationContent replyContent = messageFactory.createMessage( + NotificationType.REPLY_ON_MY_COMMENT, params); + notificationCommandService.sendNotification( + event.getParentCommentAuthorId(), + NotificationType.REPLY_ON_MY_COMMENT, + replyContent.title(), + replyContent.body(), + replyContent.inboxMessage(), + event.getDailyReportId().toString(), + String.format("COMMENT_%d_PARENT_AUTHOR", event.getSubCommentId()) + ); + log.debug("Sub-comment notification sent to parent author: subCommentId={}, parentCommentAuthorId={}", event.getSubCommentId(), event.getParentCommentAuthorId()); + } + } + + // 2. 참여자 알림 (author, 부모 댓글 작성자 제외) — 리포트 당사자도 참여자면 여기서 처리 + List excludeFromParticipants = List.of( + event.getAuthorId(), + event.getParentCommentAuthorId() + ); + List participantIds = commentRepository.findDistinctSubCommentAuthorIds( + event.getParentCommentId(), excludeFromParticipants); + + // 3. 리포트 당사자가 참여자가 아닌 경우에만 COMMENT_ON_MY_REPORT + boolean reportOwnerIsParentAuthor = event.getReportOwnerId().equals(event.getParentCommentAuthorId()); + boolean reportOwnerIsParticipant = participantIds.contains(event.getReportOwnerId()); + boolean reportOwnerIsAuthor = event.getReportOwnerId().equals(event.getAuthorId()); + + if (!reportOwnerIsAuthor && !reportOwnerIsParentAuthor && !reportOwnerIsParticipant) { + User reportOwner = userRepository.findById(event.getReportOwnerId()).orElse(null); + if (reportOwner == null || reportOwner.getDeletedAt() != null) { + log.debug("Report owner not found or deleted, skip notification: reportOwnerId={}", event.getReportOwnerId()); + } else { + NotificationContent reportOwnerContent = messageFactory.createMessage( + NotificationType.COMMENT_ON_MY_REPORT, params); + notificationCommandService.sendNotification( + event.getReportOwnerId(), + NotificationType.COMMENT_ON_MY_REPORT, + reportOwnerContent.title(), + reportOwnerContent.body(), + reportOwnerContent.inboxMessage(), + event.getDailyReportId().toString(), + String.format("COMMENT_%d_REPORT_OWNER", event.getSubCommentId()) + ); + log.debug("Sub-comment notification sent to report owner: subCommentId={}, reportOwnerId={}", event.getSubCommentId(), event.getReportOwnerId()); + } + } + + // 4. 참여자 알림 + if (!participantIds.isEmpty()) { + if (!event.isSecret() || event.isParentSecret()) { + // 공개 대댓글 or 비밀 부모 아래 대댓글: 참여자들 열람 가능 — 차단 관계 제외 + List notifiableParticipantIds = participantIds.stream() + .filter(id -> !blockedByAuthor.contains(id)) + .toList(); + if (!notifiableParticipantIds.isEmpty()) { + NotificationContent participantContent = messageFactory.createMessage( + NotificationType.REPLY_ON_PARTICIPATED_COMMENT, params); + for (Long participantId : notifiableParticipantIds) { + notificationCommandService.sendNotification( + participantId, + NotificationType.REPLY_ON_PARTICIPATED_COMMENT, + participantContent.title(), + participantContent.body(), + participantContent.inboxMessage(), + event.getDailyReportId().toString(), + String.format("COMMENT_%d_PARTICIPANT_%d", event.getSubCommentId(), participantId) + ); + } + log.debug("Sub-comment notifications sent: subCommentId={}, participantCount={}", event.getSubCommentId(), notifiableParticipantIds.size()); + } + } else if (reportOwnerIsParticipant && !reportOwnerIsAuthor + && !blockedByAuthor.contains(event.getReportOwnerId())) { + // 공개 부모 댓글에 달린 비밀 대댓글 : 일반 참여자는 열람 불가하지만 리포트 당사자는 가능 + User reportOwner = userRepository.findById(event.getReportOwnerId()).orElse(null); + if (reportOwner != null && reportOwner.getDeletedAt() == null) { + NotificationContent participantContent = messageFactory.createMessage( + NotificationType.REPLY_ON_PARTICIPATED_COMMENT, params); + notificationCommandService.sendNotification( + event.getReportOwnerId(), + NotificationType.REPLY_ON_PARTICIPATED_COMMENT, + participantContent.title(), + participantContent.body(), + participantContent.inboxMessage(), + event.getDailyReportId().toString(), + String.format("COMMENT_%d_PARTICIPANT_%d", event.getSubCommentId(), event.getReportOwnerId()) + ); + log.debug("Sub-comment notification sent to report owner (participant): subCommentId={}", event.getSubCommentId()); + } + } + } + } catch (Exception e) { + log.error("Failed to handle SubCommentCreatedEvent: subCommentId={}, error={}", + event.getSubCommentId(), e.getMessage(), e); + } + } + + private String truncate(String content) { + if (content == null) return ""; + int[] codePoints = content.codePoints().toArray(); + if (codePoints.length <= 20) return content; + return new String(codePoints, 0, 20).stripTrailing() + "..."; + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/friend/FriendNotificationEventListener.java b/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/social/FriendNotificationEventListener.java similarity index 99% rename from src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/friend/FriendNotificationEventListener.java rename to src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/social/FriendNotificationEventListener.java index 467de66a..5c9039c4 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/friend/FriendNotificationEventListener.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/social/FriendNotificationEventListener.java @@ -1,4 +1,4 @@ -package com.devkor.ifive.nadab.domain.notification.application.event.friend; +package com.devkor.ifive.nadab.domain.notification.application.event.social; import com.devkor.ifive.nadab.domain.friend.application.event.FriendRequestAcceptedEvent; import com.devkor.ifive.nadab.domain.friend.application.event.FriendRequestReceivedEvent; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/notification/core/entity/NotificationType.java b/src/main/java/com/devkor/ifive/nadab/domain/notification/core/entity/NotificationType.java index a9d7f478..c3821999 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/notification/core/entity/NotificationType.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/notification/core/entity/NotificationType.java @@ -22,7 +22,10 @@ public enum NotificationType { // 소셜 알림 FRIEND_REQUEST_RECEIVED("친구 요청", NotificationGroup.SOCIAL), - FRIEND_REQUEST_ACCEPTED("친구 수락", NotificationGroup.SOCIAL); + FRIEND_REQUEST_ACCEPTED("친구 수락", NotificationGroup.SOCIAL), + COMMENT_ON_MY_REPORT("내 리포트에 댓글/대댓글", NotificationGroup.SOCIAL), + REPLY_ON_MY_COMMENT("내 댓글에 대댓글", NotificationGroup.SOCIAL), + REPLY_ON_PARTICIPATED_COMMENT("참여 댓글에 대댓글", NotificationGroup.SOCIAL); private final String description; private final NotificationGroup group; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsService.java new file mode 100644 index 00000000..2674a93a --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsService.java @@ -0,0 +1,151 @@ +package com.devkor.ifive.nadab.domain.stats.application; + +import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalReasonType; +import com.devkor.ifive.nadab.domain.stats.core.dto.withdrawal.WithdrawalEventRowViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.withdrawal.WithdrawalStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.repository.WithdrawalStatsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.sql.Timestamp; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.*; + +@Service +@RequiredArgsConstructor +public class WithdrawalStatsService { + + private static final int RECENT_WITHDRAWAL_EVENT_LIMIT = 100; + private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final WithdrawalStatsRepository repo; + + public WithdrawalStatsViewModel getWithdrawalStats() { + List rows = repo.findLatestWithdrawalReasonRows(RECENT_WITHDRAWAL_EVENT_LIMIT); + List totalReasonRows = repo.countAllWithdrawalReasons(); + + Map eventMap = new LinkedHashMap<>(); + Map reasonCountMap = new EnumMap<>(WithdrawalReasonType.class); + + for (Object[] row : rows) { + long userId = toLong(row[0]); + OffsetDateTime withdrawnAt = toOffsetDateTime(row[1]); + String reasonCode = String.valueOf(row[2]); + WithdrawalReasonType reasonType = parseReasonType(reasonCode); + String customReason = row[3] == null ? null : String.valueOf(row[3]).trim(); + + OffsetDateTime normalizedWithdrawnAt = withdrawnAt.truncatedTo(ChronoUnit.SECONDS); + EventKey key = new EventKey(userId, normalizedWithdrawnAt); + EventAccumulator accumulator = eventMap.computeIfAbsent( + key, + k -> new EventAccumulator(formatDateTime(normalizedWithdrawnAt)) + ); + + accumulator.reasons.add(toReasonLabel(reasonType, reasonCode)); + if (customReason != null && !customReason.isEmpty()) { + accumulator.customReason = customReason; + } + + } + + for (Object[] row : totalReasonRows) { + String reasonCode = String.valueOf(row[0]); + WithdrawalReasonType reasonType = parseReasonType(reasonCode); + if (reasonType == null) { + continue; + } + reasonCountMap.put(reasonType, toLong(row[1])); + } + + List reasonTypes = Arrays.stream(WithdrawalReasonType.values()).toList(); + List reasonLabels = reasonTypes.stream() + .map(this::toReasonLabel) + .toList(); + List reasonCounts = reasonTypes.stream() + .map(type -> reasonCountMap.getOrDefault(type, 0L)) + .toList(); + + List eventRows = eventMap.values().stream() + .map(event -> new WithdrawalEventRowViewModel( + event.withdrawnAt, + String.join(", ", event.reasons), + event.customReason == null ? "-" : event.customReason + )) + .toList(); + + return new WithdrawalStatsViewModel( + RECENT_WITHDRAWAL_EVENT_LIMIT, + eventRows.size(), + reasonLabels, + reasonCounts, + eventRows, + OffsetDateTime.now(SEOUL).format(FMT) + ); + } + + private long toLong(Object value) { + if (value instanceof Number n) { + return n.longValue(); + } + return Long.parseLong(String.valueOf(value)); + } + + private OffsetDateTime toOffsetDateTime(Object value) { + if (value instanceof OffsetDateTime odt) { + return odt; + } + if (value instanceof LocalDateTime ldt) { + return ldt.atZone(SEOUL).toOffsetDateTime(); + } + if (value instanceof Timestamp ts) { + return ts.toInstant().atZone(SEOUL).toOffsetDateTime(); + } + return OffsetDateTime.parse(String.valueOf(value)); + } + + private String formatDateTime(OffsetDateTime value) { + return value.atZoneSameInstant(SEOUL).format(FMT); + } + + private WithdrawalReasonType parseReasonType(String reasonCode) { + try { + return WithdrawalReasonType.valueOf(reasonCode); + } catch (Exception ignored) { + return null; + } + } + + private String toReasonLabel(WithdrawalReasonType reasonType) { + return switch (reasonType) { + case DAILY_LOGGING_BURDEN -> "매일 기록이 부담"; + case INSUFFICIENT_QUESTION_ANALYSIS -> "질문·분석 부족"; + case LOSS_OF_INTEREST_IN_WRITING -> "흥미 상실"; + case PRIVACY_RECORD_CONCERN -> "기록 보안 우려"; + case APP_ERROR_OR_SLOWNESS -> "오류·속도 문제"; + case OTHER -> "기타(직접 입력)"; + }; + } + + private String toReasonLabel(WithdrawalReasonType reasonType, String rawReasonCode) { + if (reasonType == null) { + return rawReasonCode; + } + return toReasonLabel(reasonType); + } + + private record EventKey(long userId, OffsetDateTime withdrawnAt) { + } + + private static class EventAccumulator { + private final String withdrawnAt; + private final List reasons = new ArrayList<>(); + private String customReason; + + private EventAccumulator(String withdrawnAt) { + this.withdrawnAt = withdrawnAt; + } + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java index b0650050..2e5a18ed 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java @@ -4,11 +4,13 @@ import com.devkor.ifive.nadab.domain.stats.application.MonthlyStatsService; import com.devkor.ifive.nadab.domain.stats.application.TotalStatsService; import com.devkor.ifive.nadab.domain.stats.application.TypeStatsService; +import com.devkor.ifive.nadab.domain.stats.application.WithdrawalStatsService; import com.devkor.ifive.nadab.domain.stats.application.WeeklyStatsService; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.total.TotalStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.withdrawal.WithdrawalStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.weekly.WeeklyStatsViewModel; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; @@ -24,6 +26,7 @@ public class StatsController { private final MonthlyStatsService monthlyStatsService; private final TotalStatsService totalStatsService; private final TypeStatsService typeStatsService; + private final WithdrawalStatsService withdrawalStatsService; @GetMapping("stats/daily") @@ -65,4 +68,12 @@ public String typeStats(Model model) { model.addAttribute("activeTab", "type"); return "stats/type"; } + + @GetMapping("/stats/withdrawal") + public String withdrawalStats(Model model) { + WithdrawalStatsViewModel vm = withdrawalStatsService.getWithdrawalStats(); + model.addAttribute("vm", vm); + model.addAttribute("activeTab", "withdrawal"); + return "stats/withdrawal"; + } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/withdrawal/WithdrawalEventRowViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/withdrawal/WithdrawalEventRowViewModel.java new file mode 100644 index 00000000..393f258c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/withdrawal/WithdrawalEventRowViewModel.java @@ -0,0 +1,8 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.withdrawal; + +public record WithdrawalEventRowViewModel( + String withdrawnAt, + String reasons, + String customReason +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/withdrawal/WithdrawalStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/withdrawal/WithdrawalStatsViewModel.java new file mode 100644 index 00000000..aec10d82 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/withdrawal/WithdrawalStatsViewModel.java @@ -0,0 +1,13 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.withdrawal; + +import java.util.List; + +public record WithdrawalStatsViewModel( + int recentEventLimit, + long eventCount, + List reasonLabels, + List reasonCounts, + List rows, + String refreshedAt +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/WithdrawalStatsRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/WithdrawalStatsRepository.java new file mode 100644 index 00000000..c9a6fcf9 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/WithdrawalStatsRepository.java @@ -0,0 +1,51 @@ +package com.devkor.ifive.nadab.domain.stats.core.repository; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class WithdrawalStatsRepository { + + private final EntityManager em; + + public List findLatestWithdrawalReasonRows(int limitEvents) { + return em.createNativeQuery(""" + with ranked_events as ( + select + uwr.user_id, + uwr.withdrawn_at, + row_number() over (order by uwr.withdrawn_at desc, uwr.user_id desc) as rn + from user_withdrawal_reasons uwr + group by uwr.user_id, uwr.withdrawn_at + ) + select + uwr.user_id, + uwr.withdrawn_at, + uwr.reason, + uwr.custom_reason + from user_withdrawal_reasons uwr + join ranked_events re + on re.user_id = uwr.user_id + and re.withdrawn_at = uwr.withdrawn_at + where re.rn <= :limitEvents + order by uwr.withdrawn_at desc, uwr.user_id desc, uwr.reason asc + """) + .setParameter("limitEvents", limitEvents) + .getResultList(); + } + + public List countAllWithdrawalReasons() { + return em.createNativeQuery(""" + select + uwr.reason, + count(*) as cnt + from user_withdrawal_reasons uwr + group by uwr.reason + """) + .getResultList(); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/test/api/TestController.java b/src/main/java/com/devkor/ifive/nadab/domain/test/api/TestController.java index 9f8ad3cd..5de97689 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/test/api/TestController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/test/api/TestController.java @@ -1,14 +1,18 @@ package com.devkor.ifive.nadab.domain.test.api; import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.DailyReportResponse; +import com.devkor.ifive.nadab.domain.test.api.dto.request.CreateTestUserRequest; import com.devkor.ifive.nadab.domain.test.api.dto.request.PromptTestDailyReportRequest; import com.devkor.ifive.nadab.domain.test.api.dto.request.TestDailyReportRequest; import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.CreateDailyReportResponse; +import com.devkor.ifive.nadab.domain.test.api.dto.response.CreateTestUserResponse; import com.devkor.ifive.nadab.domain.test.api.dto.response.TestDailyReportResponse; import com.devkor.ifive.nadab.domain.test.application.TestReportService; +import com.devkor.ifive.nadab.domain.test.application.TestUserService; import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; import com.devkor.ifive.nadab.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -32,6 +36,17 @@ public class TestController { private final TestReportService testReportService; + private final TestUserService testUserService; + + @Hidden + @PostMapping("/users") + @PermitAll + public ResponseEntity> createTestUser( + @Valid @RequestBody CreateTestUserRequest request + ) { + CreateTestUserResponse response = testUserService.createTestUser(request); + return ApiResponseEntity.ok(response); + } @PostMapping("/generate/daily-report") @PermitAll @@ -209,4 +224,13 @@ public ResponseEntity> deleteTypeReport( testReportService.deleteTypeReport(principal.getId(), interestCode); return ApiResponseEntity.noContent(); } + + @Hidden + @PostMapping("/delete/monthly-report-v1") + public ResponseEntity> deleteMonthlyReportV1( + @RequestParam String email + ) { + testReportService.deleteMonthMonthlyReportV1(email); + return ApiResponseEntity.noContent(); + } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/test/api/dto/request/CreateTestUserRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/test/api/dto/request/CreateTestUserRequest.java new file mode 100644 index 00000000..1cbed82e --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/test/api/dto/request/CreateTestUserRequest.java @@ -0,0 +1,16 @@ +package com.devkor.ifive.nadab.domain.test.api.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CreateTestUserRequest( + @NotBlank(message = "email is required") + @Email(message = "email format is invalid") + String email, + + @NotBlank(message = "nickname is required") + @Size(min = 2, max = 10, message = "nickname must be between 2 and 10 characters") + String nickname +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/test/api/dto/response/CreateTestUserResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/test/api/dto/response/CreateTestUserResponse.java new file mode 100644 index 00000000..d5671f61 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/test/api/dto/response/CreateTestUserResponse.java @@ -0,0 +1,9 @@ +package com.devkor.ifive.nadab.domain.test.api.dto.response; + +public record CreateTestUserResponse( + Long userId, + String email, + String nickname, + String signupStatus +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/test/application/TestReportService.java b/src/main/java/com/devkor/ifive/nadab/domain/test/application/TestReportService.java index b443ee28..73b40bc2 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/test/application/TestReportService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/test/application/TestReportService.java @@ -2,7 +2,9 @@ import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReport; import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2; import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportRepository; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository; import com.devkor.ifive.nadab.domain.test.api.dto.request.PromptTestDailyReportRequest; import com.devkor.ifive.nadab.domain.test.api.dto.request.TestDailyReportRequest; import com.devkor.ifive.nadab.domain.test.api.dto.response.TestDailyReportResponse; @@ -14,6 +16,7 @@ import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; import com.devkor.ifive.nadab.domain.user.core.entity.User; import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.domain.user.core.service.ProfileImageService; import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLog; import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogReason; import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogStatus; @@ -41,6 +44,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; + @Service @RequiredArgsConstructor public class TestReportService { @@ -55,10 +60,13 @@ public class TestReportService { private final UserRepository userRepository; private final WeeklyReportRepository weeklyReportRepository; private final MonthlyReportRepository monthlyReportRepository; + private final MonthlyReportV2Repository monthlyReportV2Repository; private final TypeReportRepository typeReportRepository; private final TestCrystalLogRepository testCrystalLogRepository; private final UserWalletRepository userWalletRepository; + private final ProfileImageService profileImageService; + private static final long WEEKLY_REPORT_COST = 20L; private static final long MONTHLY_REPORT_COST = 40L; private static final long TYPE_REPORT_COST = 100L; @@ -205,17 +213,24 @@ public void deleteThisMonthMonthlyReport(Long userId) { MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); - MonthlyReport report = monthlyReportRepository.findByUserIdAndMonthStartDate(user.getId(), range.monthStartDate()) - .orElseThrow(() -> new NotFoundException(ErrorCode.MONTHLY_REPORT_NOT_FOUND)); + Optional reportV2 = monthlyReportV2Repository.findByUserIdAndMonthStartDate(user.getId(), range.monthStartDate()); + if (reportV2.isEmpty()) { + deleteMonthMonthlyReportV1(user.getEmail()); + return; + } + + MonthlyReportV2 report = reportV2.get(); if (report.getStatus() != MonthlyReportStatus.COMPLETED) { throw new BadRequestException(ErrorCode.MONTHLY_REPORT_NOT_COMPLETED); } + profileImageService.deleteProfileImage(report.getImageKey()); + CrystalLog purchaseLog = testCrystalLogRepository .findByUserIdAndRefTypeAndRefIdAndReasonAndStatus( userId, - "MONTHLY_REPORT", + "MONTHLY_REPORT_V2", report.getId(), CrystalLogReason.REPORT_GENERATE_MONTHLY, CrystalLogStatus.CONFIRMED @@ -233,12 +248,12 @@ public void deleteThisMonthMonthlyReport(Long userId) { CrystalLog refundLog = CrystalLog.createConfirmed(user, MONTHLY_REPORT_COST, balanceAfter, - CrystalLogReason.TEST_DELETE_REPORT_REFUND_MONTHLY, "MONTHLY_REPORT_REFUND", report.getId()); + CrystalLogReason.TEST_DELETE_REPORT_REFUND_MONTHLY, "MONTHLY_REPORT_V2_REFUND", report.getId()); testCrystalLogRepository.save(refundLog); testCrystalLogRepository.markRefunded(purchaseLog.getId()); - monthlyReportRepository.delete(report); + monthlyReportV2Repository.delete(report); } @Transactional @@ -282,4 +297,47 @@ public void deleteTypeReport(Long userId, String interestCode) { typeReportRepository.delete(report); } + + @Transactional + public void deleteMonthMonthlyReportV1(String email) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); + + MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); + + MonthlyReport report = monthlyReportRepository.findByUserIdAndMonthStartDate(user.getId(), range.monthStartDate()) + .orElseThrow(() -> new NotFoundException(ErrorCode.MONTHLY_REPORT_NOT_FOUND)); + + if (report.getStatus() != MonthlyReportStatus.COMPLETED) { + throw new BadRequestException(ErrorCode.MONTHLY_REPORT_NOT_COMPLETED); + } + + CrystalLog purchaseLog = testCrystalLogRepository + .findByUserIdAndRefTypeAndRefIdAndReasonAndStatus( + user.getId(), + "MONTHLY_REPORT", + report.getId(), + CrystalLogReason.REPORT_GENERATE_MONTHLY, + CrystalLogStatus.CONFIRMED + ) + .orElseThrow(() -> new BadRequestException(ErrorCode.CRYSTAL_LOG_NOT_FOUND)); + + int updated = userWalletRepository.refund(user.getId(), MONTHLY_REPORT_COST); + if (updated == 0) { + throw new NotFoundException(ErrorCode.WALLET_NOT_FOUND); + } + + UserWallet wallet = userWalletRepository.findByUserId(user.getId()) + .orElseThrow(() -> new NotFoundException(ErrorCode.WALLET_NOT_FOUND)); + long balanceAfter = wallet.getCrystalBalance(); + + + CrystalLog refundLog = CrystalLog.createConfirmed(user, MONTHLY_REPORT_COST, balanceAfter, + CrystalLogReason.TEST_DELETE_REPORT_REFUND_MONTHLY, "MONTHLY_REPORT_REFUND", report.getId()); + testCrystalLogRepository.save(refundLog); + + testCrystalLogRepository.markRefunded(purchaseLog.getId()); + + monthlyReportRepository.delete(report); + } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/test/application/TestUserService.java b/src/main/java/com/devkor/ifive/nadab/domain/test/application/TestUserService.java new file mode 100644 index 00000000..1a7e39b9 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/test/application/TestUserService.java @@ -0,0 +1,62 @@ +package com.devkor.ifive.nadab.domain.test.application; + +import com.devkor.ifive.nadab.domain.terms.application.TermsCommandService; +import com.devkor.ifive.nadab.domain.test.api.dto.request.CreateTestUserRequest; +import com.devkor.ifive.nadab.domain.test.api.dto.response.CreateTestUserResponse; +import com.devkor.ifive.nadab.domain.user.core.entity.SignupStatusType; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.domain.user.core.service.UserProfileUpdateService; +import com.devkor.ifive.nadab.domain.wallet.core.entity.UserWallet; +import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import com.devkor.ifive.nadab.global.exception.ConflictException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Profile({"local", "dev"}) +@Service +@Transactional +@RequiredArgsConstructor +public class TestUserService { + + private final UserRepository userRepository; + private final UserWalletRepository userWalletRepository; + private final UserProfileUpdateService userProfileUpdateService; + private final TermsCommandService termsCommandService; + private final PasswordEncoder passwordEncoder; + + @Value("${test.account.password:}") + private String testUserPassword; + + public CreateTestUserResponse createTestUser(CreateTestUserRequest request) { + if (testUserPassword == null || testUserPassword.isBlank()) { + throw new BadRequestException(ErrorCode.TEST_USER_PASSWORD_NOT_CONFIGURED); + } + if (userRepository.existsByEmail(request.email())) { + throw new ConflictException(ErrorCode.EMAIL_ALREADY_EXISTS); + } + + String passwordHash = passwordEncoder.encode(testUserPassword); + User user = User.createUser(request.email(), passwordHash); + userRepository.save(user); + + userProfileUpdateService.updateNickname(user, request.nickname()); + user.updateSignupStatus(SignupStatusType.COMPLETED); + + userWalletRepository.save(UserWallet.create(user)); + termsCommandService.saveConsents(user.getId(), true, true, true, false); + + return new CreateTestUserResponse( + user.getId(), + user.getEmail(), + user.getNickname(), + user.getSignupStatus().name() + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyReportRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyReportRepository.java index 51eed2ee..6fdfa6c8 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyReportRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyReportRepository.java @@ -25,6 +25,8 @@ Optional findByUserIdAndWeekStartDateAndStatus( WeeklyReportStatus status ); + List findAllByUserIdAndStatus(Long userId, WeeklyReportStatus status); + // 특정 시점(snapshotDate) 이전에 완료된(COMPLETED) 주간 리포트 조회 List findAllByUserIdAndStatusAndWeekEndDateLessThanEqualOrderByWeekStartDateAsc( Long userId, @@ -98,4 +100,4 @@ List findMonthlyOverlappedWeeklyReports( @Param("monthEnd") LocalDate monthEnd, @Param("status") WeeklyReportStatus status ); -} \ No newline at end of file +} diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/notification/message/NotificationMessageFactory.java b/src/main/java/com/devkor/ifive/nadab/global/core/notification/message/NotificationMessageFactory.java index 8a4f64a2..62826a2b 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/notification/message/NotificationMessageFactory.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/notification/message/NotificationMessageFactory.java @@ -50,7 +50,8 @@ private void validateAllTemplates() { "senderName", "테스트", "categoryName", "테스트", "daysInactive", "5", - "milestone", "30" + "milestone", "30", + "commentContent", "테스트 댓글 내용" ); for (NotificationType type : NotificationType.values()) { diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/LocalMonthlyReportPromptLoader.java b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/LocalMonthlyReportPromptLoader.java index d5000405..12a48576 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/LocalMonthlyReportPromptLoader.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/LocalMonthlyReportPromptLoader.java @@ -15,15 +15,16 @@ @Slf4j public class LocalMonthlyReportPromptLoader implements MonthlyReportPromptLoader { - private static final String PROMPT_PATH = "secret/monthly-prompt-local.txt"; + private static final String V1_PROMPT_PATH = "secret/monthly-prompt-v1-local.txt"; + private static final String V2_BASELINE_PROMPT_PATH = "secret/monthly-prompt-v2-baseline-local.txt"; @Override - public String loadPrompt() { + public String loadV1Prompt() { try { - ClassPathResource resource = new ClassPathResource(PROMPT_PATH); + ClassPathResource resource = new ClassPathResource(V1_PROMPT_PATH); if (!resource.exists()) { - log.error("월간 리포트 프롬프트 파일이 존재하지 않습니다: {}", PROMPT_PATH); + log.error("월간 리포트 프롬프트 파일이 존재하지 않습니다: {}", V1_PROMPT_PATH); throw new BadRequestException(ErrorCode.PROMPT_MONTHLY_FILE_NOT_FOUND); } @@ -31,8 +32,55 @@ public String loadPrompt() { return new String(bytes, StandardCharsets.UTF_8); } catch (IOException e) { - log.error("로컬 월간 리포트 프롬프트 파일 읽기 실패: {}", PROMPT_PATH, e); + log.error("로컬 월간 리포트 프롬프트 파일 읽기 실패: {}", V1_PROMPT_PATH, e); throw new BadRequestException(ErrorCode.PROMPT_MONTHLY_FILE_READ_FAILED); } } + + @Override + public String loadV2BaselinePrompt() { + try { + ClassPathResource resource = new ClassPathResource(V2_BASELINE_PROMPT_PATH); + + if (!resource.exists()) { + log.error("월간 리포트 프롬프트 파일이 존재하지 않습니다: {}", V2_BASELINE_PROMPT_PATH); + throw new BadRequestException(ErrorCode.PROMPT_MONTHLY_FILE_NOT_FOUND); + } + + byte[] bytes = resource.getContentAsByteArray(); + return new String(bytes, StandardCharsets.UTF_8); + + } catch (IOException e) { + log.error("로컬 월간 리포트 프롬프트 파일 읽기 실패: {}", V2_BASELINE_PROMPT_PATH, e); + throw new BadRequestException(ErrorCode.PROMPT_MONTHLY_FILE_READ_FAILED); + } + } + + @Override + public String loadImagePrompt() { + return """ + Create a premium abstract monthly self-reflection cover image for a Korean journaling app. + + Report context: + - Monthly summary: %s + - Gentle comment summary: %s + - Dominant keyword: %s + - Month: %s to %s + + Task: + Create a symbolic, abstract cover image that represents the emotional meaning of this monthly report. Do not use a fixed scene. Choose objects, colors, light, space, and composition that naturally match the report's theme. + + The image should feel calm, warm, reflective, comforting, refined, and suitable for a premium mobile app monthly report card. + + Style: + Premium editorial illustration, modern app-friendly aesthetic, soft lighting, subtle texture, refined color palette, clean composition, enough negative space. + + Strict constraints: + No people, no faces, no hands, no body parts. + No readable text, no letters, no Korean characters, no English characters, no numbers, no symbols, no handwriting, no typography. + No UI, no logos, no brand marks, no watermarks. + No medical, therapy, counseling, hospital, or diagnosis-related imagery. + No dark, horror, gloomy, childish, or overly dramatic mood. + """; + } } diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/MonthlyReportPromptLoader.java b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/MonthlyReportPromptLoader.java index 2092ebad..68898a41 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/MonthlyReportPromptLoader.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/MonthlyReportPromptLoader.java @@ -1,5 +1,9 @@ package com.devkor.ifive.nadab.global.core.prompt.monthly; public interface MonthlyReportPromptLoader { - String loadPrompt(); + String loadV1Prompt(); + + String loadV2BaselinePrompt(); + + String loadImagePrompt(); } diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/SecretMonthlyReportPromptLoader.java b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/SecretMonthlyReportPromptLoader.java index e955820a..728368a2 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/SecretMonthlyReportPromptLoader.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/SecretMonthlyReportPromptLoader.java @@ -14,16 +14,57 @@ @RequiredArgsConstructor public class SecretMonthlyReportPromptLoader implements MonthlyReportPromptLoader { - @Value("${MONTHLY_PROMPT}") - private String rawPrompt; + @Value("${MONTHLY_V1_PROMPT}") + private String monthlyV1Prompt; + + @Value("${MONTHLY_V2_BASELINE_PROMPT}") + private String monthlyV2BaselinePrompt; + + @Override + public String loadV1Prompt() { + if (monthlyV1Prompt == null || monthlyV1Prompt.isBlank()) { + log.error("환경 변수 MONTHLY_V1_PROMPT가 비어있습니다."); + throw new BadRequestException(ErrorCode.PROMPT_MONTHLY_ENV_VAR_NOT_SET); + } + + return monthlyV1Prompt; + } @Override - public String loadPrompt() { - if (rawPrompt == null || rawPrompt.isBlank()) { - log.error("환경 변수 MONTHLY_PROMPT가 비어있습니다."); + public String loadV2BaselinePrompt() { + if (monthlyV2BaselinePrompt == null || monthlyV2BaselinePrompt.isBlank()) { + log.error("환경 변수 MONTHLY_V2_BASELINE_PROMPT가 비어있습니다."); throw new BadRequestException(ErrorCode.PROMPT_MONTHLY_ENV_VAR_NOT_SET); } - return rawPrompt; + return monthlyV2BaselinePrompt; + } + + @Override + public String loadImagePrompt() { + return """ + Create a premium abstract monthly self-reflection cover image for a Korean journaling app. + + Report context: + - Monthly summary: %s + - Gentle comment summary: %s + - Dominant keyword: %s + - Month: %s to %s + + Task: + Create a symbolic, abstract cover image that represents the emotional meaning of this monthly report. Do not use a fixed scene. Choose objects, colors, light, space, and composition that naturally match the report's theme. + + The image should feel calm, warm, reflective, comforting, refined, and suitable for a premium mobile app monthly report card. + + Style: + Premium editorial illustration, modern app-friendly aesthetic, soft lighting, subtle texture, refined color palette, clean composition, enough negative space. + + Strict constraints: + No people, no faces, no hands, no body parts. + No readable text, no letters, no Korean characters, no English characters, no numbers, no symbols, no handwriting, no typography. + No UI, no logos, no brand marks, no watermarks. + No medical, therapy, counseling, hospital, or diagnosis-related imagery. + No dark, horror, gloomy, childish, or overly dramatic mood. + """; } } diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java index 950c02a0..398ee556 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java @@ -14,6 +14,7 @@ public enum ErrorCode { USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다"), EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 사용 중인 이메일입니다"), FILE_STORAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "스토리지에서 해당 파일을 찾을 수 없습니다"), // S3 등 스토리지에 실제 파일 객체가 없는 경우 + TEST_USER_PASSWORD_NOT_CONFIGURED(HttpStatus.BAD_REQUEST, "테스트 계정 비밀번호가 설정되지 않았습니다"), // ==================== AUTH (인증) ==================== // 400 Bad Request @@ -28,6 +29,12 @@ public enum ErrorCode { AUTH_SOCIAL_ACCOUNT_RESTORE_FORBIDDEN(HttpStatus.BAD_REQUEST, "소셜 로그인 계정은 일반 계정 복구를 사용할 수 없습니다"), AUTH_UNSUPPORTED_OAUTH2_PROVIDER(HttpStatus.BAD_REQUEST, "지원하지 않는 OAuth2 제공자입니다"), + AUTH_WITHDRAWAL_REASON_REQUIRED(HttpStatus.BAD_REQUEST, "탈퇴 사유는 최소 1개 이상 선택해야 합니다"), + AUTH_WITHDRAWAL_REASON_DUPLICATED(HttpStatus.BAD_REQUEST, "탈퇴 사유는 중복 선택할 수 없습니다"), + AUTH_WITHDRAWAL_OTHER_REASON_REQUIRED(HttpStatus.BAD_REQUEST, "기타 사유를 선택한 경우 직접 입력이 필요합니다"), + AUTH_WITHDRAWAL_OTHER_REASON_TOO_LONG(HttpStatus.BAD_REQUEST, "기타 사유는 200자 이하로 입력해야 합니다"), + AUTH_WITHDRAWAL_OTHER_REASON_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "기타 사유는 OTHER 선택 시에만 입력할 수 있습니다"), + // 401 Unauthorized AUTH_INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다"), AUTH_INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않거나 만료된 Refresh Token입니다"), @@ -165,6 +172,7 @@ public enum ErrorCode { // 404 Not Found MONTHLY_REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "월간 리포트를 찾을 수 없습니다"), MONTHLY_REPORT_NOT_COMPLETED(HttpStatus.NOT_FOUND, "해당 월간 리포트가 아직 생성 완료되지 않았습니다"), + MONTHLY_REPORT_ACCESS_FORBIDDEN(HttpStatus.FORBIDDEN, "본인 월간 리포트만 조회할 수 있습니다"), // 409 Conflict MONTHLY_REPORT_ALREADY_COMPLETED(HttpStatus.CONFLICT, "이미 작성된 월간 리포트가 존재합니다"), @@ -391,7 +399,44 @@ public enum ErrorCode { CONTENT_REPORT_SELF_REPORT_FORBIDDEN(HttpStatus.BAD_REQUEST, "자신의 게시글은 신고할 수 없습니다"), // 409 Conflict - CONTENT_REPORT_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 신고한 게시글입니다"); + CONTENT_REPORT_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 신고한 게시글입니다"), + + // ==================== MODERATION (소셜 정지) ==================== + // 400 Bad Request + SOCIAL_SUSPENDED(HttpStatus.BAD_REQUEST, "소셜 활동이 정지된 상태입니다"), + + // ==================== COMMENT (댓글) ==================== + // 400 Bad Request + COMMENT_NOT_TOP_LEVEL(HttpStatus.BAD_REQUEST, "대댓글에는 답글을 달 수 없습니다"), + + // 404 Not Found + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다"), + + // 409 Conflict + COMMENT_DELETED(HttpStatus.CONFLICT, "이미 삭제된 댓글입니다"), + + // ==================== LIKE (좋아요) ==================== + // 400 Bad Request + CANNOT_LIKE_OWN_CONTENT(HttpStatus.BAD_REQUEST, "본인의 게시글/댓글에는 좋아요를 누를 수 없습니다"), + + // 403 Forbidden + DAILY_REPORT_LIKE_LIST_FORBIDDEN(HttpStatus.FORBIDDEN, "본인의 게시글 좋아요 리스트만 조회할 수 있습니다"), + + // 404 Not Found + LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, "좋아요를 찾을 수 없습니다"), + + // ==================== APP_VERSION (앱 버전) ==================== + // 404 Not Found + APP_VERSION_NOT_FOUND(HttpStatus.NOT_FOUND, "앱 버전을 찾을 수 없습니다"), + APP_VERSION_ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, "앱 버전 항목을 찾을 수 없습니다"), + + // 409 Conflict + APP_VERSION_ALREADY_EXISTS(HttpStatus.CONFLICT, "해당 플랫폼의 같은 버전이 이미 존재합니다"), + APP_VERSION_ITEM_DISPLAY_ORDER_DUPLICATED(HttpStatus.CONFLICT, "같은 버전 내 displayOrder가 중복됩니다"), + + // ==================== ADMIN (어드민) ==================== + // 401 Unauthorized + ADMIN_PAGE_INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "관리자 페이지 비밀번호가 올바르지 않습니다"); private final HttpStatus httpStatus; diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index e89709a2..3a62b47a 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -48,6 +48,10 @@ profile-image: env: dev base-url: ${PROFILE_IMAGE_BASE_URL} +test: + account: + password: ${DEV_TEST_ACCOUNT_PASSWORD:} + app: cookie: secure: true @@ -60,4 +64,4 @@ cors: - ${FRONTEND_DEV_URL} - ${FRONTEND_DEV_URL_2} - ${SWAGGER_ORIGIN_PUBLIC_IP} - - ${SWAGGER_ORIGIN_DOMAIN} \ No newline at end of file + - ${SWAGGER_ORIGIN_DOMAIN} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d8522b89..a3a30f59 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -83,8 +83,22 @@ app: secure: false same-site: Lax +admin: + page: + password: ${ADMIN_PAGE_PASSWORD} + token-expiration-seconds: 43200 + cookie-name: admin_auth + api_prefix: /api/v1 firebase: admin: - key-base64: ${FIREBASE_ADMIN_KEY} \ No newline at end of file + key-base64: ${FIREBASE_ADMIN_KEY} + +openai: + api-key: ${OPENAI_API_KEY} + image: + model: gpt-image-1.5 + size: 1024x1024 + quality: low + output-format: webp diff --git a/src/main/resources/db/migration/V20260512_1400__IS_create_monthly_reports_v2_table.sql b/src/main/resources/db/migration/V20260512_1400__IS_create_monthly_reports_v2_table.sql new file mode 100644 index 00000000..06ad830d --- /dev/null +++ b/src/main/resources/db/migration/V20260512_1400__IS_create_monthly_reports_v2_table.sql @@ -0,0 +1,40 @@ +CREATE TABLE monthly_reports_v2 ( + id BIGSERIAL PRIMARY KEY, + + user_id BIGINT NOT NULL, + month_start_date DATE NOT NULL, + month_end_date DATE NOT NULL, + + -- 리포트 생성 기준일 + date DATE NOT NULL, + + -- 1. 이미지 + image_key VARCHAR(255), + image_status VARCHAR(16) NOT NULL DEFAULT 'PENDING', + + -- 2~4. v2 리포트 + content JSONB NOT NULL, + emotion_summary_content JSONB NOT NULL, + emotion_stats JSONB NOT NULL, + + + -- 조회/목록 최적화용 캐시 컬럼 + summary VARCHAR(80) NOT NULL, + comment_summary VARCHAR(80) NOT NULL, + dominant_keyword VARCHAR(30) NOT NULL, + + -- 이전 월간 리포트 비교 상태 + comparison_type VARCHAR(20) NOT NULL DEFAULT 'BASELINE', + + -- 생성 상태 + status VARCHAR(16) NOT NULL, + + analyzed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT uq_monthly_reports_v2_user_month + UNIQUE (user_id, month_start_date), + + CONSTRAINT fk_monthly_reports_v2_user + FOREIGN KEY (user_id) REFERENCES users(id) +); \ No newline at end of file diff --git a/src/main/resources/db/migration/V20260515_1710__IS_add_interest_stats_to_monthly_reports_v2.sql b/src/main/resources/db/migration/V20260515_1710__IS_add_interest_stats_to_monthly_reports_v2.sql new file mode 100644 index 00000000..65ead72d --- /dev/null +++ b/src/main/resources/db/migration/V20260515_1710__IS_add_interest_stats_to_monthly_reports_v2.sql @@ -0,0 +1,2 @@ +ALTER TABLE monthly_reports_v2 + ADD COLUMN interest_stats JSONB NOT NULL DEFAULT '{"interests":[]}'::jsonb; diff --git a/src/main/resources/db/migration/V20260515_1730__CH_create_comments_table.sql b/src/main/resources/db/migration/V20260515_1730__CH_create_comments_table.sql new file mode 100644 index 00000000..3baf0d14 --- /dev/null +++ b/src/main/resources/db/migration/V20260515_1730__CH_create_comments_table.sql @@ -0,0 +1,18 @@ +CREATE TABLE comments ( + id BIGSERIAL PRIMARY KEY, + daily_report_id BIGINT NOT NULL, + author_id BIGINT NOT NULL, + parent_comment_id BIGINT, + content VARCHAR(500) NOT NULL, + is_secret BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + + CONSTRAINT fk_comments_daily_report FOREIGN KEY (daily_report_id) REFERENCES daily_reports(id) ON DELETE CASCADE, + CONSTRAINT fk_comments_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_comments_parent_comment FOREIGN KEY (parent_comment_id) REFERENCES comments(id) ON DELETE CASCADE +); + +CREATE INDEX idx_comments_daily_report_id ON comments (daily_report_id); +CREATE INDEX idx_comments_parent_comment_id ON comments (parent_comment_id); \ No newline at end of file diff --git a/src/main/resources/db/migration/V20260515_1735__CH_create_likes_tables.sql b/src/main/resources/db/migration/V20260515_1735__CH_create_likes_tables.sql new file mode 100644 index 00000000..4cafb269 --- /dev/null +++ b/src/main/resources/db/migration/V20260515_1735__CH_create_likes_tables.sql @@ -0,0 +1,19 @@ +CREATE TABLE daily_report_likes ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + daily_report_id BIGINT NOT NULL REFERENCES daily_reports(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_daily_report_likes_user_report UNIQUE (user_id, daily_report_id) +); + +CREATE INDEX idx_daily_report_likes_report ON daily_report_likes (daily_report_id); + +CREATE TABLE comment_likes ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + comment_id BIGINT NOT NULL REFERENCES comments(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_comment_likes_user_comment UNIQUE (user_id, comment_id) +); + +CREATE INDEX idx_comment_likes_comment ON comment_likes (comment_id); \ No newline at end of file diff --git a/src/main/resources/db/migration/V20260518_1500__CH_create_social_suspensions_table.sql b/src/main/resources/db/migration/V20260518_1500__CH_create_social_suspensions_table.sql new file mode 100644 index 00000000..52164e41 --- /dev/null +++ b/src/main/resources/db/migration/V20260518_1500__CH_create_social_suspensions_table.sql @@ -0,0 +1,11 @@ +CREATE TABLE social_suspensions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + + CONSTRAINT fk_social_suspensions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX idx_social_suspensions_user_expires ON social_suspensions(user_id, expires_at); +CREATE INDEX idx_social_suspensions_expires ON social_suspensions(expires_at); \ No newline at end of file diff --git a/src/main/resources/db/migration/V20260518_1503__CH_alter_content_reports_for_comments.sql b/src/main/resources/db/migration/V20260518_1503__CH_alter_content_reports_for_comments.sql new file mode 100644 index 00000000..3fa68161 --- /dev/null +++ b/src/main/resources/db/migration/V20260518_1503__CH_alter_content_reports_for_comments.sql @@ -0,0 +1,29 @@ +-- comment_id 컬럼 추가 +ALTER TABLE content_reports ADD COLUMN comment_id BIGINT; + +-- daily_report_id NOT NULL → nullable +ALTER TABLE content_reports ALTER COLUMN daily_report_id DROP NOT NULL; + +-- 기존 UNIQUE 제약 제거 +ALTER TABLE content_reports DROP CONSTRAINT uq_content_reports_reporter_daily_report; + +-- daily_report_id FK: ON DELETE CASCADE → ON DELETE SET NULL +ALTER TABLE content_reports DROP CONSTRAINT fk_content_reports_daily_report; +ALTER TABLE content_reports + ADD CONSTRAINT fk_content_reports_daily_report + FOREIGN KEY (daily_report_id) REFERENCES daily_reports(id) ON DELETE SET NULL; + +-- comment_id FK +ALTER TABLE content_reports + ADD CONSTRAINT fk_content_reports_comment + FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE SET NULL; + +-- 게시글 신고 partial unique (게시글 신고에서 중복 방지) +CREATE UNIQUE INDEX uq_content_reports_reporter_daily_report + ON content_reports(reporter_id, daily_report_id) + WHERE daily_report_id IS NOT NULL AND comment_id IS NULL; + +-- 댓글 신고 partial unique (댓글 신고에서 중복 방지) +CREATE UNIQUE INDEX uq_content_reports_reporter_comment + ON content_reports(reporter_id, comment_id) + WHERE comment_id IS NOT NULL; \ No newline at end of file diff --git a/src/main/resources/db/migration/V20260527_1600__IS_create_app_versions_table.sql b/src/main/resources/db/migration/V20260527_1600__IS_create_app_versions_table.sql new file mode 100644 index 00000000..8e615730 --- /dev/null +++ b/src/main/resources/db/migration/V20260527_1600__IS_create_app_versions_table.sql @@ -0,0 +1,34 @@ +CREATE TABLE app_versions ( + id BIGSERIAL PRIMARY KEY, + platform VARCHAR(20) NOT NULL, + version VARCHAR(30) NOT NULL, + is_latest BOOLEAN NOT NULL, + summary VARCHAR(120) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uk_app_versions_platform_version UNIQUE (platform, version) +); + +CREATE TABLE app_version_items ( + id BIGSERIAL PRIMARY KEY, + app_version_id BIGINT NOT NULL, + title VARCHAR(100) NOT NULL, + description VARCHAR(500) NOT NULL, + display_order INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_app_version_items_app_version FOREIGN KEY (app_version_id) + REFERENCES app_versions(id) ON DELETE CASCADE, + CONSTRAINT uk_app_version_items_order UNIQUE (app_version_id, display_order) +); + +CREATE INDEX idx_app_version_items_app_version_id + ON app_version_items (app_version_id); + +CREATE UNIQUE INDEX uk_app_versions_platform_latest + ON app_versions (platform) + WHERE is_latest = true; + +INSERT INTO app_versions (platform, version, is_latest, summary) VALUES + ('IOS', '1.2.0', true, ''), + ('ANDROID', '1.2.0', true, ''); diff --git a/src/main/resources/db/migration/V20260530_2300__IS_create_user_app_version_dismissals_table.sql b/src/main/resources/db/migration/V20260530_2300__IS_create_user_app_version_dismissals_table.sql new file mode 100644 index 00000000..bbf424e3 --- /dev/null +++ b/src/main/resources/db/migration/V20260530_2300__IS_create_user_app_version_dismissals_table.sql @@ -0,0 +1,11 @@ +CREATE TABLE user_app_version_dismissals ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + app_version_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_uavd_user FOREIGN KEY (user_id) + REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_uavd_app_version FOREIGN KEY (app_version_id) + REFERENCES app_versions(id) ON DELETE CASCADE, + CONSTRAINT uk_uavd_user_app_version UNIQUE (user_id, app_version_id) +); diff --git a/src/main/resources/db/migration/V20260531_1800__IS_create_user_withdrawal_reasons_table.sql b/src/main/resources/db/migration/V20260531_1800__IS_create_user_withdrawal_reasons_table.sql new file mode 100644 index 00000000..66c14f08 --- /dev/null +++ b/src/main/resources/db/migration/V20260531_1800__IS_create_user_withdrawal_reasons_table.sql @@ -0,0 +1,23 @@ +CREATE TABLE user_withdrawal_reasons ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + reason VARCHAR(50) NOT NULL, + custom_reason VARCHAR(200), + withdrawn_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT fk_user_withdrawal_reasons_user + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + + CONSTRAINT chk_user_withdrawal_reasons_other_custom_reason + CHECK ( + (reason = 'OTHER' AND custom_reason IS NOT NULL AND LENGTH(BTRIM(custom_reason)) > 0) + OR + (reason <> 'OTHER' AND custom_reason IS NULL) + ) +); + +CREATE INDEX idx_user_withdrawal_reasons_user_id ON user_withdrawal_reasons (user_id); +CREATE INDEX idx_user_withdrawal_reasons_reason_created_at ON user_withdrawal_reasons (reason, created_at DESC); +CREATE INDEX idx_user_withdrawal_reasons_withdrawn_at ON user_withdrawal_reasons (withdrawn_at DESC); +CREATE INDEX idx_user_withdrawal_reasons_user_withdrawn_at ON user_withdrawal_reasons (user_id, withdrawn_at DESC); diff --git a/src/main/resources/db/migration/V20260601_2000__IS_add_withdrawn_at_to_user_withdrawal_reasons.sql b/src/main/resources/db/migration/V20260601_2000__IS_add_withdrawn_at_to_user_withdrawal_reasons.sql new file mode 100644 index 00000000..a2c33637 --- /dev/null +++ b/src/main/resources/db/migration/V20260601_2000__IS_add_withdrawn_at_to_user_withdrawal_reasons.sql @@ -0,0 +1,15 @@ +ALTER TABLE user_withdrawal_reasons + ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ; + +UPDATE user_withdrawal_reasons +SET withdrawn_at = created_at +WHERE withdrawn_at IS NULL; + +ALTER TABLE user_withdrawal_reasons + ALTER COLUMN withdrawn_at SET NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_user_withdrawal_reasons_withdrawn_at + ON user_withdrawal_reasons (withdrawn_at DESC); + +CREATE INDEX IF NOT EXISTS idx_user_withdrawal_reasons_user_withdrawn_at + ON user_withdrawal_reasons (user_id, withdrawn_at DESC); diff --git a/src/main/resources/templates/admin/login.html b/src/main/resources/templates/admin/login.html new file mode 100644 index 00000000..0858d5c0 --- /dev/null +++ b/src/main/resources/templates/admin/login.html @@ -0,0 +1,164 @@ + + + + + + NADAB Admin Login + + + +
+

관리자 로그인

+

비밀번호를 입력해 버전 관리 페이지에 접근합니다.

+
+ + + +
+
+
+ + + + diff --git a/src/main/resources/templates/admin/version.html b/src/main/resources/templates/admin/version.html new file mode 100644 index 00000000..db82efe4 --- /dev/null +++ b/src/main/resources/templates/admin/version.html @@ -0,0 +1,735 @@ + + + + + + NADAB Admin Version + + + +
+
+

Admin Console

+ +
+ + + +
+

Create Version

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+

Current Latest Versions

+
+
+
+ + + + + + diff --git a/src/main/resources/templates/stats/daily.html b/src/main/resources/templates/stats/daily.html index 3378b75e..391d3dd8 100644 --- a/src/main/resources/templates/stats/daily.html +++ b/src/main/resources/templates/stats/daily.html @@ -280,6 +280,9 @@ 유형 + 탈퇴 전체 diff --git a/src/main/resources/templates/stats/monthly.html b/src/main/resources/templates/stats/monthly.html index 09a6c87f..4a2aa44e 100644 --- a/src/main/resources/templates/stats/monthly.html +++ b/src/main/resources/templates/stats/monthly.html @@ -257,6 +257,9 @@ 유형 + 탈퇴 전체 diff --git a/src/main/resources/templates/stats/total.html b/src/main/resources/templates/stats/total.html index f99411eb..96e7038d 100644 --- a/src/main/resources/templates/stats/total.html +++ b/src/main/resources/templates/stats/total.html @@ -289,6 +289,9 @@ 유형 + 탈퇴 전체 diff --git a/src/main/resources/templates/stats/type.html b/src/main/resources/templates/stats/type.html index 8396ed5f..390f5998 100644 --- a/src/main/resources/templates/stats/type.html +++ b/src/main/resources/templates/stats/type.html @@ -245,6 +245,9 @@ 유형 + 탈퇴 전체 diff --git a/src/main/resources/templates/stats/weekly.html b/src/main/resources/templates/stats/weekly.html index 0b022fc2..7ebc1192 100644 --- a/src/main/resources/templates/stats/weekly.html +++ b/src/main/resources/templates/stats/weekly.html @@ -257,6 +257,9 @@ 유형 + 탈퇴 전체 diff --git a/src/main/resources/templates/stats/withdrawal.html b/src/main/resources/templates/stats/withdrawal.html new file mode 100644 index 00000000..ff99c806 --- /dev/null +++ b/src/main/resources/templates/stats/withdrawal.html @@ -0,0 +1,322 @@ + + + + + + NADAB · Stats + + + + + + + + + + +
+
+
+
탈퇴 사유 분포
+
전체 탈퇴 사유 집계
+
+
+ +
+
+ +
+
+
탈퇴 이벤트 목록 (최신순)
+
표는 최신 100개 이벤트를 표시합니다.
+
+
+
+ + + + + + + + + + + + + + + +
탈퇴 시각선택 사유기타 사유
+
+
표시할 탈퇴 이벤트가 없습니다.
+
+
+
+ + + + diff --git a/src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2Test.java b/src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2Test.java new file mode 100644 index 00000000..8d2112db --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2Test.java @@ -0,0 +1,154 @@ +package com.devkor.ifive.nadab.domain.auth.application; + +import com.devkor.ifive.nadab.domain.auth.core.entity.UserWithdrawalReason; +import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalReasonType; +import com.devkor.ifive.nadab.domain.auth.core.repository.UserWithdrawalReasonRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.OffsetDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuthServiceV2Test { + + @Mock + WithdrawalService withdrawalService; + + @Mock + UserRepository userRepository; + + @Mock + UserWithdrawalReasonRepository userWithdrawalReasonRepository; + + AuthServiceV2 authServiceV2; + + @BeforeEach + void setUp() { + authServiceV2 = new AuthServiceV2( + withdrawalService, + userRepository, + userWithdrawalReasonRepository + ); + } + + @Test + void withdrawUser_saves_selected_reasons_with_effective_withdrawn_at() { + // given + Long userId = 1L; + User user = User.createUser("test@example.com", "hashed_password"); + doAnswer(invocation -> { + user.softDelete(); + return null; + }).when(withdrawalService).withdrawUser(userId); + when(userRepository.getReferenceById(userId)).thenReturn(user); + + // when + authServiceV2.withdrawUser( + userId, + List.of(WithdrawalReasonType.DAILY_LOGGING_BURDEN, WithdrawalReasonType.OTHER), + " custom reason " + ); + + // then + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(withdrawalService).withdrawUser(userId); + verify(userWithdrawalReasonRepository).saveAll(captor.capture()); + + List savedReasons = captor.getValue(); + OffsetDateTime deletedAt = user.getDeletedAt(); + + assertThat(savedReasons).hasSize(2); + assertThat(savedReasons) + .extracting(UserWithdrawalReason::getUser) + .containsOnly(user); + assertThat(savedReasons) + .extracting(UserWithdrawalReason::getWithdrawnAt) + .containsOnly(deletedAt); + assertThat(savedReasons) + .extracting(UserWithdrawalReason::getReason) + .containsExactly( + WithdrawalReasonType.DAILY_LOGGING_BURDEN, + WithdrawalReasonType.OTHER + ); + assertThat(savedReasons.get(0).getCustomReason()).isNull(); + assertThat(savedReasons.get(1).getCustomReason()).isEqualTo("custom reason"); + } + + @Test + void withdrawUser_rejects_empty_reasons_before_withdrawal() { + assertValidationFailure( + List.of(), + null, + ErrorCode.AUTH_WITHDRAWAL_REASON_REQUIRED + ); + } + + @Test + void withdrawUser_rejects_duplicated_reasons_before_withdrawal() { + assertValidationFailure( + List.of(WithdrawalReasonType.OTHER, WithdrawalReasonType.OTHER), + "custom reason", + ErrorCode.AUTH_WITHDRAWAL_REASON_DUPLICATED + ); + } + + @Test + void withdrawUser_rejects_other_without_custom_reason_before_withdrawal() { + assertValidationFailure( + List.of(WithdrawalReasonType.OTHER), + " ", + ErrorCode.AUTH_WITHDRAWAL_OTHER_REASON_REQUIRED + ); + } + + @Test + void withdrawUser_rejects_custom_reason_without_other_before_withdrawal() { + assertValidationFailure( + List.of(WithdrawalReasonType.APP_ERROR_OR_SLOWNESS), + "custom reason", + ErrorCode.AUTH_WITHDRAWAL_OTHER_REASON_NOT_ALLOWED + ); + } + + @Test + void withdrawUser_rejects_too_long_custom_reason_before_withdrawal() { + assertValidationFailure( + List.of(WithdrawalReasonType.OTHER), + "a".repeat(201), + ErrorCode.AUTH_WITHDRAWAL_OTHER_REASON_TOO_LONG + ); + } + + private void assertValidationFailure( + List reasons, + String customReason, + ErrorCode expectedErrorCode + ) { + assertThatThrownBy(() -> authServiceV2.withdrawUser(1L, reasons, customReason)) + .isInstanceOfSatisfying(BadRequestException.class, e -> + assertThat(e.getErrorCode()).isEqualTo(expectedErrorCode) + ); + + verify(withdrawalService, never()).withdrawUser(1L); + verify(userRepository, never()).getReferenceById(1L); + verify(userWithdrawalReasonRepository, never()).saveAll(anyList()); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsServiceTest.java new file mode 100644 index 00000000..a87dc158 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsServiceTest.java @@ -0,0 +1,63 @@ +package com.devkor.ifive.nadab.domain.stats.application; + +import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalReasonType; +import com.devkor.ifive.nadab.domain.stats.core.dto.withdrawal.WithdrawalEventRowViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.withdrawal.WithdrawalStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.repository.WithdrawalStatsRepository; +import org.junit.jupiter.api.Test; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class WithdrawalStatsServiceTest { + + @Test + void getWithdrawalStats_groups_latest_rows_by_user_and_withdrawn_at() { + // given + WithdrawalStatsRepository repo = mock(WithdrawalStatsRepository.class); + WithdrawalStatsService service = new WithdrawalStatsService(repo); + + OffsetDateTime withdrawnAt = OffsetDateTime.of( + 2026, 6, 1, 12, 30, 5, 900_000_000, ZoneOffset.UTC + ); + when(repo.findLatestWithdrawalReasonRows(100)).thenReturn(List.of( + row(1L, withdrawnAt, "DAILY_LOGGING_BURDEN", null), + row(1L, withdrawnAt, "OTHER", " custom reason "), + row(2L, Timestamp.valueOf(LocalDateTime.of(2026, 6, 2, 10, 0, 0)), "UNKNOWN_REASON", null) + )); + when(repo.countAllWithdrawalReasons()).thenReturn(List.of( + row("DAILY_LOGGING_BURDEN", 2L), + row("OTHER", 1L), + row("UNKNOWN_REASON", 99L) + )); + + // when + WithdrawalStatsViewModel vm = service.getWithdrawalStats(); + + // then + assertThat(vm.recentEventLimit()).isEqualTo(100); + assertThat(vm.eventCount()).isEqualTo(2); + assertThat(vm.reasonLabels()).hasSize(WithdrawalReasonType.values().length); + assertThat(vm.reasonCounts()).containsExactly(2L, 0L, 0L, 0L, 0L, 1L); + + WithdrawalEventRowViewModel first = vm.rows().get(0); + assertThat(first.withdrawnAt()).isEqualTo("2026-06-01 21:30:05"); + assertThat(first.reasons()).contains(", "); + assertThat(first.customReason()).isEqualTo("custom reason"); + + WithdrawalEventRowViewModel second = vm.rows().get(1); + assertThat(second.reasons()).isEqualTo("UNKNOWN_REASON"); + assertThat(second.customReason()).isEqualTo("-"); + } + + private Object[] row(Object... values) { + return values; + } +}