diff --git a/jenkins/prod/Jenkinsfile b/jenkins/prod/Jenkinsfile index 9f0513946..8d273d9e1 100644 --- a/jenkins/prod/Jenkinsfile +++ b/jenkins/prod/Jenkinsfile @@ -140,31 +140,52 @@ pipeline { } post { - failure { + success { script { - sendSlackBuildNotification(":scream_cat: Stage *${FAILED_STAGE}* failed.", env.SLACK_COLOR_FAILURE) + sendBuildNotification(":rocket: Deployment completed successfully", env.NOTIFICATION_COLOR_SUCCESS) } } - - success { + failure { script { - sendSlackBuildNotification(":rocket: Deployment completed successfully", env.SLACK_COLOR_SUCCESS) + sendBuildNotification(":scream_cat: Deployment failed in stage *${FAILED_STAGE}*", env.NOTIFICATION_COLOR_FAILURE) } } } } -def sendSlackBuildNotification(String message, String color) { +def sendBuildNotification(String message, String color) { def jobUrl = "${env.JENKINS_DOMAIN}/job/${env.JOB_NAME}" def consoleOutputUrl = "${jobUrl}/${env.BUILD_NUMBER}/console" + def changelog = env.GIT_CHANGELOG + + def notificationPlatforms = readJSON text: env.NOTIFICATION_PLATFORMS_JSON.trim() + + notificationPlatforms.each { notification -> + if (notification.enabled.toBoolean()) { + def platform = notification.platform ? notification.platform.trim().toLowerCase() : "" + echo "Processing notification for platform: '${platform}'" + + def payload = null + switch (platform) { + case 'slack': + payload = createSlackPayload(message, color, jobUrl, consoleOutputUrl, changelog) + break + case 'discord': + payload = createDiscordPayload(message, color, jobUrl, consoleOutputUrl, changelog) + break + default: + echo "Unsupported or undefined notification platform: '${platform}'" + } - def payload = createSlackPayload(message, color, jobUrl, consoleOutputUrl) - def payloadJson = groovy.json.JsonOutput.toJson(payload) - - sendHttpPostRequest(env.SLACK_WEBHOOK_URL, payloadJson) + if (payload != null) { + def payloadJson = groovy.json.JsonOutput.toJson(payload) + sendHttpPostRequest(notification.'webhook-url', payloadJson) + } + } + } } -def createSlackPayload(String message, String color, String jobUrl, String consoleOutputUrl) { +def createSlackPayload(String message, String color, String jobUrl, String consoleOutputUrl, String changelog) { return [ blocks: [ [ @@ -183,7 +204,7 @@ def createSlackPayload(String message, String color, String jobUrl, String conso type: "section", text: [ type: "mrkdwn", - text: "*Change Log:*\n${env.GIT_CHANGELOG}" + text: "*Change Log:*\n${changelog}" ] ], [ @@ -217,6 +238,23 @@ def createSlackPayload(String message, String color, String jobUrl, String conso ] } +def createDiscordPayload(String message, String color, String jobUrl, String consoleOutputUrl, String changelog) { + return [ + embeds: [ + [ + title: message, + color: parseColor(color), + description: "*Change Log:*\n${changelog}\n\n[Job](${jobUrl}) | [Console Output](${consoleOutputUrl})", + ] + ] + ] +} + +def parseColor(String hexColor) { + // Discord requires the color in decimal format + return Integer.parseInt(hexColor.replace("#", ""), 16) +} + def sendHttpPostRequest(String url, String payload) { def CONTENT_TYPE_JSON = 'application/json' def HTTP_POST = 'POST' @@ -233,9 +271,9 @@ def loadEnvironmentVariables(String configFile) { def config = readYaml(file: configFile) env.JENKINS_DOMAIN = config.'jenkins-domain' - env.SLACK_WEBHOOK_URL = config.slack.'webhook-url' - env.SLACK_COLOR_SUCCESS = config.slack.'color-success' - env.SLACK_COLOR_FAILURE = config.slack.'color-failure' + env.NOTIFICATION_COLOR_SUCCESS = config.notifications.common.'color-success' + env.NOTIFICATION_COLOR_FAILURE = config.notifications.common.'color-failure' + env.NOTIFICATION_PLATFORMS_JSON = groovy.json.JsonOutput.toJson(config.notifications.platforms) env.PG_USER = config.postgresql.user env.PG_PASSWORD = config.postgresql.password diff --git a/jenkins/prod/config.yml b/jenkins/prod/config.yml index c476127f5..ca0530808 100644 --- a/jenkins/prod/config.yml +++ b/jenkins/prod/config.yml @@ -1,60 +1,74 @@ -jenkins-domain: "https://jenkins.example.com" # Jenkins domain +jenkins-domain: "https://jenkins.example.com" # Base URL of the Jenkins instance -slack: - webhook-url: "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX" # Slack webhook URL - color-success: "#F2C744" # Slack message color for success - color-failure: "#8D1E0E" # Slack message color for failure +notifications: + common: + color-success: "#F2C744" # Common hex color code for success messages across all notification platforms + color-failure: "#8D1E0E" # Common hex color code for failure messages across all notification platforms + + platforms: + - platform: "slack" # Name of the notification platform (e.g., Slack) + enabled: true # Enable (true) or disable (false) notifications for this platform + webhook-url: "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX" # Webhook URL for sending messages to Slack + + - platform: "discord" # Name of the notification platform (e.g., Discord) + enabled: true # Enable (true) or disable (false) notifications for this platform + webhook-url: "https://discord.com/api/webhooks/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" # Webhook URL for sending messages to Discord + + # Add additional notification platforms below as needed + # Example: + # - platform: "microsoft-teams" + # enabled: true + # webhook-url: "https://outlook.office.com/webhook/..." postgresql: - user: "postgres_user" # PostgreSQL username - password: "postgres_password" # PostgreSQL password - backup-dir: "/var/backups/postgresql" # Directory for PostgreSQL backups + user: "postgres_user" # Username for PostgreSQL + password: "postgres_password" # Password for PostgreSQL + backup-dir: "/var/backups/postgresql" # Directory where PostgreSQL backups are stored dockerhub: - repo: "yourdockerhub/repository" # Docker Hub repository name - user: "dockerhub_user" # Docker Hub username - password: "dockerhub_password" # Docker Hub password + repo: "yourdockerhub/repository" # Docker Hub repository name + user: "dockerhub_user" # Docker Hub username + password: "dockerhub_password" # Docker Hub password external-server: - config-path: "/path/to/external/config" # Path for external server configuration - cloud-path: "/path/to/external/cloud" # Path for external server cloud storage - logs-path: "/path/to/external/logs" # Path for external server logs + config-path: "/path/to/external/config" # Path to external server configuration files + cloud-path: "/path/to/external/cloud" # Path to external server cloud storage + logs-path: "/path/to/external/logs" # Path to external server log files internal-server: - config-path: "/path/to/internal/config" # Path for internal server configuration - cloud-path: "/path/to/internal/cloud" # Path for internal server cloud storage - logs-path: "/path/to/internal/logs" # Path for internal server logs + config-path: "/path/to/internal/config" # Path to internal server configuration files + cloud-path: "/path/to/internal/cloud" # Path to internal server cloud storage + logs-path: "/path/to/internal/logs" # Path to internal server log files containers: - blue: "blue-container" # Blue-Green deployment: Blue container name - green: "green-container" # Blue-Green deployment: Green container name - blue-url: "http://blue-container:8080" # URL for the Blue container environment - green-url: "http://green-container:8080" # URL for the Green container environment - image-name: "application-image" # Docker image name for the application + blue: "blue-container" # Name of the Blue container in Blue-Green deployment + green: "green-container" # Name of the Green container in Blue-Green deployment + blue-url: "http://blue-container:8080" # URL for accessing the Blue container environment + green-url: "http://green-container:8080" # URL for accessing the Green container environment + image-name: "application-image" # Docker image name for the application networks: - application: "application-network" # Docker network for the application - monitoring: "monitoring-network" # Docker network for monitoring + application: "application-network" # Docker network used by the application + monitoring: "monitoring-network" # Docker network used for monitoring services spring: - profile: "default" # Spring profile setting - port-a: 8080 # Application port A - port-b: 8081 # Application port B + profile: "default" # Spring profile setting + port-a: 8080 # Application port A + port-b: 8081 # Application port B admin: - username: "admin" # Backend admin username - password: "admin_password" # Backend admin password + username: "admin" # Username for the backend admin + password: "admin_password" # Password for the backend admin docker: - dockerfile-path: "/jenkins/prod/Dockerfile" # Path to the Dockerfile - nginx-container-name: "nginx" # Nginx container name - postgresql-container-name: "postgresql" # PostgreSQL container name + dockerfile-path: "/jenkins/prod/Dockerfile" # Path to the Dockerfile used for building the application image + nginx-container-name: "nginx" # Name of the Nginx container + postgresql-container-name: "postgresql" # Name of the PostgreSQL container staging: - user: "user" # Staging user name - host: "host" # Staging host name or IP address - backup-dir-path: "/var/backups/postgresql" # Staging Directory Path for PostgreSQL backups - restore-backup-script-path: "/var/restore.sh" # Staging Restore Backup Script Path - ssh-port: 22 # Staging SSH Port - postgresql-user: "postgres_user" # Staging Postgresql User - + user: "user" # Username for accessing the staging environment + host: "host" # Hostname or IP address of the staging server + backup-dir-path: "/var/backups/postgresql" # Directory path on the staging server for PostgreSQL backups + restore-backup-script-path: "/var/restore.sh" # Path to the script on the staging server for restoring backups + ssh-port: 22 # SSH port for accessing the staging server + postgresql-user: "postgres_user" # PostgreSQL username on the staging server diff --git a/jenkins/stage/Jenkinsfile b/jenkins/stage/Jenkinsfile index d22b26d02..77ee2d304 100644 --- a/jenkins/stage/Jenkinsfile +++ b/jenkins/stage/Jenkinsfile @@ -118,31 +118,52 @@ pipeline { } post { - failure { + success { script { - sendSlackBuildNotification(":scream_cat: Stage *${FAILED_STAGE}* failed.", env.SLACK_COLOR_FAILURE) + sendBuildNotification(":rocket: Deployment completed successfully", env.NOTIFICATION_COLOR_SUCCESS) } } - - success { + failure { script { - sendSlackBuildNotification(":rocket: Deployment completed successfully", env.SLACK_COLOR_SUCCESS) + sendBuildNotification(":scream_cat: Deployment failed in stage *${FAILED_STAGE}*", env.NOTIFICATION_COLOR_FAILURE) } } } } -def sendSlackBuildNotification(String message, String color) { +def sendBuildNotification(String message, String color) { def jobUrl = "${env.JENKINS_DOMAIN}/job/${env.JOB_NAME}" def consoleOutputUrl = "${jobUrl}/${env.BUILD_NUMBER}/console" + def changelog = env.GIT_CHANGELOG + + def notificationPlatforms = readJSON text: env.NOTIFICATION_PLATFORMS_JSON.trim() + + notificationPlatforms.each { notification -> + if (notification.enabled.toBoolean()) { + def platform = notification.platform ? notification.platform.trim().toLowerCase() : "" + echo "Processing notification for platform: '${platform}'" + + def payload = null + switch (platform) { + case 'slack': + payload = createSlackPayload(message, color, jobUrl, consoleOutputUrl, changelog) + break + case 'discord': + payload = createDiscordPayload(message, color, jobUrl, consoleOutputUrl, changelog) + break + default: + echo "Unsupported or undefined notification platform: '${platform}'" + } - def payload = createSlackPayload(message, color, jobUrl, consoleOutputUrl) - def payloadJson = groovy.json.JsonOutput.toJson(payload) - - sendHttpPostRequest(env.SLACK_WEBHOOK_URL, payloadJson) + if (payload != null) { + def payloadJson = groovy.json.JsonOutput.toJson(payload) + sendHttpPostRequest(notification.'webhook-url', payloadJson) + } + } + } } -def createSlackPayload(String message, String color, String jobUrl, String consoleOutputUrl) { +def createSlackPayload(String message, String color, String jobUrl, String consoleOutputUrl, String changelog) { return [ blocks: [ [ @@ -161,7 +182,7 @@ def createSlackPayload(String message, String color, String jobUrl, String conso type: "section", text: [ type: "mrkdwn", - text: "*Change Log:*\n${env.GIT_CHANGELOG}" + text: "*Change Log:*\n${changelog}" ] ], [ @@ -195,6 +216,23 @@ def createSlackPayload(String message, String color, String jobUrl, String conso ] } +def createDiscordPayload(String message, String color, String jobUrl, String consoleOutputUrl, String changelog) { + return [ + embeds: [ + [ + title: message, + color: parseColor(color), + description: "*Change Log:*\n${changelog}\n\n[Job](${jobUrl}) | [Console Output](${consoleOutputUrl})", + ] + ] + ] +} + +def parseColor(String hexColor) { + // Discord requires the color in decimal format + return Integer.parseInt(hexColor.replace("#", ""), 16) +} + def sendHttpPostRequest(String url, String payload) { def CONTENT_TYPE_JSON = 'application/json' def HTTP_POST = 'POST' @@ -211,9 +249,9 @@ def loadEnvironmentVariables(String configFile) { def config = readYaml(file: configFile) env.JENKINS_DOMAIN = config.'jenkins-domain' - env.SLACK_WEBHOOK_URL = config.slack.'webhook-url' - env.SLACK_COLOR_SUCCESS = config.slack.'color-success' - env.SLACK_COLOR_FAILURE = config.slack.'color-failure' + env.NOTIFICATION_COLOR_SUCCESS = config.notifications.common.'color-success' + env.NOTIFICATION_COLOR_FAILURE = config.notifications.common.'color-failure' + env.NOTIFICATION_PLATFORMS_JSON = groovy.json.JsonOutput.toJson(config.notifications.platforms) env.PG_USER = config.postgresql.user env.PG_PASSWORD = config.postgresql.password diff --git a/jenkins/stage/config.yml b/jenkins/stage/config.yml index 4ba799d8c..7adf0faf3 100644 --- a/jenkins/stage/config.yml +++ b/jenkins/stage/config.yml @@ -1,50 +1,66 @@ -jenkins-domain: "https://jenkins.example.com" # Jenkins domain +jenkins-domain: "https://jenkins.example.com" # Base URL of the Jenkins instance -slack: - webhook-url: "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX" # Slack webhook URL - color-success: "#F2C744" # Slack message color for success - color-failure: "#8D1E0E" # Slack message color for failure +notifications: + common: + color-success: "#F2C744" # Common hex color code for success messages across all notification platforms + color-failure: "#8D1E0E" # Common hex color code for failure messages across all notification platforms + + platforms: + - platform: "slack" # Name of the notification platform (e.g., Slack) + enabled: true # Enable (true) or disable (false) notifications for this platform + webhook-url: "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX" # Webhook URL for sending messages to Slack + + - platform: "discord" # Name of the notification platform (e.g., Discord) + enabled: true # Enable (true) or disable (false) notifications for this platform + webhook-url: "https://discord.com/api/webhooks/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" # Webhook URL for sending messages to Discord + + # Add additional notification platforms below as needed + # Example: + # - platform: "microsoft-teams" + # enabled: true + # webhook-url: "https://outlook.office.com/webhook/..." postgresql: - user: "postgres_user" # PostgreSQL username - password: "postgres_password" # PostgreSQL password - backup-dir: "/var/backups/postgresql" # Directory for PostgreSQL backups + user: "postgres_user" # Username for PostgreSQL + password: "postgres_password" # Password for PostgreSQL + backup-dir: "/var/backups/postgresql" # Directory where PostgreSQL backups are stored dockerhub: - repo: "yourdockerhub/repository" # Docker Hub repository name - user: "dockerhub_user" # Docker Hub username - password: "dockerhub_password" # Docker Hub password + repo: "yourdockerhub/repository" # Docker Hub repository name + user: "dockerhub_user" # Docker Hub username + password: "dockerhub_password" # Docker Hub password external-server: - config-path: "/path/to/external/config" # Path for external server configuration - cloud-path: "/path/to/external/cloud" # Path for external server cloud storage - logs-path: "/path/to/external/logs" # Path for external server logs + config-path: "/path/to/external/config" # Path to external server configuration files + cloud-path: "/path/to/external/cloud" # Path to external server cloud storage + logs-path: "/path/to/external/logs" # Path to external server log files internal-server: - config-path: "/path/to/internal/config" # Path for internal server configuration - cloud-path: "/path/to/internal/cloud" # Path for internal server cloud storage - logs-path: "/path/to/internal/logs" # Path for internal server logs + config-path: "/path/to/internal/config" # Path to internal server configuration files + cloud-path: "/path/to/internal/cloud" # Path to internal server cloud storage + logs-path: "/path/to/internal/logs" # Path to internal server log files containers: - blue: "blue-container" # Blue-Green deployment: Blue container name - green: "green-container" # Blue-Green deployment: Green container name - blue-url: "http://blue-container:8080" # URL for the Blue container environment - green-url: "http://green-container:8080" # URL for the Green container environment - image-name: "application-image" # Docker image name for the application + blue: "blue-container" # Name of the Blue container in Blue-Green deployment + green: "green-container" # Name of the Green container in Blue-Green deployment + blue-url: "http://blue-container:8080" # URL for accessing the Blue container environment + green-url: "http://green-container:8080" # URL for accessing the Green container environment + image-name: "application-image" # Docker image name for the application networks: - application: "application-network" # Docker network for the application - monitoring: "monitoring-network" # Docker network for monitoring + application: "application-network" # Docker network used by the application + monitoring: "monitoring-network" # Docker network used for monitoring services spring: - profile: "default" # Spring profile setting - port-a: 8080 # Application port A - port-b: 8081 # Application port B + profile: "default" # Spring profile setting + port-a: 8080 # Application port A + port-b: 8081 # Application port B admin: - username: "admin" # Backend admin username - password: "admin_password" # Backend admin password + username: "admin" # Username for the backend admin + password: "admin_password" # Password for the backend admin + docker: - dockerfile-path: "/jenkins/stage/Dockerfile" # Path to the Dockerfile - nginx-container-name: "nginx" # Nginx container name - postgresql-container-name: "postgresql" # PostgreSQL container name + dockerfile-path: "/jenkins/stage/Dockerfile" # Path to the Dockerfile used for building the application image + nginx-container-name: "nginx" # Name of the Nginx container + postgresql-container-name: "postgresql" # Name of the PostgreSQL container diff --git a/src/main/java/page/clab/api/domain/community/board/adapter/in/web/BoardEmojiToggleController.java b/src/main/java/page/clab/api/domain/community/board/adapter/in/web/BoardEmojiToggleController.java index c3e322c5c..4b402ffd1 100644 --- a/src/main/java/page/clab/api/domain/community/board/adapter/in/web/BoardEmojiToggleController.java +++ b/src/main/java/page/clab/api/domain/community/board/adapter/in/web/BoardEmojiToggleController.java @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import page.clab.api.domain.community.board.application.dto.response.BoardEmojiToggleResponseDto; import page.clab.api.domain.community.board.application.port.in.ToggleBoardEmojiUseCase; import page.clab.api.global.common.dto.ApiResponse; @@ -22,11 +23,11 @@ public class BoardEmojiToggleController { @Operation(summary = "[U] 커뮤니티 게시글 이모지 누르기/취소하기", description = "ROLE_USER 이상의 권한이 필요함") @PreAuthorize("hasRole('USER')") @PostMapping("/{boardId}/react/{emoji}") - public ApiResponse toggleEmojiStatus( - @PathVariable(name = "boardId") Long boardId, - @PathVariable(name = "emoji") String emoji + public ApiResponse toggleEmojiStatus( + @PathVariable(name = "boardId") Long boardId, + @PathVariable(name = "emoji") String emoji ) { - String id = toggleBoardEmojiUseCase.toggleEmojiStatus(boardId, emoji); - return ApiResponse.success(id); + BoardEmojiToggleResponseDto responseDto = toggleBoardEmojiUseCase.toggleEmojiStatus(boardId, emoji); + return ApiResponse.success(responseDto); } } diff --git a/src/main/java/page/clab/api/domain/community/board/adapter/in/web/HotBoardsRetrievalController.java b/src/main/java/page/clab/api/domain/community/board/adapter/in/web/HotBoardsRetrievalController.java new file mode 100644 index 000000000..5236643cd --- /dev/null +++ b/src/main/java/page/clab/api/domain/community/board/adapter/in/web/HotBoardsRetrievalController.java @@ -0,0 +1,36 @@ +package page.clab.api.domain.community.board.adapter.in.web; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import page.clab.api.domain.community.board.application.dto.response.BoardListResponseDto; +import page.clab.api.domain.community.board.application.port.in.RetrieveHotBoardsUseCase; +import page.clab.api.global.common.dto.ApiResponse; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/boards") +@RequiredArgsConstructor +@Tag(name = "Community - Board", description = "커뮤니티 게시판") +public class HotBoardsRetrievalController { + + private final RetrieveHotBoardsUseCase retrieveHotBoardsUseCase; + + @Operation(summary = "[G] 커뮤니티 인기 게시글 목록 조회", description = "ROLE_GUEST 이상의 권한이 필요함
" + + "인기게시글 선정 전략별 조회가 가능함
" + + "- DEFAULT : 반응 순 기본 전략
") + @PreAuthorize("hasRole('GUEST')") + @GetMapping("/hot") + public ApiResponse> retrieveHotBoards( + @RequestParam(name = "strategyName", defaultValue = "DEFAULT") String strategyName + ) { + List boards = retrieveHotBoardsUseCase.retrieveHotBoards(strategyName); + return ApiResponse.success(boards); + } +} diff --git a/src/main/java/page/clab/api/domain/community/board/adapter/out/persistence/BoardPersistenceAdapter.java b/src/main/java/page/clab/api/domain/community/board/adapter/out/persistence/BoardPersistenceAdapter.java index 142661091..9b8602ed3 100644 --- a/src/main/java/page/clab/api/domain/community/board/adapter/out/persistence/BoardPersistenceAdapter.java +++ b/src/main/java/page/clab/api/domain/community/board/adapter/out/persistence/BoardPersistenceAdapter.java @@ -10,6 +10,10 @@ import page.clab.api.domain.community.board.domain.BoardCategory; import page.clab.api.global.exception.NotFoundException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + @Component @RequiredArgsConstructor public class BoardPersistenceAdapter implements @@ -40,6 +44,20 @@ public Board findByIdRegardlessOfDeletion(Long boardId) { .orElseThrow(() -> new NotFoundException("[Board] id: " + boardId + "에 해당하는 게시글이 존재하지 않습니다.")); } + @Override + public List findAllWithinDateRange(LocalDateTime startDate, LocalDateTime endDate) { + return boardRepository.findAllWithinDateRange(startDate, endDate).stream() + .map(boardMapper::toDomain) + .collect(Collectors.toList()); + } + + @Override + public List findAll() { + return boardRepository.findAll().stream() + .map(boardMapper::toDomain) + .collect(Collectors.toList()); + } + @Override public Page findAllByCategory(BoardCategory category, Pageable pageable) { return boardRepository.findAllByCategory(category, pageable) diff --git a/src/main/java/page/clab/api/domain/community/board/adapter/out/persistence/BoardRepository.java b/src/main/java/page/clab/api/domain/community/board/adapter/out/persistence/BoardRepository.java index bbb0dc8ed..31d51db74 100644 --- a/src/main/java/page/clab/api/domain/community/board/adapter/out/persistence/BoardRepository.java +++ b/src/main/java/page/clab/api/domain/community/board/adapter/out/persistence/BoardRepository.java @@ -1,5 +1,6 @@ package page.clab.api.domain.community.board.adapter.out.persistence; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; @@ -16,6 +17,9 @@ public interface BoardRepository extends JpaRepository { @Query("SELECT b FROM BoardJpaEntity b WHERE b.memberId = ?1 AND b.isDeleted = false") Page findAllByMemberIdAndIsDeletedFalse(String memberId, Pageable pageable); + @Query("SELECT b FROM BoardJpaEntity b WHERE b.createdAt BETWEEN :start AND :end AND b.isDeleted = false") + List findAllWithinDateRange(LocalDateTime start, LocalDateTime end); + Page findAllByCategory(BoardCategory category, Pageable pageable); @Query(value = "SELECT b.* FROM board b WHERE b.is_deleted = true", nativeQuery = true) diff --git a/src/main/java/page/clab/api/domain/community/board/adapter/out/persistence/RedisHotBoardPersistenceAdapter.java b/src/main/java/page/clab/api/domain/community/board/adapter/out/persistence/RedisHotBoardPersistenceAdapter.java new file mode 100644 index 000000000..3ff92cf92 --- /dev/null +++ b/src/main/java/page/clab/api/domain/community/board/adapter/out/persistence/RedisHotBoardPersistenceAdapter.java @@ -0,0 +1,58 @@ +package page.clab.api.domain.community.board.adapter.out.persistence; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; +import page.clab.api.domain.community.board.application.port.out.RegisterHotBoardPort; +import page.clab.api.domain.community.board.application.port.out.RemoveHotBoardPort; +import page.clab.api.domain.community.board.application.port.out.RetrieveHotBoardPort; + +import java.util.List; +import java.util.Objects; +import java.util.Set; + +@Component +@RequiredArgsConstructor +public class RedisHotBoardPersistenceAdapter implements + RegisterHotBoardPort, + RetrieveHotBoardPort, + RemoveHotBoardPort { + + private static final String HOT_BOARDS_PREFIX = "hotBoards"; + + private final RedisTemplate redisTemplate; + + @Override + public void save(String boardId, String strategyName) { + String key = getRedisKey(strategyName); + redisTemplate.opsForList().rightPush(key, boardId); + } + + @Override + public List findByHotBoardStrategy(String strategyName) { + String key = getRedisKey(strategyName); + List hotBoards = redisTemplate.opsForList().range(key, 0, -1); + return (hotBoards != null) ? hotBoards : List.of(); + } + + @Override + public void clearHotBoard() { + String pattern = HOT_BOARDS_PREFIX + ":*"; + Set keys = redisTemplate.keys(pattern); + if (keys == null) { + return; + } + keys.stream() + .filter(Objects::nonNull) + .forEach(key -> { + Long size = redisTemplate.opsForList().size(key); + if (size != null && size > 0) { + redisTemplate.delete(key); + } + }); + } + + private String getRedisKey(String strategyName) { + return String.format("%s:%s", HOT_BOARDS_PREFIX, strategyName); + } +} diff --git a/src/main/java/page/clab/api/domain/community/board/application/dto/mapper/BoardDtoMapper.java b/src/main/java/page/clab/api/domain/community/board/application/dto/mapper/BoardDtoMapper.java index 33a546928..0ed4eae3e 100644 --- a/src/main/java/page/clab/api/domain/community/board/application/dto/mapper/BoardDtoMapper.java +++ b/src/main/java/page/clab/api/domain/community/board/application/dto/mapper/BoardDtoMapper.java @@ -7,6 +7,7 @@ import page.clab.api.domain.community.board.application.dto.response.BoardDetailsResponseDto; import page.clab.api.domain.community.board.application.dto.response.BoardEmojiCountResponseDto; import page.clab.api.domain.community.board.application.dto.response.BoardHashtagResponseDto; +import page.clab.api.domain.community.board.application.dto.response.BoardEmojiToggleResponseDto; import page.clab.api.domain.community.board.application.dto.response.BoardListResponseDto; import page.clab.api.domain.community.board.application.dto.response.BoardMyResponseDto; import page.clab.api.domain.community.board.application.dto.response.BoardOverviewResponseDto; diff --git a/src/main/java/page/clab/api/domain/community/board/application/dto/mapper/BoardEmojiDtoMapper.java b/src/main/java/page/clab/api/domain/community/board/application/dto/mapper/BoardEmojiDtoMapper.java new file mode 100644 index 000000000..be063ce1c --- /dev/null +++ b/src/main/java/page/clab/api/domain/community/board/application/dto/mapper/BoardEmojiDtoMapper.java @@ -0,0 +1,18 @@ +package page.clab.api.domain.community.board.application.dto.mapper; + +import org.springframework.stereotype.Component; +import page.clab.api.domain.community.board.application.dto.response.BoardEmojiToggleResponseDto; +import page.clab.api.domain.community.board.domain.BoardEmoji; + +@Component +public class BoardEmojiDtoMapper { + + public BoardEmojiToggleResponseDto toDto(BoardEmoji boardEmoji, String category) { + return BoardEmojiToggleResponseDto.builder() + .boardId(boardEmoji.getBoardId()) + .category(category) + .emoji(boardEmoji.getEmoji()) + .isDeleted(boardEmoji.getIsDeleted()) + .build(); + } +} diff --git a/src/main/java/page/clab/api/domain/community/board/application/dto/response/BoardEmojiToggleResponseDto.java b/src/main/java/page/clab/api/domain/community/board/application/dto/response/BoardEmojiToggleResponseDto.java new file mode 100644 index 000000000..4dbf1b331 --- /dev/null +++ b/src/main/java/page/clab/api/domain/community/board/application/dto/response/BoardEmojiToggleResponseDto.java @@ -0,0 +1,14 @@ +package page.clab.api.domain.community.board.application.dto.response; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class BoardEmojiToggleResponseDto { + + private Long boardId; + private String category; + private String emoji; + private Boolean isDeleted; +} diff --git a/src/main/java/page/clab/api/domain/community/board/application/port/in/RetrieveHotBoardsUseCase.java b/src/main/java/page/clab/api/domain/community/board/application/port/in/RetrieveHotBoardsUseCase.java new file mode 100644 index 000000000..11da513c5 --- /dev/null +++ b/src/main/java/page/clab/api/domain/community/board/application/port/in/RetrieveHotBoardsUseCase.java @@ -0,0 +1,9 @@ +package page.clab.api.domain.community.board.application.port.in; + +import page.clab.api.domain.community.board.application.dto.response.BoardListResponseDto; + +import java.util.List; + +public interface RetrieveHotBoardsUseCase { + List retrieveHotBoards(String strategyName); +} diff --git a/src/main/java/page/clab/api/domain/community/board/application/port/in/ToggleBoardEmojiUseCase.java b/src/main/java/page/clab/api/domain/community/board/application/port/in/ToggleBoardEmojiUseCase.java index 0b6a0a062..75ab8e5ad 100644 --- a/src/main/java/page/clab/api/domain/community/board/application/port/in/ToggleBoardEmojiUseCase.java +++ b/src/main/java/page/clab/api/domain/community/board/application/port/in/ToggleBoardEmojiUseCase.java @@ -1,6 +1,8 @@ package page.clab.api.domain.community.board.application.port.in; +import page.clab.api.domain.community.board.application.dto.response.BoardEmojiToggleResponseDto; + public interface ToggleBoardEmojiUseCase { - String toggleEmojiStatus(Long boardId, String emoji); + BoardEmojiToggleResponseDto toggleEmojiStatus(Long boardId, String emoji); } diff --git a/src/main/java/page/clab/api/domain/community/board/application/port/out/RegisterHotBoardPort.java b/src/main/java/page/clab/api/domain/community/board/application/port/out/RegisterHotBoardPort.java new file mode 100644 index 000000000..914b10dc0 --- /dev/null +++ b/src/main/java/page/clab/api/domain/community/board/application/port/out/RegisterHotBoardPort.java @@ -0,0 +1,5 @@ +package page.clab.api.domain.community.board.application.port.out; + +public interface RegisterHotBoardPort { + void save(String boardId, String strategyName); +} diff --git a/src/main/java/page/clab/api/domain/community/board/application/port/out/RemoveHotBoardPort.java b/src/main/java/page/clab/api/domain/community/board/application/port/out/RemoveHotBoardPort.java new file mode 100644 index 000000000..c0fbe82c6 --- /dev/null +++ b/src/main/java/page/clab/api/domain/community/board/application/port/out/RemoveHotBoardPort.java @@ -0,0 +1,5 @@ +package page.clab.api.domain.community.board.application.port.out; + +public interface RemoveHotBoardPort { + void clearHotBoard(); +} diff --git a/src/main/java/page/clab/api/domain/community/board/application/port/out/RetrieveBoardPort.java b/src/main/java/page/clab/api/domain/community/board/application/port/out/RetrieveBoardPort.java index 54aabd972..5e42bcc25 100644 --- a/src/main/java/page/clab/api/domain/community/board/application/port/out/RetrieveBoardPort.java +++ b/src/main/java/page/clab/api/domain/community/board/application/port/out/RetrieveBoardPort.java @@ -5,12 +5,19 @@ import page.clab.api.domain.community.board.domain.Board; import page.clab.api.domain.community.board.domain.BoardCategory; +import java.time.LocalDateTime; +import java.util.List; + public interface RetrieveBoardPort { Board getById(Long boardId); Board findByIdRegardlessOfDeletion(Long boardId); + List findAllWithinDateRange(LocalDateTime startDate, LocalDateTime endDate); + + List findAll(); + Page findAll(Pageable pageable); Page findAllByCategory(BoardCategory category, Pageable pageable); diff --git a/src/main/java/page/clab/api/domain/community/board/application/port/out/RetrieveHotBoardPort.java b/src/main/java/page/clab/api/domain/community/board/application/port/out/RetrieveHotBoardPort.java new file mode 100644 index 000000000..a1042709f --- /dev/null +++ b/src/main/java/page/clab/api/domain/community/board/application/port/out/RetrieveHotBoardPort.java @@ -0,0 +1,7 @@ +package page.clab.api.domain.community.board.application.port.out; + +import java.util.List; + +public interface RetrieveHotBoardPort { + List findByHotBoardStrategy(String strategyName); +} diff --git a/src/main/java/page/clab/api/domain/community/board/application/service/BoardEmojiToggleService.java b/src/main/java/page/clab/api/domain/community/board/application/service/BoardEmojiToggleService.java index 5ad4c5aa3..7f5f8d4a0 100644 --- a/src/main/java/page/clab/api/domain/community/board/application/service/BoardEmojiToggleService.java +++ b/src/main/java/page/clab/api/domain/community/board/application/service/BoardEmojiToggleService.java @@ -3,6 +3,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import page.clab.api.domain.community.board.application.dto.mapper.BoardEmojiDtoMapper; +import page.clab.api.domain.community.board.application.dto.response.BoardEmojiToggleResponseDto; import page.clab.api.domain.community.board.application.port.in.ToggleBoardEmojiUseCase; import page.clab.api.domain.community.board.application.port.out.RegisterBoardEmojiPort; import page.clab.api.domain.community.board.application.port.out.RetrieveBoardEmojiPort; @@ -22,6 +24,7 @@ public class BoardEmojiToggleService implements ToggleBoardEmojiUseCase { private final RetrieveBoardEmojiPort retrieveBoardEmojiPort; private final RegisterBoardEmojiPort registerBoardEmojiPort; private final ExternalRetrieveMemberUseCase externalRetrieveMemberUseCase; + private final BoardEmojiDtoMapper mapper; /** * 게시글의 이모지 상태를 토글합니다. @@ -36,7 +39,7 @@ public class BoardEmojiToggleService implements ToggleBoardEmojiUseCase { */ @Transactional @Override - public String toggleEmojiStatus(Long boardId, String emoji) { + public BoardEmojiToggleResponseDto toggleEmojiStatus(Long boardId, String emoji) { if (!EmojiUtils.isEmoji(emoji)) { throw new InvalidEmojiException("지원하지 않는 이모지입니다."); } @@ -50,6 +53,6 @@ public String toggleEmojiStatus(Long boardId, String emoji) { }) .orElseGet(() -> BoardEmoji.create(memberId, boardId, emoji)); registerBoardEmojiPort.save(boardEmoji); - return board.getCategory().getKey(); + return mapper.toDto(boardEmoji, board.getCategory().getKey()); } } diff --git a/src/main/java/page/clab/api/domain/community/board/application/service/DefaultHotBoardSelectionStrategy.java b/src/main/java/page/clab/api/domain/community/board/application/service/DefaultHotBoardSelectionStrategy.java new file mode 100644 index 000000000..b31a4f1c3 --- /dev/null +++ b/src/main/java/page/clab/api/domain/community/board/application/service/DefaultHotBoardSelectionStrategy.java @@ -0,0 +1,86 @@ +package page.clab.api.domain.community.board.application.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import page.clab.api.domain.community.board.application.port.out.RetrieveBoardEmojiPort; +import page.clab.api.domain.community.board.application.port.out.RetrieveBoardPort; +import page.clab.api.domain.community.board.domain.Board; +import page.clab.api.external.community.comment.application.port.ExternalRetrieveCommentUseCase; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +@Service(HotBoardSelectionStrategies.DEFAULT) +@RequiredArgsConstructor +public class DefaultHotBoardSelectionStrategy implements HotBoardSelectionStrategy { + + private final RetrieveBoardPort retrieveBoardPort; + private final RetrieveBoardEmojiPort retrieveBoardEmojiPort; + private final ExternalRetrieveCommentUseCase externalRetrieveCommentUseCase; + + @Transactional + @Override + public List getHotBoards() { + // 만약 게시글의 총 개수가 5개보다 적다면 모든 게시글 반환 + List allBoards = retrieveBoardPort.findAll(); + if (allBoards.size() < 5) { + return sortBoardsByReactionAndDateWithLimit(allBoards.size(), allBoards); + } + + List hotBoards = getHotBoardsForWeek(1, 5); + + int weeksAgo = 2; + // 필요한 수량을 확보할 때까지 반복해서 이전 주로 이동하여 인기 게시글 보충 + while (hotBoards.size() < 5) { + List additionalBoards = getLatestHotBoardForWeek(weeksAgo++, 5 - hotBoards.size()); + if (additionalBoards != null && !additionalBoards.isEmpty()) { + hotBoards.addAll(additionalBoards); + } + } + + return hotBoards; + } + + private List getHotBoardsForWeek(int weeksAgo, int size) { + LocalDateTime startOfWeek = LocalDate.now().minusWeeks(weeksAgo).with(DayOfWeek.MONDAY).atStartOfDay(); + LocalDateTime endOfWeek = LocalDate.now().minusWeeks(weeksAgo).with(DayOfWeek.SUNDAY).atTime(23, 59, 59); + + List boardsForWeek = retrieveBoardPort.findAllWithinDateRange(startOfWeek, endOfWeek); + + return sortBoardsByReactionAndDateWithLimit(size, boardsForWeek); + } + + private List sortBoardsByReactionAndDateWithLimit(int size, List boardsForWeek) { + if (boardsForWeek == null) { + return null; + } + + return boardsForWeek.stream() + .sorted(Comparator + .comparingInt(this::getTotalReactionCount).reversed() + .thenComparing(Board::getCreatedAt, Comparator.reverseOrder())) + .limit(size) + .collect(Collectors.toList()); + } + + private List getLatestHotBoardForWeek(int weeksAgo, int size) { + + List topHotBoardsForWeek = getHotBoardsForWeek(weeksAgo, size); + + return topHotBoardsForWeek.stream() + .sorted(Comparator.comparing(Board::getCreatedAt).reversed()) + .toList(); + } + + private int getTotalReactionCount(Board board) { + Long commentCount = externalRetrieveCommentUseCase.countByBoardId(board.getId()); + int emojiCount = retrieveBoardEmojiPort.findEmojiClickCountsByBoardId(board.getId(), null).size(); + + return commentCount.intValue() + emojiCount; + } +} diff --git a/src/main/java/page/clab/api/domain/community/board/application/service/HotBoardRegisterService.java b/src/main/java/page/clab/api/domain/community/board/application/service/HotBoardRegisterService.java new file mode 100644 index 000000000..462ef38dc --- /dev/null +++ b/src/main/java/page/clab/api/domain/community/board/application/service/HotBoardRegisterService.java @@ -0,0 +1,62 @@ +package page.clab.api.domain.community.board.application.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import page.clab.api.domain.community.board.application.port.out.RegisterHotBoardPort; +import page.clab.api.domain.community.board.application.port.out.RemoveHotBoardPort; +import page.clab.api.domain.community.board.domain.Board; + +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class HotBoardRegisterService { + + private final Map strategyMap; + private final RegisterHotBoardPort registerHotBoardPort; + private final RemoveHotBoardPort removeHotBoardPort; + + /** + * 주어진 전략을 기반으로 인기 게시글을 등록합니다. + *

+ * 지정된 전략을 기반으로 인기 게시글을 선정하고, 이를 Redis에 저장합니다. + * + * @param strategyName 인기 게시글 선정 전략 이름 + * @return 인기 게시글 ID 리스트 + */ + @Transactional + public List registerHotBoards(String strategyName) { + HotBoardSelectionStrategy strategy = strategyMap.get(strategyName); + List hotBoardIds = getHotBoardIds(strategy); + hotBoardIds.forEach(id -> registerHotBoardPort.save(id, strategyName)); + return hotBoardIds; + } + + /** + * 기본 전략을 이용하여 인기 게시글을 등록합니다. + *

+ * 매주 월요일 자정에 실행되도록 스케줄링되어 있습니다. 먼저 기존 인기 게시글을 초기화한 뒤, + * 기본 전략을 이용하여 선정된 인기 게시글을 Redis에 저장합니다. + */ + @Transactional + @Scheduled(cron = "0 0 0 * * MON") // 매주 월요일 00:00 실행 + public void registerDefaultHotBoards() { + HotBoardSelectionStrategy strategy = strategyMap.get(HotBoardSelectionStrategies.DEFAULT); + + removeHotBoardPort.clearHotBoard(); // 저장된 지난 모든 인기 게시글 초기화 + + List hotBoardIds = getHotBoardIds(strategy); + hotBoardIds.forEach(id -> + registerHotBoardPort.save(id, HotBoardSelectionStrategies.DEFAULT)); + } + + private static List getHotBoardIds(HotBoardSelectionStrategy strategy) { + return strategy.getHotBoards().stream() + .map(Board::getId) + .map(String::valueOf) + .toList(); + } +} diff --git a/src/main/java/page/clab/api/domain/community/board/application/service/HotBoardRetrievalService.java b/src/main/java/page/clab/api/domain/community/board/application/service/HotBoardRetrievalService.java new file mode 100644 index 000000000..aed58c011 --- /dev/null +++ b/src/main/java/page/clab/api/domain/community/board/application/service/HotBoardRetrievalService.java @@ -0,0 +1,64 @@ +package page.clab.api.domain.community.board.application.service; + +import com.drew.lang.annotations.NotNull; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import page.clab.api.domain.community.board.application.dto.mapper.BoardDtoMapper; +import page.clab.api.domain.community.board.application.dto.response.BoardListResponseDto; +import page.clab.api.domain.community.board.application.port.in.RetrieveHotBoardsUseCase; +import page.clab.api.domain.community.board.application.port.out.RetrieveBoardPort; +import page.clab.api.domain.community.board.application.port.out.RetrieveHotBoardPort; +import page.clab.api.domain.community.board.domain.Board; +import page.clab.api.domain.memberManagement.member.application.dto.shared.MemberDetailedInfoDto; +import page.clab.api.external.community.comment.application.port.ExternalRetrieveCommentUseCase; +import page.clab.api.external.memberManagement.member.application.port.ExternalRetrieveMemberUseCase; + +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class HotBoardRetrievalService implements RetrieveHotBoardsUseCase { + + private final RetrieveHotBoardPort retrieveHotBoardPort; + private final RetrieveBoardPort retrieveBoardPort; + private final ExternalRetrieveMemberUseCase externalRetrieveMemberUseCase; + private final ExternalRetrieveCommentUseCase externalRetrieveCommentUseCase; + private final HotBoardRegisterService hotBoardRegisterService; + private final Map strategyMap; + private final BoardDtoMapper mapper; + + @Transactional + @Override + public List retrieveHotBoards(String strategyName) { + String validatedStrategyName = validateStrategyName(strategyName); + List hotBoardIds = retrieveHotBoardPort.findByHotBoardStrategy(validatedStrategyName); + if (hotBoardIds.isEmpty()) { + hotBoardIds = hotBoardRegisterService.registerHotBoards(validatedStrategyName); + } + + return hotBoardIds.stream() + .map(hotBoardId -> retrieveBoardPort.getById(Long.parseLong(hotBoardId))) + .map(board -> mapToBoardListResponseDto(board, getMemberDetailedInfoByBoard(board))) + .toList(); + } + + private MemberDetailedInfoDto getMemberDetailedInfoByBoard(Board board) { + return externalRetrieveMemberUseCase.getMemberDetailedInfoById(board.getMemberId()); + } + + @NotNull + private BoardListResponseDto mapToBoardListResponseDto(Board board, MemberDetailedInfoDto memberInfo) { + Long commentCount = externalRetrieveCommentUseCase.countByBoardId(board.getId()); + + return mapper.toListDto(board, memberInfo, commentCount); + } + + private String validateStrategyName(String strategyName) { + if (strategyName == null || !strategyMap.containsKey(strategyName)) { + strategyName = HotBoardSelectionStrategies.DEFAULT; + } + return strategyName; + } +} diff --git a/src/main/java/page/clab/api/domain/community/board/application/service/HotBoardSelectionStrategies.java b/src/main/java/page/clab/api/domain/community/board/application/service/HotBoardSelectionStrategies.java new file mode 100644 index 000000000..1b37ef5c5 --- /dev/null +++ b/src/main/java/page/clab/api/domain/community/board/application/service/HotBoardSelectionStrategies.java @@ -0,0 +1,10 @@ +package page.clab.api.domain.community.board.application.service; + +public class HotBoardSelectionStrategies { + + public static final String DEFAULT = "default"; + + private HotBoardSelectionStrategies() { + // 인스턴스화 방지 + } +} diff --git a/src/main/java/page/clab/api/domain/community/board/application/service/HotBoardSelectionStrategy.java b/src/main/java/page/clab/api/domain/community/board/application/service/HotBoardSelectionStrategy.java new file mode 100644 index 000000000..6a2d4a5c9 --- /dev/null +++ b/src/main/java/page/clab/api/domain/community/board/application/service/HotBoardSelectionStrategy.java @@ -0,0 +1,9 @@ +package page.clab.api.domain.community.board.application.service; + +import page.clab.api.domain.community.board.domain.Board; + +import java.util.List; + +public interface HotBoardSelectionStrategy { + List getHotBoards(); +} diff --git a/src/main/java/page/clab/api/domain/community/comment/adapter/in/web/CommentLikeToggleController.java b/src/main/java/page/clab/api/domain/community/comment/adapter/in/web/CommentLikeToggleController.java index 69e840726..0f2f47597 100644 --- a/src/main/java/page/clab/api/domain/community/comment/adapter/in/web/CommentLikeToggleController.java +++ b/src/main/java/page/clab/api/domain/community/comment/adapter/in/web/CommentLikeToggleController.java @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import page.clab.api.domain.community.comment.application.dto.response.CommentLikeToggleResponseDto; import page.clab.api.domain.community.comment.application.port.in.ToggleCommentLikeUseCase; import page.clab.api.global.common.dto.ApiResponse; @@ -22,10 +23,10 @@ public class CommentLikeToggleController { @Operation(summary = "[U] 댓글 좋아요 누르기/취소하기", description = "ROLE_USER 이상의 권한이 필요함") @PreAuthorize("hasRole('USER')") @PostMapping("/likes/{commentId}") - public ApiResponse toggleLikeStatus( - @PathVariable(name = "commentId") Long commentId + public ApiResponse toggleLikeStatus( + @PathVariable(name = "commentId") Long commentId ) { - Long id = toggleCommentLikeUseCase.toggleLikeStatus(commentId); - return ApiResponse.success(id); + CommentLikeToggleResponseDto responseDto = toggleCommentLikeUseCase.toggleLikeStatus(commentId); + return ApiResponse.success(responseDto); } } diff --git a/src/main/java/page/clab/api/domain/community/comment/application/dto/mapper/CommentLikeDtoMapper.java b/src/main/java/page/clab/api/domain/community/comment/application/dto/mapper/CommentLikeDtoMapper.java new file mode 100644 index 000000000..872568c56 --- /dev/null +++ b/src/main/java/page/clab/api/domain/community/comment/application/dto/mapper/CommentLikeDtoMapper.java @@ -0,0 +1,16 @@ +package page.clab.api.domain.community.comment.application.dto.mapper; + +import org.springframework.stereotype.Component; +import page.clab.api.domain.community.comment.application.dto.response.CommentLikeToggleResponseDto; + +@Component +public class CommentLikeDtoMapper { + + public CommentLikeToggleResponseDto of(Long boardId, Long commentLikes, Boolean isDeleted) { + return CommentLikeToggleResponseDto.builder() + .boardId(boardId) + .likes(commentLikes) + .isDeleted(isDeleted) + .build(); + } +} diff --git a/src/main/java/page/clab/api/domain/community/comment/application/dto/response/CommentLikeToggleResponseDto.java b/src/main/java/page/clab/api/domain/community/comment/application/dto/response/CommentLikeToggleResponseDto.java new file mode 100644 index 000000000..a7317773f --- /dev/null +++ b/src/main/java/page/clab/api/domain/community/comment/application/dto/response/CommentLikeToggleResponseDto.java @@ -0,0 +1,15 @@ +package page.clab.api.domain.community.comment.application.dto.response; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Builder +public class CommentLikeToggleResponseDto { + + private Long boardId; + private Long likes; + private Boolean isDeleted; +} diff --git a/src/main/java/page/clab/api/domain/community/comment/application/port/in/ToggleCommentLikeUseCase.java b/src/main/java/page/clab/api/domain/community/comment/application/port/in/ToggleCommentLikeUseCase.java index d39e73af1..744263c6b 100644 --- a/src/main/java/page/clab/api/domain/community/comment/application/port/in/ToggleCommentLikeUseCase.java +++ b/src/main/java/page/clab/api/domain/community/comment/application/port/in/ToggleCommentLikeUseCase.java @@ -1,6 +1,8 @@ package page.clab.api.domain.community.comment.application.port.in; +import page.clab.api.domain.community.comment.application.dto.response.CommentLikeToggleResponseDto; + public interface ToggleCommentLikeUseCase { - Long toggleLikeStatus(Long commentId); + CommentLikeToggleResponseDto toggleLikeStatus(Long commentId); } diff --git a/src/main/java/page/clab/api/domain/community/comment/application/service/CommentLikeToggleService.java b/src/main/java/page/clab/api/domain/community/comment/application/service/CommentLikeToggleService.java index 32b4edaed..34c4ab0a3 100644 --- a/src/main/java/page/clab/api/domain/community/comment/application/service/CommentLikeToggleService.java +++ b/src/main/java/page/clab/api/domain/community/comment/application/service/CommentLikeToggleService.java @@ -3,6 +3,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import page.clab.api.domain.community.comment.application.dto.mapper.CommentLikeDtoMapper; +import page.clab.api.domain.community.comment.application.dto.response.CommentLikeToggleResponseDto; import page.clab.api.domain.community.comment.application.port.in.ToggleCommentLikeUseCase; import page.clab.api.domain.community.comment.application.port.out.RegisterCommentLikePort; import page.clab.api.domain.community.comment.application.port.out.RemoveCommentLikePort; @@ -23,6 +25,7 @@ public class CommentLikeToggleService implements ToggleCommentLikeUseCase { private final ExternalRetrieveCommentUseCase externalRetrieveCommentUseCase; private final ExternalRegisterCommentUseCase externalRegisterCommentUseCase; private final ExternalRetrieveMemberUseCase externalRetrieveMemberUseCase; + private final CommentLikeDtoMapper mapper; /** * 댓글의 좋아요 상태를 토글합니다. @@ -35,20 +38,23 @@ public class CommentLikeToggleService implements ToggleCommentLikeUseCase { */ @Transactional @Override - public Long toggleLikeStatus(Long commentId) { + public CommentLikeToggleResponseDto toggleLikeStatus(Long commentId) { String currentMemberId = externalRetrieveMemberUseCase.getCurrentMemberId(); Comment comment = externalRetrieveCommentUseCase.getById(commentId); + Long boardId = comment.getBoardId(); return retrieveCommentLikePort.findByCommentIdAndMemberId(comment.getId(), currentMemberId) - .map(commentLike -> { - removeCommentLikePort.delete(commentLike); - comment.decrementLikes(); - return externalRegisterCommentUseCase.save(comment).getLikes(); - }) - .orElseGet(() -> { - CommentLike newLike = CommentLike.create(currentMemberId, comment.getId()); - registerCommentLikePort.save(newLike); - comment.incrementLikes(); - return externalRegisterCommentUseCase.save(comment).getLikes(); - }); + .map(commentLike -> { + removeCommentLikePort.delete(commentLike); + comment.decrementLikes(); + externalRegisterCommentUseCase.save(comment); + return mapper.of(boardId, comment.getLikes(), true); + }) + .orElseGet(() -> { + CommentLike newLike = CommentLike.create(currentMemberId, comment.getId()); + registerCommentLikePort.save(newLike); + comment.incrementLikes(); + externalRegisterCommentUseCase.save(comment); + return mapper.of(boardId, comment.getLikes(), false); + }); } }