From 8d4adfd7f1026eb495275545b782220ff688227e Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 6 Oct 2026 13:14:22 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(imweb):=20=EC=97=85=EC=B2=B4=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=EB=A1=9C=20=EB=93=A4=EC=96=B4=EC=98=A4?= =?UTF-8?q?=EB=A9=B4=20=EC=8B=A0=EC=B2=AD=20=EB=95=8C=20=EC=9C=A0=EC=9E=85?= =?UTF-8?q?=EC=9D=84=20=EA=B7=B8=20=EC=97=85=EC=B2=B4=EB=A1=9C=20=EB=B0=94?= =?UTF-8?q?=EA=BF=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 광고로 가입한 회원이 업체 링크로 다시 와서 신청해도, 보관 중인 첫 경로(광고)가 남아 신청 때 유입이 광고로 잡혔다. 업체 칸(업체 실적)은 그 업체라 한 줄 안에서 유입과 업체가 어긋났다. 업체 단축 링크가 붙인 UTM(utm_medium=partner)으로 들어오면 첫 경로를 그 업체로 덮어쓴다. 여러 업체를 거치면 마지막 업체 — 업체 실적의 마지막 클릭 규칙과 같다. 화면의 유입 기준은 그대로 하나만 둔다. Co-Authored-By: Claude Opus 5.5 --- backend/src/shared/utils/partner-link.ts | 5 ++++- imweb/README.md | 9 ++++++++- imweb/imweb-header-top.html | 10 +++++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/backend/src/shared/utils/partner-link.ts b/backend/src/shared/utils/partner-link.ts index 3f16c2c..27396cb 100644 --- a/backend/src/shared/utils/partner-link.ts +++ b/backend/src/shared/utils/partner-link.ts @@ -86,7 +86,10 @@ export function buildEntryUrl(siteUrl: string, code: string): string { /** UTM 파라미터 접두어 — imweb-header-top.html 의 UTM 스크립트가 읽는다 */ const UTM_PARAM_PREFIX = 'utm_'; -/** 업체 링크로 온 방문의 매체 값 */ +/** + * 업체 링크로 온 방문의 매체 값. + * imweb-header-top.html 의 PARTNER_MEDIUM 과 같아야 한다 — 이 값이면 UTM 스크립트가 최초유입을 업체로 덮어쓴다 + */ export const PARTNER_UTM_MEDIUM = 'partner'; /** diff --git a/imweb/README.md b/imweb/README.md index 38bb4b3..1a9553e 100644 --- a/imweb/README.md +++ b/imweb/README.md @@ -99,6 +99,9 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls | 가입 경로 | 그 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외) | naver | 다른 기기에서 신청하면 신청 때 유입은 direct 로 남는다. +- **업체 링크는 첫 경로보다 우선한다** — 업체 링크(`utm_medium=partner`)로 들어오면 보관 중인 첫 경로를 + 그 업체로 바꾼다. 예) 어제 광고로 들어와 가입 → 오늘 업체 링크로 신청: 가입 경로 광고, 신청 때 유입 업체. + 여러 업체 링크를 거치면 마지막 업체가 남는다 (업체 실적의 마지막 클릭 규칙과 같다) - 백엔드는 아임웹에 실제로 있는 회원인지 확인하고(동기화 전 신규 가입자는 아임웹 API 로 직접 확인), 회원·상품당 한 건만 남긴다. 이름·연락처는 브라우저 값이 아니라 아임웹 회원 정보를 쓴다 - 오픈카톡 버튼 클릭은 `POST /api/applications/{id}/kakao-clicks` 로 처음 한 번만 남는다 @@ -145,7 +148,11 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls - **구매 수**: 결제완료 주문만 센다. 전액 환불되거나 해당 상품 품목이 모두 취소·반품되면 빠진다. 금액은 관리하지 않는다 - **링크는 지우지 않고 중지**한다 — 중지해도 상품 페이지로 이동은 되지만 클릭·실적은 쌓이지 않는다. 링크가 있는 업체는 삭제할 수 없다 (실적 기록 보존) -- 업체 링크로 온 방문은 UTM 스크립트가 업체 UTM 으로 기록하므로, 신청 때 유입·가입 경로 집계에도 업체 이름이 나온다 +- 업체 링크로 온 방문은 UTM 스크립트가 업체 UTM 으로 기록하므로, 신청 때 유입·가입 경로 집계에도 업체 이름이 나온다. + 먼저 광고 등으로 들어와 첫 경로가 남아 있어도 업체로 덮어쓴다 (Header Code v1.2) + - 단, 관리자가 이동 주소에 직접 UTM 을 넣은 링크는 `utm_medium=partner` 가 아니므로 덮어쓰지 않는다 + - 업체 실적은 **그 상품의** 링크만 세지만 유입은 상품을 가리지 않는다 — + A 상품 업체 링크로 들어와 B 상품을 신청하면 신청 때 유입은 업체, 업체 칸은 비어 있다 - 이동 주소는 아임웹 사이트(www 유무 무관) 안만 허용한다 — 단축 링크가 피싱에 쓰이지 않게. 사이트 주소는 `IMWEB_SITE_URL`, 비우면 `IMWEB_ADMIN_PAGE_URL` 의 origin - 한계 diff --git a/imweb/imweb-header-top.html b/imweb/imweb-header-top.html index 7d6375f..7d96044 100644 --- a/imweb/imweb-header-top.html +++ b/imweb/imweb-header-top.html @@ -28,7 +28,8 @@ + \ No newline at end of file diff --git a/imweb/imweb-members.html b/imweb/imweb-members.html index cfc89f0..08f3f26 100644 --- a/imweb/imweb-members.html +++ b/imweb/imweb-members.html @@ -476,10 +476,10 @@
-

신청 유입 상위

- 최근 30일 · 신청 때 유입 기준 +

신청·구매 유입 상위

+ 최근 30일 · 신청·구매 때 유입 기준 - +
불러오는 중…
@@ -576,7 +576,7 @@
- +
@@ -597,7 +597,7 @@ 일시구분이름연락처상품업체카톡 버튼 - 신청 때 유입가입 경로 + 신청·구매 때 유입가입 경로 @@ -1524,7 +1524,7 @@ $('[data-role="ov-app-bars"]').addEventListener('click', function (e) { var bar = closest(e.target, '.mgm-bar'); if (!bar || bar.disabled) return; - openApplications({ range: '30', type: 'application', inflowFilter: { basis: 'apply', field: 'source', value: bar.getAttribute('data-key') } }); + openApplications({ range: '30', type: 'all', inflowFilter: { basis: 'apply', field: 'source', value: bar.getAttribute('data-key') } }); }); $('[data-role="ov-join-bars"]').addEventListener('click', function (e) { var bar = closest(e.target, '.mgm-bar'); @@ -1542,7 +1542,7 @@ /* ===================== 신청·구매 내역 ===================== */ var appForm = $('[data-role="app-filter"]'); - var APP_BASIS_LABEL = { apply: '신청 때 유입', join: '가입 경로' }; + var APP_BASIS_LABEL = { apply: '신청·구매 때 유입', join: '가입 경로' }; var APP_GROUP_LABEL = { source: '유입원', medium: '매체', campaign: '캠페인', content: '콘텐츠' }; var APP_TYPE_LABEL = { application: '무료 신청', purchase: '유료 구매' }; @@ -1639,15 +1639,9 @@ } function countCell(n) { return n ? '' + num(n) + '' : '0'; } - // 유입 집계 — 구매에는 '신청 때 유입'이 없으므로 유료 구매만 볼 때는 가입 경로로 고정한다 + // 유입 집계 기준 버튼 — 신청·구매 모두 두 기준으로 볼 수 있다 function syncBasisButtons() { - var app = state.app, onlyPurchase = app.type === 'purchase'; - if (onlyPurchase && app.basis === 'apply') app.basis = 'join'; - each($all('[data-app-basis]'), function (b) { - var isApply = b.getAttribute('data-app-basis') === 'apply'; - b.disabled = onlyPurchase && isApply; - }); - setSeg('[data-app-basis]', 'data-app-basis', app.basis); + setSeg('[data-app-basis]', 'data-app-basis', state.app.basis); } function loadAppStats() { @@ -1662,9 +1656,8 @@ $('[data-role="app-stats-bars"]').innerHTML = barsHtml(s.items.slice(0, 10), true, selected, '건'); var counted = []; if (app.type !== 'purchase') counted.push('무료 신청 ' + num(s.applicationTotal) + '건'); - if (s.basis === 'join' && app.type !== 'application') counted.push('유료 구매 ' + num(s.purchaseTotal) + '건'); - $('[data-role="app-stats-desc"]').textContent = counted.join(' · ') + ' · ' + APP_BASIS_LABEL[s.basis] + ' 기준' + - (s.basis === 'apply' && app.type === 'all' ? ' (구매는 가입 경로로만 볼 수 있습니다)' : '') + ' · 막대를 누르면 목록을 거릅니다'; + if (app.type !== 'application') counted.push('유료 구매 ' + num(s.purchaseTotal) + '건'); + $('[data-role="app-stats-desc"]').textContent = counted.join(' · ') + ' · ' + APP_BASIS_LABEL[s.basis] + ' 기준 · 막대를 누르면 목록을 거릅니다'; }).catch(function (err) { if (seq === app.statsSeq) errorNotice(err); }); } @@ -1695,7 +1688,7 @@ '' + (isApp && a.kakaoClickedAt ? '클릭' : '-') + '' + - '' + (isApp ? inflowText(a.inflow) : '-') + '' + + '' + inflowText(a.inflow) + '' + '' + (!a.member && !a.memberUid ? '비회원' : joinInflowCell(a.member)) + '' + ''; } @@ -1773,6 +1766,12 @@ ['연락처', dash(phone(o.phone))], ['회원 여부', isGuest ? '비회원 주문' : '회원 주문'] ]) + '' + + '

구매 때 유입

' + (a.inflow && a.inflow.source ? dlHtml([ + ['유입원', esc(a.inflow.source)], + ['매체', esc(a.inflow.medium)], + ['캠페인', esc(a.inflow.campaign)], + ['콘텐츠', esc(a.inflow.content)] + ]) : '

기록 없음 — 주문완료 화면 연동 전 주문이거나, 다른 기기·브라우저에서 결제한 경우입니다.

') + '
' + (isGuest ? '' : memberSectionsHtml(a.member)); } openDrawer((activityName(a) || '알 수 없음') + ' 님의 ' + APP_TYPE_LABEL[a.type], html); @@ -1796,9 +1795,6 @@ var type = b.getAttribute('data-app-type'); if (type === state.app.type) return; state.app.type = type; - // 구매에는 신청 때 유입이 없으므로 그 기준의 유입 필터는 풀어 준다 - var inf = state.app.inflowFilter; - if (type === 'purchase' && inf && inf.basis === 'apply') state.app.inflowFilter = null; setSeg('[data-app-type]', 'data-app-type', type); loadApplications(true); }); @@ -2028,7 +2024,9 @@ '' + (PTN_CONV_TYPE[c.type] || esc(c.type)) + (c.isValid ? '' : ' 환불·취소') + (c.orderNo ? '주문 ' + esc(c.orderNo) + '' : '') + '' + - '' + (m ? personCell(m.name, m.uid) : personCell(c.memberCode, '회원 정보 없음')) + '' + + '' + (m ? personCell(m.name, m.uid) + : c.memberCode ? personCell(c.memberCode, '회원 정보 없음') + : personCell(c.ordererName, '비회원 구매')) + '' + '' + dash(phone(m && m.callnum)) + '' + '' + esc(kstShort(c.touchedAt)) + '' + ''; From bf891d2177696130c00db8f378a8d7bd82b5198e Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 6 Oct 2026 13:14:42 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=EA=B5=AC=EB=A7=A4=20=EB=95=8C=20?= =?UTF-8?q?=EC=9C=A0=EC=9E=85=C2=B7=EB=B9=84=ED=9A=8C=EC=9B=90=20=EC=97=85?= =?UTF-8?q?=EC=B2=B4=20=EC=8B=A4=EC=A0=81=20=EA=B5=AC=ED=98=84=20=ED=98=84?= =?UTF-8?q?=ED=99=A9=EA=B3=BC=20=EB=B0=B0=ED=8F=AC=20=EC=88=9C=EC=84=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/purchase-tracking.md | 132 ++++++++++++++++++++++---------------- 1 file changed, 78 insertions(+), 54 deletions(-) diff --git a/docs/purchase-tracking.md b/docs/purchase-tracking.md index cb3ad3a..f473585 100644 --- a/docs/purchase-tracking.md +++ b/docs/purchase-tracking.md @@ -1,37 +1,37 @@ -# 유료 구매 추적 — 현황과 다음 작업 +# 유료 구매 추적 — 현황과 배포 > 작성: 2026-10-06 · 다음 작업을 이어갈 때 먼저 읽는 문서. -> 운영 사용 안내는 [`imweb/README.md`](../imweb/README.md) 의 "신청·구매 내역" · "유료 구매 기록" · "업체 링크" 절. +> 운영 사용 안내는 [`imweb/README.md`](../imweb/README.md) 의 "신청·구매 내역" · "유료 구매 기록" · "구매 때 유입" · "업체 링크" 절. ## 0. 한눈에 | 항목 | 상태 | | --- | --- | -| 유료 구매 목록 (회원·비회원) | **구현 완료 · 운영 미배포** | -| 신청·구매 통합 화면 | **구현 완료 · 운영 미배포** | +| 유료 구매 목록 (회원·비회원) | 구현 완료 · `main` 머지 · **운영 미배포** | +| 신청·구매 통합 화면 | 구현 완료 · `main` 머지 · **운영 미배포** | | 업체 링크 구매 실적 (회원) | 운영 중 (이전 배포분) | -| 구매 때 유입경로 | **미구현** — 다음 작업 (4절) | -| 비회원 구매의 업체 실적 | **미구현** — 다음 작업 (4절) | +| 업체 링크 우선 (신청·구매 때 유입) | **구현 완료 · 푸시·머지 전** (`feat/purchase-inflow`, 4.5절) | +| 구매 때 유입경로 | **구현 완료 · 푸시·머지 전** (`feat/purchase-inflow`, 4절) | +| 비회원 구매의 업체 실적 | **구현 완료 · 푸시·머지 전** (`feat/purchase-inflow`, 4절) | -브랜치: `feat/purchase-history` (커밋 `ab7ef37`, 푸시·머지 전). -`fix/admin-ui-tweaks` 에서 따왔으므로 그 브랜치의 미푸시 커밋 3개(카톡 버튼 문구·링크 칸 줄 맞춤·직전 유입 표시 제거)를 함께 담고 있다. +`feat/purchase-history` 는 `main` 에 머지·푸시됐다 (`9e6cae7`). 구매 때 유입 작업은 그 위의 +`feat/purchase-inflow` 브랜치에 있다 (2026-10-06 기준 푸시·머지 전). --- ## 1. 지금 구매에 대해 알 수 있는 것 아임웹 주문 API(`GET /orders`)에는 **손님이 어디서 왔는지(UTM)가 없다.** -무료 신청은 신청 완료 화면이 그 순간의 유입을 보내지만, 구매에는 그런 화면 연동이 아직 없다. +그래서 무료 신청처럼 주문완료 화면이 그 순간의 유입을 보내고, 주문 동기화가 주문번호로 잇는다 (4절). | 항목 | 알 수 있나 | 근거 · 조건 | | --- | --- | --- | | 누가 샀는지 | O | 회원: 회원 정보 / 비회원: 주문서의 주문자 이름·이메일·연락처 | | 무엇을·언제 | O | 주문·상품당 한 줄, 주문 시각(wtime) | | 환불·취소 여부 | O | 최근 60일 주문만 다시 봐서 반영 | -| 업체 링크로 왔는지 | 일부 | **회원만.** 링크를 누른 브라우저에서 로그인 → 30일 안에 같은 상품 구매 | +| 업체 링크로 왔는지 | O | 회원: 링크를 누른 브라우저에서 로그인 / 비회원: 주문완료 화면이 보낸 클릭 ID. 30일 안에 같은 상품 구매 | | 가입 경로 | 회원만 | 가입 때 경로라 이번 구매 경로와 다를 수 있다 | -| 구매 때 유입경로 | **X** | 주문 데이터에 없다 | -| 비회원 구매의 업체 | **X** | 회원 코드가 없어 클릭과 이을 수 없다 | +| 구매 때 유입경로 | O (연동 후 주문) | 주문완료 화면이 보낸 값. 다른 기기에서 결제·스크립트 차단이면 없음 | --- @@ -90,7 +90,7 @@ | `GET /api/admin/activities/groups` | 상품별·업체별 신청·구매 수 | `by`(product·partner), `from`/`to` | | `GET /api/admin/activities/inflow-stats` | 유입 집계 | `basis`(apply·join), `groupBy`, 목록 필터와 동일 | -- `basis=apply`(신청 때 유입)는 **무료 신청만** 센다. 구매는 `join`(가입 경로)으로만 집계된다 — 비회원은 값 없음. +- `basis=apply` 는 처음엔 무료 신청만 셌으나, 4절 이후 **신청·구매 때 유입**(구매 포함)이다. `join` 은 가입 경로 — 비회원은 값 없음. - 구매 수는 `is_valid` 만, 환불·취소는 `cancelledCount` 로 따로. - **예전 `/api/admin/applications` (목록·groups·inflow-stats·products) 는 삭제했다.** 위젯과 백엔드를 함께 배포해야 한다. - 무료 신청 수집(`POST /api/applications`, 카톡 클릭)은 그대로다. `inflow_last_*` 는 저장만 하고 화면에서 쓰지 않는다. @@ -99,7 +99,7 @@ - 메뉴 "신청 내역" → **"신청·구매"**. 종류 [전체·무료 신청·유료 구매], 상품별/업체별 표에 무료 신청·카톡 버튼 클릭·유료 구매(환불 N) 칸. - 목록: 구분 배지(무료 신청 / 유료 구매 / 환불·취소), 비회원은 주문자 이름 + "비회원". 상세 패널에 주문번호·상태·주문자. -- 유료 구매만 볼 때 유입 집계는 가입 경로로 고정(신청 때 유입 버튼 비활성). +- (4절 이전) 유료 구매만 볼 때 유입 집계는 가입 경로로 고정했다 — 4절에서 풀었다. - 개요: "최근 7일 무료 신청" · "최근 7일 유료 구매" 카드, "최근 신청·구매" 목록. ### 2.6 파일 @@ -127,61 +127,83 @@ imweb/imweb-members.html · imweb/README.md ## 3. 배포 순서 (아직 안 함) -1. `feat/purchase-history` 머지 → 운영 서버에서 `docker compose --env-file .env.production up -d --build` - (마이그레이션 자동 실행, 에러 코드 변경 없음) -2. **바로 이어서** `imweb/imweb-members.html` 을 아임웹 관리자 페이지 코드에 다시 붙여 넣기 - (예전 조회 경로를 지웠으므로 한쪽만 바뀌면 RES_001 "찾을 수 없음") +`feat/purchase-history`(main 머지 완료)와 4절 작업을 **한 번에** 배포한다고 보고 적었다. + +1. 4절 브랜치 머지 → 운영 서버에서 `docker compose --env-file .env.production up -d --build` + (마이그레이션 `AddPurchases` · `AddOrderInflows` 자동 실행, 에러 코드 변경 없음) +2. **바로 이어서** 아임웹에 두 파일을 다시 붙여 넣는다 + - `imweb/imweb-members.html` → 관리자 페이지 코드 (예전 조회 경로를 지웠으므로 한쪽만 바뀌면 RES_001 "찾을 수 없음") + - `imweb/imweb-header-top.html` → Header Code (업체 링크 우선 v1.2 · 결제창 리퍼러 제외 v1.3 · 구매 때 유입 전송) 3. 업체 링크 메뉴의 **구매 집계 새로고침** → 1년치 주문 동기화 → 신청·구매 화면에 구매가 보이는지 확인 4. 비회원 구매 한 건을 열어 **주문자 이름·연락처가 실제로 채워지는지** 확인 (5절 첫 항목) +5. 테스트 주문 한 건 → 주문완료 화면 → 다음 동기화(또는 새로고침) 후 그 구매의 상세 패널에 **구매 때 유입**이 보이는지 확인 + (브라우저 개발자 도구 네트워크 탭에서 `order-inflows` 가 204 인지도 볼 수 있다) --- -## 4. 다음 작업 — 주문완료 화면에서 구매 때 유입·업체 잡기 +## 4. 구매 때 유입·비회원 업체 실적 (`feat/purchase-inflow`, 푸시·머지 전) -### 4.1 목표 - -- 구매 때 유입(UTM)을 남겨 신청 때 유입처럼 집계한다. -- **비회원 구매도 업체 실적으로 잡는다** — 회원 대신 주문번호로 잇는다. - -### 4.2 방식 (추천안) - -무료 신청 완료 위젯과 같은 방식이다. +### 4.1 흐름 ``` -주문완료 화면 ── 스크립트가 { 주문번호, MG_UTM.peek().value, MG_PARTNER.refs() } 를 보낸다 - │ POST /api/order-inflows (공개 · 엄격한 rate limit · 이름은 api-design 규칙에 맞춰 확정) +주문완료 화면 (/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 유일, inflow raw/파싱값, partner refs, received_at - │ 주문 동기화가 같은 order_no 의 구매를 가져올 때 매칭 - ▼ -purchases 에 구매 때 유입 컬럼(가칭 inflow_source 등) 채움 -partner_conversions: 회원·비회원 모두 refs 로 귀속 (지금은 회원 touch 로만) +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 를 지운다 ``` -- 스크립트 위치: Header Code(`imweb-header-top.html`)가 주문완료 페이지를 감지하거나, 주문완료 페이지 전용 코드 위젯. - UTM·업체 클릭 ID 는 이미 Header Code 가 보관하므로 읽기만 하면 된다. -- 위조 대응: 공개 API 라 막을 수는 없다. **아임웹에 실제로 있는 주문번호에만** 붙인다(동기화 시점 매칭이라 자연히 걸러짐). - 같은 주문번호에 두 번 오면 첫 값만, 오래 매칭되지 않은 행은 정리한다. -- 무통장입금: 주문완료 시점에 받아두고, 입금 확인 후 구매로 잡힐 때 붙는다. -- 귀속 규칙 변경 검토: 주문완료에서 온 refs 를 회원 touch 보다 우선할지, 30일 기준을 그대로 쓸지. -- 화면: `inflow-stats` 의 `basis=apply` 를 "신청·구매 때 유입"으로 넓히고, 목록의 "신청 때 유입" 칸에 구매도 표시. +- 주문완료 주소의 `order_no` 는 아임웹 API `orderNo` 와 같다 (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.3 시작 전에 확인할 것 +### 4.2 파일 -- [ ] **주문완료 페이지 주소와 주문번호 위치** — 주소창 파라미터인지 화면 DOM 인지. 테스트 주문 한 번으로 확인. - (아임웹 API 의 `orderNo` 와 같은 값인지도 확인) -- [ ] UTM 보관 기간 — 지금 `TTL_DAYS: 2` (`imweb-header-top.html` 49행). 광고 후 며칠 뒤 사는 경우가 많으면 늘릴지 결정. - 늘리면 무료 신청의 신청 때 유입에도 같이 영향 -- [ ] 비회원 구매 비중 — 많을수록 이 작업의 효과가 크다 (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.4 작업 체크리스트 +### 4.3 검증한 것 -- [ ] 엔티티·마이그레이션 (`order_inflows`, `purchases` 유입 컬럼) — 생성된 SQL 검토 -- [ ] 공개 엔드포인트 DTO 검증 + `@Throttle` (신청 기록처럼) -- [ ] 동기화 매칭 + 비회원 업체 귀속 + 단위 테스트 -- [ ] `activities` 유입 집계·목록 확장 -- [ ] 아임웹 스크립트 + `imweb/README.md` -- [ ] 필요하면 에러 코드 등록 → `npm run sync:error-codes` +- 백엔드 테스트 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). +광고로 가입 → 업체 링크로 신청·구매하면 가입 경로는 광고, 신청·구매 때 유입은 업체로 남는다. +여러 업체를 거치면 마지막 업체 — 업체 실적의 마지막 클릭 규칙과 같다. --- @@ -193,4 +215,6 @@ partner_conversions: 회원·비회원 모두 refs 로 귀속 (지금은 회원 결제 수단 `FREE` 주문을 뺄지 결정 (업체 실적 판정 `evaluateOrder` 도 같이 바꿔야 수가 맞는다). - 60일보다 오래된 주문의 환불은 반영되지 않는다 (서버 재시작 직후 1년치 실행 때만 다시 본다). - 휴대폰에서 링크·광고를 보고 PC 에서 사면 유입·업체가 이어지지 않는다 (무료 신청도 같다). +- 구매 때 유입은 공개 API 로 받으므로, 주문번호를 아는 사람이 먼저 가짜 값을 보내면 그 값이 남는다 + (실제 주문에만 붙고, 업체 실적은 실제로 있는 클릭만 인정한다). - 구매자의 가입 경로는 "이번 구매 경로"가 아니다 — 화면 문구에서 혼동하지 않게 유지.