From 38e945767b91f5dfd0290c58c89dbe2240e6b414 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Tue, 6 Oct 2026 00:22:02 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=8B=A0=EC=B2=AD=20=EB=95=8C=20?= =?UTF-8?q?=EC=B5=9C=EC=A2=85=20=EC=9C=A0=EC=9E=85=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EA=B3=BC=20=EC=8B=A0=EC=B2=AD=20=EC=9C=A0=EC=9E=85=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 가입 경로와 신청(수강) 유입을 구분해 보기 위해서다. 기존 신청 유입은 신청 시점 브라우저의 '최초' 유입(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 --- .../1791213287479-AddApplicationLastInflow.ts | 51 ++++++ .../applications/application.entity.ts | 43 +++++ .../applications/applications.controller.ts | 22 ++- .../applications/applications.service.spec.ts | 40 ++++ .../applications/applications.service.ts | 171 ++++++++++++++---- .../applications/dto/application-query.dto.ts | 83 ++++++++- .../dto/application-response.dto.ts | 53 +++++- .../dto/create-application.dto.ts | 10 + imweb/README.md | 13 +- imweb/imweb-free-apply.html | 22 +++ imweb/imweb-members.html | 107 ++++++++++- 11 files changed, 565 insertions(+), 50 deletions(-) create mode 100644 backend/src/migrations/1791213287479-AddApplicationLastInflow.ts diff --git a/backend/src/migrations/1791213287479-AddApplicationLastInflow.ts b/backend/src/migrations/1791213287479-AddApplicationLastInflow.ts new file mode 100644 index 0000000..705dc5c --- /dev/null +++ b/backend/src/migrations/1791213287479-AddApplicationLastInflow.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddApplicationLastInflow1791213287479 implements MigrationInterface { + name = 'AddApplicationLastInflow1791213287479'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`, + ); + } +} diff --git a/backend/src/modules/applications/application.entity.ts b/backend/src/modules/applications/application.entity.ts index acba0bd..6662c11 100644 --- a/backend/src/modules/applications/application.entity.ts +++ b/backend/src/modules/applications/application.entity.ts @@ -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; } diff --git a/backend/src/modules/applications/applications.controller.ts b/backend/src/modules/applications/applications.controller.ts index 65a3500..41bfa21 100644 --- a/backend/src/modules/applications/applications.controller.ts +++ b/backend/src/modules/applications/applications.controller.ts @@ -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 { return this.applications.products(); } + + @Get('inflow-stats') + @ApiOperation({ + summary: + '신청 유입 집계 — 신청 때 최종·최초 유입 또는 가입 경로 기준으로 신청 수를 센다', + }) + @ApiStandardResponse(ApplicationInflowStatsResponseDto, { + description: '집계 성공', + }) + @ApiErrorResponse('AUTH_001', '로그인 필요') + @ApiErrorResponse('VAL_001', '필터·기준 형식 오류') + inflowStats( + @Query() query: ApplicationInflowStatsQueryDto, + ): Promise { + return this.applications.inflowStats(query); + } } diff --git a/backend/src/modules/applications/applications.service.spec.ts b/backend/src/modules/applications/applications.service.spec.ts index 0745856..694e6eb 100644 --- a/backend/src/modules/applications/applications.service.spec.ts +++ b/backend/src/modules/applications/applications.service.spec.ts @@ -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(); diff --git a/backend/src/modules/applications/applications.service.ts b/backend/src/modules/applications/applications.service.ts index ed6be69..1f2803c 100644 --- a/backend/src/modules/applications/applications.service.ts +++ b/backend/src/modules/applications/applications.service.ts @@ -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> { - 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 { + 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 { + 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 { const rows = await this.applications diff --git a/backend/src/modules/applications/dto/application-query.dto.ts b/backend/src/modules/applications/dto/application-query.dto.ts index 5b505d2..e81d12e 100644 --- a/backend/src/modules/applications/dto/application-query.dto.ts +++ b/backend/src/modules/applications/dto/application-query.dto.ts @@ -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'; +} diff --git a/backend/src/modules/applications/dto/application-response.dto.ts b/backend/src/modules/applications/dto/application-response.dto.ts index 71e86b5..ae08b62 100644 --- a/backend/src/modules/applications/dto/application-response.dto.ts +++ b/backend/src/modules/applications/dto/application-response.dto.ts @@ -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[]; +} diff --git a/backend/src/modules/applications/dto/create-application.dto.ts b/backend/src/modules/applications/dto/create-application.dto.ts index b09713a..517f9ae 100644 --- a/backend/src/modules/applications/dto/create-application.dto.ts +++ b/backend/src/modules/applications/dto/create-application.dto.ts @@ -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())', diff --git a/imweb/README.md b/imweb/README.md index a707553..abbce31 100644 --- a/imweb/README.md +++ b/imweb/README.md @@ -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` 로 처음 한 번만 남는다 diff --git a/imweb/imweb-free-apply.html b/imweb/imweb-free-apply.html index 1a0bbeb..fac64bf 100644 --- a/imweb/imweb-free-apply.html +++ b/imweb/imweb-free-apply.html @@ -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; diff --git a/imweb/imweb-members.html b/imweb/imweb-members.html index ed625ad..76073bf 100644 --- a/imweb/imweb-members.html +++ b/imweb/imweb-members.html @@ -505,9 +505,30 @@ +
+
+

신청 유입 집계

+ 위 기간·상품 조건 · 막대를 누르면 아래 목록을 거릅니다 + +
+ + + +
+
+ + + + +
+
+
불러오는 중…
+

신청

+ +
@@ -515,7 +536,7 @@ 신청일시상품이름아이디 / 이메일연락처 - 오픈카톡신청 때 유입가입 때 유입회원 그룹 + 오픈카톡신청 때 유입가입 때 유입회원 그룹 @@ -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 + ? '' + esc(APP_BASIS_LABEL[inf.basis] + ' ' + APP_GROUP_LABEL[inf.field]) + ' ' + + esc(inf.value) + '' + : ''; + } + + // 신청 때 유입 칸 — 최종 유입을 보여주고, 첫 경로가 다르면 '최초'로 함께 적는다 + 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 += '
최초: ' + + esc(first.source) + (first.medium ? ' / ' + esc(first.medium) : '') + ''; + } + return html; + } + function productLabel(p) { return (p.productName || '상품') + ' #' + p.productIdx; } @@ -1473,7 +1541,7 @@ '' + (a.kakaoClickedAt ? '눌렀음' : '-') + '' + - '' + inflowText(a.inflow) + '' + + '' + applyInflowCell(a) + '' + '' + inflowText(m && m.inflow) + '' + '' + (m && m.groups.length ? m.groups.map(function (g) { return '' + esc(g.title) + ''; }).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);