diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..f6a4fe7 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,59 @@ +name: Deploy to EC2 + +on: + push: + branches: + - main # main브랜치에 push되었을 때 + pull_request: + branches: + - main # main브랜치에 pr 날렸을 때 + +jobs: + deploy: + name: Build & Deploy + runs-on: ubuntu-latest + + steps: + # 1) 레포 체크아웃 + - name: Checkout + uses: actions/checkout@v4 + + # Action Secret에 설정한 ENV_VARS 값을 env 폴더안의 .env.production으로 만듦 + - name: create env file + run: | + mkdir -p env + echo "${{ secrets.ENV_VARS }}" > env/.env.production + + # EC2에 접속해 원격디렉토리 생성 + - name: create remote directory + uses: appleboy/ssh-action@v0.1.4 + with: + host: ${{ secrets.HOST }} + username: ubuntu + key: ${{ secrets.KEY }} + script: mkdir -p /home/ubuntu/srv/ubuntu + + # SSH 키를 통해 소스 코드를 EC2 인스턴스(서버)로 복사 + - name: copy source via ssh key + uses: burnett01/rsync-deployments@4.1 + with: + switches: -avzr --delete + remote_path: /home/ubuntu/srv/ubuntu/ + remote_host: ${{ secrets.HOST }} + remote_user: ubuntu + remote_key: ${{ secrets.KEY }} + + # 서버에 접속한 뒤 deploy.sh(배포 스크립트)를 실행 + - name: executing remote ssh commands using password + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.HOST }} + username: ubuntu + key: ${{ secrets.KEY }} + script: | + set -e + # 권한/개행 정리 + sed -i 's/\r$//' /home/ubuntu/srv/ubuntu/configs/scripts/deploy.sh + chmod +x /home/ubuntu/srv/ubuntu/configs/scripts/deploy.sh + # 실행 (실행비트가 없어도 확실히 하려면 bash로 읽어 실행) + bash /home/ubuntu/srv/ubuntu/configs/scripts/deploy.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e98fd3c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +# Debian slim 기반: 과학 스택(NumPy/Scipy/Sklearn/kiwi/fasttext) 빌드 호환성↑ +FROM python:3.12-slim + +# 파이썬 런타임 편의/일관성 +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + TZ=Asia/Seoul + +# 작업 디렉토리 +WORKDIR /app + +# 시스템 의존성 +# - libpq-dev: (psycopg2-binary는 없어도 되지만 있어도 무방) +# - libjpeg-dev, zlib1g-dev: Pillow +# - libgomp1, libstdc++6: fasttext, sklearn/numba 등 OpenMP/CPP 런타임 +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + gcc g++ \ + libpq-dev \ + libjpeg-dev zlib1g-dev \ + cmake ninja-build \ + libgomp1 libstdc++6 \ + tzdata \ + && rm -rf /var/lib/apt/lists/* + +# pip / setuptools / wheel 버전 고정(네 요구사항에 맞춤) +RUN python -m pip install --upgrade "pip==25.2" "setuptools==80.9.0" wheel + +# 의존성 설치: 대규모 빌드 가속 위해 numpy 먼저 +COPY requirements.txt /app/requirements.txt +RUN pip install "numpy==1.26.4" +RUN pip install --no-build-isolation -r requirements.txt + +# 비루트 유저 +RUN useradd -m appuser && chown -R appuser:appuser /app +USER appuser + +# 앱 소스 복사 +COPY . /app/ + +# (옵션) 기본 포트 노출 — gunicorn 8000 사용 시 +EXPOSE 8000 + +# 실행 명령은 docker-compose나 ECS에서 지정 +# 예) gunicorn configs.wsgi:application --bind 0.0.0.0:8000 diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 0000000..ecbde44 --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,82 @@ +# ====================== +# BUILDER +# ====================== +FROM python:3.12-slim AS builder + +WORKDIR /usr/src/app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + DEBIAN_FRONTEND=noninteractive + + +# psycopg2, Pillow, 과학 스택 빌드에 필요한 헤더/툴 +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential gcc g++ \ + libpq-dev \ + libjpeg-dev zlib1g-dev \ + tzdata \ + && rm -rf /var/lib/apt/lists/* + +# 최신 pip +RUN python -m pip install --upgrade "pip==25.2" "setuptools==80.9.0" wheel + +# 의존성 wheel 미리 빌드 +COPY ./requirements.txt . +RUN pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels "numpy==1.26.4" +RUN pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels -r requirements.txt + + +# ====================== +# FINAL +# ====================== +FROM python:3.12-slim + + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + DEBIAN_FRONTEND=noninteractive \ + TZ=Asia/Seoul \ + HOME=/home/app \ + APP_HOME=/home/app/web + +# 런타임 의존성만 최소 설치 +# - libgomp1 / libstdc++6: scikit-learn/fasttext OpenMP/CPP 런타임 +# - libjpeg/zlib: Pillow +# - libpq5: psycopg2-binary 실행 시 필요할 수 있음(안전 차원) +# - ca-certificates: 외부 요청(https) 안정화 +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgomp1 libstdc++6 \ + libjpeg62-turbo zlib1g \ + libpq5 \ + tzdata ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# 앱 사용자/디렉터리 +RUN mkdir -p /home/app && \ + addgroup --system app && adduser --system --ingroup app app && \ + mkdir -p $APP_HOME/static $APP_HOME/media +WORKDIR $APP_HOME + +# wheel 설치 +COPY --from=builder /usr/src/app/wheels /wheels +COPY --from=builder /usr/src/app/requirements.txt . +RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt + +# 소스 코드 전체를 이미지에 복사 +COPY . $APP_HOME + +# 엔트리포인트 복사 + 윈도 개행 제거 + 실행권한 +COPY ./configs/docker/entrypoint.prod.sh /home/app/web/entrypoint.prod.sh +RUN sed -i 's/\r$//' /home/app/web/entrypoint.prod.sh \ + && chmod +x /home/app/web/entrypoint.prod.sh + + +# 권한 +RUN chown -R app:app $APP_HOME +USER app + +# 서비스 포트 (gunicorn 8000 가정) +EXPOSE 8000 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/configs/asgi.py b/configs/asgi.py index 787b5bd..f06ca70 100644 --- a/configs/asgi.py +++ b/configs/asgi.py @@ -4,4 +4,4 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'configs.settings') -application = get_asgi_application() +application = get_asgi_application() \ No newline at end of file diff --git a/configs/docker/entrypoint.prod.sh b/configs/docker/entrypoint.prod.sh new file mode 100644 index 0000000..b8e337a --- /dev/null +++ b/configs/docker/entrypoint.prod.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# python manage.py collectstatic --no-input echo "Apply database migrations" python manage.py migrate +python manage.py collectstatic --no-input +python manage.py makemigrations +python manage.py migrate +exec "$@" \ No newline at end of file diff --git a/configs/nginx/Dockerfile b/configs/nginx/Dockerfile new file mode 100644 index 0000000..ea187e2 --- /dev/null +++ b/configs/nginx/Dockerfile @@ -0,0 +1,5 @@ +FROM nginx:1.19.0-alpine + + +RUN rm /etc/nginx/conf.d/default.conf +COPY nginx.conf /etc/nginx/conf.d \ No newline at end of file diff --git a/configs/nginx/nginx.conf b/configs/nginx/nginx.conf new file mode 100644 index 0000000..d9c56e1 --- /dev/null +++ b/configs/nginx/nginx.conf @@ -0,0 +1,29 @@ +# 프로젝트명이라는 업스트림(백엔드 서버 그룹) 정의 +upstream configs { + # web이라는 이름의 서비스에서 8000 포트로 요청을 전달하도록 설정 + server web:8000; +} + +# Nginx 서버 블록 정의 +server { + # Nginx가 80번 포트에서 HTTP 요청을 수신하도록 설정 + listen 80; + + location / { + # 클라이언트의 요청을 configs 업스트림으로 전달 + proxy_pass http://configs; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_redirect off; + } + + location /static/ { + alias /home/app/web/static/; + } + + location /media/ { + alias /home/app/web/media/; + } + # 클라이언트가 업로드할 수 있는 최대 요청 본문 크기 설정 가능 + # client_max_body_size 10M; +} diff --git a/configs/scripts/deploy.sh b/configs/scripts/deploy.sh new file mode 100644 index 0000000..065742d --- /dev/null +++ b/configs/scripts/deploy.sh @@ -0,0 +1,65 @@ +#!/bin/bash +set -euo pipefail + +# Docker 설치 여부 확인, 없다면 설치 +if ! type docker > /dev/null +then + echo "docker does not exist" + echo "Start installing docker" + sudo apt-get update + sudo apt install -y apt-transport-https ca-certificates curl software-properties-common + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - + sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu bionic stable" + sudo apt update + apt-cache policy docker-ce + sudo apt install -y docker-ce +fi + +# Docker Compose 설치 여부 확인, 없다면 설치 +if ! type docker-compose > /dev/null +then + echo "docker-compose does not exist" + echo "Start installing docker-compose" + sudo curl -L "https://github.com/docker/compose/releases/download/1.27.3/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose +fi + +# fastText ko 모델 호스트에 사전 설치 (inline) +############################################ +MODEL_DIR="/home/ubuntu/ft_models" +MODEL_BIN="${MODEL_DIR}/cc.ko.300.bin" +MODEL_GZ="${MODEL_BIN}.gz" +MODEL_URL="https://dl.fbaipublicfiles.com/fasttext/vectors-crawl/cc.ko.300.bin.gz" + +# 도구 준비 +if ! command -v wget >/dev/null 2>&1; then + sudo apt-get update -y + sudo apt-get install -y wget +fi +if ! command -v gunzip >/dev/null 2>&1; then + sudo apt-get update -y + sudo apt-get install -y gzip +fi + +# 디렉토리/권한 +sudo mkdir -p "${MODEL_DIR}" +sudo chown -R ubuntu:ubuntu "${MODEL_DIR}" + +# 없으면 다운로드+압축해제, 있으면 스킵 +if [ ! -f "${MODEL_BIN}" ]; then + echo "[INFO] fastText ko 모델 다운로드 시작..." + tmp="${MODEL_GZ}.part" + wget -O "${tmp}" "${MODEL_URL}" + mv "${tmp}" "${MODEL_GZ}" + gunzip -f "${MODEL_GZ}" + sudo chmod 644 "${MODEL_BIN}" + echo "[INFO] fastText 모델 준비 완료: ${MODEL_BIN}" +else + echo "[INFO] fastText 모델 이미 존재: ${MODEL_BIN}" +fi + +ls -lh "${MODEL_BIN}" || true + +# Docker Compose로 서버 빌드 및 실행 (docker-compose.prod.yml 사용) +echo "start docker-compose up: ubuntu" +sudo docker-compose -f /home/ubuntu/srv/ubuntu/docker-compose.prod.yml up --build -d \ No newline at end of file diff --git a/configs/settings/__init__.py b/configs/settings/__init__.py index a32ba3e..ac4592d 100644 --- a/configs/settings/__init__.py +++ b/configs/settings/__init__.py @@ -1,6 +1,2 @@ -from .base import DEBUG - -if DEBUG: - from .development import * -else: - from .production import * +# 패키지 초기화 단계에서 __init__.py가 from .base import DEBUG 를 실행하면서 .base를 무조건 임포트해버림. +# .env/환경변수 준비 전이라도 SECRET_KEY = env('SECRET_KEY')가 바로 실행되어 실패한다. diff --git a/configs/settings/base.py b/configs/settings/base.py index 6479ae1..733d922 100644 --- a/configs/settings/base.py +++ b/configs/settings/base.py @@ -7,17 +7,12 @@ # env - env = environ.Env(DEBUG=(bool, False)) -environ.Env.read_env(os.path.join(BASE_DIR, 'env', '.env.base')) - -SECRET_KEY = env('SECRET_KEY') - -DEBUG = env('DEBUG') -NCLOUD_CLIENT_ID = env('NCLOUD_CLIENT_ID') -NCLOUD_CLIENT_SECRET = env('NCLOUD_CLIENT_SECRET') +environ.Env.read_env(os.path.join(BASE_DIR, 'env', '.env.production')) +SECRET_KEY = env("SECRET_KEY") +DEBUG = env.bool("DEBUG", default=True) # Application definition @@ -111,13 +106,12 @@ # https://docs.djangoproject.com/en/5.2/howto/static-files/ STATIC_URL = 'static/' - +STATIC_ROOT = os.getenv("STATIC_ROOT", "/home/app/web/static") # Media files MEDIA_URL = '/media/' - -MEDIA_ROOT = os.path.join(BASE_DIR, 'media') +MEDIA_ROOT = os.getenv("MEDIA_ROOT", "/home/app/web/media") # Default primary key field type diff --git a/configs/settings/development.py b/configs/settings/development.py index 4e91254..dc5a703 100644 --- a/configs/settings/development.py +++ b/configs/settings/development.py @@ -1,12 +1,12 @@ from .base import * -import environ -environ.Env.read_env(os.path.join(BASE_DIR, 'env', '.env.development')) +DEBUG = True DATABASES = { 'default': env.db(), } + CACHES = { 'default': env.cache(), } @@ -14,3 +14,6 @@ ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[]) CORS_ALLOWED_ORIGINS = env.list('CORS_ALLOWED_ORIGINS', default=[]) + +NCLOUD_CLIENT_ID = env('NCLOUD_CLIENT_ID') +NCLOUD_CLIENT_SECRET = env('NCLOUD_CLIENT_SECRET') diff --git a/configs/settings/production.py b/configs/settings/production.py index 6bbbcd1..697c382 100644 --- a/configs/settings/production.py +++ b/configs/settings/production.py @@ -1,7 +1,6 @@ from .base import * -import environ -environ.Env.read_env(os.path.join(BASE_DIR, 'env', '.env.production')) +DEBUG = False DATABASES = { 'default': env.db(), @@ -14,3 +13,6 @@ ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[]) CORS_ORIGIN_ALLOW_ALL = True + +NCLOUD_CLIENT_ID = env('NCLOUD_CLIENT_ID') +NCLOUD_CLIENT_SECRET = env('NCLOUD_CLIENT_SECRET') diff --git a/configs/wsgi.py b/configs/wsgi.py index 9b9d195..a20d937 100644 --- a/configs/wsgi.py +++ b/configs/wsgi.py @@ -4,4 +4,4 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'configs.settings') -application = get_wsgi_application() +application = get_wsgi_application() \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..7f8f15a --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,48 @@ +version: "3" + +services: + # Django 웹 애플리케이션을 실행 + web: + container_name: web + build: + context: ./ + dockerfile: Dockerfile.prod # Dockerfile.prod 를 사용해 이미지를 빌드 + # Gunicorn으로 Django 실행 + command: gunicorn configs.wsgi:application --bind 0.0.0.0:8000 + environment: # configs.settings.production.py를 설정 모듈로 설정 + DJANGO_SETTINGS_MODULE: configs.settings.production + FASTTEXT_MODEL_PATH: /models/cc.ko.300.bin + env_file: + - env/.env.production + volumes: + - static:/home/app/web/static + - media:/home/app/web/media + - /home/ubuntu/ft_models:/models:ro + expose: + - "8000" + # 컨테이너가 시작될 때 entrypoint.prod.sh 스크립트를 실행하여 초기화 작업을 수행 + entrypoint: + - sh + - configs/docker/entrypoint.prod.sh + + # Nginx를 사용하여 웹 서버를 설정, Django 애플리케이션에 대한 요청을 처리 + nginx: + container_name: nginx + # ./configs/nginx의 Dockerfile을 사용해 Nginx 이미지를 빌드 + build: ./configs/nginx + volumes: + - static:/home/app/web/static # Nginx가 정적 파일을 제공할 수 있도록 설정 + - media:/home/app/web/media # 미디어 파일을 제공할 수 있도록 설정 + ports: + - "80:80" + # 호스트의 80 포트를 컨테이너의 80 포트에 매핑하여 + # Nginx가 HTTP 요청을 수신할 수 있도록 함 + depends_on: + - web + environment: + TZ: "Asia/Seoul" + +# 정적 파일과 미디어 파일을 저장할 볼륨을 정의 +volumes: + static: + media: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e91ccf2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +version: "3" +services: + # PostgreSQL 데이터베이스 + db: + container_name: db + image: postgres:16 + restart: always + environment: + POSTGRES_USER: kim-yeon-woo + POSTGRES_PASSWORD: 0000 + POSTGRES_DB: sunnydb + ports: + - "5433:5432" + env_file: + - env/.env.development + volumes: + - pgdata:/var/lib/postgresql/data + + # Django 개발 서버 + web: + container_name: web + build: . + working_dir: /app + command: > + sh -c "python manage.py migrate --settings=configs.settings.development + && python manage.py runserver 0.0.0.0:8000 --settings=configs.settings.development" + env_file: + - env/.env.development + restart: always + ports: + - "8000:8000" + volumes: + - .:/app + depends_on: + - db + +volumes: + app: + pgdata: diff --git a/env_example/.env.development b/env_example/.env.development index 64ac698..0083e85 100644 --- a/env_example/.env.development +++ b/env_example/.env.development @@ -1,4 +1,8 @@ +SECRET_KEY= + DATABASE_URL= CACHE_URL= ALLOWED_HOSTS= -CORS_ALLOWED_ORIGINS= \ No newline at end of file +NCLOUD_CLIENT_ID= +NCLOUD_CLIENT_SECRET= +CORS_ALLOWED_ORIGINS= diff --git a/env_example/.env.production b/env_example/.env.production index 64ac698..206bc01 100644 --- a/env_example/.env.production +++ b/env_example/.env.production @@ -1,4 +1,7 @@ DATABASE_URL= CACHE_URL= ALLOWED_HOSTS= -CORS_ALLOWED_ORIGINS= \ No newline at end of file +SECRET_KEY= +CORS_ALLOWED_ORIGINS= +NCLOUD_CLIENT_ID= +NCLOUD_CLIENT_SECRET= \ No newline at end of file diff --git a/manage.py b/manage.py index f99f9f2..5cbd4ac 100644 --- a/manage.py +++ b/manage.py @@ -19,4 +19,4 @@ def main(): if __name__ == '__main__': - main() + main() \ No newline at end of file diff --git a/pays/models.py b/pays/models.py index 69090f6..e1e1df9 100644 --- a/pays/models.py +++ b/pays/models.py @@ -30,6 +30,8 @@ class Order(models.Model): "pays.Payment", on_delete=models.PROTECT, related_name='order', + null=True, + blank=True, ) item = models.JSONField( default=dict, diff --git a/recommendations/apps.py b/recommendations/apps.py index de4eacd..1f7451e 100644 --- a/recommendations/apps.py +++ b/recommendations/apps.py @@ -1,6 +1,5 @@ from django.apps import AppConfig - class RecommendationsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'recommendations' diff --git a/recommendations/services.py b/recommendations/services.py index ac9d3a8..df4ee98 100644 --- a/recommendations/services.py +++ b/recommendations/services.py @@ -1,20 +1,23 @@ import os -from typing import Literal +from typing import Literal, Optional, Dict, Any, Set, List import heapq import re import numpy as np +from dataclasses import dataclass from django.conf import settings from django.core.cache import cache -from django.db.models import Q, Case, When, Value +from django.db.models import Case, Count, OuterRef, Q, Subquery, Value, When, BooleanField, IntegerField +from django.db.models.functions import Coalesce from django.http import HttpRequest -from rest_framework.exceptions import ValidationError, NotFound, APIException +from rest_framework.exceptions import ValidationError, NotFound, APIException, PermissionDenied from kiwipiepy import Kiwi import fasttext -import fasttext.util from sklearn.metrics.pairwise import cosine_similarity -from utils.choices import ProfileChoices +from utils.choices import ProfileChoices, FounderTargetChoices from utils.constants import CacheKey from utils.decorators.service import require_profile +from utils.times import _parse_hhmm, _minutes_between, _overlap_minutes +from accounts.models import ProposerLevel from proposals.models import Proposal from proposals.serializers import ProposalListSerializer @@ -35,43 +38,29 @@ '하나', '둘', '셋', '넷', '다섯' ] -def download_fasttext_model(lang='ko'): +# ---- FastText lazy loader (환경변수 경로만 읽음) ---- +FASTTEXT_MODEL_PATH = os.getenv("FASTTEXT_MODEL_PATH") +_fasttext_model = None + +def get_fasttext_model(): """ - FastText 사전훈련 모델을 다운로드합니다. + 호스트에 놓여 있는 FastText .bin을 1회 로드해 재사용. + 컨테이너 재시작/재생성에도 바인드 마운트만 유지되면 재다운로드 없음. """ - try: - # 모델 저장 경로 설정 - model_dir = os.path.join(settings.BASE_DIR, 'recommendations') - os.makedirs(model_dir, exist_ok=True) - - # 현재 작업 디렉토리를 모델 디렉토리로 변경 - original_dir = os.getcwd() - os.chdir(model_dir) - - # 모델 다운로드 (약 7GB for Korean) - print(f"{lang} 모델 다운로드 중...") - fasttext.util.download_model(lang, if_exists='ignore') - - # 원래 디렉토리로 복원 - os.chdir(original_dir) + global _fasttext_model + if _fasttext_model is not None: + return _fasttext_model - # 모델 파일 경로 반환 - model_path = os.path.join(model_dir, f'cc.{lang}.300.bin') - return model_path + if not FASTTEXT_MODEL_PATH: + raise APIException("FASTTEXT_MODEL_PATH가 설정되지 않았어요.") + if not os.path.exists(FASTTEXT_MODEL_PATH): + raise APIException(f"모델 파일이 없어요: {FASTTEXT_MODEL_PATH}") + try: + _fasttext_model = fasttext.load_model(FASTTEXT_MODEL_PATH) + return _fasttext_model except Exception as e: - print(f"모델 다운로드 실패: {e}") - os.chdir(original_dir) # 오류 시에도 디렉토리 복원 - return None - -try: - model_path = os.path.join(settings.BASE_DIR, 'recommendations', 'cc.ko.300.bin') - if not os.path.exists(model_path): - model_path = download_fasttext_model('ko') - fasttext_model = fasttext.load_model(model_path) -except Exception as e: - print(f"모델 로드 실패: {e}") - fasttext_model = None + raise APIException(f"AI 모델 로드 실패: {e}") class AI: def __init__(self, model): @@ -129,9 +118,8 @@ class RecommendationScrapService: def __init__(self, request:HttpRequest): self.request = request - if not fasttext_model: - raise APIException('AI 모델을 불러오지 못했어요. 관리자에게 문의하세요.') - self.ai = AI(fasttext_model) + model = get_fasttext_model() + self.ai = AI(model) def _cache_key_proposal(self, proposal_id): return CacheKey.PROPOSAL_VECTOR.format(proposal_id=proposal_id) @@ -140,16 +128,11 @@ def _calc_vectors(self, cache_key_method, posts, option:Literal['vector']|None=N valid_vectors = list() for post in posts: vector = cache.get(cache_key_method(post.id)) - if not vector: - # 각 게시물 벡터 계산 - vector = self.ai.vectorize(post.title + post.content) + if vector is None: # 캐시가 없거나 유효한 벡터가 아닐 때 + vector = self.ai.vectorize(' '.join([post.title, post.content])) # 게시물 벡터 계산 cache.set(cache_key_method(post.id), vector, timeout=365*24*60*60*1) # 수정 불가능하여 데이터가 변경되는 경우가 없으므로 1년 캐싱 - # 유효한 벡터만 필터링 - if vector is not None: - if option == 'vector': - valid_vectors.append(vector) - else: - valid_vectors.append((post, vector)) + if vector is not None: # 캐시 또는 새로 계산한 값이 유효한 벡터일 때 + valid_vectors.append(vector if option == 'vector' else (post, vector)) return valid_vectors @require_profile(ProfileChoices.founder) @@ -206,14 +189,24 @@ def recommend_founder_scrap_proposal(self): top_recommended_proposals = Proposal.objects.filter( id__in=top_recommended_proposal_id_list ).annotate( - similarity_order=Case(*[When(id=pk, then=Value(pos)) for pos, pk in enumerate(top_recommended_proposal_id_list)]) + similarity_order=Case( + *[When(id=pk, then=Value(pos)) for pos, pk in enumerate(top_recommended_proposal_id_list)], + output_field=IntegerField() + ) ).order_by( 'similarity_order' ).with_analytics( ).with_user( + ).with_flags( + user=self.request.user, + profile=ProfileChoices.founder.value ) - serializer = ProposalListSerializer(top_recommended_proposals, many=True) + serializer = ProposalListSerializer( + top_recommended_proposals, + context={"request": self.request, "profile": ProfileChoices.founder.value}, + many=True + ) result = serializer.data cache.set( @@ -226,18 +219,242 @@ def recommend_founder_scrap_proposal(self): ) return result + class RecommendationCalcService: - def __init__(self, request:HttpRequest): + """ + Founder 전용: 단순 계산식 기반 제안글 추천 + - Level (40): 해당 제안글 '동'에서의 제안자 레벨(1/2/3 → 33/67/100로 정규화 후 가중) + - Likes ratio (40): founder.target(local/stranger)에 맞춘 비율 가중 + - Business hours (20): 겹치는 시간 / 두 사람 중 더 긴 시간 + 필터: + - funding__isnull=True (펀딩 없는 제안만) + - 주소: Founder가 선택한 '1개 동' (쿼리 or founder address 기본값) + - 업종: Proposal.industry ∈ Founder.industry(최대 3개) + 정렬: + - score desc, likes_count desc, id desc + """ + def __init__(self, request: HttpRequest): self.request = request + user = getattr(request, "user", None) + if not getattr(user, "is_authenticated", False): + raise PermissionDenied("로그인이 필요해요.") + if not hasattr(user, "founder"): + raise PermissionDenied("창업자 프로필이 필요해요.") + + self.user = user + self.founder = user.founder + + # Founder 개인화 속성 + self.founder_addresses = getattr(self.founder, "address", None) or [] + self.founder_industries: Set[str] = set(getattr(self.founder, "industry", []) or []) + self.founder_targets: Set[str] = set(getattr(self.founder, "target", []) or []) + self.founder_hours: Dict[str, str] = getattr(self.founder, "business_hours", {}) or {} + if not self.founder_addresses: + raise PermissionDenied("추천을 위해 founder의 우리동네(최대 2개) 설정이 필요해요.") + if not self.founder_industries: + # 명세: 제안글 업종이 founder 관심업종에 포함되어야 함 → 관심업종 없으면 추천 불가 + raise PermissionDenied("추천을 위해 founder의 관심 업종 설정이 필요해요.") + + + # ------------------------------- + # Address resolve (1개 동 필수) + # ------------------------------- + def _resolve_address(self, sido: Optional[str], sigungu: Optional[str], eupmyundong: Optional[str]) -> Dict[str, str]: + # 1) 쿼리 파라미터가 모두 오면 그것을 사용 + if all([sido, sigungu, eupmyundong]): + return {"sido": sido, "sigungu": sigungu, "eupmyundong": eupmyundong} + + # 2) Founder 저장 주소(최대 2개)에서 첫 번째를 기본값으로 + # - 리스트/튜플/단일 dict 모두 방어 + if isinstance(self.founder_addresses, list) and self.founder_addresses: + a0 = self.founder_addresses[0] + return { + "sido": a0.get("sido"), + "sigungu": a0.get("sigungu"), + "eupmyundong": a0.get("eupmyundong"), + } + if isinstance(self.founder_addresses, dict): + return { + "sido": self.founder_addresses.get("sido"), + "sigungu": self.founder_addresses.get("sigungu"), + "eupmyundong": self.founder_addresses.get("eupmyundong"), + } + + # 3) 그래도 없으면 권한 에러 + raise PermissionDenied("추천을 위해 우리동네(동) 설정이 필요해요.") + + # ------------------------------- + # Component scorers - 점수 컴포넌트 계산 + # ------------------------------- + @staticmethod + def _norm_level_to_pct(level_val: Optional[int]) -> int: + return {1: 33, 2: 67, 3: 100}.get(int(level_val or 0), 0) + + def _score_business_hours(self, proposal: Proposal) -> int: + ps = proposal.business_hours or {} + fs = self.founder_hours or {} + + p_start = _parse_hhmm(ps.get("start")); p_end = _parse_hhmm(ps.get("end")) + f_start = _parse_hhmm(fs.get("start")); f_end = _parse_hhmm(fs.get("end")) + if not (p_start and p_end and f_start and f_end): + return 0 + + p_len = _minutes_between(p_start, p_end) + f_len = _minutes_between(f_start, f_end) + if p_len <= 0 or f_len <= 0: + return 0 + + overlap = _overlap_minutes(p_start, p_end, f_start, f_end) + base = max(p_len, f_len) # ← 긴 쪽 기준 + return 100 if overlap >= 0.5 * base else 0 + + def _likes_component_from_annot(self, p: Proposal) -> int: + total = getattr(p, "total_likes", 0) or 0 + if total <= 0: + return 0 + local = getattr(p, "local_likes", 0) or 0 + local_ratio = max(min(local / total, 1), 0) + + t = self.founder_targets + if t == {FounderTargetChoices.LOCAL}: + base = local_ratio + elif t == {FounderTargetChoices.STRANGER}: + base = 1 - local_ratio + else: + base = max(local_ratio, 1 - local_ratio) + return int(round(base * 100)) + + def recommend_calc(self, *, limit: Optional[int] = 10) -> List[Dict[str, Any]]: + limit = max(1, min(int(limit or 10), 50)) + + # 1) 주소 필터: 쿼리가 3개 다 오면 단일 동, 아니면 founder의 주소들(최대 2개) OR + q_sido = getattr(self.request, "GET", {}).get("sido") + q_sigungu = getattr(self.request, "GET", {}).get("sigungu") + q_eup = getattr(self.request, "GET", {}).get("eupmyundong") + + # Founder 주소들(or 조건)로 후보군 필터 + addr_q = Q() + if q_sido and q_sigungu and q_eup: + # 단일 동 선택(옵션) + addr_q = Q( + address__sido=q_sido, + address__sigungu=q_sigungu, + address__eupmyundong=q_eup, + ) + else: + # 창업자가 가진 주소들 전체(OR) – 최대 2개 + for a in (self.founder_addresses or [])[:2]: + s, g, e = a.get("sido"), a.get("sigungu"), a.get("eupmyundong") + if s and g and e: + addr_q |= Q(address__sido=s, address__sigungu=g, address__eupmyundong=e) + + if not addr_q: + raise PermissionDenied("추천을 위해 founder의 우리동네(동) 정보가 필요해요.") + + # 레벨 Subquery: "해당 제안글의 동"에서의 작성자(Proposer) 레벨 + level_subq = ProposerLevel.objects.filter( + user=OuterRef("user"), + address__sido=OuterRef("address__sido"), + address__sigungu=OuterRef("address__sigungu"), + address__eupmyundong=OuterRef("address__eupmyundong"), + ).order_by("-id").values("level")[:1] + + + qs = ( + Proposal.objects + .filter(funding__isnull=True) + .filter(addr_q) + .filter(industry__in=list(self.founder_industries)) + .select_related("user", "user__user") # ← obj.user.user 접근 대비 (N+1 방지) + ) - def _calc_level(self): - pass + # 1) 항상 존재해야 하는 필드들을 기본값/집계로 채움 + qs = qs.annotate( + # 제안자의 '해당 동' 레벨 + proposer_level_at_addr=Coalesce(Subquery(level_subq, output_field=IntegerField()), 0), - def _calc_likes_ratio(self): - pass + # 좋아요/스크랩 집계 + likes_count=Coalesce(Count("proposer_like_proposal__user", distinct=True), 0), + scraps_count=Coalesce(Count("founder_scrap_proposal__user", distinct=True), 0), - def _calc_business_hours(self): - pass + # founder가 이 제안을 스크랩했는지 + _my_scrap=Count( + "founder_scrap_proposal__user", + filter=Q(founder_scrap_proposal__user=self.user.founder), + distinct=True, + ), + ).annotate( + is_scrapped=Case( + When(_my_scrap__gt=0, then=Value(True)), + default=Value(False), + output_field=BooleanField(), + ), + + # founder 응답에서는 좋아요 불가 → False 고정(Detail/MapItem에서 founder면 어차피 pop될 수도 있음) + is_liked=Value(False, output_field=BooleanField()), + + # 주소 필터로 이미 founder 우리동네만 옴 → True 고정 + is_address=Value(True, output_field=BooleanField()), + + # Detail/ZoomFounder가 요구 + has_funding=Value(False, output_field=BooleanField()), + ) - def recommend_calc(self): - pass + # (필요하면) with_analytics/with_user가 있으면 덮어쓰기 + try: + qs = qs.with_analytics() + + except Exception: + pass + try: + try: + qs = qs.with_user(self.user) + except TypeError: + qs = qs.with_user() + except Exception: + pass + + # 정렬/슬라이스는 기존 로직 유지 + candidates = list(qs.order_by("-likes_count", "-created_at", "-id")[:200]) + + + + + + @dataclass + class CalcWeights: + level: int = 40 + likes_ratio: int = 40 + business_hours: int = 20 + + CALC_WEIGHTS = CalcWeights() + + w = CALC_WEIGHTS + denom = (w.level + w.likes_ratio + w.business_hours) + rows: List[Dict[str, Any]] = [] + for p in candidates: + level_pct = self._norm_level_to_pct(getattr(p, "proposer_level_at_addr", 0)) + likes_pct = self._likes_component_from_annot(p) + hours_pct = self._score_business_hours(p) + + total = ( + (level_pct * w.level) + + (likes_pct * w.likes_ratio) + + (hours_pct * w.business_hours) + ) / denom + score = int(round(total)) + + if score >= 60: # 명세 컷 유지 + rows.append({"proposal": p, "score": score}) + + # 정렬: score desc, likes_count desc, id desc + rows.sort(key=lambda x: (x["score"], getattr(x["proposal"], "likes_count", 0), x["proposal"].id), reverse=True) + top = rows[:limit] + + # 2) 직렬화 (score/components 주입하지 않음) + ser = ProposalListSerializer( + [r["proposal"] for r in top], + many=True, + context={"request": self.request, "profile": ProfileChoices.founder.value}, + ).data + return ser # ← 점수 미노출 \ No newline at end of file diff --git a/recommendations/urls.py b/recommendations/urls.py index df15346..f4610c5 100644 --- a/recommendations/urls.py +++ b/recommendations/urls.py @@ -4,7 +4,7 @@ app_name = 'recommendations' urlpatterns = [ - path('proposal/calc', ProposalCalc.as_view()), + path('proposal/calc/', ProposalCalc.as_view()), path('proposal/scrap-similarity', ProposalScrapSimilarity.as_view()), path('proposal/funding-success-similarity', ProposalFundingSuccessSimilarity.as_view()), ] diff --git a/recommendations/views.py b/recommendations/views.py index 90a3f77..785d164 100644 --- a/recommendations/views.py +++ b/recommendations/views.py @@ -2,14 +2,20 @@ from rest_framework import status from rest_framework.views import APIView from rest_framework.response import Response -from .services import RecommendationScrapService +from .services import RecommendationScrapService, RecommendationCalcService +from rest_framework.permissions import IsAuthenticated +from django.core.exceptions import PermissionDenied class ProposalCalc(APIView): - def get(self, request:HttpRequest, format=None): - return Response( - '', - status=status.HTTP_200_OK, - ) + permission_classes = [IsAuthenticated] + + def get(self, request, *args, **kwargs): + try: + svc = RecommendationCalcService(request) + data = svc.recommend_calc() + return Response(data if data else [], status=status.HTTP_200_OK) + except PermissionDenied as e: + return Response({"detail": str(e)}, status=status.HTTP_403_FORBIDDEN) class ProposalScrapSimilarity(APIView): def get(self, request:HttpRequest, format=None): diff --git a/requirements.txt b/requirements.txt index ceaf073..1ed6b98 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ numpy==1.26.4 packaging==25.0 pillow==11.3.0 pip==25.2 -psycopg2==2.9.10 +psycopg2-binary==2.9.10 pybind11==3.0.1 pycryptodome==3.19.0 PyJWT==2.10.1 diff --git a/utils/times.py b/utils/times.py new file mode 100644 index 0000000..4946a33 --- /dev/null +++ b/utils/times.py @@ -0,0 +1,29 @@ +from typing import Optional, Tuple + +def _parse_hhmm(s: Optional[str]) -> Optional[Tuple[int, int]]: + """'HH:MM' -> (hour, minute) or None""" + if not s or not isinstance(s, str) or ":" not in s: + return None + try: + h, m = s.split(":") + h = int(h); m = int(m) + if 0 <= h < 24 and 0 <= m < 60: + return h, m + except Exception: + return None + return None + + +def _minutes_between(a: Tuple[int, int], b: Tuple[int, int]) -> int: + return (b[0] * 60 + b[1]) - (a[0] * 60 + a[1]) + + +def _overlap_minutes(a_start, a_end, b_start, b_end) -> int: + """Same-day overlap minutes (no overnight).""" + a1 = a_start[0] * 60 + a_start[1] + a2 = a_end[0] * 60 + a_end[1] + b1 = b_start[0] * 60 + b_start[1] + b2 = b_end[0] * 60 + b_end[1] + lo = max(a1, b1) + hi = min(a2, b2) + return max(hi - lo, 0) \ No newline at end of file