From 479edf94034b6d25bd718f2c4beb901ba5f2b36c Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 2 Sep 2026 22:37:53 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/backend:=20=EB=82=B4=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=8A=B8=20=EB=AA=A9=EB=A1=9D=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=E2=80=94=20places=20LEF?= =?UTF-8?q?T=20JOIN=20sites=20=EB=8B=A8=EC=9D=BC=20=EC=A7=88=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그인한 사장님이 자기 사이트를 볼 화면이 없었다. 사이트는 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) --- solution/backend/crud/site_crud.py | 36 ++++++- solution/backend/router/router.py | 1 + solution/backend/router/v1/site/protocol.py | 39 +++++++- solution/backend/router/v1/site/site.py | 22 ++++- solution/backend/services/site_service.py | 42 +++++++- solution/backend/tests/test_my_sites.py | 90 +++++++++++++++++ .../frontend/src/api/generated/model/index.ts | 11 +++ .../api/generated/model/listMySitesParams.ts | 18 ++++ .../src/api/generated/model/mySiteData.ts | 36 +++++++ .../generated/model/mySiteDataCreatedAt.ts | 8 ++ .../api/generated/model/mySiteDataDomain.ts | 8 ++ .../generated/model/mySiteDataPublishedAt.ts | 8 ++ .../generated/model/mySiteDataRoadAddress.ts | 8 ++ .../api/generated/model/mySiteDataSiteId.ts | 8 ++ .../api/generated/model/mySiteDataStatus.ts | 9 ++ .../generated/model/mySiteDataTemplateId.ts | 8 ++ .../src/api/generated/model/reqSiteTheme.ts | 6 +- .../src/api/generated/model/resMySites.ts | 18 ++++ .../src/api/generated/model/resMySitesMsg.ts | 8 ++ .../frontend/src/api/generated/site/site.ts | 99 ++++++++++++++++++- 20 files changed, 473 insertions(+), 10 deletions(-) create mode 100644 solution/backend/tests/test_my_sites.py create mode 100644 solution/frontend/src/api/generated/model/listMySitesParams.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteData.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteDataCreatedAt.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteDataDomain.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteDataPublishedAt.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteDataRoadAddress.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteDataSiteId.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteDataStatus.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteDataTemplateId.ts create mode 100644 solution/frontend/src/api/generated/model/resMySites.ts create mode 100644 solution/frontend/src/api/generated/model/resMySitesMsg.ts diff --git a/solution/backend/crud/site_crud.py b/solution/backend/crud/site_crud.py index 926712c..9910803 100644 --- a/solution/backend/crud/site_crud.py +++ b/solution/backend/crud/site_crud.py @@ -5,7 +5,7 @@ from sqlalchemy import and_, func, select, update from sqlalchemy.ext.asyncio import AsyncSession 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.logger import LOG 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]: pass + @abstractmethod + async def list_company_sites(self, cdb: AsyncSession, company_id, skip, limit) -> Tuple[ErrorType, list, int]: + pass + @abstractmethod async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]: pass @@ -85,6 +89,36 @@ class SiteCRUD(ISiteCRUD): LOG.e_no_callstack(ex) 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]: """후보 주소들 중 이미 쓰이는 것만 추린다. 대안 제안이 후보마다 왕복하지 않게 한 번에 본다.""" try: diff --git a/solution/backend/router/router.py b/solution/backend/router/router.py index 462f484..1b0aaaf 100644 --- a/solution/backend/router/router.py +++ b/solution/backend/router/router.py @@ -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.job.job.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.weather_router) diff --git a/solution/backend/router/v1/site/protocol.py b/solution/backend/router/v1/site/protocol.py index 6574fb1..8687ea7 100644 --- a/solution/backend/router/v1/site/protocol.py +++ b/solution/backend/router/v1/site/protocol.py @@ -4,8 +4,17 @@ from typing import Any, Optional from pydantic import ConfigDict -from common.enums import BuildStatus, JobStatus, PublishAction, PublishRejectReason, PublishResult, SiteStatus -from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol +from common.enums import ( + BuildStatus, + JobStatus, + PlaceCategory, + PlaceStatus, + PublishAction, + PublishRejectReason, + PublishResult, + SiteStatus, +) +from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol class SiteProtocol(WebPacketProtocol): @@ -56,6 +65,32 @@ class SiteData(WebPacketProtocol): 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): model_config = ConfigDict(from_attributes=True) diff --git a/solution/backend/router/v1/site/site.py b/solution/backend/router/v1/site/site.py index d34fd59..b9cb12f 100644 --- a/solution/backend/router/v1/site/site.py +++ b/solution/backend/router/v1/site/site.py @@ -2,7 +2,7 @@ from uuid import UUID 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 services.site_service import SiteService from .protocol import ( @@ -11,6 +11,7 @@ from .protocol import ( Req_SiteTemplate, Req_SiteTheme, Req_StartBuild, + Res_MySites, Res_PublishLogs, Res_Site, Res_SeoAudit, @@ -23,6 +24,25 @@ from .protocol import ( # 사이트/발행 라우터. 사업장 하위 리소스이며 회사 스코프는 service 가 사업장 조회로 강제한다. 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( path="", diff --git a/solution/backend/services/site_service.py b/solution/backend/services/site_service.py index 5b1e89e..3103797 100644 --- a/solution/backend/services/site_service.py +++ b/solution/backend/services/site_service.py @@ -16,12 +16,13 @@ from common.enums import ( SiteStatus, ) 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 crud.job_crud import JobQueue from crud.place_crud import PlaceCRUD from crud.site_crud import ISiteCRUD, SiteCRUD from router.v1.site.protocol import ( + MySiteData, PublishLogData, RenderStatusData, Req_SiteSlug, @@ -29,6 +30,7 @@ from router.v1.site.protocol import ( Req_SiteTemplate, Req_SiteTheme, Req_StartBuild, + Res_MySites, Res_PublishLogs, Res_Site, Res_SeoAudit, @@ -417,6 +419,44 @@ class SiteService: return _THEME_REJECTED 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: res = Res_Site() err_type, place = await self._load_place(user_info, place_id) diff --git a/solution/backend/tests/test_my_sites.py b/solution/backend/tests/test_my_sites.py new file mode 100644 index 0000000..3ad532b --- /dev/null +++ b/solution/backend/tests/test_my_sites.py @@ -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 diff --git a/solution/frontend/src/api/generated/model/index.ts b/solution/frontend/src/api/generated/model/index.ts index 2851bae..87864fa 100644 --- a/solution/frontend/src/api/generated/model/index.ts +++ b/solution/frontend/src/api/generated/model/index.ts @@ -54,6 +54,7 @@ export * from './listFactsParams'; export * from './listFaqsParams'; export * from './listLinksParams'; export * from './listMediaParams'; +export * from './listMySitesParams'; export * from './listPlacesParams'; export * from './localContentData'; export * from './localContentDataBody'; @@ -77,6 +78,14 @@ export * from './mediaDataUnitId'; export * from './mediaDataVisionConfidence'; export * from './mediaDataWidth'; 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 './placeCandidateAddress'; export * from './placeCandidateCategoryName'; @@ -215,6 +224,8 @@ export * from './resMeMsg'; export * from './resMeName'; export * from './resMediaList'; export * from './resMediaListMsg'; +export * from './resMySites'; +export * from './resMySitesMsg'; export * from './resPlace'; export * from './resPlaceList'; export * from './resPlaceListMsg'; diff --git a/solution/frontend/src/api/generated/model/listMySitesParams.ts b/solution/frontend/src/api/generated/model/listMySitesParams.ts new file mode 100644 index 0000000..9e04322 --- /dev/null +++ b/solution/frontend/src/api/generated/model/listMySitesParams.ts @@ -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; +}; diff --git a/solution/frontend/src/api/generated/model/mySiteData.ts b/solution/frontend/src/api/generated/model/mySiteData.ts new file mode 100644 index 0000000..cb463b1 --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteData.ts @@ -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; +} diff --git a/solution/frontend/src/api/generated/model/mySiteDataCreatedAt.ts b/solution/frontend/src/api/generated/model/mySiteDataCreatedAt.ts new file mode 100644 index 0000000..3ae245a --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataCreatedAt.ts @@ -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; diff --git a/solution/frontend/src/api/generated/model/mySiteDataDomain.ts b/solution/frontend/src/api/generated/model/mySiteDataDomain.ts new file mode 100644 index 0000000..f9010e9 --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataDomain.ts @@ -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; diff --git a/solution/frontend/src/api/generated/model/mySiteDataPublishedAt.ts b/solution/frontend/src/api/generated/model/mySiteDataPublishedAt.ts new file mode 100644 index 0000000..1b2b7b5 --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataPublishedAt.ts @@ -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; diff --git a/solution/frontend/src/api/generated/model/mySiteDataRoadAddress.ts b/solution/frontend/src/api/generated/model/mySiteDataRoadAddress.ts new file mode 100644 index 0000000..2f5fd1a --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataRoadAddress.ts @@ -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; diff --git a/solution/frontend/src/api/generated/model/mySiteDataSiteId.ts b/solution/frontend/src/api/generated/model/mySiteDataSiteId.ts new file mode 100644 index 0000000..56035c0 --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataSiteId.ts @@ -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; diff --git a/solution/frontend/src/api/generated/model/mySiteDataStatus.ts b/solution/frontend/src/api/generated/model/mySiteDataStatus.ts new file mode 100644 index 0000000..66b4a78 --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataStatus.ts @@ -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; diff --git a/solution/frontend/src/api/generated/model/mySiteDataTemplateId.ts b/solution/frontend/src/api/generated/model/mySiteDataTemplateId.ts new file mode 100644 index 0000000..8bd2686 --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataTemplateId.ts @@ -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; diff --git a/solution/frontend/src/api/generated/model/reqSiteTheme.ts b/solution/frontend/src/api/generated/model/reqSiteTheme.ts index f6fa1ae..eff465b 100644 --- a/solution/frontend/src/api/generated/model/reqSiteTheme.ts +++ b/solution/frontend/src/api/generated/model/reqSiteTheme.ts @@ -16,9 +16,9 @@ import type { ReqSiteThemeTheme } from './reqSiteThemeTheme'; 떨어뜨리므로 화면은 깨지지 않는다. ★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다: - {"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}} - sections 는 {id, name, enabled, locked, variantId?} 의 목록이고 **배열 순서가 곧 섹션 순서**다 - (별도 order 필드가 없다). variantId 는 고른 게 있을 때만 키가 붙는다. + {"theme": {"colors": {...}, "fontStyle": "...", "look": {...}, "colorPaletteId": "...", "sections": [...]}} + sections 는 {id, name, enabled, locked, variantId?, body?, data?} 의 목록이고 **배열 순서가 곧 섹션 순서**다 + (별도 order 필드가 없다). variantId·본문 body·붙여넣기 JSON data 는 값이 있을 때만 키가 붙는다. pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 — 서버는 배달부지 심판이 아니다. diff --git a/solution/frontend/src/api/generated/model/resMySites.ts b/solution/frontend/src/api/generated/model/resMySites.ts new file mode 100644 index 0000000..f8c5655 --- /dev/null +++ b/solution/frontend/src/api/generated/model/resMySites.ts @@ -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[]; +} diff --git a/solution/frontend/src/api/generated/model/resMySitesMsg.ts b/solution/frontend/src/api/generated/model/resMySitesMsg.ts new file mode 100644 index 0000000..4cb7a4c --- /dev/null +++ b/solution/frontend/src/api/generated/model/resMySitesMsg.ts @@ -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; diff --git a/solution/frontend/src/api/generated/site/site.ts b/solution/frontend/src/api/generated/site/site.ts index 9cdbd7c..c44b8ee 100644 --- a/solution/frontend/src/api/generated/site/site.ts +++ b/solution/frontend/src/api/generated/site/site.ts @@ -26,11 +26,13 @@ import type { import type { CheckSlugParams, HTTPValidationError, + ListMySitesParams, ReqSiteSlug, ReqSiteStatus, ReqSiteTemplate, ReqSiteTheme, ReqStartBuild, + ResMySites, ResPublishLogs, ResSeoAudit, ResSite, @@ -467,7 +469,7 @@ export const useSetTemplate = ,signal?: AbortSignal +) => { + + + return customFetch( + {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 = >, TError = void | HTTPValidationError>(params?: ListMySitesParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListMySitesQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => listMySites(params, requestOptions, signal); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type ListMySitesQueryResult = NonNullable>> +export type ListMySitesQueryError = void | HTTPValidationError + + +export function useListMySites>, TError = void | HTTPValidationError>( + params: undefined | ListMySitesParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useListMySites>, TError = void | HTTPValidationError>( + params?: ListMySitesParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useListMySites>, TError = void | HTTPValidationError>( + params?: ListMySitesParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 내 사이트 목록 + */ + +export function useListMySites>, TError = void | HTTPValidationError>( + params?: ListMySitesParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getListMySitesQueryOptions(params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + +