diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..91f0653 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..8bc2b42 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,48 @@ +name: Deploy FastAPI to ECR + +on: + workflow_run: + workflows: ["Build and Test FastAPI Backend"] + types: + - completed + branches: + - main + workflow_dispatch: + +jobs: + deploy-to-ecr: + name: Deploy to ECR + runs-on: ubuntu-latest + if: ${{ (github.event_name == 'workflow_dispatch') || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') }} + environment: production + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Log in to Amazon ECR + run: | + aws ecr get-login-password --region ${{ secrets.AWS_REGION }} \ + | docker login --username AWS --password-stdin \ + $(aws sts get-caller-identity --query 'Account' --output text).dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com + + - name: Build Docker image + run: | + docker build -t ${{ secrets.ECR_REPOSITORY }}:${{ secrets.IMAGE_TAG }} -f Dockerfile.prod . + + - name: Tag Docker image + run: | + docker tag ${{ secrets.ECR_REPOSITORY }}:${{ secrets.IMAGE_TAG }} \ + $(aws sts get-caller-identity --query 'Account' --output text).dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/${{ secrets.ECR_REPOSITORY }}:${{ secrets.IMAGE_TAG }} + + - name: Push Docker image to Amazon ECR + run: | + docker push \ + $(aws sts get-caller-identity --query 'Account' --output text).dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/${{ secrets.ECR_REPOSITORY }}:${{ secrets.IMAGE_TAG }} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ebe2943 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: Build and Test FastAPI Backend + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + workflow_dispatch: + +jobs: + build-and-test: + name: Build and Test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: 'app/requirements.txt' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest flake8 + pip install -r app/requirements.txt + + - name: Lint with flake8 + run: flake8 app/ --count --select=E9,F63,F7,F82 --show-source --statistics + + - name: Test with pytest + working-directory: ./app + run: pytest || echo "No tests configured" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build API Docker image (local) + uses: docker/build-push-action@v5 + with: + context: . + push: false + load: true + tags: saburo-api:local-test + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml new file mode 100644 index 0000000..c0424d1 --- /dev/null +++ b/.github/workflows/dev.yml @@ -0,0 +1,48 @@ +name: Push to ECR + +on: + push: + branches: + - develop + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + # リポジトリのクローン + - name: Checkout code + uses: actions/checkout@v3 + + # AWS CLI のセットアップ + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v3 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + # Dockerログイン + - name: Log in to Amazon ECR + run: | + aws ecr get-login-password --region ${{ secrets.AWS_REGION }} \ + | docker login --username AWS --password-stdin \ + $(aws sts get-caller-identity --query 'Account' --output text).dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com + + # Dockerイメージのビルド + - name: Build Docker image + run: | + docker build -t ${{ secrets.ECR_REPOSITORY }}:${{ secrets.IMAGE_TAG }} -f Dockerfile.prod . + + # Dockerイメージのタグ付け + - name: Tag Docker image + run: | + docker tag ${{ secrets.ECR_REPOSITORY }}:${{ secrets.IMAGE_TAG }} \ + $(aws sts get-caller-identity --query 'Account' --output text).dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/${{ secrets.ECR_REPOSITORY }}:${{ secrets.IMAGE_TAG }} + + # Dockerイメージのプッシュ + - name: Push Docker image to Amazon ECR + run: | + docker push \ + $(aws sts get-caller-identity --query 'Account' --output text).dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/${{ secrets.ECR_REPOSITORY }}:${{ secrets.IMAGE_TAG }} diff --git a/.gitignore b/.gitignore index d308cd2..b3eaeaf 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ ### Alembic ### alembic/__pycache__/ -alembic/versions/__pycache__/ +# alembic/versions/__pycache__/ ### Python ### # Byte-compiled / optimized / DLL files diff --git a/Dockerfile b/Dockerfile index f823951..caaa45b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,8 +13,10 @@ RUN apt-get update && \ COPY app/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY app/ . +COPY app ./app +ENV PYTHONPATH=/app EXPOSE 8000 -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] \ No newline at end of file diff --git a/Dockerfile.prd b/Dockerfile.prod similarity index 88% rename from Dockerfile.prd rename to Dockerfile.prod index c1eff8f..b877399 100644 --- a/Dockerfile.prd +++ b/Dockerfile.prod @@ -12,9 +12,10 @@ RUN pip install --no-cache-dir -r requirements.txt # アプリケーション本体をすべてコンテナにコピー(app ディレクトリの中身を /app にコピー) COPY app/ . +ENV PYTHONPATH=/app # アプリケーションで使用するポートを開放(FastAPI や uvicorn のデフォルトに近い 8000 番を使用) EXPOSE 8000 # コンテナ起動時に実行されるコマンドを指定(uvicorn で main.py 内の app を起動) -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"] diff --git a/Makefile b/Makefile index 110e0ae..5858053 100644 --- a/Makefile +++ b/Makefile @@ -11,23 +11,26 @@ logs: docker compose logs exec-app: - docker exec -it fastapi-app bash + docker compose exec -it web bash exec-db: - docker exec -it mysql-db bash + docker compose exec -it db bash + +db-shell: + docker compose exec -it db bash -c "mysql -u root -p" down: docker compose down migrate: - docker compose exec fastapi-app sh -c "cd /app && alembic upgrade head" + docker compose exec -it web bash -c "cd /app && alembic upgrade head" makemigration: @read -p "Migration name: " name; \ - docker compose exec fastapi-app sh -c "cd /app && alembic revision --autogenerate -m $$name" + docker compose exec -it web bash -c "cd /app && alembic revision --autogenerate -m $$name" show: - docker compose exec fastapi-app sh -c "cd /app && alembic history" + docker compose exec -it web bash -c "cd /app && alembic history" downgrade: - docker compose exec fastapi-app sh -c "cd /app && alembic downgrade -1" \ No newline at end of file + docker compose exec -it web bash -c "cd /app && alembic downgrade -1" diff --git a/app/api/api.py b/app/api/api.py index e69de29..16c78fd 100644 --- a/app/api/api.py +++ b/app/api/api.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter +from api.endpoints import auth, admin, user, session, message + + +router = APIRouter() + + +# 認証用エンドポイント +router.include_router(auth.router, tags=["Auth"]) +# 管理者用エンドポイント +router.include_router(admin.router, tags=["Admin"]) +# アカウント情報用エンドポイント +router.include_router(user.router, tags=["User"]) +# チャット用エンドポイント +router.include_router(session.router, tags=["Session"]) +# メッセージ用エンドポイント +router.include_router(message.router, tags=["Message"]) \ No newline at end of file diff --git a/app/api/deps.py b/app/api/deps.py index e69de29..23f63b0 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -0,0 +1,7 @@ +from sqlalchemy.orm import Session +from app.db.session import get_db +from fastapi import Depends + +# データベースセッションを依存関係として注入する +def get_db_dependency(db: Session = Depends(get_db)): + return db diff --git a/app/api/endpoints/admin.py b/app/api/endpoints/admin.py index e69de29..61fb8db 100644 --- a/app/api/endpoints/admin.py +++ b/app/api/endpoints/admin.py @@ -0,0 +1,192 @@ +from fastapi import APIRouter, HTTPException, Depends +from sqlalchemy.orm import Session + +from datetime import datetime, timedelta +from models import User, Message +from schemas.user import UserRegister, UserLogin, UserUpdate, UserResponse, TokenResponse +from schemas.message import AdminMessageResponse +from core.config import settings +from core.database import get_db +from services.session import get_all_sessions_with_first_message +from utils.auth import hash_password, verify_password, create_access_token, get_current_admin_user +from utils.timestamp import now_jst + +router = APIRouter() + +# ------------------ +# 管理者アカウント登録 +# ------------------ +@router.post("/admin/register/", response_model=TokenResponse) +async def register_admin( + admin_data: UserRegister, + db: Session = Depends(get_db) +): + if db.query(User).filter_by(email=admin_data.email).first(): + raise HTTPException(status_code=400, detail="このメールアドレスは既に登録されています。") + + new_admin = User( + email=admin_data.email, + password_hash=hash_password(admin_data.password), + user_name=admin_data.user_name, + is_admin=True + ) + db.add(new_admin) + db.commit() + db.refresh(new_admin) + + token = create_access_token({"sub": str(new_admin.id)}) + return {"token": token } + + +# ------------------ +# 管理者ログイン +# ------------------ +@router.post("/admin/login", response_model=TokenResponse) +async def login_admin( + admin_data: UserLogin, + db: Session = Depends(get_db) +): + admin = db.query(User).filter_by(email=admin_data.email, is_admin=True).first() + if not admin: + raise HTTPException(status_code=400, detail="メールアドレスまたはパスワードが違います") + + if not verify_password(admin_data.password, admin.password_hash): + raise HTTPException(status_code=400, detail="メールアドレスまたはパスワードが違います") + + # PINコードは.envファイルから取得 + if str(admin_data.pin_code) != settings.ADMIN_PIN_CODE: + raise HTTPException(status_code=400, detail="PINコードが正しくありません") + + token = create_access_token({"sub": str(admin.id)}) + return {"token": token, "is_admin": admin.is_admin} + + +# ----------------------- +# 管理者アカウント情報取得 +# ------------------ +@router.get("/admin_info", response_model=UserResponse) +async def get_admin_info( + current_admin: User = Depends(get_current_admin_user) +): + return current_admin + + +# ------------------ +# 一般ユーザ情報一覧取得 +# ------------------ +@router.get("/users", response_model=list[UserResponse]) +async def get_all_users( + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user) +): + users = db.query(User).filter(User.is_admin==False).all() + now = datetime.now() + return [ + UserResponse( + id=user.id, + email=user.email, + user_name=user.user_name, + is_active=user.is_active, + can_be_delete=( + not user.is_active + and user.deleted_at + and (now - user.deleted_at >= timedelta(days=30)) + ) + )for user in users + ] + + +# ---------------------------- +# ユーザーアカウントを削除する処理 +# ---------------------------- +@router.delete("/users/{user_id}") +async def delete_user( + user_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user) +): + user = db.query(User).filter_by(id=user_id).first() + if not user: + raise HTTPException(status_code=404, detail="ユーザーが見つかりません") + + if user.is_active or not user.deleted_at: + raise HTTPException(status_code=400, detail="無効化されたアカウントのみ削除可能です") + + db.delete(user) + db.commit() + return {"message": "ユーザーが削除されました"} + + +# ---------------------------- +# ユーザーアカウントを凍結する処理 +# ---------------------------- +@router.patch("/users/{user_id}/deactivate") +async def deactivate_user( + user_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user) +): + user = db.query(User).filter_by(id=user_id).first() + if not user: + raise HTTPException(status_code=404, detail="ユーザーが見つかりません") + + user.is_active = False + user.deleted_at = now_jst() + db.commit() + return {"message": "ユーザーが無効化されました"} + + +# ---------------------------- +# ユーザーアカウントを有効化する処理 +# ---------------------------- +@router.patch("/users/{user_id}/activate") +async def activate_user( + user_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user) +): + user = db.query(User).filter_by(id=user_id).first() + if not user: + raise HTTPException(status_code=404, detail="ユーザーが見つかりません") + + user.is_active = True + user.deleted_at = None # 論理削除日時をリセット + db.commit() + return {"message": "ユーザーが有効化されました"} + + +# --------------- +# ユーザー情報更新 +# ---------------- +@router.patch("/users/{user_id}") +async def update_user( + user_id: int, + user_data: UserUpdate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user) +): + user = db.query(User).filter_by(id=user_id).first() + if not user: + raise HTTPException(status_code=404, detail="ユーザーが見つかりません") + + if user_data.user_name is None and user_data.email is None: + raise HTTPException(status_code=400, detail="更新内容がありません") + + if user_data.user_name is not None: + user.user_name = user_data.user_name + if user_data.email is not None: + user.email = user_data.email + + db.commit() + return {"message": "ユーザー情報が更新されました"} + + +# -------- +# 投稿一覧 +# -------- +@router.get("/messages", response_model=list[AdminMessageResponse]) +async def get_messages( + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user) + ): + return get_all_sessions_with_first_message(db) \ No newline at end of file diff --git a/app/api/endpoints/auth.py b/app/api/endpoints/auth.py new file mode 100644 index 0000000..e4520e7 --- /dev/null +++ b/app/api/endpoints/auth.py @@ -0,0 +1,59 @@ +from fastapi import APIRouter, HTTPException, Depends, status +from sqlalchemy.orm import Session + +from core.config import settings +from schemas.user import UserRegister, UserLogin, TokenResponse +from models import User +from core.database import get_db +from utils.auth import hash_password, verify_password, create_access_token, get_current_user + + +router = APIRouter() + +# ------------- +# アカウント登録 +# ------------- +@router.post("/register", response_model=TokenResponse) +async def register(user_data: UserRegister, db: Session = Depends(get_db)): + + if db.query(User).filter_by(email=user_data.email).first(): + raise HTTPException(status_code=400, detail="このメールアドレスは既に登録されています。") + + new_user = User( + email=user_data.email, + user_name=user_data.user_name, + password_hash=hash_password(user_data.password) + ) + db.add(new_user) + db.commit() + db.refresh(new_user) + + token = create_access_token({"sub": str(new_user.id)}) + return {"token": token, "is_admin": new_user.is_admin} + + +# -------- +# ログイン +# --------- +@router.post("/login", response_model=TokenResponse) +async def login(user_data: UserLogin, db: Session = Depends(get_db)): + user = db.query(User).filter_by(email=user_data.email).first() + if not user or not verify_password(user_data.password, user.password_hash): + raise HTTPException(status_code=400, detail="メールアドレスまたはパスワードが違います。") + + if user.is_admin: + if user_data.pin_code is None: + raise HTTPException(status_code=400, detail="PINコードが必要です。") + if user_data.pin_code != settings.admin_pin_code: + raise HTTPException(status_code=400, detail="PINコードが違います。") + + token = create_access_token({"sub": str(user.id)}) + return {"token": token} + + +# ---------- +# ログアウト +# ---------- +@router.post("/logout") +async def logout(current_user: User = Depends(get_current_user)): + return {"message": "ログアウトしました。"} \ No newline at end of file diff --git a/app/api/endpoints/chat.py b/app/api/endpoints/chat.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/api/endpoints/emotion.py b/app/api/endpoints/emotion.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/api/endpoints/favorite.py b/app/api/endpoints/favorite.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/api/endpoints/generated_media.py b/app/api/endpoints/generated_media.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/api/endpoints/message.py b/app/api/endpoints/message.py new file mode 100644 index 0000000..cf245a8 --- /dev/null +++ b/app/api/endpoints/message.py @@ -0,0 +1,111 @@ +from fastapi import Request, APIRouter, HTTPException, Depends +from sqlalchemy.orm import Session +from slowapi import Limiter +from slowapi.util import get_remote_address + +from models import Message, Session as SessionModel +from models import User +from schemas.message import MessageCreate, MessageResponse, MessageUpdate +from core.database import get_db +from utils.auth import get_current_user +from services.message import create_message_with_ai, get_messages_by_session, update_user_message ,delete_message + + +router = APIRouter() +# レートリミッター +limiter = Limiter(key_func=get_remote_address) + +# ---------------------------------- +# 日記の投稿またはキャラクターの返答を作成 +# ---------------------------------- +@router.post("/sessions/{session_id}/messages") +@limiter.limit("2/minute") # IPごとに2回/分 +async def create_message( + request: Request, + session_id: int, + message_data: MessageCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + session = db.query(SessionModel).filter( + SessionModel.id == session_id, + SessionModel.user_id == current_user.id + ).first() + + if not session: + raise HTTPException(status_code=404, detail="チャットが見つかりません") + + # return create_user_message(db, session_id, content=message_data.content) #テスト + + try: + return await create_message_with_ai(db, session_id, message_data.content) + except HTTPException as e: + print(f"HTTPエラー: {e.detail}") + raise e + except Exception as e: + print(f"未処理エラー:{e}") + raise HTTPException(status_code=500, detail="サーバ内部エラー") + + +# ---------------------------------- +# 特定のチャットの全メッセージを取得 +# ---------------------------------- +@router.get("/sessions/{session_id}/messages", response_model=list[MessageResponse]) +async def get_messages_endpoint( + session_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + session = db.query(SessionModel).filter( + SessionModel.id == session_id, + SessionModel.user_id == current_user.id + ).first() + + if not session: + raise HTTPException(status_code=404, detail="チャットが見つかりません") + + return get_messages_by_session(db, session_id) + + +# ---------------------- +# ユーザのメッセージを更新 +# ---------------------- +@router.put("/sessions/{session_id}/messages/{message_id}", response_model=MessageResponse) +async def update_message( + session_id: int, + message_id: int, + message_data: MessageUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + session = db.query(SessionModel).filter( + SessionModel.id == session_id, + SessionModel.user_id == current_user.id + ).first() + + if not session: + raise HTTPException(status_code=404, detail="チャットが見つかりません") + + return update_user_message(db, message_id, message_data.content) + + +# ---------------------- +# 特定のメッセージを削除 +# ----------------------- +@router.delete("/sessions/{session_id}/messages/{message_id}") +async def delete_message_endpoint( + session_id: int, + message_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + session = db.query(SessionModel).filter( + SessionModel.id == session_id, + SessionModel.user_id == current_user.id + ).first() + + if not session: + raise HTTPException(status_code=404, detail="チャットが見つかりません。") + + delete_message(db, message_id) + return {"message": "メッセージを削除しました。"} \ No newline at end of file diff --git a/app/api/endpoints/personality.py b/app/api/endpoints/personality.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/api/endpoints/poem.py b/app/api/endpoints/poem.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/api/endpoints/session.py b/app/api/endpoints/session.py new file mode 100644 index 0000000..a9209f2 --- /dev/null +++ b/app/api/endpoints/session.py @@ -0,0 +1,157 @@ +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import APIRouter, HTTPException, Depends +from sqlalchemy.orm import Session +from models import Session as SessionModel +from models import User +from schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionWithMessagesResponse, SessionSummaryResponse +from core.database import get_db +from utils.auth import get_current_user +from utils.timestamp import now_jst +from services.session import get_sessions_with_first_message, toggle_favorite_session, delete_session + + +router = APIRouter() + +# ----------------- +# チャットの開始 +# ----------------- +@router.post("/sessions", response_model=SessionResponse) +async def create_session( + session_data: SessionCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) + ): + + user_id=current_user.id + today = now_jst().date() + + # 当日のセッションを確認 + existing_session = db.query(SessionModel).filter( + SessionModel.user_id == user_id, + SessionModel.created_at >= datetime.combine(today, datetime.min.time()), + SessionModel.created_at <= datetime.combine(today, datetime.max.time()) + ).first() + + # if existing_session: + # # 1日1回制限:エラーメッセージで伝える + # raise HTTPException( + # status_code=403, + # detail="今日はすでにチャットを開始しています。明日またご利用ください。" + # ) + + + new_session = SessionModel( + character_mode=session_data.character_mode, + user_id=user_id + ) + db.add(new_session) + db.commit() + db.refresh(new_session) + return new_session + + +# ---------------- +# チャット一覧取得 +# ---------------- +@router.get("/sessions", response_model=list[SessionSummaryResponse]) +async def get_sessions( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), + favorite_only: bool =False, + keyword: Optional[str] = None + ): + return get_sessions_with_first_message( + db = db, + user_id = current_user.id, + favorite_only = favorite_only, + keyword = keyword + ) + + +# ------------------- +# 特定のチャットを取得 +# ------------------- +@router.get("/sessions/{session_id}", response_model=SessionWithMessagesResponse) +async def get_session( + session_id: int, + db: Session = Depends(get_db), + current_user = Depends(get_current_user) +): + session = db.query(SessionModel).filter( + SessionModel.id == session_id, + SessionModel.user_id == current_user.id + ).first() + + if not session: + raise HTTPException(status_code=404, detail="セッションが見つかりません。") + + messages =[{ + "message_id": m.id, + "message_text": m.content, + "sender_type": "user" if m.is_user else "ai" + } for m in session.messages + ] + + return { + "session_id" : session.id, + "messages" : messages, + "created_at": session.created_at, + "updated_at": session.updated_at, + } + + +# ------------------- +# 特定のチャットを変更 +# ------------------- +@router.patch("/sessions/{session_id}", response_model= SessionResponse) +async def update_session( + id: int, + session_data: SessionUpdate, + db: Session = Depends(get_db), + current_user : User= Depends(get_current_user) + ): + + session = db.query(SessionModel).filter( + SessionModel.id == id, + SessionModel.user_id == current_user.id + ).first() + + if not session: + raise HTTPException(status_code=404, detail="チャットが見つかりません") + + # 更新 + if session_data.character_mode: + session.character_mode = session_data.character_mode + + db.commit() + db.refresh(session) + return session + + +# ------------------ +# 特定のチャットを削除 +# ------------------ +@router.delete("/sessions/{session_id}") +async def delete_session_route( + session_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + success = delete_session(db, session_id, current_user.id) + if not success: + raise HTTPException(status_code=404, detail="チャットが見つかりません。") + return {"message": "チャットを削除しました。"} + + +# ---------------- +# お気に入りのトグル +# ---------------- +@router.post("/sessions/{session_id}/favorite") +def toggle_favorite( + session_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + return toggle_favorite_session(db, session_id, current_user.id) diff --git a/app/api/endpoints/user.py b/app/api/endpoints/user.py index e69de29..b244d22 100644 --- a/app/api/endpoints/user.py +++ b/app/api/endpoints/user.py @@ -0,0 +1,97 @@ +from fastapi import APIRouter, HTTPException, Depends, status +from sqlalchemy.orm import Session +from models import User +from schemas.user import UserUpdate, PasswordUpdate, UserResponse +from core.database import get_db +from utils.auth import hash_password, verify_password, get_current_user +from utils.timestamp import now_jst + + +router = APIRouter() + +# ----------------- +# アカウント情報取得 +# ----------------- +@router.get("/user", response_model=UserResponse) +async def get_my_account(current_user: User = Depends(get_current_user)): + return current_user + + +# ----------------- +# アカウント情報の更新 +# ----------------- +@router.patch("/user", response_model=UserResponse) +async def update_my_account( + user_update: UserUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + # ユーザ名の更新 + if user_update.user_name and user_update.user_name != current_user.user_name: + current_user.user_name = user_update.user_name + + # メールアドレスの更新 + if user_update.email and user_update.email != current_user.email: + # 他のユーザと重複していないかチェック + if db.query(User).filter_by(email=user_update.email).first(): + raise HTTPException(status_code=400, detail="このメールアドレスはすでに使われています") + current_user.email = user_update.email + + # パスワードの更新 + if user_update.password: + current_user.password_hash = hash_password(user_update.password) + + db.commit() + db.refresh(current_user) + return current_user + + +# ----------------- +# パスワード変更 +# ----------------- +@router.put("/password") +async def change_password( + password_data: PasswordUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + if not verify_password(password_data.current_password, current_user.password_hash): + raise HTTPException(status_code=400, detail="現在のパスワードが間違っています") + + current_user.password_hash = hash_password(password_data.new_password) + db.commit() + + return {"message": "パスワードを変更しました"} + + +# ----------------- +# アカウント凍結 +# ----------------- +@router.delete("/user") +async def deactivate_account( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + if current_user.is_admin: + raise HTTPException(status_code=403, detail="管理者アカウントは削除できません") + + current_user.is_active =False + current_user.deleted_at = now_jst() + db.commit() + return {"message": "アカウントを削除しました"} + + +# ------------- +# アカウント削除 +# -------------- +@router.delete("/user/delete") +async def delete_account( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + if current_user.is_admin: + raise HTTPException(status_code=403, detail="管理者アカウントは削除できません") + + db.delete(current_user) + db.commit() + return {"message": "アカウントを完全に削除しました"} \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py index 45f142d..571a8aa 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,10 +1,26 @@ -from pydantic import BaseSettings +import os +from pydantic_settings import BaseSettings + +# ローカル環境のみ .env を読み込む +if os.getenv("ENV") != "production": + from dotenv import load_dotenv + load_dotenv() class Settings(BaseSettings): - # OpenAI API key + SECRET_KEY: str + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 + + # PIN code + ADMIN_PIN_CODE: str + + # OpenAI openai_api_key: str - # OpenAI Model - openai_model: str ="gpt-4.1-nano" + openai_model: str = "gpt-4.1-nano" + + # AWS Bedrock + MODEL_ID: str = "anthropic.claude-instant-v1" + REGION: str = "ap-northeast-1" # Database mysql_root_password: str @@ -12,6 +28,9 @@ class Settings(BaseSettings): mysql_user: str mysql_password: str database_url: str + admin_pin_code: int class Config: - env_file = ".env" \ No newline at end of file + env_file = ".env" # ローカルで .env を参照するように設定 + +settings = Settings() diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000..7229adf --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,22 @@ +import os +from dotenv import load_dotenv +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base +from core.config import settings + +# 本番以外のときだけ .env を読み込む +if os.getenv("ENV") != "production": + load_dotenv() + +DATABASE_URL = os.getenv("DATABASE_URL") + +engine = create_engine(DATABASE_URL, echo=True, future=True) +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/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..51fa802 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,25 @@ +# db/session.py +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session +from fastapi import Depends +from models import Base + +# ローカル開発環境のみ .env を読み込む +if os.getenv("ENV") != "production": + from dotenv import load_dotenv + load_dotenv() + +DATABASE_URL = os.getenv("DATABASE_URL") + +# MySQL用:connect_argsなしでエンジン作成 +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# データベースの接続を管理する関数 +def get_db() -> Session: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py index 731166c..5e6bf52 100644 --- a/app/main.py +++ b/app/main.py @@ -1,10 +1,23 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -import mysql.connector +from slowapi.errors import RateLimitExceeded +from slowapi.middleware import SlowAPIMiddleware +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.util import get_remote_address + import os +from api.api import router as api_router + + app = FastAPI() +# レートリミットの設定 +limiter = Limiter(key_func=get_remote_address) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) +app.add_middleware(SlowAPIMiddleware) + # ✅ CORS設定(Next.jsからアクセス可能にする) app.add_middleware( CORSMiddleware, @@ -15,22 +28,11 @@ ) @app.get("/") -def read_root(): - return {"message": "Hello from FastAPI + MySQL in Docker!"} - -@app.get("/db-status") -def db_status(): - try: - connection = mysql.connector.connect( - host="db", # ← Dockerコンテナ名 - user=os.getenv("MYSQL_USER"), - password=os.getenv("MYSQL_PASSWORD"), - database=os.getenv("MYSQL_DATABASE") - ) - if connection.is_connected(): - connection.close() # ✅ 接続が確認できたら明示的にクローズ - return {"db_status": "connected"} - else: - return {"db_status": "not connected"} - except Exception as e: - return {"db_status": "error", "details": str(e)} +async def root(): + return {"message": "Saburo FastAPI application is running", "status": "ok"} + +@app.get("/health") +async def health_check(): + return {"status": "healthy", "service": "saburo-fastapi"} + +app.include_router(api_router, prefix="/api") diff --git a/app/migrations/env.py b/app/migrations/env.py index ca3e7b6..9897454 100644 --- a/app/migrations/env.py +++ b/app/migrations/env.py @@ -1,17 +1,18 @@ from logging.config import fileConfig import os , sys from pathlib import Path - from sqlalchemy import engine_from_config, pool - from alembic import context -from dotenv import load_dotenv # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config -load_dotenv() +# .envはローカル環境のみ読み込む +if os.getenv("ENV") != "production": + from dotenv import load_dotenv + load_dotenv() + database_url = os.getenv("DATABASE_URL") if database_url is None: raise ValueError("DATABASE_URLの環境変数が見つかりません。") diff --git a/app/migrations/versions/4e316c0822e0_initial_migration.py b/app/migrations/versions/3e6918d322bf_deploy.py similarity index 63% rename from app/migrations/versions/4e316c0822e0_initial_migration.py rename to app/migrations/versions/3e6918d322bf_deploy.py index 9339022..a425ef9 100644 --- a/app/migrations/versions/4e316c0822e0_initial_migration.py +++ b/app/migrations/versions/3e6918d322bf_deploy.py @@ -1,8 +1,8 @@ -"""initial migration +"""deploy -Revision ID: 4e316c0822e0 +Revision ID: 3e6918d322bf Revises: -Create Date: 2025-04-23 15:13:35.197369 +Create Date: 2025-05-24 07:32:24.105720 """ from typing import Sequence, Union @@ -12,7 +12,7 @@ # revision identifiers, used by Alembic. -revision: str = '4e316c0822e0' +revision: str = '3e6918d322bf' down_revision: Union[str, None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -22,79 +22,65 @@ def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### op.create_table('users', - sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), sa.Column('password_hash', sa.String(length=255), nullable=False), sa.Column('email', sa.String(length=255), nullable=False), sa.Column('user_name', sa.String(length=255), nullable=False), - sa.Column('poem_id', sa.Integer(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), sa.Column('is_admin', sa.Boolean(), nullable=True), - sa.ForeignKeyConstraint(['poem_id'], ['poems.id'], ), - sa.PrimaryKeyConstraint('user_id') + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) - op.create_index(op.f('ix_users_user_id'), 'users', ['user_id'], unique=False) + op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) op.create_table('sessions', sa.Column('id', sa.Integer(), nullable=False), sa.Column('user_id', sa.Integer(), nullable=True), sa.Column('character_mode', sa.Enum('saburo', 'bijyo', 'anger_mom', name='charactermodeenum'), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['users.user_id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_sessions_id'), 'sessions', ['id'], unique=False) - op.create_table('messages', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('session_id', sa.Integer(), nullable=True), - sa.Column('is_users', sa.Boolean(), nullable=True), - sa.Column('response_type', sa.Enum('praise', 'insult', name='responsetypeenum'), nullable=True), - sa.Column('content', sa.Text(), nullable=False), sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), sa.PrimaryKeyConstraint('id') ) - op.create_index(op.f('ix_messages_id'), 'messages', ['id'], unique=False) + op.create_index(op.f('ix_sessions_id'), 'sessions', ['id'], unique=False) op.create_table('favorites', sa.Column('id', sa.Integer(), nullable=False), - sa.Column('message_id', sa.Integer(), nullable=True), + sa.Column('session_id', sa.Integer(), nullable=True), sa.Column('user_id', sa.Integer(), nullable=True), sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(['message_id'], ['messages.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['users.user_id'], ), + sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_favorites_id'), 'favorites', ['id'], unique=False) - op.create_table('generated_media', + op.create_table('messages', sa.Column('id', sa.Integer(), nullable=False), - sa.Column('message_id', sa.Integer(), nullable=True), - sa.Column('emotion_id', sa.Integer(), nullable=True), - sa.Column('media_type', sa.Enum('IMAGE', 'BGM', name='mediatypeenum'), nullable=False), - sa.Column('media_url', sa.String(length=255), nullable=False), - sa.Column('image_prompt', sa.Text(), nullable=True), - sa.Column('bgm_prompt', sa.Text(), nullable=True), - sa.Column('bgm_duration', sa.Integer(), nullable=True), + sa.Column('session_id', sa.Integer(), nullable=True), + sa.Column('is_user', sa.Boolean(), nullable=True), + sa.Column('response_type', sa.Enum('praise', 'insult', name='responsetypeenum'), nullable=True), + sa.Column('content', sa.Text(), nullable=False), sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(['emotion_id'], ['emotions.id'], ), - sa.ForeignKeyConstraint(['message_id'], ['messages.id'], ), + sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'), sa.PrimaryKeyConstraint('id') ) - op.create_index(op.f('ix_generated_media_id'), 'generated_media', ['id'], unique=False) + op.create_index(op.f('ix_messages_id'), 'messages', ['id'], unique=False) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_generated_media_id'), table_name='generated_media') - op.drop_table('generated_media') - op.drop_index(op.f('ix_favorites_id'), table_name='favorites') - op.drop_table('favorites') op.drop_index(op.f('ix_messages_id'), table_name='messages') op.drop_table('messages') + op.drop_index(op.f('ix_favorites_id'), table_name='favorites') + op.drop_table('favorites') op.drop_index(op.f('ix_sessions_id'), table_name='sessions') op.drop_table('sessions') - op.drop_index(op.f('ix_users_user_id'), table_name='users') + op.drop_index(op.f('ix_users_id'), table_name='users') op.drop_index(op.f('ix_users_email'), table_name='users') op.drop_table('users') # ### end Alembic commands ### diff --git a/app/models/__init__.py b/app/models/__init__.py index 3d4562c..84f1e40 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,8 +1,5 @@ from .base import Base from .user import User -from .poem import Poem from .session import Session from .message import Message -from .favorite import Favorite -from .emotion import Emotion -from .generated_media import GeneratedMedia \ No newline at end of file +from .favorite import Favorite \ No newline at end of file diff --git a/app/models/emotion.py b/app/models/emotion.py deleted file mode 100644 index e3f237e..0000000 --- a/app/models/emotion.py +++ /dev/null @@ -1,11 +0,0 @@ -from sqlalchemy import Column, Integer, String, DateTime -from sqlalchemy.orm import relationship -from datetime import datetime -from .base import Base, TimestampMixin - -class Emotion(Base, TimestampMixin): - __tablename__ = "emotions" - - id = Column(Integer, primary_key=True, index=True) - emotion = Column(String(255), nullable=False) - generated_media = relationship("GeneratedMedia", back_populates="emotion") \ No newline at end of file diff --git a/app/models/favorite.py b/app/models/favorite.py index 29fb64f..f9ecf37 100644 --- a/app/models/favorite.py +++ b/app/models/favorite.py @@ -7,8 +7,8 @@ class Favorite(Base, TimestampMixin): __tablename__ = "favorites" id = Column(Integer, primary_key=True, index=True) - message_id = Column(Integer, ForeignKey("messages.id")) - user_id = Column(Integer, ForeignKey("users.user_id")) + session_id = Column(Integer, ForeignKey("sessions.id", ondelete="CASCADE")) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE")) - message = relationship("Message", back_populates="favorites") + session = relationship("Session", back_populates="favorites") user = relationship("User", back_populates="favorites") diff --git a/app/models/generated_media.py b/app/models/generated_media.py deleted file mode 100644 index f5986cf..0000000 --- a/app/models/generated_media.py +++ /dev/null @@ -1,26 +0,0 @@ -from sqlalchemy import Column, Integer, String, Enum, Text, DateTime, ForeignKey -from sqlalchemy.orm import relationship -import enum -from .base import Base, TimestampMixin - -class MediaTypeEnum(str, enum.Enum): - IMAGE = "image" - BGM = "bgm" - -class GeneratedMedia(Base, TimestampMixin): - __tablename__ = "generated_media" - - id = Column(Integer, primary_key=True, index=True) - message_id = Column(Integer, ForeignKey("messages.id")) - emotion_id = Column(Integer, ForeignKey("emotions.id")) - media_type = Column(Enum(MediaTypeEnum), nullable=False) - media_url = Column(String(255), nullable=False) - - image_prompt = Column(Text, nullable=True) - - bgm_prompt = Column(Text, nullable=True) - bgm_duration = Column(Integer, nullable=True) - - message = relationship("Message", back_populates="generated_media") - emotion = relationship("Emotion", back_populates="generated_media") - \ No newline at end of file diff --git a/app/models/message.py b/app/models/message.py index fa18fcf..86852b0 100644 --- a/app/models/message.py +++ b/app/models/message.py @@ -12,11 +12,9 @@ class Message(Base, TimestampMixin): __tablename__ = "messages" id = Column(Integer, primary_key=True, index=True) - session_id = Column(Integer, ForeignKey("sessions.id")) - is_users = Column(Boolean, default=True) + session_id = Column(Integer, ForeignKey("sessions.id", ondelete="CASCADE")) + is_user = Column(Boolean, default=True) response_type = Column(Enum(ResponseTypeEnum)) content = Column(Text, nullable=False) - session = relationship("Session", back_populates="messages") - favorites = relationship("Favorite", back_populates="message") - generated_media = relationship("GeneratedMedia", back_populates="message") \ No newline at end of file + session = relationship("Session", back_populates="messages") \ No newline at end of file diff --git a/app/models/session.py b/app/models/session.py index e978ec9..9140669 100644 --- a/app/models/session.py +++ b/app/models/session.py @@ -9,12 +9,13 @@ class CharacterModeEnum(str, enum.Enum): bijyo = "bijyo" anger_mom = "anger_mom" -class Session(Base): +class Session(Base, TimestampMixin): __tablename__ = "sessions" id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.user_id")) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE")) character_mode = Column(Enum(CharacterModeEnum), nullable=False) user = relationship("User", back_populates="sessions") - messages = relationship("Message", back_populates="session") + messages = relationship("Message", back_populates="session", cascade="all, delete-orphan") + favorites = relationship("Favorite", back_populates="session", cascade="all, delete-orphan") diff --git a/app/models/user.py b/app/models/user.py index 719a865..30ed04e 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -1,19 +1,17 @@ from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey from sqlalchemy.orm import relationship -from datetime import datetime -from zoneinfo import ZoneInfo from .base import Base, TimestampMixin -JST = ZoneInfo("Asia/Tokyo") - -class User(Base): +class User(Base, TimestampMixin): __tablename__ = "users" - user_id = Column(Integer, primary_key=True, index=True) + id = Column(Integer, primary_key=True, index=True) password_hash = Column(String(255), nullable=False) email = Column(String(255), unique=True, index=True, nullable=False) user_name = Column(String(255), nullable=False) + is_active = Column(Boolean, default=True) + deleted_at = Column(DateTime, nullable=True) # 論理削除日時 is_admin = Column(Boolean, default=False) - - sessions = relationship("Session", back_populates="user") - favorites = relationship("Favorite", back_populates="user") + + sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan") + favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan") diff --git a/app/requirements.txt b/app/requirements.txt index 623e696..5b8f28a 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -2,5 +2,14 @@ fastapi uvicorn[standard] mysql-connector-python pymysql +email-validator +pydantic-settings cryptography -alembic \ No newline at end of file +python-jose +passlib[bcrypt] +bcrypt==3.2.0 +alembic +openai>=1.3.8,<2.0.0 +boto3 +slowapi +tenacity \ No newline at end of file diff --git a/app/schemas/emotion.py b/app/schemas/emotion.py deleted file mode 100644 index ab73d77..0000000 --- a/app/schemas/emotion.py +++ /dev/null @@ -1,16 +0,0 @@ -from pydantic import BaseModel -from datetime import datetime - -class EmotionBase(BaseModel): - emotion: str - -class EmotionCreate(EmotionBase): - pass - -class EmotionResponse(EmotionBase): - id: int - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True \ No newline at end of file diff --git a/app/schemas/favorite.py b/app/schemas/favorite.py deleted file mode 100644 index 95cf16b..0000000 --- a/app/schemas/favorite.py +++ /dev/null @@ -1,17 +0,0 @@ -from pydantic import BaseModel -from datetime import datetime - -class FavoriteBase(BaseModel): - message_id: int - user_id: int - -class FavoriteCreate(FavoriteBase): - pass - -class FavoriteResponse(FavoriteBase): - id: int - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True \ No newline at end of file diff --git a/app/schemas/generated_media.py b/app/schemas/generated_media.py deleted file mode 100644 index 63cc821..0000000 --- a/app/schemas/generated_media.py +++ /dev/null @@ -1,27 +0,0 @@ -from enum import Enum -from pydantic import BaseModel -from datetime import datetime -from typing import Optional - -class MediaType(str, Enum): - IMG = "img" - BGM = "bgm" - -class GeneratedMediaBase(BaseModel): - message_id: int - emotion_id: int - media_type: MediaType - image_prompt: Optional[str] = None - bgm_prompt: Optional[str] = None - bgm_duration: Optional[str] = None - -class GeneratedMediaCreate(GeneratedMediaBase): - pass - -class GeneratedMediaResponse(GeneratedMediaBase): - id: int - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True \ No newline at end of file diff --git a/app/schemas/message.py b/app/schemas/message.py index 8333516..cdcb911 100644 --- a/app/schemas/message.py +++ b/app/schemas/message.py @@ -1,7 +1,9 @@ from enum import Enum from pydantic import BaseModel from datetime import datetime -from typing import Optional +from typing import Optional, Literal +from schemas.user import UserResponse + class ResponseType(str, Enum): PRAISE = "praise" @@ -9,17 +11,42 @@ class ResponseType(str, Enum): class MessageBase(BaseModel): session_id: int - is_users: bool + is_user: bool response_type: Optional[ResponseType] = None content: str + + class Config: + from_attributes = True class MessageCreate(MessageBase): pass +class MessageUpdate(BaseModel): + content: str + class MessageResponse(MessageBase): id: int - created_at: datetime - updated_at: datetime + created_at: Optional[datetime] + updated_at: Optional[datetime] + user: Optional[UserResponse] = None + + class Config: + from_attributes = True + + +# 履歴の詳細表示用 +class MessageDetail(BaseModel): + message_id: int + message_text: str + sender_type: Literal["user", "ai"] + + +# 管理者投稿一覧表示 +class AdminMessageResponse(BaseModel): + user_name: str + content: str + created_at: Optional[datetime] + class Config: from_attributes = True \ No newline at end of file diff --git a/app/schemas/session.py b/app/schemas/session.py index 218a986..954797d 100644 --- a/app/schemas/session.py +++ b/app/schemas/session.py @@ -1,24 +1,66 @@ +from fastapi import Query from enum import Enum from pydantic import BaseModel from datetime import datetime -from typing import Optional +from typing import Optional, List +from schemas.message import MessageDetail + class CharacterMode(str, Enum): SABURO = "saburo" BIJYO = "bijyo" ANGER_MOM = "anger_mom" +class SessionQueryParams(BaseModel): + favorite_only: bool = False + keyword: Optional[str] = None + + class SessionBase(BaseModel): - user_id: int character_mode: CharacterMode + class SessionCreate(SessionBase): pass +class SessionUpdate(BaseModel): + character_mode: Optional[CharacterMode] = None + token: str + + class Config: + from_attributes = True + class SessionResponse(SessionBase): id: int + user_id: int created_at: datetime updated_at: datetime + class Config: + from_attributes = True + +class SessionSummaryResponse(BaseModel): + session_id: int + character_mode: CharacterMode + first_message: Optional[str] = "" + created_at: datetime + is_favorite: bool = False + + class Config: + from_attributes = True + + +class MessageSummary(BaseModel): + message_id :int + message_text: str + sender_type : str # "user" or "ai" + + +class SessionWithMessagesResponse(BaseModel): + session_id: int + created_at: datetime + updated_at: datetime + messages: List[MessageDetail] + class Config: from_attributes = True \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py index 0e3197f..c88ce9f 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -2,24 +2,49 @@ from datetime import datetime from typing import Optional +# アカウント登録・リクエスト class UserRegister(BaseModel): email: EmailStr password: str user_name: str + is_admin: Optional[bool] = False + +# ログイン・リクエスト class UserLogin(BaseModel): email: EmailStr password: str + pin_code: Optional[int] = None # 管理者ログイン時のみ使用 + + +# アカウント情報更新・リクエスト +class UserUpdate(BaseModel): + email: Optional[EmailStr] = None + user_name: Optional[str] = None + password: Optional[str] = None + + +# パスワード変更・リクエスト +class PasswordUpdate(BaseModel): + current_password: str + new_password: str + +# アカウント登録・ログイン レスポンス class TokenResponse(BaseModel): token: str + + class Config: + from_attributes = True + +# アカウント情報取得・レスポンス class UserResponse(BaseModel): - user_id: int + id: int email: EmailStr user_name: str - created_at: datetime - updated_at: datetime + is_active: bool + can_be_deleted: Optional[bool] = False # 管理画面の「削除可能」表示用 class Config: from_attributes = True \ No newline at end of file diff --git a/app/servises/__init__.py b/app/services/__init__.py similarity index 100% rename from app/servises/__init__.py rename to app/services/__init__.py diff --git a/app/services/ai/generator.py b/app/services/ai/generator.py new file mode 100644 index 0000000..4c136a7 --- /dev/null +++ b/app/services/ai/generator.py @@ -0,0 +1,166 @@ +from tenacity import retry, wait_fixed, stop_after_attempt, retry_if_exception_type +from botocore.exceptions import NoCredentialsError, ClientError, EndpointConnectionError +from openai import OpenAI, OpenAIError, APIError, RateLimitError, AuthenticationError +import base64 +import uuid +import random +import boto3 +import json +import re +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from core.config import settings +from services.ai.prompts import CHARACTER_PROMPTS +from models.session import CharacterModeEnum +from models.message import ResponseTypeEnum + + +# ============================================ +# AWS Bedrock(Claude Instantモデル)版AI応答生成 +# =========================================== + +bedrock = boto3.client("bedrock-runtime", region_name=settings.REGION) + +# 起動時 or 初回リスエスト時に接続確認 +def verify_bedrock_connection(): + try: + mgmt_client = boto3.client("bedrock", region_name=settings.REGION) + response = mgmt_client.list_foundation_models() + print(f"✅ Bedrock接続確認:利用可能モデル数 = {len(response['modelSummaries'])}") + except NoCredentialsError: + raise RuntimeError("❌ AWS認証情報が見つかりません。") + except EndpointConnectionError: + raise RuntimeError("❌ Bedrock エンドポイントに接続できません。region_name=settings.REGION を確認してください。") + except ClientError as e: + raise RuntimeError(f"❌ Bedrockクライアントエラー: {e}") + except Exception as e: + raise RuntimeError(f"❌ Bedrock接続失敗: {e}") + + +# アプリ起動時にBedrock接続確認 +verify_bedrock_connection() + +@retry( # 関数失敗時にリトライ + wait=wait_fixed(10), # 10秒まつ + stop=stop_after_attempt(3) # 最大3回試行 + ) +def generate_ai_response_via_bedrock( + character_mode: CharacterModeEnum, + user_input: str + )->tuple[str, ResponseTypeEnum]: + + if character_mode == CharacterModeEnum.saburo: + prompt_data = CHARACTER_PROMPTS["saburo"] + response_type = None + + elif character_mode == CharacterModeEnum.bijyo: + if random.randint(1, 5) == 1: + prompt_data = CHARACTER_PROMPTS["anger-mom"] + response_type = ResponseTypeEnum.insult + else: + prompt_data = CHARACTER_PROMPTS["bijyo"] + response_type = ResponseTypeEnum.praise + + else: + raise ValueError("無効なキャラクターモードです") + + # Claude形式のプロンプトを作成 + prompt = f"Human: {prompt_data['description']}\n{prompt_data['prompt']}\nユーザーの日記:{user_input}\nAssistant:" + print("Bedrockへのプロンプト>>>",prompt) + + try: + response = bedrock.invoke_model( + modelId = settings.MODEL_ID, + body = json.dumps({ + "prompt": prompt, + "max_tokens_to_sample": 128, # 最大出力トークン(1文字=約1.5トークン。50文字の出力を想定) + "temperature": 0.7, # 返答の自由さ(1に近いほど自由) + "stop_sequences": ["\n\n", "Human", "ユーザー:"] # AIの出力を終了する区切り + }), + contentType ="application/json", + accept = "application/json" + ) + response_body = json.loads(response["body"].read()) + bedrock_reply = response_body["completion"].strip() + + # 句点で切る + ai_reply = stop_generate_sentence(bedrock_reply) + + return ai_reply, response_type + + except bedrock.exceptions.AccessDeniedException: + raise HTTPException(status_code=403, detail="Bedrockへのアクセスが拒否されました") + except Exception as e: + print(f"Bedrockエラー: {e}") + raise HTTPException(status_code=500, detail=f"AI応答の生成中にエラーが発生しました: {str(e)}") + +# ------------------------ +# Bedrockの出力を句点で切る +# ------------------------- +def stop_generate_sentence(text: str)-> str: + # (。!?)までを残す + match = re.search(r'[。!?](?!.*[。!?])', text) + if match: + return text[:match.end()] + return text + + + +# ========================= +# OpenAIキー版 AI応答生成 +# ========================= + +# OpenAIクライアント初期化 +client = OpenAI(api_key=settings.openai_api_key) + +# AI応答のOpenAI呼び出し(リトライ付き) +@retry( + wait=wait_fixed(10), # 10秒待つ + stop=stop_after_attempt(3), # 最大3回まで試す + retry=retry_if_exception_type(RateLimitError) +) +# キャラモードに応じたAI返答を生成 +def generate_ai_response( + character_mode: CharacterModeEnum, + user_input: str + )->tuple[str, ResponseTypeEnum]: + + if character_mode == CharacterModeEnum.saburo: + prompt_data = CHARACTER_PROMPTS["saburo"] + response_type = None + + elif character_mode == CharacterModeEnum.bijyo: + if random.randint(1, 5) == 1: + prompt_data = CHARACTER_PROMPTS["anger-mom"] + response_type = ResponseTypeEnum.insult + else: + prompt_data = CHARACTER_PROMPTS["bijyo"] + response_type = ResponseTypeEnum.praise + + else: + raise ValueError("無効なキャラクターモードです") + + system_prompt = prompt_data["description"] + "\n" + prompt_data["prompt"] + user_diary = f"ユーザーの日記: {user_input}" + + try: + response = client.chat.completions.create( + model = settings.openai_model, + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_diary} + ] + ) + ai_reply = response.choices[0].message.content.strip() + return ai_reply, response_type + + + except AuthenticationError: + raise HTTPException(status_code=401, detail="OpenAIの認証に失敗しました。APIキーを確認してください") + except APIError as e: + print(f"OpenAIサーバでエラー: {e}") + raise HTTPException(status_code=502, detail="OpenAIサーバでエラーが発生しました") + except Exception as e: + print(f"AI生成中のエラー: {e}") + raise HTTPException(status_code=500, detail=f"AI応答の生成中にエラーが発生しました: {str(e)}") \ No newline at end of file diff --git a/app/services/ai/prompts.py b/app/services/ai/prompts.py new file mode 100644 index 0000000..8bf3aaf --- /dev/null +++ b/app/services/ai/prompts.py @@ -0,0 +1,36 @@ +# app/services/ai/characters.py + +CHARACTER_PROMPTS= { + "saburo": { + "name": "岩谷三郎", + "description":( + "岩谷三郎は、通称「さぶちゃん」として知られる54歳のおじさんです。\ + 彼はいろんな苦難を乗り越えてきた経験豊富な人物です。\ + 飄々としていますが、さっぱりとした親しみやすい人物で、よく若者の相談に乗っています。\ + 彼と話した若者は皆明るく前向きな気持ちになります。" + ), + "prompt": + ( + "あなたは岩谷三郎(さぶちゃん)というキャラクターとして振る舞ってください。" + "少し口が悪いが根は優しく、頼れる年上の人物です。" + "ユーザーの日記に対して、励ましやユーモアを交えてフランクにコメントしてください。" + "語尾は『~だな』『~だぞ』など、自然な口調にしてください。" + ) + }, + "bijyo": { + "name": "黒髪お姉さん", + "description":( + "黒髪お姉さんは、仕事ができて頭のいい姉御肌の28歳キャリアウーマンです。\ + いつも長くて綺麗な黒髪を靡かせて颯爽と歩き、周囲の目を引く魅力あふれる女性です。\ + キリッとしていますが優しく、褒めるのがとても上手です。\ + 彼女に褒められると皆、天にも昇るような気持ちで喜び、意欲を増します。" + ), + "prompt": + ( + "あなたは黒髪お姉さんというキャラクターとして振る舞ってください。" + "キリッとした雰囲気を持ちつつも、優しさと褒め上手な一面を持っています。" + "ユーザーの日記に対して、励ましやユーモアを交えてフランクにコメントしてください。" + "語尾は『〜ね』など、自然な口調にしてください。" + ) + }, +} \ No newline at end of file diff --git a/app/services/message.py b/app/services/message.py new file mode 100644 index 0000000..e269540 --- /dev/null +++ b/app/services/message.py @@ -0,0 +1,88 @@ +from fastapi import HTTPException +from sqlalchemy.orm import Session +from starlette.concurrency import run_in_threadpool +from typing import List +from models.message import Message +from models.session import Session as SessionModel +from schemas.message import MessageResponse +from services.ai.generator import generate_ai_response, generate_ai_response_via_bedrock + + +# -------------------------------- +# 日記を保存してキャラクターの返答を生成 +# --------------------------------- +async def create_message_with_ai( + db: Session, + session_id: int, + content: str, +) -> MessageResponse: + # ユーザーの日記を保存 + user_message = Message( + session_id=session_id, + content=content, + is_user=True + ) + db.add(user_message) + db.commit() + db.refresh(user_message) + print("ユーザーの日記を保存") + + # セッションを取得してモードを確認 + session = db.query(SessionModel).filter(SessionModel.id == session_id).first() + if session is None: + raise HTTPException(status_code=404, detail="チャットが見つかりません") + character_mode = session.character_mode + + # AI返答を生成 + ai_reply, response_type = await run_in_threadpool( + # generate_ai_response, # Open API用 + generate_ai_response_via_bedrock, # Amazon Bedrock用 + character_mode=character_mode, + user_input=content + ) + + # AI返答を保存 + ai_message = Message( + session_id=session_id, + content=ai_reply, + is_user=False, + response_type=response_type + ) + db.add(ai_message) + db.commit() + db.refresh(ai_message) + + # AI返答を返す + return MessageResponse.from_orm(ai_message) + +# -------------------------- +# チャット内の全メッセージを取得 +# -------------------------- +def get_messages_by_session(db: Session, session_id: int) -> List[MessageResponse]: + messages = db.query(Message).filter(Message.session_id == session_id).order_by(Message.created_at).all() + return [MessageResponse.from_orm(m) for m in messages] + + +# -------------- +# メッセージを更新 +# -------------- +def update_user_message(db: Session, message_id: int, new_content: str) -> MessageResponse: + db_message = db.query(Message).filter(Message.id == message_id, Message.is_user == True).first() + if db_message is None: + raise HTTPException(status_code=404, detail="該当するメッセージが見つかりません") # もしくはreturn None + db_message.content = new_content + db.commit() + db.refresh(db_message) + return MessageResponse.from_orm(db_message) + + +# ------------- +# メッセージ削除 +# ------------- +def delete_message(db: Session, message_id: int) -> None: + db_message = db.query(Message).filter(Message.id == message_id).first() + if db_message is None: + raise HTTPException(status_code=404, detail="該当するメッセージが見つかりません") + db.delete(db_message) + db.commit() + diff --git a/app/services/session.py b/app/services/session.py new file mode 100644 index 0000000..b2ee84e --- /dev/null +++ b/app/services/session.py @@ -0,0 +1,141 @@ +from sqlalchemy.orm import Session +from sqlalchemy import cast, String, or_ +from models.session import Session as SessionModel +from models.message import Message +from models.favorite import Favorite +from schemas.session import SessionSummaryResponse +from fastapi import HTTPException + + +# -------------- +# チャット履歴取得 +# -------------- +def get_sessions_with_first_message( + db: Session, + user_id: int, + favorite_only: bool = False, + keyword: str | None = None, + )->list[SessionSummaryResponse]: + query = db.query(SessionModel).filter(SessionModel.user_id == user_id) + + if favorite_only or keyword: + query = query.join(SessionModel.messages) + + if favorite_only: + query = query.join(SessionModel.favorites) + + #  キーワード検索 + if keyword: + query = query.join(SessionModel.messages).filter( + or_( + Message.content.ilike(f"%{keyword}%"), + cast(SessionModel.created_at, String).ilike(f"%{keyword}%") + ) + ) + + sessions = query.distinct().all() + + result = [] + for session in sessions: + first_message = ( + db.query(Message) + .filter(Message.session_id == session.id) + .order_by(Message.created_at.asc()) + .first() + ) + + is_fav = db.query(Favorite).filter( + Favorite.session_id == session.id, + Favorite.user_id == user_id + ).first() is not None + + result.append(SessionSummaryResponse( + session_id=session.id, + character_mode=session.character_mode, + first_message=first_message.content[:20] if first_message else "", + created_at=session.created_at, + is_favorite=is_fav + )) + return result + + +# ------------------ +# 管理者用投稿内容一覧 +# ------------------ +def get_all_sessions_with_first_message( + db:Session, +)-> list[dict]: + sessions = db.query(SessionModel).join(SessionModel.user).all() + result = [] + for session in sessions: + first_user_message = ( + db.query(Message) + .filter( + Message.session_id == session.id, + Message.is_user == True + ) + .order_by(Message.created_at.asc()) + .first() + ) + + if first_user_message: + result.append({ + "user_name": session.user.user_name, + "content": first_user_message.content, + "created_at": first_user_message.created_at or session.created_at, + }) + return result + + +# -------- +# 履歴削除 +# --------- +def delete_session( + db: Session, + session_id: int, + user_id: int +)-> bool: + session = db.query(SessionModel).filter( + SessionModel.id == session_id, + SessionModel.user_id == user_id + ).first() + + if not session: + return False + + db.delete(session) # cascade設定によりメッセージも削除される + db.commit() + return True + + +# ---------------- +# お気に入りのトグル +# ----------------- +def toggle_favorite_session( + db: Session, + session_id: int, + user_id: int +)->dict: + # セッションの存在を確認 + session = db.query(SessionModel).filter(SessionModel.id == session_id).first() + if not session: + raise HTTPException(status_code=404, detail= "チャットが見つかりません") + + # お気に入りの状態チェック + favorite = db.query(Favorite).filter_by( + session_id = session_id, + user_id = user_id + ).first() + + if favorite: + db.delete(favorite) + db.commit() + return{"message": "お気に入りを解除しました", "is_favorite": False} + else: + new_fav = Favorite( + session_id = session_id, + user_id = user_id + ) + db.add(new_fav) + db.commit() + return {"message": "お気に入りを追加しました", "is_favorite": True} diff --git a/app/utils/auth.py b/app/utils/auth.py new file mode 100644 index 0000000..eed73f1 --- /dev/null +++ b/app/utils/auth.py @@ -0,0 +1,66 @@ +from sqlalchemy.orm import Session +from datetime import datetime, timedelta +from jose import jwt, JWTError +from passlib.context import CryptContext +from fastapi import HTTPException, Depends +from models.user import User +from core.database import get_db +from fastapi.security import OAuth2PasswordBearer +from core.config import settings + + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/login") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + +SECRET_KEY = settings.SECRET_KEY +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + +def create_access_token(data: dict, expires_delta: timedelta = None) -> str: + to_encode = data.copy() + expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def get_current_user(db: Session = Depends(get_db), token: str = Depends(oauth2_scheme)) -> User: + credentials_exception = HTTPException( + status_code=401, + detail="認証情報が無効です", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + print("JWT payload:", payload) + user_id: str = payload.get("sub") + if user_id is None: + raise credentials_exception + except JWTError as e: + print("JWTError:", e) + raise credentials_exception + + user = db.query(User).filter(User.id == int(user_id)).first() + if user is None: + raise credentials_exception + elif not user.is_active: + raise HTTPException(status_code=403, detail="このアカウントは無効です") + return user + + +def get_current_admin_user( + db: Session = Depends(get_db), + token: str = Depends(oauth2_scheme) +)-> User: + user = get_current_user(db, token) + if not user.is_admin: + raise HTTPException(status_code=400, detail="管理者アカウントではありません") + return user diff --git a/docker-compose.yml b/docker-compose.yml index de3d0d3..73e176e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,10 +14,6 @@ services: ports: - "8000:8000" - # ホスト側の ./app ディレクトリをコンテナの /app にマウント(ソースコードの同期) - volumes: - - ./app:/app - # 環境変数を定義した .env ファイルを読み込む env_file: - .env @@ -29,6 +25,13 @@ services: # コンテナ起動時の実行コマンド(開発用:ホットリロードを有効にして uvicorn を起動) command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload + environment: + - PYTHONPATH=/app + + # ホスト側の ./app ディレクトリをコンテナの /app にマウント(ソースコードの同期) + volumes: + - ./app:/app + # MySQL データベースのサービス定義 db: # 使用する公式 MySQL イメージのバージョンを指定(8.0)