diff --git a/.env.example b/.env.example index 5d438da..378cd03 100644 --- a/.env.example +++ b/.env.example @@ -17,11 +17,9 @@ REDIS_USER= REDIS_PASS= REDIS_DB=0 -# 게스트 계정 활성화 여부 및 계정 정보 +# 게스트 계정 활성화 여부 +# 계정 정보는 app/demo.py 파일 참고 GUEST_LOGIN_ENABLE=true -GUEST_USERNAME= -GUEST_EMAIL=guest@example.com -GUEST_PASSWORD=guest # production 모드에서 로그인 쿠키 옵션 https only에 사용됨 # IS_HTTPS 우선, 없으면 PROTO이 https인 경우 https only가 적용됨 diff --git a/.gitignore b/.gitignore index 8bccb82..34ab125 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ !.env*.example* data/** !data/url.txt +.initialized # virtual environments venv diff --git a/Dockerfile b/Dockerfile index 9209e38..9a52075 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,4 +41,5 @@ COPY data/url.txt ./data/url.txt COPY app ./app COPY scripts ./scripts RUN sha256sum "/defaults/requirements.txt" | sed "s|/defaults|/app|" > "$VIRTUAL_ENV/.requirements.lock" +VOLUME /app/data WORKDIR /app diff --git a/app/core/validate.py b/app/core/validate.py index d0d3888..b2a2e55 100644 --- a/app/core/validate.py +++ b/app/core/validate.py @@ -2,8 +2,10 @@ from fastapi import status from ..crud.service import get_region_by_id, get_regions, get_high_school_map +from ..schemas.service import RecommendationCreateRequest from ..core.enums import ( AppErrorCodeEnum, + InfrastructureTypeEnum, ) from ..core.exception import AppException @@ -57,3 +59,24 @@ def verify_high_schools(redis: Redis, high_school_ids: list[int]) -> list[dict]: }, ) return schools + + +def verify_recommendation_request_data( + redis: Redis, + body: RecommendationCreateRequest, +): + region = verify_region(redis, body.region_id) if body.region_id else {} + + return { + "region_id": region.get("id"), + "region_name": region.get("name"), + "infrastructure_types": body.infrastructure_types, + # 학군 유형은 인프라 유형 내 초/중/고등학교 중 1개 이상 포함된 경우에만 사용 + "school_district_types": body.school_district_types if any([x in (InfrastructureTypeEnum.ELEMENTARY_SCHOOL, InfrastructureTypeEnum.MIDDLE_SCHOOL, InfrastructureTypeEnum.HIGH_SCHOOL) for x in body.infrastructure_types]) else [], + # 고등학교 목록은 인프라 유형 내 고등학교가 포함된 경우에만 사용 + "high_school_ids": [s["id"] for s in verify_high_schools(redis, body.high_school_ids)] if body.high_school_ids and InfrastructureTypeEnum.HIGH_SCHOOL in body.infrastructure_types else [], + "sale_price_min": body.sale_price.min if body.sale_price else None, + "sale_price_max": body.sale_price.max if body.sale_price else None, + "jeonse_price_min": body.jeonse_price.min if body.jeonse_price else None, + "jeonse_price_max": body.jeonse_price.max if body.jeonse_price else None, + } diff --git a/app/crud/service.py b/app/crud/service.py index 557297e..c599b7e 100644 --- a/app/crud/service.py +++ b/app/crud/service.py @@ -146,7 +146,7 @@ def get_high_school_map(redis: Redis, sort: bool = True): def create_recommendation( db: Session, task_id: str, - region: str, + region_name: str, infrastructure_types: list[str] | None, school_district_types: list[SchoolDistrictTypeEnum] | None, high_school_ids: list[int] | None, @@ -165,7 +165,7 @@ def create_recommendation( rec = Recommendation( task_id=task_id, - region=region, + region=region_name, school_district_types=school_district_types, high_school_ids=high_school_ids, sale_price_min=sale_price_min, @@ -207,4 +207,6 @@ def get_search_log_by_user_id(db: Session, user_id: int) -> list[SearchLog]: SearchLog.recommendation_id == Recommendation.id, ).filter( SearchLog.user_id == user_id, + ).order_by( + SearchLog.requested_at.desc() ).all() diff --git a/app/demo.py b/app/demo.py index 73342bd..abee125 100644 --- a/app/demo.py +++ b/app/demo.py @@ -1,26 +1,22 @@ """데모용 함수 모음""" -import os -from dotenv import load_dotenv from fastapi import APIRouter, Depends, Request, status from fastapi.exceptions import HTTPException from sqlalchemy.orm import Session from .config import GUEST_LOGIN_ENABLE from .database import get_db +from .models import User from .core import session -from .core.exception import AppException -from .crud.user import create_user +from .crud.user import get_user_by_cuid from .schemas.error import NotFoundError -from .schemas.auth import UserCreateRequest from .schemas.user import UserInfo -load_dotenv() - -GUEST_EMAIL = os.getenv("GUEST_EMAIL") or "guest@example.com" -GUEST_USERNAME = os.getenv("GUEST_USERNAME") or None -GUEST_PASSWORD = os.getenv("GUEST_PASSWORD") or "guest" +GUEST_CUID = "guest" +GUEST_EMAIL = "guest@example.com" +GUEST_USERNAME = "guest" +GUEST_PASSWORD = "guest" # fns @@ -35,11 +31,16 @@ def create_guest_user(db: Session): status_code=status.HTTP_404_NOT_FOUND, ) - return create_user(db, UserCreateRequest( - name=GUEST_USERNAME, - email=GUEST_EMAIL, - password=GUEST_PASSWORD, - )) + created = False + guest = get_user_by_cuid(db, GUEST_CUID) or User() + guest.cuid = GUEST_CUID + guest.name = GUEST_USERNAME + guest.email = GUEST_EMAIL + guest.password = GUEST_PASSWORD + db.add(guest) + db.commit() + db.refresh(guest) + return guest, created def get_guest_user(db: Session): """ diff --git a/app/manage/seeds/insert.py b/app/manage/seeds/insert.py index 4817ec0..f63c5d9 100644 --- a/app/manage/seeds/insert.py +++ b/app/manage/seeds/insert.py @@ -8,6 +8,7 @@ from ...demo import create_guest_user from ...crud.service import get_regions, get_high_schools from ...utils import run_with_progress +from ...schemas.service import RecommendationCreateRequest from ...models import ( User, Recommendation, @@ -65,36 +66,46 @@ def generate_seed_recommendations( request_users = random.sample(users, k=max_request_users) task_id = f"{SEED_TASK_ID_PREFIX}{suffix}" + has_region = random.choice([True] + [False] * 9) has_sale_price = random.choice([True, False]) has_jeonse_price = random.choice([True, False]) - selected_region = random.choice(regions) + selected_region = random.choice(regions) if has_region else None selected_infra_types = random.sample(infra_types, k=random.randint(1, len(infra_types))) selected_sale_price = random_range(0, 999999999999) if has_sale_price else (None, None) selected_jeonse_price = random_range(0, 999999999999) if has_jeonse_price else (None, None) selected_school_district_types = random.sample(school_district_types, k=random.randint(0, len(school_district_types))) - selected_high_school_ids = random.sample(high_school_ids, k=random.randint(0, min(10, len(high_school_ids)))) + selected_high_school_ids = random.sample(high_school_ids, k=random.randint(0, min(5, len(high_school_ids)))) random.shuffle(selected_infra_types) rec = None created_at, finished_at = map(datetime.fromtimestamp, random_range(start_ts, end_ts)) - updated_at = finished_at + timedelta(minutes=random.randint(100, 1000)) if random.choice([True, [False] * 4]) else None + updated_at = finished_at + timedelta(minutes=random.randint(100, 1000)) if random.choice([True] + [False] * 4) else finished_at for user in request_users: + request_data = RecommendationCreateRequest( + name=task_id, + region_id=selected_region["id"] if selected_region else None, + infrastructure_types=selected_infra_types, + high_school_ids=selected_high_school_ids, + school_district_types=selected_school_district_types, + sale_price={ + "min": selected_sale_price[0], + "max": selected_sale_price[1], + }, + jeonse_price={ + "min": selected_jeonse_price[0], + "max": selected_jeonse_price[1], + }, + ) rec = generate_recommendation( db, + redis, background_tasks=None, task_id=task_id, request_user=user, rec_name=None if random.choice([True, False]) else f"추천 {'x' * random.randint(1, 10)}", - region=selected_region, - infrastructure_types=selected_infra_types, - school_district_types=selected_school_district_types, - high_school_ids=selected_high_school_ids, - sale_price_min=selected_sale_price[0], - sale_price_max=selected_sale_price[1], - jeonse_price_min=selected_jeonse_price[0], - jeonse_price_max=selected_jeonse_price[1], + request_data=request_data, ) is_last_viewed = random.choice([True, False]) last_viewed_at = created_at + timedelta(minutes=random.randint(1, 1000)) if is_last_viewed else None diff --git a/app/models/property_infrastructure.py b/app/models/property_infrastructure.py index b170ce3..49ac073 100644 --- a/app/models/property_infrastructure.py +++ b/app/models/property_infrastructure.py @@ -1,6 +1,6 @@ import builtins from typing import TYPE_CHECKING -from sqlalchemy import Column, ForeignKey, Enum, DateTime, String +from sqlalchemy import Column, ForeignKey, Enum, DateTime, String, Index from sqlalchemy.dialects.mysql import INTEGER, DECIMAL from sqlalchemy.orm import Mapped, relationship from sqlalchemy.sql import func @@ -40,6 +40,11 @@ class PropertyInfrastructure(Base): infrastructure: Mapped["Infrastructure"] = relationship("Infrastructure", back_populates="property_scores") property: Mapped["Property"] = relationship("Property", back_populates="infrastructure_scores") + __table_args__ = ( + # window function PARTITION BY property_id, infrastructure_type ORDER BY score DESC 최적화 + Index("idx_pi_property_type_score", "property_id", "infrastructure_type", "score"), + ) + @builtins.property def walking_duration(self): """distance를 도보 시간(분)으로 환산한 값 (도보 속도 60m/min 기준)""" diff --git a/app/routers/recommendations.py b/app/routers/recommendations.py index c3c877b..071fd8a 100644 --- a/app/routers/recommendations.py +++ b/app/routers/recommendations.py @@ -56,31 +56,14 @@ def request_generate_recommendation( 응답에 포함된 `task_id`로 추천 결과를 조회할 수 있음 """ - region = verify_region(redis, body.region_id) if body.region_id else None - schools = verify_high_schools(redis, body.high_school_ids) if body.high_school_ids else [] - rec_name = (body.name if body.name else "").strip() or None - infrastructure_types = body.infrastructure_types - school_district_types = body.school_district_types or [] - high_school_ids = [x["id"] for x in schools] - sale_price_min = body.sale_price.min if body.sale_price else None - sale_price_max = body.sale_price.max if body.sale_price else None - jeonse_price_min = body.jeonse_price.min if body.jeonse_price else None - jeonse_price_max = body.jeonse_price.max if body.jeonse_price else None - rec = generate_recommendation( db, + redis, background_tasks, request_user=user, rec_name=rec_name, - region=region, - infrastructure_types=infrastructure_types, - school_district_types=school_district_types, - high_school_ids=high_school_ids, - sale_price_min=sale_price_min, - sale_price_max=sale_price_max, - jeonse_price_min=jeonse_price_min, - jeonse_price_max=jeonse_price_max, + request_data=body, ) return { @@ -138,6 +121,7 @@ def get_recommendation_summary( "max": recommendation.jeonse_price_max, }, } + request_infra_types: set[str] = set([x.type for x in request_data["infrastructure_types"]]) db_properties = db.query(Property).filter(Property.id.in_([p["id"] for p in top_properties])).all() property_map = {prop.id: prop for prop in db_properties} @@ -164,7 +148,7 @@ def get_recommendation_summary( { **InfrastructureTypeEnum[infra["type"]].meta._asdict(), **infra, - } for infra in p["infrastructure_scores"][:2] + } for infra in p["infrastructure_scores"] if infra["type"] in request_infra_types ], } for p in top_properties ] diff --git a/app/schemas/service.py b/app/schemas/service.py index 6f73b06..2f31583 100644 --- a/app/schemas/service.py +++ b/app/schemas/service.py @@ -136,7 +136,7 @@ class RecommendationReportItemSummary(BaseModel): address: AddressDetails = Field(description="매물 주소 정보") sale_price: PriceRange = Field(description="매물의 매매 가격 범위") jeonse_price: PriceRange = Field(description="매물의 전세 가격 범위") - infrastructure: list[RecommendationReportItemInfrastructureSummary] = Field(description="매물 주변 인프라 요약 정보 (최대 2개)", max_length=2) + infrastructure: list[RecommendationReportItemInfrastructureSummary] = Field(description="매물 주변 인프라 요약 정보 (요청 시 선택한 인프라 유형만 포함, 거리순)") class RecommendationReport(RecommendationCreateResponse): """추천 결과""" @@ -165,4 +165,4 @@ class UserRecommendations(BaseModel): """추천 요청 목록""" total: int = Field(description="추천 요청 수") - items: list[UserRecommendationsItem] = Field(description="추천 요청 목록") + items: list[UserRecommendationsItem] = Field(description="추천 요청 목록, 최신순") diff --git a/app/services/recommendation.py b/app/services/recommendation.py index d6f0f09..5701677 100644 --- a/app/services/recommendation.py +++ b/app/services/recommendation.py @@ -7,14 +7,17 @@ from fastapi import BackgroundTasks from sqlalchemy import func, or_, select from sqlalchemy.orm import Session +from redis import Redis from ..database import SessionLocal from ..models import User, Recommendation, Property, Infrastructure, PropertyInfrastructure, Region from ..core.enums import SchoolDistrictTypeEnum, InfrastructureTypeEnum +from ..core.validate import verify_recommendation_request_data +from ..schemas.service import RecommendationCreateRequest from ..crud.service import get_recommendation_by_task_id, create_recommendation -def generate_recommendation_task_id( +def _generate_recommendation_task_id( region_id: int | None, infrastructure_types: list[InfrastructureTypeEnum], high_school_ids: list[int] | None, @@ -46,7 +49,6 @@ def _serialize_val(val): "jeonse_price_min": jeonse_price_min, "jeonse_price_max": jeonse_price_max, } - values = {name: _serialize_val(val) for name, val in params.items() if val is not None} # 입력된 순서를 유지하면서 JSON 직렬화 후 SHA256 hash 생성 @@ -179,7 +181,8 @@ def _main( # Step 4: 인프라 유형별 가중치 적용 (지수 기반: 1위=100, 2위=50, 3위=25, ...) # 선택하지 않은 인프라는 N+1번째 가중치(= 100 * 0.5^N)로 동일하게 적용 - n = len(infra_types) + # 여기서 N은 사용자가 선택한 인프라 유형 개수가 아닌 총 인프라 유형 개수 + n = len(InfrastructureTypeEnum) weight_map: dict[str, float] = { infra_type: 100.0 * (0.5 ** i) for i, infra_type in enumerate(infra_types) @@ -309,65 +312,39 @@ def _bg_run( def generate_recommendation( db: Session, + redis: Redis, background_tasks: BackgroundTasks | None, *, request_user: User, rec_name: str | None = None, - region: dict | None = None, - infrastructure_types: list[InfrastructureTypeEnum] = [], - school_district_types: list[SchoolDistrictTypeEnum] = [], - high_school_ids: list[int] = [], - sale_price_min: float | None = None, - sale_price_max: float | None = None, - jeonse_price_min: float | None = None, - jeonse_price_max: float | None = None, task_id: str | None = None, + request_data: RecommendationCreateRequest, ): """ 추천 생성 로직 - 처리 중 에러 발생 시 저장된 값을 쉽게 무효화(rollback)하기 위해 except, else를 통해서만 commit을 진행함 """ - region_id = region["id"] if region else None - region_name = region["name"] if region else None - - request_data = { - "region": region_name, - "region_id": region_id, - "infrastructure_types": infrastructure_types, - "school_district_types": school_district_types, - "high_school_ids": high_school_ids, - "sale_price_min": sale_price_min, - "sale_price_max": sale_price_max, - "jeonse_price_min": jeonse_price_min, - "jeonse_price_max": jeonse_price_max, - } + data = verify_recommendation_request_data(redis, request_data) # 입력받은 조건들을 기반으로 task_id 생성 - task_id = task_id or generate_recommendation_task_id( - **request_data, + task_id = task_id or _generate_recommendation_task_id( + **data, ) - failed = False recommendation = get_recommendation_by_task_id(db, task_id) - if recommendation: - if recommendation.failed_at: - # 이전에 실패했다면 다시 로직 실행 - recommendation.failed_at = None - failed = True - if not recommendation: recommendation = create_recommendation( db, task_id=task_id, - **request_data, + **data, ) recommendation.add_user(request_user, rec_name) - in_progress = recommendation.in_progress - recommendation.in_progress = True - db.commit() - if in_progress is not True and (failed or recommendation.finished_at is None): + if not recommendation.in_progress and (recommendation.failed_at or recommendation.finished_at is None): + recommendation.in_progress = True + recommendation.failed_at = None + db.commit() if background_tasks: # 백그라운드에서 비동기로 추천 생성 실행 background_tasks.add_task( diff --git a/root/etc/s6-overlay/s6-rc.d/file-delivery/run b/root/etc/s6-overlay/s6-rc.d/file-delivery/run index 26f5897..a21f3f2 100755 --- a/root/etc/s6-overlay/s6-rc.d/file-delivery/run +++ b/root/etc/s6-overlay/s6-rc.d/file-delivery/run @@ -2,8 +2,9 @@ cd /app -if [ -f "requirements.txt" ]; then +if [ -f ".initialized" ]; then exit 0 fi -mv /defaults/* . +cp -a /defaults/. . +touch .initialized diff --git a/tests/test_recommendations.py b/tests/test_recommendations.py index 67797e0..bf2c6aa 100644 --- a/tests/test_recommendations.py +++ b/tests/test_recommendations.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch from app.models import User, Recommendation, SearchLog, Property, Infrastructure from app.core.enums import InfrastructureTypeEnum, SchoolDistrictTypeEnum -from app.services.recommendation import generate_recommendation_task_id +from app.services.recommendation import _generate_recommendation_task_id from datetime import datetime @@ -24,7 +24,7 @@ def make_mock_user(id=1, email="test@example.com", name="Test User", cuid="cuid1 @patch("app.dependencies.get_user_by_cuid") @patch("app.routers.auth.get_user_by_email") @patch("app.core.validate.get_region_by_id") -@patch("app.routers.recommendations.verify_high_schools") +@patch("app.core.validate.verify_high_schools") def test_request_generate_recommendation_success( mock_verify_high_schools, mock_get_region_by_id, mock_get_user_by_email, mock_get_user_by_cuid, client, mock_db ) -> None: @@ -38,7 +38,7 @@ def test_request_generate_recommendation_success( mock_get_region_by_id.return_value = {"id": 1, "name": "서울특별시 용산구 도원동"} mock_verify_high_schools.return_value = [{"id": 1, "name": "고등학교1"}, {"id": 2, "name": "고등학교2"}] - mock_db.query().filter().first.return_value = None + mock_db.query.return_value.filter.return_value.first.return_value = None # 2. 로그인 client.post("/auth/login", json={"email": "test@example.com", "password": "password123"}) @@ -49,7 +49,7 @@ def test_request_generate_recommendation_success( json={ "name": "내 맞춤 추천", "region_id": 1, - "infrastructure_types": ["SUBWAY_STATION", "PARK"], + "infrastructure_types": ["SUBWAY_STATION", "PARK", "HIGH_SCHOOL"], "high_school_ids": [1, 2], "school_district_types": ["INTENSIVE", "BALANCED"], "sale_price": {"min": 0, "max": 1000000000}, @@ -60,9 +60,9 @@ def test_request_generate_recommendation_success( assert response.status_code == 202 data = response.json() - expected_task_id = generate_recommendation_task_id( + expected_task_id = _generate_recommendation_task_id( region_id=1, - infrastructure_types=[InfrastructureTypeEnum.SUBWAY_STATION, InfrastructureTypeEnum.PARK], + infrastructure_types=[InfrastructureTypeEnum.SUBWAY_STATION, InfrastructureTypeEnum.PARK, InfrastructureTypeEnum.HIGH_SCHOOL], high_school_ids=[1, 2], school_district_types=[SchoolDistrictTypeEnum.INTENSIVE, SchoolDistrictTypeEnum.BALANCED], sale_price_min=0, @@ -138,7 +138,7 @@ def test_request_generate_recommendation_invalid_infra( @patch("app.dependencies.get_user_by_cuid") @patch("app.routers.auth.get_user_by_email") @patch("app.core.validate.get_region_by_id") -@patch("app.routers.recommendations.verify_high_schools") +@patch("app.core.validate.verify_high_schools") def test_request_generate_recommendation_invalid_high_schools( mock_verify_high_schools, mock_get_region_by_id, mock_get_user_by_email, mock_get_user_by_cuid, client ) -> None: @@ -167,7 +167,7 @@ def test_request_generate_recommendation_invalid_high_schools( "/recommendations", json={ "region_id": 1, - "infrastructure_types": ["SUBWAY_STATION"], + "infrastructure_types": ["SUBWAY_STATION", "HIGH_SCHOOL"], "high_school_ids": [999] } ) diff --git a/tests/test_users.py b/tests/test_users.py index 22904b5..6912e70 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -173,7 +173,7 @@ def test_get_user_recommendations(mock_get_user_by_email, mock_get_user_by_cuid, mock_log2.requested_at = datetime.utcnow() mock_log2.last_viewed_at = datetime.utcnow() - mock_db.query().join().filter().all.return_value = [mock_log1, mock_log2] + mock_db.query().join().filter().order_by().all.return_value = [mock_log1, mock_log2] # Redis 모킹 설정 import json as py_json