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())',