generated from ttipo/project-base
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cab7e840fe |
@@ -99,8 +99,9 @@ export class PartnerLinkResponseDto {
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '업체에 전달할 단축 링크',
|
||||
example: 'https://moneyguide.wandergo.net/r/Ab3kX9pQ',
|
||||
description:
|
||||
'업체에 전달할 링크 — 아임웹 사이트의 /go 입구 (사이트 주소를 모르면 백엔드 /r/{코드})',
|
||||
example: 'https://www.moneyguider.co.kr/go?c=Ab3kX9pQ',
|
||||
})
|
||||
shortUrl!: string;
|
||||
|
||||
|
||||
@@ -18,10 +18,12 @@ import {
|
||||
CLICK_REF_LENGTH,
|
||||
LINK_CODE_LENGTH,
|
||||
LINK_CODE_PATTERN,
|
||||
buildEntryUrl,
|
||||
isBotUserAgent,
|
||||
normalizeTargetUrl,
|
||||
toBase62,
|
||||
withPartnerRef,
|
||||
withPartnerUtm,
|
||||
} from '@/shared/utils/partner-link';
|
||||
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
|
||||
import {
|
||||
@@ -287,7 +289,10 @@ export class PartnerLinksService {
|
||||
method: string,
|
||||
): Promise<string> {
|
||||
const link = LINK_CODE_PATTERN.test(code)
|
||||
? await this.links.findOne({ where: { code } })
|
||||
? await this.links.findOne({
|
||||
where: { code },
|
||||
relations: { partner: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!link) {
|
||||
@@ -295,8 +300,14 @@ export class PartnerLinksService {
|
||||
if (!home) throw new BusinessException('RES_001', { context: { code } });
|
||||
return home;
|
||||
}
|
||||
// 업체 UTM 은 클릭 기록 여부와 상관없이 붙인다 — 유입경로 기록용이고 업체 실적과는 별개다
|
||||
const target = withPartnerUtm(
|
||||
link.targetUrl,
|
||||
link.partner?.name ?? '',
|
||||
link.code,
|
||||
);
|
||||
if (!link.isActive || method !== 'GET' || isBotUserAgent(userAgent)) {
|
||||
return link.targetUrl;
|
||||
return target;
|
||||
}
|
||||
|
||||
const ref = toBase62(randomBytes(CLICK_REF_LENGTH));
|
||||
@@ -307,9 +318,9 @@ export class PartnerLinksService {
|
||||
this.logger.warn(
|
||||
`업체 링크 클릭 기록 실패: link=${link.id} ${String(error)}`,
|
||||
);
|
||||
return link.targetUrl;
|
||||
return target;
|
||||
}
|
||||
return withPartnerRef(link.targetUrl, ref);
|
||||
return withPartnerRef(target, ref);
|
||||
}
|
||||
|
||||
private async require(id: string): Promise<PartnerLink> {
|
||||
@@ -357,12 +368,17 @@ export class PartnerLinksService {
|
||||
]);
|
||||
const partnerById = new Map(partnerRows.map((p) => [p.id, p]));
|
||||
const productByNo = new Map(productRows.map((p) => [p.prodNo, p]));
|
||||
const siteUrl = this.imwebConfig.siteUrl;
|
||||
const shortBase = this.shortLinkBase();
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
shortUrl: shortBase ? `${shortBase}/${SHORT_LINK_PATH}/${row.code}` : '',
|
||||
shortUrl: siteUrl
|
||||
? buildEntryUrl(siteUrl, row.code)
|
||||
: shortBase
|
||||
? `${shortBase}/${SHORT_LINK_PATH}/${row.code}`
|
||||
: '',
|
||||
partner: {
|
||||
id: row.partnerId,
|
||||
name: partnerById.get(row.partnerId)?.name ?? '',
|
||||
@@ -378,7 +394,10 @@ export class PartnerLinksService {
|
||||
}));
|
||||
}
|
||||
|
||||
/** 단축 링크 앞부분 — 백엔드 공개 주소(BACKEND_API_URL)의 origin */
|
||||
/**
|
||||
* 백엔드 단축 링크 앞부분 — 백엔드 공개 주소(BACKEND_API_URL)의 origin.
|
||||
* 업체에는 아임웹 /go 주소를 주고, 이 주소는 아임웹 사이트 주소를 모를 때만 대신 쓴다.
|
||||
*/
|
||||
private shortLinkBase(): string {
|
||||
const apiUrl = (this.config.get<string>('BACKEND_API_URL') ?? '').trim();
|
||||
if (!apiUrl) return '';
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
buildEntryUrl,
|
||||
isBotUserAgent,
|
||||
normalizeTargetUrl,
|
||||
toBase62,
|
||||
withPartnerRef,
|
||||
withPartnerUtm,
|
||||
} from './partner-link';
|
||||
|
||||
const SITE = 'https://www.moneyguider.co.kr';
|
||||
@@ -62,6 +64,36 @@ describe('withPartnerRef', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEntryUrl', () => {
|
||||
it('아임웹 사이트의 /go 페이지 주소를 만든다', () => {
|
||||
expect(buildEntryUrl('https://www.moneyguider.co.kr', 'Ab3kX9pQ')).toBe(
|
||||
'https://www.moneyguider.co.kr/go?c=Ab3kX9pQ',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withPartnerUtm', () => {
|
||||
const TARGET = 'https://www.moneyguider.co.kr/free-shop/?idx=52';
|
||||
|
||||
it('UTM 이 없으면 업체 이름·partner·링크 코드를 붙인다', () => {
|
||||
const url = new URL(withPartnerUtm(TARGET, '머니 튜브', 'Ab3kX9pQ'));
|
||||
expect(url.searchParams.get('idx')).toBe('52');
|
||||
expect(url.searchParams.get('utm_source')).toBe('머니 튜브');
|
||||
expect(url.searchParams.get('utm_medium')).toBe('partner');
|
||||
expect(url.searchParams.get('utm_campaign')).toBe('Ab3kX9pQ');
|
||||
});
|
||||
|
||||
it('관리자가 넣은 UTM 이 하나라도 있으면 건드리지 않는다', () => {
|
||||
const custom = `${TARGET}&utm_campaign=fall`;
|
||||
expect(withPartnerUtm(custom, '머니튜브', 'Ab3kX9pQ')).toBe(custom);
|
||||
});
|
||||
|
||||
it('업체 이름이 비어 있으면 링크 코드를 유입원으로 쓴다', () => {
|
||||
const url = new URL(withPartnerUtm(TARGET, ' ', 'Ab3kX9pQ'));
|
||||
expect(url.searchParams.get('utm_source')).toBe('Ab3kX9pQ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBotUserAgent', () => {
|
||||
it('링크 미리보기·검색봇은 사람으로 세지 않는다', () => {
|
||||
expect(
|
||||
|
||||
@@ -69,6 +69,51 @@ export function withPartnerRef(targetUrl: string, clickRef: string): string {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 업체에 전달하는 입구 주소 — 아임웹 사이트의 '/go' 페이지.
|
||||
* https://www.moneyguider.co.kr/go?c={코드}
|
||||
* Header Code(imweb/imweb-header-top.html)가 이 주소를 보면 페이지를 그리기 전에
|
||||
* 백엔드 단축 링크(/r/{코드})로 넘긴다. 아임웹에 'go' 페이지가 있어야 한다.
|
||||
*/
|
||||
export const PARTNER_ENTRY_PATH = '/go';
|
||||
export const PARTNER_ENTRY_PARAM = 'c';
|
||||
|
||||
export function buildEntryUrl(siteUrl: string, code: string): string {
|
||||
const url = new URL(PARTNER_ENTRY_PATH, siteUrl);
|
||||
url.searchParams.set(PARTNER_ENTRY_PARAM, code);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/** UTM 파라미터 접두어 — imweb-header-top.html 의 UTM 스크립트가 읽는다 */
|
||||
const UTM_PARAM_PREFIX = 'utm_';
|
||||
/** 업체 링크로 온 방문의 매체 값 */
|
||||
export const PARTNER_UTM_MEDIUM = 'partner';
|
||||
|
||||
/**
|
||||
* 업체 링크 UTM 을 붙인다 — 이동 주소에 UTM 이 하나도 없을 때만.
|
||||
* (관리자가 이동 주소에 직접 넣은 UTM 이 있으면 그대로 둔다)
|
||||
*
|
||||
* 그래야 UTM 스크립트가 이 방문을 direct 가 아니라 그 업체로 기록해,
|
||||
* 신청 때 유입·가입 경로에도 업체가 나타난다.
|
||||
* - utm_source 업체 이름 (집계 화면에서 읽기 쉽게)
|
||||
* - utm_medium partner
|
||||
* - utm_campaign 링크 코드 (업체 이름을 바꿔도 변하지 않는 식별자)
|
||||
*/
|
||||
export function withPartnerUtm(
|
||||
targetUrl: string,
|
||||
partnerName: string,
|
||||
linkCode: string,
|
||||
): string {
|
||||
const url = new URL(targetUrl);
|
||||
for (const key of url.searchParams.keys()) {
|
||||
if (key.toLowerCase().startsWith(UTM_PARAM_PREFIX)) return url.toString();
|
||||
}
|
||||
url.searchParams.set('utm_source', partnerName.trim() || linkCode);
|
||||
url.searchParams.set('utm_medium', PARTNER_UTM_MEDIUM);
|
||||
url.searchParams.set('utm_campaign', linkCode);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사람이 아닌 요청 — 링크 미리보기(카카오톡·페이스북 등)와 검색봇.
|
||||
* 업체가 카톡방에 링크를 올리면 미리보기용 요청이 먼저 오므로 세지 않는다.
|
||||
|
||||
+11
-3
@@ -103,8 +103,13 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
|
||||
관리자 화면 **업체 링크** 메뉴에서 업체 추가 → 링크 발급 → 실적 확인.
|
||||
|
||||
```
|
||||
업체에 링크 전달 ── https://moneyguide.wandergo.net/r/{코드}
|
||||
│ ① 클릭 기록 (카톡 미리보기·검색봇 제외) 후 상품 페이지로 이동하며 클릭 ID(mg_ref)를 붙인다
|
||||
업체에 링크 전달 ── https://www.moneyguider.co.kr/go?c={코드} (아임웹 'go' 페이지)
|
||||
│ ⓪ Header Code 가 페이지를 그리기 전에 백엔드 단축 링크 moneyguide.wandergo.net/r/{코드} 로 넘긴다
|
||||
▼
|
||||
백엔드 단축 링크
|
||||
│ ① 클릭 기록 (카톡 미리보기·검색봇 제외) 후 상품 페이지로 이동하며 클릭 ID(mg_ref)와
|
||||
│ 업체 UTM(utm_source=업체 이름, utm_medium=partner, utm_campaign=링크 코드)을 붙인다
|
||||
│ (이동 주소에 관리자가 직접 넣은 UTM 이 있으면 그것을 쓴다)
|
||||
▼
|
||||
상품 페이지 ── imweb-header-top.html(Header Code)의 업체 링크 추적이 클릭 ID 를 30일 보관하고 주소창에서 지운다
|
||||
│ (UTM 스크립트처럼 localStorage + .moneyguider.co.kr 쿠키 — www ↔ apex 이동에도 유지)
|
||||
@@ -118,6 +123,7 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
|
||||
- **구매 수**: 결제완료 주문만 센다. 전액 환불되거나 해당 상품 품목이 모두 취소·반품되면 빠진다. 금액은 관리하지 않는다
|
||||
- **링크는 지우지 않고 중지**한다 — 중지해도 상품 페이지로 이동은 되지만 클릭·실적은 쌓이지 않는다.
|
||||
링크가 있는 업체는 삭제할 수 없다 (실적 기록 보존)
|
||||
- 업체 링크로 온 방문은 UTM 스크립트가 업체 UTM 으로 기록하므로, 신청 때 유입·가입 경로 집계에도 업체 이름이 나온다
|
||||
- 이동 주소는 아임웹 사이트(www 유무 무관) 안만 허용한다 — 단축 링크가 피싱에 쓰이지 않게.
|
||||
사이트 주소는 `IMWEB_SITE_URL`, 비우면 `IMWEB_ADMIN_PAGE_URL` 의 origin
|
||||
- 한계
|
||||
@@ -135,7 +141,9 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
|
||||
4. `imweb-header-top.html` 을 아임웹 **Header Code** 에 다시 붙여 넣는다 (UTM 스크립트 아래에 업체 링크 추적이 추가됨).
|
||||
`imweb-header.html`(로그인 링크 UTM 보존)은 바꾸지 않았다
|
||||
5. `imweb-free-apply.html` 을 신청 완료 위젯에 다시 붙여 넣는다 (신청 기록·클릭 ID 전송 추가됨)
|
||||
6. 업체 링크 메뉴에서 **상품 불러오기** → 업체 추가 → 링크 발급
|
||||
6. 아임웹에 주소(URL)가 `go` 인 페이지를 만든다 — 메뉴에서 숨기고 전체 공개. 내용은 비워 둬도 된다
|
||||
(업체에 주는 `www.moneyguider.co.kr/go?c=코드` 의 입구. 이 페이지가 없으면 링크가 404 가 된다)
|
||||
7. 업체 링크 메뉴에서 **상품 불러오기** → 업체 추가 → 링크 발급
|
||||
|
||||
## 인증 방식 — 왜 쿠키가 아닌가
|
||||
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
<meta name="facebook-domain-verification" content="sldnjjycwvg0acy344qq4zu3mhxh72" />
|
||||
<meta name="facebook-domain-verification" content="rjbwdcpgd8bp4a5220oshav850duv1" />
|
||||
|
||||
<!-- ============================================================
|
||||
[자본주의가이드] 업체 링크 입구 v1.0 — 이 블록은 반드시 맨 위에 둔다
|
||||
업체에 전달하는 링크: https://www.moneyguider.co.kr/go?c=코드
|
||||
→ 페이지를 그리기 전에 백엔드 단축 링크(/r/코드)로 바로 넘긴다.
|
||||
백엔드가 클릭을 기록하고 클릭 ID(mg_ref)와 업체 UTM 을 붙여 상품 페이지로 보낸다.
|
||||
필요: 아임웹에 주소가 'go' 인 페이지 (메뉴에 숨김, 전체 공개). 내용은 비워 둬도 된다.
|
||||
아래 UTM 스크립트는 이 입구 페이지에서 기록하지 않는다(MG_PARTNER_ENTRY) —
|
||||
기록은 이동한 상품 페이지에서 업체 UTM 으로 한 번만 남긴다.
|
||||
============================================================ -->
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var ENTRY_PATH = /^\/go\/?$/; // 백엔드 PARTNER_ENTRY_PATH 와 같아야 한다
|
||||
var ENTRY_PARAM = 'c'; // 백엔드 PARTNER_ENTRY_PARAM
|
||||
var CODE_PATTERN = /^[0-9A-Za-z]{8}$/; // 백엔드 LINK_CODE_PATTERN
|
||||
var SHORT_LINK_BASE = 'https://moneyguide.wandergo.net/r/'; // 백엔드 단축 링크
|
||||
try {
|
||||
if (!ENTRY_PATH.test(location.pathname)) return;
|
||||
var code = new URLSearchParams(location.search).get(ENTRY_PARAM);
|
||||
if (!code || !CODE_PATTERN.test(code)) return;
|
||||
window.MG_PARTNER_ENTRY = true;
|
||||
location.replace(SHORT_LINK_BASE + code); // replace — 뒤로가기로 입구에 다시 오지 않게
|
||||
} catch (e) { }
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- ============================================================
|
||||
[자본주의가이드] UTM 기반 회원가입 유입경로 자동추적 v1.1
|
||||
설치 위치: 아임웹 관리자 > 환경설정 > SEO(검색엔진최적화)
|
||||
@@ -428,6 +454,9 @@
|
||||
}
|
||||
|
||||
/* ===================== 부팅 ===================== */
|
||||
// 업체 링크 입구(/go?c=코드)는 곧바로 다른 페이지로 넘어간다 — 여기서 유입을 기록하면
|
||||
// 업체가 링크를 올린 사이트가 최초유입으로 굳어 업체 UTM 이 최종유입으로만 남는다
|
||||
if (window.MG_PARTNER_ENTRY) return;
|
||||
capture();
|
||||
|
||||
function start() {
|
||||
|
||||
@@ -568,6 +568,8 @@
|
||||
<p class="mgm-muted" style="margin:10px 0 0">
|
||||
링크를 누른 뒤 30일 안에 신청·구매하면, 그 상품의 링크 중 마지막으로 누른 업체 실적으로 잡습니다.
|
||||
구매는 결제완료 주문만 세며(환불·취소 제외) 아임웹 주문을 1시간마다 확인합니다.
|
||||
업체에 줄 링크는 <b>www.moneyguider.co.kr/go?c=코드</b> 형식이며, 아임웹에 주소가 'go' 인 페이지가 있어야 동작합니다.
|
||||
링크로 들어온 방문에는 업체 UTM(유입원=업체 이름, 매체=partner, 캠페인=링크 코드)이 자동으로 붙습니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user