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:
2026-10-06 13:14:42 +09:00
parent 1f71ce048f
commit 5badf94dc7
3 changed files with 111 additions and 31 deletions
+28 -5
View File
@@ -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일보다 오래된 구매의 환불은 반영되지 않는다
+62 -3
View File
@@ -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
View File
@@ -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>';