diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..0f809702 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,53 @@ +name: Java CI/CD with Gradle + +on: + push: + branches: [ "dev" ] # dev 브랜치에 푸시할 때 작동 + workflow_dispatch: # + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: 'gradle' # 캐싱 추가: 빌드 속도가 훨씬 빨라집니다. + + - name: Build with Gradle + run: | + chmod +x ./gradlew + ./gradlew build -x test + + - name: Create .env file from Secret + run: | + cat <<'EOF' > .env + ${{ secrets.ENV_VARIABLES }} + EOF + + - name: Copy JAR and .env to EC2 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + # -plain.jar는 배포에 필요 없으므로 제외합니다. + source: "build/libs/*-SNAPSHOT.jar, .env" + target: "~/" + strip_components: 2 + + - name: Deploy to EC2 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + fuser -k 8080/tcp || true + + chmod +x ~/start.sh + ~/start.sh \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleController.java b/src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleController.java new file mode 100644 index 00000000..b115abc3 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleController.java @@ -0,0 +1,51 @@ +package com.swyp.picke.domain.battle.controller; + +import com.swyp.picke.domain.battle.dto.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.battle.dto.request.AdminBattleUpdateRequest; +import com.swyp.picke.domain.battle.dto.response.AdminBattleDeleteResponse; +import com.swyp.picke.domain.battle.dto.response.AdminBattleDetailResponse; +import com.swyp.picke.domain.battle.service.BattleService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "배틀 API (관리자)", description = "배틀 생성/수정/삭제 (관리자 전용)") +@RestController +@RequestMapping("/api/v1/admin/battles") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminBattleController { + + private final BattleService battleService; + + @Operation(summary = "배틀 생성") + @PostMapping + public ApiResponse createBattle( + @RequestBody @Valid AdminBattleCreateRequest request, + @AuthenticationPrincipal Long adminUserId + ) { + return ApiResponse.onSuccess(battleService.createBattle(request, adminUserId)); + } + + @Operation(summary = "배틀 수정 (변경 필드만 포함)") + @PatchMapping("/{battleId}") + public ApiResponse updateBattle( + @PathVariable Long battleId, + @RequestBody @Valid AdminBattleUpdateRequest request + ) { + return ApiResponse.onSuccess(battleService.updateBattle(battleId, request)); + } + + @Operation(summary = "배틀 삭제") + @DeleteMapping("/{battleId}") + public ApiResponse deleteBattle( + @PathVariable Long battleId + ) { + return ApiResponse.onSuccess(battleService.deleteBattle(battleId)); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleCreateRequest.java b/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleCreateRequest.java new file mode 100644 index 00000000..48aa5b4a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleCreateRequest.java @@ -0,0 +1,24 @@ +package com.swyp.picke.domain.battle.dto.request; + +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.enums.BattleType; +import java.time.LocalDate; +import java.util.List; + +public record AdminBattleCreateRequest( + String title, + String titlePrefix, + String titleSuffix, + String summary, + String description, + String thumbnailUrl, + BattleType type, + BattleStatus status, + String itemA, + String itemADesc, + String itemB, + String itemBDesc, + LocalDate targetDate, + List tagIds, + List options +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleOptionRequest.java b/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleOptionRequest.java new file mode 100644 index 00000000..36c1c212 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleOptionRequest.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.battle.dto.request; + +import com.swyp.picke.domain.battle.enums.BattleOptionLabel; + +import java.util.List; + +public record AdminBattleOptionRequest( + BattleOptionLabel label, + String title, + String stance, + String representative, + String quote, + String imageUrl, + Boolean isCorrect, + List tagIds // 옵션 전용 태그 (철학자, 가치관 - 추후 사용자 유형 분석에 사용) +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleUpdateRequest.java b/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleUpdateRequest.java new file mode 100644 index 00000000..aa5e4477 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleUpdateRequest.java @@ -0,0 +1,23 @@ +package com.swyp.picke.domain.battle.dto.request; + +import com.swyp.picke.domain.battle.enums.BattleStatus; +import java.time.LocalDate; +import java.util.List; + +public record AdminBattleUpdateRequest( + String title, + String titlePrefix, + String titleSuffix, + String summary, + String description, + String thumbnailUrl, + String itemA, + String itemADesc, + String itemB, + String itemBDesc, + LocalDate targetDate, + Integer audioDuration, + BattleStatus status, + List tagIds, + List options +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDeleteResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDeleteResponse.java new file mode 100644 index 00000000..43c64d66 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDeleteResponse.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.battle.dto.response; + +import java.time.LocalDateTime; + +/** + * 관리자 - 배틀 삭제 응답 + * 역할: 배틀이 성공적으로 소프트 딜리트 되었는지 확인하고 삭제 시점을 반환합니다. + */ + +public record AdminBattleDeleteResponse( + Boolean success, // 삭제 성공 여부 + LocalDateTime deletedAt // 삭제 처리된 일시 (Soft Delete) +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDetailResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDetailResponse.java new file mode 100644 index 00000000..fd382332 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDetailResponse.java @@ -0,0 +1,36 @@ +package com.swyp.picke.domain.battle.dto.response; + +import com.swyp.picke.domain.battle.enums.BattleCreatorType; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.enums.BattleType; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 관리자 - 배틀 상세 상세 조회 응답 + * 역할: 관리자가 배틀의 모든 설정 값(상태, 생성자 타입, 수정일 등)을 확인하고 수정할 때 사용합니다. + */ + +public record AdminBattleDetailResponse( + Long battleId, + String title, + String titlePrefix, + String titleSuffix, + String summary, + String description, + String thumbnailUrl, + BattleType type, + String itemA, + String itemADesc, + String itemB, + String itemBDesc, + LocalDate targetDate, + BattleStatus status, + BattleCreatorType creatorType, + List tags, + List options, + LocalDateTime createdAt, + LocalDateTime updatedAt +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/enums/BattleType.java b/src/main/java/com/swyp/picke/domain/battle/enums/BattleType.java new file mode 100644 index 00000000..648e1eff --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/enums/BattleType.java @@ -0,0 +1,5 @@ +package com.swyp.picke.domain.battle.enums; + +public enum BattleType { + BATTLE, QUIZ, VOTE +} diff --git a/src/main/java/com/swyp/picke/domain/tag/dto/request/TagRequest.java b/src/main/java/com/swyp/picke/domain/tag/dto/request/TagRequest.java new file mode 100644 index 00000000..736bfda6 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/tag/dto/request/TagRequest.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.tag.dto.request; + +import com.swyp.picke.domain.tag.enums.TagType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record TagRequest( + @NotBlank(message = "태그 이름을 입력해주세요.") + String name, + + @NotNull(message = "태그 타입을 선택해주세요.") + TagType type +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/tag/dto/response/TagDeleteResponse.java b/src/main/java/com/swyp/picke/domain/tag/dto/response/TagDeleteResponse.java new file mode 100644 index 00000000..71b350e8 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/tag/dto/response/TagDeleteResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.tag.dto.response; + +import java.time.LocalDateTime; + +public record TagDeleteResponse( + boolean success, + LocalDateTime deletedAt +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/tag/dto/response/TagResponse.java b/src/main/java/com/swyp/picke/domain/tag/dto/response/TagResponse.java new file mode 100644 index 00000000..70554dde --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/tag/dto/response/TagResponse.java @@ -0,0 +1,12 @@ +package com.swyp.picke.domain.tag.dto.response; + +import com.swyp.picke.domain.tag.enums.TagType; +import java.time.LocalDateTime; + +public record TagResponse( + Long tagId, + String name, + TagType type, + LocalDateTime createdAt, + LocalDateTime updatedAt +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/test/controller/TestController.java b/src/main/java/com/swyp/picke/domain/test/controller/TestController.java new file mode 100644 index 00000000..c937631e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/test/controller/TestController.java @@ -0,0 +1,34 @@ +package com.swyp.picke.domain.test.controller; + +import com.swyp.picke.domain.oauth.jwt.JwtProvider; +import com.swyp.picke.global.common.response.ApiResponse; +import lombok.RequiredArgsConstructor; +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 java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/test") +@RequiredArgsConstructor +public class TestController { + + private final JwtProvider jwtProvider; + + @GetMapping("/response") + public ApiResponse> testResponse() { + List teamMembers = List.of("주천수", "팀원2", "팀원3", "팀원4"); + return ApiResponse.onSuccess(teamMembers); + } + + @GetMapping("/token") + public ApiResponse> getTestToken( + @RequestParam(defaultValue = "1") Long userId + ) { + String token = jwtProvider.createAccessToken(userId, "USER"); + return ApiResponse.onSuccess(Map.of("accessToken", token)); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/vote/entity/Vote.java b/src/main/java/com/swyp/picke/domain/vote/entity/Vote.java new file mode 100644 index 00000000..47054b65 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/vote/entity/Vote.java @@ -0,0 +1,87 @@ +package com.swyp.picke.domain.vote.entity; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "votes") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Vote extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "battle_id", nullable = false) + private Battle battle; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "pre_vote_option_id") + private BattleOption preVoteOption; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_vote_option_id") + private BattleOption postVoteOption; + + @Column(name = "is_tts_listened", nullable = false) + private Boolean isTtsListened = false; + + @Builder + private Vote(User user, Battle battle, BattleOption preVoteOption, + BattleOption postVoteOption, Boolean isTtsListened) { + this.user = user; + this.battle = battle; + this.preVoteOption = preVoteOption; + this.postVoteOption = postVoteOption; + this.isTtsListened = isTtsListened != null ? isTtsListened : false; + } + + /** + * 최초 투표(사전 투표) 시 사용하는 정적 팩토리 메서드 + */ + public static Vote createPreVote(User user, Battle battle, BattleOption option) { + return Vote.builder() + .user(user) + .battle(battle) + .preVoteOption(option) + .isTtsListened(false) + // status 설정 삭제됨 + .build(); + } + + /** + * 사전 투표 옵션 수정 메서드 + */ + public void updatePreVote(BattleOption preVoteOption) { + this.preVoteOption = preVoteOption; + } + + /** + * 사후 투표 업데이트 + */ + public void doPostVote(BattleOption postOption) { + this.postVoteOption = postOption; + // status 업데이트 삭제됨 + } + + /** + * TTS 청취 상태 업데이트 + */ + public void completeTts() { + this.isTtsListened = true; + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/vote/repository/VoteRepository.java b/src/main/java/com/swyp/picke/domain/vote/repository/VoteRepository.java new file mode 100644 index 00000000..4159beb1 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/vote/repository/VoteRepository.java @@ -0,0 +1,69 @@ +package com.swyp.picke.domain.vote.repository; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.battle.enums.BattleOptionLabel; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.vote.entity.Vote; +import org.springframework.data.domain.Pageable; +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 VoteRepository extends JpaRepository { + + List findAllByBattle(Battle battle); + + Optional findByBattleIdAndUserId(Long battleId, Long userId); + + @Query("SELECT v FROM Vote v LEFT JOIN FETCH v.postVoteOption WHERE v.battle.id = :battleId AND v.user.id = :userId") + Optional findByBattleIdAndUserIdWithOption(@Param("battleId") Long battleId, @Param("userId") Long userId); + + Optional findByBattleAndUser(Battle battle, User user); + + long countByBattle(Battle battle); + + long countByBattleAndPreVoteOption(Battle battle, BattleOption preVoteOption); + + Optional findTopByBattleOrderByUpdatedAtDesc(Battle battle); + + @Query("SELECT v FROM Vote v JOIN FETCH v.battle JOIN FETCH v.preVoteOption " + + "WHERE v.user.id = :userId ORDER BY v.createdAt DESC") + List findByUserIdOrderByCreatedAtDesc(@Param("userId") Long userId, Pageable pageable); + + @Query("SELECT v FROM Vote v JOIN FETCH v.battle JOIN FETCH v.preVoteOption " + + "WHERE v.user.id = :userId AND v.preVoteOption.label = :label ORDER BY v.createdAt DESC") + List findByUserIdAndPreVoteOptionLabelOrderByCreatedAtDesc( + @Param("userId") Long userId, @Param("label") BattleOptionLabel label, Pageable pageable); + + long countByUserId(Long userId); + + @Query("SELECT COUNT(v) FROM Vote v WHERE v.user.id = :userId AND v.preVoteOption.label = :label") + long countByUserIdAndPreVoteOptionLabel(@Param("userId") Long userId, @Param("label") BattleOptionLabel label); + + @Query("SELECT COUNT(v) FROM Vote v WHERE v.user.id = :userId " + + "AND v.postVoteOption IS NOT NULL " + + "AND v.preVoteOption <> v.postVoteOption") + long countOpinionChangesByUserId(@Param("userId") Long userId); + + List findByUserId(Long userId); + + // MypageService: 철학자 유형 산출용 - 최초 N개 투표 조회 (생성순) + @Query("SELECT v FROM Vote v JOIN FETCH v.battle WHERE v.user.id = :userId ORDER BY v.createdAt ASC") + List findByUserIdOrderByCreatedAtAsc(@Param("userId") Long userId, Pageable pageable); + + // 추천용: 유저가 참여한 배틀 ID 조회 + @Query("SELECT v.battle.id FROM Vote v WHERE v.user.id = :userId") + List findParticipatedBattleIdsByUserId(@Param("userId") Long userId); + + // 추천용: 특정 배틀에 참여한 유저 ID 조회 + @Query("SELECT DISTINCT v.user.id FROM Vote v WHERE v.battle.id IN :battleIds") + List findUserIdsByBattleIds(@Param("battleIds") List battleIds); + + // 추천용: 특정 유저들이 참여한 배틀 ID 조회 + @Query("SELECT v.battle.id FROM Vote v WHERE v.user.id IN :userIds") + List findParticipatedBattleIdsByUserIds(@Param("userIds") List userIds); +} diff --git a/src/main/java/com/swyp/picke/domain/vote/service/VoteService.java b/src/main/java/com/swyp/picke/domain/vote/service/VoteService.java new file mode 100644 index 00000000..77d68fe6 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/vote/service/VoteService.java @@ -0,0 +1,18 @@ +package com.swyp.picke.domain.vote.service; + +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.vote.dto.request.VoteRequest; +import com.swyp.picke.domain.vote.dto.response.MyVoteResponse; +import com.swyp.picke.domain.vote.dto.response.VoteResultResponse; +import com.swyp.picke.domain.vote.dto.response.VoteStatsResponse; + +public interface VoteService { + BattleOption findPreVoteOption(Long battleId, Long userId); + Long findPostVoteOptionId(Long battleId, Long userId); + VoteStatsResponse getVoteStats(Long battleId); + MyVoteResponse getMyVote(Long battleId, Long userId); + VoteResultResponse preVote(Long battleId, Long userId, VoteRequest request); + VoteResultResponse postVote(Long battleId, Long userId, VoteRequest request); + void deleteVotesByBattleId(Long battleId); + void completeTts(Long battleId, Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/vote/service/VoteServiceImpl.java b/src/main/java/com/swyp/picke/domain/vote/service/VoteServiceImpl.java new file mode 100644 index 00000000..32a2d956 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/vote/service/VoteServiceImpl.java @@ -0,0 +1,200 @@ +package com.swyp.picke.domain.vote.service; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.battle.repository.BattleOptionRepository; +import com.swyp.picke.domain.battle.service.BattleService; +import com.swyp.picke.domain.user.dto.response.UserBattleStatusResponse; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.UserBattleStep; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.domain.user.service.UserBattleService; +import com.swyp.picke.domain.vote.converter.VoteConverter; +import com.swyp.picke.domain.vote.dto.request.VoteRequest; +import com.swyp.picke.domain.vote.dto.response.MyVoteResponse; +import com.swyp.picke.domain.vote.dto.response.VoteResultResponse; +import com.swyp.picke.domain.vote.dto.response.VoteStatsResponse; +import com.swyp.picke.domain.vote.entity.Vote; +import com.swyp.picke.domain.vote.repository.VoteRepository; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class VoteServiceImpl implements VoteService { + + private final VoteRepository voteRepository; + private final BattleService battleService; + private final BattleOptionRepository battleOptionRepository; + private final UserRepository userRepository; + private final UserBattleService userBattleService; + + @Override + public BattleOption findPreVoteOption(Long battleId, Long userId) { + Battle battle = battleService.findById(battleId); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + Vote vote = voteRepository.findByBattleAndUser(battle, user) + .orElseThrow(() -> new CustomException(ErrorCode.VOTE_NOT_FOUND)); + + if (vote.getPreVoteOption() == null) { + throw new CustomException(ErrorCode.PRE_VOTE_REQUIRED); + } + return vote.getPreVoteOption(); + } + + @Override + public Long findPostVoteOptionId(Long battleId, Long userId) { + return voteRepository.findByBattleIdAndUserId(battleId, userId) + .map(vote -> vote.getPostVoteOption() != null ? vote.getPostVoteOption().getId() : null) + .orElse(null); + } + + @Override + public VoteStatsResponse getVoteStats(Long battleId) { + Battle battle = battleService.findById(battleId); + List options = battleOptionRepository.findByBattle(battle); + long totalCount = voteRepository.countByBattle(battle); + + List stats = options.stream() + .map(option -> { + long count = voteRepository.countByBattleAndPreVoteOption(battle, option); + double ratio = totalCount > 0 + ? Math.round((double) count / totalCount * 1000.0) / 10.0 + : 0.0; + return new VoteStatsResponse.OptionStat( + option.getId(), option.getLabel().name(), option.getTitle(), count, ratio); + }) + .toList(); + + LocalDateTime updatedAt = voteRepository.findTopByBattleOrderByUpdatedAtDesc(battle) + .map(Vote::getUpdatedAt) + .orElse(null); + + return VoteConverter.toVoteStatsResponse(stats, totalCount, updatedAt); + } + + @Override + public MyVoteResponse getMyVote(Long battleId, Long userId) { + Battle battle = battleService.findById(battleId); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + Vote vote = voteRepository.findByBattleAndUser(battle, user) + .orElseThrow(() -> new CustomException(ErrorCode.VOTE_NOT_FOUND)); + + UserBattleStatusResponse status = userBattleService.getUserBattleStatus(user, battle); + return VoteConverter.toMyVoteResponse(vote, status.step()); + } + + @Override + @Transactional + public VoteResultResponse preVote(Long battleId, Long userId, VoteRequest request) { + // 1. 기본 정보 조회 (배틀, 유저, 선택한 옵션) + Battle battle = battleService.findById(battleId); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + BattleOption option = battleOptionRepository.findById(request.optionId()) + .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); + + // 2. 기존 투표 여부 확인 (에러 대신 Optional로 받음) + Optional existingVote = voteRepository.findByBattleAndUser(battle, user); + Vote vote; + + if (existingVote.isPresent()) { + vote = existingVote.get(); + vote.updatePreVote(option); + } else { + vote = Vote.createPreVote(user, battle, option); + voteRepository.save(vote); + battle.addParticipant(); + } + + // 3. 현재 유저의 진행 단계 확인 + UserBattleStatusResponse status = userBattleService.getUserBattleStatus(user, battle); + + // 4. 단계 업데이트 (처음 참여하는 경우에만 단계를 PRE_VOTE로 변경) + // 이미 POST_VOTE나 COMPLETED라면 단계를 강제로 낮추지 않음 + if (status.step() == UserBattleStep.NONE) { + userBattleService.upsertStep(user, battle, UserBattleStep.PRE_VOTE); + } + + // 5. 현재 유지 중인 단계를 반환 (수정 후에도 COMPLETED 유지 가능) + UserBattleStep currentStep = (status.step() == UserBattleStep.NONE) ? UserBattleStep.PRE_VOTE : status.step(); + return new VoteResultResponse(vote.getId(), currentStep); + } + + @Override + @Transactional + public VoteResultResponse postVote(Long battleId, Long userId, VoteRequest request) { + Battle battle = battleService.findById(battleId); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + BattleOption option = battleOptionRepository.findById(request.optionId()) + .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); + + Vote vote = voteRepository.findByBattleAndUser(battle, user) + .orElseThrow(() -> new CustomException(ErrorCode.VOTE_NOT_FOUND)); + + // [검증] 사전 투표를 완료한 상태(혹은 오디오 청취 완료 상태)인지 확인 + UserBattleStatusResponse status = userBattleService.getUserBattleStatus(user, battle); + if (status.step() == UserBattleStep.NONE) { + throw new CustomException(ErrorCode.PRE_VOTE_REQUIRED); + } + + // 1. 사후 투표 업데이트 + vote.doPostVote(option); + + // 2. 최종 완료 단계(COMPLETED)로 업데이트 + userBattleService.upsertStep(user, battle, UserBattleStep.COMPLETED); + + return new VoteResultResponse(vote.getId(), UserBattleStep.COMPLETED); + } + + @Override + @Transactional + public void deleteVotesByBattleId(Long battleId) { + // 1. 배틀 조회 + Battle battle = battleService.findById(battleId); + + // 2. 해당 배틀의 모든 투표 조회 + List votes = voteRepository.findAllByBattle(battle); + + for (Vote vote : votes) { + // 3. 유저의 진행 단계 초기화 (이건 유저별로 다 해줘야 함) + userBattleService.upsertStep(vote.getUser(), battle, UserBattleStep.NONE); + + // 4. 옵션별 카운트 감소 (필요 시) + if (vote.getPreVoteOption() != null) { /* 감소 로직 */ } + if (vote.getPostVoteOption() != null) { /* 감소 로직 */ } + } + + // 5. 투표 데이터 일괄 삭제 + voteRepository.deleteAllInBatch(votes); + } + + @Override + @Transactional + public void completeTts(Long battleId, Long userId) { + Battle battle = battleService.findById(battleId); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // 1. 엔티티 상태 변경 (isTtsListened = true) + Vote vote = voteRepository.findByBattleAndUser(battle, user) + .orElseThrow(() -> new CustomException(ErrorCode.VOTE_NOT_FOUND)); + vote.completeTts(); + + // 2. 단계를 POST_VOTE(사후 투표 가능 단계)로 업데이트 + userBattleService.upsertStep(user, battle, UserBattleStep.POST_VOTE); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/global/infra/tts/service/GoogleCloudTtsServiceImpl.java b/src/main/java/com/swyp/picke/global/infra/tts/service/GoogleCloudTtsServiceImpl.java new file mode 100644 index 00000000..f61d22ec --- /dev/null +++ b/src/main/java/com/swyp/picke/global/infra/tts/service/GoogleCloudTtsServiceImpl.java @@ -0,0 +1,71 @@ +package com.swyp.picke.global.infra.tts.service; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.texttospeech.v1.*; +import com.google.protobuf.ByteString; +import com.swyp.picke.domain.scenario.enums.SpeakerType; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.util.UUID; + +@Slf4j +// @Primary - 사용할 때 주석 삭제 +@Service +public class GoogleCloudTtsServiceImpl implements TtsService { + + @Value("${spring.cloud.gcp.credentials.location}") + private String credentialsLocation; + + @Override + public File generateTtsWithSsml(String rawText, SpeakerType speakerType) throws Exception { + // SSML 태그가 없으면 자동으로 씌워줍니다. + String ssmlInput = rawText.trim().startsWith("") ? rawText : "" + rawText + ""; + + try (FileInputStream credentialsStream = new FileInputStream(credentialsLocation)) { + GoogleCredentials credentials = GoogleCredentials.fromStream(credentialsStream); + TextToSpeechSettings settings = TextToSpeechSettings.newBuilder() + .setCredentialsProvider(() -> credentials) + .build(); + + try (TextToSpeechClient textToSpeechClient = TextToSpeechClient.create(settings)) { + SynthesisInput input = SynthesisInput.newBuilder().setSsml(ssmlInput).build(); + VoiceSelectionParams voice = buildVoiceSelection(speakerType); + AudioConfig audioConfig = AudioConfig.newBuilder().setAudioEncoding(AudioEncoding.MP3).build(); + + // 실제 구글 API가 호출될 때만 찍히는 로그 + String logText = rawText.length() > 15 ? rawText.substring(0, 15) + "..." : rawText; + log.info("[TTS 호출] 💳 구글 API 실제 요청 발생! (화자: {}, 대사: '{}')", speakerType.name(), logText); + + SynthesizeSpeechResponse response = textToSpeechClient.synthesizeSpeech(input, voice, audioConfig); + ByteString audioContents = response.getAudioContent(); + + File tempFile = File.createTempFile("tts_" + UUID.randomUUID(), ".mp3"); + try (FileOutputStream out = new FileOutputStream(tempFile)) { + out.write(audioContents.toByteArray()); + } + return tempFile; + } + } catch (Exception e) { + log.error("[TTS 호출 실패] GCP 키 파일 확인 필요: {}", credentialsLocation, e); + throw e; + } + } + + private VoiceSelectionParams buildVoiceSelection(SpeakerType type) { + String voiceName = switch (type) { + case A -> "ko-KR-Wavenet-C"; + case B -> "ko-KR-Wavenet-D"; + case USER -> "ko-KR-Wavenet-B"; + case NARRATOR -> "ko-KR-Wavenet-A"; + }; + return VoiceSelectionParams.newBuilder() + .setLanguageCode("ko-KR") + .setName(voiceName) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/global/infra/tts/service/OpenAiTtsServiceImpl.java b/src/main/java/com/swyp/picke/global/infra/tts/service/OpenAiTtsServiceImpl.java new file mode 100644 index 00000000..c5168e42 --- /dev/null +++ b/src/main/java/com/swyp/picke/global/infra/tts/service/OpenAiTtsServiceImpl.java @@ -0,0 +1,97 @@ +package com.swyp.picke.global.infra.tts.service; + +import com.swyp.picke.domain.scenario.enums.SpeakerType; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.io.File; +import java.io.FileOutputStream; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +@Slf4j +// @Primary - 사용할 때 주석 삭제 +@Service +public class OpenAiTtsServiceImpl implements TtsService { + + @Value("${openai.api-key}") + private String openAiApiKey; + + @Value("${openai.tts.model:gpt-4o-mini-tts}") + private String ttsModel; + + @Value("${openai.tts.url:https://api.openai.com/v1/audio/speech}") + private String ttsUrl; + + @Override + public File generateTtsWithSsml(String rawText, SpeakerType speakerType) throws Exception { + // 1. 억지스러운 전처리 제거 (자연스러운 문장 부호 유지) + String actingText = cleanTextForNaturalFlow(rawText); + + String voice = getOpenAiVoice(speakerType); + double speed = getVoiceSpeed(speakerType); + + log.info("[TTS 호출] OpenAI 호출 (화자: {}, 속도: {}, 대사: '{}')", voice, speed, actingText); + + RestTemplate restTemplate = new RestTemplate(); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(openAiApiKey); + + Map requestBody = new HashMap<>(); + requestBody.put("model", ttsModel); + requestBody.put("input", actingText); + requestBody.put("voice", voice); + requestBody.put("response_format", "mp3"); + requestBody.put("speed", speed); + + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + + try { + ResponseEntity response = restTemplate.exchange(ttsUrl, HttpMethod.POST, entity, byte[].class); + File tempFile = File.createTempFile("tts_pro_" + UUID.randomUUID(), ".mp3"); + try (FileOutputStream out = new FileOutputStream(tempFile)) { + out.write(response.getBody()); + } + return tempFile; + } catch (Exception e) { + log.error("[TTS 호출 실패]", e); + throw e; + } + } + + /** + * 인위적인 쉼표 조작을 없애고, AI가 마침표(.)와 느낌표(!)를 보고 + * 스스로 억양을 잡게 합니다. + */ + private String cleanTextForNaturalFlow(String rawText) { + // SSML만 제거하고, 원래 문장의 쉼표와 마침표를 그대로 살립니다. + // OpenAI는 마침표에서 톤을 낮추고, 느낌표에서 톤을 높이는 연기를 알아서 합니다. + return rawText.replaceAll("<[^>]*>", "").replaceAll("\\s+", " ").trim(); + } + + private String getOpenAiVoice(SpeakerType type) { + return switch (type) { + case A -> "shimmer"; // 날카롭고 빠른 반응에 최적 + case B -> "fable"; // 단호한 반박 + case USER -> "alloy"; + case NARRATOR -> "onyx"; + }; + } + + /** + * 박진감을 위해 속도를 1.15~1.2 수준으로 올립니다. + * 1.2가 넘어가면 말이 뭉개질 수 있으니 여기가 마지노선입니다. + */ + private double getVoiceSpeed(SpeakerType type) { + return switch (type) { + case NARRATOR -> 1.05; // 해설도 지루하지 않게 + case A, B -> 1.18; // 🔥 대결 톤! 1.18~1.2 정도면 아주 긴박합니다. + case USER -> 1.12; + }; + } +} \ No newline at end of file diff --git a/src/main/resources/templates/share/result.html b/src/main/resources/templates/share/result.html new file mode 100644 index 00000000..b75c451f --- /dev/null +++ b/src/main/resources/templates/share/result.html @@ -0,0 +1,92 @@ + + + + + + Pické - 철학자 유형 결과 + + + + + + + +
+ + + + + + + +
+ + + + +