Compare commits

..

30 Commits

Author SHA1 Message Date
efa1643359 Merge pull request '콘텐츠 등록 - 콘텐츠 설명 전체 오픈 여부 추가' (#30) from test into main
Reviewed-on: #30
2024-11-23 18:07:30 +00:00
3b294ba020 Merge pull request ''소다라이브' -> '보이스온' 으로 변경' (#29) from test into main
Reviewed-on: #29
2024-11-21 12:43:18 +00:00
8cdbea59de Merge pull request '소장만 기능 추가' (#28) from test into main
Reviewed-on: #28
2024-11-12 14:25:52 +00:00
8ac488bf6f Merge pull request '콘텐츠 업로드 - 미리듣기 최소 시간 30초에서 15초로 변경' (#27) from test into main
Reviewed-on: #27
2024-09-25 11:17:15 +00:00
5664f1be9e Merge pull request '시리즈 관리 페이지에서만 html전체 scroll을 동작하지 않도록 수정' (#26) from test into main
Reviewed-on: #26
2024-08-16 13:28:47 +00:00
c5f707efb9 Merge pull request '시리즈 관리 - 시리즈 크기 한줄에 6개가 들어가도록 수정' (#25) from test into main
Reviewed-on: #25
2024-08-14 08:32:30 +00:00
27a827662e Merge pull request 'test' (#24) from test into main
Reviewed-on: #24
2024-08-14 07:44:43 +00:00
b7c8bed727 Merge pull request '커뮤니티 정산 - 합계 추가' (#23) from test into main
Reviewed-on: #23
2024-06-20 06:03:29 +00:00
f059dda7eb Merge pull request 'test' (#22) from test into main
Reviewed-on: #22
2024-06-03 22:26:57 +00:00
4cdcf1d0b6 Merge pull request '콘텐츠 가격 수정, 시그니처 정렬' (#21) from test into main
Reviewed-on: #21
2024-05-29 17:16:28 +00:00
4497141061 Merge pull request '커뮤니티 정산페이지 추가' (#20) from test into main
Reviewed-on: #20
2024-05-27 08:33:24 +00:00
dfaac20b63 Merge pull request '시그니처 후원 생성/수정 - 재생시간 추가' (#19) from test into main
Reviewed-on: #19
2024-05-02 06:28:56 +00:00
0d3bc1c16e Merge pull request '시리즈 등록 - 크리에이터 -> 장르로 변경' (#18) from test into main
Reviewed-on: #18
2024-04-26 19:15:33 +00:00
c88aa227fd Merge pull request '시리즈' (#17) from test into main
Reviewed-on: #17
2024-04-26 18:57:40 +00:00
3631919245 Merge pull request '콘텐츠 리스트 한정판 표시 - 판매된 개수/전체개수, 다 팔리면 Sold Out으로 표시하도록 수정' (#16) from test into main
Reviewed-on: #16
2024-03-29 03:21:24 +00:00
4a4783563e Merge pull request 'test' (#15) from test into main
Reviewed-on: #15
2024-03-28 06:58:57 +00:00
28d56ab59a Merge pull request '시그니처 관리' (#14) from test into main
Reviewed-on: #14
2024-03-13 11:39:55 +00:00
be97e0ab31 Merge pull request '시그니처 조회/등록/수정/삭제 페이지 추가' (#13) from test into main
Reviewed-on: #13
2024-03-12 06:34:30 +00:00
5e8ec80621 Merge pull request '파비콘 변경' (#12) from test into main
Reviewed-on: #12
2024-02-17 15:02:19 +00:00
0efb3ea86d Merge pull request 'test' (#11) from test into main
Reviewed-on: #11
2024-02-07 09:53:49 +00:00
c0b6a23782 Merge pull request '콘텐츠 업로드 - 미리듣기 생성하지 않을 수 있도록 수정' (#10) from test into main
Reviewed-on: #10
2024-01-30 03:31:28 +00:00
a838b3673c Merge pull request '콘텐츠 등록 - 예약 업로드 추가' (#9) from test into main
Reviewed-on: #9
2024-01-11 09:16:41 +00:00
f64f1f0fb7 Merge pull request '일자별 콘텐츠 후원 페이지 - 유/무료 구분 추가' (#8) from test into main
Reviewed-on: #8
2023-11-14 13:24:07 +00:00
3088f957e2 Merge pull request 'test' (#7) from test into main
Reviewed-on: #7
2023-11-14 12:11:01 +00:00
41f99a175c Merge pull request '콘텐츠별 누적 현황 페이지 추가' (#6) from test into main
Reviewed-on: #6
2023-11-13 15:31:10 +00:00
61b5d785a3 Merge pull request '일자별 콘텐츠 정산 페이지 추가' (#5) from test into main
Reviewed-on: #5
2023-11-13 10:07:06 +00:00
3fe7554e06 Merge pull request '콘텐츠 등록 - 대여만 가능한 콘텐츠 등록할 수 있도록 체크박스 추가' (#4) from test into main
Reviewed-on: #4
2023-10-30 02:10:49 +00:00
1def9ddd4a Merge pull request '미리듣기 시간설정 기능 추가' (#3) from test into main
Reviewed-on: #3
2023-10-04 15:43:22 +00:00
a13d442924 Merge pull request '정산 - 합계 추가' (#2) from test into main
Reviewed-on: #2
2023-10-04 07:22:59 +00:00
a1f206a3c0 Merge pull request 'test' (#1) from test into main
Reviewed-on: #1
2023-10-04 03:34:29 +00:00
24 changed files with 42 additions and 1663 deletions

View File

@@ -1,6 +1,2 @@
VUE_APP_API_URL=https://test-api.sodalive.net
NODE_ENV=development
VUE_APP_GOOGLE_CLIENT_ID=758414412471-mosodbj2chno7l1j0iihldh6edmk0gk9.apps.googleusercontent.com
VUE_APP_KAKAO_JS_KEY=2be3c619ed36fd3e138bf45812c57d7f
VUE_APP_APPLE_CLIENT_ID=kr.co.vividnext.sodalive.service.debug
VUE_APP_APPLE_REDIRECT_URI=https://test-creator.sodalive.net

View File

@@ -1,6 +1,2 @@
VUE_APP_API_URL=https://api.sodalive.net
NODE_ENV=production
VUE_APP_GOOGLE_CLIENT_ID=983594297130-5hrmkh6vpskeq6v34350kmilf74574h2.apps.googleusercontent.com
VUE_APP_KAKAO_JS_KEY=378e800dd9029907c559390e786157ef
VUE_APP_APPLE_CLIENT_ID=kr.co.vividnext.sodalive.service
VUE_APP_APPLE_REDIRECT_URI=https://creator.sodalive.net

View File

@@ -1,43 +0,0 @@
# AGENTS.md
## 문서 목적
- 이 문서는 `/Users/klaus/Develop/sodalive/Admin/soda-creator-admin` 저장소에서 작업하는 에이전트용 실행 가이드다.
- 목표는 "추측 최소화 + 기존 패턴 준수 + 검증 우선"이다.
- 이 문서의 규칙은 코드/테스트/문서 변경 모두에 적용한다.
## 커뮤니케이션 규칙
- **"질문에 대한 답변과 설명은 한국어로 한다."**
- 이 저장소에서 사용자에게 전달하는 설명, 진행 상황, 결과 보고는 한국어로 작성한다.
- 코드 식별자, 경로, 명령어는 원문(영문) 그대로 유지한다.
## 커밋 메시지 규칙 (표준 Conventional Commits)
- 기본 형식은 `<type>(scope): <description>`를 사용한다.
- `type`은 소문자(`feat`, `fix`, `chore`, `docs`, `refactor`, `test` 등)를 사용한다.
- 제목(description)은 한글로 작성하고, 명령형/간결한 현재형으로 작성한다.
### 커밋 메시지 검증 절차
- `git commit` 실행 직전에 `work/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 확인한다.
- `git commit` 실행 직후에도 `work/check-commit-message-rules.sh`를 다시 실행해 최종 메시지를 재검증한다.
- 스크립트 결과가 `[FAIL]`이면 커밋 메시지를 규칙에 맞게 수정한 뒤 다시 검증한다.
## 작업 계획 문서 규칙 (docs)
- 모든 작업 시작 전에 `docs` 폴더 아래에 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현을 진행한다.
- **기능 추가 중 발생한 오류 수정이나 보완 작업은 새로운 문서를 만들지 않고, 기존 기능 추가 계획 문서에 내용을 추가한다.**
- 계획 문서 파일명은 `[날짜]_구현할내용한글.md` 형식을 사용한다.
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
- 파일명 예시: `20260101_구글계정으로로그인.md`
- 구현 항목은 기능/작업 단위로 분리해 체크박스(`- [ ]`) 목록으로 작성한다.
- 구현 완료 시마다 체크박스를 `- [x]`로 갱신하고, 각 항목이 정상 구현되었는지 확인한다.
- 작업 도중 범위가 변경되면 계획 문서의 체크박스 항목을 먼저 업데이트한 뒤 구현을 진행한다.
- 모든 구현이 끝난 후 결과 보고 시 계획 문서 맨 아래에 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 기록한다.
- 후속 수정이 발생해도 기존 검증 기록은 삭제/덮어쓰지 않고 누적한다(예: `1차 구현`, `2차 수정`).
- 검증 기록은 단계별로 `무엇을/왜/어떻게`를 유지해 작성하고, 이전 단계와 구분이 되도록 명시한다.
- 단계별 `어떻게`에는 실제 실행한 검증 명령과 결과(성공/실패/불가 사유)를 함께 기록한다.
- 기존 기록 정정이 필요하면 원문을 지우지 말고 `정정` 항목을 추가해 사유와 변경 내용을 남긴다.
## 에이전트 동작 원칙
- 추측하지 말고, 근거 파일을 읽고 결정한다.
- 기존 관례를 깨는 변경은 이유가 명확할 때만 수행한다.
- 불필요한 리팩터링 확장은 피하고 요청 범위를 우선 충족한다.
- 결과 보고 시 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 남긴다.
-

View File

@@ -1,37 +0,0 @@
# 20260227 시그니처 이미지 크롭 기능 추가
## 구현 목표
시그니처 이미지를 등록할 때 사용자 편의를 위해 크롭 기능을 추가한다.
- 해상도 가로 최대 800px (800px보다 작은 경우 원본 크기 유지)
- 비율 1:1 지원
- GIF 이미지는 크롭 없이 바로 업로드
- 기타 이미지 포맷은 크롭 기능 적용
## 작업 내역
- [x] `SignatureManagement.vue` 수정
- [x] `cropperjs` 임포트 및 스타일 추가
- [x] 이미지 크롭 다이얼로그 UI 구현
- [x] `imageAdd` 메서드 수정 (GIF 구분 및 크롭 처리)
- [x] `cropImage` 메서드 구현 (1:1 비율 및 800px 리사이징 로직 포함)
- [x] `cancelCropper` 메서드 구현
- [x] `data`에 크롭 관련 변수 추가
- [x] CSS 스타일 정의
## 검증 계획
- [x] GIF 이미지 업로드 테스트: 크롭 다이얼로그 없이 바로 등록되는지 확인 (코드 로직 검증)
- [x] 일반 이미지(JPG/PNG) 업로드 테스트: 1:1 비율 크롭 다이얼로그 표시 확인 (코드 로직 검증)
- [x] 이미지 리사이징 테스트:
- [x] 가로 800px 초과 이미지 크롭 시 가로 800px로 리사이징되는지 확인 (코드 로직 검증)
- [x] 가로 800px 이하 이미지 크롭 시 원본 가로 크기가 유지되는지 확인 (코드 로직 검증)
- [x] 크롭 후 최종 등록 및 서버 업로드 확인 (코드 로직 검증)
## 검증 기록
### 1차 구현
- 무엇을: 시그니처 이미지 등록 시 1:1 크롭 기능 구현
- 왜: 시그니처 이미지 해상도 관리 및 사용자 편의 제공
- 어떻게:
- `cropperjs`를 사용하여 1:1 비율 크롭 다이얼로그 구현
- GIF 포맷은 `file.type === 'image/gif'` 조건으로 크롭 제외
- `getCroppedCanvas()` 이후 가로가 800px을 초과할 경우 `canvas`를 사용하여 800x800으로 리사이징 수행
- 800px 이하인 경우 원본 크롭 해상도 유지
- `SignatureManagement.vue` 파일 수정 및 관련 스타일 추가

View File

@@ -1,38 +0,0 @@
# 20260227_시리즈커버이미지크롭기능추가.md
## 구현할 내용
- [x] `ContentSeriesList.vue``cropperjs`를 활용한 이미지 크롭 기능 추가
- [x] 1:1.5 비율(aspectRatio: 1/1.5) 지원
- [x] 가로 해상도 최대 1000px 제한 로직 추가 (1000px 미만은 원본 크기 유지)
- [x] 크롭 다이얼로그 및 관련 상태 관리 로직 구현
- [x] 크롭 후 `_isCropped` 플래그를 통한 중복 처리 방지
- [x] 크롭 다이얼로그 내 이미지 표시 영역 높이 제한 및 스타일 수정
- [x] 큰 이미지가 표시될 때 다이얼로그 밖으로 나가지 않도록 `max-height` 조정
- [x] 시리즈 이미지 크롭 비율을 A4 비율(210:297)로 수정
## 검증 기록
### 1차 구현
- **무엇을**: `ContentSeriesList.vue` 시리즈 등록/수정 시 커버 이미지 크롭 기능 추가
- **왜**: 시리즈 커버 이미지의 비율을 1:1.5로 강제하고, 고해상도 이미지 업로드 시 가로 1000px로 리사이징하여 서버 부하 및 레이아웃 깨짐을 방지하기 위함
- **어떻게**:
- `cropperjs` 라이브러리를 도입하여 `aspectRatio: 1 / 1.5` 설정
- `cropImage` 메서드에서 `canvas.width`가 1000px을 초과할 경우에만 리사이징 수행
- `file._isCropped` 플래그를 사용하여 크롭 완료 후 `v-file-input` 모델 변경 시 발생하는 `imageAdd` 중복 호출 방지
- `npm run lint`를 통해 코드 스타일 검증 완료
### 2차 수정
- **무엇을**: `ContentSeriesList.vue`의 크롭퍼 이미지 컨테이너 스타일 수정
- **왜**: 이미지가 너무 클 경우 다이얼로그를 벗어나 스크롤을 해야만 '크롭 완료' 버튼을 누를 수 있는 불편함이 있음.
- **어떻게**:
- `.cropper-wrapper``max-height``500px`에서 `70vh`로 변경하여 브라우저 높이에 대응하도록 수정.
- `npm run lint` 실행 결과 이상 없음 확인.
+
+### 3차 수정
+- **무엇을**: 시리즈 커버 이미지 크롭 비율 수정 (1:1.5 -> A4 비율)
+- **왜**: 시리즈 이미지 규격을 A4 비율(210:297)로 통일하기 위함
+- **어떻게**:
+ - `ContentSeriesList.vue`에서 `aspectRatio``210 / 297`로 수정
+ - 크롭 다이얼로그 타이틀을 "이미지 크롭 (A4 비율)"으로 변경
+ - 리사이징 시 높이 계산 방식을 `MAX_WIDTH * (297 / 210)`으로 수정
+ - 목록의 커버 이미지 `aspect-ratio``210/297`로 수정
+ - `npm run lint` 실행 결과 이상 없음 확인

View File

@@ -1,14 +0,0 @@
# 20260227_이미지크롭포커스이슈수정.md
## 구현할 내용
- [x] 이미지 크롭 후 포커스가 다른 곳으로 가면 다시 크롭화면이 표시되는 문제 해결
- [x] 크롭 후 `v-file-input` 값 초기화 로직 추가
## 검증 기록
### 1차 수정
- **무엇을**: `ContentList.vue`의 이미지 선택 및 크롭 로직 수정
- **왜**: `v-file-input`에 선택된 파일이 남아 있어 다른 곳에 포커스가 갔다가 돌아오거나 입력이 발생할 때 `@change` 이벤트가 예기치 않게 발생할 가능성이 있고, 사용자 경험상 불편함이 있음.
- **어떻게**:
- `imageAdd` 메서드에서 파일을 처리하기 전에 `file._isCropped` 플래그를 확인하여 이미 크롭된 파일인 경우 중복 처리를 방지함.
- `cropImage` 메서드에서 크롭된 새로운 `File` 객체를 생성할 때 `_isCropped = true` 플래그를 설정하여 `audio_content.cover_image`가 업데이트될 때 발생하는 `@change` 이벤트에 의해 크롭 다이얼로그가 다시 열리는 것을 방지함.
- `npm run lint` 실행 결과 이상 없음.

View File

@@ -1,18 +0,0 @@
# 20260227_초기환경설정및가이드준수
## 구현 항목
- [x] AGENTS.md 파일 확인 및 내용 숙지
- [x] work/check-commit-message-rules.sh 파일 확인 및 실행 권한 확인
- [x] 변경 사항 스테이징
- [x] 커밋 메시지 규칙 검증
- [x] 가이드에 따른 커밋 수행
## 검증 기록
### 1차 구현
#### 무엇을
- 초기 프로젝트 설정 파일(`AGENTS.md`, `work/check-commit-message-rules.sh`)을 커밋한다.
#### 왜
- 에이전트 작업 가이드를 준수하고, 이후 작업의 기반을 마련하기 위함이다.
#### 어떻게
- `work/check-commit-message-rules.sh --message "docs: 에이전트 작업 가이드 및 검증 스크립트 추가"` 명령으로 메시지 규칙을 검증했다.
- `git commit -m "docs: 에이전트 작업 가이드 및 검증 스크립트 추가"` 명령으로 커밋을 시도한다.

View File

@@ -1,39 +0,0 @@
# 20260227_콘텐츠커버이미지크롭기능추가.md
## 작업 개요
콘텐츠 등록 및 수정 시 커버 이미지를 1:1 비율로 크롭하여 등록할 수 있는 기능을 추가한다.
## 작업 항목
- [x] cropperjs 라이브러리 설치
- [x] ContentList.vue에 크롭용 다이얼로그 및 UI 추가
- [x] cropperjs 연동 및 1:1 비율 설정 구현
- [x] 이미지 리사이징 로직 구현 (800x800 제한)
- [x] 콘텐츠 등록(save) 및 수정(modify) 로직에 크롭된 이미지 적용
- [x] 기존 이미지 선택 로직(imageAdd) 수정
## 검증 계획
### 무엇을
- 커버 이미지 선택 시 크롭 다이얼로그가 정상적으로 표시되는지 확인
- 1:1 비율로 크롭이 제한되는지 확인
- 해상도가 800px 이상인 이미지가 800x800으로 리사이징되는지 확인
- 해상도가 800px 미만인 이미지가 원래 해상도를 유지하며 1:1로 크롭되는지 확인
- 최종적으로 서버에 크롭된 이미지가 정상 전달되는지 확인
### 왜
- 사용자 요구사항(1:1 비율, 800x800 제한) 충족 여부 확인
### 결과 보고
1차 구현 완료.
- 무엇을: 커버 이미지 크롭 기능 및 800x800 리사이징 로직 구현
- 왜: 1:1 비율 유지 및 서버 저장 용량 최적화를 위해
- 어떻게:
- `npm install cropperjs` 실행
- `ContentList.vue``cropperjs` 연동 및 `canvas`를 이용한 리사이징 로직 추가
- `npm run lint`를 통해 코드 스타일 검증 (성공)
2차 수정 완료.
- 무엇을: `Module not found: Error: Can't resolve 'cropperjs/dist/cropper.css'` 에러 수정
- 왜: `cropperjs` 2.x 버전에서는 CSS 파일이 포함되어 있지 않아 빌드 에러 발생
- 어떻게:
- `cropperjs`를 1.x 버전(`^1.5.13`)으로 재설치하여 CSS 파일을 포함하도록 수정
- `npm run lint`를 통해 코드 스타일 검증 (성공)

View File

@@ -1,15 +0,0 @@
# 20260326_시그니처이미지크롭비율자유조정
## 구현 항목
- [x] `src/views/Signature/SignatureManagement.vue` 수정
- [x] `Cropper` 초기화 시 `aspectRatio: 1`을 제거하여 자유 비율 허용
- [x] UI 상의 "(1:1 비율)" 안내 텍스트 수정
- [x] `cropImage` 메서드 내 리사이징 로직 수정 (비율 유지하며 리사이징)
## 검증 기록
- 1차 구현
- 무엇을: 시그니처 이미지 크롭 비율 자유 조정 기능
- 왜: 사용자가 1:1 비율이 아닌 자유로운 비율로 이미지를 등록할 수 있도록 하기 위함
- 어떻게:
- `src/views/Signature/SignatureManagement.vue` 파일의 `Cropper` 설정에서 `aspectRatio: 1`을 제거하고, `cropImage` 메서드에서 리사이징 시 고정 높이가 아닌 원본 비율에 따른 높이를 계산하도록 수정함.
- `work/check-commit-message-rules.sh`를 사용하여 커밋 메시지 규칙 준수 여부 확인 완료.

View File

@@ -1,202 +0,0 @@
# 애플 로그인 추가
## 체크리스트 (기능/작업 단위)
- [x] public/index.html에 Apple JS SDK 스크립트 추가
- [x] Login.vue에 애플 로그인 버튼(UI) 추가 및 클릭 핸들러 연결
- [x] AppleID.auth.init 구성(usePopup, response_type, scope, redirectURI 등)
- [x] Apple signIn 성공 시 id_token/code 수집 및 스토어 디스패치 처리
- [x] API 모듈(src/api/member.js)에 loginApple(token|code) 함수 추가
- [x] 스토어(src/store/accountStore.js)에 LOGIN_APPLE 액션 추가 및 공통 LOGIN mutation 연동
- [x] 환경변수 설정 추가 가이드: VUE_APP_APPLE_CLIENT_ID, VUE_APP_APPLE_REDIRECT_URI (.env.*)
- [x] 서버 엔드포인트 요청 형식 정합화: POST /member/login/apple 본문에 container/identityToken/nonce 전달
- [x] 트러블슈팅 가이드 추가: 403 에러 및 200 응답 후 중단 현상 원인 분석
- [x] nonce 정합화 보완: iOS와 동일한 raw nonce 생성 규칙 + SHA-256(Base64URL) 적용
- [ ] QA/수동검증: 실제 Apple 계정으로 팝업 로그인 플로우 확인 및 토큰 교환 성공 확인(테스트 서버 배포 후)
## 범위 변경 사항
- 현재 프론트엔드에서 id_token을 우선 사용하도록 구현. code만 수신되는 경우도 대비해 code를 Bearer로 전달하도록 임시 대응(서버에서 code 교환 처리 필요).
## 서버 연동 규격(중요)
백엔드 요청 스키마(Swift/Kotlin 등) 예시:
```
data class SocialLoginRequest(
val container: String,
val pushToken: String? = null,
val marketingPid: String? = null,
val identityToken: String? = null,
val nonce: String? = null
)
```
프론트엔드에서 호출하는 실제 요청(JSON):
```
POST /member/login/apple
Content-Type: application/json
{
"container": "web",
"identityToken": "<Apple에서 받은 id_token>",
"nonce": "<로그인 시 생성한 raw nonce>",
"pushToken": null,
"marketingPid": null
}
```
- container 값은 web로 고정합니다.
- nonce는 프론트에서 매 로그인 시점에 보안 난수로 생성합니다(raw). Apple에 전달하는 nonce는 SHA-256 해시(Base64URL)로 보냅니다. 서버에서는 raw nonce를 받아 동일한 방식으로 해시하여 id_token 내 nonce와 일치하는지 검증합니다.
코드 반영 사항:
- src/views/Login/Login.vue: 로그인 직전 raw nonce 생성(iOS와 동일한 문자셋/샘플링 규칙) → SHA-256 해시(Base64URL)를 AppleID.auth.init의 nonce로 설정 → signIn 후 id_token과 raw nonce를 서버로 전송.
- src/api/member.js: Authorization 헤더 제거. 요청 본문으로 { container: 'web', identityToken, nonce, ... } 전송.
## 환경변수 설정(redirect URI, apple_client_id)
Apple Service ID(= apple_client_id)와 Redirect URI는 Apple Developer 계정의 Identifiers > Service IDs에 등록되어야 하며, Redirect URI 도메인은 도메인 소유권/연결이 되어 있어야 합니다.
프로젝트 .env 샘플(필요한 파일에 추가):
```
# 테스트 서버용(개발/로컬에서도 동일 값 사용 권장)
VUE_APP_APPLE_CLIENT_ID=com.example.creator.admin.test # Service ID (예: com.soda.creator.admin.test)
VUE_APP_APPLE_REDIRECT_URI=https://test-admin.example.com/apple/callback
# 프로덕션
VUE_APP_APPLE_CLIENT_ID=com.example.creator.admin # Service ID (예: com.soda.creator.admin)
VUE_APP_APPLE_REDIRECT_URI=https://creator-admin.example.com/apple/callback
```
주의(로컬 개발 환경):
- Apple은 localhost를 Redirect URI로 허용하지 않습니다. 로컬에서도 테스트/스테이징 도메인에 등록된 Redirect URI를 사용하세요.
- 즉, 로컬에서 `npm run serve_local`로 앱을 띄우더라도, env는 테스트 Service ID와 Redirect URI(https://test-... 도메인)를 사용하면 팝업 플로우가 동작합니다.
## 로컬/테스트 환경에서의 검증 방법
1) 환경 구성
- 로컬 실행: `npm run serve_local` (또는 `npm run serve`)
- .env.local 또는 .env.development에 테스트용 값을 설정
- `VUE_APP_APPLE_CLIENT_ID=com.example.creator.admin.test`
- `VUE_APP_APPLE_REDIRECT_URI=https://test-admin.example.com/apple/callback`
2) 브라우저에서 로그인 페이지 접속 후 확인
- 개발자 도구 콘솔에서 `window.AppleID``AppleID.auth.init` 호출 에러 여부 확인
- "Apple로 로그인" 버튼 클릭 시 팝업 표시 확인
3) 요청/응답 확인(네트워크 탭)
- `POST /member/login/apple` 요청 본문이 다음 형태인지 확인:
- `container: "web"`
- `identityToken: <길이가 긴 JWT 문자열>`
- `nonce: <base64url 형태의 원본 nonce>`
- 서버가 토큰 검증/교환에 성공하면 200 + 로그인 토큰 수신 → 자동 라우팅
4) 실패 시 점검 포인트
- Apple Service ID와 Redirect URI가 Apple Developer 콘솔에 정확히 등록/연결되었는지
- 테스트 도메인이 HTTPS이며, Apple에서 허용된 도메인인지
- 서버의 `/member/login/apple`에서 nonce 검증(원본 → SHA-256 → id_token 내 nonce 비교)이 구현되어 있는지
## 200 응답 + id_token 수신 후 '아무 일도 안 일어남' 트러블슈팅
다음 조건 중 하나라도 만족하면 "팝업 인증은 성공(200, id_token 발급)했지만, 라우팅/세션 저장이 일어나지 않는" 증상이 발생할 수 있습니다.
1) redirectURI 오리진(origin) 불일치
- Apple JS(Web) 팝업 방식에서는 redirectURI가 로드되는 페이지에 Apple JS(`appleid.auth.js`)가 포함되어 있어야 하며, 가능하면 로그인 호출을 한 앱과 동일 오리진을 사용하는 것이 안전합니다.
- 현재 프로젝트의 .env 값 예시(확인 필요):
- development: `VUE_APP_APPLE_REDIRECT_URI=https://test-creator.sodalive.net`
- production: `VUE_APP_APPLE_REDIRECT_URI=https://creator.sodalive.net`
- 만약 이 Admin 앱이 위 도메인에서 서비스되지 않는다면(예: 관리자앱이 별도 도메인에서 운영), 팝업 콜백 메시지 전파가 실패하거나 흐름이 중단될 수 있습니다.
- 권장: Admin 앱이 서비스되는 오리진과 동일한 URL(예: `https://<admin-domain>/apple/callback` 또는 단순히 `https://<admin-domain>/`)을 redirectURI로 등록하고, 해당 페이지가 Apple JS 스크립트를 포함하도록 합니다. 본 프로젝트의 `public/index.html`에는 Apple JS가 포함되어 있으므로 같은 오리진이라면 별도의 콜백 페이지 없이도 동작합니다.
2) 백엔드 응답 스키마 불일치
- 프론트는 다음 형태를 기대합니다.
- HTTP 200
- `res.data.success === true`
- `res.data.data` 안에 `token`, `userId`, `nickname`, `profileImage` 등 로그인 세션 정보 존재
- 200이더라도 `success !== true`이거나 `data.token`이 없으면 프론트는 라우팅을 진행하지 않습니다. 서버 응답 형식을 위 스키마에 맞추거나, 임시로 프론트에 스키마 호환 계층을 추가해야 합니다.
3) 콘솔/네트워크에서 즉시 확인할 항목
- 콘솔 경고:
- `[Apple Sign-In] 환경변수 누락: VUE_APP_APPLE_CLIENT_ID 혹은 VUE_APP_APPLE_REDIRECT_URI`
- `[Apple Sign-In] redirectURI 오리진이 현재 앱과 다릅니다.`
- 네트워크:
- `POST /member/login/apple` 응답 본문에서 `success`, `data.token` 확인
### 조치 가이드(샘플)
1) redirectURI와 Service ID(apple_client_id) 샘플
```
# 테스트(동일 오리진 권장)
VUE_APP_APPLE_CLIENT_ID=kr.co.vividnext.sodalive.service.debug
VUE_APP_APPLE_REDIRECT_URI=https://<admin-test-domain>/apple/callback # 또는 https://<admin-test-domain>/
# 운영
VUE_APP_APPLE_CLIENT_ID=kr.co.vividnext.sodalive.service
VUE_APP_APPLE_REDIRECT_URI=https://<admin-prod-domain>/apple/callback # 또는 https://<admin-prod-domain>/
```
-`<admin-*-domain>`에는 실제 관리자앱이 서비스되는 도메인을 사용하세요. 만약 기존 값처럼 `https://test-creator.sodalive.net`(크리에이터 사용자용 도메인)로 설정되어 있다면, 관리자앱이 동일 도메인에서 구동되는지 먼저 확인해야 합니다.
2) 서버 응답 예시(프론트 기대 형식)
```
HTTP/1.1 200 OK
Content-Type: application/json
{
"success": true,
"data": {
"userId": "12345",
"nickname": "관리자",
"profileImage": null,
"token": "<JWT or opaque access token>"
}
}
```
## 검증 기록
### 2차 수정(리다이렉트/200 이후 중단 진단 보강)
- 무엇을: redirectURI 오리진 점검 항목과 콘솔 경고 추가, 200 이후 중단 시 백엔드 응답 스키마 점검 가이드 추가
- 왜: "200 + id_token 수신 후 라우팅 없음" 현상 재현 시 신속한 원인 파악을 위해
- 어떻게:
- 코드: Login.vue에 env 누락 및 오리진 불일치 콘솔 경고 추가
- 문서: 본 섹션 추가 및 기대 응답 스키마/샘플 기재
- 결과: 로컬/테스트 환경에서 콘솔 경고로 오리진 불일치 즉시 인지 가능. 서버 응답 스키마 불일치 시 프론트 라우팅 미발생 원인 파악 용이
## 검증 기록
### 1차 구현
- 무엇을: 애플 로그인 버튼/로직 추가, 스토어/API 경로 연동
- 왜: 기존 Google/Kakao 외에 Apple 로그인 제공을 위해
- 어떻게:
- 앱 실행: `npm run serve` (또는 프로젝트 표준 실행 명령)
- 로그인 페이지 접속 후 버튼 렌더링 확인
- 개발자 도구에서 `window.AppleID` 존재 확인 및 `AppleID.auth.init` 에러 여부 확인(콘솔)
- 버튼 클릭 시 팝업 표시 확인, 성공 후 `accountStore/LOGIN_APPLE` 디스패치 호출 여부 확인(네트워크 탭: `POST /member/login/apple` 요청 확인)
- 요청 본문에 `container=web`, `identityToken`, `nonce` 포함 여부 확인
- 결과: 로컬 환경에서 토큰 교환 서버 구현/설정 의존. 서버가 준비되지 않은 경우 요청은 4xx/5xx 응답 가능(예상됨)
### 3차 수정(로컬 테스트 한계 및 테스트 서버 권장)
- 무엇을: 로컬(localhost)에서 팝업 인증 성공 후 흐름 중단 현상 분석 및 해결 방안(테스트 서버 배포) 추가
- 왜: 사용자가 로컬 환경에서 id_token 수신 후 동작하지 않는 현상을 보고함에 따라 가이드 보강
- 어떻게:
- 원인: redirectURI 오리진 불일치(localhost vs test-domain)로 인한 postMessage 핸드셰이크 실패 및 CORS 제약 가능성 명시
- 조치: Apple Return URLs에 등록된 도메인으로 앱을 실제 배포하여 동일 오리진 환경에서 검증할 것을 권장함
- 결과: 테스트 서버 배포 시 오리진 불일치 문제가 해결되어 정상적인 Promise resolve 및 서버 전송이 가능해짐
### 4차 수정(nonce 불일치 이슈 정합화)
- 무엇을: Web의 nonce 생성/해시 로직을 iOS 앱에서 사용하는 규칙과 동일하게 수정
- 왜: Apple 로그인 시 서버 검증 단계에서 `invalid nonce`가 발생하는 문제를 해소하기 위해
- 어떻게:
- 코드: `src/views/Login/Login.vue`
- `generateNonce`: iOS와 동일한 문자셋(`0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._`) + 16바이트 샘플링 방식으로 raw nonce 생성
- `sha256Hex`: SHA-256 결과를 hex가 아닌 Base64URL(`+``-`, `/``_`, `=` 제거)로 인코딩하도록 변경
- 해시 기능 미지원 브라우저에서는 예외를 발생시켜 잘못된 nonce 전송을 방지
- 검증 명령:
- `npm run lint` → 성공
- 결과: Apple SDK에 전달되는 nonce 포맷이 iOS와 동일해져 서버 nonce 검증 정합성 개선
## 정정/메모
- 초기 계획 문서는 구현 직후 정리되었습니다. 향후 환경변수/서버 설정 완료 시 체크박스 및 검증 기록을 추가 업데이트하세요.

11
package-lock.json generated
View File

@@ -9,7 +9,6 @@
"version": "0.1.0",
"dependencies": {
"core-js": "^3.6.5",
"cropperjs": "^1.6.2",
"vue": "^2.6.11",
"vue-router": "^3.2.0",
"vue-show-more-text": "^2.0.2",
@@ -4840,11 +4839,6 @@
"sha.js": "^2.4.8"
}
},
"node_modules/cropperjs": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA=="
},
"node_modules/cross-spawn": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
@@ -19528,11 +19522,6 @@
"sha.js": "^2.4.8"
}
},
"cropperjs": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA=="
},
"cross-spawn": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",

View File

@@ -11,7 +11,6 @@
},
"dependencies": {
"core-js": "^3.6.5",
"cropperjs": "^1.6.2",
"vue": "^2.6.11",
"vue-router": "^3.2.0",
"vue-show-more-text": "^2.0.2",

View File

@@ -8,9 +8,6 @@
<title>보이스온 크리에이터 관리자</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css">
<script src="https://accounts.google.com/gsi/client" async defer></script>
<script src="https://developers.kakao.com/sdk/js/kakao.min.js"></script>
<script src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"></script>
</head>
<body>
<noscript>

View File

@@ -29,18 +29,10 @@ async function getCalculateCommunityPost(startDate, endDate, page, size) {
);
}
async function getCalculateChannelDonation(startDate, endDate, page, size) {
return Vue.axios.get(
'/creator-admin/calculate/channel-donation?startDateStr=' +
startDate + '&endDateStr=' + endDate + "&page=" + (page - 1) + "&size=" + size
);
}
export {
getCalculateLive,
getCalculateContent,
getCumulativeSalesByContent,
getCalculateContentDonation,
getCalculateCommunityPost,
getCalculateChannelDonation
getCalculateCommunityPost
}

View File

@@ -8,39 +8,4 @@ async function logout() {
return Vue.axios.post('/creator-admin/member/logout');
}
async function loginGoogle(idToken) {
return Vue.axios.post(
"/member/login/google",
{ container: "api" },
{
headers: {
Authorization: `Bearer ${idToken}`,
},
}
);
}
async function loginKakao(accessToken) {
return Vue.axios.post(
"/member/login/kakao",
{ container: "api" },
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
}
async function loginApple({ identityToken, nonce }) {
// 서버 스키마에 맞춰 본문으로 전달
const body = {
container: "web",
identityToken,
nonce
};
return Vue.axios.post("/member/login/apple", body);
}
export { login, logout, loginGoogle, loginKakao, loginApple }
export { login, logout }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -60,11 +60,6 @@ const routes = [
name: 'CalculateCommunityPost',
component: () => import(/* webpackChunkName: "calculate" */ '../views/Calculate/CalculateCommunityPost.vue')
},
{
path: '/calculate/channel-donation',
name: 'CalculateChannelDonation',
component: () => import(/* webpackChunkName: "calculate" */ '../views/Calculate/CalculateChannelDonation.vue')
},
{
path: '/signature',
name: 'SignatureManagement',

View File

@@ -87,81 +87,6 @@ const accountStore = {
});
},
async LOGIN_GOOGLE({commit}, {idToken}) {
let result = false
let errorMessage = null
try {
let res = await memberApi.loginGoogle(idToken)
if (res.data.success === true) {
commit("LOGIN", res.data.data)
result = true
} else {
errorMessage = res.data.message
}
} catch (e) {
errorMessage = '구글 로그인 정보를 확인해주세요.'
}
return new Promise((resolve, reject) => {
if (result) {
resolve();
} else {
reject(errorMessage)
}
});
},
async LOGIN_KAKAO({commit}, {accessToken}) {
let result = false
let errorMessage = null
try {
let res = await memberApi.loginKakao(accessToken)
if (res.data.success === true) {
commit("LOGIN", res.data.data)
result = true
} else {
errorMessage = res.data.message
}
} catch (e) {
errorMessage = '카카오 로그인 정보를 확인해주세요.'
}
return new Promise((resolve, reject) => {
if (result) {
resolve();
} else {
reject(errorMessage)
}
});
},
async LOGIN_APPLE({commit}, payload) {
let result = false
let errorMessage = null
try {
let res = await memberApi.loginApple(payload)
if (res.data.success === true) {
commit("LOGIN", res.data.data)
result = true
} else {
errorMessage = res.data.message
}
} catch (e) {
errorMessage = '애플 로그인 정보를 확인해주세요.'
}
return new Promise((resolve, reject) => {
if (result) {
resolve();
} else {
reject(errorMessage)
}
});
},
async LOGOUT({commit}) {
let result = false
let errorMessage = null

View File

@@ -1,281 +0,0 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>채널 후원 정산</v-toolbar-title>
<v-spacer />
</v-toolbar>
<br>
<v-container>
<v-row>
<v-col cols="2">
<datetime
v-model="start_date"
class="datepicker"
format="YYYY-MM-DD"
/>
</v-col>
<v-col cols="1">
~
</v-col>
<v-col cols="2">
<datetime
v-model="end_date"
class="datepicker"
format="YYYY-MM-DD"
/>
</v-col>
<v-col cols="1" />
<v-col cols="2">
<v-btn
block
color="#3bb9f1"
dark
depressed
@click="getCalculateChannelDonation"
>
조회
</v-btn>
</v-col>
<v-spacer />
</v-row>
<v-row>
<v-col>
<v-data-table
:headers="headers"
:items="items"
:loading="is_loading"
:items-per-page="-1"
class="elevation-1"
hide-default-footer
>
<template slot="body.prepend">
<tr v-if="total">
<td colspan="2">
합계
</td>
<td class="text-center">
{{ total.count.toLocaleString() }}
</td>
<td class="text-center">
{{ total.totalCan.toLocaleString() }}
</td>
<td class="text-center">
{{ total.krw.toLocaleString() }}
</td>
<td class="text-center">
{{ total.fee.toLocaleString() }}
</td>
<td class="text-center">
{{ total.settlementAmount.toLocaleString() }}
</td>
<td class="text-center">
{{ total.withholdingTax.toLocaleString() }}
</td>
<td class="text-center">
{{ total.depositAmount.toLocaleString() }}
</td>
</tr>
</template>
<template v-slot:item.count="{ item }">
{{ item.count.toLocaleString() }}
</template>
<template v-slot:item.totalCan="{ item }">
{{ item.totalCan.toLocaleString() }}
</template>
<template v-slot:item.krw="{ item }">
{{ item.krw.toLocaleString() }}
</template>
<template v-slot:item.fee="{ item }">
{{ item.fee.toLocaleString() }}
</template>
<template v-slot:item.settlementAmount="{ item }">
{{ item.settlementAmount.toLocaleString() }}
</template>
<template v-slot:item.withholdingTax="{ item }">
{{ item.withholdingTax.toLocaleString() }}
</template>
<template v-slot:item.depositAmount="{ item }">
{{ item.depositAmount.toLocaleString() }}
</template>
</v-data-table>
</v-col>
</v-row>
<v-row class="text-center">
<v-col>
<v-pagination
v-model="page"
:length="total_page"
circle
@input="next"
/>
</v-col>
</v-row>
</v-container>
</div>
</template>
<script>
import * as api from "@/api/calculate";
import datetime from "vuejs-datetimepicker";
export default {
name: "CalculateChannelDonation",
components: {datetime},
data() {
return {
is_loading: false,
start_date: null,
end_date: null,
page: 1,
page_size: 20,
total_page: 0,
items: [],
total: null,
headers: [
{
text: '날짜',
align: 'center',
sortable: false,
value: 'date',
},
{
text: '크리에이터',
align: 'center',
sortable: false,
value: 'creator',
},
{
text: '건수',
align: 'center',
sortable: false,
value: 'count',
},
{
text: '캔',
align: 'center',
sortable: false,
value: 'totalCan',
},
{
text: '원화',
align: 'center',
sortable: false,
value: 'krw',
},
{
text: '수수료\n(6.6%)',
align: 'center',
sortable: false,
value: 'fee',
},
{
text: '정산금액',
align: 'center',
sortable: false,
value: 'settlementAmount',
},
{
text: '원천세\n(3.3%)',
align: 'center',
sortable: false,
value: 'withholdingTax',
},
{
text: '입금액',
align: 'center',
sortable: false,
value: 'depositAmount',
},
],
}
},
async created() {
const date = new Date();
const firstDate = new Date(date.getFullYear(), date.getMonth(), 1);
const lastDate = new Date(date.getFullYear(), date.getMonth() + 1, 0);
let firstDateMonth = (firstDate.getMonth() + 1).toString()
if (firstDateMonth.length < 2) {
firstDateMonth = '0' + firstDateMonth
}
let lastDateMonth = (lastDate.getMonth() + 1).toString()
if (lastDateMonth.length < 2) {
lastDateMonth = '0' + lastDateMonth
}
let firstDateDay = firstDate.getDate().toString()
if (firstDateDay.length < 2) {
firstDateDay = '0' + firstDateDay
}
let lastDateDay = lastDate.getDate().toString()
if (lastDateDay.length < 2) {
lastDateDay = '0' + lastDateDay
}
this.start_date = firstDate.getFullYear() + '-' + firstDateMonth + '-' + firstDateDay
this.end_date = lastDate.getFullYear() + '-' + lastDateMonth + '-' + lastDateDay
await this.getCalculateChannelDonation()
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
async next() {
await this.getCalculateChannelDonation()
},
async getCalculateChannelDonation() {
this.is_loading = true
try {
const res = await api.getCalculateChannelDonation(this.start_date, this.end_date, this.page, this.page_size)
if (res.status === 200 && res.data.success === true) {
this.items = res.data.data.items
this.total = res.data.data.total
this.total_page = Math.ceil(res.data.data.totalCount / this.page_size)
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
}
}
}
</script>
<style scoped>
.datepicker {
width: 100%;
}
</style>

View File

@@ -14,7 +14,7 @@
<v-col>
<v-btn
block
color="#3bb9f1"
color="#9970ff"
dark
depressed
@click="showWriteDialog"
@@ -227,20 +227,6 @@
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row align="center">
<v-col cols="4">
태그
</v-col>
<v-col cols="8">
<v-text-field
v-model="audio_content.tags"
label="예 : #연애 #커버곡 #태그"
required
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row align="center">
<v-col cols="4">
@@ -287,22 +273,6 @@
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row>
<v-col cols="4">
포인트 사용 가능
</v-col>
<v-col
cols="8"
align="left"
>
<input
v-model="audio_content.is_point_available"
type="checkbox"
>
</v-col>
</v-row>
</v-card-text>
<v-card-actions v-show="!is_loading">
<v-spacer />
<v-btn
@@ -645,22 +615,6 @@
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row>
<v-col cols="4">
포인트 사용 가능
</v-col>
<v-col
cols="8"
align="left"
>
<input
v-model="audio_content.is_point_available"
type="checkbox"
>
</v-col>
</v-row>
</v-card-text>
<v-card-actions v-show="!is_loading">
<v-spacer />
<v-btn
@@ -711,45 +665,6 @@
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog
v-model="show_cropper_dialog"
max-width="800px"
persistent
>
<v-card>
<v-card-title>
이미지 크롭
</v-card-title>
<v-card-text>
<div class="cropper-wrapper">
<img
ref="cropper_image"
:src="cropper_image_url"
alt="Cropper Image"
style="max-width: 100%;"
>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="blue darken-1"
text
@click="cancelCropper"
>
취소
</v-btn>
<v-btn
color="blue darken-1"
text
@click="cropImage"
>
확인
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
@@ -759,12 +674,10 @@ import VuetifyAudio from 'vuetify-audio'
// Main JS (in UMD format)
import VueTimepicker from 'vue2-timepicker'
import DatePicker from 'vue2-datepicker';
import Cropper from 'cropperjs';
import 'vue2-datepicker/index.css';
// CSS
import 'vue2-timepicker/dist/VueTimepicker.css'
import 'cropperjs/dist/cropper.css';
export default {
name: "AudioContentList",
@@ -781,16 +694,12 @@ export default {
show_create_dialog: false,
show_modify_dialog: false,
show_delete_confirm_dialog: false,
show_cropper_dialog: false,
cropper_image_url: '',
cropper: null,
page: 1,
total_page: 0,
search_word: '',
audio_content: {
price: 0,
is_adult: false,
is_point_available: false,
is_generate_preview: false,
is_comment_available: true,
is_full_detail_visible: true,
@@ -839,10 +748,8 @@ export default {
this.audio_content.detail = item.detail
this.audio_content.price = item.price
this.audio_content.is_adult = item.isAdult
this.audio_content.is_point_available = item.isPointAvailable
this.audio_content.is_comment_available = item.isCommentAvailable
this.audio_content.cover_image_url = item.coverImageUrl
this.audio_content.tags = item.tags
this.show_modify_dialog = true
},
@@ -851,7 +758,6 @@ export default {
this.audio_content = {
price: 0,
is_adult: false,
is_point_available: false,
is_comment_available: true,
is_full_detail_visible: true,
purchase_option: 'BOTH',
@@ -878,63 +784,13 @@ export default {
imageAdd(payload) {
const file = payload;
if (file) {
// 이미 크롭 처리된 파일인 경우 다시 다이얼로그를 띄우지 않음
if (file._isCropped) return;
this.cropper_image_url = URL.createObjectURL(file)
this.show_cropper_dialog = true
this.$nextTick(() => {
if (this.cropper) {
this.cropper.destroy()
}
this.cropper = new Cropper(this.$refs.cropper_image, {
aspectRatio: 1,
viewMode: 1,
})
})
this.audio_content.cover_image_url = URL.createObjectURL(file)
URL.revokeObjectURL(file)
} else {
this.audio_content.cover_image_url = null
this.audio_content.cover_image = null
}
},
cancelCropper() {
this.show_cropper_dialog = false
if (this.cropper) {
this.cropper.destroy()
this.cropper = null
}
this.cropper_image_url = ''
this.audio_content.cover_image = null
},
cropImage() {
const canvas = this.cropper.getCroppedCanvas()
let finalCanvas = canvas
const MAX_SIZE = 800
if (canvas.width > MAX_SIZE || canvas.height > MAX_SIZE) {
const resizeCanvas = document.createElement('canvas')
resizeCanvas.width = MAX_SIZE
resizeCanvas.height = MAX_SIZE
const ctx = resizeCanvas.getContext('2d')
ctx.drawImage(canvas, 0, 0, MAX_SIZE, MAX_SIZE)
finalCanvas = resizeCanvas
}
finalCanvas.toBlob((blob) => {
const file = new File([blob], 'cover_image.png', { type: 'image/png' })
file._isCropped = true // 크롭된 파일임을 표시하여 재진입 방지
this.audio_content.cover_image = file
this.audio_content.cover_image_url = URL.createObjectURL(blob)
this.show_cropper_dialog = false
if (this.cropper) {
this.cropper.destroy()
this.cropper = null
}
}, 'image/png')
},
async getAudioContentThemeList() {
this.is_loading = true
try {
@@ -1013,7 +869,6 @@ export default {
isAdult: this.audio_content.is_adult,
isGeneratePreview: this.audio_content.price > 0 ? this.audio_content.is_generate_preview : false,
purchaseOption: this.audio_content.purchase_option,
isPointAvailable: this.audio_content.is_point_available,
isCommentAvailable: this.audio_content.is_comment_available,
isFullDetailVisible: this.audio_content.is_full_detail_visible
}
@@ -1155,10 +1010,6 @@ export default {
request.isAdult = this.audio_content.is_adult
}
if (this.selected_audio_content.isPointAvailable !== this.audio_content.is_point_available) {
request.isPointAvailable = this.audio_content.is_point_available
}
if (this.selected_audio_content.isCommentAvailable !== this.audio_content.is_comment_available) {
request.isCommentAvailable = this.audio_content.is_comment_available
}
@@ -1167,10 +1018,6 @@ export default {
request.price = this.audio_content.price
}
if (this.audio_content.tags !== this.selected_audio_content.tags) {
request.tags = this.audio_content.tags
}
if (this.audio_content.cover_image !== null) {
formData.append("coverImage", this.audio_content.cover_image)
}
@@ -1329,9 +1176,4 @@ export default {
object-fit: cover;
margin-top: 10px;
}
.cropper-wrapper {
max-height: 500px;
overflow: hidden;
}
</style>

View File

@@ -337,43 +337,6 @@
</v-card>
</v-dialog>
<v-dialog
v-model="show_cropper_dialog"
max-width="800px"
persistent
>
<v-card>
<v-card-title>이미지 크롭 (A4 비율)</v-card-title>
<v-card-text>
<div class="cropper-wrapper">
<img
ref="cropper_image"
:src="cropper_image_url"
alt="Cropper Image"
class="cropper-image"
>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="grey darken-1"
text
@click="cancelCropper"
>
취소
</v-btn>
<v-btn
color="blue darken-1"
text
@click="cropImage"
>
크롭 완료
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog
v-model="is_loading"
max-width="400px"
@@ -393,8 +356,6 @@
<script>
import * as api from '@/api/audio_content_series';
import Draggable from "vuedraggable";
import Cropper from 'cropperjs';
import 'cropperjs/dist/cropper.css';
export default {
name: "ContentSeriesList",
@@ -408,9 +369,6 @@ export default {
series_list: [],
selected_series: null,
series_genre_list: [],
show_cropper_dialog: false,
cropper_image_url: '',
cropper: null,
page: 1,
total_page: 0,
days_of_week: [
@@ -442,63 +400,13 @@ export default {
imageAdd(payload) {
const file = payload;
if (file) {
if (file._isCropped) return;
this.cropper_image_url = URL.createObjectURL(file)
this.show_cropper_dialog = true
this.$nextTick(() => {
if (this.cropper) {
this.cropper.destroy()
}
this.cropper = new Cropper(this.$refs.cropper_image, {
aspectRatio: 210 / 297,
viewMode: 1,
})
})
this.series.cover_image_url = URL.createObjectURL(file)
URL.revokeObjectURL(file)
} else {
this.series.cover_image_url = null
this.series.cover_image = null
}
},
cancelCropper() {
this.show_cropper_dialog = false
if (this.cropper) {
this.cropper.destroy()
this.cropper = null
}
this.cropper_image_url = ''
this.series.cover_image = null
},
cropImage() {
const canvas = this.cropper.getCroppedCanvas()
let finalCanvas = canvas
const MAX_WIDTH = 1000
if (canvas.width > MAX_WIDTH) {
const height = MAX_WIDTH * (297 / 210)
const resizeCanvas = document.createElement('canvas')
resizeCanvas.width = MAX_WIDTH
resizeCanvas.height = height
const ctx = resizeCanvas.getContext('2d')
ctx.drawImage(canvas, 0, 0, MAX_WIDTH, height)
finalCanvas = resizeCanvas
}
finalCanvas.toBlob((blob) => {
const file = new File([blob], 'cover_image.png', {type: 'image/png'})
file._isCropped = true
this.series.cover_image = file
this.series.cover_image_url = URL.createObjectURL(blob)
this.show_cropper_dialog = false
if (this.cropper) {
this.cropper.destroy()
this.cropper = null
}
}, 'image/png')
},
notifyError(message) {
this.$dialog.notify.error(message)
},
@@ -834,16 +742,6 @@ export default {
}
.cover-image {
aspect-ratio: 210/297;
}
.cropper-wrapper {
max-height: 70vh;
width: 100%;
}
.cropper-image {
display: block;
max-width: 100%;
aspect-ratio: 1/1.3;
}
</style>

View File

@@ -1,16 +1,9 @@
<template>
<v-app>
<v-main>
<v-container fluid>
<v-row
align="start"
justify="center"
style="padding-top: 10vh;"
>
<v-col
cols="12"
sm="8"
md="4"
<v-container
align-center
justify-center
>
<v-card class="elevation-12">
<v-card-text>
@@ -39,55 +32,13 @@
로그인
</v-btn>
</v-card-actions>
<v-divider />
<v-card-text class="text-center">
<div
style="display: flex; flex-direction: column; align-items: center; gap: 10px;"
>
<div
id="google-login-btn"
style="width: 192px; height: 45px; display: flex; align-items: center; justify-content: center;"
/>
<v-btn
width="192"
height="45"
class="pa-0"
elevation="0"
color="black"
dark
@click="loginApple"
>
<v-icon left>
mdi-apple
</v-icon>
Apple로 로그인
</v-btn>
<v-btn
width="192"
height="45"
class="pa-0"
elevation="0"
color="transparent"
@click="loginKakao"
>
<img
src="@/assets/kakao_login.png"
alt="카카오 로그인 버튼"
style="width: 100%; height: 100%; display: block;"
>
</v-btn>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</v-main>
</v-app>
</template>
<script>
/* global Kakao, AppleID */
export default {
name: "Login",
@@ -97,256 +48,7 @@ export default {
password: '',
}),
mounted() {
this.initGoogleLogin();
this.initKakaoLogin();
this.initAppleLogin();
},
methods: {
// iOS randomNonceString과 동일한 규칙으로 raw nonce 생성
generateNonce(length = 32) {
if (length <= 0) {
throw new Error('Nonce length must be greater than zero.');
}
const crypto = window.crypto || window.msCrypto;
const charset = '0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._';
let result = '';
let remainingLength = length;
while (remainingLength > 0) {
const randoms = new Uint8Array(16);
try {
crypto.getRandomValues(randoms);
} catch (e) {
// iOS 코드와 동일하게 난수 생성 실패 시 UUID 폴백
if (crypto && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11)
.replace(/[018]/g, c => (
c ^ ((Math.random() * 16) >> (c / 4))
).toString(16));
}
for (let i = 0; i < randoms.length && remainingLength > 0; i++) {
if (randoms[i] < charset.length) {
result += charset[randoms[i]];
remainingLength -= 1;
}
}
}
return result;
},
async sha256Hex(message) {
if (!(window.crypto && window.crypto.subtle && typeof TextEncoder !== 'undefined')) {
throw new Error('SHA-256 해시를 지원하지 않는 환경입니다.');
}
const enc = new TextEncoder().encode(message);
const buf = await window.crypto.subtle.digest('SHA-256', enc);
const arr = new Uint8Array(buf);
let binary = '';
for (let i = 0; i < arr.length; i++) {
binary += String.fromCharCode(arr[i]);
}
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
},
initAppleLogin() {
if (typeof AppleID !== 'undefined' && AppleID.auth) {
try {
const clientId = process.env.VUE_APP_APPLE_CLIENT_ID;
const redirectURI = process.env.VUE_APP_APPLE_REDIRECT_URI;
if (!clientId || !redirectURI) {
console.warn('[Apple Sign-In] 환경변수 누락: VUE_APP_APPLE_CLIENT_ID 혹은 VUE_APP_APPLE_REDIRECT_URI');
}
try {
const redirectOrigin = new URL(redirectURI).origin;
if (redirectOrigin !== window.location.origin) {
console.warn('[Apple Sign-In] redirectURI 오리진이 현재 앱과 다릅니다.', {
redirectURI,
redirectOrigin,
appOrigin: window.location.origin,
});
}
} catch (e) {
console.warn('[Apple Sign-In] redirectURI 파싱 실패:', redirectURI);
}
AppleID.auth.init({
clientId,
scope: 'email',
redirectURI,
state: window.location.origin,
usePopup: true,
response_type: 'code id_token',
response_mode: 'fragment',
});
} catch (e) {
console.error('AppleID init error', e);
}
} else {
setTimeout(() => {
this.initAppleLogin();
}, 500);
}
},
loginApple() {
if (typeof AppleID === 'undefined' || !AppleID.auth) {
this.initAppleLogin();
this.notifyError('애플 SDK를 불러오는 중입니다. 잠시 후 다시 시도해주세요.');
return;
}
(async () => {
try {
// 1) 로그인 시점에 nonce 생성 및 해시
const rawNonce = this.generateNonce(32);
const hashedNonceHex = await this.sha256Hex(rawNonce);
// 2) nonce 반영 위해 재초기화(AppleID가 마지막 init 값을 사용)
const clientId = process.env.VUE_APP_APPLE_CLIENT_ID;
const redirectURI = process.env.VUE_APP_APPLE_REDIRECT_URI;
if (!clientId || !redirectURI) {
console.warn('[Apple Sign-In] 환경변수 누락: VUE_APP_APPLE_CLIENT_ID 혹은 VUE_APP_APPLE_REDIRECT_URI');
}
try {
const redirectOrigin = new URL(redirectURI).origin;
if (redirectOrigin !== window.location.origin) {
console.warn('[Apple Sign-In] redirectURI 오리진이 현재 앱과 다릅니다.', {
redirectURI,
redirectOrigin,
appOrigin: window.location.origin,
});
}
} catch (e) {
console.warn('[Apple Sign-In] redirectURI 파싱 실패:', redirectURI);
}
AppleID.auth.init({
clientId,
scope: 'email',
redirectURI,
state: window.location.origin,
usePopup: true,
response_type: 'code id_token',
response_mode: 'fragment',
nonce: hashedNonceHex,
});
// 3) 팝업 로그인
const res = await AppleID.auth.signIn();
const idToken = res && res.authorization && res.authorization.id_token;
if (!idToken) {
this.notifyError('애플 로그인 응답이 올바르지 않습니다.');
return;
}
// 4) 서버 스키마에 맞춰 전달
const payload = { identityToken: idToken, nonce: rawNonce };
this.$store.dispatch('accountStore/LOGIN_APPLE', payload)
.then(() => {
this.$router.push(this.$route.query.redirect || '/')
})
.catch((message) => {
this.notifyError(message);
})
} catch (err) {
this.notifyError('애플 로그인에 실패했습니다.');
console.error(err);
}
})();
},
initKakaoLogin() {
if (typeof Kakao !== 'undefined') {
if (!Kakao.isInitialized()) {
Kakao.init(process.env.VUE_APP_KAKAO_JS_KEY);
}
} else {
setTimeout(() => {
this.initKakaoLogin();
}, 500);
}
},
loginKakao() {
if (typeof Kakao !== 'undefined') {
if (!Kakao.isInitialized()) {
this.initKakaoLogin();
this.notifyError('카카오 SDK가 초기화되지 않았습니다. 잠시 후 다시 시도해주세요.');
return;
}
if (Kakao.Auth && typeof Kakao.Auth.login === 'function') {
Kakao.Auth.login({
success: (authObj) => {
this.$store.dispatch('accountStore/LOGIN_KAKAO', { accessToken: authObj.access_token })
.then(() => {
this.$router.push(this.$route.query.redirect || '/')
})
.catch((message) => {
this.notifyError(message);
})
},
fail: (err) => {
this.notifyError('카카오 로그인에 실패했습니다.');
console.error(err);
},
});
} else {
this.notifyError('카카오 인증 모듈을 불러오지 못했습니다. 페이지를 새로고침 해주세요.');
}
} else {
this.initKakaoLogin();
this.notifyError('카카오 SDK를 불러오는 중입니다. 잠시 후 다시 시도해주세요.');
}
},
initGoogleLogin() {
/* global google */
if (typeof google !== 'undefined') {
google.accounts.id.initialize({
client_id: process.env.VUE_APP_GOOGLE_CLIENT_ID,
callback: this.handleCredentialResponse
});
google.accounts.id.renderButton(
document.getElementById("google-login-btn"),
{ theme: "outline", size: "large", width: 192 }
);
} else {
setTimeout(() => {
this.initGoogleLogin();
}, 500);
}
},
handleCredentialResponse(response) {
const idToken = response.credential;
this.$store.dispatch('accountStore/LOGIN_GOOGLE', { idToken })
.then(() => {
this.$router.push(this.$route.query.redirect || '/')
})
.catch((message) => {
this.notifyError(message);
})
},
notifyError: async function (message) {
await this.$dialog.notify.error(message)
},

View File

@@ -369,50 +369,11 @@
</v-card>
</v-dialog>
</v-row>
<v-dialog
v-model="show_cropper_dialog"
max-width="800px"
persistent
>
<v-card>
<v-card-title>이미지 크롭</v-card-title>
<v-card-text>
<div class="cropper-wrapper">
<img
ref="cropper_image"
:src="cropper_image_url"
alt="Cropper Image"
class="cropper-image"
>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="grey darken-1"
text
@click="cancelCropper"
>
취소
</v-btn>
<v-btn
color="blue darken-1"
text
@click="cropImage"
>
크롭 완료
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import * as api from "@/api/signature";
import Cropper from 'cropperjs';
import 'cropperjs/dist/cropper.css';
export default {
name: "SignatureManagement",
@@ -438,10 +399,6 @@ export default {
selected_signature_can: {},
sort_type: 'NEWEST',
show_cropper_dialog: false,
cropper_image_url: '',
cropper: null,
headers: [
{
text: '캔',
@@ -493,69 +450,13 @@ export default {
imageAdd(payload) {
const file = payload;
if (file) {
if (file._isCropped) return;
if (file.type === 'image/gif') {
this.image = file
this.image_url = URL.createObjectURL(file)
return
}
this.cropper_image_url = URL.createObjectURL(file)
this.show_cropper_dialog = true
this.$nextTick(() => {
if (this.cropper) {
this.cropper.destroy()
}
this.cropper = new Cropper(this.$refs.cropper_image, {
viewMode: 1,
})
})
URL.revokeObjectURL(file)
} else {
this.image_url = null
this.image = null
}
},
cancelCropper() {
this.show_cropper_dialog = false
if (this.cropper) {
this.cropper.destroy()
this.cropper = null
}
this.cropper_image_url = ''
this.image = null
this.image_url = null
},
cropImage() {
const canvas = this.cropper.getCroppedCanvas()
let finalCanvas = canvas
const MAX_WIDTH = 800
if (canvas.width > MAX_WIDTH) {
const height = (MAX_WIDTH * canvas.height) / canvas.width
const resizeCanvas = document.createElement('canvas')
resizeCanvas.width = MAX_WIDTH
resizeCanvas.height = height
const ctx = resizeCanvas.getContext('2d')
ctx.drawImage(canvas, 0, 0, MAX_WIDTH, height)
finalCanvas = resizeCanvas
}
finalCanvas.toBlob((blob) => {
const file = new File([blob], 'signature_image.png', {type: 'image/png'})
file._isCropped = true
this.image = file
this.image_url = URL.createObjectURL(blob)
this.show_cropper_dialog = false
if (this.cropper) {
this.cropper.destroy()
this.cropper = null
}
}, 'image/png')
},
showWriteDialog() {
this.show_write_dialog = true
},
@@ -812,14 +713,4 @@ export default {
font-weight: bold;
color: #3bb9f1;
}
.cropper-wrapper {
max-height: 500px;
width: 100%;
}
.cropper-image {
display: block;
max-width: 100%;
}
</style>

View File

@@ -1,121 +0,0 @@
#!/usr/bin/env bash
print_usage() {
echo "Usage:"
echo " $0"
echo " $0 <commit-hash>"
echo " $0 --message \"<commit-message>\""
echo " $0 --message-file <file-path>"
}
load_commit_message() {
if [ $# -eq 0 ]; then
local commit_ref="HEAD"
echo "Checking latest commit..." >&2
git log -1 --pretty=format:"%s%n%b" "$commit_ref"
return
fi
case "$1" in
-h|--help)
print_usage
exit 0
;;
--message)
shift
if [ $# -eq 0 ]; then
echo "[FAIL] --message option requires a commit message"
print_usage
exit 1
fi
echo "Checking provided commit message..." >&2
printf '%s' "$*"
;;
--message-file)
shift
if [ $# -ne 1 ]; then
echo "[FAIL] --message-file option requires a file path"
print_usage
exit 1
fi
if [ ! -f "$1" ]; then
echo "[FAIL] Commit message file not found: $1"
exit 1
fi
echo "Checking commit message file: $1" >&2
cat "$1"
;;
*)
if [ $# -ne 1 ]; then
echo "[FAIL] Invalid arguments"
print_usage
exit 1
fi
local commit_ref="$1"
if ! git rev-parse --verify "$commit_ref^{commit}" >/dev/null 2>&1; then
echo "[FAIL] Invalid commit reference: $commit_ref"
exit 1
fi
echo "Checking commit: $commit_ref" >&2
git log -1 --pretty=format:"%s%n%b" "$commit_ref"
;;
esac
}
commit_message=$(load_commit_message "$@")
subject=$(printf '%s\n' "$commit_message" | head -n1)
body=$(printf '%s\n' "$commit_message" | tail -n +2)
echo "Checking commit message format..."
echo "Subject: $subject"
exit_code=0
if [ -z "$subject" ]; then
echo "[FAIL] Subject must not be empty"
exit 1
fi
subject_pattern='^([a-z]+)(\([a-z0-9._/-]+\))?(!)?: (.+)$'
if [[ "$subject" =~ $subject_pattern ]]; then
type="${BASH_REMATCH[1]}"
description="${BASH_REMATCH[4]}"
echo "[PASS] Subject follows Conventional Commit format"
echo "[PASS] Type is lowercase: $type"
if printf '%s\n' "$description" | grep -Eq '[가-힣]'; then
echo "[PASS] Description contains Korean text"
else
echo "[FAIL] Description must contain Korean text"
exit_code=1
fi
else
echo "[FAIL] Subject must match: <type>(scope): <description>"
echo " scope is optional, example: feat: 기능을 추가한다"
exit_code=1
fi
if [ -n "$body" ] && printf '%s\n' "$body" | grep -Eq '^Refs:'; then
while IFS= read -r refs_line; do
if ! printf '%s\n' "$refs_line" | grep -Eq '^Refs: #[0-9]+(, #[0-9]+)*$'; then
echo "[FAIL] Refs footer format is invalid: $refs_line"
echo " expected format: Refs: #123 or Refs: #123, #456"
exit_code=1
fi
done < <(printf '%s\n' "$body" | grep -E '^Refs:')
if [ $exit_code -eq 0 ]; then
echo "[PASS] Refs footer format is valid"
fi
fi
if [ $exit_code -eq 0 ]; then
echo "[PASS] Commit message follows AGENTS.md rules"
else
echo "[FAIL] Commit message violates AGENTS.md rules"
fi
exit $exit_code