[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:
parent
00e13bca7f
commit
94551afdaf
@ -186,10 +186,11 @@ USER role=1 → 403 DEVELOPER role=3 → 200
|
||||
OWNER role=2 → 403
|
||||
```
|
||||
|
||||
OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다.
|
||||
OWNER 가 막히는 게 핵심이다 — 내부 운영 화면을 볼 권한이 아니다.
|
||||
`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`).
|
||||
위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 가
|
||||
|
||||
@ -88,9 +88,9 @@
|
||||
| 포트 | **9800** | negosium 9300 / negodata 9400 / agent 9500 / lps 9600 / anchoring 9700 다음 번호 |
|
||||
| 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 가 생기는 순간 다시 필요해진다 |
|
||||
| 남긴 것 | 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 모듈과 함께 재이식 예정 |
|
||||
| `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 | 원본이 구간을 나눠 쓰는 방식 유지 |
|
||||
| 외부 API 키 | `[ExternalApiConfig]` 로 toml + env override. **키가 비면 해당 어댑터만 비활성, 서버는 그대로 뜬다** | 부팅이 외부 계약에 묶이면 안 됨 |
|
||||
| 백그라운드 작업 | 원본에 전용 작업 큐 없음(APScheduler 크론만). 수집·비전분석·빌드는 몇 분 걸리므로 **큐를 새로 얹어야 한다** — 방식 미정 | 원본에 없는 것이라 팀 컨벤션 확인 필요. 아래 3번 참고 |
|
||||
|
||||
@ -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) 목업 사이트가 죽었다 — 참조된 자산은 기간과 무관하게 남긴다
|
||||
|
||||
**무슨 일**
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
-- 단일 PostgreSQL 인스턴스, 단일 database(web4ai_db) 안에서 도메인별 schema 로 묶는다.
|
||||
-- postgres (1개 서버, 5432)
|
||||
-- └── web4ai_db
|
||||
-- ├── company : companies, users
|
||||
-- ├── company : users
|
||||
-- ├── place : places, place_aliases, place_links, units, media
|
||||
-- ├── fact : facts, faqs
|
||||
-- ├── local : local_contents, routes, nearby_links
|
||||
@ -46,28 +46,12 @@ CREATE SCHEMA IF NOT EXISTS site;
|
||||
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 (
|
||||
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>)
|
||||
password VARCHAR(255) NULL, -- bcrypt 해시. 소셜 계정은 NULL
|
||||
name VARCHAR(50) NULL, -- 이름
|
||||
@ -88,8 +72,7 @@ CREATE TABLE IF NOT EXISTS company.users (
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS place.places (
|
||||
place_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 사업장 식별자(PK)
|
||||
company_id uuid NOT NULL, -- 테넌트(company.companies.company_id)
|
||||
owner_user_id uuid NULL, -- 사장님 계정(company.users.user_id)
|
||||
owner_user_id uuid NOT NULL, -- ★ 스코프 키. 사장님 계정(company.users.user_id)
|
||||
name VARCHAR(200) NOT NULL, -- 상호명(입력값)
|
||||
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
|
||||
@ -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)로 건다.
|
||||
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
|
||||
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_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);
|
||||
@ -448,3 +427,26 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON company.users (provid
|
||||
-- 2026-09-03 쇼케이스 카드 썸네일. CREATE TABLE 에만 있어서 기존 DB 가 조용히 깨졌다
|
||||
-- (실측: 킹서버에서 GET /v1/showcase 가 200 인데 내용은 비었다).
|
||||
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;
|
||||
|
||||
@ -10,7 +10,6 @@ from common.enums import (
|
||||
DBType,
|
||||
UserStatus,
|
||||
UserRole,
|
||||
CompanyStatus,
|
||||
PlaceStatus,
|
||||
FactStatus,
|
||||
MediaStatus,
|
||||
@ -45,30 +44,11 @@ class MainTableMixin(_DBTypeMixin):
|
||||
|
||||
|
||||
# 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):
|
||||
__tablename__ = "users"
|
||||
__table_args__ = {"schema": "company"}
|
||||
|
||||
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자)로 만들면서 넓혔다 —
|
||||
# sub 를 잘라 쓰면 앞자리가 같은 두 계정이 한 아이디로 겹친다.
|
||||
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)
|
||||
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) # 상호명(입력값)
|
||||
category = Column(SmallInteger, nullable=False) # PlaceCategory — 업종 스키마 선택 키
|
||||
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PlaceStatus.DRAFT.value)
|
||||
|
||||
@ -67,9 +67,8 @@ class PageParams:
|
||||
class UserInfo(StructModel):
|
||||
"""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 재조회 키
|
||||
company_id: str # users.company_id (uuid) — 멀티테넌트 스코프 키
|
||||
role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
|
||||
@ -14,7 +14,7 @@ from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -92,7 +92,7 @@ async def db_engine(_test_db_lifecycle):
|
||||
# 도메인 테이블 전부 비워 격리 (CASCADE: FK 미설정이라 안전망)
|
||||
await conn.execute(
|
||||
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, "
|
||||
"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
|
||||
async def company_id(db_engine) -> str:
|
||||
"""테스트용 소속사 1개를 시드하고 company_id(uuid str)를 돌려준다.
|
||||
users 는 company_id 를 요구하므로 계정 생성 테스트의 선행 조건이다.
|
||||
async def owner_id(db_engine) -> str:
|
||||
"""사장님 계정 1개를 시드하고 user_id(uuid str)를 돌려준다.
|
||||
|
||||
★ 예전엔 `company_id`(소속사)였다. 회사(테넌트)를 걷어내면서 사업장이 `owner_user_id` 로
|
||||
계정에 직접 매이게 됐다 — DB 를 직접 시드하는 테스트가 place 에 넣을 주인이 이 값이다.
|
||||
"""
|
||||
cid = uuid.uuid4()
|
||||
uid = uuid.uuid4()
|
||||
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(
|
||||
text("INSERT INTO companies (company_id, name, status) VALUES (:cid, :name, :status)"),
|
||||
{"cid": cid, "name": "테스트사", "status": CompanyStatus.ACTIVE.value},
|
||||
text(
|
||||
"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)
|
||||
|
||||
|
||||
@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)
|
||||
return str(uid)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
@ -139,28 +133,27 @@ async def client(db_engine):
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def auth_headers(db_engine, client, company_id):
|
||||
async def auth_headers(db_engine, client):
|
||||
"""테스트 유저를 시드하고 로그인 헤더(Bearer)를 돌려주는 팩토리.
|
||||
|
||||
계정 생성 API 가 없으므로 users 행을 직접 INSERT(비번 bcrypt 해시)한 뒤 /v1/auth/login 으로 토큰을 받는다.
|
||||
company 미지정 시 기본 소속사(company_id 픽스처). role 로 OWNER 계정도 만들 수 있다.
|
||||
호출: `h = await auth_headers("user1")` / `await auth_headers("userB", other_company_id)`.
|
||||
★ 회사 인자가 없다. 스코프가 계정 자체이므로 **다른 login_id 로 한 번 더 부르면 그게 남**이다
|
||||
— 격리 테스트는 `await auth_headers("o2")` 하나면 된다.
|
||||
호출: `h = await auth_headers("user1")`.
|
||||
"""
|
||||
from common.enums import UserRole, UserStatus
|
||||
from router.v1.validator.dependencies import GetHashedPW
|
||||
|
||||
async def _make(login_id, company=None, *, password="pw1234", role=UserRole.USER.value, name="n"):
|
||||
cid = company or company_id
|
||||
async def _make(login_id, *, password="pw1234", role=UserRole.USER.value, name="n"):
|
||||
hashed = await GetHashedPW(password)
|
||||
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(
|
||||
text(
|
||||
"INSERT INTO users (user_id, company_id, id, password, name, status, role, last_accessed_at) "
|
||||
"VALUES (:uid, :cid, :id, :pw, :name, :status, :role, now())"
|
||||
"INSERT INTO users (user_id, id, password, name, status, role, last_accessed_at) "
|
||||
"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,
|
||||
},
|
||||
)
|
||||
|
||||
@ -11,26 +11,26 @@ from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
|
||||
|
||||
# 사업장 CRUD. 모든 조회는 company_id(테넌트)로 스코프한다 — 남의 회사 사업장이 보이면 안 된다.
|
||||
# 사업장 CRUD. 모든 조회는 owner_user_id(사장님)로 스코프한다 — 남의 가게가 보이면 안 된다.
|
||||
class IPlaceCRUD(ABC):
|
||||
@abstractmethod
|
||||
async def add_place(self, cdb: AsyncSession, place: places) -> ErrorType:
|
||||
pass
|
||||
|
||||
@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
|
||||
|
||||
@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
|
||||
|
||||
@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
|
||||
|
||||
@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
|
||||
|
||||
@abstractmethod
|
||||
@ -73,11 +73,11 @@ class PlaceCRUD(IPlaceCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
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:
|
||||
query = (
|
||||
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)
|
||||
)
|
||||
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
|
||||
@ -91,11 +91,11 @@ class PlaceCRUD(IPlaceCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
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,
|
||||
) -> Tuple[ErrorType, list, int]:
|
||||
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:
|
||||
conditions.append(places.category == category)
|
||||
if status is not None:
|
||||
@ -126,14 +126,14 @@ class PlaceCRUD(IPlaceCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
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, 적용행수)."""
|
||||
try:
|
||||
if not data:
|
||||
return ErrorType.SUCCESS, 0
|
||||
query = (
|
||||
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())
|
||||
)
|
||||
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
|
||||
@ -141,12 +141,12 @@ class PlaceCRUD(IPlaceCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
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:
|
||||
query = (
|
||||
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)
|
||||
except Exception as ex:
|
||||
|
||||
@ -22,7 +22,7 @@ class ISiteCRUD(ABC):
|
||||
pass
|
||||
|
||||
@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
|
||||
|
||||
@abstractmethod
|
||||
@ -93,13 +93,13 @@ class SiteCRUD(ISiteCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
async def list_company_sites(self, cdb: AsyncSession, company_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]:
|
||||
"""회사의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수).
|
||||
async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]:
|
||||
"""사장님의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수).
|
||||
|
||||
따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장
|
||||
(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다."""
|
||||
try:
|
||||
where = and_(places.deleted == False, places.company_id == company_id) # noqa: E712
|
||||
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))
|
||||
if cnt_err != ErrorType.SUCCESS:
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
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 common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import users, companies
|
||||
from common.enums import ErrorType, UserRole
|
||||
from common.database.model.models import users
|
||||
from common.enums import ErrorType
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
|
||||
@ -39,22 +39,6 @@ class IUserCRUD(ABC):
|
||||
async def update_last_accessed(self, cdb: AsyncSession, user_id) -> ErrorType:
|
||||
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
|
||||
async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]:
|
||||
pass
|
||||
@ -145,72 +129,6 @@ class UserCRUD(IUserCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
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]:
|
||||
try:
|
||||
query = select(users).where(users.user_id == user_id, users.deleted == False).limit(1) # noqa: E712
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from common.enums import AuthProvider, UserRole
|
||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
@ -17,7 +15,7 @@ class Req_Login(AuthProtocol):
|
||||
|
||||
|
||||
class Req_Signup(AuthProtocol):
|
||||
"""id/pw 가입. 가입 = 새 테넌트(회사) 1개 + 그 회사의 첫 계정 1개다.
|
||||
"""id/pw 가입. 가입 = 계정 1개다.
|
||||
|
||||
★ 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면
|
||||
한 사람에게 계정이 둘 생긴다. 지금 이메일 인증 절차는 없다 — 소유 증명이 아니라
|
||||
@ -27,7 +25,6 @@ class Req_Signup(AuthProtocol):
|
||||
password: str = ""
|
||||
name: Optional[str] = None
|
||||
email: str = ""
|
||||
company_name: Optional[str] = None # 상호. 비우면 이름 → 아이디 순으로 채운다
|
||||
|
||||
|
||||
class Req_GoogleLogin(AuthProtocol):
|
||||
@ -46,7 +43,7 @@ class Res_Login(Res_WebPacketProtocol):
|
||||
|
||||
|
||||
class Req_UpdateMe(AuthProtocol):
|
||||
# 본인 정보 수정. role·company·id 는 받지 않는다(자기 권한·소속 변경 불가).
|
||||
# 본인 정보 수정. role·id 는 받지 않는다(자기 권한 변경 불가).
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
contact_number: Optional[str] = None
|
||||
@ -58,11 +55,6 @@ class Res_RefreshToken(Res_WebPacketProtocol):
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class CompanyData(WebPacketProtocol):
|
||||
company_id: str = ""
|
||||
name: str = ""
|
||||
|
||||
|
||||
class Res_Me(Res_WebPacketProtocol):
|
||||
user_id: str = ""
|
||||
id: str = ""
|
||||
@ -73,4 +65,3 @@ class Res_Me(Res_WebPacketProtocol):
|
||||
# 이 계정이 무엇으로 로그인하는가. 구글 계정에는 바꿀 비밀번호가 없어서(update_me 가 막는다)
|
||||
# 내 정보 화면이 붙을 때 이 값으로 갈라야 한다.
|
||||
provider: AuthProvider = AuthProvider.LOCAL
|
||||
company: Optional[CompanyData] = Field(default=None)
|
||||
|
||||
@ -31,7 +31,7 @@ from .protocol import (
|
||||
Res_UnitList,
|
||||
)
|
||||
|
||||
# 사업장 라우터. 모든 조회·변경은 토큰의 회사(company_id)로 스코프된다.
|
||||
# 사업장 라우터. 모든 조회·변경은 토큰의 사장님(places.owner_user_id)으로 스코프된다.
|
||||
router = APIRouter(prefix="/v1/place", tags=["Place"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
|
||||
@ -18,7 +18,8 @@ class Req_CreatePlace(PlaceProtocol):
|
||||
# 상호명 하나로 시작한다. 나머지는 카카오 로컬 검증이 채운다.
|
||||
name: str = ""
|
||||
category: PlaceCategory = PlaceCategory.LODGING
|
||||
owner_user_id: Optional[uuid.UUID] = None
|
||||
# ★ 주인은 받지 않는다 — 토큰이 정한다(place_service.create_place). 여기로 받으면
|
||||
# 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다.
|
||||
|
||||
|
||||
class Req_VerifyPlace(PlaceProtocol):
|
||||
@ -58,8 +59,8 @@ class Req_VerifyPlaceByUrl(PlaceProtocol):
|
||||
|
||||
|
||||
class Req_UpdatePlace(PlaceProtocol):
|
||||
# ★ 주인은 못 바꾼다(위 Req_CreatePlace 주석). 소유권 이전은 아직 기능이 아니다.
|
||||
name: Optional[str] = None
|
||||
owner_user_id: Optional[uuid.UUID] = None
|
||||
status: Optional[PlaceStatus] = None
|
||||
|
||||
|
||||
@ -212,7 +213,7 @@ class Res_VerifyCandidates(Res_WebPacketProtocol):
|
||||
class PlaceSearchItem(WebPacketProtocol):
|
||||
"""공개 검색 결과 1건.
|
||||
|
||||
★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·company_id·소유자)은
|
||||
★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·소유자)은
|
||||
하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다.
|
||||
★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고,
|
||||
확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다."""
|
||||
|
||||
@ -53,13 +53,11 @@ async def main():
|
||||
engine = create_async_engine(dsn)
|
||||
|
||||
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:
|
||||
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,company_id,id,password,name,status,role,last_accessed_at) "
|
||||
"VALUES (:u,:c,:i,:p,:n,:s,:r,now())"),
|
||||
{"u": uid, "c": cid, "i": login, "p": await GetHashedPW("pw1234"),
|
||||
await c.execute(text("INSERT INTO company.users (user_id,id,password,name,status,role,last_accessed_at) "
|
||||
"VALUES (:u,:i,:p,:n,:s,:r,now())"),
|
||||
{"u": uid, "i": login, "p": await GetHashedPW("pw1234"),
|
||||
"n": "데모", "s": UserStatus.ACTIVE.value, "r": UserRole.OWNER.value})
|
||||
|
||||
from router.router import app
|
||||
|
||||
@ -41,12 +41,11 @@ async def main():
|
||||
engine = create_async_engine(dsn)
|
||||
|
||||
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:
|
||||
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,company_id,id,password,name,status,role,last_accessed_at) "
|
||||
"VALUES (:u,:c,:i,:p,:n,:s,:r,now())"),
|
||||
{"u": uid, "c": cid, "i": login, "p": await GetHashedPW("pw1234"),
|
||||
await c.execute(text("INSERT INTO company.users (user_id,id,password,name,status,role,last_accessed_at) "
|
||||
"VALUES (:u,:i,:p,:n,:s,:r,now())"),
|
||||
{"u": uid, "i": login, "p": await GetHashedPW("pw1234"),
|
||||
"n": "데모", "s": UserStatus.ACTIVE.value, "r": UserRole.OWNER.value})
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@ -4,13 +4,12 @@ import uuid
|
||||
from fastapi import Depends
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import companies, users
|
||||
from common.enums import AuthProvider, CompanyStatus, DBWRType, ErrorType, UserRole, UserStatus
|
||||
from common.database.model.models import users
|
||||
from common.enums import AuthProvider, DBWRType, ErrorType, UserRole, UserStatus
|
||||
from common.logger import LOG
|
||||
from common.models.gmodel import UserInfo
|
||||
from crud.user_crud import IUserCRUD, UserCRUD
|
||||
from router.v1.auth.protocol import (
|
||||
CompanyData,
|
||||
Req_GoogleLogin,
|
||||
Req_Signup,
|
||||
Req_UpdateMe,
|
||||
@ -71,11 +70,11 @@ class AuthService:
|
||||
|
||||
@staticmethod
|
||||
def _user_info(user: users) -> UserInfo:
|
||||
# uuid → str (JWT json 직렬화 위해). 기능 라우터는 company_id 로 스코프한다.
|
||||
# uuid → str (JWT json 직렬화 위해). 기능 라우터는 user_id 로 스코프한다
|
||||
# — 사업장이 places.owner_user_id 로 이 값에 매여 있다.
|
||||
return UserInfo(
|
||||
user_id=str(user.user_id),
|
||||
id=user.id,
|
||||
company_id=str(user.company_id),
|
||||
role=user.role,
|
||||
)
|
||||
|
||||
@ -140,26 +139,17 @@ class AuthService:
|
||||
password_hash: str | None,
|
||||
name: str | None,
|
||||
email: str | None,
|
||||
company_name: str,
|
||||
provider: AuthProvider,
|
||||
provider_uid: str | None,
|
||||
) -> tuple[ErrorType, users]:
|
||||
"""회사 1개 + 그 회사의 첫 계정 1개를 한 트랜잭션으로 만든다.
|
||||
"""계정 1개를 만든다.
|
||||
|
||||
★ 가입은 곧 새 테넌트다. users.company_id 가 NOT NULL 이고 모든 도메인(사업장·사이트)이
|
||||
company_id 로 스코프되므로, 회사 없는 계정은 아무것도 만들지 못한다.
|
||||
★ uuid 를 여기서 미리 만든다. 모델 default 는 flush 시점에 적용돼서, 그 전에
|
||||
company.company_id 를 읽으면 None 이다 — 그대로 넣으면 NOT NULL 위반이다."""
|
||||
company_uuid = uuid.uuid4()
|
||||
company = companies(
|
||||
company_id=company_uuid,
|
||||
name=_fit(company_name, 100),
|
||||
email=_fit(email, 255),
|
||||
status=CompanyStatus.ACTIVE.value,
|
||||
)
|
||||
★ 예전엔 가입 한 번이 **회사(테넌트) 하나**를 같이 만들었고 모든 도메인이 그 회사로
|
||||
스코프됐다. 쓰는 사람은 사장님 혼자인데 자기 회사에 소속된 직원이 되는 구조라
|
||||
걷어냈다(2026-09-08) — 이제 사업장이 `places.owner_user_id` 로 이 계정에 직접 매인다.
|
||||
★ uuid 를 여기서 미리 만든다. 모델 default 는 flush 시점에 적용돼서 그 전에 읽으면 None 이다."""
|
||||
user = users(
|
||||
user_id=uuid.uuid4(),
|
||||
company_id=company_uuid,
|
||||
id=login_id,
|
||||
password=password_hash,
|
||||
name=_fit(name, 50),
|
||||
@ -171,10 +161,7 @@ class AuthService:
|
||||
)
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[users.DBType()],
|
||||
[
|
||||
lambda s: self.user_crud.add_company(s, company),
|
||||
lambda s: self.user_crud.add_user(s, user),
|
||||
],
|
||||
[lambda s: self.user_crud.add_user(s, user)],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, None
|
||||
@ -226,7 +213,6 @@ class AuthService:
|
||||
password_hash=await GetHashedPW(req.password),
|
||||
name=name,
|
||||
email=email,
|
||||
company_name=(req.company_name or "").strip() or name or login_id,
|
||||
provider=AuthProvider.LOCAL,
|
||||
provider_uid=None,
|
||||
)
|
||||
@ -268,13 +254,12 @@ class AuthService:
|
||||
res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT)
|
||||
return res
|
||||
|
||||
# 3) 첫 방문 — 계정과 회사를 만든다.
|
||||
# 3) 첫 방문 — 계정을 만든다.
|
||||
err_type, user = await self._create_account(
|
||||
login_id=_google_login_id(account.sub),
|
||||
password_hash=None,
|
||||
name=account.name or None,
|
||||
email=account.email or None,
|
||||
company_name=account.name or account.email or _google_login_id(account.sub),
|
||||
provider=AuthProvider.GOOGLE,
|
||||
provider_uid=account.sub,
|
||||
)
|
||||
@ -299,16 +284,6 @@ class AuthService:
|
||||
return res
|
||||
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.id = user.id
|
||||
res.name = user.name
|
||||
@ -316,7 +291,6 @@ class AuthService:
|
||||
res.contact_number = user.contact_number
|
||||
res.role = UserRole(user.role)
|
||||
res.provider = AuthProvider(user.provider)
|
||||
res.company = company
|
||||
return res
|
||||
|
||||
async def update_me(self, user_info: UserInfo, req: Req_UpdateMe) -> Res_Me:
|
||||
|
||||
@ -431,12 +431,12 @@ async def run_collect(job: dict) -> dict:
|
||||
"""COLLECT 잡 핸들러. 반환값이 jobs.result 에 저장돼 폴링·감사에 쓰인다."""
|
||||
payload = job["payload"]
|
||||
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(
|
||||
places.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)),
|
||||
lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)),
|
||||
)
|
||||
if err != ErrorType.SUCCESS or place is None:
|
||||
raise CollectAborted(f"사업장을 찾을 수 없다: {place_id}")
|
||||
@ -464,7 +464,7 @@ async def run_collect(job: dict) -> dict:
|
||||
|
||||
if not targets:
|
||||
result["note"] = "크롤링 대상이 없다(어댑터가 처리할 수 있는 확정 URL 0건)"
|
||||
await _finish(place_id, company_id, PlaceStatus.REVIEW)
|
||||
await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)
|
||||
return result
|
||||
|
||||
# ★ 이미 충분하면 크롤링 자체를 건너뛴다(force 가 아닐 때).
|
||||
@ -472,16 +472,19 @@ async def run_collect(job: dict) -> dict:
|
||||
if before["enough"] and not payload.get("force"):
|
||||
result["coverage"] = before
|
||||
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']} — 크롤링 생략")
|
||||
return result
|
||||
|
||||
from common.models.gmodel import 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",
|
||||
company_id=company_id,
|
||||
role=1,
|
||||
)
|
||||
|
||||
@ -538,24 +541,24 @@ async def run_collect(job: dict) -> dict:
|
||||
# 사진이 들어왔으면 분석을 이어서 건다 — 수집과 분석은 각각 몇 분이라 한 잡에 묶지 않는다.
|
||||
# (묶으면 분석에서 죽었을 때 수집까지 다시 하게 되고, 유료 API 를 두 번 태운다.)
|
||||
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']}장")
|
||||
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(
|
||||
places.DBType(),
|
||||
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 crud.job_crud import JobQueue
|
||||
@ -567,7 +570,7 @@ async def _enqueue_vision(place_id: str, company_id: str) -> str | None:
|
||||
return None
|
||||
job_id, _created = await enqueue_job(
|
||||
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}",
|
||||
)
|
||||
return job_id
|
||||
|
||||
@ -40,10 +40,10 @@ class CopyAborted(RuntimeError):
|
||||
|
||||
|
||||
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"]
|
||||
place_id = payload["place_id"]
|
||||
company_id = payload["company_id"]
|
||||
owner_user_id = payload["owner_user_id"]
|
||||
|
||||
if not gemini_text.is_configured():
|
||||
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(
|
||||
places.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)),
|
||||
lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)),
|
||||
)
|
||||
if err != ErrorType.SUCCESS or place is None:
|
||||
raise CopyAborted(f"사업장을 찾을 수 없다: {place_id}")
|
||||
@ -165,9 +165,12 @@ async def run_copy(job: dict) -> dict:
|
||||
}
|
||||
|
||||
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",
|
||||
company_id=company_id,
|
||||
role=1,
|
||||
)
|
||||
service = FactService(_fact_crud, _place_crud)
|
||||
|
||||
@ -73,7 +73,7 @@ class FactService:
|
||||
err_type, place = await DB_SESSION_MNG.execute_lambda(
|
||||
places.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)),
|
||||
lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return ErrorType.PLACE_NOT_FOUND, None
|
||||
|
||||
@ -38,14 +38,14 @@ class FaqService:
|
||||
self.crud = crud
|
||||
self.place_crud = place_crud
|
||||
|
||||
# ---- 사업장 로드(회사 스코프) ----
|
||||
# ---- 사업장 로드(사장님 스코프) ----
|
||||
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(
|
||||
places.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)),
|
||||
lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return ErrorType.PLACE_NOT_FOUND, None
|
||||
|
||||
@ -41,7 +41,7 @@ class MediaService:
|
||||
err_type, place = await DB_SESSION_MNG.execute_lambda(
|
||||
places.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)),
|
||||
lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return ErrorType.PLACE_NOT_FOUND, None
|
||||
|
||||
@ -70,12 +70,12 @@ class PlaceService:
|
||||
# ---- 조회 ----
|
||||
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)
|
||||
cid = uuid.UUID(user_info.company_id)
|
||||
uid = uuid.UUID(user_info.user_id)
|
||||
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
|
||||
places.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.crud.list_places(
|
||||
s, cid, search,
|
||||
s, uid, search,
|
||||
category.value if isinstance(category, PlaceCategory) else category,
|
||||
status.value if isinstance(status, PlaceStatus) else status,
|
||||
pg.skip, pg.size,
|
||||
@ -102,7 +102,7 @@ class PlaceService:
|
||||
err_type, place = await DB_SESSION_MNG.execute_lambda(
|
||||
places.DBType(),
|
||||
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:
|
||||
return ErrorType.PLACE_NOT_FOUND, None
|
||||
@ -122,8 +122,11 @@ class PlaceService:
|
||||
return res
|
||||
|
||||
place = places(
|
||||
company_id=uuid.UUID(user_info.company_id),
|
||||
owner_user_id=req.owner_user_id,
|
||||
# ★ 주인은 **토큰이 정한다.** 예전엔 요청 body 의 owner_user_id 를 그대로 넣었는데,
|
||||
# 그 값은 아무도 안 보내서 92건 전부 NULL 이었고 스코프는 회사가 대신 하고 있었다.
|
||||
# 회사를 걷어내면서 이 컬럼이 스코프 키가 됐다 — body 로 남의 계정을 적을 수 있으면
|
||||
# 만들자마자 남의 목록에 들어간다.
|
||||
owner_user_id=uuid.UUID(user_info.user_id),
|
||||
name=req.name.strip(),
|
||||
category=req.category.value,
|
||||
status=PlaceStatus.DRAFT.value,
|
||||
@ -149,7 +152,7 @@ class PlaceService:
|
||||
if data:
|
||||
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
|
||||
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:
|
||||
res.result.SetResult(err_type)
|
||||
@ -164,7 +167,7 @@ class PlaceService:
|
||||
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
|
||||
places.DBType(),
|
||||
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:
|
||||
@ -251,7 +254,7 @@ class PlaceService:
|
||||
await DB_SESSION_MNG.execute_lambda_claim(
|
||||
places.DBType(),
|
||||
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:
|
||||
@ -292,7 +295,7 @@ class PlaceService:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
cid = uuid.UUID(user_info.company_id)
|
||||
uid = uuid.UUID(user_info.user_id)
|
||||
now = GTime.UTC()
|
||||
data = {
|
||||
"external_source": req.source.value,
|
||||
@ -320,7 +323,7 @@ class PlaceService:
|
||||
}
|
||||
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
|
||||
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:
|
||||
res.result.SetResult(err_type)
|
||||
@ -508,7 +511,7 @@ class PlaceService:
|
||||
|
||||
payload = {
|
||||
"place_id": place_id,
|
||||
"company_id": user_info.company_id,
|
||||
"owner_user_id": user_info.user_id,
|
||||
"category": place.category,
|
||||
# 명시적으로 고른 링크가 있을 때만 대상을 제한한다. 기본 요청에서 현재
|
||||
# 확정 링크를 복사하면, 잡의 discover 단계가 새로 확정한 네이버 링크가
|
||||
@ -537,7 +540,7 @@ class PlaceService:
|
||||
await DB_SESSION_MNG.execute_lambda_claim(
|
||||
places.DBType(),
|
||||
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},
|
||||
),
|
||||
)
|
||||
@ -584,7 +587,7 @@ class PlaceService:
|
||||
|
||||
job_id, created = await enqueue_job(
|
||||
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}",
|
||||
)
|
||||
if job_id is None:
|
||||
@ -844,7 +847,7 @@ class PlaceService:
|
||||
|
||||
job_id, created = await enqueue_job(
|
||||
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}",
|
||||
)
|
||||
if job_id is None:
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
고르는 자리를 한 곳으로 모았다. 사이트 한 곳을 여는 것과 발행 업소 명단을 통째로
|
||||
긁는 것은 다른 일이라, 페이지에 이미 적혀 있는 것만 나간다.
|
||||
|
||||
나가지 않는 것: place_id · company_id · site_id · 전화번호 · 상세 주소 · 좌표.
|
||||
나가지 않는 것: place_id · 소유자 · site_id · 전화번호 · 상세 주소 · 좌표.
|
||||
"""
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
|
||||
@ -28,10 +28,10 @@ class VisionAborted(RuntimeError):
|
||||
|
||||
|
||||
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"]
|
||||
place_id = payload["place_id"]
|
||||
company_id = payload["company_id"]
|
||||
owner_user_id = payload["owner_user_id"]
|
||||
force = bool(payload.get("force"))
|
||||
|
||||
if not gemini.is_configured():
|
||||
@ -40,7 +40,7 @@ async def run_vision(job: dict) -> dict:
|
||||
err, place = await DB_SESSION_MNG.execute_lambda(
|
||||
places.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)),
|
||||
lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)),
|
||||
)
|
||||
if err != ErrorType.SUCCESS or place is None:
|
||||
raise VisionAborted(f"사업장을 찾을 수 없다: {place_id}")
|
||||
|
||||
@ -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 호출.
|
||||
기대결과: 200, 본인 id·name·소속사(company_id)가 그대로 반환."""
|
||||
기대결과: 200, 본인 id·name 이 그대로 반환."""
|
||||
h = await auth_headers("user1", name="홍길동")
|
||||
|
||||
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()
|
||||
assert me["id"] == "user1"
|
||||
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):
|
||||
@ -49,7 +50,7 @@ _SIGNUP = {"id": "sajang1", "password": "pw12345678", "name": "김사장", "emai
|
||||
|
||||
async def test_signup_creates_account_and_logs_in(client, db_engine):
|
||||
"""검증: 가입 → 받은 토큰으로 곧바로 /me.
|
||||
기대결과: 토큰이 실려 오고, /me 가 방금 만든 신원과 **새로 생긴 소속사**를 돌려준다."""
|
||||
기대결과: 토큰이 실려 오고, /me 가 방금 만든 신원을 돌려준다."""
|
||||
r = await client.post("/v1/auth/signup", json=_SIGNUP)
|
||||
body = r.json()
|
||||
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["email"] == "boss@example.com"
|
||||
assert me["provider"] == 1 # AuthProvider.LOCAL
|
||||
assert me["company"]["name"] == "김사장" # 회사명 미입력 → 이름으로 채운다
|
||||
|
||||
|
||||
async def test_signup_rejects_duplicate_id(client, db_engine):
|
||||
|
||||
@ -264,12 +264,12 @@ async def test_versions_accumulate(auth_headers, client, db_engine):
|
||||
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."""
|
||||
h1 = await auth_headers("o1")
|
||||
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)
|
||||
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
|
||||
|
||||
|
||||
@ -156,13 +156,13 @@ async def test_targeted_collect_payload_carries_requested_confirmed_link(auth_he
|
||||
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 — 사업장이 안 보이니 수집도 못 건다."""
|
||||
h1 = await auth_headers("o1")
|
||||
pid = await _place(client, h1, kakao="c7")
|
||||
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={})
|
||||
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
|
||||
|
||||
@ -208,7 +208,7 @@ async def test_recollect_cannot_overwrite_corrected_value(auth_headers, client):
|
||||
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
|
||||
@ -216,13 +216,13 @@ async def test_pipeline_refuses_unverified_place(db_engine, company_id):
|
||||
pid = uuid.uuid4()
|
||||
async with db_engine.begin() as conn:
|
||||
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)"),
|
||||
{"pid": pid, "cid": uuid.UUID(company_id), "n": "미검증펜션"},
|
||||
{"pid": pid, "cid": uuid.UUID(owner_id), "n": "미검증펜션"},
|
||||
)
|
||||
|
||||
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)
|
||||
await worker.process_one()
|
||||
|
||||
|
||||
@ -269,12 +269,12 @@ async def test_crawl_only_does_not_mark_rebuild(auth_headers, client):
|
||||
assert place.get("content_updated_at") is None
|
||||
|
||||
|
||||
async def test_facts_are_scoped_to_company(auth_headers, client, other_company_id):
|
||||
"""검증: 다른 회사 계정으로 남의 사업장 fact 를 조회한다.
|
||||
async def test_facts_are_scoped_to_owner(auth_headers, client):
|
||||
"""검증: 다른 사장님 계정으로 남의 사업장 fact 를 조회한다.
|
||||
기대결과: PLACE_NOT_FOUND — 사업장이 안 보이니 fact 도 안 보인다."""
|
||||
h1 = await auth_headers("o1")
|
||||
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)
|
||||
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
|
||||
|
||||
@ -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 를 돌려준다."""
|
||||
pid = uuid.uuid4()
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
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())"
|
||||
),
|
||||
{
|
||||
"pid": pid, "cid": uuid.UUID(company_id), "name": "테스트펜션",
|
||||
"pid": pid, "cid": uuid.UUID(owner_id), "name": "테스트펜션",
|
||||
"cat": PlaceCategory.LODGING.value, "status": PlaceStatus.DRAFT.value,
|
||||
"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 를 두 번 넣는다.
|
||||
기대결과: 두 번째 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)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
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", "16:00", FactStatus.PENDING_OWNER)
|
||||
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건이 공존해야 한다"
|
||||
|
||||
|
||||
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 를 새로 노출한다.
|
||||
기대결과: 통과 — 틀린 값은 이력으로 남고, 새 값이 노출 자리를 차지한다."""
|
||||
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", "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 이력과 새 값이 함께 남아야 한다"
|
||||
|
||||
|
||||
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 도 유니크에서 빠진다."""
|
||||
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.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 를 각각 가질 수 있는지.
|
||||
기대결과: 통과 — 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()
|
||||
async with db_engine.begin() as conn:
|
||||
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)
|
||||
|
||||
|
||||
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 를 사업장 단위와 객실 단위로 동시에 갖는 경우.
|
||||
기대결과: 통과 — 부분 인덱스가 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()
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
|
||||
@ -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", []) == []
|
||||
|
||||
|
||||
async def test_other_company_cannot_read_or_touch_faq(auth_headers, client, db_engine, other_company_id):
|
||||
"""검증: 남의 회사 사용자가 place_id 를 알아내 FAQ 를 조회·전이한다.
|
||||
async def test_other_owner_cannot_read_or_touch_faq(auth_headers, client, db_engine):
|
||||
"""검증: 남의 사용자가 place_id 를 알아내 FAQ 를 조회·전이한다.
|
||||
기대결과: PLACE_NOT_FOUND — 존재 여부조차 알려주지 않는다."""
|
||||
h = await auth_headers("u1")
|
||||
pid = await _place(client, h)
|
||||
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
|
||||
body = await _transition(client, other, pid, fid, {"status": FactStatus.VERIFIED.value})
|
||||
assert body["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
이 경로가 절대 하면 안 되는 것:
|
||||
- 사이트가 아직 없는 사업장을 빼는 것 — 위저드를 걸어오다 만 가게가 목록에서 사라지면
|
||||
사장님은 그걸 다시 찾을 길이 없다(에디터 주소를 아무도 기억하지 않는다).
|
||||
- 회사 스코프를 놓치는 것 — 남의 가게가 내 목록에 섞이면 그건 목록이 아니라 사고다.
|
||||
- 사장님 스코프를 놓치는 것 — 남의 가게가 내 목록에 섞이면 그건 목록이 아니라 사고다.
|
||||
- 단건(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
|
||||
|
||||
|
||||
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건."""
|
||||
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, theirs, "남의펜션")
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
"""places 도메인 e2e — 등록 / 동일 업소 검증 / 회사 스코프 / 채널 URL 확정 게이트.
|
||||
"""places 도메인 e2e — 등록 / 동일 업소 검증 / 사장님 스코프 / 채널 URL 확정 게이트.
|
||||
|
||||
★ 이 도메인의 핵심 규칙 두 개를 고정한다:
|
||||
1. 검증(verify) 전에는 채널 URL 을 확정할 수 없다 → 크롤링이 안 열린다
|
||||
2. 남의 회사 사업장은 '없음'으로 보인다
|
||||
2. 남의 사업장은 '없음'으로 보인다
|
||||
"""
|
||||
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):
|
||||
"""검증: 같은 회사에서 같은 카카오 장소를 두 사업장에 붙인다.
|
||||
"""검증: 같은 사장님이 같은 카카오 장소를 두 사업장에 붙인다.
|
||||
기대결과: 둘 다 등록된다 — 한 사용자가 같은 실제 업장으로 여러 프로젝트를 만들 수 있다."""
|
||||
h = await auth_headers("u1")
|
||||
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
|
||||
|
||||
|
||||
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 차단)."""
|
||||
h1 = await auth_headers("owner1")
|
||||
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)
|
||||
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
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"):
|
||||
assert leaked not in item, f"{leaked} 가 공개 응답에 나갔다"
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@ def test_empty_site_returns_actionable_failures():
|
||||
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")
|
||||
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 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()
|
||||
assert denied["result"]["success"] is False
|
||||
|
||||
@ -13,14 +13,14 @@ from sqlalchemy import text
|
||||
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 를 직접 넣는다 — 여기서 보는 건 목록 조회지 빌드 파이프라인이 아니다."""
|
||||
pid, sid = uuid.uuid4(), uuid.uuid4()
|
||||
async with db_engine.begin() as c:
|
||||
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)"),
|
||||
{"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,
|
||||
"road": "강원특별자치도 양양군 현북면 하조대3길 12-3", "addr": "강원특별자치도 양양군 현북면 하광정리 1-2",
|
||||
"phone": "033-672-0000"},
|
||||
@ -34,12 +34,12 @@ async def _publish(db_engine, company_id, name, *, status, domain, thumb=None, m
|
||||
return str(pid)
|
||||
|
||||
|
||||
async def test_발행된_사이트만_로그인_없이_보인다(client, db_engine, company_id):
|
||||
async def test_발행된_사이트만_로그인_없이_보인다(client, db_engine, owner_id):
|
||||
"""검증: 발행본 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")
|
||||
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")
|
||||
|
||||
@ -51,10 +51,10 @@ async def test_발행된_사이트만_로그인_없이_보인다(client, db_engi
|
||||
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]
|
||||
|
||||
@ -66,10 +66,10 @@ async def test_개인정보와_내부값은_나가지_않는다(client, db_engin
|
||||
assert "하조대3길" not in body
|
||||
|
||||
|
||||
async def test_썸네일이_없으면_키가_없다(client, db_engine, company_id):
|
||||
async def test_썸네일이_없으면_키가_없다(client, db_engine, owner_id):
|
||||
"""★ 썸네일은 발행의 부수 효과라 실패할 수 있다(대표 사진이 없거나 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]
|
||||
|
||||
@ -77,9 +77,9 @@ async def test_썸네일이_없으면_키가_없다(client, db_engine, company_i
|
||||
assert "thumbnail_url" not in item
|
||||
|
||||
|
||||
async def test_최신_발행순이고_limit_로_자른다(client, db_engine, company_id):
|
||||
await _publish(db_engine, company_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)
|
||||
async def test_최신_발행순이고_limit_로_자른다(client, db_engine, owner_id):
|
||||
await _publish(db_engine, owner_id, "먼저", status=SiteStatus.PUBLISHED.value, domain="first", minutes_ago=60)
|
||||
await _publish(db_engine, owner_id, "나중", status=SiteStatus.PUBLISHED.value, domain="second", minutes_ago=1)
|
||||
|
||||
items = (await client.get("/v1/showcase")).json()["items"]
|
||||
assert [i["name"] for i in items] == ["나중", "먼저"]
|
||||
|
||||
@ -129,11 +129,11 @@ async def test_published_site_slug_is_locked(auth_headers, client, db_engine):
|
||||
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(존재 여부조차 알려주지 않는다)."""
|
||||
h = await auth_headers("slug5")
|
||||
intruder = await auth_headers("slug6", other_company_id)
|
||||
intruder = await auth_headers("slug6")
|
||||
pid = await _place(client, h)
|
||||
|
||||
assert (await _check(client, intruder, pid, "doflo"))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
|
||||
|
||||
@ -99,11 +99,11 @@ async def test_published_site_template_is_not_locked(auth_headers, client, db_en
|
||||
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(존재 여부조차 알려주지 않는다)."""
|
||||
h = await auth_headers("tpl6")
|
||||
intruder = await auth_headers("tpl7", other_company_id)
|
||||
intruder = await auth_headers("tpl7")
|
||||
pid = await _place(client, h)
|
||||
|
||||
blocked = await _set_template(client, intruder, pid, "stay-quiet-margin")
|
||||
|
||||
@ -129,11 +129,11 @@ async def test_published_site_theme_is_not_locked(auth_headers, client, db_engin
|
||||
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(존재 여부조차 알려주지 않는다)."""
|
||||
h = await auth_headers("thm7")
|
||||
intruder = await auth_headers("thm8", other_company_id)
|
||||
intruder = await auth_headers("thm8")
|
||||
pid = await _place(client, h)
|
||||
|
||||
assert (await _set_theme(client, intruder, pid, _THEME))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
|
||||
|
||||
@ -10,13 +10,13 @@ from common.enums import FactStatus, MediaStatus, PlaceCategory, SourceType
|
||||
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()
|
||||
async with db_engine.begin() as c:
|
||||
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())"),
|
||||
{"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"},
|
||||
)
|
||||
return pid
|
||||
@ -53,10 +53,10 @@ class _Place:
|
||||
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 를 섞어 넣는다.
|
||||
기대결과: ★ 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, "wifi", "true", FactStatus.CORRECTED)
|
||||
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"}
|
||||
|
||||
|
||||
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 신뢰도가 낮아 확인 큐에 남은 사진은 안 나간다."""
|
||||
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/pending.jpg", MediaStatus.PENDING_REVIEW)
|
||||
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"]
|
||||
|
||||
|
||||
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 없는 이미지는 접근성도 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/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"]
|
||||
|
||||
|
||||
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 라벨.
|
||||
기대결과: 업종 스키마의 한글 라벨이 붙는다 — 화면이 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)
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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.
|
||||
기대결과: unit_id 가 실려 빌더가 객실별로 묶을 수 있다."""
|
||||
pid = await _seed(db_engine, company_id)
|
||||
pid = await _seed(db_engine, owner_id)
|
||||
uid = uuid.uuid4()
|
||||
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)"),
|
||||
@ -120,10 +120,10 @@ async def test_unit_scoped_facts_carry_unit_id(db_engine, company_id):
|
||||
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건으로 거부할 재료가 된다."""
|
||||
pid = await _seed(db_engine, company_id)
|
||||
pid = await _seed(db_engine, owner_id)
|
||||
snap = await build_snapshot(_Place(pid))
|
||||
assert snap["facts"] == [] and snap["media"] == [] and snap["faqs"] == []
|
||||
assert snap["place"]["name"] == "스냅샷펜션"
|
||||
@ -156,13 +156,13 @@ class _RegionPlace(_Place):
|
||||
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 원문이 사이트로 새면
|
||||
'미검증 값 노출 금지'가 깨진다(fact 를 VERIFIED 로 거르는 것과 같은 규칙)."""
|
||||
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.REVIEW, "검수대기축제")
|
||||
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"]] == ["발행축제"]
|
||||
|
||||
|
||||
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
|
||||
@ -179,7 +179,7 @@ async def test_local_content_outside_display_window_is_excluded(db_engine, compa
|
||||
from common.enums import LocalContentStatus, LocalContentType
|
||||
|
||||
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, "끝난축제",
|
||||
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"]] == ["지금축제"]
|
||||
|
||||
|
||||
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 로 묶인다.
|
||||
기대결과: 다른 지역의 발행 콘텐츠는 담기지 않는다."""
|
||||
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, "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"]] == ["우리지역축제"]
|
||||
|
||||
|
||||
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 가 비어 있지만 도로명주소는 있는 사업장.
|
||||
기대결과: 주소에서 지역 키를 유도해 그 지역 콘텐츠를 담는다 — places.region_code 를 채우는
|
||||
코드가 생기기 전에 만들어진 사업장(실측 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
|
||||
|
||||
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, "양양축제")
|
||||
|
||||
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"]] == ["양양축제"]
|
||||
|
||||
|
||||
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
|
||||
|
||||
pid = await _seed(db_engine, company_id)
|
||||
pid = await _seed(db_engine, owner_id)
|
||||
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "어딘가축제")
|
||||
|
||||
place = _RegionPlace(pid, "")
|
||||
|
||||
@ -136,13 +136,13 @@ async def test_candidates_require_configured_source(auth_headers, client, monkey
|
||||
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."""
|
||||
_patch_naver(monkeypatch, _match(kakao_client.MatchOutcome.MATCHED, _np("a", "b"), [], "x"))
|
||||
h1 = await auth_headers("o1")
|
||||
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()
|
||||
assert body["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -11,7 +11,7 @@ import type { PlaceSearchItemCategory } from './placeSearchItemCategory';
|
||||
/**
|
||||
* 공개 검색 결과 1건.
|
||||
|
||||
★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·company_id·소유자)은
|
||||
★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·소유자)은
|
||||
하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다.
|
||||
★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고,
|
||||
확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다.
|
||||
|
||||
@ -5,10 +5,9 @@
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
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;
|
||||
name?: ReqSignupName;
|
||||
email?: string;
|
||||
company_name?: ReqSignupCompanyName;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -11,7 +11,6 @@ import type { ResMeEmail } from './resMeEmail';
|
||||
import type { ResMeContactNumber } from './resMeContactNumber';
|
||||
import type { UserRole } from './userRole';
|
||||
import type { AuthProvider } from './authProvider';
|
||||
import type { ResMeCompany } from './resMeCompany';
|
||||
|
||||
export interface ResMe {
|
||||
result?: ErrorInfo;
|
||||
@ -23,5 +22,4 @@ export interface ResMe {
|
||||
contact_number?: ResMeContactNumber;
|
||||
role?: UserRole;
|
||||
provider?: AuthProvider;
|
||||
company?: ResMeCompany;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -12,7 +12,7 @@ import type { ShowcaseItemThumbnailUrl } from './showcaseItemThumbnailUrl';
|
||||
* 랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.**
|
||||
|
||||
★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다.
|
||||
place_id·company_id·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과
|
||||
place_id·소유자·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과
|
||||
발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다.
|
||||
*/
|
||||
export interface ShowcaseItem {
|
||||
|
||||
@ -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"
|
||||
>
|
||||
{userLabel(user)}
|
||||
{user.companyName ? ` · ${user.companyName}` : ''}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="mb-2 truncate px-2 py-1 text-[11px] text-sidebar-foreground">
|
||||
|
||||
@ -10,8 +10,8 @@ import {toAuthUser, useAuthStore} from '@/stores/auth';
|
||||
/**
|
||||
* 내 정보 — `PATCH /v1/auth/me` 한 곳이 받는 것만 그린다.
|
||||
*
|
||||
* ★ 상호(company)는 읽기 전용이다. Req_UpdateMe 에 없다 — 자기 소속을 스스로 바꾸지 못하게
|
||||
* 일부러 뺀 필드라, 입력칸을 두면 저장을 눌러도 아무 일이 안 일어난다.
|
||||
* ★ 상호 칸은 없다. 회사(테넌트)를 걷어내면서(2026-09-08) 계정에 상호가 없어졌다 —
|
||||
* 가게 이름은 사업장(place)이 갖는다.
|
||||
* ★ 구글 계정에는 바꿀 비밀번호가 없다(서버가 ACCOUNT_PROVIDER_CONFLICT 로 막는다) —
|
||||
* 입력칸 자체를 그리지 않는다.
|
||||
*/
|
||||
@ -77,13 +77,6 @@ export function AccountPage() {
|
||||
{isGoogle ? '구글 계정으로 로그인합니다.' : '아이디는 바꿀 수 없습니다.'}
|
||||
</p>
|
||||
</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 className="space-y-4 rounded-xl border border-border bg-card p-5">
|
||||
|
||||
@ -212,10 +212,9 @@ export function BuilderPage() {
|
||||
<span className="h-3.5 w-px bg-white/20" />
|
||||
<span
|
||||
className="max-w-[14rem] truncate text-background/70"
|
||||
title={user.companyName ? `${userLabel(user)} · ${user.companyName}` : userLabel(user)}
|
||||
title={userLabel(user)}
|
||||
>
|
||||
{userLabel(user)}
|
||||
{user.companyName ? ` · ${user.companyName}` : ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -31,7 +31,6 @@ export function SignupPage() {
|
||||
passwordConfirm: '',
|
||||
name: '',
|
||||
email: '',
|
||||
companyName: '',
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
@ -63,7 +62,6 @@ export function SignupPage() {
|
||||
password: form.password,
|
||||
name: form.name.trim() || null,
|
||||
email: form.email.trim(),
|
||||
company_name: form.companyName.trim() || null,
|
||||
});
|
||||
if (res.result?.success === false) {
|
||||
notifyApiError({data: res}, '가입하지 못했습니다.');
|
||||
@ -180,17 +178,6 @@ export function SignupPage() {
|
||||
required
|
||||
/>
|
||||
</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>
|
||||
|
||||
<Button type="submit" variant="primary" className="w-full" isLoading={isSubmitting}>
|
||||
|
||||
@ -8,8 +8,6 @@ export interface AuthUser {
|
||||
name?: string;
|
||||
email?: string;
|
||||
role: number;
|
||||
companyId?: string;
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -26,8 +24,6 @@ export function toAuthUser(res: ResMe): AuthUser {
|
||||
name: res.name ?? undefined,
|
||||
email: res.email ?? undefined,
|
||||
role: res.role ?? UserRole.USER,
|
||||
companyId: res.company?.company_id,
|
||||
companyName: res.company?.name,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user