generated from ttipo/project-base
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f29d48fcfa | |||
| bf891d2177 | |||
| 5badf94dc7 | |||
| 1f71ce048f | |||
| 8d4adfd7f1 |
@@ -0,0 +1,57 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddOrderInflows1791258525922 implements MigrationInterface {
|
||||
name = 'AddOrderInflows1791258525922';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
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"`);
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export class AdminActivitiesController {
|
||||
@Get('inflow-stats')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'유입 집계 — 신청 때 유입(무료 신청만) 또는 가입 경로(신청·구매) 기준',
|
||||
'유입 집계 — 신청·구매 때 유입 또는 가입 경로 기준 (신청·구매 모두)',
|
||||
})
|
||||
@ApiStandardResponse(ActivityInflowStatsResponseDto, {
|
||||
description: '집계 성공',
|
||||
|
||||
@@ -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<ObjectLiteral> | 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<ObjectLiteral> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/** 주문 실적 새로고침 상태 (서버 메모리 기준 — 재시작하면 비워진다) */
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<number>,
|
||||
partnerRefs: readonly string[] = [],
|
||||
): Promise<OrderAttributionResult> {
|
||||
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<PartnerTouch | null> {
|
||||
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<PartnerTouch[]> {
|
||||
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<TouchCandidate[]> {
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -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<string, string[]>(),
|
||||
) {
|
||||
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<void> {
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
await this.orderInflows.record(dto);
|
||||
}
|
||||
}
|
||||
@@ -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<InsertQueryMock, []>;
|
||||
values: jest.Mock<InsertQueryMock, [OrderInflow]>;
|
||||
orIgnore: jest.Mock<InsertQueryMock, []>;
|
||||
execute: jest.Mock<Promise<void>, []>;
|
||||
}
|
||||
|
||||
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<OrderInflow>[])),
|
||||
delete: jest.fn(() => Promise.resolve({ affected: 0 })),
|
||||
};
|
||||
const service = new OrderInflowsService(
|
||||
repo as unknown as Repository<OrderInflow>,
|
||||
{ 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<OrderInflow>,
|
||||
private readonly config: ImwebConfig,
|
||||
) {}
|
||||
|
||||
/** 같은 주문번호가 다시 오면 처음 값을 그대로 둔다 (새로고침·중복 전송에도 안전) */
|
||||
async record(dto: CreateOrderInflowDto): Promise<void> {
|
||||
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<Map<string, string[]>> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<Purchase>);
|
||||
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 () => {
|
||||
|
||||
@@ -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<Map<string, string>> {
|
||||
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<string, string>();
|
||||
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<void> {
|
||||
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],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,10 @@ export function buildEntryUrl(siteUrl: string, code: string): string {
|
||||
|
||||
/** UTM 파라미터 접두어 — imweb-header-top.html 의 UTM 스크립트가 읽는다 */
|
||||
const UTM_PARAM_PREFIX = 'utm_';
|
||||
/** 업체 링크로 온 방문의 매체 값 */
|
||||
/**
|
||||
* 업체 링크로 온 방문의 매체 값.
|
||||
* imweb-header-top.html 의 PARTNER_MEDIUM 과 같아야 한다 — 이 값이면 UTM 스크립트가 최초유입을 업체로 덮어쓴다
|
||||
*/
|
||||
export const PARTNER_UTM_MEDIUM = 'partner';
|
||||
|
||||
/**
|
||||
|
||||
+78
-54
@@ -1,37 +1,37 @@
|
||||
# 유료 구매 추적 — 현황과 다음 작업
|
||||
# 유료 구매 추적 — 현황과 배포
|
||||
|
||||
> 작성: 2026-10-06 · 다음 작업을 이어갈 때 먼저 읽는 문서.
|
||||
> 운영 사용 안내는 [`imweb/README.md`](../imweb/README.md) 의 "신청·구매 내역" · "유료 구매 기록" · "업체 링크" 절.
|
||||
> 운영 사용 안내는 [`imweb/README.md`](../imweb/README.md) 의 "신청·구매 내역" · "유료 구매 기록" · "구매 때 유입" · "업체 링크" 절.
|
||||
|
||||
## 0. 한눈에
|
||||
|
||||
| 항목 | 상태 |
|
||||
| --- | --- |
|
||||
| 유료 구매 목록 (회원·비회원) | **구현 완료 · 운영 미배포** |
|
||||
| 신청·구매 통합 화면 | **구현 완료 · 운영 미배포** |
|
||||
| 유료 구매 목록 (회원·비회원) | 구현 완료 · `main` 머지 · **운영 미배포** |
|
||||
| 신청·구매 통합 화면 | 구현 완료 · `main` 머지 · **운영 미배포** |
|
||||
| 업체 링크 구매 실적 (회원) | 운영 중 (이전 배포분) |
|
||||
| 구매 때 유입경로 | **미구현** — 다음 작업 (4절) |
|
||||
| 비회원 구매의 업체 실적 | **미구현** — 다음 작업 (4절) |
|
||||
| 업체 링크 우선 (신청·구매 때 유입) | **구현 완료 · 푸시·머지 전** (`feat/purchase-inflow`, 4.5절) |
|
||||
| 구매 때 유입경로 | **구현 완료 · 푸시·머지 전** (`feat/purchase-inflow`, 4절) |
|
||||
| 비회원 구매의 업체 실적 | **구현 완료 · 푸시·머지 전** (`feat/purchase-inflow`, 4절) |
|
||||
|
||||
브랜치: `feat/purchase-history` (커밋 `ab7ef37`, 푸시·머지 전).
|
||||
`fix/admin-ui-tweaks` 에서 따왔으므로 그 브랜치의 미푸시 커밋 3개(카톡 버튼 문구·링크 칸 줄 맞춤·직전 유입 표시 제거)를 함께 담고 있다.
|
||||
`feat/purchase-history` 는 `main` 에 머지·푸시됐다 (`9e6cae7`). 구매 때 유입 작업은 그 위의
|
||||
`feat/purchase-inflow` 브랜치에 있다 (2026-10-06 기준 푸시·머지 전).
|
||||
|
||||
---
|
||||
|
||||
## 1. 지금 구매에 대해 알 수 있는 것
|
||||
|
||||
아임웹 주문 API(`GET /orders`)에는 **손님이 어디서 왔는지(UTM)가 없다.**
|
||||
무료 신청은 신청 완료 화면이 그 순간의 유입을 보내지만, 구매에는 그런 화면 연동이 아직 없다.
|
||||
그래서 무료 신청처럼 주문완료 화면이 그 순간의 유입을 보내고, 주문 동기화가 주문번호로 잇는다 (4절).
|
||||
|
||||
| 항목 | 알 수 있나 | 근거 · 조건 |
|
||||
| --- | --- | --- |
|
||||
| 누가 샀는지 | O | 회원: 회원 정보 / 비회원: 주문서의 주문자 이름·이메일·연락처 |
|
||||
| 무엇을·언제 | O | 주문·상품당 한 줄, 주문 시각(wtime) |
|
||||
| 환불·취소 여부 | O | 최근 60일 주문만 다시 봐서 반영 |
|
||||
| 업체 링크로 왔는지 | 일부 | **회원만.** 링크를 누른 브라우저에서 로그인 → 30일 안에 같은 상품 구매 |
|
||||
| 업체 링크로 왔는지 | O | 회원: 링크를 누른 브라우저에서 로그인 / 비회원: 주문완료 화면이 보낸 클릭 ID. 30일 안에 같은 상품 구매 |
|
||||
| 가입 경로 | 회원만 | 가입 때 경로라 이번 구매 경로와 다를 수 있다 |
|
||||
| 구매 때 유입경로 | **X** | 주문 데이터에 없다 |
|
||||
| 비회원 구매의 업체 | **X** | 회원 코드가 없어 클릭과 이을 수 없다 |
|
||||
| 구매 때 유입경로 | O (연동 후 주문) | 주문완료 화면이 보낸 값. 다른 기기에서 결제·스크립트 차단이면 없음 |
|
||||
|
||||
---
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
| `GET /api/admin/activities/groups` | 상품별·업체별 신청·구매 수 | `by`(product·partner), `from`/`to` |
|
||||
| `GET /api/admin/activities/inflow-stats` | 유입 집계 | `basis`(apply·join), `groupBy`, 목록 필터와 동일 |
|
||||
|
||||
- `basis=apply`(신청 때 유입)는 **무료 신청만** 센다. 구매는 `join`(가입 경로)으로만 집계된다 — 비회원은 값 없음.
|
||||
- `basis=apply` 는 처음엔 무료 신청만 셌으나, 4절 이후 **신청·구매 때 유입**(구매 포함)이다. `join` 은 가입 경로 — 비회원은 값 없음.
|
||||
- 구매 수는 `is_valid` 만, 환불·취소는 `cancelledCount` 로 따로.
|
||||
- **예전 `/api/admin/applications` (목록·groups·inflow-stats·products) 는 삭제했다.** 위젯과 백엔드를 함께 배포해야 한다.
|
||||
- 무료 신청 수집(`POST /api/applications`, 카톡 클릭)은 그대로다. `inflow_last_*` 는 저장만 하고 화면에서 쓰지 않는다.
|
||||
@@ -99,7 +99,7 @@
|
||||
|
||||
- 메뉴 "신청 내역" → **"신청·구매"**. 종류 [전체·무료 신청·유료 구매], 상품별/업체별 표에 무료 신청·카톡 버튼 클릭·유료 구매(환불 N) 칸.
|
||||
- 목록: 구분 배지(무료 신청 / 유료 구매 / 환불·취소), 비회원은 주문자 이름 + "비회원". 상세 패널에 주문번호·상태·주문자.
|
||||
- 유료 구매만 볼 때 유입 집계는 가입 경로로 고정(신청 때 유입 버튼 비활성).
|
||||
- (4절 이전) 유료 구매만 볼 때 유입 집계는 가입 경로로 고정했다 — 4절에서 풀었다.
|
||||
- 개요: "최근 7일 무료 신청" · "최근 7일 유료 구매" 카드, "최근 신청·구매" 목록.
|
||||
|
||||
### 2.6 파일
|
||||
@@ -127,61 +127,83 @@ imweb/imweb-members.html · imweb/README.md
|
||||
|
||||
## 3. 배포 순서 (아직 안 함)
|
||||
|
||||
1. `feat/purchase-history` 머지 → 운영 서버에서 `docker compose --env-file .env.production up -d --build`
|
||||
(마이그레이션 자동 실행, 에러 코드 변경 없음)
|
||||
2. **바로 이어서** `imweb/imweb-members.html` 을 아임웹 관리자 페이지 코드에 다시 붙여 넣기
|
||||
(예전 조회 경로를 지웠으므로 한쪽만 바뀌면 RES_001 "찾을 수 없음")
|
||||
`feat/purchase-history`(main 머지 완료)와 4절 작업을 **한 번에** 배포한다고 보고 적었다.
|
||||
|
||||
1. 4절 브랜치 머지 → 운영 서버에서 `docker compose --env-file .env.production up -d --build`
|
||||
(마이그레이션 `AddPurchases` · `AddOrderInflows` 자동 실행, 에러 코드 변경 없음)
|
||||
2. **바로 이어서** 아임웹에 두 파일을 다시 붙여 넣는다
|
||||
- `imweb/imweb-members.html` → 관리자 페이지 코드 (예전 조회 경로를 지웠으므로 한쪽만 바뀌면 RES_001 "찾을 수 없음")
|
||||
- `imweb/imweb-header-top.html` → Header Code (업체 링크 우선 v1.2 · 결제창 리퍼러 제외 v1.3 · 구매 때 유입 전송)
|
||||
3. 업체 링크 메뉴의 **구매 집계 새로고침** → 1년치 주문 동기화 → 신청·구매 화면에 구매가 보이는지 확인
|
||||
4. 비회원 구매 한 건을 열어 **주문자 이름·연락처가 실제로 채워지는지** 확인 (5절 첫 항목)
|
||||
5. 테스트 주문 한 건 → 주문완료 화면 → 다음 동기화(또는 새로고침) 후 그 구매의 상세 패널에 **구매 때 유입**이 보이는지 확인
|
||||
(브라우저 개발자 도구 네트워크 탭에서 `order-inflows` 가 204 인지도 볼 수 있다)
|
||||
|
||||
---
|
||||
|
||||
## 4. 다음 작업 — 주문완료 화면에서 구매 때 유입·업체 잡기
|
||||
## 4. 구매 때 유입·비회원 업체 실적 (`feat/purchase-inflow`, 푸시·머지 전)
|
||||
|
||||
### 4.1 목표
|
||||
|
||||
- 구매 때 유입(UTM)을 남겨 신청 때 유입처럼 집계한다.
|
||||
- **비회원 구매도 업체 실적으로 잡는다** — 회원 대신 주문번호로 잇는다.
|
||||
|
||||
### 4.2 방식 (추천안)
|
||||
|
||||
무료 신청 완료 위젯과 같은 방식이다.
|
||||
### 4.1 흐름
|
||||
|
||||
```
|
||||
주문완료 화면 ── 스크립트가 { 주문번호, MG_UTM.peek().value, MG_PARTNER.refs() } 를 보낸다
|
||||
│ POST /api/order-inflows (공개 · 엄격한 rate limit · 이름은 api-design 규칙에 맞춰 확정)
|
||||
주문완료 화면 (/shop_payment_complete?order_no=…)
|
||||
│ Header Code '구매 때 유입 전송' → { orderNo, inflow: MG_UTM.peek().value, partnerRefs: MG_PARTNER.refs() }
|
||||
│ POST /api/order-inflows (공개 · 1분 10회 · 항상 204 · 같은 주문번호는 처음 값만)
|
||||
▼
|
||||
order_inflows (가칭) order_no 유일, inflow raw/파싱값, partner refs, received_at
|
||||
│ 주문 동기화가 같은 order_no 의 구매를 가져올 때 매칭
|
||||
▼
|
||||
purchases 에 구매 때 유입 컬럼(가칭 inflow_source 등) 채움
|
||||
partner_conversions: 회원·비회원 모두 refs 로 귀속 (지금은 회원 touch 로만)
|
||||
order_inflows order_no PK, inflow raw/파싱값, partner_refs varchar(16)[], received_at
|
||||
│ 주문 동기화(PartnerOrderSyncService)가 페이지마다
|
||||
├─▶ PurchasesService.recordOrders → UPDATE purchases … FROM order_inflows (inflow_source 가 빈 구매에만)
|
||||
└─▶ PartnerAttributionService.attributeOrder(order, linked, refs)
|
||||
후보 = 회원 클릭 연결(partner_touches) ∪ refs 의 실제 클릭(사용 중인 그 상품 링크) → 30일 안 마지막 클릭
|
||||
→ partner_conversions (비회원이면 member_code null)
|
||||
동기화가 끝까지 성공하면 90일 지난 order_inflows 를 지운다
|
||||
```
|
||||
|
||||
- 스크립트 위치: Header Code(`imweb-header-top.html`)가 주문완료 페이지를 감지하거나, 주문완료 페이지 전용 코드 위젯.
|
||||
UTM·업체 클릭 ID 는 이미 Header Code 가 보관하므로 읽기만 하면 된다.
|
||||
- 위조 대응: 공개 API 라 막을 수는 없다. **아임웹에 실제로 있는 주문번호에만** 붙인다(동기화 시점 매칭이라 자연히 걸러짐).
|
||||
같은 주문번호에 두 번 오면 첫 값만, 오래 매칭되지 않은 행은 정리한다.
|
||||
- 무통장입금: 주문완료 시점에 받아두고, 입금 확인 후 구매로 잡힐 때 붙는다.
|
||||
- 귀속 규칙 변경 검토: 주문완료에서 온 refs 를 회원 touch 보다 우선할지, 30일 기준을 그대로 쓸지.
|
||||
- 화면: `inflow-stats` 의 `basis=apply` 를 "신청·구매 때 유입"으로 넓히고, 목록의 "신청 때 유입" 칸에 구매도 표시.
|
||||
- 주문완료 주소의 `order_no` 는 아임웹 API `orderNo` 와 같다 (2026-10-06 관리자 화면에서 확인).
|
||||
- 유입 기준은 무료 신청과 같다 — 첫 경로(2일 보관), 업체 링크로 오면 그 업체가 우선 (Header Code v1.2).
|
||||
- 결제창(카카오페이·네이버페이·카드사)에서 돌아올 때의 리퍼러는 유입으로 치지 않는다 — `AUTH_PATH` 에 `shop_payment` 추가 (v1.3).
|
||||
이전에는 `pay.kakao.com` 이 '카카오 소셜', `pay.naver.com` 이 '네이버 검색' 유입으로 잡힐 수 있었다.
|
||||
- 관리자 화면: 유입 기준 "신청 때 유입" → **"신청·구매 때 유입"**, 구매 행·상세 패널에 구매 때 유입 표시,
|
||||
업체 링크 실적 상세에 비회원 구매는 주문자 이름 + "비회원 구매".
|
||||
- 0원 주문은 지금처럼 둔다 (2026-10-06 결정).
|
||||
|
||||
### 4.3 시작 전에 확인할 것
|
||||
### 4.2 파일
|
||||
|
||||
- [ ] **주문완료 페이지 주소와 주문번호 위치** — 주소창 파라미터인지 화면 DOM 인지. 테스트 주문 한 번으로 확인.
|
||||
(아임웹 API 의 `orderNo` 와 같은 값인지도 확인)
|
||||
- [ ] UTM 보관 기간 — 지금 `TTL_DAYS: 2` (`imweb-header-top.html` 49행). 광고 후 며칠 뒤 사는 경우가 많으면 늘릴지 결정.
|
||||
늘리면 무료 신청의 신청 때 유입에도 같이 영향
|
||||
- [ ] 비회원 구매 비중 — 많을수록 이 작업의 효과가 크다 (2절 배포 후 화면에서 확인 가능)
|
||||
```
|
||||
backend/src/modules/purchases/order-inflow.entity.ts order_inflows
|
||||
backend/src/modules/purchases/order-inflows.{controller,service}.ts (+spec) POST /api/order-inflows · 보관·정리
|
||||
backend/src/modules/purchases/purchases.service.ts (+spec) 구매 때 유입 채우기 · 주문자 이름 조회
|
||||
backend/src/modules/purchases/purchase.entity.ts inflow_* 칸
|
||||
backend/src/modules/partners/partner-attribution.service.ts (+spec) 비회원 귀속 (refs)
|
||||
backend/src/modules/partners/partner-order-sync.service.ts (+spec) refs 전달 · 정리
|
||||
backend/src/modules/partners/entities/partner-conversion.entity.ts member_code nullable
|
||||
backend/src/modules/partners/partner-links.service.ts · dto 비회원 실적에 주문자 이름
|
||||
backend/src/modules/activities/ 구매도 신청·구매 때 유입으로 조회·집계
|
||||
backend/src/migrations/1791258525922-AddOrderInflows.ts
|
||||
imweb/imweb-header-top.html · imweb/imweb-members.html · imweb/README.md
|
||||
```
|
||||
|
||||
### 4.4 작업 체크리스트
|
||||
### 4.3 검증한 것
|
||||
|
||||
- [ ] 엔티티·마이그레이션 (`order_inflows`, `purchases` 유입 컬럼) — 생성된 SQL 검토
|
||||
- [ ] 공개 엔드포인트 DTO 검증 + `@Throttle` (신청 기록처럼)
|
||||
- [ ] 동기화 매칭 + 비회원 업체 귀속 + 단위 테스트
|
||||
- [ ] `activities` 유입 집계·목록 확장
|
||||
- [ ] 아임웹 스크립트 + `imweb/README.md`
|
||||
- [ ] 필요하면 에러 코드 등록 → `npm run sync:error-codes`
|
||||
- 백엔드 테스트 201개 통과 (새 테스트 12개), lint·build 통과.
|
||||
- 로컬 Postgres: 공개 API(첫 값만 저장·형식 오류 VAL_001) → 구매 저장 시 유입 채움 → 비회원 주문이 클릭 ID 로
|
||||
업체에 귀속 → 목록·유입 필터·집계·업체 실적 상세(주문자 이름)까지 확인. 넣은 데이터는 지웠다.
|
||||
- Header Code 를 Node 로 흉내 내 확인: 결제창 리퍼러 무시, 업체 링크 클릭 ID 전송, 새로고침 시 재전송 없음,
|
||||
주문완료가 아닌 페이지·잘못된 주문번호면 보내지 않음.
|
||||
- **실제 아임웹 주문완료 화면에서는 아직 돌려보지 않았다** (3절 5번).
|
||||
|
||||
### 4.4 남은 확인
|
||||
|
||||
- [ ] UTM 보관 기간 — 지금 `TTL_DAYS: 2`. 광고 후 며칠 뒤 사는 경우가 많으면 늘릴지 결정 (무료 신청에도 같이 영향)
|
||||
- [ ] 비회원 구매 비중 — 배포 후 화면에서 확인
|
||||
- [ ] 예전 신청 기록 보정 — v1.2 이전 신청은 업체 링크로 왔어도 첫 경로가 광고일 수 있다
|
||||
(`inflow_last_medium='partner'` 인데 첫 유입이 다른 행). 보정할지 결정
|
||||
|
||||
### 4.5 업체 링크 우선 (같은 브랜치)
|
||||
|
||||
업체 링크(`utm_medium=partner`)로 들어오면 보관 중인 첫 경로를 그 업체로 바꾼다 (Header Code v1.2).
|
||||
광고로 가입 → 업체 링크로 신청·구매하면 가입 경로는 광고, 신청·구매 때 유입은 업체로 남는다.
|
||||
여러 업체를 거치면 마지막 업체 — 업체 실적의 마지막 클릭 규칙과 같다.
|
||||
|
||||
---
|
||||
|
||||
@@ -193,4 +215,6 @@ partner_conversions: 회원·비회원 모두 refs 로 귀속 (지금은 회원
|
||||
결제 수단 `FREE` 주문을 뺄지 결정 (업체 실적 판정 `evaluateOrder` 도 같이 바꿔야 수가 맞는다).
|
||||
- 60일보다 오래된 주문의 환불은 반영되지 않는다 (서버 재시작 직후 1년치 실행 때만 다시 본다).
|
||||
- 휴대폰에서 링크·광고를 보고 PC 에서 사면 유입·업체가 이어지지 않는다 (무료 신청도 같다).
|
||||
- 구매 때 유입은 공개 API 로 받으므로, 주문번호를 아는 사람이 먼저 가짜 값을 보내면 그 값이 남는다
|
||||
(실제 주문에만 붙고, 업체 실적은 실제로 있는 클릭만 인정한다).
|
||||
- 구매자의 가입 경로는 "이번 구매 경로"가 아니다 — 화면 문구에서 혼동하지 않게 유지.
|
||||
|
||||
+36
-6
@@ -79,8 +79,9 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
|
||||
|
||||
- 종류 [전체 · 무료 신청 · 유료 구매] 로 목록을 거르고, 위쪽 표에서 상품별·업체별 신청 수·구매 수를 함께 본다
|
||||
- 구매 수는 결제완료만 센다 — 환불·취소된 구매는 목록에 '환불·취소'로 남고, 표에는 '환불 N'으로 따로 적는다
|
||||
- 유입 집계: '신청 때 유입'은 무료 신청에만 있다 (구매 시점 유입은 아임웹 주문에 남지 않는다).
|
||||
구매는 '가입 경로'(회원이 가입할 때 기록된 경로)로만 볼 수 있고, 비회원 구매는 값이 없다
|
||||
- 유입 집계: '신청·구매 때 유입'(신청 완료·주문완료 화면이 남긴 그 순간의 경로)과
|
||||
'가입 경로'(회원이 가입할 때 기록된 경로) 두 기준으로 본다.
|
||||
주문완료 화면 연동 전 구매는 구매 때 유입이 없고, 비회원 구매는 가입 경로가 없다
|
||||
|
||||
## 강의 신청 기록 (신청 완료 화면 → 신청·구매 내역)
|
||||
|
||||
@@ -99,6 +100,9 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
|
||||
| 가입 경로 | 그 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외) | naver |
|
||||
|
||||
다른 기기에서 신청하면 신청 때 유입은 direct 로 남는다.
|
||||
- **업체 링크는 첫 경로보다 우선한다** — 업체 링크(`utm_medium=partner`)로 들어오면 보관 중인 첫 경로를
|
||||
그 업체로 바꾼다. 예) 어제 광고로 들어와 가입 → 오늘 업체 링크로 신청: 가입 경로 광고, 신청 때 유입 업체.
|
||||
여러 업체 링크를 거치면 마지막 업체가 남는다 (업체 실적의 마지막 클릭 규칙과 같다)
|
||||
- 백엔드는 아임웹에 실제로 있는 회원인지 확인하고(동기화 전 신규 가입자는 아임웹 API 로 직접 확인),
|
||||
회원·상품당 한 건만 남긴다. 이름·연락처는 브라우저 값이 아니라 아임웹 회원 정보를 쓴다
|
||||
- 오픈카톡 버튼 클릭은 `POST /api/applications/{id}/kakao-clicks` 로 처음 한 번만 남는다
|
||||
@@ -117,7 +121,29 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
|
||||
- 서버가 뜬 뒤 첫 실행은 **최근 1년**, 그 뒤로는 **최근 60일**만 다시 본다 (60일보다 오래된 주문의 환불은 반영되지 않는다)
|
||||
- 사은품 품목은 넣지 않는다. 0원 주문도 결제완료면 구매로 남는다
|
||||
- 금액은 관리하지 않는다
|
||||
- 구매 때 유입경로는 아직 없다 — 현황·다음 작업은 [`docs/purchase-tracking.md`](../docs/purchase-tracking.md)
|
||||
- 구매 때 유입은 아래 '구매 때 유입' 절 — 현황·한계는 [`docs/purchase-tracking.md`](../docs/purchase-tracking.md)
|
||||
|
||||
## 구매 때 유입 (주문완료 화면 → 구매 기록)
|
||||
|
||||
아임웹 주문에는 손님이 어디서 왔는지가 없어서, Header Code(`imweb-header-top.html` 의 '구매 때 유입 전송')가
|
||||
주문완료 화면(`/shop_payment_complete?order_no=주문번호`)에서 `POST /api/order-inflows` 로 보낸다.
|
||||
|
||||
```
|
||||
주문완료 화면 ── { orderNo, inflow: MG_UTM.peek().value, partnerRefs: MG_PARTNER.refs() }
|
||||
│ POST /api/order-inflows (공개 · 1분 10회 · 항상 204 · 같은 주문번호는 처음 값만)
|
||||
▼
|
||||
order_inflows ── 주문 동기화(1시간마다)가 같은 주문번호의 구매에 붙인다
|
||||
├─▶ purchases.inflow_* 구매 때 유입 (비어 있는 구매에만 — 한 번 붙으면 바뀌지 않는다)
|
||||
└─▶ partner_conversions 업체 실적 — 회원 클릭 연결 + 주문완료 클릭 ID 중 마지막 클릭 (비회원도)
|
||||
```
|
||||
|
||||
- 주소의 `order_no` 가 아임웹 API 의 `orderNo` 와 같은 값이다 (관리자 화면에서 확인함)
|
||||
- 유입 기준은 무료 신청과 같다 — 처음 들어온 경로(2일 보관), 단 업체 링크로 오면 그 업체가 우선
|
||||
- 결제창(카카오페이·네이버페이·카드사)에서 돌아올 때의 리퍼러는 유입으로 치지 않는다 (`/shop_payment*` 경로 제외, v1.3)
|
||||
- 무통장입금은 주문완료 때 받아 두었다가 입금이 확인돼 구매로 잡힐 때 붙는다
|
||||
- 90일 동안 주문과 이어지지 않은 값(없는 주문번호·끝내 결제되지 않은 주문)은 동기화가 지운다
|
||||
- 한계: 다른 기기에서 결제하거나 스크립트가 막힌 브라우저면 남지 않는다. 공개 API 라 주문번호를 아는 사람은
|
||||
먼저 가짜 값을 보낼 수 있다 (실제 주문에만 붙고, 업체 실적은 실제로 있는 클릭만 인정한다)
|
||||
|
||||
## 업체 링크 (업체별 신청·구매 추적)
|
||||
|
||||
@@ -138,19 +164,23 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
|
||||
│ ② 로그인 회원이 확인되면 "이 회원이 이 클릭으로 들어왔다"를 POST /api/partner-touches 로 알린다
|
||||
▼
|
||||
신청: 신청 완료 위젯이 신청 기록에 클릭 ID 를 함께 보낸다 → 바로 귀속
|
||||
구매: 구매 기록과 같은 주문 동기화(1시간마다)가 회원 주문의 결제완료 상품을 귀속, 환불·취소는 집계에서 뺀다
|
||||
구매: 구매 기록과 같은 주문 동기화(1시간마다)가 결제완료 상품을 귀속 (회원은 클릭 연결, 비회원은 주문완료 화면의 클릭 ID), 환불·취소는 집계에서 뺀다
|
||||
```
|
||||
|
||||
- **귀속 규칙**: 신청·구매 직전 30일 안에 누른 **그 상품의** 업체 링크 중 마지막 것 (마지막 클릭)
|
||||
- **구매 수**: 결제완료 주문만 센다. 전액 환불되거나 해당 상품 품목이 모두 취소·반품되면 빠진다. 금액은 관리하지 않는다
|
||||
- **링크는 지우지 않고 중지**한다 — 중지해도 상품 페이지로 이동은 되지만 클릭·실적은 쌓이지 않는다.
|
||||
링크가 있는 업체는 삭제할 수 없다 (실적 기록 보존)
|
||||
- 업체 링크로 온 방문은 UTM 스크립트가 업체 UTM 으로 기록하므로, 신청 때 유입·가입 경로 집계에도 업체 이름이 나온다
|
||||
- 업체 링크로 온 방문은 UTM 스크립트가 업체 UTM 으로 기록하므로, 신청 때 유입·가입 경로 집계에도 업체 이름이 나온다.
|
||||
먼저 광고 등으로 들어와 첫 경로가 남아 있어도 업체로 덮어쓴다 (Header Code v1.2)
|
||||
- 단, 관리자가 이동 주소에 직접 UTM 을 넣은 링크는 `utm_medium=partner` 가 아니므로 덮어쓰지 않는다
|
||||
- 업체 실적은 **그 상품의** 링크만 세지만 유입은 상품을 가리지 않는다 —
|
||||
A 상품 업체 링크로 들어와 B 상품을 신청하면 신청 때 유입은 업체, 업체 칸은 비어 있다
|
||||
- 이동 주소는 아임웹 사이트(www 유무 무관) 안만 허용한다 — 단축 링크가 피싱에 쓰이지 않게.
|
||||
사이트 주소는 `IMWEB_SITE_URL`, 비우면 `IMWEB_ADMIN_PAGE_URL` 의 origin
|
||||
- 한계
|
||||
- 링크를 누른 브라우저와 신청·구매한 브라우저가 다르면(휴대폰 → PC) 추적되지 않는다
|
||||
- 비회원 주문은 회원과 이을 수 없어 세지 않는다
|
||||
- 비회원 주문은 주문완료 화면이 클릭 ID 를 보냈을 때만 센다 (위 '구매 때 유입')
|
||||
- 공개 페이지에서 오는 기록이라 위조를 완전히 막을 수는 없다 (실제 아임웹 회원·실제 클릭인지만 확인)
|
||||
- 60일보다 오래된 구매의 환불은 반영되지 않는다
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
</script>
|
||||
|
||||
<!-- ============================================================
|
||||
[자본주의가이드] UTM 기반 회원가입 유입경로 자동추적 v1.1
|
||||
[자본주의가이드] UTM 기반 회원가입 유입경로 자동추적 v1.3
|
||||
v1.2: 업체 링크(utm_medium=partner) 방문은 보관 중인 최초유입을 덮어쓴다
|
||||
v1.3: 결제 페이지(/shop_payment*)에서는 결제창 리퍼러를 유입으로 기록하지 않는다
|
||||
설치 위치: 아임웹 관리자 > 환경설정 > SEO(검색엔진최적화)
|
||||
> 고급 > 공통 코드 삽입 > "Header Code"
|
||||
대상 필드: 회원가입 사용자 정의 항목 "유입경로(필수)" = join_form_64454
|
||||
@@ -51,6 +53,7 @@
|
||||
HP_ENABLED: true, // 홈페이지 항목에도 유입정보 기록 (Open API homePage 로 조회용)
|
||||
HP_LABEL: '홈페이지', // 홈페이지 입력칸을 name/id로 못 찾을 때 라벨로 재탐색
|
||||
OWN_HOST: 'moneyguider.co.kr',
|
||||
PARTNER_MEDIUM: 'partner', // 업체 링크 UTM 의 utm_medium (백엔드 PARTNER_UTM_MEDIUM 과 같아야 한다)
|
||||
DEBUG: false // true로 바꾸면 콘솔에 로그 출력
|
||||
};
|
||||
|
||||
@@ -158,11 +161,14 @@
|
||||
[/(^|\.)(tistory|brunch|velog|medium)\./i, 'blog', 'referral']
|
||||
];
|
||||
|
||||
/* 인증·가입 관련 페이지.
|
||||
/* 인증·가입·결제 관련 페이지.
|
||||
카카오/네이버 소셜 로그인은 외부 인증 도메인을 왕복하는데, 돌아올 때의
|
||||
리퍼러(kauth.kakao.com 등)를 새 유입으로 오해하면 원래 유입원이 가려진다.
|
||||
결제도 같다 — 결제창(카카오페이 pay.kakao.com, 네이버페이 pay.naver.com, 카드사 등)에서
|
||||
주문완료(/shop_payment_complete)로 돌아올 때의 리퍼러가 '카카오 소셜'·'네이버 검색'
|
||||
유입으로 잡혀 구매 때 유입을 가린다.
|
||||
이 경로들에서는 URL에 UTM이 직접 있을 때만 기록한다. */
|
||||
var AUTH_PATH = /^\/(oauth|site_join|login|logout|member|social|join)/i;
|
||||
var AUTH_PATH = /^\/(oauth|site_join|login|logout|member|social|join|shop_payment)/i;
|
||||
|
||||
/* 소셜 로그인 인증 도메인.
|
||||
위 AUTH_PATH는 '인증 페이지에 있을 때'를 막지만, 인증을 마치고
|
||||
@@ -253,6 +259,8 @@
|
||||
if (t.ref && AUTH_REF.test(t.ref) && !explicitUtm) return;
|
||||
|
||||
var isDirect = (t.src === 'direct');
|
||||
// 업체 링크로 온 방문 — 업체 단축 링크가 붙인 UTM 이 주소에 실제로 있을 때만
|
||||
var isPartner = explicitUtm && t.med === CFG.PARTNER_MEDIUM;
|
||||
|
||||
// 최종유입: 실제 외부 유입일 때만 갱신 (사이트 내부 이동으로 덮어쓰지 않음)
|
||||
if (!isDirect) saveTouch(K_LAST, t);
|
||||
@@ -260,6 +268,10 @@
|
||||
var prev = readTouch(K_FIRST);
|
||||
if (!prev) {
|
||||
saveTouch(K_FIRST, t); // 최초 1회 (또는 보관기간 만료 후 재시작)
|
||||
} else if (isPartner) {
|
||||
// 업체 링크는 이전 유입(광고 등)보다 우선한다 — 신청 때 유입·가입 경로가
|
||||
// 업체 실적(가장 최근에 누른 업체 링크로 귀속)과 같은 업체를 가리키게 한다
|
||||
saveTouch(K_FIRST, t);
|
||||
} else if (!isDirect && prev.src === 'direct') {
|
||||
saveTouch(K_FIRST, t); // 이전 기록이 direct였다면 실제 유입원으로 승격
|
||||
}
|
||||
@@ -648,4 +660,59 @@
|
||||
window.setTimeout(poll, POLL_MS);
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- ============================================================
|
||||
[자본주의가이드] 구매 때 유입 전송 v1.0
|
||||
위 두 스크립트(MG_UTM · MG_PARTNER) 뒤에 있어야 한다.
|
||||
|
||||
주문완료 화면(/shop_payment_complete?order_no=주문번호)에서 주문번호와
|
||||
그 순간의 유입정보(MG_UTM.peek().value), 업체 링크 클릭 ID(MG_PARTNER.refs())를
|
||||
서버에 한 번 보낸다.
|
||||
→ 서버가 아임웹 주문 동기화(1시간마다) 때 같은 주문번호의 구매에 붙인다.
|
||||
구매 때 유입이 남고, 비회원 구매도 업체 링크 실적으로 잡힌다.
|
||||
무통장입금은 입금이 확인돼 구매로 잡힐 때 붙는다.
|
||||
같은 주문번호는 서버가 처음 값만 남기므로 새로고침해도 안전하다.
|
||||
============================================================ -->
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var API_BASE = 'https://moneyguide.wandergo.net/api'; // 위 업체 링크 추적과 같은 값
|
||||
var COMPLETE_PATH = /^\/shop_payment_complete\/?$/; // 아임웹 주문완료 페이지
|
||||
var ORDER_PARAM = 'order_no'; // 아임웹 API 의 orderNo 와 같은 값
|
||||
var ORDER_NO_PATTERN = /^\d{1,30}$/; // 백엔드 ORDER_NO_PATTERN 과 같아야 한다
|
||||
var INFLOW_MAX = 1000;
|
||||
var K_SENT = 'mg_order_sent'; // 이 탭에서 이미 보낸 주문번호 (새로고침 때 다시 보내지 않게)
|
||||
|
||||
function ssGet(k) { try { return sessionStorage.getItem(k); } catch (e) { return null; } }
|
||||
function ssSet(k, v) { try { sessionStorage.setItem(k, v); } catch (e) { } }
|
||||
|
||||
try {
|
||||
if (!COMPLETE_PATH.test(location.pathname)) return;
|
||||
var orderNo = new URLSearchParams(location.search).get(ORDER_PARAM);
|
||||
if (!orderNo || !ORDER_NO_PATTERN.test(orderNo)) return;
|
||||
if (typeof window.fetch !== 'function' || ssGet(K_SENT) === orderNo) return;
|
||||
|
||||
var body = { orderNo: orderNo };
|
||||
try {
|
||||
var peeked = window.MG_UTM && window.MG_UTM.peek();
|
||||
var value = peeked && peeked.value;
|
||||
if (typeof value === 'string' && value) body.inflow = value.slice(0, INFLOW_MAX);
|
||||
} catch (e) { }
|
||||
try {
|
||||
var refs = window.MG_PARTNER && window.MG_PARTNER.refs();
|
||||
if (refs && refs.length) body.partnerRefs = refs;
|
||||
} catch (e) { }
|
||||
|
||||
window.fetch(API_BASE + '/order-inflows', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'omit',
|
||||
body: JSON.stringify(body)
|
||||
}).then(function (res) {
|
||||
if (res.ok) ssSet(K_SENT, orderNo); // 실패하면 새로고침 때 다시 보낸다
|
||||
}).catch(function () { });
|
||||
} catch (e) { }
|
||||
})();
|
||||
</script>
|
||||
+21
-23
@@ -476,10 +476,10 @@
|
||||
<div class="mgm-grid-2">
|
||||
<section class="mgm-card">
|
||||
<div class="mgm-card-head">
|
||||
<h3>신청 유입 상위</h3>
|
||||
<span class="mgm-muted">최근 30일 · 신청 때 유입 기준</span>
|
||||
<h3>신청·구매 유입 상위</h3>
|
||||
<span class="mgm-muted">최근 30일 · 신청·구매 때 유입 기준</span>
|
||||
<span class="mgm-spacer"></span>
|
||||
<button type="button" class="mgm-link" data-goto="applications" data-app-preset="30" data-app-kind="application">신청·구매 →</button>
|
||||
<button type="button" class="mgm-link" data-goto="applications" data-app-preset="30">신청·구매 →</button>
|
||||
</div>
|
||||
<div class="mgm-bars" data-role="ov-app-bars"><div class="mgm-muted">불러오는 중…</div></div>
|
||||
</section>
|
||||
@@ -576,7 +576,7 @@
|
||||
</div>
|
||||
<div class="mgm-row" style="margin-bottom:12px">
|
||||
<div class="mgm-seg" role="group" aria-label="유입 기준">
|
||||
<button type="button" data-app-basis="apply" class="is-active" title="신청 시점에 브라우저에 남아 있던 유입경로 (UTM 스크립트 2일 보관) — 무료 신청에만 있습니다">신청 때 유입</button>
|
||||
<button type="button" data-app-basis="apply" class="is-active" title="신청 완료·주문완료 화면이 남긴 그 순간의 유입경로 (UTM 스크립트 2일 보관, 업체 링크 우선) — 주문완료 화면 연동 전 구매는 값 없음">신청·구매 때 유입</button>
|
||||
<button type="button" data-app-basis="join" title="신청·구매한 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외, 비회원 구매는 값 없음)">가입 경로</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -597,7 +597,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>일시</th><th>구분</th><th>이름</th><th>연락처</th><th>상품</th><th>업체</th><th title="신청 완료 화면의 오픈카톡 버튼을 누른 수 — 실제 입장 여부는 카카오가 알려주지 않아 알 수 없습니다">카톡 버튼</th>
|
||||
<th title="신청 시점에 브라우저에 남아 있던 유입경로 (UTM 스크립트 2일 보관) — 구매에는 이 기록이 없습니다">신청 때 유입</th><th>가입 경로</th>
|
||||
<th title="신청 완료·주문완료 화면이 남긴 그 순간의 유입경로 (UTM 스크립트 2일 보관, 업체 링크 우선) — 주문완료 화면 연동 전 구매는 값 없음">신청·구매 때 유입</th><th>가입 경로</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-role="app-rows"></tbody>
|
||||
@@ -1524,7 +1524,7 @@
|
||||
$('[data-role="ov-app-bars"]').addEventListener('click', function (e) {
|
||||
var bar = closest(e.target, '.mgm-bar');
|
||||
if (!bar || bar.disabled) return;
|
||||
openApplications({ range: '30', type: 'application', inflowFilter: { basis: 'apply', field: 'source', value: bar.getAttribute('data-key') } });
|
||||
openApplications({ range: '30', type: 'all', inflowFilter: { basis: 'apply', field: 'source', value: bar.getAttribute('data-key') } });
|
||||
});
|
||||
$('[data-role="ov-join-bars"]').addEventListener('click', function (e) {
|
||||
var bar = closest(e.target, '.mgm-bar');
|
||||
@@ -1542,7 +1542,7 @@
|
||||
|
||||
/* ===================== 신청·구매 내역 ===================== */
|
||||
var appForm = $('[data-role="app-filter"]');
|
||||
var APP_BASIS_LABEL = { apply: '신청 때 유입', join: '가입 경로' };
|
||||
var APP_BASIS_LABEL = { apply: '신청·구매 때 유입', join: '가입 경로' };
|
||||
var APP_GROUP_LABEL = { source: '유입원', medium: '매체', campaign: '캠페인', content: '콘텐츠' };
|
||||
var APP_TYPE_LABEL = { application: '무료 신청', purchase: '유료 구매' };
|
||||
|
||||
@@ -1639,15 +1639,9 @@
|
||||
}
|
||||
function countCell(n) { return n ? '<b>' + num(n) + '</b>' : '<span class="mgm-faint">0</span>'; }
|
||||
|
||||
// 유입 집계 — 구매에는 '신청 때 유입'이 없으므로 유료 구매만 볼 때는 가입 경로로 고정한다
|
||||
// 유입 집계 기준 버튼 — 신청·구매 모두 두 기준으로 볼 수 있다
|
||||
function syncBasisButtons() {
|
||||
var app = state.app, onlyPurchase = app.type === 'purchase';
|
||||
if (onlyPurchase && app.basis === 'apply') app.basis = 'join';
|
||||
each($all('[data-app-basis]'), function (b) {
|
||||
var isApply = b.getAttribute('data-app-basis') === 'apply';
|
||||
b.disabled = onlyPurchase && isApply;
|
||||
});
|
||||
setSeg('[data-app-basis]', 'data-app-basis', app.basis);
|
||||
setSeg('[data-app-basis]', 'data-app-basis', state.app.basis);
|
||||
}
|
||||
|
||||
function loadAppStats() {
|
||||
@@ -1662,9 +1656,8 @@
|
||||
$('[data-role="app-stats-bars"]').innerHTML = barsHtml(s.items.slice(0, 10), true, selected, '건');
|
||||
var counted = [];
|
||||
if (app.type !== 'purchase') counted.push('무료 신청 ' + num(s.applicationTotal) + '건');
|
||||
if (s.basis === 'join' && app.type !== 'application') counted.push('유료 구매 ' + num(s.purchaseTotal) + '건');
|
||||
$('[data-role="app-stats-desc"]').textContent = counted.join(' · ') + ' · ' + APP_BASIS_LABEL[s.basis] + ' 기준' +
|
||||
(s.basis === 'apply' && app.type === 'all' ? ' (구매는 가입 경로로만 볼 수 있습니다)' : '') + ' · 막대를 누르면 목록을 거릅니다';
|
||||
if (app.type !== 'application') counted.push('유료 구매 ' + num(s.purchaseTotal) + '건');
|
||||
$('[data-role="app-stats-desc"]').textContent = counted.join(' · ') + ' · ' + APP_BASIS_LABEL[s.basis] + ' 기준 · 막대를 누르면 목록을 거릅니다';
|
||||
}).catch(function (err) { if (seq === app.statsSeq) errorNotice(err); });
|
||||
}
|
||||
|
||||
@@ -1695,7 +1688,7 @@
|
||||
'<td>' + (isApp && a.kakaoClickedAt
|
||||
? '<span class="mgm-badge is-ok" title="' + esc(kstDateTime(a.kakaoClickedAt)) + ' 에 버튼을 눌렀습니다 (실제 입장 여부는 알 수 없음)">클릭</span>'
|
||||
: '<span class="mgm-faint">-</span>') + '</td>' +
|
||||
'<td class="mgm-wrap">' + (isApp ? inflowText(a.inflow) : '<span class="mgm-faint">-</span>') + '</td>' +
|
||||
'<td class="mgm-wrap">' + inflowText(a.inflow) + '</td>' +
|
||||
'<td class="mgm-wrap">' + (!a.member && !a.memberUid ? '<span class="mgm-faint">비회원</span>' : joinInflowCell(a.member)) + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
@@ -1773,6 +1766,12 @@
|
||||
['연락처', dash(phone(o.phone))],
|
||||
['회원 여부', isGuest ? '비회원 주문' : '회원 주문']
|
||||
]) + '</section>' +
|
||||
'<section class="mgm-dsec"><h4>구매 때 유입</h4>' + (a.inflow && a.inflow.source ? dlHtml([
|
||||
['유입원', esc(a.inflow.source)],
|
||||
['매체', esc(a.inflow.medium)],
|
||||
['캠페인', esc(a.inflow.campaign)],
|
||||
['콘텐츠', esc(a.inflow.content)]
|
||||
]) : '<p class="mgm-muted">기록 없음 — 주문완료 화면 연동 전 주문이거나, 다른 기기·브라우저에서 결제한 경우입니다.</p>') + '</section>' +
|
||||
(isGuest ? '' : memberSectionsHtml(a.member));
|
||||
}
|
||||
openDrawer((activityName(a) || '알 수 없음') + ' 님의 ' + APP_TYPE_LABEL[a.type], html);
|
||||
@@ -1796,9 +1795,6 @@
|
||||
var type = b.getAttribute('data-app-type');
|
||||
if (type === state.app.type) return;
|
||||
state.app.type = type;
|
||||
// 구매에는 신청 때 유입이 없으므로 그 기준의 유입 필터는 풀어 준다
|
||||
var inf = state.app.inflowFilter;
|
||||
if (type === 'purchase' && inf && inf.basis === 'apply') state.app.inflowFilter = null;
|
||||
setSeg('[data-app-type]', 'data-app-type', type);
|
||||
loadApplications(true);
|
||||
});
|
||||
@@ -2028,7 +2024,9 @@
|
||||
'<td>' + (PTN_CONV_TYPE[c.type] || esc(c.type)) +
|
||||
(c.isValid ? '' : ' <span class="mgm-badge is-danger" title="환불·취소되어 집계에서 빠졌습니다">환불·취소</span>') +
|
||||
(c.orderNo ? '<span class="mgm-cell-sub">주문 ' + esc(c.orderNo) + '</span>' : '') + '</td>' +
|
||||
'<td>' + (m ? personCell(m.name, m.uid) : personCell(c.memberCode, '회원 정보 없음')) + '</td>' +
|
||||
'<td>' + (m ? personCell(m.name, m.uid)
|
||||
: c.memberCode ? personCell(c.memberCode, '회원 정보 없음')
|
||||
: personCell(c.ordererName, '비회원 구매')) + '</td>' +
|
||||
'<td>' + dash(phone(m && m.callnum)) + '</td>' +
|
||||
'<td><span class="mgm-cell-sub" style="margin:0">' + esc(kstShort(c.touchedAt)) + '</span></td>' +
|
||||
'</tr>';
|
||||
|
||||
Reference in New Issue
Block a user