diff --git a/colyseus-server/test/MyRoom_test.ts b/colyseus-server/test/MyRoom_test.ts index 93af8d9..e1007ac 100644 --- a/colyseus-server/test/MyRoom_test.ts +++ b/colyseus-server/test/MyRoom_test.ts @@ -1,31 +1,57 @@ import assert from "assert"; +import { generateKeyPairSync } from "crypto"; +import jwt from "jsonwebtoken"; import { ColyseusTestServer, boot } from "@colyseus/testing"; -// import your "app.config.ts" file here. -import appConfig from "../src/app.config"; +import appConfig, { ServerGlobal } from "../src/app.config"; import { MyRoomState } from "../src/rooms/schema/MyRoomState"; describe("testing your Colyseus app", () => { let colyseus: ColyseusTestServer; - before(async () => colyseus = await boot(appConfig)); - after(async () => colyseus.shutdown()); + before(async () => { + const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + + ServerGlobal.publicKey = publicKey.export({ type: "pkcs1", format: "pem" }).toString(); + + const token = jwt.sign( + { nickname: "tester", role: "player" }, + privateKey.export({ type: "pkcs1", format: "pem" }).toString(), + { + algorithm: "RS256", + subject: "user-1", + expiresIn: "1h", + } + ); + + colyseus = await boot(appConfig); + + // keep token for test scope + (global as any).__TEST_TOKEN__ = token; + }); + + after(async () => { + delete (global as any).__TEST_TOKEN__; + await colyseus.shutdown(); + }); beforeEach(async () => await colyseus.cleanup()); it("connecting into a room", async () => { - // `room` is the server-side Room instance reference. const room = await colyseus.createRoom("my_room", {}); - // `client1` is the client-side `Room` instance reference (same as JavaScript SDK) - const client1 = await colyseus.connectTo(room); + const client1 = await colyseus.connectTo(room, { + token: (global as any).__TEST_TOKEN__, + }); - // make your assertions assert.strictEqual(client1.sessionId, room.clients[0].sessionId); - // wait for state sync await room.waitForNextPatch(); - assert.deepStrictEqual({ mySynchronizedProperty: "Hello world" }, client1.state.toJSON()); + const player = client1.state.players[client1.sessionId]; + assert.ok(player, "player should be added to room state"); + assert.strictEqual(player.x, 0); + assert.strictEqual(player.y, 0); + assert.strictEqual(player.speed, 200); }); }); diff --git a/fastapi-backend/app/main.py b/fastapi-backend/app/main.py index e1a5261..9a234b0 100644 --- a/fastapi-backend/app/main.py +++ b/fastapi-backend/app/main.py @@ -3,7 +3,7 @@ from starlette.middleware.sessions import SessionMiddleware from app.core.config import settings -from app.routers import auth, user, team +from app.routers import auth, user, team, room app = FastAPI(title=settings.PROJECT_NAME) @@ -26,6 +26,7 @@ # Router 등록 app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(user.router, prefix="/api", tags=["users"]) +app.include_router(room.router, prefix="/api", tags=["rooms"]) app.include_router(team.router, prefix="/api", tags=["team"]) diff --git a/fastapi-backend/app/routers/room.py b/fastapi-backend/app/routers/room.py new file mode 100644 index 0000000..00ad71e --- /dev/null +++ b/fastapi-backend/app/routers/room.py @@ -0,0 +1,234 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.db.database import get_db +from app.db.models import User +from app.dependencies.auth import get_current_user +from app.schemas.room_role import RoomRoleListResponse, RoomRoleResponse, RoomRoleUpdateRequest +from app.schemas.room_layout import ( + PatchObjectsRequest, + PatchObjectsResponse, + PatchPortalsRequest, + PatchPortalsResponse, + PatchTilesRequest, + PatchTilesResponse, + ReplaceObjectsRequest, + ReplaceObjectsResponse, + ReplacePortalsRequest, + ReplacePortalsResponse, + ReplaceTilesRequest, + ReplaceTilesResponse, + RoomLayoutResponse, +) +from app.schemas.room import RoomCreate, RoomListResponse, RoomResponse, RoomUpdate +from app.services.room_layout_service import RoomLayoutService +from app.services.room_role_service import RoomRoleService +from app.services.room_service import RoomService + +router = APIRouter(prefix="/rooms", tags=["rooms"]) + + +@router.get("", response_model=RoomListResponse) +# 룸 목록 조회 API +async def list_rooms( + mine_only: bool = False, + limit: int = 20, + offset: int = 0, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + if limit < 1 or limit > 100: + raise HTTPException(status_code=400, detail="limit must be between 1 and 100") + if offset < 0: + raise HTTPException(status_code=400, detail="offset must be 0 or greater") + + room_service = RoomService(db) + total, rooms = room_service.list_rooms( + user=current_user, + limit=limit, + offset=offset, + mine_only=mine_only, + ) + return RoomListResponse(total=total, limit=limit, offset=offset, rooms=rooms) + + +@router.post("", response_model=RoomResponse) +# 룸 생성 API +async def create_room( + room_data: RoomCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + room_service = RoomService(db) + room = room_service.create_room(current_user, room_data) + return room + + +@router.get("/{room_id}", response_model=RoomResponse) +# 룸 상세 조회 API +async def get_room( + room_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + room_service = RoomService(db) + room = room_service.get_room(room_id, current_user) + return room + + +@router.patch("/{room_id}", response_model=RoomResponse) +# 룸 수정 API +async def update_room( + room_id: int, + room_data: RoomUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + if not room_data.model_fields_set: + raise HTTPException(status_code=400, detail="No fields to update") + + room_service = RoomService(db) + room = room_service.update_room(room_id, current_user, room_data) + return room + + +@router.get("/{room_id}/layout", response_model=RoomLayoutResponse) +# 룸 레이아웃 조회 API +async def get_room_layout( + room_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + room_layout_service = RoomLayoutService(db) + return room_layout_service.get_layout(room_id, current_user) + + +@router.put("/{room_id}/tiles", response_model=ReplaceTilesResponse) +# 룸 타일 일괄 교체 API +async def replace_room_tiles( + room_id: int, + request_data: ReplaceTilesRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + room_layout_service = RoomLayoutService(db) + tiles = room_layout_service.replace_tiles(room_id, current_user, request_data) + return ReplaceTilesResponse(room_id=room_id, tiles=tiles) + + +@router.patch("/{room_id}/tiles", response_model=PatchTilesResponse) +# 룸 타일 부분 수정 API +async def patch_room_tiles( + room_id: int, + request_data: PatchTilesRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + room_layout_service = RoomLayoutService(db) + tiles = room_layout_service.patch_tiles(room_id, current_user, request_data) + return PatchTilesResponse(room_id=room_id, tiles=tiles) + + +@router.put("/{room_id}/objects", response_model=ReplaceObjectsResponse) +# 룸 오브젝트 일괄 교체 API +async def replace_room_objects( + room_id: int, + request_data: ReplaceObjectsRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + room_layout_service = RoomLayoutService(db) + objects = room_layout_service.replace_objects(room_id, current_user, request_data) + return ReplaceObjectsResponse(room_id=room_id, objects=objects) + + +@router.patch("/{room_id}/objects", response_model=PatchObjectsResponse) +# 룸 오브젝트 부분 수정 API +async def patch_room_objects( + room_id: int, + request_data: PatchObjectsRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + room_layout_service = RoomLayoutService(db) + objects = room_layout_service.patch_objects(room_id, current_user, request_data) + return PatchObjectsResponse(room_id=room_id, objects=objects) + + +@router.put("/{room_id}/portals", response_model=ReplacePortalsResponse) +# 룸 포탈 일괄 교체 API +async def replace_room_portals( + room_id: int, + request_data: ReplacePortalsRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + room_layout_service = RoomLayoutService(db) + portals = room_layout_service.replace_portals(room_id, current_user, request_data) + return ReplacePortalsResponse(room_id=room_id, portals=portals) + + +@router.patch("/{room_id}/portals", response_model=PatchPortalsResponse) +# 룸 포탈 부분 수정 API +async def patch_room_portals( + room_id: int, + request_data: PatchPortalsRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + room_layout_service = RoomLayoutService(db) + portals = room_layout_service.patch_portals(room_id, current_user, request_data) + return PatchPortalsResponse(room_id=room_id, portals=portals) + + +@router.get("/{room_id}/roles", response_model=RoomRoleListResponse) +# 룸 역할 목록 조회 API +async def list_room_roles( + room_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + room_role_service = RoomRoleService(db) + roles = room_role_service.list_roles(room_id, current_user) + return RoomRoleListResponse(room_id=room_id, roles=roles) + + +@router.get("/{room_id}/roles/me", response_model=RoomRoleResponse) +# 내 역할 조회 API +async def get_my_room_role( + room_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + room_role_service = RoomRoleService(db) + return room_role_service.get_role(room_id, current_user.id, current_user) + + +@router.patch("/{room_id}/roles/{target_user_id}", response_model=RoomRoleResponse) +# 룸 역할 수정 API +async def upsert_room_role( + room_id: int, + target_user_id: int, + request_data: RoomRoleUpdateRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + room_role_service = RoomRoleService(db) + return room_role_service.upsert_role( + room_id=room_id, + target_user_id=target_user_id, + role=request_data.role, + current_user=current_user, + ) + + +@router.get("/{room_id}/roles/{target_user_id}", response_model=RoomRoleResponse) +# 룸 특정 유저 역할 조회 API +async def get_room_role( + room_id: int, + target_user_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + room_role_service = RoomRoleService(db) + return room_role_service.get_role(room_id, target_user_id, current_user) diff --git a/fastapi-backend/app/schemas/room.py b/fastapi-backend/app/schemas/room.py new file mode 100644 index 0000000..183151f --- /dev/null +++ b/fastapi-backend/app/schemas/room.py @@ -0,0 +1,47 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + +from app.db.enums import RoomType, OwnerType + + +class RoomCreate(BaseModel): + room_type: RoomType + name: str = Field(min_length=1, max_length=50) + owner_type: OwnerType = OwnerType.USER + is_public: bool = False + password: Optional[str] = Field(default=None, min_length=1, max_length=255) + width: int = Field(ge=1) + height: int = Field(ge=1) + + +class RoomUpdate(BaseModel): + name: Optional[str] = Field(default=None, min_length=1, max_length=50) + is_public: Optional[bool] = None + password: Optional[str] = Field(default=None, min_length=1, max_length=255) + width: Optional[int] = Field(default=None, ge=1) + height: Optional[int] = Field(default=None, ge=1) + + +class RoomResponse(BaseModel): + id: int + room_type: RoomType + name: str + owner_type: OwnerType + owner_id: Optional[int] + is_public: bool + width: int + height: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class RoomListResponse(BaseModel): + total: int + limit: int + offset: int + rooms: list[RoomResponse] diff --git a/fastapi-backend/app/schemas/room_layout.py b/fastapi-backend/app/schemas/room_layout.py new file mode 100644 index 0000000..187a07b --- /dev/null +++ b/fastapi-backend/app/schemas/room_layout.py @@ -0,0 +1,97 @@ +from typing import List + +from pydantic import BaseModel, Field + + +class RoomTilePayload(BaseModel): + x: int = Field(ge=0) + y: int = Field(ge=0) + tile_asset_id: int + + +class RoomObjectPayload(BaseModel): + x: int = Field(ge=0) + y: int = Field(ge=0) + object_asset_id: str = Field(min_length=1, max_length=100) + + +class RoomPortalPayload(BaseModel): + from_x: int = Field(ge=0) + from_y: int = Field(ge=0) + to_room_id: int + + +class GridPosition(BaseModel): + x: int = Field(ge=0) + y: int = Field(ge=0) + + +class PortalPosition(BaseModel): + from_x: int = Field(ge=0) + from_y: int = Field(ge=0) + + +class ReplaceTilesRequest(BaseModel): + tiles: List[RoomTilePayload] + + +class ReplaceObjectsRequest(BaseModel): + objects: List[RoomObjectPayload] + + +class ReplacePortalsRequest(BaseModel): + portals: List[RoomPortalPayload] + + +class PatchTilesRequest(BaseModel): + upserts: List[RoomTilePayload] = Field(default_factory=list) + removes: List[GridPosition] = Field(default_factory=list) + + +class PatchObjectsRequest(BaseModel): + upserts: List[RoomObjectPayload] = Field(default_factory=list) + removes: List[GridPosition] = Field(default_factory=list) + + +class PatchPortalsRequest(BaseModel): + upserts: List[RoomPortalPayload] = Field(default_factory=list) + removes: List[PortalPosition] = Field(default_factory=list) + + +class RoomLayoutResponse(BaseModel): + room_id: int + width: int + height: int + tiles: List[RoomTilePayload] + objects: List[RoomObjectPayload] + portals: List[RoomPortalPayload] + + +class ReplaceTilesResponse(BaseModel): + room_id: int + tiles: List[RoomTilePayload] + + +class ReplaceObjectsResponse(BaseModel): + room_id: int + objects: List[RoomObjectPayload] + + +class ReplacePortalsResponse(BaseModel): + room_id: int + portals: List[RoomPortalPayload] + + +class PatchTilesResponse(BaseModel): + room_id: int + tiles: List[RoomTilePayload] + + +class PatchObjectsResponse(BaseModel): + room_id: int + objects: List[RoomObjectPayload] + + +class PatchPortalsResponse(BaseModel): + room_id: int + portals: List[RoomPortalPayload] diff --git a/fastapi-backend/app/schemas/room_role.py b/fastapi-backend/app/schemas/room_role.py new file mode 100644 index 0000000..ec585fd --- /dev/null +++ b/fastapi-backend/app/schemas/room_role.py @@ -0,0 +1,25 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + +from app.db.enums import RoomRoleType + + +class RoomRoleUpdateRequest(BaseModel): + role: RoomRoleType + + +class RoomRoleResponse(BaseModel): + room_id: int + user_id: int + role: RoomRoleType + created_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class RoomRoleListResponse(BaseModel): + room_id: int + roles: list[RoomRoleResponse] diff --git a/fastapi-backend/app/services/room_layout_service.py b/fastapi-backend/app/services/room_layout_service.py new file mode 100644 index 0000000..a45b023 --- /dev/null +++ b/fastapi-backend/app/services/room_layout_service.py @@ -0,0 +1,343 @@ +from datetime import datetime, timezone + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.db.enums import OwnerType +from app.db.models import Room, RoomObject, RoomPortal, RoomTile, User +from app.schemas.room_layout import ( + PatchObjectsRequest, + PatchPortalsRequest, + PatchTilesRequest, + ReplaceObjectsRequest, + ReplacePortalsRequest, + ReplaceTilesRequest, + RoomLayoutResponse, + RoomObjectPayload, + RoomPortalPayload, + RoomTilePayload, +) + + +class RoomLayoutService: + def __init__(self, db: Session): + self.db = db + + def _get_room(self, room_id: int) -> Room: + room = self.db.query(Room).filter(Room.id == room_id).first() + if room is None: + raise HTTPException(status_code=404, detail="Room not found") + return room + + def _ensure_user_can_edit(self, room: Room, user: User) -> None: + if room.owner_type != OwnerType.USER or room.owner_id != user.id: + raise HTTPException(status_code=403, detail="No permission to edit this room") + + def _ensure_user_can_read(self, room: Room, user: User) -> None: + if room.is_public: + return + if room.owner_type == OwnerType.USER and room.owner_id == user.id: + return + raise HTTPException(status_code=403, detail="No permission to read this room") + + def _touch_room(self, room: Room) -> None: + room.updated_at = datetime.now(timezone.utc) + + def get_layout(self, room_id: int, user: User) -> RoomLayoutResponse: + room = self._get_room(room_id) + self._ensure_user_can_read(room, user) + + tiles = self.db.query(RoomTile).filter(RoomTile.room_id == room_id).all() + objects = self.db.query(RoomObject).filter(RoomObject.room_id == room_id).all() + portals = self.db.query(RoomPortal).filter(RoomPortal.from_room_id == room_id).all() + + return RoomLayoutResponse( + room_id=room.id, + width=room.width, + height=room.height, + tiles=[ + RoomTilePayload(x=t.x, y=t.y, tile_asset_id=t.tile_asset_id) + for t in tiles + ], + objects=[ + RoomObjectPayload(x=o.x, y=o.y, object_asset_id=o.object_asset_id) + for o in objects + ], + portals=[ + RoomPortalPayload(from_x=p.from_x, from_y=p.from_y, to_room_id=p.to_room_id) + for p in portals + ], + ) + + def replace_tiles( + self, room_id: int, user: User, request_data: ReplaceTilesRequest + ) -> list[RoomTilePayload]: + room = self._get_room(room_id) + self._ensure_user_can_edit(room, user) + + visited: set[tuple[int, int]] = set() + for tile in request_data.tiles: + if tile.x >= room.width or tile.y >= room.height: + raise HTTPException(status_code=400, detail="Tile coordinates out of room bounds") + pos = (tile.x, tile.y) + if pos in visited: + raise HTTPException(status_code=400, detail="Duplicate tile coordinates") + visited.add(pos) + + self.db.query(RoomTile).filter(RoomTile.room_id == room_id).delete(synchronize_session=False) + for tile in request_data.tiles: + self.db.add( + RoomTile( + room_id=room_id, + x=tile.x, + y=tile.y, + tile_asset_id=tile.tile_asset_id, + ) + ) + + self._touch_room(room) + self.db.commit() + + return request_data.tiles + + def replace_objects( + self, room_id: int, user: User, request_data: ReplaceObjectsRequest + ) -> list[RoomObjectPayload]: + room = self._get_room(room_id) + self._ensure_user_can_edit(room, user) + + visited: set[tuple[int, int]] = set() + for obj in request_data.objects: + if obj.x >= room.width or obj.y >= room.height: + raise HTTPException(status_code=400, detail="Object coordinates out of room bounds") + pos = (obj.x, obj.y) + if pos in visited: + raise HTTPException(status_code=400, detail="Duplicate object coordinates") + visited.add(pos) + + self.db.query(RoomObject).filter(RoomObject.room_id == room_id).delete(synchronize_session=False) + for obj in request_data.objects: + self.db.add( + RoomObject( + room_id=room_id, + x=obj.x, + y=obj.y, + object_asset_id=obj.object_asset_id, + ) + ) + + self._touch_room(room) + self.db.commit() + + return request_data.objects + + def replace_portals( + self, room_id: int, user: User, request_data: ReplacePortalsRequest + ) -> list[RoomPortalPayload]: + room = self._get_room(room_id) + self._ensure_user_can_edit(room, user) + + visited: set[tuple[int, int]] = set() + target_room_ids = {portal.to_room_id for portal in request_data.portals} + if target_room_ids: + existing_target_ids = { + r.id for r in self.db.query(Room).filter(Room.id.in_(target_room_ids)).all() + } + missing = target_room_ids - existing_target_ids + if missing: + raise HTTPException(status_code=400, detail=f"Invalid target room ids: {sorted(missing)}") + + for portal in request_data.portals: + if portal.from_x >= room.width or portal.from_y >= room.height: + raise HTTPException(status_code=400, detail="Portal coordinates out of room bounds") + pos = (portal.from_x, portal.from_y) + if pos in visited: + raise HTTPException(status_code=400, detail="Duplicate portal coordinates") + visited.add(pos) + + self.db.query(RoomPortal).filter(RoomPortal.from_room_id == room_id).delete( + synchronize_session=False + ) + for portal in request_data.portals: + self.db.add( + RoomPortal( + from_room_id=room_id, + from_x=portal.from_x, + from_y=portal.from_y, + to_room_id=portal.to_room_id, + ) + ) + + self._touch_room(room) + self.db.commit() + + return request_data.portals + + def patch_tiles( + self, room_id: int, user: User, request_data: PatchTilesRequest + ) -> list[RoomTilePayload]: + room = self._get_room(room_id) + self._ensure_user_can_edit(room, user) + + upsert_positions = {(t.x, t.y) for t in request_data.upserts} + remove_positions = {(p.x, p.y) for p in request_data.removes} + if upsert_positions & remove_positions: + raise HTTPException(status_code=400, detail="Same tile coordinates cannot be upserted and removed") + + if len(upsert_positions) != len(request_data.upserts): + raise HTTPException(status_code=400, detail="Duplicate tile coordinates in upserts") + + for tile in request_data.upserts: + if tile.x >= room.width or tile.y >= room.height: + raise HTTPException(status_code=400, detail="Tile coordinates out of room bounds") + + for pos in request_data.removes: + if pos.x >= room.width or pos.y >= room.height: + raise HTTPException(status_code=400, detail="Tile remove coordinates out of room bounds") + + for pos in request_data.removes: + self.db.query(RoomTile).filter( + RoomTile.room_id == room_id, RoomTile.x == pos.x, RoomTile.y == pos.y + ).delete(synchronize_session=False) + + for tile in request_data.upserts: + existing = self.db.query(RoomTile).filter( + RoomTile.room_id == room_id, RoomTile.x == tile.x, RoomTile.y == tile.y + ).first() + if existing: + existing.tile_asset_id = tile.tile_asset_id + else: + self.db.add( + RoomTile( + room_id=room_id, + x=tile.x, + y=tile.y, + tile_asset_id=tile.tile_asset_id, + ) + ) + + self._touch_room(room) + self.db.commit() + + return [ + RoomTilePayload(x=t.x, y=t.y, tile_asset_id=t.tile_asset_id) + for t in self.db.query(RoomTile).filter(RoomTile.room_id == room_id).all() + ] + + def patch_objects( + self, room_id: int, user: User, request_data: PatchObjectsRequest + ) -> list[RoomObjectPayload]: + room = self._get_room(room_id) + self._ensure_user_can_edit(room, user) + + upsert_positions = {(o.x, o.y) for o in request_data.upserts} + remove_positions = {(p.x, p.y) for p in request_data.removes} + if upsert_positions & remove_positions: + raise HTTPException( + status_code=400, + detail="Same object coordinates cannot be upserted and removed", + ) + + if len(upsert_positions) != len(request_data.upserts): + raise HTTPException(status_code=400, detail="Duplicate object coordinates in upserts") + + for obj in request_data.upserts: + if obj.x >= room.width or obj.y >= room.height: + raise HTTPException(status_code=400, detail="Object coordinates out of room bounds") + + for pos in request_data.removes: + if pos.x >= room.width or pos.y >= room.height: + raise HTTPException(status_code=400, detail="Object remove coordinates out of room bounds") + + for pos in request_data.removes: + self.db.query(RoomObject).filter( + RoomObject.room_id == room_id, RoomObject.x == pos.x, RoomObject.y == pos.y + ).delete(synchronize_session=False) + + for obj in request_data.upserts: + existing = self.db.query(RoomObject).filter( + RoomObject.room_id == room_id, RoomObject.x == obj.x, RoomObject.y == obj.y + ).first() + if existing: + existing.object_asset_id = obj.object_asset_id + else: + self.db.add( + RoomObject( + room_id=room_id, + x=obj.x, + y=obj.y, + object_asset_id=obj.object_asset_id, + ) + ) + + self._touch_room(room) + self.db.commit() + + return [ + RoomObjectPayload(x=o.x, y=o.y, object_asset_id=o.object_asset_id) + for o in self.db.query(RoomObject).filter(RoomObject.room_id == room_id).all() + ] + + def patch_portals( + self, room_id: int, user: User, request_data: PatchPortalsRequest + ) -> list[RoomPortalPayload]: + room = self._get_room(room_id) + self._ensure_user_can_edit(room, user) + + upsert_positions = {(p.from_x, p.from_y) for p in request_data.upserts} + remove_positions = {(p.from_x, p.from_y) for p in request_data.removes} + if upsert_positions & remove_positions: + raise HTTPException( + status_code=400, + detail="Same portal coordinates cannot be upserted and removed", + ) + + if len(upsert_positions) != len(request_data.upserts): + raise HTTPException(status_code=400, detail="Duplicate portal coordinates in upserts") + + target_room_ids = {portal.to_room_id for portal in request_data.upserts} + if target_room_ids: + existing_target_ids = { + r.id for r in self.db.query(Room).filter(Room.id.in_(target_room_ids)).all() + } + missing = target_room_ids - existing_target_ids + if missing: + raise HTTPException(status_code=400, detail=f"Invalid target room ids: {sorted(missing)}") + + for portal in request_data.upserts: + if portal.from_x >= room.width or portal.from_y >= room.height: + raise HTTPException(status_code=400, detail="Portal coordinates out of room bounds") + + for pos in request_data.removes: + if pos.from_x >= room.width or pos.from_y >= room.height: + raise HTTPException(status_code=400, detail="Portal remove coordinates out of room bounds") + + for pos in request_data.removes: + self.db.query(RoomPortal).filter( + RoomPortal.from_room_id == room_id, + RoomPortal.from_x == pos.from_x, + RoomPortal.from_y == pos.from_y, + ).delete(synchronize_session=False) + + for portal in request_data.upserts: + self.db.query(RoomPortal).filter( + RoomPortal.from_room_id == room_id, + RoomPortal.from_x == portal.from_x, + RoomPortal.from_y == portal.from_y, + ).delete(synchronize_session=False) + self.db.add( + RoomPortal( + from_room_id=room_id, + from_x=portal.from_x, + from_y=portal.from_y, + to_room_id=portal.to_room_id, + ) + ) + + self._touch_room(room) + self.db.commit() + + return [ + RoomPortalPayload(from_x=p.from_x, from_y=p.from_y, to_room_id=p.to_room_id) + for p in self.db.query(RoomPortal).filter(RoomPortal.from_room_id == room_id).all() + ] diff --git a/fastapi-backend/app/services/room_role_service.py b/fastapi-backend/app/services/room_role_service.py new file mode 100644 index 0000000..d73dad6 --- /dev/null +++ b/fastapi-backend/app/services/room_role_service.py @@ -0,0 +1,133 @@ +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.db.enums import OwnerType, RoomRoleType +from app.db.models import Room, RoomRole, User +from app.schemas.room_role import RoomRoleResponse + + +class RoomRoleService: + def __init__(self, db: Session): + self.db = db + + def _get_room(self, room_id: int) -> Room: + room = self.db.query(Room).filter(Room.id == room_id).first() + if room is None: + raise HTTPException(status_code=404, detail="Room not found") + return room + + def _ensure_owner_can_manage(self, room: Room, current_user: User) -> None: + if room.owner_type != OwnerType.USER or room.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="No permission to manage room roles") + + def _ensure_user_can_read(self, room: Room, current_user: User) -> None: + if room.is_public: + return + if room.owner_type == OwnerType.USER and room.owner_id == current_user.id: + return + raise HTTPException(status_code=403, detail="No permission to read room roles") + + def list_roles(self, room_id: int, current_user: User) -> list[RoomRoleResponse]: + room = self._get_room(room_id) + self._ensure_user_can_read(room, current_user) + + roles = [] + if room.owner_type == OwnerType.USER and room.owner_id is not None: + roles.append( + RoomRoleResponse( + room_id=room.id, + user_id=room.owner_id, + role=RoomRoleType.OWNER, + created_at=None, + ) + ) + + role_rows = self.db.query(RoomRole).filter(RoomRole.room_id == room_id).all() + for row in role_rows: + if room.owner_type == OwnerType.USER and row.user_id == room.owner_id: + continue + roles.append( + RoomRoleResponse( + room_id=row.room_id, + user_id=row.user_id, + role=row.role, + created_at=row.created_at, + ) + ) + + return roles + + def get_role( + self, room_id: int, target_user_id: int, current_user: User + ) -> RoomRoleResponse: + room = self._get_room(room_id) + self._ensure_user_can_read(room, current_user) + + if room.owner_type == OwnerType.USER and room.owner_id == target_user_id: + return RoomRoleResponse( + room_id=room.id, + user_id=target_user_id, + role=RoomRoleType.OWNER, + created_at=None, + ) + + row = self.db.query(RoomRole).filter( + RoomRole.room_id == room_id, RoomRole.user_id == target_user_id + ).first() + if row is None: + return RoomRoleResponse( + room_id=room_id, + user_id=target_user_id, + role=RoomRoleType.VISITOR, + created_at=None, + ) + + return RoomRoleResponse( + room_id=row.room_id, + user_id=row.user_id, + role=row.role, + created_at=row.created_at, + ) + + def upsert_role( + self, room_id: int, target_user_id: int, role: RoomRoleType, current_user: User + ) -> RoomRoleResponse: + room = self._get_room(room_id) + self._ensure_owner_can_manage(room, current_user) + + target_user = self.db.query(User).filter(User.id == target_user_id).first() + if target_user is None: + raise HTTPException(status_code=404, detail="Target user not found") + + if room.owner_type == OwnerType.USER and room.owner_id == target_user_id: + raise HTTPException(status_code=400, detail="Owner role is fixed and cannot be changed") + + if role == RoomRoleType.OWNER: + raise HTTPException(status_code=400, detail="OWNER assignment is not supported in this API") + + if role == RoomRoleType.VISITOR: + self.db.query(RoomRole).filter( + RoomRole.room_id == room_id, RoomRole.user_id == target_user_id + ).delete(synchronize_session=False) + self.db.commit() + return RoomRoleResponse( + room_id=room_id, + user_id=target_user_id, + role=RoomRoleType.VISITOR, + created_at=None, + ) + + row = self.db.query(RoomRole).filter( + RoomRole.room_id == room_id, RoomRole.user_id == target_user_id + ).first() + if row is None: + row = RoomRole(room_id=room_id, user_id=target_user_id, role=role) + self.db.add(row) + else: + row.role = role + + self.db.commit() + self.db.refresh(row) + return RoomRoleResponse( + room_id=row.room_id, user_id=row.user_id, role=row.role, created_at=row.created_at + ) diff --git a/fastapi-backend/app/services/room_service.py b/fastapi-backend/app/services/room_service.py new file mode 100644 index 0000000..22f7b20 --- /dev/null +++ b/fastapi-backend/app/services/room_service.py @@ -0,0 +1,103 @@ +import hashlib + +from fastapi import HTTPException +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from app.db.enums import OwnerType +from app.db.models import Room, User +from app.schemas.room import RoomCreate, RoomUpdate + + +class RoomService: +# 룸 생성, 조회, 수정 등의 비즈니스 로직을 담당하는 서비스 클래스 + def __init__(self, db: Session): + self.db = db + + def _hash_password(self, password: str) -> str: + return hashlib.sha256(password.encode("utf-8")).hexdigest() + + def _ensure_user_can_edit(self, room: Room, user: User) -> None: + if room.owner_type != OwnerType.USER or room.owner_id != user.id: + raise HTTPException(status_code=403, detail="No permission to edit this room") + + def _ensure_user_can_read(self, room: Room, user: User) -> None: + if room.is_public: + return + if room.owner_type == OwnerType.USER and room.owner_id == user.id: + return + raise HTTPException(status_code=403, detail="No permission to read this room") + + def create_room(self, user: User, room_data: RoomCreate) -> Room: + if room_data.owner_type != OwnerType.USER: + raise HTTPException(status_code=400, detail="Only USER owner type is supported now") + + password_hash = None + if not room_data.is_public and room_data.password: + password_hash = self._hash_password(room_data.password) + + room = Room( + room_type=room_data.room_type, + name=room_data.name, + owner_type=OwnerType.USER, + owner_id=user.id, + is_public=room_data.is_public, + password_hash=password_hash, + width=room_data.width, + height=room_data.height, + ) + self.db.add(room) + self.db.commit() + self.db.refresh(room) + return room + + def get_room(self, room_id: int, user: User) -> Room: + room = self.db.query(Room).filter(Room.id == room_id).first() + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + self._ensure_user_can_read(room, user) + return room + + def list_rooms( + self, user: User, limit: int = 20, offset: int = 0, mine_only: bool = False + ) -> tuple[int, list[Room]]: + query = self.db.query(Room) + + if mine_only: + query = query.filter(Room.owner_type == OwnerType.USER, Room.owner_id == user.id) + else: + query = query.filter( + or_( + Room.is_public.is_(True), + (Room.owner_type == OwnerType.USER) & (Room.owner_id == user.id), + ) + ) + + total = query.count() + rooms = query.order_by(Room.updated_at.desc()).offset(offset).limit(limit).all() + return total, rooms + + def update_room(self, room_id: int, user: User, room_data: RoomUpdate) -> Room: + room = self.db.query(Room).filter(Room.id == room_id).first() + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + self._ensure_user_can_edit(room, user) + + if room_data.name is not None: + room.name = room_data.name + if room_data.width is not None: + room.width = room_data.width + if room_data.height is not None: + room.height = room_data.height + if room_data.is_public is not None: + room.is_public = room_data.is_public + if room.is_public: + room.password_hash = None + if room_data.password is not None: + room.password_hash = self._hash_password(room_data.password) + + self.db.commit() + self.db.refresh(room) + return room