diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 542ce6f..0609380 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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, diff --git a/backend/src/migrations/1791220773446-AddPurchases.ts b/backend/src/migrations/1791220773446-AddPurchases.ts new file mode 100644 index 0000000..a288317 --- /dev/null +++ b/backend/src/migrations/1791220773446-AddPurchases.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPurchases1791220773446 implements MigrationInterface { + name = 'AddPurchases1791220773446'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } +} diff --git a/backend/src/modules/activities/activities.controller.ts b/backend/src/modules/activities/activities.controller.ts new file mode 100644 index 0000000..1a20e66 --- /dev/null +++ b/backend/src/modules/activities/activities.controller.ts @@ -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> { + return this.activities.findMany(query); + } + + @Get('groups') + @ApiOperation({ + summary: '상품별·업체별 신청·구매 수 — 요약 표 (기간만 거른다)', + }) + @ApiStandardResponse(ActivityGroupsResponseDto, { description: '조회 성공' }) + @ApiErrorResponse('AUTH_001', '로그인 필요') + @ApiErrorResponse('VAL_001', '기준·기간 형식 오류') + groups( + @Query() query: ActivityGroupsQueryDto, + ): Promise { + return this.activities.groups(query); + } + + @Get('inflow-stats') + @ApiOperation({ + summary: + '유입 집계 — 신청 때 유입(무료 신청만) 또는 가입 경로(신청·구매) 기준', + }) + @ApiStandardResponse(ActivityInflowStatsResponseDto, { + description: '집계 성공', + }) + @ApiErrorResponse('AUTH_001', '로그인 필요') + @ApiErrorResponse('VAL_001', '필터·기준 형식 오류') + inflowStats( + @Query() query: ActivityInflowStatsQueryDto, + ): Promise { + return this.activities.inflowStats(query); + } +} diff --git a/backend/src/modules/activities/activities.module.ts b/backend/src/modules/activities/activities.module.ts new file mode 100644 index 0000000..1a85327 --- /dev/null +++ b/backend/src/modules/activities/activities.module.ts @@ -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 {} diff --git a/backend/src/modules/activities/activities.service.ts b/backend/src/modules/activities/activities.service.ts new file mode 100644 index 0000000..3333d30 --- /dev/null +++ b/backend/src/modules/activities/activities.service.ts @@ -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( + qb: SelectQueryBuilder, + columns: Columns, +): SelectQueryBuilder { + 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, + @InjectRepository(Partner) + private readonly partners: Repository, + private readonly membersService: MembersService, + ) {} + + async findMany( + query: ActivityListQueryDto, + ): Promise> { + const parts: SelectQueryBuilder[] = []; + 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(); + 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 { + 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(); + + 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(); + + return { + by: query.by, + totals: toCounts(totals), + items: rows.map((row) => ({ + key: row.key, + label: row.label, + ...toCounts(row), + })), + }; + } + + /** + * 유입 집계. + * - 신청 때 유입: 무료 신청만 센다 (구매 시점의 유입은 아임웹 주문에 남지 않는다) + * - 가입 경로: 신청·구매(환불·취소 제외) 모두 회원의 가입 경로로 센다. 비회원 구매는 값 없음 + */ + async inflowStats( + query: ActivityInflowStatsQueryDto, + ): Promise { + const isJoin = query.basis === 'join'; + const parts: SelectQueryBuilder[] = []; + + 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 | null { + return filter.type === 'purchase' ? null : this.applicationQuery(filter); + } + + /** 유료 구매 쪽 — 종류 필터로 빠지거나, 신청 때 유입으로 거르면(구매엔 없는 기록) null */ + private purchasePart( + filter: ListFilter, + ): SelectQueryBuilder | 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 { + 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 { + 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, + 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[], + ): SelectQueryBuilder { + 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> { + 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> { + 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])); + } +} diff --git a/backend/src/modules/activities/dto/activity-query.dto.ts b/backend/src/modules/activities/dto/activity-query.dto.ts new file mode 100644 index 0000000..868aa49 --- /dev/null +++ b/backend/src/modules/activities/dto/activity-query.dto.ts @@ -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'; +} diff --git a/backend/src/modules/activities/dto/activity-response.dto.ts b/backend/src/modules/activities/dto/activity-response.dto.ts new file mode 100644 index 0000000..2feef7a --- /dev/null +++ b/backend/src/modules/activities/dto/activity-response.dto.ts @@ -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[]; +} diff --git a/backend/src/modules/applications/applications.controller.ts b/backend/src/modules/applications/applications.controller.ts index e6c0b98..58b88e8 100644 --- a/backend/src/modules/applications/applications.controller.ts +++ b/backend/src/modules/applications/applications.controller.ts @@ -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> { - return this.applications.findMany(query); - } - - @Get('products') - @ApiOperation({ summary: '상품별 신청 수 (최근 신청이 있는 상품부터)' }) - @ApiStandardResponse(ApplicationProductDto, { - description: '조회 성공', - isArray: true, - }) - @ApiErrorResponse('AUTH_001', '로그인 필요') - products(): Promise { - return this.applications.products(); - } - - @Get('groups') - @ApiOperation({ - summary: '상품별·업체별 신청 수 — 신청 내역의 요약 표 (기간만 거른다)', - }) - @ApiStandardResponse(ApplicationGroupsResponseDto, { - description: '조회 성공', - }) - @ApiErrorResponse('AUTH_001', '로그인 필요') - @ApiErrorResponse('VAL_001', '기준·기간 형식 오류') - groups( - @Query() query: ApplicationGroupsQueryDto, - ): Promise { - return this.applications.groups(query); - } - - @Get('inflow-stats') - @ApiOperation({ - summary: - '신청 유입 집계 — 신청 때 최종·최초 유입 또는 가입 경로 기준으로 신청 수를 센다', - }) - @ApiStandardResponse(ApplicationInflowStatsResponseDto, { - description: '집계 성공', - }) - @ApiErrorResponse('AUTH_001', '로그인 필요') - @ApiErrorResponse('VAL_001', '필터·기준 형식 오류') - inflowStats( - @Query() query: ApplicationInflowStatsQueryDto, - ): Promise { - return this.applications.inflowStats(query); - } -} diff --git a/backend/src/modules/applications/applications.module.ts b/backend/src/modules/applications/applications.module.ts index b4285a4..62556e2 100644 --- a/backend/src/modules/applications/applications.module.ts +++ b/backend/src/modules/applications/applications.module.ts @@ -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 {} diff --git a/backend/src/modules/applications/applications.service.spec.ts b/backend/src/modules/applications/applications.service.spec.ts index 8732c7c..cf84aa8 100644 --- a/backend/src/modules/applications/applications.service.spec.ts +++ b/backend/src/modules/applications/applications.service.spec.ts @@ -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, - {} as Repository, lookup as unknown as ImwebMemberLookupService, - {} as MembersService, { trackingHost: 'utm.moneyguider.co.kr' } as ImwebConfig, attribution as unknown as PartnerAttributionService, - {} as Repository, - {} as Repository, ); return { service, store, applications, lookup, attribution, calls }; } diff --git a/backend/src/modules/applications/applications.service.ts b/backend/src/modules/applications/applications.service.ts index 5c06af2..0ae89e5 100644 --- a/backend/src/modules/applications/applications.service.ts +++ b/backend/src/modules/applications/applications.service.ts @@ -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, - @InjectRepository(ImwebMember) - private readonly members: Repository, private readonly lookup: ImwebMemberLookupService, - private readonly membersService: MembersService, private readonly config: ImwebConfig, private readonly attribution: PartnerAttributionService, - @InjectRepository(PartnerConversion) - private readonly conversions: Repository, - @InjectRepository(Partner) - private readonly partners: Repository, ) {} /** 신청 기록 — 같은 회원·상품이면 기존 기록을 돌려준다 (몇 번을 불러도 한 건) */ @@ -215,332 +148,10 @@ export class ApplicationsService { ); } - async findMany( - query: ApplicationListQueryDto, - ): Promise> { - 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 { - const base = this.filtered(query); - const total = await base.clone().getCount(); - const rows = await base - .clone() - .select(inflowExpression(query.basis, query.groupBy), 'key') - .addSelect('COUNT(*)', 'count') - .groupBy('key') - .orderBy('count', 'DESC') - .addOrderBy('key', 'ASC', 'NULLS LAST') - .limit(STATS_MAX_ITEMS) - .getRawMany<{ key: string | null; count: string }>(); - - return { - basis: query.basis, - groupBy: query.groupBy, - total, - items: rows.map((row) => ({ key: row.key, count: Number(row.count) })), - }; - } - - /** 목록·집계 공통 필터 — 회원 검색·가입 경로를 위해 동기화된 회원과 잇는다 */ - private filtered( - filter: ApplicationFilterDto & - Partial< - Pick< - ApplicationListQueryDto, - 'source' | 'inflowBasis' | 'inflowField' | 'inflowValue' | 'q' - > - >, - ): SelectQueryBuilder { - const qb = this.applications - .createQueryBuilder(ALIAS) - // 탈퇴 회원은 회원 정보 없이 남는다 - .leftJoin( - ImwebMember, - MEMBER_ALIAS, - `${MEMBER_ALIAS}.member_code = ${ALIAS}.member_code`, - ); - - if (filter.productIdx) { - qb.andWhere(`${ALIAS}.productIdx = :productIdx`, { - productIdx: filter.productIdx, - }); - } - if (filter.appliedFrom) { - qb.andWhere(`${ALIAS}.appliedAt >= :appliedFrom`, { - appliedFrom: filter.appliedFrom, - }); - } - if (filter.appliedTo) { - qb.andWhere(`${ALIAS}.appliedAt < :appliedTo`, { - appliedTo: filter.appliedTo, - }); - } - if (filter.source) { - qb.andWhere(`${ALIAS}.inflowSource = :source`, { source: filter.source }); - } - // 업체 — 신청 실적은 신청당 하나라 잇는 행이 늘어나지 않는다 - if (filter.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 { - 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> { - 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(); - 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 { - 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 { return this.applications.findOne({ where: { memberCode, productIdx } }); } - - private async membersByCode( - codes: string[], - ): Promise> { - 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])); - } } diff --git a/backend/src/modules/applications/dto/application-query.dto.ts b/backend/src/modules/applications/dto/application-query.dto.ts deleted file mode 100644 index 5c282b5..0000000 --- a/backend/src/modules/applications/dto/application-query.dto.ts +++ /dev/null @@ -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'; -} diff --git a/backend/src/modules/applications/dto/application-response.dto.ts b/backend/src/modules/applications/dto/application-response.dto.ts deleted file mode 100644 index f4aa191..0000000 --- a/backend/src/modules/applications/dto/application-response.dto.ts +++ /dev/null @@ -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[]; -} diff --git a/backend/src/modules/imweb/imweb.types.ts b/backend/src/modules/imweb/imweb.types.ts index d7769b9..fd8f7ae 100644 --- a/backend/src/modules/imweb/imweb.types.ts +++ b/backend/src/modules/imweb/imweb.types.ts @@ -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?: { diff --git a/backend/src/modules/partners/partner-order-sync.service.spec.ts b/backend/src/modules/partners/partner-order-sync.service.spec.ts new file mode 100644 index 0000000..41d0c37 --- /dev/null +++ b/backend/src/modules/partners/partner-order-sync.service.spec.ts @@ -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 { + 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); + }); +}); diff --git a/backend/src/modules/partners/partner-order-sync.service.ts b/backend/src/modules/partners/partner-order-sync.service.ts index 76dd8bd..772e3a4 100644 --- a/backend/src/modules/partners/partner-order-sync.service.ts +++ b/backend/src/modules/partners/partner-order-sync.service.ts @@ -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 | 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( - 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( + 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), ); } diff --git a/backend/src/modules/partners/partners.module.ts b/backend/src/modules/partners/partners.module.ts index 13b8f4b..3e75f76 100644 --- a/backend/src/modules/partners/partners.module.ts +++ b/backend/src/modules/partners/partners.module.ts @@ -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, diff --git a/backend/src/modules/purchases/purchase.entity.ts b/backend/src/modules/purchases/purchase.entity.ts new file mode 100644 index 0000000..b152033 --- /dev/null +++ b/backend/src/modules/purchases/purchase.entity.ts @@ -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; +} diff --git a/backend/src/modules/purchases/purchases.module.ts b/backend/src/modules/purchases/purchases.module.ts new file mode 100644 index 0000000..96b8adb --- /dev/null +++ b/backend/src/modules/purchases/purchases.module.ts @@ -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 {} diff --git a/backend/src/modules/purchases/purchases.service.spec.ts b/backend/src/modules/purchases/purchases.service.spec.ts new file mode 100644 index 0000000..9a5edfb --- /dev/null +++ b/backend/src/modules/purchases/purchases.service.spec.ts @@ -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 { + 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; + values: jest.Mock; + orUpdate: jest.Mock; + execute: jest.Mock, []>; +} + +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); + 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); + }); +}); diff --git a/backend/src/modules/purchases/purchases.service.ts b/backend/src/modules/purchases/purchases.service.ts new file mode 100644 index 0000000..8e9b2b8 --- /dev/null +++ b/backend/src/modules/purchases/purchases.service.ts @@ -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, + ) {} + + /** @returns 남기거나 갱신한 대상 줄 수 (결제된 주문의 상품 수) */ + async recordOrders(orders: readonly ImwebOrderData[]): Promise { + // 같은 주문·상품이 한 문장에 두 번 들어가면 ON CONFLICT 가 실패하므로 하나로 모은다 + const rows = new Map(); + 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; + } +} diff --git a/backend/src/shared/utils/order-purchase.spec.ts b/backend/src/shared/utils/order-purchase.spec.ts new file mode 100644 index 0000000..1fb9f73 --- /dev/null +++ b/backend/src/shared/utils/order-purchase.spec.ts @@ -0,0 +1,80 @@ +import { OrderForPurchase, purchaseLines } from './order-purchase'; + +function order( + paymentStatus: string, + sections: NonNullable, +): 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([]); + }); +}); diff --git a/backend/src/shared/utils/order-purchase.ts b/backend/src/shared/utils/order-purchase.ts new file mode 100644 index 0000000..7ad1810 --- /dev/null +++ b/backend/src/shared/utils/order-purchase.ts @@ -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(); + 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, + })); +} diff --git a/imweb/README.md b/imweb/README.md index 1845533..36c5c33 100644 --- a/imweb/README.md +++ b/imweb/README.md @@ -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,17 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls - 한계: 신청 완료 화면은 공개 페이지라 "실제로 그 회원이 보냈는지"는 확인할 수 없다 (있는 회원인지만 확인). 무료 신청 기록 용도라 이 수준으로 둔다 +## 유료 구매 기록 (아임웹 주문 → 신청·구매 내역) + +아임웹은 주문 완료를 알려주지 않으므로(웹훅 없음) 서버가 1시간마다 아임웹 주문(`GET /orders`)을 훑어 +결제된 주문을 `purchases` 에 주문·상품당 한 줄로 남긴다. 화면의 '구매 집계 새로고침'(업체 링크 메뉴)으로 바로 돌릴 수도 있다. + +- 회원·비회원 주문을 모두 남긴다. 비회원은 주문서의 주문자 이름·이메일·연락처로 보여준다 +- 결제 전·결제 실패·입금 전 취소 주문은 남기지 않는다. 나중에 환불·취소되면 지우지 않고 '환불·취소'로 바꾼다 +- 서버가 뜬 뒤 첫 실행은 **최근 1년**, 그 뒤로는 **최근 60일**만 다시 본다 (60일보다 오래된 주문의 환불은 반영되지 않는다) +- 사은품 품목은 넣지 않는다. 0원 주문도 결제완료면 구매로 남는다 +- 금액은 관리하지 않는다 + ## 업체 링크 (업체별 신청·구매 추적) 상품마다 업체에 단축 링크를 발급하고, 그 링크로 들어온 회원의 신청·구매를 업체별로 센다. @@ -116,7 +137,7 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls │ ② 로그인 회원이 확인되면 "이 회원이 이 클릭으로 들어왔다"를 POST /api/partner-touches 로 알린다 ▼ 신청: 신청 완료 위젯이 신청 기록에 클릭 ID 를 함께 보낸다 → 바로 귀속 -구매: 서버가 1시간마다 최근 60일 아임웹 주문(회원 주문)을 훑어 결제완료 상품을 귀속, 환불·취소는 집계에서 뺀다 +구매: 구매 기록과 같은 주문 동기화(1시간마다)가 회원 주문의 결제완료 상품을 귀속, 환불·취소는 집계에서 뺀다 ``` - **귀속 규칙**: 신청·구매 직전 30일 안에 누른 **그 상품의** 업체 링크 중 마지막 것 (마지막 클릭) diff --git a/imweb/imweb-members.html b/imweb/imweb-members.html index 2f87e70..cfc89f0 100644 --- a/imweb/imweb-members.html +++ b/imweb/imweb-members.html @@ -421,7 +421,7 @@ 운영 - + +
불러오는 중…
@@ -501,13 +501,13 @@
-

최근 신청

+

최근 신청·구매

- +
신청일시신청자상품업체 · 유입
일시이름상품업체 · 유입
@@ -524,7 +524,7 @@
- +