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>
This commit is contained in:
2026-10-06 00:22:02 +09:00
parent e91f7620a3
commit 38e945767b
11 changed files with 565 additions and 50 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"`,
);
}
}
@@ -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[];
}
@@ -52,6 +52,16 @@ export class CreateApplicationDto {
@MaxLength(INFLOW_RAW_MAX_LENGTH)
inflow?: string;
@ApiPropertyOptional({
description:
'신청 직전의 최종 유입 (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())',
+12 -1
View File
@@ -77,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().value` — 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` 로 처음 한 번만 남는다
+22
View File
@@ -446,6 +446,26 @@
} catch (e) { return ''; }
}
/* 신청 직전의 최종 유입 — 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 {
@@ -465,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;
+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);