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:
AutonetSellCar Deploy
2025-12-30 13:24:39 +09:00
commit 1f0dcb1ddb
224 changed files with 55119 additions and 0 deletions

81
.gitignore vendored Normal file
View 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

View 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*

File diff suppressed because it is too large Load Diff

686
CLAUDE.md Normal file
View 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
View 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*

View 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
View 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
View 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
View 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*

View 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
View 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
View 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
View File

@@ -0,0 +1 @@
# Carmodoo Agent

View 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
View 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
View 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
View 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"]

View File

@@ -0,0 +1 @@
Error: Failed to fetch

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

View 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
View 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
View File

@@ -0,0 +1 @@
# AutonetSellCar Backend API

View File

@@ -0,0 +1 @@
# API Routes

546
backend/app/api/auth.py Normal file
View 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

File diff suppressed because it is too large Load Diff

340
backend/app/api/cars.py Normal file
View 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
View 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"
}

View 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
View 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}

View 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

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

View 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
}

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

View 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

File diff suppressed because it is too large Load Diff

View 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

View 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}

View 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
View 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}

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

View 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
View 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
View 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"}

View 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",
]

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

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

View 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,
},
]

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

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

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

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

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

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

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

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

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

View 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
View 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"]

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

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

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

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

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

View 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

View 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

View 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

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

View 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

View 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

View 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}

View 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

View 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

View 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

View 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

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

View 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

View 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

View 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

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

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

View 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

View 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

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

View 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
View 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
View 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
View 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!')

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

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

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

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

View 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
View 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
View 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&amp;cx=c&amp;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