generated from ttipo/project-base
feat: 업체 링크를 아임웹 주소(/go)로 바꾸고 업체 UTM 자동 부착
업체에 우리 백엔드 도메인(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:
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사람이 아닌 요청 — 링크 미리보기(카카오톡·페이스북 등)와 검색봇.
|
||||
* 업체가 카톡방에 링크를 올리면 미리보기용 요청이 먼저 오므로 세지 않는다.
|
||||
|
||||
Reference in New Issue
Block a user