Initial commit: AutonetSellCar platform with deployment system
- Frontend: Next.js 14 with TypeScript - Backend: FastAPI with SQLAlchemy - Agent: Carmodoo sync agent - Deployment: Docker Compose based staging/production setup - Scripts: Automated deployment with rollback support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
81
.gitignore
vendored
Normal file
81
.gitignore
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Build outputs
|
||||
.next/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# Environment files (keep sample, ignore actual)
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
.env.staging
|
||||
*.env
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# Uploads (large files)
|
||||
backend/uploads/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# Deploy secrets (if any)
|
||||
deploy/*.pem
|
||||
deploy/*.key
|
||||
|
||||
# Debug/temp HTML files
|
||||
debug_*.html
|
||||
autobegins_*.html
|
||||
ajax_*.xml
|
||||
dealer_car_view.html
|
||||
|
||||
# Legacy code
|
||||
_legacy_agent/
|
||||
|
||||
# Session history and dev notes
|
||||
SESSION_HISTORY*.md
|
||||
HANDOVER*.md
|
||||
*.bat
|
||||
|
||||
# Claude config (local)
|
||||
.claude/
|
||||
|
||||
# Dev/Debug files
|
||||
kill_server.py
|
||||
*_raw.html
|
||||
nul
|
||||
|
||||
# Root uploads (use backend/uploads)
|
||||
/uploads/
|
||||
|
||||
# Korean docs (optional)
|
||||
비용구조.md
|
||||
751
AUTONETSELLCAR_DEVELOPMENT_PLAN.md
Normal file
751
AUTONETSELLCAR_DEVELOPMENT_PLAN.md
Normal file
@@ -0,0 +1,751 @@
|
||||
# AutonetSellCar 개발 계획서
|
||||
## 몽골 중고차 수출 플랫폼 (www.autonetsellcar.com)
|
||||
### 작성일: 2025-12-06
|
||||
|
||||
---
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
### 서비스 소개
|
||||
**AutonetSellCar**는 한국 중고차를 몽골 바이어에게 수출하는 B2C/B2B 플랫폼입니다.
|
||||
카모두(Carmodoo) 딜러 시스템에서 차량 데이터를 자동 수집하여 몽골 바이어에게 제공합니다.
|
||||
|
||||
### 타겟 사용자
|
||||
| 사용자 유형 | 설명 | 인증 방식 |
|
||||
|------------|------|----------|
|
||||
| 몽골 일반 바이어 | 개인 차량 구매자 | 이메일/Facebook/Google |
|
||||
| 몽골 비즈니스 바이어 | 대량 구매/딜러 | Google OAuth + 사업자 인증 |
|
||||
| 중계자 (Agent) | 한-몽 중개인 | 신분증 인증 필수 |
|
||||
| 관리자 | 시스템 관리 | 이메일/비밀번호 + 2FA |
|
||||
|
||||
---
|
||||
|
||||
## 2. 현재 구현 상태
|
||||
|
||||
### 완료된 기능
|
||||
- [x] FastAPI Backend 기본 구조
|
||||
- [x] 차량 목록/상세 API
|
||||
- [x] 기본 이메일/비밀번호 인증
|
||||
- [x] Next.js Frontend 기본 구조
|
||||
- [x] 차량 목록/상세 페이지
|
||||
- [x] SQLite 로컬 개발 환경
|
||||
- [x] Carmodoo Agent (차량 데이터 수집기)
|
||||
|
||||
### 미구현 기능
|
||||
- [ ] 소셜 로그인 (Facebook, Google OAuth)
|
||||
- [ ] 몽골 SMS OTP 인증
|
||||
- [ ] 중계자 신분증 인증
|
||||
- [ ] 메인 히어로 슬라이더 (영화 필름 스타일)
|
||||
- [ ] 관리자 대시보드
|
||||
- [ ] 배너/슬라이더 관리 기능
|
||||
|
||||
---
|
||||
|
||||
## 3. 기술 스택
|
||||
|
||||
### 3.1 Frontend
|
||||
| 기술 | 버전 | 용도 |
|
||||
|------|------|------|
|
||||
| Next.js | 14.1.0 | React 프레임워크 (App Router) |
|
||||
| TypeScript | 5.3+ | 타입 안정성 |
|
||||
| Tailwind CSS | 3.4+ | 스타일링 |
|
||||
| Axios | 1.6+ | HTTP 클라이언트 |
|
||||
| Zustand | 4.5+ | 상태 관리 |
|
||||
| React Hook Form | 7.49+ | 폼 관리 |
|
||||
| Framer Motion | 11.x | 애니메이션 (슬라이더용) |
|
||||
| next-auth | 4.x | 소셜 로그인 통합 |
|
||||
|
||||
### 3.2 Backend
|
||||
| 기술 | 버전 | 용도 |
|
||||
|------|------|------|
|
||||
| FastAPI | 0.109+ | Python 웹 프레임워크 |
|
||||
| SQLAlchemy | 2.0+ | ORM |
|
||||
| PostgreSQL | 16 | 프로덕션 DB (Server1) |
|
||||
| SQLite | - | 로컬 개발용 DB |
|
||||
| Redis | 7 | 세션/캐시 (Server1) |
|
||||
| Pydantic | 2.x | 데이터 검증 |
|
||||
| python-jose | - | JWT 토큰 |
|
||||
| passlib | - | 비밀번호 해싱 |
|
||||
| httpx | - | 비동기 HTTP 클라이언트 |
|
||||
| aiofiles | - | 비동기 파일 처리 |
|
||||
|
||||
### 3.3 인증 서비스 (외부)
|
||||
| 서비스 | 용도 | 비고 |
|
||||
|--------|------|------|
|
||||
| Facebook OAuth 2.0 | 소셜 로그인 | PKCE 방식 |
|
||||
| Google OAuth 2.0 | 소셜 로그인 | 비즈니스용 |
|
||||
| Twilio / MessageBird | SMS OTP | 몽골 번호 지원 확인 필요 |
|
||||
| AWS S3 / Cloudflare R2 | 이미지 저장 | 선택 |
|
||||
|
||||
### 3.4 인프라
|
||||
| 서버 | IP | 역할 |
|
||||
|------|-----|------|
|
||||
| Server1 | 192.168.0.201 | PostgreSQL, Redis, Nginx Proxy Manager |
|
||||
| Server2 | 192.168.0.202 | AutonetSellCar (Backend:8000, Frontend:3000) |
|
||||
| Server3 | 192.168.0.203 | Grantech.kr, Cylinx.kr |
|
||||
|
||||
---
|
||||
|
||||
## 4. 인증 시스템 상세 설계
|
||||
|
||||
### 4.0 보안 아키텍처 개요
|
||||
|
||||
중고차 거래 플랫폼의 특성상 **보안이 매우 중요**합니다. 고액 거래가 이루어지므로 이중 토큰 전략을 적용합니다.
|
||||
|
||||
#### 최종 권장 아키텍처
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 몽골 중고차 사이트 인증 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ Facebook OAuth │ │ 이메일/비밀번호 │ │
|
||||
│ │ + PKCE │ │ (대체 수단) │ │
|
||||
│ └────────┬────────┘ └────────┬────────┘ │
|
||||
│ │ │ │
|
||||
│ └──────────┬───────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ 자체 JWT 발급 │ │
|
||||
│ │ (Access + Refresh) │ │
|
||||
│ └──────────┬──────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────┴──────────┐ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ Access Token │ │ Refresh Token │ │
|
||||
│ │ (메모리/State) │ │ (HttpOnly Cookie)│ │
|
||||
│ │ 15분 수명 │ │ 7일 수명 │ │
|
||||
│ └─────────────────┘ └─────────────────┘ │
|
||||
│ │
|
||||
│ + 판매자 등록 시: 휴대폰 인증 추가 │
|
||||
│ + 고액 거래 시: 2FA 고려 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 이중 토큰 전략 (Dual Token Strategy)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Access Token │
|
||||
│ - 수명: 15분 (기본) ~ 1시간 (최대) │
|
||||
│ - 용도: API 요청 인증 │
|
||||
│ - 저장: 메모리 (더 안전) 또는 Zustand State │
|
||||
│ - 특징: 짧은 수명으로 탈취 시 피해 최소화 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Refresh Token │
|
||||
│ - 수명: 7일 (기본) ~ 30일 (최대) │
|
||||
│ - 용도: Access Token 재발급 │
|
||||
│ - 저장: HttpOnly Cookie (XSS 방지) │
|
||||
│ - 특징: JavaScript 접근 불가, CSRF 보호 필요 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 토큰 갱신 플로우
|
||||
```
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Frontend │ │ Backend │ │ Database │
|
||||
└────┬─────┘ └────┬─────┘ └────┬─────┘
|
||||
│ │ │
|
||||
│ API 요청 (Access Token 만료) │
|
||||
│──────────────▶│ │
|
||||
│ │ │
|
||||
│ 401 Unauthorized │
|
||||
│◀──────────────│ │
|
||||
│ │ │
|
||||
│ /auth/refresh (Refresh Token in Cookie)
|
||||
│──────────────▶│ │
|
||||
│ │ 토큰 검증 │
|
||||
│ │──────────────▶│
|
||||
│ │◀──────────────│
|
||||
│ │ │
|
||||
│ 새 Access Token + 새 Refresh Token
|
||||
│◀──────────────│ │
|
||||
│ │ │
|
||||
│ 원래 API 재요청 │
|
||||
│──────────────▶│ │
|
||||
└───────────────┴───────────────┘
|
||||
```
|
||||
|
||||
### 4.1 인증 방식 목록
|
||||
|
||||
#### 1) 이메일/비밀번호 기본 인증
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Frontend │───▶│ Backend │───▶│ Database │
|
||||
│ Login Form │ │ /auth/login │ │ users │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
```
|
||||
- bcrypt 해싱 (비밀번호 암호화)
|
||||
- 이중 토큰 (Access + Refresh) 발급
|
||||
- Access Token: 15분 수명
|
||||
- Refresh Token: 7일 수명, HttpOnly Cookie 저장
|
||||
|
||||
**비밀번호 정책:**
|
||||
- 최소 8자 이상
|
||||
- 대문자, 소문자, 숫자, 특수문자 포함 권장
|
||||
- bcrypt cost factor: 12 (보안 강화)
|
||||
|
||||
#### 2) Facebook OAuth 2.0 + PKCE + JWT
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Frontend │───▶│ Facebook │───▶│ Backend │───▶│ 자체 JWT │
|
||||
│ FB Button │ │ OAuth+PKCE │ │ /auth/fb/cb │ │ 토큰 발급 │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
|
||||
```
|
||||
- **PKCE (Proof Key for Code Exchange)** 필수 적용
|
||||
- Facebook 인증 후 → 자체 JWT(Access + Refresh) 발급
|
||||
- 몽골에서 Facebook 사용률 높음
|
||||
- 프로필 정보: email, name, profile_picture
|
||||
|
||||
**PKCE 플로우:**
|
||||
```
|
||||
1. Frontend: code_verifier (랜덤 문자열) 생성
|
||||
2. Frontend: code_challenge = SHA256(code_verifier) 계산
|
||||
3. Frontend → Facebook: code_challenge 전송
|
||||
4. Facebook → Frontend: authorization_code 반환
|
||||
5. Frontend → Backend: authorization_code + code_verifier 전송
|
||||
6. Backend → Facebook: code_verifier로 토큰 교환
|
||||
7. Backend: 자체 JWT(Access + Refresh) 발급
|
||||
```
|
||||
|
||||
**필요 설정:**
|
||||
```env
|
||||
FACEBOOK_APP_ID=your_app_id
|
||||
FACEBOOK_APP_SECRET=your_app_secret
|
||||
FACEBOOK_REDIRECT_URI=https://autonetsellcar.com/api/auth/facebook/callback
|
||||
```
|
||||
|
||||
#### 3) Google OAuth 2.0 + JWT
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Frontend │───▶│ Google │───▶│ Backend │───▶│ 자체 JWT │
|
||||
│ Google Btn │ │ OAuth Server│ │ /auth/google│ │ 토큰 발급 │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
|
||||
```
|
||||
- 비즈니스 바이어/외국인 용
|
||||
- Google Workspace 계정 연동 가능
|
||||
- Google 인증 후 → 자체 JWT(Access + Refresh) 발급
|
||||
|
||||
**필요 설정:**
|
||||
```env
|
||||
GOOGLE_CLIENT_ID=your_client_id
|
||||
GOOGLE_CLIENT_SECRET=your_client_secret
|
||||
GOOGLE_REDIRECT_URI=https://autonetsellcar.com/api/auth/google/callback
|
||||
```
|
||||
|
||||
#### 4) 몽골 휴대폰 SMS OTP 인증
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Frontend │───▶│ Backend │───▶│ SMS Gateway │───▶│ User Phone │
|
||||
│ Phone Input │ │ /auth/sms │ │ Twilio │ │ +976 xxxx │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
|
||||
```
|
||||
- 몽골 휴대폰 번호 형식: +976 XXXX XXXX
|
||||
- OTP 6자리, 유효시간 5분
|
||||
- Redis에 OTP 임시 저장
|
||||
|
||||
**SMS 게이트웨이 옵션:**
|
||||
| 서비스 | 몽골 지원 | 가격 | 비고 |
|
||||
|--------|----------|------|------|
|
||||
| Twilio | O | ~$0.05/SMS | 글로벌 |
|
||||
| MessageBird | O | ~$0.04/SMS | 유럽 기반 |
|
||||
| Vonage | O | ~$0.05/SMS | 글로벌 |
|
||||
| 몽골 로컬 | 확인 필요 | - | 직접 연동 |
|
||||
|
||||
**필요 설정:**
|
||||
```env
|
||||
TWILIO_ACCOUNT_SID=your_account_sid
|
||||
TWILIO_AUTH_TOKEN=your_auth_token
|
||||
TWILIO_PHONE_NUMBER=+1234567890
|
||||
```
|
||||
|
||||
#### 5) 중계자 신분증 인증
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Frontend │───▶│ Backend │───▶│ Admin │
|
||||
│ ID Upload │ │ /auth/verify│ │ Manual OK │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
```
|
||||
- 신분증 이미지 업로드 (앞/뒤)
|
||||
- 관리자 수동 승인
|
||||
- 승인 후 "중계자" 역할 부여
|
||||
|
||||
### 4.2 사용자 역할 (Role)
|
||||
| 역할 | 권한 | 비고 |
|
||||
|------|------|------|
|
||||
| buyer | 차량 조회, 문의하기 | 기본 |
|
||||
| business | buyer + 대량 견적 요청 | 사업자 인증 |
|
||||
| agent | business + 중계 수수료 관리 | 신분증 인증 |
|
||||
| admin | 모든 권한 | 시스템 관리 |
|
||||
|
||||
### 4.3 데이터베이스 스키마 (인증 관련)
|
||||
|
||||
```sql
|
||||
-- 사용자 테이블 (확장)
|
||||
ALTER TABLE users ADD COLUMN auth_provider VARCHAR(20) DEFAULT 'email';
|
||||
-- 'email', 'facebook', 'google', 'phone'
|
||||
|
||||
ALTER TABLE users ADD COLUMN provider_id VARCHAR(100);
|
||||
-- 소셜 로그인 시 provider의 user_id
|
||||
|
||||
ALTER TABLE users ADD COLUMN phone_number VARCHAR(20);
|
||||
ALTER TABLE users ADD COLUMN phone_verified BOOLEAN DEFAULT FALSE;
|
||||
|
||||
ALTER TABLE users ADD COLUMN role VARCHAR(20) DEFAULT 'buyer';
|
||||
-- 'buyer', 'business', 'agent', 'admin'
|
||||
|
||||
ALTER TABLE users ADD COLUMN id_card_front VARCHAR(500);
|
||||
ALTER TABLE users ADD COLUMN id_card_back VARCHAR(500);
|
||||
ALTER TABLE users ADD COLUMN verification_status VARCHAR(20) DEFAULT 'none';
|
||||
-- 'none', 'pending', 'approved', 'rejected'
|
||||
|
||||
-- SMS OTP 테이블 (또는 Redis 사용)
|
||||
CREATE TABLE sms_otps (
|
||||
id SERIAL PRIMARY KEY,
|
||||
phone_number VARCHAR(20) NOT NULL,
|
||||
otp_code VARCHAR(6) NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
is_used BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 메인 히어로 슬라이더 설계
|
||||
|
||||
### 5.1 요구사항
|
||||
- **이미지 크기**: 500x300 픽셀 (중고차 사진)
|
||||
- **애니메이션**: 영화 필름처럼 한 칸씩 연속 슬라이드
|
||||
- **속도**: 3-5초마다 자동 전환
|
||||
- **관리자 기능**: 배너 이미지 CRUD
|
||||
|
||||
### 5.2 UI/UX 컨셉
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ 🚗 Car1 │ │ 🚗 Car2 │ │ 🚗 Car3 │ │ 🚗 Car4 │ ───────▶ │
|
||||
│ │ 500x300 │ │ 500x300 │ │ 500x300 │ │ 500x300 │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
│ Premium Korean Used Cars │
|
||||
│ Quality vehicles exported to Mongolia at competitive prices │
|
||||
│ │
|
||||
│ [ Browse All Cars ] │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.3 기술 구현
|
||||
|
||||
#### Frontend 컴포넌트 (FilmStripSlider.tsx)
|
||||
```typescript
|
||||
// Framer Motion 기반 무한 슬라이드
|
||||
const FilmStripSlider = ({ images }: { images: BannerImage[] }) => {
|
||||
// 무한 루프를 위한 이미지 복제
|
||||
const duplicatedImages = [...images, ...images];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ x: [0, -totalWidth] }}
|
||||
transition={{
|
||||
x: {
|
||||
repeat: Infinity,
|
||||
repeatType: "loop",
|
||||
duration: images.length * 3, // 이미지당 3초
|
||||
ease: "linear"
|
||||
}
|
||||
}}
|
||||
>
|
||||
{duplicatedImages.map((img, i) => (
|
||||
<div key={i} className="w-[500px] h-[300px]">
|
||||
<Image src={img.url} alt={img.title} fill />
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 5.4 배너 관리 API
|
||||
|
||||
#### Backend Endpoints
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | /api/hero-banners | 활성 배너 목록 (Public) |
|
||||
| GET | /api/hero-banners/settings | 슬라이더 설정 |
|
||||
| GET | /api/admin/hero-banners | 모든 배너 (Admin) |
|
||||
| POST | /api/admin/hero-banners | 배너 생성 |
|
||||
| PUT | /api/admin/hero-banners/{id} | 배너 수정 |
|
||||
| DELETE | /api/admin/hero-banners/{id} | 배너 삭제 |
|
||||
| POST | /api/admin/hero-banners/upload | 이미지 업로드 |
|
||||
| PUT | /api/admin/hero-banners/settings | 설정 변경 |
|
||||
|
||||
#### 데이터베이스 스키마
|
||||
```sql
|
||||
-- 히어로 배너 설정
|
||||
CREATE TABLE hero_banner_settings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slide_interval INTEGER DEFAULT 3000, -- ms
|
||||
animation_type VARCHAR(20) DEFAULT 'film-strip', -- 'film-strip', 'fade', 'slide'
|
||||
image_width INTEGER DEFAULT 500,
|
||||
image_height INTEGER DEFAULT 300,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 히어로 배너 이미지
|
||||
CREATE TABLE hero_banners (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title_ko VARCHAR(100),
|
||||
title_en VARCHAR(100),
|
||||
title_mn VARCHAR(100), -- 몽골어
|
||||
image_url VARCHAR(500) NOT NULL,
|
||||
link_url VARCHAR(500), -- 클릭 시 이동 URL (선택)
|
||||
car_id INTEGER REFERENCES cars(id), -- 연결된 차량 (선택)
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
display_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 관리자 페이지 설계
|
||||
|
||||
### 6.1 관리자 메뉴 구조
|
||||
```
|
||||
/admin
|
||||
├── /dashboard # 대시보드 (통계, 현황)
|
||||
├── /hero-banners # 히어로 배너 관리
|
||||
├── /cars # 차량 관리
|
||||
│ ├── /list # 차량 목록
|
||||
│ ├── /sync # 카모두 동기화
|
||||
│ └── /makers # 제조사/모델 관리
|
||||
├── /users # 사용자 관리
|
||||
│ ├── /list # 사용자 목록
|
||||
│ └── /verifications # 신분증 인증 대기
|
||||
├── /inquiries # 문의 관리
|
||||
├── /settings # 사이트 설정
|
||||
└── /profile # 관리자 프로필
|
||||
```
|
||||
|
||||
### 6.2 대시보드 통계
|
||||
| 항목 | 설명 |
|
||||
|------|------|
|
||||
| 총 차량 수 | 등록된 차량 수 |
|
||||
| 오늘 방문자 | 일일 방문자 통계 |
|
||||
| 신규 문의 | 미읽음 문의 수 |
|
||||
| 신규 가입 | 오늘 가입자 수 |
|
||||
| 인증 대기 | 신분증 인증 대기 건수 |
|
||||
|
||||
### 6.3 Grantech.kr 참고 구조
|
||||
Grantech.kr에서 참고할 관리자 기능:
|
||||
- 배너 관리 (`/admin/banners`)
|
||||
- 프로젝트 관리 (`/admin/projects`)
|
||||
- 문의 관리 (`/admin/contact`)
|
||||
- 알림 관리 (`/admin/notifications`)
|
||||
|
||||
---
|
||||
|
||||
## 7. 개발 일정 (작업 순서)
|
||||
|
||||
### Phase 1: 메인 히어로 슬라이더 (우선순위 높음)
|
||||
| 작업 | 상세 | 예상 |
|
||||
|------|------|------|
|
||||
| 1-1 | Backend: hero_banners 모델/스키마 생성 | - |
|
||||
| 1-2 | Backend: hero_banners API 구현 | - |
|
||||
| 1-3 | Frontend: FilmStripSlider 컴포넌트 | - |
|
||||
| 1-4 | Frontend: 메인 페이지 히어로 섹션 적용 | - |
|
||||
| 1-5 | Admin: 히어로 배너 관리 페이지 | - |
|
||||
|
||||
### Phase 2: 관리자 페이지 기본
|
||||
| 작업 | 상세 | 예상 |
|
||||
|------|------|------|
|
||||
| 2-1 | Admin: 레이아웃 및 네비게이션 | - |
|
||||
| 2-2 | Admin: 로그인 페이지 | - |
|
||||
| 2-3 | Admin: 대시보드 | - |
|
||||
| 2-4 | Admin: 차량 관리 페이지 | - |
|
||||
|
||||
### Phase 3: 소셜 로그인
|
||||
| 작업 | 상세 | 예상 |
|
||||
|------|------|------|
|
||||
| 3-1 | Facebook OAuth 설정 (Developer Console) | - |
|
||||
| 3-2 | Google OAuth 설정 (Cloud Console) | - |
|
||||
| 3-3 | Backend: OAuth 엔드포인트 구현 | - |
|
||||
| 3-4 | Frontend: 소셜 로그인 버튼 | - |
|
||||
| 3-5 | 사용자 DB 스키마 확장 | - |
|
||||
|
||||
### Phase 4: SMS OTP 인증
|
||||
| 작업 | 상세 | 예상 |
|
||||
|------|------|------|
|
||||
| 4-1 | SMS 게이트웨이 선정 및 계정 생성 | - |
|
||||
| 4-2 | Backend: SMS 발송 서비스 구현 | - |
|
||||
| 4-3 | Backend: OTP 검증 API | - |
|
||||
| 4-4 | Frontend: 휴대폰 인증 UI | - |
|
||||
|
||||
### Phase 5: 중계자 인증
|
||||
| 작업 | 상세 | 예상 |
|
||||
|------|------|------|
|
||||
| 5-1 | Backend: 신분증 업로드 API | - |
|
||||
| 5-2 | Frontend: 신분증 업로드 UI | - |
|
||||
| 5-3 | Admin: 인증 승인 관리 페이지 | - |
|
||||
|
||||
---
|
||||
|
||||
## 8. 환경 변수 설정
|
||||
|
||||
### Backend (.env)
|
||||
```env
|
||||
# Database
|
||||
USE_SQLITE=True # False for production
|
||||
DB_HOST=192.168.0.201
|
||||
DB_PORT=5432
|
||||
DB_NAME=mongolcar
|
||||
DB_USER=admin
|
||||
DB_PASSWORD=your_password
|
||||
|
||||
# Redis (Refresh Token 저장, OTP 임시 저장)
|
||||
REDIS_HOST=192.168.0.201
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=your_password
|
||||
|
||||
# ============================================
|
||||
# JWT 이중 토큰 설정 (보안 강화)
|
||||
# ============================================
|
||||
# Access Token 설정
|
||||
SECRET_KEY=your-super-secret-key-for-jwt-minimum-32-chars
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=15 # 15분 (보안 강화)
|
||||
|
||||
# Refresh Token 설정
|
||||
REFRESH_SECRET_KEY=your-refresh-secret-key-different-from-access
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7 # 7일
|
||||
|
||||
# 세션 타임아웃 (참고용 - 프론트엔드에서 관리)
|
||||
SESSION_TIMEOUT_MINUTES=30
|
||||
|
||||
# ============================================
|
||||
# 비밀번호 보안 설정
|
||||
# ============================================
|
||||
BCRYPT_COST_FACTOR=12 # bcrypt rounds (12 권장, 높을수록 안전)
|
||||
|
||||
# ============================================
|
||||
# OAuth 설정
|
||||
# ============================================
|
||||
# Facebook OAuth 2.0 + PKCE
|
||||
FACEBOOK_APP_ID=
|
||||
FACEBOOK_APP_SECRET=
|
||||
FACEBOOK_REDIRECT_URI=http://localhost:3000/api/auth/facebook/callback
|
||||
|
||||
# Google OAuth 2.0
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/google/callback
|
||||
|
||||
# ============================================
|
||||
# SMS OTP 설정 (Twilio)
|
||||
# ============================================
|
||||
TWILIO_ACCOUNT_SID=
|
||||
TWILIO_AUTH_TOKEN=
|
||||
TWILIO_PHONE_NUMBER=
|
||||
OTP_EXPIRE_MINUTES=5 # OTP 유효시간 5분
|
||||
|
||||
# ============================================
|
||||
# Carmodoo Agent
|
||||
# ============================================
|
||||
CARMODOO_USER_ID=01033315258
|
||||
CARMODOO_PASSWORD=alskfl@1122
|
||||
AGENT_API_KEY=your_agent_api_key
|
||||
|
||||
# ============================================
|
||||
# 파일 업로드
|
||||
# ============================================
|
||||
UPLOAD_DIR=./uploads
|
||||
MAX_FILE_SIZE=10485760 # 10MB
|
||||
|
||||
# ============================================
|
||||
# 보안 설정
|
||||
# ============================================
|
||||
CORS_ORIGINS=http://localhost:3000,https://autonetsellcar.com
|
||||
COOKIE_DOMAIN=localhost # 프로덕션: .autonetsellcar.com
|
||||
COOKIE_SECURE=False # 프로덕션: True (HTTPS 필수)
|
||||
```
|
||||
|
||||
### Frontend (.env.local)
|
||||
```env
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
|
||||
# NextAuth (소셜 로그인 사용 시)
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_SECRET=your-nextauth-secret
|
||||
|
||||
# Facebook
|
||||
FACEBOOK_ID=
|
||||
FACEBOOK_SECRET=
|
||||
|
||||
# Google
|
||||
GOOGLE_ID=
|
||||
GOOGLE_SECRET=
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 파일 구조 (예상)
|
||||
|
||||
### Backend 추가 파일
|
||||
```
|
||||
mongolcar/backend/app/
|
||||
├── api/
|
||||
│ ├── auth.py # 확장: OAuth, SMS OTP
|
||||
│ ├── hero_banners.py # 새로 추가
|
||||
│ └── admin/
|
||||
│ ├── __init__.py
|
||||
│ ├── dashboard.py
|
||||
│ ├── users.py
|
||||
│ └── verifications.py
|
||||
├── models/
|
||||
│ ├── hero_banner.py # 새로 추가
|
||||
│ └── user.py # 확장
|
||||
├── schemas/
|
||||
│ ├── hero_banner.py # 새로 추가
|
||||
│ └── user.py # 확장
|
||||
└── services/
|
||||
├── oauth.py # 새로 추가
|
||||
└── sms.py # 새로 추가
|
||||
```
|
||||
|
||||
### Frontend 추가 파일
|
||||
```
|
||||
mongolcar/frontend/src/
|
||||
├── app/
|
||||
│ ├── admin/
|
||||
│ │ ├── layout.tsx
|
||||
│ │ ├── page.tsx # 대시보드
|
||||
│ │ ├── login/page.tsx
|
||||
│ │ ├── hero-banners/page.tsx
|
||||
│ │ ├── cars/page.tsx
|
||||
│ │ ├── users/page.tsx
|
||||
│ │ └── verifications/page.tsx
|
||||
│ └── auth/
|
||||
│ ├── login/page.tsx # 확장
|
||||
│ └── verify-phone/page.tsx
|
||||
├── components/
|
||||
│ ├── FilmStripSlider.tsx # 새로 추가
|
||||
│ ├── SocialLoginButtons.tsx
|
||||
│ └── admin/
|
||||
│ ├── Sidebar.tsx
|
||||
│ └── Header.tsx
|
||||
└── lib/
|
||||
└── auth.ts # NextAuth 설정
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. API 엔드포인트 전체 목록
|
||||
|
||||
### 인증 API (이중 토큰 전략)
|
||||
| Method | Endpoint | 설명 | 권한 |
|
||||
|--------|----------|------|------|
|
||||
| POST | /api/auth/register | 회원가입 (Access + Refresh 발급) | Public |
|
||||
| POST | /api/auth/login | 이메일 로그인 (Access + Refresh 발급) | Public |
|
||||
| POST | /api/auth/refresh | Access Token 재발급 (Refresh Cookie 필요) | Public |
|
||||
| POST | /api/auth/logout | 로그아웃 (Refresh Token 무효화) | User |
|
||||
| GET | /api/auth/me | 현재 사용자 정보 | User |
|
||||
| GET | /api/auth/facebook | Facebook OAuth + PKCE 시작 | Public |
|
||||
| POST | /api/auth/facebook/callback | Facebook 콜백 (자체 JWT 발급) | Public |
|
||||
| GET | /api/auth/google | Google OAuth 시작 | Public |
|
||||
| POST | /api/auth/google/callback | Google 콜백 (자체 JWT 발급) | Public |
|
||||
| POST | /api/auth/send-otp | SMS OTP 발송 (Redis 저장) | Public |
|
||||
| POST | /api/auth/verify-otp | SMS OTP 검증 | Public |
|
||||
| POST | /api/auth/upload-id-card | 신분증 업로드 (중계자용) | User |
|
||||
| PUT | /api/auth/change-password | 비밀번호 변경 (bcrypt) | User |
|
||||
|
||||
### 히어로 배너 API
|
||||
| Method | Endpoint | 설명 | 권한 |
|
||||
|--------|----------|------|------|
|
||||
| GET | /api/hero-banners | 활성 배너 목록 | Public |
|
||||
| GET | /api/hero-banners/settings | 슬라이더 설정 | Public |
|
||||
| GET | /api/admin/hero-banners | 모든 배너 | Admin |
|
||||
| POST | /api/admin/hero-banners | 배너 생성 | Admin |
|
||||
| PUT | /api/admin/hero-banners/{id} | 배너 수정 | Admin |
|
||||
| DELETE | /api/admin/hero-banners/{id} | 배너 삭제 | Admin |
|
||||
| POST | /api/admin/hero-banners/upload | 이미지 업로드 | Admin |
|
||||
| PUT | /api/admin/hero-banners/settings | 설정 변경 | Admin |
|
||||
|
||||
### 관리자 API
|
||||
| Method | Endpoint | 설명 | 권한 |
|
||||
|--------|----------|------|------|
|
||||
| GET | /api/admin/dashboard | 대시보드 통계 | Admin |
|
||||
| GET | /api/admin/users | 사용자 목록 | Admin |
|
||||
| GET | /api/admin/verifications | 인증 대기 목록 | Admin |
|
||||
| PUT | /api/admin/verifications/{id} | 인증 승인/거절 | Admin |
|
||||
|
||||
---
|
||||
|
||||
## 11. 참고 자료
|
||||
|
||||
### 프로젝트 파일 위치
|
||||
```
|
||||
D:\Workspace\claudeCode\AutonetSellCar\
|
||||
├── mongolcar/
|
||||
│ ├── backend/ # FastAPI 백엔드
|
||||
│ ├── frontend/ # Next.js 프론트엔드
|
||||
│ └── agent/ # Carmodoo Agent
|
||||
├── agent/ # 원본 Agent (백업)
|
||||
├── Grantech.kr/ # 참고용 (관리자 페이지)
|
||||
└── 문서들
|
||||
├── PROGRESS_2025-11-27.md
|
||||
├── PROGRESS_2025-11-28.md
|
||||
├── SERVER_INFRASTRUCTURE_PLAN.md
|
||||
└── AUTONETSELLCAR_DEVELOPMENT_PLAN.md (이 파일)
|
||||
```
|
||||
|
||||
### OAuth 설정 가이드
|
||||
- Facebook: https://developers.facebook.com/docs/facebook-login/
|
||||
- Google: https://developers.google.com/identity/protocols/oauth2
|
||||
|
||||
### SMS 게이트웨이
|
||||
- Twilio: https://www.twilio.com/docs/sms
|
||||
- MessageBird: https://developers.messagebird.com/
|
||||
|
||||
---
|
||||
|
||||
## 12. 보안 체크리스트
|
||||
|
||||
### 12.1 인증 보안
|
||||
- [ ] bcrypt cost factor 12 이상 적용
|
||||
- [ ] Access Token 수명 15분 이하
|
||||
- [ ] Refresh Token HttpOnly Cookie 저장
|
||||
- [ ] PKCE 적용 (Facebook OAuth)
|
||||
- [ ] CSRF 토큰 적용
|
||||
- [ ] Rate Limiting (로그인 시도 제한)
|
||||
|
||||
### 12.2 통신 보안
|
||||
- [ ] HTTPS 강제 (프로덕션)
|
||||
- [ ] CORS 설정 (허용 도메인만)
|
||||
- [ ] Cookie Secure 플래그 (HTTPS)
|
||||
- [ ] Cookie SameSite=Strict
|
||||
|
||||
### 12.3 데이터 보안
|
||||
- [ ] SQL Injection 방지 (ORM 사용)
|
||||
- [ ] XSS 방지 (React 자동 이스케이프)
|
||||
- [ ] 민감 정보 로깅 금지
|
||||
- [ ] 환경 변수 Git 제외
|
||||
|
||||
---
|
||||
|
||||
## 13. 변경 이력
|
||||
|
||||
| 날짜 | 내용 | 작성자 |
|
||||
|------|------|--------|
|
||||
| 2025-12-06 | 최초 작성 | Claude Code |
|
||||
| 2025-12-06 | 이중 토큰 전략 추가 (Access + Refresh Token) | Claude Code |
|
||||
| 2025-12-06 | Facebook OAuth PKCE 상세 플로우 추가 | Claude Code |
|
||||
| 2025-12-06 | 보안 아키텍처 다이어그램 추가 | Claude Code |
|
||||
| 2025-12-06 | bcrypt 비밀번호 정책 추가 | Claude Code |
|
||||
| 2025-12-06 | 보안 체크리스트 추가 | Claude Code |
|
||||
|
||||
---
|
||||
|
||||
*Generated by Claude Code - 2025-12-06*
|
||||
2413
AutonetSellcar_Platform_Plan.md
Normal file
2413
AutonetSellcar_Platform_Plan.md
Normal file
File diff suppressed because it is too large
Load Diff
686
CLAUDE.md
Normal file
686
CLAUDE.md
Normal file
@@ -0,0 +1,686 @@
|
||||
# AutonetSellCar.com 개발 가이드
|
||||
|
||||
이 문서는 Claude Code 세션에서 반드시 읽고 참고해야 하는 중요한 정보입니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 프로젝트 구조
|
||||
|
||||
```
|
||||
AutonetSellCar.com/
|
||||
├── backend/ # FastAPI 백엔드
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # API 라우터
|
||||
│ │ ├── models/ # SQLAlchemy 모델
|
||||
│ │ ├── services/ # 비즈니스 로직
|
||||
│ │ └── database.py # DB 연결
|
||||
│ ├── uploads/ # 업로드 파일 저장
|
||||
│ │ └── performance_checks/ # 성능점검표 PDF
|
||||
│ ├── autonet.db # ★ 실제 사용하는 DB (이것만 사용!)
|
||||
│ └── venv/ # Python 가상환경
|
||||
├── frontend/ # Next.js 프론트엔드
|
||||
│ └── src/
|
||||
│ ├── app/ # 페이지
|
||||
│ └── lib/api.ts # API 함수
|
||||
└── restart-dev.bat # 개발 서버 재시작 스크립트
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 중요: DB 파일 관리
|
||||
|
||||
### ★★★ 반드시 확인 ★★★
|
||||
|
||||
**실제 사용하는 DB 파일**: `backend/autonet.db` (이것만!)
|
||||
|
||||
과거에 테스트용으로 생성된 빈 DB 파일들이 있을 수 있습니다:
|
||||
- `autonet.db` (루트) - 사용하지 않음
|
||||
- `autonetsellcar.db` - 사용하지 않음
|
||||
- `car_platform.db` - 사용하지 않음
|
||||
|
||||
**이런 파일들이 발견되면 삭제해야 합니다.**
|
||||
|
||||
```python
|
||||
# DB 파일 확인 스크립트
|
||||
from pathlib import Path
|
||||
base = Path(r'D:\Workspace\claudeCode\AutonetSellCar.com')
|
||||
for db in base.rglob('*.db'):
|
||||
if 'venv' not in str(db) and 'node_modules' not in str(db):
|
||||
print(f"{db}: {db.stat().st_size / 1024:.1f} KB")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 성능점검표 PDF 시스템
|
||||
|
||||
### 3.1 PDF 생성 흐름
|
||||
|
||||
1. 관리자가 Carmodoo에서 차량 검색
|
||||
2. 차량 가져오기(import) 시 `check_num` (성능점검번호) 추출
|
||||
3. `capture_performance_check_pdf()` 함수로 PDF 캡처 시도
|
||||
4. 성공 시 `car_performance_checks.pdf_path`에 경로 저장
|
||||
5. 프론트엔드에서 `pdf_path`가 있으면 PDF 보기 버튼 표시
|
||||
|
||||
### 3.2 PDF 버튼이 안 보이는 경우
|
||||
|
||||
**원인**: `pdf_path`가 NULL인 경우
|
||||
|
||||
**해결 방법**:
|
||||
1. 관리자 > Hero Banners 페이지에서 "PDF 재시도" 버튼 클릭
|
||||
2. 또는 API 직접 호출: `POST /api/carmodoo/admin/retry-all-failed-pdfs`
|
||||
|
||||
### 3.3 PDF 관련 파일들
|
||||
|
||||
- `backend/app/services/pdf_service.py` - PDF 캡처 로직 (3회 자동 재시도)
|
||||
- `backend/app/api/carmodoo.py` - PDF 관련 API 엔드포인트
|
||||
- `frontend/src/app/cars/[id]/page.tsx` - PDF 보기 버튼 (라인 605 근처)
|
||||
|
||||
```javascript
|
||||
// PDF 버튼 표시 조건 (page.tsx)
|
||||
{performanceCheck.data?.pdf_path && (
|
||||
// PDF 버튼 렌더링
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. check_num (성능점검번호) 처리
|
||||
|
||||
### 4.1 check_num 추출 위치
|
||||
|
||||
Carmodoo HTML에서 `checkNum`은 **주석 처리된 부분**에 있음:
|
||||
```html
|
||||
<!-- ... checkNum=9830018360 ... -->
|
||||
```
|
||||
|
||||
### 4.2 파싱 로직 (carmodoo.py)
|
||||
|
||||
```python
|
||||
# 전체 row HTML에서 checkNum 추출 (주석 포함)
|
||||
row_html = etree.tostring(row, encoding='unicode')
|
||||
check_match = re.search(r'checkNum=(\d+)', row_html)
|
||||
if check_match:
|
||||
check_num = check_match.group(1)
|
||||
```
|
||||
|
||||
### 4.3 check_num 관련 모델/인터페이스
|
||||
|
||||
**백엔드** (`carmodoo.py`):
|
||||
```python
|
||||
class CarmodooSearchResultItem(BaseModel):
|
||||
# ... 기타 필드 ...
|
||||
check_num: Optional[str] = None # 성능점검번호
|
||||
```
|
||||
|
||||
**프론트엔드** (`api.ts`):
|
||||
```typescript
|
||||
export interface CarmodooSearchResult {
|
||||
// ... 기타 필드 ...
|
||||
check_num?: string; // 성능점검번호
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 서버 재시작 관련
|
||||
|
||||
### 5.1 uvicorn 캐시 문제
|
||||
|
||||
**증상**: 코드 수정 후에도 API 스키마에 변경사항이 반영되지 않음
|
||||
|
||||
**원인**:
|
||||
- `--reload` 옵션이 있어도 일부 변경사항이 적용되지 않는 경우 있음
|
||||
- `__pycache__` 캐시 문제
|
||||
|
||||
**해결 방법**:
|
||||
```powershell
|
||||
# 1. __pycache__ 삭제
|
||||
Get-ChildItem -Path 'backend' -Recurse -Directory -Filter '__pycache__' | Remove-Item -Recurse -Force
|
||||
|
||||
# 2. 서버 완전 재시작
|
||||
# 방법 A: restart-dev.bat 실행
|
||||
# 방법 B: 수동으로 uvicorn 프로세스 종료 후 재시작
|
||||
```
|
||||
|
||||
### 5.2 포트 확인
|
||||
|
||||
```cmd
|
||||
netstat -ano | findstr :8000
|
||||
netstat -ano | findstr :3000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 관리자 기능
|
||||
|
||||
### 6.1 Hero Banners 페이지 (/admin/hero-banners)
|
||||
|
||||
**차량 검색 필터**:
|
||||
- 제조사, 모델, 등급
|
||||
- 연식 (From ~ To)
|
||||
- 연료, 배기량, 주행거리
|
||||
|
||||
**PDF 관리**:
|
||||
- "PDF 재시도" 버튼 - PDF 없는 모든 차량에 대해 PDF 재생성
|
||||
|
||||
### 6.2 관리자 API 엔드포인트
|
||||
|
||||
| 엔드포인트 | 설명 |
|
||||
|-----------|------|
|
||||
| `GET /api/carmodoo/admin/pdf-failures` | PDF 생성 실패 목록 |
|
||||
| `POST /api/carmodoo/admin/retry-all-failed-pdfs` | 전체 PDF 재시도 |
|
||||
| `POST /api/carmodoo/regenerate-pdf/{car_id}` | 특정 차량 PDF 재생성 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 자주 발생하는 문제와 해결
|
||||
|
||||
### 7.1 "PDF 버튼이 안 보여요"
|
||||
|
||||
**체크리스트**:
|
||||
1. 로그인 상태 확인 (로그인 사용자만 PDF 접근 가능)
|
||||
2. DB에서 해당 차량의 `pdf_path` 확인
|
||||
```sql
|
||||
SELECT car_id, check_number, pdf_path FROM car_performance_checks WHERE car_id = ?;
|
||||
```
|
||||
3. `pdf_path`가 NULL이면 → PDF 재생성 필요
|
||||
4. PDF 파일이 실제로 존재하는지 확인: `backend/uploads/performance_checks/`
|
||||
|
||||
### 7.2 "코드 수정했는데 적용이 안 돼요"
|
||||
|
||||
1. `__pycache__` 삭제
|
||||
2. uvicorn 서버 완전 재시작
|
||||
3. 브라우저 캐시 클리어 (Ctrl+Shift+R)
|
||||
|
||||
### 7.3 "검색 결과가 안 나와요"
|
||||
|
||||
1. 캐시 만료 확인: `carmodoo_search_cache` 테이블
|
||||
2. 제조사/모델 코드 확인 (예: 기아=2, K5=38)
|
||||
3. 연도 범위 확인
|
||||
|
||||
---
|
||||
|
||||
## 8. 개발 환경 시작
|
||||
|
||||
```bash
|
||||
# 방법 1: 배치 파일 사용
|
||||
restart-dev.bat
|
||||
|
||||
# 방법 2: 수동 시작
|
||||
# 터미널 1 (백엔드)
|
||||
cd backend
|
||||
venv\Scripts\activate
|
||||
python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
# 터미널 2 (프론트엔드)
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 유용한 디버깅 명령어
|
||||
|
||||
```python
|
||||
# DB 상태 확인
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('backend/autonet.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 성능점검 데이터 확인
|
||||
cursor.execute('SELECT car_id, check_number, pdf_path FROM car_performance_checks')
|
||||
for row in cursor.fetchall():
|
||||
print(row)
|
||||
|
||||
# PDF 파일 존재 확인
|
||||
from pathlib import Path
|
||||
pdf_dir = Path('backend/uploads/performance_checks')
|
||||
for pdf in pdf_dir.glob('*.pdf'):
|
||||
print(f"{pdf.name}: {pdf.stat().st_size / 1024:.1f} KB")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 환율 시스템
|
||||
|
||||
### 10.1 개요
|
||||
|
||||
한국수출입은행 API에서 실시간 환율을 가져와 모든 가격 표시에 적용합니다.
|
||||
|
||||
- **갱신 주기**: 매일 11:30 AM (한국수출입은행 고시 시간)
|
||||
- **캐싱**: 30분간 캐시 유지
|
||||
- **폴백**: API 실패 시 기본값 사용
|
||||
|
||||
### 10.2 환율 API 엔드포인트
|
||||
|
||||
| 엔드포인트 | 설명 |
|
||||
|-----------|------|
|
||||
| `GET /api/exchange-rate` | 전체 환율 정보 (상세) |
|
||||
| `GET /api/exchange-rate/simple` | 간단한 환율 정보 (USD, EUR, JPY 등) |
|
||||
|
||||
**응답 예시** (`/api/exchange-rate/simple`):
|
||||
```json
|
||||
{
|
||||
"USD": {"rate": 1483.4, "symbol": "$", "name": "미국 달러"},
|
||||
"EUR": {"rate": 1749.74, "symbol": "€", "name": "유로"},
|
||||
"JPY": {"rate": 9.5001, "symbol": "¥", "name": "일본 옌"}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.3 프론트엔드 환율 Store
|
||||
|
||||
**파일**: `frontend/src/lib/exchangeRateStore.ts`
|
||||
|
||||
```typescript
|
||||
// 환율 가져오기
|
||||
const store = useExchangeRateStore.getState();
|
||||
const usdRate = store.rates.USD?.rate || 1483; // 폴백 값
|
||||
|
||||
// KRW → USD 변환
|
||||
const usdAmount = krwAmount / usdRate;
|
||||
|
||||
// USD → KRW 변환
|
||||
const krwAmount = usdAmount * usdRate;
|
||||
```
|
||||
|
||||
**기본값 (API 실패 시)**:
|
||||
| 통화 | rate (1 단위당 KRW) |
|
||||
|------|---------------------|
|
||||
| USD | 1483 |
|
||||
| EUR | 1750 |
|
||||
| JPY | 9.5 |
|
||||
| CNY | 203 |
|
||||
| MNT | 0.43 |
|
||||
| RUB | 14.5 |
|
||||
|
||||
### 10.4 환율 적용 파일들
|
||||
|
||||
| 파일 | 용도 |
|
||||
|------|------|
|
||||
| `frontend/src/lib/exchangeRateStore.ts` | Zustand 환율 스토어 |
|
||||
| `frontend/src/lib/i18n.ts` | `formatPriceWithCurrency()` 함수 |
|
||||
| `frontend/src/app/exchange-rate/page.tsx` | 환율 정보 페이지 |
|
||||
| `frontend/src/app/cost/page.tsx` | 비용 계산기 |
|
||||
| `frontend/src/components/SearchFilters.tsx` | 검색 필터 가격 표시 |
|
||||
| `frontend/src/app/admin/purchased/page.tsx` | 구매 차량 관리 |
|
||||
|
||||
### 10.5 주의사항
|
||||
|
||||
**하드코딩된 환율 사용 금지!**
|
||||
|
||||
```typescript
|
||||
// 잘못된 예
|
||||
const usd = krwAmount * 0.00069; // 하드코딩 X
|
||||
const krw = usdAmount * 1333; // 하드코딩 X
|
||||
|
||||
// 올바른 예
|
||||
const usdRate = useExchangeRateStore.getState().rates.USD?.rate || 1483;
|
||||
const usd = krwAmount / usdRate;
|
||||
const krw = usdAmount * usdRate;
|
||||
```
|
||||
|
||||
### 10.6 환율 디버깅
|
||||
|
||||
```bash
|
||||
# API 테스트
|
||||
curl http://localhost:8000/api/exchange-rate/simple
|
||||
|
||||
# DB 환율 데이터 확인
|
||||
sqlite3 backend/autonet.db "SELECT * FROM exchange_rates ORDER BY updated_at DESC LIMIT 5;"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 다국어 번역 시스템
|
||||
|
||||
### 11.1 개요
|
||||
|
||||
차량 정보(차량명, 연료, 변속기, 색상 등)를 사용자 선택 언어로 번역합니다.
|
||||
|
||||
- **지원 언어**: 한국어(ko), 영어(en), 몽골어(mn), 러시아어(ru)
|
||||
- **기본값**: 해당 언어 번역이 없으면 영어(en)로 대체
|
||||
|
||||
### 11.2 번역 관련 파일들
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `frontend/src/lib/i18n.ts` | 정적 번역 사전 (`CAR_TRANSLATIONS`), `translateCarName()` 함수 |
|
||||
| `frontend/src/lib/useTranslate.ts` | `translate()` 훅 - API + 정적 번역 폴백 |
|
||||
|
||||
### 11.3 `CAR_TRANSLATIONS` 구조
|
||||
|
||||
```typescript
|
||||
const CAR_TRANSLATIONS: Record<string, Record<string, string>> = {
|
||||
// 연료 타입
|
||||
'휘발유': { ko: '휘발유', en: 'Gasoline', mn: 'Бензин', ru: 'Бензин' },
|
||||
'경유': { ko: '경유', en: 'Diesel', mn: 'Дизель', ru: 'Дизель' },
|
||||
|
||||
// 변속기
|
||||
'오토': { ko: '오토', en: 'Auto', mn: 'Авто', ru: 'Авто' },
|
||||
'수동': { ko: '수동', en: 'Manual', mn: 'Механик', ru: 'Механика' },
|
||||
|
||||
// 제조사
|
||||
'KG모빌리티(쌍용)': { ko: 'KG모빌리티(쌍용)', en: 'KG Mobility (SsangYong)', ... },
|
||||
|
||||
// 모델
|
||||
'렉스턴 스포츠': { ko: '렉스턴 스포츠', en: 'Rexton Sports', ... },
|
||||
|
||||
// 색상
|
||||
'흰색': { ko: '흰색', en: 'White', mn: 'Цагаан', ru: 'Белый' },
|
||||
};
|
||||
```
|
||||
|
||||
### 11.4 번역 함수 사용법
|
||||
|
||||
**방법 1: `useTranslate` 훅** (권장)
|
||||
```tsx
|
||||
import { useTranslate } from '@/lib/useTranslate';
|
||||
|
||||
function Component() {
|
||||
const { translate } = useTranslate();
|
||||
return <span>{translate(car.fuel)}</span>; // '휘발유' → 'Gasoline'
|
||||
}
|
||||
```
|
||||
|
||||
**방법 2: `translateCarName` 함수** (직접 호출)
|
||||
```tsx
|
||||
import { translateCarName } from '@/lib/i18n';
|
||||
|
||||
const translatedFuel = translateCarName(car.fuel, language); // '경유' → 'Diesel'
|
||||
```
|
||||
|
||||
### 11.5 새 번역 추가하기
|
||||
|
||||
`CAR_TRANSLATIONS`에 새 항목 추가:
|
||||
```typescript
|
||||
'새로운용어': { ko: '새로운용어', en: 'New Term', mn: 'Шинэ нэр', ru: 'Новый термин' },
|
||||
```
|
||||
|
||||
**주의**: 긴 문자열을 먼저 매칭하기 위해 `SORTED_CAR_KEYS`가 자동으로 정렬됨
|
||||
|
||||
### 11.6 번역이 안 되는 경우
|
||||
|
||||
1. **`CAR_TRANSLATIONS`에 해당 용어가 없음** → 새 항목 추가
|
||||
2. **프론트엔드 리빌드 필요** → `npm run dev` 재시작 또는 `.next` 폴더 삭제
|
||||
3. **`translate()` 함수 미사용** → 페이지에서 직접 값 출력 중 (수정 필요)
|
||||
4. **localStorage에 'ko' 언어 저장됨** → 일반 유저는 한국어 선택 불가지만 localStorage에 남아있으면 번역 스킵됨
|
||||
- `LanguageSelector.tsx`에서 자동으로 영어로 리셋하도록 수정됨
|
||||
|
||||
### 11.7 언어 선택 제한
|
||||
|
||||
- **관리자(is_admin=true)**: 한국어, 영어, 몽골어, 러시아어 모두 선택 가능
|
||||
- **일반 유저**: 영어, 몽골어, 러시아어만 선택 가능 (한국어 숨김)
|
||||
- 파일: `frontend/src/components/LanguageSelector.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 12. CC (Car Credit) 시스템
|
||||
|
||||
### 12.1 CC란?
|
||||
- 플랫폼 내 가상 화폐
|
||||
- **차량 추천 서비스**에 사용 (1 CC = N대 추천, 관리자 설정 가능)
|
||||
- 신규 가입 시 1 CC 지급
|
||||
- **관리자 설정**: `/admin/settings`에서 `Cars per CC` 값으로 1 CC당 추천 대수 조절 가능 (기본값: 3대)
|
||||
|
||||
### 12.2 CC 충전 패키지
|
||||
|
||||
| 충전 금액 | 받는 CC | 추천 가능 차량 (기본 3대/CC) | 할인율 |
|
||||
|-----------|---------|----------------|--------|
|
||||
| $10 | 10 CC | 30대 | - |
|
||||
| $27 | 30 CC | 90대 | 10% |
|
||||
| $40 | 50 CC | 150대 | 20% |
|
||||
|
||||
> **참고**: 추천 가능 차량 수는 관리자 설정의 `Cars per CC` 값에 따라 변동됩니다.
|
||||
|
||||
### 12.3 결제 수단
|
||||
|
||||
| 수단 | 대상 | 처리 방식 |
|
||||
|------|------|-----------|
|
||||
| **Stripe** | 몽골 사용자 | Visa/Mastercard 자동 결제 |
|
||||
| **몽골 파트너 계좌** | 러시아 사용자 | 수동 충전 요청 → 관리자 승인 |
|
||||
|
||||
### 12.4 Stripe 설정
|
||||
|
||||
**환경변수** (backend/.env):
|
||||
```env
|
||||
STRIPE_SECRET_KEY=sk_test_... # Stripe 비밀키
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_... # Stripe 공개키
|
||||
STRIPE_WEBHOOK_SECRET=whsec_... # Webhook 시크릿
|
||||
STRIPE_SUCCESS_URL=https://yourdomain.com/cc/success
|
||||
STRIPE_CANCEL_URL=https://yourdomain.com/cc
|
||||
```
|
||||
|
||||
**API 엔드포인트**:
|
||||
| 엔드포인트 | 설명 |
|
||||
|-----------|------|
|
||||
| `GET /api/cc/packages` | CC 패키지 목록 |
|
||||
| `POST /api/cc/create-checkout-session` | Stripe 결제 세션 생성 |
|
||||
| `POST /api/cc/webhook` | Stripe Webhook 수신 |
|
||||
| `GET /api/cc/checkout-success` | 결제 완료 확인 |
|
||||
| `POST /api/cc/manual-request` | 수동 충전 요청 (러시아용) |
|
||||
|
||||
**Stripe Webhook 설정**:
|
||||
1. Stripe Dashboard → Webhooks
|
||||
2. 엔드포인트 추가: `https://yourdomain.com/api/cc/webhook`
|
||||
3. 이벤트 선택: `checkout.session.completed`, `checkout.session.expired`
|
||||
|
||||
### 12.5 CC vs 차량 열람
|
||||
|
||||
> **중요**: CC는 추천 서비스에만 사용됩니다!
|
||||
|
||||
| 기능 | 필요 조건 |
|
||||
|------|-----------|
|
||||
| 차량 정보 열람 (이미지, 딜러, 성능점검표) | 로그인만 하면 무료 |
|
||||
| 배너 차량 열람 | 비로그인도 무료 |
|
||||
| 차량 추천 서비스 | **CC 필요** (1 CC = N대, 설정에 따름) |
|
||||
|
||||
---
|
||||
|
||||
## 13. 상세사양조회 시스템 (AUTOBEGINS)
|
||||
|
||||
### 13.1 개요
|
||||
|
||||
Carmodoo 딜러 포탈의 AUTOBEGINS 서비스를 통해 차량번호 기반 상세사양을 조회합니다.
|
||||
|
||||
- **데이터 소스**: AUTOBEGINS (api.autobegins.com) via Carmodoo iframe
|
||||
- **조회 방식**: Playwright 브라우저 자동화
|
||||
- **저장 시점**: 배너 등록(import) 시 자동 저장
|
||||
|
||||
### 13.2 조회 가능 정보
|
||||
|
||||
| 카테고리 | 정보 |
|
||||
|----------|------|
|
||||
| 기본 정보 | 제조사, 모델명, 등급, 연식 |
|
||||
| 엔진/구동 | 배기량, 연료, 변속기, 구동방식 |
|
||||
| 성능 | 최대출력, 최대토크, 연비 |
|
||||
| 차체 | 차체형식, 도어수, 승차정원, 전장/전폭/전고/휠베이스 |
|
||||
| 옵션 | 안전옵션, 편의옵션, 외장옵션, 내장옵션 |
|
||||
| 가격 | 출고가, 기본가, 옵션가 |
|
||||
|
||||
### 13.3 관련 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `backend/app/services/spec_service.py` | Playwright 기반 사양 조회 |
|
||||
| `backend/app/models/car_specification.py` | CarSpecification 모델 |
|
||||
| `backend/app/api/carmodoo.py` | `/specifications/{car_number}`, `/car/{car_id}/specifications` API |
|
||||
|
||||
### 13.4 딜러 상세설명 (dealer_description)
|
||||
|
||||
- `cars.dealer_description` 컬럼에 저장
|
||||
- Carmodoo 상세페이지에서 딜러가 작성한 설명 추출
|
||||
- 차량 상세 페이지에서 amber 배경으로 표시 (로그인 사용자)
|
||||
|
||||
**추출 방식 (2024-12-25 개선)**:
|
||||
1. 검색 결과에서 `car_key` 추출 (`dealerCarviewPopup('...')` 패턴)
|
||||
2. `dealerCarView.html?key=<car_key>` URL로 상세 페이지 접근
|
||||
3. `<div class="carViewMemoWrap"><h3>상세설명</h3><div class="memo">...</div></div>` 파싱
|
||||
|
||||
```python
|
||||
# carmodoo.py - 딜러 설명 추출 (car_key 기반)
|
||||
async def get_car_detail(self, car_no: str, car_key: str = "") -> dict:
|
||||
if car_key:
|
||||
url = f"{CARMODOO_BASE_URL}/car/dealerCarView.html"
|
||||
params = {"key": car_key, "tabStart": "1"}
|
||||
# EUC-KR 디코딩 후 상세설명 추출
|
||||
# <div class="carViewMemoWrap">...<div class="memo">설명</div></div>
|
||||
```
|
||||
|
||||
**관련 필드**:
|
||||
- `CarmodooSearchResultItem.car_key` - 검색 결과에 포함
|
||||
- `AdminSearchResultItem.car_key` - 관리자 검색 결과에 포함
|
||||
- `ImportCarRequest.car_key` - Import 요청에 포함
|
||||
|
||||
---
|
||||
|
||||
## 14. Quote Request 시스템 (차량 추천 요청)
|
||||
|
||||
### 14.1 개요
|
||||
|
||||
사용자가 원하는 차량 조건을 입력하면 관리자가 맞춤 차량을 추천해주는 서비스입니다.
|
||||
|
||||
- **비용**: 1 CC per request
|
||||
- **응답 시간**: 24시간 이내 추천
|
||||
- **페이지**: `/vehicle-request`
|
||||
|
||||
### 14.2 요청 조건
|
||||
|
||||
| 필드 | 필수 | 설명 |
|
||||
|------|------|------|
|
||||
| 제조사 | ★ | 기아, 현대 등 |
|
||||
| 모델 | ★ | K5, 소나타 등 |
|
||||
| 등급 | - | 선택 사항 |
|
||||
| 연식 범위 | - | 2020 ~ 2024 |
|
||||
| 주행거리 | - | 만km 단위 |
|
||||
| 연료 | - | 휘발유, 경유, 하이브리드 등 |
|
||||
| 배기량 | - | 1000cc ~ 5000cc |
|
||||
|
||||
### 14.3 CC 결제 흐름
|
||||
|
||||
1. 사용자가 조건 입력
|
||||
2. 제출 시 CC 잔액 확인 (1 CC 필요)
|
||||
3. 잔액 부족 시 `/cc` 페이지로 안내
|
||||
4. CC 차감 후 요청 생성
|
||||
5. `vehicle_requests.cc_paid` 컬럼에 기록
|
||||
|
||||
### 14.4 관련 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `backend/app/api/vehicle_requests.py` | 요청 생성 API (CC 차감) |
|
||||
| `backend/app/models/vehicle_request.py` | VehicleRequest 모델 (cc_paid 컬럼) |
|
||||
| `frontend/src/app/vehicle-request/page.tsx` | 요청 폼 UI (CC 안내) |
|
||||
|
||||
---
|
||||
|
||||
## 15. 현지딜러 시스템
|
||||
|
||||
### 15.1 딜러 등급 및 수수료
|
||||
|
||||
몽골 마진(5%)에서 딜러 수수료 지급:
|
||||
|
||||
| 등급 | 조건 | 수수료율 |
|
||||
|------|------|----------|
|
||||
| 일반 (Standard) | 기본 | 3.0% |
|
||||
| 실버 (Silver) | 10건+ 판매 | 3.5% |
|
||||
| 골드 (Gold) | 30건+ 판매 | 4.0% |
|
||||
| 플래티넘 (Platinum) | 100건+ 판매 | 4.5% |
|
||||
|
||||
### 15.2 레퍼럴 시스템
|
||||
|
||||
- 딜러가 고객에게 추천 코드 제공
|
||||
- 고객이 차량 구매 시 딜러에게 수수료 지급
|
||||
- **1단계 직접 추천만 인정** (다단계 아님)
|
||||
|
||||
---
|
||||
|
||||
## 16. 딜러 설명 번역 시스템
|
||||
|
||||
### 16.1 개요
|
||||
|
||||
딜러 설명(dealer_description)을 Azure Translator API를 사용하여 다국어로 번역합니다.
|
||||
|
||||
- **번역 API**: Microsoft Azure Translator (한국어 → 영어/몽골어/러시아어 직접 지원)
|
||||
- **무료 한도**: 월 200만 글자
|
||||
- **저장 시점**: Import 시 자동 번역, 관리자가 확인 후 배너/추천 전송
|
||||
|
||||
### 16.2 DB 필드
|
||||
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| `cars.dealer_description` | 한국어 원문 |
|
||||
| `cars.dealer_description_en` | 영어 번역 |
|
||||
| `cars.dealer_description_mn` | 몽골어 번역 |
|
||||
| `cars.dealer_description_ru` | 러시아어 번역 |
|
||||
|
||||
### 16.3 환경 변수
|
||||
|
||||
```env
|
||||
# Azure Translator API
|
||||
AZURE_TRANSLATOR_KEY=your_api_key
|
||||
AZURE_TRANSLATOR_REGION=koreacentral
|
||||
```
|
||||
|
||||
### 16.4 관련 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `backend/app/services/translation_service.py` | Azure Translator 연동 |
|
||||
| `backend/app/api/carmodoo.py` | 번역 관리 API 엔드포인트 |
|
||||
| `frontend/src/app/admin/dealer-translations/page.tsx` | 관리자 번역 확인/수정 UI |
|
||||
| `frontend/src/app/cars/[id]/page.tsx` | 사용자 페이지 번역 표시 |
|
||||
|
||||
### 16.5 관리자 API 엔드포인트
|
||||
|
||||
| 엔드포인트 | 설명 |
|
||||
|-----------|------|
|
||||
| `GET /api/carmodoo/car/{car_id}/translations` | 차량 번역 조회 |
|
||||
| `PUT /api/carmodoo/car/{car_id}/translations` | 차량 번역 수정 |
|
||||
| `POST /api/carmodoo/car/{car_id}/translations/regenerate` | 번역 재생성 |
|
||||
| `GET /api/carmodoo/admin/untranslated-cars` | 미번역 차량 목록 |
|
||||
| `POST /api/carmodoo/admin/translate-all-pending` | 일괄 번역 |
|
||||
|
||||
### 16.6 번역 흐름
|
||||
|
||||
```
|
||||
Import 시 자동 번역 (Azure API)
|
||||
↓
|
||||
관리자 확인 (/admin/dealer-translations)
|
||||
↓
|
||||
필요시 수정 또는 재번역
|
||||
↓
|
||||
배너 등록 / 추천 전송
|
||||
↓
|
||||
사용자 페이지에서 한국어 원문 + 선택언어 번역 표시
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 17. 변경 이력
|
||||
|
||||
| 날짜 | 변경 내용 |
|
||||
|------|----------|
|
||||
| 2024-12-27 | **딜러 설명 번역 시스템 추가**: Azure Translator API 연동, 한국어→영어/몽골어/러시아어 직접 번역 |
|
||||
| 2024-12-27 | 관리자 번역 관리 페이지 추가 (`/admin/dealer-translations`) |
|
||||
| 2024-12-27 | DB 스키마 확장: `dealer_description_en/mn/ru` 컬럼 추가 |
|
||||
| 2024-12-25 | **딜러 상세설명 추출 방식 개선**: car_key 기반 dealerCarView.html 사용 (기존 carPopView.html 404 문제 해결) |
|
||||
| 2024-12-25 | 검색 결과에 `car_key` 필드 추가 (CarmodooSearchResultItem, AdminSearchResultItem) |
|
||||
| 2024-12-25 | Import 시 car_key 전달하여 딜러 설명 자동 추출 |
|
||||
| 2024-12-25 | 상세사양조회 시스템 추가 (AUTOBEGINS, spec_service.py, CarSpecification 모델) |
|
||||
| 2024-12-25 | 딜러 상세설명 필드 추가 (cars.dealer_description) |
|
||||
| 2024-12-25 | Quote Request 1CC 결제 시스템 추가 (vehicle_requests.cc_paid) |
|
||||
| 2024-12-25 | CC당 추천 대수 관리자 설정 추가 (cars_per_cc, 기본값 3대) |
|
||||
| 2024-12-25 | 차량 검색 시 연료 조건 필터링 버그 수정 |
|
||||
| 2024-12-24 | Stripe 결제 연동 (CC 충전 패키지, Checkout Session, Webhook) |
|
||||
| 2024-12-24 | CC 시스템 변경 (추천 서비스 기반, 차량열람 무료화) |
|
||||
| 2024-12-24 | 언어 자동 리셋 버그 수정 (localStorage 'ko' 문제) |
|
||||
| 2024-12-24 | 다국어 번역 시스템 개선 (연료/변속기/색상/차량명) |
|
||||
| 2024-12-24 | 환율 시스템 동적 적용 (하드코딩 제거, API 연동) |
|
||||
| 2024-12-24 | PDF 재시도 로직 추가 (3회 자동 재시도) |
|
||||
| 2024-12-24 | 빈 DB 파일 정리 |
|
||||
| 2024-12-24 | 관리자 PDF 재시도 UI/API 추가 |
|
||||
| 2024-12-24 | Hero Banners 검색에 주행거리 필터 추가 |
|
||||
|
||||
---
|
||||
|
||||
**이 문서는 새 세션 시작 시 반드시 읽어주세요!**
|
||||
330
DEPLOYMENT_GUIDE.md
Normal file
330
DEPLOYMENT_GUIDE.md
Normal file
@@ -0,0 +1,330 @@
|
||||
# 사이트 배포 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
이 문서는 Next.js Frontend + FastAPI Backend 사이트를 Ubuntu 서버에 배포하는 전체 과정을 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
## 배포 순서도
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 사이트 배포 전체 흐름 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
1. 사전 준비
|
||||
│
|
||||
├─→ DNS 설정 (도메인 → 공인 IP)
|
||||
│ ├─ example.com → 59.14.158.123
|
||||
│ ├─ www.example.com → 59.14.158.123
|
||||
│ └─ api.example.com → 59.14.158.123 ★ 중요: API 서브도메인 필수
|
||||
│
|
||||
├─→ 공유기 포트포워딩
|
||||
│ ├─ 80 → 192.168.0.201:80 (NPM)
|
||||
│ └─ 443 → 192.168.0.201:443 (NPM)
|
||||
│
|
||||
└─→ 서버 준비
|
||||
├─ Docker 설치
|
||||
├─ Node.js 설치
|
||||
└─ Python 설치
|
||||
|
||||
2. 소스코드 전송
|
||||
│
|
||||
├─→ 압축 (node_modules 제외)
|
||||
│ └─ 7z a -tzip project.zip frontend backend -xr!node_modules
|
||||
│
|
||||
├─→ 전송 (내부망 경유 권장)
|
||||
│ └─ scp project.zip damon@192.168.0.203:~/sites/project/
|
||||
│
|
||||
└─→ 압축 해제
|
||||
└─ unzip project.zip
|
||||
|
||||
3. Backend 배포
|
||||
│
|
||||
├─→ 가상환경 생성
|
||||
│ └─ python3 -m venv venv && source venv/bin/activate
|
||||
│
|
||||
├─→ 의존성 설치
|
||||
│ └─ pip install -r requirements.txt
|
||||
│
|
||||
├─→ ★★★ CORS 설정 수정 ★★★
|
||||
│ └─ config.py의 CORS_ORIGINS에 도메인 추가
|
||||
│ - http://example.com
|
||||
│ - https://example.com
|
||||
│ - http://www.example.com
|
||||
│ - https://www.example.com
|
||||
│
|
||||
├─→ Admin 계정 생성/리셋
|
||||
│ └─ python init_admin.py 또는 reset_pw.py 실행
|
||||
│
|
||||
└─→ 서버 실행
|
||||
└─ nohup uvicorn app.main:app --host 0.0.0.0 --port 8001 > ~/logs/backend.log 2>&1 &
|
||||
|
||||
4. Frontend 배포
|
||||
│
|
||||
├─→ 의존성 설치
|
||||
│ └─ npm install
|
||||
│
|
||||
├─→ ★★★ 환경변수 설정 ★★★
|
||||
│ └─ echo "NEXT_PUBLIC_API_URL=http://api.example.com/api" > .env.local
|
||||
│
|
||||
├─→ 빌드
|
||||
│ └─ npm run build
|
||||
│
|
||||
└─→ 서버 실행
|
||||
└─ nohup sh -c 'PORT=3001 npm start' > ~/logs/frontend.log 2>&1 &
|
||||
|
||||
5. NPM (Nginx Proxy Manager) 설정
|
||||
│
|
||||
├─→ Frontend Proxy Host
|
||||
│ ├─ Domain: example.com, www.example.com
|
||||
│ ├─ Forward: 192.168.0.203:3001
|
||||
│ └─ SSL: Let's Encrypt
|
||||
│
|
||||
└─→ ★★★ API Proxy Host ★★★
|
||||
├─ Domain: api.example.com
|
||||
├─ Forward: 192.168.0.203:8001
|
||||
└─ SSL: Let's Encrypt
|
||||
|
||||
6. 테스트
|
||||
│
|
||||
├─→ 사이트 접속: http://example.com
|
||||
├─→ API 접속: http://api.example.com/api/health
|
||||
└─→ Admin 로그인: http://example.com/admin/login
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 핵심 체크리스트
|
||||
|
||||
### 배포 전 필수 확인 사항
|
||||
|
||||
- [ ] DNS에 api 서브도메인 A 레코드 추가했는가?
|
||||
- [ ] NPM에 api.도메인 프록시 설정했는가?
|
||||
- [ ] Backend CORS_ORIGINS에 도메인 추가했는가?
|
||||
- [ ] Frontend .env.local에 API URL 설정했는가?
|
||||
- [ ] Admin 계정이 DB에 존재하는가?
|
||||
|
||||
---
|
||||
|
||||
## 자주 발생하는 문제와 해결법
|
||||
|
||||
### 1. 사이트 로딩만 계속됨 (뱅글뱅글)
|
||||
|
||||
**원인**: Frontend가 API에 연결하지 못함
|
||||
|
||||
**확인 순서**:
|
||||
```bash
|
||||
# 1. Backend 실행 확인
|
||||
ps aux | grep uvicorn
|
||||
|
||||
# 2. API 직접 테스트
|
||||
curl http://api.example.com/api/health
|
||||
|
||||
# 3. .env.local 확인
|
||||
cat ~/sites/project/frontend/.env.local
|
||||
|
||||
# 4. 빌드에 환경변수 적용 확인
|
||||
grep -r "api.example" ~/sites/project/frontend/.next/ | head -3
|
||||
```
|
||||
|
||||
**해결**:
|
||||
```bash
|
||||
# .env.local 설정 후 재빌드
|
||||
echo "NEXT_PUBLIC_API_URL=http://api.example.com/api" > .env.local
|
||||
rm -rf .next
|
||||
npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. CORS 에러
|
||||
|
||||
**원인**: Backend에서 Frontend 도메인을 허용하지 않음
|
||||
|
||||
**증상**: 브라우저 Network 탭에서 "CORS error" 표시
|
||||
|
||||
**확인**:
|
||||
```bash
|
||||
cat ~/sites/project/backend/app/core/config.py | grep CORS
|
||||
```
|
||||
|
||||
**해결**: config.py의 CORS_ORIGINS에 도메인 추가
|
||||
```python
|
||||
CORS_ORIGINS: list = [
|
||||
"http://localhost:3000",
|
||||
"http://example.com",
|
||||
"https://example.com",
|
||||
"http://www.example.com",
|
||||
"https://www.example.com"
|
||||
]
|
||||
```
|
||||
|
||||
Backend 재시작:
|
||||
```bash
|
||||
pkill -f uvicorn
|
||||
nohup uvicorn app.main:app --host 0.0.0.0 --port 8001 > ~/logs/backend.log 2>&1 &
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 로그인 안 됨
|
||||
|
||||
**원인**: DB에 admin 계정이 없거나 비밀번호가 다름
|
||||
|
||||
**확인**:
|
||||
```bash
|
||||
# API 직접 테스트
|
||||
curl -X POST "http://api.example.com/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"비밀번호"}'
|
||||
```
|
||||
|
||||
**해결**: 비밀번호 리셋 스크립트 실행
|
||||
```bash
|
||||
cd ~/sites/project/backend
|
||||
source venv/bin/activate
|
||||
cat > reset_pw.py << 'EOF'
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.admin import Admin
|
||||
from app.core.security import get_password_hash
|
||||
db = SessionLocal()
|
||||
admin = db.query(Admin).filter(Admin.username == 'admin').first()
|
||||
if admin:
|
||||
admin.password_hash = get_password_hash('새비밀번호')
|
||||
db.commit()
|
||||
print('Password reset')
|
||||
else:
|
||||
print('Admin not found')
|
||||
db.close()
|
||||
EOF
|
||||
python reset_pw.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 포트 충돌 (EADDRINUSE)
|
||||
|
||||
**원인**: 기존 프로세스가 포트를 점유 중
|
||||
|
||||
**확인**:
|
||||
```bash
|
||||
sudo netstat -tlnp | grep 3001
|
||||
sudo netstat -tlnp | grep 8001
|
||||
```
|
||||
|
||||
**해결**:
|
||||
```bash
|
||||
# PID 확인 후 종료
|
||||
sudo kill -9 <PID>
|
||||
|
||||
# 또는 프로세스 이름으로 종료
|
||||
pkill -f "npm start"
|
||||
pkill -f uvicorn
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 500 Internal Server Error (정적 파일)
|
||||
|
||||
**원인**: 빌드 파일 손상 또는 캐시 문제
|
||||
|
||||
**해결**:
|
||||
```bash
|
||||
cd ~/sites/project/frontend
|
||||
pkill -f "npm start"
|
||||
rm -rf .next
|
||||
rm -rf node_modules/.cache
|
||||
npm run build
|
||||
nohup sh -c 'PORT=3001 npm start' > ~/logs/frontend.log 2>&1 &
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 유용한 명령어 모음
|
||||
|
||||
### 프로세스 관리
|
||||
|
||||
```bash
|
||||
# 프로세스 확인
|
||||
ps aux | grep -E "npm|uvicorn|node"
|
||||
|
||||
# 포트 사용 확인
|
||||
sudo netstat -tlnp | grep -E "3001|8001"
|
||||
|
||||
# 로그 확인
|
||||
tail -f ~/logs/frontend.log
|
||||
tail -f ~/logs/backend.log
|
||||
```
|
||||
|
||||
### 서비스 재시작
|
||||
|
||||
```bash
|
||||
# Frontend 재시작
|
||||
pkill -f "npm start"
|
||||
cd ~/sites/project/frontend
|
||||
nohup sh -c 'PORT=3001 npm start' > ~/logs/frontend.log 2>&1 &
|
||||
|
||||
# Backend 재시작
|
||||
pkill -f uvicorn
|
||||
cd ~/sites/project/backend
|
||||
source venv/bin/activate
|
||||
nohup uvicorn app.main:app --host 0.0.0.0 --port 8001 > ~/logs/backend.log 2>&1 &
|
||||
```
|
||||
|
||||
### API 테스트
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://api.example.com/api/health
|
||||
|
||||
# 로그인 테스트
|
||||
curl -X POST "http://api.example.com/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"password"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 서버 정보
|
||||
|
||||
### 네트워크 구성
|
||||
|
||||
| 서버 | 내부 IP | 역할 |
|
||||
|------|---------|------|
|
||||
| Server1 | 192.168.0.201 | NPM, PostgreSQL, Redis, 모니터링 |
|
||||
| Server2 | 192.168.0.202 | MongolCar (autonetsellcar.com) |
|
||||
| Server3 | 192.168.0.203 | Grantech, Cylinx |
|
||||
| Server4 | 192.168.0.204 | Windows PC (파일 전송 중계) |
|
||||
|
||||
### 외부 접속
|
||||
|
||||
| 포트 | 용도 |
|
||||
|------|------|
|
||||
| 80, 443 | NPM (웹 서비스) |
|
||||
| 81 | NPM 관리 페이지 |
|
||||
| 201, 202, 203 | SSH (각 서버) |
|
||||
|
||||
### 도메인
|
||||
|
||||
| 도메인 | 서비스 |
|
||||
|--------|--------|
|
||||
| autonetsellcar.com | MongolCar |
|
||||
| grantech.kr | Grantech |
|
||||
| api.grantech.kr | Grantech API |
|
||||
| cylinx.kr | Cylinx (예정) |
|
||||
|
||||
---
|
||||
|
||||
## 버전 정보
|
||||
|
||||
- Node.js: 20.x
|
||||
- Python: 3.10
|
||||
- Next.js: 16.x
|
||||
- FastAPI: Latest
|
||||
- Ubuntu: 22.04
|
||||
|
||||
---
|
||||
|
||||
*최종 업데이트: 2025-12-05*
|
||||
867
Doc/FILE_SERVER_SETUP_2025-12-05.md
Normal file
867
Doc/FILE_SERVER_SETUP_2025-12-05.md
Normal file
@@ -0,0 +1,867 @@
|
||||
# 파일 서버 구축 상세 문서
|
||||
|
||||
**작성일**: 2025-12-05
|
||||
**서버**: Server3 (192.168.0.203)
|
||||
**운영체제**: Ubuntu 22.04.5 LTS Server
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [아키텍처 개요](#아키텍처-개요)
|
||||
2. [기술 스택](#기술-스택)
|
||||
3. [하드웨어 구성](#하드웨어-구성)
|
||||
4. [디스크 설정](#디스크-설정)
|
||||
5. [Samba 파일 서버](#samba-파일-서버)
|
||||
6. [Nextcloud 클라우드](#nextcloud-클라우드)
|
||||
7. [NPM 프록시 설정](#npm-프록시-설정)
|
||||
8. [보안 설정](#보안-설정)
|
||||
9. [문제 해결](#문제-해결)
|
||||
10. [유지보수](#유지보수)
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처 개요
|
||||
|
||||
### 시스템 구성도
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 파일 서버 아키텍처 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────┐
|
||||
│ 인터넷 (WAN) │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
┌────────▼─────────┐
|
||||
│ 공유기 (Router) │
|
||||
│ 59.14.158.123 │
|
||||
│ │
|
||||
│ 포트포워딩: │
|
||||
│ 80,443 → :201 │
|
||||
│ 81 → :201 │
|
||||
│ 203 → :203:22 │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
┌────────────────────────┼────────────────────────┐
|
||||
│ │ │
|
||||
┌────────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐
|
||||
│ Server1 │ │ Server2 │ │ Server3 │
|
||||
│ 192.168.0.201 │ │ 192.168.0.202 │ │ 192.168.0.203 │
|
||||
│ │ │ │ │ │
|
||||
│ ┌───────────┐ │ │ ┌───────────┐ │ │ ┌───────────┐ │
|
||||
│ │ NPM │ │ │ │ MongolCar │ │ │ │ Grantech │ │
|
||||
│ │ :80/443 │──┼──────┼──│ :3000 │ │ │ │ :3001 │ │
|
||||
│ └───────────┘ │ │ └───────────┘ │ │ ├───────────┤ │
|
||||
│ │ │ │ │ │ FastAPI │ │
|
||||
│ ┌───────────┐ │ │ │ │ │ :8001 │ │
|
||||
│ │ PostgreSQL│ │ │ │ │ ├───────────┤ │
|
||||
│ │ :5432 │ │ │ │ │ │ Nextcloud │ │
|
||||
│ └───────────┘ │ │ │ │ │ :8080 │ │
|
||||
│ │ │ │ │ ├───────────┤ │
|
||||
│ ┌───────────┐ │ │ │ │ │ Samba │ │
|
||||
│ │ Redis │ │ │ │ │ │ :445 │ │
|
||||
│ │ :6379 │ │ │ │ │ └───────────┘ │
|
||||
│ └───────────┘ │ │ │ │ │
|
||||
└─────────────────┘ └─────────────────┘ │ ┌───────────┐ │
|
||||
│ │ 10TB HDD │ │
|
||||
│ │ /data │ │
|
||||
│ └───────────┘ │
|
||||
└─────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 데이터 접근 경로 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────┐ ┌──────────────────────┐
|
||||
│ 내부 사용자 │──── SMB (445) ────────────────────▶│ │
|
||||
│ (사무실) │ \\192.168.0.203\share │ │
|
||||
│ │ X: 드라이브 (공유) │ /data (10TB) │
|
||||
│ │ Y: 드라이브 (개인) │ ├── /share │
|
||||
└──────────────┘ │ │ (공유 폴더) │
|
||||
│ │ │
|
||||
┌──────────────┐ │ └── /damon │
|
||||
│ 외부 사용자 │──── HTTPS (443) ──────────────────▶│ (개인 폴더) │
|
||||
│ (외부) │ cloud.grantech.kr │ │
|
||||
│ │ Nextcloud 웹 인터페이스 └──────────────────────┘
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
### 접근 방식별 특징
|
||||
|
||||
| 구분 | Samba (SMB) | Nextcloud |
|
||||
|------|-------------|-----------|
|
||||
| 접근 방식 | Windows 탐색기 | 웹 브라우저 / 앱 |
|
||||
| 네트워크 | 내부망 전용 | 외부망 가능 |
|
||||
| 속도 | 매우 빠름 (LAN) | 상대적으로 느림 |
|
||||
| 보안 | 내부망 보호 | HTTPS 암호화 |
|
||||
| 동기화 | 없음 (직접 접근) | 자동 동기화 가능 |
|
||||
| 모바일 | 불가 | iOS/Android 앱 |
|
||||
|
||||
---
|
||||
|
||||
## 기술 스택
|
||||
|
||||
### 운영체제 및 기반
|
||||
|
||||
| 구성요소 | 버전 | 설명 |
|
||||
|----------|------|------|
|
||||
| Ubuntu Server | 22.04.5 LTS | 장기 지원 버전 (2027년까지) |
|
||||
| Linux Kernel | 5.15.x | HWE (Hardware Enablement) |
|
||||
| Docker | 27.x | 컨테이너 런타임 |
|
||||
| Docker Compose | 2.x | 멀티 컨테이너 관리 |
|
||||
|
||||
### 파일 시스템
|
||||
|
||||
| 구성요소 | 선택 | 이유 |
|
||||
|----------|------|------|
|
||||
| 파일시스템 | XFS | 대용량 파일/디스크 최적화, 빠른 I/O |
|
||||
| 마운트 옵션 | defaults | 일반적인 사용 패턴 |
|
||||
| 백업 우선순위 | 0 | 덤프 사용 안함 |
|
||||
| fsck 순서 | 2 | 루트 이후 검사 |
|
||||
|
||||
#### XFS vs ext4 비교
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 파일시스템 비교 │
|
||||
├─────────────────┬─────────────────┬─────────────────────────────┤
|
||||
│ 항목 │ XFS │ ext4 │
|
||||
├─────────────────┼─────────────────┼─────────────────────────────┤
|
||||
│ 최대 파일 크기 │ 8 EB │ 16 TB │
|
||||
│ 최대 볼륨 크기 │ 8 EB │ 1 EB │
|
||||
│ 대용량 파일 I/O │ 우수 │ 보통 │
|
||||
│ 작은 파일 처리 │ 보통 │ 우수 │
|
||||
│ 온라인 확장 │ 지원 │ 지원 │
|
||||
│ 온라인 축소 │ 미지원 │ 지원 │
|
||||
│ 메타데이터 저널 │ 지원 │ 지원 │
|
||||
│ 엔터프라이즈 │ 권장 │ 일반용 │
|
||||
└─────────────────┴─────────────────┴─────────────────────────────┘
|
||||
|
||||
선택: XFS - 10TB 대용량 디스크에 적합, 엔터프라이즈 환경 표준
|
||||
```
|
||||
|
||||
### 파일 공유
|
||||
|
||||
| 구성요소 | 버전 | 역할 |
|
||||
|----------|------|------|
|
||||
| Samba | 4.15.x | SMB/CIFS 파일 공유 |
|
||||
| Nextcloud | Latest | 웹 기반 클라우드 스토리지 |
|
||||
| MariaDB | 10.6 | Nextcloud 데이터베이스 |
|
||||
|
||||
### 네트워크 서비스
|
||||
|
||||
| 구성요소 | 포트 | 역할 |
|
||||
|----------|------|------|
|
||||
| SMB | 445 | Windows 파일 공유 |
|
||||
| HTTP | 8080 | Nextcloud 내부 포트 |
|
||||
| HTTPS | 443 | NPM 리버스 프록시 |
|
||||
|
||||
---
|
||||
|
||||
## 하드웨어 구성
|
||||
|
||||
### 서버 사양
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Server3 하드웨어 │
|
||||
├─────────────────┬───────────────────────────────────────────────┤
|
||||
│ CPU │ (기존 사양) │
|
||||
│ RAM │ (기존 사양) │
|
||||
│ 시스템 디스크 │ /dev/nvme0n1 또는 /dev/sdb (기존) │
|
||||
│ 데이터 디스크 │ /dev/sda - 10TB HDD │
|
||||
│ 네트워크 │ 1Gbps Ethernet │
|
||||
│ IP 주소 │ 192.168.0.203 (고정) │
|
||||
└─────────────────┴───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 스토리지 레이아웃
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 디스크 레이아웃 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
/dev/sda (10TB HDD)
|
||||
│
|
||||
└── /dev/sda1 (전체 용량)
|
||||
│
|
||||
└── 마운트: /data
|
||||
│
|
||||
├── /data/share ← 공유 폴더 (모든 사용자)
|
||||
│ ├── 문서/
|
||||
│ ├── 프로젝트/
|
||||
│ └── 백업/
|
||||
│
|
||||
└── /data/damon ← 개인 폴더 (damon 전용)
|
||||
├── 개인문서/
|
||||
└── 설정백업/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 디스크 설정
|
||||
|
||||
### 1. 디스크 확인
|
||||
|
||||
```bash
|
||||
# 연결된 디스크 확인
|
||||
lsblk
|
||||
|
||||
# 출력 예시:
|
||||
# NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
|
||||
# sda 8:0 0 9.1T 0 disk
|
||||
# ├─sda1 8:1 0 9.1T 0 part
|
||||
# sdb 8:16 0 465.8G 0 disk
|
||||
# └─sdb1 8:17 0 465.8G 0 part /
|
||||
|
||||
# 디스크 상세 정보
|
||||
sudo fdisk -l /dev/sda
|
||||
```
|
||||
|
||||
### 2. 파티션 생성
|
||||
|
||||
```bash
|
||||
# GPT 파티션 테이블 생성 (2TB 이상 필수)
|
||||
sudo parted /dev/sda mklabel gpt
|
||||
|
||||
# 전체 디스크를 하나의 파티션으로
|
||||
sudo parted /dev/sda mkpart primary 0% 100%
|
||||
|
||||
# 파티션 확인
|
||||
sudo parted /dev/sda print
|
||||
|
||||
# 출력:
|
||||
# Model: ATA ST10000VN0008 (scsi)
|
||||
# Disk /dev/sda: 10.0TB
|
||||
# Sector size (logical/physical): 512B/4096B
|
||||
# Partition Table: gpt
|
||||
#
|
||||
# Number Start End Size File system Name Flags
|
||||
# 1 1049kB 10.0TB 10.0TB primary
|
||||
```
|
||||
|
||||
### 3. XFS 파일시스템 생성
|
||||
|
||||
```bash
|
||||
# XFS 포맷
|
||||
sudo mkfs.xfs /dev/sda1
|
||||
|
||||
# 출력:
|
||||
# meta-data=/dev/sda1 isize=512 agcount=10, agsize=268435455 blks
|
||||
# = sectsz=4096 attr=2, projid32bit=1
|
||||
# = crc=1 finobt=1, sparse=1, rmapbt=0
|
||||
# = reflink=1 bigtime=0 inobtcount=0
|
||||
# data = bsize=4096 blocks=2441609211, imaxpct=5
|
||||
# = sunit=0 swidth=0 blks
|
||||
# naming =version 2 bsize=4096 ascii-ci=0, ftype=1
|
||||
# log =internal log bsize=4096 blocks=521728, version=2
|
||||
# = sectsz=4096 sunit=1 blks, lazy-count=1
|
||||
# realtime =none extsz=4096 blocks=0, rtextents=0
|
||||
```
|
||||
|
||||
### 4. 마운트 포인트 생성 및 마운트
|
||||
|
||||
```bash
|
||||
# 마운트 디렉토리 생성
|
||||
sudo mkdir -p /data
|
||||
|
||||
# 수동 마운트 (테스트)
|
||||
sudo mount /dev/sda1 /data
|
||||
|
||||
# 마운트 확인
|
||||
df -h /data
|
||||
|
||||
# 출력:
|
||||
# Filesystem Size Used Avail Use% Mounted on
|
||||
# /dev/sda1 9.1T 68G 9.1T 1% /data
|
||||
```
|
||||
|
||||
### 5. 자동 마운트 설정 (fstab)
|
||||
|
||||
```bash
|
||||
# UUID 확인
|
||||
sudo blkid /dev/sda1
|
||||
|
||||
# 출력:
|
||||
# /dev/sda1: UUID="998ba6f8-57ee-402c-97a7-6f11f998dd7f" TYPE="xfs" ...
|
||||
|
||||
# fstab 편집
|
||||
sudo nano /etc/fstab
|
||||
|
||||
# 추가할 라인:
|
||||
UUID=998ba6f8-57ee-402c-97a7-6f11f998dd7f /data xfs defaults 0 2
|
||||
|
||||
# fstab 검증
|
||||
sudo mount -a
|
||||
|
||||
# 재부팅 후에도 마운트 유지됨
|
||||
```
|
||||
|
||||
### 6. 디렉토리 구조 생성
|
||||
|
||||
```bash
|
||||
# 공유 폴더 생성
|
||||
sudo mkdir -p /data/share
|
||||
sudo mkdir -p /data/damon
|
||||
|
||||
# 권한 설정
|
||||
sudo chown damon:damon /data/share
|
||||
sudo chown damon:damon /data/damon
|
||||
sudo chmod 775 /data/share
|
||||
sudo chmod 700 /data/damon
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Samba 파일 서버
|
||||
|
||||
### 1. Samba 설치
|
||||
|
||||
```bash
|
||||
# 패키지 설치
|
||||
sudo apt update
|
||||
sudo apt install -y samba samba-common-bin
|
||||
|
||||
# 서비스 상태 확인
|
||||
sudo systemctl status smbd
|
||||
sudo systemctl status nmbd
|
||||
```
|
||||
|
||||
### 2. 사용자 생성
|
||||
|
||||
```bash
|
||||
# 시스템 사용자 생성 (이미 있으면 생략)
|
||||
sudo adduser grantech_YWJ
|
||||
|
||||
# Samba 사용자 등록 (비밀번호 설정)
|
||||
sudo smbpasswd -a damon
|
||||
# Password: (입력)
|
||||
|
||||
sudo smbpasswd -a grantech_YWJ
|
||||
# Password: 8561
|
||||
|
||||
# 사용자 활성화
|
||||
sudo smbpasswd -e damon
|
||||
sudo smbpasswd -e grantech_YWJ
|
||||
```
|
||||
|
||||
### 3. Samba 설정
|
||||
|
||||
```bash
|
||||
sudo nano /etc/samba/smb.conf
|
||||
```
|
||||
|
||||
#### 전체 설정 파일
|
||||
|
||||
```ini
|
||||
# /etc/samba/smb.conf
|
||||
# Samba 설정 파일 - Grantech 파일 서버
|
||||
|
||||
#======================= Global Settings =======================
|
||||
[global]
|
||||
workgroup = WORKGROUP
|
||||
server string = Grantech File Server
|
||||
|
||||
# 보안 설정
|
||||
security = user
|
||||
map to guest = never
|
||||
|
||||
# 로깅
|
||||
log file = /var/log/samba/log.%m
|
||||
max log size = 1000
|
||||
logging = file
|
||||
panic action = /usr/share/samba/panic-action %d
|
||||
|
||||
# 인증
|
||||
server role = standalone server
|
||||
obey pam restrictions = yes
|
||||
unix password sync = yes
|
||||
passwd program = /usr/bin/passwd %u
|
||||
passwd chat = *Enter\snew\s*\spassword:* %n\n *Retype\snew\s*\spassword:* %n\n *password\supdated\ssuccessfully* .
|
||||
pam password change = yes
|
||||
|
||||
# 성능 최적화
|
||||
socket options = TCP_NODELAY IPTOS_LOWDELAY
|
||||
read raw = yes
|
||||
write raw = yes
|
||||
|
||||
# 문자셋 (한글 지원)
|
||||
unix charset = UTF-8
|
||||
dos charset = CP949
|
||||
|
||||
#======================= Share Definitions =======================
|
||||
|
||||
# 공유 폴더 - 모든 등록 사용자 접근 가능
|
||||
[share]
|
||||
comment = Grantech Shared Drive
|
||||
path = /data/share
|
||||
browseable = yes
|
||||
read only = no
|
||||
writable = yes
|
||||
valid users = damon, grantech_YWJ
|
||||
create mask = 0664
|
||||
directory mask = 0775
|
||||
force group = damon
|
||||
|
||||
# 개인 폴더 - damon 전용 (검색 불가)
|
||||
[damon]
|
||||
comment = Damon Private Storage
|
||||
path = /data/damon
|
||||
browseable = no
|
||||
read only = no
|
||||
writable = yes
|
||||
valid users = damon
|
||||
create mask = 0600
|
||||
directory mask = 0700
|
||||
```
|
||||
|
||||
### 4. 설정 검증 및 서비스 재시작
|
||||
|
||||
```bash
|
||||
# 설정 문법 검사
|
||||
testparm
|
||||
|
||||
# 출력:
|
||||
# Load smb config files from /etc/samba/smb.conf
|
||||
# Loaded services file OK.
|
||||
# ...
|
||||
|
||||
# 서비스 재시작
|
||||
sudo systemctl restart smbd nmbd
|
||||
|
||||
# 부팅 시 자동 시작 설정
|
||||
sudo systemctl enable smbd nmbd
|
||||
```
|
||||
|
||||
### 5. 방화벽 설정
|
||||
|
||||
```bash
|
||||
# UFW가 활성화된 경우
|
||||
sudo ufw allow samba
|
||||
|
||||
# 또는 개별 포트
|
||||
sudo ufw allow 139/tcp
|
||||
sudo ufw allow 445/tcp
|
||||
sudo ufw allow 137/udp
|
||||
sudo ufw allow 138/udp
|
||||
```
|
||||
|
||||
### 6. Windows 클라이언트 연결
|
||||
|
||||
#### 네트워크 드라이브 매핑
|
||||
|
||||
```
|
||||
X: 드라이브 (공유)
|
||||
경로: \\192.168.0.203\share
|
||||
사용자: damon 또는 grantech_YWJ
|
||||
|
||||
Y: 드라이브 (개인)
|
||||
경로: \\192.168.0.203\damon
|
||||
사용자: damon
|
||||
```
|
||||
|
||||
#### PowerShell 명령어
|
||||
|
||||
```powershell
|
||||
# 공유 드라이브 연결
|
||||
net use X: \\192.168.0.203\share /user:damon /persistent:yes
|
||||
|
||||
# 개인 드라이브 연결
|
||||
net use Y: \\192.168.0.203\damon /user:damon /persistent:yes
|
||||
|
||||
# 연결 확인
|
||||
net use
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nextcloud 클라우드
|
||||
|
||||
### 1. Docker Compose 설정
|
||||
|
||||
```bash
|
||||
# 디렉토리 생성
|
||||
mkdir -p ~/nextcloud
|
||||
cd ~/nextcloud
|
||||
```
|
||||
|
||||
#### docker-compose.yml
|
||||
|
||||
```yaml
|
||||
# ~/nextcloud/docker-compose.yml
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
# Nextcloud 애플리케이션
|
||||
nextcloud:
|
||||
image: nextcloud:latest
|
||||
container_name: nextcloud
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
# Nextcloud 데이터
|
||||
- ./data:/var/www/html
|
||||
# 외부 스토리지 마운트 (Samba 공유 폴더 연동)
|
||||
- /data/share:/external/share
|
||||
- /data/damon:/external/damon
|
||||
environment:
|
||||
- MYSQL_HOST=db
|
||||
- MYSQL_DATABASE=nextcloud
|
||||
- MYSQL_USER=nextcloud
|
||||
- MYSQL_PASSWORD=nextcloud123
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
- nextcloud-net
|
||||
|
||||
# MariaDB 데이터베이스
|
||||
db:
|
||||
image: mariadb:10.6
|
||||
container_name: nextcloud-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=rootpass123
|
||||
- MYSQL_DATABASE=nextcloud
|
||||
- MYSQL_USER=nextcloud
|
||||
- MYSQL_PASSWORD=nextcloud123
|
||||
volumes:
|
||||
- ./db:/var/lib/mysql
|
||||
networks:
|
||||
- nextcloud-net
|
||||
|
||||
networks:
|
||||
nextcloud-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
### 2. Nextcloud 시작
|
||||
|
||||
```bash
|
||||
# 컨테이너 시작
|
||||
cd ~/nextcloud
|
||||
docker compose up -d
|
||||
|
||||
# 상태 확인
|
||||
docker compose ps
|
||||
|
||||
# 출력:
|
||||
# NAME IMAGE STATUS PORTS
|
||||
# nextcloud nextcloud:latest Up 2 hours 0.0.0.0:8080->80/tcp
|
||||
# nextcloud-db mariadb:10.6 Up 2 hours 3306/tcp
|
||||
|
||||
# 로그 확인
|
||||
docker compose logs -f nextcloud
|
||||
```
|
||||
|
||||
### 3. 초기 설정
|
||||
|
||||
1. 브라우저에서 `http://192.168.0.203:8080` 접속
|
||||
2. 관리자 계정 생성
|
||||
- Username: admin
|
||||
- Password: (안전한 비밀번호)
|
||||
3. 데이터베이스 설정 (자동 감지됨)
|
||||
|
||||
### 4. 외부 스토리지 연동
|
||||
|
||||
Nextcloud 관리자 → 앱 → External storage support 활성화
|
||||
|
||||
외부 스토리지 설정:
|
||||
```
|
||||
폴더명: 공유 폴더
|
||||
외부 스토리지: 로컬
|
||||
설정: /external/share
|
||||
적용 대상: 모든 사용자
|
||||
|
||||
폴더명: 개인 폴더
|
||||
외부 스토리지: 로컬
|
||||
설정: /external/damon
|
||||
적용 대상: admin
|
||||
```
|
||||
|
||||
### 5. 설치된 앱
|
||||
|
||||
| 앱 | 용도 |
|
||||
|----|------|
|
||||
| Calendar | 일정 관리, CalDAV 동기화 |
|
||||
| Contacts | 연락처 관리, CardDAV 동기화 |
|
||||
| Notes | 메모/노트 작성 |
|
||||
|
||||
---
|
||||
|
||||
## NPM 프록시 설정
|
||||
|
||||
### Server1 (192.168.0.201) NPM 설정
|
||||
|
||||
#### cloud.grantech.kr 프록시 호스트
|
||||
|
||||
```
|
||||
Domain Names: cloud.grantech.kr
|
||||
Scheme: http
|
||||
Forward Hostname/IP: 192.168.0.203
|
||||
Forward Port: 8080
|
||||
Websockets Support: ON
|
||||
Block Common Exploits: ON
|
||||
|
||||
SSL:
|
||||
- SSL Certificate: Let's Encrypt
|
||||
- Force SSL: ON
|
||||
- HTTP/2 Support: ON
|
||||
```
|
||||
|
||||
### DNS 설정 (dotname.co.kr)
|
||||
|
||||
```
|
||||
Type: A
|
||||
Host: cloud
|
||||
Value: 59.14.158.123
|
||||
TTL: 3600
|
||||
```
|
||||
|
||||
### 접속 URL
|
||||
|
||||
- 내부: http://192.168.0.203:8080
|
||||
- 외부: https://cloud.grantech.kr
|
||||
|
||||
---
|
||||
|
||||
## 보안 설정
|
||||
|
||||
### 접근 제어
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 보안 계층 구조 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
[인터넷]
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ 공유기 방화벽 │ ← 포트포워딩으로 제한된 포트만 허용
|
||||
│ (NAT) │ 80, 443, 81, 201-203
|
||||
└────────┬─────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ NPM (HTTPS) │ ← SSL/TLS 암호화
|
||||
│ Let's Encrypt │ 도메인 기반 라우팅
|
||||
└────────┬─────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Docker Network │ ← 컨테이너 격리
|
||||
│ │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Nextcloud Auth │ ← 사용자 인증
|
||||
│ │ 2FA 가능
|
||||
└──────────────────┘
|
||||
|
||||
[내부망만]
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Samba (SMB) │ ← 내부망만 접근 가능
|
||||
│ 사용자 인증 │ 사용자별 권한 분리
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
### 사용자 권한
|
||||
|
||||
| 사용자 | Samba share | Samba damon | Nextcloud |
|
||||
|--------|-------------|-------------|-----------|
|
||||
| damon | ✅ 읽기/쓰기 | ✅ 읽기/쓰기 | ✅ 전체 |
|
||||
| grantech_YWJ | ✅ 읽기/쓰기 | ❌ 접근불가 | ⚠️ 제한적 |
|
||||
|
||||
### 파일 권한
|
||||
|
||||
```bash
|
||||
# /data/share - 공유 폴더
|
||||
drwxrwxr-x damon damon /data/share
|
||||
# 새 파일: 0664 (rw-rw-r--)
|
||||
# 새 디렉토리: 0775 (rwxrwxr-x)
|
||||
|
||||
# /data/damon - 개인 폴더
|
||||
drwx------ damon damon /data/damon
|
||||
# 새 파일: 0600 (rw-------)
|
||||
# 새 디렉토리: 0700 (rwx------)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### Samba 연결 문제
|
||||
|
||||
#### 증상: "네트워크 경로를 찾을 수 없습니다"
|
||||
|
||||
```bash
|
||||
# 서버에서 확인
|
||||
sudo systemctl status smbd
|
||||
|
||||
# 포트 확인
|
||||
sudo netstat -tlnp | grep 445
|
||||
|
||||
# Windows에서 테스트
|
||||
ping 192.168.0.203
|
||||
```
|
||||
|
||||
#### 증상: "로그온 실패"
|
||||
|
||||
```bash
|
||||
# Samba 사용자 확인
|
||||
sudo pdbedit -L
|
||||
|
||||
# 비밀번호 재설정
|
||||
sudo smbpasswd -a username
|
||||
```
|
||||
|
||||
#### 증상: "권한이 없습니다"
|
||||
|
||||
```bash
|
||||
# 디렉토리 권한 확인
|
||||
ls -la /data/share
|
||||
|
||||
# 권한 수정
|
||||
sudo chmod 775 /data/share
|
||||
sudo chown damon:damon /data/share
|
||||
```
|
||||
|
||||
### Nextcloud 문제
|
||||
|
||||
#### 증상: 502 Bad Gateway
|
||||
|
||||
```bash
|
||||
# 컨테이너 상태 확인
|
||||
docker compose ps
|
||||
|
||||
# 컨테이너 재시작
|
||||
docker compose restart nextcloud
|
||||
```
|
||||
|
||||
#### 증상: 외부 스토리지 접근 불가
|
||||
|
||||
```bash
|
||||
# 컨테이너 내부 권한 확인
|
||||
docker exec nextcloud ls -la /external/
|
||||
|
||||
# 권한 수정 (호스트에서)
|
||||
sudo chmod 755 /data/share
|
||||
```
|
||||
|
||||
### 로그 확인
|
||||
|
||||
```bash
|
||||
# Samba 로그
|
||||
sudo tail -f /var/log/samba/log.smbd
|
||||
|
||||
# Nextcloud 로그
|
||||
docker compose logs -f nextcloud
|
||||
|
||||
# 시스템 로그
|
||||
sudo journalctl -u smbd -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 유지보수
|
||||
|
||||
### 일일 점검
|
||||
|
||||
```bash
|
||||
# 디스크 사용량
|
||||
df -h /data
|
||||
|
||||
# 서비스 상태
|
||||
sudo systemctl status smbd
|
||||
docker compose ps
|
||||
|
||||
# 현재 Samba 연결
|
||||
sudo smbstatus
|
||||
```
|
||||
|
||||
### 백업 전략
|
||||
|
||||
```bash
|
||||
# 중요 설정 파일 백업
|
||||
sudo cp /etc/samba/smb.conf /data/share/backup/
|
||||
sudo cp /etc/fstab /data/share/backup/
|
||||
cp ~/nextcloud/docker-compose.yml /data/share/backup/
|
||||
|
||||
# Nextcloud 데이터베이스 백업
|
||||
docker exec nextcloud-db mysqldump -u nextcloud -pnextcloud123 nextcloud > backup.sql
|
||||
```
|
||||
|
||||
### 업데이트
|
||||
|
||||
```bash
|
||||
# Samba 업데이트
|
||||
sudo apt update && sudo apt upgrade samba
|
||||
|
||||
# Nextcloud 업데이트
|
||||
cd ~/nextcloud
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 부록: 명령어 요약
|
||||
|
||||
### 서비스 관리
|
||||
|
||||
```bash
|
||||
# Samba
|
||||
sudo systemctl start|stop|restart|status smbd
|
||||
sudo systemctl enable smbd
|
||||
|
||||
# Docker (Nextcloud)
|
||||
docker compose up -d
|
||||
docker compose down
|
||||
docker compose restart
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
### 사용자 관리
|
||||
|
||||
```bash
|
||||
# Samba 사용자 추가
|
||||
sudo smbpasswd -a username
|
||||
|
||||
# Samba 사용자 삭제
|
||||
sudo smbpasswd -x username
|
||||
|
||||
# Samba 사용자 목록
|
||||
sudo pdbedit -L
|
||||
```
|
||||
|
||||
### 디스크 관리
|
||||
|
||||
```bash
|
||||
# 용량 확인
|
||||
df -h /data
|
||||
|
||||
# 디렉토리별 용량
|
||||
du -sh /data/*
|
||||
|
||||
# inode 확인
|
||||
df -i /data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 변경 내용 | 작업자 |
|
||||
|------|-----------|--------|
|
||||
| 2025-12-05 | 초기 구축 및 문서 작성 | Claude |
|
||||
|
||||
---
|
||||
|
||||
*문서 작성일: 2025-12-05*
|
||||
157
Doc/관세.md
Normal file
157
Doc/관세.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# 몽골 차량 수입 관세 및 세금 가이드
|
||||
|
||||
> 작성일: 2025년 12월
|
||||
> 대상: 한국 → 몽골 중고차 수출 시 몽골 현지 관세 및 세금
|
||||
|
||||
---
|
||||
|
||||
## 1. 기본 세금 구조
|
||||
|
||||
| 세목 | 세율 | 과세 기준 |
|
||||
|------|------|----------|
|
||||
| **관세 (Import Duty)** | **5%** | CIF 가격 기준 |
|
||||
| **부가가치세 (VAT)** | **13%** | (CIF + 관세) 기준 |
|
||||
| **특별소비세 (Excise Tax)** | **$500 ~ $4,000+** | 배기량·연식별 차등 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 특별소비세 상세
|
||||
|
||||
몽골 정부는 2006년 특별소비세법을 제정하여 수입 승용차에 대해 **연식**과 **엔진 배기량**에 따라 차등적으로 특별소비세를 부과하고 있습니다.
|
||||
|
||||
### 2.1 연식별 특별소비세 배율
|
||||
|
||||
| 연식 | 세율 배율 | 비고 |
|
||||
|------|----------|------|
|
||||
| 3년 이하 | 기본 세율 | 가장 낮음 |
|
||||
| 4~9년 | 기본 세율 × 1.5~2배 | 중간 수준 |
|
||||
| **10년 이상** | **기본 세율 × 2~3배** | ⚠️ 가장 높음 |
|
||||
|
||||
> ⚠️ **중요**: 연식 10년 이상 중고차는 9년 이하 차량 대비 특별소비세가 **2~3배** 부과됩니다.
|
||||
> 따라서 **9년 이하 차량 수출을 강력히 권장**합니다.
|
||||
|
||||
### 2.2 배기량별 특별소비세 기준 (추정)
|
||||
|
||||
| 배기량 | 9년 이하 | 10년 이상 |
|
||||
|--------|----------|----------|
|
||||
| 1,500cc 이하 | $500 ~ $800 | $1,500 ~ $2,400 |
|
||||
| 1,501 ~ 2,500cc | $800 ~ $1,500 | $2,400 ~ $4,000 |
|
||||
| 2,501 ~ 3,000cc | $1,200 ~ $2,000 | $3,600 ~ $6,000 |
|
||||
| 3,000cc 초과 | $1,500 ~ $2,500 | $4,500 ~ $7,500 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 친환경 차량 세금 감면
|
||||
|
||||
| 차량 유형 | 혜택 내용 |
|
||||
|----------|----------|
|
||||
| **하이브리드 차량** | 특별소비세 50% 감면 또는 전면 면제 |
|
||||
| **LPG 차량** | 특별소비세 전면 면제 |
|
||||
| **전기차 (EV)** | 특별소비세 감면 |
|
||||
|
||||
> 2010년 특별소비세법 개정으로 친환경 HYBRID 및 LPG 차량에 대한 특별소비세가 전면 철폐되었습니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 세금 계산 예시
|
||||
|
||||
### 예시 1: 2018년식 현대 싼타페 (2,200cc)
|
||||
|
||||
**조건**: CIF 가격 $5,000, 연식 7년
|
||||
|
||||
| 항목 | 계산식 | 금액 |
|
||||
|------|--------|------|
|
||||
| 관세 (5%) | $5,000 × 5% | $250 |
|
||||
| 특별소비세 | 7년 차, 2,200cc 기준 | 약 $1,200 |
|
||||
| 부가가치세 (13%) | ($5,000 + $250) × 13% | $682 |
|
||||
| **총 세금** | | **$2,132** |
|
||||
|
||||
### 예시 2: 2012년식 기아 쏘렌토 (2,400cc)
|
||||
|
||||
**조건**: CIF 가격 $3,500, 연식 13년 (10년 초과)
|
||||
|
||||
| 항목 | 계산식 | 금액 |
|
||||
|------|--------|------|
|
||||
| 관세 (5%) | $3,500 × 5% | $175 |
|
||||
| 특별소비세 | 13년 차, 2,400cc (10년 초과 할증) | 약 $3,000 |
|
||||
| 부가가치세 (13%) | ($3,500 + $175) × 13% | $478 |
|
||||
| **총 세금** | | **$3,653** |
|
||||
|
||||
> ⚠️ 10년 초과 차량은 차량 가격보다 세금이 더 높을 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 일-몽 EPA (경제동반자협정) 영향
|
||||
|
||||
| 구분 | 내용 |
|
||||
|------|------|
|
||||
| 체결 시기 | 2015년 체결, 2016년 6월 발효 |
|
||||
| 3년 이하 차량 | 관세 5% **전면 철폐** |
|
||||
| 3년 초과 중고차 | 6~20년에 걸쳐 **단계적 철폐** |
|
||||
|
||||
> **참고**: 한국은 현재 몽골과 FTA가 체결되지 않아 일본산 차량 대비 관세 측면에서 불리할 수 있습니다.
|
||||
> 한-몽 FTA 협상이 진행 중이며, 체결 시 양국 간 교역이 활성화될 것으로 기대됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 차량 유형별 총 세금 요약
|
||||
|
||||
| 차량 유형 | 연식 | 관세+세금 예상 |
|
||||
|----------|------|---------------|
|
||||
| 소형차 (1,500cc 이하) | 9년 이하 | $1,500 ~ $2,500 |
|
||||
| 소형차 (1,500cc 이하) | 10년 이상 | $3,000 ~ $5,000 |
|
||||
| 중형차 (2,500cc 이하) | 9년 이하 | $2,000 ~ $3,500 |
|
||||
| 중형차 (2,500cc 이하) | 10년 이상 | $4,000 ~ $7,000 |
|
||||
| SUV (2,500cc 이하) | 9년 이하 | $2,500 ~ $4,000 |
|
||||
| SUV (2,500cc 이하) | 10년 이상 | $5,000 ~ $8,000 |
|
||||
| 대형차 (3,000cc 초과) | 9년 이하 | $3,500 ~ $5,000 |
|
||||
| 대형차 (3,000cc 초과) | 10년 이상 | $7,000 ~ $12,000 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 기타 비용
|
||||
|
||||
| 항목 | 예상 비용 |
|
||||
|------|----------|
|
||||
| 몽골 통관 수수료 | $50 ~ $100 |
|
||||
| 세관 검사비 | $30 ~ $50 |
|
||||
| 서류 처리비 | $20 ~ $50 |
|
||||
| 차량 등록비 | $50 ~ $100 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 수출 시 핵심 체크포인트
|
||||
|
||||
### ✅ 권장 사항
|
||||
|
||||
- [x] **9년 이하 차량** 수출 - 특소세 절감
|
||||
- [x] **하이브리드/LPG 차량** 우선 - 세금 감면 혜택
|
||||
- [x] **소형 배기량** 차량 - 특소세 최소화
|
||||
- [x] **좌핸들 차량** - 몽골 우측통행 적합
|
||||
|
||||
### ⚠️ 주의 사항
|
||||
|
||||
- [ ] 10년 이상 차량 - 특소세 2~3배 급증
|
||||
- [ ] 대배기량 차량 - 특소세 부담 증가
|
||||
- [ ] 우핸들 차량 - 향후 수입 제한 논의 중
|
||||
|
||||
---
|
||||
|
||||
## 9. 참고 자료
|
||||
|
||||
- KOTRA 울란바토르 무역관
|
||||
- 몽골 관세청 (Mongolian Customs)
|
||||
- 몽골 특별소비세법 (2006년 제정, 2010년 개정)
|
||||
- PWC Mongolia Tax Guide
|
||||
- 몽골 도로교통개발부
|
||||
|
||||
---
|
||||
|
||||
## 10. 면책 조항
|
||||
|
||||
> 본 문서의 세율 및 금액은 참고용이며, 실제 세금은 몽골 관세청의 최신 규정 및 환율에 따라 달라질 수 있습니다.
|
||||
> 정확한 세금 산출을 위해서는 몽골 현지 통관 대행업체 또는 관세사와 상담하시기 바랍니다.
|
||||
|
||||
---
|
||||
|
||||
*문서 작성: Grantech Co., Ltd.*
|
||||
566
Doc/에스크로.md
Normal file
566
Doc/에스크로.md
Normal file
@@ -0,0 +1,566 @@
|
||||
# USDC 에스크로 서비스 조사 결과
|
||||
|
||||
## 개요
|
||||
|
||||
중고차 거래 특성상 **구매자 보호**(차량 확보 전 대금 지불 불안)와 **판매자 보호**(차량 인도 후 미수금 위험)를 모두 해결해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 사용 가능한 USDC 에스크로 서비스
|
||||
|
||||
### Option A: Circle Refund Protocol (추천 ⭐)
|
||||
|
||||
Circle에서 2025년 4월 출시한 스마트 컨트랙트 기반 에스크로 프로토콜
|
||||
|
||||
**특징:**
|
||||
- **비수탁형(Non-Custodial)**: 제3자가 자금을 직접 보유하지 않음
|
||||
- **중재자 시스템**: 분쟁 발생 시 중재자가 수령자에게 전달 또는 발신자에게 환불만 가능
|
||||
- **락업 기간 설정**: 일정 기간 후 자동 해제
|
||||
- **오픈소스**: GitHub에 코드 공개
|
||||
|
||||
**작동 방식:**
|
||||
```
|
||||
구매자 → USDC 예치 → 스마트 컨트랙트(락업)
|
||||
↓
|
||||
[조건 충족 확인]
|
||||
↓
|
||||
판매자에게 USDC 전달 또는 환불
|
||||
```
|
||||
|
||||
**비용:** 무료 (Gas fee만 발생)
|
||||
|
||||
**참고 링크:**
|
||||
- https://www.circle.com/blog/refund-protocol-non-custodial-dispute-resolution-for-stablecoin-payments
|
||||
- https://github.com/circlefin/stablecoin-evm
|
||||
|
||||
---
|
||||
|
||||
### Option B: Uniscrow
|
||||
|
||||
블록체인 에스크로 전문 서비스
|
||||
|
||||
**특징:**
|
||||
- Ethereum + USDC 기반
|
||||
- API, SDK, White Label 제공
|
||||
- 자동화된 KPI 기반 결제 (API로 조건 검증)
|
||||
- 법인/스타트업 지원
|
||||
|
||||
**비용:** 거래액의 1% (최소 $20)
|
||||
|
||||
**적합 시나리오:** 빠른 도입이 필요할 때
|
||||
|
||||
**참고 링크:**
|
||||
- https://uniscrow.com/
|
||||
- https://uniscrow.com/blockchain-escrow-payment/
|
||||
|
||||
---
|
||||
|
||||
### Option C: Guaranty Escrow
|
||||
|
||||
20년 이상 에스크로 경험을 가진 기업
|
||||
|
||||
**특징:**
|
||||
- BitGo 기관급 멀티시그 커스터디
|
||||
- USDC, USDT 지원
|
||||
- 규제 준수 (미국 기준)
|
||||
- 24/7 자산 보호
|
||||
|
||||
**비용:** 문의 필요 (기관 수준 서비스)
|
||||
|
||||
**참고 링크:**
|
||||
- https://www.guaranty-escrow.com/1031-exchange-aspen/stablecoin-escrow-company/
|
||||
|
||||
---
|
||||
|
||||
### Option D: 자체 스마트 컨트랙트 개발
|
||||
|
||||
Circle의 오픈소스 코드를 활용한 커스텀 솔루션
|
||||
|
||||
**장점:**
|
||||
- 완전한 커스터마이징
|
||||
- 수수료 없음 (Gas만)
|
||||
- 비즈니스 로직 직접 구현
|
||||
|
||||
**단점:**
|
||||
- 개발 및 보안 감사 필요
|
||||
- 스마트 컨트랙트 취약점 위험
|
||||
|
||||
---
|
||||
|
||||
## 2. AutonetSellCar 맞춤 에스크로 플로우 제안
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 차량 구매 에스크로 플로우 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [1단계] 구매자: USDC를 에스크로 컨트랙트에 예치 │
|
||||
│ └→ 상태: "대금 예치 완료" (구매자 보호) │
|
||||
│ │
|
||||
│ [2단계] Grantech: 예치 확인 후 한국 딜러에게 차량 대금 지불 │
|
||||
│ └→ 상태: "차량 확보 중" │
|
||||
│ │
|
||||
│ [3단계] 차량 인천항 도착 & 선적 │
|
||||
│ └→ 상태: "운송 중" (B/L 업로드) │
|
||||
│ │
|
||||
│ [4단계] 몽골 통관 완료 & 인도 │
|
||||
│ └→ 상태: "인도 완료" │
|
||||
│ │
|
||||
│ [5단계] 구매자 인수 확인 (앱에서 버튼 클릭) │
|
||||
│ └→ 에스크로 해제 → Grantech에 USDC 전달 │
|
||||
│ │
|
||||
│ [분쟁 시] 중재자(Grantech 또는 제3자)가 증거 검토 후 결정 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 코인 월렛 통합 방안
|
||||
|
||||
### 추천 스택: WalletConnect + Wagmi
|
||||
|
||||
**지원 월렛:**
|
||||
- MetaMask
|
||||
- Coinbase Wallet
|
||||
- Trust Wallet
|
||||
- Rainbow
|
||||
- 200+ 기타 월렛
|
||||
|
||||
**설치:**
|
||||
```bash
|
||||
npm install wagmi viem @web3modal/wagmi @tanstack/react-query
|
||||
```
|
||||
|
||||
**구현 예시:**
|
||||
```typescript
|
||||
// Web3 Provider 설정
|
||||
import { createWeb3Modal } from '@web3modal/wagmi/react'
|
||||
import { defaultWagmiConfig } from '@web3modal/wagmi/react/config'
|
||||
|
||||
const config = defaultWagmiConfig({
|
||||
chains: [mainnet, polygon, arbitrum, base],
|
||||
projectId: 'YOUR_WALLETCONNECT_PROJECT_ID',
|
||||
metadata: {
|
||||
name: 'AutonetSellCar',
|
||||
description: 'Korean Used Car Export Platform',
|
||||
url: 'https://autonetsellcar.com',
|
||||
icons: ['https://autonetsellcar.com/logo.png']
|
||||
}
|
||||
})
|
||||
|
||||
// 컴포넌트에서 사용
|
||||
function ConnectButton() {
|
||||
const { address, isConnected } = useAccount()
|
||||
const { connect } = useConnect()
|
||||
|
||||
return (
|
||||
<button onClick={() => open()}>
|
||||
{isConnected ? `${address.slice(0,6)}...` : 'Connect Wallet'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**참고 링크:**
|
||||
- https://docs.walletconnect.com/web3modal/nextjs/about
|
||||
|
||||
---
|
||||
|
||||
## 4. 추천 구현 전략
|
||||
|
||||
### Phase 1: 빠른 MVP (1-2주)
|
||||
- **Uniscrow API** 연동
|
||||
- 기본 월렛 연결 (WalletConnect)
|
||||
- 구매 플로우에 에스크로 통합
|
||||
|
||||
### Phase 2: 자체 솔루션 (1-2개월)
|
||||
- Circle Refund Protocol 기반 커스텀 스마트 컨트랙트
|
||||
- 배송 상태와 연동된 자동 해제 조건
|
||||
- 분쟁 해결 대시보드
|
||||
|
||||
### Phase 3: 고급 기능
|
||||
- 다중 서명(Multi-sig) 에스크로
|
||||
- 부분 해제 (단계별 결제)
|
||||
- 보험 연동
|
||||
|
||||
---
|
||||
|
||||
## 5. 블록체인 선택
|
||||
|
||||
| 체인 | Gas Fee | 속도 | USDC 지원 | 추천도 |
|
||||
|------|---------|------|-----------|--------|
|
||||
| **Base** | $0.01 미만 | 빠름 | ✅ | ⭐⭐⭐ |
|
||||
| Polygon | $0.01-0.05 | 빠름 | ✅ | ⭐⭐⭐ |
|
||||
| Arbitrum | $0.05-0.20 | 빠름 | ✅ | ⭐⭐ |
|
||||
| Ethereum | $1-50 | 느림 | ✅ | ⭐ |
|
||||
|
||||
**추천:** Circle이 만든 **Base** 체인 - 저렴한 Gas, 빠른 속도, USDC 네이티브 지원
|
||||
|
||||
---
|
||||
|
||||
## 6. 에스크로 스마트 컨트랙트 예시 (Solidity)
|
||||
|
||||
```solidity
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.19;
|
||||
|
||||
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
||||
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
|
||||
|
||||
contract CarEscrow is ReentrancyGuard {
|
||||
IERC20 public usdc;
|
||||
|
||||
enum EscrowState { Created, Funded, Shipped, Delivered, Completed, Disputed, Refunded }
|
||||
|
||||
struct Escrow {
|
||||
address buyer;
|
||||
address seller;
|
||||
uint256 amount;
|
||||
EscrowState state;
|
||||
uint256 createdAt;
|
||||
string vehicleId;
|
||||
}
|
||||
|
||||
mapping(uint256 => Escrow) public escrows;
|
||||
uint256 public escrowCount;
|
||||
|
||||
address public arbiter;
|
||||
|
||||
event EscrowCreated(uint256 indexed escrowId, address buyer, address seller, uint256 amount);
|
||||
event EscrowFunded(uint256 indexed escrowId);
|
||||
event VehicleShipped(uint256 indexed escrowId);
|
||||
event VehicleDelivered(uint256 indexed escrowId);
|
||||
event EscrowCompleted(uint256 indexed escrowId);
|
||||
event EscrowRefunded(uint256 indexed escrowId);
|
||||
event DisputeRaised(uint256 indexed escrowId);
|
||||
|
||||
constructor(address _usdc, address _arbiter) {
|
||||
usdc = IERC20(_usdc);
|
||||
arbiter = _arbiter;
|
||||
}
|
||||
|
||||
// 에스크로 생성 및 USDC 예치
|
||||
function createEscrow(
|
||||
address _seller,
|
||||
uint256 _amount,
|
||||
string memory _vehicleId
|
||||
) external nonReentrant returns (uint256) {
|
||||
require(_amount > 0, "Amount must be greater than 0");
|
||||
require(usdc.transferFrom(msg.sender, address(this), _amount), "Transfer failed");
|
||||
|
||||
escrowCount++;
|
||||
escrows[escrowCount] = Escrow({
|
||||
buyer: msg.sender,
|
||||
seller: _seller,
|
||||
amount: _amount,
|
||||
state: EscrowState.Funded,
|
||||
createdAt: block.timestamp,
|
||||
vehicleId: _vehicleId
|
||||
});
|
||||
|
||||
emit EscrowCreated(escrowCount, msg.sender, _seller, _amount);
|
||||
emit EscrowFunded(escrowCount);
|
||||
|
||||
return escrowCount;
|
||||
}
|
||||
|
||||
// 판매자: 차량 선적 완료 표시
|
||||
function markShipped(uint256 _escrowId) external {
|
||||
Escrow storage escrow = escrows[_escrowId];
|
||||
require(msg.sender == escrow.seller, "Only seller");
|
||||
require(escrow.state == EscrowState.Funded, "Invalid state");
|
||||
|
||||
escrow.state = EscrowState.Shipped;
|
||||
emit VehicleShipped(_escrowId);
|
||||
}
|
||||
|
||||
// 구매자: 차량 인수 확인 → 판매자에게 대금 전달
|
||||
function confirmDelivery(uint256 _escrowId) external nonReentrant {
|
||||
Escrow storage escrow = escrows[_escrowId];
|
||||
require(msg.sender == escrow.buyer, "Only buyer");
|
||||
require(escrow.state == EscrowState.Shipped, "Invalid state");
|
||||
|
||||
escrow.state = EscrowState.Completed;
|
||||
require(usdc.transfer(escrow.seller, escrow.amount), "Transfer failed");
|
||||
|
||||
emit VehicleDelivered(_escrowId);
|
||||
emit EscrowCompleted(_escrowId);
|
||||
}
|
||||
|
||||
// 분쟁 제기
|
||||
function raiseDispute(uint256 _escrowId) external {
|
||||
Escrow storage escrow = escrows[_escrowId];
|
||||
require(
|
||||
msg.sender == escrow.buyer || msg.sender == escrow.seller,
|
||||
"Only buyer or seller"
|
||||
);
|
||||
require(
|
||||
escrow.state == EscrowState.Funded || escrow.state == EscrowState.Shipped,
|
||||
"Invalid state"
|
||||
);
|
||||
|
||||
escrow.state = EscrowState.Disputed;
|
||||
emit DisputeRaised(_escrowId);
|
||||
}
|
||||
|
||||
// 중재자: 분쟁 해결
|
||||
function resolveDispute(uint256 _escrowId, bool _releaseToBuyer) external nonReentrant {
|
||||
require(msg.sender == arbiter, "Only arbiter");
|
||||
Escrow storage escrow = escrows[_escrowId];
|
||||
require(escrow.state == EscrowState.Disputed, "Not disputed");
|
||||
|
||||
if (_releaseToBuyer) {
|
||||
escrow.state = EscrowState.Refunded;
|
||||
require(usdc.transfer(escrow.buyer, escrow.amount), "Transfer failed");
|
||||
emit EscrowRefunded(_escrowId);
|
||||
} else {
|
||||
escrow.state = EscrowState.Completed;
|
||||
require(usdc.transfer(escrow.seller, escrow.amount), "Transfer failed");
|
||||
emit EscrowCompleted(_escrowId);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 참고 자료
|
||||
|
||||
- [Circle Refund Protocol](https://www.circle.com/blog/refund-protocol-non-custodial-dispute-resolution-for-stablecoin-payments)
|
||||
- [Circle Stablecoin EVM GitHub](https://github.com/circlefin/stablecoin-evm)
|
||||
- [Uniscrow Blockchain Escrow](https://uniscrow.com/blockchain-escrow-payment/)
|
||||
- [Guaranty Escrow Stablecoin Services](https://www.guaranty-escrow.com/1031-exchange-aspen/stablecoin-escrow-company/)
|
||||
- [WalletConnect Web3Modal Docs](https://docs.walletconnect.com/web3modal/nextjs/about)
|
||||
- [Propy Onchain Escrow](https://www.inman.com/2024/10/24/propy-advances-onchain-escrow/)
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
# USDC 대금 수령 시 국내 법적 검토
|
||||
|
||||
## 결론 요약
|
||||
|
||||
| 항목 | 현재 상태 | 위험도 |
|
||||
|------|----------|--------|
|
||||
| VASP 등록 의무 | **해당 가능성 높음** | 🔴 높음 |
|
||||
| 외국환거래법 | **2025 하반기 신고 의무화** | 🔴 높음 |
|
||||
| 법인세 | **과세 대상** | 🟡 중간 |
|
||||
| 부가가치세 | **불명확 (해석 필요)** | 🟡 중간 |
|
||||
| 자금세탁방지 | **Travel Rule 적용** | 🔴 높음 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 가상자산사업자(VASP) 등록 의무
|
||||
|
||||
### 현행법 (특금법)
|
||||
|
||||
특금법 제2조에 따르면, **가상자산의 매도, 매수, 교환, 이전, 보관, 관리, 중개, 알선** 등의 영업을 하는 자는 가상자산사업자로 신고해야 합니다.
|
||||
|
||||
**Grantech 해당 여부 분석:**
|
||||
|
||||
| 행위 | VASP 해당? | 설명 |
|
||||
|------|-----------|------|
|
||||
| USDC 수령 (대금결제) | △ 불명확 | 단순 수령은 해당 안 될 수 있음 |
|
||||
| USDC → 원화 환전 | ✅ 해당 | 교환 행위로 해석 가능 |
|
||||
| 에스크로 보관 | ✅ 해당 | 보관/관리로 해석 가능 |
|
||||
| 고객 월렛 제공 | ✅ 해당 | 관리 서비스로 해석 |
|
||||
|
||||
### VASP 등록 요건
|
||||
|
||||
1. **ISMS 인증** (한국인터넷진흥원) - 102개 심사 항목
|
||||
2. **실명확인 입출금계정** - 은행과 계약 필요
|
||||
3. **대표자/임원 결격사유 없음**
|
||||
4. **AML/KYC 체계 구축**
|
||||
|
||||
### 미등록 시 처벌
|
||||
|
||||
> **최대 5년 이하 징역 또는 5천만원 이하 벌금**
|
||||
|
||||
---
|
||||
|
||||
## 9. 외국환거래법 적용
|
||||
|
||||
### 2025년 개정 사항 (중요!)
|
||||
|
||||
2025년 하반기부터 **가상자산 국제거래 신고 의무화**가 시행됩니다.
|
||||
|
||||
**신고 의무 내용:**
|
||||
- 국제 가상자산 거래 사업자는 **사전 등록** 필요
|
||||
- **매월** 한국은행에 거래 보고서 제출
|
||||
- 데이터는 국세청, 관세청, 금융당국과 공유
|
||||
|
||||
**위반 시 문제:**
|
||||
- 국제 가상자산 거래의 **88%가 불법 외환거래**로 적발 (관세청 추정)
|
||||
- 총 **1.65조원** 규모의 외환범죄가 가상자산 관련
|
||||
|
||||
### Grantech 리스크
|
||||
|
||||
```
|
||||
몽골 구매자 → USDC 송금 → Grantech (한국)
|
||||
↓
|
||||
[외국환거래법 적용 대상]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 세금 문제
|
||||
|
||||
### 법인세 (해당)
|
||||
|
||||
법인이 가상자산을 취득/처분하면 **법인세 과세 대상**입니다.
|
||||
|
||||
```
|
||||
USDC 수령 시점: 시가로 원화 환산 → 수익 인식
|
||||
USDC 처분 시점: 취득가와 처분가 차이 → 손익 인식
|
||||
```
|
||||
|
||||
**취득가액 평가방법:**
|
||||
- 거래소 경유: **이동평균법**
|
||||
- 그 외: **선입선출법**
|
||||
|
||||
### 부가가치세 (불명확)
|
||||
|
||||
현재 가상자산 결제에 대한 부가세 규정이 명확하지 않습니다.
|
||||
|
||||
**쟁점:**
|
||||
- 차량 판매 대금을 USDC로 받으면 과세표준은?
|
||||
- USDC → 원화 환전 시 부가세 적용?
|
||||
|
||||
**권장:** 세무사 자문 필요
|
||||
|
||||
---
|
||||
|
||||
## 11. 디지털자산기본법 (2025-2026)
|
||||
|
||||
### 2025년 6월 통과
|
||||
|
||||
디지털자산기본법(DABA)이 통과되어 **2026년 1월 시행** 예정입니다.
|
||||
|
||||
**주요 내용:**
|
||||
- 스테이블코인 발행자 **자본금 50억원** 요구
|
||||
- 거래소의 자체 스테이블코인 발행 **금지**
|
||||
- 은행이 51% 이상 지분 보유해야 스테이블코인 발행 가능
|
||||
- **원화 스테이블코인** 규제 도입
|
||||
|
||||
### Grantech 영향
|
||||
|
||||
- USDC는 **외국 스테이블코인**으로 분류
|
||||
- 사용 자체는 금지되지 않으나, **규제 준수 부담 증가**
|
||||
|
||||
---
|
||||
|
||||
## 12. 자금세탁방지 (AML)
|
||||
|
||||
### Travel Rule 강화
|
||||
|
||||
2025년 11월부터 **모든 금액**에 Travel Rule 적용 (기존 100만원 미만 면제 폐지)
|
||||
|
||||
**의무 사항:**
|
||||
- 송금인/수취인 정보 확인
|
||||
- 거래소 간 정보 공유
|
||||
- 의심거래 보고 (STR)
|
||||
|
||||
---
|
||||
|
||||
## 13. 법적 리스크 최소화 방안
|
||||
|
||||
### Option A: 직접 USDC 수령 (고위험)
|
||||
|
||||
```
|
||||
[필요 조치]
|
||||
1. VASP 등록 (ISMS 인증, 실명계좌)
|
||||
2. 외국환거래 신고
|
||||
3. AML/KYC 시스템 구축
|
||||
4. 세무 자문
|
||||
|
||||
[예상 비용] 1-2억원 이상 + 6개월 이상 소요
|
||||
[위험] VASP 등록 심사 1년 이상 지연 중
|
||||
```
|
||||
|
||||
### Option B: 제3자 결제대행 활용 (중위험)
|
||||
|
||||
```
|
||||
[구조]
|
||||
구매자 → USDC → 결제대행사(해외 VASP) → 원화 → Grantech
|
||||
|
||||
[장점]
|
||||
- VASP 등록 불필요
|
||||
- 외국환거래법 리스크 감소
|
||||
|
||||
[단점]
|
||||
- 수수료 발생 (1-3%)
|
||||
- 결제대행사 의존
|
||||
```
|
||||
|
||||
**가능한 서비스:**
|
||||
- Circle 비즈니스 계정
|
||||
- Coinbase Commerce
|
||||
- BitPay
|
||||
|
||||
### Option C: 해외 법인 설립 (저위험)
|
||||
|
||||
```
|
||||
[구조]
|
||||
구매자 → USDC → Grantech Mongolia LLC → 한국 법인
|
||||
|
||||
[장점]
|
||||
- 한국 VASP 규제 회피
|
||||
- 몽골에서 합법적 운영
|
||||
|
||||
[단점]
|
||||
- 해외법인 설립/운영 비용
|
||||
- 이전가격 세무 이슈
|
||||
```
|
||||
|
||||
### Option D: 원화 결제만 수령 (무위험)
|
||||
|
||||
```
|
||||
[구조]
|
||||
구매자 → 현지 거래소에서 USDC → 원화 환전 → 송금 → Grantech
|
||||
|
||||
[장점]
|
||||
- 모든 규제 회피
|
||||
- 기존 외환 시스템 활용
|
||||
|
||||
[단점]
|
||||
- 고객 불편
|
||||
- 송금 수수료 발생
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. 권장 사항
|
||||
|
||||
### 단기 (즉시)
|
||||
1. **세무사/법무사 자문** 받기
|
||||
2. **Option D** (원화 결제)로 시작
|
||||
3. 규제 동향 모니터링
|
||||
|
||||
### 중기 (6개월 내)
|
||||
1. **Option B** (결제대행) 검토
|
||||
2. 해외 결제대행사 계약
|
||||
3. 몽골 규제 환경 조사
|
||||
|
||||
### 장기 (1년 이상)
|
||||
1. 디지털자산기본법 시행 후 재검토
|
||||
2. **Option A** (VASP 등록) 또는 **Option C** (해외법인) 결정
|
||||
|
||||
---
|
||||
|
||||
## 15. 법적 검토 참고 자료
|
||||
|
||||
- [한국 가상자산법과 스테이블코인 전망](https://bd-notes2155.com/blog/2025/11/13/korea-virtual-asset-law-stablecoin-outlook-2026/)
|
||||
- [Korea Digital Asset Basic Act](https://www.thekoreanlawblog.com/2025/09/korean-digital-asset-basic-act.html)
|
||||
- [Korea Stablecoin Regulation Framework](https://law.asia/korea-stablecoin-regulation-framework/)
|
||||
- [가상자산 사업자 등록 절차](https://www.coinbro.blog/2025/06/vasp-registration-guide.html)
|
||||
- [Korea Foreign Exchange Transactions Regulations](https://www.lexology.com/library/detail.aspx?g=e4901a9c-d89b-4337-9d09-1e9d01fc4ccf)
|
||||
- [South Korea Cross-Border Crypto Monitoring](https://www.blockhead.co/2024/10/28/south-korea-to-monitor-cross-border-crypto-transactions/)
|
||||
- [가상자산 과세 유예 2027년](https://kbthink.com/crypto/crypto-tax.html)
|
||||
- [특금법 VASP 갱신 심사 지연](https://www.ajunews.com/view/20251202082307835)
|
||||
|
||||
---
|
||||
|
||||
*작성일: 2025-12-15*
|
||||
*법적 검토 추가: 2025-12-15*
|
||||
592
PROGRESS_ReadMe.md
Normal file
592
PROGRESS_ReadMe.md
Normal file
@@ -0,0 +1,592 @@
|
||||
# MongolCar 프로젝트 통합 진행 보고서
|
||||
|
||||
> 마지막 업데이트: 2025-12-07
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [프로젝트 개요](#1-프로젝트-개요)
|
||||
2. [서버 인프라](#2-서버-인프라)
|
||||
3. [MongolCar 플랫폼](#3-mongolcar-플랫폼)
|
||||
4. [Carmodoo Agent](#4-carmodoo-agent)
|
||||
5. [Grantech 사이트](#5-grantech-사이트)
|
||||
6. [파일 서버](#6-파일-서버)
|
||||
7. [작업 일지](#7-작업-일지)
|
||||
8. [해결된 기술적 이슈](#8-해결된-기술적-이슈)
|
||||
9. [TODO 목록](#9-todo-목록)
|
||||
10. [계정 정보](#10-계정-정보)
|
||||
11. [명령어 참고](#11-명령어-참고)
|
||||
|
||||
---
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
몽골 중고차 수출 플랫폼 (MongolCar) 개발 프로젝트
|
||||
|
||||
- 한국 중고차 딜러 시스템(카모두)에서 차량 데이터 추출
|
||||
- 몽골 바이어에게 차량 정보 제공
|
||||
- 수출 프로세스 관리 (컨테이너, 선적, 통관 등)
|
||||
- 다국어 지원 (한국어, 영어, 몽골어, 러시아어)
|
||||
- CC 코인 시스템 (차량 상세정보 조회용)
|
||||
|
||||
---
|
||||
|
||||
## 2. 서버 인프라
|
||||
|
||||
### 2.1 서버 구성
|
||||
|
||||
| 서버 | IP | CPU | RAM | SSD | 역할 | 상태 |
|
||||
|------|-----|-----|-----|-----|------|------|
|
||||
| Server1 | 192.168.0.201 | Ryzen 7700 | 64GB | 2TB | Master (DB, Proxy, Monitoring) | ✅ 완료 |
|
||||
| Server2 | 192.168.0.202 | Ryzen 7700 | 64GB | 1TB | MongolCar (autonetsellcar.com) | ✅ 완료 |
|
||||
| Server3 | 192.168.0.203 | Ryzen 7700 | 64GB | 1TB + 10TB HDD | Grantech, 파일서버 | ✅ 완료 |
|
||||
|
||||
### 2.2 운영 사이트
|
||||
|
||||
| 도메인 | 용도 | 서버 | 상태 |
|
||||
|--------|------|------|------|
|
||||
| https://autonetsellcar.com | 몽골 중고차 수출 플랫폼 | Server2 | ✅ |
|
||||
| http://grantech.kr | Grantech 기업 사이트 | Server3 | ✅ |
|
||||
| http://api.grantech.kr | Grantech API | Server3 | ✅ |
|
||||
| http://cloud.grantech.kr | Nextcloud 파일 서버 | Server3 | ✅ |
|
||||
|
||||
### 2.3 Server1 (Master) 설치된 서비스
|
||||
|
||||
| 서비스 | 포트 | 용도 | 접속 URL |
|
||||
|--------|------|------|----------|
|
||||
| Nginx Proxy Manager | 80, 443, 81 | 리버스 프록시, SSL | http://192.168.0.201:81 |
|
||||
| PostgreSQL | 5432 | 데이터베이스 | - |
|
||||
| Redis | 6379 | 캐시/세션 | - |
|
||||
| Portainer | 9000 | Docker 관리 | http://192.168.0.201:9000 |
|
||||
| Prometheus | 9090 | 메트릭 수집 | http://192.168.0.201:9090 |
|
||||
| Grafana | 3100 | 모니터링 대시보드 | http://192.168.0.201:3100 |
|
||||
|
||||
### 2.4 생성된 데이터베이스
|
||||
|
||||
- `mongolcar` - MongolCar 서비스용
|
||||
- `grantech` - Grantech 서비스용
|
||||
- `cylinx` - Cylinx 서비스용
|
||||
|
||||
### 2.5 네트워크 구성
|
||||
|
||||
```
|
||||
공인 IP: 59.14.158.123
|
||||
도메인: grantech.kr (기존), autonetsellcar.com (신규)
|
||||
|
||||
SSH 포트포워딩:
|
||||
- grantech.kr:201 → 192.168.0.201:22 (Server1)
|
||||
- grantech.kr:202 → 192.168.0.202:22 (Server2)
|
||||
- grantech.kr:203 → 192.168.0.203:22 (Server3)
|
||||
|
||||
웹 포트포워딩:
|
||||
- 80 → 192.168.0.201:80
|
||||
- 443 → 192.168.0.201:443
|
||||
- 81 → 192.168.0.201:81
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. MongolCar 플랫폼
|
||||
|
||||
### 3.1 기술 스택
|
||||
|
||||
**Backend:**
|
||||
- Python 3.11
|
||||
- FastAPI
|
||||
- SQLAlchemy (ORM)
|
||||
- Pydantic (검증)
|
||||
- SQLite (개발) / PostgreSQL (운영)
|
||||
|
||||
**Frontend:**
|
||||
- Next.js 14 (App Router)
|
||||
- TypeScript
|
||||
- Tailwind CSS
|
||||
- Zustand (상태관리)
|
||||
- Axios (HTTP 클라이언트)
|
||||
|
||||
### 3.2 주요 기능
|
||||
|
||||
#### 다국어 지원 (i18n)
|
||||
- 한국어 (ko)
|
||||
- 영어 (en)
|
||||
- 몽골어 (mn)
|
||||
- 러시아어 (ru)
|
||||
|
||||
#### CC 코인 시스템
|
||||
- 신규 가입 시 3 CC 무료 지급
|
||||
- 1 USDC = 10 CC
|
||||
- 차량 상세정보 조회: 1 CC
|
||||
- 무료 미리보기: 사진 2장
|
||||
|
||||
#### 차량 요청 검색 기능 (2025-12-07 구현)
|
||||
카모두 스타일 확장 필터:
|
||||
- 제조사
|
||||
- 모델
|
||||
- 등급(그레이드)
|
||||
- 연식 (시작 ~ 종료)
|
||||
- 주행거리 (만km 단위)
|
||||
- 가격 (만원 단위)
|
||||
- 연료 (가솔린, 디젤, 하이브리드, 전기, LPG)
|
||||
- 변속기 (자동, 수동, 세미오토, CVT)
|
||||
|
||||
검색 시 10초 대기 + 진행률 표시 후 결과 표시
|
||||
|
||||
#### 가격 마진 계산
|
||||
- 한국 딜러 마진: 5%
|
||||
- 몽골 딜러 마진: 5% (한국 마진 적용 후)
|
||||
- 총 마크업: 약 10.25%
|
||||
|
||||
### 3.3 API 엔드포인트
|
||||
|
||||
```
|
||||
GET /api/cars/ # 차량 목록
|
||||
GET /api/cars/{id} # 차량 상세
|
||||
POST /api/cars/ # 차량 등록
|
||||
PUT /api/cars/{id} # 차량 수정
|
||||
DELETE /api/cars/{id} # 차량 삭제
|
||||
GET /api/cars/makers/ # 제조사 목록
|
||||
GET /api/cars/models/ # 모델 목록
|
||||
|
||||
POST /api/auth/register # 회원가입
|
||||
POST /api/auth/login # 로그인
|
||||
GET /api/auth/me # 내 정보
|
||||
|
||||
POST /api/inquiries/ # 문의 등록
|
||||
GET /api/inquiries/ # 문의 목록
|
||||
|
||||
GET /api/carmodoo/makers # 카모두 제조사 목록
|
||||
GET /api/carmodoo/models/{code} # 카모두 모델 목록
|
||||
GET /api/carmodoo/grades # 등급 목록
|
||||
GET /api/carmodoo/request-search # 차량 요청 검색 (마진 포함)
|
||||
|
||||
GET /api/cc/balance # CC 잔액 조회
|
||||
POST /api/cc/purchase-view # 차량 상세정보 구매
|
||||
GET /api/cc/check-view/{car_id} # 구매 여부 확인
|
||||
|
||||
GET /api/hero-banners # 히어로 배너 목록
|
||||
GET /api/translations # 번역 데이터
|
||||
```
|
||||
|
||||
### 3.4 페이지 구성
|
||||
|
||||
```
|
||||
/ # 홈페이지 (히어로 슬라이더, 최신 차량)
|
||||
/cars # 차량 목록 (필터, 페이지네이션)
|
||||
/cars/[id] # 차량 상세 (CC 결제 시스템)
|
||||
/request # 차량 요청 검색 (확장 필터)
|
||||
/login # 로그인
|
||||
/register # 회원가입
|
||||
/admin # 관리자 대시보드
|
||||
/admin/cars # 차량 관리
|
||||
/admin/hero-banners # 히어로 배너 관리
|
||||
/admin/translations # 번역 관리
|
||||
```
|
||||
|
||||
### 3.5 데이터베이스 모델
|
||||
|
||||
- User (사용자) - cc_balance 포함
|
||||
- CarMaker (제조사)
|
||||
- CarModel (모델)
|
||||
- Car (차량)
|
||||
- CarImage (차량 이미지)
|
||||
- CarOption (차량 옵션)
|
||||
- CarView (구매한 차량 조회 기록)
|
||||
- Inquiry (문의)
|
||||
- HeroBanner (히어로 배너)
|
||||
- Translation (번역)
|
||||
|
||||
---
|
||||
|
||||
## 4. Carmodoo Agent
|
||||
|
||||
### 4.1 프로젝트 구조
|
||||
|
||||
```
|
||||
agent/
|
||||
├── config.yaml # 설정 파일
|
||||
├── .env # 환경 변수 (로그인 정보)
|
||||
├── requirements.txt # Python 의존성
|
||||
├── run_agent.py # 실행 스크립트
|
||||
├── src/
|
||||
│ ├── config.py # 설정 관리
|
||||
│ ├── logger.py # 로깅 설정
|
||||
│ ├── carmodoo_client.py # 카모두 API 클라이언트 (핵심)
|
||||
│ ├── api_client.py # MongolCar 서버 API 클라이언트
|
||||
│ ├── agent.py # 메인 에이전트 클래스
|
||||
│ └── main.py # CLI 엔트리포인트
|
||||
├── data/
|
||||
│ └── makers.json # 제조사/모델 데이터 (154개 제조사)
|
||||
└── logs/ # 로그 파일
|
||||
```
|
||||
|
||||
### 4.2 구현된 기능
|
||||
|
||||
1. **로그인**: 카모두 딜러 시스템 인증
|
||||
2. **제조사/모델 조회**: 154개 제조사, 각 제조사별 모델 목록
|
||||
3. **차량 상세 조회**: 암호화 키 기반 차량 정보 조회
|
||||
4. **이미지 다운로드**: 차량 이미지 일괄 다운로드
|
||||
5. **세션 유지**: 자동 세션 갱신
|
||||
|
||||
### 4.3 카모두 API 엔드포인트
|
||||
|
||||
| 엔드포인트 | 용도 |
|
||||
|-----------|------|
|
||||
| `POST /member/login_ok.html` | 로그인 |
|
||||
| `POST /common/ajax/sessionHold.html` | 세션 유지 |
|
||||
| `GET /common/ajax/AutoDBCode.html` | 제조사/모델 코드 |
|
||||
| `GET /common/ajax/AutoDBProc.html` | 차량 목록 |
|
||||
| `GET /common/ajax/AutoDB.html` | 차량 상세 |
|
||||
| `GET /data/__carPhoto/{path}` | 차량 이미지 |
|
||||
|
||||
### 4.4 이미지 URL 패턴
|
||||
|
||||
```
|
||||
http://dealer.carmodoo.com/data/__carPhoto/{3자리}/{3자리}/{3자리}/cmcar_{순번}.jpg
|
||||
예: 차량번호 6320434 → /006/320/434/cmcar_0.jpg
|
||||
```
|
||||
|
||||
### 4.5 CLI 명령어
|
||||
|
||||
```bash
|
||||
# 연결 테스트
|
||||
python run_agent.py test
|
||||
|
||||
# 제조사/모델 목록 가져오기
|
||||
python run_agent.py fetch-makers -o ./data/makers.json
|
||||
|
||||
# 차량 상세 정보 조회
|
||||
python run_agent.py get-detail "ENCRYPTED_KEY"
|
||||
|
||||
# 차량 이미지 다운로드
|
||||
python run_agent.py download-images 6320434 -o ./downloads --max 20
|
||||
|
||||
# 에이전트 실행 (서버 연동)
|
||||
python run_agent.py run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Grantech 사이트
|
||||
|
||||
### 5.1 Server3 설정
|
||||
|
||||
- **운영체제**: Ubuntu 22.04.5 Server
|
||||
- **호스트명**: server3
|
||||
|
||||
### 5.2 설치된 서비스
|
||||
|
||||
| 서비스 | 포트 | 용도 |
|
||||
|--------|------|------|
|
||||
| Grantech Frontend | 3001 | Next.js 웹사이트 |
|
||||
| Grantech Backend | 8001 | FastAPI REST API |
|
||||
|
||||
### 5.3 접속 URL
|
||||
|
||||
| 서비스 | URL |
|
||||
|--------|-----|
|
||||
| Grantech 웹사이트 | http://grantech.kr |
|
||||
| Grantech API | http://api.grantech.kr |
|
||||
| Grantech Admin | http://grantech.kr/admin/login |
|
||||
|
||||
---
|
||||
|
||||
## 6. 파일 서버
|
||||
|
||||
### 6.1 아키텍처
|
||||
|
||||
```
|
||||
Server3 (192.168.0.203) - 파일 서버
|
||||
│
|
||||
├── 10TB HDD (/dev/sda1 - XFS)
|
||||
│ Mount: /data
|
||||
│ ├── /data/share (공용폴더, 777 권한)
|
||||
│ └── /data/damon (개인폴더, 700 권한)
|
||||
│
|
||||
├── Samba (Port 445)
|
||||
│ ├── \\192.168.0.203\share (공용)
|
||||
│ └── \\192.168.0.203\damon (개인)
|
||||
│
|
||||
└── Nextcloud (Docker - Port 8080)
|
||||
└── cloud.grantech.kr
|
||||
```
|
||||
|
||||
### 6.2 접속 정보
|
||||
|
||||
| 서비스 | 접속 방법 | 사용자 |
|
||||
|--------|-----------|--------|
|
||||
| Samba 공용 | \\192.168.0.203\share | damon, grantech_YWJ |
|
||||
| Samba 개인 | \\192.168.0.203\damon | damon만 |
|
||||
| Nextcloud | http://cloud.grantech.kr | admin |
|
||||
|
||||
### 6.3 네트워크 드라이브 매핑
|
||||
|
||||
| 드라이브 | 경로 | 용도 |
|
||||
|----------|------|------|
|
||||
| X: | \\192.168.0.203\share | 공용 파일 |
|
||||
| Y: | \\192.168.0.203\damon | 개인 파일 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 작업 일지
|
||||
|
||||
### 2025-11-27
|
||||
- 카모두 API 역공학 분석 완료
|
||||
- Python Agent 개발 완료
|
||||
- 제조사 154개, 모델 목록 동기화 성공
|
||||
|
||||
### 2025-11-28
|
||||
- Server1 (Master) Docker 환경 구축
|
||||
- PostgreSQL, Redis, Nginx Proxy Manager 설치
|
||||
- Server2 MongolCar Backend 배포
|
||||
|
||||
### 2025-11-29
|
||||
- MongolCar Frontend (Next.js) 개발
|
||||
- Carmodoo Agent 연동
|
||||
- 공유기 포트포워딩 설정
|
||||
- autonetsellcar.com DNS 설정 및 SSL 인증서 발급
|
||||
|
||||
### 2025-12-05
|
||||
- Server3 환경 구축 및 Grantech 사이트 배포
|
||||
- 파일 서버 구축 (10TB HDD, Samba, Nextcloud)
|
||||
- Grantech Admin 로그인 문제 해결
|
||||
- CORS 설정 및 API 연동
|
||||
|
||||
### 2025-12-06 ~ 2025-12-07
|
||||
- MongolCar 다국어 지원 (EN, MN, RU, KO)
|
||||
- CC 코인 시스템 구현
|
||||
- 히어로 배너 관리 기능
|
||||
- 차량 요청 검색 기능 (카모두 스타일 확장 필터)
|
||||
- 가격 마진 계산 (한국 5% + 몽골 5%)
|
||||
- 데이터베이스 마이그레이션 (cc_balance, car_views 테이블)
|
||||
|
||||
### 2025-12-07 (추가 작업)
|
||||
**문제 발견 및 해결: 카모두 API 페이징 이슈**
|
||||
|
||||
#### 문제 상황
|
||||
- 사용자 보고: "기아 K5 전체등급 21~22년 1만~5만Km 검색하면 카모두에서는 수백건이 나오는데 여기서는 2건 나오네"
|
||||
- 캐시에 K5 차량이 42건만 저장됨 (실제로는 수백 건 존재)
|
||||
|
||||
#### 원인 분석
|
||||
1. 카모두 API `_inc_carListPhoto.html` 엔드포인트가 `sf_page` 파라미터를 무시
|
||||
2. 모든 페이지에서 동일한 50건만 반환 (중복된 차량 ID)
|
||||
3. `page_size=100` 요청해도 50건만 응답
|
||||
|
||||
#### 해결책: 연도별 분할 검색 (Year-Segmented Fetching)
|
||||
- `carmodoo.py`에 `search_cars_by_year_segment()` 메서드 추가
|
||||
- 2010년부터 현재까지 연도별로 개별 검색
|
||||
- 중복 제거 후 병합
|
||||
- `cache_service.py`에서 새 방식 사용
|
||||
|
||||
#### 결과 비교
|
||||
| 항목 | 이전 | 현재 | 개선율 |
|
||||
|------|------|------|--------|
|
||||
| K5 전체 캐시 | 42건 | **769건** | **18배** |
|
||||
| 2021-2022년 검색 | 2건 | **90건** | **45배** |
|
||||
| 2021-2022년 + 1만-5만Km | 2건 | **28건** | **14배** |
|
||||
|
||||
#### 수정된 파일
|
||||
- `backend/app/api/carmodoo.py` - `search_cars_by_year_segment()` 메서드 추가
|
||||
- `backend/app/services/cache_service.py` - `fetch_all_cars_for_cache()` 연도별 분할 사용
|
||||
|
||||
#### 테스트 스크립트 생성
|
||||
- `test_carmodoo_pagination.py` - 페이징 문제 검증
|
||||
- `test_year_segmented.py` - 연도별 분할 검색 테스트
|
||||
|
||||
---
|
||||
|
||||
## 8. 해결된 기술적 이슈
|
||||
|
||||
### 인프라 관련
|
||||
| 이슈 | 해결 방법 |
|
||||
|------|-----------|
|
||||
| DNS 해결 실패 | netplan에 nameservers 추가 (8.8.8.8, 8.8.4.4) |
|
||||
| Grafana 재시작 반복 | 데이터 디렉토리 권한 수정 (472:472) |
|
||||
| Prometheus 재시작 반복 | 데이터 디렉토리 권한 수정 (65534:65534) |
|
||||
| DB 비밀번호 @ 문자 | URL 파싱 문제로 @ 제거 |
|
||||
| Docker ContainerConfig 오류 | --remove-orphans와 system prune |
|
||||
| 헤어핀 NAT 미지원 | hosts 파일에 내부 IP 매핑 |
|
||||
|
||||
### 백엔드 관련
|
||||
| 이슈 | 해결 방법 |
|
||||
|------|-----------|
|
||||
| email-validator 누락 | requirements.txt에 추가 |
|
||||
| XML 파싱 인코딩 오류 | _clean_xml_bytes() 메서드 추가 |
|
||||
| no such column: users.cc_balance | ALTER TABLE로 컬럼 추가 |
|
||||
| 카모두 API 페이징 미작동 | 연도별 분할 검색으로 우회 |
|
||||
| 가격 필터 키 불일치 | `price` vs `original_price` 둘 다 체크 |
|
||||
|
||||
### 프론트엔드 관련
|
||||
| 이슈 | 해결 방법 |
|
||||
|------|-----------|
|
||||
| package-lock.json 누락 | npm ci → npm install 변경 |
|
||||
| Windows 콘솔 한글 출력 | UTF-8 출력 래퍼 추가 |
|
||||
| CORS 에러 | Backend CORS_ORIGINS에 도메인 추가 |
|
||||
| 포트 충돌 | 기존 프로세스 종료 후 재시작 |
|
||||
|
||||
---
|
||||
|
||||
## 8.1 교훈 및 주의사항
|
||||
|
||||
### 외부 API 연동 시 주의사항
|
||||
|
||||
1. **API 문서를 맹신하지 말 것**
|
||||
- 카모두 API의 `sf_page` 파라미터가 실제로는 작동하지 않음
|
||||
- 항상 실제 응답을 검증하고 테스트해야 함
|
||||
|
||||
2. **중복 데이터 검증 필수**
|
||||
- 페이지네이션 결과가 중복될 수 있음 (같은 차량 ID 반복)
|
||||
- `set()`를 사용해 중복 제거 필수
|
||||
|
||||
3. **대안 전략 마련**
|
||||
- 페이징이 안 되면 다른 조건(연도, 가격대 등)으로 분할 검색
|
||||
- Rate limiting 고려 (0.3초 딜레이 적용)
|
||||
|
||||
### 캐싱 시스템 주의사항
|
||||
|
||||
1. **캐시 키 설계**
|
||||
- 제조사+모델 조합으로 고유 키 생성 (`maker_code_model_code`)
|
||||
- 필터 조건은 캐시 후 메모리에서 적용
|
||||
|
||||
2. **캐시 갱신 트리거**
|
||||
- TTL 만료 시 자동 삭제 (2시간)
|
||||
- 신규 검색 시 캐시 Miss → 전체 데이터 수집
|
||||
|
||||
3. **동시 요청 처리**
|
||||
- asyncio.Lock으로 동일 캐시 키에 대한 중복 요청 병합
|
||||
- Event로 완료 대기 처리
|
||||
|
||||
### 필터링 시 주의사항
|
||||
|
||||
1. **키 이름 불일치 문제**
|
||||
- 원본 데이터: `price`
|
||||
- 변환 후 데이터: `original_price`
|
||||
- 둘 다 체크하도록 구현: `c.get('price') or c.get('original_price')`
|
||||
|
||||
2. **값 매핑 필요**
|
||||
- 연료: 프론트엔드 '가솔린' → 카모두 '휘발유'
|
||||
- 변속기: 프론트엔드 '자동' → 카모두 '오토'
|
||||
|
||||
---
|
||||
|
||||
## 9. TODO 목록
|
||||
|
||||
### 긴급 (즉시 수행)
|
||||
- [x] 백엔드 서버 재시작 (새 API 반영) ✅ 2025-12-07 완료
|
||||
- [x] 차량 요청 검색 기능 테스트 ✅ 2025-12-07 완료
|
||||
- [x] 카모두 API 페이징 이슈 해결 ✅ 2025-12-07 완료
|
||||
|
||||
### 단기 (이번 주)
|
||||
- [ ] grantech.kr SSL 인증서 발급
|
||||
- [ ] api.grantech.kr SSL 인증서 발급
|
||||
- [ ] cloud.grantech.kr SSL 인증서 발급
|
||||
- [ ] api.autonetsellcar.com Proxy Host 추가
|
||||
- [ ] 백업 스크립트 설정
|
||||
|
||||
### 중기 (이번 달)
|
||||
- [ ] cylinx.kr 배포
|
||||
- [ ] 서비스 자동 시작 설정 (systemd)
|
||||
- [ ] 차량 검색 결과에서 실제 카모두 API 연동
|
||||
- [ ] CC 코인 결제 시스템 완성
|
||||
- [ ] 차량 상세정보 잠금/해제 UI 완성
|
||||
|
||||
### 장기 (다음 달)
|
||||
- [ ] 다중 소스 지원 (다른 중고차 플랫폼)
|
||||
- [ ] 자동화 시스템 (주기적 데이터 동기화)
|
||||
- [ ] 견적서 자동 생성
|
||||
- [ ] 수출 비용 계산기
|
||||
- [ ] 환율 연동
|
||||
|
||||
---
|
||||
|
||||
## 10. 계정 정보
|
||||
|
||||
### Server1 서비스
|
||||
| 서비스 | 계정 | 비고 |
|
||||
|--------|------|------|
|
||||
| Nginx Proxy Manager | 설정한 이메일/비밀번호 | 첫 로그인시 변경 |
|
||||
| Portainer | 설정한 계정 | 첫 접속시 생성 |
|
||||
| Grafana | admin / .env의 GRAFANA_PASSWORD | |
|
||||
| PostgreSQL | admin / roskfl1122 | |
|
||||
| Redis | roskfl@1122 (비밀번호만) | |
|
||||
|
||||
### MongolCar
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| Admin Email | admin@example.com |
|
||||
| Admin Password | admin123 |
|
||||
|
||||
### Grantech
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| Admin Username | admin |
|
||||
| Admin Password | grantech2024 |
|
||||
|
||||
### Carmodoo
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| User ID | 01033315258 |
|
||||
| Password | alskfl@1122 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 명령어 참고
|
||||
|
||||
### Server1 관리
|
||||
```bash
|
||||
cd ~/master && docker-compose ps # 컨테이너 상태
|
||||
docker logs postgres-primary # 로그 확인
|
||||
docker-compose restart # 재시작
|
||||
docker exec -it postgres-primary psql -U admin -d mongolcar # DB 접속
|
||||
```
|
||||
|
||||
### Server2 관리
|
||||
```bash
|
||||
cd ~/mongolcar && docker-compose ps # 컨테이너 상태
|
||||
docker logs mongolcar-backend # Backend 로그
|
||||
docker-compose restart backend # Backend 재시작
|
||||
docker-compose build backend && docker-compose up -d backend # 재빌드
|
||||
```
|
||||
|
||||
### Server3 관리
|
||||
```bash
|
||||
ps aux | grep -E "npm|uvicorn" # 프로세스 확인
|
||||
sudo netstat -tlnp | grep 3001 # 포트 확인
|
||||
sudo kill -9 <PID> # 프로세스 종료
|
||||
nohup sh -c 'PORT=3001 npm start' > ~/logs/frontend.log 2>&1 & # Frontend 시작
|
||||
```
|
||||
|
||||
### Windows 로컬 개발
|
||||
```bash
|
||||
# Backend 시작
|
||||
cd D:\Workspace\claudeCode\AutonetSellCar.com\backend
|
||||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
# Frontend 시작
|
||||
cd D:\Workspace\claudeCode\AutonetSellCar.com\frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 파일 전송
|
||||
```bash
|
||||
scp -r D:\Workspace\claudeCode\AutonetSellCar.com\backend damon@192.168.0.202:~/mongolcar/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Windows 로컬 파일 위치
|
||||
|
||||
```
|
||||
D:\Workspace\claudeCode\AutonetSellCar.com\
|
||||
├── backend/ # FastAPI Backend
|
||||
├── frontend/ # Next.js Frontend
|
||||
├── agent/ # Carmodoo 연동 에이전트
|
||||
├── _legacy_agent/ # 원본 분석 스크립트 (백업)
|
||||
├── PROGRESS_ReadMe.md # 이 파일 (통합 진행 보고서)
|
||||
├── mongol-car-platform-plan.md # 전체 프로젝트 계획
|
||||
└── SERVER_INFRASTRUCTURE_PLAN.md # 서버 인프라 계획
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Generated by Claude Code - Last updated: 2025-12-07*
|
||||
611
SERVER_INFRASTRUCTURE_PLAN.md
Normal file
611
SERVER_INFRASTRUCTURE_PLAN.md
Normal file
@@ -0,0 +1,611 @@
|
||||
# MongolCar 서버 인프라 구성 계획
|
||||
|
||||
## 1. 서버 현황
|
||||
|
||||
| 서버 | IP | CPU | RAM | SSD | 상태 |
|
||||
|------|-----|-----|-----|-----|------|
|
||||
| Server1 | 192.168.0.201 | Ryzen 7700 | 64GB | 2TB | 신규 설치 |
|
||||
| Server2 | 192.168.0.202 | Ryzen 7700 | 64GB | 확인 필요 | 정리 필요 |
|
||||
| Server3 | 192.168.0.203 | Ryzen 7700 | 64GB | 확인 필요 | 정리 필요 |
|
||||
|
||||
## 2. 운영 사이트
|
||||
|
||||
| 도메인 | 용도 |
|
||||
|--------|------|
|
||||
| www.autonetsellcar.com | 몽골 중고차 수출 플랫폼 (MongolCar) |
|
||||
| www.grantech.kr | Grantech 기업 사이트 |
|
||||
| www.cylinx.kr | Cylinx 기업 사이트 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 권장 아키텍처: Master-Worker 구성
|
||||
|
||||
### 3.1 서버 역할 분배
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Server1 (192.168.0.201) │
|
||||
│ [ MASTER NODE ] │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Nginx │ │ PostgreSQL │ │ Redis │ │
|
||||
│ │ Proxy │ │ (Primary) │ │ (Primary) │ │
|
||||
│ │ Manager │ │ │ │ │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Portainer │ │ Grafana │ │ Prometheus │ │
|
||||
│ │ (Docker) │ │ (Monitoring)│ │ (Metrics) │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Server2 (192.168.0.202) │
|
||||
│ [ WORKER NODE 1 ] │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ www.autonetsellcar.com (MongolCar) │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
|
||||
│ │ │ Next.js │ │ FastAPI │ │ Carmodoo │ │ │
|
||||
│ │ │ Frontend │ │ Backend │ │ Agent │ │ │
|
||||
│ │ │ :3000 │ │ :8000 │ │ (Cron Job) │ │ │
|
||||
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ PostgreSQL │ │ Redis │ │
|
||||
│ │ (Replica) │ │ (Replica) │ │
|
||||
│ └─────────────┘ └─────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Server3 (192.168.0.203) │
|
||||
│ [ WORKER NODE 2 ] │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ www.grantech.kr │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ │ │
|
||||
│ │ │ Next.js │ │ FastAPI │ │ │
|
||||
│ │ │ Frontend │ │ Backend │ │ │
|
||||
│ │ │ :3001 │ │ :8001 │ │ │
|
||||
│ │ └─────────────┘ └─────────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ www.cylinx.kr │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ │ │
|
||||
│ │ │ Next.js │ │ FastAPI │ │ │
|
||||
│ │ │ Frontend │ │ Backend │ │ │
|
||||
│ │ │ :3002 │ │ :8002 │ │ │
|
||||
│ │ └─────────────┘ └─────────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ PostgreSQL │ │ Redis │ │
|
||||
│ │ (Replica) │ │ (Replica) │ │
|
||||
│ └─────────────┘ └─────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 네트워크 구성
|
||||
|
||||
### 4.1 외부 접근 (인터넷 → 서버)
|
||||
|
||||
```
|
||||
인터넷
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 공유기/방화벽 │
|
||||
│ (Port Forward) │
|
||||
└─────────────────┘
|
||||
│
|
||||
├── 80/443 ──→ Server1:80/443 (Nginx Proxy Manager)
|
||||
│
|
||||
└── 22 ──→ Server1:22 (SSH - VPN 권장)
|
||||
```
|
||||
|
||||
### 4.2 내부 네트워크
|
||||
|
||||
| 용도 | 포트 | 서버 |
|
||||
|------|------|------|
|
||||
| SSH | 22 | 모든 서버 |
|
||||
| PostgreSQL | 5432 | Server1 (Primary) |
|
||||
| Redis | 6379 | Server1 (Primary) |
|
||||
| Nginx Proxy Manager | 80, 443, 81 | Server1 |
|
||||
| Portainer | 9000 | Server1 |
|
||||
| Grafana | 3100 | Server1 |
|
||||
| Prometheus | 9090 | Server1 |
|
||||
| MongolCar Frontend | 3000 | Server2 |
|
||||
| MongolCar Backend | 8000 | Server2 |
|
||||
| Grantech Frontend | 3001 | Server3 |
|
||||
| Grantech Backend | 8001 | Server3 |
|
||||
| Cylinx Frontend | 3002 | Server3 |
|
||||
| Cylinx Backend | 8002 | Server3 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Docker 컨테이너 구성
|
||||
|
||||
### 5.1 Server1 (Master) - docker-compose.yml
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Reverse Proxy
|
||||
nginx-proxy-manager:
|
||||
image: 'jc21/nginx-proxy-manager:latest'
|
||||
container_name: nginx-proxy-manager
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '80:80'
|
||||
- '443:443'
|
||||
- '81:81' # Admin UI
|
||||
volumes:
|
||||
- ./data/nginx/data:/data
|
||||
- ./data/nginx/letsencrypt:/etc/letsencrypt
|
||||
|
||||
# Database
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: postgres-primary
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: admin
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: mongolcar
|
||||
ports:
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
- ./init-db:/docker-entrypoint-initdb.d
|
||||
|
||||
# Cache
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: redis-primary
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '6379:6379'
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
|
||||
|
||||
# Docker Management
|
||||
portainer:
|
||||
image: portainer/portainer-ce:latest
|
||||
container_name: portainer
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '9000:9000'
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./data/portainer:/data
|
||||
|
||||
# Monitoring
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
container_name: prometheus
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '9090:9090'
|
||||
volumes:
|
||||
- ./config/prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- ./data/prometheus:/prometheus
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: grafana
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3100:3000'
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD}
|
||||
volumes:
|
||||
- ./data/grafana:/var/lib/grafana
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: master-network
|
||||
```
|
||||
|
||||
### 5.2 Server2 (MongolCar) - docker-compose.yml
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# MongolCar Frontend
|
||||
mongolcar-frontend:
|
||||
build: ./mongolcar/frontend
|
||||
container_name: mongolcar-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=http://192.168.0.202:8000
|
||||
depends_on:
|
||||
- mongolcar-backend
|
||||
|
||||
# MongolCar Backend
|
||||
mongolcar-backend:
|
||||
build: ./mongolcar/backend
|
||||
container_name: mongolcar-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '8000:8000'
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://admin:${DB_PASSWORD}@192.168.0.201:5432/mongolcar
|
||||
- REDIS_URL=redis://:${REDIS_PASSWORD}@192.168.0.201:6379/0
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
volumes:
|
||||
- ./data/uploads:/app/uploads
|
||||
|
||||
# Carmodoo Agent (차량 데이터 수집)
|
||||
carmodoo-agent:
|
||||
build: ./carmodoo-agent
|
||||
container_name: carmodoo-agent
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- CARMODOO_USER_ID=${CARMODOO_USER_ID}
|
||||
- CARMODOO_PASSWORD=${CARMODOO_PASSWORD}
|
||||
- API_SERVER_URL=http://mongolcar-backend:8000/api
|
||||
- AGENT_API_KEY=${AGENT_API_KEY}
|
||||
volumes:
|
||||
- ./data/agent:/app/data
|
||||
- ./logs/agent:/app/logs
|
||||
depends_on:
|
||||
- mongolcar-backend
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: mongolcar-network
|
||||
```
|
||||
|
||||
### 5.3 Server3 (Grantech & Cylinx) - docker-compose.yml
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Grantech Frontend
|
||||
grantech-frontend:
|
||||
build: ./grantech/frontend
|
||||
container_name: grantech-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3001:3000'
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=http://192.168.0.203:8001
|
||||
|
||||
# Grantech Backend
|
||||
grantech-backend:
|
||||
build: ./grantech/backend
|
||||
container_name: grantech-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '8001:8000'
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://admin:${DB_PASSWORD}@192.168.0.201:5432/grantech
|
||||
- REDIS_URL=redis://:${REDIS_PASSWORD}@192.168.0.201:6379/1
|
||||
|
||||
# Cylinx Frontend
|
||||
cylinx-frontend:
|
||||
build: ./cylinx/frontend
|
||||
container_name: cylinx-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3002:3000'
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=http://192.168.0.203:8002
|
||||
|
||||
# Cylinx Backend
|
||||
cylinx-backend:
|
||||
build: ./cylinx/backend
|
||||
container_name: cylinx-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '8002:8000'
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://admin:${DB_PASSWORD}@192.168.0.201:5432/cylinx
|
||||
- REDIS_URL=redis://:${REDIS_PASSWORD}@192.168.0.201:6379/2
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: sites-network
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 데이터베이스 설계
|
||||
|
||||
### 6.1 PostgreSQL 데이터베이스 분리
|
||||
|
||||
| 데이터베이스 | 용도 | Redis DB |
|
||||
|-------------|------|----------|
|
||||
| mongolcar | MongolCar 서비스 | 0 |
|
||||
| grantech | Grantech 서비스 | 1 |
|
||||
| cylinx | Cylinx 서비스 | 2 |
|
||||
|
||||
### 6.2 MongolCar 주요 테이블
|
||||
|
||||
```sql
|
||||
-- 제조사
|
||||
CREATE TABLE car_makers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code VARCHAR(10) UNIQUE NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
name_en VARCHAR(100),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 모델
|
||||
CREATE TABLE car_models (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code VARCHAR(10) NOT NULL,
|
||||
maker_id INTEGER REFERENCES car_makers(id),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
name_en VARCHAR(100),
|
||||
UNIQUE(code, maker_id)
|
||||
);
|
||||
|
||||
-- 차량
|
||||
CREATE TABLE cars (
|
||||
id SERIAL PRIMARY KEY,
|
||||
source VARCHAR(50) NOT NULL DEFAULT 'carmodoo',
|
||||
source_id VARCHAR(50) NOT NULL,
|
||||
source_key TEXT, -- encrypted key
|
||||
maker_id INTEGER REFERENCES car_makers(id),
|
||||
model_id INTEGER REFERENCES car_models(id),
|
||||
car_name VARCHAR(200),
|
||||
year INTEGER,
|
||||
month INTEGER,
|
||||
mileage INTEGER,
|
||||
price_krw BIGINT,
|
||||
price_usd DECIMAL(12,2),
|
||||
fuel VARCHAR(20),
|
||||
transmission VARCHAR(20),
|
||||
color VARCHAR(50),
|
||||
displacement INTEGER,
|
||||
car_number VARCHAR(20),
|
||||
seize_count INTEGER DEFAULT 0,
|
||||
collateral_count INTEGER DEFAULT 0,
|
||||
check_num VARCHAR(50),
|
||||
dealer_name VARCHAR(100),
|
||||
dealer_phone VARCHAR(50),
|
||||
shop_name VARCHAR(100),
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
synced_at TIMESTAMP,
|
||||
UNIQUE(source, source_id)
|
||||
);
|
||||
|
||||
-- 차량 이미지
|
||||
CREATE TABLE car_images (
|
||||
id SERIAL PRIMARY KEY,
|
||||
car_id INTEGER REFERENCES cars(id) ON DELETE CASCADE,
|
||||
url VARCHAR(500),
|
||||
local_path VARCHAR(500),
|
||||
is_main BOOLEAN DEFAULT FALSE,
|
||||
sort_order INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
-- 차량 옵션
|
||||
CREATE TABLE car_options (
|
||||
id SERIAL PRIMARY KEY,
|
||||
car_id INTEGER REFERENCES cars(id) ON DELETE CASCADE,
|
||||
option_name VARCHAR(100)
|
||||
);
|
||||
|
||||
-- 사용자 (바이어)
|
||||
CREATE TABLE users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(100),
|
||||
phone VARCHAR(50),
|
||||
country VARCHAR(50) DEFAULT 'Mongolia',
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 문의
|
||||
CREATE TABLE inquiries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
car_id INTEGER REFERENCES cars(id),
|
||||
message TEXT,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX idx_cars_maker ON cars(maker_id);
|
||||
CREATE INDEX idx_cars_model ON cars(model_id);
|
||||
CREATE INDEX idx_cars_price ON cars(price_krw);
|
||||
CREATE INDEX idx_cars_year ON cars(year);
|
||||
CREATE INDEX idx_cars_status ON cars(status);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Nginx Proxy Manager 설정
|
||||
|
||||
### 7.1 Proxy Hosts 설정
|
||||
|
||||
| Domain | Scheme | Forward Host | Forward Port | SSL |
|
||||
|--------|--------|--------------|--------------|-----|
|
||||
| autonetsellcar.com | http | 192.168.0.202 | 3000 | Let's Encrypt |
|
||||
| api.autonetsellcar.com | http | 192.168.0.202 | 8000 | Let's Encrypt |
|
||||
| grantech.kr | http | 192.168.0.203 | 3001 | Let's Encrypt |
|
||||
| api.grantech.kr | http | 192.168.0.203 | 8001 | Let's Encrypt |
|
||||
| cylinx.kr | http | 192.168.0.203 | 3002 | Let's Encrypt |
|
||||
| api.cylinx.kr | http | 192.168.0.203 | 8002 | Let's Encrypt |
|
||||
| portainer.local | http | 192.168.0.201 | 9000 | - |
|
||||
| grafana.local | http | 192.168.0.201 | 3100 | - |
|
||||
|
||||
---
|
||||
|
||||
## 8. 설치 순서
|
||||
|
||||
### Phase 1: Server1 (Master) 설정
|
||||
|
||||
```bash
|
||||
# 1. Docker 설치
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
sudo apt install -y docker.io docker-compose
|
||||
sudo systemctl enable docker
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# 2. 디렉토리 구조 생성
|
||||
mkdir -p ~/master/{data,config,init-db}
|
||||
mkdir -p ~/master/data/{nginx,postgres,redis,portainer,prometheus,grafana}
|
||||
|
||||
# 3. docker-compose.yml 작성 및 실행
|
||||
cd ~/master
|
||||
# docker-compose.yml 파일 생성
|
||||
docker-compose up -d
|
||||
|
||||
# 4. PostgreSQL 초기 DB 생성
|
||||
docker exec -it postgres-primary psql -U admin -c "CREATE DATABASE grantech;"
|
||||
docker exec -it postgres-primary psql -U admin -c "CREATE DATABASE cylinx;"
|
||||
```
|
||||
|
||||
### Phase 2: Server2 (MongolCar) 설정
|
||||
|
||||
```bash
|
||||
# 1. Docker 설치 (동일)
|
||||
# 2. 프로젝트 디렉토리 구조
|
||||
mkdir -p ~/mongolcar/{frontend,backend,carmodoo-agent,data,logs}
|
||||
|
||||
# 3. 코드 배포 및 실행
|
||||
cd ~/mongolcar
|
||||
# docker-compose.yml 파일 생성
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Phase 3: Server3 (Grantech & Cylinx) 설정
|
||||
|
||||
```bash
|
||||
# 1. Docker 설치 (동일)
|
||||
# 2. 프로젝트 디렉토리 구조
|
||||
mkdir -p ~/sites/{grantech,cylinx}
|
||||
mkdir -p ~/sites/grantech/{frontend,backend}
|
||||
mkdir -p ~/sites/cylinx/{frontend,backend}
|
||||
|
||||
# 3. 코드 배포 및 실행
|
||||
cd ~/sites
|
||||
# docker-compose.yml 파일 생성
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Phase 4: DNS 및 SSL 설정
|
||||
|
||||
```bash
|
||||
# 1. 도메인 DNS A 레코드 설정 (공인 IP로)
|
||||
# autonetsellcar.com → 공인IP
|
||||
# grantech.kr → 공인IP
|
||||
# cylinx.kr → 공인IP
|
||||
|
||||
# 2. 공유기 포트포워딩
|
||||
# 80 → 192.168.0.201:80
|
||||
# 443 → 192.168.0.201:443
|
||||
|
||||
# 3. Nginx Proxy Manager에서 SSL 인증서 발급
|
||||
# http://192.168.0.201:81 접속
|
||||
# Proxy Hosts 추가 및 Let's Encrypt SSL 설정
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 백업 전략
|
||||
|
||||
### 9.1 자동 백업 스크립트 (Server1)
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# /home/user/backup.sh
|
||||
|
||||
BACKUP_DIR="/backup/$(date +%Y%m%d)"
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
# PostgreSQL 백업
|
||||
docker exec postgres-primary pg_dumpall -U admin > $BACKUP_DIR/postgres_all.sql
|
||||
|
||||
# Redis 백업
|
||||
docker exec redis-primary redis-cli -a $REDIS_PASSWORD BGSAVE
|
||||
cp ~/master/data/redis/dump.rdb $BACKUP_DIR/
|
||||
|
||||
# 7일 이상 된 백업 삭제
|
||||
find /backup -type d -mtime +7 -exec rm -rf {} \;
|
||||
```
|
||||
|
||||
### 9.2 Cron 설정
|
||||
|
||||
```bash
|
||||
# 매일 새벽 3시 백업
|
||||
0 3 * * * /home/user/backup.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 모니터링 항목
|
||||
|
||||
### 10.1 Prometheus 수집 대상
|
||||
|
||||
- Node Exporter (각 서버 시스템 메트릭)
|
||||
- PostgreSQL Exporter
|
||||
- Redis Exporter
|
||||
- Docker Container 메트릭
|
||||
- Nginx 메트릭
|
||||
|
||||
### 10.2 Grafana 대시보드
|
||||
|
||||
- 서버 리소스 (CPU, Memory, Disk, Network)
|
||||
- 컨테이너 상태
|
||||
- 데이터베이스 연결/쿼리 성능
|
||||
- API 응답시간
|
||||
- 에러율
|
||||
|
||||
---
|
||||
|
||||
## 11. 보안 체크리스트
|
||||
|
||||
- [ ] SSH 키 기반 인증 설정
|
||||
- [ ] 방화벽 (UFW) 설정
|
||||
- [ ] Fail2ban 설치
|
||||
- [ ] 불필요한 포트 차단
|
||||
- [ ] PostgreSQL 외부 접근 제한
|
||||
- [ ] Redis 비밀번호 설정
|
||||
- [ ] HTTPS 강제 리다이렉트
|
||||
- [ ] 환경변수로 비밀정보 관리
|
||||
- [ ] 정기 보안 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 12. 예상 리소스 사용량
|
||||
|
||||
| 서버 | CPU 예상 | RAM 예상 | Disk 예상 |
|
||||
|------|----------|----------|-----------|
|
||||
| Server1 (Master) | 10-20% | 8-12GB | 100GB+ |
|
||||
| Server2 (MongolCar) | 20-40% | 8-16GB | 500GB+ (이미지) |
|
||||
| Server3 (Sites) | 10-30% | 4-8GB | 50GB |
|
||||
|
||||
---
|
||||
|
||||
## 13. 다음 단계
|
||||
|
||||
1. **Server2, Server3 SSD 용량 확인**
|
||||
2. **Server1 Docker 환경 구축**
|
||||
3. **MongolCar Backend API 개발 (FastAPI)**
|
||||
4. **MongolCar Frontend 개발 (Next.js)**
|
||||
5. **Carmodoo Agent Docker화**
|
||||
6. **DNS 설정 및 SSL 인증서 발급**
|
||||
|
||||
---
|
||||
|
||||
*Generated by Claude Code - 2025-11-28*
|
||||
22
agent/Dockerfile
Normal file
22
agent/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
libxml2-dev \
|
||||
libxslt1-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create data and logs directories
|
||||
RUN mkdir -p /app/data /app/logs
|
||||
|
||||
CMD ["python", "-m", "src.sync_agent"]
|
||||
6
agent/requirements.txt
Normal file
6
agent/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
aiohttp>=3.9.0
|
||||
aiofiles>=23.2.1
|
||||
lxml>=5.1.0
|
||||
pyyaml>=6.0.1
|
||||
python-dotenv>=1.0.0
|
||||
httpx>=0.26.0
|
||||
1
agent/src/__init__.py
Normal file
1
agent/src/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Carmodoo Agent
|
||||
294
agent/src/carmodoo_client.py
Normal file
294
agent/src/carmodoo_client.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
Carmodoo API Client - HTTP based car data extraction
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import logging
|
||||
from typing import Optional, Dict, List, Any
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
import aiohttp
|
||||
from lxml import etree
|
||||
|
||||
|
||||
@dataclass
|
||||
class CarmodooConfig:
|
||||
base_url: str = "https://dealer.carmodoo.com"
|
||||
check_url: str = "https://ck.carmodoo.com"
|
||||
encoding: str = "euc-kr"
|
||||
user_id: str = ""
|
||||
password: str = ""
|
||||
request_timeout: int = 30
|
||||
request_delay: float = 0.5
|
||||
max_retries: int = 3
|
||||
retry_delay: int = 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class CarMaker:
|
||||
code: str
|
||||
name: str
|
||||
cho: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CarModel:
|
||||
code: str
|
||||
name: str
|
||||
maker_code: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CarDetail:
|
||||
car_no: str
|
||||
car_name: str
|
||||
maker_code: str
|
||||
model_code: str
|
||||
year: int
|
||||
month: Optional[int]
|
||||
mileage: int
|
||||
price: int
|
||||
fuel: str
|
||||
transmission: str
|
||||
color: str
|
||||
displacement: int
|
||||
car_number: str
|
||||
seize_count: int
|
||||
collateral_count: int
|
||||
options: List[str]
|
||||
memo: str
|
||||
dealer_memo: str
|
||||
main_image: str
|
||||
images: List[str]
|
||||
thumbnails: List[str]
|
||||
check_num: str
|
||||
check_url: str
|
||||
check_gubun: str
|
||||
dealer_name: str
|
||||
dealer_phone: str
|
||||
shop_name: str
|
||||
shop_tel: str
|
||||
raw_data: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class CarmodooClient:
|
||||
def __init__(self, config: CarmodooConfig):
|
||||
self.config = config
|
||||
self.logger = logging.getLogger('carmodoo')
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
self.cookies: Dict[str, str] = {}
|
||||
self.is_logged_in = False
|
||||
self.last_session_refresh = None
|
||||
|
||||
self.headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36',
|
||||
'Accept-Language': 'ko-KR,ko;q=0.9',
|
||||
}
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.create_session()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.close()
|
||||
|
||||
async def create_session(self):
|
||||
if self.session is None or self.session.closed:
|
||||
timeout = aiohttp.ClientTimeout(total=self.config.request_timeout)
|
||||
self.session = aiohttp.ClientSession(timeout=timeout, headers=self.headers)
|
||||
return self.session
|
||||
|
||||
async def close(self):
|
||||
if self.session and not self.session.closed:
|
||||
await self.session.close()
|
||||
|
||||
def _decode_response(self, content: bytes) -> str:
|
||||
try:
|
||||
return content.decode(self.config.encoding)
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
return content.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
return content.decode('latin-1')
|
||||
|
||||
def _clean_xml_bytes(self, content: bytes) -> bytes:
|
||||
try:
|
||||
text = content.decode(self.config.encoding)
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
text = content.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
text = content.decode('latin-1')
|
||||
|
||||
text = re.sub(r'^[0-9a-fA-F]+\r?\n', '', text, flags=re.MULTILINE)
|
||||
text = text.strip()
|
||||
|
||||
if not text.startswith('<?xml'):
|
||||
xml_start = text.find('<?xml')
|
||||
if xml_start > 0:
|
||||
text = text[xml_start:]
|
||||
|
||||
text = re.sub(r'encoding=["\'][^"\']*["\']', 'encoding="UTF-8"', text)
|
||||
return text.encode('utf-8')
|
||||
|
||||
async def _request(self, method: str, url: str, **kwargs):
|
||||
await self.create_session()
|
||||
|
||||
for attempt in range(self.config.max_retries):
|
||||
try:
|
||||
if attempt > 0:
|
||||
await asyncio.sleep(self.config.retry_delay)
|
||||
|
||||
async with self.session.request(method, url, **kwargs) as response:
|
||||
content = await response.read()
|
||||
return response.status, content, dict(response.cookies)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
self.logger.warning(f"Request failed (attempt {attempt + 1}): {e}")
|
||||
if attempt == self.config.max_retries - 1:
|
||||
raise
|
||||
|
||||
raise Exception("Max retries exceeded")
|
||||
|
||||
async def login(self, user_id: Optional[str] = None, password: Optional[str] = None) -> bool:
|
||||
user_id = user_id or self.config.user_id
|
||||
password = password or self.config.password
|
||||
|
||||
if not user_id or not password:
|
||||
self.logger.error("User ID and password are required")
|
||||
return False
|
||||
|
||||
self.logger.info(f"Attempting login for user: {user_id}")
|
||||
|
||||
url = f"{self.config.base_url}/member/login_ok.html"
|
||||
data = {
|
||||
'prevURL': '',
|
||||
'id': user_id,
|
||||
'passwd': password,
|
||||
'idSave': 'Y',
|
||||
'button': 'LOGIN'
|
||||
}
|
||||
|
||||
headers = {
|
||||
**self.headers,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Origin': self.config.base_url,
|
||||
'Referer': f'{self.config.base_url}/member/login_v2.html',
|
||||
}
|
||||
|
||||
try:
|
||||
status, content, cookies = await self._request(
|
||||
'POST', url, data=data, headers=headers, allow_redirects=False
|
||||
)
|
||||
|
||||
text = self._decode_response(content)
|
||||
|
||||
if 'goMain' in text or 'PHPSESSID' in str(cookies):
|
||||
self.cookies.update(cookies)
|
||||
self.is_logged_in = True
|
||||
self.last_session_refresh = datetime.now()
|
||||
self.logger.info("Login successful")
|
||||
return True
|
||||
else:
|
||||
self.logger.error("Login failed: unexpected response")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Login error: {e}")
|
||||
return False
|
||||
|
||||
async def get_car_makers(self) -> List[CarMaker]:
|
||||
url = f"{self.config.base_url}/common/ajax/AutoDBCode.html"
|
||||
params = {'mode': 'getCarInit', 'ctl': 'car'}
|
||||
|
||||
headers = {
|
||||
**self.headers,
|
||||
'Accept': 'application/xml, text/xml, */*; q=0.01',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
}
|
||||
|
||||
try:
|
||||
status, content, _ = await self._request(
|
||||
'GET', url, params=params, headers=headers, cookies=self.cookies
|
||||
)
|
||||
|
||||
if status != 200:
|
||||
return []
|
||||
|
||||
return self._parse_car_makers(content)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting car makers: {e}")
|
||||
return []
|
||||
|
||||
def _parse_car_makers(self, content: bytes) -> List[CarMaker]:
|
||||
makers = []
|
||||
try:
|
||||
xml_bytes = self._clean_xml_bytes(content)
|
||||
root = etree.fromstring(xml_bytes)
|
||||
|
||||
for item in root.findall('.//item'):
|
||||
key = item.findtext('key', '')
|
||||
name = item.findtext('name', '')
|
||||
cho = item.findtext('cho', '')
|
||||
|
||||
if key and name:
|
||||
makers.append(CarMaker(code=key, name=name, cho=cho))
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error parsing car makers: {e}")
|
||||
|
||||
return makers
|
||||
|
||||
async def get_car_models(self, maker_code: str) -> List[CarModel]:
|
||||
url = f"{self.config.base_url}/common/ajax/AutoDBCode.html"
|
||||
params = {
|
||||
'mode': 'getCarModelInit',
|
||||
'ctl': 'car',
|
||||
'company': maker_code,
|
||||
'selected': '',
|
||||
}
|
||||
|
||||
headers = {
|
||||
**self.headers,
|
||||
'Accept': 'application/xml, text/xml, */*; q=0.01',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
}
|
||||
|
||||
try:
|
||||
status, content, _ = await self._request(
|
||||
'GET', url, params=params, headers=headers, cookies=self.cookies
|
||||
)
|
||||
|
||||
if status != 200:
|
||||
return []
|
||||
|
||||
return self._parse_car_models(content, maker_code)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting car models: {e}")
|
||||
return []
|
||||
|
||||
def _parse_car_models(self, content: bytes, maker_code: str) -> List[CarModel]:
|
||||
models = []
|
||||
try:
|
||||
xml_bytes = self._clean_xml_bytes(content)
|
||||
root = etree.fromstring(xml_bytes)
|
||||
|
||||
for item in root.findall('.//item'):
|
||||
key = item.findtext('key', '')
|
||||
name = item.findtext('name', '')
|
||||
|
||||
if key and name:
|
||||
models.append(CarModel(code=key, name=name, maker_code=maker_code))
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error parsing car models: {e}")
|
||||
|
||||
return models
|
||||
|
||||
def get_image_url(self, car_no: str, index: int = 0) -> str:
|
||||
padded = car_no.zfill(9)
|
||||
folder = f"{padded[0:3]}/{padded[3:6]}/{padded[6:9]}"
|
||||
return f"{self.config.base_url}/data/__carPhoto/{folder}/cmcar_{index}.jpg"
|
||||
159
agent/src/sync_agent.py
Normal file
159
agent/src/sync_agent.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Carmodoo Sync Agent - Syncs makers/models from Carmodoo to AutonetSellCar Backend
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
from .carmodoo_client import CarmodooClient, CarmodooConfig
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger('sync_agent')
|
||||
|
||||
|
||||
class SyncAgent:
|
||||
def __init__(self):
|
||||
load_dotenv()
|
||||
|
||||
# Carmodoo config
|
||||
self.carmodoo_config = CarmodooConfig(
|
||||
user_id=os.getenv('CARMODOO_USER_ID', ''),
|
||||
password=os.getenv('CARMODOO_PASSWORD', ''),
|
||||
)
|
||||
|
||||
# Backend API
|
||||
self.api_url = os.getenv('API_SERVER_URL', 'http://autonet-backend:8000/api')
|
||||
self.api_key = os.getenv('AGENT_API_KEY', '')
|
||||
|
||||
self.carmodoo: CarmodooClient = None
|
||||
self.http_client: httpx.AsyncClient = None
|
||||
|
||||
async def start(self):
|
||||
"""Initialize connections"""
|
||||
logger.info("Starting Sync Agent...")
|
||||
|
||||
self.carmodoo = CarmodooClient(self.carmodoo_config)
|
||||
await self.carmodoo.create_session()
|
||||
|
||||
self.http_client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
# Login to Carmodoo
|
||||
if not await self.carmodoo.login():
|
||||
logger.error("Failed to login to Carmodoo")
|
||||
return False
|
||||
|
||||
logger.info("Sync Agent started successfully")
|
||||
return True
|
||||
|
||||
async def stop(self):
|
||||
"""Cleanup connections"""
|
||||
if self.carmodoo:
|
||||
await self.carmodoo.close()
|
||||
if self.http_client:
|
||||
await self.http_client.aclose()
|
||||
logger.info("Sync Agent stopped")
|
||||
|
||||
async def sync_makers(self):
|
||||
"""Sync car makers from Carmodoo to Backend"""
|
||||
logger.info("Syncing car makers...")
|
||||
|
||||
makers = await self.carmodoo.get_car_makers()
|
||||
logger.info(f"Found {len(makers)} makers from Carmodoo")
|
||||
|
||||
synced = 0
|
||||
for maker in makers:
|
||||
try:
|
||||
response = await self.http_client.post(
|
||||
f"{self.api_url}/cars/makers/",
|
||||
json={
|
||||
"code": maker.code,
|
||||
"name": maker.name,
|
||||
}
|
||||
)
|
||||
if response.status_code in [200, 201]:
|
||||
synced += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing maker {maker.code}: {e}")
|
||||
|
||||
logger.info(f"Synced {synced}/{len(makers)} makers")
|
||||
return makers
|
||||
|
||||
async def sync_models(self, makers):
|
||||
"""Sync car models for all makers"""
|
||||
logger.info("Syncing car models...")
|
||||
|
||||
total_models = 0
|
||||
synced = 0
|
||||
|
||||
for maker in makers:
|
||||
await asyncio.sleep(0.5) # Rate limiting
|
||||
|
||||
models = await self.carmodoo.get_car_models(maker.code)
|
||||
total_models += len(models)
|
||||
|
||||
# Get maker ID from backend
|
||||
try:
|
||||
response = await self.http_client.get(f"{self.api_url}/cars/makers/")
|
||||
if response.status_code == 200:
|
||||
backend_makers = response.json()
|
||||
maker_id = None
|
||||
for bm in backend_makers:
|
||||
if bm['code'] == maker.code:
|
||||
maker_id = bm['id']
|
||||
break
|
||||
|
||||
if maker_id:
|
||||
for model in models:
|
||||
try:
|
||||
resp = await self.http_client.post(
|
||||
f"{self.api_url}/cars/models/",
|
||||
json={
|
||||
"code": model.code,
|
||||
"maker_id": maker_id,
|
||||
"name": model.name,
|
||||
}
|
||||
)
|
||||
if resp.status_code in [200, 201]:
|
||||
synced += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing model {model.code}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting makers from backend: {e}")
|
||||
|
||||
logger.debug(f"Maker {maker.name}: {len(models)} models")
|
||||
|
||||
logger.info(f"Synced {synced}/{total_models} models")
|
||||
|
||||
async def run_sync(self):
|
||||
"""Run full sync"""
|
||||
if not await self.start():
|
||||
return
|
||||
|
||||
try:
|
||||
# Sync makers
|
||||
makers = await self.sync_makers()
|
||||
|
||||
# Sync models
|
||||
await self.sync_models(makers)
|
||||
|
||||
logger.info("Sync completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Sync error: {e}")
|
||||
finally:
|
||||
await self.stop()
|
||||
|
||||
|
||||
async def main():
|
||||
agent = SyncAgent()
|
||||
await agent.run_sync()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
40
backend/.env.example
Normal file
40
backend/.env.example
Normal file
@@ -0,0 +1,40 @@
|
||||
# Database
|
||||
USE_SQLITE=True
|
||||
DB_HOST=192.168.0.201
|
||||
DB_PORT=5432
|
||||
DB_NAME=autonet
|
||||
DB_USER=admin
|
||||
DB_PASSWORD=
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=192.168.0.201
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# JWT
|
||||
SECRET_KEY=your-secret-key-change-in-production
|
||||
|
||||
# Agent
|
||||
AGENT_API_KEY=
|
||||
|
||||
# App
|
||||
DEBUG=True
|
||||
|
||||
# Stripe Configuration
|
||||
# Get your keys from https://dashboard.stripe.com/apikeys
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||
|
||||
# Stripe Webhook Secret
|
||||
# Get this from https://dashboard.stripe.com/webhooks when you create a webhook
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# Stripe redirect URLs (adjust for production)
|
||||
STRIPE_SUCCESS_URL=http://localhost:3000/cc/success
|
||||
STRIPE_CANCEL_URL=http://localhost:3000/cc
|
||||
|
||||
# Azure Translator API (Microsoft Azure)
|
||||
# Get your keys from https://portal.azure.com -> Translator resource
|
||||
# Free tier: 2 million characters/month
|
||||
AZURE_TRANSLATOR_KEY=
|
||||
AZURE_TRANSLATOR_REGION=koreacentral
|
||||
23
backend/Dockerfile
Normal file
23
backend/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create uploads directory
|
||||
RUN mkdir -p /app/uploads /app/logs
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
1
backend/SearchCarNum_full.js
Normal file
1
backend/SearchCarNum_full.js
Normal file
@@ -0,0 +1 @@
|
||||
Error: Failed to fetch
|
||||
36
backend/add_exchange_rate_columns.py
Normal file
36
backend/add_exchange_rate_columns.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Add exchange rate weight columns to system_settings table
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
def upgrade():
|
||||
conn = sqlite3.connect('autonet.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check existing columns
|
||||
cursor.execute("PRAGMA table_info(system_settings)")
|
||||
columns = [col[1] for col in cursor.fetchall()]
|
||||
|
||||
new_columns = [
|
||||
('exchange_rate_weight_usd', 'FLOAT DEFAULT 0.0'),
|
||||
('exchange_rate_weight_mnt', 'FLOAT DEFAULT 0.0'),
|
||||
('exchange_rate_weight_rub', 'FLOAT DEFAULT 0.0'),
|
||||
('exchange_rate_weight_cny', 'FLOAT DEFAULT 0.0'),
|
||||
]
|
||||
|
||||
for col_name, col_type in new_columns:
|
||||
if col_name not in columns:
|
||||
try:
|
||||
cursor.execute(f'ALTER TABLE system_settings ADD COLUMN {col_name} {col_type}')
|
||||
print(f'Added column: {col_name}')
|
||||
except Exception as e:
|
||||
print(f'Error adding {col_name}: {e}')
|
||||
else:
|
||||
print(f'Column {col_name} already exists')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print('Database migration complete!')
|
||||
|
||||
if __name__ == '__main__':
|
||||
upgrade()
|
||||
21
backend/add_pdf_path_column.py
Normal file
21
backend/add_pdf_path_column.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Add pdf_path column to car_performance_checks table"""
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
os.chdir(r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
|
||||
conn = sqlite3.connect('autonet.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if column already exists
|
||||
cursor.execute('PRAGMA table_info(car_performance_checks)')
|
||||
columns = [col[1] for col in cursor.fetchall()]
|
||||
|
||||
if 'pdf_path' not in columns:
|
||||
print("Adding pdf_path column...")
|
||||
cursor.execute('ALTER TABLE car_performance_checks ADD COLUMN pdf_path VARCHAR(500)')
|
||||
conn.commit()
|
||||
print("pdf_path column added successfully!")
|
||||
else:
|
||||
print("pdf_path column already exists")
|
||||
|
||||
conn.close()
|
||||
56
backend/analyze_page.py
Normal file
56
backend/analyze_page.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import asyncio, os, sys
|
||||
sys.path.insert(0, r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
|
||||
os.chdir(r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
async def analyze():
|
||||
url = "https://ck.carmodoo.com/carCheck/carmodooPrint.do?print=0&checkNum=7400044430"
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
page = await browser.new_page(viewport={'width': 1400, 'height': 900})
|
||||
await page.goto(url, wait_until='networkidle', timeout=60000)
|
||||
await page.wait_for_timeout(3000)
|
||||
|
||||
# Get page structure
|
||||
result = await page.evaluate("""() => {
|
||||
const body = document.body;
|
||||
const children = Array.from(body.children);
|
||||
|
||||
// Find elements that look like pages
|
||||
const pageSelectors = ['.page', '[class*="page"]', 'table', '.print', '[class*="print"]'];
|
||||
const found = {};
|
||||
|
||||
pageSelectors.forEach(sel => {
|
||||
const elems = document.querySelectorAll(sel);
|
||||
if (elems.length > 0) {
|
||||
found[sel] = {
|
||||
count: elems.length,
|
||||
firstClass: elems[0].className,
|
||||
firstTag: elems[0].tagName
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Get direct children of body
|
||||
const bodyChildren = children.map(c => ({
|
||||
tag: c.tagName,
|
||||
class: c.className,
|
||||
id: c.id,
|
||||
height: c.offsetHeight
|
||||
}));
|
||||
|
||||
return { found, bodyChildren: bodyChildren.slice(0, 20) };
|
||||
}""")
|
||||
|
||||
print("=== Found elements ===")
|
||||
for sel, info in result['found'].items():
|
||||
print(f" {sel}: {info}")
|
||||
|
||||
print("\n=== Body children ===")
|
||||
for child in result['bodyChildren']:
|
||||
print(f" {child['tag']} class='{child['class']}' id='{child['id']}' height={child['height']}")
|
||||
|
||||
await browser.close()
|
||||
|
||||
asyncio.run(analyze())
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# AutonetSellCar Backend API
|
||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API Routes
|
||||
546
backend/app/api/auth.py
Normal file
546
backend/app/api/auth.py
Normal file
@@ -0,0 +1,546 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
from jose import JWTError, jwt
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import bcrypt
|
||||
from ..database import get_db
|
||||
from ..models import User
|
||||
from ..models.user import generate_referral_code
|
||||
from ..schemas import UserCreate, UserUpdate, UserResponse, Token
|
||||
from ..config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
settings = get_settings()
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
email: str = payload.get("sub")
|
||||
if email is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
|
||||
# Optional 인증 scheme
|
||||
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
||||
|
||||
|
||||
def get_current_user_optional(
|
||||
token: str = Depends(oauth2_scheme_optional),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User | None:
|
||||
"""선택적 인증 - 토큰이 없거나 유효하지 않아도 None 반환"""
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
email: str = payload.get("sub")
|
||||
if email is None:
|
||||
return None
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
return user
|
||||
|
||||
|
||||
def get_current_admin_user(
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> User:
|
||||
"""관리자 권한 확인"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse)
|
||||
def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||
"""회원가입"""
|
||||
from ..models.user import VerificationCode
|
||||
from datetime import datetime
|
||||
|
||||
existing = db.query(User).filter(User.email == user_data.email).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
# Check if email was verified (pre-registration verification)
|
||||
email_verified = False
|
||||
verification = db.query(VerificationCode).filter(
|
||||
VerificationCode.email == user_data.email,
|
||||
VerificationCode.code_type == "email",
|
||||
VerificationCode.verified_at.isnot(None)
|
||||
).order_by(VerificationCode.verified_at.desc()).first()
|
||||
|
||||
if verification:
|
||||
email_verified = True
|
||||
|
||||
# Generate unique referral code
|
||||
referral_code = generate_referral_code()
|
||||
while db.query(User).filter(User.referral_code == referral_code).first():
|
||||
referral_code = generate_referral_code()
|
||||
|
||||
user = User(
|
||||
email=user_data.email,
|
||||
password_hash=get_password_hash(user_data.password),
|
||||
name=user_data.name,
|
||||
phone=user_data.phone,
|
||||
country=user_data.country,
|
||||
referral_code=referral_code,
|
||||
referred_by=getattr(user_data, 'referred_by', None),
|
||||
email_verified=email_verified,
|
||||
email_verified_at=datetime.utcnow() if email_verified else None,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""로그인"""
|
||||
user = db.query(User).filter(User.email == form_data.username).first()
|
||||
if not user or not verify_password(form_data.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
access_token = create_access_token(data={"sub": user.email})
|
||||
return Token(access_token=access_token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_me(current_user: User = Depends(get_current_user)):
|
||||
"""현재 사용자 정보"""
|
||||
return current_user
|
||||
|
||||
|
||||
@router.put("/me", response_model=UserResponse)
|
||||
def update_me(
|
||||
user_update: UserUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""사용자 정보 수정"""
|
||||
if user_update.name is not None:
|
||||
current_user.name = user_update.name
|
||||
if user_update.phone is not None:
|
||||
current_user.phone = user_update.phone
|
||||
if user_update.country is not None:
|
||||
current_user.country = user_update.country
|
||||
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
return current_user
|
||||
|
||||
|
||||
# Admin User Management Endpoints
|
||||
@router.get("/admin/users")
|
||||
def admin_get_users(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
search: str = None,
|
||||
is_dealer: bool = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""모든 사용자 목록 조회 (관리자) - 삭제된 사용자 제외"""
|
||||
query = db.query(User).filter(
|
||||
User.is_admin == False,
|
||||
User.deleted_at.is_(None) # 삭제되지 않은 사용자만
|
||||
)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
(User.email.ilike(f"%{search}%")) |
|
||||
(User.name.ilike(f"%{search}%")) |
|
||||
(User.phone.ilike(f"%{search}%"))
|
||||
)
|
||||
|
||||
if is_dealer is not None:
|
||||
query = query.filter(User.is_dealer == is_dealer)
|
||||
|
||||
total = query.count()
|
||||
users = query.order_by(User.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return {
|
||||
"users": [
|
||||
{
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"phone": user.phone,
|
||||
"country": user.country,
|
||||
"cc_balance": user.cc_balance,
|
||||
"is_dealer": user.is_dealer,
|
||||
"referral_code": user.referral_code,
|
||||
"referred_by": user.referred_by,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
}
|
||||
for user in users
|
||||
],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
|
||||
|
||||
@router.get("/admin/users/{user_id}")
|
||||
def admin_get_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""특정 사용자 상세 정보 (관리자)"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"phone": user.phone,
|
||||
"country": user.country,
|
||||
"cc_balance": user.cc_balance,
|
||||
"is_admin": user.is_admin,
|
||||
"is_dealer": user.is_dealer,
|
||||
"referral_code": user.referral_code,
|
||||
"referred_by": user.referred_by,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/admin/users/{user_id}")
|
||||
def admin_update_user(
|
||||
user_id: int,
|
||||
user_update: UserUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""사용자 정보 수정 (관리자)"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if user_update.name is not None:
|
||||
user.name = user_update.name
|
||||
if user_update.phone is not None:
|
||||
user.phone = user_update.phone
|
||||
if user_update.country is not None:
|
||||
user.country = user_update.country
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"phone": user.phone,
|
||||
"country": user.country,
|
||||
"cc_balance": user.cc_balance,
|
||||
"is_dealer": user.is_dealer,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/admin/users/{user_id}/cc")
|
||||
def admin_adjust_cc(
|
||||
user_id: int,
|
||||
amount: float,
|
||||
reason: str = "Admin adjustment",
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""사용자 CC 잔액 조정 (관리자)"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
user.cc_balance = user.cc_balance + amount
|
||||
if user.cc_balance < 0:
|
||||
user.cc_balance = 0
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return {
|
||||
"message": f"CC balance adjusted by {amount}",
|
||||
"new_balance": user.cc_balance
|
||||
}
|
||||
|
||||
|
||||
# ===== 사용자 탈퇴 =====
|
||||
|
||||
class WithdrawalRequest(BaseModel):
|
||||
"""탈퇴 요청"""
|
||||
reason: Optional[str] = None
|
||||
password: str # 본인 확인용
|
||||
|
||||
|
||||
@router.post("/withdraw")
|
||||
def request_withdrawal(
|
||||
request: WithdrawalRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""사용자 탈퇴 요청"""
|
||||
# 비밀번호 확인
|
||||
if not verify_password(request.password, current_user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Incorrect password")
|
||||
|
||||
# 이미 탈퇴 요청한 경우
|
||||
if current_user.withdrawal_requested_at:
|
||||
raise HTTPException(status_code=400, detail="Withdrawal already requested")
|
||||
|
||||
# 관리자는 탈퇴 불가
|
||||
if current_user.is_admin:
|
||||
raise HTTPException(status_code=400, detail="Admin cannot withdraw")
|
||||
|
||||
# 탈퇴 요청 기록
|
||||
current_user.withdrawal_requested_at = datetime.utcnow()
|
||||
current_user.withdrawal_reason = request.reason
|
||||
current_user.is_active = False # 계정 비활성화
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Withdrawal request submitted. Your account has been deactivated.",
|
||||
"withdrawal_requested_at": current_user.withdrawal_requested_at.isoformat()
|
||||
}
|
||||
|
||||
|
||||
@router.post("/withdraw/cancel")
|
||||
def cancel_withdrawal(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""탈퇴 요청 취소 (아직 삭제되지 않은 경우)"""
|
||||
if not current_user.withdrawal_requested_at:
|
||||
raise HTTPException(status_code=400, detail="No withdrawal request found")
|
||||
|
||||
if current_user.deleted_at:
|
||||
raise HTTPException(status_code=400, detail="Account already deleted")
|
||||
|
||||
# 탈퇴 요청 취소
|
||||
current_user.withdrawal_requested_at = None
|
||||
current_user.withdrawal_reason = None
|
||||
current_user.is_active = True
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"message": "Withdrawal request cancelled. Your account is active again."}
|
||||
|
||||
|
||||
# ===== 관리자 사용자 삭제 =====
|
||||
|
||||
@router.delete("/admin/users/{user_id}")
|
||||
def admin_delete_user(
|
||||
user_id: int,
|
||||
hard_delete: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""사용자 삭제 (관리자 전용)
|
||||
- hard_delete=False: 소프트 삭제 (deleted_at 설정)
|
||||
- hard_delete=True: 완전 삭제 (DB에서 제거)
|
||||
"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# 관리자는 삭제 불가
|
||||
if user.is_admin:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete admin user")
|
||||
|
||||
# 자기 자신은 삭제 불가
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
|
||||
user_email = user.email
|
||||
|
||||
if hard_delete:
|
||||
# 완전 삭제 - 관련 데이터도 함께 삭제
|
||||
from ..models import CarView, PerformanceCheckView, ChargeHistory, Inquiry, Notification
|
||||
|
||||
db.query(CarView).filter(CarView.user_id == user_id).delete()
|
||||
db.query(PerformanceCheckView).filter(PerformanceCheckView.user_id == user_id).delete()
|
||||
db.query(ChargeHistory).filter(ChargeHistory.user_id == user_id).delete()
|
||||
db.query(Inquiry).filter(Inquiry.user_id == user_id).delete()
|
||||
db.query(Notification).filter(Notification.user_id == user_id).delete()
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"User {user_email} permanently deleted",
|
||||
"deleted_user_id": user_id,
|
||||
"hard_delete": True
|
||||
}
|
||||
else:
|
||||
# 소프트 삭제
|
||||
user.deleted_at = datetime.utcnow()
|
||||
user.is_active = False
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"User {user_email} soft deleted",
|
||||
"deleted_user_id": user_id,
|
||||
"deleted_at": user.deleted_at.isoformat(),
|
||||
"hard_delete": False
|
||||
}
|
||||
|
||||
|
||||
@router.get("/admin/users/withdrawn")
|
||||
def admin_get_withdrawn_users(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""탈퇴 요청한 사용자 목록 (관리자)"""
|
||||
query = db.query(User).filter(
|
||||
User.withdrawal_requested_at.isnot(None),
|
||||
User.deleted_at.is_(None) # 아직 삭제되지 않은 사용자만
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
users = query.order_by(User.withdrawal_requested_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return {
|
||||
"users": [
|
||||
{
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"phone": user.phone,
|
||||
"country": user.country,
|
||||
"cc_balance": user.cc_balance,
|
||||
"is_dealer": user.is_dealer,
|
||||
"withdrawal_requested_at": user.withdrawal_requested_at.isoformat() if user.withdrawal_requested_at else None,
|
||||
"withdrawal_reason": user.withdrawal_reason,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
}
|
||||
for user in users
|
||||
],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
|
||||
|
||||
@router.get("/admin/users/deleted")
|
||||
def admin_get_deleted_users(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
search: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""삭제된 사용자 목록 (관리자)"""
|
||||
query = db.query(User).filter(
|
||||
User.deleted_at.isnot(None) # 삭제된 사용자만
|
||||
)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
(User.email.ilike(f"%{search}%")) |
|
||||
(User.name.ilike(f"%{search}%")) |
|
||||
(User.phone.ilike(f"%{search}%"))
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
users = query.order_by(User.deleted_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return {
|
||||
"users": [
|
||||
{
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"phone": user.phone,
|
||||
"country": user.country,
|
||||
"cc_balance": user.cc_balance,
|
||||
"is_dealer": user.is_dealer,
|
||||
"referral_code": user.referral_code,
|
||||
"referred_by": user.referred_by,
|
||||
"deleted_at": user.deleted_at.isoformat() if user.deleted_at else None,
|
||||
"withdrawal_reason": user.withdrawal_reason,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
}
|
||||
for user in users
|
||||
],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
|
||||
|
||||
@router.post("/admin/users/{user_id}/restore")
|
||||
def admin_restore_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""삭제된 사용자 복원 (관리자)"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if not user.deleted_at:
|
||||
raise HTTPException(status_code=400, detail="User is not deleted")
|
||||
|
||||
user.deleted_at = None
|
||||
user.is_active = True
|
||||
user.withdrawal_requested_at = None
|
||||
user.withdrawal_reason = None
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"User {user.email} restored successfully",
|
||||
"user_id": user_id
|
||||
}
|
||||
2691
backend/app/api/carmodoo.py
Normal file
2691
backend/app/api/carmodoo.py
Normal file
File diff suppressed because it is too large
Load Diff
340
backend/app/api/cars.py
Normal file
340
backend/app/api/cars.py
Normal file
@@ -0,0 +1,340 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from typing import Optional, List
|
||||
from ..database import get_db
|
||||
from ..models import Car, CarMaker, CarModel, CarImage, CarOption
|
||||
from ..schemas import (
|
||||
CarCreate, CarUpdate, CarResponse, CarListResponse,
|
||||
CarMakerCreate, CarMakerResponse,
|
||||
CarModelCreate, CarModelResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/cars", tags=["cars"])
|
||||
|
||||
|
||||
def car_to_response(car: Car) -> dict:
|
||||
"""Convert Car model to response dict with computed final prices"""
|
||||
return {
|
||||
"id": car.id,
|
||||
"source": car.source,
|
||||
"source_id": car.source_id,
|
||||
"car_name": car.car_name,
|
||||
"year": car.year,
|
||||
"month": car.month,
|
||||
"mileage": car.mileage,
|
||||
"price_krw": car.price_krw,
|
||||
"margin_krw": car.margin_krw or 0,
|
||||
"margin_mn": car.margin_mn or 0,
|
||||
"final_price_krw": (car.price_krw or 0) + (car.margin_krw or 0),
|
||||
"final_price_mn": (car.price_krw or 0) + (car.margin_mn or 0),
|
||||
"price_usd": car.price_usd,
|
||||
"is_displayed": car.is_displayed or False,
|
||||
"fuel": car.fuel,
|
||||
"transmission": car.transmission,
|
||||
"color": car.color,
|
||||
"displacement": car.displacement,
|
||||
"car_number": car.car_number,
|
||||
"seize_count": car.seize_count or 0,
|
||||
"collateral_count": car.collateral_count or 0,
|
||||
"check_num": car.check_num,
|
||||
"dealer_name": car.dealer_name,
|
||||
"dealer_description": car.dealer_description,
|
||||
"status": car.status,
|
||||
"created_at": car.created_at,
|
||||
"updated_at": car.updated_at,
|
||||
"maker": car.maker,
|
||||
"model": car.model,
|
||||
"images": car.images,
|
||||
"specification": car.specification,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=CarListResponse)
|
||||
def get_cars(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
maker_id: Optional[int] = None,
|
||||
model_id: Optional[int] = None,
|
||||
year_min: Optional[int] = None,
|
||||
year_max: Optional[int] = None,
|
||||
price_min: Optional[int] = None,
|
||||
price_max: Optional[int] = None,
|
||||
mileage_max: Optional[int] = None,
|
||||
fuel: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
is_displayed: Optional[bool] = None,
|
||||
admin: bool = Query(False, description="Admin mode - show all cars"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""차량 목록 조회"""
|
||||
# Base query for filtering (without eager loading for count)
|
||||
base_query = db.query(Car)
|
||||
|
||||
# For non-admin (user-facing), only show displayed cars
|
||||
if not admin:
|
||||
base_query = base_query.filter(Car.is_displayed == True)
|
||||
|
||||
# status 필터 (None이면 전체 조회)
|
||||
if status:
|
||||
base_query = base_query.filter(Car.status == status)
|
||||
|
||||
# is_displayed 필터 (admin mode에서만 의미있음)
|
||||
if is_displayed is not None and admin:
|
||||
base_query = base_query.filter(Car.is_displayed == is_displayed)
|
||||
|
||||
if maker_id:
|
||||
base_query = base_query.filter(Car.maker_id == maker_id)
|
||||
if model_id:
|
||||
base_query = base_query.filter(Car.model_id == model_id)
|
||||
if year_min:
|
||||
base_query = base_query.filter(Car.year >= year_min)
|
||||
if year_max:
|
||||
base_query = base_query.filter(Car.year <= year_max)
|
||||
if price_min:
|
||||
base_query = base_query.filter(Car.price_krw >= price_min)
|
||||
if price_max:
|
||||
base_query = base_query.filter(Car.price_krw <= price_max)
|
||||
if mileage_max:
|
||||
base_query = base_query.filter(Car.mileage <= mileage_max)
|
||||
if fuel:
|
||||
base_query = base_query.filter(Car.fuel == fuel)
|
||||
|
||||
total = base_query.count()
|
||||
|
||||
# Add eager loading for actual data fetch
|
||||
cars = base_query.options(
|
||||
joinedload(Car.maker),
|
||||
joinedload(Car.model),
|
||||
joinedload(Car.images)
|
||||
).order_by(Car.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
# Convert to response with computed fields
|
||||
cars_response = [car_to_response(car) for car in cars]
|
||||
|
||||
return CarListResponse(
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
cars=cars_response
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{car_id}", response_model=CarResponse)
|
||||
def get_car(car_id: int, admin: bool = Query(False), db: Session = Depends(get_db)):
|
||||
"""차량 상세 조회"""
|
||||
car = db.query(Car).options(
|
||||
joinedload(Car.maker),
|
||||
joinedload(Car.model),
|
||||
joinedload(Car.images),
|
||||
joinedload(Car.specification)
|
||||
).filter(Car.id == car_id).first()
|
||||
if not car:
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
# Non-admin can only see displayed cars
|
||||
if not admin and not car.is_displayed:
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
return car_to_response(car)
|
||||
|
||||
|
||||
@router.post("", response_model=CarResponse)
|
||||
def create_car(car_data: CarCreate, db: Session = Depends(get_db)):
|
||||
"""차량 등록 (Agent용)"""
|
||||
# Check if car already exists
|
||||
existing = db.query(Car).filter(
|
||||
Car.source == car_data.source,
|
||||
Car.source_id == car_data.source_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Car already exists")
|
||||
|
||||
# Get maker and model IDs
|
||||
maker_id = None
|
||||
model_id = None
|
||||
|
||||
if car_data.maker_code:
|
||||
maker = db.query(CarMaker).filter(CarMaker.code == car_data.maker_code).first()
|
||||
if maker:
|
||||
maker_id = maker.id
|
||||
|
||||
if car_data.model_code and maker_id:
|
||||
model = db.query(CarModel).filter(
|
||||
CarModel.code == car_data.model_code,
|
||||
CarModel.maker_id == maker_id
|
||||
).first()
|
||||
if model:
|
||||
model_id = model.id
|
||||
|
||||
# Create car
|
||||
car = Car(
|
||||
source=car_data.source,
|
||||
source_id=car_data.source_id,
|
||||
source_key=car_data.source_key,
|
||||
maker_id=maker_id,
|
||||
model_id=model_id,
|
||||
car_name=car_data.car_name,
|
||||
year=car_data.year,
|
||||
month=car_data.month,
|
||||
mileage=car_data.mileage,
|
||||
price_krw=car_data.price_krw,
|
||||
price_usd=car_data.price_usd,
|
||||
fuel=car_data.fuel,
|
||||
transmission=car_data.transmission,
|
||||
color=car_data.color,
|
||||
displacement=car_data.displacement,
|
||||
car_number=car_data.car_number,
|
||||
seize_count=car_data.seize_count,
|
||||
collateral_count=car_data.collateral_count,
|
||||
check_num=car_data.check_num,
|
||||
dealer_name=car_data.dealer_name,
|
||||
dealer_phone=car_data.dealer_phone,
|
||||
shop_name=car_data.shop_name,
|
||||
memo=car_data.memo,
|
||||
)
|
||||
db.add(car)
|
||||
db.flush()
|
||||
|
||||
# Add images
|
||||
for i, img in enumerate(car_data.images):
|
||||
car_image = CarImage(
|
||||
car_id=car.id,
|
||||
url=img.url,
|
||||
local_path=img.local_path,
|
||||
is_main=(i == 0),
|
||||
sort_order=i
|
||||
)
|
||||
db.add(car_image)
|
||||
|
||||
# Add options
|
||||
for opt in car_data.options:
|
||||
car_option = CarOption(car_id=car.id, option_name=opt)
|
||||
db.add(car_option)
|
||||
|
||||
db.commit()
|
||||
db.refresh(car)
|
||||
return car
|
||||
|
||||
|
||||
@router.put("/{car_id}", response_model=CarResponse)
|
||||
def update_car(car_id: int, car_data: CarUpdate, db: Session = Depends(get_db)):
|
||||
"""차량 정보 수정"""
|
||||
car = db.query(Car).options(
|
||||
joinedload(Car.maker),
|
||||
joinedload(Car.model),
|
||||
joinedload(Car.images),
|
||||
joinedload(Car.specification)
|
||||
).filter(Car.id == car_id).first()
|
||||
if not car:
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
|
||||
for key, value in car_data.dict(exclude_unset=True).items():
|
||||
setattr(car, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(car)
|
||||
return car_to_response(car)
|
||||
|
||||
|
||||
@router.delete("/{car_id}")
|
||||
def delete_car(car_id: int, db: Session = Depends(get_db)):
|
||||
"""차량 삭제 (관련 데이터 포함)"""
|
||||
print(f"[DELETE] Deleting car {car_id}")
|
||||
car = db.query(Car).filter(Car.id == car_id).first()
|
||||
if not car:
|
||||
print(f"[DELETE] Car {car_id} not found")
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
|
||||
try:
|
||||
# 관련 테이블 데이터 삭제
|
||||
from ..models.car import CarImage, CarOption
|
||||
from ..models.performance_check import CarPerformanceCheck
|
||||
from ..models.car_specification import CarSpecification
|
||||
from ..models.hero_banner import HeroBanner
|
||||
from ..models.user import CarView, PerformanceCheckView
|
||||
from sqlalchemy import text
|
||||
|
||||
# 이미지 삭제
|
||||
img_count = db.query(CarImage).filter(CarImage.car_id == car_id).delete(synchronize_session=False)
|
||||
print(f"[DELETE] Deleted {img_count} images")
|
||||
# 옵션 삭제
|
||||
opt_count = db.query(CarOption).filter(CarOption.car_id == car_id).delete(synchronize_session=False)
|
||||
print(f"[DELETE] Deleted {opt_count} options")
|
||||
# 성능점검 삭제
|
||||
pc_count = db.query(CarPerformanceCheck).filter(CarPerformanceCheck.car_id == car_id).delete(synchronize_session=False)
|
||||
print(f"[DELETE] Deleted {pc_count} performance checks")
|
||||
# 사양 삭제
|
||||
spec_count = db.query(CarSpecification).filter(CarSpecification.car_id == car_id).delete(synchronize_session=False)
|
||||
print(f"[DELETE] Deleted {spec_count} specifications")
|
||||
# 차량 조회 기록 삭제
|
||||
cv_count = db.query(CarView).filter(CarView.car_id == car_id).delete(synchronize_session=False)
|
||||
print(f"[DELETE] Deleted {cv_count} car views")
|
||||
# 성능점검 조회 기록 삭제
|
||||
pcv_count = db.query(PerformanceCheckView).filter(PerformanceCheckView.car_id == car_id).delete(synchronize_session=False)
|
||||
print(f"[DELETE] Deleted {pcv_count} performance check views")
|
||||
# 문의 기록에서 car_id 제거 (raw SQL로 실행하여 모델 스키마 검증 방지)
|
||||
result = db.execute(text("UPDATE inquiries SET car_id = NULL WHERE car_id = :car_id"), {"car_id": car_id})
|
||||
inq_count = result.rowcount
|
||||
print(f"[DELETE] Unlinked {inq_count} inquiries")
|
||||
# 배너에서 car_id 제거 (배너는 삭제하지 않고 연결만 해제)
|
||||
banner_count = db.query(HeroBanner).filter(HeroBanner.car_id == car_id).update({"car_id": None}, synchronize_session=False)
|
||||
print(f"[DELETE] Unlinked {banner_count} banners")
|
||||
|
||||
# 차량 삭제
|
||||
db.delete(car)
|
||||
db.commit()
|
||||
print(f"[DELETE] Car {car_id} deleted successfully")
|
||||
return {"message": "Car deleted"}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
import traceback
|
||||
error_trace = traceback.format_exc()
|
||||
print(f"[DELETE] Error deleting car {car_id}: {e}\n{error_trace}")
|
||||
raise HTTPException(status_code=500, detail=f"Delete failed: {str(e)}")
|
||||
|
||||
|
||||
# Makers
|
||||
@router.get("/makers/", response_model=List[CarMakerResponse])
|
||||
def get_makers(db: Session = Depends(get_db)):
|
||||
"""제조사 목록 조회"""
|
||||
return db.query(CarMaker).all()
|
||||
|
||||
|
||||
@router.post("/makers/", response_model=CarMakerResponse)
|
||||
def create_maker(maker_data: CarMakerCreate, db: Session = Depends(get_db)):
|
||||
"""제조사 등록"""
|
||||
existing = db.query(CarMaker).filter(CarMaker.code == maker_data.code).first()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
maker = CarMaker(**maker_data.dict())
|
||||
db.add(maker)
|
||||
db.commit()
|
||||
db.refresh(maker)
|
||||
return maker
|
||||
|
||||
|
||||
# Models
|
||||
@router.get("/models/", response_model=List[CarModelResponse])
|
||||
def get_models(maker_id: Optional[int] = None, db: Session = Depends(get_db)):
|
||||
"""모델 목록 조회"""
|
||||
query = db.query(CarModel)
|
||||
if maker_id:
|
||||
query = query.filter(CarModel.maker_id == maker_id)
|
||||
return query.all()
|
||||
|
||||
|
||||
@router.post("/models/", response_model=CarModelResponse)
|
||||
def create_model(model_data: CarModelCreate, db: Session = Depends(get_db)):
|
||||
"""모델 등록"""
|
||||
existing = db.query(CarModel).filter(
|
||||
CarModel.code == model_data.code,
|
||||
CarModel.maker_id == model_data.maker_id
|
||||
).first()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
model = CarModel(**model_data.dict())
|
||||
db.add(model)
|
||||
db.commit()
|
||||
db.refresh(model)
|
||||
return model
|
||||
886
backend/app/api/cc.py
Normal file
886
backend/app/api/cc.py
Normal file
@@ -0,0 +1,886 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
import stripe
|
||||
import logging
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import User, Car, CarView, PerformanceCheckView, ChargeHistory, CarPerformanceCheck, CCPackage, DEFAULT_CC_PACKAGES
|
||||
from ..models.settings import SystemSettings
|
||||
from ..models.user import PaymentSettings
|
||||
from ..schemas import UserResponse, CarViewResponse, PurchaseViewRequest
|
||||
from .auth import get_current_user, get_current_admin_user, get_current_user_optional
|
||||
from .referral import create_referral_reward
|
||||
from .carmodoo import CarmodooClient, capture_performance_check_pdf
|
||||
from .notification import notify_system
|
||||
from ..config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
# Configure Stripe
|
||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||
|
||||
router = APIRouter(prefix="/cc", tags=["cc"])
|
||||
|
||||
|
||||
class ChargeRequest(BaseModel):
|
||||
amount: int
|
||||
currency: str = "USD"
|
||||
payment_method: str = "card"
|
||||
transaction_id: Optional[str] = None # For crypto payments
|
||||
wallet_address: Optional[str] = None # User's wallet for refunds
|
||||
|
||||
|
||||
class USDCChargeRequest(BaseModel):
|
||||
amount_usdc: int
|
||||
transaction_hash: str
|
||||
wallet_address: str
|
||||
network: str = "Polygon"
|
||||
|
||||
|
||||
class ChargeHistoryResponse(BaseModel):
|
||||
id: int
|
||||
amount_usd: int
|
||||
cc_amount: int
|
||||
payment_method: str
|
||||
status: str
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/balance")
|
||||
def get_cc_balance(current_user: User = Depends(get_current_user)):
|
||||
"""Get current user's CC balance"""
|
||||
return {"cc_balance": current_user.cc_balance or 0}
|
||||
|
||||
|
||||
@router.get("/views", response_model=List[CarViewResponse])
|
||||
def get_purchased_views(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get list of cars the user has paid to view"""
|
||||
views = db.query(CarView).filter(CarView.user_id == current_user.id).all()
|
||||
return views
|
||||
|
||||
|
||||
@router.get("/views/car-ids")
|
||||
def get_purchased_car_ids(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get list of car IDs the user has paid to view (for quick lookup)"""
|
||||
views = db.query(CarView.car_id).filter(CarView.user_id == current_user.id).all()
|
||||
return {"car_ids": [v[0] for v in views]}
|
||||
|
||||
|
||||
@router.post("/purchase-view")
|
||||
def purchase_car_view(
|
||||
request: PurchaseViewRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Purchase access to view full car details (costs 1 CC)"""
|
||||
car_id = request.car_id
|
||||
|
||||
# Check if car exists
|
||||
car = db.query(Car).filter(Car.id == car_id).first()
|
||||
if not car:
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
|
||||
# Check if already purchased
|
||||
existing_view = db.query(CarView).filter(
|
||||
CarView.user_id == current_user.id,
|
||||
CarView.car_id == car_id
|
||||
).first()
|
||||
|
||||
if existing_view:
|
||||
return {"message": "Already purchased", "cc_balance": current_user.cc_balance}
|
||||
|
||||
# Check if user has enough CC
|
||||
if (current_user.cc_balance or 0) < 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Insufficient CC balance. You need 1 CC to view full car details."
|
||||
)
|
||||
|
||||
# Deduct CC and create view record
|
||||
current_user.cc_balance = (current_user.cc_balance or 0) - 1
|
||||
|
||||
car_view = CarView(
|
||||
user_id=current_user.id,
|
||||
car_id=car_id,
|
||||
cc_paid=1
|
||||
)
|
||||
db.add(car_view)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Purchase successful",
|
||||
"cc_balance": current_user.cc_balance,
|
||||
"car_id": car_id
|
||||
}
|
||||
|
||||
|
||||
@router.get("/check-view/{car_id}")
|
||||
def check_car_view(
|
||||
car_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Check if user has purchased view access for a specific car"""
|
||||
existing_view = db.query(CarView).filter(
|
||||
CarView.user_id == current_user.id,
|
||||
CarView.car_id == car_id
|
||||
).first()
|
||||
|
||||
return {
|
||||
"has_access": existing_view is not None,
|
||||
"cc_balance": current_user.cc_balance or 0
|
||||
}
|
||||
|
||||
|
||||
PERFORMANCE_CHECK_COST = 0.1 # 0.1 CC for performance check view
|
||||
|
||||
|
||||
@router.post("/purchase-performance-check")
|
||||
async def purchase_performance_check_view(
|
||||
request: PurchaseViewRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Purchase access to view performance check (costs 0.1 CC)"""
|
||||
car_id = request.car_id
|
||||
|
||||
# Check if car exists
|
||||
car = db.query(Car).filter(Car.id == car_id).first()
|
||||
if not car:
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
|
||||
# Check if performance check record exists
|
||||
perf_check = db.query(CarPerformanceCheck).filter(
|
||||
CarPerformanceCheck.car_id == car_id
|
||||
).first()
|
||||
|
||||
# If no performance check record, try to fetch from Carmodoo
|
||||
if not perf_check:
|
||||
try:
|
||||
carmodoo_client = CarmodooClient()
|
||||
check_num = car.check_num or ""
|
||||
|
||||
# Try to get check_num if not available
|
||||
if not check_num:
|
||||
check_num = await carmodoo_client.get_car_check_num(car.source_id, car.source_key or "")
|
||||
|
||||
if check_num:
|
||||
# Fetch performance check data
|
||||
perf_result = await carmodoo_client.get_performance_check(car.source_id, car.source_key or "", check_num)
|
||||
|
||||
if perf_result.get("found") and perf_result.get("data"):
|
||||
perf_data = perf_result["data"]
|
||||
# Create CarPerformanceCheck record
|
||||
perf_check = CarPerformanceCheck(
|
||||
car_id=car.id,
|
||||
check_number=perf_data.get("check_number") or check_num,
|
||||
check_date=perf_data.get("check_date"),
|
||||
valid_until=perf_data.get("valid_until"),
|
||||
first_registration=perf_data.get("first_registration"),
|
||||
mileage=perf_data.get("mileage"),
|
||||
mileage_status=perf_data.get("mileage_status"),
|
||||
seize_count=perf_data.get("seize_count", 0),
|
||||
collateral_count=perf_data.get("collateral_count", 0),
|
||||
is_flood_damaged=perf_data.get("is_flood_damaged", False),
|
||||
is_fire_damaged=perf_data.get("is_fire_damaged", False),
|
||||
is_total_loss=perf_data.get("is_total_loss", False),
|
||||
engine_status=perf_data.get("engine_status"),
|
||||
transmission_status=perf_data.get("transmission_status"),
|
||||
power_delivery_status=perf_data.get("power_delivery_status"),
|
||||
raw_data=perf_data,
|
||||
raw_html=perf_result.get("raw_html", "")[:50000],
|
||||
)
|
||||
db.add(perf_check)
|
||||
db.flush()
|
||||
|
||||
# Capture PDF
|
||||
try:
|
||||
pdf_path = await capture_performance_check_pdf(perf_check.check_number, car.id)
|
||||
if pdf_path:
|
||||
perf_check.pdf_path = pdf_path
|
||||
except Exception as pdf_error:
|
||||
logger.warning(f"PDF capture failed: {pdf_error}")
|
||||
|
||||
db.commit()
|
||||
db.refresh(perf_check)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch performance check: {e}")
|
||||
|
||||
if not perf_check:
|
||||
raise HTTPException(status_code=404, detail="Performance check not available for this car")
|
||||
|
||||
# Check if already purchased
|
||||
existing_view = db.query(PerformanceCheckView).filter(
|
||||
PerformanceCheckView.user_id == current_user.id,
|
||||
PerformanceCheckView.car_id == car_id
|
||||
).first()
|
||||
|
||||
if existing_view:
|
||||
return {"message": "Already purchased", "cc_balance": current_user.cc_balance}
|
||||
|
||||
# Check if user has enough CC
|
||||
if (current_user.cc_balance or 0) < PERFORMANCE_CHECK_COST:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Insufficient CC balance. You need {PERFORMANCE_CHECK_COST} CC to view performance check."
|
||||
)
|
||||
|
||||
# Deduct CC and create view record
|
||||
current_user.cc_balance = (current_user.cc_balance or 0) - PERFORMANCE_CHECK_COST
|
||||
|
||||
perf_view = PerformanceCheckView(
|
||||
user_id=current_user.id,
|
||||
car_id=car_id,
|
||||
cc_paid=PERFORMANCE_CHECK_COST
|
||||
)
|
||||
db.add(perf_view)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Purchase successful",
|
||||
"cc_balance": current_user.cc_balance,
|
||||
"car_id": car_id
|
||||
}
|
||||
|
||||
|
||||
@router.get("/check-performance-check/{car_id}")
|
||||
def check_performance_check_view(
|
||||
car_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Check if user has purchased performance check view for a specific car"""
|
||||
# Check if performance check exists for this car
|
||||
perf_check = db.query(CarPerformanceCheck).filter(
|
||||
CarPerformanceCheck.car_id == car_id
|
||||
).first()
|
||||
|
||||
# Check 1: Purchased performance check (0.1 CC)
|
||||
existing_perf_view = db.query(PerformanceCheckView).filter(
|
||||
PerformanceCheckView.user_id == current_user.id,
|
||||
PerformanceCheckView.car_id == car_id
|
||||
).first()
|
||||
|
||||
# Check 2: Purchased full car view (1 CC) -> performance check included free
|
||||
existing_car_view = db.query(CarView).filter(
|
||||
CarView.user_id == current_user.id,
|
||||
CarView.car_id == car_id
|
||||
).first()
|
||||
|
||||
has_access = (existing_perf_view is not None) or (existing_car_view is not None)
|
||||
|
||||
return {
|
||||
"has_access": has_access,
|
||||
"has_performance_check": perf_check is not None,
|
||||
"cc_balance": current_user.cc_balance or 0,
|
||||
"cost": PERFORMANCE_CHECK_COST,
|
||||
"included_in_car_view": existing_car_view is not None # True if already purchased car view
|
||||
}
|
||||
|
||||
|
||||
@router.get("/payment-info")
|
||||
def get_payment_info():
|
||||
"""Get payment information including USDC wallet address"""
|
||||
return {
|
||||
"usdc_wallet_address": PaymentSettings.USDC_WALLET_ADDRESS,
|
||||
"usdc_network": PaymentSettings.USDC_NETWORK,
|
||||
"min_charge_usd": PaymentSettings.MIN_CHARGE_USD,
|
||||
"max_charge_usd": PaymentSettings.MAX_CHARGE_USD,
|
||||
"supported_currencies": PaymentSettings.SUPPORTED_CURRENCIES,
|
||||
"supported_methods": PaymentSettings.SUPPORTED_METHODS,
|
||||
"rate": "1 USD = 1 CC",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/charge")
|
||||
def charge_cc(
|
||||
request: ChargeRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a charge request (for card or bank transfer - requires admin verification)"""
|
||||
# Validate amount
|
||||
if request.amount < PaymentSettings.MIN_CHARGE_USD:
|
||||
raise HTTPException(status_code=400, detail=f"Minimum charge amount is ${PaymentSettings.MIN_CHARGE_USD}")
|
||||
|
||||
if request.amount > PaymentSettings.MAX_CHARGE_USD:
|
||||
raise HTTPException(status_code=400, detail=f"Maximum charge amount is ${PaymentSettings.MAX_CHARGE_USD}")
|
||||
|
||||
# Calculate CC amount (1 USD = 1 CC)
|
||||
cc_amount = request.amount
|
||||
|
||||
# Determine status based on payment method
|
||||
# Card payments would go through a payment gateway (not implemented yet)
|
||||
# USDC and bank transfers require manual verification
|
||||
status = "pending" if request.payment_method in ["usdc", "bank_transfer"] else "pending"
|
||||
|
||||
# Create charge history record
|
||||
charge_record = ChargeHistory(
|
||||
user_id=current_user.id,
|
||||
amount=request.amount,
|
||||
amount_usd=request.amount, # Backwards compatibility
|
||||
cc_amount=cc_amount,
|
||||
currency=request.currency,
|
||||
payment_method=request.payment_method,
|
||||
transaction_id=request.transaction_id,
|
||||
wallet_address=request.wallet_address,
|
||||
status=status
|
||||
)
|
||||
db.add(charge_record)
|
||||
db.commit()
|
||||
db.refresh(charge_record)
|
||||
|
||||
return {
|
||||
"message": "Charge request created" if status == "pending" else "Charge successful",
|
||||
"charge_id": charge_record.id,
|
||||
"amount": request.amount,
|
||||
"currency": request.currency,
|
||||
"cc_amount": cc_amount,
|
||||
"status": status,
|
||||
"payment_info": {
|
||||
"usdc_wallet": PaymentSettings.USDC_WALLET_ADDRESS if request.payment_method == "usdc" else None,
|
||||
"network": PaymentSettings.USDC_NETWORK if request.payment_method == "usdc" else None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/charge/usdc")
|
||||
def charge_cc_usdc(
|
||||
request: USDCChargeRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create USDC charge request with transaction hash"""
|
||||
# Validate amount
|
||||
if request.amount_usdc < PaymentSettings.MIN_CHARGE_USD:
|
||||
raise HTTPException(status_code=400, detail=f"Minimum charge amount is {PaymentSettings.MIN_CHARGE_USD} USDC")
|
||||
|
||||
if request.amount_usdc > PaymentSettings.MAX_CHARGE_USD:
|
||||
raise HTTPException(status_code=400, detail=f"Maximum charge amount is {PaymentSettings.MAX_CHARGE_USD} USDC")
|
||||
|
||||
# Check for duplicate transaction
|
||||
existing = db.query(ChargeHistory).filter(
|
||||
ChargeHistory.transaction_id == request.transaction_hash
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="This transaction has already been submitted")
|
||||
|
||||
# Create pending charge record
|
||||
charge_record = ChargeHistory(
|
||||
user_id=current_user.id,
|
||||
amount=request.amount_usdc,
|
||||
amount_usd=request.amount_usdc,
|
||||
cc_amount=request.amount_usdc,
|
||||
currency="USDC",
|
||||
payment_method="usdc",
|
||||
transaction_id=request.transaction_hash,
|
||||
wallet_address=request.wallet_address,
|
||||
status="pending"
|
||||
)
|
||||
db.add(charge_record)
|
||||
db.commit()
|
||||
db.refresh(charge_record)
|
||||
|
||||
return {
|
||||
"message": "USDC payment submitted for verification",
|
||||
"charge_id": charge_record.id,
|
||||
"amount_usdc": request.amount_usdc,
|
||||
"cc_amount": request.amount_usdc,
|
||||
"status": "pending",
|
||||
"transaction_hash": request.transaction_hash
|
||||
}
|
||||
|
||||
|
||||
@router.get("/charge-history")
|
||||
def get_charge_history(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get user's charge history"""
|
||||
history = db.query(ChargeHistory).filter(
|
||||
ChargeHistory.user_id == current_user.id
|
||||
).order_by(desc(ChargeHistory.created_at)).limit(50).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": h.id,
|
||||
"amount": h.amount or h.amount_usd,
|
||||
"amount_usd": h.amount_usd,
|
||||
"currency": h.currency or "USD",
|
||||
"cc_amount": h.cc_amount,
|
||||
"payment_method": h.payment_method,
|
||||
"transaction_id": h.transaction_id,
|
||||
"status": h.status,
|
||||
"created_at": h.created_at.isoformat() if h.created_at else None
|
||||
}
|
||||
for h in history
|
||||
]
|
||||
|
||||
|
||||
# Admin endpoints for payment verification
|
||||
@router.get("/admin/pending")
|
||||
def admin_get_pending_payments(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get all pending payment requests (Admin only)"""
|
||||
pending = db.query(ChargeHistory).filter(
|
||||
ChargeHistory.status == "pending"
|
||||
).order_by(desc(ChargeHistory.created_at)).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": h.id,
|
||||
"user_id": h.user_id,
|
||||
"user_email": h.user.email if h.user else None,
|
||||
"user_name": h.user.name if h.user else None,
|
||||
"amount": h.amount or h.amount_usd,
|
||||
"currency": h.currency or "USD",
|
||||
"cc_amount": h.cc_amount,
|
||||
"payment_method": h.payment_method,
|
||||
"transaction_id": h.transaction_id,
|
||||
"wallet_address": h.wallet_address,
|
||||
"status": h.status,
|
||||
"created_at": h.created_at.isoformat() if h.created_at else None
|
||||
}
|
||||
for h in pending
|
||||
]
|
||||
|
||||
|
||||
@router.get("/admin/all")
|
||||
def admin_get_all_payments(
|
||||
status: str = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get all payment records with optional status filter (Admin only)"""
|
||||
query = db.query(ChargeHistory)
|
||||
|
||||
if status:
|
||||
query = query.filter(ChargeHistory.status == status)
|
||||
|
||||
total = query.count()
|
||||
payments = query.order_by(desc(ChargeHistory.created_at)).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return {
|
||||
"payments": [
|
||||
{
|
||||
"id": h.id,
|
||||
"user_id": h.user_id,
|
||||
"user_email": h.user.email if h.user else None,
|
||||
"user_name": h.user.name if h.user else None,
|
||||
"amount": h.amount or h.amount_usd,
|
||||
"currency": h.currency or "USD",
|
||||
"cc_amount": h.cc_amount,
|
||||
"payment_method": h.payment_method,
|
||||
"transaction_id": h.transaction_id,
|
||||
"wallet_address": h.wallet_address,
|
||||
"admin_note": h.admin_note,
|
||||
"status": h.status,
|
||||
"verified_at": h.verified_at.isoformat() if h.verified_at else None,
|
||||
"created_at": h.created_at.isoformat() if h.created_at else None
|
||||
}
|
||||
for h in payments
|
||||
],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
|
||||
|
||||
@router.put("/admin/{charge_id}/verify")
|
||||
def admin_verify_payment(
|
||||
charge_id: int,
|
||||
approved: bool,
|
||||
admin_note: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Verify and approve/reject a pending payment (Admin only)"""
|
||||
charge = db.query(ChargeHistory).filter(ChargeHistory.id == charge_id).first()
|
||||
if not charge:
|
||||
raise HTTPException(status_code=404, detail="Charge record not found")
|
||||
|
||||
if charge.status != "pending":
|
||||
raise HTTPException(status_code=400, detail=f"Charge is already {charge.status}")
|
||||
|
||||
if approved:
|
||||
charge.status = "completed"
|
||||
charge.verified_at = datetime.utcnow()
|
||||
charge.verified_by = current_user.id
|
||||
charge.admin_note = admin_note
|
||||
|
||||
# Credit CC to user
|
||||
user = db.query(User).filter(User.id == charge.user_id).first()
|
||||
if user:
|
||||
user.cc_balance = (user.cc_balance or 0) + charge.cc_amount
|
||||
|
||||
# Trigger referral reward if applicable
|
||||
if user.referred_by:
|
||||
referrer = db.query(User).filter(
|
||||
User.referral_code == user.referred_by
|
||||
).first()
|
||||
if referrer:
|
||||
create_referral_reward(
|
||||
referrer_id=referrer.id,
|
||||
referred_user_id=user.id,
|
||||
payment_amount=charge.amount_usd or charge.amount,
|
||||
db=db
|
||||
)
|
||||
|
||||
# Send notification to user
|
||||
notify_system(
|
||||
db,
|
||||
user.id,
|
||||
"Payment Confirmed",
|
||||
f"Your payment of {charge.amount} {charge.currency or 'USD'} has been confirmed. {charge.cc_amount} CC has been added to your balance.",
|
||||
"/profile"
|
||||
)
|
||||
else:
|
||||
charge.status = "rejected"
|
||||
charge.verified_at = datetime.utcnow()
|
||||
charge.verified_by = current_user.id
|
||||
charge.admin_note = admin_note
|
||||
|
||||
# Send notification to user
|
||||
user = db.query(User).filter(User.id == charge.user_id).first()
|
||||
if user:
|
||||
notify_system(
|
||||
db,
|
||||
user.id,
|
||||
"Payment Rejected",
|
||||
f"Your payment request for {charge.amount} {charge.currency or 'USD'} was rejected. Reason: {admin_note or 'No reason provided'}",
|
||||
"/profile"
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Payment {'approved' if approved else 'rejected'}",
|
||||
"charge_id": charge_id,
|
||||
"new_status": charge.status
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# Stripe Payment Endpoints
|
||||
# ============================================
|
||||
|
||||
class CreateCheckoutRequest(BaseModel):
|
||||
package_id: int
|
||||
|
||||
|
||||
@router.get("/stripe-key")
|
||||
def get_stripe_publishable_key():
|
||||
"""Get Stripe publishable key for frontend"""
|
||||
return {"publishable_key": settings.STRIPE_PUBLISHABLE_KEY}
|
||||
|
||||
|
||||
@router.get("/packages")
|
||||
def get_cc_packages(db: Session = Depends(get_db)):
|
||||
"""Get available CC packages"""
|
||||
# Get system settings for cars_per_cc
|
||||
system_settings = db.query(SystemSettings).first()
|
||||
cars_per_cc = system_settings.cars_per_cc if system_settings and system_settings.cars_per_cc else 3
|
||||
|
||||
# First try to get from database
|
||||
packages = db.query(CCPackage).filter(CCPackage.is_active == True).order_by(CCPackage.sort_order).all()
|
||||
|
||||
# If no packages in DB, initialize with defaults
|
||||
if not packages:
|
||||
for pkg_data in DEFAULT_CC_PACKAGES:
|
||||
pkg = CCPackage(**pkg_data)
|
||||
db.add(pkg)
|
||||
db.commit()
|
||||
packages = db.query(CCPackage).filter(CCPackage.is_active == True).order_by(CCPackage.sort_order).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": pkg.id,
|
||||
"name": pkg.name,
|
||||
"price_usd": pkg.price_usd,
|
||||
"cc_amount": pkg.cc_amount,
|
||||
"bonus_cc": pkg.bonus_cc,
|
||||
"total_cc": pkg.cc_amount + pkg.bonus_cc,
|
||||
"discount_percent": pkg.discount_percent,
|
||||
"recommendations": (pkg.cc_amount + pkg.bonus_cc) * cars_per_cc,
|
||||
"cars_per_cc": cars_per_cc, # 프론트엔드에서 표시용
|
||||
}
|
||||
for pkg in packages
|
||||
]
|
||||
|
||||
|
||||
@router.post("/create-checkout-session")
|
||||
def create_checkout_session(
|
||||
request: CreateCheckoutRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create Stripe checkout session for CC purchase"""
|
||||
if not settings.STRIPE_SECRET_KEY:
|
||||
raise HTTPException(status_code=500, detail="Stripe is not configured")
|
||||
|
||||
# Get package
|
||||
package = db.query(CCPackage).filter(CCPackage.id == request.package_id).first()
|
||||
if not package:
|
||||
raise HTTPException(status_code=404, detail="Package not found")
|
||||
|
||||
if not package.is_active:
|
||||
raise HTTPException(status_code=400, detail="This package is no longer available")
|
||||
|
||||
try:
|
||||
# Create Stripe Checkout Session
|
||||
checkout_session = stripe.checkout.Session.create(
|
||||
payment_method_types=["card"],
|
||||
line_items=[
|
||||
{
|
||||
"price_data": {
|
||||
"currency": "usd",
|
||||
"unit_amount": package.price_usd * 100, # Stripe uses cents
|
||||
"product_data": {
|
||||
"name": f"AutonetSellCar CC - {package.name}",
|
||||
"description": f"{package.cc_amount + package.bonus_cc} CC ({package.cc_amount} + {package.bonus_cc} bonus)",
|
||||
},
|
||||
},
|
||||
"quantity": 1,
|
||||
}
|
||||
],
|
||||
mode="payment",
|
||||
success_url=f"{settings.STRIPE_SUCCESS_URL}?session_id={{CHECKOUT_SESSION_ID}}",
|
||||
cancel_url=settings.STRIPE_CANCEL_URL,
|
||||
client_reference_id=str(current_user.id),
|
||||
metadata={
|
||||
"user_id": str(current_user.id),
|
||||
"package_id": str(package.id),
|
||||
"cc_amount": str(package.cc_amount),
|
||||
"bonus_cc": str(package.bonus_cc),
|
||||
},
|
||||
customer_email=current_user.email,
|
||||
)
|
||||
|
||||
# Create pending charge record
|
||||
charge_record = ChargeHistory(
|
||||
user_id=current_user.id,
|
||||
package_id=package.id,
|
||||
amount=package.price_usd,
|
||||
amount_usd=package.price_usd,
|
||||
cc_amount=package.cc_amount,
|
||||
bonus_cc=package.bonus_cc,
|
||||
currency="USD",
|
||||
payment_method="stripe",
|
||||
stripe_session_id=checkout_session.id,
|
||||
status="pending"
|
||||
)
|
||||
db.add(charge_record)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"checkout_url": checkout_session.url,
|
||||
"session_id": checkout_session.id
|
||||
}
|
||||
|
||||
except stripe.error.StripeError as e:
|
||||
logger.error(f"Stripe error: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/webhook")
|
||||
async def stripe_webhook(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Handle Stripe webhook events"""
|
||||
payload = await request.body()
|
||||
sig_header = request.headers.get("stripe-signature")
|
||||
|
||||
if not settings.STRIPE_WEBHOOK_SECRET:
|
||||
logger.warning("Stripe webhook secret not configured")
|
||||
raise HTTPException(status_code=500, detail="Webhook not configured")
|
||||
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error(f"Invalid payload: {e}")
|
||||
raise HTTPException(status_code=400, detail="Invalid payload")
|
||||
except stripe.error.SignatureVerificationError as e:
|
||||
logger.error(f"Invalid signature: {e}")
|
||||
raise HTTPException(status_code=400, detail="Invalid signature")
|
||||
|
||||
# Handle the checkout.session.completed event
|
||||
if event["type"] == "checkout.session.completed":
|
||||
session = event["data"]["object"]
|
||||
|
||||
# Get charge record by session ID
|
||||
charge = db.query(ChargeHistory).filter(
|
||||
ChargeHistory.stripe_session_id == session["id"]
|
||||
).first()
|
||||
|
||||
if charge and charge.status == "pending":
|
||||
# Update charge record
|
||||
charge.status = "completed"
|
||||
charge.stripe_payment_intent_id = session.get("payment_intent")
|
||||
charge.verified_at = datetime.utcnow()
|
||||
|
||||
# Credit CC to user
|
||||
user = db.query(User).filter(User.id == charge.user_id).first()
|
||||
if user:
|
||||
total_cc = charge.cc_amount + (charge.bonus_cc or 0)
|
||||
user.cc_balance = (user.cc_balance or 0) + total_cc
|
||||
|
||||
# Trigger referral reward if applicable
|
||||
if user.referred_by:
|
||||
referrer = db.query(User).filter(
|
||||
User.referral_code == user.referred_by
|
||||
).first()
|
||||
if referrer:
|
||||
create_referral_reward(
|
||||
referrer_id=referrer.id,
|
||||
referred_user_id=user.id,
|
||||
payment_amount=charge.amount_usd or charge.amount,
|
||||
db=db
|
||||
)
|
||||
|
||||
# Send notification
|
||||
notify_system(
|
||||
db,
|
||||
user.id,
|
||||
"CC Purchase Successful",
|
||||
f"Your purchase of {total_cc} CC has been completed. Your new balance is {user.cc_balance} CC.",
|
||||
"/cc"
|
||||
)
|
||||
|
||||
logger.info(f"CC credited: user={user.id}, amount={total_cc}")
|
||||
|
||||
db.commit()
|
||||
|
||||
elif event["type"] == "checkout.session.expired":
|
||||
session = event["data"]["object"]
|
||||
|
||||
# Update charge record to cancelled
|
||||
charge = db.query(ChargeHistory).filter(
|
||||
ChargeHistory.stripe_session_id == session["id"]
|
||||
).first()
|
||||
|
||||
if charge and charge.status == "pending":
|
||||
charge.status = "cancelled"
|
||||
db.commit()
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.get("/checkout-success")
|
||||
def checkout_success(
|
||||
session_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Verify checkout session and return result"""
|
||||
# Find charge record
|
||||
charge = db.query(ChargeHistory).filter(
|
||||
ChargeHistory.stripe_session_id == session_id,
|
||||
ChargeHistory.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not charge:
|
||||
raise HTTPException(status_code=404, detail="Payment record not found")
|
||||
|
||||
# If still pending, try to verify with Stripe
|
||||
if charge.status == "pending":
|
||||
try:
|
||||
session = stripe.checkout.Session.retrieve(session_id)
|
||||
if session.payment_status == "paid":
|
||||
charge.status = "completed"
|
||||
charge.stripe_payment_intent_id = session.payment_intent
|
||||
charge.verified_at = datetime.utcnow()
|
||||
|
||||
# Credit CC
|
||||
total_cc = charge.cc_amount + (charge.bonus_cc or 0)
|
||||
current_user.cc_balance = (current_user.cc_balance or 0) + total_cc
|
||||
|
||||
db.commit()
|
||||
except stripe.error.StripeError as e:
|
||||
logger.error(f"Error verifying session: {e}")
|
||||
|
||||
return {
|
||||
"status": charge.status,
|
||||
"cc_amount": charge.cc_amount,
|
||||
"bonus_cc": charge.bonus_cc or 0,
|
||||
"total_cc": charge.cc_amount + (charge.bonus_cc or 0),
|
||||
"cc_balance": current_user.cc_balance or 0
|
||||
}
|
||||
|
||||
|
||||
# Manual CC charge request (for Russian users via Mongolian partner)
|
||||
class ManualChargeRequest(BaseModel):
|
||||
package_id: int
|
||||
payment_note: Optional[str] = None # e.g., "Paid via Mongolian partner bank"
|
||||
|
||||
|
||||
@router.post("/manual-request")
|
||||
def create_manual_charge_request(
|
||||
request: ManualChargeRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create manual CC charge request (for Russian users)"""
|
||||
# Get package
|
||||
package = db.query(CCPackage).filter(CCPackage.id == request.package_id).first()
|
||||
if not package:
|
||||
raise HTTPException(status_code=404, detail="Package not found")
|
||||
|
||||
# Create pending charge record
|
||||
charge_record = ChargeHistory(
|
||||
user_id=current_user.id,
|
||||
package_id=package.id,
|
||||
amount=package.price_usd,
|
||||
amount_usd=package.price_usd,
|
||||
cc_amount=package.cc_amount,
|
||||
bonus_cc=package.bonus_cc,
|
||||
currency="USD",
|
||||
payment_method="manual",
|
||||
admin_note=request.payment_note,
|
||||
status="pending"
|
||||
)
|
||||
db.add(charge_record)
|
||||
db.commit()
|
||||
db.refresh(charge_record)
|
||||
|
||||
# Notify admins
|
||||
admins = db.query(User).filter(User.is_admin == True).all()
|
||||
for admin in admins:
|
||||
notify_system(
|
||||
db,
|
||||
admin.id,
|
||||
"New Manual CC Request",
|
||||
f"User {current_user.email} requested {package.cc_amount} CC (${package.price_usd}). Payment method: manual.",
|
||||
"/admin/cc"
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Manual charge request created. An admin will verify your payment.",
|
||||
"charge_id": charge_record.id,
|
||||
"package": {
|
||||
"name": package.name,
|
||||
"price_usd": package.price_usd,
|
||||
"cc_amount": package.cc_amount + package.bonus_cc
|
||||
},
|
||||
"status": "pending"
|
||||
}
|
||||
443
backend/app/api/dashboard.py
Normal file
443
backend/app/api/dashboard.py
Normal file
@@ -0,0 +1,443 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, desc
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import (
|
||||
User, Car, Inquiry, InquiryStatus,
|
||||
VehicleRequest, RequestVehicle, PurchasedVehicle,
|
||||
DealerApplication, DealerInfo,
|
||||
VehicleShare, ShareReward,
|
||||
WithdrawalRequest,
|
||||
ReferralReward,
|
||||
HeroBanner,
|
||||
ChargeHistory,
|
||||
)
|
||||
from .auth import get_current_admin_user
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
|
||||
|
||||
|
||||
class DashboardStats(BaseModel):
|
||||
total_users: int
|
||||
new_users_today: int
|
||||
new_users_this_week: int
|
||||
total_dealers: int
|
||||
pending_dealer_applications: int
|
||||
total_cars: int
|
||||
total_vehicle_requests: int
|
||||
pending_requests: int
|
||||
total_purchased_vehicles: int
|
||||
total_inquiries: int
|
||||
pending_inquiries: int
|
||||
total_shares: int
|
||||
purchased_shares: int
|
||||
total_withdrawals: int
|
||||
pending_withdrawals: int
|
||||
total_cc_charged: float
|
||||
total_withdrawal_amount: float
|
||||
|
||||
|
||||
class RevenueStats(BaseModel):
|
||||
total_revenue: float
|
||||
revenue_this_month: float
|
||||
revenue_last_month: float
|
||||
platform_commission: float
|
||||
dealer_commission: float
|
||||
|
||||
|
||||
class ChartData(BaseModel):
|
||||
labels: List[str]
|
||||
values: List[int]
|
||||
|
||||
|
||||
class DailyStats(BaseModel):
|
||||
date: str
|
||||
users: int
|
||||
requests: int
|
||||
purchases: int
|
||||
revenue: float
|
||||
|
||||
|
||||
class RecentActivity(BaseModel):
|
||||
type: str
|
||||
title: str
|
||||
description: str
|
||||
time: str
|
||||
icon: str
|
||||
|
||||
|
||||
class TopDealer(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
dealer_code: str
|
||||
total_sales: int
|
||||
total_commission: float
|
||||
|
||||
|
||||
@router.get("/stats", response_model=DashboardStats)
|
||||
def get_dashboard_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get comprehensive dashboard statistics"""
|
||||
today = datetime.utcnow().date()
|
||||
week_ago = today - timedelta(days=7)
|
||||
|
||||
# User stats
|
||||
total_users = db.query(func.count(User.id)).filter(User.is_admin == False).scalar() or 0
|
||||
new_users_today = db.query(func.count(User.id)).filter(
|
||||
and_(
|
||||
User.is_admin == False,
|
||||
func.date(User.created_at) == today
|
||||
)
|
||||
).scalar() or 0
|
||||
new_users_this_week = db.query(func.count(User.id)).filter(
|
||||
and_(
|
||||
User.is_admin == False,
|
||||
func.date(User.created_at) >= week_ago
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
# Dealer stats
|
||||
total_dealers = db.query(func.count(DealerInfo.id)).filter(DealerInfo.is_active == True).scalar() or 0
|
||||
pending_dealer_applications = db.query(func.count(DealerApplication.id)).filter(
|
||||
DealerApplication.status == "pending"
|
||||
).scalar() or 0
|
||||
|
||||
# Car stats
|
||||
total_cars = db.query(func.count(Car.id)).scalar() or 0
|
||||
|
||||
# Vehicle request stats
|
||||
total_vehicle_requests = db.query(func.count(VehicleRequest.id)).scalar() or 0
|
||||
pending_requests = db.query(func.count(VehicleRequest.id)).filter(
|
||||
VehicleRequest.status == "pending"
|
||||
).scalar() or 0
|
||||
|
||||
# Purchased vehicles
|
||||
total_purchased_vehicles = db.query(func.count(PurchasedVehicle.id)).scalar() or 0
|
||||
|
||||
# Inquiry stats
|
||||
total_inquiries = db.query(func.count(Inquiry.id)).scalar() or 0
|
||||
pending_inquiries = db.query(func.count(Inquiry.id)).filter(
|
||||
Inquiry.status == InquiryStatus.PENDING
|
||||
).scalar() or 0
|
||||
|
||||
# Share stats
|
||||
total_shares = db.query(func.count(VehicleShare.id)).scalar() or 0
|
||||
purchased_shares = db.query(func.count(VehicleShare.id)).filter(
|
||||
VehicleShare.is_purchased == True
|
||||
).scalar() or 0
|
||||
|
||||
# Withdrawal stats
|
||||
total_withdrawals = db.query(func.count(WithdrawalRequest.id)).scalar() or 0
|
||||
pending_withdrawals = db.query(func.count(WithdrawalRequest.id)).filter(
|
||||
WithdrawalRequest.status == "pending"
|
||||
).scalar() or 0
|
||||
|
||||
# CC stats
|
||||
total_cc_charged = db.query(func.coalesce(func.sum(ChargeHistory.amount), 0)).filter(
|
||||
ChargeHistory.status == "completed"
|
||||
).scalar() or 0
|
||||
|
||||
total_withdrawal_amount = db.query(func.coalesce(func.sum(WithdrawalRequest.amount), 0)).filter(
|
||||
WithdrawalRequest.status == "completed"
|
||||
).scalar() or 0
|
||||
|
||||
return DashboardStats(
|
||||
total_users=total_users,
|
||||
new_users_today=new_users_today,
|
||||
new_users_this_week=new_users_this_week,
|
||||
total_dealers=total_dealers,
|
||||
pending_dealer_applications=pending_dealer_applications,
|
||||
total_cars=total_cars,
|
||||
total_vehicle_requests=total_vehicle_requests,
|
||||
pending_requests=pending_requests,
|
||||
total_purchased_vehicles=total_purchased_vehicles,
|
||||
total_inquiries=total_inquiries,
|
||||
pending_inquiries=pending_inquiries,
|
||||
total_shares=total_shares,
|
||||
purchased_shares=purchased_shares,
|
||||
total_withdrawals=total_withdrawals,
|
||||
pending_withdrawals=pending_withdrawals,
|
||||
total_cc_charged=float(total_cc_charged),
|
||||
total_withdrawal_amount=float(total_withdrawal_amount),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/revenue", response_model=RevenueStats)
|
||||
def get_revenue_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get revenue statistics"""
|
||||
today = datetime.utcnow().date()
|
||||
this_month_start = today.replace(day=1)
|
||||
last_month_end = this_month_start - timedelta(days=1)
|
||||
last_month_start = last_month_end.replace(day=1)
|
||||
|
||||
# Total CC charged as revenue
|
||||
total_revenue = db.query(func.coalesce(func.sum(ChargeHistory.amount), 0)).filter(
|
||||
ChargeHistory.status == "completed"
|
||||
).scalar() or 0
|
||||
|
||||
revenue_this_month = db.query(func.coalesce(func.sum(ChargeHistory.amount), 0)).filter(
|
||||
and_(
|
||||
ChargeHistory.status == "completed",
|
||||
func.date(ChargeHistory.created_at) >= this_month_start
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
revenue_last_month = db.query(func.coalesce(func.sum(ChargeHistory.amount), 0)).filter(
|
||||
and_(
|
||||
ChargeHistory.status == "completed",
|
||||
func.date(ChargeHistory.created_at) >= last_month_start,
|
||||
func.date(ChargeHistory.created_at) <= last_month_end
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
# Commission stats from purchased vehicles
|
||||
platform_commission = db.query(func.coalesce(func.sum(PurchasedVehicle.platform_commission), 0)).scalar() or 0
|
||||
dealer_commission = db.query(func.coalesce(func.sum(PurchasedVehicle.dealer_commission), 0)).scalar() or 0
|
||||
|
||||
return RevenueStats(
|
||||
total_revenue=float(total_revenue),
|
||||
revenue_this_month=float(revenue_this_month),
|
||||
revenue_last_month=float(revenue_last_month),
|
||||
platform_commission=float(platform_commission),
|
||||
dealer_commission=float(dealer_commission),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/chart/users", response_model=ChartData)
|
||||
def get_user_chart_data(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get user registration chart data for last N days"""
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
labels = []
|
||||
values = []
|
||||
|
||||
for i in range(days - 1, -1, -1):
|
||||
date = today - timedelta(days=i)
|
||||
count = db.query(func.count(User.id)).filter(
|
||||
and_(
|
||||
User.is_admin == False,
|
||||
func.date(User.created_at) == date
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
labels.append(date.strftime("%m/%d"))
|
||||
values.append(count)
|
||||
|
||||
return ChartData(labels=labels, values=values)
|
||||
|
||||
|
||||
@router.get("/chart/requests", response_model=ChartData)
|
||||
def get_request_chart_data(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get vehicle request chart data for last N days"""
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
labels = []
|
||||
values = []
|
||||
|
||||
for i in range(days - 1, -1, -1):
|
||||
date = today - timedelta(days=i)
|
||||
count = db.query(func.count(VehicleRequest.id)).filter(
|
||||
func.date(VehicleRequest.created_at) == date
|
||||
).scalar() or 0
|
||||
|
||||
labels.append(date.strftime("%m/%d"))
|
||||
values.append(count)
|
||||
|
||||
return ChartData(labels=labels, values=values)
|
||||
|
||||
|
||||
@router.get("/chart/revenue", response_model=ChartData)
|
||||
def get_revenue_chart_data(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get daily revenue chart data for last N days"""
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
labels = []
|
||||
values = []
|
||||
|
||||
for i in range(days - 1, -1, -1):
|
||||
date = today - timedelta(days=i)
|
||||
amount = db.query(func.coalesce(func.sum(ChargeHistory.amount), 0)).filter(
|
||||
and_(
|
||||
ChargeHistory.status == "completed",
|
||||
func.date(ChargeHistory.created_at) == date
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
labels.append(date.strftime("%m/%d"))
|
||||
values.append(int(amount))
|
||||
|
||||
return ChartData(labels=labels, values=values)
|
||||
|
||||
|
||||
@router.get("/recent-activities", response_model=List[RecentActivity])
|
||||
def get_recent_activities(
|
||||
limit: int = 10,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get recent activities across the platform"""
|
||||
activities = []
|
||||
|
||||
# Recent user registrations
|
||||
recent_users = db.query(User).filter(User.is_admin == False).order_by(
|
||||
desc(User.created_at)
|
||||
).limit(3).all()
|
||||
|
||||
for user in recent_users:
|
||||
activities.append({
|
||||
"type": "user",
|
||||
"title": "New User Registration",
|
||||
"description": f"{user.name or user.email} joined the platform",
|
||||
"time": user.created_at.isoformat() if user.created_at else "",
|
||||
"icon": "user"
|
||||
})
|
||||
|
||||
# Recent vehicle requests
|
||||
recent_requests = db.query(VehicleRequest).order_by(
|
||||
desc(VehicleRequest.created_at)
|
||||
).limit(3).all()
|
||||
|
||||
for req in recent_requests:
|
||||
activities.append({
|
||||
"type": "request",
|
||||
"title": "Vehicle Request",
|
||||
"description": f"Request #{req.id} - {req.status}",
|
||||
"time": req.created_at.isoformat() if req.created_at else "",
|
||||
"icon": "car"
|
||||
})
|
||||
|
||||
# Recent inquiries
|
||||
recent_inquiries = db.query(Inquiry).order_by(
|
||||
desc(Inquiry.created_at)
|
||||
).limit(3).all()
|
||||
|
||||
for inq in recent_inquiries:
|
||||
activities.append({
|
||||
"type": "inquiry",
|
||||
"title": "New Inquiry",
|
||||
"description": f"{inq.subject or 'General inquiry'} - {inq.status}",
|
||||
"time": inq.created_at.isoformat() if inq.created_at else "",
|
||||
"icon": "message"
|
||||
})
|
||||
|
||||
# Recent dealer applications
|
||||
recent_applications = db.query(DealerApplication).filter(
|
||||
DealerApplication.status == "pending"
|
||||
).order_by(desc(DealerApplication.applied_at)).limit(2).all()
|
||||
|
||||
for app in recent_applications:
|
||||
activities.append({
|
||||
"type": "dealer",
|
||||
"title": "Dealer Application",
|
||||
"description": f"{app.real_name} ({app.business_name}) applied",
|
||||
"time": app.applied_at.isoformat() if app.applied_at else "",
|
||||
"icon": "badge"
|
||||
})
|
||||
|
||||
# Recent withdrawals
|
||||
recent_withdrawals = db.query(WithdrawalRequest).filter(
|
||||
WithdrawalRequest.status == "pending"
|
||||
).order_by(desc(WithdrawalRequest.requested_at)).limit(2).all()
|
||||
|
||||
for wd in recent_withdrawals:
|
||||
activities.append({
|
||||
"type": "withdrawal",
|
||||
"title": "Withdrawal Request",
|
||||
"description": f"₩{wd.amount:,.0f} withdrawal requested",
|
||||
"time": wd.requested_at.isoformat() if wd.requested_at else "",
|
||||
"icon": "wallet"
|
||||
})
|
||||
|
||||
# Sort by time
|
||||
activities.sort(key=lambda x: x["time"], reverse=True)
|
||||
|
||||
return [RecentActivity(**a) for a in activities[:limit]]
|
||||
|
||||
|
||||
@router.get("/top-dealers", response_model=List[TopDealer])
|
||||
def get_top_dealers(
|
||||
limit: int = 5,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get top performing dealers"""
|
||||
# Get dealers with their stats
|
||||
dealers = db.query(
|
||||
DealerInfo,
|
||||
User.name,
|
||||
func.count(PurchasedVehicle.id).label("sales_count"),
|
||||
func.coalesce(func.sum(PurchasedVehicle.dealer_commission), 0).label("total_commission")
|
||||
).join(
|
||||
User, DealerInfo.user_id == User.id
|
||||
).outerjoin(
|
||||
PurchasedVehicle, DealerInfo.user_id == PurchasedVehicle.selected_dealer_id
|
||||
).filter(
|
||||
DealerInfo.is_active == True
|
||||
).group_by(
|
||||
DealerInfo.id, User.name
|
||||
).order_by(
|
||||
desc("sales_count")
|
||||
).limit(limit).all()
|
||||
|
||||
return [
|
||||
TopDealer(
|
||||
id=dealer.DealerInfo.id,
|
||||
name=dealer.name or "Unknown",
|
||||
dealer_code=dealer.DealerInfo.dealer_code,
|
||||
total_sales=dealer.sales_count,
|
||||
total_commission=float(dealer.total_commission)
|
||||
)
|
||||
for dealer in dealers
|
||||
]
|
||||
|
||||
|
||||
@router.get("/pending-actions")
|
||||
def get_pending_actions(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get counts of pending items requiring admin action"""
|
||||
pending_requests = db.query(func.count(VehicleRequest.id)).filter(
|
||||
VehicleRequest.status == "pending"
|
||||
).scalar() or 0
|
||||
|
||||
pending_inquiries = db.query(func.count(Inquiry.id)).filter(
|
||||
Inquiry.status == InquiryStatus.PENDING
|
||||
).scalar() or 0
|
||||
|
||||
pending_dealer_apps = db.query(func.count(DealerApplication.id)).filter(
|
||||
DealerApplication.status == "pending"
|
||||
).scalar() or 0
|
||||
|
||||
pending_withdrawals = db.query(func.count(WithdrawalRequest.id)).filter(
|
||||
WithdrawalRequest.status == "pending"
|
||||
).scalar() or 0
|
||||
|
||||
return {
|
||||
"pending_requests": pending_requests,
|
||||
"pending_inquiries": pending_inquiries,
|
||||
"pending_dealer_applications": pending_dealer_apps,
|
||||
"pending_withdrawals": pending_withdrawals,
|
||||
"total_pending": pending_requests + pending_inquiries + pending_dealer_apps + pending_withdrawals
|
||||
}
|
||||
254
backend/app/api/dealer.py
Normal file
254
backend/app/api/dealer.py
Normal file
@@ -0,0 +1,254 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from ..database import get_db
|
||||
from ..models import User, DealerApplication, DealerInfo
|
||||
from ..models.dealer import generate_dealer_code
|
||||
from ..schemas import (
|
||||
DealerApplicationCreate, DealerApplicationResponse,
|
||||
DealerApplicationReject, DealerInfoResponse, DealerPublicInfo,
|
||||
)
|
||||
from .auth import get_current_user
|
||||
from .notification import notify_dealer_approved, notify_dealer_rejected
|
||||
|
||||
router = APIRouter(prefix="/dealer", tags=["dealer"])
|
||||
|
||||
|
||||
@router.post("/apply", response_model=DealerApplicationResponse)
|
||||
def apply_dealer(
|
||||
application: DealerApplicationCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Submit a dealer application"""
|
||||
# Check if user already has a pending or approved application
|
||||
existing = db.query(DealerApplication).filter(
|
||||
DealerApplication.user_id == current_user.id,
|
||||
DealerApplication.status.in_(["pending", "approved"])
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
if existing.status == "approved":
|
||||
raise HTTPException(status_code=400, detail="You are already a dealer")
|
||||
raise HTTPException(status_code=400, detail="You already have a pending application")
|
||||
|
||||
# Check if user is already a dealer
|
||||
if current_user.is_dealer:
|
||||
raise HTTPException(status_code=400, detail="You are already a dealer")
|
||||
|
||||
# Create new application
|
||||
new_application = DealerApplication(
|
||||
user_id=current_user.id,
|
||||
business_name=application.business_name,
|
||||
business_number=application.business_number,
|
||||
real_name=application.real_name,
|
||||
id_number_encrypted=application.id_number, # TODO: Encrypt this properly
|
||||
phone=application.phone,
|
||||
bank_name=application.bank_name,
|
||||
bank_account=application.bank_account,
|
||||
account_holder=application.account_holder,
|
||||
photo_url=application.photo_url,
|
||||
status="pending"
|
||||
)
|
||||
|
||||
db.add(new_application)
|
||||
db.commit()
|
||||
db.refresh(new_application)
|
||||
return new_application
|
||||
|
||||
|
||||
@router.get("/my-application", response_model=DealerApplicationResponse)
|
||||
def get_my_application(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's dealer application"""
|
||||
application = db.query(DealerApplication).filter(
|
||||
DealerApplication.user_id == current_user.id
|
||||
).order_by(DealerApplication.applied_at.desc()).first()
|
||||
|
||||
if not application:
|
||||
raise HTTPException(status_code=404, detail="No application found")
|
||||
|
||||
return application
|
||||
|
||||
|
||||
@router.get("/my-info", response_model=DealerInfoResponse)
|
||||
def get_my_dealer_info(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's dealer info (if approved)"""
|
||||
if not current_user.is_dealer:
|
||||
raise HTTPException(status_code=403, detail="You are not a dealer")
|
||||
|
||||
dealer_info = db.query(DealerInfo).filter(
|
||||
DealerInfo.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not dealer_info:
|
||||
raise HTTPException(status_code=404, detail="Dealer info not found")
|
||||
|
||||
return dealer_info
|
||||
|
||||
|
||||
@router.get("/list", response_model=List[DealerPublicInfo])
|
||||
def list_dealers(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get list of active dealers (public info only)"""
|
||||
dealers = db.query(DealerInfo).filter(
|
||||
DealerInfo.is_active == True
|
||||
).all()
|
||||
return dealers
|
||||
|
||||
|
||||
# Admin endpoints
|
||||
@router.get("/admin/applications", response_model=List[DealerApplicationResponse])
|
||||
def get_applications(
|
||||
status_filter: str = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Get all dealer applications"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
query = db.query(DealerApplication)
|
||||
if status_filter:
|
||||
query = query.filter(DealerApplication.status == status_filter)
|
||||
|
||||
applications = query.order_by(DealerApplication.applied_at.desc()).all()
|
||||
return applications
|
||||
|
||||
|
||||
@router.put("/admin/applications/{application_id}/approve", response_model=DealerInfoResponse)
|
||||
def approve_application(
|
||||
application_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Approve a dealer application"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
application = db.query(DealerApplication).filter(
|
||||
DealerApplication.id == application_id
|
||||
).first()
|
||||
|
||||
if not application:
|
||||
raise HTTPException(status_code=404, detail="Application not found")
|
||||
|
||||
if application.status != "pending":
|
||||
raise HTTPException(status_code=400, detail="Application is not pending")
|
||||
|
||||
# Generate unique dealer code
|
||||
dealer_code = generate_dealer_code()
|
||||
while db.query(DealerInfo).filter(DealerInfo.dealer_code == dealer_code).first():
|
||||
dealer_code = generate_dealer_code()
|
||||
|
||||
# Create dealer info
|
||||
dealer_info = DealerInfo(
|
||||
user_id=application.user_id,
|
||||
dealer_code=dealer_code,
|
||||
business_name=application.business_name,
|
||||
real_name=application.real_name,
|
||||
phone=application.phone,
|
||||
photo_url=application.photo_url,
|
||||
bank_name=application.bank_name,
|
||||
bank_account=application.bank_account,
|
||||
account_holder=application.account_holder,
|
||||
)
|
||||
|
||||
# Update application status
|
||||
application.status = "approved"
|
||||
application.approved_at = datetime.utcnow()
|
||||
|
||||
# Update user is_dealer flag
|
||||
user = db.query(User).filter(User.id == application.user_id).first()
|
||||
user.is_dealer = True
|
||||
|
||||
db.add(dealer_info)
|
||||
db.commit()
|
||||
db.refresh(dealer_info)
|
||||
|
||||
# TODO: Generate dealer card image here
|
||||
# dealer_info.dealer_card_url = generate_dealer_card(dealer_info)
|
||||
# db.commit()
|
||||
|
||||
# Send notification to user about dealer approval
|
||||
notify_dealer_approved(db, application.user_id, dealer_code)
|
||||
|
||||
return dealer_info
|
||||
|
||||
|
||||
@router.put("/admin/applications/{application_id}/reject")
|
||||
def reject_application(
|
||||
application_id: int,
|
||||
reject_data: DealerApplicationReject,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Reject a dealer application"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
application = db.query(DealerApplication).filter(
|
||||
DealerApplication.id == application_id
|
||||
).first()
|
||||
|
||||
if not application:
|
||||
raise HTTPException(status_code=404, detail="Application not found")
|
||||
|
||||
if application.status != "pending":
|
||||
raise HTTPException(status_code=400, detail="Application is not pending")
|
||||
|
||||
application.status = "rejected"
|
||||
application.rejected_reason = reject_data.reason
|
||||
|
||||
db.commit()
|
||||
|
||||
# Send notification to user about dealer rejection
|
||||
notify_dealer_rejected(db, application.user_id, reject_data.reason)
|
||||
|
||||
return {"message": "Application rejected", "reason": reject_data.reason}
|
||||
|
||||
|
||||
@router.get("/admin/dealers", response_model=List[DealerInfoResponse])
|
||||
def get_all_dealers(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Get all dealers with full info"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
dealers = db.query(DealerInfo).all()
|
||||
return dealers
|
||||
|
||||
|
||||
@router.put("/admin/dealers/{dealer_id}/toggle-active")
|
||||
def toggle_dealer_active(
|
||||
dealer_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Toggle dealer active status"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
dealer = db.query(DealerInfo).filter(DealerInfo.id == dealer_id).first()
|
||||
if not dealer:
|
||||
raise HTTPException(status_code=404, detail="Dealer not found")
|
||||
|
||||
dealer.is_active = not dealer.is_active
|
||||
|
||||
# Also update user's is_dealer status
|
||||
user = db.query(User).filter(User.id == dealer.user_id).first()
|
||||
if user:
|
||||
user.is_dealer = dealer.is_active
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"message": f"Dealer {'activated' if dealer.is_active else 'deactivated'}", "is_active": dealer.is_active}
|
||||
247
backend/app/api/exchange_rate.py
Normal file
247
backend/app/api/exchange_rate.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
Exchange Rate API - 환율 정보 조회 (한국수출입은행 API 연동)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from ..database import get_db
|
||||
from ..models.exchange_rate import ExchangeRate, ExchangeRateHistory
|
||||
from ..models.user import User
|
||||
from .auth import get_current_admin_user
|
||||
from ..services.exchange_rate_service import (
|
||||
update_exchange_rates,
|
||||
get_all_exchange_rates,
|
||||
convert_krw_to_currency,
|
||||
SUPPORTED_CURRENCIES
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/exchange-rate", tags=["Exchange Rate"])
|
||||
|
||||
|
||||
class ExchangeRateData(BaseModel):
|
||||
currency_code: str
|
||||
currency_name: str
|
||||
symbol: str
|
||||
deal_base_rate: float # 매매기준율 (1 USD = X KRW)
|
||||
ttb_rate: float # 전신환 받을때
|
||||
tts_rate: float # 전신환 보낼때
|
||||
weight_percent: float # 가중치 (%)
|
||||
adjusted_rate: float # 가중치 적용 환율
|
||||
source_date: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class ExchangeRatesResponse(BaseModel):
|
||||
base_currency: str
|
||||
rates: List[ExchangeRateData]
|
||||
source: str
|
||||
last_updated: str
|
||||
|
||||
|
||||
class ExchangeRateWeightUpdate(BaseModel):
|
||||
currency_code: str
|
||||
weight_percent: float
|
||||
|
||||
|
||||
class ConvertRequest(BaseModel):
|
||||
amount: float
|
||||
from_currency: str = "KRW"
|
||||
to_currency: str
|
||||
|
||||
|
||||
class ConvertResponse(BaseModel):
|
||||
original_amount: float
|
||||
from_currency: str
|
||||
converted_amount: float
|
||||
to_currency: str
|
||||
rate_used: float
|
||||
|
||||
|
||||
@router.get("", response_model=ExchangeRatesResponse)
|
||||
async def get_exchange_rates(db: Session = Depends(get_db)):
|
||||
"""환율 정보 조회"""
|
||||
|
||||
rates = get_all_exchange_rates(db)
|
||||
|
||||
# DB에 데이터가 없으면 업데이트 시도
|
||||
if not rates:
|
||||
await update_exchange_rates(db)
|
||||
rates = get_all_exchange_rates(db)
|
||||
|
||||
rate_list = []
|
||||
for rate in rates:
|
||||
symbol = SUPPORTED_CURRENCIES.get(rate.currency_code, {}).get("symbol", "")
|
||||
rate_list.append(ExchangeRateData(
|
||||
currency_code=rate.currency_code,
|
||||
currency_name=rate.currency_name,
|
||||
symbol=symbol,
|
||||
deal_base_rate=rate.deal_base_rate,
|
||||
ttb_rate=rate.ttb_rate or rate.deal_base_rate,
|
||||
tts_rate=rate.tts_rate or rate.deal_base_rate,
|
||||
weight_percent=rate.weight_percent or 0.0,
|
||||
adjusted_rate=rate.adjusted_rate or rate.deal_base_rate,
|
||||
source_date=rate.source_date or "",
|
||||
updated_at=rate.updated_at.isoformat() if rate.updated_at else ""
|
||||
))
|
||||
|
||||
last_updated = ""
|
||||
if rates:
|
||||
latest = max(rates, key=lambda r: r.updated_at if r.updated_at else datetime.min)
|
||||
last_updated = latest.updated_at.isoformat() if latest.updated_at else ""
|
||||
|
||||
return ExchangeRatesResponse(
|
||||
base_currency="KRW",
|
||||
rates=rate_list,
|
||||
source="koreaexim",
|
||||
last_updated=last_updated
|
||||
)
|
||||
|
||||
|
||||
@router.get("/currency/{currency_code}")
|
||||
async def get_single_rate(currency_code: str, db: Session = Depends(get_db)):
|
||||
"""특정 통화 환율 조회"""
|
||||
rate = db.query(ExchangeRate).filter(
|
||||
ExchangeRate.currency_code == currency_code.upper(),
|
||||
ExchangeRate.is_active == True
|
||||
).first()
|
||||
|
||||
if not rate:
|
||||
raise HTTPException(status_code=404, detail=f"Currency {currency_code} not found")
|
||||
|
||||
symbol = SUPPORTED_CURRENCIES.get(rate.currency_code, {}).get("symbol", "")
|
||||
|
||||
return {
|
||||
"currency_code": rate.currency_code,
|
||||
"currency_name": rate.currency_name,
|
||||
"symbol": symbol,
|
||||
"deal_base_rate": rate.deal_base_rate,
|
||||
"adjusted_rate": rate.adjusted_rate,
|
||||
"weight_percent": rate.weight_percent,
|
||||
"source_date": rate.source_date,
|
||||
"updated_at": rate.updated_at.isoformat() if rate.updated_at else None
|
||||
}
|
||||
|
||||
|
||||
@router.post("/convert", response_model=ConvertResponse)
|
||||
async def convert_currency(
|
||||
request: ConvertRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""통화 변환"""
|
||||
if request.from_currency.upper() != "KRW":
|
||||
raise HTTPException(status_code=400, detail="Currently only KRW conversion is supported")
|
||||
|
||||
converted = convert_krw_to_currency(db, request.amount, request.to_currency.upper())
|
||||
|
||||
if converted is None:
|
||||
raise HTTPException(status_code=404, detail=f"Currency {request.to_currency} not found")
|
||||
|
||||
rate = db.query(ExchangeRate).filter(
|
||||
ExchangeRate.currency_code == request.to_currency.upper()
|
||||
).first()
|
||||
|
||||
return ConvertResponse(
|
||||
original_amount=request.amount,
|
||||
from_currency=request.from_currency.upper(),
|
||||
converted_amount=round(converted, 2),
|
||||
to_currency=request.to_currency.upper(),
|
||||
rate_used=rate.adjusted_rate if rate else 0
|
||||
)
|
||||
|
||||
|
||||
@router.get("/weights")
|
||||
async def get_exchange_rate_weights(db: Session = Depends(get_db)):
|
||||
"""환율 가중치 설정 조회"""
|
||||
rates = get_all_exchange_rates(db)
|
||||
|
||||
return {
|
||||
rate.currency_code.lower(): rate.weight_percent or 0.0
|
||||
for rate in rates
|
||||
}
|
||||
|
||||
|
||||
@router.put("/weights/{currency_code}")
|
||||
async def update_exchange_rate_weight(
|
||||
currency_code: str,
|
||||
weight_percent: float,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""환율 가중치 수정 (관리자 전용)"""
|
||||
rate = db.query(ExchangeRate).filter(
|
||||
ExchangeRate.currency_code == currency_code.upper()
|
||||
).first()
|
||||
|
||||
if not rate:
|
||||
raise HTTPException(status_code=404, detail=f"Currency {currency_code} not found")
|
||||
|
||||
rate.weight_percent = weight_percent
|
||||
rate.adjusted_rate = rate.deal_base_rate * (1 + weight_percent / 100)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Weight updated successfully",
|
||||
"currency_code": rate.currency_code,
|
||||
"weight_percent": rate.weight_percent,
|
||||
"adjusted_rate": rate.adjusted_rate
|
||||
}
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh_exchange_rates(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""환율 강제 갱신 (관리자 전용)"""
|
||||
result = await update_exchange_rates(db, force=True)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/history/{currency_code}")
|
||||
async def get_exchange_rate_history(
|
||||
currency_code: str,
|
||||
limit: int = 30,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""환율 변동 이력 조회"""
|
||||
history = db.query(ExchangeRateHistory).filter(
|
||||
ExchangeRateHistory.currency_code == currency_code.upper()
|
||||
).order_by(ExchangeRateHistory.created_at.desc()).limit(limit).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"currency_code": h.currency_code,
|
||||
"deal_base_rate": h.deal_base_rate,
|
||||
"source_date": h.source_date,
|
||||
"created_at": h.created_at.isoformat() if h.created_at else None
|
||||
}
|
||||
for h in history
|
||||
]
|
||||
|
||||
|
||||
# 프론트엔드용 간단 API
|
||||
@router.get("/simple")
|
||||
async def get_simple_rates(db: Session = Depends(get_db)):
|
||||
"""프론트엔드용 간단 환율 정보"""
|
||||
rates = get_all_exchange_rates(db)
|
||||
|
||||
# DB에 데이터가 없으면 업데이트 시도
|
||||
if not rates:
|
||||
await update_exchange_rates(db)
|
||||
rates = get_all_exchange_rates(db)
|
||||
|
||||
result = {}
|
||||
for rate in rates:
|
||||
result[rate.currency_code] = {
|
||||
"rate": rate.adjusted_rate, # KRW per 1 unit (e.g., 1 USD = 1450 KRW)
|
||||
"symbol": SUPPORTED_CURRENCIES.get(rate.currency_code, {}).get("symbol", ""),
|
||||
"name": rate.currency_name
|
||||
}
|
||||
|
||||
return result
|
||||
265
backend/app/api/hero_banners.py
Normal file
265
backend/app/api/hero_banners.py
Normal file
@@ -0,0 +1,265 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import os
|
||||
import uuid
|
||||
import aiofiles
|
||||
|
||||
from ..database import get_db
|
||||
from ..models.hero_banner import HeroBanner, HeroBannerSettings
|
||||
from ..schemas.hero_banner import (
|
||||
HeroBannerCreate, HeroBannerUpdate, HeroBannerResponse,
|
||||
HeroBannerListResponse, HeroBannerLocalizedResponse,
|
||||
HeroBannerSettingsUpdate, HeroBannerSettingsResponse,
|
||||
)
|
||||
from .auth import get_current_user
|
||||
from ..models import User
|
||||
from ..config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/hero-banners", tags=["hero-banners"])
|
||||
|
||||
settings = get_settings()
|
||||
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
|
||||
|
||||
def get_localized_field(obj, field: str, lang: str) -> Optional[str]:
|
||||
"""Get localized field value with fallback to Korean then English"""
|
||||
localized = getattr(obj, f"{field}_{lang}", None)
|
||||
if localized:
|
||||
return localized
|
||||
# Fallback to Korean
|
||||
ko_value = getattr(obj, f"{field}_ko", None)
|
||||
if ko_value:
|
||||
return ko_value
|
||||
# Fallback to English
|
||||
return getattr(obj, f"{field}_en", None)
|
||||
|
||||
|
||||
# ==================== Public Endpoints ====================
|
||||
|
||||
@router.get("/", response_model=List[HeroBannerLocalizedResponse])
|
||||
def get_hero_banners(
|
||||
lang: str = Query("ko", regex="^(ko|en|mn)$"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""활성 히어로 배너 목록 조회 (Public)"""
|
||||
banners = db.query(HeroBanner).filter(
|
||||
HeroBanner.is_active == True
|
||||
).order_by(HeroBanner.display_order.asc(), HeroBanner.id.desc()).all()
|
||||
|
||||
result = []
|
||||
for b in banners:
|
||||
result.append(HeroBannerLocalizedResponse(
|
||||
id=b.id,
|
||||
title=get_localized_field(b, "title", lang),
|
||||
subtitle=get_localized_field(b, "subtitle", lang),
|
||||
image_url=b.image_url,
|
||||
link_url=b.link_url,
|
||||
car_id=b.car_id,
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/check-car/{car_id}")
|
||||
def check_banner_car(car_id: int, db: Session = Depends(get_db)):
|
||||
"""차량이 Hero Banner에 연결되어 있는지 확인 (Public)
|
||||
|
||||
Banner에 연결된 차량은 샘플로 모든 정보를 무료로 공개합니다.
|
||||
"""
|
||||
banner = db.query(HeroBanner).filter(
|
||||
HeroBanner.car_id == car_id,
|
||||
HeroBanner.is_active == True
|
||||
).first()
|
||||
|
||||
return {
|
||||
"car_id": car_id,
|
||||
"is_banner_car": banner is not None,
|
||||
"banner_id": banner.id if banner else None
|
||||
}
|
||||
|
||||
|
||||
@router.get("/settings", response_model=HeroBannerSettingsResponse)
|
||||
def get_banner_settings(db: Session = Depends(get_db)):
|
||||
"""배너 슬라이더 설정 조회 (Public)"""
|
||||
settings_obj = db.query(HeroBannerSettings).first()
|
||||
if not settings_obj:
|
||||
# 기본 설정 생성
|
||||
settings_obj = HeroBannerSettings(
|
||||
slide_interval=3000,
|
||||
animation_type="film-strip",
|
||||
image_width=500,
|
||||
image_height=300,
|
||||
auto_play=True,
|
||||
)
|
||||
db.add(settings_obj)
|
||||
db.commit()
|
||||
db.refresh(settings_obj)
|
||||
return settings_obj
|
||||
|
||||
|
||||
# ==================== Admin Endpoints ====================
|
||||
|
||||
def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""관리자 권한 확인 (임시: 모든 로그인 사용자 허용)"""
|
||||
# TODO: 실제 관리자 역할 체크 추가
|
||||
# if current_user.role != "admin":
|
||||
# raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/admin/list", response_model=List[HeroBannerListResponse])
|
||||
def admin_get_banners(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""모든 히어로 배너 조회 (Admin)"""
|
||||
banners = db.query(HeroBanner).order_by(
|
||||
HeroBanner.display_order.asc(),
|
||||
HeroBanner.id.desc()
|
||||
).all()
|
||||
return banners
|
||||
|
||||
|
||||
@router.get("/admin/{banner_id}", response_model=HeroBannerResponse)
|
||||
def admin_get_banner(
|
||||
banner_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""히어로 배너 상세 조회 (Admin)"""
|
||||
banner = db.query(HeroBanner).filter(HeroBanner.id == banner_id).first()
|
||||
if not banner:
|
||||
raise HTTPException(status_code=404, detail="Banner not found")
|
||||
return banner
|
||||
|
||||
|
||||
@router.post("/admin", response_model=HeroBannerResponse)
|
||||
def create_banner(
|
||||
banner_data: HeroBannerCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""히어로 배너 생성 (Admin)"""
|
||||
banner = HeroBanner(**banner_data.model_dump())
|
||||
db.add(banner)
|
||||
db.commit()
|
||||
db.refresh(banner)
|
||||
return banner
|
||||
|
||||
|
||||
@router.put("/admin/{banner_id}", response_model=HeroBannerResponse)
|
||||
def update_banner(
|
||||
banner_id: int,
|
||||
banner_data: HeroBannerUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""히어로 배너 수정 (Admin)"""
|
||||
banner = db.query(HeroBanner).filter(HeroBanner.id == banner_id).first()
|
||||
if not banner:
|
||||
raise HTTPException(status_code=404, detail="Banner not found")
|
||||
|
||||
update_data = banner_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(banner, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(banner)
|
||||
return banner
|
||||
|
||||
|
||||
@router.delete("/admin/{banner_id}")
|
||||
def delete_banner(
|
||||
banner_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""히어로 배너 삭제 (Admin)"""
|
||||
banner = db.query(HeroBanner).filter(HeroBanner.id == banner_id).first()
|
||||
if not banner:
|
||||
raise HTTPException(status_code=404, detail="Banner not found")
|
||||
|
||||
# 로컬 이미지 파일 삭제
|
||||
if banner.image_url and banner.image_url.startswith("/uploads/"):
|
||||
try:
|
||||
filepath = os.path.join(settings.UPLOAD_DIR if hasattr(settings, 'UPLOAD_DIR') else "./uploads",
|
||||
os.path.basename(banner.image_url))
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db.delete(banner)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Banner deleted successfully"}
|
||||
|
||||
|
||||
@router.put("/admin/settings", response_model=HeroBannerSettingsResponse)
|
||||
def update_banner_settings(
|
||||
settings_data: HeroBannerSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""배너 슬라이더 설정 수정 (Admin)"""
|
||||
settings_obj = db.query(HeroBannerSettings).first()
|
||||
if not settings_obj:
|
||||
settings_obj = HeroBannerSettings()
|
||||
db.add(settings_obj)
|
||||
|
||||
update_data = settings_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(settings_obj, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(settings_obj)
|
||||
return settings_obj
|
||||
|
||||
|
||||
# ==================== Image Upload ====================
|
||||
|
||||
@router.post("/admin/upload-image")
|
||||
async def upload_banner_image(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""배너 이미지 업로드 (Admin)"""
|
||||
# 파일 확장자 검증
|
||||
ext = os.path.splitext(file.filename)[1].lower()
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"File type not allowed. Allowed: {ALLOWED_EXTENSIONS}"
|
||||
)
|
||||
|
||||
# 파일 읽기 및 크기 검증
|
||||
contents = await file.read()
|
||||
max_size = 10 * 1024 * 1024 # 10MB
|
||||
if len(contents) > max_size:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"File too large. Max size: {max_size / 1024 / 1024}MB"
|
||||
)
|
||||
|
||||
# 업로드 디렉토리 생성
|
||||
upload_dir = "./uploads/hero-banners"
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
# 고유 파일명 생성
|
||||
filename = f"hero_{uuid.uuid4()}{ext}"
|
||||
filepath = os.path.join(upload_dir, filename)
|
||||
|
||||
# 파일 저장
|
||||
async with aiofiles.open(filepath, 'wb') as f:
|
||||
await f.write(contents)
|
||||
|
||||
# 상대 URL 반환
|
||||
image_url = f"/uploads/hero-banners/{filename}"
|
||||
|
||||
return {
|
||||
"message": "Image uploaded successfully",
|
||||
"image_url": image_url,
|
||||
"filename": filename,
|
||||
}
|
||||
326
backend/app/api/inquiries.py
Normal file
326
backend/app/api/inquiries.py
Normal file
@@ -0,0 +1,326 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import User
|
||||
from ..models.inquiry import Inquiry, InquiryMessage, InquiryStatus
|
||||
from ..schemas.inquiry import (
|
||||
InquiryCreate, InquiryResponse, InquiryListResponse,
|
||||
InquiryMessageCreate, InquiryMessageResponse, InquiryWithMessages,
|
||||
AdminInquiryRespond, AdminInquiryUpdateStatus
|
||||
)
|
||||
from .auth import get_current_user
|
||||
from .notification import create_notification
|
||||
|
||||
router = APIRouter(prefix="/inquiries", tags=["inquiries"])
|
||||
|
||||
|
||||
# =====================
|
||||
# User Endpoints
|
||||
# =====================
|
||||
|
||||
@router.get("", response_model=List[InquiryResponse])
|
||||
def get_inquiries(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's inquiries (legacy endpoint)"""
|
||||
return db.query(Inquiry).filter(Inquiry.user_id == current_user.id).order_by(desc(Inquiry.created_at)).all()
|
||||
|
||||
|
||||
@router.post("", response_model=InquiryResponse)
|
||||
def create_inquiry(
|
||||
inquiry_data: InquiryCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new inquiry"""
|
||||
inquiry = Inquiry(
|
||||
user_id=current_user.id,
|
||||
car_id=inquiry_data.car_id,
|
||||
category=inquiry_data.category,
|
||||
subject=inquiry_data.subject or f"{inquiry_data.category} 문의",
|
||||
message=inquiry_data.message,
|
||||
contact_email=inquiry_data.contact_email or current_user.email,
|
||||
contact_phone=inquiry_data.contact_phone or current_user.phone,
|
||||
status=InquiryStatus.PENDING
|
||||
)
|
||||
|
||||
db.add(inquiry)
|
||||
db.commit()
|
||||
db.refresh(inquiry)
|
||||
|
||||
return inquiry
|
||||
|
||||
|
||||
@router.get("/my-inquiries", response_model=InquiryListResponse)
|
||||
def get_my_inquiries(
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
status: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's inquiries with pagination"""
|
||||
query = db.query(Inquiry).filter(Inquiry.user_id == current_user.id)
|
||||
|
||||
if status:
|
||||
query = query.filter(Inquiry.status == status)
|
||||
|
||||
total = query.count()
|
||||
inquiries = query.order_by(desc(Inquiry.created_at)) \
|
||||
.offset((page - 1) * page_size) \
|
||||
.limit(page_size) \
|
||||
.all()
|
||||
|
||||
return InquiryListResponse(
|
||||
inquiries=[InquiryResponse.model_validate(i) for i in inquiries],
|
||||
total=total
|
||||
)
|
||||
|
||||
|
||||
@router.get("/my-inquiries/{inquiry_id}", response_model=InquiryWithMessages)
|
||||
def get_my_inquiry_detail(
|
||||
inquiry_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get details of a specific inquiry with messages"""
|
||||
inquiry = db.query(Inquiry).filter(
|
||||
Inquiry.id == inquiry_id,
|
||||
Inquiry.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not inquiry:
|
||||
raise HTTPException(status_code=404, detail="Inquiry not found")
|
||||
|
||||
messages = db.query(InquiryMessage).filter(
|
||||
InquiryMessage.inquiry_id == inquiry_id
|
||||
).order_by(InquiryMessage.created_at).all()
|
||||
|
||||
return InquiryWithMessages(
|
||||
inquiry=InquiryResponse.model_validate(inquiry),
|
||||
messages=[InquiryMessageResponse.model_validate(m) for m in messages]
|
||||
)
|
||||
|
||||
|
||||
@router.post("/my-inquiries/{inquiry_id}/message", response_model=InquiryMessageResponse)
|
||||
def add_message_to_inquiry(
|
||||
inquiry_id: int,
|
||||
message_data: InquiryMessageCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Add a message to an existing inquiry"""
|
||||
inquiry = db.query(Inquiry).filter(
|
||||
Inquiry.id == inquiry_id,
|
||||
Inquiry.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not inquiry:
|
||||
raise HTTPException(status_code=404, detail="Inquiry not found")
|
||||
|
||||
if inquiry.status == InquiryStatus.CLOSED:
|
||||
raise HTTPException(status_code=400, detail="Cannot add message to closed inquiry")
|
||||
|
||||
message = InquiryMessage(
|
||||
inquiry_id=inquiry_id,
|
||||
user_id=current_user.id,
|
||||
message=message_data.message,
|
||||
is_admin=False
|
||||
)
|
||||
|
||||
# Update inquiry status if it was resolved
|
||||
if inquiry.status == InquiryStatus.RESOLVED:
|
||||
inquiry.status = InquiryStatus.IN_PROGRESS
|
||||
|
||||
db.add(message)
|
||||
db.commit()
|
||||
db.refresh(message)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
@router.get("/{inquiry_id}", response_model=InquiryResponse)
|
||||
def get_inquiry(
|
||||
inquiry_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get inquiry detail (legacy endpoint)"""
|
||||
inquiry = db.query(Inquiry).filter(
|
||||
Inquiry.id == inquiry_id,
|
||||
Inquiry.user_id == current_user.id
|
||||
).first()
|
||||
if not inquiry:
|
||||
raise HTTPException(status_code=404, detail="Inquiry not found")
|
||||
return inquiry
|
||||
|
||||
|
||||
# =====================
|
||||
# Admin Endpoints
|
||||
# =====================
|
||||
|
||||
@router.get("/admin/list", response_model=InquiryListResponse)
|
||||
def admin_get_all_inquiries(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
status: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Get all inquiries"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
query = db.query(Inquiry)
|
||||
|
||||
if status:
|
||||
query = query.filter(Inquiry.status == status)
|
||||
if category:
|
||||
query = query.filter(Inquiry.category == category)
|
||||
|
||||
total = query.count()
|
||||
inquiries = query.order_by(desc(Inquiry.created_at)) \
|
||||
.offset((page - 1) * page_size) \
|
||||
.limit(page_size) \
|
||||
.all()
|
||||
|
||||
return InquiryListResponse(
|
||||
inquiries=[InquiryResponse.model_validate(i) for i in inquiries],
|
||||
total=total
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/{inquiry_id}", response_model=InquiryWithMessages)
|
||||
def admin_get_inquiry_detail(
|
||||
inquiry_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Get inquiry details with messages"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
inquiry = db.query(Inquiry).filter(Inquiry.id == inquiry_id).first()
|
||||
|
||||
if not inquiry:
|
||||
raise HTTPException(status_code=404, detail="Inquiry not found")
|
||||
|
||||
messages = db.query(InquiryMessage).filter(
|
||||
InquiryMessage.inquiry_id == inquiry_id
|
||||
).order_by(InquiryMessage.created_at).all()
|
||||
|
||||
return InquiryWithMessages(
|
||||
inquiry=InquiryResponse.model_validate(inquiry),
|
||||
messages=[InquiryMessageResponse.model_validate(m) for m in messages]
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/{inquiry_id}/respond", response_model=InquiryMessageResponse)
|
||||
def admin_respond_to_inquiry(
|
||||
inquiry_id: int,
|
||||
response_data: AdminInquiryRespond,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Respond to an inquiry"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
inquiry = db.query(Inquiry).filter(Inquiry.id == inquiry_id).first()
|
||||
|
||||
if not inquiry:
|
||||
raise HTTPException(status_code=404, detail="Inquiry not found")
|
||||
|
||||
# Create message
|
||||
message = InquiryMessage(
|
||||
inquiry_id=inquiry_id,
|
||||
user_id=current_user.id,
|
||||
message=response_data.message,
|
||||
is_admin=True
|
||||
)
|
||||
|
||||
# Update inquiry
|
||||
inquiry.admin_response = response_data.message
|
||||
inquiry.responded_at = datetime.utcnow()
|
||||
inquiry.responded_by = current_user.id
|
||||
|
||||
if response_data.status:
|
||||
inquiry.status = response_data.status
|
||||
elif inquiry.status == InquiryStatus.PENDING:
|
||||
inquiry.status = InquiryStatus.IN_PROGRESS
|
||||
|
||||
db.add(message)
|
||||
db.commit()
|
||||
db.refresh(message)
|
||||
|
||||
# Send notification to user
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=inquiry.user_id,
|
||||
notification_type="system",
|
||||
title="문의 답변 도착",
|
||||
message=f"'{inquiry.subject}' 문의에 답변이 등록되었습니다.",
|
||||
link="/contact"
|
||||
)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
@router.put("/admin/{inquiry_id}/status", response_model=InquiryResponse)
|
||||
def admin_update_inquiry_status(
|
||||
inquiry_id: int,
|
||||
status_data: AdminInquiryUpdateStatus,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Update inquiry status"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
inquiry = db.query(Inquiry).filter(Inquiry.id == inquiry_id).first()
|
||||
|
||||
if not inquiry:
|
||||
raise HTTPException(status_code=404, detail="Inquiry not found")
|
||||
|
||||
valid_statuses = [InquiryStatus.PENDING, InquiryStatus.IN_PROGRESS, InquiryStatus.RESOLVED, InquiryStatus.CLOSED]
|
||||
if status_data.status not in valid_statuses:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid status. Must be one of: {valid_statuses}"
|
||||
)
|
||||
|
||||
inquiry.status = status_data.status
|
||||
db.commit()
|
||||
db.refresh(inquiry)
|
||||
|
||||
return inquiry
|
||||
|
||||
|
||||
@router.get("/admin/stats")
|
||||
def admin_get_inquiry_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Get inquiry statistics"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
total = db.query(Inquiry).count()
|
||||
pending = db.query(Inquiry).filter(Inquiry.status == InquiryStatus.PENDING).count()
|
||||
in_progress = db.query(Inquiry).filter(Inquiry.status == InquiryStatus.IN_PROGRESS).count()
|
||||
resolved = db.query(Inquiry).filter(Inquiry.status == InquiryStatus.RESOLVED).count()
|
||||
closed = db.query(Inquiry).filter(Inquiry.status == InquiryStatus.CLOSED).count()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"pending": pending,
|
||||
"in_progress": in_progress,
|
||||
"resolved": resolved,
|
||||
"closed": closed
|
||||
}
|
||||
363
backend/app/api/notification.py
Normal file
363
backend/app/api/notification.py
Normal file
@@ -0,0 +1,363 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import User, Notification
|
||||
from ..schemas.notification import (
|
||||
NotificationCreate, NotificationResponse,
|
||||
NotificationListResponse, NotificationMarkRead
|
||||
)
|
||||
from .auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
# =====================
|
||||
# Helper Functions
|
||||
# =====================
|
||||
|
||||
def create_notification(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
notification_type: str,
|
||||
title: str,
|
||||
message: str,
|
||||
link: Optional[str] = None,
|
||||
related_id: Optional[int] = None,
|
||||
related_type: Optional[str] = None
|
||||
) -> Notification:
|
||||
"""Create a new notification"""
|
||||
notification = Notification(
|
||||
user_id=user_id,
|
||||
notification_type=notification_type,
|
||||
title=title,
|
||||
message=message,
|
||||
link=link,
|
||||
related_id=related_id,
|
||||
related_type=related_type
|
||||
)
|
||||
db.add(notification)
|
||||
db.commit()
|
||||
db.refresh(notification)
|
||||
return notification
|
||||
|
||||
|
||||
def notify_vehicle_recommended(db: Session, user_id: int, request_id: int, vehicle_count: int):
|
||||
"""Notify user when vehicles are recommended for their request"""
|
||||
return create_notification(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
notification_type="vehicle_recommended",
|
||||
title="차량 추천 완료",
|
||||
message=f"{vehicle_count}대의 차량이 추천되었습니다. 지금 확인해보세요!",
|
||||
link=f"/my-request",
|
||||
related_id=request_id,
|
||||
related_type="vehicle_request"
|
||||
)
|
||||
|
||||
|
||||
def notify_shipping_update(db: Session, user_id: int, vehicle_id: int, status: int, car_name: str):
|
||||
"""Notify user when shipping status changes"""
|
||||
status_names = {
|
||||
1: "구매완료",
|
||||
2: "인천항 도착",
|
||||
3: "텐진항 도착",
|
||||
4: "자먼우드 도착",
|
||||
5: "울란바토르 도착",
|
||||
6: "통관 진행중",
|
||||
7: "배송완료"
|
||||
}
|
||||
status_name = status_names.get(status, f"상태 {status}")
|
||||
|
||||
return create_notification(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
notification_type="shipping_update",
|
||||
title="배송 상태 업데이트",
|
||||
message=f"{car_name}: {status_name}",
|
||||
link=f"/find-my-car",
|
||||
related_id=vehicle_id,
|
||||
related_type="purchased_vehicle"
|
||||
)
|
||||
|
||||
|
||||
def notify_withdrawal_processed(db: Session, user_id: int, withdrawal_id: int, status: str, amount: float):
|
||||
"""Notify user when withdrawal request is processed"""
|
||||
status_messages = {
|
||||
"approved": f"출금 신청이 승인되었습니다. {amount:,.0f}원이 곧 입금됩니다.",
|
||||
"completed": f"출금 완료! {amount:,.0f}원이 입금되었습니다.",
|
||||
"rejected": "출금 신청이 거부되었습니다. 관리자에게 문의해주세요."
|
||||
}
|
||||
|
||||
return create_notification(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
notification_type="withdrawal_processed",
|
||||
title="출금 처리 알림",
|
||||
message=status_messages.get(status, "출금 상태가 변경되었습니다."),
|
||||
link="/withdrawal",
|
||||
related_id=withdrawal_id,
|
||||
related_type="withdrawal"
|
||||
)
|
||||
|
||||
|
||||
def notify_referral_reward(db: Session, user_id: int, reward_amount: float, referred_name: str):
|
||||
"""Notify user when they receive referral reward"""
|
||||
return create_notification(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
notification_type="referral_reward",
|
||||
title="레퍼럴 보상 적립",
|
||||
message=f"{referred_name}님의 충전으로 {reward_amount:,.0f}원이 적립되었습니다!",
|
||||
link="/withdrawal",
|
||||
related_type="referral"
|
||||
)
|
||||
|
||||
|
||||
def notify_dealer_approved(db: Session, user_id: int, dealer_code: str):
|
||||
"""Notify user when dealer application is approved"""
|
||||
return create_notification(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
notification_type="dealer_approved",
|
||||
title="딜러 승인 완료",
|
||||
message=f"딜러 승인이 완료되었습니다! 딜러 코드: {dealer_code}",
|
||||
link="/dealer/my-card",
|
||||
related_type="dealer"
|
||||
)
|
||||
|
||||
|
||||
def notify_dealer_rejected(db: Session, user_id: int, reason: str):
|
||||
"""Notify user when dealer application is rejected"""
|
||||
return create_notification(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
notification_type="dealer_rejected",
|
||||
title="딜러 신청 거부",
|
||||
message=f"딜러 신청이 거부되었습니다. 사유: {reason}",
|
||||
link="/dealer/apply",
|
||||
related_type="dealer"
|
||||
)
|
||||
|
||||
|
||||
def notify_share_purchased(db: Session, user_id: int, share_id: int, reward_amount: float, car_name: str):
|
||||
"""Notify user when their shared vehicle is purchased"""
|
||||
return create_notification(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
notification_type="share_purchased",
|
||||
title="공유 차량 판매 완료",
|
||||
message=f"{car_name} 판매 완료! 리워드 {reward_amount:,.0f}원이 적립되었습니다.",
|
||||
link="/withdrawal",
|
||||
related_id=share_id,
|
||||
related_type="vehicle_share"
|
||||
)
|
||||
|
||||
|
||||
def notify_payment_confirmed(db: Session, user_id: int, charge_id: int, amount: float, cc_amount: int):
|
||||
"""Notify user when payment is confirmed"""
|
||||
return create_notification(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
notification_type="payment_confirmed",
|
||||
title="결제 확인 완료",
|
||||
message=f"결제가 확인되었습니다! ${amount:.2f} → {cc_amount} CC가 충전되었습니다.",
|
||||
link="/charge",
|
||||
related_id=charge_id,
|
||||
related_type="charge"
|
||||
)
|
||||
|
||||
|
||||
def notify_inquiry_reply(db: Session, user_id: int, inquiry_id: int, subject: str = None):
|
||||
"""Notify user when admin replies to their inquiry"""
|
||||
return create_notification(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
notification_type="inquiry_reply",
|
||||
title="문의 답변 등록",
|
||||
message=f"문의에 답변이 등록되었습니다." + (f" ({subject})" if subject else ""),
|
||||
link=f"/my-inquiries/{inquiry_id}",
|
||||
related_id=inquiry_id,
|
||||
related_type="inquiry"
|
||||
)
|
||||
|
||||
|
||||
def notify_system(db: Session, user_id: int, title: str, message: str, link: Optional[str] = None):
|
||||
"""Send a general system notification to a user"""
|
||||
return create_notification(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
notification_type="system",
|
||||
title=title,
|
||||
message=message,
|
||||
link=link
|
||||
)
|
||||
|
||||
|
||||
# =====================
|
||||
# User Endpoints
|
||||
# =====================
|
||||
|
||||
@router.get("/", response_model=NotificationListResponse)
|
||||
def get_notifications(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
unread_only: bool = False,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get user's notifications"""
|
||||
query = db.query(Notification).filter(Notification.user_id == current_user.id)
|
||||
|
||||
if unread_only:
|
||||
query = query.filter(Notification.is_read == False)
|
||||
|
||||
total = query.count()
|
||||
unread_count = db.query(Notification).filter(
|
||||
Notification.user_id == current_user.id,
|
||||
Notification.is_read == False
|
||||
).count()
|
||||
|
||||
notifications = query.order_by(desc(Notification.created_at)) \
|
||||
.offset((page - 1) * page_size) \
|
||||
.limit(page_size) \
|
||||
.all()
|
||||
|
||||
return NotificationListResponse(
|
||||
notifications=[NotificationResponse.model_validate(n) for n in notifications],
|
||||
unread_count=unread_count,
|
||||
total=total
|
||||
)
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
def get_unread_count(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get count of unread notifications"""
|
||||
count = db.query(Notification).filter(
|
||||
Notification.user_id == current_user.id,
|
||||
Notification.is_read == False
|
||||
).count()
|
||||
|
||||
return {"unread_count": count}
|
||||
|
||||
|
||||
@router.post("/mark-read")
|
||||
def mark_as_read(
|
||||
data: NotificationMarkRead,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Mark notifications as read"""
|
||||
notifications = db.query(Notification).filter(
|
||||
Notification.id.in_(data.notification_ids),
|
||||
Notification.user_id == current_user.id
|
||||
).all()
|
||||
|
||||
for notification in notifications:
|
||||
notification.is_read = True
|
||||
notification.read_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"message": f"Marked {len(notifications)} notifications as read"}
|
||||
|
||||
|
||||
@router.post("/mark-all-read")
|
||||
def mark_all_as_read(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Mark all notifications as read"""
|
||||
count = db.query(Notification).filter(
|
||||
Notification.user_id == current_user.id,
|
||||
Notification.is_read == False
|
||||
).update({
|
||||
"is_read": True,
|
||||
"read_at": datetime.utcnow()
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"message": f"Marked {count} notifications as read"}
|
||||
|
||||
|
||||
@router.delete("/{notification_id}")
|
||||
def delete_notification(
|
||||
notification_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete a notification"""
|
||||
notification = db.query(Notification).filter(
|
||||
Notification.id == notification_id,
|
||||
Notification.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not notification:
|
||||
raise HTTPException(status_code=404, detail="Notification not found")
|
||||
|
||||
db.delete(notification)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Notification deleted"}
|
||||
|
||||
|
||||
# =====================
|
||||
# Admin Endpoints
|
||||
# =====================
|
||||
|
||||
@router.post("/admin/send")
|
||||
def admin_send_notification(
|
||||
notification_data: NotificationCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Send notification to a user"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
notification = create_notification(
|
||||
db=db,
|
||||
user_id=notification_data.user_id,
|
||||
notification_type=notification_data.notification_type,
|
||||
title=notification_data.title,
|
||||
message=notification_data.message,
|
||||
link=notification_data.link,
|
||||
related_id=notification_data.related_id,
|
||||
related_type=notification_data.related_type
|
||||
)
|
||||
|
||||
return NotificationResponse.model_validate(notification)
|
||||
|
||||
|
||||
@router.post("/admin/send-all")
|
||||
def admin_send_to_all(
|
||||
title: str,
|
||||
message: str,
|
||||
link: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Send notification to all users"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
users = db.query(User).filter(User.is_active == True).all()
|
||||
|
||||
for user in users:
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
notification_type="system",
|
||||
title=title,
|
||||
message=message,
|
||||
link=link
|
||||
)
|
||||
|
||||
return {"message": f"Sent notification to {len(users)} users"}
|
||||
276
backend/app/api/push.py
Normal file
276
backend/app/api/push.py
Normal file
@@ -0,0 +1,276 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from ..database import get_db
|
||||
from ..models import User, PushSubscription, UserNotificationPreference
|
||||
from .auth import get_current_user, get_current_admin_user
|
||||
|
||||
router = APIRouter(prefix="/push", tags=["Push Notifications"])
|
||||
|
||||
|
||||
# VAPID keys for web push (in production, store these securely)
|
||||
# Generate these using: npx web-push generate-vapid-keys
|
||||
VAPID_PUBLIC_KEY = "BMjR7pDj6PUjFo8VkA4f1BYhOAzGhJPcVnT7mJ6Bq8jG9yYKvN8dZ5jT3pQ2sL9wR0xF4bM1nK3vH5uC7yX2aE0"
|
||||
|
||||
|
||||
class PushSubscriptionCreate(BaseModel):
|
||||
endpoint: str
|
||||
p256dh_key: str
|
||||
auth_key: str
|
||||
device_info: Optional[str] = None
|
||||
|
||||
|
||||
class NotificationPreferenceUpdate(BaseModel):
|
||||
vehicle_recommended: Optional[bool] = None
|
||||
shipping_update: Optional[bool] = None
|
||||
payment_confirmed: Optional[bool] = None
|
||||
withdrawal_processed: Optional[bool] = None
|
||||
dealer_status: Optional[bool] = None
|
||||
share_purchased: Optional[bool] = None
|
||||
referral_reward: Optional[bool] = None
|
||||
inquiry_reply: Optional[bool] = None
|
||||
system_announcements: Optional[bool] = None
|
||||
push_enabled: Optional[bool] = None
|
||||
email_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("/vapid-key")
|
||||
def get_vapid_public_key():
|
||||
"""Get VAPID public key for push subscription"""
|
||||
return {"public_key": VAPID_PUBLIC_KEY}
|
||||
|
||||
|
||||
@router.post("/subscribe")
|
||||
def subscribe_push(
|
||||
subscription: PushSubscriptionCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Subscribe to push notifications"""
|
||||
# Check if subscription already exists
|
||||
existing = db.query(PushSubscription).filter(
|
||||
PushSubscription.user_id == current_user.id,
|
||||
PushSubscription.endpoint == subscription.endpoint
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# Update existing subscription
|
||||
existing.p256dh_key = subscription.p256dh_key
|
||||
existing.auth_key = subscription.auth_key
|
||||
existing.device_info = subscription.device_info
|
||||
existing.is_active = True
|
||||
existing.last_used_at = datetime.utcnow()
|
||||
else:
|
||||
# Create new subscription
|
||||
new_sub = PushSubscription(
|
||||
user_id=current_user.id,
|
||||
endpoint=subscription.endpoint,
|
||||
p256dh_key=subscription.p256dh_key,
|
||||
auth_key=subscription.auth_key,
|
||||
device_info=subscription.device_info,
|
||||
is_active=True
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
db.commit()
|
||||
return {"message": "Push subscription saved successfully"}
|
||||
|
||||
|
||||
@router.delete("/unsubscribe")
|
||||
def unsubscribe_push(
|
||||
endpoint: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Unsubscribe from push notifications"""
|
||||
subscription = db.query(PushSubscription).filter(
|
||||
PushSubscription.user_id == current_user.id,
|
||||
PushSubscription.endpoint == endpoint
|
||||
).first()
|
||||
|
||||
if subscription:
|
||||
subscription.is_active = False
|
||||
db.commit()
|
||||
|
||||
return {"message": "Push subscription removed"}
|
||||
|
||||
|
||||
@router.get("/subscriptions")
|
||||
def get_my_subscriptions(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get user's active push subscriptions"""
|
||||
subscriptions = db.query(PushSubscription).filter(
|
||||
PushSubscription.user_id == current_user.id,
|
||||
PushSubscription.is_active == True
|
||||
).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": sub.id,
|
||||
"endpoint": sub.endpoint[:50] + "..." if len(sub.endpoint) > 50 else sub.endpoint,
|
||||
"device_info": sub.device_info,
|
||||
"created_at": sub.created_at.isoformat() if sub.created_at else None,
|
||||
"last_used_at": sub.last_used_at.isoformat() if sub.last_used_at else None
|
||||
}
|
||||
for sub in subscriptions
|
||||
]
|
||||
|
||||
|
||||
@router.get("/preferences")
|
||||
def get_notification_preferences(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get user's notification preferences"""
|
||||
prefs = db.query(UserNotificationPreference).filter(
|
||||
UserNotificationPreference.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not prefs:
|
||||
# Create default preferences
|
||||
prefs = UserNotificationPreference(user_id=current_user.id)
|
||||
db.add(prefs)
|
||||
db.commit()
|
||||
db.refresh(prefs)
|
||||
|
||||
return {
|
||||
"vehicle_recommended": prefs.vehicle_recommended,
|
||||
"shipping_update": prefs.shipping_update,
|
||||
"payment_confirmed": prefs.payment_confirmed,
|
||||
"withdrawal_processed": prefs.withdrawal_processed,
|
||||
"dealer_status": prefs.dealer_status,
|
||||
"share_purchased": prefs.share_purchased,
|
||||
"referral_reward": prefs.referral_reward,
|
||||
"inquiry_reply": prefs.inquiry_reply,
|
||||
"system_announcements": prefs.system_announcements,
|
||||
"push_enabled": prefs.push_enabled,
|
||||
"email_enabled": prefs.email_enabled,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/preferences")
|
||||
def update_notification_preferences(
|
||||
preferences: NotificationPreferenceUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update user's notification preferences"""
|
||||
prefs = db.query(UserNotificationPreference).filter(
|
||||
UserNotificationPreference.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not prefs:
|
||||
prefs = UserNotificationPreference(user_id=current_user.id)
|
||||
db.add(prefs)
|
||||
|
||||
# Update preferences
|
||||
for field, value in preferences.dict(exclude_none=True).items():
|
||||
setattr(prefs, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(prefs)
|
||||
|
||||
return {"message": "Preferences updated successfully"}
|
||||
|
||||
|
||||
# Admin endpoints
|
||||
@router.get("/admin/stats")
|
||||
def admin_get_push_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get push notification statistics (Admin only)"""
|
||||
total_subscriptions = db.query(PushSubscription).filter(
|
||||
PushSubscription.is_active == True
|
||||
).count()
|
||||
|
||||
users_with_push = db.query(PushSubscription.user_id).filter(
|
||||
PushSubscription.is_active == True
|
||||
).distinct().count()
|
||||
|
||||
return {
|
||||
"total_subscriptions": total_subscriptions,
|
||||
"users_with_push": users_with_push
|
||||
}
|
||||
|
||||
|
||||
# Helper function to send push notification (called from other modules)
|
||||
def send_push_notification(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
title: str,
|
||||
body: str,
|
||||
url: str = None,
|
||||
notification_type: str = "system"
|
||||
):
|
||||
"""
|
||||
Send push notification to a user.
|
||||
In production, this would use pywebpush to actually send the notification.
|
||||
"""
|
||||
# Check user preferences
|
||||
prefs = db.query(UserNotificationPreference).filter(
|
||||
UserNotificationPreference.user_id == user_id
|
||||
).first()
|
||||
|
||||
if prefs and not prefs.push_enabled:
|
||||
return False
|
||||
|
||||
# Check specific notification type preference
|
||||
if prefs:
|
||||
type_pref_map = {
|
||||
"vehicle_recommended": prefs.vehicle_recommended,
|
||||
"shipping_update": prefs.shipping_update,
|
||||
"payment_confirmed": prefs.payment_confirmed,
|
||||
"withdrawal_processed": prefs.withdrawal_processed,
|
||||
"dealer_approved": prefs.dealer_status,
|
||||
"dealer_rejected": prefs.dealer_status,
|
||||
"share_purchased": prefs.share_purchased,
|
||||
"referral_reward": prefs.referral_reward,
|
||||
"inquiry_reply": prefs.inquiry_reply,
|
||||
"system": prefs.system_announcements,
|
||||
}
|
||||
if notification_type in type_pref_map and not type_pref_map[notification_type]:
|
||||
return False
|
||||
|
||||
# Get user's active subscriptions
|
||||
subscriptions = db.query(PushSubscription).filter(
|
||||
PushSubscription.user_id == user_id,
|
||||
PushSubscription.is_active == True
|
||||
).all()
|
||||
|
||||
if not subscriptions:
|
||||
return False
|
||||
|
||||
# In production, use pywebpush to send notifications
|
||||
# For now, we just log and return success
|
||||
# Example with pywebpush:
|
||||
# from pywebpush import webpush, WebPushException
|
||||
# for sub in subscriptions:
|
||||
# try:
|
||||
# webpush(
|
||||
# subscription_info={
|
||||
# "endpoint": sub.endpoint,
|
||||
# "keys": {
|
||||
# "p256dh": sub.p256dh_key,
|
||||
# "auth": sub.auth_key
|
||||
# }
|
||||
# },
|
||||
# data=json.dumps({
|
||||
# "title": title,
|
||||
# "body": body,
|
||||
# "url": url
|
||||
# }),
|
||||
# vapid_private_key=VAPID_PRIVATE_KEY,
|
||||
# vapid_claims={"sub": "mailto:admin@autosellcar.com"}
|
||||
# )
|
||||
# sub.last_used_at = datetime.utcnow()
|
||||
# except WebPushException as ex:
|
||||
# if ex.response and ex.response.status_code == 410:
|
||||
# sub.is_active = False
|
||||
# db.commit()
|
||||
|
||||
return True
|
||||
192
backend/app/api/referral.py
Normal file
192
backend/app/api/referral.py
Normal file
@@ -0,0 +1,192 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func as sql_func
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from ..database import get_db
|
||||
from ..models import User, ReferralReward, SystemSettings
|
||||
from ..schemas import (
|
||||
ReferralRewardResponse, ReferralStats,
|
||||
ReferralSettingsResponse, ReferralSettingsUpdate,
|
||||
)
|
||||
from .auth import get_current_user
|
||||
from .notification import notify_referral_reward
|
||||
|
||||
router = APIRouter(prefix="/referral", tags=["referral"])
|
||||
|
||||
|
||||
def get_referral_settings(db: Session) -> SystemSettings:
|
||||
"""Get or create system settings"""
|
||||
settings = db.query(SystemSettings).first()
|
||||
if not settings:
|
||||
settings = SystemSettings()
|
||||
db.add(settings)
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
return settings
|
||||
|
||||
|
||||
def create_referral_reward(
|
||||
referrer_id: int,
|
||||
referred_user_id: int,
|
||||
payment_amount: float,
|
||||
db: Session
|
||||
):
|
||||
"""Create a referral reward when a referred user makes a payment"""
|
||||
settings = get_referral_settings(db)
|
||||
|
||||
# Check if referral rewards are enabled
|
||||
if not settings.referral_reward_enabled:
|
||||
return None
|
||||
|
||||
# Check if this is a one_time reward and already exists
|
||||
if settings.referral_reward_type == "one_time":
|
||||
existing = db.query(ReferralReward).filter(
|
||||
ReferralReward.referrer_id == referrer_id,
|
||||
ReferralReward.referred_user_id == referred_user_id
|
||||
).first()
|
||||
if existing:
|
||||
return None # Already gave reward for this referral
|
||||
|
||||
# Calculate reward amount
|
||||
reward_amount = payment_amount * (settings.referral_reward_percent / 100)
|
||||
|
||||
# Create reward record
|
||||
reward = ReferralReward(
|
||||
referrer_id=referrer_id,
|
||||
referred_user_id=referred_user_id,
|
||||
payment_amount=payment_amount,
|
||||
reward_amount=reward_amount,
|
||||
status="credited", # Auto-credit for simplicity
|
||||
credited_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
db.add(reward)
|
||||
db.commit()
|
||||
db.refresh(reward)
|
||||
|
||||
# Send notification to referrer
|
||||
referred_user = db.query(User).filter(User.id == referred_user_id).first()
|
||||
referred_name = referred_user.name or referred_user.email if referred_user else "회원"
|
||||
notify_referral_reward(db, referrer_id, reward_amount, referred_name)
|
||||
|
||||
return reward
|
||||
|
||||
|
||||
@router.get("/my-link")
|
||||
def get_my_referral_link(current_user: User = Depends(get_current_user)):
|
||||
"""Get current user's referral link/code"""
|
||||
return {
|
||||
"referral_code": current_user.referral_code,
|
||||
"referral_link": f"/register?ref={current_user.referral_code}"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/my-rewards", response_model=List[ReferralRewardResponse])
|
||||
def get_my_rewards(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's referral rewards"""
|
||||
rewards = db.query(ReferralReward).filter(
|
||||
ReferralReward.referrer_id == current_user.id
|
||||
).order_by(ReferralReward.created_at.desc()).all()
|
||||
|
||||
return rewards
|
||||
|
||||
|
||||
@router.get("/stats", response_model=ReferralStats)
|
||||
def get_referral_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get referral statistics for current user"""
|
||||
# Get all rewards where user is the referrer
|
||||
rewards = db.query(ReferralReward).filter(
|
||||
ReferralReward.referrer_id == current_user.id
|
||||
).all()
|
||||
|
||||
# Count unique referred users
|
||||
referred_users = db.query(sql_func.count(sql_func.distinct(ReferralReward.referred_user_id))).filter(
|
||||
ReferralReward.referrer_id == current_user.id
|
||||
).scalar() or 0
|
||||
|
||||
total_rewards_earned = sum(r.reward_amount for r in rewards)
|
||||
total_rewards_credited = sum(r.reward_amount for r in rewards if r.status == "credited")
|
||||
total_rewards_pending = sum(r.reward_amount for r in rewards if r.status == "pending")
|
||||
total_withdrawn = sum(r.reward_amount for r in rewards if r.status == "withdrawn")
|
||||
|
||||
return ReferralStats(
|
||||
total_referrals=referred_users,
|
||||
total_rewards_earned=total_rewards_earned,
|
||||
total_rewards_credited=total_rewards_credited,
|
||||
total_rewards_pending=total_rewards_pending,
|
||||
available_for_withdrawal=total_rewards_credited - total_withdrawn
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings", response_model=ReferralSettingsResponse)
|
||||
def get_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get referral settings (public endpoint)"""
|
||||
settings = get_referral_settings(db)
|
||||
|
||||
return ReferralSettingsResponse(
|
||||
referral_reward_enabled=settings.referral_reward_enabled,
|
||||
referral_reward_percent=settings.referral_reward_percent,
|
||||
referral_reward_type=settings.referral_reward_type
|
||||
)
|
||||
|
||||
|
||||
# Admin endpoints
|
||||
@router.put("/admin/settings", response_model=ReferralSettingsResponse)
|
||||
def update_settings(
|
||||
update_data: ReferralSettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Update referral settings"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
settings = get_referral_settings(db)
|
||||
|
||||
if update_data.referral_reward_enabled is not None:
|
||||
settings.referral_reward_enabled = update_data.referral_reward_enabled
|
||||
|
||||
if update_data.referral_reward_percent is not None:
|
||||
if update_data.referral_reward_percent < 0 or update_data.referral_reward_percent > 100:
|
||||
raise HTTPException(status_code=400, detail="Reward percent must be between 0 and 100")
|
||||
settings.referral_reward_percent = update_data.referral_reward_percent
|
||||
|
||||
if update_data.referral_reward_type is not None:
|
||||
if update_data.referral_reward_type not in ["one_time", "recurring"]:
|
||||
raise HTTPException(status_code=400, detail="Reward type must be 'one_time' or 'recurring'")
|
||||
settings.referral_reward_type = update_data.referral_reward_type
|
||||
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
|
||||
return ReferralSettingsResponse(
|
||||
referral_reward_enabled=settings.referral_reward_enabled,
|
||||
referral_reward_percent=settings.referral_reward_percent,
|
||||
referral_reward_type=settings.referral_reward_type
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/all-rewards", response_model=List[ReferralRewardResponse])
|
||||
def admin_get_all_rewards(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Get all referral rewards"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
rewards = db.query(ReferralReward).order_by(
|
||||
ReferralReward.created_at.desc()
|
||||
).limit(100).all()
|
||||
|
||||
return rewards
|
||||
72
backend/app/api/settings.py
Normal file
72
backend/app/api/settings.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..models.settings import SystemSettings
|
||||
from ..schemas.settings import SystemSettingsUpdate, SystemSettingsResponse
|
||||
from .auth import get_current_user
|
||||
from ..models import User
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""관리자 권한 확인 (임시: 모든 로그인 사용자 허용)"""
|
||||
return current_user
|
||||
|
||||
|
||||
def get_or_create_settings(db: Session) -> SystemSettings:
|
||||
"""시스템 설정 조회 또는 기본값 생성"""
|
||||
settings = db.query(SystemSettings).first()
|
||||
if not settings:
|
||||
settings = SystemSettings(
|
||||
search_page_size=20,
|
||||
korea_margin_percent=5.0,
|
||||
mongolia_margin_percent=5.0,
|
||||
cc_per_usdc=1, # 1 USD = 1 CC
|
||||
cc_per_view=1, # 차량 상세 조회 시 1 CC
|
||||
cars_per_cc=3, # 1 CC = 3 recommended vehicles per request
|
||||
cc_signup_bonus=3, # 3 CC free on signup
|
||||
cache_ttl_hours=2,
|
||||
container_logistics_usd=3600,
|
||||
shoring_cost_usd=300,
|
||||
)
|
||||
db.add(settings)
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
return settings
|
||||
|
||||
|
||||
# ==================== Public Endpoints ====================
|
||||
|
||||
@router.get("/", response_model=SystemSettingsResponse)
|
||||
def get_system_settings(db: Session = Depends(get_db)):
|
||||
"""시스템 설정 조회 (Public)"""
|
||||
return get_or_create_settings(db)
|
||||
|
||||
|
||||
@router.get("/search-page-size")
|
||||
def get_search_page_size(db: Session = Depends(get_db)):
|
||||
"""검색 결과 페이지 크기 조회 (Public)"""
|
||||
settings = get_or_create_settings(db)
|
||||
return {"search_page_size": settings.search_page_size}
|
||||
|
||||
|
||||
# ==================== Admin Endpoints ====================
|
||||
|
||||
@router.put("/", response_model=SystemSettingsResponse)
|
||||
def update_system_settings(
|
||||
settings_data: SystemSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""시스템 설정 수정 (Admin)"""
|
||||
settings = get_or_create_settings(db)
|
||||
|
||||
update_data = settings_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(settings, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
return settings
|
||||
1018
backend/app/api/translations.py
Normal file
1018
backend/app/api/translations.py
Normal file
File diff suppressed because it is too large
Load Diff
385
backend/app/api/vehicle_requests.py
Normal file
385
backend/app/api/vehicle_requests.py
Normal file
@@ -0,0 +1,385 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import VehicleRequest, RequestVehicle, PurchasedVehicle, User, DealerInfo, SystemSettings
|
||||
from ..schemas import (
|
||||
VehicleRequestCreate, VehicleRequestResponse,
|
||||
RequestVehicleCreate, RequestVehicleResponse, RequestVehicleApprove,
|
||||
PurchasedVehicleCreate, PurchasedVehicleResponse, PurchasedVehicleUpdateStatus,
|
||||
VehicleRequestWithVehicles,
|
||||
)
|
||||
from .auth import get_current_user
|
||||
from .notification import notify_vehicle_recommended, notify_shipping_update
|
||||
|
||||
|
||||
def get_system_settings(db: Session) -> SystemSettings:
|
||||
"""Get or create system settings"""
|
||||
settings = db.query(SystemSettings).first()
|
||||
if not settings:
|
||||
settings = SystemSettings()
|
||||
db.add(settings)
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
return settings
|
||||
|
||||
|
||||
def calculate_dealer_commission(vehicle_price_krw: int, db: Session) -> tuple:
|
||||
"""Calculate dealer and platform commission based on Mongolia margin"""
|
||||
settings = get_system_settings(db)
|
||||
|
||||
# Calculate Mongolia margin (vehicle price * margin percent)
|
||||
mongolia_margin = vehicle_price_krw * (settings.mongolia_margin_percent / 100)
|
||||
|
||||
# 50/50 split between dealer and platform
|
||||
dealer_commission = int(mongolia_margin * 0.5)
|
||||
platform_commission = int(mongolia_margin * 0.5)
|
||||
|
||||
return dealer_commission, platform_commission
|
||||
|
||||
router = APIRouter(prefix="/vehicle-requests", tags=["vehicle-requests"])
|
||||
|
||||
# Development mode - skip 24 hour wait
|
||||
DEV_MODE = True
|
||||
|
||||
|
||||
# =====================
|
||||
# User Endpoints
|
||||
# =====================
|
||||
|
||||
QUOTE_REQUEST_COST = 1.0 # 1 CC for quote request submission
|
||||
|
||||
|
||||
@router.post("/", response_model=VehicleRequestResponse)
|
||||
def create_request(
|
||||
request_data: VehicleRequestCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new vehicle search request (costs 1 CC)"""
|
||||
# Check if user has enough CC
|
||||
if (current_user.cc_balance or 0) < QUOTE_REQUEST_COST:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Insufficient CC balance. You need {QUOTE_REQUEST_COST} CC to submit a vehicle request. Current balance: {current_user.cc_balance or 0}"
|
||||
)
|
||||
|
||||
# Deduct CC from user's balance
|
||||
current_user.cc_balance = (current_user.cc_balance or 0) - QUOTE_REQUEST_COST
|
||||
|
||||
# Create the request
|
||||
request = VehicleRequest(
|
||||
user_id=current_user.id,
|
||||
cc_paid=QUOTE_REQUEST_COST,
|
||||
**request_data.model_dump()
|
||||
)
|
||||
db.add(request)
|
||||
db.commit()
|
||||
db.refresh(request)
|
||||
return request
|
||||
|
||||
|
||||
@router.get("/my-requests", response_model=List[VehicleRequestWithVehicles])
|
||||
def get_my_requests(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's vehicle requests with approved vehicles"""
|
||||
requests = db.query(VehicleRequest).filter(
|
||||
VehicleRequest.user_id == current_user.id
|
||||
).order_by(VehicleRequest.created_at.desc()).all()
|
||||
|
||||
result = []
|
||||
for req in requests:
|
||||
# In dev mode, show all approved vehicles immediately
|
||||
# In production, only show after 24 hours
|
||||
if DEV_MODE or (req.created_at and datetime.utcnow() - req.created_at > timedelta(hours=24)):
|
||||
approved_vehicles = [v for v in req.recommended_vehicles if v.is_approved]
|
||||
else:
|
||||
approved_vehicles = []
|
||||
|
||||
result.append(VehicleRequestWithVehicles(
|
||||
request=VehicleRequestResponse.model_validate(req),
|
||||
approved_vehicles=[RequestVehicleResponse.model_validate(v) for v in approved_vehicles]
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# =====================
|
||||
# Purchased Vehicles (Find My Car)
|
||||
# =====================
|
||||
|
||||
@router.get("/purchased", response_model=List[PurchasedVehicleResponse])
|
||||
def get_purchased_vehicles(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get user's purchased vehicles with shipping status"""
|
||||
vehicles = db.query(PurchasedVehicle).filter(
|
||||
PurchasedVehicle.user_id == current_user.id
|
||||
).order_by(PurchasedVehicle.purchased_at.desc()).all()
|
||||
return vehicles
|
||||
|
||||
|
||||
# =====================
|
||||
# Admin Endpoints
|
||||
# =====================
|
||||
|
||||
@router.get("/admin/list", response_model=List[VehicleRequestResponse])
|
||||
def admin_get_all_requests(
|
||||
status: str = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Admin: Get all vehicle requests"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
query = db.query(VehicleRequest)
|
||||
if status:
|
||||
query = query.filter(VehicleRequest.status == status)
|
||||
|
||||
requests = query.order_by(VehicleRequest.created_at.desc()).all()
|
||||
return requests
|
||||
|
||||
|
||||
@router.get("/admin/{request_id}", response_model=VehicleRequestWithVehicles)
|
||||
def admin_get_request_detail(
|
||||
request_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Admin: Get request detail with all recommended vehicles"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
request = db.query(VehicleRequest).filter(VehicleRequest.id == request_id).first()
|
||||
if not request:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
|
||||
return VehicleRequestWithVehicles(
|
||||
request=VehicleRequestResponse.model_validate(request),
|
||||
approved_vehicles=[RequestVehicleResponse.model_validate(v) for v in request.recommended_vehicles]
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/{request_id}/vehicles", response_model=RequestVehicleResponse)
|
||||
def admin_add_vehicle(
|
||||
request_id: int,
|
||||
vehicle_data: RequestVehicleCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Admin: Add a vehicle to a request"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
request = db.query(VehicleRequest).filter(VehicleRequest.id == request_id).first()
|
||||
if not request:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
|
||||
vehicle = RequestVehicle(
|
||||
request_id=request_id,
|
||||
car_data=vehicle_data.car_data,
|
||||
is_approved=vehicle_data.is_approved,
|
||||
approved_at=datetime.utcnow() if vehicle_data.is_approved else None
|
||||
)
|
||||
db.add(vehicle)
|
||||
|
||||
# Update request status
|
||||
request.status = "reviewed"
|
||||
request.admin_reviewed_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(vehicle)
|
||||
return vehicle
|
||||
|
||||
|
||||
@router.post("/admin/{request_id}/approve-vehicles")
|
||||
def admin_approve_vehicles(
|
||||
request_id: int,
|
||||
approval: RequestVehicleApprove,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Admin: Approve multiple vehicles for a request"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
vehicles = db.query(RequestVehicle).filter(
|
||||
RequestVehicle.request_id == request_id,
|
||||
RequestVehicle.id.in_(approval.vehicle_ids)
|
||||
).all()
|
||||
|
||||
for vehicle in vehicles:
|
||||
vehicle.is_approved = True
|
||||
vehicle.approved_at = datetime.utcnow()
|
||||
|
||||
# Update request status
|
||||
request = db.query(VehicleRequest).filter(VehicleRequest.id == request_id).first()
|
||||
if request:
|
||||
request.status = "completed"
|
||||
request.admin_reviewed_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
# Send notification to user
|
||||
if request and len(vehicles) > 0:
|
||||
notify_vehicle_recommended(db, request.user_id, request_id, len(vehicles))
|
||||
|
||||
return {"message": f"Approved {len(vehicles)} vehicles"}
|
||||
|
||||
|
||||
@router.put("/admin/{request_id}/status")
|
||||
def admin_update_request_status(
|
||||
request_id: int,
|
||||
new_status: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Admin: Update request status"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
request = db.query(VehicleRequest).filter(VehicleRequest.id == request_id).first()
|
||||
if not request:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
|
||||
request.status = new_status
|
||||
request.admin_reviewed_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
return {"message": "Status updated"}
|
||||
|
||||
|
||||
@router.delete("/admin/{request_id}/vehicles/{vehicle_id}")
|
||||
def admin_delete_vehicle(
|
||||
request_id: int,
|
||||
vehicle_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Admin: Delete a recommended vehicle from a request"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
vehicle = db.query(RequestVehicle).filter(
|
||||
RequestVehicle.id == vehicle_id,
|
||||
RequestVehicle.request_id == request_id
|
||||
).first()
|
||||
|
||||
if not vehicle:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
|
||||
db.delete(vehicle)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Vehicle deleted successfully"}
|
||||
|
||||
|
||||
# =====================
|
||||
# Admin: Purchased Vehicles Management
|
||||
# =====================
|
||||
|
||||
@router.post("/admin/purchased", response_model=PurchasedVehicleResponse)
|
||||
def admin_create_purchased(
|
||||
vehicle_data: PurchasedVehicleCreate,
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Admin: Create a purchased vehicle record"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
# Calculate dealer commission if dealer is selected
|
||||
dealer_commission_krw = 0
|
||||
platform_commission_krw = 0
|
||||
|
||||
if vehicle_data.selected_dealer_id:
|
||||
# Verify dealer exists and is active
|
||||
dealer_info = db.query(DealerInfo).filter(
|
||||
DealerInfo.id == vehicle_data.selected_dealer_id,
|
||||
DealerInfo.is_active == True
|
||||
).first()
|
||||
|
||||
if not dealer_info:
|
||||
raise HTTPException(status_code=400, detail="Selected dealer not found or inactive")
|
||||
|
||||
# Calculate commissions
|
||||
dealer_commission_krw, platform_commission_krw = calculate_dealer_commission(
|
||||
vehicle_data.vehicle_price_krw, db
|
||||
)
|
||||
|
||||
# Credit commission to dealer's account
|
||||
dealer_info.total_commission_earned += dealer_commission_krw
|
||||
|
||||
vehicle = PurchasedVehicle(
|
||||
user_id=user_id,
|
||||
car_name=vehicle_data.car_name,
|
||||
car_data=vehicle_data.car_data,
|
||||
car_image=vehicle_data.car_image,
|
||||
vehicle_price_krw=vehicle_data.vehicle_price_krw,
|
||||
domestic_cost_krw=vehicle_data.domestic_cost_krw,
|
||||
shipping_cost_usd=vehicle_data.shipping_cost_usd,
|
||||
total_cost_krw=vehicle_data.total_cost_krw,
|
||||
car_type=vehicle_data.car_type,
|
||||
selected_dealer_id=vehicle_data.selected_dealer_id,
|
||||
dealer_commission_krw=dealer_commission_krw,
|
||||
platform_commission_krw=platform_commission_krw,
|
||||
)
|
||||
db.add(vehicle)
|
||||
db.commit()
|
||||
db.refresh(vehicle)
|
||||
return vehicle
|
||||
|
||||
|
||||
@router.get("/admin/purchased/all", response_model=List[PurchasedVehicleResponse])
|
||||
def admin_get_all_purchased(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Admin: Get all purchased vehicles"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
vehicles = db.query(PurchasedVehicle).order_by(PurchasedVehicle.purchased_at.desc()).all()
|
||||
return vehicles
|
||||
|
||||
|
||||
@router.put("/admin/purchased/{vehicle_id}/status", response_model=PurchasedVehicleResponse)
|
||||
def admin_update_shipping_status(
|
||||
vehicle_id: int,
|
||||
status_update: PurchasedVehicleUpdateStatus,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Admin: Update shipping status of a purchased vehicle"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
vehicle = db.query(PurchasedVehicle).filter(PurchasedVehicle.id == vehicle_id).first()
|
||||
if not vehicle:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
|
||||
vehicle.shipping_status = status_update.shipping_status
|
||||
vehicle.status_updated_at = datetime.utcnow()
|
||||
|
||||
if status_update.current_location:
|
||||
vehicle.current_location = status_update.current_location
|
||||
if status_update.estimated_arrival:
|
||||
vehicle.estimated_arrival = status_update.estimated_arrival
|
||||
|
||||
if status_update.shipping_status == 7: # Delivered (배송완료)
|
||||
vehicle.delivered_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(vehicle)
|
||||
|
||||
# Send notification to user about shipping update
|
||||
notify_shipping_update(db, vehicle.user_id, vehicle.id, status_update.shipping_status, vehicle.car_name)
|
||||
|
||||
return vehicle
|
||||
286
backend/app/api/vehicle_share.py
Normal file
286
backend/app/api/vehicle_share.py
Normal file
@@ -0,0 +1,286 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from ..database import get_db
|
||||
from ..models import User, VehicleShare, ShareReward, RequestVehicle
|
||||
from ..models.vehicle_share import generate_share_code
|
||||
from ..schemas import (
|
||||
VehicleShareCreate, VehicleShareResponse,
|
||||
ShareRewardResponse, ShareRewardSummary,
|
||||
)
|
||||
from .auth import get_current_user, get_current_user_optional
|
||||
from .notification import notify_share_purchased
|
||||
|
||||
router = APIRouter(prefix="/share", tags=["vehicle-share"])
|
||||
|
||||
# Tax rate for rewards (3.3% withholding tax in Korea)
|
||||
TAX_RATE = 0.033
|
||||
# Reward percentage (90% of markup goes to sharer)
|
||||
REWARD_RATE = 0.90
|
||||
|
||||
|
||||
@router.post("/create", response_model=VehicleShareResponse)
|
||||
def create_share(
|
||||
share_data: VehicleShareCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a shareable link for a vehicle with optional price markup"""
|
||||
# Get the request vehicle
|
||||
request_vehicle = db.query(RequestVehicle).filter(
|
||||
RequestVehicle.id == share_data.request_vehicle_id
|
||||
).first()
|
||||
|
||||
if not request_vehicle:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
|
||||
# Check if user owns this request (through VehicleRequest)
|
||||
if request_vehicle.vehicle_request.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="You can only share vehicles from your own requests")
|
||||
|
||||
# Check if vehicle is approved
|
||||
if not request_vehicle.is_approved:
|
||||
raise HTTPException(status_code=400, detail="Only approved vehicles can be shared")
|
||||
|
||||
# Generate unique share code
|
||||
share_code = generate_share_code()
|
||||
while db.query(VehicleShare).filter(VehicleShare.share_code == share_code).first():
|
||||
share_code = generate_share_code()
|
||||
|
||||
# Calculate prices
|
||||
original_price = request_vehicle.price_krw or 0
|
||||
markup = share_data.markup_amount_krw if share_data.markup_amount_krw > 0 else 0
|
||||
shared_price = original_price + markup
|
||||
|
||||
# Create share
|
||||
vehicle_share = VehicleShare(
|
||||
user_id=current_user.id,
|
||||
request_vehicle_id=share_data.request_vehicle_id,
|
||||
share_code=share_code,
|
||||
original_price_krw=original_price,
|
||||
markup_amount_krw=markup,
|
||||
shared_price_krw=shared_price,
|
||||
)
|
||||
|
||||
db.add(vehicle_share)
|
||||
db.commit()
|
||||
db.refresh(vehicle_share)
|
||||
|
||||
return vehicle_share
|
||||
|
||||
|
||||
@router.get("/my-shares", response_model=List[VehicleShareResponse])
|
||||
def get_my_shares(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all vehicle shares created by current user"""
|
||||
shares = db.query(VehicleShare).filter(
|
||||
VehicleShare.user_id == current_user.id
|
||||
).order_by(VehicleShare.created_at.desc()).all()
|
||||
|
||||
return shares
|
||||
|
||||
|
||||
@router.get("/my-rewards", response_model=List[ShareRewardResponse])
|
||||
def get_my_rewards(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all rewards earned from vehicle shares"""
|
||||
rewards = db.query(ShareReward).filter(
|
||||
ShareReward.user_id == current_user.id
|
||||
).order_by(ShareReward.created_at.desc()).all()
|
||||
|
||||
return rewards
|
||||
|
||||
|
||||
@router.get("/my-rewards/summary", response_model=ShareRewardSummary)
|
||||
def get_rewards_summary(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get summary of share rewards"""
|
||||
rewards = db.query(ShareReward).filter(
|
||||
ShareReward.user_id == current_user.id
|
||||
).all()
|
||||
|
||||
total_rewards = sum(r.net_amount for r in rewards)
|
||||
total_withdrawn = sum(r.net_amount for r in rewards if r.status == "withdrawn")
|
||||
pending = sum(r.net_amount for r in rewards if r.status == "pending")
|
||||
approved = sum(r.net_amount for r in rewards if r.status == "approved")
|
||||
|
||||
return ShareRewardSummary(
|
||||
total_rewards=total_rewards,
|
||||
total_withdrawn=total_withdrawn,
|
||||
pending_amount=pending,
|
||||
available_for_withdrawal=approved,
|
||||
reward_count=len(rewards)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{share_code}")
|
||||
def get_shared_vehicle(
|
||||
share_code: str,
|
||||
current_user: User = Depends(get_current_user_optional),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get shared vehicle details (public endpoint)"""
|
||||
share = db.query(VehicleShare).filter(
|
||||
VehicleShare.share_code == share_code
|
||||
).first()
|
||||
|
||||
if not share:
|
||||
raise HTTPException(status_code=404, detail="Shared vehicle not found")
|
||||
|
||||
# Increment view count
|
||||
share.view_count += 1
|
||||
db.commit()
|
||||
|
||||
# Get vehicle details
|
||||
vehicle = share.request_vehicle
|
||||
|
||||
return {
|
||||
"share": {
|
||||
"id": share.id,
|
||||
"share_code": share.share_code,
|
||||
"shared_price_krw": share.shared_price_krw,
|
||||
"original_price_krw": share.original_price_krw,
|
||||
"markup_amount_krw": share.markup_amount_krw,
|
||||
"view_count": share.view_count,
|
||||
"is_purchased": share.is_purchased,
|
||||
"created_at": share.created_at,
|
||||
},
|
||||
"vehicle": {
|
||||
"id": vehicle.id,
|
||||
"car_id": vehicle.car_id,
|
||||
"maker": vehicle.maker,
|
||||
"model": vehicle.model,
|
||||
"year": vehicle.year,
|
||||
"mileage": vehicle.mileage,
|
||||
"fuel_type": vehicle.fuel_type,
|
||||
"color": vehicle.color,
|
||||
"grade": vehicle.grade,
|
||||
"image_url": vehicle.image_url,
|
||||
"performance_check_url": vehicle.performance_check_url,
|
||||
"dealer_name": vehicle.dealer_name,
|
||||
"dealer_phone": vehicle.dealer_phone,
|
||||
},
|
||||
"sharer": {
|
||||
"name": share.user.name or "Anonymous",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{share_code}/purchase")
|
||||
def purchase_shared_vehicle(
|
||||
share_code: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Purchase a vehicle through a shared link"""
|
||||
share = db.query(VehicleShare).filter(
|
||||
VehicleShare.share_code == share_code
|
||||
).first()
|
||||
|
||||
if not share:
|
||||
raise HTTPException(status_code=404, detail="Shared vehicle not found")
|
||||
|
||||
if share.is_purchased:
|
||||
raise HTTPException(status_code=400, detail="This vehicle has already been purchased")
|
||||
|
||||
if share.user_id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="You cannot purchase your own shared vehicle")
|
||||
|
||||
# Mark as purchased
|
||||
share.is_purchased = True
|
||||
share.purchased_by_user_id = current_user.id
|
||||
share.purchased_at = datetime.utcnow()
|
||||
|
||||
# Create reward for the sharer (if there's markup)
|
||||
reward_net = 0
|
||||
if share.markup_amount_krw > 0:
|
||||
reward_amount = share.markup_amount_krw * REWARD_RATE # 90%
|
||||
tax_amount = reward_amount * TAX_RATE # 3.3% tax
|
||||
net_amount = reward_amount - tax_amount
|
||||
reward_net = net_amount
|
||||
|
||||
reward = ShareReward(
|
||||
user_id=share.user_id,
|
||||
vehicle_share_id=share.id,
|
||||
markup_amount=share.markup_amount_krw,
|
||||
reward_amount=reward_amount,
|
||||
tax_amount=tax_amount,
|
||||
net_amount=net_amount,
|
||||
status="pending" # Needs admin approval
|
||||
)
|
||||
db.add(reward)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Send notification to sharer about the sale
|
||||
vehicle = share.request_vehicle
|
||||
car_name = f"{vehicle.maker} {vehicle.model}" if vehicle else "차량"
|
||||
notify_share_purchased(db, share.user_id, share.id, reward_net, car_name)
|
||||
|
||||
return {
|
||||
"message": "Vehicle purchase initiated",
|
||||
"share_code": share_code,
|
||||
"price": share.shared_price_krw
|
||||
}
|
||||
|
||||
|
||||
# Admin endpoints
|
||||
@router.get("/admin/all", response_model=List[VehicleShareResponse])
|
||||
def get_all_shares(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Get all vehicle shares"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
shares = db.query(VehicleShare).order_by(VehicleShare.created_at.desc()).all()
|
||||
return shares
|
||||
|
||||
|
||||
@router.get("/admin/rewards", response_model=List[ShareRewardResponse])
|
||||
def get_all_rewards(
|
||||
status_filter: str = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Get all share rewards"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
query = db.query(ShareReward)
|
||||
if status_filter:
|
||||
query = query.filter(ShareReward.status == status_filter)
|
||||
|
||||
rewards = query.order_by(ShareReward.created_at.desc()).all()
|
||||
return rewards
|
||||
|
||||
|
||||
@router.put("/admin/rewards/{reward_id}/approve")
|
||||
def approve_reward(
|
||||
reward_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Approve a share reward for withdrawal"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
reward = db.query(ShareReward).filter(ShareReward.id == reward_id).first()
|
||||
if not reward:
|
||||
raise HTTPException(status_code=404, detail="Reward not found")
|
||||
|
||||
if reward.status != "pending":
|
||||
raise HTTPException(status_code=400, detail="Reward is not pending")
|
||||
|
||||
reward.status = "approved"
|
||||
db.commit()
|
||||
|
||||
return {"message": "Reward approved", "reward_id": reward_id}
|
||||
231
backend/app/api/verification.py
Normal file
231
backend/app/api/verification.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
Verification API Endpoints
|
||||
Handles email and phone verification for users
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import User
|
||||
from ..services import verification_service
|
||||
from .auth import get_current_user, get_current_user_optional
|
||||
|
||||
router = APIRouter(prefix="/verification", tags=["verification"])
|
||||
|
||||
|
||||
# Request/Response schemas
|
||||
class SendEmailCodeRequest(BaseModel):
|
||||
email: EmailStr
|
||||
language: str = "en"
|
||||
|
||||
|
||||
class SendPhoneCodeRequest(BaseModel):
|
||||
phone: str
|
||||
language: str = "en"
|
||||
|
||||
|
||||
class VerifyCodeRequest(BaseModel):
|
||||
code: str
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
|
||||
|
||||
class VerificationResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
class VerificationStatusResponse(BaseModel):
|
||||
email_verified: bool
|
||||
phone_verified: bool
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
|
||||
|
||||
# Email Verification Endpoints
|
||||
@router.post("/email/send", response_model=VerificationResponse)
|
||||
async def send_email_code(
|
||||
request: SendEmailCodeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_optional)
|
||||
):
|
||||
"""Send email verification code"""
|
||||
user_id = current_user.id if current_user else None
|
||||
|
||||
# If user is logged in, only allow sending to their email
|
||||
if current_user and current_user.email != request.email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You can only verify your own email address"
|
||||
)
|
||||
|
||||
success, message = await verification_service.send_email_verification(
|
||||
db=db,
|
||||
email=request.email,
|
||||
user_id=user_id,
|
||||
language=request.language
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
||||
|
||||
return VerificationResponse(success=True, message=message)
|
||||
|
||||
|
||||
@router.post("/email/verify", response_model=VerificationResponse)
|
||||
async def verify_email_code(
|
||||
request: VerifyCodeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_optional)
|
||||
):
|
||||
"""Verify email code"""
|
||||
email = request.email
|
||||
if current_user:
|
||||
email = current_user.email
|
||||
|
||||
if not email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email is required"
|
||||
)
|
||||
|
||||
success, message = verification_service.verify_code(
|
||||
db=db,
|
||||
code=request.code,
|
||||
code_type="email",
|
||||
email=email
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
||||
|
||||
# If user is logged in, mark their email as verified
|
||||
if current_user:
|
||||
verification_service.mark_email_verified(db, current_user)
|
||||
|
||||
return VerificationResponse(success=True, message=message)
|
||||
|
||||
|
||||
# Phone Verification Endpoints
|
||||
@router.post("/phone/send", response_model=VerificationResponse)
|
||||
async def send_phone_code(
|
||||
request: SendPhoneCodeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user) # Requires login
|
||||
):
|
||||
"""Send phone verification code (requires login)"""
|
||||
success, message = await verification_service.send_sms_verification(
|
||||
db=db,
|
||||
phone=request.phone,
|
||||
user_id=current_user.id,
|
||||
language=request.language
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
||||
|
||||
return VerificationResponse(success=True, message=message)
|
||||
|
||||
|
||||
@router.post("/phone/verify", response_model=VerificationResponse)
|
||||
async def verify_phone_code(
|
||||
request: VerifyCodeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user) # Requires login
|
||||
):
|
||||
"""Verify phone code (requires login)"""
|
||||
if not request.phone:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Phone number is required"
|
||||
)
|
||||
|
||||
# Normalize phone number
|
||||
phone = request.phone.strip().replace(" ", "").replace("-", "")
|
||||
if not phone.startswith("+"):
|
||||
if phone.startswith("9") and len(phone) == 8:
|
||||
phone = "+976" + phone
|
||||
|
||||
success, message = verification_service.verify_code(
|
||||
db=db,
|
||||
code=request.code,
|
||||
code_type="phone",
|
||||
phone=phone
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
||||
|
||||
# Mark phone as verified
|
||||
verification_service.mark_phone_verified(db, current_user, phone)
|
||||
|
||||
return VerificationResponse(success=True, message=message)
|
||||
|
||||
|
||||
# Status Endpoint
|
||||
@router.get("/status", response_model=VerificationStatusResponse)
|
||||
async def get_verification_status(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get current user's verification status"""
|
||||
return VerificationStatusResponse(
|
||||
email_verified=current_user.email_verified or False,
|
||||
phone_verified=current_user.phone_verified or False,
|
||||
email=current_user.email,
|
||||
phone=current_user.phone
|
||||
)
|
||||
|
||||
|
||||
# Pre-registration email verification (for signup flow)
|
||||
@router.post("/email/send-preregister", response_model=VerificationResponse)
|
||||
async def send_preregister_email_code(
|
||||
request: SendEmailCodeRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Send email verification code for new registration (no login required)"""
|
||||
# Check if email is already registered
|
||||
existing = db.query(User).filter(User.email == request.email).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This email is already registered"
|
||||
)
|
||||
|
||||
success, message = await verification_service.send_email_verification(
|
||||
db=db,
|
||||
email=request.email,
|
||||
user_id=None,
|
||||
language=request.language
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
||||
|
||||
return VerificationResponse(success=True, message=message)
|
||||
|
||||
|
||||
@router.post("/email/verify-preregister", response_model=VerificationResponse)
|
||||
async def verify_preregister_email_code(
|
||||
request: VerifyCodeRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Verify email code for new registration (no login required)"""
|
||||
if not request.email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email is required"
|
||||
)
|
||||
|
||||
success, message = verification_service.verify_code(
|
||||
db=db,
|
||||
code=request.code,
|
||||
code_type="email",
|
||||
email=request.email
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
||||
|
||||
return VerificationResponse(success=True, message=message)
|
||||
334
backend/app/api/visitor.py
Normal file
334
backend/app/api/visitor.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
Visitor Analytics API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Request, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, desc
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..database import get_db
|
||||
from ..models.visitor import VisitorLog, VisitorDailyStats
|
||||
from ..models import User
|
||||
from ..services.visitor_service import log_visit, aggregate_daily_stats
|
||||
from .auth import get_current_admin_user, get_current_user_optional
|
||||
|
||||
router = APIRouter(prefix="/visitor", tags=["Visitor Analytics"])
|
||||
|
||||
|
||||
# Pydantic schemas
|
||||
class VisitLogRequest(BaseModel):
|
||||
page_path: str
|
||||
page_title: Optional[str] = None
|
||||
referrer: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
utm_source: Optional[str] = None
|
||||
utm_medium: Optional[str] = None
|
||||
utm_campaign: Optional[str] = None
|
||||
|
||||
|
||||
class VisitorStatsResponse(BaseModel):
|
||||
total_visits: int
|
||||
unique_visitors: int
|
||||
device_breakdown: dict
|
||||
browser_breakdown: dict
|
||||
country_breakdown: dict
|
||||
|
||||
|
||||
class ChartData(BaseModel):
|
||||
labels: List[str]
|
||||
values: List[int]
|
||||
|
||||
|
||||
class TopPage(BaseModel):
|
||||
path: str
|
||||
views: int
|
||||
title: Optional[str] = None
|
||||
|
||||
|
||||
class TopReferrer(BaseModel):
|
||||
domain: str
|
||||
visits: int
|
||||
|
||||
|
||||
# Background task wrapper for async log_visit
|
||||
async def _log_visit_background(
|
||||
db: Session,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
page_path: str,
|
||||
page_title: Optional[str],
|
||||
referrer: Optional[str],
|
||||
session_id: Optional[str],
|
||||
user_id: Optional[int],
|
||||
utm_source: Optional[str],
|
||||
utm_medium: Optional[str],
|
||||
utm_campaign: Optional[str],
|
||||
):
|
||||
"""Background wrapper for log_visit"""
|
||||
try:
|
||||
await log_visit(
|
||||
db, ip, user_agent, page_path, page_title,
|
||||
referrer, session_id, user_id,
|
||||
utm_source, utm_medium, utm_campaign
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Visitor] Log visit failed: {e}")
|
||||
|
||||
|
||||
# Public endpoint for logging visits
|
||||
@router.post("/log")
|
||||
async def log_page_visit(
|
||||
visit_data: VisitLogRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
"""
|
||||
Log a page visit (called from frontend)
|
||||
"""
|
||||
# Get client IP (handle proxies)
|
||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if forwarded_for:
|
||||
ip = forwarded_for.split(",")[0].strip()
|
||||
else:
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
user_agent = request.headers.get("User-Agent", "")
|
||||
user_id = current_user.id if current_user else None
|
||||
|
||||
# Log visit directly (async)
|
||||
try:
|
||||
await log_visit(
|
||||
db,
|
||||
ip,
|
||||
user_agent,
|
||||
visit_data.page_path,
|
||||
visit_data.page_title,
|
||||
visit_data.referrer,
|
||||
visit_data.session_id,
|
||||
user_id,
|
||||
visit_data.utm_source,
|
||||
visit_data.utm_medium,
|
||||
visit_data.utm_campaign,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Visitor] Log visit failed: {e}")
|
||||
|
||||
return {"status": "logged"}
|
||||
|
||||
|
||||
# Admin endpoints
|
||||
@router.get("/admin/overview", response_model=VisitorStatsResponse)
|
||||
def get_visitor_overview(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get visitor statistics overview for last N days"""
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# Total visits
|
||||
total_visits = db.query(func.count(VisitorLog.id)).filter(
|
||||
VisitorLog.visited_at >= start_date
|
||||
).scalar() or 0
|
||||
|
||||
# Unique visitors
|
||||
unique_visitors = db.query(func.count(func.distinct(VisitorLog.visitor_hash))).filter(
|
||||
VisitorLog.visited_at >= start_date
|
||||
).scalar() or 0
|
||||
|
||||
# Device breakdown
|
||||
device_query = db.query(
|
||||
VisitorLog.device_type,
|
||||
func.count(VisitorLog.id)
|
||||
).filter(
|
||||
VisitorLog.visited_at >= start_date
|
||||
).group_by(VisitorLog.device_type).all()
|
||||
|
||||
device_breakdown = {d[0] or "unknown": d[1] for d in device_query}
|
||||
|
||||
# Browser breakdown
|
||||
browser_query = db.query(
|
||||
VisitorLog.browser,
|
||||
func.count(VisitorLog.id)
|
||||
).filter(
|
||||
VisitorLog.visited_at >= start_date
|
||||
).group_by(VisitorLog.browser).all()
|
||||
|
||||
browser_breakdown = {b[0] or "unknown": b[1] for b in browser_query}
|
||||
|
||||
# Country breakdown
|
||||
country_query = db.query(
|
||||
VisitorLog.country_code,
|
||||
func.count(VisitorLog.id)
|
||||
).filter(
|
||||
VisitorLog.visited_at >= start_date
|
||||
).group_by(VisitorLog.country_code).all()
|
||||
|
||||
country_breakdown = {c[0] or "unknown": c[1] for c in country_query}
|
||||
|
||||
return VisitorStatsResponse(
|
||||
total_visits=total_visits,
|
||||
unique_visitors=unique_visitors,
|
||||
device_breakdown=device_breakdown,
|
||||
browser_breakdown=browser_breakdown,
|
||||
country_breakdown=country_breakdown,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/chart/visits", response_model=ChartData)
|
||||
def get_visits_chart(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get daily visits chart data"""
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
labels = []
|
||||
values = []
|
||||
|
||||
for i in range(days - 1, -1, -1):
|
||||
date = today - timedelta(days=i)
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
|
||||
count = db.query(func.count(VisitorLog.id)).filter(
|
||||
func.date(VisitorLog.visited_at) == date_str
|
||||
).scalar() or 0
|
||||
|
||||
labels.append(date.strftime("%m/%d"))
|
||||
values.append(count)
|
||||
|
||||
return ChartData(labels=labels, values=values)
|
||||
|
||||
|
||||
@router.get("/admin/chart/unique-visitors", response_model=ChartData)
|
||||
def get_unique_visitors_chart(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get daily unique visitors chart data"""
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
labels = []
|
||||
values = []
|
||||
|
||||
for i in range(days - 1, -1, -1):
|
||||
date = today - timedelta(days=i)
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
|
||||
count = db.query(func.count(func.distinct(VisitorLog.visitor_hash))).filter(
|
||||
func.date(VisitorLog.visited_at) == date_str
|
||||
).scalar() or 0
|
||||
|
||||
labels.append(date.strftime("%m/%d"))
|
||||
values.append(count)
|
||||
|
||||
return ChartData(labels=labels, values=values)
|
||||
|
||||
|
||||
@router.get("/admin/top-pages", response_model=List[TopPage])
|
||||
def get_top_pages(
|
||||
days: int = 30,
|
||||
limit: int = 20,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get top visited pages"""
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
pages = db.query(
|
||||
VisitorLog.page_path,
|
||||
VisitorLog.page_title,
|
||||
func.count(VisitorLog.id).label("views")
|
||||
).filter(
|
||||
VisitorLog.visited_at >= start_date
|
||||
).group_by(
|
||||
VisitorLog.page_path, VisitorLog.page_title
|
||||
).order_by(
|
||||
desc("views")
|
||||
).limit(limit).all()
|
||||
|
||||
return [
|
||||
TopPage(path=p[0], title=p[1], views=p[2])
|
||||
for p in pages
|
||||
]
|
||||
|
||||
|
||||
@router.get("/admin/top-referrers", response_model=List[TopReferrer])
|
||||
def get_top_referrers(
|
||||
days: int = 30,
|
||||
limit: int = 10,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get top referrer sources"""
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
referrers = db.query(
|
||||
VisitorLog.referrer_domain,
|
||||
func.count(VisitorLog.id).label("visits")
|
||||
).filter(
|
||||
and_(
|
||||
VisitorLog.visited_at >= start_date,
|
||||
VisitorLog.referrer_domain.isnot(None),
|
||||
VisitorLog.referrer_domain != ""
|
||||
)
|
||||
).group_by(
|
||||
VisitorLog.referrer_domain
|
||||
).order_by(
|
||||
desc("visits")
|
||||
).limit(limit).all()
|
||||
|
||||
return [
|
||||
TopReferrer(domain=r[0], visits=r[1])
|
||||
for r in referrers
|
||||
]
|
||||
|
||||
|
||||
@router.get("/admin/realtime")
|
||||
def get_realtime_visitors(
|
||||
minutes: int = 5,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get visitors in the last N minutes (real-time)"""
|
||||
start_time = datetime.utcnow() - timedelta(minutes=minutes)
|
||||
|
||||
active_count = db.query(func.count(func.distinct(VisitorLog.visitor_hash))).filter(
|
||||
VisitorLog.visited_at >= start_time
|
||||
).scalar() or 0
|
||||
|
||||
# Recent pages
|
||||
recent_pages = db.query(
|
||||
VisitorLog.page_path,
|
||||
func.count(VisitorLog.id).label("views")
|
||||
).filter(
|
||||
VisitorLog.visited_at >= start_time
|
||||
).group_by(
|
||||
VisitorLog.page_path
|
||||
).order_by(
|
||||
desc("views")
|
||||
).limit(5).all()
|
||||
|
||||
return {
|
||||
"active_visitors": active_count,
|
||||
"minutes": minutes,
|
||||
"recent_pages": [{"path": p[0], "views": p[1]} for p in recent_pages],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/admin/aggregate/{date_str}")
|
||||
def trigger_aggregation(
|
||||
date_str: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Manually trigger aggregation for a specific date (YYYY-MM-DD)"""
|
||||
result = aggregate_daily_stats(db, date_str)
|
||||
if result:
|
||||
return {"status": "success", "date": date_str}
|
||||
return {"status": "no_data", "date": date_str}
|
||||
217
backend/app/api/withdrawal.py
Normal file
217
backend/app/api/withdrawal.py
Normal file
@@ -0,0 +1,217 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func as sql_func
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from ..database import get_db
|
||||
from ..models import User, WithdrawalRequest, DealerInfo, ShareReward, ReferralReward
|
||||
from ..schemas import (
|
||||
WithdrawalRequestCreate, WithdrawalRequestResponse,
|
||||
WithdrawalProcess, WithdrawalBalance,
|
||||
)
|
||||
from .auth import get_current_user
|
||||
from .notification import notify_withdrawal_processed
|
||||
|
||||
router = APIRouter(prefix="/withdrawal", tags=["withdrawal"])
|
||||
|
||||
# Tax rate (3.3% withholding)
|
||||
TAX_RATE = 0.033
|
||||
|
||||
|
||||
def calculate_user_balance(user: User, db: Session) -> WithdrawalBalance:
|
||||
"""Calculate user's withdrawal balance from all sources"""
|
||||
total_earned = 0.0
|
||||
total_withdrawn = 0.0
|
||||
pending_withdrawal = 0.0
|
||||
|
||||
# Get dealer earnings if user is a dealer
|
||||
if user.is_dealer:
|
||||
dealer_info = db.query(DealerInfo).filter(DealerInfo.user_id == user.id).first()
|
||||
if dealer_info:
|
||||
total_earned += dealer_info.total_commission_earned
|
||||
total_withdrawn += dealer_info.total_withdrawn
|
||||
|
||||
# Get share rewards
|
||||
share_rewards = db.query(ShareReward).filter(
|
||||
ShareReward.user_id == user.id,
|
||||
ShareReward.status.in_(["approved", "withdrawn"])
|
||||
).all()
|
||||
|
||||
for reward in share_rewards:
|
||||
total_earned += reward.net_amount
|
||||
if reward.status == "withdrawn":
|
||||
total_withdrawn += reward.net_amount
|
||||
|
||||
# Get referral rewards
|
||||
referral_rewards = db.query(ReferralReward).filter(
|
||||
ReferralReward.referrer_id == user.id,
|
||||
ReferralReward.status.in_(["credited", "withdrawn"])
|
||||
).all()
|
||||
|
||||
for reward in referral_rewards:
|
||||
total_earned += reward.reward_amount
|
||||
if reward.status == "withdrawn":
|
||||
total_withdrawn += reward.reward_amount
|
||||
|
||||
# Get pending withdrawals
|
||||
pending_requests = db.query(WithdrawalRequest).filter(
|
||||
WithdrawalRequest.user_id == user.id,
|
||||
WithdrawalRequest.status.in_(["pending", "approved"])
|
||||
).all()
|
||||
|
||||
for req in pending_requests:
|
||||
pending_withdrawal += req.net_amount
|
||||
|
||||
available_balance = total_earned - total_withdrawn - pending_withdrawal
|
||||
|
||||
return WithdrawalBalance(
|
||||
total_earned=total_earned,
|
||||
total_withdrawn=total_withdrawn,
|
||||
pending_withdrawal=pending_withdrawal,
|
||||
available_balance=max(0, available_balance)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/balance", response_model=WithdrawalBalance)
|
||||
def get_balance(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's withdrawal balance"""
|
||||
return calculate_user_balance(current_user, db)
|
||||
|
||||
|
||||
@router.post("/request", response_model=WithdrawalRequestResponse)
|
||||
def create_withdrawal_request(
|
||||
request_data: WithdrawalRequestCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new withdrawal request"""
|
||||
# Check balance
|
||||
balance = calculate_user_balance(current_user, db)
|
||||
|
||||
if request_data.amount <= 0:
|
||||
raise HTTPException(status_code=400, detail="Amount must be positive")
|
||||
|
||||
if request_data.amount > balance.available_balance:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Insufficient balance. Available: {balance.available_balance}"
|
||||
)
|
||||
|
||||
# Minimum withdrawal amount
|
||||
MIN_WITHDRAWAL = 10 # 10 USD minimum
|
||||
if request_data.amount < MIN_WITHDRAWAL:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Minimum withdrawal amount is ${MIN_WITHDRAWAL} USD"
|
||||
)
|
||||
|
||||
# Calculate tax and net amount
|
||||
tax_amount = request_data.amount * TAX_RATE
|
||||
net_amount = request_data.amount - tax_amount
|
||||
|
||||
# Create withdrawal request
|
||||
withdrawal = WithdrawalRequest(
|
||||
user_id=current_user.id,
|
||||
amount=request_data.amount,
|
||||
tax_withheld=tax_amount,
|
||||
net_amount=net_amount,
|
||||
bank_name=request_data.bank_name,
|
||||
bank_account=request_data.bank_account,
|
||||
account_holder=request_data.account_holder,
|
||||
status="pending"
|
||||
)
|
||||
|
||||
db.add(withdrawal)
|
||||
db.commit()
|
||||
db.refresh(withdrawal)
|
||||
|
||||
return withdrawal
|
||||
|
||||
|
||||
@router.get("/my-requests", response_model=List[WithdrawalRequestResponse])
|
||||
def get_my_requests(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's withdrawal requests"""
|
||||
requests = db.query(WithdrawalRequest).filter(
|
||||
WithdrawalRequest.user_id == current_user.id
|
||||
).order_by(WithdrawalRequest.requested_at.desc()).all()
|
||||
|
||||
return requests
|
||||
|
||||
|
||||
# Admin endpoints
|
||||
@router.get("/admin/list", response_model=List[WithdrawalRequestResponse])
|
||||
def get_all_requests(
|
||||
status_filter: str = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Get all withdrawal requests"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
query = db.query(WithdrawalRequest)
|
||||
if status_filter:
|
||||
query = query.filter(WithdrawalRequest.status == status_filter)
|
||||
|
||||
requests = query.order_by(WithdrawalRequest.requested_at.desc()).all()
|
||||
return requests
|
||||
|
||||
|
||||
@router.put("/admin/{request_id}/process", response_model=WithdrawalRequestResponse)
|
||||
def process_withdrawal(
|
||||
request_id: int,
|
||||
process_data: WithdrawalProcess,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Process a withdrawal request"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
withdrawal = db.query(WithdrawalRequest).filter(
|
||||
WithdrawalRequest.id == request_id
|
||||
).first()
|
||||
|
||||
if not withdrawal:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
|
||||
valid_statuses = ["approved", "completed", "rejected"]
|
||||
if process_data.status not in valid_statuses:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid status. Must be one of: {valid_statuses}"
|
||||
)
|
||||
|
||||
# Update status
|
||||
withdrawal.status = process_data.status
|
||||
withdrawal.admin_note = process_data.admin_note
|
||||
withdrawal.processed_at = datetime.utcnow()
|
||||
|
||||
# If completed, update user's withdrawal totals
|
||||
if process_data.status == "completed":
|
||||
user = db.query(User).filter(User.id == withdrawal.user_id).first()
|
||||
|
||||
# Update dealer info if applicable
|
||||
if user.is_dealer:
|
||||
dealer_info = db.query(DealerInfo).filter(
|
||||
DealerInfo.user_id == user.id
|
||||
).first()
|
||||
if dealer_info:
|
||||
dealer_info.total_withdrawn += withdrawal.net_amount
|
||||
|
||||
# Mark related share rewards as withdrawn
|
||||
# (This is a simplified version - in production you'd track which specific rewards were withdrawn)
|
||||
|
||||
db.commit()
|
||||
db.refresh(withdrawal)
|
||||
|
||||
# Send notification to user about withdrawal status
|
||||
notify_withdrawal_processed(db, withdrawal.user_id, withdrawal.id, process_data.status, withdrawal.net_amount)
|
||||
|
||||
return withdrawal
|
||||
80
backend/app/config.py
Normal file
80
backend/app/config.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
import os
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Database - Local SQLite or Remote PostgreSQL
|
||||
USE_SQLITE: bool = True # Set to False for production PostgreSQL
|
||||
DB_HOST: str = "192.168.0.201"
|
||||
DB_PORT: int = 5432
|
||||
DB_NAME: str = "autonet"
|
||||
DB_USER: str = "admin"
|
||||
DB_PASSWORD: str = ""
|
||||
|
||||
# Redis
|
||||
REDIS_HOST: str = "192.168.0.201"
|
||||
REDIS_PORT: int = 6379
|
||||
REDIS_PASSWORD: str = ""
|
||||
|
||||
# JWT
|
||||
SECRET_KEY: str = "your-secret-key-for-dev-123"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480 # 8 hours for development
|
||||
|
||||
# Agent
|
||||
AGENT_API_KEY: str = ""
|
||||
|
||||
# App
|
||||
DEBUG: bool = True
|
||||
|
||||
# Stripe
|
||||
STRIPE_SECRET_KEY: str = "" # sk_test_... or sk_live_...
|
||||
STRIPE_PUBLISHABLE_KEY: str = "" # pk_test_... or pk_live_...
|
||||
STRIPE_WEBHOOK_SECRET: str = "" # whsec_...
|
||||
STRIPE_SUCCESS_URL: str = "http://localhost:3000/cc/success"
|
||||
STRIPE_CANCEL_URL: str = "http://localhost:3000/cc/purchase"
|
||||
|
||||
# Azure Translator
|
||||
AZURE_TRANSLATOR_KEY: str = ""
|
||||
AZURE_TRANSLATOR_REGION: str = "koreacentral"
|
||||
|
||||
# Email Settings (SMTP)
|
||||
SMTP_HOST: str = "smtp.gmail.com"
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = "" # App password for Gmail
|
||||
SMTP_FROM_EMAIL: str = ""
|
||||
SMTP_FROM_NAME: str = "AutonetSellCar"
|
||||
|
||||
# SMS Settings (Twilio)
|
||||
TWILIO_ACCOUNT_SID: str = ""
|
||||
TWILIO_AUTH_TOKEN: str = ""
|
||||
TWILIO_PHONE_NUMBER: str = "" # Your Twilio phone number
|
||||
|
||||
# Verification Settings
|
||||
VERIFICATION_CODE_EXPIRE_MINUTES: int = 10
|
||||
EMAIL_VERIFICATION_REQUIRED: bool = True # Require email verification for signup
|
||||
PHONE_VERIFICATION_REQUIRED_FOR_CC: bool = True # Require phone for CC charging
|
||||
|
||||
@property
|
||||
def DATABASE_URL(self) -> str:
|
||||
if self.USE_SQLITE:
|
||||
# Get the backend directory path
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
db_path = os.path.join(base_dir, "autonet.db")
|
||||
return f"sqlite:///{db_path}"
|
||||
return f"postgresql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
|
||||
|
||||
@property
|
||||
def REDIS_URL(self) -> str:
|
||||
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/0"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached settings - updated with SMTP credentials"""
|
||||
return Settings()
|
||||
174
backend/app/data/carmodoo_makers_models.json
Normal file
174
backend/app/data/carmodoo_makers_models.json
Normal file
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"makers": [
|
||||
{"code": "5", "name": "현대"},
|
||||
{"code": "146", "name": "제네시스"},
|
||||
{"code": "2", "name": "기아"},
|
||||
{"code": "1", "name": "쉐보레(대우)"},
|
||||
{"code": "3", "name": "르노(삼성)"},
|
||||
{"code": "4", "name": "KG모빌리티(쌍용)"},
|
||||
{"code": "76", "name": "닛산"},
|
||||
{"code": "78", "name": "렉서스"},
|
||||
{"code": "77", "name": "토요타"},
|
||||
{"code": "125", "name": "혼다"}
|
||||
],
|
||||
"models": {
|
||||
"5": [
|
||||
{"code": "93", "name": "i30"},
|
||||
{"code": "94", "name": "i40"},
|
||||
{"code": "96", "name": "그랜저"},
|
||||
{"code": "1185", "name": "넥쏘"},
|
||||
{"code": "1243", "name": "베뉴"},
|
||||
{"code": "106", "name": "벨로스터"},
|
||||
{"code": "108", "name": "스타렉스"},
|
||||
{"code": "1421", "name": "스타리아"},
|
||||
{"code": "110", "name": "싼타페"},
|
||||
{"code": "111", "name": "쏘나타"},
|
||||
{"code": "112", "name": "아반떼"},
|
||||
{"code": "114", "name": "아이오닉"},
|
||||
{"code": "1594", "name": "아이오닉 5"},
|
||||
{"code": "1595", "name": "아이오닉 6"},
|
||||
{"code": "116", "name": "에쿠스"},
|
||||
{"code": "1467", "name": "캐스퍼"},
|
||||
{"code": "1167", "name": "코나"},
|
||||
{"code": "124", "name": "투싼"},
|
||||
{"code": "1207", "name": "팰리세이드"},
|
||||
{"code": "129", "name": "포터"}
|
||||
],
|
||||
"146": [
|
||||
{"code": "763", "name": "EQ900"},
|
||||
{"code": "1172", "name": "G70"},
|
||||
{"code": "1171", "name": "G80"},
|
||||
{"code": "1205", "name": "G90"},
|
||||
{"code": "1469", "name": "GV60"},
|
||||
{"code": "1389", "name": "GV70"},
|
||||
{"code": "1272", "name": "GV80"}
|
||||
],
|
||||
"2": [
|
||||
{"code": "1755", "name": "EV3"},
|
||||
{"code": "1455", "name": "EV6"},
|
||||
{"code": "1701", "name": "EV9"},
|
||||
{"code": "37", "name": "K3"},
|
||||
{"code": "38", "name": "K5"},
|
||||
{"code": "39", "name": "K7"},
|
||||
{"code": "1420", "name": "K8"},
|
||||
{"code": "40", "name": "K9"},
|
||||
{"code": "41", "name": "니로"},
|
||||
{"code": "42", "name": "레이"},
|
||||
{"code": "46", "name": "모닝"},
|
||||
{"code": "47", "name": "모하비"},
|
||||
{"code": "49", "name": "봉고"},
|
||||
{"code": "1244", "name": "셀토스"},
|
||||
{"code": "1168", "name": "스토닉"},
|
||||
{"code": "1160", "name": "스팅어"},
|
||||
{"code": "54", "name": "스포티지"},
|
||||
{"code": "56", "name": "쏘렌토"},
|
||||
{"code": "57", "name": "쏘울"},
|
||||
{"code": "64", "name": "카니발"},
|
||||
{"code": "1380", "name": "텔루라이드"},
|
||||
{"code": "71", "name": "포르테"}
|
||||
],
|
||||
"1": [
|
||||
{"code": "4", "name": "다마스"},
|
||||
{"code": "6", "name": "라보"},
|
||||
{"code": "12", "name": "말리부"},
|
||||
{"code": "1154", "name": "볼트"},
|
||||
{"code": "18", "name": "스파크"},
|
||||
{"code": "24", "name": "올란도"},
|
||||
{"code": "1196", "name": "이쿼녹스"},
|
||||
{"code": "28", "name": "카마로"},
|
||||
{"code": "30", "name": "캡티바"},
|
||||
{"code": "1249", "name": "콜로라도"},
|
||||
{"code": "32", "name": "크루즈"},
|
||||
{"code": "1251", "name": "트래버스"},
|
||||
{"code": "34", "name": "트랙스"},
|
||||
{"code": "1273", "name": "트레일블레이저"}
|
||||
],
|
||||
"3": [
|
||||
{"code": "75", "name": "QM3"},
|
||||
{"code": "76", "name": "QM5"},
|
||||
{"code": "1137", "name": "QM6"},
|
||||
{"code": "77", "name": "SM3"},
|
||||
{"code": "79", "name": "SM5"},
|
||||
{"code": "80", "name": "SM6"},
|
||||
{"code": "81", "name": "SM7"},
|
||||
{"code": "1279", "name": "XM3"},
|
||||
{"code": "1765", "name": "그랑 콜레오스"},
|
||||
{"code": "1204", "name": "마스터"},
|
||||
{"code": "1748", "name": "아르카나"},
|
||||
{"code": "1345", "name": "캡쳐"}
|
||||
],
|
||||
"4": [
|
||||
{"code": "83", "name": "렉스턴"},
|
||||
{"code": "84", "name": "로디우스"},
|
||||
{"code": "88", "name": "체어맨"},
|
||||
{"code": "90", "name": "코란도"},
|
||||
{"code": "1573", "name": "토레스"},
|
||||
{"code": "91", "name": "티볼리"}
|
||||
],
|
||||
"76": [
|
||||
{"code": "271", "name": "GT-R"},
|
||||
{"code": "1418", "name": "노트"},
|
||||
{"code": "273", "name": "로그"},
|
||||
{"code": "275", "name": "리프"},
|
||||
{"code": "277", "name": "맥시마"},
|
||||
{"code": "279", "name": "무라노"},
|
||||
{"code": "289", "name": "알티마"},
|
||||
{"code": "1216", "name": "엑스트레일"},
|
||||
{"code": "293", "name": "쥬크"},
|
||||
{"code": "294", "name": "캐시카이"},
|
||||
{"code": "295", "name": "퀘스트"},
|
||||
{"code": "297", "name": "티아나"},
|
||||
{"code": "298", "name": "패스파인더"}
|
||||
],
|
||||
"78": [
|
||||
{"code": "342", "name": "CT"},
|
||||
{"code": "343", "name": "ES"},
|
||||
{"code": "344", "name": "GS"},
|
||||
{"code": "345", "name": "GX"},
|
||||
{"code": "346", "name": "IS"},
|
||||
{"code": "1161", "name": "LC"},
|
||||
{"code": "347", "name": "LS"},
|
||||
{"code": "348", "name": "LX"},
|
||||
{"code": "349", "name": "NX"},
|
||||
{"code": "350", "name": "RC"},
|
||||
{"code": "351", "name": "RX"},
|
||||
{"code": "1224", "name": "UX"}
|
||||
],
|
||||
"77": [
|
||||
{"code": "302", "name": "86"},
|
||||
{"code": "1195", "name": "C-HR"},
|
||||
{"code": "304", "name": "FJ 크루져"},
|
||||
{"code": "301", "name": "GR86"},
|
||||
{"code": "308", "name": "라브4"},
|
||||
{"code": "1326", "name": "랜드크루저"},
|
||||
{"code": "324", "name": "아발론"},
|
||||
{"code": "326", "name": "알파드"},
|
||||
{"code": "327", "name": "에스티마"},
|
||||
{"code": "317", "name": "세콰이어"},
|
||||
{"code": "323", "name": "시에나"},
|
||||
{"code": "1232", "name": "시엔타"},
|
||||
{"code": "330", "name": "캠리"},
|
||||
{"code": "331", "name": "코롤라"},
|
||||
{"code": "332", "name": "크라운"},
|
||||
{"code": "333", "name": "타코마"},
|
||||
{"code": "334", "name": "툰드라"},
|
||||
{"code": "338", "name": "프리우스"},
|
||||
{"code": "339", "name": "하이랜더"},
|
||||
{"code": "322", "name": "수프라"}
|
||||
],
|
||||
"125": [
|
||||
{"code": "702", "name": "CR-V"},
|
||||
{"code": "703", "name": "CR-Z"},
|
||||
{"code": "1130", "name": "HR-V"},
|
||||
{"code": "705", "name": "S2000"},
|
||||
{"code": "1231", "name": "베젤"},
|
||||
{"code": "1416", "name": "스텝웨건"},
|
||||
{"code": "710", "name": "시빅"},
|
||||
{"code": "711", "name": "어코드"},
|
||||
{"code": "713", "name": "오딧세이"},
|
||||
{"code": "714", "name": "인사이트"},
|
||||
{"code": "719", "name": "파일럿"},
|
||||
{"code": "722", "name": "피트"}
|
||||
]
|
||||
}
|
||||
}
|
||||
27
backend/app/database.py
Normal file
27
backend/app/database.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from .config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# SQLite needs check_same_thread=False for FastAPI
|
||||
if settings.USE_SQLITE:
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
connect_args={"check_same_thread": False}
|
||||
)
|
||||
else:
|
||||
engine = create_engine(settings.DATABASE_URL)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
168
backend/app/main.py
Normal file
168
backend/app/main.py
Normal file
@@ -0,0 +1,168 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from contextlib import asynccontextmanager
|
||||
import os
|
||||
import asyncio
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from .database import engine, Base, SessionLocal
|
||||
from .api import cars, auth, inquiries, hero_banners, carmodoo, translations, cc, settings, vehicle_requests, dealer, vehicle_share, withdrawal, referral, notification, dashboard, push, exchange_rate, verification, visitor
|
||||
from .config import get_settings
|
||||
from .services.exchange_rate_service import update_exchange_rates
|
||||
from .services.visitor_service import aggregate_daily_stats, cleanup_old_visitor_logs
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
app_settings = get_settings()
|
||||
|
||||
# Create tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# APScheduler 설정
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
|
||||
async def scheduled_update_exchange_rates():
|
||||
"""스케줄된 환율 업데이트 작업"""
|
||||
print("[Scheduler] Starting daily exchange rate update...")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = await update_exchange_rates(db, force=True)
|
||||
print(f"[Scheduler] Exchange rate update completed: {result}")
|
||||
except Exception as e:
|
||||
print(f"[Scheduler] Exchange rate update failed: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def scheduled_aggregate_visitor_stats():
|
||||
"""Aggregate yesterday's visitor stats"""
|
||||
print("[Scheduler] Aggregating visitor stats...")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
result = aggregate_daily_stats(db, yesterday)
|
||||
if result:
|
||||
print(f"[Scheduler] Visitor stats aggregated for {yesterday}")
|
||||
else:
|
||||
print(f"[Scheduler] No visitor data for {yesterday}")
|
||||
except Exception as e:
|
||||
print(f"[Scheduler] Visitor stats aggregation failed: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def scheduled_cleanup_old_visitor_logs():
|
||||
"""Delete visitor logs older than 90 days"""
|
||||
print("[Scheduler] Cleaning up old visitor logs...")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
deleted = cleanup_old_visitor_logs(db, days=90)
|
||||
print(f"[Scheduler] Deleted {deleted} old visitor logs")
|
||||
except Exception as e:
|
||||
print(f"[Scheduler] Visitor log cleanup failed: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""앱 시작/종료 시 실행되는 lifespan 이벤트"""
|
||||
# 시작 시
|
||||
print("[Startup] Initializing scheduler...")
|
||||
|
||||
# 환율 업데이트 스케줄 등록 (매일 오전 11시 30분 - 수출입은행 11시경 업데이트)
|
||||
scheduler.add_job(
|
||||
scheduled_update_exchange_rates,
|
||||
CronTrigger(hour=11, minute=30),
|
||||
id="daily_exchange_rate_update",
|
||||
name="Daily Exchange Rate Update",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
# 방문자 통계 집계 (매일 새벽 2시)
|
||||
scheduler.add_job(
|
||||
scheduled_aggregate_visitor_stats,
|
||||
CronTrigger(hour=2, minute=0),
|
||||
id="daily_visitor_stats_aggregation",
|
||||
name="Daily Visitor Stats Aggregation",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
# 오래된 방문자 로그 정리 (매주 일요일 새벽 3시)
|
||||
scheduler.add_job(
|
||||
scheduled_cleanup_old_visitor_logs,
|
||||
CronTrigger(day_of_week='sun', hour=3, minute=0),
|
||||
id="weekly_visitor_log_cleanup",
|
||||
name="Weekly Visitor Log Cleanup",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
scheduler.start()
|
||||
print("[Startup] Scheduler started - Exchange rates: 11:30 AM, Visitor stats: 2:00 AM, Log cleanup: Sunday 3:00 AM")
|
||||
|
||||
# 서버 시작 시 환율 데이터 초기화 (백그라운드에서)
|
||||
asyncio.create_task(scheduled_update_exchange_rates())
|
||||
|
||||
yield
|
||||
|
||||
# 종료 시
|
||||
print("[Shutdown] Stopping scheduler...")
|
||||
scheduler.shutdown()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="AutonetSellCar API",
|
||||
description="AutonetSellCar - Used Car Export Platform API",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# CORS - credentials=True requires explicit origins (not "*")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://localhost:8000",
|
||||
"http://192.168.0.202:3000", # Local network
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Static files for uploads
|
||||
os.makedirs("./uploads/hero-banners", exist_ok=True)
|
||||
app.mount("/uploads", StaticFiles(directory="./uploads"), name="uploads")
|
||||
|
||||
# Routes
|
||||
app.include_router(cars.router, prefix="/api")
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(inquiries.router, prefix="/api")
|
||||
app.include_router(hero_banners.router, prefix="/api")
|
||||
app.include_router(carmodoo.router, prefix="/api")
|
||||
app.include_router(translations.router, prefix="/api")
|
||||
app.include_router(cc.router, prefix="/api")
|
||||
app.include_router(settings.router, prefix="/api")
|
||||
app.include_router(vehicle_requests.router, prefix="/api")
|
||||
app.include_router(dealer.router, prefix="/api")
|
||||
app.include_router(vehicle_share.router, prefix="/api")
|
||||
app.include_router(withdrawal.router, prefix="/api")
|
||||
app.include_router(referral.router, prefix="/api")
|
||||
app.include_router(notification.router, prefix="/api")
|
||||
app.include_router(dashboard.router, prefix="/api")
|
||||
app.include_router(push.router, prefix="/api")
|
||||
app.include_router(exchange_rate.router)
|
||||
app.include_router(verification.router, prefix="/api")
|
||||
app.include_router(visitor.router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {"message": "AutonetSellCar API", "version": "1.0.0"}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "healthy"}
|
||||
64
backend/app/models/__init__.py
Normal file
64
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from .car import CarMaker, CarModel, Car, CarImage, CarOption
|
||||
from .user import User, CarView, PerformanceCheckView, ChargeHistory, VerificationCode
|
||||
from .inquiry import Inquiry, InquiryMessage, InquiryStatus, InquiryCategory
|
||||
from .hero_banner import HeroBanner, HeroBannerSettings
|
||||
from .translation import Translation
|
||||
from .cache import CarCache, CarDetailCache, CacheRequestQueue
|
||||
from .settings import SystemSettings
|
||||
from .vehicle_request import VehicleRequest, RequestVehicle, PurchasedVehicle
|
||||
from .dealer import DealerApplication, DealerInfo
|
||||
from .vehicle_share import VehicleShare, ShareReward
|
||||
from .withdrawal import WithdrawalRequest
|
||||
from .referral import ReferralReward
|
||||
from .notification import Notification
|
||||
from .push_subscription import PushSubscription, UserNotificationPreference
|
||||
from .performance_check import CarPerformanceCheck
|
||||
from .car_specification import CarSpecification
|
||||
from .exchange_rate import ExchangeRate, ExchangeRateHistory
|
||||
from .cc_package import CCPackage, DEFAULT_CC_PACKAGES
|
||||
from .visitor import VisitorLog, VisitorDailyStats, VisitorSession
|
||||
|
||||
__all__ = [
|
||||
"CarMaker",
|
||||
"CarModel",
|
||||
"Car",
|
||||
"CarImage",
|
||||
"CarOption",
|
||||
"CarPerformanceCheck",
|
||||
"CarSpecification",
|
||||
"User",
|
||||
"CarView",
|
||||
"PerformanceCheckView",
|
||||
"ChargeHistory",
|
||||
"VerificationCode",
|
||||
"Inquiry",
|
||||
"InquiryMessage",
|
||||
"InquiryStatus",
|
||||
"InquiryCategory",
|
||||
"HeroBanner",
|
||||
"HeroBannerSettings",
|
||||
"Translation",
|
||||
"CarCache",
|
||||
"CarDetailCache",
|
||||
"CacheRequestQueue",
|
||||
"SystemSettings",
|
||||
"VehicleRequest",
|
||||
"RequestVehicle",
|
||||
"PurchasedVehicle",
|
||||
"DealerApplication",
|
||||
"DealerInfo",
|
||||
"VehicleShare",
|
||||
"ShareReward",
|
||||
"WithdrawalRequest",
|
||||
"ReferralReward",
|
||||
"Notification",
|
||||
"PushSubscription",
|
||||
"UserNotificationPreference",
|
||||
"ExchangeRate",
|
||||
"ExchangeRateHistory",
|
||||
"CCPackage",
|
||||
"DEFAULT_CC_PACKAGES",
|
||||
"VisitorLog",
|
||||
"VisitorDailyStats",
|
||||
"VisitorSession",
|
||||
]
|
||||
75
backend/app/models/cache.py
Normal file
75
backend/app/models/cache.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
캐시 모델 - 카모두 검색 결과 캐싱
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Text, Index
|
||||
from sqlalchemy.sql import func
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class CarCache(Base):
|
||||
"""
|
||||
검색 결과 캐시 테이블 (Maker + Model 단위)
|
||||
캐시 키: maker_code_model_code (예: "2_38" = 기아_K5)
|
||||
"""
|
||||
__tablename__ = "car_cache"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
cache_key = Column(String(50), unique=True, nullable=False, index=True)
|
||||
|
||||
maker_code = Column(String(10), nullable=False)
|
||||
maker_name = Column(String(100), nullable=False)
|
||||
model_code = Column(String(10), nullable=False)
|
||||
model_name = Column(String(100), nullable=False)
|
||||
|
||||
total_count = Column(Integer, nullable=False, default=0)
|
||||
cars_data = Column(Text, nullable=False) # JSON: 전체 차량 목록
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_car_cache_expires', 'expires_at'),
|
||||
Index('idx_car_cache_maker_model', 'maker_code', 'model_code'),
|
||||
)
|
||||
|
||||
|
||||
class CarDetailCache(Base):
|
||||
"""
|
||||
개별 차량 상세 정보 캐시 테이블
|
||||
"""
|
||||
__tablename__ = "car_detail_cache"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
car_id = Column(String(50), unique=True, nullable=False, index=True) # 카모두 차량 ID
|
||||
|
||||
detail_data = Column(Text, nullable=False) # JSON: 상세 정보
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_car_detail_cache_expires', 'expires_at'),
|
||||
)
|
||||
|
||||
|
||||
class CacheRequestQueue(Base):
|
||||
"""
|
||||
캐시 요청 대기열 - 동일 조건 요청 병합용
|
||||
"""
|
||||
__tablename__ = "cache_request_queue"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
cache_key = Column(String(50), nullable=False, index=True)
|
||||
status = Column(String(20), nullable=False, default='pending') # pending, processing, completed, failed
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
started_at = Column(DateTime(timezone=True))
|
||||
completed_at = Column(DateTime(timezone=True))
|
||||
|
||||
error_message = Column(Text)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_cache_request_status', 'status', 'cache_key'),
|
||||
)
|
||||
110
backend/app/models/car.py
Normal file
110
backend/app/models/car.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from sqlalchemy import Column, Integer, String, BigInteger, Boolean, ForeignKey, DateTime, Text, DECIMAL
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class CarMaker(Base):
|
||||
__tablename__ = "car_makers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(10), unique=True, nullable=False, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
name_en = Column(String(100))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
models = relationship("CarModel", back_populates="maker")
|
||||
cars = relationship("Car", back_populates="maker")
|
||||
|
||||
|
||||
class CarModel(Base):
|
||||
__tablename__ = "car_models"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(10), nullable=False, index=True)
|
||||
maker_id = Column(Integer, ForeignKey("car_makers.id"), nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
name_en = Column(String(100))
|
||||
|
||||
maker = relationship("CarMaker", back_populates="models")
|
||||
cars = relationship("Car", back_populates="model")
|
||||
|
||||
|
||||
class Car(Base):
|
||||
__tablename__ = "cars"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
source = Column(String(50), nullable=False, default="carmodoo")
|
||||
source_id = Column(String(50), nullable=False, index=True)
|
||||
source_key = Column(Text)
|
||||
|
||||
maker_id = Column(Integer, ForeignKey("car_makers.id"))
|
||||
model_id = Column(Integer, ForeignKey("car_models.id"))
|
||||
car_name = Column(String(200))
|
||||
|
||||
year = Column(Integer, index=True)
|
||||
month = Column(Integer)
|
||||
mileage = Column(Integer)
|
||||
price_krw = Column(BigInteger, index=True)
|
||||
margin_krw = Column(BigInteger, default=0) # Korean margin amount in KRW
|
||||
margin_mn = Column(BigInteger, default=0) # Mongolian margin amount in KRW
|
||||
price_usd = Column(DECIMAL(12, 2))
|
||||
is_displayed = Column(Boolean, default=False, index=True) # Show to users
|
||||
|
||||
fuel = Column(String(20))
|
||||
transmission = Column(String(20))
|
||||
color = Column(String(50))
|
||||
displacement = Column(Integer)
|
||||
car_number = Column(String(20))
|
||||
|
||||
seize_count = Column(Integer, default=0)
|
||||
collateral_count = Column(Integer, default=0)
|
||||
|
||||
check_num = Column(String(50))
|
||||
dealer_name = Column(String(100))
|
||||
dealer_phone = Column(String(50))
|
||||
shop_name = Column(String(100))
|
||||
dealer_description = Column(Text) # 딜러가 작성한 차량 상세설명 (한국어 원문)
|
||||
dealer_description_en = Column(Text) # 영어 번역
|
||||
dealer_description_mn = Column(Text) # 몽골어 번역
|
||||
dealer_description_ru = Column(Text) # 러시아어 번역
|
||||
|
||||
memo = Column(Text)
|
||||
|
||||
status = Column(String(20), default="active", index=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
synced_at = Column(DateTime(timezone=True))
|
||||
|
||||
maker = relationship("CarMaker", back_populates="cars")
|
||||
model = relationship("CarModel", back_populates="cars")
|
||||
images = relationship("CarImage", back_populates="car", cascade="all, delete-orphan")
|
||||
options = relationship("CarOption", back_populates="car", cascade="all, delete-orphan")
|
||||
# inquiries relationship disabled due to schema mismatch - use raw SQL for inquiry operations
|
||||
# inquiries = relationship("Inquiry", back_populates="car")
|
||||
views = relationship("CarView", back_populates="car", cascade="all, delete-orphan")
|
||||
performance_check = relationship("CarPerformanceCheck", back_populates="car", uselist=False, cascade="all, delete-orphan")
|
||||
specification = relationship("CarSpecification", back_populates="car", uselist=False, cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class CarImage(Base):
|
||||
__tablename__ = "car_images"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
car_id = Column(Integer, ForeignKey("cars.id", ondelete="CASCADE"), nullable=False)
|
||||
url = Column(String(500))
|
||||
local_path = Column(String(500))
|
||||
is_main = Column(Boolean, default=False)
|
||||
sort_order = Column(Integer, default=0)
|
||||
|
||||
car = relationship("Car", back_populates="images")
|
||||
|
||||
|
||||
class CarOption(Base):
|
||||
__tablename__ = "car_options"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
car_id = Column(Integer, ForeignKey("cars.id", ondelete="CASCADE"), nullable=False)
|
||||
option_name = Column(String(100))
|
||||
|
||||
car = relationship("Car", back_populates="options")
|
||||
59
backend/app/models/car_specification.py
Normal file
59
backend/app/models/car_specification.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
차량 상세사양 (Car Specifications) 모델
|
||||
카모두 상세사양조회 서비스에서 가져온 차량 스펙 정보를 저장
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Text, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class CarSpecification(Base):
|
||||
"""차량 상세사양"""
|
||||
__tablename__ = "car_specifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
car_id = Column(Integer, ForeignKey("cars.id", ondelete="CASCADE"), nullable=False, unique=True)
|
||||
|
||||
# 기본 정보
|
||||
manufacturer = Column(String(50)) # 제조사
|
||||
model_name = Column(String(100)) # 모델명
|
||||
grade = Column(String(100)) # 등급/트림
|
||||
model_year = Column(String(20)) # 연식
|
||||
|
||||
# 엔진/성능
|
||||
displacement = Column(Integer) # 배기량 (cc)
|
||||
fuel_type = Column(String(30)) # 연료 (가솔린/디젤/하이브리드/전기)
|
||||
transmission = Column(String(30)) # 변속기 (자동/수동/CVT)
|
||||
drive_type = Column(String(30)) # 구동방식 (전륜/후륜/4륜)
|
||||
max_power = Column(String(50)) # 최고출력 (예: 180ps/6,000rpm)
|
||||
max_torque = Column(String(50)) # 최대토크 (예: 23.5kg.m/4,200rpm)
|
||||
fuel_efficiency = Column(String(50)) # 연비 (예: 12.5km/L)
|
||||
|
||||
# 차체
|
||||
body_type = Column(String(30)) # 차체형태 (세단/SUV/해치백 등)
|
||||
door_count = Column(Integer) # 도어수
|
||||
seating_capacity = Column(Integer) # 승차정원
|
||||
|
||||
# 제원
|
||||
length = Column(Integer) # 전장 (mm)
|
||||
width = Column(Integer) # 전폭 (mm)
|
||||
height = Column(Integer) # 전고 (mm)
|
||||
wheelbase = Column(Integer) # 축거 (mm)
|
||||
curb_weight = Column(Integer) # 공차중량 (kg)
|
||||
|
||||
# 옵션/편의장치 (JSON 배열)
|
||||
safety_options = Column(JSON) # 안전옵션 ["에어백", "ABS", ...]
|
||||
comfort_options = Column(JSON) # 편의옵션 ["썬루프", "열선시트", ...]
|
||||
exterior_options = Column(JSON) # 외장옵션 ["LED헤드램프", ...]
|
||||
interior_options = Column(JSON) # 내장옵션 ["가죽시트", ...]
|
||||
|
||||
# 원본 데이터
|
||||
raw_data = Column(JSON) # 전체 원본 데이터 (파싱하지 못한 정보 포함)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationship
|
||||
car = relationship("Car", back_populates="specification")
|
||||
48
backend/app/models/cc_package.py
Normal file
48
backend/app/models/cc_package.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Float
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class CCPackage(Base):
|
||||
"""CC charging packages"""
|
||||
__tablename__ = "cc_packages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(50), nullable=False) # e.g., "Basic", "Standard", "Premium"
|
||||
price_usd = Column(Integer, nullable=False) # Price in USD (10, 27, 40)
|
||||
cc_amount = Column(Integer, nullable=False) # CC amount (10, 30, 50)
|
||||
bonus_cc = Column(Integer, default=0) # Bonus CC (0, 3, 10)
|
||||
discount_percent = Column(Integer, default=0) # Discount percentage (0, 10, 20)
|
||||
is_active = Column(Boolean, default=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
|
||||
# Stripe Price ID for recurring or one-time payments
|
||||
stripe_price_id = Column(String(100), nullable=True)
|
||||
|
||||
|
||||
# Default CC packages
|
||||
DEFAULT_CC_PACKAGES = [
|
||||
{
|
||||
"name": "Basic",
|
||||
"price_usd": 10,
|
||||
"cc_amount": 10,
|
||||
"bonus_cc": 0,
|
||||
"discount_percent": 0,
|
||||
"sort_order": 1,
|
||||
},
|
||||
{
|
||||
"name": "Standard",
|
||||
"price_usd": 27,
|
||||
"cc_amount": 27,
|
||||
"bonus_cc": 3,
|
||||
"discount_percent": 10,
|
||||
"sort_order": 2,
|
||||
},
|
||||
{
|
||||
"name": "Premium",
|
||||
"price_usd": 40,
|
||||
"cc_amount": 40,
|
||||
"bonus_cc": 10,
|
||||
"discount_percent": 20,
|
||||
"sort_order": 3,
|
||||
},
|
||||
]
|
||||
85
backend/app/models/dealer.py
Normal file
85
backend/app/models/dealer.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, Float
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
import hashlib
|
||||
from ..database import Base
|
||||
|
||||
|
||||
def generate_dealer_code():
|
||||
"""Generate a unique 6-character dealer code"""
|
||||
unique_id = uuid.uuid4().hex
|
||||
return "D" + hashlib.sha256(unique_id.encode()).hexdigest()[:5].upper()
|
||||
|
||||
|
||||
class DealerApplication(Base):
|
||||
"""Dealer application for users wanting to become dealers"""
|
||||
__tablename__ = "dealer_applications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
# Business info
|
||||
business_name = Column(String(100), nullable=False) # 상호명
|
||||
business_number = Column(String(50), nullable=True) # 사업자번호 (선택)
|
||||
|
||||
# Personal info
|
||||
real_name = Column(String(100), nullable=False) # 실명
|
||||
id_number_encrypted = Column(String(255), nullable=True) # 주민번호/외국인번호 (암호화)
|
||||
phone = Column(String(50), nullable=False) # 연락처
|
||||
|
||||
# Bank info for withdrawals
|
||||
bank_name = Column(String(50), nullable=False) # 은행명
|
||||
bank_account = Column(String(100), nullable=False) # 계좌번호
|
||||
account_holder = Column(String(100), nullable=False) # 예금주명
|
||||
|
||||
# Photo
|
||||
photo_url = Column(String(500), nullable=True) # 본인 사진 URL
|
||||
|
||||
# Application status
|
||||
status = Column(String(20), default="pending") # pending, approved, rejected
|
||||
rejected_reason = Column(Text, nullable=True) # 거부 사유
|
||||
|
||||
# Timestamps
|
||||
applied_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
approved_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="dealer_application")
|
||||
|
||||
|
||||
class DealerInfo(Base):
|
||||
"""Approved dealer information"""
|
||||
__tablename__ = "dealer_info"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False)
|
||||
|
||||
# Dealer identification
|
||||
dealer_code = Column(String(10), unique=True, index=True, nullable=False) # 딜러 고유 코드 (D + 5자리)
|
||||
dealer_card_url = Column(String(500), nullable=True) # 딜러증 이미지 URL
|
||||
|
||||
# Business info (from application)
|
||||
business_name = Column(String(100), nullable=False)
|
||||
real_name = Column(String(100), nullable=False)
|
||||
phone = Column(String(50), nullable=False)
|
||||
photo_url = Column(String(500), nullable=True)
|
||||
|
||||
# Bank info (from application)
|
||||
bank_name = Column(String(50), nullable=False)
|
||||
bank_account = Column(String(100), nullable=False)
|
||||
account_holder = Column(String(100), nullable=False)
|
||||
|
||||
# Earnings
|
||||
total_commission_earned = Column(Float, default=0.0) # 총 수수료 수익 (KRW)
|
||||
total_withdrawn = Column(Float, default=0.0) # 총 출금액 (KRW)
|
||||
pending_withdrawal = Column(Float, default=0.0) # 출금 대기 금액 (KRW)
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="dealer_info")
|
||||
46
backend/app/models/exchange_rate.py
Normal file
46
backend/app/models/exchange_rate.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Exchange Rate Model - 환율 정보 저장
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, Boolean
|
||||
from sqlalchemy.sql import func
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class ExchangeRate(Base):
|
||||
"""환율 정보 테이블"""
|
||||
__tablename__ = "exchange_rates"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# 통화 정보
|
||||
currency_code = Column(String(10), unique=True, index=True) # USD, MNT, RUB, CNY
|
||||
currency_name = Column(String(100)) # 미국 달러, 몽골 투그릭 등
|
||||
|
||||
# 환율 정보 (한국수출입은행 기준)
|
||||
deal_base_rate = Column(Float) # 매매기준율 (1 USD = X KRW)
|
||||
ttb_rate = Column(Float) # 전신환(송금) 받을때
|
||||
tts_rate = Column(Float) # 전신환(송금) 보낼때
|
||||
|
||||
# 가중치 적용 환율
|
||||
weight_percent = Column(Float, default=0.0) # 관리자 설정 가중치 (%)
|
||||
adjusted_rate = Column(Float) # 가중치 적용된 환율
|
||||
|
||||
# 메타 정보
|
||||
source_date = Column(String(20)) # 수출입은행 기준일 (예: 20241223)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class ExchangeRateHistory(Base):
|
||||
"""환율 변동 이력 테이블"""
|
||||
__tablename__ = "exchange_rate_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
currency_code = Column(String(10), index=True)
|
||||
deal_base_rate = Column(Float)
|
||||
source_date = Column(String(20))
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
67
backend/app/models/hero_banner.py
Normal file
67
backend/app/models/hero_banner.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class HeroBannerSettings(Base):
|
||||
"""히어로 배너 슬라이더 설정"""
|
||||
__tablename__ = "hero_banner_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# 슬라이드 전환 간격 (밀리초)
|
||||
slide_interval = Column(Integer, default=3000) # 3초
|
||||
|
||||
# 애니메이션 타입: 'film-strip', 'fade', 'slide'
|
||||
animation_type = Column(String(20), default="film-strip")
|
||||
|
||||
# 이미지 크기
|
||||
image_width = Column(Integer, default=500)
|
||||
image_height = Column(Integer, default=300)
|
||||
|
||||
# 자동 재생 여부
|
||||
auto_play = Column(Boolean, default=True)
|
||||
|
||||
# 타임스탬프
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class HeroBanner(Base):
|
||||
"""히어로 배너 이미지"""
|
||||
__tablename__ = "hero_banners"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# 다국어 제목
|
||||
title_ko = Column(String(100))
|
||||
title_en = Column(String(100))
|
||||
title_mn = Column(String(100)) # 몽골어
|
||||
|
||||
# 다국어 서브타이틀
|
||||
subtitle_ko = Column(String(200))
|
||||
subtitle_en = Column(String(200))
|
||||
subtitle_mn = Column(String(200))
|
||||
|
||||
# 이미지 URL
|
||||
image_url = Column(String(500), nullable=False)
|
||||
|
||||
# 클릭 시 이동 URL (선택)
|
||||
link_url = Column(String(500))
|
||||
|
||||
# 연결된 차량 ID (선택 - 차량 상세 페이지로 연결)
|
||||
car_id = Column(Integer, ForeignKey("cars.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
# 활성화 여부
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# 표시 순서 (낮을수록 먼저)
|
||||
display_order = Column(Integer, default=0)
|
||||
|
||||
# 타임스탬프
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# 관계
|
||||
car = relationship("Car", foreign_keys=[car_id])
|
||||
79
backend/app/models/inquiry.py
Normal file
79
backend/app/models/inquiry.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class InquiryStatus:
|
||||
PENDING = "pending"
|
||||
IN_PROGRESS = "in_progress"
|
||||
RESOLVED = "resolved"
|
||||
CLOSED = "closed"
|
||||
|
||||
|
||||
class InquiryCategory:
|
||||
GENERAL = "general"
|
||||
VEHICLE = "vehicle"
|
||||
PAYMENT = "payment"
|
||||
SHIPPING = "shipping"
|
||||
DEALER = "dealer"
|
||||
ACCOUNT = "account"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class Inquiry(Base):
|
||||
"""User inquiry/support ticket"""
|
||||
__tablename__ = "inquiries"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# Legacy field for backward compatibility
|
||||
car_id = Column(Integer, ForeignKey("cars.id"), nullable=True)
|
||||
|
||||
# Inquiry details
|
||||
category = Column(String(50), default=InquiryCategory.GENERAL)
|
||||
subject = Column(String(200), nullable=True)
|
||||
message = Column(Text, nullable=False)
|
||||
|
||||
# Contact info (can be different from user's profile)
|
||||
contact_email = Column(String(255), nullable=True)
|
||||
contact_phone = Column(String(50), nullable=True)
|
||||
|
||||
# Status
|
||||
status = Column(String(20), default=InquiryStatus.PENDING)
|
||||
|
||||
# Admin response
|
||||
admin_response = Column(Text, nullable=True)
|
||||
responded_at = Column(DateTime(timezone=True), nullable=True)
|
||||
responded_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", foreign_keys=[user_id], back_populates="inquiries")
|
||||
responder = relationship("User", foreign_keys=[responded_by])
|
||||
# car relationship disabled due to schema mismatch - Car model doesn't have inquiries relationship
|
||||
# car = relationship("Car", back_populates="inquiries")
|
||||
|
||||
|
||||
class InquiryMessage(Base):
|
||||
"""Messages within an inquiry thread"""
|
||||
__tablename__ = "inquiry_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
inquiry_id = Column(Integer, ForeignKey("inquiries.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
message = Column(Text, nullable=False)
|
||||
is_admin = Column(Boolean, default=False)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
inquiry = relationship("Inquiry", backref="messages")
|
||||
user = relationship("User")
|
||||
37
backend/app/models/notification.py
Normal file
37
backend/app/models/notification.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
"""User notifications"""
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
# Notification type: vehicle_recommended, shipping_update, withdrawal_processed,
|
||||
# referral_reward, dealer_approved, share_purchased, system
|
||||
notification_type = Column(String(50), nullable=False)
|
||||
|
||||
# Title and message (supports i18n keys or direct text)
|
||||
title = Column(String(200), nullable=False)
|
||||
message = Column(Text, nullable=False)
|
||||
|
||||
# Optional link to navigate when clicked
|
||||
link = Column(String(500), nullable=True)
|
||||
|
||||
# Related entity (optional)
|
||||
related_id = Column(Integer, nullable=True) # ID of related entity
|
||||
related_type = Column(String(50), nullable=True) # Type: vehicle_request, purchased_vehicle, withdrawal, etc.
|
||||
|
||||
# Status
|
||||
is_read = Column(Boolean, default=False)
|
||||
read_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", backref="notifications")
|
||||
119
backend/app/models/performance_check.py
Normal file
119
backend/app/models/performance_check.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
성능점검표 (Performance Check Report) 모델
|
||||
카모두에서 가져온 차량 성능점검 정보를 저장
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, DateTime, Text, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class CarPerformanceCheck(Base):
|
||||
"""차량 성능점검표"""
|
||||
__tablename__ = "car_performance_checks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
car_id = Column(Integer, ForeignKey("cars.id", ondelete="CASCADE"), nullable=False, unique=True)
|
||||
|
||||
# 성능점검 기본정보
|
||||
check_number = Column(String(50)) # 성능점검번호
|
||||
check_date = Column(String(20)) # 점검일자
|
||||
valid_until = Column(String(20)) # 유효기간
|
||||
inspector_name = Column(String(50)) # 점검자명
|
||||
inspector_license = Column(String(50)) # 점검자 자격번호
|
||||
|
||||
# 차량 기본정보 (car_number는 cars 테이블에서 관리 - 원자성)
|
||||
first_registration = Column(String(20)) # 최초등록일
|
||||
model_year = Column(String(20)) # 연식
|
||||
|
||||
# 주행거리
|
||||
mileage = Column(Integer) # 주행거리
|
||||
mileage_status = Column(String(20)) # 주행거리 상태 (정상/조작의심/교환됨)
|
||||
|
||||
# 압류/저당 정보
|
||||
seize_count = Column(Integer, default=0) # 압류 건수
|
||||
collateral_count = Column(Integer, default=0) # 저당 건수
|
||||
|
||||
# 특별 이력 (침수/화재/전손)
|
||||
is_flood_damaged = Column(Boolean, default=False) # 침수
|
||||
is_fire_damaged = Column(Boolean, default=False) # 화재
|
||||
is_total_loss = Column(Boolean, default=False) # 전손
|
||||
|
||||
# 용도이력
|
||||
usage_history = Column(String(100)) # 자가용/영업용/관용 등
|
||||
is_rental_used = Column(Boolean, default=False) # 렌트 이력
|
||||
|
||||
# 주요장치 상태 (JSON으로 상세정보 저장)
|
||||
# 각 항목: 양호/주의/불량
|
||||
engine_status = Column(String(20)) # 원동기
|
||||
transmission_status = Column(String(20)) # 변속기
|
||||
power_delivery_status = Column(String(20)) # 동력전달
|
||||
steering_status = Column(String(20)) # 조향장치
|
||||
brake_status = Column(String(20)) # 제동장치
|
||||
electrical_status = Column(String(20)) # 전기장치
|
||||
fuel_system_status = Column(String(20)) # 연료장치
|
||||
|
||||
# 타이어 상태
|
||||
tire_front_left = Column(String(20)) # 전좌
|
||||
tire_front_right = Column(String(20)) # 전우
|
||||
tire_rear_left = Column(String(20)) # 후좌
|
||||
tire_rear_right = Column(String(20)) # 후우
|
||||
|
||||
# 사고 이력 (외판/주요골격) - JSON으로 상세 저장
|
||||
# 부위별: 없음/교환/판금용접/부식/손상
|
||||
accident_history = Column(JSON) # {"hood": "교환", "front_fender_left": "판금", ...}
|
||||
|
||||
# 외판 부위
|
||||
hood = Column(String(20)) # 후드
|
||||
front_fender_left = Column(String(20)) # 프론트휀더(좌)
|
||||
front_fender_right = Column(String(20)) # 프론트휀더(우)
|
||||
front_door_left = Column(String(20)) # 프론트도어(좌)
|
||||
front_door_right = Column(String(20)) # 프론트도어(우)
|
||||
rear_door_left = Column(String(20)) # 리어도어(좌)
|
||||
rear_door_right = Column(String(20)) # 리어도어(우)
|
||||
trunk_lid = Column(String(20)) # 트렁크리드
|
||||
radiator_support = Column(String(20)) # 라디에이터서포트
|
||||
roof_panel = Column(String(20)) # 루프패널
|
||||
quarter_panel_left = Column(String(20)) # 쿼터패널(좌)
|
||||
quarter_panel_right = Column(String(20)) # 쿼터패널(우)
|
||||
side_sill_left = Column(String(20)) # 사이드실패널(좌)
|
||||
side_sill_right = Column(String(20)) # 사이드실패널(우)
|
||||
|
||||
# 주요골격 부위
|
||||
front_panel = Column(String(20)) # 프론트패널
|
||||
cross_member = Column(String(20)) # 크로스멤버
|
||||
inside_panel_left = Column(String(20)) # 인사이드패널(좌)
|
||||
inside_panel_right = Column(String(20)) # 인사이드패널(우)
|
||||
side_member_left = Column(String(20)) # 사이드멤버(좌)
|
||||
side_member_right = Column(String(20)) # 사이드멤버(우)
|
||||
wheel_house_left = Column(String(20)) # 휠하우스(좌)
|
||||
wheel_house_right = Column(String(20)) # 휠하우스(우)
|
||||
dash_panel = Column(String(20)) # 대쉬패널
|
||||
floor_panel = Column(String(20)) # 플로어패널
|
||||
trunk_floor = Column(String(20)) # 트렁크플로어
|
||||
rear_panel = Column(String(20)) # 리어패널
|
||||
pillar_a_left = Column(String(20)) # 필러A(좌)
|
||||
pillar_a_right = Column(String(20)) # 필러A(우)
|
||||
pillar_b_left = Column(String(20)) # 필러B(좌)
|
||||
pillar_b_right = Column(String(20)) # 필러B(우)
|
||||
pillar_c_left = Column(String(20)) # 필러C(좌)
|
||||
pillar_c_right = Column(String(20)) # 필러C(우)
|
||||
package_tray = Column(String(20)) # 패키지트레이
|
||||
|
||||
# 원본 데이터 (파싱하지 못한 추가 정보)
|
||||
raw_data = Column(JSON) # 전체 원본 데이터
|
||||
raw_html = Column(Text) # 원본 HTML (디버깅용)
|
||||
|
||||
# 점검표 이미지 URL
|
||||
report_image_url = Column(String(500)) # 성능점검표 이미지
|
||||
report_image_local = Column(String(500)) # 로컬 저장 경로
|
||||
|
||||
# PDF 파일 경로 (Playwright로 캡처한 성능점검표)
|
||||
pdf_path = Column(String(500)) # PDF 파일 상대경로 (/uploads/performance_checks/xxx.pdf)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationship
|
||||
car = relationship("Car", back_populates="performance_check")
|
||||
48
backend/app/models/push_subscription.py
Normal file
48
backend/app/models/push_subscription.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class PushSubscription(Base):
|
||||
"""Store user's push notification subscriptions"""
|
||||
__tablename__ = "push_subscriptions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
endpoint = Column(Text, nullable=False) # Push service endpoint URL
|
||||
p256dh_key = Column(String(255), nullable=False) # Public key for encryption
|
||||
auth_key = Column(String(255), nullable=False) # Auth secret for encryption
|
||||
device_info = Column(String(255), nullable=True) # Browser/device info
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
last_used_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
user = relationship("User", backref="push_subscriptions")
|
||||
|
||||
|
||||
class UserNotificationPreference(Base):
|
||||
"""User preferences for different notification types"""
|
||||
__tablename__ = "user_notification_preferences"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
|
||||
|
||||
# Notification type preferences (True = enabled)
|
||||
vehicle_recommended = Column(Boolean, default=True)
|
||||
shipping_update = Column(Boolean, default=True)
|
||||
payment_confirmed = Column(Boolean, default=True)
|
||||
withdrawal_processed = Column(Boolean, default=True)
|
||||
dealer_status = Column(Boolean, default=True)
|
||||
share_purchased = Column(Boolean, default=True)
|
||||
referral_reward = Column(Boolean, default=True)
|
||||
inquiry_reply = Column(Boolean, default=True)
|
||||
system_announcements = Column(Boolean, default=True)
|
||||
|
||||
# Channel preferences
|
||||
push_enabled = Column(Boolean, default=True)
|
||||
email_enabled = Column(Boolean, default=False)
|
||||
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
user = relationship("User", backref="notification_preferences")
|
||||
37
backend/app/models/referral.py
Normal file
37
backend/app/models/referral.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class ReferralReward(Base):
|
||||
"""레퍼럴 보상 모델"""
|
||||
__tablename__ = "referral_rewards"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# 추천인 (보상 받는 사람)
|
||||
referrer_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
# 피추천인 (추천받아 가입한 사람)
|
||||
referred_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
# 결제 금액 (피추천인이 충전한 금액 USD)
|
||||
payment_amount = Column(Float, nullable=False)
|
||||
|
||||
# 보상 금액 (결제 금액의 X%)
|
||||
reward_amount = Column(Float, nullable=False)
|
||||
|
||||
# 보상 상태: pending(대기), credited(적립), withdrawn(출금)
|
||||
status = Column(String(20), default="pending")
|
||||
|
||||
# 출금 요청 ID (출금 시 연결)
|
||||
withdrawal_request_id = Column(Integer, ForeignKey("withdrawal_requests.id"), nullable=True)
|
||||
|
||||
# 타임스탬프
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
credited_at = Column(DateTime(timezone=True), nullable=True) # 적립 시각
|
||||
|
||||
# Relationships
|
||||
referrer = relationship("User", foreign_keys=[referrer_id], backref="referral_rewards_given")
|
||||
referred_user = relationship("User", foreign_keys=[referred_user_id], backref="referral_rewards_received")
|
||||
45
backend/app/models/settings.py
Normal file
45
backend/app/models/settings.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, Boolean
|
||||
from sqlalchemy.sql import func
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class SystemSettings(Base):
|
||||
"""시스템 설정"""
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# 검색 결과 페이지 크기
|
||||
search_page_size = Column(Integer, default=20)
|
||||
|
||||
# 마진 설정 (%)
|
||||
korea_margin_percent = Column(Float, default=5.0)
|
||||
mongolia_margin_percent = Column(Float, default=5.0)
|
||||
|
||||
# CC 코인 설정
|
||||
cc_per_usdc = Column(Integer, default=10) # 1 USDC = 10 CC
|
||||
cc_per_view = Column(Integer, default=1) # 차량 상세 조회 시 1 CC
|
||||
cc_signup_bonus = Column(Integer, default=3) # 신규 가입 시 3 CC
|
||||
cars_per_cc = Column(Integer, default=3) # 1 CC당 추천 차량 수 (기본 3대)
|
||||
|
||||
# 캐시 TTL (시간)
|
||||
cache_ttl_hours = Column(Integer, default=2)
|
||||
|
||||
# 컨테이너 물류비 설정 (USD)
|
||||
container_logistics_usd = Column(Integer, default=3600) # 컨테이너 물류비 $3,600
|
||||
shoring_cost_usd = Column(Integer, default=300) # 쇼링비 (컨테이너 고정) $300
|
||||
|
||||
# 레퍼럴 보상 설정
|
||||
referral_reward_enabled = Column(Boolean, default=True) # 레퍼럴 보상 활성화
|
||||
referral_reward_percent = Column(Float, default=10.0) # 보상 비율 (기본 10%)
|
||||
referral_reward_type = Column(String(20), default="one_time") # one_time / recurring
|
||||
|
||||
# 환율 가중치 설정 (%)
|
||||
exchange_rate_weight_usd = Column(Float, default=0.0) # USD 가중치
|
||||
exchange_rate_weight_mnt = Column(Float, default=0.0) # MNT (몽골 투그릭) 가중치
|
||||
exchange_rate_weight_rub = Column(Float, default=0.0) # RUB (러시아 루블) 가중치
|
||||
exchange_rate_weight_cny = Column(Float, default=0.0) # CNY (중국 위안) 가중치
|
||||
|
||||
# 타임스탬프
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
28
backend/app/models/translation.py
Normal file
28
backend/app/models/translation.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Index
|
||||
from sqlalchemy.sql import func
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class Translation(Base):
|
||||
"""Translation dictionary for car-related terms"""
|
||||
__tablename__ = "translations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Source text (Korean)
|
||||
source_text = Column(String(500), nullable=False, index=True)
|
||||
|
||||
# Category: maker, model, fuel, transmission, color, car_name, etc.
|
||||
category = Column(String(50), nullable=False, index=True)
|
||||
|
||||
# Translations
|
||||
text_en = Column(String(500)) # English
|
||||
text_mn = Column(String(500)) # Mongolian
|
||||
text_ru = Column(String(500)) # Russian
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_translations_source_category', 'source_text', 'category', unique=True),
|
||||
)
|
||||
138
backend/app/models/user.py
Normal file
138
backend/app/models/user.py
Normal file
@@ -0,0 +1,138 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Float
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
import hashlib
|
||||
from ..database import Base
|
||||
|
||||
|
||||
def generate_referral_code():
|
||||
"""Generate a unique 8-character referral code"""
|
||||
unique_id = uuid.uuid4().hex
|
||||
return hashlib.sha256(unique_id.encode()).hexdigest()[:8].upper()
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
name = Column(String(100))
|
||||
phone = Column(String(50))
|
||||
country = Column(String(50), default="Mongolia")
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_admin = Column(Boolean, default=False)
|
||||
is_dealer = Column(Boolean, default=False) # Dealer status
|
||||
cc_balance = Column(Float, default=3.0) # CC coin balance, 3 free on signup
|
||||
referral_code = Column(String(8), unique=True, index=True) # Unique referral code for sharing
|
||||
referred_by = Column(String(8), nullable=True) # Referral code of the user who referred this user
|
||||
|
||||
# Email verification
|
||||
email_verified = Column(Boolean, default=False)
|
||||
email_verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Phone verification
|
||||
phone_verified = Column(Boolean, default=False)
|
||||
phone_verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Account withdrawal/deletion
|
||||
withdrawal_requested_at = Column(DateTime(timezone=True), nullable=True) # 탈퇴 요청 시각
|
||||
withdrawal_reason = Column(String(500), nullable=True) # 탈퇴 사유
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True) # 실제 삭제 시각 (soft delete)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Note: foreign_keys specified as string to avoid circular import
|
||||
inquiries = relationship("Inquiry", back_populates="user", primaryjoin="User.id == Inquiry.user_id")
|
||||
car_views = relationship("CarView", back_populates="user")
|
||||
performance_check_views = relationship("PerformanceCheckView", back_populates="user")
|
||||
charge_history = relationship("ChargeHistory", back_populates="user", primaryjoin="User.id == ChargeHistory.user_id")
|
||||
dealer_application = relationship("DealerApplication", back_populates="user", uselist=False)
|
||||
dealer_info = relationship("DealerInfo", back_populates="user", uselist=False)
|
||||
|
||||
|
||||
class VerificationCode(Base):
|
||||
"""Store temporary verification codes for email and phone"""
|
||||
__tablename__ = "verification_codes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Nullable for pre-registration
|
||||
email = Column(String(255), nullable=True, index=True) # For email verification
|
||||
phone = Column(String(50), nullable=True, index=True) # For phone verification
|
||||
code = Column(String(10), nullable=False) # 6-digit code
|
||||
code_type = Column(String(20), nullable=False) # 'email' or 'phone'
|
||||
purpose = Column(String(50), default="verification") # 'verification', 'password_reset'
|
||||
attempts = Column(Integer, default=0) # Failed verification attempts
|
||||
max_attempts = Column(Integer, default=5)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class CarView(Base):
|
||||
"""Track which cars a user has purchased (paid CC to view full details)"""
|
||||
__tablename__ = "car_views"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
car_id = Column(Integer, ForeignKey("cars.id"), nullable=False)
|
||||
cc_paid = Column(Integer, default=1) # CC paid for this view
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user = relationship("User", back_populates="car_views")
|
||||
car = relationship("Car", back_populates="views")
|
||||
|
||||
|
||||
class PerformanceCheckView(Base):
|
||||
"""Track which performance checks a user has purchased (paid 0.1 CC to view)"""
|
||||
__tablename__ = "performance_check_views"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
car_id = Column(Integer, ForeignKey("cars.id"), nullable=False)
|
||||
cc_paid = Column(Float, default=0.1) # CC paid for this view (0.1 CC)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user = relationship("User", back_populates="performance_check_views")
|
||||
|
||||
|
||||
class ChargeHistory(Base):
|
||||
"""Track CC charge history for users"""
|
||||
__tablename__ = "charge_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
package_id = Column(Integer, ForeignKey("cc_packages.id"), nullable=True) # CC package purchased
|
||||
amount = Column(Integer, nullable=False) # Amount in selected currency
|
||||
amount_usd = Column(Integer, nullable=True) # Amount in USD (for backwards compatibility)
|
||||
cc_amount = Column(Integer, nullable=False) # CC received
|
||||
bonus_cc = Column(Integer, default=0) # Bonus CC received
|
||||
currency = Column(String(10), default="USD") # USD, USDC, KRW
|
||||
payment_method = Column(String(50), default="stripe") # stripe, manual, usdc, bank_transfer
|
||||
|
||||
# Stripe fields
|
||||
stripe_session_id = Column(String(200), nullable=True) # Stripe Checkout Session ID
|
||||
stripe_payment_intent_id = Column(String(200), nullable=True) # Stripe Payment Intent ID
|
||||
|
||||
# Legacy fields
|
||||
transaction_id = Column(String(100), nullable=True) # External transaction ID (crypto tx hash)
|
||||
wallet_address = Column(String(100), nullable=True) # User's wallet address for refunds
|
||||
|
||||
admin_note = Column(String(500), nullable=True) # Admin notes
|
||||
status = Column(String(20), default="pending") # pending, completed, failed, cancelled
|
||||
verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
verified_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user = relationship("User", back_populates="charge_history", foreign_keys=[user_id])
|
||||
|
||||
|
||||
# Payment settings constants
|
||||
class PaymentSettings:
|
||||
USDC_WALLET_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678" # Platform USDC receiving address
|
||||
USDC_NETWORK = "Polygon" # Default network (Polygon for low fees)
|
||||
MIN_CHARGE_USD = 10
|
||||
MAX_CHARGE_USD = 10000
|
||||
SUPPORTED_CURRENCIES = ["USD", "USDC", "KRW"]
|
||||
SUPPORTED_METHODS = ["card", "usdc", "bank_transfer"]
|
||||
106
backend/app/models/vehicle_request.py
Normal file
106
backend/app/models/vehicle_request.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, JSON, Float
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class VehicleRequest(Base):
|
||||
"""Track vehicle search requests from users"""
|
||||
__tablename__ = "vehicle_requests"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
# Search criteria
|
||||
maker_code = Column(String(50))
|
||||
maker_name = Column(String(100))
|
||||
model_code = Column(String(50))
|
||||
model_name = Column(String(100))
|
||||
grade_code = Column(String(50))
|
||||
grade_name = Column(String(100))
|
||||
year_from = Column(Integer)
|
||||
year_to = Column(Integer)
|
||||
mileage_min = Column(Integer)
|
||||
mileage_max = Column(Integer)
|
||||
fuel = Column(String(50)) # 연료 타입 (휘발유, 경유, 하이브리드, LPG, 전기)
|
||||
displacement_min = Column(Integer) # 최소 배기량 (cc)
|
||||
displacement_max = Column(Integer) # 최대 배기량 (cc)
|
||||
|
||||
# CC payment for request submission
|
||||
cc_paid = Column(Float, default=1.0) # CC paid for this request (1 CC)
|
||||
|
||||
# Status: pending, reviewed, completed
|
||||
status = Column(String(20), default="pending")
|
||||
admin_reviewed_at = Column(DateTime(timezone=True))
|
||||
admin_notes = Column(Text)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", backref="vehicle_requests")
|
||||
recommended_vehicles = relationship("RequestVehicle", back_populates="request", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class RequestVehicle(Base):
|
||||
"""Vehicles recommended by admin for a user's request"""
|
||||
__tablename__ = "request_vehicles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
request_id = Column(Integer, ForeignKey("vehicle_requests.id"), nullable=False)
|
||||
|
||||
# Car data from Carmodoo (stored as JSON)
|
||||
car_data = Column(JSON, nullable=False)
|
||||
|
||||
# Admin approval
|
||||
is_approved = Column(Boolean, default=False)
|
||||
approved_at = Column(DateTime(timezone=True))
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
request = relationship("VehicleRequest", back_populates="recommended_vehicles")
|
||||
|
||||
|
||||
class PurchasedVehicle(Base):
|
||||
"""Track purchased vehicles and their shipping status"""
|
||||
__tablename__ = "purchased_vehicles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
# Vehicle info
|
||||
car_name = Column(String(200))
|
||||
car_data = Column(JSON) # Full car details
|
||||
car_image = Column(String(500)) # Main image URL
|
||||
|
||||
# Price info
|
||||
vehicle_price_krw = Column(Integer)
|
||||
domestic_cost_krw = Column(Integer)
|
||||
shipping_cost_usd = Column(Integer)
|
||||
total_cost_krw = Column(Integer)
|
||||
car_type = Column(String(20)) # small, compact
|
||||
|
||||
# Dealer selection and commission (50/50 split of Mongolia margin)
|
||||
selected_dealer_id = Column(Integer, ForeignKey("dealer_info.id"), nullable=True)
|
||||
dealer_commission_krw = Column(Integer, default=0) # 50% of Mongolia margin
|
||||
platform_commission_krw = Column(Integer, default=0) # 50% of Mongolia margin
|
||||
commission_paid = Column(Boolean, default=False) # Whether commission has been paid
|
||||
commission_paid_at = Column(DateTime(timezone=True))
|
||||
|
||||
# Shipping status: 1-5
|
||||
# 1: Purchased, 2: Incheon Port, 3: In Transit, 4: Customs, 5: Delivered
|
||||
shipping_status = Column(Integer, default=1)
|
||||
status_updated_at = Column(DateTime(timezone=True))
|
||||
|
||||
# Location info
|
||||
current_location = Column(String(200))
|
||||
estimated_arrival = Column(DateTime(timezone=True))
|
||||
|
||||
# Timestamps
|
||||
purchased_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
delivered_at = Column(DateTime(timezone=True))
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", backref="purchased_vehicles")
|
||||
selected_dealer = relationship("DealerInfo", backref="purchased_vehicles")
|
||||
75
backend/app/models/vehicle_share.py
Normal file
75
backend/app/models/vehicle_share.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Float, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
import hashlib
|
||||
from ..database import Base
|
||||
|
||||
|
||||
def generate_share_code():
|
||||
"""Generate a unique 10-character share code"""
|
||||
unique_id = uuid.uuid4().hex
|
||||
return hashlib.sha256(unique_id.encode()).hexdigest()[:10].upper()
|
||||
|
||||
|
||||
class VehicleShare(Base):
|
||||
"""Track vehicle shares with price markup"""
|
||||
__tablename__ = "vehicle_shares"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) # User who shared
|
||||
|
||||
# Reference to the original vehicle
|
||||
request_vehicle_id = Column(Integer, ForeignKey("request_vehicles.id"), nullable=False)
|
||||
|
||||
# Share code for the link
|
||||
share_code = Column(String(10), unique=True, index=True, nullable=False)
|
||||
|
||||
# Pricing
|
||||
original_price_krw = Column(Float, nullable=False) # Original vehicle price
|
||||
markup_amount_krw = Column(Float, default=0) # Additional amount added by sharer
|
||||
shared_price_krw = Column(Float, nullable=False) # Total shared price (original + markup)
|
||||
|
||||
# Statistics
|
||||
view_count = Column(Integer, default=0)
|
||||
is_purchased = Column(Boolean, default=False)
|
||||
purchased_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
expires_at = Column(DateTime(timezone=True), nullable=True) # Optional expiration
|
||||
purchased_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", foreign_keys=[user_id], backref="vehicle_shares")
|
||||
purchased_by = relationship("User", foreign_keys=[purchased_by_user_id])
|
||||
request_vehicle = relationship("RequestVehicle", backref="shares")
|
||||
|
||||
|
||||
class ShareReward(Base):
|
||||
"""Track rewards earned from vehicle shares"""
|
||||
__tablename__ = "share_rewards"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) # User who earned the reward
|
||||
vehicle_share_id = Column(Integer, ForeignKey("vehicle_shares.id"), nullable=False)
|
||||
|
||||
# Amounts
|
||||
markup_amount = Column(Float, nullable=False) # Original markup amount
|
||||
reward_amount = Column(Float, nullable=False) # 90% of markup
|
||||
tax_amount = Column(Float, nullable=False) # 3.3% tax withholding
|
||||
net_amount = Column(Float, nullable=False) # Final amount after tax
|
||||
|
||||
# Status
|
||||
status = Column(String(20), default="pending") # pending, approved, withdrawn
|
||||
|
||||
# Withdrawal tracking
|
||||
withdrawal_request_id = Column(Integer, ForeignKey("withdrawal_requests.id"), nullable=True)
|
||||
withdrawn_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", backref="share_rewards")
|
||||
vehicle_share = relationship("VehicleShare", backref="reward")
|
||||
111
backend/app/models/visitor.py
Normal file
111
backend/app/models/visitor.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Visitor tracking models for analytics
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Text, Index
|
||||
from sqlalchemy.sql import func
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class VisitorLog(Base):
|
||||
"""
|
||||
Raw visitor log - tracks every page visit
|
||||
IP addresses are hashed for privacy
|
||||
"""
|
||||
__tablename__ = "visitor_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Visitor identification (hashed for privacy)
|
||||
visitor_hash = Column(String(64), nullable=False, index=True) # SHA256 hash of IP + User-Agent
|
||||
ip_hash = Column(String(64), nullable=False) # SHA256 hash of IP only
|
||||
|
||||
# Session tracking
|
||||
session_id = Column(String(64), nullable=True, index=True) # Cookie-based session ID
|
||||
user_id = Column(Integer, nullable=True, index=True) # If logged in
|
||||
|
||||
# Page information
|
||||
page_path = Column(String(500), nullable=False, index=True)
|
||||
page_title = Column(String(200), nullable=True)
|
||||
referrer = Column(String(1000), nullable=True)
|
||||
referrer_domain = Column(String(200), nullable=True, index=True)
|
||||
|
||||
# Device information
|
||||
device_type = Column(String(20), nullable=True, index=True) # mobile, desktop, tablet
|
||||
browser = Column(String(50), nullable=True, index=True)
|
||||
browser_version = Column(String(20), nullable=True)
|
||||
os = Column(String(50), nullable=True)
|
||||
os_version = Column(String(20), nullable=True)
|
||||
|
||||
# Geographic information (from IP geolocation)
|
||||
country = Column(String(50), nullable=True, index=True)
|
||||
country_code = Column(String(5), nullable=True)
|
||||
city = Column(String(100), nullable=True)
|
||||
region = Column(String(100), nullable=True)
|
||||
|
||||
# UTM parameters
|
||||
utm_source = Column(String(100), nullable=True)
|
||||
utm_medium = Column(String(100), nullable=True)
|
||||
utm_campaign = Column(String(100), nullable=True)
|
||||
|
||||
# Timestamp
|
||||
visited_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
|
||||
class VisitorDailyStats(Base):
|
||||
"""
|
||||
Aggregated daily statistics for faster queries
|
||||
Pre-computed by a scheduled task
|
||||
"""
|
||||
__tablename__ = "visitor_daily_stats"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stat_date = Column(String(10), nullable=False, unique=True, index=True) # YYYY-MM-DD
|
||||
|
||||
# Visitor counts
|
||||
total_visits = Column(Integer, default=0)
|
||||
unique_visitors = Column(Integer, default=0)
|
||||
|
||||
# Device breakdown (JSON string)
|
||||
device_breakdown = Column(Text) # {"mobile": 100, "desktop": 200, "tablet": 20}
|
||||
|
||||
# Browser breakdown (JSON string)
|
||||
browser_breakdown = Column(Text) # {"Chrome": 150, "Safari": 100, ...}
|
||||
|
||||
# Country breakdown (JSON string)
|
||||
country_breakdown = Column(Text) # {"MN": 200, "RU": 50, "KR": 30}
|
||||
|
||||
# Top pages (JSON string)
|
||||
top_pages = Column(Text) # [{"path": "/", "views": 500}, ...]
|
||||
|
||||
# Top referrers (JSON string)
|
||||
top_referrers = Column(Text) # [{"domain": "google.com", "visits": 100}, ...]
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class VisitorSession(Base):
|
||||
"""
|
||||
Track visitor sessions for better analytics
|
||||
"""
|
||||
__tablename__ = "visitor_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
session_id = Column(String(64), unique=True, nullable=False, index=True)
|
||||
visitor_hash = Column(String(64), nullable=False, index=True)
|
||||
user_id = Column(Integer, nullable=True)
|
||||
|
||||
# Session info
|
||||
first_page = Column(String(500))
|
||||
last_page = Column(String(500))
|
||||
page_count = Column(Integer, default=1)
|
||||
|
||||
# Device/geo info (copied from first visit)
|
||||
device_type = Column(String(20))
|
||||
browser = Column(String(50))
|
||||
country = Column(String(50))
|
||||
|
||||
# Timestamps
|
||||
started_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
last_activity_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
35
backend/app/models/withdrawal.py
Normal file
35
backend/app/models/withdrawal.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Float, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class WithdrawalRequest(Base):
|
||||
"""Track withdrawal requests from users"""
|
||||
__tablename__ = "withdrawal_requests"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
# Amount details
|
||||
amount = Column(Float, nullable=False) # Requested withdrawal amount
|
||||
tax_withheld = Column(Float, default=0) # Tax amount withheld (3.3%)
|
||||
net_amount = Column(Float, nullable=False) # Net amount after tax
|
||||
|
||||
# Bank info (snapshot at time of request)
|
||||
bank_name = Column(String(50), nullable=False)
|
||||
bank_account = Column(String(100), nullable=False)
|
||||
account_holder = Column(String(100), nullable=False)
|
||||
|
||||
# Status
|
||||
status = Column(String(20), default="pending") # pending, approved, completed, rejected
|
||||
|
||||
# Admin notes
|
||||
admin_note = Column(Text, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
requested_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
processed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", backref="withdrawal_requests")
|
||||
81
backend/app/schemas/__init__.py
Normal file
81
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from .car import (
|
||||
CarMakerCreate, CarMakerResponse,
|
||||
CarModelCreate, CarModelResponse,
|
||||
CarCreate, CarUpdate, CarResponse, CarListResponse,
|
||||
CarImageCreate, CarImageResponse,
|
||||
)
|
||||
from .user import UserCreate, UserUpdate, UserResponse, Token, CarViewResponse, PurchaseViewRequest
|
||||
from .inquiry import (
|
||||
InquiryCreate, InquiryResponse,
|
||||
InquiryMessageCreate, InquiryMessageResponse,
|
||||
InquiryWithMessages, InquiryListResponse,
|
||||
AdminInquiryRespond, AdminInquiryUpdateStatus,
|
||||
)
|
||||
from .hero_banner import (
|
||||
HeroBannerCreate, HeroBannerUpdate, HeroBannerResponse,
|
||||
HeroBannerListResponse, HeroBannerLocalizedResponse,
|
||||
HeroBannerSettingsUpdate, HeroBannerSettingsResponse,
|
||||
)
|
||||
from .translation import (
|
||||
TranslationCreate, TranslationUpdate, TranslationResponse,
|
||||
TranslationListResponse, TranslationBulkRequest, TranslationBulkResponse,
|
||||
)
|
||||
from .vehicle_request import (
|
||||
VehicleRequestCreate, VehicleRequestResponse,
|
||||
RequestVehicleCreate, RequestVehicleResponse, RequestVehicleApprove,
|
||||
PurchasedVehicleCreate, PurchasedVehicleResponse, PurchasedVehicleUpdateStatus,
|
||||
VehicleRequestWithVehicles,
|
||||
)
|
||||
from .dealer import (
|
||||
DealerApplicationCreate, DealerApplicationResponse,
|
||||
DealerApplicationApprove, DealerApplicationReject,
|
||||
DealerInfoResponse, DealerPublicInfo,
|
||||
)
|
||||
from .vehicle_share import (
|
||||
VehicleShareCreate, VehicleShareResponse, VehicleSharePublic,
|
||||
ShareRewardResponse, ShareRewardSummary,
|
||||
)
|
||||
from .withdrawal import (
|
||||
WithdrawalRequestCreate, WithdrawalRequestResponse,
|
||||
WithdrawalProcess, WithdrawalBalance,
|
||||
)
|
||||
from .referral import (
|
||||
ReferralRewardResponse, ReferralStats,
|
||||
ReferralSettingsResponse, ReferralSettingsUpdate,
|
||||
)
|
||||
from .notification import (
|
||||
NotificationCreate, NotificationResponse,
|
||||
NotificationListResponse, NotificationMarkRead,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CarMakerCreate", "CarMakerResponse",
|
||||
"CarModelCreate", "CarModelResponse",
|
||||
"CarCreate", "CarUpdate", "CarResponse", "CarListResponse",
|
||||
"CarImageCreate", "CarImageResponse",
|
||||
"UserCreate", "UserUpdate", "UserResponse", "Token", "CarViewResponse", "PurchaseViewRequest",
|
||||
"InquiryCreate", "InquiryResponse",
|
||||
"InquiryMessageCreate", "InquiryMessageResponse",
|
||||
"InquiryWithMessages", "InquiryListResponse",
|
||||
"AdminInquiryRespond", "AdminInquiryUpdateStatus",
|
||||
"HeroBannerCreate", "HeroBannerUpdate", "HeroBannerResponse",
|
||||
"HeroBannerListResponse", "HeroBannerLocalizedResponse",
|
||||
"HeroBannerSettingsUpdate", "HeroBannerSettingsResponse",
|
||||
"TranslationCreate", "TranslationUpdate", "TranslationResponse",
|
||||
"TranslationListResponse", "TranslationBulkRequest", "TranslationBulkResponse",
|
||||
"VehicleRequestCreate", "VehicleRequestResponse",
|
||||
"RequestVehicleCreate", "RequestVehicleResponse", "RequestVehicleApprove",
|
||||
"PurchasedVehicleCreate", "PurchasedVehicleResponse", "PurchasedVehicleUpdateStatus",
|
||||
"VehicleRequestWithVehicles",
|
||||
"DealerApplicationCreate", "DealerApplicationResponse",
|
||||
"DealerApplicationApprove", "DealerApplicationReject",
|
||||
"DealerInfoResponse", "DealerPublicInfo",
|
||||
"VehicleShareCreate", "VehicleShareResponse", "VehicleSharePublic",
|
||||
"ShareRewardResponse", "ShareRewardSummary",
|
||||
"WithdrawalRequestCreate", "WithdrawalRequestResponse",
|
||||
"WithdrawalProcess", "WithdrawalBalance",
|
||||
"ReferralRewardResponse", "ReferralStats",
|
||||
"ReferralSettingsResponse", "ReferralSettingsUpdate",
|
||||
"NotificationCreate", "NotificationResponse",
|
||||
"NotificationListResponse", "NotificationMarkRead",
|
||||
]
|
||||
185
backend/app/schemas/car.py
Normal file
185
backend/app/schemas/car.py
Normal file
@@ -0,0 +1,185 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Any
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
# CarSpecification Schema
|
||||
class CarSpecificationResponse(BaseModel):
|
||||
id: int
|
||||
car_id: int
|
||||
manufacturer: Optional[str] = None
|
||||
model_name: Optional[str] = None
|
||||
grade: Optional[str] = None
|
||||
model_year: Optional[str] = None
|
||||
displacement: Optional[int] = None
|
||||
fuel_type: Optional[str] = None
|
||||
transmission: Optional[str] = None
|
||||
drive_type: Optional[str] = None
|
||||
max_power: Optional[str] = None
|
||||
max_torque: Optional[str] = None
|
||||
fuel_efficiency: Optional[str] = None
|
||||
body_type: Optional[str] = None
|
||||
door_count: Optional[int] = None
|
||||
seating_capacity: Optional[int] = None
|
||||
length: Optional[int] = None
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
wheelbase: Optional[int] = None
|
||||
curb_weight: Optional[int] = None
|
||||
safety_options: Optional[List[str]] = None
|
||||
comfort_options: Optional[List[str]] = None
|
||||
exterior_options: Optional[List[str]] = None
|
||||
interior_options: Optional[List[str]] = None
|
||||
raw_data: Optional[Any] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# CarMaker Schemas
|
||||
class CarMakerCreate(BaseModel):
|
||||
code: str
|
||||
name: str
|
||||
name_en: Optional[str] = None
|
||||
|
||||
|
||||
class CarMakerResponse(BaseModel):
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
name_en: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# CarModel Schemas
|
||||
class CarModelCreate(BaseModel):
|
||||
code: str
|
||||
maker_id: int
|
||||
name: str
|
||||
name_en: Optional[str] = None
|
||||
|
||||
|
||||
class CarModelResponse(BaseModel):
|
||||
id: int
|
||||
code: str
|
||||
maker_id: int
|
||||
name: str
|
||||
name_en: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# CarImage Schemas
|
||||
class CarImageCreate(BaseModel):
|
||||
url: Optional[str] = None
|
||||
local_path: Optional[str] = None
|
||||
is_main: bool = False
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class CarImageResponse(BaseModel):
|
||||
id: int
|
||||
url: Optional[str] = None
|
||||
local_path: Optional[str] = None
|
||||
is_main: bool
|
||||
sort_order: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Car Schemas
|
||||
class CarCreate(BaseModel):
|
||||
source: str = "carmodoo"
|
||||
source_id: str
|
||||
source_key: Optional[str] = None
|
||||
maker_code: Optional[str] = None
|
||||
model_code: Optional[str] = None
|
||||
car_name: Optional[str] = None
|
||||
year: Optional[int] = None
|
||||
month: Optional[int] = None
|
||||
mileage: Optional[int] = None
|
||||
price_krw: Optional[int] = None
|
||||
price_usd: Optional[Decimal] = None
|
||||
fuel: Optional[str] = None
|
||||
transmission: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
displacement: Optional[int] = None
|
||||
car_number: Optional[str] = None
|
||||
seize_count: int = 0
|
||||
collateral_count: int = 0
|
||||
check_num: Optional[str] = None
|
||||
dealer_name: Optional[str] = None
|
||||
dealer_phone: Optional[str] = None
|
||||
shop_name: Optional[str] = None
|
||||
memo: Optional[str] = None
|
||||
images: List[CarImageCreate] = []
|
||||
options: List[str] = []
|
||||
|
||||
|
||||
class CarUpdate(BaseModel):
|
||||
car_name: Optional[str] = None
|
||||
year: Optional[int] = None
|
||||
month: Optional[int] = None
|
||||
mileage: Optional[int] = None
|
||||
price_krw: Optional[int] = None
|
||||
margin_krw: Optional[int] = None
|
||||
margin_mn: Optional[int] = None
|
||||
price_usd: Optional[Decimal] = None
|
||||
fuel: Optional[str] = None
|
||||
transmission: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
is_displayed: Optional[bool] = None
|
||||
|
||||
|
||||
class CarResponse(BaseModel):
|
||||
id: int
|
||||
source: str
|
||||
source_id: str
|
||||
car_name: Optional[str] = None
|
||||
year: Optional[int] = None
|
||||
month: Optional[int] = None
|
||||
mileage: Optional[int] = None
|
||||
price_krw: Optional[int] = None
|
||||
margin_krw: Optional[int] = 0
|
||||
margin_mn: Optional[int] = 0
|
||||
final_price_krw: Optional[int] = None # Computed: price_krw + margin_krw (for Korean users)
|
||||
final_price_mn: Optional[int] = None # Computed: price_krw + margin_mn (for Mongolian users)
|
||||
price_usd: Optional[Decimal] = None
|
||||
is_displayed: bool = False
|
||||
fuel: Optional[str] = None
|
||||
transmission: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
displacement: Optional[int] = None
|
||||
car_number: Optional[str] = None
|
||||
seize_count: int
|
||||
collateral_count: int
|
||||
check_num: Optional[str] = None
|
||||
dealer_name: Optional[str] = None
|
||||
dealer_description: Optional[str] = None
|
||||
dealer_description_en: Optional[str] = None
|
||||
dealer_description_mn: Optional[str] = None
|
||||
dealer_description_ru: Optional[str] = None
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
maker: Optional[CarMakerResponse] = None
|
||||
model: Optional[CarModelResponse] = None
|
||||
images: List[CarImageResponse] = []
|
||||
specification: Optional[CarSpecificationResponse] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CarListResponse(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
cars: List[CarResponse]
|
||||
80
backend/app/schemas/dealer.py
Normal file
80
backend/app/schemas/dealer.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class DealerApplicationCreate(BaseModel):
|
||||
"""Schema for creating a dealer application"""
|
||||
business_name: str
|
||||
business_number: Optional[str] = None
|
||||
real_name: str
|
||||
id_number: Optional[str] = None # Will be encrypted before storage
|
||||
phone: str
|
||||
bank_name: str
|
||||
bank_account: str
|
||||
account_holder: str
|
||||
photo_url: Optional[str] = None
|
||||
|
||||
|
||||
class DealerApplicationResponse(BaseModel):
|
||||
"""Schema for dealer application response"""
|
||||
id: int
|
||||
user_id: int
|
||||
business_name: str
|
||||
business_number: Optional[str] = None
|
||||
real_name: str
|
||||
phone: str
|
||||
bank_name: str
|
||||
bank_account: str
|
||||
account_holder: str
|
||||
photo_url: Optional[str] = None
|
||||
status: str
|
||||
rejected_reason: Optional[str] = None
|
||||
applied_at: datetime
|
||||
approved_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DealerApplicationApprove(BaseModel):
|
||||
"""Schema for approving a dealer application"""
|
||||
pass # No additional fields needed
|
||||
|
||||
|
||||
class DealerApplicationReject(BaseModel):
|
||||
"""Schema for rejecting a dealer application"""
|
||||
reason: str
|
||||
|
||||
|
||||
class DealerInfoResponse(BaseModel):
|
||||
"""Schema for dealer info response"""
|
||||
id: int
|
||||
user_id: int
|
||||
dealer_code: str
|
||||
dealer_card_url: Optional[str] = None
|
||||
business_name: str
|
||||
real_name: str
|
||||
phone: str
|
||||
photo_url: Optional[str] = None
|
||||
total_commission_earned: float
|
||||
total_withdrawn: float
|
||||
pending_withdrawal: float
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DealerPublicInfo(BaseModel):
|
||||
"""Public dealer info for displaying in lists"""
|
||||
id: int
|
||||
dealer_code: str
|
||||
business_name: str
|
||||
real_name: str
|
||||
photo_url: Optional[str] = None
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
101
backend/app/schemas/hero_banner.py
Normal file
101
backend/app/schemas/hero_banner.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from pydantic import BaseModel, HttpUrl
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ==================== Hero Banner Settings ====================
|
||||
|
||||
class HeroBannerSettingsBase(BaseModel):
|
||||
slide_interval: int = 3000
|
||||
animation_type: str = "film-strip"
|
||||
image_width: int = 500
|
||||
image_height: int = 300
|
||||
auto_play: bool = True
|
||||
|
||||
|
||||
class HeroBannerSettingsUpdate(BaseModel):
|
||||
slide_interval: Optional[int] = None
|
||||
animation_type: Optional[str] = None
|
||||
image_width: Optional[int] = None
|
||||
image_height: Optional[int] = None
|
||||
auto_play: Optional[bool] = None
|
||||
|
||||
|
||||
class HeroBannerSettingsResponse(HeroBannerSettingsBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ==================== Hero Banner ====================
|
||||
|
||||
class HeroBannerBase(BaseModel):
|
||||
title_ko: Optional[str] = None
|
||||
title_en: Optional[str] = None
|
||||
title_mn: Optional[str] = None
|
||||
subtitle_ko: Optional[str] = None
|
||||
subtitle_en: Optional[str] = None
|
||||
subtitle_mn: Optional[str] = None
|
||||
image_url: str
|
||||
link_url: Optional[str] = None
|
||||
car_id: Optional[int] = None
|
||||
is_active: bool = True
|
||||
display_order: int = 0
|
||||
|
||||
|
||||
class HeroBannerCreate(HeroBannerBase):
|
||||
pass
|
||||
|
||||
|
||||
class HeroBannerUpdate(BaseModel):
|
||||
title_ko: Optional[str] = None
|
||||
title_en: Optional[str] = None
|
||||
title_mn: Optional[str] = None
|
||||
subtitle_ko: Optional[str] = None
|
||||
subtitle_en: Optional[str] = None
|
||||
subtitle_mn: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
link_url: Optional[str] = None
|
||||
car_id: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
display_order: Optional[int] = None
|
||||
|
||||
|
||||
class HeroBannerResponse(HeroBannerBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class HeroBannerListResponse(BaseModel):
|
||||
id: int
|
||||
title_ko: Optional[str] = None
|
||||
title_en: Optional[str] = None
|
||||
image_url: str
|
||||
link_url: Optional[str] = None
|
||||
car_id: Optional[int] = None
|
||||
is_active: bool
|
||||
display_order: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# 다국어 지원 응답 (Public API용)
|
||||
class HeroBannerLocalizedResponse(BaseModel):
|
||||
id: int
|
||||
title: Optional[str] = None
|
||||
subtitle: Optional[str] = None
|
||||
image_url: str
|
||||
link_url: Optional[str] = None
|
||||
car_id: Optional[int] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
67
backend/app/schemas/inquiry.py
Normal file
67
backend/app/schemas/inquiry.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class InquiryCreate(BaseModel):
|
||||
category: str = "general"
|
||||
subject: Optional[str] = None
|
||||
message: str
|
||||
contact_email: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
car_id: Optional[int] = None # For backward compatibility
|
||||
|
||||
|
||||
class InquiryResponse(BaseModel):
|
||||
id: int
|
||||
user_id: Optional[int] = None
|
||||
car_id: Optional[int] = None
|
||||
category: Optional[str] = "general"
|
||||
subject: Optional[str] = None
|
||||
message: str
|
||||
contact_email: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
status: str
|
||||
admin_response: Optional[str] = None
|
||||
responded_at: Optional[datetime] = None
|
||||
responded_by: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class InquiryMessageCreate(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class InquiryMessageResponse(BaseModel):
|
||||
id: int
|
||||
inquiry_id: int
|
||||
user_id: int
|
||||
message: str
|
||||
is_admin: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class InquiryWithMessages(BaseModel):
|
||||
inquiry: InquiryResponse
|
||||
messages: List[InquiryMessageResponse]
|
||||
|
||||
|
||||
class InquiryListResponse(BaseModel):
|
||||
inquiries: List[InquiryResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class AdminInquiryRespond(BaseModel):
|
||||
message: str
|
||||
status: Optional[str] = None # Can update status with response
|
||||
|
||||
|
||||
class AdminInquiryUpdateStatus(BaseModel):
|
||||
status: str
|
||||
44
backend/app/schemas/notification.py
Normal file
44
backend/app/schemas/notification.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class NotificationCreate(BaseModel):
|
||||
"""Create notification schema"""
|
||||
user_id: int
|
||||
notification_type: str
|
||||
title: str
|
||||
message: str
|
||||
link: Optional[str] = None
|
||||
related_id: Optional[int] = None
|
||||
related_type: Optional[str] = None
|
||||
|
||||
|
||||
class NotificationResponse(BaseModel):
|
||||
"""Notification response schema"""
|
||||
id: int
|
||||
user_id: int
|
||||
notification_type: str
|
||||
title: str
|
||||
message: str
|
||||
link: Optional[str] = None
|
||||
related_id: Optional[int] = None
|
||||
related_type: Optional[str] = None
|
||||
is_read: bool
|
||||
read_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NotificationListResponse(BaseModel):
|
||||
"""Notification list with unread count"""
|
||||
notifications: List[NotificationResponse]
|
||||
unread_count: int
|
||||
total: int
|
||||
|
||||
|
||||
class NotificationMarkRead(BaseModel):
|
||||
"""Mark notifications as read"""
|
||||
notification_ids: List[int]
|
||||
41
backend/app/schemas/referral.py
Normal file
41
backend/app/schemas/referral.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ReferralRewardResponse(BaseModel):
|
||||
"""레퍼럴 보상 응답 스키마"""
|
||||
id: int
|
||||
referrer_id: int
|
||||
referred_user_id: int
|
||||
payment_amount: float
|
||||
reward_amount: float
|
||||
status: str
|
||||
created_at: datetime
|
||||
credited_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ReferralStats(BaseModel):
|
||||
"""레퍼럴 통계 스키마"""
|
||||
total_referrals: int # 총 추천한 회원 수
|
||||
total_rewards_earned: float # 총 보상 금액
|
||||
total_rewards_credited: float # 적립된 보상 금액
|
||||
total_rewards_pending: float # 대기 중인 보상 금액
|
||||
available_for_withdrawal: float # 출금 가능 금액
|
||||
|
||||
|
||||
class ReferralSettingsResponse(BaseModel):
|
||||
"""레퍼럴 설정 응답 스키마"""
|
||||
referral_reward_enabled: bool
|
||||
referral_reward_percent: float
|
||||
referral_reward_type: str # one_time / recurring
|
||||
|
||||
|
||||
class ReferralSettingsUpdate(BaseModel):
|
||||
"""레퍼럴 설정 업데이트 스키마"""
|
||||
referral_reward_enabled: Optional[bool] = None
|
||||
referral_reward_percent: Optional[float] = None
|
||||
referral_reward_type: Optional[str] = None
|
||||
37
backend/app/schemas/settings.py
Normal file
37
backend/app/schemas/settings.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class SystemSettingsUpdate(BaseModel):
|
||||
"""시스템 설정 수정용 스키마"""
|
||||
search_page_size: Optional[int] = None
|
||||
korea_margin_percent: Optional[float] = None
|
||||
mongolia_margin_percent: Optional[float] = None
|
||||
cc_per_usdc: Optional[int] = None
|
||||
cc_per_view: Optional[int] = None
|
||||
cc_signup_bonus: Optional[int] = None
|
||||
cars_per_cc: Optional[int] = None
|
||||
cache_ttl_hours: Optional[int] = None
|
||||
container_logistics_usd: Optional[int] = None
|
||||
shoring_cost_usd: Optional[int] = None
|
||||
|
||||
|
||||
class SystemSettingsResponse(BaseModel):
|
||||
"""시스템 설정 응답 스키마"""
|
||||
id: int
|
||||
search_page_size: int
|
||||
korea_margin_percent: float
|
||||
mongolia_margin_percent: float
|
||||
cc_per_usdc: int
|
||||
cc_per_view: int
|
||||
cc_signup_bonus: int
|
||||
cars_per_cc: int
|
||||
cache_ttl_hours: int
|
||||
container_logistics_usd: int
|
||||
shoring_cost_usd: int
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
52
backend/app/schemas/translation.py
Normal file
52
backend/app/schemas/translation.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TranslationCreate(BaseModel):
|
||||
source_text: str
|
||||
category: str
|
||||
text_en: Optional[str] = None
|
||||
text_mn: Optional[str] = None
|
||||
text_ru: Optional[str] = None
|
||||
|
||||
|
||||
class TranslationUpdate(BaseModel):
|
||||
source_text: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
text_en: Optional[str] = None
|
||||
text_mn: Optional[str] = None
|
||||
text_ru: Optional[str] = None
|
||||
|
||||
|
||||
class TranslationResponse(BaseModel):
|
||||
id: int
|
||||
source_text: str
|
||||
category: str
|
||||
text_en: Optional[str] = None
|
||||
text_mn: Optional[str] = None
|
||||
text_ru: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TranslationListResponse(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
translations: List[TranslationResponse]
|
||||
|
||||
|
||||
class TranslationBulkRequest(BaseModel):
|
||||
"""Bulk translation lookup request"""
|
||||
texts: List[str]
|
||||
category: Optional[str] = None
|
||||
lang: str = "en"
|
||||
|
||||
|
||||
class TranslationBulkResponse(BaseModel):
|
||||
"""Returns a dictionary mapping source text to translated text"""
|
||||
translations: dict # {source_text: translated_text}
|
||||
62
backend/app/schemas/user.py
Normal file
62
backend/app/schemas/user.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
name: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
country: str = "Mongolia"
|
||||
referred_by: Optional[str] = None # Referral code of the user who referred
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Schema for updating user profile"""
|
||||
name: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
name: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
country: str
|
||||
is_active: bool
|
||||
is_admin: bool = False
|
||||
is_dealer: bool = False
|
||||
cc_balance: float = 0.0 # Float to support fractional CC (e.g., 0.1 CC)
|
||||
referral_code: Optional[str] = None # User's unique referral code
|
||||
email_verified: bool = False
|
||||
phone_verified: bool = False
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CarViewResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
car_id: int
|
||||
cc_paid: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PurchaseViewRequest(BaseModel):
|
||||
car_id: int
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
email: Optional[str] = None
|
||||
122
backend/app/schemas/vehicle_request.py
Normal file
122
backend/app/schemas/vehicle_request.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# Vehicle Request Schemas
|
||||
class VehicleRequestCreate(BaseModel):
|
||||
maker_code: str
|
||||
maker_name: Optional[str] = None
|
||||
model_code: str
|
||||
model_name: Optional[str] = None
|
||||
grade_code: Optional[str] = None
|
||||
grade_name: Optional[str] = None
|
||||
year_from: Optional[int] = None
|
||||
year_to: Optional[int] = None
|
||||
mileage_min: Optional[int] = None
|
||||
mileage_max: Optional[int] = None
|
||||
fuel: Optional[str] = None
|
||||
displacement_min: Optional[int] = None
|
||||
displacement_max: Optional[int] = None
|
||||
|
||||
|
||||
class VehicleRequestResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
maker_code: Optional[str]
|
||||
maker_name: Optional[str]
|
||||
model_code: Optional[str]
|
||||
model_name: Optional[str]
|
||||
grade_code: Optional[str]
|
||||
grade_name: Optional[str]
|
||||
year_from: Optional[int]
|
||||
year_to: Optional[int]
|
||||
mileage_min: Optional[int]
|
||||
mileage_max: Optional[int]
|
||||
fuel: Optional[str]
|
||||
displacement_min: Optional[int]
|
||||
displacement_max: Optional[int]
|
||||
status: str
|
||||
admin_reviewed_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Request Vehicle (Admin recommended) Schemas
|
||||
class RequestVehicleCreate(BaseModel):
|
||||
request_id: int
|
||||
car_data: dict
|
||||
is_approved: bool = False
|
||||
|
||||
|
||||
class RequestVehicleResponse(BaseModel):
|
||||
id: int
|
||||
request_id: int
|
||||
car_data: dict
|
||||
is_approved: bool
|
||||
approved_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RequestVehicleApprove(BaseModel):
|
||||
vehicle_ids: List[int]
|
||||
|
||||
|
||||
# Purchased Vehicle Schemas
|
||||
class PurchasedVehicleCreate(BaseModel):
|
||||
car_name: str
|
||||
car_data: Optional[dict] = None
|
||||
car_image: Optional[str] = None
|
||||
vehicle_price_krw: int
|
||||
domestic_cost_krw: int
|
||||
shipping_cost_usd: int
|
||||
total_cost_krw: int
|
||||
car_type: str # small, compact
|
||||
selected_dealer_id: Optional[int] = None # Selected dealer for commission split
|
||||
|
||||
|
||||
class PurchasedVehicleResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
car_name: Optional[str]
|
||||
car_data: Optional[dict]
|
||||
car_image: Optional[str]
|
||||
vehicle_price_krw: Optional[int]
|
||||
domestic_cost_krw: Optional[int]
|
||||
shipping_cost_usd: Optional[int]
|
||||
total_cost_krw: Optional[int]
|
||||
car_type: Optional[str]
|
||||
selected_dealer_id: Optional[int] = None
|
||||
dealer_commission_krw: Optional[int] = 0
|
||||
platform_commission_krw: Optional[int] = 0
|
||||
commission_paid: bool = False
|
||||
commission_paid_at: Optional[datetime] = None
|
||||
shipping_status: int
|
||||
status_updated_at: Optional[datetime]
|
||||
current_location: Optional[str]
|
||||
estimated_arrival: Optional[datetime]
|
||||
purchased_at: datetime
|
||||
delivered_at: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PurchasedVehicleUpdateStatus(BaseModel):
|
||||
shipping_status: int # 1-7: 구매완료, 인천항, 텐진항, 자먼우드, 울란바토르, 통관, 배송완료
|
||||
current_location: Optional[str] = None
|
||||
estimated_arrival: Optional[datetime] = None
|
||||
|
||||
|
||||
# List response with request and approved vehicles
|
||||
class VehicleRequestWithVehicles(BaseModel):
|
||||
request: VehicleRequestResponse
|
||||
approved_vehicles: List[RequestVehicleResponse]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
69
backend/app/schemas/vehicle_share.py
Normal file
69
backend/app/schemas/vehicle_share.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class VehicleShareCreate(BaseModel):
|
||||
"""Schema for creating a vehicle share"""
|
||||
request_vehicle_id: int
|
||||
markup_amount_krw: float = 0
|
||||
|
||||
|
||||
class VehicleShareResponse(BaseModel):
|
||||
"""Schema for vehicle share response"""
|
||||
id: int
|
||||
user_id: int
|
||||
request_vehicle_id: int
|
||||
share_code: str
|
||||
original_price_krw: float
|
||||
markup_amount_krw: float
|
||||
shared_price_krw: float
|
||||
view_count: int
|
||||
is_purchased: bool
|
||||
purchased_by_user_id: Optional[int] = None
|
||||
created_at: datetime
|
||||
expires_at: Optional[datetime] = None
|
||||
purchased_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class VehicleSharePublic(BaseModel):
|
||||
"""Public schema for shared vehicle (for viewing shared link)"""
|
||||
id: int
|
||||
share_code: str
|
||||
shared_price_krw: float
|
||||
view_count: int
|
||||
is_purchased: bool
|
||||
created_at: datetime
|
||||
# Vehicle info will be added separately
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ShareRewardResponse(BaseModel):
|
||||
"""Schema for share reward response"""
|
||||
id: int
|
||||
user_id: int
|
||||
vehicle_share_id: int
|
||||
markup_amount: float
|
||||
reward_amount: float
|
||||
tax_amount: float
|
||||
net_amount: float
|
||||
status: str
|
||||
withdrawn_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ShareRewardSummary(BaseModel):
|
||||
"""Summary of user's share rewards"""
|
||||
total_rewards: float
|
||||
total_withdrawn: float
|
||||
pending_amount: float
|
||||
available_for_withdrawal: float
|
||||
reward_count: int
|
||||
44
backend/app/schemas/withdrawal.py
Normal file
44
backend/app/schemas/withdrawal.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class WithdrawalRequestCreate(BaseModel):
|
||||
"""Schema for creating a withdrawal request"""
|
||||
amount: float
|
||||
bank_name: str
|
||||
bank_account: str
|
||||
account_holder: str
|
||||
|
||||
|
||||
class WithdrawalRequestResponse(BaseModel):
|
||||
"""Schema for withdrawal request response"""
|
||||
id: int
|
||||
user_id: int
|
||||
amount: float
|
||||
tax_withheld: float
|
||||
net_amount: float
|
||||
bank_name: str
|
||||
bank_account: str
|
||||
account_holder: str
|
||||
status: str
|
||||
admin_note: Optional[str] = None
|
||||
requested_at: datetime
|
||||
processed_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class WithdrawalProcess(BaseModel):
|
||||
"""Schema for processing a withdrawal (admin)"""
|
||||
status: str # approved, completed, rejected
|
||||
admin_note: Optional[str] = None
|
||||
|
||||
|
||||
class WithdrawalBalance(BaseModel):
|
||||
"""Schema for user's withdrawal balance"""
|
||||
total_earned: float # Total earnings (dealer commission + share rewards)
|
||||
total_withdrawn: float # Total already withdrawn
|
||||
pending_withdrawal: float # Currently pending withdrawal requests
|
||||
available_balance: float # Available for withdrawal
|
||||
310
backend/app/services/cache_service.py
Normal file
310
backend/app/services/cache_service.py
Normal file
@@ -0,0 +1,310 @@
|
||||
"""
|
||||
캐시 서비스 - 카모두 검색 결과 캐싱 및 필터링
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional, Dict, Any, Tuple, TYPE_CHECKING
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
|
||||
from ..models.cache import CarCache, CarDetailCache, CacheRequestQueue
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..api.carmodoo import CarmodooClient
|
||||
|
||||
# 캐시 TTL 설정 (시간 단위)
|
||||
CACHE_TTL_HOURS = 2
|
||||
|
||||
# 요청 큐 락
|
||||
_request_lock = asyncio.Lock()
|
||||
_pending_requests: Dict[str, asyncio.Event] = {}
|
||||
|
||||
|
||||
class CacheService:
|
||||
def __init__(self, db: Session, carmodoo_client: "CarmodooClient" = None):
|
||||
self.db = db
|
||||
self.carmodoo_client = carmodoo_client
|
||||
|
||||
def get_cache_key(self, maker_code: str, model_code: str) -> str:
|
||||
"""캐시 키 생성"""
|
||||
return f"{maker_code}_{model_code}"
|
||||
|
||||
def get_cache(self, cache_key: str) -> Optional[CarCache]:
|
||||
"""캐시 조회 (만료 확인)"""
|
||||
cache = self.db.query(CarCache).filter(
|
||||
CarCache.cache_key == cache_key
|
||||
).first()
|
||||
|
||||
if cache:
|
||||
# 만료 확인
|
||||
if cache.expires_at < datetime.utcnow():
|
||||
# 만료된 캐시 삭제
|
||||
self.db.delete(cache)
|
||||
self.db.commit()
|
||||
return None
|
||||
return cache
|
||||
return None
|
||||
|
||||
def save_cache(
|
||||
self,
|
||||
cache_key: str,
|
||||
maker_code: str,
|
||||
maker_name: str,
|
||||
model_code: str,
|
||||
model_name: str,
|
||||
cars: List[Dict[str, Any]]
|
||||
) -> CarCache:
|
||||
"""캐시 저장"""
|
||||
expires_at = datetime.utcnow() + timedelta(hours=CACHE_TTL_HOURS)
|
||||
|
||||
# 기존 캐시 삭제
|
||||
existing = self.db.query(CarCache).filter(
|
||||
CarCache.cache_key == cache_key
|
||||
).first()
|
||||
if existing:
|
||||
self.db.delete(existing)
|
||||
self.db.commit()
|
||||
|
||||
# 새 캐시 저장
|
||||
cache = CarCache(
|
||||
cache_key=cache_key,
|
||||
maker_code=maker_code,
|
||||
maker_name=maker_name,
|
||||
model_code=model_code,
|
||||
model_name=model_name,
|
||||
total_count=len(cars),
|
||||
cars_data=json.dumps(cars, ensure_ascii=False),
|
||||
expires_at=expires_at
|
||||
)
|
||||
self.db.add(cache)
|
||||
self.db.commit()
|
||||
self.db.refresh(cache)
|
||||
return cache
|
||||
|
||||
def get_cars_from_cache(self, cache: CarCache) -> List[Dict[str, Any]]:
|
||||
"""캐시에서 차량 목록 가져오기"""
|
||||
return json.loads(cache.cars_data)
|
||||
|
||||
def filter_cars(
|
||||
self,
|
||||
cars: List[Dict[str, Any]],
|
||||
year_min: Optional[int] = None,
|
||||
year_max: Optional[int] = None,
|
||||
mileage_min: Optional[int] = None,
|
||||
mileage_max: Optional[int] = None,
|
||||
price_min: Optional[int] = None,
|
||||
price_max: Optional[int] = None,
|
||||
fuel: Optional[str] = None,
|
||||
transmission: Optional[str] = None,
|
||||
displacement_min: Optional[int] = None,
|
||||
displacement_max: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""캐시된 데이터에서 필터링"""
|
||||
filtered = cars
|
||||
|
||||
if year_min:
|
||||
filtered = [c for c in filtered if c.get('year') and c['year'] >= year_min]
|
||||
if year_max:
|
||||
filtered = [c for c in filtered if c.get('year') and c['year'] <= year_max]
|
||||
if mileage_min:
|
||||
filtered = [c for c in filtered if c.get('mileage') and c['mileage'] >= mileage_min]
|
||||
if mileage_max:
|
||||
filtered = [c for c in filtered if c.get('mileage') and c['mileage'] <= mileage_max]
|
||||
if price_min:
|
||||
# 'price' 또는 'original_price' 키 둘 다 체크 (카모두 파싱 결과는 'price', 변환 후에는 'original_price')
|
||||
filtered = [c for c in filtered if (c.get('price') or c.get('original_price')) and (c.get('price') or c.get('original_price', 0)) >= price_min]
|
||||
if price_max:
|
||||
filtered = [c for c in filtered if (c.get('price') or c.get('original_price')) and (c.get('price') or c.get('original_price', 0)) <= price_max]
|
||||
if fuel:
|
||||
# 연료 타입 매핑 (프론트엔드 값 -> 카모두 값)
|
||||
fuel_map = {
|
||||
'가솔린': ['휘발유', '가솔린'],
|
||||
'디젤': ['경유', '디젤'],
|
||||
'LPG': ['LPG'],
|
||||
'하이브리드': ['하이브리드'],
|
||||
'전기': ['전기'],
|
||||
'휘발유': ['휘발유', '가솔린'],
|
||||
'경유': ['경유', '디젤'],
|
||||
}
|
||||
allowed_fuels = fuel_map.get(fuel, [fuel])
|
||||
filtered = [c for c in filtered if c.get('fuel') in allowed_fuels]
|
||||
if transmission:
|
||||
# 변속기 타입 매핑
|
||||
trans_map = {
|
||||
'자동': ['오토', '자동'],
|
||||
'수동': ['수동'],
|
||||
'세미오토': ['세미오토'],
|
||||
'CVT': ['CVT'],
|
||||
}
|
||||
allowed_trans = trans_map.get(transmission, [transmission])
|
||||
filtered = [c for c in filtered if c.get('transmission') in allowed_trans]
|
||||
if displacement_min:
|
||||
filtered = [c for c in filtered if c.get('displacement') and c['displacement'] >= displacement_min]
|
||||
if displacement_max:
|
||||
filtered = [c for c in filtered if c.get('displacement') and c['displacement'] <= displacement_max]
|
||||
|
||||
return filtered
|
||||
|
||||
def paginate_cars(
|
||||
self,
|
||||
cars: List[Dict[str, Any]],
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""페이징 처리"""
|
||||
total = len(cars)
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
return cars[start:end], total
|
||||
|
||||
async def fetch_all_cars_for_cache(
|
||||
self,
|
||||
maker_code: str,
|
||||
model_code: str,
|
||||
maker_name: str = "",
|
||||
model_name: str = ""
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""캐시용 전체 데이터 수집 (연도별 분할 검색)
|
||||
|
||||
카모두 API는 페이징이 제대로 동작하지 않아 한 번에 최대 50대만 반환합니다.
|
||||
연도별로 나누어 검색하여 더 많은 차량을 수집합니다.
|
||||
"""
|
||||
if not self.carmodoo_client:
|
||||
return []
|
||||
|
||||
try:
|
||||
# 연도별 분할 검색 사용 (최근 15년간)
|
||||
all_cars = await self.carmodoo_client.search_cars_by_year_segment(
|
||||
maker_code=maker_code,
|
||||
model_code=model_code,
|
||||
year_start=2010, # 2010년부터
|
||||
year_end=None # 현재 연도까지
|
||||
)
|
||||
return all_cars
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching cars for cache: {e}")
|
||||
return []
|
||||
|
||||
async def get_or_fetch_cache(
|
||||
self,
|
||||
maker_code: str,
|
||||
model_code: str,
|
||||
maker_name: str = "",
|
||||
model_name: str = ""
|
||||
) -> Optional[CarCache]:
|
||||
"""캐시 조회 또는 새로 가져오기 (요청 병합 포함)"""
|
||||
cache_key = self.get_cache_key(maker_code, model_code)
|
||||
|
||||
# 1. 캐시 확인
|
||||
cache = self.get_cache(cache_key)
|
||||
if cache:
|
||||
return cache
|
||||
|
||||
# 2. 요청 락으로 동시 요청 병합
|
||||
async with _request_lock:
|
||||
# 다른 요청이 이미 처리 중인지 확인
|
||||
if cache_key in _pending_requests:
|
||||
event = _pending_requests[cache_key]
|
||||
else:
|
||||
# 새 이벤트 생성
|
||||
event = asyncio.Event()
|
||||
_pending_requests[cache_key] = event
|
||||
|
||||
# 백그라운드에서 데이터 가져오기
|
||||
asyncio.create_task(
|
||||
self._fetch_and_cache(cache_key, maker_code, model_code, maker_name, model_name, event)
|
||||
)
|
||||
|
||||
# 3. 완료 대기
|
||||
await event.wait()
|
||||
|
||||
# 4. 캐시 반환
|
||||
return self.get_cache(cache_key)
|
||||
|
||||
async def _fetch_and_cache(
|
||||
self,
|
||||
cache_key: str,
|
||||
maker_code: str,
|
||||
model_code: str,
|
||||
maker_name: str,
|
||||
model_name: str,
|
||||
event: asyncio.Event
|
||||
):
|
||||
"""데이터 가져와서 캐시에 저장"""
|
||||
try:
|
||||
cars = await self.fetch_all_cars_for_cache(
|
||||
maker_code, model_code, maker_name, model_name
|
||||
)
|
||||
|
||||
if cars:
|
||||
self.save_cache(
|
||||
cache_key=cache_key,
|
||||
maker_code=maker_code,
|
||||
maker_name=maker_name,
|
||||
model_code=model_code,
|
||||
model_name=model_name,
|
||||
cars=cars
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error caching {cache_key}: {e}")
|
||||
finally:
|
||||
# 완료 시그널
|
||||
event.set()
|
||||
# 대기열에서 제거
|
||||
if cache_key in _pending_requests:
|
||||
del _pending_requests[cache_key]
|
||||
|
||||
def cleanup_expired_cache(self):
|
||||
"""만료된 캐시 정리"""
|
||||
expired = self.db.query(CarCache).filter(
|
||||
CarCache.expires_at < datetime.utcnow()
|
||||
).all()
|
||||
|
||||
for cache in expired:
|
||||
self.db.delete(cache)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return len(expired)
|
||||
|
||||
# 상세 정보 캐시 관련
|
||||
def get_detail_cache(self, car_id: str) -> Optional[CarDetailCache]:
|
||||
"""상세 정보 캐시 조회"""
|
||||
cache = self.db.query(CarDetailCache).filter(
|
||||
CarDetailCache.car_id == car_id
|
||||
).first()
|
||||
|
||||
if cache:
|
||||
if cache.expires_at < datetime.utcnow():
|
||||
self.db.delete(cache)
|
||||
self.db.commit()
|
||||
return None
|
||||
return cache
|
||||
return None
|
||||
|
||||
def save_detail_cache(self, car_id: str, detail_data: Dict[str, Any]) -> CarDetailCache:
|
||||
"""상세 정보 캐시 저장"""
|
||||
expires_at = datetime.utcnow() + timedelta(hours=CACHE_TTL_HOURS)
|
||||
|
||||
existing = self.db.query(CarDetailCache).filter(
|
||||
CarDetailCache.car_id == car_id
|
||||
).first()
|
||||
if existing:
|
||||
self.db.delete(existing)
|
||||
self.db.commit()
|
||||
|
||||
cache = CarDetailCache(
|
||||
car_id=car_id,
|
||||
detail_data=json.dumps(detail_data, ensure_ascii=False),
|
||||
expires_at=expires_at
|
||||
)
|
||||
self.db.add(cache)
|
||||
self.db.commit()
|
||||
self.db.refresh(cache)
|
||||
return cache
|
||||
|
||||
def get_detail_from_cache(self, cache: CarDetailCache) -> Dict[str, Any]:
|
||||
"""상세 정보 캐시에서 데이터 가져오기"""
|
||||
return json.loads(cache.detail_data)
|
||||
305
backend/app/services/exchange_rate_service.py
Normal file
305
backend/app/services/exchange_rate_service.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
Exchange Rate Service - 한국수출입은행 API 연동
|
||||
API 문서: https://www.koreaexim.go.kr/ir/HPHKIR020M01?apino=2&viewtype=C
|
||||
"""
|
||||
import httpx
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, List
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models.exchange_rate import ExchangeRate, ExchangeRateHistory
|
||||
|
||||
|
||||
# 한국수출입은행 API 설정
|
||||
KOREAEXIM_API_URL = "https://oapi.koreaexim.go.kr/site/program/financial/exchangeJSON"
|
||||
KOREAEXIM_API_KEY = os.getenv("KOREAEXIM_API_KEY", "rOzKaATDEinF9luHla1wVTosjWribjKL")
|
||||
|
||||
# 지원 통화 목록
|
||||
SUPPORTED_CURRENCIES = {
|
||||
"USD": {"name_ko": "미국 달러", "name_en": "US Dollar", "symbol": "$"},
|
||||
"MNT": {"name_ko": "몽골 투그릭", "name_en": "Mongolian Tugrik", "symbol": "₮"},
|
||||
"RUB": {"name_ko": "러시아 루블", "name_en": "Russian Ruble", "symbol": "₽"},
|
||||
"CNY": {"name_ko": "중국 위안", "name_en": "Chinese Yuan", "symbol": "¥"},
|
||||
"JPY": {"name_ko": "일본 엔", "name_en": "Japanese Yen", "symbol": "¥"},
|
||||
"EUR": {"name_ko": "유로", "name_en": "Euro", "symbol": "€"},
|
||||
}
|
||||
|
||||
# 기본 환율 (API 실패 시 사용, 2024년 12월 기준)
|
||||
DEFAULT_RATES = {
|
||||
"USD": 1450.0,
|
||||
"MNT": 0.42, # 1 MNT = 0.42 KRW
|
||||
"RUB": 14.0,
|
||||
"CNY": 198.0,
|
||||
"JPY": 9.5, # 100엔 기준이면 950
|
||||
"EUR": 1510.0,
|
||||
}
|
||||
|
||||
|
||||
async def fetch_rates_from_koreaexim(search_date: Optional[str] = None) -> Optional[List[Dict]]:
|
||||
"""
|
||||
한국수출입은행 API에서 환율 정보 조회
|
||||
|
||||
Args:
|
||||
search_date: 조회일자 (YYYYMMDD 형식), 없으면 오늘
|
||||
|
||||
Returns:
|
||||
환율 데이터 리스트 또는 None
|
||||
"""
|
||||
if not KOREAEXIM_API_KEY:
|
||||
print("Warning: KOREAEXIM_API_KEY not set, using fallback rates")
|
||||
return None
|
||||
|
||||
if not search_date:
|
||||
search_date = datetime.now().strftime("%Y%m%d")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
KOREAEXIM_API_URL,
|
||||
params={
|
||||
"authkey": KOREAEXIM_API_KEY,
|
||||
"searchdate": search_date,
|
||||
"data": "AP01" # 환율 데이터
|
||||
},
|
||||
timeout=15.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
# API 결과 코드 확인
|
||||
if isinstance(data, list) and len(data) > 0:
|
||||
return data
|
||||
else:
|
||||
print(f"Korea Exim API returned empty data for date {search_date}")
|
||||
# 주말/공휴일이면 이전 영업일 데이터 조회
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to fetch from Korea Exim API: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_koreaexim_response(data: List[Dict]) -> Dict[str, Dict]:
|
||||
"""
|
||||
한국수출입은행 API 응답 파싱
|
||||
|
||||
Response format:
|
||||
{
|
||||
"result": 1,
|
||||
"cur_unit": "USD",
|
||||
"cur_nm": "미국 달러",
|
||||
"ttb": "1,438.71", # 전신환(송금) 받을때
|
||||
"tts": "1,467.28", # 전신환(송금) 보낼때
|
||||
"deal_bas_r": "1,452.99", # 매매 기준율
|
||||
"bkpr": "1,452", # 장부가격
|
||||
...
|
||||
}
|
||||
"""
|
||||
parsed = {}
|
||||
|
||||
for item in data:
|
||||
try:
|
||||
cur_unit = item.get("cur_unit", "").replace("(100)", "").strip()
|
||||
|
||||
if cur_unit not in SUPPORTED_CURRENCIES:
|
||||
continue
|
||||
|
||||
# 쉼표 제거 후 숫자 변환
|
||||
deal_base_rate = float(item.get("deal_bas_r", "0").replace(",", ""))
|
||||
ttb_rate = float(item.get("ttb", "0").replace(",", ""))
|
||||
tts_rate = float(item.get("tts", "0").replace(",", ""))
|
||||
|
||||
# 100엔 단위인 경우 (JPY(100))
|
||||
if "(100)" in item.get("cur_unit", ""):
|
||||
deal_base_rate /= 100
|
||||
ttb_rate /= 100
|
||||
tts_rate /= 100
|
||||
|
||||
parsed[cur_unit] = {
|
||||
"currency_code": cur_unit,
|
||||
"currency_name": item.get("cur_nm", SUPPORTED_CURRENCIES[cur_unit]["name_ko"]),
|
||||
"deal_base_rate": deal_base_rate,
|
||||
"ttb_rate": ttb_rate,
|
||||
"tts_rate": tts_rate,
|
||||
}
|
||||
|
||||
except (ValueError, KeyError) as e:
|
||||
print(f"Error parsing currency {item.get('cur_unit')}: {e}")
|
||||
continue
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
async def update_exchange_rates(db: Session, force: bool = False) -> Dict:
|
||||
"""
|
||||
환율 정보 업데이트
|
||||
|
||||
Args:
|
||||
db: DB 세션
|
||||
force: 강제 업데이트 여부
|
||||
|
||||
Returns:
|
||||
업데이트 결과
|
||||
"""
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
|
||||
# 오늘 이미 업데이트했는지 확인 (force가 아닌 경우)
|
||||
if not force:
|
||||
existing = db.query(ExchangeRate).filter(
|
||||
ExchangeRate.source_date == today
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return {
|
||||
"status": "skipped",
|
||||
"message": f"Already updated for {today}",
|
||||
"source_date": today
|
||||
}
|
||||
|
||||
# API 호출 (오늘 데이터 시도)
|
||||
api_data = await fetch_rates_from_koreaexim(today)
|
||||
source_date = today
|
||||
|
||||
# 오늘 데이터 없으면 어제 시도 (주말/공휴일 대응)
|
||||
if not api_data:
|
||||
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
|
||||
api_data = await fetch_rates_from_koreaexim(yesterday)
|
||||
source_date = yesterday
|
||||
|
||||
# 그래도 없으면 기본값 사용
|
||||
if not api_data:
|
||||
print("Using fallback rates")
|
||||
rates_data = {
|
||||
code: {
|
||||
"currency_code": code,
|
||||
"currency_name": info["name_ko"],
|
||||
"deal_base_rate": DEFAULT_RATES.get(code, 1.0),
|
||||
"ttb_rate": DEFAULT_RATES.get(code, 1.0) * 0.98,
|
||||
"tts_rate": DEFAULT_RATES.get(code, 1.0) * 1.02,
|
||||
}
|
||||
for code, info in SUPPORTED_CURRENCIES.items()
|
||||
}
|
||||
source = "fallback"
|
||||
else:
|
||||
rates_data = parse_koreaexim_response(api_data)
|
||||
source = "koreaexim"
|
||||
|
||||
# DB에 저장/업데이트
|
||||
updated_currencies = []
|
||||
for code, rate_info in rates_data.items():
|
||||
existing = db.query(ExchangeRate).filter(
|
||||
ExchangeRate.currency_code == code
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# 기존 데이터 업데이트
|
||||
old_rate = existing.deal_base_rate
|
||||
existing.currency_name = rate_info["currency_name"]
|
||||
existing.deal_base_rate = rate_info["deal_base_rate"]
|
||||
existing.ttb_rate = rate_info["ttb_rate"]
|
||||
existing.tts_rate = rate_info["tts_rate"]
|
||||
existing.adjusted_rate = rate_info["deal_base_rate"] * (1 + existing.weight_percent / 100)
|
||||
existing.source_date = source_date
|
||||
|
||||
# 변동이 있으면 히스토리 저장
|
||||
if old_rate != rate_info["deal_base_rate"]:
|
||||
history = ExchangeRateHistory(
|
||||
currency_code=code,
|
||||
deal_base_rate=rate_info["deal_base_rate"],
|
||||
source_date=source_date
|
||||
)
|
||||
db.add(history)
|
||||
else:
|
||||
# 신규 데이터 추가
|
||||
new_rate = ExchangeRate(
|
||||
currency_code=code,
|
||||
currency_name=rate_info["currency_name"],
|
||||
deal_base_rate=rate_info["deal_base_rate"],
|
||||
ttb_rate=rate_info["ttb_rate"],
|
||||
tts_rate=rate_info["tts_rate"],
|
||||
weight_percent=0.0,
|
||||
adjusted_rate=rate_info["deal_base_rate"],
|
||||
source_date=source_date,
|
||||
is_active=True
|
||||
)
|
||||
db.add(new_rate)
|
||||
|
||||
# 히스토리 저장
|
||||
history = ExchangeRateHistory(
|
||||
currency_code=code,
|
||||
deal_base_rate=rate_info["deal_base_rate"],
|
||||
source_date=source_date
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
updated_currencies.append(code)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Updated {len(updated_currencies)} currencies",
|
||||
"currencies": updated_currencies,
|
||||
"source": source,
|
||||
"source_date": source_date
|
||||
}
|
||||
|
||||
|
||||
def get_exchange_rate(db: Session, currency_code: str) -> Optional[ExchangeRate]:
|
||||
"""특정 통화 환율 조회"""
|
||||
return db.query(ExchangeRate).filter(
|
||||
ExchangeRate.currency_code == currency_code,
|
||||
ExchangeRate.is_active == True
|
||||
).first()
|
||||
|
||||
|
||||
def get_all_exchange_rates(db: Session) -> List[ExchangeRate]:
|
||||
"""모든 환율 조회"""
|
||||
return db.query(ExchangeRate).filter(
|
||||
ExchangeRate.is_active == True
|
||||
).all()
|
||||
|
||||
|
||||
def convert_krw_to_currency(db: Session, krw_amount: float, currency_code: str) -> Optional[float]:
|
||||
"""
|
||||
KRW를 다른 통화로 변환
|
||||
|
||||
Args:
|
||||
db: DB 세션
|
||||
krw_amount: 원화 금액
|
||||
currency_code: 대상 통화 코드 (USD, MNT, RUB, CNY)
|
||||
|
||||
Returns:
|
||||
변환된 금액 또는 None
|
||||
"""
|
||||
rate = get_exchange_rate(db, currency_code)
|
||||
|
||||
if not rate or rate.adjusted_rate <= 0:
|
||||
return None
|
||||
|
||||
# KRW / 환율 = 외화
|
||||
return krw_amount / rate.adjusted_rate
|
||||
|
||||
|
||||
def convert_currency_to_krw(db: Session, amount: float, currency_code: str) -> Optional[float]:
|
||||
"""
|
||||
다른 통화를 KRW로 변환
|
||||
|
||||
Args:
|
||||
db: DB 세션
|
||||
amount: 외화 금액
|
||||
currency_code: 원화 통화 코드
|
||||
|
||||
Returns:
|
||||
KRW 금액 또는 None
|
||||
"""
|
||||
rate = get_exchange_rate(db, currency_code)
|
||||
|
||||
if not rate:
|
||||
return None
|
||||
|
||||
# 외화 * 환율 = KRW
|
||||
return amount * rate.adjusted_rate
|
||||
356
backend/app/services/pdf_service.py
Normal file
356
backend/app/services/pdf_service.py
Normal file
@@ -0,0 +1,356 @@
|
||||
"""
|
||||
PDF Service for capturing web pages as PDF using Playwright
|
||||
Used for capturing Korean vehicle performance check reports (성능점검기록부)
|
||||
"""
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Tuple
|
||||
from datetime import datetime
|
||||
import tempfile
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# PDF generation failure log
|
||||
PDF_FAILURES: List[dict] = [] # In-memory log of recent failures
|
||||
|
||||
# Playwright imports
|
||||
try:
|
||||
from playwright.async_api import async_playwright, Browser, Page
|
||||
PLAYWRIGHT_AVAILABLE = True
|
||||
except ImportError:
|
||||
PLAYWRIGHT_AVAILABLE = False
|
||||
print("Warning: Playwright not installed. PDF capture will not work.")
|
||||
|
||||
# Image to PDF imports
|
||||
try:
|
||||
import img2pdf
|
||||
from PIL import Image
|
||||
IMG2PDF_AVAILABLE = True
|
||||
except ImportError:
|
||||
IMG2PDF_AVAILABLE = False
|
||||
print("Warning: img2pdf/pillow not installed. Image-based PDF will not work.")
|
||||
|
||||
# PDF storage directory
|
||||
PDF_STORAGE_DIR = Path(__file__).parent.parent.parent / "uploads" / "performance_checks"
|
||||
|
||||
|
||||
def ensure_pdf_directory():
|
||||
"""Ensure PDF storage directory exists"""
|
||||
PDF_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def log_pdf_failure(car_id: int, check_num: str, error: str):
|
||||
"""Log PDF generation failure"""
|
||||
global PDF_FAILURES
|
||||
failure = {
|
||||
"car_id": car_id,
|
||||
"check_num": check_num,
|
||||
"error": str(error),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"retried": False
|
||||
}
|
||||
PDF_FAILURES.append(failure)
|
||||
# Keep only last 100 failures
|
||||
if len(PDF_FAILURES) > 100:
|
||||
PDF_FAILURES = PDF_FAILURES[-100:]
|
||||
logger.error(f"PDF generation failed - car_id={car_id}, check_num={check_num}: {error}")
|
||||
|
||||
|
||||
def get_pdf_failures() -> List[dict]:
|
||||
"""Get list of recent PDF generation failures"""
|
||||
return PDF_FAILURES.copy()
|
||||
|
||||
|
||||
def clear_pdf_failure(car_id: int):
|
||||
"""Clear failure record for a car after successful retry"""
|
||||
global PDF_FAILURES
|
||||
PDF_FAILURES = [f for f in PDF_FAILURES if f["car_id"] != car_id]
|
||||
|
||||
|
||||
async def capture_performance_check_pdf(
|
||||
check_num: str,
|
||||
car_id: int,
|
||||
timeout: int = 60000,
|
||||
max_retries: int = 3,
|
||||
retry_delay: int = 2
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Capture Korean vehicle performance check report as PDF
|
||||
Uses screenshot-based approach for accurate rendering
|
||||
Includes automatic retry on failure
|
||||
|
||||
Args:
|
||||
check_num: Performance check number (성능점검번호)
|
||||
car_id: Car ID for naming the PDF file
|
||||
timeout: Page load timeout in milliseconds
|
||||
max_retries: Maximum number of retry attempts (default: 3)
|
||||
retry_delay: Delay between retries in seconds (default: 2)
|
||||
|
||||
Returns:
|
||||
PDF file path (relative) if successful, None if failed
|
||||
"""
|
||||
if not PLAYWRIGHT_AVAILABLE:
|
||||
error_msg = "Playwright not available. Cannot capture PDF."
|
||||
logger.error(error_msg)
|
||||
log_pdf_failure(car_id, check_num, error_msg)
|
||||
return None
|
||||
|
||||
if not IMG2PDF_AVAILABLE:
|
||||
error_msg = "img2pdf/pillow not available. Cannot create PDF from screenshots."
|
||||
logger.error(error_msg)
|
||||
log_pdf_failure(car_id, check_num, error_msg)
|
||||
return None
|
||||
|
||||
ensure_pdf_directory()
|
||||
|
||||
last_error = None
|
||||
|
||||
for attempt in range(1, max_retries + 1):
|
||||
# 별도 스레드에서 새 이벤트 루프로 실행하여 uvicorn과의 충돌 방지
|
||||
try:
|
||||
result = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
_capture_pdf_in_new_loop,
|
||||
check_num, car_id, timeout, attempt
|
||||
)
|
||||
if result:
|
||||
# Success - clear any previous failure record
|
||||
clear_pdf_failure(car_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"PDF capture attempt {attempt} failed: {e}")
|
||||
|
||||
if attempt < max_retries:
|
||||
logger.warning(f"PDF capture attempt {attempt}/{max_retries} failed for car_id={car_id}, retrying in {retry_delay}s...")
|
||||
await asyncio.sleep(retry_delay)
|
||||
|
||||
# All retries failed
|
||||
log_pdf_failure(car_id, check_num, f"Failed after {max_retries} attempts")
|
||||
return None
|
||||
|
||||
|
||||
def _capture_pdf_in_new_loop(check_num: str, car_id: int, timeout: int, attempt: int) -> Optional[str]:
|
||||
"""별도 이벤트 루프에서 PDF 캡처 실행"""
|
||||
import asyncio
|
||||
|
||||
# 새 이벤트 루프 생성
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
result = loop.run_until_complete(_capture_pdf_single_attempt(check_num, car_id, timeout, attempt))
|
||||
return result
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
async def _capture_pdf_single_attempt(
|
||||
check_num: str,
|
||||
car_id: int,
|
||||
timeout: int,
|
||||
attempt: int
|
||||
) -> Optional[str]:
|
||||
"""Single attempt to capture PDF"""
|
||||
print(f"[PDF] _capture_pdf_single_attempt: car_id={car_id}, check_num={check_num}, attempt={attempt}")
|
||||
ensure_pdf_directory()
|
||||
|
||||
# Performance check URL from carmodoo
|
||||
url = f"https://ck.carmodoo.com/carCheck/carmodooPrint.do?print=0&checkNum={check_num}"
|
||||
print(f"[PDF] URL: {url}")
|
||||
|
||||
# PDF filename: car_id_timestamp.pdf
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
pdf_filename = f"{car_id}_{timestamp}.pdf"
|
||||
pdf_path = PDF_STORAGE_DIR / pdf_filename
|
||||
relative_path = f"/uploads/performance_checks/{pdf_filename}"
|
||||
print(f"[PDF] Output path: {pdf_path}")
|
||||
|
||||
temp_images: List[Path] = []
|
||||
browser = None
|
||||
|
||||
try:
|
||||
print(f"[PDF] Launching playwright...")
|
||||
async with async_playwright() as p:
|
||||
# Launch browser (headless mode) with extended timeout
|
||||
print(f"[PDF] Launching chromium...")
|
||||
browser: Browser = await p.chromium.launch(
|
||||
headless=True,
|
||||
timeout=30000, # 30 second browser launch timeout
|
||||
args=[
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
'--disable-extensions',
|
||||
'--disable-background-networking',
|
||||
'--single-process' # Use single process for stability
|
||||
]
|
||||
)
|
||||
print(f"[PDF] Browser launched")
|
||||
|
||||
# Create new page - narrower viewport for larger content
|
||||
context = await browser.new_context(
|
||||
locale='ko-KR',
|
||||
viewport={'width': 900, 'height': 800},
|
||||
device_scale_factor=2 # High DPI for better quality
|
||||
)
|
||||
page: Page = await context.new_page()
|
||||
print(f"[PDF] Page created, navigating to URL...")
|
||||
|
||||
# Navigate to performance check page
|
||||
await page.goto(url, wait_until='networkidle', timeout=timeout)
|
||||
print(f"[PDF] Navigation complete")
|
||||
|
||||
# Wait for content to fully load
|
||||
await page.wait_for_timeout(3000)
|
||||
print(f"[PDF] Content loaded, taking screenshot...")
|
||||
|
||||
# Get full page dimensions
|
||||
page_height = await page.evaluate("document.documentElement.scrollHeight")
|
||||
page_width = await page.evaluate("document.documentElement.scrollWidth")
|
||||
|
||||
print(f"Page size: {page_width}x{page_height}")
|
||||
|
||||
# Take single full-page screenshot (no page splits)
|
||||
screenshot_path = PDF_STORAGE_DIR / f"temp_{car_id}_full.png"
|
||||
await page.screenshot(
|
||||
path=str(screenshot_path),
|
||||
full_page=True
|
||||
)
|
||||
temp_images.append(screenshot_path)
|
||||
print(f"Captured full page screenshot")
|
||||
|
||||
await browser.close()
|
||||
|
||||
# Convert screenshots to PDF
|
||||
if temp_images:
|
||||
print(f"Converting {len(temp_images)} images to PDF...")
|
||||
|
||||
# Process images for A4 size
|
||||
processed_images = []
|
||||
for img_path in temp_images:
|
||||
# Open and convert to RGB (required for PDF)
|
||||
with Image.open(img_path) as img:
|
||||
if img.mode in ('RGBA', 'P'):
|
||||
img = img.convert('RGB')
|
||||
|
||||
# Save as temporary JPEG for better compression
|
||||
temp_jpg = img_path.with_suffix('.jpg')
|
||||
img.save(temp_jpg, 'JPEG', quality=95)
|
||||
processed_images.append(temp_jpg)
|
||||
|
||||
# Create PDF with margins (25mm left/right, 30mm top/bottom)
|
||||
margin_lr_mm = 25 # left/right margin
|
||||
margin_tb_mm = 30 # top/bottom margin
|
||||
|
||||
# Get image dimensions to calculate page size
|
||||
with Image.open(processed_images[0]) as img:
|
||||
img_width_px, img_height_px = img.size
|
||||
|
||||
# Convert image pixels to points (assuming 150 DPI for reasonable size)
|
||||
dpi = 150
|
||||
img_width_pt = img_width_px * 72 / dpi
|
||||
img_height_pt = img_height_px * 72 / dpi
|
||||
|
||||
# Page size = image size + margins
|
||||
page_width_pt = img_width_pt + 2 * img2pdf.mm_to_pt(margin_lr_mm)
|
||||
page_height_pt = img_height_pt + 2 * img2pdf.mm_to_pt(margin_tb_mm)
|
||||
|
||||
with open(pdf_path, 'wb') as f:
|
||||
pdf_bytes = img2pdf.convert(
|
||||
[str(img) for img in processed_images],
|
||||
layout_fun=img2pdf.get_layout_fun(
|
||||
pagesize=(page_width_pt, page_height_pt),
|
||||
border=(img2pdf.mm_to_pt(margin_lr_mm), img2pdf.mm_to_pt(margin_tb_mm),
|
||||
img2pdf.mm_to_pt(margin_lr_mm), img2pdf.mm_to_pt(margin_tb_mm)),
|
||||
fit=img2pdf.FitMode.into
|
||||
)
|
||||
)
|
||||
f.write(pdf_bytes)
|
||||
|
||||
# Cleanup temporary files
|
||||
for img_path in temp_images:
|
||||
if img_path.exists():
|
||||
img_path.unlink()
|
||||
for img_path in processed_images:
|
||||
if img_path.exists():
|
||||
img_path.unlink()
|
||||
|
||||
# Verify PDF was created
|
||||
if pdf_path.exists() and pdf_path.stat().st_size > 0:
|
||||
logger.info(f"PDF captured successfully (attempt {attempt}): {pdf_path}")
|
||||
return relative_path
|
||||
else:
|
||||
logger.warning(f"PDF file not created or empty: {pdf_path}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_trace = traceback.format_exc()
|
||||
logger.error(f"Error capturing PDF for check_num={check_num} (attempt {attempt}): {e}\n{error_trace}")
|
||||
print(f"[PDF] ERROR: {e}\n{error_trace}")
|
||||
# Cleanup on error
|
||||
for img_path in temp_images:
|
||||
if img_path.exists():
|
||||
img_path.unlink()
|
||||
return None
|
||||
|
||||
|
||||
def capture_performance_check_pdf_sync(check_num: str, car_id: int) -> Optional[str]:
|
||||
"""
|
||||
Synchronous wrapper for capture_performance_check_pdf
|
||||
For use in non-async contexts
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
return loop.run_until_complete(capture_performance_check_pdf(check_num, car_id))
|
||||
|
||||
|
||||
def get_pdf_path(car_id: int) -> Optional[str]:
|
||||
"""
|
||||
Get existing PDF path for a car if it exists
|
||||
Returns the most recent PDF for the car
|
||||
"""
|
||||
ensure_pdf_directory()
|
||||
|
||||
# Find all PDFs for this car
|
||||
pattern = f"{car_id}_*.pdf"
|
||||
pdf_files = list(PDF_STORAGE_DIR.glob(pattern))
|
||||
|
||||
if not pdf_files:
|
||||
return None
|
||||
|
||||
# Return the most recent one
|
||||
latest_pdf = max(pdf_files, key=lambda p: p.stat().st_mtime)
|
||||
return f"/uploads/performance_checks/{latest_pdf.name}"
|
||||
|
||||
|
||||
def delete_pdf(relative_path: str) -> bool:
|
||||
"""Delete a PDF file"""
|
||||
try:
|
||||
filename = Path(relative_path).name
|
||||
full_path = PDF_STORAGE_DIR / filename
|
||||
if full_path.exists():
|
||||
full_path.unlink()
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Error deleting PDF: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_pdf_full_path(relative_path: str) -> Optional[Path]:
|
||||
"""Get full filesystem path from relative path"""
|
||||
if not relative_path:
|
||||
return None
|
||||
filename = Path(relative_path).name
|
||||
full_path = PDF_STORAGE_DIR / filename
|
||||
if full_path.exists():
|
||||
return full_path
|
||||
return None
|
||||
181
backend/app/services/sensitive_filter.py
Normal file
181
backend/app/services/sensitive_filter.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Sensitive Information Detection and Masking Service
|
||||
|
||||
Detects and masks Korean phone numbers, addresses, and other PII in dealer descriptions.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import List, Tuple, Dict
|
||||
|
||||
|
||||
# Korean phone number patterns
|
||||
PHONE_PATTERNS = [
|
||||
r'01[0-9]-?\d{3,4}-?\d{4}', # Mobile: 010-1234-5678, 0101234567
|
||||
r'02-?\d{3,4}-?\d{4}', # Seoul: 02-123-4567
|
||||
r'0[3-6][0-9]-?\d{3,4}-?\d{4}', # Regional: 031-123-4567
|
||||
r'070-?\d{3,4}-?\d{4}', # Internet phone: 070-1234-5678
|
||||
r'1[0-9]{2,3}-?\d{4}', # Service numbers: 1588-1234
|
||||
r'\d{2,4}[-.)]\s*\d{3,4}[-.)]\s*\d{4}', # Various formats with separators
|
||||
]
|
||||
|
||||
# Korean address patterns
|
||||
ADDRESS_PATTERNS = [
|
||||
r'[가-힣]+시\s+[가-힣]+구', # 서울시 강남구
|
||||
r'[가-힣]+도\s+[가-힣]+시', # 경기도 성남시
|
||||
r'[가-힣]+시\s+[가-힣]+동', # 서울시 역삼동
|
||||
r'[가-힣]+구\s+[가-힣]+동', # 강남구 역삼동
|
||||
r'[가-힣]+로\s*\d+', # 테헤란로 123
|
||||
r'[가-힣]+길\s*\d+', # 역삼길 45
|
||||
r'\d+번지', # 123번지
|
||||
r'[가-힣]+빌딩', # XX빌딩
|
||||
r'[가-힣]+타워', # XX타워
|
||||
r'[가-힣]+센터', # XX센터
|
||||
]
|
||||
|
||||
# Other sensitive patterns
|
||||
OTHER_PATTERNS = [
|
||||
r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', # Email
|
||||
r'카[카톡|톡]\s*:?\s*[a-zA-Z0-9가-힣]+', # 카톡 ID
|
||||
r'카카오톡?\s*:?\s*[a-zA-Z0-9가-힣]+', # 카카오톡 ID
|
||||
]
|
||||
|
||||
|
||||
def detect_sensitive_info(text: str) -> Dict[str, List[Tuple[int, int, str]]]:
|
||||
"""
|
||||
Detect sensitive information in text.
|
||||
|
||||
Returns:
|
||||
Dict with categories as keys and list of (start, end, matched_text) tuples as values.
|
||||
"""
|
||||
if not text:
|
||||
return {"phones": [], "addresses": [], "others": []}
|
||||
|
||||
result = {
|
||||
"phones": [],
|
||||
"addresses": [],
|
||||
"others": []
|
||||
}
|
||||
|
||||
# Detect phone numbers
|
||||
for pattern in PHONE_PATTERNS:
|
||||
for match in re.finditer(pattern, text):
|
||||
result["phones"].append((match.start(), match.end(), match.group()))
|
||||
|
||||
# Detect addresses
|
||||
for pattern in ADDRESS_PATTERNS:
|
||||
for match in re.finditer(pattern, text):
|
||||
result["addresses"].append((match.start(), match.end(), match.group()))
|
||||
|
||||
# Detect other sensitive info
|
||||
for pattern in OTHER_PATTERNS:
|
||||
for match in re.finditer(pattern, text):
|
||||
result["others"].append((match.start(), match.end(), match.group()))
|
||||
|
||||
# Remove duplicates and sort by position
|
||||
for category in result:
|
||||
result[category] = sorted(set(result[category]), key=lambda x: x[0])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def mask_sensitive_info(text: str, mask_char: str = "*") -> str:
|
||||
"""
|
||||
Mask all detected sensitive information in text.
|
||||
|
||||
Args:
|
||||
text: Original text
|
||||
mask_char: Character to use for masking (default: *)
|
||||
|
||||
Returns:
|
||||
Text with sensitive info masked
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
detected = detect_sensitive_info(text)
|
||||
|
||||
# Collect all ranges to mask
|
||||
ranges = []
|
||||
for category in detected.values():
|
||||
for start, end, _ in category:
|
||||
ranges.append((start, end))
|
||||
|
||||
# Merge overlapping ranges
|
||||
ranges = sorted(ranges)
|
||||
merged = []
|
||||
for start, end in ranges:
|
||||
if merged and start <= merged[-1][1]:
|
||||
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
|
||||
else:
|
||||
merged.append((start, end))
|
||||
|
||||
# Apply masking (reverse order to preserve positions)
|
||||
result = text
|
||||
for start, end in reversed(merged):
|
||||
original = result[start:end]
|
||||
# Keep first and last char, mask the middle
|
||||
if len(original) > 4:
|
||||
masked = original[:2] + mask_char * (len(original) - 4) + original[-2:]
|
||||
else:
|
||||
masked = mask_char * len(original)
|
||||
result = result[:start] + masked + result[end:]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def highlight_sensitive_info(text: str) -> str:
|
||||
"""
|
||||
Add HTML highlighting to detected sensitive information.
|
||||
Used for admin preview.
|
||||
|
||||
Returns:
|
||||
HTML string with sensitive info wrapped in <mark> tags
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
detected = detect_sensitive_info(text)
|
||||
|
||||
# Collect all ranges with their categories
|
||||
ranges = []
|
||||
for category, items in detected.items():
|
||||
for start, end, matched in items:
|
||||
ranges.append((start, end, matched, category))
|
||||
|
||||
# Sort by position (reverse for replacement)
|
||||
ranges = sorted(ranges, key=lambda x: x[0], reverse=True)
|
||||
|
||||
result = text
|
||||
for start, end, matched, category in ranges:
|
||||
color = {
|
||||
"phones": "#fee2e2", # red-100
|
||||
"addresses": "#fef3c7", # amber-100
|
||||
"others": "#dbeafe" # blue-100
|
||||
}.get(category, "#e5e7eb")
|
||||
|
||||
result = (
|
||||
result[:start] +
|
||||
f'<mark style="background-color: {color}; padding: 0 2px;">{matched}</mark>' +
|
||||
result[end:]
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def has_sensitive_info(text: str) -> bool:
|
||||
"""Check if text contains any sensitive information."""
|
||||
if not text:
|
||||
return False
|
||||
detected = detect_sensitive_info(text)
|
||||
return any(len(items) > 0 for items in detected.values())
|
||||
|
||||
|
||||
def get_sensitivity_summary(text: str) -> Dict[str, int]:
|
||||
"""Get count of each type of sensitive info detected."""
|
||||
if not text:
|
||||
return {"phones": 0, "addresses": 0, "others": 0, "total": 0}
|
||||
|
||||
detected = detect_sensitive_info(text)
|
||||
counts = {k: len(v) for k, v in detected.items()}
|
||||
counts["total"] = sum(counts.values())
|
||||
return counts
|
||||
364
backend/app/services/spec_service.py
Normal file
364
backend/app/services/spec_service.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""
|
||||
Specification Service for fetching vehicle specifications from AUTOBEGINS via Carmodoo
|
||||
Uses Playwright to interact with the dealer portal and call AUTOBEGINS API
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Playwright imports
|
||||
try:
|
||||
from playwright.async_api import async_playwright, Browser, Page
|
||||
PLAYWRIGHT_AVAILABLE = True
|
||||
except ImportError:
|
||||
PLAYWRIGHT_AVAILABLE = False
|
||||
logger.warning("Playwright not installed. Specification lookup will not work.")
|
||||
|
||||
# Carmodoo credentials
|
||||
CARMODOO_BASE_URL = "https://dealer.carmodoo.com"
|
||||
CARMODOO_USER_ID = os.getenv("CARMODOO_USER_ID", "01033315258")
|
||||
CARMODOO_PASSWORD = os.getenv("CARMODOO_PASSWORD", "alskfl@1122")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CarSpecification:
|
||||
"""Vehicle specification data from AUTOBEGINS"""
|
||||
car_number: str = ""
|
||||
manufacturer: str = ""
|
||||
model_name: str = ""
|
||||
grade: str = ""
|
||||
model_year: str = ""
|
||||
first_registration: str = ""
|
||||
body_type: str = ""
|
||||
transmission: str = ""
|
||||
fuel_type: str = ""
|
||||
displacement: int = 0
|
||||
color: str = ""
|
||||
mileage: int = 0
|
||||
usage: str = ""
|
||||
vin: str = ""
|
||||
inspection_validity: str = ""
|
||||
|
||||
# Price info (in 만원)
|
||||
release_price: int = 0
|
||||
base_price: int = 0
|
||||
option_price: int = 0
|
||||
|
||||
# Mortgage/Seizure
|
||||
mortgage_count: int = 0
|
||||
seizure_count: int = 0
|
||||
|
||||
# Options
|
||||
standard_options: list = field(default_factory=list)
|
||||
selected_options: list = field(default_factory=list)
|
||||
|
||||
# Raw data
|
||||
raw_data: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
def _parse_spec_html(html: str, car_number: str) -> CarSpecification:
|
||||
"""Parse HTML content from AUTOBEGINS search.html to extract specification data"""
|
||||
spec = CarSpecification(car_number=car_number)
|
||||
spec.raw_data = {"html_length": len(html)}
|
||||
|
||||
try:
|
||||
# Manufacturer and Model (from logo and text)
|
||||
model_match = re.search(r'<ul class="model">\s*<li>.*?([^>]+)<br>([^<]+)</li>', html, re.DOTALL)
|
||||
if model_match:
|
||||
# Extract manufacturer from text before <br>
|
||||
maker_text = model_match.group(1).strip()
|
||||
maker_clean = re.sub(r'<[^>]+>', '', maker_text).strip()
|
||||
spec.manufacturer = maker_clean
|
||||
|
||||
# Model name after <br>
|
||||
spec.model_name = model_match.group(2).strip()
|
||||
|
||||
# Alternative manufacturer detection
|
||||
if not spec.manufacturer:
|
||||
maker_patterns = ['기아', '현대', 'KG모빌리티', '쌍용', '르노', '쉐보레', 'BMW', '벤츠', '아우디', '볼보', '렉서스', '토요타']
|
||||
for maker in maker_patterns:
|
||||
if maker in html:
|
||||
spec.manufacturer = maker
|
||||
break
|
||||
|
||||
# Year (년형)
|
||||
year_match = re.search(r'<th>년형</th>\s*<td>(\d{4})년</td>', html)
|
||||
if year_match:
|
||||
spec.model_year = year_match.group(1)
|
||||
|
||||
# First registration (최초등록일)
|
||||
reg_match = re.search(r'<th>최초등록일</th>\s*<td>(\d{4}\.\d{2}\.\d{2})</td>', html)
|
||||
if reg_match:
|
||||
spec.first_registration = reg_match.group(1)
|
||||
|
||||
# Body type (외형)
|
||||
body_match = re.search(r'<th>외형</th>\s*<td>([^<]+)</td>', html)
|
||||
if body_match:
|
||||
spec.body_type = body_match.group(1).strip()
|
||||
|
||||
# Transmission (미션)
|
||||
trans_match = re.search(r'<th>미션</th>\s*<td>([^<]+)</td>', html)
|
||||
if trans_match:
|
||||
spec.transmission = trans_match.group(1).strip()
|
||||
|
||||
# Fuel type (연료)
|
||||
fuel_match = re.search(r'<th>연료</th>\s*<td>([^<]+)</td>', html)
|
||||
if fuel_match:
|
||||
spec.fuel_type = fuel_match.group(1).strip()
|
||||
|
||||
# Displacement (배기량)
|
||||
disp_match = re.search(r'<th>배기량</th>\s*<td>(\d+)cc</td>', html)
|
||||
if disp_match:
|
||||
spec.displacement = int(disp_match.group(1))
|
||||
|
||||
# Color (색상)
|
||||
color_match = re.search(r'<th>색상</th>\s*<td>([^<]+)</td>', html)
|
||||
if color_match:
|
||||
spec.color = color_match.group(1).strip()
|
||||
|
||||
# Mileage (주행거리)
|
||||
mileage_match = re.search(r'<th>주행거리</th>\s*<td>([\d,]+)km</td>', html)
|
||||
if mileage_match:
|
||||
spec.mileage = int(mileage_match.group(1).replace(',', ''))
|
||||
|
||||
# Usage (용도)
|
||||
usage_match = re.search(r'<th>용도</th>\s*<td>([^<]+)</td>', html)
|
||||
if usage_match:
|
||||
spec.usage = usage_match.group(1).strip()
|
||||
|
||||
# VIN (차대번호)
|
||||
vin_match = re.search(r'value="([A-Z0-9]{17})"', html)
|
||||
if vin_match:
|
||||
spec.vin = vin_match.group(1)
|
||||
|
||||
# Inspection validity (검사유효기간)
|
||||
insp_match = re.search(r'<th>검사유효기간</th>\s*<td[^>]*>([^<]+)</td>', html)
|
||||
if insp_match:
|
||||
spec.inspection_validity = insp_match.group(1).strip()
|
||||
|
||||
# Price extraction - digit_area contains nested spans with hidden digits
|
||||
# Format: <span class="digit_area red">...<span class="hide">1</span>...<span class="hide">4</span>...</span>
|
||||
def extract_price_from_section(section_html):
|
||||
"""Extract price from a section of HTML containing digit_area spans"""
|
||||
digits = re.findall(r'<span class="hide">([0-9])</span>', section_html)
|
||||
if digits:
|
||||
try:
|
||||
return int(''.join(digits))
|
||||
except:
|
||||
pass
|
||||
return 0
|
||||
|
||||
# Release price (출고가) - find the whole price_table row
|
||||
release_section = re.search(r'출고가.*?</td>', html, re.DOTALL)
|
||||
if release_section:
|
||||
spec.release_price = extract_price_from_section(release_section.group(0))
|
||||
|
||||
# Base price (기본가)
|
||||
base_section = re.search(r'>기본가<.*?</td>', html, re.DOTALL)
|
||||
if base_section:
|
||||
spec.base_price = extract_price_from_section(base_section.group(0))
|
||||
|
||||
# Option price (출고시 옵션가)
|
||||
option_section = re.search(r'출고시 옵션가.*?</td>', html, re.DOTALL)
|
||||
if option_section:
|
||||
spec.option_price = extract_price_from_section(option_section.group(0))
|
||||
|
||||
# Mortgage/Seizure (저당/압류)
|
||||
mortgage_match = re.search(r'<span class="title_big">저당</span>\s*<strong[^>]*>(\d+)</strong>', html)
|
||||
if mortgage_match:
|
||||
spec.mortgage_count = int(mortgage_match.group(1))
|
||||
|
||||
seizure_match = re.search(r'<span class="title_big">압류</span>\s*<strong[^>]*>(\d+)</strong>', html)
|
||||
if seizure_match:
|
||||
spec.seizure_count = int(seizure_match.group(1))
|
||||
|
||||
# Standard options (기본품목)
|
||||
std_opts = re.findall(r'<ul class="opt_base">.*?</ul>', html, re.DOTALL)
|
||||
if std_opts:
|
||||
spec.standard_options = re.findall(r'<span>([^<]+)</span>', std_opts[0])
|
||||
|
||||
# Selected options (선택품목)
|
||||
sel_opts = re.findall(r'<li><span>([^<]+)</span>\s*<strong>([^<]+)</strong></li>', html)
|
||||
spec.selected_options = [f"{name} ({price})" for name, price in sel_opts]
|
||||
|
||||
logger.info(f"Parsed spec for {car_number}: {spec.manufacturer} {spec.model_name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing spec HTML: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
return spec
|
||||
|
||||
|
||||
async def get_specifications_from_carmodoo(car_number: str, timeout: int = 60000) -> Optional[CarSpecification]:
|
||||
"""
|
||||
Fetch vehicle specifications from AUTOBEGINS via Carmodoo dealer portal
|
||||
|
||||
Args:
|
||||
car_number: Korean license plate number (e.g., "117더3590")
|
||||
timeout: Maximum wait time in milliseconds
|
||||
|
||||
Returns:
|
||||
CarSpecification object or None if not found
|
||||
"""
|
||||
if not PLAYWRIGHT_AVAILABLE:
|
||||
logger.error("Playwright not available for specification lookup")
|
||||
return None
|
||||
|
||||
if not car_number or len(car_number) < 7:
|
||||
logger.error(f"Invalid car number: {car_number}")
|
||||
return None
|
||||
|
||||
try:
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
page = await browser.new_page()
|
||||
|
||||
try:
|
||||
# Login to Carmodoo
|
||||
logger.info("Logging in to Carmodoo...")
|
||||
await page.goto(f"{CARMODOO_BASE_URL}/member/login_v2.html", timeout=timeout)
|
||||
await page.fill('input[name="id"]', CARMODOO_USER_ID)
|
||||
await page.fill('input[name="passwd"]', CARMODOO_PASSWORD)
|
||||
await page.click('input[value="LOGIN"]')
|
||||
await page.wait_for_timeout(3000)
|
||||
|
||||
# Navigate to spec search page
|
||||
logger.info("Navigating to spec search...")
|
||||
await page.goto(f"{CARMODOO_BASE_URL}/info/search_ab.html", timeout=timeout)
|
||||
await page.wait_for_timeout(3000)
|
||||
|
||||
# Find the AUTOBEGINS iframe
|
||||
target_frame = None
|
||||
for frame in page.frames:
|
||||
if 'autobegins.com/cp/?k=' in frame.url:
|
||||
target_frame = frame
|
||||
break
|
||||
|
||||
if not target_frame:
|
||||
logger.error("Could not find AUTOBEGINS frame")
|
||||
return None
|
||||
|
||||
# Get OTP values
|
||||
otp = await target_frame.evaluate("document.getElementById('otp').value")
|
||||
next_otp = await target_frame.evaluate("document.getElementById('nextOtp').value")
|
||||
|
||||
logger.info(f"Calling AUTOBEGINS API for: {car_number}")
|
||||
|
||||
# Call the API directly
|
||||
api_result = await target_frame.evaluate("""
|
||||
async (params) => {
|
||||
const { carNum, otp, nextOtp } = params;
|
||||
try {
|
||||
const response = await fetch('/ext/gg1_ab.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `mode=search&carNum=${encodeURIComponent(carNum)}&otp=${otp}&nextOtp=${nextOtp}`
|
||||
});
|
||||
const text = await response.text();
|
||||
return { success: true, data: text };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
}
|
||||
""", {"carNum": car_number, "otp": otp, "nextOtp": next_otp})
|
||||
|
||||
if not api_result.get('success'):
|
||||
logger.error(f"API call failed: {api_result.get('error')}")
|
||||
return None
|
||||
|
||||
# Parse API response
|
||||
try:
|
||||
data = json.loads(api_result['data'])
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Failed to parse API response")
|
||||
return None
|
||||
|
||||
rst_code = data.get('rst_code')
|
||||
if rst_code != 1:
|
||||
rst_msg = data.get('rst_msg', 'Unknown error')
|
||||
logger.warning(f"AUTOBEGINS API returned: {rst_msg} (code: {rst_code})")
|
||||
return None
|
||||
|
||||
# Get search result
|
||||
sd_key = data.get('sdKey')
|
||||
sd_type = data.get('sdType')
|
||||
|
||||
if sd_type not in [2, 3]:
|
||||
logger.warning(f"Unexpected sdType: {sd_type}")
|
||||
return None
|
||||
|
||||
# Navigate to result page
|
||||
page_name = 'search_yet.html' if sd_type == 2 else 'search.html'
|
||||
result_url = f'/cp/{page_name}?otp={otp}&nextOtp={next_otp}&S_SDDATA={sd_key}'
|
||||
|
||||
await target_frame.evaluate(f"""
|
||||
document.getElementById('searchIFrame').src = '{result_url}';
|
||||
""")
|
||||
|
||||
logger.info("Waiting for result page to load...")
|
||||
await page.wait_for_timeout(8000)
|
||||
|
||||
# Find and read result frame
|
||||
result_content = None
|
||||
for frame in page.frames:
|
||||
if page_name in frame.url and 'S_SDDATA' in frame.url:
|
||||
result_content = await frame.content()
|
||||
break
|
||||
|
||||
if not result_content:
|
||||
logger.error("Could not find result frame content")
|
||||
return None
|
||||
|
||||
# Parse the HTML
|
||||
spec = _parse_spec_html(result_content, car_number)
|
||||
logger.info(f"Successfully retrieved specs for {car_number}")
|
||||
return spec
|
||||
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching specifications for {car_number}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
|
||||
def spec_to_dict(spec: CarSpecification) -> dict:
|
||||
"""Convert CarSpecification to dictionary for database storage"""
|
||||
return {
|
||||
"car_number": spec.car_number,
|
||||
"manufacturer": spec.manufacturer,
|
||||
"model_name": spec.model_name,
|
||||
"grade": spec.grade,
|
||||
"model_year": spec.model_year,
|
||||
"first_registration": spec.first_registration,
|
||||
"body_type": spec.body_type,
|
||||
"transmission": spec.transmission,
|
||||
"fuel_type": spec.fuel_type,
|
||||
"displacement": spec.displacement,
|
||||
"color": spec.color,
|
||||
"mileage": spec.mileage,
|
||||
"usage": spec.usage,
|
||||
"vin": spec.vin,
|
||||
"inspection_validity": spec.inspection_validity,
|
||||
"release_price": spec.release_price,
|
||||
"base_price": spec.base_price,
|
||||
"option_price": spec.option_price,
|
||||
"mortgage_count": spec.mortgage_count,
|
||||
"seizure_count": spec.seizure_count,
|
||||
"standard_options": spec.standard_options,
|
||||
"selected_options": spec.selected_options,
|
||||
"raw_data": spec.raw_data,
|
||||
}
|
||||
174
backend/app/services/translation_service.py
Normal file
174
backend/app/services/translation_service.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Azure Translator Service for dealer descriptions
|
||||
Supports Korean → English, Mongolian, Russian direct translation
|
||||
"""
|
||||
import os
|
||||
import httpx
|
||||
from typing import Optional, Dict
|
||||
import json
|
||||
|
||||
|
||||
class AzureTranslationService:
|
||||
"""Microsoft Azure Translator API Service"""
|
||||
|
||||
AZURE_ENDPOINT = "https://api.cognitive.microsofttranslator.com"
|
||||
API_VERSION = "3.0"
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = os.getenv("AZURE_TRANSLATOR_KEY", "")
|
||||
self.region = os.getenv("AZURE_TRANSLATOR_REGION", "koreacentral")
|
||||
self._is_configured = bool(self.api_key)
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if Azure Translator API is configured"""
|
||||
return self._is_configured
|
||||
|
||||
async def translate(self, text: str, target_lang: str, source_lang: str = "ko") -> Optional[str]:
|
||||
"""
|
||||
Translate text from source language to target language
|
||||
|
||||
Args:
|
||||
text: Text to translate (Korean)
|
||||
target_lang: Target language code (en, mn, ru)
|
||||
source_lang: Source language code (default: ko)
|
||||
|
||||
Returns:
|
||||
Translated text or None if failed
|
||||
"""
|
||||
if not self._is_configured:
|
||||
print("[Translation] Azure Translator API not configured")
|
||||
return None
|
||||
|
||||
if not text or not text.strip():
|
||||
return ""
|
||||
|
||||
try:
|
||||
url = f"{self.AZURE_ENDPOINT}/translate"
|
||||
params = {
|
||||
"api-version": self.API_VERSION,
|
||||
"from": source_lang,
|
||||
"to": target_lang
|
||||
}
|
||||
headers = {
|
||||
"Ocp-Apim-Subscription-Key": self.api_key,
|
||||
"Ocp-Apim-Subscription-Region": self.region,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
body = [{"text": text}]
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
json=body
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result and len(result) > 0 and "translations" in result[0]:
|
||||
translated = result[0]["translations"][0]["text"]
|
||||
print(f"[Translation] Success: {source_lang} -> {target_lang}")
|
||||
return translated
|
||||
else:
|
||||
error_msg = response.text
|
||||
print(f"[Translation] API Error ({response.status_code}): {error_msg}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Translation] Exception: {e}")
|
||||
return None
|
||||
|
||||
async def translate_all_languages(self, text: str) -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
Translate text to all supported languages (en, mn, ru) in a single API call
|
||||
|
||||
Args:
|
||||
text: Korean text to translate
|
||||
|
||||
Returns:
|
||||
Dictionary with translations: {
|
||||
'en': '...',
|
||||
'mn': '...',
|
||||
'ru': '...'
|
||||
}
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return {'en': '', 'mn': '', 'ru': ''}
|
||||
|
||||
if not self._is_configured:
|
||||
print("[Translation] Azure Translator API not configured")
|
||||
return {'en': None, 'mn': None, 'ru': None}
|
||||
|
||||
try:
|
||||
# Azure supports multiple target languages in a single call
|
||||
url = f"{self.AZURE_ENDPOINT}/translate"
|
||||
params = {
|
||||
"api-version": self.API_VERSION,
|
||||
"from": "ko",
|
||||
"to": ["en", "mn", "ru"] # All three languages at once
|
||||
}
|
||||
headers = {
|
||||
"Ocp-Apim-Subscription-Key": self.api_key,
|
||||
"Ocp-Apim-Subscription-Region": self.region,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
body = [{"text": text}]
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
json=body
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result and len(result) > 0 and "translations" in result[0]:
|
||||
translations = result[0]["translations"]
|
||||
result_dict = {'en': None, 'mn': None, 'ru': None}
|
||||
|
||||
for trans in translations:
|
||||
lang = trans.get("to")
|
||||
text_translated = trans.get("text")
|
||||
if lang in result_dict:
|
||||
result_dict[lang] = text_translated
|
||||
|
||||
print(f"[Translation] Success: ko -> en, mn, ru (batch)")
|
||||
return result_dict
|
||||
else:
|
||||
error_msg = response.text
|
||||
print(f"[Translation] API Error ({response.status_code}): {error_msg}")
|
||||
return {'en': None, 'mn': None, 'ru': None}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Translation] Exception: {e}")
|
||||
return {'en': None, 'mn': None, 'ru': None}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_translation_service: Optional[AzureTranslationService] = None
|
||||
|
||||
|
||||
def get_translation_service() -> AzureTranslationService:
|
||||
"""Get or create the translation service singleton"""
|
||||
global _translation_service
|
||||
if _translation_service is None:
|
||||
_translation_service = AzureTranslationService()
|
||||
return _translation_service
|
||||
|
||||
|
||||
async def translate_dealer_description(text: str) -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
Convenience function to translate dealer description to all languages
|
||||
|
||||
Args:
|
||||
text: Korean dealer description
|
||||
|
||||
Returns:
|
||||
Dictionary with translations for en, mn, ru
|
||||
"""
|
||||
service = get_translation_service()
|
||||
return await service.translate_all_languages(text)
|
||||
313
backend/app/services/verification_service.py
Normal file
313
backend/app/services/verification_service.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Verification Service for Email and SMS
|
||||
Handles sending and verifying codes for user authentication
|
||||
"""
|
||||
import random
|
||||
import string
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..config import get_settings
|
||||
from ..models.user import User, VerificationCode
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def generate_code(length: int = 6) -> str:
|
||||
"""Generate a random numeric code"""
|
||||
return ''.join(random.choices(string.digits, k=length))
|
||||
|
||||
|
||||
def create_verification_code(
|
||||
db: Session,
|
||||
code_type: str, # 'email' or 'phone'
|
||||
email: Optional[str] = None,
|
||||
phone: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
purpose: str = "verification"
|
||||
) -> VerificationCode:
|
||||
"""Create a new verification code"""
|
||||
# Invalidate any existing codes for this email/phone
|
||||
if email:
|
||||
db.query(VerificationCode).filter(
|
||||
VerificationCode.email == email,
|
||||
VerificationCode.code_type == code_type,
|
||||
VerificationCode.verified_at.is_(None)
|
||||
).delete()
|
||||
if phone:
|
||||
db.query(VerificationCode).filter(
|
||||
VerificationCode.phone == phone,
|
||||
VerificationCode.code_type == code_type,
|
||||
VerificationCode.verified_at.is_(None)
|
||||
).delete()
|
||||
|
||||
# Create new code
|
||||
code = VerificationCode(
|
||||
user_id=user_id,
|
||||
email=email,
|
||||
phone=phone,
|
||||
code=generate_code(),
|
||||
code_type=code_type,
|
||||
purpose=purpose,
|
||||
expires_at=datetime.utcnow() + timedelta(minutes=settings.VERIFICATION_CODE_EXPIRE_MINUTES)
|
||||
)
|
||||
db.add(code)
|
||||
db.commit()
|
||||
db.refresh(code)
|
||||
return code
|
||||
|
||||
|
||||
def verify_code(
|
||||
db: Session,
|
||||
code: str,
|
||||
code_type: str,
|
||||
email: Optional[str] = None,
|
||||
phone: Optional[str] = None
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Verify a code and return (success, message)
|
||||
"""
|
||||
query = db.query(VerificationCode).filter(
|
||||
VerificationCode.code_type == code_type,
|
||||
VerificationCode.verified_at.is_(None)
|
||||
)
|
||||
|
||||
if email:
|
||||
query = query.filter(VerificationCode.email == email)
|
||||
if phone:
|
||||
query = query.filter(VerificationCode.phone == phone)
|
||||
|
||||
verification = query.order_by(VerificationCode.created_at.desc()).first()
|
||||
|
||||
if not verification:
|
||||
return False, "No verification code found. Please request a new one."
|
||||
|
||||
# Check if expired
|
||||
if datetime.utcnow() > verification.expires_at.replace(tzinfo=None):
|
||||
return False, "Verification code has expired. Please request a new one."
|
||||
|
||||
# Check attempts
|
||||
if verification.attempts >= verification.max_attempts:
|
||||
return False, "Too many failed attempts. Please request a new code."
|
||||
|
||||
# Check code
|
||||
if verification.code != code:
|
||||
verification.attempts += 1
|
||||
db.commit()
|
||||
remaining = verification.max_attempts - verification.attempts
|
||||
return False, f"Invalid code. {remaining} attempts remaining."
|
||||
|
||||
# Success
|
||||
verification.verified_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
return True, "Verification successful"
|
||||
|
||||
|
||||
async def send_email_verification(
|
||||
db: Session,
|
||||
email: str,
|
||||
user_id: Optional[int] = None,
|
||||
language: str = "en"
|
||||
) -> Tuple[bool, str]:
|
||||
"""Send email verification code"""
|
||||
# Check rate limit (1 email per minute)
|
||||
recent = db.query(VerificationCode).filter(
|
||||
VerificationCode.email == email,
|
||||
VerificationCode.code_type == "email",
|
||||
VerificationCode.created_at > datetime.utcnow() - timedelta(minutes=1)
|
||||
).first()
|
||||
|
||||
if recent:
|
||||
return False, "Please wait 1 minute before requesting another code."
|
||||
|
||||
# Create verification code
|
||||
verification = create_verification_code(
|
||||
db=db,
|
||||
code_type="email",
|
||||
email=email,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
# Send email
|
||||
try:
|
||||
# Email templates by language
|
||||
subjects = {
|
||||
"en": "AutonetSellCar - Email Verification Code",
|
||||
"ko": "AutonetSellCar - 이메일 인증 코드",
|
||||
"mn": "AutonetSellCar - Имэйл баталгаажуулах код",
|
||||
"ru": "AutonetSellCar - Код подтверждения email"
|
||||
}
|
||||
|
||||
bodies = {
|
||||
"en": f"""
|
||||
Hello,
|
||||
|
||||
Your verification code is: {verification.code}
|
||||
|
||||
This code will expire in {settings.VERIFICATION_CODE_EXPIRE_MINUTES} minutes.
|
||||
|
||||
If you didn't request this code, please ignore this email.
|
||||
|
||||
Best regards,
|
||||
AutonetSellCar Team
|
||||
""",
|
||||
"ko": f"""
|
||||
안녕하세요,
|
||||
|
||||
인증 코드: {verification.code}
|
||||
|
||||
이 코드는 {settings.VERIFICATION_CODE_EXPIRE_MINUTES}분 후에 만료됩니다.
|
||||
|
||||
요청하지 않은 경우 이 이메일을 무시하세요.
|
||||
|
||||
감사합니다,
|
||||
AutonetSellCar 팀
|
||||
""",
|
||||
"mn": f"""
|
||||
Сайн байна уу,
|
||||
|
||||
Таны баталгаажуулах код: {verification.code}
|
||||
|
||||
Энэ код {settings.VERIFICATION_CODE_EXPIRE_MINUTES} минутын дараа хүчингүй болно.
|
||||
|
||||
Хэрэв та энэ кодыг хүсээгүй бол энэ имэйлийг үл тоомсорлоно уу.
|
||||
|
||||
Хүндэтгэсэн,
|
||||
AutonetSellCar баг
|
||||
""",
|
||||
"ru": f"""
|
||||
Здравствуйте,
|
||||
|
||||
Ваш код подтверждения: {verification.code}
|
||||
|
||||
Этот код истечет через {settings.VERIFICATION_CODE_EXPIRE_MINUTES} минут.
|
||||
|
||||
Если вы не запрашивали этот код, проигнорируйте это письмо.
|
||||
|
||||
С уважением,
|
||||
Команда AutonetSellCar
|
||||
"""
|
||||
}
|
||||
|
||||
subject = subjects.get(language, subjects["en"])
|
||||
body = bodies.get(language, bodies["en"])
|
||||
|
||||
# Check if SMTP is configured
|
||||
if not settings.SMTP_USER or not settings.SMTP_PASSWORD:
|
||||
# Development mode - just log the code
|
||||
print(f"[DEV] Email verification code for {email}: {verification.code}")
|
||||
return True, "Verification code sent (dev mode)"
|
||||
|
||||
# Send actual email
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_FROM_EMAIL or settings.SMTP_USER}>"
|
||||
msg['To'] = email
|
||||
msg['Subject'] = subject
|
||||
msg.attach(MIMEText(body, 'plain', 'utf-8'))
|
||||
|
||||
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
|
||||
server.starttls()
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
server.send_message(msg)
|
||||
|
||||
return True, "Verification code sent to your email"
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to send email: {e}")
|
||||
return False, f"Failed to send email: {str(e)}"
|
||||
|
||||
|
||||
async def send_sms_verification(
|
||||
db: Session,
|
||||
phone: str,
|
||||
user_id: Optional[int] = None,
|
||||
language: str = "en"
|
||||
) -> Tuple[bool, str]:
|
||||
"""Send SMS verification code"""
|
||||
# Normalize phone number
|
||||
phone = phone.strip().replace(" ", "").replace("-", "")
|
||||
if not phone.startswith("+"):
|
||||
# Assume Mongolia if no country code
|
||||
if phone.startswith("9") and len(phone) == 8:
|
||||
phone = "+976" + phone
|
||||
|
||||
# Check rate limit (1 SMS per minute)
|
||||
recent = db.query(VerificationCode).filter(
|
||||
VerificationCode.phone == phone,
|
||||
VerificationCode.code_type == "phone",
|
||||
VerificationCode.created_at > datetime.utcnow() - timedelta(minutes=1)
|
||||
).first()
|
||||
|
||||
if recent:
|
||||
return False, "Please wait 1 minute before requesting another code."
|
||||
|
||||
# Create verification code
|
||||
verification = create_verification_code(
|
||||
db=db,
|
||||
code_type="phone",
|
||||
phone=phone,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
# SMS messages by language
|
||||
messages = {
|
||||
"en": f"AutonetSellCar verification code: {verification.code}. Valid for {settings.VERIFICATION_CODE_EXPIRE_MINUTES} min.",
|
||||
"ko": f"AutonetSellCar 인증 코드: {verification.code}. {settings.VERIFICATION_CODE_EXPIRE_MINUTES}분간 유효.",
|
||||
"mn": f"AutonetSellCar баталгаажуулах код: {verification.code}. {settings.VERIFICATION_CODE_EXPIRE_MINUTES} мин хүчинтэй.",
|
||||
"ru": f"Код подтверждения AutonetSellCar: {verification.code}. Действителен {settings.VERIFICATION_CODE_EXPIRE_MINUTES} мин."
|
||||
}
|
||||
|
||||
message = messages.get(language, messages["en"])
|
||||
|
||||
try:
|
||||
# Check if Twilio is configured
|
||||
if not settings.TWILIO_ACCOUNT_SID or not settings.TWILIO_AUTH_TOKEN:
|
||||
# Development mode - just log the code
|
||||
print(f"[DEV] SMS verification code for {phone}: {verification.code}")
|
||||
return True, "Verification code sent (dev mode)"
|
||||
|
||||
# Send actual SMS via Twilio
|
||||
from twilio.rest import Client
|
||||
|
||||
client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN)
|
||||
client.messages.create(
|
||||
body=message,
|
||||
from_=settings.TWILIO_PHONE_NUMBER,
|
||||
to=phone
|
||||
)
|
||||
|
||||
return True, "Verification code sent to your phone"
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to send SMS: {e}")
|
||||
return False, f"Failed to send SMS: {str(e)}"
|
||||
|
||||
|
||||
def mark_email_verified(db: Session, user: User) -> None:
|
||||
"""Mark user's email as verified"""
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
|
||||
def mark_phone_verified(db: Session, user: User, phone: str) -> None:
|
||||
"""Mark user's phone as verified and update phone number"""
|
||||
user.phone = phone
|
||||
user.phone_verified = True
|
||||
user.phone_verified_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
|
||||
def is_email_verified(user: User) -> bool:
|
||||
"""Check if user's email is verified"""
|
||||
return user.email_verified
|
||||
|
||||
|
||||
def is_phone_verified(user: User) -> bool:
|
||||
"""Check if user's phone is verified"""
|
||||
return user.phone_verified
|
||||
299
backend/app/services/visitor_service.py
Normal file
299
backend/app/services/visitor_service.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Visitor Tracking Service
|
||||
- Tracks page visits with privacy-preserving IP hashing
|
||||
- Parses user agent for device/browser info
|
||||
- Geolocation using free ip-api.com service
|
||||
"""
|
||||
import hashlib
|
||||
import httpx
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
from ..models.visitor import VisitorLog, VisitorDailyStats, VisitorSession
|
||||
|
||||
# IP Geolocation service (free, 45 req/min limit)
|
||||
IP_API_URL = "http://ip-api.com/json/{ip}?fields=status,country,countryCode,regionName,city"
|
||||
|
||||
# Cache for IP geolocation results (in-memory, simple)
|
||||
_geo_cache: Dict[str, Dict] = {}
|
||||
_geo_cache_expiry: Dict[str, datetime] = {}
|
||||
GEO_CACHE_TTL = timedelta(hours=24)
|
||||
|
||||
|
||||
def hash_ip(ip: str) -> str:
|
||||
"""Hash IP address for privacy"""
|
||||
return hashlib.sha256(ip.encode()).hexdigest()
|
||||
|
||||
|
||||
def hash_visitor(ip: str, user_agent: str) -> str:
|
||||
"""Create unique visitor hash from IP + User-Agent"""
|
||||
combined = f"{ip}:{user_agent}"
|
||||
return hashlib.sha256(combined.encode()).hexdigest()
|
||||
|
||||
|
||||
def parse_device_info(user_agent_string: str) -> Dict:
|
||||
"""Parse user agent string for device/browser info"""
|
||||
try:
|
||||
from user_agents import parse as parse_user_agent
|
||||
ua = parse_user_agent(user_agent_string)
|
||||
|
||||
# Determine device type
|
||||
if ua.is_mobile:
|
||||
device_type = "mobile"
|
||||
elif ua.is_tablet:
|
||||
device_type = "tablet"
|
||||
else:
|
||||
device_type = "desktop"
|
||||
|
||||
return {
|
||||
"device_type": device_type,
|
||||
"browser": ua.browser.family,
|
||||
"browser_version": ua.browser.version_string,
|
||||
"os": ua.os.family,
|
||||
"os_version": ua.os.version_string,
|
||||
}
|
||||
except ImportError:
|
||||
# Fallback if user-agents not installed
|
||||
return {
|
||||
"device_type": "unknown",
|
||||
"browser": "unknown",
|
||||
"browser_version": "",
|
||||
"os": "unknown",
|
||||
"os_version": "",
|
||||
}
|
||||
|
||||
|
||||
async def get_geo_info(ip: str) -> Optional[Dict]:
|
||||
"""Get geographic info from IP address using free ip-api.com"""
|
||||
# Check cache first
|
||||
if ip in _geo_cache:
|
||||
if datetime.now() < _geo_cache_expiry.get(ip, datetime.min):
|
||||
return _geo_cache[ip]
|
||||
|
||||
# Skip private/local IPs
|
||||
if ip.startswith(('127.', '192.168.', '10.', '172.16.', '172.17.', '172.18.', '172.19.',
|
||||
'172.20.', '172.21.', '172.22.', '172.23.', '172.24.', '172.25.',
|
||||
'172.26.', '172.27.', '172.28.', '172.29.', '172.30.', '172.31.', 'localhost', '::1')):
|
||||
return {"country": "Local", "country_code": "LO", "region": "", "city": ""}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
IP_API_URL.format(ip=ip),
|
||||
timeout=5.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get("status") == "success":
|
||||
result = {
|
||||
"country": data.get("country", "Unknown"),
|
||||
"country_code": data.get("countryCode", ""),
|
||||
"region": data.get("regionName", ""),
|
||||
"city": data.get("city", ""),
|
||||
}
|
||||
# Cache the result
|
||||
_geo_cache[ip] = result
|
||||
_geo_cache_expiry[ip] = datetime.now() + GEO_CACHE_TTL
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"Geo lookup failed for {ip}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_referrer_domain(referrer: str) -> Optional[str]:
|
||||
"""Extract domain from referrer URL"""
|
||||
if not referrer:
|
||||
return None
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(referrer)
|
||||
return parsed.netloc or None
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
async def log_visit(
|
||||
db: Session,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
page_path: str,
|
||||
page_title: Optional[str] = None,
|
||||
referrer: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
utm_source: Optional[str] = None,
|
||||
utm_medium: Optional[str] = None,
|
||||
utm_campaign: Optional[str] = None,
|
||||
) -> VisitorLog:
|
||||
"""
|
||||
Log a page visit
|
||||
"""
|
||||
# Hash IP for privacy
|
||||
ip_hash = hash_ip(ip)
|
||||
visitor_hash = hash_visitor(ip, user_agent)
|
||||
|
||||
# Parse device info
|
||||
device_info = parse_device_info(user_agent)
|
||||
|
||||
# Get geo info (async)
|
||||
geo_info = await get_geo_info(ip) or {}
|
||||
|
||||
# Extract referrer domain
|
||||
referrer_domain = extract_referrer_domain(referrer)
|
||||
|
||||
# Create log entry
|
||||
log = VisitorLog(
|
||||
visitor_hash=visitor_hash,
|
||||
ip_hash=ip_hash,
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
page_path=page_path,
|
||||
page_title=page_title,
|
||||
referrer=referrer,
|
||||
referrer_domain=referrer_domain,
|
||||
device_type=device_info["device_type"],
|
||||
browser=device_info["browser"],
|
||||
browser_version=device_info["browser_version"],
|
||||
os=device_info["os"],
|
||||
os_version=device_info["os_version"],
|
||||
country=geo_info.get("country"),
|
||||
country_code=geo_info.get("country_code"),
|
||||
city=geo_info.get("city"),
|
||||
region=geo_info.get("region"),
|
||||
utm_source=utm_source,
|
||||
utm_medium=utm_medium,
|
||||
utm_campaign=utm_campaign,
|
||||
)
|
||||
|
||||
db.add(log)
|
||||
|
||||
# Update or create session
|
||||
if session_id:
|
||||
session = db.query(VisitorSession).filter(
|
||||
VisitorSession.session_id == session_id
|
||||
).first()
|
||||
|
||||
if session:
|
||||
session.last_page = page_path
|
||||
session.page_count += 1
|
||||
session.last_activity_at = datetime.utcnow()
|
||||
if user_id and not session.user_id:
|
||||
session.user_id = user_id
|
||||
else:
|
||||
session = VisitorSession(
|
||||
session_id=session_id,
|
||||
visitor_hash=visitor_hash,
|
||||
user_id=user_id,
|
||||
first_page=page_path,
|
||||
last_page=page_path,
|
||||
device_type=device_info["device_type"],
|
||||
browser=device_info["browser"],
|
||||
country=geo_info.get("country"),
|
||||
)
|
||||
db.add(session)
|
||||
|
||||
db.commit()
|
||||
db.refresh(log)
|
||||
|
||||
return log
|
||||
|
||||
|
||||
def aggregate_daily_stats(db: Session, date_str: str) -> Optional[VisitorDailyStats]:
|
||||
"""
|
||||
Aggregate visitor stats for a given date (YYYY-MM-DD)
|
||||
Called by scheduled task
|
||||
"""
|
||||
# Query all visits for the date
|
||||
visits = db.query(VisitorLog).filter(
|
||||
func.date(VisitorLog.visited_at) == date_str
|
||||
).all()
|
||||
|
||||
if not visits:
|
||||
return None
|
||||
|
||||
total_visits = len(visits)
|
||||
unique_visitors = len(set(v.visitor_hash for v in visits))
|
||||
|
||||
# Device breakdown
|
||||
device_counts = {}
|
||||
for v in visits:
|
||||
device = v.device_type or "unknown"
|
||||
device_counts[device] = device_counts.get(device, 0) + 1
|
||||
|
||||
# Browser breakdown
|
||||
browser_counts = {}
|
||||
for v in visits:
|
||||
browser = v.browser or "unknown"
|
||||
browser_counts[browser] = browser_counts.get(browser, 0) + 1
|
||||
|
||||
# Country breakdown
|
||||
country_counts = {}
|
||||
for v in visits:
|
||||
country = v.country_code or "unknown"
|
||||
country_counts[country] = country_counts.get(country, 0) + 1
|
||||
|
||||
# Top pages
|
||||
page_counts = {}
|
||||
for v in visits:
|
||||
page_counts[v.page_path] = page_counts.get(v.page_path, 0) + 1
|
||||
top_pages = sorted(
|
||||
[{"path": k, "views": v} for k, v in page_counts.items()],
|
||||
key=lambda x: x["views"],
|
||||
reverse=True
|
||||
)[:20]
|
||||
|
||||
# Top referrers
|
||||
referrer_counts = {}
|
||||
for v in visits:
|
||||
if v.referrer_domain:
|
||||
referrer_counts[v.referrer_domain] = referrer_counts.get(v.referrer_domain, 0) + 1
|
||||
top_referrers = sorted(
|
||||
[{"domain": k, "visits": v} for k, v in referrer_counts.items()],
|
||||
key=lambda x: x["visits"],
|
||||
reverse=True
|
||||
)[:10]
|
||||
|
||||
# Create or update daily stats
|
||||
existing = db.query(VisitorDailyStats).filter(
|
||||
VisitorDailyStats.stat_date == date_str
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.total_visits = total_visits
|
||||
existing.unique_visitors = unique_visitors
|
||||
existing.device_breakdown = json.dumps(device_counts)
|
||||
existing.browser_breakdown = json.dumps(browser_counts)
|
||||
existing.country_breakdown = json.dumps(country_counts)
|
||||
existing.top_pages = json.dumps(top_pages)
|
||||
existing.top_referrers = json.dumps(top_referrers)
|
||||
stats = existing
|
||||
else:
|
||||
stats = VisitorDailyStats(
|
||||
stat_date=date_str,
|
||||
total_visits=total_visits,
|
||||
unique_visitors=unique_visitors,
|
||||
device_breakdown=json.dumps(device_counts),
|
||||
browser_breakdown=json.dumps(browser_counts),
|
||||
country_breakdown=json.dumps(country_counts),
|
||||
top_pages=json.dumps(top_pages),
|
||||
top_referrers=json.dumps(top_referrers),
|
||||
)
|
||||
db.add(stats)
|
||||
|
||||
db.commit()
|
||||
return stats
|
||||
|
||||
|
||||
def cleanup_old_visitor_logs(db: Session, days: int = 90) -> int:
|
||||
"""Delete visitor logs older than specified days"""
|
||||
cutoff = datetime.now() - timedelta(days=days)
|
||||
deleted = db.query(VisitorLog).filter(
|
||||
VisitorLog.visited_at < cutoff
|
||||
).delete()
|
||||
db.commit()
|
||||
return deleted
|
||||
11
backend/check_api_schema.py
Normal file
11
backend/check_api_schema.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
with urllib.request.urlopen("http://localhost:8000/openapi.json") as response:
|
||||
data = json.loads(response.read().decode())
|
||||
|
||||
props = data['components']['schemas']['CarmodooSearchResultItem']['properties']
|
||||
print("Properties in CarmodooSearchResultItem:")
|
||||
for p in props.keys():
|
||||
print(f" - {p}")
|
||||
print(f"\ncheck_num present: {'check_num' in props}")
|
||||
42
backend/check_banner_cars.py
Normal file
42
backend/check_banner_cars.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Check banner cars and their source_id for fetching performance checks"""
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
os.chdir(r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
|
||||
conn = sqlite3.connect('autonet.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 먼저 cars 테이블 구조 확인
|
||||
cursor.execute('PRAGMA table_info(cars)')
|
||||
columns = cursor.fetchall()
|
||||
print('=== cars Table Columns ===')
|
||||
col_names = [col[1] for col in columns]
|
||||
print(col_names)
|
||||
print()
|
||||
|
||||
# 배너 차량들의 source_id 확인
|
||||
cursor.execute('''
|
||||
SELECT h.car_id, c.source_id,
|
||||
(SELECT COUNT(*) FROM car_performance_checks WHERE car_id = h.car_id) as has_perf
|
||||
FROM hero_banners h
|
||||
JOIN cars c ON h.car_id = c.id
|
||||
WHERE h.is_active = 1
|
||||
''')
|
||||
banner_cars = cursor.fetchall()
|
||||
|
||||
print('=== Banner Cars Status ===')
|
||||
print()
|
||||
cars_without_perf = []
|
||||
for car in banner_cars:
|
||||
car_id, source_id, has_perf = car
|
||||
status = 'O' if has_perf > 0 else 'X'
|
||||
print(f'Car ID: {car_id}, Source ID: {source_id}, Perf Check: {status}')
|
||||
if has_perf == 0 and source_id:
|
||||
cars_without_perf.append((car_id, source_id))
|
||||
|
||||
print()
|
||||
print('=== Cars that need performance check fetch ===')
|
||||
for car_id, source_id in cars_without_perf:
|
||||
print(f' Car ID: {car_id}, Source ID: {source_id}')
|
||||
|
||||
conn.close()
|
||||
8
backend/check_config.py
Normal file
8
backend/check_config.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import sys
|
||||
sys.path.insert(0, r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
print(f"USE_SQLITE: {settings.USE_SQLITE}")
|
||||
print(f"DATABASE_URL: {settings.DATABASE_URL}")
|
||||
47
backend/check_data.py
Normal file
47
backend/check_data.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
os.chdir(r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
|
||||
conn = sqlite3.connect('autonet.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 활성 배너 차량 확인
|
||||
cursor.execute('SELECT id, car_id, is_active FROM hero_banners WHERE is_active = 1')
|
||||
banners = cursor.fetchall()
|
||||
print('=== Active Banners ===')
|
||||
for b in banners:
|
||||
print(f' Banner ID: {b[0]}, Car ID: {b[1]}')
|
||||
|
||||
print()
|
||||
|
||||
# 성능점검표 테이블 구조 확인
|
||||
cursor.execute('PRAGMA table_info(car_performance_checks)')
|
||||
columns = cursor.fetchall()
|
||||
print('=== car_performance_checks Table Structure ===')
|
||||
for col in columns:
|
||||
print(f' {col[1]} ({col[2]})')
|
||||
|
||||
print()
|
||||
|
||||
# 배너 차량들의 성능점검표 확인
|
||||
if banners:
|
||||
car_ids = [b[1] for b in banners if b[1]]
|
||||
if car_ids:
|
||||
placeholders = ','.join('?' * len(car_ids))
|
||||
cursor.execute(f'SELECT car_id, check_number FROM car_performance_checks WHERE car_id IN ({placeholders})', car_ids)
|
||||
perf_checks = cursor.fetchall()
|
||||
print('=== Performance Checks for Banner Cars ===')
|
||||
if perf_checks:
|
||||
for pc in perf_checks:
|
||||
print(f' Car ID: {pc[0]}, Check #: {pc[1]}')
|
||||
else:
|
||||
print(' No performance checks found for banner cars!')
|
||||
|
||||
print()
|
||||
|
||||
# 전체 성능점검표 개수
|
||||
cursor.execute('SELECT COUNT(*) FROM car_performance_checks')
|
||||
count = cursor.fetchone()[0]
|
||||
print(f'=== Total Performance Checks: {count} ===')
|
||||
|
||||
conn.close()
|
||||
81
backend/check_db.py
Normal file
81
backend/check_db.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect('D:/Workspace/claudeCode/AutonetSellCar.com/backend/car_platform.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# List all tables
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = cursor.fetchall()
|
||||
print('Tables in database:')
|
||||
for t in tables:
|
||||
print(f' - {t[0]}')
|
||||
|
||||
# Check if inquiries table exists
|
||||
if ('inquiries',) not in tables:
|
||||
print('\nCreating inquiries table...')
|
||||
cursor.execute('''
|
||||
CREATE TABLE inquiries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
car_id INTEGER,
|
||||
category VARCHAR(50) DEFAULT "general",
|
||||
subject VARCHAR(200),
|
||||
message TEXT NOT NULL,
|
||||
contact_email VARCHAR(255),
|
||||
contact_phone VARCHAR(50),
|
||||
status VARCHAR(20) DEFAULT "pending",
|
||||
admin_response TEXT,
|
||||
responded_at DATETIME,
|
||||
responded_by INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (car_id) REFERENCES cars (id)
|
||||
)
|
||||
''')
|
||||
print('inquiries table created!')
|
||||
else:
|
||||
print('\nChecking columns in inquiries table...')
|
||||
cursor.execute('PRAGMA table_info(inquiries)')
|
||||
columns = cursor.fetchall()
|
||||
existing_cols = [col[1] for col in columns]
|
||||
print('Existing columns:', existing_cols)
|
||||
|
||||
# Add new columns if they don't exist
|
||||
new_columns = [
|
||||
('category', 'VARCHAR(50) DEFAULT "general"'),
|
||||
('subject', 'VARCHAR(200)'),
|
||||
('contact_email', 'VARCHAR(255)'),
|
||||
('contact_phone', 'VARCHAR(50)'),
|
||||
('admin_response', 'TEXT'),
|
||||
('responded_at', 'DATETIME'),
|
||||
('responded_by', 'INTEGER'),
|
||||
('updated_at', 'DATETIME'),
|
||||
]
|
||||
|
||||
for col_name, col_type in new_columns:
|
||||
if col_name not in existing_cols:
|
||||
try:
|
||||
cursor.execute(f'ALTER TABLE inquiries ADD COLUMN {col_name} {col_type}')
|
||||
print(f'Added column: {col_name}')
|
||||
except Exception as e:
|
||||
print(f'Error adding {col_name}: {e}')
|
||||
|
||||
# Create inquiry_messages table if not exists
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS inquiry_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
inquiry_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
is_admin BOOLEAN DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (inquiry_id) REFERENCES inquiries (id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
)
|
||||
''')
|
||||
print('\ninquiry_messages table created/verified')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print('\nDatabase update complete!')
|
||||
46
backend/check_db_status.py
Normal file
46
backend/check_db_status.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
db_path = os.path.join(os.path.dirname(__file__), 'car_platform.db')
|
||||
print(f"Database path: {db_path}")
|
||||
print(f"Database exists: {os.path.exists(db_path)}")
|
||||
print(f"Database size: {os.path.getsize(db_path) if os.path.exists(db_path) else 0} bytes")
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check tables
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = cursor.fetchall()
|
||||
print(f"\nTables: {[t[0] for t in tables]}")
|
||||
|
||||
# Check counts for each important table
|
||||
table_counts = [
|
||||
'car_makers',
|
||||
'car_models',
|
||||
'cars',
|
||||
'hero_banners',
|
||||
'users',
|
||||
'translations'
|
||||
]
|
||||
|
||||
print("\nTable counts:")
|
||||
for table in table_counts:
|
||||
try:
|
||||
cursor.execute(f"SELECT COUNT(*) FROM {table}")
|
||||
count = cursor.fetchone()[0]
|
||||
print(f" {table}: {count}")
|
||||
except Exception as e:
|
||||
print(f" {table}: Error - {e}")
|
||||
|
||||
# Check if there are any car_makers
|
||||
cursor.execute("SELECT * FROM car_makers LIMIT 5")
|
||||
makers = cursor.fetchall()
|
||||
print(f"\nSample car_makers: {makers}")
|
||||
|
||||
# Check if there are any hero_banners
|
||||
cursor.execute("SELECT * FROM hero_banners LIMIT 5")
|
||||
banners = cursor.fetchall()
|
||||
print(f"\nSample hero_banners: {banners}")
|
||||
|
||||
conn.close()
|
||||
49
backend/check_dbs.py
Normal file
49
backend/check_dbs.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
base_path = Path(r'D:\Workspace\claudeCode\AutonetSellCar.com')
|
||||
|
||||
# Find all .db files
|
||||
db_files = []
|
||||
for p in base_path.rglob('*.db'):
|
||||
if 'node_modules' not in str(p) and 'venv' not in str(p):
|
||||
db_files.append(p)
|
||||
|
||||
print("=" * 60)
|
||||
print("발견된 DB 파일들:")
|
||||
print("=" * 60)
|
||||
|
||||
for db_path in sorted(db_files):
|
||||
size = db_path.stat().st_size / 1024 # KB
|
||||
mtime = db_path.stat().st_mtime
|
||||
from datetime import datetime
|
||||
mtime_str = datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M:%S')
|
||||
print(f"\n{db_path}")
|
||||
print(f" 크기: {size:.1f} KB")
|
||||
print(f" 수정: {mtime_str}")
|
||||
|
||||
# Check tables
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = [t[0] for t in cursor.fetchall()]
|
||||
print(f" 테이블: {', '.join(tables[:10])}")
|
||||
|
||||
# Check if car_performance_checks table exists
|
||||
if 'car_performance_checks' in tables:
|
||||
cursor.execute("SELECT COUNT(*), COUNT(pdf_path) FROM car_performance_checks WHERE pdf_path IS NOT NULL AND pdf_path != ''")
|
||||
total, with_pdf = cursor.fetchone()
|
||||
print(f" 성능점검: 총 {total}개, PDF있음 {with_pdf}개")
|
||||
|
||||
# Show recent records
|
||||
cursor.execute("SELECT car_id, check_number, pdf_path FROM car_performance_checks ORDER BY id DESC LIMIT 3")
|
||||
for row in cursor.fetchall():
|
||||
print(f" car_id={row[0]}, check_num={row[1]}, pdf={row[2]}")
|
||||
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f" 오류: {e}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
40
backend/check_pdf_status.py
Normal file
40
backend/check_pdf_status.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Check PDF migration status"""
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
os.chdir(r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
|
||||
conn = sqlite3.connect('autonet.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check total records
|
||||
cursor.execute('SELECT COUNT(*) FROM car_performance_checks')
|
||||
total = cursor.fetchone()[0]
|
||||
|
||||
# Check records with check_number
|
||||
cursor.execute("SELECT COUNT(*) FROM car_performance_checks WHERE check_number IS NOT NULL AND check_number != ''")
|
||||
with_check_num = cursor.fetchone()[0]
|
||||
|
||||
# Check records with pdf_path
|
||||
cursor.execute("SELECT COUNT(*) FROM car_performance_checks WHERE pdf_path IS NOT NULL AND pdf_path != ''")
|
||||
with_pdf = cursor.fetchone()[0]
|
||||
|
||||
print("=== Performance Check PDF Status ===")
|
||||
print(f"Total records: {total}")
|
||||
print(f"With check_number: {with_check_num}")
|
||||
print(f"With PDF: {with_pdf}")
|
||||
print(f"Pending PDF generation: {with_check_num - with_pdf}")
|
||||
|
||||
print()
|
||||
|
||||
# Show details
|
||||
cursor.execute("SELECT car_id, check_number, pdf_path FROM car_performance_checks")
|
||||
records = cursor.fetchall()
|
||||
print("=== Details ===")
|
||||
for r in records:
|
||||
car_id, check_num, pdf_path = r
|
||||
status = "O" if pdf_path else "X"
|
||||
print(f"Car ID: {car_id}, Check#: {check_num}, PDF: {status}")
|
||||
if pdf_path:
|
||||
print(f" -> {pdf_path}")
|
||||
|
||||
conn.close()
|
||||
91
backend/fetch_perf_check.py
Normal file
91
backend/fetch_perf_check.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""성능점검표 가져오기 스크립트"""
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
from app.api.carmodoo import carmodoo_client
|
||||
from app.database import SessionLocal
|
||||
from app.models import Car, CarPerformanceCheck
|
||||
|
||||
async def fetch_and_save():
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# 1. 최근 차량 중 성능점검표 없는 것 확인
|
||||
cars = db.query(Car).order_by(Car.id.desc()).limit(5).all()
|
||||
print("Recent cars:")
|
||||
for c in cars:
|
||||
perf = db.query(CarPerformanceCheck).filter(CarPerformanceCheck.car_id == c.id).first()
|
||||
print(f" ID {c.id}: {c.source_id} - has_perf: {perf is not None}")
|
||||
|
||||
# K5 차량 (ID 6) 처리
|
||||
car = db.query(Car).filter(Car.id == 6).first()
|
||||
if not car:
|
||||
print("Car ID 4 not found")
|
||||
return
|
||||
|
||||
print(f"Car: {car.car_name}, source_id: {car.source_id}")
|
||||
|
||||
# 2. 기존 성능점검표 삭제
|
||||
existing = db.query(CarPerformanceCheck).filter(CarPerformanceCheck.car_id == 4).first()
|
||||
if existing:
|
||||
db.delete(existing)
|
||||
db.commit()
|
||||
print("Deleted existing performance check")
|
||||
|
||||
# 3. 카모두에서 성능점검표 가져오기
|
||||
print(f"Fetching performance check for car_no: {car.source_id}")
|
||||
result = await carmodoo_client.get_performance_check(car.source_id)
|
||||
|
||||
print(f"Result found: {result.get('found')}")
|
||||
print(f"Check number: {result.get('check_num')}")
|
||||
|
||||
if result.get('found') and result.get('data'):
|
||||
perf_data = result['data']
|
||||
print(f"Performance check data keys: {perf_data.keys()}")
|
||||
|
||||
# 4. DB에 저장
|
||||
performance_check = CarPerformanceCheck(
|
||||
car_id=car.id,
|
||||
check_number=perf_data.get("check_number"),
|
||||
check_date=perf_data.get("check_date"),
|
||||
valid_until=perf_data.get("valid_until"),
|
||||
car_number=perf_data.get("car_number"),
|
||||
first_registration=perf_data.get("first_registration"),
|
||||
mileage=perf_data.get("mileage"),
|
||||
mileage_status=perf_data.get("mileage_status"),
|
||||
seize_count=perf_data.get("seize_count", 0),
|
||||
collateral_count=perf_data.get("collateral_count", 0),
|
||||
is_flood_damaged=perf_data.get("is_flood_damaged", False),
|
||||
is_fire_damaged=perf_data.get("is_fire_damaged", False),
|
||||
is_total_loss=perf_data.get("is_total_loss", False),
|
||||
usage_history=perf_data.get("usage_history"),
|
||||
is_rental_used=perf_data.get("is_rental_used", False),
|
||||
engine_status=perf_data.get("engine_status"),
|
||||
transmission_status=perf_data.get("transmission_status"),
|
||||
power_delivery_status=perf_data.get("power_delivery_status"),
|
||||
steering_status=perf_data.get("steering_status"),
|
||||
brake_status=perf_data.get("brake_status"),
|
||||
electrical_status=perf_data.get("electrical_status"),
|
||||
fuel_system_status=perf_data.get("fuel_system_status"),
|
||||
raw_data=perf_data,
|
||||
raw_html=result.get("raw_html", ""),
|
||||
)
|
||||
db.add(performance_check)
|
||||
db.commit()
|
||||
print(f"Saved performance check: {perf_data.get('check_number')}")
|
||||
else:
|
||||
print("No performance check data found from Carmodoo")
|
||||
print(f"Full result: {result}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(fetch_and_save())
|
||||
97
backend/fetch_perf_direct.py
Normal file
97
backend/fetch_perf_direct.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""성능점검표 직접 가져오기 (checkNum 직접 입력)"""
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
import httpx
|
||||
from lxml import html as lxml_html
|
||||
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models import Car, CarPerformanceCheck
|
||||
|
||||
async def fetch_performance_check_direct(check_num: str):
|
||||
"""checkNum으로 직접 성능점검표 가져오기"""
|
||||
print(f"Fetching performance check: {check_num}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
perf_url = "https://ck.carmodoo.com/carCheck/carmodooPrint.do"
|
||||
params = {"print": "0", "checkNum": check_num}
|
||||
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'ko-KR,ko;q=0.9',
|
||||
}
|
||||
|
||||
response = await client.get(perf_url, params=params, headers=headers)
|
||||
|
||||
if response.status_code == 200:
|
||||
html = response.content.decode('utf-8')
|
||||
print(f"Got HTML response: {len(html)} bytes")
|
||||
|
||||
# 간단한 파싱
|
||||
tree = lxml_html.fromstring(html)
|
||||
|
||||
# 차명 추출
|
||||
car_name = tree.xpath('//th[contains(text(), "차명")]/following-sibling::td/text()')
|
||||
if car_name:
|
||||
print(f"Car name: {car_name[0].strip()}")
|
||||
|
||||
# 등록번호 추출
|
||||
car_number = tree.xpath('//th[contains(text(), "자동차등록번호")]/following-sibling::td/text()')
|
||||
if car_number:
|
||||
print(f"Car number: {car_number[0].strip()}")
|
||||
|
||||
# 주행거리 추출
|
||||
mileage_text = tree.xpath('//*[contains(text(), "현재 주행거리")]/text()')
|
||||
print(f"Mileage raw: {mileage_text}")
|
||||
|
||||
return html
|
||||
else:
|
||||
print(f"Error: {response.status_code}")
|
||||
return None
|
||||
|
||||
async def save_to_db(car_id: int, check_num: str, html: str):
|
||||
"""성능점검표 DB에 저장"""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# 기존 데이터 삭제
|
||||
existing = db.query(CarPerformanceCheck).filter(CarPerformanceCheck.car_id == car_id).first()
|
||||
if existing:
|
||||
db.delete(existing)
|
||||
db.commit()
|
||||
print(f"Deleted existing performance check for car {car_id}")
|
||||
|
||||
# 새 데이터 저장
|
||||
perf_check = CarPerformanceCheck(
|
||||
car_id=car_id,
|
||||
check_number=check_num,
|
||||
raw_html=html,
|
||||
)
|
||||
db.add(perf_check)
|
||||
db.commit()
|
||||
print(f"Saved performance check {check_num} for car {car_id}")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def main():
|
||||
# 성능점검번호 (사용자가 제공한 K5 차량)
|
||||
check_num = "7400044430"
|
||||
|
||||
# 성능점검표 가져오기
|
||||
html = await fetch_performance_check_direct(check_num)
|
||||
|
||||
if html:
|
||||
# 가장 최근 차량(K5)에 저장 - ID 6
|
||||
car_id = 6
|
||||
await save_to_db(car_id, check_num, html)
|
||||
print("Done!")
|
||||
else:
|
||||
print("Failed to fetch performance check")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
107
backend/fetch_performance_check.py
Normal file
107
backend/fetch_performance_check.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
기존 차량의 성능점검표를 가져오는 스크립트
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import SessionLocal, engine
|
||||
from app.models import Car, CarPerformanceCheck
|
||||
from app.api.carmodoo import carmodoo_client
|
||||
|
||||
|
||||
async def fetch_and_save_performance_check(car_id: int, car_no: str):
|
||||
"""특정 차량의 성능점검표를 가져와서 저장"""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# 이미 있는지 확인
|
||||
existing = db.query(CarPerformanceCheck).filter(
|
||||
CarPerformanceCheck.car_id == car_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
print(f"Car {car_id} already has performance check")
|
||||
return False
|
||||
|
||||
# 카모두에서 성능점검표 가져오기
|
||||
print(f"Fetching performance check for car_no: {car_no}")
|
||||
result = await carmodoo_client.get_performance_check(car_no)
|
||||
|
||||
# raw_html 저장 (디버깅용)
|
||||
if result.get("raw_html"):
|
||||
with open(f"./debug_perf_check_{car_no}.html", "w", encoding="utf-8") as f:
|
||||
f.write(result["raw_html"])
|
||||
print(f" Raw HTML saved to debug_perf_check_{car_no}.html (length: {len(result['raw_html'])})")
|
||||
|
||||
if not result.get("found"):
|
||||
print(f"Performance check not found for car_no: {car_no}")
|
||||
return False
|
||||
|
||||
perf_data = result["data"]
|
||||
print(f"Performance check found: {perf_data.get('check_number', 'N/A')}")
|
||||
|
||||
# CarPerformanceCheck 생성
|
||||
performance_check = CarPerformanceCheck(
|
||||
car_id=car_id,
|
||||
check_number=perf_data.get("check_number"),
|
||||
check_date=perf_data.get("check_date"),
|
||||
valid_until=perf_data.get("valid_until"),
|
||||
car_number=perf_data.get("car_number"),
|
||||
first_registration=perf_data.get("first_registration"),
|
||||
mileage=perf_data.get("mileage"),
|
||||
mileage_status=perf_data.get("mileage_status"),
|
||||
seize_count=perf_data.get("seize_count", 0),
|
||||
collateral_count=perf_data.get("collateral_count", 0),
|
||||
is_flood_damaged=perf_data.get("is_flood_damaged", False),
|
||||
is_fire_damaged=perf_data.get("is_fire_damaged", False),
|
||||
is_total_loss=perf_data.get("is_total_loss", False),
|
||||
usage_history=perf_data.get("usage_history"),
|
||||
is_rental_used=perf_data.get("is_rental_used", False),
|
||||
engine_status=perf_data.get("engine_status"),
|
||||
transmission_status=perf_data.get("transmission_status"),
|
||||
power_delivery_status=perf_data.get("power_delivery_status"),
|
||||
steering_status=perf_data.get("steering_status"),
|
||||
brake_status=perf_data.get("brake_status"),
|
||||
electrical_status=perf_data.get("electrical_status"),
|
||||
fuel_system_status=perf_data.get("fuel_system_status"),
|
||||
raw_data=perf_data,
|
||||
raw_html=result.get("raw_html", "")[:50000],
|
||||
report_image_url=perf_data.get("report_image_url"),
|
||||
)
|
||||
db.add(performance_check)
|
||||
db.commit()
|
||||
|
||||
print(f"Performance check saved for car {car_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
db.rollback()
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def main():
|
||||
# 모든 차량 조회
|
||||
db = SessionLocal()
|
||||
cars = db.query(Car).filter(
|
||||
Car.source == "carmodoo",
|
||||
Car.source_id.isnot(None)
|
||||
).all()
|
||||
db.close()
|
||||
|
||||
print(f"Found {len(cars)} cars from carmodoo")
|
||||
|
||||
for car in cars:
|
||||
print(f"\n--- Processing Car ID: {car.id}, Source ID: {car.source_id} ---")
|
||||
await fetch_and_save_performance_check(car.id, car.source_id)
|
||||
await asyncio.sleep(1) # 서버 부하 방지
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
48
backend/fix_pdf.py
Normal file
48
backend/fix_pdf.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import asyncio
|
||||
import sqlite3
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
|
||||
os.chdir(r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
|
||||
|
||||
from app.services.pdf_service import capture_performance_check_pdf
|
||||
|
||||
async def fix_missing_pdfs():
|
||||
conn = sqlite3.connect('autonet.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Find cars with check_number but no pdf_path
|
||||
cursor.execute("""
|
||||
SELECT car_id, check_number, pdf_path
|
||||
FROM car_performance_checks
|
||||
WHERE check_number IS NOT NULL AND check_number != ''
|
||||
AND (pdf_path IS NULL OR pdf_path = '')
|
||||
""")
|
||||
|
||||
missing = cursor.fetchall()
|
||||
print(f"PDF 없는 성능점검 레코드: {len(missing)}개")
|
||||
|
||||
for car_id, check_num, pdf_path in missing:
|
||||
print(f"\n차량 {car_id}: check_num={check_num}")
|
||||
print(f" PDF 생성 중...")
|
||||
|
||||
try:
|
||||
pdf = await capture_performance_check_pdf(check_num, car_id)
|
||||
if pdf:
|
||||
cursor.execute(
|
||||
"UPDATE car_performance_checks SET pdf_path = ? WHERE car_id = ?",
|
||||
(pdf, car_id)
|
||||
)
|
||||
conn.commit()
|
||||
print(f" [OK] PDF: {pdf}")
|
||||
else:
|
||||
print(f" [FAIL] PDF generation failed")
|
||||
except Exception as e:
|
||||
print(f" [ERROR] {e}")
|
||||
|
||||
conn.close()
|
||||
print("\n완료!")
|
||||
|
||||
asyncio.run(fix_missing_pdfs())
|
||||
145
backend/frame_0_after.html
Normal file
145
backend/frame_0_after.html
Normal file
@@ -0,0 +1,145 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html xmlns="http://www.w3.org/1999/xhtml"><head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=euc-kr">
|
||||
<title>카모두 - 딜러매물 공유시스템</title>
|
||||
<link href="/css/layout.css?ver=20241018" rel="stylesheet" type="text/css">
|
||||
<link href="/css/sub.css?ver=20241018" rel="stylesheet" type="text/css">
|
||||
<link href="/css/sub_search.css?ver=20241018" rel="stylesheet" type="text/css">
|
||||
<script type="text/javascript" async="" src="https://www.google-analytics.com/analytics.js"></script><script type="text/javascript" async="" src="https://www.googletagmanager.com/gtag/js?id=G-KQMHL5VC6S&cx=c&gtm=4e5ca1"></script><script src="/common/default.js?ver=20241018" type="text/javascript"></script>
|
||||
<script src="/common/jquery.js?ver=20241018" type="text/javascript"></script>
|
||||
<script src="/js/jsapi.js?ver=20241018" type="text/javascript"></script>
|
||||
<script src="/common/carSearch.js?ver=20241018" type="text/javascript"></script>
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
// container 실제 높이 설정
|
||||
$('.container').css('height',realContainerHeight()+'px');
|
||||
|
||||
//$("#iframeLayer").css('height',realContainerHeight()+'px');
|
||||
setIframeUrl();
|
||||
});
|
||||
|
||||
function setIframeUrl() {
|
||||
//console.log($('.container').height());
|
||||
var h = $('.container').height() - 0;
|
||||
//h = realContainerHeight() - 50;
|
||||
$("#iframeLayer").html("<iframe name='dataIframe' id='dataIframe' src='https://api.autobegins.com/cp/?k=edc921b19788b782f6760a1fc0a6588c0553a4aa2cfbfb03be8c0433165960b4' style='margin:0px;padding:0px;border:0px;width:100%; height:" + h + "px;'></iframe>");
|
||||
}
|
||||
|
||||
function realContainerHeight(){
|
||||
var scrH = $(window).height();
|
||||
var footerH = 0;
|
||||
if($('.header_n').length != 0){
|
||||
footerH = $('.header_n').outerHeight(true);
|
||||
}
|
||||
return scrH - footerH;
|
||||
}
|
||||
</script>
|
||||
<!-- favicon set -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/favicon/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon/favicon-16x16.png">
|
||||
<link rel="manifest" href="/images/favicon/site.webmanifest">
|
||||
<link rel="mask-icon" href="/images/favicon/safari-pinned-tab.svg" color="#3fcdff">
|
||||
<meta name="msapplication-TileColor" content="#3fcdff">
|
||||
<meta name="theme-color" content="#3fcdff">
|
||||
<!--// favicon set 예외 아이디 입력(구글애널리틱스 동작안하게 처리) -->
|
||||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
||||
<script async="" src="https://www.googletagmanager.com/gtag/js?id=UA-141060518-2"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'UA-141060518-2');
|
||||
</script>
|
||||
<script type="text/javascript" src="//wcs.naver.net/wcslog.js"></script><script type="text/javascript" async="" src="https://ssl.pstatic.net/melona/libs/gfp-nac-module/synchronizer.js"></script>
|
||||
<script type="text/javascript">
|
||||
if(!wcs_add) var wcs_add = {};
|
||||
wcs_add["wa"] = "a47b8ec8c031b8";
|
||||
if(window.wcs) {
|
||||
wcs_do();
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="temptempLayer"></div>
|
||||
<script src="/js/checkbox.js?ver=20241018" type="text/javascript"></script>
|
||||
<div class="header_wrap_n">
|
||||
<iframe name="sessionCatch_iframe" src="/common/sessionCatch.html" width="0" height="0" frameborder="0" scrolling="NO"></iframe>
|
||||
<div class="header_n">
|
||||
<div class="cont">
|
||||
<div class="logo" onclick="location.href='/main.html'"></div>
|
||||
<div class="gnb_n">
|
||||
<ul>
|
||||
<li><a href="/car/carListPhoto.html"><span class="m2">매물조회</span></a>
|
||||
|
||||
<ul class="sub1">
|
||||
<li><a href="/car/carListPhoto.html">매물조회</a></li>
|
||||
<li><a href="/car/carListSoldNew.html">팔린매물</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<!--<li ><a href="/car/speedList.html"><span class="m4">급구차량</span></a></li>-->
|
||||
<li><span class="m5">중고차상담</span>
|
||||
<ul class="sub5">
|
||||
<li><a href="/consult/list.html">판매상담</a></li>
|
||||
<li><a href="/consult/list.html?code=buy">구매상담</a></li>
|
||||
<li><a href="/consult/sms.html">문자상담내역</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="on"><a href="/info/totalSearch.html"><span class="m6">조회서비스</span></a>
|
||||
<ul class="sub6">
|
||||
<li><a href="/info/search_ab.html">상세사양조회</a></li>
|
||||
<!-- <li><a href="/info/totalSearch.html">사양/원부조회</a></li>
|
||||
<li><a href="/info/carinfoSearch.html">사양조회</a></li> -->
|
||||
<li><a href="/info/wonbuSearch.html">간편원부조회</a></li>
|
||||
<li><a href="/info/newcarIndex.html">신차정보</a></li>
|
||||
<li><a href="/info/price.html">중고차시세</a></li>
|
||||
<li><a href="/info/dealerSearch.html">종사원확인</a></li>
|
||||
<li><a href="/info/companySearch.html">상사확인</a></li>
|
||||
<li><a href="/info/carCheck.html">성능점검인쇄</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li id="m6"><a href="/board/board.html?code=notice"><span class="m7">게시판</span></a>
|
||||
<ul class="sub7">
|
||||
<li><a href="/board/board.html?code=notice">공지사항</a></li>
|
||||
<li><a href="/board/board.html?code=qna">건의함</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="/car/sangsaList.html"><span class="m3">상사매물</span></a></li>
|
||||
<li id="m7"><a href="/mypage/mycarList.html?pStatus=1"><span class="m8">내차관리</span></a>
|
||||
<ul class="sub8">
|
||||
<li><a href="/mypage/mycarList.html?pStatus=1">나의매물</a></li>
|
||||
<li><a href="/mypage/mycarList.html?pStatus=3">계약중</a></li>
|
||||
<li><a href="/car/interestListNew.html">관심차량</a></li>
|
||||
<!-- <li><a href="/car/searchListNew.html">최근검색</a></li> -->
|
||||
<li><a href="/mypage/settingLogin.html">환경설정</a></li>
|
||||
<li><a href="/mypage/regModify.html">나의 정보관리</a></li>
|
||||
<!-- <li><a href="/mypage/carRegist.html">매물등록</a></li> -->
|
||||
<!-- <li><span>광고관리</span>
|
||||
<ul>
|
||||
<li><a href="/mypage/adcarList.html">광고차량관리</a></li>
|
||||
<li><a href="/mypage/adCompany.html">광고업체설정</a></li>
|
||||
</ul>
|
||||
</li> -->
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="header_info">
|
||||
<div class="customer">
|
||||
<span>시스템 문의전화</span>
|
||||
031-242-8940
|
||||
</div>
|
||||
<a href="/member/logout.html" class="btn_logout">로그아웃</a>
|
||||
</div>
|
||||
<!--<strong>홍사성</strong>님-->
|
||||
<a href="javascript:window.history.back()" class="btn_back">뒤로가기</a>
|
||||
</div>
|
||||
</div><!--// header-->
|
||||
</div>
|
||||
<div class="container" id="iframeLayer" style="height: 670px;"><iframe name="dataIframe" id="dataIframe" src="https://api.autobegins.com/cp/?k=edc921b19788b782f6760a1fc0a6588c0553a4aa2cfbfb03be8c0433165960b4" style="margin:0px;padding:0px;border:0px;width:100%; height:670px;"></iframe></div>
|
||||
|
||||
|
||||
|
||||
|
||||
</body></html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user