[BE 테크스펙] 택시팟 매칭 도메인 - 100-hours-a-week/KTB4-3rd-wiki GitHub Wiki

배경 (Background)

  • 프로젝트 목표 (Objective): 출발지 · 도착지 · 출발 시각이 같은 사용자를 서버가 자동으로 묶어 택시비를 나눠 낼 수 있게 한다. 사용자는 조건만 입력하고, 팟을 찾고 만드는 일은 서버가 한다.

    • 핵심 결과 (Key Result) 1: 매칭 시작 요청 중 2명 이상이 모여 운행 시작까지 도달한 비율 측정
    • 핵심 결과 (Key Result) 2: 운행 시작 → 정산 완료 전환율 90% 이상
  • 문제 정의 (Problem):

    • 택시를 모집할 때 자신의 니즈에 맞는 다른 사용자를 찾는 과정은 매우 번거롭고 어려운 일입니다.
    • 모르는 사람과의 택시 동행이 이루어진다고 하더라도, 사람들의 신원을 알지 못하기 때문에 선결제·정산에 대한 두려움이 앞섭니다.
  • 가설 (Hypothesis): 출발지·목적지·출발 시간이 같은, 신원이 확보된 사용자들을 매칭시켜주고, 그 과정에서 택시비 기록, 송금 인증을 서버에서 관여한다면 사용자들은 모르는 사람들과의 택시팟에 참여할 것이고, 지속적인 참여를 위해 부정한 행동을 하지 않을 것이다.

  • 관련 자료:


목표가 아닌 것 (Non-goals) (Optional)

이번 프로젝트에서 다루지 않는 내용:

  • 채팅 메시지 조회 · 전송 · 읽음: 채팅 도메인 소관입니다. 택시팟은 시스템 메시지를 만들기만 하고, 메시지 타입 정의와 전송 계약은 채팅 문서를 따릅니다.
  • 장소 검색 · 위치 미세 조정: 프론트가 지도 SDK를 직접 호출합니다. API가 없습니다.
  • 매칭 조건 고도화 (허용 반경 · 시간창): 초기 구현은 출발지 · 도착지 · 출발 시각의 완전 일치입니다.
  • 동승자 위치 기반 도착 감지: 방장의 위치만 받고, 다른 참여자들의 위치 정보는 활용하지 않습니다.
  • 정원이 찼을 때 출발 시각 전 운행 시작 허용 여부: 현재는 출발 시간이 도달한 이후에만 운행 시작 트리거를 제공하고, 추후 사용자의 불편의 원인이 되는 시점에 수정합니다.

설계 및 기술 자료 (Architecture and Technical Documentation)

상태 전이

택시팟의 상태는 두 축입니다. 하나로 읽으면 화면이 맞지 않습니다.

① companions.status — 동행 1건의 생명주기

   POST /taxi-pots
        │
        ▼
   RECRUITING ◄──── 정원 미달 복귀 (참여자 나가기)
        │  │
        │  └──────────────► CANCELED
        │       · 마지막 1명 나가기
        │       · 출발 시각 +12h 배치
        │
        │ PATCH {status:'IN_PROGRESS'}  · 방장 · 2명 이상 · 출발 시각 이후
        ▼
   IN_PROGRESS
        │ PATCH {status:'COMPLETED'}    · 방장
        ▼
   COMPLETED ──► 정산 도메인

② companion_participants.outcome_status — 참여 1건의 결말

   PENDING ──► INCOMPLETE   (나가기 · 자동 취소)
        └────► COMPLETED    (정산 완료)
  생성 ─────────────── departure_at ─────────────── +12h
   │                        │                         │
   │  모집만 가능            │  운행 시작 가능          │  배치 자동취소
   │  (시작 불가)            │  (2명 이상일 때)         │  → CANCELED

화면 ↔︎ 저장 상태

화면 판정식
TAXI-006 매칭 대기 status='RECRUITING' AND current_count < capacity
TAXI-007 매칭 완료 status='RECRUITING' AND current_count = capacity
RIDE-001 운행 시작 확인 카드 status='RECRUITING' AND current_count >= 2 AND departure_at <= NOW() + 방장만
운행 중 status='IN_PROGRESS'
PAY-001 운행 종료 확인 카드 서버가 도착 감지로 시스템 메시지 생성. 버튼은 방장만 활성
PAY-001 COMPLETED_PENDING_METER status='COMPLETED' AND settlements 행 없음
그 이후 settlements.status
  • 모집 마감은 저장 상태가 아닙니다. current_count = capacity로 계산하므로 한 명이 나가면 다시 모집 중인 상태로 돌아갑니다.

데이터베이스 스키마 (ERD)

  • 택시팟 · 카풀 · 동행모집은 만드는 방법만 다르고 결과물이 같으므로 companions 한 테이블을 씁니다. 택시팟은 kind = 'TAXI_POT'입니다.

    https://www.erdcloud.com/d/bFLCWE9QxAGPRXRBx

  • 주요 테이블:

    • companions: 동행 1건 (종류, 출발지 · 도착지 좌표, 출발 시각, 정원, 현재 인원, 상태, 방장)
    • companion_participants: 참여자 (동행 ID, 사용자 ID, 완주 여부, 참여 · 종료 시각)
    • chat_rooms: 동행 1건에 대한 채팅방
    • settlements: 동행 1건에 대한 정산

API 명세 (API Specifications)

  • 목차:

    • 진행 중인 택시팟 조회 API
    • 택시팟 매칭 시작 API
    • 택시팟 상세 조회 API
    • 운행 상태 전이 API
    • 매칭 나가기 API
    • 자동 취소 (배치 - 엔드포인트 아님)
    • 도착 자동 감지 (WebSocket - 엔드포인트 아님)
    • 정산 시작 API
    • 정산 상태 조회 API
    • 미터기 사진 교체 API
    • 정산 진행 API
    • 송금 인증 제출 API
    • 송금 인증 사진 조회 API
    • 정산 타임아웃 (배치 - 엔드포인트 아님)
  • 공통:

    • 택시팟 매칭은 회원에게만 제공하는 서비스
    • 전부 Authorization: Bearer {access_token} 필수

진행 중인 택시팟 조회 API

클래스 메서드
TaxiPotController getMyCurrent()
TaxiPotService findMyCurrent(Long userId)
CompanionParticipantRepository findCurrentTaxiPot(Long userId)
  • 처리 로직:
    1. companion_participants에서 user_id = me AND outcome_status = 'PENDING'인 행을 companions와 조인해 kind = 'TAXI_POT'인 것 1건 조회
      1. idx_user_outcome으로 사용자의 PENDING 행만 좁힌 뒤 kind 확인
    2. 있으면 200 + data: { id, chat_room_id, status, current_count, capacity }
    3. 없으면 200 + data: null - 클라이언트는 null이면 모달 없이 진행, 값이 있으면 매칭 제한 모달 표시
  • 용도: TAXI-001 매칭 제한 모달 분기. 한 사용자는 동시에 하나의 택시팟에만 참여 가능
  • 정산 중(status='COMPLETED')인 팟도 PENDING 참여자가 있으므로 반환됨. 정산 완료 때까지 새 매칭을 막는 것은 의도된 동작

택시팟 매칭 시작 API

클래스 메서드
TaxiPotController start(TaxiPotStartRequest request)
TaxiPotService start(Long userId, TaxiPotStartCommand command)
BankAccountQueryPort hasBankAccount(Long userId)
CompanionParticipantRepository existsPendingTaxiPot(Long userId) (FOR UPDATE)
CompanionRepository findMatchable(...) · tryJoin(Long id) · save(Companion c)
ChatRoomRepository save(ChatRoom room)
SystemMessagePort enter(chatRoomId, userId)
  • 요청 (Request Body): origin_name · origin_lat · origin_lng · dest_name · dest_lat · dest_lng · departure_at 전부 필수
  • 요청 DTO: TaxiPotStartRequest(record)
필드 애노테이션 reason
origin_name dest_name @NotBlank · @Size(max=100) REQUIRED · MAX_LENGTH
origin_lat dest_lat @NotNull · @DecimalMin(-90) · @DecimalMax(90) · @Digits(3,6) REQUIRED · OUT_OF_RANGE
origin_lng dest_lng @NotNull · @DecimalMin(-180) · @DecimalMax(180) · @Digits(3,6) REQUIRED · OUT_OF_RANGE
departure_at @NotNull · RFC 3339 REQUIRED · INVALID_FORMAT
  • capacitytransport_type은 받지 않습니다. 택시팟은 4인 · TAXI 고정입니다.
  • 처리 로직:
    1. 형식 검증 → 실패 시 422
    2. 비즈니스 검증 → 422
      1. departure_at이 현재 이전 → DEPARTURE_TIME_PASSED
      2. departure_at이 현재 + 3시간 초과 → DEPARTURE_TIME_TOO_FAR
      3. 출발지 = 도착지 → SAME_ORIGIN_DEST
      4. 정산 계좌 미등록 → BANK_ACCOUNT_REQUIRED
    3. 중복 참여 확인 - PENDING인 택시팟 참여가 있으면 409 MATCH_ALREADY_IN_PROGRESS
      1. FOR UPDATE로 잠가 검사와 생성 사이에 다른 요청이 끼어들지 못하게 함
    4. 매칭 - 조건이 맞는 RECRUITING 팟이 있으면 합류, 없으면 새로 만듦
      1. 조건: kind='TAXI_POT' AND status='RECRUITING' AND current_count < capacity AND 출발지 좌표 · 도착지 좌표 · departure_at 완전 일치
      2. 합류: UPDATE companions SET current_count = current_count + 1 WHERE id = ? AND current_count < capacity AND status = 'RECRUITING' → 합류할 매칭이 없으면 다음 후보 또는 신규 생성
      3. 신규: companions(creator_id = host_id = me, capacity = 4, current_count = 1) + chat_rooms + companion_participants 생성
    5. 입장 시스템 메시지 생성
    6. 응답 - 201 Created + Location: /taxi-pots/{id} + data: { id, chat_room_id, status, current_count, capacity }
  • 합류든 신규든 201입니다 - 어느 경우든 companion_participants 행이 생기고 Location으로 접근 가능한 URI를 알려줍니다.
  • 409를 받으면 클라이언트는 진행 중인 택시팟 조회 API로 id를 얻어 TAXI-006으로 이동합니다.

택시팟 상세 조회 API

클래스 메서드
TaxiPotController get(Long taxiPotId)
TaxiPotService find(Long userId, Long taxiPotId)
CompanionRepository findTaxiPotForParticipant(Long id, Long userId)
  • 요청 (Path): taxi_pot_id
  • 처리 로직:
    1. companionscompanion_participants와 조인해 id = ? AND kind = 'TAXI_POT' AND 참여자 = me 조회
    2. 없으면 404 NOT_FOUND
    3. 응답 - 200 + data: { id, chat_room_id, status, origin_name, dest_name, departure_at, current_count, capacity, host_id }

운행 상태 변경 API

클래스 메서드
TaxiPotController transition(Long taxiPotId, TaxiPotTransitionRequest request)
TaxiPotService transition(Long userId, Long taxiPotId, CompanionStatus target)
CompanionRepository findTaxiPotForParticipant · startRide(...) · completeRide(...) (조건부 UPDATE)
SystemMessagePort rideStarted(chatRoomId) · meterRequested(chatRoomId)
  • 요청 (Path): companion_id
  • 요청 (Request Body): status - IN_PROGRESS 또는 COMPLETED
  • 전이 조건:
전이 화면 조건
RECRUITING → IN_PROGRESS RIDE-001 운행 시작 방장 · current_count >= 2 · departure_at <= NOW()
IN_PROGRESS → COMPLETED PAY-001 운행 종료 확인 방장
  • 처리 로직:
    1. 형식 검증 - status가 허용 값이 아니면 400
    2. 참여 확인
      • 비참여자 · 없는 id → 404
      • 참여자인데 방장이 아님 → 403 HOST_ONLY
    3. 조건부 UPDATE - 한 문장으로 전이와 중복 방지를 함께 처리
UPDATE companions SET status = 'IN_PROGRESS'
 WHERE id = ? AND kind = 'TAXI_POT' AND status = 'RECRUITING' AND host_id = ?
   AND current_count >= 2 AND departure_at <= NOW(6);
   1. 영향받은 행이 0이면 사유를 `SELECT`로 판별 → `409 INVALID_STATE_TRANSITION` / `409 NOT_ENOUGH_PARTICIPANTS` / `409 DEPARTURE_NOT_REACHED`
4. 시스템 메시지 생성 (같은 트랜잭션)
   - `→ IN_PROGRESS`는 `SYSTEM_RIDE_STARTED`
   - `→ COMPLETED`는 `SYSTEM_METER_REQUESTED`
5. 응답 - `200 + data: { id, chat_room_id, status, origin_name, dest_name, departure_at, current_count, capacity, host_id }`
  • 정원 충족은 조건이 아닙니다. 택시는 4명이 아니어도 운행합니다. 혼자만 아니면 되고, 출발 시각이 지났으면 됩니다.
  • NOT_ENOUGH_PARTICIPANTSDEPARTURE_NOT_REACHED를 나눈 이유 - 혼자일 땐 기다리는 것 말고 할 게 없지만, 시간 전이면 기다리면 버튼이 켜집니다.
  • 상태 전이가 한 번만 성공하므로 시스템 메시지도 한 번만 생깁니다.
  • COMPLETED 전이는 정산 행을 만들지 않습니다. 정산은 미터기 사진 전송 시점에 생성됩니다.

매칭 나가기 API

클래스 메서드
TaxiPotController leave(Long taxiPotId)
TaxiPotService leave(Long userId, Long taxiPotId)
CompanionRepository findTaxiPotForParticipant · decrementCount · changeHost · cancel
CompanionParticipantRepository markLeft(...) · findEarliestPending(companionId)
SettlementQueryPort existsUnsettled(companionId)
ChatRoomRepository close(chatRoomId)
SystemMessagePort leave · hostChanged · canceled
  • 요청 (Path): companion_id
  • 처리 로직:
    1. 참여 확인 - 없으면 404 NOT_FOUND
    2. 차단 조건 → 409
      1. status = 'IN_PROGRESS'RIDE_IN_PROGRESS
      2. status = 'COMPLETED'이고 정산 미완료 → SETTLEMENT_IN_PROGRESS
    3. 참여 종료 - outcome_status = 'INCOMPLETE', left_at = NOW(6)
      1. 이미 COMPLETED인 행은 덮어쓰지 않음. 정산 완료 후 나가는 사람은 완주
    4. 택시팟 현재 인원 - 1 → current_count - 1
    5. 방장이 나갔으면 남은 PENDING 참여자 중 joined_at이 가장 이른 사람에게 승계 + SYSTEM_HOST_CHANGED
    6. 마지막 1명이 나갔으면 status = 'CANCELED' + chat_rooms.closed_at
    7. SYSTEM_LEAVE 메시지
    8. 응답 - 204 No Content
  • 정원이 찬 뒤 나가면 다시 모집 가능한 상태가 됩니다. 모집 마감을 상태로 저장하지 않기 때문입니다.

자동 취소 (배치)

  • 트리거: 스케줄러 (주기 10분, TaxiPotAutoCancelScheduler)

  • 대상: kind = 'TAXI_POT' AND status = 'RECRUITING' AND departure_at < NOW(6) - INTERVAL 12 HOUR

  • 처리 로직 (팟 1건당 하나의 트랜잭션):

    1. companions.status = 'CANCELED', current_count = 0
    2. companion_participantsPENDING 행 전부 INCOMPLETE, left_at = NOW(6)
    3. chat_rooms.closed_at = NOW(6)
    4. SYSTEM_CANCELED 메시지
  • 엔드포인트가 아니지만 명세에 적는 이유 — 클라이언트가 관측하는 변화입니다. 진행 중인 택시팟 조회가 data: null로 바뀌고, 채팅 목록에서 방이 사라집니다.

  • RECRUITING만 취소합니다. IN_PROGRESS · COMPLETED는 정산이 걸려 있어 건드리지 않습니다.

  • 출발 시각 기준인 이유 — "이미 지나간 약속"이라는 사실이 기준이 되어야 합니다. 생성 시각 기준이면 3시간 뒤 출발과 10분 뒤 출발이 같은 수명을 갖습니다.

  • TAXI-006 [4] "자동 취소는 없음"과 어긋납니다. 그 문구는 모집 중 서비스가 임의로 매칭을 끊지 않는다는 뜻으로 읽었고, 화면 문서 정정 대상입니다.


도착 자동 감지

운행 종료 확인 카드는 두 경로로 생성되며, 먼저 도착한 쪽이 만듭니다. 이미 카드가 있으면 만들지 않습니다.

경로 트리거 역할
① 위치 감지 방장이 보낸 좌표가 도착지 반경 500m 안에서 10초 유지 더 일찍 띄우는 최적화
② ETA 도달 배치가 NOW() >= eta_at인 팟을 찾음 기본. 위치 정보가 안 와도 종료 확인 카드가 뜨게 하는 트리거

① 위치 감지 (WebSocket)

  • 채널: 채팅 WebSocket에 메시지 타입 1개 추가. 새 REST 엔드포인트 없음
  • 발신 조건: companions.status = 'IN_PROGRESS'이고 발신자가 host_id일 때만. 10초 주기
  • 클래스 배치:
클래스 메서드
LocationMessageHandler onHostLocation(chatRoomId, userId, lat, lng) (채팅 WS 핸들러)
ArrivalDetector check(companionId, lat, lng)
SystemMessagePort arrivalConfirmRequested(chatRoomId)
  • 처리 로직:

    1. 발신자가 방장이고 팟이 IN_PROGRESS인지 확인. 아니면 무시
    2. ST_Distance_Sphere(dest_location, 클라이언트로부터 수신한 현재 좌표) <= 500 판정
    3. 500m 이내가 10초 연속 유지되면 카드 생성
    4. 좌표는 저장하지 않음. 판정 후 버림
  • 방장만 보내는 이유 - 결제자라 도착 시점에 화면을 보고 있을 가능성이 높고, 발신자가 1명이면 관리 용이

② ETA 폴백

  • eta_at 채우는 시점: → IN_PROGRESS 상태 변경 커밋 후 비동기. 운행당 1회

    POST https://apis-navi.kakaomobility.com/v1/directions Authorization: KakaoAK {REST_API_KEY}

    • 성공 → eta_at = NOW(6) + duration
    • 실패 → eta_at = NOW(6) + 60분 (상한 폴백)
    1. 운행 상태 변경 트랜잭션에서는 eta_at을 채우지 않습니다.
    2. 호출이 실패해도 상한 폴백으로 값을 채웁니다.
  • 클래스 배치:

클래스 메서드
EtaRefresher refresh(Long companionId) - afterCommit에서 비동기 호출. 실패 시 상한값 기록
KakaoNaviClient estimateDuration(origin, dest)
TaxiPotArrivalScheduler sweep() - 자동 취소 배치와 같은 스케줄러 (10분 주기)
  • 배치 조건:
SELECT id FROM companions
 WHERE kind = 'TAXI_POT' AND status = 'IN_PROGRESS' AND NOW(6) >= eta_at;
  • 길찾기 호출을 트랜잭션 밖에 두는 이유 - 카카오모빌리티 장애가 운행 시작을 롤백시키면 안 됩니다. 유저 도메인에서 카카오 unlink를 트랜잭션 밖으로 뺀 것과 같은 판단입니다.
  • 재시도 큐를 두지 않습니다. 실패하면 상한값이 들어가고, 그 전에 방장이 도착하면 위치 감지가 먼저 카드를 띄웁니다.
  • 값이 부정확해도 되는 이유 - eta_at은 "카드를 언제 띄울까"의 기준일 뿐이고 확정은 사람이 합니다. 일찍 뜨면 방장이 무시하면 되고 카드는 남아 있습니다. 늦게 뜨는 쪽이 나쁜데 그건 위치 감지가 커버합니다.
  • 카카오 모빌리티의 길찾기 REST API를 호출하여, 추정 시간을 가져옵니다.

카드 생성 공통 규칙

  1. SystemMessagePort.existsArrivalConfirm(chatRoomId)로 중복 확인 → 있으면 생성하지 않음
  2. "운행이 종료 됐나요?" 시스템 메시지 생성 - 전원에게 보이고 버튼은 방장만 활성 (PAY-001 [3])
  3. 상태 전이는 사람이 합니다. 카드는 띄울 뿐이고 → COMPLETED는 방장이 버튼을 눌러 운행 상태 전이 API를 호출해야 일어납니다.
  • 방장이 앱을 백그라운드로 두면 ①이 동작하지 않습니다. 웹의 geolocation 한계이며 ②가 그 구멍을 메웁니다.
  • 카드가 떠도 방장이 누르지 않으면 IN_PROGRESS로 남습니다. 자동 취소 배치는 RECRUITING만 대상입니다. "사람이 안 누르면 진행이 멈춘다"는 정산 종료 보장과 같은 문제이며 정산 도메인에서 함께 다룹니다.
  • 약관에 위치정보 항목을 추가해야 합니다. 서버가 개인위치정보를 처리하므로 이용약관 명시 · 동의가 필요합니다.

정산

운행이 끝난 뒤 방장이 선결제한 택시비를 동승자에게 청구하고, 송금을 인증받아 마무리하는 과정입니다. companions.status = 'COMPLETED'가 된 뒤에만 시작됩니다.

AI가 두 번 개입합니다. 성격이 달라 저장 위치도 다릅니다.

대상 저장 위치 실패 시
① 미터기 OCR 방장이 올린 미터기 사진에서 총액 추출 settlements.meter_analysis_status 방장이 수동 입력
② 송금 캡처 OCR 동승자가 올린 캡처의 금액 · 수취인 · 위조 대조 settlement_participants.analysis_status · is_forged 동승자가 재제출

미터기 사진 업로드 API

클래스 메서드
SettlementController start(SettlementStartRequest request)
SettlementService start(Long userId, Long companionId, String meterImageUrl)
CompanionRepository findTaxiPotForParticipant
UserRepository findById
SettlementRepository save(Settlement s)
MeterAnalysisPort requestAnalysis(settlementId, meterImageUrl)
  • 요청 (Request Body): companion_id(필수), meter_image_url(필수)
  • 처리 로직:
    1. 형식 검증 → 실패 시 422
    2. 권한 · 상태 확인
      1. 비참여자 · 없는 id → 404, 방장 아님 → 403 HOST_ONLY
      2. companions.status <> 'COMPLETED'409 RIDE_NOT_COMPLETED
      3. 이미 정산 행이 있으면 → 409 SETTLEMENT_ALREADY_STARTED
    3. 정산 행 생성 (하나의 트랜잭션)
      1. payee_id = host_id, payee_bank_name · payee_account_nousers에서 복사 (스냅샷)
      2. participant_count = companions.current_count (스냅샷)
      3. status='PENDING' · meter_analysis_status='ANALYZING' · analysis_started_at=NOW(6) · total_amount=NULL
    4. 커밋 후 AI 분석 요청 - MeterAnalysisPort.requestAnalysis(...)
    5. 응답 - 202 Accepted + Location: /settlements/{id} + data: { id, meter_analysis_status, analysis_started_at }
  • 성공 응답이 202인 이유 - 총액은 아직 없습니다. 접수만 하고 완료는 정산 상태 조회로 확인하는 비동기 패턴
  • 분석 상태를 담을 행이 분석 시작 시점에 필요하기 때문에 총액이 없는 상태로 행을 만듭니다. chk_amount_required가 "금액 없이 청구 불가"를 스키마로 막고 있어 PENDING 단계에서만 NULL이 허용됩니다.
  • participant_count를 이 시점에 찍어도 안전한 이유 - companions.status='COMPLETED'이면 매칭 나가기가 409 SETTLEMENT_IN_PROGRESS로 막혀 인원이 변하지 않습니다.
  • AI 호출을 커밋 밖에 두는 이유 - AI 서버 장애가 정산 시작을 롤백시키면 안 됩니다. 실패하면 meter_analysis_status='FAILED'로 갱신되고 화면이 수동 입력을 유도합니다.

미터기 금액 인식 상태 조회 API

클래스 메서드
SettlementController getAnalysisStatus(Long settlementId)
SettlementService findAnalysisStatus(Long userId, Long settlementId)
SettlementRepository findForParticipant(Long id, Long userId)
  • 요청 (Path): settlement_id
  • 처리 로직:
    1. 참여 확인 - 없으면 404 NOT_FOUND
    2. 응답 - 200 + data: { group_settlement_status, meter_analysis_status, total_amount }
  • 폴링 전용 엔드포인트입니다. 미터기 인식은 비동기라 클라이언트가 주기적으로 조회합니다. 새로고침하거나 화면을 이탈했다 돌아와도 서버 상태로 복원됩니다.

정산 금액 수동 입력 API

클래스 메서드
SettlementController putAmount(Long settlementId, AmountRequest request)
SettlementService inputAmount(Long userId, Long settlementId, int totalAmount)
SettlementRepository requestPayment(...) (조건부 UPDATE)
SettlementParticipantRepository saveAll(...)
NotificationPort settlementRequested(...)
SystemMessagePort paymentRequested(chatRoomId)
  • 요청 (Request Body): total_amount(필수) - 숫자만, 최대 6자리, 공백 · 소수점 · 음수 불가
  • 처리 로직:
    1. 형식 검증 → 실패 시 422
    2. 조건부 UPDATE - WHERE status='PENDING'. 영향 행 0이면 409 ALREADY_REQUESTED
      1. total_amount 기록 + meter_analysis_status='MANUAL' + status='REQUESTED'
    3. settlement_participants N - 1행 생성 (같은 트랜잭션) - analysis_status='PENDING'
      1. 동승자 인당 = total_amount / participant_count의 내림
      2. 방장 몫 = total_amount − (인당 × (participant_count − 1)) - 나머지를 방장이 흡수합니다
    4. SYSTEM_PAYMENT_REQUESTED 메시지 - "방장이 결제를 완료했어요"
    5. SETTLEMENT_REQUESTED 알림 발송 - 이 호출의 부수 효과로 notifications 행을 만듭니다. 채팅방 밖에 있는 동승자도 청구 사실을 알아야 합니다
    6. 응답 - 200 + data: { participant_count, amount_per_person, group_settlement_status, meter_analysis_status }
  • 쓰이는 시점 - 미터기 인식이 지연되어 방장이 기다리지 않기로 했거나(10초 이후 [수동 입력하기] 버튼 클릭) 실패한 경우
  • 인식이 성공한 경우는 이 API를 거치지 않습니다. MeterRecognitionPort의 콜백이 같은 로직(2~5)을 meter_analysis_status='PASSED'로 수행합니다.
  • participant_count를 여기서 다시 세지 않습니다 - 정산 생성 시점의 스냅샷을 그대로 씁니다.

송금 인증사진 제출 API

클래스 메서드
TransferProofController submit(Long settlementId, TransferProofRequest request)
TransferProofService submit(Long userId, Long settlementId, String imageUrl)
SettlementParticipantRepository findBySettlementIdAndUserId · markAnalyzing
TransferAnalysisPort request(participantId, imageUrl)
  • 요청 (Request Body): transfer_image_url(필수)
  • 처리 로직:
    1. 참여 확인 - settlement_participants 행이 없으면 404 NOT_FOUND
    2. 재제출 가능 여부 확인 → 불가하면 409 RETRY_LIMIT_EXCEEDED
    3. transfer_image_url 기록 + analysis_status='ANALYZING'
    4. 커밋 후 AI 검증 요청 - TransferAnalysisPort.request(...)
    5. 응답 - 202 Accepted + data: { participant_count, group_settlement_status, member_analysis_status, transferred_at, is_forged }
  • 재제출은 1회만 허용합니다 - transferred_atNULL인지로 판단합니다.
  • AI 검증 결과 반영 - 별도 API 호출 없이 서버가 자동으로 전환합니다
결과 처리
금액 · 수취인 일치 analysis_status='PASSED' · transfer_completed=TRUE · transferred_at=NOW(6) + SYSTEM_TRANSFER_DONE 메시지
금액 불일치 analysis_status='FAILED' - "송금 금액이 정산 금액과 일치하지 않아요"
송금자 · 수취인 불일치 analysis_status='FAILED' - "본인이 송금한 내역만 인증할 수 있어요"
위조 탐지 analysis_status='PASSED' · is_forged=TRUE - 재제출 1회 허용
OCR 실패 · 서버 오류 analysis_status='FAILED'
  • POST인 이유 - 같은 사용자가 여러 번 보낼 수 있는 요청이 아니라 1회 한정 재시도가 있는 제출이라, 멱등한 교체(PUT)보다 제출 행위(POST)가 계약에 맞습니다. 중복은 2의 상태 검사가 막습니다.

내 송금 상태 조회 API

클래스 메서드
TransferProofController getMine(Long settlementId)
TransferProofService findMine(Long userId, Long settlementId)
SettlementParticipantRepository findBySettlementIdAndUserId
  • 요청 (Path): settlement_id
  • 처리 로직:
    1. 참여 확인 - 없으면 404 NOT_FOUND
    2. 응답 - 200 + data: { participant_count, group_settlement_status, member_analysis_status, is_forged, transferred_at }
  • member_analysis_status를 그대로 내립니다.
의미
PENDING 아직 인증 사진을 올리지 않음
ANALYZING 업로드 완료, AI 위조 판단 진행 중
PASSED AI 분석 성공
FAILED AI 분석 실패
  • 클라이언트는 이 응답의 transferred_atis_forged로 재제출 버튼 활성 여부를 판정합니다.

송금 인증사진 조회 API

클래스 메서드
TransferProofController get(Long settlementId, Long userId)
TransferProofService find(Long requesterId, Long settlementId, Long targetUserId)
SettlementRepository findForParticipant(Long id, Long requesterId)
SettlementParticipantRepository findBySettlementIdAndUserId
ImageAccessPort issueTemporaryUrl(String storedUrl)
  • 요청 (Path): settlement_id, user_id
  • 처리 로직:
    1. 정산 참여 확인 - 요청자가 그 정산의 참여자가 아니면 404 NOT_FOUND
    2. 열람 권한 확인 - 요청자가 방장도 당사자도 아니면 403 FORBIDDEN
    3. 대상 참여자 행 조회 - 없으면 404 NOT_FOUND
    4. 이미지 판정
      1. transfer_image_urlNULL이거나 만료 · 손상 → 200 + data: null
      2. 있으면 만료형 접근 URL을 발급해 응답
    5. 응답 - 200 + data: { image_url, transferred_at, member_analysis_status, is_forged }
  • 403404를 나눈 기준 - 같은 정산의 동승자는 그 정산의 존재를 이미 압니다. 감출 것이 없으므로 "볼 권한이 없다"는 403이 정직합니다. 정산 자체의 비참여자에게는 존재를 감춰 404를 줍니다.
  • 읽기 전용입니다. 다운로드를 제공하지 않으며, image_url만료형 접근 URL이라 외부로 새어도 시간이 지나면 못 엽니다.

정산 완료 처리 API

클래스 메서드
SettlementController complete(Long settlementId)
SettlementService complete(Long userId, Long settlementId)
SettlementRepository complete(...) (조건부 UPDATE)
SettlementParticipantRepository completeAll(Long settlementId)
CompanionParticipantRepository completeAll(Long companionId, LocalDateTime now)
ChatRoomRepository close(chatRoomId)
SystemMessagePort settlementCompleted(chatRoomId)
  • 요청: 본문 없음
  • 처리 로직:
    1. 조건부 UPDATE - WHERE status='REQUESTED'status='COMPLETED', completed_at=NOW(6)
      1. 영향 행 0이면 409 INVALID_STATE_TRANSITION
    2. 마무리 (같은 트랜잭션)
      1. transfer_completed=FALSE인 동승자 행을 전부 TRUE로 확정
      2. companion_participantsPENDING 행을 전부 outcome_status='COMPLETED', left_at=NOW(6)
      3. chat_rooms.closed_at=NOW(6) - 방장 정산 완료 확인 시 채팅방에서 모든 사용자 나가기 처리
      4. SYSTEM_SETTLEMENT_COMPLETED 메시지
    3. 응답 - 204 No Content
  • 동승자의 송금 상태를 조건으로 걸지 않습니다. PASSED인데 is_forged=TRUE거나 재시도를 다 썼는데도 FAILED인 참여자가 있어도, 방장의 완료 처리가 조건 없이 그룹을 COMPLETED로 넘깁니다. 그 참여자의 member_analysis_status는 멈춘 값 그대로 남아 기록이 됩니다.
  • 204인 이유 - 클라이언트가 이 응답으로 그릴 것이 없습니다. 화면은 곧바로 평가 모달이 뜨고, 상태 변화는 채팅 시스템 메시지로 전원에게 전달됩니다.
  • 정산 타임아웃 (배치) - 정산이 방치될 때의 해제 보장이 지금은 없는 상태라 논의 필요

기술 스택 (Technology Stack)

  • Backend: Java 25 / Spring Boot
  • Database: MySQL 8 — DECIMAL(9,6) 좌표, POINT SRID 4326 생성 컬럼, ST_Distance_Sphere
  • Scheduler: Spring @Scheduled (자동 취소 · ETA 폴백)
  • Realtime: 채팅 WebSocket 재사용 (도착 감지)
  • 외부 연동: 카카오모빌리티 길찾기 API (apis-navi.kakaomobility.com/v1/directions)
    • 운행 시작 시 1회, 예상 소요 시간 조회