Compare commits

..

6 Commits

Author SHA1 Message Date
Dohyun Lim
c6fc164c41 Merge branch 'facebook' of https://gitea.o2o.kr/castad/o2o-castad-backend into facebook
마지막 main 머지를 facebook 브렌치로 백업, 저장한 버전
2026-03-13 15:49:38 +09:00
Dohyun Lim
4fbfbf92a6 add exception error cases 2026-03-13 11:22:55 +09:00
Dohyun Lim
50796ac743 Merge facebook into main 2026-03-13 11:04:25 +09:00
Dohyun Lim
9c3f616f37 finish facebook login 2026-03-13 10:59:03 +09:00
Dohyun Lim
0dd0c8595f add fc 2026-03-09 13:54:10 +09:00
Dohyun Lim
6d86aaa1be first commit 2026-03-05 13:29:33 +09:00
148 changed files with 5504 additions and 13192 deletions

11
.gitignore vendored
View File

@ -32,11 +32,8 @@ media/
*.ipynb_checkpoint*
# Static files (공유 기본 이미지는 예외로 추적)
static/*
!static/images/
static/images/*
!static/images/ado2_image.png
# Static files
static/
# Log files
*.log
@ -54,6 +51,4 @@ logs/
Dockerfile
.dockerignore
zzz/
credentials/service_account.json
o2o-castad-scheduler/
zzz/

View File

@ -69,9 +69,6 @@ PROJECT_DOMAIN=localhost:8000 # 프로젝트 도메인 (호스트:포
PROJECT_VERSION=0.1.0 # 프로젝트 버전
DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명
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: 운영)
# ================================
@ -179,66 +176,6 @@ fastapi dev 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 문서
서버 실행 후 `/docs` 에서 Scalar API 문서를 확인할 수 있습니다.
@ -339,5 +276,3 @@ socket에만 bind하고 해당 서비스 관리자의 메모리 제한을 별도
│◀───────────────│ │ │
│ │ │ │
```
testAc

674
WORK_PLAN_FACEBOOK_OAUTH.md Normal file
View 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 패턴 |

View File

@ -1,78 +1,48 @@
from pathlib import Path
from fastapi import FastAPI
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.backoffice.admin.auth import AdminAuthBackend
from app.backoffice.credit_view import CreditChargeRequestAdmin, CreditTransactionAdmin
from app.backoffice.dashboard import get_dashboard_context
from app.user.api.user_admin import SocialAccountAdmin, UserAdmin
from config import prj_settings
TEMPLATES_DIR = Path(__file__).parent / "backoffice" / "frontend" / "templates"
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(
app: FastAPI,
db_engine: AsyncEngine,
base_url: str = prj_settings.ADMIN_BASE_URL,
) -> Admin:
auth_backend = AdminAuthBackend(secret_key=prj_settings.ADMIN_SESSION_SECRET)
admin = DashboardAdmin(
app,
db_engine,
base_url=base_url,
authentication_backend=auth_backend,
title="ADO2 관리자",
templates_dir=str(TEMPLATES_DIR),
)
# 사용자 관리
admin.add_view(UserAdmin)
admin.add_view(SocialAccountAdmin)
# 크레딧 관리 (superadmin: 전체, viewer: 읽기 전용)
admin.add_view(CreditChargeRequestAdmin)
admin.add_view(CreditTransactionAdmin)
# 백오피스 설정
admin.add_view(AdminAdmin)
return admin
from fastapi import FastAPI
from sqladmin import Admin
from app.database.session import engine
from app.home.api.home_admin import ImageAdmin, ProjectAdmin
from app.lyric.api.lyrics_admin import LyricAdmin
from app.song.api.song_admin import SongAdmin
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
# https://github.com/aminalaee/sqladmin
def init_admin(
app: FastAPI,
db_engine: engine,
base_url: str = prj_settings.ADMIN_BASE_URL,
) -> Admin:
admin = Admin(
app,
db_engine,
base_url=base_url,
)
# 프로젝트 관리
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(RefreshTokenAdmin)
admin.add_view(SocialAccountAdmin)
# SNS 관리
admin.add_view(SNSUploadTaskAdmin)
return admin

View File

@ -16,14 +16,7 @@ from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.utils.logger import get_logger
from app.utils.pagination import PaginatedResponse
from app.utils.upload_blob_as_request import to_playback_url
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.models import Video
from app.video.schemas.video_schema import VideoListItem
logger = get_logger(__name__)
@ -106,22 +99,9 @@ async def get_videos(
total_result = await session.execute(count_query)
total = total_result.scalar() or 0
# 쿼리 2: Video + Project + comment_count 조회 (like_count는 Redis에서)
comment_count_subq = (
select(func.count(Comment.id))
.where(
Comment.video_id == Video.id,
Comment.is_deleted == False, # noqa: E712
)
.correlate(Video)
.scalar_subquery()
)
# 쿼리 2: Video + Project 데이터 조회 (task_id별 최신 영상만)
data_query = (
select(
Video,
Project,
comment_count_subq.label("comment_count"),
)
select(Video, Project)
.join(Project, Video.project_id == Project.id)
.where(Video.id.in_(select(latest_video_ids.c.latest_id)))
.order_by(Video.created_at.desc())
@ -131,47 +111,6 @@ async def get_videos(
result = await session.execute(data_query)
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으로 변환
items = [
VideoListItem(
@ -179,17 +118,10 @@ async def get_videos(
store_name=project.store_name,
region=project.region,
task_id=video.task_id,
result_movie_url=to_playback_url(video.result_movie_url),
poster_url=video.poster_url,
title=video.title,
description=video.description,
hashtags=video.hashtags,
result_movie_url=video.result_movie_url,
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(

View File

@ -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

View File

@ -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

View File

@ -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})>"

View File

@ -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}")

View File

@ -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))
)

View File

@ -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,
}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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")

View File

@ -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)

View File

@ -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="댓글이 삭제되었습니다.",
)

View File

@ -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",
)

View File

@ -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="결과 메시지")

View File

@ -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()

View File

@ -51,9 +51,6 @@ async def lifespan(app: FastAPI):
await close_shared_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

View File

@ -314,10 +314,7 @@ def add_exception_handlers(app: FastAPI):
@app.exception_handler(DashboardException)
def dashboard_exception_handler(request: Request, exc: DashboardException) -> Response:
if exc.status_code < 500:
logger.warning(f"Handled DashboardException: {exc.__class__.__name__} - {exc.message}")
else:
logger.error(f"Handled DashboardException: {exc.__class__.__name__} - {exc.message}")
logger.debug(f"Handled DashboardException: {exc.__class__.__name__} - {exc.message}")
return JSONResponse(
status_code=exc.status_code,
content={

View File

View File

@ -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,
)

View File

@ -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

View File

@ -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")>"
)

View File

@ -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

View File

@ -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

View File

@ -4,22 +4,43 @@ Dashboard API 라우터
YouTube Analytics 기반 대시보드 통계를 제공합니다.
"""
import json
import logging
from datetime import date, datetime, timedelta
from typing import Literal
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dashboard.utils.redis_cache import delete_cache_pattern
from app.dashboard.schemas import (
CacheDeleteResponse,
ConnectedAccountsResponse,
DashboardResponse,
from app.dashboard.exceptions import (
YouTubeAccountNotConnectedError,
YouTubeAccountNotFoundError,
YouTubeAccountSelectionRequiredError,
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.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.models import User
from app.user.models import SocialAccount, User
logger = logging.getLogger(__name__)
@ -40,8 +61,41 @@ async def get_connected_accounts(
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> ConnectedAccountsResponse:
service = DashboardService()
connected = await service.get_connected_accounts(current_user, session)
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()
# 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)
@ -88,8 +142,328 @@ async def get_dashboard_stats(
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> 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(
@ -109,7 +483,7 @@ async def get_dashboard_stats(
`dashboard:{user_uuid}:{platform_user_id}:{mode}` (mode: day 또는 month)
## 파라미터
- `user_uuid`: 삭제할 사용자 UUID (필수)
- `user_uuid`: 특정 사용자 캐시만 삭제. 미입력 시 전체 삭제
- `mode`: day / month / all (기본값: all)
""",
)
@ -118,16 +492,33 @@ async def delete_dashboard_cache(
default="all",
description="삭제할 캐시 모드: day, month, all(기본값, 모두 삭제)",
),
user_uuid: str = Query(
description="대상 사용자 UUID",
user_uuid: str | None = Query(
default=None,
description="대상 사용자 UUID. 미입력 시 전체 사용자 캐시 삭제",
),
) -> CacheDeleteResponse:
if mode == "all":
deleted = await delete_cache_pattern(f"dashboard:{user_uuid}:*")
message = f"전체 캐시 삭제 완료 ({deleted}개)"
"""
대시보드 캐시 삭제
Args:
mode: 삭제할 캐시 모드 (day / month / all)
user_uuid: 대상 사용자 UUID (없으면 전체 삭제)
Returns:
CacheDeleteResponse: 삭제된 캐시 키 개수 및 메시지
"""
if user_uuid:
if mode == "all":
deleted = await delete_cache_pattern(f"dashboard:{user_uuid}:*")
message = f"전체 캐시 삭제 완료 ({deleted}개)"
else:
cache_key = f"dashboard:{user_uuid}:{mode}"
success = await delete_cache(cache_key)
deleted = 1 if success else 0
message = f"{mode} 캐시 삭제 {'완료' if success else '실패 (키 없음)'}"
else:
deleted = await delete_cache_pattern(f"dashboard:{user_uuid}:*:{mode}")
message = f"{mode} 캐시 삭제 완료 ({deleted}개)"
deleted = await delete_cache_pattern("dashboard:*")
message = f"전체 사용자 캐시 삭제 완료 ({deleted}개)"
logger.info(
f"[CACHE DELETE] user_uuid={user_uuid or 'ALL'}, mode={mode}, deleted={deleted}"

View File

@ -113,7 +113,7 @@ class YouTubeAccountSelectionRequiredError(DashboardException):
def __init__(self):
super().__init__(
message="연결된 YouTube 계정이 여러 개입니다. platform_user_id 파라미터로 사용할 계정을 선택해주세요.",
message="연결된 YouTube 계정이 여러 개입니다. social_account_id 파라미터로 사용할 계정을 선택해주세요.",
status_code=status.HTTP_400_BAD_REQUEST,
code="YOUTUBE_ACCOUNT_SELECTION_REQUIRED",
)

View File

@ -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):
"""대시보드 전체 응답
@ -226,6 +255,7 @@ class DashboardResponse(BaseModel):
top_content: list[TopContent] = Field(alias="topContent")
audience_data: AudienceData = Field(alias="audienceData")
has_uploads: bool = Field(default=True, alias="hasUploads")
# platform_data: list[PlatformData] = Field(default=[], alias="platformData") # 미사용
model_config = ConfigDict(
alias_generator=to_camel,

View File

@ -4,12 +4,10 @@ Dashboard Services
YouTube Analytics API 연동 및 데이터 가공 서비스를 제공합니다.
"""
from app.dashboard.services.dashboard_service import DashboardService
from app.dashboard.services.data_processor import DataProcessor
from app.dashboard.services.youtube_analytics import YouTubeAnalyticsService
__all__ = [
"DashboardService",
"YouTubeAnalyticsService",
"DataProcessor",
]

View File

@ -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

View File

@ -143,8 +143,8 @@ class DataProcessor:
monthly_data = []
audience_data = self._build_audience_data(
raw_data.get("demographics") or {},
raw_data.get("region") or {},
raw_data.get("demographics", {}),
raw_data.get("region", {}),
)
logger.debug(
f"[DataProcessor.process] SUCCESS - "

View File

@ -141,9 +141,6 @@ class YouTubeAnalyticsService:
results = await asyncio.gather(*tasks, return_exceptions=True)
# 에러 체크 (YouTubeAuthError, YouTubeQuotaExceededError는 원형 그대로 전파)
# demographics(index 5)는 YouTubeAPIError 시 None으로 허용 (YouTube 서버 간헐적 오류 대응)
OPTIONAL_INDICES = {5, 6} # demographics, region
results = list(results)
for i, result in enumerate(results):
if isinstance(result, Exception):
logger.error(
@ -151,12 +148,6 @@ class YouTubeAnalyticsService:
)
if isinstance(result, (YouTubeAuthError, YouTubeQuotaExceededError)):
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__}")
logger.debug(
@ -433,10 +424,6 @@ class YouTubeAnalyticsService:
logger.debug("[YouTubeAnalyticsService._fetch_region] SUCCESS")
return result
# 5xx/네트워크 오류 재시도 설정 (구글 backendError 등 일시적 장애 대응)
_MAX_RETRIES = 3
_RETRY_BACKOFF_SECONDS = (0.5, 1.0, 2.0)
async def _call_api(
self,
params: dict[str, str],
@ -457,67 +444,51 @@ class YouTubeAnalyticsService:
Raises:
YouTubeQuotaExceededError: 할당량 초과 (429)
YouTubeAuthError: 인증 실패 (401, 403)
YouTubeAPIError: 기타 API 오류 (5xx/네트워크 오류는 최대 3회 재시도 후 발생)
YouTubeAPIError: 기타 API 오류
Note:
- 타임아웃: 30초
- 할당량 초과 시 자동으로 YouTubeQuotaExceededError 발생
- 인증 실패 시 자동으로 YouTubeAuthError 발생
- 5xx 응답 및 네트워크 오류는 지수 백오프(0.5s→1s→2s)로 최대 3회 재시도
"""
headers = {"Authorization": f"Bearer {access_token}"}
last_error: Exception | None = None
for attempt in range(self._MAX_RETRIES + 1):
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
self.BASE_URL,
params=params,
headers=headers,
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
self.BASE_URL,
params=params,
headers=headers,
)
# 할당량 초과 체크
if response.status_code == 429:
logger.warning("[YouTubeAnalyticsService._call_api] QUOTA_EXCEEDED")
raise YouTubeQuotaExceededError()
# 인증 실패 체크
if response.status_code in (401, 403):
logger.warning(
f"[YouTubeAnalyticsService._call_api] AUTH_FAILED - status={response.status_code}"
)
raise YouTubeAuthError(f"YouTube 인증 실패: {response.status_code}")
# 할당량 초과 체크
if response.status_code == 429:
logger.warning("[YouTubeAnalyticsService._call_api] QUOTA_EXCEEDED")
raise YouTubeQuotaExceededError()
# HTTP 에러 체크
response.raise_for_status()
# 인증 실패 체크
if response.status_code in (401, 403):
logger.warning(
f"[YouTubeAnalyticsService._call_api] AUTH_FAILED - status={response.status_code}"
)
raise YouTubeAuthError(f"YouTube 인증 실패: {response.status_code}")
return response.json()
# HTTP 에러 체크
response.raise_for_status()
return response.json()
except (YouTubeAuthError, YouTubeQuotaExceededError):
raise # 이미 처리된 예외는 재시도 없이 그대로 전파
except httpx.HTTPStatusError as e:
logger.error(
f"[YouTubeAnalyticsService._call_api] HTTP_ERROR - "
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}")
last_error = YouTubeAPIError(f"HTTP {e.response.status_code}")
except httpx.RequestError as e:
logger.error(f"[YouTubeAnalyticsService._call_api] REQUEST_ERROR - {e}")
last_error = YouTubeAPIError(f"네트워크 오류: {e}")
except Exception as e:
logger.error(f"[YouTubeAnalyticsService._call_api] UNEXPECTED_ERROR - {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
except (YouTubeAuthError, YouTubeQuotaExceededError):
raise # 이미 처리된 예외는 그대로 전파
except httpx.HTTPStatusError as e:
logger.error(
f"[YouTubeAnalyticsService._call_api] HTTP_ERROR - "
f"status={e.response.status_code}, body={e.response.text[:500]}"
)
raise YouTubeAPIError(f"HTTP {e.response.status_code}")
except httpx.RequestError as e:
logger.error(f"[YouTubeAnalyticsService._call_api] REQUEST_ERROR - {e}")
raise YouTubeAPIError(f"네트워크 오류: {e}")
except Exception as e:
logger.error(f"[YouTubeAnalyticsService._call_api] UNEXPECTED_ERROR - {e}")
raise YouTubeAPIError(f"알 수 없는 오류: {e}")

View File

@ -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)

View File

@ -1,13 +1,9 @@
import time
import traceback
from typing import AsyncGenerator
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
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 config import db_settings
@ -28,10 +24,12 @@ engine = create_async_engine(
max_overflow=20, # 추가 연결: 20 (총 최대 40)
pool_timeout=30, # 풀에서 연결 대기 시간 (초)
pool_recycle=280, # MySQL wait_timeout(기본 28800s, 클라우드는 보통 300s) 보다 짧게 설정
pool_use_lifo=True,
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
pool_reset_on_return="rollback", # 반환 시 롤백으로 초기화
connect_args={
"connect_timeout": 10, # DB 연결 타임아웃
"read_timeout": 30,
"charset": "utf8mb4",
},
)
@ -55,10 +53,12 @@ background_engine = create_async_engine(
max_overflow=10, # 추가 연결: 10 (총 최대 20)
pool_timeout=60, # 백그라운드는 대기 시간 여유있게
pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정
pool_use_lifo=True,
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
pool_reset_on_return="rollback",
connect_args={
"connect_timeout": 10,
"read_timeout": 30,
"charset": "utf8mb4",
},
)
@ -77,18 +77,15 @@ async def create_db_tables():
# 모델 import (테이블 메타데이터 등록용)
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.song.models import Song, SongTimestamp # noqa: F401
from app.video.models import Video, VideoReaction # noqa: F401
from app.comment.models import Comment # noqa: F401
from app.video.models import Video # noqa: F401
from app.sns.models import SNSUploadTask # noqa: F401
from app.social.models import SocialUpload # 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 = [
User.__table__,
RefreshToken.__table__,
@ -99,16 +96,10 @@ async def create_db_tables():
Song.__table__,
SongTimestamp.__table__,
Video.__table__,
VideoReaction.__table__,
Comment.__table__,
SNSUploadTask.__table__,
SocialUpload.__table__,
MarketingIntel.__table__,
Dashboard.__table__,
ImageTag.__table__,
Admin.__table__,
CreditChargeRequest.__table__,
CreditTransaction.__table__,
]
logger.info("Creating database tables...")
@ -140,33 +131,24 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
# )
try:
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:
from fastapi import HTTPException
await session.rollback()
# status_code < 500인 도메인 예외(계정 미연동 등)는 정상적인 비즈니스 흐름이므로 ERROR로 남기지 않음
if getattr(e, "status_code", 500) < 500:
duration = (time.perf_counter() - start_time) * 1000
# 클라이언트 에러(4xx)는 WARNING, 서버 에러(5xx)는 ERROR로 구분
if isinstance(e, HTTPException) and e.status_code < 500:
logger.warning(
f"[get_session] ROLLBACK - client error: {type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
f"[get_session] ROLLBACK ({e.status_code}) - "
f"error: {type(e).__name__}: {e}, duration: {duration:.1f}ms"
)
else:
import traceback
logger.error(traceback.format_exc())
logger.error(
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:
total_time = time.perf_counter() - start_time
# logger.debug(
@ -194,8 +176,6 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
# )
try:
yield session
except HTTPException:
raise
except Exception as e:
await session.rollback()
logger.error(
@ -203,8 +183,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
f"error: {type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
)
logger.debug(traceback.format_exc())
raise
raise e
finally:
total_time = time.perf_counter() - start_time
# logger.debug(

File diff suppressed because it is too large Load Diff

View File

@ -9,8 +9,7 @@ Home 모듈 SQLAlchemy 모델 정의
from datetime import datetime
from typing import TYPE_CHECKING, List, Optional, Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Computed, Index, Integer, String, Text, JSON, func
from sqlalchemy.dialects.mysql import INTEGER
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, JSON, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
@ -114,13 +113,6 @@ class Project(Base):
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(
String(50),
nullable=False,
@ -274,7 +266,6 @@ class MarketingIntel(Base):
Attributes:
id: 고유 식별자 (자동 증가)
place_id : 데이터 소스별 식별자
official_site_url : 업체 공식 링크 (플레이스 홈페이지 항목, 없으면 크롤링 소스 URL)
intel_result : 마케팅 분석 결과물 json
created_at: 생성 일시 (자동 설정)
"""
@ -297,16 +288,10 @@ class MarketingIntel(Base):
comment="고유 식별자",
)
place_id: Mapped[Optional[str]] = mapped_column(
place_id: Mapped[str] = mapped_column(
String(36),
nullable=True,
comment="매장 소스별 고유 식별자 (네이버 크롤링 시 'nv{id}' 형식; 직접 입력 시 NULL)",
)
official_site_url: Mapped[Optional[str]] = mapped_column(
String(2048),
nullable=True,
comment="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 시 NULL)",
nullable=False,
comment="매장 소스별 고유 식별자",
)
intel_result : Mapped[dict[str, Any]] = mapped_column(
@ -315,18 +300,6 @@ class MarketingIntel(Base):
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(
DateTime,
nullable=False,
@ -335,52 +308,13 @@ class MarketingIntel(Base):
)
def __repr__(self) -> str:
return (
f"<MarketingIntel(id={self.id}, place_id='{self.place_id}')>"
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
)
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",
)
return (
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
)

View File

@ -1,6 +1,6 @@
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
class CrawlingRequest(BaseModel):
@ -78,7 +78,6 @@ class ProcessedInfo(BaseModel):
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
region: 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):
@ -98,12 +97,6 @@ class ProcessedInfo(BaseModel):
# 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):
"""크롤링 응답 스키마"""
@ -111,7 +104,7 @@ class CrawlingResponse(BaseModel):
json_schema_extra={
"example": {
"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,
"processed_info": {
"customer_name": "스테이 머뭄",
@ -240,8 +233,8 @@ class CrawlingResponse(BaseModel):
default="completed",
description="처리 상태 (completed: 성공, failed: ChatGPT 분석 실패)"
)
image_list: Optional[list[ImageListItem]] = Field(None, description="이미지 목록 (preview/original URL)")
image_count: Optional[int] = Field(None, description="이미지 개수")
image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
image_count: int = Field(..., description="이미지 개수")
processed_info: Optional[ProcessedInfo] = Field(
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
)
@ -249,43 +242,6 @@ class CrawlingResponse(BaseModel):
None, description="마케팅 분석 결과 . 실패 시 null"
)
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):

View File

@ -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,
)

View File

@ -32,7 +32,7 @@ class NaverSearchClient:
display: int = 5,
) -> List[dict]:
"""
장소 검색 (숙박, 음식점 등)
숙박/펜션 검색
Args:
query: 검색어
@ -41,7 +41,8 @@ class NaverSearchClient:
Returns:
검색 결과 리스트 (address, roadAddress, title)
"""
search_query = query
# 숙박/펜션 카테고리 검색을 위해 쿼리에 키워드 추가
search_query = f"{query} 숙박"
headers = {
"X-Naver-Client-Id": self.client_id,

View File

@ -40,11 +40,9 @@ from app.lyric.schemas.lyric import (
LyricDetailResponse,
LyricListItem,
LyricStatusResponse,
SubtitleStatusResponse,
)
from app.lyric.worker.lyric_task import generate_lyric_background
from app.video.worker.creative_assets_task import generate_subtitle_background
from app.utils.prompts.chatgpt_prompt import ChatgptService
from app.utils.chatgpt_prompt import ChatgptService
from app.utils.logger import get_logger
from app.utils.pagination import PaginatedResponse, get_paginated
@ -161,7 +159,6 @@ async def get_lyric_by_task_id(
project_id=lyric.project_id,
status=lyric.status,
lyric_result=lyric.lyric_result,
genre=lyric.genre,
created_at=lyric.created_at,
)
@ -242,22 +239,7 @@ async def generate_lyric(
request_start = time.perf_counter()
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(
@ -271,36 +253,46 @@ async def generate_lyric(
step1_start = time.perf_counter()
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 = 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 = {
"customer_name" : request_body.customer_name,
"region" : request_body.region,
"detail_region_info" : request_body.detail_region_info or "",
"marketing_intelligence_summary" : json.dumps(marketing_intel.intel_result, ensure_ascii = False),
"language" : request_body.language,
"genre": request_body.genre or "", # 비어있으면 GPT가 가사 무드에 맞춰 장르를 직접 추천
"exclude_genre": exclude_genre, # 자동 선택 재생성 시 직전 장르 제외
"industry": request_body.industry, # 크롤 응답에서 받아 전달된 업종 enum
"promotional_expression_example" : promotional_expressions[request_body.language],
"timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
}
# 업종 분기는 프롬프트 내부 {industry}로 처리하므로 단일 프롬프트 사용
selected_lyric_prompt = lyric_prompt
step1_elapsed = (time.perf_counter() - step1_start) * 1000
#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,
language=request_body.language,
user_uuid=current_user.user_uuid,
marketing_intelligence=request_body.m_id,
industry=request_body.industry,
marketing_intelligence = request_body.m_id
)
session.add(project)
await session.commit()
@ -341,7 +332,7 @@ async def generate_lyric(
step3_start = time.perf_counter()
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(
project_id=project.id,
task_id=task_id,
@ -349,7 +340,6 @@ async def generate_lyric(
lyric_prompt=estimated_prompt,
lyric_result=None,
language=request_body.language,
genre=request_body.genre or None, # 자동 선택인 경우 GPT 응답 완료 후 채워짐
)
session.add(lyric)
await session.commit()
@ -361,27 +351,13 @@ async def generate_lyric(
# ========== Step 4: 백그라운드 태스크 스케줄링 ==========
step4_start = time.perf_counter()
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(
generate_lyric_background,
task_id=task_id,
prompt=selected_lyric_prompt,
lyric_input_data=lyric_input_data,
lyric_id=lyric.id,
)
background_tasks.add_task(
generate_subtitle_background,
orientation=orientation,
generate_lyric_background,
task_id=task_id,
prompt=lyric_prompt,
lyric_input_data=lyric_input_data,
lyric_id=lyric.id,
)
step4_elapsed = (time.perf_counter() - step4_start) * 1000
@ -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(
"/{task_id}",
summary="가사 상세 조회",

View File

@ -93,13 +93,6 @@ class Lyric(Base):
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(
Boolean,
nullable=False,

View File

@ -23,7 +23,7 @@ Lyric API Schemas
"""
from datetime import datetime
from typing import Optional, Literal
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
@ -42,8 +42,7 @@ class GenerateLyricRequest(BaseModel):
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean",
"m_id" : 2,
"orientation" : "vertical"
"m_id" : 1
}
"""
@ -55,8 +54,7 @@ class GenerateLyricRequest(BaseModel):
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean",
"m_id" : 1,
"orientation" : "vertical"
"m_id" : 1
}
}
)
@ -71,18 +69,7 @@ class GenerateLyricRequest(BaseModel):
default="Korean",
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 값")
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):
@ -205,62 +192,9 @@ class LyricDetailResponse(BaseModel):
project_id: int = Field(..., description="프로젝트 ID")
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
lyric_result: Optional[str] = Field(None, description="생성된 가사 또는 에러 메시지 (실패 시)")
genre: Optional[str] = Field(
None,
description="확정된 음악 장르. 수동 선택 시 요청값, 자동 선택 시 GPT 추천값 (완료 전에는 None)",
)
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):
"""가사 목록 아이템 스키마

View File

@ -4,24 +4,26 @@ Lyric Background Tasks
가사 생성 관련 백그라운드 태스크를 정의합니다.
"""
import traceback
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.database.session import BackgroundSessionLocal
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.logger import get_logger
# 로거 설정
logger = get_logger("lyric")
async def _update_lyric_status(
task_id: str,
status: str,
result: str | None = None,
lyric_id: int | None = None,
genre: str | None = None,
) -> bool:
"""Lyric 테이블의 상태를 업데이트합니다.
@ -30,7 +32,6 @@ async def _update_lyric_status(
status: 변경할 상태 ("processing", "completed", "failed")
result: 가사 결과 또는 에러 메시지
lyric_id: 특정 Lyric 레코드 ID (재생성 시 정확한 레코드 식별용)
genre: 확정된 음악 장르 (자동 선택이었던 경우 GPT 추천값으로 갱신)
Returns:
bool: 업데이트 성공 여부
@ -56,8 +57,6 @@ async def _update_lyric_status(
lyric.status = status
if result is not None:
lyric.lyric_result = result
if genre is not None:
lyric.genre = genre
await session.commit()
logger.info(f"[Lyric] Status updated - task_id: {task_id}, lyric_id: {lyric_id}, status: {status}")
return True
@ -101,6 +100,13 @@ async def generate_lyric_background(
step1_start = time.perf_counter()
logger.debug(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
# service = ChatgptService(
# customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
# region="",
# detail_region_info="",
# language=language,
# )
chatgpt = ChatgptService()
step1_elapsed = (time.perf_counter() - step1_start) * 1000
@ -114,18 +120,14 @@ async def generate_lyric_background(
#result = await service.generate(prompt=prompt)
result_response = await chatgpt.generate_structured_output(prompt, lyric_input_data)
result = result_response.lyric
confirmed_genre = result_response.recommended_genre
step2_elapsed = (time.perf_counter() - step2_start) * 1000
logger.info(
f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자, "
f"genre: {confirmed_genre} ({step2_elapsed:.1f}ms)"
)
logger.info(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
# ========== Step 3: DB 상태 업데이트 ==========
step3_start = time.perf_counter()
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
logger.debug(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)")

View 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 계정 연동이 해제되었습니다."}

View File

@ -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

View File

@ -52,6 +52,7 @@ class SNSUploadTask(Base):
Index("idx_sns_upload_task_user_uuid", "user_uuid"),
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_platform", "platform"),
Index("idx_sns_upload_task_status", "status"),
Index("idx_sns_upload_task_is_scheduled", "is_scheduled"),
Index("idx_sns_upload_task_scheduled_at", "scheduled_at"),
@ -116,6 +117,12 @@ class SNSUploadTask(Base):
comment="소셜 계정 외래키 (SocialAccount.id 참조)",
)
platform: Mapped[Optional[str]] = mapped_column(
String(20),
nullable=True,
comment="업로드 대상 플랫폼 (instagram, facebook 등)",
)
# ==========================================================================
# 업로드 콘텐츠
# ==========================================================================
@ -131,6 +138,18 @@ class SNSUploadTask(Base):
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)",
)
# ==========================================================================
# 발행 상태
# ==========================================================================

View 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 페이지 목록"
)

View 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()

View File

@ -33,5 +33,5 @@ async def youtube_seo_description(
session: AsyncSession = Depends(get_session),
) -> YoutubeDescriptionResponse:
return await seo_service.get_youtube_seo_description(
request_body.video_id, current_user, session
request_body.task_id, current_user, session
)

View File

@ -95,6 +95,8 @@ YOUTUBE_SCOPES = [
"https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필
]
YOUTUBE_SEO_HASH = "SEO_Describtion_YT"
# =============================================================================
# Instagram/Facebook OAuth Scopes (추후 구현)
# =============================================================================

View File

@ -59,7 +59,7 @@ class YouTubeOAuthClient(BaseOAuthClient):
"response_type": "code",
"scope": " ".join(YOUTUBE_SCOPES),
"access_type": "offline", # refresh_token 받기 위해 필요
"prompt": "consent", # 항상 동의 화면 표시하여 refresh_token 발급 보장
"prompt": "select_account", # 계정 선택만 표시 (동의 화면은 최초 1회만)
"state": state,
}
url = f"{self.AUTHORIZATION_URL}?{urlencode(params)}"

View File

@ -8,12 +8,12 @@ from pydantic import BaseModel, ConfigDict, Field
class YoutubeDescriptionRequest(BaseModel):
"""유튜브 SEO Description 제안 요청"""
video_id: int = Field(..., description="영상 고유 ID")
task_id: str = Field(..., description="작업 고유 식별자")
model_config = ConfigDict(
json_schema_extra={
"example": {
"video_id": 123
"task_id": "019c739f-65fc-7d15-8c88-b31be00e588e"
}
}
)

View File

@ -56,8 +56,6 @@ class SocialUploadResponse(BaseModel):
platform: str = Field(..., description="플랫폼명")
status: str = Field(..., description="업로드 상태")
message: str = Field(..., description="응답 메시지")
scheduled_at: Optional[datetime] = Field(None, description="예약 시간 (예약 업로드 충돌 시 반환)")
model_config = ConfigDict(
json_schema_extra={
@ -67,7 +65,6 @@ class SocialUploadResponse(BaseModel):
"platform": "youtube",
"status": "pending",
"message": "업로드 요청이 접수되었습니다.",
"scheduled_at": "2026-02-02T15:00:00",
}
}
)
@ -125,7 +122,6 @@ class SocialUploadHistoryItem(BaseModel):
platform: str = Field(..., description="플랫폼명")
status: str = Field(..., description="업로드 상태")
title: str = Field(..., description="영상 제목")
platform_username: Optional[str] = Field(None, description="플랫폼 채널명")
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
error_message: Optional[str] = Field(None, description="에러 메시지")
scheduled_at: Optional[datetime] = Field(None, description="예약 게시 시간")

View File

@ -306,7 +306,7 @@ class SocialAccountService:
else:
# DB datetime은 naive, now()는 aware이므로 naive로 통일하여 비교
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:
should_refresh = True

View File

@ -1,132 +1,88 @@
"""
유튜브 SEO 서비스
영상 제목/설명/해시태그를 생성하고 video 테이블에 저장합니다.
SEO description 생성 및 Redis 캐싱 로직을 처리합니다.
"""
import json
import logging
from fastapi import HTTPException, status
from fastapi import HTTPException
from redis.asyncio import Redis
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import db_settings
from app.home.models import MarketingIntel, Project
from app.social.constants import YOUTUBE_SEO_HASH
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.video.models import Video
from app.utils.chatgpt_prompt import ChatgptService
from app.utils.prompts.prompts import yt_upload_prompt
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:
"""유튜브 SEO 비즈니스 로직 서비스"""
async def get_youtube_seo_description(
self,
video_id: int,
task_id: str,
current_user: User,
session: AsyncSession,
) -> YoutubeDescriptionResponse:
"""
저장된 SNS 메타데이터를 반환하거나, 없으면 생성 후 video에 저장합니다.
유튜브 SEO description 생성
Redis 캐시 확인 후 miss이면 GPT로 생성하고 캐싱.
"""
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)
if video is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"video_id '{video_id}'에 해당하는 영상을 찾을 수 없습니다.",
)
cached = await self._get_from_redis(task_id)
if cached:
return cached
if has_stored_sns_metadata(video):
return self._response_from_video(video)
logger.info(f"[SEO_SERVICE] Cache miss - user: {current_user.user_uuid}")
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
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(
self,
task_id: str,
current_user: User,
session: AsyncSession,
) -> YoutubeDescriptionResponse:
"""GPT를 사용하여 SEO description 생성"""
from app.utils.prompts.chatgpt_prompt import ChatgptService
from app.utils.prompts.prompts import yt_upload_prompt
logger.info(f"[SEO_SERVICE] Generating SEO - task_id: {task_id}")
logger.info(f"[SEO_SERVICE] Generating SEO - user: {current_user.user_uuid}")
try:
project_result = await session.execute(
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())
.limit(1)
)
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(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
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"]
@ -138,13 +94,10 @@ class SeoService:
),
"language": project.language,
"target_keywords": hashtags,
"industry": project.industry or "",
}
chatgpt = ChatgptService(timeout=180)
yt_seo_output = await chatgpt.generate_structured_output(
yt_upload_prompt, yt_seo_input_data
)
yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data)
return YoutubeDescriptionResponse(
title=yt_seo_output.title,
@ -152,8 +105,6 @@ class SeoService:
keywords=hashtags,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[SEO_SERVICE] EXCEPTION - error: {e}")
raise HTTPException(
@ -161,12 +112,18 @@ class SeoService:
detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}",
)
def _response_from_video(self, video: Video) -> YoutubeDescriptionResponse:
return YoutubeDescriptionResponse(
title=video.title or "",
description=video.description or "",
keywords=list(video.hashtags or []),
)
async def _get_from_redis(self, task_id: str) -> YoutubeDescriptionResponse | None:
field = f"task_id:{task_id}"
yt_seo_info = await redis_seo_client.hget(YOUTUBE_SEO_HASH, field)
if yt_seo_info:
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()

View File

@ -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 [])

View File

@ -10,7 +10,7 @@ from datetime import datetime
from typing import Optional
from fastapi import BackgroundTasks, HTTPException, status
from sqlalchemy import func, or_, select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from config import TIMEZONE
@ -26,7 +26,6 @@ from app.social.schemas import (
SocialUploadRequest,
)
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.user.models import User
from app.video.models import Video
@ -77,12 +76,6 @@ class SocialUploadService:
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. 소셜 계정 조회 및 소유권 검증
account = await self._account_service.get_account_by_id(
user_uuid=current_user.user_uuid,
@ -97,19 +90,12 @@ class SocialUploadService:
)
raise SocialAccountNotFoundError()
# 3. 중복 업로드 확인
now_kst_naive = datetime.now(TIMEZONE).replace(tzinfo=None)
# 3-1. 진행 중인 업로드 확인 (즉시 pending 또는 uploading)
# 3. 진행 중인 업로드 확인 (pending 또는 uploading 상태만)
in_progress_result = await session.execute(
select(SocialUpload).where(
SocialUpload.video_id == body.video_id,
SocialUpload.social_account_id == account.id,
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()
@ -126,32 +112,6 @@ class SocialUploadService:
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. 업로드 순번 계산
max_seq_result = await session.execute(
select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where(
@ -193,6 +153,7 @@ class SocialUploadService:
)
# 6. 즉시 업로드이면 백그라운드 태스크 등록
now_kst_naive = datetime.now(TIMEZONE).replace(tzinfo=None)
is_scheduled = body.scheduled_at and body.scheduled_at > now_kst_naive
if not is_scheduled:
background_tasks.add_task(process_social_upload, social_upload.id)
@ -330,7 +291,6 @@ class SocialUploadService:
platform=upload.platform,
status=upload.status,
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,
error_message=upload.error_message,
scheduled_at=upload.scheduled_at,

View File

@ -144,7 +144,7 @@ class YouTubeUploader(BaseSocialUploader):
body = {
"snippet": {
"title": metadata.title,
"description": self._sanitize_description(metadata.description),
"description": metadata.description or "",
"tags": metadata.tags or [],
"categoryId": self._get_category_id(metadata),
},
@ -380,12 +380,6 @@ class YouTubeUploader(BaseSocialUploader):
)
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:
"""
PrivacyStatus를 YouTube API 형식으로 변환

View File

@ -35,33 +35,6 @@ logger = get_logger("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(
"/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 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다.
""",
@ -130,22 +103,6 @@ async def generate_song(
from app.database.session import AsyncSessionLocal
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(
f"[generate_song] START - task_id: {task_id}, "
f"genre: {request_body.genre}, language: {request_body.language}"
@ -212,10 +169,9 @@ async def generate_song(
)
# Song 테이블에 초기 데이터 저장
if request_body.instrumental:
song_prompt = f"[Instrumental]\n[Genre]\n{request_body.genre}"
else:
song_prompt = f"[Lyrics]\n{request_body.lyrics}\n\n[Genre]\n{request_body.genre}"
song_prompt = (
f"[Lyrics]\n{request_body.lyrics}\n\n[Genre]\n{request_body.genre}"
)
logger.debug(
f"[generate_song] Lyrics comparison - task_id: {task_id}\n"
f"{'=' * 60}\n"
@ -277,7 +233,6 @@ async def generate_song(
suno_task_id = await suno_service.generate(
prompt=request_body.lyrics,
genre=request_body.genre,
instrumental=request_body.instrumental,
)
stage2_time = time.perf_counter()
@ -440,14 +395,12 @@ async def get_song_status(
clips_data = response_data.get("sunoData") or []
if clips_data:
# 생성된 클립(보통 2개) 중 목표 길이(1분)에 가장 가까운 클립 선택 (동일하면 첫 번째)
first_clip = _select_clip_by_duration(clips_data)
# 첫 번째 클립(clips[0])의 audioUrl과 duration 사용
first_clip = clips_data[0]
audio_url = first_clip.get("audioUrl")
clip_duration = first_clip.get("duration")
logger.debug(
f"[get_song_status] Selected clip by duration - id: {first_clip.get('id')}, "
f"audio_url: {audio_url}, duration: {clip_duration}, "
f"candidates: {[(c.get('id'), c.get('duration')) for c in clips_data]}"
f"[get_song_status] Using first clip - id: {first_clip.get('id')}, audio_url: {audio_url}, duration: {clip_duration}"
)
if audio_url:
@ -483,8 +436,14 @@ async def get_song_status(
)
suno_audio_id = first_clip.get("id")
# BGM 모드(lyric_result가 비어 있음)에서는 타임스탬프 생성 스킵
word_data = await suno_service.get_lyric_timestamp(
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(
select(Lyric)
.where(Lyric.task_id == song.task_id)
@ -492,74 +451,51 @@ async def get_song_status(
.limit(1)
)
lyric = lyric_result.scalar_one_or_none()
gt_lyric = lyric.lyric_result if lyric else None
gt_lyric = lyric.lyric_result
lyric_line_list = gt_lyric.split("\n")
sentences = [
lyric_line.strip(",. ")
for lyric_line in lyric_line_list
if lyric_line and lyric_line != "---"
]
logger.debug(
f"[get_song_status] sentences from lyric - "
f"sentences: {sentences}"
)
if gt_lyric:
word_data = await suno_service.get_lyric_timestamp(
suno_task_id, suno_audio_id
)
timestamped_lyrics = suno_service.align_lyrics(
word_data, sentences
)
logger.debug(
f"[get_song_status] sentences from lyric - "
f"sentences: {sentences}"
)
# 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")
sentences = [
lyric_line.strip(",. ")
for lyric_line in lyric_line_list
if lyric_line and lyric_line != "---"
]
logger.debug(
f"[get_song_status] sentences from lyric - "
f"sentences: {sentences}"
)
timestamped_lyrics = suno_service.align_lyrics(
word_data, sentences
)
for order_idx, timestamped_lyric in enumerate(
timestamped_lyrics
# TODO : DB upload timestamped_lyrics
for order_idx, timestamped_lyric in enumerate(
timestamped_lyrics
):
# start_sec 또는 end_sec가 None인 경우 건너뛰기
if (
timestamped_lyric["start_sec"] is None
or timestamped_lyric["end_sec"] is None
):
if (
timestamped_lyric["start_sec"] is None
or timestamped_lyric["end_sec"] is None
):
logger.warning(
f"[get_song_status] Skipping timestamp - "
f"lyric_line: {timestamped_lyric['text']}, "
f"start_sec: {timestamped_lyric['start_sec']}, "
f"end_sec: {timestamped_lyric['end_sec']}"
)
continue
song_timestamp = SongTimestamp(
suno_audio_id=suno_audio_id,
order_idx=order_idx,
lyric_line=timestamped_lyric["text"],
start_time=timestamped_lyric["start_sec"],
end_time=timestamped_lyric["end_sec"],
logger.warning(
f"[get_song_status] Skipping timestamp - "
f"lyric_line: {timestamped_lyric['text']}, "
f"start_sec: {timestamped_lyric['start_sec']}, "
f"end_sec: {timestamped_lyric['end_sec']}"
)
session.add(song_timestamp)
else:
logger.info(
f"[get_song_status] BGM 모드 - 타임스탬프 생성 스킵, "
f"suno_task_id: {suno_task_id}"
continue
song_timestamp = SongTimestamp(
suno_audio_id=suno_audio_id,
order_idx=order_idx,
lyric_line=timestamped_lyric["text"],
start_time=timestamped_lyric["start_sec"],
end_time=timestamped_lyric["end_sec"],
)
session.add(song_timestamp)
await session.commit()
parsed_response.status = "processing"

View File

@ -1,6 +1,6 @@
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(
...,
description="음악 장르 (K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz 등)",
@ -42,15 +42,6 @@ class GenerateSongRequest(BaseModel):
default="Korean",
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):

View File

@ -7,14 +7,14 @@ from sqlalchemy import Connection, text
from sqlalchemy.exc import SQLAlchemyError
from app.utils.logger import get_logger
from app.lyric.schemas.lyrics_schema import (
from app.lyrics.schemas.lyrics_schema import (
AttributeData,
PromptTemplateData,
SongFormData,
SongSampleData,
StoreData,
)
from app.utils.prompts.chatgpt_prompt import chatgpt_api
from app.utils.chatgpt_prompt import chatgpt_api
logger = get_logger("song")

View File

@ -23,7 +23,6 @@ logger = logging.getLogger(__name__)
from app.user.dependencies import get_current_user
from app.user.models import RefreshToken, User
from app.user.schemas.user_schema import (
CreditResponse,
KakaoCodeRequest,
KakaoLoginResponse,
LoginResponse,
@ -158,12 +157,10 @@ async def kakao_callback(
logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}")
# 프론트엔드로 토큰과 함께 리다이렉트
# is_new_user: 프론트가 신규 가입 시에만 Meta CompleteRegistration 전환 추적을 호출하도록 전달
redirect_url = (
f"{prj_settings.PROJECT_DOMAIN}"
f"?access_token={result.access_token}"
f"&refresh_token={result.refresh_token}"
f"&is_new_user={str(result.is_new_user).lower()}"
)
logger.info(
f"[ROUTER] 카카오 콜백 완료, 프론트엔드로 리다이렉트 - redirect_url: {redirect_url[:50]}..."
@ -356,22 +353,6 @@ async def get_me(
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에서 라우터가 등록됨)
# =============================================================================

View File

@ -1,35 +1,20 @@
import logging
from sqladmin import ModelView
from sqladmin import ModelView, action
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__)
from app.user.models import RefreshToken, SocialAccount, User
class UserAdmin(SuperAdminEditable, ModelView, model=User):
class UserAdmin(ModelView, model=User):
name = "사용자"
name_plural = "사용자 목록"
icon = "fa-solid fa-user"
category = "사용자 관리"
page_size = 30
can_edit = True
can_delete = True
page_size = 20
column_list = [
"id",
"user_uuid",
"kakao_id",
"email",
"nickname",
"credits",
"role",
"is_active",
"is_deleted",
@ -38,7 +23,7 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
column_details_list = [
"id",
"user_uuid",
"kakao_id",
"email",
"nickname",
"profile_image_url",
@ -47,7 +32,6 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
"name",
"birth_date",
"gender",
"credits",
"is_active",
"is_admin",
"role",
@ -58,22 +42,16 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
"updated_at",
]
form_columns = [
"nickname",
"email",
"phone",
"name",
"birth_date",
"gender",
"credits",
"is_active",
"is_admin",
"role",
"is_deleted",
form_excluded_columns = [
"created_at",
"updated_at",
"projects",
"refresh_tokens",
"social_accounts",
]
column_searchable_list = [
User.user_uuid,
User.kakao_id,
User.email,
User.nickname,
User.phone,
@ -84,10 +62,9 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
column_sortable_list = [
User.id,
User.user_uuid,
User.kakao_id,
User.email,
User.nickname,
User.credits,
User.role,
User.is_active,
User.is_deleted,
@ -96,16 +73,15 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
column_labels = {
"id": "ID",
"user_uuid": "UUID",
"kakao_id": "카카오 ID",
"email": "이메일",
"nickname": "닉네임",
"profile_image_url": "프로필 이미지",
"thumbnail_image_url": "썸네일 이미지",
"phone": "전화번호",
"name": "이름",
"name": "실명",
"birth_date": "생년월일",
"gender": "성별",
"credits": "크레딧",
"is_active": "활성화",
"is_admin": "관리자",
"role": "권한",
@ -116,71 +92,71 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
"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(
name="02_unblock_user",
label="차단 해제",
confirmation_message="선택한 사용자의 차단을 해제하시겠습니까?",
add_in_list=True,
)
async def seq_e_unblock_user_action(self, request: Request) -> RedirectResponse:
return await handle_block_users(request, self.identity, block=False)
class RefreshTokenAdmin(ModelView, model=RefreshToken):
name = "리프레시 토큰"
name_plural = "리프레시 토큰 목록"
icon = "fa-solid fa-key"
category = "사용자 관리"
page_size = 20
@action(
name="03_grant_credits_1",
label="크레딧 +1",
confirmation_message="선택한 사용자에게 크레딧 1개를 충전하시겠습니까?",
add_in_list=True,
)
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)
column_list = [
"id",
"user_id",
"is_revoked",
"expires_at",
"created_at",
]
@action(
name="04_grant_credits_5",
label="크레딧 +5",
confirmation_message="선택한 사용자에게 크레딧 5개를 충전하시겠습니까?",
add_in_list=True,
)
async def seq_c_grant_credits_5_action(self, request: Request) -> RedirectResponse:
admin_id = request.session.get("admin_id")
return await handle_grant_credits(request, self.identity, amount=5, admin_id=admin_id)
column_details_list = [
"id",
"user_id",
"token_hash",
"expires_at",
"is_revoked",
"created_at",
"revoked_at",
"user_agent",
"ip_address",
]
@action(
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)
form_excluded_columns = ["created_at", "user"]
@action(
name="06_deduct_credits_1",
label="크레딧 -1",
confirmation_message="선택한 사용자의 크레딧 1개를 차감하시겠습니까?",
add_in_list=True,
)
async def seq_a_deduct_credits_1_action(self, request: Request) -> RedirectResponse:
admin_id = request.session.get("admin_id")
return await handle_deduct_credits(request, self.identity, amount=1, admin_id=admin_id)
column_searchable_list = [
RefreshToken.user_id,
RefreshToken.token_hash,
RefreshToken.ip_address,
]
column_default_sort = (RefreshToken.created_at, True)
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_plural = "소셜 계정 목록"
icon = "fa-solid fa-share-nodes"
category = "사용자 관리"
page_size = 30
page_size = 20
column_list = [
"id",
@ -198,6 +174,8 @@ class SocialAccountAdmin(ViewerAccessible, ModelView, model=SocialAccount):
"platform",
"platform_user_id",
"platform_username",
"platform_data",
"scope",
"token_expires_at",
"is_active",
"is_deleted",

View File

@ -18,11 +18,10 @@ from app.user.services.auth import (
AdminRequiredError,
InvalidTokenError,
MissingTokenError,
TokenExpiredError,
UserInactiveError,
UserNotFoundError,
)
from app.user.services.jwt import decode_token, is_token_expired
from app.user.services.jwt import decode_token
logger = logging.getLogger(__name__)
@ -59,9 +58,6 @@ async def get_current_user(
payload = decode_token(token)
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:]}")
raise InvalidTokenError()

View File

@ -16,10 +16,7 @@ from app.database.session import Base
if TYPE_CHECKING:
from app.comment.models import Comment
from app.credit.models import CreditChargeRequest, CreditTransaction
from app.home.models import Project
from app.video.models import VideoReaction
class User(Base):
@ -219,59 +216,6 @@ class User(Base):
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(
DateTime,
nullable=False,
@ -324,38 +268,6 @@ class User(Base):
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:
return (
f"<User("

View File

@ -160,22 +160,6 @@ class LoginResponse(BaseModel):
}
}
# =============================================================================
# 크레딧 스키마
# =============================================================================
class CreditResponse(BaseModel):
"""잔여 크레딧 응답"""
credits: int = Field(..., description="영상 생성 크레딧")
model_config = {
"json_schema_extra": {
"example": {
"credits": 3
}
}
}
# =============================================================================
# 내부 사용 스키마 (카카오 API 응답 파싱)

View File

@ -92,7 +92,6 @@ from app.user.services.jwt import (
get_access_token_expire_seconds,
get_refresh_token_expires_at,
get_token_hash,
is_token_expired,
)
from app.user.services.kakao import kakao_client
@ -213,9 +212,6 @@ class AuthService:
# 1. 토큰 디코딩 및 검증
payload = decode_token(refresh_token)
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:]}")
raise InvalidTokenError()

View File

@ -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

View File

@ -116,28 +116,6 @@ def decode_token(token: str) -> Optional[dict]:
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:
"""
토큰의 SHA-256 해시값 생성

View File

@ -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 ""

View File

@ -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

View File

@ -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)]

View 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

View File

@ -19,16 +19,12 @@ Note:
import os
import time
import re
from typing import Any, Optional, Type
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
def normalize_location(name: str) -> str:
return re.sub(r'(특별시|광역시|특별자치시|특별자치도|시|군|구|도)$', '', name)
def _generate_uuid7_string() -> str:
"""UUID7 문자열을 생성합니다.

File diff suppressed because it is too large Load Diff

376
app/utils/facebook_oauth.py Normal file
View 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

View File

@ -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]

View File

@ -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

View File

@ -1,8 +1,4 @@
import asyncio
import random
import re
from html import unescape
from difflib import SequenceMatcher
from playwright.async_api import async_playwright
from urllib import parse
import time
@ -19,82 +15,29 @@ class NvMapPwScraper():
_context = None
_win_width = 1280
_win_height = 720
_max_retry = 3
_timeout = 30 # 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)
);
}
'''
_max_retry = 3
_timeout = 60 # place id timeout threshold seconds
# instance var
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
def default_context_builder(cls):
cls._current_profile = cls._pick_profile()
profile = cls._current_profile
context_builder_dict = {}
context_builder_dict['viewport'] = dict(profile['viewport'])
context_builder_dict['screen'] = dict(profile['viewport'])
context_builder_dict['user_agent'] = profile['user_agent']
context_builder_dict['viewport'] = {
'width' : cls._win_width,
'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['timezone_id']='Asia/Seoul'
return context_builder_dict
@classmethod
async def initiate_scraper(cls):
if not cls._playwright:
@ -104,147 +47,6 @@ if (originalQuery) {
if not cls._context:
cls._context = await cls._browser.new_context(**cls.default_context_builder())
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):
if not self.is_ready:
@ -258,255 +60,92 @@ if (originalQuery) {
await self.page.close()
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")
async def goto_url(self, url, wait_until="domcontentloaded", timeout=20000):
page = self.page
await page.goto(url, wait_until=wait_until, timeout=timeout)
@staticmethod
def _clean_title(text: str) -> str:
text = unescape(text) # HTML 엔티티 디코딩 (&amp; → &)
text = re.sub(r"<.*?>", "", text) # HTML 태그 제거
return text.strip()
async def get_place_id_url(self, selected):
count = 0
get_place_id_url_start = time.perf_counter()
while (count <= self._max_retry):
title = selected['title'].replace("<b>", "").replace("</b>", "")
address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "")
encoded_query = parse.quote(f"{address} {title}")
url = f"https://map.naver.com/p/search/{encoded_query}"
wait_first_start = time.perf_counter()
@staticmethod
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")
await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000)
except:
if "/place/" in self.page.url:
return self.page.url
logger.error(f"[ERROR] Can't Finish networkidle")
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])
wait_first_time = (time.perf_counter() - wait_first_start) * 1000
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}"
logger.debug(f"[DEBUG] Try {count+1} : Wait for perfect matching : {wait_first_time}ms")
# 1순위: allSearch API 응답 캡처 (iframe 렌더링 대기 불필요, 업체명 단독 검색도 지원)
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:
await self.goto_url(correct_url, wait_until="networkidle", timeout=self._timeout * 1000)
except:
if "/place/" in self.page.url:
return self.page.url
logger.error("[ERROR] Can't Finish networkidle (isCorrectAnswer)")
if "/place/" in self.page.url:
return self.page.url
return None
logger.debug(f"[DEBUG] Try {count+1} : url place id not found, retry for forced collect answer")
wait_forced_correct_start = time.perf_counter()
async def get_place_id_url(self, selected):
title = self._clean_title(selected['title'])
address = self._clean_title(selected.get('roadAddress', selected['address']))
url = self.page.url.replace("?","?isCorrectAnswer=true&")
try:
await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000)
except:
if "/place/" in self.page.url:
return self.page.url
logger.error(f"[ERROR] Can't Finish networkidle")
# 1차 시도: 원본 주소 + 업체명
logger.debug(f"[DEBUG] 1차 시도 - address: {address}")
result = await self._try_search(address, title)
if result:
return result
wait_forced_correct_time = (time.perf_counter() - wait_forced_correct_start) * 1000
logger.debug(f"[DEBUG] Try {count+1} : Wait for forced isCorrectAnswer flag : {wait_forced_correct_time}ms")
if "/place/" in self.page.url:
return self.page.url
count += 1
# 2차 시도: 정제 주소 + 업체명
refined = self._refine_address(address)
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
logger.error("[ERROR] Not found url for {selected}")
# 3차 시도: 업체명만으로 검색
await self.page.wait_for_timeout(self._retry_delay_ms)
logger.info(f"[RETRY] 업체명만으로 재시도: '{title}'")
result = await self._try_search("", title)
if result:
return result
return None # 404
# 1~3차 모두 실패 → 네이버 안티봇 차단 의심, 컨텍스트 재생성 후 원본 조건으로 1회 재시도
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
# 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")

View File

@ -16,10 +16,6 @@ class GraphQLException(Exception):
"""GraphQL 요청 실패 시 발생하는 예외"""
pass
class URLNotFoundException(Exception):
"""Place ID 발견 불가능 시 발생하는 예외"""
pass
class CrawlingTimeoutException(Exception):
"""크롤링 타임아웃 시 발생하는 예외"""
@ -35,13 +31,6 @@ class NvMapScraper:
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
REQUEST_TIMEOUT = 120 # 초
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 = """
query getAccommodation($id: String!, $deviceType: String) {
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
@ -57,37 +46,8 @@ query getAccommodation($id: String!, $deviceType: String) {
conveniences
visitorReviewsTotal
}
menus {
name
price
description
recommend
}
images { 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
}
}
}
cpImages(source: [ugcImage]) { images { origin url } }
}
}"""
@ -105,14 +65,9 @@ query getVisitorReviewStats($id: String!) {
)
self.scrap_type: str | None = None
self.rawdata: dict | None = None
self.image_link_list: list[dict] | None = None # [{"preview": str, "original": str}]
self.owner_images: list[dict] | None = None # 업체 등록 사진 (마케팅 필터 제외 대상)
self.extra_photo_urls: list[dict] | None = None # 방문자/AI View 보충 사진 (마케팅 필터 대상)
self.image_link_list: list[str] | None = None
self.base_info: dict | 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:
headers = self.DEFAULT_HEADERS.copy()
@ -120,98 +75,6 @@ query getVisitorReviewStats($id: String!) {
headers["Cookie"] = self.cookies
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:
"""URL에서 place ID를 추출합니다. 단축 URL인 경우 실제 URL로 변환합니다."""
place_pattern = r"/place/(\d+)"
@ -223,270 +86,37 @@ query getVisitorReviewStats($id: String!) {
async with session.get(self.url) as response:
self.url = str(response.url)
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)
if not match:
# place.naver.com/{type}/{id} 형식 (예: m.place.naver.com/restaurant/11667161)
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")
raise GraphQLException("Failed to parse place ID from URL")
return match[1]
async def scrap(self):
place_id = await self.parse_url()
try:
place_id = await self.parse_url()
data = await self._call_get_accommodation(place_id)
self.rawdata = data
fac_data = await self._get_facility_string(place_id)
# Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것.
self.place_id = self.data_source_identifier + place_id
self.rawdata["facilities"] = fac_data
self.image_link_list = [
nv_image["origin"]
for nv_image in data["data"]["business"]["images"]["images"]
]
self.base_info = data["data"]["business"]["base"]
self.facility_info = fac_data
self.scrap_type = "GraphQL"
# ── 빠른 경로: 직접 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
# Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것.
self.place_id = self.data_source_identifier + place_id
self.rawdata["facilities"] = fac_data
business = data["data"]["business"]
# 업체 등록 이미지 (마케팅 적합성 필터 제외 대상 — 자체 dedup).
# 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.facility_info = fac_data
self.voted_keyword_stats = stats_data
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
@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:
"""GraphQL API를 호출하여 숙소 정보를 가져옵니다.
@ -515,14 +145,13 @@ query getVisitorReviewStats($id: String!) {
async with session.post(
self.GRAPHQL_URL,
data=json_payload,
headers=self._get_request_headers(),
headers=self._get_request_headers()
) as response:
if response.status == 200:
logger.info(f"[NvMapScraper] SUCCESS - place_id: {place_id}")
return await response.json()
# 405/429 등 실패: 네이버 WTM 안티봇 캡차 차단 (데이터센터 IP/지문 기반).
# 직접 호출로는 통과 불가 → scrap()에서 브라우저 폴백으로 전환.
# 실패 상태 코드
logger.error(f"[NvMapScraper] Failed with status {response.status} - place_id: {place_id}")
raise GraphQLException(
f"Request failed with status {response.status}"
@ -531,74 +160,33 @@ query getVisitorReviewStats($id: String!) {
except (TimeoutError, asyncio.TimeoutError):
logger.error(f"[NvMapScraper] Timeout - place_id: {place_id}")
raise CrawlingTimeoutException(f"Request timed out after {self.REQUEST_TIMEOUT}s")
except aiohttp.ClientError as e:
logger.error(f"[NvMapScraper] Client error: {e}")
raise GraphQLException(f"Client error: {e}")
async def _call_get_review_stats(self, place_id: str) -> list[dict] | 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]:
"""장소 페이지에서 편의시설 정보와 홈페이지 링크를 크롤링합니다. 숙소, 음식점 순으로 시도합니다.
async def _get_facility_string(self, place_id: str) -> str | None:
"""숙소 페이지에서 편의시설 정보를 크롤링합니다.
Args:
place_id: 네이버 지도 장소 ID
Returns:
(편의시설 정보 문자열 또는 None, 홈페이지 링크 또는 None)
편의시설 정보 문자열 또는 None
"""
facility: str | None = None
homepage: str | None = None
place_types = ["place", "accommodation", "restaurant"]
url = f"https://pcmap.place.naver.com/accommodation/{place_id}/home"
try:
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:
raw = await response.read()
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="편의")
if c_elem:
facility = c_elem.parent.parent.find("div").string
if facility is not None and homepage is not None:
break
return facility, homepage
async with session.get(url, headers=self._get_request_headers()) as response:
soup = bs4.BeautifulSoup(await response.read(), "html.parser")
c_elem = soup.find("span", "place_blind", string="편의")
if c_elem:
facilities = c_elem.parent.parent.find("div").string
return facilities
return None
except Exception as e:
logger.warning(f"[NvMapScraper] Failed to get facility/homepage info: {e}")
return facility, homepage
logger.warning(f"[NvMapScraper] Failed to get facility info: {e}")
return None
# if __name__ == "__main__":

View File

@ -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