generated from ttipo/project-base
bf891d2177
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 KiB
15 KiB
유료 구매 추적 — 현황과 배포
작성: 2026-10-06 · 다음 작업을 이어갈 때 먼저 읽는 문서. 운영 사용 안내는
imweb/README.md의 "신청·구매 내역" · "유료 구매 기록" · "구매 때 유입" · "업체 링크" 절.
0. 한눈에
| 항목 | 상태 |
|---|---|
| 유료 구매 목록 (회원·비회원) | 구현 완료 · main 머지 · 운영 미배포 |
| 신청·구매 통합 화면 | 구현 완료 · main 머지 · 운영 미배포 |
| 업체 링크 구매 실적 (회원) | 운영 중 (이전 배포분) |
| 업체 링크 우선 (신청·구매 때 유입) | 구현 완료 · 푸시·머지 전 (feat/purchase-inflow, 4.5절) |
| 구매 때 유입경로 | 구현 완료 · 푸시·머지 전 (feat/purchase-inflow, 4절) |
| 비회원 구매의 업체 실적 | 구현 완료 · 푸시·머지 전 (feat/purchase-inflow, 4절) |
feat/purchase-history 는 main 에 머지·푸시됐다 (9e6cae7). 구매 때 유입 작업은 그 위의
feat/purchase-inflow 브랜치에 있다 (2026-10-06 기준 푸시·머지 전).
1. 지금 구매에 대해 알 수 있는 것
아임웹 주문 API(GET /orders)에는 손님이 어디서 왔는지(UTM)가 없다.
그래서 무료 신청처럼 주문완료 화면이 그 순간의 유입을 보내고, 주문 동기화가 주문번호로 잇는다 (4절).
| 항목 | 알 수 있나 | 근거 · 조건 |
|---|---|---|
| 누가 샀는지 | O | 회원: 회원 정보 / 비회원: 주문서의 주문자 이름·이메일·연락처 |
| 무엇을·언제 | O | 주문·상품당 한 줄, 주문 시각(wtime) |
| 환불·취소 여부 | O | 최근 60일 주문만 다시 봐서 반영 |
| 업체 링크로 왔는지 | O | 회원: 링크를 누른 브라우저에서 로그인 / 비회원: 주문완료 화면이 보낸 클릭 ID. 30일 안에 같은 상품 구매 |
| 가입 경로 | 회원만 | 가입 때 경로라 이번 구매 경로와 다를 수 있다 |
| 구매 때 유입경로 | O (연동 후 주문) | 주문완료 화면이 보낸 값. 다른 기기에서 결제·스크립트 차단이면 없음 |
2. 구현된 것 (feat/purchase-history)
2.1 데이터 흐름
아임웹 GET /orders ── 1시간마다 (IMWEB_SYNC_INTERVAL_MINUTES, 기본 60)
│ PartnerOrderSyncService (modules/partners/partner-order-sync.service.ts)
│ · 서버 기동 후 첫 실행: 최근 365일 / 성공 후: 최근 60일
│ · 회원 여부로 거르지 않는다 (isMember 파라미터 없음)
├─▶ PurchasesService.recordOrders() → purchases (모든 결제 주문)
└─▶ PartnerAttributionService.attributeOrder() → partner_conversions
(업체 링크 상품 + 회원 주문만)
관리자 화면 ── GET /api/admin/activities* (ActivitiesService)
member_applications ∪ purchases (UNION ALL) + partner_conversions 로 업체 표시
- 수동 실행: 관리자 화면 업체 링크 → 구매 집계 새로고침 (
POST /api/admin/partner-order-sync-runs) - "1년치 완료" 여부는 메모리 플래그(
isBackfilled)다. 서버를 재시작할 때마다 첫 실행은 다시 1년치를 본다 (재시작 동안 놓친 환불도 메운다). 1년치 중간에 실패하면 다음 실행에서 다시 1년치를 본다.
2.2 purchases 테이블 (마이그레이션 1791220773446-AddPurchases)
| 컬럼 | 설명 |
|---|---|
id uuid PK |
앱에서 생성 |
order_no varchar(30) + prod_no int |
유일 (uq_purchases_order_product) — 주문·상품당 한 줄 |
product_name |
주문 당시 상품명 (productInfo.prodName) |
member_code / member_uid |
비회원 주문이면 null |
orderer_name / orderer_email / orderer_call |
주문서 주문자 (회원 주문도 저장) |
ordered_at |
아임웹 wtime |
is_valid |
지금 구매로 세는가 — 환불·취소·반품이면 false |
created_at |
처음 잡힌 시각 |
- 저장은
INSERT ... ON CONFLICT (order_no, prod_no) DO UPDATE(skipUpdateIfNoValuesChanged) — 여러 번 돌려도 안전. - 금액은 저장하지 않는다 (요청사항).
2.3 판정 규칙 — shared/utils/order-purchase.ts
- 남기는 주문: 결제가 한 번이라도 끝난 주문
(
PAYMENT_COMPLETE·PARTIAL_REFUND_COMPLETE·REFUND_PROCESSING·REFUND_COMPLETE·REFUND_FAILED, 취소 결제 행 제외). 결제 대기·실패·입금 전 취소는 남기지 않는다. - 구매로 세는가(
is_valid): 업체 실적과 같은evaluateOrder()— 결제완료(부분 환불 포함)이고 그 상품 섹션이CANCEL_COMPLETE/RETURN_COMPLETE가 아닐 때. → 업체 실적 구매 수와 화면 구매 수가 어긋나지 않는다. - 사은품 품목(
isPromotion=Y)은 넣지 않는다. - 0원 주문도 결제완료면 구매로 남는다 (미결정 — 5절).
2.4 관리자 API (modules/activities/)
| 경로 | 용도 | 주요 파라미터 |
|---|---|---|
GET /api/admin/activities |
신청·구매 목록 (커서) | type(application·purchase), from/to, productIdx, partnerId(uuid·none), q, inflowBasis(apply·join)+inflowField+inflowValue |
GET /api/admin/activities/groups |
상품별·업체별 신청·구매 수 | by(product·partner), from/to |
GET /api/admin/activities/inflow-stats |
유입 집계 | basis(apply·join), groupBy, 목록 필터와 동일 |
basis=apply는 처음엔 무료 신청만 셌으나, 4절 이후 신청·구매 때 유입(구매 포함)이다.join은 가입 경로 — 비회원은 값 없음.- 구매 수는
is_valid만, 환불·취소는cancelledCount로 따로. - 예전
/api/admin/applications(목록·groups·inflow-stats·products) 는 삭제했다. 위젯과 백엔드를 함께 배포해야 한다. - 무료 신청 수집(
POST /api/applications, 카톡 클릭)은 그대로다.inflow_last_*는 저장만 하고 화면에서 쓰지 않는다.
2.5 관리자 화면 (imweb/imweb-members.html)
- 메뉴 "신청 내역" → "신청·구매". 종류 [전체·무료 신청·유료 구매], 상품별/업체별 표에 무료 신청·카톡 버튼 클릭·유료 구매(환불 N) 칸.
- 목록: 구분 배지(무료 신청 / 유료 구매 / 환불·취소), 비회원은 주문자 이름 + "비회원". 상세 패널에 주문번호·상태·주문자.
- (4절 이전) 유료 구매만 볼 때 유입 집계는 가입 경로로 고정했다 — 4절에서 풀었다.
- 개요: "최근 7일 무료 신청" · "최근 7일 유료 구매" 카드, "최근 신청·구매" 목록.
2.6 파일
backend/src/shared/utils/order-purchase.ts (+spec) 판정 규칙 (순수 함수)
backend/src/modules/purchases/ 엔티티 · recordOrders (+spec)
backend/src/modules/activities/ 관리자 통합 조회 (UNION ALL)
backend/src/modules/partners/partner-order-sync.service.ts (+spec) 주문 동기화 (1년→60일)
backend/src/modules/imweb/imweb.types.ts ordererName/Email/Call 추가
backend/src/modules/applications/ 관리자 조회 제거 (수집만 남음)
backend/src/migrations/1791220773446-AddPurchases.ts
imweb/imweb-members.html · imweb/README.md
2.7 검증한 것
- 백엔드 테스트 189개 통과, lint·build 통과.
- 로컬 DB 에 회원 신청·회원 구매·비회원 구매·환불 건을 넣고 API(필터·커서·묶음·유입 집계·잘못된 커서 VAL_001)와 실제 화면(종류 전환·업체별·상세 패널)을 확인. 넣은 데이터는 지웠다.
- upsert 를 실제 Postgres 에서 확인 (결제 전 무시 → 결제 저장 → 재저장 1행 유지 → 환불 반영).
- 실제 아임웹 주문으로는 아직 돌려보지 않았다.
3. 배포 순서 (아직 안 함)
feat/purchase-history(main 머지 완료)와 4절 작업을 한 번에 배포한다고 보고 적었다.
- 4절 브랜치 머지 → 운영 서버에서
docker compose --env-file .env.production up -d --build(마이그레이션AddPurchases·AddOrderInflows자동 실행, 에러 코드 변경 없음) - 바로 이어서 아임웹에 두 파일을 다시 붙여 넣는다
imweb/imweb-members.html→ 관리자 페이지 코드 (예전 조회 경로를 지웠으므로 한쪽만 바뀌면 RES_001 "찾을 수 없음")imweb/imweb-header-top.html→ Header Code (업체 링크 우선 v1.2 · 결제창 리퍼러 제외 v1.3 · 구매 때 유입 전송)
- 업체 링크 메뉴의 구매 집계 새로고침 → 1년치 주문 동기화 → 신청·구매 화면에 구매가 보이는지 확인
- 비회원 구매 한 건을 열어 주문자 이름·연락처가 실제로 채워지는지 확인 (5절 첫 항목)
- 테스트 주문 한 건 → 주문완료 화면 → 다음 동기화(또는 새로고침) 후 그 구매의 상세 패널에 구매 때 유입이 보이는지 확인
(브라우저 개발자 도구 네트워크 탭에서
order-inflows가 204 인지도 볼 수 있다)
4. 구매 때 유입·비회원 업체 실적 (feat/purchase-inflow, 푸시·머지 전)
4.1 흐름
주문완료 화면 (/shop_payment_complete?order_no=…)
│ Header Code '구매 때 유입 전송' → { orderNo, inflow: MG_UTM.peek().value, partnerRefs: MG_PARTNER.refs() }
│ POST /api/order-inflows (공개 · 1분 10회 · 항상 204 · 같은 주문번호는 처음 값만)
▼
order_inflows order_no PK, inflow raw/파싱값, partner_refs varchar(16)[], received_at
│ 주문 동기화(PartnerOrderSyncService)가 페이지마다
├─▶ PurchasesService.recordOrders → UPDATE purchases … FROM order_inflows (inflow_source 가 빈 구매에만)
└─▶ PartnerAttributionService.attributeOrder(order, linked, refs)
후보 = 회원 클릭 연결(partner_touches) ∪ refs 의 실제 클릭(사용 중인 그 상품 링크) → 30일 안 마지막 클릭
→ partner_conversions (비회원이면 member_code null)
동기화가 끝까지 성공하면 90일 지난 order_inflows 를 지운다
- 주문완료 주소의
order_no는 아임웹 APIorderNo와 같다 (2026-10-06 관리자 화면에서 확인). - 유입 기준은 무료 신청과 같다 — 첫 경로(2일 보관), 업체 링크로 오면 그 업체가 우선 (Header Code v1.2).
- 결제창(카카오페이·네이버페이·카드사)에서 돌아올 때의 리퍼러는 유입으로 치지 않는다 —
AUTH_PATH에shop_payment추가 (v1.3). 이전에는pay.kakao.com이 '카카오 소셜',pay.naver.com이 '네이버 검색' 유입으로 잡힐 수 있었다. - 관리자 화면: 유입 기준 "신청 때 유입" → "신청·구매 때 유입", 구매 행·상세 패널에 구매 때 유입 표시, 업체 링크 실적 상세에 비회원 구매는 주문자 이름 + "비회원 구매".
- 0원 주문은 지금처럼 둔다 (2026-10-06 결정).
4.2 파일
backend/src/modules/purchases/order-inflow.entity.ts order_inflows
backend/src/modules/purchases/order-inflows.{controller,service}.ts (+spec) POST /api/order-inflows · 보관·정리
backend/src/modules/purchases/purchases.service.ts (+spec) 구매 때 유입 채우기 · 주문자 이름 조회
backend/src/modules/purchases/purchase.entity.ts inflow_* 칸
backend/src/modules/partners/partner-attribution.service.ts (+spec) 비회원 귀속 (refs)
backend/src/modules/partners/partner-order-sync.service.ts (+spec) refs 전달 · 정리
backend/src/modules/partners/entities/partner-conversion.entity.ts member_code nullable
backend/src/modules/partners/partner-links.service.ts · dto 비회원 실적에 주문자 이름
backend/src/modules/activities/ 구매도 신청·구매 때 유입으로 조회·집계
backend/src/migrations/1791258525922-AddOrderInflows.ts
imweb/imweb-header-top.html · imweb/imweb-members.html · imweb/README.md
4.3 검증한 것
- 백엔드 테스트 201개 통과 (새 테스트 12개), lint·build 통과.
- 로컬 Postgres: 공개 API(첫 값만 저장·형식 오류 VAL_001) → 구매 저장 시 유입 채움 → 비회원 주문이 클릭 ID 로 업체에 귀속 → 목록·유입 필터·집계·업체 실적 상세(주문자 이름)까지 확인. 넣은 데이터는 지웠다.
- Header Code 를 Node 로 흉내 내 확인: 결제창 리퍼러 무시, 업체 링크 클릭 ID 전송, 새로고침 시 재전송 없음, 주문완료가 아닌 페이지·잘못된 주문번호면 보내지 않음.
- 실제 아임웹 주문완료 화면에서는 아직 돌려보지 않았다 (3절 5번).
4.4 남은 확인
- UTM 보관 기간 — 지금
TTL_DAYS: 2. 광고 후 며칠 뒤 사는 경우가 많으면 늘릴지 결정 (무료 신청에도 같이 영향) - 비회원 구매 비중 — 배포 후 화면에서 확인
- 예전 신청 기록 보정 — v1.2 이전 신청은 업체 링크로 왔어도 첫 경로가 광고일 수 있다
(
inflow_last_medium='partner'인데 첫 유입이 다른 행). 보정할지 결정
4.5 업체 링크 우선 (같은 브랜치)
업체 링크(utm_medium=partner)로 들어오면 보관 중인 첫 경로를 그 업체로 바꾼다 (Header Code v1.2).
광고로 가입 → 업체 링크로 신청·구매하면 가입 경로는 광고, 신청·구매 때 유입은 업체로 남는다.
여러 업체를 거치면 마지막 업체 — 업체 실적의 마지막 클릭 규칙과 같다.
5. 열린 결정 · 알려진 한계
- 주문자 필드 실제 응답 미확인 —
ordererName/ordererEmail/ordererCall은 아임웹 문서 필드명으로 받는다. 운영에서 비어 있으면 응답 구조를 다시 확인해야 한다. - 0원 주문 — 결제완료면 유료 구매로 잡힌다. 무료 상품을 아임웹에서 0원 주문으로 받는 경우가 있으면
결제 수단
FREE주문을 뺄지 결정 (업체 실적 판정evaluateOrder도 같이 바꿔야 수가 맞는다). - 60일보다 오래된 주문의 환불은 반영되지 않는다 (서버 재시작 직후 1년치 실행 때만 다시 본다).
- 휴대폰에서 링크·광고를 보고 PC 에서 사면 유입·업체가 이어지지 않는다 (무료 신청도 같다).
- 구매 때 유입은 공개 API 로 받으므로, 주문번호를 아는 사람이 먼저 가짜 값을 보내면 그 값이 남는다 (실제 주문에만 붙고, 업체 실적은 실제로 있는 클릭만 인정한다).
- 구매자의 가입 경로는 "이번 구매 경로"가 아니다 — 화면 문구에서 혼동하지 않게 유지.