docs: 유료 구매 추적 현황과 다음 작업(구매 때 유입) 정리

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 10:24:19 +09:00
parent ab7ef371dc
commit 9a413174ed
2 changed files with 197 additions and 0 deletions
+196
View File
@@ -0,0 +1,196 @@
# 유료 구매 추적 — 현황과 다음 작업
> 작성: 2026-10-06 · 다음 작업을 이어갈 때 먼저 읽는 문서.
> 운영 사용 안내는 [`imweb/README.md`](../imweb/README.md) 의 "신청·구매 내역" · "유료 구매 기록" · "업체 링크" 절.
## 0. 한눈에
| 항목 | 상태 |
| --- | --- |
| 유료 구매 목록 (회원·비회원) | **구현 완료 · 운영 미배포** |
| 신청·구매 통합 화면 | **구현 완료 · 운영 미배포** |
| 업체 링크 구매 실적 (회원) | 운영 중 (이전 배포분) |
| 구매 때 유입경로 | **미구현** — 다음 작업 (4절) |
| 비회원 구매의 업체 실적 | **미구현** — 다음 작업 (4절) |
브랜치: `feat/purchase-history` (커밋 `ab7ef37`, 푸시·머지 전).
`fix/admin-ui-tweaks` 에서 따왔으므로 그 브랜치의 미푸시 커밋 3개(카톡 버튼 문구·링크 칸 줄 맞춤·직전 유입 표시 제거)를 함께 담고 있다.
---
## 1. 지금 구매에 대해 알 수 있는 것
아임웹 주문 API(`GET /orders`)에는 **손님이 어디서 왔는지(UTM)가 없다.**
무료 신청은 신청 완료 화면이 그 순간의 유입을 보내지만, 구매에는 그런 화면 연동이 아직 없다.
| 항목 | 알 수 있나 | 근거 · 조건 |
| --- | --- | --- |
| 누가 샀는지 | O | 회원: 회원 정보 / 비회원: 주문서의 주문자 이름·이메일·연락처 |
| 무엇을·언제 | O | 주문·상품당 한 줄, 주문 시각(wtime) |
| 환불·취소 여부 | O | 최근 60일 주문만 다시 봐서 반영 |
| 업체 링크로 왔는지 | 일부 | **회원만.** 링크를 누른 브라우저에서 로그인 → 30일 안에 같은 상품 구매 |
| 가입 경로 | 회원만 | 가입 때 경로라 이번 구매 경로와 다를 수 있다 |
| 구매 때 유입경로 | **X** | 주문 데이터에 없다 |
| 비회원 구매의 업체 | **X** | 회원 코드가 없어 클릭과 이을 수 없다 |
---
## 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`(신청 때 유입)는 **무료 신청만** 센다. 구매는 `join`(가입 경로)으로만 집계된다 — 비회원은 값 없음.
- 구매 수는 `is_valid` 만, 환불·취소는 `cancelledCount` 로 따로.
- **예전 `/api/admin/applications` (목록·groups·inflow-stats·products) 는 삭제했다.** 위젯과 백엔드를 함께 배포해야 한다.
- 무료 신청 수집(`POST /api/applications`, 카톡 클릭)은 그대로다. `inflow_last_*` 는 저장만 하고 화면에서 쓰지 않는다.
### 2.5 관리자 화면 (`imweb/imweb-members.html`)
- 메뉴 "신청 내역" → **"신청·구매"**. 종류 [전체·무료 신청·유료 구매], 상품별/업체별 표에 무료 신청·카톡 버튼 클릭·유료 구매(환불 N) 칸.
- 목록: 구분 배지(무료 신청 / 유료 구매 / 환불·취소), 비회원은 주문자 이름 + "비회원". 상세 패널에 주문번호·상태·주문자.
- 유료 구매만 볼 때 유입 집계는 가입 경로로 고정(신청 때 유입 버튼 비활성).
- 개요: "최근 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. 배포 순서 (아직 안 함)
1. `feat/purchase-history` 머지 → 운영 서버에서 `docker compose --env-file .env.production up -d --build`
(마이그레이션 자동 실행, 에러 코드 변경 없음)
2. **바로 이어서** `imweb/imweb-members.html` 을 아임웹 관리자 페이지 코드에 다시 붙여 넣기
(예전 조회 경로를 지웠으므로 한쪽만 바뀌면 RES_001 "찾을 수 없음")
3. 업체 링크 메뉴의 **구매 집계 새로고침** → 1년치 주문 동기화 → 신청·구매 화면에 구매가 보이는지 확인
4. 비회원 구매 한 건을 열어 **주문자 이름·연락처가 실제로 채워지는지** 확인 (5절 첫 항목)
---
## 4. 다음 작업 — 주문완료 화면에서 구매 때 유입·업체 잡기
### 4.1 목표
- 구매 때 유입(UTM)을 남겨 신청 때 유입처럼 집계한다.
- **비회원 구매도 업체 실적으로 잡는다** — 회원 대신 주문번호로 잇는다.
### 4.2 방식 (추천안)
무료 신청 완료 위젯과 같은 방식이다.
```
주문완료 화면 ── 스크립트가 { 주문번호, MG_UTM.peek().value, MG_PARTNER.refs() } 를 보낸다
│ POST /api/order-inflows (공개 · 엄격한 rate limit · 이름은 api-design 규칙에 맞춰 확정)
▼
order_inflows (가칭) order_no 유일, inflow raw/파싱값, partner refs, received_at
│ 주문 동기화가 같은 order_no 의 구매를 가져올 때 매칭
▼
purchases 에 구매 때 유입 컬럼(가칭 inflow_source 등) 채움
partner_conversions: 회원·비회원 모두 refs 로 귀속 (지금은 회원 touch 로만)
```
- 스크립트 위치: Header Code(`imweb-header-top.html`)가 주문완료 페이지를 감지하거나, 주문완료 페이지 전용 코드 위젯.
UTM·업체 클릭 ID 는 이미 Header Code 가 보관하므로 읽기만 하면 된다.
- 위조 대응: 공개 API 라 막을 수는 없다. **아임웹에 실제로 있는 주문번호에만** 붙인다(동기화 시점 매칭이라 자연히 걸러짐).
같은 주문번호에 두 번 오면 첫 값만, 오래 매칭되지 않은 행은 정리한다.
- 무통장입금: 주문완료 시점에 받아두고, 입금 확인 후 구매로 잡힐 때 붙는다.
- 귀속 규칙 변경 검토: 주문완료에서 온 refs 를 회원 touch 보다 우선할지, 30일 기준을 그대로 쓸지.
- 화면: `inflow-stats` 의 `basis=apply` 를 "신청·구매 때 유입"으로 넓히고, 목록의 "신청 때 유입" 칸에 구매도 표시.
### 4.3 시작 전에 확인할 것
- [ ] **주문완료 페이지 주소와 주문번호 위치** — 주소창 파라미터인지 화면 DOM 인지. 테스트 주문 한 번으로 확인.
(아임웹 API 의 `orderNo` 와 같은 값인지도 확인)
- [ ] UTM 보관 기간 — 지금 `TTL_DAYS: 2` (`imweb-header-top.html` 49행). 광고 후 며칠 뒤 사는 경우가 많으면 늘릴지 결정.
늘리면 무료 신청의 신청 때 유입에도 같이 영향
- [ ] 비회원 구매 비중 — 많을수록 이 작업의 효과가 크다 (2절 배포 후 화면에서 확인 가능)
### 4.4 작업 체크리스트
- [ ] 엔티티·마이그레이션 (`order_inflows`, `purchases` 유입 컬럼) — 생성된 SQL 검토
- [ ] 공개 엔드포인트 DTO 검증 + `@Throttle` (신청 기록처럼)
- [ ] 동기화 매칭 + 비회원 업체 귀속 + 단위 테스트
- [ ] `activities` 유입 집계·목록 확장
- [ ] 아임웹 스크립트 + `imweb/README.md`
- [ ] 필요하면 에러 코드 등록 → `npm run sync:error-codes`
---
## 5. 열린 결정 · 알려진 한계
- **주문자 필드 실제 응답 미확인** — `ordererName/ordererEmail/ordererCall` 은 아임웹 문서 필드명으로 받는다.
운영에서 비어 있으면 응답 구조를 다시 확인해야 한다.
- **0원 주문** — 결제완료면 유료 구매로 잡힌다. 무료 상품을 아임웹에서 0원 주문으로 받는 경우가 있으면
결제 수단 `FREE` 주문을 뺄지 결정 (업체 실적 판정 `evaluateOrder` 도 같이 바꿔야 수가 맞는다).
- 60일보다 오래된 주문의 환불은 반영되지 않는다 (서버 재시작 직후 1년치 실행 때만 다시 본다).
- 휴대폰에서 링크·광고를 보고 PC 에서 사면 유입·업체가 이어지지 않는다 (무료 신청도 같다).
- 구매자의 가입 경로는 "이번 구매 경로"가 아니다 — 화면 문구에서 혼동하지 않게 유지.