feat: 업체 링크를 아임웹 주소(/go)로 바꾸고 업체 UTM 자동 부착
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

업체에 우리 백엔드 도메인(moneyguide.wandergo.net)이 아니라 사이트 주소를 주기 위해서다.
또 업체 링크로 온 방문이 UTM 이 없어 direct 로 분류되면서, 이전에 남은 유입(예: 개발자센터)이
신청 때 유입으로 잡히는 문제가 있었다.

- 업체에 줄 링크: https://www.moneyguider.co.kr/go?c={코드}
  imweb-header-top.html 맨 위의 입구 블록이 페이지를 그리기 전에 백엔드 /r/{코드} 로 넘긴다
  (입구 페이지에서는 UTM 스크립트가 기록하지 않는다). 아임웹에 'go' 페이지가 필요하다
- 기존 백엔드 링크(/r/{코드})는 그대로 동작한다
- 단축 링크 리다이렉트에 업체 UTM 자동 부착 — utm_source=업체 이름, utm_medium=partner,
  utm_campaign=링크 코드 (이동 주소에 관리자가 넣은 UTM 이 있으면 그대로 둔다)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 01:01:00 +09:00
parent 38e945767b
commit cab7e840fe
7 changed files with 147 additions and 11 deletions
@@ -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(
+45
View File
@@ -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
View File
@@ -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. 업체 링크 메뉴에서 **상품 불러오기** → 업체 추가 → 링크 발급
## 인증 방식 — 왜 쿠키가 아닌가
+29
View File
@@ -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() {
+2
View File
@@ -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>