Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
*-env.txt
*.p8
secrets/
# Firebase·Google 서비스 계정 키(JSON). backend/private에는 README만 둔다.
backend/private/*
!backend/private/README.md
*firebase-adminsdk*.json
*service-account*.json
.data/
*.db

Expand Down
45 changes: 44 additions & 1 deletion HANDOFF.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 콜록(Collog) 개발 HANDOFF

마지막 갱신: 2026-08-18 (Asia/Seoul)
마지막 갱신: 2026-10-24 (Asia/Seoul)

이 문서는 콜록 개발의 단일 인수인계 기준이다. 구현, 계약, 검증 결과, 미완료 항목이 바뀌면
코드와 같은 커밋에서 반드시 이 문서를 갱신한다. 비밀키와 실제 건강정보는 기록하지 않는다.
Expand Down Expand Up @@ -132,6 +132,8 @@ STT/LLM/음향 파이프라인이 끝까지 도는 것을 확인했다. 다만
- iOS PushKit용 APNs HTTP/2 provider와 `/devices` idempotent 등록
- PushKit → CallKit → `/accept` → LiveKit iOS 계약 문서
- `/team` 모바일 웹 포털과 `/team/status.json` 비밀값 없는 연동 상태 endpoint
- Android용 Google 로그인 `/v1/auth/google/challenge`·`/v1/auth/google`. Apple 로그인과 같은
서버 nonce challenge 1회 소비 구조이며 `users.google_subject`로 계정을 찾는다
- 연결 대기 질문 ElevenLabs provider, MP3 cache, local/S3 만료 download URL과 iOS 로컬 폴백
- ElevenLabs voice 목록/한국어 MP3 preview CLI. provider key는 backend에만 보관
- APNs 자격증명 점검·실기기 발송 CLI와 sandbox 실기기 CallKit 수신 검증
Expand Down Expand Up @@ -160,6 +162,18 @@ STT/LLM/음향 파이프라인이 끝까지 도는 것을 확인했다. 다만

### 의도적으로 미완료

- **Google 로그인은 `GOOGLE_CLIENT_ID`를 설정해야 켜진다.** 값이 비면 두 엔드포인트가 503이다.
Google Cloud에 Web OAuth 클라이언트(이 값)와 Android OAuth 클라이언트(패키지 이름·서명
SHA-1)를 같은 프로젝트에 만들어야 한다. 같은 사람이 iOS(Apple)와 Android(Google)로
가입하면 계정이 따로 생기며 병합하지 않는다. Google 계정 삭제는 OTP 계정처럼 재인증 없이
진행한다.
- **Android 통화·리포트 알림은 Firebase 서비스 계정 키가 있어야 나간다.** compose에서는 APNs 키처럼
`FCM_KEY_FILE`에 호스트 절대 경로를 주면 `/run/secrets/fcm-service-account.json`으로 읽기 전용
bind되고 `FCM_SERVICE_ACCOUNT_PATH`가 그 경로로 채워진다. compose 없이 직접 실행할 때만
`FCM_SERVICE_ACCOUNT_PATH`에 키 경로를 준다. 값이 비면
Android 기기는 수신 기기로 치지 않아 Android만 가진 상대에게 거는 통화는 409(수신 기기 없음)이고,
리포트 알림도 Android 기기를 대상에서 뺀다. Android 기기는 `platform=ANDROID`로 FCM 토큰 하나를
`token`·`voipToken`·`pushToken`에 같이 등록한다. 키 JSON은 `.gitignore`가 막는다.
- **새 피그마와 현재 백엔드의 건강 주체 모델이 다르다.** 현재 API는 자녀 발신·부모 분석,
부모 질환 코드 배열에 고정돼 있다. 부모/자녀 양방향 통화와 양 참여자 동시 분석,
복용약·걱정·출처·본인 확인, Q/A 관찰 event와 리포트 v2는 설계만 완료됐다.
Expand Down Expand Up @@ -296,6 +310,8 @@ APNs payload에는 `callId/callUUID/callerId/callerName/expiresAt`만 넣는다.
| 파일 | 역할 |
|---|---|
| `services/livekit.py` | self-host LiveKit room/token/audio-track 조회/Track Egress/webhook adapter와 mock |
| `services/fcm.py` | Android FCM HTTP v1 발송. 서비스 계정 JWT로 OAuth 토큰 발급·재사용, 수신 통화·리포트 데이터 메시지, 만료 토큰 판정 |
| `services/google_auth.py` | Google ID 토큰 JWKS 검증(iss 두 값, aud=Web client ID, nonce), 키 cache·실패 backoff |
| `services/notifications.py` | APNs ES256 provider JWT와 HTTP/2 VoIP push, mock/disabled adapter |
| `services/storage.py` | 로컬/S3 저장, presigned upload/download, TTS write/cache/read/delete |
| `services/tts.py` | ElevenLabs 한국어 질문 합성·cache key·만료 URL과 질문별 iOS 폴백 |
Expand Down Expand Up @@ -553,6 +569,8 @@ Compose는 ignored `backend/private/`를 `/run/secrets/collog`에 read-only moun
| `HF_TOKEN` | Hugging Face 계정에서 HAI-DEF 약관 수락 후 발급 | HeAR detector artifact를 처음 받을 때만 필요 | gated model download. 앱/런타임 API key가 아니며 Git에 넣지 않음 |
| `LIVEKIT_API_KEY/SECRET` | 우리가 직접 강한 난수로 생성 | 필수 | self-hosted room token, server API, Egress, webhook 서명 |
| `JWT_SECRET` | 우리가 직접 강한 난수로 생성 | 필수 | 콜록 사용자 인증 JWT |
| `FCM_KEY_FILE`(compose) / `FCM_SERVICE_ACCOUNT_PATH`(직접 실행) | Firebase Console 프로젝트 설정의 서비스 계정 키(JSON) | Android 통화 수신 시 필수 | FCM 발송. 비밀값이며 Git에 넣지 않음 |
| `GOOGLE_CLIENT_ID` | Google Cloud Console의 Web OAuth 클라이언트 ID | Android Google 로그인 시 필수 | ID 토큰 `aud` 검증. 비밀값 아님 |
| APNs `.p8`/Key ID/Team ID/Bundle ID | Apple Developer 발급·확인 | 실기기 백그라운드 수신 시 필수 | PushKit VoIP push |
| MinIO access key/secret | 우리가 직접 생성 | Egress 녹음 시 필수 | self-hosted S3 호환 오디오 저장 |

Expand Down Expand Up @@ -916,3 +934,28 @@ API를 바꿀 때에는 기존 camelCase 계약과 테스트를 유지하고,
- 2026-08-20: 통합 직후 main이 lint 실패 상태였던 것을 고쳤다. `pipeline.py`의 로그 한 줄이
E501, `seed_narrative_history.py`가 async 안에서 `pathlib`을 써 ASYNC240이었다. 파일 읽기를
동기 함수로 분리했다. 브랜치를 머지할 때 `ruff`를 다시 돌려야 한다.
- 2026-10-24: Android 포팅을 위해 Google 로그인 엔드포인트, `google_subject` 컬럼과
`google_login_challenges` 테이블(migration 0011), 검증기·라우트 테스트 40개를 추가했다.
- 2026-10-24: Android 기기로 수신 통화·리포트 알림을 FCM HTTP v1으로 보내는 발송기와 플랫폼별 발송
분기, 테스트 12개를 추가했다.
- 2026-10-24: FCM 보안 리뷰 반영. 빈 키 경로를 미설정으로 처리(기동 실패 방지), 리포트 재시도 때
이미 받은 기기 재발송 방지, 수신 기기 사전 검사에 ANDROID 포함, 죽은 토큰 판정을 `message.token`
지목 시로 한정, 토큰 정리를 해당 기기 행으로 한정, token_uri 고정, 서비스 계정 JSON gitignore.
- 2026-10-25: 실기기(에뮬레이터) Google 로그인이 401이었다. 맥 시계가 Google보다 약 4초 느려 막 발급된
ID 토큰의 `iat`가 미래로 보였고, 검증이 시계 오차를 0초 허용했다. Google 검증에 60초 leeway를
두고 테스트를 추가했다.
`backend/.env`가 있으면 pytest도 그 값을 읽어 Apple·팀 포털 테스트가 실패한다. 로컬 실행용 값은
`backend/private/`에 두고 실행할 때만 불러온다.
- 2026-10-26: 에뮬레이터 자녀 → Android 휴대폰 부모 통화에서 양방향 음성까지 로컬 E2E로 확인했다
(Google 로그인, FCM 수신, 받기, 음성, 끊으면 양쪽 종료).
- 2026-10-26: compose에 FCM 키 bind가 없어, 운영 env에 호스트 경로를 넣으면 컨테이너가 키를 못 읽고
`AppContainer` 생성에서 기동 실패하던 문제를 고쳤다. `FCM_KEY_FILE` → `/run/secrets/fcm-service-account.json`,
비우면 `/dev/null`을 bind하고 Android 발송을 끈다(운영·로컬 compose 모두).
- 2026-10-26: 동의 문서를 Android에도 맞게 고치고 버전을 `2026-10-26.v5`로 올렸다. "아이폰 직접 요청"·
"아이폰 기본 음성"을 "휴대폰"으로, 로그인 토큰을 "Apple·Google"로 바꿨다. Android도 질문 음성을
ElevenLabs에 직접 요청하므로 IP 전달 문구가 Android 사용자에게도 사실이어야 한다. 버전이 바뀌므로
배포 후 기존 사용자는 다시 동의해야 녹음·AI 분석이 켜진다(통화는 그대로 된다).
- 2026-10-26: Apple 신원 토큰 검증에도 Google과 같은 60초 leeway를 두고 테스트를 추가했다.
- 2026-10-26: 알려진 취약점이 있던 의존성을 잠금 파일에서 올렸다. pyjwt 2.13.0→2.15.1,
aiohttp 3.14.3→3.14.5, urllib3 2.7.0→2.8.0, certifi→2026.10.23, multidict 6.7.1→6.9.1(yarl 1.25.1 따라옴).
올린 뒤 `pip-audit` 0건.
4 changes: 4 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ APPLE_CLIENT_ID=com.dohyeoplim.collog-ios
APPLE_TEAM_ID=
APPLE_KEY_ID=
APPLE_PRIVATE_KEY_PATH=
# Android Sign in with Google: Web OAuth client ID (ID token aud). Empty disables it.
GOOGLE_CLIENT_ID=
# Android call/report push (FCM HTTP v1): Firebase service account JSON path. Empty disables it.
FCM_SERVICE_ACCOUNT_PATH=
SOLAPI_API_KEY=
SOLAPI_API_SECRET=
SOLAPI_SENDER=
Expand Down
27 changes: 27 additions & 0 deletions backend/alembic/versions/0011_google_login.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import sqlalchemy as sa
from alembic import op

revision = "0011_google_login"
down_revision = "0010_processing_retries"
branch_labels = None
depends_on = None


def upgrade() -> None:
with op.batch_alter_table("users") as batch:
batch.add_column(sa.Column("google_subject", sa.String(255), nullable=True))
batch.create_index("ix_users_google_subject", ["google_subject"], unique=True)
op.create_table(
"google_login_challenges",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("nonce_hash", sa.String(64), unique=True, nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index(
"ix_google_login_challenges_expires_at", "google_login_challenges", ["expires_at"]
)


def downgrade() -> None:
raise RuntimeError("Restore a verified backup instead of a destructive downgrade")
16 changes: 16 additions & 0 deletions backend/alembic/versions/0012_profile_concerns.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import sqlalchemy as sa
from alembic import op

revision = "0012_profile_concerns"
down_revision = "0011_google_login"
branch_labels = None
depends_on = None


def upgrade() -> None:
with op.batch_alter_table("parent_profiles") as batch:
batch.add_column(sa.Column("concerns", sa.Text(), nullable=True))


def downgrade() -> None:
raise RuntimeError("Restore a verified backup instead of a destructive downgrade")
91 changes: 91 additions & 0 deletions backend/app/auth_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
AppleLoginChallenge,
Device,
Family,
GoogleLoginChallenge,
OtpChallenge,
RefreshSession,
User,
Expand All @@ -22,6 +23,8 @@
from app.schemas import (
AppleChallengeResponse,
AppleLoginRequest,
GoogleChallengeResponse,
GoogleLoginRequest,
OtpRequest,
OtpVerify,
RefreshRequest,
Expand All @@ -31,6 +34,7 @@
from app.security import SessionDep, issue_token, otp_hash
from app.services.apple_auth import AppleIdentityError, AppleServiceError
from app.services.domain import family_of
from app.services.google_auth import GoogleIdentityError, GoogleServiceError
from app.services.sms import SmsDeliveryError, send_otp

router = APIRouter(prefix="/auth", tags=["Auth"])
Expand Down Expand Up @@ -162,6 +166,93 @@ async def apple_login(
return await create_session(session, user, settings)


def require_google_login(settings: Settings) -> None:
if not settings.google_client_id:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE, "Google 로그인을 사용할 수 없습니다"
)


@router.post("/google/challenge", response_model=GoogleChallengeResponse)
async def google_challenge(request: Request, session: SessionDep) -> GoogleChallengeResponse:
settings: Settings = request.app.state.container.settings
require_google_login(settings)
now = datetime.now(UTC)
await session.execute(
delete(GoogleLoginChallenge).where(GoogleLoginChallenge.expires_at < now)
)
nonce = secrets.token_urlsafe(32)
challenge = GoogleLoginChallenge(
nonce_hash=hashlib.sha256(nonce.encode()).hexdigest(),
expires_at=now + timedelta(seconds=settings.apple_challenge_ttl_seconds),
)
session.add(challenge)
await session.commit()
return GoogleChallengeResponse(
challenge_id=challenge.id, nonce=nonce, expires_in=settings.apple_challenge_ttl_seconds
)


@router.post("/google", response_model=TokenResponse)
async def google_login(
payload: GoogleLoginRequest, request: Request, session: SessionDep
) -> TokenResponse:
settings: Settings = request.app.state.container.settings
require_google_login(settings)
challenge = await session.get(GoogleLoginChallenge, payload.challenge_id)
if challenge is None or challenge.consumed_at is not None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Google 로그인을 다시 시작해주세요")
if challenge.expires_at.replace(tzinfo=UTC) <= datetime.now(UTC):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Google 로그인 요청이 만료되었습니다")
try:
identity = await request.app.state.container.google_identity.verify(payload.id_token)
except GoogleIdentityError as exc:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, "Google 로그인 정보를 확인할 수 없습니다"
) from exc
except GoogleServiceError as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE, "Google 로그인 확인을 잠시 후 다시 시도해주세요"
) from exc
nonce_hash = hashlib.sha256(identity.nonce.encode()).hexdigest()
if not hmac.compare_digest(challenge.nonce_hash, nonce_hash):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Google 로그인 요청이 일치하지 않습니다")
consumed = await session.scalar(
update(GoogleLoginChallenge)
.where(
GoogleLoginChallenge.id == payload.challenge_id,
GoogleLoginChallenge.consumed_at.is_(None),
GoogleLoginChallenge.expires_at > datetime.now(UTC),
)
.values(consumed_at=datetime.now(UTC))
.execution_options(synchronize_session=False)
.returning(GoogleLoginChallenge.id)
)
if consumed is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "이미 사용한 Google 로그인 요청입니다")
user = await session.scalar(select(User).where(User.google_subject == identity.subject))
if user is None:
try:
async with session.begin_nested():
user = User(
google_subject=identity.subject,
phone=None,
role=payload.role,
name=(payload.name or "").strip() or "사용자",
)
session.add(user)
await session.flush()
except IntegrityError:
user = await session.scalar(
select(User).where(User.google_subject == identity.subject)
)
if user is None:
raise HTTPException(
status.HTTP_409_CONFLICT, "Google 로그인을 다시 시도해주세요"
) from None
return await create_session(session, user, settings)


@router.post("/otp/request", status_code=202)
async def request_otp(
payload: OtpRequest, request: Request, session: SessionDep
Expand Down
18 changes: 16 additions & 2 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from pathlib import Path
from typing import Literal

from pydantic import Field
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


Expand Down Expand Up @@ -37,6 +37,10 @@ class Settings(BaseSettings):
apple_team_id: str = ""
apple_key_id: str = ""
apple_private_key_path: Path | None = None
# Android Sign in with Google. ID 토큰의 aud가 되는 Web OAuth 클라이언트 ID이며
# 비워두면 Google 로그인 엔드포인트는 503을 반환한다. nonce 유효 시간은
# apple_challenge_ttl_seconds를 같이 쓴다.
google_client_id: str = ""

schema_auto_reset: bool = False

Expand All @@ -51,6 +55,9 @@ class Settings(BaseSettings):
apns_key_id: str = ""
apns_bundle_id: str = ""
apns_private_key_path: Path | None = None
# Android 통화·리포트 알림(FCM HTTP v1). Firebase 서비스 계정 키 JSON 경로이며,
# 비워두면 Android 기기로는 보내지 않는다.
fcm_service_account_path: Path | None = None
incoming_call_ttl_seconds: int = 45

# Track Egress worker가 없는 개발 환경에서 기기의 분석용 PCM만으로 파이프라인을 돌린다.
Expand Down Expand Up @@ -89,7 +96,7 @@ class Settings(BaseSettings):
s3_use_instance_role: bool = False
s3_force_path_style: bool = True

consent_document_version: str = "2026-09-13.v4"
consent_document_version: str = "2026-10-26.v5"
parent_min_speech_seconds: int = 20
raw_audio_wait_seconds: int = 30
egress_wait_seconds: int = Field(default=120, ge=1)
Expand Down Expand Up @@ -143,6 +150,13 @@ class Settings(BaseSettings):
team_portal_enabled: bool = True
team_portal_repository_url: str = "https://github.com/Collog-App/14th-hackathon"

@field_validator("fcm_service_account_path", mode="before")
@classmethod
def empty_fcm_path_is_unset(cls, value: object) -> object:
# compose의 `${FCM_KEY_FILE:+...}`는 키 파일이 없으면 빈 문자열을 넘기고,
# pydantic은 그걸 Path(".")로 바꾼다. 빈 값은 "설정 안 됨"으로 본다.
return None if isinstance(value, str) and not value.strip() else value

@property
def livekit_http_url(self) -> str:
value = self.livekit_internal_url or self.livekit_url
Expand Down
2 changes: 1 addition & 1 deletion backend/app/consent.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
CONSENT_VERSION = "2026-09-13.v4"
CONSENT_VERSION = "2026-10-26.v5"
CONSENT_ITEMS = [
"SENSITIVE_HEALTH_COLLECTION",
"VOICE_FEATURE_EXTRACTION",
Expand Down
8 changes: 7 additions & 1 deletion backend/app/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
from app.services.apple_auth import AppleIdentityVerifier
from app.services.calls import CallLifecycle
from app.services.deepgram import create_stt_gateway
from app.services.fcm import create_android_push_gateways
from app.services.gemini import create_extraction_gateway
from app.services.google_auth import GoogleIdentityVerifier
from app.services.livekit import create_livekit_gateway
from app.services.notifications import create_report_push_gateway, create_voip_push_gateway
from app.services.pipeline import ProcessingPipeline
Expand All @@ -21,14 +23,18 @@ class AppContainer:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.apple_identity = AppleIdentityVerifier(settings)
self.google_identity = GoogleIdentityVerifier(settings)
self.database = Database(settings.database_url)
self.storage = create_storage(settings)
self.question_tts = create_question_tts_gateway(settings, self.storage)
self.livekit = create_livekit_gateway(settings)
self.calls = CallLifecycle(settings, self.database, self.livekit, self.storage)
self.voip_push = create_voip_push_gateway(settings)
self.report_push = create_report_push_gateway(settings)
self.report_notifications = ReportNotifications(self.database, self.report_push)
self.android_voip_push, self.android_report_push = create_android_push_gateways(settings)
self.report_notifications = ReportNotifications(
self.database, self.report_push, self.android_report_push
)
self.stt = create_stt_gateway(settings)
self.extraction = create_extraction_gateway(settings)
self.acoustics = create_acoustic_analyzer(settings)
Expand Down
2 changes: 2 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await task
await container.voip_push.close()
await container.report_push.close()
await container.android_voip_push.close()
await container.apple_identity.close()
await container.google_identity.close()
await container.database.close()

app = FastAPI(
Expand Down
Loading
Loading