From 94b388ea56a15f0fc8c26978bd321cc1b28aee28 Mon Sep 17 00:00:00 2001 From: SD-gif Date: Tue, 25 Aug 2026 14:45:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(schedule):=20=EC=9D=BC=EC=A0=95=EC=97=90?= =?UTF-8?q?=20=EC=86=8C=EC=9C=A0=EC=9E=90=EB=A5=BC=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spring 이 검증한 액세스 토큰에서 꺼낸 사용자 ID 를 X-Auth-User-Id 헤더로 보낸다. 이를 schedules.user_id 에 저장한다. 없으면 내 일정 목록, 수정 권한, 공유가 성립하지 않는다. 헤더는 Spring 과의 내부 호출에만 쓴다. 외부에서 들어오는 값이 아니라 검증된 토큰에서 꺼낸 것이고, 두 컨테이너는 같은 도커 네트워크에 있으며 이 서버는 외부에 공개되지 않는다. 헤더가 없으면 소유자 없는 일정으로 저장한다. Spring 이 아직 인가를 켜지 않아 비로그인 생성이 가능하다. 소유자는 한 번 정해지면 바꾸지 않는다. save_schedule 은 ON CONFLICT DO UPDATE 라 일정 수정에서도 실행되는데, 수정 요청에는 소유자가 실리지 않는다. 그대로 덮으면 첫 수정에서 주인이 사라진다. COALESCE 로 기존 값을 유지한다. 컬럼은 Spring 의 V10 migration 이 만든다. 그쪽을 먼저 배포해야 한다. 순서가 바뀌면 없는 컬럼에 INSERT 해서 일정 생성이 전부 실패한다. 로컬에서 Spring 과 함께 띄워 확인했다. 로그인 상태로 만든 일정에 user_id 가 저장되고, 소유자 헤더가 없는 수정 요청 뒤에도 값이 유지되며, 비로그인 생성은 NULL 로 남는다. Co-Authored-By: Claude Opus 5 --- app.py | 10 +++++++++- schedule/persistence.py | 20 +++++++++++++++++--- schedule/service.py | 3 ++- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/app.py b/app.py index cd5047f..e3b98d6 100644 --- a/app.py +++ b/app.py @@ -487,7 +487,14 @@ def get_schedule_preview_endpoint(preview_id: UUID) -> SchedulePreviewResponse: def create_schedule_endpoint( payload: ScheduleCreateRequest | SchedulePreviewScheduleRequest, idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + auth_user_id: int | None = Header(default=None, alias="X-Auth-User-Id"), ) -> ScheduleResponse: + """일정을 만든다. + + X-Auth-User-Id 는 Spring 이 검증한 액세스 토큰에서 꺼낸 소유자다. 외부에서 직접 + 들어오는 값이 아니라 같은 도커 네트워크 안의 Spring 만 보낸다. 헤더가 없으면 + 소유자 없는 일정으로 저장한다. 인가를 아직 켜지 않아 비로그인 생성이 가능하다. + """ started_at = monotonic() if idempotency_key: if not isinstance(payload, SchedulePreviewScheduleRequest): @@ -502,6 +509,7 @@ def create_schedule_endpoint( create_request, preview_id=preview.preview_id, fixed_events_by_day=fixed_events_by_day, + owner_id=auth_user_id, ) attach_schedule_to_preview(preview.preview_id, schedule.id) log.info( @@ -519,7 +527,7 @@ def create_schedule_endpoint( payload.start_date, payload.end_date, ) - schedule = create_schedule(payload) + schedule = create_schedule(payload, owner_id=auth_user_id) log.info( "direct schedule created. scheduleId=%s, elapsedMs=%d", schedule.id, diff --git a/schedule/persistence.py b/schedule/persistence.py index a1f4997..ddc2601 100644 --- a/schedule/persistence.py +++ b/schedule/persistence.py @@ -178,7 +178,17 @@ def mark_preview_consumed(preview_id: UUID) -> None: conn.commit() -def save_schedule(schedule: ScheduleResponse, condition_request: ScheduleCreateRequest) -> None: +def save_schedule( + schedule: ScheduleResponse, + condition_request: ScheduleCreateRequest, + owner_id: int | None = None, +) -> None: + """일정을 저장한다. + + owner_id 는 Spring 이 검증한 토큰에서 꺼내 X-Auth-User-Id 헤더로 전달한 값이다. + 한 번 정해진 소유자는 바꾸지 않는다. 수정 요청은 소유자를 싣지 않으므로, + 덮어쓰면 첫 수정에서 주인이 사라진다. + """ with connect() as conn: with conn.cursor() as cur: place_ids = sorted({stop.place.id for day in schedule.days for stop in day.stops if stop.place.id is not None}) @@ -196,12 +206,12 @@ def save_schedule(schedule: ScheduleResponse, condition_request: ScheduleCreateR id, status, start_date, end_date, daily_start_time, daily_end_time, start_place_name, start_longitude, start_latitude, end_place_name, end_longitude, end_latitude, preview_id, time_zone, lodging_mode, route_coverage, planning_warnings_json, - style_summary, condition_json, created_at, updated_at + style_summary, condition_json, user_id, created_at, updated_at ) VALUES ( %(id)s, %(status)s, %(start_date)s, %(end_date)s, %(daily_start_time)s, %(daily_end_time)s, %(start_place_name)s, %(start_longitude)s, %(start_latitude)s, %(end_place_name)s, %(end_longitude)s, %(end_latitude)s, %(preview_id)s, %(time_zone)s, %(lodging_mode)s, %(route_coverage)s, %(planning_warnings_json)s, - %(style_summary)s, %(condition_json)s, COALESCE((SELECT created_at FROM schedules WHERE id = %(id)s), %(now)s), %(now)s + %(style_summary)s, %(condition_json)s, %(user_id)s, COALESCE((SELECT created_at FROM schedules WHERE id = %(id)s), %(now)s), %(now)s ) ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, @@ -222,6 +232,9 @@ def save_schedule(schedule: ScheduleResponse, condition_request: ScheduleCreateR planning_warnings_json = EXCLUDED.planning_warnings_json, style_summary = EXCLUDED.style_summary, condition_json = EXCLUDED.condition_json, + -- 이미 주인이 있으면 유지한다. 수정 요청은 소유자를 싣지 않아 + -- 그대로 덮으면 첫 수정에서 NULL 이 된다. + user_id = COALESCE(schedules.user_id, EXCLUDED.user_id), updated_at = EXCLUDED.updated_at """, { @@ -237,6 +250,7 @@ def save_schedule(schedule: ScheduleResponse, condition_request: ScheduleCreateR "end_place_name": schedule.days[-1].end_location.name if schedule.days and schedule.days[-1].end_location else None, "end_longitude": schedule.days[-1].end_location.longitude if schedule.days and schedule.days[-1].end_location else None, "end_latitude": schedule.days[-1].end_location.latitude if schedule.days and schedule.days[-1].end_location else None, + "user_id": owner_id, "preview_id": schedule.preview_id, "time_zone": schedule.planning_assumptions.time_zone if schedule.planning_assumptions else "Asia/Seoul", "lodging_mode": schedule.planning_assumptions.lodging_mode if schedule.planning_assumptions else "UNSPECIFIED", diff --git a/schedule/service.py b/schedule/service.py index be5ed88..6d7c9ce 100644 --- a/schedule/service.py +++ b/schedule/service.py @@ -396,6 +396,7 @@ def create_schedule( request: ScheduleCreateRequest, preview_id: UUID | None = None, fixed_events_by_day: dict[int, list[FixedEventSpec]] | None = None, + owner_id: int | None = None, ) -> ScheduleResponse: log.info( "schedule build started. previewId=%s, startDate=%s, endDate=%s, candidateSource=%s", @@ -465,7 +466,7 @@ def create_schedule( saved = STORE.save(schedule) if db_enabled(): try: - save_schedule_to_db(saved, condition_request=request) + save_schedule_to_db(saved, condition_request=request, owner_id=owner_id) except Exception: log.exception("schedule persistence failed. scheduleId=%s", saved.id) log.info(