[feat] solution/backend: 내 사이트 목록 엔드포인트 — places LEFT JOIN sites 단일 질의
로그인한 사장님이 자기 사이트를 볼 화면이 없었다. 사이트는 place_id 로 한 건씩만 읽혀서 (site_crud.get_site_by_place) 사업장 목록으로 그리면 줄마다 사이트를 다시 물어 N+1 이 된다. - site_crud.list_company_sites: places LEFT JOIN sites LEFT JOIN site_versions 한 번. 사이트가 아직 없는 사업장(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다 - protocol.MySiteData: 한 줄 = 사업장 + 사이트. render(정적 파일 존재)는 넣지 않았다 — 보고서 파일을 읽는 값이라 줄 수만큼 파일 IO 가 된다. 단건(Res_Site)이 계속 소유한다 - site_service.list_my_sites: 회사 스코프. needs_rebuild 는 단건과 같은 규칙으로 판정한다 - GET /v1/site/list 는 라우터 객체를 따로 둔다 — 기존 라우터는 접두어에 place_id 가 박혀 있다 테스트 5건 추가(비어 있는 사업장·조인·회사 격리·재빌드 일치·비로그인), 539 passed (기존 실패 4건은 이 변경 전에도 같다 — build_publish 3 · snapshot 1)
This commit is contained in:
parent
d376677b86
commit
479edf9403
@ -5,7 +5,7 @@ from sqlalchemy import and_, func, select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import publish_logs, site_versions, sites
|
from common.database.model.models import places, publish_logs, site_versions, sites
|
||||||
from common.enums import BuildStatus, ErrorType
|
from common.enums import BuildStatus, ErrorType
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
@ -21,6 +21,10 @@ class ISiteCRUD(ABC):
|
|||||||
async def get_site_by_domain(self, cdb: AsyncSession, domain: str) -> Tuple[ErrorType, sites]:
|
async def get_site_by_domain(self, cdb: AsyncSession, domain: str) -> Tuple[ErrorType, sites]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_company_sites(self, cdb: AsyncSession, company_id, skip, limit) -> Tuple[ErrorType, list, int]:
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]:
|
async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]:
|
||||||
pass
|
pass
|
||||||
@ -85,6 +89,36 @@ class SiteCRUD(ISiteCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, None
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
|
async def list_company_sites(self, cdb: AsyncSession, company_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]:
|
||||||
|
"""회사의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수).
|
||||||
|
|
||||||
|
따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장
|
||||||
|
(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다."""
|
||||||
|
try:
|
||||||
|
where = and_(places.deleted == False, places.company_id == company_id) # noqa: E712
|
||||||
|
|
||||||
|
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(places).where(where))
|
||||||
|
if cnt_err != ErrorType.SUCCESS:
|
||||||
|
return cnt_err, [], 0
|
||||||
|
total = int(cnt_rows[0] or 0) if cnt_rows else 0
|
||||||
|
|
||||||
|
query = (
|
||||||
|
select(places, sites, site_versions.built_at)
|
||||||
|
.outerjoin(sites, and_(sites.place_id == places.place_id, sites.deleted == False)) # noqa: E712
|
||||||
|
.outerjoin(site_versions, site_versions.site_version_id == sites.current_version_id)
|
||||||
|
.where(where)
|
||||||
|
.order_by(places.created_at.desc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
list_err, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||||
|
if list_err != ErrorType.SUCCESS:
|
||||||
|
return list_err, [], 0
|
||||||
|
return ErrorType.SUCCESS, list(rows), total
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, [], 0
|
||||||
|
|
||||||
async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]:
|
async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]:
|
||||||
"""후보 주소들 중 이미 쓰이는 것만 추린다. 대안 제안이 후보마다 왕복하지 않게 한 번에 본다."""
|
"""후보 주소들 중 이미 쓰이는 것만 추린다. 대안 제안이 후보마다 왕복하지 않게 한 번에 본다."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -87,5 +87,6 @@ app.include_router(router.v1.faq.faq.router)
|
|||||||
app.include_router(router.v1.media.media.router)
|
app.include_router(router.v1.media.media.router)
|
||||||
app.include_router(router.v1.job.job.router)
|
app.include_router(router.v1.job.job.router)
|
||||||
app.include_router(router.v1.site.site.router)
|
app.include_router(router.v1.site.site.router)
|
||||||
|
app.include_router(router.v1.site.site.my_router)
|
||||||
app.include_router(router.v1.local.local.router)
|
app.include_router(router.v1.local.local.router)
|
||||||
app.include_router(router.v1.local.local.weather_router)
|
app.include_router(router.v1.local.local.weather_router)
|
||||||
|
|||||||
@ -4,8 +4,17 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from pydantic import ConfigDict
|
from pydantic import ConfigDict
|
||||||
|
|
||||||
from common.enums import BuildStatus, JobStatus, PublishAction, PublishRejectReason, PublishResult, SiteStatus
|
from common.enums import (
|
||||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
BuildStatus,
|
||||||
|
JobStatus,
|
||||||
|
PlaceCategory,
|
||||||
|
PlaceStatus,
|
||||||
|
PublishAction,
|
||||||
|
PublishRejectReason,
|
||||||
|
PublishResult,
|
||||||
|
SiteStatus,
|
||||||
|
)
|
||||||
|
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
|
||||||
|
|
||||||
|
|
||||||
class SiteProtocol(WebPacketProtocol):
|
class SiteProtocol(WebPacketProtocol):
|
||||||
@ -56,6 +65,32 @@ class SiteData(WebPacketProtocol):
|
|||||||
published_at: Optional[datetime] = None
|
published_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MySiteData(WebPacketProtocol):
|
||||||
|
"""내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site).
|
||||||
|
|
||||||
|
★ render 는 여기 없다 — 보고서 **파일**을 읽는 값이라 줄 수만큼 파일 IO 가 된다(단건이 소유).
|
||||||
|
★ site_id 아래가 전부 None 이면 아직 사이트가 없는 사업장이다."""
|
||||||
|
|
||||||
|
place_id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
category: PlaceCategory
|
||||||
|
place_status: PlaceStatus
|
||||||
|
road_address: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
site_id: Optional[uuid.UUID] = None
|
||||||
|
status: Optional[SiteStatus] = None
|
||||||
|
domain: Optional[str] = None
|
||||||
|
template_id: Optional[str] = None
|
||||||
|
published_at: Optional[datetime] = None
|
||||||
|
# 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다.
|
||||||
|
needs_rebuild: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class Res_MySites(Res_PageProtocol):
|
||||||
|
sites: list[MySiteData] = []
|
||||||
|
|
||||||
|
|
||||||
class PublishLogData(WebPacketProtocol):
|
class PublishLogData(WebPacketProtocol):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
from common.models.gmodel import UserInfo
|
from common.models.gmodel import PageParams, UserInfo
|
||||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
||||||
from services.site_service import SiteService
|
from services.site_service import SiteService
|
||||||
from .protocol import (
|
from .protocol import (
|
||||||
@ -11,6 +11,7 @@ from .protocol import (
|
|||||||
Req_SiteTemplate,
|
Req_SiteTemplate,
|
||||||
Req_SiteTheme,
|
Req_SiteTheme,
|
||||||
Req_StartBuild,
|
Req_StartBuild,
|
||||||
|
Res_MySites,
|
||||||
Res_PublishLogs,
|
Res_PublishLogs,
|
||||||
Res_Site,
|
Res_Site,
|
||||||
Res_SeoAudit,
|
Res_SeoAudit,
|
||||||
@ -23,6 +24,25 @@ from .protocol import (
|
|||||||
# 사이트/발행 라우터. 사업장 하위 리소스이며 회사 스코프는 service 가 사업장 조회로 강제한다.
|
# 사이트/발행 라우터. 사업장 하위 리소스이며 회사 스코프는 service 가 사업장 조회로 강제한다.
|
||||||
router = APIRouter(prefix="/v1/place/{place_id}/site", tags=["Site"], responses={404: {"description": "Not found"}})
|
router = APIRouter(prefix="/v1/place/{place_id}/site", tags=["Site"], responses={404: {"description": "Not found"}})
|
||||||
|
|
||||||
|
# ★ 내 사이트 목록은 사업장 하위가 아니라 계정 하위다 — 위 라우터는 접두어에 place_id 가 박혀 있어
|
||||||
|
# "내 것 전부"가 들어갈 자리가 없다. 라우터 객체를 하나 더 둔다(router.py 에서 같이 등록).
|
||||||
|
my_router = APIRouter(prefix="/v1/site", tags=["Site"], responses={404: {"description": "Not found"}})
|
||||||
|
|
||||||
|
|
||||||
|
@my_router.get(
|
||||||
|
path="/list",
|
||||||
|
response_model=Res_MySites,
|
||||||
|
summary="내 사이트 목록",
|
||||||
|
description="로그인한 계정(회사)이 가진 사이트 전부. 아직 사이트가 만들어지지 않은 사업장도 "
|
||||||
|
"site_id=null 로 함께 내려간다 — 위저드를 걸어오다 만 것을 목록에서 잃지 않게 한다.",
|
||||||
|
)
|
||||||
|
async def list_my_sites(
|
||||||
|
service: SiteService = Depends(),
|
||||||
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||||
|
pg: PageParams = Depends(),
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(await service.list_my_sites(user_info, pg))
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
path="",
|
path="",
|
||||||
|
|||||||
@ -16,12 +16,13 @@ from common.enums import (
|
|||||||
SiteStatus,
|
SiteStatus,
|
||||||
)
|
)
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from common.models.gmodel import UserInfo
|
from common.models.gmodel import PageParams, UserInfo
|
||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
from crud.job_crud import JobQueue
|
from crud.job_crud import JobQueue
|
||||||
from crud.place_crud import PlaceCRUD
|
from crud.place_crud import PlaceCRUD
|
||||||
from crud.site_crud import ISiteCRUD, SiteCRUD
|
from crud.site_crud import ISiteCRUD, SiteCRUD
|
||||||
from router.v1.site.protocol import (
|
from router.v1.site.protocol import (
|
||||||
|
MySiteData,
|
||||||
PublishLogData,
|
PublishLogData,
|
||||||
RenderStatusData,
|
RenderStatusData,
|
||||||
Req_SiteSlug,
|
Req_SiteSlug,
|
||||||
@ -29,6 +30,7 @@ from router.v1.site.protocol import (
|
|||||||
Req_SiteTemplate,
|
Req_SiteTemplate,
|
||||||
Req_SiteTheme,
|
Req_SiteTheme,
|
||||||
Req_StartBuild,
|
Req_StartBuild,
|
||||||
|
Res_MySites,
|
||||||
Res_PublishLogs,
|
Res_PublishLogs,
|
||||||
Res_Site,
|
Res_Site,
|
||||||
Res_SeoAudit,
|
Res_SeoAudit,
|
||||||
@ -417,6 +419,44 @@ class SiteService:
|
|||||||
return _THEME_REJECTED
|
return _THEME_REJECTED
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
|
async def list_my_sites(self, user_info: UserInfo, pg: PageParams) -> Res_MySites:
|
||||||
|
"""로그인한 계정(회사)이 가진 사이트 전부.
|
||||||
|
|
||||||
|
사업장 목록(/v1/place/list)과 따로 두는 이유: 화면이 알아야 하는 건 '사업장이 있다'가 아니라
|
||||||
|
'발행돼 있나 · 주소가 뭔가 · 다시 구워야 하나'다."""
|
||||||
|
res = Res_MySites(page=pg.page, size=pg.size)
|
||||||
|
cid = uuid.UUID(user_info.company_id)
|
||||||
|
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
places.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.crud.list_company_sites(s, cid, pg.skip, pg.size),
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
res.sites = [self._my_site_row(place, site, built_at) for place, site, built_at in rows]
|
||||||
|
res.total = total
|
||||||
|
return res
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _my_site_row(place, site, built_at) -> MySiteData:
|
||||||
|
# ★ 재빌드 판별은 단건(get_site)과 같은 규칙이어야 한다 — 다르면 목록과 에디터가 다른 답을 한다.
|
||||||
|
changed = place.content_updated_at
|
||||||
|
return MySiteData(
|
||||||
|
place_id=place.place_id,
|
||||||
|
name=place.name,
|
||||||
|
category=place.category,
|
||||||
|
place_status=place.status,
|
||||||
|
road_address=place.road_address or place.address,
|
||||||
|
created_at=place.created_at,
|
||||||
|
site_id=getattr(site, "site_id", None),
|
||||||
|
status=getattr(site, "status", None),
|
||||||
|
domain=getattr(site, "domain", None),
|
||||||
|
template_id=getattr(site, "template_id", None),
|
||||||
|
published_at=getattr(site, "published_at", None),
|
||||||
|
needs_rebuild=bool(site is not None and changed and (built_at is None or changed > built_at)),
|
||||||
|
)
|
||||||
|
|
||||||
async def get_site(self, user_info: UserInfo, place_id: str) -> Res_Site:
|
async def get_site(self, user_info: UserInfo, place_id: str) -> Res_Site:
|
||||||
res = Res_Site()
|
res = Res_Site()
|
||||||
err_type, place = await self._load_place(user_info, place_id)
|
err_type, place = await self._load_place(user_info, place_id)
|
||||||
|
|||||||
90
solution/backend/tests/test_my_sites.py
Normal file
90
solution/backend/tests/test_my_sites.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
"""내 사이트 목록 — 로그인한 사장님이 자기 사이트 전부를 보는 화면의 뒷단.
|
||||||
|
|
||||||
|
이 경로가 절대 하면 안 되는 것:
|
||||||
|
- 사이트가 아직 없는 사업장을 빼는 것 — 위저드를 걸어오다 만 가게가 목록에서 사라지면
|
||||||
|
사장님은 그걸 다시 찾을 길이 없다(에디터 주소를 아무도 기억하지 않는다).
|
||||||
|
- 회사 스코프를 놓치는 것 — 남의 가게가 내 목록에 섞이면 그건 목록이 아니라 사고다.
|
||||||
|
- 단건(GET /v1/place/{id}/site)과 다른 재빌드 판정을 내는 것 — 목록과 에디터가 서로 다른
|
||||||
|
답을 하면 사장님은 어느 쪽을 믿을지 알 수 없다.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from common.enums import ErrorType, SiteStatus
|
||||||
|
|
||||||
|
|
||||||
|
async def _place(client, headers, name):
|
||||||
|
r = await client.post("/v1/place", headers=headers, json={"name": name, "category": 1})
|
||||||
|
return r.json()["place"]["place_id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _list(client, headers, **params):
|
||||||
|
return (await client.get("/v1/site/list", headers=headers, params=params)).json()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_place_without_site_is_still_listed(auth_headers, client):
|
||||||
|
"""검증: 사이트 행이 없는 사업장(위저드만 걸어온 것)도 목록에 나온다.
|
||||||
|
기대결과: 줄은 있고 site_id 는 없다 — 화면이 '만드는 중'으로 그릴 근거다."""
|
||||||
|
h = await auth_headers("my1")
|
||||||
|
await _place(client, h, "아직펜션")
|
||||||
|
|
||||||
|
body = await _list(client, h)
|
||||||
|
assert body["result"]["code"] == ErrorType.SUCCESS.value
|
||||||
|
assert body["total"] == 1
|
||||||
|
row = body["sites"][0]
|
||||||
|
assert row["name"] == "아직펜션"
|
||||||
|
assert row.get("site_id") is None
|
||||||
|
assert row.get("status") is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_site_row_is_joined_into_the_line(auth_headers, client):
|
||||||
|
"""검증: 사업장과 사이트가 한 줄로 합쳐져 온다(줄마다 사이트를 다시 묻지 않는다).
|
||||||
|
기대결과: 템플릿·주소가 목록에 그대로 보인다."""
|
||||||
|
h = await auth_headers("my2")
|
||||||
|
pid = await _place(client, h, "합쳐진펜션")
|
||||||
|
await client.post(f"/v1/place/{pid}/site/template", headers=h, json={"template_id": "stay-quiet-margin"})
|
||||||
|
await client.post(f"/v1/place/{pid}/site/slug", headers=h, json={"slug": "joined-stay"})
|
||||||
|
|
||||||
|
row = (await _list(client, h))["sites"][0]
|
||||||
|
assert row["site_id"]
|
||||||
|
assert row["template_id"] == "stay-quiet-margin"
|
||||||
|
assert row["domain"] == "joined-stay"
|
||||||
|
assert row["status"] == SiteStatus.DRAFT.value
|
||||||
|
|
||||||
|
|
||||||
|
async def test_other_company_sites_are_not_listed(auth_headers, client, other_company_id):
|
||||||
|
"""검증: 회사(테넌트) 스코프. 남의 회사 사업장은 보이지 않는다.
|
||||||
|
기대결과: 각자 자기 것만 1건."""
|
||||||
|
mine = await auth_headers("my3")
|
||||||
|
theirs = await auth_headers("my3b", other_company_id)
|
||||||
|
await _place(client, mine, "내펜션")
|
||||||
|
await _place(client, theirs, "남의펜션")
|
||||||
|
|
||||||
|
assert [r["name"] for r in (await _list(client, mine))["sites"]] == ["내펜션"]
|
||||||
|
assert [r["name"] for r in (await _list(client, theirs))["sites"]] == ["남의펜션"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_needs_rebuild_matches_the_single_site_answer(auth_headers, client, db_engine):
|
||||||
|
"""검증: 재빌드 판정이 단건 조회와 같은 답을 낸다.
|
||||||
|
기대결과: 노출값이 바뀐 사업장은 목록에서도 needs_rebuild=true."""
|
||||||
|
h = await auth_headers("my4")
|
||||||
|
pid = await _place(client, h, "고친펜션")
|
||||||
|
# 템플릿 저장이 사이트 행을 만든다. 그 뒤 노출값이 바뀐 것으로 표시한다.
|
||||||
|
await client.post(f"/v1/place/{pid}/site/template", headers=h, json={"template_id": "t"})
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
text("UPDATE places SET content_updated_at = now() WHERE place_id = :pid"),
|
||||||
|
{"pid": uuid.UUID(pid)},
|
||||||
|
)
|
||||||
|
|
||||||
|
single = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()
|
||||||
|
row = (await _list(client, h))["sites"][0]
|
||||||
|
assert row["needs_rebuild"] is True
|
||||||
|
assert row["needs_rebuild"] == single["needs_rebuild"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_requires_login(client):
|
||||||
|
"""검증: 내 것을 보는 화면이므로 토큰 없이는 열리지 않는다.
|
||||||
|
기대결과: 401."""
|
||||||
|
assert (await client.get("/v1/site/list")).status_code == 401
|
||||||
@ -54,6 +54,7 @@ export * from './listFactsParams';
|
|||||||
export * from './listFaqsParams';
|
export * from './listFaqsParams';
|
||||||
export * from './listLinksParams';
|
export * from './listLinksParams';
|
||||||
export * from './listMediaParams';
|
export * from './listMediaParams';
|
||||||
|
export * from './listMySitesParams';
|
||||||
export * from './listPlacesParams';
|
export * from './listPlacesParams';
|
||||||
export * from './localContentData';
|
export * from './localContentData';
|
||||||
export * from './localContentDataBody';
|
export * from './localContentDataBody';
|
||||||
@ -77,6 +78,14 @@ export * from './mediaDataUnitId';
|
|||||||
export * from './mediaDataVisionConfidence';
|
export * from './mediaDataVisionConfidence';
|
||||||
export * from './mediaDataWidth';
|
export * from './mediaDataWidth';
|
||||||
export * from './mediaStatus';
|
export * from './mediaStatus';
|
||||||
|
export * from './mySiteData';
|
||||||
|
export * from './mySiteDataCreatedAt';
|
||||||
|
export * from './mySiteDataDomain';
|
||||||
|
export * from './mySiteDataPublishedAt';
|
||||||
|
export * from './mySiteDataRoadAddress';
|
||||||
|
export * from './mySiteDataSiteId';
|
||||||
|
export * from './mySiteDataStatus';
|
||||||
|
export * from './mySiteDataTemplateId';
|
||||||
export * from './placeCandidate';
|
export * from './placeCandidate';
|
||||||
export * from './placeCandidateAddress';
|
export * from './placeCandidateAddress';
|
||||||
export * from './placeCandidateCategoryName';
|
export * from './placeCandidateCategoryName';
|
||||||
@ -215,6 +224,8 @@ export * from './resMeMsg';
|
|||||||
export * from './resMeName';
|
export * from './resMeName';
|
||||||
export * from './resMediaList';
|
export * from './resMediaList';
|
||||||
export * from './resMediaListMsg';
|
export * from './resMediaListMsg';
|
||||||
|
export * from './resMySites';
|
||||||
|
export * from './resMySitesMsg';
|
||||||
export * from './resPlace';
|
export * from './resPlace';
|
||||||
export * from './resPlaceList';
|
export * from './resPlaceList';
|
||||||
export * from './resPlaceListMsg';
|
export * from './resPlaceListMsg';
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Web4Ai API
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ListMySitesParams = {
|
||||||
|
/**
|
||||||
|
* @minimum 1
|
||||||
|
*/
|
||||||
|
page?: number;
|
||||||
|
/**
|
||||||
|
* @minimum 1
|
||||||
|
* @maximum 100
|
||||||
|
*/
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
36
solution/frontend/src/api/generated/model/mySiteData.ts
Normal file
36
solution/frontend/src/api/generated/model/mySiteData.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Web4Ai API
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { PlaceCategory } from './placeCategory';
|
||||||
|
import type { PlaceStatus } from './placeStatus';
|
||||||
|
import type { MySiteDataRoadAddress } from './mySiteDataRoadAddress';
|
||||||
|
import type { MySiteDataCreatedAt } from './mySiteDataCreatedAt';
|
||||||
|
import type { MySiteDataSiteId } from './mySiteDataSiteId';
|
||||||
|
import type { MySiteDataStatus } from './mySiteDataStatus';
|
||||||
|
import type { MySiteDataDomain } from './mySiteDataDomain';
|
||||||
|
import type { MySiteDataTemplateId } from './mySiteDataTemplateId';
|
||||||
|
import type { MySiteDataPublishedAt } from './mySiteDataPublishedAt';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site).
|
||||||
|
|
||||||
|
★ render 는 여기 없다 — 보고서 **파일**을 읽는 값이라 줄 수만큼 파일 IO 가 된다(단건이 소유).
|
||||||
|
★ site_id 아래가 전부 None 이면 아직 사이트가 없는 사업장이다.
|
||||||
|
*/
|
||||||
|
export interface MySiteData {
|
||||||
|
place_id: string;
|
||||||
|
name: string;
|
||||||
|
category: PlaceCategory;
|
||||||
|
place_status: PlaceStatus;
|
||||||
|
road_address?: MySiteDataRoadAddress;
|
||||||
|
created_at?: MySiteDataCreatedAt;
|
||||||
|
site_id?: MySiteDataSiteId;
|
||||||
|
status?: MySiteDataStatus;
|
||||||
|
domain?: MySiteDataDomain;
|
||||||
|
template_id?: MySiteDataTemplateId;
|
||||||
|
published_at?: MySiteDataPublishedAt;
|
||||||
|
needs_rebuild?: boolean;
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Web4Ai API
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MySiteDataCreatedAt = string | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Web4Ai API
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MySiteDataDomain = string | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Web4Ai API
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MySiteDataPublishedAt = string | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Web4Ai API
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MySiteDataRoadAddress = string | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Web4Ai API
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MySiteDataSiteId = string | null;
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Web4Ai API
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { SiteStatus } from './siteStatus';
|
||||||
|
|
||||||
|
export type MySiteDataStatus = SiteStatus | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Web4Ai API
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MySiteDataTemplateId = string | null;
|
||||||
@ -16,9 +16,9 @@ import type { ReqSiteThemeTheme } from './reqSiteThemeTheme';
|
|||||||
떨어뜨리므로 화면은 깨지지 않는다.
|
떨어뜨리므로 화면은 깨지지 않는다.
|
||||||
|
|
||||||
★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다:
|
★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다:
|
||||||
{"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}}
|
{"theme": {"colors": {...}, "fontStyle": "...", "look": {...}, "colorPaletteId": "...", "sections": [...]}}
|
||||||
sections 는 {id, name, enabled, locked, variantId?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
|
sections 는 {id, name, enabled, locked, variantId?, body?, data?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
|
||||||
(별도 order 필드가 없다). variantId 는 고른 게 있을 때만 키가 붙는다.
|
(별도 order 필드가 없다). variantId·본문 body·붙여넣기 JSON data 는 값이 있을 때만 키가 붙는다.
|
||||||
pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 —
|
pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 —
|
||||||
서버는 배달부지 심판이 아니다.
|
서버는 배달부지 심판이 아니다.
|
||||||
|
|
||||||
|
|||||||
18
solution/frontend/src/api/generated/model/resMySites.ts
Normal file
18
solution/frontend/src/api/generated/model/resMySites.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Web4Ai API
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { ErrorInfo } from './errorInfo';
|
||||||
|
import type { ResMySitesMsg } from './resMySitesMsg';
|
||||||
|
import type { MySiteData } from './mySiteData';
|
||||||
|
|
||||||
|
export interface ResMySites {
|
||||||
|
result?: ErrorInfo;
|
||||||
|
msg?: ResMySitesMsg;
|
||||||
|
total?: number;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
sites?: MySiteData[];
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Web4Ai API
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ResMySitesMsg = string | null;
|
||||||
@ -26,11 +26,13 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
CheckSlugParams,
|
CheckSlugParams,
|
||||||
HTTPValidationError,
|
HTTPValidationError,
|
||||||
|
ListMySitesParams,
|
||||||
ReqSiteSlug,
|
ReqSiteSlug,
|
||||||
ReqSiteStatus,
|
ReqSiteStatus,
|
||||||
ReqSiteTemplate,
|
ReqSiteTemplate,
|
||||||
ReqSiteTheme,
|
ReqSiteTheme,
|
||||||
ReqStartBuild,
|
ReqStartBuild,
|
||||||
|
ResMySites,
|
||||||
ResPublishLogs,
|
ResPublishLogs,
|
||||||
ResSeoAudit,
|
ResSeoAudit,
|
||||||
ResSite,
|
ResSite,
|
||||||
@ -467,7 +469,7 @@ export const useSetTemplate = <TError = void | HTTPValidationError,
|
|||||||
return useMutation(mutationOptions, queryClient);
|
return useMutation(mutationOptions, queryClient);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* 에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: {"theme":{"colors":{...},"fontStyle":"...","colorPaletteId":"...","sections":[{"id","name","enabled","locked","variantId"}]}}. ★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). ★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. 직렬화 크기(64KB)만 막는다. ★ templateId 는 여기 담지 않는다 — sites.template_id 와 POST /template 이 담당한다. ★ colorPaletteId 는 에디터 복원 전용이라 저장·반환만 하고 발행 payload 에는 싣지 않는다. ★ 빈 값({})을 보내면 NULL 로 되돌아가 업종 기본 색·서체·섹션으로 떨어진다. ★ 템플릿과 같이 발행 뒤에도 바꿀 수 있다(디자인이 바뀌어도 URL 은 그대로다). 이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다(needs_rebuild=true).
|
* 에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: {"theme":{"colors":{...},"fontStyle":"...","look":{...},"colorPaletteId":"...","sections":[{"id","name","enabled","locked","variantId","body","data"}]}}. ★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). ★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. 직렬화 크기(64KB)만 막는다. ★ templateId 는 여기 담지 않는다 — sites.template_id 와 POST /template 이 담당한다. ★ colorPaletteId 는 에디터 복원 전용이라 저장·반환만 하고 발행 payload 에는 싣지 않는다. ★ 빈 값({})을 보내면 NULL 로 되돌아가 업종 기본 색·서체·섹션으로 떨어진다. ★ 템플릿과 같이 발행 뒤에도 바꿀 수 있다(디자인이 바뀌어도 URL 은 그대로다). 이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다(needs_rebuild=true).
|
||||||
* @summary 디자인(색·서체·섹션) 저장
|
* @summary 디자인(색·서체·섹션) 저장
|
||||||
*/
|
*/
|
||||||
export const setTheme = (
|
export const setTheme = (
|
||||||
@ -849,4 +851,97 @@ export const useChangeStatus = <TError = void | HTTPValidationError,
|
|||||||
|
|
||||||
return useMutation(mutationOptions, queryClient);
|
return useMutation(mutationOptions, queryClient);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 로그인한 계정(회사)이 가진 사이트 전부. 아직 사이트가 만들어지지 않은 사업장도 site_id=null 로 함께 내려간다 — 위저드를 걸어오다 만 것을 목록에서 잃지 않게 한다.
|
||||||
|
* @summary 내 사이트 목록
|
||||||
|
*/
|
||||||
|
export const listMySites = (
|
||||||
|
params?: ListMySitesParams,
|
||||||
|
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
|
||||||
|
|
||||||
|
return customFetch<ResMySites>(
|
||||||
|
{url: `/v1/site/list`, method: 'GET',
|
||||||
|
params, signal
|
||||||
|
},
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getListMySitesQueryKey = (params?: ListMySitesParams,) => {
|
||||||
|
return [
|
||||||
|
`/v1/site/list`, ...(params ? [params]: [])
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getListMySitesQueryOptions = <TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getListMySitesQueryKey(params);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof listMySites>>> = ({ signal }) => listMySites(params, requestOptions, signal);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListMySitesQueryResult = NonNullable<Awaited<ReturnType<typeof listMySites>>>
|
||||||
|
export type ListMySitesQueryError = void | HTTPValidationError
|
||||||
|
|
||||||
|
|
||||||
|
export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(
|
||||||
|
params: undefined | ListMySitesParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>> & Pick<
|
||||||
|
DefinedInitialDataOptions<
|
||||||
|
Awaited<ReturnType<typeof listMySites>>,
|
||||||
|
TError,
|
||||||
|
Awaited<ReturnType<typeof listMySites>>
|
||||||
|
> , 'initialData'
|
||||||
|
>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(
|
||||||
|
params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>> & Pick<
|
||||||
|
UndefinedInitialDataOptions<
|
||||||
|
Awaited<ReturnType<typeof listMySites>>,
|
||||||
|
TError,
|
||||||
|
Awaited<ReturnType<typeof listMySites>>
|
||||||
|
> , 'initialData'
|
||||||
|
>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(
|
||||||
|
params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
/**
|
||||||
|
* @summary 내 사이트 목록
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(
|
||||||
|
params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||||
|
|
||||||
|
const queryOptions = getListMySitesQueryOptions(params,options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||||
|
|
||||||
|
query.queryKey = queryOptions.queryKey ;
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user