Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6fc164c41 | ||
|
|
4fbfbf92a6 | ||
|
|
50796ac743 | ||
|
|
9c3f616f37 | ||
|
|
0dd0c8595f | ||
|
|
6d86aaa1be |
9
.gitignore
vendored
9
.gitignore
vendored
@ -32,11 +32,8 @@ media/
|
|||||||
|
|
||||||
|
|
||||||
*.ipynb_checkpoint*
|
*.ipynb_checkpoint*
|
||||||
# Static files (공유 기본 이미지는 예외로 추적)
|
# Static files
|
||||||
static/*
|
static/
|
||||||
!static/images/
|
|
||||||
static/images/*
|
|
||||||
!static/images/ado2_image.png
|
|
||||||
|
|
||||||
# Log files
|
# Log files
|
||||||
*.log
|
*.log
|
||||||
@ -55,5 +52,3 @@ Dockerfile
|
|||||||
.dockerignore
|
.dockerignore
|
||||||
|
|
||||||
zzz/
|
zzz/
|
||||||
credentials/service_account.json
|
|
||||||
o2o-castad-scheduler/
|
|
||||||
65
README.md
65
README.md
@ -69,9 +69,6 @@ PROJECT_DOMAIN=localhost:8000 # 프로젝트 도메인 (호스트:포
|
|||||||
PROJECT_VERSION=0.1.0 # 프로젝트 버전
|
PROJECT_VERSION=0.1.0 # 프로젝트 버전
|
||||||
DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명
|
DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명
|
||||||
ADMIN_BASE_URL=/admin # 관리자 페이지 기본 URL
|
ADMIN_BASE_URL=/admin # 관리자 페이지 기본 URL
|
||||||
SHARE_FRONTEND_URL=https://ado2.o2osolution.ai # 공유 페이지 → 영상 상세 이동 프론트 URL (로컬: http://localhost:3000, 테스트: https://dev.castad.net)
|
|
||||||
SHARE_API_BASE_URL= # 공유 OG 페이지의 외부 공개 API URL (예: https://dev-ssul.castad.net/api). 프록시가 /api 를 떼면 필수
|
|
||||||
SHARE_DEFAULT_IMAGE_URL= # 포스터 없을 때 OG 이미지 (비우면 API /static/images/ado2_image.png)
|
|
||||||
DEBUG=True # 디버그 모드 (True: 개발, False: 운영)
|
DEBUG=True # 디버그 모드 (True: 개발, False: 운영)
|
||||||
|
|
||||||
# ================================
|
# ================================
|
||||||
@ -179,66 +176,6 @@ fastapi dev main.py
|
|||||||
fastapi run main.py
|
fastapi run main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### 운영 업로드 및 메모리 한도
|
|
||||||
|
|
||||||
`POST /api/image/upload/blob`은 애플리케이션에서 파일당 15 MiB까지만 허용합니다.
|
|
||||||
운영 Nginx에서는 multipart 오버헤드를 고려해 이 엔드포인트의 요청 본문을
|
|
||||||
25 MiB로 제한합니다. 앱의 요청당 파일 합계 상한은 20 MiB이며, 나머지 5 MiB는
|
|
||||||
multipart 헤더와 `images_json`을 위한 여유입니다. 한 task에는 최대 100개
|
|
||||||
이미지만 누적할 수 있습니다. 200 MiB 이상의 요청을
|
|
||||||
허용하도록 Nginx 한도를 올리지 마세요. 프론트엔드는 이미지를 압축한 뒤 파일
|
|
||||||
한 개씩 전송해야 합니다.
|
|
||||||
|
|
||||||
운영 Nginx 설정은 이 저장소에서 관리되지 않으므로, 기존
|
|
||||||
`location = /api/image/upload/blob` 블록 안에서 다음 스니펫을 include합니다.
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
include /배포경로/deploy/nginx/ado2-image-upload-limit.conf;
|
|
||||||
```
|
|
||||||
|
|
||||||
기존 설정이 prefix location만 사용한다면 그 블록의 `proxy_pass` 및 헤더 설정을
|
|
||||||
그대로 유지한 채, exact location을 추가하고 동일한 프록시 설정을 적용해야
|
|
||||||
합니다. 반영 전후에 실제 로드된 설정과 문법을 확인합니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo nginx -T | grep -n -E 'server_name|image/upload/blob|client_max_body_size'
|
|
||||||
sudo nginx -t
|
|
||||||
sudo systemctl reload nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
`proxy_request_buffering off`는 이 스니펫에 포함하지 않았습니다. 이 옵션만으로
|
|
||||||
FastAPI의 multipart 파싱이 Azure 청크 스트리밍으로 바뀌지는 않으며, 느린
|
|
||||||
클라이언트 연결이 애플리케이션을 직접 점유하는 시간이 늘어날 수 있습니다.
|
|
||||||
|
|
||||||
Compose로 API를 실행하는 서버에서는 리소스 override를 함께 적용합니다.
|
|
||||||
이 override는 API 포트를 기본적으로 `127.0.0.1:8000`에만 바인딩해 외부
|
|
||||||
클라이언트가 Nginx의 요청 크기 제한을 우회하지 못하게 합니다. 운영 Nginx가
|
|
||||||
별도 컨테이너라면 호스트 포트를 공개하는 대신 두 서비스를 같은 내부 Docker
|
|
||||||
네트워크에 연결하세요. 부득이하게 `APP_BIND_ADDRESS`를 바꿀 때도 방화벽에서
|
|
||||||
8000 포트의 외부 접근을 차단해야 합니다. `!override` 구문을 위해 Docker
|
|
||||||
Compose 2.24.4 이상이 필요합니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.yml -f compose.resources.yaml config --quiet
|
|
||||||
docker compose -f docker-compose.yml -f compose.resources.yaml up -d --force-recreate app
|
|
||||||
docker inspect castad-app \
|
|
||||||
--format 'memory={{.HostConfig.Memory}} reservation={{.HostConfig.MemoryReservation}} swap={{.HostConfig.MemorySwap}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
기본값은 hard limit 2 GiB, reservation 512 MiB이며 추가 swap은 허용하지
|
|
||||||
않습니다. 호스트 용량과 실제 렌더링 부하를 측정한 뒤
|
|
||||||
`APP_MEMORY_LIMIT`/`APP_MEMORY_RESERVATION`으로 조정할 수 있습니다. 예를 들어
|
|
||||||
`APP_MEMORY_LIMIT=3g`를 설정하면 hard limit와 swap limit가 함께 3 GiB로
|
|
||||||
변경됩니다.
|
|
||||||
|
|
||||||
주의: 현재 저장소의 Dockerfile은 Uvicorn을 실행하지만 운영 로그 파일명에는
|
|
||||||
Gunicorn이 나타납니다. 운영 프로세스가 호스트의 systemd/Gunicorn으로 직접
|
|
||||||
실행 중이라면 이 Compose 제한은 적용되지 않습니다. 배포 전에 실제 실행
|
|
||||||
주체를 확인하고, Compose 컨테이너가 아니라면 Gunicorn을 loopback 또는 Unix
|
|
||||||
socket에만 bind하고 해당 서비스 관리자의 메모리 제한을 별도로 설정해야
|
|
||||||
합니다. 외부에서 앱 포트로 직접 접근할 수 있으면 Nginx의 25 MiB 제한을
|
|
||||||
우회할 수 있습니다.
|
|
||||||
|
|
||||||
## API 문서
|
## API 문서
|
||||||
|
|
||||||
서버 실행 후 `/docs` 에서 Scalar API 문서를 확인할 수 있습니다.
|
서버 실행 후 `/docs` 에서 Scalar API 문서를 확인할 수 있습니다.
|
||||||
@ -339,5 +276,3 @@ socket에만 bind하고 해당 서비스 관리자의 메모리 제한을 별도
|
|||||||
│◀───────────────│ │ │
|
│◀───────────────│ │ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
```
|
```
|
||||||
|
|
||||||
testAc
|
|
||||||
|
|||||||
674
WORK_PLAN_FACEBOOK_OAUTH.md
Normal file
674
WORK_PLAN_FACEBOOK_OAUTH.md
Normal file
@ -0,0 +1,674 @@
|
|||||||
|
# Facebook OAuth 구현 작업 계획서
|
||||||
|
|
||||||
|
> 작성일: 2026-03-03
|
||||||
|
> 프로젝트: O2O Castad Backend
|
||||||
|
> 대상 모듈: `app/sns/` (Facebook OAuth 연동)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
Facebook OAuth 2.0 로그인 기능을 구현합니다.
|
||||||
|
Facebook Graph API를 통해 사용자 인증, 토큰 교환, 장기 토큰 발급, 페이지 토큰 조회 기능을 제공합니다.
|
||||||
|
|
||||||
|
### 참조 공식 문서
|
||||||
|
- Facebook Manual Login Flow: https://developers.facebook.com/docs/facebook-login/guides/advanced/manual-flow
|
||||||
|
- Facebook Graph API: https://developers.facebook.com/docs/graph-api
|
||||||
|
- Long-Lived Token Exchange: https://developers.facebook.com/docs/facebook-login/guides/access-tokens/get-long-lived
|
||||||
|
|
||||||
|
### Facebook OAuth 2.0 핵심 흐름
|
||||||
|
```
|
||||||
|
1. 사용자 → Facebook 인증 페이지 리다이렉트
|
||||||
|
URL: https://www.facebook.com/v21.0/dialog/oauth
|
||||||
|
Params: client_id, redirect_uri, state, scope, response_type=code
|
||||||
|
|
||||||
|
2. Facebook → 콜백 URL로 인가 코드(code) 전달
|
||||||
|
|
||||||
|
3. 서버 → 인가 코드를 액세스 토큰으로 교환
|
||||||
|
URL: https://graph.facebook.com/v21.0/oauth/access_token
|
||||||
|
Params: client_id, client_secret, code, redirect_uri
|
||||||
|
|
||||||
|
4. 서버 → 단기 토큰을 장기 토큰으로 교환 (약 60일)
|
||||||
|
URL: https://graph.facebook.com/v21.0/oauth/access_token
|
||||||
|
Params: grant_type=fb_exchange_token, client_id, client_secret, fb_exchange_token
|
||||||
|
|
||||||
|
5. 서버 → 사용자 정보 조회
|
||||||
|
URL: https://graph.facebook.com/v21.0/me
|
||||||
|
Params: fields=id,name,email,picture
|
||||||
|
|
||||||
|
6. 서버 → 페이지 목록 및 페이지 토큰 조회 (선택)
|
||||||
|
URL: https://graph.facebook.com/v21.0/{user-id}/accounts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 파일 구조 및 역할 분담
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── utils/
|
||||||
|
│ └── facebook_oauth.py # [신규] Facebook OAuth 클래스 (API 통신 전담)
|
||||||
|
├── sns/
|
||||||
|
│ ├── api/routers/v1/
|
||||||
|
│ │ └── oauth.py # [구현] 엔드포인트 정의 (prefix: /sns)
|
||||||
|
│ ├── services/
|
||||||
|
│ │ └── facebook.py # [구현] 비즈니스 로직 (facebook_oauth.py 클래스 호출)
|
||||||
|
│ ├── models.py # [검토/수정] SNSUploadTask 필드 추가 여부 확인
|
||||||
|
│ ├── dependency.py # [구현] SNS 전용 Depends 정의
|
||||||
|
│ └── schemas/
|
||||||
|
│ └── facebook_schema.py # [신규] Facebook OAuth 스키마 정의
|
||||||
|
├── config.py # [수정] FacebookSettings 독립 클래스 신설 + 인스턴스 생성
|
||||||
|
├── .env # [수정] FACEBOOK_ 환경변수 추가
|
||||||
|
└── main.py # [수정] 라우터 등록 및 Scalar 문서 업데이트
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 상세 작업 단계
|
||||||
|
|
||||||
|
### Phase 1: 설정 및 기반 작업
|
||||||
|
|
||||||
|
#### Step 1.1: config.py - FacebookSettings 독립 클래스 신설
|
||||||
|
- **파일**: `config.py`
|
||||||
|
- **작업**: `FacebookSettings` 독립 클래스를 신규 생성 (기존 `SocialOAuthSettings` 내 주석 코드는 삭제)
|
||||||
|
- **네이밍 패턴**: 기존 `KakaoSettings`, `JWTSettings` 등과 동일한 패턴 준수
|
||||||
|
- **환경변수**: 실제 값은 `.env` 파일에서 `FACEBOOK_` prefix 변수로 관리
|
||||||
|
|
||||||
|
**config.py에 추가할 클래스** (KakaoSettings 바로 아래에 배치):
|
||||||
|
```python
|
||||||
|
class FacebookSettings(BaseSettings):
|
||||||
|
"""Facebook OAuth 설정
|
||||||
|
|
||||||
|
Facebook Graph API를 통한 OAuth 2.0 인증 설정입니다.
|
||||||
|
Meta for Developers (https://developers.facebook.com/)에서 앱을 생성하고
|
||||||
|
App ID/Secret을 발급받아야 합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
FACEBOOK_APP_ID: str = Field(
|
||||||
|
default="",
|
||||||
|
description="Facebook App ID (Meta 개발자 콘솔에서 발급)",
|
||||||
|
)
|
||||||
|
FACEBOOK_APP_SECRET: str = Field(
|
||||||
|
default="",
|
||||||
|
description="Facebook App Secret",
|
||||||
|
)
|
||||||
|
FACEBOOK_REDIRECT_URI: str = Field(
|
||||||
|
default="http://localhost:8000/sns/facebook/callback",
|
||||||
|
description="Facebook OAuth 콜백 URI",
|
||||||
|
)
|
||||||
|
FACEBOOK_GRAPH_API_VERSION: str = Field(
|
||||||
|
default="v21.0",
|
||||||
|
description="Facebook Graph API 버전",
|
||||||
|
)
|
||||||
|
FACEBOOK_OAUTH_SCOPE: str = Field(
|
||||||
|
default="public_profile,email,pages_show_list,pages_read_engagement,pages_manage_posts",
|
||||||
|
description="Facebook OAuth 요청 권한 범위 (쉼표 구분)",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = _base_config
|
||||||
|
```
|
||||||
|
|
||||||
|
**인스턴스 생성** (파일 하단 인스턴스 블록에 추가):
|
||||||
|
```python
|
||||||
|
facebook_settings = FacebookSettings()
|
||||||
|
```
|
||||||
|
|
||||||
|
**SocialOAuthSettings 내 기존 Facebook 주석 코드 처리**:
|
||||||
|
- `SocialOAuthSettings` 내의 Facebook 관련 주석 처리된 코드 블록(524~530행)은 삭제
|
||||||
|
- 해당 책임이 `FacebookSettings` 클래스로 완전 이관됨
|
||||||
|
|
||||||
|
**.env 파일에 추가할 환경변수**:
|
||||||
|
```env
|
||||||
|
# ============================================================
|
||||||
|
# Facebook OAuth 설정
|
||||||
|
# ============================================================
|
||||||
|
FACEBOOK_APP_ID=your-facebook-app-id
|
||||||
|
FACEBOOK_APP_SECRET=your-facebook-app-secret
|
||||||
|
FACEBOOK_REDIRECT_URI=http://localhost:8000/sns/facebook/callback
|
||||||
|
FACEBOOK_GRAPH_API_VERSION=v21.0
|
||||||
|
FACEBOOK_OAUTH_SCOPE=public_profile,email,pages_show_list,pages_read_engagement,pages_manage_posts
|
||||||
|
```
|
||||||
|
|
||||||
|
- **주의**: redirect_uri는 sns 프리픽스 기준으로 설정 (`/sns/facebook/callback`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: OAuth 클라이언트 구현
|
||||||
|
|
||||||
|
#### Step 2.1: app/utils/facebook_oauth.py - FacebookOAuthClient 클래스 구현
|
||||||
|
- **파일**: `app/utils/facebook_oauth.py` (신규)
|
||||||
|
- **클래스명**: `FacebookOAuthClient` (oauth 단어 포함 필수)
|
||||||
|
- **역할**: Facebook Graph API와의 HTTP 통신 전담
|
||||||
|
- **패턴**: `KakaoOAuthClient` (app/user/services/kakao.py) 패턴 준수
|
||||||
|
- **HTTP 클라이언트**: `httpx.AsyncClient` (기존 Instagram 패턴 사용)
|
||||||
|
- **Graph API 버전**: v21.0
|
||||||
|
|
||||||
|
**클래스 구조**:
|
||||||
|
```python
|
||||||
|
from config import facebook_settings
|
||||||
|
|
||||||
|
class FacebookOAuthClient:
|
||||||
|
"""Facebook OAuth 2.0 API 클라이언트"""
|
||||||
|
|
||||||
|
# URL 템플릿 (API 버전은 facebook_settings에서 동적 로드)
|
||||||
|
AUTH_URL_TEMPLATE = "https://www.facebook.com/{version}/dialog/oauth"
|
||||||
|
TOKEN_URL_TEMPLATE = "https://graph.facebook.com/{version}/oauth/access_token"
|
||||||
|
USER_INFO_URL_TEMPLATE = "https://graph.facebook.com/{version}/me"
|
||||||
|
PAGES_URL_TEMPLATE = "https://graph.facebook.com/{version}/{user_id}/accounts"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# facebook_settings에서 설정값 로드
|
||||||
|
self.client_id = facebook_settings.FACEBOOK_APP_ID
|
||||||
|
self.client_secret = facebook_settings.FACEBOOK_APP_SECRET
|
||||||
|
self.redirect_uri = facebook_settings.FACEBOOK_REDIRECT_URI
|
||||||
|
self.api_version = facebook_settings.FACEBOOK_GRAPH_API_VERSION
|
||||||
|
self.scope = facebook_settings.FACEBOOK_OAUTH_SCOPE
|
||||||
|
|
||||||
|
def get_authorization_url(self, state: str) -> str:
|
||||||
|
# Facebook 인증 페이지 URL 생성
|
||||||
|
# scope: facebook_settings.FACEBOOK_OAUTH_SCOPE에서 로드
|
||||||
|
|
||||||
|
async def get_access_token(self, code: str) -> dict:
|
||||||
|
# 인가 코드 → 단기 액세스 토큰 교환
|
||||||
|
# 반환: {access_token, token_type, expires_in}
|
||||||
|
|
||||||
|
async def exchange_long_lived_token(self, short_lived_token: str) -> dict:
|
||||||
|
# 단기 토큰 → 장기 토큰 교환 (약 60일)
|
||||||
|
# 반환: {access_token, token_type, expires_in}
|
||||||
|
|
||||||
|
async def get_user_info(self, access_token: str) -> dict:
|
||||||
|
# 사용자 프로필 조회
|
||||||
|
# fields: id, name, email, picture
|
||||||
|
# 반환: {id, name, email, picture}
|
||||||
|
|
||||||
|
async def get_user_pages(self, user_id: str, access_token: str) -> list[dict]:
|
||||||
|
# 사용자가 관리하는 Facebook 페이지 목록 조회
|
||||||
|
# 반환: [{id, name, access_token, category}, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
**예외 클래스** (같은 파일 내 정의):
|
||||||
|
```python
|
||||||
|
class FacebookOAuthException(HTTPException):
|
||||||
|
"""Facebook OAuth 기본 예외"""
|
||||||
|
|
||||||
|
class FacebookAuthFailedError(FacebookOAuthException):
|
||||||
|
"""인증 실패 (400)"""
|
||||||
|
|
||||||
|
class FacebookAPIError(FacebookOAuthException):
|
||||||
|
"""API 호출 오류 (500)"""
|
||||||
|
|
||||||
|
class FacebookTokenExpiredError(FacebookOAuthException):
|
||||||
|
"""토큰 만료 (401)"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**필수 사항**:
|
||||||
|
- 모든 메서드에 `logger.debug()` 로 입력값, 호출 URL, 응답 상태 기록
|
||||||
|
- 중요 함수 호출 부분에 한글 주석 추가
|
||||||
|
- 모듈 로거: `get_logger(__name__)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: 스키마 정의
|
||||||
|
|
||||||
|
#### Step 3.1: app/sns/schemas/facebook_schema.py - Facebook 스키마 구현
|
||||||
|
- **파일**: `app/sns/schemas/facebook_schema.py` (신규)
|
||||||
|
- **패턴**: 기존 `sns_schema.py` 패턴 준수
|
||||||
|
|
||||||
|
**스키마 정의**:
|
||||||
|
```python
|
||||||
|
class FacebookConnectResponse(BaseModel):
|
||||||
|
"""Facebook OAuth 연동 시작 응답"""
|
||||||
|
auth_url: str # Facebook 인증 페이지 URL
|
||||||
|
state: str # CSRF 방지용 state 토큰
|
||||||
|
|
||||||
|
class FacebookCallbackRequest(BaseModel):
|
||||||
|
"""Facebook OAuth 콜백 파라미터"""
|
||||||
|
code: str # 인가 코드
|
||||||
|
state: str # CSRF state 토큰
|
||||||
|
|
||||||
|
class FacebookTokenResponse(BaseModel):
|
||||||
|
"""Facebook 토큰 교환 응답"""
|
||||||
|
access_token: str # 액세스 토큰
|
||||||
|
token_type: str # Bearer
|
||||||
|
expires_in: int # 만료 시간 (초)
|
||||||
|
|
||||||
|
class FacebookUserInfo(BaseModel):
|
||||||
|
"""Facebook 사용자 정보"""
|
||||||
|
id: str # Facebook 사용자 ID
|
||||||
|
name: str # 이름
|
||||||
|
email: Optional[str] # 이메일 (선택)
|
||||||
|
picture: Optional[dict] # 프로필 사진 (선택)
|
||||||
|
|
||||||
|
class FacebookPageInfo(BaseModel):
|
||||||
|
"""Facebook 페이지 정보"""
|
||||||
|
id: str # 페이지 ID
|
||||||
|
name: str # 페이지 이름
|
||||||
|
access_token: str # 페이지 액세스 토큰
|
||||||
|
category: Optional[str] # 카테고리
|
||||||
|
|
||||||
|
class FacebookAccountResponse(BaseModel):
|
||||||
|
"""Facebook 연동 완료 응답"""
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
account_id: int # SocialAccount.id
|
||||||
|
platform_user_id: str # Facebook 사용자 ID
|
||||||
|
platform_username: str # Facebook 사용자 이름
|
||||||
|
pages: Optional[list[FacebookPageInfo]] # 관리 가능한 페이지 목록
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: 의존성 정의
|
||||||
|
|
||||||
|
#### Step 4.1: app/sns/dependency.py - SNS 전용 Depends 구현
|
||||||
|
- **파일**: `app/sns/dependency.py`
|
||||||
|
- **역할**: SNS 모듈에서만 사용하는 FastAPI 의존성
|
||||||
|
|
||||||
|
**정의 내용**:
|
||||||
|
```python
|
||||||
|
async def get_facebook_oauth_client() -> FacebookOAuthClient:
|
||||||
|
"""FacebookOAuthClient 인스턴스를 제공하는 의존성"""
|
||||||
|
|
||||||
|
async def get_facebook_social_account(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> SocialAccount:
|
||||||
|
"""현재 사용자의 활성 Facebook 소셜 계정을 조회하는 의존성"""
|
||||||
|
# SocialAccount에서 platform=facebook, is_active=True, is_deleted=False 조회
|
||||||
|
# 없으면 SocialAccountNotFoundError 발생
|
||||||
|
|
||||||
|
async def validate_oauth_state(state: str) -> str:
|
||||||
|
"""OAuth state 토큰 유효성 검증 의존성"""
|
||||||
|
# Redis에서 state 조회 및 검증
|
||||||
|
# 유효하지 않으면 예외 발생
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: 서비스 레이어 구현
|
||||||
|
|
||||||
|
#### Step 5.1: app/sns/services/facebook.py - 비즈니스 로직 구현
|
||||||
|
- **파일**: `app/sns/services/facebook.py`
|
||||||
|
- **역할**: 엔드포인트와 OAuth 클라이언트 사이의 비즈니스 로직
|
||||||
|
- **핵심 원칙**: 모든 Facebook API 호출은 `FacebookOAuthClient` 클래스를 통해서만 수행
|
||||||
|
|
||||||
|
**서비스 함수 구조**:
|
||||||
|
```python
|
||||||
|
class FacebookService:
|
||||||
|
def __init__(self):
|
||||||
|
self.oauth_client = FacebookOAuthClient()
|
||||||
|
|
||||||
|
async def start_connect(self, user_uuid: str) -> FacebookConnectResponse:
|
||||||
|
"""Facebook 연동 시작"""
|
||||||
|
# 1. CSRF state 토큰 생성 (secrets.token_urlsafe)
|
||||||
|
# 2. Redis에 state:user_uuid 매핑 저장 (TTL: OAUTH_STATE_TTL_SECONDS)
|
||||||
|
# 3. oauth_client.get_authorization_url(state) 호출
|
||||||
|
# 4. FacebookConnectResponse 반환
|
||||||
|
|
||||||
|
async def handle_callback(
|
||||||
|
self, code: str, state: str, session: AsyncSession
|
||||||
|
) -> FacebookAccountResponse:
|
||||||
|
"""OAuth 콜백 처리"""
|
||||||
|
# 1. Redis에서 state로 user_uuid 조회 및 검증
|
||||||
|
# 2. oauth_client.get_access_token(code) → 단기 토큰 획득
|
||||||
|
# 3. oauth_client.exchange_long_lived_token() → 장기 토큰 교환
|
||||||
|
# 4. oauth_client.get_user_info() → 사용자 정보 조회
|
||||||
|
# 5. oauth_client.get_user_pages() → 페이지 목록 조회
|
||||||
|
# 6. SocialAccount 생성 또는 업데이트 (DB 저장)
|
||||||
|
# - platform: facebook
|
||||||
|
# - access_token: 장기 토큰
|
||||||
|
# - platform_user_id: Facebook 사용자 ID
|
||||||
|
# - platform_username: Facebook 사용자 이름
|
||||||
|
# - platform_data: {pages: [...], picture_url: "..."}
|
||||||
|
# - token_expires_at: 현재시간 + expires_in
|
||||||
|
# - scope: 요청한 scope 문자열
|
||||||
|
# 7. FacebookAccountResponse 반환
|
||||||
|
|
||||||
|
async def disconnect(
|
||||||
|
self, user_uuid: str, session: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
"""Facebook 연동 해제"""
|
||||||
|
# SocialAccount 소프트 삭제 (is_deleted=True, is_active=False)
|
||||||
|
|
||||||
|
facebook_service = FacebookService()
|
||||||
|
```
|
||||||
|
|
||||||
|
**필수 사항**:
|
||||||
|
- 모든 주요 단계에서 `logger.debug()` 호출
|
||||||
|
- 주요 함수 호출 부분에 한글 주석 추가
|
||||||
|
- 에러 발생 시 `logger.error()` 로 상세 에러 정보 기록
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 6: 라우터 구현
|
||||||
|
|
||||||
|
#### Step 6.1: app/sns/api/routers/v1/oauth.py - 엔드포인트 구현
|
||||||
|
- **파일**: `app/sns/api/routers/v1/oauth.py`
|
||||||
|
- **prefix**: `/sns` (항목 1번 요구사항 - sns 프리픽스 필수)
|
||||||
|
- **tags**: `["SNS OAuth"]`
|
||||||
|
|
||||||
|
**엔드포인트 구조**:
|
||||||
|
```python
|
||||||
|
router = APIRouter(prefix="/sns", tags=["SNS OAuth"])
|
||||||
|
|
||||||
|
@router.get("/facebook/connect")
|
||||||
|
async def facebook_connect(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> FacebookConnectResponse:
|
||||||
|
"""Facebook OAuth 연동 시작"""
|
||||||
|
# facebook_service.start_connect() 호출
|
||||||
|
|
||||||
|
@router.get("/facebook/callback")
|
||||||
|
async def facebook_callback(
|
||||||
|
code: str | None = Query(None),
|
||||||
|
state: str | None = Query(None),
|
||||||
|
error: str | None = Query(None),
|
||||||
|
error_description: str | None = Query(None),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
"""Facebook OAuth 콜백 처리"""
|
||||||
|
# 에러/취소 처리
|
||||||
|
# facebook_service.handle_callback() 호출
|
||||||
|
# 성공/실패에 따라 프론트엔드로 리다이렉트
|
||||||
|
|
||||||
|
@router.delete("/facebook/disconnect")
|
||||||
|
async def facebook_disconnect(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Facebook 계정 연동 해제"""
|
||||||
|
# facebook_service.disconnect() 호출
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 7: 모델 검토 및 수정
|
||||||
|
|
||||||
|
#### Step 7.1: SNSUploadTask 모델 필드 검토
|
||||||
|
- **파일**: `app/sns/models.py`
|
||||||
|
- **검토 항목**: Facebook 토큰 정보 저장을 위한 추가 필드 필요 여부
|
||||||
|
|
||||||
|
**분석 결과**:
|
||||||
|
- `SNSUploadTask`는 업로드 작업 관리용 모델이며, 토큰 저장 역할이 아님
|
||||||
|
- Facebook 토큰 정보는 기존 `SocialAccount` 모델에 이미 적절한 필드가 존재:
|
||||||
|
- `access_token` (Text): 장기 토큰 저장
|
||||||
|
- `refresh_token` (Text, nullable): Facebook은 refresh_token 미지원이므로 NULL
|
||||||
|
- `token_expires_at` (DateTime): 장기 토큰 만료 시간
|
||||||
|
- `platform_data` (JSON): 페이지 토큰, 페이지 ID 등 추가 정보
|
||||||
|
- `scope` (Text): OAuth scope 저장
|
||||||
|
|
||||||
|
- `SNSUploadTask`에 Facebook 업로드 시 필요한 필드 추가 검토:
|
||||||
|
- `platform` 필드 추가 (String(20)): 어떤 플랫폼에 업로드하는지 구분 (현재 Instagram 전용 구조)
|
||||||
|
- `platform_post_id` 필드 추가 (String(255), nullable): 업로드 후 플랫폼에서 반환한 게시물 ID
|
||||||
|
- `platform_post_url` 필드 추가 (String(2048), nullable): 업로드 후 게시물 URL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 8: main.py 라우터 등록 및 Scalar 문서 업데이트
|
||||||
|
|
||||||
|
#### Step 8.1: main.py 수정
|
||||||
|
- **파일**: `main.py`
|
||||||
|
|
||||||
|
**변경 내용**:
|
||||||
|
1. **라우터 import 추가**:
|
||||||
|
```python
|
||||||
|
from app.sns.api.routers.v1.oauth import router as sns_oauth_router
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **라우터 등록**:
|
||||||
|
```python
|
||||||
|
app.include_router(sns_oauth_router) # SNS OAuth 라우터 (Facebook)
|
||||||
|
```
|
||||||
|
- 주의: oauth.py 내부 router에 이미 `/sns` prefix가 있으므로 main.py에서는 prefix 추가 불필요
|
||||||
|
|
||||||
|
3. **tags_metadata 업데이트** - SNS 태그 설명에 Facebook 추가:
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"name": "SNS OAuth",
|
||||||
|
"description": """SNS OAuth API - Facebook 계정 연동
|
||||||
|
|
||||||
|
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
|
||||||
|
|
||||||
|
## Facebook OAuth 연동 흐름
|
||||||
|
|
||||||
|
1. `GET /sns/facebook/connect` - Facebook OAuth 인증 URL 획득
|
||||||
|
2. 사용자를 auth_url로 리다이렉트 → Facebook 로그인 및 권한 승인
|
||||||
|
3. Facebook에서 `/sns/facebook/callback`으로 인가 코드 전달
|
||||||
|
4. 서버에서 토큰 교환, 장기 토큰 발급, 사용자 정보 조회
|
||||||
|
5. 연동 완료 후 프론트엔드로 리다이렉트
|
||||||
|
|
||||||
|
## 계정 관리
|
||||||
|
|
||||||
|
- `DELETE /sns/facebook/disconnect` - Facebook 계정 연동 해제
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **공개 엔드포인트 추가** (인증 불필요):
|
||||||
|
```python
|
||||||
|
public_endpoints = [
|
||||||
|
...
|
||||||
|
"/sns/facebook/callback", # Facebook OAuth 콜백
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **기존 SNS 태그 설명 업데이트**:
|
||||||
|
- SNS 태그 description에 Facebook 업로드 관련 엔드포인트 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 작업 순서 (실행 순서)
|
||||||
|
|
||||||
|
| 순서 | Phase | 작업 내용 | 대상 파일 | 의존성 |
|
||||||
|
|------|-------|----------|----------|--------|
|
||||||
|
| 1 | Phase 1 | config.py FacebookSettings 클래스 신설 + .env 변수 추가 | `config.py`, `.env` | 없음 |
|
||||||
|
| 2 | Phase 3 | Facebook 스키마 정의 | `app/sns/schemas/facebook_schema.py` | 없음 |
|
||||||
|
| 3 | Phase 2 | FacebookOAuthClient 클래스 구현 | `app/utils/facebook_oauth.py` | Step 1 (config) |
|
||||||
|
| 4 | Phase 4 | SNS 전용 Depends 구현 | `app/sns/dependency.py` | Step 3 |
|
||||||
|
| 5 | Phase 5 | Facebook 서비스 레이어 구현 | `app/sns/services/facebook.py` | Step 2, 3, 4 |
|
||||||
|
| 6 | Phase 6 | OAuth 라우터 엔드포인트 구현 | `app/sns/api/routers/v1/oauth.py` | Step 2, 5 |
|
||||||
|
| 7 | Phase 7 | SNSUploadTask 모델 필드 추가 | `app/sns/models.py` | Step 1~6 완료 후 검토 |
|
||||||
|
| 8 | Phase 8 | main.py 라우터 등록 및 Scalar 문서 | `main.py` | Step 6 |
|
||||||
|
| 9 | - | 코드리뷰 수행 (`/review`) | 전체 변경 파일 | Step 1~8 전체 |
|
||||||
|
| 10 | - | 코드리뷰 결과 기반 개선 | 해당 파일 | Step 9 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 품질 기준 (전 단계 공통)
|
||||||
|
|
||||||
|
### 5.1 로깅 기준 (항목 7)
|
||||||
|
- **모든 엔드포인트 진입점**: `logger.info()` 로 요청 시작 기록
|
||||||
|
- **외부 API 호출 전/후**: `logger.debug()` 로 URL, 파라미터, 응답 상태 기록
|
||||||
|
- **중요 변수 할당**: `logger.debug()` 로 변수값 확인
|
||||||
|
- **에러 발생 시**: `logger.error()` 로 상세 에러 정보 기록
|
||||||
|
- **로거 패턴**: `from app.utils.logger import get_logger; logger = get_logger(__name__)`
|
||||||
|
|
||||||
|
### 5.2 주석 기준 (항목 8)
|
||||||
|
- 각 메서드의 주요 단계별 한글 주석 (예: `# 인가 코드를 액세스 토큰으로 교환`)
|
||||||
|
- 외부 API 호출 시 호출 대상 명시 (예: `# Facebook Graph API - 사용자 정보 조회`)
|
||||||
|
- 복잡한 분기 로직에 판단 기준 설명
|
||||||
|
|
||||||
|
### 5.3 코드 품질 기준
|
||||||
|
- 타입 힌트 필수
|
||||||
|
- async/await 패턴 준수
|
||||||
|
- Pydantic v2 스키마 사용
|
||||||
|
- 서비스 레이어 패턴 (router → service → oauth_client)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Scalar 문서 설정 (항목 9)
|
||||||
|
|
||||||
|
### 변경 파일: `main.py`
|
||||||
|
- `tags_metadata`에 "SNS OAuth" 태그 추가
|
||||||
|
- `custom_openapi()` 함수의 `public_endpoints`에 Facebook 콜백 경로 추가
|
||||||
|
- 기존 "SNS" 태그 description에 Facebook 업로드 안내 추가 (향후 확장)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 코드리뷰 수행 (항목 10, 11)
|
||||||
|
|
||||||
|
### 리뷰 범위
|
||||||
|
모든 구현 완료 후 1회 코드리뷰 수행:
|
||||||
|
|
||||||
|
1. **설계 검증**
|
||||||
|
- 레이어 분리 적정성 (router → service → oauth_client)
|
||||||
|
- 의존성 방향 확인 (순환 의존 없음)
|
||||||
|
- 예외 처리 체계 일관성
|
||||||
|
|
||||||
|
2. **코드 정의 검증**
|
||||||
|
- 타입 힌트 누락 확인
|
||||||
|
- async/await 적정 사용
|
||||||
|
- 로거 사용 패턴 일관성
|
||||||
|
- 주석 존재 여부
|
||||||
|
|
||||||
|
3. **보안 검증**
|
||||||
|
- CSRF state 토큰 검증 로직
|
||||||
|
- 토큰 노출 방지 (로그에 토큰 전체 미출력)
|
||||||
|
- redirect_uri 고정 검증
|
||||||
|
|
||||||
|
4. **기능 검증**
|
||||||
|
- OAuth 흐름 완전성
|
||||||
|
- 에러 케이스 처리 (취소, 코드 만료, 토큰 실패)
|
||||||
|
- DB 저장 정합성
|
||||||
|
|
||||||
|
### 리뷰 결과 처리 (항목 11)
|
||||||
|
- 설계적 오류: 구조 변경 및 수정
|
||||||
|
- 코드 정의 오류: 즉시 수정 반영
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 검수 1차 - 작업 계획 완전성 검증
|
||||||
|
|
||||||
|
### 검수 항목별 준수 여부
|
||||||
|
|
||||||
|
| # | 요구사항 | 준수 여부 | 근거 |
|
||||||
|
|---|---------|----------|------|
|
||||||
|
| 1 | oauth.py 라우터에 sns 프리픽스 | **O** | `router = APIRouter(prefix="/sns")` - Phase 6 |
|
||||||
|
| 2 | utils/facebook_oauth.py에 단일 클래스, 이름에 oauth 포함 | **O** | `FacebookOAuthClient` 클래스 - Phase 2 |
|
||||||
|
| 3 | 비즈니스 로직은 facebook.py, facebook_oauth.py 클래스 사용 | **O** | `FacebookService`가 `FacebookOAuthClient`를 호출 - Phase 5 |
|
||||||
|
| 4 | SNSUploadTask 모델 참조 후 필요 필드 추가 | **O** | Phase 7에서 platform, platform_post_id, platform_post_url 추가 검토 |
|
||||||
|
| 5 | SNS 전용 Depends는 sns/dependency.py | **O** | Phase 4에서 구현 |
|
||||||
|
| 6 | 스키마는 sns/schemas에 정의 | **O** | `facebook_schema.py` - Phase 3 |
|
||||||
|
| 7 | 중요 입력/호출/변수 logger.debug 출력 | **O** | 품질 기준 5.1 적용 |
|
||||||
|
| 8 | 중요 함수 호출 주석 | **O** | 품질 기준 5.2 적용 |
|
||||||
|
| 9 | Scalar 문서 설정 업데이트 | **O** | Phase 8에서 tags_metadata, public_endpoints 수정 |
|
||||||
|
| 10 | 1회 코드리뷰 수행 | **O** | Step 9에서 `/review` 수행 |
|
||||||
|
| 11 | 리뷰 후 설계적/코드 정의 오류 개선 | **O** | Step 10에서 수정 반영 |
|
||||||
|
|
||||||
|
### 1차 검수 발견 사항
|
||||||
|
|
||||||
|
**발견 1: redirect_uri 불일치 위험**
|
||||||
|
- config.py의 `SocialOAuthSettings` 내 기존 주석은 `/social/facebook/callback`으로 되어 있으나, 본 계획에서는 `/sns/facebook/callback`으로 설정
|
||||||
|
- **해결**: `FacebookSettings` 독립 클래스에서 redirect_uri를 `/sns/facebook/callback`으로 명확히 설정하고, `SocialOAuthSettings` 내 Facebook 주석 코드는 삭제
|
||||||
|
|
||||||
|
**발견 2: Redis 의존성 미확인**
|
||||||
|
- OAuth state 토큰 저장에 Redis 사용 예정이나, Redis 클라이언트 획득 방법 미명시
|
||||||
|
- **해결**: `app/database/session.py`의 Redis 연결 활용 또는 `dependency.py`에 Redis 의존성 추가 명시
|
||||||
|
|
||||||
|
**발견 3: 기존 SNS 라우터와의 prefix 충돌 가능성**
|
||||||
|
- 기존 `app/sns/api/routers/v1/sns.py`에 이미 `prefix="/sns"`가 설정되어 있음
|
||||||
|
- 새로운 `oauth.py`에도 `prefix="/sns"` 설정 시 main.py에서 중복 prefix 발생 가능
|
||||||
|
- **해결**: main.py에서 기존 sns_router는 prefix 없이 등록 (`app.include_router(sns_router)`), 새 oauth_router도 prefix 없이 등록하되 router 내부에 `/sns` prefix 유지
|
||||||
|
|
||||||
|
**발견 4: schemas 디렉토리 __init__.py 확인 필요**
|
||||||
|
- `app/sns/schemas/` 디렉토리에 `__init__.py`가 있는지 확인하여 import 가능하도록 보장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 검수 2차 - 설계 개선 검토
|
||||||
|
|
||||||
|
### 설계 관점 검토
|
||||||
|
|
||||||
|
**검토 1: 레이어 분리 적정성** - **적정**
|
||||||
|
```
|
||||||
|
Router (oauth.py)
|
||||||
|
↓ 호출
|
||||||
|
Service (facebook.py - FacebookService)
|
||||||
|
↓ 호출
|
||||||
|
OAuth Client (facebook_oauth.py - FacebookOAuthClient)
|
||||||
|
↓ HTTP 요청
|
||||||
|
Facebook Graph API
|
||||||
|
```
|
||||||
|
- 각 레이어의 책임이 명확히 분리됨
|
||||||
|
- OAuth 클라이언트는 HTTP 통신만, 서비스는 비즈니스 로직만, 라우터는 HTTP 요청/응답만 담당
|
||||||
|
|
||||||
|
**검토 2: 기존 social 모듈과의 관계** - **개선 필요 없음**
|
||||||
|
- `app/social/` 모듈은 YouTube OAuth 및 범용 업로드 담당
|
||||||
|
- `app/sns/` 모듈은 Instagram/Facebook 등 SNS 특화 기능 담당
|
||||||
|
- 역할이 명확히 분리되어 있으므로 현행 구조 유지
|
||||||
|
|
||||||
|
**검토 3: 토큰 갱신 전략** - **보완 필요**
|
||||||
|
- Facebook은 refresh_token을 미지원하며, 장기 토큰도 약 60일 만료
|
||||||
|
- **보완**: `FacebookOAuthClient`에 토큰 만료 확인 유틸리티 메서드 추가
|
||||||
|
```python
|
||||||
|
def is_token_expired(self, token_expires_at: datetime) -> bool:
|
||||||
|
"""토큰 만료 여부 확인 (만료 7일 전부터 True 반환)"""
|
||||||
|
```
|
||||||
|
- 서비스 레이어에서 API 호출 전 토큰 만료 확인 → 만료 시 재연동 안내 예외 발생
|
||||||
|
|
||||||
|
**검토 4: 페이지 토큰 관리** - **적정**
|
||||||
|
- 페이지 토큰은 `SocialAccount.platform_data` JSON 필드에 저장
|
||||||
|
- 이 방식은 기존 YouTube의 channel_id 저장 패턴과 일관됨
|
||||||
|
|
||||||
|
**검토 5: scope 범위** - **해결 완료**
|
||||||
|
- 기본 scope: `public_profile, email` (Facebook 앱 리뷰 없이 사용 가능)
|
||||||
|
- 페이지 관리 scope: `pages_show_list, pages_read_engagement, pages_manage_posts` (앱 리뷰 필요)
|
||||||
|
- 비디오 업로드 scope: `publish_video` (향후 확장 시)
|
||||||
|
- **해결**: `FacebookSettings.FACEBOOK_OAUTH_SCOPE` 필드에서 .env 변수로 관리
|
||||||
|
- 기본값: `public_profile,email,pages_show_list,pages_read_engagement,pages_manage_posts`
|
||||||
|
- 향후 확장 시 .env 파일에서 scope 변경만으로 대응 가능
|
||||||
|
|
||||||
|
**검토 6: 에러 코드 체계** - **적정**
|
||||||
|
- Facebook OAuth 예외 클래스가 기존 Kakao 패턴과 동일 구조
|
||||||
|
- HTTP 상태 코드 + 내부 에러 코드 + 메시지 3단계 체계
|
||||||
|
|
||||||
|
### 2차 검수 발견 사항
|
||||||
|
|
||||||
|
**발견 1: state 토큰 저장소 구현 세부사항 보완**
|
||||||
|
- Redis 키 패턴 명확화 필요: `facebook_oauth_state:{state}` → value: `user_uuid`
|
||||||
|
- TTL 설정: `social_oauth_settings.OAUTH_STATE_TTL_SECONDS` (기본 300초)
|
||||||
|
- **반영**: Phase 5의 `start_connect()` 구현 시 Redis 키 패턴 명시
|
||||||
|
|
||||||
|
**발견 2: 기존 SocialAccount 중복 체크 로직**
|
||||||
|
- 동일 Facebook 사용자가 재연동할 경우, 기존 레코드를 업데이트해야 함
|
||||||
|
- **반영**: `handle_callback()`에서 `(user_uuid, platform, platform_user_id)` 기준으로 UPSERT 로직 구현
|
||||||
|
|
||||||
|
**발견 3: 향후 확장성 확보**
|
||||||
|
- 현재 Facebook 전용이지만 TikTok 등 추가 시 패턴 재사용 가능한 구조
|
||||||
|
- `dependency.py`의 `get_facebook_social_account()`는 플랫폼 파라미터화하여 범용화 가능
|
||||||
|
- **판단**: 현재 단계에서는 Facebook 전용으로 유지 (YAGNI 원칙)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 최종 작업 실행 순서 (확정)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. config.py 수정 (FacebookSettings 독립 클래스 신설 + SocialOAuthSettings 내 Facebook 주석 삭제)
|
||||||
|
2. .env 파일에 FACEBOOK_ 환경변수 추가
|
||||||
|
3. app/sns/schemas/facebook_schema.py 생성 (스키마 정의)
|
||||||
|
4. app/utils/facebook_oauth.py 생성 (FacebookOAuthClient 클래스 - facebook_settings 참조)
|
||||||
|
5. app/sns/dependency.py 구현 (SNS 전용 Depends)
|
||||||
|
6. app/sns/services/facebook.py 구현 (비즈니스 로직)
|
||||||
|
7. app/sns/api/routers/v1/oauth.py 구현 (엔드포인트)
|
||||||
|
8. app/sns/models.py 검토 및 필드 추가
|
||||||
|
9. main.py 수정 (라우터 등록 + Scalar 문서 업데이트)
|
||||||
|
10. 코드리뷰 수행 (/review)
|
||||||
|
11. 코드리뷰 결과 기반 개선 수행
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록: 참조 소스
|
||||||
|
|
||||||
|
### 기존 코드 패턴 참조 파일
|
||||||
|
| 패턴 | 참조 파일 | 설명 |
|
||||||
|
|------|----------|------|
|
||||||
|
| OAuth 클래스 | `app/user/services/kakao.py` | KakaoOAuthClient 구조 |
|
||||||
|
| HTTP 클라이언트 | `app/utils/instagram.py` | httpx.AsyncClient 패턴 |
|
||||||
|
| SNS 라우터 | `app/sns/api/routers/v1/sns.py` | prefix, 예외 처리 패턴 |
|
||||||
|
| Social OAuth 라우터 | `app/social/api/routers/v1/oauth.py` | 콜백, 리다이렉트 패턴 |
|
||||||
|
| 모델 | `app/user/models.py` | SocialAccount, Platform enum |
|
||||||
|
| 스키마 | `app/sns/schemas/sns_schema.py` | Pydantic v2 패턴 |
|
||||||
|
| 설정 (독립 클래스) | `config.py` | KakaoSettings 패턴 → FacebookSettings |
|
||||||
|
| 설정 (OAuth 공통) | `config.py` | SocialOAuthSettings (state TTL, 프론트엔드 URL 등) |
|
||||||
|
| 로거 | `app/utils/logger.py` | get_logger 패턴 |
|
||||||
@ -1,78 +1,48 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from sqladmin import Admin
|
from sqladmin import Admin
|
||||||
from sqladmin.authentication import login_required
|
|
||||||
from starlette.exceptions import HTTPException
|
|
||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import Response
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
||||||
|
|
||||||
from app.backoffice.admin.admin_view import AdminAdmin
|
from app.database.session import engine
|
||||||
from app.backoffice.admin.auth import AdminAuthBackend
|
from app.home.api.home_admin import ImageAdmin, ProjectAdmin
|
||||||
from app.backoffice.credit_view import CreditChargeRequestAdmin, CreditTransactionAdmin
|
from app.lyric.api.lyrics_admin import LyricAdmin
|
||||||
from app.backoffice.dashboard import get_dashboard_context
|
from app.song.api.song_admin import SongAdmin
|
||||||
from app.user.api.user_admin import SocialAccountAdmin, UserAdmin
|
from app.sns.api.sns_admin import SNSUploadTaskAdmin
|
||||||
|
from app.user.api.user_admin import RefreshTokenAdmin, SocialAccountAdmin, UserAdmin
|
||||||
|
from app.video.api.video_admin import VideoAdmin
|
||||||
from config import prj_settings
|
from config import prj_settings
|
||||||
|
|
||||||
TEMPLATES_DIR = Path(__file__).parent / "backoffice" / "frontend" / "templates"
|
# https://github.com/aminalaee/sqladmin
|
||||||
|
|
||||||
|
|
||||||
class DashboardAdmin(Admin):
|
|
||||||
@login_required
|
|
||||||
async def index(self, request: Request) -> Response:
|
|
||||||
ctx = await get_dashboard_context()
|
|
||||||
admin_role = request.session.get("admin_role", "viewer")
|
|
||||||
return await self.templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"sqladmin/index.html",
|
|
||||||
{"title": "대시보드", "subtitle": "", "admin_role": admin_role, **ctx},
|
|
||||||
)
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
async def edit(self, request: Request) -> Response:
|
|
||||||
if request.session.get("admin_role") == "viewer":
|
|
||||||
raise HTTPException(status_code=403)
|
|
||||||
return await super().edit(request)
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
async def create(self, request: Request) -> Response:
|
|
||||||
if request.session.get("admin_role") == "viewer":
|
|
||||||
raise HTTPException(status_code=403)
|
|
||||||
return await super().create(request)
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
async def delete(self, request: Request) -> Response:
|
|
||||||
if request.session.get("admin_role") == "viewer":
|
|
||||||
raise HTTPException(status_code=403)
|
|
||||||
return await super().delete(request)
|
|
||||||
|
|
||||||
|
|
||||||
def init_admin(
|
def init_admin(
|
||||||
app: FastAPI,
|
app: FastAPI,
|
||||||
db_engine: AsyncEngine,
|
db_engine: engine,
|
||||||
base_url: str = prj_settings.ADMIN_BASE_URL,
|
base_url: str = prj_settings.ADMIN_BASE_URL,
|
||||||
) -> Admin:
|
) -> Admin:
|
||||||
auth_backend = AdminAuthBackend(secret_key=prj_settings.ADMIN_SESSION_SECRET)
|
admin = Admin(
|
||||||
|
|
||||||
admin = DashboardAdmin(
|
|
||||||
app,
|
app,
|
||||||
db_engine,
|
db_engine,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
authentication_backend=auth_backend,
|
|
||||||
title="ADO2 관리자",
|
|
||||||
templates_dir=str(TEMPLATES_DIR),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 프로젝트 관리
|
||||||
|
admin.add_view(ProjectAdmin)
|
||||||
|
admin.add_view(ImageAdmin)
|
||||||
|
|
||||||
|
# 가사 관리
|
||||||
|
admin.add_view(LyricAdmin)
|
||||||
|
|
||||||
|
# 노래 관리
|
||||||
|
admin.add_view(SongAdmin)
|
||||||
|
|
||||||
|
# 영상 관리
|
||||||
|
admin.add_view(VideoAdmin)
|
||||||
|
|
||||||
# 사용자 관리
|
# 사용자 관리
|
||||||
admin.add_view(UserAdmin)
|
admin.add_view(UserAdmin)
|
||||||
|
admin.add_view(RefreshTokenAdmin)
|
||||||
admin.add_view(SocialAccountAdmin)
|
admin.add_view(SocialAccountAdmin)
|
||||||
|
|
||||||
# 크레딧 관리 (superadmin: 전체, viewer: 읽기 전용)
|
# SNS 관리
|
||||||
admin.add_view(CreditChargeRequestAdmin)
|
admin.add_view(SNSUploadTaskAdmin)
|
||||||
admin.add_view(CreditTransactionAdmin)
|
|
||||||
|
|
||||||
# 백오피스 설정
|
|
||||||
admin.add_view(AdminAdmin)
|
|
||||||
|
|
||||||
return admin
|
return admin
|
||||||
|
|||||||
@ -16,14 +16,7 @@ from app.user.dependencies.auth import get_current_user
|
|||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.pagination import PaginatedResponse
|
from app.utils.pagination import PaginatedResponse
|
||||||
from app.utils.upload_blob_as_request import to_playback_url
|
from app.video.models import Video
|
||||||
from app.comment.models import Comment
|
|
||||||
from app.database.like_cache import (
|
|
||||||
bulk_is_user_liked,
|
|
||||||
get_like_counts,
|
|
||||||
mset_like_counts,
|
|
||||||
)
|
|
||||||
from app.video.models import Video, VideoReaction
|
|
||||||
from app.video.schemas.video_schema import VideoListItem
|
from app.video.schemas.video_schema import VideoListItem
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@ -106,22 +99,9 @@ async def get_videos(
|
|||||||
total_result = await session.execute(count_query)
|
total_result = await session.execute(count_query)
|
||||||
total = total_result.scalar() or 0
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
# 쿼리 2: Video + Project + comment_count 조회 (like_count는 Redis에서)
|
# 쿼리 2: Video + Project 데이터 조회 (task_id별 최신 영상만)
|
||||||
comment_count_subq = (
|
|
||||||
select(func.count(Comment.id))
|
|
||||||
.where(
|
|
||||||
Comment.video_id == Video.id,
|
|
||||||
Comment.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
.correlate(Video)
|
|
||||||
.scalar_subquery()
|
|
||||||
)
|
|
||||||
data_query = (
|
data_query = (
|
||||||
select(
|
select(Video, Project)
|
||||||
Video,
|
|
||||||
Project,
|
|
||||||
comment_count_subq.label("comment_count"),
|
|
||||||
)
|
|
||||||
.join(Project, Video.project_id == Project.id)
|
.join(Project, Video.project_id == Project.id)
|
||||||
.where(Video.id.in_(select(latest_video_ids.c.latest_id)))
|
.where(Video.id.in_(select(latest_video_ids.c.latest_id)))
|
||||||
.order_by(Video.created_at.desc())
|
.order_by(Video.created_at.desc())
|
||||||
@ -131,47 +111,6 @@ async def get_videos(
|
|||||||
result = await session.execute(data_query)
|
result = await session.execute(data_query)
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
|
|
||||||
# Redis mget으로 like_count 일괄 조회
|
|
||||||
video_ids = [video.id for video, project, _ in rows]
|
|
||||||
like_count_map = await get_like_counts(video_ids)
|
|
||||||
|
|
||||||
# 캐시 미스(None)인 video_id만 DB에서 보정
|
|
||||||
missing_ids = [vid for vid, cnt in like_count_map.items() if cnt is None]
|
|
||||||
if missing_ids:
|
|
||||||
db_counts = (await session.execute(
|
|
||||||
select(VideoReaction.video_id, func.count(VideoReaction.id))
|
|
||||||
.where(VideoReaction.video_id.in_(missing_ids))
|
|
||||||
.group_by(VideoReaction.video_id)
|
|
||||||
)).all()
|
|
||||||
db_found_ids = set()
|
|
||||||
batch = {}
|
|
||||||
for vid, cnt in db_counts:
|
|
||||||
batch[vid] = cnt
|
|
||||||
like_count_map[vid] = cnt
|
|
||||||
db_found_ids.add(vid)
|
|
||||||
await mset_like_counts(batch)
|
|
||||||
for vid in missing_ids:
|
|
||||||
if vid not in db_found_ids:
|
|
||||||
like_count_map[vid] = 0
|
|
||||||
|
|
||||||
# is_liked_by_me: Redis user-set 기준, 캐시 미스 시 현재 사용자 상태만 DB 조회
|
|
||||||
raw_liked = await bulk_is_user_liked(video_ids, current_user.user_uuid)
|
|
||||||
needs_db_lookup = [
|
|
||||||
vid for vid, liked in raw_liked.items()
|
|
||||||
if liked is None and like_count_map.get(vid, 0) > 0
|
|
||||||
]
|
|
||||||
if needs_db_lookup:
|
|
||||||
liked_video_ids = set((await session.execute(
|
|
||||||
select(VideoReaction.video_id).where(
|
|
||||||
VideoReaction.video_id.in_(needs_db_lookup),
|
|
||||||
VideoReaction.user_uuid == current_user.user_uuid,
|
|
||||||
)
|
|
||||||
)).scalars().all())
|
|
||||||
for vid in needs_db_lookup:
|
|
||||||
raw_liked[vid] = vid in liked_video_ids
|
|
||||||
|
|
||||||
liked_map = {vid: bool(liked) for vid, liked in raw_liked.items()}
|
|
||||||
|
|
||||||
# VideoListItem으로 변환
|
# VideoListItem으로 변환
|
||||||
items = [
|
items = [
|
||||||
VideoListItem(
|
VideoListItem(
|
||||||
@ -179,17 +118,10 @@ async def get_videos(
|
|||||||
store_name=project.store_name,
|
store_name=project.store_name,
|
||||||
region=project.region,
|
region=project.region,
|
||||||
task_id=video.task_id,
|
task_id=video.task_id,
|
||||||
result_movie_url=to_playback_url(video.result_movie_url),
|
result_movie_url=video.result_movie_url,
|
||||||
poster_url=video.poster_url,
|
|
||||||
title=video.title,
|
|
||||||
description=video.description,
|
|
||||||
hashtags=video.hashtags,
|
|
||||||
created_at=video.created_at,
|
created_at=video.created_at,
|
||||||
like_count=like_count_map.get(video.id) or 0,
|
|
||||||
comment_count=comment_count or 0,
|
|
||||||
is_liked_by_me=liked_map.get(video.id, False),
|
|
||||||
)
|
)
|
||||||
for video, project, comment_count in rows
|
for video, project in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
response = PaginatedResponse.create(
|
response = PaginatedResponse.create(
|
||||||
|
|||||||
@ -1,74 +0,0 @@
|
|||||||
from sqladmin import ModelView
|
|
||||||
from wtforms import PasswordField, SelectField
|
|
||||||
|
|
||||||
from app.backoffice.admin.models import Admin
|
|
||||||
from app.backoffice.mixins import SuperAdminOnly
|
|
||||||
|
|
||||||
|
|
||||||
class AdminAdmin(SuperAdminOnly, ModelView, model=Admin):
|
|
||||||
name = "관리자 계정"
|
|
||||||
name_plural = "관리자 계정 목록"
|
|
||||||
icon = "fa-solid fa-user-shield"
|
|
||||||
category = "백오피스 설정"
|
|
||||||
page_size = 30
|
|
||||||
|
|
||||||
column_list = [
|
|
||||||
"id",
|
|
||||||
"username",
|
|
||||||
"name",
|
|
||||||
"role",
|
|
||||||
"is_active",
|
|
||||||
"last_login_at",
|
|
||||||
"created_at",
|
|
||||||
]
|
|
||||||
|
|
||||||
column_details_list = [
|
|
||||||
"id",
|
|
||||||
"username",
|
|
||||||
"name",
|
|
||||||
"role",
|
|
||||||
"is_active",
|
|
||||||
"last_login_at",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
]
|
|
||||||
|
|
||||||
form_columns = ["username", "password", "name", "role", "is_active"]
|
|
||||||
|
|
||||||
form_overrides = {
|
|
||||||
"password": PasswordField,
|
|
||||||
"role": SelectField,
|
|
||||||
}
|
|
||||||
|
|
||||||
form_args = {
|
|
||||||
"role": {
|
|
||||||
"label": "권한",
|
|
||||||
"choices": [("superadmin", "전체 관리자"), ("viewer", "일반 관리자")],
|
|
||||||
"default": "viewer",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
column_searchable_list = [Admin.username, Admin.name]
|
|
||||||
|
|
||||||
column_default_sort = (Admin.created_at, True)
|
|
||||||
|
|
||||||
column_sortable_list = [
|
|
||||||
Admin.id,
|
|
||||||
Admin.username,
|
|
||||||
Admin.is_active,
|
|
||||||
Admin.last_login_at,
|
|
||||||
Admin.created_at,
|
|
||||||
]
|
|
||||||
|
|
||||||
column_labels = {
|
|
||||||
"id": "ID",
|
|
||||||
"username": "아이디",
|
|
||||||
"name": "이름",
|
|
||||||
"role": "권한",
|
|
||||||
"is_active": "활성화",
|
|
||||||
"last_login_at": "마지막 로그인",
|
|
||||||
"created_at": "생성일시",
|
|
||||||
"updated_at": "수정일시",
|
|
||||||
}
|
|
||||||
|
|
||||||
can_delete = False
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqladmin.authentication import AuthenticationBackend
|
|
||||||
from sqlalchemy import select
|
|
||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import RedirectResponse
|
|
||||||
|
|
||||||
from app.backoffice.admin.models import Admin
|
|
||||||
from app.database.session import AsyncSessionLocal
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class AdminAuthBackend(AuthenticationBackend):
|
|
||||||
async def login(self, request: Request) -> bool:
|
|
||||||
form = await request.form()
|
|
||||||
username = form.get("username", "")
|
|
||||||
password = form.get("password", "")
|
|
||||||
|
|
||||||
if not username or not password:
|
|
||||||
return False
|
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Admin).where(
|
|
||||||
Admin.username == username,
|
|
||||||
Admin.is_active == True, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
admin = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if admin is None or admin.password != password:
|
|
||||||
logger.warning(f"[ADMIN-AUTH] login failed username={username}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
request.session["admin_id"] = admin.id
|
|
||||||
request.session["admin_role"] = admin.role
|
|
||||||
request.session["admin_name"] = admin.name or admin.username
|
|
||||||
logger.info(f"[ADMIN-AUTH] login success admin_id={admin.id} username={username} role={admin.role}")
|
|
||||||
|
|
||||||
# 마지막 로그인 시간 갱신
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
result = await session.execute(select(Admin).where(Admin.id == admin.id))
|
|
||||||
a = result.scalar_one()
|
|
||||||
a.last_login_at = datetime.now()
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def logout(self, request: Request) -> bool:
|
|
||||||
request.session.clear()
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def authenticate(self, request: Request) -> bool:
|
|
||||||
admin_id = request.session.get("admin_id")
|
|
||||||
if not admin_id:
|
|
||||||
return False
|
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Admin).where(
|
|
||||||
Admin.id == admin_id,
|
|
||||||
Admin.is_active == True, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
admin = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if admin is None:
|
|
||||||
logger.warning(f"[ADMIN-AUTH] authenticate failed admin_id={admin_id}")
|
|
||||||
request.session.clear()
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, Boolean, DateTime, Index, String, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.database.session import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Admin(Base):
|
|
||||||
__tablename__ = "admin"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_admin_username", "username", unique=True),
|
|
||||||
Index("idx_admin_is_active", "is_active"),
|
|
||||||
{
|
|
||||||
"mysql_engine": "InnoDB",
|
|
||||||
"mysql_charset": "utf8mb4",
|
|
||||||
"mysql_collate": "utf8mb4_unicode_ci",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
|
||||||
BigInteger,
|
|
||||||
primary_key=True,
|
|
||||||
nullable=False,
|
|
||||||
autoincrement=True,
|
|
||||||
comment="고유 식별자",
|
|
||||||
)
|
|
||||||
|
|
||||||
username: Mapped[str] = mapped_column(
|
|
||||||
String(50),
|
|
||||||
nullable=False,
|
|
||||||
unique=True,
|
|
||||||
comment="로그인 ID",
|
|
||||||
)
|
|
||||||
|
|
||||||
password: Mapped[str] = mapped_column(
|
|
||||||
String(255),
|
|
||||||
nullable=False,
|
|
||||||
comment="비밀번호",
|
|
||||||
)
|
|
||||||
|
|
||||||
name: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(50),
|
|
||||||
nullable=True,
|
|
||||||
comment="표시 이름",
|
|
||||||
)
|
|
||||||
|
|
||||||
role: Mapped[str] = mapped_column(
|
|
||||||
String(20),
|
|
||||||
nullable=False,
|
|
||||||
default="viewer",
|
|
||||||
server_default="viewer",
|
|
||||||
comment="권한 (superadmin: 전체, viewer: 조회만)",
|
|
||||||
)
|
|
||||||
|
|
||||||
is_active: Mapped[bool] = mapped_column(
|
|
||||||
Boolean,
|
|
||||||
nullable=False,
|
|
||||||
default=True,
|
|
||||||
comment="활성화 상태 (비활성화 시 로그인 차단)",
|
|
||||||
)
|
|
||||||
|
|
||||||
last_login_at: Mapped[Optional[datetime]] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=True,
|
|
||||||
comment="마지막 로그인 일시",
|
|
||||||
)
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=False,
|
|
||||||
server_default=func.now(),
|
|
||||||
comment="생성 일시",
|
|
||||||
)
|
|
||||||
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=False,
|
|
||||||
server_default=func.now(),
|
|
||||||
onupdate=func.now(),
|
|
||||||
comment="수정 일시",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<Admin(id={self.id}, username='{self.username}', is_active={self.is_active})>"
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.backoffice.admin.models import Admin
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_admin(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
username: str,
|
|
||||||
password: str,
|
|
||||||
name: Optional[str] = None,
|
|
||||||
) -> Admin:
|
|
||||||
admin = Admin(
|
|
||||||
username=username,
|
|
||||||
password=password,
|
|
||||||
name=name,
|
|
||||||
)
|
|
||||||
session.add(admin)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(admin)
|
|
||||||
logger.info(f"[ADMIN] created admin username={username}")
|
|
||||||
return admin
|
|
||||||
|
|
||||||
|
|
||||||
async def change_password(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
admin_id: int,
|
|
||||||
new_password: str,
|
|
||||||
) -> None:
|
|
||||||
result = await session.execute(select(Admin).where(Admin.id == admin_id))
|
|
||||||
admin = result.scalar_one_or_none()
|
|
||||||
if admin is None:
|
|
||||||
raise ValueError(f"Admin id={admin_id} not found")
|
|
||||||
admin.password = new_password
|
|
||||||
await session.commit()
|
|
||||||
logger.info(f"[ADMIN] password changed admin_id={admin_id}")
|
|
||||||
@ -1,205 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import outerjoin
|
|
||||||
from sqladmin import ModelView, action
|
|
||||||
from app.backoffice.mixins import SuperAdminEditable
|
|
||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import RedirectResponse
|
|
||||||
|
|
||||||
from app.backoffice.admin.models import Admin
|
|
||||||
from app.credit.models import CreditChargeRequest, CreditTransaction
|
|
||||||
from app.credit.services.credit_service import approve_charge_request, reject_charge_request
|
|
||||||
from app.database.session import AsyncSessionLocal
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class CreditChargeRequestAdmin(SuperAdminEditable, ModelView, model=CreditChargeRequest):
|
|
||||||
name = "충전 요청"
|
|
||||||
name_plural = "충전 요청 목록"
|
|
||||||
icon = "fa-solid fa-coins"
|
|
||||||
category = "크레딧 관리"
|
|
||||||
page_size = 30
|
|
||||||
can_edit = True
|
|
||||||
can_delete = False
|
|
||||||
|
|
||||||
column_list = [
|
|
||||||
"id",
|
|
||||||
"user_uuid",
|
|
||||||
"requested_amount",
|
|
||||||
"status",
|
|
||||||
"admin.name",
|
|
||||||
"processed_at",
|
|
||||||
"created_at",
|
|
||||||
]
|
|
||||||
|
|
||||||
column_details_list = [
|
|
||||||
"id",
|
|
||||||
"user_uuid",
|
|
||||||
"requested_amount",
|
|
||||||
"message",
|
|
||||||
"status",
|
|
||||||
"admin.name",
|
|
||||||
"admin_note",
|
|
||||||
"processed_at",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
]
|
|
||||||
|
|
||||||
form_columns = ["admin_note"]
|
|
||||||
|
|
||||||
can_create = False
|
|
||||||
|
|
||||||
column_searchable_list = [
|
|
||||||
CreditChargeRequest.user_uuid,
|
|
||||||
CreditChargeRequest.status,
|
|
||||||
]
|
|
||||||
|
|
||||||
column_default_sort = (CreditChargeRequest.created_at, True)
|
|
||||||
|
|
||||||
column_sortable_list = [
|
|
||||||
CreditChargeRequest.id,
|
|
||||||
CreditChargeRequest.user_uuid,
|
|
||||||
CreditChargeRequest.requested_amount,
|
|
||||||
CreditChargeRequest.status,
|
|
||||||
CreditChargeRequest.processed_at,
|
|
||||||
CreditChargeRequest.created_at,
|
|
||||||
]
|
|
||||||
|
|
||||||
column_labels = {
|
|
||||||
"id": "ID",
|
|
||||||
"user_uuid": "사용자 UUID",
|
|
||||||
"requested_amount": "요청 크레딧",
|
|
||||||
"message": "사용자 메시지",
|
|
||||||
"status": "상태",
|
|
||||||
"admin.name": "처리 관리자",
|
|
||||||
"admin_note": "관리자 메모",
|
|
||||||
"processed_at": "처리일시",
|
|
||||||
"created_at": "요청일시",
|
|
||||||
"updated_at": "수정일시",
|
|
||||||
}
|
|
||||||
|
|
||||||
@action(
|
|
||||||
name="approve_request",
|
|
||||||
label="승인",
|
|
||||||
confirmation_message="선택한 충전 요청을 승인하시겠습니까?",
|
|
||||||
add_in_detail=True,
|
|
||||||
add_in_list=True,
|
|
||||||
)
|
|
||||||
async def approve_action(self, request: Request) -> RedirectResponse:
|
|
||||||
admin_id = request.session.get("admin_id")
|
|
||||||
pks = request.query_params.get("pks", "")
|
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
for pk in pks.split(","):
|
|
||||||
if not pk.strip():
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
await approve_charge_request(
|
|
||||||
session=session,
|
|
||||||
request_id=int(pk),
|
|
||||||
admin_id=admin_id,
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
except Exception as e:
|
|
||||||
await session.rollback()
|
|
||||||
logger.warning(f"[CREDIT-ADMIN] approve failed request_id={pk} error={e}")
|
|
||||||
|
|
||||||
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
|
|
||||||
|
|
||||||
@action(
|
|
||||||
name="reject_request",
|
|
||||||
label="반려",
|
|
||||||
confirmation_message="선택한 충전 요청을 반려하시겠습니까?",
|
|
||||||
add_in_detail=True,
|
|
||||||
add_in_list=True,
|
|
||||||
)
|
|
||||||
async def reject_action(self, request: Request) -> RedirectResponse:
|
|
||||||
admin_id = request.session.get("admin_id")
|
|
||||||
pks = request.query_params.get("pks", "")
|
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
for pk in pks.split(","):
|
|
||||||
if not pk.strip():
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
await reject_charge_request(
|
|
||||||
session=session,
|
|
||||||
request_id=int(pk),
|
|
||||||
admin_id=admin_id,
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
except Exception as e:
|
|
||||||
await session.rollback()
|
|
||||||
logger.warning(f"[CREDIT-ADMIN] reject failed request_id={pk} error={e}")
|
|
||||||
|
|
||||||
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
|
|
||||||
|
|
||||||
|
|
||||||
class CreditTransactionAdmin(SuperAdminEditable, ModelView, model=CreditTransaction):
|
|
||||||
name = "크레딧 변경"
|
|
||||||
name_plural = "크레딧 변경 목록"
|
|
||||||
icon = "fa-solid fa-clock-rotate-left"
|
|
||||||
category = "크레딧 관리"
|
|
||||||
page_size = 30
|
|
||||||
|
|
||||||
can_create = False
|
|
||||||
can_edit = False
|
|
||||||
can_delete = False
|
|
||||||
|
|
||||||
column_list = [
|
|
||||||
"id",
|
|
||||||
"user_uuid",
|
|
||||||
"amount",
|
|
||||||
"balance_after",
|
|
||||||
"type",
|
|
||||||
"admin.name",
|
|
||||||
"related_request_id",
|
|
||||||
"created_at",
|
|
||||||
]
|
|
||||||
|
|
||||||
column_details_list = [
|
|
||||||
"id",
|
|
||||||
"user_uuid",
|
|
||||||
"amount",
|
|
||||||
"balance_after",
|
|
||||||
"type",
|
|
||||||
"reason",
|
|
||||||
"admin.name",
|
|
||||||
"related_request_id",
|
|
||||||
"created_at",
|
|
||||||
]
|
|
||||||
|
|
||||||
column_searchable_list = [
|
|
||||||
CreditTransaction.user_uuid,
|
|
||||||
CreditTransaction.type,
|
|
||||||
]
|
|
||||||
|
|
||||||
column_default_sort = (CreditTransaction.created_at, True)
|
|
||||||
|
|
||||||
column_sortable_list = [
|
|
||||||
CreditTransaction.id,
|
|
||||||
CreditTransaction.user_uuid,
|
|
||||||
CreditTransaction.amount,
|
|
||||||
CreditTransaction.type,
|
|
||||||
CreditTransaction.created_at,
|
|
||||||
]
|
|
||||||
|
|
||||||
column_labels = {
|
|
||||||
"id": "ID",
|
|
||||||
"user_uuid": "사용자 UUID",
|
|
||||||
"amount": "변경 크레딧",
|
|
||||||
"balance_after": "변경 후 잔액",
|
|
||||||
"type": "변경 유형",
|
|
||||||
"reason": "사유",
|
|
||||||
"admin.name": "처리 관리자",
|
|
||||||
"related_request_id": "충전 요청 ID",
|
|
||||||
"created_at": "변경 일시",
|
|
||||||
}
|
|
||||||
|
|
||||||
def list_query(self, _request: Request):
|
|
||||||
return (
|
|
||||||
select(CreditTransaction)
|
|
||||||
.select_from(outerjoin(CreditTransaction, Admin, CreditTransaction.admin_id == Admin.id))
|
|
||||||
)
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import Response
|
|
||||||
|
|
||||||
from app.credit.models import ChargeRequestStatus, CreditChargeRequest, CreditTransaction, CreditTransactionType
|
|
||||||
from app.database.session import AsyncSessionLocal
|
|
||||||
from app.user.models import User
|
|
||||||
from config import TIMEZONE
|
|
||||||
|
|
||||||
|
|
||||||
async def get_dashboard_context() -> dict:
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
today_start = datetime.now(TIMEZONE).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
|
|
||||||
pending_charge_requests_count = (await session.execute(
|
|
||||||
select(func.count()).select_from(CreditChargeRequest)
|
|
||||||
.where(CreditChargeRequest.status == ChargeRequestStatus.PENDING)
|
|
||||||
)).scalar()
|
|
||||||
|
|
||||||
today_charge = (await session.execute(
|
|
||||||
select(func.count()).select_from(CreditTransaction)
|
|
||||||
.where(
|
|
||||||
CreditTransaction.type == CreditTransactionType.CHARGE,
|
|
||||||
CreditTransaction.created_at >= today_start,
|
|
||||||
)
|
|
||||||
)).scalar()
|
|
||||||
|
|
||||||
today_consume = (await session.execute(
|
|
||||||
select(func.count()).select_from(CreditTransaction)
|
|
||||||
.where(
|
|
||||||
CreditTransaction.type == CreditTransactionType.CONSUME,
|
|
||||||
CreditTransaction.created_at >= today_start,
|
|
||||||
)
|
|
||||||
)).scalar()
|
|
||||||
|
|
||||||
month_start = datetime.now(TIMEZONE).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
month_consume = (await session.execute(
|
|
||||||
select(func.coalesce(func.sum(func.abs(CreditTransaction.amount)), 0))
|
|
||||||
.select_from(CreditTransaction)
|
|
||||||
.where(
|
|
||||||
CreditTransaction.type == CreditTransactionType.CONSUME,
|
|
||||||
CreditTransaction.created_at >= month_start,
|
|
||||||
)
|
|
||||||
)).scalar()
|
|
||||||
|
|
||||||
pending_requests = (await session.execute(
|
|
||||||
select(CreditChargeRequest)
|
|
||||||
.where(CreditChargeRequest.status == ChargeRequestStatus.PENDING)
|
|
||||||
.order_by(CreditChargeRequest.created_at.desc())
|
|
||||||
.limit(10)
|
|
||||||
)).scalars().all()
|
|
||||||
|
|
||||||
recent_transactions = (await session.execute(
|
|
||||||
select(CreditTransaction)
|
|
||||||
.order_by(CreditTransaction.created_at.desc())
|
|
||||||
.limit(10)
|
|
||||||
)).scalars().all()
|
|
||||||
|
|
||||||
recent_users = (await session.execute(
|
|
||||||
select(User)
|
|
||||||
.where(User.is_deleted == False)
|
|
||||||
.order_by(User.created_at.desc())
|
|
||||||
.limit(10)
|
|
||||||
)).scalars().all()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"stats": {
|
|
||||||
"pending_charge_requests": pending_charge_requests_count,
|
|
||||||
"today_charge": today_charge,
|
|
||||||
"today_consume": today_consume,
|
|
||||||
"month_consume": month_consume,
|
|
||||||
},
|
|
||||||
"pending_requests": pending_requests,
|
|
||||||
"recent_transactions": recent_transactions,
|
|
||||||
"recent_users": recent_users,
|
|
||||||
}
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
{% extends "sqladmin/layout.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="card-title">
|
|
||||||
{% for pk in model_view.pk_columns -%}
|
|
||||||
{{ pk.name }}
|
|
||||||
{%- if not loop.last %};{% endif -%}
|
|
||||||
{% endfor %}: {{ get_object_identifier(model) }}</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body border-bottom py-3">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table card-table table-vcenter text-nowrap table-hover table-bordered">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="w-1">Column</th>
|
|
||||||
<th class="w-1">Value</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for name in model_view._details_prop_names %}
|
|
||||||
{% set label = model_view._column_labels.get(name, name) %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ label }}</td>
|
|
||||||
{% set value, formatted_value = model_view.get_detail_value(model, name) %}
|
|
||||||
{% if name in model_view._relation_names %}
|
|
||||||
{% if is_list( value ) %}
|
|
||||||
<td>
|
|
||||||
{% for elem, formatted_elem in zip(value, formatted_value) %}
|
|
||||||
{% if model_view.show_compact_lists %}
|
|
||||||
<a href="{{ model_view._build_url_for('admin:details', request, elem) }}">({{ formatted_elem }})</a>
|
|
||||||
{% else %}
|
|
||||||
<a href="{{ model_view._build_url_for('admin:details', request, elem) }}">{{ formatted_elem }}</a><br/>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</td>
|
|
||||||
{% else %}
|
|
||||||
<td><a href="{{ model_view._url_for_details_with_prop(request, model, name) }}">{{ formatted_value }}</a>
|
|
||||||
</td>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<td>{{ formatted_value }}</td>
|
|
||||||
{% endif %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer container">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-1">
|
|
||||||
<a href="{{ url_for('admin:list', identity=model_view.identity) }}" class="btn">
|
|
||||||
Go Back
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% if model_view.can_delete and request.session.get('admin_role') == 'superadmin' %}
|
|
||||||
<div class="col-md-1">
|
|
||||||
<a href="#" data-name="{{ model_view.name }}" data-pk="{{ get_object_identifier(model) }}"
|
|
||||||
data-url="{{ model_view._url_for_delete(request, model) }}" data-bs-toggle="modal"
|
|
||||||
data-bs-target="#modal-delete" class="btn btn-danger">
|
|
||||||
Delete
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if model_view.can_edit and request.session.get('admin_role') == 'superadmin' %}
|
|
||||||
<div class="col-md-1">
|
|
||||||
<a href="{{ model_view._build_url_for('admin:edit', request, model) }}" class="btn btn-primary">
|
|
||||||
Edit
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% for custom_action,label in model_view._custom_actions_in_detail.items() %}
|
|
||||||
<div class="col-md-1">
|
|
||||||
{% if custom_action in model_view._custom_actions_confirmation %}
|
|
||||||
<a href="#" class="btn btn-secondary" data-bs-toggle="modal"
|
|
||||||
data-bs-target="#modal-confirmation-{{ custom_action }}">
|
|
||||||
{{ label }}
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<a href="{{ model_view._url_for_action(request, custom_action) }}?pks={{ get_object_identifier(model) }}"
|
|
||||||
class="btn btn-secondary">
|
|
||||||
{{ label }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if model_view.can_delete %}
|
|
||||||
{% include 'sqladmin/modals/delete.html' %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% for custom_action in model_view._custom_actions_in_detail %}
|
|
||||||
{% if custom_action in model_view._custom_actions_confirmation %}
|
|
||||||
{% with confirmation_message = model_view._custom_actions_confirmation[custom_action], custom_action=custom_action,
|
|
||||||
url=model_view._url_for_action(request, custom_action) + '?pks=' + (get_object_identifier(model) | string) %}
|
|
||||||
{% include 'sqladmin/modals/details_action_confirmation.html' %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,149 +0,0 @@
|
|||||||
{% extends "sqladmin/layout.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<!-- 요약 카드 -->
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="row row-deck row-cards mb-4">
|
|
||||||
<div class="col-sm-6 col-lg-3">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="subheader">대기 중인 요청</div>
|
|
||||||
<div class="h1 mb-3 text-warning">{{ stats.pending_charge_requests }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-6 col-lg-3">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="subheader">오늘 승인한 요청</div>
|
|
||||||
<div class="h1 mb-3 text-success">{{ stats.today_charge }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-6 col-lg-3">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="subheader">오늘 소모 크레딧</div>
|
|
||||||
<div class="h1 mb-3 text-danger">{{ stats.today_consume }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-6 col-lg-3">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="subheader">이번 달 소모 크레딧</div>
|
|
||||||
<div class="h1 mb-3 text-danger">{{ stats.month_consume }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 대기 중인 요청 -->
|
|
||||||
<div class="col-12 col-lg-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="card-title">대기 중인 요청</h3>
|
|
||||||
<div class="card-options">
|
|
||||||
<a href="{{ request.url_for('admin:list', identity='credit-charge-request') }}" class="btn btn-sm btn-primary">전체 보기</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-vcenter card-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>사용자 UUID</th>
|
|
||||||
<th>요청 크레딧</th>
|
|
||||||
<th>요청일시</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for req in pending_requests %}
|
|
||||||
<tr>
|
|
||||||
<td class="text-truncate" style="max-width:150px;">{{ req.user_uuid }}</td>
|
|
||||||
<td>{{ req.requested_amount }}</td>
|
|
||||||
<td>{{ req.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr><td colspan="3" class="text-center text-muted">대기 중인 요청 없음</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 최근 크레딧 변화 -->
|
|
||||||
<div class="col-12 col-lg-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="card-title">최근 크레딧 변화</h3>
|
|
||||||
<div class="card-options">
|
|
||||||
<a href="{{ request.url_for('admin:list', identity='credit-transaction') }}" class="btn btn-sm btn-primary">전체 보기</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-vcenter card-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>사용자 UUID</th>
|
|
||||||
<th>유형</th>
|
|
||||||
<th>변경</th>
|
|
||||||
<th>잔액</th>
|
|
||||||
<th>일시</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for tx in recent_transactions %}
|
|
||||||
<tr>
|
|
||||||
<td class="text-truncate" style="max-width:120px;">{{ tx.user_uuid }}</td>
|
|
||||||
<td>{{ tx.type }}</td>
|
|
||||||
<td class="{{ 'text-success' if tx.amount > 0 else 'text-danger' }}">{{ '%+d' % tx.amount }}</td>
|
|
||||||
<td>{{ tx.balance_after }}</td>
|
|
||||||
<td>{{ tx.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr><td colspan="5" class="text-center text-muted">이력 없음</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 최근 가입 사용자 -->
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="card-title">최근 가입 사용자</h3>
|
|
||||||
<div class="card-options">
|
|
||||||
<a href="{{ request.url_for('admin:list', identity='user') }}" class="btn btn-sm btn-primary">전체 보기</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-vcenter card-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>닉네임</th>
|
|
||||||
<th>이메일</th>
|
|
||||||
<th>크레딧</th>
|
|
||||||
<th>권한</th>
|
|
||||||
<th>가입일시</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for user in recent_users %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ user.nickname or '-' }}</td>
|
|
||||||
<td>{{ user.email or '-' }}</td>
|
|
||||||
<td>{{ user.credits }}</td>
|
|
||||||
<td>{{ user.role }}</td>
|
|
||||||
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
{% extends "sqladmin/base.html" %}
|
|
||||||
{% from 'sqladmin/_macros.html' import display_menu %}
|
|
||||||
{% block body %}
|
|
||||||
<div class="wrapper">
|
|
||||||
<aside class="navbar navbar-expand-lg navbar-vertical navbar-expand-md navbar-dark">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<h1 class="navbar-brand navbar-brand-autodark">
|
|
||||||
<a href="{{ url_for('admin:index') }}">
|
|
||||||
{% if admin.logo_url %}
|
|
||||||
<img src="{{ admin.logo_url }}" width="64" height="64" alt="Admin" class="navbar-brand-image" />
|
|
||||||
{% else %}
|
|
||||||
<h3>{{ admin.title }}</h3>
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
</h1>
|
|
||||||
<nav class="navbar navbar-expand-sm" id="navbar-menu">
|
|
||||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
|
|
||||||
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
|
||||||
{{ display_menu(admin._menu, request) }}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
{% if admin.authentication_backend %}
|
|
||||||
<div class="mb-2 text-center text-white">
|
|
||||||
<div class="fw-bold">{{ request.session.get('admin_name', '') }}</div>
|
|
||||||
<small>
|
|
||||||
{% if request.session.get('admin_role') == 'superadmin' %}
|
|
||||||
<span class="badge bg-danger">전체 관리자</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-warning text-dark">일반 관리자</span>
|
|
||||||
{% endif %}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<a href="{{ request.url_for('admin:logout') }}" class="btn btn-secondary btn-icon">
|
|
||||||
<i class="fa fa-sign-out"></i>
|
|
||||||
<span>Logout</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
<div class="page-wrapper">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class="page-header d-print-none">
|
|
||||||
{% block content_header %}
|
|
||||||
<div class="row align-items-center">
|
|
||||||
<div class="col">
|
|
||||||
<h2 class="page-title">{{ title }}</h2>
|
|
||||||
<div class="page-pretitle">{{ subtitle }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="page-body flex-grow-1">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class="row row-deck row-cards">
|
|
||||||
{% block content %} {% endblock %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,299 +0,0 @@
|
|||||||
{% extends "sqladmin/layout.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="d-flex">
|
|
||||||
<div class="flex-grow-1 me-2">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="card-title">{{ model_view.name_plural }}</h3>
|
|
||||||
<div class="ms-auto">
|
|
||||||
{% if model_view.can_export %}
|
|
||||||
{% if model_view.export_types | length > 1 %}
|
|
||||||
<div class="ms-3 d-inline-block dropdown">
|
|
||||||
<a href="#" class="btn btn-secondary dropdown-toggle" id="dropdownMenuButton1" data-bs-toggle="dropdown"
|
|
||||||
aria-expanded="false">
|
|
||||||
Export
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
|
|
||||||
{% for export_type in model_view.export_types %}
|
|
||||||
<li><a class="dropdown-item"
|
|
||||||
href="{{ url_for('admin:export', identity=model_view.identity, export_type=export_type) }}">{{
|
|
||||||
export_type | upper }}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% elif model_view.export_types | length == 1 %}
|
|
||||||
<div class="ms-3 d-inline-block">
|
|
||||||
<a href="{{ url_for('admin:export', identity=model_view.identity, export_type=model_view.export_types[0]) }}"
|
|
||||||
class="btn btn-secondary">
|
|
||||||
Export
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% if model_view.can_create %}
|
|
||||||
<div class="ms-3 d-inline-block">
|
|
||||||
<a href="{{ url_for('admin:create', identity=model_view.identity) }}" class="btn btn-primary">
|
|
||||||
+ New {{ model_view.name }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body border-bottom py-3">
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<div class="dropdown col-4">
|
|
||||||
<button {% if not model_view.can_delete and not model_view._custom_actions_in_list %} disabled {% endif %}
|
|
||||||
class="btn btn-light dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown"
|
|
||||||
aria-haspopup="true" aria-expanded="false">
|
|
||||||
Actions
|
|
||||||
</button>
|
|
||||||
{% if model_view.can_delete or model_view._custom_actions_in_list %}
|
|
||||||
<div class="dropdown-menu" aria-labelledby="dropdownMenuButton">
|
|
||||||
{% if model_view.can_delete and request.session.get('admin_role') == 'superadmin' %}
|
|
||||||
<a class="dropdown-item" id="action-delete" href="#" data-name="{{ model_view.name }}"
|
|
||||||
data-url="{{ url_for('admin:delete', identity=model_view.identity) }}" data-bs-toggle="modal"
|
|
||||||
data-bs-target="#modal-delete">Delete selected items</a>
|
|
||||||
{% endif %}
|
|
||||||
{% for custom_action, label in model_view._custom_actions_in_list.items() %}
|
|
||||||
{% if custom_action in model_view._custom_actions_confirmation %}
|
|
||||||
<a class="dropdown-item" id="action-customconfirm-{{ custom_action }}" href="#" data-bs-toggle="modal"
|
|
||||||
data-bs-target="#modal-confirmation-{{ custom_action }}">
|
|
||||||
{{ label }}
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<a class="dropdown-item" id="action-custom-{{ custom_action }}" href="#"
|
|
||||||
data-url="{{ model_view._url_for_action(request, custom_action) }}">
|
|
||||||
{{ label }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if model_view.column_searchable_list %}
|
|
||||||
<div class="col-md-4 text-muted">
|
|
||||||
<div class="input-group">
|
|
||||||
<input id="search-input" type="text" class="form-control"
|
|
||||||
placeholder="Search: {{ model_view.search_placeholder() }}"
|
|
||||||
value="{{ request.query_params.get('search', '') }}">
|
|
||||||
<button id="search-button" class="btn" type="button">Search</button>
|
|
||||||
<button id="search-reset" class="btn" type="button" {% if not request.query_params.get('search')
|
|
||||||
%}disabled{% endif %}><i class="fa-solid fa-times"></i></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table card-table table-vcenter text-nowrap">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="w-1"><input class="form-check-input m-0 align-middle" type="checkbox" aria-label="Select all"
|
|
||||||
id="select-all"></th>
|
|
||||||
<th class="w-1"></th>
|
|
||||||
{% for name in model_view._list_prop_names %}
|
|
||||||
{% set label = model_view._column_labels.get(name, name) %}
|
|
||||||
<th>
|
|
||||||
{% if name in model_view._sort_fields %}
|
|
||||||
{% if request.query_params.get("sortBy") == name and request.query_params.get("sort") == "asc" %}
|
|
||||||
<a href="{{ request.url.include_query_params(sort='desc') }}"><i class="fa-solid fa-arrow-up"></i> {{
|
|
||||||
label }}</a>
|
|
||||||
{% elif request.query_params.get("sortBy") == name and request.query_params.get("sort") == "desc" %}
|
|
||||||
<a href="{{ request.url.include_query_params(sort='asc') }}"><i class="fa-solid fa-arrow-down"></i> {{ label
|
|
||||||
}}</a>
|
|
||||||
{% else %}
|
|
||||||
<a href="{{ request.url.include_query_params(sortBy=name, sort='asc') }}">{{ label }}</a>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
{{ label }}
|
|
||||||
{% endif %}
|
|
||||||
</th>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in pagination.rows %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<input type="hidden" value="{{ get_object_identifier(row) }}">
|
|
||||||
<input class="form-check-input m-0 align-middle select-box" type="checkbox" aria-label="Select item">
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
{% if model_view.can_view_details %}
|
|
||||||
<a href="{{ model_view._build_url_for('admin:details', request, row) }}" data-bs-toggle="tooltip"
|
|
||||||
data-bs-placement="top" title="View">
|
|
||||||
<span class="me-1"><i class="fa-solid fa-eye"></i></span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if model_view.can_edit and request.session.get('admin_role') == 'superadmin' %}
|
|
||||||
<a href="{{ model_view._build_url_for('admin:edit', request, row) }}" data-bs-toggle="tooltip"
|
|
||||||
data-bs-placement="top" title="Edit">
|
|
||||||
<span class="me-1"><i class="fa-solid fa-pen-to-square"></i></span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if model_view.can_delete and request.session.get('admin_role') == 'superadmin' %}
|
|
||||||
<a href="#" data-name="{{ model_view.name }}" data-pk="{{ get_object_identifier(row) }}"
|
|
||||||
data-url="{{ model_view._url_for_delete(request, row) }}" data-bs-toggle="modal"
|
|
||||||
data-bs-target="#modal-delete" title="Delete">
|
|
||||||
<span class="me-1"><i class="fa-solid fa-trash"></i></span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
{% for name in model_view._list_prop_names %}
|
|
||||||
{% set value, formatted_value = model_view.get_list_value(row, name) %}
|
|
||||||
{% if name in model_view._relation_names %}
|
|
||||||
{% if is_list( value ) %}
|
|
||||||
<td>
|
|
||||||
{% for elem, formatted_elem in zip(value, formatted_value) %}
|
|
||||||
{% if model_view.show_compact_lists %}
|
|
||||||
<a href="{{ model_view._build_url_for('admin:details', request, elem) }}">({{ formatted_elem }})</a>
|
|
||||||
{% else %}
|
|
||||||
<a href="{{ model_view._build_url_for('admin:details', request, elem) }}">{{ formatted_elem }}</a><br/>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</td>
|
|
||||||
{% else %}
|
|
||||||
<td><a href="{{ model_view._url_for_details_with_prop(request, row, name) }}">{{ formatted_value }}</a></td>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<td>{{ formatted_value }}</td>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer d-flex justify-content-between align-items-center gap-2">
|
|
||||||
<p class="m-0 text-muted">Showing <span>{{ ((pagination.page - 1) * pagination.page_size) + 1 }}</span> to
|
|
||||||
<span>{{ min(pagination.page * pagination.page_size, pagination.count) }}</span> of <span>{{ pagination.count
|
|
||||||
}}</span> items
|
|
||||||
</p>
|
|
||||||
<ul class="pagination m-0 ms-auto">
|
|
||||||
<li class="page-item {% if not pagination.has_previous %}disabled{% endif %}">
|
|
||||||
{% if pagination.has_previous %}
|
|
||||||
<a class="page-link" href="{{ pagination.previous_page.url }}">
|
|
||||||
{% else %}
|
|
||||||
<a class="page-link" href="#">
|
|
||||||
{% endif %}
|
|
||||||
<i class="fa-solid fa-chevron-left"></i>
|
|
||||||
prev
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% for page_control in pagination.page_controls %}
|
|
||||||
<li class="page-item {% if page_control.number == pagination.page %}active{% endif %}"><a class="page-link"
|
|
||||||
href="{{ page_control.url }}">{{ page_control.number }}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
|
|
||||||
{% if pagination.has_next %}
|
|
||||||
<a class="page-link" href="{{ pagination.next_page.url }}">
|
|
||||||
{% else %}
|
|
||||||
<a class="page-link" href="#">
|
|
||||||
{% endif %}
|
|
||||||
next
|
|
||||||
<i class="fa-solid fa-chevron-right"></i>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="dropdown text-muted">
|
|
||||||
Show
|
|
||||||
<a href="#" class="btn btn-sm btn-light dropdown-toggle" data-toggle="dropdown" aria-haspopup="true"
|
|
||||||
aria-expanded="false">
|
|
||||||
{{ request.query_params.get("pageSize") or model_view.page_size }} / Page
|
|
||||||
</a>
|
|
||||||
<div class="dropdown-menu">
|
|
||||||
{% for page_size_option in model_view.page_size_options %}
|
|
||||||
<a class="dropdown-item" href="{{ request.url.include_query_params(pageSize=page_size_option, page=pagination.resize(page_size_option).page) }}">
|
|
||||||
{{ page_size_option }} / Page
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if model_view.get_filters() %}
|
|
||||||
<div class="col-md-3" style="width: 300px; flex-shrink: 0;">
|
|
||||||
<div id="filter-sidebar" class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="card-title">Filters</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% for filter in model_view.get_filters() %}
|
|
||||||
{% if filter.has_operator %}
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="fw-bold text-truncate">{{ filter.title }}</div>
|
|
||||||
<div>
|
|
||||||
<!-- Show current filter if active -->
|
|
||||||
{% set current_filter = request.query_params.get(filter.parameter_name, '') %}
|
|
||||||
{% set current_op = request.query_params.get(filter.parameter_name + '_op', '') %}
|
|
||||||
{% if current_filter %}
|
|
||||||
<div class="mb-2 text-muted small">
|
|
||||||
Current: {{ current_op }} {{ current_filter }}
|
|
||||||
<a href="{{ request.url.remove_query_params(filter.parameter_name).remove_query_params(filter.parameter_name + '_op') }}" class="text-decoration-none">[Clear]</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<!-- Single form with dropdown for operations -->
|
|
||||||
<form method="get" class="d-flex flex-column" style="gap: 8px;">
|
|
||||||
<!-- Preserve existing query parameters -->
|
|
||||||
{% for key, value in request.query_params.items() %}
|
|
||||||
{% if key != filter.parameter_name and key != filter.parameter_name + '_op' %}
|
|
||||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
<!-- Operation dropdown -->
|
|
||||||
<select name="{{ filter.parameter_name }}_op" class="form-select form-select-sm" required>
|
|
||||||
<option value="">Select operation...</option>
|
|
||||||
{% for op_value, op_label in filter.get_operation_options_for_model(model_view.model) %}
|
|
||||||
<option value="{{ op_value }}" {% if current_op == op_value %}selected{% endif %}>{{ op_label }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<!-- Value input -->
|
|
||||||
<input type="text"
|
|
||||||
name="{{ filter.parameter_name }}"
|
|
||||||
placeholder="Enter value"
|
|
||||||
class="form-control form-control-sm"
|
|
||||||
value="{{ current_filter }}"
|
|
||||||
required>
|
|
||||||
<button type="submit" class="btn btn-sm btn-outline-primary">Apply Filter</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<!-- Fallback for other filter types -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="fw-bold text-truncate">{{ filter.title }}</div>
|
|
||||||
<div>
|
|
||||||
{% for lookup in filter.lookups(request, model_view.model, model_view._run_arbitrary_query) %}
|
|
||||||
<a href="{{ request.url.include_query_params(**{filter.parameter_name: lookup[0]}) }}" class="d-block text-decoration-none text-truncate">
|
|
||||||
{{ lookup[1] }}
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if model_view.can_delete %}
|
|
||||||
{% include 'sqladmin/modals/delete.html' %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% for custom_action in model_view._custom_actions_in_list %}
|
|
||||||
{% if custom_action in model_view._custom_actions_confirmation %}
|
|
||||||
{% with confirmation_message = model_view._custom_actions_confirmation[custom_action], custom_action=custom_action,
|
|
||||||
url=model_view._url_for_action(request, custom_action) %}
|
|
||||||
{% include 'sqladmin/modals/list_action_confirmation.html' %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
from starlette.requests import Request
|
|
||||||
|
|
||||||
|
|
||||||
class SuperAdminOnly:
|
|
||||||
"""superadmin만 접근 가능 (편집/삭제/액션 모두 허용)"""
|
|
||||||
|
|
||||||
def is_accessible(self, request: Request) -> bool:
|
|
||||||
return request.session.get("admin_role") == "superadmin"
|
|
||||||
|
|
||||||
|
|
||||||
class ViewerReadOnly:
|
|
||||||
"""viewer만 접근 가능한 읽기 전용 뷰"""
|
|
||||||
|
|
||||||
can_create = False
|
|
||||||
can_edit = False
|
|
||||||
can_delete = False
|
|
||||||
|
|
||||||
def is_accessible(self, request: Request) -> bool:
|
|
||||||
return request.session.get("admin_role") == "viewer"
|
|
||||||
|
|
||||||
|
|
||||||
class ViewerAccessible:
|
|
||||||
"""superadmin + viewer 접근 가능, 읽기 전용"""
|
|
||||||
|
|
||||||
can_create = False
|
|
||||||
can_edit = False
|
|
||||||
can_delete = False
|
|
||||||
|
|
||||||
def is_accessible(self, request: Request) -> bool:
|
|
||||||
return request.session.get("admin_role") in ("superadmin", "viewer")
|
|
||||||
|
|
||||||
|
|
||||||
class SuperAdminEditable:
|
|
||||||
"""superadmin + viewer 접근 가능, superadmin만 편집"""
|
|
||||||
|
|
||||||
can_create = False
|
|
||||||
can_edit = False
|
|
||||||
can_delete = False
|
|
||||||
|
|
||||||
def is_accessible(self, request: Request) -> bool:
|
|
||||||
return request.session.get("admin_role") in ("superadmin", "viewer")
|
|
||||||
@ -1,114 +0,0 @@
|
|||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import RedirectResponse
|
|
||||||
|
|
||||||
from app.credit.models import CreditTransactionType
|
|
||||||
from app.credit.services.credit_service import charge_credit, deduct_credit
|
|
||||||
from app.database.session import AsyncSessionLocal
|
|
||||||
from app.user.models import User
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_users_by_pks(session, pks: str) -> list[User]:
|
|
||||||
ids = [int(pk) for pk in pks.split(",") if pk.strip()]
|
|
||||||
if not ids:
|
|
||||||
return []
|
|
||||||
result = await session.execute(select(User).where(User.id.in_(ids)))
|
|
||||||
return list(result.scalars().all())
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_block_users(request: Request, identity: str, block: bool) -> RedirectResponse:
|
|
||||||
pks = request.query_params.get("pks", "")
|
|
||||||
action_str = "차단" if block else "차단 해제"
|
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
users = await _get_users_by_pks(session, pks)
|
|
||||||
for user in users:
|
|
||||||
user.is_active = not block
|
|
||||||
await session.commit()
|
|
||||||
logger.info(f"[USER-ADMIN] {action_str} count={len(users)}")
|
|
||||||
|
|
||||||
return RedirectResponse(request.url_for("admin:list", identity=identity), status_code=302)
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_set_role(request: Request, identity: str, role: str) -> RedirectResponse:
|
|
||||||
pks = request.query_params.get("pks", "")
|
|
||||||
is_admin = role == "admin"
|
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
users = await _get_users_by_pks(session, pks)
|
|
||||||
for user in users:
|
|
||||||
user.role = role
|
|
||||||
user.is_admin = is_admin
|
|
||||||
await session.commit()
|
|
||||||
logger.info(f"[USER-ADMIN] set_role role={role} count={len(users)}")
|
|
||||||
|
|
||||||
return RedirectResponse(request.url_for("admin:list", identity=identity), status_code=302)
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_grant_credits(
|
|
||||||
request: Request,
|
|
||||||
identity: str,
|
|
||||||
amount: int,
|
|
||||||
admin_id: Optional[int],
|
|
||||||
) -> RedirectResponse:
|
|
||||||
pks = request.query_params.get("pks", "")
|
|
||||||
ids = [int(pk) for pk in pks.split(",") if pk.strip()]
|
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
for user_id in ids:
|
|
||||||
result = await session.execute(select(User).where(User.id == user_id))
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
if user is None:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
await charge_credit(
|
|
||||||
session=session,
|
|
||||||
user_uuid=user.user_uuid,
|
|
||||||
amount=amount,
|
|
||||||
type=CreditTransactionType.ADMIN_ADJUST,
|
|
||||||
reason="관리자 수동 충전",
|
|
||||||
admin_id=admin_id,
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
except Exception as e:
|
|
||||||
await session.rollback()
|
|
||||||
logger.warning(f"[USER-ADMIN] grant_credits failed user_id={user_id} error={e}")
|
|
||||||
|
|
||||||
return RedirectResponse(request.url_for("admin:list", identity=identity), status_code=302)
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_deduct_credits(
|
|
||||||
request: Request,
|
|
||||||
identity: str,
|
|
||||||
amount: int,
|
|
||||||
admin_id: Optional[int],
|
|
||||||
) -> RedirectResponse:
|
|
||||||
pks = request.query_params.get("pks", "")
|
|
||||||
ids = [int(pk) for pk in pks.split(",") if pk.strip()]
|
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
for user_id in ids:
|
|
||||||
result = await session.execute(select(User).where(User.id == user_id))
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
if user is None:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
await deduct_credit(
|
|
||||||
session=session,
|
|
||||||
user_uuid=user.user_uuid,
|
|
||||||
amount=amount,
|
|
||||||
type=CreditTransactionType.ADMIN_ADJUST,
|
|
||||||
reason="관리자 수동 차감",
|
|
||||||
admin_id=admin_id,
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
except Exception as e:
|
|
||||||
await session.rollback()
|
|
||||||
logger.warning(f"[USER-ADMIN] deduct_credits failed user_id={user_id} error={e}")
|
|
||||||
|
|
||||||
return RedirectResponse(request.url_for("admin:list", identity=identity), status_code=302)
|
|
||||||
@ -1,177 +0,0 @@
|
|||||||
"""
|
|
||||||
Comment API Router
|
|
||||||
|
|
||||||
영상 댓글 관련 엔드포인트를 제공합니다.
|
|
||||||
|
|
||||||
엔드포인트 목록:
|
|
||||||
- POST /comment/video/{video_id}: 댓글/대댓글 작성 (로그인 필수)
|
|
||||||
- GET /comment/video/{video_id}: 댓글 목록 조회 (비로그인 허용)
|
|
||||||
- DELETE /comment/{comment_id}: 본인 댓글 소프트 삭제 (로그인 필수)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.comment.schemas.comment_schema import (
|
|
||||||
CommentCreateRequest,
|
|
||||||
CommentCreateResponse,
|
|
||||||
CommentItem,
|
|
||||||
DeleteCommentResponse,
|
|
||||||
)
|
|
||||||
from app.comment.services.comment import create_comment, delete_comment, list_comments
|
|
||||||
from app.database.session import get_session
|
|
||||||
from app.dependencies.pagination import PaginationParams, get_pagination_params
|
|
||||||
from app.user.dependencies.auth import get_current_user, get_current_user_optional
|
|
||||||
from app.user.models import User
|
|
||||||
from app.utils.logger import get_logger
|
|
||||||
from app.utils.pagination import PaginatedResponse
|
|
||||||
|
|
||||||
logger = get_logger("comment")
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/comment", tags=["Comment"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/video/{video_id}",
|
|
||||||
summary="댓글/대댓글 작성",
|
|
||||||
description="""
|
|
||||||
## 개요
|
|
||||||
영상에 댓글 또는 대댓글을 작성합니다. 로그인 필수.
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **video_id**: 댓글을 달 영상의 ID
|
|
||||||
|
|
||||||
## 요청 본문
|
|
||||||
- **content**: 댓글 본문 (1~100자)
|
|
||||||
- **parent_id**: 대댓글일 때만 부모 댓글 id (생략 시 최상위 댓글)
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- 작성자 닉네임/프로필 이미지는 카카오 로그인 정보를 그대로 사용합니다 (클라이언트에서 지정 불가).
|
|
||||||
- 대댓글에 또 대댓글을 다는 것은 불가합니다 (최대 2-depth).
|
|
||||||
""",
|
|
||||||
response_model=CommentCreateResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "댓글 작성 성공"},
|
|
||||||
400: {"description": "잘못된 parent_id (2-depth 초과, 다른 영상의 댓글 등)"},
|
|
||||||
401: {"description": "인증 실패"},
|
|
||||||
404: {"description": "영상을 찾을 수 없음"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def post_comment(
|
|
||||||
video_id: int,
|
|
||||||
body: CommentCreateRequest,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> CommentCreateResponse:
|
|
||||||
logger.info(
|
|
||||||
f"[post_comment] START - video_id: {video_id}, user: {current_user.user_uuid}, "
|
|
||||||
f"parent_id: {body.parent_id}"
|
|
||||||
)
|
|
||||||
comment = await create_comment(
|
|
||||||
session=session,
|
|
||||||
video_id=video_id,
|
|
||||||
user_uuid=current_user.user_uuid,
|
|
||||||
nickname=current_user.nickname,
|
|
||||||
content=body.content,
|
|
||||||
parent_id=body.parent_id,
|
|
||||||
)
|
|
||||||
logger.info(f"[post_comment] SUCCESS - comment_id: {comment.id}")
|
|
||||||
return CommentCreateResponse(
|
|
||||||
id=comment.id,
|
|
||||||
nickname=comment.nickname or "익명",
|
|
||||||
profile_image_url=current_user.profile_image_url,
|
|
||||||
parent_id=comment.parent_id,
|
|
||||||
content=comment.content,
|
|
||||||
created_at=comment.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/video/{video_id}",
|
|
||||||
summary="댓글 목록 조회",
|
|
||||||
description="""
|
|
||||||
## 개요
|
|
||||||
영상의 댓글 목록을 페이지네이션하여 반환합니다. 비로그인도 접근 가능.
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **video_id**: 댓글을 조회할 영상의 ID
|
|
||||||
|
|
||||||
## 쿼리 파라미터
|
|
||||||
- **page**: 페이지 번호 (기본값: 1)
|
|
||||||
- **page_size**: 페이지당 댓글 수 (기본값: 10, 최대: 100)
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- 최상위 댓글만 페이지네이션됩니다. 각 댓글의 대댓글은 전부 포함됩니다.
|
|
||||||
- 작성자 닉네임/프로필 이미지는 카카오 로그인 정보 기준이며, is_mine으로 본인 댓글 여부도 확인 가능합니다.
|
|
||||||
- 삭제된 댓글은 content=null로 노출됩니다 (대댓글이 있는 경우).
|
|
||||||
""",
|
|
||||||
response_model=PaginatedResponse[CommentItem],
|
|
||||||
responses={
|
|
||||||
200: {"description": "댓글 목록 조회 성공"},
|
|
||||||
500: {"description": "조회 실패"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def get_comments(
|
|
||||||
video_id: int,
|
|
||||||
current_user: User | None = Depends(get_current_user_optional),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination_params),
|
|
||||||
) -> PaginatedResponse[CommentItem]:
|
|
||||||
logger.info(
|
|
||||||
f"[get_comments] START - video_id: {video_id}, "
|
|
||||||
f"page: {pagination.page}, page_size: {pagination.page_size}"
|
|
||||||
)
|
|
||||||
current_user_uuid = current_user.user_uuid if current_user else None
|
|
||||||
result = await list_comments(
|
|
||||||
session=session,
|
|
||||||
video_id=video_id,
|
|
||||||
page=pagination.page,
|
|
||||||
page_size=pagination.page_size,
|
|
||||||
current_user_uuid=current_user_uuid,
|
|
||||||
)
|
|
||||||
logger.info(f"[get_comments] SUCCESS - total: {result.total}, items: {len(result.items)}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/{comment_id}",
|
|
||||||
summary="댓글 소프트 삭제",
|
|
||||||
description="""
|
|
||||||
## 개요
|
|
||||||
본인이 작성한 댓글을 소프트 삭제합니다. 로그인 필수.
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **comment_id**: 삭제할 댓글의 ID
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- 본인 댓글만 삭제 가능합니다.
|
|
||||||
- 소프트 삭제 방식으로 DB에 데이터는 유지됩니다.
|
|
||||||
- 부모 댓글 삭제 시 대댓글은 유지되며, 목록 조회 시 content=null로 표시됩니다.
|
|
||||||
""",
|
|
||||||
response_model=DeleteCommentResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "삭제 성공"},
|
|
||||||
401: {"description": "인증 실패"},
|
|
||||||
403: {"description": "삭제 권한 없음"},
|
|
||||||
404: {"description": "댓글을 찾을 수 없음"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def remove_comment(
|
|
||||||
comment_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> DeleteCommentResponse:
|
|
||||||
logger.info(
|
|
||||||
f"[remove_comment] START - comment_id: {comment_id}, user: {current_user.user_uuid}"
|
|
||||||
)
|
|
||||||
await delete_comment(
|
|
||||||
session=session,
|
|
||||||
comment_id=comment_id,
|
|
||||||
current_user_uuid=current_user.user_uuid,
|
|
||||||
)
|
|
||||||
logger.info(f"[remove_comment] SUCCESS - comment_id: {comment_id}")
|
|
||||||
return DeleteCommentResponse(
|
|
||||||
success=True,
|
|
||||||
comment_id=comment_id,
|
|
||||||
message="댓글이 삭제되었습니다.",
|
|
||||||
)
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from app.database.session import Base
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.user.models import User
|
|
||||||
from app.video.models import Video
|
|
||||||
|
|
||||||
|
|
||||||
class Comment(Base):
|
|
||||||
"""
|
|
||||||
영상 댓글 테이블
|
|
||||||
|
|
||||||
2-depth 구조 (최상위 댓글 + 대댓글 1단계).
|
|
||||||
parent_id가 NULL이면 최상위 댓글, 값이 있으면 대댓글.
|
|
||||||
작성자 닉네임은 카카오 로그인 정보를 작성 시점에 그대로 저장한 스냅샷이며,
|
|
||||||
프로필 이미지는 별도 컬럼 없이 응답 시 User 테이블을 조인해 최신값을 조회한다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "comment"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_comment_video_id", "video_id"),
|
|
||||||
Index("idx_comment_user_uuid", "user_uuid"),
|
|
||||||
Index("idx_comment_parent_id", "parent_id"),
|
|
||||||
Index("idx_comment_is_deleted", "is_deleted"),
|
|
||||||
{
|
|
||||||
"mysql_engine": "InnoDB",
|
|
||||||
"mysql_charset": "utf8mb4",
|
|
||||||
"mysql_collate": "utf8mb4_unicode_ci",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
|
||||||
Integer, primary_key=True, autoincrement=True, comment="고유 식별자"
|
|
||||||
)
|
|
||||||
video_id: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
ForeignKey("video.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
comment="연결된 Video의 id",
|
|
||||||
)
|
|
||||||
user_uuid: Mapped[str] = mapped_column(
|
|
||||||
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
comment="작성자 UUID (응답 미노출, 권한 검증용)",
|
|
||||||
)
|
|
||||||
parent_id: Mapped[Optional[int]] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
ForeignKey("comment.id", ondelete="CASCADE"),
|
|
||||||
nullable=True,
|
|
||||||
comment="NULL=최상위 댓글, 값=대댓글의 부모 id",
|
|
||||||
)
|
|
||||||
nickname: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(50), nullable=True, comment="댓글 작성자 카카오 닉네임 스냅샷 (null이면 익명)"
|
|
||||||
)
|
|
||||||
content: Mapped[str] = mapped_column(
|
|
||||||
String(100), nullable=False, comment="댓글 본문 (한글 기준 100자 이내)"
|
|
||||||
)
|
|
||||||
is_deleted: Mapped[bool] = mapped_column(
|
|
||||||
Boolean, nullable=False, default=False, comment="소프트 삭제 여부"
|
|
||||||
)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=False,
|
|
||||||
server_default=func.now(),
|
|
||||||
comment="작성 일시",
|
|
||||||
)
|
|
||||||
|
|
||||||
video: Mapped["Video"] = relationship("Video", back_populates="comments")
|
|
||||||
user: Mapped["User"] = relationship("User", back_populates="comments")
|
|
||||||
parent: Mapped[Optional["Comment"]] = relationship(
|
|
||||||
"Comment", remote_side=[id], back_populates="replies"
|
|
||||||
)
|
|
||||||
replies: Mapped[List["Comment"]] = relationship(
|
|
||||||
"Comment",
|
|
||||||
back_populates="parent",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
)
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class CommentCreateRequest(BaseModel):
|
|
||||||
content: str = Field(..., min_length=1, max_length=100, description="댓글 본문 (한글 기준 100자 이내)")
|
|
||||||
parent_id: Optional[int] = Field(None, description="대댓글일 때만 부모 댓글 id")
|
|
||||||
|
|
||||||
|
|
||||||
class ReplyItem(BaseModel):
|
|
||||||
"""대댓글 응답"""
|
|
||||||
|
|
||||||
id: int = Field(..., description="댓글 고유 ID")
|
|
||||||
nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')")
|
|
||||||
profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필, 로그인 시점 기준 최신값)")
|
|
||||||
content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)")
|
|
||||||
is_deleted: bool = Field(..., description="삭제 여부")
|
|
||||||
is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부")
|
|
||||||
created_at: datetime = Field(..., description="작성 일시")
|
|
||||||
|
|
||||||
|
|
||||||
class CommentItem(BaseModel):
|
|
||||||
"""최상위 댓글 응답 — replies 포함"""
|
|
||||||
|
|
||||||
id: int = Field(..., description="댓글 고유 ID")
|
|
||||||
nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')")
|
|
||||||
profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필, 로그인 시점 기준 최신값)")
|
|
||||||
content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)")
|
|
||||||
is_deleted: bool = Field(..., description="삭제 여부")
|
|
||||||
is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부")
|
|
||||||
created_at: datetime = Field(..., description="작성 일시")
|
|
||||||
replies: List[ReplyItem] = Field(default_factory=list, description="대댓글 목록")
|
|
||||||
|
|
||||||
|
|
||||||
class CommentCreateResponse(BaseModel):
|
|
||||||
id: int = Field(..., description="생성된 댓글 고유 ID")
|
|
||||||
nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')")
|
|
||||||
profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필)")
|
|
||||||
parent_id: Optional[int] = Field(None, description="부모 댓글 id (대댓글인 경우)")
|
|
||||||
content: str = Field(..., description="댓글 본문")
|
|
||||||
created_at: datetime = Field(..., description="작성 일시")
|
|
||||||
|
|
||||||
|
|
||||||
class DeleteCommentResponse(BaseModel):
|
|
||||||
success: bool = Field(..., description="삭제 성공 여부")
|
|
||||||
comment_id: int = Field(..., description="삭제된 댓글 ID")
|
|
||||||
message: str = Field(..., description="결과 메시지")
|
|
||||||
@ -1,203 +0,0 @@
|
|||||||
from collections import defaultdict
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from sqlalchemy import exists, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.comment.models import Comment
|
|
||||||
from app.comment.schemas.comment_schema import CommentItem, ReplyItem
|
|
||||||
from app.user.models import User
|
|
||||||
from app.utils.pagination import PaginatedResponse
|
|
||||||
from app.video.models import Video
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_parent(
|
|
||||||
session: AsyncSession,
|
|
||||||
parent_id: int,
|
|
||||||
video_id: int,
|
|
||||||
) -> None:
|
|
||||||
"""2-depth 제한 + 동일 video 검증."""
|
|
||||||
result = await session.execute(
|
|
||||||
select(Comment).where(
|
|
||||||
Comment.id == parent_id,
|
|
||||||
Comment.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
parent = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if parent is None:
|
|
||||||
raise HTTPException(status_code=400, detail="부모 댓글을 찾을 수 없습니다.")
|
|
||||||
if parent.video_id != video_id:
|
|
||||||
raise HTTPException(status_code=400, detail="다른 영상의 댓글에는 대댓글을 달 수 없습니다.")
|
|
||||||
if parent.parent_id is not None:
|
|
||||||
raise HTTPException(status_code=400, detail="대댓글에는 대댓글을 달 수 없습니다. (최대 2-depth)")
|
|
||||||
|
|
||||||
|
|
||||||
def _build_comment_items(
|
|
||||||
parents: list,
|
|
||||||
replies_map: dict,
|
|
||||||
current_user_uuid: Optional[str],
|
|
||||||
profile_image_map: dict,
|
|
||||||
) -> List[CommentItem]:
|
|
||||||
items = []
|
|
||||||
for c in parents:
|
|
||||||
raw_replies = replies_map.get(c.id, [])
|
|
||||||
replies = [
|
|
||||||
ReplyItem(
|
|
||||||
id=r.id,
|
|
||||||
nickname=r.nickname or "익명",
|
|
||||||
profile_image_url=profile_image_map.get(r.user_uuid),
|
|
||||||
content=None if r.is_deleted else r.content,
|
|
||||||
is_deleted=r.is_deleted,
|
|
||||||
is_mine=(current_user_uuid == r.user_uuid) if current_user_uuid else False,
|
|
||||||
created_at=r.created_at,
|
|
||||||
)
|
|
||||||
for r in raw_replies
|
|
||||||
]
|
|
||||||
items.append(
|
|
||||||
CommentItem(
|
|
||||||
id=c.id,
|
|
||||||
nickname=c.nickname or "익명",
|
|
||||||
profile_image_url=profile_image_map.get(c.user_uuid),
|
|
||||||
content=None if c.is_deleted else c.content,
|
|
||||||
is_deleted=c.is_deleted,
|
|
||||||
is_mine=(current_user_uuid == c.user_uuid) if current_user_uuid else False,
|
|
||||||
created_at=c.created_at,
|
|
||||||
replies=replies,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
async def create_comment(
|
|
||||||
session: AsyncSession,
|
|
||||||
video_id: int,
|
|
||||||
user_uuid: str,
|
|
||||||
nickname: Optional[str],
|
|
||||||
content: str,
|
|
||||||
parent_id: Optional[int],
|
|
||||||
) -> Comment:
|
|
||||||
# Video 존재 확인
|
|
||||||
video_result = await session.execute(
|
|
||||||
select(Video).where(
|
|
||||||
Video.id == video_id,
|
|
||||||
Video.status == "completed",
|
|
||||||
Video.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if video_result.scalar_one_or_none() is None:
|
|
||||||
raise HTTPException(status_code=404, detail="영상을 찾을 수 없습니다.")
|
|
||||||
|
|
||||||
# parent_id 검증
|
|
||||||
if parent_id is not None:
|
|
||||||
await _validate_parent(session, parent_id, video_id)
|
|
||||||
|
|
||||||
comment = Comment(
|
|
||||||
video_id=video_id,
|
|
||||||
user_uuid=user_uuid,
|
|
||||||
nickname=nickname,
|
|
||||||
parent_id=parent_id,
|
|
||||||
content=content,
|
|
||||||
)
|
|
||||||
session.add(comment)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(comment)
|
|
||||||
return comment
|
|
||||||
|
|
||||||
|
|
||||||
async def list_comments(
|
|
||||||
session: AsyncSession,
|
|
||||||
video_id: int,
|
|
||||||
page: int,
|
|
||||||
page_size: int,
|
|
||||||
current_user_uuid: Optional[str],
|
|
||||||
) -> PaginatedResponse[CommentItem]:
|
|
||||||
offset = (page - 1) * page_size
|
|
||||||
|
|
||||||
# 살아있는 자식이 있는지 확인하는 서브쿼리
|
|
||||||
has_live_reply = (
|
|
||||||
exists()
|
|
||||||
.where(
|
|
||||||
Comment.parent_id == Comment.id,
|
|
||||||
Comment.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
.correlate(Comment)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 최상위 댓글 필터: 삭제 안 됐거나 살아있는 대댓글이 있는 것
|
|
||||||
parent_where = [
|
|
||||||
Comment.video_id == video_id,
|
|
||||||
Comment.parent_id.is_(None),
|
|
||||||
(Comment.is_deleted == False) | has_live_reply, # noqa: E712
|
|
||||||
]
|
|
||||||
|
|
||||||
from sqlalchemy import func
|
|
||||||
|
|
||||||
count_q = select(func.count(Comment.id)).where(*parent_where)
|
|
||||||
total = (await session.execute(count_q)).scalar() or 0
|
|
||||||
|
|
||||||
parents_q = (
|
|
||||||
select(Comment)
|
|
||||||
.where(*parent_where)
|
|
||||||
.order_by(Comment.created_at.desc())
|
|
||||||
.offset(offset)
|
|
||||||
.limit(page_size)
|
|
||||||
)
|
|
||||||
parents = (await session.execute(parents_q)).scalars().all()
|
|
||||||
|
|
||||||
replies_map: dict = defaultdict(list)
|
|
||||||
replies: list = []
|
|
||||||
if parents:
|
|
||||||
parent_ids = [c.id for c in parents]
|
|
||||||
replies_q = (
|
|
||||||
select(Comment)
|
|
||||||
.where(
|
|
||||||
Comment.parent_id.in_(parent_ids),
|
|
||||||
Comment.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
.order_by(Comment.created_at.asc())
|
|
||||||
)
|
|
||||||
replies = (await session.execute(replies_q)).scalars().all()
|
|
||||||
for r in replies:
|
|
||||||
replies_map[r.parent_id].append(r)
|
|
||||||
|
|
||||||
# 작성자 프로필 이미지는 스냅샷을 저장하지 않고, 응답 시 User 테이블을 조인해 최신값을 조회한다.
|
|
||||||
user_uuids = {c.user_uuid for c in parents} | {r.user_uuid for r in replies}
|
|
||||||
profile_image_map: dict = {}
|
|
||||||
if user_uuids:
|
|
||||||
profile_q = select(User.user_uuid, User.profile_image_url).where(
|
|
||||||
User.user_uuid.in_(user_uuids)
|
|
||||||
)
|
|
||||||
profile_image_map = {uuid: url for uuid, url in (await session.execute(profile_q)).all()}
|
|
||||||
|
|
||||||
items = _build_comment_items(list(parents), replies_map, current_user_uuid, profile_image_map)
|
|
||||||
|
|
||||||
return PaginatedResponse.create(
|
|
||||||
items=items,
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_comment(
|
|
||||||
session: AsyncSession,
|
|
||||||
comment_id: int,
|
|
||||||
current_user_uuid: str,
|
|
||||||
) -> None:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Comment).where(
|
|
||||||
Comment.id == comment_id,
|
|
||||||
Comment.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
comment = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if comment is None:
|
|
||||||
raise HTTPException(status_code=404, detail="댓글을 찾을 수 없습니다.")
|
|
||||||
if comment.user_uuid != current_user_uuid:
|
|
||||||
raise HTTPException(status_code=403, detail="삭제 권한이 없습니다.")
|
|
||||||
|
|
||||||
comment.is_deleted = True
|
|
||||||
await session.commit()
|
|
||||||
@ -51,9 +51,6 @@ async def lifespan(app: FastAPI):
|
|||||||
await close_shared_client()
|
await close_shared_client()
|
||||||
await close_shared_blob_client()
|
await close_shared_blob_client()
|
||||||
|
|
||||||
from app.database.like_cache import close_like_cache
|
|
||||||
await close_like_cache()
|
|
||||||
|
|
||||||
# 데이터베이스 엔진 종료
|
# 데이터베이스 엔진 종료
|
||||||
from app.database.session import dispose_engine
|
from app.database.session import dispose_engine
|
||||||
|
|
||||||
|
|||||||
@ -314,10 +314,7 @@ def add_exception_handlers(app: FastAPI):
|
|||||||
|
|
||||||
@app.exception_handler(DashboardException)
|
@app.exception_handler(DashboardException)
|
||||||
def dashboard_exception_handler(request: Request, exc: DashboardException) -> Response:
|
def dashboard_exception_handler(request: Request, exc: DashboardException) -> Response:
|
||||||
if exc.status_code < 500:
|
logger.debug(f"Handled DashboardException: {exc.__class__.__name__} - {exc.message}")
|
||||||
logger.warning(f"Handled DashboardException: {exc.__class__.__name__} - {exc.message}")
|
|
||||||
else:
|
|
||||||
logger.error(f"Handled DashboardException: {exc.__class__.__name__} - {exc.message}")
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=exc.status_code,
|
status_code=exc.status_code,
|
||||||
content={
|
content={
|
||||||
|
|||||||
@ -1,162 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.credit.exceptions import ChargeRequestForbiddenError, ChargeRequestNotFoundError
|
|
||||||
from app.credit.models import ChargeRequestStatus, CreditChargeRequest, CreditTransaction
|
|
||||||
from app.credit.schemas.credit_schema import (
|
|
||||||
ChargeRequestCreate,
|
|
||||||
ChargeRequestListResponse,
|
|
||||||
ChargeRequestResponse,
|
|
||||||
CreditTransactionListResponse,
|
|
||||||
CreditTransactionResponse,
|
|
||||||
)
|
|
||||||
from app.database.session import get_session
|
|
||||||
from app.user.dependencies.auth import get_current_user
|
|
||||||
from app.user.models import User
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/credits", tags=["Credits"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/charge-requests",
|
|
||||||
response_model=ChargeRequestResponse,
|
|
||||||
status_code=201,
|
|
||||||
summary="크레딧 충전 요청 제출",
|
|
||||||
)
|
|
||||||
async def create_charge_request(
|
|
||||||
body: ChargeRequestCreate,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> ChargeRequestResponse:
|
|
||||||
charge_request = CreditChargeRequest(
|
|
||||||
user_uuid=current_user.user_uuid,
|
|
||||||
requested_amount=body.requested_amount,
|
|
||||||
message=body.message,
|
|
||||||
)
|
|
||||||
session.add(charge_request)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(charge_request)
|
|
||||||
return ChargeRequestResponse.model_validate(charge_request)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/charge-requests",
|
|
||||||
response_model=ChargeRequestListResponse,
|
|
||||||
summary="내 충전 요청 목록",
|
|
||||||
)
|
|
||||||
async def list_charge_requests(
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> ChargeRequestListResponse:
|
|
||||||
offset = (page - 1) * page_size
|
|
||||||
|
|
||||||
total_result = await session.execute(
|
|
||||||
select(func.count()).where(CreditChargeRequest.user_uuid == current_user.user_uuid)
|
|
||||||
)
|
|
||||||
total = total_result.scalar_one()
|
|
||||||
|
|
||||||
items_result = await session.execute(
|
|
||||||
select(CreditChargeRequest)
|
|
||||||
.where(CreditChargeRequest.user_uuid == current_user.user_uuid)
|
|
||||||
.order_by(CreditChargeRequest.created_at.desc())
|
|
||||||
.offset(offset)
|
|
||||||
.limit(page_size)
|
|
||||||
)
|
|
||||||
items = items_result.scalars().all()
|
|
||||||
|
|
||||||
return ChargeRequestListResponse(
|
|
||||||
items=[ChargeRequestResponse.model_validate(i) for i in items],
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/charge-requests/{request_id}",
|
|
||||||
response_model=ChargeRequestResponse,
|
|
||||||
summary="내 충전 요청 상세",
|
|
||||||
)
|
|
||||||
async def get_charge_request(
|
|
||||||
request_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> ChargeRequestResponse:
|
|
||||||
result = await session.execute(
|
|
||||||
select(CreditChargeRequest).where(
|
|
||||||
CreditChargeRequest.id == request_id,
|
|
||||||
CreditChargeRequest.user_uuid == current_user.user_uuid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
charge_request = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if charge_request is None:
|
|
||||||
raise ChargeRequestNotFoundError()
|
|
||||||
|
|
||||||
return ChargeRequestResponse.model_validate(charge_request)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/charge-requests/{request_id}",
|
|
||||||
status_code=204,
|
|
||||||
summary="충전 요청 취소 (pending 상태만)",
|
|
||||||
)
|
|
||||||
async def cancel_charge_request(
|
|
||||||
request_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> None:
|
|
||||||
result = await session.execute(
|
|
||||||
select(CreditChargeRequest).where(CreditChargeRequest.id == request_id)
|
|
||||||
)
|
|
||||||
charge_request = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if charge_request is None:
|
|
||||||
raise ChargeRequestNotFoundError()
|
|
||||||
if charge_request.user_uuid != current_user.user_uuid:
|
|
||||||
raise ChargeRequestForbiddenError()
|
|
||||||
|
|
||||||
from app.credit.exceptions import InvalidRequestStateError
|
|
||||||
if charge_request.status != ChargeRequestStatus.PENDING:
|
|
||||||
raise InvalidRequestStateError("대기 중인 요청만 취소할 수 있습니다.")
|
|
||||||
|
|
||||||
charge_request.status = ChargeRequestStatus.CANCELLED
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/transactions",
|
|
||||||
response_model=CreditTransactionListResponse,
|
|
||||||
summary="내 크레딧 거래 이력",
|
|
||||||
)
|
|
||||||
async def list_transactions(
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> CreditTransactionListResponse:
|
|
||||||
offset = (page - 1) * page_size
|
|
||||||
|
|
||||||
total_result = await session.execute(
|
|
||||||
select(func.count()).where(CreditTransaction.user_uuid == current_user.user_uuid)
|
|
||||||
)
|
|
||||||
total = total_result.scalar_one()
|
|
||||||
|
|
||||||
items_result = await session.execute(
|
|
||||||
select(CreditTransaction)
|
|
||||||
.where(CreditTransaction.user_uuid == current_user.user_uuid)
|
|
||||||
.order_by(CreditTransaction.created_at.desc())
|
|
||||||
.offset(offset)
|
|
||||||
.limit(page_size)
|
|
||||||
)
|
|
||||||
items = items_result.scalars().all()
|
|
||||||
|
|
||||||
return CreditTransactionListResponse(
|
|
||||||
items=[CreditTransactionResponse.model_validate(i) for i in items],
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size,
|
|
||||||
)
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
from starlette import status
|
|
||||||
|
|
||||||
from app.core.exceptions import FastShipError
|
|
||||||
|
|
||||||
|
|
||||||
class InsufficientCreditError(FastShipError):
|
|
||||||
"""크레딧이 부족합니다."""
|
|
||||||
|
|
||||||
status = status.HTTP_400_BAD_REQUEST
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidRequestStateError(FastShipError):
|
|
||||||
"""이미 처리된 요청입니다."""
|
|
||||||
|
|
||||||
status = status.HTTP_409_CONFLICT
|
|
||||||
|
|
||||||
|
|
||||||
class ChargeRequestNotFoundError(FastShipError):
|
|
||||||
"""충전 요청을 찾을 수 없습니다."""
|
|
||||||
|
|
||||||
status = status.HTTP_404_NOT_FOUND
|
|
||||||
|
|
||||||
|
|
||||||
class ChargeRequestForbiddenError(FastShipError):
|
|
||||||
"""본인의 충전 요청만 조회할 수 있습니다."""
|
|
||||||
|
|
||||||
status = status.HTTP_403_FORBIDDEN
|
|
||||||
@ -1,243 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from enum import Enum
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from app.database.session import Base
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.backoffice.admin.models import Admin
|
|
||||||
from app.user.models import User
|
|
||||||
|
|
||||||
|
|
||||||
class ChargeRequestStatus(str, Enum):
|
|
||||||
PENDING = "pending"
|
|
||||||
APPROVED = "approved"
|
|
||||||
REJECTED = "rejected"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
class CreditTransactionType(str, Enum):
|
|
||||||
CHARGE = "charge"
|
|
||||||
CONSUME = "consume"
|
|
||||||
REFUND = "refund"
|
|
||||||
ADMIN_ADJUST = "admin_adjust"
|
|
||||||
|
|
||||||
|
|
||||||
class CreditChargeRequest(Base):
|
|
||||||
__tablename__ = "credit_charge_request"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_credit_request_user_uuid", "user_uuid"),
|
|
||||||
Index("idx_credit_request_status", "status"),
|
|
||||||
Index("idx_credit_request_created_at", "created_at"),
|
|
||||||
Index("idx_credit_request_status_created", "status", "created_at"),
|
|
||||||
{
|
|
||||||
"mysql_engine": "InnoDB",
|
|
||||||
"mysql_charset": "utf8mb4",
|
|
||||||
"mysql_collate": "utf8mb4_unicode_ci",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
|
||||||
BigInteger,
|
|
||||||
primary_key=True,
|
|
||||||
nullable=False,
|
|
||||||
autoincrement=True,
|
|
||||||
comment="고유 식별자",
|
|
||||||
)
|
|
||||||
|
|
||||||
user_uuid: Mapped[str] = mapped_column(
|
|
||||||
String(36),
|
|
||||||
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
comment="사용자 UUID (user.user_uuid 참조)",
|
|
||||||
)
|
|
||||||
|
|
||||||
requested_amount: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
nullable=False,
|
|
||||||
comment="요청 크레딧 수량 (양수)",
|
|
||||||
)
|
|
||||||
|
|
||||||
message: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(500),
|
|
||||||
nullable=True,
|
|
||||||
comment="사용자 요청 메시지",
|
|
||||||
)
|
|
||||||
|
|
||||||
status: Mapped[str] = mapped_column(
|
|
||||||
String(20),
|
|
||||||
nullable=False,
|
|
||||||
default=ChargeRequestStatus.PENDING,
|
|
||||||
server_default="pending",
|
|
||||||
comment="처리 상태 (pending/approved/rejected/cancelled)",
|
|
||||||
)
|
|
||||||
|
|
||||||
admin_id: Mapped[Optional[int]] = mapped_column(
|
|
||||||
BigInteger,
|
|
||||||
ForeignKey("admin.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
comment="처리한 백오피스 관리자 ID",
|
|
||||||
)
|
|
||||||
|
|
||||||
admin_note: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(1000),
|
|
||||||
nullable=True,
|
|
||||||
comment="관리자 메모",
|
|
||||||
)
|
|
||||||
|
|
||||||
processed_at: Mapped[Optional[datetime]] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=True,
|
|
||||||
comment="처리 일시",
|
|
||||||
)
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=False,
|
|
||||||
server_default=func.now(),
|
|
||||||
comment="요청 일시",
|
|
||||||
)
|
|
||||||
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=False,
|
|
||||||
server_default=func.now(),
|
|
||||||
onupdate=func.now(),
|
|
||||||
comment="수정 일시",
|
|
||||||
)
|
|
||||||
|
|
||||||
user: Mapped["User"] = relationship(
|
|
||||||
"User",
|
|
||||||
foreign_keys=[user_uuid],
|
|
||||||
primaryjoin="CreditChargeRequest.user_uuid == User.user_uuid",
|
|
||||||
back_populates="credit_requests",
|
|
||||||
lazy="noload",
|
|
||||||
)
|
|
||||||
|
|
||||||
transactions: Mapped[list["CreditTransaction"]] = relationship(
|
|
||||||
"CreditTransaction",
|
|
||||||
back_populates="charge_request",
|
|
||||||
lazy="noload",
|
|
||||||
)
|
|
||||||
|
|
||||||
admin: Mapped[Optional["Admin"]] = relationship(
|
|
||||||
"Admin",
|
|
||||||
foreign_keys=[admin_id],
|
|
||||||
primaryjoin="CreditChargeRequest.admin_id == Admin.id",
|
|
||||||
lazy="selectin",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"<CreditChargeRequest("
|
|
||||||
f"id={self.id}, user_uuid='{self.user_uuid}', "
|
|
||||||
f"amount={self.requested_amount}, status='{self.status}'"
|
|
||||||
f")>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CreditTransaction(Base):
|
|
||||||
__tablename__ = "credit_transaction"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_credit_tx_user_uuid", "user_uuid"),
|
|
||||||
Index("idx_credit_tx_user_uuid_created", "user_uuid", "created_at"),
|
|
||||||
Index("idx_credit_tx_type", "type"),
|
|
||||||
Index("idx_credit_tx_related_request", "related_request_id"),
|
|
||||||
{
|
|
||||||
"mysql_engine": "InnoDB",
|
|
||||||
"mysql_charset": "utf8mb4",
|
|
||||||
"mysql_collate": "utf8mb4_unicode_ci",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
|
||||||
BigInteger,
|
|
||||||
primary_key=True,
|
|
||||||
nullable=False,
|
|
||||||
autoincrement=True,
|
|
||||||
comment="고유 식별자",
|
|
||||||
)
|
|
||||||
|
|
||||||
user_uuid: Mapped[str] = mapped_column(
|
|
||||||
String(36),
|
|
||||||
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
comment="사용자 UUID",
|
|
||||||
)
|
|
||||||
|
|
||||||
amount: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
nullable=False,
|
|
||||||
comment="변경 크레딧 수량 (충전 양수, 차감 음수)",
|
|
||||||
)
|
|
||||||
|
|
||||||
balance_after: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
nullable=False,
|
|
||||||
comment="변경 직후 잔액",
|
|
||||||
)
|
|
||||||
|
|
||||||
type: Mapped[str] = mapped_column(
|
|
||||||
String(20),
|
|
||||||
nullable=False,
|
|
||||||
comment="변경 유형 (charge/consume/refund/admin_adjust)",
|
|
||||||
)
|
|
||||||
|
|
||||||
reason: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(255),
|
|
||||||
nullable=True,
|
|
||||||
comment="변경 사유",
|
|
||||||
)
|
|
||||||
|
|
||||||
admin_id: Mapped[Optional[int]] = mapped_column(
|
|
||||||
BigInteger,
|
|
||||||
ForeignKey("admin.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
comment="처리 관리자 ID (관리자 충전/차감 시)",
|
|
||||||
)
|
|
||||||
|
|
||||||
related_request_id: Mapped[Optional[int]] = mapped_column(
|
|
||||||
BigInteger,
|
|
||||||
ForeignKey("credit_charge_request.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
comment="연관 충전 요청 ID",
|
|
||||||
)
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=False,
|
|
||||||
server_default=func.now(),
|
|
||||||
comment="변경 일시",
|
|
||||||
)
|
|
||||||
|
|
||||||
user: Mapped["User"] = relationship(
|
|
||||||
"User",
|
|
||||||
foreign_keys=[user_uuid],
|
|
||||||
primaryjoin="CreditTransaction.user_uuid == User.user_uuid",
|
|
||||||
back_populates="credit_transactions",
|
|
||||||
lazy="noload",
|
|
||||||
)
|
|
||||||
|
|
||||||
charge_request: Mapped[Optional[CreditChargeRequest]] = relationship(
|
|
||||||
"CreditChargeRequest",
|
|
||||||
back_populates="transactions",
|
|
||||||
lazy="noload",
|
|
||||||
)
|
|
||||||
|
|
||||||
admin: Mapped[Optional["Admin"]] = relationship(
|
|
||||||
"Admin",
|
|
||||||
foreign_keys=[admin_id],
|
|
||||||
primaryjoin="CreditTransaction.admin_id == Admin.id",
|
|
||||||
lazy="selectin",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"<CreditTransaction("
|
|
||||||
f"id={self.id}, user_uuid='{self.user_uuid}', "
|
|
||||||
f"amount={self.amount}, type='{self.type}'"
|
|
||||||
f")>"
|
|
||||||
)
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ChargeRequestCreate(BaseModel):
|
|
||||||
requested_amount: int = Field(..., gt=0, le=10000, description="요청 크레딧 수량")
|
|
||||||
message: Optional[str] = Field(None, max_length=500, description="요청 메시지")
|
|
||||||
|
|
||||||
model_config = {
|
|
||||||
"json_schema_extra": {
|
|
||||||
"example": {
|
|
||||||
"requested_amount": 10,
|
|
||||||
"message": "크레딧 충전 요청합니다.",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ChargeRequestResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
user_uuid: str
|
|
||||||
requested_amount: int
|
|
||||||
message: Optional[str]
|
|
||||||
status: str
|
|
||||||
admin_note: Optional[str]
|
|
||||||
processed_at: Optional[datetime]
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
class ChargeRequestListResponse(BaseModel):
|
|
||||||
items: list[ChargeRequestResponse]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
|
|
||||||
|
|
||||||
class CreditTransactionResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
user_uuid: str
|
|
||||||
amount: int
|
|
||||||
balance_after: int
|
|
||||||
type: str
|
|
||||||
reason: Optional[str]
|
|
||||||
related_request_id: Optional[int]
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
class CreditTransactionListResponse(BaseModel):
|
|
||||||
items: list[CreditTransactionResponse]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
@ -1,195 +0,0 @@
|
|||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from config import TIMEZONE
|
|
||||||
|
|
||||||
from sqlalchemy import select, update
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.credit.exceptions import (
|
|
||||||
ChargeRequestNotFoundError,
|
|
||||||
InsufficientCreditError,
|
|
||||||
InvalidRequestStateError,
|
|
||||||
)
|
|
||||||
from app.credit.models import (
|
|
||||||
ChargeRequestStatus,
|
|
||||||
CreditChargeRequest,
|
|
||||||
CreditTransaction,
|
|
||||||
CreditTransactionType,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def record_transaction(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
user_uuid: str,
|
|
||||||
amount: int,
|
|
||||||
balance_after: int,
|
|
||||||
type: CreditTransactionType,
|
|
||||||
reason: Optional[str] = None,
|
|
||||||
admin_id: Optional[int] = None,
|
|
||||||
related_request_id: Optional[int] = None,
|
|
||||||
) -> CreditTransaction:
|
|
||||||
tx = CreditTransaction(
|
|
||||||
user_uuid=user_uuid,
|
|
||||||
amount=amount,
|
|
||||||
balance_after=balance_after,
|
|
||||||
type=type,
|
|
||||||
reason=reason,
|
|
||||||
admin_id=admin_id,
|
|
||||||
related_request_id=related_request_id,
|
|
||||||
)
|
|
||||||
session.add(tx)
|
|
||||||
await session.flush()
|
|
||||||
return tx
|
|
||||||
|
|
||||||
|
|
||||||
async def charge_credit(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
user_uuid: str,
|
|
||||||
amount: int,
|
|
||||||
type: CreditTransactionType = CreditTransactionType.CHARGE,
|
|
||||||
reason: Optional[str] = None,
|
|
||||||
admin_id: Optional[int] = None,
|
|
||||||
related_request_id: Optional[int] = None,
|
|
||||||
) -> CreditTransaction:
|
|
||||||
from app.user.models import User
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(User).where(User.user_uuid == user_uuid).with_for_update()
|
|
||||||
)
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
if user is None:
|
|
||||||
from app.user.services.auth import UserNotFoundError
|
|
||||||
raise UserNotFoundError()
|
|
||||||
|
|
||||||
user.credits = user.credits + amount
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
tx = await record_transaction(
|
|
||||||
session=session,
|
|
||||||
user_uuid=user_uuid,
|
|
||||||
amount=amount,
|
|
||||||
balance_after=user.credits,
|
|
||||||
type=type,
|
|
||||||
reason=reason,
|
|
||||||
admin_id=admin_id,
|
|
||||||
related_request_id=related_request_id,
|
|
||||||
)
|
|
||||||
logger.info(f"[CREDIT] charge user_uuid={user_uuid} amount=+{amount} balance_after={user.credits}")
|
|
||||||
return tx
|
|
||||||
|
|
||||||
|
|
||||||
async def deduct_credit(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
user_uuid: str,
|
|
||||||
amount: int,
|
|
||||||
type: CreditTransactionType = CreditTransactionType.CONSUME,
|
|
||||||
reason: Optional[str] = None,
|
|
||||||
admin_id: Optional[int] = None,
|
|
||||||
) -> CreditTransaction:
|
|
||||||
from app.user.models import User
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(User).where(User.user_uuid == user_uuid).with_for_update()
|
|
||||||
)
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
if user is None:
|
|
||||||
from app.user.services.auth import UserNotFoundError
|
|
||||||
raise UserNotFoundError()
|
|
||||||
|
|
||||||
if user.credits < amount:
|
|
||||||
logger.warning(f"[CREDIT] insufficient credits user_uuid={user_uuid} credits={user.credits} requested={amount}")
|
|
||||||
raise InsufficientCreditError()
|
|
||||||
|
|
||||||
user.credits = user.credits - amount
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
tx = await record_transaction(
|
|
||||||
session=session,
|
|
||||||
user_uuid=user_uuid,
|
|
||||||
amount=-amount,
|
|
||||||
balance_after=user.credits,
|
|
||||||
type=type,
|
|
||||||
reason=reason,
|
|
||||||
admin_id=admin_id,
|
|
||||||
)
|
|
||||||
logger.info(f"[CREDIT] deduct user_uuid={user_uuid} amount=-{amount} balance_after={user.credits}")
|
|
||||||
return tx
|
|
||||||
|
|
||||||
|
|
||||||
async def approve_charge_request(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
request_id: int,
|
|
||||||
admin_id: int,
|
|
||||||
admin_note: Optional[str] = None,
|
|
||||||
) -> CreditChargeRequest:
|
|
||||||
result = await session.execute(
|
|
||||||
select(CreditChargeRequest)
|
|
||||||
.where(CreditChargeRequest.id == request_id)
|
|
||||||
.with_for_update()
|
|
||||||
)
|
|
||||||
charge_request = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if charge_request is None:
|
|
||||||
raise ChargeRequestNotFoundError()
|
|
||||||
|
|
||||||
if charge_request.status != ChargeRequestStatus.PENDING:
|
|
||||||
logger.warning(f"[CREDIT] approve blocked request_id={request_id} status={charge_request.status}")
|
|
||||||
raise InvalidRequestStateError()
|
|
||||||
|
|
||||||
await charge_credit(
|
|
||||||
session=session,
|
|
||||||
user_uuid=charge_request.user_uuid,
|
|
||||||
amount=charge_request.requested_amount,
|
|
||||||
type=CreditTransactionType.CHARGE,
|
|
||||||
reason="충전 요청 승인",
|
|
||||||
admin_id=admin_id,
|
|
||||||
related_request_id=request_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
charge_request.status = ChargeRequestStatus.APPROVED
|
|
||||||
charge_request.admin_id = admin_id
|
|
||||||
charge_request.admin_note = admin_note
|
|
||||||
charge_request.processed_at = datetime.now(TIMEZONE)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
logger.info(f"[CREDIT] approved request_id={request_id} admin_id={admin_id} amount={charge_request.requested_amount}")
|
|
||||||
return charge_request
|
|
||||||
|
|
||||||
|
|
||||||
async def reject_charge_request(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
request_id: int,
|
|
||||||
admin_id: int,
|
|
||||||
admin_note: Optional[str] = None,
|
|
||||||
) -> CreditChargeRequest:
|
|
||||||
result = await session.execute(
|
|
||||||
select(CreditChargeRequest)
|
|
||||||
.where(CreditChargeRequest.id == request_id)
|
|
||||||
.with_for_update()
|
|
||||||
)
|
|
||||||
charge_request = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if charge_request is None:
|
|
||||||
raise ChargeRequestNotFoundError()
|
|
||||||
|
|
||||||
if charge_request.status != ChargeRequestStatus.PENDING:
|
|
||||||
logger.warning(f"[CREDIT] reject blocked request_id={request_id} status={charge_request.status}")
|
|
||||||
raise InvalidRequestStateError()
|
|
||||||
|
|
||||||
charge_request.status = ChargeRequestStatus.REJECTED
|
|
||||||
charge_request.admin_id = admin_id
|
|
||||||
charge_request.admin_note = admin_note
|
|
||||||
charge_request.processed_at = datetime.now(TIMEZONE)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
logger.info(f"[CREDIT] rejected request_id={request_id} admin_id={admin_id}")
|
|
||||||
return charge_request
|
|
||||||
@ -4,22 +4,43 @@ Dashboard API 라우터
|
|||||||
YouTube Analytics 기반 대시보드 통계를 제공합니다.
|
YouTube Analytics 기반 대시보드 통계를 제공합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.dashboard.utils.redis_cache import delete_cache_pattern
|
from app.dashboard.exceptions import (
|
||||||
from app.dashboard.schemas import (
|
YouTubeAccountNotConnectedError,
|
||||||
CacheDeleteResponse,
|
YouTubeAccountNotFoundError,
|
||||||
ConnectedAccountsResponse,
|
YouTubeAccountSelectionRequiredError,
|
||||||
DashboardResponse,
|
YouTubeTokenExpiredError,
|
||||||
|
)
|
||||||
|
from app.dashboard.schemas import (
|
||||||
|
AudienceData,
|
||||||
|
CacheDeleteResponse,
|
||||||
|
ConnectedAccount,
|
||||||
|
ConnectedAccountsResponse,
|
||||||
|
ContentMetric,
|
||||||
|
DashboardResponse,
|
||||||
|
TopContent,
|
||||||
|
)
|
||||||
|
from app.dashboard.services import DataProcessor, YouTubeAnalyticsService
|
||||||
|
from app.dashboard.redis_cache import (
|
||||||
|
delete_cache,
|
||||||
|
delete_cache_pattern,
|
||||||
|
get_cache,
|
||||||
|
set_cache,
|
||||||
)
|
)
|
||||||
from app.dashboard.services import DashboardService
|
|
||||||
from app.database.session import get_session
|
from app.database.session import get_session
|
||||||
|
from app.dashboard.models import Dashboard
|
||||||
|
from app.social.exceptions import TokenExpiredError
|
||||||
|
from app.social.services import SocialAccountService
|
||||||
from app.user.dependencies.auth import get_current_user
|
from app.user.dependencies.auth import get_current_user
|
||||||
from app.user.models import User
|
from app.user.models import SocialAccount, User
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -40,8 +61,41 @@ async def get_connected_accounts(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> ConnectedAccountsResponse:
|
) -> ConnectedAccountsResponse:
|
||||||
service = DashboardService()
|
result = await session.execute(
|
||||||
connected = await service.get_connected_accounts(current_user, session)
|
select(SocialAccount).where(
|
||||||
|
SocialAccount.user_uuid == current_user.user_uuid,
|
||||||
|
SocialAccount.platform == "youtube",
|
||||||
|
SocialAccount.is_active == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
accounts_raw = result.scalars().all()
|
||||||
|
|
||||||
|
# platform_user_id 기준
|
||||||
|
seen_platform_ids: set[str] = set()
|
||||||
|
connected = []
|
||||||
|
for acc in sorted(
|
||||||
|
accounts_raw, key=lambda a: a.connected_at or datetime.min, reverse=True
|
||||||
|
):
|
||||||
|
if acc.platform_user_id in seen_platform_ids:
|
||||||
|
continue
|
||||||
|
seen_platform_ids.add(acc.platform_user_id)
|
||||||
|
data = acc.platform_data if isinstance(acc.platform_data, dict) else {}
|
||||||
|
connected.append(
|
||||||
|
ConnectedAccount(
|
||||||
|
id=acc.id,
|
||||||
|
platform=acc.platform,
|
||||||
|
platform_username=acc.platform_username,
|
||||||
|
platform_user_id=acc.platform_user_id,
|
||||||
|
channel_title=data.get("channel_title"),
|
||||||
|
connected_at=acc.connected_at,
|
||||||
|
is_active=acc.is_active,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[ACCOUNTS] YouTube 계정 목록 조회 - "
|
||||||
|
f"user_uuid={current_user.user_uuid}, count={len(connected)}"
|
||||||
|
)
|
||||||
return ConnectedAccountsResponse(accounts=connected)
|
return ConnectedAccountsResponse(accounts=connected)
|
||||||
|
|
||||||
|
|
||||||
@ -88,8 +142,328 @@ async def get_dashboard_stats(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> DashboardResponse:
|
) -> DashboardResponse:
|
||||||
service = DashboardService()
|
"""
|
||||||
return await service.get_stats(mode, platform_user_id, current_user, session)
|
대시보드 통계 조회
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: 조회 모드 (day: 최근 30일, month: 최근 12개월)
|
||||||
|
platform_user_id: 사용할 YouTube 채널 ID (여러 계정 연결 시 필수, 재연동해도 불변)
|
||||||
|
current_user: 현재 인증된 사용자
|
||||||
|
session: 데이터베이스 세션
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DashboardResponse: 대시보드 통계 데이터
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
YouTubeAccountNotConnectedError: YouTube 계정이 연동되어 있지 않음
|
||||||
|
YouTubeAccountSelectionRequiredError: 여러 계정이 연결되어 있으나 계정 미선택
|
||||||
|
YouTubeAccountNotFoundError: 지정한 계정을 찾을 수 없음
|
||||||
|
YouTubeTokenExpiredError: YouTube 토큰 만료 (재연동 필요)
|
||||||
|
YouTubeAPIError: YouTube Analytics API 호출 실패
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
f"[DASHBOARD] 통계 조회 시작 - "
|
||||||
|
f"user_uuid={current_user.user_uuid}, mode={mode}, platform_user_id={platform_user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. 모드별 날짜 자동 계산
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
if mode == "day":
|
||||||
|
# 48시간 지연 적용: 오늘 기준 -2일을 end로 사용
|
||||||
|
# ex) 오늘 2/20 → end=2/18, start=1/20
|
||||||
|
end_dt = today - timedelta(days=2)
|
||||||
|
kpi_end_dt = end_dt
|
||||||
|
start_dt = end_dt - timedelta(days=29)
|
||||||
|
# 이전 30일 (YouTube API day_previous와 동일 기준)
|
||||||
|
prev_start_dt = start_dt - timedelta(days=30)
|
||||||
|
prev_kpi_end_dt = kpi_end_dt - timedelta(days=30)
|
||||||
|
period_desc = "최근 30일"
|
||||||
|
else: # mode == "month"
|
||||||
|
# 월별 차트: dimensions=month API는 YYYY-MM-01 형식 필요
|
||||||
|
# ex) 오늘 2/24 → end=2026-02-01, start=2025-03-01 → 2025-03 ~ 2026-02 (12개월)
|
||||||
|
end_dt = today.replace(day=1)
|
||||||
|
# KPI 등 집계형 API: 48시간 지연 적용하여 현재 월 전체 데이터 포함
|
||||||
|
kpi_end_dt = today - timedelta(days=2)
|
||||||
|
|
||||||
|
start_month = end_dt.month - 11
|
||||||
|
if start_month <= 0:
|
||||||
|
start_month += 12
|
||||||
|
start_year = end_dt.year - 1
|
||||||
|
else:
|
||||||
|
start_year = end_dt.year
|
||||||
|
start_dt = date(start_year, start_month, 1)
|
||||||
|
# 이전 12개월 (YouTube API previous와 동일 기준 — 1년 전)
|
||||||
|
prev_start_dt = start_dt.replace(year=start_dt.year - 1)
|
||||||
|
try:
|
||||||
|
prev_kpi_end_dt = kpi_end_dt.replace(year=kpi_end_dt.year - 1)
|
||||||
|
except ValueError: # 윤년 2/29 → 이전 연도 2/28
|
||||||
|
prev_kpi_end_dt = kpi_end_dt.replace(year=kpi_end_dt.year - 1, day=28)
|
||||||
|
period_desc = "최근 12개월"
|
||||||
|
|
||||||
|
start_date = start_dt.strftime("%Y-%m-%d")
|
||||||
|
end_date = end_dt.strftime("%Y-%m-%d")
|
||||||
|
kpi_end_date = kpi_end_dt.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[1] 날짜 계산 완료 - period={period_desc}, start={start_date}, end={end_date}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. YouTube 계정 연동 확인
|
||||||
|
result = await session.execute(
|
||||||
|
select(SocialAccount).where(
|
||||||
|
SocialAccount.user_uuid == current_user.user_uuid,
|
||||||
|
SocialAccount.platform == "youtube",
|
||||||
|
SocialAccount.is_active == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
social_accounts_raw = result.scalars().all()
|
||||||
|
|
||||||
|
# platform_user_id 기준으로 중복 제거 (가장 최근 연동 계정 우선)
|
||||||
|
seen_platform_ids_stats: set[str] = set()
|
||||||
|
social_accounts = []
|
||||||
|
for acc in sorted(
|
||||||
|
social_accounts_raw, key=lambda a: a.connected_at or datetime.min, reverse=True
|
||||||
|
):
|
||||||
|
if acc.platform_user_id not in seen_platform_ids_stats:
|
||||||
|
seen_platform_ids_stats.add(acc.platform_user_id)
|
||||||
|
social_accounts.append(acc)
|
||||||
|
|
||||||
|
if not social_accounts:
|
||||||
|
logger.warning(
|
||||||
|
f"[NO YOUTUBE ACCOUNT] YouTube 계정 미연동 - "
|
||||||
|
f"user_uuid={current_user.user_uuid}"
|
||||||
|
)
|
||||||
|
raise YouTubeAccountNotConnectedError()
|
||||||
|
|
||||||
|
if platform_user_id is not None:
|
||||||
|
matched = [a for a in social_accounts if a.platform_user_id == platform_user_id]
|
||||||
|
if not matched:
|
||||||
|
logger.warning(
|
||||||
|
f"[ACCOUNT NOT FOUND] 지정 계정 없음 - "
|
||||||
|
f"user_uuid={current_user.user_uuid}, platform_user_id={platform_user_id}"
|
||||||
|
)
|
||||||
|
raise YouTubeAccountNotFoundError()
|
||||||
|
social_account = matched[0]
|
||||||
|
elif len(social_accounts) == 1:
|
||||||
|
social_account = social_accounts[0]
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"[MULTI ACCOUNT] 계정 선택 필요 - "
|
||||||
|
f"user_uuid={current_user.user_uuid}, count={len(social_accounts)}"
|
||||||
|
)
|
||||||
|
raise YouTubeAccountSelectionRequiredError()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[2] YouTube 계정 확인 완료 - platform_user_id={social_account.platform_user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 기간 내 업로드 영상 수 조회
|
||||||
|
count_result = await session.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Dashboard)
|
||||||
|
.where(
|
||||||
|
Dashboard.user_uuid == current_user.user_uuid,
|
||||||
|
Dashboard.platform == "youtube",
|
||||||
|
Dashboard.platform_user_id == social_account.platform_user_id,
|
||||||
|
Dashboard.uploaded_at >= start_dt,
|
||||||
|
Dashboard.uploaded_at < today + timedelta(days=1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
period_video_count = count_result.scalar() or 0
|
||||||
|
|
||||||
|
# 이전 기간 업로드 영상 수 조회 (trend 계산용)
|
||||||
|
prev_count_result = await session.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Dashboard)
|
||||||
|
.where(
|
||||||
|
Dashboard.user_uuid == current_user.user_uuid,
|
||||||
|
Dashboard.platform == "youtube",
|
||||||
|
Dashboard.platform_user_id == social_account.platform_user_id,
|
||||||
|
Dashboard.uploaded_at >= prev_start_dt,
|
||||||
|
Dashboard.uploaded_at <= prev_kpi_end_dt,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
prev_period_video_count = prev_count_result.scalar() or 0
|
||||||
|
logger.debug(
|
||||||
|
f"[3] 기간 내 업로드 영상 수 - current={period_video_count}, prev={prev_period_video_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Redis 캐시 조회
|
||||||
|
# platform_user_id 기준 캐시 키: 재연동해도 채널 ID는 불변 → 캐시 유지됨
|
||||||
|
cache_key = f"dashboard:{current_user.user_uuid}:{social_account.platform_user_id}:{mode}"
|
||||||
|
cached_raw = await get_cache(cache_key)
|
||||||
|
|
||||||
|
if cached_raw:
|
||||||
|
try:
|
||||||
|
payload = json.loads(cached_raw)
|
||||||
|
logger.info(f"[CACHE HIT] 캐시 반환 - user_uuid={current_user.user_uuid}")
|
||||||
|
response = DashboardResponse.model_validate(payload["response"])
|
||||||
|
for metric in response.content_metrics:
|
||||||
|
if metric.id == "uploaded-videos":
|
||||||
|
metric.value = float(period_video_count)
|
||||||
|
video_trend = float(period_video_count - prev_period_video_count)
|
||||||
|
metric.trend = video_trend
|
||||||
|
metric.trend_direction = "up" if video_trend > 0 else ("down" if video_trend < 0 else "-")
|
||||||
|
break
|
||||||
|
return response
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
logger.warning(f"[CACHE PARSE ERROR] 포맷 오류, 무시 - key={cache_key}")
|
||||||
|
|
||||||
|
logger.debug("[4] 캐시 MISS - YouTube API 호출 필요")
|
||||||
|
|
||||||
|
# 5. 최근 30개 업로드 영상 조회 (Analytics API 전달용)
|
||||||
|
# YouTube Analytics API 제약사항:
|
||||||
|
# - 영상 개수: 20~30개 권장 (최대 50개, 그 이상은 응답 지연 발생)
|
||||||
|
# - URL 길이: 2000자 제한 (video ID 11자 × 30개 = 330자로 안전)
|
||||||
|
result = await session.execute(
|
||||||
|
select(
|
||||||
|
Dashboard.platform_video_id,
|
||||||
|
Dashboard.title,
|
||||||
|
Dashboard.uploaded_at,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Dashboard.user_uuid == current_user.user_uuid,
|
||||||
|
Dashboard.platform == "youtube",
|
||||||
|
Dashboard.platform_user_id == social_account.platform_user_id,
|
||||||
|
)
|
||||||
|
.order_by(Dashboard.uploaded_at.desc())
|
||||||
|
.limit(30)
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
logger.debug(f"[5] 영상 조회 완료 - count={len(rows)}")
|
||||||
|
|
||||||
|
# 6. video_ids + 메타데이터 조회용 dict 구성
|
||||||
|
video_ids = []
|
||||||
|
video_lookup: dict[str, tuple[str, datetime]] = {} # {video_id: (title, uploaded_at)}
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
platform_video_id, title, uploaded_at = row
|
||||||
|
video_ids.append(platform_video_id)
|
||||||
|
video_lookup[platform_video_id] = (title, uploaded_at)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[6] 영상 메타데이터 구성 완료 - count={len(video_ids)}, sample={video_ids[:3]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6.1 업로드 영상 없음 → YouTube API 호출 없이 빈 응답 반환
|
||||||
|
if not video_ids:
|
||||||
|
logger.info(
|
||||||
|
f"[DASHBOARD] 업로드 영상 없음, 빈 응답 반환 - "
|
||||||
|
f"user_uuid={current_user.user_uuid}"
|
||||||
|
)
|
||||||
|
return DashboardResponse(
|
||||||
|
content_metrics=[
|
||||||
|
ContentMetric(id="total-views", label="조회수", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
||||||
|
ContentMetric(id="total-watch-time", label="시청시간", value=0.0, unit="hours", trend=0.0, trend_direction="-"),
|
||||||
|
ContentMetric(id="avg-view-duration", label="평균 시청시간", value=0.0, unit="minutes", trend=0.0, trend_direction="-"),
|
||||||
|
ContentMetric(id="new-subscribers", label="신규 구독자", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
||||||
|
ContentMetric(id="likes", label="좋아요", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
||||||
|
ContentMetric(id="comments", label="댓글", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
||||||
|
ContentMetric(id="shares", label="공유", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
||||||
|
ContentMetric(id="uploaded-videos", label="업로드 영상", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
||||||
|
],
|
||||||
|
monthly_data=[],
|
||||||
|
daily_data=[],
|
||||||
|
top_content=[],
|
||||||
|
audience_data=AudienceData(age_groups=[], gender={"male": 0, "female": 0}, top_regions=[]),
|
||||||
|
has_uploads=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 7. 토큰 유효성 확인 및 자동 갱신 (만료 10분 전 갱신)
|
||||||
|
try:
|
||||||
|
access_token = await SocialAccountService().ensure_valid_token(
|
||||||
|
social_account, session
|
||||||
|
)
|
||||||
|
except TokenExpiredError:
|
||||||
|
logger.warning(
|
||||||
|
f"[TOKEN EXPIRED] 재연동 필요 - user_uuid={current_user.user_uuid}"
|
||||||
|
)
|
||||||
|
raise YouTubeTokenExpiredError()
|
||||||
|
|
||||||
|
logger.debug("[7] 토큰 유효성 확인 완료")
|
||||||
|
|
||||||
|
# 8. YouTube Analytics API 호출 (7개 병렬)
|
||||||
|
youtube_service = YouTubeAnalyticsService()
|
||||||
|
raw_data = await youtube_service.fetch_all_metrics(
|
||||||
|
video_ids=video_ids,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
kpi_end_date=kpi_end_date,
|
||||||
|
access_token=access_token,
|
||||||
|
mode=mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug("[8] YouTube Analytics API 호출 완료")
|
||||||
|
|
||||||
|
# 9. TopContent 조립 (Analytics top_videos + DB lookup)
|
||||||
|
processor = DataProcessor()
|
||||||
|
top_content_rows = raw_data.get("top_videos", {}).get("rows", [])
|
||||||
|
top_content: list[TopContent] = []
|
||||||
|
for row in top_content_rows[:4]:
|
||||||
|
if len(row) < 4:
|
||||||
|
continue
|
||||||
|
video_id, views, likes, comments = row[0], row[1], row[2], row[3]
|
||||||
|
meta = video_lookup.get(video_id)
|
||||||
|
if not meta:
|
||||||
|
continue
|
||||||
|
title, uploaded_at = meta
|
||||||
|
engagement_rate = ((likes + comments) / views * 100) if views > 0 else 0
|
||||||
|
top_content.append(
|
||||||
|
TopContent(
|
||||||
|
id=video_id,
|
||||||
|
title=title,
|
||||||
|
thumbnail=f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg",
|
||||||
|
platform="youtube",
|
||||||
|
views=int(views),
|
||||||
|
engagement=f"{engagement_rate:.1f}%",
|
||||||
|
date=uploaded_at.strftime("%Y.%m.%d"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"[9] TopContent 조립 완료 - count={len(top_content)}")
|
||||||
|
|
||||||
|
# 10. 데이터 가공 (period_video_count=0 — API 무관 DB 집계값, 캐시에 포함하지 않음)
|
||||||
|
dashboard_data = processor.process(
|
||||||
|
raw_data, top_content, 0, mode=mode, end_date=end_date
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug("[10] 데이터 가공 완료")
|
||||||
|
|
||||||
|
# 11. Redis 캐싱 (TTL: 12시간)
|
||||||
|
# YouTube Analytics는 하루 1회 갱신 (PT 자정, 한국 시간 오후 5~8시)
|
||||||
|
# 48시간 지연된 데이터이므로 12시간 캐싱으로 API 호출 최소화
|
||||||
|
# period_video_count는 캐시에 포함하지 않음 (DB 직접 집계, API 미사용)
|
||||||
|
cache_payload = json.dumps(
|
||||||
|
{"response": json.loads(dashboard_data.model_dump_json())}
|
||||||
|
)
|
||||||
|
cache_success = await set_cache(
|
||||||
|
cache_key,
|
||||||
|
cache_payload,
|
||||||
|
ttl=43200, # 12시간
|
||||||
|
)
|
||||||
|
|
||||||
|
if cache_success:
|
||||||
|
logger.debug(f"[CACHE SET] 캐시 저장 성공 - key={cache_key}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[CACHE SET] 캐시 저장 실패 - key={cache_key}")
|
||||||
|
|
||||||
|
# 12. 업로드 영상 수 및 trend 주입 (캐시 저장 후 — 항상 DB에서 직접 집계)
|
||||||
|
for metric in dashboard_data.content_metrics:
|
||||||
|
if metric.id == "uploaded-videos":
|
||||||
|
metric.value = float(period_video_count)
|
||||||
|
video_trend = float(period_video_count - prev_period_video_count)
|
||||||
|
metric.trend = video_trend
|
||||||
|
metric.trend_direction = "up" if video_trend > 0 else ("down" if video_trend < 0 else "-")
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[DASHBOARD] 통계 조회 완료 - "
|
||||||
|
f"user_uuid={current_user.user_uuid}, "
|
||||||
|
f"mode={mode}, period={period_desc}, videos={len(video_ids)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return dashboard_data
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
@ -109,7 +483,7 @@ async def get_dashboard_stats(
|
|||||||
`dashboard:{user_uuid}:{platform_user_id}:{mode}` (mode: day 또는 month)
|
`dashboard:{user_uuid}:{platform_user_id}:{mode}` (mode: day 또는 month)
|
||||||
|
|
||||||
## 파라미터
|
## 파라미터
|
||||||
- `user_uuid`: 삭제할 사용자 UUID (필수)
|
- `user_uuid`: 특정 사용자 캐시만 삭제. 미입력 시 전체 삭제
|
||||||
- `mode`: day / month / all (기본값: all)
|
- `mode`: day / month / all (기본값: all)
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
@ -118,16 +492,33 @@ async def delete_dashboard_cache(
|
|||||||
default="all",
|
default="all",
|
||||||
description="삭제할 캐시 모드: day, month, all(기본값, 모두 삭제)",
|
description="삭제할 캐시 모드: day, month, all(기본값, 모두 삭제)",
|
||||||
),
|
),
|
||||||
user_uuid: str = Query(
|
user_uuid: str | None = Query(
|
||||||
description="대상 사용자 UUID",
|
default=None,
|
||||||
|
description="대상 사용자 UUID. 미입력 시 전체 사용자 캐시 삭제",
|
||||||
),
|
),
|
||||||
) -> CacheDeleteResponse:
|
) -> CacheDeleteResponse:
|
||||||
|
"""
|
||||||
|
대시보드 캐시 삭제
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: 삭제할 캐시 모드 (day / month / all)
|
||||||
|
user_uuid: 대상 사용자 UUID (없으면 전체 삭제)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CacheDeleteResponse: 삭제된 캐시 키 개수 및 메시지
|
||||||
|
"""
|
||||||
|
if user_uuid:
|
||||||
if mode == "all":
|
if mode == "all":
|
||||||
deleted = await delete_cache_pattern(f"dashboard:{user_uuid}:*")
|
deleted = await delete_cache_pattern(f"dashboard:{user_uuid}:*")
|
||||||
message = f"전체 캐시 삭제 완료 ({deleted}개)"
|
message = f"전체 캐시 삭제 완료 ({deleted}개)"
|
||||||
else:
|
else:
|
||||||
deleted = await delete_cache_pattern(f"dashboard:{user_uuid}:*:{mode}")
|
cache_key = f"dashboard:{user_uuid}:{mode}"
|
||||||
message = f"{mode} 캐시 삭제 완료 ({deleted}개)"
|
success = await delete_cache(cache_key)
|
||||||
|
deleted = 1 if success else 0
|
||||||
|
message = f"{mode} 캐시 삭제 {'완료' if success else '실패 (키 없음)'}"
|
||||||
|
else:
|
||||||
|
deleted = await delete_cache_pattern("dashboard:*")
|
||||||
|
message = f"전체 사용자 캐시 삭제 완료 ({deleted}개)"
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[CACHE DELETE] user_uuid={user_uuid or 'ALL'}, mode={mode}, deleted={deleted}"
|
f"[CACHE DELETE] user_uuid={user_uuid or 'ALL'}, mode={mode}, deleted={deleted}"
|
||||||
|
|||||||
@ -113,7 +113,7 @@ class YouTubeAccountSelectionRequiredError(DashboardException):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
message="연결된 YouTube 계정이 여러 개입니다. platform_user_id 파라미터로 사용할 계정을 선택해주세요.",
|
message="연결된 YouTube 계정이 여러 개입니다. social_account_id 파라미터로 사용할 계정을 선택해주세요.",
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
code="YOUTUBE_ACCOUNT_SELECTION_REQUIRED",
|
code="YOUTUBE_ACCOUNT_SELECTION_REQUIRED",
|
||||||
)
|
)
|
||||||
|
|||||||
@ -197,6 +197,35 @@ class AudienceData(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# class PlatformMetric(BaseModel):
|
||||||
|
# """플랫폼별 메트릭 (미사용 — platform_data 기능 미구현)"""
|
||||||
|
#
|
||||||
|
# id: str
|
||||||
|
# label: str
|
||||||
|
# value: str
|
||||||
|
# unit: Optional[str] = None
|
||||||
|
# trend: float
|
||||||
|
# trend_direction: Literal["up", "down", "-"] = Field(alias="trendDirection")
|
||||||
|
#
|
||||||
|
# model_config = ConfigDict(
|
||||||
|
# alias_generator=to_camel,
|
||||||
|
# populate_by_name=True,
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# class PlatformData(BaseModel):
|
||||||
|
# """플랫폼별 데이터 (미사용 — platform_data 기능 미구현)"""
|
||||||
|
#
|
||||||
|
# platform: Literal["youtube", "instagram"]
|
||||||
|
# display_name: str = Field(alias="displayName")
|
||||||
|
# metrics: list[PlatformMetric]
|
||||||
|
#
|
||||||
|
# model_config = ConfigDict(
|
||||||
|
# alias_generator=to_camel,
|
||||||
|
# populate_by_name=True,
|
||||||
|
# )
|
||||||
|
|
||||||
|
|
||||||
class DashboardResponse(BaseModel):
|
class DashboardResponse(BaseModel):
|
||||||
"""대시보드 전체 응답
|
"""대시보드 전체 응답
|
||||||
|
|
||||||
@ -226,6 +255,7 @@ class DashboardResponse(BaseModel):
|
|||||||
top_content: list[TopContent] = Field(alias="topContent")
|
top_content: list[TopContent] = Field(alias="topContent")
|
||||||
audience_data: AudienceData = Field(alias="audienceData")
|
audience_data: AudienceData = Field(alias="audienceData")
|
||||||
has_uploads: bool = Field(default=True, alias="hasUploads")
|
has_uploads: bool = Field(default=True, alias="hasUploads")
|
||||||
|
# platform_data: list[PlatformData] = Field(default=[], alias="platformData") # 미사용
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
alias_generator=to_camel,
|
alias_generator=to_camel,
|
||||||
|
|||||||
@ -4,12 +4,10 @@ Dashboard Services
|
|||||||
YouTube Analytics API 연동 및 데이터 가공 서비스를 제공합니다.
|
YouTube Analytics API 연동 및 데이터 가공 서비스를 제공합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from app.dashboard.services.dashboard_service import DashboardService
|
|
||||||
from app.dashboard.services.data_processor import DataProcessor
|
from app.dashboard.services.data_processor import DataProcessor
|
||||||
from app.dashboard.services.youtube_analytics import YouTubeAnalyticsService
|
from app.dashboard.services.youtube_analytics import YouTubeAnalyticsService
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DashboardService",
|
|
||||||
"YouTubeAnalyticsService",
|
"YouTubeAnalyticsService",
|
||||||
"DataProcessor",
|
"DataProcessor",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,358 +0,0 @@
|
|||||||
"""
|
|
||||||
Dashboard Service
|
|
||||||
|
|
||||||
대시보드 비즈니스 로직을 담당합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import date, datetime, timedelta
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.dashboard.exceptions import (
|
|
||||||
YouTubeAccountNotConnectedError,
|
|
||||||
YouTubeAccountNotFoundError,
|
|
||||||
YouTubeAccountSelectionRequiredError,
|
|
||||||
YouTubeTokenExpiredError,
|
|
||||||
)
|
|
||||||
from app.dashboard.models import Dashboard
|
|
||||||
from app.dashboard.utils.redis_cache import get_cache, set_cache
|
|
||||||
from app.dashboard.schemas import (
|
|
||||||
AudienceData,
|
|
||||||
ConnectedAccount,
|
|
||||||
ContentMetric,
|
|
||||||
DashboardResponse,
|
|
||||||
TopContent,
|
|
||||||
)
|
|
||||||
from app.dashboard.services.data_processor import DataProcessor
|
|
||||||
from app.dashboard.services.youtube_analytics import YouTubeAnalyticsService
|
|
||||||
from app.social.exceptions import TokenExpiredError
|
|
||||||
from app.social.services import SocialAccountService
|
|
||||||
from app.user.models import SocialAccount, User
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class DashboardService:
|
|
||||||
async def get_connected_accounts(
|
|
||||||
self,
|
|
||||||
current_user: User,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> list[ConnectedAccount]:
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialAccount).where(
|
|
||||||
SocialAccount.user_uuid == current_user.user_uuid,
|
|
||||||
SocialAccount.platform == "youtube",
|
|
||||||
SocialAccount.is_active == True, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
accounts_raw = result.scalars().all()
|
|
||||||
|
|
||||||
connected = []
|
|
||||||
for acc in accounts_raw:
|
|
||||||
data = acc.platform_data if isinstance(acc.platform_data, dict) else {}
|
|
||||||
connected.append(
|
|
||||||
ConnectedAccount(
|
|
||||||
id=acc.id,
|
|
||||||
platform=acc.platform,
|
|
||||||
platform_username=acc.platform_username,
|
|
||||||
platform_user_id=acc.platform_user_id,
|
|
||||||
channel_title=data.get("channel_title"),
|
|
||||||
connected_at=acc.connected_at,
|
|
||||||
is_active=acc.is_active,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[ACCOUNTS] YouTube 계정 목록 조회 - "
|
|
||||||
f"user_uuid={current_user.user_uuid}, count={len(connected)}"
|
|
||||||
)
|
|
||||||
return connected
|
|
||||||
|
|
||||||
def calculate_date_range(
|
|
||||||
self, mode: Literal["day", "month"]
|
|
||||||
) -> tuple[date, date, date, date, date, str]:
|
|
||||||
"""모드별 날짜 범위 계산. (start_dt, end_dt, kpi_end_dt, prev_start_dt, prev_kpi_end_dt, period_desc) 반환"""
|
|
||||||
today = date.today()
|
|
||||||
|
|
||||||
if mode == "day":
|
|
||||||
end_dt = today - timedelta(days=2)
|
|
||||||
kpi_end_dt = end_dt
|
|
||||||
start_dt = end_dt - timedelta(days=29)
|
|
||||||
prev_start_dt = start_dt - timedelta(days=30)
|
|
||||||
prev_kpi_end_dt = kpi_end_dt - timedelta(days=30)
|
|
||||||
period_desc = "최근 30일"
|
|
||||||
else:
|
|
||||||
end_dt = today.replace(day=1)
|
|
||||||
kpi_end_dt = today - timedelta(days=2)
|
|
||||||
start_month = end_dt.month - 11
|
|
||||||
if start_month <= 0:
|
|
||||||
start_month += 12
|
|
||||||
start_year = end_dt.year - 1
|
|
||||||
else:
|
|
||||||
start_year = end_dt.year
|
|
||||||
start_dt = date(start_year, start_month, 1)
|
|
||||||
prev_start_dt = start_dt.replace(year=start_dt.year - 1)
|
|
||||||
try:
|
|
||||||
prev_kpi_end_dt = kpi_end_dt.replace(year=kpi_end_dt.year - 1)
|
|
||||||
except ValueError:
|
|
||||||
prev_kpi_end_dt = kpi_end_dt.replace(year=kpi_end_dt.year - 1, day=28)
|
|
||||||
period_desc = "최근 12개월"
|
|
||||||
|
|
||||||
return start_dt, end_dt, kpi_end_dt, prev_start_dt, prev_kpi_end_dt, period_desc
|
|
||||||
|
|
||||||
async def resolve_social_account(
|
|
||||||
self,
|
|
||||||
current_user: User,
|
|
||||||
session: AsyncSession,
|
|
||||||
platform_user_id: str | None,
|
|
||||||
) -> SocialAccount:
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialAccount).where(
|
|
||||||
SocialAccount.user_uuid == current_user.user_uuid,
|
|
||||||
SocialAccount.platform == "youtube",
|
|
||||||
SocialAccount.is_active == True, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
social_accounts_raw = result.scalars().all()
|
|
||||||
|
|
||||||
social_accounts = list(social_accounts_raw)
|
|
||||||
|
|
||||||
if not social_accounts:
|
|
||||||
raise YouTubeAccountNotConnectedError()
|
|
||||||
|
|
||||||
if platform_user_id is not None:
|
|
||||||
matched = [a for a in social_accounts if a.platform_user_id == platform_user_id]
|
|
||||||
if not matched:
|
|
||||||
raise YouTubeAccountNotFoundError()
|
|
||||||
return matched[0]
|
|
||||||
elif len(social_accounts) == 1:
|
|
||||||
return social_accounts[0]
|
|
||||||
else:
|
|
||||||
raise YouTubeAccountSelectionRequiredError()
|
|
||||||
|
|
||||||
async def get_video_counts(
|
|
||||||
self,
|
|
||||||
current_user: User,
|
|
||||||
session: AsyncSession,
|
|
||||||
social_account: SocialAccount,
|
|
||||||
start_dt: date,
|
|
||||||
prev_start_dt: date,
|
|
||||||
prev_kpi_end_dt: date,
|
|
||||||
) -> tuple[int, int]:
|
|
||||||
today = date.today()
|
|
||||||
count_result = await session.execute(
|
|
||||||
select(func.count())
|
|
||||||
.select_from(Dashboard)
|
|
||||||
.where(
|
|
||||||
Dashboard.user_uuid == current_user.user_uuid,
|
|
||||||
Dashboard.platform == "youtube",
|
|
||||||
Dashboard.platform_user_id == social_account.platform_user_id,
|
|
||||||
Dashboard.uploaded_at >= start_dt,
|
|
||||||
Dashboard.uploaded_at < today + timedelta(days=1),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
period_video_count = count_result.scalar() or 0
|
|
||||||
|
|
||||||
prev_count_result = await session.execute(
|
|
||||||
select(func.count())
|
|
||||||
.select_from(Dashboard)
|
|
||||||
.where(
|
|
||||||
Dashboard.user_uuid == current_user.user_uuid,
|
|
||||||
Dashboard.platform == "youtube",
|
|
||||||
Dashboard.platform_user_id == social_account.platform_user_id,
|
|
||||||
Dashboard.uploaded_at >= prev_start_dt,
|
|
||||||
Dashboard.uploaded_at <= prev_kpi_end_dt,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
prev_period_video_count = prev_count_result.scalar() or 0
|
|
||||||
|
|
||||||
return period_video_count, prev_period_video_count
|
|
||||||
|
|
||||||
async def get_video_ids(
|
|
||||||
self,
|
|
||||||
current_user: User,
|
|
||||||
session: AsyncSession,
|
|
||||||
social_account: SocialAccount,
|
|
||||||
) -> tuple[list[str], dict[str, tuple[str, datetime]]]:
|
|
||||||
result = await session.execute(
|
|
||||||
select(
|
|
||||||
Dashboard.platform_video_id,
|
|
||||||
Dashboard.title,
|
|
||||||
Dashboard.uploaded_at,
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
Dashboard.user_uuid == current_user.user_uuid,
|
|
||||||
Dashboard.platform == "youtube",
|
|
||||||
Dashboard.platform_user_id == social_account.platform_user_id,
|
|
||||||
)
|
|
||||||
.order_by(Dashboard.uploaded_at.desc())
|
|
||||||
.limit(30)
|
|
||||||
)
|
|
||||||
rows = result.all()
|
|
||||||
|
|
||||||
video_ids = []
|
|
||||||
video_lookup: dict[str, tuple[str, datetime]] = {}
|
|
||||||
for row in rows:
|
|
||||||
platform_video_id, title, uploaded_at = row
|
|
||||||
video_ids.append(platform_video_id)
|
|
||||||
video_lookup[platform_video_id] = (title, uploaded_at)
|
|
||||||
|
|
||||||
return video_ids, video_lookup
|
|
||||||
|
|
||||||
def build_empty_response(self) -> DashboardResponse:
|
|
||||||
return DashboardResponse(
|
|
||||||
content_metrics=[
|
|
||||||
ContentMetric(id="total-views", label="조회수", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
|
||||||
ContentMetric(id="total-watch-time", label="시청시간", value=0.0, unit="hours", trend=0.0, trend_direction="-"),
|
|
||||||
ContentMetric(id="avg-view-duration", label="평균 시청시간", value=0.0, unit="minutes", trend=0.0, trend_direction="-"),
|
|
||||||
ContentMetric(id="new-subscribers", label="신규 구독자", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
|
||||||
ContentMetric(id="likes", label="좋아요", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
|
||||||
ContentMetric(id="comments", label="댓글", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
|
||||||
ContentMetric(id="shares", label="공유", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
|
||||||
ContentMetric(id="uploaded-videos", label="업로드 영상", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
|
||||||
],
|
|
||||||
monthly_data=[],
|
|
||||||
daily_data=[],
|
|
||||||
top_content=[],
|
|
||||||
audience_data=AudienceData(age_groups=[], gender={"male": 0, "female": 0}, top_regions=[]),
|
|
||||||
has_uploads=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def inject_video_count(
|
|
||||||
self,
|
|
||||||
response: DashboardResponse,
|
|
||||||
period_video_count: int,
|
|
||||||
prev_period_video_count: int,
|
|
||||||
) -> None:
|
|
||||||
for metric in response.content_metrics:
|
|
||||||
if metric.id == "uploaded-videos":
|
|
||||||
metric.value = float(period_video_count)
|
|
||||||
video_trend = float(period_video_count - prev_period_video_count)
|
|
||||||
metric.trend = video_trend
|
|
||||||
metric.trend_direction = "up" if video_trend > 0 else ("down" if video_trend < 0 else "-")
|
|
||||||
break
|
|
||||||
|
|
||||||
async def get_stats(
|
|
||||||
self,
|
|
||||||
mode: Literal["day", "month"],
|
|
||||||
platform_user_id: str | None,
|
|
||||||
current_user: User,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> DashboardResponse:
|
|
||||||
logger.info(
|
|
||||||
f"[DASHBOARD] 통계 조회 시작 - "
|
|
||||||
f"user_uuid={current_user.user_uuid}, mode={mode}, platform_user_id={platform_user_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 1. 날짜 계산
|
|
||||||
start_dt, end_dt, kpi_end_dt, prev_start_dt, prev_kpi_end_dt, period_desc = (
|
|
||||||
self.calculate_date_range(mode)
|
|
||||||
)
|
|
||||||
start_date = start_dt.strftime("%Y-%m-%d")
|
|
||||||
end_date = end_dt.strftime("%Y-%m-%d")
|
|
||||||
kpi_end_date = kpi_end_dt.strftime("%Y-%m-%d")
|
|
||||||
logger.debug(f"[1] 날짜 계산 완료 - period={period_desc}, start={start_date}, end={end_date}")
|
|
||||||
|
|
||||||
# 2. YouTube 계정 확인
|
|
||||||
social_account = await self.resolve_social_account(current_user, session, platform_user_id)
|
|
||||||
logger.debug(f"[2] YouTube 계정 확인 완료 - platform_user_id={social_account.platform_user_id}")
|
|
||||||
|
|
||||||
# 3. 영상 수 조회
|
|
||||||
period_video_count, prev_period_video_count = await self.get_video_counts(
|
|
||||||
current_user, session, social_account, start_dt, prev_start_dt, prev_kpi_end_dt
|
|
||||||
)
|
|
||||||
logger.debug(f"[3] 영상 수 - current={period_video_count}, prev={prev_period_video_count}")
|
|
||||||
|
|
||||||
# 4. 캐시 조회
|
|
||||||
cache_key = f"dashboard:{current_user.user_uuid}:{social_account.platform_user_id}:{mode}"
|
|
||||||
cached_raw = await get_cache(cache_key)
|
|
||||||
if cached_raw:
|
|
||||||
try:
|
|
||||||
payload = json.loads(cached_raw)
|
|
||||||
logger.info(f"[CACHE HIT] 캐시 반환 - user_uuid={current_user.user_uuid}")
|
|
||||||
response = DashboardResponse.model_validate(payload["response"])
|
|
||||||
self.inject_video_count(response, period_video_count, prev_period_video_count)
|
|
||||||
return response
|
|
||||||
except (json.JSONDecodeError, KeyError):
|
|
||||||
logger.warning(f"[CACHE PARSE ERROR] 포맷 오류, 무시 - key={cache_key}")
|
|
||||||
|
|
||||||
logger.debug("[4] 캐시 MISS - YouTube API 호출 필요")
|
|
||||||
|
|
||||||
# 5. 업로드 영상 조회
|
|
||||||
video_ids, video_lookup = await self.get_video_ids(current_user, session, social_account)
|
|
||||||
logger.debug(f"[5] 영상 조회 완료 - count={len(video_ids)}")
|
|
||||||
|
|
||||||
if not video_ids:
|
|
||||||
logger.info(f"[DASHBOARD] 업로드 영상 없음, 빈 응답 반환 - user_uuid={current_user.user_uuid}")
|
|
||||||
return self.build_empty_response()
|
|
||||||
|
|
||||||
# 6. 토큰 유효성 확인
|
|
||||||
try:
|
|
||||||
access_token = await SocialAccountService().ensure_valid_token(social_account, session)
|
|
||||||
except TokenExpiredError:
|
|
||||||
logger.warning(f"[TOKEN EXPIRED] 재연동 필요 - user_uuid={current_user.user_uuid}")
|
|
||||||
raise YouTubeTokenExpiredError()
|
|
||||||
logger.debug("[6] 토큰 유효성 확인 완료")
|
|
||||||
|
|
||||||
# 7. YouTube Analytics API 호출
|
|
||||||
youtube_service = YouTubeAnalyticsService()
|
|
||||||
raw_data = await youtube_service.fetch_all_metrics(
|
|
||||||
video_ids=video_ids,
|
|
||||||
start_date=start_date,
|
|
||||||
end_date=end_date,
|
|
||||||
kpi_end_date=kpi_end_date,
|
|
||||||
access_token=access_token,
|
|
||||||
mode=mode,
|
|
||||||
)
|
|
||||||
logger.debug("[7] YouTube Analytics API 호출 완료")
|
|
||||||
|
|
||||||
# 8. TopContent 조립
|
|
||||||
processor = DataProcessor()
|
|
||||||
top_content_rows = raw_data.get("top_videos", {}).get("rows", [])
|
|
||||||
top_content: list[TopContent] = []
|
|
||||||
for row in top_content_rows[:4]:
|
|
||||||
if len(row) < 4:
|
|
||||||
continue
|
|
||||||
video_id, views, likes, comments = row[0], row[1], row[2], row[3]
|
|
||||||
meta = video_lookup.get(video_id)
|
|
||||||
if not meta:
|
|
||||||
continue
|
|
||||||
title, uploaded_at = meta
|
|
||||||
engagement_rate = ((likes + comments) / views * 100) if views > 0 else 0
|
|
||||||
top_content.append(
|
|
||||||
TopContent(
|
|
||||||
id=video_id,
|
|
||||||
title=title,
|
|
||||||
thumbnail=f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg",
|
|
||||||
platform="youtube",
|
|
||||||
views=int(views),
|
|
||||||
engagement=f"{engagement_rate:.1f}%",
|
|
||||||
date=uploaded_at.strftime("%Y.%m.%d"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
logger.debug(f"[8] TopContent 조립 완료 - count={len(top_content)}")
|
|
||||||
|
|
||||||
# 9. 데이터 가공
|
|
||||||
dashboard_data = processor.process(raw_data, top_content, 0, mode=mode, end_date=end_date)
|
|
||||||
logger.debug("[9] 데이터 가공 완료")
|
|
||||||
|
|
||||||
# 10. 캐시 저장
|
|
||||||
cache_payload = json.dumps({"response": dashboard_data.model_dump(mode="json")})
|
|
||||||
cache_success = await set_cache(cache_key, cache_payload, ttl=43200)
|
|
||||||
if cache_success:
|
|
||||||
logger.debug(f"[CACHE SET] 캐시 저장 성공 - key={cache_key}")
|
|
||||||
else:
|
|
||||||
logger.warning(f"[CACHE SET] 캐시 저장 실패 - key={cache_key}")
|
|
||||||
|
|
||||||
# 11. 업로드 영상 수 주입
|
|
||||||
self.inject_video_count(dashboard_data, period_video_count, prev_period_video_count)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[DASHBOARD] 통계 조회 완료 - "
|
|
||||||
f"user_uuid={current_user.user_uuid}, mode={mode}, period={period_desc}, videos={len(video_ids)}"
|
|
||||||
)
|
|
||||||
return dashboard_data
|
|
||||||
@ -143,8 +143,8 @@ class DataProcessor:
|
|||||||
monthly_data = []
|
monthly_data = []
|
||||||
|
|
||||||
audience_data = self._build_audience_data(
|
audience_data = self._build_audience_data(
|
||||||
raw_data.get("demographics") or {},
|
raw_data.get("demographics", {}),
|
||||||
raw_data.get("region") or {},
|
raw_data.get("region", {}),
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[DataProcessor.process] SUCCESS - "
|
f"[DataProcessor.process] SUCCESS - "
|
||||||
|
|||||||
@ -141,9 +141,6 @@ class YouTubeAnalyticsService:
|
|||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
# 에러 체크 (YouTubeAuthError, YouTubeQuotaExceededError는 원형 그대로 전파)
|
# 에러 체크 (YouTubeAuthError, YouTubeQuotaExceededError는 원형 그대로 전파)
|
||||||
# demographics(index 5)는 YouTubeAPIError 시 None으로 허용 (YouTube 서버 간헐적 오류 대응)
|
|
||||||
OPTIONAL_INDICES = {5, 6} # demographics, region
|
|
||||||
results = list(results)
|
|
||||||
for i, result in enumerate(results):
|
for i, result in enumerate(results):
|
||||||
if isinstance(result, Exception):
|
if isinstance(result, Exception):
|
||||||
logger.error(
|
logger.error(
|
||||||
@ -151,12 +148,6 @@ class YouTubeAnalyticsService:
|
|||||||
)
|
)
|
||||||
if isinstance(result, (YouTubeAuthError, YouTubeQuotaExceededError)):
|
if isinstance(result, (YouTubeAuthError, YouTubeQuotaExceededError)):
|
||||||
raise result
|
raise result
|
||||||
if i in OPTIONAL_INDICES and isinstance(result, YouTubeAPIError):
|
|
||||||
logger.warning(
|
|
||||||
f"[YouTubeAnalyticsService] 선택적 API 호출 {i+1}/7 실패, None으로 처리: {result}"
|
|
||||||
)
|
|
||||||
results[i] = None
|
|
||||||
continue
|
|
||||||
raise YouTubeAPIError(f"데이터 조회 실패: {result.__class__.__name__}")
|
raise YouTubeAPIError(f"데이터 조회 실패: {result.__class__.__name__}")
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@ -433,10 +424,6 @@ class YouTubeAnalyticsService:
|
|||||||
logger.debug("[YouTubeAnalyticsService._fetch_region] SUCCESS")
|
logger.debug("[YouTubeAnalyticsService._fetch_region] SUCCESS")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# 5xx/네트워크 오류 재시도 설정 (구글 backendError 등 일시적 장애 대응)
|
|
||||||
_MAX_RETRIES = 3
|
|
||||||
_RETRY_BACKOFF_SECONDS = (0.5, 1.0, 2.0)
|
|
||||||
|
|
||||||
async def _call_api(
|
async def _call_api(
|
||||||
self,
|
self,
|
||||||
params: dict[str, str],
|
params: dict[str, str],
|
||||||
@ -457,18 +444,15 @@ class YouTubeAnalyticsService:
|
|||||||
Raises:
|
Raises:
|
||||||
YouTubeQuotaExceededError: 할당량 초과 (429)
|
YouTubeQuotaExceededError: 할당량 초과 (429)
|
||||||
YouTubeAuthError: 인증 실패 (401, 403)
|
YouTubeAuthError: 인증 실패 (401, 403)
|
||||||
YouTubeAPIError: 기타 API 오류 (5xx/네트워크 오류는 최대 3회 재시도 후 발생)
|
YouTubeAPIError: 기타 API 오류
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
- 타임아웃: 30초
|
- 타임아웃: 30초
|
||||||
- 할당량 초과 시 자동으로 YouTubeQuotaExceededError 발생
|
- 할당량 초과 시 자동으로 YouTubeQuotaExceededError 발생
|
||||||
- 인증 실패 시 자동으로 YouTubeAuthError 발생
|
- 인증 실패 시 자동으로 YouTubeAuthError 발생
|
||||||
- 5xx 응답 및 네트워크 오류는 지수 백오프(0.5s→1s→2s)로 최대 3회 재시도
|
|
||||||
"""
|
"""
|
||||||
headers = {"Authorization": f"Bearer {access_token}"}
|
headers = {"Authorization": f"Bearer {access_token}"}
|
||||||
|
|
||||||
last_error: Exception | None = None
|
|
||||||
for attempt in range(self._MAX_RETRIES + 1):
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
@ -495,29 +479,16 @@ class YouTubeAnalyticsService:
|
|||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
except (YouTubeAuthError, YouTubeQuotaExceededError):
|
except (YouTubeAuthError, YouTubeQuotaExceededError):
|
||||||
raise # 이미 처리된 예외는 재시도 없이 그대로 전파
|
raise # 이미 처리된 예외는 그대로 전파
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[YouTubeAnalyticsService._call_api] HTTP_ERROR - "
|
f"[YouTubeAnalyticsService._call_api] HTTP_ERROR - "
|
||||||
f"status={e.response.status_code}, body={e.response.text[:500]}"
|
f"status={e.response.status_code}, body={e.response.text[:500]}"
|
||||||
)
|
)
|
||||||
# 4xx는 재시도해도 동일하게 실패하므로 즉시 전파, 5xx만 재시도
|
|
||||||
if e.response.status_code < 500:
|
|
||||||
raise YouTubeAPIError(f"HTTP {e.response.status_code}")
|
raise YouTubeAPIError(f"HTTP {e.response.status_code}")
|
||||||
last_error = YouTubeAPIError(f"HTTP {e.response.status_code}")
|
|
||||||
except httpx.RequestError as e:
|
except httpx.RequestError as e:
|
||||||
logger.error(f"[YouTubeAnalyticsService._call_api] REQUEST_ERROR - {e}")
|
logger.error(f"[YouTubeAnalyticsService._call_api] REQUEST_ERROR - {e}")
|
||||||
last_error = YouTubeAPIError(f"네트워크 오류: {e}")
|
raise YouTubeAPIError(f"네트워크 오류: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[YouTubeAnalyticsService._call_api] UNEXPECTED_ERROR - {e}")
|
logger.error(f"[YouTubeAnalyticsService._call_api] UNEXPECTED_ERROR - {e}")
|
||||||
raise YouTubeAPIError(f"알 수 없는 오류: {e}")
|
raise YouTubeAPIError(f"알 수 없는 오류: {e}")
|
||||||
|
|
||||||
if attempt < self._MAX_RETRIES:
|
|
||||||
backoff = self._RETRY_BACKOFF_SECONDS[attempt]
|
|
||||||
logger.warning(
|
|
||||||
f"[YouTubeAnalyticsService._call_api] RETRY {attempt + 1}/{self._MAX_RETRIES} "
|
|
||||||
f"in {backoff}s - {last_error}"
|
|
||||||
)
|
|
||||||
await asyncio.sleep(backoff)
|
|
||||||
|
|
||||||
raise last_error
|
|
||||||
|
|||||||
@ -1,235 +0,0 @@
|
|||||||
"""
|
|
||||||
좋아요 Redis 캐시 클라이언트
|
|
||||||
|
|
||||||
Write-Behind 패턴 적용:
|
|
||||||
- 토글 시 Redis를 즉시 업데이트하고 dirty SET에 표시
|
|
||||||
- 스케줄러가 1분마다 dirty 항목을 MySQL에 bulk write
|
|
||||||
|
|
||||||
Key 패턴:
|
|
||||||
- video:like:count:{video_id} INT — 좋아요 카운트
|
|
||||||
- video:like:users:{video_id} SET — 좋아요 누른 user_uuid 목록
|
|
||||||
- video:reaction:dirty SET — DB 동기화 대기 "{video_id}:{user_uuid}"
|
|
||||||
- video:reaction:dirty:processing SET — 플러시 중 임시 (크래시 복구용)
|
|
||||||
|
|
||||||
캐시 미스(Redis 재시작 등) 시 호출부에서 DB 조회 후 backfill_user_set() / set_like_count()로 복구합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import redis.asyncio as aioredis
|
|
||||||
|
|
||||||
from config import db_settings
|
|
||||||
|
|
||||||
_client: aioredis.Redis | None = None
|
|
||||||
|
|
||||||
# 원자적 토글 Lua 스크립트 — 동시 더블클릭 race condition 방지
|
|
||||||
_TOGGLE_LIKE_SCRIPT = """
|
|
||||||
local user_key = KEYS[1]
|
|
||||||
local count_key = KEYS[2]
|
|
||||||
local user_uuid = ARGV[1]
|
|
||||||
|
|
||||||
if redis.call('SISMEMBER', user_key, user_uuid) == 1 then
|
|
||||||
redis.call('SREM', user_key, user_uuid)
|
|
||||||
local c = tonumber(redis.call('DECR', count_key))
|
|
||||||
if c < 0 then
|
|
||||||
redis.call('SET', count_key, 0)
|
|
||||||
c = 0
|
|
||||||
end
|
|
||||||
return {0, c}
|
|
||||||
else
|
|
||||||
redis.call('SADD', user_key, user_uuid)
|
|
||||||
local c = tonumber(redis.call('INCR', count_key))
|
|
||||||
return {1, c}
|
|
||||||
end
|
|
||||||
"""
|
|
||||||
|
|
||||||
_DIRTY_KEY = "video:reaction:dirty"
|
|
||||||
_DIRTY_PROCESSING_KEY = "video:reaction:dirty:processing"
|
|
||||||
|
|
||||||
|
|
||||||
def get_like_cache() -> aioredis.Redis:
|
|
||||||
global _client
|
|
||||||
if _client is None:
|
|
||||||
_client = aioredis.Redis(
|
|
||||||
host=db_settings.REDIS_HOST,
|
|
||||||
port=db_settings.REDIS_PORT,
|
|
||||||
db=2,
|
|
||||||
decode_responses=True,
|
|
||||||
)
|
|
||||||
return _client
|
|
||||||
|
|
||||||
|
|
||||||
async def close_like_cache() -> None:
|
|
||||||
global _client
|
|
||||||
if _client:
|
|
||||||
await _client.aclose()
|
|
||||||
_client = None
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# Key 헬퍼
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _key(video_id: int) -> str:
|
|
||||||
return f"video:like:count:{video_id}"
|
|
||||||
|
|
||||||
|
|
||||||
def _user_key(video_id: int) -> str:
|
|
||||||
return f"video:like:users:{video_id}"
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# 카운트 (기존 API 유지)
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def get_like_count(video_id: int) -> int | None:
|
|
||||||
"""Redis에서 like_count 조회. 캐시 미스 시 None 반환."""
|
|
||||||
val = await get_like_cache().get(_key(video_id))
|
|
||||||
if val is None:
|
|
||||||
return None
|
|
||||||
return max(int(val), 0)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_like_counts(video_ids: list[int]) -> dict[int, int | None]:
|
|
||||||
"""여러 영상의 like_count를 한 번에 조회 (mget).
|
|
||||||
캐시 미스인 video_id는 None으로 반환."""
|
|
||||||
if not video_ids:
|
|
||||||
return {}
|
|
||||||
keys = [_key(vid) for vid in video_ids]
|
|
||||||
values = await get_like_cache().mget(*keys)
|
|
||||||
return {
|
|
||||||
vid: max(int(v), 0) if v is not None else None
|
|
||||||
for vid, v in zip(video_ids, values)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def set_like_count(video_id: int, count: int) -> None:
|
|
||||||
"""like_count를 Redis에 저장 (음수 방지)."""
|
|
||||||
await get_like_cache().set(_key(video_id), max(count, 0))
|
|
||||||
|
|
||||||
|
|
||||||
async def mset_like_counts(counts: dict[int, int]) -> None:
|
|
||||||
"""여러 영상의 like_count를 한 번에 저장 (mset)."""
|
|
||||||
if not counts:
|
|
||||||
return
|
|
||||||
await get_like_cache().mset({_key(vid): max(cnt, 0) for vid, cnt in counts.items()})
|
|
||||||
|
|
||||||
|
|
||||||
async def incr_like_count(video_id: int) -> int:
|
|
||||||
"""like_count를 1 증가 후 반환."""
|
|
||||||
return max(int(await get_like_cache().incr(_key(video_id))), 0)
|
|
||||||
|
|
||||||
|
|
||||||
async def decr_like_count(video_id: int) -> int:
|
|
||||||
"""like_count를 1 감소 후 반환 (음수 방지)."""
|
|
||||||
count = int(await get_like_cache().decr(_key(video_id)))
|
|
||||||
if count < 0:
|
|
||||||
await get_like_cache().set(_key(video_id), 0)
|
|
||||||
return 0
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# 유저 SET (is_liked_by_me source of truth)
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def toggle_like_atomic(video_id: int, user_uuid: str) -> tuple[bool, int]:
|
|
||||||
"""Lua 스크립트로 원자적 좋아요 토글.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(is_liked, new_count) 튜플
|
|
||||||
"""
|
|
||||||
result = await get_like_cache().eval(
|
|
||||||
_TOGGLE_LIKE_SCRIPT,
|
|
||||||
2,
|
|
||||||
_user_key(video_id),
|
|
||||||
_key(video_id),
|
|
||||||
user_uuid,
|
|
||||||
)
|
|
||||||
return bool(result[0]), int(result[1])
|
|
||||||
|
|
||||||
|
|
||||||
async def is_user_liked(video_id: int, user_uuid: str) -> bool | None:
|
|
||||||
"""Redis user-set에서 좋아요 여부 조회.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True/False: 조회 성공
|
|
||||||
None: user-set 키가 없음 (cold-start backfill 필요 신호)
|
|
||||||
"""
|
|
||||||
client = get_like_cache()
|
|
||||||
key = _user_key(video_id)
|
|
||||||
if not await client.exists(key):
|
|
||||||
return None
|
|
||||||
return bool(await client.sismember(key, user_uuid))
|
|
||||||
|
|
||||||
|
|
||||||
async def is_user_set_exists(video_id: int) -> bool:
|
|
||||||
"""Redis user-set 키 존재 여부 확인."""
|
|
||||||
return bool(await get_like_cache().exists(_user_key(video_id)))
|
|
||||||
|
|
||||||
|
|
||||||
async def bulk_is_user_liked(
|
|
||||||
video_ids: list[int], user_uuid: str
|
|
||||||
) -> dict[int, bool | None]:
|
|
||||||
"""여러 영상의 is_liked 여부를 한 번에 조회 (pipeline).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{video_id: True/False} — user-set 키가 없는 영상은 None
|
|
||||||
"""
|
|
||||||
if not video_ids:
|
|
||||||
return {}
|
|
||||||
client = get_like_cache()
|
|
||||||
async with client.pipeline(transaction=False) as pipe:
|
|
||||||
for vid in video_ids:
|
|
||||||
pipe.exists(_user_key(vid))
|
|
||||||
pipe.sismember(_user_key(vid), user_uuid)
|
|
||||||
responses = await pipe.execute()
|
|
||||||
|
|
||||||
return {
|
|
||||||
vid: (bool(responses[i * 2 + 1]) if responses[i * 2] else None)
|
|
||||||
for i, vid in enumerate(video_ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def backfill_user_set(video_id: int, user_uuids: list[str]) -> None:
|
|
||||||
"""DB에서 가져온 유저 목록을 Redis SET에 일괄 적재."""
|
|
||||||
if user_uuids:
|
|
||||||
await get_like_cache().sadd(_user_key(video_id), *user_uuids)
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# Dirty SET (Write-Behind 큐)
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def mark_dirty(video_id: int, user_uuid: str) -> None:
|
|
||||||
"""DB 동기화 대기 목록에 추가."""
|
|
||||||
await get_like_cache().sadd(_DIRTY_KEY, f"{video_id}:{user_uuid}")
|
|
||||||
|
|
||||||
|
|
||||||
async def drain_dirty() -> list[tuple[int, str]]:
|
|
||||||
"""dirty SET을 processing으로 RENAME 후 전체 반환.
|
|
||||||
|
|
||||||
이전 실행 중 크래시로 남은 processing 항목은 먼저 병합하여 유실 방지.
|
|
||||||
"""
|
|
||||||
client = get_like_cache()
|
|
||||||
|
|
||||||
# 이전 크래시 잔여 항목 병합
|
|
||||||
if await client.exists(_DIRTY_PROCESSING_KEY):
|
|
||||||
await client.sunionstore(_DIRTY_KEY, _DIRTY_KEY, _DIRTY_PROCESSING_KEY)
|
|
||||||
await client.delete(_DIRTY_PROCESSING_KEY)
|
|
||||||
|
|
||||||
if not await client.exists(_DIRTY_KEY):
|
|
||||||
return []
|
|
||||||
|
|
||||||
# RENAME으로 플러시 중 새로 들어오는 토글과 분리
|
|
||||||
await client.rename(_DIRTY_KEY, _DIRTY_PROCESSING_KEY)
|
|
||||||
members = await client.smembers(_DIRTY_PROCESSING_KEY)
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for member in members:
|
|
||||||
vid_str, user_uuid = member.split(":", 1)
|
|
||||||
result.append((int(vid_str), user_uuid))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def commit_dirty_processing() -> None:
|
|
||||||
"""DB 반영 완료 후 processing SET 삭제."""
|
|
||||||
await get_like_cache().delete(_DIRTY_PROCESSING_KEY)
|
|
||||||
@ -1,13 +1,9 @@
|
|||||||
import time
|
import time
|
||||||
import traceback
|
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from app.dashboard.exceptions import DashboardException
|
|
||||||
from app.social.exceptions import SocialException
|
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from config import db_settings
|
from config import db_settings
|
||||||
|
|
||||||
@ -28,10 +24,12 @@ engine = create_async_engine(
|
|||||||
max_overflow=20, # 추가 연결: 20 (총 최대 40)
|
max_overflow=20, # 추가 연결: 20 (총 최대 40)
|
||||||
pool_timeout=30, # 풀에서 연결 대기 시간 (초)
|
pool_timeout=30, # 풀에서 연결 대기 시간 (초)
|
||||||
pool_recycle=280, # MySQL wait_timeout(기본 28800s, 클라우드는 보통 300s) 보다 짧게 설정
|
pool_recycle=280, # MySQL wait_timeout(기본 28800s, 클라우드는 보통 300s) 보다 짧게 설정
|
||||||
|
pool_use_lifo=True,
|
||||||
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
|
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
|
||||||
pool_reset_on_return="rollback", # 반환 시 롤백으로 초기화
|
pool_reset_on_return="rollback", # 반환 시 롤백으로 초기화
|
||||||
connect_args={
|
connect_args={
|
||||||
"connect_timeout": 10, # DB 연결 타임아웃
|
"connect_timeout": 10, # DB 연결 타임아웃
|
||||||
|
"read_timeout": 30,
|
||||||
"charset": "utf8mb4",
|
"charset": "utf8mb4",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -55,10 +53,12 @@ background_engine = create_async_engine(
|
|||||||
max_overflow=10, # 추가 연결: 10 (총 최대 20)
|
max_overflow=10, # 추가 연결: 10 (총 최대 20)
|
||||||
pool_timeout=60, # 백그라운드는 대기 시간 여유있게
|
pool_timeout=60, # 백그라운드는 대기 시간 여유있게
|
||||||
pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정
|
pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정
|
||||||
|
pool_use_lifo=True,
|
||||||
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
|
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
|
||||||
pool_reset_on_return="rollback",
|
pool_reset_on_return="rollback",
|
||||||
connect_args={
|
connect_args={
|
||||||
"connect_timeout": 10,
|
"connect_timeout": 10,
|
||||||
|
"read_timeout": 30,
|
||||||
"charset": "utf8mb4",
|
"charset": "utf8mb4",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -77,18 +77,15 @@ async def create_db_tables():
|
|||||||
|
|
||||||
# 모델 import (테이블 메타데이터 등록용)
|
# 모델 import (테이블 메타데이터 등록용)
|
||||||
from app.user.models import User, RefreshToken, SocialAccount # noqa: F401
|
from app.user.models import User, RefreshToken, SocialAccount # noqa: F401
|
||||||
from app.home.models import Image, Project, MarketingIntel, ImageTag # noqa: F401
|
from app.home.models import Image, Project, MarketingIntel # noqa: F401
|
||||||
from app.lyric.models import Lyric # noqa: F401
|
from app.lyric.models import Lyric # noqa: F401
|
||||||
from app.song.models import Song, SongTimestamp # noqa: F401
|
from app.song.models import Song, SongTimestamp # noqa: F401
|
||||||
from app.video.models import Video, VideoReaction # noqa: F401
|
from app.video.models import Video # noqa: F401
|
||||||
from app.comment.models import Comment # noqa: F401
|
|
||||||
from app.sns.models import SNSUploadTask # noqa: F401
|
from app.sns.models import SNSUploadTask # noqa: F401
|
||||||
from app.social.models import SocialUpload # noqa: F401
|
from app.social.models import SocialUpload # noqa: F401
|
||||||
from app.dashboard.models import Dashboard # noqa: F401
|
from app.dashboard.models import Dashboard # noqa: F401
|
||||||
from app.backoffice.admin.models import Admin # noqa: F401
|
|
||||||
from app.credit.models import CreditChargeRequest, CreditTransaction # noqa: F401
|
|
||||||
|
|
||||||
# 생성할 테이블 목록 (FK 순서: 참조 대상 먼저)
|
# 생성할 테이블 목록
|
||||||
tables_to_create = [
|
tables_to_create = [
|
||||||
User.__table__,
|
User.__table__,
|
||||||
RefreshToken.__table__,
|
RefreshToken.__table__,
|
||||||
@ -99,16 +96,10 @@ async def create_db_tables():
|
|||||||
Song.__table__,
|
Song.__table__,
|
||||||
SongTimestamp.__table__,
|
SongTimestamp.__table__,
|
||||||
Video.__table__,
|
Video.__table__,
|
||||||
VideoReaction.__table__,
|
|
||||||
Comment.__table__,
|
|
||||||
SNSUploadTask.__table__,
|
SNSUploadTask.__table__,
|
||||||
SocialUpload.__table__,
|
SocialUpload.__table__,
|
||||||
MarketingIntel.__table__,
|
MarketingIntel.__table__,
|
||||||
Dashboard.__table__,
|
Dashboard.__table__,
|
||||||
ImageTag.__table__,
|
|
||||||
Admin.__table__,
|
|
||||||
CreditChargeRequest.__table__,
|
|
||||||
CreditTransaction.__table__,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info("Creating database tables...")
|
logger.info("Creating database tables...")
|
||||||
@ -140,33 +131,24 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
# )
|
# )
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except (SocialException, DashboardException) as e:
|
|
||||||
# 전역 exception handler가 응답으로 변환하는 도메인 예외.
|
|
||||||
# 정상 플로우이므로 ERROR traceback 없이 롤백만 수행.
|
|
||||||
await session.rollback()
|
|
||||||
logger.warning(
|
|
||||||
f"[get_session] ROLLBACK - handled domain error: "
|
|
||||||
f"{type(e).__name__}: {e}, "
|
|
||||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
from fastapi import HTTPException
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
# status_code < 500인 도메인 예외(계정 미연동 등)는 정상적인 비즈니스 흐름이므로 ERROR로 남기지 않음
|
duration = (time.perf_counter() - start_time) * 1000
|
||||||
if getattr(e, "status_code", 500) < 500:
|
# 클라이언트 에러(4xx)는 WARNING, 서버 에러(5xx)는 ERROR로 구분
|
||||||
|
if isinstance(e, HTTPException) and e.status_code < 500:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[get_session] ROLLBACK - client error: {type(e).__name__}: {e}, "
|
f"[get_session] ROLLBACK ({e.status_code}) - "
|
||||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
f"error: {type(e).__name__}: {e}, duration: {duration:.1f}ms"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
import traceback
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
|
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
|
||||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
f"duration: {duration:.1f}ms"
|
||||||
)
|
)
|
||||||
raise
|
raise e
|
||||||
finally:
|
finally:
|
||||||
total_time = time.perf_counter() - start_time
|
total_time = time.perf_counter() - start_time
|
||||||
# logger.debug(
|
# logger.debug(
|
||||||
@ -194,8 +176,6 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
# )
|
# )
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
logger.error(
|
logger.error(
|
||||||
@ -203,8 +183,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
f"error: {type(e).__name__}: {e}, "
|
f"error: {type(e).__name__}: {e}, "
|
||||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
||||||
)
|
)
|
||||||
logger.debug(traceback.format_exc())
|
raise e
|
||||||
raise
|
|
||||||
finally:
|
finally:
|
||||||
total_time = time.perf_counter() - start_time
|
total_time = time.perf_counter() - start_time
|
||||||
# logger.debug(
|
# logger.debug(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -9,8 +9,7 @@ Home 모듈 SQLAlchemy 모델 정의
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, List, Optional, Any
|
from typing import TYPE_CHECKING, List, Optional, Any
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Computed, Index, Integer, String, Text, JSON, func
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, JSON, func
|
||||||
from sqlalchemy.dialects.mysql import INTEGER
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database.session import Base
|
from app.database.session import Base
|
||||||
@ -114,13 +113,6 @@ class Project(Base):
|
|||||||
comment="마케팅 인텔리전스 결과 정보 저장",
|
comment="마케팅 인텔리전스 결과 정보 저장",
|
||||||
)
|
)
|
||||||
|
|
||||||
industry: Mapped[str] = mapped_column(
|
|
||||||
String(50),
|
|
||||||
nullable=False,
|
|
||||||
default="",
|
|
||||||
comment="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)",
|
|
||||||
)
|
|
||||||
|
|
||||||
language: Mapped[str] = mapped_column(
|
language: Mapped[str] = mapped_column(
|
||||||
String(50),
|
String(50),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
@ -274,7 +266,6 @@ class MarketingIntel(Base):
|
|||||||
Attributes:
|
Attributes:
|
||||||
id: 고유 식별자 (자동 증가)
|
id: 고유 식별자 (자동 증가)
|
||||||
place_id : 데이터 소스별 식별자
|
place_id : 데이터 소스별 식별자
|
||||||
official_site_url : 업체 공식 링크 (플레이스 홈페이지 항목, 없으면 크롤링 소스 URL)
|
|
||||||
intel_result : 마케팅 분석 결과물 json
|
intel_result : 마케팅 분석 결과물 json
|
||||||
created_at: 생성 일시 (자동 설정)
|
created_at: 생성 일시 (자동 설정)
|
||||||
"""
|
"""
|
||||||
@ -297,16 +288,10 @@ class MarketingIntel(Base):
|
|||||||
comment="고유 식별자",
|
comment="고유 식별자",
|
||||||
)
|
)
|
||||||
|
|
||||||
place_id: Mapped[Optional[str]] = mapped_column(
|
place_id: Mapped[str] = mapped_column(
|
||||||
String(36),
|
String(36),
|
||||||
nullable=True,
|
nullable=False,
|
||||||
comment="매장 소스별 고유 식별자 (네이버 크롤링 시 'nv{id}' 형식; 직접 입력 시 NULL)",
|
comment="매장 소스별 고유 식별자",
|
||||||
)
|
|
||||||
|
|
||||||
official_site_url: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(2048),
|
|
||||||
nullable=True,
|
|
||||||
comment="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 시 NULL)",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
intel_result : Mapped[dict[str, Any]] = mapped_column(
|
intel_result : Mapped[dict[str, Any]] = mapped_column(
|
||||||
@ -315,18 +300,6 @@ class MarketingIntel(Base):
|
|||||||
comment="마케팅 인텔리전스 결과물",
|
comment="마케팅 인텔리전스 결과물",
|
||||||
)
|
)
|
||||||
|
|
||||||
subtitle : Mapped[dict[str, Any]] = mapped_column(
|
|
||||||
JSON,
|
|
||||||
nullable=True,
|
|
||||||
comment="자막 정보 생성 결과물",
|
|
||||||
)
|
|
||||||
|
|
||||||
image_match : Mapped[dict[str, Any]] = mapped_column(
|
|
||||||
JSON,
|
|
||||||
nullable=True,
|
|
||||||
comment="이미지 슬롯 배정 결과물 — {슬롯명: image_url, 'audio-music': music_url, ...}",
|
|
||||||
)
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
@ -335,52 +308,13 @@ class MarketingIntel(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
|
task_id_str = (
|
||||||
|
(self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id
|
||||||
|
)
|
||||||
|
img_name_str = (
|
||||||
|
(self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"<MarketingIntel(id={self.id}, place_id='{self.place_id}')>"
|
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ImageTag(Base):
|
|
||||||
"""
|
|
||||||
이미지 태그 테이블
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "image_tags"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_img_url_hash", "img_url_hash"), # CRC32 index
|
|
||||||
{
|
|
||||||
"mysql_engine": "InnoDB",
|
|
||||||
"mysql_charset": "utf8mb4",
|
|
||||||
"mysql_collate": "utf8mb4_unicode_ci",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
primary_key=True,
|
|
||||||
nullable=False,
|
|
||||||
autoincrement=True,
|
|
||||||
comment="고유 식별자",
|
|
||||||
)
|
|
||||||
|
|
||||||
img_url: Mapped[str] = mapped_column(
|
|
||||||
String(2048),
|
|
||||||
nullable=False,
|
|
||||||
comment="이미지 URL (blob, CDN 경로)",
|
|
||||||
)
|
|
||||||
|
|
||||||
img_url_hash: Mapped[int] = mapped_column(
|
|
||||||
INTEGER(unsigned=True),
|
|
||||||
Computed("CRC32(img_url)", persisted=True), # generated column
|
|
||||||
comment="URL CRC32 해시 (검색용 index)",
|
|
||||||
)
|
|
||||||
|
|
||||||
img_tag: Mapped[dict[str, Any]] = mapped_column(
|
|
||||||
# none_as_null=True: ORM에서 None 대입 시 JSON 리터럴 null이 아닌 SQL NULL로 저장.
|
|
||||||
# 미지정 시 JSON null이 저장되어 `img_tag.is_not(None)` 필터를 통과하는 버그가 있었음.
|
|
||||||
JSON(none_as_null=True),
|
|
||||||
nullable=True,
|
|
||||||
default=None,
|
|
||||||
comment="태그 JSON",
|
|
||||||
)
|
)
|
||||||
@ -1,6 +1,6 @@
|
|||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from app.utils.prompts.schemas import MarketingPromptOutput
|
from app.utils.prompts.schemas import MarketingPromptOutput
|
||||||
|
|
||||||
class CrawlingRequest(BaseModel):
|
class CrawlingRequest(BaseModel):
|
||||||
@ -78,7 +78,6 @@ class ProcessedInfo(BaseModel):
|
|||||||
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
|
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
|
||||||
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
|
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
|
||||||
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
|
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
|
||||||
industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)")
|
|
||||||
|
|
||||||
|
|
||||||
# class MarketingAnalysisDetail(BaseModel):
|
# class MarketingAnalysisDetail(BaseModel):
|
||||||
@ -98,12 +97,6 @@ class ProcessedInfo(BaseModel):
|
|||||||
# selling_points: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
|
# selling_points: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
|
||||||
|
|
||||||
|
|
||||||
class ImageListItem(BaseModel):
|
|
||||||
"""크롤링 이미지 아이템 (미리보기 URL + 원본 URL)"""
|
|
||||||
preview: str = Field(..., description="미리보기용 CDN URL")
|
|
||||||
original: str = Field(..., description="업로드용 원본 URL")
|
|
||||||
|
|
||||||
|
|
||||||
class CrawlingResponse(BaseModel):
|
class CrawlingResponse(BaseModel):
|
||||||
"""크롤링 응답 스키마"""
|
"""크롤링 응답 스키마"""
|
||||||
|
|
||||||
@ -111,7 +104,7 @@ class CrawlingResponse(BaseModel):
|
|||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"image_list": [{"preview": "https://example.com/image1.jpg", "original": "https://example.com/image1.jpg"}],
|
"image_list": ["https://example.com/image1.jpg", "https://example.com/image2.jpg"],
|
||||||
"image_count": 2,
|
"image_count": 2,
|
||||||
"processed_info": {
|
"processed_info": {
|
||||||
"customer_name": "스테이 머뭄",
|
"customer_name": "스테이 머뭄",
|
||||||
@ -240,8 +233,8 @@ class CrawlingResponse(BaseModel):
|
|||||||
default="completed",
|
default="completed",
|
||||||
description="처리 상태 (completed: 성공, failed: ChatGPT 분석 실패)"
|
description="처리 상태 (completed: 성공, failed: ChatGPT 분석 실패)"
|
||||||
)
|
)
|
||||||
image_list: Optional[list[ImageListItem]] = Field(None, description="이미지 목록 (preview/original URL)")
|
image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
|
||||||
image_count: Optional[int] = Field(None, description="이미지 개수")
|
image_count: int = Field(..., description="이미지 개수")
|
||||||
processed_info: Optional[ProcessedInfo] = Field(
|
processed_info: Optional[ProcessedInfo] = Field(
|
||||||
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
|
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
|
||||||
)
|
)
|
||||||
@ -249,43 +242,6 @@ class CrawlingResponse(BaseModel):
|
|||||||
None, description="마케팅 분석 결과 . 실패 시 null"
|
None, description="마케팅 분석 결과 . 실패 시 null"
|
||||||
)
|
)
|
||||||
m_id : int = Field(..., description="마케팅 분석 결과 ID")
|
m_id : int = Field(..., description="마케팅 분석 결과 ID")
|
||||||
industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)")
|
|
||||||
|
|
||||||
|
|
||||||
class ManualMarketingRequest(BaseModel):
|
|
||||||
"""업체명+주소 직접 입력 마케팅 분석 요청"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"store_name": "스테이 머뭄",
|
|
||||||
"address": "전북특별자치도 군산시 절골길 18",
|
|
||||||
"category": "펜션",
|
|
||||||
"official_site_url": "https://www.staymeomoom.com",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
store_name: str = Field(..., description="업체명 / 브랜드명")
|
|
||||||
address: str = Field(..., description="도로명 또는 지번 주소")
|
|
||||||
category: str = Field(default="", description="업체 업종/카테고리 자유 입력 (예: 펜션, 카페). 크롤링 경로와 동일하게 AI가 8개 industry enum으로 자동 분류하는 데 사용. 비우면 업체명 기반 AI 분류")
|
|
||||||
official_site_url: Optional[str] = Field(
|
|
||||||
default=None,
|
|
||||||
max_length=2048,
|
|
||||||
description="업체 공식 홈페이지 링크 (선택, http/https만 허용). 영상 응답의 official_site_url로 노출됨",
|
|
||||||
)
|
|
||||||
|
|
||||||
@field_validator("official_site_url")
|
|
||||||
@classmethod
|
|
||||||
def _validate_official_site_url(cls, v: Optional[str]) -> Optional[str]:
|
|
||||||
if v is None:
|
|
||||||
return None
|
|
||||||
v = v.strip()
|
|
||||||
if not v:
|
|
||||||
return None
|
|
||||||
if not v.startswith(("http://", "https://")):
|
|
||||||
raise ValueError("official_site_url은 http:// 또는 https://로 시작해야 합니다.")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorResponse(BaseModel):
|
class ErrorResponse(BaseModel):
|
||||||
|
|||||||
@ -1,380 +0,0 @@
|
|||||||
"""이미지 업로드 입력 검증과 continuation 소유권 검사 유틸리티."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import time
|
|
||||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from enum import StrEnum
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Literal
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import HTTPException, UploadFile, status
|
|
||||||
from sqlalchemy import select, text
|
|
||||||
|
|
||||||
from app.database.session import AsyncSessionLocal, engine
|
|
||||||
from app.home.models import Image
|
|
||||||
from app.home.schemas.home_schema import ImageUploadResultItem
|
|
||||||
from app.utils.logger import get_logger
|
|
||||||
from config import azure_blob_settings
|
|
||||||
|
|
||||||
logger = get_logger("image_upload")
|
|
||||||
_image_upload_lock_slots = asyncio.Semaphore(
|
|
||||||
azure_blob_settings.IMAGE_UPLOAD_MAX_CONCURRENT_LOCKS
|
|
||||||
)
|
|
||||||
|
|
||||||
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif"}
|
|
||||||
_HEIF_BRANDS = {
|
|
||||||
b"heic",
|
|
||||||
b"heix",
|
|
||||||
b"hevc",
|
|
||||||
b"hevx",
|
|
||||||
b"heim",
|
|
||||||
b"heis",
|
|
||||||
b"mif1",
|
|
||||||
b"msf1",
|
|
||||||
}
|
|
||||||
_IMAGE_SIGNATURE_BYTES = 64
|
|
||||||
|
|
||||||
|
|
||||||
class ImageUploadLockTimeoutError(TimeoutError):
|
|
||||||
"""동일 task 이미지 변경 락을 제한 시간 내 획득하지 못했습니다."""
|
|
||||||
|
|
||||||
|
|
||||||
class BlobReferenceState(StrEnum):
|
|
||||||
"""불명확한 DB commit 뒤 Blob URL 참조 확인 결과."""
|
|
||||||
|
|
||||||
ALL = "all"
|
|
||||||
NONE = "none"
|
|
||||||
MIXED = "mixed"
|
|
||||||
UNKNOWN = "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def should_cleanup_failed_upload_blobs(
|
|
||||||
*,
|
|
||||||
commit_started: bool,
|
|
||||||
reference_state: BlobReferenceState = BlobReferenceState.UNKNOWN,
|
|
||||||
) -> bool:
|
|
||||||
"""DB commit 시도 후에는 참조가 없다고 확정된 경우에만 Blob을 삭제합니다."""
|
|
||||||
return not commit_started or reference_state == BlobReferenceState.NONE
|
|
||||||
|
|
||||||
|
|
||||||
def classify_blob_references(
|
|
||||||
expected_urls: set[str], found_urls: set[str]
|
|
||||||
) -> BlobReferenceState:
|
|
||||||
"""예상 Blob URL과 독립 조회 결과를 보존 우선 상태로 분류합니다."""
|
|
||||||
if not expected_urls:
|
|
||||||
return BlobReferenceState.NONE
|
|
||||||
matched_urls = expected_urls & found_urls
|
|
||||||
if matched_urls == expected_urls:
|
|
||||||
return BlobReferenceState.ALL
|
|
||||||
if not matched_urls:
|
|
||||||
return BlobReferenceState.NONE
|
|
||||||
return BlobReferenceState.MIXED
|
|
||||||
|
|
||||||
|
|
||||||
async def inspect_blob_references(
|
|
||||||
task_id: str,
|
|
||||||
blob_urls: set[str],
|
|
||||||
) -> BlobReferenceState:
|
|
||||||
"""독립 세션에서 이번 요청 Blob URL의 DB 반영 여부를 확인합니다.
|
|
||||||
|
|
||||||
commit 응답 유실 직후의 짧은 가시성 경합을 피하려고 NONE 결과만 세 번
|
|
||||||
재확인합니다. 존재/혼재/조회 실패는 즉시 보존 쪽으로 판정합니다.
|
|
||||||
"""
|
|
||||||
if not blob_urls:
|
|
||||||
return BlobReferenceState.NONE
|
|
||||||
for attempt in range(3):
|
|
||||||
try:
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Image.img_url).where(
|
|
||||||
Image.task_id == task_id,
|
|
||||||
Image.img_url.in_(blob_urls),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
found_urls = set(result.scalars().all())
|
|
||||||
state = classify_blob_references(blob_urls, found_urls)
|
|
||||||
if state != BlobReferenceState.NONE:
|
|
||||||
return state
|
|
||||||
if attempt < 2:
|
|
||||||
await asyncio.sleep(0.1 * (attempt + 1))
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error(
|
|
||||||
f"[inspect_blob_references] DB verification failed - task_id: "
|
|
||||||
f"{task_id}, {type(exc).__name__}: {exc}"
|
|
||||||
)
|
|
||||||
return BlobReferenceState.UNKNOWN
|
|
||||||
return BlobReferenceState.NONE
|
|
||||||
|
|
||||||
|
|
||||||
async def compensate_failed_upload_blobs(
|
|
||||||
*,
|
|
||||||
task_id: str,
|
|
||||||
blob_urls: set[str],
|
|
||||||
commit_started: bool,
|
|
||||||
cleanup: Callable[[], Awaitable[None]],
|
|
||||||
) -> BlobReferenceState:
|
|
||||||
"""DB 쓰기 실패 후 안전하다고 확인된 Blob만 보상 삭제합니다."""
|
|
||||||
reference_state = BlobReferenceState.UNKNOWN
|
|
||||||
if commit_started:
|
|
||||||
reference_state = await inspect_blob_references(task_id, blob_urls)
|
|
||||||
|
|
||||||
if should_cleanup_failed_upload_blobs(
|
|
||||||
commit_started=commit_started,
|
|
||||||
reference_state=reference_state,
|
|
||||||
):
|
|
||||||
await cleanup()
|
|
||||||
return reference_state
|
|
||||||
|
|
||||||
|
|
||||||
def validate_task_image_count(*, existing_count: int, incoming_count: int) -> int:
|
|
||||||
"""한 task에 누적 가능한 이미지 수를 검증하고 예상 총 개수를 반환합니다."""
|
|
||||||
total_count = existing_count + incoming_count
|
|
||||||
max_task_images = azure_blob_settings.IMAGE_UPLOAD_MAX_TASK_IMAGES
|
|
||||||
if total_count > max_task_images:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"한 작업에는 이미지를 최대 {max_task_images}개까지 추가할 수 있습니다.",
|
|
||||||
)
|
|
||||||
return total_count
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def _image_upload_lock_slot(
|
|
||||||
lock_name: str, timeout_seconds: int
|
|
||||||
) -> AsyncIterator[None]:
|
|
||||||
"""DB 락 연결 슬롯 대기에도 동일한 제한 시간을 적용합니다."""
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(
|
|
||||||
_image_upload_lock_slots.acquire(),
|
|
||||||
timeout=timeout_seconds,
|
|
||||||
)
|
|
||||||
except TimeoutError as exc:
|
|
||||||
raise ImageUploadLockTimeoutError(lock_name) from exc
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
_image_upload_lock_slots.release()
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def image_upload_task_lock(task_id: str) -> AsyncIterator[None]:
|
|
||||||
"""동일 task 요청 전체를 MySQL advisory lock으로 직렬화합니다.
|
|
||||||
|
|
||||||
yield dependency가 Azure 업로드부터 최종 태깅까지 connection을 점유하므로,
|
|
||||||
worker별 semaphore로 main DB pool의 나머지 connection을 보존합니다.
|
|
||||||
"""
|
|
||||||
lock_name = f"image_upload:{task_id}"
|
|
||||||
timeout_seconds = azure_blob_settings.IMAGE_UPLOAD_LOCK_TIMEOUT_SECONDS
|
|
||||||
lock_started = time.perf_counter()
|
|
||||||
|
|
||||||
async with _image_upload_lock_slot(lock_name, timeout_seconds):
|
|
||||||
async with engine.connect() as connection:
|
|
||||||
lock_result = await connection.execute(
|
|
||||||
text("SELECT GET_LOCK(:lock_name, :timeout_seconds)"),
|
|
||||||
{
|
|
||||||
"lock_name": lock_name,
|
|
||||||
"timeout_seconds": timeout_seconds,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if lock_result.scalar_one_or_none() != 1:
|
|
||||||
raise ImageUploadLockTimeoutError(lock_name)
|
|
||||||
logger.info(
|
|
||||||
f"[image_upload_task_lock] ACQUIRED - task_id: {task_id}, "
|
|
||||||
f"wait_ms: {(time.perf_counter() - lock_started) * 1000:.1f}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
release_task = asyncio.create_task(
|
|
||||||
connection.execute(
|
|
||||||
text("SELECT RELEASE_LOCK(:lock_name)"),
|
|
||||||
{"lock_name": lock_name},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
release_result = await asyncio.shield(release_task)
|
|
||||||
if release_result.scalar_one_or_none() != 1:
|
|
||||||
raise RuntimeError(f"RELEASE_LOCK failed: {lock_name}")
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
# shield 바깥 task가 다시 취소돼도 release query는 끝까지 기다립니다.
|
|
||||||
try:
|
|
||||||
await release_task
|
|
||||||
except BaseException as release_exc:
|
|
||||||
await connection.invalidate(release_exc)
|
|
||||||
raise
|
|
||||||
except BaseException as exc:
|
|
||||||
# 락이 남은 connection이 pool로 복귀하지 않도록 폐기합니다.
|
|
||||||
await connection.invalidate(exc)
|
|
||||||
logger.error(
|
|
||||||
f"[image_upload_task_lock] RELEASE_LOCK failed - "
|
|
||||||
f"{type(exc).__name__}: {exc}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
f"[image_upload_task_lock] RELEASED - task_id: {task_id}, "
|
|
||||||
f"held_ms: {(time.perf_counter() - lock_started) * 1000:.1f}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_image_extension(filename: str | None) -> bool:
|
|
||||||
"""파일명의 확장자가 지원 이미지 확장자인지 확인합니다."""
|
|
||||||
if not filename:
|
|
||||||
return False
|
|
||||||
return Path(filename).suffix.lower() in ALLOWED_IMAGE_EXTENSIONS
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_image_format(header: bytes) -> str | None:
|
|
||||||
"""신뢰할 수 없는 파일명/MIME 대신 파일 시그니처로 형식을 판별합니다."""
|
|
||||||
if header.startswith(b"\xff\xd8\xff"):
|
|
||||||
return "jpeg"
|
|
||||||
if header.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
||||||
return "png"
|
|
||||||
if len(header) >= 12 and header.startswith(b"RIFF") and header[8:12] == b"WEBP":
|
|
||||||
return "webp"
|
|
||||||
if len(header) >= 12 and header[4:8] == b"ftyp":
|
|
||||||
brands = {header[8:12]}
|
|
||||||
brands.update(
|
|
||||||
header[index : index + 4] for index in range(16, len(header) - 3, 4)
|
|
||||||
)
|
|
||||||
if brands & _HEIF_BRANDS:
|
|
||||||
return "heif"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extension_matches_format(extension: str, detected_format: str) -> bool:
|
|
||||||
"""동일 포맷의 별칭을 고려해 확장자와 시그니처 일치 여부를 확인합니다."""
|
|
||||||
expected_formats = {
|
|
||||||
".jpg": "jpeg",
|
|
||||||
".jpeg": "jpeg",
|
|
||||||
".png": "png",
|
|
||||||
".webp": "webp",
|
|
||||||
".heic": "heif",
|
|
||||||
".heif": "heif",
|
|
||||||
}
|
|
||||||
return expected_formats.get(extension) == detected_format
|
|
||||||
|
|
||||||
|
|
||||||
async def inspect_upload_file(file: UploadFile) -> tuple[str, str, int]:
|
|
||||||
"""UploadFile을 상수 메모리로 검사하고 실제 바이트 크기를 반환합니다."""
|
|
||||||
original_name = file.filename or ""
|
|
||||||
if len(original_name) > 255:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="파일명은 255자를 초과할 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
extension = Path(original_name).suffix.lower()
|
|
||||||
max_file_size = azure_blob_settings.IMAGE_UPLOAD_MAX_FILE_SIZE_BYTES
|
|
||||||
validation_chunk_size = min(
|
|
||||||
azure_blob_settings.AZURE_BLOB_UPLOAD_BLOCK_SIZE_BYTES,
|
|
||||||
1024 * 1024,
|
|
||||||
)
|
|
||||||
total_size = 0
|
|
||||||
header = bytearray()
|
|
||||||
|
|
||||||
await file.seek(0)
|
|
||||||
try:
|
|
||||||
while chunk := await file.read(validation_chunk_size):
|
|
||||||
total_size += len(chunk)
|
|
||||||
if total_size > max_file_size:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
|
||||||
detail=(
|
|
||||||
f"파일 '{original_name}'이 최대 크기 "
|
|
||||||
f"{max_file_size // (1024 * 1024)} MiB를 초과합니다."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if len(header) < _IMAGE_SIGNATURE_BYTES:
|
|
||||||
remaining = _IMAGE_SIGNATURE_BYTES - len(header)
|
|
||||||
header.extend(chunk[:remaining])
|
|
||||||
finally:
|
|
||||||
await file.seek(0)
|
|
||||||
|
|
||||||
if total_size == 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"빈 파일은 업로드할 수 없습니다: {original_name}",
|
|
||||||
)
|
|
||||||
|
|
||||||
detected_format = _detect_image_format(bytes(header))
|
|
||||||
if detected_format is None or not _extension_matches_format(
|
|
||||||
extension, detected_format
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=(
|
|
||||||
f"파일 내용과 확장자가 일치하는 지원 이미지가 아닙니다: {original_name}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return original_name, extension, total_size
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_continuation_task_id(task_id: str) -> str:
|
|
||||||
"""continuation task_id를 canonical UUID7 문자열로 검증합니다."""
|
|
||||||
try:
|
|
||||||
parsed = UUID(task_id)
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
parsed = None
|
|
||||||
|
|
||||||
if (
|
|
||||||
parsed is None
|
|
||||||
or len(task_id) != 36
|
|
||||||
or parsed.version != 7
|
|
||||||
or str(parsed) != task_id.lower()
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="task_id는 올바른 UUID7 형식이어야 합니다.",
|
|
||||||
)
|
|
||||||
return str(parsed)
|
|
||||||
|
|
||||||
|
|
||||||
def _blob_url_prefix(user_uuid: str, task_id: str) -> str:
|
|
||||||
"""현재 사용자와 task에 허용된 Azure 이미지 URL prefix를 반환합니다."""
|
|
||||||
base_url = azure_blob_settings.AZURE_BLOB_BASE_URL.rstrip("/")
|
|
||||||
return f"{base_url}/{user_uuid}/{task_id}/image/"
|
|
||||||
|
|
||||||
|
|
||||||
def assert_continuation_owner(
|
|
||||||
images: list[Image], user_uuid: str, task_id: str
|
|
||||||
) -> None:
|
|
||||||
"""Image에 owner 컬럼이 없어 Blob 경로로 continuation 소유권을 검증합니다."""
|
|
||||||
if not images:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="이어 올릴 이미지 작업을 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
base_prefix = f"{azure_blob_settings.AZURE_BLOB_BASE_URL.rstrip('/')}/"
|
|
||||||
owner_prefix = _blob_url_prefix(user_uuid, task_id)
|
|
||||||
internal_urls = [
|
|
||||||
image.img_url for image in images if image.img_url.startswith(base_prefix)
|
|
||||||
]
|
|
||||||
|
|
||||||
if not internal_urls or any(
|
|
||||||
not image_url.startswith(owner_prefix) for image_url in internal_urls
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="이 이미지 업로드 작업을 이어서 수정할 권한이 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def image_result_item(image: Image) -> ImageUploadResultItem:
|
|
||||||
"""DB Image를 기존 응답 아이템으로 변환합니다."""
|
|
||||||
base_prefix = f"{azure_blob_settings.AZURE_BLOB_BASE_URL.rstrip('/')}/"
|
|
||||||
source: Literal["url", "blob"] = (
|
|
||||||
"blob" if image.img_url.startswith(base_prefix) else "url"
|
|
||||||
)
|
|
||||||
return ImageUploadResultItem(
|
|
||||||
id=image.id,
|
|
||||||
img_name=image.img_name,
|
|
||||||
img_url=image.img_url,
|
|
||||||
img_order=image.img_order,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
@ -32,7 +32,7 @@ class NaverSearchClient:
|
|||||||
display: int = 5,
|
display: int = 5,
|
||||||
) -> List[dict]:
|
) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
장소 검색 (숙박, 음식점 등)
|
숙박/펜션 검색
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: 검색어
|
query: 검색어
|
||||||
@ -41,7 +41,8 @@ class NaverSearchClient:
|
|||||||
Returns:
|
Returns:
|
||||||
검색 결과 리스트 (address, roadAddress, title)
|
검색 결과 리스트 (address, roadAddress, title)
|
||||||
"""
|
"""
|
||||||
search_query = query
|
# 숙박/펜션 카테고리 검색을 위해 쿼리에 키워드 추가
|
||||||
|
search_query = f"{query} 숙박"
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"X-Naver-Client-Id": self.client_id,
|
"X-Naver-Client-Id": self.client_id,
|
||||||
|
|||||||
@ -40,11 +40,9 @@ from app.lyric.schemas.lyric import (
|
|||||||
LyricDetailResponse,
|
LyricDetailResponse,
|
||||||
LyricListItem,
|
LyricListItem,
|
||||||
LyricStatusResponse,
|
LyricStatusResponse,
|
||||||
SubtitleStatusResponse,
|
|
||||||
)
|
)
|
||||||
from app.lyric.worker.lyric_task import generate_lyric_background
|
from app.lyric.worker.lyric_task import generate_lyric_background
|
||||||
from app.video.worker.creative_assets_task import generate_subtitle_background
|
from app.utils.chatgpt_prompt import ChatgptService
|
||||||
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.pagination import PaginatedResponse, get_paginated
|
from app.utils.pagination import PaginatedResponse, get_paginated
|
||||||
|
|
||||||
@ -161,7 +159,6 @@ async def get_lyric_by_task_id(
|
|||||||
project_id=lyric.project_id,
|
project_id=lyric.project_id,
|
||||||
status=lyric.status,
|
status=lyric.status,
|
||||||
lyric_result=lyric.lyric_result,
|
lyric_result=lyric.lyric_result,
|
||||||
genre=lyric.genre,
|
|
||||||
created_at=lyric.created_at,
|
created_at=lyric.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -243,21 +240,6 @@ async def generate_lyric(
|
|||||||
request_start = time.perf_counter()
|
request_start = time.perf_counter()
|
||||||
task_id = request_body.task_id
|
task_id = request_body.task_id
|
||||||
|
|
||||||
user = (await session.execute(
|
|
||||||
select(User).where(User.user_uuid == current_user.user_uuid)
|
|
||||||
)).scalar_one()
|
|
||||||
|
|
||||||
if user.credits <= 0:
|
|
||||||
logger.info(
|
|
||||||
f"크레딧 부족, user_uuid: {current_user.user_uuid}, credits: {current_user.credits}"
|
|
||||||
)
|
|
||||||
return GenerateLyricResponse(
|
|
||||||
success=False,
|
|
||||||
task_id=task_id,
|
|
||||||
lyric=None,
|
|
||||||
language=request_body.language,
|
|
||||||
error_message="No credits remaining.",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"[generate_lyric] ========== START ==========")
|
logger.info(f"[generate_lyric] ========== START ==========")
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -271,21 +253,35 @@ async def generate_lyric(
|
|||||||
step1_start = time.perf_counter()
|
step1_start = time.perf_counter()
|
||||||
logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
|
logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
|
||||||
|
|
||||||
|
# service = ChatgptService(
|
||||||
|
# customer_name=request_body.customer_name,
|
||||||
|
# region=request_body.region,
|
||||||
|
# detail_region_info=request_body.detail_region_info or "",
|
||||||
|
# language=request_body.language,
|
||||||
|
# )
|
||||||
|
|
||||||
|
# prompt = service.build_lyrics_prompt()
|
||||||
|
# 원래는 실제 사용할 프롬프트가 들어가야 하나, 로직이 변경되어 이 시점에서 이곳에서 프롬프트를 생성할 이유가 없어서 삭제됨.
|
||||||
|
# 기존 코드와의 호환을 위해 동일한 로직으로 프롬프트 생성
|
||||||
|
|
||||||
|
promotional_expressions = {
|
||||||
|
"Korean" : "인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소",
|
||||||
|
"English" : "Instagram vibes, picture-perfect day, healing, travel, getaway",
|
||||||
|
"Chinese" : "网红打卡, 治愈系, 旅行, 度假, 拍照圣地",
|
||||||
|
"Japanese" : "インスタ映え, 写真のような一日, 癒し, 旅行, 絶景",
|
||||||
|
"Thai" : "ที่พักสวย, ฮีลใจ, เที่ยว, ถ่ายรูป, วิวสวย",
|
||||||
|
"Vietnamese" : "check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp"
|
||||||
|
}# HARD CODED, 어디에 정리하지? 아직 정리되지 않음
|
||||||
|
|
||||||
|
timing_rules = {
|
||||||
|
"60s" : """
|
||||||
|
8–12 lines
|
||||||
|
Full verse flow, immersive mood
|
||||||
|
"""
|
||||||
|
}
|
||||||
marketing_intel_result = await session.execute(select(MarketingIntel).where(MarketingIntel.id == request_body.m_id))
|
marketing_intel_result = await session.execute(select(MarketingIntel).where(MarketingIntel.id == request_body.m_id))
|
||||||
marketing_intel = marketing_intel_result.scalar_one_or_none()
|
marketing_intel = marketing_intel_result.scalar_one_or_none()
|
||||||
|
|
||||||
# 자동 선택(genre 미지정) 재생성 시, 직전에 사용된 장르를 조회해 이번엔 다른 장르가 나오도록 제외 처리
|
|
||||||
exclude_genre = ""
|
|
||||||
if not request_body.genre:
|
|
||||||
previous_lyric_result = await session.execute(
|
|
||||||
select(Lyric)
|
|
||||||
.where(Lyric.task_id == task_id, Lyric.genre.is_not(None))
|
|
||||||
.order_by(Lyric.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
previous_lyric = previous_lyric_result.scalar_one_or_none()
|
|
||||||
if previous_lyric:
|
|
||||||
exclude_genre = previous_lyric.genre
|
|
||||||
|
|
||||||
lyric_input_data = {
|
lyric_input_data = {
|
||||||
"customer_name" : request_body.customer_name,
|
"customer_name" : request_body.customer_name,
|
||||||
@ -293,14 +289,10 @@ async def generate_lyric(
|
|||||||
"detail_region_info" : request_body.detail_region_info or "",
|
"detail_region_info" : request_body.detail_region_info or "",
|
||||||
"marketing_intelligence_summary" : json.dumps(marketing_intel.intel_result, ensure_ascii = False),
|
"marketing_intelligence_summary" : json.dumps(marketing_intel.intel_result, ensure_ascii = False),
|
||||||
"language" : request_body.language,
|
"language" : request_body.language,
|
||||||
"genre": request_body.genre or "", # 비어있으면 GPT가 가사 무드에 맞춰 장르를 직접 추천
|
"promotional_expression_example" : promotional_expressions[request_body.language],
|
||||||
"exclude_genre": exclude_genre, # 자동 선택 재생성 시 직전 장르 제외
|
"timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
|
||||||
"industry": request_body.industry, # 크롤 응답에서 받아 전달된 업종 enum
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# 업종 분기는 프롬프트 내부 {industry}로 처리하므로 단일 프롬프트 사용
|
|
||||||
selected_lyric_prompt = lyric_prompt
|
|
||||||
|
|
||||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||||
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
|
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
|
||||||
|
|
||||||
@ -326,8 +318,7 @@ async def generate_lyric(
|
|||||||
detail_region_info=request_body.detail_region_info,
|
detail_region_info=request_body.detail_region_info,
|
||||||
language=request_body.language,
|
language=request_body.language,
|
||||||
user_uuid=current_user.user_uuid,
|
user_uuid=current_user.user_uuid,
|
||||||
marketing_intelligence=request_body.m_id,
|
marketing_intelligence = request_body.m_id
|
||||||
industry=request_body.industry,
|
|
||||||
)
|
)
|
||||||
session.add(project)
|
session.add(project)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@ -341,7 +332,7 @@ async def generate_lyric(
|
|||||||
step3_start = time.perf_counter()
|
step3_start = time.perf_counter()
|
||||||
logger.debug(f"[generate_lyric] Step 3: Lyric 저장 (processing)...")
|
logger.debug(f"[generate_lyric] Step 3: Lyric 저장 (processing)...")
|
||||||
|
|
||||||
estimated_prompt = selected_lyric_prompt.build_prompt(lyric_input_data)
|
estimated_prompt = lyric_prompt.build_prompt(lyric_input_data)
|
||||||
lyric = Lyric(
|
lyric = Lyric(
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
@ -349,7 +340,6 @@ async def generate_lyric(
|
|||||||
lyric_prompt=estimated_prompt,
|
lyric_prompt=estimated_prompt,
|
||||||
lyric_result=None,
|
lyric_result=None,
|
||||||
language=request_body.language,
|
language=request_body.language,
|
||||||
genre=request_body.genre or None, # 자동 선택인 경우 GPT 응답 완료 후 채워짐
|
|
||||||
)
|
)
|
||||||
session.add(lyric)
|
session.add(lyric)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@ -361,29 +351,15 @@ async def generate_lyric(
|
|||||||
# ========== Step 4: 백그라운드 태스크 스케줄링 ==========
|
# ========== Step 4: 백그라운드 태스크 스케줄링 ==========
|
||||||
step4_start = time.perf_counter()
|
step4_start = time.perf_counter()
|
||||||
logger.debug(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
|
logger.debug(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
|
||||||
orientation = request_body.orientation
|
|
||||||
|
|
||||||
if request_body.instrumental:
|
|
||||||
# BGM 모드: ChatGPT 가사 생성 없이 Lyric을 즉시 completed로 마무리
|
|
||||||
lyric.status = "completed"
|
|
||||||
lyric.lyric_result = ""
|
|
||||||
await session.commit()
|
|
||||||
logger.info(f"[generate_lyric] BGM 모드 - 가사 생성 스킵, lyric_id: {lyric.id}")
|
|
||||||
else:
|
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
generate_lyric_background,
|
generate_lyric_background,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
prompt=selected_lyric_prompt,
|
prompt=lyric_prompt,
|
||||||
lyric_input_data=lyric_input_data,
|
lyric_input_data=lyric_input_data,
|
||||||
lyric_id=lyric.id,
|
lyric_id=lyric.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
background_tasks.add_task(
|
|
||||||
generate_subtitle_background,
|
|
||||||
orientation=orientation,
|
|
||||||
task_id=task_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
||||||
logger.debug(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)")
|
logger.debug(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)")
|
||||||
|
|
||||||
@ -521,96 +497,6 @@ async def list_lyrics(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/subtitle/status/{task_id}",
|
|
||||||
summary="자막 생성 상태 조회",
|
|
||||||
description="""
|
|
||||||
자막(subtitle) 생성 완료 여부를 조회합니다.
|
|
||||||
|
|
||||||
## 인증
|
|
||||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **task_id**: 프로젝트 task_id (필수)
|
|
||||||
|
|
||||||
## 상태 값
|
|
||||||
- **pending**: 자막 생성 진행 중 — 잠시 후 재요청
|
|
||||||
- **completed**: 자막 생성 완료 — `/video/generate/{task_id}` 호출 가능
|
|
||||||
|
|
||||||
## 사용 예시 (cURL)
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:8000/lyric/subtitle/status/019123ab-cdef-7890-abcd-ef1234567890" \\
|
|
||||||
-H "Authorization: Bearer {access_token}"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- 자막은 `/lyric/generate` 호출 시 백그라운드에서 자동 생성됩니다.
|
|
||||||
- 클라이언트는 `completed` 상태 확인 후 `/video/generate`를 호출해야 합니다.
|
|
||||||
""",
|
|
||||||
response_model=SubtitleStatusResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "상태 조회 성공"},
|
|
||||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
|
||||||
404: {"description": "해당 task_id에 해당하는 프로젝트를 찾을 수 없음"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def get_subtitle_status(
|
|
||||||
task_id: str,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> SubtitleStatusResponse:
|
|
||||||
"""task_id로 자막 생성 상태를 조회합니다."""
|
|
||||||
logger.info(f"[get_subtitle_status] START - task_id: {task_id}")
|
|
||||||
|
|
||||||
project_result = await session.execute(
|
|
||||||
select(Project)
|
|
||||||
.where(Project.task_id == task_id)
|
|
||||||
.order_by(Project.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
project = project_result.scalar_one_or_none()
|
|
||||||
if not project:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail=f"task_id '{task_id}'에 해당하는 프로젝트를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
marketing_result = await session.execute(
|
|
||||||
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
|
||||||
)
|
|
||||||
intel = marketing_result.scalar_one_or_none()
|
|
||||||
if not intel:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail=f"task_id '{task_id}'에 해당하는 마케팅 인텔리전스를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 자막과 이미지 배정 모두 완료돼야 영상 생성 가능
|
|
||||||
subtitle_done = bool(intel.subtitle)
|
|
||||||
image_match_done = bool(intel.image_match)
|
|
||||||
|
|
||||||
if subtitle_done and image_match_done:
|
|
||||||
logger.info(f"[get_subtitle_status] completed (subtitle+image_match) - task_id: {task_id}")
|
|
||||||
return SubtitleStatusResponse(
|
|
||||||
task_id=task_id,
|
|
||||||
status="completed",
|
|
||||||
message="자막 생성 및 이미지 배정이 완료되었습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
pending_parts = []
|
|
||||||
if not subtitle_done:
|
|
||||||
pending_parts.append("자막")
|
|
||||||
if not image_match_done:
|
|
||||||
pending_parts.append("이미지 배정")
|
|
||||||
pending_msg = ", ".join(pending_parts)
|
|
||||||
logger.info(f"[get_subtitle_status] pending ({pending_msg}) - task_id: {task_id}")
|
|
||||||
return SubtitleStatusResponse(
|
|
||||||
task_id=task_id,
|
|
||||||
status="pending",
|
|
||||||
message=f"{pending_msg} 처리가 진행 중입니다. 잠시 후 다시 확인해주세요.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{task_id}",
|
"/{task_id}",
|
||||||
summary="가사 상세 조회",
|
summary="가사 상세 조회",
|
||||||
|
|||||||
@ -93,13 +93,6 @@ class Lyric(Base):
|
|||||||
comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
)
|
)
|
||||||
|
|
||||||
genre: Mapped[str | None] = mapped_column(
|
|
||||||
String(20),
|
|
||||||
nullable=True,
|
|
||||||
comment="음악 장르 (kpop, pop, ballad, hip-hop, rnb, edm, jazz, rock). "
|
|
||||||
"수동 선택 시 요청값, 자동 선택 시 GPT 추천값",
|
|
||||||
)
|
|
||||||
|
|
||||||
is_deleted: Mapped[bool] = mapped_column(
|
is_deleted: Mapped[bool] = mapped_column(
|
||||||
Boolean,
|
Boolean,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|||||||
@ -23,7 +23,7 @@ Lyric API Schemas
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Literal
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
@ -42,8 +42,7 @@ class GenerateLyricRequest(BaseModel):
|
|||||||
"region": "군산",
|
"region": "군산",
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
"language": "Korean",
|
"language": "Korean",
|
||||||
"m_id" : 2,
|
"m_id" : 1
|
||||||
"orientation" : "vertical"
|
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -55,8 +54,7 @@ class GenerateLyricRequest(BaseModel):
|
|||||||
"region": "군산",
|
"region": "군산",
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
"language": "Korean",
|
"language": "Korean",
|
||||||
"m_id" : 1,
|
"m_id" : 1
|
||||||
"orientation" : "vertical"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -71,18 +69,7 @@ class GenerateLyricRequest(BaseModel):
|
|||||||
default="Korean",
|
default="Korean",
|
||||||
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
)
|
)
|
||||||
orientation: Literal["horizontal", "vertical"] = Field(
|
|
||||||
default="vertical",
|
|
||||||
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
|
|
||||||
)
|
|
||||||
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
|
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
|
||||||
industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)")
|
|
||||||
instrumental: bool = Field(default=False, description="BGM 전용 모드 (가사 생성 안 함)")
|
|
||||||
genre: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
description="사용자가 수동으로 고른 음악 장르 (kpop|pop|ballad|hip-hop|rnb|edm|jazz|rock). "
|
|
||||||
"'자동 선택'인 경우 None으로 전달하면 GPT가 가사 무드에 맞춰 추천",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GenerateLyricResponse(BaseModel):
|
class GenerateLyricResponse(BaseModel):
|
||||||
@ -205,62 +192,9 @@ class LyricDetailResponse(BaseModel):
|
|||||||
project_id: int = Field(..., description="프로젝트 ID")
|
project_id: int = Field(..., description="프로젝트 ID")
|
||||||
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
|
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
|
||||||
lyric_result: Optional[str] = Field(None, description="생성된 가사 또는 에러 메시지 (실패 시)")
|
lyric_result: Optional[str] = Field(None, description="생성된 가사 또는 에러 메시지 (실패 시)")
|
||||||
genre: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
description="확정된 음악 장르. 수동 선택 시 요청값, 자동 선택 시 GPT 추천값 (완료 전에는 None)",
|
|
||||||
)
|
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||||
|
|
||||||
|
|
||||||
class SubtitleStatusResponse(BaseModel):
|
|
||||||
"""자막 생성 상태 조회 응답 스키마
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
GET /subtitle/status/{task_id}
|
|
||||||
클라이언트가 subtitle 완료 여부를 polling할 때 사용합니다.
|
|
||||||
|
|
||||||
Status Values:
|
|
||||||
- pending: 자막 생성 진행 중 (재시도 필요)
|
|
||||||
- completed: 자막 생성 완료 (/video/generate 호출 가능)
|
|
||||||
- failed: 자막 생성 실패 (/lyric/generate 재호출 필요)
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"examples": [
|
|
||||||
{
|
|
||||||
"summary": "생성 중",
|
|
||||||
"value": {
|
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"status": "pending",
|
|
||||||
"message": "자막 생성이 진행 중입니다. 잠시 후 다시 확인해주세요.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"summary": "완료",
|
|
||||||
"value": {
|
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"status": "completed",
|
|
||||||
"message": "자막 생성이 완료되었습니다.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"summary": "실패",
|
|
||||||
"value": {
|
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"status": "failed",
|
|
||||||
"message": "자막 생성에 실패했습니다. 다시 시도해주세요.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
|
||||||
status: Literal["pending", "completed", "failed"] = Field(..., description="자막 생성 상태")
|
|
||||||
message: str = Field(..., description="상태 메시지")
|
|
||||||
|
|
||||||
|
|
||||||
class LyricListItem(BaseModel):
|
class LyricListItem(BaseModel):
|
||||||
"""가사 목록 아이템 스키마
|
"""가사 목록 아이템 스키마
|
||||||
|
|
||||||
|
|||||||
@ -4,24 +4,26 @@ Lyric Background Tasks
|
|||||||
가사 생성 관련 백그라운드 태스크를 정의합니다.
|
가사 생성 관련 백그라운드 태스크를 정의합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from app.database.session import BackgroundSessionLocal
|
from app.database.session import BackgroundSessionLocal
|
||||||
from app.lyric.models import Lyric
|
from app.lyric.models import Lyric
|
||||||
from app.utils.prompts.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
||||||
from app.utils.prompts.prompts import Prompt
|
from app.utils.prompts.prompts import Prompt
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
|
|
||||||
# 로거 설정
|
# 로거 설정
|
||||||
logger = get_logger("lyric")
|
logger = get_logger("lyric")
|
||||||
|
|
||||||
|
|
||||||
async def _update_lyric_status(
|
async def _update_lyric_status(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
status: str,
|
status: str,
|
||||||
result: str | None = None,
|
result: str | None = None,
|
||||||
lyric_id: int | None = None,
|
lyric_id: int | None = None,
|
||||||
genre: str | None = None,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Lyric 테이블의 상태를 업데이트합니다.
|
"""Lyric 테이블의 상태를 업데이트합니다.
|
||||||
|
|
||||||
@ -30,7 +32,6 @@ async def _update_lyric_status(
|
|||||||
status: 변경할 상태 ("processing", "completed", "failed")
|
status: 변경할 상태 ("processing", "completed", "failed")
|
||||||
result: 가사 결과 또는 에러 메시지
|
result: 가사 결과 또는 에러 메시지
|
||||||
lyric_id: 특정 Lyric 레코드 ID (재생성 시 정확한 레코드 식별용)
|
lyric_id: 특정 Lyric 레코드 ID (재생성 시 정확한 레코드 식별용)
|
||||||
genre: 확정된 음악 장르 (자동 선택이었던 경우 GPT 추천값으로 갱신)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: 업데이트 성공 여부
|
bool: 업데이트 성공 여부
|
||||||
@ -56,8 +57,6 @@ async def _update_lyric_status(
|
|||||||
lyric.status = status
|
lyric.status = status
|
||||||
if result is not None:
|
if result is not None:
|
||||||
lyric.lyric_result = result
|
lyric.lyric_result = result
|
||||||
if genre is not None:
|
|
||||||
lyric.genre = genre
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info(f"[Lyric] Status updated - task_id: {task_id}, lyric_id: {lyric_id}, status: {status}")
|
logger.info(f"[Lyric] Status updated - task_id: {task_id}, lyric_id: {lyric_id}, status: {status}")
|
||||||
return True
|
return True
|
||||||
@ -101,6 +100,13 @@ async def generate_lyric_background(
|
|||||||
step1_start = time.perf_counter()
|
step1_start = time.perf_counter()
|
||||||
logger.debug(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
|
logger.debug(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
|
||||||
|
|
||||||
|
# service = ChatgptService(
|
||||||
|
# customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
|
||||||
|
# region="",
|
||||||
|
# detail_region_info="",
|
||||||
|
# language=language,
|
||||||
|
# )
|
||||||
|
|
||||||
chatgpt = ChatgptService()
|
chatgpt = ChatgptService()
|
||||||
|
|
||||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||||
@ -114,18 +120,14 @@ async def generate_lyric_background(
|
|||||||
#result = await service.generate(prompt=prompt)
|
#result = await service.generate(prompt=prompt)
|
||||||
result_response = await chatgpt.generate_structured_output(prompt, lyric_input_data)
|
result_response = await chatgpt.generate_structured_output(prompt, lyric_input_data)
|
||||||
result = result_response.lyric
|
result = result_response.lyric
|
||||||
confirmed_genre = result_response.recommended_genre
|
|
||||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||||
logger.info(
|
logger.info(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
|
||||||
f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자, "
|
|
||||||
f"genre: {confirmed_genre} ({step2_elapsed:.1f}ms)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ========== Step 3: DB 상태 업데이트 ==========
|
# ========== Step 3: DB 상태 업데이트 ==========
|
||||||
step3_start = time.perf_counter()
|
step3_start = time.perf_counter()
|
||||||
logger.debug(f"[generate_lyric_background] Step 3: DB 상태 업데이트...")
|
logger.debug(f"[generate_lyric_background] Step 3: DB 상태 업데이트...")
|
||||||
|
|
||||||
await _update_lyric_status(task_id, "completed", result, lyric_id, genre=confirmed_genre)
|
await _update_lyric_status(task_id, "completed", result, lyric_id)
|
||||||
|
|
||||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||||
logger.debug(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)")
|
logger.debug(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)")
|
||||||
|
|||||||
209
app/sns/api/routers/v1/oauth.py
Normal file
209
app/sns/api/routers/v1/oauth.py
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
"""
|
||||||
|
SNS OAuth API 라우터
|
||||||
|
|
||||||
|
Facebook OAuth 연동 관련 엔드포인트를 제공합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database.session import get_session
|
||||||
|
from app.sns.schemas.facebook_schema import FacebookConnectResponse
|
||||||
|
from app.sns.services.facebook import facebook_service
|
||||||
|
from app.user.dependencies.auth import get_current_user
|
||||||
|
from app.user.models import User
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
from config import social_oauth_settings
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/sns", tags=["SNS OAuth"])
|
||||||
|
|
||||||
|
|
||||||
|
def _build_redirect_url(is_success: bool, params: dict) -> str:
|
||||||
|
"""OAuth 완료 후 프론트엔드 리다이렉트 URL 생성"""
|
||||||
|
base_url = social_oauth_settings.OAUTH_FRONTEND_URL.rstrip("/")
|
||||||
|
path = (
|
||||||
|
social_oauth_settings.OAUTH_SUCCESS_PATH
|
||||||
|
if is_success
|
||||||
|
else social_oauth_settings.OAUTH_ERROR_PATH
|
||||||
|
)
|
||||||
|
return f"{base_url}{path}?{urlencode(params)}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/facebook/connect",
|
||||||
|
response_model=FacebookConnectResponse,
|
||||||
|
summary="Facebook OAuth 연동 시작",
|
||||||
|
description="""
|
||||||
|
## 개요
|
||||||
|
Facebook OAuth 2.0 인증을 시작합니다.
|
||||||
|
|
||||||
|
## 플로우
|
||||||
|
1. 이 엔드포인트를 호출하여 `auth_url`과 `state`를 받음
|
||||||
|
2. 프론트엔드에서 `auth_url`로 사용자를 리다이렉트
|
||||||
|
3. 사용자가 Facebook에서 로그인 및 권한 승인
|
||||||
|
4. Facebook이 `/sns/facebook/callback` 엔드포인트로 리다이렉트
|
||||||
|
5. 연동 완료 후 프론트엔드로 리다이렉트
|
||||||
|
|
||||||
|
## 인증
|
||||||
|
- Bearer 토큰 필요 (Authorization: Bearer <token>)
|
||||||
|
""",
|
||||||
|
responses={
|
||||||
|
200: {"description": "인증 URL 반환 성공"},
|
||||||
|
401: {"description": "인증 실패"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def facebook_connect(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> FacebookConnectResponse:
|
||||||
|
"""Facebook OAuth 연동을 시작합니다."""
|
||||||
|
logger.info(f"[SNS_OAUTH] Facebook 연동 시작 - user_uuid: {current_user.user_uuid}")
|
||||||
|
|
||||||
|
# FacebookService를 통해 연동 시작
|
||||||
|
response = await facebook_service.start_connect(user_uuid=current_user.user_uuid)
|
||||||
|
|
||||||
|
logger.info("[SNS_OAUTH] Facebook 연동 URL 생성 완료")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/facebook/callback",
|
||||||
|
summary="Facebook OAuth 콜백",
|
||||||
|
description="""
|
||||||
|
## 개요
|
||||||
|
Facebook OAuth 콜백을 처리합니다.
|
||||||
|
|
||||||
|
이 엔드포인트는 Facebook에서 직접 호출되며,
|
||||||
|
처리 완료 후 프론트엔드로 리다이렉트합니다.
|
||||||
|
|
||||||
|
## 파라미터
|
||||||
|
- **code**: Facebook에서 발급한 인가 코드
|
||||||
|
- **state**: CSRF 방지용 state 토큰
|
||||||
|
- **error**: OAuth 에러 코드 (사용자 취소 등)
|
||||||
|
""",
|
||||||
|
responses={
|
||||||
|
302: {"description": "프론트엔드로 리다이렉트"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def facebook_callback(
|
||||||
|
code: str | None = Query(None, description="Facebook 인가 코드"),
|
||||||
|
state: str | None = Query(None, description="CSRF 방지용 state 토큰"),
|
||||||
|
error: str | None = Query(None, description="OAuth 에러 코드"),
|
||||||
|
error_description: str | None = Query(None, description="OAuth 에러 설명"),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
"""Facebook OAuth 콜백을 처리합니다."""
|
||||||
|
|
||||||
|
# 사용자가 취소하거나 에러가 발생한 경우
|
||||||
|
if error:
|
||||||
|
logger.info(
|
||||||
|
f"[SNS_OAUTH] Facebook 콜백 에러/취소 - "
|
||||||
|
f"error: {error}, description: {error_description}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 에러 메시지 분기
|
||||||
|
if error == "access_denied":
|
||||||
|
error_message = "사용자가 Facebook 연동을 취소했습니다."
|
||||||
|
else:
|
||||||
|
error_message = error_description or error
|
||||||
|
|
||||||
|
redirect_url = _build_redirect_url(
|
||||||
|
is_success=False,
|
||||||
|
params={
|
||||||
|
"platform": "facebook",
|
||||||
|
"error": error_message,
|
||||||
|
"cancelled": "true" if error == "access_denied" else "false",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return RedirectResponse(url=redirect_url, status_code=302)
|
||||||
|
|
||||||
|
# code 또는 state가 없는 경우
|
||||||
|
if not code or not state:
|
||||||
|
logger.warning(
|
||||||
|
f"[SNS_OAUTH] Facebook 콜백 파라미터 누락 - "
|
||||||
|
f"code: {bool(code)}, state: {bool(state)}"
|
||||||
|
)
|
||||||
|
redirect_url = _build_redirect_url(
|
||||||
|
is_success=False,
|
||||||
|
params={
|
||||||
|
"platform": "facebook",
|
||||||
|
"error": "잘못된 요청입니다. 다시 시도해주세요.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return RedirectResponse(url=redirect_url, status_code=302)
|
||||||
|
|
||||||
|
logger.info(f"[SNS_OAUTH] Facebook 콜백 수신 - code: {code[:20]}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# FacebookService를 통해 콜백 처리
|
||||||
|
account_response = await facebook_service.handle_callback(
|
||||||
|
code=code,
|
||||||
|
state=state,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 성공 시 프론트엔드로 리다이렉트
|
||||||
|
redirect_url = _build_redirect_url(
|
||||||
|
is_success=True,
|
||||||
|
params={
|
||||||
|
"platform": "facebook",
|
||||||
|
"account_id": account_response.account_id,
|
||||||
|
"channel_name": account_response.platform_username,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
logger.info("[SNS_OAUTH] Facebook 연동 성공, 리다이렉트")
|
||||||
|
return RedirectResponse(url=redirect_url, status_code=302)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[SNS_OAUTH] Facebook 콜백 처리 실패 - error: {e}")
|
||||||
|
# 실패 시 에러 페이지로 리다이렉트
|
||||||
|
redirect_url = _build_redirect_url(
|
||||||
|
is_success=False,
|
||||||
|
params={
|
||||||
|
"platform": "facebook",
|
||||||
|
"error": str(e),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return RedirectResponse(url=redirect_url, status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/facebook/disconnect",
|
||||||
|
summary="Facebook 계정 연동 해제",
|
||||||
|
description="""
|
||||||
|
## 개요
|
||||||
|
Facebook 계정 연동을 해제합니다.
|
||||||
|
|
||||||
|
## 연동 해제 시
|
||||||
|
- Facebook으로의 업로드가 불가능해집니다
|
||||||
|
- 기존 업로드 기록은 유지됩니다
|
||||||
|
- 재연동 시 다시 권한 승인이 필요합니다
|
||||||
|
|
||||||
|
## 인증
|
||||||
|
- Bearer 토큰 필요 (Authorization: Bearer <token>)
|
||||||
|
""",
|
||||||
|
responses={
|
||||||
|
200: {"description": "연동 해제 성공"},
|
||||||
|
401: {"description": "인증 실패"},
|
||||||
|
404: {"description": "연동된 Facebook 계정 없음"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def facebook_disconnect(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Facebook 계정 연동을 해제합니다."""
|
||||||
|
logger.info(f"[SNS_OAUTH] Facebook 연동 해제 - user_uuid: {current_user.user_uuid}")
|
||||||
|
|
||||||
|
# FacebookService를 통해 연동 해제
|
||||||
|
await facebook_service.disconnect(
|
||||||
|
user_uuid=current_user.user_uuid,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("[SNS_OAUTH] Facebook 연동 해제 완료")
|
||||||
|
return {"success": True, "message": "Facebook 계정 연동이 해제되었습니다."}
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
SNS 모듈 전용 FastAPI 의존성
|
||||||
|
|
||||||
|
SNS 모듈에서만 사용하는 의존성을 정의합니다.
|
||||||
|
Facebook OAuth 관련 의존성을 포함합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database.session import get_session
|
||||||
|
from app.user.dependencies.auth import get_current_user
|
||||||
|
from app.user.models import Platform, SocialAccount, User
|
||||||
|
from app.utils.facebook_oauth import FacebookOAuthClient
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_facebook_oauth_client() -> FacebookOAuthClient:
|
||||||
|
"""
|
||||||
|
FacebookOAuthClient 인스턴스를 제공하는 의존성
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FacebookOAuthClient: Facebook OAuth API 클라이언트 인스턴스
|
||||||
|
"""
|
||||||
|
return FacebookOAuthClient()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_facebook_social_account(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> SocialAccount:
|
||||||
|
"""
|
||||||
|
현재 사용자의 활성 Facebook 소셜 계정을 조회하는 의존성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_user: 현재 인증된 사용자
|
||||||
|
session: DB 세션
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SocialAccount: 활성 Facebook 소셜 계정
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: Facebook 소셜 계정이 없는 경우 404 반환
|
||||||
|
"""
|
||||||
|
logger.debug(f"[SNS_DEP] Facebook 소셜 계정 조회 - user_uuid: {current_user.user_uuid}")
|
||||||
|
|
||||||
|
# SocialAccount에서 Facebook 활성 계정 조회
|
||||||
|
result = await session.execute(
|
||||||
|
select(SocialAccount).where(
|
||||||
|
SocialAccount.user_uuid == current_user.user_uuid,
|
||||||
|
SocialAccount.platform == Platform.FACEBOOK,
|
||||||
|
SocialAccount.is_active == True, # noqa: E712
|
||||||
|
SocialAccount.is_deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
social_account = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if social_account is None:
|
||||||
|
logger.warning(f"[SNS_DEP] Facebook 계정 없음 - user_uuid: {current_user.user_uuid}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"code": "FACEBOOK_ACCOUNT_NOT_FOUND",
|
||||||
|
"message": "연동된 Facebook 계정을 찾을 수 없습니다.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"[SNS_DEP] Facebook 소셜 계정 확인 - account_id: {social_account.id}")
|
||||||
|
return social_account
|
||||||
@ -52,6 +52,7 @@ class SNSUploadTask(Base):
|
|||||||
Index("idx_sns_upload_task_user_uuid", "user_uuid"),
|
Index("idx_sns_upload_task_user_uuid", "user_uuid"),
|
||||||
Index("idx_sns_upload_task_task_id", "task_id"),
|
Index("idx_sns_upload_task_task_id", "task_id"),
|
||||||
Index("idx_sns_upload_task_social_account_id", "social_account_id"),
|
Index("idx_sns_upload_task_social_account_id", "social_account_id"),
|
||||||
|
Index("idx_sns_upload_task_platform", "platform"),
|
||||||
Index("idx_sns_upload_task_status", "status"),
|
Index("idx_sns_upload_task_status", "status"),
|
||||||
Index("idx_sns_upload_task_is_scheduled", "is_scheduled"),
|
Index("idx_sns_upload_task_is_scheduled", "is_scheduled"),
|
||||||
Index("idx_sns_upload_task_scheduled_at", "scheduled_at"),
|
Index("idx_sns_upload_task_scheduled_at", "scheduled_at"),
|
||||||
@ -116,6 +117,12 @@ class SNSUploadTask(Base):
|
|||||||
comment="소셜 계정 외래키 (SocialAccount.id 참조)",
|
comment="소셜 계정 외래키 (SocialAccount.id 참조)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
platform: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
nullable=True,
|
||||||
|
comment="업로드 대상 플랫폼 (instagram, facebook 등)",
|
||||||
|
)
|
||||||
|
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# 업로드 콘텐츠
|
# 업로드 콘텐츠
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
@ -131,6 +138,18 @@ class SNSUploadTask(Base):
|
|||||||
comment="게시물 캡션/설명",
|
comment="게시물 캡션/설명",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
platform_post_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
comment="업로드 후 플랫폼에서 반환한 게시물 ID",
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_post_url: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(2048),
|
||||||
|
nullable=True,
|
||||||
|
comment="업로드 후 게시물 URL (permalink)",
|
||||||
|
)
|
||||||
|
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# 발행 상태
|
# 발행 상태
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
|
|||||||
122
app/sns/schemas/facebook_schema.py
Normal file
122
app/sns/schemas/facebook_schema.py
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
"""
|
||||||
|
Facebook OAuth API Schemas
|
||||||
|
|
||||||
|
Facebook OAuth 연동 관련 Pydantic 스키마를 정의합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class FacebookConnectResponse(BaseModel):
|
||||||
|
"""Facebook OAuth 연동 시작 응답
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
GET /sns/facebook/connect
|
||||||
|
Facebook OAuth 인증 URL과 CSRF state 토큰을 반환합니다.
|
||||||
|
|
||||||
|
Example Response:
|
||||||
|
{
|
||||||
|
"auth_url": "https://www.facebook.com/v21.0/dialog/oauth?client_id=...",
|
||||||
|
"state": "abc123xyz"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"auth_url": "https://www.facebook.com/v21.0/dialog/oauth?client_id=123&redirect_uri=...",
|
||||||
|
"state": "abc123xyz456",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_url: str = Field(..., description="Facebook 인증 페이지 URL")
|
||||||
|
state: str = Field(..., description="CSRF 방지용 state 토큰")
|
||||||
|
|
||||||
|
|
||||||
|
class FacebookTokenResponse(BaseModel):
|
||||||
|
"""Facebook 토큰 교환 응답
|
||||||
|
|
||||||
|
Facebook Graph API에서 반환하는 액세스 토큰 정보입니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
access_token: str = Field(..., description="액세스 토큰")
|
||||||
|
token_type: str = Field(default="bearer", description="토큰 타입")
|
||||||
|
expires_in: int = Field(..., description="만료 시간 (초)")
|
||||||
|
|
||||||
|
|
||||||
|
class FacebookUserInfo(BaseModel):
|
||||||
|
"""Facebook 사용자 정보
|
||||||
|
|
||||||
|
Graph API /me 엔드포인트에서 반환하는 사용자 프로필 정보입니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = Field(..., description="Facebook 사용자 ID")
|
||||||
|
name: str = Field(..., description="사용자 이름")
|
||||||
|
email: Optional[str] = Field(default=None, description="이메일 (동의 시 제공)")
|
||||||
|
picture: Optional[dict] = Field(default=None, description="프로필 사진 정보")
|
||||||
|
|
||||||
|
|
||||||
|
class FacebookPageInfo(BaseModel):
|
||||||
|
"""Facebook 페이지 정보
|
||||||
|
|
||||||
|
사용자가 관리하는 Facebook 페이지 정보입니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"id": "123456789",
|
||||||
|
"name": "My Business Page",
|
||||||
|
"access_token": "EAA...",
|
||||||
|
"category": "Business",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
id: str = Field(..., description="페이지 ID")
|
||||||
|
name: str = Field(..., description="페이지 이름")
|
||||||
|
access_token: str = Field(..., description="페이지 액세스 토큰")
|
||||||
|
category: Optional[str] = Field(default=None, description="페이지 카테고리")
|
||||||
|
|
||||||
|
|
||||||
|
class FacebookAccountResponse(BaseModel):
|
||||||
|
"""Facebook 연동 완료 응답
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
Facebook OAuth 콜백 처리 완료 후 반환되는 연동 결과입니다.
|
||||||
|
|
||||||
|
Example Response:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Facebook 계정 연동이 완료되었습니다.",
|
||||||
|
"account_id": 1,
|
||||||
|
"platform_user_id": "123456789",
|
||||||
|
"platform_username": "홍길동",
|
||||||
|
"pages": [...]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"success": True,
|
||||||
|
"message": "Facebook 계정 연동이 완료되었습니다.",
|
||||||
|
"account_id": 1,
|
||||||
|
"platform_user_id": "123456789",
|
||||||
|
"platform_username": "홍길동",
|
||||||
|
"pages": [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
success: bool = Field(..., description="연동 성공 여부")
|
||||||
|
message: str = Field(..., description="결과 메시지")
|
||||||
|
account_id: int = Field(..., description="SocialAccount ID")
|
||||||
|
platform_user_id: str = Field(..., description="Facebook 사용자 ID")
|
||||||
|
platform_username: str = Field(..., description="Facebook 사용자 이름")
|
||||||
|
pages: Optional[list[FacebookPageInfo]] = Field(
|
||||||
|
default=None, description="관리 가능한 Facebook 페이지 목록"
|
||||||
|
)
|
||||||
287
app/sns/services/facebook.py
Normal file
287
app/sns/services/facebook.py
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
"""
|
||||||
|
Facebook OAuth 서비스
|
||||||
|
|
||||||
|
Facebook OAuth 연동 관련 비즈니스 로직을 처리합니다.
|
||||||
|
모든 Facebook API 호출은 FacebookOAuthClient를 통해서만 수행됩니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from redis.asyncio import Redis
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.sns.schemas.facebook_schema import (
|
||||||
|
FacebookAccountResponse,
|
||||||
|
FacebookConnectResponse,
|
||||||
|
FacebookPageInfo,
|
||||||
|
)
|
||||||
|
from app.user.models import Platform, SocialAccount
|
||||||
|
from app.utils.facebook_oauth import FacebookOAuthClient
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
from app.utils.timezone import now
|
||||||
|
from config import db_settings, social_oauth_settings
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Facebook OAuth용 Redis 클라이언트 (DB 3 사용 - social과 분리)
|
||||||
|
_redis_client = Redis(
|
||||||
|
host=db_settings.REDIS_HOST,
|
||||||
|
port=db_settings.REDIS_PORT,
|
||||||
|
db=3,
|
||||||
|
decode_responses=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FacebookService:
|
||||||
|
"""
|
||||||
|
Facebook OAuth 연동 서비스
|
||||||
|
|
||||||
|
OAuth 인증 시작, 콜백 처리, 연동 해제 기능을 제공합니다.
|
||||||
|
모든 Facebook Graph API 호출은 FacebookOAuthClient를 통해 수행됩니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Redis key prefix for Facebook OAuth state
|
||||||
|
STATE_KEY_PREFIX = "facebook:oauth:state:"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# FacebookOAuthClient 인스턴스 생성
|
||||||
|
self.oauth_client = FacebookOAuthClient()
|
||||||
|
|
||||||
|
async def start_connect(self, user_uuid: str) -> FacebookConnectResponse:
|
||||||
|
"""
|
||||||
|
Facebook OAuth 연동 시작
|
||||||
|
|
||||||
|
CSRF state 토큰을 생성하고 Redis에 저장한 뒤,
|
||||||
|
Facebook 인증 페이지 URL을 반환합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_uuid: 사용자 UUID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FacebookConnectResponse: 인증 URL 및 state 토큰
|
||||||
|
"""
|
||||||
|
logger.info(f"[FACEBOOK_SVC] 연동 시작 - user_uuid: {user_uuid}")
|
||||||
|
|
||||||
|
# 1. CSRF state 토큰 생성
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
logger.debug(f"[FACEBOOK_SVC] state 토큰 생성 - state: {state[:20]}...")
|
||||||
|
|
||||||
|
# 2. Redis에 state:user_uuid 매핑 저장 (TTL 적용)
|
||||||
|
state_key = f"{self.STATE_KEY_PREFIX}{state}"
|
||||||
|
state_data = json.dumps({"user_uuid": user_uuid})
|
||||||
|
await _redis_client.setex(
|
||||||
|
state_key,
|
||||||
|
social_oauth_settings.OAUTH_STATE_TTL_SECONDS,
|
||||||
|
state_data,
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"[FACEBOOK_SVC] Redis state 저장 - "
|
||||||
|
f"key: {state_key}, ttl: {social_oauth_settings.OAUTH_STATE_TTL_SECONDS}초"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Facebook 인증 페이지 URL 생성
|
||||||
|
auth_url = self.oauth_client.get_authorization_url(state)
|
||||||
|
|
||||||
|
logger.info("[FACEBOOK_SVC] 연동 시작 완료 - 인증 URL 생성됨")
|
||||||
|
|
||||||
|
return FacebookConnectResponse(auth_url=auth_url, state=state)
|
||||||
|
|
||||||
|
async def handle_callback(
|
||||||
|
self, code: str, state: str, session: AsyncSession
|
||||||
|
) -> FacebookAccountResponse:
|
||||||
|
"""
|
||||||
|
Facebook OAuth 콜백 처리
|
||||||
|
|
||||||
|
인가 코드를 토큰으로 교환하고, 장기 토큰 발급 후
|
||||||
|
사용자 정보를 조회하여 SocialAccount에 저장합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: Facebook OAuth 인가 코드
|
||||||
|
state: CSRF 방지용 state 토큰
|
||||||
|
session: DB 세션
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FacebookAccountResponse: 연동 완료 정보
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: state 무효, 토큰 교환 실패 등
|
||||||
|
"""
|
||||||
|
logger.info(f"[FACEBOOK_SVC] 콜백 처리 시작 - state: {state[:20]}...")
|
||||||
|
|
||||||
|
# 1. Redis에서 state로 user_uuid 조회 및 검증
|
||||||
|
state_key = f"{self.STATE_KEY_PREFIX}{state}"
|
||||||
|
state_data_str = await _redis_client.get(state_key)
|
||||||
|
|
||||||
|
if state_data_str is None:
|
||||||
|
logger.warning(f"[FACEBOOK_SVC] state 토큰 없음 또는 만료 - state: {state[:20]}...")
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={
|
||||||
|
"code": "FACEBOOK_STATE_EXPIRED",
|
||||||
|
"message": "인증 세션이 만료되었습니다. 다시 시도해주세요.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# state 데이터 파싱 및 삭제 (일회성)
|
||||||
|
state_data = json.loads(state_data_str)
|
||||||
|
user_uuid = state_data["user_uuid"]
|
||||||
|
await _redis_client.delete(state_key)
|
||||||
|
logger.debug(f"[FACEBOOK_SVC] state 검증 완료 및 삭제 - user_uuid: {user_uuid}")
|
||||||
|
|
||||||
|
# 2. 인가 코드를 단기 액세스 토큰으로 교환
|
||||||
|
short_token_data = await self.oauth_client.get_access_token(code)
|
||||||
|
short_lived_token = short_token_data["access_token"]
|
||||||
|
logger.debug("[FACEBOOK_SVC] 단기 토큰 획득 완료")
|
||||||
|
|
||||||
|
# 3. 단기 토큰을 장기 토큰으로 교환 (약 60일)
|
||||||
|
long_token_data = await self.oauth_client.exchange_long_lived_token(short_lived_token)
|
||||||
|
long_lived_token = long_token_data["access_token"]
|
||||||
|
expires_in = long_token_data.get("expires_in", 5184000) # 기본 60일
|
||||||
|
logger.debug(f"[FACEBOOK_SVC] 장기 토큰 교환 완료 - expires_in: {expires_in}초")
|
||||||
|
|
||||||
|
# 4. 사용자 정보 조회
|
||||||
|
user_info = await self.oauth_client.get_user_info(long_lived_token)
|
||||||
|
facebook_user_id = user_info["id"]
|
||||||
|
facebook_user_name = user_info.get("name", "")
|
||||||
|
facebook_picture = user_info.get("picture", {})
|
||||||
|
logger.debug(
|
||||||
|
f"[FACEBOOK_SVC] 사용자 정보 조회 완료 - "
|
||||||
|
f"id: {facebook_user_id}, name: {facebook_user_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. 사용자 관리 페이지 목록 조회
|
||||||
|
pages = []
|
||||||
|
try:
|
||||||
|
pages_data = await self.oauth_client.get_user_pages(facebook_user_id, long_lived_token)
|
||||||
|
pages = [
|
||||||
|
FacebookPageInfo(
|
||||||
|
id=page["id"],
|
||||||
|
name=page.get("name", ""),
|
||||||
|
access_token=page.get("access_token", ""),
|
||||||
|
category=page.get("category"),
|
||||||
|
)
|
||||||
|
for page in pages_data
|
||||||
|
]
|
||||||
|
logger.debug(f"[FACEBOOK_SVC] 페이지 목록 조회 완료 - 페이지 수: {len(pages)}")
|
||||||
|
except Exception as e:
|
||||||
|
# 페이지 조회 실패는 연동 실패로 처리하지 않음
|
||||||
|
logger.warning(f"[FACEBOOK_SVC] 페이지 목록 조회 실패 (무시) - error: {str(e)}")
|
||||||
|
|
||||||
|
# 6. SocialAccount 생성 또는 업데이트 (UPSERT)
|
||||||
|
token_expires_at = now() + timedelta(seconds=expires_in)
|
||||||
|
platform_data = {
|
||||||
|
"picture_url": facebook_picture.get("data", {}).get("url") if isinstance(facebook_picture, dict) else None,
|
||||||
|
"pages": [page.model_dump() for page in pages],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 기존 계정 조회 (user_uuid + platform + platform_user_id 기준)
|
||||||
|
existing_result = await session.execute(
|
||||||
|
select(SocialAccount).where(
|
||||||
|
SocialAccount.user_uuid == user_uuid,
|
||||||
|
SocialAccount.platform == Platform.FACEBOOK,
|
||||||
|
SocialAccount.platform_user_id == facebook_user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_account = existing_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing_account:
|
||||||
|
# 기존 계정 업데이트 (토큰 갱신 + 재활성화)
|
||||||
|
logger.info(f"[FACEBOOK_SVC] 기존 계정 업데이트 - account_id: {existing_account.id}")
|
||||||
|
existing_account.access_token = long_lived_token
|
||||||
|
existing_account.token_expires_at = token_expires_at
|
||||||
|
existing_account.platform_username = facebook_user_name
|
||||||
|
existing_account.platform_data = platform_data
|
||||||
|
existing_account.scope = self.oauth_client.scope
|
||||||
|
existing_account.is_active = True
|
||||||
|
existing_account.is_deleted = False
|
||||||
|
existing_account.connected_at = now()
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(existing_account)
|
||||||
|
account = existing_account
|
||||||
|
else:
|
||||||
|
# 새 소셜 계정 생성
|
||||||
|
logger.info(f"[FACEBOOK_SVC] 새 계정 생성 - user_uuid: {user_uuid}")
|
||||||
|
account = SocialAccount(
|
||||||
|
user_uuid=user_uuid,
|
||||||
|
platform=Platform.FACEBOOK,
|
||||||
|
access_token=long_lived_token,
|
||||||
|
refresh_token=None, # Facebook은 refresh_token 미지원
|
||||||
|
token_expires_at=token_expires_at,
|
||||||
|
scope=self.oauth_client.scope,
|
||||||
|
platform_user_id=facebook_user_id,
|
||||||
|
platform_username=facebook_user_name,
|
||||||
|
platform_data=platform_data,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
session.add(account)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(account)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[FACEBOOK_SVC] 연동 완료 - "
|
||||||
|
f"account_id: {account.id}, platform_user_id: {facebook_user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return FacebookAccountResponse(
|
||||||
|
success=True,
|
||||||
|
message="Facebook 계정 연동이 완료되었습니다.",
|
||||||
|
account_id=account.id,
|
||||||
|
platform_user_id=facebook_user_id,
|
||||||
|
platform_username=facebook_user_name,
|
||||||
|
pages=pages if pages else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def disconnect(self, user_uuid: str, session: AsyncSession) -> None:
|
||||||
|
"""
|
||||||
|
Facebook 계정 연동 해제
|
||||||
|
|
||||||
|
SocialAccount를 소프트 삭제 처리합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_uuid: 사용자 UUID
|
||||||
|
session: DB 세션
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 연동된 Facebook 계정이 없는 경우
|
||||||
|
"""
|
||||||
|
logger.info(f"[FACEBOOK_SVC] 연동 해제 시작 - user_uuid: {user_uuid}")
|
||||||
|
|
||||||
|
# 활성 Facebook 계정 조회
|
||||||
|
result = await session.execute(
|
||||||
|
select(SocialAccount).where(
|
||||||
|
SocialAccount.user_uuid == user_uuid,
|
||||||
|
SocialAccount.platform == Platform.FACEBOOK,
|
||||||
|
SocialAccount.is_active == True, # noqa: E712
|
||||||
|
SocialAccount.is_deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
account = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if account is None:
|
||||||
|
logger.warning(f"[FACEBOOK_SVC] 연동 해제 대상 없음 - user_uuid: {user_uuid}")
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"code": "FACEBOOK_ACCOUNT_NOT_FOUND",
|
||||||
|
"message": "연동된 Facebook 계정을 찾을 수 없습니다.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 소프트 삭제 처리
|
||||||
|
account.is_active = False
|
||||||
|
account.is_deleted = True
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(f"[FACEBOOK_SVC] 연동 해제 완료 - account_id: {account.id}")
|
||||||
|
|
||||||
|
|
||||||
|
# 모듈 레벨 싱글턴 인스턴스
|
||||||
|
facebook_service = FacebookService()
|
||||||
@ -33,5 +33,5 @@ async def youtube_seo_description(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> YoutubeDescriptionResponse:
|
) -> YoutubeDescriptionResponse:
|
||||||
return await seo_service.get_youtube_seo_description(
|
return await seo_service.get_youtube_seo_description(
|
||||||
request_body.video_id, current_user, session
|
request_body.task_id, current_user, session
|
||||||
)
|
)
|
||||||
|
|||||||
@ -95,6 +95,8 @@ YOUTUBE_SCOPES = [
|
|||||||
"https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필
|
"https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필
|
||||||
]
|
]
|
||||||
|
|
||||||
|
YOUTUBE_SEO_HASH = "SEO_Describtion_YT"
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Instagram/Facebook OAuth Scopes (추후 구현)
|
# Instagram/Facebook OAuth Scopes (추후 구현)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@ -59,7 +59,7 @@ class YouTubeOAuthClient(BaseOAuthClient):
|
|||||||
"response_type": "code",
|
"response_type": "code",
|
||||||
"scope": " ".join(YOUTUBE_SCOPES),
|
"scope": " ".join(YOUTUBE_SCOPES),
|
||||||
"access_type": "offline", # refresh_token 받기 위해 필요
|
"access_type": "offline", # refresh_token 받기 위해 필요
|
||||||
"prompt": "consent", # 항상 동의 화면 표시하여 refresh_token 발급 보장
|
"prompt": "select_account", # 계정 선택만 표시 (동의 화면은 최초 1회만)
|
||||||
"state": state,
|
"state": state,
|
||||||
}
|
}
|
||||||
url = f"{self.AUTHORIZATION_URL}?{urlencode(params)}"
|
url = f"{self.AUTHORIZATION_URL}?{urlencode(params)}"
|
||||||
|
|||||||
@ -8,12 +8,12 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
class YoutubeDescriptionRequest(BaseModel):
|
class YoutubeDescriptionRequest(BaseModel):
|
||||||
"""유튜브 SEO Description 제안 요청"""
|
"""유튜브 SEO Description 제안 요청"""
|
||||||
|
|
||||||
video_id: int = Field(..., description="영상 고유 ID")
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
"video_id": 123
|
"task_id": "019c739f-65fc-7d15-8c88-b31be00e588e"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -56,8 +56,6 @@ class SocialUploadResponse(BaseModel):
|
|||||||
platform: str = Field(..., description="플랫폼명")
|
platform: str = Field(..., description="플랫폼명")
|
||||||
status: str = Field(..., description="업로드 상태")
|
status: str = Field(..., description="업로드 상태")
|
||||||
message: str = Field(..., description="응답 메시지")
|
message: str = Field(..., description="응답 메시지")
|
||||||
scheduled_at: Optional[datetime] = Field(None, description="예약 시간 (예약 업로드 충돌 시 반환)")
|
|
||||||
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
@ -67,7 +65,6 @@ class SocialUploadResponse(BaseModel):
|
|||||||
"platform": "youtube",
|
"platform": "youtube",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"message": "업로드 요청이 접수되었습니다.",
|
"message": "업로드 요청이 접수되었습니다.",
|
||||||
"scheduled_at": "2026-02-02T15:00:00",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -125,7 +122,6 @@ class SocialUploadHistoryItem(BaseModel):
|
|||||||
platform: str = Field(..., description="플랫폼명")
|
platform: str = Field(..., description="플랫폼명")
|
||||||
status: str = Field(..., description="업로드 상태")
|
status: str = Field(..., description="업로드 상태")
|
||||||
title: str = Field(..., description="영상 제목")
|
title: str = Field(..., description="영상 제목")
|
||||||
platform_username: Optional[str] = Field(None, description="플랫폼 채널명")
|
|
||||||
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
|
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지")
|
error_message: Optional[str] = Field(None, description="에러 메시지")
|
||||||
scheduled_at: Optional[datetime] = Field(None, description="예약 게시 시간")
|
scheduled_at: Optional[datetime] = Field(None, description="예약 게시 시간")
|
||||||
|
|||||||
@ -306,7 +306,7 @@ class SocialAccountService:
|
|||||||
else:
|
else:
|
||||||
# DB datetime은 naive, now()는 aware이므로 naive로 통일하여 비교
|
# DB datetime은 naive, now()는 aware이므로 naive로 통일하여 비교
|
||||||
current_time = now().replace(tzinfo=None)
|
current_time = now().replace(tzinfo=None)
|
||||||
buffer_time = current_time + timedelta(minutes=20)
|
buffer_time = current_time + timedelta(hours=1)
|
||||||
if account.token_expires_at <= buffer_time:
|
if account.token_expires_at <= buffer_time:
|
||||||
should_refresh = True
|
should_refresh = True
|
||||||
|
|
||||||
|
|||||||
@ -1,132 +1,88 @@
|
|||||||
"""
|
"""
|
||||||
유튜브 SEO 서비스
|
유튜브 SEO 서비스
|
||||||
|
|
||||||
영상 제목/설명/해시태그를 생성하고 video 테이블에 저장합니다.
|
SEO description 생성 및 Redis 캐싱 로직을 처리합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException
|
||||||
|
from redis.asyncio import Redis
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import db_settings
|
||||||
from app.home.models import MarketingIntel, Project
|
from app.home.models import MarketingIntel, Project
|
||||||
|
from app.social.constants import YOUTUBE_SEO_HASH
|
||||||
from app.social.schemas import YoutubeDescriptionResponse
|
from app.social.schemas import YoutubeDescriptionResponse
|
||||||
from app.social.services.sns_metadata import apply_sns_metadata, has_stored_sns_metadata
|
|
||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.video.models import Video
|
from app.utils.chatgpt_prompt import ChatgptService
|
||||||
|
from app.utils.prompts.prompts import yt_upload_prompt
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
redis_seo_client = Redis(
|
||||||
|
host=db_settings.REDIS_HOST,
|
||||||
|
port=db_settings.REDIS_PORT,
|
||||||
|
db=0,
|
||||||
|
decode_responses=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SeoService:
|
class SeoService:
|
||||||
"""유튜브 SEO 비즈니스 로직 서비스"""
|
"""유튜브 SEO 비즈니스 로직 서비스"""
|
||||||
|
|
||||||
async def get_youtube_seo_description(
|
async def get_youtube_seo_description(
|
||||||
self,
|
self,
|
||||||
video_id: int,
|
task_id: str,
|
||||||
current_user: User,
|
current_user: User,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> YoutubeDescriptionResponse:
|
) -> YoutubeDescriptionResponse:
|
||||||
"""
|
"""
|
||||||
저장된 SNS 메타데이터를 반환하거나, 없으면 생성 후 video에 저장합니다.
|
유튜브 SEO description 생성
|
||||||
|
|
||||||
|
Redis 캐시 확인 후 miss이면 GPT로 생성하고 캐싱.
|
||||||
"""
|
"""
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[SEO_SERVICE] Load metadata - user: {current_user.user_uuid} / video_id: {video_id}"
|
f"[SEO_SERVICE] Try Cache - user: {current_user.user_uuid} / task_id: {task_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
video = await self._get_owned_video(video_id, current_user.user_uuid, session)
|
cached = await self._get_from_redis(task_id)
|
||||||
if video is None:
|
if cached:
|
||||||
raise HTTPException(
|
return cached
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"video_id '{video_id}'에 해당하는 영상을 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if has_stored_sns_metadata(video):
|
logger.info(f"[SEO_SERVICE] Cache miss - user: {current_user.user_uuid}")
|
||||||
return self._response_from_video(video)
|
result = await self._generate_seo_description(task_id, current_user, session)
|
||||||
|
await self._set_to_redis(task_id, result)
|
||||||
|
|
||||||
result = await self.generate_and_save_for_video(video.id, session)
|
|
||||||
if result is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"video_id '{video_id}'에 해당하는 영상을 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def generate_and_save_for_video(
|
|
||||||
self,
|
|
||||||
video_id: int,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> YoutubeDescriptionResponse | None:
|
|
||||||
"""GPT로 SEO를 생성해 지정한 video 행에 저장합니다. 워커/온디맨드 공용."""
|
|
||||||
video_result = await session.execute(select(Video).where(Video.id == video_id))
|
|
||||||
video = video_result.scalar_one_or_none()
|
|
||||||
if video is None:
|
|
||||||
logger.warning(f"[SEO_SERVICE] Video NOT FOUND - video_id: {video_id}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if has_stored_sns_metadata(video):
|
|
||||||
return self._response_from_video(video)
|
|
||||||
|
|
||||||
result = await self._generate_seo_description(video.task_id, session)
|
|
||||||
apply_sns_metadata(video, result.title, result.description, result.keywords)
|
|
||||||
await session.flush()
|
|
||||||
logger.info(f"[SEO_SERVICE] Saved metadata - video_id: {video_id}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def _get_owned_video(
|
|
||||||
self,
|
|
||||||
video_id: int,
|
|
||||||
user_uuid: str,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> Video | None:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Video)
|
|
||||||
.join(Project, Project.id == Video.project_id)
|
|
||||||
.where(
|
|
||||||
Video.id == video_id,
|
|
||||||
Project.user_uuid == user_uuid,
|
|
||||||
Video.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
async def _generate_seo_description(
|
async def _generate_seo_description(
|
||||||
self,
|
self,
|
||||||
task_id: str,
|
task_id: str,
|
||||||
|
current_user: User,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> YoutubeDescriptionResponse:
|
) -> YoutubeDescriptionResponse:
|
||||||
"""GPT를 사용하여 SEO description 생성"""
|
"""GPT를 사용하여 SEO description 생성"""
|
||||||
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
logger.info(f"[SEO_SERVICE] Generating SEO - user: {current_user.user_uuid}")
|
||||||
from app.utils.prompts.prompts import yt_upload_prompt
|
|
||||||
|
|
||||||
logger.info(f"[SEO_SERVICE] Generating SEO - task_id: {task_id}")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
project_result = await session.execute(
|
project_result = await session.execute(
|
||||||
select(Project)
|
select(Project)
|
||||||
.where(Project.task_id == task_id)
|
.where(
|
||||||
|
Project.task_id == task_id,
|
||||||
|
Project.user_uuid == current_user.user_uuid,
|
||||||
|
)
|
||||||
.order_by(Project.created_at.desc())
|
.order_by(Project.created_at.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
project = project_result.scalar_one_or_none()
|
project = project_result.scalar_one_or_none()
|
||||||
if project is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
marketing_result = await session.execute(
|
marketing_result = await session.execute(
|
||||||
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
||||||
)
|
)
|
||||||
marketing_intelligence = marketing_result.scalar_one_or_none()
|
marketing_intelligence = marketing_result.scalar_one_or_none()
|
||||||
if marketing_intelligence is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="마케팅 인텔리전스를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
hashtags = marketing_intelligence.intel_result["target_keywords"]
|
hashtags = marketing_intelligence.intel_result["target_keywords"]
|
||||||
|
|
||||||
@ -138,13 +94,10 @@ class SeoService:
|
|||||||
),
|
),
|
||||||
"language": project.language,
|
"language": project.language,
|
||||||
"target_keywords": hashtags,
|
"target_keywords": hashtags,
|
||||||
"industry": project.industry or "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
chatgpt = ChatgptService(timeout=180)
|
chatgpt = ChatgptService(timeout=180)
|
||||||
yt_seo_output = await chatgpt.generate_structured_output(
|
yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data)
|
||||||
yt_upload_prompt, yt_seo_input_data
|
|
||||||
)
|
|
||||||
|
|
||||||
return YoutubeDescriptionResponse(
|
return YoutubeDescriptionResponse(
|
||||||
title=yt_seo_output.title,
|
title=yt_seo_output.title,
|
||||||
@ -152,8 +105,6 @@ class SeoService:
|
|||||||
keywords=hashtags,
|
keywords=hashtags,
|
||||||
)
|
)
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[SEO_SERVICE] EXCEPTION - error: {e}")
|
logger.error(f"[SEO_SERVICE] EXCEPTION - error: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@ -161,12 +112,18 @@ class SeoService:
|
|||||||
detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}",
|
detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _response_from_video(self, video: Video) -> YoutubeDescriptionResponse:
|
async def _get_from_redis(self, task_id: str) -> YoutubeDescriptionResponse | None:
|
||||||
return YoutubeDescriptionResponse(
|
field = f"task_id:{task_id}"
|
||||||
title=video.title or "",
|
yt_seo_info = await redis_seo_client.hget(YOUTUBE_SEO_HASH, field)
|
||||||
description=video.description or "",
|
if yt_seo_info:
|
||||||
keywords=list(video.hashtags or []),
|
return YoutubeDescriptionResponse(**json.loads(yt_seo_info))
|
||||||
)
|
return None
|
||||||
|
|
||||||
|
async def _set_to_redis(self, task_id: str, yt_seo: YoutubeDescriptionResponse) -> None:
|
||||||
|
field = f"task_id:{task_id}"
|
||||||
|
yt_seo_info = json.dumps(yt_seo.model_dump(), ensure_ascii=False)
|
||||||
|
await redis_seo_client.hset(YOUTUBE_SEO_HASH, field, yt_seo_info)
|
||||||
|
await redis_seo_client.expire(YOUTUBE_SEO_HASH, 3600)
|
||||||
|
|
||||||
|
|
||||||
seo_service = SeoService()
|
seo_service = SeoService()
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
"""SNS 업로드용 영상 메타데이터 비교/반영 헬퍼."""
|
|
||||||
|
|
||||||
from app.video.models import Video
|
|
||||||
|
|
||||||
|
|
||||||
def has_stored_sns_metadata(video: Video) -> bool:
|
|
||||||
"""video 행에 SNS 제목이 이미 저장되어 있는지 확인합니다."""
|
|
||||||
return bool(video.title)
|
|
||||||
|
|
||||||
|
|
||||||
def sns_metadata_changed(
|
|
||||||
video: Video,
|
|
||||||
title: str,
|
|
||||||
description: str | None,
|
|
||||||
tags: list[str] | None,
|
|
||||||
) -> bool:
|
|
||||||
"""게시 폼 값이 저장된 SNS 메타데이터와 다른지 비교합니다."""
|
|
||||||
stored_tags = list(video.hashtags or [])
|
|
||||||
incoming_tags = list(tags or [])
|
|
||||||
return (
|
|
||||||
(video.title or "") != title
|
|
||||||
or (video.description or "") != (description or "")
|
|
||||||
or stored_tags != incoming_tags
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_sns_metadata(
|
|
||||||
video: Video,
|
|
||||||
title: str,
|
|
||||||
description: str | None,
|
|
||||||
hashtags: list[str] | None,
|
|
||||||
) -> None:
|
|
||||||
"""video 행에 SNS 메타데이터를 반영합니다."""
|
|
||||||
video.title = title
|
|
||||||
video.description = description
|
|
||||||
video.hashtags = list(hashtags or [])
|
|
||||||
@ -10,7 +10,7 @@ from datetime import datetime
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import BackgroundTasks, HTTPException, status
|
from fastapi import BackgroundTasks, HTTPException, status
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from config import TIMEZONE
|
from config import TIMEZONE
|
||||||
@ -26,7 +26,6 @@ from app.social.schemas import (
|
|||||||
SocialUploadRequest,
|
SocialUploadRequest,
|
||||||
)
|
)
|
||||||
from app.social.services.account_service import SocialAccountService
|
from app.social.services.account_service import SocialAccountService
|
||||||
from app.social.services.sns_metadata import apply_sns_metadata, sns_metadata_changed
|
|
||||||
from app.social.worker.upload_task import process_social_upload
|
from app.social.worker.upload_task import process_social_upload
|
||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.video.models import Video
|
from app.video.models import Video
|
||||||
@ -77,12 +76,6 @@ class SocialUploadService:
|
|||||||
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
|
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if sns_metadata_changed(video, body.title, body.description, body.tags):
|
|
||||||
apply_sns_metadata(video, body.title, body.description, body.tags)
|
|
||||||
logger.info(
|
|
||||||
f"[UPLOAD_SERVICE] video SNS 메타데이터 갱신 - video_id: {body.video_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. 소셜 계정 조회 및 소유권 검증
|
# 2. 소셜 계정 조회 및 소유권 검증
|
||||||
account = await self._account_service.get_account_by_id(
|
account = await self._account_service.get_account_by_id(
|
||||||
user_uuid=current_user.user_uuid,
|
user_uuid=current_user.user_uuid,
|
||||||
@ -97,19 +90,12 @@ class SocialUploadService:
|
|||||||
)
|
)
|
||||||
raise SocialAccountNotFoundError()
|
raise SocialAccountNotFoundError()
|
||||||
|
|
||||||
# 3. 중복 업로드 확인
|
# 3. 진행 중인 업로드 확인 (pending 또는 uploading 상태만)
|
||||||
now_kst_naive = datetime.now(TIMEZONE).replace(tzinfo=None)
|
|
||||||
|
|
||||||
# 3-1. 진행 중인 업로드 확인 (즉시 pending 또는 uploading)
|
|
||||||
in_progress_result = await session.execute(
|
in_progress_result = await session.execute(
|
||||||
select(SocialUpload).where(
|
select(SocialUpload).where(
|
||||||
SocialUpload.video_id == body.video_id,
|
SocialUpload.video_id == body.video_id,
|
||||||
SocialUpload.social_account_id == account.id,
|
SocialUpload.social_account_id == account.id,
|
||||||
SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]),
|
SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]),
|
||||||
or_(
|
|
||||||
SocialUpload.scheduled_at.is_(None),
|
|
||||||
SocialUpload.scheduled_at <= now_kst_naive,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
in_progress_upload = in_progress_result.scalar_one_or_none()
|
in_progress_upload = in_progress_result.scalar_one_or_none()
|
||||||
@ -126,32 +112,6 @@ class SocialUploadService:
|
|||||||
message="이미 업로드가 진행 중입니다.",
|
message="이미 업로드가 진행 중입니다.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3-2. 미래 예약 업로드 확인
|
|
||||||
scheduled_result = await session.execute(
|
|
||||||
select(SocialUpload).where(
|
|
||||||
SocialUpload.video_id == body.video_id,
|
|
||||||
SocialUpload.social_account_id == account.id,
|
|
||||||
SocialUpload.status == UploadStatus.PENDING.value,
|
|
||||||
SocialUpload.scheduled_at.isnot(None),
|
|
||||||
SocialUpload.scheduled_at > now_kst_naive,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
scheduled_upload = scheduled_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if scheduled_upload:
|
|
||||||
logger.info(
|
|
||||||
f"[UPLOAD_SERVICE] 예약된 업로드 존재 - "
|
|
||||||
f"upload_id: {scheduled_upload.id}, scheduled_at: {scheduled_upload.scheduled_at}"
|
|
||||||
)
|
|
||||||
return SocialUploadResponse(
|
|
||||||
success=False,
|
|
||||||
upload_id=scheduled_upload.id,
|
|
||||||
platform=account.platform,
|
|
||||||
status=scheduled_upload.status,
|
|
||||||
message="이미 예약된 업로드가 있습니다.",
|
|
||||||
scheduled_at=scheduled_upload.scheduled_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. 업로드 순번 계산
|
# 4. 업로드 순번 계산
|
||||||
max_seq_result = await session.execute(
|
max_seq_result = await session.execute(
|
||||||
select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where(
|
select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where(
|
||||||
@ -193,6 +153,7 @@ class SocialUploadService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 6. 즉시 업로드이면 백그라운드 태스크 등록
|
# 6. 즉시 업로드이면 백그라운드 태스크 등록
|
||||||
|
now_kst_naive = datetime.now(TIMEZONE).replace(tzinfo=None)
|
||||||
is_scheduled = body.scheduled_at and body.scheduled_at > now_kst_naive
|
is_scheduled = body.scheduled_at and body.scheduled_at > now_kst_naive
|
||||||
if not is_scheduled:
|
if not is_scheduled:
|
||||||
background_tasks.add_task(process_social_upload, social_upload.id)
|
background_tasks.add_task(process_social_upload, social_upload.id)
|
||||||
@ -330,7 +291,6 @@ class SocialUploadService:
|
|||||||
platform=upload.platform,
|
platform=upload.platform,
|
||||||
status=upload.status,
|
status=upload.status,
|
||||||
title=upload.title,
|
title=upload.title,
|
||||||
platform_username=upload.social_account.platform_data.get("display_name") if upload.social_account and upload.social_account.platform_data else None,
|
|
||||||
platform_url=upload.platform_url,
|
platform_url=upload.platform_url,
|
||||||
error_message=upload.error_message,
|
error_message=upload.error_message,
|
||||||
scheduled_at=upload.scheduled_at,
|
scheduled_at=upload.scheduled_at,
|
||||||
|
|||||||
@ -144,7 +144,7 @@ class YouTubeUploader(BaseSocialUploader):
|
|||||||
body = {
|
body = {
|
||||||
"snippet": {
|
"snippet": {
|
||||||
"title": metadata.title,
|
"title": metadata.title,
|
||||||
"description": self._sanitize_description(metadata.description),
|
"description": metadata.description or "",
|
||||||
"tags": metadata.tags or [],
|
"tags": metadata.tags or [],
|
||||||
"categoryId": self._get_category_id(metadata),
|
"categoryId": self._get_category_id(metadata),
|
||||||
},
|
},
|
||||||
@ -380,12 +380,6 @@ class YouTubeUploader(BaseSocialUploader):
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _sanitize_description(self, description: str | None) -> str:
|
|
||||||
"""YouTube API가 거부하는 문자를 제거합니다. (<, > 포함 시 invalidVideoDescription 오류 발생)"""
|
|
||||||
if not description:
|
|
||||||
return ""
|
|
||||||
return description.replace("<", "").replace(">", "")
|
|
||||||
|
|
||||||
def _convert_privacy_status(self, privacy_status: PrivacyStatus) -> str:
|
def _convert_privacy_status(self, privacy_status: PrivacyStatus) -> str:
|
||||||
"""
|
"""
|
||||||
PrivacyStatus를 YouTube API 형식으로 변환
|
PrivacyStatus를 YouTube API 형식으로 변환
|
||||||
|
|||||||
@ -35,33 +35,6 @@ logger = get_logger("song")
|
|||||||
|
|
||||||
router = APIRouter(prefix="/song", tags=["Song"])
|
router = APIRouter(prefix="/song", tags=["Song"])
|
||||||
|
|
||||||
TARGET_SONG_DURATION_SECONDS = 40.0
|
|
||||||
|
|
||||||
|
|
||||||
def _select_clip_by_duration(
|
|
||||||
clips_data: list[dict], target_seconds: float = TARGET_SONG_DURATION_SECONDS
|
|
||||||
) -> dict:
|
|
||||||
"""생성된 클립 중 목표 길이(target_seconds)에 가장 가까운 클립을 선택합니다.
|
|
||||||
|
|
||||||
길이 차이가 동일하면 먼저 등장한 클립(더 낮은 인덱스)을 선택합니다.
|
|
||||||
duration 정보가 없는 클립은 비교 대상에서 제외되며, 모든 클립에 duration이 없으면
|
|
||||||
첫 번째 클립을 반환합니다.
|
|
||||||
"""
|
|
||||||
best_clip = None
|
|
||||||
best_diff = None
|
|
||||||
|
|
||||||
for clip in clips_data:
|
|
||||||
duration = clip.get("duration")
|
|
||||||
if duration is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
diff = abs(duration - target_seconds)
|
|
||||||
if best_diff is None or diff < best_diff:
|
|
||||||
best_diff = diff
|
|
||||||
best_clip = clip
|
|
||||||
|
|
||||||
return best_clip if best_clip is not None else clips_data[0]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/generate/{task_id}",
|
"/generate/{task_id}",
|
||||||
@ -99,7 +72,7 @@ curl -X POST "http://localhost:8000/song/generate/019123ab-cdef-7890-abcd-ef1234
|
|||||||
```
|
```
|
||||||
|
|
||||||
## 참고
|
## 참고
|
||||||
- 생성되는 노래는 약 40초 내외 길이입니다.
|
- 생성되는 노래는 약 1분 이내 길이입니다.
|
||||||
- song_id를 사용하여 /status/{song_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다.
|
- song_id를 사용하여 /status/{song_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다.
|
||||||
- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다.
|
- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다.
|
||||||
""",
|
""",
|
||||||
@ -130,22 +103,6 @@ async def generate_song(
|
|||||||
from app.database.session import AsyncSessionLocal
|
from app.database.session import AsyncSessionLocal
|
||||||
|
|
||||||
request_start = time.perf_counter()
|
request_start = time.perf_counter()
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
user = (await session.execute(
|
|
||||||
select(User).where(User.user_uuid == current_user.user_uuid)
|
|
||||||
)).scalar_one()
|
|
||||||
|
|
||||||
if user.credits <= 0:
|
|
||||||
logger.info(f"크레딧 부족, user_uuid: {current_user.user_uuid}, credits: {user.credits}")
|
|
||||||
return GenerateSongResponse(
|
|
||||||
success=False,
|
|
||||||
task_id=task_id,
|
|
||||||
song_id=None,
|
|
||||||
message="No credits remaining.",
|
|
||||||
error_message="No credits remaining.",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[generate_song] START - task_id: {task_id}, "
|
f"[generate_song] START - task_id: {task_id}, "
|
||||||
f"genre: {request_body.genre}, language: {request_body.language}"
|
f"genre: {request_body.genre}, language: {request_body.language}"
|
||||||
@ -212,10 +169,9 @@ async def generate_song(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Song 테이블에 초기 데이터 저장
|
# Song 테이블에 초기 데이터 저장
|
||||||
if request_body.instrumental:
|
song_prompt = (
|
||||||
song_prompt = f"[Instrumental]\n[Genre]\n{request_body.genre}"
|
f"[Lyrics]\n{request_body.lyrics}\n\n[Genre]\n{request_body.genre}"
|
||||||
else:
|
)
|
||||||
song_prompt = f"[Lyrics]\n{request_body.lyrics}\n\n[Genre]\n{request_body.genre}"
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[generate_song] Lyrics comparison - task_id: {task_id}\n"
|
f"[generate_song] Lyrics comparison - task_id: {task_id}\n"
|
||||||
f"{'=' * 60}\n"
|
f"{'=' * 60}\n"
|
||||||
@ -277,7 +233,6 @@ async def generate_song(
|
|||||||
suno_task_id = await suno_service.generate(
|
suno_task_id = await suno_service.generate(
|
||||||
prompt=request_body.lyrics,
|
prompt=request_body.lyrics,
|
||||||
genre=request_body.genre,
|
genre=request_body.genre,
|
||||||
instrumental=request_body.instrumental,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
stage2_time = time.perf_counter()
|
stage2_time = time.perf_counter()
|
||||||
@ -440,14 +395,12 @@ async def get_song_status(
|
|||||||
clips_data = response_data.get("sunoData") or []
|
clips_data = response_data.get("sunoData") or []
|
||||||
|
|
||||||
if clips_data:
|
if clips_data:
|
||||||
# 생성된 클립(보통 2개) 중 목표 길이(1분)에 가장 가까운 클립 선택 (동일하면 첫 번째)
|
# 첫 번째 클립(clips[0])의 audioUrl과 duration 사용
|
||||||
first_clip = _select_clip_by_duration(clips_data)
|
first_clip = clips_data[0]
|
||||||
audio_url = first_clip.get("audioUrl")
|
audio_url = first_clip.get("audioUrl")
|
||||||
clip_duration = first_clip.get("duration")
|
clip_duration = first_clip.get("duration")
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[get_song_status] Selected clip by duration - id: {first_clip.get('id')}, "
|
f"[get_song_status] Using first clip - id: {first_clip.get('id')}, audio_url: {audio_url}, duration: {clip_duration}"
|
||||||
f"audio_url: {audio_url}, duration: {clip_duration}, "
|
|
||||||
f"candidates: {[(c.get('id'), c.get('duration')) for c in clips_data]}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if audio_url:
|
if audio_url:
|
||||||
@ -483,8 +436,14 @@ async def get_song_status(
|
|||||||
)
|
)
|
||||||
|
|
||||||
suno_audio_id = first_clip.get("id")
|
suno_audio_id = first_clip.get("id")
|
||||||
|
word_data = await suno_service.get_lyric_timestamp(
|
||||||
# BGM 모드(lyric_result가 비어 있음)에서는 타임스탬프 생성 스킵
|
suno_task_id, suno_audio_id
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"[get_song_status] word_data from get_lyric_timestamp - "
|
||||||
|
f"suno_task_id: {suno_task_id}, suno_audio_id: {suno_audio_id}, "
|
||||||
|
f"word_data: {word_data}"
|
||||||
|
)
|
||||||
lyric_result = await session.execute(
|
lyric_result = await session.execute(
|
||||||
select(Lyric)
|
select(Lyric)
|
||||||
.where(Lyric.task_id == song.task_id)
|
.where(Lyric.task_id == song.task_id)
|
||||||
@ -492,31 +451,7 @@ async def get_song_status(
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
lyric = lyric_result.scalar_one_or_none()
|
lyric = lyric_result.scalar_one_or_none()
|
||||||
gt_lyric = lyric.lyric_result if lyric else None
|
gt_lyric = lyric.lyric_result
|
||||||
|
|
||||||
if gt_lyric:
|
|
||||||
word_data = await suno_service.get_lyric_timestamp(
|
|
||||||
suno_task_id, suno_audio_id
|
|
||||||
)
|
|
||||||
|
|
||||||
# None이면 Suno 타임스탬프가 아직 미준비 상태.
|
|
||||||
# processing을 반환해 클라이언트가 폴링을 계속하도록 한다.
|
|
||||||
if word_data is None:
|
|
||||||
logger.info(
|
|
||||||
f"[get_song_status] 타임스탬프 미준비 - 폴링 유지, "
|
|
||||||
f"suno_task_id: {suno_task_id}, suno_audio_id: {suno_audio_id}"
|
|
||||||
)
|
|
||||||
return PollingSongResponse(
|
|
||||||
success=True,
|
|
||||||
status="processing",
|
|
||||||
message="타임스탬프 생성 중입니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"[get_song_status] word_data from get_lyric_timestamp - "
|
|
||||||
f"suno_task_id: {suno_task_id}, suno_audio_id: {suno_audio_id}, "
|
|
||||||
f"word_data: {word_data}"
|
|
||||||
)
|
|
||||||
lyric_line_list = gt_lyric.split("\n")
|
lyric_line_list = gt_lyric.split("\n")
|
||||||
sentences = [
|
sentences = [
|
||||||
lyric_line.strip(",. ")
|
lyric_line.strip(",. ")
|
||||||
@ -531,10 +466,16 @@ async def get_song_status(
|
|||||||
timestamped_lyrics = suno_service.align_lyrics(
|
timestamped_lyrics = suno_service.align_lyrics(
|
||||||
word_data, sentences
|
word_data, sentences
|
||||||
)
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"[get_song_status] sentences from lyric - "
|
||||||
|
f"sentences: {sentences}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO : DB upload timestamped_lyrics
|
||||||
for order_idx, timestamped_lyric in enumerate(
|
for order_idx, timestamped_lyric in enumerate(
|
||||||
timestamped_lyrics
|
timestamped_lyrics
|
||||||
):
|
):
|
||||||
|
# start_sec 또는 end_sec가 None인 경우 건너뛰기
|
||||||
if (
|
if (
|
||||||
timestamped_lyric["start_sec"] is None
|
timestamped_lyric["start_sec"] is None
|
||||||
or timestamped_lyric["end_sec"] is None
|
or timestamped_lyric["end_sec"] is None
|
||||||
@ -555,11 +496,6 @@ async def get_song_status(
|
|||||||
end_time=timestamped_lyric["end_sec"],
|
end_time=timestamped_lyric["end_sec"],
|
||||||
)
|
)
|
||||||
session.add(song_timestamp)
|
session.add(song_timestamp)
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
f"[get_song_status] BGM 모드 - 타임스탬프 생성 스킵, "
|
|
||||||
f"suno_task_id: {suno_task_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
parsed_response.status = "processing"
|
parsed_response.status = "processing"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@ -33,7 +33,7 @@ class GenerateSongRequest(BaseModel):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lyrics: Optional[str] = Field(None, description="노래에 사용할 가사 (instrumental=True이면 생략 가능)")
|
lyrics: str = Field(..., description="노래에 사용할 가사")
|
||||||
genre: str = Field(
|
genre: str = Field(
|
||||||
...,
|
...,
|
||||||
description="음악 장르 (K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz 등)",
|
description="음악 장르 (K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz 등)",
|
||||||
@ -42,15 +42,6 @@ class GenerateSongRequest(BaseModel):
|
|||||||
default="Korean",
|
default="Korean",
|
||||||
description="노래 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
description="노래 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
)
|
)
|
||||||
instrumental: bool = Field(default=False, description="BGM 전용 모드 (가사 없이 음악만 생성)")
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def validate_lyrics_required(self) -> "GenerateSongRequest":
|
|
||||||
if not self.instrumental and not self.lyrics:
|
|
||||||
raise ValueError("instrumental=False일 때 lyrics는 필수입니다.")
|
|
||||||
if self.instrumental:
|
|
||||||
self.lyrics = None
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
class GenerateSongResponse(BaseModel):
|
class GenerateSongResponse(BaseModel):
|
||||||
|
|||||||
@ -7,14 +7,14 @@ from sqlalchemy import Connection, text
|
|||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.lyric.schemas.lyrics_schema import (
|
from app.lyrics.schemas.lyrics_schema import (
|
||||||
AttributeData,
|
AttributeData,
|
||||||
PromptTemplateData,
|
PromptTemplateData,
|
||||||
SongFormData,
|
SongFormData,
|
||||||
SongSampleData,
|
SongSampleData,
|
||||||
StoreData,
|
StoreData,
|
||||||
)
|
)
|
||||||
from app.utils.prompts.chatgpt_prompt import chatgpt_api
|
from app.utils.chatgpt_prompt import chatgpt_api
|
||||||
|
|
||||||
logger = get_logger("song")
|
logger = get_logger("song")
|
||||||
|
|
||||||
|
|||||||
@ -23,7 +23,6 @@ logger = logging.getLogger(__name__)
|
|||||||
from app.user.dependencies import get_current_user
|
from app.user.dependencies import get_current_user
|
||||||
from app.user.models import RefreshToken, User
|
from app.user.models import RefreshToken, User
|
||||||
from app.user.schemas.user_schema import (
|
from app.user.schemas.user_schema import (
|
||||||
CreditResponse,
|
|
||||||
KakaoCodeRequest,
|
KakaoCodeRequest,
|
||||||
KakaoLoginResponse,
|
KakaoLoginResponse,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
@ -158,12 +157,10 @@ async def kakao_callback(
|
|||||||
logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}")
|
logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}")
|
||||||
|
|
||||||
# 프론트엔드로 토큰과 함께 리다이렉트
|
# 프론트엔드로 토큰과 함께 리다이렉트
|
||||||
# is_new_user: 프론트가 신규 가입 시에만 Meta CompleteRegistration 전환 추적을 호출하도록 전달
|
|
||||||
redirect_url = (
|
redirect_url = (
|
||||||
f"{prj_settings.PROJECT_DOMAIN}"
|
f"{prj_settings.PROJECT_DOMAIN}"
|
||||||
f"?access_token={result.access_token}"
|
f"?access_token={result.access_token}"
|
||||||
f"&refresh_token={result.refresh_token}"
|
f"&refresh_token={result.refresh_token}"
|
||||||
f"&is_new_user={str(result.is_new_user).lower()}"
|
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[ROUTER] 카카오 콜백 완료, 프론트엔드로 리다이렉트 - redirect_url: {redirect_url[:50]}..."
|
f"[ROUTER] 카카오 콜백 완료, 프론트엔드로 리다이렉트 - redirect_url: {redirect_url[:50]}..."
|
||||||
@ -356,22 +353,6 @@ async def get_me(
|
|||||||
return UserResponse.model_validate(current_user)
|
return UserResponse.model_validate(current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/me/credits",
|
|
||||||
response_model=CreditResponse,
|
|
||||||
summary="잔여 크레딧 조회",
|
|
||||||
description="현재 로그인한 사용자의 잔여 영상 생성 크레딧을 반환합니다.",
|
|
||||||
responses={
|
|
||||||
200: {"description": "조회 성공"},
|
|
||||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def get_my_credits(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
) -> CreditResponse:
|
|
||||||
return CreditResponse(credits=current_user.credits)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 테스트용 엔드포인트 (DEBUG 모드에서만 main.py에서 라우터가 등록됨)
|
# 테스트용 엔드포인트 (DEBUG 모드에서만 main.py에서 라우터가 등록됨)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@ -1,35 +1,20 @@
|
|||||||
import logging
|
from sqladmin import ModelView
|
||||||
|
|
||||||
from sqladmin import ModelView, action
|
from app.user.models import RefreshToken, SocialAccount, User
|
||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import RedirectResponse
|
|
||||||
|
|
||||||
from app.backoffice.mixins import SuperAdminEditable, ViewerAccessible
|
|
||||||
from app.backoffice.user_view_actions import (
|
|
||||||
handle_block_users,
|
|
||||||
handle_deduct_credits,
|
|
||||||
handle_grant_credits,
|
|
||||||
)
|
|
||||||
from app.user.models import SocialAccount, User
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class UserAdmin(SuperAdminEditable, ModelView, model=User):
|
class UserAdmin(ModelView, model=User):
|
||||||
name = "사용자"
|
name = "사용자"
|
||||||
name_plural = "사용자 목록"
|
name_plural = "사용자 목록"
|
||||||
icon = "fa-solid fa-user"
|
icon = "fa-solid fa-user"
|
||||||
category = "사용자 관리"
|
category = "사용자 관리"
|
||||||
page_size = 30
|
page_size = 20
|
||||||
can_edit = True
|
|
||||||
can_delete = True
|
|
||||||
|
|
||||||
column_list = [
|
column_list = [
|
||||||
"id",
|
"id",
|
||||||
"user_uuid",
|
"kakao_id",
|
||||||
"email",
|
"email",
|
||||||
"nickname",
|
"nickname",
|
||||||
"credits",
|
|
||||||
"role",
|
"role",
|
||||||
"is_active",
|
"is_active",
|
||||||
"is_deleted",
|
"is_deleted",
|
||||||
@ -38,7 +23,7 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
|
|||||||
|
|
||||||
column_details_list = [
|
column_details_list = [
|
||||||
"id",
|
"id",
|
||||||
"user_uuid",
|
"kakao_id",
|
||||||
"email",
|
"email",
|
||||||
"nickname",
|
"nickname",
|
||||||
"profile_image_url",
|
"profile_image_url",
|
||||||
@ -47,7 +32,6 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
|
|||||||
"name",
|
"name",
|
||||||
"birth_date",
|
"birth_date",
|
||||||
"gender",
|
"gender",
|
||||||
"credits",
|
|
||||||
"is_active",
|
"is_active",
|
||||||
"is_admin",
|
"is_admin",
|
||||||
"role",
|
"role",
|
||||||
@ -58,22 +42,16 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
|
|||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
form_columns = [
|
form_excluded_columns = [
|
||||||
"nickname",
|
"created_at",
|
||||||
"email",
|
"updated_at",
|
||||||
"phone",
|
"projects",
|
||||||
"name",
|
"refresh_tokens",
|
||||||
"birth_date",
|
"social_accounts",
|
||||||
"gender",
|
|
||||||
"credits",
|
|
||||||
"is_active",
|
|
||||||
"is_admin",
|
|
||||||
"role",
|
|
||||||
"is_deleted",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
column_searchable_list = [
|
column_searchable_list = [
|
||||||
User.user_uuid,
|
User.kakao_id,
|
||||||
User.email,
|
User.email,
|
||||||
User.nickname,
|
User.nickname,
|
||||||
User.phone,
|
User.phone,
|
||||||
@ -84,10 +62,9 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
|
|||||||
|
|
||||||
column_sortable_list = [
|
column_sortable_list = [
|
||||||
User.id,
|
User.id,
|
||||||
User.user_uuid,
|
User.kakao_id,
|
||||||
User.email,
|
User.email,
|
||||||
User.nickname,
|
User.nickname,
|
||||||
User.credits,
|
|
||||||
User.role,
|
User.role,
|
||||||
User.is_active,
|
User.is_active,
|
||||||
User.is_deleted,
|
User.is_deleted,
|
||||||
@ -96,16 +73,15 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
|
|||||||
|
|
||||||
column_labels = {
|
column_labels = {
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"user_uuid": "UUID",
|
"kakao_id": "카카오 ID",
|
||||||
"email": "이메일",
|
"email": "이메일",
|
||||||
"nickname": "닉네임",
|
"nickname": "닉네임",
|
||||||
"profile_image_url": "프로필 이미지",
|
"profile_image_url": "프로필 이미지",
|
||||||
"thumbnail_image_url": "썸네일 이미지",
|
"thumbnail_image_url": "썸네일 이미지",
|
||||||
"phone": "전화번호",
|
"phone": "전화번호",
|
||||||
"name": "이름",
|
"name": "실명",
|
||||||
"birth_date": "생년월일",
|
"birth_date": "생년월일",
|
||||||
"gender": "성별",
|
"gender": "성별",
|
||||||
"credits": "크레딧",
|
|
||||||
"is_active": "활성화",
|
"is_active": "활성화",
|
||||||
"is_admin": "관리자",
|
"is_admin": "관리자",
|
||||||
"role": "권한",
|
"role": "권한",
|
||||||
@ -116,71 +92,71 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
|
|||||||
"updated_at": "수정일시",
|
"updated_at": "수정일시",
|
||||||
}
|
}
|
||||||
|
|
||||||
@action(
|
|
||||||
name="01_block_user",
|
|
||||||
label="계정 차단",
|
|
||||||
confirmation_message="선택한 사용자를 차단하시겠습니까?",
|
|
||||||
add_in_list=True,
|
|
||||||
)
|
|
||||||
async def seq_f_block_user_action(self, request: Request) -> RedirectResponse:
|
|
||||||
return await handle_block_users(request, self.identity, block=True)
|
|
||||||
|
|
||||||
@action(
|
class RefreshTokenAdmin(ModelView, model=RefreshToken):
|
||||||
name="02_unblock_user",
|
name = "리프레시 토큰"
|
||||||
label="차단 해제",
|
name_plural = "리프레시 토큰 목록"
|
||||||
confirmation_message="선택한 사용자의 차단을 해제하시겠습니까?",
|
icon = "fa-solid fa-key"
|
||||||
add_in_list=True,
|
category = "사용자 관리"
|
||||||
)
|
page_size = 20
|
||||||
async def seq_e_unblock_user_action(self, request: Request) -> RedirectResponse:
|
|
||||||
return await handle_block_users(request, self.identity, block=False)
|
|
||||||
|
|
||||||
@action(
|
column_list = [
|
||||||
name="03_grant_credits_1",
|
"id",
|
||||||
label="크레딧 +1",
|
"user_id",
|
||||||
confirmation_message="선택한 사용자에게 크레딧 1개를 충전하시겠습니까?",
|
"is_revoked",
|
||||||
add_in_list=True,
|
"expires_at",
|
||||||
)
|
"created_at",
|
||||||
async def seq_d_grant_credits_1_action(self, request: Request) -> RedirectResponse:
|
]
|
||||||
admin_id = request.session.get("admin_id")
|
|
||||||
return await handle_grant_credits(request, self.identity, amount=1, admin_id=admin_id)
|
|
||||||
|
|
||||||
@action(
|
column_details_list = [
|
||||||
name="04_grant_credits_5",
|
"id",
|
||||||
label="크레딧 +5",
|
"user_id",
|
||||||
confirmation_message="선택한 사용자에게 크레딧 5개를 충전하시겠습니까?",
|
"token_hash",
|
||||||
add_in_list=True,
|
"expires_at",
|
||||||
)
|
"is_revoked",
|
||||||
async def seq_c_grant_credits_5_action(self, request: Request) -> RedirectResponse:
|
"created_at",
|
||||||
admin_id = request.session.get("admin_id")
|
"revoked_at",
|
||||||
return await handle_grant_credits(request, self.identity, amount=5, admin_id=admin_id)
|
"user_agent",
|
||||||
|
"ip_address",
|
||||||
|
]
|
||||||
|
|
||||||
@action(
|
form_excluded_columns = ["created_at", "user"]
|
||||||
name="05_grant_credits_10",
|
|
||||||
label="크레딧 +10",
|
|
||||||
confirmation_message="선택한 사용자에게 크레딧 10개를 충전하시겠습니까?",
|
|
||||||
add_in_list=True,
|
|
||||||
)
|
|
||||||
async def seq_b_grant_credits_10_action(self, request: Request) -> RedirectResponse:
|
|
||||||
admin_id = request.session.get("admin_id")
|
|
||||||
return await handle_grant_credits(request, self.identity, amount=10, admin_id=admin_id)
|
|
||||||
|
|
||||||
@action(
|
column_searchable_list = [
|
||||||
name="06_deduct_credits_1",
|
RefreshToken.user_id,
|
||||||
label="크레딧 -1",
|
RefreshToken.token_hash,
|
||||||
confirmation_message="선택한 사용자의 크레딧 1개를 차감하시겠습니까?",
|
RefreshToken.ip_address,
|
||||||
add_in_list=True,
|
]
|
||||||
)
|
|
||||||
async def seq_a_deduct_credits_1_action(self, request: Request) -> RedirectResponse:
|
column_default_sort = (RefreshToken.created_at, True)
|
||||||
admin_id = request.session.get("admin_id")
|
|
||||||
return await handle_deduct_credits(request, self.identity, amount=1, admin_id=admin_id)
|
column_sortable_list = [
|
||||||
|
RefreshToken.id,
|
||||||
|
RefreshToken.user_id,
|
||||||
|
RefreshToken.is_revoked,
|
||||||
|
RefreshToken.expires_at,
|
||||||
|
RefreshToken.created_at,
|
||||||
|
]
|
||||||
|
|
||||||
|
column_labels = {
|
||||||
|
"id": "ID",
|
||||||
|
"user_id": "사용자 ID",
|
||||||
|
"token_hash": "토큰 해시",
|
||||||
|
"expires_at": "만료일시",
|
||||||
|
"is_revoked": "폐기됨",
|
||||||
|
"created_at": "생성일시",
|
||||||
|
"revoked_at": "폐기일시",
|
||||||
|
"user_agent": "User Agent",
|
||||||
|
"ip_address": "IP 주소",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class SocialAccountAdmin(ViewerAccessible, ModelView, model=SocialAccount):
|
class SocialAccountAdmin(ModelView, model=SocialAccount):
|
||||||
name = "소셜 계정"
|
name = "소셜 계정"
|
||||||
name_plural = "소셜 계정 목록"
|
name_plural = "소셜 계정 목록"
|
||||||
icon = "fa-solid fa-share-nodes"
|
icon = "fa-solid fa-share-nodes"
|
||||||
category = "사용자 관리"
|
category = "사용자 관리"
|
||||||
page_size = 30
|
page_size = 20
|
||||||
|
|
||||||
column_list = [
|
column_list = [
|
||||||
"id",
|
"id",
|
||||||
@ -198,6 +174,8 @@ class SocialAccountAdmin(ViewerAccessible, ModelView, model=SocialAccount):
|
|||||||
"platform",
|
"platform",
|
||||||
"platform_user_id",
|
"platform_user_id",
|
||||||
"platform_username",
|
"platform_username",
|
||||||
|
"platform_data",
|
||||||
|
"scope",
|
||||||
"token_expires_at",
|
"token_expires_at",
|
||||||
"is_active",
|
"is_active",
|
||||||
"is_deleted",
|
"is_deleted",
|
||||||
|
|||||||
@ -18,11 +18,10 @@ from app.user.services.auth import (
|
|||||||
AdminRequiredError,
|
AdminRequiredError,
|
||||||
InvalidTokenError,
|
InvalidTokenError,
|
||||||
MissingTokenError,
|
MissingTokenError,
|
||||||
TokenExpiredError,
|
|
||||||
UserInactiveError,
|
UserInactiveError,
|
||||||
UserNotFoundError,
|
UserNotFoundError,
|
||||||
)
|
)
|
||||||
from app.user.services.jwt import decode_token, is_token_expired
|
from app.user.services.jwt import decode_token
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -59,9 +58,6 @@ async def get_current_user(
|
|||||||
|
|
||||||
payload = decode_token(token)
|
payload = decode_token(token)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
if is_token_expired(token):
|
|
||||||
logger.info(f"[AUTH-DEP] Access Token 만료 - token: ...{token[-20:]}")
|
|
||||||
raise TokenExpiredError()
|
|
||||||
logger.warning(f"[AUTH-DEP] Access Token 디코딩 실패 - token: ...{token[-20:]}")
|
logger.warning(f"[AUTH-DEP] Access Token 디코딩 실패 - token: ...{token[-20:]}")
|
||||||
raise InvalidTokenError()
|
raise InvalidTokenError()
|
||||||
|
|
||||||
|
|||||||
@ -16,10 +16,7 @@ from app.database.session import Base
|
|||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.comment.models import Comment
|
|
||||||
from app.credit.models import CreditChargeRequest, CreditTransaction
|
|
||||||
from app.home.models import Project
|
from app.home.models import Project
|
||||||
from app.video.models import VideoReaction
|
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
@ -219,59 +216,6 @@ class User(Base):
|
|||||||
comment="마지막 로그인 일시",
|
comment="마지막 로그인 일시",
|
||||||
)
|
)
|
||||||
|
|
||||||
first_video_created_at: Mapped[Optional[datetime]] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=True,
|
|
||||||
comment="첫 영상 생성 완료 일시 (Meta FirstVideoCreated 전환 이벤트 1회 발화 판정용)",
|
|
||||||
)
|
|
||||||
|
|
||||||
registration_tracked_at: Mapped[Optional[datetime]] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=True,
|
|
||||||
comment="가입 전환 추적 일시 (Meta CompleteRegistration 전환 이벤트 1회 발화 판정용)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 광고 유입 경로 (UTM, 가입 시점 first-touch)
|
|
||||||
# ==========================================================================
|
|
||||||
utm_source: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(255),
|
|
||||||
nullable=True,
|
|
||||||
comment="유입 매체 (예: meta, google, naver)",
|
|
||||||
)
|
|
||||||
|
|
||||||
utm_medium: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(255),
|
|
||||||
nullable=True,
|
|
||||||
comment="유입 방식 (예: paid_social, cpc)",
|
|
||||||
)
|
|
||||||
|
|
||||||
utm_campaign: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(255),
|
|
||||||
nullable=True,
|
|
||||||
comment="캠페인 이름",
|
|
||||||
)
|
|
||||||
|
|
||||||
utm_content: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(255),
|
|
||||||
nullable=True,
|
|
||||||
comment="광고 소재 구분 (A/B 테스트용)",
|
|
||||||
)
|
|
||||||
|
|
||||||
utm_term: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(255),
|
|
||||||
nullable=True,
|
|
||||||
comment="검색 키워드",
|
|
||||||
)
|
|
||||||
|
|
||||||
credits: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
nullable=False,
|
|
||||||
default=3,
|
|
||||||
server_default="3",
|
|
||||||
comment="잔여 영상 생성 크레딧",
|
|
||||||
)
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
@ -324,38 +268,6 @@ class User(Base):
|
|||||||
lazy="selectin",
|
lazy="selectin",
|
||||||
)
|
)
|
||||||
|
|
||||||
credit_requests: Mapped[List["CreditChargeRequest"]] = relationship(
|
|
||||||
"CreditChargeRequest",
|
|
||||||
foreign_keys="CreditChargeRequest.user_uuid",
|
|
||||||
primaryjoin="User.user_uuid == CreditChargeRequest.user_uuid",
|
|
||||||
back_populates="user",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
lazy="noload",
|
|
||||||
)
|
|
||||||
|
|
||||||
credit_transactions: Mapped[List["CreditTransaction"]] = relationship(
|
|
||||||
"CreditTransaction",
|
|
||||||
foreign_keys="CreditTransaction.user_uuid",
|
|
||||||
primaryjoin="User.user_uuid == CreditTransaction.user_uuid",
|
|
||||||
back_populates="user",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
lazy="noload",
|
|
||||||
)
|
|
||||||
|
|
||||||
comments: Mapped[List["Comment"]] = relationship(
|
|
||||||
"Comment",
|
|
||||||
back_populates="user",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
lazy="noload",
|
|
||||||
)
|
|
||||||
|
|
||||||
video_reactions: Mapped[List["VideoReaction"]] = relationship(
|
|
||||||
"VideoReaction",
|
|
||||||
back_populates="user",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
lazy="noload",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f"<User("
|
f"<User("
|
||||||
|
|||||||
@ -160,22 +160,6 @@ class LoginResponse(BaseModel):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 크레딧 스키마
|
|
||||||
# =============================================================================
|
|
||||||
class CreditResponse(BaseModel):
|
|
||||||
"""잔여 크레딧 응답"""
|
|
||||||
|
|
||||||
credits: int = Field(..., description="영상 생성 크레딧")
|
|
||||||
|
|
||||||
model_config = {
|
|
||||||
"json_schema_extra": {
|
|
||||||
"example": {
|
|
||||||
"credits": 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 내부 사용 스키마 (카카오 API 응답 파싱)
|
# 내부 사용 스키마 (카카오 API 응답 파싱)
|
||||||
|
|||||||
@ -92,7 +92,6 @@ from app.user.services.jwt import (
|
|||||||
get_access_token_expire_seconds,
|
get_access_token_expire_seconds,
|
||||||
get_refresh_token_expires_at,
|
get_refresh_token_expires_at,
|
||||||
get_token_hash,
|
get_token_hash,
|
||||||
is_token_expired,
|
|
||||||
)
|
)
|
||||||
from app.user.services.kakao import kakao_client
|
from app.user.services.kakao import kakao_client
|
||||||
|
|
||||||
@ -213,9 +212,6 @@ class AuthService:
|
|||||||
# 1. 토큰 디코딩 및 검증
|
# 1. 토큰 디코딩 및 검증
|
||||||
payload = decode_token(refresh_token)
|
payload = decode_token(refresh_token)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
if is_token_expired(refresh_token):
|
|
||||||
logger.info(f"[AUTH] 토큰 갱신 실패 [1/8 만료] - token: ...{refresh_token[-20:]}")
|
|
||||||
raise TokenExpiredError()
|
|
||||||
logger.warning(f"[AUTH] 토큰 갱신 실패 [1/8 디코딩] - token: ...{refresh_token[-20:]}")
|
logger.warning(f"[AUTH] 토큰 갱신 실패 [1/8 디코딩] - token: ...{refresh_token[-20:]}")
|
||||||
raise InvalidTokenError()
|
raise InvalidTokenError()
|
||||||
|
|
||||||
|
|||||||
@ -1,24 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.credit.exceptions import InsufficientCreditError
|
|
||||||
from app.credit.models import CreditTransactionType
|
|
||||||
from app.credit.services.credit_service import deduct_credit
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def consume_credit(user_uuid: str, session: AsyncSession, *, reason: str = "video generation") -> bool:
|
|
||||||
"""크레딧 1 차감. 기존 호출처와 시그니처 호환 유지."""
|
|
||||||
try:
|
|
||||||
await deduct_credit(
|
|
||||||
session=session,
|
|
||||||
user_uuid=user_uuid,
|
|
||||||
amount=1,
|
|
||||||
type=CreditTransactionType.CONSUME,
|
|
||||||
reason=reason,
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
except InsufficientCreditError:
|
|
||||||
return False
|
|
||||||
@ -116,28 +116,6 @@ def decode_token(token: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def is_token_expired(token: str) -> bool:
|
|
||||||
"""
|
|
||||||
토큰이 만료됐는지 확인 (서명/형식은 유효하지만 exp 초과인 경우)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True: 서명은 유효하나 만료된 토큰, False: 형식/서명 자체가 잘못된 토큰
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(
|
|
||||||
token,
|
|
||||||
jwt_settings.JWT_SECRET,
|
|
||||||
algorithms=[jwt_settings.JWT_ALGORITHM],
|
|
||||||
options={"verify_exp": False},
|
|
||||||
)
|
|
||||||
exp = payload.get("exp")
|
|
||||||
if exp is None:
|
|
||||||
return False
|
|
||||||
return datetime.fromtimestamp(exp) < datetime.now()
|
|
||||||
except JWTError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_token_hash(token: str) -> str:
|
def get_token_hash(token: str) -> str:
|
||||||
"""
|
"""
|
||||||
토큰의 SHA-256 해시값 생성
|
토큰의 SHA-256 해시값 생성
|
||||||
|
|||||||
@ -1,112 +0,0 @@
|
|||||||
# 특별시/광역시/세종시: 하위 행정구역이 구(gu)이므로 별도 처리
|
|
||||||
METRO_SIDOS: dict[str, str] = {
|
|
||||||
"서울특별시": "서울시",
|
|
||||||
"부산광역시": "부산시",
|
|
||||||
"대구광역시": "대구시",
|
|
||||||
"인천광역시": "인천시",
|
|
||||||
"광주광역시": "광주시",
|
|
||||||
"대전광역시": "대전시",
|
|
||||||
"울산광역시": "울산시",
|
|
||||||
"세종특별시": "세종시",
|
|
||||||
}
|
|
||||||
|
|
||||||
SIDO_CITIES: dict[str, list[str]] = {
|
|
||||||
"서울특별시": ["서울시"],
|
|
||||||
"부산광역시": ["부산시"],
|
|
||||||
"대구광역시": ["대구시"],
|
|
||||||
"인천광역시": ["인천시"],
|
|
||||||
"광주광역시": ["광주시"],
|
|
||||||
"대전광역시": ["대전시"],
|
|
||||||
"울산광역시": ["울산시"],
|
|
||||||
"세종특별시": ["세종시"],
|
|
||||||
"경기도": [
|
|
||||||
"수원시", "성남시", "고양시", "용인시", "부천시", "안산시", "안양시", "남양주시",
|
|
||||||
"화성시", "평택시", "의정부시", "시흥시", "파주시", "김포시", "광주시", "광명시",
|
|
||||||
"군포시", "하남시", "오산시", "이천시", "안성시", "구리시", "양주시", "포천시",
|
|
||||||
"여주시", "동두천시", "과천시", "가평군", "양평군", "연천군",
|
|
||||||
],
|
|
||||||
"강원도": [
|
|
||||||
"춘천시", "원주시", "강릉시", "동해시", "태백시", "속초시", "삼척시",
|
|
||||||
"홍천군", "횡성군", "영월군", "평창군", "정선군", "철원군", "화천군",
|
|
||||||
"양구군", "인제군", "고성군", "양양군",
|
|
||||||
],
|
|
||||||
"충청북도": ["청주시", "충주시", "제천시", "보은군", "옥천군", "영동군", "증평군", "진천군", "괴산군", "음성군", "단양군"],
|
|
||||||
"충청남도": ["천안시", "공주시", "보령시", "아산시", "서산시", "논산시", "계룡시", "당진시", "금산군", "부여군", "서천군", "청양군", "홍성군", "예산군", "태안군"],
|
|
||||||
"전라북도": ["전주시", "군산시", "익산시", "정읍시", "남원시", "김제시", "완주군", "진안군", "무주군", "장수군", "임실군", "순창군", "고창군", "부안군"],
|
|
||||||
"전라남도": ["목포시", "여수시", "순천시", "나주시", "광양시", "담양군", "곡성군", "구례군", "고흥군", "보성군", "화순군", "장흥군", "강진군", "해남군", "영암군", "무안군", "함평군", "영광군", "장성군", "완도군", "진도군", "신안군"],
|
|
||||||
"경상북도": ["포항시", "경주시", "김천시", "안동시", "구미시", "영주시", "영천시", "상주시", "문경시", "경산시", "의성군", "청송군", "영양군", "영덕군", "청도군", "고령군", "성주군", "칠곡군", "예천군", "봉화군", "울진군", "울릉군"],
|
|
||||||
"경상남도": ["창원시", "진주시", "통영시", "사천시", "김해시", "밀양시", "거제시", "양산시", "의령군", "함안군", "창녕군", "고성군", "남해군", "하동군", "산청군", "함양군", "거창군", "합천군"],
|
|
||||||
"제주도": ["제주시", "서귀포시"],
|
|
||||||
}
|
|
||||||
|
|
||||||
# 도 약칭 → 정식 명칭
|
|
||||||
SIDO_NAME_ALIASES: dict[str, str] = {
|
|
||||||
"서울": "서울특별시", "부산": "부산광역시", "대구": "대구광역시",
|
|
||||||
"인천": "인천광역시", "광주": "광주광역시", "대전": "대전광역시",
|
|
||||||
"울산": "울산광역시", "세종": "세종특별시",
|
|
||||||
"경기": "경기도", "강원": "강원도",
|
|
||||||
"충북": "충청북도", "충남": "충청남도",
|
|
||||||
"전북": "전라북도", "전남": "전라남도",
|
|
||||||
"경북": "경상북도", "경남": "경상남도",
|
|
||||||
"제주": "제주도",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 도 정식 명칭 → 약칭 + 이형 목록 (필터 검색용)
|
|
||||||
SIDO_SEARCH_ALIASES: dict[str, list[str]] = {
|
|
||||||
"경기도": ["경기도", "경기"],
|
|
||||||
"강원도": ["강원도", "강원", "강원특별자치도"],
|
|
||||||
"충청북도": ["충청북도", "충북", "충북특별자치도"],
|
|
||||||
"충청남도": ["충청남도", "충남"],
|
|
||||||
"전라북도": ["전라북도", "전북", "전북특별자치도"],
|
|
||||||
"전라남도": ["전라남도", "전남"],
|
|
||||||
"경상북도": ["경상북도", "경북"],
|
|
||||||
"경상남도": ["경상남도", "경남"],
|
|
||||||
"제주도": ["제주도", "제주", "제주특별자치도"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def extract_sigungu(address: str) -> str:
|
|
||||||
"""주소 문자열에서 시/군 이름을 추출합니다."""
|
|
||||||
tokens = address.split()
|
|
||||||
if not tokens:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
# 첫 토큰으로 도 판별 (정식명 or 약칭)
|
|
||||||
sido = SIDO_NAME_ALIASES.get(tokens[0], tokens[0])
|
|
||||||
|
|
||||||
# 특별시/광역시/세종시: 구(district) 레벨이 하위이므로 시 이름을 바로 반환
|
|
||||||
metro_name = METRO_SIDOS.get(sido)
|
|
||||||
if metro_name:
|
|
||||||
return metro_name
|
|
||||||
|
|
||||||
cities = SIDO_CITIES.get(sido)
|
|
||||||
if cities and len(tokens) >= 2:
|
|
||||||
second = tokens[1]
|
|
||||||
if second in cities:
|
|
||||||
return second
|
|
||||||
# DB에 없는 신설 행정구역 대비 — 시/군 접미사 폴백
|
|
||||||
if second.endswith(("시", "군")):
|
|
||||||
return second
|
|
||||||
|
|
||||||
# 도 판별 실패 시 전체 도에서 토큰 완전 일치 검색
|
|
||||||
token_set = set(tokens)
|
|
||||||
for city_list in SIDO_CITIES.values():
|
|
||||||
for city in city_list:
|
|
||||||
if city in token_set:
|
|
||||||
return city
|
|
||||||
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def extract_region_from_address(
|
|
||||||
road_address: str | None,
|
|
||||||
jibun_address: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""도로명 주소 우선으로 시/군을 추출합니다. 실패 시 지번 주소로 재시도합니다."""
|
|
||||||
if road_address:
|
|
||||||
result = extract_sigungu(road_address)
|
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
if jibun_address:
|
|
||||||
return extract_sigungu(jibun_address)
|
|
||||||
return ""
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
from pydantic.main import BaseModel
|
|
||||||
|
|
||||||
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
|
||||||
from app.utils.prompts.prompts import image_autotag_prompt
|
|
||||||
from app.utils.prompts.schemas import SpaceType, Subject, Camera, MotionRecommended
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
# medium 추론은 출력 비용의 80%를 차지하고, minimal은 A/B 비교(4회)에서 narrative 점수가
|
|
||||||
# welcome 단계로 편향되고 태그를 과다 선택하는 패턴이 반복돼 low로 고정한다.
|
|
||||||
IMAGE_TAG_REASONING_EFFORT = "low"
|
|
||||||
|
|
||||||
async def autotag_image(image_url : str, industry: str = "") -> list[str]: #tag_list
|
|
||||||
chatgpt = ChatgptService(model_type="gpt")
|
|
||||||
image_input_data = {
|
|
||||||
"img_url" : image_url,
|
|
||||||
"industry" : industry,
|
|
||||||
"space_type" : list(SpaceType),
|
|
||||||
"subject" : list(Subject),
|
|
||||||
"camera" : list(Camera),
|
|
||||||
"motion_recommended" : list(MotionRecommended)
|
|
||||||
}
|
|
||||||
|
|
||||||
image_result = await chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_url, True, reasoning_effort=IMAGE_TAG_REASONING_EFFORT)
|
|
||||||
return image_result
|
|
||||||
|
|
||||||
async def autotag_images(image_url_list : list[str], industry: str = "") -> list[dict]: #tag_list
|
|
||||||
chatgpt = ChatgptService(model_type="gpt")
|
|
||||||
image_input_data_list = [{
|
|
||||||
"img_url" : image_url,
|
|
||||||
"industry" : industry,
|
|
||||||
"space_type" : list(SpaceType),
|
|
||||||
"subject" : list(Subject),
|
|
||||||
"camera" : list(Camera),
|
|
||||||
"motion_recommended" : list(MotionRecommended)
|
|
||||||
}for image_url in image_url_list]
|
|
||||||
|
|
||||||
image_result_tasks = [chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_input_data['img_url'], True, silent = True, reasoning_effort=IMAGE_TAG_REASONING_EFFORT) for image_input_data in image_input_data_list]
|
|
||||||
image_result_list: list[BaseModel | BaseException] = await asyncio.gather(*image_result_tasks, return_exceptions=True)
|
|
||||||
MAX_RETRY = 2
|
|
||||||
for _ in range(MAX_RETRY):
|
|
||||||
failed_idx = [i for i, r in enumerate(image_result_list) if isinstance(r, Exception)]
|
|
||||||
# print("Failed", failed_idx)
|
|
||||||
if not failed_idx:
|
|
||||||
break
|
|
||||||
retried = await asyncio.gather(
|
|
||||||
*[chatgpt.generate_structured_output(image_autotag_prompt, image_input_data_list[i], image_input_data_list[i]['img_url'], True, silent=True, reasoning_effort=IMAGE_TAG_REASONING_EFFORT) for i in failed_idx],
|
|
||||||
return_exceptions=True
|
|
||||||
)
|
|
||||||
for i, result in zip(failed_idx, retried):
|
|
||||||
image_result_list[i] = result
|
|
||||||
|
|
||||||
# print("Failed", failed_idx)
|
|
||||||
return image_result_list
|
|
||||||
@ -1,143 +0,0 @@
|
|||||||
"""
|
|
||||||
BGM 모드용 더미 가사 템플릿
|
|
||||||
|
|
||||||
instrumental=True 호출 시 Suno가 가사 길이/구조를 참고해 60초짜리 BGM을 생성하도록
|
|
||||||
placeholder 가사를 제공합니다. 실제 보컬은 생성되지 않습니다.
|
|
||||||
|
|
||||||
장르 순서: K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz
|
|
||||||
섹션 태그 없이 한국어 10줄로 구성.
|
|
||||||
"""
|
|
||||||
|
|
||||||
_BGM_DUMMY_LYRICS: dict[str, str] = {
|
|
||||||
"K-Pop": (
|
|
||||||
"반짝이는 눈빛으로 날 바라봐\n"
|
|
||||||
"심장이 터질 것 같은 이 느낌\n"
|
|
||||||
"너만 보면 세상이 달라 보여\n"
|
|
||||||
"오늘도 설레임이 멈추질 않아\n"
|
|
||||||
"같이 걸어가는 이 길 위에서\n"
|
|
||||||
"우리 둘만의 노래가 흘러\n"
|
|
||||||
"빛나는 순간들을 모아모아\n"
|
|
||||||
"영원히 기억할 우리의 이야기\n"
|
|
||||||
"지금 이 순간 네 곁에 있을게\n"
|
|
||||||
"함께라면 뭐든 할 수 있어\n"
|
|
||||||
),
|
|
||||||
"Pop": (
|
|
||||||
"햇살 가득한 아침이 시작되고\n"
|
|
||||||
"따스한 바람이 살며시 불어와\n"
|
|
||||||
"거리마다 웃음꽃이 피어나고\n"
|
|
||||||
"오늘도 설레는 하루가 열려\n"
|
|
||||||
"가볍게 발걸음을 내딛으며\n"
|
|
||||||
"환한 빛 속으로 걸어가는 길\n"
|
|
||||||
"두근두근 설레는 이 순간을\n"
|
|
||||||
"온 마음 가득 담아 느껴봐\n"
|
|
||||||
"오늘 하루도 빛나는 하루야\n"
|
|
||||||
"환한 미소로 하루를 마무리해\n"
|
|
||||||
),
|
|
||||||
"R&B": (
|
|
||||||
"부드럽게 흐르는 이 그루브에\n"
|
|
||||||
"몸이 저절로 리듬을 타기 시작해\n"
|
|
||||||
"네 목소리가 귓가에 맴돌고\n"
|
|
||||||
"이 감각이 온몸을 감싸줘\n"
|
|
||||||
"달콤한 밤이 깊어질수록\n"
|
|
||||||
"너와 나 사이 거리가 좁혀져\n"
|
|
||||||
"촛불처럼 은은하게 타오르는\n"
|
|
||||||
"이 감정을 숨길 수가 없어\n"
|
|
||||||
"부드럽게 내 손을 잡아줘\n"
|
|
||||||
"오늘 밤 우리만의 노래를 불러\n"
|
|
||||||
),
|
|
||||||
"Hip-Hop": (
|
|
||||||
"내 방식대로 살아가는 이 길\n"
|
|
||||||
"누가 뭐래도 내 페이스로 가\n"
|
|
||||||
"매일 아침 눈을 뜨면 새로운 무대\n"
|
|
||||||
"두려움 없이 앞으로 나아가\n"
|
|
||||||
"땀과 노력으로 쌓아온 오늘\n"
|
|
||||||
"포기란 없어 끝까지 밀어붙여\n"
|
|
||||||
"내 이름을 기억해 두라고\n"
|
|
||||||
"이 무대 위에 내 발자국 남겨\n"
|
|
||||||
"하나둘 쌓여가는 내 이야기\n"
|
|
||||||
"진짜배기는 지금부터 시작이야\n"
|
|
||||||
),
|
|
||||||
"Ballad": (
|
|
||||||
"저녁 노을이 물드는 창가에서\n"
|
|
||||||
"조용히 흘러가는 시간 속에\n"
|
|
||||||
"잔잔한 바람이 마음을 적시고\n"
|
|
||||||
"기억 속 풍경이 스쳐 지나가\n"
|
|
||||||
"부드럽게 감기는 이 느낌처럼\n"
|
|
||||||
"천천히 숨을 고르며 머물러\n"
|
|
||||||
"마음 깊은 곳에 스며드는 온기\n"
|
|
||||||
"조용히 눈을 감고 느껴봐\n"
|
|
||||||
"이 순간 여기 머무는 것만으로도 충분해\n"
|
|
||||||
"고요한 밤이 나를 감싸 안아줘\n"
|
|
||||||
),
|
|
||||||
"EDM": (
|
|
||||||
"밤거리에 불빛이 타오르고\n"
|
|
||||||
"심장이 두근두근 뛰기 시작해\n"
|
|
||||||
"온몸에 퍼지는 뜨거운 열기\n"
|
|
||||||
"멈출 수 없는 이 흐름 속으로\n"
|
|
||||||
"있는 힘껏 달려가는 이 순간\n"
|
|
||||||
"모든 걸 내려놓고 느껴봐\n"
|
|
||||||
"짜릿하게 타오르는 지금 이 밤\n"
|
|
||||||
"온 세상이 하나로 움직여\n"
|
|
||||||
"끝까지 불태워 이 에너지를\n"
|
|
||||||
"새벽빛이 밝아올 때까지 달려\n"
|
|
||||||
),
|
|
||||||
"Rock": (
|
|
||||||
"굉음을 내며 울려 퍼지는 기타\n"
|
|
||||||
"온몸을 뒤흔드는 강렬한 비트\n"
|
|
||||||
"규칙 따위는 집어치우고\n"
|
|
||||||
"있는 그대로 외쳐봐\n"
|
|
||||||
"불꽃처럼 타오르는 이 열정\n"
|
|
||||||
"아무도 막을 수 없어 지금\n"
|
|
||||||
"거침없이 달려가는 이 무대\n"
|
|
||||||
"목청껏 소리 질러 자유를\n"
|
|
||||||
"부서질 듯 뜨겁게 흔들어\n"
|
|
||||||
"이 밤이 끝날 때까지 록이야\n"
|
|
||||||
),
|
|
||||||
"Jazz": (
|
|
||||||
"커피 향이 피어오르는 오후\n"
|
|
||||||
"느긋하게 흐르는 재즈 선율에\n"
|
|
||||||
"발끝이 리듬을 타기 시작해\n"
|
|
||||||
"달콤한 여유가 가득 차오르고\n"
|
|
||||||
"창밖엔 황금빛 도시가 반짝여\n"
|
|
||||||
"잔을 들어 이 순간을 건배해\n"
|
|
||||||
"스윙하는 박자에 몸을 맡기고\n"
|
|
||||||
"부드럽게 흘러가는 이 밤을\n"
|
|
||||||
"기억 속에 새겨두고 싶어\n"
|
|
||||||
"재즈처럼 자유롭게 살고 싶어\n"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_GENRE_ALIAS: dict[str, str] = {
|
|
||||||
"kpop": "K-Pop",
|
|
||||||
"k-pop": "K-Pop",
|
|
||||||
"k_pop": "K-Pop",
|
|
||||||
"pop": "Pop",
|
|
||||||
"rnb": "R&B",
|
|
||||||
"r&b": "R&B",
|
|
||||||
"r_b": "R&B",
|
|
||||||
"hiphop": "Hip-Hop",
|
|
||||||
"hip-hop": "Hip-Hop",
|
|
||||||
"hip_hop": "Hip-Hop",
|
|
||||||
"ballad": "Ballad",
|
|
||||||
"edm": "EDM",
|
|
||||||
"rock": "Rock",
|
|
||||||
"jazz": "Jazz",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_genre(genre: str) -> str:
|
|
||||||
return _GENRE_ALIAS.get(genre.lower(), genre)
|
|
||||||
|
|
||||||
|
|
||||||
def get_bgm_lyrics(genre: str) -> str:
|
|
||||||
"""장르에 맞는 BGM 더미 가사를 반환합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
genre: 장르명 (K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz)
|
|
||||||
대소문자 및 구분자(-, _) 무관하게 처리됩니다.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
선택된 장르의 가사 텍스트
|
|
||||||
"""
|
|
||||||
return _BGM_DUMMY_LYRICS[normalize_genre(genre)]
|
|
||||||
95
app/utils/chatgpt_prompt.py
Normal file
95
app/utils/chatgpt_prompt.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
from config import apikey_settings, recovery_settings
|
||||||
|
from app.utils.prompts.prompts import Prompt
|
||||||
|
|
||||||
|
|
||||||
|
# 로거 설정
|
||||||
|
logger = get_logger("chatgpt")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatGPTResponseError(Exception):
|
||||||
|
"""ChatGPT API 응답 에러"""
|
||||||
|
def __init__(self, status: str, error_code: str = None, error_message: str = None):
|
||||||
|
self.status = status
|
||||||
|
self.error_code = error_code
|
||||||
|
self.error_message = error_message
|
||||||
|
super().__init__(f"ChatGPT response failed: status={status}, code={error_code}, message={error_message}")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatgptService:
|
||||||
|
"""ChatGPT API 서비스 클래스
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, timeout: float = None):
|
||||||
|
self.timeout = timeout or recovery_settings.CHATGPT_TIMEOUT
|
||||||
|
self.max_retries = recovery_settings.CHATGPT_MAX_RETRIES
|
||||||
|
self.client = AsyncOpenAI(
|
||||||
|
api_key=apikey_settings.CHATGPT_API_KEY,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _call_pydantic_output(self, prompt : str, output_format : BaseModel, model : str) -> BaseModel: # 입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
|
||||||
|
content = [{"type": "input_text", "text": prompt}]
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(self.max_retries + 1):
|
||||||
|
response = await self.client.responses.parse(
|
||||||
|
model=model,
|
||||||
|
input=[{"role": "user", "content": content}],
|
||||||
|
text_format=output_format
|
||||||
|
)
|
||||||
|
# Response 디버그 로깅
|
||||||
|
logger.debug(f"[ChatgptService] attempt: {attempt}")
|
||||||
|
logger.debug(f"[ChatgptService] Response ID: {response.id}")
|
||||||
|
logger.debug(f"[ChatgptService] Response status: {response.status}")
|
||||||
|
logger.debug(f"[ChatgptService] Response model: {response.model}")
|
||||||
|
|
||||||
|
# status 확인: completed, failed, incomplete, cancelled, queued, in_progress
|
||||||
|
if response.status == "completed":
|
||||||
|
logger.debug(f"[ChatgptService] Response output_text: {response.output_text[:200]}..." if len(response.output_text) > 200 else f"[ChatgptService] Response output_text: {response.output_text}")
|
||||||
|
structured_output = response.output_parsed
|
||||||
|
return structured_output #.model_dump() or {}
|
||||||
|
|
||||||
|
# 에러 상태 처리
|
||||||
|
if response.status == "failed":
|
||||||
|
error_code = getattr(response.error, 'code', None) if response.error else None
|
||||||
|
error_message = getattr(response.error, 'message', None) if response.error else None
|
||||||
|
logger.warning(f"[ChatgptService] Response failed (attempt {attempt + 1}/{self.max_retries + 1}): code={error_code}, message={error_message}")
|
||||||
|
last_error = ChatGPTResponseError(response.status, error_code, error_message)
|
||||||
|
|
||||||
|
elif response.status == "incomplete":
|
||||||
|
reason = getattr(response.incomplete_details, 'reason', None) if response.incomplete_details else None
|
||||||
|
logger.warning(f"[ChatgptService] Response incomplete (attempt {attempt + 1}/{self.max_retries + 1}): reason={reason}")
|
||||||
|
last_error = ChatGPTResponseError(response.status, reason, f"Response incomplete: {reason}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# cancelled, queued, in_progress 등 예상치 못한 상태
|
||||||
|
logger.warning(f"[ChatgptService] Unexpected response status (attempt {attempt + 1}/{self.max_retries + 1}): {response.status}")
|
||||||
|
last_error = ChatGPTResponseError(response.status, None, f"Unexpected status: {response.status}")
|
||||||
|
|
||||||
|
# 마지막 시도가 아니면 재시도
|
||||||
|
if attempt < self.max_retries:
|
||||||
|
logger.info(f"[ChatgptService] Retrying request...")
|
||||||
|
|
||||||
|
# 모든 재시도 실패
|
||||||
|
logger.error(f"[ChatgptService] All retries exhausted. Last error: {last_error}")
|
||||||
|
raise last_error
|
||||||
|
|
||||||
|
async def generate_structured_output(
|
||||||
|
self,
|
||||||
|
prompt : Prompt,
|
||||||
|
input_data : dict,
|
||||||
|
) -> BaseModel:
|
||||||
|
prompt_text = prompt.build_prompt(input_data)
|
||||||
|
|
||||||
|
logger.debug(f"[ChatgptService] Generated Prompt (length: {len(prompt_text)})")
|
||||||
|
logger.info(f"[ChatgptService] Starting GPT request with structured output with model: {prompt.prompt_model}")
|
||||||
|
|
||||||
|
# GPT API 호출
|
||||||
|
#response = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
|
||||||
|
response = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model)
|
||||||
|
return response
|
||||||
@ -19,16 +19,12 @@ Note:
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import re
|
|
||||||
from typing import Any, Optional, Type
|
from typing import Any, Optional, Type
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
def normalize_location(name: str) -> str:
|
|
||||||
return re.sub(r'(특별시|광역시|특별자치시|특별자치도|시|군|구|도)$', '', name)
|
|
||||||
|
|
||||||
def _generate_uuid7_string() -> str:
|
def _generate_uuid7_string() -> str:
|
||||||
"""UUID7 문자열을 생성합니다.
|
"""UUID7 문자열을 생성합니다.
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
376
app/utils/facebook_oauth.py
Normal file
376
app/utils/facebook_oauth.py
Normal file
@ -0,0 +1,376 @@
|
|||||||
|
"""
|
||||||
|
Facebook OAuth 2.0 API 클라이언트
|
||||||
|
|
||||||
|
Facebook Graph API를 통한 OAuth 2.0 인증 흐름을 처리하는 클라이언트입니다.
|
||||||
|
|
||||||
|
인증 흐름:
|
||||||
|
1. get_authorization_url()로 Facebook 로그인 페이지 URL 획득
|
||||||
|
2. 사용자가 Facebook에서 로그인 후 인가 코드(code) 발급
|
||||||
|
3. get_access_token()으로 인가 코드를 단기 액세스 토큰으로 교환
|
||||||
|
4. exchange_long_lived_token()으로 단기 토큰을 장기 토큰(약 60일)으로 교환
|
||||||
|
5. get_user_info()로 사용자 정보 조회
|
||||||
|
6. get_user_pages()로 관리 페이지 목록 조회 (선택)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
client = FacebookOAuthClient()
|
||||||
|
auth_url = client.get_authorization_url(state="csrf_token")
|
||||||
|
token_data = await client.get_access_token(code="auth_code")
|
||||||
|
long_token = await client.exchange_long_lived_token(token_data["access_token"])
|
||||||
|
user_info = await client.get_user_info(long_token["access_token"])
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
from config import facebook_settings
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Facebook OAuth 예외 클래스 정의
|
||||||
|
# =============================================================================
|
||||||
|
class FacebookOAuthException(HTTPException):
|
||||||
|
"""Facebook OAuth 관련 기본 예외"""
|
||||||
|
|
||||||
|
def __init__(self, status_code: int, code: str, message: str):
|
||||||
|
super().__init__(status_code=status_code, detail={"code": code, "message": message})
|
||||||
|
|
||||||
|
|
||||||
|
class FacebookAuthFailedError(FacebookOAuthException):
|
||||||
|
"""Facebook 인증 실패"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "Facebook 인증에 실패했습니다."):
|
||||||
|
super().__init__(status.HTTP_400_BAD_REQUEST, "FACEBOOK_AUTH_FAILED", message)
|
||||||
|
|
||||||
|
|
||||||
|
class FacebookAPIError(FacebookOAuthException):
|
||||||
|
"""Facebook API 호출 오류"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "Facebook API 호출 중 오류가 발생했습니다."):
|
||||||
|
super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, "FACEBOOK_API_ERROR", message)
|
||||||
|
|
||||||
|
|
||||||
|
class FacebookTokenExpiredError(FacebookOAuthException):
|
||||||
|
"""Facebook 토큰 만료"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "Facebook 토큰이 만료되었습니다. 재연동이 필요합니다."):
|
||||||
|
super().__init__(status.HTTP_401_UNAUTHORIZED, "FACEBOOK_TOKEN_EXPIRED", message)
|
||||||
|
|
||||||
|
|
||||||
|
class FacebookOAuthClient:
|
||||||
|
"""
|
||||||
|
Facebook OAuth 2.0 API 클라이언트
|
||||||
|
|
||||||
|
Facebook Graph API를 통한 OAuth 인증 흐름을 처리합니다.
|
||||||
|
모든 설정값은 config.py의 FacebookSettings에서 로드됩니다.
|
||||||
|
|
||||||
|
인증 흐름:
|
||||||
|
1. get_authorization_url() → Facebook 로그인 페이지 URL 생성
|
||||||
|
2. get_access_token() → 인가 코드를 단기 토큰으로 교환
|
||||||
|
3. exchange_long_lived_token() → 단기 토큰을 장기 토큰(~60일)으로 교환
|
||||||
|
4. get_user_info() → 사용자 프로필 조회
|
||||||
|
5. get_user_pages() → 관리 페이지 목록 조회
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Facebook OAuth/Graph API URL 템플릿
|
||||||
|
AUTH_URL_TEMPLATE = "https://www.facebook.com/{version}/dialog/oauth"
|
||||||
|
TOKEN_URL_TEMPLATE = "https://graph.facebook.com/{version}/oauth/access_token"
|
||||||
|
USER_INFO_URL_TEMPLATE = "https://graph.facebook.com/{version}/me"
|
||||||
|
PAGES_URL_TEMPLATE = "https://graph.facebook.com/{version}/{user_id}/accounts"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# FacebookSettings에서 설정값 로드
|
||||||
|
self.client_id = facebook_settings.FACEBOOK_APP_ID
|
||||||
|
self.client_secret = facebook_settings.FACEBOOK_APP_SECRET
|
||||||
|
self.redirect_uri = facebook_settings.FACEBOOK_REDIRECT_URI
|
||||||
|
self.api_version = facebook_settings.FACEBOOK_GRAPH_API_VERSION
|
||||||
|
self.scope = facebook_settings.FACEBOOK_OAUTH_SCOPE
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[FACEBOOK] OAuth 클라이언트 초기화 - "
|
||||||
|
f"api_version: {self.api_version}, redirect_uri: {self.redirect_uri}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_authorization_url(self, state: str) -> str:
|
||||||
|
"""
|
||||||
|
Facebook 로그인 페이지 URL 생성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: CSRF 방지용 state 토큰
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Facebook OAuth 인증 페이지 URL
|
||||||
|
"""
|
||||||
|
# Facebook 인증 페이지 URL 조합
|
||||||
|
base_url = self.AUTH_URL_TEMPLATE.format(version=self.api_version)
|
||||||
|
params = {
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"redirect_uri": self.redirect_uri,
|
||||||
|
"state": state,
|
||||||
|
"scope": self.scope,
|
||||||
|
"response_type": "code",
|
||||||
|
}
|
||||||
|
auth_url = f"{base_url}?{urlencode(params)}"
|
||||||
|
|
||||||
|
logger.info(f"[FACEBOOK] 인증 URL 생성 - redirect_uri: {self.redirect_uri}")
|
||||||
|
logger.debug(f"[FACEBOOK] 인증 URL 상세 - state: {state[:20]}..., scope: {self.scope}")
|
||||||
|
|
||||||
|
return auth_url
|
||||||
|
|
||||||
|
async def get_access_token(self, code: str) -> dict:
|
||||||
|
"""
|
||||||
|
인가 코드를 단기 액세스 토큰으로 교환
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: Facebook 로그인 후 발급받은 인가 코드
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {access_token, token_type, expires_in}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FacebookAuthFailedError: 토큰 발급 실패 시
|
||||||
|
FacebookAPIError: API 호출 오류 시
|
||||||
|
"""
|
||||||
|
logger.info(f"[FACEBOOK] 액세스 토큰 요청 시작 - code: {code[:20]}...")
|
||||||
|
|
||||||
|
# Facebook Graph API - 인가 코드를 액세스 토큰으로 교환
|
||||||
|
token_url = self.TOKEN_URL_TEMPLATE.format(version=self.api_version)
|
||||||
|
params = {
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": self.redirect_uri,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
logger.debug(f"[FACEBOOK] 토큰 요청 URL: {token_url}")
|
||||||
|
response = await client.get(token_url, params=params)
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
logger.debug(f"[FACEBOOK] 토큰 응답 상태 - status: {response.status_code}")
|
||||||
|
|
||||||
|
# 에러 응답 처리
|
||||||
|
if "error" in result:
|
||||||
|
error_msg = result.get("error", {})
|
||||||
|
error_message = error_msg.get("message", "알 수 없는 오류")
|
||||||
|
logger.error(
|
||||||
|
f"[FACEBOOK] 토큰 발급 실패 - "
|
||||||
|
f"type: {error_msg.get('type')}, message: {error_message}"
|
||||||
|
)
|
||||||
|
raise FacebookAuthFailedError(f"Facebook 토큰 발급 실패: {error_message}")
|
||||||
|
|
||||||
|
logger.info("[FACEBOOK] 단기 액세스 토큰 발급 성공")
|
||||||
|
logger.debug(
|
||||||
|
f"[FACEBOOK] 토큰 정보 - "
|
||||||
|
f"token_type: {result.get('token_type')}, "
|
||||||
|
f"expires_in: {result.get('expires_in')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except FacebookAuthFailedError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[FACEBOOK] API 호출 오류 - error: {str(e)}")
|
||||||
|
raise FacebookAPIError(f"Facebook API 호출 중 오류 발생: {str(e)}")
|
||||||
|
|
||||||
|
async def exchange_long_lived_token(self, short_lived_token: str) -> dict:
|
||||||
|
"""
|
||||||
|
단기 토큰을 장기 토큰으로 교환 (약 60일 유효)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
short_lived_token: 단기 액세스 토큰
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {access_token, token_type, expires_in}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FacebookAuthFailedError: 토큰 교환 실패 시
|
||||||
|
FacebookAPIError: API 호출 오류 시
|
||||||
|
"""
|
||||||
|
logger.info("[FACEBOOK] 장기 토큰 교환 시작")
|
||||||
|
|
||||||
|
# Facebook Graph API - 단기 토큰을 장기 토큰으로 교환
|
||||||
|
token_url = self.TOKEN_URL_TEMPLATE.format(version=self.api_version)
|
||||||
|
params = {
|
||||||
|
"grant_type": "fb_exchange_token",
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
"fb_exchange_token": short_lived_token,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
logger.debug(f"[FACEBOOK] 장기 토큰 교환 URL: {token_url}")
|
||||||
|
response = await client.get(token_url, params=params)
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
logger.debug(f"[FACEBOOK] 장기 토큰 교환 응답 상태 - status: {response.status_code}")
|
||||||
|
|
||||||
|
# 에러 응답 처리
|
||||||
|
if "error" in result:
|
||||||
|
error_msg = result.get("error", {})
|
||||||
|
error_message = error_msg.get("message", "알 수 없는 오류")
|
||||||
|
logger.error(
|
||||||
|
f"[FACEBOOK] 장기 토큰 교환 실패 - "
|
||||||
|
f"type: {error_msg.get('type')}, message: {error_message}"
|
||||||
|
)
|
||||||
|
raise FacebookAuthFailedError(f"Facebook 장기 토큰 교환 실패: {error_message}")
|
||||||
|
|
||||||
|
expires_in = result.get("expires_in", 0)
|
||||||
|
logger.info(f"[FACEBOOK] 장기 토큰 교환 성공 - expires_in: {expires_in}초 (약 {expires_in // 86400}일)")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except FacebookAuthFailedError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[FACEBOOK] API 호출 오류 - error: {str(e)}")
|
||||||
|
raise FacebookAPIError(f"Facebook API 호출 중 오류 발생: {str(e)}")
|
||||||
|
|
||||||
|
async def get_user_info(self, access_token: str) -> dict:
|
||||||
|
"""
|
||||||
|
액세스 토큰으로 사용자 정보 조회
|
||||||
|
|
||||||
|
Args:
|
||||||
|
access_token: Facebook 액세스 토큰
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {id, name, email, picture}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FacebookAuthFailedError: 사용자 정보 조회 실패 시
|
||||||
|
FacebookAPIError: API 호출 오류 시
|
||||||
|
"""
|
||||||
|
logger.info("[FACEBOOK] 사용자 정보 조회 시작")
|
||||||
|
|
||||||
|
# Facebook Graph API - 사용자 프로필 조회
|
||||||
|
user_info_url = self.USER_INFO_URL_TEMPLATE.format(version=self.api_version)
|
||||||
|
params = {
|
||||||
|
"fields": "id,name,email,picture",
|
||||||
|
"access_token": access_token,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
logger.debug(f"[FACEBOOK] 사용자 정보 요청 URL: {user_info_url}")
|
||||||
|
response = await client.get(user_info_url, params=params)
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
logger.debug(f"[FACEBOOK] 사용자 정보 응답 상태 - status: {response.status_code}")
|
||||||
|
|
||||||
|
# 에러 응답 처리
|
||||||
|
if "error" in result:
|
||||||
|
error_msg = result.get("error", {})
|
||||||
|
error_message = error_msg.get("message", "알 수 없는 오류")
|
||||||
|
error_code = error_msg.get("code")
|
||||||
|
logger.error(
|
||||||
|
f"[FACEBOOK] 사용자 정보 조회 실패 - "
|
||||||
|
f"code: {error_code}, message: {error_message}"
|
||||||
|
)
|
||||||
|
# 토큰 만료 에러 (code=190)
|
||||||
|
if error_code == 190:
|
||||||
|
raise FacebookTokenExpiredError()
|
||||||
|
raise FacebookAuthFailedError(f"Facebook 사용자 정보 조회 실패: {error_message}")
|
||||||
|
|
||||||
|
# 필수 필드(id) 확인
|
||||||
|
if "id" not in result:
|
||||||
|
logger.error(f"[FACEBOOK] 사용자 정보에 id 없음 - response: {result}")
|
||||||
|
raise FacebookAuthFailedError("Facebook 사용자 정보를 가져올 수 없습니다.")
|
||||||
|
|
||||||
|
logger.info(f"[FACEBOOK] 사용자 정보 조회 성공 - id: {result.get('id')}")
|
||||||
|
logger.debug(
|
||||||
|
f"[FACEBOOK] 사용자 상세 정보 - "
|
||||||
|
f"name: {result.get('name')}, email: {result.get('email')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except (FacebookAuthFailedError, FacebookTokenExpiredError):
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[FACEBOOK] API 호출 오류 - error: {str(e)}")
|
||||||
|
raise FacebookAPIError(f"Facebook API 호출 중 오류 발생: {str(e)}")
|
||||||
|
|
||||||
|
async def get_user_pages(self, user_id: str, access_token: str) -> list[dict]:
|
||||||
|
"""
|
||||||
|
사용자가 관리하는 Facebook 페이지 목록 조회
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Facebook 사용자 ID
|
||||||
|
access_token: Facebook 액세스 토큰
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[dict]: [{id, name, access_token, category}, ...]
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FacebookAPIError: API 호출 오류 시
|
||||||
|
"""
|
||||||
|
logger.info(f"[FACEBOOK] 페이지 목록 조회 시작 - user_id: {user_id}")
|
||||||
|
|
||||||
|
# Facebook Graph API - 사용자 관리 페이지 목록 조회
|
||||||
|
pages_url = self.PAGES_URL_TEMPLATE.format(
|
||||||
|
version=self.api_version, user_id=user_id
|
||||||
|
)
|
||||||
|
params = {"access_token": access_token}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
logger.debug(f"[FACEBOOK] 페이지 목록 요청 URL: {pages_url}")
|
||||||
|
response = await client.get(pages_url, params=params)
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
logger.debug(f"[FACEBOOK] 페이지 목록 응답 상태 - status: {response.status_code}")
|
||||||
|
|
||||||
|
# 에러 응답 처리
|
||||||
|
if "error" in result:
|
||||||
|
error_msg = result.get("error", {})
|
||||||
|
error_message = error_msg.get("message", "알 수 없는 오류")
|
||||||
|
logger.error(f"[FACEBOOK] 페이지 목록 조회 실패 - message: {error_message}")
|
||||||
|
raise FacebookAPIError(f"Facebook 페이지 목록 조회 실패: {error_message}")
|
||||||
|
|
||||||
|
pages = result.get("data", [])
|
||||||
|
logger.info(f"[FACEBOOK] 페이지 목록 조회 성공 - 페이지 수: {len(pages)}")
|
||||||
|
logger.debug(
|
||||||
|
f"[FACEBOOK] 페이지 상세 - "
|
||||||
|
f"pages: {[{'id': p.get('id'), 'name': p.get('name')} for p in pages]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return pages
|
||||||
|
|
||||||
|
except FacebookAPIError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[FACEBOOK] API 호출 오류 - error: {str(e)}")
|
||||||
|
raise FacebookAPIError(f"Facebook API 호출 중 오류 발생: {str(e)}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_token_expired(token_expires_at) -> bool:
|
||||||
|
"""
|
||||||
|
토큰 만료 여부 확인 (만료 7일 전부터 True 반환)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token_expires_at: 토큰 만료 일시 (aware datetime)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True: 토큰이 만료되었거나 7일 이내 만료 예정
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from app.utils.timezone import now
|
||||||
|
|
||||||
|
# 만료 7일 전부터 재연동 필요로 판단 (aware datetime 사용)
|
||||||
|
threshold = now() + timedelta(days=7)
|
||||||
|
is_expired = token_expires_at <= threshold
|
||||||
|
logger.debug(
|
||||||
|
f"[FACEBOOK] 토큰 만료 확인 - "
|
||||||
|
f"expires_at: {token_expires_at}, threshold: {threshold}, expired: {is_expired}"
|
||||||
|
)
|
||||||
|
return is_expired
|
||||||
@ -1,125 +0,0 @@
|
|||||||
"""크롤링 단계 이미지 마케팅 적합성 필터.
|
|
||||||
|
|
||||||
마케팅 적합성(marketing_acceptable) 판정은 이 모듈이 전담한다. 크롤링 시점에
|
|
||||||
방문자/AI View 사진(extra_photo_urls)만을 대상으로 판정하며, 업로드 이후 단계의
|
|
||||||
태깅(app/utils/autotag.py의 autotag_images(), sheet 기반 image_autotag_prompt)은
|
|
||||||
공간/피사체/카메라/모션/내러티브 태그만 다루고 marketing_acceptable은 포함하지 않는다
|
|
||||||
(이미 이 단계에서 걸러진 이미지만 태깅 대상이 되기 때문). 프롬프트가 짧아
|
|
||||||
시트(Prompt) 대신 코드에 하드코딩한다.
|
|
||||||
|
|
||||||
업체 등록 사진(owner_images)은 이 필터의 대상이 아니며, 호출측(라우터)에서
|
|
||||||
owner_images 개수가 NvMapScraper.SUPPLEMENT_THRESHOLD(30) 이상이면 이 모듈을
|
|
||||||
아예 호출하지 않는 것이 원칙이다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
|
||||||
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
|
||||||
from app.utils.prompts.schemas import MarketingFilterOutput
|
|
||||||
|
|
||||||
logger = get_logger("image_filter")
|
|
||||||
|
|
||||||
# 크롤링 단계 필터링에 사용할 Gemini 모델. 시트(prompts.py)와 달리 코드에 고정한다.
|
|
||||||
MARKETING_FILTER_MODEL = "gpt-5-mini"
|
|
||||||
|
|
||||||
MAX_RETRY = 2
|
|
||||||
|
|
||||||
MARKETING_FILTER_PROMPT_TEMPLATE = """\
|
|
||||||
당신은 광고 영상 제작을 위한 이미지 심사자입니다. 아래 업종의 광고 영상에 이 이미지를 \
|
|
||||||
사용해도 되는지 판정하세요.
|
|
||||||
|
|
||||||
업종(industry): {industry}
|
|
||||||
|
|
||||||
## MARKETING SUITABILITY CHECK (industry-aware)
|
|
||||||
- marketing_acceptable: true | false
|
|
||||||
- reject_reason: short reason if false (e.g., 저화질, 인물 신원 노출, 업종 부적합, 민감/의료 장면, 미성년자 식별 노출). Empty if acceptable.
|
|
||||||
- Person identity protection: 식별 가능한 얼굴이 주피사체이고 동의 여부가 불확실하면 보수적으로 처리.
|
|
||||||
|
|
||||||
주어진 스키마에 맞춰 marketing_acceptable과 reject_reason만 반환하세요.\
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _build_prompt(industry: str) -> str:
|
|
||||||
return MARKETING_FILTER_PROMPT_TEMPLATE.format(industry=industry or "미상")
|
|
||||||
|
|
||||||
|
|
||||||
async def filter_marketing_images(image_url_list: list[str], industry: str = "") -> list[bool]:
|
|
||||||
"""이미지 URL마다 마케팅 적합성(marketing_acceptable)만 판정해 통과 여부 리스트를 반환한다.
|
|
||||||
|
|
||||||
이미지 1장당 API 호출 1회를 asyncio.gather로 병렬 실행한다(배치 호출 아님).
|
|
||||||
실패한 이미지는 최대 MAX_RETRY회 재시도하며, 그래도 실패하면 보수적으로
|
|
||||||
False(제외)로 처리한다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image_url_list: 판정할 이미지 URL 목록 (extra_photo_urls의 original만 전달할 것)
|
|
||||||
industry: 업종 분류값 (_resolve_industry 결과). 필터 프롬프트 앞에서 먼저 계산되어야 함.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
image_url_list와 같은 순서의 통과 여부(bool) 리스트.
|
|
||||||
"""
|
|
||||||
if not image_url_list:
|
|
||||||
return []
|
|
||||||
|
|
||||||
chatgpt = ChatgptService(model_type="gpt")
|
|
||||||
prompt_text = _build_prompt(industry)
|
|
||||||
|
|
||||||
async def _call(url: str):
|
|
||||||
return await chatgpt._call_pydantic_output_chat_completion(
|
|
||||||
prompt=prompt_text,
|
|
||||||
output_format=MarketingFilterOutput,
|
|
||||||
model=MARKETING_FILTER_MODEL,
|
|
||||||
img_url=url,
|
|
||||||
image_detail_high=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
results: list[MarketingFilterOutput | BaseException] = await asyncio.gather(
|
|
||||||
*[_call(url) for url in image_url_list], return_exceptions=True
|
|
||||||
)
|
|
||||||
|
|
||||||
for _ in range(MAX_RETRY):
|
|
||||||
failed_idx = [i for i, r in enumerate(results) if isinstance(r, Exception)]
|
|
||||||
if not failed_idx:
|
|
||||||
break
|
|
||||||
# logger.warning(f"[image_filter] 재시도 대상 인덱스: {failed_idx}")
|
|
||||||
retried = await asyncio.gather(
|
|
||||||
*[_call(image_url_list[i]) for i in failed_idx], return_exceptions=True
|
|
||||||
)
|
|
||||||
for i, result in zip(failed_idx, retried):
|
|
||||||
results[i] = result
|
|
||||||
|
|
||||||
final_failed = [i for i, r in enumerate(results) if isinstance(r, Exception)]
|
|
||||||
# if final_failed:
|
|
||||||
# logger.warning(
|
|
||||||
# f"[image_filter] {len(final_failed)}건 최종 실패 → 보수적으로 제외 처리: {final_failed}"
|
|
||||||
# )
|
|
||||||
|
|
||||||
return [
|
|
||||||
(not isinstance(r, Exception)) and r.marketing_acceptable
|
|
||||||
for r in results
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def assemble_images(
|
|
||||||
owner_images: list[dict],
|
|
||||||
extra_images: list[dict],
|
|
||||||
extra_pass_flags: list[bool],
|
|
||||||
max_images: int,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""owner_images 전량 + 필터 통과한 extra_images를 합쳐 최대 max_images장을 만든다.
|
|
||||||
|
|
||||||
owner_images는 무조건 포함(필터링 대상 아님). extra_images는 extra_pass_flags와
|
|
||||||
같은 순서로 매칭되며, 통과분만 순서대로 이어붙인다. original URL 기준으로 중복을
|
|
||||||
제거한다(nvMapScraper._extract_origins의 dedup 방식과 동일).
|
|
||||||
"""
|
|
||||||
passed_extra = [img for img, ok in zip(extra_images, extra_pass_flags) if ok]
|
|
||||||
|
|
||||||
seen: set[str] = set()
|
|
||||||
combined: list[dict] = []
|
|
||||||
for img in owner_images + passed_extra:
|
|
||||||
original = img.get("original")
|
|
||||||
if original and original not in seen:
|
|
||||||
seen.add(original)
|
|
||||||
combined.append(img)
|
|
||||||
|
|
||||||
return combined[:max_images]
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
"""
|
|
||||||
Meta Conversions API (CAPI) 클라이언트
|
|
||||||
|
|
||||||
Meta 픽셀과 병행하여 서버 사이드 전환 이벤트를 전송합니다.
|
|
||||||
브라우저 픽셀(fbq)과 동일한 event_id를 사용하여 Meta가 중복 이벤트를
|
|
||||||
제거(deduplication)할 수 있도록 합니다.
|
|
||||||
|
|
||||||
전송 규칙 (Meta 요구사항):
|
|
||||||
- external_id 등 개인 식별 정보: SHA-256 해시 후 전송
|
|
||||||
- fbc/fbp 쿠키, client_ip_address, client_user_agent: 원문 그대로 전송
|
|
||||||
- event_time: 유닉스 타임스탬프 (7일 이내)
|
|
||||||
- action_source: "website" 고정
|
|
||||||
|
|
||||||
참고: https://developers.facebook.com/docs/marketing-api/conversions-api
|
|
||||||
"""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import time
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
|
||||||
from config import meta_conversion_settings
|
|
||||||
|
|
||||||
logger = get_logger("meta_capi")
|
|
||||||
|
|
||||||
# Meta Graph API 버전 및 요청 타임아웃
|
|
||||||
GRAPH_API_VERSION = "v21.0"
|
|
||||||
REQUEST_TIMEOUT = 10.0
|
|
||||||
|
|
||||||
|
|
||||||
def sha256_hash(value: str) -> str:
|
|
||||||
"""개인 식별 정보를 Meta 매칭 규격에 맞게 SHA-256 해시합니다.
|
|
||||||
|
|
||||||
Meta는 소문자 변환 + 공백 제거 후 해싱을 요구합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value: 해시할 원문 문자열 (예: user_uuid, 이메일)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: SHA-256 해시 (16진수 소문자)
|
|
||||||
"""
|
|
||||||
normalized = value.strip().lower()
|
|
||||||
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
async def send_capi_event(
|
|
||||||
event_name: str,
|
|
||||||
event_id: str,
|
|
||||||
external_id: str,
|
|
||||||
client_ip: str | None = None,
|
|
||||||
client_user_agent: str | None = None,
|
|
||||||
fbc: str | None = None,
|
|
||||||
fbp: str | None = None,
|
|
||||||
event_source_url: str | None = None,
|
|
||||||
custom_data: dict | None = None,
|
|
||||||
test_event_code: str | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""Meta Conversions API로 서버 이벤트를 전송합니다.
|
|
||||||
|
|
||||||
전송 실패는 로깅만 하고 예외를 전파하지 않습니다 (전환 추적 실패가
|
|
||||||
서비스 기능에 영향을 주지 않도록 fire-and-forget 처리).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event_name: 이벤트 이름 (브라우저 fbq와 철자/대소문자 일치 필수)
|
|
||||||
event_id: 중복제거용 이벤트 ID (브라우저 fbq의 eventID와 동일해야 함)
|
|
||||||
external_id: 사용자 식별자 원문 (user_uuid). 내부에서 SHA-256 해시됨
|
|
||||||
client_ip: 클라이언트 IP 주소 (원문)
|
|
||||||
client_user_agent: 클라이언트 User-Agent (원문)
|
|
||||||
fbc: Meta 클릭 ID 쿠키(_fbc) 원문
|
|
||||||
fbp: Meta 브라우저 ID 쿠키(_fbp) 원문
|
|
||||||
event_source_url: 이벤트가 발생한 페이지 URL
|
|
||||||
custom_data: 추가 데이터 (예: Purchase의 value/currency)
|
|
||||||
test_event_code: 이벤트 관리자 "테스트 이벤트" 검증용 코드.
|
|
||||||
미지정 시 FACEBOOK_TEST_EVENT_CODE 환경변수 값을 사용 (운영 시 빈 값 유지)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 전송 성공 여부
|
|
||||||
"""
|
|
||||||
pixel_id = meta_conversion_settings.FACEBOOK_PIXEL_ID
|
|
||||||
access_token = meta_conversion_settings.FACEBOOK_ACCESS_TOKEN
|
|
||||||
|
|
||||||
if not pixel_id or not access_token:
|
|
||||||
logger.warning(
|
|
||||||
"[MetaCAPI] SKIP - FACEBOOK_PIXEL_ID/FACEBOOK_ACCESS_TOKEN 미설정 "
|
|
||||||
f"(event_name: {event_name}, event_id: {event_id})"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
user_data: dict = {
|
|
||||||
"external_id": [sha256_hash(external_id)],
|
|
||||||
}
|
|
||||||
if client_ip:
|
|
||||||
user_data["client_ip_address"] = client_ip
|
|
||||||
if client_user_agent:
|
|
||||||
user_data["client_user_agent"] = client_user_agent
|
|
||||||
if fbc:
|
|
||||||
user_data["fbc"] = fbc
|
|
||||||
if fbp:
|
|
||||||
user_data["fbp"] = fbp
|
|
||||||
|
|
||||||
event: dict = {
|
|
||||||
"event_name": event_name,
|
|
||||||
"event_time": int(time.time()),
|
|
||||||
"event_id": event_id,
|
|
||||||
"action_source": "website",
|
|
||||||
"user_data": user_data,
|
|
||||||
}
|
|
||||||
if event_source_url:
|
|
||||||
event["event_source_url"] = event_source_url
|
|
||||||
if custom_data:
|
|
||||||
event["custom_data"] = custom_data
|
|
||||||
|
|
||||||
payload: dict = {"data": [event]}
|
|
||||||
effective_test_code = test_event_code or meta_conversion_settings.FACEBOOK_TEST_EVENT_CODE
|
|
||||||
if effective_test_code:
|
|
||||||
payload["test_event_code"] = effective_test_code
|
|
||||||
logger.info(f"[MetaCAPI] TEST MODE - test_event_code: {effective_test_code}")
|
|
||||||
|
|
||||||
url = f"https://graph.facebook.com/{GRAPH_API_VERSION}/{pixel_id}/events"
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.post(
|
|
||||||
url,
|
|
||||||
json=payload,
|
|
||||||
params={"access_token": access_token},
|
|
||||||
timeout=REQUEST_TIMEOUT,
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
logger.info(
|
|
||||||
f"[MetaCAPI] SUCCESS - event_name: {event_name}, "
|
|
||||||
f"event_id: {event_id}, response: {response.json()}"
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
logger.error(
|
|
||||||
f"[MetaCAPI] FAILED - event_name: {event_name}, event_id: {event_id}, "
|
|
||||||
f"status: {response.status_code}, body: {response.text}"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
except httpx.HTTPError as e:
|
|
||||||
logger.error(
|
|
||||||
f"[MetaCAPI] HTTP ERROR - event_name: {event_name}, "
|
|
||||||
f"event_id: {event_id}, error: {e}"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"[MetaCAPI] UNEXPECTED ERROR - event_name: {event_name}, "
|
|
||||||
f"event_id: {event_id}, error: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
@ -1,8 +1,4 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import random
|
|
||||||
import re
|
|
||||||
from html import unescape
|
|
||||||
from difflib import SequenceMatcher
|
|
||||||
from playwright.async_api import async_playwright
|
from playwright.async_api import async_playwright
|
||||||
from urllib import parse
|
from urllib import parse
|
||||||
import time
|
import time
|
||||||
@ -20,76 +16,23 @@ class NvMapPwScraper():
|
|||||||
_win_width = 1280
|
_win_width = 1280
|
||||||
_win_height = 720
|
_win_height = 720
|
||||||
_max_retry = 3
|
_max_retry = 3
|
||||||
_timeout = 30 # place id timeout threshold seconds
|
_timeout = 60 # place id timeout threshold seconds
|
||||||
_retry_delay_ms = 1500 # 검색 실패 후 재시도 전 대기시간 (네이버 요청 밀도 완화)
|
|
||||||
|
|
||||||
# UA·뷰포트·sec-ch-ua를 한 세트로 묶은 브라우저 프로필 후보군 (안티봇 핑거프린트 회피용)
|
|
||||||
_UA_PROFILES = [
|
|
||||||
{
|
|
||||||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
|
||||||
"sec_ch_ua": '"Not?A_Brand";v="99", "Chromium";v="130", "Google Chrome";v="130"',
|
|
||||||
"viewport": {"width": 1280, "height": 720},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
|
|
||||||
"sec_ch_ua": '"Not?A_Brand";v="99", "Chromium";v="129", "Google Chrome";v="129"',
|
|
||||||
"viewport": {"width": 1366, "height": 768},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
|
||||||
"sec_ch_ua": '"Not?A_Brand";v="99", "Chromium";v="130", "Google Chrome";v="130"',
|
|
||||||
"viewport": {"width": 1440, "height": 900},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
||||||
"sec_ch_ua": '"Not?A_Brand";v="99", "Chromium";v="131", "Google Chrome";v="131"',
|
|
||||||
"viewport": {"width": 1536, "height": 864},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
_current_profile = None
|
|
||||||
|
|
||||||
# 헤드리스 자동화 탐지 신호(navigator.webdriver, 빈 plugins/languages, window.chrome 부재,
|
|
||||||
# permissions.query 이상 동작)를 정상 브라우저처럼 위장하는 스텔스 패치.
|
|
||||||
# create_page()와 fetch_graphql() 양쪽에서 생성하는 모든 page에 반드시 적용해야 한다 —
|
|
||||||
# 한쪽이라도 빠지면 그 경로만 헤드리스로 노출되어 안티봇 캡차 트리거 확률이 올라간다.
|
|
||||||
_STEALTH_INIT_SCRIPT = '''
|
|
||||||
Object.defineProperty(Navigator.prototype, "webdriver", {
|
|
||||||
set: undefined,
|
|
||||||
enumerable: true,
|
|
||||||
configurable: true,
|
|
||||||
get: () => false,
|
|
||||||
});
|
|
||||||
Object.defineProperty(navigator, "plugins", { get: () => [1, 2, 3, 4, 5] });
|
|
||||||
Object.defineProperty(navigator, "languages", { get: () => ["ko-KR", "ko"] });
|
|
||||||
window.chrome = window.chrome || { runtime: {} };
|
|
||||||
const originalQuery = window.navigator.permissions && window.navigator.permissions.query;
|
|
||||||
if (originalQuery) {
|
|
||||||
window.navigator.permissions.query = (parameters) => (
|
|
||||||
parameters.name === "notifications"
|
|
||||||
? Promise.resolve({ state: Notification.permission })
|
|
||||||
: originalQuery(parameters)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
'''
|
|
||||||
|
|
||||||
# instance var
|
# instance var
|
||||||
page = None
|
page = None
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _pick_profile(cls):
|
|
||||||
"""현재 프로필과 다른 프로필을 무작위로 선택한다 (후보가 1개뿐이면 그대로 반환)."""
|
|
||||||
candidates = [p for p in cls._UA_PROFILES if p is not cls._current_profile]
|
|
||||||
return random.choice(candidates or cls._UA_PROFILES)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def default_context_builder(cls):
|
def default_context_builder(cls):
|
||||||
cls._current_profile = cls._pick_profile()
|
|
||||||
profile = cls._current_profile
|
|
||||||
|
|
||||||
context_builder_dict = {}
|
context_builder_dict = {}
|
||||||
context_builder_dict['viewport'] = dict(profile['viewport'])
|
context_builder_dict['viewport'] = {
|
||||||
context_builder_dict['screen'] = dict(profile['viewport'])
|
'width' : cls._win_width,
|
||||||
context_builder_dict['user_agent'] = profile['user_agent']
|
'height' : cls._win_height
|
||||||
|
}
|
||||||
|
context_builder_dict['screen'] = {
|
||||||
|
'width' : cls._win_width,
|
||||||
|
'height' : cls._win_height
|
||||||
|
}
|
||||||
|
context_builder_dict['user_agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
|
||||||
context_builder_dict['locale'] = 'ko-KR'
|
context_builder_dict['locale'] = 'ko-KR'
|
||||||
context_builder_dict['timezone_id']='Asia/Seoul'
|
context_builder_dict['timezone_id']='Asia/Seoul'
|
||||||
|
|
||||||
@ -105,147 +48,6 @@ if (originalQuery) {
|
|||||||
cls._context = await cls._browser.new_context(**cls.default_context_builder())
|
cls._context = await cls._browser.new_context(**cls.default_context_builder())
|
||||||
cls.is_ready = True
|
cls.is_ready = True
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def _recreate_context(cls):
|
|
||||||
"""네이버 안티봇 차단이 의심될 때 브라우저 컨텍스트를 새로 생성해 세션/핑거프린트를 초기화한다."""
|
|
||||||
old_context = cls._context
|
|
||||||
cls._context = await cls._browser.new_context(**cls.default_context_builder())
|
|
||||||
if old_context:
|
|
||||||
await old_context.close()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def _new_stealth_page(cls):
|
|
||||||
"""스텔스 패치(webdriver 위장 등)와 sec-ch-ua 헤더가 적용된 새 page를 생성한다."""
|
|
||||||
page = await cls._context.new_page()
|
|
||||||
await page.add_init_script(cls._STEALTH_INIT_SCRIPT)
|
|
||||||
if cls._current_profile:
|
|
||||||
await page.set_extra_http_headers({
|
|
||||||
'sec-ch-ua': cls._current_profile['sec_ch_ua']
|
|
||||||
})
|
|
||||||
return page
|
|
||||||
|
|
||||||
GRAPHQL_URL = "https://pcmap-api.place.naver.com/graphql"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def fetch_graphql(
|
|
||||||
cls,
|
|
||||||
place_id: str,
|
|
||||||
payloads: list[dict],
|
|
||||||
capture_apollo: bool = False,
|
|
||||||
) -> list[dict | None] | None | tuple[list[dict | None] | None, str | None]:
|
|
||||||
"""실제 브라우저로 네이버 WTM 안티봇 캡차를 통과해 GraphQL 쿼리를 실행한다.
|
|
||||||
|
|
||||||
네이버 pcmap GraphQL은 두 헤더를 검사한다:
|
|
||||||
- x-wtm-graphql : base64({"arg": place_id, "type": ..., "source": "place"})
|
|
||||||
- x-wtm-ncaptcha-token : 캡차 JS가 생성 (요청마다 발급, 직접 생성 불가)
|
|
||||||
둘 다 없으면 405(캡차)로 막힌다. 따라서 place 페이지를 실제로 로드해
|
|
||||||
페이지가 자연 발생시키는 GraphQL 요청에서 두 헤더를 캡처한 뒤,
|
|
||||||
같은 토큰으로 우리 쿼리들을 in-page fetch로 실행한다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
place_id: 네이버 place ID
|
|
||||||
payloads: GraphQL POST 본문 목록
|
|
||||||
capture_apollo: True면 place 페이지에 인라인된 __APOLLO_STATE__
|
|
||||||
JSON 문자열도 함께 캡처해 (results, apollo_json) 튜플로 반환.
|
|
||||||
(GraphQL base가 노출하지 않는 homepages 등 SSR 캐시 전용 필드용)
|
|
||||||
Returns:
|
|
||||||
payload별 파싱 JSON 목록(실패 항목은 None). 토큰 캡처 실패 시 None.
|
|
||||||
capture_apollo=True면 (위 결과, apollo_json 또는 None) 튜플.
|
|
||||||
"""
|
|
||||||
def _ret(results, apollo=None):
|
|
||||||
return (results, apollo) if capture_apollo else results
|
|
||||||
|
|
||||||
if not cls.is_ready:
|
|
||||||
logger.warning("[NvMapPwScraper] fetch_graphql: scraper가 초기화되지 않았습니다")
|
|
||||||
return _ret(None)
|
|
||||||
|
|
||||||
page = await cls._new_stealth_page()
|
|
||||||
captured: dict = {}
|
|
||||||
|
|
||||||
def on_request(req):
|
|
||||||
if "/graphql" in req.url and req.method == "POST" and "tok" not in captured:
|
|
||||||
tok = req.headers.get("x-wtm-ncaptcha-token")
|
|
||||||
if tok:
|
|
||||||
captured["tok"] = tok
|
|
||||||
captured["wtm"] = req.headers.get("x-wtm-graphql")
|
|
||||||
|
|
||||||
page.on("request", on_request)
|
|
||||||
|
|
||||||
try:
|
|
||||||
for t in ("place", "restaurant", "accommodation"):
|
|
||||||
try:
|
|
||||||
await page.goto(
|
|
||||||
f"https://pcmap.place.naver.com/{t}/{place_id}/home",
|
|
||||||
wait_until="domcontentloaded",
|
|
||||||
timeout=30000,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"[NvMapPwScraper] goto {t} 실패: {e}")
|
|
||||||
continue
|
|
||||||
# 페이지가 GraphQL 요청을 보내며 토큰이 헤더에 실릴 때까지 대기 (최대 ~9초)
|
|
||||||
for _ in range(30):
|
|
||||||
if captured.get("tok"):
|
|
||||||
break
|
|
||||||
await page.wait_for_timeout(300)
|
|
||||||
if captured.get("tok"):
|
|
||||||
logger.info(f"[NvMapPwScraper] WTM 토큰 캡처 성공 (type={t})")
|
|
||||||
break
|
|
||||||
|
|
||||||
if not captured.get("tok"):
|
|
||||||
logger.warning("[NvMapPwScraper] WTM 토큰 캡처 실패")
|
|
||||||
return _ret(None)
|
|
||||||
|
|
||||||
apollo_json: str | None = None
|
|
||||||
if capture_apollo:
|
|
||||||
try:
|
|
||||||
apollo_json = await page.evaluate(
|
|
||||||
"() => { try { return JSON.stringify(window.__APOLLO_STATE__ || null); }"
|
|
||||||
" catch (e) { return null; } }"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[NvMapPwScraper] __APOLLO_STATE__ 캡처 실패: {e}")
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-wtm-graphql": captured["wtm"],
|
|
||||||
"x-wtm-ncaptcha-token": captured["tok"],
|
|
||||||
}
|
|
||||||
|
|
||||||
results: list[dict | None] = []
|
|
||||||
for idx, payload in enumerate(payloads, start=1):
|
|
||||||
r = await page.evaluate(
|
|
||||||
"""async (a) => {
|
|
||||||
const [url, body, hdr] = a;
|
|
||||||
try {
|
|
||||||
const r = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: hdr,
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
if (r.status !== 200) return {__err: r.status};
|
|
||||||
return await r.json();
|
|
||||||
} catch (e) { return {__err: String(e)}; }
|
|
||||||
}""",
|
|
||||||
[cls.GRAPHQL_URL, payload, headers],
|
|
||||||
)
|
|
||||||
if isinstance(r, dict) and "__err" in r:
|
|
||||||
op = payload.get("operationName", "?")
|
|
||||||
logger.warning(
|
|
||||||
f"[NvMapPwScraper] graphql fetch 실패 "
|
|
||||||
f"({idx}/{len(payloads)} {op}): {r['__err']}"
|
|
||||||
)
|
|
||||||
results.append(None)
|
|
||||||
else:
|
|
||||||
results.append(r)
|
|
||||||
return _ret(results, apollo_json)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[NvMapPwScraper] fetch_graphql 오류: {e}")
|
|
||||||
return _ret(None)
|
|
||||||
finally:
|
|
||||||
await page.close()
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
if not self.is_ready:
|
if not self.is_ready:
|
||||||
raise Exception("nvMapScraper is not initiated")
|
raise Exception("nvMapScraper is not initiated")
|
||||||
@ -258,255 +60,92 @@ if (originalQuery) {
|
|||||||
await self.page.close()
|
await self.page.close()
|
||||||
|
|
||||||
async def create_page(self):
|
async def create_page(self):
|
||||||
self.page = await self._new_stealth_page()
|
self.page = await self._context.new_page()
|
||||||
|
await self.page.add_init_script(
|
||||||
|
'''const defaultGetter = Object.getOwnPropertyDescriptor(
|
||||||
|
Navigator.prototype,
|
||||||
|
"webdriver"
|
||||||
|
).get;
|
||||||
|
defaultGetter.apply(navigator);
|
||||||
|
defaultGetter.toString();
|
||||||
|
Object.defineProperty(Navigator.prototype, "webdriver", {
|
||||||
|
set: undefined,
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true,
|
||||||
|
get: new Proxy(defaultGetter, {
|
||||||
|
apply: (target, thisArg, args) => {
|
||||||
|
Reflect.apply(target, thisArg, args);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const patchedGetter = Object.getOwnPropertyDescriptor(
|
||||||
|
Navigator.prototype,
|
||||||
|
"webdriver"
|
||||||
|
).get;
|
||||||
|
patchedGetter.apply(navigator);
|
||||||
|
patchedGetter.toString();''')
|
||||||
|
|
||||||
|
await self.page.set_extra_http_headers({
|
||||||
|
'sec-ch-ua': '\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"'
|
||||||
|
})
|
||||||
await self.page.goto("http://google.com")
|
await self.page.goto("http://google.com")
|
||||||
|
|
||||||
async def goto_url(self, url, wait_until="domcontentloaded", timeout=20000):
|
async def goto_url(self, url, wait_until="domcontentloaded", timeout=20000):
|
||||||
page = self.page
|
page = self.page
|
||||||
await page.goto(url, wait_until=wait_until, timeout=timeout)
|
await page.goto(url, wait_until=wait_until, timeout=timeout)
|
||||||
|
|
||||||
@staticmethod
|
async def get_place_id_url(self, selected):
|
||||||
def _clean_title(text: str) -> str:
|
count = 0
|
||||||
text = unescape(text) # HTML 엔티티 디코딩 (& → &)
|
get_place_id_url_start = time.perf_counter()
|
||||||
text = re.sub(r"<.*?>", "", text) # HTML 태그 제거
|
while (count <= self._max_retry):
|
||||||
return text.strip()
|
title = selected['title'].replace("<b>", "").replace("</b>", "")
|
||||||
|
address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "")
|
||||||
@staticmethod
|
encoded_query = parse.quote(f"{address} {title}")
|
||||||
def _similarity(a: str, b: str) -> float:
|
|
||||||
return SequenceMatcher(None, a, b).ratio()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _refine_address(address: str) -> str:
|
|
||||||
"""한국 주소 패턴에서 첫 번째 유효한 주소만 추출한다."""
|
|
||||||
patterns = [
|
|
||||||
# 도로명 (정식): 경기도 가평군 운악로 278
|
|
||||||
re.compile(
|
|
||||||
r'[가-힣]+(?:특별시|광역시|특별자치시|도|특별자치도|시)\s+'
|
|
||||||
r'[가-힣\s]+?(?:로|길|대로)\s+\d+(?:-\d+)?'
|
|
||||||
),
|
|
||||||
# 지번 (정식): 경기도 가평군 조종면 운악리 278
|
|
||||||
re.compile(
|
|
||||||
r'[가-힣]+(?:특별시|광역시|특별자치시|도|특별자치도|시)\s+'
|
|
||||||
r'[가-힣\s]+?(?:읍|면|동|리|가)\s+\d+(?:-\d+)?'
|
|
||||||
),
|
|
||||||
# 도로명 (축약): 경기 가평 운악로 278
|
|
||||||
re.compile(
|
|
||||||
r'[가-힣]{1,4}\s+[가-힣]{1,6}\s+'
|
|
||||||
r'[가-힣\s]+?(?:로|길|대로)\s+\d+(?:-\d+)?'
|
|
||||||
),
|
|
||||||
# 지번 (축약): 경기 가평 조종면 운악리 278
|
|
||||||
re.compile(
|
|
||||||
r'[가-힣]{1,4}\s+[가-힣]{1,6}\s+'
|
|
||||||
r'[가-힣\s]+?(?:읍|면|동|리|가)\s+\d+(?:-\d+)?'
|
|
||||||
),
|
|
||||||
]
|
|
||||||
for pattern in patterns:
|
|
||||||
m = pattern.search(address)
|
|
||||||
if m:
|
|
||||||
return m.group().strip()
|
|
||||||
return address
|
|
||||||
|
|
||||||
async def _extract_candidates_from_list_page(self) -> list[dict]:
|
|
||||||
"""pcmap.place.naver.com iframe HTML에서 place ID와 업체명을 추출한다."""
|
|
||||||
pcmap_frame = None
|
|
||||||
for frame in self.page.frames:
|
|
||||||
if "pcmap.place.naver.com" in frame.url:
|
|
||||||
pcmap_frame = frame
|
|
||||||
logger.debug(f"[DEBUG] pcmap frame 발견: {frame.url[:80]}")
|
|
||||||
break
|
|
||||||
|
|
||||||
if not pcmap_frame:
|
|
||||||
logger.debug("[DEBUG] pcmap frame 없음")
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
|
||||||
html = await pcmap_frame.content()
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"[DEBUG] pcmap frame content 추출 실패: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
# {"id":"11659052","name":"프레지던트 호텔",...} 형태의 JSON 쌍 추출
|
|
||||||
pair_pattern = re.compile(
|
|
||||||
r'"id"\s*:\s*"(\d{5,})"[^}]{0,200}?"name"\s*:\s*"([^"]{1,60})"'
|
|
||||||
r'|"name"\s*:\s*"([^"]{1,60})"[^}]{0,200}?"id"\s*:\s*"(\d{5,})"'
|
|
||||||
)
|
|
||||||
|
|
||||||
seen = {} # place_id → title (순서 보존)
|
|
||||||
for m in pair_pattern.finditer(html):
|
|
||||||
if m.group(1): # id 먼저
|
|
||||||
pid, title = m.group(1), m.group(2)
|
|
||||||
else: # name 먼저
|
|
||||||
pid, title = m.group(4), m.group(3)
|
|
||||||
if pid not in seen:
|
|
||||||
seen[pid] = title
|
|
||||||
|
|
||||||
candidates = [
|
|
||||||
{"title": title, "place_url": f"https://map.naver.com/p/entry/place/{pid}"}
|
|
||||||
for pid, title in list(seen.items())[:10]
|
|
||||||
]
|
|
||||||
|
|
||||||
for i, c in enumerate(candidates):
|
|
||||||
logger.debug(f"[DEBUG] 후보 {i+1}: {c['title']} / {c['place_url']}")
|
|
||||||
|
|
||||||
logger.debug(f"[DEBUG] 목록 후보 {len(candidates)}개 추출")
|
|
||||||
return candidates
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_allsearch_candidates(body: dict) -> list[dict]:
|
|
||||||
"""allSearch 응답 JSON에서 후보 목록을 추출한다."""
|
|
||||||
place_list = (((body.get("result") or {}).get("place") or {}).get("list")) or []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"title": p.get("name") or "",
|
|
||||||
"place_url": f"https://map.naver.com/p/entry/place/{p['id']}",
|
|
||||||
"roadAddress": p.get("roadAddress") or "",
|
|
||||||
"address": p.get("address") or "",
|
|
||||||
}
|
|
||||||
for p in place_list
|
|
||||||
if p.get("id")
|
|
||||||
]
|
|
||||||
|
|
||||||
def _select_best_candidate(self, candidates: list[dict], title: str, address: str) -> dict | None:
|
|
||||||
"""이름 유사도(70%)와 주소 유사도(30%)를 함께 고려해 최적 후보를 선택한다.
|
|
||||||
|
|
||||||
주소 없이 업체명만으로 검색한 경우(3차 폴백)는 이름 유사도만 사용한다.
|
|
||||||
"""
|
|
||||||
if not candidates:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for c in candidates:
|
|
||||||
name_score = self._similarity(title, self._clean_title(c["title"]))
|
|
||||||
cand_addr = c.get("roadAddress") or c.get("address") or ""
|
|
||||||
addr_score = self._similarity(address, cand_addr) if address and cand_addr else 0.0
|
|
||||||
c["_name_score"] = name_score
|
|
||||||
c["_addr_score"] = addr_score
|
|
||||||
c["_total_score"] = name_score * 0.7 + addr_score * 0.3 if address else name_score
|
|
||||||
|
|
||||||
return max(candidates, key=lambda c: c["_total_score"])
|
|
||||||
|
|
||||||
async def _capture_allsearch(self, url: str, timeout_s: float = 5.0) -> list[dict]:
|
|
||||||
"""검색 페이지가 자연 발생시키는 allSearch API 응답을 캡처해 후보 목록으로 변환한다.
|
|
||||||
|
|
||||||
pcmap iframe 렌더링을 기다릴 필요가 없어 경쟁 조건이 없고, 업체명 단독
|
|
||||||
검색처럼 iframe 자체가 생성되지 않는 케이스에서도 동작한다.
|
|
||||||
"""
|
|
||||||
captured: list[dict] = []
|
|
||||||
|
|
||||||
def on_response(resp):
|
|
||||||
# GET 요청의 실제 응답만 캡처 (OPTIONS preflight 등 오탐 방지)
|
|
||||||
if "allSearch" in resp.url and resp.request.method == "GET":
|
|
||||||
async def _consume():
|
|
||||||
try:
|
|
||||||
captured.append(await resp.json())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
asyncio.create_task(_consume())
|
|
||||||
|
|
||||||
self.page.on("response", on_response)
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
await self.goto_url(url, wait_until="domcontentloaded", timeout=self._timeout * 1000)
|
|
||||||
except Exception:
|
|
||||||
logger.error("[ERROR] Can't Finish domcontentloaded")
|
|
||||||
|
|
||||||
deadline = time.monotonic() + timeout_s
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
if "/place/" in self.page.url or captured:
|
|
||||||
break
|
|
||||||
await self.page.wait_for_timeout(200)
|
|
||||||
finally:
|
|
||||||
self.page.remove_listener("response", on_response)
|
|
||||||
|
|
||||||
if not captured:
|
|
||||||
return []
|
|
||||||
return self._parse_allsearch_candidates(captured[0])
|
|
||||||
|
|
||||||
async def _try_search(self, address: str, title: str) -> str | None:
|
|
||||||
"""주어진 주소+업체명으로 검색해서 place URL을 반환한다. 실패 시 None."""
|
|
||||||
encoded_query = parse.quote(f"{address} {title}".strip())
|
|
||||||
url = f"https://map.naver.com/p/search/{encoded_query}"
|
url = f"https://map.naver.com/p/search/{encoded_query}"
|
||||||
|
|
||||||
# 1순위: allSearch API 응답 캡처 (iframe 렌더링 대기 불필요, 업체명 단독 검색도 지원)
|
wait_first_start = time.perf_counter()
|
||||||
candidates = await self._capture_allsearch(url)
|
|
||||||
if "/place/" in self.page.url:
|
|
||||||
return self.page.url
|
|
||||||
|
|
||||||
if candidates:
|
|
||||||
best = self._select_best_candidate(candidates, title, address)
|
|
||||||
logger.info(
|
|
||||||
f"[AUTO-SELECT] '{title}' → '{best['title']}' "
|
|
||||||
f"(name={best['_name_score']:.2f}, addr={best['_addr_score']:.2f}) {best['place_url']}"
|
|
||||||
)
|
|
||||||
return best['place_url']
|
|
||||||
|
|
||||||
# 2순위(안전망): allSearch 캡처 실패 시 기존 iframe HTML 파싱 방식으로 폴백
|
|
||||||
# ── 잠시 비활성화 ──
|
|
||||||
# logger.warning("[FALLBACK] allSearch 캡처 실패 → iframe 파싱 방식 시도")
|
|
||||||
# iframe_candidates = []
|
|
||||||
# for _ in range(3): # 500ms × 3 = 최대 1.5초
|
|
||||||
# if "/place/" in self.page.url:
|
|
||||||
# return self.page.url
|
|
||||||
# iframe_candidates = await self._extract_candidates_from_list_page()
|
|
||||||
# if iframe_candidates:
|
|
||||||
# break
|
|
||||||
# await self.page.wait_for_timeout(500)
|
|
||||||
#
|
|
||||||
# if iframe_candidates:
|
|
||||||
# best = self._select_best_candidate(iframe_candidates, title, address)
|
|
||||||
# logger.info(
|
|
||||||
# f"[AUTO-SELECT-IFRAME] '{title}' → '{best['title']}' "
|
|
||||||
# f"(name={best['_name_score']:.2f}, addr={best['_addr_score']:.2f}) {best['place_url']}"
|
|
||||||
# )
|
|
||||||
# return best['place_url']
|
|
||||||
|
|
||||||
# isCorrectAnswer=true 로 강제 단일결과 재시도 (원본 로직 유지)
|
|
||||||
correct_url = self.page.url.replace("?", "?isCorrectAnswer=true&")
|
|
||||||
try:
|
try:
|
||||||
await self.goto_url(correct_url, wait_until="networkidle", timeout=self._timeout * 1000)
|
await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000)
|
||||||
except:
|
except:
|
||||||
if "/place/" in self.page.url:
|
if "/place/" in self.page.url:
|
||||||
return self.page.url
|
return self.page.url
|
||||||
logger.error("[ERROR] Can't Finish networkidle (isCorrectAnswer)")
|
logger.error(f"[ERROR] Can't Finish networkidle")
|
||||||
|
|
||||||
|
|
||||||
|
wait_first_time = (time.perf_counter() - wait_first_start) * 1000
|
||||||
|
|
||||||
|
logger.debug(f"[DEBUG] Try {count+1} : Wait for perfect matching : {wait_first_time}ms")
|
||||||
|
|
||||||
if "/place/" in self.page.url:
|
if "/place/" in self.page.url:
|
||||||
return self.page.url
|
return self.page.url
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_place_id_url(self, selected):
|
logger.debug(f"[DEBUG] Try {count+1} : url place id not found, retry for forced collect answer")
|
||||||
title = self._clean_title(selected['title'])
|
wait_forced_correct_start = time.perf_counter()
|
||||||
address = self._clean_title(selected.get('roadAddress', selected['address']))
|
|
||||||
|
|
||||||
# 1차 시도: 원본 주소 + 업체명
|
url = self.page.url.replace("?","?isCorrectAnswer=true&")
|
||||||
logger.debug(f"[DEBUG] 1차 시도 - address: {address}")
|
try:
|
||||||
result = await self._try_search(address, title)
|
await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000)
|
||||||
if result:
|
except:
|
||||||
return result
|
if "/place/" in self.page.url:
|
||||||
|
return self.page.url
|
||||||
|
logger.error(f"[ERROR] Can't Finish networkidle")
|
||||||
|
|
||||||
# 2차 시도: 정제 주소 + 업체명
|
wait_forced_correct_time = (time.perf_counter() - wait_forced_correct_start) * 1000
|
||||||
refined = self._refine_address(address)
|
logger.debug(f"[DEBUG] Try {count+1} : Wait for forced isCorrectAnswer flag : {wait_forced_correct_time}ms")
|
||||||
if refined != address:
|
|
||||||
await self.page.wait_for_timeout(self._retry_delay_ms)
|
|
||||||
logger.info(f"[REFINE] 주소 정제: '{address}' → '{refined}'")
|
|
||||||
result = await self._try_search(refined, title)
|
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 3차 시도: 업체명만으로 검색
|
if "/place/" in self.page.url:
|
||||||
await self.page.wait_for_timeout(self._retry_delay_ms)
|
return self.page.url
|
||||||
logger.info(f"[RETRY] 업체명만으로 재시도: '{title}'")
|
count += 1
|
||||||
result = await self._try_search("", title)
|
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 1~3차 모두 실패 → 네이버 안티봇 차단 의심, 컨텍스트 재생성 후 원본 조건으로 1회 재시도
|
logger.error("[ERROR] Not found url for {selected}")
|
||||||
logger.warning(f"[BLOCK-SUSPECTED] 3회 시도 모두 실패 → 컨텍스트 재생성 후 재시도: '{title}'")
|
|
||||||
await self.page.close()
|
|
||||||
await self._recreate_context()
|
|
||||||
await self.create_page()
|
|
||||||
result = await self._try_search(address, title)
|
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
|
|
||||||
logger.error(f"[ERROR] Not found url for {selected}")
|
return None # 404
|
||||||
return None
|
|
||||||
|
|
||||||
|
# if (count == self._max_retry / 2):
|
||||||
|
# raise Exception("Failed to identify place id. loading timeout")
|
||||||
|
# else:
|
||||||
|
# raise Exception("Failed to identify place id. item is ambiguous")
|
||||||
|
|||||||
@ -16,10 +16,6 @@ class GraphQLException(Exception):
|
|||||||
"""GraphQL 요청 실패 시 발생하는 예외"""
|
"""GraphQL 요청 실패 시 발생하는 예외"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class URLNotFoundException(Exception):
|
|
||||||
"""Place ID 발견 불가능 시 발생하는 예외"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class CrawlingTimeoutException(Exception):
|
class CrawlingTimeoutException(Exception):
|
||||||
"""크롤링 타임아웃 시 발생하는 예외"""
|
"""크롤링 타임아웃 시 발생하는 예외"""
|
||||||
@ -35,13 +31,6 @@ class NvMapScraper:
|
|||||||
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
|
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
|
||||||
REQUEST_TIMEOUT = 120 # 초
|
REQUEST_TIMEOUT = 120 # 초
|
||||||
data_source_identifier = "nv"
|
data_source_identifier = "nv"
|
||||||
SUPPLEMENT_THRESHOLD = 30 # 업체 사진이 이 수 미만일 때 방문자 사진으로 보충
|
|
||||||
# 방문자 사진 보충 시의 합산 상한 (필터링·정책상 제한).
|
|
||||||
# 업체 제공 사진은 필터링 면제 대상이므로 이 상한과 무관하게 수집분을 전부 사용한다
|
|
||||||
# (수집 자체는 BIZ_MAX_PAGES가 상한 — 초과 시 warning 로그 발생).
|
|
||||||
MAX_IMAGES = 50
|
|
||||||
BIZ_PAGE_SIZE = 20 # getPhotoViewerItems 'biz' 커서의 페이지당 사진 수
|
|
||||||
BIZ_MAX_PAGES = 5 # 업체 사진 수집 상한 (5×20=100장, 도달 시 warning)
|
|
||||||
OVERVIEW_QUERY: str = """
|
OVERVIEW_QUERY: str = """
|
||||||
query getAccommodation($id: String!, $deviceType: String) {
|
query getAccommodation($id: String!, $deviceType: String) {
|
||||||
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
|
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
|
||||||
@ -57,37 +46,8 @@ query getAccommodation($id: String!, $deviceType: String) {
|
|||||||
conveniences
|
conveniences
|
||||||
visitorReviewsTotal
|
visitorReviewsTotal
|
||||||
}
|
}
|
||||||
menus {
|
|
||||||
name
|
|
||||||
price
|
|
||||||
description
|
|
||||||
recommend
|
|
||||||
}
|
|
||||||
images { images { origin url } }
|
images { images { origin url } }
|
||||||
}
|
cpImages(source: [ugcImage]) { images { origin url } }
|
||||||
}"""
|
|
||||||
|
|
||||||
PHOTO_VIEWER_QUERY: str = """
|
|
||||||
query getPhotoViewerItems($input: PhotoViewerInput) {
|
|
||||||
photoViewer(input: $input) {
|
|
||||||
photos {
|
|
||||||
originalUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}"""
|
|
||||||
|
|
||||||
REVIEW_STATS_QUERY: str = """
|
|
||||||
query getVisitorReviewStats($id: String!) {
|
|
||||||
visitorReviewStats(input: {businessId: $id}) {
|
|
||||||
analysis {
|
|
||||||
votedKeyword {
|
|
||||||
details {
|
|
||||||
code
|
|
||||||
displayName
|
|
||||||
count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}"""
|
}"""
|
||||||
|
|
||||||
@ -105,14 +65,9 @@ query getVisitorReviewStats($id: String!) {
|
|||||||
)
|
)
|
||||||
self.scrap_type: str | None = None
|
self.scrap_type: str | None = None
|
||||||
self.rawdata: dict | None = None
|
self.rawdata: dict | None = None
|
||||||
self.image_link_list: list[dict] | None = None # [{"preview": str, "original": str}]
|
self.image_link_list: list[str] | None = None
|
||||||
self.owner_images: list[dict] | None = None # 업체 등록 사진 (마케팅 필터 제외 대상)
|
|
||||||
self.extra_photo_urls: list[dict] | None = None # 방문자/AI View 보충 사진 (마케팅 필터 대상)
|
|
||||||
self.base_info: dict | None = None
|
self.base_info: dict | None = None
|
||||||
self.facility_info: str | None = None
|
self.facility_info: str | None = None
|
||||||
self.voted_keyword_stats: list[dict] | None = None # 키워드 투표 집계 (displayName, count)
|
|
||||||
self.menu_info: list[dict] | None = None # 메뉴 목록 (name, price, description, recommend)
|
|
||||||
self.official_site_url: str | None = None # 업체 공식 링크 (base.homepages 대표 URL)
|
|
||||||
|
|
||||||
def _get_request_headers(self) -> dict:
|
def _get_request_headers(self) -> dict:
|
||||||
headers = self.DEFAULT_HEADERS.copy()
|
headers = self.DEFAULT_HEADERS.copy()
|
||||||
@ -120,98 +75,6 @@ query getVisitorReviewStats($id: String!) {
|
|||||||
headers["Cookie"] = self.cookies
|
headers["Cookie"] = self.cookies
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
_GIF_PATTERN = re.compile(r"\.gif(?:/|$)", re.IGNORECASE)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _is_gif_url(cls, url: str) -> bool:
|
|
||||||
"""URL의 확장자가 gif인지 확인한다.
|
|
||||||
|
|
||||||
네이버 CDN은 리사이즈 파라미터를 확장자 뒤에 경로 세그먼트로 붙인다
|
|
||||||
(예: ".../3982000924.gif/500x500") — endswith(".gif")로는 잡히지 않으므로
|
|
||||||
경로 세그먼트 단위로 ".gif" 뒤에 "/" 또는 문자열 끝이 오는지 검사한다.
|
|
||||||
"""
|
|
||||||
return bool(cls._GIF_PATTERN.search(url))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_origins(image_node: dict) -> list[dict]:
|
|
||||||
"""GraphQL 이미지 노드({ images: [{origin, url}, ...] })에서 {preview, original} 목록을 추출한다.
|
|
||||||
origin = 원본(업로드용), url = CDN 리사이즈(미리보기용)
|
|
||||||
"""
|
|
||||||
items = image_node.get("images") or []
|
|
||||||
return [
|
|
||||||
{"preview": item.get("url") or item["origin"], "original": item["origin"]}
|
|
||||||
for item in items
|
|
||||||
if item.get("origin") and not NvMapScraper._is_gif_url(item["origin"])
|
|
||||||
]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_photo_viewer_urls(photo_viewer_raw: dict | None) -> list[dict]:
|
|
||||||
"""getPhotoViewerItems 응답에서 {preview, original} 목록을 추출한다.
|
|
||||||
photoViewer는 썸네일 필드가 없으므로 preview = original 동일하게 사용.
|
|
||||||
"""
|
|
||||||
photos = ((photo_viewer_raw or {}).get("data") or {}).get("photoViewer") or {}
|
|
||||||
items = photos.get("photos") or []
|
|
||||||
return [
|
|
||||||
{"preview": p["originalUrl"], "original": p["originalUrl"]}
|
|
||||||
for p in items
|
|
||||||
if p.get("originalUrl") and not NvMapScraper._is_gif_url(p["originalUrl"])
|
|
||||||
]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _build_biz_payload(cls, place_id: str, page: int) -> dict:
|
|
||||||
"""'biz' 커서(업체 등록 사진)의 page번째(0-base) 페이지 요청 payload를 만든다."""
|
|
||||||
start_index = page * cls.BIZ_PAGE_SIZE
|
|
||||||
cursor: dict = {"id": "biz"}
|
|
||||||
if start_index > 0:
|
|
||||||
cursor.update({
|
|
||||||
"startIndex": start_index,
|
|
||||||
"hasNext": True,
|
|
||||||
"lastCursor": str(start_index),
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
"operationName": "getPhotoViewerItems",
|
|
||||||
"variables": {
|
|
||||||
"input": {
|
|
||||||
"businessId": place_id,
|
|
||||||
"cursors": [cursor],
|
|
||||||
"dateRange": "",
|
|
||||||
"excludeAuthorIds": [],
|
|
||||||
"excludeClipIds": [],
|
|
||||||
"excludeSection": [],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"query": cls.PHOTO_VIEWER_QUERY,
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _raw_photo_count(photo_viewer_raw: dict | None) -> int:
|
|
||||||
"""getPhotoViewerItems 응답의 사진 수(gif 필터링 전 원본 기준)를 반환한다.
|
|
||||||
|
|
||||||
페이지네이션 계속 여부 판단용 — gif가 걸러진 뒤의 수로 판단하면
|
|
||||||
마지막 페이지가 아닌데도 조기 종료할 수 있어 원본 개수를 사용한다.
|
|
||||||
"""
|
|
||||||
photos = ((photo_viewer_raw or {}).get("data") or {}).get("photoViewer") or {}
|
|
||||||
return len(photos.get("photos") or [])
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _dedup_by_original(images: list[dict]) -> list[dict]:
|
|
||||||
"""{"preview","original"} dict 리스트를 original URL 기준으로 순서를 유지한 채 중복 제거한다."""
|
|
||||||
seen: set[str] = set()
|
|
||||||
result = []
|
|
||||||
for img in images:
|
|
||||||
original = img.get("original")
|
|
||||||
if original and original not in seen:
|
|
||||||
seen.add(original)
|
|
||||||
result.append(img)
|
|
||||||
return result
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _interleave(a: list, b: list) -> list:
|
|
||||||
"""두 리스트를 1:1 교차 병합한다. 남은 항목은 뒤에 붙인다."""
|
|
||||||
result = [x for pair in zip(a, b) for x in pair]
|
|
||||||
longer = a[len(b):] if len(a) > len(b) else b[len(a):]
|
|
||||||
return result + longer
|
|
||||||
|
|
||||||
async def parse_url(self) -> str:
|
async def parse_url(self) -> str:
|
||||||
"""URL에서 place ID를 추출합니다. 단축 URL인 경우 실제 URL로 변환합니다."""
|
"""URL에서 place ID를 추출합니다. 단축 URL인 경우 실제 URL로 변환합니다."""
|
||||||
place_pattern = r"/place/(\d+)"
|
place_pattern = r"/place/(\d+)"
|
||||||
@ -223,270 +86,37 @@ query getVisitorReviewStats($id: String!) {
|
|||||||
async with session.get(self.url) as response:
|
async with session.get(self.url) as response:
|
||||||
self.url = str(response.url)
|
self.url = str(response.url)
|
||||||
else:
|
else:
|
||||||
raise URLNotFoundException("This URL does not contain a place ID")
|
raise GraphQLException("This URL does not contain a place ID")
|
||||||
|
|
||||||
match = re.search(place_pattern, self.url)
|
match = re.search(place_pattern, self.url)
|
||||||
if not match:
|
if not match:
|
||||||
# place.naver.com/{type}/{id} 형식 (예: m.place.naver.com/restaurant/11667161)
|
raise GraphQLException("Failed to parse place ID from URL")
|
||||||
type_match = re.search(r"place\.naver\.com/([a-zA-Z]+)/(\d+)", self.url)
|
|
||||||
if type_match:
|
|
||||||
return type_match.group(2)
|
|
||||||
if not match:
|
|
||||||
raise URLNotFoundException("Failed to parse place ID from URL")
|
|
||||||
return match[1]
|
return match[1]
|
||||||
|
|
||||||
async def scrap(self):
|
async def scrap(self):
|
||||||
|
try:
|
||||||
place_id = await self.parse_url()
|
place_id = await self.parse_url()
|
||||||
|
data = await self._call_get_accommodation(place_id)
|
||||||
# ── 빠른 경로: 직접 aiohttp 호출 (현재 비활성화) ──
|
|
||||||
# 데이터센터 IP에서는 네이버 WTM 안티봇이 대부분 405(캡차)로 차단해
|
|
||||||
# 시간만 버리므로 비활성화하고 곧바로 브라우저 경로를 탄다.
|
|
||||||
# 네이버가 차단을 완화하거나 다른 IP에서 운영해 직접 호출 성공률이
|
|
||||||
# 높아지면 아래 try 블록을 되살려 빠른 경로를 우선 시도하면 된다.
|
|
||||||
# try:
|
|
||||||
# data, fac_data, stats_data = await asyncio.gather(
|
|
||||||
# self._call_get_accommodation(place_id),
|
|
||||||
# self._get_facility_string(place_id),
|
|
||||||
# self._call_get_review_stats(place_id),
|
|
||||||
# )
|
|
||||||
# self.scrap_type = "GraphQL"
|
|
||||||
# except (GraphQLException, CrawlingTimeoutException) as e:
|
|
||||||
# logger.info(f"[NvMapScraper] 직접 호출 실패({e}) → 브라우저 폴백 시도")
|
|
||||||
# data, stats_data = await self._scrap_via_browser(place_id)
|
|
||||||
# fac_data = None # 편의시설(HTML)은 폴백 경로에서 생략 (best-effort)
|
|
||||||
# self.scrap_type = "GraphQL-Browser"
|
|
||||||
|
|
||||||
# ── 실제 브라우저로 WTM 캡차 우회 ──
|
|
||||||
data, stats_data, extra_photo_urls, biz_photo_urls, homepage_url = await self._scrap_via_browser(place_id)
|
|
||||||
# 편의시설은 HTML 페이지 파싱이라 GraphQL(WTM) 차단과 별개로 직접 시도 (best-effort, 실패 시 None)
|
|
||||||
# 홈페이지 링크는 브라우저 캡처가 실패한 경우에만 HTML 경로로 보충한다.
|
|
||||||
fac_data, html_homepage = await self._get_facility_and_homepage(place_id)
|
|
||||||
homepage_url = homepage_url or html_homepage
|
|
||||||
self.scrap_type = "GraphQL-Browser"
|
|
||||||
|
|
||||||
self.rawdata = data
|
self.rawdata = data
|
||||||
|
fac_data = await self._get_facility_string(place_id)
|
||||||
# Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것.
|
# Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것.
|
||||||
self.place_id = self.data_source_identifier + place_id
|
self.place_id = self.data_source_identifier + place_id
|
||||||
self.rawdata["facilities"] = fac_data
|
self.rawdata["facilities"] = fac_data
|
||||||
business = data["data"]["business"]
|
self.image_link_list = [
|
||||||
|
nv_image["origin"]
|
||||||
# 업체 등록 이미지 (마케팅 적합성 필터 제외 대상 — 자체 dedup).
|
for nv_image in data["data"]["business"]["images"]["images"]
|
||||||
# placeDetail.images가 대표 사진 일부만 반환하는 경우가 있어 biz 커서 결과를 병합한다.
|
]
|
||||||
self.owner_images = self._dedup_by_original(
|
|
||||||
self._extract_origins(business.get("images") or {}) + biz_photo_urls
|
|
||||||
)
|
|
||||||
|
|
||||||
# 방문자/AI View 보충 사진 (마케팅 적합성 필터 대상). owner에 이미 있는 원본은 제외.
|
|
||||||
owner_originals = {img["original"] for img in self.owner_images}
|
|
||||||
self.extra_photo_urls = self._dedup_by_original(
|
|
||||||
[img for img in extra_photo_urls if img["original"] not in owner_originals]
|
|
||||||
)
|
|
||||||
|
|
||||||
# 업체 사진이 임계값 미만이면 내부/외부/리뷰 사진으로 보충
|
|
||||||
# (필터링 없는 기본 조립. 마케팅 적합성 필터를 적용하려면 호출측에서
|
|
||||||
# owner_images / extra_photo_urls를 직접 사용해 image_filter.assemble_images로 재조립할 것.)
|
|
||||||
# MAX_IMAGES 상한은 방문자 사진이 섞이는 보충 경로에만 적용하며(필터링·정책상 제한),
|
|
||||||
# 업체 제공 사진만으로 구성되는 경우는 수집분(최대 BIZ_MAX_PAGES 페이지)을 전부 사용한다.
|
|
||||||
if len(self.owner_images) < self.SUPPLEMENT_THRESHOLD:
|
|
||||||
combined = self._dedup_by_original(self.owner_images + self.extra_photo_urls)
|
|
||||||
logger.info(
|
|
||||||
f"[NvMapScraper] 업체 사진 {len(self.owner_images)}장 < {self.SUPPLEMENT_THRESHOLD}장 "
|
|
||||||
f"→ 보충 사진 {len(self.extra_photo_urls)}장 추가 (합산 {len(combined)}장, 상한 {self.MAX_IMAGES}장)"
|
|
||||||
)
|
|
||||||
self.image_link_list = combined[: self.MAX_IMAGES]
|
|
||||||
else:
|
|
||||||
self.image_link_list = list(self.owner_images)
|
|
||||||
self.base_info = data["data"]["business"]["base"]
|
self.base_info = data["data"]["business"]["base"]
|
||||||
self.facility_info = fac_data
|
self.facility_info = fac_data
|
||||||
self.voted_keyword_stats = stats_data
|
self.scrap_type = "GraphQL"
|
||||||
self.menu_info = business.get("menus") or None
|
|
||||||
self.official_site_url = homepage_url
|
except GraphQLException:
|
||||||
|
logger.debug("GraphQL failed, fallback to Playwright")
|
||||||
|
self.scrap_type = "Playwright"
|
||||||
|
pass # 나중에 pw 이용한 crawling으로 fallback 추가
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_homepage_from_html(html: str) -> str | None:
|
|
||||||
"""플레이스 페이지 HTML의 __APOLLO_STATE__에서 홈페이지 링크를 추출한다.
|
|
||||||
|
|
||||||
GraphQL placeDetail(base)는 homepages 필드를 노출하지 않으므로(400),
|
|
||||||
SSR 페이지에 인라인된 Apollo 캐시의 "homepages" 객체를 직접 파싱한다.
|
|
||||||
대표(repr) 링크 우선, 죽은 링크(isDeadUrl)는 제외. 홈페이지 항목은
|
|
||||||
자체 홈페이지 외에 인스타그램/블로그 등일 수도 있다 — 업체가 대표로
|
|
||||||
등록한 링크를 그대로 신뢰한다.
|
|
||||||
"""
|
|
||||||
decoder = json.JSONDecoder()
|
|
||||||
search_from = 0
|
|
||||||
while True:
|
|
||||||
idx = html.find('"homepages":', search_from)
|
|
||||||
if idx == -1:
|
|
||||||
return None
|
|
||||||
search_from = idx + 1
|
|
||||||
try:
|
|
||||||
homepages, _ = decoder.raw_decode(html, idx + len('"homepages":'))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if not isinstance(homepages, dict):
|
|
||||||
continue
|
|
||||||
candidates = [homepages.get("repr"), *(homepages.get("etc") or [])]
|
|
||||||
for item in candidates:
|
|
||||||
if isinstance(item, dict) and item.get("url") and not item.get("isDeadUrl"):
|
|
||||||
return item["url"]
|
|
||||||
|
|
||||||
async def _scrap_via_browser(self, place_id: str) -> tuple[dict, list[dict] | None, list[dict], list[dict], str | None]:
|
|
||||||
"""직접 호출이 WTM 캡차에 막힌 경우, 실제 브라우저로 GraphQL을 호출한다.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(overview_data, review_stats_details, extra_photo_urls, biz_photo_urls, homepage_url)
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
GraphQLException: 브라우저 폴백마저 실패한 경우
|
|
||||||
"""
|
|
||||||
from app.utils.nvMapPwScraper import NvMapPwScraper
|
|
||||||
|
|
||||||
overview_payload = {
|
|
||||||
"operationName": "getAccommodation",
|
|
||||||
"variables": {"id": place_id, "deviceType": "pc"},
|
|
||||||
"query": self.OVERVIEW_QUERY,
|
|
||||||
}
|
|
||||||
stats_payload = {
|
|
||||||
"operationName": "getVisitorReviewStats",
|
|
||||||
"variables": {"id": place_id},
|
|
||||||
"query": self.REVIEW_STATS_QUERY,
|
|
||||||
}
|
|
||||||
interior_payload = {
|
|
||||||
"operationName": "getPhotoViewerItems",
|
|
||||||
"variables": {
|
|
||||||
"input": {
|
|
||||||
"businessId": place_id,
|
|
||||||
"cursors": [{"id": "aiView"}],
|
|
||||||
"filter": "AI View",
|
|
||||||
"subFilter": "INTERIOR",
|
|
||||||
"dateRange": "",
|
|
||||||
"excludeAuthorIds": [],
|
|
||||||
"excludeClipIds": [],
|
|
||||||
"excludeSection": [],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"query": self.PHOTO_VIEWER_QUERY,
|
|
||||||
}
|
|
||||||
exterior_payload = {
|
|
||||||
"operationName": "getPhotoViewerItems",
|
|
||||||
"variables": {
|
|
||||||
"input": {
|
|
||||||
"businessId": place_id,
|
|
||||||
"cursors": [{"id": "aiView"}],
|
|
||||||
"filter": "AI View",
|
|
||||||
"subFilter": "EXTERIOR",
|
|
||||||
"dateRange": "",
|
|
||||||
"excludeAuthorIds": [],
|
|
||||||
"excludeClipIds": [],
|
|
||||||
"excludeSection": [],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"query": self.PHOTO_VIEWER_QUERY,
|
|
||||||
}
|
|
||||||
review_payload = {
|
|
||||||
"operationName": "getPhotoViewerItems",
|
|
||||||
"variables": {
|
|
||||||
"input": {
|
|
||||||
"businessId": place_id,
|
|
||||||
"cursors": [{"id": "placeReview"}],
|
|
||||||
"dateRange": "",
|
|
||||||
"excludeAuthorIds": [],
|
|
||||||
"excludeClipIds": [],
|
|
||||||
"excludeSection": [],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"query": self.PHOTO_VIEWER_QUERY,
|
|
||||||
}
|
|
||||||
# 업체 등록 사진. placeDetail.images는 대표 사진 일부(1~수 장)만 반환하는
|
|
||||||
# 경우가 있어, 사진 탭이 실제 사용하는 'biz' 커서로 별도 조회해 보강한다.
|
|
||||||
# biz 커서는 페이지당 BIZ_PAGE_SIZE(20)장이며 lastCursor가 단순 인덱스 문자열
|
|
||||||
# ("20", "40")이라 이전 응답 없이도 모든 페이지를 미리 만들 수 있고, 범위를
|
|
||||||
# 벗어난 페이지는 빈 목록을 반환하므로 블라인드 요청해도 안전하다.
|
|
||||||
# 따라서 상한(BIZ_MAX_PAGES)까지의 전 페이지를 첫 배치에 한꺼번에 실어 보낸다
|
|
||||||
# — 추가 왕복(페이지 재탐색 + WTM 토큰 재캡처)이 없고, 개별 페이지 실패가
|
|
||||||
# 이후 페이지 수집을 막지 못한다.
|
|
||||||
biz_payloads = [
|
|
||||||
self._build_biz_payload(place_id, page) for page in range(self.BIZ_MAX_PAGES)
|
|
||||||
]
|
|
||||||
|
|
||||||
payloads = [overview_payload, stats_payload, interior_payload, exterior_payload, review_payload, *biz_payloads]
|
|
||||||
MAX_RETRY = 3
|
|
||||||
results = None
|
|
||||||
apollo_json: str | None = None
|
|
||||||
last_error: Exception | None = None
|
|
||||||
for attempt in range(1, MAX_RETRY + 1):
|
|
||||||
try:
|
|
||||||
results, captured_apollo = await NvMapPwScraper.fetch_graphql(
|
|
||||||
place_id, payloads, capture_apollo=True
|
|
||||||
)
|
|
||||||
apollo_json = apollo_json or captured_apollo
|
|
||||||
except Exception as e:
|
|
||||||
last_error = e
|
|
||||||
logger.warning(f"[NvMapScraper] 브라우저 폴백 시도 {attempt}/{MAX_RETRY} 오류: {e}")
|
|
||||||
if attempt < MAX_RETRY:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
continue
|
|
||||||
if results and results[0] is not None and "data" in results[0]:
|
|
||||||
break
|
|
||||||
logger.warning(f"[NvMapScraper] 브라우저 폴백 시도 {attempt}/{MAX_RETRY} 실패(405 등), 재시도")
|
|
||||||
results = None
|
|
||||||
if attempt < MAX_RETRY:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
if not results or results[0] is None or "data" not in results[0]:
|
|
||||||
if last_error:
|
|
||||||
raise GraphQLException(f"브라우저 폴백 실패: {last_error}")
|
|
||||||
raise GraphQLException("브라우저 폴백 크롤링 실패 (WTM 토큰 또는 응답 없음)")
|
|
||||||
|
|
||||||
data = results[0]
|
|
||||||
stats_data = None
|
|
||||||
stats_raw = results[1] if len(results) > 1 else None
|
|
||||||
if stats_raw:
|
|
||||||
_vrs = (stats_raw.get("data") or {}).get("visitorReviewStats") or {}
|
|
||||||
_analysis = _vrs.get("analysis") or {}
|
|
||||||
_voted = _analysis.get("votedKeyword") or {}
|
|
||||||
stats_data = _voted.get("details") or None
|
|
||||||
|
|
||||||
interior_urls = self._extract_photo_viewer_urls(results[2] if len(results) > 2 else None)
|
|
||||||
exterior_urls = self._extract_photo_viewer_urls(results[3] if len(results) > 3 else None)
|
|
||||||
review_urls = self._extract_photo_viewer_urls(results[4] if len(results) > 4 else None)
|
|
||||||
|
|
||||||
biz_pages = results[5:]
|
|
||||||
biz_urls = [url for page_result in biz_pages for url in self._extract_photo_viewer_urls(page_result)]
|
|
||||||
# 개별 페이지 실패(None)는 해당 20장만 누락되고 나머지 페이지는 영향 없다 (best-effort).
|
|
||||||
failed_biz_pages = sum(1 for r in biz_pages if r is None)
|
|
||||||
if failed_biz_pages:
|
|
||||||
logger.warning(
|
|
||||||
f"[NvMapScraper] 업체 사진 {failed_biz_pages}개 페이지 응답 실패 "
|
|
||||||
f"— 페이지당 최대 {self.BIZ_PAGE_SIZE}장 누락 가능 (수집분으로 진행)"
|
|
||||||
)
|
|
||||||
# 마지막 페이지까지 가득 차 있으면 상한 밖에 사진이 더 있을 수 있다.
|
|
||||||
if biz_pages and self._raw_photo_count(biz_pages[-1]) >= self.BIZ_PAGE_SIZE:
|
|
||||||
logger.warning(
|
|
||||||
f"[NvMapScraper] 업체 사진이 수집 상한(BIZ_MAX_PAGES={self.BIZ_MAX_PAGES}, "
|
|
||||||
f"{self.BIZ_MAX_PAGES * self.BIZ_PAGE_SIZE}장)까지 가득 참 — 초과분은 수집되지 않음"
|
|
||||||
)
|
|
||||||
|
|
||||||
extra_photo_urls = self._interleave(interior_urls, exterior_urls) + review_urls
|
|
||||||
logger.info(
|
|
||||||
f"[NvMapScraper] 보충 이미지 - 내부:{len(interior_urls)} 외부:{len(exterior_urls)} "
|
|
||||||
f"리뷰:{len(review_urls)} / 업체(biz):{len(biz_urls)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 홈페이지 링크: GraphQL base는 homepages를 노출하지 않으므로(400),
|
|
||||||
# 브라우저가 로드한 place 페이지의 __APOLLO_STATE__ JSON에서 추출한다.
|
|
||||||
homepage_url = (
|
|
||||||
self._extract_homepage_from_html(apollo_json) if apollo_json else None
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[NvMapScraper] 브라우저 폴백 SUCCESS - place_id: {place_id}, "
|
|
||||||
f"homepage: {homepage_url or '없음'}"
|
|
||||||
)
|
|
||||||
return data, stats_data, extra_photo_urls, biz_urls, homepage_url
|
|
||||||
|
|
||||||
async def _call_get_accommodation(self, place_id: str) -> dict:
|
async def _call_get_accommodation(self, place_id: str) -> dict:
|
||||||
"""GraphQL API를 호출하여 숙소 정보를 가져옵니다.
|
"""GraphQL API를 호출하여 숙소 정보를 가져옵니다.
|
||||||
|
|
||||||
@ -515,14 +145,13 @@ query getVisitorReviewStats($id: String!) {
|
|||||||
async with session.post(
|
async with session.post(
|
||||||
self.GRAPHQL_URL,
|
self.GRAPHQL_URL,
|
||||||
data=json_payload,
|
data=json_payload,
|
||||||
headers=self._get_request_headers(),
|
headers=self._get_request_headers()
|
||||||
) as response:
|
) as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
logger.info(f"[NvMapScraper] SUCCESS - place_id: {place_id}")
|
logger.info(f"[NvMapScraper] SUCCESS - place_id: {place_id}")
|
||||||
return await response.json()
|
return await response.json()
|
||||||
|
|
||||||
# 405/429 등 실패: 네이버 WTM 안티봇 캡차 차단 (데이터센터 IP/지문 기반).
|
# 실패 상태 코드
|
||||||
# 직접 호출로는 통과 불가 → scrap()에서 브라우저 폴백으로 전환.
|
|
||||||
logger.error(f"[NvMapScraper] Failed with status {response.status} - place_id: {place_id}")
|
logger.error(f"[NvMapScraper] Failed with status {response.status} - place_id: {place_id}")
|
||||||
raise GraphQLException(
|
raise GraphQLException(
|
||||||
f"Request failed with status {response.status}"
|
f"Request failed with status {response.status}"
|
||||||
@ -531,74 +160,33 @@ query getVisitorReviewStats($id: String!) {
|
|||||||
except (TimeoutError, asyncio.TimeoutError):
|
except (TimeoutError, asyncio.TimeoutError):
|
||||||
logger.error(f"[NvMapScraper] Timeout - place_id: {place_id}")
|
logger.error(f"[NvMapScraper] Timeout - place_id: {place_id}")
|
||||||
raise CrawlingTimeoutException(f"Request timed out after {self.REQUEST_TIMEOUT}s")
|
raise CrawlingTimeoutException(f"Request timed out after {self.REQUEST_TIMEOUT}s")
|
||||||
|
|
||||||
except aiohttp.ClientError as e:
|
except aiohttp.ClientError as e:
|
||||||
logger.error(f"[NvMapScraper] Client error: {e}")
|
logger.error(f"[NvMapScraper] Client error: {e}")
|
||||||
raise GraphQLException(f"Client error: {e}")
|
raise GraphQLException(f"Client error: {e}")
|
||||||
|
|
||||||
async def _call_get_review_stats(self, place_id: str) -> list[dict] | None:
|
async def _get_facility_string(self, place_id: str) -> str | None:
|
||||||
"""방문자 키워드 투표 집계를 가져옵니다.
|
"""숙소 페이지에서 편의시설 정보를 크롤링합니다.
|
||||||
|
|
||||||
Returns:
|
|
||||||
[{"code": ..., "displayName": ..., "count": ...}, ...] 또는 None
|
|
||||||
"""
|
|
||||||
payload = {
|
|
||||||
"operationName": "getVisitorReviewStats",
|
|
||||||
"variables": {"id": place_id},
|
|
||||||
"query": self.REVIEW_STATS_QUERY,
|
|
||||||
}
|
|
||||||
timeout = aiohttp.ClientTimeout(total=self.REQUEST_TIMEOUT)
|
|
||||||
try:
|
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
||||||
async with session.post(
|
|
||||||
self.GRAPHQL_URL,
|
|
||||||
json=payload,
|
|
||||||
headers=self._get_request_headers(),
|
|
||||||
) as response:
|
|
||||||
if response.status != 200:
|
|
||||||
logger.warning(f"[NvMapScraper] review stats failed: {response.status}")
|
|
||||||
return None
|
|
||||||
result = await response.json()
|
|
||||||
_vrs = (result.get("data") or {}).get("visitorReviewStats") or {}
|
|
||||||
_analysis = _vrs.get("analysis") or {}
|
|
||||||
_voted = _analysis.get("votedKeyword") or {}
|
|
||||||
return _voted.get("details") or None
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[NvMapScraper] Failed to get review stats: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _get_facility_and_homepage(self, place_id: str) -> tuple[str | None, str | None]:
|
|
||||||
"""장소 페이지에서 편의시설 정보와 홈페이지 링크를 크롤링합니다. 숙소, 음식점 순으로 시도합니다.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
place_id: 네이버 지도 장소 ID
|
place_id: 네이버 지도 장소 ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(편의시설 정보 문자열 또는 None, 홈페이지 링크 또는 None)
|
편의시설 정보 문자열 또는 None
|
||||||
"""
|
"""
|
||||||
facility: str | None = None
|
url = f"https://pcmap.place.naver.com/accommodation/{place_id}/home"
|
||||||
homepage: str | None = None
|
|
||||||
place_types = ["place", "accommodation", "restaurant"]
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
for place_type in place_types:
|
|
||||||
url = f"https://pcmap.place.naver.com/{place_type}/{place_id}/home"
|
|
||||||
async with session.get(url, headers=self._get_request_headers()) as response:
|
async with session.get(url, headers=self._get_request_headers()) as response:
|
||||||
raw = await response.read()
|
soup = bs4.BeautifulSoup(await response.read(), "html.parser")
|
||||||
if homepage is None:
|
|
||||||
homepage = self._extract_homepage_from_html(
|
|
||||||
raw.decode("utf-8", errors="ignore")
|
|
||||||
)
|
|
||||||
if facility is None:
|
|
||||||
soup = bs4.BeautifulSoup(raw, "html.parser")
|
|
||||||
c_elem = soup.find("span", "place_blind", string="편의")
|
c_elem = soup.find("span", "place_blind", string="편의")
|
||||||
if c_elem:
|
if c_elem:
|
||||||
facility = c_elem.parent.parent.find("div").string
|
facilities = c_elem.parent.parent.find("div").string
|
||||||
if facility is not None and homepage is not None:
|
return facilities
|
||||||
break
|
return None
|
||||||
return facility, homepage
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[NvMapScraper] Failed to get facility/homepage info: {e}")
|
logger.warning(f"[NvMapScraper] Failed to get facility info: {e}")
|
||||||
return facility, homepage
|
return None
|
||||||
|
|
||||||
|
|
||||||
# if __name__ == "__main__":
|
# if __name__ == "__main__":
|
||||||
|
|||||||
@ -1,277 +0,0 @@
|
|||||||
import json
|
|
||||||
import re
|
|
||||||
from pydantic import BaseModel, ValidationError
|
|
||||||
from typing import List, Optional
|
|
||||||
from openai import AsyncOpenAI
|
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
|
||||||
from config import apikey_settings, recovery_settings
|
|
||||||
from app.utils.prompts.prompts import Prompt
|
|
||||||
|
|
||||||
|
|
||||||
# 로거 설정
|
|
||||||
logger = get_logger("chatgpt")
|
|
||||||
|
|
||||||
|
|
||||||
class ChatGPTResponseError(Exception):
|
|
||||||
"""ChatGPT API 응답 에러"""
|
|
||||||
def __init__(self, status: str, error_code: str = None, error_message: str = None):
|
|
||||||
self.status = status
|
|
||||||
self.error_code = error_code
|
|
||||||
self.error_message = error_message
|
|
||||||
super().__init__(f"ChatGPT response failed: status={status}, code={error_code}, message={error_message}")
|
|
||||||
|
|
||||||
|
|
||||||
class ChatgptService:
|
|
||||||
"""ChatGPT API 서비스 클래스
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_type : str
|
|
||||||
|
|
||||||
def __init__(self, model_type:str = "gpt", timeout: float = None):
|
|
||||||
self.timeout = timeout or recovery_settings.CHATGPT_TIMEOUT
|
|
||||||
self.max_retries = recovery_settings.CHATGPT_MAX_RETRIES
|
|
||||||
self.model_type = model_type
|
|
||||||
match model_type:
|
|
||||||
case "gpt":
|
|
||||||
self.client = AsyncOpenAI(
|
|
||||||
api_key=apikey_settings.CHATGPT_API_KEY,
|
|
||||||
timeout=self.timeout
|
|
||||||
)
|
|
||||||
case "gemini":
|
|
||||||
self.client = AsyncOpenAI(
|
|
||||||
api_key=apikey_settings.GEMINI_API_KEY,
|
|
||||||
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
||||||
timeout=self.timeout
|
|
||||||
)
|
|
||||||
case _:
|
|
||||||
raise NotImplementedError(f"Unknown Provider : {model_type}")
|
|
||||||
|
|
||||||
def _log_usage(self, response, model: str, output_format: type[BaseModel]) -> None:
|
|
||||||
usage = getattr(response, "usage", None)
|
|
||||||
if usage is None:
|
|
||||||
return
|
|
||||||
# 토큰 소모량 로깅 (필요 시 주석 해제)
|
|
||||||
# cached = getattr(getattr(usage, "prompt_tokens_details", None), "cached_tokens", None) or 0
|
|
||||||
# reasoning = getattr(getattr(usage, "completion_tokens_details", None), "reasoning_tokens", None) or 0
|
|
||||||
# logger.info(
|
|
||||||
# f"[ChatgptService({self.model_type})] usage model={model} output={output_format.__name__} "
|
|
||||||
# f"prompt={usage.prompt_tokens} cached={cached} "
|
|
||||||
# f"completion={usage.completion_tokens} reasoning={reasoning} total={usage.total_tokens}"
|
|
||||||
# )
|
|
||||||
|
|
||||||
async def _call_pydantic_output(
|
|
||||||
self,
|
|
||||||
prompt : str,
|
|
||||||
output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
|
|
||||||
model : str,
|
|
||||||
img_url : str,
|
|
||||||
image_detail_high : bool) -> BaseModel:
|
|
||||||
content = []
|
|
||||||
if img_url:
|
|
||||||
content.append({
|
|
||||||
"type" : "input_image",
|
|
||||||
"image_url" : img_url,
|
|
||||||
"detail": "high" if image_detail_high else "low"
|
|
||||||
})
|
|
||||||
content.append({
|
|
||||||
"type": "input_text",
|
|
||||||
"text": prompt}
|
|
||||||
)
|
|
||||||
last_error = None
|
|
||||||
for attempt in range(self.max_retries + 1):
|
|
||||||
response = await self.client.responses.parse(
|
|
||||||
model=model,
|
|
||||||
input=[{"role": "user", "content": content}],
|
|
||||||
text_format=output_format
|
|
||||||
)
|
|
||||||
# Response 디버그 로깅
|
|
||||||
logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}")
|
|
||||||
logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}")
|
|
||||||
logger.debug(f"[ChatgptService({self.model_type})] Response status: {response.status}")
|
|
||||||
logger.debug(f"[ChatgptService({self.model_type})] Response model: {response.model}")
|
|
||||||
|
|
||||||
# status 확인: completed, failed, incomplete, cancelled, queued, in_progress
|
|
||||||
if response.status == "completed":
|
|
||||||
logger.debug(f"[ChatgptService({self.model_type})] Response output_text: {response.output_text[:200]}..." if len(response.output_text) > 200 else f"[ChatgptService] Response output_text: {response.output_text}")
|
|
||||||
structured_output = response.output_parsed
|
|
||||||
return structured_output #.model_dump() or {}
|
|
||||||
|
|
||||||
# 에러 상태 처리
|
|
||||||
if response.status == "failed":
|
|
||||||
error_code = getattr(response.error, 'code', None) if response.error else None
|
|
||||||
error_message = getattr(response.error, 'message', None) if response.error else None
|
|
||||||
logger.warning(f"[ChatgptService({self.model_type})] Response failed (attempt {attempt + 1}/{self.max_retries + 1}): code={error_code}, message={error_message}")
|
|
||||||
last_error = ChatGPTResponseError(response.status, error_code, error_message)
|
|
||||||
|
|
||||||
elif response.status == "incomplete":
|
|
||||||
reason = getattr(response.incomplete_details, 'reason', None) if response.incomplete_details else None
|
|
||||||
logger.warning(f"[ChatgptService({self.model_type})] Response incomplete (attempt {attempt + 1}/{self.max_retries + 1}): reason={reason}")
|
|
||||||
last_error = ChatGPTResponseError(response.status, reason, f"Response incomplete: {reason}")
|
|
||||||
|
|
||||||
else:
|
|
||||||
# cancelled, queued, in_progress 등 예상치 못한 상태
|
|
||||||
logger.warning(f"[ChatgptService({self.model_type})] Unexpected response status (attempt {attempt + 1}/{self.max_retries + 1}): {response.status}")
|
|
||||||
last_error = ChatGPTResponseError(response.status, None, f"Unexpected status: {response.status}")
|
|
||||||
|
|
||||||
# 마지막 시도가 아니면 재시도
|
|
||||||
if attempt < self.max_retries:
|
|
||||||
logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
|
|
||||||
|
|
||||||
# 모든 재시도 실패
|
|
||||||
logger.error(f"[ChatgptService({self.model_type})] All retries exhausted. Last error: {last_error}")
|
|
||||||
raise last_error
|
|
||||||
|
|
||||||
async def _call_pydantic_output_chat_completion( # alter version
|
|
||||||
self,
|
|
||||||
prompt : str,
|
|
||||||
output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
|
|
||||||
model : str,
|
|
||||||
img_url : str,
|
|
||||||
image_detail_high : bool,
|
|
||||||
reasoning_effort : Optional[str] = None) -> BaseModel:
|
|
||||||
content = []
|
|
||||||
if img_url:
|
|
||||||
content.append({
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": {
|
|
||||||
"url": img_url,
|
|
||||||
"detail": "high" if image_detail_high else "low"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
content.append({
|
|
||||||
"type": "text",
|
|
||||||
"text": prompt
|
|
||||||
})
|
|
||||||
# gpt-5.4 계열/Gemini 호환 엔드포인트는 허용 값이 다르거나 파라미터를 거부하므로 지정된 경우에만 전달
|
|
||||||
extra_kwargs = {"reasoning_effort": reasoning_effort} if reasoning_effort else {}
|
|
||||||
last_error = None
|
|
||||||
for attempt in range(self.max_retries + 1):
|
|
||||||
try:
|
|
||||||
response = await self.client.beta.chat.completions.parse(
|
|
||||||
model=model,
|
|
||||||
messages=[{"role": "user", "content": content}],
|
|
||||||
response_format=output_format,
|
|
||||||
**extra_kwargs,
|
|
||||||
)
|
|
||||||
except (ValidationError, json.JSONDecodeError) as e:
|
|
||||||
# 모델이 스키마에 맞지 않는 JSON을 반환한 경우 (예: trailing characters).
|
|
||||||
# 확률적 출력 문제일 수 있으므로 재시도 대상에 포함한다.
|
|
||||||
logger.warning(
|
|
||||||
f"[ChatgptService({self.model_type})] Structured output parse failed "
|
|
||||||
f"(attempt {attempt + 1}/{self.max_retries + 1}): {e}"
|
|
||||||
)
|
|
||||||
last_error = ChatGPTResponseError("parse_error", type(e).__name__, str(e))
|
|
||||||
if attempt < self.max_retries:
|
|
||||||
logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
|
|
||||||
continue
|
|
||||||
self._log_usage(response, model, output_format)
|
|
||||||
# Response 디버그 로깅
|
|
||||||
# logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}")
|
|
||||||
# logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}")
|
|
||||||
# logger.debug(f"[ChatgptService({self.model_type})] Response finish_reason: {response.id}")
|
|
||||||
# logger.debug(f"[ChatgptService({self.model_type})] Response model: {response.model}")
|
|
||||||
|
|
||||||
choice = response.choices[0]
|
|
||||||
finish_reason = choice.finish_reason
|
|
||||||
|
|
||||||
if finish_reason == "stop":
|
|
||||||
# output_text = choice.message.content or ""
|
|
||||||
# logger.debug(f"[ChatgptService({self.model_type})] Response output_text: {output_text[:200]}..." if len(output_text) > 200 else f"[ChatgptService] Response output_text: {output_text}")
|
|
||||||
return choice.message.parsed
|
|
||||||
|
|
||||||
elif finish_reason == "length":
|
|
||||||
logger.warning(f"[ChatgptService({self.model_type})] Response incomplete - token limit reached (attempt {attempt + 1}/{self.max_retries + 1})")
|
|
||||||
last_error = ChatGPTResponseError("incomplete", finish_reason, "Response incomplete: max tokens reached")
|
|
||||||
|
|
||||||
elif finish_reason == "content_filter":
|
|
||||||
logger.warning(f"[ChatgptService({self.model_type})] Response blocked by content filter (attempt {attempt + 1}/{self.max_retries + 1})")
|
|
||||||
last_error = ChatGPTResponseError("failed", finish_reason, "Response blocked by content filter")
|
|
||||||
|
|
||||||
else:
|
|
||||||
logger.warning(f"[ChatgptService({self.model_type})] Unexpected finish_reason (attempt {attempt + 1}/{self.max_retries + 1}): {finish_reason}")
|
|
||||||
last_error = ChatGPTResponseError("failed", finish_reason, f"Unexpected finish_reason: {finish_reason}")
|
|
||||||
|
|
||||||
# 마지막 시도가 아니면 재시도
|
|
||||||
if attempt < self.max_retries:
|
|
||||||
logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
|
|
||||||
|
|
||||||
# 모든 재시도 실패
|
|
||||||
logger.error(f"[ChatgptService({self.model_type})] All retries exhausted. Last error: {last_error}")
|
|
||||||
raise last_error
|
|
||||||
|
|
||||||
async def generate_structured_output_multi_image(
|
|
||||||
self,
|
|
||||||
prompt_text: str,
|
|
||||||
output_format: BaseModel,
|
|
||||||
model: str,
|
|
||||||
img_urls: List[str],
|
|
||||||
image_detail_high: bool = True,
|
|
||||||
) -> BaseModel:
|
|
||||||
"""여러 이미지를 한 번에 보고 구조화 출력을 생성합니다 (썸네일 비전 선택용).
|
|
||||||
|
|
||||||
sheet 기반 Prompt를 거치지 않고 코드에서 조립한 프롬프트 텍스트와
|
|
||||||
이미지 URL 리스트를 직접 받는다. 이미지들은 프롬프트에 나열된 순서와
|
|
||||||
동일하게 첨부되므로, 프롬프트에서 "N번째 이미지"로 지칭할 수 있다.
|
|
||||||
"""
|
|
||||||
content = []
|
|
||||||
for url in img_urls:
|
|
||||||
content.append({
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": {
|
|
||||||
"url": url,
|
|
||||||
"detail": "high" if image_detail_high else "low",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
content.append({"type": "text", "text": prompt_text})
|
|
||||||
|
|
||||||
last_error = None
|
|
||||||
for attempt in range(self.max_retries + 1):
|
|
||||||
try:
|
|
||||||
response = await self.client.beta.chat.completions.parse(
|
|
||||||
model=model,
|
|
||||||
messages=[{"role": "user", "content": content}],
|
|
||||||
response_format=output_format,
|
|
||||||
)
|
|
||||||
except (ValidationError, json.JSONDecodeError) as e:
|
|
||||||
logger.warning(
|
|
||||||
f"[ChatgptService({self.model_type})] multi-image parse failed "
|
|
||||||
f"(attempt {attempt + 1}/{self.max_retries + 1}): {e}"
|
|
||||||
)
|
|
||||||
last_error = ChatGPTResponseError("parse_error", type(e).__name__, str(e))
|
|
||||||
if attempt < self.max_retries:
|
|
||||||
continue
|
|
||||||
raise last_error
|
|
||||||
|
|
||||||
self._log_usage(response, model, output_format)
|
|
||||||
choice = response.choices[0]
|
|
||||||
if choice.finish_reason == "stop":
|
|
||||||
return choice.message.parsed
|
|
||||||
logger.warning(
|
|
||||||
f"[ChatgptService({self.model_type})] multi-image unexpected finish_reason "
|
|
||||||
f"(attempt {attempt + 1}/{self.max_retries + 1}): {choice.finish_reason}"
|
|
||||||
)
|
|
||||||
last_error = ChatGPTResponseError("failed", choice.finish_reason, "multi-image call failed")
|
|
||||||
|
|
||||||
raise last_error
|
|
||||||
|
|
||||||
async def generate_structured_output(
|
|
||||||
self,
|
|
||||||
prompt : Prompt,
|
|
||||||
input_data : dict,
|
|
||||||
img_url : Optional[str] = None,
|
|
||||||
img_detail_high : bool = False,
|
|
||||||
silent : bool = True,
|
|
||||||
reasoning_effort : Optional[str] = None,
|
|
||||||
) -> BaseModel:
|
|
||||||
prompt_text = prompt.build_prompt(input_data, silent)
|
|
||||||
|
|
||||||
logger.debug(f"[ChatgptService({self.model_type})] Generated Prompt (length: {len(prompt_text)})")
|
|
||||||
if not silent:
|
|
||||||
logger.info(f"[ChatgptService({self.model_type})] Starting GPT request with structured output with model: {prompt.prompt_model}")
|
|
||||||
|
|
||||||
# GPT API 호출
|
|
||||||
#parsed = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
|
|
||||||
# parsed = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
|
|
||||||
parsed = await self._call_pydantic_output_chat_completion(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high, reasoning_effort)
|
|
||||||
return parsed
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user