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 indent_style = space
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
max_line_length = 130 max_line_length = 120
tab_width = 4 tab_width = 4

3
.gitignore vendored
View File

@@ -323,7 +323,4 @@ gradle-app.setting
### Gradle Patch ### ### Gradle Patch ###
**/build/ **/build/
.kiro/
.junie
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,intellij,java,kotlin,macos,windows,eclipse,gradle # 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 // android publisher
implementation("com.google.apis:google-api-services-androidpublisher:v3-rev20240319-2.0.0") 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.apache.poi:poi-ooxml:5.2.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") 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") developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("com.h2database:h2") runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j") runtimeOnly("com.mysql:mysql-connector-j")

View File

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

View File

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

View File

@@ -21,15 +21,11 @@ data class GetCumulativeSalesByContentQueryData @QueryProjection constructor(
val numberOfPeople: Long, val numberOfPeople: Long,
// 합계 // 합계
val totalCan: Int, val totalCan: Int,
// 포인트
val totalPoint: Int,
// 정산비율 // 정산비율
val settlementRatio: Int? val settlementRatio: Int?
) { ) {
fun toCumulativeSalesByContentItem(): CumulativeSalesByContentItem { fun toCumulativeSalesByContentItem(): CumulativeSalesByContentItem {
val orderTypeStr = if (totalPoint > 0) { val orderTypeStr = if (orderType == OrderType.RENTAL) {
"포인트"
} else if (orderType == OrderType.RENTAL) {
"대여" "대여"
} else { } 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.common.BaseEntity
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import java.time.LocalDateTime
import javax.persistence.Entity import javax.persistence.Entity
import javax.persistence.FetchType import javax.persistence.FetchType
import javax.persistence.JoinColumn import javax.persistence.JoinColumn
@@ -10,29 +9,12 @@ import javax.persistence.OneToOne
@Entity @Entity
data class CreatorSettlementRatio( data class CreatorSettlementRatio(
var subsidy: Int, val subsidy: Int,
var liveSettlementRatio: Int, val liveSettlementRatio: Int,
var contentSettlementRatio: Int, val contentSettlementRatio: Int,
var communitySettlementRatio: Int val communitySettlementRatio: Int
) : BaseEntity() { ) : BaseEntity() {
@OneToOne(fetch = FetchType.LAZY) @OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false) @JoinColumn(name = "member_id", nullable = false)
var member: Member? = null 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.data.domain.Pageable
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping 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.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
@@ -28,14 +27,4 @@ class CreatorSettlementRatioController(private val service: CreatorSettlementRat
limit = pageable.pageSize.toLong() 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 : interface CreatorSettlementRatioRepository :
JpaRepository<CreatorSettlementRatio, Long>, JpaRepository<CreatorSettlementRatio, Long>,
CreatorSettlementRatioQueryRepository { CreatorSettlementRatioQueryRepository
fun findByMemberId(memberId: Long): CreatorSettlementRatio?
}
interface CreatorSettlementRatioQueryRepository { interface CreatorSettlementRatioQueryRepository {
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem> fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
@@ -23,7 +21,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
return queryFactory return queryFactory
.select( .select(
QGetCreatorSettlementRatioItem( QGetCreatorSettlementRatioItem(
member.id,
member.nickname, member.nickname,
creatorSettlementRatio.subsidy, creatorSettlementRatio.subsidy,
creatorSettlementRatio.liveSettlementRatio, creatorSettlementRatio.liveSettlementRatio,
@@ -33,7 +30,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
) )
.from(creatorSettlementRatio) .from(creatorSettlementRatio)
.innerJoin(creatorSettlementRatio.member, member) .innerJoin(creatorSettlementRatio.member, member)
.where(creatorSettlementRatio.deletedAt.isNull)
.orderBy(creatorSettlementRatio.id.asc()) .orderBy(creatorSettlementRatio.id.asc())
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)
@@ -44,7 +40,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
return queryFactory return queryFactory
.select(creatorSettlementRatio.id) .select(creatorSettlementRatio.id)
.from(creatorSettlementRatio) .from(creatorSettlementRatio)
.where(creatorSettlementRatio.deletedAt.isNull)
.fetch() .fetch()
.size .size
} }

View File

@@ -14,6 +14,8 @@ class CreatorSettlementRatioService(
) { ) {
@Transactional @Transactional
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) { fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
val creatorSettlementRatio = request.toEntity()
val creator = memberRepository.findByIdOrNull(request.memberId) val creator = memberRepository.findByIdOrNull(request.memberId)
?: throw SodaException("잘못된 크리에이터 입니다.") ?: throw SodaException("잘못된 크리에이터 입니다.")
@@ -21,52 +23,10 @@ class CreatorSettlementRatioService(
throw SodaException("잘못된 크리에이터 입니다.") 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 creatorSettlementRatio.member = creator
repository.save(creatorSettlementRatio) 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) @Transactional(readOnly = true)
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse { fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
val totalCount = repository.getCreatorSettlementRatioTotalCount() val totalCount = repository.getCreatorSettlementRatioTotalCount()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,10 @@
package kr.co.vividnext.sodalive.admin.charge package kr.co.vividnext.sodalive.admin.charge
import java.math.BigDecimal
data class GetChargeStatusDetailResponse( data class GetChargeStatusDetailResponse(
val memberId: Long, val memberId: Long,
val nickname: String, val nickname: String,
val method: String, val method: String,
val amount: BigDecimal, val amount: Int,
val locale: String, val locale: String,
val datetime: 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 package kr.co.vividnext.sodalive.admin.charge
import com.querydsl.core.annotations.QueryProjection data class GetChargeStatusResponse(
import java.math.BigDecimal
data class GetChargeStatusResponse @QueryProjection constructor(
val date: String, val date: String,
val chargeAmount: BigDecimal, val chargeAmount: Int,
val chargeCount: Long, val chargeCount: Long,
val pg: String, val pg: String
val currency: 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 audioContent.duration.isNotNull
.and(audioContent.member.isNotNull) .and(audioContent.member.isNotNull)
.and(audioContentHashTag.audioContent.id.eq(audioContentId)) .and(audioContentHashTag.audioContent.id.eq(audioContentId))
.and(audioContentHashTag.isActive.isTrue)
) )
.fetch() .fetch()
} }

View File

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

View File

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

View File

@@ -11,7 +11,6 @@ data class GetAdminMemberListResponseItem(
val nickname: String, val nickname: String,
val profileUrl: String, val profileUrl: String,
val userType: String, val userType: String,
val loginType: String,
val container: String, val container: String,
val auth: Boolean, val auth: Boolean,
val signUpDate: String, 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.CaseBuilder
import com.querydsl.core.types.dsl.DateTimePath import com.querydsl.core.types.dsl.DateTimePath
import com.querydsl.core.types.dsl.Expressions import com.querydsl.core.types.dsl.Expressions
import com.querydsl.core.types.dsl.NumberExpression
import com.querydsl.core.types.dsl.StringTemplate import com.querydsl.core.types.dsl.StringTemplate
import com.querydsl.jpa.impl.JPAQueryFactory import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
@@ -45,12 +46,6 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
.otherwise(0) .otherwise(0)
.sum() .sum()
val launchCount = CaseBuilder()
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.APP_LAUNCH))
.then(1)
.otherwise(0)
.sum()
val loginCount = CaseBuilder() val loginCount = CaseBuilder()
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.LOGIN)) .`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.LOGIN))
.then(1) .then(1)
@@ -66,7 +61,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
val firstPaymentTotalAmount = CaseBuilder() val firstPaymentTotalAmount = CaseBuilder()
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT)) .`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT))
.then(adTrackingHistory.price) .then(adTrackingHistory.price)
.otherwise(0.toBigDecimal()) .otherwise(Expressions.constant(0.0))
.sum() .sum()
val repeatPaymentCount = CaseBuilder() val repeatPaymentCount = CaseBuilder()
@@ -78,7 +73,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
val repeatPaymentTotalAmount = CaseBuilder() val repeatPaymentTotalAmount = CaseBuilder()
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT)) .`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
.then(adTrackingHistory.price) .then(adTrackingHistory.price)
.otherwise(0.toBigDecimal()) .otherwise(Expressions.constant(0.0))
.sum() .sum()
val allPaymentCount = CaseBuilder() val allPaymentCount = CaseBuilder()
@@ -96,7 +91,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
.or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT)) .or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
) )
.then(adTrackingHistory.price) .then(adTrackingHistory.price)
.otherwise(0.toBigDecimal()) .otherwise(Expressions.constant(0.0))
.sum() .sum()
return queryFactory return queryFactory
@@ -106,15 +101,14 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
adTrackingHistory.mediaGroup, adTrackingHistory.mediaGroup,
adTrackingHistory.pid, adTrackingHistory.pid,
adTrackingHistory.pidName, adTrackingHistory.pidName,
launchCount,
loginCount, loginCount,
signUpCount, signUpCount,
firstPaymentCount, firstPaymentCount,
firstPaymentTotalAmount, roundedValueDecimalPlaces2(firstPaymentTotalAmount),
repeatPaymentCount, repeatPaymentCount,
repeatPaymentTotalAmount, roundedValueDecimalPlaces2(repeatPaymentTotalAmount),
allPaymentCount, allPaymentCount,
allPaymentTotalAmount roundedValueDecimalPlaces2(allPaymentTotalAmount)
) )
) )
.from(adTrackingHistory) .from(adTrackingHistory)
@@ -147,4 +141,13 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
"%Y-%m-%d" "%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 package kr.co.vividnext.sodalive.admin.statistics.ad
import com.querydsl.core.annotations.QueryProjection import com.querydsl.core.annotations.QueryProjection
import java.math.BigDecimal
data class GetAdminAdStatisticsResponse( data class GetAdminAdStatisticsResponse(
val totalCount: Int, val totalCount: Int,
@@ -13,13 +12,12 @@ data class GetAdminAdStatisticsItem @QueryProjection constructor(
val mediaGroup: String, val mediaGroup: String,
val pid: String, val pid: String,
val pidName: String, val pidName: String,
val launchCount: Int,
val loginCount: Int, val loginCount: Int,
val signUpCount: Int, val signUpCount: Int,
val firstPaymentCount: Int, val firstPaymentCount: Int,
val firstPaymentTotalAmount: BigDecimal, val firstPaymentTotalAmount: Double,
val repeatPaymentCount: Int, val repeatPaymentCount: Int,
val repeatPaymentTotalAmount: BigDecimal, val repeatPaymentTotalAmount: Double,
val allPaymentCount: Int, 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.charge.QCharge.charge
import kr.co.vividnext.sodalive.can.payment.PaymentStatus import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.can.payment.QPayment.payment 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.QMember.member
import kr.co.vividnext.sodalive.member.QSignOut.signOut import kr.co.vividnext.sodalive.member.QSignOut.signOut
import kr.co.vividnext.sodalive.member.auth.QAuth.auth
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -29,57 +27,6 @@ class AdminMemberStatisticsRepository(private val queryFactory: JPAQueryFactory)
.size .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 { fun getTotalSignOutCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
return queryFactory return queryFactory
.select(signOut.id) .select(signOut.id)
@@ -132,81 +79,6 @@ class AdminMemberStatisticsRepository(private val queryFactory: JPAQueryFactory)
.fetch() .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> { fun getSignOutCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> {
return queryFactory return queryFactory
.select( .select(

View File

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

View File

@@ -2,11 +2,7 @@ package kr.co.vividnext.sodalive.admin.statistics.member
data class GetMemberStatisticsResponse( data class GetMemberStatisticsResponse(
val totalCount: Int, val totalCount: Int,
val totalAuthCount: Int,
val totalSignUpCount: Int, val totalSignUpCount: Int,
val totalSignUpEmailCount: Int,
val totalSignUpKakaoCount: Int,
val totalSignUpGoogleCount: Int,
val totalSignOutCount: Int, val totalSignOutCount: Int,
val totalPaymentMemberCount: Int, val totalPaymentMemberCount: Int,
val items: List<GetMemberStatisticsItem> val items: List<GetMemberStatisticsItem>
@@ -14,11 +10,7 @@ data class GetMemberStatisticsResponse(
data class GetMemberStatisticsItem( data class GetMemberStatisticsItem(
val date: String, val date: String,
val authCount: Int,
val signUpCount: Int, val signUpCount: Int,
val signUpEmailCount: Int,
val signUpKakaoCount: Int,
val signUpGoogleCount: Int,
val signOutCount: Int, val signOutCount: Int,
val paymentMemberCount: 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 getCompletedAuditionCount(isAdult: Boolean): Int
fun getAuditionList(offset: Long, limit: Long, isAdult: Boolean): List<GetAuditionListItem> fun getAuditionList(offset: Long, limit: Long, isAdult: Boolean): List<GetAuditionListItem>
fun getAuditionDetail(auditionId: Long): GetAuditionDetailRawData fun getAuditionDetail(auditionId: Long): GetAuditionDetailRawData
fun getInProgressAuditionList(isAdult: Boolean): List<GetAuditionListItem>
} }
class AuditionQueryRepositoryImpl( class AuditionQueryRepositoryImpl(
@@ -95,27 +94,4 @@ class AuditionQueryRepositoryImpl(
.where(audition.id.eq(auditionId)) .where(audition.id.eq(auditionId))
.fetchFirst() .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 roleList = roleList
) )
} }
fun getInProgressAuditionList(isAdult: Boolean): List<GetAuditionListItem> {
return repository.getInProgressAuditionList(isAdult)
}
} }

View File

@@ -49,12 +49,10 @@ class AuditionApplicantQueryRepositoryImpl(
return queryFactory return queryFactory
.select(auditionApplicant.id) .select(auditionApplicant.id)
.from(auditionApplicant) .from(auditionApplicant)
.innerJoin(auditionApplicant.member, member)
.innerJoin(auditionApplicant.role, auditionRole) .innerJoin(auditionApplicant.role, auditionRole)
.where( .where(
auditionRole.id.eq(auditionRoleId), auditionRole.id.eq(auditionRoleId),
auditionApplicant.isActive.isTrue, auditionApplicant.isActive.isTrue
member.isActive.isTrue
) )
.fetch() .fetch()
.size .size
@@ -89,8 +87,7 @@ class AuditionApplicantQueryRepositoryImpl(
.leftJoin(auditionVote).on(auditionApplicant.id.eq(auditionVote.applicant.id)) .leftJoin(auditionVote).on(auditionApplicant.id.eq(auditionVote.applicant.id))
.where( .where(
auditionRole.id.eq(auditionRoleId), auditionRole.id.eq(auditionRoleId),
auditionApplicant.isActive.isTrue, auditionApplicant.isActive.isTrue
member.isActive.isTrue
) )
.groupBy(auditionApplicant.id) .groupBy(auditionApplicant.id)
.orderBy(orderBy) .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 package kr.co.vividnext.sodalive.can
import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.common.BaseEntity
import java.math.BigDecimal
import javax.persistence.Column
import javax.persistence.Entity import javax.persistence.Entity
import javax.persistence.EnumType import javax.persistence.EnumType
import javax.persistence.Enumerated import javax.persistence.Enumerated
@@ -12,10 +10,7 @@ data class Can(
var title: String, var title: String,
var can: Int, var can: Int,
var rewardCan: Int, var rewardCan: Int,
@Column(precision = 10, scale = 4, nullable = false) var price: Int,
var price: BigDecimal,
@Column(length = 3, nullable = false, columnDefinition = "CHAR(3)")
var currency: String,
@Enumerated(value = EnumType.STRING) @Enumerated(value = EnumType.STRING)
var status: CanStatus var status: CanStatus
) : BaseEntity() ) : BaseEntity()

View File

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

View File

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

View File

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

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

View File

@@ -1,9 +1,7 @@
package kr.co.vividnext.sodalive.can.charge package kr.co.vividnext.sodalive.can.charge
import java.math.BigDecimal
data class ChargeCompleteResponse( data class ChargeCompleteResponse(
val price: BigDecimal, val price: Double,
val currencyCode: String, val currencyCode: String,
val isFirstCharged: Boolean 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.AdTrackingHistoryType
import kr.co.vividnext.sodalive.marketing.AdTrackingService import kr.co.vividnext.sodalive.marketing.AdTrackingService
import kr.co.vividnext.sodalive.member.Member 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.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.time.LocalDateTime import java.time.LocalDateTime
import javax.servlet.http.HttpServletRequest
@RestController @RestController
@RequestMapping("/charge") @RequestMapping("/charge")
class ChargeController( class ChargeController(
private val service: ChargeService, private val service: ChargeService,
private val trackingService: AdTrackingService, private val trackingService: AdTrackingService
@Value("\${payverse.inbound-ip}")
private val payverseInboundIp: String
) { ) {
@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 @PostMapping
fun charge( fun charge(
@RequestBody chargeRequest: ChargeRequest, @RequestBody chargeRequest: ChargeRequest,
@@ -168,7 +111,8 @@ class ChargeController(
memberId = member.id!!, memberId = member.id!!,
chargeId = chargeId, chargeId = chargeId,
productId = request.productId, productId = request.productId,
purchaseToken = request.purchaseToken purchaseToken = request.purchaseToken,
paymentGateway = request.paymentGateway
) )
trackingCharge(member, response) trackingCharge(member, response)

View File

@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.can.charge
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.can.payment.PaymentGateway import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import java.math.BigDecimal
data class ChargeRequest(val canId: Long, val paymentGateway: PaymentGateway) data class ChargeRequest(val canId: Long, val paymentGateway: PaymentGateway)
@@ -21,14 +20,14 @@ data class VerifyResult(
val method: String, val method: String,
val pg: String, val pg: String,
val status: Int, val status: Int,
val price: BigDecimal val price: Int
) )
data class AppleChargeRequest( data class AppleChargeRequest(
val title: String, val title: String,
val chargeCan: Int, val chargeCan: Int,
val paymentGateway: PaymentGateway, val paymentGateway: PaymentGateway,
var price: BigDecimal? = null, var price: Double? = null,
var locale: String? = null var locale: String? = null
) )
@@ -39,53 +38,9 @@ data class AppleVerifyResponse(val status: Int)
data class GoogleChargeRequest( data class GoogleChargeRequest(
val title: String, val title: String,
val chargeCan: Int, val chargeCan: Int,
val price: BigDecimal, val price: Double,
val currencyCode: String, val currencyCode: String,
val productId: String, val productId: String,
val purchaseToken: String, val purchaseToken: String,
val paymentGateway: PaymentGateway 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.CanRepository
import kr.co.vividnext.sodalive.can.charge.event.ChargeSpringEvent import kr.co.vividnext.sodalive.can.charge.event.ChargeSpringEvent
import kr.co.vividnext.sodalive.can.coupon.CanCouponNumberRepository 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.Payment
import kr.co.vividnext.sodalive.can.payment.PaymentGateway import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.payment.PaymentStatus 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.google.GooglePlayService
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository 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.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.apache.commons.codec.digest.DigestUtils
import org.json.JSONObject import org.json.JSONObject
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
@@ -34,8 +27,6 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Service @Service
@Transactional(readOnly = true) @Transactional(readOnly = true)
@@ -45,9 +36,6 @@ class ChargeService(
private val memberRepository: MemberRepository, private val memberRepository: MemberRepository,
private val couponNumberRepository: CanCouponNumberRepository, private val couponNumberRepository: CanCouponNumberRepository,
private val grantLogRepository: PointGrantLogRepository,
private val memberPointRepository: MemberPointRepository,
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
private val okHttpClient: OkHttpClient, private val okHttpClient: OkHttpClient,
private val applicationEventPublisher: ApplicationEventPublisher, private val applicationEventPublisher: ApplicationEventPublisher,
@@ -65,341 +53,34 @@ class ChargeService(
@Value("\${apple.iap-verify-sandbox-url}") @Value("\${apple.iap-verify-sandbox-url}")
private val appleInAppVerifySandBoxUrl: String, private val appleInAppVerifySandBoxUrl: String,
@Value("\${apple.iap-verify-url}") @Value("\${apple.iap-verify-url}")
private val appleInAppVerifyUrl: String, 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
) { ) {
@Transactional @Transactional
fun payverseWebhook(request: PayverseWebhookRequest): Boolean { fun chargeByCoupon(couponNumber: String, member: Member) {
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 {
val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber) val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber)
?: throw SodaException("잘못된 쿠폰번호입니다.\n고객센터로 문의해 주시기 바랍니다.") ?: throw SodaException("잘못된 쿠폰번호입니다.\n고객센터로 문의해 주시기 바랍니다.")
if (canCouponNumber.member != null) { if (canCouponNumber.member != null) {
throw SodaException("이미 사용한 쿠폰번호 입니다.") throw SodaException("이미 사용한 쿠폰번호 입니다.")
} }
canCouponNumber.member = member canCouponNumber.member = member
val coupon = canCouponNumber.canCoupon!! val coupon = canCouponNumber.canCoupon!!
val couponCharge = Charge(0, coupon.can, status = ChargeStatus.COUPON)
couponCharge.title = "${coupon.can}"
couponCharge.member = member
when (coupon.couponType) { val payment = Payment(
CouponType.CAN -> { status = PaymentStatus.COMPLETE,
val couponCharge = Charge(0, coupon.can, status = ChargeStatus.COUPON) paymentGateway = PaymentGateway.PG
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 reqDate = savedCharge.createdAt!!.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) payment.method = coupon.couponName
val sign = DigestUtils.sha512Hex( couponCharge.payment = payment
String.format( chargeRepository.save(couponCharge)
"||%s||%s||%s||%s||%s||",
secretKey,
mid,
chargeId,
amount,
reqDate
)
)
val customerId = "${serverEnv}_user_${member.id!!}"
val payload = linkedMapOf( member.charge(0, coupon.can, "pg")
"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("결제정보에 오류가 있습니다.")
}
}
} }
@Transactional @Transactional
@@ -413,7 +94,7 @@ class ChargeService(
charge.can = can charge.can = can
val payment = Payment(paymentGateway = request.paymentGateway) val payment = Payment(paymentGateway = request.paymentGateway)
payment.price = can.price payment.price = can.price.toDouble()
charge.payment = payment charge.payment = payment
chargeRepository.save(charge) chargeRepository.save(charge)
@@ -452,14 +133,14 @@ class ChargeService(
) )
return ChargeCompleteResponse( return ChargeCompleteResponse(
price = charge.payment!!.price, price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId) isFirstCharged = chargeRepository.isFirstCharged(memberId)
) )
} else { } else {
throw SodaException("결제정보에 오류가 있습니다.") throw SodaException("결제정보에 오류가 있습니다.")
} }
} catch (_: Exception) { } catch (e: Exception) {
throw SodaException("결제정보에 오류가 있습니다.") throw SodaException("결제정보에 오류가 있습니다.")
} }
} else { } else {
@@ -484,7 +165,7 @@ class ChargeService(
VerifyResult::class.java VerifyResult::class.java
) )
if (verifyResult.status == 1) { if (verifyResult.status == 1 && verifyResult.price == charge.can?.price) {
charge.payment?.receiptId = verifyResult.receiptId charge.payment?.receiptId = verifyResult.receiptId
charge.payment?.method = if (verifyResult.pg.contains("카카오")) { charge.payment?.method = if (verifyResult.pg.contains("카카오")) {
"${verifyResult.pg}-${verifyResult.method}" "${verifyResult.pg}-${verifyResult.method}"
@@ -502,14 +183,14 @@ class ChargeService(
) )
return ChargeCompleteResponse( return ChargeCompleteResponse(
price = charge.payment!!.price, price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId) isFirstCharged = chargeRepository.isFirstCharged(memberId)
) )
} else { } else {
throw SodaException("결제정보에 오류가 있습니다.") throw SodaException("결제정보에 오류가 있습니다.")
} }
} catch (_: Exception) { } catch (e: Exception) {
throw SodaException("결제정보에 오류가 있습니다.") throw SodaException("결제정보에 오류가 있습니다.")
} }
} else { } else {
@@ -527,7 +208,7 @@ class ChargeService(
payment.price = if (request.price != null) { payment.price = if (request.price != null) {
request.price!! request.price!!
} else { } else {
0.toBigDecimal() 0.toDouble()
} }
payment.locale = request.locale payment.locale = request.locale
@@ -562,7 +243,7 @@ class ChargeService(
) )
return ChargeCompleteResponse( return ChargeCompleteResponse(
price = charge.payment!!.price, price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId) isFirstCharged = chargeRepository.isFirstCharged(memberId)
) )
@@ -579,7 +260,7 @@ class ChargeService(
member: Member, member: Member,
title: String, title: String,
chargeCan: Int, chargeCan: Int,
price: BigDecimal, price: Double,
currencyCode: String, currencyCode: String,
productId: String, productId: String,
purchaseToken: String, purchaseToken: String,
@@ -607,7 +288,8 @@ class ChargeService(
memberId: Long, memberId: Long,
chargeId: Long, chargeId: Long,
productId: String, productId: String,
purchaseToken: String purchaseToken: String,
paymentGateway: PaymentGateway
): ChargeCompleteResponse { ): ChargeCompleteResponse {
val charge = chargeRepository.findByIdOrNull(id = chargeId) val charge = chargeRepository.findByIdOrNull(id = chargeId)
?: throw SodaException("결제정보에 오류가 있습니다.") ?: throw SodaException("결제정보에 오류가 있습니다.")
@@ -629,7 +311,7 @@ class ChargeService(
) )
return ChargeCompleteResponse( return ChargeCompleteResponse(
price = charge.payment!!.price, price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId) isFirstCharged = chargeRepository.isFirstCharged(memberId)
) )
@@ -711,13 +393,4 @@ class ChargeService(
throw SodaException("결제를 완료하지 못했습니다.") 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 package kr.co.vividnext.sodalive.can.charge.temp
import kr.co.vividnext.sodalive.can.payment.PaymentGateway import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import java.math.BigDecimal
data class ChargeTempRequest( data class ChargeTempRequest(
val can: Int, val can: Int,
val price: BigDecimal, val price: Int,
val paymentGateway: PaymentGateway val paymentGateway: PaymentGateway
) )

View File

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

View File

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

View File

@@ -109,11 +109,11 @@ class CanCouponController(private val service: CanCouponService) {
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val completeMessage = service.useCanCoupon( ApiResponse.ok(
couponNumber = request.couponNumber, service.useCanCoupon(
memberId = member.id!! 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? { override fun findByCouponNumber(couponNumber: String): CanCouponNumber? {
return queryFactory return queryFactory
.selectFrom(canCouponNumber) .selectFrom(canCouponNumber)
.innerJoin(canCouponNumber.canCoupon, canCoupon)
.where(canCouponNumber.couponNumber.eq(couponNumber)) .where(canCouponNumber.couponNumber.eq(couponNumber))
.fetchFirst() .fetchFirst()
} }

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,6 @@ data class GetCouponListResponse(
data class GetCouponListItemResponse @QueryProjection constructor( data class GetCouponListItemResponse @QueryProjection constructor(
val id: Long, val id: Long,
val couponName: String, val couponName: String,
val couponType: String,
val can: Int, val can: Int,
val couponCount: Int, val couponCount: Int,
var useCouponCount: 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.UseCanCalculateRepository
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.can.use.UseCanRepository 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.common.SodaException
import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.order.Order import kr.co.vividnext.sodalive.content.order.Order
@@ -38,8 +37,6 @@ class CanPaymentService(
memberId: Long, memberId: Long,
needCan: Int, needCan: Int,
canUsage: CanUsage, canUsage: CanUsage,
chatRoomId: Long? = null,
characterId: Long? = null,
isSecret: Boolean = false, isSecret: Boolean = false,
liveRoom: LiveRoom? = null, liveRoom: LiveRoom? = null,
order: Order? = null, order: Order? = null,
@@ -112,14 +109,6 @@ class CanPaymentService(
recipientId = liveRoom.member!!.id!! recipientId = liveRoom.member!!.id!!
useCan.room = liveRoom useCan.room = liveRoom
useCan.member = member 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 { } else {
throw SodaException("잘못된 요청입니다.") throw SodaException("잘못된 요청입니다.")
} }
@@ -338,98 +327,4 @@ class CanPaymentService(
chargeRepository.save(charge) 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.can.charge.Charge
import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.common.BaseEntity
import java.math.BigDecimal
import javax.persistence.Column import javax.persistence.Column
import javax.persistence.Entity import javax.persistence.Entity
import javax.persistence.EnumType import javax.persistence.EnumType
@@ -26,8 +25,7 @@ data class Payment(
var receiptId: String? = null var receiptId: String? = null
var method: String? = null var method: String? = null
@Column(precision = 10, scale = 4, nullable = false) var price: Double = 0.toDouble()
var price: BigDecimal = 0.toBigDecimal()
var locale: String? = null var locale: String? = null
var orderId: String? = null var orderId: String? = null
} }

View File

@@ -1,5 +1,5 @@
package kr.co.vividnext.sodalive.can.payment package kr.co.vividnext.sodalive.can.payment
enum class PaymentGateway { 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, SPIN_ROULETTE,
PAID_COMMUNITY_POST, PAID_COMMUNITY_POST,
ALARM_SLOT, ALARM_SLOT,
AUDITION_VOTE, AUDITION_VOTE
CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용)
CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매
CHAT_QUOTA_PURCHASE, // 채팅 횟수(쿼터) 충전
CHAT_ROOM_RESET // 채팅방 초기화 결제(별도 구분)
} }

View File

@@ -1,8 +1,6 @@
package kr.co.vividnext.sodalive.can.use package kr.co.vividnext.sodalive.can.use
import kr.co.vividnext.sodalive.audition.AuditionApplicant 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.common.BaseEntity
import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.order.Order import kr.co.vividnext.sodalive.content.order.Order
@@ -30,11 +28,7 @@ data class UseCan(
var isRefund: Boolean = false, var isRefund: Boolean = false,
val isSecret: Boolean = false, val isSecret: Boolean = false
// 채팅 연동을 위한 식별자 (옵션)
var chatRoomId: Long? = null,
var characterId: Long? = null
) : BaseEntity() { ) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false) @JoinColumn(name = "member_id", nullable = false)
@@ -64,16 +58,6 @@ data class UseCan(
@JoinColumn(name = "audition_applicant_id", nullable = true) @JoinColumn(name = "audition_applicant_id", nullable = true)
var auditionApplicant: AuditionApplicant? = null 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]) @OneToMany(mappedBy = "useCan", cascade = [CascadeType.ALL])
val useCanCalculates: MutableList<UseCanCalculate> = mutableListOf() val useCanCalculates: MutableList<UseCanCalculate> = mutableListOf()
} }

View File

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