Repository navigation
feat: 판매자 푸시 발송 소비자(Expo Push Service)·영수증 스케줄러 - #512
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🧹 knip — dead-code 리포트전체 리포트
|
🩺 NestJS Doctor — 90/100 (Excellent)진단 484건 (error 12).
architecture / security 상위 항목
|
Coverage report
Show new covered files 🐣
Show files with reduced coverage 🔻
Test suite run success4236 tests passing in 383 suites. Report generated by 🧪jest coverage report action from 3a7d013 |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6c686267c8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const response = await withTimeout( | ||
| fetchFn(url, { method: 'POST', headers, body: JSON.stringify(body) }), | ||
| options.timeoutMs, | ||
| label, | ||
| ); |
There was a problem hiding this comment.
Abort timed-out Expo POSTs before retrying them
When Expo takes longer than EXPO_PUSH_TIMEOUT_MS, withTimeout rejects without cancelling fetchFn, so the consumer retries while the original send POST can still complete and sellers may receive duplicate notifications. The timeout also ends as soon as response headers arrive, leaving response.json() able to hang indefinitely. Use an AbortController and keep the timeout active through reading the response body.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
반영: fetch에 AbortController signal을 넘기고 기한이 바디(JSON) 읽기까지 덮게 한 뒤, TimeoutError면 abort — 기한 초과 뒤 원 POST가 완료돼 재시도와 겹치는 중복 발송·헤더 뒤 json() 무한 대기 둘 다 막음. 던지는 에러는 기존과 같은 TimeoutError(fetch가 AbortError로 끝나도 호출자는 TimeoutError → retry 경로). 테스트 3건(기한 초과 시 signal.aborted, 바디 읽기 지연도 기한에 걸림, 제때 끝나면 abort 안 함) — AbortController 없는 코드로 되돌리면 실패 확인.
| await this.deliveries.markTickets(results); | ||
| await this.devices.disableByIds( |
There was a problem hiding this comment.
Disable rejected devices before finalizing deliveries
If the process exits or disableByIds fails after markTickets succeeds, the retry finds no PENDING delivery and never disables the device that returned DeviceNotRegistered, so future events keep targeting the dead token. The receipt scheduler has the same terminal-state-before-disable ordering and also excludes the finalized row on retry; make these state changes atomic or perform the idempotent disable before marking the delivery terminal.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
반영: 소비자·영수증 스케줄러 둘 다 멱등한 disableByIds(disabled_at IS NULL 조건)를 먼저 하고 그 다음 markTickets/markReceipts로 전달 행을 종료 상태로 표시하도록 순서 변경. 행을 닫은 뒤 죽어도 디바이스는 이미 비활성이고, 비활성 뒤 죽으면 행이 PENDING/TICKET_OK로 남아 재시도·다음 틱이 다시 본다. 테스트 2건(markTickets/markReceipts를 throw시켜도 디바이스는 비활성) — 순서를 되돌리면 실패 확인.
6c68626 to
24985ab
Compare
판매자 앱에 새 주문·새 문의 푸시를 보낸다. #507의 outbox 이벤트를 #510의 디바이스로 전달하는 마지막 조각.
- expo-push.config: EXPO_PUSH_ENABLED(기본 false)·ACCESS_TOKEN·TIMEOUT_MS(기본 5000). 비표준 값은 기본값, 부팅 실패 조건 없음.
app.module load 등록, README 환경 변수 표·infra/app.env.example에 키 3개.
- src/global/expo-push: EXPO_PUSH_TRANSPORT 토큰·ExpoPushTransport 계약·fetch 기본 구현. send ≤100·getReceipts ≤1000(넘기면
throw), withTimeout, 비 2xx는 상태 코드를 담은 ExpoPushHttpError, 401/403은 ExpoPushAuthError, 200+errors·ticket 수
불일치도 거절. kakao-local과 같이 모듈 없이 NotificationModule.providers에 useValue로 등록.
- SellerPushOutboxConsumer(@SubscribeOutbox order.submitted·conversation.buyer_message_sent, worker): enabled=false면 debug
로그 후 ack(이력 없음), payload 파싱 실패 throw, 매장 활성 디바이스 → claim(createFromEvent 방식 — 기존 행 제외 후
createMany, skipDuplicates 없음, unique가 최종 방어, PENDING만 전송) → 100개 배치 send → TICKET_OK(ticket_id·sent_at) /
TICKET_ERROR(error_code), DeviceNotRegistered는 즉시 디바이스 비활성. 전송 예외는 던져 호스트 retry/DLQ, 인증 실패는
expo-push:auth 경보 뒤 throw. 메트릭 caquick_expo_push_sends_total{result}.
- SellerPushReceiptScheduler(5분, worker 역할 가드·enabled 가드·동시 실행 플래그): 15분 지난 TICKET_OK 최대 300건 →
RECEIPT_OK / RECEIPT_ERROR(+DeviceNotRegistered 비활성), 24h 넘게 영수증 없으면 RECEIPT_UNKNOWN, 그 전엔 유지. 실패는 warn +
expo-push:receipts(인증 실패는 expo-push:auth), 행은 그대로.
- SellerPushDeliveryRepository(claim·markTickets·listForReceipt·markReceipts), seller-push-messages.helper(주문 "새 주문" /
"{상품} {n}개 · 픽업 M/d HH:mm"(KST), 문의 "새 문의" / preview, data kind·id, channelId default).
- order·conversation 배럴에 payload 타입 export.
- spec: config 19, transport 20, helper 6, delivery repo 9(FK 오류 전파 반증), consumer 14(재전달 send 0회·150→100+50·
배치 중간 실패 뒤 남은 50만 재전송·401 경보·enabled=false·payload 오류·구독 외 throw), scheduler 10(15분 미만 미조회·
24h UNKNOWN·api/ws 미실행·동시 실행 건너뜀), metrics 지표 표 1행. 가드 5개(PENDING 필터·enabled·running·15분·100개 상한)를
지우면 기대한 7건만 실패하는 것 확인.
Codex 2라운드 리뷰 반영. - 전송 어댑터: withTimeout이 fetch를 취소하지 않아 기한 초과 뒤에도 원 POST가 완료돼 재시도와 겹치면 중복 발송될 수 있고, 헤더 도착 뒤 response.json()은 기한 밖이라 무한 대기가 가능했다. AbortController를 fetch에 넘기고 기한이 바디(JSON) 읽기까지 덮게 한 뒤 TimeoutError면 abort한다. 던지는 에러는 기존과 같은 TimeoutError(fetch가 AbortError로 끝나도 호출자는 TimeoutError). - 소비자·영수증 스케줄러: markTickets/markReceipts 뒤에 disableByIds를 하던 순서를 뒤집는다. 전달 행을 종료 상태로 바꾼 뒤 죽으면 재시도·다음 틱이 그 행을 다시 보지 않아 DeviceNotRegistered 디바이스가 영영 비활성되지 않았다. disableByIds는 멱등(disabled_at IS NULL 조건)이라 먼저 해도 안전하다. - 회귀 테스트 5건: 전송 3(기한 초과 시 signal abort·AbortError여도 TimeoutError, 바디 읽기 지연도 기한에 걸림, 제때 끝나면 abort 안 함), 소비자 1(markTickets가 던져도 디바이스는 이미 비활성·행은 PENDING), 스케줄러 1(markReceipts가 던져도 디바이스는 이미 비활성·행은 다음 틱 대상). 운영 코드를 HEAD로 되돌리면 이 5건만 실패하는 것을 확인.
rebase 뒤 develop SDL 기준으로 seller·admin 루트 필드 수 갱신.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 24985ab39c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const errorCode = receipt.details?.error ?? EXPO_ERROR_UNKNOWN; | ||
| results.push({ id: row.id, status: 'RECEIPT_ERROR', errorCode }); |
There was a problem hiding this comment.
Alert on receipt-level credential failures
When Expo returns a receipt-level InvalidCredentials or MismatchSenderId error, the HTTP request itself succeeded, so the surrounding exception handler never runs; this branch merely stores RECEIPT_ERROR without logging, alerting, or incrementing a metric. These errors can disable pushes for an entire platform while remaining invisible outside direct database inspection. Expo documents both as receipt errors, so raise a deduplicated operational alert for these codes: Expo receipt error documentation.
Useful? React with 👍 / 👎.
| const errorCode = receipt.details?.error ?? EXPO_ERROR_UNKNOWN; | ||
| results.push({ id: row.id, status: 'RECEIPT_ERROR', errorCode }); |
There was a problem hiding this comment.
Retry notifications rejected by transient receipt errors
When a receipt contains MessageRateExceeded, this branch marks the delivery permanently as RECEIPT_ERROR; listForReceipt then excludes it, so the new-order or inquiry notification is never attempted again. Expo explicitly classifies this receipt error as transient and recommends exponential-backoff retries, so distinguish it from permanent failures and schedule a new send rather than terminally closing the delivery: Expo receipt error guidance.
Useful? React with 👍 / 👎.
| const rows = await this.deliveries.listForReceipt({ | ||
| sentBefore: new Date(now.getTime() - RECEIPT_DELAY_MS), | ||
| limit: RECEIPT_BATCH_LIMIT, | ||
| }); |
There was a problem hiding this comment.
Drain more than one receipt batch per scheduler run
With a single 300-row query every five minutes, this scheduler can check only one receipt per second on average. If traffic or a backlog exceeds that rate, TICKET_OK rows accumulate faster than they are drained and eventually reach Expo's 24-hour receipt-retention limit before being inspected, at which point valid failures such as DeviceNotRegistered are reduced to RECEIPT_UNKNOWN and dead tokens remain enabled. Loop through bounded batches (the API permits up to 1,000 IDs per request) or schedule work frequently enough to catch up: Expo receipt timing and retention guidance.
Useful? React with 👍 / 👎.
24985ab to
3a7d013
Compare
요약
order.submitted·conversation.buyer_message_sent)를 #510의 디바이스로 전달하는 마지막 조각이며, SDL·Prisma 변경은 없습니다.SellerPushOutboxConsumer(worker, 큐q.SellerPushOutboxConsumer), 스케줄러SellerPushReceiptScheduler(5분)src/global/expo-push, 설정expo-push.config, 메트릭caquick_expo_push_sends_total{result}변경
EXPO_PUSH_ENABLED(기본false) ·EXPO_PUSH_ACCESS_TOKEN(공백은null) ·EXPO_PUSH_TIMEOUT_MS(기본 5000,0·abc는 기본값)app.moduleload 등록, README 환경 변수 표 "푸시 (선택)" 행,infra/app.env.example키 3개NotificationModule.providers에useValue로 등록합니다(@Global모듈 없음, global은 common·config만 의존).send(≤100, 초과 throw) ·getReceipts(≤1000) ·fetch+withTimeout·Authorization: Bearer(토큰 있을 때만)ExpoPushHttpError, 401/403은ExpoPushAuthError, 200이어도errors면 거절, ticket 수가 요청과 다르면 거절enabled=false→ debug 로그 후 ack(전송 이력 없음) / payload 파싱 실패·구독 외 event_type → throw(호스트 retry → DLQ)NotificationRepository.createFromEvent방식 — 기존 행 제외 후createMany(skipDuplicates없음, FK 오류가 삼켜지지 않음), unique(source_event_id, push_device_id)가 최종 방어, PENDING 행만 전송TICKET_OK(ticket_id·sent_at) /TICKET_ERROR(error_code),DeviceNotRegistered는 즉시disableByIds(DEVICE_NOT_REGISTERED)ExpoPushAuthError는expo-push:auth(error) 경보 뒤 throw새 주문/{상품명} {수량}개 · 픽업 M/d HH:mm(KST,kst-time재사용) /data { kind: ORDER_SUBMITTED, orderId }, 문의새 문의/ preview /data { kind: BUYER_MESSAGE, conversationId },channelId: defaultTICKET_OK·sent_at < now − 15분·receipt_checked_at IS NULL최대 300건ok→RECEIPT_OK,error→RECEIPT_ERROR+ error_code(DeviceNotRegistered면 디바이스 비활성), 응답에 없고 24h 경과 →RECEIPT_UNKNOWN(그 전엔 유지해 다음 틱에 다시)expo-push:receipts(warn) 경보, 인증 실패는expo-push:auth(error); worker 역할 가드·enabled가드·동시 실행 플래그SellerPushDeliveryRepository(claim·markTickets·listForReceipt·markReceipts),seller-push-messages.helperMetricsService.expoPushSendsCounter 필드(라벨TICKET_OK/TICKET_ERROR/AUTH_ERROR, 기존 Counter 필드 방식)order·conversation배럴에 payload 타입 export(OrderSubmittedPayload·ConversationBuyerMessageSentPayload)EXPO_ERROR_DEVICE_NOT_REGISTERED·EXPO_ERROR_UNKNOWN을seller-push.constants에테스트
module-wiring.spec포함)입니다.expo-push.config.spec19건:EXPO_PUSH_ENABLED9값it.each(비표준 값 false), 토큰 공백 → null, 타임아웃0·-1·abc→ 5000expo-push.transport.spec20건: URL·헤더(토큰 유무)·JSON 본문, 101개 throw(100개는 전송), 비 2xx 4종, 401/403, 타임아웃,errors거절, 형식 오류 3종, getReceipts 1001개 throw, 기본 구현의 전역 fetchseller-push-delivery.repository.spec9건: 선점·재전달 제외·다른 이벤트 무관·없는 디바이스 id는 FK 오류로 던짐(skipDuplicates였다면 조용히 0건)·listForReceipt 조건/정렬/limit·markReceiptsseller-push-outbox.consumer.spec(real DB, transport stub) 14건: 활성 2 → send 1회·메시지 2·행 TICKET_OK / 해제·타 매장 제외 / 0 → 미호출 / 같은 eventId 재호출 → send 0회(반증: 다른 eventId는 1회) / 150 → 100+50 / DeviceNotRegistered → 비활성 / 코드 없는 오류 UNKNOWN / throw → PENDING 유지·재호출 재전송 / 두 번째 배치만 실패 → 재전달은 남은 50개만 / 401 → 경보+throw·AUTH_ERROR 2 / enabled=false / payload 오류 throw / 문의 이벤트 body·kind / 구독 외 throwseller-push-receipt.scheduler.spec(real DB) 10건: 15분 경과만 조회(반증: 14분은 미조회) / 대상 0 → 미호출 / DeviceNotRegistered 비활성(MessageTooBig은 유지) / 24h UNKNOWN·24h−1분 유지 후 다음 틱 재조회 / throw → warn 경보·행 불변 / 401 → auth 경보 / api·ws 미실행 / enabled=false / 앞선 틱 진행 중 건너뜀·끝난 뒤 재실행seller-push-messages.helper.spec6건,metrics.service.spec지표 표 1행 추가tsc·lint·arch:check) 통과이며, SDL 변경이 없어roles-coverage·dto:check·docs:check영향이 없습니다.플랜 대조
설계와 달리 한 결정
enabled가드를 두어 꺼진 동안에는 Expo를 부르지 않습니다(설계는 소비자만 언급). 인증 실패로 끈 뒤 영수증 조회가expo-push:auth를 계속 내지 않게 하기 위해서입니다.claim은 현재 활성 디바이스 집합으로 한정해 PENDING을 돌려줍니다(push_device_id순). 전송 전에 해제된 디바이스의 PENDING 행은 전송하지 않고 남겨 둡니다.errors와 ticket 수 불일치를 추가로 거절합니다 — 순서 기반 디바이스 매핑을 믿을 수 없을 때 조용히 기록하지 않기 위해서입니다.AUTH_ERROR메트릭은 요청이 아니라 메시지 수 단위로 셉니다(TICKET_*와 같은 단위).outboxPublisherProviders를 넣지 않았습니다 — 소비자·두 repository 모두OutboxPublisher를 주입받지 않아 필요가 없습니다.후속 이슈(등록 예정)
운영 반영
EXPO_PUSH_ENABLED=true추가 필요(메인 세션 작업). 미설정이면 worker가 이벤트를 전송 없이 ack하므로 그 기간의 푸시는 복구되지 않습니다.EXPO_PUSH_ACCESS_TOKEN은 선택(Expo 대시보드에서 발급 시 함께 추가).