3 Commits

Author SHA1 Message Date
ttipo 6316666bea Merge branch 'feat/admin-ui-redesign'
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
2026-10-06 01:38:32 +09:00
ttipo b5ed8850c7 feat: 관리자 화면 전체 개편과 신청 내역 상품별·업체별 보기
관리자 화면이 기능을 덧붙이며 복잡해져(회원 표 15열, 업체 화면에 폼·표·설명이 모두 펼쳐짐,
브라우저 기본 prompt/confirm) 보기 어렵고, 신청을 업체 기준으로 볼 방법이 없었다.

- 메뉴를 개요 / 운영(신청 내역·업체 링크·회원) / 설정(연동 관리)으로 묶음
- 개요: 오늘·7일 신청, 7일 업체 링크 신청, 7일 가입 지표 + 신청 유입·업체 실적 상위·최근 신청
- 신청 내역: 상품별 ↔ 업체별 전환 — 요약 표의 행을 누르면 목록과 유입 집계를 그 항목으로 좁힘,
  신청자 표에 업체 열 추가, 행을 누르면 상세 패널(신청·유입·회원·가입 경로)
- 업체 링크: 왼쪽 업체 목록 + 오른쪽 링크 표, 실적 지표, 링크 발급·수정은 대화상자,
  사용/중지 스위치, 링크 실적은 상세 패널, 집계 기준은 접는 도움말
- 회원: 표를 7열로 줄이고 유입경로 필터는 접어 둠, 행을 누르면 상세 패널
- 토스트 알림·대화상자·상세 패널 공통 컴포넌트, 연락처 하이픈 표시, 휴대폰 화면 정리
- API: GET /api/admin/applications/groups (상품별·업체별 신청 수·오픈카톡 입장 수),
  신청 목록·유입 집계에 partnerId 필터(업체 ID 또는 none), 신청 응답에 데려온 업체(partner)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 01:37:49 +09:00
ttipo cab7e840fe 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>
2026-10-06 01:01:00 +09:00
13 changed files with 2093 additions and 1163 deletions
@@ -23,10 +23,12 @@ import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
import { ADMIN_BEARER_AUTH } from '@/modules/admin-auth/admin-auth.constants';
import { ApplicationsService } from './applications.service';
import {
ApplicationGroupsQueryDto,
ApplicationInflowStatsQueryDto,
ApplicationListQueryDto,
} from './dto/application-query.dto';
import {
ApplicationGroupsResponseDto,
ApplicationInflowStatsResponseDto,
ApplicationProductDto,
ApplicationResponseDto,
@@ -109,6 +111,21 @@ export class AdminApplicationsController {
return this.applications.products();
}
@Get('groups')
@ApiOperation({
summary: '상품별·업체별 신청 수 — 신청 내역의 요약 표 (기간만 거른다)',
})
@ApiStandardResponse(ApplicationGroupsResponseDto, {
description: '조회 성공',
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '기준·기간 형식 오류')
groups(
@Query() query: ApplicationGroupsQueryDto,
): Promise<ApplicationGroupsResponseDto> {
return this.applications.groups(query);
}
@Get('inflow-stats')
@ApiOperation({
summary:
@@ -5,6 +5,8 @@ import { AdminAuthModule } from '@/modules/admin-auth/admin-auth.module';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { ImwebModule } from '@/modules/imweb/imweb.module';
import { MembersModule } from '@/modules/members/members.module';
import { PartnerConversion } from '@/modules/partners/entities/partner-conversion.entity';
import { Partner } from '@/modules/partners/entities/partner.entity';
import { PartnersModule } from '@/modules/partners/partners.module';
import { MemberApplication } from './application.entity';
import {
@@ -16,7 +18,13 @@ import { ApplicationsService } from './applications.service';
/** 강의 신청 기록 — 신청 완료 화면이 보내고, 관리자 화면에서 본다 */
@Module({
imports: [
TypeOrmModule.forFeature([MemberApplication, ImwebMember]),
// 신청을 데려온 업체를 보여주려고 업체 실적·업체를 읽는다
TypeOrmModule.forFeature([
MemberApplication,
ImwebMember,
PartnerConversion,
Partner,
]),
AdminAuthModule,
ImwebModule,
MembersModule,
@@ -4,6 +4,8 @@ import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import type { ImwebConfig } from '@/modules/imweb/imweb.config';
import type { ImwebMemberLookupService } from '@/modules/imweb/imweb-member-lookup.service';
import type { MembersService } from '@/modules/members/members.service';
import type { PartnerConversion } from '@/modules/partners/entities/partner-conversion.entity';
import type { Partner } from '@/modules/partners/entities/partner.entity';
import type { PartnerAttributionService } from '@/modules/partners/partner-attribution.service';
import { MemberApplication } from './application.entity';
import { ApplicationsService } from './applications.service';
@@ -51,6 +53,8 @@ function setup(member: ImwebMember | null = MEMBER) {
{} as MembersService,
{ trackingHost: 'utm.moneyguider.co.kr' } as ImwebConfig,
attribution as unknown as PartnerAttributionService,
{} as Repository<PartnerConversion>,
{} as Repository<Partner>,
);
return { service, store, applications, lookup, attribution, calls };
}
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import {
In,
IsNull,
QueryFailedError,
Repository,
@@ -14,23 +15,33 @@ import {
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { ImwebProduct } from '@/modules/imweb/entities/imweb-product.entity';
import { ImwebConfig } from '@/modules/imweb/imweb.config';
import { ImwebMemberLookupService } from '@/modules/imweb/imweb-member-lookup.service';
import { InflowGroupBy } from '@/modules/members/dto/member-query.dto';
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
import { MembersService } from '@/modules/members/members.service';
import {
ConversionType,
PartnerConversion,
} from '@/modules/partners/entities/partner-conversion.entity';
import { Partner } from '@/modules/partners/entities/partner.entity';
import { PartnerAttributionService } from '@/modules/partners/partner-attribution.service';
import { InflowStatus, parseInflow } from '@/shared/utils/inflow';
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
import { MemberApplication } from './application.entity';
import {
ApplicationFilterDto,
ApplicationGroupsQueryDto,
ApplicationInflowBasis,
ApplicationInflowStatsQueryDto,
ApplicationListQueryDto,
PARTNER_FILTER_NONE,
} from './dto/application-query.dto';
import {
ApplicationGroupsResponseDto,
ApplicationInflowStatsResponseDto,
ApplicationPartnerDto,
ApplicationProductDto,
ApplicationResponseDto,
} from './dto/application-response.dto';
@@ -41,6 +52,9 @@ import {
const ALIAS = 'a';
const MEMBER_ALIAS = 'm';
const CONVERSION_ALIAS = 'pc';
const PARTNER_ALIAS = 'p';
const PRODUCT_ALIAS = 'ip';
/** Postgres 유일 제약 위반 — 같은 회원·상품이 동시에 두 번 들어온 경우 */
const PG_UNIQUE_VIOLATION = '23505';
/** 집계 결과 최대 줄 수 (회원 유입 집계와 같다) */
@@ -92,6 +106,10 @@ export class ApplicationsService {
private readonly membersService: MembersService,
private readonly config: ImwebConfig,
private readonly attribution: PartnerAttributionService,
@InjectRepository(PartnerConversion)
private readonly conversions: Repository<PartnerConversion>,
@InjectRepository(Partner)
private readonly partners: Repository<Partner>,
) {}
/** 신청 기록 — 같은 회원·상품이면 기존 기록을 돌려준다 (몇 번을 불러도 한 건) */
@@ -230,10 +248,12 @@ export class ApplicationsService {
const items = rows.slice(0, query.limit);
const last = items.at(-1);
const [membersByCode, groupTitles] = await Promise.all([
this.membersByCode(items.map((row) => row.memberCode)),
this.membersService.groupTitleMap(),
]);
const [membersByCode, groupTitles, partnersByApplication] =
await Promise.all([
this.membersByCode(items.map((row) => row.memberCode)),
this.membersService.groupTitleMap(),
this.partnersByApplication(items.map((row) => row.id)),
]);
return toPaginatedResult(
items.map((row) => {
@@ -258,6 +278,7 @@ export class ApplicationsService {
campaign: row.inflowLastCampaign,
content: row.inflowLastContent,
},
partner: partnersByApplication.get(row.id) ?? null,
};
}),
{
@@ -338,6 +359,21 @@ export class ApplicationsService {
if (filter.source) {
qb.andWhere(`${ALIAS}.inflowSource = :source`, { source: filter.source });
}
// 업체 — 신청 실적은 신청당 하나라 잇는 행이 늘어나지 않는다
if (filter.partnerId) {
qb.leftJoin(
PartnerConversion,
CONVERSION_ALIAS,
`${CONVERSION_ALIAS}.application_id = ${ALIAS}.id`,
);
if (filter.partnerId === PARTNER_FILTER_NONE) {
qb.andWhere(`${CONVERSION_ALIAS}.id IS NULL`);
} else {
qb.andWhere(`${CONVERSION_ALIAS}.partner_id = :partnerId`, {
partnerId: filter.partnerId,
});
}
}
// 집계 막대를 눌러 거를 때 — 기준·항목·값이 모두 있어야 한다
if (filter.inflowBasis && filter.inflowField && filter.inflowValue) {
qb.andWhere(
@@ -354,6 +390,116 @@ export class ApplicationsService {
return qb;
}
/**
* 상품별·업체별 신청 수 — 신청 내역 화면의 요약 표.
* 업체별은 업체 링크 실적으로 잡힌 신청을 업체로 묶고, 나머지는 key=null(업체 없음)로 센다.
*/
async groups(
query: ApplicationGroupsQueryDto,
): Promise<ApplicationGroupsResponseDto> {
const base = this.applications.createQueryBuilder(ALIAS);
if (query.appliedFrom) {
base.andWhere(`${ALIAS}.appliedAt >= :appliedFrom`, {
appliedFrom: query.appliedFrom,
});
}
if (query.appliedTo) {
base.andWhere(`${ALIAS}.appliedAt < :appliedTo`, {
appliedTo: query.appliedTo,
});
}
const totals = await base
.clone()
.select('COUNT(*)', 'total')
.addSelect(`COUNT(${ALIAS}.kakao_clicked_at)`, 'kakaoTotal')
.getRawOne<{ total: string; kakaoTotal: string }>();
const grouped = base.clone();
if (query.by === 'partner') {
grouped
.leftJoin(
PartnerConversion,
CONVERSION_ALIAS,
`${CONVERSION_ALIAS}.application_id = ${ALIAS}.id AND ${CONVERSION_ALIAS}.type = :applicationType`,
{ applicationType: ConversionType.APPLICATION },
)
.leftJoin(
Partner,
PARTNER_ALIAS,
`${PARTNER_ALIAS}.id = ${CONVERSION_ALIAS}.partner_id`,
)
.select(`${CONVERSION_ALIAS}.partner_id`, 'key')
.addSelect(`MAX(${PARTNER_ALIAS}.name)`, 'label');
} else {
// 상품명은 아임웹 상품 기준, 아직 불러오지 않은 상품이면 가장 최근 신청의 표시값
grouped
.leftJoin(
ImwebProduct,
PRODUCT_ALIAS,
`CAST(${PRODUCT_ALIAS}.prod_no AS text) = ${ALIAS}.product_idx`,
)
.select(`${ALIAS}.product_idx`, 'key')
.addSelect(
`COALESCE(MAX(${PRODUCT_ALIAS}.name), (ARRAY_AGG(${ALIAS}.product_name ORDER BY ${ALIAS}.applied_at DESC))[1])`,
'label',
);
}
const rows = await grouped
.addSelect('COUNT(*)', 'count')
.addSelect(`COUNT(${ALIAS}.kakao_clicked_at)`, 'kakaoCount')
.groupBy('key')
.orderBy('count', 'DESC')
.addOrderBy('key', 'ASC', 'NULLS LAST')
.limit(STATS_MAX_ITEMS)
.getRawMany<{
key: string | null;
label: string | null;
count: string;
kakaoCount: string;
}>();
return {
by: query.by,
total: Number(totals?.total ?? 0),
kakaoTotal: Number(totals?.kakaoTotal ?? 0),
items: rows.map((row) => ({
key: row.key,
label: row.label,
count: Number(row.count),
kakaoCount: Number(row.kakaoCount),
})),
};
}
/** 신청 ID → 그 신청을 데려온 업체 (업체 링크 실적이 없으면 빠진다) */
private async partnersByApplication(
applicationIds: string[],
): Promise<Map<string, ApplicationPartnerDto>> {
if (applicationIds.length === 0) return new Map();
const conversions = await this.conversions.find({
select: { applicationId: true, partnerId: true },
where: { applicationId: In(applicationIds) },
});
const partnerIds = [...new Set(conversions.map((c) => c.partnerId))];
if (partnerIds.length === 0) return new Map();
const partners = await this.partners.find({
select: { id: true, name: true },
where: { id: In(partnerIds) },
});
const nameById = new Map(partners.map((p) => [p.id, p.name]));
const result = new Map<string, ApplicationPartnerDto>();
for (const conversion of conversions) {
if (!conversion.applicationId) continue;
result.set(conversion.applicationId, {
id: conversion.partnerId,
name: nameById.get(conversion.partnerId) ?? '',
});
}
return result;
}
/** 상품별 신청 수 — 최근 신청이 있는 상품부터 */
async products(): Promise<ApplicationProductDto[]> {
const rows = await this.applications
@@ -14,6 +14,10 @@ import type { InflowGroupBy } from '@/modules/members/dto/member-query.dto';
import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow';
const SEARCH_MAX_LENGTH = 100;
/** 업체 필터 — 업체 ID(UUID) 또는 none(업체 링크를 거치지 않은 신청) */
export const PARTNER_FILTER_NONE = 'none';
const PARTNER_FILTER_PATTERN =
/^(none|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
/**
* 신청 유입을 어느 기록으로 볼 것인가.
@@ -50,6 +54,47 @@ export class ApplicationFilterDto {
@IsOptional()
@IsDateString({}, { message: '신청 종료일 형식이 올바르지 않습니다.' })
appliedTo?: string;
@ApiPropertyOptional({
description:
'업체 — 그 업체 링크 실적으로 잡힌 신청만. none 이면 업체 링크를 거치지 않은 신청',
example: 'none',
})
@IsOptional()
@Matches(PARTNER_FILTER_PATTERN, { message: '업체 값이 올바르지 않습니다.' })
partnerId?: string;
}
/** 신청 요약 묶음 기준 — 상품별 / 업체별 */
export const APPLICATION_GROUP_BY = ['product', 'partner'] as const;
export type ApplicationGroupBy = (typeof APPLICATION_GROUP_BY)[number];
/** 상품별·업체별 신청 수 — 기간만 걸러서 센다 */
export class ApplicationGroupsQueryDto {
@ApiPropertyOptional({
description: '묶음 기준 — product(상품별, 기본) · partner(업체별)',
enum: APPLICATION_GROUP_BY,
default: 'product',
})
@IsOptional()
@IsIn(APPLICATION_GROUP_BY, { message: '묶음 기준 값이 올바르지 않습니다.' })
by: ApplicationGroupBy = 'product';
@ApiPropertyOptional({
description: '신청 시각 시작 (이상, ISO 8601)',
example: '2026-09-30T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '신청 시작일 형식이 올바르지 않습니다.' })
appliedFrom?: string;
@ApiPropertyOptional({
description: '신청 시각 끝 (미만, ISO 8601)',
example: '2026-10-31T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '신청 종료일 형식이 올바르지 않습니다.' })
appliedTo?: string;
}
/** 관리자 신청 내역 조회 */
@@ -3,8 +3,14 @@ import { ApiProperty } from '@nestjs/swagger';
import { INFLOW_GROUP_BY } from '@/modules/members/dto/member-query.dto';
import type { InflowGroupBy } from '@/modules/members/dto/member-query.dto';
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
import { APPLICATION_INFLOW_BASIS } from './application-query.dto';
import type { ApplicationInflowBasis } from './application-query.dto';
import {
APPLICATION_GROUP_BY,
APPLICATION_INFLOW_BASIS,
} from './application-query.dto';
import type {
ApplicationGroupBy,
ApplicationInflowBasis,
} from './application-query.dto';
/** 신청 순간의 유입정보 */
export class ApplicationInflowDto {
@@ -21,6 +27,15 @@ export class ApplicationInflowDto {
content!: string | null;
}
/** 신청을 데려온 업체 (업체 링크 실적으로 잡힌 경우) */
export class ApplicationPartnerDto {
@ApiProperty({ description: '업체 ID' })
id!: string;
@ApiProperty({ description: '업체 이름', example: '머니튜브' })
name!: string;
}
/** 관리자 신청 내역 한 건 */
export class ApplicationResponseDto {
@ApiProperty({ description: '신청 기록 ID' })
@@ -67,6 +82,14 @@ export class ApplicationResponseDto {
type: ApplicationInflowDto,
})
lastInflow!: ApplicationInflowDto;
@ApiProperty({
description:
'업체 링크 실적으로 잡힌 경우 그 업체 — 업체 링크를 거치지 않았으면 null',
type: ApplicationPartnerDto,
nullable: true,
})
partner!: ApplicationPartnerDto | null;
}
/** 상품별 신청 수 — 필터 칩에 쓴다 */
@@ -122,3 +145,56 @@ export class ApplicationInflowStatsResponseDto {
})
items!: ApplicationInflowStatItemDto[];
}
/** 상품별·업체별 신청 수 한 줄 */
export class ApplicationGroupItemDto {
@ApiProperty({
description:
'묶음 값 — 상품번호 또는 업체 ID. 업체별에서 업체 링크를 거치지 않은 신청은 null',
nullable: true,
example: '52',
})
key!: string | null;
@ApiProperty({
description:
'표시 이름 — 상품명(아임웹 기준, 없으면 신청 화면 표시값) 또는 업체 이름',
nullable: true,
example: '[무료한정] 하루 2시간 월1000 버는 AI 효자 상품 찾기',
})
label!: string | null;
@ApiProperty({ description: '신청 수', example: 120 })
count!: number;
@ApiProperty({
description: '그중 오픈카톡 입장 버튼을 누른 수',
example: 87,
})
kakaoCount!: number;
}
/** 상품별·업체별 신청 수 */
export class ApplicationGroupsResponseDto {
@ApiProperty({
description: '묶음 기준',
enum: APPLICATION_GROUP_BY,
example: 'product',
})
by!: ApplicationGroupBy;
@ApiProperty({ description: '기간 내 전체 신청 수', example: 310 })
total!: number;
@ApiProperty({
description: '기간 내 오픈카톡 입장 버튼을 누른 신청 수',
example: 220,
})
kakaoTotal!: number;
@ApiProperty({
description: '신청 수 많은 순',
type: [ApplicationGroupItemDto],
})
items!: ApplicationGroupItemDto[];
}
@@ -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() {
+1645 -1145
View File
File diff suppressed because it is too large Load Diff