feat: 신청 때 최종 유입 저장과 신청 유입 집계 추가
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled

가입 경로와 신청(수강) 유입을 구분해 보기 위해서다. 기존 신청 유입은 신청 시점
브라우저의 '최초' 유입(2일 보관)이라, 그 사이 다른 경로로 다시 와서 신청하면
신청을 일으킨 경로가 가려졌다.

- member_applications 에 최종 유입(inflow_last_*) 저장 — 없으면 최초 유입으로 채움 (기존 행도 동일)
- 신청 완료 위젯이 MG_UTM.peek().last 를 lastInflow 로 함께 보냄
- GET /api/admin/applications/inflow-stats: 신청 때 최종·최초 / 가입 경로(가입 때 기록만) 기준 집계
- 신청 목록에 inflowBasis·inflowField·inflowValue 필터
- 관리자 화면 신청 내역: '신청 유입 집계' 카드(기준·항목 전환, 막대 클릭으로 목록 필터),
  신청 때 유입 칸에 최종 유입 표시 + 최초가 다르면 함께 표시

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 00:22:02 +09:00
parent e91f7620a3
commit 38e945767b
11 changed files with 565 additions and 50 deletions
+12 -1
View File
@@ -77,7 +77,18 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
아임웹 `/apply` 페이지의 신청 완료 위젯(`imweb-free-apply.html`)이 표시될 때
`POST /api/applications` 로 신청을 남긴다. 관리자 화면 **신청 내역** 메뉴에서 본다.
- 보내는 값: `MEMBER_UID`(회원 아이디 또는 회원 코드), 상품번호(`idx`), 상품명, 그 순간의 유입정보(`MG_UTM.peek().value` — peek() 는 객체를 돌려준다), 업체 링크 클릭 ID(`MG_PARTNER.refs()`)
- 보내는 값: `MEMBER_UID`(회원 아이디 또는 회원 코드), 상품번호(`idx`), 상품명,
최초 유입(`MG_UTM.peek().value`), 최종 유입(`MG_UTM.peek().last` 를 같은 파이프 형식으로),
업체 링크 클릭 ID(`MG_PARTNER.refs()`) — `peek()` 는 문자열이 아니라 객체를 돌려준다
- **가입 경로와 신청 유입은 따로 남는다** — 관리자 화면 신청 내역의 '신청 유입 집계'에서 기준을 바꿔 비교한다
| 기준 | 값 | 예) 3주 전 네이버로 가입 → 어제 블로그로 방문 → 오늘 페이스북 광고로 와서 신청 |
| --- | --- | --- |
| 신청 때 (최종) | 신청 직전에 마지막으로 들어온 경로 — 신청을 일으킨 유입 | facebook |
| 신청 때 (최초) | 신청 시점 브라우저에 남아 있던 첫 경로 (UTM 스크립트 보관 2일) | blog |
| 가입 경로 | 그 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외) | naver |
유입이 한 번뿐이면 최초 = 최종이다. 다른 기기에서 신청하면 신청 유입은 direct 로 남는다.
- 백엔드는 아임웹에 실제로 있는 회원인지 확인하고(동기화 전 신규 가입자는 아임웹 API 로 직접 확인),
회원·상품당 한 건만 남긴다. 이름·연락처는 브라우저 값이 아니라 아임웹 회원 정보를 쓴다
- 오픈카톡 버튼 클릭은 `POST /api/applications/{id}/kakao-clicks` 로 처음 한 번만 남는다
+22
View File
@@ -446,6 +446,26 @@
} catch (e) { return ''; }
}
/* 신청 직전의 최종 유입 — MG_UTM.peek().last 객체를 위와 같은 파이프 형식으로 바꾼다.
최초 유입(위 value)은 2일간 처음 들어온 경로라, 그 사이 다른 경로로 다시 와서
신청하면 신청을 일으킨 경로는 last 에만 있다. 없으면 빈 값 (서버가 최초 유입으로 채운다) */
var LAST_KEYS = ['src', 'med', 'cmp', 'trm', 'cnt', 'cid', 'ref', 'lp', 'pid', 'ts'];
function readLastInflow() {
try {
var peeked = window.MG_UTM && typeof window.MG_UTM.peek === 'function'
? window.MG_UTM.peek() : null;
var last = peeked && typeof peeked === 'object' ? peeked.last : null;
if (!last || typeof last !== 'object' || !last.src) return '';
var parts = [];
for (var i = 0; i < LAST_KEYS.length; i++) {
var v = last[LAST_KEYS[i]];
/* UTM 스크립트가 이미 | = 를 지운 값이지만 한 번 더 막는다 */
if (v) parts.push(LAST_KEYS[i] + '=' + String(v).replace(/[|=]/g, ' '));
}
return parts.join('|').slice(0, INFLOW_MAX);
} catch (e) { return ''; }
}
/* imweb-header-top.html(업체 링크 추적)이 보관한 클릭 ID — 없으면 빈 배열 */
function readPartnerRefs() {
try {
@@ -465,6 +485,8 @@
if (name) body.productName = name.slice(0, PRODUCT_NAME_MAX);
var inflow = readInflow();
if (inflow) body.inflow = inflow;
var lastInflow = readLastInflow();
if (lastInflow) body.lastInflow = lastInflow;
var refs = readPartnerRefs();
if (refs.length) body.partnerRefs = refs;
+103 -4
View File
@@ -505,9 +505,30 @@
</div>
</form>
</section>
<section class="mgm-card" style="margin-bottom:16px">
<div class="mgm-card-head">
<h3>신청 유입 집계</h3>
<span class="mgm-muted" data-role="app-stats-desc">위 기간·상품 조건 · 막대를 누르면 아래 목록을 거릅니다</span>
<span class="mgm-spacer"></span>
<div class="mgm-seg" role="group" aria-label="유입 기준">
<button type="button" data-app-basis="apply_last" class="is-active" title="신청 직전에 마지막으로 들어온 경로 — 신청을 일으킨 유입">신청 때 (최종)</button>
<button type="button" data-app-basis="apply_first" title="신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)">신청 때 (최초)</button>
<button type="button" data-app-basis="join" title="신청한 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외)">가입 경로</button>
</div>
<div class="mgm-seg" role="group" aria-label="집계 항목">
<button type="button" data-app-group="source" class="is-active">유입원</button>
<button type="button" data-app-group="medium">매체</button>
<button type="button" data-app-group="campaign">캠페인</button>
<button type="button" data-app-group="content">콘텐츠</button>
</div>
</div>
<div class="mgm-bars" data-role="app-stats-bars"><div class="mgm-muted">불러오는 중…</div></div>
</section>
<section class="mgm-card">
<div class="mgm-card-head">
<h3>신청</h3>
<span data-role="app-inflow-filter" class="mgm-row" style="gap:6px"></span>
<span class="mgm-spacer"></span>
<span class="mgm-muted" data-role="app-total"></span>
</div>
<div class="mgm-table-wrap">
@@ -515,7 +536,7 @@
<thead>
<tr>
<th>신청일시</th><th>상품</th><th>이름</th><th>아이디 / 이메일</th><th>연락처</th>
<th>오픈카톡</th><th>신청 때 유입</th><th>가입 때 유입</th><th>회원 그룹</th>
<th>오픈카톡</th><th title="신청 직전 최종 유입 — 첫 경로와 다르면 아래에 '최초'로 함께 표시">신청 때 유입</th><th>가입 때 유입</th><th>회원 그룹</th>
</tr>
</thead>
<tbody data-role="app-rows"></tbody>
@@ -699,7 +720,11 @@
view: 'overview',
cursor: null,
listSeq: 0,
app: { range: 'all', product: '', cursor: null, loading: false, seq: 0, products: null, productsStale: true },
app: {
range: 'all', product: '', cursor: null, loading: false, seq: 0, products: null, productsStale: true,
// 신청 유입 집계 — basis: apply_last | apply_first | join, inflowFilter: 막대를 눌러 건 목록 필터
basis: 'apply_last', group: 'source', statsSeq: 0, inflowFilter: null
},
// 업체 링크 — partnerId 는 링크 목록 필터, conv 는 열어 둔 실적 상세
ptn: {
range: 'all', partnerId: '', cursor: null, loading: false, seq: 0,
@@ -1421,9 +1446,52 @@
var q = appForm.q.value.trim();
if (q) out.q = q;
if (state.app.product) out.productIdx = state.app.product;
var inf = state.app.inflowFilter;
if (inf) { out.inflowBasis = inf.basis; out.inflowField = inf.field; out.inflowValue = inf.value; }
return out;
}
/* 신청 유입 집계 — 같은 신청을 신청 때 유입 / 가입 경로로 나눠 센다 */
var APP_BASIS_LABEL = { apply_last: '신청 때(최종)', apply_first: '신청 때(최초)', join: '가입 경로' };
var APP_GROUP_LABEL = { source: '유입원', medium: '매체', campaign: '캠페인', content: '콘텐츠' };
var APP_STATS_TOP = 12;
function loadAppStats() {
var app = state.app;
var seq = ++app.statsSeq;
var q = { basis: app.basis, groupBy: app.group };
var f = appFilters(); // 기간·상품만 쓴다 (검색어·유입 필터는 목록 전용)
if (f.appliedFrom) q.appliedFrom = f.appliedFrom;
if (f.appliedTo) q.appliedTo = f.appliedTo;
if (f.productIdx) q.productIdx = f.productIdx;
api('GET', '/admin/applications/inflow-stats' + toQuery(q)).then(function (s) {
if (seq !== app.statsSeq) return;
$('[data-role="app-stats-bars"]').innerHTML = barsHtml(s.items.slice(0, APP_STATS_TOP), true);
$('[data-role="app-stats-desc"]').textContent = '신청 ' + num(s.total) + '건 · ' +
APP_BASIS_LABEL[s.basis] + ' 기준 · 막대를 누르면 아래 목록을 거릅니다';
}).catch(function (err) { if (seq === app.statsSeq) errorNotice(err); });
}
function renderAppInflowFilter() {
var inf = state.app.inflowFilter;
$('[data-role="app-inflow-filter"]').innerHTML = inf
? '<span class="mgm-active-chip"><b>' + esc(APP_BASIS_LABEL[inf.basis] + ' ' + APP_GROUP_LABEL[inf.field]) + '</b> ' +
esc(inf.value) + '<button type="button" data-app-clear-inflow aria-label="유입 필터 지우기">×</button></span>'
: '';
}
// 신청 때 유입 칸 — 최종 유입을 보여주고, 첫 경로가 다르면 '최초'로 함께 적는다
function applyInflowCell(a) {
var first = a.inflow, last = a.lastInflow && a.lastInflow.source ? a.lastInflow : first;
var html = inflowText(last);
if (first && first.source && last && last.source &&
(first.source !== last.source || first.medium !== last.medium || first.campaign !== last.campaign)) {
html += '<br><span class="mgm-muted" title="신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)">최초: ' +
esc(first.source) + (first.medium ? ' / ' + esc(first.medium) : '') + '</span>';
}
return html;
}
function productLabel(p) {
return (p.productName || '상품') + ' #' + p.productIdx;
}
@@ -1473,7 +1541,7 @@
'<td>' + (a.kakaoClickedAt
? '<span class="mgm-badge is-ok" title="' + esc(kstDateTime(a.kakaoClickedAt)) + '">눌렀음</span>'
: '<span class="mgm-muted">-</span>') + '</td>' +
'<td class="mgm-wrap">' + inflowText(a.inflow) + '</td>' +
'<td class="mgm-wrap">' + applyInflowCell(a) + '</td>' +
'<td class="mgm-wrap">' + inflowText(m && m.inflow) + '</td>' +
'<td class="mgm-group-cell">' + (m && m.groups.length
? m.groups.map(function (g) { return '<span class="mgm-group-badge">' + esc(g.title) + '</span>'; }).join('')
@@ -1487,7 +1555,7 @@
if (!reset && app.loading) return;
var seq = ++app.seq;
app.loading = true;
if (reset) { app.cursor = null; loadAppProducts(); }
if (reset) { app.cursor = null; loadAppProducts(); loadAppStats(); renderAppInflowFilter(); }
var q = appFilters();
q.limit = PAGE_SIZE;
@@ -1534,6 +1602,37 @@
loadApplications(true);
});
// 집계 기준·항목 바꾸기 — 집계만 다시 센다 (목록 필터는 막대를 누를 때 바뀐다)
each($all('[data-app-basis]'), function (b) {
b.addEventListener('click', function () {
state.app.basis = b.getAttribute('data-app-basis');
each($all('[data-app-basis]'), function (x) { x.classList.toggle('is-active', x === b); });
loadAppStats();
});
});
each($all('[data-app-group]'), function (b) {
b.addEventListener('click', function () {
state.app.group = b.getAttribute('data-app-group');
each($all('[data-app-group]'), function (x) { x.classList.toggle('is-active', x === b); });
loadAppStats();
});
});
// 막대 → 같은 기준·항목·값으로 목록을 거른다 (다시 누르면 해제)
$('[data-role="app-stats-bars"]').addEventListener('click', function (e) {
var bar = e.target.closest ? e.target.closest('.mgm-bar') : null;
if (!bar || bar.disabled) return;
var value = bar.getAttribute('data-key');
var cur = state.app.inflowFilter;
var same = cur && cur.basis === state.app.basis && cur.field === state.app.group && cur.value === value;
state.app.inflowFilter = same ? null : { basis: state.app.basis, field: state.app.group, value: value };
loadApplications(true);
});
$('[data-role="app-inflow-filter"]').addEventListener('click', function (e) {
if (!e.target.closest || !e.target.closest('[data-app-clear-inflow]')) return;
state.app.inflowFilter = null;
loadApplications(true);
});
var appSearchTimer = null;
appForm.q.addEventListener('input', function () {
clearTimeout(appSearchTimer);