From 40266cc6e53c1e8d16936a05059dd43fa6239bc7 Mon Sep 17 00:00:00 2001 From: imnyang Date: Sat, 13 Sep 2025 16:18:28 +0900 Subject: [PATCH] a --- .github/workflows/black-check.yml | 68 +++++ .github/workflows/comment-command.yml | 118 ++++++++ .github/workflows/issue_or_pr.yml.disabled | 40 +++ .github/workflows/pr-merge-status.yml | 55 ++++ .github/workflows/pr-review-status.yml | 66 +++++ .gitignore | 232 ++++++++++++++++ .vscode/settings.json | 4 + Backend/__init__.py | 12 + Backend/core/security.py | 78 ++++++ Backend/router/endpoints/avatar.py | 49 ++++ Backend/router/endpoints/diary.py | 102 +++++++ Backend/router/endpoints/friendship.py | 81 ++++++ Backend/router/endpoints/guestbook.py | 63 +++++ Backend/router/endpoints/letter.py | 79 ++++++ Backend/router/endpoints/photo.py | 117 ++++++++ Backend/router/endpoints/room.py | 110 ++++++++ Backend/router/endpoints/store.py | 48 ++++ Backend/router/endpoints/user.py | 116 ++++++++ Backend/router/router.py | 21 ++ Backend/schemas/__init__.py | 0 Backend/schemas/avatar.py | 106 ++++++++ Backend/schemas/diary.py | 109 ++++++++ Backend/schemas/friendship.py | 59 ++++ Backend/schemas/guestbook.py | 46 ++++ Backend/schemas/letter.py | 47 ++++ Backend/schemas/photo.py | 117 ++++++++ Backend/schemas/room.py | 296 +++++++++++++++++++++ Backend/schemas/user.py | 93 +++++++ Backend/services/__init__.py | 0 Backend/services/avatar_service.py | 74 ++++++ Backend/services/diary_service.py | 144 ++++++++++ Backend/services/friendship_service.py | 142 ++++++++++ Backend/services/guestbook_service.py | 110 ++++++++ Backend/services/letter_service.py | 81 ++++++ Backend/services/photo_service.py | 150 +++++++++++ Backend/services/room_service.py | 149 +++++++++++ Backend/services/store_service.py | 33 +++ Backend/services/user_service.py | 178 +++++++++++++ Backend/tests/__init__.py | 0 Backend/tests/conftest.py | 81 ++++++ Backend/tests/test_avatar.py | 71 +++++ Backend/tests/test_diary.py | 52 ++++ Backend/tests/test_friendship.py | 45 ++++ Backend/tests/test_guestbook.py | 38 +++ Backend/tests/test_letter.py | 32 +++ Backend/tests/test_photo.py | 94 +++++++ Backend/tests/test_room.py | 161 +++++++++++ Backend/tests/test_user.py | 68 +++++ Backend/utils/__init__.py | 0 Backend/utils/db.py | 53 ++++ Backend/utils/default_queries.py | 168 ++++++++++++ Backend/utils/email_processor.py | 75 ++++++ Backend/utils/image_processor.py | 179 +++++++++++++ Backend/utils/queries/__init__.py | 0 Backend/utils/queries/avatar.py | 25 ++ Backend/utils/queries/diary.py | 71 +++++ Backend/utils/queries/friendship.py | 89 +++++++ Backend/utils/queries/guestbook.py | 40 +++ Backend/utils/queries/room.py | 81 ++++++ Backend/utils/queries/user.py | 61 +++++ Backend/utils/run_server.py | 64 +++++ README.md | 49 ++++ api.json | 1 + public/avatar/교복상의.png | Bin 0 -> 294 bytes public/avatar/교복조끼상의.png | Bin 0 -> 297 bytes public/avatar/교복조끼하의남.png | Bin 0 -> 183 bytes public/avatar/교복조끼하의여.png | Bin 0 -> 165 bytes public/avatar/교복하의남.png | Bin 0 -> 186 bytes public/avatar/교복하의여.png | Bin 0 -> 165 bytes public/avatar/남자.png | Bin 0 -> 441 bytes public/avatar/동잠상의.png | Bin 0 -> 270 bytes public/avatar/멜빵바지상의 .png | Bin 0 -> 309 bytes public/avatar/멜빵바지상의.png | Bin 0 -> 309 bytes public/avatar/멜빵치마상의.png | Bin 0 -> 303 bytes public/avatar/무지개맨투맨상의.png | Bin 0 -> 297 bytes public/avatar/산타상의.png | Bin 0 -> 288 bytes public/avatar/산타하의.png | Bin 0 -> 189 bytes public/avatar/여자.png | Bin 0 -> 549 bytes public/avatar/청바지하의.png | Bin 0 -> 189 bytes public/funiture/검정 노트북1-0.png | Bin 0 -> 244 bytes public/funiture/검정 노트북1-180.png | Bin 0 -> 251 bytes public/funiture/검정 노트북1-270.png | Bin 0 -> 245 bytes public/funiture/검정 노트북1-90.png | Bin 0 -> 264 bytes public/funiture/검정 노트북2-0.png | Bin 0 -> 244 bytes public/funiture/검정 노트북2-180.png | Bin 0 -> 260 bytes public/funiture/검정 노트북2-270.png | Bin 0 -> 248 bytes public/funiture/검정 노트북2-90.png | Bin 0 -> 254 bytes public/funiture/검정 노트북3-0.png | Bin 0 -> 244 bytes public/funiture/검정 노트북3-180.png | Bin 0 -> 254 bytes public/funiture/검정 노트북3-270.png | Bin 0 -> 250 bytes public/funiture/검정 노트북3-90.png | Bin 0 -> 259 bytes public/funiture/나무 탁자-90.png | Bin 0 -> 511 bytes public/funiture/나무탁자-0.png | Bin 0 -> 506 bytes public/funiture/노트북1-0.png | Bin 0 -> 303 bytes public/funiture/노트북1-180.png | Bin 0 -> 269 bytes public/funiture/노트북1-270.png | Bin 0 -> 266 bytes public/funiture/노트북1-90.png | Bin 0 -> 285 bytes public/funiture/노트북2-0.png | Bin 0 -> 303 bytes public/funiture/노트북2-180.png | Bin 0 -> 288 bytes public/funiture/노트북2-270.png | Bin 0 -> 270 bytes public/funiture/노트북2-90.png | Bin 0 -> 285 bytes public/funiture/노트북3-0.png | Bin 0 -> 303 bytes public/funiture/노트북3-180.png | Bin 0 -> 296 bytes public/funiture/노트북3-270.png | Bin 0 -> 263 bytes public/funiture/노트북3-90.png | Bin 0 -> 285 bytes public/funiture/녹색 침대-0.png | Bin 0 -> 674 bytes public/funiture/녹색 침대-180.png | Bin 0 -> 591 bytes public/funiture/녹색 침대-270.png | Bin 0 -> 616 bytes public/funiture/녹색 침대-90.png | Bin 0 -> 677 bytes public/funiture/녹색 탁자.png | Bin 0 -> 427 bytes public/funiture/미니 냉장고-0.png | Bin 0 -> 488 bytes public/funiture/미니 냉장고-180.png | Bin 0 -> 365 bytes public/funiture/미니 냉장고-90.png | Bin 0 -> 480 bytes public/funiture/박스-0.png | Bin 0 -> 628 bytes public/funiture/박스-90.png | Bin 0 -> 612 bytes public/funiture/분홍색 탁자.png | Bin 0 -> 423 bytes public/funiture/빨간 침대-0.png | Bin 0 -> 695 bytes public/funiture/빨간 침대-180.png | Bin 0 -> 650 bytes public/funiture/빨간 침대-270.png | Bin 0 -> 618 bytes public/funiture/빨간 침대-90.png | Bin 0 -> 692 bytes public/funiture/선반-0.png | Bin 0 -> 781 bytes public/funiture/선반-180.png | Bin 0 -> 497 bytes public/funiture/선반-270.png | Bin 0 -> 509 bytes public/funiture/선반-90.png | Bin 0 -> 734 bytes public/funiture/소파-0.png | Bin 0 -> 865 bytes public/funiture/소파-180.png | Bin 0 -> 670 bytes public/funiture/소파-270.png | Bin 0 -> 657 bytes public/funiture/소파-90.png | Bin 0 -> 891 bytes public/funiture/쓰레기통닫힘.png | Bin 0 -> 345 bytes public/funiture/쓰레기통열림.png | Bin 0 -> 595 bytes public/funiture/어항-0.png | Bin 0 -> 824 bytes public/funiture/어항-180.png | Bin 0 -> 842 bytes public/funiture/어항-270.png | Bin 0 -> 819 bytes public/funiture/어항-90.png | Bin 0 -> 816 bytes public/funiture/음료 냉장고-0.png | Bin 0 -> 722 bytes public/funiture/음료 냉장고-180.png | Bin 0 -> 261 bytes public/funiture/음료 냉장고-270.png | Bin 0 -> 261 bytes public/funiture/음료 냉장고-90.png | Bin 0 -> 537 bytes public/funiture/의자-0.png | Bin 0 -> 690 bytes public/funiture/의자-180.png | Bin 0 -> 686 bytes public/funiture/의자-270.png | Bin 0 -> 630 bytes public/funiture/의자-90.png | Bin 0 -> 760 bytes public/funiture/작은 선반-0.png | Bin 0 -> 702 bytes public/funiture/작은 선반-180.png | Bin 0 -> 558 bytes public/funiture/작은 선반-270.png | Bin 0 -> 545 bytes public/funiture/작은 선반-90.png | Bin 0 -> 601 bytes public/funiture/작은 식물.png | Bin 0 -> 922 bytes public/funiture/주황 침대-0.png | Bin 0 -> 700 bytes public/funiture/주황 침대-180.png | Bin 0 -> 618 bytes public/funiture/주황 침대-270.png | Bin 0 -> 659 bytes public/funiture/주황 침대-90.png | Bin 0 -> 697 bytes public/funiture/책장-0.png | Bin 0 -> 1040 bytes public/funiture/책장-180.png | Bin 0 -> 515 bytes public/funiture/책장-270.png | Bin 0 -> 503 bytes public/funiture/책장-90.png | Bin 0 -> 899 bytes public/funiture/큰 식물.png | Bin 0 -> 1138 bytes public/funiture/티비-0.png | Bin 0 -> 521 bytes public/funiture/티비-180.png | Bin 0 -> 416 bytes public/funiture/티비-270.png | Bin 0 -> 391 bytes public/funiture/티비-90.png | Bin 0 -> 376 bytes public/funiture/파란 침대-0.png | Bin 0 -> 680 bytes public/funiture/파란 침대-180.png | Bin 0 -> 595 bytes public/funiture/파란 침대-270.png | Bin 0 -> 621 bytes public/funiture/파란 침대-90.png | Bin 0 -> 673 bytes public/funiture/파란색 탁자.png | Bin 0 -> 423 bytes public/funiture/회색 탁자.png | Bin 0 -> 422 bytes public/funiture/흰 노트북1-0.png | Bin 0 -> 265 bytes public/funiture/흰 노트북1-180.png | Bin 0 -> 235 bytes public/funiture/흰 노트북1-270.png | Bin 0 -> 237 bytes public/funiture/흰 노트북1-90.png | Bin 0 -> 265 bytes public/funiture/흰 노트북2-0.png | Bin 0 -> 265 bytes public/funiture/흰 노트북2-180.png | Bin 0 -> 242 bytes public/funiture/흰 노트북2-270.png | Bin 0 -> 240 bytes public/funiture/흰 노트북2-90.png | Bin 0 -> 265 bytes public/funiture/흰 노트북3-0.png | Bin 0 -> 265 bytes public/funiture/흰 노트북3-180.png | Bin 0 -> 231 bytes public/funiture/흰 노트북3-270.png | Bin 0 -> 233 bytes public/funiture/흰 노트북3-90.png | Bin 0 -> 265 bytes public/funiture/흰색 선반-0.png | Bin 0 -> 749 bytes public/funiture/흰색 선반-180.png | Bin 0 -> 490 bytes public/funiture/흰색 선반-270.png | Bin 0 -> 493 bytes public/funiture/흰색 선반-90.png | Bin 0 -> 718 bytes public/funiture/흰색 작은 선반-0.png | Bin 0 -> 663 bytes public/funiture/흰색 작은 선반-180.png | Bin 0 -> 548 bytes public/funiture/흰색 작은 선반-270.png | Bin 0 -> 543 bytes public/funiture/흰색 작은 선반-90.png | Bin 0 -> 607 bytes public/funiture/흰색 탁자.png | Bin 0 -> 428 bytes public/room/room_1.png | Bin 0 -> 2846 bytes public/room/room_2.png | Bin 0 -> 3958 bytes pytest.ini | 3 + requirements.txt | 28 ++ 191 files changed, 5022 insertions(+) create mode 100644 .github/workflows/black-check.yml create mode 100644 .github/workflows/comment-command.yml create mode 100644 .github/workflows/issue_or_pr.yml.disabled create mode 100644 .github/workflows/pr-merge-status.yml create mode 100644 .github/workflows/pr-review-status.yml create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 Backend/__init__.py create mode 100644 Backend/core/security.py create mode 100644 Backend/router/endpoints/avatar.py create mode 100644 Backend/router/endpoints/diary.py create mode 100644 Backend/router/endpoints/friendship.py create mode 100644 Backend/router/endpoints/guestbook.py create mode 100644 Backend/router/endpoints/letter.py create mode 100644 Backend/router/endpoints/photo.py create mode 100644 Backend/router/endpoints/room.py create mode 100644 Backend/router/endpoints/store.py create mode 100644 Backend/router/endpoints/user.py create mode 100644 Backend/router/router.py create mode 100644 Backend/schemas/__init__.py create mode 100644 Backend/schemas/avatar.py create mode 100644 Backend/schemas/diary.py create mode 100644 Backend/schemas/friendship.py create mode 100644 Backend/schemas/guestbook.py create mode 100644 Backend/schemas/letter.py create mode 100644 Backend/schemas/photo.py create mode 100644 Backend/schemas/room.py create mode 100644 Backend/schemas/user.py create mode 100644 Backend/services/__init__.py create mode 100644 Backend/services/avatar_service.py create mode 100644 Backend/services/diary_service.py create mode 100644 Backend/services/friendship_service.py create mode 100644 Backend/services/guestbook_service.py create mode 100644 Backend/services/letter_service.py create mode 100644 Backend/services/photo_service.py create mode 100644 Backend/services/room_service.py create mode 100644 Backend/services/store_service.py create mode 100644 Backend/services/user_service.py create mode 100644 Backend/tests/__init__.py create mode 100644 Backend/tests/conftest.py create mode 100644 Backend/tests/test_avatar.py create mode 100644 Backend/tests/test_diary.py create mode 100644 Backend/tests/test_friendship.py create mode 100644 Backend/tests/test_guestbook.py create mode 100644 Backend/tests/test_letter.py create mode 100644 Backend/tests/test_photo.py create mode 100644 Backend/tests/test_room.py create mode 100644 Backend/tests/test_user.py create mode 100644 Backend/utils/__init__.py create mode 100644 Backend/utils/db.py create mode 100644 Backend/utils/default_queries.py create mode 100644 Backend/utils/email_processor.py create mode 100644 Backend/utils/image_processor.py create mode 100644 Backend/utils/queries/__init__.py create mode 100644 Backend/utils/queries/avatar.py create mode 100644 Backend/utils/queries/diary.py create mode 100644 Backend/utils/queries/friendship.py create mode 100644 Backend/utils/queries/guestbook.py create mode 100644 Backend/utils/queries/room.py create mode 100644 Backend/utils/queries/user.py create mode 100644 Backend/utils/run_server.py create mode 100644 README.md create mode 100644 api.json create mode 100644 public/avatar/교복상의.png create mode 100644 public/avatar/교복조끼상의.png create mode 100644 public/avatar/교복조끼하의남.png create mode 100644 public/avatar/교복조끼하의여.png create mode 100644 public/avatar/교복하의남.png create mode 100644 public/avatar/교복하의여.png create mode 100644 public/avatar/남자.png create mode 100644 public/avatar/동잠상의.png create mode 100644 public/avatar/멜빵바지상의 .png create mode 100644 public/avatar/멜빵바지상의.png create mode 100644 public/avatar/멜빵치마상의.png create mode 100644 public/avatar/무지개맨투맨상의.png create mode 100644 public/avatar/산타상의.png create mode 100644 public/avatar/산타하의.png create mode 100644 public/avatar/여자.png create mode 100644 public/avatar/청바지하의.png create mode 100644 public/funiture/검정 노트북1-0.png create mode 100644 public/funiture/검정 노트북1-180.png create mode 100644 public/funiture/검정 노트북1-270.png create mode 100644 public/funiture/검정 노트북1-90.png create mode 100644 public/funiture/검정 노트북2-0.png create mode 100644 public/funiture/검정 노트북2-180.png create mode 100644 public/funiture/검정 노트북2-270.png create mode 100644 public/funiture/검정 노트북2-90.png create mode 100644 public/funiture/검정 노트북3-0.png create mode 100644 public/funiture/검정 노트북3-180.png create mode 100644 public/funiture/검정 노트북3-270.png create mode 100644 public/funiture/검정 노트북3-90.png create mode 100644 public/funiture/나무 탁자-90.png create mode 100644 public/funiture/나무탁자-0.png create mode 100644 public/funiture/노트북1-0.png create mode 100644 public/funiture/노트북1-180.png create mode 100644 public/funiture/노트북1-270.png create mode 100644 public/funiture/노트북1-90.png create mode 100644 public/funiture/노트북2-0.png create mode 100644 public/funiture/노트북2-180.png create mode 100644 public/funiture/노트북2-270.png create mode 100644 public/funiture/노트북2-90.png create mode 100644 public/funiture/노트북3-0.png create mode 100644 public/funiture/노트북3-180.png create mode 100644 public/funiture/노트북3-270.png create mode 100644 public/funiture/노트북3-90.png create mode 100644 public/funiture/녹색 침대-0.png create mode 100644 public/funiture/녹색 침대-180.png create mode 100644 public/funiture/녹색 침대-270.png create mode 100644 public/funiture/녹색 침대-90.png create mode 100644 public/funiture/녹색 탁자.png create mode 100644 public/funiture/미니 냉장고-0.png create mode 100644 public/funiture/미니 냉장고-180.png create mode 100644 public/funiture/미니 냉장고-90.png create mode 100644 public/funiture/박스-0.png create mode 100644 public/funiture/박스-90.png create mode 100644 public/funiture/분홍색 탁자.png create mode 100644 public/funiture/빨간 침대-0.png create mode 100644 public/funiture/빨간 침대-180.png create mode 100644 public/funiture/빨간 침대-270.png create mode 100644 public/funiture/빨간 침대-90.png create mode 100644 public/funiture/선반-0.png create mode 100644 public/funiture/선반-180.png create mode 100644 public/funiture/선반-270.png create mode 100644 public/funiture/선반-90.png create mode 100644 public/funiture/소파-0.png create mode 100644 public/funiture/소파-180.png create mode 100644 public/funiture/소파-270.png create mode 100644 public/funiture/소파-90.png create mode 100644 public/funiture/쓰레기통닫힘.png create mode 100644 public/funiture/쓰레기통열림.png create mode 100644 public/funiture/어항-0.png create mode 100644 public/funiture/어항-180.png create mode 100644 public/funiture/어항-270.png create mode 100644 public/funiture/어항-90.png create mode 100644 public/funiture/음료 냉장고-0.png create mode 100644 public/funiture/음료 냉장고-180.png create mode 100644 public/funiture/음료 냉장고-270.png create mode 100644 public/funiture/음료 냉장고-90.png create mode 100644 public/funiture/의자-0.png create mode 100644 public/funiture/의자-180.png create mode 100644 public/funiture/의자-270.png create mode 100644 public/funiture/의자-90.png create mode 100644 public/funiture/작은 선반-0.png create mode 100644 public/funiture/작은 선반-180.png create mode 100644 public/funiture/작은 선반-270.png create mode 100644 public/funiture/작은 선반-90.png create mode 100644 public/funiture/작은 식물.png create mode 100644 public/funiture/주황 침대-0.png create mode 100644 public/funiture/주황 침대-180.png create mode 100644 public/funiture/주황 침대-270.png create mode 100644 public/funiture/주황 침대-90.png create mode 100644 public/funiture/책장-0.png create mode 100644 public/funiture/책장-180.png create mode 100644 public/funiture/책장-270.png create mode 100644 public/funiture/책장-90.png create mode 100644 public/funiture/큰 식물.png create mode 100644 public/funiture/티비-0.png create mode 100644 public/funiture/티비-180.png create mode 100644 public/funiture/티비-270.png create mode 100644 public/funiture/티비-90.png create mode 100644 public/funiture/파란 침대-0.png create mode 100644 public/funiture/파란 침대-180.png create mode 100644 public/funiture/파란 침대-270.png create mode 100644 public/funiture/파란 침대-90.png create mode 100644 public/funiture/파란색 탁자.png create mode 100644 public/funiture/회색 탁자.png create mode 100644 public/funiture/흰 노트북1-0.png create mode 100644 public/funiture/흰 노트북1-180.png create mode 100644 public/funiture/흰 노트북1-270.png create mode 100644 public/funiture/흰 노트북1-90.png create mode 100644 public/funiture/흰 노트북2-0.png create mode 100644 public/funiture/흰 노트북2-180.png create mode 100644 public/funiture/흰 노트북2-270.png create mode 100644 public/funiture/흰 노트북2-90.png create mode 100644 public/funiture/흰 노트북3-0.png create mode 100644 public/funiture/흰 노트북3-180.png create mode 100644 public/funiture/흰 노트북3-270.png create mode 100644 public/funiture/흰 노트북3-90.png create mode 100644 public/funiture/흰색 선반-0.png create mode 100644 public/funiture/흰색 선반-180.png create mode 100644 public/funiture/흰색 선반-270.png create mode 100644 public/funiture/흰색 선반-90.png create mode 100644 public/funiture/흰색 작은 선반-0.png create mode 100644 public/funiture/흰색 작은 선반-180.png create mode 100644 public/funiture/흰색 작은 선반-270.png create mode 100644 public/funiture/흰색 작은 선반-90.png create mode 100644 public/funiture/흰색 탁자.png create mode 100644 public/room/room_1.png create mode 100644 public/room/room_2.png create mode 100644 pytest.ini create mode 100644 requirements.txt diff --git a/.github/workflows/black-check.yml b/.github/workflows/black-check.yml new file mode 100644 index 0000000..cac0448 --- /dev/null +++ b/.github/workflows/black-check.yml @@ -0,0 +1,68 @@ +name: Black Formatter Check + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + black-check: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Get commit messages + id: commits + run: | + messages=$(gh pr view ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --json commits --jq '.commits[].message') + if echo "$messages" | grep -q "chore: format code with black"; then + echo "skip=true" >> $GITHUB_OUTPUT + else + echo "skip=false" >> $GITHUB_OUTPUT + fi + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + + - name: Set up Python 3.x + if: steps.commits.outputs.skip == 'false' + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + - name: Install black + if: steps.commits.outputs.skip == 'false' + run: pip install black + + - name: Run black --check + if: steps.commits.outputs.skip == 'false' + id: black-check + run: | + black . --check + continue-on-error: true + + - name: Comment on PR (success) + if: steps.commits.outputs.skip == 'false' && steps.black-check.outcome == 'success' + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: "> [!NOTE]\n> 🎉 Black 포매팅 검사가 성공적으로 완료되었어요.\n`/review`로 리뷰를 요청하실 수 있어요." + }) + + - name: Comment on PR (failure) + if: steps.commits.outputs.skip == 'false' && steps.black-check.outcome == 'failure' + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: "> [!WARNING]\n> 😢 코드 포매팅이 필요해요.\n`black .` 명령어로 코드를 포매팅하거나 `/format` 명령어를 사용해 주세요." + }) diff --git a/.github/workflows/comment-command.yml b/.github/workflows/comment-command.yml new file mode 100644 index 0000000..d6dccde --- /dev/null +++ b/.github/workflows/comment-command.yml @@ -0,0 +1,118 @@ +name: PR Comment Commands + +on: + issue_comment: + types: [created] + +jobs: + format: + if: > + startsWith(github.event.comment.body, '/format') && + github.event.issue.pull_request != null && + !contains(github.event.comment.body, '[!NOTE]') + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - name: Checkout PR branch + uses: actions/checkout@v3 + with: + ref: refs/pull/${{ github.event.issue.number }}/head + token: ${{ secrets.GH_TOKEN }} + + - name: Set up Python 3.x + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + - name: Install black + run: pip install black + + - name: Run black + run: black . + + - name: Get PR branch name + id: get_pr_branch + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + core.setOutput('branch', pr.data.head.ref); + + - name: Commit & Push if changed + env: + PR_BRANCH: ${{ steps.get_pr_branch.outputs.branch }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + if [ -n "$(git status --porcelain)" ]; then + git add . + git commit -m "chore: format code with black" + git push origin HEAD:${PR_BRANCH} + else + echo "No changes to commit." + fi + + - name: Comment on PR + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: "> [!NOTE]\n> 코드 포매팅이 완료되었어요." + }) + + review: + if: > + startsWith(github.event.comment.body, '/review') && + github.event.issue.pull_request != null && + !contains(github.event.comment.body, '[!NOTE]') + runs-on: ubuntu-latest + + permissions: + contents: write + pull-requests: write + + steps: + - name: Label PR as "review required" + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ["status: review required"] + }) + - name: Request reviewers + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + const submitter = pr.data.head.user.login; + const reviewers = ["norhu1130", "janghanul090801"].filter(r => r !== submitter); + if (reviewers.length > 0) { + await github.rest.pulls.requestReviewers({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + reviewers + }); + } diff --git a/.github/workflows/issue_or_pr.yml.disabled b/.github/workflows/issue_or_pr.yml.disabled new file mode 100644 index 0000000..e1d30db --- /dev/null +++ b/.github/workflows/issue_or_pr.yml.disabled @@ -0,0 +1,40 @@ +name: Issue or PR Label + +on: + pull_request: + types: [opened] + issues: + types: [opened] + +jobs: + add-label: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Check if issue or PR is opened + id: check + run: | + if [[ "${{ github.event_name }}" == "issues" ]]; then + echo "type=issue" >> $GITHUB_OUTPUT + elif [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "type=pull_request" >> $GITHUB_OUTPUT + else + echo "type=unknown" >> $GITHUB_OUTPUT + fi + + - name: Add label to issue or PR + if: steps.check.outputs.type == 'issue' || steps.check.outputs.type == 'pull_request' + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + const label = steps.check.outputs.type === 'issue' ? 'type: issue' : 'type: pull request'; + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: [label] + }); \ No newline at end of file diff --git a/.github/workflows/pr-merge-status.yml b/.github/workflows/pr-merge-status.yml new file mode 100644 index 0000000..6b52705 --- /dev/null +++ b/.github/workflows/pr-merge-status.yml @@ -0,0 +1,55 @@ +name: PR Status - When Merged + +on: + pull_request: + types: [closed] + +jobs: + handle-merge: + # PR이 closed 되었을 때, merged 여부를 검사하여 merged=true인 경우에만 실행 + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Update status to done + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + const prNumber = context.payload.pull_request.number; + + const labelsToRemove = [ + "status: in progress", + "status: review required", + "status: change required", + "status: waiting merge" + ]; + + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + + for (const labelName of labelsToRemove) { + if (currentLabels.some(l => l.name === labelName)) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: labelName + }).catch(err => { + if (err.status !== 404) throw err; + }); + } + } + + // 병합이 완료된 PR에 "status: done" 라벨을 부착 + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: ["status: done"] + }); \ No newline at end of file diff --git a/.github/workflows/pr-review-status.yml b/.github/workflows/pr-review-status.yml new file mode 100644 index 0000000..bfa0482 --- /dev/null +++ b/.github/workflows/pr-review-status.yml @@ -0,0 +1,66 @@ +name: PR Status - Review Outcome + +on: + pull_request_review: + types: [submitted] + +jobs: + handle-review-outcome: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Handle review outcomes + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + const prNumber = context.payload.pull_request.number; + + const labelInProgress = "status: in progress"; + const labelReviewReq = "status: review required"; + const labelChangeReq = "status: change required"; + const labelWaitingMerge = "status: waiting merge"; + + const labelsToRemove = [ + labelInProgress, + labelReviewReq, + labelChangeReq, + labelWaitingMerge + ]; + + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + + for (const labelName of labelsToRemove) { + if (currentLabels.some(l => l.name === labelName)) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: labelName + }).catch(err => { + if (err.status !== 404) throw err; + }); + } + } + + if (context.payload.review.state === "approved") { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: [labelWaitingMerge] + }); + } else if (context.payload.review.state === "changes_requested") { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: [labelChangeReq] + }); + } \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e0a72e --- /dev/null +++ b/.gitignore @@ -0,0 +1,232 @@ +__pycache__# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Created by https://www.toptal.com/developers/gitignore/api/macos +# Edit at https://www.toptal.com/developers/gitignore?templates=macos + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +# End of https://www.toptal.com/developers/gitignore/api/macos diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ba2a6c0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:system", + "python-envs.pythonProjects": [] +} \ No newline at end of file diff --git a/Backend/__init__.py b/Backend/__init__.py new file mode 100644 index 0000000..5d3f802 --- /dev/null +++ b/Backend/__init__.py @@ -0,0 +1,12 @@ +from .router.router import router +import sys +from pathlib import Path +from Backend.utils.run_server import init_FastAPI + +# 프로젝트 루트를 Python 경로에 추가 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +app = init_FastAPI() + +app.include_router(router) diff --git a/Backend/core/security.py b/Backend/core/security.py new file mode 100644 index 0000000..c5866cf --- /dev/null +++ b/Backend/core/security.py @@ -0,0 +1,78 @@ +from jose import jwt, JWTError +from datetime import datetime, timedelta +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from typing import Optional + +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + +SECRET_KEY = "WIXYAhAfU6tLOloxqHgI4thAAo6kshkK" +ALGORITHM = "HS256" + +security = HTTPBearer() + + +def create_access_token(data: dict): + to_encode = data.copy() + expire = datetime.now() + 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 verify_token(token: str) -> Optional[dict]: + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except JWTError: + return None + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), +): + from ..schemas.user import User + from ..utils.db import fetch_one + + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = verify_token(credentials.credentials) + if payload is None: + raise credentials_exception + + username: str = payload.get("sub") + if username is None: + raise credentials_exception + + except JWTError: + raise credentials_exception + + # 데이터베이스에서 사용자 조회 + user_row = await fetch_one("SELECT * FROM users WHERE username = ?", (username,)) + + if user_row is None: + raise credentials_exception + + user = User( + id=user_row["id"], + username=user_row["username"], + email=user_row["email"], + password_hash=user_row["password_hash"], + salt=user_row["salt"], + created_at=user_row["created_at"], + profile_image_path=user_row["profile_image_path"], + is_active=user_row["is_active"], + ) + + return user + + +async def get_current_active_user(current_user=Depends(get_current_user)): + if not current_user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user diff --git a/Backend/router/endpoints/avatar.py b/Backend/router/endpoints/avatar.py new file mode 100644 index 0000000..ee4b48e --- /dev/null +++ b/Backend/router/endpoints/avatar.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, HTTPException, Depends +from ...schemas.avatar import ( + AvatarUpdate, + AvatarResponse, + AvatarOptions, +) +from ...services.avatar_service import AvatarService +from ...core.security import get_current_user +from ...schemas.user import User + +router = APIRouter(prefix="/avatar", tags=["avatar"]) +avatar_service = AvatarService() + + +@router.get("", response_model=AvatarResponse) +async def get_my_avatar( + current_user: User = Depends(get_current_user), +) -> AvatarResponse: + try: + avatar = await avatar_service.get_or_create_avatar(current_user.id) + return avatar.to_response() + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.put("", response_model=AvatarResponse) +async def update_avatar( + avatar_data: AvatarUpdate, current_user: User = Depends(get_current_user) +) -> AvatarResponse: + try: + avatar = await avatar_service.update_avatar(current_user.id, avatar_data) + return avatar.to_response() + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/options", response_model=AvatarOptions) +async def get_avatar_options() -> AvatarOptions: + try: + return await avatar_service.get_avatar_options() + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + +@router.get("/{user_id}", response_model=AvatarResponse) +async def get_avatar_by_userId(user_id: int) -> AvatarResponse: + avatar = await avatar_service.get_avatar_by_userId(user_id) + if not avatar: + raise HTTPException(status_code=404, detail="Avatar not found") + return avatar.to_response() diff --git a/Backend/router/endpoints/diary.py b/Backend/router/endpoints/diary.py new file mode 100644 index 0000000..e01a508 --- /dev/null +++ b/Backend/router/endpoints/diary.py @@ -0,0 +1,102 @@ +from fastapi import ( + APIRouter, + HTTPException, + Depends, + UploadFile, + File, +) +from typing import List, Optional +from ...schemas.diary import DiaryCreate, DiaryUpdate, DiaryResponse, Diary +from ...services.diary_service import DiaryService +from ...core.security import get_current_user +from ...schemas.user import User + +router = APIRouter(prefix="/diary", tags=["diary"]) +diary_service = DiaryService() + + +@router.post("", response_model=DiaryResponse) +async def create_diary( + diary_data: DiaryCreate = Depends(DiaryCreate.as_form), + file: List[UploadFile] = File(default=None), + current_user: User = Depends(get_current_user), +) -> DiaryResponse: + try: + diary = await diary_service.create_diary(current_user.id, diary_data, file) + return diary.to_response() + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("", response_model=List[DiaryResponse]) +async def get_user_diaries( + skip: int = 0, + limit: int = 20, + category: Optional[str] = None, + current_user: User = Depends(get_current_user), +) -> List[DiaryResponse]: + try: + diaries = await diary_service.get_user_diaries( + current_user.id, skip, limit, category + ) + return [diary.to_response() for diary in diaries] + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/w/{target_user_id}", response_model=List[DiaryResponse]) +async def get_target_user_diaries( + target_user_id: int, + skip: int = 0, + limit: int = 20, + category: Optional[str] = None, +) -> List[DiaryResponse]: + try: + diaries = await diary_service.get_user_diaries( + target_user_id, skip, limit, category + ) + return [diary.to_response() for diary in diaries] + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/{diary_id}", response_model=DiaryResponse) +async def get_diary(diary_id: int) -> DiaryResponse: + try: + diary = await diary_service.get_diary_by_id(diary_id) + if not diary: + raise HTTPException(status_code=404, detail="Diary not found") + return diary.to_response() + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.put("/{diary_id}", response_model=DiaryResponse) +async def update_diary( + diary_id: int, + diary_data: DiaryUpdate = Depends(DiaryUpdate.as_form), + file: List[UploadFile] = File(default=None), + current_user: User = Depends(get_current_user), +) -> DiaryResponse: + try: + diary = await diary_service.update_diary( + diary_id, current_user.id, diary_data, file + ) + if not diary: + raise HTTPException(status_code=404, detail="Diary not found") + return diary.to_response() + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.delete("/{diary_id}") +async def delete_diary( + diary_id: int, current_user: User = Depends(get_current_user) +) -> dict: + try: + success = await diary_service.delete_diary(diary_id, current_user.id) + if not success: + raise HTTPException(status_code=404, detail="Diary not found") + return {"message": "Diary deleted successfully"} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/Backend/router/endpoints/friendship.py b/Backend/router/endpoints/friendship.py new file mode 100644 index 0000000..a3b4beb --- /dev/null +++ b/Backend/router/endpoints/friendship.py @@ -0,0 +1,81 @@ +from fastapi import APIRouter, HTTPException, Depends +from typing import List, Optional +from ...schemas.friendship import ( + FriendshipRequest, + FriendshipResponse, +) +from ...services.friendship_service import FriendshipService +from ...core.security import get_current_user +from ...schemas.user import User + +router = APIRouter(prefix="/friendship", tags=["friendship"]) +friendship_service = FriendshipService() + + +@router.post("/request", response_model=FriendshipResponse) +async def send_friendship_request( + request_data: FriendshipRequest, current_user: User = Depends(get_current_user) +) -> FriendshipResponse: + try: + friendship = await friendship_service.send_friendship_request( + current_user.id, request_data.friend_username + ) + return friendship + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.put("/{friendship_id}/accept", response_model=FriendshipResponse) +async def accept_friendship_request( + friendship_id: int, current_user: User = Depends(get_current_user) +) -> FriendshipResponse: + try: + friendship = await friendship_service.accept_friendship_request( + friendship_id, current_user.id + ) + if not friendship: + raise HTTPException(status_code=404, detail="Friendship request not found") + return friendship + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("", response_model=List[FriendshipResponse]) +async def get_friendships( + status: Optional[str] = None, current_user: User = Depends(get_current_user) +) -> List[FriendshipResponse]: + try: + friendships = await friendship_service.get_user_friendships( + current_user.id, status + ) + return friendships + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.delete("/{friendship_id}") +async def delete_friendship( + friendship_id: int, current_user: User = Depends(get_current_user) +) -> dict: + try: + success = await friendship_service.delete_friendship( + friendship_id, current_user.id + ) + if not success: + raise HTTPException(status_code=404, detail="Friendship not found") + return {"message": "Friendship deleted successfully"} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/pending", response_model=List[FriendshipResponse]) +async def get_pending_requests( + current_user: User = Depends(get_current_user), +) -> List[FriendshipResponse]: + try: + pending_requests = await friendship_service.get_pending_requests( + current_user.id + ) + return pending_requests + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/Backend/router/endpoints/guestbook.py b/Backend/router/endpoints/guestbook.py new file mode 100644 index 0000000..9246638 --- /dev/null +++ b/Backend/router/endpoints/guestbook.py @@ -0,0 +1,63 @@ +from typing import List +from fastapi import APIRouter, HTTPException, Depends, status +from Backend.core.security import get_current_user +from Backend.schemas.guestbook import ( + GuestBookCreate, + GuestbookUpdate, + GuestbookResponse, +) +from Backend.schemas.user import User +from Backend.services.guestbook_service import GuestbookService + +router = APIRouter(prefix="/guestbook", tags=["guestbook"]) + +guestbook_service = GuestbookService() + + +@router.post("", status_code=status.HTTP_201_CREATED, response_model=GuestbookResponse) +async def create_guestbook( + guestbook: GuestBookCreate, current_user: User = Depends(get_current_user) +) -> GuestbookResponse: + try: + response = await guestbook_service.create_guestbook(guestbook, current_user) + return response + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/{target_user_id}", response_model=List[GuestbookResponse]) +async def get_guestbook(target_user_id: int): + try: + response = await guestbook_service.get_target_user_guestbooks(target_user_id) + response.reverse() + return response + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.put("/{id}", response_model=GuestbookResponse) +async def update_guestbook( + id: int, + guestbook: GuestbookUpdate, +) -> GuestbookResponse: + try: + response = await guestbook_service.update_guestbook_by_id(id, guestbook.content) + return response + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.delete("/{id}") +async def delete_guestbook( + id: int, + user: User = Depends(get_current_user), +) -> dict: + try: + is_success = await guestbook_service.delete_guestbook_by_id(id, user.id) + if is_success: + return {"detail": "success"} + else: + raise HTTPException(status_code=404, detail="guestbook not found") + + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/Backend/router/endpoints/letter.py b/Backend/router/endpoints/letter.py new file mode 100644 index 0000000..ee784e6 --- /dev/null +++ b/Backend/router/endpoints/letter.py @@ -0,0 +1,79 @@ +# from fastapi import APIRouter, HTTPException, Depends +# +# from ...schemas.letter import LetterCreate, LetterResponse, EmailRequest +# from ...services.letter_service import LetterService +# from ...core.security import get_current_user +# from ...schemas.user import User +# +# router = APIRouter(prefix="/letter", tags=["letter"]) +# letter_service = LetterService() +# +# +# @router.post("", response_model=LetterResponse) +# async def create_letter( +# letter_data: LetterCreate, +# current_user: User = Depends(get_current_user), +# ) -> LetterResponse: +# try: +# letter = await letter_service.create_letter(current_user.id, letter_data) +# return letter.to_response() +# except Exception as e: +# raise HTTPException(status_code=400, detail=str(e)) +# +# +# @router.get("/{letter_id}", response_model=LetterResponse) +# async def get_letter( +# letter_id: int, current_user: User = Depends(get_current_user) +# ) -> LetterResponse: +# try: +# letter = await letter_service.get_letter_by_id(letter_id, current_user.id) +# if not letter: +# raise HTTPException(status_code=404, detail="Letter not found") +# return letter.to_response() +# except Exception as e: +# raise HTTPException(status_code=400, detail=str(e)) +# +# +# @router.delete("/{letter_id}") +# async def delete_letter(letter_id: int, current_user: User = Depends(get_current_user)): +# try: +# is_success = await letter_service.delete_letter(letter_id, current_user.id) +# if not is_success: +# raise HTTPException(status_code=404, detail="Letter not found") +# return {"detail": "Letter deleted"} +# except Exception as e: +# raise HTTPException(status_code=400, detail=str(e)) +# +# +# @router.put("/{letter_id}", response_model=LetterResponse) +# async def update_letter( +# letter_id: int, +# letter_data: LetterCreate, +# current_user: User = Depends(get_current_user), +# ): +# try: +# letter = await letter_service.update_letter( +# letter_id, current_user.id, letter_data.content +# ) +# if not letter: +# raise HTTPException(status_code=404, detail="Letter not found") +# return letter.to_response() +# except Exception as e: +# raise HTTPException(status_code=400, detail=str(e)) +# +# +# @router.post("/{letter_id}/send") +# async def send_letter( +# letter_id: int, +# letter_data: EmailRequest, +# current_user: User = Depends(get_current_user), +# ): +# try: +# letter = await letter_service.get_letter_by_id(letter_id, current_user.id) +# if not letter: +# raise HTTPException(status_code=404, detail="Letter not found") +# +# await letter_service.send_letter(letter, letter_data) +# return {"message": "Email sent successfully!"} +# except Exception as e: +# raise HTTPException(status_code=400, detail=str(e)) diff --git a/Backend/router/endpoints/photo.py b/Backend/router/endpoints/photo.py new file mode 100644 index 0000000..412ed79 --- /dev/null +++ b/Backend/router/endpoints/photo.py @@ -0,0 +1,117 @@ +# from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, Query, Form +# from typing import List +# from ...schemas.photo import ( +# PhotoUpload, +# PhotoResponse, +# CommentCreate, +# CommentResponse, +# FilterRequest, +# ) +# from ...services.photo_service import PhotoService +# from ...core.security import get_current_user +# from ...schemas.user import User +# from pydantic import ValidationError +# +# router = APIRouter(prefix="/photo", tags=["photo"]) +# photo_service = PhotoService() +# +# +# @router.post("/upload", response_model=PhotoResponse) +# async def upload_photo( +# photo_data: UploadFile = Form(...), +# file: UploadFile = File(...), +# current_user: User = Depends(get_current_user), +# ) -> PhotoResponse: +# import json +# +# try: +# photo_data_bytes = await photo_data.read() +# photo_info = json.loads(photo_data_bytes.decode("utf-8")) +# photo_data = PhotoUpload(**photo_info) +# except (json.JSONDecodeError, ValidationError) as e: +# raise HTTPException(status_code=400, detail="Invalid photo data format") +# try: +# photo = await photo_service.upload_photo(current_user.id, photo_data, file) +# return photo.to_response() +# except Exception as e: +# raise HTTPException(status_code=400, detail=str(e)) +# +# +# @router.get("", response_model=List[PhotoResponse]) +# async def get_user_photos( +# skip: int = 0, +# limit: int = 20, +# album_name: str = None, +# current_user: User = Depends(get_current_user), +# ) -> List[PhotoResponse]: +# try: +# photos = await photo_service.get_user_photos( +# current_user.id, skip, limit, album_name +# ) +# return [photo.to_response() for photo in photos] +# except Exception as e: +# raise HTTPException(status_code=400, detail=str(e)) +# +# +# @router.post("/{photo_id}/comment", response_model=CommentResponse) +# async def add_photo_comment( +# photo_id: int, +# comment_data: CommentCreate, +# current_user: User = Depends(get_current_user), +# ) -> CommentResponse: +# try: +# is_friend = await photo_service.check_friendship(current_user.id, photo_id) +# if not is_friend: +# raise HTTPException( +# status_code=403, detail="Only friends can comment on photos" +# ) +# +# comment = await photo_service.add_comment( +# photo_id, current_user.id, comment_data +# ) +# return comment.to_response(current_user.username) +# except Exception as e: +# raise HTTPException(status_code=400, detail=str(e)) +# +# +# @router.get("/{photo_id}/comments", response_model=List[CommentResponse]) +# async def get_photo_comments( +# photo_id: int, current_user: User = Depends(get_current_user) +# ) -> List[CommentResponse]: +# try: +# comments = await photo_service.get_photo_comments(photo_id) +# return comments +# except Exception as e: +# raise HTTPException(status_code=400, detail=str(e)) +# +# +# @router.post("/edit-filter") +# async def apply_photo_filter( +# filter_request: FilterRequest, current_user: User = Depends(get_current_user) +# ) -> dict: +# if filter_request.cover and filter_request.title is None: +# raise HTTPException(status_code=400, detail="title must be Not Null") +# try: +# filtered_image_path = await photo_service.apply_filter( +# filter_request.photo_id, +# filter_request.filter_type, +# current_user.id, +# filter_request.cover, +# filter_request.title, +# ) +# return {"filtered_image_path": filtered_image_path} +# except Exception as e: +# raise HTTPException(status_code=400, detail=str(e)) +# +# +# @router.delete("/{photo_id}") +# async def delete_photo( +# photo_id: int, current_user: User = Depends(get_current_user) +# ) -> dict: +# try: +# success = await photo_service.delete_photo(photo_id, current_user.id) +# if not success: +# raise HTTPException(status_code=404, detail="Photo not found") +# return {"message": "Photo deleted successfully"} +# except Exception as e: +# raise HTTPException(status_code=400, detail=str(e)) diff --git a/Backend/router/endpoints/room.py b/Backend/router/endpoints/room.py new file mode 100644 index 0000000..d654ea3 --- /dev/null +++ b/Backend/router/endpoints/room.py @@ -0,0 +1,110 @@ +from fastapi import APIRouter, Depends, HTTPException +from typing import List +from Backend.schemas.room import ( + FurnitureItem, + RoomFurniturePlacement, + FurniturePlacementRequest, + RoomNameUpdateRequest, + UpdateRoomTypeRequest, + RoomResponse, + RoomTypeResponse, + RoomFurnitureResponse, +) +from Backend.schemas.user import User +from Backend.core.security import get_current_user +from Backend.services.room_service import RoomService + +router = APIRouter(prefix="/room", tags=["room"]) +room_service = RoomService() + + +@router.get("/catalog", response_model=List[FurnitureItem]) +async def get_furniture_catalog() -> List[FurnitureItem]: + return await room_service.get_furniture_catalog() + + +@router.get("/layout", response_model=RoomFurnitureResponse) +async def get_my_room_layout(current_user: User = Depends(get_current_user)): + room_id = await room_service.get_or_create_room(current_user.id) + return await room_service.get_room_furnitures(room_id) + + +@router.get("/layout/{user_id}", response_model=RoomFurnitureResponse) +async def get_user_room_layout(user_id: int) -> RoomFurnitureResponse: + room_id = await room_service.get_or_create_room(user_id) + return await room_service.get_room_furnitures(room_id) + + +@router.post("/furniture") +async def place_furniture( + request: FurniturePlacementRequest, current_user: User = Depends(get_current_user) +): + room_id = await room_service.get_or_create_room(current_user.id) + try: + await room_service.place_furniture(room_id, request) + return {"message": "Furniture placed successfully"} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.delete("/furniture") +async def remove_furniture( + x: int, y: int, furniture_name: str, current_user: User = Depends(get_current_user) +): + room_id = await room_service.get_or_create_room(current_user.id) + await room_service.remove_furniture(room_id, x, y, furniture_name) + return {"message": "Furniture removed successfully"} + + +@router.put("/") +async def update_room_name( + data: RoomNameUpdateRequest, current_user: User = Depends(get_current_user) +): + try: + room_id = await room_service.get_or_create_room(current_user.id) + await room_service.update_room_name(room_id, data.new_name) + return {"message": "Room name updated successfully"} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/") +async def get_my_room(current_user: User = Depends(get_current_user)) -> RoomResponse: + try: + room_id = await room_service.get_or_create_room(current_user.id) + return (await room_service.get_room_by_id(room_id)).to_response() + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/types") +async def get_room_types() -> List[RoomTypeResponse]: + return await room_service.get_room_types() + + +@router.patch("/") +async def update_room_type( + data: UpdateRoomTypeRequest, current_user: User = Depends(get_current_user) +) -> RoomResponse: + try: + room_id = await room_service.get_or_create_room(current_user.id) + return (await room_service.update_room_type(room_id, data.type)).to_response() + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/my") +async def get_my_furniture(current_user: User = Depends(get_current_user)): + try: + list_ = await room_service.get_user_furniture(current_user.id) + return list_ + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + +@router.get("/{user_id}", response_model=RoomResponse) +async def get_room_by_userId(user_id: int) -> RoomResponse: + try: + room = await room_service.get_room_by_userId(user_id) + return room.to_response() + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) \ No newline at end of file diff --git a/Backend/router/endpoints/store.py b/Backend/router/endpoints/store.py new file mode 100644 index 0000000..b2f79fa --- /dev/null +++ b/Backend/router/endpoints/store.py @@ -0,0 +1,48 @@ +from fastapi import APIRouter, Depends, HTTPException +from ...core.security import get_current_user +from ...schemas.user import User +from Backend.services.store_service import StoreService +from ...services.room_service import RoomService + +router = APIRouter(prefix="/store", tags=["store"]) +store_service = StoreService() +room_service = RoomService() + + +@router.get("") +async def get_dotory( + current_user: User = Depends(get_current_user), +): + try: + dotory = await store_service.get_dotory_by_id(current_user.id) + return {"dotory": dotory} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/{product_id}") +async def buy_product( + product_id: int, + product_name: str, + current_user: User = Depends(get_current_user), +): + try: + response = await store_service.buy_product(product_id, current_user.id) + if response["isSuccess"]: + await room_service.add_furniture(current_user.id, product_name) + return {"dotory": response["dotory"]} + raise HTTPException(status_code=404, detail="Product not found") + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.put("") +async def update_dotory( + dotory_num: int, + current_user: User = Depends(get_current_user), +): + try: + dotory = await store_service.update_user_dotory(current_user.id, dotory_num) + return {"dotory": dotory} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/Backend/router/endpoints/user.py b/Backend/router/endpoints/user.py new file mode 100644 index 0000000..342f2c7 --- /dev/null +++ b/Backend/router/endpoints/user.py @@ -0,0 +1,116 @@ +from typing import List + +from fastapi import APIRouter, HTTPException, status, UploadFile, File, Depends +from Backend.schemas.user import UserCreate, UserLogin, UserResponse, User, UserUpdate +from Backend.services.user_service import UserService +from Backend.core.security import create_access_token, get_current_user + +router = APIRouter(prefix="/user", tags=["user"]) + +user_service = UserService() + + +@router.post( + "/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED +) +async def register_user( + user_data: UserCreate = Depends(UserCreate.as_form), + profile_file: UploadFile = File(default=None), +) -> UserResponse: + existing_user = await user_service.get_user_by_username + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already registered" + ) + + existing_email = await user_service.get_user_by_email(user_data.email) + if existing_email: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + try: + user = await user_service.create_user(user_data, profile_file) + return user.to_response() + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to created user: {str(e)}" + ) + +@router.post("/login") +async def login_user(login_data: UserLogin) -> dict: + user = await user_service.authenticate_user( + login_data.username, login_data.password + ) + + if not user: + raise HTTPException( + status_code= + detail= + ) + + if not user.is_active: + raise HTTPException( + status_code=, detail="" + ) + + access_token = + + return {"access_token": access_token, "token_type": "bearer"} + + + +@router.get("/profile/{username}", response_model=UserResponse) +async def get_user_profile(username: str) -> UserResponse: + user = await user_service.get_user_by_username(username) + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found" + ) + + return user.to_response() + + +@router.get("/me", response_model=UserResponse) +async def get_user_me(user: User = Depends(get_current_user)) -> UserResponse: + return user.to_response() + + +@router.delete("/{username}") +async def delete_user(username: str) -> dict: + is_success = await user_service.delete_user(username) + if not is_success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found" + ) + return {"detail": "User deleted"} + + +@router.get("/find/{username}", response_model=List[UserResponse]) +async def find_user(username: str) -> List[UserResponse]: + users = await user_service.find_user(username) + return users + + +@router.put("/", response_model=UserResponse) +async def update_user( + current_user: User = Depends(get_current_user), + user_data: UserUpdate = Depends(UserUpdate.as_form), + profile_file: UploadFile = File(default=None), +) -> UserResponse: + try: + updated_user = await user_service.update_user( + user=current_user, user_data=user_data, profile_file=profile_file + ) + return updated_user.to_response() + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update user: {str(e)}", + ) diff --git a/Backend/router/router.py b/Backend/router/router.py new file mode 100644 index 0000000..dadeafe --- /dev/null +++ b/Backend/router/router.py @@ -0,0 +1,21 @@ +import os +import traceback +from fastapi import APIRouter +from fastapi.logger import logger + + +router = APIRouter() + +for router_file in os.listdir("Backend/router/endpoints"): + try: + if router_file.endswith(".py"): + module_name = "Backend.router.endpoints." + router_file[:-3] + module = __import__(module_name, fromlist=[""]) + router_object = getattr(module, "router") + prefix = getattr(module, "prefix", router_file[:-3]) + + router.include_router(router_object, prefix="/api", tags=[router_file[:-3]]) + print(f"Loaded router: /api/{prefix} - {router_file}") + + except Exception as e: + logger.error(f"Error loading router {router_file}:\n{traceback.format_exc()}") diff --git a/Backend/schemas/__init__.py b/Backend/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Backend/schemas/avatar.py b/Backend/schemas/avatar.py new file mode 100644 index 0000000..5a4e3ad --- /dev/null +++ b/Backend/schemas/avatar.py @@ -0,0 +1,106 @@ +from pydantic import BaseModel +import enum + + +class AvatarType(str, enum.Enum): + MALE = "남성" + FEMALE = "여성" + + +class TopClothesType(str, enum.Enum): + SCHOOL_CLOTHES = "교복" + SCHOOL_CLOTHES_2 = "교복 조끼" + ANA_CLOTHES = "AnA 동잠" + SUSPENDERS_CLOTHES_1 = "멜빵 바지" + SUSPENDERS_CLOTHES_2 = "멜빵 치마" + RAINBOW_CLOTHES = "무지개 맨투맨" + SANTA_CLOTHES = "산타" + + +class BottomClothesType(str, enum.Enum): + SCHOOL_CLOTHES = "교복 바지" + SCHOOL_CLOTHES_2 = "교복 치마" + SCHOOL_CLOTHES_3 = "교복 조끼 바지" + SCHOOL_CLOTHES_4 = "교복 조끼 치마" + SANTA_CLOTHES = "산타 바지" + JEANS = "청바지" + + +avatar_path_ = {"남성": "public/avatar/남자.png", "여성": "public/avatar/여자.png"} +top_clothe_path_ = { + "교복": "public/avatar/교복상의.png", + "교복 조끼": "public/avatar/교복조끼상의.png", + "AnA 동잠": "public/avatar/동잠상의.png", + "멜빵 바지": "public/avatar/멜빵바지상의.png", + "멜빵 치마": "public/avatar/멜빵치마상의.png", + "무지개 맨투맨": "public/avatar/무지개맨투맨상의.png", + "산타": "public/avatar/산타상의.png", +} +bottom_clothe_path_ = { + "교복 바지": "public/avatar/교복하의남.png", + "교복 치마": "public/avatar/교복하의여.png", + "교복 조끼 바지": "public/avatar/교복조끼하의남.png", + "교복 조끼 치마": "public/avatar/교복조끼하의여.png", + "산타 바지": "public/avatar/산타하의.png", + "청바지": "public/avatar/청바지하의.png", +} + + +class AvatarTypeResponse(BaseModel): + name: str + path: str + + +class AvatarUpdate(BaseModel): + avatar_type: AvatarType + top_clothe_type: TopClothesType + bottom_clothe_type: BottomClothesType + + +class AvatarResponse(BaseModel): + id: int + user_id: int + avatar_type: AvatarTypeResponse + top_clothe_type: AvatarTypeResponse + bottom_clothe_type: AvatarTypeResponse + + +class AvatarOptions(BaseModel): + avatar_types: list[str] + top_clothe_types: list[str] + bottom_clothe_types: list[str] + + + +class Avatar: + def __init__( + self, + id: int, + user_id: int, + avatar_type: AvatarType, + top_clothe_type: TopClothesType, + bottom_clothe_type: BottomClothesType, + ): + self.id = id + self.user_id = user_id + self.avatar_type = avatar_type + self.top_clothe_type = top_clothe_type + self.bottom_clothe_type = bottom_clothe_type + + def to_response(self) -> AvatarResponse: + return AvatarResponse( + id=self.id, + user_id=self.user_id, + avatar_type=AvatarTypeResponse( + name=self.avatar_type, + path=avatar_path_[self.avatar_type], + ), + top_clothe_type=AvatarTypeResponse( + name=self.top_clothe_type, + path=top_clothe_path_[self.top_clothe_type], + ), + bottom_clothe_type=AvatarTypeResponse( + name=self.bottom_clothe_type, + path=bottom_clothe_path_[self.bottom_clothe_type], + ), + ) diff --git a/Backend/schemas/diary.py b/Backend/schemas/diary.py new file mode 100644 index 0000000..cf2d9d1 --- /dev/null +++ b/Backend/schemas/diary.py @@ -0,0 +1,109 @@ +from pydantic import BaseModel, field_validator +from datetime import datetime +from typing import Optional, List +from fastapi import Form + + +class DiaryCreate(BaseModel): + title: str + content: str + category: str + + @classmethod + def as_form( + cls, + title: str = Form(...), + content: str = Form(...), + category: str = Form(...), + ) -> "DiaryCreate": + return cls(title=title, content=content, category=category) + + @field_validator("title") + @classmethod + def validate_title(cls, v): + if len(v.strip()) < 1: + raise ValueError("Title cannot be empty") + if len(v) > 100: + raise ValueError("Title must be less than 100 characters") + return v.strip() + + @field_validator("content") + @classmethod + def validate_content(cls, v): + if len(v.strip()) < 1: + raise ValueError("Content cannot be empty") + if len(v) > 5000: + raise ValueError("Content must be less than 5000 characters") + return v.strip() + + +class DiaryUpdate(BaseModel): + title: Optional[str] = None + content: Optional[str] = None + category: Optional[str] = None + + @classmethod + def as_form( + cls, + title: Optional[str] = Form(None), + content: Optional[str] = Form(None), + category: Optional[str] = Form(None), + ) -> "DiaryUpdate": + return cls(title=title, content=content, category=category) + + +class DiaryResponse(BaseModel): + id: int + user_id: int + title: str + content: str + images: List[str] + category: str + created_at: datetime + is_submitted: bool + email_sent: bool + + +class Diary: + def __init__( + self, + id: int, + user_id: int, + title: str, + content: str, + images: str, # JSON string + category: str, + created_at: datetime, + is_submitted: bool = False, + email_sent: bool = False, + ): + self.id = id + self.user_id = user_id + self.title = title + self.content = content + self.images = images + self.category = category + self.created_at = created_at + self.is_submitted = is_submitted + self.email_sent = email_sent + + @property + def image_list(self) -> List[str]: + return ( + [img.strip() for img in self.images.split(",") if img.strip()] + if self.images + else [] + ) + + def to_response(self) -> DiaryResponse: + return DiaryResponse( + id=self.id, + user_id=self.user_id, + title=self.title, + content=self.content, + images=self.image_list, + category=self.category, + created_at=self.created_at, + is_submitted=self.is_submitted, + email_sent=self.email_sent, + ) diff --git a/Backend/schemas/friendship.py b/Backend/schemas/friendship.py new file mode 100644 index 0000000..a747653 --- /dev/null +++ b/Backend/schemas/friendship.py @@ -0,0 +1,59 @@ +from pydantic import BaseModel, field_validator +from datetime import datetime +import enum + + +class FriendshipStatus(str, enum.Enum): + PENDING = "pending" + ACCEPTED = "accepted" + REJECTED = "rejected" + + +class FriendshipRequest(BaseModel): + friend_username: str + + @field_validator("friend_username") + @classmethod + def validate_friend_username(cls, v): + if len(v.strip()) < 1: + raise ValueError("Friend username cannot be empty") + return v.strip() + + +class FriendshipResponse(BaseModel): + id: int + user_id: int + friend_id: int + friend_username: str + status: FriendshipStatus + created_at: datetime + + +class FriendshipUpdate(BaseModel): + status: FriendshipStatus + + +class Friendship: + def __init__( + self, + id: int, + user_id: int, + friend_id: int, + status: str, + created_at: datetime, + ): + self.id = id + self.user_id = user_id + self.friend_id = friend_id + self.status = status + self.created_at = created_at + + def to_response(self, friend_username: str) -> FriendshipResponse: + return FriendshipResponse( + id=self.id, + user_id=self.user_id, + friend_id=self.friend_id, + friend_username=friend_username, + status=FriendshipStatus(self.status), + created_at=self.created_at, + ) diff --git a/Backend/schemas/guestbook.py b/Backend/schemas/guestbook.py new file mode 100644 index 0000000..4472367 --- /dev/null +++ b/Backend/schemas/guestbook.py @@ -0,0 +1,46 @@ +from datetime import datetime +from pydantic import BaseModel, field_validator + + +class GuestBookCreate(BaseModel): + content: str + target_user_id: int + + @field_validator("content") + @classmethod + def validate_content(cls, v): + if len(v.strip()) < 1: + raise ValueError("GuestBook content cannot be empty") + if len(v) > 2000: + raise ValueError("GuestBook content must be less than 2000 characters") + return v.strip() + + +class GuestbookUpdate(BaseModel): + content: str + + +class GuestbookResponse(BaseModel): + id: int + content: str + target_user_id: int + user_id: int + user_profile_path: str + username: str + created_at: datetime + + +class GuestBook: + def __init__( + self, + id: int, + target_user_id: int, + user_id, + content, + created_at: datetime, + ): + self.id = id + self.target_user_id = target_user_id + self.user_id = user_id + self.content = content + self.created_at = created_at diff --git a/Backend/schemas/letter.py b/Backend/schemas/letter.py new file mode 100644 index 0000000..400366d --- /dev/null +++ b/Backend/schemas/letter.py @@ -0,0 +1,47 @@ +# from pydantic import BaseModel, field_validator, EmailStr +# +# # email validator 삭제 및 EmailStr 사용 +# +# +# class LetterCreate(BaseModel): +# content: str +# +# @field_validator("content") +# @classmethod +# def validate_content(cls, v): +# if len(v.strip()) < 1: +# raise ValueError("Letter content cannot be empty") +# if len(v) > 2000: +# raise ValueError("Letter content must be less than 2000 characters") +# return v.strip() +# +# +# class LetterResponse(BaseModel): +# id: int +# sender_id: int +# content: str +# +# +# class Letter: +# def __init__( +# self, +# id: int, +# sender_id: int, +# content: str, +# ): +# self.id = id +# self.sender_id = sender_id +# self.content = content +# +# def to_response(self) -> LetterResponse: +# return LetterResponse( +# id=self.id, +# sender_id=self.sender_id, +# content=self.content, +# ) +# +# +# class EmailRequest(BaseModel): +# sender_email: EmailStr +# sender_password: str +# sender_name: str diff --git a/Backend/schemas/photo.py b/Backend/schemas/photo.py new file mode 100644 index 0000000..234b1db --- /dev/null +++ b/Backend/schemas/photo.py @@ -0,0 +1,117 @@ +# from pydantic import BaseModel, field_validator +# from datetime import datetime +# +# +# class PhotoUpload(BaseModel): +# album_name: str +# title: str +# +# @field_validator("album_name") +# @classmethod +# def validate_album_name(cls, v): +# if len(v.strip()) < 1: +# raise ValueError("Album name cannot be empty") +# if len(v) > 50: +# raise ValueError("Album name must be less than 50 characters") +# return v.strip() +# +# @field_validator("title") +# @classmethod +# def validate_title(cls, v): +# if len(v.strip()) < 1: +# raise ValueError("Title cannot be empty") +# if len(v) > 100: +# raise ValueError("Title must be less than 100 characters") +# return v.strip() +# +# +# class PhotoResponse(BaseModel): +# id: int +# user_id: int +# album_name: str +# image_path: str +# title: str +# created_at: datetime +# +# +# class CommentCreate(BaseModel): +# content: str +# +# @field_validator("content") +# @classmethod +# def validate_content(cls, v): +# if len(v.strip()) < 1: +# raise ValueError("Comment cannot be empty") +# if len(v) > 500: +# raise ValueError("Comment must be less than 500 characters") +# return v.strip() +# +# +# class CommentResponse(BaseModel): +# id: int +# photo_id: int +# user_id: int +# username: str +# content: str +# created_at: datetime +# +# +# class FilterRequest(BaseModel): +# photo_id: int +# filter_type: str +# cover: bool +# title: str = None +# +# +# class Photo: +# def __init__( +# self, +# id: int, +# user_id: int, +# album_name: str, +# image_path: str, +# title: str, +# created_at: datetime, +# ): +# self.id = id +# self.user_id = user_id +# self.album_name = album_name +# self.image_path = image_path +# self.title = title +# self.created_at = created_at +# +# def to_response(self) -> PhotoResponse: +# return PhotoResponse( +# id=self.id, +# user_id=self.user_id, +# album_name=self.album_name, +# image_path=self.image_path, +# title=self.title, +# created_at=self.created_at, +# ) +# +# +# class PhotoComment: +# def __init__( +# self, +# id: int, +# photo_id: int, +# user_id: int, +# content: str, +# created_at: datetime, +# ): +# self.id = id +# self.photo_id = photo_id +# self.user_id = user_id +# self.content = content +# self.created_at = created_at +# +# def to_response(self, username: str) -> CommentResponse: +# return CommentResponse( +# id=self.id, +# photo_id=self.photo_id, +# user_id=self.user_id, +# username=username, +# content=self.content, +# created_at=self.created_at, +# ) diff --git a/Backend/schemas/room.py b/Backend/schemas/room.py new file mode 100644 index 0000000..c9381f6 --- /dev/null +++ b/Backend/schemas/room.py @@ -0,0 +1,296 @@ +import enum +from pydantic import BaseModel, field_validator +from typing import Optional, List + + +class RoomNameUpdateRequest(BaseModel): + new_name: str + + +class RoomTypes(enum.Enum): + ROOM_1 = "room_1" + ROOM_2 = "room_2" + + +class UpdateRoomTypeRequest(BaseModel): + type: RoomTypes + + +class RoomTypeResponse(BaseModel): + type: str + image_path: str + + +class RoomResponse(BaseModel): + id: int + user_id: int + room_name: str + room_type: RoomTypes + room_image_path: str + + +room_path = { + "room_1": "public/room/room_1.png", + "room_2": "public/room/room_2.png", +} + + +class FurnitureItem(BaseModel): + name: str + image_path: str + width: int + + +class Furniture(str, enum.Enum): + BLACK_LAPTOP1_0 = "검정 노트북1-01" + BLACK_LAPTOP1_180 = "검정 노트북1-1801" + BLACK_LAPTOP1_270 = "검정 노트북1-2701" + BLACK_LAPTOP1_90 = "검정 노트북1-901" + BLACK_LAPTOP2_0 = "검정 노트북2-01" + BLACK_LAPTOP2_180 = "검정 노트북2-1801" + BLACK_LAPTOP2_270 = "검정 노트북2-2701" + BLACK_LAPTOP2_90 = "검정 노트북2-901" + BLACK_LAPTOP3_0 = "검정 노트북3-01" + BLACK_LAPTOP3_180 = "검정 노트북3-1801" + BLACK_LAPTOP3_270 = "검정 노트북3-2701" + BLACK_LAPTOP3_90 = "검정 노트북3-901" + WOODEN_TABLE_90 = "나무 탁자-901" + WOODEN_TABLE_0 = "나무 탁자-01" + LAPTOP1_0 = "노트북1-01" + LAPTOP1_180 = "노트북1-1801" + LAPTOP1_270 = "노트북1-2701" + LAPTOP1_90 = "노트북1-901" + LAPTOP2_0 = "노트북2-01" + LAPTOP2_180 = "노트북2-1801" + LAPTOP2_270 = "노트북2-2701" + LAPTOP2_90 = "노트북2-901" + LAPTOP3_0 = "노트북3-01" + LAPTOP3_180 = "노트북3-1801" + LAPTOP3_270 = "노트북3-2701" + LAPTOP3_90 = "노트북3-901" + GREEN_TABLE = "녹색 탁자1" + MINI_FRIDGE_0 = "미니 냉장고-01" + MINI_FRIDGE_180 = "미니 냉장고-1801" + MINI_FRIDGE_90 = "미니 냉장고-901" + BOX_0 = "박스-01" + BOX_90 = "박스-901" + PINK_TABLE = "분홍색 탁자1" + SHELF_0 = "선반-01" + SHELF_180 = "선반-1801" + SHELF_270 = "선반-2701" + SHELF_90 = "선반-901" + TRASH_CAN_CLOSED = "쓰레기통 닫힘1" + TRASH_CAN_OPEN = "쓰레기통 열림1" + FISHBOWL_0 = "어항-01" + FISHBOWL_180 = "어항-1801" + FISHBOWL_270 = "어항-2701" + FISHBOWL_90 = "어항-901" + BEVERAGE_FRIDGE_0 = "음료 냉장고-01" + BEVERAGE_FRIDGE_180 = "음료 냉장고-1801" + BEVERAGE_FRIDGE_270 = "음료 냉장고-2701" + BEVERAGE_FRIDGE_90 = "음료 냉장고-901" + CHAIR_0 = "의자-01" + CHAIR_180 = "의자-1801" + CHAIR_270 = "의자-2701" + CHAIR_90 = "의자-901" + SMALL_SHELF_0 = "작은 선반-01" + SMALL_SHELF_180 = "작은 선반-1801" + SMALL_SHELF_270 = "작은 선반-2701" + SMALL_SHELF_90 = "작은 선반-901" + SMALL_PLANT = "작은 식물1" + BOOKSHELF_0 = "책장-01" + BOOKSHELF_180 = "책장-1801" + BOOKSHELF_270 = "책장-2701" + BOOKSHELF_90 = "책장-901" + LARGE_PLANT = "큰 식물1" + TV_0 = "티비-01" + TV_180 = "티비-1801" + TV_270 = "티비-2701" + TV_90 = "티비-901" + BLUE_TABLE = "파란색 탁자1" + GRAY_TABLE = "회색 탁자1" + WHITE_LAPTOP1_0 = "흰 노트북1-01" + WHITE_LAPTOP1_180 = "흰 노트북1-1801" + WHITE_LAPTOP1_270 = "흰 노트북1-2701" + WHITE_LAPTOP1_90 = "흰 노트북1-901" + WHITE_LAPTOP2_0 = "흰 노트북2-01" + WHITE_LAPTOP2_180 = "흰 노트북2-1801" + WHITE_LAPTOP2_270 = "흰 노트북2-2701" + WHITE_LAPTOP2_90 = "흰 노트북2-901" + WHITE_LAPTOP3_0 = "흰 노트북3-01" + WHITE_LAPTOP3_180 = "흰 노트북3-1801" + WHITE_LAPTOP3_270 = "흰 노트북3-2701" + WHITE_LAPTOP3_90 = "흰 노트북3-901" + WHITE_SHELF_0 = "흰색 선반-01" + WHITE_SHELF_180 = "흰색 선반-1801" + WHITE_SHELF_270 = "흰색 선반-2701" + WHITE_SHELF_90 = "흰색 선반-901" + WHITE_SMALL_SHELF_0 = "흰색 작은 선반-01" + WHITE_SMALL_SHELF_180 = "흰색 작은 선반-1801" + WHITE_SMALL_SHELF_270 = "흰색 작은 선반-2701" + WHITE_SMALL_SHELF_90 = "흰색 작은 선반-901" + WHITE_TABLE = "흰색 탁자1" + EMPTY = "1" + + +class RoomFurniturePlacement(BaseModel): + id: int + room_id: int + furniture_name: Furniture + x: int + y: int + + +class FurniturePlacementRequest(BaseModel): + furniture_name: Furniture + x: int + y: int + + @field_validator("x", "y") + @classmethod + def validate_coordinates(cls, v): + if v < 0 or v >= 10: + raise ValueError("position must be between 0 and 10") + return v + + +class FurniturePlacementResponse(BaseModel): + furniture_name: Furniture + x: int + y: int + image_path: str + + +class RoomFurnitureResponse(BaseModel): + room: RoomResponse + furniture: List[FurniturePlacementResponse] + + +furniture_path = { + "검정 노트북1-01": "public/funiture/검정 노트북1-0.png", + "검정 노트북1-1801": "public/funiture/검정 노트북1-180.png", + "검정 노트북1-2701": "public/funiture/검정 노트북1-270.png", + "검정 노트북1-901": "public/funiture/검정 노트북1-90.png", + "검정 노트북2-01": "public/funiture/검정 노트북2-0.png", + "검정 노트북2-1801": "public/funiture/검정 노트북2-180.png", + "검정 노트북2-2701": "public/funiture/검정 노트북2-270.png", + "검정 노트북2-901": "public/funiture/검정 노트북2-90.png", + "검정 노트북3-01": "public/funiture/검정 노트북3-0.png", + "검정 노트북3-1801": "public/funiture/검정 노트북3-180.png", + "검정 노트북3-2701": "public/funiture/검정 노트북3-270.png", + "검정 노트북3-901": "public/funiture/검정 노트북3-90.png", + "나무 탁자-901": "public/funiture/나무 탁자-90.png", + "나무 탁자-01": "public/funiture/나무탁자-0.png", + "노트북1-01": "public/funiture/노트북1-0.png", + "노트북1-1801": "public/funiture/노트북1-180.png", + "노트북1-2701": "public/funiture/노트북1-270.png", + "노트북1-901": "public/funiture/노트북1-90.png", + "노트북2-01": "public/funiture/노트북2-0.png", + "노트북2-1801": "public/funiture/노트북2-180.png", + "노트북2-2701": "public/funiture/노트북2-270.png", + "노트북2-901": "public/funiture/노트북2-90.png", + "노트북3-01": "public/funiture/노트북3-0.png", + "노트북3-1801": "public/funiture/노트북3-180.png", + "노트북3-2701": "public/funiture/노트북3-270.png", + "노트북3-901": "public/funiture/노트북3-90.png", + "녹색 침대-02": "public/funiture/녹색 침대-0.png", + "녹색 침대-1802": "public/funiture/녹색 침대-180.png", + "녹색 침대-2702": "public/funiture/녹색 침대-270.png", + "녹색 침대-902": "public/funiture/녹색 침대-90.png", + "녹색 탁자1": "public/funiture/녹색 탁자.png", + "미니 냉장고-01": "public/funiture/미니 냉장고-0.png", + "미니 냉장고-1801": "public/funiture/미니 냉장고-180.png", + "미니 냉장고-901": "public/funiture/미니 냉장고-90.png", + "박스-01": "public/funiture/박스-0.png", + "박스-901": "public/funiture/박스-90.png", + "분홍색 탁자1": "public/funiture/분홍색 탁자.png", + "빨간 침대-02": "public/funiture/빨간 침대-0.png", + "빨간 침대-1802": "public/funiture/빨간 침대-180.png", + "빨간 침대-2702": "public/funiture/빨간 침대-270.png", + "빨간 침대-902": "public/funiture/빨간 침대-90.png", + "선반-01": "public/funiture/선반-0.png", + "선반-1801": "public/funiture/선반-180.png", + "선반-2701": "public/funiture/선반-270.png", + "선반-901": "public/funiture/선반-90.png", + "소파-02": "public/funiture/소파-0.png", + "소파-1802": "public/funiture/소파-180.png", + "소파-2702": "public/funiture/소파-270.png", + "소파-902": "public/funiture/소파-90.png", + "쓰레기통 닫힘1": "public/funiture/쓰레기통닫힘.png", + "쓰레기통 열림1": "public/funiture/쓰레기통열림.png", + "어항-01": "public/funiture/어항-0.png", + "어항-1801": "public/funiture/어항-180.png", + "어항-2701": "public/funiture/어항-270.png", + "어항-901": "public/funiture/어항-90.png", + "음료 냉장고-01": "public/funiture/음료 냉장고-0.png", + "음료 냉장고-1801": "public/funiture/음료 냉장고-180.png", + "음료 냉장고-2701": "public/funiture/음료 냉장고-270.png", + "음료 냉장고-901": "public/funiture/음료 냉장고-90.png", + "의자-01": "public/funiture/의자-0.png", + "의자-1801": "public/funiture/의자-180.png", + "의자-2701": "public/funiture/의자-270.png", + "의자-901": "public/funiture/의자-90.png", + "작은 선반-01": "public/funiture/작은 선반-0.png", + "작은 선반-1801": "public/funiture/작은 선반-180.png", + "작은 선반-2701": "public/funiture/작은 선반-270.png", + "작은 선반-901": "public/funiture/작은 선반-90.png", + "작은 식물1": "public/funiture/작은 식물.png", + "주황 침대-02": "public/funiture/주황 침대-0.png", + "주황 침대-1802": "public/funiture/주황 침대-180.png", + "주황 침대-2702": "public/funiture/주황 침대-270.png", + "주황 침대-902": "public/funiture/주황 침대-90.png", + "책장-01": "public/funiture/책장-0.png", + "책장-1801": "public/funiture/책장-180.png", + "책장-2701": "public/funiture/책장-270.png", + "책장-901": "public/funiture/책장-90.png", + "큰 식물1": "public/funiture/큰 식물.png", + "티비-01": "public/funiture/티비-0.png", + "티비-1801": "public/funiture/티비-180.png", + "티비-2701": "public/funiture/티비-270.png", + "티비-901": "public/funiture/티비-90.png", + "파란 침대-02": "public/funiture/파란 침대-0.png", + "파란 침대-1802": "public/funiture/파란 침대-180.png", + "파란 침대-2702": "public/funiture/파란 침대-270.png", + "파란 침대-902": "public/funiture/파란 침대-90.png", + "파란색 탁자1": "public/funiture/파란색 탁자.png", + "회색 탁자1": "public/funiture/회색 탁자.png", + "흰 노트북1-01": "public/funiture/흰 노트북1-0.png", + "흰 노트북1-1801": "public/funiture/흰 노트북1-180.png", + "흰 노트북1-2701": "public/funiture/흰 노트북1-270.png", + "흰 노트북1-901": "public/funiture/흰 노트북1-90.png", + "흰 노트북2-01": "public/funiture/흰 노트북2-0.png", + "흰 노트북2-1801": "public/funiture/흰 노트북2-180.png", + "흰 노트북2-2701": "public/funiture/흰 노트북2-270.png", + "흰 노트북2-901": "public/funiture/흰 노트북2-90.png", + "흰 노트북3-01": "public/funiture/흰 노트북3-0.png", + "흰 노트북3-1801": "public/funiture/흰 노트북3-180.png", + "흰 노트북3-2701": "public/funiture/흰 노트북3-270.png", + "흰 노트북3-901": "public/funiture/흰 노트북3-90.png", + "흰색 선반-01": "public/funiture/흰색 선반-0.png", + "흰색 선반-1801": "public/funiture/흰색 선반-180.png", + "흰색 선반-2701": "public/funiture/흰색 선반-270.png", + "흰색 선반-901": "public/funiture/흰색 선반-90.png", + "흰색 작은 선반-01": "public/funiture/흰색 작은 선반-0.png", + "흰색 작은 선반-1801": "public/funiture/흰색 작은 선반-180.png", + "흰색 작은 선반-2701": "public/funiture/흰색 작은 선반-270.png", + "흰색 작은 선반-901": "public/funiture/흰색 작은 선반-90.png", + "흰색 탁자1": "public/funiture/흰색 탁자.png", + "1": "1", +} + + +class Room: + def __init__(self, id: int, user_id: int, room_name: str, room_type: RoomTypes): + self.id = id + self.user_id = user_id + self.room_name = room_name + self.room_type = room_type + + def to_response(self) -> RoomResponse: + return RoomResponse( + id=self.id, + user_id=self.user_id, + room_name=self.room_name, + room_type=self.room_type, + room_image_path=room_path[self.room_type], + ) diff --git a/Backend/schemas/user.py b/Backend/schemas/user.py new file mode 100644 index 0000000..1082521 --- /dev/null +++ b/Backend/schemas/user.py @@ -0,0 +1,93 @@ +from pydantic import BaseModel, EmailStr +from datetime import datetime +from typing import Optional +import hashlib +import secrets +from fastapi import Form + + +class UserCreate(BaseModel): + username: str + email: EmailStr + password: str + + @classmethod + def as_form( + cls, + username: str = Form(...), + email: EmailStr = Form(...), + password: str = Form(...), + ) -> "UserCreate": + return cls(username=username, email=email, password=password) + + +class UserUpdate(BaseModel): + email: Optional[EmailStr] = None + password: Optional[str] = None + + @classmethod + def as_form( + cls, + email: Optional[EmailStr] = Form(default=None), + password: Optional[str] = Form(default=None), + ) -> "UserUpdate": + return cls(email=email, password=password) + + +class UserLogin(BaseModel): + username: str + password: str + + +class UserResponse(BaseModel): + id: int + username: str + email: str + created_at: datetime + profile_image_path: str + is_active: bool + + +class User: + def __init__( + self, + id: int, + username: str, + email: str, + password_hash: str, + salt: str, + created_at: datetime, + profile_image_path: str, + is_active: bool = True, + ): + self.id = id + self.username = username + self.email = email + self.password_hash = password_hash + self.salt = salt + self.created_at = created_at + self.profile_image_path = profile_image_path + self.is_active = is_active + + @staticmethod + def hash_password(password: str, salt: Optional[str] = None) -> tuple[str, str]: + if salt is None: + salt = secrets.token_hex(32) + password_hash = hashlib.pbkdf2_hmac( + "sha256", password.encode("utf-8"), salt.encode("utf-8"), 100000 + ) + return password_hash.hex(), salt + + def verify_password(self, password: str) -> bool: + password_hash, _ = self.hash_password(password, self.salt) + return password_hash == self.password_hash + + def to_response(self) -> UserResponse: + return UserResponse( + id=self.id, + username=self.username, + email=self.email, + created_at=self.created_at, + profile_image_path=self.profile_image_path, + is_active=self.is_active, + ) diff --git a/Backend/services/__init__.py b/Backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Backend/services/avatar_service.py b/Backend/services/avatar_service.py new file mode 100644 index 0000000..0869583 --- /dev/null +++ b/Backend/services/avatar_service.py @@ -0,0 +1,74 @@ +from typing import Optional +from ..schemas.avatar import ( + AvatarUpdate, + Avatar, + AvatarOptions, + AvatarType, + TopClothesType, + BottomClothesType, +) +from ..utils.db import execute, fetch_one +from ..utils.queries.avatar import AvatarQueries + + +class AvatarService: + + @staticmethod + async def init_db(): + await execute(AvatarQueries.CREATE_TABLE) + + async def get_or_create_avatar(self, user_id: int) -> Avatar: + avatar = await self.get_user_avatar(user_id) + if not avatar: + avatar = await self.create_default_avatar(user_id) + return avatar + + async def get_user_avatar(self, user_id: int) -> Optional[Avatar]: + row = await fetch_one(AvatarQueries.SELECT_USER_AVATAR, (user_id,)) + if not row: + return None + return Avatar(**row) + + async def create_default_avatar(self, user_id: int) -> Avatar: + await execute( + AvatarQueries.INSERT_AVATAR, + ( + user_id, + AvatarType.MALE.value, + TopClothesType.ANA_CLOTHES.value, + BottomClothesType.JEANS.value, + ), + ) + return await self.get_user_avatar(user_id) + + async def update_avatar(self, user_id: int, avatar_data: AvatarUpdate) -> Avatar: + + update_fields = [] + params = [] + + if avatar_data.avatar_type is not None: + update_fields.append("avatar_type = ?") + params.append(avatar_data.avatar_type.value) + if avatar_data.top_clothe_type is not None: + update_fields.append("top_clothe_type = ?") + params.append(avatar_data.top_clothe_type.value) + if avatar_data.bottom_clothe_type is not None: + update_fields.append("bottom_clothe_type = ?") + params.append(avatar_data.bottom_clothe_type.value) + + if update_fields: + query = AvatarQueries.UPDATE_AVATAR.format(fields=", ".join(update_fields)) + params.append(user_id) + await execute(query, tuple(params)) + + return await self.get_user_avatar(user_id) + + async def get_avatar_options(self) -> AvatarOptions: + return AvatarOptions( + avatar_types=list(AvatarType), + top_clothe_types=list(TopClothesType), + bottom_clothe_types=list(BottomClothesType), + ) + + async def get_avatar_by_userId(self, user_id: int) -> Optional[Avatar]: + return await self.get_user_avatar(user_id) \ No newline at end of file diff --git a/Backend/services/diary_service.py b/Backend/services/diary_service.py new file mode 100644 index 0000000..e8fd232 --- /dev/null +++ b/Backend/services/diary_service.py @@ -0,0 +1,144 @@ +import os +from datetime import datetime +from typing import List, Optional +from fastapi import UploadFile +from ..schemas.diary import DiaryCreate, DiaryUpdate, Diary +from ..utils.db import execute, fetch_one, fetch_all +from ..utils.image_processor import ImageProcessor +from ..utils.queries.diary import DiaryQueries + + +class DiaryService: + def __init__(self): + self.image_processor = ImageProcessor() + self.upload_dir = "uploads/diary" + os.makedirs(self.upload_dir, exist_ok=True) + + @staticmethod + async def init_db(): + await execute(DiaryQueries.CREATE_TABLE) + + async def create_diary( + self, user_id: int, diary_data: DiaryCreate, files: List[UploadFile] + ) -> Diary: + # image_path 는 ,로 구분 되어 있음 + image_path = "" + if files is not None: + for file in files: + image_path += ( + "," + + await self.image_processor.write_file_and_get_image_path( + file, self.upload_dir + ) + ) + image_path = image_path[1:] + + query = DiaryQueries.INSERT_DIARY + + created_at = datetime.now() + await execute( + query, + ( + user_id, + diary_data.title, + diary_data.content, + image_path, + diary_data.category, + created_at, + False, + False, + ), + ) + + row = await fetch_one( + DiaryQueries.SELECT_LATEST_USER_DIARY, + (user_id,), + ) + + return Diary(**row) + + async def get_user_diaries( + self, user_id: int, skip: int = 0, limit: int = 20, category: str = None + ) -> List[Diary]: + if category: + query = DiaryQueries.SELECT_USER_DIARIES_BY_CATEGORY + rows = await fetch_all(query, (user_id, category, limit, skip)) + else: + query = DiaryQueries.SELECT_USER_DIARIES + rows = await fetch_all(query, (user_id, limit, skip)) + + return [Diary(**row) for row in rows] + + async def get_diary_by_id(self, diary_id: int) -> Optional[Diary]: + query = DiaryQueries.SELECT_BY_ID + row = await fetch_one(query, (diary_id,)) + + if not row: + return None + + return Diary(**row) + + async def get_diary_with_user_id( + self, diary_id: int, user_id: int + ) -> Optional[Diary]: + query = DiaryQueries.SELECT_BY_ID_WITH_USER_ID + row = await fetch_one(query, (diary_id, user_id)) + + if not row: + return None + + return Diary(**row) + + async def update_diary( + self, + diary_id: int, + user_id: int, + diary_data: DiaryUpdate, + files: List[UploadFile], + ) -> Optional[Diary]: + diary = await self.get_diary_with_user_id(diary_id, user_id) + if not diary: + return None + + update_fields = [] + params = [] + + if diary_data.title is not None: + update_fields.append("title = ?") + params.append(diary_data.title) + + if diary_data.content is not None: + update_fields.append("content = ?") + params.append(diary_data.content) + + if diary_data.category is not None: + update_fields.append("category = ?") + params.append(diary_data.category) + + if files is not None: + update_fields.append("images = ?") + image_paths = "" + for file in files: + image_paths += ( + "," + + await self.image_processor.write_file_and_get_image_path( + file, self.upload_dir + ) + ) + image_paths = image_paths[1:] + params.append(image_paths) + + if update_fields: + query = DiaryQueries.UPDATE_DIARY.format(fields=", ".join(update_fields)) + params.extend([diary_id, user_id]) + await execute(query, tuple(params)) + + return await self.get_diary_with_user_id(diary_id, user_id) + + async def delete_diary(self, diary_id: int, user_id: int) -> bool: + try: + query = DiaryQueries.DELETE_DIARY + await execute(query, (diary_id, user_id)) + return True + except Exception: + return False diff --git a/Backend/services/friendship_service.py b/Backend/services/friendship_service.py new file mode 100644 index 0000000..3ec4415 --- /dev/null +++ b/Backend/services/friendship_service.py @@ -0,0 +1,142 @@ +from typing import List, Optional +from datetime import datetime +from ..schemas.friendship import Friendship, FriendshipResponse, FriendshipStatus +from ..utils.db import execute, fetch_one, fetch_all +from ..utils.queries.friendship import FriendshipQueries + + +class FriendshipService: + + @staticmethod + async def init_db(): + await execute(FriendshipQueries.CREATE_TABLE) + + async def send_friendship_request( + self, user_id: int, friend_username: str + ) -> FriendshipResponse: + friend_query = FriendshipQueries.SELECT_USER_BY_USERNAME + friend_row = await fetch_one(friend_query, (friend_username,)) + + if not friend_row: + raise ValueError("User not found") + + friend_id = friend_row["id"] + + if user_id == friend_id: + raise ValueError("Cannot send friendship request to yourself") + + existing_query = FriendshipQueries.SELECT_EXISTING_FRIENDSHIP + existing_row = await fetch_one( + existing_query, (user_id, friend_id, friend_id, user_id) + ) + + if existing_row: + raise ValueError("Friendship request already exists") + + created_at = datetime.now() + + query = FriendshipQueries.INSERT_FRIENDSHIP + + await execute( + query, (user_id, friend_id, FriendshipStatus.PENDING.value, created_at) + ) + + friendship_row = await fetch_one( + FriendshipQueries.SELECT_FRIENDSHIP_BY_IDS, + (user_id, friend_id), + ) + + if not friendship_row: + raise ValueError("Friendship not found after creation") + + friendship = Friendship( + id=friendship_row["id"], + user_id=friendship_row["user_id"], + friend_id=friendship_row["friend_id"], + status=friendship_row["status"], + created_at=friendship_row["created_at"], + ) + + return friendship.to_response(friend_username) + + async def accept_friendship_request( + self, friendship_id: int, user_id: int + ) -> Optional[FriendshipResponse]: + friendship_query = FriendshipQueries.SELECT_FRIENDSHIP_FOR_ACCEPT + + friendship_row = await fetch_one( + friendship_query, + (friendship_id, user_id, FriendshipStatus.PENDING.value), + ) + + if not friendship_row: + return None + + update_query = FriendshipQueries.UPDATE_FRIENDSHIP_STATUS + await execute(update_query, (FriendshipStatus.ACCEPTED.value, friendship_id)) + + friendship = Friendship( + id=friendship_row["id"], + user_id=friendship_row["user_id"], + friend_id=friendship_row["friend_id"], + status=FriendshipStatus.ACCEPTED.value, + created_at=friendship_row["created_at"], + ) + + return friendship.to_response(friendship_row["username"]) + + async def get_user_friendships( + self, user_id: int, status: Optional[str] = None + ) -> List[FriendshipResponse]: + if status: + query = FriendshipQueries.SELECT_USER_FRIENDSHIPS_BY_STATUS + rows = await fetch_all(query, (user_id, user_id, user_id, status)) + else: + query = FriendshipQueries.SELECT_USER_FRIENDSHIPS + rows = await fetch_all( + query, (user_id, user_id, user_id, FriendshipStatus.ACCEPTED.value) + ) + + friendships = [] + for row in rows: + friendship = Friendship( + id=row["id"], + user_id=row["user_id"], + friend_id=row["friend_id"], + status=row["status"], + created_at=row["created_at"], + ) + friendships.append(friendship.to_response(row["username"])) + + return friendships + + async def delete_friendship(self, friendship_id: int, user_id: int) -> bool: + query = FriendshipQueries.DELETE_FRIENDSHIP + await execute(query, (friendship_id, user_id, user_id)) + return True + + async def get_pending_requests(self, user_id: int) -> List[FriendshipResponse]: + query = FriendshipQueries.SELECT_PENDING_REQUESTS + + rows = await fetch_all(query, (user_id, FriendshipStatus.PENDING.value)) + + friendships = [] + for row in rows: + friendship = Friendship( + id=row["id"], + user_id=row["user_id"], + friend_id=row["friend_id"], + status=row["status"], + created_at=row["created_at"], + ) + friendships.append(friendship.to_response(row["username"])) + + return friendships + + async def check_friendship(self, user_id1: int, user_id2: int) -> bool: + friendship_query = FriendshipQueries.CHECK_FRIENDSHIP_STATUS + friendship_row = await fetch_one( + friendship_query, (user_id1, user_id2, user_id2, user_id1) + ) + + return friendship_row is not None diff --git a/Backend/services/guestbook_service.py b/Backend/services/guestbook_service.py new file mode 100644 index 0000000..d1648a2 --- /dev/null +++ b/Backend/services/guestbook_service.py @@ -0,0 +1,110 @@ +from datetime import datetime +from typing import List +from fastapi import HTTPException +from Backend.utils.db import execute, fetch_one, fetch_all +from Backend.utils.queries.guestbook import GuestBookQueries +from Backend.schemas.guestbook import GuestBook, GuestBookCreate, GuestbookResponse +from Backend.schemas.user import User +from Backend.utils.queries.user import UserQueries + + +class GuestbookService: + def __init__(self): + pass + + @staticmethod + async def init_db(): + await execute(GuestBookQueries.CREATE_TABLE) + + async def create_guestbook( + self, data: GuestBookCreate, user: User + ) -> GuestbookResponse: + user_exist = await fetch_one(UserQueries.SELECT_BY_ID, (data.target_user_id,)) + if user_exist is None: + raise HTTPException(status_code=404, detail="User not found") + + ex_row = await fetch_one( + GuestBookQueries.SELECT_GUEST_BOOK_BY_USER_ID, (user.id,) + ) + created_at = datetime.now() + query = GuestBookQueries.INSERT_GUEST_BOOK + await execute(query, (data.target_user_id, user.id, data.content, created_at)) + + query = GuestBookQueries.SELECT_GUEST_BOOK_BY_USER_ID + row = await fetch_one(query, (user.id,)) + + if not (ex_row is None): + if row is None or ex_row["id"] == row["id"]: + raise HTTPException( + status_code=400, detail="Failed to create guest book" + ) + + return GuestbookResponse( + id=row["id"], + content=row["content"], + target_user_id=row["target_user_id"], + user_id=row["user_id"], + user_profile_path=user.profile_image_path, + username=user.username, + created_at=row["created_at"], + ) + + async def get_target_user_guestbooks( + self, target_user_id: int, limit: int = 20, offset: int = 0 + ) -> List[GuestbookResponse]: + query = GuestBookQueries.SELECT_TARGET_USER_GUEST_BOOKS + + rows = await fetch_all(query, (target_user_id, limit, offset)) + + response_list = [] + for row in rows: + user = await fetch_one(UserQueries.SELECT_BY_ID, (row["user_id"],)) + + response_list.append( + GuestbookResponse( + id=row["id"], + content=row["content"], + target_user_id=row["target_user_id"], + user_id=row["user_id"], + user_profile_path=user["profile_image_path"], + username=user["username"], + created_at=row["created_at"], + ) + ) + + return response_list + + async def update_guestbook_by_id(self, id: int, content: str) -> GuestbookResponse: + query = GuestBookQueries.SELECT_GUEST_BOOK_BY_ID + + row = await fetch_one(query, (id,)) + + if row is None: + raise HTTPException(status_code=404, detail="Guest book not found") + + query = GuestBookQueries.UPDATE_GUEST_BOOK_BY_ID + await execute(query, (content, id)) + + query = GuestBookQueries.SELECT_GUEST_BOOK_BY_ID + + row = await fetch_one(query, (id,)) + + user = await fetch_one(UserQueries.SELECT_BY_ID, (row["user_id"],)) + + return GuestbookResponse( + id=row["id"], + content=row["content"], + target_user_id=row["target_user_id"], + user_id=row["user_id"], + user_profile_path=user["profile_image_path"], + username=user["username"], + created_at=row["created_at"], + ) + + async def delete_guestbook_by_id(self, id: int, user_id: int) -> bool: + try: + query = GuestBookQueries.DELETE_GUEST_BOOK + await execute(query, (id, user_id)) + return True + except Exception: + return False diff --git a/Backend/services/letter_service.py b/Backend/services/letter_service.py new file mode 100644 index 0000000..fcc6422 --- /dev/null +++ b/Backend/services/letter_service.py @@ -0,0 +1,81 @@ +# from typing import List, Optional +# from ..schemas.letter import LetterCreate, Letter, EmailRequest +# from ..utils.db import execute, fetch_one, fetch_all +# from ..utils.default_queries import LetterQueries +# from ..utils.email_processor import EmailProcessor +# +# +# class LetterService: +# def __init__(self): +# self.email_processor = EmailProcessor() +# +# @staticmethod +# async def init_db(): +# await execute(LetterQueries.CREATE_TABLE) +# +# async def create_letter(self, sender_id: int, letter_data: LetterCreate) -> Letter: +# query = LetterQueries.INSERT_LETTER +# +# await execute( +# query, +# (sender_id, letter_data.content), +# ) +# +# row = await fetch_one( +# LetterQueries.SELECT_LATEST_USER_LETTER, +# (sender_id,), +# ) +# +# return Letter(**row) +# +# async def get_user_letters( +# self, sender_id: int, skip: int = 0, limit: int = 20 +# ) -> List[Letter]: +# query = LetterQueries.SELECT_USER_LETTERS +# rows = await fetch_all(query, (sender_id, limit, skip)) +# return [Letter(**row) for row in rows] +# +# async def get_letter_by_id( +# self, letter_id: int, sender_id: int +# ) -> Optional[Letter]: +# query = LetterQueries.SELECT_LETTER_BY_ID +# row = await fetch_one(query, (letter_id, sender_id)) +# if not row: +# return None +# return Letter(**row) +# +# async def delete_letter(self, letter_id: int, sender_id: int) -> bool: +# try: +# query = LetterQueries.DELETE_LETTER +# await execute( +# query, +# (letter_id, sender_id), +# ) +# return True +# except Exception: +# return False +# +# async def update_letter( +# self, letter_id: int, sender_id: int, content: str +# ) -> Optional[Letter]: +# query = LetterQueries.UPDATE_LETTER +# await execute( +# query, +# (content, letter_id, sender_id), +# ) +# +# row = await fetch_one( +# LetterQueries.SELECT_LETTER_BY_ID, +# (letter_id, sender_id), +# ) +# if row is None: +# return None +# +# return Letter(**row) +# +# async def send_letter(self, letter: Letter, data: EmailRequest): +# subject = f"2025_SSF_LETTER_{data.sender_name}" +# content = letter.content +# await self.email_processor.send_email( +# subject, content, data.sender_email, data.sender_password +# ) diff --git a/Backend/services/photo_service.py b/Backend/services/photo_service.py new file mode 100644 index 0000000..43f11f8 --- /dev/null +++ b/Backend/services/photo_service.py @@ -0,0 +1,150 @@ +# import os +# from datetime import datetime +# from typing import List +# from fastapi import UploadFile +# from ..schemas.photo import ( +# PhotoUpload, +# Photo, +# PhotoComment, +# CommentCreate, +# CommentResponse, +# ) +# from ..utils.db import execute, fetch_one, fetch_all +# from ..utils.default_queries import PhotoQueries +# from ..utils.image_processor import ImageProcessor +# +# +# class PhotoService: +# def __init__(self): +# self.image_processor = ImageProcessor() +# self.upload_dir = "uploads/photos" +# os.makedirs(self.upload_dir, exist_ok=True) +# +# @staticmethod +# async def init_db(): +# await execute(PhotoQueries.CREATE_TABLE) +# await execute(PhotoQueries.CREATE_COMMENTS_TABLE) +# +# async def upload_photo( +# self, user_id: int, photo_data: PhotoUpload, file: UploadFile +# ) -> Photo: +# if not file.content_type.startswith("image/"): +# raise ValueError("File must be an image") +# +# self.image_processor.validate_image_file(file.filename, file.size) +# +# created_at = datetime.now() +# +# image_path = await self.image_processor.write_file_and_get_image_path( +# file, self.upload_dir +# ) +# +# query = PhotoQueries.INSERT_PHOTO +# +# await execute( +# query, +# (user_id, photo_data.album_name, image_path, photo_data.title, created_at), +# ) +# +# row = await fetch_one( +# PhotoQueries.SELECT_LATEST_USER_PHOTO, +# (user_id,), +# ) +# +# return Photo(**row) +# +# async def get_user_photos( +# self, user_id: int, skip: int = 0, limit: int = 20, album_name: str = None +# ) -> List[Photo]: +# if album_name: +# query = PhotoQueries.SELECT_USER_PHOTOS_BY_ALBUM +# rows = await fetch_all(query, (user_id, album_name, limit, skip)) +# else: +# query = PhotoQueries.SELECT_USER_PHOTOS +# rows = await fetch_all(query, (user_id, limit, skip)) +# +# return [Photo(**row) for row in rows] +# +# async def check_friendship(self, user_id: int, photo_id: int) -> bool: +# photo_query = PhotoQueries.SELECT_PHOTO_OWNER +# photo_row = await fetch_one(photo_query, (photo_id,)) +# +# if not photo_row: +# return False +# +# photo_owner_id = photo_row["user_id"] +# +# if user_id == photo_owner_id: +# return True +# +# from ..services.friendship_service import FriendshipService +# +# friendship_service = FriendshipService() +# return await friendship_service.check_friendship(user_id, photo_owner_id) +# +# async def add_comment( +# self, photo_id: int, user_id: int, comment_data: CommentCreate +# ) -> PhotoComment: +# if not await self.check_friendship(user_id, photo_id): +# raise ValueError("Cannot add comment before being friends") +# created_at = datetime.now() +# +# query = PhotoQueries.INSERT_COMMENT +# +# await execute(query, (photo_id, user_id, comment_data.content, created_at)) +# +# row = await fetch_one( +# PhotoQueries.SELECT_LATEST_COMMENT, +# (photo_id, user_id), +# ) +# +# return PhotoComment(**row) +# +# async def get_photo_comments(self, photo_id: int) -> List[CommentResponse]: +# query = PhotoQueries.SELECT_PHOTO_COMMENTS +# +# rows = await fetch_all(query, (photo_id,)) +# +# return [CommentResponse(**row) for row in rows] +# +# async def apply_filter( +# self, +# photo_id: int, +# filter_type: str, +# user_id: int, +# cover: bool = False, +# title: str = None, +# ) -> str: +# photo_query = PhotoQueries.SELECT_PHOTO_BY_ID +# row = await fetch_one(photo_query, (photo_id, user_id)) +# +# if not row: +# raise ValueError("Photo not found") +# +# original_path = row["image_path"] +# filtered_path = await self.image_processor.apply_filter( +# original_path, filter_type +# ) +# +# if cover: +# photo_update_query = PhotoQueries.UPDATE_PHOTO_PATH +# await execute(photo_update_query, (filtered_path, photo_id, user_id)) +# else: +# row = await fetch_one( +# PhotoQueries.SELECT_PHOTO_ALBUM_NAME, (photo_id, user_id) +# ) +# photo_create_query = PhotoQueries.INSERT_PHOTO +# await execute( +# photo_create_query, +# (user_id, row["album_name"], filtered_path, title, datetime.now()), +# ) +# +# return filtered_path +# +# async def delete_photo(self, photo_id: int, user_id: int) -> bool: +# try: +# query = PhotoQueries.DELETE_PHOTO +# await execute(query, (photo_id, user_id)) +# return True +# except Exception: +# return False diff --git a/Backend/services/room_service.py b/Backend/services/room_service.py new file mode 100644 index 0000000..122e4ba --- /dev/null +++ b/Backend/services/room_service.py @@ -0,0 +1,149 @@ +from fastapi import HTTPException +from typing import List +from Backend.utils.queries.room import RoomQueries +from Backend.schemas.room import ( + FurnitureItem, + RoomFurniturePlacement, + FurniturePlacementRequest, + Room, + RoomTypes, + RoomTypeResponse, + room_path, + Furniture, + furniture_path, + RoomFurnitureResponse, + FurniturePlacementResponse, + RoomResponse, +) +from Backend.utils.db import fetch_all, fetch_one, execute + + +class RoomService: + + @staticmethod + async def init_db(): + await execute(RoomQueries.CREATE_TABLE) + await execute(RoomQueries.CREATE_TABLE_ROOM_FURNITURE) + await execute(RoomQueries.CREATE_TABLE_USER_FURNITURE) + + async def get_or_create_room(self, user_id: int) -> int: + row = await fetch_one(RoomQueries.SELECT_ROOM_ID_BY_USER_ID, (user_id,)) + if row: + return row["id"] + + await execute(RoomQueries.INSERT_ROOM, (user_id, "My Room", "room_1")) + new_row = await fetch_one(RoomQueries.SELECT_ROOM_ID_BY_USER_ID, (user_id,)) + + return new_row["id"] + + async def get_room_by_id(self, id: int) -> Room: + row = await fetch_one(RoomQueries.SELECT_ROOM_BY_ID, (id,)) + if row is None: + raise HTTPException(status_code=404, detail="Room not found") + + return Room(**row) + + async def get_room_by_userId(self, id: int) -> Room: + row = await fetch_one(RoomQueries.SELECT_ROOM_BY_USER_ID, (id,)) + if row is None: + raise HTTPException(status_code=404, detail="Room not found") + + return Room(**row) + + async def get_room_types(self) -> List[RoomTypeResponse]: + return [ + RoomTypeResponse(type=rt.value, image_path=room_path[rt.value]) + for rt in RoomTypes + ] + + async def update_room_name(self, room_id: int, new_name: str): + await execute(RoomQueries.UPDATE_ROOM_NAME, (new_name, room_id)) + + async def update_room_type(self, room_id: int, new_type: RoomTypes): + query = RoomQueries.UPDATE_ROOM_TYPE + await execute(query, (new_type.value, room_id)) + + row = await fetch_one(RoomQueries.SELECT_ROOM_BY_ID, (room_id,)) + return Room(**row) + + # furniture + async def get_furniture_catalog(self) -> List[FurnitureItem]: + furniture_list = [] + for f in list(Furniture): + furniture_list.append( + FurnitureItem( + name=f, + image_path=furniture_path[f], + width=int(f[-1]), + ) + ) + + return furniture_list + + async def get_room_furnitures(self, room_id: int) -> RoomFurnitureResponse: + rows = await fetch_all(RoomQueries.SELECT_ROOM_FURNITURE, (room_id,)) + furniture_placement_response = [] + for row in rows: + furniture_placement_response.append( + FurniturePlacementResponse( + furniture_name=row["furniture_name"], + x=row["x"], + y=row["y"], + image_path=furniture_path[row["furniture_name"]], + ) + ) + row = await fetch_one(RoomQueries.SELECT_ROOM_BY_ID, (room_id,)) + return RoomFurnitureResponse( + furniture=furniture_placement_response, + room=RoomResponse( + id=row["id"], + user_id=row["user_id"], + room_name=row["room_name"], + room_type=row["room_type"], + room_image_path=room_path[row["room_type"]], + ), + ) + + async def place_furniture(self, room_id: int, request: FurniturePlacementRequest): + is_oneone = furniture_path.get(request.furniture_name + "1") is not None + + placed_furnitures = await fetch_all( + RoomQueries.SELECT_ROOM_FURNITURE, (room_id,) + ) + + for f in placed_furnitures: + if f["x"] == request.x and f["y"] == request.y: + raise HTTPException(status_code=409, detail="Furniture already placed") + + await execute( + RoomQueries.INSERT_ROOM_FURNITURE, + (room_id, request.furniture_name, request.x, request.y), + ) + if not is_oneone: + await execute( + RoomQueries.INSERT_ROOM_FURNITURE, + (room_id, "1", request.x - 1, request.y), + ) + + async def remove_furniture(self, room_id: int, x: int, y: int, furniture_name: str): + is_oneone = furniture_path.get(furniture_name + "1") is not None + await execute(RoomQueries.DELETE_FURNITURE, (room_id, x, y)) + if not is_oneone: + await execute(RoomQueries.DELETE_FURNITURE, (room_id, x - 1, y)) + + async def add_furniture(self, user_id: int, furniture_name: str): + await execute(RoomQueries.INSERT_USER_FURNITURE, (user_id, furniture_name)) + + async def get_user_furniture(self, user_id: int): + rows = await fetch_all(RoomQueries.SELECT_USER_FURNITURE, (user_id,)) + furniture_list = [] + for row in rows: + furniture_list.append( + FurnitureItem( + name=row["furniture_name"], + image_path=furniture_path[row["furniture_name"]], + width=int(row["furniture_name"][-1]), + ) + ) + + return furniture_list \ No newline at end of file diff --git a/Backend/services/store_service.py b/Backend/services/store_service.py new file mode 100644 index 0000000..d393875 --- /dev/null +++ b/Backend/services/store_service.py @@ -0,0 +1,33 @@ +import httpx + + +class StoreService: + def __init__(self): + self.SERVER_URL = "https://dotory.ana.st" + + async def get_dotory_by_id(self, user_id: int): + async with httpx.AsyncClient() as client: + response = await client.get(f"{self.SERVER_URL}/") + response_json = response.json() + return + + async def register_user(self, user_id: int): + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.SERVER_URL}/", json={"user_id": user_id} + ) + return + + async def buy_product(self, product_id: int, user_id: int): + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.SERVER_URL}/", json={"user_id": user_id} + ) + return + + async def update_user_dotory(self, user_id: int, dotoryNum: int): + async with httpx.AsyncClient() as client: + response = await client.put( + f"{self.SERVER_URL}/", json={"num": dotoryNum} + ) + return \ No newline at end of file diff --git a/Backend/services/user_service.py b/Backend/services/user_service.py new file mode 100644 index 0000000..93bf44d --- /dev/null +++ b/Backend/services/user_service.py @@ -0,0 +1,178 @@ +from datetime import datetime, timezone, timedelta +from typing import Optional, List +import os +from fastapi import UploadFile +from Backend.utils.db import execute, fetch_one, fetch_all +from Backend.utils.image_processor import ImageProcessor +from Backend.utils.queries.user import UserQueries +from Backend.schemas.user import User, UserCreate, UserResponse, UserUpdate +from Backend.services.store_service import StoreService + +store_service = StoreService() + +class UserService: + def __init__(self): + self.image_processor = ImageProcessor() + self.upload_dir = "uploads/profile" + os.makedirs(self.upload_dir, exist_ok=True) + + @staticmethod + async def init_db(): + await execute(UserQueries.CREATE_TABLE) + + async def create_user( + self, user_data: UserCreate, profile_file: UploadFile = None + ) -> User: + password_hash, salt = User.hash_password(user_data.password) + + if profile_file is not None: + await self.image_processor.validate_image_file( + + ) + image_path = await self.image_processor.write_file_and_get_image_path( + profile_file, upload_dir=self.upload_dir + ) + query = UserQueries.INSERT_USER_WITH_PROFILE + params = ( + user_data.username, + user_data.email, + password_hash, + salt, + image_path, + ) + else: + query = UserQueries.INSERT_USER_WITHOUT_PROFILE + params = (user_data.username, user_data.email, password_hash, salt) + + await execute( + query, + params, + ) + + row = await fetch_one( + UserQueries.SELECT_BY_USERNAME, + (user_data.username,), + ) + + if row is None: + raise Exception("User creation failed") + + row = dict(row) + + if isinstance(row["created_at"], str): + datetime.fromisoformat(row["created_at"].replace("Z", "+09:00")) + + row["is_active"] = bool(row["is_active"]) + + await store_service.register_user(row["id"]) + + + return User(**row) + + async def get_user_by_username(self, username: str) -> Optional[User]: + row = await fetch_one( + UserQueries.SELECT_BY_USERNAME, + (username,), + ) + + if row is None: + return None + + row = dict(row) + + if isinstance(row["created_at"], str): + datetime.fromisoformat(row["created_at"].replace("Z", "+09:00")) + + row["is_active"] = bool(row["is_active"]) + + return User(**row) + + async def get_user_by_email(self, email: str) -> Optional[User]: + row = await fetch_one( + UserQueries.SELECT_BY_EMAIL, + (email,), + ) + + if row is None: + return None + + row = dict(row) + + if isinstance(row["created_at"], str): + datetime.fromisoformat(row["created_at"].replace("Z", "+09:00")) + + row["is_active"] = bool(row["is_active"]) + + return User(**row) + + async def authenticate_user(self, username: str, password: str) -> Optional[User]: + user = + if user and : + return user + return None + + async def delete_user(self, username: str) -> bool: + try: + query = UserQueries.DELETE_USER_BY_USERNAME + await execute(query, (username,)) + return True + except Exception: + return False + + async def find_user(self, username: str) -> List[UserResponse]: + query = UserQueries.SELECT_BY_USERNAME_LIKE + rows = await fetch_all( + query, + ("%" + username + "%",), + ) + + return [User(**row).to_response() for row in rows] + + async def get_user_by_id(self, user_id: int) -> Optional[User]: + row = await fetch_one(UserQueries.SELECT_BY_ID, (user_id,)) + if row is None: + return None + row = dict(row) + if isinstance(row["created_at"], str): + datetime.fromisoformat(row["created_at"].replace("Z", "+09:00")) + row["is_active"] = bool(row["is_active"]) + return User(**row) + + async def update_user( + self, user: User, user_data: UserUpdate, profile_file: UploadFile = None + ) -> User: + update_fields = {} + if user_data.email: + existing_user = await fetch_one( + UserQueries.SELECT_USER_BY_EMAIL_AND_NOT_ID, + (user_data.email, user.id), + ) + if existing_user: + raise ValueError("Email already registered") + update_fields["email"] = user_data.email + + if user_data.password: + password_hash, salt = User.hash_password(user_data.password) + update_fields["password_hash"] = password_hash + update_fields["salt"] = salt + + if profile_file: + await self.image_processor.validate_image_file( + profile_file.filename, profile_file.size + ) + image_path = await self.image_processor.write_file_and_get_image_path( + profile_file, upload_dir=self.upload_dir + ) + update_fields["profile_image_path"] = image_path + + if not update_fields: + return user + + set_clause = ", ".join(f"{key} = ?" for key in update_fields.keys()) + query = UserQueries.UPDATE_USER_BY_ID.format(set_clause) + params = list(update_fields.values()) + params.append(user.id) + + await execute(query, tuple(params)) + + return await self.get_user_by_id(user.id) diff --git a/Backend/tests/__init__.py b/Backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Backend/tests/conftest.py b/Backend/tests/conftest.py new file mode 100644 index 0000000..312f139 --- /dev/null +++ b/Backend/tests/conftest.py @@ -0,0 +1,81 @@ +import pytest +from fastapi.testclient import TestClient +import sys +import os + +# Add Backend to path +sys.path.append(os.path.join(os.path.dirname(__file__), "..")) + +from Backend import app + + +@pytest.fixture(scope="module") +def client(): + with TestClient(app) as c: + yield c + + +@pytest.fixture +def authenticated_user(client): + user_data = { + "username": "testuser_authenticated", + "email": "test_auth@example.com", + "password": "testpassword123", + } + # Register user + response = client.post("/api/user/register", data=user_data) + + # Login to get token + login_data = {"username": user_data["username"], "password": user_data["password"]} + response = client.post("/api/user/login", json=login_data) + token = response.json()["access_token"] + + yield {"token": token, "user_data": user_data} + + # Cleanup: delete user via API + headers = {"Authorization": f"Bearer {token}"} + client.delete(f"/api/user/{user_data['username']}", headers=headers) + + +@pytest.fixture +def two_authenticated_users(client): + user1_data = { + "username": "testuser1", + "email": "test1@example.com", + "password": "testpassword123", + } + user2_data = { + "username": "testuser2", + "email": "test2@example.com", + "password": "testpassword123", + } + + # Register users + client.post("/api/user/register", data=user1_data) + client.post("/api/user/register", data=user2_data) + + # Login users + login1_data = { + "username": user1_data["username"], + "password": user1_data["password"], + } + response1 = client.post("/api/user/login", json=login1_data) + token1 = response1.json()["access_token"] + + login2_data = { + "username": user2_data["username"], + "password": user2_data["password"], + } + response2 = client.post("/api/user/login", json=login2_data) + token2 = response2.json()["access_token"] + + yield { + "user1": {"token": token1, "username": user1_data["username"]}, + "user2": {"token": token2, "username": user2_data["username"]}, + } + + # Cleanup + headers1 = {"Authorization": f"Bearer {token1}"} + headers2 = {"Authorization": f"Bearer {token2}"} + client.delete(f"/api/user/{user1_data['username']}", headers=headers1) + client.delete(f"/api/user/{user2_data['username']}", headers=headers2) diff --git a/Backend/tests/test_avatar.py b/Backend/tests/test_avatar.py new file mode 100644 index 0000000..13d41d8 --- /dev/null +++ b/Backend/tests/test_avatar.py @@ -0,0 +1,71 @@ +from fastapi.testclient import TestClient +from Backend.schemas.avatar import ( + AvatarUpdate, + AvatarType, + TopClothesType, + BottomClothesType, +) + + +def test_get_my_avatar(client: TestClient, authenticated_user): + headers = {"Authorization": f"Bearer {authenticated_user['token']}"} + response = client.get("/api/avatar", headers=headers) + assert response.status_code == 200 + avatar_data = response.json() + assert "id" in avatar_data + assert "user_id" in avatar_data + assert avatar_data["avatar_type"]["name"] == AvatarType.MALE.value + + +def test_update_my_avatar(client: TestClient, authenticated_user): + headers = {"Authorization": f"Bearer {authenticated_user['token']}"} + + response = client.get("/api/avatar", headers=headers) + assert response.status_code == 200 + update_data = AvatarUpdate( + avatar_type=AvatarType.FEMALE, + top_clothe_type=TopClothesType.SCHOOL_CLOTHES, + bottom_clothe_type=BottomClothesType.SCHOOL_CLOTHES_2, + ) + response = client.put( + "/api/avatar", json=update_data.model_dump(mode="json"), headers=headers + ) + + assert response.status_code == 200 + avatar_data = response.json() + assert avatar_data["avatar_type"]["name"] == update_data.avatar_type.value + assert avatar_data["top_clothe_type"]["name"] == update_data.top_clothe_type.value + assert ( + avatar_data["bottom_clothe_type"]["name"] + == update_data.bottom_clothe_type.value + ) + + +def test_get_avatar_options(client: TestClient): + response = client.get("/api/avatar/options") + assert response.status_code == 200 + options = response.json() + assert "avatar_types" in options + assert "top_clothe_types" in options + assert "bottom_clothe_types" in options + assert all(isinstance(item, str) for item in options["avatar_types"]) + assert all(isinstance(item, str) for item in options["top_clothe_types"]) + assert all(isinstance(item, str) for item in options["bottom_clothe_types"]) + + +def test_get_avatar_by_user_id(client: TestClient, authenticated_user): + headers = {"Authorization": f"Bearer {authenticated_user['token']}"} + response = client.get("/api/avatar", headers=headers) + assert response.status_code == 200 + user_id = response.json()["user_id"] + + response = client.get(f"/api/avatar/{user_id}", headers=headers) + assert response.status_code == 200 + avatar_data = response.json() + assert "id" in avatar_data + assert avatar_data["user_id"] == user_id + assert avatar_data["avatar_type"]["name"] == AvatarType.MALE.value + + # Test for a non-existent user + response = client.get("/api/avatar/9999", headers=headers) + assert response.status_code == 404 \ No newline at end of file diff --git a/Backend/tests/test_diary.py b/Backend/tests/test_diary.py new file mode 100644 index 0000000..f5bd904 --- /dev/null +++ b/Backend/tests/test_diary.py @@ -0,0 +1,52 @@ +from io import BytesIO + + +def test_diary_operations(client, authenticated_user): + token = authenticated_user["token"] + headers = {"Authorization": f"Bearer {token}"} + + # Create Diary + diary_data = { + "title": "test title", + "content": "test content", + "category": "test category", + } + response = client.post( + "/api/diary", + data=diary_data, + files={"file": ("a.jpg", BytesIO(b"aaa"), "image/jpeg")}, + headers=headers, + ) + assert response.status_code == 200 + diary_id = response.json()["id"] + assert response.json()["title"] == "test title" + + # Get Diary + response = client.get(f"/api/diary/{diary_id}", headers=headers) + assert response.status_code == 200 + assert response.json()["id"] == diary_id + + # List Diaries + response = client.get("/api/diary", headers=headers) + assert response.status_code == 200 + assert len(response.json()) > 0 + + # Update Diary + updated_diary_data = {"title": "updated title", "content": "updated content"} + response = client.put( + f"/api/diary/{diary_id}", + data=updated_diary_data, + files={"file": ("b.jpg", BytesIO(b"bbb"), "image/jpeg")}, + headers=headers, + ) + assert response.status_code == 200 + assert response.json()["title"] == "updated title" + + # Delete Diary + response = client.delete(f"/api/diary/{diary_id}", headers=headers) + assert response.status_code == 200 + assert response.json()["message"] == "Diary deleted successfully" + + # Verify Deletion + response = client.get(f"/api/diary/{diary_id}", headers=headers) + assert response.status_code == 400 diff --git a/Backend/tests/test_friendship.py b/Backend/tests/test_friendship.py new file mode 100644 index 0000000..781fc4a --- /dev/null +++ b/Backend/tests/test_friendship.py @@ -0,0 +1,45 @@ +def test_friendship_flow(client, two_authenticated_users): + user1 = two_authenticated_users["user1"] + user2 = two_authenticated_users["user2"] + + headers1 = {"Authorization": f"Bearer {user1['token']}"} + headers2 = {"Authorization": f"Bearer {user2['token']}"} + + # User 1 sends friendship request to User 2 + response = client.post( + "/api/friendship/request", + json={"friend_username": user2["username"]}, + headers=headers1, + ) + assert response.status_code == 200 + friendship_id = response.json()["id"] + assert response.json()["status"] == "pending" + + # User 2 accepts friendship request + response = client.put(f"/api/friendship/{friendship_id}/accept", headers=headers2) + assert response.status_code == 200 + assert response.json()["status"] == "accepted" + + # User 1 lists friends + response = client.get("/api/friendship", headers=headers1) + assert response.status_code == 200 + assert any(f["id"] == friendship_id for f in response.json()) + + # User 2 lists friends + response = client.get("/api/friendship", headers=headers2) + assert response.status_code == 200 + assert any(f["id"] == friendship_id for f in response.json()) + + # User 1 deletes friendship + response = client.delete(f"/api/friendship/{friendship_id}", headers=headers1) + assert response.status_code == 200 + assert response.json()["message"] == "Friendship deleted successfully" + + # Verify deletion for both users + response = client.get("/api/friendship", headers=headers1) + assert response.status_code == 200 + assert not any(f["id"] == friendship_id for f in response.json()) + + response = client.get("/api/friendship", headers=headers2) + assert response.status_code == 200 + assert not any(f["id"] == friendship_id for f in response.json()) diff --git a/Backend/tests/test_guestbook.py b/Backend/tests/test_guestbook.py new file mode 100644 index 0000000..c256f97 --- /dev/null +++ b/Backend/tests/test_guestbook.py @@ -0,0 +1,38 @@ +def test_guest_book(client, authenticated_user): + token = authenticated_user["token"] + headers = {"Authorization": f"Bearer {token}"} + + user_data = { + "username": "testtarget", + "email": "test@example.com", + "password": "testpassword123", + } + response = client.post("/api/user/register", data=user_data) + assert response.status_code == 201 + user_id = response.json()["id"] + username = response.json()["username"] + + response = client.post( + "/api/guestbook", + json={"target_user_id": user_id, "content": "test"}, + headers=headers, + ) + assert response.status_code == 201 + assert response.json()["content"] == "test" + id = response.json()["id"] + + response = client.get(f"/api/guestbook/{user_id}", headers=headers) + assert response.status_code == 200 + assert response.json()[0]["content"] == "test" + + response = client.put( + f"/api/guestbook/{id}", json={"content": "test2"}, headers=headers + ) + assert response.status_code == 200 + assert response.json()["content"] == "test2" + + response = client.delete(f"/api/guestbook/{id}", headers=headers) + assert response.status_code == 200 + + client.delete(f"/api/user/{username}", headers=headers) + assert response.status_code == 200 diff --git a/Backend/tests/test_letter.py b/Backend/tests/test_letter.py new file mode 100644 index 0000000..014db38 --- /dev/null +++ b/Backend/tests/test_letter.py @@ -0,0 +1,32 @@ +# def test_letter_operations(client, authenticated_user): +# token = authenticated_user["token"] +# headers = {"Authorization": f"Bearer {token}"} +# +# # Create Letter +# letter_data = {"content": "test content"} +# response = client.post("/api/letter", json=letter_data, headers=headers) +# assert response.status_code == 200 +# letter_id = response.json()["id"] +# assert response.json()["content"] == "test content" +# +# # Get Letter +# response = client.get(f"/api/letter/{letter_id}", headers=headers) +# assert response.status_code == 200 +# assert response.json()["id"] == letter_id +# +# # Update Letter +# updated_letter_data = {"content": "updated content"} +# response = client.put( +# f"/api/letter/{letter_id}", json=updated_letter_data, headers=headers +# ) +# assert response.status_code == 200 +# assert response.json()["content"] == "updated content" +# +# # Delete Letter +# response = client.delete(f"/api/letter/{letter_id}", headers=headers) +# assert response.status_code == 200 +# assert response.json()["detail"] == "Letter deleted" +# +# # Verify Deletion +# response = client.get(f"/api/letter/{letter_id}", headers=headers) +# assert response.status_code == 400 diff --git a/Backend/tests/test_photo.py b/Backend/tests/test_photo.py new file mode 100644 index 0000000..932b771 --- /dev/null +++ b/Backend/tests/test_photo.py @@ -0,0 +1,94 @@ +# from io import BytesIO +# import json +# +# +# def test_photo_upload_and_delete(client, authenticated_user): +# token = authenticated_user["token"] +# headers = {"Authorization": f"Bearer {token}"} +# +# # Upload Photo +# photo_data = {"album_name": "test_album", "title": "test_title"} +# photo_data_json = json.dumps(photo_data).encode("utf-8") +# +# response = client.post( +# "/api/photo/upload", +# files={ +# "photo_data": ( +# "photo_data.json", +# BytesIO(photo_data_json), +# "application/json", +# ), +# "file": ("a.jpg", BytesIO(b"aaa"), "image/jpeg"), +# }, +# headers=headers, +# ) +# assert response.status_code == 200 +# photo_id = response.json()["id"] +# assert response.json()["album_name"] == "test_album" +# +# # Delete Photo +# response = client.delete(f"/api/photo/{photo_id}", headers=headers) +# assert response.status_code == 200 +# assert response.json()["message"] == "Photo deleted successfully" +# +# +# def test_photo_commenting(client, two_authenticated_users): +# user1 = two_authenticated_users["user1"] +# user2 = two_authenticated_users["user2"] +# +# headers1 = {"Authorization": f"Bearer {user1['token']}"} +# headers2 = {"Authorization": f"Bearer {user2['token']}"} +# +# # User 1 uploads a photo +# photo_data = {"album_name": "test_album", "title": "test_title"} +# photo_data_json = json.dumps(photo_data).encode("utf-8") +# response = client.post( +# "/api/photo/upload", +# files={ +# "photo_data": ( +# "photo_data.json", +# BytesIO(photo_data_json), +# "application/json", +# ), +# "file": ("a.jpg", BytesIO(b"aaa"), "image/jpeg"), +# }, +# headers=headers1, +# ) +# assert response.status_code == 200 +# photo_id = response.json()["id"] +# +# # User 2 cannot comment before being friends +# response = client.post( +# f"/api/photo/{photo_id}/comment", +# json={"content": "test comment"}, +# headers=headers2, +# ) +# assert response.status_code == 400 +# +# # User 1 sends friendship request to User 2 +# response = client.post( +# "/api/friendship/request", +# json={"friend_username": user2["username"]}, +# headers=headers1, +# ) +# assert response.status_code == 200 +# friendship_id = response.json()["id"] +# +# # User 2 accepts friendship request +# response = client.put(f"/api/friendship/{friendship_id}/accept", headers=headers2) +# assert response.status_code == 200 +# +# # User 2 can now comment +# response = client.post( +# f"/api/photo/{photo_id}/comment", +# json={"content": "test comment"}, +# headers=headers2, +# ) +# assert response.status_code == 200 +# assert response.json()["content"] == "test comment" +# +# # User 1 can see the comment +# response = client.get(f"/api/photo/{photo_id}/comments", headers=headers1) +# assert response.status_code == 200 +# assert len(response.json()) > 0 +# assert response.json()[0]["content"] == "test comment" diff --git a/Backend/tests/test_room.py b/Backend/tests/test_room.py new file mode 100644 index 0000000..509fafd --- /dev/null +++ b/Backend/tests/test_room.py @@ -0,0 +1,161 @@ +from fastapi.testclient import TestClient +from Backend.schemas.room import Furniture, RoomTypes + + +def test_get_furniture_catalog(client: TestClient): + response = client.get("/api/room/catalog") + assert response.status_code == 200 + catalog = response.json() + assert isinstance(catalog, list) + assert len(catalog) > 0 + for item in catalog: + assert "name" in item + assert "image_path" in item + assert "width" in item + + +def test_get_room_types(client: TestClient): + response = client.get("/api/room/types") + assert response.status_code == 200 + types = response.json() + assert isinstance(types, list) + assert len(types) > 0 + for room_type in types: + assert "type" in room_type + assert "image_path" in room_type + + +def test_get_my_room(client: TestClient, authenticated_user): + headers = {"Authorization": f"Bearer {authenticated_user['token']}"} + response = client.get("/api/room/", headers=headers) + assert response.status_code == 200 + room_data = response.json() + assert "id" in room_data + assert "room_name" in room_data + assert "room_type" in room_data + assert "room_image_path" in room_data + + +def test_update_room_name(client: TestClient, authenticated_user): + headers = {"Authorization": f"Bearer {authenticated_user['token']}"} + response = client.get("/api/room/", headers=headers) + new_name = "My Awesome Room" + response = client.put("/api/room/", json={"new_name": new_name}, headers=headers) + + assert response.status_code == 200 + assert response.json() == {"message": "Room name updated successfully"} + + # Verify the change + response = client.get("/api/room/", headers=headers) + assert response.status_code == 200 + assert response.json()["room_name"] == new_name + + +def test_update_room_type(client: TestClient, authenticated_user): + headers = {"Authorization": f"Bearer {authenticated_user['token']}"} + response = client.get("/api/room/", headers=headers) + new_type = RoomTypes.ROOM_2.value + + response = client.patch("/api/room/", json={"type": new_type}, headers=headers) + assert response.status_code == 200 + updated_room = response.json() + assert updated_room["room_type"] == new_type + + +def test_get_my_room_layout(client: TestClient, authenticated_user): + headers = {"Authorization": f"Bearer {authenticated_user['token']}"} + response = client.get("/api/room/layout", headers=headers) + assert response.status_code == 200 + layout_data = response.json() + assert "room" in layout_data + assert "furniture" in layout_data + assert isinstance(layout_data["furniture"], list) + + +def test_place_furniture(client: TestClient, authenticated_user): + headers = {"Authorization": f"Bearer {authenticated_user['token']}"} + placement_data = { + "furniture_name": Furniture.SOFA_0.value, + "x": 5, + "y": 5, + } + response = client.post("/api/room/furniture", json=placement_data, headers=headers) + assert response.status_code == 200 + assert response.json() == {"message": "Furniture placed successfully"} + + # Verify placement + layout_response = client.get("/api/room/layout", headers=headers) + assert layout_response.status_code == 200 + new_layout = layout_response.json() + assert any( + item["furniture_name"] == placement_data["furniture_name"] + and item["x"] == placement_data["x"] + and item["y"] == placement_data["y"] + for item in new_layout["furniture"] + ) + + # Cleanup + client.delete( + f"/api/room/furniture?x={placement_data['x']}&y={placement_data['y']}&furniture_name={placement_data['furniture_name']}", + headers=headers, + ) + + +def test_remove_furniture(client: TestClient, authenticated_user): + headers = {"Authorization": f"Bearer {authenticated_user['token']}"} + placement_data = { + "furniture_name": Furniture.CHAIR_0.value, + "x": 1, + "y": 1, + } + # Place furniture first + client.post("/api/room/furniture", json=placement_data, headers=headers) + + # Remove furniture + response = client.delete( + f"/api/room/furniture?x={placement_data['x']}&y={placement_data['y']}&furniture_name={placement_data['furniture_name']}", + headers=headers, + ) + assert response.status_code == 200 + assert response.json() == {"message": "Furniture removed successfully"} + + # Verify removal + final_layout_response = client.get("/api/room/layout", headers=headers) + assert final_layout_response.status_code == 200 + final_layout = final_layout_response.json() + assert not any( + item["furniture_name"] == placement_data["furniture_name"] + and item["x"] == placement_data["x"] + and item["y"] == placement_data["y"] + for item in final_layout["furniture"] + ) + + +def test_invalid_furniture_placement(client: TestClient, authenticated_user): + headers = {"Authorization": f"Bearer {authenticated_user['token']}"} + + # Test with invalid coordinates (out of bounds) + invalid_coords_data = { + "furniture_name": Furniture.CHAIR_0.value, + "x": 11, + "y": -1, + } + response = client.post( + "/api/room/furniture", json=invalid_coords_data, headers=headers + ) + assert response.status_code == 422 # Pydantic validation error + + # Test collision + placement_data = {"furniture_name": Furniture.CHAIR_0.value, "x": 1, "y": 1} + # Place once + client.post("/api/room/furniture", json=placement_data, headers=headers) + # Try to place again in the same spot + response = client.post("/api/room/furniture", json=placement_data, headers=headers) + assert response.status_code == 400 + assert "already placed" in response.json()["detail"] + + # Cleanup + client.delete( + f"/api/room/furniture?x={placement_data['x']}&y={placement_data['y']}&furniture_name={placement_data['furniture_name']}", + headers=headers, + ) diff --git a/Backend/tests/test_user.py b/Backend/tests/test_user.py new file mode 100644 index 0000000..48394c9 --- /dev/null +++ b/Backend/tests/test_user.py @@ -0,0 +1,68 @@ +from io import BytesIO + + +def test_user_registration_and_login(client): + # Registration + user_data = { + "username": "testuser999", + "email": "test@example.com", + "password": "testpassword123", + } + response = client.post( + "/api/user/register", + data=user_data, + files={"file": ("a.jpg", BytesIO(b"aaa"), "image/jpeg")}, + ) + + assert response.status_code == 201 + assert response.json()["username"] == "testuser999" + + # Duplicate registration + response = client.post( + "/api/user/register", + data=user_data, + files={"file": ("a.jpg", BytesIO(b"aaa"), "image/jpeg")}, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Username already registered" + + # Login + login_data = { + "username": "testuser999", + "password": "testpassword123", + } + response = client.post("/api/user/login", json=login_data) + assert response.status_code == 200 + assert "access_token" in response.json() + + # Login with wrong password + login_data["password"] = "wrongpassword" + response = client.post("/api/user/login", json=login_data) + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid username or password" + + # delete + response = client.delete(f"/api/user/{user_data['username']}") + assert response.status_code == 200 + + +def test_get_user_profile(client, authenticated_user): + token = authenticated_user["token"] + username = authenticated_user["user_data"]["username"] + headers = {"Authorization": f"Bearer {token}"} + + response = client.get(f"/api/user/profile/{username}", headers=headers) + + assert response.status_code == 200 + assert response.json()["username"] == username + + +def test_user_delete(client, authenticated_user): + token = authenticated_user["token"] + username = authenticated_user["user_data"]["username"] + headers = {"Authorization": f"Bearer {token}"} + + response = client.delete(f"/api/user/{username}", headers=headers) + + assert response.status_code == 200 + assert response.json()["detail"] == "User deleted" diff --git a/Backend/utils/__init__.py b/Backend/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Backend/utils/db.py b/Backend/utils/db.py new file mode 100644 index 0000000..863a9a7 --- /dev/null +++ b/Backend/utils/db.py @@ -0,0 +1,53 @@ +import aiosqlite +from typing import Any, List, Tuple, Optional, Dict, Union + +DB_PATH = "database.sqlite3" + +# Generated by Github Copilot +# db 사용을 편하게 하기 위한 함수 + + +async def get_db_connection(db_path: str = DB_PATH) -> aiosqlite.Connection: + return await aiosqlite.connect(db_path) + + +async def execute( + query: str, + params: Union[Tuple[Any, ...], Dict[str, Any]] = (), + db_path: str = DB_PATH, +) -> None: + async with aiosqlite.connect(db_path) as db: + await db.execute(query, params) + await db.commit() + + +async def fetch_one( + query: str, + params: Union[Tuple[Any, ...], Dict[str, Any]] = (), + db_path: str = DB_PATH, +) -> Optional[aiosqlite.Row]: + async with aiosqlite.connect(db_path) as db: + db.row_factory = aiosqlite.Row + async with db.execute(query, params) as cursor: + return await cursor.fetchone() + + +async def fetch_all( + query: str, + params: Union[Tuple[Any, ...], Dict[str, Any]] = (), + db_path: str = DB_PATH, +) -> List[aiosqlite.Row]: + async with aiosqlite.connect(db_path) as db: + db.row_factory = aiosqlite.Row + async with db.execute(query, params) as cursor: + return await cursor.fetchall() + + +async def executemany( + query: str, + seq_of_params: List[Union[Tuple[Any, ...], Dict[str, Any]]], + db_path: str = DB_PATH, +) -> None: + async with aiosqlite.connect(db_path) as db: + await db.executemany(query, seq_of_params) + await db.commit() diff --git a/Backend/utils/default_queries.py b/Backend/utils/default_queries.py new file mode 100644 index 0000000..e070ba7 --- /dev/null +++ b/Backend/utils/default_queries.py @@ -0,0 +1,168 @@ +class PhotoQueries: + CREATE_TABLE = """ + CREATE TABLE IF NOT EXISTS photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + album_name TEXT NOT NULL, + image_path TEXT NOT NULL, + title TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + """ + + CREATE_COMMENTS_TABLE = """ + CREATE TABLE IF NOT EXISTS photo_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + photo_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (photo_id) REFERENCES photos (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + """ + + INSERT_PHOTO = """ + INSERT INTO photos (user_id, album_name, image_path, title, created_at) + VALUES (?, ?, ?, ?, ?) + """ + + SELECT_USER_PHOTOS = """ + SELECT * FROM photos + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + """ + + SELECT_USER_PHOTOS_BY_ALBUM = """ + SELECT * FROM photos + WHERE user_id = ? AND album_name = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + """ + + SELECT_LATEST_USER_PHOTO = """ + SELECT * FROM photos WHERE user_id = ? ORDER BY id DESC LIMIT 1 + """ + + SELECT_PHOTO_OWNER = """ + SELECT user_id FROM photos WHERE id = ? + """ + + SELECT_PHOTO_ALBUM_NAME = """ + SELECT album_name FROM photos WHERE id = ? AND user_id = ? + """ + + INSERT_COMMENT = """ + INSERT INTO photo_comments (photo_id, user_id, content, created_at) + VALUES (?, ?, ?, ?) + """ + + SELECT_LATEST_COMMENT = """ + SELECT * FROM photo_comments WHERE photo_id = ? AND user_id = ? ORDER BY id DESC LIMIT 1 + """ + + SELECT_PHOTO_COMMENTS = """ + SELECT pc.*, u.username + FROM photo_comments pc + JOIN users u ON pc.user_id = u.id + WHERE pc.photo_id = ? + ORDER BY pc.created_at ASC + """ + + SELECT_PHOTO_BY_ID = """ + SELECT * FROM photos WHERE id = ? AND user_id = ? + """ + + DELETE_PHOTO = """ + DELETE FROM photos WHERE id = ? AND user_id = ? + """ + + UPDATE_PHOTO_PATH = """ + UPDATE photos SET image_path = ? WHERE id = ? AND user_id = ? + """ + + +class LetterQueries: + CREATE_TABLE = """ + CREATE TABLE IF NOT EXISTS letters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + content TEXT NOT NULL, + FOREIGN KEY (sender_id) REFERENCES users (id) ON DELETE CASCADE + ) + """ + + INSERT_LETTER = """ + INSERT INTO letters (sender_id, content) + VALUES (?, ?) + """ + + SELECT_USER_LETTERS = """ + SELECT * FROM letters + WHERE sender_id = ? + """ + + SELECT_LATEST_USER_LETTER = """ + SELECT * FROM letters WHERE sender_id = ? LIMIT 1 + """ + + SELECT_LETTER_BY_ID = """ + SELECT * FROM letters WHERE id = ? AND sender_id = ? + """ + + SELECT_LETTER_FOR_DELIVERY = """ + SELECT * FROM letters WHERE id = ? + """ + + SELECT_SENDER_USERNAME = """ + SELECT username FROM users WHERE id = ? + """ + + UPDATE_LETTER = """ + UPDATE letters SET content = ? WHERE id = ? AND sender_id = ? + """ + + DELETE_LETTER = """ + DELETE FROM letters WHERE id = ? AND sender_id = ? + """ + + +class DatabaseIndexes: + USER_INDEXES = [ + "CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)", + "CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)", + ] + + DIARY_INDEXES = [ + "CREATE INDEX IF NOT EXISTS idx_diaries_user_id ON diaries(user_id)", + "CREATE INDEX IF NOT EXISTS idx_diaries_category ON diaries(category)", + "CREATE INDEX IF NOT EXISTS idx_diaries_created_at ON diaries(created_at)", + ] + + PHOTO_INDEXES = [ + "CREATE INDEX IF NOT EXISTS idx_photos_user_id ON photos(user_id)", + "CREATE INDEX IF NOT EXISTS idx_photos_album ON photos(album_name)", + "CREATE INDEX IF NOT EXISTS idx_photo_comments_photo_id ON photo_comments(photo_id)", + ] + + FRIENDSHIP_INDEXES = [ + "CREATE INDEX IF NOT EXISTS idx_friendships_user_id ON friendships(user_id)", + "CREATE INDEX IF NOT EXISTS idx_friendships_friend_id ON friendships(friend_id)", + "CREATE INDEX IF NOT EXISTS idx_friendships_status ON friendships(status)", + ] + + LETTER_INDEXES = [ + "CREATE INDEX IF NOT EXISTS idx_letters_sender_id ON letters(sender_id)", + "CREATE INDEX IF NOT EXISTS idx_letters_sent_date ON letters(sent_date)", + ] + + AVATAR_INDEXES = [ + "CREATE INDEX IF NOT EXISTS idx_avatars_user_id ON avatars(user_id)", + ] + + GUEST_BOOK_INDEXES = [ + "CREATE INDEX IF NOT EXISTS idx_guest_books_user_id ON guest_books(user_id)", + "CREATE INDEX IF NOT EXISTS idx_guest_books_created_at ON guest_books(created_at)", + ] diff --git a/Backend/utils/email_processor.py b/Backend/utils/email_processor.py new file mode 100644 index 0000000..4d6c5c7 --- /dev/null +++ b/Backend/utils/email_processor.py @@ -0,0 +1,75 @@ +import aiosmtplib +from email.message import EmailMessage +from typing import Optional +import os +from pydantic import EmailStr +import ssl + +# aiosmtplib을 사용한 비동기 이메일 전송 +# EmailStr 사용 + + +class EmailProcessor: + def __init__(self): + self.smtp_server = os.getenv("SMTP_SERVER", "smtp.gmail.com") + self.smtp_port = int(os.getenv("SMTP_PORT", "587")) + self.sender_email = os.getenv("SENDER_EMAIL") + self.sender_password = os.getenv("SENDER_PASSWORD") + self.receiver_email = "" + + async def send_email( + self, + subject: str, + content: str, + sender_email: EmailStr, + sender_password: str, + html_content: Optional[str] = None, + ) -> bool: + if not self.sender_email or not self.sender_password: + print("Email credentials not configured, cannot send email.") + return False + + if not sender_email or not sender_password: + print( + "Warning: Email credentials (SENDER_EMAIL, SENDER_PASSWORD) " + "are not configured in environment variables." + ) + return False + + message = EmailMessage() + message["Subject"] = subject + message["From"] = sender_email + message["To"] = self.receiver_email + message.set_content(content) + + # HTML 내용이 있는 경우, 대체 콘텐츠로 추가 + if html_content: + message.add_alternative(html_content, subtype="html") + + try: + # 포트 465는 SMTPS (implicit TLS)를 사용하고, 포트 587은 STARTTLS를 사용합니다. + use_tls = self.smtp_port == 465 + context = ssl.create_default_context() if use_tls else None + + await aiosmtplib.send( + message, + hostname=self.smtp_server, + port=self.smtp_port, + username=sender_email, + password=sender_password, + use_tls=use_tls, + ssl_context=context, + timeout=10, + ) + + print(f"Email sent successfully to {self.receiver_email}") + return True + + except aiosmtplib.SMTPException as e: + print(f"SMTP error occurred while sending to {self.receiver_email}: {e}") + return False + except Exception as e: + print( + f"An unexpected error occurred while sending email to {self.receiver_email}: {e}" + ) + return False diff --git a/Backend/utils/image_processor.py b/Backend/utils/image_processor.py new file mode 100644 index 0000000..e694725 --- /dev/null +++ b/Backend/utils/image_processor.py @@ -0,0 +1,179 @@ +import os +import aiofiles +from PIL import Image, ImageFilter, ImageEnhance +from datetime import datetime +from starlette.datastructures import UploadFile + + +class ImageProcessor: + def __init__(self): + self.filter_dir = "uploads/filtered" + os.makedirs(self.filter_dir, exist_ok=True) + + async def apply_filter(self, image_path: str, filter_type: str) -> str: + """Apply filter to image and return the path to filtered image""" + + # Remove leading slash if exists for local file access + local_path = image_path.lstrip("/") + + if not os.path.exists(local_path): + raise ValueError(f"Image file not found: {local_path}") + + try: + with Image.open(local_path) as img: + # Convert to RGB if necessary + if img.mode != "RGB": + img = img.convert("RGB") + + filtered_img = self._apply_filter_effect(img, filter_type) + + # Generate filtered image filename + base_name = os.path.basename(image_path) + name, ext = os.path.splitext(base_name) + filtered_filename = f"{name}_{filter_type}{ext}" + filtered_path = os.path.join(self.filter_dir, filtered_filename) + + # Save filtered image + filtered_img.save(filtered_path, quality=85, optimize=True) + + return f"/uploads/filtered/{filtered_filename}" + + except Exception as e: + raise ValueError(f"Failed to apply filter: {str(e)}") + + def _apply_filter_effect(self, img: Image.Image, filter_type: str) -> Image.Image: + """Apply specific filter effect to image""" + if filter_type == "none": + return img + elif filter_type == "vintage": + return self._apply_vintage_filter(img) + elif filter_type == "black_white": + return self._apply_black_white_filter(img) + elif filter_type == "sepia": + return self._apply_sepia_filter(img) + elif filter_type == "blur": + return self._apply_blur_filter(img) + elif filter_type == "sharpen": + return self._apply_sharpen_filter(img) + elif filter_type == "bright": + return self._apply_brightness_filter(img) + elif filter_type == "contrast": + return self._apply_contrast_filter(img) + else: + raise ValueError(f"Unknown filter type: {filter_type}") + + def _apply_vintage_filter(self, img: Image.Image) -> Image.Image: + # Reduce saturation + enhancer = ImageEnhance.Color(img) + img = enhancer.enhance(0.7) + + # Add slight warm tint + r, g, b = img.split() + r = ImageEnhance.Brightness(r).enhance(1.1) + g = ImageEnhance.Brightness(g).enhance(1.05) + b = ImageEnhance.Brightness(b).enhance(0.9) + + return Image.merge("RGB", (r, g, b)) + + def _apply_black_white_filter(self, img: Image.Image) -> Image.Image: + return img.convert("L").convert("RGB") + + def _apply_sepia_filter(self, img: Image.Image) -> Image.Image: + pixels = img.load() + width, height = img.size + + for py in range(height): + for px in range(width): + r, g, b = pixels[px, py] + + tr = int(0.393 * r + 0.769 * g + 0.189 * b) + tg = int(0.349 * r + 0.686 * g + 0.168 * b) + tb = int(0.272 * r + 0.534 * g + 0.131 * b) + + pixels[px, py] = (min(255, tr), min(255, tg), min(255, tb)) + + return img + + def _apply_blur_filter(self, img: Image.Image) -> Image.Image: + return img.filter(ImageFilter.GaussianBlur(radius=2)) + + def _apply_sharpen_filter(self, img: Image.Image) -> Image.Image: + return img.filter(ImageFilter.SHARPEN) + + def _apply_brightness_filter(self, img: Image.Image) -> Image.Image: + enhancer = ImageEnhance.Brightness(img) + return enhancer.enhance(1.3) + + def _apply_contrast_filter(self, img: Image.Image) -> Image.Image: + enhancer = ImageEnhance.Contrast(img) + return enhancer.enhance(1.2) + + async def resize_image( + self, image_path: str, max_width: int = 800, max_height: int = 800 + ) -> str: + """Resize image while maintaining aspect ratio""" + + local_path = image_path.lstrip("/") + + if not os.path.exists(local_path): + raise ValueError(f"Image file not found: {local_path}") + + try: + with Image.open(local_path) as img: + # Calculate new size maintaining aspect ratio + img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + + # Generate resized image filename + base_name = os.path.basename(image_path) + name, ext = os.path.splitext(base_name) + resized_filename = f"{name}_resized{ext}" + resized_path = os.path.join( + os.path.dirname(local_path), resized_filename + ) + + # Save resized image + img.save(resized_path, quality=85, optimize=True) + + return f"/{resized_path}" + + except Exception as e: + raise ValueError(f"Failed to resize image: {str(e)}") + + async def validate_image_file(self, filename: str, file_size: int): + """Validate image file type and size""" + + allowed_extensions = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"} + + # Check file extension + _, ext = os.path.splitext(filename.lower()) + if ext not in allowed_extensions: + raise ValueError("Unsupported file type") + + if file_size > 20 * 1024 * 1024: # 20MB limit + raise ValueError("File size must be less than 20MB") + + def get_safe_filename(self, filename: str) -> str: + """Generate safe filename by removing dangerous characters""" + + import re + + # Keep only alphanumeric characters, dots, and hyphens + safe_name = re.sub(r"[^a-zA-Z0-9._-]", "_", filename) + + # Add timestamp to avoid collisions + name, ext = os.path.splitext(safe_name) + timestamp = int(datetime.now().timestamp()) + + return f"{name}_{timestamp}{ext}" + + async def write_file_and_get_image_path( + self, file: UploadFile, upload_dir: str + ) -> str: + filename = self.get_safe_filename(file.filename) + file_path = os.path.join(upload_dir, filename) + + async with aiofiles.open(file_path, "wb") as f: + content = await file.read() + await f.write(content) + + return file_path diff --git a/Backend/utils/queries/__init__.py b/Backend/utils/queries/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Backend/utils/queries/avatar.py b/Backend/utils/queries/avatar.py new file mode 100644 index 0000000..c6ebd95 --- /dev/null +++ b/Backend/utils/queries/avatar.py @@ -0,0 +1,25 @@ +class AvatarQueries: + + CREATE_TABLE = """ + CREATE TABLE IF NOT EXISTS avatars ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER UNIQUE NOT NULL, + avatar_type TEXT NOT NULL, + top_clothe_type TEXT, + bottom_clothe_type TEXT, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + """ + + SELECT_USER_AVATAR = """ + SELECT * FROM avatars WHERE user_id = ? + """ + + INSERT_AVATAR = """ + INSERT INTO avatars (user_id, avatar_type, top_clothe_type, bottom_clothe_type) + VALUES (?, ?, ?, ?) + """ + + UPDATE_AVATAR = """ + UPDATE avatars SET {fields} WHERE user_id = ? + """ \ No newline at end of file diff --git a/Backend/utils/queries/diary.py b/Backend/utils/queries/diary.py new file mode 100644 index 0000000..04a38d1 --- /dev/null +++ b/Backend/utils/queries/diary.py @@ -0,0 +1,71 @@ +class DiaryQueries: + + CREATE_TABLE = """ + CREATE TABLE IF NOT EXISTS diaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + images TEXT, + category TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_submitted BOOLEAN DEFAULT FALSE, + email_sent BOOLEAN DEFAULT FALSE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + """ + + INSERT_DIARY = """ + INSERT INTO diaries (user_id, title, content, images, category, created_at, is_submitted, email_sent) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """ + + SELECT_USER_DIARIES = """ + SELECT * FROM diaries + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + """ + + SELECT_USER_DIARIES_BY_CATEGORY = """ + SELECT * FROM diaries + WHERE user_id = ? AND category = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + """ + + SELECT_BY_ID = """ + SELECT * FROM diaries WHERE id = ? + """ + + SELECT_BY_ID_WITH_USER_ID = """ + SELECT * FROM diaries WHERE id = ? AND user_id = ? + """ + + SELECT_LATEST_USER_DIARY = """ + SELECT * FROM diaries WHERE user_id = ? ORDER BY id DESC LIMIT 1 + """ + + SELECT_IMAGES_BY_ID = """ + SELECT images FROM diaries WHERE id = ? + """ + + UPDATE_DIARY = """ + UPDATE diaries SET {fields} WHERE id = ? AND user_id = ? + """ + + DELETE_DIARY = """ + DELETE FROM diaries WHERE id = ? AND user_id = ? + """ + + UPDATE_SUBMISSION_STATUS = """ + UPDATE diaries SET is_submitted = ? WHERE id = ? AND user_id = ? + """ + + UPDATE_EMAIL_SENT = """ + UPDATE diaries SET email_sent = ? WHERE id = ? + """ + + UPDATE_DIARY_IMAGE_BY_ID = """ + UPDATE diaries SET images = ? WHERE id = ? + """ diff --git a/Backend/utils/queries/friendship.py b/Backend/utils/queries/friendship.py new file mode 100644 index 0000000..f019f2a --- /dev/null +++ b/Backend/utils/queries/friendship.py @@ -0,0 +1,89 @@ +class FriendshipQueries: + """친구 관계 관련 쿼리""" + + CREATE_TABLE = """ + CREATE TABLE IF NOT EXISTS friendships ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + friend_id INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (friend_id) REFERENCES users (id) ON DELETE CASCADE, + UNIQUE(user_id, friend_id) + ) + """ + + SELECT_USER_BY_USERNAME = """ + SELECT id FROM users WHERE username = ? + """ + + SELECT_EXISTING_FRIENDSHIP = """ + SELECT * FROM friendships + WHERE (user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?) + """ + + INSERT_FRIENDSHIP = """ + INSERT INTO friendships (user_id, friend_id, status, created_at) + VALUES (?, ?, ?, ?) + """ + + SELECT_FRIENDSHIP_BY_IDS = """ + SELECT * FROM friendships WHERE user_id = ? AND friend_id = ? + """ + + SELECT_FRIENDSHIP_FOR_ACCEPT = """ + SELECT f.*, u.username + FROM friendships f + JOIN users u ON f.user_id = u.id + WHERE f.id = ? AND f.friend_id = ? AND f.status = ? + """ + + UPDATE_FRIENDSHIP_STATUS = """ + UPDATE friendships SET status = ? WHERE id = ? + """ + + SELECT_USER_FRIENDSHIPS = """ + SELECT f.*, u.username + FROM friendships f + JOIN users u ON ( + CASE + WHEN f.user_id = ? THEN f.friend_id = u.id + ELSE f.user_id = u.id + END + ) + WHERE (f.user_id = ? OR f.friend_id = ?) AND f.status = ? + ORDER BY f.created_at DESC + """ + + SELECT_USER_FRIENDSHIPS_BY_STATUS = """ + SELECT f.*, u.username + FROM friendships f + JOIN users u ON ( + CASE + WHEN f.user_id = ? THEN f.friend_id = u.id + ELSE f.user_id = u.id + END + ) + WHERE (f.user_id = ? OR f.friend_id = ?) AND f.status = ? + ORDER BY f.created_at DESC + """ + + DELETE_FRIENDSHIP = """ + DELETE FROM friendships + WHERE id = ? AND (user_id = ? OR friend_id = ?) + """ + + SELECT_PENDING_REQUESTS = """ + SELECT f.*, u.username + FROM friendships f + JOIN users u ON f.user_id = u.id + WHERE f.friend_id = ? AND f.status = ? + ORDER BY f.created_at DESC + """ + + CHECK_FRIENDSHIP_STATUS = """ + SELECT * FROM friendships + WHERE ((user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)) + AND status = 'accepted' + """ diff --git a/Backend/utils/queries/guestbook.py b/Backend/utils/queries/guestbook.py new file mode 100644 index 0000000..33e7d13 --- /dev/null +++ b/Backend/utils/queries/guestbook.py @@ -0,0 +1,40 @@ +class GuestBookQueries: + CREATE_TABLE = """ + CREATE TABLE IF NOT EXISTS guest_books ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_user_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + """ + + INSERT_GUEST_BOOK = """ + INSERT INTO guest_books (target_user_id, user_id, content, created_at) + VALUES (?, ?, ?, ?) + """ + + SELECT_TARGET_USER_GUEST_BOOKS = """ + SELECT * FROM guest_books + WHERE target_user_id = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + """ + + SELECT_GUEST_BOOK_BY_ID = """ + SELECT * FROM guest_books WHERE id = ? + """ + + SELECT_GUEST_BOOK_BY_USER_ID = """ + SELECT * FROM guest_books WHERE user_id = ? ORDER BY created_at DESC LIMIT 1 + """ + + UPDATE_GUEST_BOOK_BY_ID = """ + UPDATE guest_books SET content = ?, updated_at=CURRENT_TIMESTAMP WHERE id = ? + """ + + DELETE_GUEST_BOOK = """ + DELETE FROM guest_books WHERE id = ? AND user_id = ? + """ diff --git a/Backend/utils/queries/room.py b/Backend/utils/queries/room.py new file mode 100644 index 0000000..8c0f07b --- /dev/null +++ b/Backend/utils/queries/room.py @@ -0,0 +1,81 @@ +class RoomQueries: + """마이 룸 관련 쿼리""" + + CREATE_TABLE = """ + CREATE TABLE IF NOT EXISTS rooms ( + id INTEGER PRIMARY KEY, + user_id INTEGER UNIQUE, + room_name TEXT, + room_type TEXT + ) + """ + CREATE_TABLE_ROOM_FURNITURE = """ + CREATE TABLE IF NOT EXISTS room_furnitures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id INTEGER NOT NULL, + furniture_name TEXT NOT NULL, + x INTEGER NOT NULL, + y INTEGER NOT NULL, + FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE + ) + """ + + CREATE_TABLE_USER_FURNITURE = """ + CREATE TABLE IF NOT EXISTS user_furnitures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + furniture_name TEXT NOT NULL + ) + """ + + INSERT_USER_FURNITURE = """ + INSERT INTO user_furnitures (user_id, furniture_name) VALUES (?, ?) + """ + + INSERT_ROOM = """ + INSERT INTO rooms (user_id, room_name, room_type) VALUES (?, ?, ?) + """ + + INSERT_ROOM_FURNITURE = """ + INSERT INTO room_furnitures (room_id, furniture_name, x, y) VALUES (?, ?, ?, ?) + """ + + SELECT_ROOM_ID_BY_USER_ID = """ + SELECT id FROM rooms WHERE user_id = ? + """ + + SELECT_ROOM_BY_ID = """ + SELECT * FROM rooms WHERE id = ? + """ + + SELECT_FURNITURE = """ + SELECT * FROM furnitures + """ + + SELECT_ROOM_FURNITURE = """ + SELECT id, furniture_name, x, y FROM room_furnitures WHERE room_id = ? + """ + + SELECT_FURNITURE_ID_BY_X_Y = """ + SELECT id FROM room_furnitures WHERE room_id = ? AND x = ? AND y = ? + """ + + SELECT_USER_FURNITURE = """ + SELECT * FROM user_furnitures WHERE user_id = ? + """ + + UPDATE_ROOM_NAME = """ + UPDATE rooms SET room_name = ? WHERE id = ? + """ + + UPDATE_ROOM_TYPE = """ + UPDATE rooms SET room_type = ? WHERE id = ? + """ + + DELETE_FURNITURE = """ + DELETE FROM room_furnitures WHERE room_id = ? AND x = ? AND y = ? + """ + + SELECT_ROOM_BY_USER_ID = """ + SELECT * FROM rooms WHERE user_id = ? + """ diff --git a/Backend/utils/queries/user.py b/Backend/utils/queries/user.py new file mode 100644 index 0000000..96dbe04 --- /dev/null +++ b/Backend/utils/queries/user.py @@ -0,0 +1,61 @@ +class UserQueries: + """사용자 관련 쿼리""" + + CREATE_TABLE = """ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + salt TEXT NOT NULL, + profile_image_path TEXT DEFAULT 'upload/profile/default.jpg', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT TRUE + ) + """ + + INSERT_USER_WITHOUT_PROFILE = """ + INSERT INTO users (username, email, password_hash, salt) + VALUES (?, ?, ?, ?) + """ + + INSERT_USER_WITH_PROFILE = """ + INSERT INTO users (username, email, password_hash, salt, profile_image_path) + VALUES (?, ?, ?, ?, ?) + """ + + SELECT_BY_USERNAME = """ + SELECT * FROM users WHERE username = ? + """ + + SELECT_BY_EMAIL = """ + SELECT * FROM users WHERE email = ? + """ + + SELECT_BY_ID = """ + SELECT * FROM users WHERE id = ? + """ + + SELECT_BY_USERNAME_LIKE = """ + SELECT * FROM users WHERE username LIKE ? + """ + + DELETE_USER_BY_USERNAME = """ + DELETE FROM users WHERE username = ? + """ + + UPDATE_PROFILE_IMAGE_PATH_BY_USERNAME = """ + UPDATE users SET profile_image_path = ? WHERE username = ? + """ + + UPDATE_PROFILE_IMAGE_PATH_BY_ID = """ + UPDATE users SET profile_image_path = ? WHERE id = ? + """ + + UPDATE_USER_BY_ID = """ + UPDATE users SET {} WHERE id = ? + """ + + SELECT_USER_BY_EMAIL_AND_NOT_ID = """ + SELECT * FROM users WHERE email = ? AND id != ? + """ diff --git a/Backend/utils/run_server.py b/Backend/utils/run_server.py new file mode 100644 index 0000000..a42b550 --- /dev/null +++ b/Backend/utils/run_server.py @@ -0,0 +1,64 @@ +import sys +import os +from pathlib import Path +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from fastapi.middleware.cors import CORSMiddleware + +# 프로젝트 루트를 Python 경로에 추가 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + + +app = FastAPI( + title="싸이월드 - 추억 속으로", + description="2000년대 감성의 소셜 네트워크 서비스", + version="1.0.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") +app.mount("/public", StaticFiles(directory="public"), name="public") + + +# 정적 파일 서빙 +async def init_folders(app: FastAPI): + app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") + + +async def init_db(): + + for service in os.listdir("Backend/services"): + try: + if service.endswith(".py"): + module_name = "Backend.services." + service[:-3] + module = __import__(module_name, fromlist=[""]) + service_class_name = service[:-3].split("_")[0] + "Service" + service_class_name = service_class_name.replace( + service_class_name[0], service_class_name[0].upper(), 1 + ) + service_class = getattr(module, service_class_name) + + await service_class.init_db() + print(f"{service_class_name} : init_db") + + except Exception as e: + print(f"failed to init_db {service}") + print(e) + + +async def startup_event(): + await init_folders(app) + await init_db() + + +def init_FastAPI() -> FastAPI: + app.add_event_handler("startup", startup_event) + return app diff --git a/README.md b/README.md new file mode 100644 index 0000000..dbe88ba --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +## 2025 AnA SSF - 나만의 싸이월드 만들기 + +### 소개 +2000년대 감성을 담은 소셜 네트워크 서비스 '싸이월드'를 직접 구현해보는 프로젝트입니다. +사진 업로드, 미니홈피, 도토리, 방명록 등 주요 기능들을 구현했습니다. + +### 기술 스택 + - 프론트엔드 : Qwik-city, Tailwind CSS + - 백엔드 : FastAPI + - 데이터베이스 : SQLite + - 배포 : git + +### 주요 기능 + - 회원가입 / 로그인 + - 프로필 사진 업로드 및 방 꾸미기 + - 방명록 작성 + - 도토리 상점 + +### 프로젝트 구조 +2025-SSF-Internal\ +├── Backend\ +│ ├── __init__.py\ +│ ├── models\ +│ ├── routes\ +│ └── ...\ +└── README.md + +### 실행 방법 +1. 프론트엔드\ +git clone https://github.com/sunrin-ana/2025-SSF-Frontend.git \ +npm install\ +npm run + +2. 백엔드\ +git clone https://github.com/sunrin-ana/2025-SSF.git \ +pip install -r requirements.txt\ +uvicorn Backed:app --reload + +### 개발 기간 +2025-05-30 ~ 2025-09-12 + +### 팀원 +장한울 - diary, ~~photo~~, ~~letter~~, store\ +고윤 - avatar, room\ +김건우 - user, friendship\ +김주영 - dotory-manage server, frontend + +### 참고자료 +싸이월드 미니홈피 소개 diff --git a/api.json b/api.json new file mode 100644 index 0000000..9cda915 --- /dev/null +++ b/api.json @@ -0,0 +1 @@ +{"openapi":"3.1.0","info":{"title":"FastAPI","version":"0.1.0"},"paths":{"/register":{"post":{"tags":["user"],"summary":"Register User","operationId":"register_user_register_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserCreate"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/login":{"post":{"tags":["user"],"summary":"Login User","operationId":"login_user_login_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserLogin"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/profile/{username}":{"get":{"tags":["user"],"summary":"Get User Profile","operationId":"get_user_profile_profile__username__get","parameters":[{"name":"username","in":"path","required":true,"schema":{"type":"string","title":"Username"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/friendship/request":{"post":{"tags":["friendship","friendship"],"summary":"Send Friendship Request","operationId":"send_friendship_request_friendship_request_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FriendshipRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FriendshipResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/friendship/{friendship_id}/accept":{"put":{"tags":["friendship","friendship"],"summary":"Accept Friendship Request","operationId":"accept_friendship_request_friendship__friendship_id__accept_put","security":[{"HTTPBearer":[]}],"parameters":[{"name":"friendship_id","in":"path","required":true,"schema":{"type":"integer","title":"Friendship Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FriendshipResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/friendship":{"get":{"tags":["friendship","friendship"],"summary":"Get Friendships","operationId":"get_friendships_friendship_get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"status","in":"query","required":false,"schema":{"type":"string","title":"Status"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/FriendshipResponse"},"title":"Response Get Friendships Friendship Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/friendship/{friendship_id}":{"delete":{"tags":["friendship","friendship"],"summary":"Delete Friendship","operationId":"delete_friendship_friendship__friendship_id__delete","security":[{"HTTPBearer":[]}],"parameters":[{"name":"friendship_id","in":"path","required":true,"schema":{"type":"integer","title":"Friendship Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Friendship Friendship Friendship Id Delete"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/friendship/pending":{"get":{"tags":["friendship","friendship"],"summary":"Get Pending Requests","operationId":"get_pending_requests_friendship_pending_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/FriendshipResponse"},"type":"array","title":"Response Get Pending Requests Friendship Pending Get"}}}}},"security":[{"HTTPBearer":[]}]}},"/diary":{"post":{"tags":["diary","diary"],"summary":"Create Diary","operationId":"create_diary_diary_post","security":[{"HTTPBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiaryCreate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiaryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["diary","diary"],"summary":"Get User Diaries","operationId":"get_user_diaries_diary_get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"skip","in":"query","required":false,"schema":{"type":"integer","default":0,"title":"Skip"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":20,"title":"Limit"}},{"name":"category","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Category"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/DiaryResponse"},"title":"Response Get User Diaries Diary Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/diary/{diary_id}":{"get":{"tags":["diary","diary"],"summary":"Get Diary","operationId":"get_diary_diary__diary_id__get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"diary_id","in":"path","required":true,"schema":{"type":"integer","title":"Diary Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiaryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["diary","diary"],"summary":"Update Diary","operationId":"update_diary_diary__diary_id__put","security":[{"HTTPBearer":[]}],"parameters":[{"name":"diary_id","in":"path","required":true,"schema":{"type":"integer","title":"Diary Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiaryUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiaryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["diary","diary"],"summary":"Delete Diary","operationId":"delete_diary_diary__diary_id__delete","security":[{"HTTPBearer":[]}],"parameters":[{"name":"diary_id","in":"path","required":true,"schema":{"type":"integer","title":"Diary Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Diary Diary Diary Id Delete"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/diary/upload-image":{"post":{"tags":["diary","diary"],"summary":"Upload Diary Image","operationId":"upload_diary_image_diary_upload_image_post","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_upload_diary_image_diary_upload_image_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Upload Diary Image Diary Upload Image Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/diary/{diary_id}/submit-for-event":{"post":{"tags":["diary","diary"],"summary":"Submit Diary For Event","operationId":"submit_diary_for_event_diary__diary_id__submit_for_event_post","security":[{"HTTPBearer":[]}],"parameters":[{"name":"diary_id","in":"path","required":true,"schema":{"type":"integer","title":"Diary Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Submit Diary For Event Diary Diary Id Submit For Event Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/avatar":{"get":{"tags":["avatar","avatar"],"summary":"Get Current Avatar","operationId":"get_current_avatar_avatar_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AvatarResponse"}}}}},"security":[{"HTTPBearer":[]}]},"put":{"tags":["avatar","avatar"],"summary":"Update Avatar","operationId":"update_avatar_avatar_put","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AvatarUpdate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AvatarResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/avatar/options":{"get":{"tags":["avatar","avatar"],"summary":"Get Avatar Options","operationId":"get_avatar_options_avatar_options_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AvatarOptions"}}}}}}},"/letter":{"post":{"tags":["letter","letter"],"summary":"Create Letter","operationId":"create_letter_letter_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LetterCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LetterResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/letter/my":{"get":{"tags":["letter","letter"],"summary":"Get My Letters","operationId":"get_my_letters_letter_my_get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"skip","in":"query","required":false,"schema":{"type":"integer","default":0,"title":"Skip"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":20,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/LetterResponse"},"title":"Response Get My Letters Letter My Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/letter/{letter_id}":{"get":{"tags":["letter","letter"],"summary":"Get Letter","operationId":"get_letter_letter__letter_id__get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"letter_id","in":"path","required":true,"schema":{"type":"integer","title":"Letter Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LetterResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/photo/upload":{"post":{"tags":["photo","photo"],"summary":"Upload Photo","operationId":"upload_photo_photo_upload_post","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_upload_photo_photo_upload_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhotoResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/photo":{"get":{"tags":["photo","photo"],"summary":"Get User Photos","operationId":"get_user_photos_photo_get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"skip","in":"query","required":false,"schema":{"type":"integer","default":0,"title":"Skip"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":20,"title":"Limit"}},{"name":"album_name","in":"query","required":false,"schema":{"type":"string","title":"Album Name"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PhotoResponse"},"title":"Response Get User Photos Photo Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/photo/{photo_id}/comment":{"post":{"tags":["photo","photo"],"summary":"Add Photo Comment","operationId":"add_photo_comment_photo__photo_id__comment_post","security":[{"HTTPBearer":[]}],"parameters":[{"name":"photo_id","in":"path","required":true,"schema":{"type":"integer","title":"Photo Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommentCreate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/photo/{photo_id}/comments":{"get":{"tags":["photo","photo"],"summary":"Get Photo Comments","operationId":"get_photo_comments_photo__photo_id__comments_get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"photo_id","in":"path","required":true,"schema":{"type":"integer","title":"Photo Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CommentResponse"},"title":"Response Get Photo Comments Photo Photo Id Comments Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/photo/edit-filter":{"post":{"tags":["photo","photo"],"summary":"Apply Photo Filter","operationId":"apply_photo_filter_photo_edit_filter_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FilterRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Apply Photo Filter Photo Edit Filter Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/photo/{photo_id}":{"delete":{"tags":["photo","photo"],"summary":"Delete Photo","operationId":"delete_photo_photo__photo_id__delete","security":[{"HTTPBearer":[]}],"parameters":[{"name":"photo_id","in":"path","required":true,"schema":{"type":"integer","title":"Photo Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Photo Photo Photo Id Delete"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/test":{"get":{"tags":["guestBook"],"summary":"Get Guest Book","operationId":"get_guest_book_test_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}}},"components":{"schemas":{"AvatarOptions":{"properties":{"face_types":{"items":{"type":"string"},"type":"array","title":"Face Types"},"hair_types":{"items":{"type":"string"},"type":"array","title":"Hair Types"},"clothes_types":{"items":{"type":"string"},"type":"array","title":"Clothes Types"},"background_types":{"items":{"type":"string"},"type":"array","title":"Background Types"}},"type":"object","required":["face_types","hair_types","clothes_types","background_types"],"title":"AvatarOptions"},"AvatarResponse":{"properties":{"id":{"type":"integer","title":"Id"},"user_id":{"type":"integer","title":"User Id"},"face_type":{"$ref":"#/components/schemas/FaceType"},"hair_type":{"$ref":"#/components/schemas/HairType"},"clothes_type":{"$ref":"#/components/schemas/ClothesType"},"background_type":{"$ref":"#/components/schemas/BackgroundType"},"position_x":{"type":"integer","title":"Position X"},"position_y":{"type":"integer","title":"Position Y"}},"type":"object","required":["id","user_id","face_type","hair_type","clothes_type","background_type","position_x","position_y"],"title":"AvatarResponse"},"AvatarUpdate":{"properties":{"face_type":{"anyOf":[{"$ref":"#/components/schemas/FaceType"},{"type":"null"}]},"hair_type":{"anyOf":[{"$ref":"#/components/schemas/HairType"},{"type":"null"}]},"clothes_type":{"anyOf":[{"$ref":"#/components/schemas/ClothesType"},{"type":"null"}]},"background_type":{"anyOf":[{"$ref":"#/components/schemas/BackgroundType"},{"type":"null"}]},"position_x":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Position X"},"position_y":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Position Y"}},"type":"object","title":"AvatarUpdate"},"BackgroundType":{"type":"string","enum":["room","garden","beach","city","space"],"title":"BackgroundType"},"Body_upload_diary_image_diary_upload_image_post":{"properties":{"file":{"type":"string","format":"binary","title":"File"}},"type":"object","required":["file"],"title":"Body_upload_diary_image_diary_upload_image_post"},"Body_upload_photo_photo_upload_post":{"properties":{"photo_data":{"$ref":"#/components/schemas/PhotoUpload"},"file":{"type":"string","format":"binary","title":"File"}},"type":"object","required":["photo_data","file"],"title":"Body_upload_photo_photo_upload_post"},"ClothesType":{"type":"string","enum":["casual","formal","sporty","vintage","cute"],"title":"ClothesType"},"CommentCreate":{"properties":{"content":{"type":"string","title":"Content"}},"type":"object","required":["content"],"title":"CommentCreate"},"CommentResponse":{"properties":{"id":{"type":"integer","title":"Id"},"photo_id":{"type":"integer","title":"Photo Id"},"user_id":{"type":"integer","title":"User Id"},"username":{"type":"string","title":"Username"},"content":{"type":"string","title":"Content"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","photo_id","user_id","username","content","created_at"],"title":"CommentResponse"},"DiaryCategory":{"type":"string","enum":["daily","travel","food","love","work","hobby"],"title":"DiaryCategory"},"DiaryCreate":{"properties":{"title":{"type":"string","title":"Title"},"content":{"type":"string","title":"Content"},"category":{"$ref":"#/components/schemas/DiaryCategory"},"images":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Images","default":[]}},"type":"object","required":["title","content","category"],"title":"DiaryCreate"},"DiaryResponse":{"properties":{"id":{"type":"integer","title":"Id"},"user_id":{"type":"integer","title":"User Id"},"title":{"type":"string","title":"Title"},"content":{"type":"string","title":"Content"},"images":{"items":{"type":"string"},"type":"array","title":"Images"},"category":{"$ref":"#/components/schemas/DiaryCategory"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"is_submitted":{"type":"boolean","title":"Is Submitted"},"email_sent":{"type":"boolean","title":"Email Sent"}},"type":"object","required":["id","user_id","title","content","images","category","created_at","is_submitted","email_sent"],"title":"DiaryResponse"},"DiaryUpdate":{"properties":{"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title"},"content":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Content"},"category":{"anyOf":[{"$ref":"#/components/schemas/DiaryCategory"},{"type":"null"}]},"images":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Images"}},"type":"object","title":"DiaryUpdate"},"FaceType":{"type":"string","enum":["round","oval","square","heart"],"title":"FaceType"},"FilterRequest":{"properties":{"photo_id":{"type":"integer","title":"Photo Id"},"filter_type":{"type":"string","title":"Filter Type"}},"type":"object","required":["photo_id","filter_type"],"title":"FilterRequest"},"FriendshipRequest":{"properties":{"friend_username":{"type":"string","title":"Friend Username"}},"type":"object","required":["friend_username"],"title":"FriendshipRequest"},"FriendshipResponse":{"properties":{"id":{"type":"integer","title":"Id"},"user_id":{"type":"integer","title":"User Id"},"friend_id":{"type":"integer","title":"Friend Id"},"friend_username":{"type":"string","title":"Friend Username"},"status":{"$ref":"#/components/schemas/FriendshipStatus"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","user_id","friend_id","friend_username","status","created_at"],"title":"FriendshipResponse"},"FriendshipStatus":{"type":"string","enum":["pending","accepted","rejected"],"title":"FriendshipStatus"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HairType":{"type":"string","enum":["short","long","curly","ponytail","bob"],"title":"HairType"},"LetterCreate":{"properties":{"recipient_email":{"type":"string","title":"Recipient Email"},"content":{"type":"string","title":"Content"}},"type":"object","required":["recipient_email","content"],"title":"LetterCreate"},"LetterResponse":{"properties":{"id":{"type":"integer","title":"Id"},"sender_id":{"type":"integer","title":"Sender Id"},"recipient_email":{"type":"string","title":"Recipient Email"},"content":{"type":"string","title":"Content"},"sent_date":{"type":"string","format":"date-time","title":"Sent Date"},"is_sent":{"type":"boolean","title":"Is Sent"}},"type":"object","required":["id","sender_id","recipient_email","content","sent_date","is_sent"],"title":"LetterResponse"},"PhotoResponse":{"properties":{"id":{"type":"integer","title":"Id"},"user_id":{"type":"integer","title":"User Id"},"album_name":{"type":"string","title":"Album Name"},"image_path":{"type":"string","title":"Image Path"},"title":{"type":"string","title":"Title"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","user_id","album_name","image_path","title","created_at"],"title":"PhotoResponse"},"PhotoUpload":{"properties":{"album_name":{"type":"string","title":"Album Name"},"title":{"type":"string","title":"Title"}},"type":"object","required":["album_name","title"],"title":"PhotoUpload"},"UserCreate":{"properties":{"username":{"type":"string","title":"Username"},"email":{"type":"string","title":"Email"},"password":{"type":"string","title":"Password"}},"type":"object","required":["username","email","password"],"title":"UserCreate"},"UserLogin":{"properties":{"username":{"type":"string","title":"Username"},"password":{"type":"string","title":"Password"}},"type":"object","required":["username","password"],"title":"UserLogin"},"UserResponse":{"properties":{"id":{"type":"integer","title":"Id"},"username":{"type":"string","title":"Username"},"email":{"type":"string","title":"Email"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"is_active":{"type":"boolean","title":"Is Active"}},"type":"object","required":["id","username","email","created_at","is_active"],"title":"UserResponse"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}},"securitySchemes":{"HTTPBearer":{"type":"http","scheme":"bearer"}}}} \ No newline at end of file diff --git a/public/avatar/교복상의.png b/public/avatar/교복상의.png new file mode 100644 index 0000000000000000000000000000000000000000..62af167dbcfc6cc716eb2e6c782616e684da8f28 GIT binary patch literal 294 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I4mJh`hT^KKFANL}jKx9jP7LeL$-HD>V7Tw; z;usR){&t!z7n37T>*OjfY1fN>8!lJbHkyCs5R*F?Wqr^uaLYyy+ewWM6GDwP9ac~| z`$yp0-kO{l3L$)J*6`;Y;Iv>=f57su`#^<7|DQ4$|J`3&S_B$8F4dXkUaHzDyQ#}a zy}P3RLi6#zoX=m&2?!?j7)a_myesyd!Lz}FyI{i(vB%byuVu75=ksP5=l)h=yR~Y1 zrBm>OJ&V{PN;bziZN8=UN9b0_b xuA8mAcIs*KO*~U167Dt#7cluPuxb=v>RqbHl7Eb!fq{X+)78&qol`;+0{~<5a>4)r literal 0 HcmV?d00001 diff --git a/public/avatar/교복조끼상의.png b/public/avatar/교복조끼상의.png new file mode 100644 index 0000000000000000000000000000000000000000..7e001cf54041549716957f1a90702b3f05afde94 GIT binary patch literal 297 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I4mJh`hT^KKFANL}jKx9jP7LeL$-HD>V0h^1 z;usR)o}40)aKPfgH{%|w1QQlFX9$te+Aw9ux%z;+AHN(;Wy|D@WfKgnYcpSOf9R*e z&UF3t6P{=;63xG{;bDDP6>oF%X1T+Sd#4|m_UiR5gP1)vEN>)c{Ma&6mxqy&(Pg&x z6sHMK*R5N}v&!pPLj3C&C!1v%xfv%s4cD_}X+QS1T3xd2{XO3;$|s5#G*%?`HAM0W zEndx>(8usohV8{WhA+p?d?;f$v3)@dgIC241`QU6oeV4;XE|L|oo2LdC}N1Wo|N{K wVXf49l~x8_8N*i%7X>7E8!rklFudHo7n1_n=8KbLh*2~7+P0EZ@W?EnA( literal 0 HcmV?d00001 diff --git a/public/avatar/교복조끼하의남.png b/public/avatar/교복조끼하의남.png new file mode 100644 index 0000000000000000000000000000000000000000..ac02326aa9a8e9fea35a28ced70d459a605aae9b GIT binary patch literal 183 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I4mJh`hT^KKFANL}jKx9jP7LeL$-HD>U?}i( zaSVxYe>=^P>wp0V!{qlCPyeqk_+-t?*CgWgYEnQ|U+0m}4xah#i{;ndXE?yXX3)UM zlfb|%@nc$};jyj1oL6P7Tp6^9x#NU#^USTe3~@S23$`&=Y`@9HAn~KUaSuxp*Sqx9 lsZ-y3cdz*L;r1QQP{u;VItMj51_lNOPgg&ebxsLQ3;^hEL0JF* literal 0 HcmV?d00001 diff --git a/public/avatar/교복조끼하의여.png b/public/avatar/교복조끼하의여.png new file mode 100644 index 0000000000000000000000000000000000000000..1a581d2e8b70b149ceaeca34a1ffb52852f243bf GIT binary patch literal 165 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I4mJh`hT^KKFANL}jKx9jP7LeL$-HD>V2Jm0 zaSVxYPfn3YIAC$$n{kg-f(eV8Gdhvc+Aw9uxq7GE6Y2WvCp^)dB+@LSpmeJLGH-rd zeTqaW!&&D9hsz9#r%z5)*A$$T%&2I)m`{S2k)gIlR?)Ej^L_>f1_n=8KbLh*2~7ZR CFfoMy literal 0 HcmV?d00001 diff --git a/public/avatar/교복하의남.png b/public/avatar/교복하의남.png new file mode 100644 index 0000000000000000000000000000000000000000..ebb7ac25a66c8846c9162cfe0ea6ba2215f48dda GIT binary patch literal 186 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I4mJh`hT^KKFANL}jKx9jP7LeL$-HD>U?}o* zaSVxYPfn3YIAC$$n{kg-f(eV8Gdhvc+Aw9uxq7GE6Y2WvCp^)dB+@LSpmeJLGH-rd zeaeE_%ns8IGMou4DQ7sb{lr3}R}B{hBqS9ZVmJ;IG47IBc!(=<-jb#ck><`&ZiY8) Zg^F$ag3dB9Ffe$!`njxgN@!wW007t)J&OPU literal 0 HcmV?d00001 diff --git a/public/avatar/교복하의여.png b/public/avatar/교복하의여.png new file mode 100644 index 0000000000000000000000000000000000000000..1a581d2e8b70b149ceaeca34a1ffb52852f243bf GIT binary patch literal 165 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I4mJh`hT^KKFANL}jKx9jP7LeL$-HD>V2Jm0 zaSVxYPfn3YIAC$$n{kg-f(eV8Gdhvc+Aw9uxq7GE6Y2WvCp^)dB+@LSpmeJLGH-rd zeTqaW!&&D9hsz9#r%z5)*A$$T%&2I)m`{S2k)gIlR?)Ej^L_>f1_n=8KbLh*2~7ZR CFfoMy literal 0 HcmV?d00001 diff --git a/public/avatar/남자.png b/public/avatar/남자.png new file mode 100644 index 0000000000000000000000000000000000000000..b09caaa189174827b341b22c716366cb15610ae4 GIT binary patch literal 441 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I4mJh`hT^KKFANL}jKx9jP7LeL$-HD>U@Y`> zaSVxYe>=_A@34bF>v8r5GhHRyB4I@-t8kHh46MEu4i@ef3??Em2Uw?COSN2z_@Z!Y zi%U`V7c-m9M%`t{f`mBtoK>5Bv-19)zbDl;J)ZXENk&qieEPiwtkC9rm%?%fwoA_-Mid3o>NJ^l13=ECBI zr`9q5IcM=@d;j{2e=au6WnPfvu%MjJX8-!Tc6LAR_1u%ta=#q%{?3&+my)KBB3TFb z)m@0nx6>?hGb)Hlb>DizI)mZQ{*-%V w3pX+*6g1Q`$>cgO7T4a~K#H7(8A5T-G@yG%+v$04)@|1^@s6 literal 0 HcmV?d00001 diff --git a/public/avatar/동잠상의.png b/public/avatar/동잠상의.png new file mode 100644 index 0000000000000000000000000000000000000000..61b4d6df1e4f3ba2f72aadeb162894d306f837ad GIT binary patch literal 270 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I4mJh`hT^KKFANL}jKx9jP7LeL$-HD>U^wpS z;usR){&w0%-WCHM*SUf(5(R%nKeAF=oZR_3lBLS+#7pJcl%<``dR=@sd5t{3%&$*U zb(C_?SfkJPfNux$y9VnI@q9h8;)Ufk-(urx>tmIz;`FVY>0fn``l{l3G-S@-mhld zz+B{Saqjm^$psp_8!c}KJP%7;XIR?EcEU11R6)Hieb0$TYo?v1U^kqV{lV0Dty}TU U#k8Xg3=9mOu6{1-oD!NC0KWlf@&Et; literal 0 HcmV?d00001 diff --git a/public/avatar/멜빵바지상의 .png b/public/avatar/멜빵바지상의 .png new file mode 100644 index 0000000000000000000000000000000000000000..6266856018b3522b1d139bebae68323019dfc8ab GIT binary patch literal 309 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I4mJh`hT^KKFANL}jKx9jP7LeL$-HD>V0i24 z;usR)o}40)aKPfgH{%|w1QQlFX9$te+At^aS$(BsVOzU@oKcMZCFb>RCb?b`36J{E z`KzA{cG14KA@T74J;&x2bQf4A1nsqUc5q<0_)KW2K+4Y5tF@Uo`CrTqd@|wY-fHuU zeF`@j`OWi^CVc8Nl!JKG!gLu#f81BRzU8?a0|Nttr>mdKI;Vst G1_l78fNWO) literal 0 HcmV?d00001 diff --git a/public/avatar/멜빵바지상의.png b/public/avatar/멜빵바지상의.png new file mode 100644 index 0000000000000000000000000000000000000000..6266856018b3522b1d139bebae68323019dfc8ab GIT binary patch literal 309 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I4mJh`hT^KKFANL}jKx9jP7LeL$-HD>V0i24 z;usR)o}40)aKPfgH{%|w1QQlFX9$te+At^aS$(BsVOzU@oKcMZCFb>RCb?b`36J{E z`KzA{cG14KA@T74J;&x2bQf4A1nsqUc5q<0_)KW2K+4Y5tF@Uo`CrTqd@|wY-fHuU zeF`@j`OWi^CVc8Nl!JKG!gLu#f81BRzU8?a0|Nttr>mdKI;Vst G1_l78fNWO) literal 0 HcmV?d00001 diff --git a/public/avatar/멜빵치마상의.png b/public/avatar/멜빵치마상의.png new file mode 100644 index 0000000000000000000000000000000000000000..d12ba999d1352923626550215282f0a952ce80dc GIT binary patch literal 303 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I4mJh`hT^KKFANL}jKx9jP7LeL$-HD>V0hu_ z;usR)o}40)aKPfgH{%|w1QQlFX9$te+At^aS$(BsVOzU@oRN)Gkc}Yh2C;ZlKoKdSdFc+-oC)&0E30sf=3G`EbL@V0h^1 z;usR){&t!n*C7W1*LuANp3CMP3bJwa`Y7;-@rY4tYe$95#I?`uCZ2saAvEYsgCfV| zEYG$-=K11fQaXQ3xSt4-!}V z9-5ahyDiwtxkxO*C}#Kdf|zj5M^0A-%oU#9fjqo!QmetSd(mr%nmG1%Dod)TMI|rouuX-qMI4hUJz`(%Z>FVdQ&MBdZfdK%; C2z%cE literal 0 HcmV?d00001 diff --git a/public/avatar/산타상의.png b/public/avatar/산타상의.png new file mode 100644 index 0000000000000000000000000000000000000000..037abddab8bee4520c893e874ab7a55a9603b4ae GIT binary patch literal 288 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I4mJh`hT^KKFANL}jKx9jP7LeL$-HD>V7TGw z;usR)o}40)aKPfgH{%|w1QQlFX9$te+OU7hmH!T$oNYfgrymUEP*zrc{Qv*||IIhJ zdwV6X?XNhv;p0u?Nj9!ql4d;JaEkjG$Bd^2g+a28Qc>bb6#C^gOh6x83 zXdHOTFyqe(<_sw&>G`(J?g#fO%kg-bSqDryz@)4^sfal+^TF%u>mRHUTe`~2fLCXC zt@Im(yu7?cXVkm|&vd`osrcnXV%ybi+dc>=sJ=*?KtrSq$0B;Nv8?paF& nBuWotaP+ht*ucZcAiBF&QHU?}r+ zaSVxYe>=^Q>wp3e%jW$8_Nuj+FP9&QU-CpjW6O;gk<-ss9Q1j%fg>^|!X}=>f!PF( zHcW36S={$;Lio9LOJmNm6|{UfRWMEIGDA+pS}s%B1KV=f3N*0&%XbLft-#ck literal 0 HcmV?d00001 diff --git a/public/avatar/여자.png b/public/avatar/여자.png new file mode 100644 index 0000000000000000000000000000000000000000..a916e81880ad1f78fb3c5065bc66f8afa9959461 GIT binary patch literal 549 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I4mJh`hT^KKFANL}jKx9jP7LeL$-HD>V7%+; z;usR){&w2N>_ZMB$Ku^uUyEpTI2%--4C?BhT^!TO7yCv&bUGhvxbg0tQ!c5N6mKYb z``}W85=Z#GmrJLA_PWO%W?d1yXHWJ0_xsA<_pqzW#ktnLj0o2hFP|vl{yGbi!>hN;a3oHUyBzm{)GC$}q8fSN9^Y!()PX>N4?-w2VBc&VJ!; zL-e)Mva^LTsg?z77Sb^?}X|o1EDLQRTvl;7(8A5T-G@yG%+v$ E08e4*GXMYp literal 0 HcmV?d00001 diff --git a/public/avatar/청바지하의.png b/public/avatar/청바지하의.png new file mode 100644 index 0000000000000000000000000000000000000000..66473e8d93b3d713924e68e939ade3dd5369a0d0 GIT binary patch literal 189 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I4mJh`hT^KKFANL}jKx9jP7LeL$-HD>U?}x; zaSVxYPfn3YIAC$$n{kg-f(eV8Gdhvc+OQ}2LA_J%iT2;uCqL1gByw2hz~Uvn5Bv1L zuWLG*@s44}Qv+_tX-eG}uQDo}c35cis^Ox5M5JOv48wsU#$6H%4{=4#Thi1a(%c!! c%}{c(P;ssC<1-8l3=E#GelF{r5}Fto0JBm;EC2ui literal 0 HcmV?d00001 diff --git a/public/funiture/검정 노트북1-0.png b/public/funiture/검정 노트북1-0.png new file mode 100644 index 0000000000000000000000000000000000000000..da7660a5b779b8b2caa6c7db7fcde8dc22988863 GIT binary patch literal 244 zcmeAS@N?(olHy`uVBq!ia0y~yV2}i14mJh`h9fUqlNlHo7>k44ofy`glX=O&z_8BK z#WBRA^X;{bybTHhtQQV@atKFExM96xZ)dBpg!#cP7Ebm5d@ugYb^F)7NI_5{y|&;Q z`bP0l+XkKZOmor literal 0 HcmV?d00001 diff --git a/public/funiture/검정 노트북1-180.png b/public/funiture/검정 노트북1-180.png new file mode 100644 index 0000000000000000000000000000000000000000..7d30b16fea6450518dc2d5654462baad2af499a0 GIT binary patch literal 251 zcmeAS@N?(olHy`uVBq!ia0y~yV31^BU=ZP8V_;yYD4({Mfq{XsILO_JVcj{ImkbOH zTRmMILo9mtUbW?GP~c&^kYvO0idDwoo!p(fFCMV|*~zIliDTh~ML%@;wED$dRxG`= zHAKqDZO4H+JtgZ&%+3?_H)?!hb#uFykfXw?9mJ5=bfWCc3tK<-Qx_iiUR|i~_nM>D zIzwTL-cBKA-`5-)gPCJ1vP{o^dCTx%rA}q}-M5?8NvJjK>?ceUe^zZ?oSyrMfq{X+)78&q Iol`;+0QIP2>;M1& literal 0 HcmV?d00001 diff --git a/public/funiture/검정 노트북1-270.png b/public/funiture/검정 노트북1-270.png new file mode 100644 index 0000000000000000000000000000000000000000..23e8994f1eb83b0032cd0b214896b78ce269eefe GIT binary patch literal 245 zcmeAS@N?(olHy`uVBq!ia0y~yV31^BU=ZP8V_;yYD4({Mfq{XsILO_JVcj{ImkbOH z>pfi@Lo9l?PO;`|HV|+r59D(?!5Sp7^!bCAf+9N^X3Ta-6?ypg=8fQ6k8IDLU|N{8 z>)Kleh6rJ~ppc?g@u>wLl!SQeypvkfJ%b)9`kr&YsxnQid;W>y(sc~-_3=OUunYdH z`u61SVW#bAAB8o$W)&2yJG!4?hS_qLX7A!mNA5e9T2Aje+i^WSc-6^WXP8ddE zFMAIqmI(&Amv?V_IrrJUTTlbyhk4Xe$2_RBX+rL(~Dde2QF^`?uf%O zB{!bLB-%?)u;$6@47z61_ulMge1xKA4Z{`z(PxiT6dZ4D;3$}2mLqq1e}ILyd2j2< U!$PSd3=9kmp00i_>zopr0NSr)OaK4? literal 0 HcmV?d00001 diff --git a/public/funiture/검정 노트북2-0.png b/public/funiture/검정 노트북2-0.png new file mode 100644 index 0000000000000000000000000000000000000000..da7660a5b779b8b2caa6c7db7fcde8dc22988863 GIT binary patch literal 244 zcmeAS@N?(olHy`uVBq!ia0y~yV2}i14mJh`h9fUqlNlHo7>k44ofy`glX=O&z_8BK z#WBRA^X;{bybTHhtQQV@atKFExM96xZ)dBpg!#cP7Ebm5d@ugYb^F)7NI_5{y|&;Q z`bP0l+XkKZOmor literal 0 HcmV?d00001 diff --git a/public/funiture/검정 노트북2-180.png b/public/funiture/검정 노트북2-180.png new file mode 100644 index 0000000000000000000000000000000000000000..7ea3580526c2f8af101f7873841aeaf752926ae4 GIT binary patch literal 260 zcmeAS@N?(olHy`uVBq!ia0y~yV31^BU=ZP8V_;yYD4({Mfq{XsILO_JVcj{ImkbOH z`#fD7Lo9mtUiIc{RuEu&Fn<~Q$3jLisc$lKY%|4U7WB`)AlexqA))oxT+QvTL|TZ> z~~MB-uqsSNM~U1%Hm(-pYoV_%Q@@L7LAn?5ARxj+KO4Vv_vP7>5SSoCwCpK z?&hr_LX&bsw8Nq*J+{Oy4UpLSY0Jl(f`0mXiCf#-H`tZ^|MA^LBHhD|;b+z3j|vLe Q3=9kmp00i_>zopr0A}rE`v3p{ literal 0 HcmV?d00001 diff --git a/public/funiture/검정 노트북2-270.png b/public/funiture/검정 노트북2-270.png new file mode 100644 index 0000000000000000000000000000000000000000..98db1d1ceadb8c35bedb5622db00eda3fc5adfe0 GIT binary patch literal 248 zcmeAS@N?(olHy`uVBq!ia0y~yV31^BU=ZP8V_;yYD4({Mfq{XsILO_JVcj{ImkbOH zn><|{Lo9l?UbYr$RuE`-sQTDZKz{<0dM#hx`;3RISMIcg%x1d5&ay3v`(OSHM`qdM zk@5@-KIbj`f>rv=T~|~HyLA0;&3GK@kty>$uy}>^qP<5e^PhHXwzGJBxJu_$!5`a# zLIwssIrhT&$7Z^(`0@Iu+QQ5^5BF?WnWqp`V=E@O=vc<1OTIgH+U+*{xO(MYPrbh1 z`4^K^KeAj*keSjwnWO!wfS#U`NSKhm+@H_c=js?8SrWHz=r^)qU|?YIboFyt=akR{ E0FUlpFaQ7m literal 0 HcmV?d00001 diff --git a/public/funiture/검정 노트북2-90.png b/public/funiture/검정 노트북2-90.png new file mode 100644 index 0000000000000000000000000000000000000000..e4378c8b9e43bf9937516bb052231d5aea3be1e7 GIT binary patch literal 254 zcmeAS@N?(olHy`uVBq!ia0y~yV31&7V36csV_;y=)_xbuz`(#*9OUlAujFrM?E3&(7fjI`$t;U4eqt9GZ0C$G8QSDJ5}-7e;y&_ z3kI(@9WA}N=-It?)0|of(F;OhjQd;mGua+exnNwwti0&w8Rf_I2{sEX6_)wC+O2=g z$?!qXg~4Kd?V@+H*Bm!#J^fK{LWh*b;d`xFwrihG;P6P~Ok&KQ(Y$K<9ql>K*s9tO z<_4agH~nM(^f`KKCWx$K&~<6uX~F3+!AyrqW(n`?k44ofy`glX=O&z_8BK z#WBRA^X;{bybTHhtQQV@atKFExM96xZ)dBpg!#cP7Ebm5d@ugYb^F)7NI_5{y|&;Q z`bP0l+XkKZOmor literal 0 HcmV?d00001 diff --git a/public/funiture/검정 노트북3-180.png b/public/funiture/검정 노트북3-180.png new file mode 100644 index 0000000000000000000000000000000000000000..e4378c8b9e43bf9937516bb052231d5aea3be1e7 GIT binary patch literal 254 zcmeAS@N?(olHy`uVBq!ia0y~yV31&7V36csV_;y=)_xbuz`(#*9OUlAujFrM?E3&(7fjI`$t;U4eqt9GZ0C$G8QSDJ5}-7e;y&_ z3kI(@9WA}N=-It?)0|of(F;OhjQd;mGua+exnNwwti0&w8Rf_I2{sEX6_)wC+O2=g z$?!qXg~4Kd?V@+H*Bm!#J^fK{LWh*b;d`xFwrihG;P6P~Ok&KQ(Y$K<9ql>K*s9tO z<_4agH~nM(^f`KKCWx$K&~<6uX~F3+!AyrqW(n`?OqgvkwY$N7H(Ok)xb@=gn|Q^S zZ9K<3L;IoPa<6S4f>kwtXigIPGr@&x{iKYCirZ%?<{t6QidM+H6!Bd8^&boVt$gn+ zEe;4)oXCsSthoNI{(I`&3cENfPfojY%(1sOuMuvDU`SB;5FwmtcYnL#?eO5NMUCyB z^I}zJvhADfkzyIhGjGb16hlwGxG76gq;^<6e184RKZce?VT(j(zHw$?U|{fc^>bP0 Hl+XkK93o-d literal 0 HcmV?d00001 diff --git a/public/funiture/검정 노트북3-90.png b/public/funiture/검정 노트북3-90.png new file mode 100644 index 0000000000000000000000000000000000000000..84d359a3eca5df20925ac2b634111bc512aa4ad9 GIT binary patch literal 259 zcmeAS@N?(olHy`uVBq!ia0y~yV31^BU=ZP8V_;yYD4({Mfq{XsILO_JVcj{ImkbOH zdp%toLo9mtUNz)vQ4nCg(Em|kT{R=O`8T;a^D+*yUfIhT?aQRn!+hdjo7;0kBgdY@ zZ;mCFWk`rJKQ9kIJ5ek`s8VRR>%0R;Jb0(|PDzkukh$LW_?DMWV3Qw5)qcB0)0f^A zsn<(3ot7z@Sj+jqnPG#^>c<-%`PH;cdbMUX;|h(n$Fd|=3*K+%V#qpwH2$xE+?J`$ zg$$znR-MX6gRGZdxVd5R@x(jl9yh1E&ye#=EI+ClIsLBvKU>CW#wzy7i_4$+E|R^; Qz`(%Z>FVdQ&MBb@0P#O$%>V!Z literal 0 HcmV?d00001 diff --git a/public/funiture/나무 탁자-90.png b/public/funiture/나무 탁자-90.png new file mode 100644 index 0000000000000000000000000000000000000000..6b419e1dfcca1f2fe41ec4fc991dff079f2eb9ae GIT binary patch literal 511 zcmeAS@N?(olHy`uVBq!ia0y~yV9)|#4mJh`hNFj1Ml&!lFct^7J29*~C-ahlfpMp& zi(^Pc>)V;OL5B?lTIWla%QQ_gzEHrR6}ibw$y*K`p($Dk!gjP(j#<)zL|xFo*7 zJ6B4qB`5OMRV~T1PrD+1e|EjKCWSR1EHErNFl=$zofvI_gSpqVt4x?2?r)vx_UDCy zaYKE{{|_7*4Bc~U8@44i-t;qjaQinq!-HqPv`)lx$>hA5eelYGIOTUj%bCjfiWqY< znf>LCT}#kH-K3Jo%x^Q#cM&>1K%RYYY_TJ+AcX#U~XAOqyc31AR zn3&#t-OroDY~{$$>+{WfLid7|jc+CI@b5mlyN~4gtGpE2&G7wjUfG)5 zECz>r<+))~uAcBc6Z=TjWB#voXOFx+`TY1=$N6cuFPHu3VVia?&OoD6=%Scd)Zs6# z&aS2rwzj7od~4--7WLKYIsEGV@uvCqH zD;L+Fz9=pbvVZ$iS%b@}CJGBHgOj$YzrDu7kbaACDSPJpD~`c0Syru;>6`0*Ylf=P z=gVPP&(G^7XWY*=N}T&s{q4&O4GVw%_wAkMu(g!w>a)V-zlbQ@f+UjMj_!D1B=GZh$jW#+wO{|KE+l$+8Lv+q*9^I*X&E_5Eu3*ji z_O^{-_V+`~cO?JuP1^I__s?e!r3GtpMW)}@(A)aBEvk{@-RsZIaZ?!romJ^X}BAwYi3S zr4qC?GB?LPtl3+seub%|Y0?hkJK_q`Q1n#^yLc;k+T>W(n11!?Y$cR zOS!-Od^Cv1Q|psX!8CQwg3Fur=d3;Usbr_U%jVna3M)$u1C@TYc-qwLw7z~hS7P+%>!&VXXt?v||FOlwE4HZxgTe~DWM4f`n2We literal 0 HcmV?d00001 diff --git a/public/funiture/노트북1-0.png b/public/funiture/노트북1-0.png new file mode 100644 index 0000000000000000000000000000000000000000..843e71d9060a2f02e5e68514fee21770c0a559fb GIT binary patch literal 303 zcmeAS@N?(olHy`uVBq!ia0y~yV2}i14mJh`h9fUqlNlHo7>k44ofy`glX=O&!0^)3 z#WBRA^XWBRzC#WoEDtnS9++4l&BNZ1P{3ir*X%HX%Sk}gbjPbMD<5X|0|jf89!$)= z{CDQvMG{wY%sy{Dc{-2r_!HO2=dO{v--R0|m@W5w)_U|!^iof@ntePBX0Jo9SU+m6 zROS)u*^#hU(Te9y$;KHC_Rd%SNIt5b(sG literal 0 HcmV?d00001 diff --git a/public/funiture/노트북1-180.png b/public/funiture/노트북1-180.png new file mode 100644 index 0000000000000000000000000000000000000000..a137f1037cde036b273d8f853d69bc534d9f31a5 GIT binary patch literal 269 zcmeAS@N?(olHy`uVBq!ia0y~yV31^BU=ZP8V_;yYD4({Mfq{XsILO_JVcj{ImkbOH z$30yfLo9leXY8!_`G02j^#AD#6cb#wE7+P%n)qzmg+oSjrUhI!YMK^s*l3aN!XrkL zbQO;qsTJuch(6f5Wb-!h<#!JGT6gj>iq*7DHy3|=dh^4(&PE4jNZcrUVZqECWME`uU|?|JkP@59{Y5Sk zn$sR`X0X&S)KR=@6r*3C5fs_#26gj`$k9dTJF8B>mNQnOIq_u=ZJ)#&Wkl~k7fzB z`4>p`ofbbKX7Jzx?;RnhHuj%Y!dp1R6B<>$Yubb#3&$wfnoXK0X#4gs&(SnVO|hlN z-3~c(o>RDH6vT6ULyN@e50Ncr3+7CFu$gDJ>JJ|E3{Jh%9)@#L42nN~`u{|10>kdF XIbTgqdS7HbP0l+XkKWe#Wf literal 0 HcmV?d00001 diff --git a/public/funiture/노트북1-90.png b/public/funiture/노트북1-90.png new file mode 100644 index 0000000000000000000000000000000000000000..891e8afbefe144585eca4f24db2c11dcb252eac3 GIT binary patch literal 285 zcmeAS@N?(olHy`uVBq!ia0y~yV31&7V36csV_;y=)_xbuz`(#*9OUlAuz*!-As(G?rzr{@G7xaBpApA(nDNDxD~cE9G&w2?NW_%7%C@2oHnQ29FIaHrgi`(^xGJ`Z=stRKLM_ zjp_D^QzgPHly~a6sLy=%Z5J~`U;E$xhZT0t@lTt0fSG^g>Pwv#o*@EE`|a;fT(Hio z%+#?_#OHU3Y1k9apaaE=l+-J%*cp0sO|2BzZ~Rj-UYokMqxrIL8Sh#Rwr?y!g6w<; qBOQHISlw9_CyK1;d#3C1nK|{^((ZXu8i@=H3=E#GelF{r5}E*cT55g( literal 0 HcmV?d00001 diff --git a/public/funiture/노트북2-0.png b/public/funiture/노트북2-0.png new file mode 100644 index 0000000000000000000000000000000000000000..843e71d9060a2f02e5e68514fee21770c0a559fb GIT binary patch literal 303 zcmeAS@N?(olHy`uVBq!ia0y~yV2}i14mJh`h9fUqlNlHo7>k44ofy`glX=O&!0^)3 z#WBRA^XWBRzC#WoEDtnS9++4l&BNZ1P{3ir*X%HX%Sk}gbjPbMD<5X|0|jf89!$)= z{CDQvMG{wY%sy{Dc{-2r_!HO2=dO{v--R0|m@W5w)_U|!^iof@ntePBX0Jo9SU+m6 zROS)u*^#hU(Te9y$;KHC_Rd%SNIt5b(sG literal 0 HcmV?d00001 diff --git a/public/funiture/노트북2-180.png b/public/funiture/노트북2-180.png new file mode 100644 index 0000000000000000000000000000000000000000..48cb9b325959f8c5e501d775a1bbe80ccb571ce2 GIT binary patch literal 288 zcmeAS@N?(olHy`uVBq!ia0y~yV31^BU=ZP8V_;yYD4({Mfq{XsILO_JVcj{ImkbOH zw>(`OLo9l?Ue)Dmb`W5Fu-#X`^`Kc}K;!BRt!f6ARa~JLnH4yh+8ZvgbidrVViwyA zgEvgSc^D7X{673n>1?;%*={+p#mf%-tqQy*D1Ad`llr!fH;tLf(FVyWyk!y0Gum9| z^5N=n8MnhKqUKI2K3e-Um$`ovzjVFv;0j6cOa=x922WQ%mvv4FO#q;iZqxt( literal 0 HcmV?d00001 diff --git a/public/funiture/노트북2-270.png b/public/funiture/노트북2-270.png new file mode 100644 index 0000000000000000000000000000000000000000..53d7068849e03992835a009d7319ceae9c632ba2 GIT binary patch literal 270 zcmeAS@N?(olHy`uVBq!ia0y~yV31^BU=ZP8V_;yYD4({Mfq{XsILO_JVcj{ImkbOH zCp=voLo9l?URfyAq9D-l@aeO-SEp28V<4ZO)Zb=6*qoVX_4Mu?EQ0vgwoVLtD{D1 zYh1K)mt1=GwZ^6W?%%mx%nSjA<{8%?+g#uftFPauVRDXH=klwgwkjg|ue3N4mEUa5 zdBxS+?&sutf2-4u^7)4>0`6~pZeO97WaxLtImv))SJz^W)>y?5n~nqt^>g!b?R{P` b|71>GRkZ4*fB#Ab1_lOCS3j3^P6z*!-As(G?rzr{@G7xaBpApA(nDNDxD~cE9G&w2?NW_%7%C@2oHnQ29FIaHrgi`(^xGJ`Z=stRKLM_ zjp_D^QzgPHly~a6sLy=%Z5J~`U;E$xhZT0t@lTt0fSG^g>Pwv#o*@EE`|a;fT(Hio z%+#?_#OHU3Y1k9apaaE=l+-J%*cp0sO|2BzZ~Rj-UYokMqxrIL8Sh#Rwr?y!g6w<; qBOQHISlw9_CyK1;d#3C1nK|{^((ZXu8i@=H3=E#GelF{r5}E*cT55g( literal 0 HcmV?d00001 diff --git a/public/funiture/노트북3-0.png b/public/funiture/노트북3-0.png new file mode 100644 index 0000000000000000000000000000000000000000..843e71d9060a2f02e5e68514fee21770c0a559fb GIT binary patch literal 303 zcmeAS@N?(olHy`uVBq!ia0y~yV2}i14mJh`h9fUqlNlHo7>k44ofy`glX=O&!0^)3 z#WBRA^XWBRzC#WoEDtnS9++4l&BNZ1P{3ir*X%HX%Sk}gbjPbMD<5X|0|jf89!$)= z{CDQvMG{wY%sy{Dc{-2r_!HO2=dO{v--R0|m@W5w)_U|!^iof@ntePBX0Jo9SU+m6 zROS)u*^#hU(Te9y$;KHC_Rd%SNIt5b(sG literal 0 HcmV?d00001 diff --git a/public/funiture/노트북3-180.png b/public/funiture/노트북3-180.png new file mode 100644 index 0000000000000000000000000000000000000000..fb6f6f90aa37574a9a2dd62ab09dfeb3d07dbf2d GIT binary patch literal 296 zcmeAS@N?(olHy`uVBq!ia0y~yV31^BU=ZP8V_;yYD4({Mfq{XsILO_JVcj{ImkbOH zk33x*Lo9l?UR}u7>uk8ST64;?ZAWddm81 zK3xq?Ts5_PKU>uFm-5$ae(XEhDFVdQ&MBb@ E07f)^b^rhX literal 0 HcmV?d00001 diff --git a/public/funiture/노트북3-270.png b/public/funiture/노트북3-270.png new file mode 100644 index 0000000000000000000000000000000000000000..c06c4046a3bbc6d78e48620cf9c32d50b150bd0a GIT binary patch literal 263 zcmeAS@N?(olHy`uVBq!ia0y~yV31^BU=ZP8V_;yYD4({Mfq{XsILO_JVcj{ImkbOH z2R&UJLo9l?UeOh5HV|lgm~XvTWEs0g^FhT6G7A=Qg|z*!-As(G?rzr{@G7xaBpApA(nDNDxD~cE9G&w2?NW_%7%C@2oHnQ29FIaHrgi`(^xGJ`Z=stRKLM_ zjp_D^QzgPHly~a6sLy=%Z5J~`U;E$xhZT0t@lTt0fSG^g>Pwv#o*@EE`|a;fT(Hio z%+#?_#OHU3Y1k9apaaE=l+-J%*cp0sO|2BzZ~Rj-UYokMqxrIL8Sh#Rwr?y!g6w<; qBOQHISlw9_CyK1;d#3C1nK|{^((ZXu8i@=H3=E#GelF{r5}E*cT55g( literal 0 HcmV?d00001 diff --git a/public/funiture/녹색 침대-0.png b/public/funiture/녹색 침대-0.png new file mode 100644 index 0000000000000000000000000000000000000000..ded87ee86767d3e19b0a9f8f0d8568bef346837f GIT binary patch literal 674 zcmeAS@N?(olHy`uVBq!ia0y~yV6bCgU@+ofV_;xlVa~8;U|?V@4sv&5Sa(k5B?ALf zjHioZNJit+S%%)sh7xV}Pvt6ElzeCBu-f^Gzu=|PmJ1iT_C^*IlnKnKULY&O!mX$G zui?=$3ymvh4<0=0S%1Lt#`+~6FL~?5>+SqJv(LeK-uI%j>pONAD$%$7V!~}W7sQ&r!iI>4+`u+&E=R(RK ztvH0E9+e8IiFI8MoN+>A{bmEftyXuxeEYSkv0ll?WkQRmkg2Ge62~ExsPx37Z$0_B zYZ!tv`5qqJz`KTN_usQ34DTjAZ!2Bivth~_rRGFwh7H@y-|y_+z?2{y&MACF&WqvN zqsl`@3t0jp?u0}fD`zv~Gwgyne_im34uLessjKQihq2niFr& z-q5p}!RV;-M6ri_4SS1)r|>Su6OSqnRaV?@ zG^}OGY2|!h9^+=p%n_Y<=(p_jiRlMYi})ETDt*X%DOe@eljpHFnGH9xvXn72oIIS5aAh~GIM$j3)_xZS{{WSC>+nYEpClh;^bY- zDRE2u5-0CsS~KU)@(rF>8LxQWZVuCOys=~zgCL9d>nWTUUVaT`sHm*!`B5#?WU}S> z`rf0DGlUxsolH67oVBEH^{$JnHgHZ*=l*E2A@ytmy- zY>h=`iSn7rYF2xjuei-%(Y5oR)$mk3M=1H{F`kF4?mybwkUtBFSK|&z_Nm<--0}z1EYrjP*1d^n5(0h^R@&=LsD% zsCsFgT;Cry3#&Wq(=O??c=qg<4C^hPlR15ffWQIMSHk8g6ANpWGez(l>=sk_{$!gr zBUk^Lh3p!(tJ|dx+*DHC!f-A;eId(=oJcKOho`R#JsH1rZrRq$F`rRm4S(j=_Xn;> zDtOPRxLx&2-$8O4r||pgyxB}52i;~=h^wbHY`(bk>-}efYDq^r=x{w)Sp`1%Kp9o-bdfu%u}`U?$KTB?kii>o$h_R^#99_zVl@Y zdLBPM)D&lI{;<~J^dXY z7TjNaeJk^ut`EoG-z<8_v7%MLjv=O|N+%YqAk%#0%VCbe+JZ#C3(FwS8t&~8{~uuw#O5>w2*c}&xm*G4`3J3m~(bIuL1 z2QUBeZV*ve{o&ppiOCl)TozKl#5SXswYq1`p|z{i84fJC%~)bzI5U^=)M@5d z-F^0|Q#ZHV?aqV`*K)5N_2oLA=zC|HLEw%ZxrvJm3d37CA3s{hDSXX8&n56sOXtNoWfE0>nh%@Q+aoN zruLzhYmv6+c?x`Ic$8XtOD9}-YoYFwva4^l4X2pvI%x?@hQFduUwtuiv(4bJ65Msp Ze&Xupee6N)&I}9;44$rjF6*2UngB2P1mgez literal 0 HcmV?d00001 diff --git a/public/funiture/녹색 침대-90.png b/public/funiture/녹색 침대-90.png new file mode 100644 index 0000000000000000000000000000000000000000..a09a8e5de14e41d34b5827f78b277072b5f91d1d GIT binary patch literal 677 zcmeAS@N?(olHy`uVBq!ia0y~yV6bCgU@+ofV_;xlVa~8;U|?V@4sv&5Sa(k5B?ALf zyr+v}NJit+S%&_%9VFV~!xOu9ZT!xzkdUPFr$HxDOHh;9S@`73t`>pf7Nu0bwvq$~ zrq0$**;3|>E?L|8WN%o7oaDD)tI+)&bA01_>)$!^PL}tbtBjlf)<2zDbm85XS|>l+ zdGM)uMubF3F-Tov_Huf7v*eH-E5r8i>GNLR@az8mH*krrWW(i0)z)|m*@&M zTz+)KjbW;Q`Xt5>uQ?tp0iK0PLJmuhdR}9=HFw>rRtrzA1@HTJ$ci{^6;iKaxbyC} z@``6nn?DL16P;k9TW=mTFK*6%1G&$I`&zQ}n!e@OlsxYV{&9Kwi=7r$e|b1q1(ub3 zR7pHk_maU&*kAbCM7{+DAJ-hR3Styd%hOtuEY(n2n3*_t4`YPzcHK3TO&JP5E@~~k z#_Qm-Cns_4{%H0C(G2&k$~XQ!_Glfa{oh;tTO=F&@(QoFUOu4{SDLtKTSaV(=cf}&%QY4M8Vtg9oGXZMB`#x(;p{pSYKWv_Hy^$U0?Q0 z+rK(y!Zb0y^(#JYf0+90Qg}iR=j0{Rxt4a`7V}$N@NrYCXX2xE9Fy4?{_G6A_U_d> zPGSCAO8Y_>)^SZ{W61e)n=fud;nPDZ4K|ltIVayRJ;&_7PoBw=>4aA776Z>cJ7=;@ zaEm;oBJloabHc9n(|ZqY%wb67p4?V;;s4ZfE+)nuNH5oM8RO mqDFj+b!_V6+eOlQ7|!pVx#Q*DRA&YT1_n=8KbLh*2~7aOgDEfo literal 0 HcmV?d00001 diff --git a/public/funiture/녹색 탁자.png b/public/funiture/녹색 탁자.png new file mode 100644 index 0000000000000000000000000000000000000000..ff88989204ece64f423d292eb4c1a16186b9adf3 GIT binary patch literal 427 zcmeAS@N?(olHy`uVBq!ia0y~yV9;V$9_W-HW;TnQcuc%lAM3I2lfCW?|THKUed6 z!8{k{Qk{b52YnspYTu}upc`;q^v2< z*B**sYU+Ko$$Y^~hQjFO#+DYM?mCZBx2pYO`p+sN%y8z5!_8F9x_l$E9efdY<7>6< zIo3AMZYaI#bo0Tw1J?q=H)vmw%HmFAw06GTF#nvs$pYh$Dh|8P4M8Vnu&`;I7qSxa zSy7_Z_ePUZKA`ib$wT2&c}<(7#Y^_zX6vr-yw9MQ8JsasBIKLGWs`N`_HAk!yE#wg jnFLhX{S#zt$iE)_?EJ1H$D$b+7#KWV{an^LB{Ts5%Nnm9 literal 0 HcmV?d00001 diff --git a/public/funiture/미니 냉장고-0.png b/public/funiture/미니 냉장고-0.png new file mode 100644 index 0000000000000000000000000000000000000000..17dfebe86876133af3a2249ff4d1188b6f9914ab GIT binary patch literal 488 zcmeAS@N?(olHy`uVBq!ia0y~yV9;h@V9?@V1Cfl2QW+Q+7>k44ofy`glX=O&z_`fM z#W5tJ_3cgTyk-ZH)`!kdCdpO^-fPfSTI#fD*%_uuN+ppDv07peZ%xwnUbabT>5?-{ zo0z}*P4;eE&G@X4UE_!L?Bg$loxbt72;^+{f4A%H*{yvl$HXODCbU2H+--k8$bsQ) z=_iRv&%4X4PKuo@c8!*O)BWV;qF%m9Zl8QEHd{L@S0-n?J+^Y<9=np~r|)>kxo^L5 z*KVi%{(X!L^W-MqY}oZl$F+FvVvhQNuUYf-O*b%nd49e8_f9SD919VyBE2nLA-8$v znyBvLUt!j<)M=6Lgeij4COO?^xVmP;6z*H1E1tD1^|>JAaf;jf<%LNeUM$`#Us&Ap z;^Sqg5Rf-a&^8N!pmX~-n{*#<(dK@xy_)H>&f5phPfMj``j&fM&sM76KKU-&*{yw_ zbaK@dz1QvKeN@r?d1;)MQOi`r%OWoYJviZ#4b zY@elnOZ@c2DHoe9qqk^p#DUD zyJ53&Ql#^OZ|#q(GizU1H{Y&cY`8Vcc#<%>?nzAA TvG*k44ofy`glX=O&z&Oj( z#W5tJ_3cgjyk-ND;~(cQ?zz*zTyiF2<*O-Pb0^HObWz_X*RWRh6r)vI-;|l0uBT4S zU@l;O|MJl07u=lx=k>PNY`@`P*ClXpfx?w%rPVK`)}M0ntUdmQS+HokeaSiXLKcpN z>hcTtPfm8|zgMU;$(-G`qJ5{wJ&}_4ZI?XmNqjlWo1pEuHn9Kf9}~BA@>!hoYsn8HofZ{wEHikr%bHa;ctqW zatv|v<})yy+n79|=8R++*Q9h=?Zzc*5>+Qo5q$>*BZb>Qc+H4lb4h4>h=_@0<)?c*o@OYGQv0diR^d0%LWkio_rWu~MjDG)541QL6gPh9&1N>(DBzPdVd~GrW!oJ; zhX+WjnBBh1X7DfZmV)+AZm}A(35+c|KXeqrI3hn>bks6C#J2nrTT_PbtYFT$A$t{5 z4!SdPwoPE2&KGo4p+_`Et3#?qXNSpzQ#Ro>X%h|#MGG^tHqH2(xocOugwDpFPP!8= zl%Dui#U}J7H0SGD?>Fv@g|ZX>E8M!g^!J*4*%+BGKQ5- zTSX>Z*stF9d1mTLg*~3!cX_Ow4VE?@VVd!_!qH%-;}(V)2^?zX9ci=HUEBS84)YS* z*bqgFT@xOe*{F70TVcScw%^WqGFQvug)d*N$mi(znO3rXp`e+LW6;`=SR=E8vlcUH zy0!FN<1l>Aem$^|KcPVG;T7)IBTYXXelQqpjz8Gmw8No=!QiIcxetsF*UhjF;1py& zkW!y>oYlq2!KciS!KaMl7^{m@27|#-2ZQhD{CqjwUR$1fq%gy9!p8o#Yq=F01$sC) m?VBh5StWndlDcib#UJpioM6-wd&I!Nz~JfX=d#Wzp$PzNpat3h literal 0 HcmV?d00001 diff --git a/public/funiture/박스-90.png b/public/funiture/박스-90.png new file mode 100644 index 0000000000000000000000000000000000000000..3770986d26f7441206b1ce9604b5be4405985a88 GIT binary patch literal 612 zcmeAS@N?(olHy`uVBq!ia0y~yU~pn!V9@1YV_;yA`nETnfq{XsILO_JVcj{ImkbO{ za-J@ZAsLNNZyDwtG7vfTk-wAYOi${|z8gE(!XGfqX=g5AT{n?;NHtBZ%RecvETP3TF?-4KW;I%z@1l*bkZ#=&rdxYm=2_E=N7Q&eb%>L z_>fdXye31p9`9?0nlGL`>+@E9>-nL=u>E<@50{1)ZCN{`7KlX^2W6gR_`mo3wM!A} z#OmuUj5^F5K8aQ9Oi*NSDz&p~XOfgUFqikm=A+k`B)JuS)E%kYUHQQ+So`jY2*!k) z?d$GHCh<0i${!APT*G9r(L60l;Tq!&mFNGRy@J{m?K2t7T8%R@1$y6ZFk#4^YpTGY zWGTIcG2-j|xZNP{8pDhm2~G-nEujo*b{gVGVx__uuU!83ZcE92HrvPT VdlO7vF)%PNc)I$ztaD0e0su@6|Cs;) literal 0 HcmV?d00001 diff --git a/public/funiture/분홍색 탁자.png b/public/funiture/분홍색 탁자.png new file mode 100644 index 0000000000000000000000000000000000000000..8d530bb5bb40f474fc271bdb91261a3a7bbb04e4 GIT binary patch literal 423 zcmeAS@N?(olHy`uVBq!ia0y~yV9;V!R) z*+bdOe$GhuVgC94%<&(}eV>{bc9!nZy!LTf!g}juU6mcVU+U}y7Be&)w&Rv~ZLR-_ zAw;#3OW?rX{5+;tF$d@7zv8;`m38~R8=@;-bLH*n;JvbyHQN6d+pAdz_}S<4Ub%VT z+0hFQbJ^Jc^R5v7z@z%<^KWj355G1^g@u*Eq@&#^K3&btdL{%3_{rF0xJx9?!w zY0p_+d=eHL9`pCMC@#){MD>7t1*EG&< z*e3NnP;7_(2Gc8|Z@Av@yyZ$`taiTLP=C%{>Vl-!)kgp4%Dft_J+1+as+C^uO`KEL zO`o$@b$9zVHI3bzr}9h! gs_gy=GB(85)xOQR@2Gy2fq{X+)78&qol`;+0B)JHw*UYD literal 0 HcmV?d00001 diff --git a/public/funiture/빨간 침대-0.png b/public/funiture/빨간 침대-0.png new file mode 100644 index 0000000000000000000000000000000000000000..5c6314b67110fde666450a99e1f095e33c0b267c GIT binary patch literal 695 zcmeAS@N?(olHy`uVBq!ia0y~yV6bCgU@+ofV_;xlVa~8;U|?V@4sv&5Sa(k5B?ALf zp{I*uNJit+S^KkZJ4m#}i{>t@eyw_wxs}Z(onuO0oRP$yk`3K=6ur0m$=d9wczL01 zfugs9vqlQfPvJ)kO{x{Vz0=!y7^lB}6z-9(y{Y8Br~1>GOD0?Vto-`=^W2767hZmw zl2U(O#d@-eo|Y~z!@iRCNdXo%f6l3~GCW_dJ}>jWUpIeKVN$FS!}6oI*Uw{SSa!X< zv$T_8j+nX`(~4G$i98F!9=RwrM0E*IX2{a@`^0wP<+l*!Bc4$V%?W3ot`NwYVsVlA z!6yX?=iD;_er^Wq8kR`Dd*_!~UtpGYvC2YnXYn!3nh#0Z7wf)! z<7Mbz5m;XI(Ij!x{++xL91Dy->Lkj(WbhI{uf0ZD)FJcHvO`wO7$d}|i(S){YA`Ll zeaNbkSwLJrIwJip!;vGa#ja(Ze||uzVXnUYv~AMeyVYu{KWlHiH}Cw%{G)ZPM`Oj$ zDQ&N~+j{cWBVR$anU8#h)mA?86`c3Wt}JYeey6#o1VdcCb=#qw%xaZ;E)S1?n58A- z=XP=1zvKR$+df|wJ}>?5ou>GbrLB*ZSKTqUI}`JhqenM!lG)0DnLBbH>UfE-kD4(> z?Om6_N5%)Q??-3*mtJq_Y(Hwf=W7A~k$)#}Oyq9{?wLGb0KmXnuk z&oR%htzZ}7P*`7b^N`BdhiOs{VxgRqJ@&}+EdKe#Wv9A~!_`AauJi3$wI^7B9_wTNa!AqZ})HV+rB)fCOWVA|5v5OR;p`?J~1#bFnGH9xvXc>07eydWcl;{W3>b2#WahI_U0maYH&K*FJ*P&!d~Izv3$B6*K-a<}f@T*#=Sx85OzF+^0%Yx~m2 zOh5jWF*fk)$6bBBmTB9e{Zal4U)9xwFwC#}-{`=2MRZ@)zn;a-e~+vZNR&QszFz!Q z;uX=l?Vnj2?tNpsvi3j^S3=qA2|H)5bYz&dgKM_M)OlwZo_v3EO)>Iy+vfA~v9s9u z4>ft6)s=U+w(DX>P_75F&bqbsyXs|`u}(q;}JETkg5vDyL%^Wx*Mr;$Z2Wh-Bgw%YB~{n z&*d<`$jx_A*SS*F|9cM0>_eH1$!rSq7bVHwyO&YEYo<@j#$x^m_xi4fQw>cS7#J8l MUHx3vIVCg!0O`0MbpQYW literal 0 HcmV?d00001 diff --git a/public/funiture/빨간 침대-270.png b/public/funiture/빨간 침대-270.png new file mode 100644 index 0000000000000000000000000000000000000000..efcd92c231ffff7770c0ea5c3f4704530d8474f2 GIT binary patch literal 618 zcmeAS@N?(olHy`uVBq!ia0y~yV6bCgV9?}XV_;y&6`GaKz`(#*9OUlAu$R4$aSkpHio5L zuTROsVmIgAw@T-%O1qjXpMRZn#rxf)>X+tMMJCt9&;IiC)j_4VQMLiwzsAqAoqc>m zYkso2%ALrSZu5EOKNdUi;^-OCuot!an0uH~ssuAzLU*uulokjlie6{j!j}~saWu4H zC;RE(h@~$WHnM95MJ!#xU~FBkxn@#1!^X>HYvl?A!cJ;8uphf*q{b+9HjIIR|9iRI z-?!5pr9Qo!o+PC5@Zkdg*+)EbQ&V(><}w%_VrbagT+DFb?1E^!LM1(iNgrO{HfK;XjeQ1zSs(LKxia=0*0MXKohGSQa3sTsje$pge%iLz9Kv>U zrT6yVU(bDx(WBzN!wdoWuWL_hGdR5e!I2=4U?Q1ZUFvJorkbc!)K|=Lw-v{_usM$t;%(P=?tF6ZsouxgPG0>r`}aJ#r^+a?G9;Vvd4<}{ z2XX;Z$}KtilLds{Y>D1-Ix^A8+r~wLVZ*+S{0tU(?=xiQcm{R4bKbYDV_;yaKTuSl z=BX0LlIY~Q$K%kE`Hx&h)SQlX>-bbm&C=arVXgacK8LWJO5!9J$5u}MCWB6~$Qcr` zkG;8r4+P9|-f?|Ra@wWi&pvrEFsz;ZdeXNAJsY;HQQDg(%do-B`u}hC<4g&{{UUOk zOl`mKedxh(>rvq$qjfA3qHI=f=y}T^)cu<4^bIbD$T`swYOfiRj{0(+P7!rjQgQ8& z(K2QZ(f6jWueGH5unK6s>)OIm&hTdL?93fZC+1o#ly=BF+PQ`CN!%O{DTft5ex9B@ z+1x@jgjqtE{|}>Bzh6OKrQCH@y@nG(@itbB4TsnazD@YJ?SI#~gZEza_r8ri(CF9K zeRz9%!#NAvRMGfvCx1(^u57q^zP#kG(Uj+QtGf1t9Vq^vxaIEG)2}W^#%Vihc$_E!^nv)%Say7#xewla@< zzlt+_`1tnujk+Hvmnq3;pW^OPI2nJ=d**AykXB{a$1Kg07KvQYoTILorJ#I5xaY}& zjSaQiZ2d!9PO9@WtoNkmCrk!y$>$mwCtD3`X>EFCH-Q)(-t3=4)bpo`M)b1nb? literal 0 HcmV?d00001 diff --git a/public/funiture/선반-180.png b/public/funiture/선반-180.png new file mode 100644 index 0000000000000000000000000000000000000000..0b1b7ae4860dc75ab569e52ce59c9a8dee782e8b GIT binary patch literal 497 zcmeAS@N?(olHy`uVBq!ia0y~yV9;VoHe=IqB4q7OT|mJs+HJ{4l-yLoqVIY+0A4uyE1} zE7vUZDVjVFy)?Ex?k~1qViO&0T~(AGU$oAFB`^P7^xb^<`T2*Q&q{Z6<4T#8sp^~{ zIyLi}-5uADPlUJjF3F6XVex(Dm3Ee7*GV6uZfp>kqc8O~W{0Xe|cG?x#E)j-z)e2R^Q<~ zMOjw-qN3xWs=!?ww>xfe37y-0-0g9en({P w4HnLA>XW@XyoHv3;Yf8~7AgJs{_gxIE;rH{w{J*fU|?YIboFyt=akR{0CC6FumAu6 literal 0 HcmV?d00001 diff --git a/public/funiture/선반-270.png b/public/funiture/선반-270.png new file mode 100644 index 0000000000000000000000000000000000000000..2f5b746689695ca5cf0dc55f5cf2080d4375303a GIT binary patch literal 509 zcmeAS@N?(olHy`uVBq!ia0y~yV9;V`e}1rqGeeS26eVz6_6^@VN4@%P_%-Cozr{gRE5;gZ3& zeJtYoiz1gCO^sYsdpA!>vs&|Nz-%3-<8I%+KF+1sD=Gu3?w1-Su~=jWh#8ml1;kN3`-Su_X;n`u5=| zH&fzF?GOFqSiPmRvHL+lNvu-$7Udp0E#Bn0u2P>uKP%}8nykDoeXZ6kz+z^2z?6ga z*Na&v-aPxY!n&Dd)!yWM&iUPI)BW5^GG55UPO;k7uXJi=1-q#1WXU}piv-e`IRhkAkQ6pXCoOF3sR zN@f+@cqEhS`s>~q3QFHnUkOc`)KdJbrE7BA#%~|b-<4ne`WELmz9WYj7#J8lUHx3v IIVCg!0PmdKkpKVy literal 0 HcmV?d00001 diff --git a/public/funiture/선반-90.png b/public/funiture/선반-90.png new file mode 100644 index 0000000000000000000000000000000000000000..f8267412b845f78005232e7296c367610a1d2be7 GIT binary patch literal 734 zcmeAS@N?(olHy`uVBq!ia0y~yV9;VANTSHCH!EZANN=-R{e2T3I%3v8?Qe1rB;%-x6ILD16W+|Ned!h8UOX%-j3QMeJkZ z+uyZli*&}Fm&trmblG?}9EmIfCG`SR9I+`G{Y*XZ>r)xYadRtzw=p8)x zljpqrpCjG+-!GY+TEjEd;AhCMTzk7adk!9#|6XigFnP~qGoH(D`^*nbK7O8o!EwD} zuVd=lis#bj)@`30egBi$1*e`ulcN@1-(oqccr)EP-&oA8{j1koxJUUNck&dL;}=72 zY@gn$kR3d&qw7bF*h7J7yvzza8DcL*uWwR5Y{FCWMD26buDfgWTau$#?}u8vFrAWY zsDE>fK1-r<;33x?8+WTtJDqt-2TF(m8Fr;t8>{kKP?z8xk-zGb~Va)w#o~Ulpkfv#+;PIsap-kDBx; zqZL;Bi~@xnpEu>7PKh%mssh(OHKLBxd+pKFJIDga?dnA#z|+c2d!E4K)m$V zf6@cyW*$CodgCPn&xUi&gTe~DWM4fp;TGh literal 0 HcmV?d00001 diff --git a/public/funiture/소파-0.png b/public/funiture/소파-0.png new file mode 100644 index 0000000000000000000000000000000000000000..1d84855712b48c611ecc8d79bd91a5a2ebf1fd07 GIT binary patch literal 865 zcmeAS@N?(olHy`uVBq!ia0y~yU~pn!U@+!jV_;xV*1Ffjz`(#*9OUlAuCoC6NrwJLd+T(WvPPvc}mZ$|%@ zA}k((60v(yF9<1|bY9{7SNNQX=0EG8KWdRpuAhWYTA%vTVq`R1-%93wOsrqgwA|!b zyBO~Mc)wcyPmnt+r)N~>&6ti z$H|3z;-mv7RbAdZ(^M%4;_l>|Z)4-N`R>-ghLh*0ykYBZU(=<}z&DT!QR9V5xA*l+ts;at$mp7~WmBzT)n^Z8*n-@PPug_y)c8;f0N$xG~m#HkN)0~+?j)^_`mC*09 zV9_r9-y8;ax3|bGI=s^T2uFaX>xs0zRx=Ei|K>lo;-GSq%Bl%-RtR-;+`C{mC1EfB ztX*DejfY!%6j>$O-TL&_?EW6%e9X#yiGWeb`j}!4qs;doGy@&yzh5SF>56FWuV20H zN8derRrF}CdrOGHGQ%JzfuBB|Qv+MpCQRq@vO6oG=o=e3S!*%J*Y15zL2F%NTOaIt zU9_-&X^rGMZ|~U2tL|*x_l|KwJHr!^+WrL2V+UVXuDMoI#c`gq=$YcKs$E4UCMNoN zR$L-Zl2sln8qQX^J`uLNIxnbeLIC6Xx5@QuYUL9bizsenx;Jmn@A^mYzCGAgF85ID zh|3BALq@iy`cJ2CI#+1?`(2QqD^kh9aM7M=cFP1M)sT)^{d4BmFX`^iPG+uUW!QK& zW!H!5s%>Y?+FQ679_@amkYW~JGW)Am)rlJ&q70LA^{32pW@u}j!Z4v`e(D+l+4XOe zH!zq^WIE}}z^Ujwc?s`ih6%nb9v7Y4HgGcBbZxkCO^TNKh5sV8g1RK=TcD)W_a@eR;@@MDnx6{vGlJ@KVak-;c=Kr6c a?8hhf-U=_YY+_(wVDNPHb6Mw<&;$T~(Q^m@ literal 0 HcmV?d00001 diff --git a/public/funiture/소파-180.png b/public/funiture/소파-180.png new file mode 100644 index 0000000000000000000000000000000000000000..962f4a5d70a27f906e746ff195576e9ef775507e GIT binary patch literal 670 zcmeAS@N?(olHy`uVBq!ia0y~yU~pn!V9@4ZV_;x7vp%khfq{XsILO_JVcj{ImkbO{ z5uPrNAsLNNXW9B6a*#MaU#*Tcoz2{AUu&{^mV}g$eS@+5VP7$}<~}_&9}7X@UPZke zp5D&RO;Xo)eaP>7J+=Dn*6?~xPtAZ?@$;rXwp7}r;^$`BC;u)_`Sxqmqn$6=6(3Yp z9iDg2dD=qPIX#~&B%jLL??1#Yt*E$AMo?5R;KPqEuRd)1qbz!{l+j_v`I?Gul@1o2 zXO|PW(=?S_1Px~26&4Jf#b+(dXw%BUArbGJapAq}9!=kNm!1ii@6CLEd(HfgZ$bi$ zuYJRW4i@{INU~GqD)1G2>6P$ulE7nDQO}@fe;x_7Te2z~@nU(=B%rS3aKeeth3A*; zTPsB+7saLP9NAoh-8fvg{wUg4-SA(@B|`azNTZ0j)2%s1my$RcV}+EY)HKU`*Ku@C z4m=k#+rZ~i-u#7Jjv@s@A8gFy+*_2Ecro;UZCbU~DTKA5`J3k%nL{PI4}&-^WbWR@ zWZABs> z+xB^Mr_OHKvv!)xYSoFY4N6FG-!Zd}EXu&3{XW{d6gWzLD;8Fn!>d~I6wath-zriRUdv9V4mH@F!5(x*LfqmI76mBYL-`xL3?-xvIvrL!@Yp$EgXuOxfa$uX-jVuQ=zlx07LC%L~5dz1<9@ zoQ8AT%+;872qo>7y|9BLLF>rXI*)7@hO3MdU-K@!*2%C&z=i#{@tv=A?%HMvsXu;{ bzn7oAlEHn?4$giC1_lOCS3j3^P6%jdlDfX@!=%@R?(f&Rzwc95eCXBi?cv{j+3R^G7(~Whsj-{y%NA>w{UWuWX~I+2 z+UR?ov$HA$CW%DGU6J2kf69HD_|_RB!IOMj_!L#cI4vy%kA!(_`nu$WVAQ%}(|b31 zojRz-#4v3&SH?2t@Wau>s*w}R`qWma-3tUY@v#A6lHiGtdMwcRpSiYvJyd`=&`QT1y| z>kAgW@X#wvPg3&PcGumEEs*r7i=W*Q!7}04F&+I^-LIt&7@SkuBrvz*2FrxCmmZwE zeZ`!GQN(HCw(!PUZiVR109P44&dBxLQt|;Bj7zpg?ovtFxyp?FN?F4~R)NQnheJ*j zg+BcJy4v{J(LNs?rjD;)Ufy!{$YJXEoVsJ}gb3CN=Uxk1DJE4aBrz^gY+8Q1z+&U1 z)*ngtnNEZ$wdlW5Vd)CFabY^cBHi9~PBAZ|88Y7r)^c*HHb|JCuI1#^ZHPE8wXfxf zQiFy6{B=%A!VXsR-DhxhhBACCYjzi7dU#$UU*G>CmqPXJowfH3KAp91TOqrgUusbZ Q0|Nttr>mdKI;Vst0MPsw8vpJP)H(3 z^Mq^I(y*zaNofTtL677gPUe|>zjadGv#g$8YlKThj=pTE9( z=driXrB?6?eJK6aYd_c5%j~h44wF=juioTSFML{7H@vs6d*3IYx9ns}inyRs!(=Zv zGr^5a7Mdw4;tMq!7V9d{>J!pYvni{aAu=*~g^T6|ODbEIQ$$ z=ls~Sg!=mV%8S;kO?POS@S>w2mWkoRk1sv@_t!m2C=jyFVK%D^XkqF8Ab3FeK|(>3 z?^Zu0W-mP~78P&}t(!ctIlqM?04nBXlYi=8P496Lf?ycnf&R+;wo^X;`y z7rrFm$YfXhFKG2|wOv9C4C3PQ3}KUMA3D9uUteu1c=%$)Y2^cE9-K@`3*FxTdCbDF z%~~*sDbC7KZ?OxbiF?PnQu))Nt;d%?zg4{Uw73U;vd(|;R^?}ln zZ;Ri#>`6I)?9++XG(L{b2~jR3s>#7?uTD~E=iYd7f%ngI)7V@YM6?@u=LJ~S*embm zxGdD+S(JNWr>6c5tD8|jL*}z4$Man{@k>Z^L9d+)|HhcmgLh|j-buX>&Tw&h@04rn zC-36%a$*&@CZnkS*z29q3#|tAbYZ`_s>jV1eB0WP$yamo7uN!oOyNuAZ)J0wg18LM zDuy&Xd6*Ve{U+9K>4fk3@xSLOaR^=5_A^^J;P<2Mot4RLitZS7npB%;EGy1 zmqOI!)Q$-^->fscSZlPai=nglYJsSd>V;gppZ^3H_po0Oa;TVo{(`FW5tjq=kIF=x zX=JI_al&+{Md=&bz)${Oyyt^aGP*!@Mq0r_?7F z|15=%drSy-n_j)|iQXwgfg5l8ZB^GzZfZ^9>UvPKH?Zcxfd_WC8SjagaJn8?Z6tAg zb&-nxl%A;8nJ3!kO=hlDXHDL?N%O<1BU;^@2mXF!X|QopZC>sDTtHL8E@PHNGub1D=h=sXT@lSS>2Yaho}orB!byU+UGcJq$4n^_i=^GJi5KFfe$!`njxgN@xNA Dbia}2 literal 0 HcmV?d00001 diff --git a/public/funiture/쓰레기통열림.png b/public/funiture/쓰레기통열림.png new file mode 100644 index 0000000000000000000000000000000000000000..a9613b0c888ae55dafc5d8d487bda1788c0a86cf GIT binary patch literal 595 zcmeAS@N?(olHy`uVBq!ia0y~yV322EU{K;=$vdM7d^$aPd2B~J9odD|)zv5S8Cf_g=BOMK z^pNlnE|}if(sDxWWlBon+UCDstM+Ce3~;L9mQ+y0tl5+u1&LP;&~X^^78 z;pd-EY@PEi<+E$$${q`^bHW#9^sq2&`k9o#yn3BzALD~xUoLHZDPCUvn^EXmQCTnN z)1ZUjYmD9}WtBeJCNC>@AS2XpX8F?4%@aaT+AXg+nGz+j`p{htwFh(J?=mpN#qB=v zth<7VVfEwL2dob#8~vY|y=9f$%B$-*`(qx=Ib7P$wCs{`Kp&#S-f-Ce%#bo$}2bN_1I|9rTaSK-XZqlX{; zc$BDUyUt|h6$g#mS>+5256-`z&%jVnyHB}{$8(Rrm-sbLQ-X>3hS{9~bXkzjSW;@7ULkzqX%O_3vnQlv830xOpe7@}OPg#TQ3* z%-wa&*Y^Tzp75HtueN=ffA5d@vL_1{Db3ibM~-Lo|>x{8@GIwQqF{|rmeBD{0Y;<-!NRcIqi@+!)wW;>{_nRL5JNNtLu?mAm{`bF9UM^SRUDn>aZ)tzd{t8vY9#NT0ozkLZ*Hsy! zGyl$O*>h~;pIYW0UmsgL92Q-$h+BE<*<4xX8BbX?dp69DS~dMZMoE%tk45?CD@+Hv zHXZ$aKjIC;jzb+cZg(zYE)aD)a^Jg^??AW&=QpV<- zfHp&x?4=*y)qPn5wtwjQ_B3z>Lr(ati~B7nb2aSV(x=zAgYCgD9Yx8)BW4Wu+tp8Y oDu*x#`FCGydiU<2l8f>m_MJ-W>$j&){=)#u60UwO>zopr06t8Ju>b%7 literal 0 HcmV?d00001 diff --git a/public/funiture/어항-180.png b/public/funiture/어항-180.png new file mode 100644 index 0000000000000000000000000000000000000000..a4419534795f73143a39c05fc73b4db3f72deab6 GIT binary patch literal 842 zcmeAS@N?(olHy`uVBq!ia0y~yV6bCgU@+idV_;zLJbluHfq{XsILO_JVcj{ImkbQd zES@foAsLNVqxO5tI7-x3b8K6->-5bNp_+OTN3>>WwU|tL^^fWGfzAbUr-qsdh^%3L z8Qkg16vn2#zKDDEQ8(L@@d_+@tu@n2%hH}-H#eRgez*VT%*yxGpU;}_<~B%9{-3sO zW4-a?chjXM!`)rB-G0r?@NW5bhK9*|Q@0sfJhauExS?Yk*8>|#*+juy;R97d+ge<= zxH3FGSj*Y%q1#XvkatKah;xCAj!B~6M%ER)LbEq?L@=eSnBeMkNXd!&!b1MLar0Zh zT>U5!emwboU7h{!mb~@yYlLDRyz)@-z8_y>vE|+BiXYd-S({H3TOOFbLvPnMrV!8Y zgSrQ5dT-ylbM>4Wlfci1i*~Ke=i8umZev}dLPKB8M}uPpA1_2U?7SL2ec!{Y1p(O~ zzFwDZm}sytlXJxzz2?_KFGBYFbZxqpQ2du?_UrUx!W&&L>s?4G^_aBatX_b3-sDA7 z-0Vy`{>=aW?D~lo!%_$LZ;N$4RoWTNpU5s&_u}>p9oD(bOO9L=`ec?J%Nocaq$s^+ zjcs7^>(9s4#ZTqz-~Ib;wUh6PTYpdAcHgLZQb=RslMBwKHzKkxjzxm2MZv$3) ztJeA5XEYNO7d*}AYoMrqd)mQ5o!*BGk|paLm@^q(Ot~%|Sy}tAc1M0QbB@9GDrpvu zy`fNNQM^&(vYW+J2D>M#mxP9{*tj*A zaNd1}$kLCNhb?C=J;P{pLoMXJ&b%mw>#{9xKlVc4adEn%x3^)5?4Q$bxsLQ E0CRVH+yDRo literal 0 HcmV?d00001 diff --git a/public/funiture/어항-270.png b/public/funiture/어항-270.png new file mode 100644 index 0000000000000000000000000000000000000000..2985ca21ed559d40f0fd2f9fe9d136f9dbddea88 GIT binary patch literal 819 zcmeAS@N?(olHy`uVBq!ia0y~yV6bCgU@+idV_;zLJbluHfq{XsILO_JVcj{ImkbO{ zZ#`WcLoynl&fK3bY$(xoe}UJTxlyNtQj;{eU3fHnVzncbHX4?=ajkNhIm1eD371OR zza~G??B$F63{0JucP-t2B>sXX&)MqIIh+69{P(Vi>-?luJ3s%d+<)F~HLKLQXYYPi z6o0Q-W1Ht)?Vzi%d($p9h8_EMF);l3@gpGhT(9u<*4!;j89fyU=1bp2M}u3pns1*gTUY-3=Zn-I z6OxtU9#1lTbNJ4kq*c1PKQA{k`td(`$i{DAshjI`b5pY?SGlPE@vDs2!^QtykIZZJ z&B^3#bFME~$TuVMag?Z9YVO?Vw-Orpn~)MAJ0<@$`(n_4$*HD zb>u#uGvQF;Pf&o>q)$P@<<3lS@Yq?&Qr}rE4^l%>6ko)A#Q&q6GO$WdozFSeRj~f%X@~!(M}Tvuh};k zCu~x9C8Xw9_=qdP;?3jlFV0lPd+f_gRI)oz(8SZ=?mvIa``?wElV8}I-SfQ>pD7-H zufBWV@}7`HrMyz{_jdb5W3P)OPSW*@VKnibA<~@5ejy~$$aqBJUR?Oj@;8KZ33#jR zOUcYF9sUeoM4T>HvGDFYzrpX8=ud?sYI0SVA03OH6MSRKt-tS1ti0Fry; z@`}BDS&&d#yWe`#KKCQL9>s{xv*F2j{qy9hNBn$s!h(U*zV;kH_;I~M6zr5A}$B*cUnDS(*&b`6{i2J z^r+P0WO&q?K4~LeCYI3;rA)|YbwzYG7{8@L0GrV-_ha}l`%)fJ@6wS@& zen{fo#ymSDBHYuXIP7+aghco4En8Mjc$E=VYBYJHC|gva^utZxras;G*Kqo(L!lBH zM{DoZoOs~+_&NL6^PAT!U$bkyfySO`e(lWZnOiERnzZC<*H@PwRGNGKSZ|G4oLppV zs(pQB@Q*h|lcvY*-g{N;Qb=^$>O((s_WU}2tZen>PxDVqUw>aS-%T|%NzGwj*J)3` zX3CP!BC={VI^1dp#?FFKZLv_G}zZ` zH(WU6VC}hvA&Hau@ku9EfoO>c|5Kt4v4;*+7F}m}CB}BI+kjU=H1WVTiBN`>oa~G5 gzB&_Z$sO{MKVxUBuy*67e+;0k;OghH&MBb@0ID^DDF6Tf literal 0 HcmV?d00001 diff --git a/public/funiture/음료 냉장고-0.png b/public/funiture/음료 냉장고-0.png new file mode 100644 index 0000000000000000000000000000000000000000..1e338da2d7536f1ec403b2a31e6490c1065441f8 GIT binary patch literal 722 zcmeAS@N?(olHy`uVBq!ia0y~yV9;V+dQ z{pdwVi8f>5C7yqd;_T5fXC6&5+*UYoQLGN{!UU@s-_KN+zke6|>E2!5zj_Zp{d;ow z{kzz{@`gt$k4}~;&C6Lm&wNRilHj_3dsjU%|25BLTh-sDYPmbFU%nN6Q#E1L>+`x1 zyEgZ+GKnV~_uJJ_Gv~k|xAhG*E(w>X*D?P5kWeKcq9D)2$0~Z_dE0%4n}6C@@iDJG zlk#`6#gUMUr+myNBs^SsUc4y6Qmbn9Mo#A5-rkv3Yh*;38O=hkaCQ5&dUJn{o1t^q zL0Ve6fme*di~IAwhU$H`Pq{76=)PNZ^~Mnv)lgq)&6S+b#L8_MjW4WycGZ~cpdDxb zu32x2Kf5gLw=X>8(x`*nXA8TRa`mu4`qyy9e2nyX5;Fj&`n_kt2Yj?Qoy)0ZE z_n9@PY0@gD19zWTTsX*d;CD}B`$qOHdjqwr7mI{m;kqp=_nI}L;??!*X1e|E@p1ZV z&bSE9#(Z#6sQ)Uh z-#QDlwn+>8bmE)rtUJfnl!1Z$s`IUHHkLgv7PFkwU)Q!*s%{yphq=NJHbINrB?a0w zlfy#HLv}1xX1;eX@~Goif#tpP85&;R6k>SrXVF?7ty?P61yUDy>wZ6zy|B`Nkbt8q4bt8q4kN?Q;lwB^Q9!|%D5|INB7x#<1L%Fp*cAD%sP`x`!mh}N3YxD(;e7kysl zqWb6{ue^TiANMyZj;qDREs6@RJniL@vGTa`kDFWOM&vO@M~;Me^~i>hx&wDkE@4<% zaX@admV*}89*GxB9!DF3cQFO6UBSk&sy-m_)*<5s#xD|+FPr*lD?jXDGRkDW_3p}t zlCSSJ&QFxRz_{h^O4d)oY_~SNDmb?Iz`xFBvo%#qb{uh7o5nqP`tB1SFFCGF^Y)O- z{r%#@VcW2}S1UCXCYvodZCi5o&W~SL<8Mr9zNGe__vN%rw!7{)pSEOwI_LB1`bVNm zeedy4(A?Oi_x-|}iH_G!v~*363%vE@Fq4sLP~hf}#>iDiPASTo=qXGt^I!Jyff$3q z-ui-@Q@>xFU$UqC!N#RGx%aL5{`>Vsty7ll=E=-=@9j*V_i^b;N7fJDF18+bvwvAJ zEza!2>o2^K%FQ=m}^Q zc<=7b=g+uT?XSM+u=MX&-l=c64IfS_J^F+5^o;d8csLKvVPIfj@O1TaS?83{1OSF7PL zE#Fw1ShjH7=;)1D)2rSy`R2}fH&0(zX*>Jo|65DzcYb0WK9@4nJ(tftDz#rQJAun~ zZX7eir7s)|AAWo|vdh1W(Q*gRc8=8!mon2U52o&Lxxo^1sCxp3P3o+3)hTXM{4Ou% z=~L1X{b2j6a>thNm!biRXJU?Zv4l7LezE#O=#ojI6E21>5o2ocO3_U@pmfVUZQG2; zNj6dcWK{x8HXp6|`7g=F=~8C;RzZQo-4)zZF4`$I|1w8DYai*vjQ0< zv*fQhI$XE=Ah;sv_X0jqgTPG2(BB<@`QsaB@BV!tqb4H$%t`atDup**yK$yjRdPQ5 z+mJa`aaxD(Z-;Qk;DV;6wTGb!?mU9{oD7t@$7#S(`>nqx~MC$n+LvH)sppW6R%%L zQsmPO*Gp$vxq69z;PsoLCLlFOBc?&yB5(#nc|j8IrS&Rz&WIIH`_TL^;AbUs{dLFN z_R$OtC*P`6q_enxIQ_Vo;lS}<%j+ut{(URUP*7CBWYE#lw*0}A?Bw#`op(=J9#9k# zisQ?BG@l`|Zhu{6_m8%4R`JhIE{d2hlUwZlMnJIdL6My3t?Hz}mW;Ps7zopr0LbGv A7ytkO literal 0 HcmV?d00001 diff --git a/public/funiture/의자-180.png b/public/funiture/의자-180.png new file mode 100644 index 0000000000000000000000000000000000000000..ab21c1d3582bd68c6c52087782cedb45b7371a4d GIT binary patch literal 686 zcmeAS@N?(olHy`uVBq!ia0y~yU@&B0V9@4ZV_;yocroE20|NtNage(c!@6@aFBuq^ zGCW-zLoyn#&fM>HI6$PmUT|sm4h~t7t{+Tuqpfy!Ta~8p zev0R687DT$a_K!%;wwF=xqbWhdcR9Q92K`*{AYLGR{f)zliTwpdlx)TndA1u-Q@(! zqlz6hn{U5eJ2xVAWA4@2iv%vd{U)|zMNg0uYiDSn+N9}|w6{E2rSLe0bEa0P+szw_ zIh%|QPMu&F>LzplQ@YFsPk~K5BA5HBIXCI5%la=CXE<>5=F3~@G2R7BpWIQ(|NQb( z#V4!4t_H`pT1&x24ZV9NubbfYg2m^mz{j;`#AH<-n*`l8&TX=gQ|fDfF=vbTr!=81prj>Rlfxt%soS4`&8f8MxqV`+y`%i8pq>O-n~x*qsy z%S+C7$*55JZnmhq$4@E0qA54|x=QiiitR;u;T27_()U!H3tH8=s;4cOB>0fSU*w;I y%e03){v!KsFXTG?cf!;7{F_tyUOy|~sNcSSWrRfdrE3ff3=E#GelF{r5}E+089jdh literal 0 HcmV?d00001 diff --git a/public/funiture/의자-270.png b/public/funiture/의자-270.png new file mode 100644 index 0000000000000000000000000000000000000000..96ca92eb42e15816a14a62a40082425a2a4128cd GIT binary patch literal 630 zcmeAS@N?(olHy`uVBq!ia0y~yU@&B0V9@4ZV_;yocroE20|NtNage(c!@6@aFBuq^ z3_M*NLoyog&e-e6Ik}xT-(l^-P3j&STaVnD@qyt3iFCke0PJft_ztwEzG4Hc5`3eQ{CvA@{f?Q>H$c zB+uuT$QE6<=t8o+bv~DPEVtKq{zFXmG;d_F+c3aOpcVYiNr}eil#vOKzIZ~ih1gr);K#}(5`%6 z4?~BbYtC$e&kPKvdOsJ1InHBeuy8oqcCD4UuUlj**Ga9we|N19nH)%$n-toVGIc@e zwV-`*EAz{YN}g|i!nwX9;P#1-c`L4X-8dzDPS9-i1atm%pIVAt*55vuV&&Z*`(dj3 zo^8S>Zy(!nYW2Mr3<{6_>{!?(xP8+3x$=B&4PWNY?fCF_=gTLbB^V6qq^w{3Z%Imt zdGbF09RtJrcKK5b9fG^RiZNWU$Yt;`{7^Aj`RCc!YzzVUa;a{AW^r1*{PsyKp~uj5 zn)yR_m%WuN3^&$pZ#}-^iL1P9X3LHklQ*tMUOY*iud~JeW`|+R+cPO=TRQh>y8VA6 ztn6NJN;ph?=aB#xj>FVdQ&MBb@0N&mtNdN!< literal 0 HcmV?d00001 diff --git a/public/funiture/의자-90.png b/public/funiture/의자-90.png new file mode 100644 index 0000000000000000000000000000000000000000..41b8d57b7366fa20aa80ec87575fe97a4bae1e5f GIT binary patch literal 760 zcmeAS@N?(olHy`uVBq!ia0y~yU@%}{U@+rgV_;xtjFy_kz`(#*9OUlAuM zZw2q`-dh*Ke!H~ocAWICte=75>@zin2W!gD{fmCUqtMlTgDdGy#FQN#n|Gd+QVMha zw{fl3yLMw2zvl^e?}_c)v%l}_UV|xB2iq!kUrk);l)-xG#$%;Rat4CeCq7$oD1tSn zvv|_sDLhjZHhpow6K6j0zV(gPmu1S~`B&O@ee(RaqvM*@1hqn?=IfRMp{%n%siiMo zbI5Szn@T6mhFl-DVzKqnF}L3NNv~Yd!0E-P)jFwk;*P^F{;zbnVp9;t9e>Z*+GR?ZnQfBa=cwrUmYX4Mr+z71m!;CQr$De%#edk6JC2&rsQ z@?-jOynMk3{#yvD6XbZKeDS*G zh}(XobF4km7~T1Ee=$SD;m6L?CekaoV8j!dht;F@<#&iCT2l{>BHX65W-_`$!X Xewx_Dq^N!d1_lOCS3j3^P6)iNP$4bhXfAK-IfCR$7GG3TbE?-ce8V_{$9N$ zL!;!S&@K_H5(5s&s1%)oI}5bVrf%??=C6KtZVB((%@LFQ-+$g+zdl}mfoF?=ll{xB zJyKR@iw{ckCjOC?WeC}RS}||Bb7pZa!bH7EV`Yj?u~Eu2S; z&G@XN^$umo{2FdYAVJ#&Fk@;6KVCr=;8XYMZlq0Exu!=_xcr=FK#!|cM9g%g$R zUH%_qVAx}R?eyXLy7t5R))mjrf4s7(ps@6)>_S_Hf~s7G2J=`(1{u}grW@D~{H$vh z4}A2wQry0Yfr0Dn!FNo{ueV*!K0JF}kc6z(ZPPok6Qn0T*|+K#d-t8Br2)|lH{&i} zyuf-$@7l(#4bn4yx*VD1l$f>W-{LQ`&i%hsIf+>^diLC9CagxvlbpU>_rB>d&wcX* z?${ZeuNz!83%G3$&eCBGV0TH0;dtO1-rDUeb|lZU@;T$1ursCstP|hX@->{BJ(neb zRWnmdn}NjTg+c_Oh>6IQi|=i?W0g>kZL{Z^G8tx;oqOukiKFZD?m%IfMO& zLKMT5PY29AavNS%HmsBk{p|3hz2LbTgYa|pCl5b2SeqM%zU>HMTse!KE88?`Z{|-p6B!itooVI7q^{Hl-x(Mf O7(8A5T-G@yGywo@@-%n= literal 0 HcmV?d00001 diff --git a/public/funiture/작은 선반-180.png b/public/funiture/작은 선반-180.png new file mode 100644 index 0000000000000000000000000000000000000000..b63b3ae875ff46b12a3099d682b42c4c77a62e9e GIT binary patch literal 558 zcmeAS@N?(olHy`uVBq!ia0y~yU{GLSV9@7aV_;xt(mA2Xz`(#*9OUlAuN%_v*TVy}YcPiTX{q)}Q_xHFJ6wSn9PoHr#N?}Y> zGvZmf^IA$=6sPGeU9qyGmwHw^Y3y{k_Wn}VmgqGX^K|P@9%k6}+~;Z2r_XEiexJVI zwzBEPpH;^g9(+oaWhl7#R*QY%1(xeyt$u&7v5|Y@ch{bqCv^f-enYn9n(`w7UmBw7 z85nNpPgg%58m=gKV5-+Rn9M$wT z_{v{By!J?=y?Us~`|9BDapuga0*ANCxW(VC{>|2~Q-MKX5C2Z#@;yQ>4$GP*@O*YK z)3T0fNNbI;tanW+Z`A1M_?N6@cjNi9MjgkT%f~NYH?tRf$+po~o?%1yA66Hmg<2fF zsuL4~v>HqlzUX8bXlXX6OxRG-ww1xf@sQ9-(TLV274{R1MGZ0w7#JMuJI*D9988I2 zJp;tpZUwG;}>jhU*vp&okR7- z14n_@Lmp?idS~&yTeEw!b^h+nR}7ZQ9XX zVKvRUlYI{_UFck2QYLl!gZ9C@LWNfw0~qRUg>q7awgyeTFx{&@%2mUAZHVeuBgegK z>;i5v{roMh7n3SKn>ChEu4V75pWoj6xOzDEL3o7cgGE7wRrbfH3!G_Fce>3juOm`3 zb6)8s>p#cN#d5LlP0xAy`GuIV*1vfsvy7kodnK_p&`*cq*(=5eg$4`^KKop72Z;~QE-l~OuWhjY!S{#*eGIG{k3JOfx88BtvtEPM-dB8Qf;&T*V?*%O zyGd-V-`F4Q`oDg+_QZp7jfdjIUi7?;jlA!<<(B&CX9wgM-4B)Szwwu0!o4`|H!dap zQ(2oD)(E_hkT3{$P&v})C$eEvL#BfCV*BHr4oVX^RG1P!tu|I-*de!unZeI2t}eWx zY3|FNw*Na~53p}@-kvP{pz)@JcmV5fmKRNq>4}o8Zv-<;bio{cZh{SO)FfcH9y85}Sb4q9e E0Ohvl^#A|> literal 0 HcmV?d00001 diff --git a/public/funiture/작은 선반-90.png b/public/funiture/작은 선반-90.png new file mode 100644 index 0000000000000000000000000000000000000000..5ecdfb28d78b2eae9d536243d25b0da5dbf0da3d GIT binary patch literal 601 zcmeAS@N?(olHy`uVBq!ia0y~yU{GLSV9@7aV_;xt(mA2Xz`(#*9OUlAut$2X}!7K;NtX)+Y8bz7|gH`ZZ<3| zP!RAjk?3u3Q+#wlcgm%2-@a{2>iJ*t()R85`QHjI#T;ll)^}pFj#&cpIaXGtm}^~` z%q%9aL=9ev#yswoW>Aj~{PttA`-bwOCtO>X9s0O%%cIIoLCoI90{eGHtYegA_0_MK zb>9DV!v45&T_@#GuLbE34Xt*^-`F=R=Z)WUPKJUDSvTJ^mpo(maN+H)Ly3<#PR(4z zTsL{2{rKI-``}IX%QWb9p={Si#|POTeps>VHTslnB<3M4wD&S2H*W{e*4fRicRwmOJ$Uv+DkrLlj`rlZTb)r9<#)<1vy z?10CyQ*05+HzVXbFI(7Vbt4Jr&I>EViz43jB}R3ucQ8v`tXr z6kK5-bE222u|zP-smrA6NTKGVdSi3*-R0Gjnz{cge0cZvY@M~S_3q~#QYTav%hWI5 zTzTb4+p@6B;?d!KV9XW1EuUYU zm~^k7bLaDzM-Mj{GACYeqL725C;jCn0lqKct^1cY zdMFgBY%ufl_OtO%|E%FXUG=?U$i3zw=E=YSrOz86a zU#phs38*wGe9hrnyDI3$uOG9S4_bfQmVKf7Z7xt2IlW z?0(}RaJ|K7`zo6kdtx&AE7sI1gbM%Kv*6_J*%}{qd)y9GoUf}q8&2PSWwV4{JD5ZXhOiI0N7P5;41i=W*V;_1H0K z`&a%`bI#wdpLsWLt|b4YKNmli-~Q*JzSQvBPlc1S8Lo0oW>GBD+xuf{*uov+{9Mj1 zTPh2pGVE)A_sy&Qm2f*(`114T`d2rWhTfj~*m2VQxiYuQ-aj{2e{=ood$&sADF@Q| z&Ht+{3I4vhbn|7UFAGA{?Wep8nY**SP*dabv1XO}uQ%fD^wuvvCTj6RRm7OHwDRxu lkl+b!20~o7*qMGY_r_>~qP+kB literal 0 HcmV?d00001 diff --git a/public/funiture/주황 침대-0.png b/public/funiture/주황 침대-0.png new file mode 100644 index 0000000000000000000000000000000000000000..94dd63ac8c2f4d1f94856fe219fcda90aa995f39 GIT binary patch literal 700 zcmeAS@N?(olHy`uVBq!ia0y~yV6bCgU@+ofV_;xlVa~8;U|?V@4sv&5Sa(k5B?ALf znWu|mNJit+S%%rS9VFWBi#W1fme|DdNtsKpwU@c)5e^V@>OwjOYYC*OIULI z_3=4xZ$1$%Z(g+LyrYXu?}P_mQk<6W5moCsQryDGApebzks;pCPBM0muU4lx=l@@y z#Tf*)&x_DIrxx`{kwe()ks_z?`L1>ipC?`IIzBFUJekD#NrRkFN<^9baxyZqqkANX#KI{JqS4CWj4-E+?Im(Wa^36lhE*SOF0xs8hlSfZg#P%QyJ*+1dY$z3S6_2E zgwNpGblXT;UAGW^-I zlU<^P^V{9iF*fRHTD1_lNOPgg&e IbxsLQ0E{gu#Q*>R literal 0 HcmV?d00001 diff --git a/public/funiture/주황 침대-180.png b/public/funiture/주황 침대-180.png new file mode 100644 index 0000000000000000000000000000000000000000..90c37a3b986e104852e572dddc9cff51a49a5175 GIT binary patch literal 618 zcmeAS@N?(olHy`uVBq!ia0y~yV6bCgV9?}XV_;y&6`GaKz`(#*9OUlAuHbpgVPKe6Avnh z3$bK1r8Mm~Fm7#-4>J?XK>i? zP50UyQT21Dg|{phy&`?q!F@(S)E?Wl472WU=;{9{d%0Cw#!zA^1H<3MqQpbXvlkoB z(=Fh0IMjal`iUx`M5jM1-`}~%$Y8ZxouQebIgoSr?f6HgJe_{l={r_Gg#WS~%tFjn_y#&sa8BxWSR(az}p5q}HU` zrA!(82CKysK0eu|#TeATW*z$po7KCe4%~3|ifK6bQI4TQvxocG{BvSVckG#Xn4f%$ zIA!bb?)>99OfBk{4w)(CuqizJFd^&*pU*_bkV7q7*Dn9hl#)2pAhGtmuAYNbNek!e zM@O6*HhaIl@2ljqK~Vh=qt?Q^!B;m|NF_GS-N6^x!ny6y!?S|7%*_?whYUUMz zbsGXzS`#E&L=;;S4mi{`DLTz^IKzKKYS@cxl6*X${*4}5KwZ147=L_wEdTTIA_leC2#H%uLO36AReXPN=k(z$1HTB3J9B%v6Si*Wo5&gv z8liD_S|9g=!;@7R=Gp!WexE72LG<5`-+Cc;`}?^P-d@&Z;aafn!_~u*etmpGtDRbM z8QRmoUpc%&d(WRT_J)7o*siQS(8IN0Yvq|gcfKev_{OyQ&hhe3WB9atYo(7_>Dlk% z+In}J*%KX?KAXm`Fniav7eTom%xm`5)V}`pqN_LR(6OxIfQ?f>U+kzBlB?9)vBUqN zQB!BTkeHM>zpjpnnq2pW2eEg;e!PBqR$2G`FF~;;|GXv+`&)O}`wux4d|cMrI_1tk ztMGykej162x={~*@F;BAU|@2k*gTwHK`e3M2m5uB4mpRMmd+Nxa+h24_S~aNIw7;J z#=1G=9BR7y`ixn_zi)0Ohn&`a-*Mh^gXSDDgEWRunp)Q$zU?hrKKrDLg!O+$*OpFO URVP121_lNOPgg&ebxsLQ0FJ#QNB{r; literal 0 HcmV?d00001 diff --git a/public/funiture/주황 침대-90.png b/public/funiture/주황 침대-90.png new file mode 100644 index 0000000000000000000000000000000000000000..026dff095e90a885fd365c4cc24019f7f73566de GIT binary patch literal 697 zcmeAS@N?(olHy`uVBq!ia0y~yV6bCgU@+ofV_;xlVa~8;U|?V@4sv&5Sa(k5B?ALf zv8Rh;NJit+S%%rS9VFV~MROP4&CaNETG+-X>(u6O;>Fb~-fX*1MBZK*%+Bw(M{Q}w zi%An!PS}|8_L>9t&D~2>lG)mM8V{7Nh*zAu_Qa)H^|@2hC;2^k@_cGa?JnT}VXM3I zCcSyznUX%~i0jg&j0dK_3lQRJpU;0|3B!XYKQ0)*y`A~d(ZFwwH`{|PKVDDwXJkl! zZr)ki$uLJu-Hd5PtHnf~1!0d|lp3PCgeNm(>H2+QyYTW`i1HE7D2C>QGf!6tWKFTS z$o$}wf`s#0A3;AigLMr{B-iD;q%SQk?=HXZ-}~FfdVgcoOt;q$Q=XiYT>hc`+kKbc z)7Sbk2yiO+J$;mU=*ipJ*$12$rgo-tzV=`b*i{jiD7%uuOW0iWnj!CjT|2gH@Vm&c zrt`MgwUg2fJ1TA-vO3MCpcc1v!?|0pYz?>_g(y&kMcIUj2PX?1uQn z!t1S@Pw2*#CvMtO5!~Xrtm1Nu=cYj^)oH{W++_M=q>9~mF4`YJO&O*ltprvc}J zj*`ejDevT-H};opYh+@4GF2~n#*%+KXR=Lj4LzhH^q&22hMi^LJ5Kh1tp*>rHtT7< z7hz#4aGjx38*pup&X4-#EDP>GUdK6kDff&$;SH+~E&0&zv74!5^`RvNCs)NYKFm4x z==pr_1Br=Af4+WlPFm;X{Au&sI)k2RkNXe1J1)N+Y_?B#`$2ntjsy33IWliDFfcH9 My85}Sb4q9e0DDzL*#H0l literal 0 HcmV?d00001 diff --git a/public/funiture/책장-0.png b/public/funiture/책장-0.png new file mode 100644 index 0000000000000000000000000000000000000000..6f318e4fbdc2efa7b8484afd02795f912032e39b GIT binary patch literal 1040 zcmeAS@N?(olHy`uVBq!ia0y~yU@&7~U~uAKV_;yA`KAhzU@Q)DcVbv~PUa;81M?|Q z7srr{#=E!eeS`yL*gw2i-J8Xv=%A#gHIdOnHo>6K(M&~+=NM;ckpj;;of$7&Os2TR zN_euX^9VO98gfl8QQ~t@3UrpzW=`1Pn7u)D<>A-WTi(B`dVg>ivUWWBTJ5N=xJ&V94{wn0&)nhA};qK+8nspqz z)+jqN->mIvn#Fn|!A9fVfnBE-Ye(iuEm*qbmDjXJwHJ=ZqYU3UTvxJK=dkcq-KGQv zi=%Evfhd4NHcr3rUOMd&a6NlO}6%ILY=WI$ySh%<8WK&~riPX$Bot#r9 zo}9(d(5Yx-V%%YNVcT(yF15*~hK3=>y0`D2c(RO%p-xtI;`!GsZT>7$q6-XXNv-SQ z4V!RY^x{;V%@5r_zc}Z;&Bw529iPsIr`xWl%wEu7QnOL_Pez0P{U0)3^R0DX_sdTz z{n^l1!(Y|*wf8({t~i^)eC?zKyf>`!roK01@QAbC^mgklmO}X@zZV^En!iwERzAPp zqVmFc?hAY_k0rmAW=2(5+;U~zAlR{tuPnfbagLYyE$@^DB?YO|E}tvEp7#IO#prs@ z*h=QD64Mu^MH_isSml_CbCLZc&(uOw)u>=4}6yl{-Ln9`?o`o z+>Gfz)OVj-klh&0aLr-j4PN_O1%e89-P8^lXVhg}X|q2+=bZZP$2%BPA6K%SZRkJw zYx3r(NRB5ZGFKg5N}BM$dHs_;CTn`0NBP&_Z%GH;f3WFVx%{2x(c89Xdc`W6cXu_I z6%PFT`m5~)!>@y!2RS%?7=7wmyW5tjMCa_IvwipErt~WJBpqnXu$q>}$nZT_H{!N# zhWw(q%NveMHU6-;TYiYitmW%^KgQ4n!X74$%&q4({=2Fv^QdVOGaKs_yNe}Vc?*PY zIG&ucZ{aH@t;V0a5epeJ8t0l`*vo#cj5{Zim2a0;fAgf1WqbCx+r5vo+fuVqY>Bp4 zRZi;V12-1@4T!j>9DaEA^Jd1CUXR|)H2=G|-L~K_i^cP>z1nqi9;oMNbTK^^I(>9; zWJBNd-#eIN-zOKca@}9>nC(kU28rJ@kf61|L~lD?)|bxCI$uu22WQ%mvv4FO#oEW B(J24` literal 0 HcmV?d00001 diff --git a/public/funiture/책장-180.png b/public/funiture/책장-180.png new file mode 100644 index 0000000000000000000000000000000000000000..b9345df59e308e8631965cedc9e906547f87ed67 GIT binary patch literal 515 zcmeAS@N?(olHy`uVBq!ia0y~yU@&7~U~uAKV_;yA`KAhzU@Q)DcVbv~PUa;81LIy# z7srr{#<#ca{SP@v9RDc4tgvyyjvX^CHa*ZzNd3sVTf#iPQK!#$mzAKk%Kp))cZcSfno1?rnAMm2{-FDTfa5mJ^AhPr0|*hCeAp{KI`21`S;_+7mArLKk3Tr zSYKUbUEgZ*;6>6hhrdf0*}FHeex1zphR;s?ioR*VL+LB}q8peR-u@CkV03~p=xQi&7WC*({TqK%buA`%N=~)_&oS7E8~0jQ9%qN)4Q1;r@ij2xc}#e WXS~Nv_w5V}3=E#GelF{r5}E*YhTIDP literal 0 HcmV?d00001 diff --git a/public/funiture/책장-270.png b/public/funiture/책장-270.png new file mode 100644 index 0000000000000000000000000000000000000000..f63d93ebecf143c515bfc8f8765beb5cf5685f1c GIT binary patch literal 503 zcmeAS@N?(olHy`uVBq!ia0y~yU@&7~U~uAKV_;yA`KAhzU@Q)DcVbv~PUa;81LHhD${iZ-tT;V-S$V^g3Ri@;q&el+gmW4 zz8w>}|7J|&e$U@_?&n{faG!Dg{K+Tw|GxY#?tA_>M&IbBiN>wt^8d8d8P_(dHr|-j zF!S)GrW=nMW*$s&%+a}E;bYp-+i)z+^{&FI7@0@U@3a3(T;}li9S1|esp^Jvf~UD$ zG}~vg+170ms9aNXB!_|bSl$P#%o)53A~_G|um-M7+}5m-cO#ZDu+u==;h|^a_J|q0 z3Nx1_l*_uXZurmYV!5r^Wt;wmj;Y&zR9SojACc7 zS@njIVcILXDqivL*)w)BE#k~g_MG}Dj$zj-nP2w#^KZv}E+~5V=IE$6NJH2{l zvqomb<_G$Rt}Hn)TVzdS!5T?Lv&0!|6}%+Uv{!t7uBh-kbPK1e!mdVFu5aI#^8fx| zy4$mfH85zy>E2|W@-k)xCy%?vNirRi=DpK5?_*>*Jva8i0mnR{j-XA=Y9b;r#r@sl zE}jPuEm-2xnCUp><7dOT1sWZv-pqTjBm3PZ1ubI<>!eig*0m<8w4wOS)VcK z2x3d-U%iPbkS{M^Ky0tVjE>9$(<8>T69c!hhXOFaKpOOili~;Vjo~1_lNOPgg&e IbxsLQ0GUyd9RL6T literal 0 HcmV?d00001 diff --git a/public/funiture/큰 식물.png b/public/funiture/큰 식물.png new file mode 100644 index 0000000000000000000000000000000000000000..1c445836e7fd26a2155e7e25433e733442652081 GIT binary patch literal 1138 zcmeAS@N?(olHy`uVBq!ia0y~yU@%}{V6fp}V_;xVIAHmQfq{XsILO_JVcj{ImkbOn zI-V|$AsLNtBdxQg9R=i`u5q7yNQFf?Albm9RXL(S(cP%Kob!Z-2LamxlT^;T~d)u}54 zpWF<(|M>qgwZ09H` zeU&Vh59ft_AI^PwFl(E}QQhO8|4x=Lo85MG?Xp|FA4DglpFZ0f;BhzqyY;VGU52ea z?{6q3Y3u(Ak>OJ5TatOln_J3`J&nO7<5IWf2ERAYZ*F?RIMt7<(?-Hc;Qq(2VXh2s zYgH68O!Zj8lk!Uc%$>Yl@yXpN%M0(yw^-Oos^&6%4VX7|{>6_O#xGS5nEzYaGS%bx z&9!Q-p6XdD$LuG3c=|uNsZe%7O6HD$PdEQ;Y87+Z^hYY9Y4;&!-WR)nSh6ly`YA_K zP1ZL+MZHp4WnJ~?Nil?gd&4Eyp`s7=16%c@jG!Yo%~a6{rhTxB0U$*i*skmc-4EAYizn3 zpWocFfK&Djqm9sw#3}yvHH*C4#F?m;6y39hSMb!?QHi_8TJ^sNF znXSlteAD^gi!SVR5I7WgntAWb8&R5@ex_=l&&!UyXQn0~c*B&P^MJ4Yq~+J1U(0-? zF|mu|UF(ZZ*WLF8Pv$@BR-J#?yW#DQr7u6%i|?`fG}G?FL21F|#a8y4&3-oOvAHmQ zd9CzbW_zo}p(?Xovvjxh8MwP1*e3ieV~Mn_?6RZl`zs6NL)b5_skQPceIZ-+{=x0v zwtH9p{9t_XeeGYn{#Oq*%9cl*|xgua2$l zPZC&tx3;$S^9Ko2{zah+oBg$GYngvPOXn|KU80;iMJP{7`tB9;#{W$Bqyobq>Cb=r z^zWNpqP41hRuXyvF(+<*J#Dh_q;n1<$G%Hujxry`Gm}@^uUlQWmw|zS!PC{xWt~$( F696zY66gQ` literal 0 HcmV?d00001 diff --git a/public/funiture/티비-0.png b/public/funiture/티비-0.png new file mode 100644 index 0000000000000000000000000000000000000000..797b7d03c8f8f4da7c2c9386fdcabe319a41e77e GIT binary patch literal 521 zcmeAS@N?(olHy`uVBq!ia0y~yV9;e?U@+idV_;xt4f=eafq{XsILO_JVcj{ImkbPy zhdo^!Loyn#&h+hTHsERHeI=!sZNi>#@$Q_Z>2D)4O-nc7;_c&1H(;|c%m3HwBs z=6-rAc$<^a#iS$SL3hZ`11=$R+b1uRVpx#7>*XYQk{f{rUSPf8V_Ce(zOa$CVo@^heF4;=n?IA2}JTjUR?L&RDhWrJwiI z-3!;AYq-z*)^EP@!<&A)ca@ocwRzuKw^x2mo9V-&bIX<*<{nvC>+c~HcQ>=QA=U9X zSAd61Md|Y7Tg>t;&(bEyJUuY~-ImoNKf2wHum9d`dOvX+>lW*kmOm8zyxDX-9ExRq zugdIc>ph$Ea@QHL_iS?n8!!Bsa!sVe@W-Srr{sb@%uOy=zY4p+ z*~`~{s^8)5dHqCpf%59~qC(F`d#{_K8(DtbO}*1Epsxh5x8_v6bw zE^SkWsiF5Q7?{1Xg+((0_##X*w0j#u+qX@Mxh=z%tI9Z^RrjY~AM^P-!FSRg@4U-y z`*GY`7IgB_rWbQA`iaEV_z7QpC1UaY|Kp|Nak>J67YoiMI(^7$w~l$S_S=$MTxCJG hJo6`QS@55+XUEqY&L?u_GcYhPc)I$ztaD0e0s!mN?==7b literal 0 HcmV?d00001 diff --git a/public/funiture/티비-180.png b/public/funiture/티비-180.png new file mode 100644 index 0000000000000000000000000000000000000000..f8844bb6a49693dceab09c701d61d90b82fe6802 GIT binary patch literal 416 zcmeAS@N?(olHy`uVBq!ia0y~yV9;e?U@+idV_;xt4f=eafq{XsILO_JVcj{ImkbPy zQJyZ2AsLNtZyM$`I|#HsRCJ$lY_99X(?8fWW1a_CHou?5y<|bDarl9&-2!pf&b&MG zPT^<1?gQbd1fC;jjE&EiENC;0aA~WXQ)|00Fy)V$lW+|3YST<^Ac-j5`c zSB>l47Eh}%Hpyx3~bpcUS#CTDxTH-x6y}rn=8;a+3c)O_UVeU+vM^65ih` z_~B@Cg@4W0`1x`_UbT03@I)s+Id5_8o!!iv!iKE>=cy?v?TV>i%jcZ3(PmoW$_|gB zsy+VmOQim<>VIrjy<{5if%xskSNGjL_vKo?nCa|KMdE9=JzKnzHE!$1`-k;BkzuD|EWAD|< bh4SCx)=o&78o!r;fq}u()z4*}Q$iB}wY$U9 literal 0 HcmV?d00001 diff --git a/public/funiture/티비-270.png b/public/funiture/티비-270.png new file mode 100644 index 0000000000000000000000000000000000000000..6198090777bd2dcae6a4c80a5a60cafc918fdec3 GIT binary patch literal 391 zcmeAS@N?(olHy`uVBq!ia0y~yV9;e?U@+idV_;xt4f=eafq{XsILO_JVcj{ImkbPy zj-D=#AsLNtZ|vqhWFXS|F#8Bg-irXPX^G5SS#3#dWr>VkTTJ2@S1Gsd$-N)0^6!%C zpOd>RG#br9dOkY4h^$e}W)j=!_I=|C)he#;n1$0foM5fe6`Q%Jc;g9{*e=&qFQRmU z9E%etYRr=EcDWD~ainY7F(t7rVq&2Sw{Bd)k=yDTnUSp%)%vZ;b#q33gh|)8E?4`E z_Yor7nBH#N%r@nVd-m4tY)_^HacvPb*>K`$MU33>i(jV%y=Xr9>s0l2bt{wg2Y*Ry znSIi8{(66A+6VrEmA7sEPJUs?eiI(61_66RiBDh-fgMwNxLXB+w;s{ zjakXcC+(B$Gu{Pn+!fo^WmsqSUC=}759^itwJVbP0l+XkK&I+o= literal 0 HcmV?d00001 diff --git a/public/funiture/티비-90.png b/public/funiture/티비-90.png new file mode 100644 index 0000000000000000000000000000000000000000..d5c096d35fd2d7921cf011e6ee34b6f2225f2f75 GIT binary patch literal 376 zcmeAS@N?(olHy`uVBq!ia0y~yV9;e?U@+idV_;xt4f=eafq{XsILO_JVcj{ImkbPy zMxHK?AsLNVZ{FrTWWeJPDE_fGz%AkbOo@d~It=mK7X^BM-jMvAFF-c(o$@Ocb*-ge z1O;dAQB-YQ+AaTd$COLY#cTH4y0|=f)p<*MzD@*JtKFd>juOpjF&DQ+6gh5B%GB5; z?(KXbD#FM$JNYC_ZmVkO7Sm}V7Zz{0(kj=xRBem+v{e`GMQAy>AG{P_Vxp6~XmL_z zYhT~eY`&=nBBFe)tfozCej9l}=&#AV?^X7$d-|i7HqL!sIqAg<8E?gl)p%`e7Ioba zv9Ml!_gB%Iv$dkCdA|iY_8$rouQHi7>q1<_DyQ!W{dp&5Ws5D0-dG)Qd3yIXsf_g( z)uM{ib-&fMUON)CCnUph|2_Y&D=TbcXPx?=IbSLJgZO#h^^GpVQtAv03=E#GelF{r G5}E*&1)mxK literal 0 HcmV?d00001 diff --git a/public/funiture/파란 침대-0.png b/public/funiture/파란 침대-0.png new file mode 100644 index 0000000000000000000000000000000000000000..4430734fd74e0885fda6d643201094d4f5f53655 GIT binary patch literal 680 zcmeAS@N?(olHy`uVBq!ia0y~yV6bCgU@+ofV_;xlVa~8;U|?V@4sv&5Sa(k5B?ALf zlBbJfNJit+S$n;h9Yx&iQx7V6$Q|%o^VX%NT_lrPGe;>ycZqGywshJbJ+0xR(ltGDM#rn(w3_nj&29BcJ}whlJcU$6)s#36sYInECRrZ)US{6d zYSLb?-)$AAwuavykIpJx+_NDk()HP)1LwY-d?jwmBFZp(M^MGVPgM$x5#7r< zPqzp;q!k7yO5SIb=)TQ$`UjUo^qlAjwf77`M`O89KM`#RvdK@Byw0f7^?kQ;{vo4v zOdcZqu2C)44BzI?&fdYKF~=fN+9C01=Nd+lxH$&A3W0yV)(SjfS>Bw-u>Z*8Q|eZG zoxkjEzS_p+7R7KX==~Qy#sym#|Li*Xi0?`aO|tSZN@ZZTUOScD-VPmXEa2t;H7BOLlN6tlrS` z`Lbj>gVm+XLoRv8gKjfiS`wro#u<+y+7Y#p>%`JJheG( z->nUnIUfj{@!{TMqg7lAIjx-UZS|DH5Bx~G_nALGxWREoLVfkj?)^t{mI`jNxM=lH o^6{ghFZwIYXBmFq{r-NC#hUddH{PW%FfcH9y85}Sb4q9e0J51R3IG5A literal 0 HcmV?d00001 diff --git a/public/funiture/파란 침대-180.png b/public/funiture/파란 침대-180.png new file mode 100644 index 0000000000000000000000000000000000000000..ad0a1e5b64ba7519aa3457015c153a2bf54b9d12 GIT binary patch literal 595 zcmeAS@N?(olHy`uVBq!ia0y~yV6bCgV9?}XV_;y&6`GaKz`(#*9OUlAu$`{jz6LH54OfrKh)(u~^w#FiG zed)%yZx^%5XY?6N-14L9CcDY|4L*Hu`(_?ubHBj1f5vQX;hO6Ik+BRDMf6?YwEV7q ztagaOpjz&&+}DF2*cd*1*)h|mgyB)}3$KVL4`ll_83JdhBx?GI-;?TKc5qJRPJ$NEhw$z%UK%BA#7P@s-ypW#j?AH1RNRGJuHuz)SC2X3DX6=1V3Gd z$EV(gFloeYS|-;}x^`X`!#O_9m8=i`luYwtm@4a?%)j8%=L#Rj4$+m{-CRyEctq%2 z+EboT!?r+3t!eALf7}P!k`K9jm=_($ka|H-t*KkMh+#YbFUA=h!WTP)lNhc$eGe%W z(^vL!QD4{GYaqCG#|@c<`*w0aT#~WXy62x<(NTF0#q1i1h40TZFfcH9y85}Sb4q9e E0HMYGFaQ7m literal 0 HcmV?d00001 diff --git a/public/funiture/파란 침대-270.png b/public/funiture/파란 침대-270.png new file mode 100644 index 0000000000000000000000000000000000000000..686012c8d58965d4de188ccdd25a2da957aff4c3 GIT binary patch literal 621 zcmeAS@N?(olHy`uVBq!ia0y~yV6bCgV9?}XV_;y&6`GaKz`(#*9OUlAuje{#+D1<~62+X;BaB1^{ zR}acHmQHa>FZ|4XW98*i4*SmZp7Ndh?SlUmp8Q-D_pXlffu?z2|HtEYVv8NZe=l6u zR^oW*dN}`Yb!CGLE{Eu*RU%x)I^moPBpQkfB@^YQGR)~*&GC5(=Yd^2{1WA6GOX#G zE%Ho|^T3WBzKQb|G4yoq7I}7(+hO+(|3fyXLpM$3mpVJ+Z2Dye2C1_%7#ZH4ez3Vf zY!d%O!FhYsO5!XUf(8FY)i0M$-@c=>N{6N4_qUAaN)s9U{AMV#G*qqSV)*grr)!zL zenYUhTA7$;662b{of1-&5wbGEYDMAc$C)y=UXH)LoWZ5F^HA!`jSPz{M9Xi!O>I5@ z{~qJTuRj(th+VdOpwY1D(DbXO&o*Z&gmz5eP1xzRr9Ck0$IG953QZb{DUM`l>@VUQ5;O7;edxSSL*7jbqE#I?K$~!l`SzJ%?e)ZTf`*r4|=YQ_e&cijewF?Y-p5B+@L zc0)*QPWNpwHK&wo*Jn007shZ3C*I>Zp2D!MwR7&ec{_K0W2g~SEBgP~iur@@41ryh zes^uBFS-*1l7KC4CU|?YIboFyt=akR{0MSwpNJit+S%%(+9VFWBdmMB;ax-+!U6()nvb)5rc5D$~SHI}EL8C;Y_bf8-JhbT2 zC58(V0+aeK4Vim*p6{0+Bl>p8X>9j-8G zC}dSR>-X6rXx=?Pznlthbu`y8*>iG=Uj^uK`PT_Tk*^`kt*Ogg7SU)=A zd2OZm555KUB?7neb>7YT@c4`7r8EYsokwHE{ggLXTx~sh=uxSl+R8_z!fKt5N(JM7 z?_u5``?|mLy0?Ud?d|46I@|08)-V1LUr{P5*Uxrv-@AvNOWr@LdQ@qBHu-7OmMsMp zu2c8(>Kjkr$CT_nLuGG>hVPwi1yZfu({+8ggv(uLm@qte`u5PR*t@2Qj~t_qOy@cf zns|w!;p^4R4^6$=;%bafChB?3II^x#?#CW`ex+=N$fez4YMtihml-Z;c+X&Y6kBgU zh;Z+c+=KAv$u%YyjF>o_O3a?e;} zd>}M&(rP`T8k7X`Pqzr_FEI{V(oqmv8^y j6t;VDPEB-Pv%cq|^}X(gVk{UK7#KWV{an^LB{Ts5QcEuB literal 0 HcmV?d00001 diff --git a/public/funiture/파란색 탁자.png b/public/funiture/파란색 탁자.png new file mode 100644 index 0000000000000000000000000000000000000000..de868218719d7a32b7179e57a0911a6bfa6f4f04 GIT binary patch literal 423 zcmeAS@N?(olHy`uVBq!ia0y~yV9;VAnXC#d%p@9c!wd|AzYtFKhX{BW$mJHOZQ$;BZe==qQg!KJv%51*4Ml(GFPL#*TlJ*nK4ta`59lrUS+g~?B=56R zAZ^*6P%V- zmnQeKI60_3yX)eiv0L<1UgU+-KSgpc%l>8j;5j?#cW-2>?#*SXwO_q6S~o>6d?UuZ hRQ!>=Aj1zv)81E`N_q{d7#J8BJYD@<);T3K0RZ6^xmN%H literal 0 HcmV?d00001 diff --git a/public/funiture/회색 탁자.png b/public/funiture/회색 탁자.png new file mode 100644 index 0000000000000000000000000000000000000000..bc1bb49562e144bf522df29e70655f5bac5a8b94 GIT binary patch literal 422 zcmeAS@N?(olHy`uVBq!ia0y~yV9;VWmBU@!kS)>pTh=CW^W+_fy>^|^hGR^mU-A1h}rH7od; zn6bdGx%ofa70!x2rkTq$85DR9C#Jd{lVCXT>NE56*MG&Oj(S$QwzF{uSjE3@xU=}G z>ydy2X@R4)QF>l^(}l%Omsa}k;r_#=p>}2MJgs_ee||~n9OhHsUoTx$I`g{t4c0AB zWBNB8h;8g$xGy0-Av+`5fcu8zEtWZ{lDsc?r@lIHOhPJPmSNihrmjaVeJsLSclFf2 zc`V>xab(Y|7s99Vnl?#`f2p{^-u-meKZYMmd^f-9b4)c2^?m&7_3gon4?p4=7#J8lUHx3vIVCg!09`b<)Bpeg literal 0 HcmV?d00001 diff --git a/public/funiture/흰 노트북1-0.png b/public/funiture/흰 노트북1-0.png new file mode 100644 index 0000000000000000000000000000000000000000..0fd0b4cf829aa7d988e65188dd00983e883a3e59 GIT binary patch literal 265 zcmeAS@N?(olHy`uVBq!ia0y~yV2}i14mJh`h9fUqlNlHo7>k44ofy`glX=O&z;M{p z#WBRA^XWBPz9t6&)(7ny&CKpK_szR@qar{$+1vlPZ3c(!%}+-Q$S1Y1$gwoW%K| zOM6D=lOuQTPvu@LHt*<~mTd;nHnHCq9)9t3bK%avkq6g^`6$XAvr&7p;`}1@>1!66 Vo_@Wfje&uI!PC{xWt~$(69Ca%ai;(P literal 0 HcmV?d00001 diff --git a/public/funiture/흰 노트북1-180.png b/public/funiture/흰 노트북1-180.png new file mode 100644 index 0000000000000000000000000000000000000000..588886af5e95add063a3965372cfce67e1643be0 GIT binary patch literal 235 zcmeAS@N?(olHy`uVBq!ia0y~yV31^BU=ZP8V_;yYD4({Mfq{XsILO_JVcj{ImkbOH zOFdm2Lo9leEB-#b{QtVV-QV;DiU}^;9YPxVCqA2Y;gHdsX#tmwnx+LDHd>^+@QBeQ zUB%-@YDGE&$m*;)+~z0(qs9z8BOVyr)_4rk^ts2=jz+9Sbjprd%*D28F$s}E|nSyS0u rM0ux(Em9FJ`?8`sKu1|sgK>98#rKuJe?MbjU|{fc^>bP0l+XkK8xvTD literal 0 HcmV?d00001 diff --git a/public/funiture/흰 노트북1-270.png b/public/funiture/흰 노트북1-270.png new file mode 100644 index 0000000000000000000000000000000000000000..62a1876dddafea0e8d80b1dbf9d7b8e80deb553e GIT binary patch literal 237 zcmeAS@N?(olHy`uVBq!ia0y~yV31^BU=ZP8V_;yYD4({Mfq{XsILO_JVcj{ImkbOH z%ROBjLo9l?PS)mYFc5HAF0X=Gc&=D;;_Fr+>**%tKiq}1 tHXrot4Ee^Mt~%!ot6tyv|H(y6;Y=xwZ+|Sj#K6G7;OXk;vd$@?2>>V&TT1`{ literal 0 HcmV?d00001 diff --git a/public/funiture/흰 노트북1-90.png b/public/funiture/흰 노트북1-90.png new file mode 100644 index 0000000000000000000000000000000000000000..eb057e71f3158b47ebad90c275a15c0f73c7daf0 GIT binary patch literal 265 zcmeAS@N?(olHy`uVBq!ia0y~yV31&7V36csV_;y=)_xbuz`(#*9OUlAu zF!6C@%#iH69_YT-tmmiT#0HT?haU1xB!~=u|k44ofy`glX=O&z;M{p z#WBRA^XWBPz9t6&)(7ny&CKpK_szR@qar{$+1vlPZ3c(!%}+-Q$S1Y1$gwoW%K| zOM6D=lOuQTPvu@LHt*<~mTd;nHnHCq9)9t3bK%avkq6g^`6$XAvr&7p;`}1@>1!66 Vo_@Wfje&uI!PC{xWt~$(69Ca%ai;(P literal 0 HcmV?d00001 diff --git a/public/funiture/흰 노트북2-180.png b/public/funiture/흰 노트북2-180.png new file mode 100644 index 0000000000000000000000000000000000000000..8e811a5693ddb3ed4af8f965cb3a171032955471 GIT binary patch literal 242 zcmeAS@N?(olHy`uVBq!ia0y~yV31^BU=ZP8V_;yYD4({Mfq{XsILO_JVcj{ImkbOH zYdl>XLo9mtUbW?GP~c$;_`q=NX!VYryQReWUnRxdyt$*D`y-Q8qKnp+KRXi%(3=E#GelF{r5}E)9tzS|A literal 0 HcmV?d00001 diff --git a/public/funiture/흰 노트북2-270.png b/public/funiture/흰 노트북2-270.png new file mode 100644 index 0000000000000000000000000000000000000000..6226b7bab6c1bacd497d66a3c66a0cfbb3a8751b GIT binary patch literal 240 zcmeAS@N?(olHy`uVBq!ia0y~yV31^BU=ZP8V_;yYD4({Mfq{XsILO_JVcj{ImkbOH zt2|vCLo9l?UbYr;Fc4{dD7@HA>i31E*QI(}vwFhTZ|*la#o(R5@ZU*V=OI>#7;;U5fkS6@M$r1YZAJ&>(5{K0AC) zHKXy0_5H==%7*seEy_>SZFnT+baZ+tGsBt7C!DUhWX*}bZr0=yv~gYTKI0#;N90Vx w?>Gf*+;7p@lC;jEyI_XDO@*EM{9D{+H`3Gg?m9V@fq{X+)78&qol`;+0I2<99RL6T literal 0 HcmV?d00001 diff --git a/public/funiture/흰 노트북2-90.png b/public/funiture/흰 노트북2-90.png new file mode 100644 index 0000000000000000000000000000000000000000..eb057e71f3158b47ebad90c275a15c0f73c7daf0 GIT binary patch literal 265 zcmeAS@N?(olHy`uVBq!ia0y~yV31&7V36csV_;y=)_xbuz`(#*9OUlAu zF!6C@%#iH69_YT-tmmiT#0HT?haU1xB!~=u|k44ofy`glX=O&z;M{p z#WBRA^XWBPz9t6&)(7ny&CKpK_szR@qar{$+1vlPZ3c(!%}+-Q$S1Y1$gwoW%K| zOM6D=lOuQTPvu@LHt*<~mTd;nHnHCq9)9t3bK%avkq6g^`6$XAvr&7p;`}1@>1!66 Vo_@Wfje&uI!PC{xWt~$(69Ca%ai;(P literal 0 HcmV?d00001 diff --git a/public/funiture/흰 노트북3-180.png b/public/funiture/흰 노트북3-180.png new file mode 100644 index 0000000000000000000000000000000000000000..3a69bf5409efd61b3e9e2ac988d25d7eef080f39 GIT binary patch literal 231 zcmeAS@N?(olHy`uVBq!ia0y~yV31^BU=ZP8V_;yYD4({Mfq{XsILO_JVcj{ImkbOH z3q4&NLo9leEB-#b{QtVV-QV;DiU}^;9YPxVCqA2Y;gHdsX#tmwnx+LDHd>^+@QBeQ zUB%-@YHVz5Y{ks#AYAlDiEF_Rmb8l}xqo+D@>!FAf=@38eqZ&VdarF{cX{Bknx#t-7Mc+i}Wgir69* k(XuZqssnVCRW%qNmS%l^e4e3(fq{X+)78&qol`;+0DYQQvj6}9 literal 0 HcmV?d00001 diff --git a/public/funiture/흰 노트북3-270.png b/public/funiture/흰 노트북3-270.png new file mode 100644 index 0000000000000000000000000000000000000000..9fdfe3e3dfe73566917e3966175a31f80f7542b8 GIT binary patch literal 233 zcmeAS@N?(olHy`uVBq!ia0y~yV31^BU=ZP8V_;yYD4({Mfq{XsILO_JVcj{ImkbOH zi#=T&Lo9l?UN#guq#)Ar@F=r^$OTa zB4NYhnhs8eGxI*~S$LwpMkA=3-*HKW_@QI71#)fPU-&e!?2BiVzoVjUp`fJT`RG5w z@<~Yzmp}R_KULZK;qONlhCAz~&;Rk-_A`fc{@o{QRx0hi@g!%b>c?GMe)s85h|AH9 qbKP){%jMfx*+&&t;ucLK6Ugz++|r literal 0 HcmV?d00001 diff --git a/public/funiture/흰 노트북3-90.png b/public/funiture/흰 노트북3-90.png new file mode 100644 index 0000000000000000000000000000000000000000..eb057e71f3158b47ebad90c275a15c0f73c7daf0 GIT binary patch literal 265 zcmeAS@N?(olHy`uVBq!ia0y~yV31&7V36csV_;y=)_xbuz`(#*9OUlAu zF!6C@%#iH69_YT-tmmiT#0HT?haU1xB!~=u|>$Gap*~>Sg}O$bkd(x(Rg;!D<@{xFOzfP}=c*NCX2y19A#;$q z_sIu_9y$?^gb%a`7>1k>_Exb&HmB{|+;h*1Ys%~N-qw39+WdL--*;Eb8E0IU z@0;|mU`oOK>85`lYFO^?nEbhWhV1W#*Dh8P>f4@GJ$Ssa z7(V=b%>Mj66MM+H{`>Jo$EUMK%e!nh{`HSNP9VwVMcT0t#&x%u{=Q_{eY^Tmu|CJf z&82J%75~1ZKCE0K~a8k78nZ2qD=7jVL8T5p${J!gi=}Xpy zVxBYgr(Fv$y>)U6pU+nH?HW$6C&YL$pTMhw@V$y z0+OqlcVAm$yybe$PEv*9MjfwFowd$3Og5b3{1z@N(^? ztl>4?pC{e^PF1GYs~fiX|KfU~fUi&9`MtRIon>8Y zE|*$*DAS-E$*@xm(i5T&NVNvp0u zUmmyV+@eXE|D>H68&+=!ouZuKWj*(PUCxUOo)OxodLJb1J9785GQ)#!zuc`bd{)X_h-H8U_g_434~GXKc9Ex%tn+EvYD*?9lH_6)m8KfipR^ES5qf!wXX zF9HO&aSC1QE!Id3cRWn^<gTe~DWM4f&pp$S literal 0 HcmV?d00001 diff --git a/public/funiture/흰색 선반-270.png b/public/funiture/흰색 선반-270.png new file mode 100644 index 0000000000000000000000000000000000000000..a8a219cbc25482d04b48db9f4fd2730bf9eae513 GIT binary patch literal 493 zcmeAS@N?(olHy`uVBq!ia0y~yV9;V`?tYtQW+p6QmGvm)k?;IO*x+wV^f?#` zCrQ<_m^&_5wesjjofiR{Ur#)uw?ywsZ&sF74&#G|@2;)B-{n02zMQ$8cjMfJg_E|u z_m@9s{)si(dFB3<%Z}arxN~+8x42E!BtDje);;_7Z`^6l;1H^sz-Sh>g?kGp<9a>4 zwHt31xwbcp_HnqV?_fB->UB`0*Y-nCA~Q6ebG`Eaar$|`@hbhhT2NNL=o>)h1Ez@mE zS9t7Q(pHopqcZ+kPgytZnGv?wHT3EIE)kRJQ=gpT{jJx#Ep5CJbAP+UEzO5H4gnUI zcW;yxeq2)$E&A==rG+g_x0y7r8P{lT(4O#UkI2KOoeo^pXH&Spu?FoCFIU?jGC}EE s+AC&H#f{$U13n({yw&#O`8)Ab&gYI+pJO`6z`(%Z>FVdQ&MBb@03o{ARR910 literal 0 HcmV?d00001 diff --git a/public/funiture/흰색 선반-90.png b/public/funiture/흰색 선반-90.png new file mode 100644 index 0000000000000000000000000000000000000000..40496d20966026183f571ac14afb8ade6a6d23dc GIT binary patch literal 718 zcmeAS@N?(olHy`uVBq!ia0y~yV9;V0OGC^JjrSlHIPP{zS)_q&CcKP#`1NUYJ_;h?aq zfqTcsU9Go&zmAxD>C~x3Op}*0Fzo(){35H2wui;dv{(J7ckJAK)v~8oHBp^f3M>ivNFf!9nYH5@Gn8C z`dsVQOz$9#R&JeZ8A3N-#_n*|lt~br-6qZ3{Nm#$FGK4o+%?tpYu6lADXLs5!7S~l zsrR7&z1Y$S!4=&f%WZ=W{7dNi@K)uJ#~)r^?IP2@TgNl| z$KA<`)33jI6QZ!BU|yPa@2N@Knpk|Qrj)Daod5plePE=pIm59)*J@3)Z-{yn=Kb*e@TN^#dcvV^vHHw~iW8sr iPYR!2`S|tdZ?hQ7 z8xQ@?8r$#PBes0{^)Qaet$%Z0@4d$Hf+toX=hUqp>D^~f>?qM?cyN6CgBLiDa4;~$n}0vl>2vJW4!$LIpY`{=m*}@)XcE{t@!|~@r(^r= z_UQCK{3P5}|Eyrc4h=gou894+F1cxNGfXX#W4Q2obIn(U^yfQ%{Naq3Q?uLuys)~Q zonhm%fYl3v&Mgi)xA;2$h1cwLCQ>t3xka>~REx<@bFGCpPX`&bUH2$oUiNOFp+chOB!rjy~UyX1UR*zLT|V zrDjJnG@QHm(C4?qv@=U%*M4AB1qgTe~DWM4fn=>Z^ literal 0 HcmV?d00001 diff --git a/public/funiture/흰색 작은 선반-180.png b/public/funiture/흰색 작은 선반-180.png new file mode 100644 index 0000000000000000000000000000000000000000..dd631af43b44455c795f6bb916b854b03287e0c7 GIT binary patch literal 548 zcmeAS@N?(olHy`uVBq!ia0y~yU{GLSV9@7aV_;xt(mA2Xz`(#*9OUlAu9jj*TPpf9CI9BUD4G0-uGqCmq{`2&OO}s^55R?eqc<*)dn5SlG^K?t~?_~4EaH=pLFs-Zl%UJ)OosnVh!GIY7qA6(t zrx=V7j|;*4++S1?OjX`c$XA z-NF6fuGUdrrI?;aOq?;ii`gc|_4ziI`1dhBcUa6KAlA~e_v6Cz-xqCGk&UbASsb`C zDD|n=)Y@AA8*e|eFaEv2BDvmazu2iQRv+Ux7M?7#eJReW$?DCw727g@u9OlrbHVLk~3PsJ@}SV(y$6c`p7 zUJPYtTCLNzqO)1KnN!c`wdI5IZSmFDt*czy_i@KO{AKg~qv$$@SsaQiwwH>ppFDcd zS-8mZ>72+(+bcI`SLCetV!gb5<&^sz@1}dK=HJ5f~H;XdZ^gnd(k?~hJR_^`6KodqV_T}Sgep=d^hVA>wz$ShWy8d3<>o= zGI*EsvWg-(~|9qOm6~QIYVa{&qU!QOOY03BXiw{&(uT5Z` z#`GhT<5keTt4s6zy)Hc8Usbz0a=oO5!iKH;czjfqobR3spUWa6FhOYFs{^*9E zoB}tt+lm(pb}+CXTgkcNfXz8ppM#2wyAnHgUoK?jUmHkm4AiSBQBrLziK^9JDa3A|IEJe?|bsB{FU6|QGb4ItYTnbVDNPHb6Mw<&;$Uc CEa)Eq literal 0 HcmV?d00001 diff --git a/public/funiture/흰색 작은 선반-90.png b/public/funiture/흰색 작은 선반-90.png new file mode 100644 index 0000000000000000000000000000000000000000..b210efc7b8d028051268d6aec4705fb290b4b3df GIT binary patch literal 607 zcmeAS@N?(olHy`uVBq!ia0y~yU{GLSV9@7aV_;xt(mA2Xz`(#*9OUlAu`Z{Z&$5qc5F4>k{6&!p=i!+L!6rHSnBBcGu`02h+)st*$D>WB5ev3$X^L^by z=5wr)Y|~Ab-eO?nUCY!Krud=uDcga_wHaT3Om^Q;wMm@g>as&0*X2K|yfmq?Q*uUq zmhZKOo@Jr_6*Es;THbh5!sNU{WhqOQb@Jl-Ep=bX&qZp>wv#4E0w#PjmdV+tj!rfgxhYu7h{I z9@nu9ZYJ^&rgnAefYJx9^_D{zf7pSW4XIH2)ik0~P`*GP@3(=bwMhVy@>7-Y1(q6H=TkXS6Lh=A15g zLGncU5`V;fuJ%kpJnwl>qA<8!LttavzVs zVqgq@yE7*I&GHEe*SBu7QefH~BzI^-LObP0l+XkKHh&E~ literal 0 HcmV?d00001 diff --git a/public/funiture/흰색 탁자.png b/public/funiture/흰색 탁자.png new file mode 100644 index 0000000000000000000000000000000000000000..9becce86612a2e0df09fd8feeee6bb21bdbeca3d GIT binary patch literal 428 zcmeAS@N?(olHy`uVBq!ia0y~yV9;V6kncwz-;B)QjvCjKF6je zZVTf*zTelS|Ft;3F#FQ}#q!JdPjR+nX^4?Hs@9WqSg!n>lt;ns_5L?g7!e zgv?q!r8cJ94Li6!1U3=Ol{(;(p zwa30{aNiNXA@z!@jJ2$-jCFIv-i5gbo;`G8wsKx-RS*}G;n4Z*v7Yy2g`Onk^N9iz zC%mspdw#jmR^iX!ymwq5j%pwHmuPeNr{Dd$dOQ9LH_YSbwk1lmy|4SS zUi|H!#Mg`r!JJ|O&Knvenplqr=qR)$FdDLOcPK_U@El}H;uw`05up+L^L+mPeA~u8 zPO;dZzo*yVsbZ29Mo$cTZdEb-{&Y5cxw!rR&+Gl|>%Tv)TUh^7i}l|4JV@yskBCP0MNlJvqe)o0O~-$6C6jnvUx9w`KY4sAx$r^jsa# z%PC&WA!e>Cx@!CWeSao>-v9sO(eC-J-QFzS-P5?P7C+A5J#V7@F`(eAiz1+HK3wP~%e>zcM-Q7=`56^Ak3;lRm|9#w_m_r7tG2U&j?(K8h z@y&5VS<=b?Tl2m{1fb30nsg4 zZ^9(@{d@S=V2#Ww!-u!z4{}|0UU4k1b>VlAk`J1R9og{9T-LU@bKBpaWk8Rhm%YIMWRonkgde8CD zv)XHB-tpPKo^|~WIpql9oip?93$Cu8G>tQS#e2Gw*(K%WJcP zQZE%ll24jnep}sB5c@3n5dR&Y`RDh2JJz4R!}9;7hw+I!XWGrLeS5dM?+oh|^>*HA zvtNF`dHvoU_PBnzb%$9`pZ)UlP4@b4C;u#Ndu?#A$oTHQt>0rW{nB=Ex6a=DI|1Z? zTfg-(|Ej)Sd~eSE+zoepw&$O%y`EF*yXZ#rjxuo_^UH6etM9Asz4Kb@o6x$uEUVjt zkDN3=`F6MdPo=Dha_fF1d=*%=@yYBb-|kM|v)+R@{LSQddCShH$IrEW>s?!9_f8b- zr113fzq_Us9AyE!Xm#wRXQ>-3U)VwQpRugP906>S_-y>Dgi z{MxPG_N>=<_)B8V%7s$VMltTYR_5Lp{=Lll(!NI~JcqmMi?UW6i)wp)!hGx7&F|+a zzyB?<#@+ero&R;`c1GsTul@G)t@YgKDX*N@%=3l{yvY_@^U!Ha+k)9wzuioa|Nnay z+v*+S>*nQ$-AT+wQ~tbNBaFwzvHI%tDi1sT;7uXGJyBIoR9YJXM1*Te|9$O z_nXc6dux}kudnzi?In2p;VzMtiH-YT6rVI-{dV*Decyijt-n9#{Pwz!KWpNiieA-) zikE@JYpOJ-)(As95kFV#cN|OhDgAeU)wX~l;rKo))8BD6FZV5pBsK&$$1)yJp*YyWh{= zWlODjzQ1D4beL+_rqC}H!oMs*DfRfipJKdW7ccw1@P4y8IbQbnvgj|%s{73s&fmPg zueS8f?EJ~!H|P~^{v`}jz5IFY>{_pivlsKj`fr-gtt}P$X380M$@j&feJ?@U=XRUl zmH&CP(|jQ)r0&$N?<<2k$^2*3;kY~}P;Bq~{q9~#{r1gJ=h&IwmEHuht8c@3-|wt9 z!jJE>s=oJpS+(=Gs~gv!6a2RGW#0VSZ%5x0U*6kPTl!{o@sZlRT_A%Df8Qwq1zQr5 zlDl@l-*n%6zHE2xx0~Xd&-;EC{Jjk1lg~R}=Ea--es#lq?(BEU-+Vzy|5@?nzswNn z{^HAb_nicJLDu~4+u27zaZ`91Y}g%yVdcNyY}QYkKl>dh)X&h;;qU*uE6GnXW&ihu Qfq{X+)78&qol`;+08_=U@&Et; literal 0 HcmV?d00001 diff --git a/public/room/room_2.png b/public/room/room_2.png new file mode 100644 index 0000000000000000000000000000000000000000..8003a19b3aec00834609f5dea1d360e5de89adbd GIT binary patch literal 3958 zcmeAS@N?(olHy`uVBq!ia0y~yVAKI&4mJh`hRWK$QU(SF#^NA%Cx&(BWL`2bFtDUM z`Z_W&Z0zU$lgP@zz`$AH5n0T@z;_sg8IR|$NMT^$=k|1Q45^5Fd&h9K%JsR&9vXAC zh>P{uHwq`3-SyD8n{!Y;$=TiQpGw%HI~xzqyr`C>+&sf0C#_7_TFlt3BJnJ5m~_Uu zx34Z=UG8_)@@nk=ch5VQSO@G$-}USG9c$aK&sOW-ubrQ;A^y*&%Tt29_W!?o`_uG< z&HbnJ8Dyp=FeY+{3ABw;!#xO&(f`$d{tegi4*q(#{_B7Ci0thNj6G@*4iZhQ zEsCU4AHnG=zD{u4ucP|*y8EXDdAqd?@Kv5F(JLZEU!Jcw5{5bzGGX))TN)8*y6J}qGDyVEVN4LPqhXK`K)V)4?i|9Ah(OY#4cE*&da z%Nu%rpU)1t?gZNzAm>kza-Lv!Bq7Up$F-KMFCdxb1lx|K0ZTo3qLc%)7EjOuIcZgk z*4sV$!s~9ZXz!R7u$0ko#;VqbZ?Es4+Z(DI^0Mu>(Zep!4NY0RSLbCV{`hYHM{8+Y zz}uDE^o7?&uxQUvZdnz=efZ+{)I*}p39Nx_TK~^z*mJy>OI*>aaBH8>jy~TFeD9)G zcx~r#_U7;k{FZvC)TgO5VC_P!w~}qTiW;srr*mH4$Z_r9mbR>yg-4VRZ%}3F-=r_R zE?Hnra|Y+tOS9LQ)TaHKX0V3$pz{0EEwA^nygoP0$@JIEm%51&!cFP7r*mGf<+wIi zr&Wu$f~WB3GViGdXIvFRohHnyiC2r+t{P!}O=y+LgR^F#)sJgGAHBZWa7C-aP4$>^ zr3mv&QmbsQ-mA#B|KC4F%4x&$6Tgif-kP59_6^5Et*iGczUi)hd%7iCzG>@ZqmZBJ zM#}On+mGFut#kIznG^dev%jYvGM$#dTLy~AeIK9Q%XuojHs!u2C=QMZtZ|HT4!!lc zcl*=cuubppT&tZ~@Ktb)-`a&yRd?Q>Fuym=d{(V`^}Ew8+0qAZtxyf|HZOdsdcpYe z+t(l;Sv76F6tu$4e)68Dz1yGlrrlo(Qo6b~VXZ-LXr=OU#{QqW`L9AMAD`WOGU#dV zv^Unje_i~RdMH#qf_vIOm#JUxoeAD5{rl~~`7`tGZvsWcGtO(GYZu-MetXRBm+|Gd z(P1EKEDvhQD26P()th^N(}~|k4}Uc#)CPd!F!@RIvumsFp7^F)y`=W$=JwYwn@d67 zymv#_yy)b<$?o^2+h*^WXBGj9$japWofEyk&8@ytdvkN^>z7SerB*FH{<3e+#_e}b ze4A_YdEcAJLtjk}W^rD<xY9;TvxxSdG?xn&HDM^;VkLu zv%NX@Re!I0e_GBohF^Nk(txFm{TFrfXNBzC_&o1X$j*tAYt!SmzYofd|CaHvYDdD> z7A@W#k;}5o&zZB=tL>~kt@Qr1oN5fe@S1A@Z&#K*miasL$v55ZJ^I~p>pmQ`a$4cF zRr>h8)Nfx`zCFFDO*?19seq^RHG66%+?Faf_te@~>hUc$HGclq-$O}XD$TU+%lHSmzA8pPv|b9M%LeVe;^{oNC_H!mJ8{gJR0l#I73=g-kK z@2XY*_Vs3QeA$%(S+fY;g;7<4Uiyvme&)`vT^jxFbf4I|!>rkwA(zD!P4@|8K5MSc z&YNRu-c>vOP4{knYp)%A!XV$D?7sL*Y04>a)zG{-J7=qe=1obtfAjj=-6ECoW$z2_ z+D7Ou^kRjSzDX+dsB7m>%4>DOsjw9;{2bv_l!FWtKS0h zf9126yOyi3L#r>^sH^VpO|1o~PyLqd~s+Wqvj~y?cVSj#Yk3n`E$8Wux z>G5V&@!L#eW`m?nEOTBfdOuwl{IqpuwP4WGR*!FUYwzy!{`Pjeezf$us|T|Ekm^;ke_ouw0uB?U_ot^F+A6UIRA5=}0Qo&R ze*Ud}Z=!arV_VG=`fpyvljalGW>}|a{d+O{&F)kC?7n@C+^w&DxKt352p$x->+(l! zdVd5Y^YZ5Orv-c2R-1+_wdLRUbb;#cb2qP_+h?czJvHC%=ab2s?Gs~nU-;tLR($@B zWaS54_N@?oH;eCQZ+ND+rDWnI?*-{$x6|WK?tA>c`tx@Cn(A-rYhJnrJ#M|!-tD{A z^2e;^TjeM1es6NWUoN($X5+Q)DW{y5pE~%QZ>AQzFEkWv|Lwb;@HH+c>*a&u_O-Gm zH`DKPefw(pX{Z|W2zkS`fzQ6Wu8E@Fz1wX4FP2^XK zJ`M8H#{9jpO;?L9T+-#wi&EuG7ZP$UGcwON-@bkvq@r@>Z<%Xi+5A?uT@SZD zf7@g8<5um;#KpDWwr*N~z9jzZ z0pP^_?Zt1K%XjyseY-jRZub22UG)j^*5JTCy1hmDDqr3DbSM9tU33LkFz%S z{;k}X$9`XG>-{!&^ZS@1wP*iNE6951be+%2HYLj@W7f}vTXMg*<$0Umnr>xw>pSyr zJ=c5J|7>a7*}deHPayHWn-zWr~x;`!;j_`#82bJ*^D$^Axb?n_Fkc%N#ecpp6j%KAkaCV?#>suf-?naEe_rnQEc^DU^V275k=E>F5|`8#j7{@Ob~jK1e{E*JOU6+e0Ee9#(1S-JSVx3u}Q z+IcVY;*Ed5Ilb+T_gv}o|5~mVFT8Z=;B&s+&mFzN3CZ}KceB|)`_#Kj9Za^xSTZm$ OFnGH9xvX=0.25.0 +Pillow>=9.0.0 +python-multipart>=0.0.5 +aiofiles>=22.0.0 +jinja2>=3.0.0 +python-jose>=3.3.0 +passlib>=1.7.0 +uvicorn>=0.18.0 +aiosqlite>=0.17.0 +pytest>=7.0.0 +pytest-asyncio>=0.18.0 +aiosmtplib>=1.1.0