Compare commits

..

298 Commits

Author SHA1 Message Date
83028f7817 Merge pull request 'test' (#298) from test into main
Reviewed-on: #298
2025-03-26 21:08:29 +00:00
70d1795557 Merge pull request 'test' (#297) from test into main
Reviewed-on: #297
2025-03-26 04:23:28 +00:00
8c6c681424 Merge pull request 'marketing 정보 업데이트 시 pid 값이 있으면 항상 로그인 기록 남기기' (#296) from test into main
Reviewed-on: #296
2025-03-25 11:25:46 +00:00
50bc9f4ff3 Merge pull request '라이브 방 - 예약 중 조회' (#295) from test into main
Reviewed-on: #295
2025-03-24 10:04:08 +00:00
f00ea03fad Merge pull request 'test' (#294) from test into main
Reviewed-on: #294
2025-03-24 09:09:16 +00:00
f22e7b9ad1 Merge pull request '자동생성 닉네임에 사용될 형용사, 명사 값 추가' (#293) from test into main
Reviewed-on: #293
2025-03-21 10:27:30 +00:00
c7ec95f4bb Merge pull request 'test' (#292) from test into main
Reviewed-on: #292
2025-03-20 19:24:03 +00:00
229e7a8ccc Merge pull request '시리즈 상세, 채널 상세' (#291) from test into main
Reviewed-on: #291
2025-03-19 09:43:06 +00:00
3c616474ff Merge pull request 'test' (#290) from test into main
Reviewed-on: #290
2025-03-19 07:51:25 +00:00
56eb6b3ce3 Merge pull request '19금 콘텐츠 보기 설정 적용' (#289) from test into main
Reviewed-on: #289
2025-03-19 02:05:17 +00:00
545836d43c Merge pull request '관리자 광고통계, 일별 전체 회원 수' (#288) from test into main
Reviewed-on: #288
2025-03-17 08:50:59 +00:00
219f83dec0 Merge pull request 'test' (#287) from test into main
Reviewed-on: #287
2025-03-17 05:54:05 +00:00
a76a841238 Merge pull request 'test' (#286) from test into main
Reviewed-on: #286
2025-03-14 16:11:17 +00:00
c26680de84 Merge pull request '이벤트 배너, 충전 이벤트 - 기간 설정에 시간 추가' (#285) from test into main
Reviewed-on: #285
2025-03-14 03:40:07 +00:00
8fffad9d3a Merge pull request 'test' (#284) from test into main
Reviewed-on: #284
2025-03-13 12:25:35 +00:00
f4f0f203a2 Merge pull request '유저 정보 조회' (#283) from test into main
Reviewed-on: #283
2025-03-12 08:00:13 +00:00
b7196f5a0c Merge pull request 'test' (#282) from test into main
Reviewed-on: #282
2025-03-11 08:01:05 +00:00
5d33a18890 Merge pull request 'test' (#281) from test into main
Reviewed-on: #281
2025-03-10 05:35:30 +00:00
96186a1a50 Merge pull request '마케팅 - 매체 파트너 코드 조회 API - link 값 수정' (#280) from test into main
Reviewed-on: #280
2025-03-07 06:27:08 +00:00
bc8bc479d1 Merge pull request 'test' (#279) from test into main
Reviewed-on: #279
2025-03-06 17:58:32 +00:00
47595b1291 Merge pull request 'test' (#278) from test into main
Reviewed-on: #278
2025-03-05 14:05:47 +00:00
01a88964df Merge pull request 'test' (#277) from test into main
Reviewed-on: #277
2025-03-05 09:44:59 +00:00
3a2b77379f Merge pull request '콘텐츠 업로드' (#276) from test into main
Reviewed-on: #276
2025-02-28 04:45:04 +00:00
dc4e5f75cd Merge pull request '콘텐츠 메인 콘텐츠 탭 - 채널별 추천 단편' (#275) from test into main
Reviewed-on: #275
2025-02-26 03:14:33 +00:00
d0178d551c Merge pull request '콘텐츠 메인 콘텐츠 탭 - 채널별 추천 단편' (#274) from test into main
Reviewed-on: #274
2025-02-25 14:54:53 +00:00
827333108d Merge pull request '콘텐츠 대여기간' (#273) from test into main
Reviewed-on: #273
2025-02-25 14:02:18 +00:00
587b90bd27 Merge pull request '콘텐츠 메인 무료 탭 - 새로운 콘텐츠' (#272) from test into main
Reviewed-on: #272
2025-02-22 01:56:49 +00:00
4dc20c5e90 Merge pull request '콘텐츠 메인 무료 탭' (#271) from test into main
Reviewed-on: #271
2025-02-22 00:39:09 +00:00
ac25782f2b Merge pull request '관리자 태그 큐레이션 - 콘텐츠 검색' (#270) from test into main
Reviewed-on: #270
2025-02-21 21:46:15 +00:00
20437d56e7 Merge pull request '메인 시리즈 탭 - 완결 시리즈' (#269) from test into main
Reviewed-on: #269
2025-02-21 21:15:52 +00:00
f0b412828a Merge pull request '메인 시리즈 탭 - 완결 시리즈' (#268) from test into main
Reviewed-on: #268
2025-02-21 19:27:33 +00:00
367faac5c3 Merge pull request 'test' (#267) from test into main
Reviewed-on: #267
2025-02-20 18:24:35 +00:00
84deaaa970 Merge pull request '콘텐츠 메인 시리즈 탭 - 장르별 시리즈' (#266) from test into main
Reviewed-on: #266
2025-02-19 12:52:17 +00:00
a2b39466c2 Merge pull request '기존 콘텐츠 메인 - 새로운 콘텐츠' (#265) from test into main
Reviewed-on: #265
2025-02-19 11:34:02 +00:00
03586c4005 Merge pull request '기존 콘텐츠 메인 - 새로운 콘텐츠' (#264) from test into main
Reviewed-on: #264
2025-02-19 09:49:04 +00:00
6ea69e1510 Merge pull request '콘텐츠 메인 무료 탭 - 새로운 무료 콘텐츠' (#263) from test into main
Reviewed-on: #263
2025-02-19 09:24:24 +00:00
553c6dc539 Merge pull request '콘텐츠 메인 단편 탭 - 새로운 단편' (#262) from test into main
Reviewed-on: #262
2025-02-19 08:20:14 +00:00
6cc22f5b6d Merge pull request '콘텐츠 메인 홈, 무료 탭' (#261) from test into main
Reviewed-on: #261
2025-02-19 06:34:53 +00:00
9103d67cc1 Merge pull request 'test' (#260) from test into main
Reviewed-on: #260
2025-02-18 18:13:25 +00:00
25083fb0e4 Merge pull request 'test' (#259) from test into main
Reviewed-on: #259
2025-02-18 14:48:09 +00:00
d2dc045255 Merge pull request 'test' (#258) from test into main
Reviewed-on: #258
2025-02-14 18:09:11 +00:00
b8621dfbb0 Merge pull request 'test' (#257) from test into main
Reviewed-on: #257
2025-02-09 13:36:21 +00:00
93633940dd Merge pull request 'test' (#256) from test into main
Reviewed-on: #256
2025-02-03 07:20:32 +00:00
b6f5325351 Merge pull request 'test' (#255) from test into main
Reviewed-on: #255
2025-01-31 15:22:23 +00:00
7c32c08f1f Merge pull request 'test' (#254) from test into main
Reviewed-on: #254
2025-01-17 05:46:00 +00:00
1d268da08d Merge pull request '오디션 등록 푸시알림 메시지 수정' (#253) from test into main
Reviewed-on: #253
2025-01-10 10:23:19 +00:00
797666ae0d Merge pull request 'test' (#252) from test into main
Reviewed-on: #252
2025-01-08 14:11:08 +00:00
dcf470997e Merge pull request 'test' (#251) from test into main
Reviewed-on: #251
2025-01-08 06:29:33 +00:00
0974d1dbf8 Merge pull request '관리자 오디션 지원 리스트' (#250) from test into main
Reviewed-on: #250
2025-01-07 19:44:39 +00:00
12a35db6cd Merge pull request '오디션' (#249) from test into main
Reviewed-on: #249
2025-01-07 17:24:40 +00:00
9abbb05ad8 Merge pull request 'test' (#248) from test into main
Reviewed-on: #248
2024-12-18 07:10:01 +00:00
1ecaf69b0b Merge pull request 'test' (#247) from test into main
Reviewed-on: #247
2024-12-17 13:43:45 +00:00
e334d1e5d9 Merge pull request '콘텐츠 댓글 푸시 대상자' (#246) from test into main
Reviewed-on: #246
2024-12-03 15:54:34 +00:00
b735e861d0 Merge pull request '콘텐츠 댓글 푸시 대상자 조회' (#245) from test into main
Reviewed-on: #245
2024-12-02 15:06:49 +00:00
4eb433d372 Merge pull request 'test' (#244) from test into main
Reviewed-on: #244
2024-12-02 12:05:30 +00:00
2416ae61f3 Merge pull request 'test' (#243) from test into main
Reviewed-on: #243
2024-12-02 04:29:50 +00:00
01fb336985 Merge pull request '콘텐츠 등록' (#242) from test into main
Reviewed-on: #242
2024-11-26 12:46:24 +00:00
b6af88a732 Merge pull request 'test' (#241) from test into main
Reviewed-on: #241
2024-11-26 05:33:45 +00:00
58a2a17d6d Merge pull request 'test' (#240) from test into main
Reviewed-on: #240
2024-11-23 17:59:23 +00:00
79f5a0f520 Merge pull request '내 콘텐츠 수정, 삭제 시 콘텐츠 조회 함수' (#239) from test into main
Reviewed-on: #239
2024-11-21 06:34:30 +00:00
7f6c0f7f04 Merge pull request 'Redis connection 수정' (#238) from test into main
Reviewed-on: #238
2024-11-20 09:58:56 +00:00
f658df4dca Merge pull request 'Redis connection' (#237) from test into main
Reviewed-on: #237
2024-11-20 07:52:58 +00:00
9d43b8e23a Merge pull request 'Redis connection' (#236) from test into main
Reviewed-on: #236
2024-11-20 06:47:52 +00:00
4270aef79b Merge pull request 'test' (#235) from test into main
Reviewed-on: #235
2024-11-11 15:34:35 +00:00
1c0dc82d44 Merge pull request '콘텐츠 구매 - 소장만 추가' (#234) from test into main
Reviewed-on: #234
2024-11-08 12:40:29 +00:00
c1e325aadf Merge pull request 'test' (#233) from test into main
Reviewed-on: #233
2024-11-05 07:26:19 +00:00
cec87da69d Merge pull request '콘텐츠 대여가격' (#232) from test into main
Reviewed-on: #232
2024-10-31 05:23:01 +00:00
f68f24cb2c Merge pull request 'test' (#231) from test into main
Reviewed-on: #231
2024-10-31 03:09:13 +00:00
ed094347fc Merge pull request '라이브 방 후원랭킹' (#230) from test into main
Reviewed-on: #230
2024-10-30 05:16:05 +00:00
b8afdffbe1 Merge pull request 'test' (#229) from test into main
Reviewed-on: #229
2024-10-29 08:35:58 +00:00
f6ba79f31c Merge pull request 'test' (#228) from test into main
Reviewed-on: #228
2024-10-25 03:45:26 +00:00
5f3b1663d2 Merge pull request '관리자 - 콘텐츠 리스트' (#227) from test into main
Reviewed-on: #227
2024-10-16 03:33:23 +00:00
66e786b4bb Merge pull request '관리자 - 시리즈 리스트 API' (#226) from test into main
Reviewed-on: #226
2024-10-14 15:39:45 +00:00
f671114574 Merge pull request '관리자 - 시리즈 리스트 API' (#225) from test into main
Reviewed-on: #225
2024-10-14 10:37:28 +00:00
ce37060d94 Merge pull request '관리자 - 시리즈 리스트 API 추가' (#224) from test into main
Reviewed-on: #224
2024-10-14 10:10:43 +00:00
7d19a4d184 Merge pull request 'test' (#223) from test into main
Reviewed-on: #223
2024-10-13 17:30:25 +00:00
22f28a2f8a Merge pull request '콘텐츠 메인 - 추천시리즈, 새로운 콘텐츠, 큐레이션' (#222) from test into main
Reviewed-on: #222
2024-10-13 16:33:15 +00:00
ceef9ca979 Merge pull request 'test' (#221) from test into main
Reviewed-on: #221
2024-10-11 05:06:22 +00:00
efe8f4f939 Merge pull request '콘텐츠 메인 - 새로운 콘텐츠 섹션 두번째 정렬 조건 추가' (#220) from test into main
Reviewed-on: #220
2024-10-04 07:21:38 +00:00
ba692a1195 Merge pull request '시리즈 상세 - 콘텐츠 리스트 두번째 정렬 조건 추가' (#219) from test into main
Reviewed-on: #219
2024-10-04 02:41:28 +00:00
d732bad042 Merge pull request '시리즈 상세' (#218) from test into main
Reviewed-on: #218
2024-10-02 09:18:19 +00:00
4c935c3bee Merge pull request '예약 라이브 개수 제한' (#217) from test into main
Reviewed-on: #217
2024-09-25 05:42:45 +00:00
c160dd791f Merge pull request 'test' (#216) from test into main
Reviewed-on: #216
2024-09-24 10:17:58 +00:00
23cd1b4601 Merge pull request '라이브 후원현황 API' (#215) from test into main
Reviewed-on: #215
2024-09-23 13:58:14 +00:00
031fc8ba1b Merge pull request 'test' (#214) from test into main
Reviewed-on: #214
2024-09-23 06:24:12 +00:00
c6853289ad Merge pull request 'test' (#213) from test into main
Reviewed-on: #213
2024-09-11 08:23:08 +00:00
2497bb69bc Merge pull request 'test' (#212) from test into main
Reviewed-on: #212
2024-09-11 07:47:35 +00:00
a58a67e0a2 Merge pull request 'test' (#211) from test into main
Reviewed-on: #211
2024-09-11 06:00:31 +00:00
4315fe12a5 Merge pull request 'test' (#210) from test into main
Reviewed-on: #210
2024-09-06 19:00:39 +00:00
42f10a8899 Merge pull request 'test' (#209) from test into main
Reviewed-on: #209
2024-09-05 10:12:14 +00:00
1e4b47f989 Merge pull request 'test' (#208) from test into main
Reviewed-on: #208
2024-08-30 09:17:41 +00:00
ff255dbfae Merge pull request 'test' (#207) from test into main
Reviewed-on: #207
2024-08-27 07:31:05 +00:00
dbe9b72feb Merge pull request 'test' (#206) from test into main
Reviewed-on: #206
2024-08-23 13:48:24 +00:00
95a714b391 Merge pull request '탐색' (#205) from test into main
Reviewed-on: #205
2024-08-19 13:21:31 +00:00
28f58c7f56 Merge pull request '라이브' (#204) from test into main
Reviewed-on: #204
2024-08-14 09:34:52 +00:00
8bd46d8f21 Merge pull request '크리에이터 관리자 시리즈' (#203) from test into main
Reviewed-on: #203
2024-08-14 07:41:33 +00:00
e1bb8e54ed Merge pull request '크리에이터 커뮤니티' (#202) from test into main
Reviewed-on: #202
2024-08-06 11:41:11 +00:00
1de705b063 Merge pull request '크리에이터 커뮤니티' (#201) from test into main
Reviewed-on: #201
2024-08-06 06:37:30 +00:00
f6926ad356 Merge pull request '남/여 크리에이터에서 특정 크리에이터 제거' (#200) from test into main
Reviewed-on: #200
2024-07-26 07:54:18 +00:00
2cdbbb1b37 Merge pull request 'test' (#199) from test into main
Reviewed-on: #199
2024-07-25 16:08:20 +00:00
4dce8c8f03 Merge pull request '크리에이터 커뮤니티' (#198) from test into main
Reviewed-on: #198
2024-07-10 05:24:58 +00:00
97a5bace6f Merge pull request 'test' (#197) from test into main
Reviewed-on: #197
2024-07-08 14:17:42 +00:00
d4d51ec48f Merge pull request 'test' (#196) from test into main
Reviewed-on: #196
2024-07-02 08:57:11 +00:00
fb91398462 Merge pull request '커뮤니티 게시물' (#195) from test into main
Reviewed-on: #195
2024-06-17 14:09:26 +00:00
105dadd798 Merge pull request '커뮤니티 게시물' (#194) from test into main
Reviewed-on: #194
2024-06-15 11:57:33 +00:00
2abf2837d3 Merge pull request '커뮤니티 게시물' (#193) from test into main
Reviewed-on: #193
2024-06-11 12:13:47 +00:00
422aa67af6 Merge pull request '커뮤니티 게시물' (#192) from test into main
Reviewed-on: #192
2024-06-11 11:55:05 +00:00
7fffab6985 Merge pull request '크리에이터 정산 - 입력된 비율로 계산' (#191) from test into main
Reviewed-on: #191
2024-06-11 08:07:22 +00:00
5a4be3d2c1 Merge pull request '콘텐츠 상세' (#190) from test into main
Reviewed-on: #190
2024-06-07 10:30:06 +00:00
f39a7681db Merge pull request 'test' (#189) from test into main
Reviewed-on: #189
2024-06-04 03:39:23 +00:00
c60a7580ba Merge pull request 'test' (#188) from test into main
Reviewed-on: #188
2024-06-03 22:13:56 +00:00
97edb56edc Merge pull request 'test' (#187) from test into main
Reviewed-on: #187
2024-05-29 17:04:39 +00:00
6ebca8d22b Merge pull request '관리자 - 라이브 리스트' (#186) from test into main
Reviewed-on: #186
2024-05-28 18:18:44 +00:00
95371ad934 Merge pull request '(크리에이터)관리자 커뮤니티 게시물 정산' (#185) from test into main
Reviewed-on: #185
2024-05-28 16:54:44 +00:00
2c176825fd Merge pull request 'test' (#184) from test into main
Reviewed-on: #184
2024-05-28 16:09:51 +00:00
fae7de48d3 Merge pull request 'test' (#183) from test into main
Reviewed-on: #183
2024-05-27 08:28:27 +00:00
b8230646a2 Merge pull request '커뮤니티 게시글 유료화' (#182) from test into main
Reviewed-on: #182
2024-05-24 14:44:14 +00:00
43279541dd Merge pull request '콘텐츠별 누적정산' (#181) from test into main
Reviewed-on: #181
2024-05-21 06:37:51 +00:00
b4791977c1 Merge pull request 'PG 심사를 위한 캔 충전 로직 추가' (#180) from test into main
Reviewed-on: #180
2024-05-20 06:38:40 +00:00
ef917ecc25 Merge pull request '라이브 방 - 크리에이터 입장 가능 설정 추가' (#179) from test into main
Reviewed-on: #179
2024-05-14 12:09:53 +00:00
a93faad951 Merge pull request '룰렛 방식 수정' (#178) from test into main
Reviewed-on: #178
2024-05-10 18:00:40 +00:00
fd001d24d3 Merge pull request '추천시리즈 API 추가' (#177) from test into main
Reviewed-on: #177
2024-05-07 10:34:10 +00:00
7aa5884797 Merge pull request '구글 인 앱 결제 검증코드 수정' (#176) from test into main
Reviewed-on: #176
2024-05-03 10:06:39 +00:00
5b237a1547 Merge pull request 'test' (#175) from test into main
Reviewed-on: #175
2024-05-03 06:08:55 +00:00
2e37990d87 Merge pull request '탐색 - 남/여 크리에이터 리스트' (#174) from test into main
Reviewed-on: #174
2024-05-02 17:24:18 +00:00
dd07d724a8 Merge pull request 'test' (#173) from test into main
Reviewed-on: #173
2024-05-02 16:41:50 +00:00
03ce8618e7 Merge pull request '관리자 시그니처 설정' (#172) from test into main
Reviewed-on: #172
2024-05-02 07:13:21 +00:00
db1a7a7fd6 Merge pull request '시그니처 후원 시간 추가' (#171) from test into main
Reviewed-on: #171
2024-05-02 06:23:22 +00:00
36a82d7f53 Merge pull request '시리즈, 시리즈 콘텐츠' (#170) from test into main
Reviewed-on: #170
2024-04-30 14:00:01 +00:00
3a34401113 Merge pull request '시리즈 상세' (#169) from test into main
Reviewed-on: #169
2024-04-30 09:44:55 +00:00
9927268330 Merge pull request '구글 인 앱 결제' (#168) from test into main
Reviewed-on: #168
2024-04-30 08:24:03 +00:00
c45c97e29d Merge pull request '시리즈' (#167) from test into main
Reviewed-on: #167
2024-04-26 18:51:10 +00:00
c64a315226 Merge pull request 'test' (#166) from test into main
Reviewed-on: #166
2024-04-18 16:40:55 +00:00
a4cafca6ab Merge pull request 'test' (#165) from test into main
Reviewed-on: #165
2024-04-18 10:02:45 +00:00
46284a0660 Merge pull request 'test' (#164) from test into main
Reviewed-on: #164
2024-04-15 12:31:42 +00:00
05df86e15a Merge pull request 'test' (#163) from test into main
Reviewed-on: #163
2024-04-09 14:40:33 +00:00
8b433027e2 Merge pull request 'test' (#162) from test into main
Reviewed-on: #162
2024-04-09 13:27:24 +00:00
5bd4ff7610 Merge pull request '결제 테이블에 구글결제의 경우 orderId 추가' (#161) from test into main
Reviewed-on: #161
2024-04-05 03:10:00 +00:00
d693c397ea Merge pull request '.' (#160) from test into main
Reviewed-on: #160
2024-04-03 06:49:36 +00:00
1d8d1ec9a5 Merge pull request 'test' (#159) from test into main
Reviewed-on: #159
2024-04-03 06:27:26 +00:00
5e491f11ee Merge pull request '크리에이터 관리자 라이브 정산' (#158) from test into main
Reviewed-on: #158
2024-04-03 03:45:31 +00:00
7cedea06ac Merge pull request '캔 사용' (#157) from test into main
Reviewed-on: #157
2024-04-01 12:42:44 +00:00
2e5f750e50 Merge pull request 'test' (#156) from test into main
Reviewed-on: #156
2024-04-01 10:20:09 +00:00
20289cad10 Merge pull request '콘텐츠 상세' (#155) from test into main
Reviewed-on: #155
2024-03-29 10:10:04 +00:00
e0d64c31c7 Merge pull request '콘텐츠 상세' (#154) from test into main
Reviewed-on: #154
2024-03-29 08:18:15 +00:00
8c1b95dc97 Merge pull request '구글 인 앱구매 검증' (#153) from test into main
Reviewed-on: #153
2024-03-28 17:13:57 +00:00
fb5641343e Merge pull request 'test' (#152) from test into main
Reviewed-on: #152
2024-03-28 06:31:43 +00:00
87765941eb Merge pull request '구글 인 앱 결제 검증' (#151) from test into main
Reviewed-on: #151
2024-03-22 20:10:01 +00:00
1809862c16 Merge pull request '구글 인 앱 결제 처리과정 축소' (#150) from test into main
Reviewed-on: #150
2024-03-22 15:27:25 +00:00
300f784f7d Merge pull request '구글 인 앱 결제 검증 과정 try/catch로 예외 처리' (#149) from test into main
Reviewed-on: #149
2024-03-22 11:59:37 +00:00
67a045eae6 Merge pull request '구글 인 앱 결제 검증 수정' (#148) from test into main
Reviewed-on: #148
2024-03-22 11:36:35 +00:00
2a79903a28 Merge pull request 'test' (#147) from test into main
Reviewed-on: #147
2024-03-22 10:08:00 +00:00
d3222ce083 Merge pull request '관리자 캔 충전현황' (#146) from test into main
Reviewed-on: #146
2024-03-21 15:56:02 +00:00
406a421742 Merge pull request '구글 인 앱 결제 검증 코드 수정' (#145) from test into main
Reviewed-on: #145
2024-03-21 15:22:34 +00:00
10bf728faf Merge pull request '구글 인 앱 결제 검증 코드 수정' (#144) from test into main
Reviewed-on: #144
2024-03-21 14:37:09 +00:00
607617747c Merge pull request '구글 인 앱 결제 검증 코드 수정' (#143) from test into main
Reviewed-on: #143
2024-03-21 12:47:51 +00:00
f0a69eb1a2 Merge pull request 'test' (#142) from test into main
Reviewed-on: #142
2024-03-21 07:45:02 +00:00
6b307a6e17 Merge pull request 'test' (#141) from test into main
Reviewed-on: #141
2024-03-13 11:28:13 +00:00
08d08a934a Merge pull request 'test' (#140) from test into main
Reviewed-on: #140
2024-03-12 06:20:02 +00:00
c500c12668 Merge pull request 'test' (#139) from test into main
Reviewed-on: #139
2024-03-08 13:40:27 +00:00
62060adeba Merge pull request '채널 후원 랭킹' (#138) from test into main
Reviewed-on: #138
2024-02-29 10:28:51 +00:00
b2fc75edb8 Merge pull request '룰렛 정렬 순서 수정' (#137) from test into main
Reviewed-on: #137
2024-02-27 17:29:36 +00:00
a999dd2085 Merge pull request 'test' (#136) from test into main
Reviewed-on: #136
2024-02-27 16:16:30 +00:00
49f95ab100 Merge pull request '회원테이블에 adid 추가' (#135) from test into main
Reviewed-on: #135
2024-02-27 05:49:47 +00:00
1a84d5b30c Merge pull request 'test' (#134) from test into main
Reviewed-on: #134
2024-02-24 20:35:57 +00:00
3b65050632 Merge pull request 'redis ssl false' (#133) from test into main
Reviewed-on: #133
2024-02-17 13:00:25 +00:00
d0df31674c Merge pull request 'test' (#132) from test into main
Reviewed-on: #132
2024-02-17 12:44:52 +00:00
1fe88402e2 Merge pull request 'test' (#131) from test into main
Reviewed-on: #131
2024-02-14 07:12:41 +00:00
67097696e6 Merge pull request '커뮤니티 게시물 시간' (#130) from test into main
Reviewed-on: #130
2024-02-12 08:14:24 +00:00
8e7e77067a Merge pull request 'test' (#129) from test into main
Reviewed-on: #129
2024-02-12 07:53:26 +00:00
9899390b61 Merge pull request '관리자 콘텐츠 리스트, 수정' (#128) from test into main
Reviewed-on: #128
2024-02-08 18:19:50 +00:00
80c476a908 Merge pull request '관리자 콘텐츠 리스트' (#127) from test into main
Reviewed-on: #127
2024-02-08 14:45:54 +00:00
59da1d6e49 Merge pull request '카테고리 콘텐츠' (#126) from test into main
Reviewed-on: #126
2024-02-07 13:33:37 +00:00
5aef7dac33 Merge pull request 'test' (#125) from test into main
Reviewed-on: #125
2024-02-07 09:39:09 +00:00
faf7aa06b6 Merge pull request 'test' (#124) from test into main
Reviewed-on: #124
2024-02-05 07:03:45 +00:00
38ef6e5583 Merge pull request 'test' (#123) from test into main
Reviewed-on: #123
2024-02-05 02:12:10 +00:00
c0b15b5d94 Merge pull request '콘텐츠 업로드' (#122) from test into main
Reviewed-on: #122
2024-01-30 03:45:31 +00:00
2cfc067ea1 Merge pull request '콘텐츠 전체 리스트' (#121) from test into main
Reviewed-on: #121
2024-01-29 09:00:23 +00:00
a91db4f956 Merge pull request '콘텐츠 상단 고정 기능 추가' (#120) from test into main
Reviewed-on: #120
2024-01-29 02:45:41 +00:00
8a09780a02 Merge pull request 'test' (#119) from test into main
Reviewed-on: #119
2024-01-26 06:19:49 +00:00
45e8ec6505 Merge pull request '콘텐츠 정렬 기준' (#118) from test into main
Reviewed-on: #118
2024-01-24 15:03:06 +00:00
4554b85914 Merge pull request '회원가입 시 닉네임 validation 조건' (#117) from test into main
Reviewed-on: #117
2024-01-24 07:11:23 +00:00
8aa79c4a9c Merge pull request '콘텐츠 등록 - 태그 등록' (#116) from test into main
Reviewed-on: #116
2024-01-16 15:19:59 +00:00
c8d3210b57 Merge pull request 'test' (#115) from test into main
Reviewed-on: #115
2024-01-11 09:05:44 +00:00
2282a49563 Merge pull request 'test' (#114) from test into main
Reviewed-on: #114
2024-01-11 03:49:53 +00:00
b82fdfb2c8 Merge pull request '예약 업로드' (#113) from test into main
Reviewed-on: #113
2024-01-10 16:59:51 +00:00
2d17eac199 Merge pull request '19세 미만이 인증처리 되던 버그 수정' (#112) from test into main
Reviewed-on: #112
2024-01-08 10:07:21 +00:00
e482bc3aad Merge pull request '콘텐츠를 올린 크리에이터가 댓글을 삭제할 수 있도록 수정' (#111) from test into main
Reviewed-on: #111
2024-01-04 11:36:43 +00:00
ec022b74d1 Merge pull request '캔 쿠폰 조회 로직 수정' (#110) from test into main
Reviewed-on: #110
2024-01-04 09:59:20 +00:00
dc42c09ce3 Merge pull request '캔 쿠폰 조회' (#109) from test into main
Reviewed-on: #109
2024-01-03 15:48:09 +00:00
046a34d2a4 Merge pull request 'test' (#108) from test into main
Reviewed-on: #108
2024-01-03 15:19:42 +00:00
9ff6ec1888 Merge pull request '캔 쿠폰 시스템' (#107) from test into main
Reviewed-on: #107
2024-01-03 11:28:48 +00:00
d2950106ec Merge pull request '콘텐츠 랭킹 - 후원 순위 제거, 룰렛 아이템 개수 10로 변경' (#106) from test into main
Reviewed-on: #106
2023-12-26 12:50:09 +00:00
962f800d2e Merge pull request '팔로우 한 크리에이터 커뮤니티 게시물 조회 - 인증하지 않은 사람은 19금이 아닌 최신 게시물이 조회되도록 수정' (#105) from test into main
Reviewed-on: #105
2023-12-25 08:19:43 +00:00
962107e507 Merge pull request '팔로우 한 크리에이터 커뮤니티 게시물 조회 - 차단된 유저는 조회되지 않도록 수정' (#104) from test into main
Reviewed-on: #104
2023-12-21 19:28:10 +00:00
039bd11963 Merge pull request '커뮤니티 게시물 조회 - 차단된 유저는 조회되지 않도록 수정' (#103) from test into main
Reviewed-on: #103
2023-12-21 19:05:12 +00:00
5c250ea4ae Merge pull request '크리에이터 커뮤니티' (#102) from test into main
Reviewed-on: #102
2023-12-21 15:10:55 +00:00
e3405bcec6 Merge pull request 'test' (#101) from test into main
Reviewed-on: #101
2023-12-13 16:14:50 +00:00
0fd1c2235f Merge pull request '라이브 정산 - 정렬 순서 추가 (라이브 방 id, 구분)' (#100) from test into main
Reviewed-on: #100
2023-12-10 16:58:05 +00:00
b20c29b022 Merge pull request 'test' (#99) from test into main
Reviewed-on: #99
2023-12-10 12:34:51 +00:00
12d5dcd298 Merge pull request 'test' (#98) from test into main
Reviewed-on: #98
2023-12-10 09:02:29 +00:00
2c305dc6c6 Merge pull request 'test' (#97) from test into main
Reviewed-on: #97
2023-12-10 06:48:43 +00:00
62f76f7433 Merge pull request 'test' (#96) from test into main
Reviewed-on: #96
2023-12-07 01:46:33 +00:00
858ce524f9 Merge pull request 'test' (#95) from test into main
Reviewed-on: #95
2023-11-27 12:48:24 +00:00
3795fb4a40 Merge pull request 'test' (#94) from test into main
Reviewed-on: #94
2023-11-24 07:03:10 +00:00
0c01aeec50 Merge pull request '관리자 - 이벤트 배너 등록' (#93) from test into main
Reviewed-on: #93
2023-11-21 16:21:19 +00:00
892206744d Merge pull request '이벤트 배너, 팝업' (#92) from test into main
Reviewed-on: #92
2023-11-21 12:59:30 +00:00
9e2c1474db Merge pull request '메시지 보내기 유저 검색' (#91) from test into main
Reviewed-on: #91
2023-11-20 05:41:57 +00:00
16328f73d9 Merge pull request '크리에이터 관리자, 관리자 - 일자별 콘텐츠 후원 정산 API' (#90) from test into main
Reviewed-on: #90
2023-11-14 13:14:19 +00:00
e0d4f53cf4 Merge pull request 'test' (#89) from test into main
Reviewed-on: #89
2023-11-14 12:15:51 +00:00
e09a59c5b4 Merge pull request 'test' (#88) from test into main
Reviewed-on: #88
2023-11-14 11:09:14 +00:00
049e654535 Merge pull request '관리자 일자별 콘텐츠 후원 정산 - 크리에이터 순으로 정렬' (#87) from test into main
Reviewed-on: #87
2023-11-14 09:22:38 +00:00
c927dc4ecd Merge pull request '관리자 - 일자별 콘텐츠 후원 정산 페이지 추가' (#86) from test into main
Reviewed-on: #86
2023-11-14 09:03:35 +00:00
fe4ecd0ad8 Merge pull request '크리에이터 관리자 - 일자별 콘텐츠 정산 페이징 안되는 버그 수정' (#85) from test into main
Reviewed-on: #85
2023-11-14 05:17:58 +00:00
78d476fe80 Merge pull request '라이브 상세 - 시작 시간 dateformat yyyy.MM.dd E hh:mm a 로 복구' (#84) from test into main
Reviewed-on: #84
2023-11-14 03:31:54 +00:00
a11c8465d5 Merge pull request '크리에이터 관리자 - @JsonProperty 추가' (#83) from test into main
Reviewed-on: #83
2023-11-13 16:10:57 +00:00
366304a9b7 Merge pull request '크리에이터 관리자 - 정산 API 캐시 추가' (#82) from test into main
Reviewed-on: #82
2023-11-13 15:56:58 +00:00
4356663688 Merge pull request '크리에이터 관리자 - 콘텐츠 누적 매출 API' (#81) from test into main
Reviewed-on: #81
2023-11-13 15:23:53 +00:00
26b55e6fcf Merge pull request '콘텐츠 누적 매출 API - orderType 추가' (#80) from test into main
Reviewed-on: #80
2023-11-13 14:48:47 +00:00
0d743f7204 Merge pull request '콘텐츠 누적 매출 API 추가' (#79) from test into main
Reviewed-on: #79
2023-11-13 13:42:17 +00:00
6cbe113b3e Merge pull request '크리에이터 콘텐츠 정산 - API 추가' (#78) from test into main
Reviewed-on: #78
2023-11-13 10:03:02 +00:00
6409b69d6c Merge pull request '콘텐츠 정산 - 결과값에 JsonProperty 를 추가하여 데이터 파싱이 진행 되도록 수정' (#77) from test into main
Reviewed-on: #77
2023-11-10 13:19:06 +00:00
c5164c76fc Merge pull request '콘텐츠 정산 - group by 날짜 수정' (#76) from test into main
Reviewed-on: #76
2023-11-10 12:46:07 +00:00
baade8e138 Merge pull request 'test' (#75) from test into main
Reviewed-on: #75
2023-11-10 10:47:11 +00:00
b848d6b4e0 Merge pull request 'test' (#74) from test into main
Reviewed-on: #74
2023-11-09 11:18:20 +00:00
d8139d2ab0 Merge pull request '라이브 리스트, 라이브 상세' (#73) from test into main
Reviewed-on: #73
2023-11-07 16:48:25 +00:00
e96d8f7469 Merge pull request 'test' (#72) from test into main
Reviewed-on: #72
2023-11-07 16:23:11 +00:00
2acffd8afc Merge pull request '콘텐츠 메인 API - 캐싱을 적용하기 위해 AudioContentMainManageService 추가' (#71) from test into main
Reviewed-on: #71
2023-11-07 11:24:40 +00:00
3c8e72073c Merge pull request '콘텐츠 메인 API - @Transactional(readOnly = true) 추가' (#70) from test into main
Reviewed-on: #70
2023-11-07 08:47:47 +00:00
724d7a9d9b Merge pull request 'test' (#69) from test into main
Reviewed-on: #69
2023-11-07 08:21:56 +00:00
2da3b0db78 Merge pull request 'test' (#68) from test into main
Reviewed-on: #68
2023-11-06 09:26:36 +00:00
685ad7afaf Merge pull request '콘텐츠 메인 캐싱전략 수정' (#67) from test into main
Reviewed-on: #67
2023-11-06 08:46:55 +00:00
264cf75964 Merge pull request '콘텐츠 메인 - 큐레이션 개수 15개만 노출' (#66) from test into main
Reviewed-on: #66
2023-11-06 08:11:00 +00:00
c773dbc7b5 Merge pull request '콘텐츠 랭킹 - 후원 랭킹 조회 로직 수정' (#65) from test into main
Reviewed-on: #65
2023-11-04 14:24:46 +00:00
37cbc64f52 Merge pull request '본인인증' (#64) from test into main
Reviewed-on: #64
2023-11-03 07:48:02 +00:00
cb1dde17bb Merge pull request 'test' (#63) from test into main
Reviewed-on: #63
2023-11-02 12:18:29 +00:00
c29988acf4 Merge pull request '콘텐츠 주문 - 대여만 가능한 콘텐츠의 경우 소장으로 주문이 들어오더라도 대여로 처리되도록 로직 수정' (#62) from test into main
Reviewed-on: #62
2023-11-01 04:49:18 +00:00
eadbf56dae Merge pull request '정산테이블에 무료충전 코인도 반영되도록 수정' (#61) from test into main
Reviewed-on: #61
2023-10-28 08:42:29 +00:00
4b3b455135 Merge pull request '캔 사용 시 제휴보상 캔도 사용할 수 있도록 수정' (#60) from test into main
Reviewed-on: #60
2023-10-27 13:48:33 +00:00
e6ac177396 Merge pull request '충전내역 - 결제수단에 "제휴보상" 표시' (#59) from test into main
Reviewed-on: #59
2023-10-26 18:48:46 +00:00
3d0e29003f Merge pull request '충전내역 - 결제수단에 "제휴보상" 표시' (#58) from test into main
Reviewed-on: #58
2023-10-26 18:22:10 +00:00
78b9b00f77 Merge pull request '충전내역 - 결제수단에 "제휴보상" 표시' (#57) from test into main
Reviewed-on: #57
2023-10-26 18:02:16 +00:00
0ee7faa551 Merge pull request '카울리를 이용한 무료충전 테이블 adProfit 과 point 타입 int -> float 로 변경' (#56) from test into main
Reviewed-on: #56
2023-10-26 16:55:39 +00:00
e5fdced681 Merge pull request '콘텐츠 메인 캐싱 전략 변경' (#55) from test into main
Reviewed-on: #55
2023-10-26 16:14:37 +00:00
afb99fef64 Merge pull request 'GetAudioContentMainItem - adult를 isAdult로 변경, 캐시 제거' (#54) from test into main
Reviewed-on: #54
2023-10-24 11:39:56 +00:00
7dfaa36024 Merge pull request 'test' (#53) from test into main
Reviewed-on: #53
2023-10-24 10:42:10 +00:00
0496f665aa Merge pull request 'getAudioContentMainBannerList 부분 캐시 제거' (#52) from test into main
Reviewed-on: #52
2023-10-17 10:02:53 +00:00
0d19e1be74 Merge pull request 'audio content banner - lazy 옵션으로 인해 발생하는 com.fasterxml.jackson.databind.exc.InvalidDefinitionException 문제 수정' (#51) from test into main
Reviewed-on: #51
2023-10-17 09:47:44 +00:00
4aff0111aa Merge pull request '로딩 속도를 위해 @Cacheable 적용' (#50) from test into main
Reviewed-on: #50
2023-10-17 09:31:08 +00:00
63b3ba2bb2 Merge pull request '인기 콘텐츠 전체보기 집계날짜 수정' (#49) from test into main
Reviewed-on: #49
2023-10-16 13:59:28 +00:00
7444b41f60 Merge pull request '콘텐츠 메인 - 인기 콘텐츠 집계날짜 수정' (#48) from test into main
Reviewed-on: #48
2023-10-16 13:42:31 +00:00
8e90dbc8b6 Merge pull request '구매목록 - isActive 가 true 인 것만 조회되도록 수정' (#47) from test into main
Reviewed-on: #47
2023-10-16 03:30:10 +00:00
9f70722521 Merge pull request '탐색 인기 크리에이터 - 날짜 설명 글 수정' (#46) from test into main
Reviewed-on: #46
2023-10-14 21:43:48 +00:00
52fae596fa Merge pull request '콘텐츠 랭킹 데이터 전체보기 API - 페이징 추가' (#45) from test into main
Reviewed-on: #45
2023-10-14 21:20:19 +00:00
ccb67957bc Merge pull request '콘텐츠 랭킹 추가' (#44) from test into main
Reviewed-on: #44
2023-10-14 19:37:55 +00:00
fb82538d0d Merge pull request '캔 소비 - 콘텐츠 주문시 캔 소비내역에 콘텐츠 내용 추가' (#43) from test into main
Reviewed-on: #43
2023-10-14 11:33:59 +00:00
72ee39612e Merge pull request '탐색 - 인기 급상승 제거, 인기 크리에이터 섹션 추가' (#42) from test into main
Reviewed-on: #42
2023-10-13 15:41:25 +00:00
51fd5408dc Merge pull request 'test' (#41) from test into main
Reviewed-on: #41
2023-10-06 08:50:32 +00:00
3fae40fbef Merge pull request '콘텐츠 상세 - 댓글 수 로직 답글 포함하지 않도록 수정' (#40) from test into main
Reviewed-on: #40
2023-10-05 02:49:22 +00:00
0745890af0 Merge pull request 'test' (#39) from test into main
Reviewed-on: #39
2023-10-04 03:24:41 +00:00
4abe1730a7 Merge pull request '관리자 라이브 정산 API - 인원 추가' (#38) from test into main
Reviewed-on: #38
2023-10-03 12:10:51 +00:00
626f0e6989 Merge pull request '관리자 - 라이브 정산 API 추가' (#37) from test into main
Reviewed-on: #37
2023-10-03 09:28:04 +00:00
9f42d9d173 Merge pull request '라이브 시작 알림 - 알림 받을 유저 조회에서 에러가 발생하는 버그 수정' (#36) from test into main
Reviewed-on: #36
2023-10-02 13:00:11 +00:00
f90a93c4bc Merge pull request '후원순위 - 유료라이브 입장 캔 반영' (#35) from test into main
Reviewed-on: #35
2023-09-27 14:57:27 +00:00
8000ad6c6a Merge pull request 'test' (#34) from test into main
Reviewed-on: #34
2023-09-27 06:49:21 +00:00
1f1f1bea1a Merge pull request 'test' (#33) from test into main
Reviewed-on: #33
2023-09-27 05:28:04 +00:00
d95460c7cd Merge pull request '닉네임 변경 가격 100 캔으로 변경' (#32) from test into main
Reviewed-on: #32
2023-09-22 07:01:27 +00:00
a3d93d4b08 Merge pull request 'test' (#31) from test into main
Reviewed-on: #31
2023-09-19 06:32:22 +00:00
07a92af982 Merge pull request '라이브 시작시간 4시간이 지나도 라이브를 시작하지 않은 경우 자동취소로직 추가' (#30) from test into main
Reviewed-on: #30
2023-09-12 14:09:37 +00:00
f4618877d4 Merge pull request '주문목록 - 크리에이터 닉네임 추가' (#29) from test into main
Reviewed-on: #29
2023-09-08 16:29:30 +00:00
2b914fd222 Merge pull request 'test' (#28) from test into main
Reviewed-on: #28
2023-09-02 16:12:08 +00:00
109e42a5a3 Merge pull request 'test' (#27) from test into main
Reviewed-on: #27
2023-08-31 08:37:42 +00:00
fa515ad39c Merge pull request '관리자 캔 충전내역 - 애플 인 앱 결제에 PG결제가 같이 나오던 버그 수정' (#26) from test into main
Reviewed-on: #26
2023-08-30 10:02:32 +00:00
f09673a795 Merge pull request 'test' (#25) from test into main
Reviewed-on: #25
2023-08-30 08:10:29 +00:00
f71536c614 Merge pull request 'test' (#24) from test into main
Reviewed-on: #24
2023-08-30 07:24:14 +00:00
7bdddc7ae8 Merge pull request '후원 전체보기 - 하단 랭킹에 콘텐츠 후원도 포함' (#23) from test into main
Reviewed-on: #23
2023-08-29 15:22:09 +00:00
aa8926a624 Merge pull request '후원 전체보기 - 상단 캔 현황 을 후원 캔만 반영하도록 수정' (#22) from test into main
Reviewed-on: #22
2023-08-29 14:53:44 +00:00
be71e59be2 Merge pull request '무료 콘텐츠를 못올리는 버그 수정' (#21) from test into main
Reviewed-on: #21
2023-08-29 13:32:13 +00:00
4d7753378f Merge pull request 'test' (#20) from test into main
Reviewed-on: #20
2023-08-29 07:33:57 +00:00
60257c4ef4 Merge pull request 'test' (#19) from test into main
Reviewed-on: #19
2023-08-28 08:54:08 +00:00
1e0b79bf62 Merge pull request 'test' (#18) from test into main
Reviewed-on: #18
2023-08-27 12:28:42 +00:00
6883434d0d Merge pull request '유저 관심사, 라이브 관심사 - 연령제한 설정 추가' (#17) from test into main
Reviewed-on: #17
2023-08-25 08:38:51 +00:00
eda2193e64 Merge pull request 'test' (#16) from test into main
Reviewed-on: #16
2023-08-25 07:50:55 +00:00
99bf829c88 Merge pull request '첫 충전 이벤트 - 본인인증한 전체 계정 중 첫 충전 시에만 첫충전 이벤트 적용' (#15) from test into main
Reviewed-on: #15
2023-08-24 16:26:54 +00:00
5feafe1b48 Merge pull request 'test' (#14) from test into main
Reviewed-on: #14
2023-08-24 14:54:37 +00:00
c9292b7d04 Merge pull request 'test' (#13) from test into main
Reviewed-on: #13
2023-08-23 14:05:00 +00:00
ae7e1a91c1 Merge pull request '캔 충전로직 수정' (#12) from test into main
Reviewed-on: #12
2023-08-21 15:12:44 +00:00
3e1887e0d1 Merge pull request 'test' (#11) from test into main
Reviewed-on: #11
2023-08-21 13:19:03 +00:00
474646db47 Merge pull request '스피커 최대 10 -> 5명으로 수정' (#10) from test into main
Reviewed-on: #10
2023-08-20 20:40:23 +00:00
56f7b6c449 Merge pull request 'test' (#9) from test into main
Reviewed-on: #9
2023-08-20 19:22:23 +00:00
76b2b5f7e3 Merge pull request '캔 사용내역 - 후원을 콘텐츠 후원, 라이브 후원으로 분리' (#8) from test into main
Reviewed-on: #8
2023-08-20 15:46:30 +00:00
e918d809eb Merge pull request '충전내역 - 관리자 지급 추가' (#7) from test into main
Reviewed-on: #7
2023-08-20 15:08:35 +00:00
7af059e543 Merge pull request 'test' (#6) from test into main
Reviewed-on: #6
2023-08-20 14:45:39 +00:00
897726e1ec Merge pull request 'test' (#5) from test into main
Reviewed-on: #5
2023-08-19 17:47:16 +00:00
8b98a2dd07 Merge pull request '비밀번호 찾기 API 추가' (#4) from test into main
Reviewed-on: #4
2023-08-19 07:05:17 +00:00
cca75420f0 Merge pull request '큐레이션 - 조건 추가' (#3) from test into main
Reviewed-on: #3
2023-08-18 14:07:34 +00:00
86c627ed1d Merge pull request 'test' (#2) from test into main
Reviewed-on: #2
2023-08-18 12:54:09 +00:00
d55514e3a7 Merge pull request 'test' (#1) from test into main
Reviewed-on: #1
2023-08-16 02:30:36 +00:00
287 changed files with 497 additions and 13445 deletions

View File

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

3
.gitignore vendored
View File

@@ -323,7 +323,4 @@ 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

@@ -65,14 +65,9 @@ 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")
runtimeOnly("com.mysql:mysql-connector-j")

View File

@@ -1,6 +1,5 @@
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
@@ -39,10 +38,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.room, liveRoom)
.innerJoin(liveRoom.member, member)
.leftJoin(creatorSettlementRatio)
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.on(member.id.eq(creatorSettlementRatio.member.id))
.where(
useCan.isRefund.isFalse
.and(useCan.createdAt.goe(startDate))
@@ -55,10 +51,6 @@ 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(
@@ -70,7 +62,6 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
order.can,
order.id.count(),
order.can.sum(),
order.point.sum(),
creatorSettlementRatio.contentSettlementRatio
)
)
@@ -78,10 +69,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio)
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.on(member.id.eq(creatorSettlementRatio.member.id))
.where(
order.createdAt.goe(startDate)
.and(order.createdAt.loe(endDate))
@@ -92,7 +80,6 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
order.type,
orderFormattedDate,
order.can,
pointGroup,
creatorSettlementRatio.contentSettlementRatio
)
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
@@ -126,10 +113,6 @@ 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(
@@ -140,7 +123,6 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
order.can,
order.id.count(),
order.can.sum(),
order.point.sum(),
creatorSettlementRatio.contentSettlementRatio
)
)
@@ -148,19 +130,9 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio)
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.on(member.id.eq(creatorSettlementRatio.member.id))
.where(order.isActive.isTrue)
.groupBy(
member.id,
audioContent.id,
order.type,
order.can,
pointGroup,
creatorSettlementRatio.contentSettlementRatio
)
.groupBy(member.id, audioContent.id, order.type, order.can)
.offset(offset)
.limit(limit)
.orderBy(member.id.desc(), audioContent.id.desc())
@@ -239,10 +211,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.communityPost, creatorCommunity)
.innerJoin(creatorCommunity.member, member)
.leftJoin(creatorSettlementRatio)
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.on(member.id.eq(creatorSettlementRatio.member.id))
.where(
useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
@@ -263,10 +232,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.room, liveRoom)
.innerJoin(liveRoom.member, member)
.leftJoin(creatorSettlementRatio)
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.on(member.id.eq(creatorSettlementRatio.member.id))
.where(
useCan.isRefund.isFalse
.and(useCan.createdAt.goe(startDate))
@@ -296,10 +262,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.room, liveRoom)
.innerJoin(liveRoom.member, member)
.leftJoin(creatorSettlementRatio)
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.on(member.id.eq(creatorSettlementRatio.member.id))
.where(
useCan.isRefund.isFalse
.and(useCan.createdAt.goe(startDate))
@@ -319,10 +282,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio)
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.on(member.id.eq(creatorSettlementRatio.member.id))
.where(
order.createdAt.goe(startDate)
.and(order.createdAt.loe(endDate))
@@ -352,10 +312,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio)
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.on(member.id.eq(creatorSettlementRatio.member.id))
.where(
order.createdAt.goe(startDate)
.and(order.createdAt.loe(endDate))
@@ -375,10 +332,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.communityPost, creatorCommunity)
.innerJoin(creatorCommunity.member, member)
.leftJoin(creatorSettlementRatio)
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.on(member.id.eq(creatorSettlementRatio.member.id))
.where(
useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
@@ -409,10 +363,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.communityPost, creatorCommunity)
.innerJoin(creatorCommunity.member, member)
.leftJoin(creatorSettlementRatio)
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.on(member.id.eq(creatorSettlementRatio.member.id))
.where(
useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ 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
@@ -10,29 +9,12 @@ import javax.persistence.OneToOne
@Entity
data class CreatorSettlementRatio(
var subsidy: Int,
var liveSettlementRatio: Int,
var contentSettlementRatio: Int,
var communitySettlementRatio: Int
val subsidy: Int,
val liveSettlementRatio: Int,
val contentSettlementRatio: Int,
val 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,7 +4,6 @@ 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
@@ -28,14 +27,4 @@ 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,9 +7,7 @@ import org.springframework.data.jpa.repository.JpaRepository
interface CreatorSettlementRatioRepository :
JpaRepository<CreatorSettlementRatio, Long>,
CreatorSettlementRatioQueryRepository {
fun findByMemberId(memberId: Long): CreatorSettlementRatio?
}
CreatorSettlementRatioQueryRepository
interface CreatorSettlementRatioQueryRepository {
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
@@ -23,7 +21,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
return queryFactory
.select(
QGetCreatorSettlementRatioItem(
member.id,
member.nickname,
creatorSettlementRatio.subsidy,
creatorSettlementRatio.liveSettlementRatio,
@@ -33,7 +30,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
)
.from(creatorSettlementRatio)
.innerJoin(creatorSettlementRatio.member, member)
.where(creatorSettlementRatio.deletedAt.isNull)
.orderBy(creatorSettlementRatio.id.asc())
.offset(offset)
.limit(limit)
@@ -44,7 +40,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
return queryFactory
.select(creatorSettlementRatio.id)
.from(creatorSettlementRatio)
.where(creatorSettlementRatio.deletedAt.isNull)
.fetch()
.size
}

View File

@@ -14,6 +14,8 @@ class CreatorSettlementRatioService(
) {
@Transactional
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
val creatorSettlementRatio = request.toEntity()
val creator = memberRepository.findByIdOrNull(request.memberId)
?: throw SodaException("잘못된 크리에이터 입니다.")
@@ -21,52 +23,10 @@ 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,7 +8,6 @@ data class GetCreatorSettlementRatioResponse(
)
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
val memberId: Long,
val nickname: String,
val subsidy: Int,
val liveSettlementRatio: Int,

View File

@@ -1,10 +1,8 @@
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
@@ -15,11 +13,6 @@ 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,38 +1,6 @@
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>, 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()
}
}
interface AdminCanRepository : JpaRepository<Can, Long>

View File

@@ -3,13 +3,11 @@ 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: BigDecimal,
val currency: String
val price: Int
) {
fun toEntity(): Can {
var title = "${can.moneyFormat()}"
@@ -22,7 +20,6 @@ data class AdminCanRequest(
can = can,
rewardCan = rewardCan,
price = price,
currency = currency,
status = CanStatus.SALE
)
}

View File

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

View File

@@ -1,6 +1,5 @@
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
@@ -15,7 +14,7 @@ import java.time.LocalDateTime
@Repository
class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory) {
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusResponse> {
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusQueryDto> {
val formattedDate = Expressions.stringTemplate(
"DATE_FORMAT({0}, {1})",
Expressions.dateTimeTemplate(
@@ -27,16 +26,15 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
),
"%Y-%m-%d"
)
val currency = Expressions.stringTemplate("substring({0}, length({0}) - 2, 3)", payment.locale)
return queryFactory
.select(
QGetChargeStatusResponse(
QGetChargeStatusQueryDto(
formattedDate,
payment.price.sum(),
can1.price.sum(),
payment.id.count(),
payment.paymentGateway.stringValue(),
currency.coalesce("KRW")
payment.paymentGateway
)
)
.from(payment)
@@ -48,46 +46,15 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
.and(charge.status.eq(ChargeStatus.CHARGE))
.and(payment.status.eq(PaymentStatus.COMPLETE))
)
.groupBy(formattedDate, payment.paymentGateway, currency.coalesce("KRW"))
.groupBy(formattedDate, payment.paymentGateway)
.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,
currency: String? = null
paymentGateway: PaymentGateway
): List<GetChargeStatusDetailQueryDto> {
val formattedDate = Expressions.stringTemplate(
"DATE_FORMAT({0}, {1})",
@@ -100,20 +67,6 @@ 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(
@@ -122,7 +75,8 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
member.nickname,
payment.method.coalesce(""),
payment.price,
currencyExpr,
can1.price,
payment.locale.coalesce(""),
formattedDate
)
)
@@ -130,7 +84,13 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
.innerJoin(charge.member, member)
.innerJoin(charge.payment, payment)
.leftJoin(charge.can, can1)
.where(whereBuilder)
.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))
)
.orderBy(formattedDate.desc())
.fetch()
}

View File

@@ -20,17 +20,48 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
val summaryRows = repository.getChargeStatusSummary(startDate, endDate)
val chargeStatusList = repository.getChargeStatus(startDate, endDate).toMutableList()
chargeStatusList.addAll(0, summaryRows)
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 = ""
)
)
return chargeStatusList.toList()
}
fun getChargeStatusDetail(
startDateStr: String,
paymentGateway: PaymentGateway,
currency: String? = null
paymentGateway: PaymentGateway
): List<GetChargeStatusDetailResponse> {
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
@@ -43,16 +74,18 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway, currency)
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway)
.asSequence()
.map {
GetChargeStatusDetailResponse(
memberId = it.memberId,
nickname = it.nickname,
method = it.method,
amount = it.amount,
amount = it.appleChargeAmount.toInt(),
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 amount: BigDecimal,
val appleChargeAmount: Double,
val pgChargeAmount: Int,
val locale: String,
val datetime: String
)

View File

@@ -1,12 +1,10 @@
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: BigDecimal,
val amount: Int,
val locale: String,
val datetime: String
)

View File

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

View File

@@ -1,229 +0,0 @@
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

@@ -1,32 +0,0 @@
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

@@ -1,139 +0,0 @@
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

@@ -1,49 +0,0 @@
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

@@ -1,62 +0,0 @@
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

@@ -1,423 +0,0 @@
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

@@ -1,82 +0,0 @@
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

@@ -1,45 +0,0 @@
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

@@ -1,153 +0,0 @@
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

@@ -1,132 +0,0 @@
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

@@ -1,90 +0,0 @@
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

@@ -1,62 +0,0 @@
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

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

View File

@@ -1,30 +0,0 @@
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

@@ -1,170 +0,0 @@
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

@@ -1,53 +0,0 @@
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

@@ -1,78 +0,0 @@
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

@@ -1,30 +0,0 @@
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

@@ -1,32 +0,0 @@
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

@@ -1,199 +0,0 @@
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

@@ -1,95 +0,0 @@
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

@@ -1,213 +0,0 @@
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

@@ -140,7 +140,6 @@ class AdminAudioContentQueryRepositoryImpl(
audioContent.duration.isNotNull
.and(audioContent.member.isNotNull)
.and(audioContentHashTag.audioContent.id.eq(audioContentId))
.and(audioContentHashTag.isActive.isTrue)
)
.fetch()
}

View File

@@ -38,7 +38,6 @@ class AdminRecommendSeriesQueryRepositoryImpl(
.and(series.isActive.isTrue)
.and(recommendSeries.isFree.eq(isFree))
)
.orderBy(recommendSeries.orders.asc())
.fetch()
}
}

View File

@@ -156,8 +156,8 @@ class AdminEventBannerService(
)
}
if (event.link != link) {
event.link = if (link.isNullOrBlank()) null else link
if (!link.isNullOrBlank() && event.link != link) {
event.link = link
}
if (!title.isNullOrBlank() && event.title != title) {

View File

@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.admin.member
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberProvider
import kr.co.vividnext.sodalive.member.MemberRole
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Pageable
@@ -99,13 +98,6 @@ class AdminMemberService(
MemberRole.BOT -> ""
}
val loginType = when (it.provider) {
MemberProvider.EMAIL -> "이메일"
MemberProvider.KAKAO -> "카카오"
MemberProvider.GOOGLE -> "구글"
MemberProvider.APPLE -> "애플"
}
val signUpDate = it.createdAt!!
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
@@ -130,7 +122,6 @@ class AdminMemberService(
"$cloudFrontHost/profile/default-profile.png"
},
userType = userType,
loginType = loginType,
container = it.container,
auth = it.auth != null,
signUpDate = signUpDate,

View File

@@ -11,7 +11,6 @@ data class GetAdminMemberListResponseItem(
val nickname: String,
val profileUrl: String,
val userType: String,
val loginType: String,
val container: String,
val auth: Boolean,
val signUpDate: String,

View File

@@ -1,45 +0,0 @@
package kr.co.vividnext.sodalive.admin.point
import kr.co.vividnext.sodalive.point.PointRewardPolicy
import kr.co.vividnext.sodalive.useraction.ActionType
import kr.co.vividnext.sodalive.useraction.PolicyType
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
data class CreatePointRewardPolicyRequest(
val title: String,
val policyType: PolicyType,
val actionType: ActionType,
val threshold: Int,
val availableCount: Int,
val pointAmount: Int,
val startDate: String,
val endDate: String
) {
fun toEntity(): PointRewardPolicy {
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
return PointRewardPolicy(
title = title,
policyType = policyType,
actionType = actionType,
threshold = threshold,
availableCount = availableCount,
pointAmount = pointAmount,
startDate = LocalDateTime.parse(startDate, dateTimeFormatter)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime(),
endDate = if (endDate.isNotBlank()) {
LocalDateTime.parse(endDate, dateTimeFormatter).withSecond(59)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
} else {
null
},
isActive = true
)
}
}

View File

@@ -1,23 +0,0 @@
package kr.co.vividnext.sodalive.admin.point
import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.useraction.ActionType
import kr.co.vividnext.sodalive.useraction.PolicyType
data class GetPointRewardPolicyListResponse(
val totalCount: Int,
val items: List<GetPointRewardPolicyListItem>
)
data class GetPointRewardPolicyListItem @QueryProjection constructor(
val id: Long,
val title: String,
val policyType: PolicyType,
val actionType: ActionType,
val threshold: Int,
val availableCount: Int,
val pointAmount: Int,
val startDate: String,
val endDate: String,
val isActive: Boolean
)

View File

@@ -1,8 +0,0 @@
package kr.co.vividnext.sodalive.admin.point
data class ModifyPointRewardPolicyRequest(
val title: String?,
val startDate: String?,
val endDate: String?,
val isActive: Boolean?
)

View File

@@ -1,36 +0,0 @@
package kr.co.vividnext.sodalive.admin.point
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.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/admin/point-policies")
@PreAuthorize("hasRole('ADMIN')")
class PointPolicyController(private val service: PointPolicyService) {
@GetMapping
fun getAll(pageable: Pageable) = ApiResponse.ok(
service.getAll(
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
@PostMapping
fun create(
@RequestBody request: CreatePointRewardPolicyRequest
) = ApiResponse.ok(service.create(request))
@PutMapping("/{id}")
fun update(
@PathVariable id: Long,
@RequestBody request: ModifyPointRewardPolicyRequest
) = ApiResponse.ok(service.update(id, request))
}

View File

@@ -1,59 +0,0 @@
package kr.co.vividnext.sodalive.admin.point
import com.querydsl.core.types.dsl.DateTimePath
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.core.types.dsl.StringTemplate
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.point.PointRewardPolicy
import kr.co.vividnext.sodalive.point.QPointRewardPolicy.pointRewardPolicy
import org.springframework.data.jpa.repository.JpaRepository
import java.time.LocalDateTime
interface PointPolicyRepository : JpaRepository<PointRewardPolicy, Long>, PointPolicyQueryRepository
interface PointPolicyQueryRepository {
fun getTotalCount(): Int
fun getAll(offset: Long, limit: Long): List<GetPointRewardPolicyListItem>
}
class PointPolicyQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : PointPolicyQueryRepository {
override fun getTotalCount(): Int {
return queryFactory
.select(pointRewardPolicy.id)
.from(pointRewardPolicy)
.fetch()
.size
}
override fun getAll(offset: Long, limit: Long): List<GetPointRewardPolicyListItem> {
return queryFactory
.select(
QGetPointRewardPolicyListItem(
pointRewardPolicy.id,
pointRewardPolicy.title,
pointRewardPolicy.policyType,
pointRewardPolicy.actionType,
pointRewardPolicy.threshold,
pointRewardPolicy.availableCount,
pointRewardPolicy.pointAmount,
getFormattedDate(pointRewardPolicy.startDate),
getFormattedDate(pointRewardPolicy.endDate),
pointRewardPolicy.isActive
)
)
.from(pointRewardPolicy)
.orderBy(pointRewardPolicy.isActive.desc(), pointRewardPolicy.startDate.desc())
.offset(offset)
.limit(limit)
.fetch()
}
private fun getFormattedDate(dateTimePath: DateTimePath<LocalDateTime>): StringTemplate {
return Expressions.stringTemplate(
"COALESCE(DATE_FORMAT(CONVERT_TZ({0}, 'UTC', 'Asia/Seoul'), '%Y-%m-%d %H:%i'), '')",
dateTimePath
)
}
}

View File

@@ -1,55 +0,0 @@
package kr.co.vividnext.sodalive.admin.point
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Service
class PointPolicyService(private val repository: PointPolicyRepository) {
fun getAll(offset: Long, limit: Long): GetPointRewardPolicyListResponse {
val totalCount = repository.getTotalCount()
val items = repository.getAll(offset, limit)
return GetPointRewardPolicyListResponse(totalCount, items)
}
@Transactional
fun create(request: CreatePointRewardPolicyRequest) {
val pointPolicy = request.toEntity()
repository.save(pointPolicy)
}
@Transactional
fun update(id: Long, request: ModifyPointRewardPolicyRequest) {
val pointPolicy = repository.findByIdOrNull(id)
?: throw SodaException("잘못된 접근입니다.")
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
if (request.title != null) {
pointPolicy.title = request.title
}
if (request.startDate != null) {
pointPolicy.startDate = LocalDateTime.parse(request.startDate, dateTimeFormatter)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
}
if (request.endDate != null) {
pointPolicy.endDate = LocalDateTime.parse(request.endDate, dateTimeFormatter).withSecond(59)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
}
if (request.isActive != null) {
pointPolicy.isActive = request.isActive
}
}
}

View File

@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.admin.statistics.ad
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.NumberExpression
import com.querydsl.core.types.dsl.StringTemplate
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
@@ -45,12 +46,6 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
.otherwise(0)
.sum()
val launchCount = CaseBuilder()
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.APP_LAUNCH))
.then(1)
.otherwise(0)
.sum()
val loginCount = CaseBuilder()
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.LOGIN))
.then(1)
@@ -66,7 +61,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
val firstPaymentTotalAmount = CaseBuilder()
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT))
.then(adTrackingHistory.price)
.otherwise(0.toBigDecimal())
.otherwise(Expressions.constant(0.0))
.sum()
val repeatPaymentCount = CaseBuilder()
@@ -78,7 +73,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
val repeatPaymentTotalAmount = CaseBuilder()
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
.then(adTrackingHistory.price)
.otherwise(0.toBigDecimal())
.otherwise(Expressions.constant(0.0))
.sum()
val allPaymentCount = CaseBuilder()
@@ -96,7 +91,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
.or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
)
.then(adTrackingHistory.price)
.otherwise(0.toBigDecimal())
.otherwise(Expressions.constant(0.0))
.sum()
return queryFactory
@@ -106,15 +101,14 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
adTrackingHistory.mediaGroup,
adTrackingHistory.pid,
adTrackingHistory.pidName,
launchCount,
loginCount,
signUpCount,
firstPaymentCount,
firstPaymentTotalAmount,
roundedValueDecimalPlaces2(firstPaymentTotalAmount),
repeatPaymentCount,
repeatPaymentTotalAmount,
roundedValueDecimalPlaces2(repeatPaymentTotalAmount),
allPaymentCount,
allPaymentTotalAmount
roundedValueDecimalPlaces2(allPaymentTotalAmount)
)
)
.from(adTrackingHistory)
@@ -147,4 +141,13 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
"%Y-%m-%d"
)
}
private fun roundedValueDecimalPlaces2(valueExpression: NumberExpression<Double>): NumberExpression<Double> {
return Expressions.numberTemplate(
Double::class.java,
"ROUND({0}, {1})",
valueExpression,
2
)
}
}

View File

@@ -1,7 +1,6 @@
package kr.co.vividnext.sodalive.admin.statistics.ad
import com.querydsl.core.annotations.QueryProjection
import java.math.BigDecimal
data class GetAdminAdStatisticsResponse(
val totalCount: Int,
@@ -13,13 +12,12 @@ data class GetAdminAdStatisticsItem @QueryProjection constructor(
val mediaGroup: String,
val pid: String,
val pidName: String,
val launchCount: Int,
val loginCount: Int,
val signUpCount: Int,
val firstPaymentCount: Int,
val firstPaymentTotalAmount: BigDecimal,
val firstPaymentTotalAmount: Double,
val repeatPaymentCount: Int,
val repeatPaymentTotalAmount: BigDecimal,
val repeatPaymentTotalAmount: Double,
val allPaymentCount: Int,
val allPaymentTotalAmount: BigDecimal
val allPaymentTotalAmount: Double
)

View File

@@ -8,10 +8,8 @@ import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.charge.QCharge.charge
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.can.payment.QPayment.payment
import kr.co.vividnext.sodalive.member.MemberProvider
import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.member.QSignOut.signOut
import kr.co.vividnext.sodalive.member.auth.QAuth.auth
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@@ -29,57 +27,6 @@ class AdminMemberStatisticsRepository(private val queryFactory: JPAQueryFactory)
.size
}
fun getTotalSignUpEmailCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
return queryFactory
.select(member.id)
.from(member)
.where(
member.createdAt.goe(startDate),
member.createdAt.loe(endDate),
member.provider.eq(MemberProvider.EMAIL)
)
.fetch()
.size
}
fun getTotalSignUpKakaoCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
return queryFactory
.select(member.id)
.from(member)
.where(
member.createdAt.goe(startDate),
member.createdAt.loe(endDate),
member.provider.eq(MemberProvider.KAKAO)
)
.fetch()
.size
}
fun getTotalSignUpGoogleCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
return queryFactory
.select(member.id)
.from(member)
.where(
member.createdAt.goe(startDate),
member.createdAt.loe(endDate),
member.provider.eq(MemberProvider.GOOGLE)
)
.fetch()
.size
}
fun getTotalAuthCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
return queryFactory
.select(auth.id)
.from(auth)
.where(
auth.createdAt.goe(startDate),
auth.createdAt.loe(endDate)
)
.fetch()
.size
}
fun getTotalSignOutCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
return queryFactory
.select(signOut.id)
@@ -132,81 +79,6 @@ class AdminMemberStatisticsRepository(private val queryFactory: JPAQueryFactory)
.fetch()
}
fun getSignUpEmailCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> {
return queryFactory
.select(
QDateAndMemberCount(
getFormattedDate(member.createdAt),
member.id.countDistinct().castToNum(Int::class.java)
)
)
.from(member)
.where(
member.createdAt.goe(startDate),
member.createdAt.loe(endDate),
member.provider.eq(MemberProvider.EMAIL)
)
.groupBy(getFormattedDate(member.createdAt))
.orderBy(getFormattedDate(member.createdAt).desc())
.fetch()
}
fun getSignUpKakaoCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> {
return queryFactory
.select(
QDateAndMemberCount(
getFormattedDate(member.createdAt),
member.id.countDistinct().castToNum(Int::class.java)
)
)
.from(member)
.where(
member.createdAt.goe(startDate),
member.createdAt.loe(endDate),
member.provider.eq(MemberProvider.KAKAO)
)
.groupBy(getFormattedDate(member.createdAt))
.orderBy(getFormattedDate(member.createdAt).desc())
.fetch()
}
fun getSignUpGoogleCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> {
return queryFactory
.select(
QDateAndMemberCount(
getFormattedDate(member.createdAt),
member.id.countDistinct().castToNum(Int::class.java)
)
)
.from(member)
.where(
member.createdAt.goe(startDate),
member.createdAt.loe(endDate),
member.provider.eq(MemberProvider.GOOGLE)
)
.groupBy(getFormattedDate(member.createdAt))
.orderBy(getFormattedDate(member.createdAt).desc())
.fetch()
}
fun getAuthCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> {
return queryFactory
.select(
QDateAndMemberCount(
getFormattedDate(auth.createdAt),
auth.id.countDistinct().castToNum(Int::class.java)
)
)
.from(auth)
.where(
auth.createdAt.goe(startDate),
auth.createdAt.loe(endDate)
)
.groupBy(getFormattedDate(auth.createdAt))
.orderBy(getFormattedDate(auth.createdAt).desc())
.fetch()
}
fun getSignOutCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> {
return queryFactory
.select(

View File

@@ -46,19 +46,6 @@ class AdminMemberStatisticsService(private val repository: AdminMemberStatistics
.toLocalDateTime()
val totalSignUpCount = repository.getTotalSignUpCount(startDate = startDateTime, endDate = endDateTime)
val totalSignUpEmailCount = repository.getTotalSignUpEmailCount(
startDate = startDateTime,
endDate = endDateTime
)
val totalSignUpKakaoCount = repository.getTotalSignUpKakaoCount(
startDate = startDateTime,
endDate = endDateTime
)
val totalSignUpGoogleCount = repository.getTotalSignUpGoogleCount(
startDate = startDateTime,
endDate = endDateTime
)
val totalAuthCount = repository.getTotalAuthCount(startDate = startDateTime, endDate = endDateTime)
val totalSignOutCount = repository.getTotalSignOutCount(startDate = startDateTime, endDate = endDateTime)
val totalPaymentMemberCount = repository.getPaymentMemberCount(startDate = startDateTime, endDate = endDateTime)
@@ -77,26 +64,6 @@ class AdminMemberStatisticsService(private val repository: AdminMemberStatistics
endDate = endDateTime
).associateBy({ it.date }, { it.memberCount })
val signUpEmailCountInRange = repository.getSignUpEmailCountInRange(
startDate = startDateTime,
endDate = endDateTime
).associateBy({ it.date }, { it.memberCount })
val signUpKakaoCountInRange = repository.getSignUpKakaoCountInRange(
startDate = startDateTime,
endDate = endDateTime
).associateBy({ it.date }, { it.memberCount })
val signUpGoogleCountInRange = repository.getSignUpGoogleCountInRange(
startDate = startDateTime,
endDate = endDateTime
).associateBy({ it.date }, { it.memberCount })
val authCountInRange = repository.getAuthCountInRange(
startDate = startDateTime,
endDate = endDateTime
).associateBy({ it.date }, { it.memberCount })
val signOutCountInRange = repository.getSignOutCountInRange(
startDate = startDateTime,
endDate = endDateTime
@@ -116,11 +83,7 @@ class AdminMemberStatisticsService(private val repository: AdminMemberStatistics
val date = it.format(formatter)
GetMemberStatisticsItem(
date = date,
authCount = authCountInRange[date] ?: 0,
signUpCount = signUpCountInRange[date] ?: 0,
signUpEmailCount = signUpEmailCountInRange[date] ?: 0,
signUpKakaoCount = signUpKakaoCountInRange[date] ?: 0,
signUpGoogleCount = signUpGoogleCountInRange[date] ?: 0,
signOutCount = signOutCountInRange[date] ?: 0,
paymentMemberCount = paymentMemberCountInRangeMap[date] ?: 0
)
@@ -129,11 +92,7 @@ class AdminMemberStatisticsService(private val repository: AdminMemberStatistics
return GetMemberStatisticsResponse(
totalCount = dateRange.totalDays,
totalAuthCount = totalAuthCount,
totalSignUpCount = totalSignUpCount,
totalSignUpEmailCount = totalSignUpEmailCount,
totalSignUpKakaoCount = totalSignUpKakaoCount,
totalSignUpGoogleCount = totalSignUpGoogleCount,
totalSignOutCount = totalSignOutCount,
totalPaymentMemberCount = totalPaymentMemberCount,
items = items

View File

@@ -2,11 +2,7 @@ package kr.co.vividnext.sodalive.admin.statistics.member
data class GetMemberStatisticsResponse(
val totalCount: Int,
val totalAuthCount: Int,
val totalSignUpCount: Int,
val totalSignUpEmailCount: Int,
val totalSignUpKakaoCount: Int,
val totalSignUpGoogleCount: Int,
val totalSignOutCount: Int,
val totalPaymentMemberCount: Int,
val items: List<GetMemberStatisticsItem>
@@ -14,11 +10,7 @@ data class GetMemberStatisticsResponse(
data class GetMemberStatisticsItem(
val date: String,
val authCount: Int,
val signUpCount: Int,
val signUpEmailCount: Int,
val signUpKakaoCount: Int,
val signUpGoogleCount: Int,
val signOutCount: Int,
val paymentMemberCount: Int
)

View File

@@ -1,28 +0,0 @@
package kr.co.vividnext.sodalive.api.home
import kr.co.vividnext.sodalive.audition.GetAuditionListItem
import kr.co.vividnext.sodalive.content.AudioContentMainItem
import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem
import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
import kr.co.vividnext.sodalive.event.GetEventResponse
import kr.co.vividnext.sodalive.explorer.GetExplorerSectionCreatorResponse
import kr.co.vividnext.sodalive.live.room.GetRoomListResponse
import kr.co.vividnext.sodalive.query.recommend.RecommendChannelResponse
data class GetHomeResponse(
val liveList: List<GetRoomListResponse>,
val creatorRanking: List<GetExplorerSectionCreatorResponse>,
val latestContentThemeList: List<String>,
val latestContentList: List<AudioContentMainItem>,
val bannerList: List<GetAudioContentBannerResponse>,
val eventBannerList: GetEventResponse,
val originalAudioDramaList: List<GetSeriesListResponse.SeriesListItem>,
val auditionList: List<GetAuditionListItem>,
val dayOfWeekSeriesList: List<GetSeriesListResponse.SeriesListItem>,
val contentRanking: List<GetAudioContentRankingItem>,
val recommendChannelList: List<RecommendChannelResponse>,
val freeContentList: List<AudioContentMainItem>,
val curationList: List<GetContentCurationResponse>
)

View File

@@ -1,66 +0,0 @@
package kr.co.vividnext.sodalive.api.home
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.member.Member
import org.springframework.security.core.annotation.AuthenticationPrincipal
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
@RequestMapping("/api/home")
class HomeController(private val service: HomeService) {
@GetMapping
fun fetchData(
@RequestParam timezone: String,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
service.fetchData(
timezone = timezone,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member
)
)
}
@GetMapping("/latest-content")
fun getLatestContentByTheme(
@RequestParam("theme") theme: String,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
service.getLatestContentByTheme(
theme = theme,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member
)
)
}
@GetMapping("/day-of-week-series")
fun getDayOfWeekSeriesList(
@RequestParam("dayOfWeek") dayOfWeek: SeriesPublishedDaysOfWeek,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
service.getDayOfWeekSeriesList(
dayOfWeek = dayOfWeek,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member
)
)
}
}

View File

@@ -1,265 +0,0 @@
package kr.co.vividnext.sodalive.api.home
import kr.co.vividnext.sodalive.audition.AuditionService
import kr.co.vividnext.sodalive.content.AudioContentMainItem
import kr.co.vividnext.sodalive.content.AudioContentService
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationService
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.event.GetEventResponse
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.live.room.LiveRoomService
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberService
import kr.co.vividnext.sodalive.query.recommend.RecommendChannelQueryService
import kr.co.vividnext.sodalive.rank.RankingRepository
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import java.time.DayOfWeek
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.temporal.TemporalAdjusters
@Service
class HomeService(
private val memberService: MemberService,
private val liveRoomService: LiveRoomService,
private val auditionService: AuditionService,
private val seriesService: ContentSeriesService,
private val contentService: AudioContentService,
private val bannerService: AudioContentBannerService,
private val curationService: AudioContentCurationService,
private val contentThemeService: AudioContentThemeService,
private val recommendChannelService: RecommendChannelQueryService,
private val rankingService: RankingService,
private val rankingRepository: RankingRepository,
private val explorerQueryRepository: ExplorerQueryRepository,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
fun fetchData(
timezone: String,
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member?
): GetHomeResponse {
val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible
val liveList = liveRoomService.getRoomList(
dateString = null,
status = LiveRoomStatus.NOW,
isAdultContentVisible = isAdultContentVisible,
pageable = Pageable.ofSize(10),
member = member,
timezone = timezone
)
val creatorRanking = rankingRepository
.getCreatorRankings()
.filter {
if (memberId != null) {
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.id!!)
} else {
true
}
}
.map {
val followerCount = explorerQueryRepository.getNotificationUserIds(it.id!!).size
val follow = if (memberId != null) {
explorerQueryRepository.isFollow(it.id!!, memberId = memberId)
} else {
false
}
it.toExplorerSectionCreator(imageHost, follow, followerCount = followerCount)
}
val latestContentThemeList = contentThemeService.getActiveThemeOfContent(
isAdult = isAdult,
contentType = contentType
)
val latestContentList = contentService.getLatestContentByTheme(
theme = latestContentThemeList,
contentType = contentType,
isFree = false,
isAdult = isAdult
).filter {
if (memberId != null) {
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
} else {
true
}
}
val eventBannerList = GetEventResponse(
totalCount = 0,
eventList = emptyList()
)
val bannerList = bannerService.getBannerList(
tabId = 1,
memberId = member?.id,
isAdult = isAdult
)
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
isAdult = isAdult,
contentType = contentType
)
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
memberId = memberId,
isAdult = isAdult,
contentType = contentType,
dayOfWeek = getDayOfWeekByTimezone(timezone)
)
val currentDateTime = LocalDateTime.now()
val startDate = currentDateTime
.withHour(15)
.withMinute(0)
.withSecond(0)
.minusWeeks(1)
.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
val endDate = startDate
.plusDays(6)
val contentRanking = rankingService.getContentRanking(
memberId = memberId,
isAdult = isAdult,
contentType = contentType,
startDate = startDate.minusDays(1),
endDate = endDate,
sortType = "매출"
)
// TODO 오디오 북
val recommendChannelList = recommendChannelService.getRecommendChannel(
memberId = memberId,
isAdult = isAdult,
contentType = contentType
)
val freeContentList = contentService.getLatestContentByTheme(
theme = contentThemeService.getActiveThemeOfContent(
isAdult = isAdult,
isFree = true,
contentType = contentType
),
contentType = contentType,
isFree = true,
isAdult = isAdult
).filter {
if (memberId != null) {
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
} else {
true
}
}
val curationList = curationService.getContentCurationList(
tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용
isAdult = isAdult,
contentType = contentType,
memberId = memberId
)
return GetHomeResponse(
liveList = liveList,
creatorRanking = creatorRanking,
latestContentThemeList = latestContentThemeList,
latestContentList = latestContentList,
bannerList = bannerList,
eventBannerList = eventBannerList,
originalAudioDramaList = originalAudioDramaList,
auditionList = auditionList,
dayOfWeekSeriesList = dayOfWeekSeriesList,
contentRanking = contentRanking,
recommendChannelList = recommendChannelList,
freeContentList = freeContentList,
curationList = curationList
)
}
fun getLatestContentByTheme(
theme: String,
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member?
): List<AudioContentMainItem> {
val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible
val themeList = if (theme.isBlank()) {
contentThemeService.getActiveThemeOfContent(
isAdult = isAdult,
isFree = true,
contentType = contentType
)
} else {
listOf(theme)
}
return contentService.getLatestContentByTheme(
theme = themeList,
contentType = contentType,
isFree = false,
isAdult = isAdult
).filter {
if (memberId != null) {
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
} else {
true
}
}
}
fun getDayOfWeekSeriesList(
dayOfWeek: SeriesPublishedDaysOfWeek,
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member?
): List<GetSeriesListResponse.SeriesListItem> {
val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible
return seriesService.getDayOfWeekSeriesList(
memberId = memberId,
isAdult = isAdult,
contentType = contentType,
dayOfWeek = dayOfWeek
)
}
private fun getDayOfWeekByTimezone(timezone: String): SeriesPublishedDaysOfWeek {
val systemTime = LocalDateTime.now()
val zoneId = ZoneId.of(timezone)
val zonedDateTime = systemTime.atZone(ZoneId.systemDefault()).withZoneSameInstant(zoneId)
val dayToSeriesPublishedDaysOfWeek = mapOf(
DayOfWeek.MONDAY to SeriesPublishedDaysOfWeek.MON,
DayOfWeek.TUESDAY to SeriesPublishedDaysOfWeek.TUE,
DayOfWeek.WEDNESDAY to SeriesPublishedDaysOfWeek.WED,
DayOfWeek.THURSDAY to SeriesPublishedDaysOfWeek.THU,
DayOfWeek.FRIDAY to SeriesPublishedDaysOfWeek.FRI,
DayOfWeek.SATURDAY to SeriesPublishedDaysOfWeek.SAT,
DayOfWeek.SUNDAY to SeriesPublishedDaysOfWeek.SUN
)
return dayToSeriesPublishedDaysOfWeek[zonedDateTime.dayOfWeek] ?: SeriesPublishedDaysOfWeek.RANDOM
}
}

View File

@@ -1,33 +0,0 @@
package kr.co.vividnext.sodalive.api.live
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import org.springframework.security.core.annotation.AuthenticationPrincipal
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
@RequestMapping("/api/live")
class LiveApiController(
private val service: LiveApiService
) {
@GetMapping
fun fetchData(
@RequestParam timezone: String,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
service.fetchData(
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
timezone = timezone,
member = member
)
)
}
}

View File

@@ -1,94 +0,0 @@
package kr.co.vividnext.sodalive.api.live
import kr.co.vividnext.sodalive.content.AudioContentService
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
import kr.co.vividnext.sodalive.live.recommend.LiveRecommendService
import kr.co.vividnext.sodalive.live.room.LiveRoomService
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
@Service
class LiveApiService(
private val liveService: LiveRoomService,
private val contentService: AudioContentService,
private val recommendService: LiveRecommendService,
private val creatorCommunityService: CreatorCommunityService,
private val blockMemberRepository: BlockMemberRepository
) {
fun fetchData(
isAdultContentVisible: Boolean,
contentType: ContentType,
timezone: String,
member: Member?
): LiveMainResponse {
val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible
val liveOnAirRoomList = liveService.getRoomList(
dateString = null,
status = LiveRoomStatus.NOW,
isAdultContentVisible = isAdultContentVisible,
pageable = Pageable.ofSize(20),
member = member,
timezone = timezone
)
val communityPostList = if (memberId != null) {
creatorCommunityService.getLatestPostListFromCreatorsYouFollow(
timezone = timezone,
memberId = memberId,
isAdult = isAdult
)
} else {
listOf()
}
val recommendLiveList = recommendService.getRecommendLive(member)
val latestFinishedLiveList = liveService.getLatestFinishedLive(member)
val replayLive = contentService.getLatestContentByTheme(
theme = listOf("다시듣기"),
contentType = contentType,
isFree = false,
isAdult = isAdult
)
.filter { content ->
if (memberId != null) {
!blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = content.creatorId)
} else {
true
}
}
val followingChannelList = if (memberId != null) {
recommendService.getFollowingChannelList(member)
} else {
listOf()
}
val liveReservationRoomList = liveService.getRoomList(
dateString = null,
status = LiveRoomStatus.RESERVATION,
isAdultContentVisible = isAdultContentVisible,
pageable = Pageable.ofSize(10),
member = member,
timezone = timezone
)
return LiveMainResponse(
liveOnAirRoomList = liveOnAirRoomList,
communityPostList = communityPostList,
recommendLiveList = recommendLiveList,
latestFinishedLiveList = latestFinishedLiveList,
replayLive = replayLive,
followingChannelList = followingChannelList,
liveReservationRoomList = liveReservationRoomList
)
}
}

View File

@@ -1,18 +0,0 @@
package kr.co.vividnext.sodalive.api.live
import kr.co.vividnext.sodalive.content.AudioContentMainItem
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.GetCommunityPostListResponse
import kr.co.vividnext.sodalive.live.recommend.GetRecommendChannelResponse
import kr.co.vividnext.sodalive.live.recommend.GetRecommendLiveResponse
import kr.co.vividnext.sodalive.live.room.GetLatestFinishedLiveResponse
import kr.co.vividnext.sodalive.live.room.GetRoomListResponse
data class LiveMainResponse(
val liveOnAirRoomList: List<GetRoomListResponse>,
val communityPostList: List<GetCommunityPostListResponse>,
val recommendLiveList: List<GetRecommendLiveResponse>,
val latestFinishedLiveList: List<GetLatestFinishedLiveResponse>,
val replayLive: List<AudioContentMainItem>,
val followingChannelList: List<GetRecommendChannelResponse>,
val liveReservationRoomList: List<GetRoomListResponse>
)

View File

@@ -12,7 +12,6 @@ interface AuditionQueryRepository {
fun getCompletedAuditionCount(isAdult: Boolean): Int
fun getAuditionList(offset: Long, limit: Long, isAdult: Boolean): List<GetAuditionListItem>
fun getAuditionDetail(auditionId: Long): GetAuditionDetailRawData
fun getInProgressAuditionList(isAdult: Boolean): List<GetAuditionListItem>
}
class AuditionQueryRepositoryImpl(
@@ -95,27 +94,4 @@ class AuditionQueryRepositoryImpl(
.where(audition.id.eq(auditionId))
.fetchFirst()
}
override fun getInProgressAuditionList(isAdult: Boolean): List<GetAuditionListItem> {
var where = audition.isActive.isTrue
.and(audition.status.eq(AuditionStatus.IN_PROGRESS))
if (!isAdult) {
where = where.and(audition.isAdult.isFalse)
}
return queryFactory
.select(
QGetAuditionListItem(
audition.id,
audition.title,
audition.imagePath.prepend("/").prepend(cloudFrontHost),
audition.status.eq(AuditionStatus.COMPLETED)
)
)
.from(audition)
.where(where)
.orderBy(audition.status.desc())
.fetch()
}
}

View File

@@ -28,8 +28,4 @@ class AuditionService(
roleList = roleList
)
}
fun getInProgressAuditionList(isAdult: Boolean): List<GetAuditionListItem> {
return repository.getInProgressAuditionList(isAdult)
}
}

View File

@@ -49,12 +49,10 @@ class AuditionApplicantQueryRepositoryImpl(
return queryFactory
.select(auditionApplicant.id)
.from(auditionApplicant)
.innerJoin(auditionApplicant.member, member)
.innerJoin(auditionApplicant.role, auditionRole)
.where(
auditionRole.id.eq(auditionRoleId),
auditionApplicant.isActive.isTrue,
member.isActive.isTrue
auditionApplicant.isActive.isTrue
)
.fetch()
.size
@@ -89,8 +87,7 @@ class AuditionApplicantQueryRepositoryImpl(
.leftJoin(auditionVote).on(auditionApplicant.id.eq(auditionVote.applicant.id))
.where(
auditionRole.id.eq(auditionRoleId),
auditionApplicant.isActive.isTrue,
member.isActive.isTrue
auditionApplicant.isActive.isTrue
)
.groupBy(auditionApplicant.id)
.orderBy(orderBy)

View File

@@ -1,48 +0,0 @@
package kr.co.vividnext.sodalive.aws.cloudfront
import com.amazonaws.services.cloudfront.CloudFrontUrlSigner
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.nio.file.Files
import java.nio.file.Paths
import java.security.KeyFactory
import java.security.PrivateKey
import java.security.spec.PKCS8EncodedKeySpec
import java.util.Date
/**
* 이미지(CloudFront) 서명 URL 생성기
* - cloud.aws.cloud-front.* 설정을 사용
*/
@Component
class ImageContentCloudFront(
@Value("\${cloud.aws.content-cloud-front.host}")
private val cloudfrontDomain: String,
@Value("\${cloud.aws.content-cloud-front.private-key-file-path}")
private val privateKeyFilePath: String,
@Value("\${cloud.aws.content-cloud-front.key-pair-id}")
private val keyPairId: String
) {
fun generateSignedURL(
resourcePath: String,
expirationTimeMillis: Long
): String {
val privateKey = loadPrivateKey(privateKeyFilePath)
return CloudFrontUrlSigner.getSignedURLWithCannedPolicy(
"$cloudfrontDomain/$resourcePath",
keyPairId,
privateKey,
Date(System.currentTimeMillis() + expirationTimeMillis)
)
}
private fun loadPrivateKey(resourceName: String): PrivateKey {
val path = Paths.get(resourceName)
val bytes = Files.readAllBytes(path)
val keySpec = PKCS8EncodedKeySpec(bytes)
val keyFactory = KeyFactory.getInstance("RSA")
return keyFactory.generatePrivate(keySpec)
}
}

View File

@@ -1,8 +1,6 @@
package kr.co.vividnext.sodalive.can
import kr.co.vividnext.sodalive.common.BaseEntity
import java.math.BigDecimal
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
@@ -12,10 +10,7 @@ data class Can(
var title: String,
var can: Int,
var rewardCan: Int,
@Column(precision = 10, scale = 4, nullable = false)
var price: BigDecimal,
@Column(length = 3, nullable = false, columnDefinition = "CHAR(3)")
var currency: String,
var price: Int,
@Enumerated(value = EnumType.STRING)
var status: CanStatus
) : BaseEntity()

View File

@@ -1,7 +1,6 @@
package kr.co.vividnext.sodalive.can
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.GeoCountry
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.domain.Pageable
@@ -10,15 +9,13 @@ import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import javax.servlet.http.HttpServletRequest
@RestController
@RequestMapping("/can")
class CanController(private val service: CanService) {
@GetMapping
fun getCans(request: HttpServletRequest): ApiResponse<List<CanResponse>> {
val geoCountry = request.getAttribute("geoCountry") as? GeoCountry ?: GeoCountry.OTHER
return ApiResponse.ok(service.getCans(geoCountry))
fun getCans(): ApiResponse<List<CanResponse>> {
return ApiResponse.ok(service.getCans())
}
@GetMapping("/status")

View File

@@ -23,7 +23,7 @@ import org.springframework.stereotype.Repository
interface CanRepository : JpaRepository<Can, Long>, CanQueryRepository
interface CanQueryRepository {
fun findAllByStatusAndCurrency(status: CanStatus, currency: String): List<CanResponse>
fun findAllByStatus(status: CanStatus): List<CanResponse>
fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan>
fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge>
fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan?
@@ -32,7 +32,7 @@ interface CanQueryRepository {
@Repository
class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQueryRepository {
override fun findAllByStatusAndCurrency(status: CanStatus, currency: String): List<CanResponse> {
override fun findAllByStatus(status: CanStatus): List<CanResponse> {
return queryFactory
.select(
QCanResponse(
@@ -40,16 +40,11 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue
can1.title,
can1.can,
can1.rewardCan,
can1.price.intValue(),
can1.currency,
can1.price.stringValue()
can1.price
)
)
.from(can1)
.where(
can1.status.eq(status),
can1.currency.eq(currency)
)
.where(can1.status.eq(status))
.orderBy(can1.can.asc())
.fetch()
}
@@ -69,13 +64,11 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue
val chargeStatusCondition = when (container) {
"aos" -> {
charge.payment.paymentGateway.eq(PaymentGateway.PG)
.or(charge.payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
.or(charge.payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP))
}
"ios" -> {
charge.payment.paymentGateway.eq(PaymentGateway.PG)
.or(charge.payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
.or(charge.payment.paymentGateway.eq(PaymentGateway.APPLE_IAP))
}

View File

@@ -7,7 +7,5 @@ data class CanResponse @QueryProjection constructor(
val title: String,
val can: Int,
val rewardCan: Int,
val price: Int,
val currency: String,
val priceStr: String
val price: Int
)

View File

@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.can
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.common.GeoCountry
import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
@@ -12,12 +11,8 @@ import java.time.format.DateTimeFormatter
@Service
class CanService(private val repository: CanRepository) {
fun getCans(geoCountry: GeoCountry): List<CanResponse> {
val currency = when (geoCountry) {
GeoCountry.KR -> "KRW"
else -> "USD"
}
return repository.findAllByStatusAndCurrency(status = CanStatus.SALE, currency = currency)
fun getCans(): List<CanResponse> {
return repository.findAllByStatus(status = CanStatus.SALE)
}
fun getCanStatus(member: Member, container: String): GetCanStatusResponse {
@@ -77,10 +72,6 @@ class CanService(private val repository: CanRepository) {
CanUsage.ORDER_CONTENT -> "[콘텐츠 구매] ${it.audioContent!!.title}"
CanUsage.PAID_COMMUNITY_POST -> "[게시글 보기] ${it.communityPost?.member?.nickname ?: ""}"
CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}"
CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매"
CanUsage.CHAT_ROOM_RESET -> "캐릭터 톡 초기화"
}
val createdAt = it.createdAt!!

View File

@@ -1,9 +1,7 @@
package kr.co.vividnext.sodalive.can.charge
import java.math.BigDecimal
data class ChargeCompleteResponse(
val price: BigDecimal,
val price: Double,
val currencyCode: String,
val isFirstCharged: Boolean
)

View File

@@ -6,77 +6,20 @@ import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
import kr.co.vividnext.sodalive.marketing.AdTrackingService
import kr.co.vividnext.sodalive.member.Member
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.time.LocalDateTime
import javax.servlet.http.HttpServletRequest
@RestController
@RequestMapping("/charge")
class ChargeController(
private val service: ChargeService,
private val trackingService: AdTrackingService,
@Value("\${payverse.inbound-ip}")
private val payverseInboundIp: String
private val trackingService: AdTrackingService
) {
@PostMapping("/payverse")
fun payverseCharge(
@RequestBody request: PayverseChargeRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) {
throw SodaException("로그인 정보를 확인해주세요.")
}
ApiResponse.ok(service.payverseCharge(member, request))
}
@PostMapping("/payverse/verify")
fun payverseVerify(
@RequestBody verifyRequest: PayverseVerifyRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) {
throw SodaException("로그인 정보를 확인해주세요.")
}
val response = service.payverseVerify(memberId = member.id!!, verifyRequest)
trackingCharge(member, response)
ApiResponse.ok(Unit)
}
// Payverse Webhook 엔드포인트 (payverseVerify 아래)
@PostMapping("/payverse/webhook")
fun payverseWebhook(
@RequestBody request: PayverseWebhookRequest,
servletRequest: HttpServletRequest
): PayverseWebhookResponse {
val header = servletRequest.getHeader("X-Forwarded-For")
val remoteIp = if (header.isNullOrEmpty()) {
servletRequest.remoteAddr
} else {
header.split(",")[0].trim() // 첫 번째 값이 클라이언트 IP
}
if (remoteIp != payverseInboundIp) {
throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
val success = service.payverseWebhook(request)
if (!success) {
throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
return PayverseWebhookResponse(receiveResult = "SUCCESS")
}
@PostMapping
fun charge(
@RequestBody chargeRequest: ChargeRequest,
@@ -168,7 +111,8 @@ class ChargeController(
memberId = member.id!!,
chargeId = chargeId,
productId = request.productId,
purchaseToken = request.purchaseToken
purchaseToken = request.purchaseToken,
paymentGateway = request.paymentGateway
)
trackingCharge(member, response)

View File

@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.can.charge
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import java.math.BigDecimal
data class ChargeRequest(val canId: Long, val paymentGateway: PaymentGateway)
@@ -21,14 +20,14 @@ data class VerifyResult(
val method: String,
val pg: String,
val status: Int,
val price: BigDecimal
val price: Int
)
data class AppleChargeRequest(
val title: String,
val chargeCan: Int,
val paymentGateway: PaymentGateway,
var price: BigDecimal? = null,
var price: Double? = null,
var locale: String? = null
)
@@ -39,53 +38,9 @@ data class AppleVerifyResponse(val status: Int)
data class GoogleChargeRequest(
val title: String,
val chargeCan: Int,
val price: BigDecimal,
val price: Double,
val currencyCode: String,
val productId: String,
val purchaseToken: String,
val paymentGateway: PaymentGateway
)
data class PayverseChargeRequest(
val canId: Long
)
data class PayverseChargeResponse(
val chargeId: Long,
val payloadJson: String
)
data class PayverseVerifyRequest(
val transactionId: String,
val orderId: String
)
data class PayverseVerifyResponse(
val resultStatus: String,
val tid: String,
val schemeGroup: String,
val schemeCode: String,
val transactionType: String,
val transactionStatus: String,
val transactionMessage: String,
val orderId: String,
val customerId: String,
val requestCurrency: String,
val requestAmount: BigDecimal
)
data class PayverseWebhookRequest(
val type: String,
val mid: String,
val tid: String,
val schemeGroup: String,
val schemeCode: String,
val orderId: String,
val requestCurrency: String,
val requestAmount: BigDecimal,
val resultStatus: String,
val approvalDay: String,
val sign: String
)
data class PayverseWebhookResponse(val receiveResult: String)

View File

@@ -5,7 +5,6 @@ import kr.co.bootpay.Bootpay
import kr.co.vividnext.sodalive.can.CanRepository
import kr.co.vividnext.sodalive.can.charge.event.ChargeSpringEvent
import kr.co.vividnext.sodalive.can.coupon.CanCouponNumberRepository
import kr.co.vividnext.sodalive.can.coupon.CouponType
import kr.co.vividnext.sodalive.can.payment.Payment
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
@@ -13,16 +12,10 @@ import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.google.GooglePlayService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.point.MemberPoint
import kr.co.vividnext.sodalive.point.MemberPointRepository
import kr.co.vividnext.sodalive.point.PointGrantLog
import kr.co.vividnext.sodalive.point.PointGrantLogRepository
import kr.co.vividnext.sodalive.useraction.ActionType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.apache.commons.codec.digest.DigestUtils
import org.json.JSONObject
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
@@ -34,8 +27,6 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.math.RoundingMode
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Service
@Transactional(readOnly = true)
@@ -45,9 +36,6 @@ class ChargeService(
private val memberRepository: MemberRepository,
private val couponNumberRepository: CanCouponNumberRepository,
private val grantLogRepository: PointGrantLogRepository,
private val memberPointRepository: MemberPointRepository,
private val objectMapper: ObjectMapper,
private val okHttpClient: OkHttpClient,
private val applicationEventPublisher: ApplicationEventPublisher,
@@ -65,341 +53,34 @@ class ChargeService(
@Value("\${apple.iap-verify-sandbox-url}")
private val appleInAppVerifySandBoxUrl: String,
@Value("\${apple.iap-verify-url}")
private val appleInAppVerifyUrl: String,
@Value("\${payverse.mid}")
private val payverseMid: String,
@Value("\${payverse.client-key}")
private val payverseClientKey: String,
@Value("\${payverse.secret-key}")
private val payverseSecretKey: String,
@Value("\${payverse.usd-mid}")
private val payverseUsdMid: String,
@Value("\${payverse.usd-client-key}")
private val payverseUsdClientKey: String,
@Value("\${payverse.usd-secret-key}")
private val payverseUsdSecretKey: String,
@Value("\${payverse.host}")
private val payverseHost: String,
@Value("\${server.env}")
private val serverEnv: String
private val appleInAppVerifyUrl: String
) {
@Transactional
fun payverseWebhook(request: PayverseWebhookRequest): Boolean {
val chargeId = request.orderId.toLongOrNull() ?: return false
val charge = chargeRepository.findByIdOrNull(chargeId) ?: return false
// 결제수단 확인
if (charge.payment?.paymentGateway != PaymentGateway.PAYVERSE) {
return false
}
// 결제 상태 분기 처리
return when (charge.payment?.status) {
PaymentStatus.REQUEST -> {
// 성공 조건 검증
val mid = if (request.requestCurrency == "KRW") {
payverseMid
} else {
payverseUsdMid
}
val expectedSign = DigestUtils.sha512Hex(
String.format(
"||%s||%s||%s||%s||%s||",
if (request.requestCurrency == "KRW") {
payverseSecretKey
} else {
payverseUsdSecretKey
},
mid,
request.orderId,
request.requestAmount,
request.approvalDay
)
)
val isAmountMatch = request.requestAmount.compareTo(
charge.payment!!.price
) == 0
val isSuccess = request.resultStatus == "SUCCESS" &&
request.mid == mid &&
request.orderId.toLongOrNull() == charge.id &&
isAmountMatch &&
request.sign == expectedSign
if (isSuccess) {
// payverseVerify의 226~246 라인과 동일 처리
charge.payment?.receiptId = request.tid
val mappedMethod = if (request.schemeGroup == "PVKR") {
mapPayverseSchemeToMethodByCode(request.schemeCode)
} else {
null
}
charge.payment?.method = mappedMethod ?: request.schemeCode
charge.payment?.status = PaymentStatus.COMPLETE
charge.payment?.locale = request.requestCurrency
val member = charge.member!!
member.charge(charge.chargeCan, charge.rewardCan, "pg")
applicationEventPublisher.publishEvent(
ChargeSpringEvent(
chargeId = charge.id!!,
memberId = member.id!!
)
)
true
} else {
false
}
}
PaymentStatus.COMPLETE -> {
// 이미 결제가 완료된 경우 성공 처리(idempotent)
true
}
else -> {
// 그 외 상태는 404
false
}
}
}
@Transactional
fun chargeByCoupon(couponNumber: String, member: Member): String {
fun chargeByCoupon(couponNumber: String, member: Member) {
val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber)
?: throw SodaException("잘못된 쿠폰번호입니다.\n고객센터로 문의해 주시기 바랍니다.")
if (canCouponNumber.member != null) {
throw SodaException("이미 사용한 쿠폰번호 입니다.")
}
canCouponNumber.member = member
val coupon = canCouponNumber.canCoupon!!
val couponCharge = Charge(0, coupon.can, status = ChargeStatus.COUPON)
couponCharge.title = "${coupon.can}"
couponCharge.member = member
when (coupon.couponType) {
CouponType.CAN -> {
val couponCharge = Charge(0, coupon.can, status = ChargeStatus.COUPON)
couponCharge.title = "${coupon.can}"
couponCharge.member = member
val payment = Payment(
status = PaymentStatus.COMPLETE,
paymentGateway = PaymentGateway.PG
)
payment.method = coupon.couponName
couponCharge.payment = payment
chargeRepository.save(couponCharge)
member.charge(0, coupon.can, "pg")
return "쿠폰 사용이 완료되었습니다.\n${coupon.can}캔이 지급되었습니다."
}
CouponType.POINT -> {
val memberId = member.id!!
val point = coupon.can
val actionType = ActionType.COUPON
grantLogRepository.save(
PointGrantLog(
memberId = memberId,
point = point,
actionType = actionType,
policyId = null,
orderId = null,
couponName = coupon.couponName
)
)
memberPointRepository.save(
MemberPoint(
memberId = memberId,
point = point,
actionType = actionType,
expiresAt = LocalDateTime.now().plusDays(3)
)
)
return "쿠폰 사용이 완료되었습니다.\n${coupon.can}포인트가 지급되었습니다."
}
}
}
@Transactional
fun payverseCharge(member: Member, request: PayverseChargeRequest): PayverseChargeResponse {
val can = canRepository.findByIdOrNull(request.canId)
?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.")
val requestCurrency = can.currency
val isKrw = requestCurrency == "KRW"
val mid = if (isKrw) {
payverseMid
} else {
payverseUsdMid
}
val clientKey = if (isKrw) {
payverseClientKey
} else {
payverseUsdClientKey
}
val secretKey = if (isKrw) {
payverseSecretKey
} else {
payverseUsdSecretKey
}
val charge = Charge(can.can, can.rewardCan)
charge.title = can.title
charge.member = member
charge.can = can
val payment = Payment(paymentGateway = PaymentGateway.PAYVERSE)
payment.price = can.price
charge.payment = payment
val savedCharge = chargeRepository.save(charge)
val chargeId = savedCharge.id!!
val amount = BigDecimal(
savedCharge.payment!!.price
.setScale(4, RoundingMode.HALF_UP)
.stripTrailingZeros()
.toPlainString()
val payment = Payment(
status = PaymentStatus.COMPLETE,
paymentGateway = PaymentGateway.PG
)
val reqDate = savedCharge.createdAt!!.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
val sign = DigestUtils.sha512Hex(
String.format(
"||%s||%s||%s||%s||%s||",
secretKey,
mid,
chargeId,
amount,
reqDate
)
)
val customerId = "${serverEnv}_user_${member.id!!}"
payment.method = coupon.couponName
couponCharge.payment = payment
chargeRepository.save(couponCharge)
val payload = linkedMapOf(
"mid" to mid,
"clientKey" to clientKey,
"orderId" to chargeId.toString(),
"customerId" to customerId,
"productName" to can.title,
"requestCurrency" to requestCurrency,
"requestAmount" to amount,
"reqDate" to reqDate,
"sign" to sign
)
val payloadJson = objectMapper.writeValueAsString(payload)
return PayverseChargeResponse(chargeId = charge.id!!, payloadJson = payloadJson)
}
@Transactional
fun payverseVerify(memberId: Long, verifyRequest: PayverseVerifyRequest): ChargeCompleteResponse {
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
?: throw SodaException("결제정보에 오류가 있습니다.")
val member = memberRepository.findByIdOrNull(memberId)
?: throw SodaException("로그인 정보를 확인해주세요.")
val isKrw = charge.can?.currency == "KRW"
val mid = if (isKrw) {
payverseMid
} else {
payverseUsdMid
}
val clientKey = if (isKrw) {
payverseClientKey
} else {
payverseUsdClientKey
}
// 결제수단 확인
if (charge.payment?.paymentGateway != PaymentGateway.PAYVERSE) {
throw SodaException("결제정보에 오류가 있습니다.")
}
// 결제 상태에 따른 분기 처리
when (charge.payment?.status) {
PaymentStatus.REQUEST -> {
try {
val url = "$payverseHost/payment/search/transaction/${verifyRequest.transactionId}"
val request = Request.Builder()
.url(url)
.addHeader("mid", mid)
.addHeader("clientKey", clientKey)
.get()
.build()
val response = okHttpClient.newCall(request).execute()
if (!response.isSuccessful) {
throw SodaException("결제정보에 오류가 있습니다.")
}
val body = response.body?.string() ?: throw SodaException("결제정보에 오류가 있습니다.")
val verifyResponse = objectMapper.readValue(body, PayverseVerifyResponse::class.java)
val customerId = "${serverEnv}_user_${member.id!!}"
val isSuccess = verifyResponse.resultStatus == "SUCCESS" &&
verifyResponse.transactionStatus == "SUCCESS" &&
verifyResponse.orderId.toLongOrNull() == charge.id &&
verifyResponse.customerId == customerId &&
verifyResponse.requestAmount.compareTo(charge.can!!.price) == 0
if (isSuccess) {
// verify 함수의 232~248 라인과 동일 처리
charge.payment?.receiptId = verifyResponse.tid
val mappedMethod = if (verifyResponse.schemeGroup == "PVKR") {
mapPayverseSchemeToMethodByCode(verifyResponse.schemeCode)
} else {
null
}
charge.payment?.method = mappedMethod ?: verifyResponse.schemeCode
charge.payment?.status = PaymentStatus.COMPLETE
// 통화코드 설정
charge.payment?.locale = verifyResponse.requestCurrency
member.charge(charge.chargeCan, charge.rewardCan, "pg")
applicationEventPublisher.publishEvent(
ChargeSpringEvent(
chargeId = charge.id!!,
memberId = member.id!!
)
)
return ChargeCompleteResponse(
price = charge.payment!!.price,
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId)
)
} else {
throw SodaException("결제정보에 오류가 있습니다.")
}
} catch (_: Exception) {
throw SodaException("결제정보에 오류가 있습니다.")
}
}
PaymentStatus.COMPLETE -> {
// 이미 결제가 완료된 경우, 동일한 데이터로 즉시 반환
return ChargeCompleteResponse(
price = charge.payment!!.price,
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId)
)
}
else -> {
throw SodaException("결제정보에 오류가 있습니다.")
}
}
member.charge(0, coupon.can, "pg")
}
@Transactional
@@ -413,7 +94,7 @@ class ChargeService(
charge.can = can
val payment = Payment(paymentGateway = request.paymentGateway)
payment.price = can.price
payment.price = can.price.toDouble()
charge.payment = payment
chargeRepository.save(charge)
@@ -452,14 +133,14 @@ class ChargeService(
)
return ChargeCompleteResponse(
price = charge.payment!!.price,
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId)
)
} else {
throw SodaException("결제정보에 오류가 있습니다.")
}
} catch (_: Exception) {
} catch (e: Exception) {
throw SodaException("결제정보에 오류가 있습니다.")
}
} else {
@@ -484,7 +165,7 @@ class ChargeService(
VerifyResult::class.java
)
if (verifyResult.status == 1) {
if (verifyResult.status == 1 && verifyResult.price == charge.can?.price) {
charge.payment?.receiptId = verifyResult.receiptId
charge.payment?.method = if (verifyResult.pg.contains("카카오")) {
"${verifyResult.pg}-${verifyResult.method}"
@@ -502,14 +183,14 @@ class ChargeService(
)
return ChargeCompleteResponse(
price = charge.payment!!.price,
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId)
)
} else {
throw SodaException("결제정보에 오류가 있습니다.")
}
} catch (_: Exception) {
} catch (e: Exception) {
throw SodaException("결제정보에 오류가 있습니다.")
}
} else {
@@ -527,7 +208,7 @@ class ChargeService(
payment.price = if (request.price != null) {
request.price!!
} else {
0.toBigDecimal()
0.toDouble()
}
payment.locale = request.locale
@@ -562,7 +243,7 @@ class ChargeService(
)
return ChargeCompleteResponse(
price = charge.payment!!.price,
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId)
)
@@ -579,7 +260,7 @@ class ChargeService(
member: Member,
title: String,
chargeCan: Int,
price: BigDecimal,
price: Double,
currencyCode: String,
productId: String,
purchaseToken: String,
@@ -607,7 +288,8 @@ class ChargeService(
memberId: Long,
chargeId: Long,
productId: String,
purchaseToken: String
purchaseToken: String,
paymentGateway: PaymentGateway
): ChargeCompleteResponse {
val charge = chargeRepository.findByIdOrNull(id = chargeId)
?: throw SodaException("결제정보에 오류가 있습니다.")
@@ -629,7 +311,7 @@ class ChargeService(
)
return ChargeCompleteResponse(
price = charge.payment!!.price,
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId)
)
@@ -711,13 +393,4 @@ class ChargeService(
throw SodaException("결제를 완료하지 못했습니다.")
}
}
// Payverse 결제수단 매핑: 특정 schemeCode는 "카드"로 표기, 아니면 null 반환
private fun mapPayverseSchemeToMethodByCode(schemeCode: String?): String? {
val cardCodes = setOf(
"041", "044", "361", "364", "365", "366", "367", "368", "369", "370", "371", "372", "373", "374", "381",
"218", "071", "002", "089", "045", "050", "048", "090", "092"
)
return if (schemeCode != null && cardCodes.contains(schemeCode)) "카드" else null
}
}

View File

@@ -1,10 +1,9 @@
package kr.co.vividnext.sodalive.can.charge.temp
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import java.math.BigDecimal
data class ChargeTempRequest(
val can: Int,
val price: BigDecimal,
val price: Int,
val paymentGateway: PaymentGateway
)

View File

@@ -41,7 +41,7 @@ class ChargeTempService(
charge.member = member
val payment = Payment(paymentGateway = request.paymentGateway)
payment.price = request.price
payment.price = request.price.toDouble()
charge.payment = payment
chargeRepository.save(charge)
@@ -66,7 +66,7 @@ class ChargeTempService(
VerifyResult::class.java
)
if (verifyResult.status == 1 && verifyResult.price == charge.payment!!.price) {
if (verifyResult.status == 1 && verifyResult.price == charge.payment!!.price.toInt()) {
charge.payment?.receiptId = verifyResult.receiptId
charge.payment?.method = verifyResult.method
charge.payment?.status = PaymentStatus.COMPLETE
@@ -74,7 +74,7 @@ class ChargeTempService(
} else {
throw SodaException("결제정보에 오류가 있습니다.")
}
} catch (_: Exception) {
} catch (e: Exception) {
throw SodaException("결제정보에 오류가 있습니다.")
}
} else {

View File

@@ -3,21 +3,13 @@ package kr.co.vividnext.sodalive.can.coupon
import kr.co.vividnext.sodalive.common.BaseEntity
import java.time.LocalDateTime
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
@Entity
data class CanCoupon(
val couponName: String,
@Enumerated(EnumType.STRING)
val couponType: CouponType,
val can: Int,
val couponCount: Int,
var validity: LocalDateTime,
var isActive: Boolean,
var isMultipleUse: Boolean
) : BaseEntity()
enum class CouponType {
CAN, POINT
}

View File

@@ -109,11 +109,11 @@ class CanCouponController(private val service: CanCouponService) {
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val completeMessage = service.useCanCoupon(
couponNumber = request.couponNumber,
memberId = member.id!!
ApiResponse.ok(
service.useCanCoupon(
couponNumber = request.couponNumber,
memberId = member.id!!
)
)
ApiResponse.ok(Unit, completeMessage)
}
}

View File

@@ -79,7 +79,6 @@ class CanCouponNumberQueryRepositoryImpl(private val queryFactory: JPAQueryFacto
override fun findByCouponNumber(couponNumber: String): CanCouponNumber? {
return queryFactory
.selectFrom(canCouponNumber)
.innerJoin(canCouponNumber.canCoupon, canCoupon)
.where(canCouponNumber.couponNumber.eq(couponNumber))
.fetchFirst()
}

View File

@@ -1,6 +1,5 @@
package kr.co.vividnext.sodalive.can.coupon
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
@@ -31,9 +30,6 @@ class CanCouponQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) :
QGetCouponListItemResponse(
canCoupon.id,
canCoupon.couponName,
CaseBuilder()
.`when`(canCoupon.couponType.eq(CouponType.POINT)).then("포인트 쿠폰")
.otherwise("캔 쿠폰"),
canCoupon.can,
canCoupon.couponCount,
Expressions.ZERO,

View File

@@ -68,12 +68,15 @@ class CanCouponService(
fun getCouponList(offset: Long, limit: Long): GetCouponListResponse {
val totalCount = repository.getCouponTotalCount()
val items = repository.getCouponList(offset = offset, limit = limit)
.asSequence()
.map {
val useCouponCount = couponNumberRepository.getUseCouponCount(id = it.id)
it.useCouponCount = useCouponCount
it
}
.toList()
return GetCouponListResponse(totalCount, items)
}
@@ -121,7 +124,7 @@ class CanCouponService(
}
}
fun useCanCoupon(couponNumber: String, memberId: Long): String {
fun useCanCoupon(couponNumber: String, memberId: Long) {
val member = memberRepository.findByIdOrNull(id = memberId)
?: throw SodaException("로그인 정보를 확인해주세요.")
@@ -129,7 +132,7 @@ class CanCouponService(
issueService.validateAvailableUseCoupon(couponNumber, memberId)
return chargeService.chargeByCoupon(couponNumber, member)
chargeService.chargeByCoupon(couponNumber, member)
}
private fun insertHyphens(input: String): String {

View File

@@ -4,7 +4,6 @@ import com.fasterxml.jackson.annotation.JsonProperty
data class GenerateCanCouponRequest(
@JsonProperty("couponName") val couponName: String,
@JsonProperty("couponType") val couponType: CouponType,
@JsonProperty("can") val can: Int,
@JsonProperty("validity") val validity: String,
@JsonProperty("isMultipleUse") val isMultipleUse: Boolean,

View File

@@ -10,7 +10,6 @@ data class GetCouponListResponse(
data class GetCouponListItemResponse @QueryProjection constructor(
val id: Long,
val couponName: String,
val couponType: String,
val can: Int,
val couponCount: Int,
var useCouponCount: Int,

View File

@@ -13,7 +13,6 @@ import kr.co.vividnext.sodalive.can.use.UseCanCalculate
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.can.use.UseCanRepository
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.order.Order
@@ -38,8 +37,6 @@ class CanPaymentService(
memberId: Long,
needCan: Int,
canUsage: CanUsage,
chatRoomId: Long? = null,
characterId: Long? = null,
isSecret: Boolean = false,
liveRoom: LiveRoom? = null,
order: Order? = null,
@@ -112,14 +109,6 @@ class CanPaymentService(
recipientId = liveRoom.member!!.id!!
useCan.room = liveRoom
useCan.member = member
} else if (canUsage == CanUsage.CHAT_QUOTA_PURCHASE && chatRoomId != null && characterId != null) {
useCan.member = member
useCan.chatRoomId = chatRoomId
useCan.characterId = characterId
} else if (canUsage == CanUsage.CHAT_ROOM_RESET) {
useCan.member = member
useCan.chatRoomId = chatRoomId
useCan.characterId = characterId
} else {
throw SodaException("잘못된 요청입니다.")
}
@@ -338,98 +327,4 @@ class CanPaymentService(
chargeRepository.save(charge)
}
}
@Transactional
fun spendCanForCharacterImage(
memberId: Long,
needCan: Int,
image: CharacterImage,
container: String
) {
val member = memberRepository.findByIdOrNull(id = memberId)
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
val useRewardCan = spendRewardCan(member, needCan, container)
val useChargeCan = if (needCan - useRewardCan.total > 0) {
spendChargeCan(member, needCan = needCan - useRewardCan.total, container = container)
} else {
null
}
if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) {
throw SodaException(
"${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " +
"캔이 부족합니다. 충전 후 이용해 주세요."
)
}
if (!useRewardCan.verify() || useChargeCan?.verify() == false) {
throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
}
val useCan = UseCan(
canUsage = CanUsage.CHARACTER_IMAGE_PURCHASE,
can = useChargeCan?.total ?: 0,
rewardCan = useRewardCan.total,
isSecret = false
)
useCan.member = member
useCan.characterImage = image
useCanRepository.save(useCan)
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD)
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
}
@Transactional
fun spendCanForChatMessage(
memberId: Long,
needCan: Int,
message: kr.co.vividnext.sodalive.chat.room.ChatMessage,
container: String
) {
val member = memberRepository.findByIdOrNull(id = memberId)
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
val useRewardCan = spendRewardCan(member, needCan, container)
val useChargeCan = if (needCan - useRewardCan.total > 0) {
spendChargeCan(member, needCan = needCan - useRewardCan.total, container = container)
} else {
null
}
if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) {
throw SodaException(
"${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " +
"캔이 부족합니다. 충전 후 이용해 주세요."
)
}
if (!useRewardCan.verify() || useChargeCan?.verify() == false) {
throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
}
val useCan = UseCan(
canUsage = CanUsage.CHAT_MESSAGE_PURCHASE,
can = useChargeCan?.total ?: 0,
rewardCan = useRewardCan.total,
isSecret = false
)
useCan.member = member
useCan.chatMessage = message
// 이미지 메시지의 경우 이미지 연관도 함께 기록
message.characterImage?.let { img ->
useCan.characterImage = img
}
useCanRepository.save(useCan)
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD)
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
}
}

View File

@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.can.payment
import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.common.BaseEntity
import java.math.BigDecimal
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
@@ -26,8 +25,7 @@ data class Payment(
var receiptId: String? = null
var method: String? = null
@Column(precision = 10, scale = 4, nullable = false)
var price: BigDecimal = 0.toBigDecimal()
var price: Double = 0.toDouble()
var locale: String? = null
var orderId: String? = null
}

View File

@@ -1,5 +1,5 @@
package kr.co.vividnext.sodalive.can.payment
enum class PaymentGateway {
PG, PAYVERSE, GOOGLE_IAP, APPLE_IAP, POINT_CLICK_AD
PG, GOOGLE_IAP, APPLE_IAP, POINT_CLICK_AD
}

View File

@@ -9,9 +9,5 @@ enum class CanUsage {
SPIN_ROULETTE,
PAID_COMMUNITY_POST,
ALARM_SLOT,
AUDITION_VOTE,
CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용)
CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매
CHAT_QUOTA_PURCHASE, // 채팅 횟수(쿼터) 충전
CHAT_ROOM_RESET // 채팅방 초기화 결제(별도 구분)
AUDITION_VOTE
}

View File

@@ -1,8 +1,6 @@
package kr.co.vividnext.sodalive.can.use
import kr.co.vividnext.sodalive.audition.AuditionApplicant
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
import kr.co.vividnext.sodalive.chat.room.ChatMessage
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.order.Order
@@ -30,11 +28,7 @@ data class UseCan(
var isRefund: Boolean = false,
val isSecret: Boolean = false,
// 채팅 연동을 위한 식별자 (옵션)
var chatRoomId: Long? = null,
var characterId: Long? = null
val isSecret: Boolean = false
) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
@@ -64,16 +58,6 @@ data class UseCan(
@JoinColumn(name = "audition_applicant_id", nullable = true)
var auditionApplicant: AuditionApplicant? = null
// 메시지를 통한 구매 연관 (옵션)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "chat_message_id", nullable = true)
var chatMessage: ChatMessage? = null
// 캐릭터 이미지 연관 (메시지 구매/단독 구매 공통 사용)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "character_image_id", nullable = true)
var characterImage: CharacterImage? = null
@OneToMany(mappedBy = "useCan", cascade = [CascadeType.ALL])
val useCanCalculates: MutableList<UseCanCalculate> = mutableListOf()
}

View File

@@ -6,56 +6,23 @@ import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface UseCanRepository : JpaRepository<UseCan, Long>, UseCanQueryRepository {
// 특정 멤버가 해당 이미지에 대해 구매 이력이 있는지(환불 제외)
fun existsByMember_IdAndIsRefundFalseAndCharacterImage_IdAndCanUsageIn(
memberId: Long,
imageId: Long,
usages: Collection<CanUsage>
): Boolean
}
interface UseCanRepository : JpaRepository<UseCan, Long>, UseCanQueryRepository
interface UseCanQueryRepository {
fun isExistCommunityPostOrdered(postId: Long, memberId: Long): Boolean
fun countPurchasedActiveImagesByCharacter(
memberId: Long,
characterId: Long,
usages: Collection<CanUsage>
): Long
fun isExistOrdered(postId: Long, memberId: Long): Boolean
}
class UseCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : UseCanQueryRepository {
override fun isExistCommunityPostOrdered(postId: Long, memberId: Long): Boolean {
override fun isExistOrdered(postId: Long, memberId: Long): Boolean {
val useCanId = queryFactory.select(useCan.id)
.from(useCan)
.where(
useCan.member.id.eq(memberId)
.and(useCan.isRefund.isFalse)
.and(useCan.communityPost.id.eq(postId))
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
)
.fetchFirst()
return useCanId != null && useCanId > 0
}
override fun countPurchasedActiveImagesByCharacter(
memberId: Long,
characterId: Long,
usages: Collection<CanUsage>
): Long {
val count = queryFactory
.selectDistinct(useCan.characterImage.id)
.from(useCan)
.where(
useCan.member.id.eq(memberId)
.and(useCan.isRefund.isFalse)
.and(useCan.characterImage.chatCharacter.id.eq(characterId))
.and(useCan.characterImage.isActive.isTrue)
.and(useCan.canUsage.`in`(usages))
)
.fetch()
.size
return count.toLong()
}
}

View File

@@ -1,163 +0,0 @@
package kr.co.vividnext.sodalive.chat.character
import kr.co.vividnext.sodalive.chat.original.OriginalWork
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.CascadeType
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.OneToMany
@Entity
class ChatCharacter(
val characterUUID: String,
// 캐릭터 이름 (API 키 내에서 유일해야 함)
var name: String,
// 캐릭터 한 줄 소개
var description: String,
// AI 시스템 프롬프트
@Column(columnDefinition = "TEXT", nullable = false)
var systemPrompt: String,
// 나이
var age: Int? = null,
// 성별
var gender: String? = null,
// mbti
var mbti: String? = null,
// 말투 패턴 설명
@Column(columnDefinition = "TEXT")
var speechPattern: String? = null,
// 대화 스타일
@Column(columnDefinition = "TEXT")
var speechStyle: String? = null,
// 외모 설명
@Column(columnDefinition = "TEXT")
var appearance: String? = null,
// 원작명/원작링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지)
@Column(nullable = true)
var originalTitle: String? = null,
// 원작 링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지)
@Column(nullable = true)
var originalLink: String? = null,
// 연관 원작 (한 캐릭터는 하나의 원작에만 속함)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "original_work_id")
var originalWork: OriginalWork? = null,
// 캐릭터 유형
@Enumerated(EnumType.STRING)
@Column(nullable = false)
var characterType: CharacterType = CharacterType.Character,
var isActive: Boolean = true
) : BaseEntity() {
var imagePath: String? = null
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
var memories: MutableList<ChatCharacterMemory> = mutableListOf()
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
var personalities: MutableList<ChatCharacterPersonality> = mutableListOf()
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
var backgrounds: MutableList<ChatCharacterBackground> = mutableListOf()
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
var relationships: MutableList<ChatCharacterRelationship> = mutableListOf()
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
var tagMappings: MutableList<ChatCharacterTagMapping> = mutableListOf()
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
var valueMappings: MutableList<ChatCharacterValueMapping> = mutableListOf()
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
var hobbyMappings: MutableList<ChatCharacterHobbyMapping> = mutableListOf()
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
var goalMappings: MutableList<ChatCharacterGoalMapping> = mutableListOf()
// 태그 추가 헬퍼 메소드
fun addTag(tag: ChatCharacterTag) {
val mapping = ChatCharacterTagMapping(this, tag)
tagMappings.add(mapping)
}
// 가치관 추가 헬퍼 메소드
fun addValue(value: ChatCharacterValue) {
val mapping = ChatCharacterValueMapping(this, value)
valueMappings.add(mapping)
}
// 취미 추가 헬퍼 메소드
fun addHobby(hobby: ChatCharacterHobby) {
val mapping = ChatCharacterHobbyMapping(this, hobby)
hobbyMappings.add(mapping)
}
// 목표 추가 헬퍼 메소드
fun addGoal(goal: ChatCharacterGoal) {
val mapping = ChatCharacterGoalMapping(this, goal)
goalMappings.add(mapping)
}
// 기억 추가 헬퍼 메소드
fun addMemory(title: String, content: String, emotion: String) {
val memory = ChatCharacterMemory(title, content, emotion, this)
memories.add(memory)
}
// 성격 추가 헬퍼 메소드
fun addPersonality(trait: String, description: String) {
val personality = ChatCharacterPersonality(trait, description, this)
personalities.add(personality)
}
// 배경 추가 헬퍼 메소드
fun addBackground(topic: String, description: String) {
val background = ChatCharacterBackground(topic, description, this)
backgrounds.add(background)
}
// 관계 추가 헬퍼 메소드
fun addRelationship(
personName: String,
relationshipName: String,
description: String,
importance: Int,
relationshipType: String,
currentStatus: String
) {
val relationship = ChatCharacterRelationship(
personName,
relationshipName,
description,
importance,
relationshipType,
currentStatus,
this
)
relationships.add(relationship)
}
}
enum class CharacterType {
Clone,
Character
}

View File

@@ -1,26 +0,0 @@
package kr.co.vividnext.sodalive.chat.character
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
/**
* 캐릭터 배경 정보
*/
@Entity
class ChatCharacterBackground(
// 배경 주제
val topic: String,
// 배경 설명
@Column(columnDefinition = "TEXT", nullable = false)
var description: String,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "chat_character_id")
val chatCharacter: ChatCharacter
) : BaseEntity()

View File

@@ -1,29 +0,0 @@
package kr.co.vividnext.sodalive.chat.character
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
/**
* 캐릭터 배너 엔티티
* 이미지와 캐릭터 ID를 가지며, 소프트 삭제(isActive = false)를 지원합니다.
* 정렬 순서(sortOrder)를 통해 배너의 표시 순서를 결정합니다.
*/
@Entity
class ChatCharacterBanner(
// 배너 이미지 경로
var imagePath: String? = null,
// 연관된 캐릭터
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "character_id")
var chatCharacter: ChatCharacter,
// 정렬 순서 (낮을수록 먼저 표시)
var sortOrder: Int = 0,
// 활성화 여부 (소프트 삭제용)
var isActive: Boolean = true
) : BaseEntity()

View File

@@ -1,22 +0,0 @@
package kr.co.vividnext.sodalive.chat.character
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.OneToMany
import javax.persistence.Table
import javax.persistence.UniqueConstraint
/**
* 캐릭터 목표
*/
@Entity
@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["goal"])])
class ChatCharacterGoal(
@Column(nullable = false)
val goal: String
) : BaseEntity() {
@OneToMany(mappedBy = "goal")
var goalMappings: MutableList<ChatCharacterGoalMapping> = mutableListOf()
}

View File

@@ -1,22 +0,0 @@
package kr.co.vividnext.sodalive.chat.character
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
/**
* ChatCharacter와 ChatCharacterGoal 간의 매핑 엔티티
* ChatCharacterGoal의 중복을 방지하기 위한 매핑 테이블
*/
@Entity
class ChatCharacterGoalMapping(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "chat_character_id")
val chatCharacter: ChatCharacter,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "goal_id")
val goal: ChatCharacterGoal
) : BaseEntity()

View File

@@ -1,22 +0,0 @@
package kr.co.vividnext.sodalive.chat.character
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.OneToMany
import javax.persistence.Table
import javax.persistence.UniqueConstraint
/**
* 캐릭터 취미
*/
@Entity
@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["hobby"])])
class ChatCharacterHobby(
@Column(nullable = false)
val hobby: String
) : BaseEntity() {
@OneToMany(mappedBy = "hobby")
var hobbyMappings: MutableList<ChatCharacterHobbyMapping> = mutableListOf()
}

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