diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index bbd33ea..bf68dea 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,49 @@ --- +## 2026-09-08 — 내 사이트 목록에 썸네일·주소·시각 — 발행할 때마다 그림이 바뀐다 + +**무슨 일** +목록 줄이 아이콘·상호·배지·주소 넷뿐이었다. 서버는 이미 `road_address`·`created_at`· +`published_at` 을 주고 있는데 화면이 안 썼다. 한 계정에 '버터브루' 가 4줄 있으면 어느 게 +어느 건지 가릴 단서가 화면에 하나도 없다. + +**리서치** (Wix · 아임웹) +- Wix `My Sites` 줄에 보이는 건 이름·URL·Premium·협업자뿐이고 **썸네일도 수정일도 없다.** + 대신 Sites API 문서가 "이렇게 그려라" 로 지목한 조합은 `displayName · thumbnail · viewUrl · + editUrl` 이고, 정렬은 최근 수정순이다 — 화면보다 API 권고 쪽이 우리 상황에 맞다. +- 아임웹 내사이트는 기본 정보 + 액션(관리자 접속·복제·템플릿 변경·소유권 이전), + 리셀러 목록은 **만료일**을 목록에서 바로 본다. 방문자·주문 숫자는 목록이 아니라 + 사이트 안 대시보드에 있다. +- 공통: 목록은 **구분 · 상태 · 여는 길** 셋만 한다. 그리고 **둘 다 생성일을 안 쓴다** — + 구분은 그림·주소·이름이 하고, 시각은 "마지막으로 뭔가 한 시각" 이 쓰인다. + +**바꾼 것** +- `MySiteData.thumbnail_url` 추가(`site_service._my_site_row`). 목록이 사이트 행을 이미 + 조인해 읽고 있어서 쿼리는 그대로다 +- 줄 앞에 썸네일. 없으면 업종 아이콘으로 떨어지고, 로드 실패해도 아이콘으로 되돌린다 — + 블롭이 지워진 옛 주소에서 깨진 그림이 뜨는 것보다 낫다 +- 줄 아래 한 칸: `도로명 주소 · 시각`. 시각은 **발행됐으면 발행일, 아니면 만든 날** 하나만 + 쓴다(위 리서치의 결론). 올해면 연도를 뗀다 — 줄이 좁아 주소가 먼저 잘린다 + +**썸네일이 발행마다 바뀌게** (`site_thumbnail.public_url`) +블롭 이름은 `thumbs/.` 로 고정이고 내용만 `overwrite=True` 로 덮어쓴다. 그래서 +주소가 안 변했고, 사장님이 사진을 바꿔 재발행해도 **캐시에 남은 지난 그림**이 계속 보였다 +(`CACHE_CONTROL` 60초만으로는 그 60초를 못 막는다). 주소에 `?v=<발행 버전>` 을 붙인다. +→ 이름에 버전을 넣지 않는 이유: 사이트당 블롭이 발행 횟수만큼 쌓이는데 지우는 코드가 없다. +→ `scripts/backfill_thumbnails.py` 처럼 그 시점 버전이 없는 경로는 `version=None` 으로 + 그냥 붙이지 않는다. + +**아직 그림이 한 장도 없다** — 로컬·현재 DB 의 사이트 39개 전부 `thumbnail_url` 이 NULL 이다. +버그가 아니라 `AZURE_STORAGE_CONNECTION_STRING` 이 비어 `site_thumbnail.is_configured()` 가 +False 라서다(썸네일은 Blob 에만 올라간다). 키를 채우면 다음 발행부터 채워진다. + +**검증** — 백엔드 전체 통과. 목록 줄이 주소·생성일·썸네일을 들고 오는지, 발행 안 한 줄에 +`thumbnail_url` 키가 아예 없는지, **재발행하면 `?v=1` → `?v=2` 로 주소가 바뀌는지** 4건 추가. +프론트 `tsc + eslint` 통과. + +--- + ## 2026-09-08 — 회사(테넌트)를 걷어냈다 — 사장님 계정이 곧 스코프다 **무슨 일** diff --git a/solution/backend/router/v1/site/protocol.py b/solution/backend/router/v1/site/protocol.py index 573df50..816980c 100644 --- a/solution/backend/router/v1/site/protocol.py +++ b/solution/backend/router/v1/site/protocol.py @@ -85,6 +85,8 @@ class MySiteData(WebPacketProtocol): domain: Optional[str] = None template_id: Optional[str] = None published_at: Optional[datetime] = None + # 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url). + thumbnail_url: Optional[str] = None # 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다. needs_rebuild: bool = False @@ -235,7 +237,7 @@ class ShowcaseItem(WebPacketProtocol): """랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.** ★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다. - place_id·company_id·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과 + place_id·소유자·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과 발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다.""" name: str diff --git a/solution/backend/services/build_service.py b/solution/backend/services/build_service.py index 5869167..e26a95c 100644 --- a/solution/backend/services/build_service.py +++ b/solution/backend/services/build_service.py @@ -97,18 +97,18 @@ async def _log(site_id, version_id, action: PublishAction, result: PublishResult async def run_build(job: dict) -> dict: - """BUILD 잡 핸들러. payload: {place_id, company_id, publish?, requested_by?} + """BUILD 잡 핸들러. payload: {place_id, owner_user_id, publish?, requested_by?} publish=True 면 게이트를 통과했을 때 바로 발행까지 한다.""" payload = job["payload"] place_id = payload["place_id"] - company_id = payload["company_id"] + owner_user_id = payload["owner_user_id"] want_publish = bool(payload.get("publish")) err, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)), + lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)), ) if err != ErrorType.SUCCESS or place is None: raise BuildAborted(f"사업장을 찾을 수 없다: {place_id}") @@ -246,7 +246,7 @@ async def run_build(job: dict) -> dict: result["azure"] = azure_result # ★ 페이지가 실제로 올라간 뒤에 썸네일을 남긴다 — 없는 페이지의 그림을 쇼케이스에 걸지 않는다. # 실패해도 발행은 성공이다(스크린샷이 아니라 대표 사진이라, 없으면 글자 카드로 떨어진다). - thumbnail_url = await site_thumbnail.store(slug, snapshot) + thumbnail_url = await site_thumbnail.store(slug, snapshot, version_no) if thumbnail_url: result["thumbnail_url"] = thumbnail_url # ★ 정적 파일이 올라간 **뒤에** 통보한다. 먼저 알리면 크롤러가 옛 파일을 가져간다. @@ -293,7 +293,7 @@ async def run_build(job: dict) -> dict: await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), lambda s: _place_crud.update_place( - s, uuid.UUID(company_id), uuid.UUID(place_id), {"status": PlaceStatus.PUBLISHED.value} + s, uuid.UUID(owner_user_id), uuid.UUID(place_id), {"status": PlaceStatus.PUBLISHED.value} ), ) await _log(site.site_id, version.site_version_id, PublishAction.PUBLISH, PublishResult.SUCCESS, None, diff --git a/solution/backend/services/site_service.py b/solution/backend/services/site_service.py index 3103797..a5fc221 100644 --- a/solution/backend/services/site_service.py +++ b/solution/backend/services/site_service.py @@ -104,7 +104,7 @@ class SiteService: err_type, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)), + lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)), ) if err_type != ErrorType.SUCCESS: return ErrorType.PLACE_NOT_FOUND, None @@ -420,16 +420,16 @@ class SiteService: 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) + uid = uuid.UUID(user_info.user_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), + lambda s: self.crud.list_owner_sites(s, uid, pg.skip, pg.size), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) @@ -454,6 +454,7 @@ class SiteService: domain=getattr(site, "domain", None), template_id=getattr(site, "template_id", None), published_at=getattr(site, "published_at", None), + thumbnail_url=getattr(site, "thumbnail_url", None), needs_rebuild=bool(site is not None and changed and (built_at is None or changed > built_at)), ) @@ -526,7 +527,7 @@ class SiteService: job_id, created = await enqueue_job( self.queue, JobType.BUILD, { - "place_id": place_id, "company_id": user_info.company_id, + "place_id": place_id, "owner_user_id": user_info.user_id, "publish": req.publish, "requested_by": user_info.user_id, }, dedupe_key=f"build:{place_id}", diff --git a/solution/backend/services/site_thumbnail.py b/solution/backend/services/site_thumbnail.py index 92b9e24..376b476 100644 --- a/solution/backend/services/site_thumbnail.py +++ b/solution/backend/services/site_thumbnail.py @@ -18,7 +18,7 @@ import asyncio import os import httpx -from azure.storage.blob import BlobServiceClient, ContentSettings +from azure.storage.blob import BlobClient, BlobServiceClient, ContentSettings from common.logger import LOG from services import azure_static, site_payload @@ -46,19 +46,71 @@ MAX_BYTES = 5 * 1024 * 1024 CACHE_CONTROL = "public, max-age=60, must-revalidate" +# ── 썸네일 전용 저장소 ──────────────────────────────────────────────────────── +# ★ 왜 스위치를 따로 두나 +# 원래는 `AZURE_STORAGE_CONNECTION_STRING` 하나가 사이트 업로드(azure_static)와 썸네일을 +# **같이** 켰다. 그런데 그 둘은 필요한 저장소가 다르다 — 사이트는 정적 호스팅(`$web`)이고 +# 썸네일은 그냥 이미지 버킷이면 된다. 하나로 묶어 두면 "썸네일 좀 보자" 고 키를 꽂는 순간 +# **발행할 때마다 사이트 전체가 그 버킷에 업로드된다.** 지금 우리가 빌려 쓰는 곳은 +# negodata·infinith 와 공용인 미디어 컨테이너라 그렇게 되면 안 된다. +# +# ★ 값 출처: o2o-negosium/negodata/backend/config/config.local.toml `[StorageConfig]`. +# 같은 계정/컨테이너를 root 디렉터리로만 가른다(negodata/ · infinith/ · web4ai/) — +# 그쪽 관례를 그대로 따른 것이지 우리 계정이 아니다. +# +# ⚠️ **임시다.** 이 컨테이너는 정적 사이트 호스팅이 아니라 발행본을 못 올린다. 그리고 SAS 가 +# 컨테이너 전체에 racwdl(삭제 포함)이라, 남의 프로젝트 파일에 닿을 수 있는 자리다 — +# web4ai 전용 스토리지 계정이 생기면 이 블록을 걷고 azure_static 쪽으로 되돌린다. +_BASE_ENV = "THUMBNAIL_BLOB_BASE_URL" # https://<계정>.blob.core.windows.net/<컨테이너> +_SAS_ENV = "THUMBNAIL_BLOB_SAS_TOKEN" # `?sv=...` (앞의 물음표는 있어도 없어도 된다) +_ROOT_ENV = "THUMBNAIL_BLOB_ROOT" # 컨테이너 안에서 우리가 쓰는 디렉터리. 예: web4ai + + +def _blob_base() -> str: + return os.environ.get(_BASE_ENV, "").strip().rstrip("/") + + +def _blob_sas() -> str: + return os.environ.get(_SAS_ENV, "").strip().lstrip("?") + + +def _blob_root() -> str: + return os.environ.get(_ROOT_ENV, "").strip().strip("/") + + +def uses_blob_store() -> bool: + """썸네일 전용 저장소를 쓰는가. 아니면 예전대로 azure_static 설정을 따른다.""" + return bool(_blob_base() and _blob_sas()) + + def is_configured() -> bool: - return azure_static.is_configured() + return uses_blob_store() or azure_static.is_configured() def blob_name(slug: str, ext: str) -> str: + if uses_blob_store(): + # base_url 에 컨테이너까지 들어 있다 — 여기서는 컨테이너 안쪽 경로만 만든다. + return "/".join(part for part in (_blob_root(), THUMB_DIR, f"{slug}.{ext}") if part) prefix = os.environ.get("AZURE_STORAGE_PREFIX", azure_static.DEFAULT_PREFIX).strip().strip("/") return "/".join(part for part in (prefix, THUMB_DIR, f"{slug}.{ext}") if part) -def public_url(slug: str, ext: str) -> str: +def public_url(slug: str, ext: str, version: int | None = None) -> str: """공개 주소. 발행 사이트와 같은 오리진이다 — 접두사는 오리진 경로로 흡수된다 - (CLAUDE.md 'AZURE_STORAGE_PREFIX 와 루트 절대경로는 충돌한다').""" - return f"{site_payload.publish_origin()}/{THUMB_DIR}/{slug}.{ext}" + (CLAUDE.md 'AZURE_STORAGE_PREFIX 와 루트 절대경로는 충돌한다'). + + ★ `?v=<버전>` 은 캐시 무효화다. 블롭 이름은 발행마다 그대로고 내용만 덮어쓰므로 + (overwrite=True), 주소가 안 변하면 브라우저·CDN 이 **옛 그림을 계속 보여준다.** + 아래 CACHE_CONTROL(60초)만으로는 부족하다 — 그 60초 동안 사장님은 방금 바꾼 사진이 + 아니라 지난 발행의 사진을 본다. 버전을 붙이면 발행 즉시 새 주소가 된다. + ★ 이름에 버전을 넣지 않는 이유: 사이트당 블롭이 발행 횟수만큼 쌓이고, 지우는 코드가 없다. + ★ version 이 None 이면 붙이지 않는다 — 옛 발행분을 사후에 채우는 경로 + (scripts/backfill_thumbnails.py)에는 그 시점의 버전이 없다.""" + # ★ 저장소가 발행 오리진 밖이면 주소도 그쪽이다. 여기서 publish_origin 을 쓰면 + # 그림은 블롭에 올라가 있는데 카드는 우리 사이트 주소를 가리켜 전부 404 다. + base = f"{_blob_base()}/{blob_name(slug, ext)}" if uses_blob_store() \ + else f"{site_payload.publish_origin()}/{THUMB_DIR}/{slug}.{ext}" + return f"{base}?v={version}" if version is not None else base async def _fetch(url: str) -> tuple[bytes, str, str] | None: @@ -109,6 +161,18 @@ async def _fetch(url: str) -> tuple[bytes, str, str] | None: def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str: + name = blob_name(slug, ext) + settings = ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL) + + if uses_blob_store(): + # ★ SAS 는 연결 문자열이 아니다 — from_connection_string 이 못 받는다. + # 블롭 주소에 토큰을 붙여 그 한 파일에만 붙는다(컨테이너 클라이언트를 만들지 않는다: + # 남의 디렉터리를 훑을 수 있는 핸들을 굳이 들고 있지 않는다). + BlobClient.from_blob_url(f"{_blob_base()}/{name}?{_blob_sas()}").upload_blob( + data, overwrite=True, content_settings=settings + ) + return name + connection_string = os.environ["AZURE_STORAGE_CONNECTION_STRING"].strip() container_name = ( os.environ.get("AZURE_STORAGE_CONTAINER", azure_static.DEFAULT_CONTAINER).strip() @@ -116,18 +180,12 @@ def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str: ) service = BlobServiceClient.from_connection_string(connection_string) container = service.get_container_client(container_name) - name = blob_name(slug, ext) - container.upload_blob( - name=name, - data=data, - overwrite=True, - # cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다. - content_settings=ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL), - ) + # cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다. + container.upload_blob(name=name, data=data, overwrite=True, content_settings=settings) return name -async def store(slug: str, snapshot: dict) -> str | None: +async def store(slug: str, snapshot: dict, version: int | None = None) -> str | None: """대표 사진을 썸네일로 올리고 공개 URL 을 돌려준다. 못 하면 None(발행은 그대로 간다). SDK 의 동기 I/O 는 별도 스레드에서 돈다 — azure_static.publish 와 같은 이유로, @@ -153,4 +211,4 @@ async def store(slug: str, snapshot: dict) -> str | None: return None LOG.i(f"[thumbnail] {slug} → {name} ({len(data)} bytes · {content_type})") - return public_url(slug, ext) + return public_url(slug, ext, version) diff --git a/solution/backend/tests/test_my_sites.py b/solution/backend/tests/test_my_sites.py index c410545..3abae3d 100644 --- a/solution/backend/tests/test_my_sites.py +++ b/solution/backend/tests/test_my_sites.py @@ -53,6 +53,42 @@ async def test_site_row_is_joined_into_the_line(auth_headers, client): assert row["status"] == SiteStatus.DRAFT.value +async def test_row_carries_what_the_card_draws(auth_headers, client, db_engine): + """검증: 목록 줄이 카드가 그릴 값을 다 들고 온다 — 주소·생성일·썸네일. + + ★ 왜 이걸 본다: 같은 상호로 만든 사업장이 여러 줄일 때(실측: 한 계정에 '버터브루' 4줄) + 이름과 상태 배지만으로는 어느 게 어느 건지 가릴 수 없다. 가르는 값은 주소와 시각이고, + **한 번이라도 발행한 줄은 그림**이다.""" + h = await auth_headers("my2b") + pid = await _place(client, h, "카드펜션") + async with db_engine.begin() as conn: + await conn.execute( + text("UPDATE places SET road_address = :addr WHERE place_id = :pid"), + {"addr": "강원특별자치도 양양군 현북면 하조대3길 12", "pid": uuid.UUID(pid)}, + ) + await conn.execute( + text("INSERT INTO sites (site_id, place_id, domain, status, thumbnail_url) " + "VALUES (:s, :p, :d, :st, :t)"), + {"s": uuid.uuid4(), "p": uuid.UUID(pid), "d": "card-stay", + "st": SiteStatus.PUBLISHED.value, "t": "https://w4ai.o2o.kr/thumbs/card-stay.jpg?v=2"}, + ) + + row = (await _list(client, h))["sites"][0] + assert row["road_address"] == "강원특별자치도 양양군 현북면 하조대3길 12" + assert row["created_at"] + assert row["thumbnail_url"] == "https://w4ai.o2o.kr/thumbs/card-stay.jpg?v=2" + + +async def test_row_without_a_site_has_no_thumbnail(auth_headers, client): + """검증: 아직 발행 안 한 줄은 그림이 없다(키 자체가 없다). + 기대결과: 화면이 '그림 없음' 자리를 그릴 근거가 된다 — 빈 문자열로 오면 깨진 이미지가 뜬다.""" + h = await auth_headers("my2c") + await _place(client, h, "그림없는펜션") + + row = (await _list(client, h))["sites"][0] + assert "thumbnail_url" not in row + + async def test_other_owners_sites_are_not_listed(auth_headers, client): """검증: 사장님 스코프. 남의 사업장은 보이지 않는다. 기대결과: 각자 자기 것만 1건.""" diff --git a/solution/backend/tests/test_site_thumbnail.py b/solution/backend/tests/test_site_thumbnail.py index 44b2808..3d81522 100644 --- a/solution/backend/tests/test_site_thumbnail.py +++ b/solution/backend/tests/test_site_thumbnail.py @@ -110,9 +110,11 @@ async def test_사이트_디렉터리_밖의_thumbs_에_올린다(blob, monkeypa _transport(monkeypatch, lambda req: httpx.Response(200, headers={"content-type": "image/jpeg"}, content=b"jpegbytes")) snapshot = _snapshot({"media_id": "m1", "url": "https://cdn.test/front.jpg", "unit_id": None}) - url = await site_thumbnail.store("butter", snapshot) + url = await site_thumbnail.store("butter", snapshot, 3) - assert url == "https://w4ai.o2o.kr/thumbs/butter.jpg" + # ★ `?v=` 는 캐시 무효화다. 블롭 이름은 그대로 덮어쓰므로 주소가 안 변하면 + # 브라우저·CDN 이 지난 발행의 그림을 계속 보여준다(site_thumbnail.public_url). + assert url == "https://w4ai.o2o.kr/thumbs/butter.jpg?v=3" name = f"{azure_static.DEFAULT_PREFIX}/thumbs/butter.jpg" assert set(blob.uploads) == {name} assert not name.startswith(f"{azure_static.DEFAULT_PREFIX}/{azure_static.SITE_ROOT_DIR}/") @@ -131,8 +133,8 @@ async def test_발행하면_사이트에_썸네일_주소가_남는다(auth_head from services import build_service from tests.test_build_publish import _approved_media, _place, _run, _verified_facts - async def _store(slug, snapshot): - return f"https://w4ai.o2o.kr/thumbs/{slug}.jpg" + async def _store(slug, snapshot, version=None): + return f"https://w4ai.o2o.kr/thumbs/{slug}.jpg?v={version}" monkeypatch.setattr(build_service.site_thumbnail, "store", _store) @@ -146,6 +148,15 @@ async def test_발행하면_사이트에_썸네일_주소가_남는다(auth_head site = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()["site"] assert site["thumbnail_url"].startswith("https://w4ai.o2o.kr/thumbs/") + assert site["thumbnail_url"].endswith("?v=1") + + # ★ 재발행하면 주소가 바뀌어야 한다. 블롭 이름은 그대로 덮어쓰므로, 주소가 그대로면 + # 사장님은 사진을 바꾸고 다시 발행해도 캐시에 남은 **지난 그림**을 계속 본다. + await client.post(f"/v1/place/{pid}/site/build", headers=h, json={"publish": True}) + await _run() + again = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()["site"] + assert again["thumbnail_url"].endswith("?v=2") + assert again["thumbnail_url"] != site["thumbnail_url"] async def test_썸네일을_못_만들어도_발행은_성공한다(auth_headers, client, db_engine, monkeypatch): @@ -154,7 +165,7 @@ async def test_썸네일을_못_만들어도_발행은_성공한다(auth_headers from services import build_service from tests.test_build_publish import _approved_media, _place, _run, _verified_facts - async def _store(slug, snapshot): + async def _store(slug, snapshot, version=None): return None monkeypatch.setattr(build_service.site_thumbnail, "store", _store) diff --git a/solution/frontend/src/api/generated/model/index.ts b/solution/frontend/src/api/generated/model/index.ts index d616f6f..2e38a7b 100644 --- a/solution/frontend/src/api/generated/model/index.ts +++ b/solution/frontend/src/api/generated/model/index.ts @@ -10,7 +10,6 @@ export * from './auditCheckDataRecommendation'; export * from './authProvider'; export * from './buildStatus'; export * from './checkSlugParams'; -export * from './companyData'; export * from './errorInfo'; export * from './errorInfoCode'; export * from './errorInfoDesc'; @@ -83,6 +82,7 @@ export * from './mySiteData'; export * from './mySiteDataCreatedAt'; export * from './mySiteDataDomain'; export * from './mySiteDataPublishedAt'; +export * from './mySiteDataThumbnailUrl'; export * from './mySiteDataRoadAddress'; export * from './mySiteDataSiteId'; export * from './mySiteDataStatus'; @@ -139,7 +139,6 @@ export * from './reqGoogleLogin'; export * from './reqLogin'; export * from './reqPublishLocalContent'; export * from './reqSignup'; -export * from './reqSignupCompanyName'; export * from './reqSignupName'; export * from './reqSiteSlug'; export * from './reqSiteStatus'; @@ -222,7 +221,6 @@ export * from './resLocalContentListMsg'; export * from './resLogin'; export * from './resLoginMsg'; export * from './resMe'; -export * from './resMeCompany'; export * from './resMeContactNumber'; export * from './resMeEmail'; export * from './resMeMsg'; diff --git a/solution/frontend/src/api/generated/model/mySiteData.ts b/solution/frontend/src/api/generated/model/mySiteData.ts index cb463b1..b3ef1d2 100644 --- a/solution/frontend/src/api/generated/model/mySiteData.ts +++ b/solution/frontend/src/api/generated/model/mySiteData.ts @@ -13,6 +13,7 @@ import type { MySiteDataStatus } from './mySiteDataStatus'; import type { MySiteDataDomain } from './mySiteDataDomain'; import type { MySiteDataTemplateId } from './mySiteDataTemplateId'; import type { MySiteDataPublishedAt } from './mySiteDataPublishedAt'; +import type { MySiteDataThumbnailUrl } from './mySiteDataThumbnailUrl'; /** * 내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site). @@ -32,5 +33,7 @@ export interface MySiteData { domain?: MySiteDataDomain; template_id?: MySiteDataTemplateId; published_at?: MySiteDataPublishedAt; + /** 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url). */ + thumbnail_url?: MySiteDataThumbnailUrl; needs_rebuild?: boolean; } diff --git a/solution/frontend/src/api/generated/model/mySiteDataThumbnailUrl.ts b/solution/frontend/src/api/generated/model/mySiteDataThumbnailUrl.ts new file mode 100644 index 0000000..16daca9 --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataThumbnailUrl.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 MySiteDataThumbnailUrl = string | null; diff --git a/solution/frontend/src/pages/SitesPage.tsx b/solution/frontend/src/pages/SitesPage.tsx index 7c70a0f..862ab78 100644 --- a/solution/frontend/src/pages/SitesPage.tsx +++ b/solution/frontend/src/pages/SitesPage.tsx @@ -1,4 +1,4 @@ -import {useState} from 'react'; +import {useMemo, useState} from 'react'; import {Link, useNavigate} from 'react-router'; import { Building2, @@ -8,17 +8,22 @@ import { MoreHorizontal, Pencil, Plus, + Search, + SearchX, Stethoscope, UtensilsCrossed, Wand2, + X, } from 'lucide-react'; import {PlaceCategory, publishUrlString, SiteStatus} from '@o2o/shared'; import {changeStatus, PublishAction, useListMySites, type MySiteData} 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'; // 발행본 주소는 PublishModal·CanvasView 와 같은 규칙이다 — 세 곳이 다른 주소를 말하면 안 된다. // ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌 @@ -32,24 +37,51 @@ const CATEGORY_ICON: Record = { [PlaceCategory.CLINIC]: Stethoscope, }; +/** + * 줄이 속하는 칸. **배지·필터·정렬이 전부 이 하나로 판정한다.** + * + * ★ 판정을 갈라 쓰면 조용히 틀린다 — '발행됨(1)' 을 눌렀는데 '내림' 배지가 낀 줄이 같이 + * 나오는 종류다. 건수까지 틀리므로 사장님은 목록을 못 믿게 된다. + * ★ 세 칸이 목록을 빈틈없이 나눈다. 하나라도 빠지면 '전체' 건수와 칸 건수의 합이 어긋난다. + */ +type SiteBucket = 'live' | 'draft'; +type SiteFilter = SiteBucket | 'all'; + +/** + * ★ 칸은 **둘**이다. 예전엔 '만드는 중'(사이트 행 없음)을 따로 뒀는데, 사장님에게 그 둘은 + * 같은 상태다 — "아직 안 나가 있다". `site_id` 가 있고 없고는 우리 DB 사정이지 사장님의 + * 구분이 아니고, 칸이 셋이면 34개가 어디 있는지 두 번 세게 된다. + */ +const FILTER_LABEL: Record = { + all: '전체', + live: '발행됨', + draft: '발행 전', +}; + +const FILTER_TABS: readonly SiteFilter[] = ['all', 'live', 'draft']; + +/** 나가 있는 것부터 본다 — 실측(계정 test): 35개 중 34개가 발행 전이라 1개가 묻힌다. */ +const BUCKET_ORDER: Record = {live: 0, draft: 1}; + +function bucketOf(row: MySiteData): SiteBucket { + return row.site_id && row.status === SiteStatus.PUBLISHED ? 'live' : 'draft'; +} + /** * 줄의 상태 배지. **사이트 상태(sites.status)만 본다** — 사업장 상태(places.status)는 * 수집 단계를 말하는 값이라 사장님이 궁금한 "지금 나가 있나"와 다르다. */ function statusBadge(row: MySiteData) { - if (!row.site_id) return {label: '만드는 중', variant: 'outline' as const}; - switch (row.status) { - case SiteStatus.PUBLISHED: - return row.needs_rebuild - ? {label: '수정됨 · 재발행 필요', variant: 'warning' as const} - : {label: '발행됨', variant: 'success' as const}; - case SiteStatus.SUSPENDED: - return {label: '중지', variant: 'outline' as const}; - case SiteStatus.UNPUBLISHED: - return {label: '내림', variant: 'outline' as const}; - default: - return {label: '발행 전', variant: 'default' as const}; + if (bucketOf(row) === 'live') { + return row.needs_rebuild + ? {label: '수정됨 · 재발행 필요', variant: 'warning' as const} + : {label: '발행됨', variant: 'success' as const}; } + // 같은 '발행 전' 이어도 **한 번 나갔다가 내린 것**은 말해 준다 — 되돌리는 일과 처음 내는 일은 + // 사장님이 할 행동이 다르다. 그 외(초안·수집 중)는 전부 '발행 전' 한 마디다. + if (row.status === SiteStatus.SUSPENDED) return {label: '중지', variant: 'outline' as const}; + if (row.status === SiteStatus.UNPUBLISHED) return {label: '내림', variant: 'outline' as const}; + return {label: '발행 전', variant: 'default' as const}; } /** 발행본이 실제로 열리는 주소. ★ 주소는 발행 전에 예약되므로 PUBLISHED 일 때만 연다 — 아니면 404 다. */ @@ -58,19 +90,135 @@ function publishedUrl(row: MySiteData): string | null { return publishUrlString(row.domain.split('.')[0], PUBLISH_HOST); } +/** + * 줄 뒤에 붙는 시각. **발행됐으면 발행일, 아니면 만든 날**이다. + * + * ★ 두 개를 같이 걸지 않는다. Wix·아임웹 목록이 시각을 한 칸만 쓰는 이유와 같다 — + * 목록에서 궁금한 건 "이게 언제 나갔나" 하나이고, 아직 안 나간 줄에만 만든 날이 의미가 있다. + * ★ 연도는 올해면 뗀다. 줄이 좁아 주소가 먼저 잘린다. + */ +function whenLabel(row: MySiteData): string { + const raw = row.published_at ?? row.created_at; + if (!raw) return ''; + const at = new Date(raw); + if (Number.isNaN(at.getTime())) return ''; + const now = new Date(); + const date = at.toLocaleDateString('ko-KR', { + ...(at.getFullYear() === now.getFullYear() ? {} : {year: 'numeric'}), + month: 'long', + day: 'numeric', + }); + return row.published_at ? `${date} 발행` : `${date} 만듦`; +} + +/** 정렬용 시각. whenLabel 이 고른 값과 같은 것을 쓴다 — 화면에 보이는 날짜와 순서가 갈라지면 안 된다. */ +function rowTime(row: MySiteData): number { + const raw = row.published_at ?? row.created_at; + if (!raw) return 0; + const at = new Date(raw).getTime(); + return Number.isNaN(at) ? 0 : at; +} + +/** + * 검색 비교용 정규화. **공백을 지운다** — 실측(계정 test)에 같은 상호 '버터브루' 가 4줄인데 + * 사장님은 '버터 브루' 로도 친다. 한국어 상호는 띄어쓰기가 원본마다 다르다. + */ +function normalizeText(value: string): string { + return value.toLowerCase().replace(/\s+/g, ''); +} + +/** 상호와 도로명 주소만 본다 — 상호가 겹치는 줄을 실제로 가르는 건 주소다. */ +function matchesQuery(row: MySiteData, needle: string): boolean { + if (!needle) return true; + return normalizeText(row.name).includes(needle) || normalizeText(row.road_address ?? '').includes(needle); +} + +/** + * 줄 앞의 그림. 발행에 성공한 사이트만 썸네일이 있고(sites.thumbnail_url), + * 없으면 업종 아이콘으로 떨어진다. + * + * ★ 주소에 `?v=<버전>` 이 붙어 있다 — 발행할 때마다 바뀐다(site_thumbnail.public_url). + * 그래서 에 캐시 무효화를 따로 걸지 않는다. 여기서 또 붙이면 발행하지 않은 + * 재방문에도 매번 새로 받는다. + * ★ 못 받으면 아이콘으로 되돌린다. 블롭이 지워졌거나 옛 주소인 줄에서 깨진 그림이 + * 뜨는 것보다 낫다. + */ +function SiteThumb({row, Icon}: {row: MySiteData; Icon: typeof Building2}) { + const [failed, setFailed] = useState(false); + const src = row.thumbnail_url; + + // ★ 비율은 **16:10 — 브라우저 창 비율**이다. 이 그림은 사이트 미리보기라 1:1 로 자르면 + // 위아래가 잘려 무슨 사이트인지 알아볼 수 없다(사진첩이 아니다). + // ★ 크기는 아임웹 내사이트 화면을 보고 키웠다(2026-09-08). 거기 썸네일도 줄 높이의 대부분을 + // 차지한다 — 같은 상호가 여러 줄일 때 그림이 유일한 구분자인데 작으면 있으나 마나다. + if (!src || failed) { + return ( +
+ +
+ ); + } + return ( + setFailed(true)} + className="aspect-[16/10] w-full border-b border-border object-cover" + /> + ); +} + /** * 내 사이트 — 로그인한 사장님의 홈이다. * * 흐름은 하나다: 위저드로 만든다 → 여기 생긴다 → 눌러서 에디터로 들어가 고친다 → 재발행한다. * ★ 그래서 줄을 누르면 에디터로 간다. 목록에 온 용건은 열에 아홉 "내 사이트 고치기"다. + * + * ★ 목록이 하는 일은 셋뿐이다 — ① 어느 게 어느 건지 가르고 ② 지금 상태를 말하고 ③ 여는 길을 준다. + * 방문자·주문 같은 숫자는 여기 넣지 않는다(Wix·아임웹도 사이트 안 대시보드에 둔다). */ export function SitesPage() { const navigate = useNavigate(); const {data, isLoading, isError, error, refetch} = useListMySites({size: 50}); const [busyId, setBusyId] = useState(null); const [menuId, setMenuId] = useState(null); + const [query, setQuery] = useState(''); + const [filter, setFilter] = useState('all'); - const rows = data?.sites ?? []; + const rows = useMemo(() => data?.sites ?? [], [data]); + + // ★ 서버로 안 보낸다. 한 번에 50줄을 이미 다 받아 놓았고(실측 35줄), 서버 검색을 붙이면 + // 글자마다 왕복 + 디바운스 + 늦게 온 응답이 최신 결과를 덮는 경합까지 따라온다. + // → 50줄을 넘어 페이지가 생기는 날이 오면 그때는 서버가 맞다(이 필터는 첫 페이지만 본다). + const found = useMemo(() => { + const needle = normalizeText(query); + return rows.filter((row) => matchesQuery(row, needle)); + }, [rows, query]); + + // ★ 건수는 **검색 결과 위에서** 센다. 검색어를 친 뒤 "발행됨 0 · 만드는 중 3" 이 보여야 + // 찾는 게 어느 칸에 있는지 알 수 있다. 검색 전 건수를 그대로 두면 칸을 눌러 보고서야 안다. + const counts = useMemo(() => { + const tally: Record = {all: found.length, live: 0, draft: 0}; + for (const row of found) tally[bucketOf(row)] += 1; + return tally; + }, [found]); + + // 서버는 사업장 생성 역순으로만 준다(site_crud.list_owner_sites) — 발행 여부를 모른다. + // 나가 있는 것을 위로 올리는 건 여기서 한다. + const visible = useMemo(() => { + const picked = filter === 'all' ? found : found.filter((row) => bucketOf(row) === filter); + return [...picked].sort( + (a, b) => BUCKET_ORDER[bucketOf(a)] - BUCKET_ORDER[bucketOf(b)] || rowTime(b) - rowTime(a), + ); + }, [found, filter]); + + const isNarrowed = query.trim().length > 0 || filter !== 'all'; + + const resetView = () => { + setQuery(''); + setFilter('all'); + }; // 발행 내리기만 둔다. ★ 삭제 경로는 만들지 않는다 — 색인된 페이지를 404 로 만들면 // 그 자리를 다시 OTA 가 가져가고, 되돌릴 방법이 사장님에게 없다(sites.status 주석). @@ -135,53 +283,145 @@ export function SitesPage() { /> )} - {rows.length > 0 && ( -
    - {rows.map((row) => { + {!isLoading && !isError && rows.length > 0 && ( +
    +
    + + setQuery(event.target.value)} + placeholder="상호나 주소로 찾기" + aria-label="상호나 주소로 찾기" + className="h-9 pr-8 pl-8" + /> + {query && ( + + )} +
    + + {/* 건수를 배지가 아니라 칸 안에 붙인다 — "34가 어디 있나"는 칸을 눌러 보기 전에 보여야 한다. */} +
    + {FILTER_TABS.map((key) => ( + + ))} +
    +
    + )} + + {/* ★ 처음 온 사람의 빈 화면과 섞지 않는다 — 사이트가 35개인데 "아직 없습니다" 라고 하면 + 사장님은 목록이 아니라 자기 사이트가 사라진 줄 안다. */} + {!isLoading && !isError && rows.length > 0 && visible.length === 0 && ( + + 조건 지우기 + + } + /> + )} + + {visible.length > 0 && ( + /* ★ 줄이 아니라 카드다. 아임웹 내사이트 화면을 보고 바꿨다(2026-09-08) — 거기 썸네일은 + 줄 높이의 대부분을 차지한다. 사이트 목록에서 그림은 장식이 아니라 **유일한 구분자**라 + (실측: 같은 상호 '버터브루' 4줄) 작으면 있으나 마나다. + 비율은 16:10 — 사이트 미리보기라 브라우저 창 비율이어야 한다. 1:1 은 사진첩이지 사이트가 아니다. */ +
      + {visible.map((row) => { const Icon = CATEGORY_ICON[row.category] ?? Building2; const badge = statusBadge(row); const url = publishedUrl(row); + const isLive = bucketOf(row) === 'live'; const editHref = `/builder?placeId=${row.place_id}`; return ( -
    • - - - -
      - {row.name} - {badge.label} -
      -

      - {url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')} -

      + /* ★ 카드는 **상태와 무관하게 같은 골격**이다. 예전엔 발행 전 카드에만 '할 일' + 줄이 하나 더 붙어서 같은 줄의 카드끼리 높이와 버튼 위치가 어긋났다(사장님 지적). + h-full + flex-col + mt-auto 로 액션 줄을 항상 카드 바닥에 붙인다. */ +
    • + + -
      - {url && ( - + {badge.label} + + +
      + +

      {row.name}

      + {/* ★ 나가 있는 카드만 주소를 진하게. 나머지의 이 자리는 "아직 없다"는 안내라 + 같은 색이면 34개의 안내문 사이에 진짜 주소가 묻힌다. */} +

      - - 사이트 열기 - - )} - - + {url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')} +

      + {/* 같은 상호가 여럿일 때 가르는 값 — 주소와 시각. */} +

      + {[row.road_address, whenLabel(row)].filter(Boolean).join(' · ')} +

      + + +
      + {/* 버튼 문구도 하나로 둔다 — '편집' 과 '이어서 만들기' 는 사장님이 할 일이 + 같은데(에디터를 연다) 글자만 달라 카드마다 폭이 들쭉날쭉했다. */} + + {url && ( + + + 열기 + + )} + +
      {menuId === row.place_id && ( @@ -193,7 +433,7 @@ export function SitesPage() { className="fixed inset-0 z-10 cursor-default" onClick={() => setMenuId(null)} /> -
      +