diff --git a/imweb/README.md b/imweb/README.md index 1a9553e..b22bcc5 100644 --- a/imweb/README.md +++ b/imweb/README.md @@ -79,8 +79,9 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls - 종류 [전체 · 무료 신청 · 유료 구매] 로 목록을 거르고, 위쪽 표에서 상품별·업체별 신청 수·구매 수를 함께 본다 - 구매 수는 결제완료만 센다 — 환불·취소된 구매는 목록에 '환불·취소'로 남고, 표에는 '환불 N'으로 따로 적는다 -- 유입 집계: '신청 때 유입'은 무료 신청에만 있다 (구매 시점 유입은 아임웹 주문에 남지 않는다). - 구매는 '가입 경로'(회원이 가입할 때 기록된 경로)로만 볼 수 있고, 비회원 구매는 값이 없다 +- 유입 집계: '신청·구매 때 유입'(신청 완료·주문완료 화면이 남긴 그 순간의 경로)과 + '가입 경로'(회원이 가입할 때 기록된 경로) 두 기준으로 본다. + 주문완료 화면 연동 전 구매는 구매 때 유입이 없고, 비회원 구매는 가입 경로가 없다 ## 강의 신청 기록 (신청 완료 화면 → 신청·구매 내역) @@ -120,7 +121,29 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls - 서버가 뜬 뒤 첫 실행은 **최근 1년**, 그 뒤로는 **최근 60일**만 다시 본다 (60일보다 오래된 주문의 환불은 반영되지 않는다) - 사은품 품목은 넣지 않는다. 0원 주문도 결제완료면 구매로 남는다 - 금액은 관리하지 않는다 -- 구매 때 유입경로는 아직 없다 — 현황·다음 작업은 [`docs/purchase-tracking.md`](../docs/purchase-tracking.md) +- 구매 때 유입은 아래 '구매 때 유입' 절 — 현황·한계는 [`docs/purchase-tracking.md`](../docs/purchase-tracking.md) + +## 구매 때 유입 (주문완료 화면 → 구매 기록) + +아임웹 주문에는 손님이 어디서 왔는지가 없어서, Header Code(`imweb-header-top.html` 의 '구매 때 유입 전송')가 +주문완료 화면(`/shop_payment_complete?order_no=주문번호`)에서 `POST /api/order-inflows` 로 보낸다. + +``` +주문완료 화면 ── { orderNo, inflow: MG_UTM.peek().value, partnerRefs: MG_PARTNER.refs() } + │ POST /api/order-inflows (공개 · 1분 10회 · 항상 204 · 같은 주문번호는 처음 값만) + ▼ +order_inflows ── 주문 동기화(1시간마다)가 같은 주문번호의 구매에 붙인다 + ├─▶ purchases.inflow_* 구매 때 유입 (비어 있는 구매에만 — 한 번 붙으면 바뀌지 않는다) + └─▶ partner_conversions 업체 실적 — 회원 클릭 연결 + 주문완료 클릭 ID 중 마지막 클릭 (비회원도) +``` + +- 주소의 `order_no` 가 아임웹 API 의 `orderNo` 와 같은 값이다 (관리자 화면에서 확인함) +- 유입 기준은 무료 신청과 같다 — 처음 들어온 경로(2일 보관), 단 업체 링크로 오면 그 업체가 우선 +- 결제창(카카오페이·네이버페이·카드사)에서 돌아올 때의 리퍼러는 유입으로 치지 않는다 (`/shop_payment*` 경로 제외, v1.3) +- 무통장입금은 주문완료 때 받아 두었다가 입금이 확인돼 구매로 잡힐 때 붙는다 +- 90일 동안 주문과 이어지지 않은 값(없는 주문번호·끝내 결제되지 않은 주문)은 동기화가 지운다 +- 한계: 다른 기기에서 결제하거나 스크립트가 막힌 브라우저면 남지 않는다. 공개 API 라 주문번호를 아는 사람은 + 먼저 가짜 값을 보낼 수 있다 (실제 주문에만 붙고, 업체 실적은 실제로 있는 클릭만 인정한다) ## 업체 링크 (업체별 신청·구매 추적) @@ -141,7 +164,7 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls │ ② 로그인 회원이 확인되면 "이 회원이 이 클릭으로 들어왔다"를 POST /api/partner-touches 로 알린다 ▼ 신청: 신청 완료 위젯이 신청 기록에 클릭 ID 를 함께 보낸다 → 바로 귀속 -구매: 구매 기록과 같은 주문 동기화(1시간마다)가 회원 주문의 결제완료 상품을 귀속, 환불·취소는 집계에서 뺀다 +구매: 구매 기록과 같은 주문 동기화(1시간마다)가 결제완료 상품을 귀속 (회원은 클릭 연결, 비회원은 주문완료 화면의 클릭 ID), 환불·취소는 집계에서 뺀다 ``` - **귀속 규칙**: 신청·구매 직전 30일 안에 누른 **그 상품의** 업체 링크 중 마지막 것 (마지막 클릭) @@ -157,7 +180,7 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls 사이트 주소는 `IMWEB_SITE_URL`, 비우면 `IMWEB_ADMIN_PAGE_URL` 의 origin - 한계 - 링크를 누른 브라우저와 신청·구매한 브라우저가 다르면(휴대폰 → PC) 추적되지 않는다 - - 비회원 주문은 회원과 이을 수 없어 세지 않는다 + - 비회원 주문은 주문완료 화면이 클릭 ID 를 보냈을 때만 센다 (위 '구매 때 유입') - 공개 페이지에서 오는 기록이라 위조를 완전히 막을 수는 없다 (실제 아임웹 회원·실제 클릭인지만 확인) - 60일보다 오래된 구매의 환불은 반영되지 않는다 diff --git a/imweb/imweb-header-top.html b/imweb/imweb-header-top.html index 7d96044..1cd62be 100644 --- a/imweb/imweb-header-top.html +++ b/imweb/imweb-header-top.html @@ -28,8 +28,9 @@ + \ 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)) + '' + '';