[feat] solution/backend,frontend: 관리자가 모든 사업장을 열고 관리 — 사이트관리 검색·업종 필터·삭제, 사장님 더보기 정리

사업장 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 <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-09-30 15:33:35 +09:00
parent 87343c791b
commit 14b6e95f9d
8 changed files with 259 additions and 58 deletions

View File

@ -44,7 +44,7 @@ class ISiteCRUD(ABC):
pass pass
@abstractmethod @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) 전용.""" """전 계정 사이트 목록(회사 스코프 없음) — 내부 운영(DEVELOPER) 전용."""
pass pass
@ -145,12 +145,23 @@ class SiteCRUD(ISiteCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0 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 필터가 없다 — 소유자 계정 정보를 같이 얹는다.""" """list_owner_sites 와 같은 조인이되 owner_user_id 필터가 없다 — 소유자 계정 정보를 같이 얹는다."""
try: try:
where = places.deleted == False # noqa: E712 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: if cnt_err != ErrorType.SUCCESS:
return cnt_err, [], 0 return cnt_err, [], 0
total = int(cnt_rows[0] or 0) if cnt_rows else 0 total = int(cnt_rows[0] or 0) if cnt_rows else 0

View File

@ -10,8 +10,14 @@ router = APIRouter(prefix="/v1/ops", tags=["Ops"])
@router.get(path="/sites", response_model=Res_OpsSites, summary="전 계정 사이트 목록(개발자 전용)") @router.get(path="/sites", response_model=Res_OpsSites, summary="전 계정 사이트 목록(개발자 전용)")
async def list_sites(service: OpsService = Depends(), pg: PageParams = Depends(), _user=Depends(RequireDeveloper)): async def list_sites(
return RemoveNoneResponse(await service.list_sites(pg)) 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="전 계정 목록(개발자 전용)") @router.get(path="/users", response_model=Res_OpsUsers, summary="전 계정 목록(개발자 전용)")

View File

@ -1,15 +1,21 @@
import asyncio import asyncio
import json import json
import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Union 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.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
import bcrypt import bcrypt
from jose import jwt, JWTError, ExpiredSignatureError 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 ( from common.enums import (
DBWRType,
ErrorType,
EXCEPTION_ACCESS_TOKEN_EXPIRED, EXCEPTION_ACCESS_TOKEN_EXPIRED,
EXCEPTION_FORBIDDEN, EXCEPTION_FORBIDDEN,
EXCEPTION_INVALID_CLIENT_ACCESS, EXCEPTION_INVALID_CLIENT_ACCESS,
@ -96,8 +102,40 @@ def DecodeRefreshToken(jwt_token: str) -> UserInfo:
# Depends 용 토큰 검증기 # Depends 용 토큰 검증기
async def IsValidAccessToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo: async def _place_owner(place_id: str) -> str | None:
return DecodeAccessToken(credentials.credentials) 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: async def IsValidRefreshToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo:

View File

@ -16,11 +16,11 @@ class OpsService:
self.site_crud = site_crud self.site_crud = site_crud
self.user_crud = user_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) res = Res_OpsSites(page=pg.page, size=pg.size)
err_type, rows, total = await DB_SESSION_MNG.execute_lambda( err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
places.DBType(), DBWRType.DB_READ.value, 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: if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type) res.result.SetResult(err_type)

View File

@ -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

View File

@ -6,6 +6,14 @@
*/ */
export type ListSitesParams = { export type ListSitesParams = {
/**
* 상호·소유자 이메일·이름·로그인 아이디 부분 일치
*/
search?: string | null;
/**
* PlaceCategory
*/
category?: number | null;
/** /**
* @minimum 1 * @minimum 1
*/ */

View File

@ -1,13 +1,16 @@
import {useState} from 'react'; import {useState} from 'react';
import {Navigate} from 'react-router'; import {Link, Navigate} from 'react-router';
import {keepPreviousData} from '@tanstack/react-query'; 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 {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 {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; 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 {PUBLISH_HOST} from '@/lib/site';
import {cn} from '@/lib/utils';
import {useAuthStore} from '@/stores/auth'; import {useAuthStore} from '@/stores/auth';
const CATEGORY_LABEL: Record<number, string> = { const CATEGORY_LABEL: Record<number, string> = {
@ -39,14 +42,46 @@ export function OpsSitesPage() {
const isRestoring = useAuthStore((s) => s.isRestoring); const isRestoring = useAuthStore((s) => s.isRestoring);
const role = useAuthStore((s) => s.user?.role); const role = useAuthStore((s) => s.user?.role);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [category, setCategory] = useState<number | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const size = 20; const size = 20;
// enabled: role 이 확정되기 전엔 요청하지 않는다 — 어차피 백엔드가 403 으로 막지만, 사장님 화면에서 실패 토스트가 먼저 뜨는 것과 새로고침 때 잠깐의 오탐 리다이렉트를 막는다. // enabled: role 이 확정되기 전엔 요청하지 않는다 — 어차피 백엔드가 403 으로 막지만, 사장님 화면에서 실패 토스트가 먼저 뜨는 것과 새로고침 때 잠깐의 오탐 리다이렉트를 막는다.
const {data, isLoading, isError, error} = useListSites( const {data, isLoading, isError, error, refetch} = useListSites(
{page, size}, {page, size, search: search || undefined, category: category ?? undefined},
{query: {placeholderData: keepPreviousData, enabled: role === UserRole.DEVELOPER}}, {query: {placeholderData: keepPreviousData, enabled: role === UserRole.DEVELOPER}},
); );
// RequireAuth 와 같은 이유 — 복구가 끝나기 전에 판단하면 새로고침마다 리다이렉트가 한 번 번쩍인다. // 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 (isRestoring) return null;
if (role !== UserRole.DEVELOPER) return <Navigate to="/sites" replace />; if (role !== UserRole.DEVELOPER) return <Navigate to="/sites" replace />;
@ -56,7 +91,55 @@ export function OpsSitesPage() {
return ( return (
<AppShell> <AppShell>
<PageContainer title="사이트관리" description="전 계정 사이트 발행 현황입니다. 회사 스코프 없이 전부 보입니다."> <PageContainer
title="사이트관리"
description="전 계정 사이트 발행 현황입니다. 회사 스코프 없이 전부 보입니다."
actions={
<div className="relative w-64">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={search}
onChange={(event) => {
setPage(1);
setSearch(event.target.value);
}}
placeholder="상호 · 이메일 · 이름 · 아이디 검색"
aria-label="상호 · 이메일 · 이름 · 아이디 검색"
className="h-9 pr-8 pl-8"
/>
{search && (
<button
type="button"
aria-label="검색어 지우기"
onClick={() => setSearch('')}
className="absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
>
<X className="size-3.5" />
</button>
)}
</div>
}
>
<div className="mb-3 flex flex-wrap gap-1.5">
{[null, ...Object.keys(CATEGORY_LABEL).map(Number)].map((value) => (
<button
key={value ?? 'all'}
type="button"
onClick={() => {
setPage(1);
setCategory(value);
}}
className={cn(
'h-8 cursor-pointer rounded-md border px-3 text-xs font-medium transition-colors',
category === value ? 'border-foreground bg-foreground text-background' : 'border-border hover:bg-muted',
)}
>
{value === null ? '전체' : CATEGORY_LABEL[value]}
</button>
))}
</div>
{isLoading && ( {isLoading && (
<div className="flex items-center justify-center py-20 text-muted-foreground"> <div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="size-5 animate-spin" /> <Loader2 className="size-5 animate-spin" />
@ -95,21 +178,47 @@ export function OpsSitesPage() {
</p> </p>
</div> </div>
{url ? ( <div className="flex shrink-0 items-center gap-1.5">
<a <Link
href={url} to={`/builder?placeId=${row.place_id}`}
target="_blank" className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted"
rel="noopener noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted"
> >
<ExternalLink className="size-3.5" /> <Pencil className="size-3.5" />
열기 빌더
</a> </Link>
) : ( <Link
<span className="shrink-0 text-xs text-muted-foreground"> to={`/blog?placeId=${row.place_id}`}
{row.domain ? `주소 예약됨 · ${row.domain}` : '주소 미정'} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted"
</span> >
)} <Newspaper className="size-3.5" />
미니블로그
</Link>
{url ? (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted"
>
<ExternalLink className="size-3.5" />
열기
</a>
) : (
<span className="shrink-0 text-xs text-muted-foreground">
{row.domain ? `주소 예약됨 · ${row.domain}` : '주소 미정'}
</span>
)}
<Button
size="icon"
variant="ghost"
aria-label={`${row.name} 삭제`}
disabled={busyId === row.place_id}
onClick={() => handleDelete(row)}
className="text-destructive"
>
{busyId === row.place_id ? <Loader2 className="animate-spin" /> : <Trash2 />}
</Button>
</div>
</li> </li>
); );
})} })}

View File

@ -6,12 +6,10 @@ import {useMemo, useState} from 'react';
import {Link, useNavigate} from 'react-router'; import {Link, useNavigate} from 'react-router';
import { import {
Building2, Building2,
CalendarClock,
Coffee, Coffee,
ExternalLink, ExternalLink,
Loader2, Loader2,
MoreHorizontal, MoreHorizontal,
Newspaper,
Pencil, Pencil,
Plus, Plus,
Search, Search,
@ -394,32 +392,6 @@ export function SitesPage() {
<Pencil className="size-3.5" /> <Pencil className="size-3.5" />
디자인·컨텐츠 관리 디자인·컨텐츠 관리
</button> </button>
<button
type="button"
onClick={() => {
setMenuId(null);
navigate(`/blog?placeId=${row.place_id}`);
}}
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted"
>
<Newspaper className="size-3.5" />
미니블로그 관리
</button>
<button
type="button"
onClick={() => {
setMenuId(null);
// 예약 요청은 DB 에 안 남는다 — 메일로만 가고 우리 쪽엔 로그 한 줄만 남는다.
notify.info(
'예약 요청은 메일로만 전달됩니다',
'연락처를 따로 저장하지 않기로 했습니다(2026-09-16). 목록이 필요하면 먼저 이 방침을 바꿔야 합니다.',
);
}}
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted"
>
<CalendarClock className="size-3.5" />
예약요청 관리
</button>
<div className="my-1 border-t border-border" /> <div className="my-1 border-t border-border" />
<button <button
type="button" type="button"