From 1f0dcb1ddb162b1e58a56b4d147f10c1efdaf23e Mon Sep 17 00:00:00 2001 From: AutonetSellCar Deploy Date: Tue, 30 Dec 2025 13:24:39 +0900 Subject: [PATCH] Initial commit: AutonetSellCar platform with deployment system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 81 + AUTONETSELLCAR_DEVELOPMENT_PLAN.md | 751 ++++ AutonetSellcar_Platform_Plan.md | 2413 +++++++++++++ CLAUDE.md | 686 ++++ DEPLOYMENT_GUIDE.md | 330 ++ Doc/FILE_SERVER_SETUP_2025-12-05.md | 867 +++++ Doc/관세.md | 157 + Doc/에스크로.md | 566 +++ PROGRESS_ReadMe.md | 592 ++++ SERVER_INFRASTRUCTURE_PLAN.md | 611 ++++ agent/Dockerfile | 22 + agent/requirements.txt | 6 + agent/src/__init__.py | 1 + agent/src/carmodoo_client.py | 294 ++ agent/src/sync_agent.py | 159 + backend/.env.example | 40 + backend/Dockerfile | 23 + backend/SearchCarNum_full.js | 1 + backend/add_exchange_rate_columns.py | 36 + backend/add_pdf_path_column.py | 21 + backend/analyze_page.py | 56 + backend/app/__init__.py | 1 + backend/app/api/__init__.py | 1 + backend/app/api/auth.py | 546 +++ backend/app/api/carmodoo.py | 2691 +++++++++++++++ backend/app/api/cars.py | 340 ++ backend/app/api/cc.py | 886 +++++ backend/app/api/dashboard.py | 443 +++ backend/app/api/dealer.py | 254 ++ backend/app/api/exchange_rate.py | 247 ++ backend/app/api/hero_banners.py | 265 ++ backend/app/api/inquiries.py | 326 ++ backend/app/api/notification.py | 363 ++ backend/app/api/push.py | 276 ++ backend/app/api/referral.py | 192 ++ backend/app/api/settings.py | 72 + backend/app/api/translations.py | 1018 ++++++ backend/app/api/vehicle_requests.py | 385 +++ backend/app/api/vehicle_share.py | 286 ++ backend/app/api/verification.py | 231 ++ backend/app/api/visitor.py | 334 ++ backend/app/api/withdrawal.py | 217 ++ backend/app/config.py | 80 + backend/app/data/carmodoo_makers_models.json | 174 + backend/app/database.py | 27 + backend/app/main.py | 168 + backend/app/models/__init__.py | 64 + backend/app/models/cache.py | 75 + backend/app/models/car.py | 110 + backend/app/models/car_specification.py | 59 + backend/app/models/cc_package.py | 48 + backend/app/models/dealer.py | 85 + backend/app/models/exchange_rate.py | 46 + backend/app/models/hero_banner.py | 67 + backend/app/models/inquiry.py | 79 + backend/app/models/notification.py | 37 + backend/app/models/performance_check.py | 119 + backend/app/models/push_subscription.py | 48 + backend/app/models/referral.py | 37 + backend/app/models/settings.py | 45 + backend/app/models/translation.py | 28 + backend/app/models/user.py | 138 + backend/app/models/vehicle_request.py | 106 + backend/app/models/vehicle_share.py | 75 + backend/app/models/visitor.py | 111 + backend/app/models/withdrawal.py | 35 + backend/app/schemas/__init__.py | 81 + backend/app/schemas/car.py | 185 + backend/app/schemas/dealer.py | 80 + backend/app/schemas/hero_banner.py | 101 + backend/app/schemas/inquiry.py | 67 + backend/app/schemas/notification.py | 44 + backend/app/schemas/referral.py | 41 + backend/app/schemas/settings.py | 37 + backend/app/schemas/translation.py | 52 + backend/app/schemas/user.py | 62 + backend/app/schemas/vehicle_request.py | 122 + backend/app/schemas/vehicle_share.py | 69 + backend/app/schemas/withdrawal.py | 44 + backend/app/services/cache_service.py | 310 ++ backend/app/services/exchange_rate_service.py | 305 ++ backend/app/services/pdf_service.py | 356 ++ backend/app/services/sensitive_filter.py | 181 + backend/app/services/spec_service.py | 364 ++ backend/app/services/translation_service.py | 174 + backend/app/services/verification_service.py | 313 ++ backend/app/services/visitor_service.py | 299 ++ backend/check_api_schema.py | 11 + backend/check_banner_cars.py | 42 + backend/check_config.py | 8 + backend/check_data.py | 47 + backend/check_db.py | 81 + backend/check_db_status.py | 46 + backend/check_dbs.py | 49 + backend/check_pdf_status.py | 40 + backend/fetch_perf_check.py | 91 + backend/fetch_perf_direct.py | 97 + backend/fetch_performance_check.py | 107 + backend/fix_pdf.py | 48 + backend/frame_0_after.html | 145 + backend/frame_1_after.html | 62 + backend/frame_2_after.html | 41 + backend/frame_3_after.html | 23 + backend/frame_after_search_0.html | 145 + backend/generate_pdf.py | 43 + backend/main_page_content.html | 145 + backend/migrate_translations.py | 40 + backend/regenerate_pdf.py | 49 + backend/requirements.txt | 17 + backend/reset_password.py | 55 + backend/result_117더3590.html | 202 ++ backend/result_117더3590.png | Bin 0 -> 86288 bytes backend/scripts/add_pdf_path_column.py | 63 + .../migrate_performance_check_to_pdf.py | 154 + backend/searchCarNum_function.js | 70 + backend/search_ab_debug.html | 147 + backend/search_iframe_content.html | 23 + backend/setup_admin.py | 140 + backend/spec_after_click.png | Bin 0 -> 105396 bytes backend/spec_debug8.png | Bin 0 -> 105396 bytes backend/spec_headed_result.png | Bin 0 -> 145970 bytes backend/spec_result.png | Bin 0 -> 105396 bytes backend/spec_result_after.png | Bin 0 -> 105396 bytes backend/spec_result_js.png | Bin 0 -> 105396 bytes backend/tests/__init__.py | 1 + .../test_integration_performance_check.py | 232 ++ backend/tests/test_performance_check.py | 216 ++ backend/update_inquiry_db.py | 49 + deploy-remote.sh | 87 + deploy/deploy-staging.sh | 70 + deploy/deploy.sh | 256 ++ deploy/post-receive | 53 + deploy/setup-server2.sh | 296 ++ docker-compose.production.yml | 61 + docker-compose.staging.yml | 44 + docs/BUSINESS_LOGIC.md | 179 + docs/DEPENDENCIES.md | 194 ++ frontend/Dockerfile | 46 + frontend/next-env.d.ts | 5 + frontend/next.config.js | 38 + frontend/package-lock.json | 2031 +++++++++++ frontend/package.json | 32 + frontend/postcss.config.js | 6 + frontend/public/sw.js | 92 + frontend/src/app/about/page.tsx | 287 ++ frontend/src/app/admin/cars/page.tsx | 2330 +++++++++++++ .../app/admin/dealer-translations/page.tsx | 396 +++ frontend/src/app/admin/dealers/page.tsx | 405 +++ frontend/src/app/admin/hero-banners/page.tsx | 543 +++ frontend/src/app/admin/inquiries/page.tsx | 432 +++ frontend/src/app/admin/layout.tsx | 195 ++ frontend/src/app/admin/login/page.tsx | 139 + frontend/src/app/admin/notifications/page.tsx | 199 ++ frontend/src/app/admin/page.tsx | 408 +++ frontend/src/app/admin/payments/page.tsx | 403 +++ frontend/src/app/admin/purchased/page.tsx | 521 +++ frontend/src/app/admin/settings/page.tsx | 589 ++++ frontend/src/app/admin/translations/page.tsx | 742 ++++ frontend/src/app/admin/users/page.tsx | 616 ++++ .../src/app/admin/vehicle-requests/page.tsx | 525 +++ frontend/src/app/admin/visitor-stats/page.tsx | 410 +++ frontend/src/app/admin/withdrawals/page.tsx | 356 ++ frontend/src/app/cars/[id]/page.tsx | 1073 ++++++ frontend/src/app/cars/page.tsx | 46 + frontend/src/app/cc/page.tsx | 416 +++ frontend/src/app/cc/success/page.tsx | 205 ++ frontend/src/app/charge/page.tsx | 386 +++ frontend/src/app/contact/page.tsx | 610 ++++ frontend/src/app/cost/page.tsx | 687 ++++ frontend/src/app/dealer/apply/page.tsx | 373 ++ frontend/src/app/dealer/my-card/page.tsx | 254 ++ frontend/src/app/exchange-rate/page.tsx | 487 +++ frontend/src/app/find-my-car/page.tsx | 331 ++ frontend/src/app/globals.css | 19 + frontend/src/app/inquiry/page.tsx | 231 ++ frontend/src/app/layout.tsx | 27 + frontend/src/app/login/page.tsx | 119 + frontend/src/app/my-inquiries/[id]/page.tsx | 231 ++ frontend/src/app/my-inquiries/page.tsx | 219 ++ frontend/src/app/my-request/page.tsx | 291 ++ frontend/src/app/my-shares/page.tsx | 310 ++ frontend/src/app/notifications/page.tsx | 317 ++ frontend/src/app/page.tsx | 120 + frontend/src/app/profile/page.tsx | 390 +++ frontend/src/app/register/page.tsx | 411 +++ frontend/src/app/request/page.tsx | 520 +++ .../src/app/settings/notifications/page.tsx | 270 ++ frontend/src/app/share/[code]/page.tsx | 295 ++ frontend/src/app/vehicle-request/page.tsx | 771 +++++ frontend/src/app/withdrawal/page.tsx | 370 ++ frontend/src/components/AuthProvider.tsx | 33 + frontend/src/components/CarCard.tsx | 87 + frontend/src/components/CarSearchTable.tsx | 191 ++ .../src/components/CarmodooSearchFilters.tsx | 391 +++ frontend/src/components/ClientLayout.tsx | 21 + frontend/src/components/FilmStripSlider.tsx | 408 +++ frontend/src/components/Footer.tsx | 48 + frontend/src/components/Header.tsx | 398 +++ frontend/src/components/LanguageSelector.tsx | 93 + .../src/components/PhoneVerificationModal.tsx | 210 ++ frontend/src/components/SearchFilters.tsx | 209 ++ frontend/src/components/SidebarLayout.tsx | 82 + frontend/src/lib/api.ts | 1609 +++++++++ frontend/src/lib/exchangeRateStore.ts | 114 + frontend/src/lib/i18n.ts | 3021 +++++++++++++++++ frontend/src/lib/store.ts | 30 + frontend/src/lib/useTranslate.ts | 134 + frontend/src/lib/useVisitorTracking.ts | 91 + frontend/src/types/index.ts | 165 + frontend/tailwind.config.ts | 29 + frontend/tsconfig.json | 26 + images/pdf화면캡처.png | Bin 0 -> 264508 bytes images/pdf화면캡처2.png | Bin 0 -> 201467 bytes images/pdf화면캡처3.png | Bin 0 -> 108356 bytes images/pdf화면캡처4.png | Bin 0 -> 246737 bytes images/pdf화면캡처5.png | Bin 0 -> 241886 bytes images/spec_debug.png | Bin 0 -> 108725 bytes images/spec_result.png | Bin 0 -> 104949 bytes images/spec_result3.png | Bin 0 -> 105098 bytes images/spec_result4.png | Bin 0 -> 86865 bytes images/spec_search_screenshot.png | Bin 0 -> 104535 bytes images/상세사양조회서비스.png | Bin 0 -> 192669 bytes images/지도.png | Bin 0 -> 975739 bytes images/한국차량수배과정.png | Bin 0 -> 109283 bytes 224 files changed, 55119 insertions(+) create mode 100644 .gitignore create mode 100644 AUTONETSELLCAR_DEVELOPMENT_PLAN.md create mode 100644 AutonetSellcar_Platform_Plan.md create mode 100644 CLAUDE.md create mode 100644 DEPLOYMENT_GUIDE.md create mode 100644 Doc/FILE_SERVER_SETUP_2025-12-05.md create mode 100644 Doc/관세.md create mode 100644 Doc/에스크로.md create mode 100644 PROGRESS_ReadMe.md create mode 100644 SERVER_INFRASTRUCTURE_PLAN.md create mode 100644 agent/Dockerfile create mode 100644 agent/requirements.txt create mode 100644 agent/src/__init__.py create mode 100644 agent/src/carmodoo_client.py create mode 100644 agent/src/sync_agent.py create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/SearchCarNum_full.js create mode 100644 backend/add_exchange_rate_columns.py create mode 100644 backend/add_pdf_path_column.py create mode 100644 backend/analyze_page.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/auth.py create mode 100644 backend/app/api/carmodoo.py create mode 100644 backend/app/api/cars.py create mode 100644 backend/app/api/cc.py create mode 100644 backend/app/api/dashboard.py create mode 100644 backend/app/api/dealer.py create mode 100644 backend/app/api/exchange_rate.py create mode 100644 backend/app/api/hero_banners.py create mode 100644 backend/app/api/inquiries.py create mode 100644 backend/app/api/notification.py create mode 100644 backend/app/api/push.py create mode 100644 backend/app/api/referral.py create mode 100644 backend/app/api/settings.py create mode 100644 backend/app/api/translations.py create mode 100644 backend/app/api/vehicle_requests.py create mode 100644 backend/app/api/vehicle_share.py create mode 100644 backend/app/api/verification.py create mode 100644 backend/app/api/visitor.py create mode 100644 backend/app/api/withdrawal.py create mode 100644 backend/app/config.py create mode 100644 backend/app/data/carmodoo_makers_models.json create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/cache.py create mode 100644 backend/app/models/car.py create mode 100644 backend/app/models/car_specification.py create mode 100644 backend/app/models/cc_package.py create mode 100644 backend/app/models/dealer.py create mode 100644 backend/app/models/exchange_rate.py create mode 100644 backend/app/models/hero_banner.py create mode 100644 backend/app/models/inquiry.py create mode 100644 backend/app/models/notification.py create mode 100644 backend/app/models/performance_check.py create mode 100644 backend/app/models/push_subscription.py create mode 100644 backend/app/models/referral.py create mode 100644 backend/app/models/settings.py create mode 100644 backend/app/models/translation.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/models/vehicle_request.py create mode 100644 backend/app/models/vehicle_share.py create mode 100644 backend/app/models/visitor.py create mode 100644 backend/app/models/withdrawal.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/car.py create mode 100644 backend/app/schemas/dealer.py create mode 100644 backend/app/schemas/hero_banner.py create mode 100644 backend/app/schemas/inquiry.py create mode 100644 backend/app/schemas/notification.py create mode 100644 backend/app/schemas/referral.py create mode 100644 backend/app/schemas/settings.py create mode 100644 backend/app/schemas/translation.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/schemas/vehicle_request.py create mode 100644 backend/app/schemas/vehicle_share.py create mode 100644 backend/app/schemas/withdrawal.py create mode 100644 backend/app/services/cache_service.py create mode 100644 backend/app/services/exchange_rate_service.py create mode 100644 backend/app/services/pdf_service.py create mode 100644 backend/app/services/sensitive_filter.py create mode 100644 backend/app/services/spec_service.py create mode 100644 backend/app/services/translation_service.py create mode 100644 backend/app/services/verification_service.py create mode 100644 backend/app/services/visitor_service.py create mode 100644 backend/check_api_schema.py create mode 100644 backend/check_banner_cars.py create mode 100644 backend/check_config.py create mode 100644 backend/check_data.py create mode 100644 backend/check_db.py create mode 100644 backend/check_db_status.py create mode 100644 backend/check_dbs.py create mode 100644 backend/check_pdf_status.py create mode 100644 backend/fetch_perf_check.py create mode 100644 backend/fetch_perf_direct.py create mode 100644 backend/fetch_performance_check.py create mode 100644 backend/fix_pdf.py create mode 100644 backend/frame_0_after.html create mode 100644 backend/frame_1_after.html create mode 100644 backend/frame_2_after.html create mode 100644 backend/frame_3_after.html create mode 100644 backend/frame_after_search_0.html create mode 100644 backend/generate_pdf.py create mode 100644 backend/main_page_content.html create mode 100644 backend/migrate_translations.py create mode 100644 backend/regenerate_pdf.py create mode 100644 backend/requirements.txt create mode 100644 backend/reset_password.py create mode 100644 backend/result_117더3590.html create mode 100644 backend/result_117더3590.png create mode 100644 backend/scripts/add_pdf_path_column.py create mode 100644 backend/scripts/migrate_performance_check_to_pdf.py create mode 100644 backend/searchCarNum_function.js create mode 100644 backend/search_ab_debug.html create mode 100644 backend/search_iframe_content.html create mode 100644 backend/setup_admin.py create mode 100644 backend/spec_after_click.png create mode 100644 backend/spec_debug8.png create mode 100644 backend/spec_headed_result.png create mode 100644 backend/spec_result.png create mode 100644 backend/spec_result_after.png create mode 100644 backend/spec_result_js.png create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/test_integration_performance_check.py create mode 100644 backend/tests/test_performance_check.py create mode 100644 backend/update_inquiry_db.py create mode 100644 deploy-remote.sh create mode 100644 deploy/deploy-staging.sh create mode 100644 deploy/deploy.sh create mode 100644 deploy/post-receive create mode 100644 deploy/setup-server2.sh create mode 100644 docker-compose.production.yml create mode 100644 docker-compose.staging.yml create mode 100644 docs/BUSINESS_LOGIC.md create mode 100644 docs/DEPENDENCIES.md create mode 100644 frontend/Dockerfile create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/next.config.js create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/sw.js create mode 100644 frontend/src/app/about/page.tsx create mode 100644 frontend/src/app/admin/cars/page.tsx create mode 100644 frontend/src/app/admin/dealer-translations/page.tsx create mode 100644 frontend/src/app/admin/dealers/page.tsx create mode 100644 frontend/src/app/admin/hero-banners/page.tsx create mode 100644 frontend/src/app/admin/inquiries/page.tsx create mode 100644 frontend/src/app/admin/layout.tsx create mode 100644 frontend/src/app/admin/login/page.tsx create mode 100644 frontend/src/app/admin/notifications/page.tsx create mode 100644 frontend/src/app/admin/page.tsx create mode 100644 frontend/src/app/admin/payments/page.tsx create mode 100644 frontend/src/app/admin/purchased/page.tsx create mode 100644 frontend/src/app/admin/settings/page.tsx create mode 100644 frontend/src/app/admin/translations/page.tsx create mode 100644 frontend/src/app/admin/users/page.tsx create mode 100644 frontend/src/app/admin/vehicle-requests/page.tsx create mode 100644 frontend/src/app/admin/visitor-stats/page.tsx create mode 100644 frontend/src/app/admin/withdrawals/page.tsx create mode 100644 frontend/src/app/cars/[id]/page.tsx create mode 100644 frontend/src/app/cars/page.tsx create mode 100644 frontend/src/app/cc/page.tsx create mode 100644 frontend/src/app/cc/success/page.tsx create mode 100644 frontend/src/app/charge/page.tsx create mode 100644 frontend/src/app/contact/page.tsx create mode 100644 frontend/src/app/cost/page.tsx create mode 100644 frontend/src/app/dealer/apply/page.tsx create mode 100644 frontend/src/app/dealer/my-card/page.tsx create mode 100644 frontend/src/app/exchange-rate/page.tsx create mode 100644 frontend/src/app/find-my-car/page.tsx create mode 100644 frontend/src/app/globals.css create mode 100644 frontend/src/app/inquiry/page.tsx create mode 100644 frontend/src/app/layout.tsx create mode 100644 frontend/src/app/login/page.tsx create mode 100644 frontend/src/app/my-inquiries/[id]/page.tsx create mode 100644 frontend/src/app/my-inquiries/page.tsx create mode 100644 frontend/src/app/my-request/page.tsx create mode 100644 frontend/src/app/my-shares/page.tsx create mode 100644 frontend/src/app/notifications/page.tsx create mode 100644 frontend/src/app/page.tsx create mode 100644 frontend/src/app/profile/page.tsx create mode 100644 frontend/src/app/register/page.tsx create mode 100644 frontend/src/app/request/page.tsx create mode 100644 frontend/src/app/settings/notifications/page.tsx create mode 100644 frontend/src/app/share/[code]/page.tsx create mode 100644 frontend/src/app/vehicle-request/page.tsx create mode 100644 frontend/src/app/withdrawal/page.tsx create mode 100644 frontend/src/components/AuthProvider.tsx create mode 100644 frontend/src/components/CarCard.tsx create mode 100644 frontend/src/components/CarSearchTable.tsx create mode 100644 frontend/src/components/CarmodooSearchFilters.tsx create mode 100644 frontend/src/components/ClientLayout.tsx create mode 100644 frontend/src/components/FilmStripSlider.tsx create mode 100644 frontend/src/components/Footer.tsx create mode 100644 frontend/src/components/Header.tsx create mode 100644 frontend/src/components/LanguageSelector.tsx create mode 100644 frontend/src/components/PhoneVerificationModal.tsx create mode 100644 frontend/src/components/SearchFilters.tsx create mode 100644 frontend/src/components/SidebarLayout.tsx create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/exchangeRateStore.ts create mode 100644 frontend/src/lib/i18n.ts create mode 100644 frontend/src/lib/store.ts create mode 100644 frontend/src/lib/useTranslate.ts create mode 100644 frontend/src/lib/useVisitorTracking.ts create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.json create mode 100644 images/pdf화면캡처.png create mode 100644 images/pdf화면캡처2.png create mode 100644 images/pdf화면캡처3.png create mode 100644 images/pdf화면캡처4.png create mode 100644 images/pdf화면캡처5.png create mode 100644 images/spec_debug.png create mode 100644 images/spec_result.png create mode 100644 images/spec_result3.png create mode 100644 images/spec_result4.png create mode 100644 images/spec_search_screenshot.png create mode 100644 images/상세사양조회서비스.png create mode 100644 images/지도.png create mode 100644 images/한국차량수배과정.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29b1486 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AUTONETSELLCAR_DEVELOPMENT_PLAN.md b/AUTONETSELLCAR_DEVELOPMENT_PLAN.md new file mode 100644 index 0000000..7d63583 --- /dev/null +++ b/AUTONETSELLCAR_DEVELOPMENT_PLAN.md @@ -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 ( + + {duplicatedImages.map((img, i) => ( +
+ {img.title} +
+ ))} +
+ ); +}; +``` + +### 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* diff --git a/AutonetSellcar_Platform_Plan.md b/AutonetSellcar_Platform_Plan.md new file mode 100644 index 0000000..acc4bd5 --- /dev/null +++ b/AutonetSellcar_Platform_Plan.md @@ -0,0 +1,2413 @@ +# 몽골 중고차 수출 플랫폼 개발 계획서 + +## 📋 프로젝트 개요 + +| 항목 | 내용 | +|------|------| +| **프로젝트명** | MongolCar - 몽골 중고차 수출 플랫폼 | +| **목적** | 한국 중고차를 몽골 고객에게 실시간 검색/판매하는 B2C 플랫폼 | +| **대상 고객** | 몽골, 러시아, 카자흐스탄 등 중앙아시아 고객 | +| **개발 기간** | 8~10주 | +| **개발 도구** | Claude Code | + +--- + +## 🏗️ 시스템 아키텍처 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 전체 시스템 구성도 │ +└─────────────────────────────────────────────────────────────────────────────┘ + + ┌─────────────────────────────────────┐ + │ 몽골/러시아 사용자 (웹브라우저) │ + │ - 차량 검색 (차종/연식/마일리지) │ + │ - 상세보기 (포인트 차감) │ + │ - 실시간 채팅 (AI 번역) │ + └──────────────────┬──────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 우분투 웹서버 (Ryzen 7700, 32GB) │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ Next.js │ │ Node.js │ │ Socket.io │ │ Claude API │ │ +│ │ Frontend │ │ API Server │ │ 실시간채팅 │ │ 번역+FAQ 챗봇 │ │ +│ │ (다국어) │ │ │ │ │ │ │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────────────┘ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────────────────┐ │ +│ │ Nginx │ │ Redis │ │ 이미지 저장소 │ │ +│ │ Reverse │ │ Cache │ │ /var/www/car-images/ │ │ +│ │ Proxy │ │ Session │ │ 1000대 × 20장 = ~10GB │ │ +│ └─────────────┘ └─────────────┘ └─────────────────────────────────────┘ │ +└─────────────────────────────────────┬───────────────────────────────────────┘ + │ + ┌─────────────────┴─────────────────┐ + │ 내부 네트워크 │ + └─────────────────┬─────────────────┘ + │ + ┌─────────────────────────────┴─────────────────────────────┐ + │ │ + ▼ ▼ +┌───────────────────────────────────┐ ┌───────────────────────────────────┐ +│ 우분투 DB서버 (Ryzen 7700, 32GB) │ │ Windows PC (사무실) │ +│ ┌─────────────────────────────┐ │ │ ┌─────────────────────────────┐ │ +│ │ PostgreSQL 16 │ │ │ │ 카모두 프로그램 │ │ +│ │ - vehicles (차량정보) │ │ │ │ (GGKucar.exe) │ │ +│ │ - users (회원) │ │ │ └──────────────┬──────────────┘ │ +│ │ - payments (결제) │ │ │ │ │ +│ │ - chat_messages (채팅) │ │ │ ┌──────────────▼──────────────┐ │ +│ │ - view_history (열람기록) │ │ │ │ Python 자동화 에이전트 │ │ +│ └─────────────────────────────┘ │ │ │ - pywinauto (UI 제어) │ │ +│ ┌─────────────────────────────┐ │ │ │ - API Client (서버 통신) │ │ +│ │ 백업 스토리지 (10TB) │ │ │ │ - 이미지 추출/업로드 │ │ +│ └─────────────────────────────┘ │ │ └─────────────────────────────┘ │ +└───────────────────────────────────┘ └───────────────────────────────────┘ +``` + +--- + +## 🖥️ 서버 인프라 + +### 서버 1: 웹서버 + +| 항목 | 사양 | +|------|------| +| **CPU** | AMD Ryzen 7 7700 | +| **RAM** | 32GB (64GB 중 할당) | +| **스토리지** | SSD (OS + 앱) + HDD 10TB 일부 | +| **OS** | Ubuntu 22.04 LTS | + +**설치 서비스:** +- Nginx (리버스 프록시, SSL) +- Node.js 20 LTS +- Next.js 14 +- Redis 7 +- PM2 + +### 서버 2: DB서버 + +| 항목 | 사양 | +|------|------| +| **CPU** | AMD Ryzen 7 7700 | +| **RAM** | 32GB (64GB 중 할당) | +| **스토리지** | SSD (DB) + HDD 10TB (백업) | +| **OS** | Ubuntu 22.04 LTS | + +**설치 서비스:** +- PostgreSQL 16 +- 자동 백업 스크립트 + +--- + +## 💻 기술 스택 + +### Frontend +| 기술 | 버전 | 용도 | +|------|------|------| +| Next.js | 14.x | React 프레임워크, SSR | +| TypeScript | 5.x | 타입 안전성 | +| Tailwind CSS | 3.x | 스타일링 | +| next-intl | latest | 다국어 (mn/en/ru/ko) | +| Socket.io-client | 4.x | 실시간 채팅 | +| React Query | 5.x | 서버 상태 관리 | +| Zustand | 4.x | 클라이언트 상태 관리 | + +### Backend +| 기술 | 버전 | 용도 | +|------|------|------| +| Node.js | 20 LTS | 런타임 | +| Express.js | 4.x | API 서버 | +| TypeScript | 5.x | 타입 안전성 | +| Socket.io | 4.x | 실시간 통신 | +| Prisma | 5.x | ORM | +| Redis | 7.x | 캐시, 세션, 큐 | +| Bull | 4.x | 작업 큐 | + +### Database +| 기술 | 버전 | 용도 | +|------|------|------| +| PostgreSQL | 16.x | 메인 데이터베이스 | + +### External APIs +| 서비스 | 용도 | +|--------|------| +| Claude API (Anthropic) | 번역 + FAQ 챗봇 | +| NOWPayments | USDT/USDC 결제 | +| ExchangeRate API | 환율 조회 | + +### Windows Agent +| 기술 | 버전 | 용도 | +|------|------|------| +| Python | 3.11+ | 에이전트 개발 | +| pywinauto | 0.6.8+ | UI 자동화 | +| aiohttp | 3.x | 비동기 HTTP | +| Pillow | 10.x | 이미지 처리 | + +--- + +## 📁 프로젝트 구조 + +``` +mongol-car-platform/ +├── README.md +├── docker-compose.yml +├── .env.example +│ +├── apps/ +│ ├── web/ # Next.js 프론트엔드 +│ │ ├── package.json +│ │ ├── next.config.js +│ │ ├── tailwind.config.js +│ │ ├── tsconfig.json +│ │ │ +│ │ ├── public/ +│ │ │ ├── locales/ # 다국어 파일 +│ │ │ │ ├── mn/ # 몽골어 +│ │ │ │ ├── en/ # 영어 +│ │ │ │ ├── ru/ # 러시아어 +│ │ │ │ └── ko/ # 한국어 +│ │ │ └── images/ +│ │ │ +│ │ ├── src/ +│ │ │ ├── app/ # App Router +│ │ │ │ ├── [locale]/ +│ │ │ │ │ ├── layout.tsx +│ │ │ │ │ ├── page.tsx # 메인 (검색) +│ │ │ │ │ ├── vehicles/ +│ │ │ │ │ │ ├── page.tsx # 검색 결과 +│ │ │ │ │ │ └── [id]/ +│ │ │ │ │ │ └── page.tsx # 차량 상세 +│ │ │ │ │ ├── auth/ +│ │ │ │ │ │ ├── login/page.tsx +│ │ │ │ │ │ └── register/page.tsx +│ │ │ │ │ ├── mypage/ +│ │ │ │ │ │ ├── page.tsx # 마이페이지 +│ │ │ │ │ │ ├── points/page.tsx # 포인트 내역 +│ │ │ │ │ │ ├── history/page.tsx # 열람 기록 +│ │ │ │ │ │ ├── orders/page.tsx # 주문 내역 +│ │ │ │ │ │ └── tracking/ +│ │ │ │ │ │ ├── page.tsx # 내차찾기 목록 +│ │ │ │ │ │ └── [orderId]/ +│ │ │ │ │ │ └── page.tsx # 상세 추적 +│ │ │ │ │ ├── payment/ +│ │ │ │ │ │ ├── page.tsx # 충전 페이지 +│ │ │ │ │ │ ├── success/page.tsx +│ │ │ │ │ │ └── cancel/page.tsx +│ │ │ │ │ └── chat/ +│ │ │ │ │ └── [roomId]/page.tsx # 채팅방 +│ │ │ │ └── api/ # API Routes (필요시) +│ │ │ │ +│ │ │ ├── components/ +│ │ │ │ ├── common/ +│ │ │ │ │ ├── Header.tsx +│ │ │ │ │ ├── Footer.tsx +│ │ │ │ │ ├── LanguageSwitcher.tsx +│ │ │ │ │ └── Loading.tsx +│ │ │ │ ├── vehicles/ +│ │ │ │ │ ├── SearchForm.tsx +│ │ │ │ │ ├── VehicleCard.tsx +│ │ │ │ │ ├── VehicleList.tsx +│ │ │ │ │ ├── VehicleDetail.tsx +│ │ │ │ │ └── ImageGallery.tsx +│ │ │ │ ├── chat/ +│ │ │ │ │ ├── ChatWindow.tsx +│ │ │ │ │ ├── MessageList.tsx +│ │ │ │ │ └── MessageInput.tsx +│ │ │ │ ├── payment/ +│ │ │ │ │ ├── PaymentModal.tsx +│ │ │ │ │ ├── CryptoSelector.tsx +│ │ │ │ │ └── QRCodeDisplay.tsx +│ │ │ │ ├── order/ +│ │ │ │ │ ├── OrderSummary.tsx +│ │ │ │ │ ├── FeeBreakdown.tsx # 수수료 상세 내역 +│ │ │ │ │ └── OrderHistory.tsx +│ │ │ │ └── tracking/ +│ │ │ │ ├── TrackingMap.tsx # 지도 컴포넌트 +│ │ │ │ ├── TrackingStatus.tsx # 현재 상태 표시 +│ │ │ │ ├── TrackingTimeline.tsx # 배송 이력 타임라인 +│ │ │ │ └── EstimatedArrival.tsx # 예상 도착일 +│ │ │ │ +│ │ │ ├── hooks/ +│ │ │ │ ├── useAuth.ts +│ │ │ │ ├── useVehicles.ts +│ │ │ │ ├── useChat.ts +│ │ │ │ ├── usePayment.ts +│ │ │ │ ├── useOrder.ts +│ │ │ │ └── useTracking.ts +│ │ │ │ +│ │ │ ├── lib/ +│ │ │ │ ├── api.ts # API 클라이언트 +│ │ │ │ ├── socket.ts # Socket.io 클라이언트 +│ │ │ │ └── utils.ts +│ │ │ │ +│ │ │ ├── stores/ +│ │ │ │ ├── authStore.ts +│ │ │ │ └── chatStore.ts +│ │ │ │ +│ │ │ └── types/ +│ │ │ ├── vehicle.ts +│ │ │ ├── user.ts +│ │ │ ├── payment.ts +│ │ │ ├── order.ts +│ │ │ ├── fee.ts +│ │ │ └── tracking.ts +│ │ │ +│ │ └── messages/ # next-intl 메시지 +│ │ ├── mn.json +│ │ ├── en.json +│ │ ├── ru.json +│ │ └── ko.json +│ │ +│ └── api/ # Node.js 백엔드 +│ ├── package.json +│ ├── tsconfig.json +│ │ +│ └── src/ +│ ├── index.ts # 엔트리포인트 +│ ├── app.ts # Express 앱 +│ │ +│ ├── config/ +│ │ ├── database.ts +│ │ ├── redis.ts +│ │ └── env.ts +│ │ +│ ├── routes/ +│ │ ├── index.ts +│ │ ├── auth.routes.ts +│ │ ├── vehicles.routes.ts +│ │ ├── payment.routes.ts +│ │ ├── chat.routes.ts +│ │ ├── order.routes.ts # 주문 관련 +│ │ ├── tracking.routes.ts # 배송 추적 +│ │ ├── fee.routes.ts # 수수료 관련 +│ │ ├── container.routes.ts # 컨테이너 관리 (관리자) +│ │ └── agent.routes.ts # Windows 에이전트용 +│ │ +│ ├── controllers/ +│ │ ├── auth.controller.ts +│ │ ├── vehicles.controller.ts +│ │ ├── payment.controller.ts +│ │ ├── chat.controller.ts +│ │ ├── order.controller.ts +│ │ ├── tracking.controller.ts +│ │ ├── fee.controller.ts +│ │ ├── container.controller.ts +│ │ └── agent.controller.ts +│ │ +│ ├── services/ +│ │ ├── auth.service.ts +│ │ ├── vehicles.service.ts +│ │ ├── payment.service.ts +│ │ ├── translation.service.ts # Claude API +│ │ ├── chat.service.ts +│ │ ├── order.service.ts +│ │ ├── tracking.service.ts +│ │ ├── fee.service.ts # 수수료 계산 로직 +│ │ ├── container.service.ts # 컨테이너 적재 로직 +│ │ └── nowpayments.service.ts +│ │ +│ ├── middleware/ +│ │ ├── auth.middleware.ts +│ │ ├── rateLimit.middleware.ts +│ │ └── error.middleware.ts +│ │ +│ ├── socket/ +│ │ ├── index.ts # Socket.io 설정 +│ │ └── chat.handler.ts # 채팅 이벤트 핸들러 +│ │ +│ ├── jobs/ +│ │ └── searchQueue.ts # Bull 큐 +│ │ +│ └── types/ +│ └── index.ts +│ +├── packages/ +│ └── database/ # Prisma 스키마 (공유) +│ ├── package.json +│ ├── prisma/ +│ │ ├── schema.prisma +│ │ └── migrations/ +│ └── src/ +│ └── index.ts # Prisma Client export +│ +├── agent/ # Windows 자동화 에이전트 +│ ├── requirements.txt +│ ├── config.yaml +│ │ +│ ├── src/ +│ │ ├── main.py # 엔트리포인트 +│ │ ├── carmodoo_agent.py # 카모두 자동화 +│ │ ├── api_client.py # 서버 API 클라이언트 +│ │ ├── image_handler.py # 이미지 추출/업로드 +│ │ └── utils.py +│ │ +│ └── tests/ +│ └── test_agent.py +│ +├── database/ # DB 스키마 및 시드 +│ ├── schema.sql # PostgreSQL 스키마 +│ ├── seed.sql # 초기 데이터 +│ └── backup.sh # 백업 스크립트 +│ +├── deploy/ # 배포 설정 +│ ├── nginx/ +│ │ └── default.conf +│ ├── systemd/ +│ │ ├── api.service +│ │ └── web.service +│ └── scripts/ +│ ├── setup-server.sh # 서버 초기 설정 +│ └── deploy.sh # 배포 스크립트 +│ +└── docs/ # 문서 + ├── API.md # API 문서 + ├── DEPLOYMENT.md # 배포 가이드 + └── AGENT.md # 에이전트 설정 가이드 +``` + +--- + +## 🗄️ 데이터베이스 스키마 + +### ERD 개요 + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ +│ users │────<│view_history │>────│ vehicles │ +└─────────────┘ └─────────────┘ └─────────────────┘ + │ │ + │◀───────────[referred_by] │ + │ │ + │ ┌─────────────┐ │ + └───────────<│ payments │ │ + │ └─────────────┘ │ + │ │ │ + │ ┌─────────────┐ │ + │ │point_history│ │ + │ └─────────────┘ │ + │ │ + │ ┌─────────────┐ │ + └───────────<│ chat_rooms │>────────────┘ + │ └─────────────┘ + │ │ + │ ┌─────────────┐ + │ │chat_messages│ + │ └─────────────┘ + │ + │ ┌──────────────────┐ + └───────────<│dealer_commissions│ (현지딜러 수수료) + └──────────────────┘ +``` + +### 주요 테이블 + +```sql +-- 1. 사용자 (users) +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + name VARCHAR(100), + phone VARCHAR(20), + country VARCHAR(50) DEFAULT 'MN', + language VARCHAR(10) DEFAULT 'mn', + + -- 회원 유형 + user_type VARCHAR(20) DEFAULT 'customer', -- 'customer', 'local_dealer', 'admin' + + -- 현지딜러 추천 시스템 (1단계 직접 추천) + referred_by INTEGER REFERENCES users(id), -- 추천인 (현지딜러) + referral_code VARCHAR(20) UNIQUE, -- 본인의 추천 코드 (딜러용) + + free_views_remaining INTEGER DEFAULT 3, + point_balance DECIMAL(12,2) DEFAULT 0, + status VARCHAR(20) DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- 1-1. 회원 유형 (user_types) - 참조 테이블 +CREATE TABLE user_types ( + code VARCHAR(20) PRIMARY KEY, + name_ko VARCHAR(50) NOT NULL, + name_en VARCHAR(50) NOT NULL, + name_mn VARCHAR(50), + description TEXT, + can_refer_customers BOOLEAN DEFAULT false, -- 고객 추천 가능 여부 + commission_rate DECIMAL(4,2) DEFAULT 0, -- 기본 수수료율 (%) + created_at TIMESTAMP DEFAULT NOW() +); + +INSERT INTO user_types (code, name_ko, name_en, name_mn, description, can_refer_customers, commission_rate) VALUES +('customer', '일반고객', 'Customer', 'Хэрэглэгч', '일반 구매 고객', false, 0), +('local_dealer', '현지딜러', 'Local Dealer', 'Орон нутгийн дилер', '몽골 현지 딜러 (고객 추천 가능)', true, 3.5), +('admin', '관리자', 'Administrator', 'Админ', '시스템 관리자', false, 0); + +-- 1-2. 현지딜러 정보 (dealer_profiles) +CREATE TABLE dealer_profiles ( + id SERIAL PRIMARY KEY, + user_id INTEGER UNIQUE REFERENCES users(id) ON DELETE CASCADE, + + -- 사업자 정보 + business_name VARCHAR(200), -- 상호명 + business_registration_no VARCHAR(50), -- 사업자등록번호 + business_address TEXT, -- 사업장 주소 + business_phone VARCHAR(30), -- 사업장 전화번호 + + -- 딜러 등급 및 수수료 + dealer_grade VARCHAR(20) DEFAULT 'standard', -- 'standard', 'silver', 'gold', 'platinum' + commission_rate DECIMAL(4,2) DEFAULT 3.5, -- 개별 수수료율 (기본 3.5%) + + -- 은행 정보 (수수료 지급용) + bank_name VARCHAR(100), + bank_account_number VARCHAR(50), + bank_account_holder VARCHAR(100), + + -- 활동 통계 + total_referrals INTEGER DEFAULT 0, -- 총 추천 고객 수 + total_sales_count INTEGER DEFAULT 0, -- 총 판매 건수 + total_commission_earned DECIMAL(14,2) DEFAULT 0, -- 총 수령 수수료 + + -- 상태 + verification_status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'verified', 'rejected' + verified_at TIMESTAMP, + notes TEXT, + + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- 1-3. 딜러 등급 (dealer_grades) +CREATE TABLE dealer_grades ( + code VARCHAR(20) PRIMARY KEY, + name_ko VARCHAR(50) NOT NULL, + name_en VARCHAR(50) NOT NULL, + name_mn VARCHAR(50), + min_sales_count INTEGER DEFAULT 0, -- 승급 조건: 최소 판매 건수 + commission_rate DECIMAL(4,2) NOT NULL, -- 등급별 수수료율 + benefits TEXT, -- 혜택 설명 + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW() +); + +INSERT INTO dealer_grades (code, name_ko, name_en, name_mn, min_sales_count, commission_rate, benefits, sort_order) VALUES +('standard', '일반', 'Standard', 'Стандарт', 0, 3.0, '기본 수수료율 적용', 1), +('silver', '실버', 'Silver', 'Мөнгө', 10, 3.5, '수수료율 0.5% 상향', 2), +('gold', '골드', 'Gold', 'Алт', 30, 4.0, '수수료율 1.0% 상향, 우선 배정', 3), +('platinum', '플래티넘', 'Platinum', 'Платин', 100, 4.5, '수수료율 1.5% 상향, 전담 매니저', 4); + +-- 2. 차량 (vehicles) +CREATE TABLE vehicles ( + id SERIAL PRIMARY KEY, + source_id VARCHAR(100) UNIQUE, + make VARCHAR(50) NOT NULL, + model VARCHAR(100) NOT NULL, + year INTEGER NOT NULL, + mileage INTEGER, + price DECIMAL(14,2), + fuel_type VARCHAR(20), + transmission VARCHAR(20), + color VARCHAR(30), + engine_cc INTEGER, + options JSONB DEFAULT '{}', + inspection_data JSONB DEFAULT '{}', + accident_history JSONB DEFAULT '[]', + status VARCHAR(20) DEFAULT 'active', + view_count INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW() +); + +-- 3. 차량 이미지 (vehicle_images) +CREATE TABLE vehicle_images ( + id SERIAL PRIMARY KEY, + vehicle_id INTEGER REFERENCES vehicles(id) ON DELETE CASCADE, + image_url VARCHAR(500) NOT NULL, + thumbnail_url VARCHAR(500), + image_type VARCHAR(30), + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW() +); + +-- 4. 열람 기록 (view_history) +CREATE TABLE view_history ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + vehicle_id INTEGER REFERENCES vehicles(id), + is_free BOOLEAN DEFAULT false, + points_used DECIMAL(10,2) DEFAULT 0, + viewed_at TIMESTAMP DEFAULT NOW(), + UNIQUE(user_id, vehicle_id) +); + +-- 5. 결제 (payments) +CREATE TABLE payments ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + payment_id VARCHAR(100) UNIQUE, + payment_type VARCHAR(20), + amount_krw DECIMAL(12,2), + amount_crypto DECIMAL(20,8), + crypto_type VARCHAR(20), + pay_address VARCHAR(200), + tx_hash VARCHAR(100), + points_added DECIMAL(12,2), + status VARCHAR(20) DEFAULT 'pending', + created_at TIMESTAMP DEFAULT NOW(), + confirmed_at TIMESTAMP +); + +-- 6. 포인트 이력 (point_history) +CREATE TABLE point_history ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + amount DECIMAL(12,2) NOT NULL, + type VARCHAR(20), + description VARCHAR(200), + balance_after DECIMAL(12,2), + created_at TIMESTAMP DEFAULT NOW() +); + +-- 7. 채팅방 (chat_rooms) +CREATE TABLE chat_rooms ( + id SERIAL PRIMARY KEY, + vehicle_id INTEGER REFERENCES vehicles(id), + customer_id INTEGER REFERENCES users(id), + dealer_id VARCHAR(50), + status VARCHAR(20) DEFAULT 'active', + last_message_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW() +); + +-- 8. 채팅 메시지 (chat_messages) +CREATE TABLE chat_messages ( + id BIGSERIAL PRIMARY KEY, + room_id INTEGER REFERENCES chat_rooms(id), + sender_id VARCHAR(50) NOT NULL, + sender_type VARCHAR(20), + original_text TEXT NOT NULL, + original_language VARCHAR(10), + translations JSONB DEFAULT '{}', + is_faq_response BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT NOW() +); + +-- 9. 검색 요청 큐 (search_requests) +CREATE TABLE search_requests ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + criteria JSONB NOT NULL, + status VARCHAR(20) DEFAULT 'pending', + result_count INTEGER, + created_at TIMESTAMP DEFAULT NOW(), + processed_at TIMESTAMP +); + +-- 10. 차량 크기 분류 (vehicle_size_types) +CREATE TABLE vehicle_size_types ( + id SERIAL PRIMARY KEY, + code VARCHAR(20) UNIQUE NOT NULL, -- 'compact', 'midsize' + name_ko VARCHAR(50) NOT NULL, + name_en VARCHAR(50) NOT NULL, + name_mn VARCHAR(50), + max_per_container INTEGER NOT NULL, -- 컨테이너당 최대 적재 수량 + created_at TIMESTAMP DEFAULT NOW() +); + +-- 초기 데이터 +INSERT INTO vehicle_size_types (code, name_ko, name_en, name_mn, max_per_container) VALUES +('compact', '소형차', 'Compact Car', 'Жижиг машин', 4), +('midsize', '중형차', 'Midsize Car', 'Дунд машин', 2); + +-- 11. 수수료 항목 마스터 (fee_types) +CREATE TABLE fee_types ( + id SERIAL PRIMARY KEY, + code VARCHAR(50) UNIQUE NOT NULL, + category VARCHAR(30) NOT NULL, -- 'vehicle', 'container', 'terminal' + name_ko VARCHAR(100) NOT NULL, + name_en VARCHAR(100) NOT NULL, + name_mn VARCHAR(100), + calculation_type VARCHAR(20) NOT NULL, -- 'fixed', 'percentage', 'per_day', 'per_container', 'per_car' + default_amount DECIMAL(14,2), + percentage_rate DECIMAL(5,2), -- 퍼센트 계산 시 사용 + is_shared BOOLEAN DEFAULT false, -- 컨테이너 비용 분담 여부 + sort_order INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW() +); + +-- 수수료 항목 초기 데이터 +-- 차량별 비용 (vehicle) +INSERT INTO fee_types (code, category, name_ko, name_en, calculation_type, default_amount, percentage_rate, is_shared, sort_order) VALUES +('performance_inspection', 'vehicle', '성능점검보험료', 'Performance inspection fee', 'fixed', 100000, NULL, false, 1), +('sales_fee', 'vehicle', '매도세', 'Sales fee', 'fixed', 440000, NULL, false, 2), +('brokerage_commission', 'vehicle', '매매알선수수료', 'Brokerage commission', 'fixed', 250000, NULL, false, 3), +('registration_tax', 'vehicle', '취등록세', 'Registration tax', 'fixed', 50000, NULL, false, 4), +('expiration_cost', 'vehicle', '말소', 'Expiration cost', 'fixed', 50000, NULL, false, 5), +('domestic_transport', 'vehicle', '국내 운송료', 'Domestic transportation charges', 'fixed', 160000, NULL, false, 6), +('yard_fee', 'vehicle', '야드비', 'Yard fee', 'per_day', 20000, NULL, false, 7), +('export_license', 'vehicle', '수출면장발급', 'Issuance of export exemption', 'fixed', 45000, NULL, false, 8), +('korean_margin', 'vehicle', '한국 마진', 'Korean Margin', 'percentage', NULL, 5.00, false, 9), +('mongolian_margin', 'vehicle', '몽골 마진', 'Mongolian Margin', 'percentage', NULL, 5.00, false, 10), + +-- 컨테이너 비용 (분담, container) +('shipping_cost', 'container', '운송비(기차 4주)', 'Shipping Cost (Train 4weeks)', 'per_container', 5220000, NULL, true, 11), +('shoring', 'container', '쇼링(컨테이너작업)', 'Shoring', 'per_container', 600000, NULL, true, 12), + +-- 터미널 비용 (분담, terminal) +('thc', 'terminal', '터미널 조작비용', 'THC(Terminal Handling Charge)', 'per_container', 190000, NULL, true, 13), +('wharfage', 'terminal', '부두하역료', 'Wharfage', 'per_container', 8400, NULL, true, 14), +('seal_fee', 'terminal', '컨테이너 실 비용', 'Seal Fee', 'per_container', 10000, NULL, true, 15), +('document_fee', 'terminal', '서류수수료', 'Document fee', 'per_container', 50000, NULL, true, 16); + +-- 12. 주문 (orders) +CREATE TABLE orders ( + id SERIAL PRIMARY KEY, + order_number VARCHAR(50) UNIQUE NOT NULL, -- ORD-20241127-0001 + user_id INTEGER REFERENCES users(id), + vehicle_id INTEGER REFERENCES vehicles(id), + vehicle_size_type_id INTEGER REFERENCES vehicle_size_types(id), + vehicle_price DECIMAL(14,2) NOT NULL, -- 차량가액 + total_fees DECIMAL(14,2) NOT NULL, -- 총 수수료 + total_amount DECIMAL(14,2) NOT NULL, -- 총 결제금액 + status VARCHAR(30) DEFAULT 'pending', -- pending, paid, processing, shipping, customs, delivered, cancelled + payment_status VARCHAR(20) DEFAULT 'unpaid', -- unpaid, partial, paid + notes TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- 13. 주문 수수료 상세 (order_fees) +CREATE TABLE order_fees ( + id SERIAL PRIMARY KEY, + order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE, + fee_type_id INTEGER REFERENCES fee_types(id), + fee_code VARCHAR(50) NOT NULL, + fee_name_ko VARCHAR(100) NOT NULL, + fee_name_en VARCHAR(100) NOT NULL, + calculation_type VARCHAR(20) NOT NULL, + base_amount DECIMAL(14,2), -- 기준 금액 + quantity INTEGER DEFAULT 1, -- 일수 등 + calculated_amount DECIMAL(14,2) NOT NULL, -- 최종 계산된 금액 + is_shared BOOLEAN DEFAULT false, + share_count INTEGER DEFAULT 1, -- 분담 차량 수 + notes VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); + +-- 14. 컨테이너 (containers) +CREATE TABLE containers ( + id SERIAL PRIMARY KEY, + container_number VARCHAR(50) UNIQUE, -- CNTR-20241127-001 + container_type VARCHAR(20) DEFAULT '40ft', -- 20ft, 40ft + max_compact_cars INTEGER DEFAULT 4, -- 소형차 최대 적재량 + max_midsize_cars INTEGER DEFAULT 2, -- 중형차 최대 적재량 (중형 4대 불가 규칙) + current_compact_count INTEGER DEFAULT 0, + current_midsize_count INTEGER DEFAULT 0, + status VARCHAR(20) DEFAULT 'loading', -- loading, sealed, in_transit, arrived, cleared + + -- 출발/도착 정보 + departure_port VARCHAR(100), -- 부산항 + arrival_port VARCHAR(100), -- 울란바타르 + + -- 일정 + estimated_departure_date DATE, + actual_departure_date DATE, + estimated_arrival_date DATE, + actual_arrival_date DATE, + + -- 비용 정보 (컨테이너 비용은 적재된 차량들이 분담) + shipping_cost DECIMAL(14,2), + shoring_cost DECIMAL(14,2), + terminal_cost DECIMAL(14,2), + total_shared_cost DECIMAL(14,2), + + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- 15. 컨테이너-차량 매핑 (container_vehicles) +CREATE TABLE container_vehicles ( + id SERIAL PRIMARY KEY, + container_id INTEGER REFERENCES containers(id) ON DELETE CASCADE, + order_id INTEGER REFERENCES orders(id), + vehicle_id INTEGER REFERENCES vehicles(id), + vehicle_size_type VARCHAR(20) NOT NULL, -- 'compact', 'midsize' + shared_cost_amount DECIMAL(14,2), -- 분담된 비용 + loaded_at TIMESTAMP DEFAULT NOW() +); + +-- 16. 배송 추적 (shipment_tracking) +CREATE TABLE shipment_tracking ( + id SERIAL PRIMARY KEY, + order_id INTEGER REFERENCES orders(id), + container_id INTEGER REFERENCES containers(id), + + -- 위치 정보 + current_location VARCHAR(200), + latitude DECIMAL(10, 7), + longitude DECIMAL(10, 7), + location_type VARCHAR(30), -- 'port', 'railway', 'customs', 'warehouse', 'delivered' + + -- 상태 + status VARCHAR(30) NOT NULL, -- preparing, departed_korea, in_transit, arrived_mongolia, customs_clearance, delivered + status_detail VARCHAR(200), + + -- 예상 일정 + estimated_arrival_date DATE, + estimated_arrival_days INTEGER, -- 남은 예상 일수 + + -- 통관 정보 + customs_status VARCHAR(30), -- pending, in_progress, cleared + customs_document_number VARCHAR(100), + + updated_at TIMESTAMP DEFAULT NOW(), + created_at TIMESTAMP DEFAULT NOW() +); + +-- 17. 배송 이력 (shipment_history) +CREATE TABLE shipment_history ( + id SERIAL PRIMARY KEY, + order_id INTEGER REFERENCES orders(id), + container_id INTEGER REFERENCES containers(id), + status VARCHAR(30) NOT NULL, + location VARCHAR(200), + latitude DECIMAL(10, 7), + longitude DECIMAL(10, 7), + description_ko VARCHAR(300), + description_en VARCHAR(300), + description_mn VARCHAR(300), + event_time TIMESTAMP DEFAULT NOW(), + created_at TIMESTAMP DEFAULT NOW() +); + +-- 배송 상태 코드 참조 테이블 (shipment_status_codes) +CREATE TABLE shipment_status_codes ( + code VARCHAR(30) PRIMARY KEY, + name_ko VARCHAR(100) NOT NULL, + name_en VARCHAR(100) NOT NULL, + name_mn VARCHAR(100), + sort_order INTEGER, + icon VARCHAR(50) -- 프론트엔드 아이콘 매핑용 +); + +INSERT INTO shipment_status_codes (code, name_ko, name_en, name_mn, sort_order, icon) VALUES +('preparing', '출고 준비 중', 'Preparing for shipment', 'Тээвэрлэлтэнд бэлтгэж байна', 1, 'package'), +('loaded', '컨테이너 적재 완료', 'Loaded into container', 'Чингэлэг ачигдсан', 2, 'truck'), +('departed_korea', '한국 출발', 'Departed from Korea', 'Солонгосоос гарсан', 3, 'ship'), +('in_transit_rail', '기차 운송 중', 'In transit (Rail)', 'Тээвэрлэж байна (Төмөр зам)', 4, 'train'), +('arrived_mongolia', '몽골 도착', 'Arrived in Mongolia', 'Монголд ирсэн', 5, 'flag'), +('customs_clearance', '통관 진행 중', 'Customs clearance in progress', 'Гаалийн бүрдүүлэлт хийгдэж байна', 6, 'clipboard'), +('customs_cleared', '통관 완료', 'Customs cleared', 'Гаалийн бүрдүүлэлт дууссан', 7, 'check'), +('ready_for_pickup', '인수 대기', 'Ready for pickup', 'Авахад бэлэн', 8, 'warehouse'), +('delivered', '인도 완료', 'Delivered', 'Хүргэгдсэн', 9, 'check-circle'); + +-- ===================================================== +-- 현지딜러 수수료 시스템 (1단계 직접 추천 구조) +-- ===================================================== + +-- 18. 딜러 수수료 내역 (dealer_commissions) +CREATE TABLE dealer_commissions ( + id SERIAL PRIMARY KEY, + dealer_id INTEGER REFERENCES users(id), -- 현지딜러 (수수료 수령자) + order_id INTEGER REFERENCES orders(id), -- 연결된 주문 + customer_id INTEGER REFERENCES users(id), -- 추천받은 고객 + + -- 수수료 계산 + vehicle_price DECIMAL(14,2) NOT NULL, -- 차량가액 + mongolian_margin_rate DECIMAL(4,2) NOT NULL, -- 몽골 마진율 (전체 5%) + dealer_commission_rate DECIMAL(4,2) NOT NULL, -- 딜러 수수료율 (3~4.5%) + platform_rate DECIMAL(4,2) NOT NULL, -- 플랫폼 수수료율 (0.5~2%) + + dealer_commission_amount DECIMAL(14,2) NOT NULL, -- 딜러 수수료 금액 + platform_amount DECIMAL(14,2) NOT NULL, -- 플랫폼 수수료 금액 + + -- 지급 정보 + status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'approved', 'paid', 'cancelled' + payment_method VARCHAR(30), -- 'bank_transfer', 'crypto' + payment_reference VARCHAR(100), -- 송금 참조번호 + paid_at TIMESTAMP, + + notes TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- 19. 딜러 수수료 정산 (dealer_payouts) +CREATE TABLE dealer_payouts ( + id SERIAL PRIMARY KEY, + dealer_id INTEGER REFERENCES users(id), + payout_number VARCHAR(50) UNIQUE, -- PAY-20241127-001 + + -- 정산 기간 + period_start DATE NOT NULL, + period_end DATE NOT NULL, + + -- 정산 금액 + total_orders INTEGER DEFAULT 0, -- 해당 기간 주문 건수 + total_commission DECIMAL(14,2) NOT NULL, -- 총 수수료 + deductions DECIMAL(14,2) DEFAULT 0, -- 공제액 (있을 경우) + net_amount DECIMAL(14,2) NOT NULL, -- 실 지급액 + + -- 지급 정보 + payment_method VARCHAR(30) NOT NULL, -- 'bank_transfer', 'crypto' + bank_name VARCHAR(100), + bank_account_number VARCHAR(50), + payment_reference VARCHAR(100), + + status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'processing', 'completed', 'failed' + processed_at TIMESTAMP, + completed_at TIMESTAMP, + + notes TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +-- 20. 추천 코드 사용 이력 (referral_history) +CREATE TABLE referral_history ( + id SERIAL PRIMARY KEY, + referral_code VARCHAR(20) NOT NULL, + dealer_id INTEGER REFERENCES users(id), -- 코드 소유자 (딜러) + customer_id INTEGER REFERENCES users(id), -- 코드 사용자 (고객) + used_at TIMESTAMP DEFAULT NOW(), + ip_address VARCHAR(50), + user_agent TEXT +); +``` + +--- + +## 🔌 API 명세 + +### 인증 API + +| Method | Endpoint | 설명 | +|--------|----------|------| +| POST | `/api/auth/register` | 회원가입 | +| POST | `/api/auth/login` | 로그인 | +| POST | `/api/auth/logout` | 로그아웃 | +| GET | `/api/auth/me` | 현재 사용자 정보 | +| PUT | `/api/auth/profile` | 프로필 수정 | + +### 차량 API + +| Method | Endpoint | 설명 | +|--------|----------|------| +| POST | `/api/vehicles/search` | 차량 검색 요청 | +| GET | `/api/vehicles/search/:requestId` | 검색 결과 조회 | +| GET | `/api/vehicles/:id` | 차량 상세 (포인트 차감) | +| GET | `/api/vehicles/:id/preview` | 차량 미리보기 (무료) | + +### 결제 API + +| Method | Endpoint | 설명 | +|--------|----------|------| +| POST | `/api/payment/card` | 카드 결제 생성 | +| POST | `/api/payment/direct` | 직접 전송 주소 생성 | +| POST | `/api/payment/webhook` | NOWPayments 웹훅 | +| GET | `/api/payment/history` | 결제 내역 | + +### 채팅 API + +| Method | Endpoint | 설명 | +|--------|----------|------| +| POST | `/api/chat/rooms` | 채팅방 생성 | +| GET | `/api/chat/rooms` | 채팅방 목록 | +| GET | `/api/chat/rooms/:id/messages` | 메시지 조회 | + +### 주문/배송 API + +| Method | Endpoint | 설명 | +|--------|----------|------| +| POST | `/api/orders` | 주문 생성 | +| GET | `/api/orders` | 내 주문 목록 | +| GET | `/api/orders/:id` | 주문 상세 (수수료 내역 포함) | +| GET | `/api/orders/:id/fees` | 주문 수수료 상세 조회 | +| GET | `/api/tracking/:orderId` | 내차 위치 추적 | +| GET | `/api/tracking/:orderId/history` | 배송 이력 조회 | + +### 수수료 API + +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | `/api/fees` | 수수료 항목 목록 | +| POST | `/api/fees/calculate` | 수수료 계산 (견적) | + +### 컨테이너 API (관리자용) + +| Method | Endpoint | 설명 | +|--------|----------|------| +| POST | `/api/admin/containers` | 컨테이너 생성 | +| POST | `/api/admin/containers/:id/vehicles` | 차량 적재 | +| PUT | `/api/admin/containers/:id/status` | 컨테이너 상태 업데이트 | +| PUT | `/api/admin/tracking/:orderId` | 배송 추적 정보 업데이트 | + +### 현지딜러 API + +| Method | Endpoint | 설명 | +|--------|----------|------| +| POST | `/api/dealer/register` | 딜러 등록 신청 | +| GET | `/api/dealer/profile` | 딜러 프로필 조회 | +| PUT | `/api/dealer/profile` | 딜러 프로필 수정 | +| GET | `/api/dealer/referrals` | 추천 고객 목록 | +| GET | `/api/dealer/commissions` | 수수료 내역 조회 | +| GET | `/api/dealer/commissions/summary` | 수수료 요약 (대시보드) | +| GET | `/api/dealer/payouts` | 정산 내역 조회 | +| POST | `/api/referral/apply` | 추천 코드 적용 (고객용) | +| GET | `/api/referral/validate/:code` | 추천 코드 유효성 검증 | + +### 딜러 관리 API (관리자용) + +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | `/api/admin/dealers` | 딜러 목록 조회 | +| PUT | `/api/admin/dealers/:id/verify` | 딜러 승인/거부 | +| PUT | `/api/admin/dealers/:id/grade` | 딜러 등급 변경 | +| POST | `/api/admin/payouts` | 정산 처리 | +| GET | `/api/admin/commissions/report` | 수수료 리포트 | + +### 에이전트 API (내부용) + +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | `/api/agent/pending-searches` | 대기 중인 검색 요청 | +| POST | `/api/agent/search-results` | 검색 결과 전송 | +| POST | `/api/agent/vehicle-detail` | 차량 상세 정보 전송 | +| POST | `/api/agent/upload-images` | 이미지 업로드 | + +--- + +## 👥 현지딜러 시스템 (1단계 직접 추천 구조) + +### 시스템 개요 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 1단계 직접 추천 구조 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 플랫폼 운영사 │ │ +│ │ (한국 마진 5% 수령) │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 몽골 마진 5% │ │ +│ │ │ │ │ +│ │ ┌─────────────────┴─────────────────┐ │ │ +│ │ ▼ ▼ │ │ +│ │ ┌──────────────┐ ┌──────────────┐ │ │ +│ │ │ 현지딜러 몫 │ │ 플랫폼 몫 │ │ │ +│ │ │ 3~4.5% │ │ 0.5~2% │ │ │ +│ │ └──────────────┘ └──────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 현지딜러 │ │ +│ │ • 고객에게 추천 코드 제공 │ │ +│ │ • 고객이 차량 구매 시 수수료 수령 │ │ +│ │ • 등급에 따라 수수료율 차등 │ │ +│ └──────────────────────┬──────────────────────────────────────┘ │ +│ │ │ +│ ┌───────────────┼───────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ 고객 A │ │ 고객 B │ │ 고객 C │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +│ │ +│ ✅ 핵심 규칙: │ +│ • 딜러가 직접 추천한 고객만 수수료 지급 (1단계만) │ +│ • 고객이 다시 다른 고객을 추천해도 원래 딜러에게 수수료 없음 │ +│ • 다단계 구조 아님 → 법적 문제 없음 │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 회원 유형 + +| 유형 | 설명 | 추천 가능 | 수수료 수령 | +|------|------|----------|------------| +| **일반고객 (customer)** | 차량 구매 고객 | ❌ | ❌ | +| **현지딜러 (local_dealer)** | 몽골 현지 딜러 | ✅ | ✅ | +| **관리자 (admin)** | 시스템 관리자 | ❌ | ❌ | + +### 현지딜러 등급 및 수수료율 + +| 등급 | 조건 | 수수료율 | 혜택 | +|------|------|---------|------| +| **일반 (Standard)** | 기본 | 3.0% | 기본 수수료율 적용 | +| **실버 (Silver)** | 10건 이상 판매 | 3.5% | 수수료율 0.5%p 상향 | +| **골드 (Gold)** | 30건 이상 판매 | 4.0% | 수수료율 1.0%p 상향, 우선 배정 | +| **플래티넘 (Platinum)** | 100건 이상 판매 | 4.5% | 수수료율 1.5%p 상향, 전담 매니저 | + +### 수수료 계산 예시 + +``` +차량가: ₩25,000,000 +몽골 마진 (5%): ₩1,250,000 + +┌─────────────────────────────────────────────────────────────┐ +│ 딜러 등급별 수수료 배분 │ +├─────────────────┬─────────────┬─────────────┬───────────────┤ +│ 딜러 등급 │ 딜러 수수료 │ 플랫폼 몫 │ 합계 (5%) │ +├─────────────────┼─────────────┼─────────────┼───────────────┤ +│ 일반 (3.0%) │ ₩750,000 │ ₩500,000 │ ₩1,250,000 │ +│ 실버 (3.5%) │ ₩875,000 │ ₩375,000 │ ₩1,250,000 │ +│ 골드 (4.0%) │ ₩1,000,000 │ ₩250,000 │ ₩1,250,000 │ +│ 플래티넘 (4.5%) │ ₩1,125,000 │ ₩125,000 │ ₩1,250,000 │ +└─────────────────┴─────────────┴─────────────┴───────────────┘ + +※ 추천인 없는 직접 구매 시: 플랫폼이 5% 전액 수령 +``` + +### 추천 시스템 플로우 + +``` +1. 딜러 등록 + ┌──────────────────────────────────────────────────────────┐ + │ 현지딜러 신청 → 서류 심사 → 승인 → 추천 코드 발급 │ + │ │ + │ 예: 추천코드 "DEALER-ABC123" │ + └──────────────────────────────────────────────────────────┘ + +2. 고객 추천 + ┌──────────────────────────────────────────────────────────┐ + │ 딜러가 고객에게 추천 코드 제공 │ + │ ↓ │ + │ 고객이 회원가입 시 추천 코드 입력 │ + │ ↓ │ + │ 고객 계정에 referred_by = 딜러 ID 저장 │ + └──────────────────────────────────────────────────────────┘ + +3. 차량 구매 + ┌──────────────────────────────────────────────────────────┐ + │ 고객이 차량 구매 완료 │ + │ ↓ │ + │ 시스템이 고객의 추천인(딜러) 확인 │ + │ ↓ │ + │ 딜러 수수료 자동 계산 및 기록 │ + │ ↓ │ + │ 월별/주별 정산 후 딜러에게 지급 │ + └──────────────────────────────────────────────────────────┘ +``` + +### 딜러 대시보드 기능 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 딜러 대시보드 (My Dashboard) │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ 추천 고객 │ │ 이번달 판매 │ │ 총 수수료 │ │ +│ │ 127명 │ │ 5건 │ │ ₩4,750,000 │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +│ 내 등급: 🥇 골드 (다음 등급까지 8건) │ +│ 수수료율: 4.0% │ +│ 추천 코드: DEALER-ABC123 [복사] │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ 최근 수수료 내역 │ │ +│ │ ────────────────────────────────────────────────── │ │ +│ │ 2024.11.25 소나타 DN8 ₩1,000,000 지급완료 │ │ +│ │ 2024.11.20 아반떼 CN7 ₩800,000 지급완료 │ │ +│ │ 2024.11.15 K5 DL3 ₩950,000 지급대기 │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ 내 추천 고객 목록 │ │ +│ │ ────────────────────────────────────────────────── │ │ +│ │ 바트수흐 bat***@email.com 가입일: 2024.11.01 │ │ +│ │ 엥흐바야르 enk***@email.com 가입일: 2024.10.15 │ │ +│ │ ... │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 정산 주기 + +| 구분 | 주기 | 최소 지급액 | 지급일 | +|------|------|-----------|--------| +| **일반 정산** | 월 1회 | ₩100,000 | 매월 10일 | +| **VIP 정산** | 주 1회 | ₩500,000 | 매주 금요일 | + +--- + +## 🚚 수수료 체계 및 비용 구조 + +### 차량별 비용 (1대 기준) + +| 내역 | Detail | 단가 | 계산 방식 | +|------|--------|------|----------| +| 차량가액 | Vehicle value | 변동 | 차량별 상이 | +| 성능점검보험료 | Performance inspection fee | ₩100,000 | 고정 | +| 매도세 | Sales fee | ₩440,000 | 고정 | +| 매매알선수수료 | Brokerage commission | ₩250,000 | 고정 | +| 취등록세 | Registration tax | ₩50,000 | 고정 | +| 말소 | Expiration cost | ₩50,000 | 고정 | +| 국내 운송료 | Domestic transportation charges | ₩160,000 | 고정 | +| 야드비 | Yard fee (₩20,000/일) | ₩60,000~ | 일수 × 20,000 | +| 수출면장발급 | Issuance of export exemption | ₩45,000 | 고정 | +| 한국 마진 | Korean Margin (5%) | 차량가 × 5% | 퍼센트 | +| 몽골 마진 | Mongolian Margin (5%) | 차량가 × 5% | 퍼센트 | + +### 컨테이너 비용 (4대 분담) + +| 내역 | Detail | 컨테이너당 | 차량당(4대 분담) | +|------|--------|-----------|----------------| +| 운송비(기차 4주) | Shipping Cost (Train 4weeks) | ₩5,220,000 | ₩1,305,000 | +| 쇼링(컨테이너작업) | Shoring | ₩600,000 | ₩150,000 | + +### 터미널 비용 (4대 분담) + +| 내역 | Detail | 컨테이너당 | 차량당(4대 분담) | +|------|--------|-----------|----------------| +| 터미널 조작비용 | THC (Terminal Handling Charge) | ₩190,000 | ₩47,500 | +| 부두하역료 | Wharfage | ₩8,400 | ₩2,100 | +| 컨테이너 실 비용 | Seal Fee | ₩10,000 | ₩2,500 | +| 서류수수료 | Document fee | ₩50,000 | ₩12,500 | + +### 비용 계산 예시 (차량가 ₩25,000,000 기준) + +``` +차량별 비용 소계: ₩28,655,000 + - 차량가액: ₩25,000,000 + - 성능점검보험료: ₩100,000 + - 매도세: ₩440,000 + - 매매알선수수료: ₩250,000 + - 취등록세: ₩50,000 + - 말소: ₩50,000 + - 국내 운송료: ₩160,000 + - 야드비(3일): ₩60,000 + - 수출면장발급: ₩45,000 + - 한국 마진(5%): ₩1,250,000 + - 몽골 마진(5%): ₩1,250,000 + +컨테이너 분담 비용 소계 (4대 분담시): ₩1,519,600 + - 운송비 분담: ₩1,305,000 + - 쇼링 분담: ₩150,000 + - THC 분담: ₩47,500 + - 부두하역료 분담: ₩2,100 + - Seal Fee 분담: ₩2,500 + - 서류수수료 분담: ₩12,500 + +총계: ₩30,174,600 +``` + +--- + +## 🚢 컨테이너 적재 규칙 + +### 차량 크기 분류 + +| 분류 | 예시 차종 | 컨테이너당 최대 | +|------|----------|----------------| +| **소형차 (Compact)** | 모닝, 스파크, 레이, i10, i20 등 | 4대 | +| **중형차 (Midsize)** | 아반떼, K3, 소나타, K5, 싼타페 등 | 2대 | + +### 적재 조합 규칙 + +1대의 컨테이너(40ft)에 다음 조합만 가능: + +| 조합 | 소형차 | 중형차 | 총 차량 수 | 가능 여부 | +|------|--------|--------|-----------|----------| +| A | 4대 | 0대 | 4대 | ✅ 가능 | +| B | 2대 | 2대 | 4대 | ✅ 가능 | +| C | 0대 | 2대 | 2대 | ✅ 가능 | +| D | 3대 | 1대 | 4대 | ✅ 가능 | +| E | 1대 | 2대 | 3대 | ✅ 가능 | +| F | 0대 | 4대 | 4대 | ❌ **불가능** | + +> **중요**: 중형차 4대 조합은 물리적 공간 제약으로 불가능합니다. + +### 비용 분담 계산 + +컨테이너 공유 비용은 적재된 차량 수로 균등 분담: + +``` +분담 비용 = 컨테이너 총 비용 ÷ 적재 차량 수 + +예시: +- 4대 적재시: ₩6,078,400 ÷ 4 = ₩1,519,600/대 +- 3대 적재시: ₩6,078,400 ÷ 3 = ₩2,026,133/대 +- 2대 적재시: ₩6,078,400 ÷ 2 = ₩3,039,200/대 +``` + +--- + +## 📍 내차찾기 (Vehicle Tracking) + +### 기능 개요 + +차량 구매 고객이 자신의 차량 배송 현황을 실시간으로 확인할 수 있는 서비스 + +### 주요 기능 + +1. **지도 표시** + - 한국 → 몽골 운송 경로 시각화 + - 현재 차량 위치 마커 표시 + - 주요 경유지 표시 (부산항, 중국 경유, 울란바타르) + +2. **배송 상태 추적** + - 단계별 진행 상황 표시 + - 예상 도착일 표시 + - 잔여 일수 카운트다운 + +3. **배송 이력** + - 타임라인 형태의 상세 이력 + - 각 단계별 날짜/시간 + - 다국어 설명 (몽골어/영어/러시아어/한국어) + +### 배송 상태 단계 + +``` +1. 출고 준비 중 (Preparing for shipment) + ↓ +2. 컨테이너 적재 완료 (Loaded into container) + ↓ +3. 한국 출발 (Departed from Korea) + ↓ +4. 기차 운송 중 (In transit - Rail) + [약 4주 소요] + ↓ +5. 몽골 도착 (Arrived in Mongolia) + ↓ +6. 통관 진행 중 (Customs clearance in progress) + ↓ +7. 통관 완료 (Customs cleared) + ↓ +8. 인수 대기 (Ready for pickup) + ↓ +9. 인도 완료 (Delivered) +``` + +### 운송 경로 (예상 소요 시간) + +``` +부산항 → (기차 4주) → 중국 경유 → 울란바타르 +- 총 예상 소요 기간: 28~35일 +- 통관 소요 기간: 3~7일 (추가) +``` + +### UI/UX 설계 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 내 차 찾기 (Track My Vehicle) │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ [ 지도 영역 ] │ │ +│ │ │ │ +│ │ 부산 ●━━━━━━━●━━━━━🚗━━━━━━━━━━━━● 울란바타르 │ │ +│ │ 중국 │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ 현재 상태: 🚂 기차 운송 중 │ +│ 예상 도착일: 2024년 12월 25일 (D-18) │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ 배송 이력 │ │ +│ │ ───────────────────────────────────────────────── │ │ +│ │ ✅ 2024.11.20 출고 준비 완료 │ │ +│ │ ✅ 2024.11.22 컨테이너 적재 완료 │ │ +│ │ ✅ 2024.11.25 한국 출발 (부산항) │ │ +│ │ 🔵 2024.11.27 기차 운송 중 (현재) │ │ +│ │ ⭕ 예정 몽골 도착 │ │ +│ │ ⭕ 예정 통관 진행 │ │ +│ │ ⭕ 예정 인도 완료 │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 프론트엔드 구조 + +``` +apps/web/src/app/[locale]/ + └── mypage/ + └── tracking/ + ├── page.tsx # 내 차량 목록 + └── [orderId]/ + └── page.tsx # 상세 추적 페이지 + +apps/web/src/components/ + └── tracking/ + ├── TrackingMap.tsx # 지도 컴포넌트 + ├── TrackingStatus.tsx # 현재 상태 표시 + ├── TrackingTimeline.tsx # 배송 이력 타임라인 + └── EstimatedArrival.tsx # 예상 도착일 표시 +``` + +### 지도 서비스 옵션 + +| 서비스 | 장점 | 단점 | +|--------|------|------| +| **Leaflet + OpenStreetMap** | 무료, 커스터마이징 가능 | 기본 스타일 제한 | +| **Mapbox** | 고품질, 커스터마이징 | 유료 (월 50,000뷰 무료) | +| **Google Maps** | 신뢰성, 정확도 | 비용, 몽골 지역 상세도 낮음 | + +> **권장**: Leaflet + OpenStreetMap (무료 + 충분한 기능) + +--- + +## 💰 과금 시스템 (CC 코인 기반) + +### 🪙 CC (AutonetSellCar Coin) 시스템 + +| 항목 | 내용 | +|------|------| +| **기준 화폐** | USDC (Solana 네트워크) | +| **내부 화폐** | CC (AutonetSellCar Coin) | +| **환율** | 1 USDC = 10 CC | +| **신규 가입 보너스** | 3 CC 무상 지급 | +| **차량 정보 열람** | 1대당 1 CC | + +### 📸 차량 정보 공개 정책 + +| 구분 | 비회원/무료 | CC 결제 후 | +|------|-------------|------------| +| **대표 이미지** | 2장만 공개 | 전체 공개 | +| **상세 이미지** | 🔒 블러 처리 | ✅ 전체 공개 | +| **차량 성능표** | 🔒 접근 불가 | ✅ 열람 가능 | +| **연락처** | 🔒 비공개 | ✅ DamonHong +82-10-3331-5258 | +| **상세 옵션** | 🔒 비공개 | ✅ 공개 | + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 차량 상세 페이지 구조 │ +├─────────────────────────────────────────────────────────────────┤ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ [대표이미지 1] [대표이미지 2] [🔒 블러] [🔒 블러] │ │ +│ │ 무료로 볼 수 있는 사진 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 기본 정보 (무료 공개) │ │ +│ │ • 차량명: 기아 K5 2024년형 │ │ +│ │ • 연식: 2024년 7월 │ │ +│ │ • 주행거리: 14,605 km │ │ +│ │ • 가격: $28,500 USDC │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 🔒 상세 정보 열람하기 (1 CC 필요) │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────────────┐ │ │ +│ │ │ [1 CC로 상세 정보 열람하기] │ │ │ +│ │ │ │ │ │ +│ │ │ 포함 내용: │ │ │ +│ │ │ ✓ 전체 사진 (20장) │ │ │ +│ │ │ ✓ 차량 성능표 │ │ │ +│ │ │ ✓ 상세 옵션 목록 │ │ │ +│ │ │ ✓ 연락처 (DamonHong +82-10-3331-5258) │ │ │ +│ │ └──────────────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 💳 결제 방식 + +| 방식 | 설명 | 수수료 | 구현 우선순위 | +|------|------|--------|---------------| +| **카드 결제** | Visa/Mastercard → USDC 자동 변환 | ~3% | Phase 1 | +| **Solana USDC 전송** | 사용자 지갑에서 직접 전송 | <$0.01 | Phase 1 | +| **Phantom Wallet 연동** | 원클릭 결제 | <$0.01 | Phase 2 | +| **TRC-20 USDT** | Tron 네트워크 (레거시 지원) | ~1 USDT | Phase 3 | + +### 💼 사용자 지갑 시스템 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 사용자 지갑 구조 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 사용자 계정 (users 테이블) │ │ +│ │ ├── user_id: 12345 │ │ +│ │ ├── email: user@example.com │ │ +│ │ ├── cc_balance: 23 CC (현재 보유 코인) │ │ +│ │ └── solana_wallet_address: "ABC123..." │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 플랫폼 Solana 지갑 (수신 전용) │ │ +│ │ ├── 주소: "PLATFORM_WALLET_ADDRESS..." │ │ +│ │ ├── USDC 수신 → 자동 CC 변환 (1 USDC = 10 CC) │ │ +│ │ └── 관리자만 출금 가능 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 📊 CC 거래 내역 관리 + +```sql +-- cc_transactions 테이블 +CREATE TABLE cc_transactions ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + type VARCHAR(20) NOT NULL, -- 'purchase', 'spend', 'bonus', 'refund' + amount INTEGER NOT NULL, -- CC 금액 (양수: 획득, 음수: 사용) + balance_after INTEGER NOT NULL, -- 거래 후 잔액 + reference_type VARCHAR(50), -- 'car_view', 'usdc_payment', 'card_payment', 'signup_bonus' + reference_id VARCHAR(100), -- 관련 ID (차량 ID, 결제 ID 등) + usdc_amount DECIMAL(12,2), -- USDC 결제 시 금액 + tx_hash VARCHAR(100), -- Solana 트랜잭션 해시 (USDC 결제 시) + created_at TIMESTAMP DEFAULT NOW() +); + +-- 차량 열람 기록 +CREATE TABLE car_views ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + car_id INTEGER REFERENCES cars(id), + cc_spent INTEGER DEFAULT 1, + viewed_at TIMESTAMP DEFAULT NOW(), + UNIQUE(user_id, car_id) -- 동일 차량 중복 결제 방지 +); +``` + +### 💰 가격 정책 + +> **CC 사용 기준**: 1 CC = 10대 차량 추천 받을 수 있음 + +| 충전 금액 (USD) | 받는 CC | 추천 가능 차량 | 할인율 | +|-----------------|---------|----------------|--------| +| $10 | 10 CC | 100대 | - | +| $27 | 30 CC | 300대 | 10% 할인 | +| $40 | 50 CC | 500대 | 20% 할인 | + +※ 결제 수단: Stripe (Visa/Mastercard), 몽골 파트너 계좌 (러시아 사용자) + +--- + +## 🔐 Solana USDC 지갑 통합 계획 + +### Phase 1: 기본 지갑 시스템 + +#### 1.1 플랫폼 수신 지갑 생성 +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 플랫폼 지갑 아키텍처 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ [플랫폼 메인 지갑] │ +│ ├── 주소: 공개 (사용자가 입금할 주소) │ +│ ├── 비밀키: 서버에서 안전하게 관리 (환경변수/Vault) │ +│ ├── 용도: USDC 수신 전용 │ +│ └── 모니터링: Webhook으로 입금 감지 │ +│ │ +│ [관리자 출금 지갑] │ +│ ├── 주소: 비공개 │ +│ ├── 비밀키: 콜드 스토리지 / 하드웨어 지갑 │ +│ └── 용도: 수익금 출금 │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### 1.2 USDC 입금 감지 시스템 +```python +# Solana USDC 입금 감지 서비스 +class SolanaPaymentService: + def __init__(self): + self.rpc_url = "https://api.mainnet-beta.solana.com" + self.usdc_mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" # USDC SPL Token + self.platform_wallet = os.getenv("PLATFORM_WALLET_ADDRESS") + + async def monitor_deposits(self): + """Webhook 또는 폴링으로 입금 감지""" + # 1. 새 USDC 전송 감지 + # 2. 메모 필드에서 user_id 추출 + # 3. CC 크레딧 자동 지급 + # 4. 트랜잭션 기록 저장 + pass + + async def credit_user(self, user_id: int, usdc_amount: float, tx_hash: str): + """USDC 입금 시 CC 자동 지급""" + cc_amount = int(usdc_amount * 10) # 1 USDC = 10 CC + # DB에 CC 잔액 업데이트 및 거래 기록 + pass +``` + +#### 1.3 입금 프로세스 (사용자 관점) +``` +1. 사용자가 "CC 충전하기" 클릭 +2. 입금 안내 페이지 표시: + - 플랫폼 Solana 지갑 주소 + - 메모(Memo): USER_12345 (사용자 식별용) + - QR 코드 (Solana Pay 호환) +3. 사용자가 Phantom/Solflare 등에서 USDC 전송 +4. 서버가 입금 감지 (1-5초 내) +5. 자동으로 CC 잔액 증가 +6. 푸시 알림 또는 화면 갱신 +``` + +### Phase 2: Phantom Wallet 연동 (원클릭 결제) + +#### 2.1 Solana Pay 통합 +```typescript +// Solana Pay를 이용한 원클릭 결제 +import { createQR, encodeURL, TransactionRequestURL } from '@solana/pay'; + +const createPaymentRequest = (userId: string, usdcAmount: number) => { + const url: TransactionRequestURL = { + recipient: PLATFORM_WALLET, + amount: new BigNumber(usdcAmount), + splToken: USDC_MINT, + reference: generateReference(), + label: 'MongolCar CC 충전', + message: `${usdcAmount} USDC → ${usdcAmount * 10} CC`, + memo: `USER_${userId}`, + }; + + return encodeURL(url); +}; +``` + +#### 2.2 Phantom Wallet Connect +```typescript +// Phantom 지갑 연결 +const connectPhantom = async () => { + if (window.solana?.isPhantom) { + const response = await window.solana.connect(); + const publicKey = response.publicKey.toString(); + // 사용자 계정에 지갑 주소 연결 + await api.linkWallet(publicKey); + } +}; + +// 원클릭 USDC 결제 +const payWithPhantom = async (usdcAmount: number) => { + const transaction = await buildUSDCTransferTx(usdcAmount); + const signedTx = await window.solana.signTransaction(transaction); + const txHash = await connection.sendRawTransaction(signedTx.serialize()); + return txHash; +}; +``` + +### Phase 3: 카드 결제 통합 + +#### 3.1 결제 게이트웨이 옵션 +| 서비스 | 특징 | 수수료 | 지원 통화 | +|--------|------|--------|-----------| +| **Stripe** | 글로벌, 안정적 | 2.9% + $0.30 | USD, EUR, KRW | +| **MoonPay** | 암호화폐 특화 | 4.5% | USDC 직접 구매 | +| **Transak** | 암호화폐 특화 | 3-5% | USDC 직접 구매 | +| **KG이니시스** | 한국 결제 | 3.3% | KRW | + +#### 3.2 카드 → USDC 변환 플로우 +``` +1. 사용자가 충전 금액 선택 (예: $10) +2. Stripe/MoonPay 결제 창 열림 +3. 카드 결제 완료 +4. 서버에서 결제 확인 +5. CC 잔액 자동 증가 (100 CC) +6. 완료 알림 +``` + +### 📁 필요한 DB 스키마 추가 + +```sql +-- 사용자 테이블 확장 +ALTER TABLE users ADD COLUMN cc_balance INTEGER DEFAULT 0; +ALTER TABLE users ADD COLUMN solana_wallet_address VARCHAR(44); +ALTER TABLE users ADD COLUMN linked_wallet_verified BOOLEAN DEFAULT FALSE; + +-- USDC 결제 기록 +CREATE TABLE usdc_payments ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + tx_hash VARCHAR(100) UNIQUE NOT NULL, + from_wallet VARCHAR(44) NOT NULL, + usdc_amount DECIMAL(12,6) NOT NULL, + cc_credited INTEGER NOT NULL, + bonus_cc INTEGER DEFAULT 0, + status VARCHAR(20) DEFAULT 'confirmed', -- pending, confirmed, failed + confirmed_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW() +); + +-- 카드 결제 기록 +CREATE TABLE card_payments ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + payment_provider VARCHAR(20) NOT NULL, -- stripe, moonpay, transak + provider_payment_id VARCHAR(100) UNIQUE, + amount_usd DECIMAL(10,2) NOT NULL, + cc_credited INTEGER NOT NULL, + bonus_cc INTEGER DEFAULT 0, + status VARCHAR(20) DEFAULT 'pending', + completed_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 🛠️ 구현 우선순위 + +| 순서 | 기능 | 예상 기간 | 설명 | +|------|------|-----------|------| +| 1 | CC 시스템 기본 구조 | 1주 | users 테이블 확장, cc_transactions 테이블, 잔액 관리 API | +| 2 | 차량 상세 열람 잠금 | 1주 | 사진 블러 처리, 상세 정보 잠금, CC 차감 로직 | +| 3 | 관리자 CC 수동 지급 | 2일 | 테스트/프로모션용 수동 지급 기능 | +| 4 | Solana 지갑 생성 | 3일 | 플랫폼 수신 지갑 생성, 주소 공개 | +| 5 | USDC 입금 감지 | 1주 | Solana RPC 모니터링, 자동 CC 지급 | +| 6 | Phantom Wallet 연동 | 1주 | 지갑 연결, Solana Pay QR 생성 | +| 7 | 카드 결제 (Stripe) | 1주 | 결제 위젯, 웹훅 처리 | +| 8 | MoonPay/Transak 통합 | 1주 | 암호화폐 직접 구매 옵션 | + +--- + +## 🌐 다국어 지원 + +### 지원 언어 + +| 코드 | 언어 | 용도 | +|------|------|------| +| `mn` | 몽골어 (Монгол) | 기본 언어 | +| `en` | 영어 (English) | 국제 | +| `ru` | 러시아어 (Русский) | 러시아, 중앙아시아 | +| `ko` | 한국어 | 관리자, 딜러 | + +### 번역 대상 + +- UI 텍스트 (정적) +- 차량 정보 (동적 - AI 번역) +- 채팅 메시지 (실시간 - Claude API) +- FAQ 응답 (사전 번역 + AI) + +--- + +## 💬 AI 채팅 시스템 + +### 기능 + +1. **실시간 번역**: 몽골어 ↔ 한국어 양방향 +2. **FAQ 자동 응답**: 딜러 부재 시 AI가 응답 +3. **맥락 인식**: 문의 중인 차량 정보 반영 + +### FAQ 카테고리 + +| 카테고리 | 예시 질문 | +|----------|-----------| +| `shipping` | 배송 기간, 방법 | +| `payment` | 결제 방법, 환불 | +| `document` | 필요 서류, 관세 | +| `vehicle` | 차량 상태, 옵션 | +| `general` | 영업시간, 연락처 | + +--- + +## 🔧 개발 단계별 작업 + +### Phase 1: 인프라 구축 (1주) + +**목표**: 서버 환경 설정 및 기본 인프라 구축 + +```bash +# 작업 목록 +□ 우분투 서버 2대 OS 설치 및 기본 설정 +□ PostgreSQL 16 설치 및 설정 +□ Redis 7 설치 +□ Nginx 설치 및 SSL 설정 +□ Node.js 20 LTS 설치 +□ PM2 설치 +□ 방화벽 설정 +□ SSH 키 기반 인증 설정 +□ 서버 간 내부 네트워크 설정 +□ 데이터베이스 스키마 생성 +``` + +**Claude Code 명령 예시**: +``` +서버 초기 설정 스크립트 작성해줘 (deploy/scripts/setup-server.sh) +- PostgreSQL 16 설치 +- Redis 7 설치 +- Nginx 설치 +- Node.js 20 설치 +- PM2 전역 설치 +- 기본 보안 설정 +``` + +--- + +### Phase 2: Windows 에이전트 (2~3주) ⭐ 핵심 + +**목표**: 카모두 프로그램 자동화 + +```bash +# 작업 목록 +□ 카모두 프로그램 UI 구조 분석 (Inspect.exe) +□ Python 에이전트 기본 구조 개발 +□ 프로그램 연결 기능 +□ 차량 검색 자동화 +□ 검색 결과 추출 +□ 상세 정보 추출 +□ 이미지 추출 (20장) +□ API 클라이언트 연동 +□ 에러 처리 및 재시도 로직 +□ 로깅 시스템 +□ Windows 서비스로 등록 +``` + +**Claude Code 명령 예시**: +``` +카모두 프로그램 자동화 에이전트 개발해줘 (agent/src/) +- pywinauto 기반 +- 검색 조건: 제조사, 모델, 연식, 마일리지, 가격 +- 결과 추출: 기본 정보 + 상세 정보 + 이미지 20장 +- 비동기 처리 (asyncio) +- API 서버와 통신 +``` + +--- + +### Phase 3: 백엔드 API (2주) + +**목표**: REST API 서버 개발 + +```bash +# 작업 목록 +□ Express.js 프로젝트 설정 +□ Prisma 스키마 및 마이그레이션 +□ 인증 API (회원가입/로그인/JWT) +□ 차량 검색 API +□ 차량 상세 조회 API (포인트 차감) +□ 결제 API (카드/직접전송) +□ NOWPayments 웹훅 처리 +□ 포인트 관리 API +□ 채팅 API +□ 에이전트 통신 API +□ Bull 큐 (검색 요청 처리) +□ 에러 핸들링 +□ API 문서화 (Swagger) +``` + +**Claude Code 명령 예시**: +``` +Express.js 백엔드 API 개발해줘 (apps/api/src/) +- TypeScript 기반 +- Prisma ORM +- JWT 인증 +- 차량 검색/조회 API +- 결제 API (NOWPayments 연동) +- Socket.io 채팅 +``` + +--- + +### Phase 4: 프론트엔드 (2주) + +**목표**: Next.js 웹 애플리케이션 개발 + +```bash +# 작업 목록 +□ Next.js 14 프로젝트 설정 +□ 다국어 설정 (next-intl) +□ 레이아웃 및 공통 컴포넌트 +□ 메인 페이지 (차량 검색 폼) +□ 검색 결과 페이지 +□ 차량 상세 페이지 +□ 이미지 갤러리 +□ 회원가입/로그인 페이지 +□ 마이페이지 (포인트, 열람기록) +□ 결제 페이지 (카드/직접전송) +□ QR 코드 표시 +□ 반응형 디자인 +□ 로딩/에러 상태 처리 +``` + +**Claude Code 명령 예시**: +``` +Next.js 14 프론트엔드 개발해줘 (apps/web/src/) +- App Router 사용 +- TypeScript +- Tailwind CSS +- 다국어 (몽골어/영어/러시아어/한국어) +- 차량 검색/목록/상세 페이지 +- 결제 모달 (USDT/USDC) +``` + +--- + +### Phase 5: 채팅 + 결제 통합 (1~2주) + +**목표**: 실시간 채팅 및 결제 시스템 완성 + +```bash +# 작업 목록 +□ Socket.io 서버 설정 +□ 채팅방 생성/입장 +□ 실시간 메시지 전송 +□ Claude API 번역 연동 +□ FAQ 자동 응답 로직 +□ 채팅 UI 컴포넌트 +□ 결제 플로우 테스트 +□ 웹훅 테스트 +□ 포인트 충전 확인 +``` + +**Claude Code 명령 예시**: +``` +실시간 번역 채팅 시스템 개발해줘 +- Socket.io 기반 +- Claude API로 몽골어↔한국어 번역 +- FAQ 자동 응답 +- 채팅 UI 컴포넌트 +``` + +--- + +### Phase 6: 테스트 및 배포 (1주) + +**목표**: 전체 시스템 테스트 및 프로덕션 배포 + +```bash +# 작업 목록 +□ 단위 테스트 작성 +□ 통합 테스트 +□ E2E 테스트 (검색→결제→열람) +□ 성능 테스트 +□ 보안 점검 +□ SSL 인증서 적용 +□ PM2 설정 +□ Nginx 설정 최적화 +□ 모니터링 설정 +□ 백업 스크립트 설정 +□ 프로덕션 배포 +□ DNS 설정 +``` + +**Claude Code 명령 예시**: +``` +배포 스크립트 작성해줘 (deploy/) +- Nginx 설정 +- PM2 ecosystem 설정 +- systemd 서비스 파일 +- 자동 배포 스크립트 +``` + +--- + +## ⚙️ 환경 변수 + +### 웹서버 (.env) + +```env +# 서버 +NODE_ENV=production +PORT=4000 +FRONTEND_URL=https://your-domain.com +API_BASE_URL=https://your-domain.com/api + +# 데이터베이스 +DATABASE_URL=postgresql://user:password@db-server:5432/mongolcar + +# Redis +REDIS_URL=redis://localhost:6379 + +# JWT +JWT_SECRET=your-jwt-secret-key +JWT_EXPIRES_IN=7d + +# Claude API +CLAUDE_API_KEY=your-claude-api-key + +# NOWPayments +NOWPAYMENTS_API_KEY=your-nowpayments-api-key +NOWPAYMENTS_IPN_SECRET=your-ipn-secret + +# 에이전트 +AGENT_API_KEY=your-agent-api-key + +# 이미지 저장 +IMAGE_STORAGE_PATH=/var/www/car-images +IMAGE_BASE_URL=https://your-domain.com/images +``` + +### Windows 에이전트 (config.yaml) + +```yaml +api: + base_url: https://your-domain.com/api + api_key: your-agent-api-key + +carmodoo: + process_name: GGKucar + search_timeout: 30 + detail_timeout: 10 + +polling: + interval: 5 # 초 + +logging: + level: INFO + file: agent.log +``` + +--- + +## 🚀 Claude Code 개발 시작 명령 + +### 1. 프로젝트 초기화 + +``` +몽골 중고차 수출 플랫폼 프로젝트 초기화해줘 +- monorepo 구조 (apps/web, apps/api, packages/database, agent) +- TypeScript 설정 +- ESLint, Prettier 설정 +- 기본 README 작성 +``` + +### 2. 데이터베이스 설정 + +``` +PostgreSQL 데이터베이스 스키마 작성해줘 (database/schema.sql) +- 위 계획서의 테이블 구조 기반 +- 인덱스 포함 +- 초기 데이터 시드 파일도 작성 +``` + +### 3. 백엔드 개발 + +``` +Express.js API 서버 개발 시작해줘 (apps/api/) +- 먼저 프로젝트 구조 설정 +- 인증 API부터 시작 +- Prisma 스키마 작성 +``` + +### 4. 프론트엔드 개발 + +``` +Next.js 14 프론트엔드 개발 시작해줘 (apps/web/) +- App Router 사용 +- 다국어 설정 (next-intl) +- 메인 페이지 (차량 검색) 먼저 개발 +``` + +### 5. 에이전트 개발 + +``` +카모두 자동화 에이전트 개발해줘 (agent/) +- Python 프로젝트 구조 설정 +- pywinauto 기반 UI 자동화 +- 검색 및 데이터 추출 기능 +``` + +--- + +## 📝 참고사항 + +### 카모두 프로그램 정보 + +- **개발사**: (주)이엠아이앤씨 +- **용도**: 경기도자동차매매사업조합 딜러 전용 +- **기술**: .NET Assembly (WinForms/WPF 추정) +- **파일**: GGKucar.exe (64KB, 런처) + +### 주요 외부 서비스 + +| 서비스 | 용도 | 링크 | +|--------|------|------| +| NOWPayments | 암호화폐 결제 | https://nowpayments.io | +| Anthropic Claude | AI 번역/챗봇 | https://anthropic.com | +| ExchangeRate API | 환율 조회 | https://exchangerate-api.com | + +--- + +## 📞 문의사항 + +### 대표 연락처 (모든 딜러 연락처 통일) +- **담당자**: DamonHong +- **전화**: +82-10-3331-5258 + +### 개발 진행 중 추가 정보가 필요한 사항: + +1. **카모두 프로그램 UI 스크린샷** - 자동화 개발에 필요 +2. **도메인 및 SSL 인증서** - 배포 시 필요 +3. **NOWPayments 계정** - 결제 테스트에 필요 +4. **Claude API 키** - 번역/챗봇 개발에 필요 + +--- + +## 🔍 카모두 API 분석 (Reverse Engineering) + +### 개요 + +Fiddler를 통해 캡처한 카모두 프로그램(GGKucar.exe)의 네트워크 트래픽 분석 결과입니다. + +### 기본 정보 + +| 항목 | 내용 | +|------|------| +| **도메인** | dealer.carmodoo.com (메인), ck.carmodoo.com (성능점검) | +| **프로토콜** | HTTPS (TLS 1.2/1.3) | +| **인증 방식** | 세션 기반 (PHPSESSID 쿠키) | +| **서버** | Apache/2.4.6 (CentOS), PHP/5.4.16 | +| **인코딩** | EUC-KR | + +### 인증 API + +#### 1. 로그인 +```http +POST https://dealer.carmodoo.com/member/login_ok.html +Content-Type: application/x-www-form-urlencoded + +prevURL=&id={전화번호}&passwd={비밀번호}&idSave=Y&button=LOGIN +``` + +**응답**: +```html + +``` + +**인증 쿠키**: `PHPSESSID`, `idSave` + +#### 2. 세션 유지 +```http +POST https://dealer.carmodoo.com/common/ajax/sessionHold.html +X-Requested-With: XMLHttpRequest +Cookie: PHPSESSID={세션ID} +``` + +### 차량 데이터 API + +#### 1. 제조사/모델 코드 조회 (AutoDBCode.html) + +**초기 데이터 로드**: +```http +GET https://dealer.carmodoo.com/common/ajax/AutoDBCode.html?mode=getCarInit&ctl=car +``` + +**제조사별 모델 목록**: +```http +GET https://dealer.carmodoo.com/common/ajax/AutoDBCode.html?mode=getCarModelInit&ctl=car&company={제조사ID} +``` + +**모델별 세부 모델**: +```http +GET https://dealer.carmodoo.com/common/ajax/AutoDBCode.html?mode=getCarModel&ctl=car&company={제조사ID}&c_nameInit={모델ID} +``` + +**세부 모델별 연식**: +```http +GET https://dealer.carmodoo.com/common/ajax/AutoDBCode.html?mode=getCarYear&ctl=car&c_name={모델코드} +``` + +**시리즈 조회**: +```http +GET https://dealer.carmodoo.com/common/ajax/AutoDBCode.html?mode=getCarSeries&ctl=car&c_name={모델코드} +``` + +**세부사양 조회**: +```http +GET https://dealer.carmodoo.com/common/ajax/AutoDBCode.html?mode=getCarSdetail&ctl=car&series={시리즈코드} +``` + +**응답 형식** (XML): +```xml + + + car + + 5 + + 1 + c_bmNo + + ... + +``` + +**주요 제조사 코드**: +| 코드 | 제조사 | +|------|--------| +| 5 | 현대 | +| 2 | 기아 | +| 1 | 쌍용 | +| 3 | 르노 | +| 67 | BMW | +| 68 | 벤츠 | +| 70 | 아우디 | + +#### 2. 차량 목록 조회 (AutoDBProc.html) + +**관심 차량 조회**: +```http +GET https://dealer.carmodoo.com/common/ajax/AutoDBProc.html?mode=getInterest&iStr={차량번호들!구분} +``` + +예시: `iStr=6290623!6290630!6290644!...` + +**응답**: `Y|` (관심등록 여부) + +**관심 차량 추가**: +```http +POST https://dealer.carmodoo.com/common/ajax/AutoDBProc.html +Content-Type: application/x-www-form-urlencoded + +mode=addInterest&c_no={차량번호}&memo={메모} +``` + +**관심 차량 삭제**: +```http +GET https://dealer.carmodoo.com/common/ajax/AutoDBProc.html?mode=delInterest&c_no={차량번호} +``` + +#### 3. 차량 상세 조회 (AutoDB.html) + +```http +GET https://dealer.carmodoo.com/common/ajax/AutoDB.html?mode=view&key={암호화된키} +``` + +**key 파라미터**: Base64 인코딩된 암호화 문자열 +예시: `U1VqWHNxc2FOaDY0LzAyYUtaRDdibTdQZnc4cXZ4dXh6czVyelJjdHRHcz0` + +**응답 형식** (XML): +```xml + + 6320434 + 1 + 2828 + 2020년형 기아 K5 DL3 2.0 가솔린 + 차량 제목 + 오토 + 가솔린 + 45000 + 흰색 + 12가3456 + 25000000 + 2020년 06월 + 2020 + 6 + 1998 + 0 + 0 + 메모 내용 + 1#5#12#34#... + /data/__carPhoto/006/320/434/cmcar_0.jpg + /data/__carPhoto/006/320/434/cmcar_0.jpg|cmcar_1.jpg|... + 썸네일 경로들 + 123456789 + 성능점검 URL + ggkucar + 딜러명 + 010-1234-5678 + 매매상사명 + 031-123-4567 + +``` + +#### 4. 차량 상세 페이지 (HTML) + +```http +GET http://dealer.carmodoo.com/car/dealerCarView.html?key={암호화된키}&tabStart=1 +``` + +### 이미지 URL 패턴 + +**원본 이미지**: +``` +http://dealer.carmodoo.com/data/__carPhoto/{3자리}/{3자리}/{3자리}/cmcar_{순번}.jpg +``` + +**썸네일 이미지**: +``` +http://dealer.carmodoo.com/data/__carPhoto/{3자리}/{3자리}/{3자리}/cmcar_{순번}.jpg__THUM +``` + +**URL 구조 설명**: +- 차량번호 6320434의 경우: `/006/320/434/` +- 첫 번째 이미지: `cmcar_0.jpg` +- 두 번째 이미지: `cmcar_1.jpg` + +### 성능점검 API + +```http +GET https://ck.carmodoo.com/carCheck/carmodooPrint.do?checkNum={성능점검번호} +``` + +### 시세 조회 API + +```http +GET https://dealer.carmodoo.com/common/ajax/SiseDB.html?mode=getSiseData&ctl={구분}&company={제조사코드}&c_name={모델코드} +``` + +### Python 에이전트 구현 참고 + +```python +import aiohttp +import asyncio +from urllib.parse import urlencode + +class CarmodooClient: + BASE_URL = "https://dealer.carmodoo.com" + + def __init__(self): + self.session = None + self.cookies = {} + + async def login(self, user_id: str, password: str) -> bool: + """로그인 및 세션 쿠키 획득""" + async with aiohttp.ClientSession() as session: + data = { + 'prevURL': '', + 'id': user_id, + 'passwd': password, + 'idSave': 'Y', + 'button': 'LOGIN' + } + async with session.post( + f"{self.BASE_URL}/member/login_ok.html", + data=data, + headers={'Content-Type': 'application/x-www-form-urlencoded'} + ) as resp: + if resp.status == 200: + self.cookies = {c.key: c.value for c in session.cookie_jar} + return 'goMain' in await resp.text() + return False + + async def get_car_makers(self) -> list: + """제조사 목록 조회""" + params = {'mode': 'getCarInit', 'ctl': 'car'} + async with aiohttp.ClientSession(cookies=self.cookies) as session: + async with session.get( + f"{self.BASE_URL}/common/ajax/AutoDBCode.html", + params=params + ) as resp: + # XML 파싱 후 반환 + pass + + async def get_car_detail(self, key: str) -> dict: + """차량 상세 정보 조회""" + params = {'mode': 'view', 'key': key} + async with aiohttp.ClientSession(cookies=self.cookies) as session: + async with session.get( + f"{self.BASE_URL}/common/ajax/AutoDB.html", + params=params + ) as resp: + # XML 파싱 후 반환 + pass + + async def download_image(self, car_no: int, index: int) -> bytes: + """차량 이미지 다운로드""" + # 차량번호를 3자리씩 분할 + path = f"{car_no:09d}" + folder = f"{path[0:3]}/{path[3:6]}/{path[6:9]}" + url = f"{self.BASE_URL}/data/__carPhoto/{folder}/cmcar_{index}.jpg" + + async with aiohttp.ClientSession(cookies=self.cookies) as session: + async with session.get(url) as resp: + if resp.status == 200: + return await resp.read() + return None +``` + +### 주요 데이터 필드 매핑 + +| carmodoo 필드 | 의미 | DB 필드 | +|---------------|------|---------| +| c_no / no | 차량 고유번호 | source_id | +| carName | 차량명 (풀네임) | - | +| c_bmNo | 제조사 코드 | make | +| c_boNo | 모델 코드 | model | +| c_year | 연식 | year | +| c_mileage | 주행거리 | mileage | +| dPrice | 판매가격 | price | +| c_fuel | 연료 | fuel_type | +| c_gearbox | 변속기 | transmission | +| c_colorName | 색상 | color | +| c_displacement | 배기량 | engine_cc | +| c_options | 옵션 (#구분) | options | +| c_checkNum | 성능점검번호 | inspection_data | +| c_seize | 압류 건수 | - | +| c_collateral | 저당 건수 | - | + +### 보안 고려사항 + +1. **HTTPS 필수**: 모든 요청은 HTTPS로 전송 +2. **세션 관리**: PHPSESSID 쿠키 유지 필요 +3. **Rate Limiting**: 과도한 요청 시 차단 가능성 +4. **인코딩**: EUC-KR → UTF-8 변환 필요 + +--- + +## 구현 완료 기능 (2024-12-25) + +### 상세사양조회 시스템 + +차량번호 기반으로 AUTOBEGINS 서비스에서 상세사양을 조회하는 시스템이 구현되었습니다. + +**주요 기능**: +- Playwright 브라우저 자동화로 Carmodoo 딜러 포탈의 AUTOBEGINS iframe 접근 +- 차량번호 입력 → 상세사양 파싱 → DB 저장 +- 배너 등록(import) 시 자동 사양 조회 + +**조회 가능 정보**: +| 카테고리 | 정보 | +|----------|------| +| 기본 정보 | 제조사, 모델명, 등급, 연식 | +| 엔진/구동 | 배기량, 연료, 변속기, 구동방식 | +| 성능 | 최대출력, 최대토크, 연비 | +| 차체 | 차체형식, 도어수, 승차정원, 전장/전폭/전고/휠베이스 | +| 가격 | 출고가, 기본가, 옵션가 | + +**관련 파일**: +- `backend/app/services/spec_service.py` - 사양 조회 로직 +- `backend/app/models/car_specification.py` - CarSpecification 모델 +- `backend/app/api/carmodoo.py` - API 엔드포인트 + +### 딜러 상세설명 + +Carmodoo 상세페이지에서 딜러가 작성한 설명을 추출하여 저장합니다. + +**추출 방식** (2024-12-25 업데이트): +1. 차량 검색 결과에서 `dealerCarviewPopup('암호화키')` JavaScript 호출 패턴을 파싱하여 `car_key` 추출 +2. `dealerCarView.html?key=&tabStart=1` URL로 딜러 상세페이지 조회 +3. `

상세설명

...
` HTML 구조에서 설명 추출 +4. `cars.dealer_description` 컬럼에 저장 + +**주의**: 이전의 `carPopView.html`은 더 이상 작동하지 않음 (404 반환) + +**관련 코드**: +- `carmodoo.py: _parse_car_list_html()` - car_key 추출 (dealerCarviewPopup 패턴) +- `carmodoo.py: get_car_detail()` - dealerCarView.html에서 상세설명 파싱 +- 차량 상세 페이지에서 amber 배경으로 표시 (로그인 사용자만) + +### Quote Request 1CC 결제 + +차량 추천 요청 시 1 CC가 차감되도록 구현되었습니다. + +**흐름**: +1. 사용자가 `/vehicle-request` 페이지에서 조건 입력 +2. 제출 시 CC 잔액 확인 (1 CC 필요) +3. 잔액 부족 시 `/cc` 충전 페이지로 안내 +4. CC 차감 후 요청 생성 + +**관련 파일**: +- `backend/app/api/vehicle_requests.py` - CC 차감 로직 +- `backend/app/models/vehicle_request.py` - cc_paid 컬럼 +- `frontend/src/app/vehicle-request/page.tsx` - UI (CC 잔액 표시, 비용 안내) + +--- + +*마지막 업데이트: 2024년 12월 25일* + +--- + +## 변경 이력 + +| 날짜 | 변경 내용 | +|------|----------| +| 2024-12-25 | 딜러 상세설명 추출 방식 변경 (carPopView.html → dealerCarView.html + car_key) | +| 2024-12-25 | car_key 필드 추가 (CarmodooSearchResultItem, AdminSearchResultItem, ImportCarRequest) | +| 2024-12-25 | 프론트엔드 api.ts, hero-banners/page.tsx에 car_key 연동 | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..acd18b8 --- /dev/null +++ b/CLAUDE.md @@ -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 + +``` + +### 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> = { + // 연료 타입 + '휘발유': { 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 {translate(car.fuel)}; // '휘발유' → '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=` URL로 상세 페이지 접근 +3. `

상세설명

...
` 파싱 + +```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 디코딩 후 상세설명 추출 + #
...
설명
+``` + +**관련 필드**: +- `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 검색에 주행거리 필터 추가 | + +--- + +**이 문서는 새 세션 시작 시 반드시 읽어주세요!** diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..9e22ba8 --- /dev/null +++ b/DEPLOYMENT_GUIDE.md @@ -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 + +# 또는 프로세스 이름으로 종료 +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* diff --git a/Doc/FILE_SERVER_SETUP_2025-12-05.md b/Doc/FILE_SERVER_SETUP_2025-12-05.md new file mode 100644 index 0000000..e16ba21 --- /dev/null +++ b/Doc/FILE_SERVER_SETUP_2025-12-05.md @@ -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* diff --git a/Doc/관세.md b/Doc/관세.md new file mode 100644 index 0000000..e1ae1e8 --- /dev/null +++ b/Doc/관세.md @@ -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.* diff --git a/Doc/에스크로.md b/Doc/에스크로.md new file mode 100644 index 0000000..d3e5b84 --- /dev/null +++ b/Doc/에스크로.md @@ -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 ( + + ) +} +``` + +**참고 링크:** +- 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* diff --git a/PROGRESS_ReadMe.md b/PROGRESS_ReadMe.md new file mode 100644 index 0000000..48f786d --- /dev/null +++ b/PROGRESS_ReadMe.md @@ -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 # 프로세스 종료 +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* diff --git a/SERVER_INFRASTRUCTURE_PLAN.md b/SERVER_INFRASTRUCTURE_PLAN.md new file mode 100644 index 0000000..995bd2f --- /dev/null +++ b/SERVER_INFRASTRUCTURE_PLAN.md @@ -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* diff --git a/agent/Dockerfile b/agent/Dockerfile new file mode 100644 index 0000000..4ed8f1a --- /dev/null +++ b/agent/Dockerfile @@ -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"] diff --git a/agent/requirements.txt b/agent/requirements.txt new file mode 100644 index 0000000..e0dd7d4 --- /dev/null +++ b/agent/requirements.txt @@ -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 diff --git a/agent/src/__init__.py b/agent/src/__init__.py new file mode 100644 index 0000000..6a45a32 --- /dev/null +++ b/agent/src/__init__.py @@ -0,0 +1 @@ +# Carmodoo Agent diff --git a/agent/src/carmodoo_client.py b/agent/src/carmodoo_client.py new file mode 100644 index 0000000..a624378 --- /dev/null +++ b/agent/src/carmodoo_client.py @@ -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(' 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" diff --git a/agent/src/sync_agent.py b/agent/src/sync_agent.py new file mode 100644 index 0000000..318ad1f --- /dev/null +++ b/agent/src/sync_agent.py @@ -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()) diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..f713c37 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..eb3faa5 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/SearchCarNum_full.js b/backend/SearchCarNum_full.js new file mode 100644 index 0000000..f597a93 --- /dev/null +++ b/backend/SearchCarNum_full.js @@ -0,0 +1 @@ +Error: Failed to fetch \ No newline at end of file diff --git a/backend/add_exchange_rate_columns.py b/backend/add_exchange_rate_columns.py new file mode 100644 index 0000000..6feaa40 --- /dev/null +++ b/backend/add_exchange_rate_columns.py @@ -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() diff --git a/backend/add_pdf_path_column.py b/backend/add_pdf_path_column.py new file mode 100644 index 0000000..c92cdc1 --- /dev/null +++ b/backend/add_pdf_path_column.py @@ -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() diff --git a/backend/analyze_page.py b/backend/analyze_page.py new file mode 100644 index 0000000..11ba621 --- /dev/null +++ b/backend/analyze_page.py @@ -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()) diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..255ebcf --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# AutonetSellCar Backend API diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..0154438 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +# API Routes diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..1342a61 --- /dev/null +++ b/backend/app/api/auth.py @@ -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 + } diff --git a/backend/app/api/carmodoo.py b/backend/app/api/carmodoo.py new file mode 100644 index 0000000..9d84f9f --- /dev/null +++ b/backend/app/api/carmodoo.py @@ -0,0 +1,2691 @@ +""" +Carmodoo API - 실제 카모두 API 연동 +""" + +import asyncio +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session +from typing import Optional, List +from pydantic import BaseModel +import httpx +import json +import re +import os +from pathlib import Path +from lxml import etree +from lxml import html as lxml_html +from ..database import get_db +from ..models import Car, CarMaker, CarModel, CarImage, CarOption, CarPerformanceCheck, CarSpecification, PerformanceCheckView, CarView, User +from ..models.settings import SystemSettings +from ..api.auth import get_current_user, get_current_user_optional, get_current_admin_user +from ..services.cache_service import CacheService +from ..services.pdf_service import capture_performance_check_pdf, get_pdf_full_path, get_pdf_failures, clear_pdf_failure +from ..services.spec_service import get_specifications_from_carmodoo, spec_to_dict +from ..services.sensitive_filter import detect_sensitive_info, mask_sensitive_info, highlight_sensitive_info, has_sensitive_info, get_sensitivity_summary +from ..services.translation_service import translate_dealer_description, get_translation_service + +router = APIRouter(prefix="/carmodoo", tags=["carmodoo"]) + +# Carmodoo 설정 +CARMODOO_BASE_URL = "https://dealer.carmodoo.com" +CARMODOO_USER_ID = os.getenv("CARMODOO_USER_ID", "01033315258") +CARMODOO_PASSWORD = os.getenv("CARMODOO_PASSWORD", "alskfl@1122") + +# JSON 데이터 로드 +DATA_PATH = Path(__file__).parent.parent / "data" / "carmodoo_makers_models.json" + +def load_makers_models(): + """JSON 파일에서 제조사/모델 데이터 로드""" + try: + with open(DATA_PATH, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + print(f"Error loading makers/models: {e}") + return {"makers": [], "models": {}} + +CARMODOO_DATA = load_makers_models() + + +# Response schemas +class CarmodooMaker(BaseModel): + code: str + name: str + + +class CarmodooModel(BaseModel): + code: str + name: str + + +class CarmodooSearchResultItem(BaseModel): + id: str + car_name: str + maker_name: Optional[str] = None + model_name: Optional[str] = None + year: Optional[int] = None + mileage: Optional[int] = None + original_price: Optional[int] = None + korea_margin: Optional[int] = None + mongolia_margin: Optional[int] = None + final_price: Optional[int] = None + fuel: Optional[str] = None + transmission: Optional[str] = None + color: Optional[str] = None + displacement: Optional[int] = None + main_image: Optional[str] = None + image_count: int = 20 + check_num: Optional[str] = None # 성능점검번호 (Performance check number) + car_key: Optional[str] = None # 암호화된 차량 키 (dealerCarviewPopup용) + + +class VehicleRequestSearchResponse(BaseModel): + total: int + cars: List[CarmodooSearchResultItem] + + +class CarmodooClient: + """카모두 API 클라이언트""" + + def __init__(self): + self.cookies = {} + self.is_logged_in = False + 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', + } + + def _decode_response(self, content: bytes) -> str: + """EUC-KR 응답 디코딩""" + try: + return content.decode('euc-kr') + except UnicodeDecodeError: + try: + return content.decode('utf-8') + except UnicodeDecodeError: + return content.decode('latin-1') + + def _clean_xml_bytes(self, content: bytes) -> bytes: + """XML 정리""" + try: + text = content.decode('euc-kr') + 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(' 0: + text = text[xml_start:] + + text = re.sub(r'encoding=["\'][^"\']*["\']', 'encoding="UTF-8"', text) + return text.encode('utf-8') + + async def login(self) -> bool: + """카모두 로그인""" + url = f"{CARMODOO_BASE_URL}/member/login_ok.html" + data = { + 'prevURL': '', + 'id': CARMODOO_USER_ID, + 'passwd': CARMODOO_PASSWORD, + 'idSave': 'Y', + 'button': 'LOGIN' + } + + headers = { + **self.headers, + 'Content-Type': 'application/x-www-form-urlencoded', + 'Origin': CARMODOO_BASE_URL, + 'Referer': f'{CARMODOO_BASE_URL}/member/login_v2.html', + } + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + url, + data=data, + headers=headers, + follow_redirects=False + ) + + text = self._decode_response(response.content) + + if 'goMain' in text or 'PHPSESSID' in str(response.cookies): + self.cookies = dict(response.cookies) + self.is_logged_in = True + print("Carmodoo login successful") + return True + else: + print("Carmodoo login failed") + return False + except Exception as e: + print(f"Carmodoo login error: {e}") + return False + + async def search_cars( + self, + maker_code: Optional[str] = None, + model_code: Optional[str] = None, + 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, + page: int = 1, + page_size: int = 50 + ) -> List[dict]: + """차량 검색 (POST 방식 AJAX API 호출)""" + + print(f"[search_cars] Called with maker={maker_code}, model={model_code}, is_logged_in={self.is_logged_in}") + + if not self.is_logged_in: + print("[search_cars] Not logged in, attempting login...") + login_result = await self.login() + print(f"[search_cars] Login result: {login_result}") + + try: + async with httpx.AsyncClient(timeout=30.0, cookies=self.cookies) as client: + # AJAX 엔드포인트 - POST 방식으로 호출 + ajax_url = f"{CARMODOO_BASE_URL}/car/_inc_carListPhoto.html" + + # POST form data + form_data = { + 'sf_page': str(page - 1), # 0-based + 'sf_pageSize': str(page_size), + } + + if maker_code: + form_data['c_bmNo'] = maker_code # 제조사 (Brand/Maker) + if model_code: + form_data['c_boInitNo'] = model_code # 모델 이니셜 (Model Init - c_boInitNo가 정확한 파라미터) + if year_min: + form_data['c_year1'] = str(year_min) + if year_max: + form_data['c_year2'] = str(year_max) + if mileage_min: + form_data['c_mileage1'] = str(mileage_min) + if mileage_max: + form_data['c_mileage2'] = str(mileage_max) + if price_min: + form_data['c_price1'] = str(price_min) + if price_max: + form_data['c_price2'] = str(price_max) + if fuel: + # 연료 타입 매핑 (프론트엔드 값 -> 카모두 API 값) + # 카모두 API는 dFuel[] 파라미터 (체크박스 배열)를 사용함 + fuel_map = { + '가솔린': '휘발유', + '디젤': '경유', + 'LPG': 'LPG', + '하이브리드': '하이브리드', + '전기': '전기', + 'CNG': 'CNG', + } + if fuel in fuel_map: + form_data['dFuel[]'] = fuel_map[fuel] + if transmission: + # 변속기 타입 매핑 (프론트엔드 값 -> 카모두 API 값) + # 카모두 API는 dGA 파라미터에 한글 텍스트 값을 사용함 + trans_map = { + '자동': '오토', + '수동': '수동', + '세미오토': '세미오토', + 'CVT': 'CVT', + } + if transmission in trans_map: + form_data['dGA'] = trans_map[transmission] + + ajax_headers = { + **self.headers, + 'Accept': 'text/html, */*; q=0.01', + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/x-www-form-urlencoded; charset=EUC-KR', + 'Origin': CARMODOO_BASE_URL, + 'Referer': f'{CARMODOO_BASE_URL}/car/carListPhoto.html', + } + + # EUC-KR 인코딩으로 form data 생성 (한글 필터 지원) + import urllib.parse + encoded_parts = [] + for key, value in form_data.items(): + # 한글 값을 EUC-KR로 인코딩 후 URL 인코딩 + if isinstance(value, str): + try: + encoded_value = urllib.parse.quote(value.encode('euc-kr'), safe='') + except UnicodeEncodeError: + encoded_value = urllib.parse.quote(value, safe='') + else: + encoded_value = str(value) + encoded_parts.append(f"{key}={encoded_value}") + encoded_body = '&'.join(encoded_parts).encode('ascii') + + response = await client.post( + ajax_url, + content=encoded_body, + headers=ajax_headers, + ) + + print(f"[search_cars] Response status: {response.status_code}, content length: {len(response.content)}") + + if response.status_code == 200: + html = self._decode_response(response.content) + print(f"[search_cars] Decoded HTML length: {len(html)}") + + # 전체 개수 파싱 (totalCntUpdate(N) 패턴) + total_count = 0 + total_match = re.search(r'totalCntUpdate\((\d+)\)', html) + if total_match: + total_count = int(total_match.group(1)) + print(f"[search_cars] Total count from API: {total_count}") + + cars = self._parse_car_list_html(html) + print(f"[search_cars] Parsed {len(cars)} cars from HTML") + + # 전체 개수를 메타데이터로 저장 + if cars: + cars[0]["_total_count"] = total_count + + # check_num이 없는 차량에 대해 상세 페이지에서 재시도 + cars_without_check = [c for c in cars if not c.get("check_num")] + if cars_without_check: + print(f"[Carmodoo] {len(cars_without_check)}개 차량의 성능점검번호를 상세 페이지에서 가져옵니다...") + retry_count = len(cars_without_check) + retry_success = 0 + for car in cars_without_check: + try: + await asyncio.sleep(0.3) # 서버 부하 방지 + check_num = await self.get_car_check_num(car["car_no"], car.get("car_key", "")) + if check_num: + car["check_num"] = check_num + retry_success += 1 + print(f" - {car['car_no']}: check_num={check_num} (재시도 성공)") + else: + print(f" - {car['car_no']}: check_num 없음 (재시도 실패)") + except Exception as e: + print(f" - {car['car_no']}: 재시도 오류 - {e}") + + # 재시도 통계를 첫 번째 차량에 포함 + if cars: + cars[0]["_retry_count"] = retry_count + cars[0]["_retry_success"] = retry_success + + return cars + + except Exception as e: + print(f"Search error: {e}") + + return [] + + async def search_cars_by_year_segment( + self, + maker_code: str, + model_code: str, + year_start: int = 2010, + year_end: int = None + ) -> List[dict]: + """연도별 분할 검색으로 더 많은 차량 수집 + + 카모두 API는 한 번에 최대 50대만 반환하므로, + 연도별로 나누어 검색하여 더 많은 차량을 수집합니다. + """ + from datetime import datetime + + if not self.is_logged_in: + await self.login() + + if year_end is None: + year_end = datetime.now().year + 1 + + all_cars = [] + seen_car_ids = set() + + for year in range(year_end, year_start - 1, -1): + try: + cars = await self.search_cars( + maker_code=maker_code, + model_code=model_code, + year_min=year, + year_max=year, + page=1, + page_size=50 + ) + + # 중복 제거 + for car in cars: + car_id = car.get("car_no") + if car_id and car_id not in seen_car_ids: + seen_car_ids.add(car_id) + all_cars.append(car) + + # 카모두 서버 부하 방지 + await asyncio.sleep(0.3) + + except Exception as e: + print(f"Error fetching year {year}: {e}") + continue + + return all_cars + + def _parse_car_list_html(self, html: str) -> List[dict]: + """HTML에서 차량 목록 파싱""" + cars = [] + + try: + tree = lxml_html.fromstring(html) + + # 각 차량 행 찾기 (tr id="trCtl_XXXXXXX") + car_rows = tree.xpath('//tr[starts-with(@id, "trCtl_")]') + + for row in car_rows: + try: + # 차량 번호 추출 + tr_id = row.get('id', '') + car_no = tr_id.replace('trCtl_', '') + + # 차량번호판 + car_plate = "" + plate_elem = row.xpath('.//td[@class="center"]/strong/text()') + if plate_elem: + car_plate = plate_elem[0].strip() + + # 제조사/모델명 + maker_name = "" + model_name = "" + car_name_full = "" + + maker_elem = row.xpath('.//span[@class="maker"]/text()') + if maker_elem: + maker_name = maker_elem[0].strip().strip('[]') + + model_elem = row.xpath('.//span[@class="model"]/text()') + if model_elem: + model_name = model_elem[0].strip() + + # 전체 차량명 (링크 텍스트) + car_name_elem = row.xpath('.//td[@class="carName"]/a/text()') + if car_name_elem: + car_name_parts = [t.strip() for t in car_name_elem if t.strip()] + if car_name_parts: + car_name_full = ' '.join(car_name_parts) + + if not car_name_full and maker_name: + car_name_full = f"{maker_name} {model_name}".strip() + + # 미션 + transmission = "" + td_centers = row.xpath('.//td[@class="center"]/text()') + for t in td_centers: + t = t.strip() + if t in ['오토', '수동', '자동']: + transmission = t + break + + # 연식 + year = None + year_elem = row.xpath('.//span[@class="year_2"]/text()') + if year_elem: + try: + year = int(year_elem[0].strip()) + except: + pass + + # 연료 + fuel = "" + for t in td_centers: + t = t.strip() + if t in ['휘발유', '경유', 'LPG', '전기', '하이브리드', '가솔린', '디젤']: + fuel = t + break + + # 주행거리 + mileage = None + mileage_elem = row.xpath('.//td[@class="right"]/text()') + if mileage_elem: + mileage_str = mileage_elem[0].strip().replace(',', '').replace('km', '') + try: + mileage = int(mileage_str) + except: + pass + + # 색상 + color = "" + # 색상은 마지막 center 셀 중 하나 + for t in td_centers: + t = t.strip() + if t in ['흰색', '검정', '은색', '회색', '파랑', '빨강', '갈색', '녹색', '기타', '노랑', '주황']: + color = t + + # 배기량 (cc) - td_centers에서 숫자만 있는 값 확인 + displacement = None + for t in td_centers: + t = t.strip() + # 배기량은 보통 숫자로만 이루어져 있음 (1999, 2497 등) + if t.isdigit() and 500 <= int(t) <= 10000: + displacement = int(t) + break + + # 가격 (만원) + price = None + price_elem = row.xpath('.//td[@class="price"]/text()') + if price_elem: + try: + price_str = price_elem[0].strip().replace(',', '') + price = int(price_str) * 10000 # 만원 -> 원 + except: + pass + + # 이미지 URL 생성 + car_no_padded = car_no.zfill(9) + main_image = f"{CARMODOO_BASE_URL}/data/__carPhoto/{car_no_padded[:3]}/{car_no_padded[3:6]}/{car_no_padded[6:]}/cmcar_0.jpg" + + # 성능점검번호 및 key 추출 시도 + check_num = "" + car_key = "" + + # 전체 row HTML에서 dealerCarviewPopup('...') 패턴으로 key와 checkNum 추출 + row_html = etree.tostring(row, encoding='unicode') + + key_match = re.search(r"dealerCarviewPopup\s*\(\s*['\"]([^'\"]+)['\"]", row_html) + if key_match: + car_key = key_match.group(1) + + # checkNum 추출 (주석 포함) + check_match = re.search(r'checkNum=(\d+)', row_html) + if check_match: + check_num = check_match.group(1) + + # data 속성에서 checkNum 추출 (대안) + if not check_num: + check_num_attr = row.get('data-checknum', '') + if not check_num_attr: + check_elem = row.xpath('.//*[@data-checknum]/@data-checknum') + if check_elem: + check_num_attr = check_elem[0] + check_num = check_num_attr + + # 성능점검 링크에서 checkNum 추출 (대안) + if not check_num: + check_link = row.xpath('.//a[contains(@href, "checkNum")]/@href') + if check_link: + check_match = re.search(r'checkNum=(\d+)', check_link[0]) + if check_match: + check_num = check_match.group(1) + + if car_no: + cars.append({ + "car_no": car_no, + "car_plate": car_plate, + "car_name": car_name_full, + "maker_name": maker_name, + "model_name": model_name, + "year": year, + "mileage": mileage, + "price": price, + "fuel": fuel, + "transmission": transmission, + "color": color, + "displacement": displacement, + "main_image": main_image, + "car_key": car_key, + "check_num": check_num, + }) + + except Exception as e: + print(f"Error parsing car row: {e}") + continue + + except Exception as e: + print(f"Error parsing HTML: {e}") + + return cars + + + async def get_grades(self, maker_code: str, model_code: str) -> List[dict]: + """등급 목록 가져오기 (카모두 API에서 c_boNo)""" + if not self.is_logged_in: + await self.login() + + try: + async with httpx.AsyncClient(timeout=30.0, cookies=self.cookies) as client: + # 카모두 API - 등급 목록 조회 + # mode=getCarModel: 제조사(company)와 모델이니셜(c_nameInit)을 받아서 등급 목록 반환 + url = f"{CARMODOO_BASE_URL}/common/ajax/AutoDBCode.html" + params = { + 'mode': 'getCarModel', + 'ctl': 'car', + 'cho': '', # 국산/수입 구분 (빈값이면 전체) + 'company': maker_code, + 'c_nameInit': model_code, + 'selected': '' + } + + headers = { + **self.headers, + 'Accept': 'application/xml, text/xml, */*; q=0.01', + 'X-Requested-With': 'XMLHttpRequest', + 'Referer': f'{CARMODOO_BASE_URL}/car/carListPhoto.html', + } + + response = await client.get(url, params=params, headers=headers) + + if response.status_code == 200: + xml_content = self._clean_xml_bytes(response.content) + return self._parse_grades_xml(xml_content) + + except Exception as e: + print(f"Get grades error: {e}") + + return [] + + def _parse_grades_xml(self, xml_content: bytes) -> List[dict]: + """등급 XML 파싱""" + grades = [] + try: + root = etree.fromstring(xml_content) + items = root.findall('.//item') + for item in items: + key_elem = item.find('key') + name_elem = item.find('name') + if key_elem is not None and name_elem is not None: + grades.append({ + 'code': key_elem.text.strip() if key_elem.text else '', + 'name': name_elem.text.strip() if name_elem.text else '' + }) + except Exception as e: + print(f"Parse grades XML error: {e}") + return grades + + async def get_car_check_num(self, car_no: str, car_key: str = "") -> str: + """차량 상세 정보에서 성능점검번호(checkNum) 가져오기""" + if not self.is_logged_in: + await self.login() + + try: + async with httpx.AsyncClient(timeout=30.0, cookies=self.cookies) as client: + url = f"{CARMODOO_BASE_URL}/common/ajax/AutoDB.html" + + headers = { + **self.headers, + 'Accept': 'application/xml, text/xml, */*; q=0.01', + 'X-Requested-With': 'XMLHttpRequest', + 'Referer': f'{CARMODOO_BASE_URL}/car/carListPhoto.html', + } + + # key가 있으면 key로 조회 + if car_key: + params = {"mode": "view", "key": car_key} + response = await client.get(url, params=params, headers=headers) + print(f" AutoDB with key: status={response.status_code}, length={len(response.content)}") + + if response.status_code == 200 and len(response.content) > 100: + try: + xml_content = self._clean_xml_bytes(response.content) + root = etree.fromstring(xml_content) + check_num_elem = root.find('.//c_checkNum') + if check_num_elem is not None and check_num_elem.text: + return check_num_elem.text.strip() + except Exception as e: + print(f" XML parse error with key: {e}") + + # key가 없으면 여러 모드 시도 + modes_to_try = [ + {"mode": "view", "c_carNo": car_no}, + {"mode": "viewNo", "no": car_no}, + ] + + for params in modes_to_try: + response = await client.get(url, params=params, headers=headers) + + if response.status_code == 200 and len(response.content) > 100: + try: + xml_content = self._clean_xml_bytes(response.content) + root = etree.fromstring(xml_content) + check_num_elem = root.find('.//c_checkNum') + if check_num_elem is not None and check_num_elem.text: + return check_num_elem.text.strip() + except Exception as e: + pass + + except Exception as e: + print(f"Get car check num error: {e}") + + return "" + + async def get_car_detail(self, car_no: str, car_key: str = "") -> dict: + """차량 상세 정보 가져오기 (딜러 설명 포함) + + Args: + car_no: 차량 번호 + car_key: 암호화된 차량 키 (dealerCarviewPopup용) + + Returns: + dict with dealer_description and other details + """ + if not self.is_logged_in: + await self.login() + + result = { + "car_no": car_no, + "dealer_description": "", + "found": False + } + + try: + async with httpx.AsyncClient(timeout=30.0, cookies=self.cookies) as client: + # car_key가 있으면 dealerCarView.html 사용 + if car_key: + url = f"{CARMODOO_BASE_URL}/car/dealerCarView.html" + params = {"key": car_key, "tabStart": "1"} + + response = await client.get(url, params=params, headers=self.headers) + + if response.status_code == 200: + # EUC-KR로 디코딩 + try: + html = response.content.decode('euc-kr') + except: + html = response.content.decode('utf-8', errors='replace') + + # 상세설명 추출 - carViewMemoWrap 내의 memo div + #

상세설명

...
+ memo_match = re.search( + r']*class=["\'][^"\']*carViewMemoWrap[^"\']*["\'][^>]*>.*?' + r'

\s*상세설명\s*

\s*]*class=["\'][^"\']*memo[^"\']*["\'][^>]*>(.*?)', + html, + re.DOTALL | re.IGNORECASE + ) + + if memo_match: + desc_html = memo_match.group(1) + # HTML 태그 제거 (br 태그는 줄바꿈으로) + desc = re.sub(r'', '\n', desc_html) + desc = re.sub(r'<[^>]+>', '', desc) + desc = desc.strip() + + if desc and len(desc) > 5: + result["dealer_description"] = desc + result["found"] = True + + # raw_html 저장 (디버깅용, 크기 제한) + result["raw_html"] = html[:10000] if len(html) > 10000 else html + + except Exception as e: + print(f"Get car detail error: {e}") + import traceback + traceback.print_exc() + + return result + + async def get_performance_check(self, car_no: str, car_key: str = "", check_num: str = "") -> dict: + """성능점검표 가져오기 - ck.carmodoo.com에서 조회 + + Args: + car_no: 차량 번호 + car_key: 암호화된 key (검색 결과에서 추출) + check_num: 성능점검번호 (이미 알고 있는 경우) + """ + if not self.is_logged_in: + await self.login() + + result = { + "car_no": car_no, + "found": False, + "data": {}, + "raw_html": "", + "check_num": "" + } + + try: + # 1. checkNum이 없으면 차량 상세에서 가져오기 + if not check_num: + check_num = await self.get_car_check_num(car_no, car_key) + + if not check_num: + print(f"No checkNum found for car_no: {car_no}") + return result + + result["check_num"] = check_num + print(f"Found checkNum: {check_num}") + + # 2. 성능점검표 페이지 가져오기 + async with httpx.AsyncClient(timeout=30.0) as client: + perf_url = f"https://ck.carmodoo.com/carCheck/carmodooPrint.do" + params = {"print": "0", "checkNum": check_num} + + headers = { + 'User-Agent': self.headers['User-Agent'], + '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: + # UTF-8로 디코딩 (ck.carmodoo.com은 UTF-8 사용) + try: + html = response.content.decode('utf-8') + except: + html = response.content.decode('euc-kr', errors='ignore') + + result["raw_html"] = html + result["data"] = self._parse_performance_check_html(html) + result["data"]["check_number"] = check_num # checkNum 저장 + result["found"] = bool(result["data"]) + + except Exception as e: + print(f"Get performance check error: {e}") + import traceback + traceback.print_exc() + + return result + + def _parse_performance_check_html(self, html: str) -> dict: + """성능점검표 HTML 파싱""" + data = {} + + try: + tree = lxml_html.fromstring(html) + + # 성능점검번호 + check_num_elem = tree.xpath('//th[contains(text(), "점검번호")]/following-sibling::td/text()') + if check_num_elem: + data["check_number"] = check_num_elem[0].strip() + + # 점검일자 + check_date_elem = tree.xpath('//th[contains(text(), "점검일")]/following-sibling::td/text()') + if check_date_elem: + data["check_date"] = check_date_elem[0].strip() + + # 유효기간 + valid_until_elem = tree.xpath('//th[contains(text(), "유효기간")]/following-sibling::td/text()') + if valid_until_elem: + data["valid_until"] = valid_until_elem[0].strip() + + # 차량번호 (자동차등록번호로 표시됨) + car_num_elem = tree.xpath('//th[contains(text(), "자동차등록번호")]/following-sibling::td/text()') + if not car_num_elem: + # Fallback to old pattern + car_num_elem = tree.xpath('//th[contains(text(), "차량번호")]/following-sibling::td/text()') + if car_num_elem: + data["car_number"] = car_num_elem[0].strip() + + # 최초등록일 + first_reg_elem = tree.xpath('//th[contains(text(), "최초등록")]/following-sibling::td/text()') + if first_reg_elem: + data["first_registration"] = first_reg_elem[0].strip() + + # 주행거리 + mileage_elem = tree.xpath('//th[contains(text(), "주행거리")]/following-sibling::td/text()') + if mileage_elem: + mileage_str = mileage_elem[0].strip().replace(',', '').replace('km', '').replace(' ', '') + try: + data["mileage"] = int(mileage_str) + except: + data["mileage_text"] = mileage_elem[0].strip() + + # 주행거리 상태 (정상/조작의심/교환됨) + mileage_status_elem = tree.xpath('//th[contains(text(), "계기상태")]/following-sibling::td/text()') + if mileage_status_elem: + data["mileage_status"] = mileage_status_elem[0].strip() + + # 압류/저당 + seize_elem = tree.xpath('//th[contains(text(), "압류")]/following-sibling::td/text()') + if seize_elem: + seize_str = seize_elem[0].strip() + try: + data["seize_count"] = int(re.search(r'(\d+)', seize_str).group(1)) if re.search(r'(\d+)', seize_str) else 0 + except: + data["seize_count"] = 0 + + collateral_elem = tree.xpath('//th[contains(text(), "저당")]/following-sibling::td/text()') + if collateral_elem: + collateral_str = collateral_elem[0].strip() + try: + data["collateral_count"] = int(re.search(r'(\d+)', collateral_str).group(1)) if re.search(r'(\d+)', collateral_str) else 0 + except: + data["collateral_count"] = 0 + + # 침수/화재/전손 - 체크박스 상태로 확인 + # bc_41_1 = 침수, bc_41_2 = 화재 + flood_checkbox = tree.xpath('//input[@id="bc_41_1"]/@checked') + fire_checkbox = tree.xpath('//input[@id="bc_41_2"]/@checked') + # 전손은 특별이력 섹션의 "있음" 체크박스와 함께 확인 + special_history_yes = tree.xpath('//input[@id="bc_4_2"]/@checked') + + data["is_flood_damaged"] = len(flood_checkbox) > 0 + data["is_fire_damaged"] = len(fire_checkbox) > 0 + # 전손은 별도 체크박스가 없으므로 텍스트로 확인 (fallback) + total_loss_text = tree.xpath('//th[contains(text(), "특별이력")]/following-sibling::td//text()') + if total_loss_text: + history_text = ' '.join([t.strip() for t in total_loss_text]) + data["is_total_loss"] = "전손" in history_text and ("유" in history_text or "있음" in history_text) + else: + data["is_total_loss"] = False + + # 용도이력 + usage_elem = tree.xpath('//th[contains(text(), "용도이력")]/following-sibling::td/text()') + if usage_elem: + data["usage_history"] = usage_elem[0].strip() + + # 렌트이력 + rental_elem = tree.xpath('//th[contains(text(), "렌트")]/following-sibling::td//text()') + if rental_elem: + rental_text = ' '.join([t.strip() for t in rental_elem]).lower() + data["is_rental_used"] = "유" in rental_text or "있음" in rental_text + + # 주요장치 상태 + device_status_map = { + "원동기": "engine_status", + "변속기": "transmission_status", + "동력전달": "power_delivery_status", + "조향장치": "steering_status", + "제동장치": "brake_status", + "전기장치": "electrical_status", + "연료장치": "fuel_system_status", + } + + for korean, english in device_status_map.items(): + elem = tree.xpath(f'//th[contains(text(), "{korean}")]/following-sibling::td/text()') + if elem: + data[english] = elem[0].strip() + + # 외판 부위 상태 (사고이력) + body_parts_map = { + "후드": "hood", + "프론트휀더(좌)": "front_fender_left", + "프론트휀더(우)": "front_fender_right", + "프론트도어(좌)": "front_door_left", + "프론트도어(우)": "front_door_right", + "리어도어(좌)": "rear_door_left", + "리어도어(우)": "rear_door_right", + "트렁크리드": "trunk_lid", + "라디에이터서포트": "radiator_support", + "루프패널": "roof_panel", + "쿼터패널(좌)": "quarter_panel_left", + "쿼터패널(우)": "quarter_panel_right", + "사이드실패널(좌)": "side_sill_left", + "사이드실패널(우)": "side_sill_right", + } + + # 주요골격 부위 + frame_parts_map = { + "프론트패널": "front_panel", + "크로스멤버": "cross_member", + "인사이드패널(좌)": "inside_panel_left", + "인사이드패널(우)": "inside_panel_right", + "사이드멤버(좌)": "side_member_left", + "사이드멤버(우)": "side_member_right", + "휠하우스(좌)": "wheel_house_left", + "휠하우스(우)": "wheel_house_right", + "대쉬패널": "dash_panel", + "플로어패널": "floor_panel", + "트렁크플로어": "trunk_floor", + "리어패널": "rear_panel", + "필러A(좌)": "pillar_a_left", + "필러A(우)": "pillar_a_right", + "필러B(좌)": "pillar_b_left", + "필러B(우)": "pillar_b_right", + "필러C(좌)": "pillar_c_left", + "필러C(우)": "pillar_c_right", + "패키지트레이": "package_tray", + } + + # 모든 부위 파싱 + all_parts = {**body_parts_map, **frame_parts_map} + accident_history = {} + + for korean, english in all_parts.items(): + elem = tree.xpath(f'//th[contains(text(), "{korean}")]/following-sibling::td/text()') + if elem: + status = elem[0].strip() + data[english] = status + if status and status not in ["없음", "무", "-", ""]: + accident_history[english] = status + + if accident_history: + data["accident_history"] = accident_history + + # 타이어 상태 + tire_map = { + "전좌": "tire_front_left", + "전우": "tire_front_right", + "후좌": "tire_rear_left", + "후우": "tire_rear_right", + } + + for korean, english in tire_map.items(): + elem = tree.xpath(f'//th[contains(text(), "타이어")]/following-sibling::td//text()[contains(., "{korean}")]') + if elem: + # 타이어 상태 추출 로직 + data[english] = "확인필요" + + # 성능점검표 이미지 URL + img_elem = tree.xpath('//img[contains(@src, "prfcheck") or contains(@src, "performance")]/@src') + if img_elem: + img_url = img_elem[0] + if not img_url.startswith('http'): + img_url = f"{CARMODOO_BASE_URL}{img_url}" + data["report_image_url"] = img_url + + except Exception as e: + print(f"Parse performance check HTML error: {e}") + import traceback + traceback.print_exc() + + return data + + +# 싱글톤 클라이언트 +carmodoo_client = CarmodooClient() + + +@router.get("/makers") +def get_makers(): + """제조사 목록 (실제 카모두 데이터)""" + return [CarmodooMaker(**m) for m in CARMODOO_DATA.get("makers", [])] + + +@router.get("/models/{maker_code}") +def get_models(maker_code: str): + """모델 목록 (실제 카모두 데이터)""" + models = CARMODOO_DATA.get("models", {}).get(maker_code, []) + return [CarmodooModel(**m) for m in models] + + +@router.get("/grades/{maker_code}/{model_code}") +async def get_grades(maker_code: str, model_code: str): + """등급 목록 (카모두에서 동적 조회)""" + grades = await carmodoo_client.get_grades(maker_code, model_code) + if not grades: + # 빈 목록 반환 (등급 선택 없이 검색 가능) + return [] + return grades + + +class AdminSearchResultItem(BaseModel): + id: str + car_name: str + maker_code: Optional[str] = None + maker_name: Optional[str] = None + model_code: Optional[str] = None + model_name: Optional[str] = None + car_type: Optional[str] = None + car_type_name: Optional[str] = None + grade: Optional[str] = None + grade_name: Optional[str] = None + year: Optional[int] = None + month: Optional[int] = None + mileage: Optional[int] = None + price: Optional[int] = None + fuel: Optional[str] = None + transmission: Optional[str] = None + color: Optional[str] = None + displacement: Optional[int] = None + car_number: Optional[str] = None + main_image: Optional[str] = None + images: Optional[List[str]] = None + options: Optional[List[str]] = None + dealer_name: Optional[str] = None + shop_name: Optional[str] = None + seize_count: Optional[int] = None + collateral_count: Optional[int] = None + check_num: Optional[str] = None # 성능점검번호 + car_key: Optional[str] = None # 암호화된 차량 키 + +class AdminSearchResponse(BaseModel): + total: int + cars: List[AdminSearchResultItem] + check_num_retried: int = 0 # 성능점검번호 재시도한 차량 수 + check_num_retry_success: int = 0 # 재시도 성공 수 + + +@router.get("/search", response_model=AdminSearchResponse) +async def admin_carmodoo_search( + maker_code: Optional[str] = None, + model_code: Optional[str] = None, + car_type: Optional[str] = None, + grade: Optional[str] = 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, + displacement_min: Optional[int] = None, + displacement_max: Optional[int] = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=50), + current_user = Depends(get_current_admin_user), +): + """ + 관리자용 카모두 차량 검색 + - 직접 카모두 API를 호출하여 검색 + - 배기량 필터는 결과에서 클라이언트 사이드 필터링 + """ + + # Debug logging + print(f"[Admin Search] Starting search with maker={maker_code}, model={model_code}") + print(f"[Admin Search] Client state: is_logged_in={carmodoo_client.is_logged_in}, cookies={bool(carmodoo_client.cookies)}") + + # 카모두 API 호출 + cars = await carmodoo_client.search_cars( + maker_code=maker_code, + model_code=model_code, + year_min=year_min, + year_max=year_max, + mileage_max=mileage_max, + price_min=price_min, + price_max=price_max, + fuel=fuel, + page=page, + page_size=page_size + ) + + # Debug logging + print(f"[Admin Search] Search returned {len(cars)} cars") + print(f"[Admin Search] Client state after: is_logged_in={carmodoo_client.is_logged_in}") + + # 배기량 필터링 (카모두 API에서 지원하지 않는 경우) + if displacement_min or displacement_max: + filtered_cars = [] + for car in cars: + car_displacement = car.get("displacement") or 0 + if displacement_min and car_displacement < displacement_min: + continue + if displacement_max and car_displacement > displacement_max: + continue + filtered_cars.append(car) + cars = filtered_cars + + # 제조사/모델 이름 조회 + maker_name = "" + model_name = "" + if maker_code: + for m in CARMODOO_DATA.get("makers", []): + if m["code"] == maker_code: + maker_name = m["name"] + break + if maker_code and model_code: + for m in CARMODOO_DATA.get("models", {}).get(maker_code, []): + if m["code"] == model_code: + model_name = m["name"] + break + + # 결과 변환 + result_cars = [] + for car in cars: + result_cars.append(AdminSearchResultItem( + id=car.get("car_no", ""), + car_name=car.get("car_name", ""), + maker_code=maker_code, + maker_name=car.get("maker_name", maker_name), + model_code=model_code, + model_name=car.get("model_name", model_name), + year=car.get("year"), + month=car.get("month"), + mileage=car.get("mileage"), + price=car.get("price") or car.get("original_price"), + fuel=car.get("fuel"), + transmission=car.get("transmission"), + color=car.get("color"), + displacement=car.get("displacement"), + car_number=car.get("car_number"), + main_image=car.get("main_image"), + seize_count=car.get("seize_count", 0), + collateral_count=car.get("collateral_count", 0), + check_num=car.get("check_num"), # 성능점검번호 + car_key=car.get("car_key"), # 암호화된 차량 키 + )) + + # 메타데이터 추출 + total_count = cars[0].get("_total_count", len(result_cars)) if cars else len(result_cars) + retry_count = cars[0].get("_retry_count", 0) if cars else 0 + retry_success = cars[0].get("_retry_success", 0) if cars else 0 + + print(f"[Admin Search] Returning total={total_count}, cars in page={len(result_cars)}") + + return AdminSearchResponse( + total=total_count, + cars=result_cars, + check_num_retried=retry_count, + check_num_retry_success=retry_success + ) + + +@router.get("/request-search", response_model=VehicleRequestSearchResponse) +async def vehicle_request_search( + maker_code: str = Query(..., description="제조사 코드 (필수)"), + model_code: str = Query(..., description="모델 코드 (필수)"), + year_min: int = Query(..., description="시작 연도 (필수)"), + year_max: int = Query(..., description="종료 연도 (필수)"), + grade: Optional[str] = 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, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + current_user = Depends(get_current_user_optional), +): + """ + 차량 요청 검색 - 캐시 우선 조회 + + 1. 캐시에서 maker+model 전체 데이터 조회 + 2. 캐시 MISS 시 카모두에서 전체 수집 후 캐시 저장 + 3. 로컬에서 year, mileage, price, fuel, transmission 필터링 + 4. 마진 계산 후 페이징하여 반환 + """ + + # 마진율 (관리자 설정에서 가져오기) + system_settings = db.query(SystemSettings).first() + if system_settings: + KOREA_MARGIN_RATE = system_settings.korea_margin_percent / 100 + MONGOLIA_MARGIN_RATE = system_settings.mongolia_margin_percent / 100 + else: + # 기본값 (설정이 없는 경우) + KOREA_MARGIN_RATE = 0.05 + MONGOLIA_MARGIN_RATE = 0.05 + + # 제조사/모델 이름 조회 + maker_name = "" + model_name = "" + for m in CARMODOO_DATA.get("makers", []): + if m["code"] == maker_code: + maker_name = m["name"] + break + for m in CARMODOO_DATA.get("models", {}).get(maker_code, []): + if m["code"] == model_code: + model_name = m["name"] + break + + # 캐시 서비스 생성 + cache_service = CacheService(db, carmodoo_client) + cache_key = cache_service.get_cache_key(maker_code, model_code) + + # 1. 캐시 조회 + cache = cache_service.get_cache(cache_key) + + if cache: + # 캐시 HIT - 로컬에서 필터링 + cars = cache_service.get_cars_from_cache(cache) + else: + # 캐시 MISS - 카모두에서 전체 수집 + cars = await cache_service.fetch_all_cars_for_cache( + maker_code=maker_code, + model_code=model_code, + maker_name=maker_name, + model_name=model_name + ) + + # 캐시 저장 + if cars: + cache_service.save_cache( + cache_key=cache_key, + maker_code=maker_code, + maker_name=maker_name, + model_code=model_code, + model_name=model_name, + cars=cars + ) + + # 2. 로컬 필터링 + filtered_cars = cache_service.filter_cars( + cars=cars, + year_min=year_min, + year_max=year_max, + mileage_min=mileage_min, + mileage_max=mileage_max, + price_min=price_min * 10000 if price_min else None, # 만원 -> 원 + price_max=price_max * 10000 if price_max else None, + fuel=fuel, + transmission=transmission, + displacement_min=displacement_min, + displacement_max=displacement_max + ) + + # 3. 페이징 + paginated_cars, total = cache_service.paginate_cars( + filtered_cars, page=page, page_size=page_size + ) + + # 4. 마진 계산 및 응답 생성 + result_cars = [] + for car in paginated_cars: + original_price = car.get("price") or car.get("original_price") or 0 + korea_margin = int(original_price * KOREA_MARGIN_RATE) + mongolia_margin = int(original_price * MONGOLIA_MARGIN_RATE) + final_price = original_price + korea_margin + mongolia_margin + + result_cars.append(CarmodooSearchResultItem( + id=car.get("car_no", car.get("id", "")), + car_name=car.get("car_name", ""), + maker_name=car.get("maker_name", maker_name), + model_name=car.get("model_name", model_name), + year=car.get("year"), + mileage=car.get("mileage"), + original_price=original_price, + korea_margin=korea_margin, + mongolia_margin=mongolia_margin, + final_price=final_price, + fuel=car.get("fuel"), + transmission=car.get("transmission"), + color=car.get("color"), + displacement=car.get("displacement"), + main_image=car.get("main_image"), + image_count=car.get("image_count", 20), + check_num=car.get("check_num"), # 성능점검번호 + car_key=car.get("car_key"), # 암호화된 차량 키 + )) + + return VehicleRequestSearchResponse( + total=total, + cars=result_cars + ) + + +class ImportCarRequest(BaseModel): + car_no: str + car_name: str + maker_name: Optional[str] = None + model_name: Optional[str] = None + year: Optional[int] = None + mileage: Optional[int] = None + price: Optional[int] = None # 원가 (KRW) + fuel: Optional[str] = None + transmission: Optional[str] = None + color: Optional[str] = None + displacement: Optional[int] = None + main_image: Optional[str] = None + check_num: Optional[str] = None # 성능점검번호 + car_key: Optional[str] = None # 암호화된 차량 키 (dealerCarviewPopup용) + dealer_description: Optional[str] = None # 편집된 딜러 설명 (민감정보 제거됨) + + +class ImportCarsRequest(BaseModel): + cars: List[ImportCarRequest] + + +async def download_image(url: str, save_path: str) -> bool: + """이미지를 다운로드해서 로컬에 저장""" + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url) + if response.status_code == 200: + os.makedirs(os.path.dirname(save_path), exist_ok=True) + with open(save_path, 'wb') as f: + f.write(response.content) + return True + except Exception as e: + print(f"Image download failed: {url} - {e}") + return False + + +@router.post("/import") +async def import_cars_from_carmodoo( + request: ImportCarsRequest, + db: Session = Depends(get_db), + current_user = Depends(get_current_user), +): + """카모두에서 선택한 차량들을 로컬 DB로 가져오기 (이미지 포함)""" + + imported = [] + skipped = [] + errors = [] + + for car_data in request.cars: + try: + # 이미 존재하는지 확인 + existing = db.query(Car).filter( + Car.source == "carmodoo", + Car.source_id == car_data.car_no + ).first() + + if existing: + skipped.append({ + "car_no": car_data.car_no, + "car_id": existing.id, + "reason": "already exists" + }) + continue + + # 제조사 찾기/생성 + maker = None + maker_code = None + if car_data.maker_name: + # 제조사 코드 찾기 + for m in CARMODOO_DATA.get("makers", []): + if m["name"] == car_data.maker_name: + maker_code = m["code"] + break + + if maker_code: + maker = db.query(CarMaker).filter(CarMaker.code == maker_code).first() + if not maker: + maker = CarMaker( + code=maker_code, + name=car_data.maker_name, + name_en=car_data.maker_name + ) + db.add(maker) + db.flush() + + # 모델 찾기/생성 + model = None + if maker and car_data.model_name and maker_code: + models_list = CARMODOO_DATA.get("models", {}).get(maker_code, []) + model_code = None + for m in models_list: + if m["name"] == car_data.model_name: + model_code = m["code"] + break + + if model_code: + model = db.query(CarModel).filter( + CarModel.code == model_code, + CarModel.maker_id == maker.id + ).first() + if not model: + model = CarModel( + code=model_code, + maker_id=maker.id, + name=car_data.model_name, + name_en=car_data.model_name + ) + db.add(model) + db.flush() + + # 차량 생성 + car = Car( + source="carmodoo", + source_id=car_data.car_no, + maker_id=maker.id if maker else None, + model_id=model.id if model else None, + car_name=car_data.car_name, + year=car_data.year, + mileage=car_data.mileage, + price_krw=car_data.price, + fuel=car_data.fuel, + transmission=car_data.transmission, + color=car_data.color, + displacement=car_data.displacement, + is_displayed=True, # Banner용은 바로 표시 + status="active" + ) + db.add(car) + db.flush() + + # 이미지 다운로드 (20장) + car_no_padded = car_data.car_no.zfill(9) + image_base_dir = f"./uploads/cars/{car.id}" + os.makedirs(image_base_dir, exist_ok=True) + + downloaded_images = [] + for i in range(20): + image_url = f"{CARMODOO_BASE_URL}/data/__carPhoto/{car_no_padded[:3]}/{car_no_padded[3:6]}/{car_no_padded[6:]}/cmcar_{i}.jpg" + local_filename = f"image_{i}.jpg" + local_path = f"{image_base_dir}/{local_filename}" + + if await download_image(image_url, local_path): + car_image = CarImage( + car_id=car.id, + url=f"/uploads/cars/{car.id}/{local_filename}", + local_path=local_path, + is_main=(i == 0), + sort_order=i + ) + db.add(car_image) + downloaded_images.append(local_filename) + else: + # 이미지 없으면 중단 (보통 20장 미만인 경우) + if i > 0: + break + + # 성능점검표 가져오기 (check_num이 있으면 직접 사용) + perf_check_result = await carmodoo_client.get_performance_check( + car_data.car_no, + check_num=car_data.check_num or "" + ) + performance_check_saved = False + + if perf_check_result.get("found") and perf_check_result.get("data"): + perf_data = perf_check_result["data"] + + # 차량번호를 cars 테이블에 저장 (원자성) + if perf_data.get("car_number"): + car.car_number = perf_data.get("car_number") + + # 차량 상세정보 가져오기 (딜러 설명) + # 관리자가 편집한 dealer_description이 있으면 사용, 없으면 원본 가져와서 자동 마스킹 + if car_data.dealer_description: + # 관리자가 편집한 설명 사용 + car.dealer_description = car_data.dealer_description + else: + # 원본 가져와서 자동 마스킹 + car_detail = await carmodoo_client.get_car_detail(car_data.car_no, car_data.car_key or "") + if car_detail.get("found") and car_detail.get("dealer_description"): + original_desc = car_detail.get("dealer_description") + # 민감정보 자동 마스킹 적용 + car.dealer_description = mask_sensitive_info(original_desc) + + # 딜러 설명 번역 (한국어 → 영어/몽골어/러시아어) + if car.dealer_description: + try: + translations = await translate_dealer_description(car.dealer_description) + car.dealer_description_en = translations.get('en') + car.dealer_description_mn = translations.get('mn') + car.dealer_description_ru = translations.get('ru') + print(f"[Translation] Dealer description translated for car {car.id}") + except Exception as trans_error: + print(f"[Translation] Failed for car {car.id}: {trans_error}") + # 번역 실패 시 원문 유지 (번역 필드는 None 상태) + + # CarPerformanceCheck 생성 (성능점검 데이터가 있을 때만) + pdf_status = {"success": False, "attempts": 0, "error": None, "check_num": None} + if perf_check_result.get("found") and perf_check_result.get("data"): + 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"), + 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"), + tire_front_left=perf_data.get("tire_front_left"), + tire_front_right=perf_data.get("tire_front_right"), + tire_rear_left=perf_data.get("tire_rear_left"), + tire_rear_right=perf_data.get("tire_rear_right"), + hood=perf_data.get("hood"), + front_fender_left=perf_data.get("front_fender_left"), + front_fender_right=perf_data.get("front_fender_right"), + front_door_left=perf_data.get("front_door_left"), + front_door_right=perf_data.get("front_door_right"), + rear_door_left=perf_data.get("rear_door_left"), + rear_door_right=perf_data.get("rear_door_right"), + trunk_lid=perf_data.get("trunk_lid"), + radiator_support=perf_data.get("radiator_support"), + roof_panel=perf_data.get("roof_panel"), + quarter_panel_left=perf_data.get("quarter_panel_left"), + quarter_panel_right=perf_data.get("quarter_panel_right"), + side_sill_left=perf_data.get("side_sill_left"), + side_sill_right=perf_data.get("side_sill_right"), + front_panel=perf_data.get("front_panel"), + cross_member=perf_data.get("cross_member"), + inside_panel_left=perf_data.get("inside_panel_left"), + inside_panel_right=perf_data.get("inside_panel_right"), + side_member_left=perf_data.get("side_member_left"), + side_member_right=perf_data.get("side_member_right"), + wheel_house_left=perf_data.get("wheel_house_left"), + wheel_house_right=perf_data.get("wheel_house_right"), + dash_panel=perf_data.get("dash_panel"), + floor_panel=perf_data.get("floor_panel"), + trunk_floor=perf_data.get("trunk_floor"), + rear_panel=perf_data.get("rear_panel"), + pillar_a_left=perf_data.get("pillar_a_left"), + pillar_a_right=perf_data.get("pillar_a_right"), + pillar_b_left=perf_data.get("pillar_b_left"), + pillar_b_right=perf_data.get("pillar_b_right"), + pillar_c_left=perf_data.get("pillar_c_left"), + pillar_c_right=perf_data.get("pillar_c_right"), + package_tray=perf_data.get("package_tray"), + accident_history=perf_data.get("accident_history"), + raw_data=perf_data, + raw_html=perf_check_result.get("raw_html", "")[:50000], # 50KB 제한 + report_image_url=perf_data.get("report_image_url"), + ) + db.add(performance_check) + db.flush() # Get ID for PDF filename + + # PDF 캡처 (성능점검표) - capture_performance_check_pdf가 내부적으로 3회 재시도 + check_num = perf_data.get("check_number") + pdf_status["check_num"] = check_num + if check_num: + try: + print(f"[PDF] Starting capture for car {car.id}, check_num={check_num}") + # 함수 내부에서 3회 재시도 수행 + pdf_path = await capture_performance_check_pdf(check_num, car.id) + if pdf_path: + performance_check.pdf_path = pdf_path + pdf_status["success"] = True + pdf_status["path"] = pdf_path + pdf_status["attempts"] = 1 + print(f"[PDF] Success: {pdf_path}") + else: + pdf_status["error"] = "No path returned after 3 retries" + pdf_status["attempts"] = 3 + print(f"[PDF] Failed after internal retries: No path returned") + except Exception as pdf_error: + pdf_status["error"] = str(pdf_error) + pdf_status["attempts"] = 3 + print(f"[PDF] Exception: {pdf_error}") + + performance_check_saved = True + + # 상세사양 조회 및 저장 (차량번호가 있는 경우만) + spec_saved = False + if car.car_number: + try: + print(f"Fetching specifications for car_number: {car.car_number}") + spec_data = await get_specifications_from_carmodoo(car.car_number) + if spec_data: + spec_dict = spec_to_dict(spec_data) + car_spec = CarSpecification( + car_id=car.id, + manufacturer=spec_dict.get("manufacturer"), + model_name=spec_dict.get("model_name"), + grade=spec_dict.get("grade"), + model_year=spec_dict.get("model_year"), + displacement=spec_dict.get("displacement"), + fuel_type=spec_dict.get("fuel_type"), + transmission=spec_dict.get("transmission"), + drive_type=spec_dict.get("drive_type"), + max_power=spec_dict.get("max_power"), + max_torque=spec_dict.get("max_torque"), + fuel_efficiency=spec_dict.get("fuel_efficiency"), + body_type=spec_dict.get("body_type"), + door_count=spec_dict.get("door_count"), + seating_capacity=spec_dict.get("seating_capacity"), + length=spec_dict.get("length"), + width=spec_dict.get("width"), + height=spec_dict.get("height"), + wheelbase=spec_dict.get("wheelbase"), + curb_weight=spec_dict.get("curb_weight"), + safety_options=spec_dict.get("safety_options"), + comfort_options=spec_dict.get("comfort_options"), + exterior_options=spec_dict.get("exterior_options"), + interior_options=spec_dict.get("interior_options"), + raw_data=spec_dict.get("raw_data"), + ) + db.add(car_spec) + spec_saved = True + print(f"Specifications saved for car {car.id}") + except Exception as spec_error: + print(f"Specification fetch failed for car {car.id}: {spec_error}") + + db.commit() + + imported.append({ + "car_no": car_data.car_no, + "car_id": car.id, + "car_name": car_data.car_name, + "images_downloaded": len(downloaded_images), + "performance_check_saved": performance_check_saved, + "specification_saved": spec_saved, + "pdf_status": pdf_status + }) + + except Exception as e: + db.rollback() + errors.append({ + "car_no": car_data.car_no, + "error": str(e) + }) + + # PDF 상태 요약 + pdf_success_count = sum(1 for i in imported if i.get("pdf_status", {}).get("success")) + pdf_failed_count = len(imported) - pdf_success_count + + return { + "imported": imported, + "skipped": skipped, + "errors": errors, + "summary": { + "imported_count": len(imported), + "skipped_count": len(skipped), + "error_count": len(errors), + "pdf_success_count": pdf_success_count, + "pdf_failed_count": pdf_failed_count + } + } + + +@router.get("/performance-check/{car_no}") +async def get_performance_check( + car_no: str, + current_user = Depends(get_current_user), +): + """카모두에서 성능점검표 조회 (미리보기용)""" + result = await carmodoo_client.get_performance_check(car_no) + return result + + +@router.get("/car/{car_id}/performance-check") +async def get_car_performance_check( + car_id: int, + db: Session = Depends(get_db), + current_user = Depends(get_current_user_optional), +): + """저장된 차량의 성능점검표 조회 (0.1 CC 결제 필요)""" + car = db.query(Car).filter(Car.id == car_id).first() + if not car: + raise HTTPException(status_code=404, detail="Car not found") + + performance_check = db.query(CarPerformanceCheck).filter( + CarPerformanceCheck.car_id == car_id + ).first() + + if not performance_check: + return {"car_id": car_id, "found": False, "has_access": False, "data": None} + + # Check if user has paid for performance check + has_access = False + if current_user: + # Admin always has access + if current_user.is_admin: + has_access = True + else: + # Check 1: User purchased this 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: User purchased full car view (1 CC) -> performance check included + 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) + + # If no access, return only basic info (that performance check exists) + if not has_access: + return { + "car_id": car_id, + "found": True, + "has_access": False, + "preview": { + "check_number": performance_check.check_number, + "check_date": performance_check.check_date, + "mileage": performance_check.mileage, + }, + "data": None + } + + # 응답 데이터 구성 + data = { + "id": performance_check.id, + "car_id": performance_check.car_id, + "check_number": performance_check.check_number, + "check_date": performance_check.check_date, + "valid_until": performance_check.valid_until, + "car_number": car.car_number, # car_number is in Car model, not PerformanceCheck + "first_registration": performance_check.first_registration, + "mileage": performance_check.mileage, + "mileage_status": performance_check.mileage_status, + "seize_count": performance_check.seize_count, + "collateral_count": performance_check.collateral_count, + "is_flood_damaged": performance_check.is_flood_damaged, + "is_fire_damaged": performance_check.is_fire_damaged, + "is_total_loss": performance_check.is_total_loss, + "usage_history": performance_check.usage_history, + "is_rental_used": performance_check.is_rental_used, + "device_status": { + "engine": performance_check.engine_status, + "transmission": performance_check.transmission_status, + "power_delivery": performance_check.power_delivery_status, + "steering": performance_check.steering_status, + "brake": performance_check.brake_status, + "electrical": performance_check.electrical_status, + "fuel_system": performance_check.fuel_system_status, + }, + "tire_status": { + "front_left": performance_check.tire_front_left, + "front_right": performance_check.tire_front_right, + "rear_left": performance_check.tire_rear_left, + "rear_right": performance_check.tire_rear_right, + }, + "body_parts": { + "hood": performance_check.hood, + "front_fender_left": performance_check.front_fender_left, + "front_fender_right": performance_check.front_fender_right, + "front_door_left": performance_check.front_door_left, + "front_door_right": performance_check.front_door_right, + "rear_door_left": performance_check.rear_door_left, + "rear_door_right": performance_check.rear_door_right, + "trunk_lid": performance_check.trunk_lid, + "radiator_support": performance_check.radiator_support, + "roof_panel": performance_check.roof_panel, + "quarter_panel_left": performance_check.quarter_panel_left, + "quarter_panel_right": performance_check.quarter_panel_right, + "side_sill_left": performance_check.side_sill_left, + "side_sill_right": performance_check.side_sill_right, + }, + "frame_parts": { + "front_panel": performance_check.front_panel, + "cross_member": performance_check.cross_member, + "inside_panel_left": performance_check.inside_panel_left, + "inside_panel_right": performance_check.inside_panel_right, + "side_member_left": performance_check.side_member_left, + "side_member_right": performance_check.side_member_right, + "wheel_house_left": performance_check.wheel_house_left, + "wheel_house_right": performance_check.wheel_house_right, + "dash_panel": performance_check.dash_panel, + "floor_panel": performance_check.floor_panel, + "trunk_floor": performance_check.trunk_floor, + "rear_panel": performance_check.rear_panel, + "pillar_a_left": performance_check.pillar_a_left, + "pillar_a_right": performance_check.pillar_a_right, + "pillar_b_left": performance_check.pillar_b_left, + "pillar_b_right": performance_check.pillar_b_right, + "pillar_c_left": performance_check.pillar_c_left, + "pillar_c_right": performance_check.pillar_c_right, + "package_tray": performance_check.package_tray, + }, + "accident_history": performance_check.accident_history, + "report_image_url": performance_check.report_image_url, + "created_at": performance_check.created_at.isoformat() if performance_check.created_at else None, + "pdf_path": performance_check.pdf_path, # PDF 파일 경로 추가 + } + + return {"car_id": car_id, "found": True, "has_access": True, "data": data} + + +@router.get("/car/{car_id}/performance-check/pdf") +async def get_car_performance_check_pdf( + car_id: int, + db: Session = Depends(get_db), + current_user = Depends(get_current_user), +): + """성능점검표 PDF 다운로드 (0.1 CC 결제 필요)""" + car = db.query(Car).filter(Car.id == car_id).first() + if not car: + raise HTTPException(status_code=404, detail="Car not found") + + performance_check = db.query(CarPerformanceCheck).filter( + CarPerformanceCheck.car_id == car_id + ).first() + + if not performance_check: + raise HTTPException(status_code=404, detail="Performance check not found") + + if not performance_check.pdf_path: + raise HTTPException(status_code=404, detail="PDF not available for this car") + + # Check access (admin, purchased performance check, or purchased car view) + has_access = False + if current_user.is_admin: + has_access = True + else: + # 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 + 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) + + if not has_access: + raise HTTPException( + status_code=403, + detail="Access denied. Please purchase access with 0.1 CC or 1 CC for full car view." + ) + + # Get full path and return file + pdf_full_path = get_pdf_full_path(performance_check.pdf_path) + if not pdf_full_path or not pdf_full_path.exists(): + raise HTTPException(status_code=404, detail="PDF file not found on server") + + return FileResponse( + path=str(pdf_full_path), + media_type="application/pdf", + filename=f"performance_check_{car_id}.pdf" + ) + + +@router.post("/car/{car_id}/fetch-performance-check") +async def fetch_performance_check_for_car( + car_id: int, + db: Session = Depends(get_db), + current_user = Depends(get_current_admin_user), +): + """기존 차량의 성능점검표 데이터 가져오기 (관리자 전용) + + 차량이 import될 때 성능점검표가 없었거나, 나중에 추가된 경우 사용 + """ + car = db.query(Car).filter(Car.id == car_id).first() + if not car: + raise HTTPException(status_code=404, detail="Car not found") + + if not car.source_id: + raise HTTPException(status_code=400, detail="Car has no source_id (car_no)") + + # 기존 성능점검 데이터가 있는지 확인 + existing_check = db.query(CarPerformanceCheck).filter( + CarPerformanceCheck.car_id == car_id + ).first() + + if existing_check: + return { + "message": "Performance check already exists", + "car_id": car_id, + "check_number": existing_check.check_number, + "has_pdf": bool(existing_check.pdf_path) + } + + # Carmodoo에서 성능점검 데이터 가져오기 + perf_check_result = await carmodoo_client.get_performance_check(car.source_id) + + if not perf_check_result.get("found") or not perf_check_result.get("data"): + raise HTTPException(status_code=404, detail="Performance check not found on Carmodoo") + + perf_data = perf_check_result["data"] + + # 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"), + tire_front_left=perf_data.get("tire_front_left"), + tire_front_right=perf_data.get("tire_front_right"), + tire_rear_left=perf_data.get("tire_rear_left"), + tire_rear_right=perf_data.get("tire_rear_right"), + hood=perf_data.get("hood"), + front_fender_left=perf_data.get("front_fender_left"), + front_fender_right=perf_data.get("front_fender_right"), + front_door_left=perf_data.get("front_door_left"), + front_door_right=perf_data.get("front_door_right"), + rear_door_left=perf_data.get("rear_door_left"), + rear_door_right=perf_data.get("rear_door_right"), + trunk_lid=perf_data.get("trunk_lid"), + radiator_support=perf_data.get("radiator_support"), + roof_panel=perf_data.get("roof_panel"), + quarter_panel_left=perf_data.get("quarter_panel_left"), + quarter_panel_right=perf_data.get("quarter_panel_right"), + side_sill_left=perf_data.get("side_sill_left"), + side_sill_right=perf_data.get("side_sill_right"), + front_panel=perf_data.get("front_panel"), + cross_member=perf_data.get("cross_member"), + inside_panel_left=perf_data.get("inside_panel_left"), + inside_panel_right=perf_data.get("inside_panel_right"), + side_member_left=perf_data.get("side_member_left"), + side_member_right=perf_data.get("side_member_right"), + wheel_house_left=perf_data.get("wheel_house_left"), + wheel_house_right=perf_data.get("wheel_house_right"), + dash_panel=perf_data.get("dash_panel"), + floor_panel=perf_data.get("floor_panel"), + trunk_floor=perf_data.get("trunk_floor"), + rear_panel=perf_data.get("rear_panel"), + pillar_a_left=perf_data.get("pillar_a_left"), + pillar_a_right=perf_data.get("pillar_a_right"), + pillar_b_left=perf_data.get("pillar_b_left"), + pillar_b_right=perf_data.get("pillar_b_right"), + pillar_c_left=perf_data.get("pillar_c_left"), + pillar_c_right=perf_data.get("pillar_c_right"), + package_tray=perf_data.get("package_tray"), + accident_history=perf_data.get("accident_history"), + report_image_url=perf_data.get("report_image_url"), + ) + db.add(performance_check) + db.flush() + + # PDF 캡처 + pdf_path = None + if performance_check.check_number: + try: + pdf_path = await capture_performance_check_pdf( + performance_check.check_number, + car.id + ) + if pdf_path: + performance_check.pdf_path = pdf_path + except Exception as e: + print(f"PDF capture failed for car {car.id}: {e}") + + db.commit() + + return { + "message": "Performance check fetched successfully", + "car_id": car_id, + "check_number": performance_check.check_number, + "has_pdf": bool(pdf_path) + } + + +@router.post("/car/{car_id}/regenerate-pdf") +async def regenerate_performance_check_pdf( + car_id: int, + db: Session = Depends(get_db), + current_user = Depends(get_current_admin_user), +): + """성능점검표 PDF 재생성 (관리자 전용)""" + + performance_check = db.query(CarPerformanceCheck).filter( + CarPerformanceCheck.car_id == car_id + ).first() + + if not performance_check: + raise HTTPException(status_code=404, detail="Performance check not found") + + if not performance_check.check_number: + raise HTTPException(status_code=400, detail="No check number available") + + try: + pdf_path = await capture_performance_check_pdf( + performance_check.check_number, + car_id + ) + if pdf_path: + performance_check.pdf_path = pdf_path + db.commit() + return {"message": "PDF regenerated successfully", "pdf_path": pdf_path} + else: + raise HTTPException(status_code=500, detail="Failed to generate PDF") + except Exception as e: + raise HTTPException(status_code=500, detail=f"PDF generation error: {str(e)}") + + +@router.post("/car/{car_id}/fetch-check-num") +async def fetch_car_check_num( + car_id: int, + db: Session = Depends(get_db), + current_user = Depends(get_current_admin_user), +): + """차량의 성능점검번호를 카모두에서 가져와서 저장 (관리자 전용)""" + + car = db.query(Car).filter(Car.id == car_id).first() + if not car: + raise HTTPException(status_code=404, detail="Car not found") + + if not car.car_number: + raise HTTPException(status_code=400, detail="Car number not available") + + # 기존 성능점검 레코드 확인 + performance_check = db.query(CarPerformanceCheck).filter( + CarPerformanceCheck.car_id == car_id + ).first() + + # 이미 check_number가 있으면 바로 PDF 생성 시도 (최대 3회 재시도) + if performance_check and performance_check.check_number: + pdf_error = None + pdf_path = None + max_retries = 3 + + for attempt in range(1, max_retries + 1): + try: + print(f"[PDF API] Attempt {attempt}/{max_retries} for car_id={car_id}, check_num={performance_check.check_number}") + pdf_path = await capture_performance_check_pdf( + performance_check.check_number, + car_id + ) + print(f"[PDF API] Attempt {attempt} result: pdf_path={pdf_path}") + + if pdf_path: + performance_check.pdf_path = pdf_path + db.commit() + return { + "message": f"PDF generated successfully (attempt {attempt})", + "check_number": performance_check.check_number, + "pdf_path": pdf_path + } + else: + pdf_error = f"Attempt {attempt}: PDF generation returned None" + except Exception as e: + import traceback + pdf_error = f"Attempt {attempt}: {str(e)}" + print(f"[PDF API] Exception on attempt {attempt}: {pdf_error}") + + # 재시도 전 대기 + if attempt < max_retries: + await asyncio.sleep(2) + + return { + "message": f"Check number exists but PDF generation failed after {max_retries} attempts: {pdf_error}", + "check_number": performance_check.check_number, + "pdf_path": None + } + + # 카모두에서 check_num 가져오기 + try: + check_num = await carmodoo_client.get_car_check_num(car.car_number, "") + if not check_num: + raise HTTPException(status_code=404, detail="Could not find check number from Carmodoo") + + # 성능점검 레코드 생성 또는 업데이트 + if not performance_check: + performance_check = CarPerformanceCheck( + car_id=car_id, + check_number=check_num + ) + db.add(performance_check) + else: + performance_check.check_number = check_num + + # PDF 생성 시도 (최대 3회 재시도) + pdf_path = None + pdf_error = None + max_retries = 3 + + for attempt in range(1, max_retries + 1): + try: + print(f"[PDF API New] Attempt {attempt}/{max_retries} for car_id={car_id}, check_num={check_num}") + pdf_path = await capture_performance_check_pdf(check_num, car_id) + + if pdf_path: + performance_check.pdf_path = pdf_path + print(f"[PDF API New] Success on attempt {attempt}: {pdf_path}") + break + else: + pdf_error = f"Attempt {attempt}: returned None" + except Exception as e: + pdf_error = f"Attempt {attempt}: {str(e)}" + print(f"[PDF API New] Exception on attempt {attempt}: {pdf_error}") + + if attempt < max_retries: + await asyncio.sleep(2) + + db.commit() + + return { + "message": "Check number fetched successfully" + (f" (PDF: {pdf_error})" if not pdf_path else ""), + "check_number": check_num, + "pdf_path": pdf_path + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error fetching check number: {str(e)}") + + +@router.get("/admin/pdf-failures") +async def get_pdf_generation_failures( + current_user: User = Depends(get_current_admin_user) +): + """Get list of recent PDF generation failures (Admin only)""" + failures = get_pdf_failures() + return { + "failures": failures, + "count": len(failures) + } + + +@router.post("/admin/retry-all-failed-pdfs") +async def retry_all_failed_pdfs( + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """Retry PDF generation for all cars with missing PDFs (Admin only)""" + results = [] + carmodoo_client = CarmodooClient() + + # 1. 배너 차량 중 car_performance_checks 레코드가 없는 차량 찾기 + cars_without_check = db.query(Car).outerjoin( + CarPerformanceCheck, Car.id == CarPerformanceCheck.car_id + ).filter( + Car.is_displayed == True, + CarPerformanceCheck.id.is_(None) + ).all() + + for car in cars_without_check: + try: + # 차량번호로 성능점검 데이터 조회 + check_num = car.check_num or "" + if not check_num and car.car_number: + # check_num이 없으면 조회 시도 + check_num = await carmodoo_client.get_car_check_num(car.source_id, car.source_key or "") + + if not check_num: + results.append({ + "car_id": car.id, + "car_name": car.car_name, + "status": "skipped", + "reason": "No check_num available" + }) + continue + + # 성능점검 데이터 가져오기 + 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"] + # CarPerformanceCheck 레코드 생성 + performance_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(performance_check) + db.flush() + + # PDF 캡처 + pdf_path = await capture_performance_check_pdf(performance_check.check_number, car.id) + if pdf_path: + performance_check.pdf_path = pdf_path + db.commit() + results.append({ + "car_id": car.id, + "car_name": car.car_name, + "check_number": performance_check.check_number, + "status": "success", + "pdf_path": pdf_path, + "action": "created_and_captured" + }) + else: + db.commit() + results.append({ + "car_id": car.id, + "car_name": car.car_name, + "check_number": performance_check.check_number, + "status": "partial", + "reason": "Record created but PDF capture failed" + }) + else: + results.append({ + "car_id": car.id, + "car_name": car.car_name, + "status": "failed", + "reason": "Could not fetch performance check data" + }) + except Exception as e: + results.append({ + "car_id": car.id, + "car_name": car.car_name, + "status": "error", + "error": str(e) + }) + + # 2. 기존 로직: check_number가 있지만 pdf_path가 없는 레코드 + missing_pdfs = db.query(CarPerformanceCheck).filter( + CarPerformanceCheck.check_number.isnot(None), + CarPerformanceCheck.check_number != '', + (CarPerformanceCheck.pdf_path.is_(None)) | (CarPerformanceCheck.pdf_path == '') + ).all() + + for check in missing_pdfs: + try: + pdf_path = await capture_performance_check_pdf(check.check_number, check.car_id) + if pdf_path: + check.pdf_path = pdf_path + db.commit() + results.append({ + "car_id": check.car_id, + "check_number": check.check_number, + "status": "success", + "pdf_path": pdf_path, + "action": "captured" + }) + else: + results.append({ + "car_id": check.car_id, + "check_number": check.check_number, + "status": "failed", + "pdf_path": None + }) + except Exception as e: + results.append({ + "car_id": check.car_id, + "check_number": check.check_number, + "status": "error", + "error": str(e) + }) + + success_count = sum(1 for r in results if r["status"] == "success") + return { + "total": len(results), + "success": success_count, + "failed": len(results) - success_count, + "results": results + } + + +@router.post("/pdf-status") +async def get_pdf_status_batch( + car_ids: List[int], + db: Session = Depends(get_db), +): + """여러 차량의 PDF 상태를 한번에 조회""" + if not car_ids: + return {} + + checks = db.query(CarPerformanceCheck).filter( + CarPerformanceCheck.car_id.in_(car_ids) + ).all() + + # Create a map of car_id -> has_pdf + result = {} + for check in checks: + result[check.car_id] = bool(check.pdf_path) + + # Fill in missing car_ids with False + for car_id in car_ids: + if car_id not in result: + result[car_id] = False + + return result + + +@router.get("/specifications/{car_number}") +async def get_car_specifications( + car_number: str, + current_user = Depends(get_current_admin_user), +): + """차량번호로 상세사양 조회 (관리자 전용) + + AUTOBEGINS 서비스를 통해 차량의 상세사양을 조회합니다. + - 제조사, 모델명, 년형, 연료, 배기량 + - 출고가, 기본가, 옵션가 + - 기본 옵션, 선택 옵션 + """ + try: + spec_data = await get_specifications_from_carmodoo(car_number) + if spec_data: + return { + "found": True, + "car_number": car_number, + "specifications": spec_to_dict(spec_data), + "raw": { + "manufacturer": spec_data.manufacturer, + "model_name": spec_data.model_name, + "model_year": spec_data.model_year, + "fuel_type": spec_data.fuel_type, + "displacement": spec_data.displacement, + "transmission": spec_data.transmission, + "body_type": spec_data.body_type, + "color": spec_data.color, + "mileage": spec_data.mileage, + "vin": spec_data.vin, + "release_price": spec_data.release_price, + "base_price": spec_data.base_price, + } + } + else: + return { + "found": False, + "car_number": car_number, + "message": "No specifications found for this car number" + } + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to fetch specifications: {str(e)}" + ) + + +@router.get("/car/{car_id}/specifications") +async def get_car_specifications_by_id( + car_id: int, + db: Session = Depends(get_db), + current_user = Depends(get_current_user), +): + """저장된 차량의 상세사양 조회""" + car = db.query(Car).filter(Car.id == car_id).first() + if not car: + raise HTTPException(status_code=404, detail="Car not found") + + # Check if specifications exist in database + spec = db.query(CarSpecification).filter(CarSpecification.car_id == car_id).first() + + if spec: + return { + "found": True, + "car_id": car_id, + "from_db": True, + "specifications": { + "manufacturer": spec.manufacturer, + "model_name": spec.model_name, + "grade": spec.grade, + "model_year": spec.model_year, + "fuel_type": spec.fuel_type, + "displacement": spec.displacement, + "transmission": spec.transmission, + "drive_type": spec.drive_type, + "body_type": spec.body_type, + "max_power": spec.max_power, + "max_torque": spec.max_torque, + "fuel_efficiency": spec.fuel_efficiency, + "seating_capacity": spec.seating_capacity, + "comfort_options": spec.comfort_options, + "interior_options": spec.interior_options, + "raw_data": spec.raw_data, + } + } + + # If not in DB but car has car_number, offer to fetch (admin only) + if current_user.is_admin and car.car_number: + return { + "found": False, + "car_id": car_id, + "car_number": car.car_number, + "message": "Specifications not saved. Use /specifications/{car_number} to fetch." + } + + return { + "found": False, + "car_id": car_id, + "message": "No specifications available for this car" + } + + +# ==================== Sensitive Info Detection ==================== + +class SensitiveInfoRequest(BaseModel): + text: str + + +class SensitiveInfoResponse(BaseModel): + has_sensitive: bool + summary: dict + highlighted_html: str + masked_text: str + + +@router.post("/check-sensitive-info", response_model=SensitiveInfoResponse) +async def check_sensitive_info( + request: SensitiveInfoRequest, + current_user = Depends(get_current_admin_user), +): + """ + Check text for sensitive information (phone numbers, addresses, etc.) + Returns highlighted HTML for preview and masked text for storage. + Admin only. + """ + text = request.text or "" + + return SensitiveInfoResponse( + has_sensitive=has_sensitive_info(text), + summary=get_sensitivity_summary(text), + highlighted_html=highlight_sensitive_info(text), + masked_text=mask_sensitive_info(text) + ) + + +@router.post("/preview-dealer-description/{car_no}") +async def preview_dealer_description( + car_no: str, + car_key: Optional[str] = None, + current_user = Depends(get_current_admin_user), +): + """ + Fetch dealer description from Carmodoo and return with sensitivity analysis. + Admin only. + """ + car_detail = await carmodoo_client.get_car_detail(car_no, car_key or "") + + if not car_detail.get("found") or not car_detail.get("dealer_description"): + return { + "found": False, + "message": "No dealer description found" + } + + original = car_detail.get("dealer_description", "") + + return { + "found": True, + "original": original, + "has_sensitive": has_sensitive_info(original), + "summary": get_sensitivity_summary(original), + "highlighted_html": highlight_sensitive_info(original), + "masked_text": mask_sensitive_info(original) + } + + +# ===== 딜러 설명 번역 관리 API ===== + +class TranslationUpdateRequest(BaseModel): + """번역 수정 요청""" + dealer_description: Optional[str] = None # 한국어 원문 + dealer_description_en: Optional[str] = None + dealer_description_mn: Optional[str] = None + dealer_description_ru: Optional[str] = None + + +@router.get("/car/{car_id}/translations") +async def get_car_translations( + car_id: int, + db: Session = Depends(get_db), + current_user = Depends(get_current_admin_user), +): + """차량의 딜러 설명 번역 정보 조회 (관리자용)""" + car = db.query(Car).filter(Car.id == car_id).first() + if not car: + raise HTTPException(status_code=404, detail="Car not found") + + translation_service = get_translation_service() + + return { + "car_id": car_id, + "car_name": car.car_name, + "dealer_description": car.dealer_description, + "translations": { + "en": car.dealer_description_en, + "mn": car.dealer_description_mn, + "ru": car.dealer_description_ru + }, + "has_translations": bool( + car.dealer_description_en or + car.dealer_description_mn or + car.dealer_description_ru + ), + "papago_configured": translation_service.is_configured + } + + +@router.put("/car/{car_id}/translations") +async def update_car_translations( + car_id: int, + request: TranslationUpdateRequest, + db: Session = Depends(get_db), + current_user = Depends(get_current_admin_user), +): + """차량의 딜러 설명 번역 수정 (관리자용)""" + car = db.query(Car).filter(Car.id == car_id).first() + if not car: + raise HTTPException(status_code=404, detail="Car not found") + + # 한국어 원문 업데이트 + if request.dealer_description is not None: + car.dealer_description = request.dealer_description + + # 번역 업데이트 + if request.dealer_description_en is not None: + car.dealer_description_en = request.dealer_description_en + if request.dealer_description_mn is not None: + car.dealer_description_mn = request.dealer_description_mn + if request.dealer_description_ru is not None: + car.dealer_description_ru = request.dealer_description_ru + + db.commit() + + return { + "message": "Translations updated successfully", + "car_id": car_id, + "dealer_description": car.dealer_description, + "translations": { + "en": car.dealer_description_en, + "mn": car.dealer_description_mn, + "ru": car.dealer_description_ru + } + } + + +@router.post("/car/{car_id}/translations/regenerate") +async def regenerate_car_translations( + car_id: int, + db: Session = Depends(get_db), + current_user = Depends(get_current_admin_user), +): + """차량의 딜러 설명 번역 재생성 (관리자용)""" + car = db.query(Car).filter(Car.id == car_id).first() + if not car: + raise HTTPException(status_code=404, detail="Car not found") + + if not car.dealer_description: + raise HTTPException(status_code=400, detail="No dealer description to translate") + + translation_service = get_translation_service() + if not translation_service.is_configured: + raise HTTPException( + status_code=503, + detail="Translation service not configured. Please set PAPAGO_CLIENT_ID and PAPAGO_CLIENT_SECRET." + ) + + try: + translations = await translate_dealer_description(car.dealer_description) + car.dealer_description_en = translations.get('en') + car.dealer_description_mn = translations.get('mn') + car.dealer_description_ru = translations.get('ru') + db.commit() + + return { + "message": "Translations regenerated successfully", + "car_id": car_id, + "translations": { + "en": car.dealer_description_en, + "mn": car.dealer_description_mn, + "ru": car.dealer_description_ru + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Translation failed: {str(e)}") + + +@router.get("/admin/untranslated-cars") +async def get_untranslated_cars( + db: Session = Depends(get_db), + current_user = Depends(get_current_admin_user), + limit: int = Query(default=50, le=100), +): + """번역이 없는 차량 목록 조회 (관리자용)""" + cars = db.query(Car).filter( + Car.dealer_description.isnot(None), + Car.dealer_description != '', + ( + (Car.dealer_description_en.is_(None)) | + (Car.dealer_description_mn.is_(None)) | + (Car.dealer_description_ru.is_(None)) + ) + ).limit(limit).all() + + return { + "count": len(cars), + "cars": [ + { + "id": car.id, + "car_name": car.car_name, + "dealer_description": car.dealer_description[:100] + "..." if car.dealer_description and len(car.dealer_description) > 100 else car.dealer_description, + "has_en": bool(car.dealer_description_en), + "has_mn": bool(car.dealer_description_mn), + "has_ru": bool(car.dealer_description_ru) + } + for car in cars + ] + } + + +@router.post("/admin/translate-all-pending") +async def translate_all_pending( + db: Session = Depends(get_db), + current_user = Depends(get_current_admin_user), +): + """번역이 없는 모든 차량에 대해 번역 수행 (관리자용)""" + translation_service = get_translation_service() + if not translation_service.is_configured: + raise HTTPException( + status_code=503, + detail="Translation service not configured. Please set PAPAGO_CLIENT_ID and PAPAGO_CLIENT_SECRET." + ) + + cars = db.query(Car).filter( + Car.dealer_description.isnot(None), + Car.dealer_description != '', + ( + (Car.dealer_description_en.is_(None)) & + (Car.dealer_description_mn.is_(None)) & + (Car.dealer_description_ru.is_(None)) + ) + ).all() + + results = [] + for car in cars: + try: + translations = await translate_dealer_description(car.dealer_description) + car.dealer_description_en = translations.get('en') + car.dealer_description_mn = translations.get('mn') + car.dealer_description_ru = translations.get('ru') + results.append({ + "car_id": car.id, + "status": "success" + }) + except Exception as e: + results.append({ + "car_id": car.id, + "status": "failed", + "error": str(e) + }) + + db.commit() + + success_count = len([r for r in results if r["status"] == "success"]) + return { + "message": f"Translated {success_count}/{len(results)} cars", + "total": len(results), + "success": success_count, + "failed": len(results) - success_count, + "results": results + } diff --git a/backend/app/api/cars.py b/backend/app/api/cars.py new file mode 100644 index 0000000..36d75ac --- /dev/null +++ b/backend/app/api/cars.py @@ -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 diff --git a/backend/app/api/cc.py b/backend/app/api/cc.py new file mode 100644 index 0000000..e2f3393 --- /dev/null +++ b/backend/app/api/cc.py @@ -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" + } diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py new file mode 100644 index 0000000..a183d90 --- /dev/null +++ b/backend/app/api/dashboard.py @@ -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 + } diff --git a/backend/app/api/dealer.py b/backend/app/api/dealer.py new file mode 100644 index 0000000..9bada20 --- /dev/null +++ b/backend/app/api/dealer.py @@ -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} diff --git a/backend/app/api/exchange_rate.py b/backend/app/api/exchange_rate.py new file mode 100644 index 0000000..ac07733 --- /dev/null +++ b/backend/app/api/exchange_rate.py @@ -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 diff --git a/backend/app/api/hero_banners.py b/backend/app/api/hero_banners.py new file mode 100644 index 0000000..40279b3 --- /dev/null +++ b/backend/app/api/hero_banners.py @@ -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, + } diff --git a/backend/app/api/inquiries.py b/backend/app/api/inquiries.py new file mode 100644 index 0000000..4e519ae --- /dev/null +++ b/backend/app/api/inquiries.py @@ -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 + } diff --git a/backend/app/api/notification.py b/backend/app/api/notification.py new file mode 100644 index 0000000..f97d778 --- /dev/null +++ b/backend/app/api/notification.py @@ -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"} diff --git a/backend/app/api/push.py b/backend/app/api/push.py new file mode 100644 index 0000000..e397f63 --- /dev/null +++ b/backend/app/api/push.py @@ -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 diff --git a/backend/app/api/referral.py b/backend/app/api/referral.py new file mode 100644 index 0000000..730e123 --- /dev/null +++ b/backend/app/api/referral.py @@ -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 diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py new file mode 100644 index 0000000..6ee57b7 --- /dev/null +++ b/backend/app/api/settings.py @@ -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 diff --git a/backend/app/api/translations.py b/backend/app/api/translations.py new file mode 100644 index 0000000..d051efa --- /dev/null +++ b/backend/app/api/translations.py @@ -0,0 +1,1018 @@ +# Translation API with default dictionaries +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import or_ +from typing import Optional, List +from ..database import get_db +from ..models import Translation +from ..schemas import ( + TranslationCreate, TranslationUpdate, TranslationResponse, + TranslationListResponse, TranslationBulkRequest, TranslationBulkResponse, +) + +router = APIRouter(prefix="/translations", tags=["translations"]) + +# Categories for translations +TRANSLATION_CATEGORIES = [ + "maker", # Car makers (Hyundai, Kia, etc.) + "model", # Car models (Sonata, K5, etc.) + "fuel", # Fuel types (가솔린, 디젤, etc.) + "transmission", # Transmission types (자동, 수동, etc.) + "color", # Colors (흰색, 검정색, etc.) + "car_name", # Full car names + "general", # General terms +] + +# Default translation dictionary for common Korean terms +DEFAULT_TRANSLATIONS = { + # Fuel types (연료) + "fuel": { + "가솔린": {"en": "Gasoline", "mn": "Бензин", "ru": "Бензин"}, + "휘발유": {"en": "Gasoline", "mn": "Бензин", "ru": "Бензин"}, + "디젤": {"en": "Diesel", "mn": "Дизель", "ru": "Дизель"}, + "경유": {"en": "Diesel", "mn": "Дизель", "ru": "Дизель"}, + "LPG": {"en": "LPG", "mn": "LPG", "ru": "Газ"}, + "가스": {"en": "LPG", "mn": "LPG", "ru": "Газ"}, + "전기": {"en": "Electric", "mn": "Цахилгаан", "ru": "Электро"}, + "하이브리드": {"en": "Hybrid", "mn": "Хайбрид", "ru": "Гибрид"}, + "플러그인하이브리드": {"en": "Plug-in Hybrid", "mn": "Плаг-ин Хайбрид", "ru": "Плагин гибрид"}, + "수소": {"en": "Hydrogen", "mn": "Устөрөгч", "ru": "Водород"}, + "CNG": {"en": "CNG", "mn": "CNG", "ru": "Метан"}, + }, + # Transmission types (변속기) + "transmission": { + "자동": {"en": "Automatic", "mn": "Автомат", "ru": "Автомат"}, + "오토": {"en": "Automatic", "mn": "Автомат", "ru": "Автомат"}, + "수동": {"en": "Manual", "mn": "Механик", "ru": "Механика"}, + "스틱": {"en": "Manual", "mn": "Механик", "ru": "Механика"}, + "CVT": {"en": "CVT", "mn": "CVT", "ru": "Вариатор"}, + "무단변속기": {"en": "CVT", "mn": "CVT", "ru": "Вариатор"}, + "DCT": {"en": "DCT", "mn": "DCT", "ru": "Робот"}, + "듀얼클러치": {"en": "Dual Clutch", "mn": "Давхар холбоо", "ru": "Робот"}, + "세미오토": {"en": "Semi-Auto", "mn": "Хагас автомат", "ru": "Полуавтомат"}, + }, + # Colors (색상) + "color": { + "흰색": {"en": "White", "mn": "Цагаан", "ru": "Белый"}, + "화이트": {"en": "White", "mn": "Цагаан", "ru": "Белый"}, + "백색": {"en": "White", "mn": "Цагаан", "ru": "Белый"}, + "검정": {"en": "Black", "mn": "Хар", "ru": "Чёрный"}, + "검정색": {"en": "Black", "mn": "Хар", "ru": "Чёрный"}, + "블랙": {"en": "Black", "mn": "Хар", "ru": "Чёрный"}, + "은색": {"en": "Silver", "mn": "Мөнгөлөг", "ru": "Серебристый"}, + "실버": {"en": "Silver", "mn": "Мөнгөлөг", "ru": "Серебристый"}, + "회색": {"en": "Gray", "mn": "Саарал", "ru": "Серый"}, + "그레이": {"en": "Gray", "mn": "Саарал", "ru": "Серый"}, + "진회색": {"en": "Dark Gray", "mn": "Бараан саарал", "ru": "Тёмно-серый"}, + "빨강": {"en": "Red", "mn": "Улаан", "ru": "Красный"}, + "빨간색": {"en": "Red", "mn": "Улаан", "ru": "Красный"}, + "레드": {"en": "Red", "mn": "Улаан", "ru": "Красный"}, + "파랑": {"en": "Blue", "mn": "Хөх", "ru": "Синий"}, + "파란색": {"en": "Blue", "mn": "Хөх", "ru": "Синий"}, + "블루": {"en": "Blue", "mn": "Хөх", "ru": "Синий"}, + "남색": {"en": "Navy", "mn": "Хар хөх", "ru": "Тёмно-синий"}, + "네이비": {"en": "Navy", "mn": "Хар хөх", "ru": "Тёмно-синий"}, + "하늘색": {"en": "Sky Blue", "mn": "Тэнгэрийн хөх", "ru": "Голубой"}, + "녹색": {"en": "Green", "mn": "Ногоон", "ru": "Зелёный"}, + "초록색": {"en": "Green", "mn": "Ногоон", "ru": "Зелёный"}, + "그린": {"en": "Green", "mn": "Ногоон", "ru": "Зелёный"}, + "노랑": {"en": "Yellow", "mn": "Шар", "ru": "Жёлтый"}, + "노란색": {"en": "Yellow", "mn": "Шар", "ru": "Жёлтый"}, + "옐로우": {"en": "Yellow", "mn": "Шар", "ru": "Жёлтый"}, + "주황색": {"en": "Orange", "mn": "Улбар шар", "ru": "Оранжевый"}, + "오렌지": {"en": "Orange", "mn": "Улбар шар", "ru": "Оранжевый"}, + "갈색": {"en": "Brown", "mn": "Бор", "ru": "Коричневый"}, + "브라운": {"en": "Brown", "mn": "Бор", "ru": "Коричневый"}, + "베이지": {"en": "Beige", "mn": "Бээж", "ru": "Бежевый"}, + "아이보리": {"en": "Ivory", "mn": "Зүс өнгө", "ru": "Слоновая кость"}, + "진주색": {"en": "Pearl", "mn": "Сувдан", "ru": "Перламутр"}, + "펄화이트": {"en": "Pearl White", "mn": "Сувдан цагаан", "ru": "Перламутровый белый"}, + "보라색": {"en": "Purple", "mn": "Ягаан", "ru": "Фиолетовый"}, + "퍼플": {"en": "Purple", "mn": "Ягаан", "ru": "Фиолетовый"}, + "분홍색": {"en": "Pink", "mn": "Ягаан", "ru": "Розовый"}, + "핑크": {"en": "Pink", "mn": "Ягаан", "ru": "Розовый"}, + "와인": {"en": "Wine", "mn": "Дарс өнгө", "ru": "Бордовый"}, + "버건디": {"en": "Burgundy", "mn": "Бургунд", "ru": "Бургунди"}, + "골드": {"en": "Gold", "mn": "Алтан", "ru": "Золотой"}, + "금색": {"en": "Gold", "mn": "Алтан", "ru": "Золотой"}, + "샴페인": {"en": "Champagne", "mn": "Шампан", "ru": "Шампань"}, + "청색": {"en": "Blue", "mn": "Хөх", "ru": "Синий"}, + "연두색": {"en": "Light Green", "mn": "Цайвар ногоон", "ru": "Светло-зелёный"}, + "민트": {"en": "Mint", "mn": "Минт", "ru": "Мятный"}, + "카키": {"en": "Khaki", "mn": "Хаки", "ru": "Хаки"}, + "올리브": {"en": "Olive", "mn": "Чидун ногоон", "ru": "Оливковый"}, + "투톤": {"en": "Two-Tone", "mn": "Хоёр өнгө", "ru": "Двухцветный"}, + "기타": {"en": "Other", "mn": "Бусад", "ru": "Другой"}, + }, + # Car makers (제조사) + "maker": { + "현대": {"en": "Hyundai", "mn": "Хёндай", "ru": "Хендай"}, + "기아": {"en": "Kia", "mn": "Киа", "ru": "Киа"}, + "제네시스": {"en": "Genesis", "mn": "Женезис", "ru": "Дженезис"}, + "쉐보레": {"en": "Chevrolet", "mn": "Шевроле", "ru": "Шевроле"}, + "르노삼성": {"en": "Renault Samsung", "mn": "Рено Самсунг", "ru": "Рено Самсунг"}, + "르노코리아": {"en": "Renault Korea", "mn": "Рено Солонгос", "ru": "Рено Корея"}, + "쌍용": {"en": "SsangYong", "mn": "Ссанёнг", "ru": "СсангЙонг"}, + "KG모빌리티": {"en": "KG Mobility", "mn": "KG Мобилити", "ru": "КГ Мобилити"}, + "BMW": {"en": "BMW", "mn": "BMW", "ru": "БМВ"}, + "벤츠": {"en": "Mercedes-Benz", "mn": "Мерседес-Бенз", "ru": "Мерседес"}, + "메르세데스벤츠": {"en": "Mercedes-Benz", "mn": "Мерседес-Бенз", "ru": "Мерседес"}, + "아우디": {"en": "Audi", "mn": "Ауди", "ru": "Ауди"}, + "폭스바겐": {"en": "Volkswagen", "mn": "Фольксваген", "ru": "Фольксваген"}, + "토요타": {"en": "Toyota", "mn": "Тойота", "ru": "Тойота"}, + "혼다": {"en": "Honda", "mn": "Хонда", "ru": "Хонда"}, + "닛산": {"en": "Nissan", "mn": "Ниссан", "ru": "Ниссан"}, + "렉서스": {"en": "Lexus", "mn": "Лексус", "ru": "Лексус"}, + "인피니티": {"en": "Infiniti", "mn": "Инфинити", "ru": "Инфинити"}, + "마쯔다": {"en": "Mazda", "mn": "Мазда", "ru": "Мазда"}, + "스바루": {"en": "Subaru", "mn": "Субару", "ru": "Субару"}, + "미쓰비시": {"en": "Mitsubishi", "mn": "Мицубиши", "ru": "Митсубиши"}, + "포드": {"en": "Ford", "mn": "Форд", "ru": "Форд"}, + "링컨": {"en": "Lincoln", "mn": "Линкольн", "ru": "Линкольн"}, + "캐딜락": {"en": "Cadillac", "mn": "Кадиллак", "ru": "Кадиллак"}, + "지프": {"en": "Jeep", "mn": "Жийп", "ru": "Джип"}, + "크라이슬러": {"en": "Chrysler", "mn": "Крайслер", "ru": "Крайслер"}, + "테슬라": {"en": "Tesla", "mn": "Тесла", "ru": "Тесла"}, + "볼보": {"en": "Volvo", "mn": "Вольво", "ru": "Вольво"}, + "포르쉐": {"en": "Porsche", "mn": "Порше", "ru": "Порше"}, + "재규어": {"en": "Jaguar", "mn": "Ягуар", "ru": "Ягуар"}, + "랜드로버": {"en": "Land Rover", "mn": "Лэнд Ровер", "ru": "Лэнд Ровер"}, + "미니": {"en": "Mini", "mn": "Мини", "ru": "Мини"}, + "페라리": {"en": "Ferrari", "mn": "Феррари", "ru": "Феррари"}, + "람보르기니": {"en": "Lamborghini", "mn": "Ламборгини", "ru": "Ламборгини"}, + "벤틀리": {"en": "Bentley", "mn": "Бентли", "ru": "Бентли"}, + "롤스로이스": {"en": "Rolls-Royce", "mn": "Роллс-Ройс", "ru": "Роллс-Ройс"}, + "마세라티": {"en": "Maserati", "mn": "Мазерати", "ru": "Мазерати"}, + "알파로메오": {"en": "Alfa Romeo", "mn": "Альфа Ромео", "ru": "Альфа Ромео"}, + "피아트": {"en": "Fiat", "mn": "Фиат", "ru": "Фиат"}, + "푸조": {"en": "Peugeot", "mn": "Пежо", "ru": "Пежо"}, + "시트로엥": {"en": "Citroen", "mn": "Ситроен", "ru": "Ситроен"}, + "르노": {"en": "Renault", "mn": "Рено", "ru": "Рено"}, + "스즈키": {"en": "Suzuki", "mn": "Сузуки", "ru": "Сузуки"}, + "다이하쓰": {"en": "Daihatsu", "mn": "Дайхатсу", "ru": "Дайхатсу"}, + "이스즈": {"en": "Isuzu", "mn": "Исузу", "ru": "Исузу"}, + "GMC": {"en": "GMC", "mn": "GMC", "ru": "ДжиЭмСи"}, + "대우": {"en": "Daewoo", "mn": "Дэу", "ru": "Дэу"}, + "아큐라": {"en": "Acura", "mn": "Акура", "ru": "Акура"}, + "뷰익": {"en": "Buick", "mn": "Бюик", "ru": "Бьюик"}, + "스마트": {"en": "Smart", "mn": "Смарт", "ru": "Смарт"}, + "BYD": {"en": "BYD", "mn": "BYD", "ru": "БИД"}, + }, + # Car models (차량 모델) - These generally keep their names but with transliteration + "model": { + # Hyundai models + "소나타": {"en": "Sonata", "mn": "Соната", "ru": "Соната"}, + "아반떼": {"en": "Avante", "mn": "Аванте", "ru": "Аванте"}, + "그랜저": {"en": "Grandeur", "mn": "Грандер", "ru": "Грандер"}, + "싼타페": {"en": "Santa Fe", "mn": "Санта Фе", "ru": "Санта Фе"}, + "투싼": {"en": "Tucson", "mn": "Туксон", "ru": "Туксон"}, + "팰리세이드": {"en": "Palisade", "mn": "Палисейд", "ru": "Палисад"}, + "스타리아": {"en": "Staria", "mn": "Стариа", "ru": "Стариа"}, + "아이오닉": {"en": "Ioniq", "mn": "Ионик", "ru": "Ионик"}, + "코나": {"en": "Kona", "mn": "Кона", "ru": "Кона"}, + "베뉴": {"en": "Venue", "mn": "Венью", "ru": "Венью"}, + "넥쏘": {"en": "Nexo", "mn": "Нексо", "ru": "Нексо"}, + "캐스퍼": {"en": "Casper", "mn": "Каспер", "ru": "Каспер"}, + "엘란트라": {"en": "Elantra", "mn": "Элантра", "ru": "Элантра"}, + "액센트": {"en": "Accent", "mn": "Акцент", "ru": "Акцент"}, + "벨로스터": {"en": "Veloster", "mn": "Велостер", "ru": "Велостер"}, + "i30": {"en": "i30", "mn": "i30", "ru": "i30"}, + "i40": {"en": "i40", "mn": "i40", "ru": "i40"}, + # Kia models + "쏘렌토": {"en": "Sorento", "mn": "Соренто", "ru": "Соренто"}, + "스포티지": {"en": "Sportage", "mn": "Спортейж", "ru": "Спортейдж"}, + "카니발": {"en": "Carnival", "mn": "Карнивал", "ru": "Карнивал"}, + "셀토스": {"en": "Seltos", "mn": "Сельтос", "ru": "Селтос"}, + "모하비": {"en": "Mohave", "mn": "Мохаве", "ru": "Мохаве"}, + "니로": {"en": "Niro", "mn": "Ниро", "ru": "Ниро"}, + "스팅어": {"en": "Stinger", "mn": "Стингер", "ru": "Стингер"}, + "레이": {"en": "Ray", "mn": "Рэй", "ru": "Рэй"}, + "모닝": {"en": "Morning", "mn": "Морнинг", "ru": "Морнинг"}, + "쏘울": {"en": "Soul", "mn": "Соул", "ru": "Соул"}, + "K3": {"en": "K3", "mn": "K3", "ru": "K3"}, + "K5": {"en": "K5", "mn": "K5", "ru": "K5"}, + "K7": {"en": "K7", "mn": "K7", "ru": "K7"}, + "K8": {"en": "K8", "mn": "K8", "ru": "K8"}, + "K9": {"en": "K9", "mn": "K9", "ru": "K9"}, + "EV6": {"en": "EV6", "mn": "EV6", "ru": "EV6"}, + "EV9": {"en": "EV9", "mn": "EV9", "ru": "EV9"}, + # Genesis models + "G70": {"en": "G70", "mn": "G70", "ru": "G70"}, + "G80": {"en": "G80", "mn": "G80", "ru": "G80"}, + "G90": {"en": "G90", "mn": "G90", "ru": "G90"}, + "GV60": {"en": "GV60", "mn": "GV60", "ru": "GV60"}, + "GV70": {"en": "GV70", "mn": "GV70", "ru": "GV70"}, + "GV80": {"en": "GV80", "mn": "GV80", "ru": "GV80"}, + # SsangYong / KG Mobility models + "렉스턴": {"en": "Rexton", "mn": "Рекстон", "ru": "Рекстон"}, + "코란도": {"en": "Korando", "mn": "Корандо", "ru": "Корандо"}, + "티볼리": {"en": "Tivoli", "mn": "Тиволи", "ru": "Тиволи"}, + "토레스": {"en": "Torres", "mn": "Торрес", "ru": "Торрес"}, + "무쏘": {"en": "Musso", "mn": "Муссо", "ru": "Муссо"}, + # Chevrolet models + "말리부": {"en": "Malibu", "mn": "Малибу", "ru": "Малибу"}, + "트래버스": {"en": "Traverse", "mn": "Траверс", "ru": "Траверс"}, + "트랙스": {"en": "Trax", "mn": "Тракс", "ru": "Тракс"}, + "이쿼녹스": {"en": "Equinox", "mn": "Эквинокс", "ru": "Эквинокс"}, + "스파크": {"en": "Spark", "mn": "Спарк", "ru": "Спарк"}, + "볼트": {"en": "Bolt", "mn": "Болт", "ru": "Болт"}, + # Renault Samsung models + "SM6": {"en": "SM6", "mn": "SM6", "ru": "SM6"}, + "QM6": {"en": "QM6", "mn": "QM6", "ru": "QM6"}, + "XM3": {"en": "XM3", "mn": "XM3", "ru": "XM3"}, + }, +} + + +@router.get("/categories") +def get_categories(): + """Get available translation categories""" + return TRANSLATION_CATEGORIES + + +@router.get("", response_model=TranslationListResponse) +def get_translations( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + category: Optional[str] = None, + search: Optional[str] = None, + db: Session = Depends(get_db), +): + """Get all translations with pagination and filtering""" + query = db.query(Translation) + + if category: + query = query.filter(Translation.category == category) + + if search: + search_term = f"%{search}%" + query = query.filter( + or_( + Translation.source_text.ilike(search_term), + Translation.text_en.ilike(search_term), + Translation.text_mn.ilike(search_term), + Translation.text_ru.ilike(search_term), + ) + ) + + total = query.count() + translations = query.order_by( + Translation.category, Translation.source_text + ).offset((page - 1) * page_size).limit(page_size).all() + + return TranslationListResponse( + total=total, + page=page, + page_size=page_size, + translations=translations + ) + + +@router.get("/{translation_id}", response_model=TranslationResponse) +def get_translation(translation_id: int, db: Session = Depends(get_db)): + """Get a specific translation by ID""" + translation = db.query(Translation).filter(Translation.id == translation_id).first() + if not translation: + raise HTTPException(status_code=404, detail="Translation not found") + return translation + + +@router.post("", response_model=TranslationResponse) +def create_translation(data: TranslationCreate, db: Session = Depends(get_db)): + """Create a new translation""" + # Check if already exists + existing = db.query(Translation).filter( + Translation.source_text == data.source_text, + Translation.category == data.category + ).first() + + if existing: + raise HTTPException(status_code=400, detail="Translation already exists for this text and category") + + translation = Translation(**data.dict()) + db.add(translation) + db.commit() + db.refresh(translation) + return translation + + +@router.put("/{translation_id}", response_model=TranslationResponse) +def update_translation( + translation_id: int, + data: TranslationUpdate, + db: Session = Depends(get_db) +): + """Update a translation""" + translation = db.query(Translation).filter(Translation.id == translation_id).first() + if not translation: + raise HTTPException(status_code=404, detail="Translation not found") + + for key, value in data.dict(exclude_unset=True).items(): + setattr(translation, key, value) + + db.commit() + db.refresh(translation) + return translation + + +@router.delete("/{translation_id}") +def delete_translation(translation_id: int, db: Session = Depends(get_db)): + """Delete a translation""" + translation = db.query(Translation).filter(Translation.id == translation_id).first() + if not translation: + raise HTTPException(status_code=404, detail="Translation not found") + + db.delete(translation) + db.commit() + return {"message": "Translation deleted"} + + +@router.post("/bulk-lookup", response_model=TranslationBulkResponse) +def bulk_lookup( + request: TranslationBulkRequest, + db: Session = Depends(get_db) +): + """Lookup translations for multiple texts at once""" + if not request.texts: + return TranslationBulkResponse(translations={}) + + query = db.query(Translation).filter(Translation.source_text.in_(request.texts)) + + if request.category: + query = query.filter(Translation.category == request.category) + + translations_db = query.all() + + # Build result dictionary + result = {} + lang_field = f"text_{request.lang}" + + for trans in translations_db: + translated = getattr(trans, lang_field, None) + if translated: + result[trans.source_text] = translated + else: + # Fallback to source text if no translation + result[trans.source_text] = trans.source_text + + # For texts not found in DB, return original + for text in request.texts: + if text not in result: + result[text] = text + + return TranslationBulkResponse(translations=result) + + +@router.get("/lookup/{text}") +def lookup_single( + text: str, + lang: str = Query("en", description="Target language: en, mn, ru"), + category: Optional[str] = None, + db: Session = Depends(get_db) +): + """Lookup translation for a single text""" + query = db.query(Translation).filter(Translation.source_text == text) + + if category: + query = query.filter(Translation.category == category) + + translation = query.first() + + if not translation: + return {"source": text, "translated": text} + + lang_field = f"text_{lang}" + translated = getattr(translation, lang_field, None) + + return { + "source": text, + "translated": translated if translated else text, + "category": translation.category + } + + +def get_default_translation(source_text: str, category: str) -> dict: + """Get default translations from the predefined dictionary""" + if category in DEFAULT_TRANSLATIONS: + return DEFAULT_TRANSLATIONS[category].get(source_text, {}) + return {} + + +# Korean year suffix translations +YEAR_SUFFIX_TRANSLATIONS = { + "년형": {"en": "", "mn": " он", "ru": " г."}, + "년식": {"en": "", "mn": " он", "ru": " г."}, + "년": {"en": "", "mn": " он", "ru": " г."}, +} + + +def translate_car_name(car_name: str, lang: str, db) -> str: + """ + Translate a car name like "기아 K5 2024년형" to "Kia K5 2024" + by translating each component separately. + """ + import re + + if not car_name: + return car_name + + result = car_name + + # First, look up all translations we have in DB + all_translations = db.query(Translation).filter( + Translation.category.in_(["maker", "model", "fuel", "transmission", "color"]) + ).all() + + # Build lookup dict + trans_lookup = {} + for t in all_translations: + lang_field = f"text_{lang}" + translated = getattr(t, lang_field, None) + if translated and t.source_text: + trans_lookup[t.source_text] = translated + + # Also add from default dictionary + for category in ["maker", "model", "fuel", "transmission", "color"]: + if category in DEFAULT_TRANSLATIONS: + for korean, translations in DEFAULT_TRANSLATIONS[category].items(): + if korean not in trans_lookup and lang in translations: + trans_lookup[korean] = translations[lang] + + # Replace Korean terms with translations (longest first to avoid partial matches) + sorted_keys = sorted(trans_lookup.keys(), key=len, reverse=True) + for korean in sorted_keys: + if korean in result: + result = result.replace(korean, trans_lookup[korean]) + + # Handle year format: "2024년형" -> "2024" (en) or "2024 он" (mn) or "2024 г." (ru) + for suffix, translations in YEAR_SUFFIX_TRANSLATIONS.items(): + pattern = r'(\d{4})' + re.escape(suffix) + replacement = r'\1' + translations.get(lang, "") + result = re.sub(pattern, replacement, result) + + # Clean up extra spaces + result = re.sub(r'\s+', ' ', result).strip() + + return result + + +@router.post("/auto-extract") +def auto_extract_terms(db: Session = Depends(get_db)): + """ + Auto-extract unique terms from cars database that need translation. + This creates translation entries for makers, models, fuels, colors, etc. + Auto-fills with default translations from built-in dictionary. + """ + from ..models import Car, CarMaker, CarModel + + added_count = 0 + + # Extract makers + makers = db.query(CarMaker).all() + for maker in makers: + existing = db.query(Translation).filter( + Translation.source_text == maker.name, + Translation.category == "maker" + ).first() + if not existing: + defaults = get_default_translation(maker.name, "maker") + trans = Translation( + source_text=maker.name, + category="maker", + text_en=defaults.get("en") or maker.name_en or maker.name, + text_mn=defaults.get("mn") or maker.name, + text_ru=defaults.get("ru") or maker.name + ) + db.add(trans) + added_count += 1 + + # Extract models + models = db.query(CarModel).all() + for model in models: + existing = db.query(Translation).filter( + Translation.source_text == model.name, + Translation.category == "model" + ).first() + if not existing: + # Models usually keep their original names (Sonata, K5, etc.) + trans = Translation( + source_text=model.name, + category="model", + text_en=model.name_en or model.name, + text_mn=model.name_en or model.name, + text_ru=model.name_en or model.name + ) + db.add(trans) + added_count += 1 + + # Extract unique fuels + fuels = db.query(Car.fuel).distinct().filter(Car.fuel.isnot(None)).all() + for (fuel,) in fuels: + if fuel: + existing = db.query(Translation).filter( + Translation.source_text == fuel, + Translation.category == "fuel" + ).first() + if not existing: + defaults = get_default_translation(fuel, "fuel") + trans = Translation( + source_text=fuel, + category="fuel", + text_en=defaults.get("en") or fuel, + text_mn=defaults.get("mn") or fuel, + text_ru=defaults.get("ru") or fuel + ) + db.add(trans) + added_count += 1 + + # Extract unique transmissions + transmissions = db.query(Car.transmission).distinct().filter(Car.transmission.isnot(None)).all() + for (trans_type,) in transmissions: + if trans_type: + existing = db.query(Translation).filter( + Translation.source_text == trans_type, + Translation.category == "transmission" + ).first() + if not existing: + defaults = get_default_translation(trans_type, "transmission") + trans = Translation( + source_text=trans_type, + category="transmission", + text_en=defaults.get("en") or trans_type, + text_mn=defaults.get("mn") or trans_type, + text_ru=defaults.get("ru") or trans_type + ) + db.add(trans) + added_count += 1 + + # Extract unique colors + colors = db.query(Car.color).distinct().filter(Car.color.isnot(None)).all() + for (color,) in colors: + if color: + existing = db.query(Translation).filter( + Translation.source_text == color, + Translation.category == "color" + ).first() + if not existing: + defaults = get_default_translation(color, "color") + trans = Translation( + source_text=color, + category="color", + text_en=defaults.get("en") or color, + text_mn=defaults.get("mn") or color, + text_ru=defaults.get("ru") or color + ) + db.add(trans) + added_count += 1 + + # Extract unique car_names and auto-translate + car_names = db.query(Car.car_name).distinct().filter(Car.car_name.isnot(None)).all() + for (car_name,) in car_names: + if car_name: + existing = db.query(Translation).filter( + Translation.source_text == car_name, + Translation.category == "car_name" + ).first() + if not existing: + # Auto-translate car name by translating each component + translated_en = translate_car_name(car_name, "en", db) + translated_mn = translate_car_name(car_name, "mn", db) + translated_ru = translate_car_name(car_name, "ru", db) + + trans = Translation( + source_text=car_name, + category="car_name", + text_en=translated_en, + text_mn=translated_mn, + text_ru=translated_ru + ) + db.add(trans) + added_count += 1 + + db.commit() + + return {"message": f"Added {added_count} new translation entries with default translations"} + + +@router.post("/fill-defaults") +def fill_default_translations(db: Session = Depends(get_db)): + """ + Fill in missing translations with default values from the built-in dictionary. + This updates existing entries that have null translations. + """ + updated_count = 0 + + # Get all translations that have at least one null translation + translations = db.query(Translation).filter( + (Translation.text_en.is_(None)) | + (Translation.text_mn.is_(None)) | + (Translation.text_ru.is_(None)) + ).all() + + for trans in translations: + defaults = get_default_translation(trans.source_text, trans.category) + updated = False + + if not trans.text_en and defaults.get("en"): + trans.text_en = defaults["en"] + updated = True + elif not trans.text_en: + # Use source text as fallback for non-Korean languages + trans.text_en = trans.source_text + updated = True + + if not trans.text_mn and defaults.get("mn"): + trans.text_mn = defaults["mn"] + updated = True + elif not trans.text_mn: + trans.text_mn = trans.source_text + updated = True + + if not trans.text_ru and defaults.get("ru"): + trans.text_ru = defaults["ru"] + updated = True + elif not trans.text_ru: + trans.text_ru = trans.source_text + updated = True + + if updated: + updated_count += 1 + + db.commit() + + return {"message": f"Updated {updated_count} translations with default values"} + + +# AI Auto-Translation Service +import httpx +import json +from typing import Dict, List +import asyncio + +# Translation service configuration +TRANSLATION_SERVICE = { + "enabled": True, + "provider": "google", # Options: "google", "deepl", "libre" + "api_key": None, # Set via environment variable + "libre_url": "https://libretranslate.com/translate", # Free option +} + + +async def translate_text_ai(text: str, source_lang: str, target_lang: str) -> str: + """ + Translate text using AI translation service. + Falls back to original text if translation fails. + """ + if not text or not TRANSLATION_SERVICE["enabled"]: + return text + + # Language code mapping + lang_map = { + "ko": "ko", + "en": "en", + "mn": "mn", + "ru": "ru", + } + + source = lang_map.get(source_lang, source_lang) + target = lang_map.get(target_lang, target_lang) + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + # Using LibreTranslate (free option) + response = await client.post( + TRANSLATION_SERVICE["libre_url"], + json={ + "q": text, + "source": source, + "target": target, + "format": "text" + }, + headers={"Content-Type": "application/json"} + ) + + if response.status_code == 200: + result = response.json() + return result.get("translatedText", text) + else: + return text + except Exception as e: + print(f"Translation error: {e}") + return text + + +def translate_text_sync(text: str, source_lang: str, target_lang: str) -> str: + """Synchronous wrapper for AI translation""" + try: + return asyncio.run(translate_text_ai(text, source_lang, target_lang)) + except RuntimeError: + # Already in an event loop + loop = asyncio.get_event_loop() + if loop.is_running(): + # Can't run async in sync context when loop is running + return text + return loop.run_until_complete(translate_text_ai(text, source_lang, target_lang)) + + +from pydantic import BaseModel +from typing import List as PyList + +class AutoTranslateRequest(BaseModel): + target_langs: PyList[str] = ["en", "mn", "ru"] + +@router.post("/auto-translate/{translation_id}") +def auto_translate_single( + translation_id: int, + request: AutoTranslateRequest = None, + db: Session = Depends(get_db) +): + """ + Auto-translate a single translation entry using AI. + Translates Korean source text to all target languages. + """ + if request is None: + request = AutoTranslateRequest() + + translation = db.query(Translation).filter(Translation.id == translation_id).first() + if not translation: + raise HTTPException(status_code=404, detail="Translation not found") + + source_text = translation.source_text + translations_result = {} + + # First check defaults + defaults = get_default_translation(source_text, translation.category) + + # Try to translate to requested languages + if "en" in request.target_langs: + if not translation.text_en or translation.text_en == source_text: + if defaults.get("en"): + translation.text_en = defaults["en"] + else: + translated = translate_text_sync(source_text, "ko", "en") + if translated != source_text: + translation.text_en = translated + translations_result["en"] = translation.text_en or "" + + if "mn" in request.target_langs: + if not translation.text_mn or translation.text_mn == source_text: + if defaults.get("mn"): + translation.text_mn = defaults["mn"] + else: + translated = translate_text_sync(source_text, "ko", "mn") + if translated != source_text: + translation.text_mn = translated + translations_result["mn"] = translation.text_mn or "" + + if "ru" in request.target_langs: + if not translation.text_ru or translation.text_ru == source_text: + if defaults.get("ru"): + translation.text_ru = defaults["ru"] + else: + translated = translate_text_sync(source_text, "ko", "ru") + if translated != source_text: + translation.text_ru = translated + translations_result["ru"] = translation.text_ru or "" + + db.commit() + db.refresh(translation) + + return { + "id": translation.id, + "source_text": translation.source_text, + "translations": translations_result, + "message": f"Auto-translated to: {', '.join(request.target_langs)}" + } + + +class BatchTranslateRequest(BaseModel): + target_langs: PyList[str] = ["en", "mn", "ru"] + category: Optional[str] = None + overwrite_existing: bool = False + +@router.post("/auto-translate-batch") +def auto_translate_batch( + request: BatchTranslateRequest = None, + limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db) +): + """ + Auto-translate multiple translations that have missing translations. + Limits batch size to avoid API rate limits. + """ + if request is None: + request = BatchTranslateRequest() + + # Build query based on options + if request.overwrite_existing: + # Get all translations (optionally filtered by category) + query = db.query(Translation) + else: + # Only get translations with missing values + query = db.query(Translation).filter( + (Translation.text_en.is_(None)) | + (Translation.text_mn.is_(None)) | + (Translation.text_ru.is_(None)) | + (Translation.text_en == Translation.source_text) | + (Translation.text_mn == Translation.source_text) | + (Translation.text_ru == Translation.source_text) | + (Translation.text_en == "") | + (Translation.text_mn == "") | + (Translation.text_ru == "") + ) + + if request.category: + query = query.filter(Translation.category == request.category) + + translations = query.limit(limit).all() + + successful = 0 + failed = 0 + results = [] + + for trans in translations: + source_text = trans.source_text + try: + updated = False + + # First check if we have default translations + defaults = get_default_translation(source_text, trans.category) + + # English + if "en" in request.target_langs: + if request.overwrite_existing or not trans.text_en or trans.text_en == source_text or trans.text_en == "": + if defaults.get("en"): + trans.text_en = defaults["en"] + updated = True + else: + translated = translate_text_sync(source_text, "ko", "en") + if translated and translated != source_text: + trans.text_en = translated + updated = True + + # Mongolian + if "mn" in request.target_langs: + if request.overwrite_existing or not trans.text_mn or trans.text_mn == source_text or trans.text_mn == "": + if defaults.get("mn"): + trans.text_mn = defaults["mn"] + updated = True + else: + translated = translate_text_sync(source_text, "ko", "mn") + if translated and translated != source_text: + trans.text_mn = translated + updated = True + + # Russian + if "ru" in request.target_langs: + if request.overwrite_existing or not trans.text_ru or trans.text_ru == source_text or trans.text_ru == "": + if defaults.get("ru"): + trans.text_ru = defaults["ru"] + updated = True + else: + translated = translate_text_sync(source_text, "ko", "ru") + if translated and translated != source_text: + trans.text_ru = translated + updated = True + + if updated: + successful += 1 + results.append({ + "id": trans.id, + "source_text": source_text, + "success": True + }) + else: + results.append({ + "id": trans.id, + "source_text": source_text, + "success": True, + "error": "No changes needed" + }) + + except Exception as e: + failed += 1 + results.append({ + "id": trans.id, + "source_text": source_text, + "success": False, + "error": str(e) + }) + + db.commit() + + return { + "total_processed": len(translations), + "successful": successful, + "failed": failed, + "results": results + } + + +class TranslateOnDemandRequest(BaseModel): + text: str + source_lang: str = "ko" + target_lang: str = "en" + +@router.post("/translate-on-demand") +def translate_on_demand( + request: TranslateOnDemandRequest, + db: Session = Depends(get_db) +): + """ + Translate a single text on-demand without saving to database. + Useful for dynamic content translation. + """ + text = request.text + target_lang = request.target_lang + + if not text: + return {"source_text": "", "translated_text": "", "source_lang": request.source_lang, "target_lang": target_lang} + + translated_text = text + + # First, check existing translations in DB + trans = db.query(Translation).filter( + Translation.source_text == text + ).first() + + if trans: + lang_field = f"text_{target_lang}" + translated = getattr(trans, lang_field, None) + if translated and translated != text: + translated_text = translated + else: + # Check default dictionary + for category in DEFAULT_TRANSLATIONS: + if text in DEFAULT_TRANSLATIONS[category]: + if target_lang in DEFAULT_TRANSLATIONS[category][text]: + translated_text = DEFAULT_TRANSLATIONS[category][text][target_lang] + break + + # If still not found, try AI translation + if translated_text == text: + translated_text = translate_text_sync(text, request.source_lang, target_lang) + + return { + "source_text": text, + "translated_text": translated_text, + "source_lang": request.source_lang, + "target_lang": target_lang + } + + +@router.get("/stats") +def get_translation_stats(db: Session = Depends(get_db)): + """Get translation statistics""" + total = db.query(Translation).count() + + # Count by category + from sqlalchemy import func + by_category = db.query( + Translation.category, + func.count(Translation.id) + ).group_by(Translation.category).all() + + # Count missing translations (null or same as source) + missing_en = db.query(Translation).filter( + (Translation.text_en.is_(None)) | + (Translation.text_en == Translation.source_text) | + (Translation.text_en == "") + ).count() + + missing_mn = db.query(Translation).filter( + (Translation.text_mn.is_(None)) | + (Translation.text_mn == Translation.source_text) | + (Translation.text_mn == "") + ).count() + + missing_ru = db.query(Translation).filter( + (Translation.text_ru.is_(None)) | + (Translation.text_ru == Translation.source_text) | + (Translation.text_ru == "") + ).count() + + translated_en = total - missing_en + translated_mn = total - missing_mn + translated_ru = total - missing_ru + + return { + "total_entries": total, + "by_category": {cat: count for cat, count in by_category}, + "translation_coverage": { + "english": { + "translated": translated_en, + "total": total, + "percentage": round(translated_en / total * 100, 1) if total > 0 else 0 + }, + "mongolian": { + "translated": translated_mn, + "total": total, + "percentage": round(translated_mn / total * 100, 1) if total > 0 else 0 + }, + "russian": { + "translated": translated_ru, + "total": total, + "percentage": round(translated_ru / total * 100, 1) if total > 0 else 0 + } + } + } diff --git a/backend/app/api/vehicle_requests.py b/backend/app/api/vehicle_requests.py new file mode 100644 index 0000000..e79cf05 --- /dev/null +++ b/backend/app/api/vehicle_requests.py @@ -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 diff --git a/backend/app/api/vehicle_share.py b/backend/app/api/vehicle_share.py new file mode 100644 index 0000000..63e986e --- /dev/null +++ b/backend/app/api/vehicle_share.py @@ -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} diff --git a/backend/app/api/verification.py b/backend/app/api/verification.py new file mode 100644 index 0000000..9eaf9df --- /dev/null +++ b/backend/app/api/verification.py @@ -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) diff --git a/backend/app/api/visitor.py b/backend/app/api/visitor.py new file mode 100644 index 0000000..0036669 --- /dev/null +++ b/backend/app/api/visitor.py @@ -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} diff --git a/backend/app/api/withdrawal.py b/backend/app/api/withdrawal.py new file mode 100644 index 0000000..075442b --- /dev/null +++ b/backend/app/api/withdrawal.py @@ -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 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..aa837be --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/data/carmodoo_makers_models.json b/backend/app/data/carmodoo_makers_models.json new file mode 100644 index 0000000..4aed7b5 --- /dev/null +++ b/backend/app/data/carmodoo_makers_models.json @@ -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": "피트"} + ] + } +} diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..d2f3b1e --- /dev/null +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..5f20e79 --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e51b5d7 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/cache.py b/backend/app/models/cache.py new file mode 100644 index 0000000..9cf1125 --- /dev/null +++ b/backend/app/models/cache.py @@ -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'), + ) diff --git a/backend/app/models/car.py b/backend/app/models/car.py new file mode 100644 index 0000000..f583b8d --- /dev/null +++ b/backend/app/models/car.py @@ -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") diff --git a/backend/app/models/car_specification.py b/backend/app/models/car_specification.py new file mode 100644 index 0000000..5c9ea7b --- /dev/null +++ b/backend/app/models/car_specification.py @@ -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") diff --git a/backend/app/models/cc_package.py b/backend/app/models/cc_package.py new file mode 100644 index 0000000..6e1706e --- /dev/null +++ b/backend/app/models/cc_package.py @@ -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, + }, +] diff --git a/backend/app/models/dealer.py b/backend/app/models/dealer.py new file mode 100644 index 0000000..d199d65 --- /dev/null +++ b/backend/app/models/dealer.py @@ -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") diff --git a/backend/app/models/exchange_rate.py b/backend/app/models/exchange_rate.py new file mode 100644 index 0000000..e139a6d --- /dev/null +++ b/backend/app/models/exchange_rate.py @@ -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()) diff --git a/backend/app/models/hero_banner.py b/backend/app/models/hero_banner.py new file mode 100644 index 0000000..d1a9a7c --- /dev/null +++ b/backend/app/models/hero_banner.py @@ -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]) diff --git a/backend/app/models/inquiry.py b/backend/app/models/inquiry.py new file mode 100644 index 0000000..930c3b5 --- /dev/null +++ b/backend/app/models/inquiry.py @@ -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") diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 0000000..e5f37ca --- /dev/null +++ b/backend/app/models/notification.py @@ -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") diff --git a/backend/app/models/performance_check.py b/backend/app/models/performance_check.py new file mode 100644 index 0000000..10e570c --- /dev/null +++ b/backend/app/models/performance_check.py @@ -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") diff --git a/backend/app/models/push_subscription.py b/backend/app/models/push_subscription.py new file mode 100644 index 0000000..23d0eef --- /dev/null +++ b/backend/app/models/push_subscription.py @@ -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") diff --git a/backend/app/models/referral.py b/backend/app/models/referral.py new file mode 100644 index 0000000..147ce9e --- /dev/null +++ b/backend/app/models/referral.py @@ -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") diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py new file mode 100644 index 0000000..f39681d --- /dev/null +++ b/backend/app/models/settings.py @@ -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()) diff --git a/backend/app/models/translation.py b/backend/app/models/translation.py new file mode 100644 index 0000000..1fffb10 --- /dev/null +++ b/backend/app/models/translation.py @@ -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), + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..304c7c6 --- /dev/null +++ b/backend/app/models/user.py @@ -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"] diff --git a/backend/app/models/vehicle_request.py b/backend/app/models/vehicle_request.py new file mode 100644 index 0000000..ef96289 --- /dev/null +++ b/backend/app/models/vehicle_request.py @@ -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") diff --git a/backend/app/models/vehicle_share.py b/backend/app/models/vehicle_share.py new file mode 100644 index 0000000..80ee644 --- /dev/null +++ b/backend/app/models/vehicle_share.py @@ -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") diff --git a/backend/app/models/visitor.py b/backend/app/models/visitor.py new file mode 100644 index 0000000..8b296fb --- /dev/null +++ b/backend/app/models/visitor.py @@ -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()) diff --git a/backend/app/models/withdrawal.py b/backend/app/models/withdrawal.py new file mode 100644 index 0000000..0ae5e81 --- /dev/null +++ b/backend/app/models/withdrawal.py @@ -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") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..5db77c3 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -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", +] diff --git a/backend/app/schemas/car.py b/backend/app/schemas/car.py new file mode 100644 index 0000000..9aa8d22 --- /dev/null +++ b/backend/app/schemas/car.py @@ -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] diff --git a/backend/app/schemas/dealer.py b/backend/app/schemas/dealer.py new file mode 100644 index 0000000..47704f6 --- /dev/null +++ b/backend/app/schemas/dealer.py @@ -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 diff --git a/backend/app/schemas/hero_banner.py b/backend/app/schemas/hero_banner.py new file mode 100644 index 0000000..7ce73a1 --- /dev/null +++ b/backend/app/schemas/hero_banner.py @@ -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 diff --git a/backend/app/schemas/inquiry.py b/backend/app/schemas/inquiry.py new file mode 100644 index 0000000..63d1fe4 --- /dev/null +++ b/backend/app/schemas/inquiry.py @@ -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 diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 0000000..302fd74 --- /dev/null +++ b/backend/app/schemas/notification.py @@ -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] diff --git a/backend/app/schemas/referral.py b/backend/app/schemas/referral.py new file mode 100644 index 0000000..8b85863 --- /dev/null +++ b/backend/app/schemas/referral.py @@ -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 diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py new file mode 100644 index 0000000..d08a75b --- /dev/null +++ b/backend/app/schemas/settings.py @@ -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 diff --git a/backend/app/schemas/translation.py b/backend/app/schemas/translation.py new file mode 100644 index 0000000..1a4373d --- /dev/null +++ b/backend/app/schemas/translation.py @@ -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} diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..6b1b614 --- /dev/null +++ b/backend/app/schemas/user.py @@ -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 diff --git a/backend/app/schemas/vehicle_request.py b/backend/app/schemas/vehicle_request.py new file mode 100644 index 0000000..c413f30 --- /dev/null +++ b/backend/app/schemas/vehicle_request.py @@ -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 diff --git a/backend/app/schemas/vehicle_share.py b/backend/app/schemas/vehicle_share.py new file mode 100644 index 0000000..5bf4aa4 --- /dev/null +++ b/backend/app/schemas/vehicle_share.py @@ -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 diff --git a/backend/app/schemas/withdrawal.py b/backend/app/schemas/withdrawal.py new file mode 100644 index 0000000..fbae69c --- /dev/null +++ b/backend/app/schemas/withdrawal.py @@ -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 diff --git a/backend/app/services/cache_service.py b/backend/app/services/cache_service.py new file mode 100644 index 0000000..d1fd289 --- /dev/null +++ b/backend/app/services/cache_service.py @@ -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) diff --git a/backend/app/services/exchange_rate_service.py b/backend/app/services/exchange_rate_service.py new file mode 100644 index 0000000..71da566 --- /dev/null +++ b/backend/app/services/exchange_rate_service.py @@ -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 diff --git a/backend/app/services/pdf_service.py b/backend/app/services/pdf_service.py new file mode 100644 index 0000000..006506c --- /dev/null +++ b/backend/app/services/pdf_service.py @@ -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 diff --git a/backend/app/services/sensitive_filter.py b/backend/app/services/sensitive_filter.py new file mode 100644 index 0000000..6635dc0 --- /dev/null +++ b/backend/app/services/sensitive_filter.py @@ -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 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'{matched}' + + 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 diff --git a/backend/app/services/spec_service.py b/backend/app/services/spec_service.py new file mode 100644 index 0000000..8d64984 --- /dev/null +++ b/backend/app/services/spec_service.py @@ -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'