From 14b6e95f9d044bf496d0158ed25d5fdafb9e404c Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 30 Sep 2026 15:33:35 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/backend,frontend:=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=EA=B0=80=20=EB=AA=A8=EB=93=A0=20=EC=82=AC?= =?UTF-8?q?=EC=97=85=EC=9E=A5=EC=9D=84=20=EC=97=B4=EA=B3=A0=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=E2=80=94=20=EC=82=AC=EC=9D=B4=ED=8A=B8=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B2=80=EC=83=89=C2=B7=EC=97=85=EC=A2=85=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=C2=B7=EC=82=AD=EC=A0=9C,=20=EC=82=AC?= =?UTF-8?q?=EC=9E=A5=EB=8B=98=20=EB=8D=94=EB=B3=B4=EA=B8=B0=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사업장 API 가 전부 "요청자 = 소유자" 로 걸러져서 관리자가 남의 빌더·미니블로그를 열면 PLACE_NOT_FOUND 였다. 운영 중 사업장 정리도 사장님 계정으로 들어가야 했다. - validator/dependencies: DEVELOPER 가 place_id 경로를 부르면 그 사업장 소유자 신원으로 처리 — [admin-access] 로그 - ops/sites: search(상호·소유자 이메일·이름·아이디) · category 필터, 건수도 같은 조건 - OpsSitesPage: 검색창 · 업종 칩 · 행마다 빌더·미니블로그·삭제(공개 중이면 발행 내린 뒤 삭제) - SitesPage: 더보기에서 미니블로그 관리 · 예약요청 관리 제거 테스트 4건 추가(test_admin_access). 전체 900 passed · 145 failed(변경 전과 같은 파일: agent_runtime·gemini 등) frontend tsc·eslint 통과 Co-Authored-By: Claude Opus 5.5 --- solution/backend/crud/site_crud.py | 17 +- solution/backend/router/v1/ops/ops.py | 10 +- .../router/v1/validator/dependencies.py | 44 +++++- solution/backend/services/ops_service.py | 4 +- solution/backend/tests/test_admin_access.py | 57 +++++++ .../api/generated/model/listSitesParams.ts | 8 + solution/frontend/src/pages/OpsSitesPage.tsx | 149 +++++++++++++++--- solution/frontend/src/pages/SitesPage.tsx | 28 ---- 8 files changed, 259 insertions(+), 58 deletions(-) create mode 100644 solution/backend/tests/test_admin_access.py diff --git a/solution/backend/crud/site_crud.py b/solution/backend/crud/site_crud.py index 2b082ba..d38d0ad 100644 --- a/solution/backend/crud/site_crud.py +++ b/solution/backend/crud/site_crud.py @@ -44,7 +44,7 @@ class ISiteCRUD(ABC): pass @abstractmethod - async def list_all_sites(self, cdb: AsyncSession, skip, limit) -> Tuple[ErrorType, list, int]: + async def list_all_sites(self, cdb: AsyncSession, skip, limit, search=None, category=None) -> Tuple[ErrorType, list, int]: """전 계정 사이트 목록(회사 스코프 없음) — 내부 운영(DEVELOPER) 전용.""" pass @@ -145,12 +145,23 @@ class SiteCRUD(ISiteCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, [], 0 - async def list_all_sites(self, cdb: AsyncSession, skip: int, limit: int) -> Tuple[ErrorType, list, int]: + async def list_all_sites( + self, cdb: AsyncSession, skip: int, limit: int, search: str | None = None, category: int | None = None + ) -> Tuple[ErrorType, list, int]: """list_owner_sites 와 같은 조인이되 owner_user_id 필터가 없다 — 소유자 계정 정보를 같이 얹는다.""" try: where = places.deleted == False # noqa: E712 + if category is not None: + where = and_(where, places.category == category) + if search and search.strip(): + like = f"%{search.strip()}%" + where = and_(where, places.name.ilike(like) | users.email.ilike(like) + | users.name.ilike(like) | users.id.ilike(like)) - cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(places).where(where)) + cnt_err, cnt_rows = await DB_SESSION_MNG.execute( + cdb, + select(func.count()).select_from(places).join(users, users.user_id == places.owner_user_id).where(where), + ) if cnt_err != ErrorType.SUCCESS: return cnt_err, [], 0 total = int(cnt_rows[0] or 0) if cnt_rows else 0 diff --git a/solution/backend/router/v1/ops/ops.py b/solution/backend/router/v1/ops/ops.py index 56f7d22..b1f5549 100644 --- a/solution/backend/router/v1/ops/ops.py +++ b/solution/backend/router/v1/ops/ops.py @@ -10,8 +10,14 @@ router = APIRouter(prefix="/v1/ops", tags=["Ops"]) @router.get(path="/sites", response_model=Res_OpsSites, summary="전 계정 사이트 목록(개발자 전용)") -async def list_sites(service: OpsService = Depends(), pg: PageParams = Depends(), _user=Depends(RequireDeveloper)): - return RemoveNoneResponse(await service.list_sites(pg)) +async def list_sites( + service: OpsService = Depends(), + pg: PageParams = Depends(), + search: str | None = Query(None, description="상호·소유자 이메일·이름·로그인 아이디 부분 일치"), + category: int | None = Query(None, description="PlaceCategory"), + _user=Depends(RequireDeveloper), +): + return RemoveNoneResponse(await service.list_sites(pg, search, category)) @router.get(path="/users", response_model=Res_OpsUsers, summary="전 계정 목록(개발자 전용)") diff --git a/solution/backend/router/v1/validator/dependencies.py b/solution/backend/router/v1/validator/dependencies.py index 4381a56..fe1f3c4 100644 --- a/solution/backend/router/v1/validator/dependencies.py +++ b/solution/backend/router/v1/validator/dependencies.py @@ -1,15 +1,21 @@ import asyncio import json +import uuid from datetime import datetime, timedelta, timezone from typing import Any, Union -from fastapi import Depends +from fastapi import Depends, Request +from sqlalchemy import select from fastapi.responses import JSONResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer import bcrypt from jose import jwt, JWTError, ExpiredSignatureError +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import places from common.enums import ( + DBWRType, + ErrorType, EXCEPTION_ACCESS_TOKEN_EXPIRED, EXCEPTION_FORBIDDEN, EXCEPTION_INVALID_CLIENT_ACCESS, @@ -96,8 +102,40 @@ def DecodeRefreshToken(jwt_token: str) -> UserInfo: # Depends 용 토큰 검증기 -async def IsValidAccessToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo: - return DecodeAccessToken(credentials.credentials) +async def _place_owner(place_id: str) -> str | None: + try: + pid = uuid.UUID(place_id) + except ValueError: + return None + err, rows = await DB_SESSION_MNG.execute_lambda( + places.DBType(), + DBWRType.DB_READ.value, + lambda s: DB_SESSION_MNG.execute( + s, select(places.owner_user_id).where(places.place_id == pid, places.deleted == False) # noqa: E712 + ), + ) + if err != ErrorType.SUCCESS or not rows: + return None + return str(rows[0]) + + +async def _act_as_place_owner(request: Request, user_info: UserInfo) -> UserInfo: + if (user_info.role or 0) < UserRole.DEVELOPER.value: + return user_info + place_id = request.path_params.get("place_id") or request.query_params.get("place_id") + if not place_id: + return user_info + owner = await _place_owner(str(place_id)) + if owner is None or owner == str(user_info.user_id): + return user_info + LOG.i(f"[admin-access] {user_info.id} → place={place_id} owner={owner} {request.method} {request.url.path}") + return UserInfo(user_id=owner, id=user_info.id, role=user_info.role, token_version=user_info.token_version) + + +async def IsValidAccessToken( + request: Request, credentials: HTTPAuthorizationCredentials = Depends(security) +) -> UserInfo: + return await _act_as_place_owner(request, DecodeAccessToken(credentials.credentials)) async def IsValidRefreshToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo: diff --git a/solution/backend/services/ops_service.py b/solution/backend/services/ops_service.py index bf853f7..1407a1b 100644 --- a/solution/backend/services/ops_service.py +++ b/solution/backend/services/ops_service.py @@ -16,11 +16,11 @@ class OpsService: self.site_crud = site_crud self.user_crud = user_crud - async def list_sites(self, pg: PageParams) -> Res_OpsSites: + async def list_sites(self, pg: PageParams, search: str | None = None, category: int | None = None) -> Res_OpsSites: res = Res_OpsSites(page=pg.page, size=pg.size) err_type, rows, total = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: self.site_crud.list_all_sites(s, pg.skip, pg.size), + lambda s: self.site_crud.list_all_sites(s, pg.skip, pg.size, search, category), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) diff --git a/solution/backend/tests/test_admin_access.py b/solution/backend/tests/test_admin_access.py new file mode 100644 index 0000000..5834d00 --- /dev/null +++ b/solution/backend/tests/test_admin_access.py @@ -0,0 +1,57 @@ +from common.enums import UserRole + + +async def _place(client, headers, name="남의가게"): + r = await client.post("/v1/place", headers=headers, json={"name": name, "category": 2}) + return r.json()["place"]["place_id"] + + +async def test_개발자는_남의_사업장을_열고_고칠_수_있다(auth_headers, client): + owner = await auth_headers("owner1") + dev = await auth_headers("dev1", role=UserRole.DEVELOPER.value) + pid = await _place(client, owner) + + got = (await client.get(f"/v1/place/{pid}", headers=dev)).json() + assert got["result"]["success"] is True + assert got["place"]["name"] == "남의가게" + + assert (await client.get(f"/v1/place/{pid}/media/list", headers=dev)).json()["result"]["success"] is True + as_owner = (await client.get(f"/v1/place/{pid}/site", headers=owner)).json() + as_dev = (await client.get(f"/v1/place/{pid}/site", headers=dev)).json() + assert as_dev == as_owner + + +async def test_일반_사장님은_남의_사업장을_못_연다(auth_headers, client): + owner = await auth_headers("owner2") + other = await auth_headers("other2") + boss = await auth_headers("boss2", role=UserRole.OWNER.value) + pid = await _place(client, owner) + + assert (await client.get(f"/v1/place/{pid}", headers=other)).json()["result"]["success"] is False + assert (await client.get(f"/v1/place/{pid}", headers=boss)).json()["result"]["success"] is False + + +async def test_개발자_자기_목록은_그대로다(auth_headers, client): + owner = await auth_headers("owner3") + dev = await auth_headers("dev3", role=UserRole.DEVELOPER.value) + await _place(client, owner) + + listed = (await client.get("/v1/place/list", headers=dev)).json() + assert listed["result"]["success"] is True + assert all(p["name"] != "남의가게" for p in listed["places"]) + + +async def test_사이트관리는_상호_소유자_업종으로_거른다(auth_headers, client): + owner = await auth_headers("owner4", name="김사장") + dev = await auth_headers("dev4", role=UserRole.DEVELOPER.value) + await client.post("/v1/place", headers=owner, json={"name": "검색카페", "category": 2}) + await client.post("/v1/place", headers=owner, json={"name": "검색펜션", "category": 1}) + + def names(r): + return sorted(s["name"] for s in r.json()["sites"]) + + assert names(await client.get("/v1/ops/sites", headers=dev, params={"search": "검색"})) == ["검색카페", "검색펜션"] + assert names(await client.get("/v1/ops/sites", headers=dev, params={"search": "김사장"})) == ["검색카페", "검색펜션"] + assert names(await client.get("/v1/ops/sites", headers=dev, params={"search": "검색", "category": 2})) == ["검색카페"] + counted = (await client.get("/v1/ops/sites", headers=dev, params={"search": "펜션"})).json() + assert counted["total"] == 1 diff --git a/solution/frontend/src/api/generated/model/listSitesParams.ts b/solution/frontend/src/api/generated/model/listSitesParams.ts index 536f6cc..53933be 100644 --- a/solution/frontend/src/api/generated/model/listSitesParams.ts +++ b/solution/frontend/src/api/generated/model/listSitesParams.ts @@ -6,6 +6,14 @@ */ export type ListSitesParams = { + /** + * 상호·소유자 이메일·이름·로그인 아이디 부분 일치 + */ + search?: string | null; + /** + * PlaceCategory + */ + category?: number | null; /** * @minimum 1 */ diff --git a/solution/frontend/src/pages/OpsSitesPage.tsx b/solution/frontend/src/pages/OpsSitesPage.tsx index 5c7d5d2..a7ced69 100644 --- a/solution/frontend/src/pages/OpsSitesPage.tsx +++ b/solution/frontend/src/pages/OpsSitesPage.tsx @@ -1,13 +1,16 @@ import {useState} from 'react'; -import {Navigate} from 'react-router'; +import {Link, Navigate} from 'react-router'; import {keepPreviousData} from '@tanstack/react-query'; -import {Building2, ChevronLeft, ChevronRight, ExternalLink, Loader2} from 'lucide-react'; +import {Building2, ChevronLeft, ChevronRight, ExternalLink, Loader2, Newspaper, Pencil, Search, Trash2, X} from 'lucide-react'; import {PlaceCategory, publishUrlString, SiteStatus} from '@o2o/shared'; -import {UserRole, useListSites, type OpsSiteData} from '@/api'; +import {changeStatus, deletePlace, PublishAction, UserRole, useListSites, type OpsSiteData} from '@/api'; import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; +import {Input} from '@/components/ui/input'; +import {notify, notifyApiError} from '@/lib/notify'; import {PUBLISH_HOST} from '@/lib/site'; +import {cn} from '@/lib/utils'; import {useAuthStore} from '@/stores/auth'; const CATEGORY_LABEL: Record = { @@ -39,14 +42,46 @@ export function OpsSitesPage() { const isRestoring = useAuthStore((s) => s.isRestoring); const role = useAuthStore((s) => s.user?.role); const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const [category, setCategory] = useState(null); + const [busyId, setBusyId] = useState(null); const size = 20; // enabled: role 이 확정되기 전엔 요청하지 않는다 — 어차피 백엔드가 403 으로 막지만, 사장님 화면에서 실패 토스트가 먼저 뜨는 것과 새로고침 때 잠깐의 오탐 리다이렉트를 막는다. - const {data, isLoading, isError, error} = useListSites( - {page, size}, + const {data, isLoading, isError, error, refetch} = useListSites( + {page, size, search: search || undefined, category: category ?? undefined}, {query: {placeholderData: keepPreviousData, enabled: role === UserRole.DEVELOPER}}, ); // RequireAuth 와 같은 이유 — 복구가 끝나기 전에 판단하면 새로고침마다 리다이렉트가 한 번 번쩍인다. + const handleDelete = async (row: OpsSiteData) => { + const published = row.status === SiteStatus.PUBLISHED; + const message = published + ? `'${row.name}' 사업장을 삭제할까요?\n발행된 사이트를 먼저 내리고 삭제합니다.` + : `'${row.name}' 사업장을 삭제할까요?`; + if (!window.confirm(message)) return; + setBusyId(row.place_id); + try { + if (published) { + const down = await changeStatus(row.place_id, {action: PublishAction.UNPUBLISH}); + if (!down.result?.success) { + notifyApiError({data: down}, '사이트를 내리지 못해 삭제하지 않았습니다.'); + return; + } + } + const res = await deletePlace(row.place_id); + if (!res.result?.success) { + notifyApiError({data: res}, '사업장을 삭제하지 못했습니다.'); + return; + } + notify.success(`'${row.name}' 을(를) 삭제했습니다.`); + await refetch(); + } catch (deleteError) { + notifyApiError(deleteError, '사업장을 삭제하지 못했습니다.'); + } finally { + setBusyId(null); + } + }; + if (isRestoring) return null; if (role !== UserRole.DEVELOPER) return ; @@ -56,7 +91,55 @@ export function OpsSitesPage() { return ( - + + + { + setPage(1); + setSearch(event.target.value); + }} + placeholder="상호 · 이메일 · 이름 · 아이디 검색" + aria-label="상호 · 이메일 · 이름 · 아이디 검색" + className="h-9 pr-8 pl-8" + /> + {search && ( + + )} + + } + > +
+ {[null, ...Object.keys(CATEGORY_LABEL).map(Number)].map((value) => ( + + ))} +
+ {isLoading && (
@@ -95,21 +178,47 @@ export function OpsSitesPage() {

- {url ? ( - + - - 열기 - - ) : ( - - {row.domain ? `주소 예약됨 · ${row.domain}` : '주소 미정'} - - )} + + 빌더 + + + + 미니블로그 + + {url ? ( + + + 열기 + + ) : ( + + {row.domain ? `주소 예약됨 · ${row.domain}` : '주소 미정'} + + )} + + ); })} diff --git a/solution/frontend/src/pages/SitesPage.tsx b/solution/frontend/src/pages/SitesPage.tsx index f8bcc9d..0e9a9e9 100644 --- a/solution/frontend/src/pages/SitesPage.tsx +++ b/solution/frontend/src/pages/SitesPage.tsx @@ -6,12 +6,10 @@ import {useMemo, useState} from 'react'; import {Link, useNavigate} from 'react-router'; import { Building2, - CalendarClock, Coffee, ExternalLink, Loader2, MoreHorizontal, - Newspaper, Pencil, Plus, Search, @@ -394,32 +392,6 @@ export function SitesPage() { 디자인·컨텐츠 관리 - -