diff --git a/backend/src/modules/partners/dto/partner-link.dto.ts b/backend/src/modules/partners/dto/partner-link.dto.ts index f6a0c04..cfe348a 100644 --- a/backend/src/modules/partners/dto/partner-link.dto.ts +++ b/backend/src/modules/partners/dto/partner-link.dto.ts @@ -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; diff --git a/backend/src/modules/partners/partner-links.service.ts b/backend/src/modules/partners/partner-links.service.ts index 4afe140..4f09f56 100644 --- a/backend/src/modules/partners/partner-links.service.ts +++ b/backend/src/modules/partners/partner-links.service.ts @@ -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 { 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 { @@ -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('BACKEND_API_URL') ?? '').trim(); if (!apiUrl) return ''; diff --git a/backend/src/shared/utils/partner-link.spec.ts b/backend/src/shared/utils/partner-link.spec.ts index 16cea76..9aaf14d 100644 --- a/backend/src/shared/utils/partner-link.spec.ts +++ b/backend/src/shared/utils/partner-link.spec.ts @@ -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( diff --git a/backend/src/shared/utils/partner-link.ts b/backend/src/shared/utils/partner-link.ts index 1a6d5c5..3f16c2c 100644 --- a/backend/src/shared/utils/partner-link.ts +++ b/backend/src/shared/utils/partner-link.ts @@ -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(); +} + /** * 사람이 아닌 요청 — 링크 미리보기(카카오톡·페이스북 등)와 검색봇. * 업체가 카톡방에 링크를 올리면 미리보기용 요청이 먼저 오므로 세지 않는다. diff --git a/imweb/README.md b/imweb/README.md index abbce31..1845533 100644 --- a/imweb/README.md +++ b/imweb/README.md @@ -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. 업체 링크 메뉴에서 **상품 불러오기** → 업체 추가 → 링크 발급 ## 인증 방식 — 왜 쿠키가 아닌가 diff --git a/imweb/imweb-header-top.html b/imweb/imweb-header-top.html index dd48589..7d6375f 100644 --- a/imweb/imweb-header-top.html +++ b/imweb/imweb-header-top.html @@ -1,6 +1,32 @@ + + +