From 1f71ce048ffa13000e9f7b71435cf3b8a7e45bd9 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 6 Oct 2026 13:14:33 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=EC=A3=BC=EB=AC=B8=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=ED=99=94=EB=A9=B4=EC=9D=98=20=EA=B5=AC=EB=A7=A4=20?= =?UTF-8?q?=EB=95=8C=20=EC=9C=A0=EC=9E=85=20=EC=88=98=EC=A7=91=EA=B3=BC=20?= =?UTF-8?q?=EB=B9=84=ED=9A=8C=EC=9B=90=20=EA=B5=AC=EB=A7=A4=20=EC=97=85?= =?UTF-8?q?=EC=B2=B4=20=EA=B7=80=EC=86=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 아임웹 주문 API 에는 손님이 어디서 왔는지가 없어 구매는 가입 경로로만 볼 수 있었고, 비회원 구매는 회원 코드가 없어 업체 링크 실적으로 잡을 수 없었다. - POST /api/order-inflows: 주문완료 화면이 주문번호·유입·업체 클릭 ID 를 보낸다 (공개 · 1분 10회 · 항상 204 · 같은 주문번호는 처음 값만) - 주문 동기화가 같은 주문번호의 구매에 유입을 채운다 (비어 있는 구매에만) - 업체 귀속 후보 = 회원 클릭 연결 + 주문완료 클릭 ID → 비회원 구매도 마지막 클릭에 귀속 (partner_conversions.member_code nullable, 실적 상세에 주문자 이름) - 90일 동안 주문과 이어지지 않은 값은 동기화가 끝날 때 지운다 - 관리자 조회: 유입 기준 apply 를 신청·구매 때 유입으로 넓힌다 Co-Authored-By: Claude Opus 5.5 --- .../1791258525922-AddOrderInflows.ts | 57 +++++++++ .../activities/activities.controller.ts | 2 +- .../modules/activities/activities.service.ts | 68 ++++++----- .../activities/dto/activity-query.dto.ts | 7 +- .../activities/dto/activity-response.dto.ts | 7 +- .../partners/dto/partner-conversion.dto.ts | 16 ++- .../entities/partner-conversion.entity.ts | 7 +- .../partner-attribution.service.spec.ts | 86 +++++++++++++- .../partners/partner-attribution.service.ts | 73 ++++++++++-- .../modules/partners/partner-links.service.ts | 25 +++- .../partner-order-sync.service.spec.ts | 38 +++++- .../partners/partner-order-sync.service.ts | 13 ++- .../purchases/dto/create-order-inflow.dto.ts | 54 +++++++++ .../modules/purchases/order-inflow.entity.ts | 68 +++++++++++ .../purchases/order-inflows.controller.ts | 31 +++++ .../purchases/order-inflows.service.spec.ts | 108 ++++++++++++++++++ .../purchases/order-inflows.service.ts | 89 +++++++++++++++ .../src/modules/purchases/purchase.entity.ts | 44 +++++++ .../src/modules/purchases/purchases.module.ts | 12 +- .../purchases/purchases.service.spec.ts | 27 ++++- .../modules/purchases/purchases.service.ts | 46 +++++++- 21 files changed, 803 insertions(+), 75 deletions(-) create mode 100644 backend/src/migrations/1791258525922-AddOrderInflows.ts create mode 100644 backend/src/modules/purchases/dto/create-order-inflow.dto.ts create mode 100644 backend/src/modules/purchases/order-inflow.entity.ts create mode 100644 backend/src/modules/purchases/order-inflows.controller.ts create mode 100644 backend/src/modules/purchases/order-inflows.service.spec.ts create mode 100644 backend/src/modules/purchases/order-inflows.service.ts diff --git a/backend/src/migrations/1791258525922-AddOrderInflows.ts b/backend/src/migrations/1791258525922-AddOrderInflows.ts new file mode 100644 index 0000000..577d2c7 --- /dev/null +++ b/backend/src/migrations/1791258525922-AddOrderInflows.ts @@ -0,0 +1,57 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddOrderInflows1791258525922 implements MigrationInterface { + name = 'AddOrderInflows1791258525922'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "order_inflows" ("order_no" character varying(30) NOT NULL, "inflow_raw" text, "inflow_source" character varying(100), "inflow_medium" character varying(100), "inflow_campaign" character varying(100), "inflow_content" character varying(100), "partner_refs" character varying(16) array NOT NULL DEFAULT '{}', "received_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_5611d45b9e9708e854d3131aeda" PRIMARY KEY ("order_no"))`, + ); + await queryRunner.query( + `CREATE INDEX "idx_order_inflows_received" ON "order_inflows" ("received_at") `, + ); + await queryRunner.query(`ALTER TABLE "purchases" ADD "inflow_raw" text`); + await queryRunner.query( + `ALTER TABLE "purchases" ADD "inflow_source" character varying(100)`, + ); + await queryRunner.query( + `ALTER TABLE "purchases" ADD "inflow_medium" character varying(100)`, + ); + await queryRunner.query( + `ALTER TABLE "purchases" ADD "inflow_campaign" character varying(100)`, + ); + await queryRunner.query( + `ALTER TABLE "purchases" ADD "inflow_content" character varying(100)`, + ); + await queryRunner.query( + `ALTER TABLE "partner_conversions" ALTER COLUMN "member_code" DROP NOT NULL`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_7fc2cb346462240f6f38ea03c1" ON "purchases" ("inflow_source") `, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "public"."IDX_7fc2cb346462240f6f38ea03c1"`, + ); + await queryRunner.query( + `ALTER TABLE "partner_conversions" ALTER COLUMN "member_code" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "purchases" DROP COLUMN "inflow_content"`, + ); + await queryRunner.query( + `ALTER TABLE "purchases" DROP COLUMN "inflow_campaign"`, + ); + await queryRunner.query( + `ALTER TABLE "purchases" DROP COLUMN "inflow_medium"`, + ); + await queryRunner.query( + `ALTER TABLE "purchases" DROP COLUMN "inflow_source"`, + ); + await queryRunner.query(`ALTER TABLE "purchases" DROP COLUMN "inflow_raw"`); + await queryRunner.query(`DROP INDEX "public"."idx_order_inflows_received"`); + await queryRunner.query(`DROP TABLE "order_inflows"`); + } +} diff --git a/backend/src/modules/activities/activities.controller.ts b/backend/src/modules/activities/activities.controller.ts index 1a20e66..6745651 100644 --- a/backend/src/modules/activities/activities.controller.ts +++ b/backend/src/modules/activities/activities.controller.ts @@ -60,7 +60,7 @@ export class AdminActivitiesController { @Get('inflow-stats') @ApiOperation({ summary: - '유입 집계 — 신청 때 유입(무료 신청만) 또는 가입 경로(신청·구매) 기준', + '유입 집계 — 신청·구매 때 유입 또는 가입 경로 기준 (신청·구매 모두)', }) @ApiStandardResponse(ActivityInflowStatsResponseDto, { description: '집계 성공', diff --git a/backend/src/modules/activities/activities.service.ts b/backend/src/modules/activities/activities.service.ts index 3333d30..abbde58 100644 --- a/backend/src/modules/activities/activities.service.ts +++ b/backend/src/modules/activities/activities.service.ts @@ -198,10 +198,10 @@ export class ActivitiesService { [`${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}.inflow_source`, 'inflow_source'], + [`${PUR}.inflow_medium`, 'inflow_medium'], + [`${PUR}.inflow_campaign`, 'inflow_campaign'], + [`${PUR}.inflow_content`, 'inflow_content'], [`${PUR}.order_no`, 'order_no'], [`${PUR}.is_valid`, 'is_valid'], [`${PUR}.orderer_name`, 'orderer_name'], @@ -272,14 +272,12 @@ export class ActivitiesService { } : 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, + inflow: { + source: row.inflow_source, + medium: row.inflow_medium, + campaign: row.inflow_campaign, + content: row.inflow_content, + }, orderNo: row.order_no, isCancelled: isApplication ? null : row.is_valid === false, orderer: isApplication @@ -388,9 +386,10 @@ export class ActivitiesService { } /** - * 유입 집계. - * - 신청 때 유입: 무료 신청만 센다 (구매 시점의 유입은 아임웹 주문에 남지 않는다) - * - 가입 경로: 신청·구매(환불·취소 제외) 모두 회원의 가입 경로로 센다. 비회원 구매는 값 없음 + * 유입 집계 — 신청·구매(환불·취소 제외) 모두 센다. + * - 신청·구매 때 유입(apply): 신청은 신청 완료 화면, 구매는 주문완료 화면이 보낸 값. + * 주문완료 화면이 보내지 못한 구매(도입 전 주문 등)는 값 없음 + * - 가입 경로(join): 회원의 가입 경로. 비회원 구매는 값 없음 */ async inflowStats( query: ActivityInflowStatsQueryDto, @@ -412,13 +411,18 @@ export class ActivitiesService { ]), ); } - const pur = isJoin ? this.purchasePart(query) : null; + const pur = this.purchasePart(query); if (pur) { pur.andWhere(`${PUR}.is_valid = true`); parts.push( selectColumns(pur, [ [`'purchase'`, 'type'], - [joinInflow(PUR_MEMBER, query.groupBy), 'key'], + [ + isJoin + ? joinInflow(PUR_MEMBER, query.groupBy) + : `${PUR}.inflow_${query.groupBy}`, + 'key', + ], ]), ); } @@ -466,16 +470,11 @@ export class ActivitiesService { return filter.type === 'purchase' ? null : this.applicationQuery(filter); } - /** 유료 구매 쪽 — 종류 필터로 빠지거나, 신청 때 유입으로 거르면(구매엔 없는 기록) null */ + /** 유료 구매 쪽 — 종류 필터로 빠지면 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); + return filter.type === 'application' ? null : this.purchaseQuery(filter); } /** 무료 신청 — 필터만 걸고 열은 부르는 쪽이 고른다 */ @@ -521,7 +520,7 @@ export class ActivitiesService { return qb; } - /** 유료 구매 — 필터만 걸고 열은 부르는 쪽이 고른다. 유입 필터는 가입 경로일 때만 건다 */ + /** 유료 구매 — 필터만 걸고 열은 부르는 쪽이 고른다 */ private purchaseQuery(filter: ListFilter): SelectQueryBuilder { const qb = this.dataSource .createQueryBuilder() @@ -552,17 +551,14 @@ export class ActivitiesService { `${PUR}.orderer_call`, ], }); - if ( - filter.inflowBasis === 'join' && - filter.inflowField && - filter.inflowValue - ) { - qb.andWhere( - `${joinInflow(PUR_MEMBER, filter.inflowField)} = :inflowValue`, - { - inflowValue: filter.inflowValue, - }, - ); + if (filter.inflowBasis && filter.inflowField && filter.inflowValue) { + const expression = + filter.inflowBasis === 'join' + ? joinInflow(PUR_MEMBER, filter.inflowField) + : `${PUR}.inflow_${filter.inflowField}`; + qb.andWhere(`${expression} = :inflowValue`, { + inflowValue: filter.inflowValue, + }); } return qb; } diff --git a/backend/src/modules/activities/dto/activity-query.dto.ts b/backend/src/modules/activities/dto/activity-query.dto.ts index 868aa49..b6db342 100644 --- a/backend/src/modules/activities/dto/activity-query.dto.ts +++ b/backend/src/modules/activities/dto/activity-query.dto.ts @@ -26,7 +26,8 @@ const PARTNER_FILTER_PATTERN = /** * 유입을 어느 기록으로 볼 것인가. - * - apply : 신청 때 유입 — 신청 시점 브라우저에 남아 있던 경로 (2일 보관). 구매에는 이 기록이 없다 + * - apply : 신청·구매 때 유입 — 신청 완료·주문완료 화면이 보낸 그 순간의 경로 (UTM 스크립트 보관 2일). + * 주문완료 화면이 보내지 못한 구매는 값 없음 * - join : 가입 경로 — 신청·구매한 회원이 가입할 때 기록된 값만 (가입 후 채워진 값·비회원은 값 없음) */ export const ACTIVITY_INFLOW_BASIS = ['apply', 'join'] as const; @@ -100,7 +101,7 @@ export class ActivityListQueryDto extends IntersectionType( ) { @ApiPropertyOptional({ description: - '유입 필터 기준 — apply(신청 때 유입, 구매는 빠진다) · join(가입 경로). inflowField·inflowValue 와 함께 쓴다', + '유입 필터 기준 — apply(신청·구매 때 유입) · join(가입 경로). inflowField·inflowValue 와 함께 쓴다', enum: ACTIVITY_INFLOW_BASIS, }) @IsOptional() @@ -139,7 +140,7 @@ export class ActivityListQueryDto extends IntersectionType( export class ActivityInflowStatsQueryDto extends ActivityFilterDto { @ApiPropertyOptional({ description: - '집계 기준 — apply(신청 때 유입, 무료 신청만 · 기본) · join(가입 경로, 신청·구매 모두)', + '집계 기준 — apply(신청·구매 때 유입 · 기본) · join(가입 경로)', enum: ACTIVITY_INFLOW_BASIS, default: 'apply', }) diff --git a/backend/src/modules/activities/dto/activity-response.dto.ts b/backend/src/modules/activities/dto/activity-response.dto.ts index 2feef7a..14f0c77 100644 --- a/backend/src/modules/activities/dto/activity-response.dto.ts +++ b/backend/src/modules/activities/dto/activity-response.dto.ts @@ -106,11 +106,10 @@ export class ActivityResponseDto { @ApiProperty({ description: - '[신청] 신청 때 유입 (신청 시점 브라우저에 남아 있던 경로) — 구매면 null', + '신청·구매 때 유입 — 신청 완료·주문완료 화면이 보낸 그 순간의 경로. 받지 못했으면 항목이 모두 null', type: ActivityInflowDto, - nullable: true, }) - inflow!: ActivityInflowDto | null; + inflow!: ActivityInflowDto; @ApiProperty({ description: '[구매] 아임웹 주문번호 — 신청이면 null', @@ -212,7 +211,7 @@ export class ActivityInflowStatsResponseDto { applicationTotal!: number; @ApiProperty({ - description: '집계한 유료 구매 수 (가입 경로 기준일 때만, 환불·취소 제외)', + description: '집계한 유료 구매 수 (환불·취소 제외)', example: 10, }) purchaseTotal!: number; diff --git a/backend/src/modules/partners/dto/partner-conversion.dto.ts b/backend/src/modules/partners/dto/partner-conversion.dto.ts index a9a8b74..49ad15e 100644 --- a/backend/src/modules/partners/dto/partner-conversion.dto.ts +++ b/backend/src/modules/partners/dto/partner-conversion.dto.ts @@ -53,15 +53,25 @@ export class ConversionResponseDto { }) isValid!: boolean; - @ApiProperty({ description: '아임웹 회원 코드' }) - memberCode!: string; + @ApiProperty({ + description: '아임웹 회원 코드 — 비회원 구매면 null', + nullable: true, + }) + memberCode!: string | null; @ApiProperty({ - description: '회원 정보 (동기화된 회원 기준) — 탈퇴 등으로 없으면 null', + description: '회원 정보 (동기화된 회원 기준) — 탈퇴·비회원이면 null', type: MemberResponseDto, nullable: true, }) member!: MemberResponseDto | null; + + @ApiProperty({ + description: '[비회원 구매] 주문서의 주문자 이름 — 회원이거나 모르면 null', + nullable: true, + example: '홍길동', + }) + ordererName!: string | null; } /** 주문 실적 새로고침 상태 (서버 메모리 기준 — 재시작하면 비워진다) */ diff --git a/backend/src/modules/partners/entities/partner-conversion.entity.ts b/backend/src/modules/partners/entities/partner-conversion.entity.ts index 8c35e30..5e0f2e7 100644 --- a/backend/src/modules/partners/entities/partner-conversion.entity.ts +++ b/backend/src/modules/partners/entities/partner-conversion.entity.ts @@ -18,7 +18,7 @@ export enum ConversionType { } /** - * 업체 실적 — 업체 링크로 들어온 회원의 신청·구매 한 건. + * 업체 실적 — 업체 링크로 들어온 손님의 신청·구매 한 건 (구매는 비회원 포함). * * 신청은 신청 기록당 하나, 구매는 주문·상품당 하나다. * 구매가 나중에 전액 환불·취소되면 지우지 않고 isValid=false 로 바꿔 집계에서 뺀다. @@ -56,8 +56,9 @@ export class PartnerConversion { @Column({ name: 'prod_no', type: 'int' }) prodNo!: number; - @Column({ name: 'member_code', type: 'varchar', length: 40 }) - memberCode!: string; + /** 아임웹 회원 코드 — 비회원 구매(주문완료 화면이 보낸 클릭 ID 로 이은 것)면 null */ + @Column({ name: 'member_code', type: 'varchar', length: 40, nullable: true }) + memberCode!: string | null; /** 신청 기록 ID (구매면 null) */ @Column({ name: 'application_id', type: 'uuid', nullable: true }) diff --git a/backend/src/modules/partners/partner-attribution.service.spec.ts b/backend/src/modules/partners/partner-attribution.service.spec.ts index 4fd3e36..c3f173d 100644 --- a/backend/src/modules/partners/partner-attribution.service.spec.ts +++ b/backend/src/modules/partners/partner-attribution.service.spec.ts @@ -277,7 +277,7 @@ describe('PartnerAttributionService.attributeOrder', () => { expect(conversionStore[0].isValid).toBe(false); }); - it('결제 전 주문·비회원 주문·링크 없는 상품은 잡지 않는다', async () => { + it('결제 전 주문·클릭 ID 없는 비회원 주문·링크 없는 상품은 잡지 않는다', async () => { const { service, conversionStore, touch } = setup(); touch(LINK_A, new Date(Date.now() - DAY)); @@ -294,6 +294,90 @@ describe('PartnerAttributionService.attributeOrder', () => { expect(conversionStore).toHaveLength(0); }); + it('비회원 주문도 주문완료 화면이 보낸 클릭 ID 로 그 상품 링크에 귀속한다', async () => { + const wtime = new Date(); + const clickedAt = new Date(wtime.getTime() - DAY); + const { service, conversionStore } = setup({ + clicks: [ + { + ref: 'AAAAAAAAAAAAAAAA', + linkId: LINK_A.id, + clickedAt, + link: { ...LINK_A, isActive: true } as PartnerLink, + }, + ], + }); + + const result = await service.attributeOrder( + order({ isMember: 'N', memberCode: null, wtime: wtime.toISOString() }), + linked, + ['AAAAAAAAAAAAAAAA'], + ); + + expect(result).toEqual({ created: 1, updated: 0 }); + expect(conversionStore[0]).toMatchObject({ + type: ConversionType.PURCHASE, + linkId: LINK_A.id, + memberCode: null, + orderNo: '202610050001', + touchedAt: clickedAt, + }); + }); + + it('다른 상품 링크·중지된 링크의 클릭 ID 는 귀속하지 않는다', async () => { + const clickedAt = new Date(Date.now() - DAY); + const { service, conversionStore } = setup({ + clicks: [ + { + ref: 'AAAAAAAAAAAAAAAA', + linkId: 'link-other', + clickedAt, + link: { id: 'link-other', prodNo: 60, isActive: true } as PartnerLink, + }, + { + ref: 'BBBBBBBBBBBBBBBB', + linkId: LINK_B.id, + clickedAt, + link: { ...LINK_B, isActive: false } as PartnerLink, + }, + ], + }); + + await service.attributeOrder( + order({ isMember: 'N', memberCode: null }), + linked, + ['AAAAAAAAAAAAAAAA', 'BBBBBBBBBBBBBBBB'], + ); + + expect(conversionStore).toHaveLength(0); + }); + + it('회원 주문은 회원 클릭과 주문완료 클릭 ID 중 마지막 클릭에 귀속한다', async () => { + const wtime = new Date(); + const { service, conversionStore, touch } = setup({ + clicks: [ + { + ref: 'BBBBBBBBBBBBBBBB', + linkId: LINK_B.id, + clickedAt: new Date(wtime.getTime() - DAY), + link: { ...LINK_B, isActive: true } as PartnerLink, + }, + ], + }); + touch(LINK_A, new Date(wtime.getTime() - 3 * DAY)); + + await service.attributeOrder( + order({ wtime: wtime.toISOString() }), + linked, + ['BBBBBBBBBBBBBBBB'], + ); + + expect(conversionStore[0]).toMatchObject({ + linkId: LINK_B.id, + memberCode: MEMBER_CODE, + }); + }); + it('클릭보다 먼저 한 주문은 그 링크의 실적이 아니다', async () => { const { service, conversionStore, touch } = setup(); const wtime = new Date(Date.now() - 3 * DAY); diff --git a/backend/src/modules/partners/partner-attribution.service.ts b/backend/src/modules/partners/partner-attribution.service.ts index 1cff86e..13cb801 100644 --- a/backend/src/modules/partners/partner-attribution.service.ts +++ b/backend/src/modules/partners/partner-attribution.service.ts @@ -10,6 +10,7 @@ import { ATTRIBUTION_WINDOW_MS, evaluateOrder, pickLastTouch, + TouchCandidate, } from '@/shared/utils/partner-attribution'; import { CLICK_REF_PATTERN } from '@/shared/utils/partner-link'; import { @@ -159,18 +160,24 @@ export class PartnerAttributionService { * 아임웹 주문 한 건을 반영한다. * - 이미 잡힌 구매는 환불·취소 여부만 갱신한다. * - 새로 결제완료된 상품은 마지막 클릭 링크가 있으면 구매로 잡는다. + * 클릭 후보는 회원의 클릭 연결(partner_touches)과 주문완료 화면이 보낸 클릭 ID 를 합친 것이다. + * 주문완료 화면의 클릭 ID 로는 비회원 구매도 잡힌다. * * @param linkedProdNos 업체 링크가 있는 상품번호 — 그 밖의 상품은 볼 필요가 없다 + * @param partnerRefs 주문완료 화면이 보낸 이 주문의 업체 링크 클릭 ID (없으면 빈 배열) */ async attributeOrder( order: ImwebOrderData, linkedProdNos: ReadonlySet, + partnerRefs: readonly string[] = [], ): Promise { const result: OrderAttributionResult = { created: 0, updated: 0 }; const orderNo = String(order.orderNo); const orderedAt = new Date(order.wtime); if (Number.isNaN(orderedAt.getTime())) return result; + const memberCode = + order.isMember === 'Y' && order.memberCode ? order.memberCode : null; const { products } = evaluateOrder(order); for (const [prodNo, counts] of products) { if (!linkedProdNos.has(prodNo)) continue; @@ -186,12 +193,17 @@ export class PartnerAttributionService { continue; } - // 결제 전이면 다음 동기화에서 다시 본다. 비회원 주문은 회원과 이을 수 없다. - if (!counts || order.isMember !== 'Y' || !order.memberCode) continue; + // 결제 전이면 다음 동기화에서 다시 본다. + // 회원도 아니고 주문완료 화면이 클릭 ID 를 보내지도 않았으면 이을 방법이 없다. + if (!counts || (!memberCode && partnerRefs.length === 0)) continue; - const touch = await this.findLastTouch( - order.memberCode, - prodNo, + const touch = pickLastTouch( + [ + ...(memberCode + ? await this.memberTouches(memberCode, prodNo, orderedAt) + : []), + ...(await this.refTouches(partnerRefs, prodNo, orderedAt)), + ], orderedAt, ); if (!touch) continue; @@ -199,7 +211,7 @@ export class PartnerAttributionService { await this.insertConversion({ type: ConversionType.PURCHASE, touch, - memberCode: order.memberCode, + memberCode, prodNo, applicationId: null, orderNo, @@ -207,7 +219,7 @@ export class PartnerAttributionService { }); result.created += 1; this.logger.log( - `업체 실적(구매): link=${touch.linkId} member=${order.memberCode} order=${orderNo} product=${prodNo}`, + `업체 실적(구매): link=${touch.linkId} member=${memberCode ?? 'guest'} order=${orderNo} product=${prodNo}`, ); } return result; @@ -227,7 +239,19 @@ export class PartnerAttributionService { prodNo: number, convertedAt: Date, ): Promise { - const candidates = await this.touches.find({ + return pickLastTouch( + await this.memberTouches(memberCode, prodNo, convertedAt), + convertedAt, + ); + } + + /** 회원의 그 상품 링크 클릭 연결 — 전환 전 30일 안 (전환 뒤 클릭은 pickLastTouch 가 거른다) */ + private memberTouches( + memberCode: string, + prodNo: number, + convertedAt: Date, + ): Promise { + return this.touches.find({ where: { memberCode, prodNo, @@ -236,13 +260,40 @@ export class PartnerAttributionService { ), }, }); - return pickLastTouch(candidates, convertedAt); + } + + /** + * 주문완료 화면이 보낸 클릭 ID 중 그 상품의 링크 클릭 — 전환 전 30일 안. + * 회원 연결(recordTouches)과 같이 실제로 있는 클릭만, 중지된 링크의 클릭은 뺀다. + */ + private async refTouches( + refs: readonly string[], + prodNo: number, + convertedAt: Date, + ): Promise { + const valid = [...new Set(refs)] + .filter((ref) => CLICK_REF_PATTERN.test(ref)) + .slice(0, PARTNER_REFS_MAX); + if (valid.length === 0) return []; + + const found = await this.clicks.find({ + where: { + ref: In(valid), + clickedAt: MoreThanOrEqual( + new Date(convertedAt.getTime() - ATTRIBUTION_WINDOW_MS), + ), + }, + relations: { link: true }, + }); + return found + .filter((click) => click.link?.isActive && click.link.prodNo === prodNo) + .map((click) => ({ linkId: click.linkId, clickedAt: click.clickedAt })); } private async insertConversion(input: { type: ConversionType; - touch: PartnerTouch; - memberCode: string; + touch: TouchCandidate; + memberCode: string | null; prodNo: number; applicationId: string | null; orderNo: string | null; diff --git a/backend/src/modules/partners/partner-links.service.ts b/backend/src/modules/partners/partner-links.service.ts index 4f09f56..c93518a 100644 --- a/backend/src/modules/partners/partner-links.service.ts +++ b/backend/src/modules/partners/partner-links.service.ts @@ -14,6 +14,7 @@ import { ImwebProduct } from '@/modules/imweb/entities/imweb-product.entity'; import { ImwebConfig } from '@/modules/imweb/imweb.config'; import { MemberResponseDto } from '@/modules/members/dto/member-response.dto'; import { MembersService } from '@/modules/members/members.service'; +import { PurchasesService } from '@/modules/purchases/purchases.service'; import { CLICK_REF_LENGTH, LINK_CODE_LENGTH, @@ -82,6 +83,7 @@ export class PartnerLinksService { private readonly membersService: MembersService, private readonly imwebConfig: ImwebConfig, private readonly config: ConfigService, + private readonly purchases: PurchasesService, ) {} /** 링크 목록 — 최근 발급순, 기간 내 실적 포함 */ @@ -240,18 +242,31 @@ export class PartnerLinksService { const items = rows.slice(0, query.limit); const last = items.at(-1); - const codes = [...new Set(items.map((row) => row.memberCode))]; - const [memberRows, groupTitles] = await Promise.all([ + const codes = [ + ...new Set( + items + .map((row) => row.memberCode) + .filter((code): code is string => !!code), + ), + ]; + // 비회원 구매는 회원 정보가 없어 주문자 이름으로 보여준다 + const guestOrderNos = items + .filter((row) => !row.memberCode && row.orderNo) + .map((row) => row.orderNo as string); + const [memberRows, groupTitles, ordererNames] = await Promise.all([ codes.length > 0 ? this.members.find({ where: { memberCode: In(codes) } }) : Promise.resolve([]), this.membersService.groupTitleMap(), + this.purchases.ordererNames(guestOrderNos), ]); const membersByCode = new Map(memberRows.map((m) => [m.memberCode, m])); return toPaginatedResult( items.map((row) => { - const member = membersByCode.get(row.memberCode); + const member = row.memberCode + ? membersByCode.get(row.memberCode) + : undefined; return { id: row.id, type: row.type, @@ -261,6 +276,10 @@ export class PartnerLinksService { isValid: row.isValid, memberCode: row.memberCode, member: member ? MemberResponseDto.from(member, groupTitles) : null, + ordererName: + !row.memberCode && row.orderNo + ? (ordererNames.get(row.orderNo) ?? null) + : null, }; }), { diff --git a/backend/src/modules/partners/partner-order-sync.service.spec.ts b/backend/src/modules/partners/partner-order-sync.service.spec.ts index 41d0c37..e3aa631 100644 --- a/backend/src/modules/partners/partner-order-sync.service.spec.ts +++ b/backend/src/modules/partners/partner-order-sync.service.spec.ts @@ -3,6 +3,7 @@ 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 { OrderInflowsService } from '@/modules/purchases/order-inflows.service'; import type { PurchasesService } from '@/modules/purchases/purchases.service'; import type { PartnerAttributionService } from './partner-attribution.service'; import { PartnerOrderSyncService } from './partner-order-sync.service'; @@ -11,7 +12,10 @@ 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[] = []) { +function setup( + linked: number[] = [], + refsByOrder = new Map(), +) { const get = jest.fn(() => Promise.resolve({ data: { list: [ORDER], totalPage: 1 } }), ); @@ -24,9 +28,14 @@ function setup(linked: number[] = []) { attributeOrder: jest.fn(() => Promise.resolve({ created: 0, updated: 0 })), }; const purchases = { recordOrders: jest.fn(() => Promise.resolve(1)) }; + const orderInflows = { + partnerRefsByOrder: jest.fn(() => Promise.resolve(refsByOrder)), + prune: jest.fn(() => Promise.resolve(0)), + }; const service = new PartnerOrderSyncService( attribution as unknown as PartnerAttributionService, purchases as unknown as PurchasesService, + orderInflows as unknown as OrderInflowsService, auth as unknown as ImwebAuthService, {} as ImwebConfig, {} as SchedulerRegistry, @@ -38,7 +47,7 @@ function setup(linked: number[] = []) { }; return new Date(params.startWtime).getTime(); }; - return { service, get, attribution, purchases, sinceOf }; + return { service, get, attribution, purchases, orderInflows, sinceOf }; } async function runOnce(service: PartnerOrderSyncService): Promise { @@ -64,9 +73,34 @@ describe('PartnerOrderSyncService', () => { expect(attribution.attributeOrder).toHaveBeenCalledWith( ORDER, new Set([52]), + [], ); }); + it('주문완료 화면이 보낸 클릭 ID 를 그 주문의 업체 실적 판정에 넘긴다 (비회원 구매 귀속)', async () => { + const refs = ['AAAAAAAAAAAAAAAA']; + const { service, attribution, orderInflows } = setup( + [52], + new Map([['1', refs]]), + ); + await runOnce(service); + expect(orderInflows.partnerRefsByOrder).toHaveBeenCalledWith(['1']); + expect(attribution.attributeOrder).toHaveBeenCalledWith( + ORDER, + new Set([52]), + refs, + ); + }); + + it('끝까지 성공했을 때만 오래된 구매 때 유입을 정리한다', async () => { + const { service, get, orderInflows } = setup(); + get.mockImplementationOnce(() => Promise.reject(new Error('down'))); + await runOnce(service); + expect(orderInflows.prune).not.toHaveBeenCalled(); + await runOnce(service); + expect(orderInflows.prune).toHaveBeenCalledTimes(1); + }); + it('첫 실행은 1년치, 성공한 뒤부터는 60일치만 본다', async () => { const { service, sinceOf } = setup(); await runOnce(service); diff --git a/backend/src/modules/partners/partner-order-sync.service.ts b/backend/src/modules/partners/partner-order-sync.service.ts index 772e3a4..b7751ee 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 { OrderInflowsService } from '@/modules/purchases/order-inflows.service'; import { PurchasesService } from '@/modules/purchases/purchases.service'; import { OrderSyncStatusDto } from './dto/partner-conversion.dto'; import { PartnerAttributionService } from './partner-attribution.service'; @@ -44,7 +45,7 @@ function toImwebTime(date: Date): string { /** * 주문 동기화 — 아임웹 주문을 주기적으로 훑어 - * (1) 결제된 주문을 구매 내역(purchases)에 남기고 (회원·비회원 모두) + * (1) 결제된 주문을 구매 내역(purchases)에 남기고 (회원·비회원 모두, 구매 때 유입도 함께 채운다) * (2) 업체 링크가 있는 상품이면 업체 실적(구매)으로 잡는다. 둘 다 환불·취소를 반영한다. * * 아임웹은 주문 완료를 알려주지 않으므로(웹훅 없음) 회원 동기화와 같은 주기로 최근 주문을 다시 본다. @@ -70,6 +71,7 @@ export class PartnerOrderSyncService constructor( private readonly attribution: PartnerAttributionService, private readonly purchases: PurchasesService, + private readonly orderInflows: OrderInflowsService, private readonly auth: ImwebAuthService, private readonly config: ImwebConfig, private readonly scheduler: SchedulerRegistry, @@ -155,12 +157,17 @@ export class PartnerOrderSyncService }, ); purchases += await this.purchases.recordOrders(data.list); - // 업체 실적은 업체 링크가 있는 상품만 (비회원 주문은 그 안에서 걸러진다) + // 업체 실적은 업체 링크가 있는 상품만. + // 비회원 주문은 주문완료 화면이 보낸 클릭 ID 가 있을 때만 잡힌다 if (linkedProdNos.size > 0) { + const refsByOrder = await this.orderInflows.partnerRefsByOrder( + data.list.map((order) => String(order.orderNo)), + ); for (const order of data.list) { const result = await this.attribution.attributeOrder( order, linkedProdNos, + refsByOrder.get(String(order.orderNo)) ?? [], ); created += result.created; updated += result.updated; @@ -170,6 +177,8 @@ export class PartnerOrderSyncService if (data.list.length === 0 || page >= data.totalPage) break; } this.isBackfilled = true; + // 주문과 이어지지 않은 채 오래된 구매 때 유입을 지운다 (동기화가 끝까지 성공했을 때만) + await this.orderInflows.prune(); this.status = { ...this.status, diff --git a/backend/src/modules/purchases/dto/create-order-inflow.dto.ts b/backend/src/modules/purchases/dto/create-order-inflow.dto.ts new file mode 100644 index 0000000..e4aeaa3 --- /dev/null +++ b/backend/src/modules/purchases/dto/create-order-inflow.dto.ts @@ -0,0 +1,54 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayMaxSize, + IsArray, + IsOptional, + IsString, + Matches, + MaxLength, +} from 'class-validator'; + +import { PARTNER_REFS_MAX } from '@/modules/partners/dto/partner-touch.dto'; +import { CLICK_REF_PATTERN } from '@/shared/utils/partner-link'; + +/** 아임웹 주문번호 — 숫자만 (예: 202610064563182). 저장 칸(30자)을 넘지 않게 */ +export const ORDER_NO_PATTERN = /^\d{1,30}$/; +/** imweb-header-top.html 이 만드는 값은 200자 남짓 — 넉넉히 두되 무한정 받지 않는다 */ +const INFLOW_RAW_MAX_LENGTH = 1000; + +/** 주문완료 화면(imweb-header-top.html)이 보내는 구매 때 유입 */ +export class CreateOrderInflowDto { + @ApiProperty({ + description: '아임웹 주문번호 (주문완료 주소의 order_no)', + example: '202610064563182', + }) + @IsString() + @Matches(ORDER_NO_PATTERN, { message: '주문번호 형식이 올바르지 않습니다.' }) + orderNo!: string; + + @ApiPropertyOptional({ + description: + '주문 순간의 유입정보 (imweb-header-top.html 의 MG_UTM.peek().value, 파이프 형식)', + example: 'src=facebook|med=paid|cmp=fall|ts=2026-10-05', + }) + @IsOptional() + @IsString() + @MaxLength(INFLOW_RAW_MAX_LENGTH) + inflow?: string; + + @ApiPropertyOptional({ + description: + '브라우저가 보관한 업체 링크 클릭 ID 목록 (imweb-header-top.html 의 MG_PARTNER.refs())', + example: ['AbCd1234EfGh5678'], + type: [String], + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(PARTNER_REFS_MAX) + @IsString({ each: true }) + @Matches(CLICK_REF_PATTERN, { + each: true, + message: '클릭 정보 형식이 올바르지 않습니다.', + }) + partnerRefs?: string[]; +} diff --git a/backend/src/modules/purchases/order-inflow.entity.ts b/backend/src/modules/purchases/order-inflow.entity.ts new file mode 100644 index 0000000..344871f --- /dev/null +++ b/backend/src/modules/purchases/order-inflow.entity.ts @@ -0,0 +1,68 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow'; + +/** + * 주문완료 화면이 보낸 구매 때 유입 — 주문번호당 한 줄 (먼저 온 값만 남긴다). + * + * 아임웹 주문 API 에는 손님이 어디서 왔는지가 없어서, 주문완료 화면(imweb-header-top.html)이 + * 그 순간 브라우저에 남아 있던 유입과 업체 링크 클릭 ID 를 보낸다. + * 공개 페이지에서 오는 값이라 받을 때는 확인하지 않고, 주문 동기화가 아임웹에 실제로 있는 + * 주문번호와 이을 때만 쓴다 (없는 주문번호는 쓰이지 않고 보관 기간이 지나면 지워진다). + */ +@Entity({ name: 'order_inflows' }) +@Index('idx_order_inflows_received', ['receivedAt']) +export class OrderInflow { + /** 아임웹 주문번호 (주문완료 주소의 order_no) */ + @PrimaryColumn({ name: 'order_no', type: 'varchar', length: 30 }) + orderNo!: string; + + /** 유입정보 원본 (파이프 형식) — 형식이 아니면 null */ + @Column({ name: 'inflow_raw', type: 'text', nullable: true }) + inflowRaw!: string | null; + + @Column({ + name: 'inflow_source', + type: 'varchar', + length: INFLOW_VALUE_MAX_LENGTH, + nullable: true, + }) + inflowSource!: string | null; + + @Column({ + name: 'inflow_medium', + type: 'varchar', + length: INFLOW_VALUE_MAX_LENGTH, + nullable: true, + }) + inflowMedium!: string | null; + + @Column({ + name: 'inflow_campaign', + type: 'varchar', + length: INFLOW_VALUE_MAX_LENGTH, + nullable: true, + }) + inflowCampaign!: string | null; + + @Column({ + name: 'inflow_content', + type: 'varchar', + length: INFLOW_VALUE_MAX_LENGTH, + nullable: true, + }) + inflowContent!: string | null; + + /** 브라우저가 보관한 업체 링크 클릭 ID (MG_PARTNER.refs()) — 비회원 구매도 업체 실적으로 잇는다 */ + @Column({ + name: 'partner_refs', + type: 'varchar', + length: 16, + array: true, + default: () => "'{}'", + }) + partnerRefs!: string[]; + + @Column({ name: 'received_at', type: 'timestamptz' }) + receivedAt!: Date; +} diff --git a/backend/src/modules/purchases/order-inflows.controller.ts b/backend/src/modules/purchases/order-inflows.controller.ts new file mode 100644 index 0000000..604f3b5 --- /dev/null +++ b/backend/src/modules/purchases/order-inflows.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; + +import { ApiErrorResponse } from '@/common/decorators/api-standard-response.decorator'; +import { SkipTransform } from '@/common/decorators/skip-transform.decorator'; +import { CreateOrderInflowDto } from './dto/create-order-inflow.dto'; +import { OrderInflowsService } from './order-inflows.service'; + +/** + * 구매 때 유입 — 아임웹 주문완료 화면(공개 페이지)의 Header Code 가 부른다. + * 로그인 없이 열려 있으므로 요청 수를 엄격히 제한한다 (신청 기록과 같다). + */ +@ApiTags('Order Inflows') +@Controller('order-inflows') +export class OrderInflowsController { + constructor(private readonly orderInflows: OrderInflowsService) {} + + @Post() + @HttpCode(HttpStatus.NO_CONTENT) + @SkipTransform() + @Throttle({ default: { ttl: 60_000, limit: 10 } }) + @ApiOperation({ + summary: '구매 때 유입 기록 — 항상 204 (같은 주문번호는 처음 값만 남는다)', + }) + @ApiErrorResponse('VAL_001', '입력값 오류') + @ApiErrorResponse('REQ_001', '요청 수 초과 (1분 10회)') + async create(@Body() dto: CreateOrderInflowDto): Promise { + await this.orderInflows.record(dto); + } +} diff --git a/backend/src/modules/purchases/order-inflows.service.spec.ts b/backend/src/modules/purchases/order-inflows.service.spec.ts new file mode 100644 index 0000000..3cfd8b8 --- /dev/null +++ b/backend/src/modules/purchases/order-inflows.service.spec.ts @@ -0,0 +1,108 @@ +import type { Repository } from 'typeorm'; + +import type { ImwebConfig } from '@/modules/imweb/imweb.config'; +import type { OrderInflow } from './order-inflow.entity'; +import { + ORDER_INFLOW_RETENTION_DAYS, + OrderInflowsService, +} from './order-inflows.service'; + +const DAY_MS = 86_400_000; + +/** insert 쿼리 빌더 흉내 — 메서드가 자기 자신을 돌려준다 */ +interface InsertQueryMock { + insert: jest.Mock; + values: jest.Mock; + orIgnore: jest.Mock; + execute: jest.Mock, []>; +} + +function setup() { + const inserted: OrderInflow[] = []; + const builder: InsertQueryMock = { + insert: jest.fn(() => builder), + values: jest.fn((row: OrderInflow) => { + inserted.push(row); + return builder; + }), + orIgnore: jest.fn(() => builder), + execute: jest.fn(() => Promise.resolve()), + }; + const repo = { + create: jest.fn((row: OrderInflow) => row), + createQueryBuilder: jest.fn(() => builder), + find: jest.fn(() => Promise.resolve([] as Partial[])), + delete: jest.fn(() => Promise.resolve({ affected: 0 })), + }; + const service = new OrderInflowsService( + repo as unknown as Repository, + { trackingHost: 'utm.moneyguider.co.kr' } as ImwebConfig, + ); + return { service, repo, builder, inserted }; +} + +describe('OrderInflowsService.record', () => { + it('유입정보를 풀어 주문번호와 함께 남긴다 (같은 주문번호는 처음 값만)', async () => { + const { service, builder, inserted } = setup(); + await service.record({ + orderNo: '202610064563182', + inflow: 'src=업체A|med=partner|cmp=CODE1234|lp=/free-shop/', + partnerRefs: ['AAAAAAAAAAAAAAAA', 'AAAAAAAAAAAAAAAA'], + }); + expect(builder.orIgnore).toHaveBeenCalled(); + expect(inserted[0]).toMatchObject({ + orderNo: '202610064563182', + inflowRaw: 'src=업체A|med=partner|cmp=CODE1234|lp=/free-shop/', + inflowSource: '업체A', + inflowMedium: 'partner', + inflowCampaign: 'CODE1234', + partnerRefs: ['AAAAAAAAAAAAAAAA'], + }); + }); + + it('유입정보가 없거나 형식이 아니면 유입 칸은 비우고 클릭 ID 만 남긴다', async () => { + const { service, inserted } = setup(); + await service.record({ + orderNo: '202610064563182', + inflow: 'hello', + partnerRefs: ['AAAAAAAAAAAAAAAA'], + }); + expect(inserted[0]).toMatchObject({ + inflowRaw: null, + inflowSource: null, + partnerRefs: ['AAAAAAAAAAAAAAAA'], + }); + }); +}); + +describe('OrderInflowsService.partnerRefsByOrder', () => { + it('클릭 ID 가 있는 주문만 돌려준다', async () => { + const { service, repo } = setup(); + repo.find.mockResolvedValueOnce([ + { orderNo: '1', partnerRefs: ['AAAAAAAAAAAAAAAA'] }, + { orderNo: '2', partnerRefs: [] }, + ]); + const refs = await service.partnerRefsByOrder(['1', '2', '1']); + expect(refs).toEqual(new Map([['1', ['AAAAAAAAAAAAAAAA']]])); + }); + + it('주문번호가 없으면 DB 를 보지 않는다', async () => { + const { service, repo } = setup(); + await expect(service.partnerRefsByOrder([])).resolves.toEqual(new Map()); + expect(repo.find).not.toHaveBeenCalled(); + }); +}); + +describe('OrderInflowsService.prune', () => { + it('보관 기간이 지난 값만 지운다', async () => { + const { service, repo } = setup(); + const now = new Date('2026-10-06T00:00:00.000Z'); + await service.prune(now); + const [where] = repo.delete.mock.calls[0] as unknown as [ + { receivedAt: { value: Date } }, + ]; + expect(now.getTime() - where.receivedAt.value.getTime()).toBe( + ORDER_INFLOW_RETENTION_DAYS * DAY_MS, + ); + }); +}); diff --git a/backend/src/modules/purchases/order-inflows.service.ts b/backend/src/modules/purchases/order-inflows.service.ts new file mode 100644 index 0000000..f8264fd --- /dev/null +++ b/backend/src/modules/purchases/order-inflows.service.ts @@ -0,0 +1,89 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, LessThan, Repository } from 'typeorm'; + +import { ImwebConfig } from '@/modules/imweb/imweb.config'; +import { parseInflow } from '@/shared/utils/inflow'; +import { CreateOrderInflowDto } from './dto/create-order-inflow.dto'; +import { OrderInflow } from './order-inflow.entity'; + +/** + * 이만큼 지나도록 아임웹 주문과 이어지지 않으면 지운다 — 없는 주문번호이거나 끝내 결제되지 않은 주문이다. + * 무통장입금은 입금 확인 후에야 구매로 잡히므로 입금 기한보다 넉넉히 둔다. + */ +export const ORDER_INFLOW_RETENTION_DAYS = 90; +const DAY_MS = 86_400_000; + +/** + * 구매 때 유입 수집 — 주문완료 화면이 보낸 값을 주문번호당 한 줄로 받아 둔다. + * + * 공개 페이지에서 오는 값이라 정말 그 주문인지는 확인할 수 없다. 그래서 + * (1) 먼저 온 값만 남기고 (2) 주문 동기화가 아임웹에 실제로 있는 주문과 이을 때만 쓰며 + * (3) 이어지지 않은 값은 보관 기간이 지나면 지운다. + */ +@Injectable() +export class OrderInflowsService { + private readonly logger = new Logger(OrderInflowsService.name); + + constructor( + @InjectRepository(OrderInflow) + private readonly inflows: Repository, + private readonly config: ImwebConfig, + ) {} + + /** 같은 주문번호가 다시 오면 처음 값을 그대로 둔다 (새로고침·중복 전송에도 안전) */ + async record(dto: CreateOrderInflowDto): Promise { + const receivedAt = new Date(); + const inflow = parseInflow( + dto.inflow, + receivedAt.toISOString(), + this.config.trackingHost, + ); + await this.inflows + .createQueryBuilder() + .insert() + .values( + this.inflows.create({ + orderNo: dto.orderNo, + inflowRaw: inflow.source ? (dto.inflow?.trim() ?? null) : null, + inflowSource: inflow.source, + inflowMedium: inflow.medium, + inflowCampaign: inflow.campaign, + inflowContent: inflow.content, + partnerRefs: [...new Set(dto.partnerRefs ?? [])], + receivedAt, + }), + ) + .orIgnore() + .execute(); + } + + /** 주문번호 → 주문완료 화면이 보낸 업체 링크 클릭 ID (보낸 적 없거나 비었으면 빠진다) */ + async partnerRefsByOrder( + orderNos: readonly string[], + ): Promise> { + const unique = [...new Set(orderNos)]; + if (unique.length === 0) return new Map(); + const rows = await this.inflows.find({ + select: { orderNo: true, partnerRefs: true }, + where: { orderNo: In(unique) }, + }); + return new Map( + rows + .filter((row) => row.partnerRefs.length > 0) + .map((row) => [row.orderNo, row.partnerRefs]), + ); + } + + /** 보관 기간이 지난 값을 지운다 — 주문 동기화가 끝날 때 부른다 */ + async prune(now: Date = new Date()): Promise { + const result = await this.inflows.delete({ + receivedAt: LessThan( + new Date(now.getTime() - ORDER_INFLOW_RETENTION_DAYS * DAY_MS), + ), + }); + const deleted = result.affected ?? 0; + if (deleted > 0) this.logger.log(`구매 때 유입 정리: ${deleted}건`); + return deleted; + } +} diff --git a/backend/src/modules/purchases/purchase.entity.ts b/backend/src/modules/purchases/purchase.entity.ts index b152033..6143a80 100644 --- a/backend/src/modules/purchases/purchase.entity.ts +++ b/backend/src/modules/purchases/purchase.entity.ts @@ -6,6 +6,8 @@ import { PrimaryColumn, } from 'typeorm'; +import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow'; + /** * 유료 구매 — 아임웹 주문을 주기적으로 훑어 결제된 주문의 상품을 남긴다 (주문·상품당 한 줄). * @@ -78,6 +80,48 @@ export class Purchase { @Column({ name: 'is_valid', type: 'boolean', default: true }) isValid!: boolean; + /* + * 구매 때 유입 — 주문완료 화면이 보낸 유입(order_inflows)을 주문 동기화가 주문번호로 이어 채운다. + * 주문완료 화면이 보내지 못한 주문(다른 기기·스크립트 차단·도입 전 주문)은 null 이다. + */ + + /** 유입정보 원본 (imweb-header-top.html 의 MG_UTM.peek().value, 파이프 형식) */ + @Column({ name: 'inflow_raw', type: 'text', nullable: true }) + inflowRaw!: string | null; + + @Index() + @Column({ + name: 'inflow_source', + type: 'varchar', + length: INFLOW_VALUE_MAX_LENGTH, + nullable: true, + }) + inflowSource!: string | null; + + @Column({ + name: 'inflow_medium', + type: 'varchar', + length: INFLOW_VALUE_MAX_LENGTH, + nullable: true, + }) + inflowMedium!: string | null; + + @Column({ + name: 'inflow_campaign', + type: 'varchar', + length: INFLOW_VALUE_MAX_LENGTH, + nullable: true, + }) + inflowCampaign!: string | null; + + @Column({ + name: 'inflow_content', + type: 'varchar', + length: INFLOW_VALUE_MAX_LENGTH, + nullable: true, + }) + inflowContent!: string | null; + /** 처음 잡힌 시각 */ @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 index 96b8adb..ac721ab 100644 --- a/backend/src/modules/purchases/purchases.module.ts +++ b/backend/src/modules/purchases/purchases.module.ts @@ -1,16 +1,22 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ImwebModule } from '@/modules/imweb/imweb.module'; +import { OrderInflow } from './order-inflow.entity'; +import { OrderInflowsController } from './order-inflows.controller'; +import { OrderInflowsService } from './order-inflows.service'; import { Purchase } from './purchase.entity'; import { PurchasesService } from './purchases.service'; /** * 유료 구매 기록 — 아임웹 주문 동기화(PartnersModule 의 PartnerOrderSyncService)가 채우고, * 관리자 신청·구매 내역(ActivitiesModule)이 읽는다. + * 구매 때 유입은 주문완료 화면이 보낸 값(order_inflows)을 동기화가 주문번호로 이어 채운다. */ @Module({ - imports: [TypeOrmModule.forFeature([Purchase])], - providers: [PurchasesService], - exports: [PurchasesService], + imports: [TypeOrmModule.forFeature([Purchase, OrderInflow]), ImwebModule], + controllers: [OrderInflowsController], + providers: [PurchasesService, OrderInflowsService], + exports: [PurchasesService, OrderInflowsService], }) export class PurchasesModule {} diff --git a/backend/src/modules/purchases/purchases.service.spec.ts b/backend/src/modules/purchases/purchases.service.spec.ts index 9a5edfb..623b257 100644 --- a/backend/src/modules/purchases/purchases.service.spec.ts +++ b/backend/src/modules/purchases/purchases.service.spec.ts @@ -46,9 +46,11 @@ function setup() { const repo = { create: jest.fn((row: Purchase) => row), createQueryBuilder: jest.fn(() => query), + // 구매 때 유입 채우기 (UPDATE ... FROM order_inflows) + query: jest.fn(() => Promise.resolve()), }; const service = new PurchasesService(repo as unknown as Repository); - return { service, inserted, query }; + return { service, inserted, query, repo }; } describe('PurchasesService.recordOrders', () => { @@ -80,13 +82,34 @@ describe('PurchasesService.recordOrders', () => { }); it('결제 전 주문만 있으면 DB 를 부르지 않는다', async () => { - const { service, query } = setup(); + const { service, query, repo } = setup(); await expect( service.recordOrders([ order({ payments: [{ paymentStatus: 'PAYMENT_PREPARATION' }] }), ]), ).resolves.toBe(0); expect(query.execute).not.toHaveBeenCalled(); + expect(repo.query).not.toHaveBeenCalled(); + }); + + it('구매를 남긴 뒤 같은 주문번호의 구매 때 유입을 아직 비어 있는 구매에만 채운다', async () => { + const { service, repo } = setup(); + await service.recordOrders([order({}), order({ orderNo: 202610050002 })]); + expect(repo.query).toHaveBeenCalledTimes(1); + const [sql, params] = repo.query.mock.calls[0] as unknown as [ + string, + unknown[], + ]; + expect(sql).toContain('"order_inflows"'); + expect(sql).toContain('p."inflow_source" IS NULL'); + expect(params).toEqual([['202610050001', '202610050002']]); + }); + + it('구매 때 유입은 다시 볼 때 덮어쓰지 않는다 (갱신 목록에 없다)', async () => { + const { service, query } = setup(); + await service.recordOrders([order({})]); + const [columns] = query.orUpdate.mock.calls[0] as [string[]]; + expect(columns.some((column) => column.startsWith('inflow_'))).toBe(false); }); it('같은 주문이 두 번 와도 한 줄로 모은다 (ON CONFLICT 이중 갱신 방지)', async () => { diff --git a/backend/src/modules/purchases/purchases.service.ts b/backend/src/modules/purchases/purchases.service.ts index 8e9b2b8..b67c9fb 100644 --- a/backend/src/modules/purchases/purchases.service.ts +++ b/backend/src/modules/purchases/purchases.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { randomUUID } from 'crypto'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { ImwebOrderData } from '@/modules/imweb/imweb.types'; import { purchaseLines } from '@/shared/utils/order-purchase'; @@ -33,6 +33,7 @@ const CONFLICT_COLUMNS = ['order_no', 'prod_no']; /** * 유료 구매 기록 — 주문 동기화(PartnerOrderSyncService)가 아임웹 주문 한 페이지씩 넘겨준다. * 같은 주문·상품은 한 줄만 남고, 다시 보면 환불·취소 여부를 갱신한다 (여러 번 불러도 안전). + * 구매 때 유입은 주문완료 화면이 보낸 값을 주문번호로 이어 채운다 (OrderInflowsService). */ @Injectable() export class PurchasesService { @@ -81,6 +82,49 @@ export class PurchasesService { skipUpdateIfNoValuesChanged: true, }) .execute(); + await this.fillInflows([ + ...new Set([...rows.values()].map((row) => row.orderNo)), + ]); return rows.size; } + + /** 주문번호 → 주문자 이름 — 업체 실적의 비회원 구매를 누군지 보여줄 때 쓴다 */ + async ordererNames( + orderNos: readonly string[], + ): Promise> { + const unique = [...new Set(orderNos)]; + if (unique.length === 0) return new Map(); + const rows = await this.purchases.find({ + select: { orderNo: true, ordererName: true }, + where: { orderNo: In(unique) }, + }); + const names = new Map(); + for (const row of rows) { + if (row.ordererName) names.set(row.orderNo, row.ordererName); + } + return names; + } + + /** + * 주문완료 화면이 보낸 구매 때 유입(order_inflows)을 같은 주문번호의 구매에 채운다. + * 아직 유입이 없는 구매에만 채운다 — 동기화가 같은 주문을 다시 봐도 값이 바뀌지 않는다. + * 유입이 구매보다 늦게 들어와도 다음 동기화(최근 60일)에서 채워진다. + */ + private async fillInflows(orderNos: string[]): Promise { + if (orderNos.length === 0) return; + await this.purchases.query( + `UPDATE "purchases" AS p + SET "inflow_raw" = o."inflow_raw", + "inflow_source" = o."inflow_source", + "inflow_medium" = o."inflow_medium", + "inflow_campaign" = o."inflow_campaign", + "inflow_content" = o."inflow_content" + FROM "order_inflows" AS o + WHERE p."order_no" = o."order_no" + AND p."order_no" = ANY($1) + AND p."inflow_source" IS NULL + AND o."inflow_source" IS NOT NULL`, + [orderNos], + ); + } }