[refactor] solution/backend,frontend,postgres-init: 회사(테넌트) 제거 — 사장님 계정이 곧 스코프

가입 한 번이 회사를 하나 만들고 사장님이 그 회사의 직원이 됐다. 가입 폼은 "상호"를 묻고
에디터 헤더에는 "이름 · 회사명" 이 붙었다 — 쓰는 사람은 사장님 한 명인데.
negodata 보일러플레이트의 멀티테넌트 스코프 키를 그대로 물려받은 것이고,
DECISIONS.md 2절이 "대행사/운영사 단위로 그대로 쓴다" 로 유지 결정을 적어 뒀던 자리다.

- gmodel: `UserInfo.company_id` 삭제 — JWT 클레임에서도 사라진다. 스코프 키는 `user_id` 다
- place_crud·site_crud: WHERE 를 `places.owner_user_id` 로. `list_company_sites` → `list_owner_sites`
- place_service: **주인은 토큰이 정한다.** `Req_CreatePlace.owner_user_id` 를 없앴다 —
  body 로 받으면 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다.
  실측: 기존 92건은 아무도 안 보내서 전부 NULL 이었고 스코프는 회사가 대신 하고 있었다
- 워커(collect·copy·build·vision): 잡 페이로드 키 `company_id` → `owner_user_id`.
  잡이 세우는 `UserInfo.user_id` 는 이제 **사업장 주인**이다 — 예전엔 요청자·검증자·랜덤 uuid
  순으로 채웠는데, 그 랜덤 uuid 가 스코프 키가 되는 순간 "남의 사업장" 이라 fact 조회가 0건이 된다
- auth: `Res_Me.company` · `Req_Signup.company_name` · `CompanyData` 삭제
- models·init.sql: `company.companies` 테이블 · `users.company_id` 삭제,
  `places.owner_user_id` NOT NULL. 마이그레이션은 백필 → NOT NULL → DROP 순서다.
  회사에 계정이 여럿이면 **가장 먼저 만든 계정**에게 몰고, 주인을 못 찾은 행은 지운다 —
  스코프가 없으면 아무에게도 안 보이는 유령이다.
  실측(로컬): place 92 → 91(고아 1건 삭제), `demoebf050` 56 · `test` 35
- 프론트: 가입 폼의 상호 칸, 내 정보의 상호 항목, 헤더의 "이름 · 회사명" 삭제
- 테스트: `company_id`/`other_company_id` 픽스처 → `owner_id` 하나.
  격리는 `auth_headers("o2")` 를 한 번 더 부르면 그게 남이다

남긴 것 — DB 스키마 이름 `company` 는 그대로다. rename 은 모든 모델의 `__table_args__` 를
건드려야 해서 이번 변경에 섞지 않았다.

검증: 전체 568 passed(실패 1건은 HEAD 에서도 깨지는 레이트리밋 테스트) ·
프론트 tsc+eslint 통과 · 실제 API 로 가입→사업장→목록→격리→발행 한 바퀴

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLWEFx4X3XRmKewUKjJWow
This commit is contained in:
Mina Choi 2026-09-08 13:01:14 +09:00
parent 00e13bca7f
commit 94551afdaf
53 changed files with 296 additions and 456 deletions

View File

@ -186,10 +186,11 @@ USER role=1 → 403 DEVELOPER role=3 → 200
OWNER role=2 → 403 OWNER role=2 → 403
``` ```
OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다. OWNER 가 막히는 게 핵심이다 — 내부 운영 화면을 볼 권한이 아니다.
`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `signup`·`google` 도 `auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `signup`·`google` 도
같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고 자기 같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고
회사(새 테넌트) 하나만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다. `places.owner_user_id` 가 자기 계정인 사업장만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다.
(2026-09-08 전에는 이 스코프가 회사(테넌트)였다 — DECISIONS.md 2절)
⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`). ⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`).
위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 가 위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 가

View File

@ -88,9 +88,9 @@
| 포트 | **9800** | negosium 9300 / negodata 9400 / agent 9500 / lps 9600 / anchoring 9700 다음 번호 | | 포트 | **9800** | negosium 9300 / negodata 9400 / agent 9500 / lps 9600 / anchoring 9700 다음 번호 |
| DB | `web4ai_db` (테스트 `web4ai_test_db`), 기존 로컬 postgres(`negosium-db` 컨테이너, 5432) 안의 **별도 database** | 원본과 같은 인스턴스·다른 DB. 스키마 네임스페이스 컨벤션 유지 | | DB | `web4ai_db` (테스트 `web4ai_test_db`), 기존 로컬 postgres(`negosium-db` 컨테이너, 5432) 안의 **별도 database** | 원본과 같은 인스턴스·다른 DB. 스키마 네임스페이스 컨벤션 유지 |
| 마이그레이션 | Alembic 안 씀. `postgres-init/init-data/init.sql` **한 벌**(전체 DDL, 재실행 안전) | 2026-08-31: 누적 ALTER 파일(`alters/`)을 없앴다. 아직 git·서버 어디에도 안 올라가 **보정할 기존 DB 가 없다** — init.sql 에 이미 전부 반영돼 있어 두 벌을 유지할 이유가 없었다. 운영 DB 가 생기는 순간 다시 필요해진다 | | 마이그레이션 | Alembic 안 씀. `postgres-init/init-data/init.sql` **한 벌**(전체 DDL, 재실행 안전) | 2026-08-31: 누적 ALTER 파일(`alters/`)을 없앴다. 아직 git·서버 어디에도 안 올라가 **보정할 기존 DB 가 없다** — init.sql 에 이미 전부 반영돼 있어 두 벌을 유지할 이유가 없었다. 운영 DB 가 생기는 순간 다시 필요해진다 |
| 남긴 것 | config 로더 · 로거 · 싱글톤 · DB 세션 매니저(R/W 분리) · gmodel · gtime · authz · JWT/bcrypt dependencies · `company.companies`/`company.users` · auth 라우터 · 스케줄러 껍데기 · conftest(테스트 DB 자동 생성/삭제) | 전 모듈이 공통으로 쓰는 인프라. 인증은 places·facts·sites 전부가 `IsValidAccessToken` 에 의존한다 | | 남긴 것 | config 로더 · 로거 · 싱글톤 · DB 세션 매니저(R/W 분리) · gmodel · gtime · authz · JWT/bcrypt dependencies · `company.users` · auth 라우터 · 스케줄러 껍데기 · conftest(테스트 DB 자동 생성/삭제) | 전 모듈이 공통으로 쓰는 인프라. 인증은 places·facts·sites 전부가 `IsValidAccessToken` 에 의존한다 |
| 뺀 것 | quotation · supplier · item · card · dashboard · statistics · learning · renegotiation · landing · admin · notification · LPS 연동 · anchoring · 초청메일(ACS/SMTP) · Azure Blob 클라이언트 | negodata 고유 도메인. Blob 클라이언트만 1-2 결론 후 media 모듈과 함께 재이식 예정 | | 뺀 것 | quotation · supplier · item · card · dashboard · statistics · learning · renegotiation · landing · admin · notification · LPS 연동 · anchoring · 초청메일(ACS/SMTP) · Azure Blob 클라이언트 | negodata 고유 도메인. Blob 클라이언트만 1-2 결론 후 media 모듈과 함께 재이식 예정 |
| `companies` 테이블 유지 | 유지 | 보일러플레이트의 멀티테넌트 스코프 키(`UserInfo.company_id`)가 전 계층에 박혀 있다. 대행사/운영사 단위로 그대로 쓴다 | | `companies` 테이블 유지 | **2026-09-08 철회 — 걷어냈다** | 보일러플레이트를 그대로 둔 결정이었는데, 이 제품의 사용자는 사장님 한 명이다. 가입 한 번이 회사를 만들고 사장님이 자기 회사의 직원이 되는 구조가 화면에까지 나왔다(가입 폼의 "상호", 헤더의 "이름 · 회사명"). 스코프 키를 `places.owner_user_id` 로 옮기고 `company.companies` 테이블 · `users.company_id` · `UserInfo.company_id` 를 삭제했다. 스키마 이름 `company` 만 남았다 — rename 은 모든 모델의 `__table_args__` 를 건드려서 따로 둔다 |
| ErrorType 구간 | 계정 = 1100. 도메인 구간 예약 — places 1200 / facts 1300 / collector 1400 / generator 1500 / local 1600 / sites 1700 / reports 1800 | 원본이 구간을 나눠 쓰는 방식 유지 | | ErrorType 구간 | 계정 = 1100. 도메인 구간 예약 — places 1200 / facts 1300 / collector 1400 / generator 1500 / local 1600 / sites 1700 / reports 1800 | 원본이 구간을 나눠 쓰는 방식 유지 |
| 외부 API 키 | `[ExternalApiConfig]` 로 toml + env override. **키가 비면 해당 어댑터만 비활성, 서버는 그대로 뜬다** | 부팅이 외부 계약에 묶이면 안 됨 | | 외부 API 키 | `[ExternalApiConfig]` 로 toml + env override. **키가 비면 해당 어댑터만 비활성, 서버는 그대로 뜬다** | 부팅이 외부 계약에 묶이면 안 됨 |
| 백그라운드 작업 | 원본에 전용 작업 큐 없음(APScheduler 크론만). 수집·비전분석·빌드는 몇 분 걸리므로 **큐를 새로 얹어야 한다** — 방식 미정 | 원본에 없는 것이라 팀 컨벤션 확인 필요. 아래 3번 참고 | | 백그라운드 작업 | 원본에 전용 작업 큐 없음(APScheduler 크론만). 수집·비전분석·빌드는 몇 분 걸리므로 **큐를 새로 얹어야 한다** — 방식 미정 | 원본에 없는 것이라 팀 컨벤션 확인 필요. 아래 3번 참고 |

View File

@ -5,6 +5,39 @@
--- ---
## 2026-09-08 — 회사(테넌트)를 걷어냈다 — 사장님 계정이 곧 스코프다
**무슨 일**
사장님이 가입하면 회사가 하나 생기고 그 회사의 직원이 됐다. 가입 폼은 "상호"를 묻고,
에디터 헤더에는 "이름 · 회사명" 이 붙었다. 쓰는 사람은 사장님 한 명인데.
**왜 그랬나**
보일러플레이트(negodata)의 멀티테넌트 스코프 키를 그대로 물려받았다. DECISIONS.md 2절이
"대행사/운영사 단위로 그대로 쓴다" 로 유지 결정을 적어 뒀던 자리다 — 2026-09-08 철회했다.
**바꾼 것**
- 스코프 키가 `company_id` → `places.owner_user_id` 다. `UserInfo` 에서 `company_id` 를 뺐고
(JWT 클레임도 같이 사라진다), `place_crud`·`site_crud` 의 WHERE 가 전부 주인으로 바뀌었다
- **주인은 토큰이 정한다.** `Req_CreatePlace.owner_user_id` 를 없앴다 — body 로 받으면 남의
계정을 적어 만들자마자 남의 목록에 넣을 수 있다. 실측: 기존 92건은 아무도 안 보내서 전부 NULL 이었고,
스코프는 회사가 대신 하고 있었다
- 잡 페이로드 키 `company_id` → `owner_user_id`. 워커가 세우는 `UserInfo.user_id` 는 이제
**사업장 주인**이다 — 예전엔 요청자·검증자·랜덤 uuid 순으로 채웠는데, 그 랜덤 uuid 가
스코프 키가 되는 순간 "남의 사업장" 이 되어 fact 조회가 0건이 된다
- `company.companies` 테이블 · `users.company_id` · `Res_Me.company` · 가입 폼의 상호 칸 삭제
- 테스트: `company_id`/`other_company_id` 픽스처 → `owner_id` 하나. 격리 테스트는
`auth_headers("o2")` 를 한 번 더 부르면 그게 남이다
**마이그레이션** (`init.sql` 끝, 재실행 안전)
백필 → NOT NULL → 컬럼 삭제 순서다. 회사에 계정이 여럿이던 경우는 **가장 먼저 만든 계정**에게
몰아준다. 주인을 못 찾은 행은 지운다 — 스코프가 없으면 아무에게도 안 보이는 유령이다.
실측(로컬): 92건 → 91건(고아 1건 삭제), `demoebf050` 56 · `test` 35.
**남긴 것** — DB 스키마 이름 `company` 는 그대로다. rename 은 모든 모델의 `__table_args__` 를
건드려야 해서 이번 변경에 섞지 않았다.
---
## 2026-09-07 — (사고 2) 목업 사이트가 죽었다 — 참조된 자산은 기간과 무관하게 남긴다 ## 2026-09-07 — (사고 2) 목업 사이트가 죽었다 — 참조된 자산은 기간과 무관하게 남긴다
**무슨 일** **무슨 일**

View File

@ -5,7 +5,7 @@
-- 단일 PostgreSQL 인스턴스, 단일 database(web4ai_db) 안에서 도메인별 schema 로 묶는다. -- 단일 PostgreSQL 인스턴스, 단일 database(web4ai_db) 안에서 도메인별 schema 로 묶는다.
-- postgres (1개 서버, 5432) -- postgres (1개 서버, 5432)
-- └── web4ai_db -- └── web4ai_db
-- ├── company : companies, users -- ├── company : users
-- ├── place : places, place_aliases, place_links, units, media -- ├── place : places, place_aliases, place_links, units, media
-- ├── fact : facts, faqs -- ├── fact : facts, faqs
-- ├── local : local_contents, routes, nearby_links -- ├── local : local_contents, routes, nearby_links
@ -46,28 +46,12 @@ CREATE SCHEMA IF NOT EXISTS site;
CREATE SCHEMA IF NOT EXISTS job; CREATE SCHEMA IF NOT EXISTS job;
-- ============================================================ -- ============================================================
-- company : 회사 / 내부 유저 -- company : 계정
-- ★ 스키마 이름만 company 다. 회사(테넌트) 개념은 2026-09-08 에 걷어냈다 —
-- 스키마 rename 은 모든 모델의 __table_args__ 를 건드려야 해서 따로 둔다.
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS company.companies (
company_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 회사 식별자(PK)
name VARCHAR(100) NOT NULL, -- 회사명
business_number VARCHAR(30) NULL, -- 사업자등록번호
code INTEGER NULL, -- 회사코드 (내부 인덱스용)
representative_name VARCHAR(50) NULL, -- 대표자명
email VARCHAR(255) NULL, -- 대표 이메일
contact_number VARCHAR(20) NULL, -- 대표 연락처
website_url VARCHAR(255) NULL, -- 홈페이지 URL
industry SMALLINT NULL, -- 업종 ( 필요한 만큼 숫자에 매핑하여 사용 )
status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성)
settings JSONB NULL, -- 회사별 커스터마이징: branding(CI)/labels(용어)/features(동작)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
CREATE TABLE IF NOT EXISTS company.users ( CREATE TABLE IF NOT EXISTS company.users (
user_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 유저 식별자(PK) user_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 유저 식별자(PK)
company_id uuid NOT NULL, -- 소속 회사(company.companies.company_id)
id VARCHAR(64) NOT NULL, -- 로그인 ID (구글 계정은 google_<sub>) id VARCHAR(64) NOT NULL, -- 로그인 ID (구글 계정은 google_<sub>)
password VARCHAR(255) NULL, -- bcrypt 해시. 소셜 계정은 NULL password VARCHAR(255) NULL, -- bcrypt 해시. 소셜 계정은 NULL
name VARCHAR(50) NULL, -- 이름 name VARCHAR(50) NULL, -- 이름
@ -88,8 +72,7 @@ CREATE TABLE IF NOT EXISTS company.users (
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS place.places ( CREATE TABLE IF NOT EXISTS place.places (
place_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 사업장 식별자(PK) place_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 사업장 식별자(PK)
company_id uuid NOT NULL, -- 테넌트(company.companies.company_id) owner_user_id uuid NOT NULL, -- ★ 스코프 키. 사장님 계정(company.users.user_id)
owner_user_id uuid NULL, -- 사장님 계정(company.users.user_id)
name VARCHAR(200) NOT NULL, -- 상호명(입력값) name VARCHAR(200) NOT NULL, -- 상호명(입력값)
category SMALLINT NOT NULL, -- 업종(PlaceCategory): 1=숙박 2=카페 3=음식점 4=피부과·성형외과 category SMALLINT NOT NULL, -- 업종(PlaceCategory): 1=숙박 2=카페 3=음식점 4=피부과·성형외과
status SMALLINT NOT NULL DEFAULT 1, -- 상태(PlaceStatus): 1=draft 2=collecting 3=review 4=published 5=suspended status SMALLINT NOT NULL DEFAULT 1, -- 상태(PlaceStatus): 1=draft 2=collecting 3=review 4=published 5=suspended
@ -355,15 +338,11 @@ CREATE TABLE IF NOT EXISTS job.jobs (
-- ============================================================ -- ============================================================
-- 인덱스 -- 인덱스
-- ============================================================ -- ============================================================
CREATE INDEX IF NOT EXISTS idx_users_company_id ON company.users (company_id);
-- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(deleted = FALSE)로 건다. -- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(deleted = FALSE)로 건다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_id ON company.users (id) WHERE deleted = FALSE; CREATE UNIQUE INDEX IF NOT EXISTS uq_users_id ON company.users (id) WHERE deleted = FALSE;
CREATE UNIQUE INDEX IF NOT EXISTS uq_companies_biz_number ON company.companies (business_number) WHERE deleted = FALSE AND business_number IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_companies_code ON company.companies (code) WHERE deleted = FALSE;
-- place -- place
CREATE INDEX IF NOT EXISTS idx_places_company_id ON place.places (company_id);
CREATE INDEX IF NOT EXISTS idx_places_owner_user_id ON place.places (owner_user_id); CREATE INDEX IF NOT EXISTS idx_places_owner_user_id ON place.places (owner_user_id);
CREATE INDEX IF NOT EXISTS idx_places_region_code ON place.places (region_code) WHERE deleted = FALSE; CREATE INDEX IF NOT EXISTS idx_places_region_code ON place.places (region_code) WHERE deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_place_aliases_place ON place.place_aliases (place_id); CREATE INDEX IF NOT EXISTS idx_place_aliases_place ON place.place_aliases (place_id);
@ -448,3 +427,26 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON company.users (provid
-- 2026-09-03 쇼케이스 카드 썸네일. CREATE TABLE 에만 있어서 기존 DB 가 조용히 깨졌다 -- 2026-09-03 쇼케이스 카드 썸네일. CREATE TABLE 에만 있어서 기존 DB 가 조용히 깨졌다
-- (실측: 킹서버에서 GET /v1/showcase 가 200 인데 내용은 비었다). -- (실측: 킹서버에서 GET /v1/showcase 가 200 인데 내용은 비었다).
ALTER TABLE site.sites ADD COLUMN IF NOT EXISTS thumbnail_url VARCHAR(500) NULL; ALTER TABLE site.sites ADD COLUMN IF NOT EXISTS thumbnail_url VARCHAR(500) NULL;
-- 2026-09-08 회사(테넌트) 제거. 쓰는 사람은 사장님 혼자인데 가입 한 번이 회사를 하나 만들고
-- 그 회사의 직원이 되는 구조였다. 사업장을 계정에 직접 매단다.
-- ★ 순서가 중요하다 — 백필 → NOT NULL → 컬럼 삭제. 반대로 하면 주인을 잃은 행이 남는다.
-- ★ 회사에 계정이 여럿이던 경우(내부 운영 계정)는 **가장 먼저 만든 계정**에게 몰아준다.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='place' AND table_name='places' AND column_name='company_id') THEN
UPDATE place.places p
SET owner_user_id = (
SELECT u.user_id FROM company.users u
WHERE u.company_id = p.company_id AND u.deleted = FALSE
ORDER BY u.created_at LIMIT 1)
WHERE p.owner_user_id IS NULL;
-- 주인을 못 찾은 행(회사가 통째로 지워진 경우)은 남겨 둘 수 없다 — 스코프가 없으면 아무에게도 안 보인다.
DELETE FROM place.places WHERE owner_user_id IS NULL;
ALTER TABLE place.places ALTER COLUMN owner_user_id SET NOT NULL;
ALTER TABLE place.places DROP COLUMN company_id;
END IF;
END $$;
ALTER TABLE company.users DROP COLUMN IF EXISTS company_id;
DROP TABLE IF EXISTS company.companies;

View File

@ -10,7 +10,6 @@ from common.enums import (
DBType, DBType,
UserStatus, UserStatus,
UserRole, UserRole,
CompanyStatus,
PlaceStatus, PlaceStatus,
FactStatus, FactStatus,
MediaStatus, MediaStatus,
@ -45,30 +44,11 @@ class MainTableMixin(_DBTypeMixin):
# ERD 도메인 모델 # ERD 도메인 모델
class companies(MainTableMixin, MAIN_BASE):
__tablename__ = "companies"
__table_args__ = {"schema": "company"}
company_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(100), nullable=False)
business_number = Column(String(30), nullable=True)
code = Column(Integer, nullable=True) # 내부 인덱스용
representative_name = Column(String(50), nullable=True)
email = Column(String(255), nullable=True)
contact_number = Column(String(20), nullable=True)
website_url = Column(String(255), nullable=True)
industry = Column(SmallInteger, nullable=True) # 업종 코드 (스키마 SMALLINT)
status = Column(SmallInteger, nullable=False, default=CompanyStatus.ACTIVE.value) # CompanyStatus
# 회사별 커스터마이징 설정. branding(CI)/labels(용어)/features(동작)
settings = Column(JSONB, nullable=True)
class users(MainTableMixin, MAIN_BASE): class users(MainTableMixin, MAIN_BASE):
__tablename__ = "users" __tablename__ = "users"
__table_args__ = {"schema": "company"} __table_args__ = {"schema": "company"}
user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# 20자였다. 구글 계정의 로그인 아이디를 `google_<sub>`(최대 28자)로 만들면서 넓혔다 — # 20자였다. 구글 계정의 로그인 아이디를 `google_<sub>`(최대 28자)로 만들면서 넓혔다 —
# sub 를 잘라 쓰면 앞자리가 같은 두 계정이 한 아이디로 겹친다. # sub 를 잘라 쓰면 앞자리가 같은 두 계정이 한 아이디로 겹친다.
id = Column(String(64), nullable=False, unique=True, index=True) # 로그인 아이디 id = Column(String(64), nullable=False, unique=True, index=True) # 로그인 아이디
@ -102,8 +82,8 @@ class places(MainTableMixin, MAIN_BASE):
) )
place_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) place_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 테넌트 스코프(company.companies) # ★ 스코프 키. 사장님 한 명이 자기 가게만 본다 — 회사(테넌트)를 걷어내면서 이 컬럼이 그 자리를 받았다.
owner_user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 사장님 계정(company.users) owner_user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 사장님 계정(company.users)
name = Column(String(200), nullable=False) # 상호명(입력값) name = Column(String(200), nullable=False) # 상호명(입력값)
category = Column(SmallInteger, nullable=False) # PlaceCategory — 업종 스키마 선택 키 category = Column(SmallInteger, nullable=False) # PlaceCategory — 업종 스키마 선택 키
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PlaceStatus.DRAFT.value) status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PlaceStatus.DRAFT.value)

View File

@ -67,9 +67,8 @@ class PageParams:
class UserInfo(StructModel): class UserInfo(StructModel):
"""JWT subject 로 인코딩되는 유저 식별 정보.""" """JWT subject 로 인코딩되는 유저 식별 정보."""
user_id: str # users.user_id (uuid) — 데이터 스코프 키 user_id: str # users.user_id (uuid) — 데이터 스코프 키. 사업장은 owner_user_id 로 이 값에 매인다
id: str # users.id (로그인 아이디) — get_me 재조회 키 id: str # users.id (로그인 아이디) — get_me 재조회 키
company_id: str # users.company_id (uuid) — 멀티테넌트 스코프 키
role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키 role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:

View File

@ -14,7 +14,7 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from common.database.model.models import MAIN_BASE from common.database.model.models import MAIN_BASE
from common.enums import CompanyStatus from common.enums import UserRole, UserStatus
from config.server_configs import main_db_config from config.server_configs import main_db_config
@ -92,7 +92,7 @@ async def db_engine(_test_db_lifecycle):
# 도메인 테이블 전부 비워 격리 (CASCADE: FK 미설정이라 안전망) # 도메인 테이블 전부 비워 격리 (CASCADE: FK 미설정이라 안전망)
await conn.execute( await conn.execute(
text( text(
"TRUNCATE TABLE users, companies, places, place_aliases, place_links, units, media, " "TRUNCATE TABLE users, places, place_aliases, place_links, units, media, "
"facts, faqs, local_contents, routes, nearby_links, " "facts, faqs, local_contents, routes, nearby_links, "
"sites, site_versions, publish_logs, ai_check_results, jobs RESTART IDENTITY CASCADE" "sites, site_versions, publish_logs, ai_check_results, jobs RESTART IDENTITY CASCADE"
) )
@ -102,30 +102,24 @@ async def db_engine(_test_db_lifecycle):
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def company_id(db_engine) -> str: async def owner_id(db_engine) -> str:
"""테스트용 소속사 1개를 시드하고 company_id(uuid str)를 돌려준다. """사장님 계정 1개를 시드하고 user_id(uuid str)를 돌려준다.
users 는 company_id 를 요구하므로 계정 생성 테스트의 선행 조건이다.
★ 예전엔 `company_id`(소속사)였다. 회사(테넌트)를 걷어내면서 사업장이 `owner_user_id` 로
계정에 직접 매이게 됐다 — DB 를 직접 시드하는 테스트가 place 에 넣을 주인이 이 값이다.
""" """
cid = uuid.uuid4() uid = uuid.uuid4()
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
# status 는 NOT NULL(모델 default 는 ORM 전용이라 raw INSERT 엔 안 먹음) → 명시. # status·role 은 NOT NULL(모델 default 는 ORM 전용이라 raw INSERT 엔 안 먹음) → 명시.
await conn.execute( await conn.execute(
text("INSERT INTO companies (company_id, name, status) VALUES (:cid, :name, :status)"), text(
{"cid": cid, "name": "테스트사", "status": CompanyStatus.ACTIVE.value}, "INSERT INTO users (user_id, id, password, name, status, role, last_accessed_at) "
"VALUES (:uid, :id, NULL, :name, :status, :role, now())"
),
{"uid": uid, "id": f"seed{uid.hex[:8]}", "name": "시드사장",
"status": UserStatus.ACTIVE.value, "role": UserRole.USER.value},
) )
return str(cid) return str(uid)
@pytest_asyncio.fixture
async def other_company_id(db_engine) -> str:
"""company_id 와 다른 소속사 1개(회사 스코프/IDOR 격리 테스트용)."""
cid = uuid.uuid4()
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO companies (company_id, name, status) VALUES (:cid, :name, :status)"),
{"cid": cid, "name": "다른회사", "status": CompanyStatus.ACTIVE.value},
)
return str(cid)
@pytest_asyncio.fixture @pytest_asyncio.fixture
@ -139,28 +133,27 @@ async def client(db_engine):
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def auth_headers(db_engine, client, company_id): async def auth_headers(db_engine, client):
"""테스트 유저를 시드하고 로그인 헤더(Bearer)를 돌려주는 팩토리. """테스트 유저를 시드하고 로그인 헤더(Bearer)를 돌려주는 팩토리.
계정 생성 API 가 없으므로 users 행을 직접 INSERT(비번 bcrypt 해시)한 뒤 /v1/auth/login 으로 토큰을 받는다. 계정 생성 API 가 없으므로 users 행을 직접 INSERT(비번 bcrypt 해시)한 뒤 /v1/auth/login 으로 토큰을 받는다.
company 미지정 시 기본 소속사(company_id 픽스처). role 로 OWNER 계정도 만들 수 있다. ★ 회사 인자가 없다. 스코프가 계정 자체이므로 **다른 login_id 로 한 번 더 부르면 그게 남**이다
호출: `h = await auth_headers("user1")` / `await auth_headers("userB", other_company_id)`. — 격리 테스트는 `await auth_headers("o2")` 하나면 된다.
호출: `h = await auth_headers("user1")`.
""" """
from common.enums import UserRole, UserStatus
from router.v1.validator.dependencies import GetHashedPW from router.v1.validator.dependencies import GetHashedPW
async def _make(login_id, company=None, *, password="pw1234", role=UserRole.USER.value, name="n"): async def _make(login_id, *, password="pw1234", role=UserRole.USER.value, name="n"):
cid = company or company_id
hashed = await GetHashedPW(password) hashed = await GetHashedPW(password)
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
# status·role 은 NOT NULL — ORM default 는 raw INSERT 에 안 먹으므로 명시(companies.status 와 동일). # status·role 은 NOT NULL — ORM default 는 raw INSERT 에 안 먹으므로 명시.
await conn.execute( await conn.execute(
text( text(
"INSERT INTO users (user_id, company_id, id, password, name, status, role, last_accessed_at) " "INSERT INTO users (user_id, id, password, name, status, role, last_accessed_at) "
"VALUES (:uid, :cid, :id, :pw, :name, :status, :role, now())" "VALUES (:uid, :id, :pw, :name, :status, :role, now())"
), ),
{ {
"uid": uuid.uuid4(), "cid": uuid.UUID(cid), "id": login_id, "pw": hashed, "uid": uuid.uuid4(), "id": login_id, "pw": hashed,
"name": name, "status": UserStatus.ACTIVE.value, "role": role, "name": name, "status": UserStatus.ACTIVE.value, "role": role,
}, },
) )

View File

@ -11,26 +11,26 @@ from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
# 사업장 CRUD. 모든 조회는 company_id(테넌트)로 스코프한다 — 남의 회사 사업장이 보이면 안 된다. # 사업장 CRUD. 모든 조회는 owner_user_id(사장님)로 스코프한다 — 남의 가게가 보이면 안 된다.
class IPlaceCRUD(ABC): class IPlaceCRUD(ABC):
@abstractmethod @abstractmethod
async def add_place(self, cdb: AsyncSession, place: places) -> ErrorType: async def add_place(self, cdb: AsyncSession, place: places) -> ErrorType:
pass pass
@abstractmethod @abstractmethod
async def get_place(self, cdb: AsyncSession, company_id, place_id) -> Tuple[ErrorType, places]: async def get_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, places]:
pass pass
@abstractmethod @abstractmethod
async def list_places(self, cdb: AsyncSession, company_id, search, category, status, skip, limit) -> Tuple[ErrorType, list, int]: async def list_places(self, cdb: AsyncSession, owner_user_id, search, category, status, skip, limit) -> Tuple[ErrorType, list, int]:
pass pass
@abstractmethod @abstractmethod
async def update_place(self, cdb: AsyncSession, company_id, place_id, data: dict) -> Tuple[ErrorType, int]: async def update_place(self, cdb: AsyncSession, owner_user_id, place_id, data: dict) -> Tuple[ErrorType, int]:
pass pass
@abstractmethod @abstractmethod
async def delete_place(self, cdb: AsyncSession, company_id, place_id) -> Tuple[ErrorType, int]: async def delete_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, int]:
pass pass
@abstractmethod @abstractmethod
@ -73,11 +73,11 @@ class PlaceCRUD(IPlaceCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED return ErrorType.DB_RUN_FAILED
async def get_place(self, cdb: AsyncSession, company_id, place_id) -> Tuple[ErrorType, places]: async def get_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, places]:
try: try:
query = ( query = (
select(places) select(places)
.where(places.place_id == place_id, places.company_id == company_id, places.deleted == False) # noqa: E712 .where(places.place_id == place_id, places.owner_user_id == owner_user_id, places.deleted == False) # noqa: E712
.limit(1) .limit(1)
) )
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query) err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
@ -91,11 +91,11 @@ class PlaceCRUD(IPlaceCRUD):
return ErrorType.DB_RUN_FAILED, None return ErrorType.DB_RUN_FAILED, None
async def list_places( async def list_places(
self, cdb: AsyncSession, company_id, search: Optional[str], category: Optional[int], self, cdb: AsyncSession, owner_user_id, search: Optional[str], category: Optional[int],
status: Optional[int], skip: int, limit: int, status: Optional[int], skip: int, limit: int,
) -> Tuple[ErrorType, list, int]: ) -> Tuple[ErrorType, list, int]:
try: try:
conditions = [places.deleted == False, places.company_id == company_id] # noqa: E712 conditions = [places.deleted == False, places.owner_user_id == owner_user_id] # noqa: E712
if category is not None: if category is not None:
conditions.append(places.category == category) conditions.append(places.category == category)
if status is not None: if status is not None:
@ -126,14 +126,14 @@ class PlaceCRUD(IPlaceCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0 return ErrorType.DB_RUN_FAILED, [], 0
async def update_place(self, cdb: AsyncSession, company_id, place_id, data: dict) -> Tuple[ErrorType, int]: async def update_place(self, cdb: AsyncSession, owner_user_id, place_id, data: dict) -> Tuple[ErrorType, int]:
"""회사 스코프를 WHERE 에 걸어 남의 회사 사업장을 못 건드리게 한다. (ErrorType, 적용행수).""" """회사 스코프를 WHERE 에 걸어 남의 회사 사업장을 못 건드리게 한다. (ErrorType, 적용행수)."""
try: try:
if not data: if not data:
return ErrorType.SUCCESS, 0 return ErrorType.SUCCESS, 0
query = ( query = (
update(places) update(places)
.where(places.place_id == place_id, places.company_id == company_id, places.deleted == False) # noqa: E712 .where(places.place_id == place_id, places.owner_user_id == owner_user_id, places.deleted == False) # noqa: E712
.values(**data, updated_at=GTime.UTC()) .values(**data, updated_at=GTime.UTC())
) )
return await DB_SESSION_MNG.add_with_rowcount(cdb, query) return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
@ -141,12 +141,12 @@ class PlaceCRUD(IPlaceCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0 return ErrorType.DB_RUN_FAILED, 0
async def delete_place(self, cdb: AsyncSession, company_id, place_id) -> Tuple[ErrorType, int]: async def delete_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, int]:
"""사업장을 실제 삭제한다. 회사 스코프 밖의 행은 건드리지 않는다.""" """사업장을 실제 삭제한다. 회사 스코프 밖의 행은 건드리지 않는다."""
try: try:
query = ( query = (
delete(places) delete(places)
.where(places.place_id == place_id, places.company_id == company_id, places.deleted == False) # noqa: E712 .where(places.place_id == place_id, places.owner_user_id == owner_user_id, places.deleted == False) # noqa: E712
) )
return await DB_SESSION_MNG.add_with_rowcount(cdb, query) return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
except Exception as ex: except Exception as ex:

View File

@ -22,7 +22,7 @@ class ISiteCRUD(ABC):
pass pass
@abstractmethod @abstractmethod
async def list_company_sites(self, cdb: AsyncSession, company_id, skip, limit) -> Tuple[ErrorType, list, int]: async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip, limit) -> Tuple[ErrorType, list, int]:
pass pass
@abstractmethod @abstractmethod
@ -93,13 +93,13 @@ 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]: async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]:
"""회사의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수). """사장님의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수).
따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장 따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장
(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다.""" (위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다."""
try: try:
where = and_(places.deleted == False, places.company_id == company_id) # noqa: E712 where = and_(places.deleted == False, places.owner_user_id == owner_user_id) # noqa: E712
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).where(where))
if cnt_err != ErrorType.SUCCESS: if cnt_err != ErrorType.SUCCESS:

View File

@ -1,12 +1,12 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Optional, Tuple from typing import Tuple
from sqlalchemy import select, func, and_, or_, update from sqlalchemy import select, func, 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 users, companies from common.database.model.models import users
from common.enums import ErrorType, UserRole from common.enums import ErrorType
from common.logger import LOG from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
@ -39,22 +39,6 @@ class IUserCRUD(ABC):
async def update_last_accessed(self, cdb: AsyncSession, user_id) -> ErrorType: async def update_last_accessed(self, cdb: AsyncSession, user_id) -> ErrorType:
pass pass
@abstractmethod
async def add_company(self, cdb: AsyncSession, company: companies) -> ErrorType:
pass
@abstractmethod
async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]:
pass
@abstractmethod
async def update_company_settings(self, cdb: AsyncSession, company_id, settings: dict) -> ErrorType:
pass
@abstractmethod
async def list_by_company(self, cdb: AsyncSession, company_id, search, skip, limit, hide_dev: bool = False) -> Tuple[ErrorType, list, int]:
pass
@abstractmethod @abstractmethod
async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]: async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]:
pass pass
@ -145,72 +129,6 @@ class UserCRUD(IUserCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED return ErrorType.DB_RUN_FAILED
async def add_company(self, cdb: AsyncSession, company: companies) -> ErrorType:
try:
return await DB_SESSION_MNG.insert(cdb, company)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]:
try:
query = select(companies).where(companies.company_id == company_id, companies.deleted == False).limit(1) # noqa: E712
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, None
if len(row_list) != 1:
return ErrorType.DB_INVALID_KEY, None
return ErrorType.SUCCESS, row_list[0]
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def update_company_settings(self, cdb: AsyncSession, company_id, settings: dict) -> ErrorType:
try:
query = (
update(companies)
.where(companies.company_id == company_id, companies.deleted == False) # noqa: E712
.values(settings=settings, updated_at=GTime.UTC())
)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def list_by_company(
self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int, hide_dev: bool = False
) -> Tuple[ErrorType, list, int]:
try:
conditions = [users.deleted == False, users.company_id == company_id] # noqa: E712
# 개발자(내부 운영) 계정은 고객사에 존재 자체가 보이면 안 된다 — 목록에서 빼고 총계에도 넣지 않는다.
if hide_dev:
conditions.append(users.role != UserRole.DEVELOPER.value)
if search:
conditions.append(
or_(
users.id.ilike(f"%{search}%"),
users.name.ilike(f"%{search}%"),
users.email.ilike(f"%{search}%"),
)
)
where = and_(*conditions)
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(users).where(where))
if cnt_err != ErrorType.SUCCESS:
return cnt_err, [], 0
total = int(cnt_rows[0] or 0) if cnt_rows else 0
list_err, rows = await DB_SESSION_MNG.execute(
cdb,
select(users).where(where).order_by(users.created_at.desc()).offset(skip).limit(limit),
)
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 get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]: async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]:
try: try:
query = select(users).where(users.user_id == user_id, users.deleted == False).limit(1) # noqa: E712 query = select(users).where(users.user_id == user_id, users.deleted == False).limit(1) # noqa: E712

View File

@ -1,7 +1,5 @@
from typing import Optional from typing import Optional
from pydantic import Field
from common.enums import AuthProvider, UserRole from common.enums import AuthProvider, UserRole
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
@ -17,7 +15,7 @@ class Req_Login(AuthProtocol):
class Req_Signup(AuthProtocol): class Req_Signup(AuthProtocol):
"""id/pw 가입. 가입 = 새 테넌트(회사) 1개 + 그 회사의 첫 계정 1개다. """id/pw 가입. 가입 = 계정 1개다.
★ 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면 ★ 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면
한 사람에게 계정이 둘 생긴다. 지금 이메일 인증 절차는 없다 — 소유 증명이 아니라 한 사람에게 계정이 둘 생긴다. 지금 이메일 인증 절차는 없다 — 소유 증명이 아니라
@ -27,7 +25,6 @@ class Req_Signup(AuthProtocol):
password: str = "" password: str = ""
name: Optional[str] = None name: Optional[str] = None
email: str = "" email: str = ""
company_name: Optional[str] = None # 상호. 비우면 이름 → 아이디 순으로 채운다
class Req_GoogleLogin(AuthProtocol): class Req_GoogleLogin(AuthProtocol):
@ -46,7 +43,7 @@ class Res_Login(Res_WebPacketProtocol):
class Req_UpdateMe(AuthProtocol): class Req_UpdateMe(AuthProtocol):
# 본인 정보 수정. role·company·id 는 받지 않는다(자기 권한·소속 변경 불가). # 본인 정보 수정. role·id 는 받지 않는다(자기 권한 변경 불가).
name: Optional[str] = None name: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
contact_number: Optional[str] = None contact_number: Optional[str] = None
@ -58,11 +55,6 @@ class Res_RefreshToken(Res_WebPacketProtocol):
token_type: str = "bearer" token_type: str = "bearer"
class CompanyData(WebPacketProtocol):
company_id: str = ""
name: str = ""
class Res_Me(Res_WebPacketProtocol): class Res_Me(Res_WebPacketProtocol):
user_id: str = "" user_id: str = ""
id: str = "" id: str = ""
@ -73,4 +65,3 @@ class Res_Me(Res_WebPacketProtocol):
# 이 계정이 무엇으로 로그인하는가. 구글 계정에는 바꿀 비밀번호가 없어서(update_me 가 막는다) # 이 계정이 무엇으로 로그인하는가. 구글 계정에는 바꿀 비밀번호가 없어서(update_me 가 막는다)
# 내 정보 화면이 붙을 때 이 값으로 갈라야 한다. # 내 정보 화면이 붙을 때 이 값으로 갈라야 한다.
provider: AuthProvider = AuthProvider.LOCAL provider: AuthProvider = AuthProvider.LOCAL
company: Optional[CompanyData] = Field(default=None)

View File

@ -31,7 +31,7 @@ from .protocol import (
Res_UnitList, Res_UnitList,
) )
# 사업장 라우터. 모든 조회·변경은 토큰의 회사(company_id)로 스코프된다. # 사업장 라우터. 모든 조회·변경은 토큰의 사장님(places.owner_user_id)으로 스코프된다.
router = APIRouter(prefix="/v1/place", tags=["Place"], responses={404: {"description": "Not found"}}) router = APIRouter(prefix="/v1/place", tags=["Place"], responses={404: {"description": "Not found"}})

View File

@ -18,7 +18,8 @@ class Req_CreatePlace(PlaceProtocol):
# 상호명 하나로 시작한다. 나머지는 카카오 로컬 검증이 채운다. # 상호명 하나로 시작한다. 나머지는 카카오 로컬 검증이 채운다.
name: str = "" name: str = ""
category: PlaceCategory = PlaceCategory.LODGING category: PlaceCategory = PlaceCategory.LODGING
owner_user_id: Optional[uuid.UUID] = None # ★ 주인은 받지 않는다 — 토큰이 정한다(place_service.create_place). 여기로 받으면
# 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다.
class Req_VerifyPlace(PlaceProtocol): class Req_VerifyPlace(PlaceProtocol):
@ -58,8 +59,8 @@ class Req_VerifyPlaceByUrl(PlaceProtocol):
class Req_UpdatePlace(PlaceProtocol): class Req_UpdatePlace(PlaceProtocol):
# ★ 주인은 못 바꾼다(위 Req_CreatePlace 주석). 소유권 이전은 아직 기능이 아니다.
name: Optional[str] = None name: Optional[str] = None
owner_user_id: Optional[uuid.UUID] = None
status: Optional[PlaceStatus] = None status: Optional[PlaceStatus] = None
@ -212,7 +213,7 @@ class Res_VerifyCandidates(Res_WebPacketProtocol):
class PlaceSearchItem(WebPacketProtocol): class PlaceSearchItem(WebPacketProtocol):
"""공개 검색 결과 1건. """공개 검색 결과 1건.
★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·company_id·소유자)은 ★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·소유자)은
하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다. 하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다.
★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고, ★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고,
확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다.""" 확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다."""

View File

@ -53,13 +53,11 @@ async def main():
engine = create_async_engine(dsn) engine = create_async_engine(dsn)
from router.v1.validator.dependencies import GetHashedPW from router.v1.validator.dependencies import GetHashedPW
cid, uid, login = uuid.uuid4(), uuid.uuid4(), f"demo{uuid.uuid4().hex[:6]}" uid, login = uuid.uuid4(), f"demo{uuid.uuid4().hex[:6]}"
async with engine.begin() as c: async with engine.begin() as c:
await c.execute(text("INSERT INTO company.companies (company_id,name,status) VALUES (:c,:n,1)"), await c.execute(text("INSERT INTO company.users (user_id,id,password,name,status,role,last_accessed_at) "
{"c": cid, "n": "데모대행사"}) "VALUES (:u,:i,:p,:n,:s,:r,now())"),
await c.execute(text("INSERT INTO company.users (user_id,company_id,id,password,name,status,role,last_accessed_at) " {"u": uid, "i": login, "p": await GetHashedPW("pw1234"),
"VALUES (:u,:c,:i,:p,:n,:s,:r,now())"),
{"u": uid, "c": cid, "i": login, "p": await GetHashedPW("pw1234"),
"n": "데모", "s": UserStatus.ACTIVE.value, "r": UserRole.OWNER.value}) "n": "데모", "s": UserStatus.ACTIVE.value, "r": UserRole.OWNER.value})
from router.router import app from router.router import app

View File

@ -41,12 +41,11 @@ async def main():
engine = create_async_engine(dsn) engine = create_async_engine(dsn)
from router.v1.validator.dependencies import GetHashedPW from router.v1.validator.dependencies import GetHashedPW
cid, uid, login = uuid.uuid4(), uuid.uuid4(), f"demo{uuid.uuid4().hex[:6]}" uid, login = uuid.uuid4(), f"demo{uuid.uuid4().hex[:6]}"
async with engine.begin() as c: async with engine.begin() as c:
await c.execute(text("INSERT INTO company.companies (company_id,name,status) VALUES (:c,:n,1)"), {"c": cid, "n": "데모대행사"}) await c.execute(text("INSERT INTO company.users (user_id,id,password,name,status,role,last_accessed_at) "
await c.execute(text("INSERT INTO company.users (user_id,company_id,id,password,name,status,role,last_accessed_at) " "VALUES (:u,:i,:p,:n,:s,:r,now())"),
"VALUES (:u,:c,:i,:p,:n,:s,:r,now())"), {"u": uid, "i": login, "p": await GetHashedPW("pw1234"),
{"u": uid, "c": cid, "i": login, "p": await GetHashedPW("pw1234"),
"n": "데모", "s": UserStatus.ACTIVE.value, "r": UserRole.OWNER.value}) "n": "데모", "s": UserStatus.ACTIVE.value, "r": UserRole.OWNER.value})
await engine.dispose() await engine.dispose()

View File

@ -4,13 +4,12 @@ import uuid
from fastapi import Depends from fastapi import Depends
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 companies, users from common.database.model.models import users
from common.enums import AuthProvider, CompanyStatus, DBWRType, ErrorType, UserRole, UserStatus from common.enums import AuthProvider, DBWRType, ErrorType, UserRole, UserStatus
from common.logger import LOG from common.logger import LOG
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
from crud.user_crud import IUserCRUD, UserCRUD from crud.user_crud import IUserCRUD, UserCRUD
from router.v1.auth.protocol import ( from router.v1.auth.protocol import (
CompanyData,
Req_GoogleLogin, Req_GoogleLogin,
Req_Signup, Req_Signup,
Req_UpdateMe, Req_UpdateMe,
@ -71,11 +70,11 @@ class AuthService:
@staticmethod @staticmethod
def _user_info(user: users) -> UserInfo: def _user_info(user: users) -> UserInfo:
# uuid → str (JWT json 직렬화 위해). 기능 라우터는 company_id 로 스코프한다. # uuid → str (JWT json 직렬화 위해). 기능 라우터는 user_id 로 스코프한다
# — 사업장이 places.owner_user_id 로 이 값에 매여 있다.
return UserInfo( return UserInfo(
user_id=str(user.user_id), user_id=str(user.user_id),
id=user.id, id=user.id,
company_id=str(user.company_id),
role=user.role, role=user.role,
) )
@ -140,26 +139,17 @@ class AuthService:
password_hash: str | None, password_hash: str | None,
name: str | None, name: str | None,
email: str | None, email: str | None,
company_name: str,
provider: AuthProvider, provider: AuthProvider,
provider_uid: str | None, provider_uid: str | None,
) -> tuple[ErrorType, users]: ) -> tuple[ErrorType, users]:
"""회사 1개 + 그 회사의 첫 계정 1개를 한 트랜잭션으로 만든다. """계정 1개를 만든다.
★ 가입은 곧 새 테넌트다. users.company_id 가 NOT NULL 이고 모든 도메인(사업장·사이트)이 ★ 예전엔 가입 한 번이 **회사(테넌트) 하나**를 같이 만들었고 모든 도메인이 그 회사로
company_id 로 스코프되므로, 회사 없는 계정은 아무것도 만들지 못한다. 스코프됐다. 쓰는 사람은 사장님 혼자인데 자기 회사에 소속된 직원이 되는 구조라
★ uuid 를 여기서 미리 만든다. 모델 default 는 flush 시점에 적용돼서, 그 전에 걷어냈다(2026-09-08) — 이제 사업장이 `places.owner_user_id` 로 이 계정에 직접 매인다.
company.company_id 를 읽으면 None 이다 — 그대로 넣으면 NOT NULL 위반이다.""" ★ uuid 를 여기서 미리 만든다. 모델 default 는 flush 시점에 적용돼서 그 전에 읽으면 None 이다."""
company_uuid = uuid.uuid4()
company = companies(
company_id=company_uuid,
name=_fit(company_name, 100),
email=_fit(email, 255),
status=CompanyStatus.ACTIVE.value,
)
user = users( user = users(
user_id=uuid.uuid4(), user_id=uuid.uuid4(),
company_id=company_uuid,
id=login_id, id=login_id,
password=password_hash, password=password_hash,
name=_fit(name, 50), name=_fit(name, 50),
@ -171,10 +161,7 @@ class AuthService:
) )
err_type = await DB_SESSION_MNG.execute_lambda_run( err_type = await DB_SESSION_MNG.execute_lambda_run(
[users.DBType()], [users.DBType()],
[ [lambda s: self.user_crud.add_user(s, user)],
lambda s: self.user_crud.add_company(s, company),
lambda s: self.user_crud.add_user(s, user),
],
) )
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
return err_type, None return err_type, None
@ -226,7 +213,6 @@ class AuthService:
password_hash=await GetHashedPW(req.password), password_hash=await GetHashedPW(req.password),
name=name, name=name,
email=email, email=email,
company_name=(req.company_name or "").strip() or name or login_id,
provider=AuthProvider.LOCAL, provider=AuthProvider.LOCAL,
provider_uid=None, provider_uid=None,
) )
@ -268,13 +254,12 @@ class AuthService:
res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT) res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT)
return res return res
# 3) 첫 방문 — 계정과 회사를 만든다. # 3) 첫 방문 — 계정을 만든다.
err_type, user = await self._create_account( err_type, user = await self._create_account(
login_id=_google_login_id(account.sub), login_id=_google_login_id(account.sub),
password_hash=None, password_hash=None,
name=account.name or None, name=account.name or None,
email=account.email or None, email=account.email or None,
company_name=account.name or account.email or _google_login_id(account.sub),
provider=AuthProvider.GOOGLE, provider=AuthProvider.GOOGLE,
provider_uid=account.sub, provider_uid=account.sub,
) )
@ -299,16 +284,6 @@ class AuthService:
return res return res
user: users user: users
# 2) 소속사 조회 (없어도 치명적 아님)
company = None
c_err, company_row = await DB_SESSION_MNG.execute_lambda(
users.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.get_company(s, user.company_id),
)
if c_err == ErrorType.SUCCESS and company_row is not None:
company = CompanyData(company_id=str(company_row.company_id), name=company_row.name)
res.user_id = str(user.user_id) res.user_id = str(user.user_id)
res.id = user.id res.id = user.id
res.name = user.name res.name = user.name
@ -316,7 +291,6 @@ class AuthService:
res.contact_number = user.contact_number res.contact_number = user.contact_number
res.role = UserRole(user.role) res.role = UserRole(user.role)
res.provider = AuthProvider(user.provider) res.provider = AuthProvider(user.provider)
res.company = company
return res return res
async def update_me(self, user_info: UserInfo, req: Req_UpdateMe) -> Res_Me: async def update_me(self, user_info: UserInfo, req: Req_UpdateMe) -> Res_Me:

View File

@ -431,12 +431,12 @@ async def run_collect(job: dict) -> dict:
"""COLLECT 잡 핸들러. 반환값이 jobs.result 에 저장돼 폴링·감사에 쓰인다.""" """COLLECT 잡 핸들러. 반환값이 jobs.result 에 저장돼 폴링·감사에 쓰인다."""
payload = job["payload"] payload = job["payload"]
place_id = payload["place_id"] place_id = payload["place_id"]
company_id = payload["company_id"] owner_user_id = payload["owner_user_id"]
err, place = await DB_SESSION_MNG.execute_lambda( err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, 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: if err != ErrorType.SUCCESS or place is None:
raise CollectAborted(f"사업장을 찾을 수 없다: {place_id}") raise CollectAborted(f"사업장을 찾을 수 없다: {place_id}")
@ -464,7 +464,7 @@ async def run_collect(job: dict) -> dict:
if not targets: if not targets:
result["note"] = "크롤링 대상이 없다(어댑터가 처리할 수 있는 확정 URL 0건)" result["note"] = "크롤링 대상이 없다(어댑터가 처리할 수 있는 확정 URL 0건)"
await _finish(place_id, company_id, PlaceStatus.REVIEW) await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)
return result return result
# ★ 이미 충분하면 크롤링 자체를 건너뛴다(force 가 아닐 때). # ★ 이미 충분하면 크롤링 자체를 건너뛴다(force 가 아닐 때).
@ -472,16 +472,19 @@ async def run_collect(job: dict) -> dict:
if before["enough"] and not payload.get("force"): if before["enough"] and not payload.get("force"):
result["coverage"] = before result["coverage"] = before
result["note"] = "이미 필수 항목이 다 차 있다 — 크롤링 생략(force=true 로 강제 가능)" result["note"] = "이미 필수 항목이 다 차 있다 — 크롤링 생략(force=true 로 강제 가능)"
await _finish(place_id, company_id, PlaceStatus.REVIEW) await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)
LOG.i(f"[collect] 충분함 place={place_id} {before['covered']}/{before['total']} — 크롤링 생략") LOG.i(f"[collect] 충분함 place={place_id} {before['covered']}/{before['total']} — 크롤링 생략")
return result return result
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
actor = UserInfo( actor = UserInfo(
user_id=payload.get("requested_by") or str(place.verified_by or uuid.uuid4()), # ★ 잡이 쓰는 신원. user_id 는 **사업장 주인**이어야 한다 — FactService 가 이 값으로
# 사업장을 스코프하고(fact_service._load_place) verified_by 에도 그대로 박는다.
# 회사를 걷어내기 전에는 스코프가 company_id 였고 여기엔 요청자·검증자·랜덤 uuid 가
# 순서대로 들어갔다. 그 랜덤 uuid 가 이제는 "남의 사업장" 이 되어 조회가 0건이 된다.
user_id=owner_user_id,
id="collector", id="collector",
company_id=company_id,
role=1, role=1,
) )
@ -538,24 +541,24 @@ async def run_collect(job: dict) -> dict:
# 사진이 들어왔으면 분석을 이어서 건다 — 수집과 분석은 각각 몇 분이라 한 잡에 묶지 않는다. # 사진이 들어왔으면 분석을 이어서 건다 — 수집과 분석은 각각 몇 분이라 한 잡에 묶지 않는다.
# (묶으면 분석에서 죽었을 때 수집까지 다시 하게 되고, 유료 API 를 두 번 태운다.) # (묶으면 분석에서 죽었을 때 수집까지 다시 하게 되고, 유료 API 를 두 번 태운다.)
if result["media"]["stored"] > 0: if result["media"]["stored"] > 0:
result["vision_job_id"] = await _enqueue_vision(place_id, company_id) result["vision_job_id"] = await _enqueue_vision(place_id, owner_user_id)
await _finish(place_id, company_id, PlaceStatus.REVIEW) await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)
LOG.i(f"[collect] 완료 place={place_id} fact {result['facts']['stored']}건 · 사진 {result['media']['stored']}장") LOG.i(f"[collect] 완료 place={place_id} fact {result['facts']['stored']}건 · 사진 {result['media']['stored']}장")
return result return result
async def _finish(place_id: str, company_id: str, status: PlaceStatus): async def _finish(place_id: str, owner_user_id: str, status: PlaceStatus):
"""수집이 끝나면 사업장을 검수 대기로 돌린다 — 수집값은 전부 후보라 사람이 봐야 한다.""" """수집이 끝나면 사업장을 검수 대기로 돌린다 — 수집값은 전부 후보라 사람이 봐야 한다."""
await DB_SESSION_MNG.execute_lambda_claim( await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(), places.DBType(),
lambda s: _place_crud.update_place( lambda s: _place_crud.update_place(
s, uuid.UUID(company_id), uuid.UUID(place_id), {"status": status.value} s, uuid.UUID(owner_user_id), uuid.UUID(place_id), {"status": status.value}
), ),
) )
async def _enqueue_vision(place_id: str, company_id: str) -> str | None: async def _enqueue_vision(place_id: str, owner_user_id: str) -> str | None:
"""사진 분석 잡을 적재한다. 키가 없거나 중복이면 조용히 건너뛴다(수집 자체는 성공이다).""" """사진 분석 잡을 적재한다. 키가 없거나 중복이면 조용히 건너뛴다(수집 자체는 성공이다)."""
from common.enums import JobType from common.enums import JobType
from crud.job_crud import JobQueue from crud.job_crud import JobQueue
@ -567,7 +570,7 @@ async def _enqueue_vision(place_id: str, company_id: str) -> str | None:
return None return None
job_id, _created = await enqueue_job( job_id, _created = await enqueue_job(
JobQueue(), JobType.VISION, JobQueue(), JobType.VISION,
{"place_id": place_id, "company_id": company_id}, {"place_id": place_id, "owner_user_id": owner_user_id},
dedupe_key=f"vision:{place_id}", dedupe_key=f"vision:{place_id}",
) )
return job_id return job_id

View File

@ -40,10 +40,10 @@ class CopyAborted(RuntimeError):
async def run_copy(job: dict) -> dict: async def run_copy(job: dict) -> dict:
"""COPY 잡 핸들러. payload: {place_id, company_id, requested_by?}""" """COPY 잡 핸들러. payload: {place_id, owner_user_id, requested_by?}"""
payload = job["payload"] payload = job["payload"]
place_id = payload["place_id"] place_id = payload["place_id"]
company_id = payload["company_id"] owner_user_id = payload["owner_user_id"]
if not gemini_text.is_configured(): if not gemini_text.is_configured():
raise CopyAborted("GEMINI_API_KEY 미설정 — 소개문·FAQ 를 생성할 수 없다") raise CopyAborted("GEMINI_API_KEY 미설정 — 소개문·FAQ 를 생성할 수 없다")
@ -51,7 +51,7 @@ async def run_copy(job: dict) -> dict:
err, place = await DB_SESSION_MNG.execute_lambda( err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, 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: if err != ErrorType.SUCCESS or place is None:
raise CopyAborted(f"사업장을 찾을 수 없다: {place_id}") raise CopyAborted(f"사업장을 찾을 수 없다: {place_id}")
@ -165,9 +165,12 @@ async def run_copy(job: dict) -> dict:
} }
actor = UserInfo( actor = UserInfo(
user_id=payload.get("requested_by") or str(place.verified_by or uuid.uuid4()), # ★ 잡이 쓰는 신원. user_id 는 **사업장 주인**이어야 한다 — FactService 가 이 값으로
# 사업장을 스코프하고(fact_service._load_place) verified_by 에도 그대로 박는다.
# 회사를 걷어내기 전에는 스코프가 company_id 였고 여기엔 요청자·검증자·랜덤 uuid 가
# 순서대로 들어갔다. 그 랜덤 uuid 가 이제는 "남의 사업장" 이 되어 조회가 0건이 된다.
user_id=owner_user_id,
id="generator", id="generator",
company_id=company_id,
role=1, role=1,
) )
service = FactService(_fact_crud, _place_crud) service = FactService(_fact_crud, _place_crud)

View File

@ -73,7 +73,7 @@ class FactService:
err_type, place = await DB_SESSION_MNG.execute_lambda( err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, 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: if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None return ErrorType.PLACE_NOT_FOUND, None

View File

@ -38,14 +38,14 @@ class FaqService:
self.crud = crud self.crud = crud
self.place_crud = place_crud self.place_crud = place_crud
# ---- 사업장 로드(회사 스코프) ---- # ---- 사업장 로드(사장님 스코프) ----
async def _load_place(self, user_info: UserInfo, place_id: str): async def _load_place(self, user_info: UserInfo, place_id: str):
"""company_id 를 WHERE 에 걸어 조회한다 — 남의 회사 place_id 를 넣으면 PLACE_NOT_FOUND. """owner_user_id 를 WHERE 에 걸어 조회한다 — 남의 place_id 를 넣으면 PLACE_NOT_FOUND.
'없다'와 '권한 없다'를 구분해 주지 않는 것도 의도다(존재 여부를 흘리지 않는다).""" '없다'와 '권한 없다'를 구분해 주지 않는 것도 의도다(존재 여부를 흘리지 않는다)."""
err_type, place = await DB_SESSION_MNG.execute_lambda( err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, 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: if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None return ErrorType.PLACE_NOT_FOUND, None

View File

@ -41,7 +41,7 @@ class MediaService:
err_type, place = await DB_SESSION_MNG.execute_lambda( err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, 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: if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None return ErrorType.PLACE_NOT_FOUND, None

View File

@ -70,12 +70,12 @@ class PlaceService:
# ---- 조회 ---- # ---- 조회 ----
async def list_places(self, user_info: UserInfo, pg: PageParams, search=None, category=None, status=None) -> Res_PlaceList: async def list_places(self, user_info: UserInfo, pg: PageParams, search=None, category=None, status=None) -> Res_PlaceList:
res = Res_PlaceList(page=pg.page, size=pg.size) res = Res_PlaceList(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( err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, DBWRType.DB_READ.value,
lambda s: self.crud.list_places( lambda s: self.crud.list_places(
s, cid, search, s, uid, search,
category.value if isinstance(category, PlaceCategory) else category, category.value if isinstance(category, PlaceCategory) else category,
status.value if isinstance(status, PlaceStatus) else status, status.value if isinstance(status, PlaceStatus) else status,
pg.skip, pg.size, pg.skip, pg.size,
@ -102,7 +102,7 @@ class PlaceService:
err_type, place = await DB_SESSION_MNG.execute_lambda( err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, DBWRType.DB_READ.value,
lambda s: self.crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)), lambda s: self.crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)),
) )
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None return ErrorType.PLACE_NOT_FOUND, None
@ -122,8 +122,11 @@ class PlaceService:
return res return res
place = places( place = places(
company_id=uuid.UUID(user_info.company_id), # ★ 주인은 **토큰이 정한다.** 예전엔 요청 body 의 owner_user_id 를 그대로 넣었는데,
owner_user_id=req.owner_user_id, # 그 값은 아무도 안 보내서 92건 전부 NULL 이었고 스코프는 회사가 대신 하고 있었다.
# 회사를 걷어내면서 이 컬럼이 스코프 키가 됐다 — body 로 남의 계정을 적을 수 있으면
# 만들자마자 남의 목록에 들어간다.
owner_user_id=uuid.UUID(user_info.user_id),
name=req.name.strip(), name=req.name.strip(),
category=req.category.value, category=req.category.value,
status=PlaceStatus.DRAFT.value, status=PlaceStatus.DRAFT.value,
@ -149,7 +152,7 @@ class PlaceService:
if data: if data:
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim( err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(), places.DBType(),
lambda s: self.crud.update_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id), data), lambda s: self.crud.update_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id), data),
) )
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type) res.result.SetResult(err_type)
@ -164,7 +167,7 @@ class PlaceService:
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim( err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(), places.DBType(),
lambda s: self.crud.delete_place( lambda s: self.crud.delete_place(
s, uuid.UUID(user_info.company_id), uuid.UUID(place_id) s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)
), ),
) )
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
@ -251,7 +254,7 @@ class PlaceService:
await DB_SESSION_MNG.execute_lambda_claim( await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(), places.DBType(),
lambda s: self.crud.update_place( lambda s: self.crud.update_place(
s, uuid.UUID(user_info.company_id), uuid.UUID(place_id), {"name": official} s, uuid.UUID(user_info.user_id), uuid.UUID(place_id), {"name": official}
), ),
) )
if verified.place: if verified.place:
@ -292,7 +295,7 @@ class PlaceService:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res return res
cid = uuid.UUID(user_info.company_id) uid = uuid.UUID(user_info.user_id)
now = GTime.UTC() now = GTime.UTC()
data = { data = {
"external_source": req.source.value, "external_source": req.source.value,
@ -320,7 +323,7 @@ class PlaceService:
} }
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim( err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(), places.DBType(),
lambda s: self.crud.update_place(s, cid, uuid.UUID(place_id), data), lambda s: self.crud.update_place(s, uid, uuid.UUID(place_id), data),
) )
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type) res.result.SetResult(err_type)
@ -508,7 +511,7 @@ class PlaceService:
payload = { payload = {
"place_id": place_id, "place_id": place_id,
"company_id": user_info.company_id, "owner_user_id": user_info.user_id,
"category": place.category, "category": place.category,
# 명시적으로 고른 링크가 있을 때만 대상을 제한한다. 기본 요청에서 현재 # 명시적으로 고른 링크가 있을 때만 대상을 제한한다. 기본 요청에서 현재
# 확정 링크를 복사하면, 잡의 discover 단계가 새로 확정한 네이버 링크가 # 확정 링크를 복사하면, 잡의 discover 단계가 새로 확정한 네이버 링크가
@ -537,7 +540,7 @@ class PlaceService:
await DB_SESSION_MNG.execute_lambda_claim( await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(), places.DBType(),
lambda s: self.crud.update_place( lambda s: self.crud.update_place(
s, uuid.UUID(user_info.company_id), uuid.UUID(place_id), s, uuid.UUID(user_info.user_id), uuid.UUID(place_id),
{"status": PlaceStatus.COLLECTING.value}, {"status": PlaceStatus.COLLECTING.value},
), ),
) )
@ -584,7 +587,7 @@ class PlaceService:
job_id, created = await enqueue_job( job_id, created = await enqueue_job(
self.queue, JobType.VISION, self.queue, JobType.VISION,
{"place_id": place_id, "company_id": user_info.company_id, "force": req.force}, {"place_id": place_id, "owner_user_id": user_info.user_id, "force": req.force},
dedupe_key=f"vision:{place_id}", dedupe_key=f"vision:{place_id}",
) )
if job_id is None: if job_id is None:
@ -844,7 +847,7 @@ class PlaceService:
job_id, created = await enqueue_job( job_id, created = await enqueue_job(
self.queue, JobType.COPY, self.queue, JobType.COPY,
{"place_id": place_id, "company_id": user_info.company_id, "requested_by": user_info.user_id}, {"place_id": place_id, "owner_user_id": user_info.user_id, "requested_by": user_info.user_id},
dedupe_key=f"copy:{place_id}", dedupe_key=f"copy:{place_id}",
) )
if job_id is None: if job_id is None:

View File

@ -5,7 +5,7 @@
고르는 자리를 한 곳으로 모았다. 사이트 한 곳을 여는 것과 발행 업소 명단을 통째로 고르는 자리를 한 곳으로 모았다. 사이트 한 곳을 여는 것과 발행 업소 명단을 통째로
긁는 것은 다른 일이라, 페이지에 이미 적혀 있는 것만 나간다. 긁는 것은 다른 일이라, 페이지에 이미 적혀 있는 것만 나간다.
나가지 않는 것: place_id · company_id · site_id · 전화번호 · 상세 주소 · 좌표. 나가지 않는 것: place_id · 소유자 · site_id · 전화번호 · 상세 주소 · 좌표.
""" """
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG

View File

@ -28,10 +28,10 @@ class VisionAborted(RuntimeError):
async def run_vision(job: dict) -> dict: async def run_vision(job: dict) -> dict:
"""VISION 잡 핸들러. payload: {place_id, company_id, force?}""" """VISION 잡 핸들러. payload: {place_id, owner_user_id, force?}"""
payload = job["payload"] payload = job["payload"]
place_id = payload["place_id"] place_id = payload["place_id"]
company_id = payload["company_id"] owner_user_id = payload["owner_user_id"]
force = bool(payload.get("force")) force = bool(payload.get("force"))
if not gemini.is_configured(): if not gemini.is_configured():
@ -40,7 +40,7 @@ async def run_vision(job: dict) -> dict:
err, place = await DB_SESSION_MNG.execute_lambda( err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, 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: if err != ErrorType.SUCCESS or place is None:
raise VisionAborted(f"사업장을 찾을 수 없다: {place_id}") raise VisionAborted(f"사업장을 찾을 수 없다: {place_id}")

View File

@ -4,9 +4,9 @@
""" """
async def test_login_and_me_flow(auth_headers, client, company_id): async def test_login_and_me_flow(auth_headers, client):
"""검증: 시드된 유저가 로그인해 받은 토큰으로 /me 호출. """검증: 시드된 유저가 로그인해 받은 토큰으로 /me 호출.
기대결과: 200, 본인 id·name·소속사(company_id)가 그대로 반환.""" 기대결과: 200, 본인 id·name 이 그대로 반환."""
h = await auth_headers("user1", name="홍길동") h = await auth_headers("user1", name="홍길동")
r = await client.get("/v1/auth/me", headers=h) r = await client.get("/v1/auth/me", headers=h)
@ -14,7 +14,8 @@ async def test_login_and_me_flow(auth_headers, client, company_id):
me = r.json() me = r.json()
assert me["id"] == "user1" assert me["id"] == "user1"
assert me["name"] == "홍길동" assert me["name"] == "홍길동"
assert me["company"]["company_id"] == company_id # ★ 소속사 필드는 없다. 회사(테넌트)를 걷어냈다(2026-09-08) — 쓰는 사람은 사장님 혼자다.
assert "company" not in me
async def test_login_with_wrong_password(auth_headers, client): async def test_login_with_wrong_password(auth_headers, client):
@ -49,7 +50,7 @@ _SIGNUP = {"id": "sajang1", "password": "pw12345678", "name": "김사장", "emai
async def test_signup_creates_account_and_logs_in(client, db_engine): async def test_signup_creates_account_and_logs_in(client, db_engine):
"""검증: 가입 → 받은 토큰으로 곧바로 /me. """검증: 가입 → 받은 토큰으로 곧바로 /me.
기대결과: 토큰이 실려 오고, /me 가 방금 만든 신원과 **새로 생긴 소속사**를 돌려준다.""" 기대결과: 토큰이 실려 오고, /me 가 방금 만든 신원을 돌려준다."""
r = await client.post("/v1/auth/signup", json=_SIGNUP) r = await client.post("/v1/auth/signup", json=_SIGNUP)
body = r.json() body = r.json()
assert body["result"]["success"] is True assert body["result"]["success"] is True
@ -59,7 +60,6 @@ async def test_signup_creates_account_and_logs_in(client, db_engine):
assert me["id"] == "sajang1" assert me["id"] == "sajang1"
assert me["email"] == "boss@example.com" assert me["email"] == "boss@example.com"
assert me["provider"] == 1 # AuthProvider.LOCAL assert me["provider"] == 1 # AuthProvider.LOCAL
assert me["company"]["name"] == "김사장" # 회사명 미입력 → 이름으로 채운다
async def test_signup_rejects_duplicate_id(client, db_engine): async def test_signup_rejects_duplicate_id(client, db_engine):

View File

@ -264,12 +264,12 @@ async def test_versions_accumulate(auth_headers, client, db_engine):
assert [v["version"] for v in versions] == [2, 1] assert [v["version"] for v in versions] == [2, 1]
async def test_site_is_scoped_to_company(auth_headers, client, other_company_id): async def test_site_is_scoped_to_owner(auth_headers, client):
"""검증: 다른 회사 계정으로 남의 사이트를 본다. """검증: 다른 사장님 계정으로 남의 사이트를 본다.
기대결과: PLACE_NOT_FOUND.""" 기대결과: PLACE_NOT_FOUND."""
h1 = await auth_headers("o1") h1 = await auth_headers("o1")
pid = await _place(client, h1, "스코프펜션") pid = await _place(client, h1, "스코프펜션")
h2 = await auth_headers("o2", other_company_id) h2 = await auth_headers("o2")
r = await client.get(f"/v1/place/{pid}/site", headers=h2) r = await client.get(f"/v1/place/{pid}/site", headers=h2)
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -156,13 +156,13 @@ async def test_targeted_collect_payload_carries_requested_confirmed_link(auth_he
assert job["payload"]["link_ids"] == [confirmed] assert job["payload"]["link_ids"] == [confirmed]
async def test_collect_is_scoped_to_company(auth_headers, client, other_company_id): async def test_collect_is_scoped_to_owner(auth_headers, client):
"""검증: 다른 회사 계정으로 남의 사업장 수집을 시작한다. """검증: 다른 사장님 계정으로 남의 사업장 수집을 시작한다.
기대결과: PLACE_NOT_FOUND — 사업장이 안 보이니 수집도 못 건다.""" 기대결과: PLACE_NOT_FOUND — 사업장이 안 보이니 수집도 못 건다."""
h1 = await auth_headers("o1") h1 = await auth_headers("o1")
pid = await _place(client, h1, kakao="c7") pid = await _place(client, h1, kakao="c7")
await _confirmed_link(client, h1, pid) await _confirmed_link(client, h1, pid)
h2 = await auth_headers("o2", other_company_id) h2 = await auth_headers("o2")
r = await client.post(f"/v1/place/{pid}/collect", headers=h2, json={}) r = await client.post(f"/v1/place/{pid}/collect", headers=h2, json={})
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -208,7 +208,7 @@ async def test_recollect_cannot_overwrite_corrected_value(auth_headers, client):
assert check_in[0]["status"] == FactStatus.CORRECTED.value assert check_in[0]["status"] == FactStatus.CORRECTED.value
async def test_pipeline_refuses_unverified_place(db_engine, company_id): async def test_pipeline_refuses_unverified_place(db_engine, owner_id):
"""검증: 검증 안 된 사업장의 수집 잡이 큐에 직접 들어간 경우(잡 적재 후 검증이 취소된 상황). """검증: 검증 안 된 사업장의 수집 잡이 큐에 직접 들어간 경우(잡 적재 후 검증이 취소된 상황).
기대결과: 잡이 실패한다 — ★ 잡 실행 시점에도 게이트를 다시 확인한다.""" 기대결과: 잡이 실패한다 — ★ 잡 실행 시점에도 게이트를 다시 확인한다."""
from sqlalchemy import text from sqlalchemy import text
@ -216,13 +216,13 @@ async def test_pipeline_refuses_unverified_place(db_engine, company_id):
pid = uuid.uuid4() pid = uuid.uuid4()
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
await conn.execute( await conn.execute(
text("INSERT INTO places (place_id, company_id, name, category, status) " text("INSERT INTO places (place_id, owner_user_id, name, category, status) "
"VALUES (:pid, :cid, :n, 1, 1)"), "VALUES (:pid, :cid, :n, 1, 1)"),
{"pid": pid, "cid": uuid.UUID(company_id), "n": "미검증펜션"}, {"pid": pid, "cid": uuid.UUID(owner_id), "n": "미검증펜션"},
) )
q = JobQueue() q = JobQueue()
job_id = await q.enqueue(JobType.COLLECT.value, {"place_id": str(pid), "company_id": company_id}, max_attempts=1) job_id = await q.enqueue(JobType.COLLECT.value, {"place_id": str(pid), "owner_user_id": owner_id}, max_attempts=1)
worker = Worker("test-worker", q, build_handler(), backoff_fn=lambda _a: 0) worker = Worker("test-worker", q, build_handler(), backoff_fn=lambda _a: 0)
await worker.process_one() await worker.process_one()

View File

@ -269,12 +269,12 @@ async def test_crawl_only_does_not_mark_rebuild(auth_headers, client):
assert place.get("content_updated_at") is None assert place.get("content_updated_at") is None
async def test_facts_are_scoped_to_company(auth_headers, client, other_company_id): async def test_facts_are_scoped_to_owner(auth_headers, client):
"""검증: 다른 회사 계정으로 남의 사업장 fact 를 조회한다. """검증: 다른 사장님 계정으로 남의 사업장 fact 를 조회한다.
기대결과: PLACE_NOT_FOUND — 사업장이 안 보이니 fact 도 안 보인다.""" 기대결과: PLACE_NOT_FOUND — 사업장이 안 보이니 fact 도 안 보인다."""
h1 = await auth_headers("o1") h1 = await auth_headers("o1")
pid = await _verified_place(client, h1, kakao="p6") pid = await _verified_place(client, h1, kakao="p6")
h2 = await auth_headers("o2", other_company_id) h2 = await auth_headers("o2")
r = await client.get(f"/v1/place/{pid}/fact/list", headers=h2) r = await client.get(f"/v1/place/{pid}/fact/list", headers=h2)
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -22,17 +22,17 @@ from common.enums import (
) )
async def _seed_place(db_engine, company_id) -> str: async def _seed_place(db_engine, owner_id) -> str:
"""검증까지 끝난 사업장 1개를 시드하고 place_id 를 돌려준다.""" """검증까지 끝난 사업장 1개를 시드하고 place_id 를 돌려준다."""
pid = uuid.uuid4() pid = uuid.uuid4()
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
await conn.execute( await conn.execute(
text( text(
"INSERT INTO places (place_id, company_id, name, category, status, external_place_id, verified_at) " "INSERT INTO places (place_id, owner_user_id, name, category, status, external_place_id, verified_at) "
"VALUES (:pid, :cid, :name, :cat, :status, :kakao, now())" "VALUES (:pid, :cid, :name, :cat, :status, :kakao, now())"
), ),
{ {
"pid": pid, "cid": uuid.UUID(company_id), "name": "테스트펜션", "pid": pid, "cid": uuid.UUID(owner_id), "name": "테스트펜션",
"cat": PlaceCategory.LODGING.value, "status": PlaceStatus.DRAFT.value, "cat": PlaceCategory.LODGING.value, "status": PlaceStatus.DRAFT.value,
"kakao": "12345678", "kakao": "12345678",
}, },
@ -54,20 +54,20 @@ async def _insert_fact(db_engine, place_id, key, value, status, unit_id=None):
) )
async def test_published_fact_is_unique_per_place_and_key(db_engine, company_id): async def test_published_fact_is_unique_per_place_and_key(db_engine, owner_id):
"""검증: 같은 사업장·같은 key 로 노출 상태 fact 를 두 번 넣는다. """검증: 같은 사업장·같은 key 로 노출 상태 fact 를 두 번 넣는다.
기대결과: 두 번째 INSERT 가 유니크 인덱스에 막힌다(체크인 시간이 두 값으로 갈라지지 않는다).""" 기대결과: 두 번째 INSERT 가 유니크 인덱스에 막힌다(체크인 시간이 두 값으로 갈라지지 않는다)."""
place_id = await _seed_place(db_engine, company_id) place_id = await _seed_place(db_engine, owner_id)
await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.VERIFIED) await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.VERIFIED)
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.CORRECTED) await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.CORRECTED)
async def test_candidates_coexist_with_published_value(db_engine, company_id): async def test_candidates_coexist_with_published_value(db_engine, owner_id):
"""검증: 노출값이 있는 상태에서 재수집 후보를 여러 건 넣는다. """검증: 노출값이 있는 상태에서 재수집 후보를 여러 건 넣는다.
기대결과: 전부 공존한다 — ★ 재수집이 노출 중인 사실을 밀어내지 않는다.""" 기대결과: 전부 공존한다 — ★ 재수집이 노출 중인 사실을 밀어내지 않는다."""
place_id = await _seed_place(db_engine, company_id) place_id = await _seed_place(db_engine, owner_id)
await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.VERIFIED) await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.VERIFIED)
await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.PENDING_OWNER) await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.PENDING_OWNER)
await _insert_fact(db_engine, place_id, "check_in_time", "14:00", FactStatus.UNVERIFIED) await _insert_fact(db_engine, place_id, "check_in_time", "14:00", FactStatus.UNVERIFIED)
@ -80,10 +80,10 @@ async def test_candidates_coexist_with_published_value(db_engine, company_id):
assert len(rows) == 3, "노출값 1건 + 후보 2건이 공존해야 한다" assert len(rows) == 3, "노출값 1건 + 후보 2건이 공존해야 한다"
async def test_rejected_fact_frees_the_key(db_engine, company_id): async def test_rejected_fact_frees_the_key(db_engine, owner_id):
"""검증: 기존 값을 REJECTED 로 내린 뒤 같은 key 를 새로 노출한다. """검증: 기존 값을 REJECTED 로 내린 뒤 같은 key 를 새로 노출한다.
기대결과: 통과 — 틀린 값은 이력으로 남고, 새 값이 노출 자리를 차지한다.""" 기대결과: 통과 — 틀린 값은 이력으로 남고, 새 값이 노출 자리를 차지한다."""
place_id = await _seed_place(db_engine, company_id) place_id = await _seed_place(db_engine, owner_id)
await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.REJECTED) await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.REJECTED)
await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.VERIFIED) await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.VERIFIED)
@ -95,18 +95,18 @@ async def test_rejected_fact_frees_the_key(db_engine, company_id):
assert len(rows) == 2, "REJECTED 이력과 새 값이 함께 남아야 한다" assert len(rows) == 2, "REJECTED 이력과 새 값이 함께 남아야 한다"
async def test_expired_fact_frees_the_key(db_engine, company_id): async def test_expired_fact_frees_the_key(db_engine, owner_id):
"""검증: 유효기간이 지나 EXPIRED 로 내린 값과 새 수집값의 공존. """검증: 유효기간이 지나 EXPIRED 로 내린 값과 새 수집값의 공존.
기대결과: 통과 — EXPIRED 도 유니크에서 빠진다.""" 기대결과: 통과 — EXPIRED 도 유니크에서 빠진다."""
place_id = await _seed_place(db_engine, company_id) place_id = await _seed_place(db_engine, owner_id)
await _insert_fact(db_engine, place_id, "cancel_policy", "구 규정", FactStatus.EXPIRED) await _insert_fact(db_engine, place_id, "cancel_policy", "구 규정", FactStatus.EXPIRED)
await _insert_fact(db_engine, place_id, "cancel_policy", "새 규정", FactStatus.VERIFIED) await _insert_fact(db_engine, place_id, "cancel_policy", "새 규정", FactStatus.VERIFIED)
async def test_same_key_allowed_across_units(db_engine, company_id): async def test_same_key_allowed_across_units(db_engine, owner_id):
"""검증: 객실이 다르면 같은 key 를 각각 가질 수 있는지. """검증: 객실이 다르면 같은 key 를 각각 가질 수 있는지.
기대결과: 통과 — A동·B동이 각자의 기준 인원을 갖는다.""" 기대결과: 통과 — A동·B동이 각자의 기준 인원을 갖는다."""
place_id = await _seed_place(db_engine, company_id) place_id = await _seed_place(db_engine, owner_id)
unit_a, unit_b = uuid.uuid4(), uuid.uuid4() unit_a, unit_b = uuid.uuid4(), uuid.uuid4()
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
for uid, name in ((unit_a, "A동"), (unit_b, "B동")): for uid, name in ((unit_a, "A동"), (unit_b, "B동")):
@ -122,10 +122,10 @@ async def test_same_key_allowed_across_units(db_engine, company_id):
await _insert_fact(db_engine, place_id, "standard_capacity", "6", FactStatus.CORRECTED, unit_id=unit_a) await _insert_fact(db_engine, place_id, "standard_capacity", "6", FactStatus.CORRECTED, unit_id=unit_a)
async def test_unit_fact_and_place_fact_are_separate(db_engine, company_id): async def test_unit_fact_and_place_fact_are_separate(db_engine, owner_id):
"""검증: 같은 key 를 사업장 단위와 객실 단위로 동시에 갖는 경우. """검증: 같은 key 를 사업장 단위와 객실 단위로 동시에 갖는 경우.
기대결과: 통과 — 부분 인덱스가 unit_id NULL 여부로 갈라져 있다.""" 기대결과: 통과 — 부분 인덱스가 unit_id NULL 여부로 갈라져 있다."""
place_id = await _seed_place(db_engine, company_id) place_id = await _seed_place(db_engine, owner_id)
unit_id = uuid.uuid4() unit_id = uuid.uuid4()
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
await conn.execute( await conn.execute(

View File

@ -56,14 +56,14 @@ async def test_generated_faq_is_pending_and_not_publishable(auth_headers, client
assert (await _list(client, h, pid, publishable_only=True)).get("faqs", []) == [] assert (await _list(client, h, pid, publishable_only=True)).get("faqs", []) == []
async def test_other_company_cannot_read_or_touch_faq(auth_headers, client, db_engine, other_company_id): async def test_other_owner_cannot_read_or_touch_faq(auth_headers, client, db_engine):
"""검증: 남의 회사 사용자가 place_id 를 알아내 FAQ 를 조회·전이한다. """검증: 남의 사용자가 place_id 를 알아내 FAQ 를 조회·전이한다.
기대결과: PLACE_NOT_FOUND — 존재 여부조차 알려주지 않는다.""" 기대결과: PLACE_NOT_FOUND — 존재 여부조차 알려주지 않는다."""
h = await auth_headers("u1") h = await auth_headers("u1")
pid = await _place(client, h) pid = await _place(client, h)
fid = await _seed_generated_faq(db_engine, pid) fid = await _seed_generated_faq(db_engine, pid)
other = await auth_headers("u2", other_company_id) other = await auth_headers("u2")
assert (await _list(client, other, pid))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value assert (await _list(client, other, pid))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
body = await _transition(client, other, pid, fid, {"status": FactStatus.VERIFIED.value}) body = await _transition(client, other, pid, fid, {"status": FactStatus.VERIFIED.value})
assert body["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value assert body["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -3,7 +3,7 @@
이 경로가 절대 하면 안 되는 것: 이 경로가 절대 하면 안 되는 것:
- 사이트가 아직 없는 사업장을 빼는 것 — 위저드를 걸어오다 만 가게가 목록에서 사라지면 - 사이트가 아직 없는 사업장을 빼는 것 — 위저드를 걸어오다 만 가게가 목록에서 사라지면
사장님은 그걸 다시 찾을 길이 없다(에디터 주소를 아무도 기억하지 않는다). 사장님은 그걸 다시 찾을 길이 없다(에디터 주소를 아무도 기억하지 않는다).
- 회사 스코프를 놓치는 것 — 남의 가게가 내 목록에 섞이면 그건 목록이 아니라 사고다. - 사장님 스코프를 놓치는 것 — 남의 가게가 내 목록에 섞이면 그건 목록이 아니라 사고다.
- 단건(GET /v1/place/{id}/site)과 다른 재빌드 판정을 내는 것 — 목록과 에디터가 서로 다른 - 단건(GET /v1/place/{id}/site)과 다른 재빌드 판정을 내는 것 — 목록과 에디터가 서로 다른
답을 하면 사장님은 어느 쪽을 믿을지 알 수 없다. 답을 하면 사장님은 어느 쪽을 믿을지 알 수 없다.
""" """
@ -53,11 +53,11 @@ async def test_site_row_is_joined_into_the_line(auth_headers, client):
assert row["status"] == SiteStatus.DRAFT.value assert row["status"] == SiteStatus.DRAFT.value
async def test_other_company_sites_are_not_listed(auth_headers, client, other_company_id): async def test_other_owners_sites_are_not_listed(auth_headers, client):
"""검증: 회사(테넌트) 스코프. 남의 회사 사업장은 보이지 않는다. """검증: 사장님 스코프. 남의 사업장은 보이지 않는다.
기대결과: 각자 자기 것만 1건.""" 기대결과: 각자 자기 것만 1건."""
mine = await auth_headers("my3") mine = await auth_headers("my3")
theirs = await auth_headers("my3b", other_company_id) theirs = await auth_headers("my3b")
await _place(client, mine, "내펜션") await _place(client, mine, "내펜션")
await _place(client, theirs, "남의펜션") await _place(client, theirs, "남의펜션")

View File

@ -1,8 +1,8 @@
"""places 도메인 e2e — 등록 / 동일 업소 검증 / 회사 스코프 / 채널 URL 확정 게이트. """places 도메인 e2e — 등록 / 동일 업소 검증 / 사장님 스코프 / 채널 URL 확정 게이트.
★ 이 도메인의 핵심 규칙 두 개를 고정한다: ★ 이 도메인의 핵심 규칙 두 개를 고정한다:
1. 검증(verify) 전에는 채널 URL 을 확정할 수 없다 → 크롤링이 안 열린다 1. 검증(verify) 전에는 채널 URL 을 확정할 수 없다 → 크롤링이 안 열린다
2. 남의 회사 사업장은 '없음'으로 보인다 2. 남의 사업장은 '없음'으로 보인다
""" """
import uuid import uuid
@ -61,7 +61,7 @@ async def test_verify_place_opens_collection(auth_headers, client):
async def test_duplicate_kakao_place_is_allowed(auth_headers, client): async def test_duplicate_kakao_place_is_allowed(auth_headers, client):
"""검증: 같은 회사에서 같은 카카오 장소를 두 사업장에 붙인다. """검증: 같은 사장님이 같은 카카오 장소를 두 사업장에 붙인다.
기대결과: 둘 다 등록된다 — 한 사용자가 같은 실제 업장으로 여러 프로젝트를 만들 수 있다.""" 기대결과: 둘 다 등록된다 — 한 사용자가 같은 실제 업장으로 여러 프로젝트를 만들 수 있다."""
h = await auth_headers("u1") h = await auth_headers("u1")
first = (await _create_place(client, h, "A펜션"))["place"]["place_id"] first = (await _create_place(client, h, "A펜션"))["place"]["place_id"]
@ -138,13 +138,13 @@ async def test_verify_without_any_identifier_is_rejected(auth_headers, client):
assert r.json()["result"]["code"] == ErrorType.PLACE_VERIFY_NO_CANDIDATE.value assert r.json()["result"]["code"] == ErrorType.PLACE_VERIFY_NO_CANDIDATE.value
async def test_place_is_scoped_to_company(auth_headers, client, other_company_id): async def test_place_is_scoped_to_owner(auth_headers, client):
"""검증: 다른 회사 계정으로 남의 사업장을 조회한다. """검증: 다른 사장님 계정으로 남의 사업장을 조회한다.
기대결과: PLACE_NOT_FOUND — 존재 자체가 보이지 않는다(IDOR 차단).""" 기대결과: PLACE_NOT_FOUND — 존재 자체가 보이지 않는다(IDOR 차단)."""
h1 = await auth_headers("owner1") h1 = await auth_headers("owner1")
pid = (await _create_place(client, h1))["place"]["place_id"] pid = (await _create_place(client, h1))["place"]["place_id"]
h2 = await auth_headers("owner2", other_company_id) h2 = await auth_headers("owner2")
r = await client.get(f"/v1/place/{pid}", headers=h2) r = await client.get(f"/v1/place/{pid}", headers=h2)
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -80,7 +80,7 @@ async def test_search_leaks_nothing_of_ours(client, monkeypatch):
item = (await client.get("/v1/place/search", params={"q": "하조대펜션"})).json()["items"][0] item = (await client.get("/v1/place/search", params={"q": "하조대펜션"})).json()["items"][0]
for leaked in ("place_id", "company_id", "owner_user_id", "phone", "latitude", "longitude", for leaked in ("place_id", "owner_user_id", "phone", "latitude", "longitude",
"external_place_id", "address"): "external_place_id", "address"):
assert leaked not in item, f"{leaked} 가 공개 응답에 나갔다" assert leaked not in item, f"{leaked} 가 공개 응답에 나갔다"

View File

@ -31,7 +31,7 @@ def test_empty_site_returns_actionable_failures():
assert any(c["status"] == "fail" and c["recommendation"] for c in report["checks"]) assert any(c["status"] == "fail" and c["recommendation"] for c in report["checks"])
async def test_audit_api_is_company_scoped(auth_headers, client, other_company_id): async def test_audit_api_is_owner_scoped(auth_headers, client):
h1 = await auth_headers("audit-owner") h1 = await auth_headers("audit-owner")
pid = (await client.post("/v1/place", headers=h1, json={"name": "진단가게", "category": 2})).json()["place"]["place_id"] pid = (await client.post("/v1/place", headers=h1, json={"name": "진단가게", "category": 2})).json()["place"]["place_id"]
@ -41,6 +41,6 @@ async def test_audit_api_is_company_scoped(auth_headers, client, other_company_i
assert 0 <= own["aeo_score"] <= 100 assert 0 <= own["aeo_score"] <= 100
assert own["checks"] assert own["checks"]
h2 = await auth_headers("audit-other", other_company_id) h2 = await auth_headers("audit-other")
denied = (await client.get(f"/v1/place/{pid}/site/audit", headers=h2)).json() denied = (await client.get(f"/v1/place/{pid}/site/audit", headers=h2)).json()
assert denied["result"]["success"] is False assert denied["result"]["success"] is False

View File

@ -13,14 +13,14 @@ from sqlalchemy import text
from common.enums import PlaceCategory, PlaceStatus, SiteStatus from common.enums import PlaceCategory, PlaceStatus, SiteStatus
async def _publish(db_engine, company_id, name, *, status, domain, thumb=None, minutes_ago=0): async def _publish(db_engine, owner_id, name, *, status, domain, thumb=None, minutes_ago=0):
"""places + sites 를 직접 넣는다 — 여기서 보는 건 목록 조회지 빌드 파이프라인이 아니다.""" """places + sites 를 직접 넣는다 — 여기서 보는 건 목록 조회지 빌드 파이프라인이 아니다."""
pid, sid = uuid.uuid4(), uuid.uuid4() pid, sid = uuid.uuid4(), uuid.uuid4()
async with db_engine.begin() as c: async with db_engine.begin() as c:
await c.execute( await c.execute(
text("INSERT INTO places (place_id, company_id, name, category, status, road_address, address, phone) " text("INSERT INTO places (place_id, owner_user_id, name, category, status, road_address, address, phone) "
"VALUES (:p,:c,:n,:cat,:st,:road,:addr,:phone)"), "VALUES (:p,:c,:n,:cat,:st,:road,:addr,:phone)"),
{"p": pid, "c": uuid.UUID(company_id), "n": name, "cat": PlaceCategory.LODGING.value, {"p": pid, "c": uuid.UUID(owner_id), "n": name, "cat": PlaceCategory.LODGING.value,
"st": PlaceStatus.PUBLISHED.value, "st": PlaceStatus.PUBLISHED.value,
"road": "강원특별자치도 양양군 현북면 하조대3길 12-3", "addr": "강원특별자치도 양양군 현북면 하광정리 1-2", "road": "강원특별자치도 양양군 현북면 하조대3길 12-3", "addr": "강원특별자치도 양양군 현북면 하광정리 1-2",
"phone": "033-672-0000"}, "phone": "033-672-0000"},
@ -34,12 +34,12 @@ async def _publish(db_engine, company_id, name, *, status, domain, thumb=None, m
return str(pid) return str(pid)
async def test_발행된_사이트만_로그인_없이_보인다(client, db_engine, company_id): async def test_발행된_사이트만_로그인_없이_보인다(client, db_engine, owner_id):
"""검증: 발행본 1개 + 미발행(DRAFT) 1개를 두고 인증 헤더 없이 부른다. """검증: 발행본 1개 + 미발행(DRAFT) 1개를 두고 인증 헤더 없이 부른다.
기대결과: 발행본만 나온다.""" 기대결과: 발행본만 나온다."""
await _publish(db_engine, company_id, "하조대펜션", status=SiteStatus.PUBLISHED.value, await _publish(db_engine, owner_id, "하조대펜션", status=SiteStatus.PUBLISHED.value,
domain="hajodae", thumb="https://w4ai.o2o.kr/thumbs/hajodae.jpg") domain="hajodae", thumb="https://w4ai.o2o.kr/thumbs/hajodae.jpg")
await _publish(db_engine, company_id, "아직펜션", status=SiteStatus.DRAFT.value, domain="notyet") await _publish(db_engine, owner_id, "아직펜션", status=SiteStatus.DRAFT.value, domain="notyet")
res = await client.get("/v1/showcase") res = await client.get("/v1/showcase")
@ -51,10 +51,10 @@ async def test_발행된_사이트만_로그인_없이_보인다(client, db_engi
assert items[0]["category"] == PlaceCategory.LODGING.value assert items[0]["category"] == PlaceCategory.LODGING.value
async def test_개인정보와_내부값은_나가지_않는다(client, db_engine, company_id): async def test_개인정보와_내부값은_나가지_않는다(client, db_engine, owner_id):
"""검증: 응답 항목의 키를 그대로 본다. """검증: 응답 항목의 키를 그대로 본다.
기대결과: 상호명·업종·지역·주소·썸네일뿐. 지역은 시·군까지고 상세 주소는 없다.""" 기대결과: 상호명·업종·지역·주소·썸네일뿐. 지역은 시·군까지고 상세 주소는 없다."""
await _publish(db_engine, company_id, "하조대펜션", status=SiteStatus.PUBLISHED.value, domain="hajodae") await _publish(db_engine, owner_id, "하조대펜션", status=SiteStatus.PUBLISHED.value, domain="hajodae")
item = (await client.get("/v1/showcase")).json()["items"][0] item = (await client.get("/v1/showcase")).json()["items"][0]
@ -66,10 +66,10 @@ async def test_개인정보와_내부값은_나가지_않는다(client, db_engin
assert "하조대3길" not in body assert "하조대3길" not in body
async def test_썸네일이_없으면_키가_없다(client, db_engine, company_id): async def test_썸네일이_없으면_키가_없다(client, db_engine, owner_id):
"""★ 썸네일은 발행의 부수 효과라 실패할 수 있다(대표 사진이 없거나 CDN 이 죽었거나). """★ 썸네일은 발행의 부수 효과라 실패할 수 있다(대표 사진이 없거나 CDN 이 죽었거나).
그때 카드는 글자로 떨어져야지 목록에서 사라지면 안 된다.""" 그때 카드는 글자로 떨어져야지 목록에서 사라지면 안 된다."""
await _publish(db_engine, company_id, "그림없는집", status=SiteStatus.PUBLISHED.value, domain="nopic") await _publish(db_engine, owner_id, "그림없는집", status=SiteStatus.PUBLISHED.value, domain="nopic")
item = (await client.get("/v1/showcase")).json()["items"][0] item = (await client.get("/v1/showcase")).json()["items"][0]
@ -77,9 +77,9 @@ async def test_썸네일이_없으면_키가_없다(client, db_engine, company_i
assert "thumbnail_url" not in item assert "thumbnail_url" not in item
async def test_최신_발행순이고_limit_로_자른다(client, db_engine, company_id): async def test_최신_발행순이고_limit_로_자른다(client, db_engine, owner_id):
await _publish(db_engine, company_id, "먼저", status=SiteStatus.PUBLISHED.value, domain="first", minutes_ago=60) await _publish(db_engine, owner_id, "먼저", status=SiteStatus.PUBLISHED.value, domain="first", minutes_ago=60)
await _publish(db_engine, company_id, "나중", status=SiteStatus.PUBLISHED.value, domain="second", minutes_ago=1) await _publish(db_engine, owner_id, "나중", status=SiteStatus.PUBLISHED.value, domain="second", minutes_ago=1)
items = (await client.get("/v1/showcase")).json()["items"] items = (await client.get("/v1/showcase")).json()["items"]
assert [i["name"] for i in items] == ["나중", "먼저"] assert [i["name"] for i in items] == ["나중", "먼저"]

View File

@ -129,11 +129,11 @@ async def test_published_site_slug_is_locked(auth_headers, client, db_engine):
assert same["site"]["domain"] == "published-stay" assert same["site"]["domain"] == "published-stay"
async def test_other_company_place_is_blocked(auth_headers, client, other_company_id): async def test_other_owners_place_is_blocked(auth_headers, client):
"""검증: 남의 회사 사업장 주소는 확인도 예약도 못 한다. """검증: 남의 사업장 주소는 확인도 예약도 못 한다.
기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다).""" 기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다)."""
h = await auth_headers("slug5") h = await auth_headers("slug5")
intruder = await auth_headers("slug6", other_company_id) intruder = await auth_headers("slug6")
pid = await _place(client, h) pid = await _place(client, h)
assert (await _check(client, intruder, pid, "doflo"))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value assert (await _check(client, intruder, pid, "doflo"))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -99,11 +99,11 @@ async def test_published_site_template_is_not_locked(auth_headers, client, db_en
assert changed["needs_rebuild"] is True assert changed["needs_rebuild"] is True
async def test_other_company_place_is_blocked(auth_headers, client, other_company_id): async def test_other_owners_place_is_blocked(auth_headers, client):
"""검증: 남의 회사 사업장의 템플릿은 바꿀 수 없다. """검증: 남의 사업장의 템플릿은 바꿀 수 없다.
기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다).""" 기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다)."""
h = await auth_headers("tpl6") h = await auth_headers("tpl6")
intruder = await auth_headers("tpl7", other_company_id) intruder = await auth_headers("tpl7")
pid = await _place(client, h) pid = await _place(client, h)
blocked = await _set_template(client, intruder, pid, "stay-quiet-margin") blocked = await _set_template(client, intruder, pid, "stay-quiet-margin")

View File

@ -129,11 +129,11 @@ async def test_published_site_theme_is_not_locked(auth_headers, client, db_engin
assert changed["needs_rebuild"] is True assert changed["needs_rebuild"] is True
async def test_other_company_place_is_blocked(auth_headers, client, other_company_id): async def test_other_owners_place_is_blocked(auth_headers, client):
"""검증: 남의 회사 사업장의 디자인은 바꿀 수 없다. """검증: 남의 사업장의 디자인은 바꿀 수 없다.
기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다).""" 기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다)."""
h = await auth_headers("thm7") h = await auth_headers("thm7")
intruder = await auth_headers("thm8", other_company_id) intruder = await auth_headers("thm8")
pid = await _place(client, h) pid = await _place(client, h)
assert (await _set_theme(client, intruder, pid, _THEME))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value assert (await _set_theme(client, intruder, pid, _THEME))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -10,13 +10,13 @@ from common.enums import FactStatus, MediaStatus, PlaceCategory, SourceType
from services.snapshot import build_snapshot from services.snapshot import build_snapshot
async def _seed(db_engine, company_id, category=PlaceCategory.LODGING): async def _seed(db_engine, owner_id, category=PlaceCategory.LODGING):
pid = uuid.uuid4() pid = uuid.uuid4()
async with db_engine.begin() as c: async with db_engine.begin() as c:
await c.execute( await c.execute(
text("INSERT INTO places (place_id, company_id, name, category, status, road_address, phone, verified_at) " text("INSERT INTO places (place_id, owner_user_id, name, category, status, road_address, phone, verified_at) "
"VALUES (:p,:c,:n,:cat,3,:addr,:tel,now())"), "VALUES (:p,:c,:n,:cat,3,:addr,:tel,now())"),
{"p": pid, "c": uuid.UUID(company_id), "n": "스냅샷펜션", "cat": category.value, {"p": pid, "c": uuid.UUID(owner_id), "n": "스냅샷펜션", "cat": category.value,
"addr": "강원특별자치도 양양군 현북면 하조대3길 11", "tel": "033-000-0000"}, "addr": "강원특별자치도 양양군 현북면 하조대3길 11", "tel": "033-000-0000"},
) )
return pid return pid
@ -53,10 +53,10 @@ class _Place:
self.longitude = None self.longitude = None
async def test_only_publishable_facts_enter_snapshot(db_engine, company_id): async def test_only_publishable_facts_enter_snapshot(db_engine, owner_id):
"""검증: 여러 상태의 fact 를 섞어 넣는다. """검증: 여러 상태의 fact 를 섞어 넣는다.
기대결과: ★ VERIFIED·CORRECTED 만 스냅샷에 담긴다 — 미검증 값이 사이트로 새지 않는다.""" 기대결과: ★ VERIFIED·CORRECTED 만 스냅샷에 담긴다 — 미검증 값이 사이트로 새지 않는다."""
pid = await _seed(db_engine, company_id) pid = await _seed(db_engine, owner_id)
await _fact(db_engine, pid, "check_in_time", "15:00", FactStatus.VERIFIED) await _fact(db_engine, pid, "check_in_time", "15:00", FactStatus.VERIFIED)
await _fact(db_engine, pid, "wifi", "true", FactStatus.CORRECTED) await _fact(db_engine, pid, "wifi", "true", FactStatus.CORRECTED)
await _fact(db_engine, pid, "parking", "true", FactStatus.UNVERIFIED) await _fact(db_engine, pid, "parking", "true", FactStatus.UNVERIFIED)
@ -69,10 +69,10 @@ async def test_only_publishable_facts_enter_snapshot(db_engine, company_id):
assert keys == {"check_in_time", "wifi"} assert keys == {"check_in_time", "wifi"}
async def test_only_approved_media_enters_snapshot(db_engine, company_id): async def test_only_approved_media_enters_snapshot(db_engine, owner_id):
"""검증: 승인/확인대기/반려 사진을 섞어 넣는다. """검증: 승인/확인대기/반려 사진을 섞어 넣는다.
기대결과: ★ APPROVED 만 담긴다 — Vision 신뢰도가 낮아 확인 큐에 남은 사진은 안 나간다.""" 기대결과: ★ APPROVED 만 담긴다 — Vision 신뢰도가 낮아 확인 큐에 남은 사진은 안 나간다."""
pid = await _seed(db_engine, company_id) pid = await _seed(db_engine, owner_id)
await _media(db_engine, pid, "https://cdn.test/ok.jpg", MediaStatus.APPROVED) await _media(db_engine, pid, "https://cdn.test/ok.jpg", MediaStatus.APPROVED)
await _media(db_engine, pid, "https://cdn.test/pending.jpg", MediaStatus.PENDING_REVIEW) await _media(db_engine, pid, "https://cdn.test/pending.jpg", MediaStatus.PENDING_REVIEW)
await _media(db_engine, pid, "https://cdn.test/no.jpg", MediaStatus.REJECTED) await _media(db_engine, pid, "https://cdn.test/no.jpg", MediaStatus.REJECTED)
@ -81,10 +81,10 @@ async def test_only_approved_media_enters_snapshot(db_engine, company_id):
assert [m["url"] for m in snap["media"]] == ["https://cdn.test/ok.jpg"] assert [m["url"] for m in snap["media"]] == ["https://cdn.test/ok.jpg"]
async def test_media_without_alt_is_excluded(db_engine, company_id): async def test_media_without_alt_is_excluded(db_engine, owner_id):
"""검증: 승인됐지만 alt 텍스트가 없는 사진. """검증: 승인됐지만 alt 텍스트가 없는 사진.
기대결과: 빠진다 — alt 없는 이미지는 접근성도 AI 검색 신호도 없다.""" 기대결과: 빠진다 — alt 없는 이미지는 접근성도 AI 검색 신호도 없다."""
pid = await _seed(db_engine, company_id) pid = await _seed(db_engine, owner_id)
await _media(db_engine, pid, "https://cdn.test/noalt.jpg", MediaStatus.APPROVED, alt="") await _media(db_engine, pid, "https://cdn.test/noalt.jpg", MediaStatus.APPROVED, alt="")
await _media(db_engine, pid, "https://cdn.test/withalt.jpg", MediaStatus.APPROVED, alt="침실 사진") await _media(db_engine, pid, "https://cdn.test/withalt.jpg", MediaStatus.APPROVED, alt="침실 사진")
@ -92,10 +92,10 @@ async def test_media_without_alt_is_excluded(db_engine, company_id):
assert [m["url"] for m in snap["media"]] == ["https://cdn.test/withalt.jpg"] assert [m["url"] for m in snap["media"]] == ["https://cdn.test/withalt.jpg"]
async def test_fact_labels_come_from_category_schema(db_engine, company_id): async def test_fact_labels_come_from_category_schema(db_engine, owner_id):
"""검증: 스냅샷의 fact 라벨. """검증: 스냅샷의 fact 라벨.
기대결과: 업종 스키마의 한글 라벨이 붙는다 — 화면이 key 를 그대로 노출하지 않게.""" 기대결과: 업종 스키마의 한글 라벨이 붙는다 — 화면이 key 를 그대로 노출하지 않게."""
pid = await _seed(db_engine, company_id) pid = await _seed(db_engine, owner_id)
await _fact(db_engine, pid, "check_in_time", "15:00", FactStatus.VERIFIED) await _fact(db_engine, pid, "check_in_time", "15:00", FactStatus.VERIFIED)
snap = await build_snapshot(_Place(pid)) snap = await build_snapshot(_Place(pid))
@ -104,10 +104,10 @@ async def test_fact_labels_come_from_category_schema(db_engine, company_id):
assert f["scope"] == "place" assert f["scope"] == "place"
async def test_unit_scoped_facts_carry_unit_id(db_engine, company_id): async def test_unit_scoped_facts_carry_unit_id(db_engine, owner_id):
"""검증: 객실 단위 fact. """검증: 객실 단위 fact.
기대결과: unit_id 가 실려 빌더가 객실별로 묶을 수 있다.""" 기대결과: unit_id 가 실려 빌더가 객실별로 묶을 수 있다."""
pid = await _seed(db_engine, company_id) pid = await _seed(db_engine, owner_id)
uid = uuid.uuid4() uid = uuid.uuid4()
async with db_engine.begin() as c: async with db_engine.begin() as c:
await c.execute(text("INSERT INTO units (unit_id, place_id, name, sort_order) VALUES (:u,:p,:n,0)"), await c.execute(text("INSERT INTO units (unit_id, place_id, name, sort_order) VALUES (:u,:p,:n,0)"),
@ -120,10 +120,10 @@ async def test_unit_scoped_facts_carry_unit_id(db_engine, company_id):
assert snap["facts"][0]["scope"] == "unit" assert snap["facts"][0]["scope"] == "unit"
async def test_empty_place_gives_empty_snapshot(db_engine, company_id): async def test_empty_place_gives_empty_snapshot(db_engine, owner_id):
"""검증: 아무것도 없는 사업장. """검증: 아무것도 없는 사업장.
기대결과: 빈 스냅샷 — 게이트가 고유 콘텐츠 0건으로 거부할 재료가 된다.""" 기대결과: 빈 스냅샷 — 게이트가 고유 콘텐츠 0건으로 거부할 재료가 된다."""
pid = await _seed(db_engine, company_id) pid = await _seed(db_engine, owner_id)
snap = await build_snapshot(_Place(pid)) snap = await build_snapshot(_Place(pid))
assert snap["facts"] == [] and snap["media"] == [] and snap["faqs"] == [] assert snap["facts"] == [] and snap["media"] == [] and snap["faqs"] == []
assert snap["place"]["name"] == "스냅샷펜션" assert snap["place"]["name"] == "스냅샷펜션"
@ -156,13 +156,13 @@ class _RegionPlace(_Place):
self.region_code = region_code self.region_code = region_code
async def test_only_published_local_content_enters_snapshot(db_engine, company_id): async def test_only_published_local_content_enters_snapshot(db_engine, owner_id):
"""검증: 검수대기·종료·발행 지역 정보를 섞어 넣는다. """검증: 검수대기·종료·발행 지역 정보를 섞어 넣는다.
기대결과: ★ PUBLISHED 만 담긴다 — 운영자가 검수하지 않은 외부 API 원문이 사이트로 새면 기대결과: ★ PUBLISHED 만 담긴다 — 운영자가 검수하지 않은 외부 API 원문이 사이트로 새면
'미검증 값 노출 금지'가 깨진다(fact 를 VERIFIED 로 거르는 것과 같은 규칙).""" '미검증 값 노출 금지'가 깨진다(fact 를 VERIFIED 로 거르는 것과 같은 규칙)."""
from common.enums import LocalContentStatus, LocalContentType from common.enums import LocalContentStatus, LocalContentType
pid = await _seed(db_engine, company_id) pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "발행축제") await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "발행축제")
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.REVIEW, "검수대기축제") await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.REVIEW, "검수대기축제")
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.ENDED, "종료축제") await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.ENDED, "종료축제")
@ -171,7 +171,7 @@ async def test_only_published_local_content_enters_snapshot(db_engine, company_i
assert [c["title"] for c in snap["local"]["contents"]] == ["발행축제"] assert [c["title"] for c in snap["local"]["contents"]] == ["발행축제"]
async def test_local_content_outside_display_window_is_excluded(db_engine, company_id): async def test_local_content_outside_display_window_is_excluded(db_engine, owner_id):
"""검증: 발행됐지만 노출 기간을 벗어난 지역 정보. """검증: 발행됐지만 노출 기간을 벗어난 지역 정보.
기대결과: 빠진다 — 끝난 축제를 '이번 주말 행사'로 걸어두는 것도 틀린 정보다.""" 기대결과: 빠진다 — 끝난 축제를 '이번 주말 행사'로 걸어두는 것도 틀린 정보다."""
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@ -179,7 +179,7 @@ async def test_local_content_outside_display_window_is_excluded(db_engine, compa
from common.enums import LocalContentStatus, LocalContentType from common.enums import LocalContentStatus, LocalContentType
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
pid = await _seed(db_engine, company_id) pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "지금축제") await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "지금축제")
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "끝난축제", await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "끝난축제",
display_end_at=now - timedelta(days=1)) display_end_at=now - timedelta(days=1))
@ -190,12 +190,12 @@ async def test_local_content_outside_display_window_is_excluded(db_engine, compa
assert [c["title"] for c in snap["local"]["contents"]] == ["지금축제"] assert [c["title"] for c in snap["local"]["contents"]] == ["지금축제"]
async def test_local_content_is_scoped_to_the_places_region(db_engine, company_id): async def test_local_content_is_scoped_to_the_places_region(db_engine, owner_id):
"""검증: 지역 캐시는 region_code 로 묶인다. """검증: 지역 캐시는 region_code 로 묶인다.
기대결과: 다른 지역의 발행 콘텐츠는 담기지 않는다.""" 기대결과: 다른 지역의 발행 콘텐츠는 담기지 않는다."""
from common.enums import LocalContentStatus, LocalContentType from common.enums import LocalContentStatus, LocalContentType
pid = await _seed(db_engine, company_id) pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "우리지역축제") await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "우리지역축제")
await _local(db_engine, "5011025", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "남의지역축제") await _local(db_engine, "5011025", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "남의지역축제")
@ -203,7 +203,7 @@ async def test_local_content_is_scoped_to_the_places_region(db_engine, company_i
assert [c["title"] for c in snap["local"]["contents"]] == ["우리지역축제"] assert [c["title"] for c in snap["local"]["contents"]] == ["우리지역축제"]
async def test_region_code_is_derived_from_the_address_when_missing(db_engine, company_id): async def test_region_code_is_derived_from_the_address_when_missing(db_engine, owner_id):
"""검증: region_code 가 비어 있지만 도로명주소는 있는 사업장. """검증: region_code 가 비어 있지만 도로명주소는 있는 사업장.
기대결과: 주소에서 지역 키를 유도해 그 지역 콘텐츠를 담는다 — places.region_code 를 채우는 기대결과: 주소에서 지역 키를 유도해 그 지역 콘텐츠를 담는다 — places.region_code 를 채우는
코드가 생기기 전에 만들어진 사업장(실측 28곳 중 25곳)이 영영 지역 정보 없이 발행되지 않게 한다.""" 코드가 생기기 전에 만들어진 사업장(실측 28곳 중 25곳)이 영영 지역 정보 없이 발행되지 않게 한다."""
@ -211,7 +211,7 @@ async def test_region_code_is_derived_from_the_address_when_missing(db_engine, c
from services.external.naver import region_key from services.external.naver import region_key
derived = region_key(_Place("x").road_address) derived = region_key(_Place("x").road_address)
pid = await _seed(db_engine, company_id) pid = await _seed(db_engine, owner_id)
await _local(db_engine, derived, LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "양양축제") await _local(db_engine, derived, LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "양양축제")
snap = await build_snapshot(_RegionPlace(pid, "")) snap = await build_snapshot(_RegionPlace(pid, ""))
@ -219,12 +219,12 @@ async def test_region_code_is_derived_from_the_address_when_missing(db_engine, c
assert [c["title"] for c in snap["local"]["contents"]] == ["양양축제"] assert [c["title"] for c in snap["local"]["contents"]] == ["양양축제"]
async def test_place_without_any_region_key_gets_no_local_content(db_engine, company_id): async def test_place_without_any_region_key_gets_no_local_content(db_engine, owner_id):
"""검증: 지역 코드도 읽을 만한 주소도 없는 사업장. """검증: 지역 코드도 읽을 만한 주소도 없는 사업장.
기대결과: 빈 목록 — 조회할 캐시 키가 없다. 지어내지 않는다.""" 기대결과: 빈 목록 — 조회할 캐시 키가 없다. 지어내지 않는다."""
from common.enums import LocalContentStatus, LocalContentType from common.enums import LocalContentStatus, LocalContentType
pid = await _seed(db_engine, company_id) pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "어딘가축제") await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "어딘가축제")
place = _RegionPlace(pid, "") place = _RegionPlace(pid, "")

View File

@ -136,13 +136,13 @@ async def test_candidates_require_configured_source(auth_headers, client, monkey
assert body["result"]["code"] == ErrorType.LOCAL_NOT_CONFIGURED.value assert body["result"]["code"] == ErrorType.LOCAL_NOT_CONFIGURED.value
async def test_candidates_scoped_to_company(auth_headers, client, other_company_id, monkeypatch): async def test_candidates_scoped_to_owner(auth_headers, client, monkeypatch):
"""검증: 다른 회사 계정으로 남의 사업장 후보를 조회한다. """검증: 다른 사장님 계정으로 남의 사업장 후보를 조회한다.
기대결과: PLACE_NOT_FOUND.""" 기대결과: PLACE_NOT_FOUND."""
_patch_naver(monkeypatch, _match(kakao_client.MatchOutcome.MATCHED, _np("a", "b"), [], "x")) _patch_naver(monkeypatch, _match(kakao_client.MatchOutcome.MATCHED, _np("a", "b"), [], "x"))
h1 = await auth_headers("o1") h1 = await auth_headers("o1")
pid = await _place(client, h1) pid = await _place(client, h1)
h2 = await auth_headers("o2", other_company_id) h2 = await auth_headers("o2")
body = (await client.get(f"/v1/place/{pid}/verify/candidates", headers=h2)).json() body = (await client.get(f"/v1/place/{pid}/verify/candidates", headers=h2)).json()
assert body["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value assert body["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -1,11 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export interface CompanyData {
company_id?: string;
name?: string;
}

View File

@ -11,7 +11,7 @@ import type { PlaceSearchItemCategory } from './placeSearchItemCategory';
/** /**
* 공개 검색 결과 1건. * 공개 검색 결과 1건.
★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·company_id·소유자)은 ★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·소유자)은
하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다. 하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다.
★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고, ★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고,
확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다. 확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다.

View File

@ -5,10 +5,9 @@
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
import type { ReqSignupName } from './reqSignupName'; import type { ReqSignupName } from './reqSignupName';
import type { ReqSignupCompanyName } from './reqSignupCompanyName';
/** /**
* id/pw 가입. 가입 = 새 테넌트(회사) 1개 + 그 회사의 첫 계정 1개다. * id/pw 가입. 가입 = 계정 1개다.
★ 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면 ★ 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면
한 사람에게 계정이 둘 생긴다. 지금 이메일 인증 절차는 없다 — 소유 증명이 아니라 한 사람에게 계정이 둘 생긴다. 지금 이메일 인증 절차는 없다 — 소유 증명이 아니라
@ -19,5 +18,4 @@ export interface ReqSignup {
password?: string; password?: string;
name?: ReqSignupName; name?: ReqSignupName;
email?: string; email?: string;
company_name?: ReqSignupCompanyName;
} }

View File

@ -1,8 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ReqSignupCompanyName = string | null;

View File

@ -11,7 +11,6 @@ import type { ResMeEmail } from './resMeEmail';
import type { ResMeContactNumber } from './resMeContactNumber'; import type { ResMeContactNumber } from './resMeContactNumber';
import type { UserRole } from './userRole'; import type { UserRole } from './userRole';
import type { AuthProvider } from './authProvider'; import type { AuthProvider } from './authProvider';
import type { ResMeCompany } from './resMeCompany';
export interface ResMe { export interface ResMe {
result?: ErrorInfo; result?: ErrorInfo;
@ -23,5 +22,4 @@ export interface ResMe {
contact_number?: ResMeContactNumber; contact_number?: ResMeContactNumber;
role?: UserRole; role?: UserRole;
provider?: AuthProvider; provider?: AuthProvider;
company?: ResMeCompany;
} }

View File

@ -1,9 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { CompanyData } from './companyData';
export type ResMeCompany = CompanyData | null;

View File

@ -12,7 +12,7 @@ import type { ShowcaseItemThumbnailUrl } from './showcaseItemThumbnailUrl';
* 랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.** * 랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.**
★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다. ★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다.
place_id·company_id·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과 place_id·소유자·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과
발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다. 발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다.
*/ */
export interface ShowcaseItem { export interface ShowcaseItem {

View File

@ -82,7 +82,6 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?
className="mb-2 block truncate rounded-md px-2 py-1 text-[11px] text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60" className="mb-2 block truncate rounded-md px-2 py-1 text-[11px] text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60"
> >
{userLabel(user)} {userLabel(user)}
{user.companyName ? ` · ${user.companyName}` : ''}
</Link> </Link>
) : ( ) : (
<div className="mb-2 truncate px-2 py-1 text-[11px] text-sidebar-foreground"> <div className="mb-2 truncate px-2 py-1 text-[11px] text-sidebar-foreground">

View File

@ -10,8 +10,8 @@ import {toAuthUser, useAuthStore} from '@/stores/auth';
/** /**
* 내 정보 — `PATCH /v1/auth/me` 한 곳이 받는 것만 그린다. * 내 정보 — `PATCH /v1/auth/me` 한 곳이 받는 것만 그린다.
* *
* ★ 상호(company)는 읽기 전용이다. Req_UpdateMe 에 없다 — 자기 소속을 스스로 바꾸지 못하게 * ★ 상호 칸은 없다. 회사(테넌트)를 걷어내면서(2026-09-08) 계정에 상호가 없어졌다 —
* 일부러 뺀 필드라, 입력칸을 두면 저장을 눌러도 아무 일이 안 일어난다. * 가게 이름은 사업장(place)이 갖는다.
* ★ 구글 계정에는 바꿀 비밀번호가 없다(서버가 ACCOUNT_PROVIDER_CONFLICT 로 막는다) — * ★ 구글 계정에는 바꿀 비밀번호가 없다(서버가 ACCOUNT_PROVIDER_CONFLICT 로 막는다) —
* 입력칸 자체를 그리지 않는다. * 입력칸 자체를 그리지 않는다.
*/ */
@ -77,13 +77,6 @@ export function AccountPage() {
{isGoogle ? '구글 계정으로 로그인합니다.' : '아이디는 바꿀 수 없습니다.'} {isGoogle ? '구글 계정으로 로그인합니다.' : '아이디는 바꿀 수 없습니다.'}
</p> </p>
</Field> </Field>
<Field label="상호">
<p className="text-sm">{data?.company?.name ?? '-'}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
상호 변경은 고객센터로 문의해 주세요.
</p>
</Field>
</section> </section>
<section className="space-y-4 rounded-xl border border-border bg-card p-5"> <section className="space-y-4 rounded-xl border border-border bg-card p-5">

View File

@ -212,10 +212,9 @@ export function BuilderPage() {
<span className="h-3.5 w-px bg-white/20" /> <span className="h-3.5 w-px bg-white/20" />
<span <span
className="max-w-[14rem] truncate text-background/70" className="max-w-[14rem] truncate text-background/70"
title={user.companyName ? `${userLabel(user)} · ${user.companyName}` : userLabel(user)} title={userLabel(user)}
> >
{userLabel(user)} {userLabel(user)}
{user.companyName ? ` · ${user.companyName}` : ''}
</span> </span>
<button <button
type="button" type="button"

View File

@ -31,7 +31,6 @@ export function SignupPage() {
passwordConfirm: '', passwordConfirm: '',
name: '', name: '',
email: '', email: '',
companyName: '',
}); });
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@ -63,7 +62,6 @@ export function SignupPage() {
password: form.password, password: form.password,
name: form.name.trim() || null, name: form.name.trim() || null,
email: form.email.trim(), email: form.email.trim(),
company_name: form.companyName.trim() || null,
}); });
if (res.result?.success === false) { if (res.result?.success === false) {
notifyApiError({data: res}, '가입하지 못했습니다.'); notifyApiError({data: res}, '가입하지 못했습니다.');
@ -180,17 +178,6 @@ export function SignupPage() {
required required
/> />
</div> </div>
<div>
<label htmlFor="signup-company" className="mb-1.5 block text-xs font-semibold">
상호 <span className="font-normal text-muted-foreground">(선택)</span>
</label>
<Input
id="signup-company"
value={form.companyName}
onChange={set('companyName')}
placeholder="비우면 이름으로 채웁니다"
/>
</div>
</div> </div>
<Button type="submit" variant="primary" className="w-full" isLoading={isSubmitting}> <Button type="submit" variant="primary" className="w-full" isLoading={isSubmitting}>

View File

@ -8,8 +8,6 @@ export interface AuthUser {
name?: string; name?: string;
email?: string; email?: string;
role: number; role: number;
companyId?: string;
companyName?: string;
} }
/** /**
@ -26,8 +24,6 @@ export function toAuthUser(res: ResMe): AuthUser {
name: res.name ?? undefined, name: res.name ?? undefined,
email: res.email ?? undefined, email: res.email ?? undefined,
role: res.role ?? UserRole.USER, role: res.role ?? UserRole.USER,
companyId: res.company?.company_id,
companyName: res.company?.name,
}; };
} }