generated from ttipo/project-base
feat(imweb): 주문완료 화면에서 구매 때 유입 전송·관리자 화면에 표시
- Header Code '구매 때 유입 전송': /shop_payment_complete 에서 order_no·유입·업체 클릭 ID 를 POST /api/order-inflows 로 한 번 보낸다 (같은 탭 새로고침 때는 다시 보내지 않는다) - 결제 페이지(/shop_payment*)에서는 리퍼러를 유입으로 기록하지 않는다 (v1.3) — 결제창에서 돌아올 때 pay.kakao.com 이 '카카오 소셜', pay.naver.com 이 '네이버 검색' 유입으로 잡혀 구매 때 유입을 가릴 수 있었다 - 관리자 화면: 유입 기준을 '신청·구매 때 유입'으로, 구매 행·상세 패널에 구매 때 유입, 업체 실적 상세의 비회원 구매는 주문자 이름으로 보여준다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+28
-5
@@ -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일보다 오래된 구매의 환불은 반영되지 않는다
|
||||
|
||||
|
||||
@@ -28,8 +28,9 @@
|
||||
</script>
|
||||
|
||||
<!-- ============================================================
|
||||
[자본주의가이드] UTM 기반 회원가입 유입경로 자동추적 v1.2
|
||||
[자본주의가이드] UTM 기반 회원가입 유입경로 자동추적 v1.3
|
||||
v1.2: 업체 링크(utm_medium=partner) 방문은 보관 중인 최초유입을 덮어쓴다
|
||||
v1.3: 결제 페이지(/shop_payment*)에서는 결제창 리퍼러를 유입으로 기록하지 않는다
|
||||
설치 위치: 아임웹 관리자 > 환경설정 > SEO(검색엔진최적화)
|
||||
> 고급 > 공통 코드 삽입 > "Header Code"
|
||||
대상 필드: 회원가입 사용자 정의 항목 "유입경로(필수)" = join_form_64454
|
||||
@@ -160,11 +161,14 @@
|
||||
[/(^|\.)(tistory|brunch|velog|medium)\./i, 'blog', 'referral']
|
||||
];
|
||||
|
||||
/* 인증·가입 관련 페이지.
|
||||
/* 인증·가입·결제 관련 페이지.
|
||||
카카오/네이버 소셜 로그인은 외부 인증 도메인을 왕복하는데, 돌아올 때의
|
||||
리퍼러(kauth.kakao.com 등)를 새 유입으로 오해하면 원래 유입원이 가려진다.
|
||||
결제도 같다 — 결제창(카카오페이 pay.kakao.com, 네이버페이 pay.naver.com, 카드사 등)에서
|
||||
주문완료(/shop_payment_complete)로 돌아올 때의 리퍼러가 '카카오 소셜'·'네이버 검색'
|
||||
유입으로 잡혀 구매 때 유입을 가린다.
|
||||
이 경로들에서는 URL에 UTM이 직접 있을 때만 기록한다. */
|
||||
var AUTH_PATH = /^\/(oauth|site_join|login|logout|member|social|join)/i;
|
||||
var AUTH_PATH = /^\/(oauth|site_join|login|logout|member|social|join|shop_payment)/i;
|
||||
|
||||
/* 소셜 로그인 인증 도메인.
|
||||
위 AUTH_PATH는 '인증 페이지에 있을 때'를 막지만, 인증을 마치고
|
||||
@@ -656,4 +660,59 @@
|
||||
window.setTimeout(poll, POLL_MS);
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- ============================================================
|
||||
[자본주의가이드] 구매 때 유입 전송 v1.0
|
||||
위 두 스크립트(MG_UTM · MG_PARTNER) 뒤에 있어야 한다.
|
||||
|
||||
주문완료 화면(/shop_payment_complete?order_no=주문번호)에서 주문번호와
|
||||
그 순간의 유입정보(MG_UTM.peek().value), 업체 링크 클릭 ID(MG_PARTNER.refs())를
|
||||
서버에 한 번 보낸다.
|
||||
→ 서버가 아임웹 주문 동기화(1시간마다) 때 같은 주문번호의 구매에 붙인다.
|
||||
구매 때 유입이 남고, 비회원 구매도 업체 링크 실적으로 잡힌다.
|
||||
무통장입금은 입금이 확인돼 구매로 잡힐 때 붙는다.
|
||||
같은 주문번호는 서버가 처음 값만 남기므로 새로고침해도 안전하다.
|
||||
============================================================ -->
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var API_BASE = 'https://moneyguide.wandergo.net/api'; // 위 업체 링크 추적과 같은 값
|
||||
var COMPLETE_PATH = /^\/shop_payment_complete\/?$/; // 아임웹 주문완료 페이지
|
||||
var ORDER_PARAM = 'order_no'; // 아임웹 API 의 orderNo 와 같은 값
|
||||
var ORDER_NO_PATTERN = /^\d{1,30}$/; // 백엔드 ORDER_NO_PATTERN 과 같아야 한다
|
||||
var INFLOW_MAX = 1000;
|
||||
var K_SENT = 'mg_order_sent'; // 이 탭에서 이미 보낸 주문번호 (새로고침 때 다시 보내지 않게)
|
||||
|
||||
function ssGet(k) { try { return sessionStorage.getItem(k); } catch (e) { return null; } }
|
||||
function ssSet(k, v) { try { sessionStorage.setItem(k, v); } catch (e) { } }
|
||||
|
||||
try {
|
||||
if (!COMPLETE_PATH.test(location.pathname)) return;
|
||||
var orderNo = new URLSearchParams(location.search).get(ORDER_PARAM);
|
||||
if (!orderNo || !ORDER_NO_PATTERN.test(orderNo)) return;
|
||||
if (typeof window.fetch !== 'function' || ssGet(K_SENT) === orderNo) return;
|
||||
|
||||
var body = { orderNo: orderNo };
|
||||
try {
|
||||
var peeked = window.MG_UTM && window.MG_UTM.peek();
|
||||
var value = peeked && peeked.value;
|
||||
if (typeof value === 'string' && value) body.inflow = value.slice(0, INFLOW_MAX);
|
||||
} catch (e) { }
|
||||
try {
|
||||
var refs = window.MG_PARTNER && window.MG_PARTNER.refs();
|
||||
if (refs && refs.length) body.partnerRefs = refs;
|
||||
} catch (e) { }
|
||||
|
||||
window.fetch(API_BASE + '/order-inflows', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'omit',
|
||||
body: JSON.stringify(body)
|
||||
}).then(function (res) {
|
||||
if (res.ok) ssSet(K_SENT, orderNo); // 실패하면 새로고침 때 다시 보낸다
|
||||
}).catch(function () { });
|
||||
} catch (e) { }
|
||||
})();
|
||||
</script>
|
||||
+21
-23
@@ -476,10 +476,10 @@
|
||||
<div class="mgm-grid-2">
|
||||
<section class="mgm-card">
|
||||
<div class="mgm-card-head">
|
||||
<h3>신청 유입 상위</h3>
|
||||
<span class="mgm-muted">최근 30일 · 신청 때 유입 기준</span>
|
||||
<h3>신청·구매 유입 상위</h3>
|
||||
<span class="mgm-muted">최근 30일 · 신청·구매 때 유입 기준</span>
|
||||
<span class="mgm-spacer"></span>
|
||||
<button type="button" class="mgm-link" data-goto="applications" data-app-preset="30" data-app-kind="application">신청·구매 →</button>
|
||||
<button type="button" class="mgm-link" data-goto="applications" data-app-preset="30">신청·구매 →</button>
|
||||
</div>
|
||||
<div class="mgm-bars" data-role="ov-app-bars"><div class="mgm-muted">불러오는 중…</div></div>
|
||||
</section>
|
||||
@@ -576,7 +576,7 @@
|
||||
</div>
|
||||
<div class="mgm-row" style="margin-bottom:12px">
|
||||
<div class="mgm-seg" role="group" aria-label="유입 기준">
|
||||
<button type="button" data-app-basis="apply" class="is-active" title="신청 시점에 브라우저에 남아 있던 유입경로 (UTM 스크립트 2일 보관) — 무료 신청에만 있습니다">신청 때 유입</button>
|
||||
<button type="button" data-app-basis="apply" class="is-active" title="신청 완료·주문완료 화면이 남긴 그 순간의 유입경로 (UTM 스크립트 2일 보관, 업체 링크 우선) — 주문완료 화면 연동 전 구매는 값 없음">신청·구매 때 유입</button>
|
||||
<button type="button" data-app-basis="join" title="신청·구매한 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외, 비회원 구매는 값 없음)">가입 경로</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -597,7 +597,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>일시</th><th>구분</th><th>이름</th><th>연락처</th><th>상품</th><th>업체</th><th title="신청 완료 화면의 오픈카톡 버튼을 누른 수 — 실제 입장 여부는 카카오가 알려주지 않아 알 수 없습니다">카톡 버튼</th>
|
||||
<th title="신청 시점에 브라우저에 남아 있던 유입경로 (UTM 스크립트 2일 보관) — 구매에는 이 기록이 없습니다">신청 때 유입</th><th>가입 경로</th>
|
||||
<th title="신청 완료·주문완료 화면이 남긴 그 순간의 유입경로 (UTM 스크립트 2일 보관, 업체 링크 우선) — 주문완료 화면 연동 전 구매는 값 없음">신청·구매 때 유입</th><th>가입 경로</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-role="app-rows"></tbody>
|
||||
@@ -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 ? '<b>' + num(n) + '</b>' : '<span class="mgm-faint">0</span>'; }
|
||||
|
||||
// 유입 집계 — 구매에는 '신청 때 유입'이 없으므로 유료 구매만 볼 때는 가입 경로로 고정한다
|
||||
// 유입 집계 기준 버튼 — 신청·구매 모두 두 기준으로 볼 수 있다
|
||||
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 @@
|
||||
'<td>' + (isApp && a.kakaoClickedAt
|
||||
? '<span class="mgm-badge is-ok" title="' + esc(kstDateTime(a.kakaoClickedAt)) + ' 에 버튼을 눌렀습니다 (실제 입장 여부는 알 수 없음)">클릭</span>'
|
||||
: '<span class="mgm-faint">-</span>') + '</td>' +
|
||||
'<td class="mgm-wrap">' + (isApp ? inflowText(a.inflow) : '<span class="mgm-faint">-</span>') + '</td>' +
|
||||
'<td class="mgm-wrap">' + inflowText(a.inflow) + '</td>' +
|
||||
'<td class="mgm-wrap">' + (!a.member && !a.memberUid ? '<span class="mgm-faint">비회원</span>' : joinInflowCell(a.member)) + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
@@ -1773,6 +1766,12 @@
|
||||
['연락처', dash(phone(o.phone))],
|
||||
['회원 여부', isGuest ? '비회원 주문' : '회원 주문']
|
||||
]) + '</section>' +
|
||||
'<section class="mgm-dsec"><h4>구매 때 유입</h4>' + (a.inflow && a.inflow.source ? dlHtml([
|
||||
['유입원', esc(a.inflow.source)],
|
||||
['매체', esc(a.inflow.medium)],
|
||||
['캠페인', esc(a.inflow.campaign)],
|
||||
['콘텐츠', esc(a.inflow.content)]
|
||||
]) : '<p class="mgm-muted">기록 없음 — 주문완료 화면 연동 전 주문이거나, 다른 기기·브라우저에서 결제한 경우입니다.</p>') + '</section>' +
|
||||
(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 @@
|
||||
'<td>' + (PTN_CONV_TYPE[c.type] || esc(c.type)) +
|
||||
(c.isValid ? '' : ' <span class="mgm-badge is-danger" title="환불·취소되어 집계에서 빠졌습니다">환불·취소</span>') +
|
||||
(c.orderNo ? '<span class="mgm-cell-sub">주문 ' + esc(c.orderNo) + '</span>' : '') + '</td>' +
|
||||
'<td>' + (m ? personCell(m.name, m.uid) : personCell(c.memberCode, '회원 정보 없음')) + '</td>' +
|
||||
'<td>' + (m ? personCell(m.name, m.uid)
|
||||
: c.memberCode ? personCell(c.memberCode, '회원 정보 없음')
|
||||
: personCell(c.ordererName, '비회원 구매')) + '</td>' +
|
||||
'<td>' + dash(phone(m && m.callnum)) + '</td>' +
|
||||
'<td><span class="mgm-cell-sub" style="margin:0">' + esc(kstShort(c.touchedAt)) + '</span></td>' +
|
||||
'</tr>';
|
||||
|
||||
Reference in New Issue
Block a user