o2o-site-AEO/solution/frontend/src/api
Mina Choi 48109fdc99 [feat] solution/backend,frontend: id/pw 가입 · 구글 로그인 — 계정을 만들 길이 없던 걸 연다
계정 생성 API 가 아예 없었다(그동안 users 를 손으로 INSERT 했다). 로그인 화면은 있는데
그 뒤에 설 계정을 만들 방법이 제품에 없는 상태였다.

- auth_service.signup: 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개**. users.company_id 가
  NOT NULL 이고 모든 도메인이 company 로 스코프돼서, 회사 없는 계정은 아무것도 못 만든다
- services/external/google_identity: 구글 ID 토큰의 서명·iss·만료에 더해 **aud(우리 client_id)와
  email_verified 를 본다.** aud 검사가 빠지면 남의 앱에 발급된 '진짜' 구글 토큰으로 우리 계정에
  들어온다 — 서명도 발급자도 전부 맞으므로 다른 검사로는 안 걸린다
- users.provider/provider_uid 추가, password NULL 허용, id 20→64자(google_<sub> 가 20자를 넘는다).
  provider 에 server_default 를 같이 준 이유: ORM default 는 raw INSERT(테스트 시드)에 안 먹어서
  NOT NULL 컬럼이면 그 경로가 통째로 깨진다
- attempt_login: 소셜 계정을 먼저 끊는다. 안 끊으면 bcrypt 가 None 해시를 만나 500 이다
- 같은 이메일이라도 id/pw 계정과 구글 계정을 **잇지 않는다.** 이으면 계정 선점이다 —
  남의 이메일로 먼저 만들어 둔 계정에 그 사람의 구글 로그인이 들어간다 → DECISIONS 1-5
- LoginPage 는 admin 과 공유라 selfServe 로 갈랐다. admin 은 가입 링크도 구글 버튼도 안 뜬다
  (admin 라우터에 /signup 이 없어 404 가 난다)
- GOOGLE_CLIENT_ID 는 루트 .env 한 곳. compose 가 VITE_GOOGLE_CLIENT_ID 로 흘려보낸다 —
  두 곳에 적으면 백엔드 aud 대조와 화면 버튼이 조용히 갈라진다

★ 이미 도는 DB 는 init.sql 을 다시 적용해야 한다(말미 ALTER 섹션).

pytest: auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료·email_verified·변조
거절 확인) 통과. 전체 527 passed / 8 failed(전부 기존 실패, 인증과 무관).
tsc·eslint·vite build 통과.
2026-09-02 09:33:59 +09:00
..
generated [feat] solution/backend,frontend: id/pw 가입 · 구글 로그인 — 계정을 만들 길이 없던 걸 연다 2026-09-02 09:33:59 +09:00
mutator 이름: solution/front → solution/frontend 2026-08-31 15:27:16 +09:00
index.ts 이름: solution/front → solution/frontend 2026-08-31 15:27:16 +09:00
pollJob.ts 이름: solution/front → solution/frontend 2026-08-31 15:27:16 +09:00
README.md 문서: 앱을 가른 뒤 낡아진 서술을 고치고, 개발과 무관해진 기록을 지운다 2026-08-31 16:58:09 +09:00

api

api/
├── index.ts              ★ 화면이 import 하는 단 하나의 입구
├── generated/            orval 산출물 — 손대지 않는다
├── mutator/custom-fetch.ts  모든 호출이 지나는 길목(토큰·에러·baseURL·434 재발급)
└── pollJob.ts            잡 폴링(수집·비전·생성·빌드 공용)

화면은 @/api 하나만 본다.

import {useListPlaces, useTransitionFact, startBuild, pollJob} from '@/api';
import type {PlaceData, FactStatus} from '@/api';

재생성

백엔드 OpenAPI 가 바뀌면 다시 뽑는다. generated/ 를 손으로 고치지 않는다.

# 레포 루트에서. 백엔드가 떠 있을 때
npm run orval

# 서버 없이 (스펙 파일을 먼저 뽑는다)
cd solution/backend && .venv/bin/python scripts/export_openapi.py
cd ../.. && ORVAL_INPUT=solution/backend/openapi.json npm run orval

생성되는 것 — 태그(도메인)별 훅과 모델.

태그
auth useLogin useRefreshToken useMe useUpdateMe
place useListPlaces useCreatePlace useGetPlace useUpdatePlace useVerifyCandidates useVerifyPlace useListUnits useCreateUnit useListLinks useCreateLink useConfirmLink useStartCollect useStartVision useStartCopy
fact useGetSchema useListFacts useUpsertFact useTransitionFact
job useGetJob useJobOps useRequeueJob
site useGetSite useStartBuild useListVersions useListLogs useChangeStatus
faq useListFaqs useCreateFaq useTransitionFaq
media useListMedia
local-content usePublish useSyncFestivals useUpdateContent useEndContent — 내부 운영 전용

orval.config.tsoperationName 이 FastAPI 의 list_places_v1_place_list_getlistPlaces 로 되돌린다 — 백엔드는 무수정이다.

규약 세 가지

1. 거절도 HTTP 200 이다. 도메인 거절은 result.success=false + result.desc(ErrorType 이름)로 온다. React Query 는 성공으로 보므로 onSuccess 안에서 직접 봐야 한다. 안 보면 저장 안 된 값이 저장된 것처럼 보인다.

onSuccess: (res) => {
  if (res.result?.success === false) return notifyApiError({data: res});
  ...
}

문구 변환은 @/lib/errorMessages 한 곳에 있다(PLACE_NOT_VERIFIED → "동일 업소 검증을 먼저…").

2. 응답의 None 필드는 키째 사라진다(백엔드 RemoveNoneResponse). 게다가 백엔드가 기본값을 준 필드는 OpenAPI 에서 required 가 아니라 생성 타입이 전부 optional 이다 — undefined 를 각오하고 쓴다.

3. 몇 분 걸리는 일은 잡이다. 수집·비전·생성·빌드는 job_id 를 받고 폴링한다. 루프는 pollJob() 하나뿐이다 — 화면마다 다시 쓰지 않는다.

const started = await startCollect(placeId, {});
const outcome = await pollJob(started.job_id!, {signal, onTick: (job) => ...});
// outcome.kind: done | dead | timeout | aborted | unreachable

done 은 "잡이 끝났다"이지 "성공했다"가 아니다. 빌드는 게이트에 막혀도 정상 종료하고 job.result.gate.passed 가 false 로 온다 — 판정은 features/publish/usePublishSite.ts 가 읽는다.