Merge branch 'feat/purchase-history'
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

This commit is contained in:
2026-10-06 10:25:45 +09:00
26 changed files with 2165 additions and 1016 deletions
+2
View File
@@ -7,6 +7,7 @@ import { CacheModule } from '@nestjs/cache-manager';
import { APP_GUARD } from '@nestjs/core';
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { ActivitiesModule } from '@/modules/activities/activities.module';
import { AdminAuthModule } from '@/modules/admin-auth/admin-auth.module';
import { ApplicationsModule } from '@/modules/applications/applications.module';
import { HealthModule } from '@/modules/health/health.module';
@@ -70,6 +71,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
MembersModule,
ApplicationsModule,
PartnersModule,
ActivitiesModule,
],
providers: [
ShutdownLogger,
@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPurchases1791220773446 implements MigrationInterface {
name = 'AddPurchases1791220773446';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "purchases" ("id" uuid NOT NULL, "order_no" character varying(30) NOT NULL, "prod_no" integer NOT NULL, "product_name" character varying(200), "member_code" character varying(40), "member_uid" character varying(255), "orderer_name" character varying(100), "orderer_email" character varying(255), "orderer_call" character varying(40), "ordered_at" TIMESTAMP WITH TIME ZONE NOT NULL, "is_valid" boolean NOT NULL DEFAULT true, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_1d55032f37a34c6eceacbbca6b8" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "idx_purchases_member" ON "purchases" ("member_code") `,
);
await queryRunner.query(
`CREATE INDEX "idx_purchases_cursor" ON "purchases" ("ordered_at", "id") `,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "uq_purchases_order_product" ON "purchases" ("order_no", "prod_no") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "public"."uq_purchases_order_product"`);
await queryRunner.query(`DROP INDEX "public"."idx_purchases_cursor"`);
await queryRunner.query(`DROP INDEX "public"."idx_purchases_member"`);
await queryRunner.query(`DROP TABLE "purchases"`);
}
}
@@ -0,0 +1,75 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { PaginatedResult } from '@/common/dto/pagination.dto';
import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
import { ADMIN_BEARER_AUTH } from '@/modules/admin-auth/admin-auth.constants';
import { ActivitiesService } from './activities.service';
import {
ActivityGroupsQueryDto,
ActivityInflowStatsQueryDto,
ActivityListQueryDto,
} from './dto/activity-query.dto';
import {
ActivityGroupsResponseDto,
ActivityInflowStatsResponseDto,
ActivityResponseDto,
} from './dto/activity-response.dto';
/** 관리자 신청·구매 내역 — 무료 신청과 유료 구매를 한 화면에서 본다 */
@ApiTags('Admin Activities')
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@UseGuards(AdminAuthGuard)
@Controller('admin/activities')
export class AdminActivitiesController {
constructor(private readonly activities: ActivitiesService) {}
@Get()
@ApiOperation({
summary: '신청·구매 내역 (최신순, 커서 페이지네이션) — 종류로 거를 수 있다',
})
@ApiStandardResponse(ActivityResponseDto, {
description: '조회 성공 — data 는 { items, pageInfo }',
isArray: true,
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '필터·커서 형식 오류')
findMany(
@Query() query: ActivityListQueryDto,
): Promise<PaginatedResult<ActivityResponseDto>> {
return this.activities.findMany(query);
}
@Get('groups')
@ApiOperation({
summary: '상품별·업체별 신청·구매 수 — 요약 표 (기간만 거른다)',
})
@ApiStandardResponse(ActivityGroupsResponseDto, { description: '조회 성공' })
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '기준·기간 형식 오류')
groups(
@Query() query: ActivityGroupsQueryDto,
): Promise<ActivityGroupsResponseDto> {
return this.activities.groups(query);
}
@Get('inflow-stats')
@ApiOperation({
summary:
'유입 집계 — 신청 때 유입(무료 신청만) 또는 가입 경로(신청·구매) 기준',
})
@ApiStandardResponse(ActivityInflowStatsResponseDto, {
description: '집계 성공',
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '필터·기준 형식 오류')
inflowStats(
@Query() query: ActivityInflowStatsQueryDto,
): Promise<ActivityInflowStatsResponseDto> {
return this.activities.inflowStats(query);
}
}
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminAuthModule } from '@/modules/admin-auth/admin-auth.module';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { MembersModule } from '@/modules/members/members.module';
import { Partner } from '@/modules/partners/entities/partner.entity';
import { AdminActivitiesController } from './activities.controller';
import { ActivitiesService } from './activities.service';
/**
* 관리자 신청·구매 내역 — 읽기 전용.
* 무료 신청(ApplicationsModule)과 유료 구매(PurchasesModule)가 쌓은 기록을 합쳐 보여준다.
*/
@Module({
imports: [
TypeOrmModule.forFeature([ImwebMember, Partner]),
AdminAuthModule,
MembersModule,
],
controllers: [AdminActivitiesController],
providers: [ActivitiesService],
})
export class ActivitiesModule {}
@@ -0,0 +1,647 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
DataSource,
In,
ObjectLiteral,
Repository,
SelectQueryBuilder,
} from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { MemberApplication } from '@/modules/applications/application.entity';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { ImwebProduct } from '@/modules/imweb/entities/imweb-product.entity';
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 { PartnerConversion } from '@/modules/partners/entities/partner-conversion.entity';
import { Partner } from '@/modules/partners/entities/partner.entity';
import { Purchase } from '@/modules/purchases/purchase.entity';
import { InflowStatus } from '@/shared/utils/inflow';
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
import {
ActivityFilterDto,
ActivityGroupsQueryDto,
ActivityInflowStatsQueryDto,
ActivityListQueryDto,
ActivityType,
PARTNER_FILTER_NONE,
} from './dto/activity-query.dto';
import {
ActivityCountsDto,
ActivityGroupsResponseDto,
ActivityInflowStatsResponseDto,
ActivityResponseDto,
} from './dto/activity-response.dto';
const APP = 'a';
const APP_MEMBER = 'am';
const APP_CONVERSION = 'apc';
const PUR = 'p';
const PUR_MEMBER = 'pm';
const PUR_CONVERSION = 'ppc';
const UNION = 'u';
const PRODUCT = 'ip';
const PARTNER = 'pt';
/** 집계 결과 최대 줄 수 (회원 유입 집계와 같다) */
const STATS_MAX_ITEMS = 100;
/** 커서의 고유값은 신청·구매 기록 ID(UUID)다 — 형식이 다르면 DB 오류 대신 VAL_001 */
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
type ListFilter = ActivityFilterDto &
Partial<
Pick<
ActivityListQueryDto,
'inflowBasis' | 'inflowField' | 'inflowValue' | 'q'
>
>;
/** [SQL 식, 별칭] — 신청·구매 두 쪽이 같은 순서로 같은 열을 내야 UNION 할 수 있다 */
type Columns = [expression: string, alias: string][];
/** 목록 한 줄 (UNION 결과 원본) */
interface ActivityRow {
type: ActivityType;
id: string;
occurred_at: Date;
product_idx: string;
product_name: string | null;
member_code: string | null;
member_uid: string | null;
partner_id: string | null;
kakao_clicked_at: Date | null;
inflow_source: string | null;
inflow_medium: string | null;
inflow_campaign: string | null;
inflow_content: string | null;
order_no: string | null;
is_valid: boolean | null;
orderer_name: string | null;
orderer_email: string | null;
orderer_call: string | null;
}
interface CountsRow {
applicationCount: string;
kakaoCount: string;
purchaseCount: string;
cancelledCount: string;
}
/**
* 가입 경로 식 — 가입 때 기록된 값만 쓴다 (가입 후 회원정보 수정 화면에서 채워진 값은 가입 경로가 아니다).
* 항목은 enum 으로 검증된 값이라 문자열로 넣어도 안전하다.
*/
function joinInflow(memberAlias: string, field: InflowGroupBy): string {
return `CASE WHEN ${memberAlias}.inflow_status = '${InflowStatus.AT_JOIN}' THEN ${memberAlias}.inflow_${field} END`;
}
function escapeLike(value: string): string {
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
}
function selectColumns<T extends ObjectLiteral>(
qb: SelectQueryBuilder<T>,
columns: Columns,
): SelectQueryBuilder<T> {
const [[firstExpression, firstAlias], ...rest] = columns;
qb.select(firstExpression, firstAlias);
for (const [expression, alias] of rest) qb.addSelect(expression, alias);
return qb;
}
/** 신청·구매 수 — UNION 결과(u)에서 센다 */
const COUNT_COLUMNS: Columns = [
[`COUNT(*) FILTER (WHERE ${UNION}.type = 'application')`, 'applicationCount'],
[`COUNT(${UNION}.kakao_clicked_at)`, 'kakaoCount'],
[
`COUNT(*) FILTER (WHERE ${UNION}.type = 'purchase' AND ${UNION}.is_valid)`,
'purchaseCount',
],
[
`COUNT(*) FILTER (WHERE ${UNION}.type = 'purchase' AND NOT ${UNION}.is_valid)`,
'cancelledCount',
],
];
function toCounts(row: CountsRow | undefined): ActivityCountsDto {
return {
applicationCount: Number(row?.applicationCount ?? 0),
kakaoCount: Number(row?.kakaoCount ?? 0),
purchaseCount: Number(row?.purchaseCount ?? 0),
cancelledCount: Number(row?.cancelledCount ?? 0),
};
}
/**
* 관리자 신청·구매 내역 — 무료 신청(member_applications)과 유료 구매(purchases)를 한 화면에서 본다.
*
* 두 기록은 따로 쌓이고(신청은 신청 완료 화면, 구매는 아임웹 주문 동기화) 여기서 UNION ALL 로 합친다.
* 업체는 업체 실적(partner_conversions)과 이어서 보여준다 — 신청은 신청 ID, 구매는 주문번호·상품으로 잇는다.
*/
@Injectable()
export class ActivitiesService {
constructor(
private readonly dataSource: DataSource,
@InjectRepository(ImwebMember)
private readonly members: Repository<ImwebMember>,
@InjectRepository(Partner)
private readonly partners: Repository<Partner>,
private readonly membersService: MembersService,
) {}
async findMany(
query: ActivityListQueryDto,
): Promise<PaginatedResult<ActivityResponseDto>> {
const parts: SelectQueryBuilder<ObjectLiteral>[] = [];
const app = this.applicationPart(query);
if (app) {
parts.push(
selectColumns(app, [
[`'application'`, 'type'],
[`${APP}.id`, 'id'],
[`${APP}.applied_at`, 'occurred_at'],
[`${APP}.product_idx`, 'product_idx'],
[`${APP}.product_name`, 'product_name'],
[`${APP}.member_code`, 'member_code'],
[`${APP}.member_uid`, 'member_uid'],
[`${APP_CONVERSION}.partner_id`, 'partner_id'],
[`${APP}.kakao_clicked_at`, 'kakao_clicked_at'],
[`${APP}.inflow_source`, 'inflow_source'],
[`${APP}.inflow_medium`, 'inflow_medium'],
[`${APP}.inflow_campaign`, 'inflow_campaign'],
[`${APP}.inflow_content`, 'inflow_content'],
['CAST(NULL AS varchar)', 'order_no'],
['CAST(NULL AS boolean)', 'is_valid'],
['CAST(NULL AS varchar)', 'orderer_name'],
['CAST(NULL AS varchar)', 'orderer_email'],
['CAST(NULL AS varchar)', 'orderer_call'],
]),
);
}
const pur = this.purchasePart(query);
if (pur) {
parts.push(
selectColumns(pur, [
[`'purchase'`, 'type'],
[`${PUR}.id`, 'id'],
[`${PUR}.ordered_at`, 'occurred_at'],
[`CAST(${PUR}.prod_no AS varchar)`, 'product_idx'],
[`${PUR}.product_name`, 'product_name'],
[`${PUR}.member_code`, 'member_code'],
[`${PUR}.member_uid`, 'member_uid'],
[`${PUR_CONVERSION}.partner_id`, 'partner_id'],
['CAST(NULL AS timestamptz)', 'kakao_clicked_at'],
['CAST(NULL AS varchar)', 'inflow_source'],
['CAST(NULL AS varchar)', 'inflow_medium'],
['CAST(NULL AS varchar)', 'inflow_campaign'],
['CAST(NULL AS varchar)', 'inflow_content'],
[`${PUR}.order_no`, 'order_no'],
[`${PUR}.is_valid`, 'is_valid'],
[`${PUR}.orderer_name`, 'orderer_name'],
[`${PUR}.orderer_email`, 'orderer_email'],
[`${PUR}.orderer_call`, 'orderer_call'],
]),
);
}
if (parts.length === 0) {
return toPaginatedResult([], {
hasNext: false,
nextCursor: null,
total: 0,
});
}
const totalRow = await this.fromUnion(parts)
.select('COUNT(*)', 'total')
.getRawOne<{ total: string }>();
const page = this.fromUnion(parts)
.select(`${UNION}.*`)
.orderBy(`${UNION}.occurred_at`, 'DESC')
.addOrderBy(`${UNION}.id`, 'DESC')
.limit(query.limit + 1);
if (query.cursor) {
const cursor = decodeTimeCursor(query.cursor);
if (!cursor || !UUID_PATTERN.test(cursor.key)) {
throw new BusinessException('VAL_001', {
context: { reason: 'invalid_cursor' },
});
}
page.andWhere(
`(${UNION}.occurred_at, ${UNION}.id) < (:cursorTime, :cursorId)`,
{ cursorTime: cursor.time, cursorId: cursor.key },
);
}
const rows = await page.getRawMany<ActivityRow>();
const hasNext = rows.length > query.limit;
const items = rows.slice(0, query.limit);
const last = items.at(-1);
const [membersByCode, groupTitles, partnerNames] = await Promise.all([
this.membersByCode(items.map((row) => row.member_code)),
this.membersService.groupTitleMap(),
this.partnerNames(items.map((row) => row.partner_id)),
]);
return toPaginatedResult(
items.map((row): ActivityResponseDto => {
const member = row.member_code
? membersByCode.get(row.member_code)
: undefined;
const isApplication = row.type === 'application';
return {
type: row.type,
id: row.id,
occurredAt: row.occurred_at.toISOString(),
productIdx: row.product_idx,
productName: row.product_name,
memberUid: row.member_uid,
member: member ? MemberResponseDto.from(member, groupTitles) : null,
partner: row.partner_id
? {
id: row.partner_id,
name: partnerNames.get(row.partner_id) ?? '',
}
: null,
kakaoClickedAt: row.kakao_clicked_at?.toISOString() ?? null,
inflow: isApplication
? {
source: row.inflow_source,
medium: row.inflow_medium,
campaign: row.inflow_campaign,
content: row.inflow_content,
}
: null,
orderNo: row.order_no,
isCancelled: isApplication ? null : row.is_valid === false,
orderer: isApplication
? null
: {
name: row.orderer_name,
email: row.orderer_email,
phone: row.orderer_call,
},
};
}),
{
hasNext,
nextCursor:
hasNext && last
? encodeTimeCursor({
time: last.occurred_at.toISOString(),
key: last.id,
})
: null,
total: Number(totalRow?.total ?? 0),
},
);
}
/**
* 상품별·업체별 신청·구매 수 — 화면 위쪽 요약 표.
* 업체별은 업체 링크 실적으로 잡힌 것을 업체로 묶고, 나머지는 key=null(업체 없음)로 센다.
*/
async groups(
query: ActivityGroupsQueryDto,
): Promise<ActivityGroupsResponseDto> {
const filter: ListFilter = { from: query.from, to: query.to };
const byPartner = query.by === 'partner';
const parts = [
selectColumns(this.applicationQuery(filter), [
[`'application'`, 'type'],
[
byPartner ? `${APP_CONVERSION}.partner_id` : `${APP}.product_idx`,
'key',
],
[byPartner ? 'CAST(NULL AS varchar)' : `${APP}.product_name`, 'label'],
[`${APP}.applied_at`, 'occurred_at'],
[`${APP}.kakao_clicked_at`, 'kakao_clicked_at'],
['CAST(NULL AS boolean)', 'is_valid'],
]),
selectColumns(this.purchaseQuery(filter), [
[`'purchase'`, 'type'],
[
byPartner
? `${PUR_CONVERSION}.partner_id`
: `CAST(${PUR}.prod_no AS varchar)`,
'key',
],
[byPartner ? 'CAST(NULL AS varchar)' : `${PUR}.product_name`, 'label'],
[`${PUR}.ordered_at`, 'occurred_at'],
['CAST(NULL AS timestamptz)', 'kakao_clicked_at'],
[`${PUR}.is_valid`, 'is_valid'],
]),
];
const totals = await selectColumns(
this.fromUnion(parts),
COUNT_COLUMNS,
).getRawOne<CountsRow>();
const grouped = this.fromUnion(parts);
if (byPartner) {
grouped
.leftJoin(Partner, PARTNER, `${PARTNER}.id = ${UNION}.key`)
.select(`${UNION}.key`, 'key')
.addSelect(`MAX(${PARTNER}.name)`, 'label');
} else {
// 상품명은 아임웹 상품 기준, 아직 불러오지 않은 상품이면 가장 최근 표시값
grouped
.leftJoin(
ImwebProduct,
PRODUCT,
`CAST(${PRODUCT}.prod_no AS varchar) = ${UNION}.key`,
)
.select(`${UNION}.key`, 'key')
.addSelect(
`COALESCE(MAX(${PRODUCT}.name), (ARRAY_AGG(${UNION}.label ORDER BY ${UNION}.occurred_at DESC) FILTER (WHERE ${UNION}.label IS NOT NULL))[1])`,
'label',
);
}
for (const [expression, alias] of COUNT_COLUMNS) {
grouped.addSelect(expression, alias);
}
const rows = await grouped
.groupBy(`${UNION}.key`)
.orderBy('COUNT(*)', 'DESC')
.addOrderBy(`${UNION}.key`, 'ASC', 'NULLS LAST')
.limit(STATS_MAX_ITEMS)
.getRawMany<CountsRow & { key: string | null; label: string | null }>();
return {
by: query.by,
totals: toCounts(totals),
items: rows.map((row) => ({
key: row.key,
label: row.label,
...toCounts(row),
})),
};
}
/**
* 유입 집계.
* - 신청 때 유입: 무료 신청만 센다 (구매 시점의 유입은 아임웹 주문에 남지 않는다)
* - 가입 경로: 신청·구매(환불·취소 제외) 모두 회원의 가입 경로로 센다. 비회원 구매는 값 없음
*/
async inflowStats(
query: ActivityInflowStatsQueryDto,
): Promise<ActivityInflowStatsResponseDto> {
const isJoin = query.basis === 'join';
const parts: SelectQueryBuilder<ObjectLiteral>[] = [];
const app = this.applicationPart(query);
if (app) {
parts.push(
selectColumns(app, [
[`'application'`, 'type'],
[
isJoin
? joinInflow(APP_MEMBER, query.groupBy)
: `${APP}.inflow_${query.groupBy}`,
'key',
],
]),
);
}
const pur = isJoin ? this.purchasePart(query) : null;
if (pur) {
pur.andWhere(`${PUR}.is_valid = true`);
parts.push(
selectColumns(pur, [
[`'purchase'`, 'type'],
[joinInflow(PUR_MEMBER, query.groupBy), 'key'],
]),
);
}
const result: ActivityInflowStatsResponseDto = {
basis: query.basis,
groupBy: query.groupBy,
applicationTotal: 0,
purchaseTotal: 0,
items: [],
};
if (parts.length === 0) return result;
const totals = await this.fromUnion(parts)
.select(
`COUNT(*) FILTER (WHERE ${UNION}.type = 'application')`,
'applicationTotal',
)
.addSelect(
`COUNT(*) FILTER (WHERE ${UNION}.type = 'purchase')`,
'purchaseTotal',
)
.getRawOne<{ applicationTotal: string; purchaseTotal: string }>();
const rows = await this.fromUnion(parts)
.select(`${UNION}.key`, 'key')
.addSelect('COUNT(*)', 'count')
.groupBy(`${UNION}.key`)
.orderBy('count', 'DESC')
.addOrderBy(`${UNION}.key`, 'ASC', 'NULLS LAST')
.limit(STATS_MAX_ITEMS)
.getRawMany<{ key: string | null; count: string }>();
return {
...result,
applicationTotal: Number(totals?.applicationTotal ?? 0),
purchaseTotal: Number(totals?.purchaseTotal ?? 0),
items: rows.map((row) => ({ key: row.key, count: Number(row.count) })),
};
}
/** 무료 신청 쪽 — 종류 필터로 빠지면 null */
private applicationPart(
filter: ListFilter,
): SelectQueryBuilder<ObjectLiteral> | null {
return filter.type === 'purchase' ? null : this.applicationQuery(filter);
}
/** 유료 구매 쪽 — 종류 필터로 빠지거나, 신청 때 유입으로 거르면(구매엔 없는 기록) null */
private purchasePart(
filter: ListFilter,
): SelectQueryBuilder<ObjectLiteral> | null {
if (filter.type === 'application') return null;
const filtersByApplyInflow =
filter.inflowBasis === 'apply' &&
!!filter.inflowField &&
!!filter.inflowValue;
return filtersByApplyInflow ? null : this.purchaseQuery(filter);
}
/** 무료 신청 — 필터만 걸고 열은 부르는 쪽이 고른다 */
private applicationQuery(
filter: ListFilter,
): SelectQueryBuilder<ObjectLiteral> {
const qb = this.dataSource
.createQueryBuilder()
.from(MemberApplication, APP)
// 탈퇴 회원은 회원 정보 없이 남는다
.leftJoin(
ImwebMember,
APP_MEMBER,
`${APP_MEMBER}.member_code = ${APP}.member_code`,
)
// 신청 실적은 신청당 하나라 잇는 행이 늘어나지 않는다
.leftJoin(
PartnerConversion,
APP_CONVERSION,
`${APP_CONVERSION}.application_id = ${APP}.id`,
);
this.applyCommonFilters(qb, filter, {
occurredAt: `${APP}.applied_at`,
productIdx: `${APP}.product_idx`,
conversion: APP_CONVERSION,
searchColumns: [
`${APP_MEMBER}.name`,
`${APP}.member_uid`,
`${APP_MEMBER}.email`,
`${APP_MEMBER}.callnum`,
],
});
if (filter.inflowBasis && filter.inflowField && filter.inflowValue) {
const expression =
filter.inflowBasis === 'join'
? joinInflow(APP_MEMBER, filter.inflowField)
: `${APP}.inflow_${filter.inflowField}`;
qb.andWhere(`${expression} = :inflowValue`, {
inflowValue: filter.inflowValue,
});
}
return qb;
}
/** 유료 구매 — 필터만 걸고 열은 부르는 쪽이 고른다. 유입 필터는 가입 경로일 때만 건다 */
private purchaseQuery(filter: ListFilter): SelectQueryBuilder<ObjectLiteral> {
const qb = this.dataSource
.createQueryBuilder()
.from(Purchase, PUR)
.leftJoin(
ImwebMember,
PUR_MEMBER,
`${PUR_MEMBER}.member_code = ${PUR}.member_code`,
)
// 구매 실적은 주문·상품당 하나다
.leftJoin(
PartnerConversion,
PUR_CONVERSION,
`${PUR_CONVERSION}.order_no = ${PUR}.order_no AND ${PUR_CONVERSION}.prod_no = ${PUR}.prod_no`,
);
this.applyCommonFilters(qb, filter, {
occurredAt: `${PUR}.ordered_at`,
productIdx: `CAST(${PUR}.prod_no AS varchar)`,
conversion: PUR_CONVERSION,
searchColumns: [
`${PUR_MEMBER}.name`,
`${PUR}.member_uid`,
`${PUR_MEMBER}.email`,
`${PUR_MEMBER}.callnum`,
`${PUR}.orderer_name`,
`${PUR}.orderer_email`,
`${PUR}.orderer_call`,
],
});
if (
filter.inflowBasis === 'join' &&
filter.inflowField &&
filter.inflowValue
) {
qb.andWhere(
`${joinInflow(PUR_MEMBER, filter.inflowField)} = :inflowValue`,
{
inflowValue: filter.inflowValue,
},
);
}
return qb;
}
/**
* 두 쪽이 같은 이름의 파라미터를 같은 값으로 쓴다 — UNION 으로 합칠 때 겹쳐도 문제없다.
* (쪽마다 다른 값이 필요한 파라미터는 여기에 두지 않는다)
*/
private applyCommonFilters(
qb: SelectQueryBuilder<ObjectLiteral>,
filter: ListFilter,
columns: {
occurredAt: string;
productIdx: string;
conversion: string;
searchColumns: string[];
},
): void {
if (filter.productIdx) {
qb.andWhere(`${columns.productIdx} = :productIdx`, {
productIdx: filter.productIdx,
});
}
if (filter.from) {
qb.andWhere(`${columns.occurredAt} >= :from`, { from: filter.from });
}
if (filter.to) {
qb.andWhere(`${columns.occurredAt} < :to`, { to: filter.to });
}
if (filter.partnerId === PARTNER_FILTER_NONE) {
qb.andWhere(`${columns.conversion}.id IS NULL`);
} else if (filter.partnerId) {
qb.andWhere(`${columns.conversion}.partner_id = :partnerId`, {
partnerId: filter.partnerId,
});
}
if (filter.q?.trim()) {
const conditions = columns.searchColumns
.map((column) => `${column} ILIKE :q`)
.join(' OR ');
qb.andWhere(`(${conditions})`, {
q: `%${escapeLike(filter.q.trim())}%`,
});
}
}
/** 두 쪽을 UNION ALL 로 합친 결과(u)에서 시작하는 쿼리 */
private fromUnion(
parts: SelectQueryBuilder<ObjectLiteral>[],
): SelectQueryBuilder<ObjectLiteral> {
const sql = parts.map((part) => `(${part.getQuery()})`).join(' UNION ALL ');
const parameters = Object.assign(
{},
...parts.map((part) => part.getParameters()),
) as ObjectLiteral;
return this.dataSource
.createQueryBuilder()
.from(`(${sql})`, UNION)
.setParameters(parameters);
}
private async membersByCode(
codes: (string | null)[],
): Promise<Map<string, ImwebMember>> {
const unique = [...new Set(codes.filter((code): code is string => !!code))];
if (unique.length === 0) return new Map();
const rows = await this.members.find({ where: { memberCode: In(unique) } });
return new Map(rows.map((row) => [row.memberCode, row]));
}
private async partnerNames(
ids: (string | null)[],
): Promise<Map<string, string>> {
const unique = [...new Set(ids.filter((id): id is string => !!id))];
if (unique.length === 0) return new Map();
const rows = await this.partners.find({
select: { id: true, name: true },
where: { id: In(unique) },
});
return new Map(rows.map((row) => [row.id, row.name]));
}
}
@@ -0,0 +1,158 @@
import { ApiPropertyOptional, IntersectionType } from '@nestjs/swagger';
import {
IsDateString,
IsIn,
IsOptional,
IsString,
Matches,
MaxLength,
} 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 const ACTIVITY_TYPES = ['application', 'purchase'] as const;
export type ActivityType = (typeof ACTIVITY_TYPES)[number];
/** 업체 필터 — 업체 ID(UUID) 또는 none(업체 링크를 거치지 않은 신청·구매) */
export const PARTNER_FILTER_NONE = 'none';
const PARTNER_FILTER_PATTERN =
/^(none|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
/**
* 유입을 어느 기록으로 볼 것인가.
* - apply : 신청 때 유입 — 신청 시점 브라우저에 남아 있던 경로 (2일 보관). 구매에는 이 기록이 없다
* - join : 가입 경로 — 신청·구매한 회원이 가입할 때 기록된 값만 (가입 후 채워진 값·비회원은 값 없음)
*/
export const ACTIVITY_INFLOW_BASIS = ['apply', 'join'] as const;
export type ActivityInflowBasis = (typeof ACTIVITY_INFLOW_BASIS)[number];
/** 요약 묶음 기준 — 상품별 / 업체별 */
export const ACTIVITY_GROUP_BY = ['product', 'partner'] as const;
export type ActivityGroupBy = (typeof ACTIVITY_GROUP_BY)[number];
/** 기간 — 신청 시각 또는 주문 시각 */
export class ActivityPeriodDto {
@ApiPropertyOptional({
description: '시작 (이상, ISO 8601) — 신청 시각·주문 시각 기준',
example: '2026-09-30T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '시작일 형식이 올바르지 않습니다.' })
from?: string;
@ApiPropertyOptional({
description: '끝 (미만, ISO 8601)',
example: '2026-10-31T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '종료일 형식이 올바르지 않습니다.' })
to?: string;
}
/** 목록·유입 집계가 함께 쓰는 필터 */
export class ActivityFilterDto extends ActivityPeriodDto {
@ApiPropertyOptional({
description:
'종류 — application(무료 신청) · purchase(유료 구매). 없으면 둘 다',
enum: ACTIVITY_TYPES,
})
@IsOptional()
@IsIn(ACTIVITY_TYPES, { message: '종류 값이 올바르지 않습니다.' })
type?: ActivityType;
@ApiPropertyOptional({ description: '상품번호', example: '52' })
@IsOptional()
@Matches(/^\d{1,20}$/, { message: '상품번호 형식이 올바르지 않습니다.' })
productIdx?: string;
@ApiPropertyOptional({
description:
'업체 — 그 업체 링크 실적으로 잡힌 신청·구매만. none 이면 업체 링크를 거치지 않은 것',
example: 'none',
})
@IsOptional()
@Matches(PARTNER_FILTER_PATTERN, { message: '업체 값이 올바르지 않습니다.' })
partnerId?: string;
}
/** 상품별·업체별 신청·구매 수 — 기간만 걸러서 센다 */
export class ActivityGroupsQueryDto extends ActivityPeriodDto {
@ApiPropertyOptional({
description: '묶음 기준 — product(상품별, 기본) · partner(업체별)',
enum: ACTIVITY_GROUP_BY,
default: 'product',
})
@IsOptional()
@IsIn(ACTIVITY_GROUP_BY, { message: '묶음 기준 값이 올바르지 않습니다.' })
by: ActivityGroupBy = 'product';
}
/** 관리자 신청·구매 내역 조회 */
export class ActivityListQueryDto extends IntersectionType(
CursorPaginationQueryDto,
ActivityFilterDto,
) {
@ApiPropertyOptional({
description:
'유입 필터 기준 — apply(신청 때 유입, 구매는 빠진다) · join(가입 경로). inflowField·inflowValue 와 함께 쓴다',
enum: ACTIVITY_INFLOW_BASIS,
})
@IsOptional()
@IsIn(ACTIVITY_INFLOW_BASIS, { message: '유입 기준 값이 올바르지 않습니다.' })
inflowBasis?: ActivityInflowBasis;
@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: '홍길동',
})
@IsOptional()
@IsString()
@MaxLength(SEARCH_MAX_LENGTH, { message: '검색어가 너무 깁니다.' })
q?: string;
}
/** 유입 집계 — 신청 때 유입 또는 가입 경로 기준 */
export class ActivityInflowStatsQueryDto extends ActivityFilterDto {
@ApiPropertyOptional({
description:
'집계 기준 — apply(신청 때 유입, 무료 신청만 · 기본) · join(가입 경로, 신청·구매 모두)',
enum: ACTIVITY_INFLOW_BASIS,
default: 'apply',
})
@IsOptional()
@IsIn(ACTIVITY_INFLOW_BASIS, { message: '유입 기준 값이 올바르지 않습니다.' })
basis: ActivityInflowBasis = 'apply';
@ApiPropertyOptional({
description: '집계 항목',
enum: INFLOW_GROUP_BY,
default: 'source',
})
@IsOptional()
@IsIn(INFLOW_GROUP_BY, { message: '집계 기준 값이 올바르지 않습니다.' })
groupBy: InflowGroupBy = 'source';
}
@@ -0,0 +1,225 @@
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 {
ACTIVITY_GROUP_BY,
ACTIVITY_INFLOW_BASIS,
ACTIVITY_TYPES,
} from './activity-query.dto';
import type {
ActivityGroupBy,
ActivityInflowBasis,
ActivityType,
} from './activity-query.dto';
/** 신청 순간의 유입정보 */
export class ActivityInflowDto {
@ApiProperty({ description: '유입원', nullable: true, example: 'facebook' })
source!: string | null;
@ApiProperty({ description: '매체', nullable: true, example: 'paid' })
medium!: string | null;
@ApiProperty({ description: '캠페인', nullable: true, example: 'fall' })
campaign!: string | null;
@ApiProperty({ description: '콘텐츠', nullable: true, example: 'video_a' })
content!: string | null;
}
/** 신청·구매를 데려온 업체 (업체 링크 실적으로 잡힌 경우) */
export class ActivityPartnerDto {
@ApiProperty({ description: '업체 ID' })
id!: string;
@ApiProperty({ description: '업체 이름', example: '머니튜브' })
name!: string;
}
/** 주문서의 주문자 — 비회원 구매는 이 값으로만 누군지 안다 */
export class ActivityOrdererDto {
@ApiProperty({ description: '주문자 이름', nullable: true })
name!: string | null;
@ApiProperty({ description: '주문자 이메일', nullable: true })
email!: string | null;
@ApiProperty({ description: '주문자 연락처', nullable: true })
phone!: string | null;
}
/** 관리자 신청·구매 내역 한 건 */
export class ActivityResponseDto {
@ApiProperty({
description: '종류 — application(무료 신청) · purchase(유료 구매)',
enum: ACTIVITY_TYPES,
})
type!: ActivityType;
@ApiProperty({ description: '신청 기록 ID 또는 구매 기록 ID' })
id!: string;
@ApiProperty({
description: '신청 시각 또는 주문 시각',
example: '2026-10-05T03:00:00.000Z',
})
occurredAt!: string;
@ApiProperty({ description: '상품번호', example: '52' })
productIdx!: string;
@ApiProperty({
description: '상품명 (신청 화면 표시값 또는 주문 당시 상품명)',
nullable: true,
})
productName!: string | null;
@ApiProperty({
description: '신청·주문 당시 아이디 — 비회원 구매면 null',
nullable: true,
})
memberUid!: string | null;
@ApiProperty({
description:
'회원 정보 (동기화된 회원 기준) — 비회원·탈퇴 등으로 없으면 null',
type: MemberResponseDto,
nullable: true,
})
member!: MemberResponseDto | null;
@ApiProperty({
description: '업체 링크 실적으로 잡힌 경우 그 업체 — 아니면 null',
type: ActivityPartnerDto,
nullable: true,
})
partner!: ActivityPartnerDto | null;
@ApiProperty({
description:
'[신청] 오픈카톡 버튼을 처음 누른 시각 — 안 눌렀거나 구매면 null',
nullable: true,
})
kakaoClickedAt!: string | null;
@ApiProperty({
description:
'[신청] 신청 때 유입 (신청 시점 브라우저에 남아 있던 경로) — 구매면 null',
type: ActivityInflowDto,
nullable: true,
})
inflow!: ActivityInflowDto | null;
@ApiProperty({
description: '[구매] 아임웹 주문번호 — 신청이면 null',
nullable: true,
})
orderNo!: string | null;
@ApiProperty({
description: '[구매] 환불·취소·반품됐는가 — 신청이면 null',
nullable: true,
})
isCancelled!: boolean | null;
@ApiProperty({
description: '[구매] 주문자 — 신청이면 null',
type: ActivityOrdererDto,
nullable: true,
})
orderer!: ActivityOrdererDto | null;
}
/** 신청·구매 수 */
export class ActivityCountsDto {
@ApiProperty({ description: '무료 신청 수', example: 120 })
applicationCount!: number;
@ApiProperty({
description: '그중 오픈카톡 버튼을 누른 수 (실제 입장 여부는 알 수 없다)',
example: 87,
})
kakaoCount!: number;
@ApiProperty({ description: '유료 구매 수 (환불·취소 제외)', example: 12 })
purchaseCount!: number;
@ApiProperty({ description: '환불·취소된 구매 수', example: 1 })
cancelledCount!: number;
}
/** 상품별·업체별 한 줄 */
export class ActivityGroupItemDto extends ActivityCountsDto {
@ApiProperty({
description:
'묶음 값 — 상품번호 또는 업체 ID. 업체별에서 업체 링크를 거치지 않은 것은 null',
nullable: true,
example: '52',
})
key!: string | null;
@ApiProperty({
description:
'표시 이름 — 상품명(아임웹 기준, 없으면 가장 최근 표시값) 또는 업체 이름',
nullable: true,
})
label!: string | null;
}
/** 상품별·업체별 신청·구매 수 */
export class ActivityGroupsResponseDto {
@ApiProperty({
description: '묶음 기준',
enum: ACTIVITY_GROUP_BY,
example: 'product',
})
by!: ActivityGroupBy;
@ApiProperty({ description: '기간 내 전체', type: ActivityCountsDto })
totals!: ActivityCountsDto;
@ApiProperty({
description: '신청·구매가 많은 순 (최대 100개)',
type: [ActivityGroupItemDto],
})
items!: ActivityGroupItemDto[];
}
/** 유입 집계 한 줄 */
export class ActivityInflowStatItemDto {
@ApiProperty({
description: '집계 값 (값이 없으면 null)',
nullable: true,
example: 'facebook',
})
key!: string | null;
@ApiProperty({ description: '신청·구매 수', example: 42 })
count!: number;
}
/** 유입 집계 결과 */
export class ActivityInflowStatsResponseDto {
@ApiProperty({ description: '집계 기준', enum: ACTIVITY_INFLOW_BASIS })
basis!: ActivityInflowBasis;
@ApiProperty({ description: '집계 항목', enum: INFLOW_GROUP_BY })
groupBy!: InflowGroupBy;
@ApiProperty({ description: '집계한 무료 신청 수', example: 300 })
applicationTotal!: number;
@ApiProperty({
description: '집계한 유료 구매 수 (가입 경로 기준일 때만, 환불·취소 제외)',
example: 10,
})
purchaseTotal!: number;
@ApiProperty({
description: '많은 순 (최대 100개, 값 없음은 key=null)',
type: [ActivityInflowStatItemDto],
})
items!: ActivityInflowStatItemDto[];
}
@@ -1,38 +1,21 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { PaginatedResult } from '@/common/dto/pagination.dto';
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 {
ApplicationGroupsQueryDto,
ApplicationInflowStatsQueryDto,
ApplicationListQueryDto,
} from './dto/application-query.dto';
import {
ApplicationGroupsResponseDto,
ApplicationInflowStatsResponseDto,
ApplicationProductDto,
ApplicationResponseDto,
} from './dto/application-response.dto';
import {
CreateApplicationDto,
CreateApplicationResultDto,
@@ -77,68 +60,3 @@ export class ApplicationsController {
await this.applications.markKakaoClicked(id);
}
}
/** 관리자 신청 내역 조회 */
@ApiTags('Admin Applications')
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@UseGuards(AdminAuthGuard)
@Controller('admin/applications')
export class AdminApplicationsController {
constructor(private readonly applications: ApplicationsService) {}
@Get()
@ApiOperation({ summary: '신청 내역 (최신순, 커서 페이지네이션)' })
@ApiStandardResponse(ApplicationResponseDto, {
description: '조회 성공 — data 는 { items, pageInfo }',
isArray: true,
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '필터·커서 형식 오류')
findMany(
@Query() query: ApplicationListQueryDto,
): Promise<PaginatedResult<ApplicationResponseDto>> {
return this.applications.findMany(query);
}
@Get('products')
@ApiOperation({ summary: '상품별 신청 수 (최근 신청이 있는 상품부터)' })
@ApiStandardResponse(ApplicationProductDto, {
description: '조회 성공',
isArray: true,
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
products(): Promise<ApplicationProductDto[]> {
return this.applications.products();
}
@Get('groups')
@ApiOperation({
summary: '상품별·업체별 신청 수 — 신청 내역의 요약 표 (기간만 거른다)',
})
@ApiStandardResponse(ApplicationGroupsResponseDto, {
description: '조회 성공',
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '기준·기간 형식 오류')
groups(
@Query() query: ApplicationGroupsQueryDto,
): Promise<ApplicationGroupsResponseDto> {
return this.applications.groups(query);
}
@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);
}
}
@@ -1,37 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminAuthModule } from '@/modules/admin-auth/admin-auth.module';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { ImwebModule } from '@/modules/imweb/imweb.module';
import { MembersModule } from '@/modules/members/members.module';
import { PartnerConversion } from '@/modules/partners/entities/partner-conversion.entity';
import { Partner } from '@/modules/partners/entities/partner.entity';
import { PartnersModule } from '@/modules/partners/partners.module';
import { MemberApplication } from './application.entity';
import {
AdminApplicationsController,
ApplicationsController,
} from './applications.controller';
import { ApplicationsController } from './applications.controller';
import { ApplicationsService } from './applications.service';
/** 강의 신청 기록 — 신청 완료 화면이 보내고, 관리자 화면에서 본다 */
/**
* 강의 신청 기록 — 신청 완료 화면이 보낸다.
* 관리자 화면의 조회는 ActivitiesModule(신청·구매 내역)이 맡는다.
*/
@Module({
imports: [
// 신청을 데려온 업체를 보여주려고 업체 실적·업체를 읽는다
TypeOrmModule.forFeature([
MemberApplication,
ImwebMember,
PartnerConversion,
Partner,
]),
AdminAuthModule,
TypeOrmModule.forFeature([MemberApplication]),
ImwebModule,
MembersModule,
// 신청을 업체 링크 실적으로 귀속한다
PartnersModule,
],
controllers: [ApplicationsController, AdminApplicationsController],
controllers: [ApplicationsController],
providers: [ApplicationsService],
})
export class ApplicationsModule {}
@@ -3,9 +3,6 @@ import { QueryFailedError, type Repository } from 'typeorm';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import type { ImwebConfig } from '@/modules/imweb/imweb.config';
import type { ImwebMemberLookupService } from '@/modules/imweb/imweb-member-lookup.service';
import type { MembersService } from '@/modules/members/members.service';
import type { PartnerConversion } from '@/modules/partners/entities/partner-conversion.entity';
import type { Partner } from '@/modules/partners/entities/partner.entity';
import type { PartnerAttributionService } from '@/modules/partners/partner-attribution.service';
import { MemberApplication } from './application.entity';
import { ApplicationsService } from './applications.service';
@@ -48,13 +45,9 @@ function setup(member: ImwebMember | null = MEMBER) {
};
const service = new ApplicationsService(
applications as unknown as Repository<MemberApplication>,
{} as Repository<ImwebMember>,
lookup as unknown as ImwebMemberLookupService,
{} as MembersService,
{ trackingHost: 'utm.moneyguider.co.kr' } as ImwebConfig,
attribution as unknown as PartnerAttributionService,
{} as Repository<PartnerConversion>,
{} as Repository<Partner>,
);
return { service, store, applications, lookup, attribution, calls };
}
@@ -1,82 +1,21 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import {
In,
IsNull,
QueryFailedError,
Repository,
SelectQueryBuilder,
} from 'typeorm';
import { IsNull, QueryFailedError, Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { ImwebProduct } from '@/modules/imweb/entities/imweb-product.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 {
ConversionType,
PartnerConversion,
} from '@/modules/partners/entities/partner-conversion.entity';
import { Partner } from '@/modules/partners/entities/partner.entity';
import { PartnerAttributionService } from '@/modules/partners/partner-attribution.service';
import { InflowStatus, parseInflow } from '@/shared/utils/inflow';
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
import { parseInflow } from '@/shared/utils/inflow';
import { MemberApplication } from './application.entity';
import {
ApplicationFilterDto,
ApplicationGroupsQueryDto,
ApplicationInflowBasis,
ApplicationInflowStatsQueryDto,
ApplicationListQueryDto,
PARTNER_FILTER_NONE,
} from './dto/application-query.dto';
import {
ApplicationGroupsResponseDto,
ApplicationInflowStatsResponseDto,
ApplicationPartnerDto,
ApplicationProductDto,
ApplicationResponseDto,
} from './dto/application-response.dto';
import {
CreateApplicationDto,
CreateApplicationResultDto,
} from './dto/create-application.dto';
const ALIAS = 'a';
const MEMBER_ALIAS = 'm';
const CONVERSION_ALIAS = 'pc';
const PARTNER_ALIAS = 'p';
const PRODUCT_ALIAS = 'ip';
/** 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}`);
}
function isUniqueViolation(error: unknown): boolean {
return (
@@ -92,6 +31,7 @@ function isUniqueViolation(error: unknown): boolean {
* 신청 완료 화면은 공개 페이지라 보낸 사람이 정말 그 회원인지는 확인할 수 없다.
* 그래서 (1) 아임웹에 실제로 있는 회원인지 확인하고 (2) 회원·상품당 한 건만 남기며
* (3) 화면에 보이는 회원 정보는 브라우저가 보낸 값이 아니라 아임웹에서 받은 값만 쓴다.
* 관리자 조회는 유료 구매와 함께 ActivitiesModule(신청·구매 내역)이 맡는다.
*/
@Injectable()
export class ApplicationsService {
@@ -100,16 +40,9 @@ export class ApplicationsService {
constructor(
@InjectRepository(MemberApplication)
private readonly applications: Repository<MemberApplication>,
@InjectRepository(ImwebMember)
private readonly members: Repository<ImwebMember>,
private readonly lookup: ImwebMemberLookupService,
private readonly membersService: MembersService,
private readonly config: ImwebConfig,
private readonly attribution: PartnerAttributionService,
@InjectRepository(PartnerConversion)
private readonly conversions: Repository<PartnerConversion>,
@InjectRepository(Partner)
private readonly partners: Repository<Partner>,
) {}
/** 신청 기록 — 같은 회원·상품이면 기존 기록을 돌려준다 (몇 번을 불러도 한 건) */
@@ -215,332 +148,10 @@ export class ApplicationsService {
);
}
async findMany(
query: ApplicationListQueryDto,
): Promise<PaginatedResult<ApplicationResponseDto>> {
const base = this.filtered(query);
const total = await base.clone().getCount();
const page = base
.clone()
.orderBy(`${ALIAS}.appliedAt`, 'DESC')
.addOrderBy(`${ALIAS}.id`, 'DESC')
.take(query.limit + 1);
if (query.cursor) {
const cursor = decodeTimeCursor(query.cursor);
if (!cursor) {
throw new BusinessException('VAL_001', {
context: { reason: 'invalid_cursor' },
});
}
page.andWhere(
`(${ALIAS}.appliedAt, ${ALIAS}.id) < (:cursorTime, :cursorId)`,
{
cursorTime: cursor.time,
cursorId: cursor.key,
},
);
}
const rows = await page.getMany();
const hasNext = rows.length > query.limit;
const items = rows.slice(0, query.limit);
const last = items.at(-1);
const [membersByCode, groupTitles, partnersByApplication] =
await Promise.all([
this.membersByCode(items.map((row) => row.memberCode)),
this.membersService.groupTitleMap(),
this.partnersByApplication(items.map((row) => row.id)),
]);
return toPaginatedResult(
items.map((row) => {
const member = membersByCode.get(row.memberCode);
return {
id: row.id,
appliedAt: row.appliedAt.toISOString(),
kakaoClickedAt: row.kakaoClickedAt?.toISOString() ?? null,
productIdx: row.productIdx,
productName: row.productName,
memberUid: row.memberUid,
member: member ? MemberResponseDto.from(member, groupTitles) : null,
inflow: {
source: row.inflowSource,
medium: row.inflowMedium,
campaign: row.inflowCampaign,
content: row.inflowContent,
},
lastInflow: {
source: row.inflowLastSource,
medium: row.inflowLastMedium,
campaign: row.inflowLastCampaign,
content: row.inflowLastContent,
},
partner: partnersByApplication.get(row.id) ?? null,
};
}),
{
hasNext,
nextCursor:
hasNext && last
? encodeTimeCursor({
time: last.appliedAt.toISOString(),
key: last.id,
})
: null,
total,
},
);
}
/**
* 신청 유입 집계 — 같은 신청을 '신청 때 최종 / 최초 유입' 또는 '가입 경로'로 나눠 센다.
* 기준을 바꿔 보면 "가입은 네이버로 했지만 이번 신청은 페이스북 광고로 왔다" 같은 차이가 보인다.
*/
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.partnerId) {
qb.leftJoin(
PartnerConversion,
CONVERSION_ALIAS,
`${CONVERSION_ALIAS}.application_id = ${ALIAS}.id`,
);
if (filter.partnerId === PARTNER_FILTER_NONE) {
qb.andWhere(`${CONVERSION_ALIAS}.id IS NULL`);
} else {
qb.andWhere(`${CONVERSION_ALIAS}.partner_id = :partnerId`, {
partnerId: filter.partnerId,
});
}
}
// 집계 막대를 눌러 거를 때 — 기준·항목·값이 모두 있어야 한다
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;
}
/**
* 상품별·업체별 신청 수 — 신청 내역 화면의 요약 표.
* 업체별은 업체 링크 실적으로 잡힌 신청을 업체로 묶고, 나머지는 key=null(업체 없음)로 센다.
*/
async groups(
query: ApplicationGroupsQueryDto,
): Promise<ApplicationGroupsResponseDto> {
const base = this.applications.createQueryBuilder(ALIAS);
if (query.appliedFrom) {
base.andWhere(`${ALIAS}.appliedAt >= :appliedFrom`, {
appliedFrom: query.appliedFrom,
});
}
if (query.appliedTo) {
base.andWhere(`${ALIAS}.appliedAt < :appliedTo`, {
appliedTo: query.appliedTo,
});
}
const totals = await base
.clone()
.select('COUNT(*)', 'total')
.addSelect(`COUNT(${ALIAS}.kakao_clicked_at)`, 'kakaoTotal')
.getRawOne<{ total: string; kakaoTotal: string }>();
const grouped = base.clone();
if (query.by === 'partner') {
grouped
.leftJoin(
PartnerConversion,
CONVERSION_ALIAS,
`${CONVERSION_ALIAS}.application_id = ${ALIAS}.id AND ${CONVERSION_ALIAS}.type = :applicationType`,
{ applicationType: ConversionType.APPLICATION },
)
.leftJoin(
Partner,
PARTNER_ALIAS,
`${PARTNER_ALIAS}.id = ${CONVERSION_ALIAS}.partner_id`,
)
.select(`${CONVERSION_ALIAS}.partner_id`, 'key')
.addSelect(`MAX(${PARTNER_ALIAS}.name)`, 'label');
} else {
// 상품명은 아임웹 상품 기준, 아직 불러오지 않은 상품이면 가장 최근 신청의 표시값
grouped
.leftJoin(
ImwebProduct,
PRODUCT_ALIAS,
`CAST(${PRODUCT_ALIAS}.prod_no AS text) = ${ALIAS}.product_idx`,
)
.select(`${ALIAS}.product_idx`, 'key')
.addSelect(
`COALESCE(MAX(${PRODUCT_ALIAS}.name), (ARRAY_AGG(${ALIAS}.product_name ORDER BY ${ALIAS}.applied_at DESC))[1])`,
'label',
);
}
const rows = await grouped
.addSelect('COUNT(*)', 'count')
.addSelect(`COUNT(${ALIAS}.kakao_clicked_at)`, 'kakaoCount')
.groupBy('key')
.orderBy('count', 'DESC')
.addOrderBy('key', 'ASC', 'NULLS LAST')
.limit(STATS_MAX_ITEMS)
.getRawMany<{
key: string | null;
label: string | null;
count: string;
kakaoCount: string;
}>();
return {
by: query.by,
total: Number(totals?.total ?? 0),
kakaoTotal: Number(totals?.kakaoTotal ?? 0),
items: rows.map((row) => ({
key: row.key,
label: row.label,
count: Number(row.count),
kakaoCount: Number(row.kakaoCount),
})),
};
}
/** 신청 ID → 그 신청을 데려온 업체 (업체 링크 실적이 없으면 빠진다) */
private async partnersByApplication(
applicationIds: string[],
): Promise<Map<string, ApplicationPartnerDto>> {
if (applicationIds.length === 0) return new Map();
const conversions = await this.conversions.find({
select: { applicationId: true, partnerId: true },
where: { applicationId: In(applicationIds) },
});
const partnerIds = [...new Set(conversions.map((c) => c.partnerId))];
if (partnerIds.length === 0) return new Map();
const partners = await this.partners.find({
select: { id: true, name: true },
where: { id: In(partnerIds) },
});
const nameById = new Map(partners.map((p) => [p.id, p.name]));
const result = new Map<string, ApplicationPartnerDto>();
for (const conversion of conversions) {
if (!conversion.applicationId) continue;
result.set(conversion.applicationId, {
id: conversion.partnerId,
name: nameById.get(conversion.partnerId) ?? '',
});
}
return result;
}
/** 상품별 신청 수 — 최근 신청이 있는 상품부터 */
async products(): Promise<ApplicationProductDto[]> {
const rows = await this.applications
.createQueryBuilder(ALIAS)
.select(`${ALIAS}.productIdx`, 'productIdx')
// 상품명은 바뀔 수 있어 가장 최근 신청의 표시값을 쓴다
.addSelect(
`(ARRAY_AGG(${ALIAS}.product_name ORDER BY ${ALIAS}.applied_at DESC))[1]`,
'productName',
)
.addSelect('COUNT(*)', 'count')
.groupBy(`${ALIAS}.productIdx`)
.orderBy(`MAX(${ALIAS}.applied_at)`, 'DESC')
.getRawMany<{
productIdx: string;
productName: string | null;
count: string;
}>();
return rows.map((row) => ({
productIdx: row.productIdx,
productName: row.productName,
count: Number(row.count),
}));
}
private findExisting(
memberCode: string,
productIdx: string,
): Promise<MemberApplication | null> {
return this.applications.findOne({ where: { memberCode, productIdx } });
}
private async membersByCode(
codes: string[],
): Promise<Map<string, ImwebMember>> {
if (codes.length === 0) return new Map();
const rows = await this.members
.createQueryBuilder(MEMBER_ALIAS)
.where(`${MEMBER_ALIAS}.memberCode IN (:...codes)`, { codes })
.getMany();
return new Map(rows.map((row) => [row.memberCode, row]));
}
}
@@ -1,175 +0,0 @@
import { ApiPropertyOptional, IntersectionType } from '@nestjs/swagger';
import {
IsDateString,
IsIn,
IsOptional,
IsString,
Matches,
MaxLength,
} 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;
/** 업체 필터 — 업체 ID(UUID) 또는 none(업체 링크를 거치지 않은 신청) */
export const PARTNER_FILTER_NONE = 'none';
const PARTNER_FILTER_PATTERN =
/^(none|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
/**
* 신청 유입을 어느 기록으로 볼 것인가.
* - 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: '상품번호 형식이 올바르지 않습니다.' })
productIdx?: string;
@ApiPropertyOptional({
description: '신청 시각 시작 (이상, ISO 8601)',
example: '2026-09-30T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '신청 시작일 형식이 올바르지 않습니다.' })
appliedFrom?: string;
@ApiPropertyOptional({
description: '신청 시각 끝 (미만, ISO 8601)',
example: '2026-10-31T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '신청 종료일 형식이 올바르지 않습니다.' })
appliedTo?: string;
@ApiPropertyOptional({
description:
'업체 — 그 업체 링크 실적으로 잡힌 신청만. none 이면 업체 링크를 거치지 않은 신청',
example: 'none',
})
@IsOptional()
@Matches(PARTNER_FILTER_PATTERN, { message: '업체 값이 올바르지 않습니다.' })
partnerId?: string;
}
/** 신청 요약 묶음 기준 — 상품별 / 업체별 */
export const APPLICATION_GROUP_BY = ['product', 'partner'] as const;
export type ApplicationGroupBy = (typeof APPLICATION_GROUP_BY)[number];
/** 상품별·업체별 신청 수 — 기간만 걸러서 센다 */
export class ApplicationGroupsQueryDto {
@ApiPropertyOptional({
description: '묶음 기준 — product(상품별, 기본) · partner(업체별)',
enum: APPLICATION_GROUP_BY,
default: 'product',
})
@IsOptional()
@IsIn(APPLICATION_GROUP_BY, { message: '묶음 기준 값이 올바르지 않습니다.' })
by: ApplicationGroupBy = 'product';
@ApiPropertyOptional({
description: '신청 시각 시작 (이상, ISO 8601)',
example: '2026-09-30T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '신청 시작일 형식이 올바르지 않습니다.' })
appliedFrom?: string;
@ApiPropertyOptional({
description: '신청 시각 끝 (미만, ISO 8601)',
example: '2026-10-31T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '신청 종료일 형식이 올바르지 않습니다.' })
appliedTo?: string;
}
/** 관리자 신청 내역 조회 */
export class ApplicationListQueryDto extends IntersectionType(
CursorPaginationQueryDto,
ApplicationFilterDto,
) {
@ApiPropertyOptional({
description:
'신청 때 최초 유입원 (inflowBasis 필터가 생기기 전부터 있던 값)',
example: 'facebook',
})
@IsOptional()
@IsString()
@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: '홍길동',
})
@IsOptional()
@IsString()
@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,200 +0,0 @@
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_GROUP_BY,
APPLICATION_INFLOW_BASIS,
} from './application-query.dto';
import type {
ApplicationGroupBy,
ApplicationInflowBasis,
} from './application-query.dto';
/** 신청 순간의 유입정보 */
export class ApplicationInflowDto {
@ApiProperty({ description: '유입원', nullable: true, example: 'facebook' })
source!: string | null;
@ApiProperty({ description: '매체', nullable: true, example: 'paid' })
medium!: string | null;
@ApiProperty({ description: '캠페인', nullable: true, example: 'fall' })
campaign!: string | null;
@ApiProperty({ description: '콘텐츠', nullable: true, example: 'video_a' })
content!: string | null;
}
/** 신청을 데려온 업체 (업체 링크 실적으로 잡힌 경우) */
export class ApplicationPartnerDto {
@ApiProperty({ description: '업체 ID' })
id!: string;
@ApiProperty({ description: '업체 이름', example: '머니튜브' })
name!: string;
}
/** 관리자 신청 내역 한 건 */
export class ApplicationResponseDto {
@ApiProperty({ description: '신청 기록 ID' })
id!: string;
@ApiProperty({
description: '신청 시각',
example: '2026-10-05T03:00:00.000Z',
})
appliedAt!: string;
@ApiProperty({
description: '오픈카톡 버튼을 처음 누른 시각 (안 눌렀으면 null)',
nullable: true,
})
kakaoClickedAt!: string | null;
@ApiProperty({ description: '상품번호', example: '52' })
productIdx!: string;
@ApiProperty({ description: '상품명 (신청 화면 표시값)', nullable: true })
productName!: string | null;
@ApiProperty({ description: '신청 당시 아이디', example: 'user@example.com' })
memberUid!: string;
@ApiProperty({
description: '회원 정보 (동기화된 회원 기준) — 탈퇴 등으로 없으면 null',
type: MemberResponseDto,
nullable: true,
})
member!: MemberResponseDto | null;
@ApiProperty({
description:
'신청 때 최초 유입 — 신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)',
type: ApplicationInflowDto,
})
inflow!: ApplicationInflowDto;
@ApiProperty({
description:
'신청 때 최종 유입 — 신청 직전에 마지막으로 들어온 경로. 유입이 한 번뿐이면 최초와 같다',
type: ApplicationInflowDto,
})
lastInflow!: ApplicationInflowDto;
@ApiProperty({
description:
'업체 링크 실적으로 잡힌 경우 그 업체 — 업체 링크를 거치지 않았으면 null',
type: ApplicationPartnerDto,
nullable: true,
})
partner!: ApplicationPartnerDto | null;
}
/** 상품별 신청 수 — 필터 칩에 쓴다 */
export class ApplicationProductDto {
@ApiProperty({ description: '상품번호', example: '52' })
productIdx!: string;
@ApiProperty({
description: '가장 최근 신청에 표시된 상품명',
nullable: true,
})
productName!: string | null;
@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[];
}
/** 상품별·업체별 신청 수 한 줄 */
export class ApplicationGroupItemDto {
@ApiProperty({
description:
'묶음 값 — 상품번호 또는 업체 ID. 업체별에서 업체 링크를 거치지 않은 신청은 null',
nullable: true,
example: '52',
})
key!: string | null;
@ApiProperty({
description:
'표시 이름 — 상품명(아임웹 기준, 없으면 신청 화면 표시값) 또는 업체 이름',
nullable: true,
example: '[무료한정] 하루 2시간 월1000 버는 AI 효자 상품 찾기',
})
label!: string | null;
@ApiProperty({ description: '신청 수', example: 120 })
count!: number;
@ApiProperty({
description: '그중 오픈카톡 입장 버튼을 누른 수',
example: 87,
})
kakaoCount!: number;
}
/** 상품별·업체별 신청 수 */
export class ApplicationGroupsResponseDto {
@ApiProperty({
description: '묶음 기준',
enum: APPLICATION_GROUP_BY,
example: 'product',
})
by!: ApplicationGroupBy;
@ApiProperty({ description: '기간 내 전체 신청 수', example: 310 })
total!: number;
@ApiProperty({
description: '기간 내 오픈카톡 입장 버튼을 누른 신청 수',
example: 220,
})
kakaoTotal!: number;
@ApiProperty({
description: '신청 수 많은 순',
type: [ApplicationGroupItemDto],
})
items!: ApplicationGroupItemDto[];
}
+4
View File
@@ -103,6 +103,10 @@ export interface ImwebOrderData {
isMember?: string | null;
memberCode?: string | null;
memberUid?: string | null;
/** 주문자 — 비회원 주문은 이 값으로만 누군지 알 수 있다 */
ordererName?: string | null;
ordererEmail?: string | null;
ordererCall?: string | null;
/** 주문 생성 시각 (UTC) */
wtime: string;
payments?: {
@@ -0,0 +1,86 @@
import type { SchedulerRegistry } from '@nestjs/schedule';
import type { ImwebConfig } from '@/modules/imweb/imweb.config';
import type { ImwebAuthService } from '@/modules/imweb/imweb-auth.service';
import type { ImwebOrderData } from '@/modules/imweb/imweb.types';
import type { PurchasesService } from '@/modules/purchases/purchases.service';
import type { PartnerAttributionService } from './partner-attribution.service';
import { PartnerOrderSyncService } from './partner-order-sync.service';
const DAY_MS = 86_400_000;
const NOW = new Date('2026-10-06T00:00:00.000Z');
const ORDER = { orderNo: 1, wtime: '2026-10-05T00:00:00Z' } as ImwebOrderData;
function setup(linked: number[] = []) {
const get = jest.fn(() =>
Promise.resolve({ data: { list: [ORDER], totalPage: 1 } }),
);
const auth = {
requireConnection: jest.fn(() => Promise.resolve({ unitCode: 'u1' })),
authorizedGet: get,
};
const attribution = {
linkedProdNos: jest.fn(() => Promise.resolve(new Set(linked))),
attributeOrder: jest.fn(() => Promise.resolve({ created: 0, updated: 0 })),
};
const purchases = { recordOrders: jest.fn(() => Promise.resolve(1)) };
const service = new PartnerOrderSyncService(
attribution as unknown as PartnerAttributionService,
purchases as unknown as PurchasesService,
auth as unknown as ImwebAuthService,
{} as ImwebConfig,
{} as SchedulerRegistry,
);
/** n번째 아임웹 호출의 조회 시작 시각 */
const sinceOf = (call: number) => {
const params = (get.mock.calls[call] as unknown[])[1] as {
startWtime: string;
};
return new Date(params.startWtime).getTime();
};
return { service, get, attribution, purchases, sinceOf };
}
async function runOnce(service: PartnerOrderSyncService): Promise<void> {
service.start();
await service.waitForIdle();
}
describe('PartnerOrderSyncService', () => {
beforeEach(() => jest.useFakeTimers({ now: NOW, doNotFake: ['setTimeout'] }));
afterEach(() => jest.useRealTimers());
it('업체 링크가 없어도 주문을 훑어 구매 내역을 남긴다 (회원 여부로 거르지 않는다)', async () => {
const { service, get, purchases, attribution } = setup([]);
await runOnce(service);
expect(purchases.recordOrders).toHaveBeenCalledWith([ORDER]);
expect(attribution.attributeOrder).not.toHaveBeenCalled();
expect(get.mock.calls[0]).not.toHaveProperty('1.isMember');
});
it('업체 링크가 있으면 같은 주문으로 업체 실적도 반영한다', async () => {
const { service, attribution } = setup([52]);
await runOnce(service);
expect(attribution.attributeOrder).toHaveBeenCalledWith(
ORDER,
new Set([52]),
);
});
it('첫 실행은 1년치, 성공한 뒤부터는 60일치만 본다', async () => {
const { service, sinceOf } = setup();
await runOnce(service);
await runOnce(service);
expect(NOW.getTime() - sinceOf(0)).toBe(365 * DAY_MS);
expect(NOW.getTime() - sinceOf(1)).toBe(60 * DAY_MS);
});
it('1년치 조회가 실패하면 다음 실행에서 다시 1년치를 본다', async () => {
const { service, get, sinceOf } = setup();
get.mockImplementationOnce(() => Promise.reject(new Error('down')));
await runOnce(service);
await runOnce(service);
expect(service.getStatus().lastErrorCode).toBeNull();
expect(NOW.getTime() - sinceOf(1)).toBe(365 * DAY_MS);
});
});
@@ -10,6 +10,7 @@ import { BusinessException } from '@/common/errors/business.exception';
import { IMWEB_ORDERS_PATH, ImwebConfig } from '@/modules/imweb/imweb.config';
import { ImwebAuthService } from '@/modules/imweb/imweb-auth.service';
import { ImwebOrderPageData } from '@/modules/imweb/imweb.types';
import { PurchasesService } from '@/modules/purchases/purchases.service';
import { OrderSyncStatusDto } from './dto/partner-conversion.dto';
import { PartnerAttributionService } from './partner-attribution.service';
@@ -19,6 +20,11 @@ const SCHEDULE_NAME = 'partner-order-sync';
* 그보다 오래된 구매의 환불은 반영되지 않는다.
*/
const ORDER_LOOKBACK_DAYS = 60;
/**
* 서버가 뜬 뒤 첫 실행은 1년치를 본다 — 구매 내역을 처음 채우고, 서버가 꺼져 있던 동안의 변경도 메운다.
* 끝까지 성공한 뒤부터는 ORDER_LOOKBACK_DAYS 만 본다.
*/
const PURCHASE_BACKFILL_DAYS = 365;
const DAY_MS = 86_400_000;
const ORDER_PAGE_SIZE = 100;
/** 페이지 사이 쉬는 시간 — 아임웹 호출 제한이 문서에 없어 보수적으로 둔다 */
@@ -37,10 +43,11 @@ function toImwebTime(date: Date): string {
}
/**
* 업체 링크 구매 집계 — 아임웹 주문을 주기적으로 훑어 구매를 잡고 환불·취소를 반영한다.
* 주문 동기화 — 아임웹 주문을 주기적으로 훑어
* (1) 결제된 주문을 구매 내역(purchases)에 남기고 (회원·비회원 모두)
* (2) 업체 링크가 있는 상품이면 업체 실적(구매)으로 잡는다. 둘 다 환불·취소를 반영한다.
*
* 아임웹은 주문 완료를 알려주지 않으므로(웹훅 없음) 회원 동기화와 같은 주기로 최근 주문을 다시 본다.
* 업체 링크가 하나도 없으면 아임웹을 부르지 않는다.
*/
@Injectable()
export class PartnerOrderSyncService
@@ -49,6 +56,8 @@ export class PartnerOrderSyncService
private readonly logger = new Logger(PartnerOrderSyncService.name);
/** 진행 중인 실행 — 동시에 두 번 돌지 않게 하고, 종료 시 기다린다 */
private running: Promise<void> | null = null;
/** 1년치를 한 번 끝까지 봤는가 — 중간에 실패하면 다음 실행에서 다시 1년치를 본다 */
private isBackfilled = false;
private status: OrderSyncStatusDto = {
isRunning: false,
lastStartedAt: null,
@@ -60,6 +69,7 @@ export class PartnerOrderSyncService
constructor(
private readonly attribution: PartnerAttributionService,
private readonly purchases: PurchasesService,
private readonly auth: ImwebAuthService,
private readonly config: ImwebConfig,
private readonly scheduler: SchedulerRegistry,
@@ -122,25 +132,31 @@ export class PartnerOrderSyncService
let checked = 0;
let created = 0;
let updated = 0;
let purchases = 0;
const lookbackDays = this.isBackfilled
? ORDER_LOOKBACK_DAYS
: PURCHASE_BACKFILL_DAYS;
try {
const linkedProdNos = await this.attribution.linkedProdNos();
if (linkedProdNos.size > 0) {
const { unitCode } = await this.auth.requireConnection();
const since = new Date(Date.now() - ORDER_LOOKBACK_DAYS * DAY_MS);
const { unitCode } = await this.auth.requireConnection();
const since = new Date(Date.now() - lookbackDays * DAY_MS);
for (let page = 1; page <= MAX_PAGES; page += 1) {
if (page > 1) await sleep(PAGE_DELAY_MS);
const { data } = await this.auth.authorizedGet<ImwebOrderPageData>(
IMWEB_ORDERS_PATH,
{
page,
limit: ORDER_PAGE_SIZE,
unitCode,
isMember: 'Y',
startWtime: toImwebTime(since),
},
);
for (let page = 1; page <= MAX_PAGES; page += 1) {
if (page > 1) await sleep(PAGE_DELAY_MS);
// 비회원 주문도 구매 내역에 남기므로 회원 여부로 거르지 않는다
const { data } = await this.auth.authorizedGet<ImwebOrderPageData>(
IMWEB_ORDERS_PATH,
{
page,
limit: ORDER_PAGE_SIZE,
unitCode,
startWtime: toImwebTime(since),
},
);
purchases += await this.purchases.recordOrders(data.list);
// 업체 실적은 업체 링크가 있는 상품만 (비회원 주문은 그 안에서 걸러진다)
if (linkedProdNos.size > 0) {
for (const order of data.list) {
const result = await this.attribution.attributeOrder(
order,
@@ -149,10 +165,11 @@ export class PartnerOrderSyncService
created += result.created;
updated += result.updated;
}
checked += data.list.length;
if (data.list.length === 0 || page >= data.totalPage) break;
}
checked += data.list.length;
if (data.list.length === 0 || page >= data.totalPage) break;
}
this.isBackfilled = true;
this.status = {
...this.status,
@@ -161,7 +178,7 @@ export class PartnerOrderSyncService
createdCount: created,
};
this.logger.log(
`업체 구매 집계 완료: orders=${checked} created=${created} updated=${updated} ${Date.now() - startedAt}ms`,
`주문 동기화 완료: days=${lookbackDays} orders=${checked} purchases=${purchases} partnerCreated=${created} partnerUpdated=${updated} ${Date.now() - startedAt}ms`,
);
} catch (error) {
const code =
@@ -174,10 +191,10 @@ export class PartnerOrderSyncService
};
// 권한 부족·미연결 등 예상된 실패는 warn, 그 밖의 오류만 error 로 남긴다
if (error instanceof BusinessException) {
this.logger.warn(`[${code}] 업체 구매 집계 실패`);
this.logger.warn(`[${code}] 주문 동기화 실패`);
} else {
this.logger.error(
`[${code}] 업체 구매 집계 실패`,
`[${code}] 주문 동기화 실패`,
error instanceof Error ? error.stack : String(error),
);
}
@@ -6,6 +6,7 @@ import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { ImwebProduct } from '@/modules/imweb/entities/imweb-product.entity';
import { ImwebModule } from '@/modules/imweb/imweb.module';
import { MembersModule } from '@/modules/members/members.module';
import { PurchasesModule } from '@/modules/purchases/purchases.module';
import { PartnerConversion } from './entities/partner-conversion.entity';
import { PartnerLinkClick } from './entities/partner-link-click.entity';
import { PartnerLink } from './entities/partner-link.entity';
@@ -29,7 +30,8 @@ import { PartnersService } from './partners.service';
/**
* 업체 링크 — 업체·상품별 단축 링크를 발급하고, 그 링크로 들어온 회원의 신청·구매를 센다.
* 신청은 ApplicationsModule 이 PartnerAttributionService 를 불러 귀속하고,
* 구매는 PartnerOrderSyncService 가 아임웹 주문을 주기적으로 훑어 찾는다.
* 구매는 PartnerOrderSyncService 가 아임웹 주문을 주기적으로 훑어 찾는다
* (같은 동기화가 업체와 무관한 구매 내역도 PurchasesModule 에 남긴다).
*/
@Module({
imports: [
@@ -45,6 +47,7 @@ import { PartnersService } from './partners.service';
AdminAuthModule,
ImwebModule,
MembersModule,
PurchasesModule,
],
controllers: [
PartnersController,
@@ -0,0 +1,84 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryColumn,
} from 'typeorm';
/**
* 유료 구매 — 아임웹 주문을 주기적으로 훑어 결제된 주문의 상품을 남긴다 (주문·상품당 한 줄).
*
* 회원·비회원 주문을 모두 남긴다. 비회원은 회원 코드가 없어 주문자 정보로만 누군지 안다.
* 나중에 환불·취소되면 지우지 않고 isValid=false 로 바꾼다.
* 금액은 관리하지 않는다.
*/
@Entity({ name: 'purchases' })
@Index('uq_purchases_order_product', ['orderNo', 'prodNo'], { unique: true })
@Index('idx_purchases_cursor', ['orderedAt', 'id'])
@Index('idx_purchases_member', ['memberCode'])
export class Purchase {
/** 앱에서 crypto.randomUUID() 로 만든다 */
@PrimaryColumn({ name: 'id', type: 'uuid' })
id!: string;
/** 아임웹 주문번호 */
@Column({ name: 'order_no', type: 'varchar', length: 30 })
orderNo!: string;
@Column({ name: 'prod_no', type: 'int' })
prodNo!: number;
/** 주문 당시 상품명 */
@Column({
name: 'product_name',
type: 'varchar',
length: 200,
nullable: true,
})
productName!: string | null;
/** 아임웹 회원 코드 — 비회원 주문이면 null */
@Column({ name: 'member_code', type: 'varchar', length: 40, nullable: true })
memberCode!: string | null;
/** 주문 당시 아이디 — 비회원 주문이면 null */
@Column({ name: 'member_uid', type: 'varchar', length: 255, nullable: true })
memberUid!: string | null;
@Column({
name: 'orderer_name',
type: 'varchar',
length: 100,
nullable: true,
})
ordererName!: string | null;
@Column({
name: 'orderer_email',
type: 'varchar',
length: 255,
nullable: true,
})
ordererEmail!: string | null;
@Column({
name: 'orderer_call',
type: 'varchar',
length: 40,
nullable: true,
})
ordererCall!: string | null;
/** 주문 시각 (아임웹 wtime) */
@Column({ name: 'ordered_at', type: 'timestamptz' })
orderedAt!: Date;
/** 지금 구매로 세는가 — 환불·취소·반품되면 false */
@Column({ name: 'is_valid', type: 'boolean', default: true })
isValid!: boolean;
/** 처음 잡힌 시각 */
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
}
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Purchase } from './purchase.entity';
import { PurchasesService } from './purchases.service';
/**
* 유료 구매 기록 — 아임웹 주문 동기화(PartnersModule 의 PartnerOrderSyncService)가 채우고,
* 관리자 신청·구매 내역(ActivitiesModule)이 읽는다.
*/
@Module({
imports: [TypeOrmModule.forFeature([Purchase])],
providers: [PurchasesService],
exports: [PurchasesService],
})
export class PurchasesModule {}
@@ -0,0 +1,114 @@
import type { Repository } from 'typeorm';
import type { ImwebOrderData } from '@/modules/imweb/imweb.types';
import type { Purchase } from './purchase.entity';
import { PurchasesService } from './purchases.service';
const PAID = [{ paymentStatus: 'PAYMENT_COMPLETE', isCancel: 'N' }];
function order(overrides: Partial<ImwebOrderData>): ImwebOrderData {
return {
orderNo: 202610050001,
isMember: 'Y',
memberCode: 'm20261005abcdef',
memberUid: 'user@test.dev',
ordererName: '홍길동',
ordererEmail: 'user@test.dev',
ordererCall: '010-1234-5678',
wtime: '2026-10-05T03:00:00.000Z',
payments: PAID,
sections: [
{ sectionItems: [{ productInfo: { prodNo: 52, prodName: 'AI 강의' } }] },
],
...overrides,
};
}
/** insert 쿼리 빌더 흉내 — 메서드가 자기 자신을 돌려준다 */
interface InsertQueryMock {
insert: jest.Mock<InsertQueryMock, []>;
values: jest.Mock<InsertQueryMock, [Purchase[]]>;
orUpdate: jest.Mock<InsertQueryMock, unknown[]>;
execute: jest.Mock<Promise<void>, []>;
}
function setup() {
const inserted: Purchase[][] = [];
const query: InsertQueryMock = {
insert: jest.fn(() => query),
values: jest.fn((rows: Purchase[]) => {
inserted.push(rows);
return query;
}),
orUpdate: jest.fn(() => query),
execute: jest.fn(() => Promise.resolve()),
};
const repo = {
create: jest.fn((row: Purchase) => row),
createQueryBuilder: jest.fn(() => query),
};
const service = new PurchasesService(repo as unknown as Repository<Purchase>);
return { service, inserted, query };
}
describe('PurchasesService.recordOrders', () => {
it('결제된 회원 주문을 주문·상품 단위로 남긴다', async () => {
const { service, inserted } = setup();
await expect(service.recordOrders([order({})])).resolves.toBe(1);
expect(inserted[0][0]).toMatchObject({
orderNo: '202610050001',
prodNo: 52,
productName: 'AI 강의',
memberCode: 'm20261005abcdef',
memberUid: 'user@test.dev',
ordererName: '홍길동',
isValid: true,
});
});
it('비회원 주문은 회원 정보 없이 주문자 정보로 남긴다', async () => {
const { service, inserted } = setup();
await service.recordOrders([
order({ isMember: 'N', memberCode: '', memberUid: '' }),
]);
expect(inserted[0][0]).toMatchObject({
memberCode: null,
memberUid: null,
ordererName: '홍길동',
ordererCall: '010-1234-5678',
});
});
it('결제 전 주문만 있으면 DB 를 부르지 않는다', async () => {
const { service, query } = setup();
await expect(
service.recordOrders([
order({ payments: [{ paymentStatus: 'PAYMENT_PREPARATION' }] }),
]),
).resolves.toBe(0);
expect(query.execute).not.toHaveBeenCalled();
});
it('같은 주문이 두 번 와도 한 줄로 모은다 (ON CONFLICT 이중 갱신 방지)', async () => {
const { service, inserted } = setup();
await expect(service.recordOrders([order({}), order({})])).resolves.toBe(1);
expect(inserted[0]).toHaveLength(1);
});
it('이미 있는 주문·상품은 환불 여부와 주문자 정보만 갱신한다', async () => {
const { service, query } = setup();
await service.recordOrders([order({})]);
expect(query.orUpdate).toHaveBeenCalledWith(
expect.arrayContaining(['is_valid', 'orderer_name']),
['order_no', 'prod_no'],
{ skipUpdateIfNoValuesChanged: true },
);
});
it('주문 시각을 읽을 수 없으면 건너뛴다', async () => {
const { service } = setup();
await expect(
service.recordOrders([order({ wtime: 'not-a-date' })]),
).resolves.toBe(0);
});
});
@@ -0,0 +1,86 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { Repository } from 'typeorm';
import { ImwebOrderData } from '@/modules/imweb/imweb.types';
import { purchaseLines } from '@/shared/utils/order-purchase';
import { Purchase } from './purchase.entity';
/** 컬럼 길이 — 아임웹이 더 긴 값을 보내도 저장이 실패하지 않게 자른다 */
const PRODUCT_NAME_MAX = 200;
const NAME_MAX = 100;
const EMAIL_MAX = 255;
const CALL_MAX = 40;
function clip(value: string | null | undefined, max: number): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed.slice(0, max) : null;
}
/** 다시 볼 때마다 갱신하는 값 — 환불·취소 여부와 주문자 정보 */
const OVERWRITE_COLUMNS = [
'is_valid',
'product_name',
'member_code',
'member_uid',
'orderer_name',
'orderer_email',
'orderer_call',
];
const CONFLICT_COLUMNS = ['order_no', 'prod_no'];
/**
* 유료 구매 기록 — 주문 동기화(PartnerOrderSyncService)가 아임웹 주문 한 페이지씩 넘겨준다.
* 같은 주문·상품은 한 줄만 남고, 다시 보면 환불·취소 여부를 갱신한다 (여러 번 불러도 안전).
*/
@Injectable()
export class PurchasesService {
constructor(
@InjectRepository(Purchase)
private readonly purchases: Repository<Purchase>,
) {}
/** @returns 남기거나 갱신한 대상 줄 수 (결제된 주문의 상품 수) */
async recordOrders(orders: readonly ImwebOrderData[]): Promise<number> {
// 같은 주문·상품이 한 문장에 두 번 들어가면 ON CONFLICT 가 실패하므로 하나로 모은다
const rows = new Map<string, Purchase>();
for (const order of orders) {
const orderedAt = new Date(order.wtime);
if (Number.isNaN(orderedAt.getTime())) continue;
const orderNo = String(order.orderNo);
const isMember = order.isMember === 'Y' && !!order.memberCode;
for (const line of purchaseLines(order)) {
rows.set(
`${orderNo}:${line.prodNo}`,
this.purchases.create({
id: randomUUID(),
orderNo,
prodNo: line.prodNo,
productName: clip(line.productName, PRODUCT_NAME_MAX),
memberCode: isMember ? (order.memberCode ?? null) : null,
memberUid: isMember ? clip(order.memberUid, EMAIL_MAX) : null,
ordererName: clip(order.ordererName, NAME_MAX),
ordererEmail: clip(order.ordererEmail, EMAIL_MAX),
ordererCall: clip(order.ordererCall, CALL_MAX),
orderedAt,
isValid: line.isValid,
}),
);
}
}
if (rows.size === 0) return 0;
await this.purchases
.createQueryBuilder()
.insert()
.values([...rows.values()])
// 바뀐 게 없으면 다시 쓰지 않는다
.orUpdate(OVERWRITE_COLUMNS, CONFLICT_COLUMNS, {
skipUpdateIfNoValuesChanged: true,
})
.execute();
return rows.size;
}
}
@@ -0,0 +1,80 @@
import { OrderForPurchase, purchaseLines } from './order-purchase';
function order(
paymentStatus: string,
sections: NonNullable<OrderForPurchase['sections']>,
): OrderForPurchase {
return { payments: [{ paymentStatus, isCancel: 'N' }], sections };
}
const item = (prodNo: number, prodName?: string, isPromotion = 'N') => ({
isPromotion,
productInfo: { prodNo, prodName },
});
describe('purchaseLines', () => {
it('결제완료 주문의 상품을 상품명과 함께 구매로 남긴다', () => {
const lines = purchaseLines(
order('PAYMENT_COMPLETE', [
{
orderSectionStatus: 'PURCHASE_CONFIRMATION',
sectionItems: [item(52, ' AI 강의 ')],
},
]),
);
expect(lines).toEqual([
{ prodNo: 52, productName: 'AI 강의', isValid: true },
]);
});
it('결제 전·실패한 주문은 구매가 아니다', () => {
for (const status of [
'PAYMENT_PREPARATION',
'PAYMENT_OVERDUE',
'PAYMENT_FAILED',
'CANCELLED_BEFORE_DEPOSIT',
]) {
expect(
purchaseLines(order(status, [{ sectionItems: [item(52)] }])),
).toEqual([]);
}
});
it('전액 환불된 주문도 남기되 구매로 세지 않는다 (환불·취소 표시)', () => {
expect(
purchaseLines(order('REFUND_COMPLETE', [{ sectionItems: [item(52)] }])),
).toEqual([{ prodNo: 52, productName: null, isValid: false }]);
});
it('취소된 섹션의 상품은 세지 않고, 남은 상품은 그대로 센다', () => {
const lines = purchaseLines(
order('PARTIAL_REFUND_COMPLETE', [
{ orderSectionStatus: 'CANCEL_COMPLETE', sectionItems: [item(52)] },
{ orderSectionStatus: 'DELIVERY_COMPLETE', sectionItems: [item(53)] },
]),
);
expect(lines).toEqual([
{ prodNo: 52, productName: null, isValid: false },
{ prodNo: 53, productName: null, isValid: true },
]);
});
it('사은품 품목은 구매 내역에 넣지 않는다', () => {
expect(
purchaseLines(
order('PAYMENT_COMPLETE', [
{ sectionItems: [item(52), item(99, '사은품', 'Y')] },
]),
).map((line) => line.prodNo),
).toEqual([52]);
});
it('취소 결제 행만 있는 주문은 결제된 것으로 보지 않는다', () => {
expect(
purchaseLines({
payments: [{ paymentStatus: 'REFUND_COMPLETE', isCancel: 'Y' }],
sections: [{ sectionItems: [item(52)] }],
}),
).toEqual([]);
});
});
@@ -0,0 +1,66 @@
/**
* 구매 내역 판정 (순수 함수) — 아임웹 주문 한 건에서 구매 내역으로 남길 상품을 고른다.
*
* - 한 번이라도 결제된 주문의 상품만 남긴다. 결제 대기·실패·입금 전 취소 주문은 구매가 아니다.
* - 나중에 환불·취소된 상품도 지우지 않고 isValid=false 로 남긴다 (화면에 '환불·취소'로 보인다).
* - '지금 구매로 세는가'는 업체 실적과 같은 규칙(evaluateOrder)을 쓴다 — 두 화면의 구매 수가 어긋나지 않게.
*/
import { evaluateOrder, OrderForAttribution } from './partner-attribution';
/** 결제가 한 번은 끝났던 결제 상태 — 지금은 환불됐어도 구매 기록은 남긴다 */
const EVER_PAID_PAYMENT_STATUSES = new Set([
'PAYMENT_COMPLETE',
'PARTIAL_REFUND_COMPLETE',
'REFUND_PROCESSING',
'REFUND_COMPLETE',
'REFUND_FAILED',
]);
/** 판정에 필요한 주문 필드 — 업체 실적 판정 필드 + 상품명 */
export interface OrderForPurchase extends OrderForAttribution {
sections?: {
orderSectionStatus?: string | null;
sectionItems?: {
isPromotion?: string | null;
productInfo?: {
prodNo?: number | null;
prodName?: string | null;
} | null;
}[];
}[];
}
export interface PurchaseLine {
prodNo: number;
/** 주문 당시 상품명 (없으면 null) */
productName: string | null;
/** 지금 구매로 세는가 — 환불·취소·반품되면 false */
isValid: boolean;
}
/** 주문 한 건 → 상품별 구매 내역 (상품당 한 줄, 사은품 제외) */
export function purchaseLines(order: OrderForPurchase): PurchaseLine[] {
const wasPaid = (order.payments ?? []).some(
(payment) =>
payment.isCancel !== 'Y' &&
EVER_PAID_PAYMENT_STATUSES.has(payment.paymentStatus ?? ''),
);
if (!wasPaid) return [];
const names = new Map<number, string>();
for (const section of order.sections ?? []) {
for (const item of section.sectionItems ?? []) {
const prodNo = item.productInfo?.prodNo;
const name = item.productInfo?.prodName?.trim();
if (typeof prodNo === 'number' && name && !names.has(prodNo)) {
names.set(prodNo, name);
}
}
}
return [...evaluateOrder(order).products].map(([prodNo, isValid]) => ({
prodNo,
productName: names.get(prodNo) ?? null,
isValid,
}));
}
+196
View File
@@ -0,0 +1,196 @@
# 유료 구매 추적 — 현황과 다음 작업
> 작성: 2026-10-06 · 다음 작업을 이어갈 때 먼저 읽는 문서.
> 운영 사용 안내는 [`imweb/README.md`](../imweb/README.md) 의 "신청·구매 내역" · "유료 구매 기록" · "업체 링크" 절.
## 0. 한눈에
| 항목 | 상태 |
| --- | --- |
| 유료 구매 목록 (회원·비회원) | **구현 완료 · 운영 미배포** |
| 신청·구매 통합 화면 | **구현 완료 · 운영 미배포** |
| 업체 링크 구매 실적 (회원) | 운영 중 (이전 배포분) |
| 구매 때 유입경로 | **미구현** — 다음 작업 (4절) |
| 비회원 구매의 업체 실적 | **미구현** — 다음 작업 (4절) |
브랜치: `feat/purchase-history` (커밋 `ab7ef37`, 푸시·머지 전).
`fix/admin-ui-tweaks` 에서 따왔으므로 그 브랜치의 미푸시 커밋 3개(카톡 버튼 문구·링크 칸 줄 맞춤·직전 유입 표시 제거)를 함께 담고 있다.
---
## 1. 지금 구매에 대해 알 수 있는 것
아임웹 주문 API(`GET /orders`)에는 **손님이 어디서 왔는지(UTM)가 없다.**
무료 신청은 신청 완료 화면이 그 순간의 유입을 보내지만, 구매에는 그런 화면 연동이 아직 없다.
| 항목 | 알 수 있나 | 근거 · 조건 |
| --- | --- | --- |
| 누가 샀는지 | O | 회원: 회원 정보 / 비회원: 주문서의 주문자 이름·이메일·연락처 |
| 무엇을·언제 | O | 주문·상품당 한 줄, 주문 시각(wtime) |
| 환불·취소 여부 | O | 최근 60일 주문만 다시 봐서 반영 |
| 업체 링크로 왔는지 | 일부 | **회원만.** 링크를 누른 브라우저에서 로그인 → 30일 안에 같은 상품 구매 |
| 가입 경로 | 회원만 | 가입 때 경로라 이번 구매 경로와 다를 수 있다 |
| 구매 때 유입경로 | **X** | 주문 데이터에 없다 |
| 비회원 구매의 업체 | **X** | 회원 코드가 없어 클릭과 이을 수 없다 |
---
## 2. 구현된 것 (`feat/purchase-history`)
### 2.1 데이터 흐름
```
아임웹 GET /orders ── 1시간마다 (IMWEB_SYNC_INTERVAL_MINUTES, 기본 60)
│ PartnerOrderSyncService (modules/partners/partner-order-sync.service.ts)
│ · 서버 기동 후 첫 실행: 최근 365일 / 성공 후: 최근 60일
│ · 회원 여부로 거르지 않는다 (isMember 파라미터 없음)
├─▶ PurchasesService.recordOrders() → purchases (모든 결제 주문)
└─▶ PartnerAttributionService.attributeOrder() → partner_conversions
(업체 링크 상품 + 회원 주문만)
관리자 화면 ── GET /api/admin/activities* (ActivitiesService)
member_applications ∪ purchases (UNION ALL) + partner_conversions 로 업체 표시
```
- 수동 실행: 관리자 화면 **업체 링크 → 구매 집계 새로고침** (`POST /api/admin/partner-order-sync-runs`)
- "1년치 완료" 여부는 **메모리 플래그**(`isBackfilled`)다. 서버를 재시작할 때마다 첫 실행은 다시 1년치를 본다
(재시작 동안 놓친 환불도 메운다). 1년치 중간에 실패하면 다음 실행에서 다시 1년치를 본다.
### 2.2 `purchases` 테이블 (마이그레이션 `1791220773446-AddPurchases`)
| 컬럼 | 설명 |
| --- | --- |
| `id` uuid PK | 앱에서 생성 |
| `order_no` varchar(30) + `prod_no` int | **유일** (`uq_purchases_order_product`) — 주문·상품당 한 줄 |
| `product_name` | 주문 당시 상품명 (`productInfo.prodName`) |
| `member_code` / `member_uid` | 비회원 주문이면 null |
| `orderer_name` / `orderer_email` / `orderer_call` | 주문서 주문자 (회원 주문도 저장) |
| `ordered_at` | 아임웹 wtime |
| `is_valid` | 지금 구매로 세는가 — 환불·취소·반품이면 false |
| `created_at` | 처음 잡힌 시각 |
- 저장은 `INSERT ... ON CONFLICT (order_no, prod_no) DO UPDATE` (`skipUpdateIfNoValuesChanged`) — 여러 번 돌려도 안전.
- 금액은 저장하지 않는다 (요청사항).
### 2.3 판정 규칙 — `shared/utils/order-purchase.ts`
- **남기는 주문**: 결제가 한 번이라도 끝난 주문
(`PAYMENT_COMPLETE` · `PARTIAL_REFUND_COMPLETE` · `REFUND_PROCESSING` · `REFUND_COMPLETE` · `REFUND_FAILED`, 취소 결제 행 제외).
결제 대기·실패·입금 전 취소는 남기지 않는다.
- **구매로 세는가(`is_valid`)**: 업체 실적과 같은 `evaluateOrder()` — 결제완료(부분 환불 포함)이고 그 상품 섹션이
`CANCEL_COMPLETE`/`RETURN_COMPLETE` 가 아닐 때. → 업체 실적 구매 수와 화면 구매 수가 어긋나지 않는다.
- 사은품 품목(`isPromotion=Y`)은 넣지 않는다.
- **0원 주문도 결제완료면 구매로 남는다** (미결정 — 5절).
### 2.4 관리자 API (`modules/activities/`)
| 경로 | 용도 | 주요 파라미터 |
| --- | --- | --- |
| `GET /api/admin/activities` | 신청·구매 목록 (커서) | `type`(application·purchase), `from`/`to`, `productIdx`, `partnerId`(uuid·none), `q`, `inflowBasis`(apply·join)+`inflowField`+`inflowValue` |
| `GET /api/admin/activities/groups` | 상품별·업체별 신청·구매 수 | `by`(product·partner), `from`/`to` |
| `GET /api/admin/activities/inflow-stats` | 유입 집계 | `basis`(apply·join), `groupBy`, 목록 필터와 동일 |
- `basis=apply`(신청 때 유입)는 **무료 신청만** 센다. 구매는 `join`(가입 경로)으로만 집계된다 — 비회원은 값 없음.
- 구매 수는 `is_valid` 만, 환불·취소는 `cancelledCount` 로 따로.
- **예전 `/api/admin/applications` (목록·groups·inflow-stats·products) 는 삭제했다.** 위젯과 백엔드를 함께 배포해야 한다.
- 무료 신청 수집(`POST /api/applications`, 카톡 클릭)은 그대로다. `inflow_last_*` 는 저장만 하고 화면에서 쓰지 않는다.
### 2.5 관리자 화면 (`imweb/imweb-members.html`)
- 메뉴 "신청 내역" → **"신청·구매"**. 종류 [전체·무료 신청·유료 구매], 상품별/업체별 표에 무료 신청·카톡 버튼 클릭·유료 구매(환불 N) 칸.
- 목록: 구분 배지(무료 신청 / 유료 구매 / 환불·취소), 비회원은 주문자 이름 + "비회원". 상세 패널에 주문번호·상태·주문자.
- 유료 구매만 볼 때 유입 집계는 가입 경로로 고정(신청 때 유입 버튼 비활성).
- 개요: "최근 7일 무료 신청" · "최근 7일 유료 구매" 카드, "최근 신청·구매" 목록.
### 2.6 파일
```
backend/src/shared/utils/order-purchase.ts (+spec) 판정 규칙 (순수 함수)
backend/src/modules/purchases/ 엔티티 · recordOrders (+spec)
backend/src/modules/activities/ 관리자 통합 조회 (UNION ALL)
backend/src/modules/partners/partner-order-sync.service.ts (+spec) 주문 동기화 (1년→60일)
backend/src/modules/imweb/imweb.types.ts ordererName/Email/Call 추가
backend/src/modules/applications/ 관리자 조회 제거 (수집만 남음)
backend/src/migrations/1791220773446-AddPurchases.ts
imweb/imweb-members.html · imweb/README.md
```
### 2.7 검증한 것
- 백엔드 테스트 189개 통과, lint·build 통과.
- 로컬 DB 에 회원 신청·회원 구매·비회원 구매·환불 건을 넣고 API(필터·커서·묶음·유입 집계·잘못된 커서 VAL_001)와
실제 화면(종류 전환·업체별·상세 패널)을 확인. 넣은 데이터는 지웠다.
- upsert 를 실제 Postgres 에서 확인 (결제 전 무시 → 결제 저장 → 재저장 1행 유지 → 환불 반영).
- **실제 아임웹 주문으로는 아직 돌려보지 않았다.**
---
## 3. 배포 순서 (아직 안 함)
1. `feat/purchase-history` 머지 → 운영 서버에서 `docker compose --env-file .env.production up -d --build`
(마이그레이션 자동 실행, 에러 코드 변경 없음)
2. **바로 이어서** `imweb/imweb-members.html` 을 아임웹 관리자 페이지 코드에 다시 붙여 넣기
(예전 조회 경로를 지웠으므로 한쪽만 바뀌면 RES_001 "찾을 수 없음")
3. 업체 링크 메뉴의 **구매 집계 새로고침** → 1년치 주문 동기화 → 신청·구매 화면에 구매가 보이는지 확인
4. 비회원 구매 한 건을 열어 **주문자 이름·연락처가 실제로 채워지는지** 확인 (5절 첫 항목)
---
## 4. 다음 작업 — 주문완료 화면에서 구매 때 유입·업체 잡기
### 4.1 목표
- 구매 때 유입(UTM)을 남겨 신청 때 유입처럼 집계한다.
- **비회원 구매도 업체 실적으로 잡는다** — 회원 대신 주문번호로 잇는다.
### 4.2 방식 (추천안)
무료 신청 완료 위젯과 같은 방식이다.
```
주문완료 화면 ── 스크립트가 { 주문번호, MG_UTM.peek().value, MG_PARTNER.refs() } 를 보낸다
│ POST /api/order-inflows (공개 · 엄격한 rate limit · 이름은 api-design 규칙에 맞춰 확정)
▼
order_inflows (가칭) order_no 유일, inflow raw/파싱값, partner refs, received_at
│ 주문 동기화가 같은 order_no 의 구매를 가져올 때 매칭
▼
purchases 에 구매 때 유입 컬럼(가칭 inflow_source 등) 채움
partner_conversions: 회원·비회원 모두 refs 로 귀속 (지금은 회원 touch 로만)
```
- 스크립트 위치: Header Code(`imweb-header-top.html`)가 주문완료 페이지를 감지하거나, 주문완료 페이지 전용 코드 위젯.
UTM·업체 클릭 ID 는 이미 Header Code 가 보관하므로 읽기만 하면 된다.
- 위조 대응: 공개 API 라 막을 수는 없다. **아임웹에 실제로 있는 주문번호에만** 붙인다(동기화 시점 매칭이라 자연히 걸러짐).
같은 주문번호에 두 번 오면 첫 값만, 오래 매칭되지 않은 행은 정리한다.
- 무통장입금: 주문완료 시점에 받아두고, 입금 확인 후 구매로 잡힐 때 붙는다.
- 귀속 규칙 변경 검토: 주문완료에서 온 refs 를 회원 touch 보다 우선할지, 30일 기준을 그대로 쓸지.
- 화면: `inflow-stats` 의 `basis=apply` 를 "신청·구매 때 유입"으로 넓히고, 목록의 "신청 때 유입" 칸에 구매도 표시.
### 4.3 시작 전에 확인할 것
- [ ] **주문완료 페이지 주소와 주문번호 위치** — 주소창 파라미터인지 화면 DOM 인지. 테스트 주문 한 번으로 확인.
(아임웹 API 의 `orderNo` 와 같은 값인지도 확인)
- [ ] UTM 보관 기간 — 지금 `TTL_DAYS: 2` (`imweb-header-top.html` 49행). 광고 후 며칠 뒤 사는 경우가 많으면 늘릴지 결정.
늘리면 무료 신청의 신청 때 유입에도 같이 영향
- [ ] 비회원 구매 비중 — 많을수록 이 작업의 효과가 크다 (2절 배포 후 화면에서 확인 가능)
### 4.4 작업 체크리스트
- [ ] 엔티티·마이그레이션 (`order_inflows`, `purchases` 유입 컬럼) — 생성된 SQL 검토
- [ ] 공개 엔드포인트 DTO 검증 + `@Throttle` (신청 기록처럼)
- [ ] 동기화 매칭 + 비회원 업체 귀속 + 단위 테스트
- [ ] `activities` 유입 집계·목록 확장
- [ ] 아임웹 스크립트 + `imweb/README.md`
- [ ] 필요하면 에러 코드 등록 → `npm run sync:error-codes`
---
## 5. 열린 결정 · 알려진 한계
- **주문자 필드 실제 응답 미확인** — `ordererName/ordererEmail/ordererCall` 은 아임웹 문서 필드명으로 받는다.
운영에서 비어 있으면 응답 구조를 다시 확인해야 한다.
- **0원 주문** — 결제완료면 유료 구매로 잡힌다. 무료 상품을 아임웹에서 0원 주문으로 받는 경우가 있으면
결제 수단 `FREE` 주문을 뺄지 결정 (업체 실적 판정 `evaluateOrder` 도 같이 바꿔야 수가 맞는다).
- 60일보다 오래된 주문의 환불은 반영되지 않는다 (서버 재시작 직후 1년치 실행 때만 다시 본다).
- 휴대폰에서 링크·광고를 보고 PC 에서 사면 유입·업체가 이어지지 않는다 (무료 신청도 같다).
- 구매자의 가입 경로는 "이번 구매 경로"가 아니다 — 화면 문구에서 혼동하지 않게 유지.
+30 -8
View File
@@ -72,23 +72,33 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
- 2026-10-05 `회원2026_10_05_1.xls` (2026-08-20 ~ 09-07 가입자 2,489명) 가져옴.
유입경로 항목이 생긴 2026-08-20 이전 가입자(약 12,500명)는 복구할 수 없다.
## 강의 신청 기록 (신청 완료 화면 → 신청 내역)
## 신청·구매 내역 (관리자 화면)
관리자 화면 **신청·구매** 메뉴는 무료 신청과 유료 구매를 한 화면에서 본다
(`GET /api/admin/activities` · `/groups` · `/inflow-stats`).
- 종류 [전체 · 무료 신청 · 유료 구매] 로 목록을 거르고, 위쪽 표에서 상품별·업체별 신청 수·구매 수를 함께 본다
- 구매 수는 결제완료만 센다 — 환불·취소된 구매는 목록에 '환불·취소'로 남고, 표에는 '환불 N'으로 따로 적는다
- 유입 집계: '신청 때 유입'은 무료 신청에만 있다 (구매 시점 유입은 아임웹 주문에 남지 않는다).
구매는 '가입 경로'(회원이 가입할 때 기록된 경로)로만 볼 수 있고, 비회원 구매는 값이 없다
## 강의 신청 기록 (신청 완료 화면 → 신청·구매 내역)
아임웹 `/apply` 페이지의 신청 완료 위젯(`imweb-free-apply.html`)이 표시될 때
`POST /api/applications` 로 신청을 남긴다. 관리자 화면 **신청 내역** 메뉴에서 본다.
`POST /api/applications` 로 신청을 남긴다. 관리자 화면 **신청·구매** 메뉴에서 본다.
- 보내는 값: `MEMBER_UID`(회원 아이디 또는 회원 코드), 상품번호(`idx`), 상품명,
최초 유입(`MG_UTM.peek().value`), 최종 유입(`MG_UTM.peek().last` 를 같은 파이프 형식으로),
업체 링크 클릭 ID(`MG_PARTNER.refs()`) — `peek()` 는 문자열이 아니라 객체를 돌려준다
- **가입 경로와 신청 유입은 따로 남는다** — 관리자 화면 신청 내역의 '신청 유입 집계'에서 기준을 바꿔 비교한다
(최종 유입은 저장만 하고 화면에는 보여주지 않는다)
- **가입 경로와 신청 유입은 따로 남는다** — 관리자 화면 '유입' 집계에서 기준을 바꿔 비교한다
| 기준 | 값 | 예) 3주 전 네이버로 가입 → 어제 블로그로 방문 → 오늘 페이스북 광고로 와서 신청 |
| 기준 | 값 | 예) 3주 전 네이버로 가입 → 오늘 페이스북 광고로 와서 신청 |
| --- | --- | --- |
| 신청 때 (최종) | 신청 직전에 마지막으로 들어온 경로 — 신청을 일으킨 유입 | facebook |
| 신청 때 (최초) | 신청 시점 브라우저에 남아 있던 첫 경로 (UTM 스크립트 보관 2일) | blog |
| 신청 때 유입 | 신청 시점 브라우저에 남아 있던 첫 경로 (UTM 스크립트 보관 2일) | facebook |
| 가입 경로 | 그 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외) | naver |
유입이 한 번뿐이면 최초 = 최종이다. 다른 기기에서 신청하면 신청 유입은 direct 로 남는다.
다른 기기에서 신청하면 신청 때 유입은 direct 로 남는다.
- 백엔드는 아임웹에 실제로 있는 회원인지 확인하고(동기화 전 신규 가입자는 아임웹 API 로 직접 확인),
회원·상품당 한 건만 남긴다. 이름·연락처는 브라우저 값이 아니라 아임웹 회원 정보를 쓴다
- 오픈카톡 버튼 클릭은 `POST /api/applications/{id}/kakao-clicks` 로 처음 한 번만 남는다
@@ -97,6 +107,18 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
- 한계: 신청 완료 화면은 공개 페이지라 "실제로 그 회원이 보냈는지"는 확인할 수 없다
(있는 회원인지만 확인). 무료 신청 기록 용도라 이 수준으로 둔다
## 유료 구매 기록 (아임웹 주문 → 신청·구매 내역)
아임웹은 주문 완료를 알려주지 않으므로(웹훅 없음) 서버가 1시간마다 아임웹 주문(`GET /orders`)을 훑어
결제된 주문을 `purchases` 에 주문·상품당 한 줄로 남긴다. 화면의 '구매 집계 새로고침'(업체 링크 메뉴)으로 바로 돌릴 수도 있다.
- 회원·비회원 주문을 모두 남긴다. 비회원은 주문서의 주문자 이름·이메일·연락처로 보여준다
- 결제 전·결제 실패·입금 전 취소 주문은 남기지 않는다. 나중에 환불·취소되면 지우지 않고 '환불·취소'로 바꾼다
- 서버가 뜬 뒤 첫 실행은 **최근 1년**, 그 뒤로는 **최근 60일**만 다시 본다 (60일보다 오래된 주문의 환불은 반영되지 않는다)
- 사은품 품목은 넣지 않는다. 0원 주문도 결제완료면 구매로 남는다
- 금액은 관리하지 않는다
- 구매 때 유입경로는 아직 없다 — 현황·다음 작업은 [`docs/purchase-tracking.md`](../docs/purchase-tracking.md)
## 업체 링크 (업체별 신청·구매 추적)
상품마다 업체에 단축 링크를 발급하고, 그 링크로 들어온 회원의 신청·구매를 업체별로 센다.
@@ -116,7 +138,7 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
│ ② 로그인 회원이 확인되면 "이 회원이 이 클릭으로 들어왔다"를 POST /api/partner-touches 로 알린다
▼
신청: 신청 완료 위젯이 신청 기록에 클릭 ID 를 함께 보낸다 → 바로 귀속
구매: 서버가 1시간마다 최근 60일 아임웹 주문(회원 주문)을 훑어 결제완료 상품을 귀속, 환불·취소는 집계에서 뺀다
구매: 구매 기록과 같은 주문 동기화(1시간마다)가 회원 주문의 결제완료 상품을 귀속, 환불·취소는 집계에서 뺀다
```
- **귀속 규칙**: 신청·구매 직전 30일 안에 누른 **그 상품의** 업체 링크 중 마지막 것 (마지막 클릭)
+191 -108
View File
@@ -243,7 +243,11 @@
/* 업체 링크 칸 */
#mg-members .mgm-code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
#mg-members .mgm-url { display: inline-block; max-width: 300px; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; }
/* 업체에 줄 링크 칸 — 1줄: 링크 + 복사(가운데 정렬), 2줄: 이동 주소(길면 말줄임) */
#mg-members .mgm-linkcell { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
#mg-members .mgm-linkcell-top { display: flex; align-items: center; gap: 6px; }
#mg-members .mgm-linkcell-top .mgm-link { padding: 0 4px; line-height: 18px; }
#mg-members .mgm-url { display: block; max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--mgm-muted); }
#mg-members .mgm-linkbox { display: flex; gap: 6px; }
#mg-members .mgm-linkbox input { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; background: var(--mgm-bg); }
@@ -417,7 +421,7 @@
<span class="mgm-nav-label">운영</span>
<button type="button" data-view="applications">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="5" y="4" width="14" height="17" rx="2"/><path d="M9 4V3h6v1"/><path d="m9 13 2 2 4-4"/></svg>
신청 내역
신청·구매
</button>
<button type="button" data-view="partners">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
@@ -448,16 +452,16 @@
<!-- ========== 개요 ========== -->
<section data-panel="overview" class="mgm-stack" hidden>
<div class="mgm-kpis">
<button type="button" class="mgm-card mgm-kpi" data-goto="applications" data-app-preset="today">
<span class="mgm-kpi-label">오늘 신청</span>
<span class="mgm-kpi-value" data-kpi="app-today">-</span>
<span class="mgm-kpi-sub" data-kpi="app-today-sub">&nbsp;</span>
</button>
<button type="button" class="mgm-card mgm-kpi" data-goto="applications" data-app-preset="7">
<span class="mgm-kpi-label">최근 7일 신청</span>
<button type="button" class="mgm-card mgm-kpi" data-goto="applications" data-app-preset="7" data-app-kind="application">
<span class="mgm-kpi-label">최근 7일 무료 신청</span>
<span class="mgm-kpi-value" data-kpi="app-week">-</span>
<span class="mgm-kpi-sub" data-kpi="app-week-sub">&nbsp;</span>
</button>
<button type="button" class="mgm-card mgm-kpi" data-goto="applications" data-app-preset="7" data-app-kind="purchase">
<span class="mgm-kpi-label">최근 7일 유료 구매</span>
<span class="mgm-kpi-value" data-kpi="buy-week">-</span>
<span class="mgm-kpi-sub" data-kpi="buy-week-sub">&nbsp;</span>
</button>
<button type="button" class="mgm-card mgm-kpi" data-goto="partners" data-ptn-preset="7">
<span class="mgm-kpi-label">최근 7일 업체 링크 신청</span>
<span class="mgm-kpi-value" data-kpi="ptn-week">-</span>
@@ -473,9 +477,9 @@
<section class="mgm-card">
<div class="mgm-card-head">
<h3>신청 유입 상위</h3>
<span class="mgm-muted">최근 30일 · 신청 직전 유입 기준</span>
<span class="mgm-muted">최근 30일 · 신청 때 유입 기준</span>
<span class="mgm-spacer"></span>
<button type="button" class="mgm-link" data-goto="applications" data-app-preset="30">신청 내역 →</button>
<button type="button" class="mgm-link" data-goto="applications" data-app-preset="30" data-app-kind="application">신청·구매 →</button>
</div>
<div class="mgm-bars" data-role="ov-app-bars"><div class="mgm-muted">불러오는 중…</div></div>
</section>
@@ -497,13 +501,13 @@
<div class="mgm-grid-2 is-wide-left">
<section class="mgm-card">
<div class="mgm-card-head">
<h3>최근 신청</h3>
<h3>최근 신청·구매</h3>
<span class="mgm-spacer"></span>
<button type="button" class="mgm-link" data-goto="applications" data-app-preset="all">전체 보기 →</button>
</div>
<div class="mgm-table-wrap">
<table>
<thead><tr><th>신청일시</th><th>신청자</th><th>상품</th><th>업체 · 유입</th></tr></thead>
<thead><tr><th>일시</th><th>이름</th><th>상품</th><th>업체 · 유입</th></tr></thead>
<tbody data-role="ov-recent-rows"></tbody>
</table>
</div>
@@ -520,7 +524,7 @@
</div>
</section>
<!-- ========== 신청 내역 ========== -->
<!-- ========== 신청·구매 내역 ========== -->
<section data-panel="applications" class="mgm-stack" hidden>
<section class="mgm-card">
<form data-role="app-filter" class="mgm-toolbar" autocomplete="off">
@@ -528,11 +532,16 @@
<button type="button" data-app-mode="product" class="is-active">상품별</button>
<button type="button" data-app-mode="partner">업체별</button>
</div>
<div class="mgm-seg" role="group" aria-label="종류">
<button type="button" data-app-type="all" class="is-active">전체</button>
<button type="button" data-app-type="application">무료 신청</button>
<button type="button" data-app-type="purchase">유료 구매</button>
</div>
<label class="mgm-search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input type="search" name="q" maxlength="100" placeholder="신청자 이름·아이디·이메일·연락처" aria-label="신청자 검색">
<input type="search" name="q" maxlength="100" placeholder="이름·아이디·이메일·연락처" aria-label="신청자·구매자 검색">
</label>
<div class="mgm-seg" role="group" aria-label="신청 기간">
<div class="mgm-seg" role="group" aria-label="기간">
<button type="button" data-app-range="all" class="is-active">전체</button>
<button type="button" data-app-range="today">오늘</button>
<button type="button" data-app-range="7">7일</button>
@@ -545,19 +554,19 @@
<div class="mgm-grid-2 is-wide-left">
<section class="mgm-card">
<div class="mgm-card-head">
<h3 data-role="app-groups-title">상품별 신청</h3>
<h3 data-role="app-groups-title">상품별 신청·구매</h3>
<span class="mgm-muted">행을 누르면 아래 목록과 오른쪽 집계를 그 항목으로 좁힙니다</span>
</div>
<div class="mgm-table-wrap">
<table>
<thead><tr><th data-role="app-groups-col">상품</th><th class="mgm-r">신청</th><th class="mgm-r">오픈카톡 입장</th></tr></thead>
<thead><tr><th data-role="app-groups-col">상품</th><th class="mgm-r">무료 신청</th><th class="mgm-r" title="신청 완료 화면의 오픈카톡 버튼을 누른 수 — 실제 입장 여부는 카카오가 알려주지 않아 알 수 없습니다">카톡 버튼 클릭</th><th class="mgm-r" title="결제완료된 주문 (환불·취소 제외). 환불·취소된 건은 옆에 따로 적습니다">유료 구매</th></tr></thead>
<tbody data-role="app-groups"></tbody>
</table>
</div>
</section>
<section class="mgm-card">
<div class="mgm-card-head">
<h3>신청 유입</h3>
<h3>유입</h3>
<span class="mgm-spacer"></span>
<div class="mgm-seg" role="group" aria-label="집계 항목">
<button type="button" data-app-group="source" class="is-active">유입원</button>
@@ -567,9 +576,8 @@
</div>
<div class="mgm-row" style="margin-bottom:12px">
<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>
<button type="button" data-app-basis="apply" class="is-active" title="신청 시점에 브라우저에 남아 있던 유입경로 (UTM 스크립트 2일 보관) — 무료 신청에만 있습니다">신청 때 유입</button>
<button type="button" data-app-basis="join" title="신청·구매한 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외, 비회원 구매는 값 없음)">가입 경로</button>
</div>
</div>
<div class="mgm-bars" data-role="app-stats-bars"><div class="mgm-muted">불러오는 중…</div></div>
@@ -579,7 +587,7 @@
<section class="mgm-card">
<div class="mgm-card-head">
<h3>신청자</h3>
<h3>신청·구매한 사람</h3>
<span data-role="app-chips" class="mgm-chiprow"></span>
<span class="mgm-spacer"></span>
<span class="mgm-muted" data-role="app-total"></span>
@@ -588,14 +596,14 @@
<table>
<thead>
<tr>
<th>신청일시</th><th>신청자</th><th>연락처</th><th>상품</th><th>업체</th><th>오픈카톡</th>
<th title="신청 직전 유입 — 첫 경로와 다르면 아래에 '최초'로 함께 표시">신청 때 유입</th><th>가입 경로</th>
<th>일시</th><th>구분</th><th>이름</th><th>연락처</th><th>상품</th><th>업체</th><th title="신청 완료 화면의 오픈카톡 버튼을 누른 수 — 실제 입장 여부는 카카오가 알려주지 않아 알 수 없습니다">카톡 버튼</th>
<th title="신청 시점에 브라우저에 남아 있던 유입경로 (UTM 스크립트 2일 보관) — 구매에는 이 기록이 없습니다">신청 때 유입</th><th>가입 경로</th>
</tr>
</thead>
<tbody data-role="app-rows"></tbody>
</table>
</div>
<div class="mgm-empty" data-role="app-empty" hidden><b>신청 기록이 없습니다</b>기간이나 선택한 항목을 바꿔 보세요.</div>
<div class="mgm-empty" data-role="app-empty" hidden><b>신청·구매 기록이 없습니다</b>기간이나 종류, 선택한 항목을 바꿔 보세요. 구매는 아임웹 주문을 1시간마다 가져옵니다.</div>
<div class="mgm-more"><button type="button" data-action="app-more" hidden>더 보기</button></div>
</section>
</section>
@@ -867,8 +875,8 @@
var VIEW_KEY = 'mg_admin_view';
var VIEWS = {
overview: { title: '개요', desc: '신청·업체 실적·가입 현황을 한눈에 봅니다' },
applications: { title: '신청 내역', desc: '강의 신청을 상품별·업체별로 보고, 신청자와 유입경로를 확인합니다' },
overview: { title: '개요', desc: '신청·구매·업체 실적·가입 현황을 한눈에 봅니다' },
applications: { title: '신청·구매 내역', desc: '무료 신청과 유료 구매를 상품별·업체별로 보고, 누가 신청·구매했는지 확인합니다' },
partners: { title: '업체 링크', desc: '업체별 링크를 발급하고 클릭·신청·구매 실적을 봅니다' },
members: { title: '회원', desc: '가입 기간·회원 그룹·유입경로로 회원을 찾습니다' },
integration: { title: '연동 관리', desc: '아임웹 연결과 회원 동기화 상태' }
@@ -892,11 +900,12 @@
groupCode: '', // 선택한 회원 그룹 (폼 reset 으로 지워지지 않게 상태로 둔다)
groups: null, groupsStale: true,
memberRows: {}, // 상세 패널용 — 회원 코드 → 회원
// 신청 — mode: product | partner, group: 고른 상품번호 / 업체 ID('none' = 업체 없음)
// 신청·구매 — mode: product | partner, type: all | application | purchase,
// group: 고른 상품번호 / 업체 ID('none' = 업체 없음)
app: {
range: 'all', mode: 'product', group: null, groupLabel: '',
range: 'all', mode: 'product', type: 'all', group: null, groupLabel: '',
cursor: null, loading: false, seq: 0, groupsSeq: 0,
basis: 'apply_last', statsGroup: 'source', statsSeq: 0, inflowFilter: null,
basis: 'apply', statsGroup: 'source', statsSeq: 0, inflowFilter: null,
rows: {}
},
// 업체 링크
@@ -1240,7 +1249,7 @@
if (!link || !root.contains(link)) return;
var target = link.getAttribute('data-goto');
if (target === 'applications' && link.hasAttribute('data-app-preset')) {
openApplications({ range: link.getAttribute('data-app-preset') });
openApplications({ range: link.getAttribute('data-app-preset'), type: link.getAttribute('data-app-kind') || 'all' });
return;
}
if (target === 'partners' && link.hasAttribute('data-ptn-preset')) {
@@ -1441,7 +1450,6 @@
['외부 리퍼러', esc(inf.referrer)],
['첫 랜딩', esc(inf.landingPath) + (inf.productId ? ' <span class="mgm-faint">#' + esc(inf.productId) + '</span>' : '')],
['최초 유입일', esc(inf.firstTouchDate)],
['최종 유입', esc(inf.lastTouch)],
['기록일', esc(inf.recordedDate)]
]) + '</section>';
}
@@ -1455,14 +1463,16 @@
function loadOverview() {
renderOverviewStatus();
api('GET', '/admin/applications/groups' + toQuery(periodQuery('today', 'appliedFrom', 'appliedTo'))).then(function (g) {
setKpi('app-today', num(g.total));
setKpi('app-today-sub', '오픈카톡 입장 ' + num(g.kakaoTotal) + '명');
}).catch(errorNotice);
api('GET', '/admin/applications/groups' + toQuery(periodQuery('7', 'appliedFrom', 'appliedTo'))).then(function (g) {
setKpi('app-week', num(g.total));
setKpi('app-week-sub', '오픈카톡 입장률 ' + pct(g.kakaoTotal, g.total));
// 오늘·최근 7일 신청·구매 수 — 두 요청이 모두 와야 카드 두 장을 함께 채운다
Promise.all([
api('GET', '/admin/activities/groups' + toQuery(periodQuery('today', 'from', 'to'))),
api('GET', '/admin/activities/groups' + toQuery(periodQuery('7', 'from', 'to')))
]).then(function (res) {
var today = res[0].totals, week = res[1].totals;
setKpi('app-week', num(week.applicationCount));
setKpi('app-week-sub', '오늘 ' + num(today.applicationCount) + '건 · 카톡 버튼 클릭률 ' + pct(week.kakaoCount, week.applicationCount));
setKpi('buy-week', num(week.purchaseCount));
setKpi('buy-week-sub', '오늘 ' + num(today.purchaseCount) + '건' + (week.cancelledCount ? ' · 환불·취소 ' + num(week.cancelledCount) + '건 별도' : ' · 환불·취소 제외'));
}).catch(errorNotice);
api('GET', '/admin/partners' + toQuery(Object.assign({ limit: LIST_LIMIT }, periodQuery('7', 'from', 'to')))).then(function (page) {
@@ -1476,7 +1486,7 @@
setKpi('join-week', num(c.atJoin + c.afterJoin + c.none));
}).catch(errorNotice);
api('GET', '/admin/applications/inflow-stats' + toQuery(Object.assign({ basis: 'apply_last', groupBy: 'source' }, periodQuery('30', 'appliedFrom', 'appliedTo')))).then(function (s) {
api('GET', '/admin/activities/inflow-stats' + toQuery(Object.assign({ basis: 'apply', groupBy: 'source' }, periodQuery('30', 'from', 'to')))).then(function (s) {
$('[data-role="ov-app-bars"]').innerHTML = barsHtml(s.items.slice(0, TOP_SIZE), true, null, '건');
}).catch(errorNotice);
@@ -1493,18 +1503,17 @@
}).join('') : '<tr><td colspan="4" class="mgm-empty">등록된 업체가 없습니다.</td></tr>';
}).catch(errorNotice);
api('GET', '/admin/applications' + toQuery({ limit: RECENT_SIZE })).then(function (page) {
api('GET', '/admin/activities' + toQuery({ limit: RECENT_SIZE })).then(function (page) {
$('[data-role="ov-recent-rows"]').innerHTML = page.items.length ? page.items.map(function (a) {
state.app.rows[a.id] = a;
var m = a.member;
var last = a.lastInflow && a.lastInflow.source ? a.lastInflow : a.inflow;
var inf = a.inflow;
return '<tr class="is-clickable" data-app-row="' + esc(a.id) + '">' +
'<td>' + whenCell(a.appliedAt) + '</td>' +
'<td>' + personCell(m ? m.name : a.memberUid, '') + '</td>' +
'<td class="mgm-wrap">' + dash(a.productName) + '</td>' +
'<td>' + (a.partner ? '<span class="mgm-badge is-accent">' + esc(a.partner.name) + '</span>' : (last && last.source ? esc(last.source) : '<span class="mgm-faint">-</span>')) + '</td>' +
'<td>' + whenCell(a.occurredAt) + '</td>' +
'<td>' + personCell(activityName(a), '') + '</td>' +
'<td class="mgm-wrap">' + typeBadge(a) + ' ' + dash(a.productName) + '</td>' +
'<td>' + (a.partner ? '<span class="mgm-badge is-accent">' + esc(a.partner.name) + '</span>' : (inf && inf.source ? esc(inf.source) : '<span class="mgm-faint">-</span>')) + '</td>' +
'</tr>';
}).join('') : '<tr><td colspan="4" class="mgm-empty">아직 신청이 없습니다.</td></tr>';
}).join('') : '<tr><td colspan="4" class="mgm-empty">아직 신청·구매가 없습니다.</td></tr>';
}).catch(errorNotice);
api('GET', '/admin/members/inflow-stats' + toQuery({ groupBy: 'source' })).then(function (s) {
@@ -1515,7 +1524,7 @@
$('[data-role="ov-app-bars"]').addEventListener('click', function (e) {
var bar = closest(e.target, '.mgm-bar');
if (!bar || bar.disabled) return;
openApplications({ range: '30', inflowFilter: { basis: 'apply_last', field: 'source', value: bar.getAttribute('data-key') } });
openApplications({ range: '30', type: 'application', inflowFilter: { basis: 'apply', field: 'source', value: bar.getAttribute('data-key') } });
});
$('[data-role="ov-join-bars"]').addEventListener('click', function (e) {
var bar = closest(e.target, '.mgm-bar');
@@ -1531,31 +1540,57 @@
if (row) openApplicationDrawer(row.getAttribute('data-app-row'));
});
/* ===================== 신청 내역 ===================== */
/* ===================== 신청·구매 내역 ===================== */
var appForm = $('[data-role="app-filter"]');
var APP_BASIS_LABEL = { apply_last: '신청 직전', apply_first: '신청 때 최초', join: '가입 경로' };
var APP_BASIS_LABEL = { apply: '신청 때 유입', join: '가입 경로' };
var APP_GROUP_LABEL = { source: '유입원', medium: '매체', campaign: '캠페인', content: '콘텐츠' };
var APP_TYPE_LABEL = { application: '무료 신청', purchase: '유료 구매' };
// 다른 화면에서 신청 내역 열기 — 기간·유입 필터를 미리 채운다
// 구분 칸 — 무료 신청 / 유료 구매 (+ 환불·취소)
function typeBadge(a) {
if (a.type === 'application') return '<span class="mgm-badge is-ok">무료 신청</span>';
return '<span class="mgm-badge is-accent">유료 구매</span>' +
(a.isCancelled ? ' <span class="mgm-badge is-danger" title="환불·취소·반품된 주문 — 구매 수에서 빠집니다">환불·취소</span>' : '');
}
// 누구인지 — 회원이면 회원 이름, 비회원 구매면 주문자 이름
function activityName(a) {
if (a.member) return a.member.name;
if (a.orderer && a.orderer.name) return a.orderer.name;
return a.memberUid || '';
}
function activityPersonCell(a) {
if (a.member) return personCell(a.member.name, a.member.uid);
if (!a.memberUid && a.orderer) return personCell(a.orderer.name, '비회원');
return personCell(a.memberUid, '회원 정보 없음');
}
function activityPhone(a) {
if (a.member && a.member.callnum) return a.member.callnum;
return a.orderer ? a.orderer.phone : '';
}
// 다른 화면에서 신청·구매 내역 열기 — 기간·종류·유입 필터를 미리 채운다
function openApplications(opts) {
var app = state.app;
app.range = opts.range || 'all';
app.type = opts.type || 'all';
app.group = null; app.groupLabel = '';
app.inflowFilter = opts.inflowFilter || null;
setSeg('[data-app-range]', 'data-app-range', app.range);
if (app.inflowFilter) app.basis = app.inflowFilter.basis;
go('applications');
}
function loadApplicationsView() {
setSeg('[data-app-mode]', 'data-app-mode', state.app.mode);
setSeg('[data-app-range]', 'data-app-range', state.app.range);
setSeg('[data-app-type]', 'data-app-type', state.app.type);
loadAppGroups();
loadApplications(true);
}
// 목록·집계 공통 — 기간 + 고른 상품/업체
// 목록·집계 공통 — 기간 + 종류 + 고른 상품/업체
function appScope() {
var app = state.app, q = periodQuery(app.range, 'appliedFrom', 'appliedTo');
var app = state.app, q = periodQuery(app.range, 'from', 'to');
if (app.type !== 'all') q.type = app.type;
if (app.group !== null) {
if (app.mode === 'product') q.productIdx = app.group;
else q.partnerId = app.group;
@@ -1571,45 +1606,65 @@
return q;
}
// 상품별·업체별 요약 표
// 상품별·업체별 요약 표 — 종류와 무관하게 신청·구매 수를 함께 보여준다
function loadAppGroups() {
var app = state.app;
var seq = ++app.groupsSeq;
var isPartner = app.mode === 'partner';
$('[data-role="app-groups-title"]').textContent = isPartner ? '업체별 신청' : '상품별 신청';
$('[data-role="app-groups-title"]').textContent = isPartner ? '업체별 신청·구매' : '상품별 신청·구매';
$('[data-role="app-groups-col"]').textContent = isPartner ? '업체' : '상품';
api('GET', '/admin/applications/groups' + toQuery(Object.assign({ by: app.mode }, periodQuery(app.range, 'appliedFrom', 'appliedTo')))).then(function (g) {
api('GET', '/admin/activities/groups' + toQuery(Object.assign({ by: app.mode }, periodQuery(app.range, 'from', 'to')))).then(function (g) {
if (seq !== app.groupsSeq) return;
var rows = [{ group: null, label: isPartner ? '전체 신청' : '전체 상품', count: g.total, kakao: g.kakaoTotal, isAll: true }]
var rows = [Object.assign({ group: null, label: isPartner ? '전체' : '전체 상품', isAll: true }, g.totals)]
.concat(g.items.map(function (i) {
return {
// 화면용 값이 API 값(label 등)을 덮어쓰도록 뒤에 둔다
return Object.assign({}, i, {
group: i.key === null ? 'none' : i.key,
label: i.key === null ? '업체 링크 없이 신청' : (i.label || (isPartner ? '(삭제된 업체)' : '상품 #' + i.key)),
label: i.key === null ? '업체 링크 없이' : (i.label || (isPartner ? '(삭제된 업체)' : '상품 #' + i.key)),
sub: !isPartner && i.key ? '#' + i.key : (i.key === null ? '직접·광고 등 업체 링크를 거치지 않음' : ''),
count: i.count, kakao: i.kakaoCount, isNone: i.key === null
};
isNone: i.key === null
});
}));
$('[data-role="app-groups"]').innerHTML = rows.map(function (r) {
var selected = r.group === app.group;
return '<tr class="is-clickable' + (selected ? ' is-selected' : '') + '" data-app-groupkey="' + esc(r.group === null ? '' : r.group) + '" data-label="' + esc(r.label) + '">' +
'<td class="mgm-wrap"><span class="mgm-cell-main"' + (r.isNone ? ' style="font-weight:500;color:var(--mgm-muted)"' : '') + '>' + esc(r.label) + '</span>' +
(r.sub ? '<span class="mgm-cell-sub">' + esc(r.sub) + '</span>' : '') + '</td>' +
'<td class="mgm-r"><b>' + num(r.count) + '</b></td>' +
'<td class="mgm-r">' + num(r.kakao) + '<span class="mgm-rate">' + pct(r.kakao, r.count) + '</span></td></tr>';
'<td class="mgm-r">' + countCell(r.applicationCount) + '</td>' +
'<td class="mgm-r">' + (r.applicationCount ? num(r.kakaoCount) + '<span class="mgm-rate">' + pct(r.kakaoCount, r.applicationCount) + '</span>' : '<span class="mgm-faint">-</span>') + '</td>' +
'<td class="mgm-r">' + countCell(r.purchaseCount) +
(r.cancelledCount ? '<span class="mgm-rate" title="환불·취소된 구매">환불 ' + num(r.cancelledCount) + '</span>' : '') + '</td></tr>';
}).join('');
}).catch(function (err) { if (seq === app.groupsSeq) errorNotice(err); });
}
function countCell(n) { return n ? '<b>' + num(n) + '</b>' : '<span class="mgm-faint">0</span>'; }
// 유입 집계 — 구매에는 '신청 때 유입'이 없으므로 유료 구매만 볼 때는 가입 경로로 고정한다
function syncBasisButtons() {
var app = state.app, onlyPurchase = app.type === 'purchase';
if (onlyPurchase && app.basis === 'apply') app.basis = 'join';
each($all('[data-app-basis]'), function (b) {
var isApply = b.getAttribute('data-app-basis') === 'apply';
b.disabled = onlyPurchase && isApply;
});
setSeg('[data-app-basis]', 'data-app-basis', app.basis);
}
function loadAppStats() {
var app = state.app;
var seq = ++app.statsSeq;
syncBasisButtons();
var q = Object.assign({ basis: app.basis, groupBy: app.statsGroup }, appScope());
api('GET', '/admin/applications/inflow-stats' + toQuery(q)).then(function (s) {
api('GET', '/admin/activities/inflow-stats' + toQuery(q)).then(function (s) {
if (seq !== app.statsSeq) return;
var inf = app.inflowFilter;
var selected = inf && inf.basis === app.basis && inf.field === app.statsGroup ? inf.value : null;
$('[data-role="app-stats-bars"]').innerHTML = barsHtml(s.items.slice(0, 10), true, selected, '건');
$('[data-role="app-stats-desc"]').textContent = '신청 ' + num(s.total) + '건 · ' + APP_BASIS_LABEL[s.basis] + ' 기준 · 막대를 누르면 목록을 거릅니다';
var counted = [];
if (app.type !== 'purchase') counted.push('무료 신청 ' + num(s.applicationTotal) + '건');
if (s.basis === 'join' && app.type !== 'application') counted.push('유료 구매 ' + num(s.purchaseTotal) + '건');
$('[data-role="app-stats-desc"]').textContent = counted.join(' · ') + ' · ' + APP_BASIS_LABEL[s.basis] + ' 기준' +
(s.basis === 'apply' && app.type === 'all' ? ' (구매는 가입 경로로만 볼 수 있습니다)' : '') + ' · 막대를 누르면 목록을 거릅니다';
}).catch(function (err) { if (seq === app.statsSeq) errorNotice(err); });
}
@@ -1627,32 +1682,21 @@
$('[data-role="app-chips"]').innerHTML = chips.join('');
}
// 신청 때 유입 칸 — 최종 유입을 보여주고, 첫 경로가 다르면 '최초'로 함께 적는다
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 += '<span class="mgm-cell-sub" title="신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)">최초: ' +
esc(first.source) + (first.medium ? ' / ' + esc(first.medium) : '') + '</span>';
}
return html;
}
function appRowHtml(a) {
var m = a.member;
var isApp = a.type === 'application';
state.app.rows[a.id] = a;
return '<tr class="is-clickable" data-app-row="' + esc(a.id) + '">' +
'<td>' + whenCell(a.appliedAt) + '</td>' +
'<td>' + (m ? personCell(m.name, m.uid) : personCell(a.memberUid, '회원 정보 없음')) + '</td>' +
'<td>' + dash(phone(m && m.callnum)) + '</td>' +
'<td>' + whenCell(a.occurredAt) + '</td>' +
'<td>' + typeBadge(a) + '</td>' +
'<td>' + activityPersonCell(a) + '</td>' +
'<td>' + dash(phone(activityPhone(a))) + '</td>' +
'<td class="mgm-wrap">' + dash(a.productName) + '<span class="mgm-cell-sub">#' + esc(a.productIdx) + '</span></td>' +
'<td>' + (a.partner ? '<span class="mgm-badge is-accent">' + esc(a.partner.name) + '</span>' : '<span class="mgm-faint">-</span>') + '</td>' +
'<td>' + (a.kakaoClickedAt
? '<span class="mgm-badge is-ok" title="' + esc(kstDateTime(a.kakaoClickedAt)) + '">입장</span>'
'<td>' + (isApp && a.kakaoClickedAt
? '<span class="mgm-badge is-ok" title="' + esc(kstDateTime(a.kakaoClickedAt)) + ' 에 버튼을 눌렀습니다 (실제 입장 여부는 알 수 없음)">클릭</span>'
: '<span class="mgm-faint">-</span>') + '</td>' +
'<td class="mgm-wrap">' + applyInflowCell(a) + '</td>' +
'<td class="mgm-wrap">' + joinInflowCell(m) + '</td>' +
'<td class="mgm-wrap">' + (isApp ? inflowText(a.inflow) : '<span class="mgm-faint">-</span>') + '</td>' +
'<td class="mgm-wrap">' + (!a.member && !a.memberUid ? '<span class="mgm-faint">비회원</span>' : joinInflowCell(a.member)) + '</td>' +
'</tr>';
}
@@ -1671,7 +1715,7 @@
var moreBtn = $('[data-action="app-more"]');
moreBtn.disabled = true;
api('GET', '/admin/applications' + toQuery(q)).then(function (page) {
api('GET', '/admin/activities' + toQuery(q)).then(function (page) {
if (seq !== app.seq) return;
var tbody = $('[data-role="app-rows"]');
var html = page.items.map(appRowHtml).join('');
@@ -1691,24 +1735,47 @@
});
}
function partnerText(a) {
return a.partner ? '<span class="mgm-badge is-accent">' + esc(a.partner.name) + '</span> 업체 링크 실적' : '업체 링크를 거치지 않음';
}
function openApplicationDrawer(id) {
var a = state.app.rows[id];
if (!a) return;
var first = a.inflow, last = a.lastInflow && a.lastInflow.source ? a.lastInflow : first;
var sameTouch = !first || !first.source || (first.source === last.source && first.medium === last.medium && first.campaign === last.campaign);
var html = '<section class="mgm-dsec"><h4>신청</h4>' + dlHtml([
['상품', dash(a.productName) + ' <span class="mgm-faint">#' + esc(a.productIdx) + '</span>'],
['신청일시', esc(kstDateTime(a.appliedAt))],
['업체', a.partner ? '<span class="mgm-badge is-accent">' + esc(a.partner.name) + '</span> 업체 링크 실적' : '업체 링크를 거치지 않음'],
['오픈카톡', a.kakaoClickedAt ? '입장 버튼 누름 · ' + esc(kstDateTime(a.kakaoClickedAt)) : '누르지 않음']
]) + '</section>' +
'<section class="mgm-dsec"><h4>신청 때 유입</h4>' + dlHtml([
['신청 직전', last && last.source ? esc(last.source) + (last.medium ? ' / ' + esc(last.medium) : '') : ''],
['캠페인', esc(last && last.campaign)],
['콘텐츠', esc(last && last.content)],
['최초 유입', sameTouch ? '<span class="mgm-faint">신청 직전과 같음</span>' : esc(first.source) + (first.medium ? ' / ' + esc(first.medium) : '') + (first.campaign ? ' · ' + esc(first.campaign) : '')]
]) + '</section>' + memberSectionsHtml(a.member);
openDrawer((a.member ? a.member.name : a.memberUid) + ' 님의 신청', html);
var html;
if (a.type === 'application') {
var inf = a.inflow || {};
html = '<section class="mgm-dsec"><h4>무료 신청</h4>' + dlHtml([
['상품', dash(a.productName) + ' <span class="mgm-faint">#' + esc(a.productIdx) + '</span>'],
['신청일시', esc(kstDateTime(a.occurredAt))],
['업체', partnerText(a)],
['카톡 버튼', a.kakaoClickedAt ? '눌렀음 · ' + esc(kstDateTime(a.kakaoClickedAt)) + ' <span class="mgm-faint">(실제 입장 여부는 알 수 없음)</span>' : '누르지 않음']
]) + '</section>' +
'<section class="mgm-dsec"><h4>신청 때 유입</h4>' + dlHtml([
['유입원', esc(inf.source)],
['매체', esc(inf.medium)],
['캠페인', esc(inf.campaign)],
['콘텐츠', esc(inf.content)]
]) + '</section>' + memberSectionsHtml(a.member);
} else {
var o = a.orderer || {};
var isGuest = !a.member && !a.memberUid;
html = '<section class="mgm-dsec"><h4>유료 구매' + (a.isCancelled ? ' <span class="mgm-badge is-danger">환불·취소</span>' : '') + '</h4>' + dlHtml([
['상품', dash(a.productName) + ' <span class="mgm-faint">#' + esc(a.productIdx) + '</span>'],
['주문일시', esc(kstDateTime(a.occurredAt))],
['주문번호', esc(a.orderNo)],
['상태', a.isCancelled ? '환불·취소됨 <span class="mgm-faint">(구매 수에서 빠집니다)</span>' : '결제완료'],
['업체', partnerText(a)]
]) + '</section>' +
'<section class="mgm-dsec"><h4>주문자</h4>' + dlHtml([
['이름', dash(o.name)],
['이메일', dash(o.email)],
['연락처', dash(phone(o.phone))],
['회원 여부', isGuest ? '비회원 주문' : '회원 주문']
]) + '</section>' +
(isGuest ? '' : memberSectionsHtml(a.member));
}
openDrawer((activityName(a) || '알 수 없음') + ' 님의 ' + APP_TYPE_LABEL[a.type], html);
}
// 보기 방식 (상품별 / 업체별)
@@ -1723,6 +1790,19 @@
loadApplications(true);
});
});
// 종류 (전체 / 무료 신청 / 유료 구매) — 요약 표는 그대로 두고 목록·유입 집계만 거른다
each($all('[data-app-type]'), function (b) {
b.addEventListener('click', function () {
var type = b.getAttribute('data-app-type');
if (type === state.app.type) return;
state.app.type = type;
// 구매에는 신청 때 유입이 없으므로 그 기준의 유입 필터는 풀어 준다
var inf = state.app.inflowFilter;
if (type === 'purchase' && inf && inf.basis === 'apply') state.app.inflowFilter = null;
setSeg('[data-app-type]', 'data-app-type', type);
loadApplications(true);
});
});
each($all('[data-app-range]'), function (b) {
b.addEventListener('click', function () {
state.app.range = b.getAttribute('data-app-range');
@@ -1746,8 +1826,8 @@
});
each($all('[data-app-basis]'), function (b) {
b.addEventListener('click', function () {
if (b.disabled) return;
state.app.basis = b.getAttribute('data-app-basis');
setSeg('[data-app-basis]', 'data-app-basis', state.app.basis);
loadAppStats();
});
});
@@ -1889,8 +1969,11 @@
return '<tr data-link-row="' + esc(l.id) + '"' + (l.id === state.ptn.conv.linkId ? ' class="is-selected"' : '') + '>' +
'<td class="mgm-wrap"><span class="mgm-cell-main">' + esc(l.partner.name) + '</span>' +
'<span class="mgm-cell-sub">' + (l.product.name ? esc(l.product.name) : '아임웹에서 지운 상품') + ' · #' + esc(l.product.prodNo) + '</span></td>' +
'<td><span class="mgm-code">' + esc(l.shortUrl) + '</span> <button type="button" class="mgm-link mgm-sm" data-ptn-copy="' + esc(l.shortUrl) + '">복사</button>' +
'<span class="mgm-cell-sub mgm-url" title="' + esc(l.targetUrl) + '">→ ' + esc(l.targetUrl) + '</span></td>' +
'<td><div class="mgm-linkcell">' +
'<div class="mgm-linkcell-top"><span class="mgm-code">' + esc(l.shortUrl) + '</span>' +
'<button type="button" class="mgm-link mgm-sm" data-ptn-copy="' + esc(l.shortUrl) + '">복사</button></div>' +
'<span class="mgm-url" title="이동할 상품 페이지: ' + esc(l.targetUrl) + '">→ ' + esc(l.targetUrl) + '</span>' +
'</div></td>' +
'<td class="mgm-r">' + num(l.stats.clickCount) + '</td>' +
'<td class="mgm-r"><b>' + num(l.stats.applicationCount) + '</b></td>' +
'<td class="mgm-r"><b>' + num(l.stats.purchaseCount) + '</b></td>' +