2 Commits

Author SHA1 Message Date
ttipo 38e945767b feat: 신청 때 최종 유입 저장과 신청 유입 집계 추가
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
가입 경로와 신청(수강) 유입을 구분해 보기 위해서다. 기존 신청 유입은 신청 시점
브라우저의 '최초' 유입(2일 보관)이라, 그 사이 다른 경로로 다시 와서 신청하면
신청을 일으킨 경로가 가려졌다.

- member_applications 에 최종 유입(inflow_last_*) 저장 — 없으면 최초 유입으로 채움 (기존 행도 동일)
- 신청 완료 위젯이 MG_UTM.peek().last 를 lastInflow 로 함께 보냄
- GET /api/admin/applications/inflow-stats: 신청 때 최종·최초 / 가입 경로(가입 때 기록만) 기준 집계
- 신청 목록에 inflowBasis·inflowField·inflowValue 필터
- 관리자 화면 신청 내역: '신청 유입 집계' 카드(기준·항목 전환, 막대 클릭으로 목록 필터),
  신청 때 유입 칸에 최종 유입 표시 + 최초가 다르면 함께 표시

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:22:02 +09:00
ttipo e91f7620a3 feat: 업체 링크 추적을 실제 아임웹 Header Code 로 합치고 신청 유입정보 누락 수정
- imweb-header-top.html·imweb-header.html: 아임웹에 실제로 들어가 있는 Header Code 원본을 저장소에 둔다
- 업체 링크 추적을 imweb-header-top.html 의 UTM 스크립트 아래로 옮기고 partner-ref.html 은 삭제
  (UTM 스크립트처럼 localStorage + .moneyguider.co.kr 쿠키에 저장 — www ↔ apex 이동·인앱브라우저 대비)
- 신청 완료 위젯: MG_UTM.peek() 는 객체라 문자열 검사에 걸려 유입정보가 빠지고 있었다 — peek().value 를 보낸다
- 단축 링크 리다이렉트에 Referrer-Policy strict-origin-when-cross-origin
  (helmet 기본 no-referrer 때문에 업체가 링크를 올린 곳이 지워져 유입원이 direct 로 잡혔다)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:11:52 +09:00
19 changed files with 1277 additions and 182 deletions
@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddApplicationLastInflow1791213287479 implements MigrationInterface {
name = 'AddApplicationLastInflow1791213287479';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "member_applications" ADD "inflow_last_raw" text`,
);
await queryRunner.query(
`ALTER TABLE "member_applications" ADD "inflow_last_source" character varying(100)`,
);
await queryRunner.query(
`ALTER TABLE "member_applications" ADD "inflow_last_medium" character varying(100)`,
);
await queryRunner.query(
`ALTER TABLE "member_applications" ADD "inflow_last_campaign" character varying(100)`,
);
await queryRunner.query(
`ALTER TABLE "member_applications" ADD "inflow_last_content" character varying(100)`,
);
// 이전 신청은 최종 유입을 따로 받지 않았다 — 앱과 같은 규칙(최종이 없으면 최초)으로 채운다
await queryRunner.query(
`UPDATE "member_applications" SET "inflow_last_source" = "inflow_source", "inflow_last_medium" = "inflow_medium", "inflow_last_campaign" = "inflow_campaign", "inflow_last_content" = "inflow_content"`,
);
await queryRunner.query(
`CREATE INDEX "IDX_71dddb12f679f697f1123480ad" ON "member_applications" ("inflow_last_source") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."IDX_71dddb12f679f697f1123480ad"`,
);
await queryRunner.query(
`ALTER TABLE "member_applications" DROP COLUMN "inflow_last_content"`,
);
await queryRunner.query(
`ALTER TABLE "member_applications" DROP COLUMN "inflow_last_campaign"`,
);
await queryRunner.query(
`ALTER TABLE "member_applications" DROP COLUMN "inflow_last_medium"`,
);
await queryRunner.query(
`ALTER TABLE "member_applications" DROP COLUMN "inflow_last_source"`,
);
await queryRunner.query(
`ALTER TABLE "member_applications" DROP COLUMN "inflow_last_raw"`,
);
}
}
@@ -47,7 +47,7 @@ export class MemberApplication {
@Column({ name: 'kakao_clicked_at', type: 'timestamptz', nullable: true })
kakaoClickedAt!: Date | null;
/** 신청 순간의 유입정보 원본 (header-top.html 이 저장해 둔 값, 파이프 형식) */
/** 신청 순간의 유입정보 원본 (imweb-header-top.html 이 저장해 둔 값, 파이프 형식) */
@Column({ name: 'inflow_raw', type: 'text', nullable: true })
inflowRaw!: string | null;
@@ -83,4 +83,47 @@ export class MemberApplication {
nullable: true,
})
inflowContent!: string | null;
/*
* 신청 때 최종 유입 — 신청 직전에 마지막으로 들어온 경로 (신청을 일으킨 유입).
* 위 inflow_* 는 신청 시점 브라우저에 남아 있던 '최초' 유입(2일 보관)이라,
* 2일 안에 다른 경로로 다시 들어와 신청하면 둘이 달라진다. 유입이 한 번뿐이면 같은 값이다.
*/
/** 최종 유입 원본 (imweb-header-top.html 의 MG_UTM.peek().last, 파이프 형식) */
@Column({ name: 'inflow_last_raw', type: 'text', nullable: true })
inflowLastRaw!: string | null;
@Index()
@Column({
name: 'inflow_last_source',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowLastSource!: string | null;
@Column({
name: 'inflow_last_medium',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowLastMedium!: string | null;
@Column({
name: 'inflow_last_campaign',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowLastCampaign!: string | null;
@Column({
name: 'inflow_last_content',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowLastContent!: string | null;
}
@@ -22,8 +22,12 @@ import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
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 { ApplicationListQueryDto } from './dto/application-query.dto';
import {
ApplicationInflowStatsQueryDto,
ApplicationListQueryDto,
} from './dto/application-query.dto';
import {
ApplicationInflowStatsResponseDto,
ApplicationProductDto,
ApplicationResponseDto,
} from './dto/application-response.dto';
@@ -104,4 +108,20 @@ export class AdminApplicationsController {
products(): Promise<ApplicationProductDto[]> {
return this.applications.products();
}
@Get('inflow-stats')
@ApiOperation({
summary:
'신청 유입 집계 — 신청 때 최종·최초 유입 또는 가입 경로 기준으로 신청 수를 센다',
})
@ApiStandardResponse(ApplicationInflowStatsResponseDto, {
description: '집계 성공',
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '필터·기준 형식 오류')
inflowStats(
@Query() query: ApplicationInflowStatsQueryDto,
): Promise<ApplicationInflowStatsResponseDto> {
return this.applications.inflowStats(query);
}
}
@@ -110,6 +110,46 @@ describe('ApplicationsService.create', () => {
expect(store).toHaveLength(0);
});
it('최종 유입을 최초 유입과 따로 저장한다 (2일 안에 다른 경로로 다시 와서 신청)', async () => {
const { service, store } = setup();
await service.create({
memberUid: 'user@test.dev',
productIdx: '52',
inflow: 'src=naver|med=organic|jd=2026-10-05',
lastInflow: 'src=facebook|med=paid|cmp=fall|cnt=video_b|ts=2026-10-05',
});
expect(store[0]).toMatchObject({
inflowSource: 'naver',
inflowMedium: 'organic',
inflowLastRaw: 'src=facebook|med=paid|cmp=fall|cnt=video_b|ts=2026-10-05',
inflowLastSource: 'facebook',
inflowLastMedium: 'paid',
inflowLastCampaign: 'fall',
inflowLastContent: 'video_b',
});
});
it('최종 유입이 없으면 최초 유입을 최종 유입으로도 쓴다 (유입이 한 번뿐)', async () => {
const { service, store } = setup();
await service.create({
memberUid: 'user@test.dev',
productIdx: '52',
inflow: 'src=naver|med=cpc|cmp=spring|jd=2026-10-05',
lastInflow: '형식 아님',
});
expect(store[0]).toMatchObject({
inflowSource: 'naver',
inflowLastRaw: null,
inflowLastSource: 'naver',
inflowLastMedium: 'cpc',
inflowLastCampaign: 'spring',
});
});
it('유입정보가 없거나 스크립트 형식이 아니면 비워 둔다', async () => {
const { service, store } = setup();
@@ -1,7 +1,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { IsNull, QueryFailedError, Repository } from 'typeorm';
import {
IsNull,
QueryFailedError,
Repository,
SelectQueryBuilder,
} from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import {
@@ -11,14 +16,21 @@ import {
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.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 { PartnerAttributionService } from '@/modules/partners/partner-attribution.service';
import { parseInflow } from '@/shared/utils/inflow';
import { InflowStatus, parseInflow } from '@/shared/utils/inflow';
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
import { MemberApplication } from './application.entity';
import { ApplicationListQueryDto } from './dto/application-query.dto';
import {
ApplicationFilterDto,
ApplicationInflowBasis,
ApplicationInflowStatsQueryDto,
ApplicationListQueryDto,
} from './dto/application-query.dto';
import {
ApplicationInflowStatsResponseDto,
ApplicationProductDto,
ApplicationResponseDto,
} from './dto/application-response.dto';
@@ -31,6 +43,22 @@ const ALIAS = 'a';
const MEMBER_ALIAS = 'm';
/** Postgres 유일 제약 위반 — 같은 회원·상품이 동시에 두 번 들어온 경우 */
const PG_UNIQUE_VIOLATION = '23505';
/** 집계 결과 최대 줄 수 (회원 유입 집계와 같다) */
const STATS_MAX_ITEMS = 100;
/**
* 유입 기준·항목 → SQL 식 (항목 이름이 곧 컬럼 접미사다: inflow_source 등).
* 가입 경로는 가입 때 기록된 값만 쓴다 — 가입 후 회원정보 수정 화면에서 채워진 값은 가입 경로가 아니다.
* 값은 모두 enum 으로 검증된 것이라 문자열로 넣어도 안전하다.
*/
function inflowExpression(
basis: ApplicationInflowBasis,
field: InflowGroupBy,
): string {
if (basis === 'apply_first') return `${ALIAS}.inflow_${field}`;
if (basis === 'apply_last') return `${ALIAS}.inflow_last_${field}`;
return `CASE WHEN ${MEMBER_ALIAS}.inflow_status = '${InflowStatus.AT_JOIN}' THEN ${MEMBER_ALIAS}.inflow_${field} END`;
}
function escapeLike(value: string): string {
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
@@ -92,6 +120,13 @@ export class ApplicationsService {
now.toISOString(),
this.config.trackingHost,
);
// 최종 유입이 없으면(유입이 한 번뿐이거나 예전 위젯) 최초 유입이 곧 최종 유입이다
const parsedLast = parseInflow(
dto.lastInflow,
now.toISOString(),
this.config.trackingHost,
);
const last = parsedLast.source ? parsedLast : inflow;
const row = this.applications.create({
id: randomUUID(),
memberCode: member.memberCode,
@@ -105,6 +140,13 @@ export class ApplicationsService {
inflowMedium: inflow.medium,
inflowCampaign: inflow.campaign,
inflowContent: inflow.content,
inflowLastRaw: parsedLast.source
? (dto.lastInflow?.trim() ?? null)
: null,
inflowLastSource: last.source,
inflowLastMedium: last.medium,
inflowLastCampaign: last.campaign,
inflowLastContent: last.content,
});
try {
@@ -158,42 +200,7 @@ export class ApplicationsService {
async findMany(
query: ApplicationListQueryDto,
): Promise<PaginatedResult<ApplicationResponseDto>> {
const base = this.applications
.createQueryBuilder(ALIAS)
// 회원 검색을 위해 동기화된 회원과 잇는다 (탈퇴 회원은 회원 정보 없이 남는다)
.leftJoin(
ImwebMember,
MEMBER_ALIAS,
`${MEMBER_ALIAS}.member_code = ${ALIAS}.member_code`,
);
if (query.productIdx) {
base.andWhere(`${ALIAS}.productIdx = :productIdx`, {
productIdx: query.productIdx,
});
}
if (query.appliedFrom) {
base.andWhere(`${ALIAS}.appliedAt >= :appliedFrom`, {
appliedFrom: query.appliedFrom,
});
}
if (query.appliedTo) {
base.andWhere(`${ALIAS}.appliedAt < :appliedTo`, {
appliedTo: query.appliedTo,
});
}
if (query.source) {
base.andWhere(`${ALIAS}.inflowSource = :source`, {
source: query.source,
});
}
if (query.q) {
base.andWhere(
`(${MEMBER_ALIAS}.name ILIKE :q OR ${ALIAS}.member_uid ILIKE :q OR ${MEMBER_ALIAS}.email ILIKE :q OR ${MEMBER_ALIAS}.callnum ILIKE :q)`,
{ q: `%${escapeLike(query.q.trim())}%` },
);
}
const base = this.filtered(query);
const total = await base.clone().getCount();
const page = base
@@ -245,6 +252,12 @@ export class ApplicationsService {
campaign: row.inflowCampaign,
content: row.inflowContent,
},
lastInflow: {
source: row.inflowLastSource,
medium: row.inflowLastMedium,
campaign: row.inflowLastCampaign,
content: row.inflowLastContent,
},
};
}),
{
@@ -261,6 +274,86 @@ export class ApplicationsService {
);
}
/**
* 신청 유입 집계 — 같은 신청을 '신청 때 최종 / 최초 유입' 또는 '가입 경로'로 나눠 센다.
* 기준을 바꿔 보면 "가입은 네이버로 했지만 이번 신청은 페이스북 광고로 왔다" 같은 차이가 보인다.
*/
async inflowStats(
query: ApplicationInflowStatsQueryDto,
): Promise<ApplicationInflowStatsResponseDto> {
const base = this.filtered(query);
const total = await base.clone().getCount();
const rows = await base
.clone()
.select(inflowExpression(query.basis, query.groupBy), 'key')
.addSelect('COUNT(*)', 'count')
.groupBy('key')
.orderBy('count', 'DESC')
.addOrderBy('key', 'ASC', 'NULLS LAST')
.limit(STATS_MAX_ITEMS)
.getRawMany<{ key: string | null; count: string }>();
return {
basis: query.basis,
groupBy: query.groupBy,
total,
items: rows.map((row) => ({ key: row.key, count: Number(row.count) })),
};
}
/** 목록·집계 공통 필터 — 회원 검색·가입 경로를 위해 동기화된 회원과 잇는다 */
private filtered(
filter: ApplicationFilterDto &
Partial<
Pick<
ApplicationListQueryDto,
'source' | 'inflowBasis' | 'inflowField' | 'inflowValue' | 'q'
>
>,
): SelectQueryBuilder<MemberApplication> {
const qb = this.applications
.createQueryBuilder(ALIAS)
// 탈퇴 회원은 회원 정보 없이 남는다
.leftJoin(
ImwebMember,
MEMBER_ALIAS,
`${MEMBER_ALIAS}.member_code = ${ALIAS}.member_code`,
);
if (filter.productIdx) {
qb.andWhere(`${ALIAS}.productIdx = :productIdx`, {
productIdx: filter.productIdx,
});
}
if (filter.appliedFrom) {
qb.andWhere(`${ALIAS}.appliedAt >= :appliedFrom`, {
appliedFrom: filter.appliedFrom,
});
}
if (filter.appliedTo) {
qb.andWhere(`${ALIAS}.appliedAt < :appliedTo`, {
appliedTo: filter.appliedTo,
});
}
if (filter.source) {
qb.andWhere(`${ALIAS}.inflowSource = :source`, { source: filter.source });
}
// 집계 막대를 눌러 거를 때 — 기준·항목·값이 모두 있어야 한다
if (filter.inflowBasis && filter.inflowField && filter.inflowValue) {
qb.andWhere(
`${inflowExpression(filter.inflowBasis, filter.inflowField)} = :inflowValue`,
{ inflowValue: filter.inflowValue },
);
}
if (filter.q) {
qb.andWhere(
`(${MEMBER_ALIAS}.name ILIKE :q OR ${ALIAS}.member_uid ILIKE :q OR ${MEMBER_ALIAS}.email ILIKE :q OR ${MEMBER_ALIAS}.callnum ILIKE :q)`,
{ q: `%${escapeLike(filter.q.trim())}%` },
);
}
return qb;
}
/** 상품별 신청 수 — 최근 신청이 있는 상품부터 */
async products(): Promise<ApplicationProductDto[]> {
const rows = await this.applications
@@ -1,6 +1,7 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { ApiPropertyOptional, IntersectionType } from '@nestjs/swagger';
import {
IsDateString,
IsIn,
IsOptional,
IsString,
Matches,
@@ -8,12 +9,27 @@ import {
} from 'class-validator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { INFLOW_GROUP_BY } from '@/modules/members/dto/member-query.dto';
import type { InflowGroupBy } from '@/modules/members/dto/member-query.dto';
import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow';
const SEARCH_MAX_LENGTH = 100;
/** 관리자 신청 내역 조회 */
export class ApplicationListQueryDto extends CursorPaginationQueryDto {
/**
* 신청 유입을 어느 기록으로 볼 것인가.
* - apply_last : 신청 때 최종 유입 — 신청 직전에 마지막으로 들어온 경로 (신청을 일으킨 유입)
* - apply_first : 신청 때 최초 유입 — 신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)
* - join : 신청한 회원의 가입 경로 — 가입 때 기록된 값만 (가입 후 채워진 값은 제외)
*/
export const APPLICATION_INFLOW_BASIS = [
'apply_last',
'apply_first',
'join',
] as const;
export type ApplicationInflowBasis = (typeof APPLICATION_INFLOW_BASIS)[number];
/** 신청 목록·집계가 함께 쓰는 필터 */
export class ApplicationFilterDto {
@ApiPropertyOptional({ description: '상품번호', example: '52' })
@IsOptional()
@Matches(/^\d{1,20}$/, { message: '상품번호 형식이 올바르지 않습니다.' })
@@ -34,9 +50,16 @@ export class ApplicationListQueryDto extends CursorPaginationQueryDto {
@IsOptional()
@IsDateString({}, { message: '신청 종료일 형식이 올바르지 않습니다.' })
appliedTo?: string;
}
/** 관리자 신청 내역 조회 */
export class ApplicationListQueryDto extends IntersectionType(
CursorPaginationQueryDto,
ApplicationFilterDto,
) {
@ApiPropertyOptional({
description: '신청 순간의 유입원',
description:
'신청 때 최초 유입원 (inflowBasis 필터가 생기기 전부터 있던 값)',
example: 'facebook',
})
@IsOptional()
@@ -44,6 +67,34 @@ export class ApplicationListQueryDto extends CursorPaginationQueryDto {
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
source?: string;
@ApiPropertyOptional({
description:
'유입 필터 기준 — apply_last(신청 때 최종) · apply_first(신청 때 최초) · join(가입 경로). inflowField·inflowValue 와 함께 쓴다',
enum: APPLICATION_INFLOW_BASIS,
})
@IsOptional()
@IsIn(APPLICATION_INFLOW_BASIS, {
message: '유입 기준 값이 올바르지 않습니다.',
})
inflowBasis?: ApplicationInflowBasis;
@ApiPropertyOptional({
description: '유입 필터 항목 — source · medium · campaign · content',
enum: INFLOW_GROUP_BY,
})
@IsOptional()
@IsIn(INFLOW_GROUP_BY, { message: '유입 항목 값이 올바르지 않습니다.' })
inflowField?: InflowGroupBy;
@ApiPropertyOptional({
description: '유입 필터 값 (집계 막대를 눌러 거를 때)',
example: 'facebook',
})
@IsOptional()
@IsString()
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
inflowValue?: string;
@ApiPropertyOptional({
description: '검색어 — 이름·아이디·이메일·연락처 부분 일치',
example: '홍길동',
@@ -53,3 +104,27 @@ export class ApplicationListQueryDto extends CursorPaginationQueryDto {
@MaxLength(SEARCH_MAX_LENGTH, { message: '검색어가 너무 깁니다.' })
q?: string;
}
/** 신청 유입 집계 — 같은 신청을 신청 때 유입 / 가입 경로로 나눠 볼 수 있다 */
export class ApplicationInflowStatsQueryDto extends ApplicationFilterDto {
@ApiPropertyOptional({
description:
'집계 기준 — apply_last(신청 때 최종, 기본) · apply_first(신청 때 최초) · join(가입 경로)',
enum: APPLICATION_INFLOW_BASIS,
default: 'apply_last',
})
@IsOptional()
@IsIn(APPLICATION_INFLOW_BASIS, {
message: '유입 기준 값이 올바르지 않습니다.',
})
basis: ApplicationInflowBasis = 'apply_last';
@ApiPropertyOptional({
description: '집계 항목',
enum: INFLOW_GROUP_BY,
default: 'source',
})
@IsOptional()
@IsIn(INFLOW_GROUP_BY, { message: '집계 기준 값이 올바르지 않습니다.' })
groupBy: InflowGroupBy = 'source';
}
@@ -1,6 +1,10 @@
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';
/** 신청 순간의 유입정보 */
export class ApplicationInflowDto {
@@ -51,10 +55,18 @@ export class ApplicationResponseDto {
member!: MemberResponseDto | null;
@ApiProperty({
description: '신청 순간의 유입정보',
description:
'신청 때 최초 유입 — 신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)',
type: ApplicationInflowDto,
})
inflow!: ApplicationInflowDto;
@ApiProperty({
description:
'신청 때 최종 유입 — 신청 직전에 마지막으로 들어온 경로. 유입이 한 번뿐이면 최초와 같다',
type: ApplicationInflowDto,
})
lastInflow!: ApplicationInflowDto;
}
/** 상품별 신청 수 — 필터 칩에 쓴다 */
@@ -71,3 +83,42 @@ export class ApplicationProductDto {
@ApiProperty({ description: '신청 수', example: 120 })
count!: number;
}
/** 신청 유입 집계 한 줄 */
export class ApplicationInflowStatItemDto {
@ApiProperty({
description: '집계 값 (값이 없으면 null)',
nullable: true,
example: 'facebook',
})
key!: string | null;
@ApiProperty({ description: '신청 수', example: 42 })
count!: number;
}
/** 신청 유입 집계 결과 */
export class ApplicationInflowStatsResponseDto {
@ApiProperty({
description: '집계 기준',
enum: APPLICATION_INFLOW_BASIS,
example: 'apply_last',
})
basis!: ApplicationInflowBasis;
@ApiProperty({
description: '집계 항목',
enum: INFLOW_GROUP_BY,
example: 'source',
})
groupBy!: InflowGroupBy;
@ApiProperty({ description: '필터에 맞는 전체 신청 수', example: 310 })
total!: number;
@ApiProperty({
description: '신청 수 많은 순 (최대 100개, 값 없음은 key=null)',
type: [ApplicationInflowStatItemDto],
})
items!: ApplicationInflowStatItemDto[];
}
@@ -14,7 +14,7 @@ import { CLICK_REF_PATTERN } from '@/shared/utils/partner-link';
const MEMBER_ID_MAX_LENGTH = 255;
const PRODUCT_NAME_MAX_LENGTH = 200;
/** header-top.html 이 만드는 값은 200자 남짓 — 넉넉히 두되 무한정 받지 않는다 */
/** imweb-header-top.html 이 만드는 값은 200자 남짓 — 넉넉히 두되 무한정 받지 않는다 */
const INFLOW_RAW_MAX_LENGTH = 1000;
/** 신청 완료 화면이 보내는 신청 기록 */
@@ -44,7 +44,7 @@ export class CreateApplicationDto {
@ApiPropertyOptional({
description:
'신청 순간의 유입정보 (header-top.html 형식: src=..|med=..|cmp=..)',
'신청 순간의 유입정보 (imweb-header-top.html 형식: src=..|med=..|cmp=..)',
example: 'src=facebook|med=paid|cmp=fall|jd=2026-10-05',
})
@IsOptional()
@@ -54,7 +54,17 @@ export class CreateApplicationDto {
@ApiPropertyOptional({
description:
'브라우저가 보관한 업체 링크 클릭 ID 목록 (imweb/partner-ref.html 의 MG_PARTNER.refs())',
'신청 직전의 최종 유입 (MG_UTM.peek().last 를 같은 파이프 형식으로). 없으면 inflow 를 쓴다',
example: 'src=facebook|med=paid|cmp=fall|ts=2026-10-05',
})
@IsOptional()
@IsString()
@MaxLength(INFLOW_RAW_MAX_LENGTH)
lastInflow?: string;
@ApiPropertyOptional({
description:
'브라우저가 보관한 업체 링크 클릭 ID 목록 (imweb/imweb-header-top.html 의 MG_PARTNER.refs())',
example: ['AbCd1234EfGh5678'],
type: [String],
})
+1 -1
View File
@@ -39,7 +39,7 @@ export const IMWEB_CALLBACK_PATH = '/imweb/oauth/callback';
const DEFAULT_SYNC_INTERVAL_MINUTES = 60;
const DEFAULT_PAGE_SIZE = 100;
/** header-top.html 이 홈페이지 항목에 쓰는 가짜 호스트 ('https://utm.' + OWN_HOST) */
/** imweb-header-top.html 이 홈페이지 항목에 쓰는 가짜 호스트 ('https://utm.' + OWN_HOST) */
export const DEFAULT_TRACKING_HOST = 'utm.moneyguider.co.kr';
/**
@@ -19,6 +19,10 @@ import { CreatePartnerTouchDto } from './dto/partner-touch.dto';
import { PartnerAttributionService } from './partner-attribution.service';
import { PartnerLinksService, SHORT_LINK_PATH } from './partner-links.service';
const REFERRER_POLICY_HEADER = 'Referrer-Policy';
/** 브라우저 기본값 — 다른 사이트로는 출처(도메인)만 보낸다 */
const REDIRECT_REFERRER_POLICY = 'strict-origin-when-cross-origin';
/**
* 업체 단축 링크 — moneyguide.wandergo.net/r/{코드} (main.ts 에서 /api prefix 제외).
*
@@ -46,11 +50,14 @@ export class PartnerRedirectController {
);
// 매 클릭을 서버가 받아야 클릭 ID 가 새로 발급된다 — 브라우저·프록시가 저장하지 않게 한다
res.setHeader('Cache-Control', 'no-store');
// helmet 기본값(no-referrer)이 리다이렉트에 붙으면 업체가 링크를 올린 곳(블로그 등)이 지워져
// 상품 페이지의 UTM 스크립트가 유입원을 direct 로 기록한다 — 출처(도메인)만은 넘긴다
res.setHeader(REFERRER_POLICY_HEADER, REDIRECT_REFERRER_POLICY);
res.redirect(HttpStatus.FOUND, target);
}
}
/** 아임웹 Header Code 스크립트(imweb/partner-ref.html)가 회원 ↔ 링크 클릭 연결을 보낸다 */
/** 아임웹 Header Code(imweb/imweb-header-top.html 의 업체 링크 추적)가 회원 ↔ 링크 클릭 연결을 보낸다 */
@ApiTags('Partner Touches')
@Controller('partner-touches')
export class PartnerTouchesController {
+1 -1
View File
@@ -2,7 +2,7 @@ import { InflowStatus, parseInflow } from './inflow';
const HOST = 'utm.moneyguider.co.kr';
/** header-top.html 의 serialize(asUrl) 와 같은 방식으로 홈페이지 값을 만든다 */
/** imweb-header-top.html 의 serialize(asUrl) 와 같은 방식으로 홈페이지 값을 만든다 */
function homePage(params: Record<string, string>): string {
const query = Object.entries(params)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* 회원 유입경로 파싱 유틸 (순수 함수).
*
* 아임웹 Header Code 의 UTM 스크립트(header-top.html)가 회원가입 폼의 '홈페이지' 항목에
* 아임웹 Header Code 의 UTM 스크립트(imweb/imweb-header-top.html)가 회원가입 폼의 '홈페이지' 항목에
* 아래 형식으로 유입정보를 기록한다. 아임웹 Open API 회원 조회에는 사용자 정의 가입 항목이
* 나오지 않고 homePage 만 나오기 때문에 이 값을 풀어서 쓴다.
*
+2 -2
View File
@@ -2,11 +2,11 @@
* 업체 링크 유틸 (순수 함수) — 이동 주소 검증, 클릭 ID 부착, 봇 판별.
*
* 업체에 주는 단축 링크(/r/{코드})를 누르면 서버가 클릭마다 고유 ID 를 발급해
* 상품 페이지 주소에 `mg_ref` 로 붙여 보낸다. 아임웹 Header Code 스크립트(imweb/partner-ref.html)가
* 상품 페이지 주소에 `mg_ref` 로 붙여 보낸다. 아임웹 Header Code 스크립트(imweb/imweb-header-top.html 의 업체 링크 추적)가
* 이 값을 브라우저에 보관했다가, 로그인한 회원이 확인되면 서버에 알려 회원과 클릭을 잇는다.
*/
/** 상품 페이지 주소에 붙이는 클릭 ID 파라미터 — imweb/partner-ref.html 과 같아야 한다 */
/** 상품 페이지 주소에 붙이는 클릭 ID 파라미터 — imweb/imweb-header-top.html 과 같아야 한다 */
export const PARTNER_REF_PARAM = 'mg_ref';
/** 업체 링크 코드 — /r/{코드} */
+30 -5
View File
@@ -12,10 +12,22 @@
아임웹 Open API /member-info/members ← homePage 에 UTM 스크립트가 기록한 유입정보
```
유입정보는 아임웹 Header Code 의 UTM 스크립트(`header-top.html`)가 회원가입 폼의
유입정보는 아임웹 Header Code 의 UTM 스크립트(`imweb-header-top.html`)가 회원가입 폼의
'홈페이지' 항목에 `https://utm.moneyguider.co.kr/?src=..&med=..&jd=..` 형식으로 남긴 값이다.
백엔드가 이 값을 풀어서 유입원·매체·캠페인별로 조회·집계한다.
## 파일 — 아임웹 어디에 붙이나
아임웹에 실제로 들어가 있는 코드의 원본이다. 고친 뒤에는 아임웹에 **다시 붙여 넣어야** 반영된다.
| 파일 | 아임웹 위치 | 하는 일 |
| --- | --- | --- |
| `imweb-header-top.html` | Header Code (사이트 전체) | UTM 유입경로 기록(`MG_UTM`) + 업체 링크 클릭 보관(`MG_PARTNER`) |
| `imweb-header.html` | Header Code (사이트 전체) | 로그인 링크의 back_url 에 UTM 을 붙여 로그인 후에도 유지 |
| `imweb-free-apply.html` | `/apply` 페이지 HTML 위젯 | 신청 완료 화면 + 신청 기록·오픈카톡 클릭 전송 |
| `imweb-members.html` | 관리자 전용 페이지 코드 위젯 | 관리자 대시보드 |
| `serve-local.js` | — | 로컬 테스트용 정적 서버 |
## 로컬에서 띄우기
1. 백엔드 env 에 아임웹 앱 키와 관리자 계정 입력 — `backend/.env.development`
@@ -65,7 +77,18 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
아임웹 `/apply` 페이지의 신청 완료 위젯(`imweb-free-apply.html`)이 표시될 때
`POST /api/applications` 로 신청을 남긴다. 관리자 화면 **신청 내역** 메뉴에서 본다.
- 보내는 값: `MEMBER_UID`(회원 아이디 또는 회원 코드), 상품번호(`idx`), 상품명, 그 순간의 유입정보(`MG_UTM.peek()`), 업체 링크 클릭 ID(`MG_PARTNER.refs()`)
- 보내는 값: `MEMBER_UID`(회원 아이디 또는 회원 코드), 상품번호(`idx`), 상품명,
최초 유입(`MG_UTM.peek().value`), 최종 유입(`MG_UTM.peek().last` 를 같은 파이프 형식으로),
업체 링크 클릭 ID(`MG_PARTNER.refs()`) — `peek()` 는 문자열이 아니라 객체를 돌려준다
- **가입 경로와 신청 유입은 따로 남는다** — 관리자 화면 신청 내역의 '신청 유입 집계'에서 기준을 바꿔 비교한다
| 기준 | 값 | 예) 3주 전 네이버로 가입 → 어제 블로그로 방문 → 오늘 페이스북 광고로 와서 신청 |
| --- | --- | --- |
| 신청 때 (최종) | 신청 직전에 마지막으로 들어온 경로 — 신청을 일으킨 유입 | facebook |
| 신청 때 (최초) | 신청 시점 브라우저에 남아 있던 첫 경로 (UTM 스크립트 보관 2일) | blog |
| 가입 경로 | 그 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외) | naver |
유입이 한 번뿐이면 최초 = 최종이다. 다른 기기에서 신청하면 신청 유입은 direct 로 남는다.
- 백엔드는 아임웹에 실제로 있는 회원인지 확인하고(동기화 전 신규 가입자는 아임웹 API 로 직접 확인),
회원·상품당 한 건만 남긴다. 이름·연락처는 브라우저 값이 아니라 아임웹 회원 정보를 쓴다
- 오픈카톡 버튼 클릭은 `POST /api/applications/{id}/kakao-clicks` 로 처음 한 번만 남는다
@@ -83,7 +106,8 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
업체에 링크 전달 ── https://moneyguide.wandergo.net/r/{코드}
│ ① 클릭 기록 (카톡 미리보기·검색봇 제외) 후 상품 페이지로 이동하며 클릭 ID(mg_ref)를 붙인다
▼
상품 페이지 ── partner-ref.html(Header Code)이 클릭 ID 를 30일 보관하고 주소창에서 지운다
상품 페이지 ── imweb-header-top.html(Header Code)의 업체 링크 추적이 클릭 ID 를 30일 보관하고 주소창에서 지운다
│ (UTM 스크립트처럼 localStorage + .moneyguider.co.kr 쿠키 — www ↔ apex 이동에도 유지)
│ ② 로그인 회원이 확인되면 "이 회원이 이 클릭으로 들어왔다"를 POST /api/partner-touches 로 알린다
▼
신청: 신청 완료 위젯이 신청 기록에 클릭 ID 를 함께 보낸다 → 바로 귀속
@@ -108,7 +132,8 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
2. 백엔드 배포 (마이그레이션 자동 실행)
3. 관리자 화면 **연동 관리 → 아임웹 연결**을 다시 한다 — 새 권한이 담긴 토큰을 받는다.
다시 연결하기 전에는 상품 불러오기·구매 집계가 `IMWEB_006`(권한 부족)으로 실패한다. 회원 동기화는 그대로 돈다
4. `partner-ref.html` 을 아임웹 **Header Code** 에 붙여 넣는다 (사이트 전체)
4. `imweb-header-top.html` 을 아임웹 **Header Code** 에 다시 붙여 넣는다 (UTM 스크립트 아래에 업체 링크 추적이 추가됨).
`imweb-header.html`(로그인 링크 UTM 보존)은 바꾸지 않았다
5. `imweb-free-apply.html` 을 신청 완료 위젯에 다시 붙여 넣는다 (신청 기록·클릭 ID 전송 추가됨)
6. 업체 링크 메뉴에서 **상품 불러오기** → 업체 추가 → 링크 발급
@@ -147,7 +172,7 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
- `imweb-members.html` 의 `API_BASE` 운영 주소(`https://moneyguide.wandergo.net/api`)를 확인하고 아임웹 코드 위젯에 붙여 넣는다
- 아임웹에서 그 페이지의 접근 권한을 관리자로 제한한다
- 업체 링크를 쓰려면 위 '업체 링크 → 처음 켤 때' 순서를 따른다
(`partner-ref.html`·`imweb-free-apply.html` 의 `API_BASE` 도 같은 운영 주소)
(`imweb-header-top.html`·`imweb-free-apply.html` 의 `API_BASE` 도 같은 운영 주소)
## 유입 기록 상태
+29 -4
View File
@@ -434,16 +434,39 @@
})();
}
/* header-top.html(UTM 스크립트)이 보관한 유입정보 — 없으면 빈 값 */
/* imweb-header-top.html(UTM 스크립트)이 보관한 유입정보 — 없으면 빈 값.
MG_UTM.peek() 는 { first, last, value, … } 객체를 돌려주고,
서버가 받는 파이프 형식(src=..|med=..|…)은 그중 value 다. */
function readInflow() {
try {
var value = window.MG_UTM && typeof window.MG_UTM.peek === 'function'
? window.MG_UTM.peek() : '';
var peeked = window.MG_UTM && typeof window.MG_UTM.peek === 'function'
? window.MG_UTM.peek() : null;
var value = peeked && typeof peeked === 'object' ? peeked.value : peeked;
return typeof value === 'string' ? value.slice(0, INFLOW_MAX) : '';
} catch (e) { return ''; }
}
/* partner-ref.html(업체 링크 스크립트)이 보관한 클릭 ID — 없으면 빈 배열 */
/* 신청 직전의 최종 유입 — MG_UTM.peek().last 객체를 위와 같은 파이프 형식으로 바꾼다.
최초 유입(위 value)은 2일간 처음 들어온 경로라, 그 사이 다른 경로로 다시 와서
신청하면 신청을 일으킨 경로는 last 에만 있다. 없으면 빈 값 (서버가 최초 유입으로 채운다) */
var LAST_KEYS = ['src', 'med', 'cmp', 'trm', 'cnt', 'cid', 'ref', 'lp', 'pid', 'ts'];
function readLastInflow() {
try {
var peeked = window.MG_UTM && typeof window.MG_UTM.peek === 'function'
? window.MG_UTM.peek() : null;
var last = peeked && typeof peeked === 'object' ? peeked.last : null;
if (!last || typeof last !== 'object' || !last.src) return '';
var parts = [];
for (var i = 0; i < LAST_KEYS.length; i++) {
var v = last[LAST_KEYS[i]];
/* UTM 스크립트가 이미 | = 를 지운 값이지만 한 번 더 막는다 */
if (v) parts.push(LAST_KEYS[i] + '=' + String(v).replace(/[|=]/g, ' '));
}
return parts.join('|').slice(0, INFLOW_MAX);
} catch (e) { return ''; }
}
/* imweb-header-top.html(업체 링크 추적)이 보관한 클릭 ID — 없으면 빈 배열 */
function readPartnerRefs() {
try {
var refs = window.MG_PARTNER && typeof window.MG_PARTNER.refs === 'function'
@@ -462,6 +485,8 @@
if (name) body.productName = name.slice(0, PRODUCT_NAME_MAX);
var inflow = readInflow();
if (inflow) body.inflow = inflow;
var lastInflow = readLastInflow();
if (lastInflow) body.lastInflow = lastInflow;
var refs = readPartnerRefs();
if (refs.length) body.partnerRefs = refs;
+622
View File
@@ -0,0 +1,622 @@
<meta name="facebook-domain-verification" content="sldnjjycwvg0acy344qq4zu3mhxh72" />
<meta name="facebook-domain-verification" content="rjbwdcpgd8bp4a5220oshav850duv1" />
<!-- ============================================================
[자본주의가이드] UTM 기반 회원가입 유입경로 자동추적 v1.1
설치 위치: 아임웹 관리자 > 환경설정 > SEO(검색엔진최적화)
> 고급 > 공통 코드 삽입 > "Header Code"
대상 필드: 회원가입 사용자 정의 항목 "유입경로(필수)" = join_form_64454
+ 기본 항목 "홈페이지" (URL 형식으로 같은 정보 기록)
사용자 정의 항목은 Open API 회원 조회에 나오지 않고,
홈페이지는 homePage 필드로 나오기 때문에 둘 다 채운다.
커버 범위: 카카오 1초 회원가입 / 카카오 소셜 가입 / 아임웹 일반 회원가입
============================================================ -->
<script>
(function () {
'use strict';
/* ===================== 설정 ===================== */
var CFG = {
FIELD_ID: 'join_64454', // 아임웹이 렌더링하는 input id (name=join_form_64454)
FIELD_LABEL: '유입경로', // id를 못 찾을 때 라벨 텍스트로 재탐색 (항목 재생성 대비)
COOKIE_DOMAIN: '.moneyguider.co.kr', // www / apex 양쪽 공유
TTL_DAYS: 2, // 최초유입 보관 기간
HIDE_FIELD: true, // true=사용자에게 숨김, false=읽기전용으로 노출
HP_ENABLED: true, // 홈페이지 항목에도 유입정보 기록 (Open API homePage 로 조회용)
HP_LABEL: '홈페이지', // 홈페이지 입력칸을 name/id로 못 찾을 때 라벨로 재탐색
OWN_HOST: 'moneyguider.co.kr',
DEBUG: false // true로 바꾸면 콘솔에 로그 출력
};
var K_FIRST = 'mg_utm_first'; // 최초 유입 (first-touch)
var K_LAST = 'mg_utm_last'; // 최종 유입 (last-touch)
function log() {
if (CFG.DEBUG && window.console && console.log) {
console.log.apply(console, ['[MG-UTM]'].concat([].slice.call(arguments)));
}
}
/* ============ 저장소: localStorage + 쿠키 이중화 ============
카카오 인앱브라우저 / Safari ITP 등에서 localStorage가 막히거나
초기화되는 경우를 대비해 쿠키에도 동일 값을 기록한다. */
function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }
function ckGet(k) {
var m = document.cookie.match('(^|;)\\s*' + k + '\\s*=\\s*([^;]*)');
if (!m) return null;
try { return decodeURIComponent(m[2]); } catch (e) { return m[2]; }
}
function ckSet(k, v, days) {
var d = new Date();
d.setTime(d.getTime() + days * 86400000);
document.cookie = k + '=' + encodeURIComponent(v) +
';expires=' + d.toUTCString() +
';path=/;domain=' + CFG.COOKIE_DOMAIN + ';SameSite=Lax';
}
/* localStorage에는 만료 개념이 없으므로 저장값 안에 만료 시각(_e)을 함께 넣고
읽을 때 직접 확인한다. 이렇게 해야 쿠키가 지워진 뒤에도 TTL_DAYS가 실제로 지켜진다. */
function saveTouch(k, t) {
var o = merge(t, { _e: Date.now() + CFG.TTL_DAYS * 86400000 });
var json = JSON.stringify(o);
lsSet(k, json);
ckSet(k, json, CFG.TTL_DAYS);
}
function dropTouch(k) {
try { localStorage.removeItem(k); } catch (e) { }
ckSet(k, '', -1);
}
function readTouch(k) {
var raw = lsGet(k) || ckGet(k);
if (!raw) return null;
var o = null;
try { o = JSON.parse(raw); } catch (e) { dropTouch(k); return null; }
if (!o || typeof o !== 'object') { dropTouch(k); return null; }
if (o._e && Date.now() > o._e) { dropTouch(k); return null; } // 보관기간 경과
delete o._e;
return o;
}
/* ===================== 유틸 ===================== */
// 파이프 포맷을 깨뜨리는 문자 제거 + 길이 제한
function clean(v, n) {
return String(v == null ? '' : v)
.replace(/[|=\r\n\t]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, n || 80);
}
function parseQuery() {
var out = {}, s = location.search.replace(/^\?/, '');
if (!s) return out;
var parts = s.split('&');
for (var i = 0; i < parts.length; i++) {
if (!parts[i]) continue;
var p = parts[i], j = p.indexOf('=');
var k = j < 0 ? p : p.slice(0, j);
var v = j < 0 ? '' : p.slice(j + 1);
try { out[decodeURIComponent(k)] = decodeURIComponent(v.replace(/\+/g, ' ')); }
catch (e) { out[k] = v; }
}
return out;
}
function merge(a, b) {
var o = {}, k;
for (k in a) if (Object.prototype.hasOwnProperty.call(a, k)) o[k] = a[k];
for (k in b) if (Object.prototype.hasOwnProperty.call(b, k)) o[k] = b[k];
return o;
}
// 한국 시간 기준 날짜 (toISOString 은 UTC 라서 0~9시에는 전날로 찍힌다)
function today() { return new Date(Date.now() + 9 * 3600000).toISOString().slice(0, 10); }
/* ============ 리퍼러 분류 (UTM이 없는 자연유입 보정) ============ */
var REF_MAP = [
[/^(m\.)?(blog|cafe|post|in)\.naver\.com$/i, 'naver_blog', 'referral'],
[/(^|\.)naver\.com$/i, 'naver', 'organic'],
[/(^|\.)google\./i, 'google', 'organic'],
[/(^|\.)bing\.com$/i, 'bing', 'organic'],
[/(^|\.)daum\.net$/i, 'daum', 'organic'],
[/(^|\.)kakao\.com$/i, 'kakao', 'social'],
[/(^|\.)instagram\.com$/i, 'instagram', 'social'],
[/(^|\.)(facebook|fb)\.com$/i, 'facebook', 'social'],
[/(^|\.)(youtube\.com|youtu\.be)$/i, 'youtube', 'social'],
[/(^|\.)tiktok\.com$/i, 'tiktok', 'social'],
[/(^|\.)threads\.(net|com)$/i, 'threads', 'social'],
[/(^|\.)(tistory|brunch|velog|medium)\./i, 'blog', 'referral']
];
/* 인증·가입 관련 페이지.
카카오/네이버 소셜 로그인은 외부 인증 도메인을 왕복하는데, 돌아올 때의
리퍼러(kauth.kakao.com 등)를 새 유입으로 오해하면 원래 유입원이 가려진다.
이 경로들에서는 URL에 UTM이 직접 있을 때만 기록한다. */
var AUTH_PATH = /^\/(oauth|site_join|login|logout|member|social|join)/i;
/* 소셜 로그인 인증 도메인.
위 AUTH_PATH는 '인증 페이지에 있을 때'를 막지만, 인증을 마치고
사이트의 일반 페이지로 곧장 되돌아오는 경우는 못 막는다.
예) 신청 버튼 → 가입 → 카카오 인증 → /apply 로 복귀
이때 /apply 는 AUTH_PATH에 걸리지 않고 리퍼러가
kauth.kakao.com 이라, REF_MAP이 '카카오 소셜 유입'으로
오인해 최종유입을 덮어쓴다. 원래 유입원(네이버 광고 등)이
지워지므로 리퍼러 쪽에서도 막는다.
※ kakao.com / naver.com 전체가 아니라 인증 전용 호스트만 넣는다.
카카오톡 공유로 들어온 진짜 유입까지 무시하면 안 된다. */
var AUTH_REF = /^(kauth\.kakao\.com|accounts\.kakao\.com|nid\.naver\.com|accounts\.google\.com|appleid\.apple\.com)$/i;
// 광고 클릭 ID -> 매체 추정 (UTM이 누락된 광고 트래픽 보정)
var CID_SRC = {
gclid: 'google', gbraid: 'google', wbraid: 'google',
fbclid: 'meta', kclid: 'kakao', ttclid: 'tiktok',
msclkid: 'bing', yclid: 'yandex', n_ad_group: 'naver'
};
/* ============ 현재 페이지의 유입정보 추출 ============ */
function buildTouch() {
var q = parseQuery();
var t = {};
var hasUtm = false;
var UTM = {
utm_source: 'src', utm_medium: 'med', utm_campaign: 'cmp',
utm_term: 'trm', utm_content: 'cnt'
};
for (var k in UTM) {
if (q[k]) { t[UTM[k]] = clean(q[k]); hasUtm = true; }
}
// 광고 클릭 ID
for (var cid in CID_SRC) {
if (q[cid]) {
t.cid = cid;
if (!hasUtm) { t.src = CID_SRC[cid]; t.med = 'cpc'; hasUtm = true; }
break;
}
}
// 리퍼러 (외부 도메인일 때만)
var ref = '';
try {
if (document.referrer) {
var h = document.referrer.split('/')[2] || '';
h = h.split('?')[0];
if (h && h.indexOf(CFG.OWN_HOST) === -1) ref = h.toLowerCase();
}
} catch (e) { }
if (!hasUtm) {
if (ref) {
var named = null;
for (var i = 0; i < REF_MAP.length; i++) {
if (REF_MAP[i][0].test(ref)) { named = REF_MAP[i]; break; }
}
t.src = named ? named[1] : clean(ref, 40);
t.med = named ? named[2] : 'referral';
} else {
t.src = 'direct';
t.med = 'none';
}
}
if (ref) t.ref = clean(ref, 40);
t.lp = clean(location.pathname, 60);
// 아임웹 상품/게시글 식별자 (/free-shop/?idx=52 처럼 idx로 페이지가 갈리는 경우)
if (q.idx) t.pid = clean(q.idx, 20);
t.ts = today();
t._utm = hasUtm; // URL에 UTM/클릭ID가 실제로 있었는지 (저장 전 제거)
return t;
}
/* ============ 유입정보 기록 ============ */
function capture() {
var t = buildTouch();
var explicitUtm = t._utm;
delete t._utm;
// 소셜 로그인 왕복(kauth.kakao.com 등)을 새 유입으로 오해하지 않도록,
// 인증·가입 페이지에서는 URL에 UTM이 직접 붙어 있을 때만 기록한다.
if (AUTH_PATH.test(location.pathname) && !explicitUtm) return;
// 인증을 마치고 일반 페이지(/apply 등)로 곧장 복귀한 경우도 동일하게 무시.
if (t.ref && AUTH_REF.test(t.ref) && !explicitUtm) return;
var isDirect = (t.src === 'direct');
// 최종유입: 실제 외부 유입일 때만 갱신 (사이트 내부 이동으로 덮어쓰지 않음)
if (!isDirect) saveTouch(K_LAST, t);
var prev = readTouch(K_FIRST);
if (!prev) {
saveTouch(K_FIRST, t); // 최초 1회 (또는 보관기간 만료 후 재시작)
} else if (!isDirect && prev.src === 'direct') {
saveTouch(K_FIRST, t); // 이전 기록이 direct였다면 실제 유입원으로 승격
}
log('capture', t);
}
/* ============ 필드에 넣을 최종 값 계산 ============ */
function resolve() {
var f = readTouch(K_FIRST);
var l = readTouch(K_LAST);
var t = f || l || { src: 'direct', med: 'none', lp: clean(location.pathname, 60), ts: today() };
// 최초유입과 최종유입이 다르면 last 항목을 덧붙임 (재방문 전환 추적)
if (f && l && (f.src !== l.src || f.cmp !== l.cmp)) {
t = merge(t, { last: clean(l.src + (l.cmp ? '/' + l.cmp : ''), 60) });
}
t.jd = today(); // 가입일
return t;
}
var ORDER = ['src', 'med', 'cmp', 'trm', 'cnt', 'cid', 'ref', 'lp', 'pid', 'ts', 'last', 'jd'];
/* 홈페이지 항목용 순서. 길이 제한(maxlength)에 걸리면 뒤에서부터 뺀다.
jd(기록일)는 API 의 joinTime 과 비교해, 가입 때가 아니라 나중에
회원정보 수정 화면에서 채워진 값을 걸러내는 데 쓰므로 src/med 바로 뒤에 둔다. */
var HP_ORDER = ['src', 'med', 'jd', 'cmp', 'cnt', 'trm', 'last', 'cid', 'lp', 'pid', 'ref', 'ts'];
function serialize(t, asUrl, order) {
order = order || ORDER;
var pairs = [];
for (var i = 0; i < order.length; i++) {
var k = order[i];
if (t[k]) pairs.push([k, t[k]]);
}
if (asUrl) {
// 항목 형식이 'URL'인 동안의 호환 모드 (HTML5 url 검증 통과용)
var qsv = [];
for (var j = 0; j < pairs.length; j++) {
qsv.push(encodeURIComponent(pairs[j][0]) + '=' + encodeURIComponent(pairs[j][1]));
}
return 'https://utm.' + CFG.OWN_HOST + '/?' + qsv.join('&');
}
var out = [];
for (var n = 0; n < pairs.length; n++) out.push(pairs[n][0] + '=' + pairs[n][1]);
return out.join('|');
}
function fallbackValue(asUrl) {
return asUrl ? ('https://utm.' + CFG.OWN_HOST + '/?src=direct&med=none')
: 'src=direct|med=none';
}
// 홈페이지 항목 값: 항상 URL 형식, maxlength 가 있으면 넘지 않게 낮은 우선순위부터 뺀다
function homepageValue(el) {
var t = resolve();
var max = el && el.maxLength > 0 ? el.maxLength : 0;
var order = HP_ORDER.slice();
var v = serialize(t, true, order);
while (max && v.length > max && order.length > 2) { // src, med 는 끝까지 남긴다
order.pop();
v = serialize(t, true, order);
}
return v;
}
/* ============ 대상 필드 찾기 ============
기본은 고정 id(join_64454). 관리자에서 항목을 지웠다 다시 만들면 아임웹이
새 인덱스를 발급하므로, id를 못 찾으면 라벨 텍스트로 한 번 더 찾는다. */
function findField() {
var el = document.getElementById && document.getElementById(CFG.FIELD_ID);
if (el) return el;
if (!CFG.FIELD_LABEL || !document.querySelectorAll) return null;
try {
var labels = document.querySelectorAll('#join_form label');
for (var i = 0; i < labels.length; i++) {
var txt = (labels[i].textContent || '').replace(/\s+/g, '');
if (txt.indexOf(CFG.FIELD_LABEL) === -1) continue;
var blk = labels[i].parentElement;
var input = blk && blk.querySelector(
'input[name^="join_form_"], select[name^="join_form_"], textarea[name^="join_form_"]');
if (input) {
log('id 미발견 - 라벨로 찾음', input.name);
return input;
}
}
} catch (e) { log('findField 실패', e); }
return null;
}
/* 홈페이지 입력칸. 아임웹 기본 항목이라 name/id 가 고정일 가능성이 높지만,
항목을 켠 뒤의 실제 마크업을 확인하기 전이므로 후보 이름과 라벨로 찾는다. */
function findHomepage() {
if (!CFG.HP_ENABLED || !document.querySelector) return null;
try {
var el = document.querySelector(
'input[name="homepage"], input[name="homePage"], input[name="home_page"], ' +
'input[id*="homepage"], input[id*="homePage"], input[name*="homepage"]');
if (el) return el;
var labels = document.querySelectorAll('form label');
for (var i = 0; i < labels.length; i++) {
var txt = (labels[i].textContent || '').replace(/\s+/g, '');
if (txt.indexOf(CFG.HP_LABEL) === -1) continue;
var blk = labels[i].parentElement;
var input = blk && blk.querySelector('input[type="url"], input[type="text"]');
if (input) return input;
}
} catch (e) { log('findHomepage 실패', e); }
return null;
}
/* ============ 회원가입 폼에 주입 ============ */
// 회원가입 폼(#join_form) 안의 입력칸인지
function inJoinForm(el) {
return !!(el.form && el.form.id === 'join_form');
}
function conceal(el) {
if (el.getAttribute('data-mg-utm') === '1') return;
el.setAttribute('data-mg-utm', '1');
var block = el.closest ? el.closest('.input_block') : null;
if (CFG.HIDE_FIELD) {
if (block) block.style.display = 'none';
else el.style.display = 'none';
} else {
el.readOnly = true;
el.setAttribute('tabindex', '-1');
el.style.background = '#f5f6f7';
el.style.color = '#8a8f98';
}
}
function put(el, value) {
// 가입 폼이 아닌 곳(회원정보 수정 화면 등)에 이미 값이 있으면 가입 때 기록된 값이므로 덮어쓰지 않는다
if (el.value && !inJoinForm(el)) { conceal(el); return; }
el.value = value;
// HTML5 검증 실패 시 URL 포맷으로 폴백 (항목 형식이 URL인 경우 방어)
if (el.checkValidity && !el.checkValidity()) {
el.value = serialize(resolve(), true);
if (!el.checkValidity()) el.value = fallbackValue(true);
}
// 아임웹 자체 검증 로직에 값 변경을 알림
var evts = ['input', 'change', 'keyup', 'blur'];
for (var i = 0; i < evts.length; i++) {
try { el.dispatchEvent(new Event(evts[i], { bubbles: true })); } catch (e) { }
}
if (window.jQuery) { try { jQuery(el).trigger('change').trigger('blur'); } catch (e) { } }
conceal(el);
log('fill', el.name || el.id, el.value);
}
function fill() {
var el = findField();
if (!el) return false;
put(el, serialize(resolve(), el.type === 'url'));
// 홈페이지 항목은 같은 폼에 함께 렌더링되므로 유입경로 칸을 찾은 시점에 같이 채운다
var hp = findHomepage();
if (hp) put(hp, homepageValue(hp));
return true;
}
/* ============ 폼 제출 직전 최종 방어 ============
필수 + 숨김 상태에서 값이 비면 브라우저가 제출을 막으므로
어떤 경우에도 빈 값으로 남지 않게 한다. */
function guard() {
function ensure() {
var el = findField();
if (el && !el.value) {
var asUrl = (el.type === 'url');
el.value = serialize(resolve(), asUrl) || fallbackValue(asUrl);
log('guard refill', el.value);
}
var hp = findHomepage();
if (hp && !hp.value) {
hp.value = homepageValue(hp) || fallbackValue(true);
log('guard refill homepage', hp.value);
}
}
document.addEventListener('submit', ensure, true);
document.addEventListener('click', function (e) {
var tg = e.target;
if (!tg || !tg.closest) return;
if (tg.closest('button, input[type=submit], a')) ensure();
}, true);
}
/* ===================== 부팅 ===================== */
capture();
function start() {
guard();
if (fill()) return;
// 아임웹은 폼을 지연 렌더링/재렌더링하므로 DOM 변화를 감시
var stop = false, mo = null;
function done() {
stop = true;
if (mo) { try { mo.disconnect(); } catch (e) { } }
}
if (window.MutationObserver && document.body) {
mo = new MutationObserver(function () { if (!stop && fill()) done(); });
mo.observe(document.body, { childList: true, subtree: true });
}
// 관측자 미지원/누락 대비 폴링 (최대 20초)
var tries = 0;
var timer = setInterval(function () {
if (stop || fill() || ++tries > 40) { clearInterval(timer); done(); }
}, 500);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
// 디버그용 핸들: 콘솔에서 MG_UTM.peek() 실행
window.MG_UTM = {
peek: function () {
var raw = lsGet(K_FIRST) || ckGet(K_FIRST);
var exp = null;
try { var o = JSON.parse(raw); if (o && o._e) exp = new Date(o._e).toISOString().slice(0, 10); } catch (e) { }
return {
first: readTouch(K_FIRST),
last: readTouch(K_LAST),
expiresOn: exp, // 이 날짜가 지나면 최초유입이 초기화됨
value: serialize(resolve(), false),
homepage: homepageValue(findHomepage()) // 홈페이지 항목에 들어갈 값
};
},
reset: function () {
dropTouch(K_FIRST); dropTouch(K_LAST);
return 'cleared';
},
refill: fill
};
})();
</script>
<!-- ============================================================
[자본주의가이드] 업체 링크 추적 v1.0
위 UTM 스크립트와 따로 동작한다 (한쪽이 실패해도 다른 쪽은 영향 없음).
1) 업체 단축 링크(moneyguide.wandergo.net/r/코드)를 누르고 들어오면 주소에 붙은
mg_ref(클릭 ID)를 30일간 보관하고 주소창에서는 지운다.
(주소를 그대로 공유해도 다른 사람이 그 업체 실적으로 잡히지 않게)
2) 로그인한 회원이 확인되면 "이 회원이 이 클릭으로 들어왔다"를 서버에 한 번 알린다.
→ 구매는 서버가 아임웹 주문을 1시간마다 확인해 이 기록으로 업체에 귀속한다.
3) 신청 완료 위젯(imweb-free-apply.html)이 window.MG_PARTNER.refs() 로 같은 값을 읽어
신청 기록에 함께 보낸다.
저장: UTM 스크립트처럼 localStorage + .moneyguider.co.kr 쿠키 이중화
(www ↔ apex 이동, 카카오 인앱브라우저·Safari ITP 대비).
클릭 ID 와 보관 시각만 저장한다 — 회원 정보는 저장하지 않는다.
============================================================ -->
<script>
(function () {
'use strict';
/* ===================== 설정 ===================== */
var API_BASE = 'https://moneyguide.wandergo.net/api'; // 백엔드 주소
var COOKIE_DOMAIN = '.moneyguider.co.kr'; // 위 UTM 스크립트와 같은 값
/* 아래는 백엔드(shared/utils/partner-link.ts)와 같아야 한다 */
var PARAM = 'mg_ref';
var REF_PATTERN = /^[0-9A-Za-z]{16}$/;
var TTL_DAYS = 30; // 귀속 기간
var DAY_MS = 86400000;
var MAX_REFS = 10; // 쿠키가 커지지 않게 최근 것만 둔다
var K_REFS = 'mg_partner_refs';
var K_SENT = 'mg_partner_sent'; // 이미 서버에 알린 클릭 ID
/* 아임웹 전역(MEMBER_UID)은 Header Code 보다 늦게 생긴다 */
var WAIT_MS = 5000;
var POLL_MS = 200;
/* ============ 저장소: localStorage + 쿠키 이중화 ============ */
function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }
function ckGet(k) {
var m = document.cookie.match('(^|;)\\s*' + k + '\\s*=\\s*([^;]*)');
if (!m) return null;
try { return decodeURIComponent(m[2]); } catch (e) { return m[2]; }
}
function ckSet(k, v, days) {
var d = new Date();
d.setTime(d.getTime() + days * DAY_MS);
document.cookie = k + '=' + encodeURIComponent(v) +
';expires=' + d.toUTCString() + ';path=/;domain=' + COOKIE_DOMAIN + ';SameSite=Lax';
}
function read(k) { return lsGet(k) || ckGet(k) || ''; }
function write(k, v) { lsSet(k, v); ckSet(k, v, TTL_DAYS); }
/* 저장 형식 'ref~보관시각(36진수),…' — 쿠키 크기를 줄이려고 JSON 대신 짧게 쓴다.
30일이 지났거나 형식이 아닌 값은 버린다. */
function loadRefs() {
var out = [], now = Date.now(), parts = read(K_REFS).split(',');
for (var i = 0; i < parts.length; i++) {
var p = parts[i].split('~');
var at = parseInt(p[1], 36);
if (REF_PATTERN.test(p[0]) && at > 0 && now - at <= TTL_DAYS * DAY_MS) {
out.push({ ref: p[0], at: at });
}
}
return out;
}
function saveRefs(list) {
var parts = [];
for (var i = 0; i < list.length; i++) parts.push(list[i].ref + '~' + list[i].at.toString(36));
write(K_REFS, parts.join(','));
}
function refsOnly(list) {
var out = [];
for (var i = 0; i < list.length; i++) out.push(list[i].ref);
return out;
}
/* ============ 1) 주소의 클릭 ID 보관 + 주소창 정리 ============ */
var refs = loadRefs();
try {
var url = new URL(window.location.href);
var ref = url.searchParams.get(PARAM);
if (ref && REF_PATTERN.test(ref)) {
var kept = [];
for (var i = 0; i < refs.length; i++) if (refs[i].ref !== ref) kept.push(refs[i]);
kept.push({ ref: ref, at: Date.now() }); // 최근 것이 뒤
refs = kept.slice(-MAX_REFS);
}
if (url.searchParams.has(PARAM)) {
url.searchParams.delete(PARAM);
window.history.replaceState(window.history.state, '', url.pathname + url.search + url.hash);
}
} catch (e) { }
saveRefs(refs);
/* ============ 3) 다른 위젯이 읽는 창구 ============ */
window.MG_PARTNER = {
refs: function () { return refsOnly(loadRefs()); }
};
/* ============ 2) 로그인 회원 ↔ 클릭 연결 ============ */
function report(memberUid) {
var current = window.MG_PARTNER.refs();
var sent = read(K_SENT) ? read(K_SENT).split(',') : [];
var pending = [];
for (var i = 0; i < current.length; i++) {
if (sent.indexOf(current[i]) === -1) pending.push(current[i]);
}
if (!pending.length || typeof window.fetch !== 'function') return;
window.fetch(API_BASE + '/partner-touches', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'omit',
body: JSON.stringify({ memberUid: memberUid, refs: pending })
}).then(function (res) {
if (!res.ok) return; // 실패하면 다음 페이지에서 다시 보낸다
// 보관 중인 것만 남겨 목록이 끝없이 늘지 않게 한다
var next = [];
var all = sent.concat(pending);
for (var j = 0; j < all.length; j++) {
if (current.indexOf(all[j]) !== -1 && next.indexOf(all[j]) === -1) next.push(all[j]);
}
write(K_SENT, next.join(','));
}).catch(function () { });
}
if (!refs.length) return; // 보관한 클릭이 없으면 할 일이 없다
var waited = 0;
(function poll() {
var uid = window.MEMBER_UID;
if (uid) { report(String(uid)); return; }
// 빈 문자열이면 비로그인 확정 — 로그인한 뒤 다른 페이지에서 다시 시도된다
if (typeof uid === 'string' || waited >= WAIT_MS) return;
waited += POLL_MS;
window.setTimeout(poll, POLL_MS);
})();
})();
</script>
+48
View File
@@ -0,0 +1,48 @@
<script>
//UTM 트래킹을 위한 코드 (무료강의 랜딩용)
document.addEventListener('mousedown', function(e) {
// 클릭한 요소가 로그인 링크인지 확인
const anchor = e.target.closest('a[href*="/login"]');
if (anchor) {
const urlParams = new URLSearchParams(window.location.search);
const utms = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'];
// 1. 현재 주소창에서 UTM 파라미터들 추출
let foundUtmParams = [];
utms.forEach(key => {
if (urlParams.has(key)) {
foundUtmParams.push(key + '=' + encodeURIComponent(urlParams.get(key)));
}
});
// UTM이 있을 때만 실행
if (foundUtmParams.length > 0) {
const url = new URL(anchor.href, window.location.origin);
let backUrl = url.searchParams.get('back_url');
if (backUrl) {
try {
// 2. 기존의 암호화된 back_url을 복호화 (예: /shop_view?idx=38&metaconversion=true)
let decodedBackUrl = atob(backUrl);
// 아임웹이 이미 UTM을 넣어둔 경우(또는 앞선 mousedown에서 넣은 경우) 중복으로 붙이지 않음
if (/[?&]utm_/.test(decodedBackUrl)) return;
// 3. 복호화된 주소 뒤에 UTM 결합
const separator = decodedBackUrl.includes('?') ? '&' : '?';
let updatedBackUrl = decodedBackUrl + separator + foundUtmParams.join('&');
// 4. 다시 Base64로 암호화해서 back_url 파라미터 교체
url.searchParams.set('back_url', btoa(updatedBackUrl));
anchor.href = url.toString();
console.log('UTM이 back_url 내부로 성공적으로 삽입되었습니다.');
} catch (err) {
console.error('Base64 디코딩/인코딩 오류:', err);
}
}
}
}
}, true);
</script>
+103 -4
View File
@@ -505,9 +505,30 @@
</div>
</form>
</section>
<section class="mgm-card" style="margin-bottom:16px">
<div class="mgm-card-head">
<h3>신청 유입 집계</h3>
<span class="mgm-muted" data-role="app-stats-desc">위 기간·상품 조건 · 막대를 누르면 아래 목록을 거릅니다</span>
<span class="mgm-spacer"></span>
<div class="mgm-seg" role="group" aria-label="유입 기준">
<button type="button" data-app-basis="apply_last" class="is-active" title="신청 직전에 마지막으로 들어온 경로 — 신청을 일으킨 유입">신청 때 (최종)</button>
<button type="button" data-app-basis="apply_first" title="신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)">신청 때 (최초)</button>
<button type="button" data-app-basis="join" title="신청한 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외)">가입 경로</button>
</div>
<div class="mgm-seg" role="group" aria-label="집계 항목">
<button type="button" data-app-group="source" class="is-active">유입원</button>
<button type="button" data-app-group="medium">매체</button>
<button type="button" data-app-group="campaign">캠페인</button>
<button type="button" data-app-group="content">콘텐츠</button>
</div>
</div>
<div class="mgm-bars" data-role="app-stats-bars"><div class="mgm-muted">불러오는 중…</div></div>
</section>
<section class="mgm-card">
<div class="mgm-card-head">
<h3>신청</h3>
<span data-role="app-inflow-filter" class="mgm-row" style="gap:6px"></span>
<span class="mgm-spacer"></span>
<span class="mgm-muted" data-role="app-total"></span>
</div>
<div class="mgm-table-wrap">
@@ -515,7 +536,7 @@
<thead>
<tr>
<th>신청일시</th><th>상품</th><th>이름</th><th>아이디 / 이메일</th><th>연락처</th>
<th>오픈카톡</th><th>신청 때 유입</th><th>가입 때 유입</th><th>회원 그룹</th>
<th>오픈카톡</th><th title="신청 직전 최종 유입 — 첫 경로와 다르면 아래에 '최초'로 함께 표시">신청 때 유입</th><th>가입 때 유입</th><th>회원 그룹</th>
</tr>
</thead>
<tbody data-role="app-rows"></tbody>
@@ -699,7 +720,11 @@
view: 'overview',
cursor: null,
listSeq: 0,
app: { range: 'all', product: '', cursor: null, loading: false, seq: 0, products: null, productsStale: true },
app: {
range: 'all', product: '', cursor: null, loading: false, seq: 0, products: null, productsStale: true,
// 신청 유입 집계 — basis: apply_last | apply_first | join, inflowFilter: 막대를 눌러 건 목록 필터
basis: 'apply_last', group: 'source', statsSeq: 0, inflowFilter: null
},
// 업체 링크 — partnerId 는 링크 목록 필터, conv 는 열어 둔 실적 상세
ptn: {
range: 'all', partnerId: '', cursor: null, loading: false, seq: 0,
@@ -1421,9 +1446,52 @@
var q = appForm.q.value.trim();
if (q) out.q = q;
if (state.app.product) out.productIdx = state.app.product;
var inf = state.app.inflowFilter;
if (inf) { out.inflowBasis = inf.basis; out.inflowField = inf.field; out.inflowValue = inf.value; }
return out;
}
/* 신청 유입 집계 — 같은 신청을 신청 때 유입 / 가입 경로로 나눠 센다 */
var APP_BASIS_LABEL = { apply_last: '신청 때(최종)', apply_first: '신청 때(최초)', join: '가입 경로' };
var APP_GROUP_LABEL = { source: '유입원', medium: '매체', campaign: '캠페인', content: '콘텐츠' };
var APP_STATS_TOP = 12;
function loadAppStats() {
var app = state.app;
var seq = ++app.statsSeq;
var q = { basis: app.basis, groupBy: app.group };
var f = appFilters(); // 기간·상품만 쓴다 (검색어·유입 필터는 목록 전용)
if (f.appliedFrom) q.appliedFrom = f.appliedFrom;
if (f.appliedTo) q.appliedTo = f.appliedTo;
if (f.productIdx) q.productIdx = f.productIdx;
api('GET', '/admin/applications/inflow-stats' + toQuery(q)).then(function (s) {
if (seq !== app.statsSeq) return;
$('[data-role="app-stats-bars"]').innerHTML = barsHtml(s.items.slice(0, APP_STATS_TOP), true);
$('[data-role="app-stats-desc"]').textContent = '신청 ' + num(s.total) + '건 · ' +
APP_BASIS_LABEL[s.basis] + ' 기준 · 막대를 누르면 아래 목록을 거릅니다';
}).catch(function (err) { if (seq === app.statsSeq) errorNotice(err); });
}
function renderAppInflowFilter() {
var inf = state.app.inflowFilter;
$('[data-role="app-inflow-filter"]').innerHTML = inf
? '<span class="mgm-active-chip"><b>' + esc(APP_BASIS_LABEL[inf.basis] + ' ' + APP_GROUP_LABEL[inf.field]) + '</b> ' +
esc(inf.value) + '<button type="button" data-app-clear-inflow aria-label="유입 필터 지우기">×</button></span>'
: '';
}
// 신청 때 유입 칸 — 최종 유입을 보여주고, 첫 경로가 다르면 '최초'로 함께 적는다
function applyInflowCell(a) {
var first = a.inflow, last = a.lastInflow && a.lastInflow.source ? a.lastInflow : first;
var html = inflowText(last);
if (first && first.source && last && last.source &&
(first.source !== last.source || first.medium !== last.medium || first.campaign !== last.campaign)) {
html += '<br><span class="mgm-muted" title="신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)">최초: ' +
esc(first.source) + (first.medium ? ' / ' + esc(first.medium) : '') + '</span>';
}
return html;
}
function productLabel(p) {
return (p.productName || '상품') + ' #' + p.productIdx;
}
@@ -1473,7 +1541,7 @@
'<td>' + (a.kakaoClickedAt
? '<span class="mgm-badge is-ok" title="' + esc(kstDateTime(a.kakaoClickedAt)) + '">눌렀음</span>'
: '<span class="mgm-muted">-</span>') + '</td>' +
'<td class="mgm-wrap">' + inflowText(a.inflow) + '</td>' +
'<td class="mgm-wrap">' + applyInflowCell(a) + '</td>' +
'<td class="mgm-wrap">' + inflowText(m && m.inflow) + '</td>' +
'<td class="mgm-group-cell">' + (m && m.groups.length
? m.groups.map(function (g) { return '<span class="mgm-group-badge">' + esc(g.title) + '</span>'; }).join('')
@@ -1487,7 +1555,7 @@
if (!reset && app.loading) return;
var seq = ++app.seq;
app.loading = true;
if (reset) { app.cursor = null; loadAppProducts(); }
if (reset) { app.cursor = null; loadAppProducts(); loadAppStats(); renderAppInflowFilter(); }
var q = appFilters();
q.limit = PAGE_SIZE;
@@ -1534,6 +1602,37 @@
loadApplications(true);
});
// 집계 기준·항목 바꾸기 — 집계만 다시 센다 (목록 필터는 막대를 누를 때 바뀐다)
each($all('[data-app-basis]'), function (b) {
b.addEventListener('click', function () {
state.app.basis = b.getAttribute('data-app-basis');
each($all('[data-app-basis]'), function (x) { x.classList.toggle('is-active', x === b); });
loadAppStats();
});
});
each($all('[data-app-group]'), function (b) {
b.addEventListener('click', function () {
state.app.group = b.getAttribute('data-app-group');
each($all('[data-app-group]'), function (x) { x.classList.toggle('is-active', x === b); });
loadAppStats();
});
});
// 막대 → 같은 기준·항목·값으로 목록을 거른다 (다시 누르면 해제)
$('[data-role="app-stats-bars"]').addEventListener('click', function (e) {
var bar = e.target.closest ? e.target.closest('.mgm-bar') : null;
if (!bar || bar.disabled) return;
var value = bar.getAttribute('data-key');
var cur = state.app.inflowFilter;
var same = cur && cur.basis === state.app.basis && cur.field === state.app.group && cur.value === value;
state.app.inflowFilter = same ? null : { basis: state.app.basis, field: state.app.group, value: value };
loadApplications(true);
});
$('[data-role="app-inflow-filter"]').addEventListener('click', function (e) {
if (!e.target.closest || !e.target.closest('[data-app-clear-inflow]')) return;
state.app.inflowFilter = null;
loadApplications(true);
});
var appSearchTimer = null;
appForm.q.addEventListener('input', function () {
clearTimeout(appSearchTimer);
-114
View File
@@ -1,114 +0,0 @@
<!-- ===== 아임웹 Header Code 삽입용 — 업체 링크 추적 (partner-ref) ===== -->
<!--
어디에 넣나 : 아임웹 관리자 > 환경설정 > SEO/HTML > Header Code (사이트 전체)
header-top.html(UTM 스크립트)과 함께 두어도 서로 영향이 없습니다.
하는 일
1) 업체 단축 링크(moneyguide.wandergo.net/r/코드)를 누르고 들어오면 주소에 붙은
mg_ref(클릭 ID)를 이 브라우저에 30일간 보관하고, 주소창에서는 지웁니다.
(주소를 그대로 공유해도 다른 사람이 그 업체 실적으로 잡히지 않게)
2) 로그인한 회원이 확인되면 "이 회원이 이 링크로 들어왔다"를 서버에 한 번 알립니다.
→ 구매는 서버가 아임웹 주문을 1시간마다 확인해 이 기록으로 업체에 귀속합니다.
3) 신청 완료 위젯(imweb-free-apply.html)이 window.MG_PARTNER.refs() 로 같은 값을 읽어
신청 기록에 함께 보냅니다.
저장하는 값 : 클릭 ID 와 보관 시각만 (회원 정보는 저장하지 않습니다)
-->
<script>
(function () {
'use strict';
/* 백엔드 주소 */
var API_BASE = 'https://moneyguide.wandergo.net/api';
/* 아래는 수정할 필요 없습니다 — 백엔드(shared/utils/partner-link.ts)와 같은 값입니다 */
var PARAM = 'mg_ref';
var REF_PATTERN = /^[0-9A-Za-z]{16}$/;
var STORE_KEY = 'mg_partner_refs'; /* [{ ref, at }] — 최근 것이 뒤 */
var SENT_KEY = 'mg_partner_sent'; /* 이미 서버에 알린 클릭 ID */
var WINDOW_MS = 30 * 24 * 60 * 60 * 1000; /* 귀속 기간 30일 */
var MAX_REFS = 20;
/* 아임웹 전역(MEMBER_UID)은 Header Code 보다 늦게 생깁니다 */
var WAIT_MS = 5000;
var POLL_MS = 200;
function readJson(key) {
try {
var value = JSON.parse(window.localStorage.getItem(key) || '[]');
return Array.isArray(value) ? value : [];
} catch (e) { return []; }
}
function writeJson(key, value) {
try { window.localStorage.setItem(key, JSON.stringify(value)); } catch (e) { }
}
/* 30일이 지났거나 형식이 아닌 값은 버립니다 */
function freshRefs() {
var now = Date.now();
return readJson(STORE_KEY).filter(function (item) {
return item && REF_PATTERN.test(item.ref) &&
typeof item.at === 'number' && now - item.at <= WINDOW_MS;
});
}
/* 1) 주소의 클릭 ID 보관 + 주소창 정리 */
var refs = freshRefs();
try {
var url = new URL(window.location.href);
var ref = url.searchParams.get(PARAM);
if (ref && REF_PATTERN.test(ref)) {
refs = refs.filter(function (item) { return item.ref !== ref; });
refs.push({ ref: ref, at: Date.now() });
if (refs.length > MAX_REFS) refs = refs.slice(-MAX_REFS);
}
if (url.searchParams.has(PARAM)) {
url.searchParams.delete(PARAM);
window.history.replaceState(window.history.state, '',
url.pathname + url.search + url.hash);
}
} catch (e) { }
writeJson(STORE_KEY, refs);
/* 3) 다른 위젯이 읽는 창구 */
window.MG_PARTNER = {
refs: function () {
return freshRefs().map(function (item) { return item.ref; });
}
};
/* 2) 로그인 회원 ↔ 클릭 연결 */
function report(memberUid) {
var current = window.MG_PARTNER.refs();
var sent = readJson(SENT_KEY);
var pending = current.filter(function (r) { return sent.indexOf(r) === -1; });
if (pending.length === 0 || typeof window.fetch !== 'function') return;
window.fetch(API_BASE + '/partner-touches', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'omit',
body: JSON.stringify({ memberUid: memberUid, refs: pending })
}).then(function (res) {
if (!res.ok) return; /* 실패하면 다음 페이지에서 다시 보냅니다 */
/* 보관 중인 것만 남겨 목록이 끝없이 늘지 않게 합니다 */
writeJson(SENT_KEY, sent.concat(pending).filter(function (r) {
return current.indexOf(r) !== -1;
}));
}).catch(function () { });
}
if (refs.length === 0) return; /* 보관한 클릭이 없으면 할 일이 없습니다 */
var waited = 0;
(function poll() {
var uid = window.MEMBER_UID;
if (uid) { report(String(uid)); return; }
/* 빈 문자열이면 비로그인 확정 — 로그인한 뒤 다른 페이지에서 다시 시도됩니다 */
if (typeof uid === 'string' || waited >= WAIT_MS) return;
waited += POLL_MS;
window.setTimeout(poll, POLL_MS);
})();
})();
</script>
<!-- ===== 코드 끝 ===== -->