generated from ttipo/project-base
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38e945767b | |||
| e91f7620a3 |
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
/**
|
||||
* 회원 유입경로 파싱 유틸 (순수 함수).
|
||||
*
|
||||
* 아임웹 Header Code 의 UTM 스크립트(header-top.html)가 회원가입 폼의 '홈페이지' 항목에
|
||||
* 아임웹 Header Code 의 UTM 스크립트(imweb/imweb-header-top.html)가 회원가입 폼의 '홈페이지' 항목에
|
||||
* 아래 형식으로 유입정보를 기록한다. 아임웹 Open API 회원 조회에는 사용자 정의 가입 항목이
|
||||
* 나오지 않고 homePage 만 나오기 때문에 이 값을 풀어서 쓴다.
|
||||
*
|
||||
|
||||
@@ -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
@@ -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` 도 같은 운영 주소)
|
||||
|
||||
## 유입 기록 상태
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
<!-- ===== 코드 끝 ===== -->
|
||||
Reference in New Issue
Block a user