Compare commits

...

780 Commits

Author SHA1 Message Date
e5937d573a Merge pull request 'test' (#349) from test into main
Reviewed-on: #349
2025-10-10 20:49:52 +00:00
88c3a84972 perf(admin-charge): 통화별 합계를 DB 그룹 집계로 이관하여 전송량/CPU 감소 2025-10-11 05:41:14 +09:00
db0d3a6ef3 refactor(admin-charge): QGetChargeStatusQueryDto의 currency가 null이면 KRW로 설정되도록 coalesce("KRW") 적용 2025-10-11 05:07:21 +09:00
3d29d27441 refactor(admin-charge): QGetChargeStatusQueryDto의 currency가 null이 되지 않도록 coalesce("") 사용 2025-10-11 04:52:58 +09:00
b5f66603bd refactor(admin-charge): QGetChargeStatusQueryDto의 currency가 null이 되지 않도록 coalesce("") 사용 2025-10-11 04:39:15 +09:00
6da86e12bd Merge pull request 'test' (#348) from test into main
Reviewed-on: #348
2025-10-10 19:19:47 +00:00
976eeaa443 refactor(admin-charge): GetChargeStatusDetailResponse의 amount 타입을 Int에서 BigDecimal로 변경
- 충전 금액 계산을 좀 더 명확하게 하기 위해서 변경
2025-10-11 03:54:47 +09:00
25d1d813f1 refactor(admin-charge): HQL 파싱 에러 해결 위해 RIGHT → substring/length 치환 2025-10-11 03:37:46 +09:00
778f0c3ba2 fix(admin/charge): 통화별 리스트와 합계 행 추가 및 전체 합계 로직 수정
- 기존 로직은 통화 구분 없이 전체 합계를 계산·표시하여 통화가 혼재된 데이터에서 오해의 소지가 있었음.
- 관리 화면 요구사항: 통화(currency)별 합계를 명확히 제공.
2025-10-11 03:12:10 +09:00
38c50a4f8a fix(admin/charge): 통화별 리스트와 합계 행 추가 및 전체 합계 로직 수정
- 기존 로직은 통화 구분 없이 전체 합계를 계산·표시하여 통화가 혼재된 데이터에서 오해의 소지가 있었음.
- 관리 화면 요구사항: 통화(currency)별 합계를 명확히 제공.
2025-10-11 02:31:15 +09:00
9049022a74 Merge pull request 'fix(admin-charge-status-detail): pgChargeAmount와 can의 가격을 가져와서 사용하는 로직을 제거하고 payment에 기록된 가격으로 계산하도록 수정' (#347) from test into main
Reviewed-on: #347
2025-10-10 14:39:40 +00:00
c497f321bb fix(admin-charge-status-detail): pgChargeAmount와 can의 가격을 가져와서 사용하는 로직을 제거하고 payment에 기록된 가격으로 계산하도록 수정 2025-10-10 23:32:36 +09:00
7b6f3a7a5f Merge pull request 'fix(admin-charge-status): pgChargeAmount와 can의 가격을 가져와서 사용하는 로직을 제거하고 payment에 기록된 가격으로 계산하도록 수정' (#346) from test into main
Reviewed-on: #346
2025-10-10 13:53:23 +00:00
84c0768c8b fix(admin-charge-status): pgChargeAmount와 can의 가격을 가져와서 사용하는 로직을 제거하고 payment에 기록된 가격으로 계산하도록 수정 2025-10-10 22:31:36 +09:00
53e9678efa Merge pull request 'fix(verify-hecto): 데이터 검증시 가격비교 제거' (#345) from test into main
Reviewed-on: #345
2025-10-10 09:58:11 +00:00
efb8d8115f fix(verify-hecto): 데이터 검증시 가격비교 제거 2025-10-10 18:49:54 +09:00
e4f547fa92 Merge pull request 'payverse 적용' (#344) from test into main
Reviewed-on: #344
2025-10-10 07:44:07 +00:00
41183b4648 fix(can-list): 국가별로 통화가 표시되도록 수정 2025-10-10 14:32:12 +09:00
36e20bf0d1 fix(payverse-webhook): orderId 비교 추가
- orderId와 chargeId 비교 로직 추가
2025-10-03 02:17:48 +09:00
0308e9ad83 fix(payverse): productName 비교 로직 제거
- productName에 +가 있는 경우 저장된 데이터와 검증을 위한 데이터가 다르게 나오기 때문에 비교 불가능
2025-10-03 02:10:30 +09:00
06c0374f16 사용하지 않는 print 제거 2025-10-03 01:56:55 +09:00
c5bc610e2f webhook 호출 IP 확인을 위해 print 추가 2025-10-03 01:48:19 +09:00
a86a24ca34 사용하지 않는 print 제거 2025-10-03 01:46:52 +09:00
cb2e3ea581 fix(payverse-wehook): 한국 원화일 때와 USD일 때 mid 값이 달라야 하는데 성공 여부 비교시 원화 mid로 고정하여 비교하던 로직 수정 2025-10-03 01:29:59 +09:00
42eaf1d5e3 fix(payverse-verify): 결제 성공 여부 판단 로직 수정
- processingAmount 대신 requestAmount와 can 가격 비교
- productName, customerId 비교 추가
2025-10-03 01:25:27 +09:00
02ef706fc2 temp: 디버깅을 위해 print 추가 2025-10-03 00:57:50 +09:00
085b217abb fix(can): 이전 버전의 호환성을 위해 기존의 int price를 유지하도록 수정 2025-10-03 00:02:47 +09:00
0866e0972a 값 확인을 위해 추가했던 println 제거 2025-10-02 22:31:23 +09:00
4b13265737 fix(charge): payverseVerify 결제금액 비교로직 수정
- BigDecimal끼리 비교하는데 casting 로직이 추가되어 문제가 생기던 버그 수정
2025-10-02 22:23:06 +09:00
79cd2b8123 debug(charge): 해외결제 DEBUG를 위해 print 임시 추가 2025-10-02 20:40:34 +09:00
8cc9641bbf feat(charge): payloadJson의 amount
- 소수점 아래 불필요한 0을 제거
2025-10-02 20:29:49 +09:00
32935aed88 feat(charge): payloadJson의 amount
- 소수점 아래 불필요한 0을 제거
2025-10-02 19:59:04 +09:00
c72adbfc4b temp(charge): 캔 리스트
- 해외 충전 테스트를 위해 전체 캔 리스트 표시
2025-10-02 19:56:23 +09:00
bc378cc619 temp(charge): 캔 리스트
- 해외 충전 테스트를 위해 전체 캔 리스트 표시
2025-10-02 19:03:42 +09:00
6327a5d2bf feat(charge): 캔 충전시 통화(KRW, USD)별로 분기 처리 2025-10-02 18:59:52 +09:00
2ab2a04748 feat(can): 캔 응답 - String 형태 가격 필드 추가 2025-10-02 15:07:57 +09:00
fb0a9e98a1 사용하지 않는 print 제거 2025-10-02 12:20:25 +09:00
e45fe1bf10 feat: 일반 유저용 캔 리스트 조회 API 추가, GeoCountryFilter(GeoCountry.OTHER, GeoCountry.KR 구분용) 추가 2025-10-01 22:29:39 +09:00
3d852a8356 feat: 관리자용 캔 리스트 조회 API 추가 2025-10-01 22:16:44 +09:00
b244944f41 feat: 캔 엔티티 currency - length 3으로 고정하여 CHAR(3)에 대응되도록 수정 2025-10-01 21:21:57 +09:00
3c7ba669e2 feat: Payment, AdTrackingHistory 엔티티 price - Decimal(10, 4)에 대응되도록 Column 추가 2025-10-01 21:08:35 +09:00
81e7e7129c feat: 캔 엔티티 currency - length 3으로 고정하여 CHAR(3)에 대응되도록 수정 2025-10-01 21:05:51 +09:00
d7ad110b9e feat: 캔 등록/조회 - currency 추가 2025-10-01 20:55:52 +09:00
0c17ea2dcd fix: 캔 가격, Payment의 price 타입 Int, Double -> BigDecimal로 변경 2025-10-01 20:37:53 +09:00
78ff13a654 temp: 캔 가격 타입 Int -> Double로 변경 2025-10-01 16:07:34 +09:00
863c285049 fix: 불필요한 print 제거 2025-09-30 18:32:12 +09:00
a3d74c0b57 fix: Payverse Webhook 엔드포인트에서 실제 클라이언트 IP를 가져올 수 있도록 수정 2025-09-30 18:22:46 +09:00
9016a72046 fix: ResponseStatusException이 ApiResponse로 래핑되지 않도록 수정
- 기본 에러 JSON 반환 유지
2025-09-30 18:16:13 +09:00
3c32614d1c temp(Exception): ResponseStatusException은 ApiResponse로 래핑하지 않고 그대로 전달 2025-09-30 17:59:55 +09:00
51988471cf temp(payverse): 호출되는 INBOUND_IP 확인하기 위해 출력 2025-09-30 17:55:31 +09:00
8990bd0722 fix(payverse): webhook 엔드포인트는 로그인 하지 않더라도 실행되도록 수정 2025-09-30 17:37:15 +09:00
aab2417976 fix(payverse): print 제거 2025-09-30 17:22:39 +09:00
1bd6f8da4e fix(payverse): PVKR 카드 코드면 method를 "카드"로 저장 2025-09-30 17:02:02 +09:00
22bd1bf042 fix(payverse): 결제 payload에 customerId 길이 30자로 제한
- customerId를 sha1 기반 30자 이내로 생성하도록 변경하여 스펙 준수
- 불필요한 billkeyReq 제거
2025-09-26 16:51:54 +09:00
d536a65fb4 fix(charge): payverse pg payload
- requestAmount의 값을 BigDecimal로 처리
2025-09-26 16:23:11 +09:00
03149a637d feat(charge): payverse pg - webhook API 추가 2025-09-25 21:18:45 +09:00
bc6c05b3ea feat(charge): payverse pg - 충전/검증 API 추가 2025-09-25 20:37:39 +09:00
59ca353b25 feat(calculate-ratio): 정산 비율 삭제 URL 수정 2025-09-22 14:17:18 +09:00
6bc65ec412 feat(calculate-ratio): 정산 비율 조회 - 결과에 memberId 추가 2025-09-22 14:01:28 +09:00
97e95b51ab feat(calculate-ratio): 정산 비율 수정/삭제(소프트 삭제)와 업서트, 쿼리 보강
- deletedAt 기반 소프트 삭제 도입 및 restore/updateValues 추가
- 생성 시 기존(삭제 포함) 레코드 복구 후 값 갱신(업서트)
- /admin/calculate/ratio/update, /delete 엔드포인트 추가
- 정산 쿼리 조인에 deletedAt.isNull 적용하여 삭제 데이터 배제
- 목록/카운트 조회에서도 삭제 데이터 제외
2025-09-22 13:36:36 +09:00
b69756ef81 Merge pull request 'test' (#343) from test into main
Reviewed-on: #343
2025-09-18 19:25:50 +00:00
a6dfa81ba6 사용하지 않는 '지정 원작에 속한 활성 캐릭터 목록 조회 API' 제거 2025-09-18 22:49:35 +09:00
dad517a953 feat(admin-character-list): 캐릭터 검색결과
- 캐릭터 목록과 동일한 내용으로 변경
2025-09-18 19:58:12 +09:00
eb2d093b02 feat(admin-character-list): 캐릭터 검색에 페이징 추가 2025-09-18 19:29:34 +09:00
67186bba55 feat(original): 원작
- 원천 원작, 원천 원작 링크, 글/그림 작가, 제작사, 태그 추가
2025-09-18 18:04:59 +09:00
1a3a9149a2 Merge pull request 'test' (#342) from test into main
Reviewed-on: #342
2025-09-16 06:11:32 +00:00
edeecad2ce feat(original-app): 원작 리스트
- 페이징 추가
2025-09-15 16:00:09 +09:00
387f5388d9 feat(original-app): 원작 상세, 캐릭터 리스트
- 원작 상세에 캐릭터 20개 조회
- 지정 원작에 속한 활성 캐릭터 목록 조회 API 추가
2025-09-15 15:32:20 +09:00
adcaa0a5fd fix(original): 캐릭터 수정
- 원작 ID가 0이 들어오면 캐릭터의 원작을 null로 처리한다.
2025-09-15 06:43:40 +09:00
47b2c1cb93 fix(original): 캐릭터 수정
- 원작 ID가 0이 들어오면 캐릭터의 원작을 null로 처리한다.
2025-09-15 06:17:55 +09:00
ce120a6d5d Merge pull request 'test' (#341) from test into main
Reviewed-on: #341
2025-09-14 20:33:50 +00:00
7f3589dcfb fix(original): 인기 캐릭터 조회
- 캐시 키 변경
2025-09-15 05:20:46 +09:00
b134c28c10 feat(original): 관리자 캐릭터 상세 조회
- 원작 데이터 추가
2025-09-15 05:18:01 +09:00
41c8d0367d feat(original): 원작별 캐릭터 조회 API 추가 2025-09-15 00:31:14 +09:00
3b148d549e feat(original-app): 앱용 원작 목록/상세 API 및 조회 로직 추가
- 공개 목록 API: 미인증 사용자는 19금 비노출, 활성 캐릭터가 1개 이상 연결된 원작만 반환, 총개수+리스트 제공
- 상세 API: 로그인/본인인증 필수, 원작 상세+소속 활성 캐릭터 리스트 반환
2025-09-14 23:27:58 +09:00
b6c96af8a2 feat(original): 원작 도메인과 캐릭터를 연결하기 위해 각각 검색 API 추가 2025-09-14 23:00:33 +09:00
4904625488 feat(original): 원작 도메인 추가, 관리자 CRUD/연결 API, 소프트 삭제, 서비스 계층 정비
- 원작 엔티티/레포지토리/관리자 API 구축(이미지 S3 업로드 포함)
- 캐릭터-원작 연관관계 및 관리자에서 배정/해제 API 제공
- 소프트 삭제(`isDeleted`) 도입 및 조회/수정/배정 로직에서 삭제 항목 필터링
- 컨트롤러-레포지토리 직접 접근 제거, `AdminOriginalWorkService`로 DB 접근 캡슐화
- 캐릭터 등록/수정에서 `originalWorkId` 지원 및 외부 API 업데이트 조건 분리
2025-09-14 22:33:30 +09:00
08b5fd23ab Merge pull request 'test' (#340) from test into main
Reviewed-on: #340
2025-09-14 08:51:11 +00:00
0574f4f629 feat(cache): 인기 캐릭터 조회에 윈도우 기반 동적 캐시 키 적용
- ChatCharacterService.getPopularCharacters()에 @Cacheable 추가
- 키: popular-chat-character:<windowStartEpoch>:<limit>
- 윈도우(매일 20:00 UTC) 전환 시 자동으로 신규 키 사용 → 전일 순위 캐시와 분리 보장

Why: 동일 윈도우 내 반복 요청의 DB 부하를 줄이고, 경계 전환 시 자연스러운 캐시 갱신을 보장.
2025-09-14 17:43:53 +09:00
4adc3e127c fix(popular): 일일 인기 캐릭터 집계 윈도우를 전날 완료 구간으로 고정
- UTC 20:00 경계 직후에도 [전날 20:00, 당일 20:00) 범위 사용으로 일일 순위 정확화
- RankingWindowCalculator.now(): lastBoundary 기반 [start, endExclusive) 계산
2025-09-14 17:28:33 +09:00
dd0a1c2293 fix(chat-character): 인기 캐릭터
- 캐시 제거
2025-09-14 16:46:56 +09:00
a07407417c fix(admin-chat-calculate): 캐릭터 정산 API
- ONLY_FULL_GROUP_BY 대응
- c2.image_path 집계식 적용
2025-09-13 05:01:52 +09:00
e33e3b43b7 fix(admin-chat-calculate): 캐릭터 정산 API
- ONLY_FULL_GROUP_BY 대응
2025-09-13 04:33:01 +09:00
634bf759ca feat(admin-chat-calculate): 캐릭터 정산 API에 채팅 횟수 구매(CHAT_QUOTA_PURCHASE) 추가 2025-09-13 03:54:24 +09:00
0ed29c6097 feat(admin-chat-calculate): 캐릭터 정산 API에 imagePath를 imageHost를 포함한 url로 변경 추가 2025-09-13 03:20:26 +09:00
b752434fbb feat(admin-chat-calculate): 캐릭터 정산 API에 totalCount 추가 2025-09-13 03:06:55 +09:00
eec63cc7b2 feat(admin-chat-calculate): 캐릭터별 정산 조회 API 추가 2025-09-13 02:00:30 +09:00
3dc9dd1f35 feat(character): 최근 등록된 캐릭터 전체보기 API
- 반환 값에 전체 개수 추가
2025-09-12 19:00:45 +09:00
88e287067b feat(character): 최근 등록된 캐릭터 전체보기 API 추가 2025-09-12 18:37:25 +09:00
eb18e2d009 Merge pull request 'test' (#339) from test into main
Reviewed-on: #339
2025-09-11 17:05:45 +00:00
27a3f450ef fix(character): 인기 캐릭터 응답을 DTO로 변경하여 jackson 직렬화 오류 해결
- ChatCharacterService.getPopularCharacters 반환을 List<ChatCharacter> → List<Character> DTO로 변경
- 캐시 대상도 DTO로 전환(@Cacheable 유지, 동적 키/고정 TTL 그대로 사용)
- 컨트롤러에서 불필요한 매핑 제거(서비스가 DTO로 반환)
- Character DTO 직렬화 안정성 확보(@JsonProperty 추가)
- 이미지 URL 생성 로직을 서비스로 이동하고 imageHost(@Value) 주입해 구성
2025-09-11 18:53:27 +09:00
58a46a09c3 fix(character): SpEL 정적 호출 오류로 @JvmStatic 추가 2025-09-11 18:21:13 +09:00
83a1316a64 feat(character): UTC 20시 경계 기반 인기 캐릭터 집계 구현 및 캐시 적용
- 집계 기준을 "채팅방 전체 메시지 수"로 변경하여 캐릭터별 인기 순위 산정
- Querydsl `PopularCharacterQuery` 추가: chat_message → chat_participant(CHARACTER) → chat_character 조인
- 시간 경계: UTC 20:00 기준 [windowStart, nextBoundary) 구간 사용(배타적 종료 `<`)
- `ChatCharacterService.getPopularCharacters`에 @Cacheable 적용
  - cacheNames: `popularCharacters_24h`
  - key: `RankingWindowCalculator.now('popular-chat-character').cacheKey`
  - 상위 20개 기본, `loadCharactersInOrder`로 랭킹 순서 보존
- `RankingWindowCalculator`: 경계별 동적 키 생성(`popular-chat-character:{windowStartEpoch}`) 및 윈도우 계산
- `RedisConfig`: 24시간 TTL 캐시 `popularCharacters_24h` 추가(문자열/JSON 직렬화 지정)
- `ChatCharacterController`: 메인 API에 인기 캐릭터 섹션 연동

WHY
- 20시(UTC) 경계 변경 시 키가 달라져 첫 조회에서 자동 재집계/재캐싱
- 방 전체 참여도를 반영해 보다 직관적인 인기 지표 제공
- 캐시(24h TTL)로 DB 부하 최소화, 경계 전환 후 자연 무효화
2025-09-11 18:06:40 +09:00
f05f146c89 fix(chat-quota): 유료 차감 후 무료·유료 동시 0일 때 next_recharge_at 설정 누락 수정
- 문제: 유료 잔여가 있을 때 유료 우선 차감 경로에서 `next_recharge_at` 설정 분기가 없어,
  무료/유료가 동시에 0이 되는 경우 다음 무료 충전 시점이 노출되지 않음
- 수정: `ChatRoomQuotaService.consumeOneForSend`의 유료 차감 분기에
  `remainingPaid==0 && remainingFree==0 && nextRechargeAt==null` 조건에서
  `now + 6h`로 `next_recharge_at`을 설정하도록 로직 추가
- 참고: 무료 차감 경로의 `next_recharge_at` 설정 및 입장 시 lazy refill 동작은 기존과 동일
2025-09-11 12:35:16 +09:00
a27852ed44 Merge pull request '캐릭터 챗봇' (#338) from test into main
Reviewed-on: #338
2025-09-10 06:08:47 +00:00
3782062f4a fix(chat-room): 입장/전송 next 계산 보완 및 채팅 가능 시 next=null 처리
enter:
roomPaid==0 && roomFree>0 && global<=0 → 글로벌 next
roomPaid==0 && roomFree==0 → (global<=0) ? max(roomNext, globalNext) : roomNext
채팅 가능(totalRemaining>0)인 경우 next=null 반환(유료>0 포함)
send:
totalRemaining==0 && global<=0 → max(roomNext, globalNext)
채팅 가능(totalRemaining>0)인 경우 next=null 반환
2025-09-10 13:31:27 +09:00
fd83abb46c feat(chat): 글로벌/방 쿼터 정책 개편, 결제/조회/차단/이관 로직 반영
글로벌: 무료 40, UTC 20:00 lazy refill(유료 제거)
방: 무료 10, 무료 0 순간 now+6h, 경과 시 lazy refill(무료=10, next=null)
전송: 유료 우선, 무료 사용 시 글로벌/룸 동시 차감, 조건 불충족 예외
API: 방 쿼터 조회/구매 추가(구매 시 30캔, UseCan에 roomId:characterId 기록)
next 계산: enter/send에서 경계 케이스 처리(max(room, global))
대화 초기화: 유료 쿼터 새 방으로 이관
2025-09-09 22:42:14 +09:00
a9d1b9f4a6 fix(character): 캐릭터 상세 조회 응답에 MBTI·성별·나이 필드 추가
- CharacterDetailResponse에 gender, age 필드 추가
- ChatCharacterController에서 gender, age 매핑
- 기존 엔티티(ChatCharacter)의 gender/age 활용
2025-09-05 16:55:50 +09:00
ad69dad725 fix(character-image): 리스트 응답 ownedCount에 프로필(+1) 반영
프로필 이미지는 무료로 항상 열람 가능하므로 보유 개수(ownedCount)에도
프로필 1장을 포함하도록 수정했습니다. 이를 통해 전체 개수(totalCount)와
보유 개수 산정 기준이 일관되게 맞춰집니다.
2025-09-01 16:33:53 +09:00
2f55303d16 feat(admin-curation): 리스트 정합성 개선 및 활성 캐릭터 수 DB 집계 적용
- 비활성(삭제) 큐레이션을 목록에서 제외: findByIsActiveTrueOrderBySortOrderAsc 사용
- 리스트 항목에 characterCount 추가 및 DB GROUP BY + COUNT로 직접 집계
- CharacterCurationMappingRepository: 집계용 프로젝션(CharacterCountPerCuration)과 countActiveCharactersByCurations 쿼리 추가
- CharacterCurationAdminService: listAll에서 집계 결과를 활용해 characterCount 매핑 (대량 엔티티 로딩 제거)
- CharacterCurationRepository: findMaxSortOrder 쿼리로 신규 등록 정렬 순서 계산에 활용
- 컨트롤러: 캐릭터 리스트 응답 DTO(CharacterCurationCharacterItemResponse) 사용, 이미지 URL은 CloudFront host + imagePath로 조립
2025-09-01 14:06:01 +09:00
3a9128a894 fix(character): 추가 정보 증분 업데이트 적용 및 값 필드 가변화
- 왜: 기존에는 추가 정보(memories, personalities, backgrounds, relationships) 수정 시 전체 삭제 후 재생성되어 변경 누락/DB 오버헤드가 발생함
- 무엇:
  - Memory/Personality/Background 값 필드(content/description/emotion)를 var로 전환해 in-place 업데이트 허용
  - 서비스 레이어에 증분 업데이트 로직 적용
    - 요청에 없는 항목만 제거, 기존 항목은 값만 갱신, 신규 키만 추가
    - relationships는 personName+relationshipName 복합 키 매칭(keyOf)으로 필드만 갱신
  - ChatCharacter 컬렉션에 orphanRemoval=true 설정하여 iterator.remove 시 고아 삭제 보장
  - updateChatCharacterWithDetails에서 clear/add 제거 → 증분 업데이트 메서드 호출로 변경
- 효과: DELETE+INSERT 제거로 성능 개선, ID/createdAt 유지로 감사 추적 용이, 데이터 정합성 향상
2025-09-01 12:29:26 +09:00
def6296d4d fix(chat-character): 캐릭터 등록/수정 API
- 재시도 규칙 제거
2025-09-01 11:03:46 +09:00
034472defa fix(chat-character): DB에서 speechStyle type을 varchar에서 text로의 변경에 따라 @Column(columnDefinition = "TEXT") 추가 2025-08-29 01:38:49 +09:00
550e4ac9ce fix(character-main): 최근 대화 캐릭터 조회에서 roomId 대신 characterId 반환 2025-08-28 19:50:20 +09:00
d26e0a89f6 feat(admin-curation): 큐레이션 캐릭터 다중 등록 및 검증 로직 개선
- 중복 ID 제거 및 0 이하 ID 필터링
- 조회 단계에서 활성 캐릭터만 조회하여 검증 포함
- 존재하지 않거나 비활성인 캐릭터는 건너뛰고 나머지만 등록
- 기존 매핑 있는 캐릭터는 무시, 다음 정렬 순서(nextOrder)로 일괄 추가
2025-08-28 19:22:31 +09:00
6767afdd35 feat(character-curation): 캐릭터 큐레이션 도메인/관리 API 추가 및 메인 화면 통합
- CharacterCuration/CharacterCurationMapping 엔티티 추가
- 리포지토리/서비스(조회·관리) 구현
- 관리자 컨트롤러에 등록/수정/삭제/정렬/캐릭터 추가·삭제·정렬 API 추가
- 앱 메인 API에 큐레이션 섹션 노출
- 정렬/소프트 삭제/활성 캐릭터 필터링 규칙 적용
2025-08-28 17:39:53 +09:00
a58de0cf92 feat(chat-room-list): 이미지 메시지면 최근 메시지를 [이미지]로 표시 2025-08-28 02:33:04 +09:00
df93f0e0ce feat(chat-quota): 30캔으로 충전시 유료 채팅 횟수
- 50 -> 40으로 변경
2025-08-28 00:22:15 +09:00
0b54b126db temp(chat-character): 최신 캐릭터 50개 조회 2025-08-28 00:21:07 +09:00
a94cf8dad9 feat(chat): 입장 라우팅 도입 및 라우팅 시 배경 이미지 URL 무시(null)
- baseRoom이 비활성/미참여면 동일 캐릭터의 내 활성 방으로 라우팅해 응답 구성
- 라우팅된 경우 bgImageUrl은 항상 null 처리; 대체 방 없으면 기존 예외 유지
2025-08-28 00:18:21 +09:00
2c3e12a42c fix(chat-room): 세션 종료 외부 API
- ContentType 설정 제거
2025-08-27 19:18:46 +09:00
c4dbdc1b8e fix(chat-room): 비활성 채팅방 접근 방지를 위해 조회 로직 일원화
- 이 변경으로 비활성화된 채팅방에 대한 메시지 전송/조회/입장/리셋 등 모든 경로에서 안전하게 접근이 차단됩니다.
2025-08-27 17:43:32 +09:00
42ed4692af feat(chat): 채팅방 초기화 API 추가 및 세션 종료 실패 시 롤백 처리
- /api/chat/room/{chatRoomId}/reset POST 엔드포인트 추가
- 초기화 절차: 30캔 결제 → 기존 방 나가기 → 동일 캐릭터로 새 방 생성 → 응답 반환
- 결제 시 CanUsage.CHAT_ROOM_RESET 신규 항목 사용(본인 귀속)
- ChatQuotaService.resetFreeToDefault 추가 및 초기화 성공 시 무료 10회로 리셋(nextRechargeAt 초기화)
- 사용내역 타이틀에 "캐릭터 톡 초기화" 노출(CanService)
- ChatRoomResetRequest DTO(container 포함) 추가
- leaveChatRoom에 throwOnSessionEndFailure 옵션 추가(기본 false 유지)
- endExternalSession에 throwOnFailure 옵션 추가: 최대 3회 재시도 후 실패 시 예외 전파 가능
- 채팅방 초기화 흐름에서는 외부 세션 종료 실패 시 예외를 던져 트랜잭션 롤백되도록 처리
2025-08-27 17:16:18 +09:00
258943535c feat(chat-room): 채팅방 입장 시 선택적 캐릭터 이미지 서명 URL 반환 및 파라미터 추가
enter API에 characterImageId 선택 파라미터 추가
동일 캐릭터/활성 여부/보유 여부 검증 후 5분 만료의 CloudFront 서명 URL 생성
ChatRoomEnterResponse에 bgImageUrl 필드 추가해 응답 포함
서명 URL 생성 실패 시 warn 로그만 남기고 null 반환하여 사용자 흐름 유지
기존 호출은 그대로 동작하며, 파라미터와 응답 필드 추가는 하위 호환됨
2025-08-27 15:18:24 +09:00
0347d767f0 feat(character-image): 캐릭터 이미지 리스트 첫 칸에 프로필 이미지 포함 및 페이징 보정
사용자 경험 향상을 위해 캐릭터 프로필 이미지를 이미지 리스트의 맨 앞에 노출하도록 변경.
2025-08-27 14:22:07 +09:00
48b0190242 feat(character-image): 보유 이미지 전용 목록 API 추가 및 DB 페이징 적용
- /api/chat/character/image/my-list 엔드포인트 추가
  - 로그인/본인인증 체크
  - 캐릭터 프로필 이미지를 리스트 맨 앞에 포함
  - 보유 이미지(무료 또는 구매 이력 존재)만 노출
  - CloudFront 서명 URL 발급로 접근 제어
- 페이징 로직 개선
  - 기존: 전체 조회 후 메모리에서 필터링/슬라이싱
  - 변경: QueryDSL로 DB 레벨에서 보유 이미지만 오프셋/리밋 조회
  - 프로필 아이템(인덱스 0) 포함을 고려하여 owned offset/limit 계산
  - 빈 페이지 요청 시 즉시 빈 결과 반환
- Repository
  - CharacterImageQueryRepository + Impl 추가
  - findOwnedActiveImagesByCharacterPaged(...) 구현
    - 구매 이력: CHAT_MESSAGE_PURCHASE, CHARACTER_IMAGE_PURCHASE만 인정, 환불 제외
    - 활성 이미지, sortOrder asc, id asc 정렬 + offset/limit
- Service
  - getCharacterImagePath(characterId) 추가
  - pageOwnedActiveByCharacterForMember(...) 추가
- Controller
  - my-list 응답 스키마는 list와 동일하게 totalCount/ownedCount/items 유지
  - 페이지 사이즈 상한 20 적용, 5분 만료 서명 URL
2025-08-26 23:52:30 +09:00
15d0952de8 fix(quota): 캐릭터 톡 채팅 쿼터 조회
- applyRefillOnEnterAndGetStatus를 적용하여 채팅 쿼터 조회 시 Lazy Refill 적용
2025-08-26 17:32:00 +09:00
84ebc1762b fix(quota): 채팅 쿼터 구매 시 사용 내역 문구
- 캐릭터 톡 이용권 구매
2025-08-26 17:28:06 +09:00
a096b16945 fix(quota): 채팅 쿼터 구매
- nextRechargeAt = null 설정
2025-08-26 17:06:35 +09:00
37ac52116a temp(quota): 기다리면 무료 쿼터 시간
- 테스트를 위해 임시로 1분으로 수정
2025-08-26 17:00:19 +09:00
fcb68be006 fix(chat-room): 채팅방 입장
- AI 채팅 쿼터 Lazy refill 적용을 위해 read/write 모두 가능하도록 Transaction 수정
2025-08-26 14:57:57 +09:00
048c48d754 fix(quota)!: AI 채팅 쿼터(무료/유료) 구매 Response를 ChatQuotaStatusResponse으로 변경 2025-08-26 13:57:02 +09:00
6ecac8d331 feat(quota)!: AI 채팅 쿼터(무료/유료) 도입 및 입장/전송 응답에 상태 포함
- ChatQuota 엔티티/레포/서비스/컨트롤러 추가
- 입장 시 Lazy refill 적용, 전송 시 무료 우선 차감 및 잔여/리필 시간 응답 포함
- ChatRoomEnterResponse에 totalRemaining/nextRechargeAtEpoch 추가
- SendChatMessageResponse 신설 및 send API 응답 스키마 변경
- CanUsage에 CHAT_QUOTA_PURCHASE 추가, CanPaymentService/CanService에 결제 흐름 반영
2025-08-26 13:22:49 +09:00
8b1dd7cb95 temp: 임시로 최신 캐릭터 30개 보여주는 것으로 수정 2025-08-25 17:37:51 +09:00
5a58fe9077 feat(chat): 이미지 메시지 조회 시 CloudFront 서명 URL 적용 및 DTO 변환 로직 공통화
- 조회 가능한(보유/무료/결제완료) 이미지 메시지의 이미지 URL을 ImageContentCloudFront.generateSignedURL(만료 5분)로 생성
- 접근 불가(미보유, 유료 미구매) 이미지 메시지는 기존 공개 호스트 URL(블러/스냅샷 경로) 유지
- ChatRoomService에 ImageContentCloudFront를 주입하고, toChatMessageItemDto에서 이미지 URL/hasAccess 결정 로직 단일화
- enterChatRoom, getChatMessages, sendMessage 경로의 중복된 DTO 매핑 로직 제거
- purchaseMessage 결제 완료 시 forceHasAccess=true로 접근 가능 DTO 반환
2025-08-25 14:28:11 +09:00
12574dbe46 feat(chat-room, payment): 유료 메시지 구매 플로우 구현 및 결제 연동(이미지 보유 처리 포함)
- 채팅 유료 메시지 구매 API 추가: POST /api/chat/room/{chatRoomId}/messages/{messageId}/purchase
- ChatRoomService.purchaseMessage 구현: 참여/유효성/가격 검증, 이미지 메시지 보유 시 결제 생략, 결제 완료 시 ChatMessageItemDto 반환
- CanPaymentService.spendCanForChatMessage 추가: UseCan에 chatMessage(+이미지 메시지면 characterImage) 연동 저장 및 게이트웨이 별 정산 기록(setUseCanCalculate)
- Character Image 결제 경로에 정산 기록 호출 누락분 보강
- ChatMessageItemDto 변환 헬퍼(toChatMessageItemDto) 추가 및 접근권한(hasAccess) 계산 일원화
2025-08-25 14:01:10 +09:00
b3e7c00232 feat(chat): 이미지/유료(PPV) 메시지 도입 — 엔티티·서비스·DTO 확장 및 트리거 전송
- ChatMessageType(TEXT/IMAGE) 도입
- ChatMessage에 messageType/characterImage/imagePath/price 추가
- ChatMessageItemDto에 messageType/imageUrl/price/hasAccess 추가
- 캐릭터 답변 로직
  - 텍스트 메시지 항상 저장/전송
  - 트리거 일치 시 이미지 메시지 추가 저장/전송
  - 미보유 시 blur + price 스냅샷, 보유 시 원본 + price=null
- enterChatRoom/getChatMessages 응답에 확장된 필드 매핑 및 hasAccess 계산 반영
2025-08-23 05:34:02 +09:00
692e060f6d feat(character-image): 이미지 단독 구매 API 및 결제 연동 추가
- 구매 요청/응답 DTO 추가
- 미보유 시 캔 차감 및 구매 이력 저장
- 서명 URL(5분) 반환
2025-08-22 21:37:18 +09:00
2ac0a5f896 feat(character-image): 캐릭터 이미지 리스트
- isAdult 값 추가
2025-08-22 01:21:04 +09:00
f8be99547a fix: ImageBlurUtil.kt
- 블러 radius 200 -> 240
2025-08-21 21:18:29 +09:00
7dd585c3dd fix: ImageBlurUtil.kt
- 블러 radius 160 -> 200
2025-08-21 21:10:35 +09:00
7355949c1e fix: ImageBlurUtil.kt
- 블러 radius 100 -> 160
2025-08-21 20:54:44 +09:00
539b9fb2b2 fix: 유저/관리자 캐릭터 이미지 리스트
- 불필요한 Response 제거
2025-08-21 20:52:39 +09:00
99386c6d53 fix: ImageBlurUtil.kt
- 블러 처리 방식 변경
2025-08-21 20:14:06 +09:00
abbd73ac00 fix: ImageBlurUtil.kt
- 블러 처리 방식 변경
2025-08-21 19:51:10 +09:00
4bee95c8a6 fix: ImageBlurUtil.kt
- 블러 적용 범위 radius 10 -> 50
2025-08-21 19:34:23 +09:00
090fc81829 fix: ImageBlurUtil.kt
- 블러 적용 범위 radius 10 -> 50
2025-08-21 19:10:37 +09:00
75100cacec fix: ImageContentCloudFront.kt
- host, key-pair-id, key-file-path 참조 변경
2025-08-21 18:09:17 +09:00
13fd262c94 feat(chat-character-image): 캐릭터 이미지 리스트 API 추가 및 보유 판단 로직 적용 2025-08-21 17:39:19 +09:00
8451cdfb80 fix(chat-character-image): 캐릭터 이미지 가격
- 이미지 단독 구매 가격과 메시지를 통한 구매 가겨으로 분리
2025-08-21 04:07:25 +09:00
c8841856c0 fix(chat-character-image): 캐릭터 이미지 트리거 수정
- triggers가 null이거나 빈 리스트이면 수정없이 실행종료
2025-08-21 04:01:47 +09:00
2a30b28e43 feat(chat-character-image): 캐릭터 이미지
- 등록시 블러 이미지를 생성하여 저장하는 기능 추가
2025-08-21 04:00:02 +09:00
dd6849b840 feat(chat-character-image): 캐릭터 이미지
- 등록, 리스트, 상세, 트리거 단어 업데이트, 삭제 기능 추가
2025-08-21 03:33:42 +09:00
ca27903e45 fix(character-comment): 캐릭터 상세 댓글 집계 및 최신 댓글 조회 기준 수정
- 최신 댓글 조회 시 원댓글(Parent=null)만 대상으로 조회하도록 Repository 메서드 및 Service 로직 변경

- 총 댓글 수를 "활성 원댓글 + 활성 부모를 가진 활성 답글"로 계산하여, 삭제된 원댓글의 답글은 집계에서 제외되도록 수정
2025-08-20 15:39:52 +09:00
aeab6eddc2 feat(chat-character-comment): 캐릭터 댓글의 답글에 댓글 내용 추가 2025-08-20 00:37:39 +09:00
1c0d40aed9 feat(chat-character-comment): 캐릭터 댓글에 글쓴이 ID 추가 2025-08-20 00:26:11 +09:00
1444afaae2 feat(chat-character-comment): 캐릭터 댓글 삭제 및 신고 API 추가
- 삭제 API: 본인 댓글에 대해 soft delete 처리
- 신고 API: 신고 내용을 그대로 저장하는 CharacterCommentReport 엔티티/리포지토리 도입
- Controller: 삭제, 신고 엔드포인트 추가 및 인증/본인인증 체크
- Service: 비즈니스 로직 구현 및 예외 처리 강화

왜: 캐릭터 댓글 관리 기능 요구사항(삭제/신고)을 충족하기 위함
무엇: 엔드포인트, 서비스 로직, DTO 및 JPA 엔티티/리포지토리 추가
2025-08-20 00:13:13 +09:00
a05bc369b7 feat(character-comment): 댓글/대댓글 API
- 커서를 추가하여 페이징 처리
2025-08-19 23:57:46 +09:00
6c7f411869 feat(character-comment): 캐릭터 댓글/답글 API 및 응답 확장
- 댓글 리스트에 댓글 개수 추가
2025-08-19 23:37:24 +09:00
f61c45e89a feat(character-comment): 캐릭터 댓글/답글 API 추가 및 상세 응답 확장
- 캐릭터 댓글 엔티티/레포지토리/서비스/컨트롤러 추가
  - 댓글 작성 POST /api/chat/character/{characterId}/comments
  - 답글 작성 POST /api/chat/character/{characterId}/comments/{commentId}/replies
  - 댓글 목록 GET /api/chat/character/{characterId}/comments?limit=20
  - 답글 목록 GET /api/chat/character/{characterId}/comments/{commentId}/replies?limit=20
- DTO 추가/확장
  - CharacterCommentResponse, CharacterReplyResponse, CharacterCommentRepliesResponse, CreateCharacterCommentRequest
- 캐릭터 상세 응답(CharacterDetailResponse) 확장
  - latestComment(최신 댓글 1건) 추가
  - totalComments(전체 활성 댓글 수) 추가
- 성능 최적화: getReplies에서 원본 댓글 replyCount 계산 시 DB 카운트 호출 제거
  - toCommentResponse(replyCountOverride) 도입으로 원본 댓글 replyCount=0 고정
- 공통 검증: 로그인/본인인증/빈 내용 체크, 비활성 캐릭터/댓글 차단

WHY
- 캐릭터 상세 화면에 댓글 경험 제공 및 전체 댓글 수 노출 요구사항 반영
- 답글 조회 시 불필요한 카운트 쿼리 제거로 DB 호출 최소화
2025-08-19 18:47:59 +09:00
27ed9f61d0 fix(chat): 채팅방 메시지 전송 API 반환값 수정
- 기존: SendChatMessageResponse으로 메시지 리스트를 한 번 더 Wrapping해서 보냄

- 수정: 메시지 리스트 반환
2025-08-14 22:00:42 +09:00
df77e31043 feat(chat): 채팅방 입장 API와 메시지 페이징 초기 로드 구현
- GET /api/chat/room/{chatRoomId}/enter 엔드포인트 추가
- 참여 검증 후 roomId, character(아이디/이름/프로필/타입) 제공
- 최신 20개 메시지 조회(내림차순 조회 후 createdAt 오름차순으로 정렬)
- hasMoreMessages 플래그 계산(가장 오래된 메시지 이전 존재 여부 판단)
2025-08-14 21:56:27 +09:00
2d65bdb8ee feat(chat): 채팅방 메시지 조회 API에 커서 기반 페이징 도입 및 createdAt 추가
cursor(< messageId) 기준의 커서 페이징 도입, 경계 exclusive 처리
limit 파라미터로 페이지 사이즈 가변화 (기본 20)
응답 스키마를 ChatMessagesPageResponse(messages, hasMore, nextCursor)로 변경
메시지 정렬을 createdAt 오름차순(표시 시간 순)으로 반환
ChatMessageItemDto에 createdAt(epoch millis) 필드 추가
레포지토리에 Pageable 기반 조회 및 이전 데이터 존재 여부 검사 메서드 추가
컨트롤러/서비스 시그니처 및 내부 로직 업데이트
2025-08-14 21:43:42 +09:00
4966aaeda9 fix(chat-room): 메시지 있는 방만 목록 조회되도록 쿼리 수정 2025-08-14 14:35:07 +09:00
28bd700b03 fix(chat-room): ONLY_FULL_GROUP_BY로 인한 그룹화 오류 수정 2025-08-14 14:09:31 +09:00
f2ca013b96 feat(chat-room): 채팅방 리스트 응답 개선(타입/미리보기/상대 이미지/시간)
- ChatRoomListQueryDto: characterType, lastActivityAt 필드 추가
- ChatRoomListItemDto: opponentType, lastMessagePreview, lastMessageTimeLabel 제공
- 레포지토리 정렬 기준을 최근 메시지 또는 생성일로 일원화(COALESCE)
2025-08-14 13:39:59 +09:00
6cf7dabaef feat(character): 홈의 최근 목록을 채팅방 기반으로 노출
- ChatRoomService.listMyChatRooms 사용, 최근 순 최대 10개 노출
- 방 title/imageUrl을 그대로 사용해 UI/데이터 일관성 유지
- 비로그인 사용자는 빈 배열 반환

refactor(dto): RecentCharacter.characterId → roomId로 변경
2025-08-14 00:53:35 +09:00
e6d63592ec fix(chat-character): 관계 스키마 변경에 따라 엔티티/CRUD/응답 DTO 수정
- ChatCharacterRelationship 엔티티를 personName, relationshipName, description(TEXT), importance, relationshipType, currentStatus로 변경

- ChatCharacter.addRelationship 및 Service 메서드 시그니처를 새 스키마에 맞게 수정

- 등록/수정 플로우에서 relationships 매핑 로직 업데이트

- Admin 상세 응답 DTO(RelationshipResponse) 및 매핑 업데이트

- 전체 빌드 성공
2025-08-13 19:49:46 +09:00
3ac4ebded3 feat(chat): 외부 캐릭터 챗 세션 API
- 응답값에 Response 모델에 JsonIgnoreProperties 추가하여 필요한 데이터만 파싱할 수 있도록 수정
2025-08-13 16:49:45 +09:00
6f9fc659f3 feat(chat): 외부 캐릭터 챗 세션 API URL 수정
- /api/session/... -> /api/sessions/...
2025-08-13 14:39:20 +09:00
005bb0ea2e feat(chat): 멤버가 최근에 대화한 캐릭터 목록
- ChatCharacterRepository.kt의 JPQL 정렬 절을 `ORDER BY MAX(COALESCE(m.createdAt, r.createdAt)) DESC`로 변경
2025-08-13 00:08:10 +09:00
80a0543e10 feat(admin-character): 캐릭터 배너 리스트 API
- 배너 이미지 URL - hostimagePath => host/imagePath로 수정
2025-08-12 23:38:18 +09:00
5d42805514 feat(admin-character): 캐릭터 배너 등록/수정 API
- 배너 이미지 저장 경로 수정
2025-08-12 23:26:13 +09:00
1b7ae8a2c5 feat(admin-character): 캐릭터 배너 등록/수정 API
- 배너 이미지 저장 경로 수정
2025-08-12 23:15:34 +09:00
168b0b13fb feat(admin-character): 캐릭터 배너 등록/수정 API
- request dto에 JsonProperty 추가
2025-08-12 23:02:18 +09:00
d99fcba468 feat(admin-character): 캐릭터 배너 등록/수정 API
- request를 JSON String으로 받도록 수정
2025-08-12 22:47:56 +09:00
147b8b0a42 feat(admin-character): 캐릭터 수정 API
- 가치관, 취미, 목표가 중복 매핑이 되지 않도록 수정
2025-08-12 21:51:52 +09:00
eed755fd11 feat(admin-character): 캐릭터 수정 API
- 태그 중복 매핑이 되지 않도록 수정
2025-08-12 21:03:06 +09:00
74a612704e feat(admin-character): 캐릭터 수정 API
- 태그 중복 매핑이 되지 않도록 수정
2025-08-12 20:40:25 +09:00
8defc56d1e ExternalApiData에 @JsonIgnoreProperties(ignoreUnknown = true)를 추가하여 없는 필드는 무시하도록 수정 2025-08-12 18:22:37 +09:00
1db20d118d ExternalApiResponse
- 각 필드에 JsonProperty 추가
2025-08-12 18:08:45 +09:00
7a70a770bb 캐릭터 Controller
- exception print
2025-08-12 17:54:39 +09:00
cc9e4f974f 캐릭터 Controller
- exception print
2025-08-12 17:38:45 +09:00
2965b8fea0 feat(admin-character): 캐릭터 리스트, 캐릭터 상세
- CharacterType: 첫 글자만 대문자, 나머지 소문자로 변경
- 이미지가 null 이면 ""으로 변경
2025-08-12 17:01:51 +09:00
00c617ec2e feat(admin-character): 캐릭터 상세 결과에 characterType 추가 2025-08-12 16:45:25 +09:00
01ef738d31 feat(chat-character): 캐릭터 상세 조회 응답 확장 및 ‘다른 캐릭터’ 추천 추가
- 상세 페이지 정보 강화 및 탐색성 향상을 위해 응답 필드를 확장
- CharacterDetailResponse에 originalTitle, originalLink, characterType, others 추가
- OtherCharacter DTO 추가 (characterId, name, imageUrl, tags)
- 공유 태그 기반으로 현재 캐릭터를 제외한 랜덤 10개 캐릭터 조회 JPA 쿼리 추가
  - ChatCharacterRepository.findRandomBySharedTags(@Query, RAND 정렬, 페이징)
- 서비스 계층에 getOtherCharactersBySharedTags 추가 및 태그 지연 로딩 초기화
- 컨트롤러에서:
  - others 리스트를 조회/매핑하여 응답에 포함
  - originalTitle, originalLink, characterType을 응답에 포함
2025-08-12 03:47:48 +09:00
423cbe7315 feat(chat-character): 캐릭터 상세 조회 응답 스키마 간소화 및 태그 포맷 규칙 적용
- CharacterDetailResponse에서 불필요 필드 제거
  - 제거: age, gender, speechPattern, speechStyle, appearance, memories, relationships, values, hobbies, goals
- 성격(personalities), 배경(backgrounds)을 각각 첫 번째 항목 1개만 반환하도록 변경
  - 단일 객체(Optional)로 응답: CharacterPersonalityResponse?, CharacterBackgroundResponse?
- 태그 포맷 규칙 적용
  - 태그에 # 프리픽스가 없으면 붙이고, 공백으로 연결하여 단일 문자열로 반환
- Controller 로직 정리
  - 불필요 매핑 제거 및 DTO 스키마 변경에 맞춘 변환 로직 반영
2025-08-12 03:16:29 +09:00
afb003c397 feat(chat-character): 원작/원작 링크/캐릭터 유형 추가 및 외부 API 호출 분리
- ChatCharacter 엔티티에 originalTitle, originalLink(Nullable), characterType(Enum) 필드 추가
  - characterType: CLONE | CHARACTER (기본값 CHARACTER)
  - 원작/원작 링크는 빈 문자열 대신 null 허용으로 저장
- Admin DTO(Register/Update)에 originalTitle, originalLink, characterType 필드 추가
- 등록 API에서 외부 API 요청 바디에 3개 필드(originalTitle, originalLink, characterType) 제외 처리
- 수정 API에서 3개 필드만 변경된 경우 외부 API 호출 생략하고 DB만 업데이트
  - hasChanges: 외부 API 대상 필드 변경 여부 판단(3개 필드 제외)
  - hasDbOnlyChanges: 3개 필드만 변경된 경우 처리 분기
- Service 계층에 필드 매핑 및 Enum 파싱 추가
  - createChatCharacter / createChatCharacterWithDetails에 originalTitle/originalLink/characterType 반영
- 이름 중복 검증 로직 유지, isActive=false 비활성화 이름 처리 로직 유지
2025-08-12 02:58:26 +09:00
2dc5a29220 feat(chat-character): 관계 name 필드 추가에 따른 등록/수정/조회 로직 및 DTO 반영
- 관계 스키마를 name, relationShip 구조로 일원화
- Admin/사용자 컨트롤러 조회 응답에서 관계를 객체로 반환하도록 수정
- 등록/수정 요청 DTO에 ChatCharacterRelationshipRequest(name, relationShip) 추가
- 서비스 계층 create/update/add 메소드 시그니처 및 매핑 로직 업데이트
- description 한 줄 소개 사용 전제 하의 관련 사용부 점검(엔티티 컬럼 구성은 기존 유지)
2025-08-12 02:13:46 +09:00
c525ec0330 feat(chat): 내 채팅방 목록 페이징 적용 및 page 파라미터 추가
- Repository에 Pageable 인자로 전달하여 DB 레벨 limit/offset 적용
- Service에서 PageRequest.of(page, 20)로 20개 페이지 처리 고정
- Controller /api/chat/room/list에 page 요청 파라미터 추가 및 전달

왜: 참여 중인 채팅방 목록이 페이징되지 않아 20개 단위로 최신 메시지 기준 내림차순 페이징 처리 필요
2025-08-11 14:26:00 +09:00
735f1e26df feat(chat-character): 최근 대화한 캐릭터 조회 구현 및 메인 API 연동
왜: 기존에는 채팅방 미구현으로 최근 대화 리스트를 빈 배열로 응답했음. 채팅방/메시지 기능이 준비됨에 따라 실제 최근 대화 캐릭터를 노출해야 함.
무엇:
- repository: findRecentCharactersByMember JPA 쿼리 추가 (채팅방/참여자/메시지 조인, 최신 메시지 기준 정렬)
- service: getRecentCharacters(member, limit) 구현 (member null 처리 및 페이징 적용)
- controller: /api/chat/character/main에서 인증 사용자 기준 최근 캐릭터 최대 10개 반환
2025-08-11 11:33:35 +09:00
5129400a29 fix(banner): 캐릭터 검색 결과
- Paging 관련 데이터 중 totalCount만 반환
2025-08-08 21:46:47 +09:00
a6a01aaa37 fix(banner): 캐릭터 검색
- 검색 결과에 imageHost와 imagePath 사이에 / 추가
2025-08-08 21:19:37 +09:00
b819df9656 feat(securityConfig): 아래 API는 로그인 하지 않아도 조회할 수 있도록 수정
- /api/chat/list
2025-08-08 17:31:21 +09:00
5d1c5fcc44 fix(chat): 채팅방 메시지
- 메시지 DB 타입을 TEXT로 변경
2025-08-08 17:11:38 +09:00
ebad3b31b7 fix(chat): 채팅방 메시지 전송 API
- 빈 메시지이면 전송하지 않고 반환
2025-08-08 16:52:30 +09:00
3e9f7f9e29 fix(chat): 채팅방, 채팅방 메시지, 채팅방 참여자 엔티티 이름 변경
- CharacterChatRoom -> ChatRoom
- CharacterChatMessage -> ChatMessage
- CharacterChatParticipant -> ChatParticipant
2025-08-08 16:47:47 +09:00
4b3463e97c feat(chat): 채팅방 메시지 전송 API 구현 2025-08-08 16:41:53 +09:00
002f2c2834 feat(chat): 채팅방 메시지 조회 API 구현 2025-08-08 16:00:30 +09:00
1509ee0729 feat(chat): 채팅방 나가기 API 구현 2025-08-08 15:48:20 +09:00
830e41dfa3 feat(chat): 채팅방 세션 조회 API 구현 2025-08-08 15:15:29 +09:00
4d1f84cc5c feat(chat-room): 채팅방 목록 API 응답 구조 개편 및 최근 메시지/프로필 이미지 제공\n\n- 페이징 객체 제거: ApiResponse<List<ChatRoomListItemDto>> 형태로 반환\n- 메시지 보낸 시간 필드 제거\n- 상대방(캐릭터) 프로필 이미지 URL 제공 (imageHost/imagePath 조합 -> imageUrl)\n- 가장 최근 메시지 1개 미리보기 제공 (최대 25자, 초과 시 ... 처리)\n- 목록 조회 쿼리 투영 DTO 및 정렬 로직 개선 (최근 메시지 없으면 방 생성 시간 사용)\n- 비인증/미본인인증 사용자: 빈 리스트 반환 2025-08-08 14:27:25 +09:00
1bafbed17c feat(chat): 채팅방 생성 API 구현
- 채팅방 생성 및 조회 기능 구현
- 외부 API 연동을 통한 세션 생성 로직 추가
- 채팅방 참여자(유저, 캐릭터) 추가 기능 구현
- UUID 기반 유저 ID 생성 로직 추가
2025-08-08 00:27:25 +09:00
694d9cd05a feat(character chat room): 채팅방, 채팅메시지, 채팅방 참여자 엔티티 구성 2025-08-07 23:35:57 +09:00
60172ae84d feat(character): 캐릭터 상세 조회 API 추가
- 캐릭터 ID로 상세 정보를 조회하는 API 엔드포인트 추가
- 캐릭터 상세 정보 조회 서비스 메서드 구현
- 캐릭터 상세 정보 응답 DTO 클래스 추가
2025-08-07 23:10:36 +09:00
7e7a1122fa refactor(character): 최근 등록된 캐릭터 조회 로직 개선
조회할 때부터 isActive = true, limit 10개를 불러오도록 리팩토링
- ChatCharacterRepository에 findByIsActiveTrueOrderByCreatedAtDesc 메소드 추가
- ChatCharacterService의 getNewCharacters 메소드 수정
2025-08-07 22:40:06 +09:00
a1533c8e98 feat(character): 캐릭터 메인 API 추가 2025-08-07 22:33:29 +09:00
b0a6fc6498 feat: weraser api 연동 부분
- exception 발생시 exception message도 같이 출력
2025-08-07 21:18:29 +09:00
74ed7b20ba feat: 캐릭터 생성/수정 Request
- JsonProperty 추가
2025-08-07 20:48:27 +09:00
206c25985a fix: 캐릭터 리포지토리
- active -> isActive로 변경
2025-08-07 16:52:41 +09:00
0001697274 fix: 환경변수 값 변수명 수정 2025-08-07 16:15:56 +09:00
add21c45c5 fix(캐릭터 성격특성): description SQL 컬럼 타입 TEXT로 변경 2025-08-07 16:01:53 +09:00
ef8458c7a3 feat(banner): 정렬 순서 추가 2025-08-07 15:31:03 +09:00
81f972edc1 fix(banner): ChatCharacterBanner 엔티티의 isActive 속성 참조 오류 수정
- 사용하지 않는 메서드 제거
2025-08-07 14:45:28 +09:00
c729a402aa feat(banner): 배너 등록/수정/삭제 API 2025-08-07 14:38:09 +09:00
2335050834 feat(admin): 관리자 페이지 캐릭터 상세 API 구현 2025-08-07 12:30:19 +09:00
6340ed27cf fix(chat): ChatCharacter 엔티티의 isActive 속성 참조 오류 수정 2025-08-07 12:01:34 +09:00
618f80fddc feat(admin): 관리자 페이지 캐릭터 리스트 API 구현
1. isActive가 true인 캐릭터만 조회하는 기능 구현
2. 페이징 처리 구현 (기본 20개 조회)
3. 필요한 데이터 포함 (id, 캐릭터명, 프로필 이미지, 설명, 성별, 나이, MBTI, 태그, 성격, 말투, 등록일, 수정일)
2025-08-07 11:59:21 +09:00
45b6c8db96 git commit -m "fix(chat): 캐릭터 등록/수정 API
- 이름 중복 검사 로직 추가
2025-08-06 22:19:52 +09:00
5132a6b9fa feat(character): 캐릭터 수정 API 구현
- ChatCharacterUpdateRequest 클래스 추가 (모든 필드 nullable)
- ChatCharacter 엔티티의 필드를 var로 변경하여 수정 가능하게 함
- 이미지 포함/제외 수정 API를 하나로 통합
- 변경된 데이터만 업데이트하도록 구현
- isActive가 false인 경우 특별 처리 추가
2025-08-06 21:59:16 +09:00
de6642b675 git commit -m "feat(chat): 캐릭터 등록 API 구현
- 외부 API 호출 및 응답 처리 구현
- 이미지 파일 S3 업로드 기능 추가
- Multipart 요청 처리 지원"
2025-08-06 20:51:01 +09:00
3b42399726 feat: 255자 넘어가야 하는 필드 columnDefinition = "TEXT" 추가 2025-08-06 18:44:56 +09:00
689f9fe48f feat(chat): ChatCharacter와 다른 엔티티 간 관계 구현
ChatCharacter와 Memory, Personality, Background, Relationship 간 1:N 관계 설정
Tag, Value, Hobby, Goal 엔티티의 중복 방지 및 관계 매핑 구현
관계 설정을 위한 서비스 및 리포지토리 클래스 추가
2025-08-06 17:42:48 +09:00
73038222cc feat: .junie/, .kiro/ 폴더 이하 파일들 git에 포함되지 않도록 코드 추가 2025-08-05 16:41:53 +09:00
c7925c1706 Merge pull request 'feat: 최근 공지사항 API 추가' (#337) from test into main
Reviewed-on: #337
2025-07-28 02:16:19 +00:00
2659adb7a9 feat: 최근 공지사항 API 추가 2025-07-25 21:44:32 +09:00
be59bd7e89 Merge pull request 'fix: 크리에이터 팔로우 API' (#336) from test into main
Reviewed-on: #336
2025-07-21 13:52:34 +00:00
fcb2ca1917 fix: 크리에이터 팔로우 API
- 본인은 팔로우 되지 않도록 수정
2025-07-21 22:30:19 +09:00
51ce143fc2 Merge pull request 'test' (#335) from test into main
Reviewed-on: #335
2025-07-21 11:46:56 +00:00
804e139385 fix: 라이브 메인 API - 최근 종료된 라이브
- 쿼리 최적화
2025-07-21 20:39:54 +09:00
f0fc996426 fix: 라이브 메인 API - 최근 종료된 라이브
- 날짜 제한 1주
2025-07-21 20:28:21 +09:00
89eb11f808 Merge pull request 'fix: 라이브 메인 API - 최근 종료된 라이브' (#334) from test into main
Reviewed-on: #334
2025-07-21 10:59:38 +00:00
efdb485a3b fix: 라이브 메인 API - 최근 종료된 라이브
- 날짜 제한 2주
2025-07-21 19:44:38 +09:00
30d89987a4 Merge pull request 'test' (#333) from test into main
Reviewed-on: #333
2025-07-21 09:54:56 +00:00
3d695069a2 fix: 홈 메인 API - 인기 크리에이터
- 팔로잉 여부 추가
2025-07-21 18:21:53 +09:00
e068b57062 fix: 라이브 메인 API - 최근 종료한 라이브
- 팔로잉 여부 제거
2025-07-21 18:05:33 +09:00
811810cd36 fix: GetCommunityPostListResponse
- json property 제거
2025-07-21 16:45:58 +09:00
c90df4b02b fix: 라이브 메인 API
- 테마별 최신콘텐츠 캐시 제거
2025-07-21 16:44:10 +09:00
7c1082f833 fix: 라이브 메인 API
- @JsonProperty 애노테이션 추가
2025-07-21 16:31:05 +09:00
800b8d3216 fix: 라이브 메인 API
- @JsonProperty 애노테이션 추가
2025-07-21 16:18:33 +09:00
ab877beae1 fix: 라이브 메인 API
- redis caching이 적용된 data class에 @JsonProperty 애노테이션 추가
2025-07-21 15:48:40 +09:00
046c163e6f feat: 라이브 메인 API
- 기존에 섹션별로 따로따로 호출하던 것을 하나로 합쳐서 호출할 수 있도록 API 추가
2025-07-21 15:14:47 +09:00
7959d3e5ed Merge pull request 'test' (#332) from test into main
Reviewed-on: #332
2025-07-18 12:33:22 +00:00
8e877a6366 fix: 라이브 다시듣기 콘텐츠 API 추가 2025-07-18 20:27:02 +09:00
d18c19dd35 fix: 최근 종료한 라이브 API 오류 수정 2025-07-18 18:09:00 +09:00
a99260209b fix: 최근 종료한 라이브 API 오류 수정 2025-07-18 18:00:36 +09:00
2192ddc8fa fix: 최근 종료한 라이브 API 오류 수정 2025-07-18 17:50:18 +09:00
741a1282a3 fix: 최근 종료한 라이브 API 오류 수정 2025-07-18 17:30:48 +09:00
1a6a331ad8 fix: 최근 종료한 라이브 API 오류 수정 2025-07-18 17:22:05 +09:00
1ba63e2cab fix: 최근 종료한 라이브 API 오류 수정 2025-07-18 17:13:37 +09:00
5696240e03 fix: 최근 종료한 라이브 API 오류 수정 2025-07-18 16:49:18 +09:00
885243a5b0 fix: 최근 종료한 라이브 API 오류 수정 2025-07-18 16:35:15 +09:00
a849d00c7f fix: 최근 종료한 라이브 API 오류 수정
- SQLSyntaxErrorException 오류수정
- select 값에 집계쿼리를 넣어서 해결
2025-07-18 15:46:20 +09:00
d04b44c931 fix: 최근 종료한 라이브 API
- 차단 당한 크리에이터는 안보이도록 수정
- 20개 미만이면 재시도 처리
- 재시도 최대 횟수 3회
2025-07-18 14:40:16 +09:00
a3aad9d2c9 feat: 최근 종료한 라이브 20개 가져오는 API 추가 2025-07-18 14:15:03 +09:00
d98268f809 refactor: timeAgo 함수
- LocalDateTime 확장함수 처리
2025-07-18 13:33:19 +09:00
34440e9ba3 fix: 라이브 후원 합계 API
- 안쓰는 파라미터 제거
2025-07-17 19:36:10 +09:00
d1c889e5f2 fix: 라이브 리스트 API
- 라이브 시작 시간 UTC 추가
2025-07-17 18:58:48 +09:00
1e29573ef7 Merge pull request 'fix: 검색 API' (#331) from test into main
Reviewed-on: #331
2025-07-16 10:58:56 +00:00
55da259510 fix: 검색 API
- 콘텐츠, 시리즈 검색에서 크리에이터의 닉네임으로도 검색 되도록 수정
2025-07-16 19:00:39 +09:00
cc2f533dc6 Merge pull request 'fix: 메인 홈 API - 요일별 시리즈' (#330) from test into main
Reviewed-on: #330
2025-07-14 19:14:06 +00:00
4436e6f20a fix: 메인 홈 API - 요일별 시리즈
- 시리즈 생성 날짜 내림차순 정렬
2025-07-15 04:03:38 +09:00
32b0c19f9d Merge pull request 'test' (#329) from test into main
Reviewed-on: #329
2025-07-14 17:57:26 +00:00
3cedd36e15 fix: 메인 홈 API
- 기존 홈 탭 상단에 있는 배너 임시 추가
2025-07-15 02:46:14 +09:00
ecbe9b2e93 . 2025-07-15 02:38:29 +09:00
9ad6b6ea48 fix: 메인 홈 API - 최신 콘텐츠
- 무료/유료 콘텐츠 모두 조회 되도록 수정
2025-07-15 01:32:45 +09:00
0d2daf4d2c fix: 메인 홈 API - 추천 채널
- 미인증 계정에서 19금 콘텐츠가 조회되지 않도록 수정
2025-07-15 01:29:57 +09:00
edf16a6021 fix: 메인 홈 API
- 기존 홈 탭 상단에 있는 배너 임시 추가
2025-07-15 01:10:00 +09:00
9af2d768e8 Merge pull request 'test' (#327) from test into main
Reviewed-on: #327
2025-07-14 11:07:57 +00:00
7551a19b34 fix: 메인 홈 API
- 로그인 하지 않고 조회가 가능하도록 수정
2025-07-14 18:48:57 +09:00
f59f45d9a4 fix: 메인 홈 - 추천 채널
- 콘텐츠가 빈 리스트로 반환되는 버그 수정
2025-07-12 03:18:37 +09:00
81e82ad731 fix: 메인 홈 - 추천 채널
- 콘텐츠가 빈 리스트로 반환되는 버그 수정
2025-07-12 02:53:31 +09:00
ca870392e2 fix: 메인 홈 - 요일별 시리즈
- groupBy 이후 없는 컬럼으로 정렬한 오류 수정
2025-07-12 00:43:45 +09:00
a7e167a95f fix: 메인 홈 - 요일별 시리즈
- groupBy 추가하여 동일한 시리즈가 여러개 추가되어 있는 버그 수정
2025-07-11 23:39:55 +09:00
a49b82a7c2 fix: 메인 홈 - 인기 크리에이터
- 팔로워 수 추가
2025-07-11 20:00:39 +09:00
704ad12ccf fix: 메인 홈 - getContentCurationList
- 캐시 제거
2025-07-11 19:36:29 +09:00
ab9fd2bc16 fix: 메인 홈 - GetAudioContentMainItem
- JsonProperty를 isPointAvailable 수정
- @JsonProperty를 -> @get:JsonProperty, @param:JsonProperty로 수정
2025-07-11 18:43:07 +09:00
69a63a77d3 fix: 메인 홈 - GetAudioContentMainItem
- JsonProperty를 pointAvailable 수정하여 Redis Cache에서 데이터 가져올 떄 파싱이 이뤄질 수 있도록 수정
2025-07-11 18:02:52 +09:00
da7e4c2156 fix: 메인 홈 - GetContentCurationResponse
- JsonProperty를 추가하여 Redis Cache에서 데이터 가져올 떄 파싱이 이뤄질 수 있도록 수정
2025-07-11 17:46:22 +09:00
a4b5185f6b fix: 메인 홈 - 최근 콘텐츠 조회
- join 하지 않은 blockMember 제거
- 정렬 조건 추가 - id 내림차순
2025-07-11 14:04:08 +09:00
22fc8b22b8 feat: 메인 홈
- API 추가
2025-07-10 15:31:41 +09:00
a8da17162a feat: 커뮤니티 글 등록/수정
- 유료 글에서만 gif를 등록할 수 있도록 수정
2025-07-03 15:26:35 +09:00
5677824cde Merge pull request 'test' (#326) from test into main
Reviewed-on: #326
2025-06-13 11:37:26 +00:00
f13c221fd6 fix: 커뮤니티 댓글 조회
- 결과값에 isSecret(비밀 댓글 여부) 추가
2025-06-13 16:51:10 +09:00
4ffa9363a8 fix: 커뮤니티 댓글 조회
- 프로필 이미지 imageHost에 /가 포함되도록 수정
2025-06-13 16:06:44 +09:00
6d2f48f86d fix: 커뮤니티 댓글 조회
- 크리에이터가 아닌 경우 내가 쓴 비밀댓글 + 일반댓글만 조회되도록 수정
2025-06-12 19:08:47 +09:00
8e01ced1f5 feat: 커뮤니티 댓글
- 유료 커뮤니티 글을 구매한 경우 비밀 댓글 쓰기 기능 추가
2025-06-12 16:10:32 +09:00
e8f1bc09f9 Merge pull request 'test' (#325) from test into main
Reviewed-on: #325
2025-06-12 05:00:31 +00:00
640f5ce6f5 fix: 팔로워 리스트
- 차단한 멤버는 팔로워 리스트에 보이지 않도록 수정
2025-06-12 13:51:03 +09:00
c0be30027c fix: 팔로워 리스트
- 차단한 멤버는 팔로워 리스트에 보이지 않도록 수정
2025-06-12 13:44:09 +09:00
832586bd41 fix: 팔로워 리스트
- 차단한 멤버는 팔로워 리스트에 보이지 않도록 수정
2025-06-12 13:25:51 +09:00
1a774937b3 fix: 커뮤니티 게시물 조회
- isAdult를 무조건 false로 조회되던 문제를 게시물의 isAdult에 따라 다르게 조회되도록 수정
2025-06-12 12:00:21 +09:00
d1a936d55b Merge pull request 'test' (#324) from test into main
Reviewed-on: #324
2025-06-10 11:01:31 +00:00
e508dafb34 feat: 시리즈 상세 콘텐츠 리스트 - 포인트 사용 가능 여부 추가 2025-06-10 18:03:52 +09:00
8335717741 feat: 크리에이터 채널 콘텐츠 리스트 - 포인트 사용 가능 여부 추가 2025-06-10 14:44:54 +09:00
16a2b82ffd feat: 콘텐츠 메인, 콘텐츠 랭킹 - 포인트 사용 가능 여부 추가 2025-06-10 11:14:48 +09:00
8db5c6443d fix: 쿠폰 사용 - 쿠폰 사용 완료 안내 문구 수정 2025-06-09 17:17:52 +09:00
9ed717fb95 feat: 쿠폰 사용 - 쿠폰 사용 완료 안내 문구 적용 2025-06-09 16:52:19 +09:00
dcd4497315 feat: 포인트 내역 - 쿠폰으로 충전한 포인트 내역도 조회할 수 있도록 포인트 정책과의 조인을 leftJoin으로 변경 2025-06-09 16:36:46 +09:00
54c0322398 feat: 쿠폰 사용 - 포인트 쿠폰이면 포인트 충전 되도록 로직 수정 2025-06-09 15:16:11 +09:00
e3c33c71a0 feat: 쿠폰 생성, 쿠폰 리스트
- 쿠폰 타입(캔, 포인트) 추가
2025-06-09 14:47:33 +09:00
dc97eaa835 Merge pull request 'fix: 앱 콘텐츠 수정' (#323) from test into main
Reviewed-on: #323
2025-06-05 02:36:25 +00:00
7055bb9872 fix: 앱 콘텐츠 수정
- 태그 수정, 포인트 사용여부 수정 기능
2025-06-04 17:21:08 +09:00
dcbe57806c Merge pull request 'test' (#322) from test into main
Reviewed-on: #322
2025-06-02 12:41:46 +00:00
fd1b17e356 fix: 크리에이터 관리자 콘텐츠 수정 - 태그 수정 기능
- 이미 있는 태그는 다시 추가되지 않도록 추가
2025-06-02 21:33:00 +09:00
28427a873a fix: 크리에이터 관리자 콘텐츠 수정 - 태그 수정 기능
- 이미 있는 태그는 다시 추가되지 않도록 추가
2025-06-02 21:20:47 +09:00
5bdb101b52 fix: 크리에이터 관리자 콘텐츠 수정 - 태그 수정 기능
- 빈 칸인 경우 #으로 추가되는 버그 수정
2025-06-02 20:53:06 +09:00
97b2b38f8e fix: 크리에이터 관리자, 관리자 콘텐츠 리스트
- isActive = True 태그만 조회되도록 수정
2025-06-02 20:25:54 +09:00
2268f4a3fc fix: 크리에이터 관리자 콘텐츠 수정 - 태그 수정 기능
- 빈 칸인 경우 #으로 추가되는 버그 수정
2025-06-02 20:21:50 +09:00
9eff828249 feat: 크리에이터 관리자 콘텐츠 수정
- 태그 수정 기능 추가
2025-06-02 20:10:13 +09:00
b14438cc15 Merge pull request 'fix: 유저 행동 기록, 포인트 지급' (#321) from test into main
Reviewed-on: #321
2025-05-28 07:19:27 +00:00
3275ac5036 fix: 유저 행동 기록, 포인트 지급
- 행동 횟수 체크 순서를 조정하여 포인트 지급 누락 보완
2025-05-28 15:41:06 +09:00
b27d3bd5c6 Merge pull request 'fix: 유저 행동 기록, 포인트 지급' (#320) from test into main
Reviewed-on: #320
2025-05-26 10:33:16 +00:00
e049e0fa3c fix: 유저 행동 기록, 포인트 지급
- 포인트 지급 완료시 푸시 보내지 않도록 수정
2025-05-26 19:22:42 +09:00
03ebc9cfe9 Merge pull request 'fix: 큐레이션 아이템 조회' (#319) from test into main
Reviewed-on: #319
2025-05-23 05:43:37 +00:00
caee89cf53 fix: 큐레이션 아이템 조회
- 관리자에서 지정한 순서대로 보이도록 수정
2025-05-23 14:37:42 +09:00
24841b9850 Merge pull request 'fix: 코루틴 내 트랜잭션 간 조회 안 되는 문제 해결' (#318) from test into main
Reviewed-on: #318
2025-05-22 04:31:42 +00:00
e67b798714 fix: actionCount 를 조회할 때 endDate가 마지막 action 저장 이전의 시간이 측정될 수도 있어서 LocalDateTime.now()로 수정 2025-05-22 13:19:52 +09:00
dc13053825 fix: 구매하지 않은 콘텐츠에 댓글을 써도 ORDER_CONTENT_COMMENT 이벤트가 있으면 유저 행동 데이터에 기록되는 버그 수정 2025-05-22 13:01:39 +09:00
af352256e9 fix: 코루틴 내 트랜잭션 간 조회 안 되는 문제 해결
- 각 트랜잭션을 TransactionTemplate 블록으로 분리하여 커밋 시점 명확화
- 두 번째 트랜잭션에서 entityManager.clear() 호출로 1차 캐시 무시
- CoroutineExceptionHandler 추가로 비동기 예외 로깅 처리
- @PreDestroy 추가로 서비스 종료 시 CoroutineScope 정리
2025-05-22 12:25:17 +09:00
d35a3d1a8c Merge pull request 'test' (#317) from test into main
Reviewed-on: #317
2025-05-20 10:26:16 +00:00
b92810efd2 fix: 앱 실행시 처음 실행하는 유저 정보 조회 API
- point 추가
2025-05-20 17:56:51 +09:00
fcbd809691 fix: 유저 포인트 조회시 유효기간을 기준으로 오름차순 정렬 2025-05-20 16:56:34 +09:00
60c4e0b528 Merge pull request 'test' (#316) from test into main
Reviewed-on: #316
2025-05-20 06:03:10 +00:00
d3ec13e6c0 fix: 유저 행동 데이터에 따른 포인트 지급
- 본인인증을 한 유저만 포인트 정책에 따라 포인트를 지급하도록 수정
2025-05-20 00:51:04 +09:00
a36d9f02d8 fix: 포인트 내역 리스트
- 유저의 포인트 보상내역, 사용내역 id 내림차순 정렬
2025-05-20 00:14:57 +09:00
d6db862c9d fix: 포인트 내역 리스트
- 유저의 포인트 보상내역, 사용내역 API 추가
2025-05-19 21:38:24 +09:00
56542a7bf1 fix: 포인트 사용내역
- 포인트를 어디에 사용했는지 알기 위해 포인트 사용내역 저장시 orderId 추가
2025-05-19 20:49:16 +09:00
36b8e8169e fix: 유저 행동 데이터에 따른 포인트 지급
- 유저가 지급 받을 포인트가 0 이상인 경우에만 포인트 지급 로그를 남기고 푸시 발송
2025-05-19 16:27:58 +09:00
b102241efd fix: 유저 행동 데이터
- commentId -> contentCommentId 로 변경
2025-05-19 15:25:17 +09:00
f36010fefa fix: 유저 행동 데이터
- commentId -> contentCommentId 로 변경
2025-05-19 15:17:44 +09:00
aa23d6d50f fix: 주문한 콘텐츠에 댓글 작성 이벤트
- 포인트 받은 현황을 조회할 때 주문 ID를 같이 조회하도록 만들어서 주문한 콘텐츠에 댓글 작성 이벤트의 경우 주문별로 참여할 수 있도록 수정
2025-05-19 15:08:21 +09:00
6df043dfac fix: 콘텐츠 댓글 작성시 유저 행동 데이터에 댓글 ID를 같이 기록하도록 수정 2025-05-19 15:05:31 +09:00
fe84292483 fix: 포인트 지급 요소 계산시 정책 시작 날짜 이후의 유저 행동들만 반영하도록 수정 2025-05-19 14:43:50 +09:00
0f48c71837 fix: transactionTemplate 을 적용하여 횟수가 잘못 판단되는 경우 최소화 2025-05-19 11:43:24 +09:00
107e8fce55 fix: 유저의 행동 데이터 기록시 주문한 콘텐츠에 댓글을 쓰는 것을 판단하기 위해 주문 정보 조회시 id 내림차순으로 하여 가장 최근 주문정보를 가져오도록 수정 2025-05-19 10:49:16 +09:00
3079998a5d fix: 구매한 콘텐츠 댓글 이벤트 추가
- 구매한 콘텐츠 댓글 쓰기시 구매한 캔을 포인트로 지급 해야 되는데 설정한 포인트로 지급되는 버그 수정
2025-05-17 18:44:04 +09:00
e2d0ae558a feat: 구매한 콘텐츠 댓글 이벤트 추가
- 구매한 콘텐츠 댓글 쓰기시 구매한 캔을 포인트로 지급
2025-05-17 18:13:11 +09:00
1bca1b27ed feat: 구매한 콘텐츠 댓글 이벤트 추가 2025-05-17 18:07:02 +09:00
6fc372c898 feat: 유저 행동 데이터 기록 Controller 추가 2025-05-16 21:24:12 +09:00
ddcd54d3b9 feat: 유저 행동 데이터 기록 추가 - 콘텐츠에 댓글 쓰기 2025-05-16 20:32:48 +09:00
eb8c8c14e8 fix: 유저 행동 데이터 기록시 포인트 지급과 로그 기록 순서 변경
- 기존: 포인트 지급 후 로그 기록
- 변경: 로그 기록 후 포인트 지급
2025-05-16 17:57:37 +09:00
affc0cc235 fix: 관리자 - 포인트 정책 리스트 값 추가
- 지급유형(매일, 전체) 추가
- 참여가능 횟수 추가
2025-05-16 17:31:28 +09:00
f23251f5bb fix: 유저 행동 데이터 기록시 포인트 지급 조건 수정
- 지급유형(매일, 전체) 추가
- 참여가능 횟수 추가
- 주문한 콘텐츠에 댓글을 쓰면 포인트 지급을 위해 포인트 지급 이력에 orderId 추가
2025-05-16 15:01:33 +09:00
84f33d1bc2 Merge pull request 'fix: 소셜로그인시 유저 행동데이터 SIGN_UP 중복 기록 버그' (#315) from test into main
Reviewed-on: #315
2025-05-12 08:24:53 +00:00
73c9a90ae3 fix: 소셜로그인시 유저 행동데이터 SIGN_UP 중복 기록 버그
- 소셜로그인 시 isNew 플래그를 통해 회원가입/로그인을 구분하여 SIGN_UP 중복 기록 버그 수정
2025-05-12 17:19:34 +09:00
c4e1709b99 Merge pull request 'test' (#314) from test into main
Reviewed-on: #314
2025-05-12 02:12:47 +00:00
ced35af66d fix: 예약 취소 푸시 발송
- push 토큰 가져올 때 push token 테이블을 참조하지 않아 발생하는 버그 수정
2025-05-09 11:28:01 +09:00
b915ace6ff fix: 푸시메시지 발송 방식 변경
- iOS일 때는 notification, android 일 때는 data-only 방식으로 발송하던 현재 방식에서 모두 notification을 사용하는 방식으로 수정
2025-05-08 19:47:59 +09:00
e7a5fd5819 Merge pull request 'fix: 구글/카카오 로그인 회원가입 오류 수정' (#313) from test into main
Reviewed-on: #313
2025-05-02 10:58:04 +00:00
2fd7419bdd fix: 구글/카카오 로그인 회원가입 오류 수정
- 회원가입 전에 푸시 토큰 등록을 시도하여 에러나는 오류 수정
2025-05-02 19:38:46 +09:00
4bde03643c Merge pull request 'test' (#312) from test into main
Reviewed-on: #312
2025-04-29 02:56:16 +00:00
fd510710d9 feat: 푸시 토큰(카카오, 구글 로그인) - 한 사람이 여러개의 디바이스로 로그인 해도 모든 푸시 토큰이 기록되어 있어서 모든 디바이스에 푸시가 가도록 수정 2025-04-28 21:58:50 +09:00
8a924bd5be feat: 푸시 토큰 - 한 사람이 여러개의 디바이스로 로그인 해도 모든 푸시 토큰이 기록되어 있어서 모든 디바이스에 푸시가 가도록 수정 2025-04-28 21:40:20 +09:00
1bc52b56af Merge pull request 'fix: 콘텐츠 업로드 - 제목과 내용에서 trim 함수를 적용하여 앞/뒤 빈칸 제거' (#311) from test into main
Reviewed-on: #311
2025-04-25 09:43:31 +00:00
73edc0515f fix: 콘텐츠 업로드 - 제목과 내용에서 trim 함수를 적용하여 앞/뒤 빈칸 제거 2025-04-25 18:37:45 +09:00
9c33fd93f7 Merge pull request 'refactor: 본인인증 - 본인인증이 완료된 후 유저 행동 데이터를 기록하도록 수정' (#310) from test into main
Reviewed-on: #310
2025-04-24 11:10:17 +00:00
7870f8ea78 refactor: 본인인증 - 본인인증이 완료된 후 유저 행동 데이터를 기록하도록 수정 2025-04-24 20:04:49 +09:00
3c087bc275 Merge pull request '유저 행동 데이터, 포인트 추가' (#309) from test into main
Reviewed-on: #309
2025-04-24 02:44:57 +00:00
27c5b991cf fix: 오디션 지원 내역 - 탈퇴한 사람은 보이지 않도록 수정 2025-04-24 11:37:11 +09:00
8a937f01a4 feat: 콘텐츠 상세 - 포인트 사용 가능 여부 추가 2025-04-24 10:50:14 +09:00
3940282ed8 feat: 마이페이지 - 포인트 추가 2025-04-23 18:26:47 +09:00
ca704f38b9 fix: 포인트 정책 수정 - @Transactional 추가 2025-04-23 17:29:08 +09:00
6ff044e4ab fix: 포인트 정책 조회 - date가 null인 경우 빈칸으로 표시 2025-04-23 17:09:57 +09:00
fa98138541 fix: 포인트 정책 생성 - endDate가 빈칸이면 null 처리 2025-04-23 16:55:58 +09:00
cb7917dc26 fix: 포인트 정책 등록 - request에 활성화 여부 제거 2025-04-23 14:57:33 +09:00
58d066af0a feat: 유저 행동 데이터 - 본인인증 추가 2025-04-23 14:45:13 +09:00
e2daff6463 feat: 콘텐츠 정산 - 포인트를 사용한 주문과 사용하지 않은 주문 분리 2025-04-23 00:55:24 +09:00
7c3b7cffc2 fix: 콘텐츠 주문 - 포인트 결제 후 추가 결제를 해야하는 캔이 남아 있는 경우에만 캔을 결제하도록 수정 (남아 있는 캔이 없는데 결제 처리가 되서 0캔으로 데이터가 쌓이는 것 방지) 2025-04-22 23:39:48 +09:00
775391f590 fix: 포인트 정책 조회 Query 로직 수정 - where 조건에 불완전한 조건문이 들어있던 버그 수정 2025-04-22 22:42:07 +09:00
57adfec490 fix: 포인트 정책 조회 Query 로직 수정 - where 조건에 불완전한 조건문이 들어있던 버그 수정 2025-04-22 22:30:12 +09:00
24e62c1885 fix: 포인트 정책 조회 Query 로직 수정 - where 조건에 불완전한 조건문이 들어있던 버그 수정 2025-04-22 22:10:38 +09:00
a70b5d89ec fix: 관리자 포인트 정책 리스트 - 전체 개수 추가 2025-04-22 21:54:42 +09:00
761d56f4bd fix: 크리에이터 관리자 콘텐츠 수정 - 포인트 사용 가능 여부 추가 2025-04-22 21:19:47 +09:00
e759f62b5f fix: 크리에이터 관리자 콘텐츠 리스트 - 포인트 사용 가능 여부 추가 2025-04-22 21:07:16 +09:00
9e2d031b5d fix: 콘텐츠 업로드 - 포인트 사용 가능 여부 추가 2025-04-22 19:39:07 +09:00
b9cb8ad4a8 fix: 포인트 결제 조건 - 포인트 결제가 가능한 콘텐츠만 포인트 결제를 하도록 수정 2025-04-22 18:49:52 +09:00
c1d4c1ff1d feat: 기존 푸시 메시지 전송 로직에 최대 3회 재시도 처리 로직 추가 2025-04-22 17:44:19 +09:00
971683a81e feat: 포인트 지급 시 FCM data-only 푸시 메시지 전송 및 실패 시 재시도 처리 2025-04-22 17:35:47 +09:00
51dae0f02c feat: 포인트 사용 로직 구현 (만료일 순 + 10포인트 단위 차감) 2025-04-22 15:39:45 +09:00
e2c70de2e0 feat: 유저 행동 기록 및 포인트 지급 로직 구현 + 회원가입 연동 2025-04-21 22:03:58 +09:00
d94418067f 관리자 포인트 지급 정책 리스트, 생성, 수정 API 2025-04-21 19:08:31 +09:00
1cb2ee77b5 포인트 지급 정책
- Title 추가
2025-04-21 14:35:05 +09:00
336d3c9434 유저 행동데이터, 포인트
- Entity 생성
2025-04-21 14:22:10 +09:00
8ad13c289e Merge pull request '회원탈퇴' (#308) from test into main
Reviewed-on: #308
2025-04-15 10:42:37 +00:00
7649ce6e52 회원탈퇴
- 이메일 가입자만 비밀번호 체크
2025-04-15 19:28:28 +09:00
7577f48a09 Merge pull request '한정판 콘텐츠' (#307) from test into main
Reviewed-on: #307
2025-04-15 09:44:12 +00:00
5759a51017 한정판 콘텐츠
- 해당 콘텐츠 크리에이터인 경우 콘텐츠 구매자 리스트 추가
2025-04-11 21:39:39 +09:00
0251906964 Merge pull request '비밀번호 찾기' (#306) from test into main
Reviewed-on: #306
2025-04-10 06:28:57 +00:00
dd5c121f1f 비밀번호 찾기
- 이메일 로그인이 아닌 계정의 비밀번호를 찾으려고 하면 예외 발생
- 에러 메시지 : 해당 계정은 OO계정으로 가입되어 있습니다. 해당 소셜 로그인을 사용해 주세요.
2025-04-10 15:16:56 +09:00
2723a5f134 Merge pull request '일별 전체 회원 수' (#305) from test into main
Reviewed-on: #305
2025-04-10 02:30:00 +00:00
cae3a92a66 일별 전체 회원 수
- 이메일, 구글, 카카오 회원 수 추가
2025-04-10 11:12:43 +09:00
c3c60605fd Merge pull request '관리자 - 회원리스트, 크리에이터 리스트' (#304) from test into main
Reviewed-on: #304
2025-04-09 10:35:01 +00:00
562550880c 관리자 - 회원리스트, 크리에이터 리스트
- 로그인 타입 추가 (소셜로그인, 이메일 로그인)
2025-04-09 19:07:04 +09:00
238f704b22 Merge pull request '소셜 로그인, 회원가입 - 이메일 체크 로직 수정' (#303) from test into main
Reviewed-on: #303
2025-04-08 07:04:11 +00:00
a9c68f9971 소셜 로그인, 회원가입 - 이메일 체크 로직 수정
- 이미 가입된 계정인 경우 안내 문구 자세히 안내
2025-04-08 15:32:45 +09:00
5639d8ac8e Merge pull request 'test' (#302) from test into main
Reviewed-on: #302
2025-04-07 10:23:13 +00:00
d822a4a8ac 카카오 로그인 추가 2025-04-07 15:58:08 +09:00
e52c914000 관리자
- 새로운 시리즈(추천 시리즈) 보이는 순서를 orders 순서대로 보이도록 수정
2025-04-07 12:16:39 +09:00
a301f854ba 구글 로그인 - provider가 구글로 기록되도록 수정 2025-04-04 14:54:20 +09:00
602d9625e2 구글 로그인 - 인증없이 실행되도록 수정 2025-04-04 14:05:31 +09:00
5598bca8d3 구글 로그인 추가 2025-04-04 13:21:49 +09:00
1bbaf8f7b7 이벤트
- link를 빈칸으로 기록할 수 있도록 수정
2025-04-03 15:30:25 +09:00
3bb2753607 이벤트
- link를 빈칸으로 기록할 수 있도록 수정
2025-04-03 15:23:28 +09:00
08848c783d 이벤트
- link를 빈칸으로 기록할 수 있도록 수정
2025-04-03 12:29:46 +09:00
9aac591591 Merge pull request 'test' (#301) from test into main
Reviewed-on: #301
2025-04-01 13:31:24 +00:00
6e229af790 콘텐츠 상세
- 이전화/다음화 추가
2025-04-01 18:27:59 +09:00
ce8cc3eb29 콘텐츠 상세
- 이전화/다음화 추가
2025-04-01 17:36:32 +09:00
198ecddc89 콘텐츠 상세
- 이전화/다음화 추가
2025-04-01 16:21:32 +09:00
ffa8e5aebb Merge pull request '일별 전체 회원 수 통계' (#300) from test into main
Reviewed-on: #300
2025-03-31 03:50:18 +00:00
ae439b7e64 일별 전체 회원 수 통계
- 본인인증 수 추가
2025-03-31 12:37:32 +09:00
cbbfe014cc Merge pull request '광고 통계' (#299) from test into main
Reviewed-on: #299
2025-03-28 05:29:40 +00:00
3f1101ff73 광고 통계
- 광고를 터치하여 앱을 실행한 수 추가
2025-03-28 11:21:03 +09:00
83028f7817 Merge pull request 'test' (#298) from test into main
Reviewed-on: #298
2025-03-26 21:08:29 +00:00
5777d9700f 크리에이터, 콘텐츠, 시리즈 검색
- 콘텐츠, 시리즈 검색 결과에 크리에이터 닉네임 추가
2025-03-27 05:40:07 +09:00
e1e9f4588a 크리에이터, 콘텐츠, 시리즈 검색 2025-03-27 00:49:00 +09:00
be2f013b9a 마케팅 트래킹
- AppLaunch 트래킹에 빈 본문 추가
2025-03-26 16:51:00 +09:00
70d1795557 Merge pull request 'test' (#297) from test into main
Reviewed-on: #297
2025-03-26 04:23:28 +00:00
0b03ebeb70 마케팅 트래킹
- type에 @Enumerated(value = EnumType.STRING) 추가
2025-03-26 13:15:43 +09:00
c466ecb77c 마케팅 트래킹
- 복합키를 AUTO_INCREMENT의 단일키로 변경
- AppLaunch 트래킹 추가
2025-03-26 13:09:09 +09:00
8c6c681424 Merge pull request 'marketing 정보 업데이트 시 pid 값이 있으면 항상 로그인 기록 남기기' (#296) from test into main
Reviewed-on: #296
2025-03-25 11:25:46 +00:00
ba9c71a4ec marketing 정보 업데이트 시 pid 값이 있으면 항상 로그인 기록 남기기 2025-03-25 18:57:24 +09:00
50bc9f4ff3 Merge pull request '라이브 방 - 예약 중 조회' (#295) from test into main
Reviewed-on: #295
2025-03-24 10:04:08 +00:00
e33050a6d6 라이브 방 - 예약 중 조회
- 로그인 없이 조회시 예약완료로 표시되는 버그 수정
2025-03-24 18:43:33 +09:00
f00ea03fad Merge pull request 'test' (#294) from test into main
Reviewed-on: #294
2025-03-24 09:09:16 +00:00
3595c02e74 라이브 방
- 로그인 없이 조회 가능하도록 수정
2025-03-22 06:37:20 +09:00
3ff84074bd 라이브 방
- 로그인 없이 조회 가능하도록 수정
2025-03-22 06:26:17 +09:00
6dd6be183b 라이브 메인
- 로그인 없이 조회 가능하도록 수정
2025-03-22 06:10:28 +09:00
0764247447 오디션 메인
- 로그인 없이 조회 가능하도록 수정
2025-03-22 05:09:08 +09:00
f9f9b9aab9 FAQ
- 로그인 없이 조회가 가능하도록 수정
2025-03-22 04:39:54 +09:00
ec0252bae0 콘텐츠 메인 홈
- 로그인 없이 인기 단편 조회가 가능하도록 수정
2025-03-22 03:16:54 +09:00
dc74d203bd 콘텐츠 메인 홈
- 로그인 없이 인기 단편 조회가 가능하도록 수정
2025-03-22 02:42:44 +09:00
387d364861 콘텐츠 메인 홈
- 로그인 없이 조회가 가능하도록 수정
2025-03-22 01:50:00 +09:00
82afdecf6c 콘텐츠 메인 홈
- 로그인 없이 조회가 가능하도록 수정
2025-03-22 01:38:32 +09:00
519c63a023 콘텐츠 메인 홈
- 로그인 없이 조회가 가능하도록 수정
2025-03-22 00:52:34 +09:00
f22e7b9ad1 Merge pull request '자동생성 닉네임에 사용될 형용사, 명사 값 추가' (#293) from test into main
Reviewed-on: #293
2025-03-21 10:27:30 +00:00
d45a25258e 자동생성 닉네임에 사용될 형용사, 명사 값 추가 2025-03-21 18:43:49 +09:00
c7ec95f4bb Merge pull request 'test' (#292) from test into main
Reviewed-on: #292
2025-03-20 19:24:03 +00:00
bc822355df 회원탈퇴 시 닉네임 앞에 "deleted_"를 추가 2025-03-21 04:15:53 +09:00
9535ff18de 닉네임 자동생성
- 닉네임을 더 유니크하게 생성할 수 있도록 형용사와 명사 추가
2025-03-21 04:11:35 +09:00
da0a83bb6d 닉네임 자동생성
- '의'가 들어간 단어 제거
2025-03-21 02:50:27 +09:00
4977ee99df 회원가입 로직 개선
- 기본 프로필 이미지와 닉네임 자동생성을 통해 회원가입 단계 축소
2025-03-21 00:24:15 +09:00
229e7a8ccc Merge pull request '시리즈 상세, 채널 상세' (#291) from test into main
Reviewed-on: #291
2025-03-19 09:43:06 +00:00
9ed031e574 시리즈 상세, 채널 상세
- 19금 콘텐츠 보기 설정 적용
2025-03-19 18:34:20 +09:00
3c616474ff Merge pull request 'test' (#290) from test into main
Reviewed-on: #290
2025-03-19 07:51:25 +00:00
b1fb62dd65 콘텐츠 메인 홈 - 인기 시리즈, 인기 단편
콘텐츠 메인 단편 - 랭킹
- 기존 조건에 계산은 최대 5번까지만 하도록 수정
2025-03-19 16:45:31 +09:00
b7b166c362 콘텐츠 메인 홈 - 인기 시리즈
- 데이터가 5개 미만이면 5개 이상이 될 때까지 랭킹 계산 시작 날짜를 1주일 씩 이전으로 설정
2025-03-19 16:27:55 +09:00
46321dd3c1 콘텐츠 메인 홈 - 인기 단편
- 데이터가 5개 미만이면 5개 이상이 될 때까지 랭킹 계산 시작 날짜를 1주일 씩 이전으로 설정
2025-03-19 16:23:44 +09:00
1998a95c35 콘텐츠 메인 단편 - 일간랭킹
- 데이터가 5개 미만이면 5개 이상이 될 때까지 랭킹 계산 시작 날짜를 5일씩 이전으로 설정
2025-03-19 16:15:27 +09:00
13a1fa674b 콘텐츠 메인 홈 - 인기 단편
- 19금 콘텐츠 보기 설정 적용
2025-03-19 14:26:03 +09:00
e488f3419e 콘텐츠 메인 홈 - 채널별 인기 콘텐츠 채널
- 19금 콘텐츠 안보기 설정시 일반 콘텐츠 판매량으로 채널 조회
2025-03-19 12:23:54 +09:00
56eb6b3ce3 Merge pull request '19금 콘텐츠 보기 설정 적용' (#289) from test into main
Reviewed-on: #289
2025-03-19 02:05:17 +00:00
dc1c29b69d 콘텐츠 메인 홈, 모닝콜, asmr, 단편, 무료, 다시듣기, 시리즈
- 남성향, 여성향 선택한 유저의 경우 해당 성향의 콘텐츠 + 미인증 크리에이터의 콘텐츠를 보여주도록 수정
2025-03-18 17:27:11 +09:00
c7eae53b22 콘텐츠 메인 홈, 모닝콜, asmr, 단편, 무료, 다시듣기, 시리즈
- 남성향, 여성향 선택한 유저의 경우 해당 성향의 콘텐츠 + 미인증 크리에이터의 콘텐츠를 보여주도록 수정
2025-03-18 17:10:19 +09:00
b3b3d46696 콘텐츠 메인 홈, 모닝콜, asmr, 단편, 무료, 다시듣기, 시리즈
- 19금 콘텐츠 (안)보기 설정
- 남성향, 여성향 콘텐츠만 보기 설정 적용
2025-03-18 16:07:10 +09:00
545836d43c Merge pull request '관리자 광고통계, 일별 전체 회원 수' (#288) from test into main
Reviewed-on: #288
2025-03-17 08:50:59 +00:00
537ec88d05 관리자 광고통계, 일별 전체 회원 수
- 1페이지 이외에 데이터가 보이지 않는 버그 수정
2025-03-17 17:44:14 +09:00
219f83dec0 Merge pull request 'test' (#287) from test into main
Reviewed-on: #287
2025-03-17 05:54:05 +00:00
d54f05fa00 관리자 광고통계, 일별 전체 회원 수
- 날짜 내림차순으로 정렬
2025-03-17 14:47:05 +09:00
5708f4f059 관리자 광고통계, 일별 전체 회원 수
- 날짜 내림차순으로 정렬
2025-03-17 14:39:01 +09:00
353807404a 관리자 광고통계
- 날짜 내림차순으로 정렬
2025-03-17 14:31:42 +09:00
a76a841238 Merge pull request 'test' (#286) from test into main
Reviewed-on: #286
2025-03-14 16:11:17 +00:00
81fa445964 관리자 - 일별 전체 회원수 API
- 결제자 수 중복을 제거하고 카운팅하도록 수정
2025-03-15 01:03:16 +09:00
f65ddbc5b8 관리자 - 일별 전체 회원수 API
- 결제자 수 중복을 제거하고 카운팅하도록 수정
2025-03-15 00:54:17 +09:00
b817a230fd 관리자 - 일별 전체 회원수 API
- 결제자 수 중복을 제거하고 카운팅하도록 수정
2025-03-15 00:46:26 +09:00
3a180d478c 관리자 - 일별 전체 회원수 API
- 합계 날짜 범위를 전체 날짜 범위로 수정
2025-03-15 00:09:35 +09:00
74fecddf95 관리자 - 일별 전체 회원수 API
- 페이지 계산 수정
2025-03-14 23:55:04 +09:00
1dec8913c5 관리자 - 일별 전체 회원수 API
- 일별 회원가입, 회원탈퇴, 결제자 수를 반환하는 API 생성
2025-03-14 21:48:52 +09:00
c26680de84 Merge pull request '이벤트 배너, 충전 이벤트 - 기간 설정에 시간 추가' (#285) from test into main
Reviewed-on: #285
2025-03-14 03:40:07 +00:00
b9063fb22f 관리자 - 충전이벤트
- 시간 계산을 Querydsl 코드에서 수행
- 등록/수정 시 이벤트 진행기간에 시간도 포함하도록 수정
2025-03-14 02:44:34 +09:00
287d133080 관리자 - 이벤트 배너
- 이벤트 기간 설정을 시간:분 까지 설정하도록 수정
2025-03-14 02:13:42 +09:00
3ef1a732e5 관리자 - 이벤트 배너 서비스
- 시작 전인 이벤트도 보이도록 수정
2025-03-14 01:55:08 +09:00
7cd95da83c 관리자 - 광고 통계
- 패키지 이동 (marketing/statistics -> statistics/ad)
2025-03-14 01:49:10 +09:00
dd138bff86 관리자 - 이벤트 배너 서비스
- 이미지 host를 Querydsl 코드에서 추가
- 시작 전인 이벤트도 보이도록 수정
2025-03-14 01:43:43 +09:00
8fffad9d3a Merge pull request 'test' (#284) from test into main
Reviewed-on: #284
2025-03-13 12:25:35 +00:00
327b0149d9 콘텐츠 홈 단편 탭
- 유료 콘텐츠만 나오도록 수정
2025-03-13 21:16:42 +09:00
b822cf47bb 광고 통계
- 전체 개수 계산시 NonUniqueResultException 버그 수정
2025-03-13 19:51:58 +09:00
f4f0f203a2 Merge pull request '유저 정보 조회' (#283) from test into main
Reviewed-on: #283
2025-03-12 08:00:13 +00:00
30e1e461e3 유저 정보 조회
- 성별, 가입일, 충전횟수 추가
2025-03-12 02:51:42 +09:00
b7196f5a0c Merge pull request 'test' (#282) from test into main
Reviewed-on: #282
2025-03-11 08:01:05 +00:00
3e25accaa3 관리자 마케팅 - 광고 통계
- 날짜별 검색 추가
2025-03-11 16:38:58 +09:00
5b3c5731ee 관리자 마케팅 - 광고 통계
- LOGIN 기록 추가
2025-03-11 16:31:31 +09:00
84de4e0c5a 마케팅 - 매체 파트너 코드 기록
- 마케팅 PID 가 변경될 때 LOGIN 기록
2025-03-11 15:27:26 +09:00
5d33a18890 Merge pull request 'test' (#281) from test into main
Reviewed-on: #281
2025-03-10 05:35:30 +00:00
48677a5a24 마케팅 - 매체 파트너 코드 정렬 수정
- id 오름차순에서 내림차순으로 변경
2025-03-10 13:50:16 +09:00
b0349ac133 마케팅 - 광고 통계
- 전체 개수를 size로 구하지 않고 count 함수를 이용하도록 수정
2025-03-09 17:38:04 +09:00
96186a1a50 Merge pull request '마케팅 - 매체 파트너 코드 조회 API - link 값 수정' (#280) from test into main
Reviewed-on: #280
2025-03-07 06:27:08 +00:00
925c5203be 마케팅 - 매체 파트너 코드 조회 API - link 값 수정
- 쿼리파라미터에 af_dp 추가
2025-03-07 15:17:52 +09:00
bc8bc479d1 Merge pull request 'test' (#279) from test into main
Reviewed-on: #279
2025-03-06 17:58:32 +00:00
89a8a145df 마케팅 - 매체 파트너 코드 조회 API - link 값 수정
- 쿼리파라미터의 키와 값을 각각 인코딩 적용
2025-03-07 02:47:34 +09:00
83a938dc53 마케팅 - 매체 파트너 코드 조회 API - link 값 수정
- 쿼리파라미터의 키와 값을 각각 인코딩 적용
2025-03-07 02:31:40 +09:00
47595b1291 Merge pull request 'test' (#278) from test into main
Reviewed-on: #278
2025-03-05 14:05:47 +00:00
75940bbb23 마케팅 - 매체 파트너 코드 조회 API - link 값 수정
- utm_campaign 값을 pidName에서 pid 로 변경
2025-03-05 22:47:29 +09:00
37516a0072 마케팅
- 광고 통계 조회 API 추가
2025-03-05 21:49:33 +09:00
01a88964df Merge pull request 'test' (#277) from test into main
Reviewed-on: #277
2025-03-05 09:44:59 +00:00
0f68b297a0 마케팅 매체 파트너 코드 조회
- 링크 인코딩 제거
2025-03-05 17:16:05 +09:00
d454acdcbe 마케팅 매체 파트너 코드 조회
- 전체 개수 추가
2025-03-05 16:10:11 +09:00
2ce13afc0a 마케팅 매체 파트너 코드 조회 - link 인코딩 수정
- host 는 인코딩하지 않고 쿼리 파라미터 부분만 인코딩 하도록 수정
2025-03-05 15:53:25 +09:00
2dd75ae7e8 marketing info 업데이트 API
- pid가 빈칸이면 등록하지 않도록 수정
2025-03-05 00:49:50 +09:00
a17a6a41da 관리자
- 매체 파트너 코드(pid) 조회 API
2025-03-05 00:39:49 +09:00
5db181aa74 관리자
- 매체 파트너 코드(pid) 수정 API
2025-03-04 23:53:22 +09:00
d74f1ddb81 관리자
- 매체 파트너 코드(pid) 등록 API
2025-03-04 23:36:31 +09:00
be12148d04 트래킹
- mediaGroup과 pidName 추가
2025-03-04 17:33:01 +09:00
72d10f9443 캔 충전
- 트래킹 로직 적용
2025-03-04 17:05:41 +09:00
81b11976a7 DateTime -> Datetime 2025-03-04 13:40:23 +09:00
f918e89307 DateTime -> Datetime 2025-03-04 13:34:56 +09:00
83ed4b6961 marketing info 업데이트 API 생성 2025-03-04 12:28:30 +09:00
3216c73ee8 회원가입 로직에 광고 트래킹 적용
- 광고 트래킹 관련 Entity 추가
- pid가 현재 광고 중인 pid인 경우 트래킹 로그 생성
2025-03-04 10:52:35 +09:00
3a2b77379f Merge pull request '콘텐츠 업로드' (#276) from test into main
Reviewed-on: #276
2025-02-28 04:45:04 +00:00
801b9934d6 콘텐츠 업로드
- 알람, 모닝콜, 슬립콜은 소장만 가능하도록 수정
2025-02-28 13:30:24 +09:00
dc4e5f75cd Merge pull request '콘텐츠 메인 콘텐츠 탭 - 채널별 추천 단편' (#275) from test into main
Reviewed-on: #275
2025-02-26 03:14:33 +00:00
7a745c2f4b 콘텐츠 메인 콘텐츠 탭 - 채널별 추천 단편
- 좋아요 개수를 기준으로 내림차순 정렬하도록 수정
- 유료 콘텐으만 나오도록 수정
2025-02-26 12:01:39 +09:00
d0178d551c Merge pull request '콘텐츠 메인 콘텐츠 탭 - 채널별 추천 단편' (#274) from test into main
Reviewed-on: #274
2025-02-25 14:54:53 +00:00
4b1c2e36ed 콘텐츠 메인 콘텐츠 탭 - 채널별 추천 단편
- 좋아요 개수를 기준으로 내림차순 정렬하도록 수정
2025-02-25 23:42:23 +09:00
827333108d Merge pull request '콘텐츠 대여기간' (#273) from test into main
Reviewed-on: #273
2025-02-25 14:02:18 +00:00
2e05b25c41 콘텐츠 메인
- 테마에 오디오북 추가
2025-02-25 22:52:33 +09:00
d4318cc48c 콘텐츠 대여기간
- 15 -> 5일로 변경
2025-02-24 12:17:07 +09:00
587b90bd27 Merge pull request '콘텐츠 메인 무료 탭 - 새로운 콘텐츠' (#272) from test into main
Reviewed-on: #272
2025-02-22 01:56:49 +00:00
780088eb0c 콘텐츠 메인 무료 탭 - 새로운 콘텐츠
- 상단 탭에 있는 테마 제거
2025-02-22 10:52:30 +09:00
4dc20c5e90 Merge pull request '콘텐츠 메인 무료 탭' (#271) from test into main
Reviewed-on: #271
2025-02-22 00:39:09 +00:00
2a3d7c9291 콘텐츠 메인 무료 탭
- 큐레이션에서 '크리에이터 소개'가 제목에 들어가면 제외하도록 수정
2025-02-22 09:33:19 +09:00
ac25782f2b Merge pull request '관리자 태그 큐레이션 - 콘텐츠 검색' (#270) from test into main
Reviewed-on: #270
2025-02-21 21:46:15 +00:00
8ac9695535 관리자 태그 큐레이션 - 콘텐츠 검색
- 이전에 추가했던 기록이 있으면 검색되지 않던 버그 수정
2025-02-22 06:26:39 +09:00
20437d56e7 Merge pull request '메인 시리즈 탭 - 완결 시리즈' (#269) from test into main
Reviewed-on: #269
2025-02-21 21:15:52 +00:00
5933a74885 메인 시리즈 탭 - 완결 시리즈
- 1~10일, 11~20일, 21~해당 월의 마지막날 로 3등분 하여 순위를 계산하고 10일씩 반영되도록 수정
ex)오늘이 23일 이면 11~20일 사이에 팔린 개수를 기준으로 순위 계산
2025-02-22 05:56:56 +09:00
f0b412828a Merge pull request '메인 시리즈 탭 - 완결 시리즈' (#268) from test into main
Reviewed-on: #268
2025-02-21 19:27:33 +00:00
afb64eb8f2 메인 시리즈 탭 - 완결 시리즈
- 1~10일, 11~20일, 21~해당 월의 마지막날 로 3등분 하여 순위를 계산하고 10일씩 반영되도록 수정
ex)오늘이 23일 이면 11~20일 사이에 팔린 개수를 기준으로 순위 계산
2025-02-22 04:09:03 +09:00
367faac5c3 Merge pull request 'test' (#267) from test into main
Reviewed-on: #267
2025-02-20 18:24:35 +00:00
3c90f065fb 관리자 콘텐츠 메인 시리즈 탭 - 큐레이션
- 시리즈 검색시 큐레이션에 추가되었다가 삭제된 시리즈도 검색 되도록 수정
2025-02-21 03:18:41 +09:00
8b731999a7 콘텐츠 메인 시리즈 탭 - 큐레이션
- 시리즈 정렬 추가
- 내용이 추가되지 않은 큐레이션은 보이지 않도록 수정
2025-02-21 03:05:08 +09:00
5182d03b16 콘텐츠 메인 시리즈 탭 - 큐레이션
- 모든 큐레이션 데이터가 시리즈 큐레이션에 중복되어 나오는 버그 수정
2025-02-21 02:56:54 +09:00
84deaaa970 Merge pull request '콘텐츠 메인 시리즈 탭 - 장르별 시리즈' (#266) from test into main
Reviewed-on: #266
2025-02-19 12:52:17 +00:00
433a9a29c5 콘텐츠 메인 시리즈 탭 - 장르별 시리즈
- 콘텐츠가 있는 장르만 표시하도록 수정
2025-02-19 21:46:02 +09:00
a2b39466c2 Merge pull request '기존 콘텐츠 메인 - 새로운 콘텐츠' (#265) from test into main
Reviewed-on: #265
2025-02-19 11:34:02 +00:00
3388bb4283 기존 콘텐츠 메인 - 새로운 콘텐츠
- 전체 테마를 선택시 데이터가 나오지 않는 버그 수정
2025-02-19 19:12:11 +09:00
03586c4005 Merge pull request '기존 콘텐츠 메인 - 새로운 콘텐츠' (#264) from test into main
Reviewed-on: #264
2025-02-19 09:49:04 +00:00
8ae225f434 기존 콘텐츠 메인 - 새로운 콘텐츠
- 전체 테마를 선택시 데이터가 나오지 않는 버그 수정
2025-02-19 18:43:48 +09:00
6ea69e1510 Merge pull request '콘텐츠 메인 무료 탭 - 새로운 무료 콘텐츠' (#263) from test into main
Reviewed-on: #263
2025-02-19 09:24:24 +00:00
c8c1087b73 콘텐츠 메인 무료 탭 - 새로운 무료 콘텐츠
- 전체를 터치하면 테마가 빈칸으로 들어가는 버그 수정
2025-02-19 18:13:26 +09:00
553c6dc539 Merge pull request '콘텐츠 메인 단편 탭 - 새로운 단편' (#262) from test into main
Reviewed-on: #262
2025-02-19 08:20:14 +00:00
feae2f5f98 콘텐츠 메인 무료 탭 - 새로운 무료 콘텐츠 테마
- 데이터가 없는 탭은 나오지 않도록 수정
2025-02-19 17:05:42 +09:00
2a96467d9c 콘텐츠 메인 단편 탭 - 새로운 단편
- 탭으로 나눠져 있는 테마는 보이지 않도록 수정
2025-02-19 16:53:24 +09:00
6cc22f5b6d Merge pull request '콘텐츠 메인 홈, 무료 탭' (#261) from test into main
Reviewed-on: #261
2025-02-19 06:34:53 +00:00
03bd915fa5 콘텐츠 메인 홈, 무료 탭
- 콘텐츠 랭킹에서 잘못 작성된 sql 수정
2025-02-19 14:47:59 +09:00
9103d67cc1 Merge pull request 'test' (#260) from test into main
Reviewed-on: #260
2025-02-18 18:13:25 +00:00
872ec7f13f 콘텐츠 메인 홈 탭 - 채널별 인기 콘텐츠
- 크리에이터 정렬 - 판매수 내림차순
2025-02-19 03:08:48 +09:00
7041aff350 콘텐츠 메인 시리즈 탭 - 완결 시리즈
- 완결시리즈 전체 보여주기
- 정렬 - 월별 판매량 순
2025-02-19 02:20:10 +09:00
000fb7c941 콘텐츠 메인 다시듣기 탭
- 인기 순위 제거
2025-02-19 01:45:45 +09:00
e0d978621b 콘텐츠 메인 ASMR 탭
- 인기 순위 제거
2025-02-19 01:43:55 +09:00
c29627bb64 콘텐츠 메인 단편 탭 - 일간 랭킹
- sortType 추가
2025-02-19 01:33:56 +09:00
839cbdeaec 콘텐츠 메인 단편 탭 - 테마
- 오디오북, 모닝콜, 알람, 슬립콜, 다시듣기, ASMR, 릴레이, 챌린지, 자기소개 제거
2025-02-19 01:10:00 +09:00
25083fb0e4 Merge pull request 'test' (#259) from test into main
Reviewed-on: #259
2025-02-18 14:48:09 +00:00
44e3eda145 콘텐츠 메인 단편 탭 - 태그별 추천 단편 태그
- 콘텐츠가 하나 이상 등록되어 있는 태그만 조회되도록 수정
2025-02-18 21:33:30 +09:00
00c705085e 콘텐츠 메인 단편 탭
- 태그별 추천 단편 API 추가
2025-02-18 19:19:52 +09:00
7b957c6732 레거시 콘텐츠 메인 - 상단 콘텐츠 배너
- 탭이 지정되지 않은 콘텐츠 배너만 보이도록 수정
2025-02-18 17:11:32 +09:00
03e1ef3271 관리자 태그 큐레이션 등록, 수정
- 태그가 #으로 시작하지 않으면 #추가
2025-02-18 17:05:28 +09:00
4370fef5d5 관리자 태그 큐레이션 등록
- 동일한 태그가 등록되지 않도록 validation 추가
2025-02-18 16:34:02 +09:00
93b0565368 관리자 태그 큐레이션 등록
- 동일한 태그가 등록되지 않도록 validation 추가
2025-02-18 16:19:59 +09:00
e0565f7eed 관리자 태그별 추천 단편
- 등록, 수정, 삭제, 순서변경 API 추가
2025-02-18 15:27:39 +09:00
43ea4191c3 콘텐츠 메인 무료 탭
- 콘텐츠 테마와 유저 테이블 데이터를 사용하므로 해당 테이블 조인 코드 추가
2025-02-18 01:59:18 +09:00
308127d044 콘텐츠 메인 무료 탭
- 채널별 추천 무료 콘텐츠 API 추가
2025-02-18 01:49:52 +09:00
ab2f581c9f 콘텐츠 메인 무료 탭
- 채널별 추천 무료 콘텐츠 API 추가
2025-02-18 01:43:37 +09:00
51c4044e2f 콘텐츠 메인 단편 탭
- 큐레이션 추가
2025-02-17 23:34:58 +09:00
fbfb951825 관리자 콘텐츠 메인 큐레이션 아이템
- 순서 변경 기능 추가
2025-02-17 22:50:22 +09:00
b529d49e78 콘텐츠 메인 알람 탭 - 새로운 알람 전체보기
- 전체 개수 추가
2025-02-17 20:22:57 +09:00
3344757af8 콘텐츠 메인 무료 탭
- 새로운 무료 콘텐츠 전체 조회가 먼저 되도록 수정
2025-02-17 12:30:17 +09:00
5521f39cc5 콘텐츠 메인 - 채널별 ** 콘텐츠
- 유료 콘텐츠만 개수에 포함
2025-02-17 12:22:34 +09:00
f9c34d14c3 콘텐츠 메인 - 채널별 ** 콘텐츠
- 매출 순위 제거
2025-02-17 12:15:02 +09:00
dc0902c555 콘텐츠 메인 - 채널별 인기 콘텐츠
- 판매개수 순위 Top2 -> Top4로 변경
2025-02-17 12:07:21 +09:00
239516b98b 콘텐츠 메인 - 홈, 단편 - 채널별 인기 콘텐츠
- 보이는 채널 조건 아래와 같이 변경
- 유료 콘텐츠 4개 이상 등록한 채널의 주간 콘텐츠 판매 개수 Top 20
2025-02-17 11:54:08 +09:00
d2dc045255 Merge pull request 'test' (#258) from test into main
Reviewed-on: #258
2025-02-14 18:09:11 +00:00
3d1716d847 콘텐츠 메인 다시듣기, ASMR
- 채널별 콘텐츠 조회 API 추가
2025-02-15 02:01:59 +09:00
34452525d4 모닝콜 새로운 콘텐츠 전체보기
- API 추가
2025-02-14 22:00:13 +09:00
713d42a674 새로운 콘텐츠 전체보기
- 무료 콘텐츠 전체보기를 위해 isFree 파라미터 추가
2025-02-14 15:55:21 +09:00
e1bfd944e9 콘텐츠 메인 무료 탭
- 새로운 콘텐츠 테마 선택 액션 API 추가
2025-02-14 15:44:40 +09:00
a6f8f6a4d4 콘텐츠 메인 무료 탭
- 크리에이터 소개 전체보기 API 추가
2025-02-14 15:08:09 +09:00
cf538a2c36 콘텐츠 메인 탭
- 큐레이션 조회 로직 수정
2025-02-14 14:39:22 +09:00
3caaa151f4 콘텐츠 메인 시리즈 탭
- 채널별 추천 시리즈 API 추가
2025-02-14 14:09:21 +09:00
60ce64d3e1 콘텐츠 메인 시리즈 탭 - 오리지널 콘텐츠 API
- id 내림차순 정렬
2025-02-14 04:57:41 +09:00
9c9aa33687 콘텐츠 메인 시리즈 탭
- 장르별 추천 시리즈 API
2025-02-14 04:50:13 +09:00
a8589ef4e7 콘텐츠 메인 시리즈 탭
- 완결 시리즈 전체보기 API
2025-02-14 04:31:05 +09:00
10dd50b332 콘텐츠 메인 시리즈 탭
- 오리지널 드라마 전체보기 API
2025-02-14 03:30:50 +09:00
bebfda0343 콘텐츠 메인 시리즈 탭
- 오리지널 드라마 전체보기 API
2025-02-14 03:20:18 +09:00
babfb27b1f 콘텐츠 메인 무료 탭
- 큐레이션에서 '크리에이터 소개' 제거
2025-02-14 01:32:57 +09:00
258bd0796d 콘텐츠 메인 단편 탭
- 채널별 추천 단편 API 추가
2025-02-13 14:56:51 +09:00
2f0182e06c 콘텐츠 메인 단편 탭
- 콘텐츠 일간 랭킹 API 추가
2025-02-13 14:37:44 +09:00
ecddf9975f 콘텐츠 메인 단편 탭
- 테마별 콘텐츠 API 추가
2025-02-13 14:15:34 +09:00
664677a005 콘텐츠 메인 단편 탭 - 새로운 단편 첫 조회시 테마 없이 전체조회 2025-02-13 14:01:09 +09:00
ca9e7da17e 콘텐츠 메인 시리즈 탭 - 채널별 추천 시리즈 변수명 수정
- salesRankContentList -> recommendSeriesByChannel
2025-02-13 02:08:09 +09:00
39eb3d48a8 콘텐츠 메인 시리즈 탭 - 새로운 시리즈
- 크리에이터 프로필 이미지 추가
2025-02-13 01:20:18 +09:00
a6e949bdd6 콘텐츠 메인 시리즈 탭 - 장르별 추천 시리즈 정렬 수정
- 판매 캔 -> 판매 개수
2025-02-12 23:48:41 +09:00
63f952d390 콘텐츠 메인 시리즈 탭
- 차단 당한 유저는 오리지널 오디오 드라마가 조회되지 않도록 수정
2025-02-12 19:54:11 +09:00
7e9cb556d0 시리즈 리스트
- 콘텐츠가 1개 이상 등록된 시리즈만 조회
2025-02-12 19:27:52 +09:00
dce1abaeff 콘텐츠 메인 홈
- 채널별 인기 콘텐츠 API 추가
2025-02-10 02:39:56 +09:00
c4602369ae 콘텐츠 랭킹 아이템
- 크리에이터 프로필 이미지 추가
2025-02-10 02:30:30 +09:00
b7610641e5 채널별 인기 콘텐츠
- audioTheme join을 추가하여 에러 제거
2025-02-09 22:58:09 +09:00
8fb1247279 공지사항
- QueryProjection 사용
- QueryDSL을 통해 DTO로 바로 조회
2025-02-09 22:54:45 +09:00
b8621dfbb0 Merge pull request 'test' (#257) from test into main
Reviewed-on: #257
2025-02-09 13:36:21 +00:00
04f2ac6815 콘텐츠 메인 무료 탭 API
= url -> /free로 변경
2025-02-08 02:46:50 +09:00
9be78062be 콘텐츠 메인
- 무료 탭 API
2025-02-08 02:40:25 +09:00
3410045f83 콘텐츠 메인
- 다시보기 탭 API
2025-02-08 00:52:39 +09:00
14b0eeec7e 콘텐츠 메인
- ASMR 탭 API
2025-02-08 00:01:14 +09:00
d1579126f3 콘텐츠 메인
- 모닝콜 탭 API
2025-02-07 23:45:44 +09:00
c5539bc7e3 콘텐츠 메인
- 단편 탭 API
2025-02-07 18:32:44 +09:00
0f8fcbcaed 콘텐츠 메인
- 시리즈 탭 API
2025-02-07 03:01:24 +09:00
b1f82f9abe 콘텐츠 메인 - 홈 탭 API
- 주간 랭킹 기간 수정
2025-02-06 21:30:16 +09:00
27deff3ff3 콘텐츠 메인 - 홈 탭 API
- 채널별 인기 콘텐츠의 크리에이터가 없는 경우 콘텐츠를 불러오지 않도록 수정
2025-02-06 21:09:11 +09:00
04eb416a73 콘텐츠 메인
- 홈 탭 API
2025-02-06 19:06:27 +09:00
05e714fff1 관리자
- 새로운 시리즈, 무료 추천 시리즈 등록/수정/순서변경 API 추가
2025-02-04 23:25:52 +09:00
55badb6206 오디션 응원 하루 최대 응원 수 수정
- 10회 -> 100회
2025-02-04 00:39:41 +09:00
1b782f3df8 본인인증 - gender 값 리턴 2025-02-03 20:49:29 +09:00
bbf3fc04b6 본인인증 - gender 값 리턴 2025-02-03 19:08:22 +09:00
7657f779b5 본인인증 - 19세 미만 본인인증 불가 메시지 년도 수정
- 2005 -> 올해년도 - 19로 변경
2025-02-03 18:57:28 +09:00
93633940dd Merge pull request 'test' (#256) from test into main
Reviewed-on: #256
2025-02-03 07:20:32 +00:00
3e4bfef14e 알림설정
- 처음에 데이터 생성시 null이 들어오면 false로 처리
2025-02-03 16:15:27 +09:00
09df1eb896 관리자 큐레이션 아이템 - 삭제
- Post -> Put 으로 변경
2025-02-03 15:23:06 +09:00
23b3e6cdce 관리자 큐레이션 아이템
- 시리즈, 콘텐츠 큐레이션 아이템 불러오기 로직 분리
2025-02-03 11:26:02 +09:00
b6f5325351 Merge pull request 'test' (#255) from test into main
Reviewed-on: #255
2025-01-31 15:22:23 +00:00
32a71664a4 라이브 정산
- 캔을 사용한 날짜를 기준으로 계산 하도록 수정
2025-02-01 00:08:37 +09:00
ce881506f9 관리자 큐레이션 아이템 추가
- 콘텐츠 조회 sql에 from 추가
2025-01-31 23:12:23 +09:00
96b832983a 관리자 큐레이션 아이템 추가/제거
- return 추가
2025-01-31 23:06:03 +09:00
8f2ec7f4dd 관리자 큐레이션 아이템 추가 Request
- contentIdList -> itemIdList 변경
2025-01-31 22:50:27 +09:00
705459ee90 관리자 큐레이션 아이템
- 조회, 추가, 삭제, 콘텐츠 검색, 시리즈 검색 API 추가
2025-01-31 21:58:31 +09:00
155ea5c5e4 관리자 큐레이션 조회
- 시리즈 큐레이션 여부 추가
2025-01-24 12:21:33 +09:00
8904ef2247 앱 큐레이션 조회
- tab이 지정 안된 큐레이션만 조회되도록 수정
2025-01-23 22:46:06 +09:00
de07b3d7de 앱 큐레이션 조회
- tab이 지정 안된 큐레이션만 조회되도록 수정
2025-01-23 22:38:52 +09:00
faf827de71 관리자 큐레이션 조회
- tabId를 추가해서 탭별로 조회할 수 있도록 수정
2025-01-23 22:18:00 +09:00
d95f95899c 시리즈
- 오리지널 여부 추가
2025-01-23 19:46:54 +09:00
e9e538168c 관리자 큐레이션 등록/수정
- 탭과 시리즈 여부 추가
2025-01-23 19:33:17 +09:00
49a5e47f9d 관리자 콘텐츠 배너 API
- 조회, 등록, 수정시 탭 설정
2025-01-21 18:54:06 +09:00
8285589b10 관리자 콘텐츠 배너 API
- 탭별 배너 조회
- 탭별 배너 등록
2025-01-21 17:03:42 +09:00
00ce6d6a7a 관리자 콘텐츠 메인 탭 조회 API 2025-01-21 16:19:36 +09:00
7c32c08f1f Merge pull request 'test' (#254) from test into main
Reviewed-on: #254
2025-01-17 05:46:00 +00:00
d36aada227 관리자 시리즈 검색 API - 내용도 검색 제거 2025-01-17 14:13:20 +09:00
5be86bf7d6 관리자 시리즈 검색 API - 내용도 검색 되도록 추가 2025-01-17 14:02:47 +09:00
ddb49f6215 관리자 시리즈 검색 API - 검색어 추가
관리자/앱 콘텐츠 배너 API - 시리즈 추가
2025-01-17 14:00:09 +09:00
40c0b72450 관리자 시리즈 검색 API 추가 2025-01-17 04:06:19 +09:00
e0c9a2cea7 관리자 콘텐츠 배너 등록/수정 API
- 시리즈 등록/수정 기능 추가
2025-01-17 03:15:31 +09:00
df3f045209 앱 이벤트 배너 조회 API
- 앱에서 불필요한 날짜, 팝업용, 본인인증 데이터 제거
2025-01-16 01:30:19 +09:00
6ccdfab551 관리자용 이벤트 배너 API 2025-01-16 01:24:04 +09:00
24dc521f83 관리자 충전이벤트
- 패키지명 변경 (admin/event -> admin/event/charge)
2025-01-15 12:29:06 +09:00
cdf96f4f6a 콘텐츠 메인 탭 엔티티
- 패키지 이동
2025-01-15 00:57:20 +09:00
807de3db57 콘텐츠 메인 탭 엔티티 추가
오디오 콘텐츠 배너
- 시리즈와의 연결을 위해 AudioContentBannerType 에 SERIES 추가
- tab, series 테이블과의 관계 추가
2025-01-14 19:09:26 +09:00
e3e4151187 불필요한 Transaction 애노테이션 제거 2025-01-13 17:19:38 +09:00
1d268da08d Merge pull request '오디션 등록 푸시알림 메시지 수정' (#253) from test into main
Reviewed-on: #253
2025-01-10 10:23:19 +00:00
b34459e6c6 오디션 등록 푸시알림 메시지 수정
- [오디션 제목]이 등록되었습니다. -> '오디션 제목'이 등록되었습니다.
2025-01-09 01:27:36 +09:00
797666ae0d Merge pull request 'test' (#252) from test into main
Reviewed-on: #252
2025-01-08 14:11:08 +00:00
dd5e6c399b 오디션 등록 푸시알림
- data key 변경
2025-01-08 22:51:48 +09:00
456372c7fb 오디션 등록 푸시알림 문구 오타수정
- 오리지얼 -> 오리지널
2025-01-08 18:00:40 +09:00
b4cd489ee9 푸시정보
- 오디션 알림 추가
2025-01-08 17:45:50 +09:00
b04f35c2da 오디션 수정
- 오디션 상태를 모집중으로 변경시 오디션 알림 받기가 되어 있는 유저에게 푸시 발송
2025-01-08 17:38:11 +09:00
dcf470997e Merge pull request 'test' (#251) from test into main
Reviewed-on: #251
2025-01-08 06:29:33 +00:00
a26bb19b0f 관리자 오디션 지원 리스트 삭제 API
- 문구변경
2025-01-08 15:15:18 +09:00
6182a7a77e 관리자 오디션 지원 리스트
- 삭제 기능 추가
2025-01-08 15:04:22 +09:00
0974d1dbf8 Merge pull request '관리자 오디션 지원 리스트' (#250) from test into main
Reviewed-on: #250
2025-01-07 19:44:39 +00:00
d090631d1c 관리자 오디션 지원 리스트
- 지원자 연락처 추가
- 유효한 지원만 조회되도록 수정
2025-01-08 04:27:21 +09:00
12a35db6cd Merge pull request '오디션' (#249) from test into main
Reviewed-on: #249
2025-01-07 17:24:40 +00:00
c4d9d503ac 탐색에 있는 크리에이터 랭킹과 동일한 별도의 API 생성 2025-01-05 15:35:26 +09:00
824cd2f3ea 오디션
- endDate 제거
2025-01-05 15:27:43 +09:00
47dfaec226 크리에이터 프로필 API
- isCreator를 isCreatorRole이라는 이름으로 변경
- 나타내는 값은 동일하다
2025-01-04 01:07:37 +09:00
eb36313c9b 크리에이터 프로필 API
- 랭킹, 추천 크리, 라이브, 콘텐츠, 시리즈, 커뮤니티, 활동요약을 크리에이터인 경우에만 조회하도록 수정
2025-01-04 00:43:10 +09:00
354fbf7e29 오디션 지원 리스트
- 지원자 memberId 추가
2025-01-03 23:40:26 +09:00
1ddd40948e 오디션 투표 - 횟수 계산 방식 수정
- 오디션 지원자별 하루 10개 -> 전체 투표 횟수 하루 10개
2025-01-03 13:00:02 +09:00
64d9f3e362 오디션 지원 리스트
- 비활성화 된 데이터는 조회되지 않도록 수정
2025-01-03 08:33:52 +09:00
460196dc4d 오디션 지원 리스트
- 페이징 적용
2025-01-03 08:04:58 +09:00
80841fe543 오디션 지원
- 오디션 지원 파일 저장 경로 수정
2025-01-03 07:53:16 +09:00
c8f96a10f0 캔 사용내역 - 오디션 투표
- "[오디션 투표] 오디션명"으로 변경
2025-01-03 01:29:42 +09:00
b10c102f94 오디션 투표 API
- 투표시 어떤 오디션 지원에 투표했는지 기록
- 캔 사용내역에 "[오디션 투표] 닉네임" 추가
2025-01-03 01:03:38 +09:00
82b109e3bd 오디션 투표 API
- 투표시 어떤 오디션 지원에 투표했는지 기록
- 캔 사용내역에 "[오디션 투표] 닉네임" 추가
2025-01-03 00:45:49 +09:00
cd0c066978 앱 - 오디션 투표 API 2025-01-03 00:09:53 +09:00
7a395a9906 앱 - 오디션 지원 API
- 기존에 지원한 내역이 있으면 false 처리 후 지원
2025-01-02 22:46:57 +09:00
96f571e0c4 앱 - 오디션 지원 API 2025-01-02 19:32:31 +09:00
8385800e48 앱 - 오디션 상세 캐릭터 리스트
- status 내림차순 정렬
2024-12-31 09:12:26 +09:00
9315447618 앱 - 오디션 리스트 API
- offset, limit 추가
2024-12-31 07:26:25 +09:00
00c306475c 앱 - 오디션 리스트 API
- 상태 정렬 추가
2024-12-31 03:37:00 +09:00
affbb3eba3 앱 - 배역 상세 API, 지원 리스트 API 2024-12-31 03:06:34 +09:00
ddd552deb4 앱 - 오디션 상세 API 2024-12-30 23:42:25 +09:00
b56b2e15af 오디션 캐릭터(배역) 관리자 API
- 관리자만 실행할 수 있도록 수정
2024-12-30 20:49:03 +09:00
f77b5f67d0 앱 API - 오디션 리스트 2024-12-30 20:29:28 +09:00
bb41a81eb1 오디션 배역 수정
- 배역 이름과 배역 정보 유효성 검사 추가
2024-12-28 03:50:43 +09:00
44dfa45ca8 오디션 배역 조회
- 오디션 배역 정보 추가
2024-12-28 03:40:04 +09:00
8cfe9ade9a 오디션 배역 등록/수정
- 오디션 배역 정보 추가
2024-12-28 03:29:11 +09:00
6e6b27bb65 오디션 배역 수정
- 모집 상태를 수정할 수 있는 변수 추가
2024-12-28 02:04:04 +09:00
df3a00f8c0 오디션 배역 수정
- 모집 상태를 수정할 수 있는 변수 추가
2024-12-28 01:51:46 +09:00
2e66b5fa45 오디션 상세 - 오디션 배역 리스트
- 활성화된 배역만 조회되도록 수정
2024-12-28 01:40:16 +09:00
1dba0a3d95 오디션 상세
- 오디션 배역 리스트 데이터에 대본 링크 추가
2024-12-28 01:11:26 +09:00
c9e90974bd 오디션 상세
- 오디션 데이터와 오디션 배역 리스트 데이터 호출을 분리
2024-12-27 23:19:21 +09:00
4f0a882b9e 오디션 상세
- groupBy에 비집계열 모두 추가
2024-12-27 22:05:32 +09:00
a35b602f1a 오디션 상세
- roleList의 조회값이 없는 경우 emptyList로 선언되도록 처리
2024-12-27 21:53:37 +09:00
a3e717f2f7 오디션 배역(캐릭터) 엔티티
- status(모집상태) 추가
2024-12-27 02:07:35 +09:00
8b10e0e770 오디션 배역(캐릭터) 엔티티
- status(모집상태) 추가
2024-12-27 02:00:15 +09:00
22c302efa0 오디션 엔티티
- status(모집상태) 추가
- 리스트 api : 응답값에 status 추가, 활성화 데이터만 조회
- 수정 api : status 수정 기능 추가
2024-12-27 01:56:45 +09:00
86450533cf 오디션 리스트 API
- endDate와 원작링크가 null인 경우 빈 칸으로 처리하는 로직 추가
2024-12-26 22:43:15 +09:00
d940b3092f 오디션 엔티티
- information column type을 TEXT로 수정
2024-12-25 02:46:55 +09:00
99fdf473ae 관리자 오디션 캐릭터
- 등록, 수정, 상세, 지원리스트 API
2024-12-25 02:39:21 +09:00
bb3263dd68 관리자 오디션 상세 API 2024-12-24 16:28:11 +09:00
e29e71b8bd 오디션
- 엔티티 작성
- 관리자 - 오디션 생성, 수정, 리스트 API
2024-12-24 03:26:20 +09:00
9abbb05ad8 Merge pull request 'test' (#248) from test into main
Reviewed-on: #248
2024-12-18 07:10:01 +00:00
0c4dc7e5df 재생목록 리스트, 상세
- 대여가 만료된 콘텐츠를 제외하고 조회되도록 수정
2024-12-18 06:39:10 +09:00
36052f034a 재생목록 등록/수정
- 등록/수정시 만료 날짜 시간 추가
2024-12-18 06:18:36 +09:00
00e4fefc8f 구매목록
- orderType 파라미터 제거
2024-12-18 03:22:13 +09:00
1ecaf69b0b Merge pull request 'test' (#247) from test into main
Reviewed-on: #247
2024-12-17 13:43:45 +00:00
a1f9b676b5 재생목록 상세 날짜 포맷 변경
- yyyy-MM-dd -> yyyy.MM.dd
2024-12-10 03:37:39 +09:00
330e4945e1 재생목록 리스트 정렬순서 변경
- 미정렬 -> 생성날짜 내림차순 정렬
2024-12-09 17:41:49 +09:00
0583a8a56f 재생목록 콘텐츠
- 크리에이터 프로필 이미지 추가
2024-12-08 15:44:42 +09:00
bf62482137 콘텐츠 URL 생성 API 2024-12-06 23:07:59 +09:00
ba17095536 구매목록 조회
- 구매유형별로 조회할 수 있도록 orderType 추가
2024-12-04 18:44:17 +09:00
4ff5e9e163 재생 목록 콘텐츠 조회
- 크리에이터 닉네임 추가
2024-12-04 13:32:35 +09:00
8a03249759 콘텐츠 커버이미지 조회 API
- 빠진 조건문 추가
2024-12-04 11:18:42 +09:00
72cb90357e 플레이 리스트 생성 API - 유효성 검사
- 콘텐츠가 1개 이상 등록되어 있어야 한다.
2024-12-04 10:59:43 +09:00
72563e9bfa 플레이 리스트 콘텐츠 가져오기 API
- 빠진 조건문 추가
2024-12-04 10:55:17 +09:00
e334d1e5d9 Merge pull request '콘텐츠 댓글 푸시 대상자' (#246) from test into main
Reviewed-on: #246
2024-12-03 15:54:34 +00:00
f503492bf9 콘텐츠 댓글 푸시 대상자
- 댓글 : 콘텐츠 크리에이터에게만
- 답글 : 원 댓글 쓴 사람
2024-12-04 00:40:26 +09:00
b735e861d0 Merge pull request '콘텐츠 댓글 푸시 대상자 조회' (#245) from test into main
Reviewed-on: #245
2024-12-02 15:06:49 +00:00
c7513e9045 콘텐츠 댓글 푸시 대상자 조회
- audioContentComment 조회 대상에 추가
2024-12-03 00:01:17 +09:00
4eb433d372 Merge pull request 'test' (#244) from test into main
Reviewed-on: #244
2024-12-02 12:05:30 +00:00
368c647151 Redisson Config
- 최소 유휴 연결 0, DNS 모니터링 간격 30초로 변경
2024-12-02 20:26:46 +09:00
1ca676ce0b Redisson Config
- 최소 유휴 연결 0, DNS 모니터링 간격 30초로 변경
2024-12-02 20:25:07 +09:00
b33945d21c Redisson Config
- ssl 설정
2024-12-02 19:48:51 +09:00
1649c08356 예약 업로드 오픈 스케줄러
- 서버 한 대에서만 실행되도록 Redisson을 이용하여 분산락 적용
2024-12-02 19:18:28 +09:00
2416ae61f3 Merge pull request 'test' (#243) from test into main
Reviewed-on: #243
2024-12-02 04:29:50 +00:00
c54105e65b AudioContentReleaseScheduledTask
- Qualifier로 audioContentReleaseScheduler 선택하도록 추가
2024-12-02 11:12:31 +09:00
9039a7a2d0 taskScheduler에 Primary 설정 2024-12-02 11:02:02 +09:00
a1ef9a4970 콘텐츠 예약 오픈 설정
- 스케줄러 설정 수정
- 외부에서 실행되는 endpoint 제거
2024-12-02 10:46:48 +09:00
c1748001d5 콘텐츠 예약 오픈 설정
- 스케줄러 설정 추가
2024-12-02 08:58:54 +09:00
e470e70612 콘텐츠 예약 오픈 설정
- 분산락 제거, 서버가 여러대라면 여러번 호출될 수 있음
2024-12-02 08:40:38 +09:00
e0d48712ac 콘텐츠 예약 오픈 설정
- 콘텐츠 id뿐 아니라 콘텐츠 전체를 불러와서 중복호출 하지 않도록 수정
2024-12-02 08:25:55 +09:00
05592f94b9 스프링 스케줄러를 이용하여 콘텐츠 예약 오픈 설정 2024-12-02 08:22:16 +09:00
4097e5a133 플레이 리스트에 저장하는 콘텐츠ID에 정렬순서 값 추가
- 이유: 플레이 리스트에 저장하는 콘텐츠ID에는 순서가 있지만 해당 값으로 조회한 콘텐츠 상세내용의 정렬이 콘텐츠ID가 저장된 순서대로 나온다는 보장이 없음, 조회한 콘텐츠 상세의 정렬을 위해 추가
2024-11-27 19:31:59 +09:00
3093d2159d 플레이 리스트 상세 API 추가 2024-11-27 19:09:18 +09:00
d6e5a45be9 플레이 리스트 수정 API 추가 2024-11-27 18:07:17 +09:00
10a65294ce 플레이 리스트 생성 API 수정
- 기존: 플레이 리스트에 콘텐츠 ID와 정렬순서도 같이 저장됨
- 변경: 콘텐츠 ID만 저장됨
- 이유: List 사용하기에 정렬순서를 별도로 저장할 필요가 없다고 판단
2024-11-27 17:38:55 +09:00
22c5c5be25 플레이 리스트 생성 API
- 기존: 플레이 리스트에 콘텐츠 ID만 저장됨
- 변경: 콘텐츠 ID와 정렬순서도 같이 저장됨
2024-11-27 17:15:33 +09:00
7f4de67d67 플레이 리스트 API
- 조회, 생성, 삭제 추가
2024-11-27 01:59:01 +09:00
01fb336985 Merge pull request '콘텐츠 등록' (#242) from test into main
Reviewed-on: #242
2024-11-26 12:46:24 +00:00
559df6c7b8 콘텐츠 등록
- 테마가 모닝콜, 알람, 슬립콜인 경우 5캔 이상의 유료콘텐츠로만 등록이 가능하도록 수정
2024-11-26 21:31:58 +09:00
b6af88a732 Merge pull request 'test' (#241) from test into main
Reviewed-on: #241
2024-11-26 05:33:45 +00:00
b55e08a719 콘텐츠 등록
- 테마가 모닝콜, 알람, 슬립콜인 경우 소장만 가능하도록 수정
2024-11-26 14:27:17 +09:00
cc72e44fca 콘텐츠 상세 - isFullDetailVisible가 false
- 콘텐츠 설명 최대 10 -> 30글자로 수정
2024-11-26 13:39:17 +09:00
58a2a17d6d Merge pull request 'test' (#240) from test into main
Reviewed-on: #240
2024-11-23 17:59:23 +00:00
84804d32ad 콘텐츠 상세
- 50캔 이상의 유료콘텐츠이고 구매하지 않은 콘텐츠 이고 isFullDetailVisible가 false이면 콘텐츠 설명이 최대 10글자까지만 보이도록 수정
2024-11-24 02:02:50 +09:00
fcae1b6770 콘텐츠 등록
- 50캔 이상의 유료콘텐츠는 콘텐츠 설명을 숨길 수 있도록 isFullDetailVisible 추가
2024-11-24 01:48:17 +09:00
b7d7afb8a5 redis를 이전하기 위해 설정했던 모든 커밋 Revert 2024-11-24 01:23:41 +09:00
e38ed331b6 redis repository 자동 스캔 비활성화 2024-11-24 00:06:02 +09:00
2ba798b606 올바르게 Bean이 설정되었는지 출력하는 코드 추가 2024-11-23 23:02:49 +09:00
ee0c99bec9 redis core, redis connection 로깅레벨 DEBUG 2024-11-23 21:34:23 +09:00
e7232db2f3 Redis 패키지 별도로 분리하여 다중 구성이 용이하도록 수정 2024-11-23 21:15:14 +09:00
4dc0a13203 라이브 방 룰렛 처리 및 저장
- Redis -> Valkey로 이전되도록 수정
2024-11-23 01:58:24 +09:00
2f2437e14d 라이브 방 메뉴 처리 및 저장
- Redis -> Valkey로 이전되도록 수정
2024-11-23 01:29:34 +09:00
695ccf975b 라이브 방 강퇴 정보 처리 및 저장
- Redis -> Valkey로 이전되도록 수정
2024-11-23 00:25:45 +09:00
2d0492cafa 라이브 방 정보 처리 및 저장
- Redis -> Valkey로 이전되도록 수정
2024-11-23 00:05:27 +09:00
68472b234e 회원토큰 처리
- Redis -> Valkey로 이전되도록 수정
2024-11-22 21:22:02 +09:00
157e3a39b6 여러대의 Redis와 Valkey에 연결할 수 있도록 환경설정 2024-11-22 17:54:23 +09:00
79f5a0f520 Merge pull request '내 콘텐츠 수정, 삭제 시 콘텐츠 조회 함수' (#239) from test into main
Reviewed-on: #239
2024-11-21 06:34:30 +00:00
831bd731ca 내 콘텐츠 수정, 삭제 시 콘텐츠 조회 함수
- 내 콘텐츠는 비활성화 된 콘텐츠도 조회할 수 있도록 수정
2024-11-21 15:27:53 +09:00
7f6c0f7f04 Merge pull request 'Redis connection 수정' (#238) from test into main
Reviewed-on: #238
2024-11-20 09:58:56 +00:00
354ae68dd1 Redis connection 수정 2024-11-20 18:43:19 +09:00
234a46d2ac Redis connection 수정 2024-11-20 18:30:24 +09:00
f658df4dca Merge pull request 'Redis connection' (#237) from test into main
Reviewed-on: #237
2024-11-20 07:52:58 +00:00
5c7bf8086c Redis connection
- clientOption 변수로 분리
2024-11-20 16:48:08 +09:00
9d43b8e23a Merge pull request 'Redis connection' (#236) from test into main
Reviewed-on: #236
2024-11-20 06:47:52 +00:00
dd614c07e2 Redis connection
- 클러스터 모드 설정
2024-11-18 14:11:09 +09:00
4270aef79b Merge pull request 'test' (#235) from test into main
Reviewed-on: #235
2024-11-11 15:34:35 +00:00
f134bc4599 라이브 방
- 현재 라이브 하트 랭킹 API
2024-11-12 00:05:40 +09:00
19dc676c36 라이브 방
- 현재 라이브 하트 랭킹 API
2024-11-11 21:07:02 +09:00
adf181f790 라이브 방
- 현재 라이브 하트 랭킹 API 404나와서 임시 제거
2024-11-11 20:49:48 +09:00
82b07897f8 라이브 방
- 현재 라이브 하트 랭킹 API 추가
2024-11-11 20:29:48 +09:00
f9dd3bc7e2 라이브 방
- 현재 라이브 하트 랭킹 API 추가
2024-11-11 16:09:46 +09:00
7cec01897f 라이브 방 - 후원 메시지 리스트
- 비밀 후원과 일반 후원 메시지 분리
2024-11-11 14:02:48 +09:00
297f6555f3 라이브 방 - 후원 메시지 리스트
- 방장은 모든 후원 메시지를 볼 수 있도록 수정
2024-11-11 12:37:14 +09:00
6111409d66 라이브 방 - 후원 메시지 리스트
- 비밀 후원 메시지 추가
2024-11-11 12:29:29 +09:00
1c0dc82d44 Merge pull request '콘텐츠 구매 - 소장만 추가' (#234) from test into main
Reviewed-on: #234
2024-11-08 12:40:29 +00:00
5820117c1a 콘텐츠 상세
- 기존 데이터와의 호환성을 위해 isOnlyRental == true <=> PurchaseOption.RENT_ONLY으로 표현되도록 수정
2024-11-08 17:15:19 +09:00
cc695c115b 콘텐츠 구매
- 대여만 가능한 콘텐츠를 소장 하려는 경우 안내메시지 추가
2024-11-08 14:54:53 +09:00
86dac7e2b4 콘텐츠 구매
- 소장만 가능한 콘텐츠를 대여 하려는 경우 안내메시지 추가
2024-11-08 14:50:56 +09:00
52ddefa631 콘텐츠 업로드
- 구매옵션이 RENT_ONLY 인 경우 기존에 있던 isOnlyRental 필드 true 로 저장
2024-11-08 14:46:48 +09:00
c46d6621ec 콘텐츠 상세
- 구매옵션(모두, 소장만, 대여만) 추가
2024-11-08 00:48:30 +09:00
d94ef1eb25 콘텐츠 등록
- 구매옵션(모두, 소장만, 대여만) 추가
2024-11-08 00:47:12 +09:00
eee59855cc BundleAudioContent 제거 2024-11-07 18:48:29 +09:00
c1e325aadf Merge pull request 'test' (#233) from test into main
Reviewed-on: #233
2024-11-05 07:26:19 +00:00
efdf1d3eed 라이브방 후원리스트
- 정렬 수정
2024-11-05 16:00:23 +09:00
65b28f92d5 라이브방 후원리스트
- 내가 후원한 비밀후원은 조회되도록 수정
2024-11-05 15:19:14 +09:00
a5845c90c2 라이브방 후원리스트
- 내가 후원한 비밀후원은 조회되도록 수정
2024-11-05 15:14:39 +09:00
cec87da69d Merge pull request '콘텐츠 대여가격' (#232) from test into main
Reviewed-on: #232
2024-10-31 05:23:01 +00:00
8ea51774e6 콘텐츠 대여가격
- 소장가격의 60% -> 70%로 변경
2024-10-31 14:13:39 +09:00
f68f24cb2c Merge pull request 'test' (#231) from test into main
Reviewed-on: #231
2024-10-31 03:09:13 +00:00
baeea79e66 이벤트 조회
- 시작날짜, 종료날짜 format 'yyyy-MM-dd'로 변경
2024-10-30 23:55:07 +09:00
85f14edc0a 이벤트 조회
- 시작날짜, 종료날짜 추가
2024-10-30 23:28:36 +09:00
116aea3431 이벤트 조회
- 시작날짜, 종료날짜 추가
2024-10-30 23:23:27 +09:00
b8299bc139 이벤트
- 시작날짜, 종료날짜 추가
2024-10-30 23:19:13 +09:00
472 changed files with 24281 additions and 966 deletions

View File

@@ -7,5 +7,5 @@ indent_size = 4
indent_style = space
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 120
max_line_length = 130
tab_width = 4

3
.gitignore vendored
View File

@@ -323,4 +323,7 @@ gradle-app.setting
### Gradle Patch ###
**/build/
.kiro/
.junie
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,intellij,java,kotlin,macos,windows,eclipse,gradle

View File

@@ -26,6 +26,8 @@ repositories {
}
dependencies {
implementation("org.redisson:redisson-spring-data-27:3.19.2")
implementation("org.springframework.boot:spring-boot-starter-aop")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.boot:spring-boot-starter-security")
@@ -63,7 +65,13 @@ dependencies {
// android publisher
implementation("com.google.apis:google-api-services-androidpublisher:v3-rev20240319-2.0.0")
implementation("com.google.api-client:google-api-client:1.32.1")
implementation("org.apache.poi:poi-ooxml:5.2.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
// file mimetype check
implementation("org.apache.tika:tika-core:3.2.0")
developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("com.h2database:h2")

View File

@@ -0,0 +1,43 @@
package kr.co.vividnext.sodalive.admin.audition
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.data.domain.Pageable
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@PreAuthorize("hasRole('ADMIN')")
@RequestMapping("/admin/audition")
class AdminAuditionController(private val service: AdminAuditionService) {
@PostMapping
fun createAudition(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String
) = ApiResponse.ok(service.createAudition(image, requestString), "등록되었습니다.")
@PutMapping
fun updateAudition(
@RequestPart("image", required = false) image: MultipartFile? = null,
@RequestPart("request") requestString: String
) = ApiResponse.ok(service.updateAudition(image, requestString), "수정되었습니다.")
@GetMapping
fun getAuditionList(pageable: Pageable) = ApiResponse.ok(
service.getAuditionList(
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
@GetMapping("/{id}")
fun getAuditionDetail(@PathVariable id: Long) = ApiResponse.ok(
service.getAuditionDetail(auditionId = id)
)
}

View File

@@ -0,0 +1,69 @@
package kr.co.vividnext.sodalive.admin.audition
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.audition.Audition
import kr.co.vividnext.sodalive.audition.QAudition.audition
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface AdminAuditionRepository : JpaRepository<Audition, Long>, AdminAuditionQueryRepository
interface AdminAuditionQueryRepository {
fun getAuditionList(offset: Long, limit: Long): List<GetAuditionListItem>
fun getAuditionListCount(): Int
fun getAuditionDetail(auditionId: Long): GetAuditionDetailRawData
}
class AdminAuditionQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory,
@Value("\${cloud.aws.cloud-front.host}")
private val coverImageHost: String
) : AdminAuditionQueryRepository {
override fun getAuditionList(offset: Long, limit: Long): List<GetAuditionListItem> {
return queryFactory
.select(
QGetAuditionListItem(
audition.id,
audition.title,
audition.imagePath.prepend("/").prepend(coverImageHost),
audition.isAdult,
audition.information,
audition.status,
audition.originalWorkUrl.coalesce("")
)
)
.from(audition)
.where(audition.isActive.isTrue)
.offset(offset)
.limit(limit)
.orderBy(audition.isActive.desc(), audition.id.desc())
.fetch()
}
override fun getAuditionListCount(): Int {
return queryFactory
.select(audition.id)
.from(audition)
.fetch()
.size
}
override fun getAuditionDetail(auditionId: Long): GetAuditionDetailRawData {
return queryFactory
.select(
QGetAuditionDetailRawData(
audition.id,
audition.title,
audition.imagePath.prepend("/").prepend(coverImageHost),
audition.information,
audition.originalWorkUrl.coalesce("")
)
)
.from(audition)
.where(audition.id.eq(auditionId))
.fetchFirst()
}
}

View File

@@ -0,0 +1,122 @@
package kr.co.vividnext.sodalive.admin.audition
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.audition.role.AdminAuditionRoleRepository
import kr.co.vividnext.sodalive.audition.AuditionStatus
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
@Service
class AdminAuditionService(
private val s3Uploader: S3Uploader,
private val objectMapper: ObjectMapper,
private val repository: AdminAuditionRepository,
private val roleRepository: AdminAuditionRoleRepository,
private val applicationEventPublisher: ApplicationEventPublisher,
@Value("\${cloud.aws.s3.bucket}")
private val bucket: String
) {
@Transactional
fun createAudition(image: MultipartFile, requestString: String) {
val request = objectMapper.readValue(requestString, CreateAuditionRequest::class.java)
val audition = repository.save(request.toAudition())
val fileName = generateFileName("audition")
val imagePath = s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = "audition/production/${audition.id}/$fileName"
)
audition.imagePath = imagePath
}
@Transactional
fun updateAudition(image: MultipartFile?, requestString: String) {
val request = objectMapper.readValue(requestString, UpdateAuditionRequest::class.java)
val audition = repository.findByIdOrNull(id = request.id)
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
if (request.title != null) {
audition.title = request.title
}
if (request.information != null) {
audition.information = request.information
}
if (request.isAdult != null) {
audition.isAdult = request.isAdult
}
if (request.status != null) {
if (
(audition.status == AuditionStatus.COMPLETED || audition.status == AuditionStatus.IN_PROGRESS) &&
request.status == AuditionStatus.NOT_STARTED
) {
throw SodaException("모집전 상태로 변경할 수 없습니다.")
}
audition.status = request.status
}
if (request.originalWorkUrl != null) {
audition.originalWorkUrl = request.originalWorkUrl
}
if (image != null) {
val fileName = generateFileName("audition")
val imagePath = s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = "audition/production/${audition.id}/$fileName"
)
audition.imagePath = imagePath
}
if (request.isActive != null) {
audition.isActive = request.isActive
}
if (request.status != null && request.status == AuditionStatus.IN_PROGRESS && audition.isActive) {
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.IN_PROGRESS_AUDITION,
title = "새로운 오디션 등록!",
message = "'${audition.title}'이 등록되었습니다. 지금 바로 오리지널 오디오 드라마 오디션에 지원해보세요!",
isAuth = audition.isAdult,
auditionId = audition.id ?: -1
)
)
}
}
fun getAuditionList(offset: Long, limit: Long): GetAuditionListResponse {
val totalCount = repository.getAuditionListCount()
val items = repository.getAuditionList(offset = offset, limit = limit)
return GetAuditionListResponse(totalCount, items)
}
fun getAuditionDetail(auditionId: Long): GetAuditionDetailResponse {
val auditionDetail = repository.getAuditionDetail(auditionId = auditionId)
val roleList = roleRepository.getAuditionRoleListByAuditionId(auditionId = auditionId)
return GetAuditionDetailResponse(
id = auditionDetail.id,
title = auditionDetail.title,
imageUrl = auditionDetail.imageUrl,
information = auditionDetail.information,
originalWorkUrl = auditionDetail.originalWorkUrl,
roleList = roleList
)
}
}

View File

@@ -0,0 +1,30 @@
package kr.co.vividnext.sodalive.admin.audition
import kr.co.vividnext.sodalive.audition.Audition
import kr.co.vividnext.sodalive.common.SodaException
data class CreateAuditionRequest(
val title: String,
val information: String,
val isAdult: Boolean = false,
val originalWorkUrl: String? = null
) {
init {
if (title.isBlank()) {
throw SodaException("오디션 제목을 입력하세요")
}
if (information.isBlank() || information.length < 10) {
throw SodaException("오디션 정보는 최소 10글자 입니다")
}
}
fun toAudition(): Audition {
return Audition(
title = title,
information = information,
isAdult = isAdult,
originalWorkUrl = originalWorkUrl
)
}
}

View File

@@ -0,0 +1,30 @@
package kr.co.vividnext.sodalive.admin.audition
import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.audition.AuditionStatus
data class GetAuditionDetailRawData @QueryProjection constructor(
val id: Long,
val title: String,
val imageUrl: String,
val information: String,
val originalWorkUrl: String
)
data class GetAuditionDetailResponse(
val id: Long,
val title: String,
val imageUrl: String,
val information: String,
val originalWorkUrl: String,
val roleList: List<GetAuditionRoleListData>
)
data class GetAuditionRoleListData @QueryProjection constructor(
val id: Long,
val name: String,
val imageUrl: String,
val information: String,
val auditionScriptUrl: String,
val status: AuditionStatus
)

View File

@@ -0,0 +1,19 @@
package kr.co.vividnext.sodalive.admin.audition
import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.audition.AuditionStatus
data class GetAuditionListResponse(
val totalCount: Int,
val items: List<GetAuditionListItem>
)
data class GetAuditionListItem @QueryProjection constructor(
val id: Long,
val title: String,
val imageUrl: String,
val isAdult: Boolean,
val information: String,
val status: AuditionStatus,
val originalWorkUrl: String
)

View File

@@ -0,0 +1,13 @@
package kr.co.vividnext.sodalive.admin.audition
import kr.co.vividnext.sodalive.audition.AuditionStatus
data class UpdateAuditionRequest(
val id: Long,
val title: String? = null,
val information: String? = null,
val isAdult: Boolean? = null,
val status: AuditionStatus? = null,
val originalWorkUrl: String? = null,
val isActive: Boolean? = null
)

View File

@@ -0,0 +1,19 @@
package kr.co.vividnext.sodalive.admin.audition.applicant
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@PreAuthorize("hasRole('ADMIN')")
@RequestMapping("/admin/audition/applicant")
class AdminAuditionApplicantController(private val service: AdminAuditionApplicantService) {
@DeleteMapping("/{id}")
fun deleteAuditionApplicant(@PathVariable id: Long) = ApiResponse.ok(
service.deleteAuditionApplicant(id),
"오디션 지원이 취소 되었습니다."
)
}

View File

@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.admin.audition.applicant
import kr.co.vividnext.sodalive.audition.AuditionApplicant
import org.springframework.data.jpa.repository.JpaRepository
interface AdminAuditionApplicantRepository : JpaRepository<AuditionApplicant, Long>

View File

@@ -0,0 +1,17 @@
package kr.co.vividnext.sodalive.admin.audition.applicant
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class AdminAuditionApplicantService(private val repository: AdminAuditionApplicantRepository) {
@Transactional
fun deleteAuditionApplicant(id: Long) {
val applicant = repository.findByIdOrNull(id)
?: throw SodaException("잘못된 요청입니다.")
applicant.isActive = false
}
}

View File

@@ -0,0 +1,47 @@
package kr.co.vividnext.sodalive.admin.audition.role
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.data.domain.Pageable
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@PreAuthorize("hasRole('ADMIN')")
@RequestMapping("/admin/audition/role")
class AdminAuditionRoleController(private val service: AdminAuditionRoleService) {
@PostMapping
fun createAuditionRole(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String
) = ApiResponse.ok(service.createAuditionRole(image, requestString), "등록되었습니다.")
@PutMapping
fun updateAuditionRole(
@RequestPart("image", required = false) image: MultipartFile? = null,
@RequestPart("request") requestString: String
) = ApiResponse.ok(service.updateAuditionRole(image, requestString), "수정되었습니다.")
@GetMapping("/{id}")
fun getAuditionRoleDetail(@PathVariable id: Long) = ApiResponse.ok(
service.getAuditionRoleDetail(auditionRoleId = id)
)
@GetMapping("/{id}/applicant")
fun getAuditionApplicantList(
@PathVariable id: Long,
pageable: Pageable
) = ApiResponse.ok(
service.getAuditionApplicantList(
auditionRoleId = id,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
}

View File

@@ -0,0 +1,106 @@
package kr.co.vividnext.sodalive.admin.audition.role
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.admin.audition.GetAuditionRoleListData
import kr.co.vividnext.sodalive.admin.audition.QGetAuditionRoleListData
import kr.co.vividnext.sodalive.audition.AuditionRole
import kr.co.vividnext.sodalive.audition.QAudition.audition
import kr.co.vividnext.sodalive.audition.QAuditionApplicant.auditionApplicant
import kr.co.vividnext.sodalive.audition.QAuditionRole.auditionRole
import kr.co.vividnext.sodalive.audition.QAuditionVote.auditionVote
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.jpa.repository.JpaRepository
interface AdminAuditionRoleRepository : JpaRepository<AuditionRole, Long>, AdminAuditionRoleQueryRepository
interface AdminAuditionRoleQueryRepository {
fun getAuditionRoleListByAuditionId(auditionId: Long): List<GetAuditionRoleListData>
fun getAuditionRoleDetail(auditionRoleId: Long): GetAuditionRoleDetailResponse
fun getAuditionApplicantList(auditionRoleId: Long, offset: Long, limit: Long): List<GetAuditionRoleApplicantItem>
fun getAuditionApplicantTotalCount(auditionRoleId: Long): Int
}
class AdminAuditionRoleQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudfrontHost: String
) : AdminAuditionRoleQueryRepository {
override fun getAuditionRoleListByAuditionId(auditionId: Long): List<GetAuditionRoleListData> {
return queryFactory
.select(
QGetAuditionRoleListData(
auditionRole.id,
auditionRole.name,
auditionRole.imagePath.prepend("/").prepend(cloudfrontHost),
auditionRole.information,
auditionRole.auditionScriptUrl,
auditionRole.status
)
)
.from(auditionRole)
.innerJoin(auditionRole.audition, audition)
.where(
auditionRole.audition.id.eq(auditionId),
auditionRole.isActive.isTrue
)
.fetch()
}
override fun getAuditionRoleDetail(auditionRoleId: Long): GetAuditionRoleDetailResponse {
return queryFactory
.select(
QGetAuditionRoleDetailResponse(
auditionRole.name,
auditionRole.imagePath.prepend("/").prepend(cloudfrontHost),
auditionRole.information,
auditionRole.auditionScriptUrl
)
)
.from(auditionRole)
.where(auditionRole.id.eq(auditionRoleId))
.fetchFirst()
}
override fun getAuditionApplicantList(
auditionRoleId: Long,
offset: Long,
limit: Long
): List<GetAuditionRoleApplicantItem> {
return queryFactory
.select(
QGetAuditionRoleApplicantItem(
auditionApplicant.id,
member.nickname,
member.profileImage.prepend("/").prepend(cloudfrontHost),
auditionApplicant.phoneNumber,
auditionApplicant.voicePath.prepend("/").prepend(cloudfrontHost),
auditionVote.id.count()
)
)
.from(auditionApplicant)
.innerJoin(auditionApplicant.member, member)
.innerJoin(auditionApplicant.role, auditionRole)
.leftJoin(auditionVote).on(auditionApplicant.id.eq(auditionVote.applicant.id))
.where(
auditionRole.id.eq(auditionRoleId),
auditionApplicant.isActive.isTrue
)
.groupBy(auditionApplicant.id)
.orderBy(auditionVote.id.count().desc())
.offset(offset)
.limit(limit)
.fetch()
}
override fun getAuditionApplicantTotalCount(auditionRoleId: Long): Int {
return queryFactory
.select(auditionApplicant.id)
.from(auditionApplicant)
.innerJoin(auditionApplicant.role, auditionRole)
.where(auditionRole.id.eq(auditionRoleId))
.fetch()
.size
}
}

View File

@@ -0,0 +1,95 @@
package kr.co.vividnext.sodalive.admin.audition.role
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.audition.AdminAuditionRepository
import kr.co.vividnext.sodalive.audition.AuditionRole
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
@Service
class AdminAuditionRoleService(
private val s3Uploader: S3Uploader,
private val objectMapper: ObjectMapper,
private val repository: AdminAuditionRoleRepository,
private val auditionRepository: AdminAuditionRepository,
@Value("\${cloud.aws.s3.bucket}")
private val bucket: String
) {
@Transactional
fun createAuditionRole(image: MultipartFile, requestString: String) {
val request = objectMapper.readValue(requestString, CreateAuditionRoleRequest::class.java)
val auditionRole = AuditionRole(
name = request.name,
information = request.information,
auditionScriptUrl = request.auditionScriptUrl
)
val audition = auditionRepository.findByIdOrNull(id = request.auditionId)
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
auditionRole.audition = audition
repository.save(auditionRole)
val fileName = generateFileName("audition_role")
val imagePath = s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = "audition/role/${auditionRole.id}/$fileName"
)
auditionRole.imagePath = imagePath
}
@Transactional
fun updateAuditionRole(image: MultipartFile?, requestString: String) {
val request = objectMapper.readValue(requestString, UpdateAuditionRoleRequest::class.java)
val auditionRole = repository.findByIdOrNull(id = request.id)
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
if (!request.name.isNullOrBlank()) {
if (request.name.length < 2) throw SodaException("배역 이름은 최소 2글자 입니다")
auditionRole.name = request.name
}
if (!request.information.isNullOrBlank()) {
if (request.information.length < 10) throw SodaException("오디션 배역 정보는 최소 10글자 입니다")
auditionRole.information = request.information
}
if (!request.auditionScriptUrl.isNullOrBlank()) {
auditionRole.auditionScriptUrl = request.auditionScriptUrl
}
if (request.status != null) {
auditionRole.status = request.status
}
if (request.isActive != null) {
auditionRole.isActive = request.isActive
}
if (image != null) {
val fileName = generateFileName("audition_role")
val imagePath = s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = "audition/role/${auditionRole.id}/$fileName"
)
auditionRole.imagePath = imagePath
}
}
fun getAuditionRoleDetail(auditionRoleId: Long): GetAuditionRoleDetailResponse {
return repository.getAuditionRoleDetail(auditionRoleId = auditionRoleId)
}
fun getAuditionApplicantList(auditionRoleId: Long, offset: Long, limit: Long): GetAuditionRoleApplicantResponse {
val totalCount = repository.getAuditionApplicantTotalCount(auditionRoleId = auditionRoleId)
val items = repository.getAuditionApplicantList(auditionRoleId = auditionRoleId, offset = offset, limit = limit)
return GetAuditionRoleApplicantResponse(totalCount, items)
}
}

View File

@@ -0,0 +1,28 @@
package kr.co.vividnext.sodalive.admin.audition.role
import kr.co.vividnext.sodalive.common.SodaException
data class CreateAuditionRoleRequest(
val auditionId: Long,
val name: String,
val information: String,
val auditionScriptUrl: String
) {
init {
if (auditionId < 0) {
throw SodaException("캐릭터가 등록될 오디션을 선택하세요")
}
if (name.isBlank() || name.length < 2) {
throw SodaException("캐릭터명을 입력하세요")
}
if (auditionScriptUrl.isBlank() || auditionScriptUrl.length < 10) {
throw SodaException("오디션 대본 URL을 입력하세요")
}
if (information.isBlank() || information.length < 10) {
throw SodaException("오디션 캐릭터 정보는 최소 10글자 입니다")
}
}
}

View File

@@ -0,0 +1,17 @@
package kr.co.vividnext.sodalive.admin.audition.role
import com.querydsl.core.annotations.QueryProjection
data class GetAuditionRoleApplicantResponse(
val totalCount: Int,
val items: List<GetAuditionRoleApplicantItem>
)
data class GetAuditionRoleApplicantItem @QueryProjection constructor(
val applicantId: Long,
val nickname: String,
val profileImageUrl: String,
val phoneNumber: String,
val voiceUrl: String,
val voteCount: Long
)

View File

@@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.admin.audition.role
import com.querydsl.core.annotations.QueryProjection
data class GetAuditionRoleDetailResponse @QueryProjection constructor(
val name: String,
val imageUrl: String,
val information: String,
val auditionScriptUrl: String
)

View File

@@ -0,0 +1,19 @@
package kr.co.vividnext.sodalive.admin.audition.role
import kr.co.vividnext.sodalive.audition.AuditionStatus
import kr.co.vividnext.sodalive.common.SodaException
data class UpdateAuditionRoleRequest(
val id: Long,
val name: String? = null,
val information: String? = null,
val auditionScriptUrl: String? = null,
val status: AuditionStatus? = null,
val isActive: Boolean? = null
) {
init {
if (id < 0) {
throw SodaException("잘못된 요청입니다.")
}
}
}

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.admin.calculate
import com.querydsl.core.types.dsl.CaseBuilder
import com.querydsl.core.types.dsl.DateTimePath
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.core.types.dsl.StringTemplate
@@ -38,11 +39,14 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.room, liveRoom)
.innerJoin(liveRoom.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
useCan.isRefund.isFalse
.and(liveRoom.beginDateTime.goe(startDate))
.and(liveRoom.beginDateTime.loe(endDate))
.and(useCan.createdAt.goe(startDate))
.and(useCan.createdAt.loe(endDate))
)
.groupBy(liveRoom.id, useCan.canUsage, creatorSettlementRatio.liveSettlementRatio)
.orderBy(member.nickname.desc(), liveRoom.id.desc(), useCan.canUsage.desc(), formattedDate.desc())
@@ -51,6 +55,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
fun getCalculateContentList(startDate: LocalDateTime, endDate: LocalDateTime): List<GetCalculateContentQueryData> {
val orderFormattedDate = getFormattedDate(order.createdAt)
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
return queryFactory
.select(
QGetCalculateContentQueryData(
@@ -62,6 +70,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
order.can,
order.id.count(),
order.can.sum(),
order.point.sum(),
creatorSettlementRatio.contentSettlementRatio
)
)
@@ -69,7 +78,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
order.createdAt.goe(startDate)
.and(order.createdAt.loe(endDate))
@@ -80,6 +92,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
order.type,
orderFormattedDate,
order.can,
pointGroup,
creatorSettlementRatio.contentSettlementRatio
)
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
@@ -113,6 +126,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
}
fun getCumulativeSalesByContent(offset: Long, limit: Long): List<GetCumulativeSalesByContentQueryData> {
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
return queryFactory
.select(
QGetCumulativeSalesByContentQueryData(
@@ -123,6 +140,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
order.can,
order.id.count(),
order.can.sum(),
order.point.sum(),
creatorSettlementRatio.contentSettlementRatio
)
)
@@ -130,9 +148,19 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(order.isActive.isTrue)
.groupBy(member.id, audioContent.id, order.type, order.can)
.groupBy(
member.id,
audioContent.id,
order.type,
order.can,
pointGroup,
creatorSettlementRatio.contentSettlementRatio
)
.offset(offset)
.limit(limit)
.orderBy(member.id.desc(), audioContent.id.desc())
@@ -211,7 +239,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.communityPost, creatorCommunity)
.innerJoin(creatorCommunity.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
@@ -232,7 +263,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.room, liveRoom)
.innerJoin(liveRoom.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
useCan.isRefund.isFalse
.and(useCan.createdAt.goe(startDate))
@@ -262,7 +296,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.room, liveRoom)
.innerJoin(liveRoom.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
useCan.isRefund.isFalse
.and(useCan.createdAt.goe(startDate))
@@ -282,7 +319,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
order.createdAt.goe(startDate)
.and(order.createdAt.loe(endDate))
@@ -312,7 +352,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
order.createdAt.goe(startDate)
.and(order.createdAt.loe(endDate))
@@ -332,7 +375,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.communityPost, creatorCommunity)
.innerJoin(creatorCommunity.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
@@ -363,7 +409,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.communityPost, creatorCommunity)
.innerJoin(creatorCommunity.member, member)
.leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id))
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))

View File

@@ -22,11 +22,15 @@ data class GetCalculateContentQueryData @QueryProjection constructor(
val numberOfPeople: Long,
// 합계
val totalCan: Int,
// 포인트
val totalPoint: Int,
// 정산비율
val settlementRatio: Int?
) {
fun toGetCalculateContentResponse(): GetCalculateContentResponse {
val orderTypeStr = if (orderType == OrderType.RENTAL) {
val orderTypeStr = if (totalPoint > 0) {
"포인트"
} else if (orderType == OrderType.RENTAL) {
"대여"
} else {
"소장"

View File

@@ -21,11 +21,15 @@ data class GetCumulativeSalesByContentQueryData @QueryProjection constructor(
val numberOfPeople: Long,
// 합계
val totalCan: Int,
// 포인트
val totalPoint: Int,
// 정산비율
val settlementRatio: Int?
) {
fun toCumulativeSalesByContentItem(): CumulativeSalesByContentItem {
val orderTypeStr = if (orderType == OrderType.RENTAL) {
val orderTypeStr = if (totalPoint > 0) {
"포인트"
} else if (orderType == OrderType.RENTAL) {
"대여"
} else {
"소장"

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.admin.calculate.ratio
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.member.Member
import java.time.LocalDateTime
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
@@ -9,12 +10,29 @@ import javax.persistence.OneToOne
@Entity
data class CreatorSettlementRatio(
val subsidy: Int,
val liveSettlementRatio: Int,
val contentSettlementRatio: Int,
val communitySettlementRatio: Int
var subsidy: Int,
var liveSettlementRatio: Int,
var contentSettlementRatio: Int,
var communitySettlementRatio: Int
) : BaseEntity() {
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
var member: Member? = null
var deletedAt: LocalDateTime? = null
fun softDelete() {
this.deletedAt = LocalDateTime.now()
}
fun restore() {
this.deletedAt = null
}
fun updateValues(subsidy: Int, live: Int, content: Int, community: Int) {
this.subsidy = subsidy
this.liveSettlementRatio = live
this.contentSettlementRatio = content
this.communitySettlementRatio = community
}
}

View File

@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.data.domain.Pageable
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
@@ -27,4 +28,14 @@ class CreatorSettlementRatioController(private val service: CreatorSettlementRat
limit = pageable.pageSize.toLong()
)
)
@PostMapping("/update")
fun updateCreatorSettlementRatio(
@RequestBody request: CreateCreatorSettlementRatioRequest
) = ApiResponse.ok(service.updateCreatorSettlementRatio(request))
@PostMapping("/delete/{memberId}")
fun deleteCreatorSettlementRatio(
@PathVariable memberId: Long
) = ApiResponse.ok(service.deleteCreatorSettlementRatio(memberId))
}

View File

@@ -7,7 +7,9 @@ import org.springframework.data.jpa.repository.JpaRepository
interface CreatorSettlementRatioRepository :
JpaRepository<CreatorSettlementRatio, Long>,
CreatorSettlementRatioQueryRepository
CreatorSettlementRatioQueryRepository {
fun findByMemberId(memberId: Long): CreatorSettlementRatio?
}
interface CreatorSettlementRatioQueryRepository {
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
@@ -21,6 +23,7 @@ class CreatorSettlementRatioQueryRepositoryImpl(
return queryFactory
.select(
QGetCreatorSettlementRatioItem(
member.id,
member.nickname,
creatorSettlementRatio.subsidy,
creatorSettlementRatio.liveSettlementRatio,
@@ -30,6 +33,7 @@ class CreatorSettlementRatioQueryRepositoryImpl(
)
.from(creatorSettlementRatio)
.innerJoin(creatorSettlementRatio.member, member)
.where(creatorSettlementRatio.deletedAt.isNull)
.orderBy(creatorSettlementRatio.id.asc())
.offset(offset)
.limit(limit)
@@ -40,6 +44,7 @@ class CreatorSettlementRatioQueryRepositoryImpl(
return queryFactory
.select(creatorSettlementRatio.id)
.from(creatorSettlementRatio)
.where(creatorSettlementRatio.deletedAt.isNull)
.fetch()
.size
}

View File

@@ -14,8 +14,6 @@ class CreatorSettlementRatioService(
) {
@Transactional
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
val creatorSettlementRatio = request.toEntity()
val creator = memberRepository.findByIdOrNull(request.memberId)
?: throw SodaException("잘못된 크리에이터 입니다.")
@@ -23,10 +21,52 @@ class CreatorSettlementRatioService(
throw SodaException("잘못된 크리에이터 입니다.")
}
val existing = repository.findByMemberId(request.memberId)
if (existing != null) {
// revive if soft-deleted, then update values
existing.restore()
existing.updateValues(
request.subsidy,
request.liveSettlementRatio,
request.contentSettlementRatio,
request.communitySettlementRatio
)
repository.save(existing)
return
}
val creatorSettlementRatio = request.toEntity()
creatorSettlementRatio.member = creator
repository.save(creatorSettlementRatio)
}
@Transactional
fun updateCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
val creator = memberRepository.findByIdOrNull(request.memberId)
?: throw SodaException("잘못된 크리에이터 입니다.")
if (creator.role != MemberRole.CREATOR) {
throw SodaException("잘못된 크리에이터 입니다.")
}
val existing = repository.findByMemberId(request.memberId)
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
existing.restore()
existing.updateValues(
request.subsidy,
request.liveSettlementRatio,
request.contentSettlementRatio,
request.communitySettlementRatio
)
repository.save(existing)
}
@Transactional
fun deleteCreatorSettlementRatio(memberId: Long) {
val existing = repository.findByMemberId(memberId)
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
existing.softDelete()
repository.save(existing)
}
@Transactional(readOnly = true)
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
val totalCount = repository.getCreatorSettlementRatioTotalCount()

View File

@@ -8,6 +8,7 @@ data class GetCreatorSettlementRatioResponse(
)
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
val memberId: Long,
val nickname: String,
val subsidy: Int,
val liveSettlementRatio: Int,

View File

@@ -1,8 +1,10 @@
package kr.co.vividnext.sodalive.admin.can
import kr.co.vividnext.sodalive.can.CanResponse
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
@@ -13,6 +15,11 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/admin/can")
@PreAuthorize("hasRole('ADMIN')")
class AdminCanController(private val service: AdminCanService) {
@GetMapping
fun getCans(): ApiResponse<List<CanResponse>> {
return ApiResponse.ok(service.getCans())
}
@PostMapping
fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request))

View File

@@ -1,6 +1,38 @@
package kr.co.vividnext.sodalive.admin.can
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.Can
import kr.co.vividnext.sodalive.can.CanResponse
import kr.co.vividnext.sodalive.can.CanStatus
import kr.co.vividnext.sodalive.can.QCan.can1
import kr.co.vividnext.sodalive.can.QCanResponse
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
interface AdminCanRepository : JpaRepository<Can, Long>
interface AdminCanRepository : JpaRepository<Can, Long>, AdminCanQueryRepository
interface AdminCanQueryRepository {
fun findAllByStatus(status: CanStatus): List<CanResponse>
}
@Repository
class AdminCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminCanQueryRepository {
override fun findAllByStatus(status: CanStatus): List<CanResponse> {
return queryFactory
.select(
QCanResponse(
can1.id,
can1.title,
can1.can,
can1.rewardCan,
can1.price.intValue(),
can1.currency,
can1.price.stringValue()
)
)
.from(can1)
.where(can1.status.eq(status))
.orderBy(can1.currency.asc(), can1.price.asc())
.fetch()
}
}

View File

@@ -3,11 +3,13 @@ package kr.co.vividnext.sodalive.admin.can
import kr.co.vividnext.sodalive.can.Can
import kr.co.vividnext.sodalive.can.CanStatus
import kr.co.vividnext.sodalive.extensions.moneyFormat
import java.math.BigDecimal
data class AdminCanRequest(
val can: Int,
val rewardCan: Int,
val price: Int
val price: BigDecimal,
val currency: String
) {
fun toEntity(): Can {
var title = "${can.moneyFormat()}"
@@ -20,6 +22,7 @@ data class AdminCanRequest(
can = can,
rewardCan = rewardCan,
price = price,
currency = currency,
status = CanStatus.SALE
)
}

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.admin.can
import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository
import kr.co.vividnext.sodalive.can.CanResponse
import kr.co.vividnext.sodalive.can.CanStatus
import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
@@ -20,6 +21,10 @@ class AdminCanService(
private val chargeRepository: ChargeRepository,
private val memberRepository: AdminMemberRepository
) {
fun getCans(): List<CanResponse> {
return repository.findAllByStatus(status = CanStatus.SALE)
}
@Transactional
fun saveCan(request: AdminCanRequest) {
repository.save(request.toEntity())

View File

@@ -21,6 +21,7 @@ class AdminChargeStatusController(private val service: AdminChargeStatusService)
@GetMapping("/detail")
fun getChargeStatusDetail(
@RequestParam startDateStr: String,
@RequestParam paymentGateway: PaymentGateway
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway))
@RequestParam paymentGateway: PaymentGateway,
@RequestParam(value = "currency", required = false) currency: String? = null
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway, currency))
}

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.admin.charge
import com.querydsl.core.BooleanBuilder
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.QCan.can1
@@ -14,7 +15,7 @@ import java.time.LocalDateTime
@Repository
class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory) {
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusQueryDto> {
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusResponse> {
val formattedDate = Expressions.stringTemplate(
"DATE_FORMAT({0}, {1})",
Expressions.dateTimeTemplate(
@@ -26,15 +27,16 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
),
"%Y-%m-%d"
)
val currency = Expressions.stringTemplate("substring({0}, length({0}) - 2, 3)", payment.locale)
return queryFactory
.select(
QGetChargeStatusQueryDto(
QGetChargeStatusResponse(
formattedDate,
payment.price.sum(),
can1.price.sum(),
payment.id.count(),
payment.paymentGateway
payment.paymentGateway.stringValue(),
currency.coalesce("KRW")
)
)
.from(payment)
@@ -46,15 +48,46 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
.and(charge.status.eq(ChargeStatus.CHARGE))
.and(payment.status.eq(PaymentStatus.COMPLETE))
)
.groupBy(formattedDate, payment.paymentGateway)
.groupBy(formattedDate, payment.paymentGateway, currency.coalesce("KRW"))
.orderBy(formattedDate.desc())
.fetch()
}
fun getChargeStatusSummary(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusResponse> {
val currency = Expressions.stringTemplate(
"substring({0}, length({0}) - 2, 3)",
payment.locale
).coalesce("KRW")
return queryFactory
.select(
QGetChargeStatusResponse(
Expressions.stringTemplate("'합계'"), // date
payment.price.sum(),
payment.id.count(),
Expressions.stringTemplate("''"),
currency
)
)
.from(payment)
.innerJoin(payment.charge, charge)
.leftJoin(charge.can, can1)
.where(
charge.createdAt.goe(startDate)
.and(charge.createdAt.loe(endDate))
.and(charge.status.eq(ChargeStatus.CHARGE))
.and(payment.status.eq(PaymentStatus.COMPLETE))
)
.groupBy(currency)
.orderBy(currency.asc())
.fetch()
}
fun getChargeStatusDetail(
startDate: LocalDateTime,
endDate: LocalDateTime,
paymentGateway: PaymentGateway
paymentGateway: PaymentGateway,
currency: String? = null
): List<GetChargeStatusDetailQueryDto> {
val formattedDate = Expressions.stringTemplate(
"DATE_FORMAT({0}, {1})",
@@ -67,6 +100,20 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
),
"%Y-%m-%d %H:%i:%s"
)
val currencyExpr = Expressions.stringTemplate(
"substring({0}, length({0}) - 2, 3)",
payment.locale
).coalesce("KRW")
val whereBuilder = BooleanBuilder()
whereBuilder.and(charge.createdAt.goe(startDate))
.and(charge.createdAt.loe(endDate))
.and(charge.status.eq(ChargeStatus.CHARGE))
.and(payment.status.eq(PaymentStatus.COMPLETE))
.and(payment.paymentGateway.eq(paymentGateway))
if (currency != null) {
whereBuilder.and(currencyExpr.eq(currency))
}
return queryFactory
.select(
@@ -75,8 +122,7 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
member.nickname,
payment.method.coalesce(""),
payment.price,
can1.price,
payment.locale.coalesce(""),
currencyExpr,
formattedDate
)
)
@@ -84,13 +130,7 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
.innerJoin(charge.member, member)
.innerJoin(charge.payment, payment)
.leftJoin(charge.can, can1)
.where(
charge.createdAt.goe(startDate)
.and(charge.createdAt.loe(endDate))
.and(charge.status.eq(ChargeStatus.CHARGE))
.and(payment.status.eq(PaymentStatus.COMPLETE))
.and(payment.paymentGateway.eq(paymentGateway))
)
.where(whereBuilder)
.orderBy(formattedDate.desc())
.fetch()
}

View File

@@ -20,48 +20,17 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
var totalChargeAmount = 0
var totalChargeCount = 0L
val chargeStatusList = repository.getChargeStatus(startDate, endDate)
.asSequence()
.map {
val chargeAmount = if (it.paymentGateWay == PaymentGateway.PG) {
it.pgChargeAmount
} else {
it.appleChargeAmount.toInt()
}
val chargeCount = it.chargeCount
totalChargeAmount += chargeAmount
totalChargeCount += chargeCount
GetChargeStatusResponse(
date = it.date,
chargeAmount = chargeAmount,
chargeCount = chargeCount,
pg = it.paymentGateWay.name
)
}
.toMutableList()
chargeStatusList.add(
0,
GetChargeStatusResponse(
date = "합계",
chargeAmount = totalChargeAmount,
chargeCount = totalChargeCount,
pg = ""
)
)
val summaryRows = repository.getChargeStatusSummary(startDate, endDate)
val chargeStatusList = repository.getChargeStatus(startDate, endDate).toMutableList()
chargeStatusList.addAll(0, summaryRows)
return chargeStatusList.toList()
}
fun getChargeStatusDetail(
startDateStr: String,
paymentGateway: PaymentGateway
paymentGateway: PaymentGateway,
currency: String? = null
): List<GetChargeStatusDetailResponse> {
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
@@ -74,18 +43,16 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway)
.asSequence()
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway, currency)
.map {
GetChargeStatusDetailResponse(
memberId = it.memberId,
nickname = it.nickname,
method = it.method,
amount = it.appleChargeAmount.toInt(),
amount = it.amount,
locale = it.locale,
datetime = it.datetime
)
}
.toList()
}
}

View File

@@ -1,13 +1,13 @@
package kr.co.vividnext.sodalive.admin.charge
import com.querydsl.core.annotations.QueryProjection
import java.math.BigDecimal
data class GetChargeStatusDetailQueryDto @QueryProjection constructor(
val memberId: Long,
val nickname: String,
val method: String,
val appleChargeAmount: Double,
val pgChargeAmount: Int,
val amount: BigDecimal,
val locale: String,
val datetime: String
)

View File

@@ -1,10 +1,12 @@
package kr.co.vividnext.sodalive.admin.charge
import java.math.BigDecimal
data class GetChargeStatusDetailResponse(
val memberId: Long,
val nickname: String,
val method: String,
val amount: Int,
val amount: BigDecimal,
val locale: String,
val datetime: String
)

View File

@@ -1,12 +0,0 @@
package kr.co.vividnext.sodalive.admin.charge
import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
data class GetChargeStatusQueryDto @QueryProjection constructor(
val date: String,
val appleChargeAmount: Double,
val pgChargeAmount: Int,
val chargeCount: Long,
val paymentGateWay: PaymentGateway
)

View File

@@ -1,8 +1,12 @@
package kr.co.vividnext.sodalive.admin.charge
data class GetChargeStatusResponse(
import com.querydsl.core.annotations.QueryProjection
import java.math.BigDecimal
data class GetChargeStatusResponse @QueryProjection constructor(
val date: String,
val chargeAmount: Int,
val chargeAmount: BigDecimal,
val chargeCount: Long,
val pg: String
val pg: String,
val currency: String
)

View File

@@ -0,0 +1,229 @@
package kr.co.vividnext.sodalive.admin.chat
import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerListPageResponse
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerRegisterRequest
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerResponse
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerUpdateRequest
import kr.co.vividnext.sodalive.admin.chat.dto.UpdateBannerOrdersRequest
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
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.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/admin/chat/banner")
@PreAuthorize("hasRole('ADMIN')")
class AdminChatBannerController(
private val bannerService: ChatCharacterBannerService,
private val adminCharacterService: AdminChatCharacterService,
private val s3Uploader: S3Uploader,
@Value("\${cloud.aws.s3.bucket}")
private val s3Bucket: String,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
/**
* 활성화된 배너 목록 조회 API
*
* @param page 페이지 번호 (0부터 시작, 기본값 0)
* @param size 페이지 크기 (기본값 20)
* @return 페이징된 배너 목록
*/
@GetMapping("/list")
fun getBannerList(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = run {
val pageable = adminCharacterService.createDefaultPageRequest(page, size)
val banners = bannerService.getActiveBanners(pageable)
val response = ChatCharacterBannerListPageResponse(
totalCount = banners.totalElements,
content = banners.content.map { ChatCharacterBannerResponse.from(it, imageHost) }
)
ApiResponse.ok(response)
}
/**
* 배너 상세 조회 API
*
* @param bannerId 배너 ID
* @return 배너 상세 정보
*/
@GetMapping("/{bannerId}")
fun getBannerDetail(@PathVariable bannerId: Long) = run {
val banner = bannerService.getBannerById(bannerId)
val response = ChatCharacterBannerResponse.from(banner, imageHost)
ApiResponse.ok(response)
}
/**
* 캐릭터 검색 API (배너 등록을 위한)
*
* @param searchTerm 검색어 (이름, 설명, MBTI, 태그)
* @param page 페이지 번호 (0부터 시작, 기본값 0)
* @param size 페이지 크기 (기본값 20)
* @return 검색된 캐릭터 목록
*/
@GetMapping("/search-character")
fun searchCharacters(
@RequestParam searchTerm: String,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = run {
val pageable = adminCharacterService.createDefaultPageRequest(page, size)
val pageResult = adminCharacterService.searchCharacters(searchTerm, pageable, imageHost)
val response = ChatCharacterSearchListPageResponse(
totalCount = pageResult.totalElements,
content = pageResult.content
)
ApiResponse.ok(response)
}
/**
* 배너 등록 API
*
* @param image 배너 이미지
* @param requestString 배너 등록 요청 정보 (캐릭터 ID와 선택적으로 정렬 순서 포함)
* @return 등록된 배너 정보
*/
@PostMapping("/register")
fun registerBanner(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String
) = run {
val objectMapper = ObjectMapper()
val request = objectMapper.readValue(
requestString,
ChatCharacterBannerRegisterRequest::class.java
)
// 1. 먼저 빈 이미지 경로로 배너 등록 (정렬 순서 포함)
val banner = bannerService.registerBanner(
characterId = request.characterId,
imagePath = ""
)
// 2. 배너 ID를 사용하여 이미지 업로드
val imagePath = saveImage(banner.id!!, image)
// 3. 이미지 경로로 배너 업데이트
val updatedBanner = bannerService.updateBanner(banner.id!!, imagePath)
val response = ChatCharacterBannerResponse.from(updatedBanner, imageHost)
ApiResponse.ok(response)
}
/**
* 이미지를 S3에 업로드하고 경로를 반환
*
* @param bannerId 배너 ID (이미지 경로에 사용)
* @param image 업로드할 이미지 파일
* @return 업로드된 이미지 경로
*/
private fun saveImage(bannerId: Long, image: MultipartFile): String {
try {
val metadata = ObjectMetadata()
metadata.contentLength = image.size
val fileName = generateFileName("character-banner")
// S3에 이미지 업로드 (배너 ID를 경로에 사용)
return s3Uploader.upload(
inputStream = image.inputStream,
bucket = s3Bucket,
filePath = "characters/banners/$bannerId/$fileName",
metadata = metadata
)
} catch (e: Exception) {
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
}
}
/**
* 배너 수정 API
*
* @param image 배너 이미지
* @param requestString 배너 수정 요청 정보 (배너 ID와 선택적으로 캐릭터 ID 포함)
* @return 수정된 배너 정보
*/
@PutMapping("/update")
fun updateBanner(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String
) = run {
val objectMapper = ObjectMapper()
val request = objectMapper.readValue(
requestString,
ChatCharacterBannerUpdateRequest::class.java
)
// 배너 정보 조회
bannerService.getBannerById(request.bannerId)
// 배너 ID를 사용하여 이미지 업로드
val imagePath = saveImage(request.bannerId, image)
// 배너 수정 (이미지와 캐릭터 모두 수정 가능)
val updatedBanner = bannerService.updateBanner(
bannerId = request.bannerId,
imagePath = imagePath,
characterId = request.characterId
)
val response = ChatCharacterBannerResponse.from(updatedBanner, imageHost)
ApiResponse.ok(response)
}
/**
* 배너 삭제 API (소프트 삭제)
*
* @param bannerId 배너 ID
* @return 성공 여부
*/
@DeleteMapping("/{bannerId}")
fun deleteBanner(@PathVariable bannerId: Long) = run {
bannerService.deleteBanner(bannerId)
ApiResponse.ok("배너가 성공적으로 삭제되었습니다.")
}
/**
* 배너 정렬 순서 일괄 변경 API
* ID 목록의 순서대로 정렬 순서를 1부터 순차적으로 설정합니다.
*
* @param request 정렬 순서 일괄 변경 요청 정보 (배너 ID 목록)
* @return 성공 메시지
*/
@PutMapping("/orders")
fun updateBannerOrders(
@RequestBody request: UpdateBannerOrdersRequest
) = run {
bannerService.updateBannerOrders(request.ids)
ApiResponse.ok(null, "배너 정렬 순서가 성공적으로 변경되었습니다.")
}
}

View File

@@ -0,0 +1,32 @@
package kr.co.vividnext.sodalive.admin.chat.calculate
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.data.domain.Pageable
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
@RestController
@PreAuthorize("hasRole('ADMIN')")
@RequestMapping("/admin/chat/calculate")
class AdminChatCalculateController(
private val service: AdminChatCalculateService
) {
@GetMapping("/characters")
fun getCharacterCalculate(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String,
@RequestParam(required = false, defaultValue = "TOTAL_SALES_DESC") sort: ChatCharacterCalculateSort,
pageable: Pageable
) = ApiResponse.ok(
service.getCharacterCalculate(
startDateStr,
endDateStr,
sort,
pageable.offset,
pageable.pageSize
)
)
}

View File

@@ -0,0 +1,139 @@
package kr.co.vividnext.sodalive.admin.chat.calculate
import com.querydsl.core.types.Projections
import com.querydsl.core.types.dsl.CaseBuilder
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
import kr.co.vividnext.sodalive.chat.character.QChatCharacter
import kr.co.vividnext.sodalive.chat.character.image.QCharacterImage.characterImage
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@Repository
class AdminChatCalculateQueryRepository(
private val queryFactory: JPAQueryFactory,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
fun getCharacterCalculate(
startUtc: LocalDateTime,
endInclusiveUtc: LocalDateTime,
sort: ChatCharacterCalculateSort,
offset: Long,
limit: Long
): List<ChatCharacterCalculateQueryData> {
val imageCanExpr = CaseBuilder()
.`when`(useCan.canUsage.eq(CanUsage.CHARACTER_IMAGE_PURCHASE))
.then(useCan.can.add(useCan.rewardCan))
.otherwise(0)
val messageCanExpr = CaseBuilder()
.`when`(useCan.canUsage.eq(CanUsage.CHAT_MESSAGE_PURCHASE))
.then(useCan.can.add(useCan.rewardCan))
.otherwise(0)
val quotaCanExpr = CaseBuilder()
.`when`(useCan.canUsage.eq(CanUsage.CHAT_QUOTA_PURCHASE))
.then(useCan.can.add(useCan.rewardCan))
.otherwise(0)
val imageSum = imageCanExpr.sum()
val messageSum = messageCanExpr.sum()
val quotaSum = quotaCanExpr.sum()
val totalSum = imageSum.add(messageSum).add(quotaSum)
// 캐릭터 조인: 이미지 경로를 통한 캐릭터(c1) + characterId 직접 지정(c2)
val c1 = QChatCharacter("c1")
val c2 = QChatCharacter("c2")
val characterIdExpr = c1.id.coalesce(c2.id)
val characterNameAgg = Expressions.stringTemplate(
"coalesce(max({0}), max({1}), '')",
c1.name,
c2.name
)
val characterImagePathAgg = Expressions.stringTemplate(
"coalesce(max({0}), max({1}))",
c1.imagePath,
c2.imagePath
)
val query = queryFactory
.select(
Projections.constructor(
ChatCharacterCalculateQueryData::class.java,
characterIdExpr,
characterNameAgg,
characterImagePathAgg.prepend("/").prepend(imageHost),
imageSum,
messageSum,
quotaSum
)
)
.from(useCan)
.leftJoin(useCan.characterImage, characterImage)
.leftJoin(characterImage.chatCharacter, c1)
.leftJoin(c2).on(c2.id.eq(useCan.characterId))
.where(
useCan.isRefund.isFalse
.and(
useCan.canUsage.`in`(
CanUsage.CHARACTER_IMAGE_PURCHASE,
CanUsage.CHAT_MESSAGE_PURCHASE,
CanUsage.CHAT_QUOTA_PURCHASE
)
)
.and(useCan.createdAt.goe(startUtc))
.and(useCan.createdAt.loe(endInclusiveUtc))
)
.groupBy(characterIdExpr)
when (sort) {
ChatCharacterCalculateSort.TOTAL_SALES_DESC ->
query.orderBy(totalSum.desc(), characterIdExpr.desc())
ChatCharacterCalculateSort.LATEST_DESC ->
query.orderBy(characterIdExpr.desc(), totalSum.desc())
}
return query
.offset(offset)
.limit(limit)
.fetch()
}
fun getCharacterCalculateTotalCount(
startUtc: LocalDateTime,
endInclusiveUtc: LocalDateTime
): Int {
val c1 = QChatCharacter("c1")
val c2 = QChatCharacter("c2")
val characterIdExpr = c1.id.coalesce(c2.id)
return queryFactory
.select(characterIdExpr)
.from(useCan)
.leftJoin(useCan.characterImage, characterImage)
.leftJoin(characterImage.chatCharacter, c1)
.leftJoin(c2).on(c2.id.eq(useCan.characterId))
.where(
useCan.isRefund.isFalse
.and(
useCan.canUsage.`in`(
CanUsage.CHARACTER_IMAGE_PURCHASE,
CanUsage.CHAT_MESSAGE_PURCHASE,
CanUsage.CHAT_QUOTA_PURCHASE
)
)
.and(useCan.createdAt.goe(startUtc))
.and(useCan.createdAt.loe(endInclusiveUtc))
)
.groupBy(characterIdExpr)
.fetch()
.size
}
}

View File

@@ -0,0 +1,49 @@
package kr.co.vividnext.sodalive.admin.chat.calculate
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Service
class AdminChatCalculateService(
private val repository: AdminChatCalculateQueryRepository
) {
private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
private val kstZone: ZoneId = ZoneId.of("Asia/Seoul")
@Transactional(readOnly = true)
fun getCharacterCalculate(
startDateStr: String,
endDateStr: String,
sort: ChatCharacterCalculateSort,
offset: Long,
pageSize: Int
): ChatCharacterCalculateResponse {
// 날짜 유효성 검증 (KST 기준)
val startDate = LocalDate.parse(startDateStr, dateFormatter)
val endDate = LocalDate.parse(endDateStr, dateFormatter)
val todayKst = LocalDate.now(kstZone)
if (endDate.isAfter(todayKst)) {
throw SodaException("끝 날짜는 오늘 날짜까지만 입력 가능합니다.")
}
if (startDate.isAfter(endDate)) {
throw SodaException("시작 날짜는 끝 날짜보다 이후일 수 없습니다.")
}
if (endDate.isAfter(startDate.plusMonths(6))) {
throw SodaException("조회 가능 기간은 최대 6개월입니다.")
}
val startUtc = startDateStr.convertLocalDateTime()
val endInclusiveUtc = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
val totalCount = repository.getCharacterCalculateTotalCount(startUtc, endInclusiveUtc)
val rows = repository.getCharacterCalculate(startUtc, endInclusiveUtc, sort, offset, pageSize.toLong())
val items = rows.map { it.toItem() }
return ChatCharacterCalculateResponse(totalCount, items)
}
}

View File

@@ -0,0 +1,62 @@
package kr.co.vividnext.sodalive.admin.chat.calculate
import com.fasterxml.jackson.annotation.JsonProperty
import com.querydsl.core.annotations.QueryProjection
import java.math.BigDecimal
import java.math.RoundingMode
// 정렬 옵션
enum class ChatCharacterCalculateSort {
TOTAL_SALES_DESC,
LATEST_DESC
}
// QueryDSL 프로젝션용 DTO
data class ChatCharacterCalculateQueryData @QueryProjection constructor(
val characterId: Long,
val characterName: String,
val characterImagePath: String?,
val imagePurchaseCan: Int?,
val messagePurchaseCan: Int?,
val quotaPurchaseCan: Int?
)
// 응답 DTO (아이템)
data class ChatCharacterCalculateItem(
@JsonProperty("characterId") val characterId: Long,
@JsonProperty("characterImage") val characterImage: String?,
@JsonProperty("name") val name: String,
@JsonProperty("imagePurchaseCan") val imagePurchaseCan: Int,
@JsonProperty("messagePurchaseCan") val messagePurchaseCan: Int,
@JsonProperty("quotaPurchaseCan") val quotaPurchaseCan: Int,
@JsonProperty("totalCan") val totalCan: Int,
@JsonProperty("totalKrw") val totalKrw: Int,
@JsonProperty("settlementKrw") val settlementKrw: Int
)
// 응답 DTO (전체)
data class ChatCharacterCalculateResponse(
@JsonProperty("totalCount") val totalCount: Int,
@JsonProperty("items") val items: List<ChatCharacterCalculateItem>
)
fun ChatCharacterCalculateQueryData.toItem(): ChatCharacterCalculateItem {
val image = imagePurchaseCan ?: 0
val message = messagePurchaseCan ?: 0
val quota = quotaPurchaseCan ?: 0
val total = image + message + quota
val totalKrw = BigDecimal(total).multiply(BigDecimal(100))
val settlement = totalKrw.multiply(BigDecimal("0.10")).setScale(0, RoundingMode.HALF_UP)
return ChatCharacterCalculateItem(
characterId = characterId,
characterImage = characterImagePath,
name = characterName,
imagePurchaseCan = image,
messagePurchaseCan = message,
quotaPurchaseCan = quota,
totalCan = total,
totalKrw = totalKrw.toInt(),
settlementKrw = settlement.toInt()
)
}

View File

@@ -0,0 +1,423 @@
package kr.co.vividnext.sodalive.admin.chat.character
import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.chat.character.CharacterType
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType
import org.springframework.http.client.SimpleClientHttpRequestFactory
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.client.RestTemplate
import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/admin/chat/character")
@PreAuthorize("hasRole('ADMIN')")
class AdminChatCharacterController(
private val service: ChatCharacterService,
private val adminService: AdminChatCharacterService,
private val s3Uploader: S3Uploader,
private val originalWorkService: AdminOriginalWorkService,
@Value("\${weraser.api-key}")
private val apiKey: String,
@Value("\${weraser.api-url}")
private val apiUrl: String,
@Value("\${cloud.aws.s3.bucket}")
private val s3Bucket: String,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
/**
* 활성화된 캐릭터 목록 조회 API
*
* @param page 페이지 번호 (0부터 시작, 기본값 0)
* @param size 페이지 크기 (기본값 20)
* @return 페이징된 캐릭터 목록
*/
@GetMapping("/list")
fun getCharacterList(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = run {
val pageable = adminService.createDefaultPageRequest(page, size)
val response = adminService.getActiveChatCharacters(pageable, imageHost)
ApiResponse.ok(response)
}
/**
* 캐릭터 검색(관리자)
* - 이름/설명/MBTI/태그 기준 부분 검색, 활성 캐릭터만 대상
* - 페이징 지원: page, size 파라미터 사용
*/
@GetMapping("/search")
fun searchCharacters(
@RequestParam("searchTerm") searchTerm: String,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = run {
val pageable = adminService.createDefaultPageRequest(page, size)
val resultPage = adminService.searchCharacters(searchTerm, pageable, imageHost)
val response = ChatCharacterSearchListPageResponse(
totalCount = resultPage.totalElements,
content = resultPage.content
)
ApiResponse.ok(response)
}
/**
* 캐릭터 상세 정보 조회 API
*
* @param characterId 캐릭터 ID
* @return 캐릭터 상세 정보
*/
@GetMapping("/{characterId}")
fun getCharacterDetail(
@PathVariable characterId: Long
) = run {
val response = adminService.getChatCharacterDetail(characterId, imageHost)
ApiResponse.ok(response)
}
@PostMapping("/register")
fun registerCharacter(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String
) = run {
// JSON 문자열을 ChatCharacterRegisterRequest 객체로 변환
val objectMapper = ObjectMapper()
val request = objectMapper.readValue(requestString, ChatCharacterRegisterRequest::class.java)
// 외부 API 호출 전 DB에 동일한 이름이 있는지 조회
val existingCharacter = service.findByName(request.name)
if (existingCharacter != null) {
throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}")
}
// 1. 외부 API 호출
val characterUUID = callExternalApi(request)
// 2. ChatCharacter 저장
val chatCharacter = service.createChatCharacterWithDetails(
characterUUID = characterUUID,
name = request.name,
description = request.description,
systemPrompt = request.systemPrompt,
age = request.age?.toIntOrNull(),
gender = request.gender,
mbti = request.mbti,
speechPattern = request.speechPattern,
speechStyle = request.speechStyle,
appearance = request.appearance,
originalTitle = request.originalTitle,
originalLink = request.originalLink,
characterType = request.characterType?.let {
runCatching { CharacterType.valueOf(it) }
.getOrDefault(CharacterType.Character)
} ?: CharacterType.Character,
tags = request.tags,
values = request.values,
hobbies = request.hobbies,
goals = request.goals,
memories = request.memories.map { Triple(it.title, it.content, it.emotion) },
personalities = request.personalities.map { Pair(it.trait, it.description) },
backgrounds = request.backgrounds.map { Pair(it.topic, it.description) },
relationships = request.relationships
)
// 3. 이미지 저장 및 ChatCharacter에 이미지 path 설정
val imagePath = saveImage(
characterId = chatCharacter.id!!,
image = image
)
chatCharacter.imagePath = imagePath
service.saveChatCharacter(chatCharacter)
// 4. 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
if (request.originalWorkId != null) {
originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!)
}
ApiResponse.ok(null)
}
private fun callExternalApi(request: ChatCharacterRegisterRequest): String {
try {
val factory = SimpleClientHttpRequestFactory()
factory.setConnectTimeout(20000) // 20초
factory.setReadTimeout(20000) // 20초
val restTemplate = RestTemplate(factory)
val headers = HttpHeaders()
headers.set("x-api-key", apiKey) // 실제 API 키로 대체 필요
headers.contentType = MediaType.APPLICATION_JSON
// 외부 API에 전달하지 않을 필드(originalTitle, originalLink, characterType)를 제외하고 바디 구성
val body = mutableMapOf<String, Any>()
body["name"] = request.name
body["systemPrompt"] = request.systemPrompt
body["description"] = request.description
request.age?.let { body["age"] = it }
request.gender?.let { body["gender"] = it }
request.mbti?.let { body["mbti"] = it }
request.speechPattern?.let { body["speechPattern"] = it }
request.speechStyle?.let { body["speechStyle"] = it }
request.appearance?.let { body["appearance"] = it }
if (request.tags.isNotEmpty()) body["tags"] = request.tags
if (request.hobbies.isNotEmpty()) body["hobbies"] = request.hobbies
if (request.values.isNotEmpty()) body["values"] = request.values
if (request.goals.isNotEmpty()) body["goals"] = request.goals
if (request.relationships.isNotEmpty()) body["relationships"] = request.relationships
if (request.personalities.isNotEmpty()) body["personalities"] = request.personalities
if (request.backgrounds.isNotEmpty()) body["backgrounds"] = request.backgrounds
if (request.memories.isNotEmpty()) body["memories"] = request.memories
val httpEntity = HttpEntity(body, headers)
val response = restTemplate.exchange(
"$apiUrl/api/characters",
HttpMethod.POST,
httpEntity,
String::class.java
)
// 응답 파싱
val objectMapper = ObjectMapper()
val apiResponse = objectMapper.readValue(response.body, ExternalApiResponse::class.java)
// success가 false이면 throw
if (!apiResponse.success) {
throw SodaException(apiResponse.message ?: "등록에 실패했습니다. 다시 시도해 주세요.")
}
// success가 true이면 data.id 반환
return apiResponse.data?.id ?: throw SodaException("등록에 실패했습니다. 응답에 ID가 없습니다.")
} catch (e: Exception) {
e.printStackTrace()
throw SodaException("${e.message}, 등록에 실패했습니다. 다시 시도해 주세요.")
}
}
private fun saveImage(characterId: Long, image: MultipartFile): String {
try {
val metadata = ObjectMetadata()
metadata.contentLength = image.size
// S3에 이미지 업로드
return s3Uploader.upload(
inputStream = image.inputStream,
bucket = s3Bucket,
filePath = "characters/$characterId/${generateFileName(prefix = "character")}",
metadata = metadata
)
} catch (e: Exception) {
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
}
}
/**
* 캐릭터 수정 API
* 1. JSON 문자열을 ChatCharacterUpdateRequest 객체로 변환
* 2. ChatCharacterUpdateRequest를 확인해서 변경한 데이터가 있는지 확인
* 3. 이미지 있는지 확인
* 4. 2, 3번 중 하나라도 해당 하면 계속 진행
* 5. 2, 3번에 데이터 없으면 throw SodaException('변경된 데이터가 없습니다.')
*
* @param image 캐릭터 이미지 (선택적)
* @param requestString ChatCharacterUpdateRequest 객체를 JSON 문자열로 변환한 값
* @return ApiResponse 객체
* @throws SodaException 변경된 데이터가 없거나 캐릭터를 찾을 수 없는 경우
*/
@PutMapping("/update")
fun updateCharacter(
@RequestPart(value = "image", required = false) image: MultipartFile?,
@RequestPart("request") requestString: String
) = run {
// 1. JSON 문자열을 ChatCharacterUpdateRequest 객체로 변환
val objectMapper = ObjectMapper()
val request = objectMapper.readValue(requestString, ChatCharacterUpdateRequest::class.java)
// 2. ChatCharacterUpdateRequest를 확인해서 변경한 데이터가 있는지 확인
val hasChangedData = hasChanges(request) // 외부 API 대상으로의 변경 여부(3가지 필드 제외)
// 3. 이미지 있는지 확인
val hasImage = image != null && !image.isEmpty
// 3가지만 변경된 경우(외부 API 변경은 없지만 DB 변경은 있는 경우)를 허용하기 위해 별도 플래그 계산
val hasDbOnlyChanges =
request.originalTitle != null ||
request.originalLink != null ||
request.characterType != null ||
request.originalWorkId != null
if (!hasChangedData && !hasImage && !hasDbOnlyChanges) {
throw SodaException("변경된 데이터가 없습니다.")
}
// 외부 API로 전달할 변경이 있을 때만 외부 API 호출(3가지 필드만 변경된 경우는 호출하지 않음)
if (hasChangedData) {
val chatCharacter = service.findById(request.id)
?: throw SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}")
// 이름이 수정된 경우 DB에 동일한 이름이 있는지 확인
if (request.name != null && request.name != chatCharacter.name) {
val existingCharacter = service.findByName(request.name)
if (existingCharacter != null) {
throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}")
}
}
callExternalApiForUpdate(chatCharacter.characterUUID, request)
}
// 이미지 경로 변수 초기화
// 이미지가 있으면 이미지 저장
val imagePath = if (hasImage) {
saveImage(
characterId = request.id,
image = image!!
)
} else {
null
}
// 엔티티 수정
service.updateChatCharacterWithDetails(
imagePath = imagePath,
request = request
)
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
if (request.originalWorkId != null) {
// 서비스에서 유효성 검증 및 저장까지 처리
originalWorkService.assignOneCharacter(request.originalWorkId, request.id)
}
ApiResponse.ok(null)
}
/**
* 요청에 변경된 데이터가 있는지 확인
* id를 제외한 모든 필드가 null이면 변경된 데이터가 없는 것으로 판단
*
* @param request 수정 요청 데이터
* @return 변경된 데이터가 있으면 true, 없으면 false
*/
private fun hasChanges(request: ChatCharacterUpdateRequest): Boolean {
return request.systemPrompt != null ||
request.description != null ||
request.age != null ||
request.gender != null ||
request.mbti != null ||
request.speechPattern != null ||
request.speechStyle != null ||
request.appearance != null ||
request.isActive != null ||
request.tags != null ||
request.hobbies != null ||
request.values != null ||
request.goals != null ||
request.relationships != null ||
request.personalities != null ||
request.backgrounds != null ||
request.memories != null ||
request.name != null
}
/**
* 외부 API 호출 - 수정 API
* 변경된 데이터만 요청에 포함
*
* @param characterUUID 캐릭터 UUID
* @param request 수정 요청 데이터
*/
private fun callExternalApiForUpdate(characterUUID: String, request: ChatCharacterUpdateRequest) {
try {
val factory = SimpleClientHttpRequestFactory()
factory.setConnectTimeout(20000) // 20초
factory.setReadTimeout(20000) // 20초
val restTemplate = RestTemplate(factory)
val headers = HttpHeaders()
headers.set("x-api-key", apiKey)
headers.contentType = MediaType.APPLICATION_JSON
// 변경된 데이터만 포함하는 맵 생성
val updateData = mutableMapOf<String, Any>()
// isActive = false인 경우 처리
if (request.isActive != null && !request.isActive) {
val inactiveName = "inactive_${request.name}"
val randomSuffix = "_" + java.util.UUID.randomUUID().toString().replace("-", "")
updateData["name"] = inactiveName + randomSuffix
} else {
request.name?.let { updateData["name"] = it }
request.systemPrompt?.let { updateData["systemPrompt"] = it }
request.description?.let { updateData["description"] = it }
request.age?.let { updateData["age"] = it }
request.gender?.let { updateData["gender"] = it }
request.mbti?.let { updateData["mbti"] = it }
request.speechPattern?.let { updateData["speechPattern"] = it }
request.speechStyle?.let { updateData["speechStyle"] = it }
request.appearance?.let { updateData["appearance"] = it }
request.tags?.let { updateData["tags"] = it }
request.hobbies?.let { updateData["hobbies"] = it }
request.values?.let { updateData["values"] = it }
request.goals?.let { updateData["goals"] = it }
request.relationships?.let { updateData["relationships"] = it }
request.personalities?.let { updateData["personalities"] = it }
request.backgrounds?.let { updateData["backgrounds"] = it }
request.memories?.let { updateData["memories"] = it }
}
val httpEntity = HttpEntity(updateData, headers)
val response = restTemplate.exchange(
"$apiUrl/api/characters/$characterUUID",
HttpMethod.PUT,
httpEntity,
String::class.java
)
// 응답 파싱
val objectMapper = ObjectMapper()
val apiResponse = objectMapper.readValue(response.body, ExternalApiResponse::class.java)
// success가 false이면 throw
if (!apiResponse.success) {
throw SodaException(apiResponse.message ?: "수정에 실패했습니다. 다시 시도해 주세요.")
}
} catch (e: Exception) {
e.printStackTrace()
throw SodaException("${e.message} 수정에 실패했습니다. 다시 시도해 주세요.")
}
}
}

View File

@@ -0,0 +1,82 @@
package kr.co.vividnext.sodalive.admin.chat.character.curation
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
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.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/admin/chat/character/curation")
@PreAuthorize("hasRole('ADMIN')")
class CharacterCurationAdminController(
private val service: CharacterCurationAdminService,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
@GetMapping("/list")
fun listAll(): ApiResponse<List<CharacterCurationListItemResponse>> =
ApiResponse.ok(service.listAll())
@GetMapping("/{curationId}/characters")
fun listCharacters(
@PathVariable curationId: Long
): ApiResponse<List<CharacterCurationCharacterItemResponse>> {
val characters = service.listCharacters(curationId)
val items = characters.map {
CharacterCurationCharacterItemResponse(
id = it.id!!,
name = it.name,
description = it.description,
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
)
}
return ApiResponse.ok(items)
}
@PostMapping("/register")
fun register(@RequestBody request: CharacterCurationRegisterRequest) =
ApiResponse.ok(service.register(request).id)
@PutMapping("/update")
fun update(@RequestBody request: CharacterCurationUpdateRequest) =
ApiResponse.ok(service.update(request).id)
@DeleteMapping("/{curationId}")
fun delete(@PathVariable curationId: Long) =
ApiResponse.ok(service.softDelete(curationId))
@PutMapping("/reorder")
fun reorder(@RequestBody request: CharacterCurationOrderUpdateRequest) =
ApiResponse.ok(service.reorder(request.ids))
@PostMapping("/{curationId}/characters")
fun addCharacter(
@PathVariable curationId: Long,
@RequestBody request: CharacterCurationAddCharacterRequest
): ApiResponse<Boolean> {
val ids = request.characterIds.filter { it > 0 }.distinct()
if (ids.isEmpty()) throw SodaException("등록할 캐릭터 ID 리스트가 비어있습니다")
service.addCharacters(curationId, ids)
return ApiResponse.ok(true)
}
@DeleteMapping("/{curationId}/characters/{characterId}")
fun removeCharacter(
@PathVariable curationId: Long,
@PathVariable characterId: Long
) = ApiResponse.ok(service.removeCharacter(curationId, characterId))
@PutMapping("/{curationId}/characters/reorder")
fun reorderCharacters(
@PathVariable curationId: Long,
@RequestBody request: CharacterCurationReorderCharactersRequest
) = ApiResponse.ok(service.reorderCharacters(curationId, request.characterIds))
}

View File

@@ -0,0 +1,45 @@
package kr.co.vividnext.sodalive.admin.chat.character.curation
data class CharacterCurationRegisterRequest(
val title: String,
val isAdult: Boolean = false,
val isActive: Boolean = true
)
data class CharacterCurationUpdateRequest(
val id: Long,
val title: String? = null,
val isAdult: Boolean? = null,
val isActive: Boolean? = null
)
data class CharacterCurationOrderUpdateRequest(
val ids: List<Long>
)
data class CharacterCurationAddCharacterRequest(
val characterIds: List<Long>
)
data class CharacterCurationReorderCharactersRequest(
val characterIds: List<Long>
)
data class CharacterCurationListItemResponse(
val id: Long,
val title: String,
val isAdult: Boolean,
val isActive: Boolean,
val characterCount: Int
)
// 관리자 큐레이션 상세 - 캐릭터 리스트 항목 응답 DTO
// id, name, description, 이미지 URL
// 이미지 URL은 컨트롤러에서 cloud-front host + imagePath로 구성
data class CharacterCurationCharacterItemResponse(
val id: Long,
val name: String,
val description: String,
val imageUrl: String
)

View File

@@ -0,0 +1,153 @@
package kr.co.vividnext.sodalive.admin.chat.character.curation
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCuration
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationMapping
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationMappingRepository
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class CharacterCurationAdminService(
private val curationRepository: CharacterCurationRepository,
private val mappingRepository: CharacterCurationMappingRepository,
private val characterRepository: ChatCharacterRepository
) {
@Transactional
fun register(request: CharacterCurationRegisterRequest): CharacterCuration {
val sortOrder = (curationRepository.findMaxSortOrder() ?: 0) + 1
val curation = CharacterCuration(
title = request.title,
isAdult = request.isAdult,
isActive = request.isActive,
sortOrder = sortOrder
)
return curationRepository.save(curation)
}
@Transactional
fun update(request: CharacterCurationUpdateRequest): CharacterCuration {
val curation = curationRepository.findById(request.id)
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: ${request.id}") }
request.title?.let { curation.title = it }
request.isAdult?.let { curation.isAdult = it }
request.isActive?.let { curation.isActive = it }
return curationRepository.save(curation)
}
@Transactional
fun softDelete(curationId: Long) {
val curation = curationRepository.findById(curationId)
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
curation.isActive = false
curationRepository.save(curation)
}
@Transactional
fun reorder(ids: List<Long>) {
ids.forEachIndexed { index, id ->
val curation = curationRepository.findById(id)
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $id") }
curation.sortOrder = index + 1
curationRepository.save(curation)
}
}
@Transactional
fun addCharacters(curationId: Long, characterIds: List<Long>) {
if (characterIds.isEmpty()) throw SodaException("등록할 캐릭터 ID 리스트가 비어있습니다")
val curation = curationRepository.findById(curationId)
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
if (!curation.isActive) throw SodaException("비활성화된 큐레이션입니다: $curationId")
val uniqueIds = characterIds.filter { it > 0 }.distinct()
if (uniqueIds.isEmpty()) throw SodaException("유효한 캐릭터 ID가 없습니다")
// 활성 캐릭터만 조회 (조회 단계에서 검증 포함)
val characters = characterRepository.findByIdInAndIsActiveTrue(uniqueIds)
val characterMap = characters.associateBy { it.id!! }
// 조회 결과에 존재하는 캐릭터만 유효
val validIds = uniqueIds.filter { id -> characterMap.containsKey(id) }
val existingMappings = mappingRepository.findByCuration(curation)
val existingCharacterIds = existingMappings.mapNotNull { it.chatCharacter.id }.toSet()
var nextOrder = (existingMappings.maxOfOrNull { it.sortOrder } ?: 0) + 1
val toSave = mutableListOf<CharacterCurationMapping>()
validIds.forEach { id ->
if (!existingCharacterIds.contains(id)) {
val character = characterMap[id] ?: return@forEach
toSave += CharacterCurationMapping(
curation = curation,
chatCharacter = character,
sortOrder = nextOrder++
)
}
}
if (toSave.isNotEmpty()) {
mappingRepository.saveAll(toSave)
}
}
@Transactional
fun removeCharacter(curationId: Long, characterId: Long) {
val curation = curationRepository.findById(curationId)
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
val mappings = mappingRepository.findByCuration(curation)
val target = mappings.firstOrNull { it.chatCharacter.id == characterId }
?: throw SodaException("매핑을 찾을 수 없습니다: curation=$curationId, character=$characterId")
mappingRepository.delete(target)
}
@Transactional
fun reorderCharacters(curationId: Long, characterIds: List<Long>) {
val curation = curationRepository.findById(curationId)
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
val mappings = mappingRepository.findByCuration(curation)
val mappingByCharacterId = mappings.associateBy { it.chatCharacter.id }
characterIds.forEachIndexed { index, cid ->
val mapping = mappingByCharacterId[cid]
?: throw SodaException("큐레이션에 포함되지 않은 캐릭터입니다: $cid")
mapping.sortOrder = index + 1
mappingRepository.save(mapping)
}
}
@Transactional(readOnly = true)
fun listAll(): List<CharacterCurationListItemResponse> {
val curations = curationRepository.findByIsActiveTrueOrderBySortOrderAsc()
if (curations.isEmpty()) return emptyList()
// DB 집계로 활성 캐릭터 수 카운트
val counts = mappingRepository.countActiveCharactersByCurations(curations)
val countByCurationId: Map<Long, Int> = counts.associate { it.curationId to it.count.toInt() }
return curations.map { curation ->
CharacterCurationListItemResponse(
id = curation.id!!,
title = curation.title,
isAdult = curation.isAdult,
isActive = curation.isActive,
characterCount = countByCurationId[curation.id!!] ?: 0
)
}
}
@Transactional(readOnly = true)
fun listCharacters(curationId: Long): List<ChatCharacter> {
val curation = curationRepository.findById(curationId)
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
val mappings = mappingRepository.findByCurationWithCharacterOrderBySortOrderAsc(curation)
return mappings.map { it.chatCharacter }
}
}

View File

@@ -0,0 +1,132 @@
package kr.co.vividnext.sodalive.admin.chat.character.dto
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
/**
* 관리자 캐릭터 상세 응답 DTO
* - 원작이 연결되어 있으면 원작 요약 정보(originalWork)를 함께 반환한다.
*/
data class ChatCharacterDetailResponse(
val id: Long,
val characterUUID: String,
val name: String,
val imageUrl: String?,
val description: String,
val systemPrompt: String,
val characterType: String,
val age: Int?,
val gender: String?,
val mbti: String?,
val speechPattern: String?,
val speechStyle: String?,
val appearance: String?,
val isActive: Boolean,
val tags: List<String>,
val hobbies: List<String>,
val values: List<String>,
val goals: List<String>,
val relationships: List<RelationshipResponse>,
val personalities: List<PersonalityResponse>,
val backgrounds: List<BackgroundResponse>,
val memories: List<MemoryResponse>,
val originalWork: OriginalWorkBriefResponse? // 추가: 원작 요약 정보
) {
companion object {
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterDetailResponse {
val fullImagePath = if (chatCharacter.imagePath != null && imageHost.isNotEmpty()) {
"$imageHost/${chatCharacter.imagePath}"
} else {
chatCharacter.imagePath ?: ""
}
val ow = chatCharacter.originalWork
val originalWorkBrief = ow?.let {
val owImage = if (it.imagePath != null && imageHost.isNotEmpty()) {
"$imageHost/${it.imagePath}"
} else {
it.imagePath
}
OriginalWorkBriefResponse(
id = it.id!!,
imageUrl = owImage,
title = it.title
)
}
return ChatCharacterDetailResponse(
id = chatCharacter.id!!,
characterUUID = chatCharacter.characterUUID,
name = chatCharacter.name,
imageUrl = fullImagePath,
description = chatCharacter.description,
systemPrompt = chatCharacter.systemPrompt,
characterType = chatCharacter.characterType.name,
age = chatCharacter.age,
gender = chatCharacter.gender,
mbti = chatCharacter.mbti,
speechPattern = chatCharacter.speechPattern,
speechStyle = chatCharacter.speechStyle,
appearance = chatCharacter.appearance,
isActive = chatCharacter.isActive,
tags = chatCharacter.tagMappings.map { it.tag.tag },
hobbies = chatCharacter.hobbyMappings.map { it.hobby.hobby },
values = chatCharacter.valueMappings.map { it.value.value },
goals = chatCharacter.goalMappings.map { it.goal.goal },
relationships = chatCharacter.relationships.map {
RelationshipResponse(
personName = it.personName,
relationshipName = it.relationshipName,
description = it.description,
importance = it.importance,
relationshipType = it.relationshipType,
currentStatus = it.currentStatus
)
},
personalities = chatCharacter.personalities.map {
PersonalityResponse(it.trait, it.description)
},
backgrounds = chatCharacter.backgrounds.map {
BackgroundResponse(it.topic, it.description)
},
memories = chatCharacter.memories.map {
MemoryResponse(it.title, it.content, it.emotion)
},
originalWork = originalWorkBrief
)
}
}
}
data class PersonalityResponse(
val trait: String,
val description: String
)
data class BackgroundResponse(
val topic: String,
val description: String
)
data class MemoryResponse(
val title: String,
val content: String,
val emotion: String
)
data class RelationshipResponse(
val personName: String,
val relationshipName: String,
val description: String,
val importance: Int,
val relationshipType: String,
val currentStatus: String
)
/**
* 원작 요약 응답 DTO(관리자 캐릭터 상세용)
*/
data class OriginalWorkBriefResponse(
val id: Long,
val imageUrl: String?,
val title: String
)

View File

@@ -0,0 +1,90 @@
package kr.co.vividnext.sodalive.admin.chat.character.dto
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
data class ChatCharacterPersonalityRequest(
@JsonProperty("trait") val trait: String,
@JsonProperty("description") val description: String
)
data class ChatCharacterBackgroundRequest(
@JsonProperty("topic") val topic: String,
@JsonProperty("description") val description: String
)
data class ChatCharacterMemoryRequest(
@JsonProperty("title") val title: String,
@JsonProperty("content") val content: String,
@JsonProperty("emotion") val emotion: String
)
data class ChatCharacterRelationshipRequest(
@JsonProperty("personName") val personName: String,
@JsonProperty("relationshipName") val relationshipName: String,
@JsonProperty("description") val description: String,
@JsonProperty("importance") val importance: Int,
@JsonProperty("relationshipType") val relationshipType: String,
@JsonProperty("currentStatus") val currentStatus: String
)
data class ChatCharacterRegisterRequest(
@JsonProperty("name") val name: String,
@JsonProperty("systemPrompt") val systemPrompt: String,
@JsonProperty("description") val description: String,
@JsonProperty("age") val age: String?,
@JsonProperty("gender") val gender: String?,
@JsonProperty("mbti") val mbti: String?,
@JsonProperty("speechPattern") val speechPattern: String?,
@JsonProperty("speechStyle") val speechStyle: String?,
@JsonProperty("appearance") val appearance: String?,
@JsonProperty("originalTitle") val originalTitle: String? = null,
@JsonProperty("originalLink") val originalLink: String? = null,
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
@JsonProperty("characterType") val characterType: String? = null,
@JsonProperty("tags") val tags: List<String> = emptyList(),
@JsonProperty("hobbies") val hobbies: List<String> = emptyList(),
@JsonProperty("values") val values: List<String> = emptyList(),
@JsonProperty("goals") val goals: List<String> = emptyList(),
@JsonProperty("relationships") val relationships: List<ChatCharacterRelationshipRequest> = emptyList(),
@JsonProperty("personalities") val personalities: List<ChatCharacterPersonalityRequest> = emptyList(),
@JsonProperty("backgrounds") val backgrounds: List<ChatCharacterBackgroundRequest> = emptyList(),
@JsonProperty("memories") val memories: List<ChatCharacterMemoryRequest> = emptyList()
)
data class ExternalApiResponse(
@JsonProperty("success") val success: Boolean,
@JsonProperty("data") val data: ExternalApiData? = null,
@JsonProperty("message") val message: String? = null
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ExternalApiData(
@JsonProperty("id") val id: String
)
data class ChatCharacterUpdateRequest(
@JsonProperty("id") val id: Long,
@JsonProperty("name") val name: String? = null,
@JsonProperty("systemPrompt") val systemPrompt: String? = null,
@JsonProperty("description") val description: String? = null,
@JsonProperty("age") val age: String? = null,
@JsonProperty("gender") val gender: String? = null,
@JsonProperty("mbti") val mbti: String? = null,
@JsonProperty("speechPattern") val speechPattern: String? = null,
@JsonProperty("speechStyle") val speechStyle: String? = null,
@JsonProperty("appearance") val appearance: String? = null,
@JsonProperty("originalTitle") val originalTitle: String? = null,
@JsonProperty("originalLink") val originalLink: String? = null,
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
@JsonProperty("characterType") val characterType: String? = null,
@JsonProperty("isActive") val isActive: Boolean? = null,
@JsonProperty("tags") val tags: List<String>? = null,
@JsonProperty("hobbies") val hobbies: List<String>? = null,
@JsonProperty("values") val values: List<String>? = null,
@JsonProperty("goals") val goals: List<String>? = null,
@JsonProperty("relationships") val relationships: List<ChatCharacterRelationshipRequest>? = null,
@JsonProperty("personalities") val personalities: List<ChatCharacterPersonalityRequest>? = null,
@JsonProperty("backgrounds") val backgrounds: List<ChatCharacterBackgroundRequest>? = null,
@JsonProperty("memories") val memories: List<ChatCharacterMemoryRequest>? = null
)

View File

@@ -0,0 +1,62 @@
package kr.co.vividnext.sodalive.admin.chat.character.dto
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import java.time.ZoneId
import java.time.format.DateTimeFormatter
data class ChatCharacterListResponse(
val id: Long,
val name: String,
val imageUrl: String?,
val description: String,
val gender: String?,
val age: Int?,
val mbti: String?,
val speechStyle: String?,
val speechPattern: String?,
val tags: List<String>,
val createdAt: String?,
val updatedAt: String?
) {
companion object {
private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
private val seoulZoneId = ZoneId.of("Asia/Seoul")
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterListResponse {
val fullImagePath = if (chatCharacter.imagePath != null && imageHost.isNotEmpty()) {
"$imageHost/${chatCharacter.imagePath}"
} else {
chatCharacter.imagePath
}
// UTC에서 Asia/Seoul로 시간대 변환 및 문자열 포맷팅
val createdAtStr = chatCharacter.createdAt?.atZone(ZoneId.of("UTC"))
?.withZoneSameInstant(seoulZoneId)
?.format(formatter)
val updatedAtStr = chatCharacter.updatedAt?.atZone(ZoneId.of("UTC"))
?.withZoneSameInstant(seoulZoneId)
?.format(formatter)
return ChatCharacterListResponse(
id = chatCharacter.id!!,
name = chatCharacter.name,
imageUrl = fullImagePath,
description = chatCharacter.description,
gender = chatCharacter.gender,
age = chatCharacter.age,
mbti = chatCharacter.mbti,
speechStyle = chatCharacter.speechStyle,
speechPattern = chatCharacter.speechPattern,
tags = chatCharacter.tagMappings.map { it.tag.tag },
createdAt = createdAtStr,
updatedAt = updatedAtStr
)
}
}
}
data class ChatCharacterListPageResponse(
val totalCount: Long,
val content: List<ChatCharacterListResponse>
)

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.admin.chat.character.dto
/**
* 캐릭터 검색 결과 페이지 응답 DTO
*/
data class ChatCharacterSearchListPageResponse(
val totalCount: Long,
val content: List<ChatCharacterListResponse>
)

View File

@@ -0,0 +1,30 @@
package kr.co.vividnext.sodalive.admin.chat.character.dto
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
/**
* 원작 연결된 캐릭터 결과 응답 DTO
*/
data class OriginalWorkChatCharacterResponse(
val id: Long,
val name: String,
val imagePath: String?
) {
companion object {
fun from(character: ChatCharacter, imageHost: String): OriginalWorkChatCharacterResponse {
return OriginalWorkChatCharacterResponse(
id = character.id!!,
name = character.name,
imagePath = character.imagePath?.let { "$imageHost/$it" }
)
}
}
}
/**
* 원작 연결된 캐릭터 결과 페이지 응답 DTO
*/
data class OriginalWorkChatCharacterListPageResponse(
val totalCount: Long,
val content: List<OriginalWorkChatCharacterResponse>
)

View File

@@ -0,0 +1,170 @@
package kr.co.vividnext.sodalive.admin.chat.character.image
import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.AdminCharacterImageResponse
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.RegisterCharacterImageRequest
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageOrdersRequest
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageTriggersRequest
import kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.utils.ImageBlurUtil
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
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.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/admin/chat/character/image")
@PreAuthorize("hasRole('ADMIN')")
class AdminCharacterImageController(
private val imageService: CharacterImageService,
private val s3Uploader: S3Uploader,
private val imageCloudFront: ImageContentCloudFront,
@Value("\${cloud.aws.s3.content-bucket}")
private val s3Bucket: String,
@Value("\${cloud.aws.s3.bucket}")
private val freeBucket: String
) {
@GetMapping("/list")
fun list(@RequestParam characterId: Long) = run {
val expiration = 5L * 60L * 1000L // 5분
val list = imageService.listActiveByCharacter(characterId)
.map { img ->
val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration)
AdminCharacterImageResponse.fromWithUrl(img, signedUrl)
}
ApiResponse.ok(list)
}
@GetMapping("/{imageId}")
fun detail(@PathVariable imageId: Long) = run {
val img = imageService.getById(imageId)
val expiration = 5L * 60L * 1000L // 5분
val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration)
ApiResponse.ok(AdminCharacterImageResponse.fromWithUrl(img, signedUrl))
}
@PostMapping("/register")
fun register(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String
) = run {
val objectMapper = ObjectMapper()
val request = objectMapper.readValue(requestString, RegisterCharacterImageRequest::class.java)
// 업로드 키 생성
val s3Key = buildS3Key(characterId = request.characterId)
// 원본 저장 (content-bucket)
val imagePath = saveImageToBucket(s3Key, image, s3Bucket)
// 블러 생성 및 저장 (무료 이미지 버킷)
val blurImagePath = saveBlurImageToBucket(s3Key, image, freeBucket)
imageService.registerImage(
characterId = request.characterId,
imagePath = imagePath,
blurImagePath = blurImagePath,
imagePriceCan = request.imagePriceCan,
messagePriceCan = request.messagePriceCan,
isAdult = request.isAdult,
triggers = request.triggers ?: emptyList()
)
ApiResponse.ok(null)
}
@PutMapping("/{imageId}/triggers")
fun updateTriggers(
@PathVariable imageId: Long,
@RequestBody request: UpdateCharacterImageTriggersRequest
) = run {
if (!request.triggers.isNullOrEmpty()) {
imageService.updateTriggers(imageId, request.triggers)
}
ApiResponse.ok(null)
}
@DeleteMapping("/{imageId}")
fun delete(@PathVariable imageId: Long) = run {
imageService.deleteImage(imageId)
ApiResponse.ok(null, "이미지가 삭제되었습니다.")
}
@PutMapping("/orders")
fun updateOrders(@RequestBody request: UpdateCharacterImageOrdersRequest) = run {
if (request.characterId == null) throw SodaException("characterId는 필수입니다")
imageService.updateOrders(request.characterId, request.ids)
ApiResponse.ok(null, "정렬 순서가 변경되었습니다.")
}
private fun buildS3Key(characterId: Long): String {
val fileName = generateFileName("character-image")
return "characters/$characterId/images/$fileName"
}
private fun saveImageToBucket(filePath: String, image: MultipartFile, bucket: String): String {
try {
val metadata = ObjectMetadata()
metadata.contentLength = image.size
return s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = filePath,
metadata = metadata
)
} catch (e: Exception) {
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
}
}
private fun saveBlurImageToBucket(filePath: String, image: MultipartFile, bucket: String): String {
try {
// 멀티파트를 BufferedImage로 읽기
val bytes = image.bytes
val bimg = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
?: throw SodaException("이미지 포맷을 인식할 수 없습니다.")
val blurred = ImageBlurUtil.blurFast(bimg)
// PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능
val baos = java.io.ByteArrayOutputStream()
val format = when (image.contentType?.lowercase()) {
"image/png" -> "png"
else -> "jpg"
}
javax.imageio.ImageIO.write(blurred, format, baos)
val inputStream = java.io.ByteArrayInputStream(baos.toByteArray())
val metadata = ObjectMetadata()
metadata.contentLength = baos.size().toLong()
metadata.contentType = image.contentType ?: if (format == "png") "image/png" else "image/jpeg"
return s3Uploader.upload(
inputStream = inputStream,
bucket = bucket,
filePath = filePath,
metadata = metadata
)
} catch (e: Exception) {
throw SodaException("블러 이미지 저장에 실패했습니다: ${e.message}")
}
}
}

View File

@@ -0,0 +1,53 @@
package kr.co.vividnext.sodalive.admin.chat.character.image.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
// 요청 DTOs
data class RegisterCharacterImageRequest(
@JsonProperty("characterId") val characterId: Long,
@JsonProperty("imagePriceCan") val imagePriceCan: Long,
@JsonProperty("messagePriceCan") val messagePriceCan: Long,
@JsonProperty("isAdult") val isAdult: Boolean = false,
@JsonProperty("triggers") val triggers: List<String>? = null
)
data class UpdateCharacterImageTriggersRequest(
@JsonProperty("triggers") val triggers: List<String>? = null
)
data class UpdateCharacterImageOrdersRequest(
@JsonProperty("characterId") val characterId: Long?,
@JsonProperty("ids") val ids: List<Long>
)
// 응답 DTOs
data class AdminCharacterImageResponse(
val id: Long,
val characterId: Long,
val imagePriceCan: Long,
val messagePriceCan: Long,
val imageUrl: String,
val triggers: List<String>,
val isAdult: Boolean
) {
companion object {
fun fromWithUrl(entity: CharacterImage, signedUrl: String): AdminCharacterImageResponse {
return base(entity, signedUrl)
}
private fun base(entity: CharacterImage, url: String): AdminCharacterImageResponse {
return AdminCharacterImageResponse(
id = entity.id!!,
characterId = entity.chatCharacter.id!!,
imagePriceCan = entity.imagePriceCan,
messagePriceCan = entity.messagePriceCan,
imageUrl = url,
triggers = entity.triggerMappings.map { it.tag.word },
isAdult = entity.isAdult
)
}
}
}

View File

@@ -0,0 +1,78 @@
package kr.co.vividnext.sodalive.admin.chat.character.service
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class AdminChatCharacterService(
private val chatCharacterRepository: ChatCharacterRepository
) {
/**
* 활성화된 캐릭터 목록을 페이징하여 조회
*
* @param pageable 페이징 정보
* @return 페이징된 캐릭터 목록
*/
@Transactional(readOnly = true)
fun getActiveChatCharacters(pageable: Pageable, imageHost: String = ""): ChatCharacterListPageResponse {
// isActive가 true인 캐릭터만 조회
val page = chatCharacterRepository.findByIsActiveTrue(pageable)
// 페이지 정보 생성
val content = page.content.map { ChatCharacterListResponse.from(it, imageHost) }
return ChatCharacterListPageResponse(
totalCount = page.totalElements,
content = content
)
}
/**
* 기본 페이지 요청 생성
*
* @param page 페이지 번호 (0부터 시작)
* @param size 페이지 크기
* @return 페이지 요청 객체
*/
fun createDefaultPageRequest(page: Int = 0, size: Int = 20): PageRequest {
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
}
/**
* 캐릭터 상세 정보 조회
*
* @param characterId 캐릭터 ID
* @param imageHost 이미지 호스트 URL
* @return 캐릭터 상세 정보
* @throws SodaException 캐릭터를 찾을 수 없는 경우
*/
@Transactional(readOnly = true)
fun getChatCharacterDetail(characterId: Long, imageHost: String = ""): ChatCharacterDetailResponse {
val chatCharacter = chatCharacterRepository.findById(characterId)
.orElseThrow { SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: $characterId") }
return ChatCharacterDetailResponse.from(chatCharacter, imageHost)
}
/**
* 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) - 페이징 (기존 사용처 호환용)
*/
@Transactional(readOnly = true)
fun searchCharacters(
searchTerm: String,
pageable: Pageable,
imageHost: String = ""
): Page<ChatCharacterListResponse> {
val characters = chatCharacterRepository.searchCharacters(searchTerm, pageable)
return characters.map { ChatCharacterListResponse.from(it, imageHost) }
}
}

View File

@@ -0,0 +1,30 @@
package kr.co.vividnext.sodalive.admin.chat.dto
import com.fasterxml.jackson.annotation.JsonProperty
/**
* 캐릭터 배너 등록 요청 DTO
*/
data class ChatCharacterBannerRegisterRequest(
// 캐릭터 ID
@JsonProperty("characterId") val characterId: Long
)
/**
* 캐릭터 배너 수정 요청 DTO
*/
data class ChatCharacterBannerUpdateRequest(
// 배너 ID
@JsonProperty("bannerId") val bannerId: Long,
// 캐릭터 ID (변경할 캐릭터)
@JsonProperty("characterId") val characterId: Long? = null
)
/**
* 캐릭터 배너 정렬 순서 일괄 변경 요청 DTO
*/
data class UpdateBannerOrdersRequest(
// 배너 ID 목록 (순서대로 정렬됨)
@JsonProperty("ids") val ids: List<Long>
)

View File

@@ -0,0 +1,32 @@
package kr.co.vividnext.sodalive.admin.chat.dto
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
/**
* 캐릭터 배너 응답 DTO
*/
data class ChatCharacterBannerResponse(
val id: Long,
val imagePath: String,
val characterId: Long,
val characterName: String
) {
companion object {
fun from(banner: ChatCharacterBanner, imageHost: String): ChatCharacterBannerResponse {
return ChatCharacterBannerResponse(
id = banner.id!!,
imagePath = "$imageHost/${banner.imagePath}",
characterId = banner.chatCharacter.id!!,
characterName = banner.chatCharacter.name
)
}
}
}
/**
* 캐릭터 배너 목록 페이지 응답 DTO
*/
data class ChatCharacterBannerListPageResponse(
val totalCount: Long,
val content: List<ChatCharacterBannerResponse>
)

View File

@@ -0,0 +1,199 @@
package kr.co.vividnext.sodalive.admin.chat.original
import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkChatCharacterListPageResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkChatCharacterResponse
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkAssignCharactersRequest
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkPageResponse
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
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.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
/**
* 원작(오리지널 작품) 관리자 API
* - 원작 등록/수정/삭제
* - 원작과 캐릭터 연결(배정) 및 해제
*/
@RestController
@RequestMapping("/admin/chat/original")
@PreAuthorize("hasRole('ADMIN')")
class AdminOriginalWorkController(
private val originalWorkService: AdminOriginalWorkService,
private val s3Uploader: S3Uploader,
@Value("\${cloud.aws.s3.bucket}")
private val s3Bucket: String,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
/**
* 원작 등록
* - 이미지 파일과 JSON 요청을 멀티파트로 받는다.
*/
@PostMapping("/register")
fun register(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String
) = run {
val objectMapper = ObjectMapper()
val request = objectMapper.readValue(requestString, OriginalWorkRegisterRequest::class.java)
// 서비스 계층을 통해 원작을 생성
val saved = originalWorkService.createOriginalWork(request)
// 이미지 업로드 후 이미지 경로 업데이트
val imagePath = uploadImage(saved.id!!, image)
originalWorkService.updateOriginalWorkImage(saved.id!!, imagePath)
ApiResponse.ok(null)
}
/**
* 원작 수정
* - 이미지가 있으면 교체, 없으면 유지
*/
@PutMapping("/update")
fun update(
@RequestPart(value = "image", required = false) image: MultipartFile?,
@RequestPart("request") requestString: String
) = run {
val objectMapper = ObjectMapper()
val request = objectMapper.readValue(requestString, OriginalWorkUpdateRequest::class.java)
// 이미지가 전달된 경우 먼저 업로드하여 경로를 생성
val imagePath = if (image != null && !image.isEmpty) {
uploadImage(request.id, image)
} else {
null
}
originalWorkService.updateOriginalWork(request, imagePath)
ApiResponse.ok(null)
}
/**
* 원작 삭제
*/
@DeleteMapping("/{id}")
fun delete(@PathVariable id: Long) = run {
originalWorkService.deleteOriginalWork(id)
ApiResponse.ok(null)
}
/**
* 원작 목록(페이징)
*/
@GetMapping("/list")
fun list(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = run {
val pageRes = originalWorkService.getOriginalWorkPage(page, size)
val content = pageRes.content.map { OriginalWorkResponse.from(it, imageHost) }
ApiResponse.ok(OriginalWorkPageResponse(totalCount = pageRes.totalElements, content = content))
}
/**
* 원작 검색(관리자)
* - 제목/콘텐츠타입/카테고리 기준 부분 검색, 소프트 삭제 제외
* - 페이징 제거: 전체 목록 반환
*/
@GetMapping("/search")
fun search(
@RequestParam("searchTerm") searchTerm: String
) = run {
val list = originalWorkService.searchOriginalWorksAll(searchTerm)
val content = list.map { OriginalWorkResponse.from(it, imageHost) }
ApiResponse.ok(content)
}
/**
* 원작 상세
*/
@GetMapping("/{id}")
fun detail(@PathVariable id: Long) = run {
ApiResponse.ok(OriginalWorkResponse.from(originalWorkService.getOriginalWork(id), imageHost))
}
/**
* 원작에 기존 캐릭터들을 배정
* - 캐릭터는 하나의 원작에만 속하므로, 해당 캐릭터들의 originalWork를 이 원작으로 설정
*/
@PostMapping("/{id}/assign-characters")
fun assignCharacters(
@PathVariable id: Long,
@RequestBody body: OriginalWorkAssignCharactersRequest
) = run {
originalWorkService.assignCharacters(id, body.characterIds)
ApiResponse.ok(null)
}
/**
* 원작에서 캐릭터들 해제
* - 캐릭터들의 originalWork를 null로 설정
*/
@PostMapping("/{id}/unassign-characters")
fun unassignCharacters(
@PathVariable id: Long,
@RequestBody body: OriginalWorkAssignCharactersRequest
) = run {
originalWorkService.unassignCharacters(id, body.characterIds)
ApiResponse.ok(null)
}
/**
* 관리자용: 지정 원작에 속한 캐릭터 목록 페이징 조회
* - 활성 캐릭터만 포함
* - 응답 항목: 캐릭터 이미지(URL), 이름
*/
@GetMapping("/{id}/characters")
fun listCharactersOfOriginal(
@PathVariable id: Long,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = run {
val pageRes = originalWorkService.getCharactersOfOriginalWorkPage(id, page, size)
val content = pageRes.content.map { OriginalWorkChatCharacterResponse.from(it, imageHost) }
ApiResponse.ok(
OriginalWorkChatCharacterListPageResponse(
totalCount = pageRes.totalElements,
content = content
)
)
}
/** 이미지 업로드 공통 처리 */
private fun uploadImage(originalWorkId: Long, image: MultipartFile): String {
try {
val metadata = ObjectMetadata()
metadata.contentLength = image.size
return s3Uploader.upload(
inputStream = image.inputStream,
bucket = s3Bucket,
filePath = "originals/$originalWorkId/${generateFileName(prefix = "original")}",
metadata = metadata
)
} catch (e: Exception) {
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
}
}
}

View File

@@ -0,0 +1,95 @@
package kr.co.vividnext.sodalive.admin.chat.original.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.chat.original.OriginalWork
/**
* 원작 등록 요청 DTO
*/
data class OriginalWorkRegisterRequest(
@JsonProperty("title") val title: String,
@JsonProperty("contentType") val contentType: String,
@JsonProperty("category") val category: String,
@JsonProperty("isAdult") val isAdult: Boolean = false,
@JsonProperty("description") val description: String = "",
@JsonProperty("originalWork") val originalWork: String? = null,
@JsonProperty("originalLink") val originalLink: String? = null,
@JsonProperty("writer") val writer: String? = null,
@JsonProperty("studio") val studio: String? = null,
@JsonProperty("originalLinks") val originalLinks: List<String>? = null,
@JsonProperty("tags") val tags: List<String>? = null
)
/**
* 원작 수정 요청 DTO (부분 수정 가능)
*/
data class OriginalWorkUpdateRequest(
@JsonProperty("id") val id: Long,
@JsonProperty("title") val title: String? = null,
@JsonProperty("contentType") val contentType: String? = null,
@JsonProperty("category") val category: String? = null,
@JsonProperty("isAdult") val isAdult: Boolean? = null,
@JsonProperty("description") val description: String? = null,
@JsonProperty("originalWork") val originalWork: String? = null,
@JsonProperty("originalLink") val originalLink: String? = null,
@JsonProperty("writer") val writer: String? = null,
@JsonProperty("studio") val studio: String? = null,
@JsonProperty("originalLinks") val originalLinks: List<String>? = null,
@JsonProperty("tags") val tags: List<String>? = null
)
/**
* 원작 상세/목록 응답 DTO
*/
data class OriginalWorkResponse(
val id: Long,
val title: String,
val contentType: String,
val category: String,
val isAdult: Boolean,
val description: String,
val originalWork: String?,
val originalLink: String?,
val writer: String?,
val studio: String?,
val originalLinks: List<String>,
val tags: List<String>,
val imageUrl: String?
) {
companion object {
fun from(entity: OriginalWork, imageHost: String = ""): OriginalWorkResponse {
val fullImagePath = if (entity.imagePath != null && imageHost.isNotEmpty()) {
"$imageHost/${entity.imagePath}"
} else {
entity.imagePath
}
return OriginalWorkResponse(
id = entity.id!!,
title = entity.title,
contentType = entity.contentType,
category = entity.category,
isAdult = entity.isAdult,
description = entity.description,
originalWork = entity.originalWork,
originalLink = entity.originalLink,
writer = entity.writer,
studio = entity.studio,
originalLinks = entity.originalLinks.map { it.url },
tags = entity.tagMappings.map { it.tag.tag },
imageUrl = fullImagePath
)
}
}
}
data class OriginalWorkPageResponse(
val totalCount: Long,
val content: List<OriginalWorkResponse>
)
/**
* 원작-캐릭터 연결/해제 요청 DTO
*/
data class OriginalWorkAssignCharactersRequest(
@JsonProperty("characterIds") val characterIds: List<Long>
)

View File

@@ -0,0 +1,213 @@
package kr.co.vividnext.sodalive.admin.chat.original.service
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.chat.original.OriginalWork
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping
import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* 원작(오리지널 작품) 관련 관리자 서비스
* - 컨트롤러와 레포지토리 사이의 서비스 계층으로 DB 접근을 캡슐화한다.
*/
@Service
class AdminOriginalWorkService(
private val originalWorkRepository: OriginalWorkRepository,
private val chatCharacterRepository: ChatCharacterRepository,
private val originalWorkTagRepository: OriginalWorkTagRepository
) {
/** 원작 등록 (중복 제목 방지 포함) */
@Transactional
fun createOriginalWork(request: OriginalWorkRegisterRequest): OriginalWork {
originalWorkRepository.findByTitleAndIsDeletedFalse(request.title)?.let {
throw SodaException("동일한 제목의 원작이 이미 존재합니다: ${request.title}")
}
val entity = OriginalWork(
title = request.title,
contentType = request.contentType,
category = request.category,
isAdult = request.isAdult,
description = request.description,
originalWork = request.originalWork,
originalLink = request.originalLink,
writer = request.writer,
studio = request.studio
)
// 링크 리스트 생성
request.originalLinks?.filter { it.isNotBlank() }?.forEach { link ->
entity.originalLinks.add(kr.co.vividnext.sodalive.chat.original.OriginalWorkLink(url = link, originalWork = entity))
}
// 태그 매핑 생성 (기존 태그 재사용)
request.tags?.let { tags ->
val normalized = tags.map { it.trim() }.filter { it.isNotBlank() }.toSet()
normalized.forEach { t ->
val tagEntity = originalWorkTagRepository.findByTag(t) ?: originalWorkTagRepository.save(OriginalWorkTag(t))
entity.tagMappings.add(OriginalWorkTagMapping(originalWork = entity, tag = tagEntity))
}
}
return originalWorkRepository.save(entity)
}
/** 원작 수정 (이미지 경로 포함 선택적 변경) */
@Transactional
fun updateOriginalWork(request: OriginalWorkUpdateRequest, imagePath: String? = null): OriginalWork {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(request.id)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
request.title?.let { ow.title = it }
request.contentType?.let { ow.contentType = it }
request.category?.let { ow.category = it }
request.isAdult?.let { ow.isAdult = it }
request.description?.let { ow.description = it }
request.originalWork?.let { ow.originalWork = it }
request.originalLink?.let { ow.originalLink = it }
request.writer?.let { ow.writer = it }
request.studio?.let { ow.studio = it }
// 링크 리스트가 전달되면 기존 것을 교체
request.originalLinks?.let { links ->
ow.originalLinks.clear()
links.filter { it.isNotBlank() }.forEach { link ->
ow.originalLinks.add(kr.co.vividnext.sodalive.chat.original.OriginalWorkLink(url = link, originalWork = ow))
}
}
// 태그 변경사항만 반영 (요청이 null이면 변경 없음)
request.tags?.let { tags ->
val normalized = tags.map { it.trim() }.filter { it.isNotBlank() }.toSet()
val current = ow.tagMappings.map { it.tag.tag }.toSet()
val toAdd = normalized.minus(current)
val toRemove = current.minus(normalized)
if (toRemove.isNotEmpty()) {
val itr = ow.tagMappings.iterator()
while (itr.hasNext()) {
val m = itr.next()
if (toRemove.contains(m.tag.tag)) {
itr.remove() // orphanRemoval=true로 매핑 삭제
}
}
}
if (toAdd.isNotEmpty()) {
toAdd.forEach { t ->
val tagEntity = originalWorkTagRepository.findByTag(t) ?: originalWorkTagRepository.save(OriginalWorkTag(t))
ow.tagMappings.add(OriginalWorkTagMapping(originalWork = ow, tag = tagEntity))
}
}
}
if (imagePath != null) {
ow.imagePath = imagePath
}
return originalWorkRepository.save(ow)
}
/** 원작 이미지 경로만 별도 갱신 */
@Transactional
fun updateOriginalWorkImage(originalWorkId: Long, imagePath: String): OriginalWork {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
ow.imagePath = imagePath
return originalWorkRepository.save(ow)
}
/** 원작 삭제 (소프트 삭제) */
@Transactional
fun deleteOriginalWork(id: Long) {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(id)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다: $id") }
ow.isDeleted = true
originalWorkRepository.save(ow)
}
/** 원작 상세 조회 (소프트 삭제 제외) */
@Transactional(readOnly = true)
fun getOriginalWork(id: Long): OriginalWork {
return originalWorkRepository.findByIdAndIsDeletedFalse(id)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
}
/** 원작 페이징 조회 */
@Transactional(readOnly = true)
fun getOriginalWorkPage(page: Int, size: Int): Page<OriginalWork> {
val safePage = if (page < 0) 0 else page
val safeSize = when {
size <= 0 -> 20
size > 100 -> 100
else -> size
}
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
return originalWorkRepository.findByIsDeletedFalse(pageable)
}
/** 지정 원작에 속한 활성 캐릭터 페이징 조회 (최신순) */
@Transactional(readOnly = true)
fun getCharactersOfOriginalWorkPage(originalWorkId: Long, page: Int, size: Int): Page<ChatCharacter> {
// 원작 존재 및 소프트 삭제 여부 확인
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
val safePage = if (page < 0) 0 else page
val safeSize = when {
size <= 0 -> 20
size > 100 -> 100
else -> size
}
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrue(originalWorkId, pageable)
}
/** 원작 검색 (제목/콘텐츠타입/카테고리, 소프트 삭제 제외) - 무페이징 */
@Transactional(readOnly = true)
fun searchOriginalWorksAll(searchTerm: String): List<OriginalWork> {
return originalWorkRepository.searchNoPaging(searchTerm)
}
/** 원작에 기존 캐릭터들을 배정 */
@Transactional
fun assignCharacters(originalWorkId: Long, characterIds: List<Long>) {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
if (characterIds.isEmpty()) return
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
characters.forEach { it.originalWork = ow }
chatCharacterRepository.saveAll(characters)
}
/** 원작에서 캐릭터들 해제 */
@Transactional
fun unassignCharacters(originalWorkId: Long, characterIds: List<Long>) {
// 원작 존재 확인 (소프트 삭제 제외)
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
if (characterIds.isEmpty()) return
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
characters.forEach { it.originalWork = null }
chatCharacterRepository.saveAll(characters)
}
/** 단일 캐릭터를 지정 원작에 배정 */
@Transactional
fun assignOneCharacter(originalWorkId: Long, characterId: Long) {
val character = chatCharacterRepository.findById(characterId)
.orElseThrow { SodaException("해당 캐릭터를 찾을 수 없습니다") }
if (originalWorkId == 0L) {
character.originalWork = null
} else {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
character.originalWork = ow
}
chatCharacterRepository.save(character)
}
}

View File

@@ -42,6 +42,9 @@ class AdminContentController(private val service: AdminContentService) {
fun modifyAudioContent(
@RequestBody request: UpdateAdminContentRequest
) = ApiResponse.ok(service.updateAudioContent(request))
@GetMapping("/main/tab")
fun getContentMainTabList() = ApiResponse.ok(service.getContentMainTabList())
}
enum class ContentReleaseStatus {

View File

@@ -32,6 +32,7 @@ interface AdminAudioContentQueryRepository {
): List<GetAdminContentListItem>
fun getHashTagList(audioContentId: Long): List<String>
fun findByIdAndActiveTrue(audioContentId: Long): AudioContent?
}
class AdminAudioContentQueryRepositoryImpl(
@@ -139,10 +140,21 @@ class AdminAudioContentQueryRepositoryImpl(
audioContent.duration.isNotNull
.and(audioContent.member.isNotNull)
.and(audioContentHashTag.audioContent.id.eq(audioContentId))
.and(audioContentHashTag.isActive.isTrue)
)
.fetch()
}
override fun findByIdAndActiveTrue(audioContentId: Long): AudioContent? {
return queryFactory
.selectFrom(audioContent)
.where(
audioContent.id.eq(audioContentId),
audioContent.isActive.isTrue
)
.fetchFirst()
}
private fun formattedDateExpression(
dateTime: DateTimePath<LocalDateTime>,
format: String = "%Y-%m-%d"

View File

@@ -1,9 +1,11 @@
package kr.co.vividnext.sodalive.admin.content
import kr.co.vividnext.sodalive.admin.content.curation.AdminContentCurationRepository
import kr.co.vividnext.sodalive.admin.content.tab.AdminContentMainTabRepository
import kr.co.vividnext.sodalive.admin.content.theme.AdminContentThemeRepository
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.main.tab.GetContentMainTabItem
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
@@ -14,7 +16,8 @@ class AdminContentService(
private val repository: AdminContentRepository,
private val themeRepository: AdminContentThemeRepository,
private val audioContentCloudFront: AudioContentCloudFront,
private val curationRepository: AdminContentCurationRepository
private val curationRepository: AdminContentCurationRepository,
private val contentMainTabRepository: AdminContentMainTabRepository
) {
fun getAudioContentList(status: ContentReleaseStatus, pageable: Pageable): GetAdminContentListResponse {
val totalCount = repository.getAudioContentTotalCount(status = status)
@@ -118,4 +121,8 @@ class AdminContentService(
audioContent.theme = theme
}
}
fun getContentMainTabList(): List<GetContentMainTabItem> {
return contentMainTabRepository.findAllByActiveIsTrue()
}
}

View File

@@ -7,6 +7,7 @@ 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.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@@ -33,5 +34,7 @@ class AdminContentBannerController(private val service: AdminContentBannerServic
) = ApiResponse.ok(service.updateBannerOrders(request.ids), "수정되었습니다.")
@GetMapping
fun getAudioContentMainBannerList() = ApiResponse.ok(service.getAudioContentMainBannerList())
fun getAudioContentMainBannerList(
@RequestParam(value = "tabId", required = false) tabId: Long? = null
) = ApiResponse.ok(service.getAudioContentMainBannerList(tabId = tabId))
}

View File

@@ -3,6 +3,8 @@ package kr.co.vividnext.sodalive.admin.content.banner
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
import kr.co.vividnext.sodalive.content.main.tab.QAudioContentMainTab.audioContentMainTab
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
import kr.co.vividnext.sodalive.event.QEvent.event
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.beans.factory.annotation.Value
@@ -13,7 +15,7 @@ import org.springframework.stereotype.Repository
interface AdminContentBannerRepository : JpaRepository<AudioContentBanner, Long>, AdminContentBannerQueryRepository
interface AdminContentBannerQueryRepository {
fun getAudioContentMainBannerList(): List<GetAdminContentBannerResponse>
fun getAudioContentMainBannerList(tabId: Long = 1): List<GetAdminContentBannerResponse>
}
class AdminContentBannerQueryRepositoryImpl(
@@ -21,17 +23,28 @@ class AdminContentBannerQueryRepositoryImpl(
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) : AdminContentBannerQueryRepository {
override fun getAudioContentMainBannerList(): List<GetAdminContentBannerResponse> {
override fun getAudioContentMainBannerList(tabId: Long): List<GetAdminContentBannerResponse> {
var where = audioContentBanner.isActive.isTrue
where = if (tabId <= 1L) {
where.and(audioContentMainTab.id.isNull)
} else {
where.and(audioContentMainTab.id.eq(tabId))
}
return queryFactory
.select(
QGetAdminContentBannerResponse(
audioContentBanner.id,
audioContentBanner.tab.id.coalesce(1),
audioContentBanner.type,
audioContentBanner.thumbnailImage.prepend("/").prepend(cloudFrontHost),
audioContentBanner.event.id,
audioContentBanner.event.thumbnailImage,
audioContentBanner.creator.id,
audioContentBanner.creator.nickname,
audioContentBanner.series.id,
audioContentBanner.series.title,
audioContentBanner.link,
audioContentBanner.isAdult
)
@@ -39,7 +52,9 @@ class AdminContentBannerQueryRepositoryImpl(
.from(audioContentBanner)
.leftJoin(audioContentBanner.event, event)
.leftJoin(audioContentBanner.creator, member)
.where(audioContentBanner.isActive.isTrue)
.leftJoin(audioContentBanner.series, series)
.leftJoin(audioContentBanner.tab, audioContentMainTab)
.where(where)
.orderBy(audioContentBanner.orders.asc())
.fetch()
}

View File

@@ -1,6 +1,8 @@
package kr.co.vividnext.sodalive.admin.content.banner
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
import kr.co.vividnext.sodalive.admin.content.tab.AdminContentMainTabRepository
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
@@ -19,7 +21,9 @@ class AdminContentBannerService(
private val s3Uploader: S3Uploader,
private val repository: AdminContentBannerRepository,
private val memberRepository: MemberRepository,
private val seriesRepository: AdminContentSeriesRepository,
private val eventRepository: EventRepository,
private val contentMainTabRepository: AdminContentMainTabRepository,
private val objectMapper: ObjectMapper,
@Value("\${cloud.aws.s3.bucket}")
@@ -32,6 +36,10 @@ class AdminContentBannerService(
throw SodaException("크리에이터를 선택하세요.")
}
if (request.type == AudioContentBannerType.SERIES && request.seriesId == null) {
throw SodaException("시리즈를 선택하세요.")
}
if (request.type == AudioContentBannerType.LINK && request.link == null) {
throw SodaException("링크 url을 입력하세요.")
}
@@ -52,11 +60,25 @@ class AdminContentBannerService(
null
}
val series = if (request.seriesId != null && request.seriesId > 0) {
seriesRepository.findByIdOrNull(request.seriesId)
} else {
null
}
val tab = if (request.tabId !== null) {
contentMainTabRepository.findByIdOrNull(request.tabId)
} else {
null
}
val audioContentBanner = AudioContentBanner(type = request.type)
audioContentBanner.link = request.link
audioContentBanner.isAdult = request.isAdult
audioContentBanner.event = event
audioContentBanner.creator = creator
audioContentBanner.series = series
audioContentBanner.tab = tab
repository.save(audioContentBanner)
val fileName = generateFileName()
@@ -96,35 +118,57 @@ class AdminContentBannerService(
audioContentBanner.creator = null
audioContentBanner.event = null
audioContentBanner.link = null
audioContentBanner.series = null
if (request.type == AudioContentBannerType.CREATOR) {
if (request.creatorId != null) {
val creator = memberRepository.findByIdOrNull(request.creatorId)
?: throw SodaException("크리에이터를 선택하세요.")
when (request.type) {
AudioContentBannerType.EVENT -> {
if (request.eventId != null) {
val event = eventRepository.findByIdOrNull(request.eventId)
?: throw SodaException("이벤트를 선택하세요.")
audioContentBanner.creator = creator
} else {
throw SodaException("크리에이터를 선택하세요.")
audioContentBanner.event = event
} else {
throw SodaException("이벤트를 선택하세요.")
}
}
} else if (request.type == AudioContentBannerType.LINK) {
if (request.link != null) {
audioContentBanner.link = request.link
} else {
throw SodaException("링크 url을 입력하세요.")
}
} else if (request.type == AudioContentBannerType.EVENT) {
if (request.eventId != null) {
val event = eventRepository.findByIdOrNull(request.eventId)
?: throw SodaException("이벤트를 선택하세요.")
audioContentBanner.event = event
} else {
throw SodaException("이벤트를 선택하세요.")
AudioContentBannerType.CREATOR -> {
if (request.creatorId != null) {
val creator = memberRepository.findByIdOrNull(request.creatorId)
?: throw SodaException("크리에이터를 선택하세요.")
audioContentBanner.creator = creator
} else {
throw SodaException("크리에이터를 선택하세요.")
}
}
AudioContentBannerType.LINK -> {
if (request.link != null) {
audioContentBanner.link = request.link
} else {
throw SodaException("링크 url을 입력하세요.")
}
}
AudioContentBannerType.SERIES -> {
if (request.seriesId != null) {
val series = seriesRepository.findByIdOrNull(request.seriesId)
?: throw SodaException("시리즈를 선택하세요.")
audioContentBanner.series = series
} else {
throw SodaException("시리즈를 선택하세요.")
}
}
}
audioContentBanner.type = request.type
}
if (request.tabId !== null) {
audioContentBanner.tab = contentMainTabRepository.findByIdOrNull(request.tabId)
}
}
@Transactional
@@ -138,7 +182,7 @@ class AdminContentBannerService(
}
}
fun getAudioContentMainBannerList(): List<GetAdminContentBannerResponse> {
return repository.getAudioContentMainBannerList()
fun getAudioContentMainBannerList(tabId: Long?): List<GetAdminContentBannerResponse> {
return repository.getAudioContentMainBannerList(tabId = tabId ?: 1)
}
}

View File

@@ -4,8 +4,10 @@ import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
data class CreateContentBannerRequest(
val type: AudioContentBannerType,
val tabId: Long?,
val eventId: Long?,
val creatorId: Long?,
val seriesId: Long?,
val link: String?,
val isAdult: Boolean
)

View File

@@ -5,12 +5,15 @@ import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
data class GetAdminContentBannerResponse @QueryProjection constructor(
val id: Long,
val tabId: Long?,
val type: AudioContentBannerType,
val thumbnailImageUrl: String,
val eventId: Long?,
val eventThumbnailImage: String?,
val creatorId: Long?,
val creatorNickname: String?,
val seriesId: Long?,
val seriesTitle: String?,
val link: String?,
val isAdult: Boolean
)

View File

@@ -5,8 +5,10 @@ import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
data class UpdateContentBannerRequest(
val id: Long,
val type: AudioContentBannerType?,
val tabId: Long?,
val eventId: Long?,
val creatorId: Long?,
val seriesId: Long?,
val link: String?,
val isAdult: Boolean?,
val isActive: Boolean?

View File

@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.admin.content.curation
data class AddItemToCurationRequest(
val curationId: Long,
val itemIdList: List<Long>
)

View File

@@ -7,6 +7,7 @@ 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.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@@ -29,5 +30,39 @@ class AdminContentCurationController(private val service: AdminContentCurationSe
) = ApiResponse.ok(service.updateContentCurationOrders(request.ids), "수정되었습니다.")
@GetMapping
fun getContentCurationList() = ApiResponse.ok(service.getContentCurationList())
fun getContentCurationList(
@RequestParam tabId: Long
) = ApiResponse.ok(service.getContentCurationList(tabId = tabId))
@GetMapping("/items")
fun getCurationItems(
@RequestParam curationId: Long
) = ApiResponse.ok(service.getCurationItem(curationId = curationId))
@GetMapping("/search/content")
fun searchCurationContentItem(
@RequestParam curationId: Long,
@RequestParam searchWord: String
) = ApiResponse.ok(service.searchCurationContentItem(curationId, searchWord))
@GetMapping("/search/series")
fun searchCurationSeriesItem(
@RequestParam curationId: Long,
@RequestParam searchWord: String
) = ApiResponse.ok(service.searchCurationSeriesItem(curationId, searchWord))
@PostMapping("/add/item")
fun addItemToCuration(
@RequestBody request: AddItemToCurationRequest
) = ApiResponse.ok(service.addItemToCuration(request), "큐레이션 아이템을 등록했습니다.")
@PutMapping("/remove/item")
fun removeItemInCuration(
@RequestBody request: RemoveItemInCurationRequest
) = ApiResponse.ok(service.removeItemInCuration(request), "큐레이션 아이템을 제거했습니다.")
@PutMapping("/orders/item")
fun updateItemInCurationOrders(
@RequestBody request: UpdateCurationItemOrdersRequest
) = ApiResponse.ok(service.updateItemInCurationOrders(request), "수정되었습니다.")
}

View File

@@ -0,0 +1,106 @@
package kr.co.vividnext.sodalive.admin.content.curation
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationItem
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCuration.audioContentCuration
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCurationItem.audioContentCurationItem
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.jpa.repository.JpaRepository
interface AdminContentCurationItemRepository :
JpaRepository<AudioContentCurationItem, Long>,
AdminContentCurationItemQueryRepository
interface AdminContentCurationItemQueryRepository {
fun findByCurationIdAndSeriesId(curationId: Long, seriesId: Long?): AudioContentCurationItem?
fun findByCurationIdAndContentId(curationId: Long, contentId: Long?): AudioContentCurationItem?
fun findByCurationIdAndItemId(curationId: Long, itemId: Long): AudioContentCurationItem?
fun getAudioContentCurationItemList(curationId: Long): List<GetCurationItemResponse>
fun getAudioContentCurationSeriesItemList(curationId: Long): List<GetCurationItemResponse>
}
class AdminContentCurationItemQueryRepositoryImpl(
val queryFactory: JPAQueryFactory,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) : AdminContentCurationItemQueryRepository {
override fun findByCurationIdAndSeriesId(curationId: Long, seriesId: Long?): AudioContentCurationItem? {
return queryFactory
.selectFrom(audioContentCurationItem)
.innerJoin(audioContentCurationItem.curation, audioContentCuration)
.innerJoin(audioContentCurationItem.series, series)
.where(
audioContentCurationItem.curation.id.eq(curationId),
audioContentCurationItem.series.id.eq(seriesId)
)
.fetchFirst()
}
override fun findByCurationIdAndContentId(curationId: Long, contentId: Long?): AudioContentCurationItem? {
return queryFactory
.selectFrom(audioContentCurationItem)
.innerJoin(audioContentCurationItem.curation, audioContentCuration)
.innerJoin(audioContentCurationItem.content, audioContent)
.where(
audioContentCurationItem.curation.id.eq(curationId),
audioContentCurationItem.content.id.eq(contentId)
)
.fetchFirst()
}
override fun findByCurationIdAndItemId(curationId: Long, itemId: Long): AudioContentCurationItem? {
return queryFactory.selectFrom(audioContentCurationItem)
.innerJoin(audioContentCurationItem.curation, audioContentCuration)
.where(audioContentCuration.id.eq(curationId), audioContentCurationItem.id.eq(itemId))
.fetchFirst()
}
override fun getAudioContentCurationItemList(curationId: Long): List<GetCurationItemResponse> {
return queryFactory
.select(
QGetCurationItemResponse(
audioContentCurationItem.id,
audioContent.title,
audioContent.detail,
audioContent.coverImage.prepend("/").prepend(imageHost),
audioContent.member.nickname.coalesce(""),
audioContent.isAdult
)
)
.from(audioContentCurationItem)
.innerJoin(audioContentCurationItem.curation, audioContentCuration)
.innerJoin(audioContentCurationItem.content, audioContent)
.where(
audioContentCuration.id.eq(curationId),
audioContentCurationItem.isActive.isTrue
)
.orderBy(audioContentCurationItem.orders.asc())
.fetch()
}
override fun getAudioContentCurationSeriesItemList(curationId: Long): List<GetCurationItemResponse> {
return queryFactory
.select(
QGetCurationItemResponse(
audioContentCurationItem.id,
series.title,
series.introduction,
series.coverImage.prepend("/").prepend(imageHost),
series.member.nickname.coalesce(""),
series.isAdult
)
)
.from(audioContentCurationItem)
.innerJoin(audioContentCurationItem.curation, audioContentCuration)
.innerJoin(audioContentCurationItem.series, series)
.where(
audioContentCuration.id.eq(curationId),
audioContentCurationItem.isActive.isTrue
)
.orderBy(audioContentCurationItem.orders.asc())
.fetch()
}
}

View File

@@ -1,8 +1,13 @@
package kr.co.vividnext.sodalive.admin.content.curation
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCuration
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCuration.audioContentCuration
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCurationItem.audioContentCurationItem
import kr.co.vividnext.sodalive.content.main.tab.QAudioContentMainTab.audioContentMainTab
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@@ -12,26 +17,37 @@ interface AdminContentCurationRepository :
AdminContentCurationQueryRepository
interface AdminContentCurationQueryRepository {
fun getAudioContentCurationList(): List<GetAdminContentCurationResponse>
fun getAudioContentCurationList(tabId: Long): List<GetAdminContentCurationResponse>
fun findByIdAndActive(id: Long): AudioContentCuration?
fun searchCurationContentItem(curationId: Long, searchWord: String): List<SearchCurationItemResponse>
fun searchCurationSeriesItem(curationId: Long, searchWord: String): List<SearchCurationItemResponse>
}
@Repository
class AdminContentCurationQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
private val queryFactory: JPAQueryFactory,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) : AdminContentCurationQueryRepository {
override fun getAudioContentCurationList(): List<GetAdminContentCurationResponse> {
override fun getAudioContentCurationList(tabId: Long): List<GetAdminContentCurationResponse> {
return queryFactory
.select(
QGetAdminContentCurationResponse(
audioContentCuration.id,
audioContentMainTab.id,
audioContentCuration.title,
audioContentCuration.description,
audioContentCuration.isAdult
audioContentCuration.isAdult,
audioContentCuration.isSeries
)
)
.from(audioContentCuration)
.where(audioContentCuration.isActive.isTrue)
.innerJoin(audioContentCuration.tab, audioContentMainTab)
.where(
audioContentCuration.isActive.isTrue,
audioContentMainTab.id.eq(tabId)
)
.orderBy(audioContentCuration.orders.asc())
.fetch()
}
@@ -45,4 +61,62 @@ class AdminContentCurationQueryRepositoryImpl(
)
.fetchFirst()
}
override fun searchCurationContentItem(
curationId: Long,
searchWord: String
): List<SearchCurationItemResponse> {
return queryFactory
.select(
QSearchCurationItemResponse(
audioContent.id,
audioContent.title,
audioContent.coverImage.prepend("/").prepend(imageHost)
)
)
.from(audioContent)
.leftJoin(audioContentCurationItem)
.on(
audioContent.id.eq(audioContentCurationItem.content.id)
.and(audioContentCurationItem.curation.id.eq(curationId))
)
.where(
audioContent.duration.isNotNull
.and(audioContent.member.isNotNull)
.and(audioContent.isActive.isTrue)
.and(audioContent.title.contains(searchWord))
.and(audioContentCurationItem.id.isNull)
)
.fetch()
}
override fun searchCurationSeriesItem(
curationId: Long,
searchWord: String
): List<SearchCurationItemResponse> {
return queryFactory
.select(
QSearchCurationItemResponse(
series.id,
series.title,
series.coverImage.prepend("/").prepend(imageHost)
)
)
.from(series)
.leftJoin(audioContentCurationItem)
.on(
series.id.eq(audioContentCurationItem.series.id)
.and(audioContentCurationItem.curation.id.eq(curationId))
)
.where(
series.isActive.isTrue
.and(series.member.isNotNull)
.and(series.title.contains(searchWord))
.and(
audioContentCurationItem.id.isNull
.or(audioContentCurationItem.isActive.isFalse)
)
)
.fetch()
}
}

View File

@@ -1,24 +1,37 @@
package kr.co.vividnext.sodalive.admin.content.curation
import kr.co.vividnext.sodalive.admin.content.AdminContentRepository
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
import kr.co.vividnext.sodalive.admin.content.tab.AdminContentMainTabRepository
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCuration
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationItem
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class AdminContentCurationService(
private val repository: AdminContentCurationRepository
private val repository: AdminContentCurationRepository,
private val contentMainTabRepository: AdminContentMainTabRepository,
private val seriesRepository: AdminContentSeriesRepository,
private val contentRepository: AdminContentRepository,
private val contentCurationItemRepository: AdminContentCurationItemRepository
) {
@Transactional
fun createContentCuration(request: CreateContentCurationRequest) {
repository.save(
AudioContentCuration(
title = request.title,
description = request.description,
isAdult = request.isAdult
)
val tab = contentMainTabRepository.findByIdOrNull(request.tabId)
?: throw SodaException("잘못된 요청입니다.")
val curation = AudioContentCuration(
title = request.title,
description = request.description,
isAdult = request.isAdult,
isSeries = request.isSeries
)
curation.tab = tab
repository.save(curation)
}
@Transactional
@@ -41,6 +54,18 @@ class AdminContentCurationService(
if (request.isActive != null) {
audioContentCuration.isActive = request.isActive
}
if (request.isSeries != null) {
audioContentCuration.isSeries = request.isSeries
}
if (request.tabId != null) {
val tab = contentMainTabRepository.findByIdOrNull(request.tabId)
if (tab != null) {
audioContentCuration.tab = tab
}
}
}
@Transactional
@@ -54,7 +79,90 @@ class AdminContentCurationService(
}
}
fun getContentCurationList(): List<GetAdminContentCurationResponse> {
return repository.getAudioContentCurationList()
fun getContentCurationList(tabId: Long): List<GetAdminContentCurationResponse> {
return repository.getAudioContentCurationList(tabId = tabId)
}
fun getCurationItem(curationId: Long): List<GetCurationItemResponse> {
val curation = repository.findByIdOrNull(curationId)
?: throw SodaException("잘못된 요청입니다.")
return if (curation.isSeries) {
contentCurationItemRepository.getAudioContentCurationSeriesItemList(curationId)
} else {
contentCurationItemRepository.getAudioContentCurationItemList(curationId)
}
}
fun searchCurationContentItem(curationId: Long, searchWord: String): List<SearchCurationItemResponse> {
return repository.searchCurationContentItem(curationId, searchWord)
}
fun searchCurationSeriesItem(curationId: Long, searchWord: String): List<SearchCurationItemResponse> {
return repository.searchCurationSeriesItem(curationId, searchWord)
}
@Transactional
fun addItemToCuration(request: AddItemToCurationRequest) {
// 큐레이션 조회
val audioContentCuration = repository.findByIdOrNull(id = request.curationId)
?: throw SodaException("잘못된 요청입니다.")
if (audioContentCuration.isSeries) {
request.itemIdList.forEach { seriesId ->
val series = seriesRepository.findByIdAndActiveTrue(seriesId)
if (series != null) {
val item = contentCurationItemRepository.findByCurationIdAndSeriesId(
curationId = request.curationId,
seriesId = series.id
) ?: AudioContentCurationItem()
item.curation = audioContentCuration
item.series = series
item.isActive = true
contentCurationItemRepository.save(item)
}
}
} else {
request.itemIdList.forEach { contentId ->
val audioContent = contentRepository.findByIdAndActiveTrue(contentId)
if (audioContent != null) {
val item = contentCurationItemRepository.findByCurationIdAndContentId(
curationId = request.curationId,
contentId = audioContent.id
) ?: AudioContentCurationItem()
item.curation = audioContentCuration
item.content = audioContent
item.isActive = true
contentCurationItemRepository.save(item)
}
}
}
}
@Transactional
fun removeItemInCuration(request: RemoveItemInCurationRequest) {
val audioContentCurationItem = contentCurationItemRepository.findByCurationIdAndItemId(
curationId = request.curationId,
itemId = request.itemId
)
audioContentCurationItem?.isActive = false
}
@Transactional
fun updateItemInCurationOrders(request: UpdateCurationItemOrdersRequest) {
val ids = request.itemIds
for (index in ids.indices) {
val item = contentCurationItemRepository.findByCurationIdAndItemId(
curationId = request.curationId,
itemId = ids[index]
)
if (item != null) {
item.orders = index + 1
}
}
}
}

View File

@@ -1,16 +1,20 @@
package kr.co.vividnext.sodalive.admin.content.curation
data class CreateContentCurationRequest(
val tabId: Long,
val title: String,
val description: String,
val isAdult: Boolean
val isAdult: Boolean,
val isSeries: Boolean
)
data class UpdateContentCurationRequest(
val id: Long,
val tabId: Long?,
val title: String?,
val description: String?,
val isAdult: Boolean?,
val isSeries: Boolean?,
val isActive: Boolean?
)

View File

@@ -4,7 +4,9 @@ import com.querydsl.core.annotations.QueryProjection
data class GetAdminContentCurationResponse @QueryProjection constructor(
val id: Long,
val tabId: Long,
val title: String,
val description: String,
val isAdult: Boolean
val isAdult: Boolean,
val isSeries: Boolean
)

View File

@@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.admin.content.curation
import com.querydsl.core.annotations.QueryProjection
data class GetCurationItemResponse @QueryProjection constructor(
val id: Long,
val title: String,
val desc: String,
val coverImageUrl: String,
val creatorNickname: String,
val isAdult: Boolean
)

View File

@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.admin.content.curation
data class RemoveItemInCurationRequest(
val curationId: Long,
val itemId: Long
)

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.admin.content.curation
import com.querydsl.core.annotations.QueryProjection
data class SearchCurationItemResponse @QueryProjection constructor(
val id: Long,
val title: String,
val coverImageUrl: String
)

View File

@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.admin.content.curation
data class UpdateCurationItemOrdersRequest(
val curationId: Long,
val itemIds: List<Long>
)

View File

@@ -0,0 +1,72 @@
package kr.co.vividnext.sodalive.admin.content.curation.tag
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.main.curation.tag.ContentHashTagCurationItem
import kr.co.vividnext.sodalive.content.main.curation.tag.QContentHashTagCuration.contentHashTagCuration
import kr.co.vividnext.sodalive.content.main.curation.tag.QContentHashTagCurationItem.contentHashTagCurationItem
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.jpa.repository.JpaRepository
interface AdminContentHashTagCurationItemRepository :
JpaRepository<ContentHashTagCurationItem, Long>,
AdminContentHashTagCurationItemQueryRepository
interface AdminContentHashTagCurationItemQueryRepository {
fun getContentHashTagCurationItemList(curationId: Long): List<GetAdminHashTagCurationItemResponse>
fun findByCurationIdAndContentId(curationId: Long, contentId: Long?): ContentHashTagCurationItem?
fun findByCurationIdAndItemId(curationId: Long, itemId: Long): ContentHashTagCurationItem?
}
class AdminContentHashTagCurationItemQueryRepositoryImpl(
val queryFactory: JPAQueryFactory,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) : AdminContentHashTagCurationItemQueryRepository {
override fun getContentHashTagCurationItemList(curationId: Long): List<GetAdminHashTagCurationItemResponse> {
return queryFactory
.select(
QGetAdminHashTagCurationItemResponse(
contentHashTagCurationItem.id,
audioContent.title,
audioContent.detail,
audioContent.coverImage.prepend("/").prepend(imageHost),
audioContent.member.nickname.coalesce(""),
audioContent.isAdult
)
)
.from(contentHashTagCurationItem)
.innerJoin(contentHashTagCurationItem.curation, contentHashTagCuration)
.innerJoin(contentHashTagCurationItem.content, audioContent)
.where(
contentHashTagCuration.id.eq(curationId),
contentHashTagCurationItem.isActive.isTrue,
audioContent.isActive.isTrue
)
.orderBy(contentHashTagCurationItem.orders.asc())
.fetch()
}
override fun findByCurationIdAndContentId(curationId: Long, contentId: Long?): ContentHashTagCurationItem? {
return queryFactory
.selectFrom(contentHashTagCurationItem)
.innerJoin(contentHashTagCurationItem.curation, contentHashTagCuration)
.innerJoin(contentHashTagCurationItem.content, audioContent)
.where(
contentHashTagCuration.id.eq(curationId),
audioContent.id.eq(contentId)
)
.fetchFirst()
}
override fun findByCurationIdAndItemId(curationId: Long, itemId: Long): ContentHashTagCurationItem? {
return queryFactory.selectFrom(contentHashTagCurationItem)
.innerJoin(contentHashTagCurationItem.curation, contentHashTagCuration)
.where(
contentHashTagCuration.id.eq(curationId),
contentHashTagCurationItem.id.eq(itemId)
)
.fetchFirst()
}
}

View File

@@ -0,0 +1,63 @@
package kr.co.vividnext.sodalive.admin.content.curation.tag
import kr.co.vividnext.sodalive.admin.content.curation.AddItemToCurationRequest
import kr.co.vividnext.sodalive.admin.content.curation.RemoveItemInCurationRequest
import kr.co.vividnext.sodalive.admin.content.curation.UpdateCurationItemOrdersRequest
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.security.access.prepost.PreAuthorize
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.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/admin/audio-content/tag/curation")
@PreAuthorize("hasRole('ADMIN')")
class AdminHashTagCurationController(private val service: AdminHashTagCurationService) {
@GetMapping
fun getContentHashTagCurationList() = ApiResponse.ok(service.getContentHashTagCurationList())
@PostMapping
fun createContentHashTagCuration(
@RequestBody request: CreateContentHashTagCurationRequest
) = ApiResponse.ok(service.createContentHashTagCuration(request))
@PutMapping
fun updateContentHashTagCuration(
@RequestBody request: UpdateContentHashTagCurationRequest
) = ApiResponse.ok(service.updateContentHashTagCuration(request))
@PutMapping("/orders")
fun updateContentHashTagCurationOrders(
@RequestBody request: UpdateContentHashTagCurationOrderRequest
) = ApiResponse.ok(service.updateContentHashTagCurationOrders(request.ids), "수정되었습니다.")
@GetMapping("/items")
fun getHashTagCurationItemList(
@RequestParam curationId: Long
) = ApiResponse.ok(service.getHashTagCurationItemList(curationId = curationId))
@GetMapping("/search/content")
fun searchHashTagCurationContentItem(
@RequestParam curationId: Long,
@RequestParam searchWord: String
) = ApiResponse.ok(service.searchHashTagCurationContentItem(curationId, searchWord))
@PostMapping("/add/item")
fun addItemToHashTagCuration(
@RequestBody request: AddItemToCurationRequest
) = ApiResponse.ok(service.addItemToHashTagCuration(request), "큐레이션 아이템을 등록했습니다.")
@PutMapping("/remove/item")
fun removeItemInHashTagCuration(
@RequestBody request: RemoveItemInCurationRequest
) = ApiResponse.ok(service.removeItemInHashTagCuration(request), "큐레이션 아이템을 제거했습니다.")
@PutMapping("/orders/item")
fun updateItemInHashTagCurationOrders(
@RequestBody request: UpdateCurationItemOrdersRequest
) = ApiResponse.ok(service.updateItemInHashTagCurationOrders(request), "수정되었습니다.")
}

View File

@@ -0,0 +1,87 @@
package kr.co.vividnext.sodalive.admin.content.curation.tag
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.admin.content.curation.QSearchCurationItemResponse
import kr.co.vividnext.sodalive.admin.content.curation.SearchCurationItemResponse
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.main.curation.tag.ContentHashTagCuration
import kr.co.vividnext.sodalive.content.main.curation.tag.QContentHashTagCuration.contentHashTagCuration
import kr.co.vividnext.sodalive.content.main.curation.tag.QContentHashTagCurationItem.contentHashTagCurationItem
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
interface AdminHashTagCurationRepository :
JpaRepository<ContentHashTagCuration, Long>,
AdminHashTagCurationQueryRepository
interface AdminHashTagCurationQueryRepository {
fun getContentHashTagCurationList(): List<GetAdminContentHashTagCurationResponse>
fun searchHashTagCurationContentItem(curationId: Long, searchWord: String): List<SearchCurationItemResponse>
fun isExistsTag(tag: String): Boolean
}
@Repository
class AdminHashTagCurationQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) : AdminHashTagCurationQueryRepository {
override fun getContentHashTagCurationList(): List<GetAdminContentHashTagCurationResponse> {
return queryFactory
.select(
QGetAdminContentHashTagCurationResponse(
contentHashTagCuration.id,
contentHashTagCuration.tag,
contentHashTagCuration.isAdult
)
)
.from(contentHashTagCuration)
.where(contentHashTagCuration.isActive.isTrue)
.orderBy(contentHashTagCuration.orders.asc())
.fetch()
}
override fun searchHashTagCurationContentItem(
curationId: Long,
searchWord: String
): List<SearchCurationItemResponse> {
return queryFactory
.select(
QSearchCurationItemResponse(
audioContent.id,
audioContent.title,
audioContent.coverImage.prepend("/").prepend(imageHost)
)
)
.from(audioContent)
.leftJoin(contentHashTagCurationItem)
.on(
audioContent.id.eq(contentHashTagCurationItem.content.id)
.and(contentHashTagCurationItem.curation.id.eq(curationId))
)
.where(
audioContent.duration.isNotNull
.and(audioContent.member.isNotNull)
.and(audioContent.isActive.isTrue)
.and(audioContent.title.contains(searchWord))
.and(
contentHashTagCurationItem.id.isNull
.or(contentHashTagCurationItem.isActive.isFalse)
)
)
.fetch()
}
override fun isExistsTag(tag: String): Boolean {
return queryFactory
.select(contentHashTagCuration.id)
.from(contentHashTagCuration)
.where(
contentHashTagCuration.tag.eq(tag),
contentHashTagCuration.isActive.isTrue
)
.fetch().isNotEmpty()
}
}

View File

@@ -0,0 +1,133 @@
package kr.co.vividnext.sodalive.admin.content.curation.tag
import kr.co.vividnext.sodalive.admin.content.curation.AddItemToCurationRequest
import kr.co.vividnext.sodalive.admin.content.curation.RemoveItemInCurationRequest
import kr.co.vividnext.sodalive.admin.content.curation.SearchCurationItemResponse
import kr.co.vividnext.sodalive.admin.content.curation.UpdateCurationItemOrdersRequest
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.content.main.curation.tag.ContentHashTagCuration
import kr.co.vividnext.sodalive.content.main.curation.tag.ContentHashTagCurationItem
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class AdminHashTagCurationService(
private val repository: AdminHashTagCurationRepository,
private val itemRepository: AdminContentHashTagCurationItemRepository,
private val audioContentRepository: AudioContentRepository
) {
@Transactional
fun createContentHashTagCuration(request: CreateContentHashTagCurationRequest) {
var tag = request.tag.trim()
if (!tag.startsWith("#")) {
tag = "#$tag"
}
val isExists = repository.isExistsTag(tag = tag)
if (isExists) {
throw SodaException("이미 등록된 태그 입니다.")
}
repository.save(
ContentHashTagCuration(
tag = tag,
isAdult = request.isAdult
)
)
}
@Transactional
fun updateContentHashTagCuration(request: UpdateContentHashTagCurationRequest) {
val hashTagCuration = repository.findByIdOrNull(id = request.id)
?: throw SodaException("잘못된 요청입니다.")
if (request.tag != null) {
var tag = request.tag.trim()
if (!tag.startsWith("#")) {
tag = "#$tag"
}
hashTagCuration.tag = tag
}
if (request.isAdult != null) {
hashTagCuration.isAdult = request.isAdult
}
if (request.isActive != null) {
hashTagCuration.isActive = request.isActive
}
}
@Transactional
fun updateContentHashTagCurationOrders(ids: List<Long>) {
for (index in ids.indices) {
val contentHashTagCuration = repository.findByIdOrNull(ids[index])
if (contentHashTagCuration != null) {
contentHashTagCuration.orders = index + 1
}
}
}
fun getContentHashTagCurationList(): List<GetAdminContentHashTagCurationResponse> {
return repository.getContentHashTagCurationList()
}
fun getHashTagCurationItemList(curationId: Long): List<GetAdminHashTagCurationItemResponse> {
return itemRepository.getContentHashTagCurationItemList(curationId)
}
fun searchHashTagCurationContentItem(curationId: Long, searchWord: String): List<SearchCurationItemResponse> {
return repository.searchHashTagCurationContentItem(curationId, searchWord)
}
@Transactional
fun addItemToHashTagCuration(request: AddItemToCurationRequest) {
val curation = repository.findByIdOrNull(id = request.curationId)
?: throw SodaException("잘못된 요청입니다.")
request.itemIdList.forEach { contentId ->
val audioContent = audioContentRepository.findByIdAndActive(contentId)
if (audioContent != null) {
val item = itemRepository.findByCurationIdAndContentId(
curationId = request.curationId,
contentId = audioContent.id
) ?: ContentHashTagCurationItem()
item.curation = curation
item.content = audioContent
item.isActive = true
itemRepository.save(item)
}
}
}
@Transactional
fun removeItemInHashTagCuration(request: RemoveItemInCurationRequest) {
val item = itemRepository.findByCurationIdAndItemId(
curationId = request.curationId,
itemId = request.itemId
)
item?.isActive = false
}
@Transactional
fun updateItemInHashTagCurationOrders(request: UpdateCurationItemOrdersRequest) {
val ids = request.itemIds
for (index in ids.indices) {
val item = itemRepository.findByCurationIdAndItemId(
curationId = request.curationId,
itemId = ids[index]
)
if (item != null) {
item.orders = index + 1
}
}
}
}

View File

@@ -0,0 +1,17 @@
package kr.co.vividnext.sodalive.admin.content.curation.tag
data class CreateContentHashTagCurationRequest(
val tag: String,
val isAdult: Boolean
)
data class UpdateContentHashTagCurationRequest(
val id: Long,
val tag: String?,
val isAdult: Boolean?,
val isActive: Boolean?
)
data class UpdateContentHashTagCurationOrderRequest(
val ids: List<Long>
)

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.admin.content.curation.tag
import com.querydsl.core.annotations.QueryProjection
data class GetAdminContentHashTagCurationResponse @QueryProjection constructor(
val id: Long,
val tag: String,
val isAdult: Boolean
)

View File

@@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.admin.content.curation.tag
import com.querydsl.core.annotations.QueryProjection
data class GetAdminHashTagCurationItemResponse @QueryProjection constructor(
val id: Long,
val title: String,
val desc: String,
val coverImageUrl: String,
val creatorNickname: String,
val isAdult: Boolean
)

View File

@@ -5,6 +5,7 @@ import org.springframework.data.domain.Pageable
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
@RestController
@@ -13,4 +14,9 @@ import org.springframework.web.bind.annotation.RestController
class AdminContentSeriesController(private val service: AdminContentSeriesService) {
@GetMapping
fun getSeriesList(pageable: Pageable) = ApiResponse.ok(service.getSeriesList(pageable))
@GetMapping("/search")
fun searchSeriesList(
@RequestParam(value = "search_word") searchWord: String
) = ApiResponse.ok(service.searchSeriesList(searchWord))
}

View File

@@ -20,6 +20,9 @@ interface AdminContentSeriesQueryRepository {
offset: Long,
limit: Long
): List<GetAdminSeriesListItem>
fun searchSeriesList(searchWord: String): List<GetAdminSearchSeriesListItem>
fun findByIdAndActiveTrue(seriesId: Long): Series?
}
class AdminContentSeriesQueryRepositoryImpl(
@@ -31,6 +34,7 @@ class AdminContentSeriesQueryRepositoryImpl(
override fun getSeriesTotalCount(): Int {
val where = series.isActive.isTrue
.and(series.member.isNotNull)
.and(series.member.isActive.isTrue)
return queryFactory
.select(series.id)
@@ -43,6 +47,7 @@ class AdminContentSeriesQueryRepositoryImpl(
override fun getSeriesList(offset: Long, limit: Long): List<GetAdminSeriesListItem> {
val where = series.isActive.isTrue
.and(series.member.isNotNull)
.and(series.member.isActive.isTrue)
return queryFactory
.select(
@@ -73,4 +78,34 @@ class AdminContentSeriesQueryRepositoryImpl(
.orderBy(series.member.id.asc(), series.orders.asc())
.fetch()
}
override fun searchSeriesList(searchWord: String): List<GetAdminSearchSeriesListItem> {
val where = series.isActive.isTrue
.and(series.title.contains(searchWord))
.and(series.member.isNotNull)
.and(series.member.isActive.isTrue)
return queryFactory
.select(
QGetAdminSearchSeriesListItem(
series.id,
series.title
)
)
.from(series)
.innerJoin(series.member, member)
.where(where)
.orderBy(series.id.desc())
.fetch()
}
override fun findByIdAndActiveTrue(seriesId: Long): Series? {
return queryFactory
.selectFrom(series)
.where(
series.id.eq(seriesId),
series.isActive.isTrue
)
.fetchFirst()
}
}

View File

@@ -14,4 +14,8 @@ class AdminContentSeriesService(private val repository: AdminContentSeriesReposi
return GetAdminSeriesListResponse(totalCount, items)
}
fun searchSeriesList(searchWord: String): List<GetAdminSearchSeriesListItem> {
return repository.searchSeriesList(searchWord)
}
}

View File

@@ -18,3 +18,8 @@ data class GetAdminSeriesListItem @QueryProjection constructor(
val state: String,
val isAdult: Boolean
)
data class GetAdminSearchSeriesListItem @QueryProjection constructor(
val id: Long,
val title: String
)

View File

@@ -0,0 +1,40 @@
package kr.co.vividnext.sodalive.admin.content.series.recommend
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.security.access.prepost.PreAuthorize
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.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@PreAuthorize("hasRole('ADMIN')")
@RequestMapping("/admin/audio-content/series/recommend")
class AdminRecommendSeriesController(private val service: AdminRecommendSeriesService) {
@GetMapping
fun getRecommendSeriesList(@RequestParam isFree: Boolean) = ApiResponse.ok(
service.getRecommendSeriesList(isFree = isFree)
)
@PostMapping
fun createRecommendSeries(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String
) = ApiResponse.ok(service.createRecommendSeries(image, requestString))
@PutMapping
fun modifyRecommendSeries(
@RequestPart("image", required = false) image: MultipartFile? = null,
@RequestPart("request") requestString: String
) = ApiResponse.ok(service.updateRecommendSeries(image, requestString))
@PutMapping("/orders")
fun updateRecommendSeriesOrders(
@RequestBody request: UpdateRecommendSeriesOrdersRequest
) = ApiResponse.ok(service.updateRecommendSeriesOrders(request.ids))
}

View File

@@ -0,0 +1,44 @@
package kr.co.vividnext.sodalive.admin.content.series.recommend
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.main.tab.QRecommendSeries.recommendSeries
import kr.co.vividnext.sodalive.content.main.tab.RecommendSeries
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.jpa.repository.JpaRepository
interface AdminRecommendSeriesRepository :
JpaRepository<RecommendSeries, Long>,
AdminRecommendSeriesQueryRepository
interface AdminRecommendSeriesQueryRepository {
fun getRecommendSeriesList(isFree: Boolean): List<GetAdminRecommendSeriesListResponse>
}
class AdminRecommendSeriesQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) : AdminRecommendSeriesQueryRepository {
override fun getRecommendSeriesList(isFree: Boolean): List<GetAdminRecommendSeriesListResponse> {
return queryFactory
.select(
QGetAdminRecommendSeriesListResponse(
recommendSeries.id,
series.id,
series.title,
recommendSeries.imagePath.prepend("/").prepend(imageHost)
)
)
.from(recommendSeries)
.innerJoin(recommendSeries.series, series)
.where(
recommendSeries.isActive.isTrue
.and(series.isActive.isTrue)
.and(recommendSeries.isFree.eq(isFree))
)
.orderBy(recommendSeries.orders.asc())
.fetch()
}
}

View File

@@ -0,0 +1,87 @@
package kr.co.vividnext.sodalive.admin.content.series.recommend
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.main.tab.RecommendSeries
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
@Service
class AdminRecommendSeriesService(
private val s3Uploader: S3Uploader,
private val repository: AdminRecommendSeriesRepository,
private val seriesRepository: AdminContentSeriesRepository,
private val objectMapper: ObjectMapper,
@Value("\${cloud.aws.s3.bucket}")
private val bucket: String
) {
fun getRecommendSeriesList(isFree: Boolean): List<GetAdminRecommendSeriesListResponse> {
return repository.getRecommendSeriesList(isFree = isFree)
}
@Transactional
fun createRecommendSeries(image: MultipartFile, requestString: String) {
val request = objectMapper.readValue(requestString, CreateRecommendSeriesRequest::class.java)
val series = seriesRepository.findByIdOrNull(request.seriesId)
?: throw SodaException("잘못된 요청입니다.")
val recommendSeries = RecommendSeries(isFree = request.isFree)
recommendSeries.series = series
repository.save(recommendSeries)
val fileName = generateFileName()
val imagePath = s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = "recommend_series/${recommendSeries.id}/$fileName"
)
recommendSeries.imagePath = imagePath
}
@Transactional
fun updateRecommendSeries(image: MultipartFile?, requestString: String) {
val request = objectMapper.readValue(requestString, UpdateRecommendSeriesRequest::class.java)
val recommendSeries = repository.findByIdOrNull(request.id)
?: throw SodaException("잘못된 요청입니다.")
if (image != null) {
val fileName = generateFileName()
val imagePath = s3Uploader.upload(
inputStream = image.inputStream,
bucket = bucket,
filePath = "recommend_series/${recommendSeries.id}/$fileName"
)
recommendSeries.imagePath = imagePath
}
if (request.isActive != null) {
recommendSeries.isActive = request.isActive
}
if (request.seriesId != null) {
val series = seriesRepository.findByIdOrNull(request.seriesId)
if (series != null) {
recommendSeries.series = series
}
}
}
@Transactional
fun updateRecommendSeriesOrders(ids: List<Long>) {
for (index in ids.indices) {
val recommendSeries = repository.findByIdOrNull(ids[index])
if (recommendSeries != null) {
recommendSeries.orders = index + 1
}
}
}
}

View File

@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.admin.content.series.recommend
data class CreateRecommendSeriesRequest(
val seriesId: Long,
val isFree: Boolean
)

View File

@@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.admin.content.series.recommend
import com.querydsl.core.annotations.QueryProjection
data class GetAdminRecommendSeriesListResponse @QueryProjection constructor(
val id: Long,
val seriesId: Long,
val seriesTitle: String,
val imageUrl: String
)

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.admin.content.series.recommend
data class UpdateRecommendSeriesOrdersRequest(
val ids: List<Long>
)

View File

@@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.admin.content.series.recommend
data class UpdateRecommendSeriesRequest(
val id: Long,
val seriesId: Long?,
val isActive: Boolean?
)

View File

@@ -0,0 +1,31 @@
package kr.co.vividnext.sodalive.admin.content.tab
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
import kr.co.vividnext.sodalive.content.main.tab.GetContentMainTabItem
import kr.co.vividnext.sodalive.content.main.tab.QAudioContentMainTab.audioContentMainTab
import kr.co.vividnext.sodalive.content.main.tab.QGetContentMainTabItem
import org.springframework.data.jpa.repository.JpaRepository
interface AdminContentMainTabRepository : JpaRepository<AudioContentMainTab, Long>, AdminContentMainTabQueryRepository
interface AdminContentMainTabQueryRepository {
fun findAllByActiveIsTrue(): List<GetContentMainTabItem>
}
class AdminContentMainTabQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : AdminContentMainTabQueryRepository {
override fun findAllByActiveIsTrue(): List<GetContentMainTabItem> {
return queryFactory
.select(
QGetContentMainTabItem(
audioContentMainTab.id,
audioContentMainTab.title
)
)
.from(audioContentMainTab)
.where(audioContentMainTab.isActive.isTrue)
.fetch()
}
}

Some files were not shown because too many files have changed in this diff Show More