generated from ttipo/project-base
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38e945767b | |||
| e91f7620a3 | |||
| 59c84dcac5 | |||
| 7bef853664 | |||
| 2f01bea6ef | |||
| c5440e0cf4 |
@@ -31,7 +31,8 @@ tools: Read, Grep, Glob, Edit, Write, Bash, Skill
|
||||
- **env 파일 존재**: 루트 `.env.{APP_ENV}` 와 `backend/.env.{APP_ENV}` 가 둘 다 있어야 기동한다.
|
||||
- **운영 배포 전**: `BACKEND_API_URL`·`CORS_ORIGIN` 이 실제 도메인인지 확인한다.
|
||||
`localhost`·`example.com` 이 남아 있으면 백엔드가 기동에 실패한다 ([[env-structure]]).
|
||||
- **프론트엔드**: 개발에서는 컨테이너로 띄우지 않는다(profile `frontend` 뒤에 있다). 운영만 nginx 정적 호스팅이다.
|
||||
- **프론트엔드**: 개발·운영 모두 컨테이너로 띄우지 않는다(base 의 profile `frontend` 뒤에 있다).
|
||||
관리자 화면은 아임웹 코드 위젯이다. nginx 정적 호스팅이 필요할 때만 `COMPOSE_PROFILES=frontend` 로 켠다.
|
||||
|
||||
## 완료 기준 (실행하고 결과를 보고한다)
|
||||
|
||||
|
||||
@@ -32,9 +32,13 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.d
|
||||
> ⚠️ `.env.production` 의 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인으로 교체**해야 한다.
|
||||
> `localhost` 나 예시 도메인(`example.com`)이 남아 있으면 백엔드가 기동에 실패한다 ([[env-structure]]).
|
||||
> 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓰는 경우에만 `ALLOW_LOCALHOST_URL=true` 를 함께 준다.
|
||||
>
|
||||
> **운영에서도 프론트엔드 컨테이너는 기본 비활성이다** (base `docker-compose.yml` 의 profile `frontend`).
|
||||
> 관리자 화면은 아임웹 코드 위젯(`imweb/`)으로 제공하므로 backend + db 만 빌드·기동한다.
|
||||
> nginx 정적 호스팅이 필요해지면 루트 `.env.production` 에 `COMPOSE_PROFILES=frontend` 를 넣는다.
|
||||
|
||||
```bash
|
||||
# 운영 환경 배포
|
||||
# 운영 환경 배포 — backend + db
|
||||
sudo docker compose --env-file .env.production up -d --build
|
||||
|
||||
# 로그 확인
|
||||
@@ -77,7 +81,8 @@ COMPOSE_PROFILES=frontend
|
||||
- 호스트 vite 와 컨테이너 vite 는 같은 호스트 포트(`FRONTEND_PORT`)를 쓴다 — **동시에 띄우지 말 것.**
|
||||
- 컨테이너 **내부** 포트는 5173 고정이다 (compose 의 `--port`·healthcheck·포트 매핑이 함께 묶여 있다).
|
||||
루트 env 는 컨테이너 안에 없으므로 `vite.config.ts` 는 기본값으로 떨어지고, compose 의 `--port` 가 이긴다.
|
||||
- 운영(`docker-compose.yml` 단독)에는 profile 이 없으므로 nginx 정적 호스팅이 그대로 동작한다.
|
||||
- profile `frontend` 는 base `docker-compose.yml` 에 걸려 있어 **운영에서도 기본 비활성**이다.
|
||||
운영에서 같은 profile 을 켜면 dev 오버라이드가 없으므로 vite 대신 nginx 정적 호스팅(8080)으로 뜬다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -92,8 +92,12 @@ UPLOAD_MAX_SIZE_MB=5
|
||||
# IMWEB_ADMIN_PAGE_URL 아임웹 연결을 마친 뒤 돌아갈 관리자 화면 주소
|
||||
# IMWEB_SYNC_INTERVAL_MINUTES 회원 전체 동기화 주기 (분, 기본 60)
|
||||
# IMWEB_PAGE_SIZE 동기화 때 한 번에 받는 회원 수 (기본 100)
|
||||
# IMWEB_SITE_URL (선택) 아임웹 사이트 주소 — 업체 링크의 이동 주소는 이 사이트 안만 허용한다.
|
||||
# 비우면 IMWEB_ADMIN_PAGE_URL 의 origin 을 쓴다 (관리자 화면이 아임웹 사이트 안에 있으므로)
|
||||
# ==========================================
|
||||
IMWEB_SITE_CODE=S20230730923b9e1418574
|
||||
IMWEB_ADMIN_PAGE_URL=http://localhost:5173/imweb-members.html
|
||||
IMWEB_SYNC_INTERVAL_MINUTES=60
|
||||
IMWEB_PAGE_SIZE=100
|
||||
# 로컬 관리자 화면(localhost:5173) 대신 실제 사이트 상품 주소로 링크를 만들어 보려면:
|
||||
# IMWEB_SITE_URL=https://www.moneyguider.co.kr
|
||||
|
||||
@@ -46,7 +46,7 @@ REDIS_DATA_DIR=./data/redis
|
||||
# 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓰는 경우에만
|
||||
# ALLOW_LOCALHOST_URL=true 로 이 검사를 건너뛴다. 실제 배포에는 설정하지 않는다.
|
||||
# ==========================================
|
||||
# 예) 백엔드 https://moneyguider.wandergo.net/api, 관리자 화면(아임웹) https://www.moneyguider.co.kr
|
||||
# 예) 백엔드 https://moneyguide.wandergo.net/api, 관리자 화면(아임웹) https://www.moneyguider.co.kr
|
||||
# → CORS_ORIGIN 에 아임웹 도메인(www 유무 둘 다)을 넣는다
|
||||
BACKEND_API_URL=https://api.example.com/api
|
||||
CORS_ORIGIN=https://www.example.com,https://example.com
|
||||
@@ -70,9 +70,11 @@ DB_SYNCHRONIZE=false
|
||||
# ==========================================
|
||||
# 선택적 서비스 (Compose profiles)
|
||||
#
|
||||
# 기본값(비어 있음) = redis 를 기동하지 않는다.
|
||||
# 캐시·세션 공유가 필요해지면 아래를 redis 로 바꾸면 컨테이너가 함께 뜬다.
|
||||
# COMPOSE_PROFILES=redis
|
||||
# 기본값(비어 있음) = redis·frontend 를 빌드·기동하지 않는다 (backend + db 만 뜬다).
|
||||
# redis 캐시·세션 공유가 필요해질 때 → COMPOSE_PROFILES=redis
|
||||
# frontend Vue 화면을 nginx 로 서빙해야 할 때 → COMPOSE_PROFILES=frontend
|
||||
# (관리자 화면은 아임웹 코드 위젯이라 지금은 쓰지 않는다)
|
||||
# 둘 다 → COMPOSE_PROFILES=redis,frontend
|
||||
# 일회성으로 켜려면: docker compose --profile redis ... up -d
|
||||
# ==========================================
|
||||
COMPOSE_PROFILES=
|
||||
@@ -94,8 +96,11 @@ UPLOAD_MAX_SIZE_MB=5
|
||||
# IMWEB_ADMIN_PAGE_URL 아임웹 연결을 마친 뒤 돌아갈 관리자 화면 주소
|
||||
# IMWEB_SYNC_INTERVAL_MINUTES 회원 전체 동기화 주기 (분, 기본 60)
|
||||
# IMWEB_PAGE_SIZE 동기화 때 한 번에 받는 회원 수 (기본 100)
|
||||
# IMWEB_SITE_URL (선택) 아임웹 사이트 주소 — 업체 링크의 이동 주소는 이 사이트 안만 허용한다.
|
||||
# 비우면 IMWEB_ADMIN_PAGE_URL 의 origin 을 쓴다 (관리자 화면이 아임웹 사이트 안에 있으므로)
|
||||
# ==========================================
|
||||
IMWEB_SITE_CODE=S20230730923b9e1418574
|
||||
IMWEB_ADMIN_PAGE_URL=https://www.moneyguider.co.kr/admin-members
|
||||
IMWEB_SYNC_INTERVAL_MINUTES=60
|
||||
IMWEB_PAGE_SIZE=100
|
||||
# IMWEB_SITE_URL=https://www.moneyguider.co.kr
|
||||
|
||||
@@ -48,7 +48,8 @@ cd frontend && npm install && npm run dev
|
||||
> vite 를 컨테이너에서 돌리면 소스가 Windows 바인드 마운트 너머에 있어 첫 서빙까지 수 분이 걸립니다.
|
||||
> 호스트에서 `npm run dev` 로 띄우면 1~2초이고, `/api` 요청은 vite 프록시가 백엔드 컨테이너로 넘깁니다.
|
||||
> 컨테이너로 띄워야 한다면 `--profile frontend` 를 붙이세요 (아래 "프론트엔드 실행 방식" 참조).
|
||||
> 운영(`docker-compose.yml` 단독)은 그대로 nginx 정적 호스팅으로 동작합니다.
|
||||
> **운영에서도 프론트엔드 컨테이너는 기본 비활성입니다.** 관리자 화면은 아임웹 코드 위젯(`imweb/`)으로 제공하므로
|
||||
> 운영 배포는 backend + db 만 빌드·기동합니다. nginx 정적 호스팅이 필요해지면 `COMPOSE_PROFILES=frontend` 를 설정하세요.
|
||||
|
||||
> Redis 는 기본적으로 뜨지 않습니다. 필요해지면 루트 `.env.{env}` 에 `COMPOSE_PROFILES=redis` 를 설정하세요.
|
||||
|
||||
@@ -58,7 +59,7 @@ cd frontend && npm install && npm run dev
|
||||
| --- | --- | --- | --- |
|
||||
| **호스트 vite (개발 기본)** | `cd frontend && npm run dev` | 1~2초 (최초 의존성 사전번들 10초 내외) | 일상 개발 |
|
||||
| 컨테이너 vite (선택) | `docker compose ... --profile frontend up -d` | 수 분 (Windows 바인드 마운트) | 호스트에 Node 를 둘 수 없을 때 |
|
||||
| nginx 정적 (운영) | `docker compose --env-file .env.production up -d --build` | - | 배포 |
|
||||
| nginx 정적 (운영, 기본 비활성) | `docker compose --env-file .env.production --profile frontend up -d --build` | - | Vue 화면을 배포할 때 |
|
||||
|
||||
> 컨테이너 방식을 상시 사용하려면 루트 `.env.development` 에 `COMPOSE_PROFILES=frontend` 를 넣습니다.
|
||||
> (redis 와 함께 켤 때는 `COMPOSE_PROFILES=frontend,redis`)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ApplicationsModule } from '@/modules/applications/applications.module';
|
||||
import { HealthModule } from '@/modules/health/health.module';
|
||||
import { ImwebModule } from '@/modules/imweb/imweb.module';
|
||||
import { MembersModule } from '@/modules/members/members.module';
|
||||
import { PartnersModule } from '@/modules/partners/partners.module';
|
||||
import { UploadModule } from '@/modules/upload/upload.module';
|
||||
import { UserModule } from '@/modules/user/user.module';
|
||||
import { parsePositiveInt } from '@/shared/utils/env';
|
||||
@@ -68,6 +69,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
|
||||
ImwebModule,
|
||||
MembersModule,
|
||||
ApplicationsModule,
|
||||
PartnersModule,
|
||||
],
|
||||
providers: [
|
||||
ShutdownLogger,
|
||||
|
||||
@@ -98,11 +98,35 @@ const DOMAIN_ERROR_CODES = {
|
||||
message:
|
||||
'아임웹 연동 설정이 완료되지 않았습니다. 관리자에게 문의해 주세요.',
|
||||
},
|
||||
// 연결된 토큰에 필요한 권한(상품·주문 읽기 등)이 없을 때 — 아임웹이 403 으로 거부한다
|
||||
IMWEB_006: {
|
||||
status: HttpStatus.FORBIDDEN,
|
||||
message:
|
||||
'아임웹 권한이 부족합니다. 연동 관리에서 아임웹 연결을 다시 해 주세요.',
|
||||
},
|
||||
// 강의 신청 기록 — 신청 완료 화면이 보낸다
|
||||
APPLY_001: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '회원 정보를 확인할 수 없어 신청을 기록하지 못했습니다.',
|
||||
},
|
||||
// 업체 링크 — 업체별 신청·구매 추적
|
||||
PARTNER_001: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message: '이 업체에는 같은 상품의 링크가 이미 있습니다.',
|
||||
},
|
||||
PARTNER_002: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'링크가 발급된 업체는 삭제할 수 없습니다. 실적 기록을 지키기 위해 링크를 중지해 주세요.',
|
||||
},
|
||||
PARTNER_003: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '이동 주소는 우리 아임웹 사이트 안의 주소여야 합니다.',
|
||||
},
|
||||
PARTNER_004: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message: '같은 이름의 업체가 이미 있습니다.',
|
||||
},
|
||||
} as const satisfies Record<string, ErrorCodeDefinition>;
|
||||
|
||||
export const ERROR_CODES = {
|
||||
|
||||
+6
-2
@@ -28,8 +28,12 @@ async function bootstrap() {
|
||||
|
||||
// 글로벌 prefix — 모든 라우트가 /api/* 로 정규화됨 (Flutter, Web 양쪽 baseURL과 일치)
|
||||
app.setGlobalPrefix('api', {
|
||||
// 헬스체크 / 루트 상태 엔드포인트는 prefix 미적용
|
||||
exclude: [{ path: '/', method: RequestMethod.GET }],
|
||||
exclude: [
|
||||
// 헬스체크 / 루트 상태 엔드포인트는 prefix 미적용
|
||||
{ path: '/', method: RequestMethod.GET },
|
||||
// 업체 단축 링크는 짧아야 하므로 /api 없이 /r/{코드} (PartnerRedirectController)
|
||||
{ path: 'r/:code', method: RequestMethod.GET },
|
||||
],
|
||||
});
|
||||
|
||||
// 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 추가
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddPartnerLinks1791210484651 implements MigrationInterface {
|
||||
name = 'AddPartnerLinks1791210484651';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "partners" ("id" uuid NOT NULL, "name" character varying(100) NOT NULL, "memo" text, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_998645b20820e4ab99aeae03b41" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "uq_partners_name" ON "partners" ("name") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "idx_partners_cursor" ON "partners" ("created_at", "id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "partner_links" ("id" uuid NOT NULL, "code" character varying(16) NOT NULL, "partner_id" uuid NOT NULL, "prod_no" integer NOT NULL, "target_url" text NOT NULL, "is_active" boolean NOT NULL DEFAULT true, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_9b3ad64da16c31b2fe6377fda91" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "uq_partner_links_code" ON "partner_links" ("code") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_85479a14717acd798f0691d6e2" ON "partner_links" ("prod_no") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "idx_partner_links_cursor" ON "partner_links" ("created_at", "id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "uq_partner_links_partner_product" ON "partner_links" ("partner_id", "prod_no") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "partner_link_clicks" ("ref" character varying(16) NOT NULL, "link_id" uuid NOT NULL, "clicked_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_3bae5d966512036470f8e12d37e" PRIMARY KEY ("ref"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "idx_partner_link_clicks_link_time" ON "partner_link_clicks" ("link_id", "clicked_at") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "partner_touches" ("id" uuid NOT NULL, "member_code" character varying(40) NOT NULL, "click_ref" character varying(16) NOT NULL, "link_id" uuid NOT NULL, "prod_no" integer NOT NULL, "clicked_at" TIMESTAMP WITH TIME ZONE NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_461c5428d39ff3b7971a607259c" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "idx_partner_touches_member_product" ON "partner_touches" ("member_code", "prod_no") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "uq_partner_touches_member_click" ON "partner_touches" ("member_code", "click_ref") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "partner_conversions" ("id" uuid NOT NULL, "type" character varying(20) NOT NULL, "link_id" uuid NOT NULL, "partner_id" uuid NOT NULL, "prod_no" integer NOT NULL, "member_code" character varying(40) NOT NULL, "application_id" uuid, "order_no" character varying(30), "is_valid" boolean NOT NULL DEFAULT true, "touched_at" TIMESTAMP WITH TIME ZONE NOT NULL, "converted_at" TIMESTAMP WITH TIME ZONE NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_ef640b4a299b8d72fa00dad9986" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "idx_partner_conversions_cursor" ON "partner_conversions" ("converted_at", "id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "idx_partner_conversions_partner_time" ON "partner_conversions" ("partner_id", "converted_at") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "idx_partner_conversions_link_time" ON "partner_conversions" ("link_id", "converted_at") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "uq_partner_conversions_order_product" ON "partner_conversions" ("order_no", "prod_no") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "uq_partner_conversions_application" ON "partner_conversions" ("application_id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "imweb_products" ("prod_no" integer NOT NULL, "name" character varying(200) NOT NULL, "prod_status" character varying(20), "prod_type" character varying(20), "is_display" boolean NOT NULL DEFAULT true, "image_url" text, "added_at" TIMESTAMP WITH TIME ZONE, "synced_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_70f2eacbd1c4ced5c69fd981234" PRIMARY KEY ("prod_no"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_2f0656a249891065be7b1582d1" ON "imweb_products" ("prod_status") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "partner_links" ADD CONSTRAINT "FK_0724320077cfbcc1a63a0fc5e4d" FOREIGN KEY ("partner_id") REFERENCES "partners"("id") ON DELETE RESTRICT ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "partner_link_clicks" ADD CONSTRAINT "FK_ac73de0b839ed8cee3576b2d0dc" FOREIGN KEY ("link_id") REFERENCES "partner_links"("id") ON DELETE RESTRICT ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "partner_touches" ADD CONSTRAINT "FK_b398d1fc61bd5f4f73ce768bcb1" FOREIGN KEY ("click_ref") REFERENCES "partner_link_clicks"("ref") ON DELETE RESTRICT ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "partner_touches" ADD CONSTRAINT "FK_1cca550fd006db68c070e80966a" FOREIGN KEY ("link_id") REFERENCES "partner_links"("id") ON DELETE RESTRICT ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "partner_conversions" ADD CONSTRAINT "FK_a3cf869f5ecc6078feca0149260" FOREIGN KEY ("link_id") REFERENCES "partner_links"("id") ON DELETE RESTRICT ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "partner_conversions" DROP CONSTRAINT "FK_a3cf869f5ecc6078feca0149260"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "partner_touches" DROP CONSTRAINT "FK_1cca550fd006db68c070e80966a"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "partner_touches" DROP CONSTRAINT "FK_b398d1fc61bd5f4f73ce768bcb1"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "partner_link_clicks" DROP CONSTRAINT "FK_ac73de0b839ed8cee3576b2d0dc"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "partner_links" DROP CONSTRAINT "FK_0724320077cfbcc1a63a0fc5e4d"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_2f0656a249891065be7b1582d1"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "imweb_products"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."uq_partner_conversions_application"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."uq_partner_conversions_order_product"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."idx_partner_conversions_link_time"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."idx_partner_conversions_partner_time"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."idx_partner_conversions_cursor"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "partner_conversions"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."uq_partner_touches_member_click"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."idx_partner_touches_member_product"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "partner_touches"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."idx_partner_link_clicks_link_time"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "partner_link_clicks"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."uq_partner_links_partner_product"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "public"."idx_partner_links_cursor"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_85479a14717acd798f0691d6e2"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "public"."uq_partner_links_code"`);
|
||||
await queryRunner.query(`DROP TABLE "partner_links"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."idx_partners_cursor"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."uq_partners_name"`);
|
||||
await queryRunner.query(`DROP TABLE "partners"`);
|
||||
}
|
||||
}
|
||||
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { parseDurationMs } from '@/shared/utils/duration';
|
||||
/**
|
||||
* 관리자 인증은 쿠키가 아니라 `Authorization: Bearer` 헤더로 한다.
|
||||
*
|
||||
* 관리자 화면은 아임웹(www.moneyguider.co.kr)에, API 는 다른 사이트(moneyguider.wandergo.net)에 있다.
|
||||
* 관리자 화면은 아임웹(www.moneyguider.co.kr)에, API 는 다른 사이트(moneyguide.wandergo.net)에 있다.
|
||||
* 이 조합에서 인증 쿠키는 제3자 쿠키가 되어 Safari·Chrome 시크릿 모드 등에서 차단되므로
|
||||
* 쿠키 방식(.claude/rules/security.md 의 기본값)을 쓸 수 없다.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -22,8 +22,12 @@ 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 {
|
||||
ApplicationInflowStatsQueryDto,
|
||||
ApplicationListQueryDto,
|
||||
} from './dto/application-query.dto';
|
||||
import {
|
||||
ApplicationInflowStatsResponseDto,
|
||||
ApplicationProductDto,
|
||||
ApplicationResponseDto,
|
||||
} from './dto/application-response.dto';
|
||||
@@ -104,4 +108,20 @@ export class AdminApplicationsController {
|
||||
products(): Promise<ApplicationProductDto[]> {
|
||||
return this.applications.products();
|
||||
}
|
||||
|
||||
@Get('inflow-stats')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'신청 유입 집계 — 신청 때 최종·최초 유입 또는 가입 경로 기준으로 신청 수를 센다',
|
||||
})
|
||||
@ApiStandardResponse(ApplicationInflowStatsResponseDto, {
|
||||
description: '집계 성공',
|
||||
})
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('VAL_001', '필터·기준 형식 오류')
|
||||
inflowStats(
|
||||
@Query() query: ApplicationInflowStatsQueryDto,
|
||||
): Promise<ApplicationInflowStatsResponseDto> {
|
||||
return this.applications.inflowStats(query);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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,
|
||||
@@ -19,6 +20,8 @@ import { ApplicationsService } from './applications.service';
|
||||
AdminAuthModule,
|
||||
ImwebModule,
|
||||
MembersModule,
|
||||
// 신청을 업체 링크 실적으로 귀속한다
|
||||
PartnersModule,
|
||||
],
|
||||
controllers: [ApplicationsController, AdminApplicationsController],
|
||||
providers: [ApplicationsService],
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
|
||||
@@ -32,14 +33,26 @@ function setup(member: ImwebMember | null = MEMBER) {
|
||||
update: jest.fn(() => Promise.resolve()),
|
||||
};
|
||||
const lookup = { find: jest.fn(() => Promise.resolve(member)) };
|
||||
const calls: string[] = [];
|
||||
const attribution = {
|
||||
recordTouches: jest.fn(() => {
|
||||
calls.push('recordTouches');
|
||||
return Promise.resolve();
|
||||
}),
|
||||
attributeApplication: jest.fn(() => {
|
||||
calls.push('attributeApplication');
|
||||
return Promise.resolve();
|
||||
}),
|
||||
};
|
||||
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,
|
||||
);
|
||||
return { service, store, applications, lookup };
|
||||
return { service, store, applications, lookup, attribution, calls };
|
||||
}
|
||||
|
||||
describe('ApplicationsService.create', () => {
|
||||
@@ -97,6 +110,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();
|
||||
|
||||
@@ -131,3 +184,61 @@ describe('ApplicationsService.create', () => {
|
||||
expect(result).toEqual({ id: 'winner-id', isNew: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApplicationsService.create — 업체 링크 실적', () => {
|
||||
it('클릭 연결을 먼저 남긴 뒤 신청을 귀속한다', async () => {
|
||||
const { service, attribution, calls } = setup();
|
||||
|
||||
const result = await service.create({
|
||||
memberUid: 'user@test.dev',
|
||||
productIdx: '52',
|
||||
partnerRefs: ['AbCd1234EfGh5678'],
|
||||
});
|
||||
|
||||
expect(attribution.recordTouches).toHaveBeenCalledWith(MEMBER.memberCode, [
|
||||
'AbCd1234EfGh5678',
|
||||
]);
|
||||
expect(attribution.attributeApplication).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: result.id,
|
||||
memberCode: MEMBER.memberCode,
|
||||
productIdx: '52',
|
||||
}),
|
||||
);
|
||||
expect(calls).toEqual(['recordTouches', 'attributeApplication']);
|
||||
});
|
||||
|
||||
it('이미 있는 신청도 귀속을 다시 시도한다 (연결 기록이 늦게 온 경우)', async () => {
|
||||
const { service, attribution } = setup();
|
||||
await service.create({ memberUid: 'user@test.dev', productIdx: '52' });
|
||||
|
||||
await service.create({ memberUid: 'user@test.dev', productIdx: '52' });
|
||||
|
||||
expect(attribution.attributeApplication).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('업체 실적 처리가 실패해도 신청 기록은 성공한다', async () => {
|
||||
const { service, store, attribution } = setup();
|
||||
attribution.recordTouches.mockRejectedValueOnce(new Error('db down'));
|
||||
attribution.attributeApplication.mockRejectedValueOnce(
|
||||
new Error('db down'),
|
||||
);
|
||||
|
||||
const result = await service.create({
|
||||
memberUid: 'user@test.dev',
|
||||
productIdx: '52',
|
||||
partnerRefs: ['AbCd1234EfGh5678'],
|
||||
});
|
||||
|
||||
expect(result.isNew).toBe(true);
|
||||
expect(store).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('클릭 ID 가 없으면 연결 기록을 부르지 않는다', async () => {
|
||||
const { service, attribution } = setup();
|
||||
|
||||
await service.create({ memberUid: 'user@test.dev', productIdx: '52' });
|
||||
|
||||
expect(attribution.recordTouches).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { IsNull, QueryFailedError, Repository } from 'typeorm';
|
||||
import {
|
||||
IsNull,
|
||||
QueryFailedError,
|
||||
Repository,
|
||||
SelectQueryBuilder,
|
||||
} from 'typeorm';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
@@ -11,13 +16,21 @@ import {
|
||||
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 { 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 { parseInflow } from '@/shared/utils/inflow';
|
||||
import { PartnerAttributionService } from '@/modules/partners/partner-attribution.service';
|
||||
import { InflowStatus, 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 {
|
||||
ApplicationFilterDto,
|
||||
ApplicationInflowBasis,
|
||||
ApplicationInflowStatsQueryDto,
|
||||
ApplicationListQueryDto,
|
||||
} from './dto/application-query.dto';
|
||||
import {
|
||||
ApplicationInflowStatsResponseDto,
|
||||
ApplicationProductDto,
|
||||
ApplicationResponseDto,
|
||||
} from './dto/application-response.dto';
|
||||
@@ -30,6 +43,22 @@ const ALIAS = 'a';
|
||||
const MEMBER_ALIAS = 'm';
|
||||
/** Postgres 유일 제약 위반 — 같은 회원·상품이 동시에 두 번 들어온 경우 */
|
||||
const PG_UNIQUE_VIOLATION = '23505';
|
||||
/** 집계 결과 최대 줄 수 (회원 유입 집계와 같다) */
|
||||
const STATS_MAX_ITEMS = 100;
|
||||
|
||||
/**
|
||||
* 유입 기준·항목 → SQL 식 (항목 이름이 곧 컬럼 접미사다: inflow_source 등).
|
||||
* 가입 경로는 가입 때 기록된 값만 쓴다 — 가입 후 회원정보 수정 화면에서 채워진 값은 가입 경로가 아니다.
|
||||
* 값은 모두 enum 으로 검증된 것이라 문자열로 넣어도 안전하다.
|
||||
*/
|
||||
function inflowExpression(
|
||||
basis: ApplicationInflowBasis,
|
||||
field: InflowGroupBy,
|
||||
): string {
|
||||
if (basis === 'apply_first') return `${ALIAS}.inflow_${field}`;
|
||||
if (basis === 'apply_last') return `${ALIAS}.inflow_last_${field}`;
|
||||
return `CASE WHEN ${MEMBER_ALIAS}.inflow_status = '${InflowStatus.AT_JOIN}' THEN ${MEMBER_ALIAS}.inflow_${field} END`;
|
||||
}
|
||||
|
||||
function escapeLike(value: string): string {
|
||||
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
|
||||
@@ -62,6 +91,7 @@ export class ApplicationsService {
|
||||
private readonly lookup: ImwebMemberLookupService,
|
||||
private readonly membersService: MembersService,
|
||||
private readonly config: ImwebConfig,
|
||||
private readonly attribution: PartnerAttributionService,
|
||||
) {}
|
||||
|
||||
/** 신청 기록 — 같은 회원·상품이면 기존 기록을 돌려준다 (몇 번을 불러도 한 건) */
|
||||
@@ -74,8 +104,15 @@ export class ApplicationsService {
|
||||
});
|
||||
}
|
||||
|
||||
// 업체 링크 클릭 연결을 먼저 남겨야 바로 아래 귀속에서 찾을 수 있다
|
||||
await this.recordPartnerRefs(member.memberCode, dto.partnerRefs);
|
||||
|
||||
const existing = await this.findExisting(member.memberCode, dto.productIdx);
|
||||
if (existing) return { id: existing.id, isNew: false };
|
||||
if (existing) {
|
||||
// 연결 기록이 신청보다 늦게 들어온 경우를 위해 다시 시도한다 (이미 귀속됐으면 그대로)
|
||||
await this.attributeToPartner(existing);
|
||||
return { id: existing.id, isNew: false };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const inflow = parseInflow(
|
||||
@@ -83,6 +120,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,
|
||||
@@ -96,6 +140,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 {
|
||||
@@ -111,9 +162,33 @@ export class ApplicationsService {
|
||||
this.logger.log(
|
||||
`강의 신청 기록: member=${member.memberCode} product=${dto.productIdx}`,
|
||||
);
|
||||
await this.attributeToPartner(row);
|
||||
return { id: row.id, isNew: true };
|
||||
}
|
||||
|
||||
/** 업체 링크 실적은 부가 기능이다 — 실패해도 신청 기록은 그대로 성공시킨다 */
|
||||
private async recordPartnerRefs(
|
||||
memberCode: string,
|
||||
refs: string[] | undefined,
|
||||
): Promise<void> {
|
||||
if (!refs || refs.length === 0) return;
|
||||
try {
|
||||
await this.attribution.recordTouches(memberCode, refs);
|
||||
} catch (error) {
|
||||
this.logger.warn(`업체 링크 연결 기록 실패: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async attributeToPartner(row: MemberApplication): Promise<void> {
|
||||
try {
|
||||
await this.attribution.attributeApplication(row);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`업체 실적(신청) 귀속 실패: application=${row.id} ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 오픈카톡 버튼 클릭 — 처음 누른 시각만 남긴다. 없는 ID 도 조용히 넘어간다 */
|
||||
async markKakaoClicked(id: string): Promise<void> {
|
||||
await this.applications.update(
|
||||
@@ -125,42 +200,7 @@ 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 base = this.filtered(query);
|
||||
const total = await base.clone().getCount();
|
||||
|
||||
const page = base
|
||||
@@ -212,6 +252,12 @@ export class ApplicationsService {
|
||||
campaign: row.inflowCampaign,
|
||||
content: row.inflowContent,
|
||||
},
|
||||
lastInflow: {
|
||||
source: row.inflowLastSource,
|
||||
medium: row.inflowLastMedium,
|
||||
campaign: row.inflowLastCampaign,
|
||||
content: row.inflowLastContent,
|
||||
},
|
||||
};
|
||||
}),
|
||||
{
|
||||
@@ -228,6 +274,86 @@ export class ApplicationsService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 신청 유입 집계 — 같은 신청을 '신청 때 최종 / 최초 유입' 또는 '가입 경로'로 나눠 센다.
|
||||
* 기준을 바꿔 보면 "가입은 네이버로 했지만 이번 신청은 페이스북 광고로 왔다" 같은 차이가 보인다.
|
||||
*/
|
||||
async inflowStats(
|
||||
query: ApplicationInflowStatsQueryDto,
|
||||
): Promise<ApplicationInflowStatsResponseDto> {
|
||||
const base = this.filtered(query);
|
||||
const total = await base.clone().getCount();
|
||||
const rows = await base
|
||||
.clone()
|
||||
.select(inflowExpression(query.basis, query.groupBy), 'key')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('key')
|
||||
.orderBy('count', 'DESC')
|
||||
.addOrderBy('key', 'ASC', 'NULLS LAST')
|
||||
.limit(STATS_MAX_ITEMS)
|
||||
.getRawMany<{ key: string | null; count: string }>();
|
||||
|
||||
return {
|
||||
basis: query.basis,
|
||||
groupBy: query.groupBy,
|
||||
total,
|
||||
items: rows.map((row) => ({ key: row.key, count: Number(row.count) })),
|
||||
};
|
||||
}
|
||||
|
||||
/** 목록·집계 공통 필터 — 회원 검색·가입 경로를 위해 동기화된 회원과 잇는다 */
|
||||
private filtered(
|
||||
filter: ApplicationFilterDto &
|
||||
Partial<
|
||||
Pick<
|
||||
ApplicationListQueryDto,
|
||||
'source' | 'inflowBasis' | 'inflowField' | 'inflowValue' | 'q'
|
||||
>
|
||||
>,
|
||||
): SelectQueryBuilder<MemberApplication> {
|
||||
const qb = this.applications
|
||||
.createQueryBuilder(ALIAS)
|
||||
// 탈퇴 회원은 회원 정보 없이 남는다
|
||||
.leftJoin(
|
||||
ImwebMember,
|
||||
MEMBER_ALIAS,
|
||||
`${MEMBER_ALIAS}.member_code = ${ALIAS}.member_code`,
|
||||
);
|
||||
|
||||
if (filter.productIdx) {
|
||||
qb.andWhere(`${ALIAS}.productIdx = :productIdx`, {
|
||||
productIdx: filter.productIdx,
|
||||
});
|
||||
}
|
||||
if (filter.appliedFrom) {
|
||||
qb.andWhere(`${ALIAS}.appliedAt >= :appliedFrom`, {
|
||||
appliedFrom: filter.appliedFrom,
|
||||
});
|
||||
}
|
||||
if (filter.appliedTo) {
|
||||
qb.andWhere(`${ALIAS}.appliedAt < :appliedTo`, {
|
||||
appliedTo: filter.appliedTo,
|
||||
});
|
||||
}
|
||||
if (filter.source) {
|
||||
qb.andWhere(`${ALIAS}.inflowSource = :source`, { source: filter.source });
|
||||
}
|
||||
// 집계 막대를 눌러 거를 때 — 기준·항목·값이 모두 있어야 한다
|
||||
if (filter.inflowBasis && filter.inflowField && filter.inflowValue) {
|
||||
qb.andWhere(
|
||||
`${inflowExpression(filter.inflowBasis, filter.inflowField)} = :inflowValue`,
|
||||
{ inflowValue: filter.inflowValue },
|
||||
);
|
||||
}
|
||||
if (filter.q) {
|
||||
qb.andWhere(
|
||||
`(${MEMBER_ALIAS}.name ILIKE :q OR ${ALIAS}.member_uid ILIKE :q OR ${MEMBER_ALIAS}.email ILIKE :q OR ${MEMBER_ALIAS}.callnum ILIKE :q)`,
|
||||
{ q: `%${escapeLike(filter.q.trim())}%` },
|
||||
);
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
/** 상품별 신청 수 — 최근 신청이 있는 상품부터 */
|
||||
async products(): Promise<ApplicationProductDto[]> {
|
||||
const rows = await this.applications
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ApiPropertyOptional, IntersectionType } from '@nestjs/swagger';
|
||||
import {
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
@@ -8,12 +9,27 @@ import {
|
||||
} 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 class ApplicationListQueryDto extends CursorPaginationQueryDto {
|
||||
/**
|
||||
* 신청 유입을 어느 기록으로 볼 것인가.
|
||||
* - apply_last : 신청 때 최종 유입 — 신청 직전에 마지막으로 들어온 경로 (신청을 일으킨 유입)
|
||||
* - apply_first : 신청 때 최초 유입 — 신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)
|
||||
* - join : 신청한 회원의 가입 경로 — 가입 때 기록된 값만 (가입 후 채워진 값은 제외)
|
||||
*/
|
||||
export const APPLICATION_INFLOW_BASIS = [
|
||||
'apply_last',
|
||||
'apply_first',
|
||||
'join',
|
||||
] as const;
|
||||
export type ApplicationInflowBasis = (typeof APPLICATION_INFLOW_BASIS)[number];
|
||||
|
||||
/** 신청 목록·집계가 함께 쓰는 필터 */
|
||||
export class ApplicationFilterDto {
|
||||
@ApiPropertyOptional({ description: '상품번호', example: '52' })
|
||||
@IsOptional()
|
||||
@Matches(/^\d{1,20}$/, { message: '상품번호 형식이 올바르지 않습니다.' })
|
||||
@@ -34,9 +50,16 @@ export class ApplicationListQueryDto extends CursorPaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: '신청 종료일 형식이 올바르지 않습니다.' })
|
||||
appliedTo?: string;
|
||||
}
|
||||
|
||||
/** 관리자 신청 내역 조회 */
|
||||
export class ApplicationListQueryDto extends IntersectionType(
|
||||
CursorPaginationQueryDto,
|
||||
ApplicationFilterDto,
|
||||
) {
|
||||
@ApiPropertyOptional({
|
||||
description: '신청 순간의 유입원',
|
||||
description:
|
||||
'신청 때 최초 유입원 (inflowBasis 필터가 생기기 전부터 있던 값)',
|
||||
example: 'facebook',
|
||||
})
|
||||
@IsOptional()
|
||||
@@ -44,6 +67,34 @@ export class ApplicationListQueryDto extends CursorPaginationQueryDto {
|
||||
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
|
||||
source?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'유입 필터 기준 — apply_last(신청 때 최종) · apply_first(신청 때 최초) · join(가입 경로). inflowField·inflowValue 와 함께 쓴다',
|
||||
enum: APPLICATION_INFLOW_BASIS,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(APPLICATION_INFLOW_BASIS, {
|
||||
message: '유입 기준 값이 올바르지 않습니다.',
|
||||
})
|
||||
inflowBasis?: ApplicationInflowBasis;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '유입 필터 항목 — source · medium · campaign · content',
|
||||
enum: INFLOW_GROUP_BY,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(INFLOW_GROUP_BY, { message: '유입 항목 값이 올바르지 않습니다.' })
|
||||
inflowField?: InflowGroupBy;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '유입 필터 값 (집계 막대를 눌러 거를 때)',
|
||||
example: 'facebook',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
|
||||
inflowValue?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '검색어 — 이름·아이디·이메일·연락처 부분 일치',
|
||||
example: '홍길동',
|
||||
@@ -53,3 +104,27 @@ export class ApplicationListQueryDto extends CursorPaginationQueryDto {
|
||||
@MaxLength(SEARCH_MAX_LENGTH, { message: '검색어가 너무 깁니다.' })
|
||||
q?: string;
|
||||
}
|
||||
|
||||
/** 신청 유입 집계 — 같은 신청을 신청 때 유입 / 가입 경로로 나눠 볼 수 있다 */
|
||||
export class ApplicationInflowStatsQueryDto extends ApplicationFilterDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'집계 기준 — apply_last(신청 때 최종, 기본) · apply_first(신청 때 최초) · join(가입 경로)',
|
||||
enum: APPLICATION_INFLOW_BASIS,
|
||||
default: 'apply_last',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(APPLICATION_INFLOW_BASIS, {
|
||||
message: '유입 기준 값이 올바르지 않습니다.',
|
||||
})
|
||||
basis: ApplicationInflowBasis = 'apply_last';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '집계 항목',
|
||||
enum: INFLOW_GROUP_BY,
|
||||
default: 'source',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(INFLOW_GROUP_BY, { message: '집계 기준 값이 올바르지 않습니다.' })
|
||||
groupBy: InflowGroupBy = 'source';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import { INFLOW_GROUP_BY } from '@/modules/members/dto/member-query.dto';
|
||||
import type { InflowGroupBy } from '@/modules/members/dto/member-query.dto';
|
||||
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
|
||||
import { APPLICATION_INFLOW_BASIS } from './application-query.dto';
|
||||
import type { ApplicationInflowBasis } from './application-query.dto';
|
||||
|
||||
/** 신청 순간의 유입정보 */
|
||||
export class ApplicationInflowDto {
|
||||
@@ -51,10 +55,18 @@ export class ApplicationResponseDto {
|
||||
member!: MemberResponseDto | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '신청 순간의 유입정보',
|
||||
description:
|
||||
'신청 때 최초 유입 — 신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)',
|
||||
type: ApplicationInflowDto,
|
||||
})
|
||||
inflow!: ApplicationInflowDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'신청 때 최종 유입 — 신청 직전에 마지막으로 들어온 경로. 유입이 한 번뿐이면 최초와 같다',
|
||||
type: ApplicationInflowDto,
|
||||
})
|
||||
lastInflow!: ApplicationInflowDto;
|
||||
}
|
||||
|
||||
/** 상품별 신청 수 — 필터 칩에 쓴다 */
|
||||
@@ -71,3 +83,42 @@ export class ApplicationProductDto {
|
||||
@ApiProperty({ description: '신청 수', example: 120 })
|
||||
count!: number;
|
||||
}
|
||||
|
||||
/** 신청 유입 집계 한 줄 */
|
||||
export class ApplicationInflowStatItemDto {
|
||||
@ApiProperty({
|
||||
description: '집계 값 (값이 없으면 null)',
|
||||
nullable: true,
|
||||
example: 'facebook',
|
||||
})
|
||||
key!: string | null;
|
||||
|
||||
@ApiProperty({ description: '신청 수', example: 42 })
|
||||
count!: number;
|
||||
}
|
||||
|
||||
/** 신청 유입 집계 결과 */
|
||||
export class ApplicationInflowStatsResponseDto {
|
||||
@ApiProperty({
|
||||
description: '집계 기준',
|
||||
enum: APPLICATION_INFLOW_BASIS,
|
||||
example: 'apply_last',
|
||||
})
|
||||
basis!: ApplicationInflowBasis;
|
||||
|
||||
@ApiProperty({
|
||||
description: '집계 항목',
|
||||
enum: INFLOW_GROUP_BY,
|
||||
example: 'source',
|
||||
})
|
||||
groupBy!: InflowGroupBy;
|
||||
|
||||
@ApiProperty({ description: '필터에 맞는 전체 신청 수', example: 310 })
|
||||
total!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '신청 수 많은 순 (최대 100개, 값 없음은 key=null)',
|
||||
type: [ApplicationInflowStatItemDto],
|
||||
})
|
||||
items!: ApplicationInflowStatItemDto[];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -7,9 +9,12 @@ import {
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
import { PARTNER_REFS_MAX } from '@/modules/partners/dto/partner-touch.dto';
|
||||
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;
|
||||
|
||||
/** 신청 완료 화면이 보내는 신청 기록 */
|
||||
@@ -39,13 +44,39 @@ 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()
|
||||
@IsString()
|
||||
@MaxLength(INFLOW_RAW_MAX_LENGTH)
|
||||
inflow?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'신청 직전의 최종 유입 (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],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(PARTNER_REFS_MAX)
|
||||
@IsString({ each: true })
|
||||
@Matches(CLICK_REF_PATTERN, {
|
||||
each: true,
|
||||
message: '클릭 정보 형식이 올바르지 않습니다.',
|
||||
})
|
||||
partnerRefs?: string[];
|
||||
}
|
||||
|
||||
/** 신청 기록 결과 — 회원 정보는 돌려주지 않는다 */
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
|
||||
import { ImwebProduct } from '../entities/imweb-product.entity';
|
||||
|
||||
const SEARCH_MAX_LENGTH = 100;
|
||||
/** 아임웹 상품 상태 코드 */
|
||||
export const PRODUCT_STATUSES = ['sale', 'soldout', 'nosale'] as const;
|
||||
|
||||
/** 관리자 상품 목록 조회 */
|
||||
export class ProductListQueryDto extends CursorPaginationQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '검색어 — 상품명 부분 일치, 숫자면 상품번호도',
|
||||
example: '무료',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(SEARCH_MAX_LENGTH, { message: '검색어가 너무 깁니다.' })
|
||||
q?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '상품 상태 — sale(판매중) · soldout(품절) · nosale(숨김)',
|
||||
enum: PRODUCT_STATUSES,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(PRODUCT_STATUSES, { message: '상품 상태 값이 올바르지 않습니다.' })
|
||||
status?: (typeof PRODUCT_STATUSES)[number];
|
||||
}
|
||||
|
||||
/** 아임웹 상품 한 개 */
|
||||
export class ProductResponseDto {
|
||||
@ApiProperty({ description: '상품번호 (상품 페이지 ?idx= 값)', example: 52 })
|
||||
prodNo!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '상품명',
|
||||
example: '[무료한정] 하루 2시간 월1000 버는 AI 효자 상품 찾기',
|
||||
})
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '상품 상태 — sale(판매중) · soldout(품절) · nosale(숨김)',
|
||||
nullable: true,
|
||||
example: 'sale',
|
||||
})
|
||||
prodStatus!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'상품 종류 — normal(일반) · digital(디지털) · subscribe(이용권)',
|
||||
nullable: true,
|
||||
example: 'subscribe',
|
||||
})
|
||||
prodType!: string | null;
|
||||
|
||||
@ApiProperty({ description: '사이트에 전시 중인가', example: true })
|
||||
isDisplay!: boolean;
|
||||
|
||||
@ApiProperty({ description: '대표 이미지 주소', nullable: true })
|
||||
imageUrl!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '마지막 동기화 시각',
|
||||
example: '2026-10-05T05:00:00.000Z',
|
||||
})
|
||||
syncedAt!: string;
|
||||
|
||||
static from(this: void, product: ImwebProduct): ProductResponseDto {
|
||||
return {
|
||||
prodNo: product.prodNo,
|
||||
name: product.name,
|
||||
prodStatus: product.prodStatus,
|
||||
prodType: product.prodType,
|
||||
isDisplay: product.isDisplay,
|
||||
imageUrl: product.imageUrl,
|
||||
syncedAt: product.syncedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 상품 동기화 결과 */
|
||||
export class ProductSyncResultDto {
|
||||
@ApiProperty({ description: '받아온 상품 수', example: 24 })
|
||||
fetchedCount!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '동기화 시각',
|
||||
example: '2026-10-05T05:00:00.000Z',
|
||||
})
|
||||
syncedAt!: string;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 아임웹 상품 사본 — 업체 링크를 만들 상품을 고르고 상품명을 보여주려고 동기화해 둔다.
|
||||
*
|
||||
* 원본은 아임웹이다. 동기화 때마다 덮어쓰고, 아임웹에서 지운 상품은 삭제한다.
|
||||
* (업체 링크는 상품번호만 들고 있으므로 상품이 지워져도 링크·실적은 남는다)
|
||||
*/
|
||||
@Entity({ name: 'imweb_products' })
|
||||
export class ImwebProduct {
|
||||
/** 아임웹 상품번호 — 상품 페이지 주소의 ?idx= 값 */
|
||||
@PrimaryColumn({ name: 'prod_no', type: 'int' })
|
||||
prodNo!: number;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 200 })
|
||||
name!: string;
|
||||
|
||||
/** sale(판매중) · soldout(품절) · nosale(숨김) */
|
||||
@Index()
|
||||
@Column({ name: 'prod_status', type: 'varchar', length: 20, nullable: true })
|
||||
prodStatus!: string | null;
|
||||
|
||||
/** normal(일반) · digital(디지털) · subscribe(이용권) */
|
||||
@Column({ name: 'prod_type', type: 'varchar', length: 20, nullable: true })
|
||||
prodType!: string | null;
|
||||
|
||||
/** 사이트에 전시 중인가 */
|
||||
@Column({ name: 'is_display', type: 'boolean', default: true })
|
||||
isDisplay!: boolean;
|
||||
|
||||
/** 대표 이미지 (첫 번째) */
|
||||
@Column({ name: 'image_url', type: 'text', nullable: true })
|
||||
imageUrl!: string | null;
|
||||
|
||||
/** 아임웹 상품 추가 시각 — 목록 정렬 기준 */
|
||||
@Column({ name: 'added_at', type: 'timestamptz', nullable: true })
|
||||
addedAt!: Date | null;
|
||||
|
||||
@Column({ name: 'synced_at', type: 'timestamptz' })
|
||||
syncedAt!: Date;
|
||||
}
|
||||
@@ -80,7 +80,7 @@ describe('ImwebAuthService', () => {
|
||||
expect(url.searchParams.get('clientId')).toBe('client-id');
|
||||
expect(url.searchParams.get('siteCode')).toBe('S-site');
|
||||
expect(url.searchParams.get('scope')).toBe(
|
||||
'site-info:write member-info:read',
|
||||
'site-info:write member-info:read product:read order:read',
|
||||
);
|
||||
const state = url.searchParams.get('state');
|
||||
expect(state).toBeTruthy();
|
||||
|
||||
@@ -297,6 +297,15 @@ export class ImwebAuthService {
|
||||
/** 아임웹 호출 실패를 사용자 응답용 에러 코드로 바꾼다 */
|
||||
private toBusinessError(error: unknown): unknown {
|
||||
if (error instanceof BusinessException) return error;
|
||||
// 토큰에 그 API 권한이 없다 — 권한을 추가한 뒤 다시 연결하기 전까지 같은 결과다
|
||||
if (error instanceof ImwebHttpError && error.isForbidden()) {
|
||||
this.logger.warn(
|
||||
`[IMWEB_006] ${error.path} 권한 부족 errorCode=${error.errorCode ?? '-'}`,
|
||||
);
|
||||
return new BusinessException('IMWEB_006', {
|
||||
context: { path: error.path, errorCode: error.errorCode },
|
||||
});
|
||||
}
|
||||
if (error instanceof ImwebHttpError) {
|
||||
// 전역 필터는 5xx 의 context 를 남기지 않으므로 진단 정보는 여기서 남긴다 (토큰·본문 제외)
|
||||
this.logger.warn(
|
||||
|
||||
@@ -9,6 +9,7 @@ const MAX_RETRIES = 3;
|
||||
const RETRY_BASE_DELAY_MS = 1_000;
|
||||
const HTTP_BAD_REQUEST = 400;
|
||||
const HTTP_UNAUTHORIZED = 401;
|
||||
const HTTP_FORBIDDEN = 403;
|
||||
const HTTP_TOO_MANY_REQUESTS = 429;
|
||||
const HTTP_SERVER_ERROR = 500;
|
||||
|
||||
@@ -35,6 +36,11 @@ export class ImwebHttpError extends Error {
|
||||
return this.status === HTTP_UNAUTHORIZED;
|
||||
}
|
||||
|
||||
/** 토큰에 이 API 권한(scope)이 없다 */
|
||||
isForbidden(): boolean {
|
||||
return this.status === HTTP_FORBIDDEN;
|
||||
}
|
||||
|
||||
/** 요청 자체가 거부됨 — 다시 시도해도 같은 결과 */
|
||||
isClientError(): boolean {
|
||||
return this.status >= HTTP_BAD_REQUEST && this.status < HTTP_SERVER_ERROR;
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Not, Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
ProductListQueryDto,
|
||||
ProductResponseDto,
|
||||
ProductSyncResultDto,
|
||||
} from './dto/product.dto';
|
||||
import { ImwebProduct } from './entities/imweb-product.entity';
|
||||
import { IMWEB_PRODUCTS_PATH } from './imweb.config';
|
||||
import { ImwebAuthService } from './imweb-auth.service';
|
||||
import { ImwebProductData, ImwebProductPageData } from './imweb.types';
|
||||
|
||||
const ALIAS = 'p';
|
||||
/** 상품 목록 한 번에 받는 개수 */
|
||||
const PRODUCT_PAGE_SIZE = 100;
|
||||
/** 응답이 이상해도 무한 루프에 빠지지 않도록 */
|
||||
const MAX_PAGES = 1_000;
|
||||
/** 엔티티 name 길이와 맞춘다 */
|
||||
const PRODUCT_NAME_MAX_LENGTH = 200;
|
||||
|
||||
function escapeLike(value: string): string {
|
||||
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 목록 응답 — 문서(openapi.json)는 data 를 '페이지 객체의 배열'로 적어 두었지만
|
||||
* 다른 목록 API 는 모두 페이지 객체 하나다. 어느 쪽이 와도 읽는다.
|
||||
*/
|
||||
function readPage(
|
||||
data: ImwebProductPageData | ImwebProductPageData[],
|
||||
): ImwebProductPageData | null {
|
||||
const page = Array.isArray(data) ? data[0] : data;
|
||||
return page && Array.isArray(page.list) ? page : null;
|
||||
}
|
||||
|
||||
function toDate(value: string | null | undefined): Date | null {
|
||||
if (!value) return null;
|
||||
const time = Date.parse(value);
|
||||
return Number.isNaN(time) ? null : new Date(time);
|
||||
}
|
||||
|
||||
function toRow(product: ImwebProductData, syncedAt: Date): ImwebProduct | null {
|
||||
if (!Number.isInteger(product.prodNo)) return null;
|
||||
return {
|
||||
prodNo: product.prodNo,
|
||||
name:
|
||||
(product.name ?? '').trim().slice(0, PRODUCT_NAME_MAX_LENGTH) ||
|
||||
`상품 ${product.prodNo}`,
|
||||
prodStatus: product.prodStatus ?? null,
|
||||
prodType: product.prodType ?? null,
|
||||
isDisplay: product.isDisplay !== 'N',
|
||||
imageUrl: product.productImages?.find(Boolean) ?? null,
|
||||
addedAt: toDate(product.addTime),
|
||||
syncedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** 아임웹 상품 → 우리 DB 동기화와 관리자 조회 */
|
||||
@Injectable()
|
||||
export class ImwebProductService {
|
||||
private readonly logger = new Logger(ImwebProductService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ImwebProduct)
|
||||
private readonly products: Repository<ImwebProduct>,
|
||||
private readonly auth: ImwebAuthService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 아임웹 상품 전체를 받아 맞춘다. 아임웹에서 지운 상품은 여기서도 지운다.
|
||||
* 상품 읽기 권한이 없으면 IMWEB_006.
|
||||
*/
|
||||
async sync(): Promise<ProductSyncResultDto> {
|
||||
const syncedAt = new Date();
|
||||
const { unitCode } = await this.auth.requireConnection();
|
||||
const rows: ImwebProduct[] = [];
|
||||
|
||||
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
||||
const { data } = await this.auth.authorizedGet<
|
||||
ImwebProductPageData | ImwebProductPageData[]
|
||||
>(IMWEB_PRODUCTS_PATH, { page, limit: PRODUCT_PAGE_SIZE, unitCode });
|
||||
const current = readPage(data);
|
||||
if (!current) {
|
||||
throw new BusinessException('IMWEB_002', {
|
||||
context: { reason: 'unexpected_products_response', page },
|
||||
});
|
||||
}
|
||||
for (const product of current.list) {
|
||||
const row = toRow(product, syncedAt);
|
||||
if (row) rows.push(row);
|
||||
}
|
||||
if (current.list.length === 0 || page >= current.totalPage) break;
|
||||
}
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.products.upsert(rows, { conflictPaths: ['prodNo'] });
|
||||
// 받은 목록이 비었으면 응답 이상으로 보고 지우지 않는다
|
||||
await this.products.delete({
|
||||
prodNo: Not(In(rows.map((row) => row.prodNo))),
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.log(`상품 동기화 완료: ${rows.length}개`);
|
||||
return { fetchedCount: rows.length, syncedAt: syncedAt.toISOString() };
|
||||
}
|
||||
|
||||
/** 관리자 상품 목록 — 최근 추가된 상품부터 (상품번호 내림차순 커서) */
|
||||
async findMany(
|
||||
query: ProductListQueryDto,
|
||||
): Promise<PaginatedResult<ProductResponseDto>> {
|
||||
const base = this.products.createQueryBuilder(ALIAS);
|
||||
|
||||
if (query.status) {
|
||||
base.andWhere(`${ALIAS}.prodStatus = :status`, { status: query.status });
|
||||
}
|
||||
if (query.q) {
|
||||
const q = query.q.trim();
|
||||
// 숫자만 입력하면 상품번호로도 찾는다
|
||||
base.andWhere(
|
||||
/^\d+$/.test(q)
|
||||
? `(${ALIAS}.name ILIKE :like OR ${ALIAS}.prodNo = :prodNo)`
|
||||
: `${ALIAS}.name ILIKE :like`,
|
||||
{ like: `%${escapeLike(q)}%`, prodNo: Number(q) },
|
||||
);
|
||||
}
|
||||
|
||||
const total = await base.clone().getCount();
|
||||
const page = base
|
||||
.clone()
|
||||
.orderBy(`${ALIAS}.prodNo`, 'DESC')
|
||||
.take(query.limit + 1);
|
||||
|
||||
if (query.cursor) {
|
||||
const cursor = Number(query.cursor);
|
||||
if (!Number.isInteger(cursor) || cursor <= 0) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'invalid_cursor' },
|
||||
});
|
||||
}
|
||||
page.andWhere(`${ALIAS}.prodNo < :cursor`, { cursor });
|
||||
}
|
||||
|
||||
const rows = await page.getMany();
|
||||
const hasNext = rows.length > query.limit;
|
||||
const items = rows.slice(0, query.limit);
|
||||
|
||||
return toPaginatedResult(items.map(ProductResponseDto.from), {
|
||||
hasNext,
|
||||
nextCursor: hasNext ? String(items.at(-1)?.prodNo) : null,
|
||||
total,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
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 {
|
||||
ProductListQueryDto,
|
||||
ProductResponseDto,
|
||||
ProductSyncResultDto,
|
||||
} from './dto/product.dto';
|
||||
import { ImwebProductService } from './imweb-product.service';
|
||||
|
||||
/** 관리자 상품 조회 — 업체 링크를 만들 상품을 고른다 */
|
||||
@ApiTags('Admin Products')
|
||||
@ApiBearerAuth(ADMIN_BEARER_AUTH)
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Controller('admin/products')
|
||||
export class ImwebProductsController {
|
||||
constructor(private readonly products: ImwebProductService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '상품 목록 (상품번호 내림차순, 커서 페이지네이션)' })
|
||||
@ApiStandardResponse(ProductResponseDto, {
|
||||
description: '조회 성공 — data 는 { items, pageInfo }',
|
||||
isArray: true,
|
||||
})
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('VAL_001', '필터·커서 형식 오류')
|
||||
findMany(
|
||||
@Query() query: ProductListQueryDto,
|
||||
): Promise<PaginatedResult<ProductResponseDto>> {
|
||||
return this.products.findMany(query);
|
||||
}
|
||||
|
||||
@Post('sync-runs')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'아임웹 상품 불러오기 — 끝날 때까지 기다린다 (상품 수가 적어 빠르다)',
|
||||
})
|
||||
@ApiStandardResponse(ProductSyncResultDto, { description: '동기화 완료' })
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('IMWEB_001', '아임웹 미연결')
|
||||
@ApiErrorResponse('IMWEB_006', '상품 읽기 권한 없음 — 아임웹 재연결 필요')
|
||||
sync(): Promise<ProductSyncResultDto> {
|
||||
return this.products.sync();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from './entities/imweb-sync-run.entity';
|
||||
import { ImwebConfig } from './imweb.config';
|
||||
import { ImwebAuthService } from './imweb-auth.service';
|
||||
import { ImwebProductService } from './imweb-product.service';
|
||||
import { ImwebSyncService } from './imweb-sync.service';
|
||||
import { ImwebMemberData, ImwebMemberPageData } from './imweb.types';
|
||||
|
||||
@@ -97,6 +98,11 @@ function setup(options: { previousSucceededRunId?: number } = {}) {
|
||||
),
|
||||
};
|
||||
const config = { pageSize: 2, trackingHost: HOST };
|
||||
const products = {
|
||||
sync: jest.fn(() =>
|
||||
Promise.resolve({ fetchedCount: 0, syncedAt: new Date().toISOString() }),
|
||||
),
|
||||
};
|
||||
|
||||
const service = new ImwebSyncService(
|
||||
members as unknown as Repository<ImwebMember>,
|
||||
@@ -106,6 +112,7 @@ function setup(options: { previousSucceededRunId?: number } = {}) {
|
||||
auth as unknown as ImwebAuthService,
|
||||
config as unknown as ImwebConfig,
|
||||
{} as SchedulerRegistry,
|
||||
products as unknown as ImwebProductService,
|
||||
);
|
||||
const finalRun = () => savedRuns.at(-1);
|
||||
return {
|
||||
@@ -115,6 +122,7 @@ function setup(options: { previousSucceededRunId?: number } = {}) {
|
||||
imports,
|
||||
groups,
|
||||
auth,
|
||||
products,
|
||||
memberPages,
|
||||
finalRun,
|
||||
};
|
||||
@@ -143,6 +151,21 @@ describe('ImwebSyncService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('상품 동기화가 실패해도(권한 부족 등) 회원 동기화는 성공한다', async () => {
|
||||
const { service, products, memberPages, finalRun } = setup();
|
||||
products.sync.mockRejectedValueOnce(new BusinessException('IMWEB_006'));
|
||||
memberPages.mockResolvedValueOnce({ data: page([member('m1')], 1, 1) });
|
||||
|
||||
await service.start(SyncTrigger.MANUAL);
|
||||
await service.waitForIdle();
|
||||
|
||||
expect(products.sync).toHaveBeenCalledTimes(1);
|
||||
expect(finalRun()).toMatchObject({
|
||||
status: SyncStatus.SUCCEEDED,
|
||||
fetchedCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('직전 성공 실행보다 오래 못 본 회원만 지운다 (두 번 연속 미확인)', async () => {
|
||||
const { service, members, memberPages, finalRun } = setup({
|
||||
previousSucceededRunId: 9,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ImwebConfig,
|
||||
} from './imweb.config';
|
||||
import { ImwebAuthService } from './imweb-auth.service';
|
||||
import { ImwebProductService } from './imweb-product.service';
|
||||
import { ImwebGroupPageData, ImwebMemberPageData } from './imweb.types';
|
||||
import { toMemberRow } from './member-row.mapper';
|
||||
|
||||
@@ -72,6 +73,7 @@ export class ImwebSyncService
|
||||
private readonly auth: ImwebAuthService,
|
||||
private readonly config: ImwebConfig,
|
||||
private readonly scheduler: SchedulerRegistry,
|
||||
private readonly products: ImwebProductService,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
@@ -169,6 +171,7 @@ export class ImwebSyncService
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await this.syncGroups();
|
||||
await this.syncProducts();
|
||||
const fetched = await this.fetchAll(run);
|
||||
run.fetchedCount = fetched;
|
||||
run.removedCount = await this.removeDeparted(run, fetched);
|
||||
@@ -228,6 +231,20 @@ export class ImwebSyncService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 목록도 같은 주기로 맞춘다. 실패해도 회원 동기화는 계속한다 —
|
||||
* 상품 읽기 권한을 추가한 뒤 아직 다시 연결하지 않은 동안(IMWEB_006)에도 회원은 받아야 한다.
|
||||
*/
|
||||
private async syncProducts(): Promise<void> {
|
||||
try {
|
||||
await this.products.sync();
|
||||
} catch (error) {
|
||||
const code =
|
||||
error instanceof BusinessException ? error.code : FALLBACK_ERROR_CODE;
|
||||
this.logger.warn(`[${code}] 상품 동기화 건너뜀 — 회원 동기화는 계속한다`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 아임웹 회원 전체를 페이지 단위로 받아 저장한다. 받은 회원 수를 돌려준다. */
|
||||
private async fetchAll(run: ImwebSyncRun): Promise<number> {
|
||||
const syncedAt = new Date();
|
||||
|
||||
@@ -18,20 +18,28 @@ export const IMWEB_INTEGRATION_COMPLETE_PATH =
|
||||
export const IMWEB_MEMBERS_PATH = '/member-info/members';
|
||||
/** 회원 그룹 목록 — 운영진 그룹(/member-info/admin/groups)은 다루지 않는다 */
|
||||
export const IMWEB_GROUPS_PATH = '/member-info/groups';
|
||||
/** 상품 목록 — 업체 링크를 만들 상품을 고르는 데 쓴다 */
|
||||
export const IMWEB_PRODUCTS_PATH = '/products';
|
||||
/** 주문 목록 — 업체 링크로 들어온 회원의 구매를 집계한다 */
|
||||
export const IMWEB_ORDERS_PATH = '/orders';
|
||||
|
||||
/**
|
||||
* 요청 권한 — 아임웹 개발자센터 앱 설정의 권한과 일치해야 한다.
|
||||
* 회원 정보는 읽기만 받는다. site-info:write 는 아임웹이 인가 때 필수로 요구하고(errorCode 30156)
|
||||
* 앱 설정에 없는 권한을 요청하면 인가가 거부된다(errorCode 30156).
|
||||
* 회원·상품·주문은 읽기만 받는다. site-info:write 는 아임웹이 인가 때 필수로 요구하고
|
||||
* 연동완료 처리에만 쓴다. (write 는 읽기를 포함한다)
|
||||
*
|
||||
* ⚠️ 권한을 바꾸면 관리자 화면에서 '아임웹 연결'을 다시 해야 새 권한이 담긴 토큰을 받는다.
|
||||
*/
|
||||
export const IMWEB_SCOPES = 'site-info:write member-info:read';
|
||||
export const IMWEB_SCOPES =
|
||||
'site-info:write member-info:read product:read order:read';
|
||||
|
||||
/** OAuth callback 경로 (BACKEND_API_URL 뒤에 붙는다) */
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -68,6 +76,21 @@ export class ImwebConfig {
|
||||
return this.read('IMWEB_ADMIN_PAGE_URL');
|
||||
}
|
||||
|
||||
/**
|
||||
* 아임웹 사이트 주소 (origin) — 업체 링크의 이동 주소는 이 사이트 안만 허용한다.
|
||||
* 관리자 화면이 아임웹 사이트 안에 있으므로 따로 주지 않으면 IMWEB_ADMIN_PAGE_URL 의 origin 을 쓴다.
|
||||
* 설정이 없거나 주소 형식이 아니면 빈 문자열.
|
||||
*/
|
||||
get siteUrl(): string {
|
||||
const raw = this.read('IMWEB_SITE_URL') || this.adminPageUrl;
|
||||
if (!raw) return '';
|
||||
try {
|
||||
return new URL(raw).origin;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
get trackingHost(): string {
|
||||
return this.read('IMWEB_TRACKING_HOST') || DEFAULT_TRACKING_HOST;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ImwebConnection } from './entities/imweb-connection.entity';
|
||||
import { ImwebGroup } from './entities/imweb-group.entity';
|
||||
import { ImwebMember } from './entities/imweb-member.entity';
|
||||
import { ImwebMemberInflowImport } from './entities/imweb-member-inflow-import.entity';
|
||||
import { ImwebProduct } from './entities/imweb-product.entity';
|
||||
import { ImwebSyncRun } from './entities/imweb-sync-run.entity';
|
||||
import { ImwebAdminController } from './imweb-admin.controller';
|
||||
import { ImwebAdminService } from './imweb-admin.service';
|
||||
@@ -14,9 +15,11 @@ import { ImwebConfig } from './imweb.config';
|
||||
import { ImwebHttpClient } from './imweb-http.client';
|
||||
import { ImwebMemberLookupService } from './imweb-member-lookup.service';
|
||||
import { ImwebOAuthController } from './imweb-oauth.controller';
|
||||
import { ImwebProductService } from './imweb-product.service';
|
||||
import { ImwebProductsController } from './imweb-products.controller';
|
||||
import { ImwebSyncService } from './imweb-sync.service';
|
||||
|
||||
/** 아임웹 Open API 연동 — OAuth 연결, 토큰 관리, 회원 동기화 */
|
||||
/** 아임웹 Open API 연동 — OAuth 연결, 토큰 관리, 회원·상품 동기화 */
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
@@ -24,11 +27,16 @@ import { ImwebSyncService } from './imweb-sync.service';
|
||||
ImwebGroup,
|
||||
ImwebMember,
|
||||
ImwebMemberInflowImport,
|
||||
ImwebProduct,
|
||||
ImwebSyncRun,
|
||||
]),
|
||||
AdminAuthModule,
|
||||
],
|
||||
controllers: [ImwebAdminController, ImwebOAuthController],
|
||||
controllers: [
|
||||
ImwebAdminController,
|
||||
ImwebOAuthController,
|
||||
ImwebProductsController,
|
||||
],
|
||||
providers: [
|
||||
ImwebConfig,
|
||||
ImwebHttpClient,
|
||||
@@ -36,7 +44,9 @@ import { ImwebSyncService } from './imweb-sync.service';
|
||||
ImwebSyncService,
|
||||
ImwebAdminService,
|
||||
ImwebMemberLookupService,
|
||||
ImwebProductService,
|
||||
],
|
||||
exports: [ImwebConfig, ImwebMemberLookupService],
|
||||
// 업체 링크 모듈이 주문 조회(ImwebAuthService)와 회원 확인(ImwebMemberLookupService)을 쓴다
|
||||
exports: [ImwebConfig, ImwebAuthService, ImwebMemberLookupService],
|
||||
})
|
||||
export class ImwebModule {}
|
||||
|
||||
@@ -65,6 +65,65 @@ export interface ImwebMemberPageData {
|
||||
list: ImwebMemberData[];
|
||||
}
|
||||
|
||||
/** GET /products — ReadShopProductResponseDto (쓰는 필드만) */
|
||||
export interface ImwebProductData {
|
||||
prodNo: number;
|
||||
name: string;
|
||||
/** sale(판매중) · soldout(품절) · nosale(숨김) */
|
||||
prodStatus?: string | null;
|
||||
/** normal(일반) · digital(디지털) · subscribe(이용권) */
|
||||
prodType?: string | null;
|
||||
/** 유닛별 전시 여부 Y/N */
|
||||
isDisplay?: string | null;
|
||||
productImages?: string[] | null;
|
||||
addTime?: string | null;
|
||||
editTime?: string | null;
|
||||
}
|
||||
|
||||
/** GET /products — ReadAllShopProductsResponseDto (페이지 방식) */
|
||||
export interface ImwebProductPageData {
|
||||
totalCount: number;
|
||||
totalPage: number;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
list: ImwebProductData[];
|
||||
}
|
||||
|
||||
/** GET /orders — OrderSectionItemDataDto (쓰는 필드만) */
|
||||
export interface ImwebOrderSectionItemData {
|
||||
orderSectionItemNo?: string;
|
||||
/** 사은품 여부 Y/N */
|
||||
isPromotion?: string | null;
|
||||
productInfo?: { prodNo?: number | null; prodName?: string | null } | null;
|
||||
}
|
||||
|
||||
/** GET /orders — OrderDataDto (쓰는 필드만) */
|
||||
export interface ImwebOrderData {
|
||||
orderNo: number;
|
||||
isMember?: string | null;
|
||||
memberCode?: string | null;
|
||||
memberUid?: string | null;
|
||||
/** 주문 생성 시각 (UTC) */
|
||||
wtime: string;
|
||||
payments?: {
|
||||
paymentStatus?: string | null;
|
||||
isCancel?: string | null;
|
||||
}[];
|
||||
sections?: {
|
||||
orderSectionStatus?: string | null;
|
||||
sectionItems?: ImwebOrderSectionItemData[];
|
||||
}[];
|
||||
}
|
||||
|
||||
/** GET /orders — ReadAllOrderResponseDto (페이지 방식) */
|
||||
export interface ImwebOrderPageData {
|
||||
totalCount: number;
|
||||
totalPage: number;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
list: ImwebOrderData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 아임웹 에러 응답 — 문서의 Error List (statusCode / errorCode / message).
|
||||
* 본문 구조는 문서에 정의돼 있지 않아 모든 필드를 선택으로 둔다.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
|
||||
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
|
||||
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
|
||||
import { ConversionType } from '../entities/partner-conversion.entity';
|
||||
|
||||
/** 링크 실적 상세 조회 */
|
||||
export class ConversionListQueryDto extends CursorPaginationQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '실적 종류 — application(신청) · purchase(구매)',
|
||||
enum: ConversionType,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(ConversionType, { message: '실적 종류 값이 올바르지 않습니다.' })
|
||||
type?: ConversionType;
|
||||
}
|
||||
|
||||
/** 실적 한 건 — 어느 회원이 언제 신청·구매했는가 */
|
||||
export class ConversionResponseDto {
|
||||
@ApiProperty({ description: '실적 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '실적 종류',
|
||||
enum: ConversionType,
|
||||
example: ConversionType.APPLICATION,
|
||||
})
|
||||
type!: ConversionType;
|
||||
|
||||
@ApiProperty({
|
||||
description: '신청·주문 시각',
|
||||
example: '2026-10-05T05:00:00.000Z',
|
||||
})
|
||||
convertedAt!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '귀속한 링크 클릭 시각',
|
||||
example: '2026-10-04T12:00:00.000Z',
|
||||
})
|
||||
touchedAt!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '아임웹 주문번호 (신청이면 null)',
|
||||
nullable: true,
|
||||
example: '202610050001234',
|
||||
})
|
||||
orderNo!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '집계에 들어가는가 — 구매가 환불·취소되면 false',
|
||||
example: true,
|
||||
})
|
||||
isValid!: boolean;
|
||||
|
||||
@ApiProperty({ description: '아임웹 회원 코드' })
|
||||
memberCode!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '회원 정보 (동기화된 회원 기준) — 탈퇴 등으로 없으면 null',
|
||||
type: MemberResponseDto,
|
||||
nullable: true,
|
||||
})
|
||||
member!: MemberResponseDto | null;
|
||||
}
|
||||
|
||||
/** 주문 실적 새로고침 상태 (서버 메모리 기준 — 재시작하면 비워진다) */
|
||||
export class OrderSyncStatusDto {
|
||||
@ApiProperty({ description: '진행 중인가', example: false })
|
||||
isRunning!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '마지막 시작 시각',
|
||||
nullable: true,
|
||||
example: '2026-10-05T05:00:00.000Z',
|
||||
})
|
||||
lastStartedAt!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '마지막 종료 시각',
|
||||
nullable: true,
|
||||
example: '2026-10-05T05:00:20.000Z',
|
||||
})
|
||||
lastFinishedAt!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '마지막 실패 에러 코드 (성공이면 null)',
|
||||
nullable: true,
|
||||
example: null,
|
||||
})
|
||||
lastErrorCode!: string | null;
|
||||
|
||||
@ApiProperty({ description: '마지막 실행에서 확인한 주문 수', example: 120 })
|
||||
checkedOrderCount!: number;
|
||||
|
||||
@ApiProperty({ description: '마지막 실행에서 새로 잡은 구매 수', example: 2 })
|
||||
createdCount!: number;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
import { PartnerPeriodQueryDto, PartnerStatsDto } from './partner.dto';
|
||||
|
||||
const TARGET_URL_MAX_LENGTH = 2000;
|
||||
|
||||
export class CreatePartnerLinkDto {
|
||||
@ApiProperty({ description: '업체 ID' })
|
||||
@IsUUID('4', { message: '업체를 선택해 주세요.' })
|
||||
partnerId!: string;
|
||||
|
||||
@ApiProperty({ description: '아임웹 상품번호', example: 52 })
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: '상품을 선택해 주세요.' })
|
||||
@Min(1, { message: '상품을 선택해 주세요.' })
|
||||
prodNo!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '이동할 상품 페이지 주소 — 아임웹 사이트 안의 주소만',
|
||||
example: 'https://www.moneyguider.co.kr/free-shop/?idx=52',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '이동 주소를 입력해 주세요.' })
|
||||
@MaxLength(TARGET_URL_MAX_LENGTH, { message: '이동 주소가 너무 깁니다.' })
|
||||
targetUrl!: string;
|
||||
}
|
||||
|
||||
export class UpdatePartnerLinkDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '이동할 상품 페이지 주소',
|
||||
example: 'https://www.moneyguider.co.kr/free-shop/?idx=52',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '이동 주소를 입력해 주세요.' })
|
||||
@MaxLength(TARGET_URL_MAX_LENGTH, { message: '이동 주소가 너무 깁니다.' })
|
||||
targetUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'사용 여부 — 중지하면 이동은 되지만 클릭·실적을 새로 남기지 않는다',
|
||||
example: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: '사용 여부 값이 올바르지 않습니다.' })
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class PartnerLinkListQueryDto extends PartnerPeriodQueryDto {
|
||||
@ApiPropertyOptional({ description: '업체 ID' })
|
||||
@IsOptional()
|
||||
@IsUUID('4', { message: '업체 ID 형식이 올바르지 않습니다.' })
|
||||
partnerId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '아임웹 상품번호', example: 52 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: '상품번호 형식이 올바르지 않습니다.' })
|
||||
@Min(1, { message: '상품번호 형식이 올바르지 않습니다.' })
|
||||
prodNo?: number;
|
||||
}
|
||||
|
||||
export class PartnerLinkPartnerDto {
|
||||
@ApiProperty({ description: '업체 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '업체 이름', example: '머니튜브' })
|
||||
name!: string;
|
||||
}
|
||||
|
||||
export class PartnerLinkProductDto {
|
||||
@ApiProperty({ description: '아임웹 상품번호', example: 52 })
|
||||
prodNo!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '상품명 — 아임웹에서 지워진 상품이면 null',
|
||||
nullable: true,
|
||||
})
|
||||
name!: string | null;
|
||||
}
|
||||
|
||||
/** 업체 링크 한 개 */
|
||||
export class PartnerLinkResponseDto {
|
||||
@ApiProperty({ description: '링크 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '단축 링크 코드', example: 'Ab3kX9pQ' })
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '업체에 전달할 단축 링크',
|
||||
example: 'https://moneyguide.wandergo.net/r/Ab3kX9pQ',
|
||||
})
|
||||
shortUrl!: string;
|
||||
|
||||
@ApiProperty({ description: '업체', type: PartnerLinkPartnerDto })
|
||||
partner!: PartnerLinkPartnerDto;
|
||||
|
||||
@ApiProperty({ description: '상품', type: PartnerLinkProductDto })
|
||||
product!: PartnerLinkProductDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: '이동할 상품 페이지 주소',
|
||||
example: 'https://www.moneyguider.co.kr/free-shop/?idx=52',
|
||||
})
|
||||
targetUrl!: string;
|
||||
|
||||
@ApiProperty({ description: '사용 여부', example: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@ApiProperty({ description: '기간 내 실적', type: PartnerStatsDto })
|
||||
stats!: PartnerStatsDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: '발급 시각',
|
||||
example: '2026-10-05T05:00:00.000Z',
|
||||
})
|
||||
createdAt!: string;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
import { CLICK_REF_PATTERN } from '@/shared/utils/partner-link';
|
||||
|
||||
const MEMBER_ID_MAX_LENGTH = 255;
|
||||
/** 한 번에 받는 클릭 ID 수 — 브라우저는 최근 30일 것만 보관한다 */
|
||||
export const PARTNER_REFS_MAX = 20;
|
||||
|
||||
/** 아임웹 Header Code 스크립트가 보내는 회원 ↔ 링크 클릭 연결 */
|
||||
export class CreatePartnerTouchDto {
|
||||
@ApiProperty({
|
||||
description: '아임웹 회원 아이디 또는 회원 코드 (window.MEMBER_UID)',
|
||||
example: 'user@example.com',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '회원 정보가 없습니다.' })
|
||||
@MaxLength(MEMBER_ID_MAX_LENGTH)
|
||||
memberUid!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '브라우저가 보관한 업체 링크 클릭 ID (mg_ref) 목록',
|
||||
example: ['AbCd1234EfGh5678'],
|
||||
type: [String],
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMaxSize(PARTNER_REFS_MAX)
|
||||
@IsString({ each: true })
|
||||
@Matches(CLICK_REF_PATTERN, {
|
||||
each: true,
|
||||
message: '클릭 정보 형식이 올바르지 않습니다.',
|
||||
})
|
||||
refs!: string[];
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import {
|
||||
IsDateString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
|
||||
import { Partner } from '../entities/partner.entity';
|
||||
|
||||
const PARTNER_NAME_MAX_LENGTH = 100;
|
||||
const PARTNER_MEMO_MAX_LENGTH = 1000;
|
||||
|
||||
/** 실적 집계 기간 — 목록의 클릭·신청·구매 수를 이 기간으로 센다 (없으면 전체) */
|
||||
export class PartnerPeriodQueryDto extends CursorPaginationQueryDto {
|
||||
@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 CreatePartnerDto {
|
||||
@ApiProperty({ description: '업체 이름', example: '머니튜브' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '업체 이름을 입력해 주세요.' })
|
||||
@MaxLength(PARTNER_NAME_MAX_LENGTH, { message: '업체 이름이 너무 깁니다.' })
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '관리자 메모 (담당자·정산 조건 등)',
|
||||
example: '담당 김OO / 신청 1건당 정산',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(PARTNER_MEMO_MAX_LENGTH, { message: '메모가 너무 깁니다.' })
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
export class UpdatePartnerDto extends PartialType(CreatePartnerDto) {}
|
||||
|
||||
/** 업체·링크의 실적 */
|
||||
export class PartnerStatsDto {
|
||||
@ApiProperty({ description: '링크 클릭 수 (사람만)', example: 320 })
|
||||
clickCount!: number;
|
||||
|
||||
@ApiProperty({ description: '신청 수', example: 41 })
|
||||
applicationCount!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '구매 수 — 결제완료 주문 (환불·취소 제외)',
|
||||
example: 7,
|
||||
})
|
||||
purchaseCount!: number;
|
||||
}
|
||||
|
||||
/** 업체 한 곳 */
|
||||
export class PartnerResponseDto {
|
||||
@ApiProperty({ description: '업체 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '업체 이름', example: '머니튜브' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: '관리자 메모', nullable: true })
|
||||
memo!: string | null;
|
||||
|
||||
@ApiProperty({ description: '발급된 링크 수', example: 3 })
|
||||
linkCount!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '기간 내 실적 (모든 링크 합계)',
|
||||
type: PartnerStatsDto,
|
||||
})
|
||||
stats!: PartnerStatsDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: '등록 시각',
|
||||
example: '2026-10-05T05:00:00.000Z',
|
||||
})
|
||||
createdAt!: string;
|
||||
|
||||
static from(
|
||||
partner: Partner,
|
||||
linkCount = 0,
|
||||
stats: PartnerStatsDto = {
|
||||
clickCount: 0,
|
||||
applicationCount: 0,
|
||||
purchaseCount: 0,
|
||||
},
|
||||
): PartnerResponseDto {
|
||||
return {
|
||||
id: partner.id,
|
||||
name: partner.name,
|
||||
memo: partner.memo,
|
||||
linkCount,
|
||||
stats,
|
||||
createdAt: partner.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { PartnerLink } from './partner-link.entity';
|
||||
|
||||
export enum ConversionType {
|
||||
/** 무료 강의 신청 — 신청 완료 화면이 보낸 신청 기록 */
|
||||
APPLICATION = 'application',
|
||||
/** 결제완료 주문 — 아임웹 주문 동기화가 찾는다 */
|
||||
PURCHASE = 'purchase',
|
||||
}
|
||||
|
||||
/**
|
||||
* 업체 실적 — 업체 링크로 들어온 회원의 신청·구매 한 건.
|
||||
*
|
||||
* 신청은 신청 기록당 하나, 구매는 주문·상품당 하나다.
|
||||
* 구매가 나중에 전액 환불·취소되면 지우지 않고 isValid=false 로 바꿔 집계에서 뺀다.
|
||||
* 금액은 관리하지 않는다 (건수만 센다).
|
||||
*/
|
||||
@Entity({ name: 'partner_conversions' })
|
||||
@Index('uq_partner_conversions_application', ['applicationId'], {
|
||||
unique: true,
|
||||
})
|
||||
@Index('uq_partner_conversions_order_product', ['orderNo', 'prodNo'], {
|
||||
unique: true,
|
||||
})
|
||||
@Index('idx_partner_conversions_link_time', ['linkId', 'convertedAt'])
|
||||
@Index('idx_partner_conversions_partner_time', ['partnerId', 'convertedAt'])
|
||||
@Index('idx_partner_conversions_cursor', ['convertedAt', 'id'])
|
||||
export class PartnerConversion {
|
||||
/** 앱에서 crypto.randomUUID() 로 만든다 */
|
||||
@PrimaryColumn({ name: 'id', type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'type', type: 'varchar', length: 20 })
|
||||
type!: ConversionType;
|
||||
|
||||
@Column({ name: 'link_id', type: 'uuid' })
|
||||
linkId!: string;
|
||||
|
||||
@ManyToOne(() => PartnerLink, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'link_id' })
|
||||
link?: PartnerLink;
|
||||
|
||||
/** 링크의 업체 — 업체별 집계용으로 함께 둔다 */
|
||||
@Column({ name: 'partner_id', type: 'uuid' })
|
||||
partnerId!: string;
|
||||
|
||||
@Column({ name: 'prod_no', type: 'int' })
|
||||
prodNo!: number;
|
||||
|
||||
@Column({ name: 'member_code', type: 'varchar', length: 40 })
|
||||
memberCode!: string;
|
||||
|
||||
/** 신청 기록 ID (구매면 null) */
|
||||
@Column({ name: 'application_id', type: 'uuid', nullable: true })
|
||||
applicationId!: string | null;
|
||||
|
||||
/** 아임웹 주문번호 (신청이면 null) */
|
||||
@Column({ name: 'order_no', type: 'varchar', length: 30, nullable: true })
|
||||
orderNo!: string | null;
|
||||
|
||||
/** 집계에 넣는가 — 구매가 전액 환불·취소되면 false */
|
||||
@Column({ name: 'is_valid', type: 'boolean', default: true })
|
||||
isValid!: boolean;
|
||||
|
||||
/** 귀속한 클릭의 시각 */
|
||||
@Column({ name: 'touched_at', type: 'timestamptz' })
|
||||
touchedAt!: Date;
|
||||
|
||||
/** 신청 시각 또는 주문 시각 */
|
||||
@Column({ name: 'converted_at', type: 'timestamptz' })
|
||||
convertedAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { PartnerLink } from './partner-link.entity';
|
||||
|
||||
/**
|
||||
* 업체 링크 클릭 — 사람이 누른 것만 남긴다 (링크 미리보기·검색봇 제외).
|
||||
*
|
||||
* ref 는 상품 페이지 주소에 mg_ref 로 붙여 보내는 클릭 ID 다.
|
||||
* 회원과 클릭을 이을 때 클릭 시각을 브라우저가 아니라 이 기록에서 가져온다.
|
||||
* IP·브라우저 정보는 남기지 않는다.
|
||||
*/
|
||||
@Entity({ name: 'partner_link_clicks' })
|
||||
@Index('idx_partner_link_clicks_link_time', ['linkId', 'clickedAt'])
|
||||
export class PartnerLinkClick {
|
||||
/** 클릭 ID (영숫자 16자) */
|
||||
@PrimaryColumn({ name: 'ref', type: 'varchar', length: 16 })
|
||||
ref!: string;
|
||||
|
||||
@Column({ name: 'link_id', type: 'uuid' })
|
||||
linkId!: string;
|
||||
|
||||
@ManyToOne(() => PartnerLink, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'link_id' })
|
||||
link?: PartnerLink;
|
||||
|
||||
@Column({ name: 'clicked_at', type: 'timestamptz' })
|
||||
clickedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { Partner } from './partner.entity';
|
||||
|
||||
/**
|
||||
* 업체 링크 — 업체·상품 조합당 하나. 업체에는 단축 링크(/r/{code})를 준다.
|
||||
*
|
||||
* 실적 기록을 지키기 위해 지우지 않고 중지(isActive=false)만 한다.
|
||||
* 중지된 링크도 이동은 시켜 주지만 클릭·실적은 새로 남기지 않는다.
|
||||
*/
|
||||
@Entity({ name: 'partner_links' })
|
||||
@Index('uq_partner_links_partner_product', ['partnerId', 'prodNo'], {
|
||||
unique: true,
|
||||
})
|
||||
@Index('idx_partner_links_cursor', ['createdAt', 'id'])
|
||||
export class PartnerLink {
|
||||
/** 앱에서 crypto.randomUUID() 로 만든다 */
|
||||
@PrimaryColumn({ name: 'id', type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
/** 단축 링크 코드 (영숫자 8자) */
|
||||
@Index('uq_partner_links_code', { unique: true })
|
||||
@Column({ name: 'code', type: 'varchar', length: 16 })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: 'partner_id', type: 'uuid' })
|
||||
partnerId!: string;
|
||||
|
||||
/** 링크가 있는 업체는 지울 수 없다 (DB 에서도 막는다) */
|
||||
@ManyToOne(() => Partner, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'partner_id' })
|
||||
partner?: Partner;
|
||||
|
||||
/** 아임웹 상품번호 — 상품이 아임웹에서 지워져도 링크·실적은 남도록 FK 를 걸지 않는다 */
|
||||
@Index()
|
||||
@Column({ name: 'prod_no', type: 'int' })
|
||||
prodNo!: number;
|
||||
|
||||
/** 이동할 상품 페이지 주소 — 아임웹 사이트 안의 주소만 허용한다 */
|
||||
@Column({ name: 'target_url', type: 'text' })
|
||||
targetUrl!: string;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { PartnerLinkClick } from './partner-link-click.entity';
|
||||
import { PartnerLink } from './partner-link.entity';
|
||||
|
||||
/**
|
||||
* 회원 ↔ 링크 클릭 연결 — "이 회원이 이 링크를 이때 눌렀다".
|
||||
*
|
||||
* 링크를 누른 브라우저에서 로그인한 회원이 확인되면 기록한다 (Header Code 스크립트·신청 완료 화면).
|
||||
* 신청·구매가 생기면 이 기록에서 마지막 클릭을 찾아 업체 실적으로 잇는다.
|
||||
*/
|
||||
@Entity({ name: 'partner_touches' })
|
||||
@Index('uq_partner_touches_member_click', ['memberCode', 'clickRef'], {
|
||||
unique: true,
|
||||
})
|
||||
@Index('idx_partner_touches_member_product', ['memberCode', 'prodNo'])
|
||||
export class PartnerTouch {
|
||||
/** 앱에서 crypto.randomUUID() 로 만든다 */
|
||||
@PrimaryColumn({ name: 'id', type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'member_code', type: 'varchar', length: 40 })
|
||||
memberCode!: string;
|
||||
|
||||
@Column({ name: 'click_ref', type: 'varchar', length: 16 })
|
||||
clickRef!: string;
|
||||
|
||||
@ManyToOne(() => PartnerLinkClick, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'click_ref' })
|
||||
click?: PartnerLinkClick;
|
||||
|
||||
@Column({ name: 'link_id', type: 'uuid' })
|
||||
linkId!: string;
|
||||
|
||||
@ManyToOne(() => PartnerLink, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'link_id' })
|
||||
link?: PartnerLink;
|
||||
|
||||
/** 링크의 상품번호 — 신청·구매 상품과 맞춰 보려고 함께 둔다 */
|
||||
@Column({ name: 'prod_no', type: 'int' })
|
||||
prodNo!: number;
|
||||
|
||||
/** 클릭 시각 (클릭 기록의 값) */
|
||||
@Column({ name: 'clicked_at', type: 'timestamptz' })
|
||||
clickedAt!: Date;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/** 업체 — 상품별 링크를 받아 신청·구매 회원을 데려오는 제휴처 */
|
||||
@Entity({ name: 'partners' })
|
||||
@Index('idx_partners_cursor', ['createdAt', 'id'])
|
||||
export class Partner {
|
||||
/** 앱에서 crypto.randomUUID() 로 만든다 */
|
||||
@PrimaryColumn({ name: 'id', type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@Index('uq_partners_name', { unique: true })
|
||||
@Column({ name: 'name', type: 'varchar', length: 100 })
|
||||
name!: string;
|
||||
|
||||
/** 관리자 메모 (담당자·정산 조건 등) */
|
||||
@Column({ name: 'memo', type: 'text', nullable: true })
|
||||
memo!: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import type { ImwebMemberLookupService } from '@/modules/imweb/imweb-member-lookup.service';
|
||||
import type { ImwebOrderData } from '@/modules/imweb/imweb.types';
|
||||
import {
|
||||
ConversionType,
|
||||
PartnerConversion,
|
||||
} from './entities/partner-conversion.entity';
|
||||
import { PartnerLinkClick } from './entities/partner-link-click.entity';
|
||||
import { PartnerLink } from './entities/partner-link.entity';
|
||||
import { PartnerTouch } from './entities/partner-touch.entity';
|
||||
import { PartnerAttributionService } from './partner-attribution.service';
|
||||
|
||||
const MEMBER_CODE = 'm20261005abcdef';
|
||||
const LINK_A = { id: 'link-a', partnerId: 'partner-a', prodNo: 52 };
|
||||
const LINK_B = { id: 'link-b', partnerId: 'partner-b', prodNo: 52 };
|
||||
const DAY = 86_400_000;
|
||||
|
||||
/** insert().values().orIgnore().execute() 를 흉내 내 값을 store 에 넣는다 */
|
||||
function insertBuilder<T>(store: T[]) {
|
||||
const builder = {
|
||||
insert: () => builder,
|
||||
values: (rows: T | T[]) => {
|
||||
store.push(...(Array.isArray(rows) ? rows : [rows]));
|
||||
return builder;
|
||||
},
|
||||
orIgnore: () => builder,
|
||||
execute: () => Promise.resolve(),
|
||||
};
|
||||
return builder;
|
||||
}
|
||||
|
||||
function setup(options: { clicks?: Partial<PartnerLinkClick>[] } = {}) {
|
||||
const touchStore: PartnerTouch[] = [];
|
||||
const conversionStore: PartnerConversion[] = [];
|
||||
const linksById = new Map(
|
||||
[LINK_A, LINK_B].map((link) => [link.id, link as PartnerLink]),
|
||||
);
|
||||
|
||||
const clicks = {
|
||||
find: jest.fn(() => Promise.resolve(options.clicks ?? [])),
|
||||
};
|
||||
const touches = {
|
||||
create: jest.fn((row: PartnerTouch) => row),
|
||||
createQueryBuilder: jest.fn(() => insertBuilder(touchStore)),
|
||||
find: jest.fn(({ where }: { where: Partial<PartnerTouch> }) =>
|
||||
Promise.resolve(
|
||||
touchStore.filter(
|
||||
(t) => t.memberCode === where.memberCode && t.prodNo === where.prodNo,
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
const conversions = {
|
||||
create: jest.fn((row: PartnerConversion) => row),
|
||||
createQueryBuilder: jest.fn(() => insertBuilder(conversionStore)),
|
||||
exists: jest.fn(({ where }: { where: Partial<PartnerConversion> }) =>
|
||||
Promise.resolve(
|
||||
conversionStore.some((c) => c.applicationId === where.applicationId),
|
||||
),
|
||||
),
|
||||
findOne: jest.fn(({ where }: { where: Partial<PartnerConversion> }) =>
|
||||
Promise.resolve(
|
||||
conversionStore.find(
|
||||
(c) => c.orderNo === where.orderNo && c.prodNo === where.prodNo,
|
||||
) ?? null,
|
||||
),
|
||||
),
|
||||
update: jest.fn((id: string, patch: Partial<PartnerConversion>) => {
|
||||
const row = conversionStore.find((c) => c.id === id);
|
||||
if (row) Object.assign(row, patch);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
};
|
||||
const links = {
|
||||
findOne: jest.fn(({ where }: { where: { id: string } }) =>
|
||||
Promise.resolve(linksById.get(where.id) ?? null),
|
||||
),
|
||||
};
|
||||
const lookup = {
|
||||
find: jest.fn(() => Promise.resolve({ memberCode: MEMBER_CODE })),
|
||||
};
|
||||
|
||||
const service = new PartnerAttributionService(
|
||||
clicks as unknown as Repository<PartnerLinkClick>,
|
||||
touches as unknown as Repository<PartnerTouch>,
|
||||
conversions as unknown as Repository<PartnerConversion>,
|
||||
links as unknown as Repository<PartnerLink>,
|
||||
lookup as unknown as ImwebMemberLookupService,
|
||||
);
|
||||
|
||||
/** 회원이 그 링크를 이 시각에 눌렀다고 기록해 둔다 */
|
||||
const touch = (link: typeof LINK_A, clickedAt: Date) =>
|
||||
touchStore.push({
|
||||
id: `touch-${touchStore.length}`,
|
||||
memberCode: MEMBER_CODE,
|
||||
clickRef: `ref${touchStore.length}`.padEnd(16, '0'),
|
||||
linkId: link.id,
|
||||
prodNo: link.prodNo,
|
||||
clickedAt,
|
||||
createdAt: clickedAt,
|
||||
});
|
||||
|
||||
return { service, touchStore, conversionStore, clicks, lookup, touch };
|
||||
}
|
||||
|
||||
function order(overrides: Partial<ImwebOrderData> = {}): ImwebOrderData {
|
||||
return {
|
||||
orderNo: 202610050001,
|
||||
isMember: 'Y',
|
||||
memberCode: MEMBER_CODE,
|
||||
wtime: new Date().toISOString(),
|
||||
payments: [{ paymentStatus: 'PAYMENT_COMPLETE', isCancel: 'N' }],
|
||||
sections: [
|
||||
{
|
||||
orderSectionStatus: 'PRODUCT_PREPARATION',
|
||||
sectionItems: [{ isPromotion: 'N', productInfo: { prodNo: 52 } }],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PartnerAttributionService.recordTouches', () => {
|
||||
it('사용 중인 링크의 클릭만 회원과 잇는다 (클릭 시각은 서버 기록)', async () => {
|
||||
const clickedAt = new Date(Date.now() - DAY);
|
||||
const { service, touchStore } = setup({
|
||||
clicks: [
|
||||
{
|
||||
ref: 'AAAAAAAAAAAAAAAA',
|
||||
linkId: LINK_A.id,
|
||||
clickedAt,
|
||||
link: { ...LINK_A, isActive: true } as PartnerLink,
|
||||
},
|
||||
{
|
||||
ref: 'BBBBBBBBBBBBBBBB',
|
||||
linkId: LINK_B.id,
|
||||
clickedAt,
|
||||
link: { ...LINK_B, isActive: false } as PartnerLink,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await service.recordTouches(MEMBER_CODE, [
|
||||
'AAAAAAAAAAAAAAAA',
|
||||
'BBBBBBBBBBBBBBBB',
|
||||
]);
|
||||
|
||||
expect(touchStore).toHaveLength(1);
|
||||
expect(touchStore[0]).toMatchObject({
|
||||
memberCode: MEMBER_CODE,
|
||||
clickRef: 'AAAAAAAAAAAAAAAA',
|
||||
linkId: LINK_A.id,
|
||||
prodNo: 52,
|
||||
clickedAt,
|
||||
});
|
||||
});
|
||||
|
||||
it('형식이 아닌 클릭 ID 만 오면 DB 를 보지 않는다', async () => {
|
||||
const { service, clicks } = setup();
|
||||
|
||||
await service.recordTouches(MEMBER_CODE, ['../../etc', 'short']);
|
||||
|
||||
expect(clicks.find).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PartnerAttributionService.touchFromBrowser', () => {
|
||||
it('아임웹에 없는 회원이면 조용히 버린다', async () => {
|
||||
const { service, lookup, clicks } = setup();
|
||||
lookup.find.mockResolvedValueOnce(null as never);
|
||||
|
||||
await service.touchFromBrowser({
|
||||
memberUid: 'nobody@test.dev',
|
||||
refs: ['AAAAAAAAAAAAAAAA'],
|
||||
});
|
||||
|
||||
expect(clicks.find).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('회원 확인이 실패해도 예외를 밖으로 던지지 않는다 (공개 API)', async () => {
|
||||
const { service, lookup } = setup();
|
||||
lookup.find.mockRejectedValueOnce(new Error('imweb down'));
|
||||
|
||||
await expect(
|
||||
service.touchFromBrowser({
|
||||
memberUid: 'user@test.dev',
|
||||
refs: ['AAAAAAAAAAAAAAAA'],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PartnerAttributionService.attributeApplication', () => {
|
||||
const appliedAt = new Date('2026-10-20T00:00:00.000Z');
|
||||
const application = {
|
||||
id: 'app-1',
|
||||
memberCode: MEMBER_CODE,
|
||||
productIdx: '52',
|
||||
appliedAt,
|
||||
};
|
||||
|
||||
it('마지막으로 누른 링크에 신청을 귀속한다', async () => {
|
||||
const { service, conversionStore, touch } = setup();
|
||||
touch(LINK_A, new Date(appliedAt.getTime() - 5 * DAY));
|
||||
touch(LINK_B, new Date(appliedAt.getTime() - 1 * DAY));
|
||||
|
||||
await service.attributeApplication(application);
|
||||
|
||||
expect(conversionStore).toHaveLength(1);
|
||||
expect(conversionStore[0]).toMatchObject({
|
||||
type: ConversionType.APPLICATION,
|
||||
linkId: LINK_B.id,
|
||||
partnerId: LINK_B.partnerId,
|
||||
applicationId: 'app-1',
|
||||
orderNo: null,
|
||||
isValid: true,
|
||||
convertedAt: appliedAt,
|
||||
});
|
||||
});
|
||||
|
||||
it('클릭이 없으면 아무것도 남기지 않는다', async () => {
|
||||
const { service, conversionStore } = setup();
|
||||
|
||||
await service.attributeApplication(application);
|
||||
|
||||
expect(conversionStore).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('이미 귀속된 신청은 다시 남기지 않는다', async () => {
|
||||
const { service, conversionStore, touch } = setup();
|
||||
touch(LINK_A, new Date(appliedAt.getTime() - DAY));
|
||||
|
||||
await service.attributeApplication(application);
|
||||
await service.attributeApplication(application);
|
||||
|
||||
expect(conversionStore).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PartnerAttributionService.attributeOrder', () => {
|
||||
const linked = new Set([52]);
|
||||
|
||||
it('링크로 들어온 회원의 결제완료 주문을 구매로 잡는다', async () => {
|
||||
const { service, conversionStore, touch } = setup();
|
||||
const wtime = new Date();
|
||||
touch(LINK_A, new Date(wtime.getTime() - 2 * DAY));
|
||||
|
||||
const result = await service.attributeOrder(
|
||||
order({ wtime: wtime.toISOString() }),
|
||||
linked,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ created: 1, updated: 0 });
|
||||
expect(conversionStore[0]).toMatchObject({
|
||||
type: ConversionType.PURCHASE,
|
||||
linkId: LINK_A.id,
|
||||
orderNo: '202610050001',
|
||||
prodNo: 52,
|
||||
applicationId: null,
|
||||
isValid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('이미 잡힌 구매가 환불되면 지우지 않고 집계에서 뺀다', async () => {
|
||||
const { service, conversionStore, touch } = setup();
|
||||
touch(LINK_A, new Date(Date.now() - DAY));
|
||||
await service.attributeOrder(order(), linked);
|
||||
|
||||
const result = await service.attributeOrder(
|
||||
order({ payments: [{ paymentStatus: 'REFUND_COMPLETE' }] }),
|
||||
linked,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ created: 0, updated: 1 });
|
||||
expect(conversionStore).toHaveLength(1);
|
||||
expect(conversionStore[0].isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('결제 전 주문·비회원 주문·링크 없는 상품은 잡지 않는다', async () => {
|
||||
const { service, conversionStore, touch } = setup();
|
||||
touch(LINK_A, new Date(Date.now() - DAY));
|
||||
|
||||
await service.attributeOrder(
|
||||
order({ payments: [{ paymentStatus: 'PAYMENT_PREPARATION' }] }),
|
||||
linked,
|
||||
);
|
||||
await service.attributeOrder(
|
||||
order({ isMember: 'N', memberCode: null }),
|
||||
linked,
|
||||
);
|
||||
await service.attributeOrder(order(), new Set([60]));
|
||||
|
||||
expect(conversionStore).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('클릭보다 먼저 한 주문은 그 링크의 실적이 아니다', async () => {
|
||||
const { service, conversionStore, touch } = setup();
|
||||
const wtime = new Date(Date.now() - 3 * DAY);
|
||||
touch(LINK_A, new Date(Date.now() - DAY));
|
||||
|
||||
await service.attributeOrder(order({ wtime: wtime.toISOString() }), linked);
|
||||
|
||||
expect(conversionStore).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { In, MoreThanOrEqual, Repository } from 'typeorm';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { ImwebMemberLookupService } from '@/modules/imweb/imweb-member-lookup.service';
|
||||
import { ImwebOrderData } from '@/modules/imweb/imweb.types';
|
||||
import {
|
||||
ATTRIBUTION_WINDOW_MS,
|
||||
evaluateOrder,
|
||||
pickLastTouch,
|
||||
} from '@/shared/utils/partner-attribution';
|
||||
import { CLICK_REF_PATTERN } from '@/shared/utils/partner-link';
|
||||
import {
|
||||
CreatePartnerTouchDto,
|
||||
PARTNER_REFS_MAX,
|
||||
} from './dto/partner-touch.dto';
|
||||
import {
|
||||
ConversionType,
|
||||
PartnerConversion,
|
||||
} from './entities/partner-conversion.entity';
|
||||
import { PartnerLinkClick } from './entities/partner-link-click.entity';
|
||||
import { PartnerLink } from './entities/partner-link.entity';
|
||||
import { PartnerTouch } from './entities/partner-touch.entity';
|
||||
|
||||
/** 신청 기록 중 귀속에 필요한 값 */
|
||||
export interface ApplicationForAttribution {
|
||||
id: string;
|
||||
memberCode: string;
|
||||
productIdx: string;
|
||||
appliedAt: Date;
|
||||
}
|
||||
|
||||
/** 주문 한 건을 반영한 결과 */
|
||||
export interface OrderAttributionResult {
|
||||
created: number;
|
||||
updated: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 업체 실적 귀속 — 회원과 링크 클릭을 잇고, 신청·구매를 마지막 클릭 링크에 기록한다.
|
||||
*
|
||||
* 규칙(마지막 클릭·30일)은 shared/utils/partner-attribution.ts 의 순수 함수가 정한다.
|
||||
* 회원 ↔ 클릭 연결은 공개 페이지에서 오므로 실제 아임웹 회원인지, 실제로 있는 클릭인지만 확인한다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PartnerAttributionService {
|
||||
private readonly logger = new Logger(PartnerAttributionService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PartnerLinkClick)
|
||||
private readonly clicks: Repository<PartnerLinkClick>,
|
||||
@InjectRepository(PartnerTouch)
|
||||
private readonly touches: Repository<PartnerTouch>,
|
||||
@InjectRepository(PartnerConversion)
|
||||
private readonly conversions: Repository<PartnerConversion>,
|
||||
@InjectRepository(PartnerLink)
|
||||
private readonly links: Repository<PartnerLink>,
|
||||
private readonly lookup: ImwebMemberLookupService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Header Code 스크립트가 보낸 회원 ↔ 클릭 연결.
|
||||
* 공개 API 라 실패 사유를 밖에 알리지 않는다 — 없는 회원·잘못된 클릭은 조용히 버린다.
|
||||
*/
|
||||
async touchFromBrowser(dto: CreatePartnerTouchDto): Promise<void> {
|
||||
if (dto.refs.length === 0) return;
|
||||
try {
|
||||
const member = await this.lookup.find(dto.memberUid);
|
||||
if (!member) return;
|
||||
await this.recordTouches(member.memberCode, dto.refs);
|
||||
} catch (error) {
|
||||
// 아임웹 미연결·일시 장애 등 — 다음 페이지 방문 때 다시 보낸다
|
||||
const code = error instanceof BusinessException ? error.code : 'SYS_001';
|
||||
this.logger.warn(`[${code}] 업체 링크 연결 기록 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원과 클릭을 잇는다. 30일이 지난 클릭과 중지된 링크의 클릭은 버린다.
|
||||
* 같은 회원·클릭은 한 번만 남는다 (여러 번 보내도 안전).
|
||||
*/
|
||||
async recordTouches(
|
||||
memberCode: string,
|
||||
refs: readonly string[],
|
||||
): Promise<void> {
|
||||
const valid = [...new Set(refs)]
|
||||
.filter((ref) => CLICK_REF_PATTERN.test(ref))
|
||||
.slice(0, PARTNER_REFS_MAX);
|
||||
if (valid.length === 0) return;
|
||||
|
||||
const since = new Date(Date.now() - ATTRIBUTION_WINDOW_MS);
|
||||
const found = await this.clicks.find({
|
||||
where: { ref: In(valid), clickedAt: MoreThanOrEqual(since) },
|
||||
relations: { link: true },
|
||||
});
|
||||
const rows: PartnerTouch[] = [];
|
||||
for (const click of found) {
|
||||
if (!click.link?.isActive) continue;
|
||||
rows.push(
|
||||
this.touches.create({
|
||||
id: randomUUID(),
|
||||
memberCode,
|
||||
clickRef: click.ref,
|
||||
linkId: click.linkId,
|
||||
prodNo: click.link.prodNo,
|
||||
clickedAt: click.clickedAt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (rows.length === 0) return;
|
||||
|
||||
await this.touches
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.values(rows)
|
||||
.orIgnore() // (회원, 클릭) 유일 — 이미 있으면 그대로 둔다
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* 신청을 마지막 클릭 링크에 귀속한다. 귀속할 클릭이 없으면 아무것도 남기지 않는다.
|
||||
* 이미 귀속된 신청이면 그대로 둔다 (여러 번 불러도 안전).
|
||||
*/
|
||||
async attributeApplication(
|
||||
application: ApplicationForAttribution,
|
||||
): Promise<void> {
|
||||
const prodNo = Number(application.productIdx);
|
||||
if (!Number.isInteger(prodNo)) return;
|
||||
|
||||
const exists = await this.conversions.exists({
|
||||
where: { applicationId: application.id },
|
||||
});
|
||||
if (exists) return;
|
||||
|
||||
const touch = await this.findLastTouch(
|
||||
application.memberCode,
|
||||
prodNo,
|
||||
application.appliedAt,
|
||||
);
|
||||
if (!touch) return;
|
||||
|
||||
await this.insertConversion({
|
||||
type: ConversionType.APPLICATION,
|
||||
touch,
|
||||
memberCode: application.memberCode,
|
||||
prodNo,
|
||||
applicationId: application.id,
|
||||
orderNo: null,
|
||||
convertedAt: application.appliedAt,
|
||||
});
|
||||
this.logger.log(
|
||||
`업체 실적(신청): link=${touch.linkId} member=${application.memberCode} product=${prodNo}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 아임웹 주문 한 건을 반영한다.
|
||||
* - 이미 잡힌 구매는 환불·취소 여부만 갱신한다.
|
||||
* - 새로 결제완료된 상품은 마지막 클릭 링크가 있으면 구매로 잡는다.
|
||||
*
|
||||
* @param linkedProdNos 업체 링크가 있는 상품번호 — 그 밖의 상품은 볼 필요가 없다
|
||||
*/
|
||||
async attributeOrder(
|
||||
order: ImwebOrderData,
|
||||
linkedProdNos: ReadonlySet<number>,
|
||||
): 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 { products } = evaluateOrder(order);
|
||||
for (const [prodNo, counts] of products) {
|
||||
if (!linkedProdNos.has(prodNo)) continue;
|
||||
|
||||
const existing = await this.conversions.findOne({
|
||||
where: { orderNo, prodNo },
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.isValid !== counts) {
|
||||
await this.conversions.update(existing.id, { isValid: counts });
|
||||
result.updated += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 결제 전이면 다음 동기화에서 다시 본다. 비회원 주문은 회원과 이을 수 없다.
|
||||
if (!counts || order.isMember !== 'Y' || !order.memberCode) continue;
|
||||
|
||||
const touch = await this.findLastTouch(
|
||||
order.memberCode,
|
||||
prodNo,
|
||||
orderedAt,
|
||||
);
|
||||
if (!touch) continue;
|
||||
|
||||
await this.insertConversion({
|
||||
type: ConversionType.PURCHASE,
|
||||
touch,
|
||||
memberCode: order.memberCode,
|
||||
prodNo,
|
||||
applicationId: null,
|
||||
orderNo,
|
||||
convertedAt: orderedAt,
|
||||
});
|
||||
result.created += 1;
|
||||
this.logger.log(
|
||||
`업체 실적(구매): link=${touch.linkId} member=${order.memberCode} order=${orderNo} product=${prodNo}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 업체 링크가 있는 상품번호 — 주문 동기화가 볼 상품을 좁힌다 */
|
||||
async linkedProdNos(): Promise<Set<number>> {
|
||||
const rows = await this.links
|
||||
.createQueryBuilder('l')
|
||||
.select('DISTINCT l.prod_no', 'prodNo')
|
||||
.getRawMany<{ prodNo: number }>();
|
||||
return new Set(rows.map((row) => Number(row.prodNo)));
|
||||
}
|
||||
|
||||
private async findLastTouch(
|
||||
memberCode: string,
|
||||
prodNo: number,
|
||||
convertedAt: Date,
|
||||
): Promise<PartnerTouch | null> {
|
||||
const candidates = await this.touches.find({
|
||||
where: {
|
||||
memberCode,
|
||||
prodNo,
|
||||
clickedAt: MoreThanOrEqual(
|
||||
new Date(convertedAt.getTime() - ATTRIBUTION_WINDOW_MS),
|
||||
),
|
||||
},
|
||||
});
|
||||
return pickLastTouch(candidates, convertedAt);
|
||||
}
|
||||
|
||||
private async insertConversion(input: {
|
||||
type: ConversionType;
|
||||
touch: PartnerTouch;
|
||||
memberCode: string;
|
||||
prodNo: number;
|
||||
applicationId: string | null;
|
||||
orderNo: string | null;
|
||||
convertedAt: Date;
|
||||
}): Promise<void> {
|
||||
const link = await this.links.findOne({
|
||||
where: { id: input.touch.linkId },
|
||||
});
|
||||
if (!link) return;
|
||||
|
||||
await this.conversions
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.values(
|
||||
this.conversions.create({
|
||||
id: randomUUID(),
|
||||
type: input.type,
|
||||
linkId: link.id,
|
||||
partnerId: link.partnerId,
|
||||
prodNo: input.prodNo,
|
||||
memberCode: input.memberCode,
|
||||
applicationId: input.applicationId,
|
||||
orderNo: input.orderNo,
|
||||
isValid: true,
|
||||
touchedAt: input.touch.clickedAt,
|
||||
convertedAt: input.convertedAt,
|
||||
}),
|
||||
)
|
||||
.orIgnore() // 신청·주문당 하나 — 동시에 두 번 들어와도 한 건
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
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 {
|
||||
ConversionListQueryDto,
|
||||
ConversionResponseDto,
|
||||
OrderSyncStatusDto,
|
||||
} from './dto/partner-conversion.dto';
|
||||
import {
|
||||
CreatePartnerLinkDto,
|
||||
PartnerLinkListQueryDto,
|
||||
PartnerLinkResponseDto,
|
||||
UpdatePartnerLinkDto,
|
||||
} from './dto/partner-link.dto';
|
||||
import { PartnerLinksService } from './partner-links.service';
|
||||
import { PartnerOrderSyncService } from './partner-order-sync.service';
|
||||
|
||||
/** 업체 링크 관리 — 발급·중지와 링크별 신청·구매 실적 */
|
||||
@ApiTags('Admin Partner Links')
|
||||
@ApiBearerAuth(ADMIN_BEARER_AUTH)
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Controller('admin/partner-links')
|
||||
export class PartnerLinksController {
|
||||
constructor(private readonly links: PartnerLinksService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '업체 링크 목록 (최근 발급순) — 기간 내 실적 포함' })
|
||||
@ApiStandardResponse(PartnerLinkResponseDto, {
|
||||
description: '조회 성공 — data 는 { items, pageInfo }',
|
||||
isArray: true,
|
||||
})
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('VAL_001', '필터·기간·커서 형식 오류')
|
||||
findMany(
|
||||
@Query() query: PartnerLinkListQueryDto,
|
||||
): Promise<PaginatedResult<PartnerLinkResponseDto>> {
|
||||
return this.links.findMany(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '업체 링크 발급 — 업체·상품 조합당 하나' })
|
||||
@ApiStandardResponse(PartnerLinkResponseDto, {
|
||||
status: 201,
|
||||
description: '발급 성공',
|
||||
})
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('VAL_001', '입력값 오류')
|
||||
@ApiErrorResponse('RES_001', '없는 업체, 또는 불러오지 않은 상품')
|
||||
@ApiErrorResponse('PARTNER_001', '같은 업체·상품의 링크가 있음')
|
||||
@ApiErrorResponse('PARTNER_003', '이동 주소가 사이트 밖')
|
||||
create(@Body() dto: CreatePartnerLinkDto): Promise<PartnerLinkResponseDto> {
|
||||
return this.links.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: '업체 링크 수정 — 이동 주소 변경, 사용/중지' })
|
||||
@ApiStandardResponse(PartnerLinkResponseDto, { description: '수정 성공' })
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('RES_001', '없는 링크')
|
||||
@ApiErrorResponse('PARTNER_003', '이동 주소가 사이트 밖')
|
||||
update(
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
@Body() dto: UpdatePartnerLinkDto,
|
||||
): Promise<PartnerLinkResponseDto> {
|
||||
return this.links.update(id, dto);
|
||||
}
|
||||
|
||||
@Get(':id/conversions')
|
||||
@ApiOperation({ summary: '링크 실적 상세 — 신청·구매한 회원 (최근순)' })
|
||||
@ApiStandardResponse(ConversionResponseDto, {
|
||||
description: '조회 성공 — data 는 { items, pageInfo }',
|
||||
isArray: true,
|
||||
})
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('RES_001', '없는 링크')
|
||||
findConversions(
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
@Query() query: ConversionListQueryDto,
|
||||
): Promise<PaginatedResult<ConversionResponseDto>> {
|
||||
return this.links.findConversions(id, query);
|
||||
}
|
||||
}
|
||||
|
||||
/** 업체 구매 집계 새로고침 — 아임웹 주문을 다시 훑는다 (기본 1시간마다 자동) */
|
||||
@ApiTags('Admin Partner Links')
|
||||
@ApiBearerAuth(ADMIN_BEARER_AUTH)
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Controller('admin/partner-order-sync-runs')
|
||||
export class PartnerOrderSyncController {
|
||||
constructor(private readonly orderSync: PartnerOrderSyncService) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
@ApiOperation({
|
||||
summary: '구매 집계 새로고침 시작 — 바로 응답하고 백그라운드에서 진행',
|
||||
})
|
||||
@ApiStandardResponse(OrderSyncStatusDto, {
|
||||
status: 202,
|
||||
description: '시작됨',
|
||||
})
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
start(): OrderSyncStatusDto {
|
||||
return this.orderSync.start();
|
||||
}
|
||||
|
||||
@Get('latest')
|
||||
@ApiOperation({ summary: '구매 집계 새로고침 상태 (서버 재시작 시 비워짐)' })
|
||||
@ApiStandardResponse(OrderSyncStatusDto, { description: '조회 성공' })
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
latest(): OrderSyncStatusDto {
|
||||
return this.orderSync.getStatus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import { In, Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
|
||||
import { ImwebProduct } from '@/modules/imweb/entities/imweb-product.entity';
|
||||
import { ImwebConfig } from '@/modules/imweb/imweb.config';
|
||||
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
|
||||
import { MembersService } from '@/modules/members/members.service';
|
||||
import {
|
||||
CLICK_REF_LENGTH,
|
||||
LINK_CODE_LENGTH,
|
||||
LINK_CODE_PATTERN,
|
||||
isBotUserAgent,
|
||||
normalizeTargetUrl,
|
||||
toBase62,
|
||||
withPartnerRef,
|
||||
} from '@/shared/utils/partner-link';
|
||||
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
|
||||
import {
|
||||
ConversionListQueryDto,
|
||||
ConversionResponseDto,
|
||||
} from './dto/partner-conversion.dto';
|
||||
import {
|
||||
CreatePartnerLinkDto,
|
||||
PartnerLinkListQueryDto,
|
||||
PartnerLinkResponseDto,
|
||||
UpdatePartnerLinkDto,
|
||||
} from './dto/partner-link.dto';
|
||||
import { PartnerConversion } from './entities/partner-conversion.entity';
|
||||
import { PartnerLinkClick } from './entities/partner-link-click.entity';
|
||||
import { PartnerLink } from './entities/partner-link.entity';
|
||||
import { Partner } from './entities/partner.entity';
|
||||
import { PartnerStatsService } from './partner-stats.service';
|
||||
import { PartnersService, isUniqueViolation } from './partners.service';
|
||||
|
||||
const ALIAS = 'l';
|
||||
const CONVERSION_ALIAS = 'v';
|
||||
/** 단축 링크 경로 — main.ts 에서 /api prefix 를 붙이지 않는다 */
|
||||
export const SHORT_LINK_PATH = 'r';
|
||||
/** 코드가 우연히 겹치면 다시 만든다 (62^8 이라 사실상 일어나지 않는다) */
|
||||
const CODE_RETRIES = 3;
|
||||
const UQ_PARTNER_PRODUCT = 'uq_partner_links_partner_product';
|
||||
|
||||
function constraintOf(error: unknown): string | undefined {
|
||||
return (error as QueryErrorWithConstraint).driverError?.constraint;
|
||||
}
|
||||
|
||||
interface QueryErrorWithConstraint {
|
||||
driverError?: { constraint?: string };
|
||||
}
|
||||
|
||||
/** 업체 링크 관리 — 발급·수정, 링크별 실적, 단축 링크 이동 */
|
||||
@Injectable()
|
||||
export class PartnerLinksService {
|
||||
private readonly logger = new Logger(PartnerLinksService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PartnerLink)
|
||||
private readonly links: Repository<PartnerLink>,
|
||||
@InjectRepository(PartnerLinkClick)
|
||||
private readonly clicks: Repository<PartnerLinkClick>,
|
||||
@InjectRepository(PartnerConversion)
|
||||
private readonly conversions: Repository<PartnerConversion>,
|
||||
@InjectRepository(Partner)
|
||||
private readonly partnerRows: Repository<Partner>,
|
||||
@InjectRepository(ImwebProduct)
|
||||
private readonly products: Repository<ImwebProduct>,
|
||||
@InjectRepository(ImwebMember)
|
||||
private readonly members: Repository<ImwebMember>,
|
||||
private readonly partners: PartnersService,
|
||||
private readonly stats: PartnerStatsService,
|
||||
private readonly membersService: MembersService,
|
||||
private readonly imwebConfig: ImwebConfig,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
/** 링크 목록 — 최근 발급순, 기간 내 실적 포함 */
|
||||
async findMany(
|
||||
query: PartnerLinkListQueryDto,
|
||||
): Promise<PaginatedResult<PartnerLinkResponseDto>> {
|
||||
const base = this.links.createQueryBuilder(ALIAS);
|
||||
if (query.partnerId) {
|
||||
base.andWhere(`${ALIAS}.partnerId = :partnerId`, {
|
||||
partnerId: query.partnerId,
|
||||
});
|
||||
}
|
||||
if (query.prodNo) {
|
||||
base.andWhere(`${ALIAS}.prodNo = :prodNo`, { prodNo: query.prodNo });
|
||||
}
|
||||
|
||||
const total = await base.clone().getCount();
|
||||
const page = base
|
||||
.clone()
|
||||
.orderBy(`${ALIAS}.createdAt`, '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}.createdAt, ${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);
|
||||
|
||||
return toPaginatedResult(await this.toResponses(items, query), {
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && last
|
||||
? encodeTimeCursor({
|
||||
time: last.createdAt.toISOString(),
|
||||
key: last.id,
|
||||
})
|
||||
: null,
|
||||
total,
|
||||
});
|
||||
}
|
||||
|
||||
/** 링크 발급 — 업체·상품 조합당 하나 */
|
||||
async create(dto: CreatePartnerLinkDto): Promise<PartnerLinkResponseDto> {
|
||||
await this.partners.require(dto.partnerId);
|
||||
const product = await this.products.findOne({
|
||||
where: { prodNo: dto.prodNo },
|
||||
});
|
||||
if (!product) {
|
||||
// 관리자 화면에서 '상품 불러오기'를 먼저 해야 한다
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'product_not_synced', prodNo: dto.prodNo },
|
||||
});
|
||||
}
|
||||
const targetUrl = this.requireTargetUrl(dto.targetUrl);
|
||||
const duplicated = await this.links.exists({
|
||||
where: { partnerId: dto.partnerId, prodNo: dto.prodNo },
|
||||
});
|
||||
if (duplicated) {
|
||||
throw new BusinessException('PARTNER_001', {
|
||||
context: { partnerId: dto.partnerId, prodNo: dto.prodNo },
|
||||
});
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= CODE_RETRIES; attempt += 1) {
|
||||
const row = this.links.create({
|
||||
id: randomUUID(),
|
||||
code: toBase62(randomBytes(LINK_CODE_LENGTH)),
|
||||
partnerId: dto.partnerId,
|
||||
prodNo: dto.prodNo,
|
||||
targetUrl,
|
||||
isActive: true,
|
||||
});
|
||||
try {
|
||||
await this.links.insert(row);
|
||||
this.logger.log(
|
||||
`업체 링크 발급: link=${row.id} partner=${dto.partnerId} product=${dto.prodNo}`,
|
||||
);
|
||||
return this.findOneResponse(row.id);
|
||||
} catch (error) {
|
||||
if (!isUniqueViolation(error)) throw error;
|
||||
if (constraintOf(error) === UQ_PARTNER_PRODUCT) {
|
||||
throw new BusinessException('PARTNER_001', {
|
||||
context: { partnerId: dto.partnerId, prodNo: dto.prodNo },
|
||||
});
|
||||
}
|
||||
// 코드가 겹친 경우 — 새 코드로 다시
|
||||
}
|
||||
}
|
||||
throw new BusinessException('SYS_001', {
|
||||
context: { reason: 'link_code_collision' },
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdatePartnerLinkDto,
|
||||
): Promise<PartnerLinkResponseDto> {
|
||||
const row = await this.require(id);
|
||||
if (dto.targetUrl !== undefined) {
|
||||
row.targetUrl = this.requireTargetUrl(dto.targetUrl);
|
||||
}
|
||||
if (dto.isActive !== undefined) row.isActive = dto.isActive;
|
||||
await this.links.save(row);
|
||||
this.logger.log(`업체 링크 수정: link=${id} active=${row.isActive}`);
|
||||
return this.findOneResponse(id);
|
||||
}
|
||||
|
||||
/** 링크 실적 상세 — 어느 회원이 언제 신청·구매했는지 (최근순) */
|
||||
async findConversions(
|
||||
linkId: string,
|
||||
query: ConversionListQueryDto,
|
||||
): Promise<PaginatedResult<ConversionResponseDto>> {
|
||||
await this.require(linkId);
|
||||
const base = this.conversions
|
||||
.createQueryBuilder(CONVERSION_ALIAS)
|
||||
.where(`${CONVERSION_ALIAS}.linkId = :linkId`, { linkId });
|
||||
if (query.type) {
|
||||
base.andWhere(`${CONVERSION_ALIAS}.type = :type`, { type: query.type });
|
||||
}
|
||||
|
||||
const total = await base.clone().getCount();
|
||||
const page = base
|
||||
.clone()
|
||||
.orderBy(`${CONVERSION_ALIAS}.convertedAt`, 'DESC')
|
||||
.addOrderBy(`${CONVERSION_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(
|
||||
`(${CONVERSION_ALIAS}.convertedAt, ${CONVERSION_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 codes = [...new Set(items.map((row) => row.memberCode))];
|
||||
const [memberRows, groupTitles] = await Promise.all([
|
||||
codes.length > 0
|
||||
? this.members.find({ where: { memberCode: In(codes) } })
|
||||
: Promise.resolve([]),
|
||||
this.membersService.groupTitleMap(),
|
||||
]);
|
||||
const membersByCode = new Map(memberRows.map((m) => [m.memberCode, m]));
|
||||
|
||||
return toPaginatedResult(
|
||||
items.map((row) => {
|
||||
const member = membersByCode.get(row.memberCode);
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
convertedAt: row.convertedAt.toISOString(),
|
||||
touchedAt: row.touchedAt.toISOString(),
|
||||
orderNo: row.orderNo,
|
||||
isValid: row.isValid,
|
||||
memberCode: row.memberCode,
|
||||
member: member ? MemberResponseDto.from(member, groupTitles) : null,
|
||||
};
|
||||
}),
|
||||
{
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && last
|
||||
? encodeTimeCursor({
|
||||
time: last.convertedAt.toISOString(),
|
||||
key: last.id,
|
||||
})
|
||||
: null,
|
||||
total,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단축 링크가 보낼 주소.
|
||||
* - 사용 중인 링크: 사람의 클릭이면 기록하고 클릭 ID 를 붙인다. 봇·미리보기는 기록 없이 이동만.
|
||||
* - 중지된 링크: 이동만 시킨다 (실적을 남기지 않는다).
|
||||
* - 없는 코드: 사이트 첫 화면으로 보낸다.
|
||||
*/
|
||||
async resolveRedirect(
|
||||
code: string,
|
||||
userAgent: string | undefined,
|
||||
method: string,
|
||||
): Promise<string> {
|
||||
const link = LINK_CODE_PATTERN.test(code)
|
||||
? await this.links.findOne({ where: { code } })
|
||||
: null;
|
||||
|
||||
if (!link) {
|
||||
const home = this.imwebConfig.siteUrl;
|
||||
if (!home) throw new BusinessException('RES_001', { context: { code } });
|
||||
return home;
|
||||
}
|
||||
if (!link.isActive || method !== 'GET' || isBotUserAgent(userAgent)) {
|
||||
return link.targetUrl;
|
||||
}
|
||||
|
||||
const ref = toBase62(randomBytes(CLICK_REF_LENGTH));
|
||||
try {
|
||||
await this.clicks.insert({ ref, linkId: link.id, clickedAt: new Date() });
|
||||
} catch (error) {
|
||||
// 클릭 기록이 실패해도 이동은 시킨다 — 업체 링크가 막히는 것이 더 나쁘다
|
||||
this.logger.warn(
|
||||
`업체 링크 클릭 기록 실패: link=${link.id} ${String(error)}`,
|
||||
);
|
||||
return link.targetUrl;
|
||||
}
|
||||
return withPartnerRef(link.targetUrl, ref);
|
||||
}
|
||||
|
||||
private async require(id: string): Promise<PartnerLink> {
|
||||
const row = await this.links.findOne({ where: { id } });
|
||||
if (!row)
|
||||
throw new BusinessException('RES_001', { context: { linkId: id } });
|
||||
return row;
|
||||
}
|
||||
|
||||
private requireTargetUrl(raw: string): string {
|
||||
const siteUrl = this.imwebConfig.siteUrl;
|
||||
if (!siteUrl) {
|
||||
throw new BusinessException('IMWEB_005', {
|
||||
context: { reason: 'missing_site_url' },
|
||||
});
|
||||
}
|
||||
const normalized = normalizeTargetUrl(raw, siteUrl);
|
||||
if (!normalized) {
|
||||
throw new BusinessException('PARTNER_003', { context: { siteUrl } });
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private async findOneResponse(id: string): Promise<PartnerLinkResponseDto> {
|
||||
const row = await this.require(id);
|
||||
const [response] = await this.toResponses([row], {});
|
||||
return response;
|
||||
}
|
||||
|
||||
private async toResponses(
|
||||
rows: PartnerLink[],
|
||||
period: { from?: string; to?: string },
|
||||
): Promise<PartnerLinkResponseDto[]> {
|
||||
if (rows.length === 0) return [];
|
||||
const partnerIds = [...new Set(rows.map((row) => row.partnerId))];
|
||||
const prodNos = [...new Set(rows.map((row) => row.prodNo))];
|
||||
|
||||
const [statsById, partnerRows, productRows] = await Promise.all([
|
||||
this.stats.byLinks(
|
||||
rows.map((row) => row.id),
|
||||
period,
|
||||
),
|
||||
this.partnerRows.find({ where: { id: In(partnerIds) } }),
|
||||
this.products.find({ where: { prodNo: In(prodNos) } }),
|
||||
]);
|
||||
const partnerById = new Map(partnerRows.map((p) => [p.id, p]));
|
||||
const productByNo = new Map(productRows.map((p) => [p.prodNo, p]));
|
||||
const shortBase = this.shortLinkBase();
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
shortUrl: shortBase ? `${shortBase}/${SHORT_LINK_PATH}/${row.code}` : '',
|
||||
partner: {
|
||||
id: row.partnerId,
|
||||
name: partnerById.get(row.partnerId)?.name ?? '',
|
||||
},
|
||||
product: {
|
||||
prodNo: row.prodNo,
|
||||
name: productByNo.get(row.prodNo)?.name ?? null,
|
||||
},
|
||||
targetUrl: row.targetUrl,
|
||||
isActive: row.isActive,
|
||||
stats: statsById.get(row.id) ?? PartnerStatsService.empty(),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 단축 링크 앞부분 — 백엔드 공개 주소(BACKEND_API_URL)의 origin */
|
||||
private shortLinkBase(): string {
|
||||
const apiUrl = (this.config.get<string>('BACKEND_API_URL') ?? '').trim();
|
||||
if (!apiUrl) return '';
|
||||
try {
|
||||
return new URL(apiUrl).origin;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnApplicationBootstrap,
|
||||
OnApplicationShutdown,
|
||||
} from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
|
||||
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 { OrderSyncStatusDto } from './dto/partner-conversion.dto';
|
||||
import { PartnerAttributionService } from './partner-attribution.service';
|
||||
|
||||
const SCHEDULE_NAME = 'partner-order-sync';
|
||||
/**
|
||||
* 이만큼 지난 주문까지 다시 본다 — 귀속 기간(30일)에 환불·취소가 늦게 반영되는 기간을 더했다.
|
||||
* 그보다 오래된 구매의 환불은 반영되지 않는다.
|
||||
*/
|
||||
const ORDER_LOOKBACK_DAYS = 60;
|
||||
const DAY_MS = 86_400_000;
|
||||
const ORDER_PAGE_SIZE = 100;
|
||||
/** 페이지 사이 쉬는 시간 — 아임웹 호출 제한이 문서에 없어 보수적으로 둔다 */
|
||||
const PAGE_DELAY_MS = 300;
|
||||
/** 응답이 이상해도 무한 루프에 빠지지 않도록 */
|
||||
const MAX_PAGES = 1_000;
|
||||
const FALLBACK_ERROR_CODE = 'SYS_001';
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** 아임웹 예시 형식(2024-09-01T00:00:00Z)에 맞춰 밀리초를 뗀다 */
|
||||
function toImwebTime(date: Date): string {
|
||||
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
}
|
||||
|
||||
/**
|
||||
* 업체 링크 구매 집계 — 아임웹 주문을 주기적으로 훑어 구매를 잡고 환불·취소를 반영한다.
|
||||
*
|
||||
* 아임웹은 주문 완료를 알려주지 않으므로(웹훅 없음) 회원 동기화와 같은 주기로 최근 주문을 다시 본다.
|
||||
* 업체 링크가 하나도 없으면 아임웹을 부르지 않는다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PartnerOrderSyncService
|
||||
implements OnApplicationBootstrap, OnApplicationShutdown
|
||||
{
|
||||
private readonly logger = new Logger(PartnerOrderSyncService.name);
|
||||
/** 진행 중인 실행 — 동시에 두 번 돌지 않게 하고, 종료 시 기다린다 */
|
||||
private running: Promise<void> | null = null;
|
||||
private status: OrderSyncStatusDto = {
|
||||
isRunning: false,
|
||||
lastStartedAt: null,
|
||||
lastFinishedAt: null,
|
||||
lastErrorCode: null,
|
||||
checkedOrderCount: 0,
|
||||
createdCount: 0,
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly attribution: PartnerAttributionService,
|
||||
private readonly auth: ImwebAuthService,
|
||||
private readonly config: ImwebConfig,
|
||||
private readonly scheduler: SchedulerRegistry,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
const timer = setInterval(() => {
|
||||
void this.runScheduled();
|
||||
}, this.config.syncIntervalMs);
|
||||
this.scheduler.addInterval(SCHEDULE_NAME, timer);
|
||||
}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
if (this.scheduler.doesExist('interval', SCHEDULE_NAME)) {
|
||||
this.scheduler.deleteInterval(SCHEDULE_NAME);
|
||||
}
|
||||
await this.waitForIdle();
|
||||
}
|
||||
|
||||
async waitForIdle(): Promise<void> {
|
||||
await this.running?.catch(() => undefined);
|
||||
}
|
||||
|
||||
getStatus(): OrderSyncStatusDto {
|
||||
return { ...this.status };
|
||||
}
|
||||
|
||||
/**
|
||||
* 새로고침을 시작하고 상태를 바로 돌려준다 (끝까지 기다리지 않는다).
|
||||
* 이미 진행 중이면 새로 시작하지 않고 현재 상태를 돌려준다.
|
||||
*/
|
||||
start(): OrderSyncStatusDto {
|
||||
if (!this.running) {
|
||||
this.status = {
|
||||
...this.status,
|
||||
isRunning: true,
|
||||
lastStartedAt: new Date().toISOString(),
|
||||
};
|
||||
this.running = this.execute().finally(() => {
|
||||
this.running = null;
|
||||
this.status = {
|
||||
...this.status,
|
||||
isRunning: false,
|
||||
lastFinishedAt: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
private async runScheduled(): Promise<void> {
|
||||
if (this.running || !this.config.isConfigured()) return;
|
||||
const connection = await this.auth.findConnection();
|
||||
if (!connection) return; // 아직 연결 전이면 조용히 건너뛴다
|
||||
this.start();
|
||||
}
|
||||
|
||||
private async execute(): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
let checked = 0;
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
|
||||
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);
|
||||
|
||||
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 (const order of data.list) {
|
||||
const result = await this.attribution.attributeOrder(
|
||||
order,
|
||||
linkedProdNos,
|
||||
);
|
||||
created += result.created;
|
||||
updated += result.updated;
|
||||
}
|
||||
checked += data.list.length;
|
||||
if (data.list.length === 0 || page >= data.totalPage) break;
|
||||
}
|
||||
}
|
||||
|
||||
this.status = {
|
||||
...this.status,
|
||||
lastErrorCode: null,
|
||||
checkedOrderCount: checked,
|
||||
createdCount: created,
|
||||
};
|
||||
this.logger.log(
|
||||
`업체 구매 집계 완료: orders=${checked} created=${created} updated=${updated} ${Date.now() - startedAt}ms`,
|
||||
);
|
||||
} catch (error) {
|
||||
const code =
|
||||
error instanceof BusinessException ? error.code : FALLBACK_ERROR_CODE;
|
||||
this.status = {
|
||||
...this.status,
|
||||
lastErrorCode: code,
|
||||
checkedOrderCount: checked,
|
||||
createdCount: created,
|
||||
};
|
||||
// 권한 부족·미연결 등 예상된 실패는 warn, 그 밖의 오류만 error 로 남긴다
|
||||
if (error instanceof BusinessException) {
|
||||
this.logger.warn(`[${code}] 업체 구매 집계 실패`);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`[${code}] 업체 구매 집계 실패`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { SkipThrottle, Throttle } from '@nestjs/throttler';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { ApiErrorResponse } from '@/common/decorators/api-standard-response.decorator';
|
||||
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
|
||||
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 제외).
|
||||
*
|
||||
* 사람이 보는 화면 이동이라 JSON 대신 상품 페이지로 리다이렉트한다.
|
||||
* 같은 통신사 IP 뒤의 여러 사용자가 동시에 누를 수 있어 요청 수 제한을 걸지 않는다
|
||||
* (클릭 수가 부풀 수는 있지만 실적은 실제 회원의 신청·구매로만 잡힌다).
|
||||
*/
|
||||
@ApiExcludeController()
|
||||
@SkipTransform()
|
||||
@SkipThrottle()
|
||||
@Controller(SHORT_LINK_PATH)
|
||||
export class PartnerRedirectController {
|
||||
constructor(private readonly links: PartnerLinksService) {}
|
||||
|
||||
@Get(':code')
|
||||
async redirect(
|
||||
@Param('code') code: string,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const target = await this.links.resolveRedirect(
|
||||
code,
|
||||
req.headers['user-agent'],
|
||||
req.method,
|
||||
);
|
||||
// 매 클릭을 서버가 받아야 클릭 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/imweb-header-top.html 의 업체 링크 추적)가 회원 ↔ 링크 클릭 연결을 보낸다 */
|
||||
@ApiTags('Partner Touches')
|
||||
@Controller('partner-touches')
|
||||
export class PartnerTouchesController {
|
||||
constructor(private readonly attribution: PartnerAttributionService) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@SkipTransform()
|
||||
@Throttle({ default: { ttl: 60_000, limit: 30 } })
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'회원 ↔ 업체 링크 클릭 연결 — 항상 204 (없는 회원·클릭은 조용히 버린다)',
|
||||
})
|
||||
@ApiErrorResponse('VAL_001', '입력값 오류')
|
||||
@ApiErrorResponse('REQ_001', '요청 수 초과 (1분 30회)')
|
||||
async create(@Body() dto: CreatePartnerTouchDto): Promise<void> {
|
||||
await this.attribution.touchFromBrowser(dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { PartnerStatsDto } from './dto/partner.dto';
|
||||
import {
|
||||
ConversionType,
|
||||
PartnerConversion,
|
||||
} from './entities/partner-conversion.entity';
|
||||
import { PartnerLinkClick } from './entities/partner-link-click.entity';
|
||||
import { PartnerLink } from './entities/partner-link.entity';
|
||||
|
||||
/** 집계 기간 — 없으면 전체 */
|
||||
export interface StatsPeriod {
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
const EMPTY_STATS: PartnerStatsDto = {
|
||||
clickCount: 0,
|
||||
applicationCount: 0,
|
||||
purchaseCount: 0,
|
||||
};
|
||||
|
||||
function withPeriod<T extends object>(
|
||||
query: SelectQueryBuilder<T>,
|
||||
column: string,
|
||||
period: StatsPeriod,
|
||||
): SelectQueryBuilder<T> {
|
||||
if (period.from) query.andWhere(`${column} >= :from`, { from: period.from });
|
||||
if (period.to) query.andWhere(`${column} < :to`, { to: period.to });
|
||||
return query;
|
||||
}
|
||||
|
||||
/** 링크·업체별 클릭·신청·구매 수 집계 (환불·취소된 구매는 뺀다) */
|
||||
@Injectable()
|
||||
export class PartnerStatsService {
|
||||
constructor(
|
||||
@InjectRepository(PartnerLinkClick)
|
||||
private readonly clicks: Repository<PartnerLinkClick>,
|
||||
@InjectRepository(PartnerConversion)
|
||||
private readonly conversions: Repository<PartnerConversion>,
|
||||
@InjectRepository(PartnerLink)
|
||||
private readonly links: Repository<PartnerLink>,
|
||||
) {}
|
||||
|
||||
/** 링크 ID → 실적 */
|
||||
async byLinks(
|
||||
linkIds: string[],
|
||||
period: StatsPeriod,
|
||||
): Promise<Map<string, PartnerStatsDto>> {
|
||||
if (linkIds.length === 0) return new Map();
|
||||
|
||||
const [clickRows, conversionRows] = await Promise.all([
|
||||
withPeriod(
|
||||
this.clicks
|
||||
.createQueryBuilder('c')
|
||||
.select('c.link_id', 'key')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('c.link_id IN (:...ids)', { ids: linkIds })
|
||||
.groupBy('c.link_id'),
|
||||
'c.clicked_at',
|
||||
period,
|
||||
).getRawMany<{ key: string; count: string }>(),
|
||||
withPeriod(
|
||||
this.conversions
|
||||
.createQueryBuilder('v')
|
||||
.select('v.link_id', 'key')
|
||||
.addSelect('v.type', 'type')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('v.link_id IN (:...ids)', { ids: linkIds })
|
||||
.andWhere('v.is_valid = true')
|
||||
.groupBy('v.link_id')
|
||||
.addGroupBy('v.type'),
|
||||
'v.converted_at',
|
||||
period,
|
||||
).getRawMany<{ key: string; type: ConversionType; count: string }>(),
|
||||
]);
|
||||
return this.merge(clickRows, conversionRows);
|
||||
}
|
||||
|
||||
/** 업체 ID → 실적 (모든 링크 합계) */
|
||||
async byPartners(
|
||||
partnerIds: string[],
|
||||
period: StatsPeriod,
|
||||
): Promise<Map<string, PartnerStatsDto>> {
|
||||
if (partnerIds.length === 0) return new Map();
|
||||
|
||||
const [clickRows, conversionRows] = await Promise.all([
|
||||
withPeriod(
|
||||
this.clicks
|
||||
.createQueryBuilder('c')
|
||||
.innerJoin(PartnerLink, 'l', 'l.id = c.link_id')
|
||||
.select('l.partner_id', 'key')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('l.partner_id IN (:...ids)', { ids: partnerIds })
|
||||
.groupBy('l.partner_id'),
|
||||
'c.clicked_at',
|
||||
period,
|
||||
).getRawMany<{ key: string; count: string }>(),
|
||||
withPeriod(
|
||||
this.conversions
|
||||
.createQueryBuilder('v')
|
||||
.select('v.partner_id', 'key')
|
||||
.addSelect('v.type', 'type')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('v.partner_id IN (:...ids)', { ids: partnerIds })
|
||||
.andWhere('v.is_valid = true')
|
||||
.groupBy('v.partner_id')
|
||||
.addGroupBy('v.type'),
|
||||
'v.converted_at',
|
||||
period,
|
||||
).getRawMany<{ key: string; type: ConversionType; count: string }>(),
|
||||
]);
|
||||
return this.merge(clickRows, conversionRows);
|
||||
}
|
||||
|
||||
/** 업체 ID → 발급된 링크 수 */
|
||||
async linkCounts(partnerIds: string[]): Promise<Map<string, number>> {
|
||||
if (partnerIds.length === 0) return new Map();
|
||||
const rows = await this.links
|
||||
.createQueryBuilder('l')
|
||||
.select('l.partner_id', 'key')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('l.partner_id IN (:...ids)', { ids: partnerIds })
|
||||
.groupBy('l.partner_id')
|
||||
.getRawMany<{ key: string; count: string }>();
|
||||
return new Map(rows.map((row) => [row.key, Number(row.count)]));
|
||||
}
|
||||
|
||||
private merge(
|
||||
clickRows: { key: string; count: string }[],
|
||||
conversionRows: { key: string; type: ConversionType; count: string }[],
|
||||
): Map<string, PartnerStatsDto> {
|
||||
const stats = new Map<string, PartnerStatsDto>();
|
||||
const get = (key: string): PartnerStatsDto => {
|
||||
let value = stats.get(key);
|
||||
if (!value) {
|
||||
value = { ...EMPTY_STATS };
|
||||
stats.set(key, value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
for (const row of clickRows) get(row.key).clickCount = Number(row.count);
|
||||
for (const row of conversionRows) {
|
||||
const value = get(row.key);
|
||||
if (row.type === ConversionType.APPLICATION) {
|
||||
value.applicationCount = Number(row.count);
|
||||
} else if (row.type === ConversionType.PURCHASE) {
|
||||
value.purchaseCount = Number(row.count);
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
static empty(): PartnerStatsDto {
|
||||
return { ...EMPTY_STATS };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { SkipTransform } from '@/common/decorators/skip-transform.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 {
|
||||
CreatePartnerDto,
|
||||
PartnerPeriodQueryDto,
|
||||
PartnerResponseDto,
|
||||
UpdatePartnerDto,
|
||||
} from './dto/partner.dto';
|
||||
import { PartnersService } from './partners.service';
|
||||
|
||||
/** 업체 관리 — 업체별 링크를 발급해 신청·구매를 추적한다 */
|
||||
@ApiTags('Admin Partners')
|
||||
@ApiBearerAuth(ADMIN_BEARER_AUTH)
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Controller('admin/partners')
|
||||
export class PartnersController {
|
||||
constructor(private readonly partners: PartnersService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '업체 목록 (최근 등록순) — 기간 내 실적 포함' })
|
||||
@ApiStandardResponse(PartnerResponseDto, {
|
||||
description: '조회 성공 — data 는 { items, pageInfo }',
|
||||
isArray: true,
|
||||
})
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('VAL_001', '기간·커서 형식 오류')
|
||||
findMany(
|
||||
@Query() query: PartnerPeriodQueryDto,
|
||||
): Promise<PaginatedResult<PartnerResponseDto>> {
|
||||
return this.partners.findMany(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '업체 등록' })
|
||||
@ApiStandardResponse(PartnerResponseDto, {
|
||||
status: 201,
|
||||
description: '등록 성공',
|
||||
})
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('VAL_001', '입력값 오류')
|
||||
@ApiErrorResponse('PARTNER_004', '같은 이름의 업체가 있음')
|
||||
create(@Body() dto: CreatePartnerDto): Promise<PartnerResponseDto> {
|
||||
return this.partners.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: '업체 이름·메모 수정' })
|
||||
@ApiStandardResponse(PartnerResponseDto, { description: '수정 성공' })
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('RES_001', '없는 업체')
|
||||
@ApiErrorResponse('PARTNER_004', '같은 이름의 업체가 있음')
|
||||
update(
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
@Body() dto: UpdatePartnerDto,
|
||||
): Promise<PartnerResponseDto> {
|
||||
return this.partners.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@SkipTransform()
|
||||
@ApiOperation({ summary: '업체 삭제 — 링크가 없는 업체만' })
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('RES_001', '없는 업체')
|
||||
@ApiErrorResponse('PARTNER_002', '링크가 발급된 업체')
|
||||
async remove(@Param('id', new ParseUUIDPipe()) id: string): Promise<void> {
|
||||
await this.partners.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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 { ImwebProduct } from '@/modules/imweb/entities/imweb-product.entity';
|
||||
import { ImwebModule } from '@/modules/imweb/imweb.module';
|
||||
import { MembersModule } from '@/modules/members/members.module';
|
||||
import { PartnerConversion } from './entities/partner-conversion.entity';
|
||||
import { PartnerLinkClick } from './entities/partner-link-click.entity';
|
||||
import { PartnerLink } from './entities/partner-link.entity';
|
||||
import { PartnerTouch } from './entities/partner-touch.entity';
|
||||
import { Partner } from './entities/partner.entity';
|
||||
import { PartnerAttributionService } from './partner-attribution.service';
|
||||
import {
|
||||
PartnerLinksController,
|
||||
PartnerOrderSyncController,
|
||||
} from './partner-links.controller';
|
||||
import { PartnerLinksService } from './partner-links.service';
|
||||
import { PartnerOrderSyncService } from './partner-order-sync.service';
|
||||
import {
|
||||
PartnerRedirectController,
|
||||
PartnerTouchesController,
|
||||
} from './partner-public.controller';
|
||||
import { PartnerStatsService } from './partner-stats.service';
|
||||
import { PartnersController } from './partners.controller';
|
||||
import { PartnersService } from './partners.service';
|
||||
|
||||
/**
|
||||
* 업체 링크 — 업체·상품별 단축 링크를 발급하고, 그 링크로 들어온 회원의 신청·구매를 센다.
|
||||
* 신청은 ApplicationsModule 이 PartnerAttributionService 를 불러 귀속하고,
|
||||
* 구매는 PartnerOrderSyncService 가 아임웹 주문을 주기적으로 훑어 찾는다.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Partner,
|
||||
PartnerLink,
|
||||
PartnerLinkClick,
|
||||
PartnerTouch,
|
||||
PartnerConversion,
|
||||
ImwebProduct,
|
||||
ImwebMember,
|
||||
]),
|
||||
AdminAuthModule,
|
||||
ImwebModule,
|
||||
MembersModule,
|
||||
],
|
||||
controllers: [
|
||||
PartnersController,
|
||||
PartnerLinksController,
|
||||
PartnerOrderSyncController,
|
||||
PartnerRedirectController,
|
||||
PartnerTouchesController,
|
||||
],
|
||||
providers: [
|
||||
PartnersService,
|
||||
PartnerLinksService,
|
||||
PartnerStatsService,
|
||||
PartnerAttributionService,
|
||||
PartnerOrderSyncService,
|
||||
],
|
||||
exports: [PartnerAttributionService],
|
||||
})
|
||||
export class PartnersModule {}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { QueryFailedError, Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
|
||||
import {
|
||||
CreatePartnerDto,
|
||||
PartnerPeriodQueryDto,
|
||||
PartnerResponseDto,
|
||||
UpdatePartnerDto,
|
||||
} from './dto/partner.dto';
|
||||
import { PartnerLink } from './entities/partner-link.entity';
|
||||
import { Partner } from './entities/partner.entity';
|
||||
import { PartnerStatsService } from './partner-stats.service';
|
||||
|
||||
const ALIAS = 'p';
|
||||
/** Postgres 유일 제약 위반 */
|
||||
const PG_UNIQUE_VIOLATION = '23505';
|
||||
|
||||
export function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof QueryFailedError &&
|
||||
(error.driverError as { code?: string } | undefined)?.code ===
|
||||
PG_UNIQUE_VIOLATION
|
||||
);
|
||||
}
|
||||
|
||||
/** 업체 관리 — 등록·수정·삭제와 업체별 실적 */
|
||||
@Injectable()
|
||||
export class PartnersService {
|
||||
private readonly logger = new Logger(PartnersService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Partner)
|
||||
private readonly partners: Repository<Partner>,
|
||||
@InjectRepository(PartnerLink)
|
||||
private readonly links: Repository<PartnerLink>,
|
||||
private readonly stats: PartnerStatsService,
|
||||
) {}
|
||||
|
||||
/** 업체 목록 — 최근 등록순, 기간 내 실적 포함 */
|
||||
async findMany(
|
||||
query: PartnerPeriodQueryDto,
|
||||
): Promise<PaginatedResult<PartnerResponseDto>> {
|
||||
const total = await this.partners.count();
|
||||
const page = this.partners
|
||||
.createQueryBuilder(ALIAS)
|
||||
.orderBy(`${ALIAS}.createdAt`, '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}.createdAt, ${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 ids = items.map((row) => row.id);
|
||||
const [statsById, linkCounts] = await Promise.all([
|
||||
this.stats.byPartners(ids, query),
|
||||
this.stats.linkCounts(ids),
|
||||
]);
|
||||
const last = items.at(-1);
|
||||
|
||||
return toPaginatedResult(
|
||||
items.map((row) =>
|
||||
PartnerResponseDto.from(
|
||||
row,
|
||||
linkCounts.get(row.id) ?? 0,
|
||||
statsById.get(row.id) ?? PartnerStatsService.empty(),
|
||||
),
|
||||
),
|
||||
{
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && last
|
||||
? encodeTimeCursor({
|
||||
time: last.createdAt.toISOString(),
|
||||
key: last.id,
|
||||
})
|
||||
: null,
|
||||
total,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async create(dto: CreatePartnerDto): Promise<PartnerResponseDto> {
|
||||
const row = this.partners.create({
|
||||
id: randomUUID(),
|
||||
name: dto.name.trim(),
|
||||
memo: dto.memo?.trim() || null,
|
||||
});
|
||||
await this.saveUnique(row);
|
||||
this.logger.log(`업체 등록: id=${row.id}`);
|
||||
return PartnerResponseDto.from(row);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdatePartnerDto): Promise<PartnerResponseDto> {
|
||||
const row = await this.require(id);
|
||||
if (dto.name !== undefined) row.name = dto.name.trim();
|
||||
if (dto.memo !== undefined) row.memo = dto.memo.trim() || null;
|
||||
await this.saveUnique(row);
|
||||
|
||||
const [statsById, linkCounts] = await Promise.all([
|
||||
this.stats.byPartners([id], {}),
|
||||
this.stats.linkCounts([id]),
|
||||
]);
|
||||
return PartnerResponseDto.from(
|
||||
row,
|
||||
linkCounts.get(id) ?? 0,
|
||||
statsById.get(id) ?? PartnerStatsService.empty(),
|
||||
);
|
||||
}
|
||||
|
||||
/** 링크가 하나라도 있으면 지우지 않는다 — 실적 기록이 업체를 가리키고 있다 */
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.require(id);
|
||||
const hasLinks = await this.links.exists({ where: { partnerId: id } });
|
||||
if (hasLinks) {
|
||||
throw new BusinessException('PARTNER_002', {
|
||||
context: { partnerId: id },
|
||||
});
|
||||
}
|
||||
await this.partners.delete(id);
|
||||
this.logger.log(`업체 삭제: id=${id}`);
|
||||
}
|
||||
|
||||
/** 업체 — 없으면 RES_001 */
|
||||
async require(id: string): Promise<Partner> {
|
||||
const row = await this.partners.findOne({ where: { id } });
|
||||
if (!row) {
|
||||
throw new BusinessException('RES_001', { context: { partnerId: id } });
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private async saveUnique(row: Partner): Promise<void> {
|
||||
try {
|
||||
await this.partners.save(row);
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new BusinessException('PARTNER_004', {
|
||||
context: { partnerId: row.id },
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,16 @@ describe('validateEnv', () => {
|
||||
).toThrow(/IMWEB_ADMIN_PAGE_URL 가 운영에서 쓸 수 없는 주소입니다/);
|
||||
});
|
||||
|
||||
it('아임웹 사이트 주소는 선택이지만, 주면 공개 주소여야 한다', () => {
|
||||
expect(() => validateEnv(prod())).not.toThrow();
|
||||
expect(() =>
|
||||
validateEnv(prod({ IMWEB_SITE_URL: 'https://www.moneyguider.co.kr' })),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateEnv(prod({ IMWEB_SITE_URL: 'http://localhost:5173' })),
|
||||
).toThrow(/IMWEB_SITE_URL 가 운영에서 쓸 수 없는 주소입니다/);
|
||||
});
|
||||
|
||||
it('아임웹 동기화 주기는 양의 정수여야 한다', () => {
|
||||
expect(() =>
|
||||
validateEnv({
|
||||
|
||||
@@ -59,6 +59,8 @@ const PUBLIC_URL_IN_PRODUCTION = [
|
||||
'BACKEND_API_URL',
|
||||
// 아임웹 OAuth 가 끝나면 브라우저를 이 주소로 돌려보낸다
|
||||
'IMWEB_ADMIN_PAGE_URL',
|
||||
// (선택) 업체 링크의 이동 주소를 허용할 사이트 — 비우면 위 주소의 origin
|
||||
'IMWEB_SITE_URL',
|
||||
] as const;
|
||||
|
||||
/** 공개 주소로 쓸 수 없는 값 — 로컬 주소와 예시 파일의 예시 도메인 */
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
/**
|
||||
* 회원 유입경로 파싱 유틸 (순수 함수).
|
||||
*
|
||||
* 아임웹 Header Code 의 UTM 스크립트(header-top.html)가 회원가입 폼의 '홈페이지' 항목에
|
||||
* 아임웹 Header Code 의 UTM 스크립트(imweb/imweb-header-top.html)가 회원가입 폼의 '홈페이지' 항목에
|
||||
* 아래 형식으로 유입정보를 기록한다. 아임웹 Open API 회원 조회에는 사용자 정의 가입 항목이
|
||||
* 나오지 않고 homePage 만 나오기 때문에 이 값을 풀어서 쓴다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { evaluateOrder, pickLastTouch } from './partner-attribution';
|
||||
|
||||
const at = (iso: string) => new Date(iso);
|
||||
|
||||
describe('pickLastTouch', () => {
|
||||
const convertedAt = at('2026-10-31T12:00:00.000Z');
|
||||
|
||||
it('전환 직전 30일 안의 클릭 중 가장 최근 것을 고른다 (마지막 클릭)', () => {
|
||||
const touches = [
|
||||
{ linkId: 'A', clickedAt: at('2026-10-10T00:00:00.000Z') },
|
||||
{ linkId: 'B', clickedAt: at('2026-10-20T00:00:00.000Z') },
|
||||
{ linkId: 'A', clickedAt: at('2026-10-15T00:00:00.000Z') },
|
||||
];
|
||||
expect(pickLastTouch(touches, convertedAt)?.linkId).toBe('B');
|
||||
});
|
||||
|
||||
it('전환 뒤에 누른 클릭은 원인이 아니므로 제외한다', () => {
|
||||
const touches = [
|
||||
{ linkId: 'A', clickedAt: at('2026-10-20T00:00:00.000Z') },
|
||||
{ linkId: 'B', clickedAt: at('2026-11-01T00:00:00.000Z') },
|
||||
];
|
||||
expect(pickLastTouch(touches, convertedAt)?.linkId).toBe('A');
|
||||
});
|
||||
|
||||
it('30일보다 오래된 클릭은 인정하지 않는다 (경계 포함 여부)', () => {
|
||||
expect(
|
||||
pickLastTouch(
|
||||
[{ linkId: 'A', clickedAt: at('2026-10-01T12:00:00.000Z') }],
|
||||
convertedAt,
|
||||
)?.linkId,
|
||||
).toBe('A'); // 정확히 30일 전은 인정
|
||||
expect(
|
||||
pickLastTouch(
|
||||
[{ linkId: 'A', clickedAt: at('2026-10-01T11:59:59.999Z') }],
|
||||
convertedAt,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('클릭이 없으면 null', () => {
|
||||
expect(pickLastTouch([], convertedAt)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateOrder', () => {
|
||||
const item = (prodNo: number, isPromotion = 'N') => ({
|
||||
isPromotion,
|
||||
productInfo: { prodNo },
|
||||
});
|
||||
|
||||
it('결제완료 주문의 상품을 구매로 센다', () => {
|
||||
const result = evaluateOrder({
|
||||
payments: [{ paymentStatus: 'PAYMENT_COMPLETE', isCancel: 'N' }],
|
||||
sections: [
|
||||
{
|
||||
orderSectionStatus: 'PRODUCT_PREPARATION',
|
||||
sectionItems: [item(52), item(60)],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.isPaid).toBe(true);
|
||||
expect(result.products).toEqual(
|
||||
new Map([
|
||||
[52, true],
|
||||
[60, true],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('입금 전·결제 실패·전액 환불이면 세지 않는다', () => {
|
||||
for (const paymentStatus of [
|
||||
'PAYMENT_PREPARATION',
|
||||
'PAYMENT_FAILED',
|
||||
'REFUND_COMPLETE',
|
||||
]) {
|
||||
const result = evaluateOrder({
|
||||
payments: [{ paymentStatus, isCancel: 'N' }],
|
||||
sections: [
|
||||
{ orderSectionStatus: 'SHIPPING', sectionItems: [item(52)] },
|
||||
],
|
||||
});
|
||||
expect(result.isPaid).toBe(false);
|
||||
expect(result.products.get(52)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('취소 결제 기록은 결제완료로 보지 않는다', () => {
|
||||
const result = evaluateOrder({
|
||||
payments: [{ paymentStatus: 'PAYMENT_COMPLETE', isCancel: 'Y' }],
|
||||
sections: [{ sectionItems: [item(52)] }],
|
||||
});
|
||||
expect(result.isPaid).toBe(false);
|
||||
});
|
||||
|
||||
it('부분 환불 주문은 취소·반품된 상품만 빠진다', () => {
|
||||
const result = evaluateOrder({
|
||||
payments: [{ paymentStatus: 'PARTIAL_REFUND_COMPLETE' }],
|
||||
sections: [
|
||||
{ orderSectionStatus: 'CANCEL_COMPLETE', sectionItems: [item(52)] },
|
||||
{ orderSectionStatus: 'SHIPPING_COMPLETE', sectionItems: [item(60)] },
|
||||
],
|
||||
});
|
||||
expect(result.products.get(52)).toBe(false);
|
||||
expect(result.products.get(60)).toBe(true);
|
||||
});
|
||||
|
||||
it('같은 상품이 여러 섹션에 있으면 하나라도 살아 있으면 구매로 본다', () => {
|
||||
const result = evaluateOrder({
|
||||
payments: [{ paymentStatus: 'PAYMENT_COMPLETE' }],
|
||||
sections: [
|
||||
{ orderSectionStatus: 'RETURN_COMPLETE', sectionItems: [item(52)] },
|
||||
{ orderSectionStatus: 'SHIPPING', sectionItems: [item(52)] },
|
||||
],
|
||||
});
|
||||
expect(result.products.get(52)).toBe(true);
|
||||
});
|
||||
|
||||
it('사은품 품목과 상품번호 없는 품목은 넣지 않는다', () => {
|
||||
const result = evaluateOrder({
|
||||
payments: [{ paymentStatus: 'PAYMENT_COMPLETE' }],
|
||||
sections: [
|
||||
{
|
||||
sectionItems: [item(99, 'Y'), { productInfo: null }, item(52)],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect([...result.products.keys()]).toEqual([52]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 업체 링크 실적 귀속 규칙 (순수 함수).
|
||||
*
|
||||
* - 마지막 클릭: 신청·구매 직전 30일 안에 누른 그 상품의 업체 링크 중 가장 최근 것에 귀속한다.
|
||||
* - 구매: 결제완료된 주문만 센다. 전액 환불됐거나 해당 상품 품목이 모두 취소·반품되면 빠진다.
|
||||
* (금액은 관리하지 않는다 — 건수만 센다)
|
||||
*/
|
||||
|
||||
/** 클릭 후 이 기간 안에 신청·구매해야 업체 실적으로 인정한다 */
|
||||
export const ATTRIBUTION_WINDOW_DAYS = 30;
|
||||
const DAY_MS = 86_400_000;
|
||||
export const ATTRIBUTION_WINDOW_MS = ATTRIBUTION_WINDOW_DAYS * DAY_MS;
|
||||
|
||||
export interface TouchCandidate {
|
||||
linkId: string;
|
||||
clickedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 귀속할 클릭을 고른다 — 전환 시각 이전 30일 안의 클릭 중 가장 최근 것. 없으면 null.
|
||||
* 전환 뒤에 누른 클릭은 그 전환의 원인이 아니므로 제외한다.
|
||||
*/
|
||||
export function pickLastTouch<T extends TouchCandidate>(
|
||||
touches: readonly T[],
|
||||
convertedAt: Date,
|
||||
): T | null {
|
||||
const end = convertedAt.getTime();
|
||||
const start = end - ATTRIBUTION_WINDOW_MS;
|
||||
let picked: T | null = null;
|
||||
|
||||
for (const touch of touches) {
|
||||
const time = touch.clickedAt.getTime();
|
||||
if (time > end || time < start) continue;
|
||||
if (!picked || time > picked.clickedAt.getTime()) picked = touch;
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
/** 결제가 끝난 것으로 보는 결제 상태 (부분 환불은 아직 결제된 주문이다) */
|
||||
const PAID_PAYMENT_STATUSES = new Set([
|
||||
'PAYMENT_COMPLETE',
|
||||
'PARTIAL_REFUND_COMPLETE',
|
||||
]);
|
||||
|
||||
/** 이 섹션의 품목은 더 이상 구매로 보지 않는다 */
|
||||
const CANCELLED_SECTION_STATUSES = new Set([
|
||||
'CANCEL_COMPLETE',
|
||||
'RETURN_COMPLETE',
|
||||
]);
|
||||
|
||||
/** 판정에 필요한 주문 필드만 — 아임웹 GET /orders 의 OrderDataDto 부분 */
|
||||
export interface OrderForAttribution {
|
||||
payments?: { paymentStatus?: string | null; isCancel?: string | null }[];
|
||||
sections?: {
|
||||
orderSectionStatus?: string | null;
|
||||
sectionItems?: {
|
||||
isPromotion?: string | null;
|
||||
productInfo?: { prodNo?: number | null } | null;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface OrderEvaluation {
|
||||
/** 결제완료(부분 환불 포함)된 주문인가 */
|
||||
isPaid: boolean;
|
||||
/**
|
||||
* 주문에 든 상품번호 → 지금 구매로 셀 수 있는가.
|
||||
* 결제가 안 됐거나, 그 상품 품목이 모두 취소·반품됐으면 false.
|
||||
* 사은품 품목은 구매가 아니므로 넣지 않는다.
|
||||
*/
|
||||
products: Map<number, boolean>;
|
||||
}
|
||||
|
||||
/** 주문 한 건을 상품별로 판정한다 */
|
||||
export function evaluateOrder(order: OrderForAttribution): OrderEvaluation {
|
||||
const isPaid = (order.payments ?? []).some(
|
||||
(payment) =>
|
||||
payment.isCancel !== 'Y' &&
|
||||
PAID_PAYMENT_STATUSES.has(payment.paymentStatus ?? ''),
|
||||
);
|
||||
|
||||
const products = new Map<number, boolean>();
|
||||
for (const section of order.sections ?? []) {
|
||||
const isCancelled = CANCELLED_SECTION_STATUSES.has(
|
||||
section.orderSectionStatus ?? '',
|
||||
);
|
||||
for (const item of section.sectionItems ?? []) {
|
||||
const prodNo = item.productInfo?.prodNo;
|
||||
if (typeof prodNo !== 'number' || item.isPromotion === 'Y') continue;
|
||||
// 같은 상품이 여러 섹션에 나뉘어 있으면 하나라도 살아 있으면 구매로 본다
|
||||
const counts = isPaid && !isCancelled;
|
||||
products.set(prodNo, (products.get(prodNo) ?? false) || counts);
|
||||
}
|
||||
}
|
||||
return { isPaid, products };
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
isBotUserAgent,
|
||||
normalizeTargetUrl,
|
||||
toBase62,
|
||||
withPartnerRef,
|
||||
} from './partner-link';
|
||||
|
||||
const SITE = 'https://www.moneyguider.co.kr';
|
||||
|
||||
describe('normalizeTargetUrl', () => {
|
||||
it('사이트 안의 주소는 그대로 받는다', () => {
|
||||
expect(
|
||||
normalizeTargetUrl(
|
||||
'https://www.moneyguider.co.kr/free-shop/?idx=52',
|
||||
SITE,
|
||||
),
|
||||
).toBe('https://www.moneyguider.co.kr/free-shop/?idx=52');
|
||||
});
|
||||
|
||||
it('www 유무만 다른 주소도 같은 사이트로 본다', () => {
|
||||
expect(
|
||||
normalizeTargetUrl('https://moneyguider.co.kr/shop/?idx=60', SITE),
|
||||
).toBe('https://moneyguider.co.kr/shop/?idx=60');
|
||||
});
|
||||
|
||||
it('다른 사이트·비슷한 도메인은 거부한다 (open redirect 방지)', () => {
|
||||
expect(normalizeTargetUrl('https://evil.com/?idx=52', SITE)).toBeNull();
|
||||
expect(
|
||||
normalizeTargetUrl('https://moneyguider.co.kr.evil.com/', SITE),
|
||||
).toBeNull();
|
||||
expect(
|
||||
normalizeTargetUrl('https://www.moneyguider.co.kr@evil.com/', SITE),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('http(s) 가 아니거나 주소 형식이 아니면 거부한다', () => {
|
||||
expect(normalizeTargetUrl('javascript:alert(1)', SITE)).toBeNull();
|
||||
expect(normalizeTargetUrl('/free-shop/?idx=52', SITE)).toBeNull();
|
||||
expect(normalizeTargetUrl('', SITE)).toBeNull();
|
||||
});
|
||||
|
||||
it('이전 클릭 ID 와 해시는 지운다', () => {
|
||||
expect(
|
||||
normalizeTargetUrl(
|
||||
'https://www.moneyguider.co.kr/shop/?idx=52&mg_ref=abc#top',
|
||||
SITE,
|
||||
),
|
||||
).toBe('https://www.moneyguider.co.kr/shop/?idx=52');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withPartnerRef', () => {
|
||||
it('기존 파라미터를 유지한 채 클릭 ID 를 붙인다', () => {
|
||||
expect(
|
||||
withPartnerRef(
|
||||
'https://www.moneyguider.co.kr/shop/?idx=52',
|
||||
'AbCd1234EfGh5678',
|
||||
),
|
||||
).toBe(
|
||||
'https://www.moneyguider.co.kr/shop/?idx=52&mg_ref=AbCd1234EfGh5678',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBotUserAgent', () => {
|
||||
it('링크 미리보기·검색봇은 사람으로 세지 않는다', () => {
|
||||
expect(
|
||||
isBotUserAgent('kakaotalk-scrap/1.0; +https://devtalk.kakao.com/'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isBotUserAgent(
|
||||
'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(isBotUserAgent('Mozilla/5.0 (compatible; Googlebot/2.1)')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isBotUserAgent(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('카카오톡 인앱 브라우저 등 실제 사용자는 센다', () => {
|
||||
expect(
|
||||
isBotUserAgent(
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 KAKAOTALK 10.4.5',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isBotUserAgent(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toBase62', () => {
|
||||
it('바이트 수만큼 영숫자 글자를 만든다', () => {
|
||||
const code = toBase62(new Uint8Array([0, 61, 62, 255]));
|
||||
expect(code).toMatch(/^[0-9A-Za-z]{4}$/);
|
||||
expect(code[0]).toBe('0');
|
||||
expect(code[1]).toBe('z');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 업체 링크 유틸 (순수 함수) — 이동 주소 검증, 클릭 ID 부착, 봇 판별.
|
||||
*
|
||||
* 업체에 주는 단축 링크(/r/{코드})를 누르면 서버가 클릭마다 고유 ID 를 발급해
|
||||
* 상품 페이지 주소에 `mg_ref` 로 붙여 보낸다. 아임웹 Header Code 스크립트(imweb/imweb-header-top.html 의 업체 링크 추적)가
|
||||
* 이 값을 브라우저에 보관했다가, 로그인한 회원이 확인되면 서버에 알려 회원과 클릭을 잇는다.
|
||||
*/
|
||||
|
||||
/** 상품 페이지 주소에 붙이는 클릭 ID 파라미터 — imweb/imweb-header-top.html 과 같아야 한다 */
|
||||
export const PARTNER_REF_PARAM = 'mg_ref';
|
||||
|
||||
/** 업체 링크 코드 — /r/{코드} */
|
||||
export const LINK_CODE_LENGTH = 8;
|
||||
export const LINK_CODE_PATTERN = /^[0-9A-Za-z]{8}$/;
|
||||
|
||||
/** 클릭 ID — 추측할 수 없게 링크 코드보다 길게 둔다 */
|
||||
export const CLICK_REF_LENGTH = 16;
|
||||
export const CLICK_REF_PATTERN = /^[0-9A-Za-z]{16}$/;
|
||||
|
||||
const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
/** 난수 바이트를 base62 문자열로 바꾼다 (바이트 수 = 글자 수) */
|
||||
export function toBase62(bytes: Uint8Array): string {
|
||||
let result = '';
|
||||
for (const byte of bytes) result += BASE62[byte % BASE62.length];
|
||||
return result;
|
||||
}
|
||||
|
||||
const WWW_PREFIX = 'www.';
|
||||
|
||||
/** www 유무만 다른 같은 사이트로 본다 (moneyguider.co.kr ↔ www.moneyguider.co.kr) */
|
||||
function bareHost(host: string): string {
|
||||
const lower = host.toLowerCase();
|
||||
return lower.startsWith(WWW_PREFIX) ? lower.slice(WWW_PREFIX.length) : lower;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이동 주소를 검증하고 정리한다. 우리 사이트 밖이면 null.
|
||||
*
|
||||
* 단축 링크가 아무 주소로나 보낼 수 있으면 우리 도메인을 빌린 피싱 링크가 된다(open redirect).
|
||||
* 그래서 아임웹 사이트(www 유무 무관) 안의 주소만 받는다.
|
||||
* 이전에 붙은 클릭 ID 는 지운다 — 리다이렉트할 때 새로 붙인다.
|
||||
*/
|
||||
export function normalizeTargetUrl(
|
||||
raw: string,
|
||||
siteUrl: string,
|
||||
): string | null {
|
||||
let target: URL;
|
||||
let site: URL;
|
||||
try {
|
||||
target = new URL(raw.trim());
|
||||
site = new URL(siteUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (target.protocol !== 'https:' && target.protocol !== 'http:') return null;
|
||||
if (target.username || target.password) return null;
|
||||
if (bareHost(target.host) !== bareHost(site.host)) return null;
|
||||
|
||||
target.searchParams.delete(PARTNER_REF_PARAM);
|
||||
target.hash = '';
|
||||
return target.toString();
|
||||
}
|
||||
|
||||
/** 이동 주소에 클릭 ID 를 붙인다 */
|
||||
export function withPartnerRef(targetUrl: string, clickRef: string): string {
|
||||
const url = new URL(targetUrl);
|
||||
url.searchParams.set(PARTNER_REF_PARAM, clickRef);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사람이 아닌 요청 — 링크 미리보기(카카오톡·페이스북 등)와 검색봇.
|
||||
* 업체가 카톡방에 링크를 올리면 미리보기용 요청이 먼저 오므로 세지 않는다.
|
||||
* 카카오톡 인앱 브라우저(KAKAOTALK)는 실제 사용자라 걸리지 않게 'kakaotalk-scrap' 만 본다.
|
||||
*/
|
||||
const BOT_USER_AGENT_PATTERN =
|
||||
/bot|crawl|spider|slurp|facebookexternalhit|kakaotalk-scrap|whatsapp|headlesschrome|curl\/|wget\/|python-requests|go-http-client|axios\//i;
|
||||
|
||||
export function isBotUserAgent(userAgent: string | undefined): boolean {
|
||||
if (!userAgent || userAgent.trim() === '') return true;
|
||||
return BOT_USER_AGENT_PATTERN.test(userAgent);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
# ----------------------------------------
|
||||
# frontend — dev 에서는 기본 기동하지 않는다 (profile 로 숨김)
|
||||
# frontend — 기본 기동하지 않는다 (profile 은 base 에서 지정 — docker-compose.yml)
|
||||
#
|
||||
# vite 를 컨테이너에서 돌리면 소스가 Windows 바인드 마운트(./frontend:/app) 너머에 있어
|
||||
# 수천 개 파일을 매 요청마다 가상 파일시스템으로 읽는다. 여기에 폴링 감시까지 겹쳐
|
||||
@@ -14,7 +14,6 @@ services:
|
||||
# 또는 루트 .env.development 에 COMPOSE_PROFILES=frontend
|
||||
# ----------------------------------------
|
||||
frontend:
|
||||
profiles: ["frontend"]
|
||||
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (수동 up 때만 실행)
|
||||
restart: "no"
|
||||
build:
|
||||
|
||||
+8
-1
@@ -1,8 +1,14 @@
|
||||
services:
|
||||
# ----------------------------------------
|
||||
# 1. Frontend (Nginx 정적 호스팅)
|
||||
# 1. Frontend (Nginx 정적 호스팅) · 기본 비활성
|
||||
#
|
||||
# 관리자 화면은 아임웹 코드 위젯(imweb/imweb-members.html)으로 제공하므로
|
||||
# Vue 프론트엔드는 개발·운영 모두 빌드·기동하지 않는다.
|
||||
# 루트 .env.{APP_ENV} 에서 COMPOSE_PROFILES=frontend
|
||||
# 또는 일회성으로 docker compose --profile frontend up -d
|
||||
# ----------------------------------------
|
||||
frontend:
|
||||
profiles: ["frontend"]
|
||||
container_name: ${PROJECT_NAME}-frontend
|
||||
build:
|
||||
context: ./${FRONTEND_DIR}
|
||||
@@ -73,6 +79,7 @@ services:
|
||||
# 아임웹 연동 — 비밀값(IMWEB_CLIENT_ID/SECRET)은 env_file(backend/.env.*)로 들어온다
|
||||
- IMWEB_SITE_CODE=${IMWEB_SITE_CODE:-}
|
||||
- IMWEB_ADMIN_PAGE_URL=${IMWEB_ADMIN_PAGE_URL:-}
|
||||
- IMWEB_SITE_URL=${IMWEB_SITE_URL:-}
|
||||
- IMWEB_SYNC_INTERVAL_MINUTES=${IMWEB_SYNC_INTERVAL_MINUTES:-60}
|
||||
- IMWEB_PAGE_SIZE=${IMWEB_PAGE_SIZE:-100}
|
||||
- REDIS_HOST=redis
|
||||
|
||||
@@ -16,7 +16,12 @@ const Map<String, String> kErrorCodeMessages = {
|
||||
'IMWEB_003': '아임웹 연결 요청이 만료되었거나 올바르지 않습니다. 처음부터 다시 연결해 주세요.',
|
||||
'IMWEB_004': '회원 동기화가 이미 진행 중입니다. 잠시 후 다시 확인해 주세요.',
|
||||
'IMWEB_005': '아임웹 연동 설정이 완료되지 않았습니다. 관리자에게 문의해 주세요.',
|
||||
'IMWEB_006': '아임웹 권한이 부족합니다. 연동 관리에서 아임웹 연결을 다시 해 주세요.',
|
||||
'APPLY_001': '회원 정보를 확인할 수 없어 신청을 기록하지 못했습니다.',
|
||||
'PARTNER_001': '이 업체에는 같은 상품의 링크가 이미 있습니다.',
|
||||
'PARTNER_002': '링크가 발급된 업체는 삭제할 수 없습니다. 실적 기록을 지키기 위해 링크를 중지해 주세요.',
|
||||
'PARTNER_003': '이동 주소는 우리 아임웹 사이트 안의 주소여야 합니다.',
|
||||
'PARTNER_004': '같은 이름의 업체가 이미 있습니다.',
|
||||
};
|
||||
|
||||
/// 매핑 실패 시 사용하는 폴백 코드
|
||||
|
||||
@@ -16,7 +16,12 @@ export const ERROR_CODE_MESSAGES = {
|
||||
IMWEB_003: '아임웹 연결 요청이 만료되었거나 올바르지 않습니다. 처음부터 다시 연결해 주세요.',
|
||||
IMWEB_004: '회원 동기화가 이미 진행 중입니다. 잠시 후 다시 확인해 주세요.',
|
||||
IMWEB_005: '아임웹 연동 설정이 완료되지 않았습니다. 관리자에게 문의해 주세요.',
|
||||
IMWEB_006: '아임웹 권한이 부족합니다. 연동 관리에서 아임웹 연결을 다시 해 주세요.',
|
||||
APPLY_001: '회원 정보를 확인할 수 없어 신청을 기록하지 못했습니다.',
|
||||
PARTNER_001: '이 업체에는 같은 상품의 링크가 이미 있습니다.',
|
||||
PARTNER_002: '링크가 발급된 업체는 삭제할 수 없습니다. 실적 기록을 지키기 위해 링크를 중지해 주세요.',
|
||||
PARTNER_003: '이동 주소는 우리 아임웹 사이트 안의 주소여야 합니다.',
|
||||
PARTNER_004: '같은 이름의 업체가 이미 있습니다.',
|
||||
} as const
|
||||
|
||||
export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES
|
||||
|
||||
+77
-10
@@ -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`
|
||||
@@ -28,8 +40,10 @@
|
||||
2. 아임웹 개발자센터 앱 설정
|
||||
- redirect URI: `http://localhost:3100/api/imweb/oauth/callback`
|
||||
(= 루트 `BACKEND_API_URL` + `/imweb/oauth/callback`, 정확히 일치해야 한다)
|
||||
- 권한: `사이트 정보 읽기∙쓰기(site-info:write)`, `회원 정보 읽기(member-info:read)`
|
||||
(site-info:write 는 아임웹이 연동 때 필수로 요구한다 — 없으면 errorCode 30156)
|
||||
- 권한: `사이트 정보 읽기∙쓰기(site-info:write)`, `회원 정보 읽기(member-info:read)`,
|
||||
`상품 읽기(product:read)`, `주문 읽기(order:read)`
|
||||
(백엔드가 이 네 가지를 요청한다 — 앱 설정에 하나라도 없으면 연결이 errorCode 30156 으로 거부된다.
|
||||
site-info:write 는 아임웹이 연동 때 필수로 요구한다)
|
||||
3. 백엔드 기동·마이그레이션
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
|
||||
@@ -60,10 +74,21 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
|
||||
|
||||
## 강의 신청 기록 (신청 완료 화면 → 신청 내역)
|
||||
|
||||
아임웹 `/apply` 페이지의 신청 완료 위젯(`money/product-free-complete.html`)이 표시될 때
|
||||
아임웹 `/apply` 페이지의 신청 완료 위젯(`imweb-free-apply.html`)이 표시될 때
|
||||
`POST /api/applications` 로 신청을 남긴다. 관리자 화면 **신청 내역** 메뉴에서 본다.
|
||||
|
||||
- 보내는 값: `MEMBER_UID`(회원 아이디 또는 회원 코드), 상품번호(`idx`), 상품명, 그 순간의 유입정보(`MG_UTM.peek()`)
|
||||
- 보내는 값: `MEMBER_UID`(회원 아이디 또는 회원 코드), 상품번호(`idx`), 상품명,
|
||||
최초 유입(`MG_UTM.peek().value`), 최종 유입(`MG_UTM.peek().last` 를 같은 파이프 형식으로),
|
||||
업체 링크 클릭 ID(`MG_PARTNER.refs()`) — `peek()` 는 문자열이 아니라 객체를 돌려준다
|
||||
- **가입 경로와 신청 유입은 따로 남는다** — 관리자 화면 신청 내역의 '신청 유입 집계'에서 기준을 바꿔 비교한다
|
||||
|
||||
| 기준 | 값 | 예) 3주 전 네이버로 가입 → 어제 블로그로 방문 → 오늘 페이스북 광고로 와서 신청 |
|
||||
| --- | --- | --- |
|
||||
| 신청 때 (최종) | 신청 직전에 마지막으로 들어온 경로 — 신청을 일으킨 유입 | facebook |
|
||||
| 신청 때 (최초) | 신청 시점 브라우저에 남아 있던 첫 경로 (UTM 스크립트 보관 2일) | blog |
|
||||
| 가입 경로 | 그 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외) | naver |
|
||||
|
||||
유입이 한 번뿐이면 최초 = 최종이다. 다른 기기에서 신청하면 신청 유입은 direct 로 남는다.
|
||||
- 백엔드는 아임웹에 실제로 있는 회원인지 확인하고(동기화 전 신규 가입자는 아임웹 API 로 직접 확인),
|
||||
회원·상품당 한 건만 남긴다. 이름·연락처는 브라우저 값이 아니라 아임웹 회원 정보를 쓴다
|
||||
- 오픈카톡 버튼 클릭은 `POST /api/applications/{id}/kakao-clicks` 로 처음 한 번만 남는다
|
||||
@@ -72,9 +97,49 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
|
||||
- 한계: 신청 완료 화면은 공개 페이지라 "실제로 그 회원이 보냈는지"는 확인할 수 없다
|
||||
(있는 회원인지만 확인). 무료 신청 기록 용도라 이 수준으로 둔다
|
||||
|
||||
## 업체 링크 (업체별 신청·구매 추적)
|
||||
|
||||
상품마다 업체에 단축 링크를 발급하고, 그 링크로 들어온 회원의 신청·구매를 업체별로 센다.
|
||||
관리자 화면 **업체 링크** 메뉴에서 업체 추가 → 링크 발급 → 실적 확인.
|
||||
|
||||
```
|
||||
업체에 링크 전달 ── https://moneyguide.wandergo.net/r/{코드}
|
||||
│ ① 클릭 기록 (카톡 미리보기·검색봇 제외) 후 상품 페이지로 이동하며 클릭 ID(mg_ref)를 붙인다
|
||||
▼
|
||||
상품 페이지 ── imweb-header-top.html(Header Code)의 업체 링크 추적이 클릭 ID 를 30일 보관하고 주소창에서 지운다
|
||||
│ (UTM 스크립트처럼 localStorage + .moneyguider.co.kr 쿠키 — www ↔ apex 이동에도 유지)
|
||||
│ ② 로그인 회원이 확인되면 "이 회원이 이 클릭으로 들어왔다"를 POST /api/partner-touches 로 알린다
|
||||
▼
|
||||
신청: 신청 완료 위젯이 신청 기록에 클릭 ID 를 함께 보낸다 → 바로 귀속
|
||||
구매: 서버가 1시간마다 최근 60일 아임웹 주문(회원 주문)을 훑어 결제완료 상품을 귀속, 환불·취소는 집계에서 뺀다
|
||||
```
|
||||
|
||||
- **귀속 규칙**: 신청·구매 직전 30일 안에 누른 **그 상품의** 업체 링크 중 마지막 것 (마지막 클릭)
|
||||
- **구매 수**: 결제완료 주문만 센다. 전액 환불되거나 해당 상품 품목이 모두 취소·반품되면 빠진다. 금액은 관리하지 않는다
|
||||
- **링크는 지우지 않고 중지**한다 — 중지해도 상품 페이지로 이동은 되지만 클릭·실적은 쌓이지 않는다.
|
||||
링크가 있는 업체는 삭제할 수 없다 (실적 기록 보존)
|
||||
- 이동 주소는 아임웹 사이트(www 유무 무관) 안만 허용한다 — 단축 링크가 피싱에 쓰이지 않게.
|
||||
사이트 주소는 `IMWEB_SITE_URL`, 비우면 `IMWEB_ADMIN_PAGE_URL` 의 origin
|
||||
- 한계
|
||||
- 링크를 누른 브라우저와 신청·구매한 브라우저가 다르면(휴대폰 → PC) 추적되지 않는다
|
||||
- 비회원 주문은 회원과 이을 수 없어 세지 않는다
|
||||
- 공개 페이지에서 오는 기록이라 위조를 완전히 막을 수는 없다 (실제 아임웹 회원·실제 클릭인지만 확인)
|
||||
- 60일보다 오래된 구매의 환불은 반영되지 않는다
|
||||
|
||||
### 처음 켤 때
|
||||
|
||||
1. 아임웹 개발자센터 앱 권한에 `상품 읽기`·`주문 읽기` 추가 (위 '로컬에서 띄우기' 2번)
|
||||
2. 백엔드 배포 (마이그레이션 자동 실행)
|
||||
3. 관리자 화면 **연동 관리 → 아임웹 연결**을 다시 한다 — 새 권한이 담긴 토큰을 받는다.
|
||||
다시 연결하기 전에는 상품 불러오기·구매 집계가 `IMWEB_006`(권한 부족)으로 실패한다. 회원 동기화는 그대로 돈다
|
||||
4. `imweb-header-top.html` 을 아임웹 **Header Code** 에 다시 붙여 넣는다 (UTM 스크립트 아래에 업체 링크 추적이 추가됨).
|
||||
`imweb-header.html`(로그인 링크 UTM 보존)은 바꾸지 않았다
|
||||
5. `imweb-free-apply.html` 을 신청 완료 위젯에 다시 붙여 넣는다 (신청 기록·클릭 ID 전송 추가됨)
|
||||
6. 업체 링크 메뉴에서 **상품 불러오기** → 업체 추가 → 링크 발급
|
||||
|
||||
## 인증 방식 — 왜 쿠키가 아닌가
|
||||
|
||||
백엔드(`moneyguider.wandergo.net`)와 관리자 화면(`www.moneyguider.co.kr`)이 **다른 사이트**라
|
||||
백엔드(`moneyguide.wandergo.net`)와 관리자 화면(`www.moneyguider.co.kr`)이 **다른 사이트**라
|
||||
인증 쿠키는 제3자 쿠키가 되어 Safari·Chrome 시크릿 모드 등에서 차단된다. 그래서
|
||||
|
||||
- 로그인 응답으로 토큰을 받아 **sessionStorage** 에 두고 `Authorization: Bearer` 로 보낸다 (탭을 닫으면 사라짐)
|
||||
@@ -95,17 +160,19 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
|
||||
```
|
||||
|
||||
개발자센터 앱 설정의 redirect URI 를 `BACKEND_API_URL + /imweb/oauth/callback` 과 **글자 하나까지** 같게 등록한다.
|
||||
(로컬 `http://localhost:3100/api/imweb/oauth/callback`, 운영 `https://moneyguider.wandergo.net/api/imweb/oauth/callback`)
|
||||
(로컬 `http://localhost:3100/api/imweb/oauth/callback`, 운영 `https://moneyguide.wandergo.net/api/imweb/oauth/callback`)
|
||||
|
||||
## 운영 배포 시
|
||||
|
||||
- 루트 `.env.production`
|
||||
- `BACKEND_API_URL=https://moneyguider.wandergo.net/api`
|
||||
- `BACKEND_API_URL=https://moneyguide.wandergo.net/api`
|
||||
- `CORS_ORIGIN=https://www.moneyguider.co.kr,https://moneyguider.co.kr`
|
||||
- `IMWEB_ADMIN_PAGE_URL=` 아임웹 관리자 페이지 실제 주소
|
||||
- 아임웹 개발자센터 redirect URI 를 `https://moneyguider.wandergo.net/api/imweb/oauth/callback` 으로 등록
|
||||
- `imweb-members.html` 의 `API_BASE` 운영 주소(`https://moneyguider.wandergo.net/api`)를 확인하고 아임웹 코드 위젯에 붙여 넣는다
|
||||
- 아임웹 개발자센터 redirect URI 를 `https://moneyguide.wandergo.net/api/imweb/oauth/callback` 으로 등록
|
||||
- `imweb-members.html` 의 `API_BASE` 운영 주소(`https://moneyguide.wandergo.net/api`)를 확인하고 아임웹 코드 위젯에 붙여 넣는다
|
||||
- 아임웹에서 그 페이지의 접근 권한을 관리자로 제한한다
|
||||
- 업체 링크를 쓰려면 위 '업체 링크 → 처음 켤 때' 순서를 따른다
|
||||
(`imweb-header-top.html`·`imweb-free-apply.html` 의 `API_BASE` 도 같은 운영 주소)
|
||||
|
||||
## 유입 기록 상태
|
||||
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
<!-- ===== 아임웹 HTML 위젯 삽입용 코드 (본문 스니펫) - 무료 신청 완료 ===== -->
|
||||
<!-- ※ 신청 위젯(course-enroll.html)과 같은 페이지에 배치하세요. 신청 완료 시 자동으로 표시됩니다. -->
|
||||
<div class="moneyguider-complete">
|
||||
<div class="moneyguider-complete__card">
|
||||
|
||||
<div class="moneyguider-complete__icon" aria-hidden="true">
|
||||
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="#1a1200" stroke-width="3"
|
||||
stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h2 class="moneyguider-complete__title">신청이 완료되었습니다.</h2>
|
||||
|
||||
<hr class="moneyguider-complete__divider" />
|
||||
|
||||
<div class="moneyguider-complete__info">
|
||||
<div class="moneyguider-complete__row">
|
||||
<span class="moneyguider-complete__label">신청강의</span>
|
||||
<span class="moneyguider-complete__value">[무료한정 😳] 하루 2시간 월1000 버는 AI 효자 상품 찾기</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="moneyguider-complete__guide">
|
||||
무료 라이브 단톡방에 입장하셔서<br />
|
||||
<strong>전자책, 무료강의 등 다양한 혜택</strong>을 받아가세요!
|
||||
</p>
|
||||
|
||||
<a class="moneyguider-complete__kakao" href="https://open.kakao.com/o/gWNuX9Eh" target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1a1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 3C6.5 3 2 6.6 2 11c0 2.8 1.9 5.3 4.7 6.7-.2.7-.7 2.6-.8 3-.1.5.2.5.4.4.2-.1 2.6-1.8 3.7-2.5.7.1 1.4.2 2 .2 5.5 0 10-3.6 10-8S17.5 3 12 3z" />
|
||||
</svg>
|
||||
오픈카톡 입장하기
|
||||
</a>
|
||||
|
||||
<a class="moneyguider-complete__secondary" href="/home">메인 페이지로 이동</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* moneyguider 신청 완료 위젯 전용 스코프 스타일 (사이트 전역에 영향 없음) */
|
||||
.moneyguider-complete {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
font-family: "Pretendard", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* 기본 숨김: 신청 완료 시 JS가 .is-shown 클래스를 추가해 표시 */
|
||||
display: none;
|
||||
}
|
||||
|
||||
.moneyguider-complete.is-shown {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.moneyguider-complete * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.moneyguider-complete__card {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding: 44px 36px 36px;
|
||||
background-color: #141414;
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 체크 아이콘 */
|
||||
.moneyguider-complete__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin: 0 auto 22px;
|
||||
background-color: #f5b727;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.moneyguider-complete .moneyguider-complete__title {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
color: #ffffff;
|
||||
letter-spacing: -0.02em;
|
||||
text-indent: 0;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.moneyguider-complete__divider {
|
||||
height: 1px;
|
||||
margin: 26px 0;
|
||||
border: 0;
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* 신청 정보 박스 */
|
||||
.moneyguider-complete__info {
|
||||
padding: 22px 24px;
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.moneyguider-complete__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.moneyguider-complete__label {
|
||||
flex: 0 0 auto;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #cfcfcf;
|
||||
}
|
||||
|
||||
.moneyguider-complete__value {
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
color: #ffffff;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 안내 문구 */
|
||||
.moneyguider-complete__guide {
|
||||
margin: 26px 0 22px;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: #9a9a9a;
|
||||
}
|
||||
|
||||
.moneyguider-complete__guide strong {
|
||||
font-weight: 700;
|
||||
color: #f5b727;
|
||||
}
|
||||
|
||||
/* 버튼 */
|
||||
.moneyguider-complete__kakao {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 17px;
|
||||
margin-bottom: 12px;
|
||||
background-color: #fee500;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1a1200 !important;
|
||||
text-decoration: none;
|
||||
transition: filter 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.moneyguider-complete__kakao:hover {
|
||||
filter: brightness(0.96);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.moneyguider-complete__secondary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 17px;
|
||||
background-color: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.moneyguider-complete__secondary:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 520px) {
|
||||
.moneyguider-complete__card {
|
||||
padding: 36px 22px 26px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.moneyguider-complete .moneyguider-complete__title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.moneyguider-complete__row {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.moneyguider-complete__value {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ========================================================
|
||||
▼▼▼ 여기만 수정하시면 됩니다 ▼▼▼
|
||||
======================================================== */
|
||||
|
||||
/* 1) 비로그인 사용자를 이 화면에서 튕겨낼지.
|
||||
주소창에 /apply 를 직접 쳐서 들어오는 것을 막습니다. */
|
||||
var REQUIRE_LOGIN = true;
|
||||
|
||||
/* 2) 튕겨낼 때 어디로 보낼지
|
||||
'join' 아임웹 회원가입 페이지로 보냅니다. 가입이 끝나면
|
||||
지금 주소(/apply?...)로 그대로 돌아와 완료 화면이
|
||||
보입니다. 신청 흐름이 끊기지 않습니다.
|
||||
주소 그 주소로 이동시킵니다.
|
||||
예) '/free-shop/?idx=52' '/home' */
|
||||
var REDIRECT_TO = 'join';
|
||||
|
||||
/* 3) 회원가입 페이지 주소 ('가입 방식 선택' 화면).
|
||||
back_url 파라미터는 자동으로 붙습니다.
|
||||
detail-buy-btn.html 의 JOIN_PAGE 와 같은 값이어야 합니다.
|
||||
|
||||
※ SITE_MEMBER.openJoinTypeChoice() 를 부르면 같은 화면이
|
||||
현재 페이지 위에 '모달' 로 뜹니다. 이 페이지는 내용을
|
||||
감춰 둔 상태라 모달 뒤가 텅 비어 보이고, 사용자가 모달을
|
||||
닫으면 빈 화면에 갇힙니다. 전체 페이지로 보냅니다. */
|
||||
var JOIN_PAGE = '/site_join_type_choice';
|
||||
|
||||
/* 4) 광고 픽셀 이벤트
|
||||
------------------------------------------------------
|
||||
※ '오픈카톡방에 실제로 입장했는지' 는 우리 쪽에서 알 수
|
||||
없습니다. open.kakao.com 은 카카오 도메인이고, 입장
|
||||
결과를 사이트로 알려주는 콜백이 없습니다.
|
||||
그래서 '입장하기 버튼 클릭' 을 전환으로 잡습니다.
|
||||
실제 입장률만큼 과대 집계되는 점을 감안하세요. */
|
||||
var PIXEL = {
|
||||
/* 켜고 끄기 */
|
||||
ON: true,
|
||||
|
||||
/* (1) 오픈카톡 버튼 클릭 → 이것을 광고 전환으로 잡으시면 됩니다.
|
||||
메타 광고 관리자에는 '구독' 으로 표시됩니다.
|
||||
|
||||
Subscribe 를 고른 이유
|
||||
- 캠페인 목표가 '판매' 면 전환 이벤트 목록에 커머스
|
||||
계열만 나옵니다. Contact / Lead 는 '잠재 고객' 목표
|
||||
전용이라 목록에 아예 안 뜹니다.
|
||||
- Subscribe 는 그 목록 안에 있으면서 아직 아무도
|
||||
쓰지 않는 이벤트입니다. (결제 시작·콘텐츠 조회·
|
||||
장바구니·구매는 아임웹 쇼핑 연동이 이미 사용 중,
|
||||
등록 완료는 아래 가입 전환이 사용 중)
|
||||
- 의미도 '채널 입장 = 구독' 으로 가장 가깝습니다.
|
||||
|
||||
※ 주의 : 상품 타입이 subscribe 로 잡혀 있어
|
||||
(data-bs-prod-type="subscribe"), 정규강의 결제가
|
||||
완료되면 아임웹이 Purchase 대신 Subscribe 를 쏠
|
||||
가능성이 있습니다. 그때는 이벤트 관리자에서 '구독'
|
||||
이벤트의 소스 URL 을 확인해 /apply 외의 주소가
|
||||
섞이는지 보세요. 섞인다면 URL 조건을 건 맞춤 전환으로
|
||||
분리하시면 됩니다.
|
||||
|
||||
맞춤 전환으로 따로 관리하시려면 KAKAO_CUSTOM 을 true 로
|
||||
두세요. 그러면 trackCustom 으로 보냅니다. */
|
||||
KAKAO_EVENT: 'Subscribe',
|
||||
KAKAO_CUSTOM: false,
|
||||
|
||||
/* (2) 회원가입 완료 → CompleteRegistration ('등록 완료')
|
||||
지금은 꺼져 있습니다. 위 (1) 구독 추적만 동작합니다.
|
||||
|
||||
true 로 바꾸면 가입 전환도 함께 집계됩니다. 그때 필요한
|
||||
표식은 detail-buy-btn.html 이 이미 남기고 있으므로
|
||||
여기 값만 바꾸면 되고, 다른 파일은 손대지 않아도 됩니다.
|
||||
|
||||
켤 경우의 동작 : 가입 페이지를 거쳐 돌아온 사람만 셉니다.
|
||||
이미 회원인 사람이 완료 화면에 다시 들른 경우는
|
||||
세지 않습니다. */
|
||||
REGISTRATION: false,
|
||||
|
||||
/* (3) 구글 광고 전환 — 전환 액션 '회원가입 (광섭)'
|
||||
구글은 메타처럼 이벤트 이름으로 구분하지 않고,
|
||||
전환 액션마다 발급되는 send_to 값으로 구분합니다.
|
||||
구글 애즈 > 목표 > 해당 전환 액션 > 태그 설정 에서
|
||||
'AW-계정ID/라벨' 형태로 확인할 수 있습니다.
|
||||
|
||||
비워두면 구글 쪽으로는 아무것도 보내지 않습니다. */
|
||||
GOOGLE_KAKAO: 'AW-17296567556/95baCOj7ze0aEITa0rdA',
|
||||
|
||||
/* 가입 완료 시점에 보낼 구글 전환. 지금은 비워 두었습니다.
|
||||
(아래 REGISTRATION 이 false 라 어차피 동작하지 않습니다)
|
||||
가입 시점으로 옮기시려면 위 값을 여기로 옮기고
|
||||
REGISTRATION 을 true 로 바꾸세요. */
|
||||
GOOGLE_SIGNUP: '',
|
||||
|
||||
/* (4) GA4 이벤트도 함께 보낼지.
|
||||
※ 현재 이 사이트에는 GA4(G-) 속성이 없고 구글 광고
|
||||
태그(AW-)만 설치돼 있습니다. 그래서 켜 두어도 지금은
|
||||
실질적인 효과가 없습니다. 나중에 GA4 를 붙이면
|
||||
그때부터 동작합니다. */
|
||||
GA4: true
|
||||
};
|
||||
|
||||
/* detail-buy-btn.html 이 남기는 표식의 이름. 양쪽이 같아야 합니다. */
|
||||
var JOIN_FLAG = 'mg-join-started';
|
||||
|
||||
/* 6) 상품별 오픈카톡 링크
|
||||
키 : 상품번호 — 주소의 ?idx= 값입니다.
|
||||
detail-buy-btn.html 이 신청 버튼에서 함께 넘겨줍니다.
|
||||
값 : 그 상품 전용 오픈채팅방 주소
|
||||
|
||||
여기에 없는 상품은 아래 KAKAO_DEFAULT 로 갑니다. */
|
||||
var KAKAO_LINKS = {
|
||||
'52': 'https://open.kakao.com/o/gWNuX9Eh'
|
||||
// '10': 'https://open.kakao.com/o/XXXXXXXX',
|
||||
};
|
||||
|
||||
/* 등록되지 않은 상품이거나 상품번호 없이 들어온 경우에 쓸 링크.
|
||||
비워두면('') 오픈카톡 버튼 자체를 숨깁니다.
|
||||
엉뚱한 방으로 보내느니 안 보여주는 편이 낫다면 비우세요. */
|
||||
var KAKAO_DEFAULT = '';
|
||||
|
||||
/* 5) 로그인 여부를 판별하는 데 기다릴 최대 시간.
|
||||
아임웹 전역(MEMBER_UID)이 늦게 실행되는 경우 대비입니다.
|
||||
이 시간이 지나도 판별이 안 되면 '통과' 시킵니다. */
|
||||
var WAIT_MS = 2000;
|
||||
var POLL_MS = 100;
|
||||
|
||||
/* 7) 신청 기록을 보낼 백엔드 주소
|
||||
신청 완료 화면이 뜰 때 회원·상품번호·유입정보·업체 링크 클릭을
|
||||
보내 관리자 화면 '신청 내역'과 '업체 링크' 실적에 남깁니다.
|
||||
비워두면('') 아무것도 보내지 않습니다. */
|
||||
var API_BASE = 'https://moneyguide.wandergo.net/api';
|
||||
|
||||
/* ========================================================
|
||||
▲▲▲ 여기까지 ▲▲▲ 아래는 수정할 필요 없습니다.
|
||||
======================================================== */
|
||||
|
||||
var el = document.querySelector('.moneyguider-complete');
|
||||
if (!el) return;
|
||||
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var name = params.get('name');
|
||||
/* 상품번호 — detail-buy-btn.html 이 붙여 보냅니다.
|
||||
오픈카톡 링크를 고르고, 픽셀에도 함께 실어 보냅니다. */
|
||||
var idx = params.get('idx') || '';
|
||||
var isApplyPage = window.location.pathname.indexOf('apply') !== -1;
|
||||
|
||||
// /apply 페이지도 아니고 name 파라미터도 없으면 아무것도 하지 않습니다
|
||||
if (!isApplyPage && !name) return;
|
||||
|
||||
/* ── 픽셀 전송 ────────────────────────────────────────
|
||||
픽셀이 아직 안 떴거나 광고차단기에 막혀도 페이지가 죽지
|
||||
않도록 전부 방어합니다. 완료 화면 표시가 픽셀보다 우선입니다. */
|
||||
function meta(name, custom, params) {
|
||||
if (!PIXEL.ON || typeof window.fbq !== 'function') return;
|
||||
try { window.fbq(custom ? 'trackCustom' : 'track', name, params || {}); }
|
||||
catch (e) { }
|
||||
}
|
||||
|
||||
function ga(name, params) {
|
||||
if (!PIXEL.ON || !PIXEL.GA4 || typeof window.gtag !== 'function') return;
|
||||
try { window.gtag('event', name, params || {}); } catch (e) { }
|
||||
}
|
||||
|
||||
/* 구글 광고 전환.
|
||||
|
||||
구글이 안내하는 스니펫은 gtag_report_conversion(url) 형태로,
|
||||
전환을 보낸 뒤 window.location 으로 이동시키는 구조입니다.
|
||||
그건 '같은 탭에서 이동하는 링크' 를 위한 것입니다.
|
||||
우리 카톡 버튼은 target="_blank" 라 현재 페이지가 그대로
|
||||
남으므로 이동을 가로챌 이유가 없습니다. 그 방식을 쓰면
|
||||
새 탭이 열리면서 현재 탭까지 카톡으로 넘어가 버립니다.
|
||||
→ 전환만 보내고 링크는 건드리지 않습니다. */
|
||||
function gads(sendTo) {
|
||||
if (!PIXEL.ON || !sendTo || typeof window.gtag !== 'function') return;
|
||||
try { window.gtag('event', 'conversion', { 'send_to': sendTo }); }
|
||||
catch (e) { }
|
||||
}
|
||||
|
||||
function ssGet(key) {
|
||||
try { return window.sessionStorage.getItem(key); }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
|
||||
function ssDrop(key) {
|
||||
try { window.sessionStorage.removeItem(key); } catch (e) { }
|
||||
}
|
||||
|
||||
/* 픽셀 스크립트는 비동기로 뜹니다. 페이지 로드 직후에는 아직
|
||||
window.fbq 가 없을 수 있어 잠깐 기다립니다.
|
||||
|
||||
이게 필요한 이유 : 가입 전환은 '표식을 지우고 → 보낸다' 인데,
|
||||
픽셀이 안 뜬 상태에서 표식부터 지워버리면 그 가입은 영영
|
||||
집계되지 않습니다. 표식은 실제로 보낸 뒤에만 지웁니다. */
|
||||
var PIXEL_WAIT_MS = 4000;
|
||||
|
||||
function whenPixelReady(cb) {
|
||||
var waited = 0;
|
||||
(function poll() {
|
||||
var ready = (typeof window.fbq === 'function');
|
||||
if (ready || waited >= PIXEL_WAIT_MS) { cb(ready); return; }
|
||||
waited += 100;
|
||||
window.setTimeout(poll, 100);
|
||||
})();
|
||||
}
|
||||
|
||||
/* ── 신청 기록 전송 ──────────────────────────────────────
|
||||
실패해도 완료 화면·픽셀에는 영향을 주지 않습니다 (전부 조용히 무시).
|
||||
같은 회원·상품은 서버가 한 건만 남기므로 새로고침해도 안전합니다. */
|
||||
var PRODUCT_IDX_PATTERN = /^\d{1,20}$/;
|
||||
var PRODUCT_NAME_MAX = 200;
|
||||
var INFLOW_MAX = 1000;
|
||||
var applicationRequest = null; /* 신청 기록 ID 를 돌려주는 Promise */
|
||||
|
||||
/* 아임웹 전역(MEMBER_UID)이 늦게 생기는 경우를 잠깐 기다립니다 */
|
||||
function whenMemberUid(cb) {
|
||||
var waited = 0;
|
||||
(function poll() {
|
||||
var uid = window.MEMBER_UID;
|
||||
if (uid) { cb(String(uid)); return; }
|
||||
if (typeof uid === 'string' || waited >= WAIT_MS) return;
|
||||
waited += POLL_MS;
|
||||
window.setTimeout(poll, POLL_MS);
|
||||
})();
|
||||
}
|
||||
|
||||
/* imweb-header-top.html(UTM 스크립트)이 보관한 유입정보 — 없으면 빈 값.
|
||||
MG_UTM.peek() 는 { first, last, value, … } 객체를 돌려주고,
|
||||
서버가 받는 파이프 형식(src=..|med=..|…)은 그중 value 다. */
|
||||
function readInflow() {
|
||||
try {
|
||||
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 ''; }
|
||||
}
|
||||
|
||||
/* 신청 직전의 최종 유입 — 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'
|
||||
? window.MG_PARTNER.refs() : [];
|
||||
return Array.isArray(refs) ? refs : [];
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
|
||||
function recordApplication() {
|
||||
if (!API_BASE || !PRODUCT_IDX_PATTERN.test(idx) ||
|
||||
typeof window.fetch !== 'function') return;
|
||||
|
||||
applicationRequest = new Promise(function (resolve) {
|
||||
whenMemberUid(function (memberUid) {
|
||||
var body = { memberUid: memberUid, productIdx: idx };
|
||||
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;
|
||||
|
||||
window.fetch(API_BASE + '/applications', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'omit',
|
||||
body: JSON.stringify(body)
|
||||
}).then(function (res) {
|
||||
return res.ok ? res.json() : null;
|
||||
}).then(function (json) {
|
||||
resolve(json && json.success && json.data ? json.data.id : null);
|
||||
}).catch(function () { resolve(null); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* 오픈카톡 버튼 클릭 기록 — 서버는 처음 한 번만 남깁니다 */
|
||||
function recordKakaoClick() {
|
||||
if (!applicationRequest) return;
|
||||
applicationRequest.then(function (id) {
|
||||
if (!id) return;
|
||||
window.fetch(API_BASE + '/applications/' + encodeURIComponent(id) +
|
||||
'/kakao-clicks', { method: 'POST', credentials: 'omit' })
|
||||
.catch(function () { });
|
||||
});
|
||||
}
|
||||
|
||||
function show() {
|
||||
el.classList.add('is-shown');
|
||||
|
||||
// URL로 전달된 상품명을 '신청강의' 값에 채움
|
||||
if (name) {
|
||||
var valueEl = el.querySelector('.moneyguider-complete__value');
|
||||
if (valueEl) valueEl.textContent = name;
|
||||
}
|
||||
|
||||
/* 신청 기록 — 아래 카톡 버튼 처리가 중간에 끝날 수 있어 먼저 보냅니다 */
|
||||
recordApplication();
|
||||
|
||||
/* (2) 방금 가입을 마치고 돌아온 경우에만 가입 전환 */
|
||||
if (PIXEL.REGISTRATION && PIXEL.ON && ssGet(JOIN_FLAG)) {
|
||||
whenPixelReady(function (ready) {
|
||||
/* 끝내 안 뜨면(광고차단기 등) 표식을 남겨 둡니다.
|
||||
어차피 못 보낼 상황이고, 지워봐야 손해만 봅니다. */
|
||||
if (!ready) return;
|
||||
|
||||
ssDrop(JOIN_FLAG); /* 보내기 직전에 지웁니다 */
|
||||
meta('CompleteRegistration', false, {
|
||||
content_name: name || '', status: true
|
||||
});
|
||||
gads(PIXEL.GOOGLE_SIGNUP);
|
||||
ga('sign_up', { method: 'imweb', content_name: name || '' });
|
||||
});
|
||||
}
|
||||
|
||||
/* (1) 오픈카톡 입장 버튼 클릭 → 광고 전환으로 쓸 이벤트
|
||||
|
||||
버튼이 target="_blank" 라 이 페이지가 그대로 남습니다.
|
||||
덕분에 이벤트가 전송될 시간이 확보됩니다. 같은 탭에서
|
||||
열도록 바꾸시면 전송 전에 페이지가 떠나 유실될 수 있습니다.
|
||||
|
||||
preventDefault 를 하지 않습니다. 링크는 평소대로 열립니다. */
|
||||
var kakao = el.querySelector('.moneyguider-complete__kakao');
|
||||
if (kakao) {
|
||||
/* 상품번호로 오픈채팅방 주소를 고릅니다.
|
||||
등록된 게 없으면 KAKAO_DEFAULT, 그것도 비어 있으면
|
||||
버튼을 숨깁니다. */
|
||||
var link = (Object.prototype.hasOwnProperty.call(KAKAO_LINKS, idx)
|
||||
? KAKAO_LINKS[idx] : '') || KAKAO_DEFAULT;
|
||||
|
||||
if (!link) {
|
||||
kakao.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
kakao.setAttribute('href', link);
|
||||
|
||||
var sent = false;
|
||||
|
||||
function onKakao() {
|
||||
/* 한 화면에서 여러 번 눌러도 한 번만 집계합니다.
|
||||
target="_blank" 라 페이지가 그대로 남아 있어서
|
||||
두세 번 누르는 일이 실제로 생깁니다. 그대로 두면
|
||||
한 사람이 전환 여러 건으로 잡힙니다. */
|
||||
if (sent) return;
|
||||
sent = true;
|
||||
|
||||
recordKakaoClick();
|
||||
|
||||
/* content_ids 를 함께 보내면 나중에 광고 관리자에서
|
||||
상품별로 전환을 쪼개 볼 수 있습니다. */
|
||||
var p = { content_name: name || '', content_category: 'kakao_openchat' };
|
||||
if (idx) p.content_ids = [idx];
|
||||
meta(PIXEL.KAKAO_EVENT, PIXEL.KAKAO_CUSTOM, p);
|
||||
gads(PIXEL.GOOGLE_KAKAO);
|
||||
ga('kakao_openchat_join', { content_name: name || '' });
|
||||
}
|
||||
|
||||
kakao.addEventListener('click', onKakao);
|
||||
|
||||
/* 가운데 버튼 클릭은 click 이 아니라 auxclick 으로 옵니다.
|
||||
새 탭에서 열리는 건 똑같으므로 함께 집계합니다.
|
||||
(Ctrl+클릭은 button 0 의 click 이라 위에서 이미 잡힙니다) */
|
||||
kakao.addEventListener('auxclick', function (e) {
|
||||
if (e.button === 1) onKakao();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!REQUIRE_LOGIN) { show(); return; }
|
||||
|
||||
/* ── 로그인 판별 ──────────────────────────────────────
|
||||
detail-buy-btn.html 과 같은 방식입니다.
|
||||
window.MEMBER_UID 로그인 회원의 식별자 (비로그인이면 빈 값)
|
||||
_imweb_login_state 로그인 상태 쿠키 ('Y')
|
||||
|
||||
반환값 : 'in' | 'out' | 'unknown'
|
||||
|
||||
※ '확실히 로그아웃(out)' 일 때만 튕겨냅니다. 판별이 안 되면
|
||||
(unknown) 화면을 보여줍니다. 반대로 하면 아임웹 스크립트가
|
||||
늦게 뜬 순간 정상적으로 가입을 마친 사람까지 완료 화면을
|
||||
못 보게 됩니다. */
|
||||
function loginState() {
|
||||
var uid = window.MEMBER_UID;
|
||||
var m = /(?:^|;\s*)_imweb_login_state=([^;]*)/.exec(document.cookie || '');
|
||||
|
||||
if (uid || (m && m[1] === 'Y')) return 'in';
|
||||
if (typeof uid === 'string') return 'out';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/* 가입 직후 돌아왔는데 판별이 한 박자 늦어 다시 튕겨내는
|
||||
무한 왕복을 막습니다. 세션당 한 번만 튕겨냅니다. */
|
||||
var ONCE_KEY = 'mg-apply-bounced';
|
||||
|
||||
function bouncedBefore() {
|
||||
try { return window.sessionStorage.getItem(ONCE_KEY) === '1'; }
|
||||
catch (e) { return false; } /* 시크릿 모드 등 - 막지 않습니다 */
|
||||
}
|
||||
|
||||
function markBounced() {
|
||||
try { window.sessionStorage.setItem(ONCE_KEY, '1'); } catch (e) { }
|
||||
}
|
||||
|
||||
/* 아임웹 복귀 주소 형식 : encodeURIComponent(btoa(주소)) */
|
||||
function backUrl(url) {
|
||||
try { return encodeURIComponent(window.btoa(url)); }
|
||||
catch (e) { return ''; }
|
||||
}
|
||||
|
||||
/* 튕겨냅니다. 성공하면 true (= 완료 화면을 보여주지 않음) */
|
||||
function bounce() {
|
||||
if (bouncedBefore()) return false;
|
||||
markBounced();
|
||||
|
||||
if (REDIRECT_TO !== 'join') {
|
||||
/* replace 를 쓰는 이유 : 뒤로가기로 다시 /apply 에
|
||||
들어와 또 튕기는 일이 없게 합니다. */
|
||||
window.location.replace(REDIRECT_TO);
|
||||
return true;
|
||||
}
|
||||
|
||||
var back = backUrl(window.location.href);
|
||||
if (!back) return false;
|
||||
|
||||
window.location.replace(JOIN_PAGE +
|
||||
(JOIN_PAGE.indexOf('?') === -1 ? '?' : '&') +
|
||||
'back_url=' + back);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* 판별될 때까지 잠깐 기다립니다. 그동안 완료 화면은 숨은 채로
|
||||
둡니다. 먼저 보여줬다가 튕겨내면 내용이 한 번 번쩍입니다. */
|
||||
var waited = 0;
|
||||
(function check() {
|
||||
var state = loginState();
|
||||
|
||||
if (state === 'unknown' && waited < WAIT_MS) {
|
||||
waited += POLL_MS;
|
||||
window.setTimeout(check, POLL_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === 'out' && bounce()) return;
|
||||
show();
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
<!-- ===== 코드 끝 ===== -->
|
||||
@@ -0,0 +1,622 @@
|
||||
<meta name="facebook-domain-verification" content="sldnjjycwvg0acy344qq4zu3mhxh72" />
|
||||
<meta name="facebook-domain-verification" content="rjbwdcpgd8bp4a5220oshav850duv1" />
|
||||
|
||||
<!-- ============================================================
|
||||
[자본주의가이드] UTM 기반 회원가입 유입경로 자동추적 v1.1
|
||||
설치 위치: 아임웹 관리자 > 환경설정 > 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',
|
||||
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 등)를 새 유입으로 오해하면 원래 유입원이 가려진다.
|
||||
이 경로들에서는 URL에 UTM이 직접 있을 때만 기록한다. */
|
||||
var AUTH_PATH = /^\/(oauth|site_join|login|logout|member|social|join)/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');
|
||||
|
||||
// 최종유입: 실제 외부 유입일 때만 갱신 (사이트 내부 이동으로 덮어쓰지 않음)
|
||||
if (!isDirect) saveTouch(K_LAST, t);
|
||||
|
||||
var prev = readTouch(K_FIRST);
|
||||
if (!prev) {
|
||||
saveTouch(K_FIRST, t); // 최초 1회 (또는 보관기간 만료 후 재시작)
|
||||
} 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);
|
||||
}
|
||||
|
||||
/* ===================== 부팅 ===================== */
|
||||
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>
|
||||
@@ -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>
|
||||
+629
-7
@@ -212,6 +212,11 @@
|
||||
#mg-members th { font-size: 12px; color: var(--mgm-muted); font-weight: 600; background: #fafbfc; position: sticky; top: 0; }
|
||||
#mg-members td.mgm-wrap { white-space: normal; min-width: 140px; }
|
||||
#mg-members .mgm-empty { padding: 32px; text-align: center; color: var(--mgm-muted); }
|
||||
#mg-members tr.is-selected td { background: var(--mgm-accent-soft); }
|
||||
|
||||
/* 업체 링크 */
|
||||
#mg-members .mgm-code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
|
||||
#mg-members .mgm-url { display: inline-block; max-width: 280px; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; }
|
||||
|
||||
/* 연동 관리 */
|
||||
#mg-members .mgm-dl { display: grid; grid-template-columns: 140px 1fr; gap: 10px 16px; margin: 0; }
|
||||
@@ -292,6 +297,10 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="5" y="4" width="14" height="17" rx="2"/><path d="M9 4V3h6v1"/><path d="m9 13 2 2 4-4"/></svg>
|
||||
신청 내역
|
||||
</button>
|
||||
<button type="button" data-view="partners">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 21V9l9-6 9 6v12"/><path d="M9 21v-6h6v6"/><path d="M3 21h18"/></svg>
|
||||
업체 링크
|
||||
</button>
|
||||
<button type="button" data-view="integration">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
연동 관리
|
||||
@@ -496,9 +505,30 @@
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="mgm-card" style="margin-bottom:16px">
|
||||
<div class="mgm-card-head">
|
||||
<h3>신청 유입 집계</h3>
|
||||
<span class="mgm-muted" data-role="app-stats-desc">위 기간·상품 조건 · 막대를 누르면 아래 목록을 거릅니다</span>
|
||||
<span class="mgm-spacer"></span>
|
||||
<div class="mgm-seg" role="group" aria-label="유입 기준">
|
||||
<button type="button" data-app-basis="apply_last" class="is-active" title="신청 직전에 마지막으로 들어온 경로 — 신청을 일으킨 유입">신청 때 (최종)</button>
|
||||
<button type="button" data-app-basis="apply_first" title="신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)">신청 때 (최초)</button>
|
||||
<button type="button" data-app-basis="join" title="신청한 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외)">가입 경로</button>
|
||||
</div>
|
||||
<div class="mgm-seg" role="group" aria-label="집계 항목">
|
||||
<button type="button" data-app-group="source" class="is-active">유입원</button>
|
||||
<button type="button" data-app-group="medium">매체</button>
|
||||
<button type="button" data-app-group="campaign">캠페인</button>
|
||||
<button type="button" data-app-group="content">콘텐츠</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mgm-bars" data-role="app-stats-bars"><div class="mgm-muted">불러오는 중…</div></div>
|
||||
</section>
|
||||
<section class="mgm-card">
|
||||
<div class="mgm-card-head">
|
||||
<h3>신청</h3>
|
||||
<span data-role="app-inflow-filter" class="mgm-row" style="gap:6px"></span>
|
||||
<span class="mgm-spacer"></span>
|
||||
<span class="mgm-muted" data-role="app-total"></span>
|
||||
</div>
|
||||
<div class="mgm-table-wrap">
|
||||
@@ -506,7 +536,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>신청일시</th><th>상품</th><th>이름</th><th>아이디 / 이메일</th><th>연락처</th>
|
||||
<th>오픈카톡</th><th>신청 때 유입</th><th>가입 때 유입</th><th>회원 그룹</th>
|
||||
<th>오픈카톡</th><th title="신청 직전 최종 유입 — 첫 경로와 다르면 아래에 '최초'로 함께 표시">신청 때 유입</th><th>가입 때 유입</th><th>회원 그룹</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-role="app-rows"></tbody>
|
||||
@@ -519,6 +549,119 @@
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- 업체 링크 -->
|
||||
<section data-panel="partners" hidden>
|
||||
<section class="mgm-card" style="margin-bottom:16px">
|
||||
<div class="mgm-filter-top">
|
||||
<div class="mgm-seg" role="group" aria-label="실적 집계 기간">
|
||||
<button type="button" data-ptn-range="all" class="is-active">전체</button>
|
||||
<button type="button" data-ptn-range="today">오늘</button>
|
||||
<button type="button" data-ptn-range="7">7일</button>
|
||||
<button type="button" data-ptn-range="30">30일</button>
|
||||
<button type="button" data-ptn-range="month">이번 달</button>
|
||||
</div>
|
||||
<span class="mgm-spacer"></span>
|
||||
<span class="mgm-muted" data-role="ptn-order-sync">구매 집계 상태 확인 중</span>
|
||||
<button type="button" data-action="ptn-order-sync">구매 집계 새로고침</button>
|
||||
<button type="button" data-action="ptn-product-sync">상품 불러오기</button>
|
||||
</div>
|
||||
<p class="mgm-muted" style="margin:10px 0 0">
|
||||
링크를 누른 뒤 30일 안에 신청·구매하면, 그 상품의 링크 중 마지막으로 누른 업체 실적으로 잡습니다.
|
||||
구매는 결제완료 주문만 세며(환불·취소 제외) 아임웹 주문을 1시간마다 확인합니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="mgm-grid-2" style="margin-bottom:16px">
|
||||
<section class="mgm-card">
|
||||
<div class="mgm-card-head">
|
||||
<h3>업체</h3>
|
||||
<span class="mgm-muted" data-role="ptn-partner-total"></span>
|
||||
</div>
|
||||
<form data-role="ptn-partner-form" class="mgm-row" style="margin-bottom:12px" autocomplete="off">
|
||||
<input name="partnerName" maxlength="100" required placeholder="업체 이름" aria-label="업체 이름" style="flex:1 1 140px;width:auto">
|
||||
<input name="memo" maxlength="1000" placeholder="메모 (선택 — 담당자·정산 조건 등)" aria-label="업체 메모" style="flex:2 1 200px;width:auto">
|
||||
<button type="submit" class="mgm-primary">업체 추가</button>
|
||||
</form>
|
||||
<div class="mgm-table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>업체</th><th>링크</th><th>클릭</th><th>신청</th><th>구매</th><th></th></tr></thead>
|
||||
<tbody data-role="ptn-partner-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mgm-empty" data-role="ptn-partner-empty" hidden>등록된 업체가 없습니다. 위에서 업체를 추가해 주세요.</div>
|
||||
</section>
|
||||
<section class="mgm-card">
|
||||
<div class="mgm-card-head">
|
||||
<h3>링크 발급</h3>
|
||||
<span class="mgm-muted">업체·상품 조합당 하나</span>
|
||||
</div>
|
||||
<form data-role="ptn-link-form" style="display:flex;flex-direction:column;gap:10px" autocomplete="off">
|
||||
<label class="mgm-field">업체
|
||||
<select name="partnerId" required><option value="">업체를 먼저 추가해 주세요</option></select>
|
||||
</label>
|
||||
<label class="mgm-field">상품
|
||||
<select name="prodNo" required><option value="">'상품 불러오기'를 눌러 주세요</option></select>
|
||||
</label>
|
||||
<label class="mgm-field">이동할 상품 페이지 주소
|
||||
<input name="targetUrl" type="url" required maxlength="2000" placeholder="https://www.moneyguider.co.kr/free-shop/?idx=52">
|
||||
</label>
|
||||
<span class="mgm-muted">아임웹에서 상품 페이지를 열고 주소창의 주소를 붙여 넣으세요. 우리 사이트 안의 주소만 됩니다.</span>
|
||||
<div class="mgm-row"><button type="submit" class="mgm-primary">링크 발급</button></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="mgm-card" style="margin-bottom:16px">
|
||||
<div class="mgm-card-head">
|
||||
<h3>링크</h3>
|
||||
<span data-role="ptn-link-filter" class="mgm-row" style="gap:6px"></span>
|
||||
<span class="mgm-spacer"></span>
|
||||
<span class="mgm-muted" data-role="ptn-link-total"></span>
|
||||
</div>
|
||||
<div class="mgm-table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>업체</th><th>상품</th><th>업체에 줄 링크</th><th>클릭</th><th>신청</th><th>구매</th>
|
||||
<th>상태</th><th>발급일</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-role="ptn-link-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mgm-empty" data-role="ptn-link-empty" hidden>발급한 링크가 없습니다.</div>
|
||||
<div class="mgm-row" style="justify-content:center;margin-top:12px">
|
||||
<button type="button" data-action="ptn-link-more" hidden>더 보기</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mgm-card" data-role="ptn-conv-card" hidden>
|
||||
<div class="mgm-card-head">
|
||||
<h3>실적 상세</h3>
|
||||
<span class="mgm-muted" data-role="ptn-conv-title"></span>
|
||||
<span class="mgm-spacer"></span>
|
||||
<div class="mgm-seg" role="group" aria-label="실적 종류">
|
||||
<button type="button" data-ptn-conv-type="" class="is-active">전체</button>
|
||||
<button type="button" data-ptn-conv-type="application">신청</button>
|
||||
<button type="button" data-ptn-conv-type="purchase">구매</button>
|
||||
</div>
|
||||
<button type="button" class="mgm-link" data-action="ptn-conv-close">닫기</button>
|
||||
</div>
|
||||
<div class="mgm-table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>신청·주문 일시</th><th>종류</th><th>이름</th><th>아이디 / 이메일</th><th>연락처</th><th>주문번호</th><th>링크 클릭</th></tr>
|
||||
</thead>
|
||||
<tbody data-role="ptn-conv-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mgm-empty" data-role="ptn-conv-empty" hidden>아직 실적이 없습니다.</div>
|
||||
<div class="mgm-row" style="justify-content:center;margin-top:12px">
|
||||
<button type="button" data-action="ptn-conv-more" hidden>더 보기</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- 연동 관리 -->
|
||||
<section data-panel="integration" hidden>
|
||||
<section class="mgm-card" style="margin-bottom:16px">
|
||||
@@ -549,7 +692,7 @@
|
||||
/* ===================== 설정 ===================== */
|
||||
var API_BASE = /^(localhost|127\.0\.0\.1)$/.test(location.hostname)
|
||||
? 'http://localhost:3100/api'
|
||||
: 'https://moneyguider.wandergo.net/api'; // 운영 백엔드 주소
|
||||
: 'https://moneyguide.wandergo.net/api'; // 운영 백엔드 주소
|
||||
var PAGE_SIZE = 50;
|
||||
var RECENT_SIZE = 8;
|
||||
var OVERVIEW_TOP = 8;
|
||||
@@ -563,6 +706,7 @@
|
||||
overview: { title: '개요', desc: '회원 수와 유입 현황을 한눈에 봅니다' },
|
||||
members: { title: '회원 목록', desc: '가입 기간·유입경로로 회원을 찾습니다' },
|
||||
applications: { title: '신청 내역', desc: '누가 어떤 강의에 신청했는지 봅니다 (신청 완료 화면 기준)' },
|
||||
partners: { title: '업체 링크', desc: '업체별 링크를 발급하고, 그 링크로 들어온 회원의 신청·구매를 셉니다' },
|
||||
integration: { title: '연동 관리', desc: '아임웹 연결과 회원 동기화 상태' }
|
||||
};
|
||||
|
||||
@@ -576,7 +720,17 @@
|
||||
view: 'overview',
|
||||
cursor: null,
|
||||
listSeq: 0,
|
||||
app: { range: 'all', product: '', cursor: null, loading: false, seq: 0, products: null, productsStale: true },
|
||||
app: {
|
||||
range: 'all', product: '', cursor: null, loading: false, seq: 0, products: null, productsStale: true,
|
||||
// 신청 유입 집계 — basis: apply_last | apply_first | join, inflowFilter: 막대를 눌러 건 목록 필터
|
||||
basis: 'apply_last', group: 'source', statsSeq: 0, inflowFilter: null
|
||||
},
|
||||
// 업체 링크 — partnerId 는 링크 목록 필터, conv 는 열어 둔 실적 상세
|
||||
ptn: {
|
||||
range: 'all', partnerId: '', cursor: null, loading: false, seq: 0,
|
||||
partners: [], products: null, syncTimer: null,
|
||||
conv: { linkId: '', label: '', type: '', cursor: null, loading: false, seq: 0 }
|
||||
},
|
||||
suggestLoaded: false,
|
||||
suggestions: {},
|
||||
groupCode: '', // 선택한 회원 그룹 (폼 reset 으로 지워지지 않게 상태로 둔다)
|
||||
@@ -792,6 +946,7 @@
|
||||
if (view === 'overview') loadOverview();
|
||||
else if (view === 'members') loadMembers(true);
|
||||
else if (view === 'applications') { state.app.productsStale = true; loadApplications(true); }
|
||||
else if (view === 'partners') loadPartnersView();
|
||||
else if (view === 'integration') renderIntegration();
|
||||
}
|
||||
|
||||
@@ -1291,9 +1446,52 @@
|
||||
var q = appForm.q.value.trim();
|
||||
if (q) out.q = q;
|
||||
if (state.app.product) out.productIdx = state.app.product;
|
||||
var inf = state.app.inflowFilter;
|
||||
if (inf) { out.inflowBasis = inf.basis; out.inflowField = inf.field; out.inflowValue = inf.value; }
|
||||
return out;
|
||||
}
|
||||
|
||||
/* 신청 유입 집계 — 같은 신청을 신청 때 유입 / 가입 경로로 나눠 센다 */
|
||||
var APP_BASIS_LABEL = { apply_last: '신청 때(최종)', apply_first: '신청 때(최초)', join: '가입 경로' };
|
||||
var APP_GROUP_LABEL = { source: '유입원', medium: '매체', campaign: '캠페인', content: '콘텐츠' };
|
||||
var APP_STATS_TOP = 12;
|
||||
|
||||
function loadAppStats() {
|
||||
var app = state.app;
|
||||
var seq = ++app.statsSeq;
|
||||
var q = { basis: app.basis, groupBy: app.group };
|
||||
var f = appFilters(); // 기간·상품만 쓴다 (검색어·유입 필터는 목록 전용)
|
||||
if (f.appliedFrom) q.appliedFrom = f.appliedFrom;
|
||||
if (f.appliedTo) q.appliedTo = f.appliedTo;
|
||||
if (f.productIdx) q.productIdx = f.productIdx;
|
||||
api('GET', '/admin/applications/inflow-stats' + toQuery(q)).then(function (s) {
|
||||
if (seq !== app.statsSeq) return;
|
||||
$('[data-role="app-stats-bars"]').innerHTML = barsHtml(s.items.slice(0, APP_STATS_TOP), true);
|
||||
$('[data-role="app-stats-desc"]').textContent = '신청 ' + num(s.total) + '건 · ' +
|
||||
APP_BASIS_LABEL[s.basis] + ' 기준 · 막대를 누르면 아래 목록을 거릅니다';
|
||||
}).catch(function (err) { if (seq === app.statsSeq) errorNotice(err); });
|
||||
}
|
||||
|
||||
function renderAppInflowFilter() {
|
||||
var inf = state.app.inflowFilter;
|
||||
$('[data-role="app-inflow-filter"]').innerHTML = inf
|
||||
? '<span class="mgm-active-chip"><b>' + esc(APP_BASIS_LABEL[inf.basis] + ' ' + APP_GROUP_LABEL[inf.field]) + '</b> ' +
|
||||
esc(inf.value) + '<button type="button" data-app-clear-inflow aria-label="유입 필터 지우기">×</button></span>'
|
||||
: '';
|
||||
}
|
||||
|
||||
// 신청 때 유입 칸 — 최종 유입을 보여주고, 첫 경로가 다르면 '최초'로 함께 적는다
|
||||
function applyInflowCell(a) {
|
||||
var first = a.inflow, last = a.lastInflow && a.lastInflow.source ? a.lastInflow : first;
|
||||
var html = inflowText(last);
|
||||
if (first && first.source && last && last.source &&
|
||||
(first.source !== last.source || first.medium !== last.medium || first.campaign !== last.campaign)) {
|
||||
html += '<br><span class="mgm-muted" title="신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)">최초: ' +
|
||||
esc(first.source) + (first.medium ? ' / ' + esc(first.medium) : '') + '</span>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function productLabel(p) {
|
||||
return (p.productName || '상품') + ' #' + p.productIdx;
|
||||
}
|
||||
@@ -1343,7 +1541,7 @@
|
||||
'<td>' + (a.kakaoClickedAt
|
||||
? '<span class="mgm-badge is-ok" title="' + esc(kstDateTime(a.kakaoClickedAt)) + '">눌렀음</span>'
|
||||
: '<span class="mgm-muted">-</span>') + '</td>' +
|
||||
'<td class="mgm-wrap">' + inflowText(a.inflow) + '</td>' +
|
||||
'<td class="mgm-wrap">' + applyInflowCell(a) + '</td>' +
|
||||
'<td class="mgm-wrap">' + inflowText(m && m.inflow) + '</td>' +
|
||||
'<td class="mgm-group-cell">' + (m && m.groups.length
|
||||
? m.groups.map(function (g) { return '<span class="mgm-group-badge">' + esc(g.title) + '</span>'; }).join('')
|
||||
@@ -1357,7 +1555,7 @@
|
||||
if (!reset && app.loading) return;
|
||||
var seq = ++app.seq;
|
||||
app.loading = true;
|
||||
if (reset) { app.cursor = null; loadAppProducts(); }
|
||||
if (reset) { app.cursor = null; loadAppProducts(); loadAppStats(); renderAppInflowFilter(); }
|
||||
|
||||
var q = appFilters();
|
||||
q.limit = PAGE_SIZE;
|
||||
@@ -1404,6 +1602,37 @@
|
||||
loadApplications(true);
|
||||
});
|
||||
|
||||
// 집계 기준·항목 바꾸기 — 집계만 다시 센다 (목록 필터는 막대를 누를 때 바뀐다)
|
||||
each($all('[data-app-basis]'), function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
state.app.basis = b.getAttribute('data-app-basis');
|
||||
each($all('[data-app-basis]'), function (x) { x.classList.toggle('is-active', x === b); });
|
||||
loadAppStats();
|
||||
});
|
||||
});
|
||||
each($all('[data-app-group]'), function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
state.app.group = b.getAttribute('data-app-group');
|
||||
each($all('[data-app-group]'), function (x) { x.classList.toggle('is-active', x === b); });
|
||||
loadAppStats();
|
||||
});
|
||||
});
|
||||
// 막대 → 같은 기준·항목·값으로 목록을 거른다 (다시 누르면 해제)
|
||||
$('[data-role="app-stats-bars"]').addEventListener('click', function (e) {
|
||||
var bar = e.target.closest ? e.target.closest('.mgm-bar') : null;
|
||||
if (!bar || bar.disabled) return;
|
||||
var value = bar.getAttribute('data-key');
|
||||
var cur = state.app.inflowFilter;
|
||||
var same = cur && cur.basis === state.app.basis && cur.field === state.app.group && cur.value === value;
|
||||
state.app.inflowFilter = same ? null : { basis: state.app.basis, field: state.app.group, value: value };
|
||||
loadApplications(true);
|
||||
});
|
||||
$('[data-role="app-inflow-filter"]').addEventListener('click', function (e) {
|
||||
if (!e.target.closest || !e.target.closest('[data-app-clear-inflow]')) return;
|
||||
state.app.inflowFilter = null;
|
||||
loadApplications(true);
|
||||
});
|
||||
|
||||
var appSearchTimer = null;
|
||||
appForm.q.addEventListener('input', function () {
|
||||
clearTimeout(appSearchTimer);
|
||||
@@ -1415,6 +1644,399 @@
|
||||
loadApplications(true);
|
||||
});
|
||||
|
||||
/* ===================== 업체 링크 ===================== */
|
||||
var ptnPanel = $('[data-panel="partners"]');
|
||||
var ptnPartnerForm = $('[data-role="ptn-partner-form"]');
|
||||
var ptnLinkForm = $('[data-role="ptn-link-form"]');
|
||||
var PTN_LIST_LIMIT = 100; // 업체·상품 선택 목록은 한 번에 받는다 (API 최대값)
|
||||
var PTN_PRODUCT_STATUS = { sale: '판매중', soldout: '품절', nosale: '숨김' };
|
||||
var PTN_SYNC_HINT = {
|
||||
IMWEB_006: '주문 읽기 권한이 없습니다 — 연동 관리에서 아임웹 연결을 다시 해 주세요',
|
||||
IMWEB_001: '아임웹이 연결되어 있지 않습니다'
|
||||
};
|
||||
var PTN_CONV_TYPE = {
|
||||
application: '<span class="mgm-badge is-ok">신청</span>',
|
||||
purchase: '<span class="mgm-badge is-warn">구매</span>'
|
||||
};
|
||||
|
||||
function ptnPeriod() {
|
||||
var f = rangeQuery(state.ptn.range), out = {};
|
||||
if (f.joinedFrom) out.from = f.joinedFrom;
|
||||
if (f.joinedTo) out.to = f.joinedTo;
|
||||
return out;
|
||||
}
|
||||
function statCells(s) {
|
||||
return '<td>' + num(s.clickCount) + '</td><td>' + num(s.applicationCount) + '</td><td>' + num(s.purchaseCount) + '</td>';
|
||||
}
|
||||
function findPartner(id) {
|
||||
return state.ptn.partners.filter(function (p) { return p.id === id; })[0] || null;
|
||||
}
|
||||
|
||||
function loadPartnersView() {
|
||||
loadPartners();
|
||||
loadPtnProducts(false);
|
||||
loadLinks(true);
|
||||
loadOrderSyncStatus();
|
||||
}
|
||||
|
||||
// 업체 목록 + 링크 발급 폼의 업체 선택
|
||||
function loadPartners() {
|
||||
return api('GET', '/admin/partners' + toQuery(Object.assign({ limit: PTN_LIST_LIMIT }, ptnPeriod())))
|
||||
.then(function (page) {
|
||||
state.ptn.partners = page.items;
|
||||
renderPartners();
|
||||
renderPartnerSelect();
|
||||
renderLinkFilter();
|
||||
}).catch(errorNotice);
|
||||
}
|
||||
|
||||
function renderPartners() {
|
||||
var list = state.ptn.partners;
|
||||
$('[data-role="ptn-partner-rows"]').innerHTML = list.map(function (p) {
|
||||
return '<tr' + (p.id === state.ptn.partnerId ? ' class="is-selected"' : '') + '>' +
|
||||
'<td class="mgm-wrap"><button type="button" class="mgm-link" data-ptn-partner="' + esc(p.id) +
|
||||
'" title="이 업체의 링크만 보기">' + esc(p.name) + '</button>' +
|
||||
(p.memo ? '<br><span class="mgm-muted">' + esc(p.memo) + '</span>' : '') + '</td>' +
|
||||
'<td>' + num(p.linkCount) + '</td>' + statCells(p.stats) +
|
||||
'<td><button type="button" class="mgm-link" data-ptn-edit-partner="' + esc(p.id) + '">수정</button>' +
|
||||
(p.linkCount === 0
|
||||
? '<button type="button" class="mgm-link" data-ptn-delete-partner="' + esc(p.id) + '">삭제</button>'
|
||||
: '') + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
$('[data-role="ptn-partner-empty"]').hidden = list.length > 0;
|
||||
$('[data-role="ptn-partner-total"]').textContent = list.length ? num(list.length) + '곳' : '';
|
||||
}
|
||||
|
||||
function renderPartnerSelect() {
|
||||
var sel = ptnLinkForm.partnerId, current = sel.value || state.ptn.partnerId;
|
||||
var list = state.ptn.partners;
|
||||
sel.innerHTML = list.length
|
||||
? '<option value="">업체 선택</option>' + list.map(function (p) {
|
||||
return '<option value="' + esc(p.id) + '">' + esc(p.name) + '</option>';
|
||||
}).join('')
|
||||
: '<option value="">업체를 먼저 추가해 주세요</option>';
|
||||
sel.value = current; // 없는 값이면 빈 값으로 돌아간다
|
||||
}
|
||||
|
||||
function loadPtnProducts(force) {
|
||||
if (state.ptn.products && !force) { renderProductSelect(); return Promise.resolve(); }
|
||||
return api('GET', '/admin/products' + toQuery({ limit: PTN_LIST_LIMIT })).then(function (page) {
|
||||
state.ptn.products = page.items;
|
||||
renderProductSelect();
|
||||
}).catch(errorNotice);
|
||||
}
|
||||
|
||||
function renderProductSelect() {
|
||||
var sel = ptnLinkForm.prodNo, current = sel.value;
|
||||
var list = state.ptn.products || [];
|
||||
sel.innerHTML = list.length
|
||||
? '<option value="">상품 선택</option>' + list.map(function (p) {
|
||||
var status = p.prodStatus !== 'sale' && PTN_PRODUCT_STATUS[p.prodStatus];
|
||||
return '<option value="' + esc(p.prodNo) + '">' + esc(p.name) + ' #' + esc(p.prodNo) +
|
||||
(status ? ' (' + esc(status) + ')' : '') + '</option>';
|
||||
}).join('')
|
||||
: '<option value="">\'상품 불러오기\'를 눌러 주세요</option>';
|
||||
sel.value = current;
|
||||
}
|
||||
|
||||
function renderLinkFilter() {
|
||||
var p = findPartner(state.ptn.partnerId);
|
||||
$('[data-role="ptn-link-filter"]').innerHTML = p
|
||||
? '<span class="mgm-active-chip"><b>업체</b> ' + esc(p.name) +
|
||||
'<button type="button" data-action="ptn-clear-partner" aria-label="업체 필터 지우기">×</button></span>'
|
||||
: '';
|
||||
}
|
||||
|
||||
function linkRowHtml(l) {
|
||||
var label = l.partner.name + ' · ' + (l.product.name || '#' + l.product.prodNo);
|
||||
return '<tr' + (l.id === state.ptn.conv.linkId ? ' class="is-selected"' : '') + ' data-link-row="' + esc(l.id) + '">' +
|
||||
'<td>' + esc(l.partner.name) + '</td>' +
|
||||
'<td class="mgm-wrap">' + (l.product.name ? esc(l.product.name) : '<span class="mgm-muted">아임웹에서 지운 상품</span>') +
|
||||
' <span class="mgm-muted">#' + esc(l.product.prodNo) + '</span></td>' +
|
||||
'<td><span class="mgm-code">' + esc(l.shortUrl) + '</span>' +
|
||||
'<button type="button" class="mgm-link" data-ptn-copy="' + esc(l.shortUrl) + '">복사</button>' +
|
||||
'<br><span class="mgm-muted mgm-url" title="' + esc(l.targetUrl) + '">→ ' + esc(l.targetUrl) + '</span></td>' +
|
||||
statCells(l.stats) +
|
||||
'<td>' + (l.isActive ? '<span class="mgm-badge is-ok">사용</span>' : '<span class="mgm-badge">중지</span>') + '</td>' +
|
||||
'<td>' + esc(kstDateTime(l.createdAt).slice(0, 10)) + '</td>' +
|
||||
'<td>' +
|
||||
'<button type="button" class="mgm-link" data-ptn-conv="' + esc(l.id) + '" data-label="' + esc(label) + '">실적</button>' +
|
||||
'<button type="button" class="mgm-link" data-ptn-edit-link="' + esc(l.id) + '" data-url="' + esc(l.targetUrl) + '">주소 변경</button>' +
|
||||
'<button type="button" class="mgm-link" data-ptn-toggle-link="' + esc(l.id) + '" data-active="' + (l.isActive ? '1' : '') + '">' +
|
||||
(l.isActive ? '중지' : '다시 사용') + '</button>' +
|
||||
'</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
// 필터를 빠르게 바꿔도 마지막 요청 결과만 그린다 (회원 목록과 같은 방식)
|
||||
function loadLinks(reset) {
|
||||
var ptn = state.ptn;
|
||||
if (!reset && ptn.loading) return;
|
||||
var seq = ++ptn.seq;
|
||||
ptn.loading = true;
|
||||
if (reset) ptn.cursor = null;
|
||||
|
||||
var q = Object.assign({ limit: PAGE_SIZE }, ptnPeriod());
|
||||
if (ptn.partnerId) q.partnerId = ptn.partnerId;
|
||||
if (ptn.cursor) q.cursor = ptn.cursor;
|
||||
|
||||
var moreBtn = $('[data-action="ptn-link-more"]');
|
||||
moreBtn.disabled = true;
|
||||
|
||||
api('GET', '/admin/partner-links' + toQuery(q)).then(function (page) {
|
||||
if (seq !== ptn.seq) return;
|
||||
var tbody = $('[data-role="ptn-link-rows"]');
|
||||
var html = page.items.map(linkRowHtml).join('');
|
||||
if (reset) tbody.innerHTML = html; else tbody.insertAdjacentHTML('beforeend', html);
|
||||
ptn.cursor = page.pageInfo.nextCursor;
|
||||
moreBtn.hidden = !page.pageInfo.hasNext;
|
||||
$('[data-role="ptn-link-empty"]').hidden = tbody.children.length > 0;
|
||||
var total = page.pageInfo.total;
|
||||
$('[data-role="ptn-link-total"]').textContent = total === null ? '' : '총 ' + num(total) + '개';
|
||||
}).catch(function (err) {
|
||||
if (seq === ptn.seq) errorNotice(err);
|
||||
}).then(function () {
|
||||
if (seq !== ptn.seq) return;
|
||||
ptn.loading = false;
|
||||
moreBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function convRowHtml(c) {
|
||||
var m = c.member;
|
||||
var idCell = m
|
||||
? esc(m.uid) + (m.email && m.email !== m.uid ? '<br><span class="mgm-muted">' + esc(m.email) + '</span>' : '')
|
||||
: esc(c.memberCode) + '<br><span class="mgm-muted">회원 정보 없음 (탈퇴 등)</span>';
|
||||
return '<tr>' +
|
||||
'<td>' + esc(kstDateTime(c.convertedAt)) + '</td>' +
|
||||
'<td>' + (PTN_CONV_TYPE[c.type] || esc(c.type)) +
|
||||
(c.isValid ? '' : ' <span class="mgm-badge is-danger" title="환불·취소되어 집계에서 빠졌습니다">환불·취소</span>') + '</td>' +
|
||||
'<td>' + dash(m && m.name) + '</td>' +
|
||||
'<td>' + idCell + '</td>' +
|
||||
'<td>' + dash(m && m.callnum) + '</td>' +
|
||||
'<td>' + dash(c.orderNo) + '</td>' +
|
||||
'<td>' + esc(kstDateTime(c.touchedAt)) + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
function markSelectedLink() {
|
||||
each($all('[data-link-row]'), function (tr) {
|
||||
tr.classList.toggle('is-selected', tr.getAttribute('data-link-row') === state.ptn.conv.linkId);
|
||||
});
|
||||
}
|
||||
|
||||
function openConversions(linkId, label) {
|
||||
var conv = state.ptn.conv;
|
||||
conv.linkId = linkId;
|
||||
conv.type = '';
|
||||
each($all('[data-ptn-conv-type]'), function (x) {
|
||||
x.classList.toggle('is-active', x.getAttribute('data-ptn-conv-type') === '');
|
||||
});
|
||||
$('[data-role="ptn-conv-title"]').textContent = label + ' · 전체 기간';
|
||||
var card = $('[data-role="ptn-conv-card"]');
|
||||
card.hidden = false;
|
||||
markSelectedLink();
|
||||
loadConversions(true);
|
||||
if (card.scrollIntoView) card.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
function loadConversions(reset) {
|
||||
var conv = state.ptn.conv;
|
||||
if (!conv.linkId || (!reset && conv.loading)) return;
|
||||
var seq = ++conv.seq;
|
||||
conv.loading = true;
|
||||
if (reset) conv.cursor = null;
|
||||
|
||||
var q = { limit: PAGE_SIZE };
|
||||
if (conv.type) q.type = conv.type;
|
||||
if (conv.cursor) q.cursor = conv.cursor;
|
||||
|
||||
var moreBtn = $('[data-action="ptn-conv-more"]');
|
||||
moreBtn.disabled = true;
|
||||
|
||||
api('GET', '/admin/partner-links/' + encodeURIComponent(conv.linkId) + '/conversions' + toQuery(q)).then(function (page) {
|
||||
if (seq !== conv.seq) return;
|
||||
var tbody = $('[data-role="ptn-conv-rows"]');
|
||||
var html = page.items.map(convRowHtml).join('');
|
||||
if (reset) tbody.innerHTML = html; else tbody.insertAdjacentHTML('beforeend', html);
|
||||
conv.cursor = page.pageInfo.nextCursor;
|
||||
moreBtn.hidden = !page.pageInfo.hasNext;
|
||||
$('[data-role="ptn-conv-empty"]').hidden = tbody.children.length > 0;
|
||||
}).catch(function (err) {
|
||||
if (seq === conv.seq) errorNotice(err);
|
||||
}).then(function () {
|
||||
if (seq !== conv.seq) return;
|
||||
conv.loading = false;
|
||||
moreBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function renderOrderSync(s) {
|
||||
var el = $('[data-role="ptn-order-sync"]');
|
||||
if (s.isRunning) { el.textContent = '구매 집계 중…'; return; }
|
||||
if (s.lastErrorCode) {
|
||||
el.innerHTML = '<span class="mgm-badge is-danger">' + esc(s.lastErrorCode) + '</span> ' +
|
||||
esc(PTN_SYNC_HINT[s.lastErrorCode] || '구매 집계에 실패했습니다');
|
||||
return;
|
||||
}
|
||||
el.textContent = s.lastFinishedAt
|
||||
? '구매 집계 ' + kstDateTime(s.lastFinishedAt) + ' · 주문 ' + num(s.checkedOrderCount) + '건 확인'
|
||||
: '구매 집계 대기 중 (1시간마다 자동)';
|
||||
}
|
||||
|
||||
function loadOrderSyncStatus() {
|
||||
return api('GET', '/admin/partner-order-sync-runs/latest').then(function (s) {
|
||||
renderOrderSync(s);
|
||||
return s;
|
||||
}).catch(function (err) { errorNotice(err); return null; });
|
||||
}
|
||||
|
||||
// 새로고침이 끝날 때까지 상태를 확인하고, 끝나면 숫자를 다시 불러온다
|
||||
function pollOrderSync() {
|
||||
clearTimeout(state.ptn.syncTimer);
|
||||
state.ptn.syncTimer = setTimeout(function () {
|
||||
loadOrderSyncStatus().then(function (s) {
|
||||
if (s && s.isRunning) { pollOrderSync(); return; }
|
||||
$('[data-action="ptn-order-sync"]').disabled = false;
|
||||
if (s && !s.lastErrorCode) notice('구매 집계를 마쳤습니다. 새로 잡힌 구매 ' + num(s.createdCount) + '건', 'ok');
|
||||
if (state.view === 'partners') {
|
||||
loadPartners();
|
||||
loadLinks(true);
|
||||
loadConversions(true);
|
||||
}
|
||||
});
|
||||
}, SYNC_POLL_MS);
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
if (!navigator.clipboard || !navigator.clipboard.writeText) return Promise.resolve(false);
|
||||
return navigator.clipboard.writeText(text).then(function () { return true; }, function () { return false; });
|
||||
}
|
||||
|
||||
each($all('[data-ptn-range]'), function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
state.ptn.range = b.getAttribute('data-ptn-range');
|
||||
each($all('[data-ptn-range]'), function (x) { x.classList.toggle('is-active', x === b); });
|
||||
loadPartners();
|
||||
loadLinks(true);
|
||||
});
|
||||
});
|
||||
|
||||
ptnPartnerForm.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
// form.name 은 폼 자신의 name 속성이라 입력란 이름을 partnerName 으로 둔다
|
||||
var name = ptnPartnerForm.partnerName.value.trim();
|
||||
if (!name) return;
|
||||
var body = { name: name };
|
||||
var memo = ptnPartnerForm.memo.value.trim();
|
||||
if (memo) body.memo = memo;
|
||||
api('POST', '/admin/partners', body).then(function (p) {
|
||||
ptnPartnerForm.reset();
|
||||
notice('업체 "' + esc(p.name) + '"을(를) 추가했습니다. 오른쪽에서 링크를 발급하세요.', 'ok');
|
||||
ptnLinkForm.partnerId.value = '';
|
||||
return loadPartners().then(function () { ptnLinkForm.partnerId.value = p.id; });
|
||||
}).catch(errorNotice);
|
||||
});
|
||||
|
||||
ptnLinkForm.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var body = {
|
||||
partnerId: ptnLinkForm.partnerId.value,
|
||||
prodNo: Number(ptnLinkForm.prodNo.value),
|
||||
targetUrl: ptnLinkForm.targetUrl.value.trim()
|
||||
};
|
||||
if (!body.partnerId || !body.prodNo || !body.targetUrl) return;
|
||||
api('POST', '/admin/partner-links', body).then(function (l) {
|
||||
ptnLinkForm.targetUrl.value = '';
|
||||
notice('링크를 발급했습니다. 업체에 이 주소를 전달하세요: <b>' + esc(l.shortUrl) + '</b>', 'ok');
|
||||
loadPartners();
|
||||
loadLinks(true);
|
||||
}).catch(errorNotice);
|
||||
});
|
||||
|
||||
ptnPanel.addEventListener('click', function (e) {
|
||||
var t = e.target.closest ? e.target.closest('button') : null;
|
||||
if (!t || !ptnPanel.contains(t)) return;
|
||||
var action = t.getAttribute('data-action');
|
||||
var id;
|
||||
|
||||
if (action === 'ptn-product-sync') {
|
||||
t.disabled = true;
|
||||
api('POST', '/admin/products/sync-runs').then(function (r) {
|
||||
notice('아임웹 상품 ' + num(r.fetchedCount) + '개를 불러왔습니다.', 'ok');
|
||||
return loadPtnProducts(true);
|
||||
}).catch(errorNotice).then(function () { t.disabled = false; });
|
||||
} else if (action === 'ptn-order-sync') {
|
||||
t.disabled = true;
|
||||
api('POST', '/admin/partner-order-sync-runs').then(function (s) {
|
||||
renderOrderSync(s);
|
||||
pollOrderSync();
|
||||
}).catch(function (err) { t.disabled = false; errorNotice(err); });
|
||||
} else if (action === 'ptn-link-more') {
|
||||
loadLinks(false);
|
||||
} else if (action === 'ptn-conv-more') {
|
||||
loadConversions(false);
|
||||
} else if (action === 'ptn-conv-close') {
|
||||
state.ptn.conv.linkId = '';
|
||||
$('[data-role="ptn-conv-card"]').hidden = true;
|
||||
markSelectedLink();
|
||||
} else if (action === 'ptn-clear-partner') {
|
||||
state.ptn.partnerId = '';
|
||||
renderPartners();
|
||||
renderLinkFilter();
|
||||
loadLinks(true);
|
||||
} else if ((id = t.getAttribute('data-ptn-partner'))) {
|
||||
// 같은 업체를 다시 누르면 필터를 푼다
|
||||
state.ptn.partnerId = state.ptn.partnerId === id ? '' : id;
|
||||
if (state.ptn.partnerId) ptnLinkForm.partnerId.value = id;
|
||||
renderPartners();
|
||||
renderLinkFilter();
|
||||
loadLinks(true);
|
||||
} else if ((id = t.getAttribute('data-ptn-edit-partner'))) {
|
||||
var partner = findPartner(id);
|
||||
if (!partner) return;
|
||||
var newName = window.prompt('업체 이름', partner.name);
|
||||
if (newName === null || !newName.trim()) return;
|
||||
var newMemo = window.prompt('메모 (비우면 지웁니다)', partner.memo || '');
|
||||
if (newMemo === null) return;
|
||||
api('PATCH', '/admin/partners/' + encodeURIComponent(id), { name: newName.trim(), memo: newMemo.trim() })
|
||||
.then(function () { loadPartners(); loadLinks(true); })
|
||||
.catch(errorNotice);
|
||||
} else if ((id = t.getAttribute('data-ptn-delete-partner'))) {
|
||||
var target = findPartner(id);
|
||||
if (!target || !window.confirm('업체 "' + target.name + '"을(를) 삭제할까요?')) return;
|
||||
api('DELETE', '/admin/partners/' + encodeURIComponent(id)).then(function () {
|
||||
if (state.ptn.partnerId === id) state.ptn.partnerId = '';
|
||||
loadPartners();
|
||||
}).catch(errorNotice);
|
||||
} else if (t.hasAttribute('data-ptn-copy')) {
|
||||
var url = t.getAttribute('data-ptn-copy');
|
||||
copyText(url).then(function (ok) {
|
||||
notice(ok ? '링크를 복사했습니다: <b>' + esc(url) + '</b>'
|
||||
: '복사하지 못했습니다. 직접 선택해 복사해 주세요: <b>' + esc(url) + '</b>', ok ? 'ok' : 'error');
|
||||
});
|
||||
} else if ((id = t.getAttribute('data-ptn-conv'))) {
|
||||
openConversions(id, t.getAttribute('data-label') || '');
|
||||
} else if ((id = t.getAttribute('data-ptn-edit-link'))) {
|
||||
var nextUrl = window.prompt('이동할 상품 페이지 주소', t.getAttribute('data-url') || '');
|
||||
if (nextUrl === null || !nextUrl.trim()) return;
|
||||
api('PATCH', '/admin/partner-links/' + encodeURIComponent(id), { targetUrl: nextUrl.trim() })
|
||||
.then(function () { notice('이동 주소를 바꿨습니다. 업체에 준 링크는 그대로 쓰면 됩니다.', 'ok'); loadLinks(true); })
|
||||
.catch(errorNotice);
|
||||
} else if ((id = t.getAttribute('data-ptn-toggle-link'))) {
|
||||
var isActive = t.getAttribute('data-active') === '1';
|
||||
if (isActive && !window.confirm('이 링크를 중지할까요?\n중지해도 상품 페이지로 이동은 되지만, 클릭·실적은 더 이상 쌓이지 않습니다.')) return;
|
||||
api('PATCH', '/admin/partner-links/' + encodeURIComponent(id), { isActive: !isActive })
|
||||
.then(function () { loadLinks(true); })
|
||||
.catch(errorNotice);
|
||||
} else if (t.hasAttribute('data-ptn-conv-type')) {
|
||||
state.ptn.conv.type = t.getAttribute('data-ptn-conv-type');
|
||||
each($all('[data-ptn-conv-type]'), function (x) { x.classList.toggle('is-active', x === t); });
|
||||
loadConversions(true);
|
||||
}
|
||||
});
|
||||
|
||||
/* ===================== 회원 목록 ===================== */
|
||||
var STATUS_BADGE = {
|
||||
at_join: '<span class="mgm-badge is-ok">가입 시</span>',
|
||||
@@ -1497,8 +2119,8 @@
|
||||
if (!result) return;
|
||||
// 아임웹이 동의 단계에서 거부한 경우의 코드 — 개발자센터 설정으로 고칠 수 있는 것들
|
||||
var IMWEB_HINT = {
|
||||
'30156': '개발자센터 앱 권한에 "사이트 정보 읽기∙쓰기(site-info:write)"를 켜 주세요.',
|
||||
'30099': '개발자센터 앱 권한에 "회원 정보 읽기(member-info:read)"와 "사이트 정보 읽기∙쓰기"를 켜 주세요.',
|
||||
'30156': '개발자센터 앱 권한에 "사이트 정보 읽기∙쓰기", "회원 정보 읽기", "상품 읽기", "주문 읽기"를 모두 켜 주세요.',
|
||||
'30099': '개발자센터 앱 권한에 "사이트 정보 읽기∙쓰기", "회원 정보 읽기", "상품 읽기", "주문 읽기"를 모두 켜 주세요.',
|
||||
'30098': '개발자센터 앱의 리다이렉트 URI 를 확인해 주세요.'
|
||||
};
|
||||
if (result === 'connected') notice('아임웹이 연결되었습니다. 첫 동기화를 시작합니다.', 'ok');
|
||||
|
||||
Reference in New Issue
Block a user