18 Commits

Author SHA1 Message Date
ttipo fbd040ba6d Merge branch 'style/admin-brand-colors'
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
2026-10-06 13:37:54 +09:00
ttipo 4ddfe2bbc3 style(imweb): 관리자 화면을 자본주의가이드 색으로 바꾸고 로고 제거
파란 강조색·푸른 회색 테마를 사이트와 같은 블랙(#141414)·골드(#f5b727)로 바꾼다
(값은 신청 완료 위젯 imweb-free-apply.html 과 같다).

골드는 흰 배경 위 글자로는 대비가 부족해 채우는 색(버튼·막대·칩·배지)으로만 쓰고
그 위 글자는 #1a1200, 흰 배경 위 링크·선택 표시·포커스는 진한 골드(#8a5d00)로 나눴다.
주의 색은 골드와 헷갈리지 않게 주황 쪽으로 옮겼다.
로그인·사이드바의 ₩ 로고를 지우고 사이드바 이름을 골드로 표시한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:37:54 +09:00
ttipo f29d48fcfa Merge branch 'feat/purchase-inflow'
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
2026-10-06 13:18:02 +09:00
ttipo bf891d2177 docs: 구매 때 유입·비회원 업체 실적 구현 현황과 배포 순서 정리
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:14:59 +09:00
ttipo 5badf94dc7 feat(imweb): 주문완료 화면에서 구매 때 유입 전송·관리자 화면에 표시
- Header Code '구매 때 유입 전송': /shop_payment_complete 에서 order_no·유입·업체 클릭 ID 를
  POST /api/order-inflows 로 한 번 보낸다 (같은 탭 새로고침 때는 다시 보내지 않는다)
- 결제 페이지(/shop_payment*)에서는 리퍼러를 유입으로 기록하지 않는다 (v1.3) —
  결제창에서 돌아올 때 pay.kakao.com 이 '카카오 소셜', pay.naver.com 이 '네이버 검색'
  유입으로 잡혀 구매 때 유입을 가릴 수 있었다
- 관리자 화면: 유입 기준을 '신청·구매 때 유입'으로, 구매 행·상세 패널에 구매 때 유입,
  업체 실적 상세의 비회원 구매는 주문자 이름으로 보여준다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:14:42 +09:00
ttipo 1f71ce048f feat(backend): 주문완료 화면의 구매 때 유입 수집과 비회원 구매 업체 귀속
아임웹 주문 API 에는 손님이 어디서 왔는지가 없어 구매는 가입 경로로만 볼 수 있었고,
비회원 구매는 회원 코드가 없어 업체 링크 실적으로 잡을 수 없었다.

- POST /api/order-inflows: 주문완료 화면이 주문번호·유입·업체 클릭 ID 를 보낸다
  (공개 · 1분 10회 · 항상 204 · 같은 주문번호는 처음 값만)
- 주문 동기화가 같은 주문번호의 구매에 유입을 채운다 (비어 있는 구매에만)
- 업체 귀속 후보 = 회원 클릭 연결 + 주문완료 클릭 ID → 비회원 구매도 마지막 클릭에 귀속
  (partner_conversions.member_code nullable, 실적 상세에 주문자 이름)
- 90일 동안 주문과 이어지지 않은 값은 동기화가 끝날 때 지운다
- 관리자 조회: 유입 기준 apply 를 신청·구매 때 유입으로 넓힌다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:14:33 +09:00
ttipo 8d4adfd7f1 feat(imweb): 업체 링크로 들어오면 신청 때 유입을 그 업체로 바꿈
광고로 가입한 회원이 업체 링크로 다시 와서 신청해도, 보관 중인 첫 경로(광고)가
남아 신청 때 유입이 광고로 잡혔다. 업체 칸(업체 실적)은 그 업체라 한 줄 안에서
유입과 업체가 어긋났다.

업체 단축 링크가 붙인 UTM(utm_medium=partner)으로 들어오면 첫 경로를 그 업체로
덮어쓴다. 여러 업체를 거치면 마지막 업체 — 업체 실적의 마지막 클릭 규칙과 같다.
화면의 유입 기준은 그대로 하나만 둔다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:14:22 +09:00
ttipo 9e6cae7b95 Merge branch 'feat/purchase-history'
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
2026-10-06 10:25:45 +09:00
ttipo 9a413174ed docs: 유료 구매 추적 현황과 다음 작업(구매 때 유입) 정리
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:24:19 +09:00
ttipo ab7ef371dc feat: 무료 신청과 유료 구매를 한 화면에서 보는 신청·구매 내역 추가
유료 구매는 업체 링크로 귀속된 것만 남아 누가 구매했는지 볼 수 없었다.
주문 동기화가 결제된 아임웹 주문을 회원·비회원 모두 purchases 에 남기고
(서버 기동 후 첫 실행 1년, 이후 60일), 관리자 조회는 신청과 구매를 합쳐
보여주는 /admin/activities 로 옮겼다. 예전 /admin/applications 조회는 없앴다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:33:56 +09:00
ttipo 1052cc3348 refactor: 신청 화면에서 신청 직전(최종) 유입 표시 제거
운영에 필요한 것은 업체 링크로 들어와 누가 신청·구매했는지이고, 신청 직전 유입은
쓰지 않는다. 기준이 둘(직전·최초)이라 화면만 복잡해져 신청 때 유입은 한 가지만 보여준다.

- 신청 유입 집계 기준: 신청 직전 · 신청 때 최초 · 가입 경로 → 신청 때 유입 · 가입 경로
- 신청자 표·상세 패널: 신청 때 유입을 한 줄로 (최초/직전 구분 제거)
- 개요의 신청 유입 상위도 신청 때 유입 기준
- 회원 상세의 가입 경로에서 '최종 유입' 줄 제거
백엔드는 그대로 둔다 (최종 유입은 저장만 하고 화면에 쓰지 않는다)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 01:57:54 +09:00
ttipo a6e1b2b501 fix: 업체 링크 표의 '업체에 줄 링크' 칸 줄 맞춤
이동 주소 줄의 .mgm-url(inline-block, vertical-align: bottom)이 .mgm-cell-sub 의
display:block 을 덮어써, 이동 주소가 복사 버튼 옆에 붙고 글꼴·버튼 여백 차이로 높이가 어긋났다.
1줄(링크 + 복사, 가운데 정렬) / 2줄(이동 주소, 길면 말줄임) 구조로 나눠 왼쪽 업체·상품 줄과 맞춘다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 01:47:11 +09:00
ttipo bd849b1090 fix: 관리자 화면 '오픈카톡 입장'을 '카톡 버튼 클릭'으로 바로잡음
실제 오픈카톡방 입장은 카카오가 알려주지 않아 추적할 수 없고, 집계하는 값은
신청 완료 화면의 오픈카톡 버튼을 처음 누른 시각(kakao_clicked_at)이다.
'입장률'로 표시하면 실제 입장으로 오해하므로 문구를 사실대로 바꾸고 안내를 붙인다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 01:43:35 +09:00
ttipo 6316666bea Merge branch 'feat/admin-ui-redesign'
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
2026-10-06 01:38:32 +09:00
ttipo b5ed8850c7 feat: 관리자 화면 전체 개편과 신청 내역 상품별·업체별 보기
관리자 화면이 기능을 덧붙이며 복잡해져(회원 표 15열, 업체 화면에 폼·표·설명이 모두 펼쳐짐,
브라우저 기본 prompt/confirm) 보기 어렵고, 신청을 업체 기준으로 볼 방법이 없었다.

- 메뉴를 개요 / 운영(신청 내역·업체 링크·회원) / 설정(연동 관리)으로 묶음
- 개요: 오늘·7일 신청, 7일 업체 링크 신청, 7일 가입 지표 + 신청 유입·업체 실적 상위·최근 신청
- 신청 내역: 상품별 ↔ 업체별 전환 — 요약 표의 행을 누르면 목록과 유입 집계를 그 항목으로 좁힘,
  신청자 표에 업체 열 추가, 행을 누르면 상세 패널(신청·유입·회원·가입 경로)
- 업체 링크: 왼쪽 업체 목록 + 오른쪽 링크 표, 실적 지표, 링크 발급·수정은 대화상자,
  사용/중지 스위치, 링크 실적은 상세 패널, 집계 기준은 접는 도움말
- 회원: 표를 7열로 줄이고 유입경로 필터는 접어 둠, 행을 누르면 상세 패널
- 토스트 알림·대화상자·상세 패널 공통 컴포넌트, 연락처 하이픈 표시, 휴대폰 화면 정리
- API: GET /api/admin/applications/groups (상품별·업체별 신청 수·오픈카톡 입장 수),
  신청 목록·유입 집계에 partnerId 필터(업체 ID 또는 none), 신청 응답에 데려온 업체(partner)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 01:37:49 +09:00
ttipo cab7e840fe feat: 업체 링크를 아임웹 주소(/go)로 바꾸고 업체 UTM 자동 부착
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
업체에 우리 백엔드 도메인(moneyguide.wandergo.net)이 아니라 사이트 주소를 주기 위해서다.
또 업체 링크로 온 방문이 UTM 이 없어 direct 로 분류되면서, 이전에 남은 유입(예: 개발자센터)이
신청 때 유입으로 잡히는 문제가 있었다.

- 업체에 줄 링크: https://www.moneyguider.co.kr/go?c={코드}
  imweb-header-top.html 맨 위의 입구 블록이 페이지를 그리기 전에 백엔드 /r/{코드} 로 넘긴다
  (입구 페이지에서는 UTM 스크립트가 기록하지 않는다). 아임웹에 'go' 페이지가 필요하다
- 기존 백엔드 링크(/r/{코드})는 그대로 동작한다
- 단축 링크 리다이렉트에 업체 UTM 자동 부착 — utm_source=업체 이름, utm_medium=partner,
  utm_campaign=링크 코드 (이동 주소에 관리자가 넣은 UTM 이 있으면 그대로 둔다)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 01:01:00 +09:00
ttipo 38e945767b feat: 신청 때 최종 유입 저장과 신청 유입 집계 추가
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
가입 경로와 신청(수강) 유입을 구분해 보기 위해서다. 기존 신청 유입은 신청 시점
브라우저의 '최초' 유입(2일 보관)이라, 그 사이 다른 경로로 다시 와서 신청하면
신청을 일으킨 경로가 가려졌다.

- member_applications 에 최종 유입(inflow_last_*) 저장 — 없으면 최초 유입으로 채움 (기존 행도 동일)
- 신청 완료 위젯이 MG_UTM.peek().last 를 lastInflow 로 함께 보냄
- GET /api/admin/applications/inflow-stats: 신청 때 최종·최초 / 가입 경로(가입 때 기록만) 기준 집계
- 신청 목록에 inflowBasis·inflowField·inflowValue 필터
- 관리자 화면 신청 내역: '신청 유입 집계' 카드(기준·항목 전환, 막대 클릭으로 목록 필터),
  신청 때 유입 칸에 최종 유입 표시 + 최초가 다르면 함께 표시

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:22:02 +09:00
ttipo e91f7620a3 feat: 업체 링크 추적을 실제 아임웹 Header Code 로 합치고 신청 유입정보 누락 수정
- imweb-header-top.html·imweb-header.html: 아임웹에 실제로 들어가 있는 Header Code 원본을 저장소에 둔다
- 업체 링크 추적을 imweb-header-top.html 의 UTM 스크립트 아래로 옮기고 partner-ref.html 은 삭제
  (UTM 스크립트처럼 localStorage + .moneyguider.co.kr 쿠키에 저장 — www ↔ apex 이동·인앱브라우저 대비)
- 신청 완료 위젯: MG_UTM.peek() 는 객체라 문자열 검사에 걸려 유입정보가 빠지고 있었다 — peek().value 를 보낸다
- 단축 링크 리다이렉트에 Referrer-Policy strict-origin-when-cross-origin
  (helmet 기본 no-referrer 때문에 업체가 링크를 올린 곳이 지워져 유입원이 direct 로 잡혔다)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:11:52 +09:00
51 changed files with 5628 additions and 1600 deletions
+2
View File
@@ -7,6 +7,7 @@ import { CacheModule } from '@nestjs/cache-manager';
import { APP_GUARD } from '@nestjs/core';
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { ActivitiesModule } from '@/modules/activities/activities.module';
import { AdminAuthModule } from '@/modules/admin-auth/admin-auth.module';
import { ApplicationsModule } from '@/modules/applications/applications.module';
import { HealthModule } from '@/modules/health/health.module';
@@ -70,6 +71,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
MembersModule,
ApplicationsModule,
PartnersModule,
ActivitiesModule,
],
providers: [
ShutdownLogger,
@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddApplicationLastInflow1791213287479 implements MigrationInterface {
name = 'AddApplicationLastInflow1791213287479';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "member_applications" ADD "inflow_last_raw" text`,
);
await queryRunner.query(
`ALTER TABLE "member_applications" ADD "inflow_last_source" character varying(100)`,
);
await queryRunner.query(
`ALTER TABLE "member_applications" ADD "inflow_last_medium" character varying(100)`,
);
await queryRunner.query(
`ALTER TABLE "member_applications" ADD "inflow_last_campaign" character varying(100)`,
);
await queryRunner.query(
`ALTER TABLE "member_applications" ADD "inflow_last_content" character varying(100)`,
);
// 이전 신청은 최종 유입을 따로 받지 않았다 — 앱과 같은 규칙(최종이 없으면 최초)으로 채운다
await queryRunner.query(
`UPDATE "member_applications" SET "inflow_last_source" = "inflow_source", "inflow_last_medium" = "inflow_medium", "inflow_last_campaign" = "inflow_campaign", "inflow_last_content" = "inflow_content"`,
);
await queryRunner.query(
`CREATE INDEX "IDX_71dddb12f679f697f1123480ad" ON "member_applications" ("inflow_last_source") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."IDX_71dddb12f679f697f1123480ad"`,
);
await queryRunner.query(
`ALTER TABLE "member_applications" DROP COLUMN "inflow_last_content"`,
);
await queryRunner.query(
`ALTER TABLE "member_applications" DROP COLUMN "inflow_last_campaign"`,
);
await queryRunner.query(
`ALTER TABLE "member_applications" DROP COLUMN "inflow_last_medium"`,
);
await queryRunner.query(
`ALTER TABLE "member_applications" DROP COLUMN "inflow_last_source"`,
);
await queryRunner.query(
`ALTER TABLE "member_applications" DROP COLUMN "inflow_last_raw"`,
);
}
}
@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPurchases1791220773446 implements MigrationInterface {
name = 'AddPurchases1791220773446';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "purchases" ("id" uuid NOT NULL, "order_no" character varying(30) NOT NULL, "prod_no" integer NOT NULL, "product_name" character varying(200), "member_code" character varying(40), "member_uid" character varying(255), "orderer_name" character varying(100), "orderer_email" character varying(255), "orderer_call" character varying(40), "ordered_at" TIMESTAMP WITH TIME ZONE NOT NULL, "is_valid" boolean NOT NULL DEFAULT true, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_1d55032f37a34c6eceacbbca6b8" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "idx_purchases_member" ON "purchases" ("member_code") `,
);
await queryRunner.query(
`CREATE INDEX "idx_purchases_cursor" ON "purchases" ("ordered_at", "id") `,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "uq_purchases_order_product" ON "purchases" ("order_no", "prod_no") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "public"."uq_purchases_order_product"`);
await queryRunner.query(`DROP INDEX "public"."idx_purchases_cursor"`);
await queryRunner.query(`DROP INDEX "public"."idx_purchases_member"`);
await queryRunner.query(`DROP TABLE "purchases"`);
}
}
@@ -0,0 +1,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"`);
}
}
@@ -0,0 +1,75 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { PaginatedResult } from '@/common/dto/pagination.dto';
import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
import { ADMIN_BEARER_AUTH } from '@/modules/admin-auth/admin-auth.constants';
import { ActivitiesService } from './activities.service';
import {
ActivityGroupsQueryDto,
ActivityInflowStatsQueryDto,
ActivityListQueryDto,
} from './dto/activity-query.dto';
import {
ActivityGroupsResponseDto,
ActivityInflowStatsResponseDto,
ActivityResponseDto,
} from './dto/activity-response.dto';
/** 관리자 신청·구매 내역 — 무료 신청과 유료 구매를 한 화면에서 본다 */
@ApiTags('Admin Activities')
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@UseGuards(AdminAuthGuard)
@Controller('admin/activities')
export class AdminActivitiesController {
constructor(private readonly activities: ActivitiesService) {}
@Get()
@ApiOperation({
summary: '신청·구매 내역 (최신순, 커서 페이지네이션) — 종류로 거를 수 있다',
})
@ApiStandardResponse(ActivityResponseDto, {
description: '조회 성공 — data 는 { items, pageInfo }',
isArray: true,
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '필터·커서 형식 오류')
findMany(
@Query() query: ActivityListQueryDto,
): Promise<PaginatedResult<ActivityResponseDto>> {
return this.activities.findMany(query);
}
@Get('groups')
@ApiOperation({
summary: '상품별·업체별 신청·구매 수 — 요약 표 (기간만 거른다)',
})
@ApiStandardResponse(ActivityGroupsResponseDto, { description: '조회 성공' })
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '기준·기간 형식 오류')
groups(
@Query() query: ActivityGroupsQueryDto,
): Promise<ActivityGroupsResponseDto> {
return this.activities.groups(query);
}
@Get('inflow-stats')
@ApiOperation({
summary:
'유입 집계 — 신청·구매 때 유입 또는 가입 경로 기준 (신청·구매 모두)',
})
@ApiStandardResponse(ActivityInflowStatsResponseDto, {
description: '집계 성공',
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '필터·기준 형식 오류')
inflowStats(
@Query() query: ActivityInflowStatsQueryDto,
): Promise<ActivityInflowStatsResponseDto> {
return this.activities.inflowStats(query);
}
}
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminAuthModule } from '@/modules/admin-auth/admin-auth.module';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { MembersModule } from '@/modules/members/members.module';
import { Partner } from '@/modules/partners/entities/partner.entity';
import { AdminActivitiesController } from './activities.controller';
import { ActivitiesService } from './activities.service';
/**
* 관리자 신청·구매 내역 — 읽기 전용.
* 무료 신청(ApplicationsModule)과 유료 구매(PurchasesModule)가 쌓은 기록을 합쳐 보여준다.
*/
@Module({
imports: [
TypeOrmModule.forFeature([ImwebMember, Partner]),
AdminAuthModule,
MembersModule,
],
controllers: [AdminActivitiesController],
providers: [ActivitiesService],
})
export class ActivitiesModule {}
@@ -0,0 +1,643 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
DataSource,
In,
ObjectLiteral,
Repository,
SelectQueryBuilder,
} from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { MemberApplication } from '@/modules/applications/application.entity';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { ImwebProduct } from '@/modules/imweb/entities/imweb-product.entity';
import { InflowGroupBy } from '@/modules/members/dto/member-query.dto';
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
import { MembersService } from '@/modules/members/members.service';
import { PartnerConversion } from '@/modules/partners/entities/partner-conversion.entity';
import { Partner } from '@/modules/partners/entities/partner.entity';
import { Purchase } from '@/modules/purchases/purchase.entity';
import { InflowStatus } from '@/shared/utils/inflow';
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
import {
ActivityFilterDto,
ActivityGroupsQueryDto,
ActivityInflowStatsQueryDto,
ActivityListQueryDto,
ActivityType,
PARTNER_FILTER_NONE,
} from './dto/activity-query.dto';
import {
ActivityCountsDto,
ActivityGroupsResponseDto,
ActivityInflowStatsResponseDto,
ActivityResponseDto,
} from './dto/activity-response.dto';
const APP = 'a';
const APP_MEMBER = 'am';
const APP_CONVERSION = 'apc';
const PUR = 'p';
const PUR_MEMBER = 'pm';
const PUR_CONVERSION = 'ppc';
const UNION = 'u';
const PRODUCT = 'ip';
const PARTNER = 'pt';
/** 집계 결과 최대 줄 수 (회원 유입 집계와 같다) */
const STATS_MAX_ITEMS = 100;
/** 커서의 고유값은 신청·구매 기록 ID(UUID)다 — 형식이 다르면 DB 오류 대신 VAL_001 */
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
type ListFilter = ActivityFilterDto &
Partial<
Pick<
ActivityListQueryDto,
'inflowBasis' | 'inflowField' | 'inflowValue' | 'q'
>
>;
/** [SQL 식, 별칭] — 신청·구매 두 쪽이 같은 순서로 같은 열을 내야 UNION 할 수 있다 */
type Columns = [expression: string, alias: string][];
/** 목록 한 줄 (UNION 결과 원본) */
interface ActivityRow {
type: ActivityType;
id: string;
occurred_at: Date;
product_idx: string;
product_name: string | null;
member_code: string | null;
member_uid: string | null;
partner_id: string | null;
kakao_clicked_at: Date | null;
inflow_source: string | null;
inflow_medium: string | null;
inflow_campaign: string | null;
inflow_content: string | null;
order_no: string | null;
is_valid: boolean | null;
orderer_name: string | null;
orderer_email: string | null;
orderer_call: string | null;
}
interface CountsRow {
applicationCount: string;
kakaoCount: string;
purchaseCount: string;
cancelledCount: string;
}
/**
* 가입 경로 식 — 가입 때 기록된 값만 쓴다 (가입 후 회원정보 수정 화면에서 채워진 값은 가입 경로가 아니다).
* 항목은 enum 으로 검증된 값이라 문자열로 넣어도 안전하다.
*/
function joinInflow(memberAlias: string, field: InflowGroupBy): string {
return `CASE WHEN ${memberAlias}.inflow_status = '${InflowStatus.AT_JOIN}' THEN ${memberAlias}.inflow_${field} END`;
}
function escapeLike(value: string): string {
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
}
function selectColumns<T extends ObjectLiteral>(
qb: SelectQueryBuilder<T>,
columns: Columns,
): SelectQueryBuilder<T> {
const [[firstExpression, firstAlias], ...rest] = columns;
qb.select(firstExpression, firstAlias);
for (const [expression, alias] of rest) qb.addSelect(expression, alias);
return qb;
}
/** 신청·구매 수 — UNION 결과(u)에서 센다 */
const COUNT_COLUMNS: Columns = [
[`COUNT(*) FILTER (WHERE ${UNION}.type = 'application')`, 'applicationCount'],
[`COUNT(${UNION}.kakao_clicked_at)`, 'kakaoCount'],
[
`COUNT(*) FILTER (WHERE ${UNION}.type = 'purchase' AND ${UNION}.is_valid)`,
'purchaseCount',
],
[
`COUNT(*) FILTER (WHERE ${UNION}.type = 'purchase' AND NOT ${UNION}.is_valid)`,
'cancelledCount',
],
];
function toCounts(row: CountsRow | undefined): ActivityCountsDto {
return {
applicationCount: Number(row?.applicationCount ?? 0),
kakaoCount: Number(row?.kakaoCount ?? 0),
purchaseCount: Number(row?.purchaseCount ?? 0),
cancelledCount: Number(row?.cancelledCount ?? 0),
};
}
/**
* 관리자 신청·구매 내역 — 무료 신청(member_applications)과 유료 구매(purchases)를 한 화면에서 본다.
*
* 두 기록은 따로 쌓이고(신청은 신청 완료 화면, 구매는 아임웹 주문 동기화) 여기서 UNION ALL 로 합친다.
* 업체는 업체 실적(partner_conversions)과 이어서 보여준다 — 신청은 신청 ID, 구매는 주문번호·상품으로 잇는다.
*/
@Injectable()
export class ActivitiesService {
constructor(
private readonly dataSource: DataSource,
@InjectRepository(ImwebMember)
private readonly members: Repository<ImwebMember>,
@InjectRepository(Partner)
private readonly partners: Repository<Partner>,
private readonly membersService: MembersService,
) {}
async findMany(
query: ActivityListQueryDto,
): Promise<PaginatedResult<ActivityResponseDto>> {
const parts: SelectQueryBuilder<ObjectLiteral>[] = [];
const app = this.applicationPart(query);
if (app) {
parts.push(
selectColumns(app, [
[`'application'`, 'type'],
[`${APP}.id`, 'id'],
[`${APP}.applied_at`, 'occurred_at'],
[`${APP}.product_idx`, 'product_idx'],
[`${APP}.product_name`, 'product_name'],
[`${APP}.member_code`, 'member_code'],
[`${APP}.member_uid`, 'member_uid'],
[`${APP_CONVERSION}.partner_id`, 'partner_id'],
[`${APP}.kakao_clicked_at`, 'kakao_clicked_at'],
[`${APP}.inflow_source`, 'inflow_source'],
[`${APP}.inflow_medium`, 'inflow_medium'],
[`${APP}.inflow_campaign`, 'inflow_campaign'],
[`${APP}.inflow_content`, 'inflow_content'],
['CAST(NULL AS varchar)', 'order_no'],
['CAST(NULL AS boolean)', 'is_valid'],
['CAST(NULL AS varchar)', 'orderer_name'],
['CAST(NULL AS varchar)', 'orderer_email'],
['CAST(NULL AS varchar)', 'orderer_call'],
]),
);
}
const pur = this.purchasePart(query);
if (pur) {
parts.push(
selectColumns(pur, [
[`'purchase'`, 'type'],
[`${PUR}.id`, 'id'],
[`${PUR}.ordered_at`, 'occurred_at'],
[`CAST(${PUR}.prod_no AS varchar)`, 'product_idx'],
[`${PUR}.product_name`, 'product_name'],
[`${PUR}.member_code`, 'member_code'],
[`${PUR}.member_uid`, 'member_uid'],
[`${PUR_CONVERSION}.partner_id`, 'partner_id'],
['CAST(NULL AS timestamptz)', 'kakao_clicked_at'],
[`${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'],
[`${PUR}.orderer_email`, 'orderer_email'],
[`${PUR}.orderer_call`, 'orderer_call'],
]),
);
}
if (parts.length === 0) {
return toPaginatedResult([], {
hasNext: false,
nextCursor: null,
total: 0,
});
}
const totalRow = await this.fromUnion(parts)
.select('COUNT(*)', 'total')
.getRawOne<{ total: string }>();
const page = this.fromUnion(parts)
.select(`${UNION}.*`)
.orderBy(`${UNION}.occurred_at`, 'DESC')
.addOrderBy(`${UNION}.id`, 'DESC')
.limit(query.limit + 1);
if (query.cursor) {
const cursor = decodeTimeCursor(query.cursor);
if (!cursor || !UUID_PATTERN.test(cursor.key)) {
throw new BusinessException('VAL_001', {
context: { reason: 'invalid_cursor' },
});
}
page.andWhere(
`(${UNION}.occurred_at, ${UNION}.id) < (:cursorTime, :cursorId)`,
{ cursorTime: cursor.time, cursorId: cursor.key },
);
}
const rows = await page.getRawMany<ActivityRow>();
const hasNext = rows.length > query.limit;
const items = rows.slice(0, query.limit);
const last = items.at(-1);
const [membersByCode, groupTitles, partnerNames] = await Promise.all([
this.membersByCode(items.map((row) => row.member_code)),
this.membersService.groupTitleMap(),
this.partnerNames(items.map((row) => row.partner_id)),
]);
return toPaginatedResult(
items.map((row): ActivityResponseDto => {
const member = row.member_code
? membersByCode.get(row.member_code)
: undefined;
const isApplication = row.type === 'application';
return {
type: row.type,
id: row.id,
occurredAt: row.occurred_at.toISOString(),
productIdx: row.product_idx,
productName: row.product_name,
memberUid: row.member_uid,
member: member ? MemberResponseDto.from(member, groupTitles) : null,
partner: row.partner_id
? {
id: row.partner_id,
name: partnerNames.get(row.partner_id) ?? '',
}
: null,
kakaoClickedAt: row.kakao_clicked_at?.toISOString() ?? null,
inflow: {
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
? null
: {
name: row.orderer_name,
email: row.orderer_email,
phone: row.orderer_call,
},
};
}),
{
hasNext,
nextCursor:
hasNext && last
? encodeTimeCursor({
time: last.occurred_at.toISOString(),
key: last.id,
})
: null,
total: Number(totalRow?.total ?? 0),
},
);
}
/**
* 상품별·업체별 신청·구매 수 — 화면 위쪽 요약 표.
* 업체별은 업체 링크 실적으로 잡힌 것을 업체로 묶고, 나머지는 key=null(업체 없음)로 센다.
*/
async groups(
query: ActivityGroupsQueryDto,
): Promise<ActivityGroupsResponseDto> {
const filter: ListFilter = { from: query.from, to: query.to };
const byPartner = query.by === 'partner';
const parts = [
selectColumns(this.applicationQuery(filter), [
[`'application'`, 'type'],
[
byPartner ? `${APP_CONVERSION}.partner_id` : `${APP}.product_idx`,
'key',
],
[byPartner ? 'CAST(NULL AS varchar)' : `${APP}.product_name`, 'label'],
[`${APP}.applied_at`, 'occurred_at'],
[`${APP}.kakao_clicked_at`, 'kakao_clicked_at'],
['CAST(NULL AS boolean)', 'is_valid'],
]),
selectColumns(this.purchaseQuery(filter), [
[`'purchase'`, 'type'],
[
byPartner
? `${PUR_CONVERSION}.partner_id`
: `CAST(${PUR}.prod_no AS varchar)`,
'key',
],
[byPartner ? 'CAST(NULL AS varchar)' : `${PUR}.product_name`, 'label'],
[`${PUR}.ordered_at`, 'occurred_at'],
['CAST(NULL AS timestamptz)', 'kakao_clicked_at'],
[`${PUR}.is_valid`, 'is_valid'],
]),
];
const totals = await selectColumns(
this.fromUnion(parts),
COUNT_COLUMNS,
).getRawOne<CountsRow>();
const grouped = this.fromUnion(parts);
if (byPartner) {
grouped
.leftJoin(Partner, PARTNER, `${PARTNER}.id = ${UNION}.key`)
.select(`${UNION}.key`, 'key')
.addSelect(`MAX(${PARTNER}.name)`, 'label');
} else {
// 상품명은 아임웹 상품 기준, 아직 불러오지 않은 상품이면 가장 최근 표시값
grouped
.leftJoin(
ImwebProduct,
PRODUCT,
`CAST(${PRODUCT}.prod_no AS varchar) = ${UNION}.key`,
)
.select(`${UNION}.key`, 'key')
.addSelect(
`COALESCE(MAX(${PRODUCT}.name), (ARRAY_AGG(${UNION}.label ORDER BY ${UNION}.occurred_at DESC) FILTER (WHERE ${UNION}.label IS NOT NULL))[1])`,
'label',
);
}
for (const [expression, alias] of COUNT_COLUMNS) {
grouped.addSelect(expression, alias);
}
const rows = await grouped
.groupBy(`${UNION}.key`)
.orderBy('COUNT(*)', 'DESC')
.addOrderBy(`${UNION}.key`, 'ASC', 'NULLS LAST')
.limit(STATS_MAX_ITEMS)
.getRawMany<CountsRow & { key: string | null; label: string | null }>();
return {
by: query.by,
totals: toCounts(totals),
items: rows.map((row) => ({
key: row.key,
label: row.label,
...toCounts(row),
})),
};
}
/**
* 유입 집계 — 신청·구매(환불·취소 제외) 모두 센다.
* - 신청·구매 때 유입(apply): 신청은 신청 완료 화면, 구매는 주문완료 화면이 보낸 값.
* 주문완료 화면이 보내지 못한 구매(도입 전 주문 등)는 값 없음
* - 가입 경로(join): 회원의 가입 경로. 비회원 구매는 값 없음
*/
async inflowStats(
query: ActivityInflowStatsQueryDto,
): Promise<ActivityInflowStatsResponseDto> {
const isJoin = query.basis === 'join';
const parts: SelectQueryBuilder<ObjectLiteral>[] = [];
const app = this.applicationPart(query);
if (app) {
parts.push(
selectColumns(app, [
[`'application'`, 'type'],
[
isJoin
? joinInflow(APP_MEMBER, query.groupBy)
: `${APP}.inflow_${query.groupBy}`,
'key',
],
]),
);
}
const pur = this.purchasePart(query);
if (pur) {
pur.andWhere(`${PUR}.is_valid = true`);
parts.push(
selectColumns(pur, [
[`'purchase'`, 'type'],
[
isJoin
? joinInflow(PUR_MEMBER, query.groupBy)
: `${PUR}.inflow_${query.groupBy}`,
'key',
],
]),
);
}
const result: ActivityInflowStatsResponseDto = {
basis: query.basis,
groupBy: query.groupBy,
applicationTotal: 0,
purchaseTotal: 0,
items: [],
};
if (parts.length === 0) return result;
const totals = await this.fromUnion(parts)
.select(
`COUNT(*) FILTER (WHERE ${UNION}.type = 'application')`,
'applicationTotal',
)
.addSelect(
`COUNT(*) FILTER (WHERE ${UNION}.type = 'purchase')`,
'purchaseTotal',
)
.getRawOne<{ applicationTotal: string; purchaseTotal: string }>();
const rows = await this.fromUnion(parts)
.select(`${UNION}.key`, 'key')
.addSelect('COUNT(*)', 'count')
.groupBy(`${UNION}.key`)
.orderBy('count', 'DESC')
.addOrderBy(`${UNION}.key`, 'ASC', 'NULLS LAST')
.limit(STATS_MAX_ITEMS)
.getRawMany<{ key: string | null; count: string }>();
return {
...result,
applicationTotal: Number(totals?.applicationTotal ?? 0),
purchaseTotal: Number(totals?.purchaseTotal ?? 0),
items: rows.map((row) => ({ key: row.key, count: Number(row.count) })),
};
}
/** 무료 신청 쪽 — 종류 필터로 빠지면 null */
private applicationPart(
filter: ListFilter,
): SelectQueryBuilder<ObjectLiteral> | null {
return filter.type === 'purchase' ? null : this.applicationQuery(filter);
}
/** 유료 구매 쪽 — 종류 필터로 빠지면 null */
private purchasePart(
filter: ListFilter,
): SelectQueryBuilder<ObjectLiteral> | null {
return filter.type === 'application' ? null : this.purchaseQuery(filter);
}
/** 무료 신청 — 필터만 걸고 열은 부르는 쪽이 고른다 */
private applicationQuery(
filter: ListFilter,
): SelectQueryBuilder<ObjectLiteral> {
const qb = this.dataSource
.createQueryBuilder()
.from(MemberApplication, APP)
// 탈퇴 회원은 회원 정보 없이 남는다
.leftJoin(
ImwebMember,
APP_MEMBER,
`${APP_MEMBER}.member_code = ${APP}.member_code`,
)
// 신청 실적은 신청당 하나라 잇는 행이 늘어나지 않는다
.leftJoin(
PartnerConversion,
APP_CONVERSION,
`${APP_CONVERSION}.application_id = ${APP}.id`,
);
this.applyCommonFilters(qb, filter, {
occurredAt: `${APP}.applied_at`,
productIdx: `${APP}.product_idx`,
conversion: APP_CONVERSION,
searchColumns: [
`${APP_MEMBER}.name`,
`${APP}.member_uid`,
`${APP_MEMBER}.email`,
`${APP_MEMBER}.callnum`,
],
});
if (filter.inflowBasis && filter.inflowField && filter.inflowValue) {
const expression =
filter.inflowBasis === 'join'
? joinInflow(APP_MEMBER, filter.inflowField)
: `${APP}.inflow_${filter.inflowField}`;
qb.andWhere(`${expression} = :inflowValue`, {
inflowValue: filter.inflowValue,
});
}
return qb;
}
/** 유료 구매 — 필터만 걸고 열은 부르는 쪽이 고른다 */
private purchaseQuery(filter: ListFilter): SelectQueryBuilder<ObjectLiteral> {
const qb = this.dataSource
.createQueryBuilder()
.from(Purchase, PUR)
.leftJoin(
ImwebMember,
PUR_MEMBER,
`${PUR_MEMBER}.member_code = ${PUR}.member_code`,
)
// 구매 실적은 주문·상품당 하나다
.leftJoin(
PartnerConversion,
PUR_CONVERSION,
`${PUR_CONVERSION}.order_no = ${PUR}.order_no AND ${PUR_CONVERSION}.prod_no = ${PUR}.prod_no`,
);
this.applyCommonFilters(qb, filter, {
occurredAt: `${PUR}.ordered_at`,
productIdx: `CAST(${PUR}.prod_no AS varchar)`,
conversion: PUR_CONVERSION,
searchColumns: [
`${PUR_MEMBER}.name`,
`${PUR}.member_uid`,
`${PUR_MEMBER}.email`,
`${PUR_MEMBER}.callnum`,
`${PUR}.orderer_name`,
`${PUR}.orderer_email`,
`${PUR}.orderer_call`,
],
});
if (filter.inflowBasis && 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;
}
/**
* 두 쪽이 같은 이름의 파라미터를 같은 값으로 쓴다 — UNION 으로 합칠 때 겹쳐도 문제없다.
* (쪽마다 다른 값이 필요한 파라미터는 여기에 두지 않는다)
*/
private applyCommonFilters(
qb: SelectQueryBuilder<ObjectLiteral>,
filter: ListFilter,
columns: {
occurredAt: string;
productIdx: string;
conversion: string;
searchColumns: string[];
},
): void {
if (filter.productIdx) {
qb.andWhere(`${columns.productIdx} = :productIdx`, {
productIdx: filter.productIdx,
});
}
if (filter.from) {
qb.andWhere(`${columns.occurredAt} >= :from`, { from: filter.from });
}
if (filter.to) {
qb.andWhere(`${columns.occurredAt} < :to`, { to: filter.to });
}
if (filter.partnerId === PARTNER_FILTER_NONE) {
qb.andWhere(`${columns.conversion}.id IS NULL`);
} else if (filter.partnerId) {
qb.andWhere(`${columns.conversion}.partner_id = :partnerId`, {
partnerId: filter.partnerId,
});
}
if (filter.q?.trim()) {
const conditions = columns.searchColumns
.map((column) => `${column} ILIKE :q`)
.join(' OR ');
qb.andWhere(`(${conditions})`, {
q: `%${escapeLike(filter.q.trim())}%`,
});
}
}
/** 두 쪽을 UNION ALL 로 합친 결과(u)에서 시작하는 쿼리 */
private fromUnion(
parts: SelectQueryBuilder<ObjectLiteral>[],
): SelectQueryBuilder<ObjectLiteral> {
const sql = parts.map((part) => `(${part.getQuery()})`).join(' UNION ALL ');
const parameters = Object.assign(
{},
...parts.map((part) => part.getParameters()),
) as ObjectLiteral;
return this.dataSource
.createQueryBuilder()
.from(`(${sql})`, UNION)
.setParameters(parameters);
}
private async membersByCode(
codes: (string | null)[],
): Promise<Map<string, ImwebMember>> {
const unique = [...new Set(codes.filter((code): code is string => !!code))];
if (unique.length === 0) return new Map();
const rows = await this.members.find({ where: { memberCode: In(unique) } });
return new Map(rows.map((row) => [row.memberCode, row]));
}
private async partnerNames(
ids: (string | null)[],
): Promise<Map<string, string>> {
const unique = [...new Set(ids.filter((id): id is string => !!id))];
if (unique.length === 0) return new Map();
const rows = await this.partners.find({
select: { id: true, name: true },
where: { id: In(unique) },
});
return new Map(rows.map((row) => [row.id, row.name]));
}
}
@@ -0,0 +1,159 @@
import { ApiPropertyOptional, IntersectionType } from '@nestjs/swagger';
import {
IsDateString,
IsIn,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { INFLOW_GROUP_BY } from '@/modules/members/dto/member-query.dto';
import type { InflowGroupBy } from '@/modules/members/dto/member-query.dto';
import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow';
const SEARCH_MAX_LENGTH = 100;
/** 내역 종류 — 무료 신청 / 유료 구매 */
export const ACTIVITY_TYPES = ['application', 'purchase'] as const;
export type ActivityType = (typeof ACTIVITY_TYPES)[number];
/** 업체 필터 — 업체 ID(UUID) 또는 none(업체 링크를 거치지 않은 신청·구매) */
export const PARTNER_FILTER_NONE = 'none';
const PARTNER_FILTER_PATTERN =
/^(none|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
/**
* 유입을 어느 기록으로 볼 것인가.
* - apply : 신청·구매 때 유입 — 신청 완료·주문완료 화면이 보낸 그 순간의 경로 (UTM 스크립트 보관 2일).
* 주문완료 화면이 보내지 못한 구매는 값 없음
* - join : 가입 경로 — 신청·구매한 회원이 가입할 때 기록된 값만 (가입 후 채워진 값·비회원은 값 없음)
*/
export const ACTIVITY_INFLOW_BASIS = ['apply', 'join'] as const;
export type ActivityInflowBasis = (typeof ACTIVITY_INFLOW_BASIS)[number];
/** 요약 묶음 기준 — 상품별 / 업체별 */
export const ACTIVITY_GROUP_BY = ['product', 'partner'] as const;
export type ActivityGroupBy = (typeof ACTIVITY_GROUP_BY)[number];
/** 기간 — 신청 시각 또는 주문 시각 */
export class ActivityPeriodDto {
@ApiPropertyOptional({
description: '시작 (이상, ISO 8601) — 신청 시각·주문 시각 기준',
example: '2026-09-30T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '시작일 형식이 올바르지 않습니다.' })
from?: string;
@ApiPropertyOptional({
description: '끝 (미만, ISO 8601)',
example: '2026-10-31T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '종료일 형식이 올바르지 않습니다.' })
to?: string;
}
/** 목록·유입 집계가 함께 쓰는 필터 */
export class ActivityFilterDto extends ActivityPeriodDto {
@ApiPropertyOptional({
description:
'종류 — application(무료 신청) · purchase(유료 구매). 없으면 둘 다',
enum: ACTIVITY_TYPES,
})
@IsOptional()
@IsIn(ACTIVITY_TYPES, { message: '종류 값이 올바르지 않습니다.' })
type?: ActivityType;
@ApiPropertyOptional({ description: '상품번호', example: '52' })
@IsOptional()
@Matches(/^\d{1,20}$/, { message: '상품번호 형식이 올바르지 않습니다.' })
productIdx?: string;
@ApiPropertyOptional({
description:
'업체 — 그 업체 링크 실적으로 잡힌 신청·구매만. none 이면 업체 링크를 거치지 않은 것',
example: 'none',
})
@IsOptional()
@Matches(PARTNER_FILTER_PATTERN, { message: '업체 값이 올바르지 않습니다.' })
partnerId?: string;
}
/** 상품별·업체별 신청·구매 수 — 기간만 걸러서 센다 */
export class ActivityGroupsQueryDto extends ActivityPeriodDto {
@ApiPropertyOptional({
description: '묶음 기준 — product(상품별, 기본) · partner(업체별)',
enum: ACTIVITY_GROUP_BY,
default: 'product',
})
@IsOptional()
@IsIn(ACTIVITY_GROUP_BY, { message: '묶음 기준 값이 올바르지 않습니다.' })
by: ActivityGroupBy = 'product';
}
/** 관리자 신청·구매 내역 조회 */
export class ActivityListQueryDto extends IntersectionType(
CursorPaginationQueryDto,
ActivityFilterDto,
) {
@ApiPropertyOptional({
description:
'유입 필터 기준 — apply(신청·구매 때 유입) · join(가입 경로). inflowField·inflowValue 와 함께 쓴다',
enum: ACTIVITY_INFLOW_BASIS,
})
@IsOptional()
@IsIn(ACTIVITY_INFLOW_BASIS, { message: '유입 기준 값이 올바르지 않습니다.' })
inflowBasis?: ActivityInflowBasis;
@ApiPropertyOptional({
description: '유입 필터 항목 — source · medium · campaign · content',
enum: INFLOW_GROUP_BY,
})
@IsOptional()
@IsIn(INFLOW_GROUP_BY, { message: '유입 항목 값이 올바르지 않습니다.' })
inflowField?: InflowGroupBy;
@ApiPropertyOptional({
description: '유입 필터 값 (집계 막대를 눌러 거를 때)',
example: 'facebook',
})
@IsOptional()
@IsString()
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
inflowValue?: string;
@ApiPropertyOptional({
description:
'검색어 — 이름·아이디·이메일·연락처 부분 일치 (비회원은 주문자 정보)',
example: '홍길동',
})
@IsOptional()
@IsString()
@MaxLength(SEARCH_MAX_LENGTH, { message: '검색어가 너무 깁니다.' })
q?: string;
}
/** 유입 집계 — 신청 때 유입 또는 가입 경로 기준 */
export class ActivityInflowStatsQueryDto extends ActivityFilterDto {
@ApiPropertyOptional({
description:
'집계 기준 — apply(신청·구매 때 유입 · 기본) · join(가입 경로)',
enum: ACTIVITY_INFLOW_BASIS,
default: 'apply',
})
@IsOptional()
@IsIn(ACTIVITY_INFLOW_BASIS, { message: '유입 기준 값이 올바르지 않습니다.' })
basis: ActivityInflowBasis = 'apply';
@ApiPropertyOptional({
description: '집계 항목',
enum: INFLOW_GROUP_BY,
default: 'source',
})
@IsOptional()
@IsIn(INFLOW_GROUP_BY, { message: '집계 기준 값이 올바르지 않습니다.' })
groupBy: InflowGroupBy = 'source';
}
@@ -0,0 +1,224 @@
import { ApiProperty } from '@nestjs/swagger';
import { INFLOW_GROUP_BY } from '@/modules/members/dto/member-query.dto';
import type { InflowGroupBy } from '@/modules/members/dto/member-query.dto';
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
import {
ACTIVITY_GROUP_BY,
ACTIVITY_INFLOW_BASIS,
ACTIVITY_TYPES,
} from './activity-query.dto';
import type {
ActivityGroupBy,
ActivityInflowBasis,
ActivityType,
} from './activity-query.dto';
/** 신청 순간의 유입정보 */
export class ActivityInflowDto {
@ApiProperty({ description: '유입원', nullable: true, example: 'facebook' })
source!: string | null;
@ApiProperty({ description: '매체', nullable: true, example: 'paid' })
medium!: string | null;
@ApiProperty({ description: '캠페인', nullable: true, example: 'fall' })
campaign!: string | null;
@ApiProperty({ description: '콘텐츠', nullable: true, example: 'video_a' })
content!: string | null;
}
/** 신청·구매를 데려온 업체 (업체 링크 실적으로 잡힌 경우) */
export class ActivityPartnerDto {
@ApiProperty({ description: '업체 ID' })
id!: string;
@ApiProperty({ description: '업체 이름', example: '머니튜브' })
name!: string;
}
/** 주문서의 주문자 — 비회원 구매는 이 값으로만 누군지 안다 */
export class ActivityOrdererDto {
@ApiProperty({ description: '주문자 이름', nullable: true })
name!: string | null;
@ApiProperty({ description: '주문자 이메일', nullable: true })
email!: string | null;
@ApiProperty({ description: '주문자 연락처', nullable: true })
phone!: string | null;
}
/** 관리자 신청·구매 내역 한 건 */
export class ActivityResponseDto {
@ApiProperty({
description: '종류 — application(무료 신청) · purchase(유료 구매)',
enum: ACTIVITY_TYPES,
})
type!: ActivityType;
@ApiProperty({ description: '신청 기록 ID 또는 구매 기록 ID' })
id!: string;
@ApiProperty({
description: '신청 시각 또는 주문 시각',
example: '2026-10-05T03:00:00.000Z',
})
occurredAt!: string;
@ApiProperty({ description: '상품번호', example: '52' })
productIdx!: string;
@ApiProperty({
description: '상품명 (신청 화면 표시값 또는 주문 당시 상품명)',
nullable: true,
})
productName!: string | null;
@ApiProperty({
description: '신청·주문 당시 아이디 — 비회원 구매면 null',
nullable: true,
})
memberUid!: string | null;
@ApiProperty({
description:
'회원 정보 (동기화된 회원 기준) — 비회원·탈퇴 등으로 없으면 null',
type: MemberResponseDto,
nullable: true,
})
member!: MemberResponseDto | null;
@ApiProperty({
description: '업체 링크 실적으로 잡힌 경우 그 업체 — 아니면 null',
type: ActivityPartnerDto,
nullable: true,
})
partner!: ActivityPartnerDto | null;
@ApiProperty({
description:
'[신청] 오픈카톡 버튼을 처음 누른 시각 — 안 눌렀거나 구매면 null',
nullable: true,
})
kakaoClickedAt!: string | null;
@ApiProperty({
description:
'신청·구매 때 유입 — 신청 완료·주문완료 화면이 보낸 그 순간의 경로. 받지 못했으면 항목이 모두 null',
type: ActivityInflowDto,
})
inflow!: ActivityInflowDto;
@ApiProperty({
description: '[구매] 아임웹 주문번호 — 신청이면 null',
nullable: true,
})
orderNo!: string | null;
@ApiProperty({
description: '[구매] 환불·취소·반품됐는가 — 신청이면 null',
nullable: true,
})
isCancelled!: boolean | null;
@ApiProperty({
description: '[구매] 주문자 — 신청이면 null',
type: ActivityOrdererDto,
nullable: true,
})
orderer!: ActivityOrdererDto | null;
}
/** 신청·구매 수 */
export class ActivityCountsDto {
@ApiProperty({ description: '무료 신청 수', example: 120 })
applicationCount!: number;
@ApiProperty({
description: '그중 오픈카톡 버튼을 누른 수 (실제 입장 여부는 알 수 없다)',
example: 87,
})
kakaoCount!: number;
@ApiProperty({ description: '유료 구매 수 (환불·취소 제외)', example: 12 })
purchaseCount!: number;
@ApiProperty({ description: '환불·취소된 구매 수', example: 1 })
cancelledCount!: number;
}
/** 상품별·업체별 한 줄 */
export class ActivityGroupItemDto extends ActivityCountsDto {
@ApiProperty({
description:
'묶음 값 — 상품번호 또는 업체 ID. 업체별에서 업체 링크를 거치지 않은 것은 null',
nullable: true,
example: '52',
})
key!: string | null;
@ApiProperty({
description:
'표시 이름 — 상품명(아임웹 기준, 없으면 가장 최근 표시값) 또는 업체 이름',
nullable: true,
})
label!: string | null;
}
/** 상품별·업체별 신청·구매 수 */
export class ActivityGroupsResponseDto {
@ApiProperty({
description: '묶음 기준',
enum: ACTIVITY_GROUP_BY,
example: 'product',
})
by!: ActivityGroupBy;
@ApiProperty({ description: '기간 내 전체', type: ActivityCountsDto })
totals!: ActivityCountsDto;
@ApiProperty({
description: '신청·구매가 많은 순 (최대 100개)',
type: [ActivityGroupItemDto],
})
items!: ActivityGroupItemDto[];
}
/** 유입 집계 한 줄 */
export class ActivityInflowStatItemDto {
@ApiProperty({
description: '집계 값 (값이 없으면 null)',
nullable: true,
example: 'facebook',
})
key!: string | null;
@ApiProperty({ description: '신청·구매 수', example: 42 })
count!: number;
}
/** 유입 집계 결과 */
export class ActivityInflowStatsResponseDto {
@ApiProperty({ description: '집계 기준', enum: ACTIVITY_INFLOW_BASIS })
basis!: ActivityInflowBasis;
@ApiProperty({ description: '집계 항목', enum: INFLOW_GROUP_BY })
groupBy!: InflowGroupBy;
@ApiProperty({ description: '집계한 무료 신청 수', example: 300 })
applicationTotal!: number;
@ApiProperty({
description: '집계한 유료 구매 수 (환불·취소 제외)',
example: 10,
})
purchaseTotal!: number;
@ApiProperty({
description: '많은 순 (최대 100개, 값 없음은 key=null)',
type: [ActivityInflowStatItemDto],
})
items!: ActivityInflowStatItemDto[];
}
@@ -47,7 +47,7 @@ export class MemberApplication {
@Column({ name: 'kakao_clicked_at', type: 'timestamptz', nullable: true })
kakaoClickedAt!: Date | null;
/** 신청 순간의 유입정보 원본 (header-top.html 이 저장해 둔 값, 파이프 형식) */
/** 신청 순간의 유입정보 원본 (imweb-header-top.html 이 저장해 둔 값, 파이프 형식) */
@Column({ name: 'inflow_raw', type: 'text', nullable: true })
inflowRaw!: string | null;
@@ -83,4 +83,47 @@ export class MemberApplication {
nullable: true,
})
inflowContent!: string | null;
/*
* 신청 때 최종 유입 — 신청 직전에 마지막으로 들어온 경로 (신청을 일으킨 유입).
* 위 inflow_* 는 신청 시점 브라우저에 남아 있던 '최초' 유입(2일 보관)이라,
* 2일 안에 다른 경로로 다시 들어와 신청하면 둘이 달라진다. 유입이 한 번뿐이면 같은 값이다.
*/
/** 최종 유입 원본 (imweb-header-top.html 의 MG_UTM.peek().last, 파이프 형식) */
@Column({ name: 'inflow_last_raw', type: 'text', nullable: true })
inflowLastRaw!: string | null;
@Index()
@Column({
name: 'inflow_last_source',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowLastSource!: string | null;
@Column({
name: 'inflow_last_medium',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowLastMedium!: string | null;
@Column({
name: 'inflow_last_campaign',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowLastCampaign!: string | null;
@Column({
name: 'inflow_last_content',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowLastContent!: string | null;
}
@@ -1,32 +1,21 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { PaginatedResult } from '@/common/dto/pagination.dto';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
import { ADMIN_BEARER_AUTH } from '@/modules/admin-auth/admin-auth.constants';
import { ApplicationsService } from './applications.service';
import { ApplicationListQueryDto } from './dto/application-query.dto';
import {
ApplicationProductDto,
ApplicationResponseDto,
} from './dto/application-response.dto';
import {
CreateApplicationDto,
CreateApplicationResultDto,
@@ -71,37 +60,3 @@ export class ApplicationsController {
await this.applications.markKakaoClicked(id);
}
}
/** 관리자 신청 내역 조회 */
@ApiTags('Admin Applications')
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@UseGuards(AdminAuthGuard)
@Controller('admin/applications')
export class AdminApplicationsController {
constructor(private readonly applications: ApplicationsService) {}
@Get()
@ApiOperation({ summary: '신청 내역 (최신순, 커서 페이지네이션)' })
@ApiStandardResponse(ApplicationResponseDto, {
description: '조회 성공 — data 는 { items, pageInfo }',
isArray: true,
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '필터·커서 형식 오류')
findMany(
@Query() query: ApplicationListQueryDto,
): Promise<PaginatedResult<ApplicationResponseDto>> {
return this.applications.findMany(query);
}
@Get('products')
@ApiOperation({ summary: '상품별 신청 수 (최근 신청이 있는 상품부터)' })
@ApiStandardResponse(ApplicationProductDto, {
description: '조회 성공',
isArray: true,
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
products(): Promise<ApplicationProductDto[]> {
return this.applications.products();
}
}
@@ -1,29 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminAuthModule } from '@/modules/admin-auth/admin-auth.module';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { ImwebModule } from '@/modules/imweb/imweb.module';
import { MembersModule } from '@/modules/members/members.module';
import { PartnersModule } from '@/modules/partners/partners.module';
import { MemberApplication } from './application.entity';
import {
AdminApplicationsController,
ApplicationsController,
} from './applications.controller';
import { ApplicationsController } from './applications.controller';
import { ApplicationsService } from './applications.service';
/** 강의 신청 기록 — 신청 완료 화면이 보내고, 관리자 화면에서 본다 */
/**
* 강의 신청 기록 — 신청 완료 화면이 보낸다.
* 관리자 화면의 조회는 ActivitiesModule(신청·구매 내역)이 맡는다.
*/
@Module({
imports: [
TypeOrmModule.forFeature([MemberApplication, ImwebMember]),
AdminAuthModule,
TypeOrmModule.forFeature([MemberApplication]),
ImwebModule,
MembersModule,
// 신청을 업체 링크 실적으로 귀속한다
PartnersModule,
],
controllers: [ApplicationsController, AdminApplicationsController],
controllers: [ApplicationsController],
providers: [ApplicationsService],
})
export class ApplicationsModule {}
@@ -3,7 +3,6 @@ import { QueryFailedError, type Repository } from 'typeorm';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import type { ImwebConfig } from '@/modules/imweb/imweb.config';
import type { ImwebMemberLookupService } from '@/modules/imweb/imweb-member-lookup.service';
import type { MembersService } from '@/modules/members/members.service';
import type { PartnerAttributionService } from '@/modules/partners/partner-attribution.service';
import { MemberApplication } from './application.entity';
import { ApplicationsService } from './applications.service';
@@ -46,9 +45,7 @@ function setup(member: ImwebMember | null = MEMBER) {
};
const service = new ApplicationsService(
applications as unknown as Repository<MemberApplication>,
{} as Repository<ImwebMember>,
lookup as unknown as ImwebMemberLookupService,
{} as MembersService,
{ trackingHost: 'utm.moneyguider.co.kr' } as ImwebConfig,
attribution as unknown as PartnerAttributionService,
);
@@ -110,6 +107,46 @@ describe('ApplicationsService.create', () => {
expect(store).toHaveLength(0);
});
it('최종 유입을 최초 유입과 따로 저장한다 (2일 안에 다른 경로로 다시 와서 신청)', async () => {
const { service, store } = setup();
await service.create({
memberUid: 'user@test.dev',
productIdx: '52',
inflow: 'src=naver|med=organic|jd=2026-10-05',
lastInflow: 'src=facebook|med=paid|cmp=fall|cnt=video_b|ts=2026-10-05',
});
expect(store[0]).toMatchObject({
inflowSource: 'naver',
inflowMedium: 'organic',
inflowLastRaw: 'src=facebook|med=paid|cmp=fall|cnt=video_b|ts=2026-10-05',
inflowLastSource: 'facebook',
inflowLastMedium: 'paid',
inflowLastCampaign: 'fall',
inflowLastContent: 'video_b',
});
});
it('최종 유입이 없으면 최초 유입을 최종 유입으로도 쓴다 (유입이 한 번뿐)', async () => {
const { service, store } = setup();
await service.create({
memberUid: 'user@test.dev',
productIdx: '52',
inflow: 'src=naver|med=cpc|cmp=spring|jd=2026-10-05',
lastInflow: '형식 아님',
});
expect(store[0]).toMatchObject({
inflowSource: 'naver',
inflowLastRaw: null,
inflowLastSource: 'naver',
inflowLastMedium: 'cpc',
inflowLastCampaign: 'spring',
});
});
it('유입정보가 없거나 스크립트 형식이 아니면 비워 둔다', async () => {
const { service, store } = setup();
@@ -4,38 +4,19 @@ import { randomUUID } from 'crypto';
import { IsNull, QueryFailedError, Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { ImwebConfig } from '@/modules/imweb/imweb.config';
import { ImwebMemberLookupService } from '@/modules/imweb/imweb-member-lookup.service';
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
import { MembersService } from '@/modules/members/members.service';
import { PartnerAttributionService } from '@/modules/partners/partner-attribution.service';
import { parseInflow } from '@/shared/utils/inflow';
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
import { MemberApplication } from './application.entity';
import { ApplicationListQueryDto } from './dto/application-query.dto';
import {
ApplicationProductDto,
ApplicationResponseDto,
} from './dto/application-response.dto';
import {
CreateApplicationDto,
CreateApplicationResultDto,
} from './dto/create-application.dto';
const ALIAS = 'a';
const MEMBER_ALIAS = 'm';
/** Postgres 유일 제약 위반 — 같은 회원·상품이 동시에 두 번 들어온 경우 */
const PG_UNIQUE_VIOLATION = '23505';
function escapeLike(value: string): string {
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
}
function isUniqueViolation(error: unknown): boolean {
return (
error instanceof QueryFailedError &&
@@ -50,6 +31,7 @@ function isUniqueViolation(error: unknown): boolean {
* 신청 완료 화면은 공개 페이지라 보낸 사람이 정말 그 회원인지는 확인할 수 없다.
* 그래서 (1) 아임웹에 실제로 있는 회원인지 확인하고 (2) 회원·상품당 한 건만 남기며
* (3) 화면에 보이는 회원 정보는 브라우저가 보낸 값이 아니라 아임웹에서 받은 값만 쓴다.
* 관리자 조회는 유료 구매와 함께 ActivitiesModule(신청·구매 내역)이 맡는다.
*/
@Injectable()
export class ApplicationsService {
@@ -58,10 +40,7 @@ export class ApplicationsService {
constructor(
@InjectRepository(MemberApplication)
private readonly applications: Repository<MemberApplication>,
@InjectRepository(ImwebMember)
private readonly members: Repository<ImwebMember>,
private readonly lookup: ImwebMemberLookupService,
private readonly membersService: MembersService,
private readonly config: ImwebConfig,
private readonly attribution: PartnerAttributionService,
) {}
@@ -92,6 +71,13 @@ export class ApplicationsService {
now.toISOString(),
this.config.trackingHost,
);
// 최종 유입이 없으면(유입이 한 번뿐이거나 예전 위젯) 최초 유입이 곧 최종 유입이다
const parsedLast = parseInflow(
dto.lastInflow,
now.toISOString(),
this.config.trackingHost,
);
const last = parsedLast.source ? parsedLast : inflow;
const row = this.applications.create({
id: randomUUID(),
memberCode: member.memberCode,
@@ -105,6 +91,13 @@ export class ApplicationsService {
inflowMedium: inflow.medium,
inflowCampaign: inflow.campaign,
inflowContent: inflow.content,
inflowLastRaw: parsedLast.source
? (dto.lastInflow?.trim() ?? null)
: null,
inflowLastSource: last.source,
inflowLastMedium: last.medium,
inflowLastCampaign: last.campaign,
inflowLastContent: last.content,
});
try {
@@ -155,153 +148,10 @@ export class ApplicationsService {
);
}
async findMany(
query: ApplicationListQueryDto,
): Promise<PaginatedResult<ApplicationResponseDto>> {
const base = this.applications
.createQueryBuilder(ALIAS)
// 회원 검색을 위해 동기화된 회원과 잇는다 (탈퇴 회원은 회원 정보 없이 남는다)
.leftJoin(
ImwebMember,
MEMBER_ALIAS,
`${MEMBER_ALIAS}.member_code = ${ALIAS}.member_code`,
);
if (query.productIdx) {
base.andWhere(`${ALIAS}.productIdx = :productIdx`, {
productIdx: query.productIdx,
});
}
if (query.appliedFrom) {
base.andWhere(`${ALIAS}.appliedAt >= :appliedFrom`, {
appliedFrom: query.appliedFrom,
});
}
if (query.appliedTo) {
base.andWhere(`${ALIAS}.appliedAt < :appliedTo`, {
appliedTo: query.appliedTo,
});
}
if (query.source) {
base.andWhere(`${ALIAS}.inflowSource = :source`, {
source: query.source,
});
}
if (query.q) {
base.andWhere(
`(${MEMBER_ALIAS}.name ILIKE :q OR ${ALIAS}.member_uid ILIKE :q OR ${MEMBER_ALIAS}.email ILIKE :q OR ${MEMBER_ALIAS}.callnum ILIKE :q)`,
{ q: `%${escapeLike(query.q.trim())}%` },
);
}
const total = await base.clone().getCount();
const page = base
.clone()
.orderBy(`${ALIAS}.appliedAt`, 'DESC')
.addOrderBy(`${ALIAS}.id`, 'DESC')
.take(query.limit + 1);
if (query.cursor) {
const cursor = decodeTimeCursor(query.cursor);
if (!cursor) {
throw new BusinessException('VAL_001', {
context: { reason: 'invalid_cursor' },
});
}
page.andWhere(
`(${ALIAS}.appliedAt, ${ALIAS}.id) < (:cursorTime, :cursorId)`,
{
cursorTime: cursor.time,
cursorId: cursor.key,
},
);
}
const rows = await page.getMany();
const hasNext = rows.length > query.limit;
const items = rows.slice(0, query.limit);
const last = items.at(-1);
const [membersByCode, groupTitles] = await Promise.all([
this.membersByCode(items.map((row) => row.memberCode)),
this.membersService.groupTitleMap(),
]);
return toPaginatedResult(
items.map((row) => {
const member = membersByCode.get(row.memberCode);
return {
id: row.id,
appliedAt: row.appliedAt.toISOString(),
kakaoClickedAt: row.kakaoClickedAt?.toISOString() ?? null,
productIdx: row.productIdx,
productName: row.productName,
memberUid: row.memberUid,
member: member ? MemberResponseDto.from(member, groupTitles) : null,
inflow: {
source: row.inflowSource,
medium: row.inflowMedium,
campaign: row.inflowCampaign,
content: row.inflowContent,
},
};
}),
{
hasNext,
nextCursor:
hasNext && last
? encodeTimeCursor({
time: last.appliedAt.toISOString(),
key: last.id,
})
: null,
total,
},
);
}
/** 상품별 신청 수 — 최근 신청이 있는 상품부터 */
async products(): Promise<ApplicationProductDto[]> {
const rows = await this.applications
.createQueryBuilder(ALIAS)
.select(`${ALIAS}.productIdx`, 'productIdx')
// 상품명은 바뀔 수 있어 가장 최근 신청의 표시값을 쓴다
.addSelect(
`(ARRAY_AGG(${ALIAS}.product_name ORDER BY ${ALIAS}.applied_at DESC))[1]`,
'productName',
)
.addSelect('COUNT(*)', 'count')
.groupBy(`${ALIAS}.productIdx`)
.orderBy(`MAX(${ALIAS}.applied_at)`, 'DESC')
.getRawMany<{
productIdx: string;
productName: string | null;
count: string;
}>();
return rows.map((row) => ({
productIdx: row.productIdx,
productName: row.productName,
count: Number(row.count),
}));
}
private findExisting(
memberCode: string,
productIdx: string,
): Promise<MemberApplication | null> {
return this.applications.findOne({ where: { memberCode, productIdx } });
}
private async membersByCode(
codes: string[],
): Promise<Map<string, ImwebMember>> {
if (codes.length === 0) return new Map();
const rows = await this.members
.createQueryBuilder(MEMBER_ALIAS)
.where(`${MEMBER_ALIAS}.memberCode IN (:...codes)`, { codes })
.getMany();
return new Map(rows.map((row) => [row.memberCode, row]));
}
}
@@ -1,55 +0,0 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow';
const SEARCH_MAX_LENGTH = 100;
/** 관리자 신청 내역 조회 */
export class ApplicationListQueryDto extends CursorPaginationQueryDto {
@ApiPropertyOptional({ description: '상품번호', example: '52' })
@IsOptional()
@Matches(/^\d{1,20}$/, { message: '상품번호 형식이 올바르지 않습니다.' })
productIdx?: string;
@ApiPropertyOptional({
description: '신청 시각 시작 (이상, ISO 8601)',
example: '2026-09-30T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '신청 시작일 형식이 올바르지 않습니다.' })
appliedFrom?: string;
@ApiPropertyOptional({
description: '신청 시각 끝 (미만, ISO 8601)',
example: '2026-10-31T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '신청 종료일 형식이 올바르지 않습니다.' })
appliedTo?: string;
@ApiPropertyOptional({
description: '신청 순간의 유입원',
example: 'facebook',
})
@IsOptional()
@IsString()
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
source?: string;
@ApiPropertyOptional({
description: '검색어 — 이름·아이디·이메일·연락처 부분 일치',
example: '홍길동',
})
@IsOptional()
@IsString()
@MaxLength(SEARCH_MAX_LENGTH, { message: '검색어가 너무 깁니다.' })
q?: string;
}
@@ -1,73 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
/** 신청 순간의 유입정보 */
export class ApplicationInflowDto {
@ApiProperty({ description: '유입원', nullable: true, example: 'facebook' })
source!: string | null;
@ApiProperty({ description: '매체', nullable: true, example: 'paid' })
medium!: string | null;
@ApiProperty({ description: '캠페인', nullable: true, example: 'fall' })
campaign!: string | null;
@ApiProperty({ description: '콘텐츠', nullable: true, example: 'video_a' })
content!: string | null;
}
/** 관리자 신청 내역 한 건 */
export class ApplicationResponseDto {
@ApiProperty({ description: '신청 기록 ID' })
id!: string;
@ApiProperty({
description: '신청 시각',
example: '2026-10-05T03:00:00.000Z',
})
appliedAt!: string;
@ApiProperty({
description: '오픈카톡 버튼을 처음 누른 시각 (안 눌렀으면 null)',
nullable: true,
})
kakaoClickedAt!: string | null;
@ApiProperty({ description: '상품번호', example: '52' })
productIdx!: string;
@ApiProperty({ description: '상품명 (신청 화면 표시값)', nullable: true })
productName!: string | null;
@ApiProperty({ description: '신청 당시 아이디', example: 'user@example.com' })
memberUid!: string;
@ApiProperty({
description: '회원 정보 (동기화된 회원 기준) — 탈퇴 등으로 없으면 null',
type: MemberResponseDto,
nullable: true,
})
member!: MemberResponseDto | null;
@ApiProperty({
description: '신청 순간의 유입정보',
type: ApplicationInflowDto,
})
inflow!: ApplicationInflowDto;
}
/** 상품별 신청 수 — 필터 칩에 쓴다 */
export class ApplicationProductDto {
@ApiProperty({ description: '상품번호', example: '52' })
productIdx!: string;
@ApiProperty({
description: '가장 최근 신청에 표시된 상품명',
nullable: true,
})
productName!: string | null;
@ApiProperty({ description: '신청 수', example: 120 })
count!: number;
}
@@ -14,7 +14,7 @@ import { CLICK_REF_PATTERN } from '@/shared/utils/partner-link';
const MEMBER_ID_MAX_LENGTH = 255;
const PRODUCT_NAME_MAX_LENGTH = 200;
/** header-top.html 이 만드는 값은 200자 남짓 — 넉넉히 두되 무한정 받지 않는다 */
/** imweb-header-top.html 이 만드는 값은 200자 남짓 — 넉넉히 두되 무한정 받지 않는다 */
const INFLOW_RAW_MAX_LENGTH = 1000;
/** 신청 완료 화면이 보내는 신청 기록 */
@@ -44,7 +44,7 @@ export class CreateApplicationDto {
@ApiPropertyOptional({
description:
'신청 순간의 유입정보 (header-top.html 형식: src=..|med=..|cmp=..)',
'신청 순간의 유입정보 (imweb-header-top.html 형식: src=..|med=..|cmp=..)',
example: 'src=facebook|med=paid|cmp=fall|jd=2026-10-05',
})
@IsOptional()
@@ -54,7 +54,17 @@ export class CreateApplicationDto {
@ApiPropertyOptional({
description:
'브라우저가 보관한 업체 링크 클릭 ID 목록 (imweb/partner-ref.html 의 MG_PARTNER.refs())',
'신청 직전의 최종 유입 (MG_UTM.peek().last 를 같은 파이프 형식으로). 없으면 inflow 를 쓴다',
example: 'src=facebook|med=paid|cmp=fall|ts=2026-10-05',
})
@IsOptional()
@IsString()
@MaxLength(INFLOW_RAW_MAX_LENGTH)
lastInflow?: string;
@ApiPropertyOptional({
description:
'브라우저가 보관한 업체 링크 클릭 ID 목록 (imweb/imweb-header-top.html 의 MG_PARTNER.refs())',
example: ['AbCd1234EfGh5678'],
type: [String],
})
+1 -1
View File
@@ -39,7 +39,7 @@ export const IMWEB_CALLBACK_PATH = '/imweb/oauth/callback';
const DEFAULT_SYNC_INTERVAL_MINUTES = 60;
const DEFAULT_PAGE_SIZE = 100;
/** header-top.html 이 홈페이지 항목에 쓰는 가짜 호스트 ('https://utm.' + OWN_HOST) */
/** imweb-header-top.html 이 홈페이지 항목에 쓰는 가짜 호스트 ('https://utm.' + OWN_HOST) */
export const DEFAULT_TRACKING_HOST = 'utm.moneyguider.co.kr';
/**
+4
View File
@@ -103,6 +103,10 @@ export interface ImwebOrderData {
isMember?: string | null;
memberCode?: string | null;
memberUid?: string | null;
/** 주문자 — 비회원 주문은 이 값으로만 누군지 알 수 있다 */
ordererName?: string | null;
ordererEmail?: string | null;
ordererCall?: string | null;
/** 주문 생성 시각 (UTC) */
wtime: string;
payments?: {
@@ -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;
}
/** 주문 실적 새로고침 상태 (서버 메모리 기준 — 재시작하면 비워진다) */
@@ -99,8 +99,9 @@ export class PartnerLinkResponseDto {
code!: string;
@ApiProperty({
description: '업체에 전달할 단축 링크',
example: 'https://moneyguide.wandergo.net/r/Ab3kX9pQ',
description:
'업체에 전달할 링크 — 아임웹 사이트의 /go 입구 (사이트 주소를 모르면 백엔드 /r/{코드})',
example: 'https://www.moneyguider.co.kr/go?c=Ab3kX9pQ',
})
shortUrl!: string;
@@ -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,14 +14,17 @@ 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,
LINK_CODE_PATTERN,
buildEntryUrl,
isBotUserAgent,
normalizeTargetUrl,
toBase62,
withPartnerRef,
withPartnerUtm,
} from '@/shared/utils/partner-link';
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
import {
@@ -80,6 +83,7 @@ export class PartnerLinksService {
private readonly membersService: MembersService,
private readonly imwebConfig: ImwebConfig,
private readonly config: ConfigService,
private readonly purchases: PurchasesService,
) {}
/** 링크 목록 — 최근 발급순, 기간 내 실적 포함 */
@@ -238,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,
@@ -259,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,
};
}),
{
@@ -287,7 +308,10 @@ export class PartnerLinksService {
method: string,
): Promise<string> {
const link = LINK_CODE_PATTERN.test(code)
? await this.links.findOne({ where: { code } })
? await this.links.findOne({
where: { code },
relations: { partner: true },
})
: null;
if (!link) {
@@ -295,8 +319,14 @@ export class PartnerLinksService {
if (!home) throw new BusinessException('RES_001', { context: { code } });
return home;
}
// 업체 UTM 은 클릭 기록 여부와 상관없이 붙인다 — 유입경로 기록용이고 업체 실적과는 별개다
const target = withPartnerUtm(
link.targetUrl,
link.partner?.name ?? '',
link.code,
);
if (!link.isActive || method !== 'GET' || isBotUserAgent(userAgent)) {
return link.targetUrl;
return target;
}
const ref = toBase62(randomBytes(CLICK_REF_LENGTH));
@@ -307,9 +337,9 @@ export class PartnerLinksService {
this.logger.warn(
`업체 링크 클릭 기록 실패: link=${link.id} ${String(error)}`,
);
return link.targetUrl;
return target;
}
return withPartnerRef(link.targetUrl, ref);
return withPartnerRef(target, ref);
}
private async require(id: string): Promise<PartnerLink> {
@@ -357,12 +387,17 @@ export class PartnerLinksService {
]);
const partnerById = new Map(partnerRows.map((p) => [p.id, p]));
const productByNo = new Map(productRows.map((p) => [p.prodNo, p]));
const siteUrl = this.imwebConfig.siteUrl;
const shortBase = this.shortLinkBase();
return rows.map((row) => ({
id: row.id,
code: row.code,
shortUrl: shortBase ? `${shortBase}/${SHORT_LINK_PATH}/${row.code}` : '',
shortUrl: siteUrl
? buildEntryUrl(siteUrl, row.code)
: shortBase
? `${shortBase}/${SHORT_LINK_PATH}/${row.code}`
: '',
partner: {
id: row.partnerId,
name: partnerById.get(row.partnerId)?.name ?? '',
@@ -378,7 +413,10 @@ export class PartnerLinksService {
}));
}
/** 단축 링크 앞부분 — 백엔드 공개 주소(BACKEND_API_URL)의 origin */
/**
* 백엔드 단축 링크 앞부분 — 백엔드 공개 주소(BACKEND_API_URL)의 origin.
* 업체에는 아임웹 /go 주소를 주고, 이 주소는 아임웹 사이트 주소를 모를 때만 대신 쓴다.
*/
private shortLinkBase(): string {
const apiUrl = (this.config.get<string>('BACKEND_API_URL') ?? '').trim();
if (!apiUrl) return '';
@@ -0,0 +1,120 @@
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';
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[] = [],
refsByOrder = new Map<string, string[]>(),
) {
const get = jest.fn(() =>
Promise.resolve({ data: { list: [ORDER], totalPage: 1 } }),
);
const auth = {
requireConnection: jest.fn(() => Promise.resolve({ unitCode: 'u1' })),
authorizedGet: get,
};
const attribution = {
linkedProdNos: jest.fn(() => Promise.resolve(new Set(linked))),
attributeOrder: jest.fn(() => Promise.resolve({ created: 0, updated: 0 })),
};
const purchases = { recordOrders: jest.fn(() => Promise.resolve(1)) };
const 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,
);
/** n번째 아임웹 호출의 조회 시작 시각 */
const sinceOf = (call: number) => {
const params = (get.mock.calls[call] as unknown[])[1] as {
startWtime: string;
};
return new Date(params.startWtime).getTime();
};
return { service, get, attribution, purchases, orderInflows, sinceOf };
}
async function runOnce(service: PartnerOrderSyncService): Promise<void> {
service.start();
await service.waitForIdle();
}
describe('PartnerOrderSyncService', () => {
beforeEach(() => jest.useFakeTimers({ now: NOW, doNotFake: ['setTimeout'] }));
afterEach(() => jest.useRealTimers());
it('업체 링크가 없어도 주문을 훑어 구매 내역을 남긴다 (회원 여부로 거르지 않는다)', async () => {
const { service, get, purchases, attribution } = setup([]);
await runOnce(service);
expect(purchases.recordOrders).toHaveBeenCalledWith([ORDER]);
expect(attribution.attributeOrder).not.toHaveBeenCalled();
expect(get.mock.calls[0]).not.toHaveProperty('1.isMember');
});
it('업체 링크가 있으면 같은 주문으로 업체 실적도 반영한다', async () => {
const { service, attribution } = setup([52]);
await runOnce(service);
expect(attribution.attributeOrder).toHaveBeenCalledWith(
ORDER,
new Set([52]),
[],
);
});
it('주문완료 화면이 보낸 클릭 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);
await runOnce(service);
expect(NOW.getTime() - sinceOf(0)).toBe(365 * DAY_MS);
expect(NOW.getTime() - sinceOf(1)).toBe(60 * DAY_MS);
});
it('1년치 조회가 실패하면 다음 실행에서 다시 1년치를 본다', async () => {
const { service, get, sinceOf } = setup();
get.mockImplementationOnce(() => Promise.reject(new Error('down')));
await runOnce(service);
await runOnce(service);
expect(service.getStatus().lastErrorCode).toBeNull();
expect(NOW.getTime() - sinceOf(1)).toBe(365 * DAY_MS);
});
});
@@ -10,6 +10,8 @@ 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';
@@ -19,6 +21,11 @@ const SCHEDULE_NAME = 'partner-order-sync';
* 그보다 오래된 구매의 환불은 반영되지 않는다.
*/
const ORDER_LOOKBACK_DAYS = 60;
/**
* 서버가 뜬 뒤 첫 실행은 1년치를 본다 — 구매 내역을 처음 채우고, 서버가 꺼져 있던 동안의 변경도 메운다.
* 끝까지 성공한 뒤부터는 ORDER_LOOKBACK_DAYS 만 본다.
*/
const PURCHASE_BACKFILL_DAYS = 365;
const DAY_MS = 86_400_000;
const ORDER_PAGE_SIZE = 100;
/** 페이지 사이 쉬는 시간 — 아임웹 호출 제한이 문서에 없어 보수적으로 둔다 */
@@ -37,10 +44,11 @@ function toImwebTime(date: Date): string {
}
/**
* 업체 링크 구매 집계 — 아임웹 주문을 주기적으로 훑어 구매를 잡고 환불·취소를 반영한다.
* 주문 동기화 — 아임웹 주문을 주기적으로 훑어
* (1) 결제된 주문을 구매 내역(purchases)에 남기고 (회원·비회원 모두, 구매 때 유입도 함께 채운다)
* (2) 업체 링크가 있는 상품이면 업체 실적(구매)으로 잡는다. 둘 다 환불·취소를 반영한다.
*
* 아임웹은 주문 완료를 알려주지 않으므로(웹훅 없음) 회원 동기화와 같은 주기로 최근 주문을 다시 본다.
* 업체 링크가 하나도 없으면 아임웹을 부르지 않는다.
*/
@Injectable()
export class PartnerOrderSyncService
@@ -49,6 +57,8 @@ export class PartnerOrderSyncService
private readonly logger = new Logger(PartnerOrderSyncService.name);
/** 진행 중인 실행 — 동시에 두 번 돌지 않게 하고, 종료 시 기다린다 */
private running: Promise<void> | null = null;
/** 1년치를 한 번 끝까지 봤는가 — 중간에 실패하면 다음 실행에서 다시 1년치를 본다 */
private isBackfilled = false;
private status: OrderSyncStatusDto = {
isRunning: false,
lastStartedAt: null,
@@ -60,6 +70,8 @@ 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,
@@ -122,37 +134,51 @@ export class PartnerOrderSyncService
let checked = 0;
let created = 0;
let updated = 0;
let purchases = 0;
const lookbackDays = this.isBackfilled
? ORDER_LOOKBACK_DAYS
: PURCHASE_BACKFILL_DAYS;
try {
const linkedProdNos = await this.attribution.linkedProdNos();
if (linkedProdNos.size > 0) {
const { unitCode } = await this.auth.requireConnection();
const since = new Date(Date.now() - ORDER_LOOKBACK_DAYS * DAY_MS);
const { unitCode } = await this.auth.requireConnection();
const since = new Date(Date.now() - lookbackDays * DAY_MS);
for (let page = 1; page <= MAX_PAGES; page += 1) {
if (page > 1) await sleep(PAGE_DELAY_MS);
const { data } = await this.auth.authorizedGet<ImwebOrderPageData>(
IMWEB_ORDERS_PATH,
{
page,
limit: ORDER_PAGE_SIZE,
unitCode,
isMember: 'Y',
startWtime: toImwebTime(since),
},
for (let page = 1; page <= MAX_PAGES; page += 1) {
if (page > 1) await sleep(PAGE_DELAY_MS);
// 비회원 주문도 구매 내역에 남기므로 회원 여부로 거르지 않는다
const { data } = await this.auth.authorizedGet<ImwebOrderPageData>(
IMWEB_ORDERS_PATH,
{
page,
limit: ORDER_PAGE_SIZE,
unitCode,
startWtime: toImwebTime(since),
},
);
purchases += await this.purchases.recordOrders(data.list);
// 업체 실적은 업체 링크가 있는 상품만.
// 비회원 주문은 주문완료 화면이 보낸 클릭 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;
}
checked += data.list.length;
if (data.list.length === 0 || page >= data.totalPage) break;
}
checked += data.list.length;
if (data.list.length === 0 || page >= data.totalPage) break;
}
this.isBackfilled = true;
// 주문과 이어지지 않은 채 오래된 구매 때 유입을 지운다 (동기화가 끝까지 성공했을 때만)
await this.orderInflows.prune();
this.status = {
...this.status,
@@ -161,7 +187,7 @@ export class PartnerOrderSyncService
createdCount: created,
};
this.logger.log(
`업체 구매 집계 완료: orders=${checked} created=${created} updated=${updated} ${Date.now() - startedAt}ms`,
`주문 동기화 완료: days=${lookbackDays} orders=${checked} purchases=${purchases} partnerCreated=${created} partnerUpdated=${updated} ${Date.now() - startedAt}ms`,
);
} catch (error) {
const code =
@@ -174,10 +200,10 @@ export class PartnerOrderSyncService
};
// 권한 부족·미연결 등 예상된 실패는 warn, 그 밖의 오류만 error 로 남긴다
if (error instanceof BusinessException) {
this.logger.warn(`[${code}] 업체 구매 집계 실패`);
this.logger.warn(`[${code}] 주문 동기화 실패`);
} else {
this.logger.error(
`[${code}] 업체 구매 집계 실패`,
`[${code}] 주문 동기화 실패`,
error instanceof Error ? error.stack : String(error),
);
}
@@ -19,6 +19,10 @@ import { CreatePartnerTouchDto } from './dto/partner-touch.dto';
import { PartnerAttributionService } from './partner-attribution.service';
import { PartnerLinksService, SHORT_LINK_PATH } from './partner-links.service';
const REFERRER_POLICY_HEADER = 'Referrer-Policy';
/** 브라우저 기본값 — 다른 사이트로는 출처(도메인)만 보낸다 */
const REDIRECT_REFERRER_POLICY = 'strict-origin-when-cross-origin';
/**
* 업체 단축 링크 — moneyguide.wandergo.net/r/{코드} (main.ts 에서 /api prefix 제외).
*
@@ -46,11 +50,14 @@ export class PartnerRedirectController {
);
// 매 클릭을 서버가 받아야 클릭 ID 가 새로 발급된다 — 브라우저·프록시가 저장하지 않게 한다
res.setHeader('Cache-Control', 'no-store');
// helmet 기본값(no-referrer)이 리다이렉트에 붙으면 업체가 링크를 올린 곳(블로그 등)이 지워져
// 상품 페이지의 UTM 스크립트가 유입원을 direct 로 기록한다 — 출처(도메인)만은 넘긴다
res.setHeader(REFERRER_POLICY_HEADER, REDIRECT_REFERRER_POLICY);
res.redirect(HttpStatus.FOUND, target);
}
}
/** 아임웹 Header Code 스크립트(imweb/partner-ref.html)가 회원 ↔ 링크 클릭 연결을 보낸다 */
/** 아임웹 Header Code(imweb/imweb-header-top.html 의 업체 링크 추적)가 회원 ↔ 링크 클릭 연결을 보낸다 */
@ApiTags('Partner Touches')
@Controller('partner-touches')
export class PartnerTouchesController {
@@ -6,6 +6,7 @@ import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { ImwebProduct } from '@/modules/imweb/entities/imweb-product.entity';
import { ImwebModule } from '@/modules/imweb/imweb.module';
import { MembersModule } from '@/modules/members/members.module';
import { PurchasesModule } from '@/modules/purchases/purchases.module';
import { PartnerConversion } from './entities/partner-conversion.entity';
import { PartnerLinkClick } from './entities/partner-link-click.entity';
import { PartnerLink } from './entities/partner-link.entity';
@@ -29,7 +30,8 @@ import { PartnersService } from './partners.service';
/**
* 업체 링크 — 업체·상품별 단축 링크를 발급하고, 그 링크로 들어온 회원의 신청·구매를 센다.
* 신청은 ApplicationsModule 이 PartnerAttributionService 를 불러 귀속하고,
* 구매는 PartnerOrderSyncService 가 아임웹 주문을 주기적으로 훑어 찾는다.
* 구매는 PartnerOrderSyncService 가 아임웹 주문을 주기적으로 훑어 찾는다
* (같은 동기화가 업체와 무관한 구매 내역도 PurchasesModule 에 남긴다).
*/
@Module({
imports: [
@@ -45,6 +47,7 @@ import { PartnersService } from './partners.service';
AdminAuthModule,
ImwebModule,
MembersModule,
PurchasesModule,
],
controllers: [
PartnersController,
@@ -0,0 +1,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;
}
}
@@ -0,0 +1,128 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryColumn,
} from 'typeorm';
import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow';
/**
* 유료 구매 — 아임웹 주문을 주기적으로 훑어 결제된 주문의 상품을 남긴다 (주문·상품당 한 줄).
*
* 회원·비회원 주문을 모두 남긴다. 비회원은 회원 코드가 없어 주문자 정보로만 누군지 안다.
* 나중에 환불·취소되면 지우지 않고 isValid=false 로 바꾼다.
* 금액은 관리하지 않는다.
*/
@Entity({ name: 'purchases' })
@Index('uq_purchases_order_product', ['orderNo', 'prodNo'], { unique: true })
@Index('idx_purchases_cursor', ['orderedAt', 'id'])
@Index('idx_purchases_member', ['memberCode'])
export class Purchase {
/** 앱에서 crypto.randomUUID() 로 만든다 */
@PrimaryColumn({ name: 'id', type: 'uuid' })
id!: string;
/** 아임웹 주문번호 */
@Column({ name: 'order_no', type: 'varchar', length: 30 })
orderNo!: string;
@Column({ name: 'prod_no', type: 'int' })
prodNo!: number;
/** 주문 당시 상품명 */
@Column({
name: 'product_name',
type: 'varchar',
length: 200,
nullable: true,
})
productName!: string | null;
/** 아임웹 회원 코드 — 비회원 주문이면 null */
@Column({ name: 'member_code', type: 'varchar', length: 40, nullable: true })
memberCode!: string | null;
/** 주문 당시 아이디 — 비회원 주문이면 null */
@Column({ name: 'member_uid', type: 'varchar', length: 255, nullable: true })
memberUid!: string | null;
@Column({
name: 'orderer_name',
type: 'varchar',
length: 100,
nullable: true,
})
ordererName!: string | null;
@Column({
name: 'orderer_email',
type: 'varchar',
length: 255,
nullable: true,
})
ordererEmail!: string | null;
@Column({
name: 'orderer_call',
type: 'varchar',
length: 40,
nullable: true,
})
ordererCall!: string | null;
/** 주문 시각 (아임웹 wtime) */
@Column({ name: 'ordered_at', type: 'timestamptz' })
orderedAt!: Date;
/** 지금 구매로 세는가 — 환불·취소·반품되면 false */
@Column({ name: 'is_valid', type: 'boolean', default: true })
isValid!: boolean;
/*
* 구매 때 유입 — 주문완료 화면이 보낸 유입(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;
}
@@ -0,0 +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, OrderInflow]), ImwebModule],
controllers: [OrderInflowsController],
providers: [PurchasesService, OrderInflowsService],
exports: [PurchasesService, OrderInflowsService],
})
export class PurchasesModule {}
@@ -0,0 +1,137 @@
import type { Repository } from 'typeorm';
import type { ImwebOrderData } from '@/modules/imweb/imweb.types';
import type { Purchase } from './purchase.entity';
import { PurchasesService } from './purchases.service';
const PAID = [{ paymentStatus: 'PAYMENT_COMPLETE', isCancel: 'N' }];
function order(overrides: Partial<ImwebOrderData>): ImwebOrderData {
return {
orderNo: 202610050001,
isMember: 'Y',
memberCode: 'm20261005abcdef',
memberUid: 'user@test.dev',
ordererName: '홍길동',
ordererEmail: 'user@test.dev',
ordererCall: '010-1234-5678',
wtime: '2026-10-05T03:00:00.000Z',
payments: PAID,
sections: [
{ sectionItems: [{ productInfo: { prodNo: 52, prodName: 'AI 강의' } }] },
],
...overrides,
};
}
/** insert 쿼리 빌더 흉내 — 메서드가 자기 자신을 돌려준다 */
interface InsertQueryMock {
insert: jest.Mock<InsertQueryMock, []>;
values: jest.Mock<InsertQueryMock, [Purchase[]]>;
orUpdate: jest.Mock<InsertQueryMock, unknown[]>;
execute: jest.Mock<Promise<void>, []>;
}
function setup() {
const inserted: Purchase[][] = [];
const query: InsertQueryMock = {
insert: jest.fn(() => query),
values: jest.fn((rows: Purchase[]) => {
inserted.push(rows);
return query;
}),
orUpdate: jest.fn(() => query),
execute: jest.fn(() => Promise.resolve()),
};
const repo = {
create: jest.fn((row: Purchase) => row),
createQueryBuilder: jest.fn(() => query),
// 구매 때 유입 채우기 (UPDATE ... FROM order_inflows)
query: jest.fn(() => Promise.resolve()),
};
const service = new PurchasesService(repo as unknown as Repository<Purchase>);
return { service, inserted, query, repo };
}
describe('PurchasesService.recordOrders', () => {
it('결제된 회원 주문을 주문·상품 단위로 남긴다', async () => {
const { service, inserted } = setup();
await expect(service.recordOrders([order({})])).resolves.toBe(1);
expect(inserted[0][0]).toMatchObject({
orderNo: '202610050001',
prodNo: 52,
productName: 'AI 강의',
memberCode: 'm20261005abcdef',
memberUid: 'user@test.dev',
ordererName: '홍길동',
isValid: true,
});
});
it('비회원 주문은 회원 정보 없이 주문자 정보로 남긴다', async () => {
const { service, inserted } = setup();
await service.recordOrders([
order({ isMember: 'N', memberCode: '', memberUid: '' }),
]);
expect(inserted[0][0]).toMatchObject({
memberCode: null,
memberUid: null,
ordererName: '홍길동',
ordererCall: '010-1234-5678',
});
});
it('결제 전 주문만 있으면 DB 를 부르지 않는다', async () => {
const { service, query, 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 () => {
const { service, inserted } = setup();
await expect(service.recordOrders([order({}), order({})])).resolves.toBe(1);
expect(inserted[0]).toHaveLength(1);
});
it('이미 있는 주문·상품은 환불 여부와 주문자 정보만 갱신한다', async () => {
const { service, query } = setup();
await service.recordOrders([order({})]);
expect(query.orUpdate).toHaveBeenCalledWith(
expect.arrayContaining(['is_valid', 'orderer_name']),
['order_no', 'prod_no'],
{ skipUpdateIfNoValuesChanged: true },
);
});
it('주문 시각을 읽을 수 없으면 건너뛴다', async () => {
const { service } = setup();
await expect(
service.recordOrders([order({ wtime: 'not-a-date' })]),
).resolves.toBe(0);
});
});
@@ -0,0 +1,130 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { In, Repository } from 'typeorm';
import { ImwebOrderData } from '@/modules/imweb/imweb.types';
import { purchaseLines } from '@/shared/utils/order-purchase';
import { Purchase } from './purchase.entity';
/** 컬럼 길이 — 아임웹이 더 긴 값을 보내도 저장이 실패하지 않게 자른다 */
const PRODUCT_NAME_MAX = 200;
const NAME_MAX = 100;
const EMAIL_MAX = 255;
const CALL_MAX = 40;
function clip(value: string | null | undefined, max: number): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed.slice(0, max) : null;
}
/** 다시 볼 때마다 갱신하는 값 — 환불·취소 여부와 주문자 정보 */
const OVERWRITE_COLUMNS = [
'is_valid',
'product_name',
'member_code',
'member_uid',
'orderer_name',
'orderer_email',
'orderer_call',
];
const CONFLICT_COLUMNS = ['order_no', 'prod_no'];
/**
* 유료 구매 기록 — 주문 동기화(PartnerOrderSyncService)가 아임웹 주문 한 페이지씩 넘겨준다.
* 같은 주문·상품은 한 줄만 남고, 다시 보면 환불·취소 여부를 갱신한다 (여러 번 불러도 안전).
* 구매 때 유입은 주문완료 화면이 보낸 값을 주문번호로 이어 채운다 (OrderInflowsService).
*/
@Injectable()
export class PurchasesService {
constructor(
@InjectRepository(Purchase)
private readonly purchases: Repository<Purchase>,
) {}
/** @returns 남기거나 갱신한 대상 줄 수 (결제된 주문의 상품 수) */
async recordOrders(orders: readonly ImwebOrderData[]): Promise<number> {
// 같은 주문·상품이 한 문장에 두 번 들어가면 ON CONFLICT 가 실패하므로 하나로 모은다
const rows = new Map<string, Purchase>();
for (const order of orders) {
const orderedAt = new Date(order.wtime);
if (Number.isNaN(orderedAt.getTime())) continue;
const orderNo = String(order.orderNo);
const isMember = order.isMember === 'Y' && !!order.memberCode;
for (const line of purchaseLines(order)) {
rows.set(
`${orderNo}:${line.prodNo}`,
this.purchases.create({
id: randomUUID(),
orderNo,
prodNo: line.prodNo,
productName: clip(line.productName, PRODUCT_NAME_MAX),
memberCode: isMember ? (order.memberCode ?? null) : null,
memberUid: isMember ? clip(order.memberUid, EMAIL_MAX) : null,
ordererName: clip(order.ordererName, NAME_MAX),
ordererEmail: clip(order.ordererEmail, EMAIL_MAX),
ordererCall: clip(order.ordererCall, CALL_MAX),
orderedAt,
isValid: line.isValid,
}),
);
}
}
if (rows.size === 0) return 0;
await this.purchases
.createQueryBuilder()
.insert()
.values([...rows.values()])
// 바뀐 게 없으면 다시 쓰지 않는다
.orUpdate(OVERWRITE_COLUMNS, CONFLICT_COLUMNS, {
skipUpdateIfNoValuesChanged: true,
})
.execute();
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],
);
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { InflowStatus, parseInflow } from './inflow';
const HOST = 'utm.moneyguider.co.kr';
/** header-top.html 의 serialize(asUrl) 와 같은 방식으로 홈페이지 값을 만든다 */
/** imweb-header-top.html 의 serialize(asUrl) 와 같은 방식으로 홈페이지 값을 만든다 */
function homePage(params: Record<string, string>): string {
const query = Object.entries(params)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* 회원 유입경로 파싱 유틸 (순수 함수).
*
* 아임웹 Header Code 의 UTM 스크립트(header-top.html)가 회원가입 폼의 '홈페이지' 항목에
* 아임웹 Header Code 의 UTM 스크립트(imweb/imweb-header-top.html)가 회원가입 폼의 '홈페이지' 항목에
* 아래 형식으로 유입정보를 기록한다. 아임웹 Open API 회원 조회에는 사용자 정의 가입 항목이
* 나오지 않고 homePage 만 나오기 때문에 이 값을 풀어서 쓴다.
*
@@ -0,0 +1,80 @@
import { OrderForPurchase, purchaseLines } from './order-purchase';
function order(
paymentStatus: string,
sections: NonNullable<OrderForPurchase['sections']>,
): OrderForPurchase {
return { payments: [{ paymentStatus, isCancel: 'N' }], sections };
}
const item = (prodNo: number, prodName?: string, isPromotion = 'N') => ({
isPromotion,
productInfo: { prodNo, prodName },
});
describe('purchaseLines', () => {
it('결제완료 주문의 상품을 상품명과 함께 구매로 남긴다', () => {
const lines = purchaseLines(
order('PAYMENT_COMPLETE', [
{
orderSectionStatus: 'PURCHASE_CONFIRMATION',
sectionItems: [item(52, ' AI 강의 ')],
},
]),
);
expect(lines).toEqual([
{ prodNo: 52, productName: 'AI 강의', isValid: true },
]);
});
it('결제 전·실패한 주문은 구매가 아니다', () => {
for (const status of [
'PAYMENT_PREPARATION',
'PAYMENT_OVERDUE',
'PAYMENT_FAILED',
'CANCELLED_BEFORE_DEPOSIT',
]) {
expect(
purchaseLines(order(status, [{ sectionItems: [item(52)] }])),
).toEqual([]);
}
});
it('전액 환불된 주문도 남기되 구매로 세지 않는다 (환불·취소 표시)', () => {
expect(
purchaseLines(order('REFUND_COMPLETE', [{ sectionItems: [item(52)] }])),
).toEqual([{ prodNo: 52, productName: null, isValid: false }]);
});
it('취소된 섹션의 상품은 세지 않고, 남은 상품은 그대로 센다', () => {
const lines = purchaseLines(
order('PARTIAL_REFUND_COMPLETE', [
{ orderSectionStatus: 'CANCEL_COMPLETE', sectionItems: [item(52)] },
{ orderSectionStatus: 'DELIVERY_COMPLETE', sectionItems: [item(53)] },
]),
);
expect(lines).toEqual([
{ prodNo: 52, productName: null, isValid: false },
{ prodNo: 53, productName: null, isValid: true },
]);
});
it('사은품 품목은 구매 내역에 넣지 않는다', () => {
expect(
purchaseLines(
order('PAYMENT_COMPLETE', [
{ sectionItems: [item(52), item(99, '사은품', 'Y')] },
]),
).map((line) => line.prodNo),
).toEqual([52]);
});
it('취소 결제 행만 있는 주문은 결제된 것으로 보지 않는다', () => {
expect(
purchaseLines({
payments: [{ paymentStatus: 'REFUND_COMPLETE', isCancel: 'Y' }],
sections: [{ sectionItems: [item(52)] }],
}),
).toEqual([]);
});
});
@@ -0,0 +1,66 @@
/**
* 구매 내역 판정 (순수 함수) — 아임웹 주문 한 건에서 구매 내역으로 남길 상품을 고른다.
*
* - 한 번이라도 결제된 주문의 상품만 남긴다. 결제 대기·실패·입금 전 취소 주문은 구매가 아니다.
* - 나중에 환불·취소된 상품도 지우지 않고 isValid=false 로 남긴다 (화면에 '환불·취소'로 보인다).
* - '지금 구매로 세는가'는 업체 실적과 같은 규칙(evaluateOrder)을 쓴다 — 두 화면의 구매 수가 어긋나지 않게.
*/
import { evaluateOrder, OrderForAttribution } from './partner-attribution';
/** 결제가 한 번은 끝났던 결제 상태 — 지금은 환불됐어도 구매 기록은 남긴다 */
const EVER_PAID_PAYMENT_STATUSES = new Set([
'PAYMENT_COMPLETE',
'PARTIAL_REFUND_COMPLETE',
'REFUND_PROCESSING',
'REFUND_COMPLETE',
'REFUND_FAILED',
]);
/** 판정에 필요한 주문 필드 — 업체 실적 판정 필드 + 상품명 */
export interface OrderForPurchase extends OrderForAttribution {
sections?: {
orderSectionStatus?: string | null;
sectionItems?: {
isPromotion?: string | null;
productInfo?: {
prodNo?: number | null;
prodName?: string | null;
} | null;
}[];
}[];
}
export interface PurchaseLine {
prodNo: number;
/** 주문 당시 상품명 (없으면 null) */
productName: string | null;
/** 지금 구매로 세는가 — 환불·취소·반품되면 false */
isValid: boolean;
}
/** 주문 한 건 → 상품별 구매 내역 (상품당 한 줄, 사은품 제외) */
export function purchaseLines(order: OrderForPurchase): PurchaseLine[] {
const wasPaid = (order.payments ?? []).some(
(payment) =>
payment.isCancel !== 'Y' &&
EVER_PAID_PAYMENT_STATUSES.has(payment.paymentStatus ?? ''),
);
if (!wasPaid) return [];
const names = new Map<number, string>();
for (const section of order.sections ?? []) {
for (const item of section.sectionItems ?? []) {
const prodNo = item.productInfo?.prodNo;
const name = item.productInfo?.prodName?.trim();
if (typeof prodNo === 'number' && name && !names.has(prodNo)) {
names.set(prodNo, name);
}
}
}
return [...evaluateOrder(order).products].map(([prodNo, isValid]) => ({
prodNo,
productName: names.get(prodNo) ?? null,
isValid,
}));
}
@@ -1,8 +1,10 @@
import {
buildEntryUrl,
isBotUserAgent,
normalizeTargetUrl,
toBase62,
withPartnerRef,
withPartnerUtm,
} from './partner-link';
const SITE = 'https://www.moneyguider.co.kr';
@@ -62,6 +64,36 @@ describe('withPartnerRef', () => {
});
});
describe('buildEntryUrl', () => {
it('아임웹 사이트의 /go 페이지 주소를 만든다', () => {
expect(buildEntryUrl('https://www.moneyguider.co.kr', 'Ab3kX9pQ')).toBe(
'https://www.moneyguider.co.kr/go?c=Ab3kX9pQ',
);
});
});
describe('withPartnerUtm', () => {
const TARGET = 'https://www.moneyguider.co.kr/free-shop/?idx=52';
it('UTM 이 없으면 업체 이름·partner·링크 코드를 붙인다', () => {
const url = new URL(withPartnerUtm(TARGET, '머니 튜브', 'Ab3kX9pQ'));
expect(url.searchParams.get('idx')).toBe('52');
expect(url.searchParams.get('utm_source')).toBe('머니 튜브');
expect(url.searchParams.get('utm_medium')).toBe('partner');
expect(url.searchParams.get('utm_campaign')).toBe('Ab3kX9pQ');
});
it('관리자가 넣은 UTM 이 하나라도 있으면 건드리지 않는다', () => {
const custom = `${TARGET}&utm_campaign=fall`;
expect(withPartnerUtm(custom, '머니튜브', 'Ab3kX9pQ')).toBe(custom);
});
it('업체 이름이 비어 있으면 링크 코드를 유입원으로 쓴다', () => {
const url = new URL(withPartnerUtm(TARGET, ' ', 'Ab3kX9pQ'));
expect(url.searchParams.get('utm_source')).toBe('Ab3kX9pQ');
});
});
describe('isBotUserAgent', () => {
it('링크 미리보기·검색봇은 사람으로 세지 않는다', () => {
expect(
+50 -2
View File
@@ -2,11 +2,11 @@
* 업체 링크 유틸 (순수 함수) — 이동 주소 검증, 클릭 ID 부착, 봇 판별.
*
* 업체에 주는 단축 링크(/r/{코드})를 누르면 서버가 클릭마다 고유 ID 를 발급해
* 상품 페이지 주소에 `mg_ref` 로 붙여 보낸다. 아임웹 Header Code 스크립트(imweb/partner-ref.html)가
* 상품 페이지 주소에 `mg_ref` 로 붙여 보낸다. 아임웹 Header Code 스크립트(imweb/imweb-header-top.html 의 업체 링크 추적)가
* 이 값을 브라우저에 보관했다가, 로그인한 회원이 확인되면 서버에 알려 회원과 클릭을 잇는다.
*/
/** 상품 페이지 주소에 붙이는 클릭 ID 파라미터 — imweb/partner-ref.html 과 같아야 한다 */
/** 상품 페이지 주소에 붙이는 클릭 ID 파라미터 — imweb/imweb-header-top.html 과 같아야 한다 */
export const PARTNER_REF_PARAM = 'mg_ref';
/** 업체 링크 코드 — /r/{코드} */
@@ -69,6 +69,54 @@ export function withPartnerRef(targetUrl: string, clickRef: string): string {
return url.toString();
}
/**
* 업체에 전달하는 입구 주소 — 아임웹 사이트의 '/go' 페이지.
* https://www.moneyguider.co.kr/go?c={코드}
* Header Code(imweb/imweb-header-top.html)가 이 주소를 보면 페이지를 그리기 전에
* 백엔드 단축 링크(/r/{코드})로 넘긴다. 아임웹에 'go' 페이지가 있어야 한다.
*/
export const PARTNER_ENTRY_PATH = '/go';
export const PARTNER_ENTRY_PARAM = 'c';
export function buildEntryUrl(siteUrl: string, code: string): string {
const url = new URL(PARTNER_ENTRY_PATH, siteUrl);
url.searchParams.set(PARTNER_ENTRY_PARAM, code);
return url.toString();
}
/** UTM 파라미터 접두어 — imweb-header-top.html 의 UTM 스크립트가 읽는다 */
const UTM_PARAM_PREFIX = 'utm_';
/**
* 업체 링크로 온 방문의 매체 값.
* imweb-header-top.html 의 PARTNER_MEDIUM 과 같아야 한다 — 이 값이면 UTM 스크립트가 최초유입을 업체로 덮어쓴다
*/
export const PARTNER_UTM_MEDIUM = 'partner';
/**
* 업체 링크 UTM 을 붙인다 — 이동 주소에 UTM 이 하나도 없을 때만.
* (관리자가 이동 주소에 직접 넣은 UTM 이 있으면 그대로 둔다)
*
* 그래야 UTM 스크립트가 이 방문을 direct 가 아니라 그 업체로 기록해,
* 신청 때 유입·가입 경로에도 업체가 나타난다.
* - utm_source 업체 이름 (집계 화면에서 읽기 쉽게)
* - utm_medium partner
* - utm_campaign 링크 코드 (업체 이름을 바꿔도 변하지 않는 식별자)
*/
export function withPartnerUtm(
targetUrl: string,
partnerName: string,
linkCode: string,
): string {
const url = new URL(targetUrl);
for (const key of url.searchParams.keys()) {
if (key.toLowerCase().startsWith(UTM_PARAM_PREFIX)) return url.toString();
}
url.searchParams.set('utm_source', partnerName.trim() || linkCode);
url.searchParams.set('utm_medium', PARTNER_UTM_MEDIUM);
url.searchParams.set('utm_campaign', linkCode);
return url.toString();
}
/**
* 사람이 아닌 요청 — 링크 미리보기(카카오톡·페이스북 등)와 검색봇.
* 업체가 카톡방에 링크를 올리면 미리보기용 요청이 먼저 오므로 세지 않는다.
+220
View File
@@ -0,0 +1,220 @@
# 유료 구매 추적 — 현황과 배포
> 작성: 2026-10-06 · 다음 작업을 이어갈 때 먼저 읽는 문서.
> 운영 사용 안내는 [`imweb/README.md`](../imweb/README.md) 의 "신청·구매 내역" · "유료 구매 기록" · "구매 때 유입" · "업체 링크" 절.
## 0. 한눈에
| 항목 | 상태 |
| --- | --- |
| 유료 구매 목록 (회원·비회원) | 구현 완료 · `main` 머지 · **운영 미배포** |
| 신청·구매 통합 화면 | 구현 완료 · `main` 머지 · **운영 미배포** |
| 업체 링크 구매 실적 (회원) | 운영 중 (이전 배포분) |
| 업체 링크 우선 (신청·구매 때 유입) | **구현 완료 · 푸시·머지 전** (`feat/purchase-inflow`, 4.5절) |
| 구매 때 유입경로 | **구현 완료 · 푸시·머지 전** (`feat/purchase-inflow`, 4절) |
| 비회원 구매의 업체 실적 | **구현 완료 · 푸시·머지 전** (`feat/purchase-inflow`, 4절) |
`feat/purchase-history` 는 `main` 에 머지·푸시됐다 (`9e6cae7`). 구매 때 유입 작업은 그 위의
`feat/purchase-inflow` 브랜치에 있다 (2026-10-06 기준 푸시·머지 전).
---
## 1. 지금 구매에 대해 알 수 있는 것
아임웹 주문 API(`GET /orders`)에는 **손님이 어디서 왔는지(UTM)가 없다.**
그래서 무료 신청처럼 주문완료 화면이 그 순간의 유입을 보내고, 주문 동기화가 주문번호로 잇는다 (4절).
| 항목 | 알 수 있나 | 근거 · 조건 |
| --- | --- | --- |
| 누가 샀는지 | O | 회원: 회원 정보 / 비회원: 주문서의 주문자 이름·이메일·연락처 |
| 무엇을·언제 | O | 주문·상품당 한 줄, 주문 시각(wtime) |
| 환불·취소 여부 | O | 최근 60일 주문만 다시 봐서 반영 |
| 업체 링크로 왔는지 | O | 회원: 링크를 누른 브라우저에서 로그인 / 비회원: 주문완료 화면이 보낸 클릭 ID. 30일 안에 같은 상품 구매 |
| 가입 경로 | 회원만 | 가입 때 경로라 이번 구매 경로와 다를 수 있다 |
| 구매 때 유입경로 | O (연동 후 주문) | 주문완료 화면이 보낸 값. 다른 기기에서 결제·스크립트 차단이면 없음 |
---
## 2. 구현된 것 (`feat/purchase-history`)
### 2.1 데이터 흐름
```
아임웹 GET /orders ── 1시간마다 (IMWEB_SYNC_INTERVAL_MINUTES, 기본 60)
│ PartnerOrderSyncService (modules/partners/partner-order-sync.service.ts)
│ · 서버 기동 후 첫 실행: 최근 365일 / 성공 후: 최근 60일
│ · 회원 여부로 거르지 않는다 (isMember 파라미터 없음)
├─▶ PurchasesService.recordOrders() → purchases (모든 결제 주문)
└─▶ PartnerAttributionService.attributeOrder() → partner_conversions
(업체 링크 상품 + 회원 주문만)
관리자 화면 ── GET /api/admin/activities* (ActivitiesService)
member_applications ∪ purchases (UNION ALL) + partner_conversions 로 업체 표시
```
- 수동 실행: 관리자 화면 **업체 링크 → 구매 집계 새로고침** (`POST /api/admin/partner-order-sync-runs`)
- "1년치 완료" 여부는 **메모리 플래그**(`isBackfilled`)다. 서버를 재시작할 때마다 첫 실행은 다시 1년치를 본다
(재시작 동안 놓친 환불도 메운다). 1년치 중간에 실패하면 다음 실행에서 다시 1년치를 본다.
### 2.2 `purchases` 테이블 (마이그레이션 `1791220773446-AddPurchases`)
| 컬럼 | 설명 |
| --- | --- |
| `id` uuid PK | 앱에서 생성 |
| `order_no` varchar(30) + `prod_no` int | **유일** (`uq_purchases_order_product`) — 주문·상품당 한 줄 |
| `product_name` | 주문 당시 상품명 (`productInfo.prodName`) |
| `member_code` / `member_uid` | 비회원 주문이면 null |
| `orderer_name` / `orderer_email` / `orderer_call` | 주문서 주문자 (회원 주문도 저장) |
| `ordered_at` | 아임웹 wtime |
| `is_valid` | 지금 구매로 세는가 — 환불·취소·반품이면 false |
| `created_at` | 처음 잡힌 시각 |
- 저장은 `INSERT ... ON CONFLICT (order_no, prod_no) DO UPDATE` (`skipUpdateIfNoValuesChanged`) — 여러 번 돌려도 안전.
- 금액은 저장하지 않는다 (요청사항).
### 2.3 판정 규칙 — `shared/utils/order-purchase.ts`
- **남기는 주문**: 결제가 한 번이라도 끝난 주문
(`PAYMENT_COMPLETE` · `PARTIAL_REFUND_COMPLETE` · `REFUND_PROCESSING` · `REFUND_COMPLETE` · `REFUND_FAILED`, 취소 결제 행 제외).
결제 대기·실패·입금 전 취소는 남기지 않는다.
- **구매로 세는가(`is_valid`)**: 업체 실적과 같은 `evaluateOrder()` — 결제완료(부분 환불 포함)이고 그 상품 섹션이
`CANCEL_COMPLETE`/`RETURN_COMPLETE` 가 아닐 때. → 업체 실적 구매 수와 화면 구매 수가 어긋나지 않는다.
- 사은품 품목(`isPromotion=Y`)은 넣지 않는다.
- **0원 주문도 결제완료면 구매로 남는다** (미결정 — 5절).
### 2.4 관리자 API (`modules/activities/`)
| 경로 | 용도 | 주요 파라미터 |
| --- | --- | --- |
| `GET /api/admin/activities` | 신청·구매 목록 (커서) | `type`(application·purchase), `from`/`to`, `productIdx`, `partnerId`(uuid·none), `q`, `inflowBasis`(apply·join)+`inflowField`+`inflowValue` |
| `GET /api/admin/activities/groups` | 상품별·업체별 신청·구매 수 | `by`(product·partner), `from`/`to` |
| `GET /api/admin/activities/inflow-stats` | 유입 집계 | `basis`(apply·join), `groupBy`, 목록 필터와 동일 |
- `basis=apply` 는 처음엔 무료 신청만 셌으나, 4절 이후 **신청·구매 때 유입**(구매 포함)이다. `join` 은 가입 경로 — 비회원은 값 없음.
- 구매 수는 `is_valid` 만, 환불·취소는 `cancelledCount` 로 따로.
- **예전 `/api/admin/applications` (목록·groups·inflow-stats·products) 는 삭제했다.** 위젯과 백엔드를 함께 배포해야 한다.
- 무료 신청 수집(`POST /api/applications`, 카톡 클릭)은 그대로다. `inflow_last_*` 는 저장만 하고 화면에서 쓰지 않는다.
### 2.5 관리자 화면 (`imweb/imweb-members.html`)
- 메뉴 "신청 내역" → **"신청·구매"**. 종류 [전체·무료 신청·유료 구매], 상품별/업체별 표에 무료 신청·카톡 버튼 클릭·유료 구매(환불 N) 칸.
- 목록: 구분 배지(무료 신청 / 유료 구매 / 환불·취소), 비회원은 주문자 이름 + "비회원". 상세 패널에 주문번호·상태·주문자.
- (4절 이전) 유료 구매만 볼 때 유입 집계는 가입 경로로 고정했다 — 4절에서 풀었다.
- 개요: "최근 7일 무료 신청" · "최근 7일 유료 구매" 카드, "최근 신청·구매" 목록.
### 2.6 파일
```
backend/src/shared/utils/order-purchase.ts (+spec) 판정 규칙 (순수 함수)
backend/src/modules/purchases/ 엔티티 · recordOrders (+spec)
backend/src/modules/activities/ 관리자 통합 조회 (UNION ALL)
backend/src/modules/partners/partner-order-sync.service.ts (+spec) 주문 동기화 (1년→60일)
backend/src/modules/imweb/imweb.types.ts ordererName/Email/Call 추가
backend/src/modules/applications/ 관리자 조회 제거 (수집만 남음)
backend/src/migrations/1791220773446-AddPurchases.ts
imweb/imweb-members.html · imweb/README.md
```
### 2.7 검증한 것
- 백엔드 테스트 189개 통과, lint·build 통과.
- 로컬 DB 에 회원 신청·회원 구매·비회원 구매·환불 건을 넣고 API(필터·커서·묶음·유입 집계·잘못된 커서 VAL_001)와
실제 화면(종류 전환·업체별·상세 패널)을 확인. 넣은 데이터는 지웠다.
- upsert 를 실제 Postgres 에서 확인 (결제 전 무시 → 결제 저장 → 재저장 1행 유지 → 환불 반영).
- **실제 아임웹 주문으로는 아직 돌려보지 않았다.**
---
## 3. 배포 순서 (아직 안 함)
`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. 구매 때 유입·비회원 업체 실적 (`feat/purchase-inflow`, 푸시·머지 전)
### 4.1 흐름
```
주문완료 화면 (/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 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 를 지운다
```
- 주문완료 주소의 `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.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.3 검증한 것
- 백엔드 테스트 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).
광고로 가입 → 업체 링크로 신청·구매하면 가입 경로는 광고, 신청·구매 때 유입은 업체로 남는다.
여러 업체를 거치면 마지막 업체 — 업체 실적의 마지막 클릭 규칙과 같다.
---
## 5. 열린 결정 · 알려진 한계
- **주문자 필드 실제 응답 미확인** — `ordererName/ordererEmail/ordererCall` 은 아임웹 문서 필드명으로 받는다.
운영에서 비어 있으면 응답 구조를 다시 확인해야 한다.
- **0원 주문** — 결제완료면 유료 구매로 잡힌다. 무료 상품을 아임웹에서 0원 주문으로 받는 경우가 있으면
결제 수단 `FREE` 주문을 뺄지 결정 (업체 실적 판정 `evaluateOrder` 도 같이 바꿔야 수가 맞는다).
- 60일보다 오래된 주문의 환불은 반영되지 않는다 (서버 재시작 직후 1년치 실행 때만 다시 본다).
- 휴대폰에서 링크·광고를 보고 PC 에서 사면 유입·업체가 이어지지 않는다 (무료 신청도 같다).
- 구매 때 유입은 공개 API 로 받으므로, 주문번호를 아는 사람이 먼저 가짜 값을 보내면 그 값이 남는다
(실제 주문에만 붙고, 업체 실적은 실제로 있는 클릭만 인정한다).
- 구매자의 가입 경로는 "이번 구매 경로"가 아니다 — 화면 문구에서 혼동하지 않게 유지.
+97 -12
View File
@@ -12,10 +12,22 @@
아임웹 Open API /member-info/members ← homePage 에 UTM 스크립트가 기록한 유입정보
```
유입정보는 아임웹 Header Code 의 UTM 스크립트(`header-top.html`)가 회원가입 폼의
유입정보는 아임웹 Header Code 의 UTM 스크립트(`imweb-header-top.html`)가 회원가입 폼의
'홈페이지' 항목에 `https://utm.moneyguider.co.kr/?src=..&med=..&jd=..` 형식으로 남긴 값이다.
백엔드가 이 값을 풀어서 유입원·매체·캠페인별로 조회·집계한다.
## 파일 — 아임웹 어디에 붙이나
아임웹에 실제로 들어가 있는 코드의 원본이다. 고친 뒤에는 아임웹에 **다시 붙여 넣어야** 반영된다.
| 파일 | 아임웹 위치 | 하는 일 |
| --- | --- | --- |
| `imweb-header-top.html` | Header Code (사이트 전체) | UTM 유입경로 기록(`MG_UTM`) + 업체 링크 클릭 보관(`MG_PARTNER`) |
| `imweb-header.html` | Header Code (사이트 전체) | 로그인 링크의 back_url 에 UTM 을 붙여 로그인 후에도 유지 |
| `imweb-free-apply.html` | `/apply` 페이지 HTML 위젯 | 신청 완료 화면 + 신청 기록·오픈카톡 클릭 전송 |
| `imweb-members.html` | 관리자 전용 페이지 코드 위젯 | 관리자 대시보드 |
| `serve-local.js` | — | 로컬 테스트용 정적 서버 |
## 로컬에서 띄우기
1. 백엔드 env 에 아임웹 앱 키와 관리자 계정 입력 — `backend/.env.development`
@@ -60,12 +72,37 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
- 2026-10-05 `회원2026_10_05_1.xls` (2026-08-20 ~ 09-07 가입자 2,489명) 가져옴.
유입경로 항목이 생긴 2026-08-20 이전 가입자(약 12,500명)는 복구할 수 없다.
## 강의 신청 기록 (신청 완료 화면 → 신청 내역)
## 신청·구매 내역 (관리자 화면)
관리자 화면 **신청·구매** 메뉴는 무료 신청과 유료 구매를 한 화면에서 본다
(`GET /api/admin/activities` · `/groups` · `/inflow-stats`).
- 종류 [전체 · 무료 신청 · 유료 구매] 로 목록을 거르고, 위쪽 표에서 상품별·업체별 신청 수·구매 수를 함께 본다
- 구매 수는 결제완료만 센다 — 환불·취소된 구매는 목록에 '환불·취소'로 남고, 표에는 '환불 N'으로 따로 적는다
- 유입 집계: '신청·구매 때 유입'(신청 완료·주문완료 화면이 남긴 그 순간의 경로)과
'가입 경로'(회원이 가입할 때 기록된 경로) 두 기준으로 본다.
주문완료 화면 연동 전 구매는 구매 때 유입이 없고, 비회원 구매는 가입 경로가 없다
## 강의 신청 기록 (신청 완료 화면 → 신청·구매 내역)
아임웹 `/apply` 페이지의 신청 완료 위젯(`imweb-free-apply.html`)이 표시될 때
`POST /api/applications` 로 신청을 남긴다. 관리자 화면 **신청 내역** 메뉴에서 본다.
`POST /api/applications` 로 신청을 남긴다. 관리자 화면 **신청·구매** 메뉴에서 본다.
- 보내는 값: `MEMBER_UID`(회원 아이디 또는 회원 코드), 상품번호(`idx`), 상품명, 그 순간의 유입정보(`MG_UTM.peek()`), 업체 링크 클릭 ID(`MG_PARTNER.refs()`)
- 보내는 값: `MEMBER_UID`(회원 아이디 또는 회원 코드), 상품번호(`idx`), 상품명,
최초 유입(`MG_UTM.peek().value`), 최종 유입(`MG_UTM.peek().last` 를 같은 파이프 형식으로),
업체 링크 클릭 ID(`MG_PARTNER.refs()`) — `peek()` 는 문자열이 아니라 객체를 돌려준다
(최종 유입은 저장만 하고 화면에는 보여주지 않는다)
- **가입 경로와 신청 유입은 따로 남는다** — 관리자 화면 '유입' 집계에서 기준을 바꿔 비교한다
| 기준 | 값 | 예) 3주 전 네이버로 가입 → 오늘 페이스북 광고로 와서 신청 |
| --- | --- | --- |
| 신청 때 유입 | 신청 시점 브라우저에 남아 있던 첫 경로 (UTM 스크립트 보관 2일) | facebook |
| 가입 경로 | 그 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외) | naver |
다른 기기에서 신청하면 신청 때 유입은 direct 로 남는다.
- **업체 링크는 첫 경로보다 우선한다** — 업체 링크(`utm_medium=partner`)로 들어오면 보관 중인 첫 경로를
그 업체로 바꾼다. 예) 어제 광고로 들어와 가입 → 오늘 업체 링크로 신청: 가입 경로 광고, 신청 때 유입 업체.
여러 업체 링크를 거치면 마지막 업체가 남는다 (업체 실적의 마지막 클릭 규칙과 같다)
- 백엔드는 아임웹에 실제로 있는 회원인지 확인하고(동기화 전 신규 가입자는 아임웹 API 로 직접 확인),
회원·상품당 한 건만 남긴다. 이름·연락처는 브라우저 값이 아니라 아임웹 회원 정보를 쓴다
- 오픈카톡 버튼 클릭은 `POST /api/applications/{id}/kakao-clicks` 로 처음 한 번만 남는다
@@ -74,31 +111,76 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
- 한계: 신청 완료 화면은 공개 페이지라 "실제로 그 회원이 보냈는지"는 확인할 수 없다
(있는 회원인지만 확인). 무료 신청 기록 용도라 이 수준으로 둔다
## 유료 구매 기록 (아임웹 주문 → 신청·구매 내역)
아임웹은 주문 완료를 알려주지 않으므로(웹훅 없음) 서버가 1시간마다 아임웹 주문(`GET /orders`)을 훑어
결제된 주문을 `purchases` 에 주문·상품당 한 줄로 남긴다. 화면의 '구매 집계 새로고침'(업체 링크 메뉴)으로 바로 돌릴 수도 있다.
- 회원·비회원 주문을 모두 남긴다. 비회원은 주문서의 주문자 이름·이메일·연락처로 보여준다
- 결제 전·결제 실패·입금 전 취소 주문은 남기지 않는다. 나중에 환불·취소되면 지우지 않고 '환불·취소'로 바꾼다
- 서버가 뜬 뒤 첫 실행은 **최근 1년**, 그 뒤로는 **최근 60일**만 다시 본다 (60일보다 오래된 주문의 환불은 반영되지 않는다)
- 사은품 품목은 넣지 않는다. 0원 주문도 결제완료면 구매로 남는다
- 금액은 관리하지 않는다
- 구매 때 유입은 아래 '구매 때 유입' 절 — 현황·한계는 [`docs/purchase-tracking.md`](../docs/purchase-tracking.md)
## 구매 때 유입 (주문완료 화면 → 구매 기록)
아임웹 주문에는 손님이 어디서 왔는지가 없어서, 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 라 주문번호를 아는 사람은
먼저 가짜 값을 보낼 수 있다 (실제 주문에만 붙고, 업체 실적은 실제로 있는 클릭만 인정한다)
## 업체 링크 (업체별 신청·구매 추적)
상품마다 업체에 단축 링크를 발급하고, 그 링크로 들어온 회원의 신청·구매를 업체별로 센다.
관리자 화면 **업체 링크** 메뉴에서 업체 추가 → 링크 발급 → 실적 확인.
```
업체에 링크 전달 ── https://moneyguide.wandergo.net/r/{코드}
│ ① 클릭 기록 (카톡 미리보기·검색봇 제외) 후 상품 페이지로 이동하며 클릭 ID(mg_ref)를 붙인다
업체에 링크 전달 ── https://www.moneyguider.co.kr/go?c={코드} (아임웹 'go' 페이지)
│ ⓪ Header Code 가 페이지를 그리기 전에 백엔드 단축 링크 moneyguide.wandergo.net/r/{코드} 로 넘긴다
▼
상품 페이지 ── partner-ref.html(Header Code)이 클릭 ID 를 30일 보관하고 주소창에서 지운다
백엔드 단축 링크
│ ① 클릭 기록 (카톡 미리보기·검색봇 제외) 후 상품 페이지로 이동하며 클릭 ID(mg_ref)와
│ 업체 UTM(utm_source=업체 이름, utm_medium=partner, utm_campaign=링크 코드)을 붙인다
│ (이동 주소에 관리자가 직접 넣은 UTM 이 있으면 그것을 쓴다)
▼
상품 페이지 ── imweb-header-top.html(Header Code)의 업체 링크 추적이 클릭 ID 를 30일 보관하고 주소창에서 지운다
│ (UTM 스크립트처럼 localStorage + .moneyguider.co.kr 쿠키 — www ↔ apex 이동에도 유지)
│ ② 로그인 회원이 확인되면 "이 회원이 이 클릭으로 들어왔다"를 POST /api/partner-touches 로 알린다
▼
신청: 신청 완료 위젯이 신청 기록에 클릭 ID 를 함께 보낸다 → 바로 귀속
구매: 서버가 1시간마다 최근 60일 아임웹 주문(회원 주문)을 훑어 결제완료 상품을 귀속, 환불·취소는 집계에서 뺀다
구매: 구매 기록과 같은 주문 동기화(1시간마다)가 결제완료 상품을 귀속 (회원은 클릭 연결, 비회원은 주문완료 화면의 클릭 ID), 환불·취소는 집계에서 뺀다
```
- **귀속 규칙**: 신청·구매 직전 30일 안에 누른 **그 상품의** 업체 링크 중 마지막 것 (마지막 클릭)
- **구매 수**: 결제완료 주문만 센다. 전액 환불되거나 해당 상품 품목이 모두 취소·반품되면 빠진다. 금액은 관리하지 않는다
- **링크는 지우지 않고 중지**한다 — 중지해도 상품 페이지로 이동은 되지만 클릭·실적은 쌓이지 않는다.
링크가 있는 업체는 삭제할 수 없다 (실적 기록 보존)
- 업체 링크로 온 방문은 UTM 스크립트가 업체 UTM 으로 기록하므로, 신청 때 유입·가입 경로 집계에도 업체 이름이 나온다.
먼저 광고 등으로 들어와 첫 경로가 남아 있어도 업체로 덮어쓴다 (Header Code v1.2)
- 단, 관리자가 이동 주소에 직접 UTM 을 넣은 링크는 `utm_medium=partner` 가 아니므로 덮어쓰지 않는다
- 업체 실적은 **그 상품의** 링크만 세지만 유입은 상품을 가리지 않는다 —
A 상품 업체 링크로 들어와 B 상품을 신청하면 신청 때 유입은 업체, 업체 칸은 비어 있다
- 이동 주소는 아임웹 사이트(www 유무 무관) 안만 허용한다 — 단축 링크가 피싱에 쓰이지 않게.
사이트 주소는 `IMWEB_SITE_URL`, 비우면 `IMWEB_ADMIN_PAGE_URL` 의 origin
- 한계
- 링크를 누른 브라우저와 신청·구매한 브라우저가 다르면(휴대폰 → PC) 추적되지 않는다
- 비회원 주문은 회원과 이을 수 없어 세지 않는다
- 비회원 주문은 주문완료 화면이 클릭 ID 를 보냈을 때만 센다 (위 '구매 때 유입')
- 공개 페이지에서 오는 기록이라 위조를 완전히 막을 수는 없다 (실제 아임웹 회원·실제 클릭인지만 확인)
- 60일보다 오래된 구매의 환불은 반영되지 않는다
@@ -108,9 +190,12 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
2. 백엔드 배포 (마이그레이션 자동 실행)
3. 관리자 화면 **연동 관리 → 아임웹 연결**을 다시 한다 — 새 권한이 담긴 토큰을 받는다.
다시 연결하기 전에는 상품 불러오기·구매 집계가 `IMWEB_006`(권한 부족)으로 실패한다. 회원 동기화는 그대로 돈다
4. `partner-ref.html` 을 아임웹 **Header Code** 에 붙여 넣는다 (사이트 전체)
4. `imweb-header-top.html` 을 아임웹 **Header Code** 에 다시 붙여 넣는다 (UTM 스크립트 아래에 업체 링크 추적이 추가됨).
`imweb-header.html`(로그인 링크 UTM 보존)은 바꾸지 않았다
5. `imweb-free-apply.html` 을 신청 완료 위젯에 다시 붙여 넣는다 (신청 기록·클릭 ID 전송 추가됨)
6. 업체 링크 메뉴에서 **상품 불러오기** → 업체 추가 → 링크 발급
6. 아임웹에 주소(URL)가 `go` 인 페이지를 만든다 — 메뉴에서 숨기고 전체 공개. 내용은 비워 둬도 된다
(업체에 주는 `www.moneyguider.co.kr/go?c=코드` 의 입구. 이 페이지가 없으면 링크가 404 가 된다)
7. 업체 링크 메뉴에서 **상품 불러오기** → 업체 추가 → 링크 발급
## 인증 방식 — 왜 쿠키가 아닌가
@@ -147,7 +232,7 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
- `imweb-members.html` 의 `API_BASE` 운영 주소(`https://moneyguide.wandergo.net/api`)를 확인하고 아임웹 코드 위젯에 붙여 넣는다
- 아임웹에서 그 페이지의 접근 권한을 관리자로 제한한다
- 업체 링크를 쓰려면 위 '업체 링크 → 처음 켤 때' 순서를 따른다
(`partner-ref.html`·`imweb-free-apply.html` 의 `API_BASE` 도 같은 운영 주소)
(`imweb-header-top.html`·`imweb-free-apply.html` 의 `API_BASE` 도 같은 운영 주소)
## 유입 기록 상태
+29 -4
View File
@@ -434,16 +434,39 @@
})();
}
/* header-top.html(UTM 스크립트)이 보관한 유입정보 — 없으면 빈 값 */
/* imweb-header-top.html(UTM 스크립트)이 보관한 유입정보 — 없으면 빈 값.
MG_UTM.peek() 는 { first, last, value, … } 객체를 돌려주고,
서버가 받는 파이프 형식(src=..|med=..|…)은 그중 value 다. */
function readInflow() {
try {
var value = window.MG_UTM && typeof window.MG_UTM.peek === 'function'
? window.MG_UTM.peek() : '';
var peeked = window.MG_UTM && typeof window.MG_UTM.peek === 'function'
? window.MG_UTM.peek() : null;
var value = peeked && typeof peeked === 'object' ? peeked.value : peeked;
return typeof value === 'string' ? value.slice(0, INFLOW_MAX) : '';
} catch (e) { return ''; }
}
/* partner-ref.html(업체 링크 스크립트)이 보관한 클릭 ID — 없으면 빈 배열 */
/* 신청 직전의 최종 유입 — MG_UTM.peek().last 객체를 위와 같은 파이프 형식으로 바꾼다.
최초 유입(위 value)은 2일간 처음 들어온 경로라, 그 사이 다른 경로로 다시 와서
신청하면 신청을 일으킨 경로는 last 에만 있다. 없으면 빈 값 (서버가 최초 유입으로 채운다) */
var LAST_KEYS = ['src', 'med', 'cmp', 'trm', 'cnt', 'cid', 'ref', 'lp', 'pid', 'ts'];
function readLastInflow() {
try {
var peeked = window.MG_UTM && typeof window.MG_UTM.peek === 'function'
? window.MG_UTM.peek() : null;
var last = peeked && typeof peeked === 'object' ? peeked.last : null;
if (!last || typeof last !== 'object' || !last.src) return '';
var parts = [];
for (var i = 0; i < LAST_KEYS.length; i++) {
var v = last[LAST_KEYS[i]];
/* UTM 스크립트가 이미 | = 를 지운 값이지만 한 번 더 막는다 */
if (v) parts.push(LAST_KEYS[i] + '=' + String(v).replace(/[|=]/g, ' '));
}
return parts.join('|').slice(0, INFLOW_MAX);
} catch (e) { return ''; }
}
/* imweb-header-top.html(업체 링크 추적)이 보관한 클릭 ID — 없으면 빈 배열 */
function readPartnerRefs() {
try {
var refs = window.MG_PARTNER && typeof window.MG_PARTNER.refs === 'function'
@@ -462,6 +485,8 @@
if (name) body.productName = name.slice(0, PRODUCT_NAME_MAX);
var inflow = readInflow();
if (inflow) body.inflow = inflow;
var lastInflow = readLastInflow();
if (lastInflow) body.lastInflow = lastInflow;
var refs = readPartnerRefs();
if (refs.length) body.partnerRefs = refs;
+718
View File
@@ -0,0 +1,718 @@
<meta name="facebook-domain-verification" content="sldnjjycwvg0acy344qq4zu3mhxh72" />
<meta name="facebook-domain-verification" content="rjbwdcpgd8bp4a5220oshav850duv1" />
<!-- ============================================================
[자본주의가이드] 업체 링크 입구 v1.0 — 이 블록은 반드시 맨 위에 둔다
업체에 전달하는 링크: https://www.moneyguider.co.kr/go?c=코드
→ 페이지를 그리기 전에 백엔드 단축 링크(/r/코드)로 바로 넘긴다.
백엔드가 클릭을 기록하고 클릭 ID(mg_ref)와 업체 UTM 을 붙여 상품 페이지로 보낸다.
필요: 아임웹에 주소가 'go' 인 페이지 (메뉴에 숨김, 전체 공개). 내용은 비워 둬도 된다.
아래 UTM 스크립트는 이 입구 페이지에서 기록하지 않는다(MG_PARTNER_ENTRY) —
기록은 이동한 상품 페이지에서 업체 UTM 으로 한 번만 남긴다.
============================================================ -->
<script>
(function () {
'use strict';
var ENTRY_PATH = /^\/go\/?$/; // 백엔드 PARTNER_ENTRY_PATH 와 같아야 한다
var ENTRY_PARAM = 'c'; // 백엔드 PARTNER_ENTRY_PARAM
var CODE_PATTERN = /^[0-9A-Za-z]{8}$/; // 백엔드 LINK_CODE_PATTERN
var SHORT_LINK_BASE = 'https://moneyguide.wandergo.net/r/'; // 백엔드 단축 링크
try {
if (!ENTRY_PATH.test(location.pathname)) return;
var code = new URLSearchParams(location.search).get(ENTRY_PARAM);
if (!code || !CODE_PATTERN.test(code)) return;
window.MG_PARTNER_ENTRY = true;
location.replace(SHORT_LINK_BASE + code); // replace — 뒤로가기로 입구에 다시 오지 않게
} catch (e) { }
})();
</script>
<!-- ============================================================
[자본주의가이드] UTM 기반 회원가입 유입경로 자동추적 v1.3
v1.2: 업체 링크(utm_medium=partner) 방문은 보관 중인 최초유입을 덮어쓴다
v1.3: 결제 페이지(/shop_payment*)에서는 결제창 리퍼러를 유입으로 기록하지 않는다
설치 위치: 아임웹 관리자 > 환경설정 > SEO(검색엔진최적화)
> 고급 > 공통 코드 삽입 > "Header Code"
대상 필드: 회원가입 사용자 정의 항목 "유입경로(필수)" = join_form_64454
+ 기본 항목 "홈페이지" (URL 형식으로 같은 정보 기록)
사용자 정의 항목은 Open API 회원 조회에 나오지 않고,
홈페이지는 homePage 필드로 나오기 때문에 둘 다 채운다.
커버 범위: 카카오 1초 회원가입 / 카카오 소셜 가입 / 아임웹 일반 회원가입
============================================================ -->
<script>
(function () {
'use strict';
/* ===================== 설정 ===================== */
var CFG = {
FIELD_ID: 'join_64454', // 아임웹이 렌더링하는 input id (name=join_form_64454)
FIELD_LABEL: '유입경로', // id를 못 찾을 때 라벨 텍스트로 재탐색 (항목 재생성 대비)
COOKIE_DOMAIN: '.moneyguider.co.kr', // www / apex 양쪽 공유
TTL_DAYS: 2, // 최초유입 보관 기간
HIDE_FIELD: true, // true=사용자에게 숨김, false=읽기전용으로 노출
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로 바꾸면 콘솔에 로그 출력
};
var K_FIRST = 'mg_utm_first'; // 최초 유입 (first-touch)
var K_LAST = 'mg_utm_last'; // 최종 유입 (last-touch)
function log() {
if (CFG.DEBUG && window.console && console.log) {
console.log.apply(console, ['[MG-UTM]'].concat([].slice.call(arguments)));
}
}
/* ============ 저장소: localStorage + 쿠키 이중화 ============
카카오 인앱브라우저 / Safari ITP 등에서 localStorage가 막히거나
초기화되는 경우를 대비해 쿠키에도 동일 값을 기록한다. */
function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }
function ckGet(k) {
var m = document.cookie.match('(^|;)\\s*' + k + '\\s*=\\s*([^;]*)');
if (!m) return null;
try { return decodeURIComponent(m[2]); } catch (e) { return m[2]; }
}
function ckSet(k, v, days) {
var d = new Date();
d.setTime(d.getTime() + days * 86400000);
document.cookie = k + '=' + encodeURIComponent(v) +
';expires=' + d.toUTCString() +
';path=/;domain=' + CFG.COOKIE_DOMAIN + ';SameSite=Lax';
}
/* localStorage에는 만료 개념이 없으므로 저장값 안에 만료 시각(_e)을 함께 넣고
읽을 때 직접 확인한다. 이렇게 해야 쿠키가 지워진 뒤에도 TTL_DAYS가 실제로 지켜진다. */
function saveTouch(k, t) {
var o = merge(t, { _e: Date.now() + CFG.TTL_DAYS * 86400000 });
var json = JSON.stringify(o);
lsSet(k, json);
ckSet(k, json, CFG.TTL_DAYS);
}
function dropTouch(k) {
try { localStorage.removeItem(k); } catch (e) { }
ckSet(k, '', -1);
}
function readTouch(k) {
var raw = lsGet(k) || ckGet(k);
if (!raw) return null;
var o = null;
try { o = JSON.parse(raw); } catch (e) { dropTouch(k); return null; }
if (!o || typeof o !== 'object') { dropTouch(k); return null; }
if (o._e && Date.now() > o._e) { dropTouch(k); return null; } // 보관기간 경과
delete o._e;
return o;
}
/* ===================== 유틸 ===================== */
// 파이프 포맷을 깨뜨리는 문자 제거 + 길이 제한
function clean(v, n) {
return String(v == null ? '' : v)
.replace(/[|=\r\n\t]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, n || 80);
}
function parseQuery() {
var out = {}, s = location.search.replace(/^\?/, '');
if (!s) return out;
var parts = s.split('&');
for (var i = 0; i < parts.length; i++) {
if (!parts[i]) continue;
var p = parts[i], j = p.indexOf('=');
var k = j < 0 ? p : p.slice(0, j);
var v = j < 0 ? '' : p.slice(j + 1);
try { out[decodeURIComponent(k)] = decodeURIComponent(v.replace(/\+/g, ' ')); }
catch (e) { out[k] = v; }
}
return out;
}
function merge(a, b) {
var o = {}, k;
for (k in a) if (Object.prototype.hasOwnProperty.call(a, k)) o[k] = a[k];
for (k in b) if (Object.prototype.hasOwnProperty.call(b, k)) o[k] = b[k];
return o;
}
// 한국 시간 기준 날짜 (toISOString 은 UTC 라서 0~9시에는 전날로 찍힌다)
function today() { return new Date(Date.now() + 9 * 3600000).toISOString().slice(0, 10); }
/* ============ 리퍼러 분류 (UTM이 없는 자연유입 보정) ============ */
var REF_MAP = [
[/^(m\.)?(blog|cafe|post|in)\.naver\.com$/i, 'naver_blog', 'referral'],
[/(^|\.)naver\.com$/i, 'naver', 'organic'],
[/(^|\.)google\./i, 'google', 'organic'],
[/(^|\.)bing\.com$/i, 'bing', 'organic'],
[/(^|\.)daum\.net$/i, 'daum', 'organic'],
[/(^|\.)kakao\.com$/i, 'kakao', 'social'],
[/(^|\.)instagram\.com$/i, 'instagram', 'social'],
[/(^|\.)(facebook|fb)\.com$/i, 'facebook', 'social'],
[/(^|\.)(youtube\.com|youtu\.be)$/i, 'youtube', 'social'],
[/(^|\.)tiktok\.com$/i, 'tiktok', 'social'],
[/(^|\.)threads\.(net|com)$/i, 'threads', 'social'],
[/(^|\.)(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|shop_payment)/i;
/* 소셜 로그인 인증 도메인.
위 AUTH_PATH는 '인증 페이지에 있을 때'를 막지만, 인증을 마치고
사이트의 일반 페이지로 곧장 되돌아오는 경우는 못 막는다.
예) 신청 버튼 → 가입 → 카카오 인증 → /apply 로 복귀
이때 /apply 는 AUTH_PATH에 걸리지 않고 리퍼러가
kauth.kakao.com 이라, REF_MAP이 '카카오 소셜 유입'으로
오인해 최종유입을 덮어쓴다. 원래 유입원(네이버 광고 등)이
지워지므로 리퍼러 쪽에서도 막는다.
※ kakao.com / naver.com 전체가 아니라 인증 전용 호스트만 넣는다.
카카오톡 공유로 들어온 진짜 유입까지 무시하면 안 된다. */
var AUTH_REF = /^(kauth\.kakao\.com|accounts\.kakao\.com|nid\.naver\.com|accounts\.google\.com|appleid\.apple\.com)$/i;
// 광고 클릭 ID -> 매체 추정 (UTM이 누락된 광고 트래픽 보정)
var CID_SRC = {
gclid: 'google', gbraid: 'google', wbraid: 'google',
fbclid: 'meta', kclid: 'kakao', ttclid: 'tiktok',
msclkid: 'bing', yclid: 'yandex', n_ad_group: 'naver'
};
/* ============ 현재 페이지의 유입정보 추출 ============ */
function buildTouch() {
var q = parseQuery();
var t = {};
var hasUtm = false;
var UTM = {
utm_source: 'src', utm_medium: 'med', utm_campaign: 'cmp',
utm_term: 'trm', utm_content: 'cnt'
};
for (var k in UTM) {
if (q[k]) { t[UTM[k]] = clean(q[k]); hasUtm = true; }
}
// 광고 클릭 ID
for (var cid in CID_SRC) {
if (q[cid]) {
t.cid = cid;
if (!hasUtm) { t.src = CID_SRC[cid]; t.med = 'cpc'; hasUtm = true; }
break;
}
}
// 리퍼러 (외부 도메인일 때만)
var ref = '';
try {
if (document.referrer) {
var h = document.referrer.split('/')[2] || '';
h = h.split('?')[0];
if (h && h.indexOf(CFG.OWN_HOST) === -1) ref = h.toLowerCase();
}
} catch (e) { }
if (!hasUtm) {
if (ref) {
var named = null;
for (var i = 0; i < REF_MAP.length; i++) {
if (REF_MAP[i][0].test(ref)) { named = REF_MAP[i]; break; }
}
t.src = named ? named[1] : clean(ref, 40);
t.med = named ? named[2] : 'referral';
} else {
t.src = 'direct';
t.med = 'none';
}
}
if (ref) t.ref = clean(ref, 40);
t.lp = clean(location.pathname, 60);
// 아임웹 상품/게시글 식별자 (/free-shop/?idx=52 처럼 idx로 페이지가 갈리는 경우)
if (q.idx) t.pid = clean(q.idx, 20);
t.ts = today();
t._utm = hasUtm; // URL에 UTM/클릭ID가 실제로 있었는지 (저장 전 제거)
return t;
}
/* ============ 유입정보 기록 ============ */
function capture() {
var t = buildTouch();
var explicitUtm = t._utm;
delete t._utm;
// 소셜 로그인 왕복(kauth.kakao.com 등)을 새 유입으로 오해하지 않도록,
// 인증·가입 페이지에서는 URL에 UTM이 직접 붙어 있을 때만 기록한다.
if (AUTH_PATH.test(location.pathname) && !explicitUtm) return;
// 인증을 마치고 일반 페이지(/apply 등)로 곧장 복귀한 경우도 동일하게 무시.
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);
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였다면 실제 유입원으로 승격
}
log('capture', t);
}
/* ============ 필드에 넣을 최종 값 계산 ============ */
function resolve() {
var f = readTouch(K_FIRST);
var l = readTouch(K_LAST);
var t = f || l || { src: 'direct', med: 'none', lp: clean(location.pathname, 60), ts: today() };
// 최초유입과 최종유입이 다르면 last 항목을 덧붙임 (재방문 전환 추적)
if (f && l && (f.src !== l.src || f.cmp !== l.cmp)) {
t = merge(t, { last: clean(l.src + (l.cmp ? '/' + l.cmp : ''), 60) });
}
t.jd = today(); // 가입일
return t;
}
var ORDER = ['src', 'med', 'cmp', 'trm', 'cnt', 'cid', 'ref', 'lp', 'pid', 'ts', 'last', 'jd'];
/* 홈페이지 항목용 순서. 길이 제한(maxlength)에 걸리면 뒤에서부터 뺀다.
jd(기록일)는 API 의 joinTime 과 비교해, 가입 때가 아니라 나중에
회원정보 수정 화면에서 채워진 값을 걸러내는 데 쓰므로 src/med 바로 뒤에 둔다. */
var HP_ORDER = ['src', 'med', 'jd', 'cmp', 'cnt', 'trm', 'last', 'cid', 'lp', 'pid', 'ref', 'ts'];
function serialize(t, asUrl, order) {
order = order || ORDER;
var pairs = [];
for (var i = 0; i < order.length; i++) {
var k = order[i];
if (t[k]) pairs.push([k, t[k]]);
}
if (asUrl) {
// 항목 형식이 'URL'인 동안의 호환 모드 (HTML5 url 검증 통과용)
var qsv = [];
for (var j = 0; j < pairs.length; j++) {
qsv.push(encodeURIComponent(pairs[j][0]) + '=' + encodeURIComponent(pairs[j][1]));
}
return 'https://utm.' + CFG.OWN_HOST + '/?' + qsv.join('&');
}
var out = [];
for (var n = 0; n < pairs.length; n++) out.push(pairs[n][0] + '=' + pairs[n][1]);
return out.join('|');
}
function fallbackValue(asUrl) {
return asUrl ? ('https://utm.' + CFG.OWN_HOST + '/?src=direct&med=none')
: 'src=direct|med=none';
}
// 홈페이지 항목 값: 항상 URL 형식, maxlength 가 있으면 넘지 않게 낮은 우선순위부터 뺀다
function homepageValue(el) {
var t = resolve();
var max = el && el.maxLength > 0 ? el.maxLength : 0;
var order = HP_ORDER.slice();
var v = serialize(t, true, order);
while (max && v.length > max && order.length > 2) { // src, med 는 끝까지 남긴다
order.pop();
v = serialize(t, true, order);
}
return v;
}
/* ============ 대상 필드 찾기 ============
기본은 고정 id(join_64454). 관리자에서 항목을 지웠다 다시 만들면 아임웹이
새 인덱스를 발급하므로, id를 못 찾으면 라벨 텍스트로 한 번 더 찾는다. */
function findField() {
var el = document.getElementById && document.getElementById(CFG.FIELD_ID);
if (el) return el;
if (!CFG.FIELD_LABEL || !document.querySelectorAll) return null;
try {
var labels = document.querySelectorAll('#join_form label');
for (var i = 0; i < labels.length; i++) {
var txt = (labels[i].textContent || '').replace(/\s+/g, '');
if (txt.indexOf(CFG.FIELD_LABEL) === -1) continue;
var blk = labels[i].parentElement;
var input = blk && blk.querySelector(
'input[name^="join_form_"], select[name^="join_form_"], textarea[name^="join_form_"]');
if (input) {
log('id 미발견 - 라벨로 찾음', input.name);
return input;
}
}
} catch (e) { log('findField 실패', e); }
return null;
}
/* 홈페이지 입력칸. 아임웹 기본 항목이라 name/id 가 고정일 가능성이 높지만,
항목을 켠 뒤의 실제 마크업을 확인하기 전이므로 후보 이름과 라벨로 찾는다. */
function findHomepage() {
if (!CFG.HP_ENABLED || !document.querySelector) return null;
try {
var el = document.querySelector(
'input[name="homepage"], input[name="homePage"], input[name="home_page"], ' +
'input[id*="homepage"], input[id*="homePage"], input[name*="homepage"]');
if (el) return el;
var labels = document.querySelectorAll('form label');
for (var i = 0; i < labels.length; i++) {
var txt = (labels[i].textContent || '').replace(/\s+/g, '');
if (txt.indexOf(CFG.HP_LABEL) === -1) continue;
var blk = labels[i].parentElement;
var input = blk && blk.querySelector('input[type="url"], input[type="text"]');
if (input) return input;
}
} catch (e) { log('findHomepage 실패', e); }
return null;
}
/* ============ 회원가입 폼에 주입 ============ */
// 회원가입 폼(#join_form) 안의 입력칸인지
function inJoinForm(el) {
return !!(el.form && el.form.id === 'join_form');
}
function conceal(el) {
if (el.getAttribute('data-mg-utm') === '1') return;
el.setAttribute('data-mg-utm', '1');
var block = el.closest ? el.closest('.input_block') : null;
if (CFG.HIDE_FIELD) {
if (block) block.style.display = 'none';
else el.style.display = 'none';
} else {
el.readOnly = true;
el.setAttribute('tabindex', '-1');
el.style.background = '#f5f6f7';
el.style.color = '#8a8f98';
}
}
function put(el, value) {
// 가입 폼이 아닌 곳(회원정보 수정 화면 등)에 이미 값이 있으면 가입 때 기록된 값이므로 덮어쓰지 않는다
if (el.value && !inJoinForm(el)) { conceal(el); return; }
el.value = value;
// HTML5 검증 실패 시 URL 포맷으로 폴백 (항목 형식이 URL인 경우 방어)
if (el.checkValidity && !el.checkValidity()) {
el.value = serialize(resolve(), true);
if (!el.checkValidity()) el.value = fallbackValue(true);
}
// 아임웹 자체 검증 로직에 값 변경을 알림
var evts = ['input', 'change', 'keyup', 'blur'];
for (var i = 0; i < evts.length; i++) {
try { el.dispatchEvent(new Event(evts[i], { bubbles: true })); } catch (e) { }
}
if (window.jQuery) { try { jQuery(el).trigger('change').trigger('blur'); } catch (e) { } }
conceal(el);
log('fill', el.name || el.id, el.value);
}
function fill() {
var el = findField();
if (!el) return false;
put(el, serialize(resolve(), el.type === 'url'));
// 홈페이지 항목은 같은 폼에 함께 렌더링되므로 유입경로 칸을 찾은 시점에 같이 채운다
var hp = findHomepage();
if (hp) put(hp, homepageValue(hp));
return true;
}
/* ============ 폼 제출 직전 최종 방어 ============
필수 + 숨김 상태에서 값이 비면 브라우저가 제출을 막으므로
어떤 경우에도 빈 값으로 남지 않게 한다. */
function guard() {
function ensure() {
var el = findField();
if (el && !el.value) {
var asUrl = (el.type === 'url');
el.value = serialize(resolve(), asUrl) || fallbackValue(asUrl);
log('guard refill', el.value);
}
var hp = findHomepage();
if (hp && !hp.value) {
hp.value = homepageValue(hp) || fallbackValue(true);
log('guard refill homepage', hp.value);
}
}
document.addEventListener('submit', ensure, true);
document.addEventListener('click', function (e) {
var tg = e.target;
if (!tg || !tg.closest) return;
if (tg.closest('button, input[type=submit], a')) ensure();
}, true);
}
/* ===================== 부팅 ===================== */
// 업체 링크 입구(/go?c=코드)는 곧바로 다른 페이지로 넘어간다 — 여기서 유입을 기록하면
// 업체가 링크를 올린 사이트가 최초유입으로 굳어 업체 UTM 이 최종유입으로만 남는다
if (window.MG_PARTNER_ENTRY) return;
capture();
function start() {
guard();
if (fill()) return;
// 아임웹은 폼을 지연 렌더링/재렌더링하므로 DOM 변화를 감시
var stop = false, mo = null;
function done() {
stop = true;
if (mo) { try { mo.disconnect(); } catch (e) { } }
}
if (window.MutationObserver && document.body) {
mo = new MutationObserver(function () { if (!stop && fill()) done(); });
mo.observe(document.body, { childList: true, subtree: true });
}
// 관측자 미지원/누락 대비 폴링 (최대 20초)
var tries = 0;
var timer = setInterval(function () {
if (stop || fill() || ++tries > 40) { clearInterval(timer); done(); }
}, 500);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
// 디버그용 핸들: 콘솔에서 MG_UTM.peek() 실행
window.MG_UTM = {
peek: function () {
var raw = lsGet(K_FIRST) || ckGet(K_FIRST);
var exp = null;
try { var o = JSON.parse(raw); if (o && o._e) exp = new Date(o._e).toISOString().slice(0, 10); } catch (e) { }
return {
first: readTouch(K_FIRST),
last: readTouch(K_LAST),
expiresOn: exp, // 이 날짜가 지나면 최초유입이 초기화됨
value: serialize(resolve(), false),
homepage: homepageValue(findHomepage()) // 홈페이지 항목에 들어갈 값
};
},
reset: function () {
dropTouch(K_FIRST); dropTouch(K_LAST);
return 'cleared';
},
refill: fill
};
})();
</script>
<!-- ============================================================
[자본주의가이드] 업체 링크 추적 v1.0
위 UTM 스크립트와 따로 동작한다 (한쪽이 실패해도 다른 쪽은 영향 없음).
1) 업체 단축 링크(moneyguide.wandergo.net/r/코드)를 누르고 들어오면 주소에 붙은
mg_ref(클릭 ID)를 30일간 보관하고 주소창에서는 지운다.
(주소를 그대로 공유해도 다른 사람이 그 업체 실적으로 잡히지 않게)
2) 로그인한 회원이 확인되면 "이 회원이 이 클릭으로 들어왔다"를 서버에 한 번 알린다.
→ 구매는 서버가 아임웹 주문을 1시간마다 확인해 이 기록으로 업체에 귀속한다.
3) 신청 완료 위젯(imweb-free-apply.html)이 window.MG_PARTNER.refs() 로 같은 값을 읽어
신청 기록에 함께 보낸다.
저장: UTM 스크립트처럼 localStorage + .moneyguider.co.kr 쿠키 이중화
(www ↔ apex 이동, 카카오 인앱브라우저·Safari ITP 대비).
클릭 ID 와 보관 시각만 저장한다 — 회원 정보는 저장하지 않는다.
============================================================ -->
<script>
(function () {
'use strict';
/* ===================== 설정 ===================== */
var API_BASE = 'https://moneyguide.wandergo.net/api'; // 백엔드 주소
var COOKIE_DOMAIN = '.moneyguider.co.kr'; // 위 UTM 스크립트와 같은 값
/* 아래는 백엔드(shared/utils/partner-link.ts)와 같아야 한다 */
var PARAM = 'mg_ref';
var REF_PATTERN = /^[0-9A-Za-z]{16}$/;
var TTL_DAYS = 30; // 귀속 기간
var DAY_MS = 86400000;
var MAX_REFS = 10; // 쿠키가 커지지 않게 최근 것만 둔다
var K_REFS = 'mg_partner_refs';
var K_SENT = 'mg_partner_sent'; // 이미 서버에 알린 클릭 ID
/* 아임웹 전역(MEMBER_UID)은 Header Code 보다 늦게 생긴다 */
var WAIT_MS = 5000;
var POLL_MS = 200;
/* ============ 저장소: localStorage + 쿠키 이중화 ============ */
function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }
function ckGet(k) {
var m = document.cookie.match('(^|;)\\s*' + k + '\\s*=\\s*([^;]*)');
if (!m) return null;
try { return decodeURIComponent(m[2]); } catch (e) { return m[2]; }
}
function ckSet(k, v, days) {
var d = new Date();
d.setTime(d.getTime() + days * DAY_MS);
document.cookie = k + '=' + encodeURIComponent(v) +
';expires=' + d.toUTCString() + ';path=/;domain=' + COOKIE_DOMAIN + ';SameSite=Lax';
}
function read(k) { return lsGet(k) || ckGet(k) || ''; }
function write(k, v) { lsSet(k, v); ckSet(k, v, TTL_DAYS); }
/* 저장 형식 'ref~보관시각(36진수),…' — 쿠키 크기를 줄이려고 JSON 대신 짧게 쓴다.
30일이 지났거나 형식이 아닌 값은 버린다. */
function loadRefs() {
var out = [], now = Date.now(), parts = read(K_REFS).split(',');
for (var i = 0; i < parts.length; i++) {
var p = parts[i].split('~');
var at = parseInt(p[1], 36);
if (REF_PATTERN.test(p[0]) && at > 0 && now - at <= TTL_DAYS * DAY_MS) {
out.push({ ref: p[0], at: at });
}
}
return out;
}
function saveRefs(list) {
var parts = [];
for (var i = 0; i < list.length; i++) parts.push(list[i].ref + '~' + list[i].at.toString(36));
write(K_REFS, parts.join(','));
}
function refsOnly(list) {
var out = [];
for (var i = 0; i < list.length; i++) out.push(list[i].ref);
return out;
}
/* ============ 1) 주소의 클릭 ID 보관 + 주소창 정리 ============ */
var refs = loadRefs();
try {
var url = new URL(window.location.href);
var ref = url.searchParams.get(PARAM);
if (ref && REF_PATTERN.test(ref)) {
var kept = [];
for (var i = 0; i < refs.length; i++) if (refs[i].ref !== ref) kept.push(refs[i]);
kept.push({ ref: ref, at: Date.now() }); // 최근 것이 뒤
refs = kept.slice(-MAX_REFS);
}
if (url.searchParams.has(PARAM)) {
url.searchParams.delete(PARAM);
window.history.replaceState(window.history.state, '', url.pathname + url.search + url.hash);
}
} catch (e) { }
saveRefs(refs);
/* ============ 3) 다른 위젯이 읽는 창구 ============ */
window.MG_PARTNER = {
refs: function () { return refsOnly(loadRefs()); }
};
/* ============ 2) 로그인 회원 ↔ 클릭 연결 ============ */
function report(memberUid) {
var current = window.MG_PARTNER.refs();
var sent = read(K_SENT) ? read(K_SENT).split(',') : [];
var pending = [];
for (var i = 0; i < current.length; i++) {
if (sent.indexOf(current[i]) === -1) pending.push(current[i]);
}
if (!pending.length || typeof window.fetch !== 'function') return;
window.fetch(API_BASE + '/partner-touches', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'omit',
body: JSON.stringify({ memberUid: memberUid, refs: pending })
}).then(function (res) {
if (!res.ok) return; // 실패하면 다음 페이지에서 다시 보낸다
// 보관 중인 것만 남겨 목록이 끝없이 늘지 않게 한다
var next = [];
var all = sent.concat(pending);
for (var j = 0; j < all.length; j++) {
if (current.indexOf(all[j]) !== -1 && next.indexOf(all[j]) === -1) next.push(all[j]);
}
write(K_SENT, next.join(','));
}).catch(function () { });
}
if (!refs.length) return; // 보관한 클릭이 없으면 할 일이 없다
var waited = 0;
(function poll() {
var uid = window.MEMBER_UID;
if (uid) { report(String(uid)); return; }
// 빈 문자열이면 비로그인 확정 — 로그인한 뒤 다른 페이지에서 다시 시도된다
if (typeof uid === 'string' || waited >= WAIT_MS) return;
waited += POLL_MS;
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>
+48
View File
@@ -0,0 +1,48 @@
<script>
//UTM 트래킹을 위한 코드 (무료강의 랜딩용)
document.addEventListener('mousedown', function(e) {
// 클릭한 요소가 로그인 링크인지 확인
const anchor = e.target.closest('a[href*="/login"]');
if (anchor) {
const urlParams = new URLSearchParams(window.location.search);
const utms = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'];
// 1. 현재 주소창에서 UTM 파라미터들 추출
let foundUtmParams = [];
utms.forEach(key => {
if (urlParams.has(key)) {
foundUtmParams.push(key + '=' + encodeURIComponent(urlParams.get(key)));
}
});
// UTM이 있을 때만 실행
if (foundUtmParams.length > 0) {
const url = new URL(anchor.href, window.location.origin);
let backUrl = url.searchParams.get('back_url');
if (backUrl) {
try {
// 2. 기존의 암호화된 back_url을 복호화 (예: /shop_view?idx=38&metaconversion=true)
let decodedBackUrl = atob(backUrl);
// 아임웹이 이미 UTM을 넣어둔 경우(또는 앞선 mousedown에서 넣은 경우) 중복으로 붙이지 않음
if (/[?&]utm_/.test(decodedBackUrl)) return;
// 3. 복호화된 주소 뒤에 UTM 결합
const separator = decodedBackUrl.includes('?') ? '&' : '?';
let updatedBackUrl = decodedBackUrl + separator + foundUtmParams.join('&');
// 4. 다시 Base64로 암호화해서 back_url 파라미터 교체
url.searchParams.set('back_url', btoa(updatedBackUrl));
anchor.href = url.toString();
console.log('UTM이 back_url 내부로 성공적으로 삽입되었습니다.');
} catch (err) {
console.error('Base64 디코딩/인코딩 오류:', err);
}
}
}
}
}, true);
</script>
+1739 -1055
View File
File diff suppressed because it is too large Load Diff
-114
View File
@@ -1,114 +0,0 @@
<!-- ===== 아임웹 Header Code 삽입용 — 업체 링크 추적 (partner-ref) ===== -->
<!--
어디에 넣나 : 아임웹 관리자 > 환경설정 > SEO/HTML > Header Code (사이트 전체)
header-top.html(UTM 스크립트)과 함께 두어도 서로 영향이 없습니다.
하는 일
1) 업체 단축 링크(moneyguide.wandergo.net/r/코드)를 누르고 들어오면 주소에 붙은
mg_ref(클릭 ID)를 이 브라우저에 30일간 보관하고, 주소창에서는 지웁니다.
(주소를 그대로 공유해도 다른 사람이 그 업체 실적으로 잡히지 않게)
2) 로그인한 회원이 확인되면 "이 회원이 이 링크로 들어왔다"를 서버에 한 번 알립니다.
→ 구매는 서버가 아임웹 주문을 1시간마다 확인해 이 기록으로 업체에 귀속합니다.
3) 신청 완료 위젯(imweb-free-apply.html)이 window.MG_PARTNER.refs() 로 같은 값을 읽어
신청 기록에 함께 보냅니다.
저장하는 값 : 클릭 ID 와 보관 시각만 (회원 정보는 저장하지 않습니다)
-->
<script>
(function () {
'use strict';
/* 백엔드 주소 */
var API_BASE = 'https://moneyguide.wandergo.net/api';
/* 아래는 수정할 필요 없습니다 — 백엔드(shared/utils/partner-link.ts)와 같은 값입니다 */
var PARAM = 'mg_ref';
var REF_PATTERN = /^[0-9A-Za-z]{16}$/;
var STORE_KEY = 'mg_partner_refs'; /* [{ ref, at }] — 최근 것이 뒤 */
var SENT_KEY = 'mg_partner_sent'; /* 이미 서버에 알린 클릭 ID */
var WINDOW_MS = 30 * 24 * 60 * 60 * 1000; /* 귀속 기간 30일 */
var MAX_REFS = 20;
/* 아임웹 전역(MEMBER_UID)은 Header Code 보다 늦게 생깁니다 */
var WAIT_MS = 5000;
var POLL_MS = 200;
function readJson(key) {
try {
var value = JSON.parse(window.localStorage.getItem(key) || '[]');
return Array.isArray(value) ? value : [];
} catch (e) { return []; }
}
function writeJson(key, value) {
try { window.localStorage.setItem(key, JSON.stringify(value)); } catch (e) { }
}
/* 30일이 지났거나 형식이 아닌 값은 버립니다 */
function freshRefs() {
var now = Date.now();
return readJson(STORE_KEY).filter(function (item) {
return item && REF_PATTERN.test(item.ref) &&
typeof item.at === 'number' && now - item.at <= WINDOW_MS;
});
}
/* 1) 주소의 클릭 ID 보관 + 주소창 정리 */
var refs = freshRefs();
try {
var url = new URL(window.location.href);
var ref = url.searchParams.get(PARAM);
if (ref && REF_PATTERN.test(ref)) {
refs = refs.filter(function (item) { return item.ref !== ref; });
refs.push({ ref: ref, at: Date.now() });
if (refs.length > MAX_REFS) refs = refs.slice(-MAX_REFS);
}
if (url.searchParams.has(PARAM)) {
url.searchParams.delete(PARAM);
window.history.replaceState(window.history.state, '',
url.pathname + url.search + url.hash);
}
} catch (e) { }
writeJson(STORE_KEY, refs);
/* 3) 다른 위젯이 읽는 창구 */
window.MG_PARTNER = {
refs: function () {
return freshRefs().map(function (item) { return item.ref; });
}
};
/* 2) 로그인 회원 ↔ 클릭 연결 */
function report(memberUid) {
var current = window.MG_PARTNER.refs();
var sent = readJson(SENT_KEY);
var pending = current.filter(function (r) { return sent.indexOf(r) === -1; });
if (pending.length === 0 || typeof window.fetch !== 'function') return;
window.fetch(API_BASE + '/partner-touches', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'omit',
body: JSON.stringify({ memberUid: memberUid, refs: pending })
}).then(function (res) {
if (!res.ok) return; /* 실패하면 다음 페이지에서 다시 보냅니다 */
/* 보관 중인 것만 남겨 목록이 끝없이 늘지 않게 합니다 */
writeJson(SENT_KEY, sent.concat(pending).filter(function (r) {
return current.indexOf(r) !== -1;
}));
}).catch(function () { });
}
if (refs.length === 0) return; /* 보관한 클릭이 없으면 할 일이 없습니다 */
var waited = 0;
(function poll() {
var uid = window.MEMBER_UID;
if (uid) { report(String(uid)); return; }
/* 빈 문자열이면 비로그인 확정 — 로그인한 뒤 다른 페이지에서 다시 시도됩니다 */
if (typeof uid === 'string' || waited >= WAIT_MS) return;
waited += POLL_MS;
window.setTimeout(poll, POLL_MS);
})();
})();
</script>
<!-- ===== 코드 끝 ===== -->