generated from ttipo/project-base
feat: 신청 때 최종 유입 저장과 신청 유입 집계 추가
가입 경로와 신청(수강) 유입을 구분해 보기 위해서다. 기존 신청 유입은 신청 시점 브라우저의 '최초' 유입(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:
@@ -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
@@ -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` 로 처음 한 번만 남는다
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user