27 Commits

Author SHA1 Message Date
ttipo 11d8b07d43 Merge branch 'feat/partner-accounts'
CI (Flutter) / Flutter (analyze · test) (push) Has been cancelled
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
2026-10-06 18:22:27 +09:00
ttipo 10a931aba2 docs: 업체 계정 운영 안내와 권한 구조 정리
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:22:21 +09:00
ttipo 696449bc42 feat(imweb): 업체 계정 로그인 화면과 최고관리자 계정 관리
- 업체 계정: '내 업체 실적' 메뉴만 보인다 (기간별 클릭·신청·구매, 나눠 줄 링크, 가린 이름의 신청·구매 목록).
  임시 비밀번호면 닫을 수 없는 비밀번호 변경 창을 먼저 띄운다 (대신 로그아웃 가능)
- 최고관리자: 업체 링크 → 업체 선택 → '계정 관리'에서 만들기·사용/중지·비밀번호 초기화.
  임시 비밀번호는 한 번만 보여주고 바깥 클릭·Esc 로 닫히지 않는다. 복사해도 알림에 비밀번호를 띄우지 않는다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:22:20 +09:00
ttipo 97780eb4b6 feat(backend): 최고관리자가 만드는 업체 계정과 업체 실적 화면 API
관리자 화면에는 env 계정 하나만 있어 업체가 자기 실적을 볼 방법이 없었다.
env 계정은 최고관리자로 그대로 두고, 최고관리자가 업체마다 로그인 계정을 만든다.

- admin_accounts(업체 계정) · admin_sessions.account_id — 비밀번호는 bcrypt 해시만
- 토큰에 역할(super·partner)을 싣고, 계정 지문으로 비밀번호 변경·초기화 시 다른 기기를 끊는다.
  중지된 계정은 즉시 거부한다
- 가드 분리: AdminAuthGuard 는 최고관리자 전용(기존 관리 API 전부, 업체 토큰은 AUTH_003),
  PartnerAuthGuard 는 업체 계정 전용(임시 비밀번호면 ACCOUNT_003), AccountAuthGuard 는 공통
- 업체 계정 관리: 만들기·사용/중지·비밀번호 초기화 — 임시 비밀번호는 응답에서 한 번만
- 업체 화면 /api/partner-portal: 요약·링크·신청·구매. 업체 ID 는 로그인 정보에서만 가져오고
  신청·구매자는 가린 이름만 내려준다 (연락처·이메일·아이디·주문번호 없음)
- 없는 아이디도 bcrypt 비교를 거쳐 응답 시간으로 아이디 존재를 알 수 없게 한다.
  bcryptjs(순수 JS)라 비용은 10 — 무차별 대입은 로그인 요청 제한이 함께 막는다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:22:20 +09:00
ttipo 7b5d16bfea feat(backend): 업체 계정용 에러 코드 등록 (ACCOUNT_001~003)
- ACCOUNT_001 (409): 이미 사용 중인 아이디
- ACCOUNT_002 (400): 현재 비밀번호 불일치
- ACCOUNT_003 (403): 임시 비밀번호를 바꾸기 전 실적 조회

npm run sync:error-codes 로 프론트엔드·Flutter 사전을 함께 갱신했다.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:11:52 +09:00
ttipo 59c84dcac5 feat: 업체 링크 관리 화면과 아임웹 추적 스크립트 추가
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
CI (Flutter) / Flutter (analyze · test) (push) Has been cancelled
- 관리자 화면 '업체 링크' 메뉴: 업체 추가, 링크 발급·복사·중지, 기간별 클릭·신청·구매,
  링크별 실적 회원 목록, 상품 불러오기·구매 집계 새로고침
- partner-ref.html (Header Code): 클릭 ID 를 30일 보관하고 로그인 회원과 잇는다
- imweb-free-apply.html: 신청 기록(클릭 ID 포함)과 오픈카톡 클릭 기록 전송 추가
- README 에 귀속 규칙·한계·처음 켤 때 순서 정리

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:47:10 +09:00
ttipo 7bef853664 feat(backend): 업체 링크별 신청·구매 추적 API 추가
상품마다 업체에 단축 링크를 발급하고, 그 링크로 들어온 회원의 신청·구매를
업체별로 세기 위해서다. 아임웹은 주문 완료를 알려주지 않으므로 구매는
주문 API 를 주기적으로 훑어 찾는다.

- 아임웹 상품 동기화(imweb_products) — 회원 동기화와 같은 주기, 실패해도 회원 동기화는 계속
- 단축 링크 /r/{코드}: 사람의 클릭만 기록하고 클릭 ID(mg_ref)를 붙여 상품 페이지로 이동
  (이동 주소는 아임웹 사이트 안만 허용 — open redirect 방지)
- 회원 ↔ 클릭 연결(POST /api/partner-touches), 신청 기록에 클릭 ID 추가
- 마지막 클릭·30일 귀속, 결제완료 주문만 구매로 집계하고 환불·취소는 제외 (금액은 관리하지 않음)
- 관리자 API: 업체·링크 관리, 링크별 실적 상세, 구매 집계 새로고침
- 권한 요청에 product:read·order:read 추가, 토큰 권한 부족은 IMWEB_006
- 에러 코드 IMWEB_006, PARTNER_001~004 추가 및 클라이언트 사전 동기화

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:47:10 +09:00
ttipo 2f01bea6ef fix: 운영 백엔드 주소 오타 수정 (moneyguider → moneyguide.wandergo.net)
운영 백엔드 도메인은 moneyguide.wandergo.net 인데 관리자 화면 API_BASE 와 문서가
moneyguider.wandergo.net 을 가리켜 로그인 요청이 TLS 단계에서 거절되었다
(ERR_SSL_UNRECOGNIZED_NAME_ALERT). 아임웹 사이트 주소 moneyguider.co.kr 은 그대로 둔다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:14:11 +09:00
ttipo c5440e0cf4 chore(docker): 운영에서도 프론트엔드 컨테이너 기본 비활성화
CI (Flutter) / Flutter (analyze · test) (push) Has been cancelled
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
관리자 화면은 아임웹 코드 위젯(imweb/)으로 제공하므로 Vue 프론트엔드를 쓰지 않는다.
그런데 profile 이 dev 오버라이드에만 걸려 있어 운영 배포 때마다 frontend 이미지가
불필요하게 빌드·기동되었다. profile 을 base compose 로 옮겨 개발·운영 모두
backend + db 만 뜨게 하고, 필요할 때만 COMPOSE_PROFILES=frontend 로 켠다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:04:35 +09:00
112 changed files with 12553 additions and 1038 deletions
+2 -1
View File
@@ -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` 로 켠다.
## 완료 기준 (실행하고 결과를 보고한다)
+7 -2
View File
@@ -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)으로 뜬다.
---
+4
View File
@@ -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
+9 -4
View File
@@ -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
+3 -2
View File
@@ -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`)
+10
View File
@@ -20,6 +20,7 @@
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.1",
"bcryptjs": "^3.0.3",
"cache-manager": "^6.4.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
@@ -5494,6 +5495,15 @@
"node": ">=6.0.0"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/binary-version": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/binary-version/-/binary-version-7.1.0.tgz",
+1
View File
@@ -40,6 +40,7 @@
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.1",
"bcryptjs": "^3.0.3",
"cache-manager": "^6.4.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
+4
View File
@@ -7,11 +7,13 @@ import { CacheModule } from '@nestjs/cache-manager';
import { APP_GUARD } from '@nestjs/core';
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { ActivitiesModule } from '@/modules/activities/activities.module';
import { AdminAuthModule } from '@/modules/admin-auth/admin-auth.module';
import { ApplicationsModule } from '@/modules/applications/applications.module';
import { HealthModule } from '@/modules/health/health.module';
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 +70,8 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
ImwebModule,
MembersModule,
ApplicationsModule,
PartnersModule,
ActivitiesModule,
],
providers: [
ShutdownLogger,
@@ -3,6 +3,7 @@ import { ExecutionContext, createParamDecorator } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import type { AdminRequest } from '@/common/guards/admin-auth.guard';
import type { AdminResponseDto } from '@/modules/admin-auth/dto/admin-response.dto';
import type { VerifiedAccount } from '@/modules/admin-auth/admin-auth.service';
/**
* AdminAuthGuard 가 검증한 관리자를 핸들러 인자로 받는다.
@@ -15,3 +16,15 @@ export const CurrentAdmin = createParamDecorator(
return request.admin;
},
);
/**
* 가드가 검증한 계정(역할·업체 포함)을 핸들러 인자로 받는다.
* 업체 화면은 반드시 이 값의 partnerId 로 데이터를 거른다 — 요청값의 업체 ID 를 믿지 않는다.
*/
export const CurrentAccount = createParamDecorator(
(_data: unknown, context: ExecutionContext): VerifiedAccount => {
const request = context.switchToHttp().getRequest<AdminRequest>();
if (!request.account) throw new BusinessException('AUTH_001');
return request.account;
},
);
+37
View File
@@ -98,11 +98,48 @@ 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: '같은 이름의 업체가 이미 있습니다.',
},
// 업체 계정 — 최고관리자가 업체마다 만들어 주는 로그인 계정
ACCOUNT_001: {
status: HttpStatus.CONFLICT,
message: '이미 사용 중인 아이디입니다. 다른 아이디를 입력해 주세요.',
},
ACCOUNT_002: {
status: HttpStatus.BAD_REQUEST,
message: '현재 비밀번호가 올바르지 않습니다.',
},
ACCOUNT_003: {
status: HttpStatus.FORBIDDEN,
message: '처음 받은 임시 비밀번호를 변경한 뒤 이용해 주세요.',
},
} as const satisfies Record<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
@@ -0,0 +1,90 @@
import type { ExecutionContext } from '@nestjs/common';
import type {
AdminAuthService,
VerifiedAccount,
} from '@/modules/admin-auth/admin-auth.service';
import {
AccountAuthGuard,
AdminAuthGuard,
AdminRequest,
PartnerAuthGuard,
} from './admin-auth.guard';
const SUPER: VerifiedAccount = {
admin: {
username: 'admin',
role: 'super',
partner: null,
mustChangePassword: false,
},
sessionId: 's-super',
role: 'super',
accountId: null,
partnerId: null,
};
function partner(mustChangePassword = false): VerifiedAccount {
return {
admin: {
username: 'moneytube',
role: 'partner',
partner: { id: 'p-1', name: '머니튜브' },
mustChangePassword,
},
sessionId: 's-partner',
role: 'partner',
accountId: 'a-1',
partnerId: 'p-1',
};
}
function run(
Guard:
| typeof AdminAuthGuard
| typeof PartnerAuthGuard
| typeof AccountAuthGuard,
verified: VerifiedAccount,
) {
const request = {
headers: { authorization: 'Bearer token' },
} as AdminRequest;
const context = {
switchToHttp: () => ({ getRequest: () => request }),
} as unknown as ExecutionContext;
const auth = {
verifyAccessToken: jest.fn(() => Promise.resolve(verified)),
} as unknown as AdminAuthService;
return { result: new Guard(auth).canActivate(context), request };
}
describe('계정 가드 (역할별 접근)', () => {
it('기존 관리 기능(AdminAuthGuard)은 최고관리자만 — 업체 계정은 AUTH_003', async () => {
await expect(run(AdminAuthGuard, SUPER).result).resolves.toBe(true);
await expect(run(AdminAuthGuard, partner()).result).rejects.toMatchObject({
code: 'AUTH_003',
});
});
it('업체 화면(PartnerAuthGuard)은 업체 계정만 — 최고관리자는 AUTH_003', async () => {
const { result, request } = run(PartnerAuthGuard, partner());
await expect(result).resolves.toBe(true);
expect(request.account?.partnerId).toBe('p-1');
await expect(run(PartnerAuthGuard, SUPER).result).rejects.toMatchObject({
code: 'AUTH_003',
});
});
it('임시 비밀번호를 바꾸기 전에는 업체 화면을 ACCOUNT_003 으로 막는다', async () => {
await expect(
run(PartnerAuthGuard, partner(true)).result,
).rejects.toMatchObject({ code: 'ACCOUNT_003' });
});
it('계정 공통(AccountAuthGuard)은 두 역할 모두 통과한다 (임시 비밀번호여도 — 비밀번호 변경용)', async () => {
await expect(run(AccountAuthGuard, SUPER).result).resolves.toBe(true);
await expect(run(AccountAuthGuard, partner(true)).result).resolves.toBe(
true,
);
});
});
+66 -9
View File
@@ -1,31 +1,88 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import type { Request } from 'express';
import { AdminAuthService } from '@/modules/admin-auth/admin-auth.service';
import { BusinessException } from '@/common/errors/business.exception';
import type { AccountRole } from '@/modules/admin-auth/admin-auth.constants';
import {
AdminAuthService,
VerifiedAccount,
} from '@/modules/admin-auth/admin-auth.service';
import { AdminResponseDto } from '@/modules/admin-auth/dto/admin-response.dto';
import { readBearerToken } from '@/shared/utils/bearer';
/** 가드가 검증한 관리자를 요청 객체에 실어 둔다 */
/** 가드가 검증한 계정을 요청 객체에 실어 둔다 */
export interface AdminRequest extends Request {
admin?: AdminResponseDto;
/** 이 요청의 로그인 세션 — 로그아웃할 때 이 세션만 끊는다 */
adminSessionId?: string;
/** 역할·업체까지 포함한 검증 결과 — 업체 화면은 여기 partnerId 로만 데이터를 거른다 */
account?: VerifiedAccount;
}
/**
* 관리자 전용 라우트 보호 — `Authorization: Bearer` 의 액세스 토큰을 검증한다.
* 실패하면 AUTH_001(401). 화면은 401 을 받으면 리프레시 토큰으로 갱신을 한 번 시도한다.
* 토큰을 검증하고 허용된 역할인지 확인한다.
* - 토큰이 없거나 무효: AUTH_001(401) — 화면은 리프레시 토큰으로 갱신을 한 번 시도한다
* - 역할이 다르면: AUTH_003(403)
*/
async function authenticate(
context: ExecutionContext,
auth: AdminAuthService,
roles: readonly AccountRole[],
): Promise<VerifiedAccount> {
const request = context.switchToHttp().getRequest<AdminRequest>();
const token = readBearerToken(request.headers.authorization);
const verified = await auth.verifyAccessToken(token);
if (!roles.includes(verified.role)) {
throw new BusinessException('AUTH_003', {
context: { reason: 'role_not_allowed', role: verified.role },
});
}
request.admin = verified.admin;
request.adminSessionId = verified.sessionId;
request.account = verified;
return verified;
}
/**
* 최고관리자 전용 — 기존 관리 기능(회원·신청·구매·업체 링크·연동)은 모두 이 가드를 쓴다.
* 업체 계정 토큰은 AUTH_003 으로 막는다.
*/
@Injectable()
export class AdminAuthGuard implements CanActivate {
constructor(private readonly auth: AdminAuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AdminRequest>();
const token = readBearerToken(request.headers.authorization);
const verified = await this.auth.verifyAccessToken(token);
request.admin = verified.admin;
request.adminSessionId = verified.sessionId;
await authenticate(context, this.auth, ['super']);
return true;
}
}
/**
* 업체 계정 전용 — 업체 화면(partner-portal). 임시 비밀번호를 바꾸기 전에는 ACCOUNT_003(403).
* 최고관리자 토큰은 AUTH_003 — 업체 화면은 업체 ID 가 있어야 의미가 있다.
*/
@Injectable()
export class PartnerAuthGuard implements CanActivate {
constructor(private readonly auth: AdminAuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const verified = await authenticate(context, this.auth, ['partner']);
if (verified.admin.mustChangePassword) {
throw new BusinessException('ACCOUNT_003', {
context: { accountId: verified.accountId },
});
}
return true;
}
}
/** 역할 무관 — 로그인한 계정이면 된다 (현재 계정 조회·로그아웃·비밀번호 변경) */
@Injectable()
export class AccountAuthGuard implements CanActivate {
constructor(private readonly auth: AdminAuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
await authenticate(context, this.auth, ['super', 'partner']);
return true;
}
}
+6 -2
View File
@@ -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"`,
);
}
}
@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPurchases1791220773446 implements MigrationInterface {
name = 'AddPurchases1791220773446';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "purchases" ("id" uuid NOT NULL, "order_no" character varying(30) NOT NULL, "prod_no" integer NOT NULL, "product_name" character varying(200), "member_code" character varying(40), "member_uid" character varying(255), "orderer_name" character varying(100), "orderer_email" character varying(255), "orderer_call" character varying(40), "ordered_at" TIMESTAMP WITH TIME ZONE NOT NULL, "is_valid" boolean NOT NULL DEFAULT true, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_1d55032f37a34c6eceacbbca6b8" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "idx_purchases_member" ON "purchases" ("member_code") `,
);
await queryRunner.query(
`CREATE INDEX "idx_purchases_cursor" ON "purchases" ("ordered_at", "id") `,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "uq_purchases_order_product" ON "purchases" ("order_no", "prod_no") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "public"."uq_purchases_order_product"`);
await queryRunner.query(`DROP INDEX "public"."idx_purchases_cursor"`);
await queryRunner.query(`DROP INDEX "public"."idx_purchases_member"`);
await queryRunner.query(`DROP TABLE "purchases"`);
}
}
@@ -0,0 +1,57 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddOrderInflows1791258525922 implements MigrationInterface {
name = 'AddOrderInflows1791258525922';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "order_inflows" ("order_no" character varying(30) NOT NULL, "inflow_raw" text, "inflow_source" character varying(100), "inflow_medium" character varying(100), "inflow_campaign" character varying(100), "inflow_content" character varying(100), "partner_refs" character varying(16) array NOT NULL DEFAULT '{}', "received_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_5611d45b9e9708e854d3131aeda" PRIMARY KEY ("order_no"))`,
);
await queryRunner.query(
`CREATE INDEX "idx_order_inflows_received" ON "order_inflows" ("received_at") `,
);
await queryRunner.query(`ALTER TABLE "purchases" ADD "inflow_raw" text`);
await queryRunner.query(
`ALTER TABLE "purchases" ADD "inflow_source" character varying(100)`,
);
await queryRunner.query(
`ALTER TABLE "purchases" ADD "inflow_medium" character varying(100)`,
);
await queryRunner.query(
`ALTER TABLE "purchases" ADD "inflow_campaign" character varying(100)`,
);
await queryRunner.query(
`ALTER TABLE "purchases" ADD "inflow_content" character varying(100)`,
);
await queryRunner.query(
`ALTER TABLE "partner_conversions" ALTER COLUMN "member_code" DROP NOT NULL`,
);
await queryRunner.query(
`CREATE INDEX "IDX_7fc2cb346462240f6f38ea03c1" ON "purchases" ("inflow_source") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."IDX_7fc2cb346462240f6f38ea03c1"`,
);
await queryRunner.query(
`ALTER TABLE "partner_conversions" ALTER COLUMN "member_code" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "purchases" DROP COLUMN "inflow_content"`,
);
await queryRunner.query(
`ALTER TABLE "purchases" DROP COLUMN "inflow_campaign"`,
);
await queryRunner.query(
`ALTER TABLE "purchases" DROP COLUMN "inflow_medium"`,
);
await queryRunner.query(
`ALTER TABLE "purchases" DROP COLUMN "inflow_source"`,
);
await queryRunner.query(`ALTER TABLE "purchases" DROP COLUMN "inflow_raw"`);
await queryRunner.query(`DROP INDEX "public"."idx_order_inflows_received"`);
await queryRunner.query(`DROP TABLE "order_inflows"`);
}
}
@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPartnerAccounts1791269588289 implements MigrationInterface {
name = 'AddPartnerAccounts1791269588289';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "admin_accounts" ("id" uuid NOT NULL, "username" character varying(50) NOT NULL, "password_hash" character varying(100) NOT NULL, "partner_id" uuid NOT NULL, "is_active" boolean NOT NULL DEFAULT true, "must_change_password" boolean NOT NULL DEFAULT true, "last_login_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_79fb3604ed15538685183d8df5f" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "uq_admin_accounts_username" ON "admin_accounts" ("username") `,
);
await queryRunner.query(
`CREATE INDEX "idx_admin_accounts_partner" ON "admin_accounts" ("partner_id", "created_at") `,
);
await queryRunner.query(
`ALTER TABLE "admin_sessions" ADD "account_id" uuid`,
);
await queryRunner.query(
`CREATE INDEX "IDX_a0e3bf56d35eae8a062e2757cc" ON "admin_sessions" ("account_id") `,
);
await queryRunner.query(
`ALTER TABLE "admin_accounts" ADD CONSTRAINT "FK_14a57a0f0e7a2b771e39dcb32f3" FOREIGN KEY ("partner_id") REFERENCES "partners"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "admin_sessions" ADD CONSTRAINT "FK_a0e3bf56d35eae8a062e2757cc3" FOREIGN KEY ("account_id") REFERENCES "admin_accounts"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "admin_sessions" DROP CONSTRAINT "FK_a0e3bf56d35eae8a062e2757cc3"`,
);
await queryRunner.query(
`ALTER TABLE "admin_accounts" DROP CONSTRAINT "FK_14a57a0f0e7a2b771e39dcb32f3"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_a0e3bf56d35eae8a062e2757cc"`,
);
await queryRunner.query(
`ALTER TABLE "admin_sessions" DROP COLUMN "account_id"`,
);
await queryRunner.query(`DROP INDEX "public"."idx_admin_accounts_partner"`);
await queryRunner.query(`DROP INDEX "public"."uq_admin_accounts_username"`);
await queryRunner.query(`DROP TABLE "admin_accounts"`);
}
}
@@ -0,0 +1,75 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { PaginatedResult } from '@/common/dto/pagination.dto';
import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
import { ADMIN_BEARER_AUTH } from '@/modules/admin-auth/admin-auth.constants';
import { ActivitiesService } from './activities.service';
import {
ActivityGroupsQueryDto,
ActivityInflowStatsQueryDto,
ActivityListQueryDto,
} from './dto/activity-query.dto';
import {
ActivityGroupsResponseDto,
ActivityInflowStatsResponseDto,
ActivityResponseDto,
} from './dto/activity-response.dto';
/** 관리자 신청·구매 내역 — 무료 신청과 유료 구매를 한 화면에서 본다 */
@ApiTags('Admin Activities')
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@UseGuards(AdminAuthGuard)
@Controller('admin/activities')
export class AdminActivitiesController {
constructor(private readonly activities: ActivitiesService) {}
@Get()
@ApiOperation({
summary: '신청·구매 내역 (최신순, 커서 페이지네이션) — 종류로 거를 수 있다',
})
@ApiStandardResponse(ActivityResponseDto, {
description: '조회 성공 — data 는 { items, pageInfo }',
isArray: true,
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '필터·커서 형식 오류')
findMany(
@Query() query: ActivityListQueryDto,
): Promise<PaginatedResult<ActivityResponseDto>> {
return this.activities.findMany(query);
}
@Get('groups')
@ApiOperation({
summary: '상품별·업체별 신청·구매 수 — 요약 표 (기간만 거른다)',
})
@ApiStandardResponse(ActivityGroupsResponseDto, { description: '조회 성공' })
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '기준·기간 형식 오류')
groups(
@Query() query: ActivityGroupsQueryDto,
): Promise<ActivityGroupsResponseDto> {
return this.activities.groups(query);
}
@Get('inflow-stats')
@ApiOperation({
summary:
'유입 집계 — 신청·구매 때 유입 또는 가입 경로 기준 (신청·구매 모두)',
})
@ApiStandardResponse(ActivityInflowStatsResponseDto, {
description: '집계 성공',
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '필터·기준 형식 오류')
inflowStats(
@Query() query: ActivityInflowStatsQueryDto,
): Promise<ActivityInflowStatsResponseDto> {
return this.activities.inflowStats(query);
}
}
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminAuthModule } from '@/modules/admin-auth/admin-auth.module';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { MembersModule } from '@/modules/members/members.module';
import { Partner } from '@/modules/partners/entities/partner.entity';
import { AdminActivitiesController } from './activities.controller';
import { ActivitiesService } from './activities.service';
/**
* 관리자 신청·구매 내역 — 읽기 전용.
* 무료 신청(ApplicationsModule)과 유료 구매(PurchasesModule)가 쌓은 기록을 합쳐 보여준다.
*/
@Module({
imports: [
TypeOrmModule.forFeature([ImwebMember, Partner]),
AdminAuthModule,
MembersModule,
],
controllers: [AdminActivitiesController],
providers: [ActivitiesService],
})
export class ActivitiesModule {}
@@ -0,0 +1,643 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
DataSource,
In,
ObjectLiteral,
Repository,
SelectQueryBuilder,
} from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { MemberApplication } from '@/modules/applications/application.entity';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { ImwebProduct } from '@/modules/imweb/entities/imweb-product.entity';
import { InflowGroupBy } from '@/modules/members/dto/member-query.dto';
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
import { MembersService } from '@/modules/members/members.service';
import { PartnerConversion } from '@/modules/partners/entities/partner-conversion.entity';
import { Partner } from '@/modules/partners/entities/partner.entity';
import { Purchase } from '@/modules/purchases/purchase.entity';
import { InflowStatus } from '@/shared/utils/inflow';
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
import {
ActivityFilterDto,
ActivityGroupsQueryDto,
ActivityInflowStatsQueryDto,
ActivityListQueryDto,
ActivityType,
PARTNER_FILTER_NONE,
} from './dto/activity-query.dto';
import {
ActivityCountsDto,
ActivityGroupsResponseDto,
ActivityInflowStatsResponseDto,
ActivityResponseDto,
} from './dto/activity-response.dto';
const APP = 'a';
const APP_MEMBER = 'am';
const APP_CONVERSION = 'apc';
const PUR = 'p';
const PUR_MEMBER = 'pm';
const PUR_CONVERSION = 'ppc';
const UNION = 'u';
const PRODUCT = 'ip';
const PARTNER = 'pt';
/** 집계 결과 최대 줄 수 (회원 유입 집계와 같다) */
const STATS_MAX_ITEMS = 100;
/** 커서의 고유값은 신청·구매 기록 ID(UUID)다 — 형식이 다르면 DB 오류 대신 VAL_001 */
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
type ListFilter = ActivityFilterDto &
Partial<
Pick<
ActivityListQueryDto,
'inflowBasis' | 'inflowField' | 'inflowValue' | 'q'
>
>;
/** [SQL 식, 별칭] — 신청·구매 두 쪽이 같은 순서로 같은 열을 내야 UNION 할 수 있다 */
type Columns = [expression: string, alias: string][];
/** 목록 한 줄 (UNION 결과 원본) */
interface ActivityRow {
type: ActivityType;
id: string;
occurred_at: Date;
product_idx: string;
product_name: string | null;
member_code: string | null;
member_uid: string | null;
partner_id: string | null;
kakao_clicked_at: Date | null;
inflow_source: string | null;
inflow_medium: string | null;
inflow_campaign: string | null;
inflow_content: string | null;
order_no: string | null;
is_valid: boolean | null;
orderer_name: string | null;
orderer_email: string | null;
orderer_call: string | null;
}
interface CountsRow {
applicationCount: string;
kakaoCount: string;
purchaseCount: string;
cancelledCount: string;
}
/**
* 가입 경로 식 — 가입 때 기록된 값만 쓴다 (가입 후 회원정보 수정 화면에서 채워진 값은 가입 경로가 아니다).
* 항목은 enum 으로 검증된 값이라 문자열로 넣어도 안전하다.
*/
function joinInflow(memberAlias: string, field: InflowGroupBy): string {
return `CASE WHEN ${memberAlias}.inflow_status = '${InflowStatus.AT_JOIN}' THEN ${memberAlias}.inflow_${field} END`;
}
function escapeLike(value: string): string {
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
}
function selectColumns<T extends ObjectLiteral>(
qb: SelectQueryBuilder<T>,
columns: Columns,
): SelectQueryBuilder<T> {
const [[firstExpression, firstAlias], ...rest] = columns;
qb.select(firstExpression, firstAlias);
for (const [expression, alias] of rest) qb.addSelect(expression, alias);
return qb;
}
/** 신청·구매 수 — UNION 결과(u)에서 센다 */
const COUNT_COLUMNS: Columns = [
[`COUNT(*) FILTER (WHERE ${UNION}.type = 'application')`, 'applicationCount'],
[`COUNT(${UNION}.kakao_clicked_at)`, 'kakaoCount'],
[
`COUNT(*) FILTER (WHERE ${UNION}.type = 'purchase' AND ${UNION}.is_valid)`,
'purchaseCount',
],
[
`COUNT(*) FILTER (WHERE ${UNION}.type = 'purchase' AND NOT ${UNION}.is_valid)`,
'cancelledCount',
],
];
function toCounts(row: CountsRow | undefined): ActivityCountsDto {
return {
applicationCount: Number(row?.applicationCount ?? 0),
kakaoCount: Number(row?.kakaoCount ?? 0),
purchaseCount: Number(row?.purchaseCount ?? 0),
cancelledCount: Number(row?.cancelledCount ?? 0),
};
}
/**
* 관리자 신청·구매 내역 — 무료 신청(member_applications)과 유료 구매(purchases)를 한 화면에서 본다.
*
* 두 기록은 따로 쌓이고(신청은 신청 완료 화면, 구매는 아임웹 주문 동기화) 여기서 UNION ALL 로 합친다.
* 업체는 업체 실적(partner_conversions)과 이어서 보여준다 — 신청은 신청 ID, 구매는 주문번호·상품으로 잇는다.
*/
@Injectable()
export class ActivitiesService {
constructor(
private readonly dataSource: DataSource,
@InjectRepository(ImwebMember)
private readonly members: Repository<ImwebMember>,
@InjectRepository(Partner)
private readonly partners: Repository<Partner>,
private readonly membersService: MembersService,
) {}
async findMany(
query: ActivityListQueryDto,
): Promise<PaginatedResult<ActivityResponseDto>> {
const parts: SelectQueryBuilder<ObjectLiteral>[] = [];
const app = this.applicationPart(query);
if (app) {
parts.push(
selectColumns(app, [
[`'application'`, 'type'],
[`${APP}.id`, 'id'],
[`${APP}.applied_at`, 'occurred_at'],
[`${APP}.product_idx`, 'product_idx'],
[`${APP}.product_name`, 'product_name'],
[`${APP}.member_code`, 'member_code'],
[`${APP}.member_uid`, 'member_uid'],
[`${APP_CONVERSION}.partner_id`, 'partner_id'],
[`${APP}.kakao_clicked_at`, 'kakao_clicked_at'],
[`${APP}.inflow_source`, 'inflow_source'],
[`${APP}.inflow_medium`, 'inflow_medium'],
[`${APP}.inflow_campaign`, 'inflow_campaign'],
[`${APP}.inflow_content`, 'inflow_content'],
['CAST(NULL AS varchar)', 'order_no'],
['CAST(NULL AS boolean)', 'is_valid'],
['CAST(NULL AS varchar)', 'orderer_name'],
['CAST(NULL AS varchar)', 'orderer_email'],
['CAST(NULL AS varchar)', 'orderer_call'],
]),
);
}
const pur = this.purchasePart(query);
if (pur) {
parts.push(
selectColumns(pur, [
[`'purchase'`, 'type'],
[`${PUR}.id`, 'id'],
[`${PUR}.ordered_at`, 'occurred_at'],
[`CAST(${PUR}.prod_no AS varchar)`, 'product_idx'],
[`${PUR}.product_name`, 'product_name'],
[`${PUR}.member_code`, 'member_code'],
[`${PUR}.member_uid`, 'member_uid'],
[`${PUR_CONVERSION}.partner_id`, 'partner_id'],
['CAST(NULL AS timestamptz)', 'kakao_clicked_at'],
[`${PUR}.inflow_source`, 'inflow_source'],
[`${PUR}.inflow_medium`, 'inflow_medium'],
[`${PUR}.inflow_campaign`, 'inflow_campaign'],
[`${PUR}.inflow_content`, 'inflow_content'],
[`${PUR}.order_no`, 'order_no'],
[`${PUR}.is_valid`, 'is_valid'],
[`${PUR}.orderer_name`, 'orderer_name'],
[`${PUR}.orderer_email`, 'orderer_email'],
[`${PUR}.orderer_call`, 'orderer_call'],
]),
);
}
if (parts.length === 0) {
return toPaginatedResult([], {
hasNext: false,
nextCursor: null,
total: 0,
});
}
const totalRow = await this.fromUnion(parts)
.select('COUNT(*)', 'total')
.getRawOne<{ total: string }>();
const page = this.fromUnion(parts)
.select(`${UNION}.*`)
.orderBy(`${UNION}.occurred_at`, 'DESC')
.addOrderBy(`${UNION}.id`, 'DESC')
.limit(query.limit + 1);
if (query.cursor) {
const cursor = decodeTimeCursor(query.cursor);
if (!cursor || !UUID_PATTERN.test(cursor.key)) {
throw new BusinessException('VAL_001', {
context: { reason: 'invalid_cursor' },
});
}
page.andWhere(
`(${UNION}.occurred_at, ${UNION}.id) < (:cursorTime, :cursorId)`,
{ cursorTime: cursor.time, cursorId: cursor.key },
);
}
const rows = await page.getRawMany<ActivityRow>();
const hasNext = rows.length > query.limit;
const items = rows.slice(0, query.limit);
const last = items.at(-1);
const [membersByCode, groupTitles, partnerNames] = await Promise.all([
this.membersByCode(items.map((row) => row.member_code)),
this.membersService.groupTitleMap(),
this.partnerNames(items.map((row) => row.partner_id)),
]);
return toPaginatedResult(
items.map((row): ActivityResponseDto => {
const member = row.member_code
? membersByCode.get(row.member_code)
: undefined;
const isApplication = row.type === 'application';
return {
type: row.type,
id: row.id,
occurredAt: row.occurred_at.toISOString(),
productIdx: row.product_idx,
productName: row.product_name,
memberUid: row.member_uid,
member: member ? MemberResponseDto.from(member, groupTitles) : null,
partner: row.partner_id
? {
id: row.partner_id,
name: partnerNames.get(row.partner_id) ?? '',
}
: null,
kakaoClickedAt: row.kakao_clicked_at?.toISOString() ?? null,
inflow: {
source: row.inflow_source,
medium: row.inflow_medium,
campaign: row.inflow_campaign,
content: row.inflow_content,
},
orderNo: row.order_no,
isCancelled: isApplication ? null : row.is_valid === false,
orderer: isApplication
? null
: {
name: row.orderer_name,
email: row.orderer_email,
phone: row.orderer_call,
},
};
}),
{
hasNext,
nextCursor:
hasNext && last
? encodeTimeCursor({
time: last.occurred_at.toISOString(),
key: last.id,
})
: null,
total: Number(totalRow?.total ?? 0),
},
);
}
/**
* 상품별·업체별 신청·구매 수 — 화면 위쪽 요약 표.
* 업체별은 업체 링크 실적으로 잡힌 것을 업체로 묶고, 나머지는 key=null(업체 없음)로 센다.
*/
async groups(
query: ActivityGroupsQueryDto,
): Promise<ActivityGroupsResponseDto> {
const filter: ListFilter = { from: query.from, to: query.to };
const byPartner = query.by === 'partner';
const parts = [
selectColumns(this.applicationQuery(filter), [
[`'application'`, 'type'],
[
byPartner ? `${APP_CONVERSION}.partner_id` : `${APP}.product_idx`,
'key',
],
[byPartner ? 'CAST(NULL AS varchar)' : `${APP}.product_name`, 'label'],
[`${APP}.applied_at`, 'occurred_at'],
[`${APP}.kakao_clicked_at`, 'kakao_clicked_at'],
['CAST(NULL AS boolean)', 'is_valid'],
]),
selectColumns(this.purchaseQuery(filter), [
[`'purchase'`, 'type'],
[
byPartner
? `${PUR_CONVERSION}.partner_id`
: `CAST(${PUR}.prod_no AS varchar)`,
'key',
],
[byPartner ? 'CAST(NULL AS varchar)' : `${PUR}.product_name`, 'label'],
[`${PUR}.ordered_at`, 'occurred_at'],
['CAST(NULL AS timestamptz)', 'kakao_clicked_at'],
[`${PUR}.is_valid`, 'is_valid'],
]),
];
const totals = await selectColumns(
this.fromUnion(parts),
COUNT_COLUMNS,
).getRawOne<CountsRow>();
const grouped = this.fromUnion(parts);
if (byPartner) {
grouped
.leftJoin(Partner, PARTNER, `${PARTNER}.id = ${UNION}.key`)
.select(`${UNION}.key`, 'key')
.addSelect(`MAX(${PARTNER}.name)`, 'label');
} else {
// 상품명은 아임웹 상품 기준, 아직 불러오지 않은 상품이면 가장 최근 표시값
grouped
.leftJoin(
ImwebProduct,
PRODUCT,
`CAST(${PRODUCT}.prod_no AS varchar) = ${UNION}.key`,
)
.select(`${UNION}.key`, 'key')
.addSelect(
`COALESCE(MAX(${PRODUCT}.name), (ARRAY_AGG(${UNION}.label ORDER BY ${UNION}.occurred_at DESC) FILTER (WHERE ${UNION}.label IS NOT NULL))[1])`,
'label',
);
}
for (const [expression, alias] of COUNT_COLUMNS) {
grouped.addSelect(expression, alias);
}
const rows = await grouped
.groupBy(`${UNION}.key`)
.orderBy('COUNT(*)', 'DESC')
.addOrderBy(`${UNION}.key`, 'ASC', 'NULLS LAST')
.limit(STATS_MAX_ITEMS)
.getRawMany<CountsRow & { key: string | null; label: string | null }>();
return {
by: query.by,
totals: toCounts(totals),
items: rows.map((row) => ({
key: row.key,
label: row.label,
...toCounts(row),
})),
};
}
/**
* 유입 집계 — 신청·구매(환불·취소 제외) 모두 센다.
* - 신청·구매 때 유입(apply): 신청은 신청 완료 화면, 구매는 주문완료 화면이 보낸 값.
* 주문완료 화면이 보내지 못한 구매(도입 전 주문 등)는 값 없음
* - 가입 경로(join): 회원의 가입 경로. 비회원 구매는 값 없음
*/
async inflowStats(
query: ActivityInflowStatsQueryDto,
): Promise<ActivityInflowStatsResponseDto> {
const isJoin = query.basis === 'join';
const parts: SelectQueryBuilder<ObjectLiteral>[] = [];
const app = this.applicationPart(query);
if (app) {
parts.push(
selectColumns(app, [
[`'application'`, 'type'],
[
isJoin
? joinInflow(APP_MEMBER, query.groupBy)
: `${APP}.inflow_${query.groupBy}`,
'key',
],
]),
);
}
const pur = this.purchasePart(query);
if (pur) {
pur.andWhere(`${PUR}.is_valid = true`);
parts.push(
selectColumns(pur, [
[`'purchase'`, 'type'],
[
isJoin
? joinInflow(PUR_MEMBER, query.groupBy)
: `${PUR}.inflow_${query.groupBy}`,
'key',
],
]),
);
}
const result: ActivityInflowStatsResponseDto = {
basis: query.basis,
groupBy: query.groupBy,
applicationTotal: 0,
purchaseTotal: 0,
items: [],
};
if (parts.length === 0) return result;
const totals = await this.fromUnion(parts)
.select(
`COUNT(*) FILTER (WHERE ${UNION}.type = 'application')`,
'applicationTotal',
)
.addSelect(
`COUNT(*) FILTER (WHERE ${UNION}.type = 'purchase')`,
'purchaseTotal',
)
.getRawOne<{ applicationTotal: string; purchaseTotal: string }>();
const rows = await this.fromUnion(parts)
.select(`${UNION}.key`, 'key')
.addSelect('COUNT(*)', 'count')
.groupBy(`${UNION}.key`)
.orderBy('count', 'DESC')
.addOrderBy(`${UNION}.key`, 'ASC', 'NULLS LAST')
.limit(STATS_MAX_ITEMS)
.getRawMany<{ key: string | null; count: string }>();
return {
...result,
applicationTotal: Number(totals?.applicationTotal ?? 0),
purchaseTotal: Number(totals?.purchaseTotal ?? 0),
items: rows.map((row) => ({ key: row.key, count: Number(row.count) })),
};
}
/** 무료 신청 쪽 — 종류 필터로 빠지면 null */
private applicationPart(
filter: ListFilter,
): SelectQueryBuilder<ObjectLiteral> | null {
return filter.type === 'purchase' ? null : this.applicationQuery(filter);
}
/** 유료 구매 쪽 — 종류 필터로 빠지면 null */
private purchasePart(
filter: ListFilter,
): SelectQueryBuilder<ObjectLiteral> | null {
return filter.type === 'application' ? null : this.purchaseQuery(filter);
}
/** 무료 신청 — 필터만 걸고 열은 부르는 쪽이 고른다 */
private applicationQuery(
filter: ListFilter,
): SelectQueryBuilder<ObjectLiteral> {
const qb = this.dataSource
.createQueryBuilder()
.from(MemberApplication, APP)
// 탈퇴 회원은 회원 정보 없이 남는다
.leftJoin(
ImwebMember,
APP_MEMBER,
`${APP_MEMBER}.member_code = ${APP}.member_code`,
)
// 신청 실적은 신청당 하나라 잇는 행이 늘어나지 않는다
.leftJoin(
PartnerConversion,
APP_CONVERSION,
`${APP_CONVERSION}.application_id = ${APP}.id`,
);
this.applyCommonFilters(qb, filter, {
occurredAt: `${APP}.applied_at`,
productIdx: `${APP}.product_idx`,
conversion: APP_CONVERSION,
searchColumns: [
`${APP_MEMBER}.name`,
`${APP}.member_uid`,
`${APP_MEMBER}.email`,
`${APP_MEMBER}.callnum`,
],
});
if (filter.inflowBasis && filter.inflowField && filter.inflowValue) {
const expression =
filter.inflowBasis === 'join'
? joinInflow(APP_MEMBER, filter.inflowField)
: `${APP}.inflow_${filter.inflowField}`;
qb.andWhere(`${expression} = :inflowValue`, {
inflowValue: filter.inflowValue,
});
}
return qb;
}
/** 유료 구매 — 필터만 걸고 열은 부르는 쪽이 고른다 */
private purchaseQuery(filter: ListFilter): SelectQueryBuilder<ObjectLiteral> {
const qb = this.dataSource
.createQueryBuilder()
.from(Purchase, PUR)
.leftJoin(
ImwebMember,
PUR_MEMBER,
`${PUR_MEMBER}.member_code = ${PUR}.member_code`,
)
// 구매 실적은 주문·상품당 하나다
.leftJoin(
PartnerConversion,
PUR_CONVERSION,
`${PUR_CONVERSION}.order_no = ${PUR}.order_no AND ${PUR_CONVERSION}.prod_no = ${PUR}.prod_no`,
);
this.applyCommonFilters(qb, filter, {
occurredAt: `${PUR}.ordered_at`,
productIdx: `CAST(${PUR}.prod_no AS varchar)`,
conversion: PUR_CONVERSION,
searchColumns: [
`${PUR_MEMBER}.name`,
`${PUR}.member_uid`,
`${PUR_MEMBER}.email`,
`${PUR_MEMBER}.callnum`,
`${PUR}.orderer_name`,
`${PUR}.orderer_email`,
`${PUR}.orderer_call`,
],
});
if (filter.inflowBasis && filter.inflowField && filter.inflowValue) {
const expression =
filter.inflowBasis === 'join'
? joinInflow(PUR_MEMBER, filter.inflowField)
: `${PUR}.inflow_${filter.inflowField}`;
qb.andWhere(`${expression} = :inflowValue`, {
inflowValue: filter.inflowValue,
});
}
return qb;
}
/**
* 두 쪽이 같은 이름의 파라미터를 같은 값으로 쓴다 — UNION 으로 합칠 때 겹쳐도 문제없다.
* (쪽마다 다른 값이 필요한 파라미터는 여기에 두지 않는다)
*/
private applyCommonFilters(
qb: SelectQueryBuilder<ObjectLiteral>,
filter: ListFilter,
columns: {
occurredAt: string;
productIdx: string;
conversion: string;
searchColumns: string[];
},
): void {
if (filter.productIdx) {
qb.andWhere(`${columns.productIdx} = :productIdx`, {
productIdx: filter.productIdx,
});
}
if (filter.from) {
qb.andWhere(`${columns.occurredAt} >= :from`, { from: filter.from });
}
if (filter.to) {
qb.andWhere(`${columns.occurredAt} < :to`, { to: filter.to });
}
if (filter.partnerId === PARTNER_FILTER_NONE) {
qb.andWhere(`${columns.conversion}.id IS NULL`);
} else if (filter.partnerId) {
qb.andWhere(`${columns.conversion}.partner_id = :partnerId`, {
partnerId: filter.partnerId,
});
}
if (filter.q?.trim()) {
const conditions = columns.searchColumns
.map((column) => `${column} ILIKE :q`)
.join(' OR ');
qb.andWhere(`(${conditions})`, {
q: `%${escapeLike(filter.q.trim())}%`,
});
}
}
/** 두 쪽을 UNION ALL 로 합친 결과(u)에서 시작하는 쿼리 */
private fromUnion(
parts: SelectQueryBuilder<ObjectLiteral>[],
): SelectQueryBuilder<ObjectLiteral> {
const sql = parts.map((part) => `(${part.getQuery()})`).join(' UNION ALL ');
const parameters = Object.assign(
{},
...parts.map((part) => part.getParameters()),
) as ObjectLiteral;
return this.dataSource
.createQueryBuilder()
.from(`(${sql})`, UNION)
.setParameters(parameters);
}
private async membersByCode(
codes: (string | null)[],
): Promise<Map<string, ImwebMember>> {
const unique = [...new Set(codes.filter((code): code is string => !!code))];
if (unique.length === 0) return new Map();
const rows = await this.members.find({ where: { memberCode: In(unique) } });
return new Map(rows.map((row) => [row.memberCode, row]));
}
private async partnerNames(
ids: (string | null)[],
): Promise<Map<string, string>> {
const unique = [...new Set(ids.filter((id): id is string => !!id))];
if (unique.length === 0) return new Map();
const rows = await this.partners.find({
select: { id: true, name: true },
where: { id: In(unique) },
});
return new Map(rows.map((row) => [row.id, row.name]));
}
}
@@ -0,0 +1,159 @@
import { ApiPropertyOptional, IntersectionType } from '@nestjs/swagger';
import {
IsDateString,
IsIn,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { INFLOW_GROUP_BY } from '@/modules/members/dto/member-query.dto';
import type { InflowGroupBy } from '@/modules/members/dto/member-query.dto';
import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow';
const SEARCH_MAX_LENGTH = 100;
/** 내역 종류 — 무료 신청 / 유료 구매 */
export const ACTIVITY_TYPES = ['application', 'purchase'] as const;
export type ActivityType = (typeof ACTIVITY_TYPES)[number];
/** 업체 필터 — 업체 ID(UUID) 또는 none(업체 링크를 거치지 않은 신청·구매) */
export const PARTNER_FILTER_NONE = 'none';
const PARTNER_FILTER_PATTERN =
/^(none|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
/**
* 유입을 어느 기록으로 볼 것인가.
* - apply : 신청·구매 때 유입 — 신청 완료·주문완료 화면이 보낸 그 순간의 경로 (UTM 스크립트 보관 2일).
* 주문완료 화면이 보내지 못한 구매는 값 없음
* - join : 가입 경로 — 신청·구매한 회원이 가입할 때 기록된 값만 (가입 후 채워진 값·비회원은 값 없음)
*/
export const ACTIVITY_INFLOW_BASIS = ['apply', 'join'] as const;
export type ActivityInflowBasis = (typeof ACTIVITY_INFLOW_BASIS)[number];
/** 요약 묶음 기준 — 상품별 / 업체별 */
export const ACTIVITY_GROUP_BY = ['product', 'partner'] as const;
export type ActivityGroupBy = (typeof ACTIVITY_GROUP_BY)[number];
/** 기간 — 신청 시각 또는 주문 시각 */
export class ActivityPeriodDto {
@ApiPropertyOptional({
description: '시작 (이상, ISO 8601) — 신청 시각·주문 시각 기준',
example: '2026-09-30T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '시작일 형식이 올바르지 않습니다.' })
from?: string;
@ApiPropertyOptional({
description: '끝 (미만, ISO 8601)',
example: '2026-10-31T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '종료일 형식이 올바르지 않습니다.' })
to?: string;
}
/** 목록·유입 집계가 함께 쓰는 필터 */
export class ActivityFilterDto extends ActivityPeriodDto {
@ApiPropertyOptional({
description:
'종류 — application(무료 신청) · purchase(유료 구매). 없으면 둘 다',
enum: ACTIVITY_TYPES,
})
@IsOptional()
@IsIn(ACTIVITY_TYPES, { message: '종류 값이 올바르지 않습니다.' })
type?: ActivityType;
@ApiPropertyOptional({ description: '상품번호', example: '52' })
@IsOptional()
@Matches(/^\d{1,20}$/, { message: '상품번호 형식이 올바르지 않습니다.' })
productIdx?: string;
@ApiPropertyOptional({
description:
'업체 — 그 업체 링크 실적으로 잡힌 신청·구매만. none 이면 업체 링크를 거치지 않은 것',
example: 'none',
})
@IsOptional()
@Matches(PARTNER_FILTER_PATTERN, { message: '업체 값이 올바르지 않습니다.' })
partnerId?: string;
}
/** 상품별·업체별 신청·구매 수 — 기간만 걸러서 센다 */
export class ActivityGroupsQueryDto extends ActivityPeriodDto {
@ApiPropertyOptional({
description: '묶음 기준 — product(상품별, 기본) · partner(업체별)',
enum: ACTIVITY_GROUP_BY,
default: 'product',
})
@IsOptional()
@IsIn(ACTIVITY_GROUP_BY, { message: '묶음 기준 값이 올바르지 않습니다.' })
by: ActivityGroupBy = 'product';
}
/** 관리자 신청·구매 내역 조회 */
export class ActivityListQueryDto extends IntersectionType(
CursorPaginationQueryDto,
ActivityFilterDto,
) {
@ApiPropertyOptional({
description:
'유입 필터 기준 — apply(신청·구매 때 유입) · join(가입 경로). inflowField·inflowValue 와 함께 쓴다',
enum: ACTIVITY_INFLOW_BASIS,
})
@IsOptional()
@IsIn(ACTIVITY_INFLOW_BASIS, { message: '유입 기준 값이 올바르지 않습니다.' })
inflowBasis?: ActivityInflowBasis;
@ApiPropertyOptional({
description: '유입 필터 항목 — source · medium · campaign · content',
enum: INFLOW_GROUP_BY,
})
@IsOptional()
@IsIn(INFLOW_GROUP_BY, { message: '유입 항목 값이 올바르지 않습니다.' })
inflowField?: InflowGroupBy;
@ApiPropertyOptional({
description: '유입 필터 값 (집계 막대를 눌러 거를 때)',
example: 'facebook',
})
@IsOptional()
@IsString()
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
inflowValue?: string;
@ApiPropertyOptional({
description:
'검색어 — 이름·아이디·이메일·연락처 부분 일치 (비회원은 주문자 정보)',
example: '홍길동',
})
@IsOptional()
@IsString()
@MaxLength(SEARCH_MAX_LENGTH, { message: '검색어가 너무 깁니다.' })
q?: string;
}
/** 유입 집계 — 신청 때 유입 또는 가입 경로 기준 */
export class ActivityInflowStatsQueryDto extends ActivityFilterDto {
@ApiPropertyOptional({
description:
'집계 기준 — apply(신청·구매 때 유입 · 기본) · join(가입 경로)',
enum: ACTIVITY_INFLOW_BASIS,
default: 'apply',
})
@IsOptional()
@IsIn(ACTIVITY_INFLOW_BASIS, { message: '유입 기준 값이 올바르지 않습니다.' })
basis: ActivityInflowBasis = 'apply';
@ApiPropertyOptional({
description: '집계 항목',
enum: INFLOW_GROUP_BY,
default: 'source',
})
@IsOptional()
@IsIn(INFLOW_GROUP_BY, { message: '집계 기준 값이 올바르지 않습니다.' })
groupBy: InflowGroupBy = 'source';
}
@@ -0,0 +1,224 @@
import { ApiProperty } from '@nestjs/swagger';
import { INFLOW_GROUP_BY } from '@/modules/members/dto/member-query.dto';
import type { InflowGroupBy } from '@/modules/members/dto/member-query.dto';
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
import {
ACTIVITY_GROUP_BY,
ACTIVITY_INFLOW_BASIS,
ACTIVITY_TYPES,
} from './activity-query.dto';
import type {
ActivityGroupBy,
ActivityInflowBasis,
ActivityType,
} from './activity-query.dto';
/** 신청 순간의 유입정보 */
export class ActivityInflowDto {
@ApiProperty({ description: '유입원', nullable: true, example: 'facebook' })
source!: string | null;
@ApiProperty({ description: '매체', nullable: true, example: 'paid' })
medium!: string | null;
@ApiProperty({ description: '캠페인', nullable: true, example: 'fall' })
campaign!: string | null;
@ApiProperty({ description: '콘텐츠', nullable: true, example: 'video_a' })
content!: string | null;
}
/** 신청·구매를 데려온 업체 (업체 링크 실적으로 잡힌 경우) */
export class ActivityPartnerDto {
@ApiProperty({ description: '업체 ID' })
id!: string;
@ApiProperty({ description: '업체 이름', example: '머니튜브' })
name!: string;
}
/** 주문서의 주문자 — 비회원 구매는 이 값으로만 누군지 안다 */
export class ActivityOrdererDto {
@ApiProperty({ description: '주문자 이름', nullable: true })
name!: string | null;
@ApiProperty({ description: '주문자 이메일', nullable: true })
email!: string | null;
@ApiProperty({ description: '주문자 연락처', nullable: true })
phone!: string | null;
}
/** 관리자 신청·구매 내역 한 건 */
export class ActivityResponseDto {
@ApiProperty({
description: '종류 — application(무료 신청) · purchase(유료 구매)',
enum: ACTIVITY_TYPES,
})
type!: ActivityType;
@ApiProperty({ description: '신청 기록 ID 또는 구매 기록 ID' })
id!: string;
@ApiProperty({
description: '신청 시각 또는 주문 시각',
example: '2026-10-05T03:00:00.000Z',
})
occurredAt!: string;
@ApiProperty({ description: '상품번호', example: '52' })
productIdx!: string;
@ApiProperty({
description: '상품명 (신청 화면 표시값 또는 주문 당시 상품명)',
nullable: true,
})
productName!: string | null;
@ApiProperty({
description: '신청·주문 당시 아이디 — 비회원 구매면 null',
nullable: true,
})
memberUid!: string | null;
@ApiProperty({
description:
'회원 정보 (동기화된 회원 기준) — 비회원·탈퇴 등으로 없으면 null',
type: MemberResponseDto,
nullable: true,
})
member!: MemberResponseDto | null;
@ApiProperty({
description: '업체 링크 실적으로 잡힌 경우 그 업체 — 아니면 null',
type: ActivityPartnerDto,
nullable: true,
})
partner!: ActivityPartnerDto | null;
@ApiProperty({
description:
'[신청] 오픈카톡 버튼을 처음 누른 시각 — 안 눌렀거나 구매면 null',
nullable: true,
})
kakaoClickedAt!: string | null;
@ApiProperty({
description:
'신청·구매 때 유입 — 신청 완료·주문완료 화면이 보낸 그 순간의 경로. 받지 못했으면 항목이 모두 null',
type: ActivityInflowDto,
})
inflow!: ActivityInflowDto;
@ApiProperty({
description: '[구매] 아임웹 주문번호 — 신청이면 null',
nullable: true,
})
orderNo!: string | null;
@ApiProperty({
description: '[구매] 환불·취소·반품됐는가 — 신청이면 null',
nullable: true,
})
isCancelled!: boolean | null;
@ApiProperty({
description: '[구매] 주문자 — 신청이면 null',
type: ActivityOrdererDto,
nullable: true,
})
orderer!: ActivityOrdererDto | null;
}
/** 신청·구매 수 */
export class ActivityCountsDto {
@ApiProperty({ description: '무료 신청 수', example: 120 })
applicationCount!: number;
@ApiProperty({
description: '그중 오픈카톡 버튼을 누른 수 (실제 입장 여부는 알 수 없다)',
example: 87,
})
kakaoCount!: number;
@ApiProperty({ description: '유료 구매 수 (환불·취소 제외)', example: 12 })
purchaseCount!: number;
@ApiProperty({ description: '환불·취소된 구매 수', example: 1 })
cancelledCount!: number;
}
/** 상품별·업체별 한 줄 */
export class ActivityGroupItemDto extends ActivityCountsDto {
@ApiProperty({
description:
'묶음 값 — 상품번호 또는 업체 ID. 업체별에서 업체 링크를 거치지 않은 것은 null',
nullable: true,
example: '52',
})
key!: string | null;
@ApiProperty({
description:
'표시 이름 — 상품명(아임웹 기준, 없으면 가장 최근 표시값) 또는 업체 이름',
nullable: true,
})
label!: string | null;
}
/** 상품별·업체별 신청·구매 수 */
export class ActivityGroupsResponseDto {
@ApiProperty({
description: '묶음 기준',
enum: ACTIVITY_GROUP_BY,
example: 'product',
})
by!: ActivityGroupBy;
@ApiProperty({ description: '기간 내 전체', type: ActivityCountsDto })
totals!: ActivityCountsDto;
@ApiProperty({
description: '신청·구매가 많은 순 (최대 100개)',
type: [ActivityGroupItemDto],
})
items!: ActivityGroupItemDto[];
}
/** 유입 집계 한 줄 */
export class ActivityInflowStatItemDto {
@ApiProperty({
description: '집계 값 (값이 없으면 null)',
nullable: true,
example: 'facebook',
})
key!: string | null;
@ApiProperty({ description: '신청·구매 수', example: 42 })
count!: number;
}
/** 유입 집계 결과 */
export class ActivityInflowStatsResponseDto {
@ApiProperty({ description: '집계 기준', enum: ACTIVITY_INFLOW_BASIS })
basis!: ActivityInflowBasis;
@ApiProperty({ description: '집계 항목', enum: INFLOW_GROUP_BY })
groupBy!: InflowGroupBy;
@ApiProperty({ description: '집계한 무료 신청 수', example: 300 })
applicationTotal!: number;
@ApiProperty({
description: '집계한 유료 구매 수 (환불·취소 제외)',
example: 10,
})
purchaseTotal!: number;
@ApiProperty({
description: '많은 순 (최대 100개, 값 없음은 key=null)',
type: [ActivityInflowStatItemDto],
})
items!: ActivityInflowStatItemDto[];
}
@@ -0,0 +1,56 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
} from 'typeorm';
import { Partner } from '@/modules/partners/entities/partner.entity';
/**
* 업체 계정 — 최고관리자가 업체마다 만들어 주는 로그인 계정.
*
* 최고관리자는 DB 계정이 아니라 env(ADMIN_USERNAME / ADMIN_PASSWORD) 계정이다.
* 업체 계정은 자기 업체의 링크·실적만 볼 수 있다 (PartnerAuthGuard).
* 지우지 않고 isActive=false 로 중지한다 — 업체를 지우면 그 업체의 계정도 함께 지워진다.
*/
@Entity({ name: 'admin_accounts' })
@Index('idx_admin_accounts_partner', ['partnerId', 'createdAt'])
export class AdminAccount {
/** 앱에서 crypto.randomUUID() 로 만든다 */
@PrimaryColumn({ name: 'id', type: 'uuid' })
id!: string;
/** 로그인 아이디 — 소문자로 저장한다 (대소문자 구분 없이 로그인) */
@Index('uq_admin_accounts_username', { unique: true })
@Column({ name: 'username', type: 'varchar', length: 50 })
username!: string;
/** bcrypt 해시 — 평문은 어디에도 남기지 않는다 */
@Column({ name: 'password_hash', type: 'varchar', length: 100 })
passwordHash!: string;
@Column({ name: 'partner_id', type: 'uuid' })
partnerId!: string;
@ManyToOne(() => Partner, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'partner_id' })
partner?: Partner;
/** 사용 여부 — 중지하면 로그인할 수 없고 열려 있던 세션도 끊긴다 */
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
/** 서버가 만든 임시 비밀번호인가 — 바꾸기 전에는 실적을 볼 수 없다 (ACCOUNT_003) */
@Column({ name: 'must_change_password', type: 'boolean', default: true })
mustChangePassword!: boolean;
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
lastLoginAt!: Date | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
}
@@ -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 의 기본값)을 쓸 수 없다.
*
@@ -28,16 +28,35 @@ export const ADMIN_PASSWORD_MIN_LENGTH = 12;
/** 로그인 시도 제한 — 1분에 5회 (브루트포스 방지, 전역 한도보다 엄격하게) */
export const LOGIN_THROTTLE = { default: { ttl: 60_000, limit: 5 } };
/**
* 계정 역할.
* - super : 최고관리자 — env 계정. 모든 관리 기능 + 업체 계정 관리
* - partner : 업체 계정 — DB(admin_accounts). 자기 업체의 링크·실적만
*/
export const ACCOUNT_ROLES = ['super', 'partner'] as const;
export type AccountRole = (typeof ACCOUNT_ROLES)[number];
/** 업체 계정 비밀번호 — 최소 길이 (bcrypt 가 72바이트까지만 쓰므로 최대는 72) */
export const ACCOUNT_PASSWORD_MIN_LENGTH = 10;
export const ACCOUNT_PASSWORD_MAX_LENGTH = 72;
/**
* bcrypt 비용 — OWASP 최소 권장값. 순수 JS 구현(bcryptjs)이라 12 면 한 번에 1초 가까이 걸려
* 로그인 실패마다 서버 CPU 를 오래 쓴다. 무차별 대입은 로그인 요청 제한(LOGIN_THROTTLE)이 함께 막는다
*/
export const BCRYPT_ROUNDS = 10;
/** Swagger 의 Bearer 인증 이름 (main.ts 의 addBearerAuth 기본값) */
export const ADMIN_BEARER_AUTH = 'bearer';
export type AdminTokenType = 'access' | 'refresh';
export interface AdminTokenPayload {
/** 관리자 아이디 (env ADMIN_USERNAME) */
/** 최고관리자는 env 아이디, 업체 계정은 계정 ID(admin_accounts.id) */
sub: string;
typ: AdminTokenType;
/** 계정 지문 — env 아이디·비밀번호가 바뀌면 달라져 모든 토큰이 무효가 된다 */
/** 역할 — 예전 토큰(역할 없음)은 최고관리자 토큰이다 */
role?: AccountRole;
/** 계정 지문 — 아이디·비밀번호가 바뀌면 달라져 그 계정의 모든 토큰이 무효가 된다 */
cred: string;
/** AdminSession.id — 로그아웃은 이 세션만 끊는다 (공유 계정의 다른 기기는 유지) */
sid: string;
@@ -19,7 +19,7 @@ import {
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentAdmin } from '@/common/decorators/current-admin.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
import { AccountAuthGuard } from '@/common/guards/admin-auth.guard';
import type { AdminRequest } from '@/common/guards/admin-auth.guard';
import { BusinessException } from '@/common/errors/business.exception';
import { ADMIN_BEARER_AUTH, LOGIN_THROTTLE } from './admin-auth.constants';
@@ -29,7 +29,7 @@ import { AdminSessionDto, RenewSessionDto } from './dto/admin-session.dto';
import { LoginDto } from './dto/login.dto';
/**
* 관리자 세션 — 로그인·갱신·로그아웃.
* 로그인 세션 — 최고관리자(env 계정)·업체 계정 공통. 로그인·갱신·로그아웃.
* 관리자 화면과 API 가 다른 사이트라 쿠키를 쓸 수 없어 토큰을 본문으로 내려준다
* (이유와 보완책은 admin-auth.constants.ts 참조).
*/
@@ -40,7 +40,10 @@ export class AdminAuthController {
@Post()
@Throttle(LOGIN_THROTTLE)
@ApiOperation({ summary: '관리자 로그인 — 액세스·리프레시 토큰 발급' })
@ApiOperation({
summary:
'로그인 — 최고관리자(env 계정) 또는 업체 계정. 액세스·리프레시 토큰 발급',
})
@ApiStandardResponse(AdminSessionDto, {
status: 201,
description: '로그인 성공',
@@ -66,9 +69,12 @@ export class AdminAuthController {
}
@Get('current')
@UseGuards(AdminAuthGuard)
@UseGuards(AccountAuthGuard)
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@ApiOperation({ summary: '현재 로그인한 관리자' })
@ApiOperation({
summary:
'현재 로그인한 계정 — 역할(최고관리자·업체)과 업체를 함께 돌려준다',
})
@ApiStandardResponse(AdminResponseDto, { description: '조회 성공' })
@ApiErrorResponse('AUTH_001', '로그인 필요')
current(@CurrentAdmin() admin: AdminResponseDto): AdminResponseDto {
@@ -76,7 +82,7 @@ export class AdminAuthController {
}
@Delete()
@UseGuards(AdminAuthGuard)
@UseGuards(AccountAuthGuard)
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform()
@@ -3,14 +3,28 @@ import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
import {
AccountAuthGuard,
AdminAuthGuard,
PartnerAuthGuard,
} from '@/common/guards/admin-auth.guard';
import { Partner } from '@/modules/partners/entities/partner.entity';
import { AdminAccount } from './admin-account.entity';
import { AdminAuthController } from './admin-auth.controller';
import { AdminAuthService } from './admin-auth.service';
import { AdminSession } from './admin-session.entity';
import {
AccountPasswordController,
PartnerAccountsController,
} from './partner-accounts.controller';
import { PartnerAccountsService } from './partner-accounts.service';
const GUARDS = [AdminAuthGuard, PartnerAuthGuard, AccountAuthGuard];
@Module({
imports: [
TypeOrmModule.forFeature([AdminSession]),
// 업체 계정이 업체에 속하는지 확인한다 (PartnersModule 을 가져오면 순환 의존이 되어 엔티티만 쓴다)
TypeOrmModule.forFeature([AdminSession, AdminAccount, Partner]),
JwtModule.registerAsync({
inject: [ConfigService],
// 운영에서는 env 검증이 JWT_SECRET 을 필수로 요구한다.
@@ -20,9 +34,13 @@ import { AdminSession } from './admin-session.entity';
}),
}),
],
controllers: [AdminAuthController],
providers: [AdminAuthService, AdminAuthGuard],
// 다른 모듈의 컨트롤러가 @UseGuards(AdminAuthGuard) 를 쓰려면 서비스와 가드가 필요하다
exports: [AdminAuthService, AdminAuthGuard],
controllers: [
AdminAuthController,
PartnerAccountsController,
AccountPasswordController,
],
providers: [AdminAuthService, PartnerAccountsService, ...GUARDS],
// 다른 모듈의 컨트롤러가 @UseGuards(...) 를 쓰려면 서비스와 가드가 필요하다
exports: [AdminAuthService, ...GUARDS],
})
export class AdminAuthModule {}
@@ -1,16 +1,56 @@
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { hashSync } from 'bcryptjs';
import { randomUUID } from 'crypto';
import { FindOperator } from 'typeorm';
import type { Repository } from 'typeorm';
import type { Partner } from '@/modules/partners/entities/partner.entity';
import { AdminAccount } from './admin-account.entity';
import { AdminAuthService } from './admin-auth.service';
import { AdminSession } from './admin-session.entity';
const USERNAME = 'moneyguider';
const PASSWORD = 'correct-horse-battery';
const SECRET = 'test-secret';
/** 테스트 픽스처는 bcrypt 비용을 낮춰 빠르게 만든다 (비교는 비용과 무관하게 동작한다) */
const FIXTURE_ROUNDS = 4;
const PARTNER = { id: 'partner-1', name: '머니튜브' } as Partner;
const PARTNER_USERNAME = 'moneytube';
const PARTNER_PASSWORD = 'temporary-pass-1';
function setup(env: Record<string, string> = {}) {
function partnerAccount(overrides: Partial<AdminAccount> = {}): AdminAccount {
return {
id: randomUUID(),
username: PARTNER_USERNAME,
passwordHash: hashSync(PARTNER_PASSWORD, FIXTURE_ROUNDS),
partnerId: PARTNER.id,
partner: PARTNER,
isActive: true,
mustChangePassword: true,
lastLoginAt: null,
createdAt: new Date(),
...overrides,
};
}
/** update 조건 흉내 — id(값 또는 Not), accountId 로 고른다 */
function matches(row: AdminSession, where: Record<string, unknown>): boolean {
const { id, accountId } = where;
if (typeof id === 'string' && row.id !== id) return false;
if (id instanceof FindOperator && id.type === 'not' && row.id === id.value) {
return false;
}
if (typeof accountId === 'string' && row.accountId !== accountId) {
return false;
}
return true;
}
function setup(
env: Record<string, string> = {},
accountRows: AdminAccount[] = [],
) {
// env 는 테스트 중에 바꿀 수 있게 객체로 둔다 (비밀번호 변경 시나리오)
const values: Record<string, string> = {
ADMIN_USERNAME: USERNAME,
@@ -32,20 +72,46 @@ function setup(env: Record<string, string> = {}) {
findOne: jest.fn(({ where }: { where: { id: string } }) =>
Promise.resolve(store.get(where.id) ?? null),
),
update: jest.fn(({ id }: { id: string }, patch: Partial<AdminSession>) => {
const row = store.get(id);
update: jest.fn(
(where: Record<string, unknown>, patch: Partial<AdminSession>) => {
for (const row of store.values()) {
if (matches(row, where)) Object.assign(row, patch);
}
return Promise.resolve();
},
),
delete: jest.fn(() => Promise.resolve({ affected: 0 })),
};
// 업체 계정 저장소 — 메모리
const accountStore = new Map(accountRows.map((row) => [row.id, row]));
const accounts = {
findOne: jest.fn(
({ where }: { where: { id?: string; username?: string } }) =>
Promise.resolve(
[...accountStore.values()].find((row) =>
where.id ? row.id === where.id : row.username === where.username,
) ?? null,
),
),
update: jest.fn((id: string, patch: Partial<AdminAccount>) => {
const row = accountStore.get(id);
if (row) Object.assign(row, patch);
return Promise.resolve();
}),
delete: jest.fn(() => Promise.resolve({ affected: 0 })),
save: jest.fn((row: AdminAccount) => {
accountStore.set(row.id, row);
return Promise.resolve(row);
}),
};
const service = new AdminAuthService(
sessions as unknown as Repository<AdminSession>,
accounts as unknown as Repository<AdminAccount>,
new JwtService({ secret: SECRET }),
config as unknown as ConfigService,
);
return { service, store, values };
return { service, store, values, accountStore };
}
describe('AdminAuthService (env 단일 계정)', () => {
@@ -54,7 +120,12 @@ describe('AdminAuthService (env 단일 계정)', () => {
const session = await service.login(USERNAME, PASSWORD, 'Mozilla/5.0');
expect(session.admin).toEqual({ username: USERNAME });
expect(session.admin).toEqual({
username: USERNAME,
role: 'super',
partner: null,
mustChangePassword: false,
});
expect(Date.parse(session.refreshTokenExpiresAt)).toBeGreaterThan(
Date.parse(session.accessTokenExpiresAt),
);
@@ -63,8 +134,11 @@ describe('AdminAuthService (env 단일 계정)', () => {
const verified = await service.verifyAccessToken(session.accessToken);
expect(verified).toEqual({
admin: { username: USERNAME },
admin: session.admin,
sessionId: [...store.keys()][0],
role: 'super',
accountId: null,
partnerId: null,
});
});
@@ -206,3 +280,121 @@ describe('AdminAuthService (env 단일 계정)', () => {
});
});
});
describe('AdminAuthService (업체 계정)', () => {
it('업체 계정으로 로그인하면 업체 역할·업체·임시 비밀번호 여부를 돌려준다 (아이디 대소문자 무시)', async () => {
const account = partnerAccount();
const { service, store } = setup({}, [account]);
const session = await service.login('MoneyTube', PARTNER_PASSWORD);
expect(session.admin).toEqual({
username: PARTNER_USERNAME,
role: 'partner',
partner: { id: PARTNER.id, name: PARTNER.name },
mustChangePassword: true,
});
expect([...store.values()][0].accountId).toBe(account.id);
expect(account.lastLoginAt).toBeInstanceOf(Date);
await expect(
service.verifyAccessToken(session.accessToken),
).resolves.toMatchObject({
role: 'partner',
accountId: account.id,
partnerId: PARTNER.id,
});
});
it('비밀번호가 틀리거나 중지된 계정·없는 아이디는 같은 AUTH_001, 세션을 만들지 않는다', async () => {
const inactive = partnerAccount({ username: 'stopped', isActive: false });
const { service, store } = setup({}, [partnerAccount(), inactive]);
for (const [username, password] of [
[PARTNER_USERNAME, 'wrong-password'],
['stopped', PARTNER_PASSWORD],
['nobody', PARTNER_PASSWORD],
]) {
await expect(service.login(username, password)).rejects.toMatchObject({
code: 'AUTH_001',
});
}
expect(store.size).toBe(0);
});
it('계정을 중지하면 이미 발급한 토큰도 바로 거부한다', async () => {
const account = partnerAccount();
const { service } = setup({}, [account]);
const session = await service.login(PARTNER_USERNAME, PARTNER_PASSWORD);
account.isActive = false;
await expect(
service.verifyAccessToken(session.accessToken),
).rejects.toMatchObject({ code: 'AUTH_001' });
await expect(service.renew(session.refreshToken)).rejects.toMatchObject({
code: 'AUTH_001',
});
});
it('비밀번호를 바꾸면 이 기기는 새 토큰으로 이어 쓰고 다른 기기는 끊긴다', async () => {
const account = partnerAccount();
const { service } = setup({}, [account]);
const deviceA = await service.login(PARTNER_USERNAME, PARTNER_PASSWORD);
const deviceB = await service.login(PARTNER_USERNAME, PARTNER_PASSWORD);
const verifiedA = await service.verifyAccessToken(deviceA.accessToken);
const renewed = await service.changePassword(
verifiedA,
PARTNER_PASSWORD,
'my-own-new-password',
);
expect(renewed.admin.mustChangePassword).toBe(false);
await expect(
service.verifyAccessToken(renewed.accessToken),
).resolves.toMatchObject({ sessionId: verifiedA.sessionId });
await expect(
service.verifyAccessToken(deviceB.accessToken),
).rejects.toMatchObject({ code: 'AUTH_001' });
await expect(
service.login(PARTNER_USERNAME, 'my-own-new-password'),
).resolves.toBeDefined();
});
it('현재 비밀번호가 틀리면 ACCOUNT_002, 최고관리자는 AUTH_003 (env 에서 바꾼다)', async () => {
const { service } = setup({}, [partnerAccount()]);
const partner = await service.verifyAccessToken(
(await service.login(PARTNER_USERNAME, PARTNER_PASSWORD)).accessToken,
);
const admin = await service.verifyAccessToken(
(await service.login(USERNAME, PASSWORD)).accessToken,
);
await expect(
service.changePassword(partner, 'wrong-current', 'my-own-new-password'),
).rejects.toMatchObject({ code: 'ACCOUNT_002' });
await expect(
service.changePassword(admin, PASSWORD, 'my-own-new-password'),
).rejects.toMatchObject({ code: 'AUTH_003' });
});
it('업체 토큰의 역할을 최고관리자로 바꿔 쓸 수 없다', async () => {
const { service } = setup({}, [partnerAccount()]);
const session = await service.login(PARTNER_USERNAME, PARTNER_PASSWORD);
const payload = JSON.parse(
Buffer.from(session.accessToken.split('.')[1], 'base64url').toString(),
) as Record<string, unknown>;
// 서명키를 아는 상황을 가정해도, 업체 지문으로는 최고관리자 확인을 통과하지 못한다
const forged = new JwtService({ secret: SECRET }).sign({
sub: USERNAME,
typ: 'access',
role: 'super',
cred: payload.cred,
sid: payload.sid,
});
await expect(service.verifyAccessToken(forged)).rejects.toMatchObject({
code: 'AUTH_001',
});
});
});
@@ -2,16 +2,20 @@ import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { compare, hash } from 'bcryptjs';
import { randomUUID } from 'crypto';
import { LessThan, Repository } from 'typeorm';
import { IsNull, LessThan, Not, Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { credentialFingerprint, safeEqual } from '@/shared/utils/credential';
import { AdminAccount } from './admin-account.entity';
import {
ADMIN_PASSWORD_KEY,
ADMIN_USERNAME_KEY,
AccountRole,
AdminTokenPayload,
AdminTokenType,
BCRYPT_ROUNDS,
accessTokenTtlMs,
refreshTokenTtlMs,
} from './admin-auth.constants';
@@ -25,54 +29,61 @@ const USER_AGENT_MAX_LENGTH = 200;
const ENDED_SESSION_RETENTION_MS = 7 * 86_400_000;
/** 가드가 확인한 요청의 주인 */
export interface VerifiedAdmin {
export interface VerifiedAccount {
admin: AdminResponseDto;
sessionId: string;
role: AccountRole;
/** [업체 계정] 계정 ID — 최고관리자면 null */
accountId: string | null;
/** [업체 계정] 업체 ID — 최고관리자면 null. 업체 화면은 이 값으로만 데이터를 거른다 */
partnerId: string | null;
}
interface AdminAccount {
username: string;
password: string;
/** 현재 env 계정의 지문 — 토큰의 cred 와 같아야 유효하다 */
fingerprint: string;
/** 토큰의 주인 — 최고관리자(env) 또는 업체 계정(DB) */
type Principal =
| { role: 'super'; username: string; fingerprint: string }
| { role: 'partner'; account: AdminAccount; fingerprint: string };
/** 아이디는 대소문자를 가리지 않는다 — 저장·조회 모두 소문자로 */
export function normalizeUsername(username: string): string {
return username.trim().toLowerCase();
}
/**
* 관리자 인증 — env(ADMIN_USERNAME / ADMIN_PASSWORD)에 둔 단일 계정.
* 계정 인증 — 최고관리자(env ADMIN_USERNAME / ADMIN_PASSWORD)와 업체 계정(admin_accounts).
*
* 계정 하나를 여러 사람이 함께 쓰는 것을 전제로, 로그인한 기기마다 세션을 따로 둔다.
* 최고관리자 계정 하나를 여러 사람이 함께 쓰는 것을 전제로, 로그인한 기기마다 세션을 따로 둔다.
* - 로그아웃: 그 기기의 세션만 끊는다
* - env 비밀번호를 바꾸고 재시작: 계정 지문이 달라져 모든 기기가 끊긴다
* - 비밀번호 변경(env 는 재시작): 계정 지문이 달라져 그 계정의 모든 기기가 끊긴다
* - 업체 계정 중지: 그 계정의 세션을 모두 끊는다
*/
@Injectable()
export class AdminAuthService {
private readonly logger = new Logger(AdminAuthService.name);
/** 없는 아이디도 bcrypt 비교를 거치게 하는 해시 — 응답 시간으로 아이디 존재를 알 수 없게 */
private dummyHash: Promise<string> | null = null;
constructor(
@InjectRepository(AdminSession)
private readonly sessions: Repository<AdminSession>,
@InjectRepository(AdminAccount)
private readonly accounts: Repository<AdminAccount>,
private readonly jwt: JwtService,
private readonly config: ConfigService,
) {}
/** 아이디·비밀번호를 env 계정과 비교하고 새 세션을 연다. 실패 원인은 구분하지 않는다. */
/**
* 아이디·비밀번호로 새 세션을 연다 — env 최고관리자 계정을 먼저 보고, 아니면 업체 계정.
* 실패 원인(아이디 없음·비밀번호 틀림·중지됨)은 구분하지 않는다.
*/
async login(
username: string,
password: string,
userAgent?: string,
): Promise<AdminSessionDto> {
const account = this.account();
// 둘 다 항상 비교해 어느 쪽이 틀렸는지 응답 시간으로 드러나지 않게 한다
const usernameMatched = safeEqual(username, account?.username ?? '');
const passwordMatched = safeEqual(password, account?.password ?? '');
if (!account || !usernameMatched || !passwordMatched) {
throw new BusinessException('AUTH_001', {
context: {
reason: account ? 'invalid_credentials' : 'admin_not_configured',
},
});
}
const principal =
this.matchSuperAdmin(username, password) ??
(await this.matchPartnerAccount(username, password));
await this.removeEndedSessions();
@@ -80,52 +91,172 @@ export class AdminAuthService {
const session = await this.sessions.save(
this.sessions.create({
id: randomUUID(),
accountId: principal.role === 'partner' ? principal.account.id : null,
userAgent: userAgent?.slice(0, USER_AGENT_MAX_LENGTH) || null,
lastUsedAt: new Date(now),
expiresAt: new Date(now + refreshTokenTtlMs()),
revokedAt: null,
}),
);
this.logger.log(`관리자 로그인: session=${session.id}`);
if (principal.role === 'partner') {
await this.accounts.update(principal.account.id, {
lastLoginAt: new Date(now),
});
this.logger.log(
`업체 계정 로그인: account=${principal.account.id} session=${session.id}`,
);
} else {
this.logger.log(`관리자 로그인: session=${session.id}`);
}
return this.issue(account, session.id, now);
return this.issue(principal, session.id, now);
}
/** 리프레시 토큰으로 같은 세션의 새 토큰 쌍을 발급하고 세션 만료를 늦춘다. */
async renew(refreshToken: string): Promise<AdminSessionDto> {
const payload = await this.verify(refreshToken, 'refresh');
const { account, session } = await this.findActive(payload);
const { principal, session } = await this.findActive(payload);
const now = Date.now();
session.lastUsedAt = new Date(now);
session.expiresAt = new Date(now + refreshTokenTtlMs());
await this.sessions.save(session);
return this.issue(account, session.id, now);
return this.issue(principal, session.id, now);
}
/** 로그아웃 — 이 세션만 끊는다. 같은 계정으로 로그인한 다른 기기는 그대로다. */
async logout(sessionId: string): Promise<void> {
await this.sessions.update({ id: sessionId }, { revokedAt: new Date() });
this.logger.log(`관리자 로그아웃: session=${sessionId}`);
this.logger.log(`로그아웃: session=${sessionId}`);
}
/**
* 액세스 토큰을 검증한다 — 가드에서 쓴다.
* 서명·만료뿐 아니라 세션과 계정 지문도 확인해, 로그아웃·비밀번호 변경이 바로 반영된다.
* 서명·만료뿐 아니라 세션과 계정 지문·계정 상태도 확인해, 로그아웃·비밀번호 변경·중지가 바로 반영된다.
* (토큰이 페이지 스크립트에 노출되는 Bearer 방식이라 즉시 무효화가 필요하다)
*/
async verifyAccessToken(token: string | null): Promise<VerifiedAdmin> {
async verifyAccessToken(token: string | null): Promise<VerifiedAccount> {
const payload = await this.verify(token, 'access');
const { account, session } = await this.findActive(payload);
return { admin: { username: account.username }, sessionId: session.id };
const { principal, session } = await this.findActive(payload);
return {
admin: this.toResponse(principal),
sessionId: session.id,
role: principal.role,
accountId: principal.role === 'partner' ? principal.account.id : null,
partnerId:
principal.role === 'partner' ? principal.account.partnerId : null,
};
}
/** env 계정. 아이디·비밀번호가 비어 있으면 null — 아무도 로그인할 수 없다. */
private account(): AdminAccount | null {
/**
* 업체 계정 비밀번호 변경. 바꾸면 계정 지문이 달라져 다른 기기의 세션은 끊기고,
* 지금 세션에는 새 토큰을 발급해 그대로 이어서 쓰게 한다.
*/
async changePassword(
verified: VerifiedAccount,
currentPassword: string,
newPassword: string,
): Promise<AdminSessionDto> {
if (verified.role !== 'partner' || !verified.accountId) {
// 최고관리자 비밀번호는 env 에서 바꾼다
throw new BusinessException('AUTH_003', {
context: { reason: 'super_admin_password_in_env' },
});
}
const account = await this.accounts.findOne({
where: { id: verified.accountId },
relations: { partner: true },
});
if (!account) throw this.rejected('account_not_found');
if (!(await compare(currentPassword, account.passwordHash))) {
throw new BusinessException('ACCOUNT_002', {
context: { accountId: account.id },
});
}
account.passwordHash = await hash(newPassword, BCRYPT_ROUNDS);
account.mustChangePassword = false;
await this.accounts.save(account);
// 다른 기기의 세션은 지문이 달라져 어차피 무효다 — 기록도 끊긴 것으로 남긴다
await this.sessions.update(
{
accountId: account.id,
id: Not(verified.sessionId),
revokedAt: IsNull(),
},
{ revokedAt: new Date() },
);
this.logger.log(`업체 계정 비밀번호 변경: account=${account.id}`);
return this.issue(
{ role: 'partner', account, fingerprint: this.fingerprintOf(account) },
verified.sessionId,
Date.now(),
);
}
/** 업체 계정의 열린 세션을 모두 끊는다 — 계정 중지·비밀번호 초기화 때 */
async revokeAccountSessions(accountId: string): Promise<void> {
await this.sessions.update(
{ accountId, revokedAt: IsNull() },
{ revokedAt: new Date() },
);
}
/** env 최고관리자 계정과 맞으면 그 주인. 어느 쪽이 틀렸는지 응답 시간으로 드러나지 않게 둘 다 비교한다 */
private matchSuperAdmin(
username: string,
password: string,
): Principal | null {
const account = this.superAccount();
const usernameMatched = safeEqual(username, account?.username ?? '');
const passwordMatched = safeEqual(password, account?.password ?? '');
if (!account || !usernameMatched || !passwordMatched) return null;
return {
role: 'super',
username: account.username,
fingerprint: account.fingerprint,
};
}
/** 업체 계정과 맞으면 그 주인, 아니면 AUTH_001. 없는 아이디도 bcrypt 비교를 거친다 */
private async matchPartnerAccount(
username: string,
password: string,
): Promise<Principal> {
const account = await this.accounts.findOne({
where: { username: normalizeUsername(username) },
relations: { partner: true },
});
const matched = await compare(
password,
account?.passwordHash ?? (await this.getDummyHash()),
);
if (!account || !matched || !account.isActive || !account.partner) {
throw new BusinessException('AUTH_001', {
context: {
reason:
account && matched ? 'account_inactive' : 'invalid_credentials',
},
});
}
return {
role: 'partner',
account,
fingerprint: this.fingerprintOf(account),
};
}
/** env 최고관리자 계정. 아이디·비밀번호·서명키 중 하나라도 비어 있으면 null — 최고관리자 로그인 불가 */
private superAccount(): {
username: string;
password: string;
fingerprint: string;
} | null {
const username = (this.config.get<string>(ADMIN_USERNAME_KEY) ?? '').trim();
const password = this.config.get<string>(ADMIN_PASSWORD_KEY) ?? '';
const secret = this.config.get<string>('JWT_SECRET') ?? '';
const secret = this.secret();
if (!username || !password || !secret) {
this.logger.warn(
`관리자 계정이 설정되지 않았습니다 — backend/.env 의 ${ADMIN_USERNAME_KEY}·${ADMIN_PASSWORD_KEY}·JWT_SECRET 을 확인하세요`,
@@ -139,29 +270,84 @@ export class AdminAuthService {
};
}
/** 업체 계정 지문 — 비밀번호(해시)가 바뀌면 달라진다. 비밀키 HMAC 이라 토큰으로 해시를 알 수 없다 */
private fingerprintOf(account: AdminAccount): string {
return credentialFingerprint(
account.id,
account.passwordHash,
this.secret(),
);
}
private secret(): string {
return this.config.get<string>('JWT_SECRET') ?? '';
}
private getDummyHash(): Promise<string> {
this.dummyHash ??= hash(randomUUID(), BCRYPT_ROUNDS);
return this.dummyHash;
}
/** 토큰이 가리키는 세션과 계정이 아직 유효한지 확인한다. */
private async findActive(
payload: AdminTokenPayload,
): Promise<{ account: AdminAccount; session: AdminSession }> {
const account = this.account();
// env 아이디·비밀번호가 바뀌었으면 이전에 발급한 모든 토큰이 무효다
): Promise<{ principal: Principal; session: AdminSession }> {
if (typeof payload.cred !== 'string') {
throw this.rejected('credential_changed');
}
const principal =
(payload.role ?? 'super') === 'super'
? this.activeSuperAdmin(payload)
: await this.activePartnerAccount(payload);
if (!payload.sid) throw this.rejected('no_session');
const session = await this.sessions.findOne({ where: { id: payload.sid } });
if (!session) throw this.rejected('no_session');
// 다른 계정의 세션을 빌려 쓰지 못하게 한다
const expectedAccountId =
principal.role === 'partner' ? principal.account.id : null;
if (session.accountId !== expectedAccountId) {
throw this.rejected('session_account_mismatch');
}
if (session.revokedAt) throw this.rejected('logged_out');
if (session.expiresAt.getTime() <= Date.now()) {
throw this.rejected('session_expired');
}
return { principal, session };
}
/** env 아이디·비밀번호가 바뀌었으면 이전에 발급한 모든 토큰이 무효다 */
private activeSuperAdmin(payload: AdminTokenPayload): Principal {
const account = this.superAccount();
if (
!account ||
typeof payload.cred !== 'string' ||
payload.sub !== account.username ||
!safeEqual(payload.cred, account.fingerprint)
) {
throw this.rejected('credential_changed');
}
if (!payload.sid) throw this.rejected('no_session');
return {
role: 'super',
username: account.username,
fingerprint: account.fingerprint,
};
}
const session = await this.sessions.findOne({ where: { id: payload.sid } });
if (!session) throw this.rejected('no_session');
if (session.revokedAt) throw this.rejected('logged_out');
if (session.expiresAt.getTime() <= Date.now()) {
throw this.rejected('session_expired');
/** 계정이 중지·삭제됐거나 비밀번호가 바뀌었으면 무효다 */
private async activePartnerAccount(
payload: AdminTokenPayload,
): Promise<Principal> {
const account = await this.accounts.findOne({
where: { id: payload.sub },
relations: { partner: true },
});
if (!account || !account.partner) throw this.rejected('account_not_found');
if (!account.isActive) throw this.rejected('account_inactive');
const fingerprint = this.fingerprintOf(account);
if (!safeEqual(payload.cred, fingerprint)) {
throw this.rejected('credential_changed');
}
return { account, session };
return { role: 'partner', account, fingerprint };
}
private rejected(reason: string): BusinessException {
@@ -175,14 +361,36 @@ export class AdminAuthService {
await this.sessions.delete({ revokedAt: LessThan(cutoff) });
}
private toResponse(principal: Principal): AdminResponseDto {
if (principal.role === 'super') {
return {
username: principal.username,
role: 'super',
partner: null,
mustChangePassword: false,
};
}
const { account } = principal;
return {
username: account.username,
role: 'partner',
partner: account.partner
? { id: account.partner.id, name: account.partner.name }
: null,
mustChangePassword: account.mustChangePassword,
};
}
private async issue(
account: AdminAccount,
principal: Principal,
sessionId: string,
now: number,
): Promise<AdminSessionDto> {
const base = {
sub: account.username,
cred: account.fingerprint,
sub:
principal.role === 'super' ? principal.username : principal.account.id,
role: principal.role,
cred: principal.fingerprint,
sid: sessionId,
};
const accessTtlMs = accessTokenTtlMs();
@@ -199,7 +407,7 @@ export class AdminAuthService {
),
]);
return {
admin: { username: account.username },
admin: this.toResponse(principal),
accessToken,
accessTokenExpiresAt: new Date(now + accessTtlMs).toISOString(),
refreshToken,
@@ -3,15 +3,20 @@ import {
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
} from 'typeorm';
import { AdminAccount } from './admin-account.entity';
/**
* 관리자 로그인 세션 — 로그인한 기기(브라우저)마다 한 행.
* 로그인 세션 — 로그인한 기기(브라우저)마다 한 행.
*
* 관리자 계정(env 단일 계정) 하나를 여러 사람이 함께 쓰므로, 로그아웃은 그 기기의 세션만 끊어야 한다.
* 최고관리자(env 단일 계정)는 여러 사람이 함께 쓰므로, 로그아웃은 그 기기의 세션만 끊어야 한다.
* (계정 단위로 끊으면 한 사람이 로그아웃할 때 모두가 함께 로그아웃된다)
* 모든 세션을 한 번에 끊는 것은 env 비밀번호 변경(토큰의 계정 지문)이 맡는다.
* 모든 세션을 한 번에 끊는 것은 비밀번호 변경(토큰의 계정 지문)이 맡는다.
* 업체 계정 세션은 accountId 로 계정과 잇는다 — 계정을 중지하면 그 계정의 세션을 모두 끊는다.
*/
@Entity({ name: 'admin_sessions' })
export class AdminSession {
@@ -19,6 +24,15 @@ export class AdminSession {
@PrimaryColumn({ name: 'id', type: 'uuid' })
id!: string;
/** 업체 계정 — 최고관리자(env 계정) 세션이면 null */
@Index()
@Column({ name: 'account_id', type: 'uuid', nullable: true })
accountId!: string | null;
@ManyToOne(() => AdminAccount, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'account_id' })
account?: AdminAccount;
/** 어느 브라우저에서 로그인했는지 — 확인·감사용 (개인정보 아님, 길이 제한) */
@Column({ name: 'user_agent', type: 'varchar', length: 200, nullable: true })
userAgent!: string | null;
@@ -1,10 +1,43 @@
import { ApiProperty } from '@nestjs/swagger';
/** 로그인한 관리자 */
import { ACCOUNT_ROLES } from '../admin-auth.constants';
import type { AccountRole } from '../admin-auth.constants';
/** 업체 계정이 속한 업체 */
export class AdminPartnerDto {
@ApiProperty({ description: '업체 ID' })
id!: string;
@ApiProperty({ description: '업체 이름', example: '머니튜브' })
name!: string;
}
/** 로그인한 계정 */
export class AdminResponseDto {
@ApiProperty({
description: '관리자 아이디 (env ADMIN_USERNAME)',
description: '아이디 (최고관리자는 env ADMIN_USERNAME)',
example: 'admin',
})
username!: string;
@ApiProperty({
description: '역할 — super(최고관리자) · partner(업체 계정)',
enum: ACCOUNT_ROLES,
example: 'super',
})
role!: AccountRole;
@ApiProperty({
description: '[업체 계정] 속한 업체 — 최고관리자면 null',
type: AdminPartnerDto,
nullable: true,
})
partner!: AdminPartnerDto | null;
@ApiProperty({
description:
'[업체 계정] 임시 비밀번호를 바꿔야 하는가 — 바꾸기 전에는 실적을 볼 수 없다',
example: false,
})
mustChangePassword!: boolean;
}
@@ -0,0 +1,119 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import {
ACCOUNT_PASSWORD_MAX_LENGTH,
ACCOUNT_PASSWORD_MIN_LENGTH,
} from '../admin-auth.constants';
/** 영문·숫자로 시작하고 영문·숫자·. _ - 만, 4~30자 (저장은 소문자) */
const USERNAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{3,29}$/;
/** 업체 계정 목록 조회 */
export class PartnerAccountListQueryDto extends CursorPaginationQueryDto {}
/** 업체 계정 만들기 — 비밀번호는 서버가 만들어 한 번만 돌려준다 */
export class CreatePartnerAccountDto {
@ApiProperty({
description: '로그인 아이디 — 영문·숫자·. _ - 4~30자 (대소문자 구분 없음)',
example: 'moneytube',
})
@IsString()
@Matches(USERNAME_PATTERN, {
message:
'아이디는 영문·숫자로 시작하는 4~30자(영문·숫자·. _ -)로 입력해 주세요.',
})
username!: string;
}
/** 업체 계정 수정 — 지금은 사용/중지만 */
export class UpdatePartnerAccountDto {
@ApiPropertyOptional({
description:
'사용 여부 — false 로 바꾸면 그 계정의 모든 기기가 로그아웃된다',
example: false,
})
@IsOptional()
@IsBoolean({ message: '사용 여부 값이 올바르지 않습니다.' })
isActive?: boolean;
}
/** 업체 계정 한 개 — 비밀번호(해시 포함)는 내려주지 않는다 */
export class PartnerAccountResponseDto {
@ApiProperty({ description: '계정 ID' })
id!: string;
@ApiProperty({ description: '로그인 아이디 (소문자)', example: 'moneytube' })
username!: string;
@ApiProperty({ description: '업체 ID' })
partnerId!: string;
@ApiProperty({ description: '사용 여부', example: true })
isActive!: boolean;
@ApiProperty({
description: '임시 비밀번호를 아직 바꾸지 않았는가',
example: true,
})
mustChangePassword!: boolean;
@ApiProperty({
description: '마지막 로그인 시각 — 한 번도 안 했으면 null',
nullable: true,
example: '2026-10-06T05:00:00.000Z',
})
lastLoginAt!: string | null;
@ApiProperty({
description: '만든 시각',
example: '2026-10-06T05:00:00.000Z',
})
createdAt!: string;
}
/** 만들기·초기화 결과 — 임시 비밀번호는 이 응답에서 한 번만 볼 수 있다 */
export class PartnerAccountCredentialDto {
@ApiProperty({ description: '업체 계정', type: PartnerAccountResponseDto })
account!: PartnerAccountResponseDto;
@ApiProperty({
description:
'임시 비밀번호 — 다시 볼 수 없다. 업체가 첫 로그인 때 직접 바꿔야 실적을 볼 수 있다',
example: 'Kp7mXz3qRt9wHc',
})
temporaryPassword!: string;
}
/** 비밀번호 변경 (업체 계정 본인) */
export class ChangePasswordDto {
@ApiProperty({ description: '현재 비밀번호 (임시 비밀번호 포함)' })
@IsString()
@IsNotEmpty({ message: '현재 비밀번호를 입력해 주세요.' })
@MaxLength(ACCOUNT_PASSWORD_MAX_LENGTH, {
message: '비밀번호가 너무 깁니다.',
})
currentPassword!: string;
@ApiProperty({
description: `새 비밀번호 — ${ACCOUNT_PASSWORD_MIN_LENGTH}자 이상`,
example: 'a-new-long-password',
})
@IsString()
@MinLength(ACCOUNT_PASSWORD_MIN_LENGTH, {
message: `새 비밀번호는 ${ACCOUNT_PASSWORD_MIN_LENGTH}자 이상이어야 합니다.`,
})
@MaxLength(ACCOUNT_PASSWORD_MAX_LENGTH, {
message: '비밀번호가 너무 깁니다.',
})
newPassword!: string;
}
@@ -0,0 +1,147 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentAccount } from '@/common/decorators/current-admin.decorator';
import { PaginatedResult } from '@/common/dto/pagination.dto';
import {
AccountAuthGuard,
AdminAuthGuard,
} from '@/common/guards/admin-auth.guard';
import { ADMIN_BEARER_AUTH, LOGIN_THROTTLE } from './admin-auth.constants';
import { AdminAuthService } from './admin-auth.service';
import type { VerifiedAccount } from './admin-auth.service';
import { AdminSessionDto } from './dto/admin-session.dto';
import {
ChangePasswordDto,
CreatePartnerAccountDto,
PartnerAccountCredentialDto,
PartnerAccountListQueryDto,
PartnerAccountResponseDto,
UpdatePartnerAccountDto,
} from './dto/partner-account.dto';
import { PartnerAccountsService } from './partner-accounts.service';
/** 업체 계정 관리 — 최고관리자 전용 */
@ApiTags('Admin Partner Accounts')
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@UseGuards(AdminAuthGuard)
@Controller('admin')
export class PartnerAccountsController {
constructor(private readonly accounts: PartnerAccountsService) {}
@Get('partners/:partnerId/accounts')
@ApiOperation({ summary: '업체 계정 목록 (최근 만든 순)' })
@ApiStandardResponse(PartnerAccountResponseDto, {
description: '조회 성공 — data 는 { items, pageInfo }',
isArray: true,
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('AUTH_003', '최고관리자가 아님')
@ApiErrorResponse('RES_001', '없는 업체')
findMany(
@Param('partnerId', new ParseUUIDPipe()) partnerId: string,
@Query() query: PartnerAccountListQueryDto,
): Promise<PaginatedResult<PartnerAccountResponseDto>> {
return this.accounts.findMany(partnerId, query);
}
@Post('partners/:partnerId/accounts')
@ApiOperation({
summary: '업체 계정 만들기 — 임시 비밀번호는 이 응답에서만 볼 수 있다',
})
@ApiStandardResponse(PartnerAccountCredentialDto, {
status: 201,
description: '생성 성공',
})
@ApiErrorResponse('AUTH_003', '최고관리자가 아님')
@ApiErrorResponse('RES_001', '없는 업체')
@ApiErrorResponse('ACCOUNT_001', '이미 쓰는 아이디')
@ApiErrorResponse('VAL_001', '아이디 형식 오류')
create(
@Param('partnerId', new ParseUUIDPipe()) partnerId: string,
@Body() dto: CreatePartnerAccountDto,
): Promise<PartnerAccountCredentialDto> {
return this.accounts.create(partnerId, dto);
}
@Patch('partner-accounts/:id')
@ApiOperation({
summary: '업체 계정 사용/중지 — 중지하면 모든 기기 로그아웃',
})
@ApiStandardResponse(PartnerAccountResponseDto, { description: '수정 성공' })
@ApiErrorResponse('AUTH_003', '최고관리자가 아님')
@ApiErrorResponse('RES_001', '없는 계정')
update(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() dto: UpdatePartnerAccountDto,
): Promise<PartnerAccountResponseDto> {
return this.accounts.update(id, dto);
}
@Post('partner-accounts/:id/password-resets')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary:
'업체 계정 비밀번호 초기화 — 새 임시 비밀번호를 한 번만 돌려주고 모든 기기 로그아웃',
})
@ApiStandardResponse(PartnerAccountCredentialDto, {
description: '초기화 성공',
})
@ApiErrorResponse('AUTH_003', '최고관리자가 아님')
@ApiErrorResponse('RES_001', '없는 계정')
resetPassword(
@Param('id', new ParseUUIDPipe()) id: string,
): Promise<PartnerAccountCredentialDto> {
return this.accounts.resetPassword(id);
}
}
/** 로그인한 계정 본인 — 비밀번호 변경 (업체 계정만. 최고관리자는 env 에서 바꾼다) */
@ApiTags('Admin Session')
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@UseGuards(AccountAuthGuard)
@Controller('admin/accounts/current')
export class AccountPasswordController {
constructor(private readonly auth: AdminAuthService) {}
@Put('password')
@Throttle(LOGIN_THROTTLE)
@ApiOperation({
summary:
'비밀번호 변경 — 다른 기기는 로그아웃되고, 이 기기에는 새 토큰을 돌려준다',
})
@ApiStandardResponse(AdminSessionDto, { description: '변경 성공' })
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('AUTH_003', '최고관리자 (env 에서 바꾼다)')
@ApiErrorResponse('ACCOUNT_002', '현재 비밀번호 불일치')
@ApiErrorResponse('VAL_001', '새 비밀번호 형식 오류')
@ApiErrorResponse('REQ_001', '시도 횟수 초과 (1분 5회)')
changePassword(
@CurrentAccount() account: VerifiedAccount,
@Body() dto: ChangePasswordDto,
): Promise<AdminSessionDto> {
return this.auth.changePassword(
account,
dto.currentPassword,
dto.newPassword,
);
}
}
@@ -0,0 +1,115 @@
import type { ConfigService } from '@nestjs/config';
import { compareSync } from 'bcryptjs';
import { QueryFailedError } from 'typeorm';
import type { Repository } from 'typeorm';
import type { Partner } from '@/modules/partners/entities/partner.entity';
import { AdminAccount } from './admin-account.entity';
import type { AdminAuthService } from './admin-auth.service';
import { PartnerAccountsService } from './partner-accounts.service';
const PARTNER_ID = '11111111-1111-4111-8111-111111111111';
function setup(options: { partnerExists?: boolean } = {}) {
const inserted: AdminAccount[] = [];
const store = new Map<string, AdminAccount>();
const accounts = {
create: jest.fn((row: Partial<AdminAccount>) => row as AdminAccount),
insert: jest.fn((row: AdminAccount) => {
inserted.push(row);
store.set(row.id, { ...row, createdAt: new Date() });
return Promise.resolve();
}),
findOne: jest.fn(({ where }: { where: { id: string } }) =>
Promise.resolve(store.get(where.id) ?? null),
),
save: jest.fn((row: AdminAccount) => {
store.set(row.id, row);
return Promise.resolve(row);
}),
};
const partners = {
exists: jest.fn(() => Promise.resolve(options.partnerExists ?? true)),
};
const auth = { revokeAccountSessions: jest.fn(() => Promise.resolve()) };
const config = {
get: (key: string) => (key === 'ADMIN_USERNAME' ? 'Admin' : ''),
};
const service = new PartnerAccountsService(
accounts as unknown as Repository<AdminAccount>,
partners as unknown as Repository<Partner>,
auth as unknown as AdminAuthService,
config as unknown as ConfigService,
);
return { service, accounts, inserted, store, auth };
}
describe('PartnerAccountsService', () => {
it('계정을 만들면 임시 비밀번호를 한 번 돌려주고 해시만 저장한다 (아이디는 소문자)', async () => {
const { service, inserted } = setup();
const result = await service.create(PARTNER_ID, { username: 'MoneyTube' });
expect(result.account).toMatchObject({
username: 'moneytube',
partnerId: PARTNER_ID,
isActive: true,
mustChangePassword: true,
});
expect(result.temporaryPassword).toHaveLength(14);
expect(inserted[0].passwordHash).not.toContain(result.temporaryPassword);
expect(
compareSync(result.temporaryPassword, inserted[0].passwordHash),
).toBe(true);
expect(JSON.stringify(result.account)).not.toContain('passwordHash');
});
it('최고관리자 아이디·이미 있는 아이디는 ACCOUNT_001, 없는 업체는 RES_001', async () => {
const { service, accounts } = setup();
await expect(
service.create(PARTNER_ID, { username: 'admin' }),
).rejects.toMatchObject({ code: 'ACCOUNT_001' });
accounts.insert.mockRejectedValueOnce(
new QueryFailedError('INSERT', [], { code: '23505' } as never),
);
await expect(
service.create(PARTNER_ID, { username: 'taken' }),
).rejects.toMatchObject({ code: 'ACCOUNT_001' });
const missing = setup({ partnerExists: false });
await expect(
missing.service.create(PARTNER_ID, { username: 'someone' }),
).rejects.toMatchObject({ code: 'RES_001' });
});
it('계정을 중지하면 그 계정의 세션을 모두 끊는다', async () => {
const { service, auth } = setup();
const { account } = await service.create(PARTNER_ID, {
username: 'moneytube',
});
const updated = await service.update(account.id, { isActive: false });
expect(updated.isActive).toBe(false);
expect(auth.revokeAccountSessions).toHaveBeenCalledWith(account.id);
});
it('비밀번호를 초기화하면 새 임시 비밀번호로 바뀌고 다시 변경을 요구하며 세션을 끊는다', async () => {
const { service, auth, store } = setup();
const created = await service.create(PARTNER_ID, { username: 'moneytube' });
store.get(created.account.id)!.mustChangePassword = false;
const reset = await service.resetPassword(created.account.id);
const row = store.get(created.account.id)!;
expect(reset.temporaryPassword).not.toBe(created.temporaryPassword);
expect(compareSync(reset.temporaryPassword, row.passwordHash)).toBe(true);
expect(compareSync(created.temporaryPassword, row.passwordHash)).toBe(
false,
);
expect(row.mustChangePassword).toBe(true);
expect(auth.revokeAccountSessions).toHaveBeenCalledWith(created.account.id);
});
});
@@ -0,0 +1,201 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { hash } from 'bcryptjs';
import { randomInt, randomUUID } from 'crypto';
import { QueryFailedError, Repository } from 'typeorm';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { Partner } from '@/modules/partners/entities/partner.entity';
import { generateTemporaryPassword } from '@/shared/utils/temporary-password';
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
import { AdminAccount } from './admin-account.entity';
import { ADMIN_USERNAME_KEY, BCRYPT_ROUNDS } from './admin-auth.constants';
import { AdminAuthService, normalizeUsername } from './admin-auth.service';
import {
CreatePartnerAccountDto,
PartnerAccountCredentialDto,
PartnerAccountListQueryDto,
PartnerAccountResponseDto,
UpdatePartnerAccountDto,
} from './dto/partner-account.dto';
const ALIAS = 'account';
/** Postgres 유일 제약 위반 — 같은 아이디가 동시에 만들어진 경우 */
const PG_UNIQUE_VIOLATION = '23505';
function isUniqueViolation(error: unknown): boolean {
return (
error instanceof QueryFailedError &&
(error.driverError as { code?: string } | undefined)?.code ===
PG_UNIQUE_VIOLATION
);
}
function toResponse(row: AdminAccount): PartnerAccountResponseDto {
return {
id: row.id,
username: row.username,
partnerId: row.partnerId,
isActive: row.isActive,
mustChangePassword: row.mustChangePassword,
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
/**
* 업체 계정 관리 — 최고관리자만 (AdminAuthGuard).
*
* 비밀번호는 서버가 만들어 응답으로 한 번만 돌려주고 해시만 남긴다.
* 업체는 첫 로그인 때 직접 바꿔야 실적을 볼 수 있다 (mustChangePassword → ACCOUNT_003).
*/
@Injectable()
export class PartnerAccountsService {
private readonly logger = new Logger(PartnerAccountsService.name);
constructor(
@InjectRepository(AdminAccount)
private readonly accounts: Repository<AdminAccount>,
@InjectRepository(Partner)
private readonly partners: Repository<Partner>,
private readonly auth: AdminAuthService,
private readonly config: ConfigService,
) {}
/** 업체의 계정 목록 — 최근 만든 순 */
async findMany(
partnerId: string,
query: PartnerAccountListQueryDto,
): Promise<PaginatedResult<PartnerAccountResponseDto>> {
await this.requirePartner(partnerId);
const base = this.accounts
.createQueryBuilder(ALIAS)
.where(`${ALIAS}.partnerId = :partnerId`, { partnerId });
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(items.map(toResponse), {
hasNext,
nextCursor:
hasNext && last
? encodeTimeCursor({
time: last.createdAt.toISOString(),
key: last.id,
})
: null,
total,
});
}
/** 업체 계정 만들기 — 임시 비밀번호를 한 번만 돌려준다 */
async create(
partnerId: string,
dto: CreatePartnerAccountDto,
): Promise<PartnerAccountCredentialDto> {
await this.requirePartner(partnerId);
const username = normalizeUsername(dto.username);
// 최고관리자(env) 아이디와 같으면 로그인 때 최고관리자로 먼저 맞춰 보므로 쓸 수 없다
const superUsername = normalizeUsername(
this.config.get<string>(ADMIN_USERNAME_KEY) ?? '',
);
if (username === superUsername) {
throw new BusinessException('ACCOUNT_001', {
context: { reason: 'super_admin_username' },
});
}
const temporaryPassword = generateTemporaryPassword(randomInt);
const row = this.accounts.create({
id: randomUUID(),
username,
passwordHash: await hash(temporaryPassword, BCRYPT_ROUNDS),
partnerId,
isActive: true,
mustChangePassword: true,
lastLoginAt: null,
});
try {
await this.accounts.insert(row);
} catch (error) {
if (isUniqueViolation(error)) {
throw new BusinessException('ACCOUNT_001', { context: { username } });
}
throw error;
}
this.logger.log(`업체 계정 생성: account=${row.id} partner=${partnerId}`);
return {
account: toResponse(await this.require(row.id)),
temporaryPassword,
};
}
/** 사용/중지 — 중지하면 그 계정의 모든 기기가 로그아웃된다 */
async update(
id: string,
dto: UpdatePartnerAccountDto,
): Promise<PartnerAccountResponseDto> {
const row = await this.require(id);
if (dto.isActive !== undefined && dto.isActive !== row.isActive) {
row.isActive = dto.isActive;
await this.accounts.save(row);
if (!row.isActive) await this.auth.revokeAccountSessions(row.id);
this.logger.log(
`업체 계정 ${row.isActive ? '사용' : '중지'}: account=${row.id}`,
);
}
return toResponse(row);
}
/** 비밀번호 초기화 — 새 임시 비밀번호를 한 번만 돌려주고 열린 세션을 모두 끊는다 */
async resetPassword(id: string): Promise<PartnerAccountCredentialDto> {
const row = await this.require(id);
const temporaryPassword = generateTemporaryPassword(randomInt);
row.passwordHash = await hash(temporaryPassword, BCRYPT_ROUNDS);
row.mustChangePassword = true;
await this.accounts.save(row);
await this.auth.revokeAccountSessions(row.id);
this.logger.log(`업체 계정 비밀번호 초기화: account=${row.id}`);
return { account: toResponse(row), temporaryPassword };
}
private async require(id: string): Promise<AdminAccount> {
const row = await this.accounts.findOne({ where: { id } });
if (!row) {
throw new BusinessException('RES_001', { context: { accountId: id } });
}
return row;
}
private async requirePartner(partnerId: string): Promise<void> {
const exists = await this.partners.exists({ where: { id: partnerId } });
if (!exists) {
throw new BusinessException('RES_001', { context: { partnerId } });
}
}
}
@@ -47,7 +47,7 @@ export class MemberApplication {
@Column({ name: 'kakao_clicked_at', type: 'timestamptz', nullable: true })
kakaoClickedAt!: Date | null;
/** 신청 순간의 유입정보 원본 (header-top.html 이 저장해 둔 값, 파이프 형식) */
/** 신청 순간의 유입정보 원본 (imweb-header-top.html 이 저장해 둔 값, 파이프 형식) */
@Column({ name: 'inflow_raw', type: 'text', nullable: true })
inflowRaw!: string | null;
@@ -83,4 +83,47 @@ export class MemberApplication {
nullable: true,
})
inflowContent!: string | null;
/*
* 신청 때 최종 유입 — 신청 직전에 마지막으로 들어온 경로 (신청을 일으킨 유입).
* 위 inflow_* 는 신청 시점 브라우저에 남아 있던 '최초' 유입(2일 보관)이라,
* 2일 안에 다른 경로로 다시 들어와 신청하면 둘이 달라진다. 유입이 한 번뿐이면 같은 값이다.
*/
/** 최종 유입 원본 (imweb-header-top.html 의 MG_UTM.peek().last, 파이프 형식) */
@Column({ name: 'inflow_last_raw', type: 'text', nullable: true })
inflowLastRaw!: string | null;
@Index()
@Column({
name: 'inflow_last_source',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowLastSource!: string | null;
@Column({
name: 'inflow_last_medium',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowLastMedium!: string | null;
@Column({
name: 'inflow_last_campaign',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowLastCampaign!: string | null;
@Column({
name: 'inflow_last_content',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowLastContent!: string | null;
}
@@ -1,32 +1,21 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { PaginatedResult } from '@/common/dto/pagination.dto';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
import { ADMIN_BEARER_AUTH } from '@/modules/admin-auth/admin-auth.constants';
import { ApplicationsService } from './applications.service';
import { ApplicationListQueryDto } from './dto/application-query.dto';
import {
ApplicationProductDto,
ApplicationResponseDto,
} from './dto/application-response.dto';
import {
CreateApplicationDto,
CreateApplicationResultDto,
@@ -71,37 +60,3 @@ export class ApplicationsController {
await this.applications.markKakaoClicked(id);
}
}
/** 관리자 신청 내역 조회 */
@ApiTags('Admin Applications')
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@UseGuards(AdminAuthGuard)
@Controller('admin/applications')
export class AdminApplicationsController {
constructor(private readonly applications: ApplicationsService) {}
@Get()
@ApiOperation({ summary: '신청 내역 (최신순, 커서 페이지네이션)' })
@ApiStandardResponse(ApplicationResponseDto, {
description: '조회 성공 — data 는 { items, pageInfo }',
isArray: true,
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '필터·커서 형식 오류')
findMany(
@Query() query: ApplicationListQueryDto,
): Promise<PaginatedResult<ApplicationResponseDto>> {
return this.applications.findMany(query);
}
@Get('products')
@ApiOperation({ summary: '상품별 신청 수 (최근 신청이 있는 상품부터)' })
@ApiStandardResponse(ApplicationProductDto, {
description: '조회 성공',
isArray: true,
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
products(): Promise<ApplicationProductDto[]> {
return this.applications.products();
}
}
@@ -1,26 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminAuthModule } from '@/modules/admin-auth/admin-auth.module';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { ImwebModule } from '@/modules/imweb/imweb.module';
import { MembersModule } from '@/modules/members/members.module';
import { PartnersModule } from '@/modules/partners/partners.module';
import { MemberApplication } from './application.entity';
import {
AdminApplicationsController,
ApplicationsController,
} from './applications.controller';
import { ApplicationsController } from './applications.controller';
import { ApplicationsService } from './applications.service';
/** 강의 신청 기록 — 신청 완료 화면이 보내고, 관리자 화면에서 본다 */
/**
* 강의 신청 기록 — 신청 완료 화면이 보낸다.
* 관리자 화면의 조회는 ActivitiesModule(신청·구매 내역)이 맡는다.
*/
@Module({
imports: [
TypeOrmModule.forFeature([MemberApplication, ImwebMember]),
AdminAuthModule,
TypeOrmModule.forFeature([MemberApplication]),
ImwebModule,
MembersModule,
// 신청을 업체 링크 실적으로 귀속한다
PartnersModule,
],
controllers: [ApplicationsController, AdminApplicationsController],
controllers: [ApplicationsController],
providers: [ApplicationsService],
})
export class ApplicationsModule {}
@@ -3,7 +3,7 @@ import { QueryFailedError, type Repository } from 'typeorm';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import type { ImwebConfig } from '@/modules/imweb/imweb.config';
import type { ImwebMemberLookupService } from '@/modules/imweb/imweb-member-lookup.service';
import type { MembersService } from '@/modules/members/members.service';
import type { PartnerAttributionService } from '@/modules/partners/partner-attribution.service';
import { MemberApplication } from './application.entity';
import { ApplicationsService } from './applications.service';
@@ -32,14 +32,24 @@ 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 +107,46 @@ describe('ApplicationsService.create', () => {
expect(store).toHaveLength(0);
});
it('최종 유입을 최초 유입과 따로 저장한다 (2일 안에 다른 경로로 다시 와서 신청)', async () => {
const { service, store } = setup();
await service.create({
memberUid: 'user@test.dev',
productIdx: '52',
inflow: 'src=naver|med=organic|jd=2026-10-05',
lastInflow: 'src=facebook|med=paid|cmp=fall|cnt=video_b|ts=2026-10-05',
});
expect(store[0]).toMatchObject({
inflowSource: 'naver',
inflowMedium: 'organic',
inflowLastRaw: 'src=facebook|med=paid|cmp=fall|cnt=video_b|ts=2026-10-05',
inflowLastSource: 'facebook',
inflowLastMedium: 'paid',
inflowLastCampaign: 'fall',
inflowLastContent: 'video_b',
});
});
it('최종 유입이 없으면 최초 유입을 최종 유입으로도 쓴다 (유입이 한 번뿐)', async () => {
const { service, store } = setup();
await service.create({
memberUid: 'user@test.dev',
productIdx: '52',
inflow: 'src=naver|med=cpc|cmp=spring|jd=2026-10-05',
lastInflow: '형식 아님',
});
expect(store[0]).toMatchObject({
inflowSource: 'naver',
inflowLastRaw: null,
inflowLastSource: 'naver',
inflowLastMedium: 'cpc',
inflowLastCampaign: 'spring',
});
});
it('유입정보가 없거나 스크립트 형식이 아니면 비워 둔다', async () => {
const { service, store } = setup();
@@ -131,3 +181,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();
});
});
@@ -4,37 +4,19 @@ import { randomUUID } from 'crypto';
import { IsNull, QueryFailedError, Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { ImwebConfig } from '@/modules/imweb/imweb.config';
import { ImwebMemberLookupService } from '@/modules/imweb/imweb-member-lookup.service';
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
import { MembersService } from '@/modules/members/members.service';
import { PartnerAttributionService } from '@/modules/partners/partner-attribution.service';
import { parseInflow } from '@/shared/utils/inflow';
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
import { MemberApplication } from './application.entity';
import { ApplicationListQueryDto } from './dto/application-query.dto';
import {
ApplicationProductDto,
ApplicationResponseDto,
} from './dto/application-response.dto';
import {
CreateApplicationDto,
CreateApplicationResultDto,
} from './dto/create-application.dto';
const ALIAS = 'a';
const MEMBER_ALIAS = 'm';
/** Postgres 유일 제약 위반 — 같은 회원·상품이 동시에 두 번 들어온 경우 */
const PG_UNIQUE_VIOLATION = '23505';
function escapeLike(value: string): string {
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
}
function isUniqueViolation(error: unknown): boolean {
return (
error instanceof QueryFailedError &&
@@ -49,6 +31,7 @@ function isUniqueViolation(error: unknown): boolean {
* 신청 완료 화면은 공개 페이지라 보낸 사람이 정말 그 회원인지는 확인할 수 없다.
* 그래서 (1) 아임웹에 실제로 있는 회원인지 확인하고 (2) 회원·상품당 한 건만 남기며
* (3) 화면에 보이는 회원 정보는 브라우저가 보낸 값이 아니라 아임웹에서 받은 값만 쓴다.
* 관리자 조회는 유료 구매와 함께 ActivitiesModule(신청·구매 내역)이 맡는다.
*/
@Injectable()
export class ApplicationsService {
@@ -57,11 +40,9 @@ export class ApplicationsService {
constructor(
@InjectRepository(MemberApplication)
private readonly applications: Repository<MemberApplication>,
@InjectRepository(ImwebMember)
private readonly members: Repository<ImwebMember>,
private readonly lookup: ImwebMemberLookupService,
private readonly membersService: MembersService,
private readonly config: ImwebConfig,
private readonly attribution: PartnerAttributionService,
) {}
/** 신청 기록 — 같은 회원·상품이면 기존 기록을 돌려준다 (몇 번을 불러도 한 건) */
@@ -74,8 +55,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 +71,13 @@ export class ApplicationsService {
now.toISOString(),
this.config.trackingHost,
);
// 최종 유입이 없으면(유입이 한 번뿐이거나 예전 위젯) 최초 유입이 곧 최종 유입이다
const parsedLast = parseInflow(
dto.lastInflow,
now.toISOString(),
this.config.trackingHost,
);
const last = parsedLast.source ? parsedLast : inflow;
const row = this.applications.create({
id: randomUUID(),
memberCode: member.memberCode,
@@ -96,6 +91,13 @@ export class ApplicationsService {
inflowMedium: inflow.medium,
inflowCampaign: inflow.campaign,
inflowContent: inflow.content,
inflowLastRaw: parsedLast.source
? (dto.lastInflow?.trim() ?? null)
: null,
inflowLastSource: last.source,
inflowLastMedium: last.medium,
inflowLastCampaign: last.campaign,
inflowLastContent: last.content,
});
try {
@@ -111,9 +113,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(
@@ -122,153 +148,10 @@ export class ApplicationsService {
);
}
async findMany(
query: ApplicationListQueryDto,
): Promise<PaginatedResult<ApplicationResponseDto>> {
const base = this.applications
.createQueryBuilder(ALIAS)
// 회원 검색을 위해 동기화된 회원과 잇는다 (탈퇴 회원은 회원 정보 없이 남는다)
.leftJoin(
ImwebMember,
MEMBER_ALIAS,
`${MEMBER_ALIAS}.member_code = ${ALIAS}.member_code`,
);
if (query.productIdx) {
base.andWhere(`${ALIAS}.productIdx = :productIdx`, {
productIdx: query.productIdx,
});
}
if (query.appliedFrom) {
base.andWhere(`${ALIAS}.appliedAt >= :appliedFrom`, {
appliedFrom: query.appliedFrom,
});
}
if (query.appliedTo) {
base.andWhere(`${ALIAS}.appliedAt < :appliedTo`, {
appliedTo: query.appliedTo,
});
}
if (query.source) {
base.andWhere(`${ALIAS}.inflowSource = :source`, {
source: query.source,
});
}
if (query.q) {
base.andWhere(
`(${MEMBER_ALIAS}.name ILIKE :q OR ${ALIAS}.member_uid ILIKE :q OR ${MEMBER_ALIAS}.email ILIKE :q OR ${MEMBER_ALIAS}.callnum ILIKE :q)`,
{ q: `%${escapeLike(query.q.trim())}%` },
);
}
const total = await base.clone().getCount();
const page = base
.clone()
.orderBy(`${ALIAS}.appliedAt`, 'DESC')
.addOrderBy(`${ALIAS}.id`, 'DESC')
.take(query.limit + 1);
if (query.cursor) {
const cursor = decodeTimeCursor(query.cursor);
if (!cursor) {
throw new BusinessException('VAL_001', {
context: { reason: 'invalid_cursor' },
});
}
page.andWhere(
`(${ALIAS}.appliedAt, ${ALIAS}.id) < (:cursorTime, :cursorId)`,
{
cursorTime: cursor.time,
cursorId: cursor.key,
},
);
}
const rows = await page.getMany();
const hasNext = rows.length > query.limit;
const items = rows.slice(0, query.limit);
const last = items.at(-1);
const [membersByCode, groupTitles] = await Promise.all([
this.membersByCode(items.map((row) => row.memberCode)),
this.membersService.groupTitleMap(),
]);
return toPaginatedResult(
items.map((row) => {
const member = membersByCode.get(row.memberCode);
return {
id: row.id,
appliedAt: row.appliedAt.toISOString(),
kakaoClickedAt: row.kakaoClickedAt?.toISOString() ?? null,
productIdx: row.productIdx,
productName: row.productName,
memberUid: row.memberUid,
member: member ? MemberResponseDto.from(member, groupTitles) : null,
inflow: {
source: row.inflowSource,
medium: row.inflowMedium,
campaign: row.inflowCampaign,
content: row.inflowContent,
},
};
}),
{
hasNext,
nextCursor:
hasNext && last
? encodeTimeCursor({
time: last.appliedAt.toISOString(),
key: last.id,
})
: null,
total,
},
);
}
/** 상품별 신청 수 — 최근 신청이 있는 상품부터 */
async products(): Promise<ApplicationProductDto[]> {
const rows = await this.applications
.createQueryBuilder(ALIAS)
.select(`${ALIAS}.productIdx`, 'productIdx')
// 상품명은 바뀔 수 있어 가장 최근 신청의 표시값을 쓴다
.addSelect(
`(ARRAY_AGG(${ALIAS}.product_name ORDER BY ${ALIAS}.applied_at DESC))[1]`,
'productName',
)
.addSelect('COUNT(*)', 'count')
.groupBy(`${ALIAS}.productIdx`)
.orderBy(`MAX(${ALIAS}.applied_at)`, 'DESC')
.getRawMany<{
productIdx: string;
productName: string | null;
count: string;
}>();
return rows.map((row) => ({
productIdx: row.productIdx,
productName: row.productName,
count: Number(row.count),
}));
}
private findExisting(
memberCode: string,
productIdx: string,
): Promise<MemberApplication | null> {
return this.applications.findOne({ where: { memberCode, productIdx } });
}
private async membersByCode(
codes: string[],
): Promise<Map<string, ImwebMember>> {
if (codes.length === 0) return new Map();
const rows = await this.members
.createQueryBuilder(MEMBER_ALIAS)
.where(`${MEMBER_ALIAS}.memberCode IN (:...codes)`, { codes })
.getMany();
return new Map(rows.map((row) => [row.memberCode, row]));
}
}
@@ -1,55 +0,0 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow';
const SEARCH_MAX_LENGTH = 100;
/** 관리자 신청 내역 조회 */
export class ApplicationListQueryDto extends CursorPaginationQueryDto {
@ApiPropertyOptional({ description: '상품번호', example: '52' })
@IsOptional()
@Matches(/^\d{1,20}$/, { message: '상품번호 형식이 올바르지 않습니다.' })
productIdx?: string;
@ApiPropertyOptional({
description: '신청 시각 시작 (이상, ISO 8601)',
example: '2026-09-30T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '신청 시작일 형식이 올바르지 않습니다.' })
appliedFrom?: string;
@ApiPropertyOptional({
description: '신청 시각 끝 (미만, ISO 8601)',
example: '2026-10-31T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '신청 종료일 형식이 올바르지 않습니다.' })
appliedTo?: string;
@ApiPropertyOptional({
description: '신청 순간의 유입원',
example: 'facebook',
})
@IsOptional()
@IsString()
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
source?: string;
@ApiPropertyOptional({
description: '검색어 — 이름·아이디·이메일·연락처 부분 일치',
example: '홍길동',
})
@IsOptional()
@IsString()
@MaxLength(SEARCH_MAX_LENGTH, { message: '검색어가 너무 깁니다.' })
q?: string;
}
@@ -1,73 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
/** 신청 순간의 유입정보 */
export class ApplicationInflowDto {
@ApiProperty({ description: '유입원', nullable: true, example: 'facebook' })
source!: string | null;
@ApiProperty({ description: '매체', nullable: true, example: 'paid' })
medium!: string | null;
@ApiProperty({ description: '캠페인', nullable: true, example: 'fall' })
campaign!: string | null;
@ApiProperty({ description: '콘텐츠', nullable: true, example: 'video_a' })
content!: string | null;
}
/** 관리자 신청 내역 한 건 */
export class ApplicationResponseDto {
@ApiProperty({ description: '신청 기록 ID' })
id!: string;
@ApiProperty({
description: '신청 시각',
example: '2026-10-05T03:00:00.000Z',
})
appliedAt!: string;
@ApiProperty({
description: '오픈카톡 버튼을 처음 누른 시각 (안 눌렀으면 null)',
nullable: true,
})
kakaoClickedAt!: string | null;
@ApiProperty({ description: '상품번호', example: '52' })
productIdx!: string;
@ApiProperty({ description: '상품명 (신청 화면 표시값)', nullable: true })
productName!: string | null;
@ApiProperty({ description: '신청 당시 아이디', example: 'user@example.com' })
memberUid!: string;
@ApiProperty({
description: '회원 정보 (동기화된 회원 기준) — 탈퇴 등으로 없으면 null',
type: MemberResponseDto,
nullable: true,
})
member!: MemberResponseDto | null;
@ApiProperty({
description: '신청 순간의 유입정보',
type: ApplicationInflowDto,
})
inflow!: ApplicationInflowDto;
}
/** 상품별 신청 수 — 필터 칩에 쓴다 */
export class ApplicationProductDto {
@ApiProperty({ description: '상품번호', example: '52' })
productIdx!: string;
@ApiProperty({
description: '가장 최근 신청에 표시된 상품명',
nullable: true,
})
productName!: string | null;
@ApiProperty({ description: '신청 수', example: 120 })
count!: number;
}
@@ -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();
+26 -3
View File
@@ -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;
}
+13 -3
View File
@@ -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 {}
+63
View File
@@ -65,6 +65,69 @@ 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;
/** 주문자 — 비회원 주문은 이 값으로만 누군지 알 수 있다 */
ordererName?: string | null;
ordererEmail?: string | null;
ordererCall?: 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,108 @@
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: '아임웹 회원 코드 — 비회원 구매면 null',
nullable: true,
})
memberCode!: string | null;
@ApiProperty({
description: '회원 정보 (동기화된 회원 기준) — 탈퇴·비회원이면 null',
type: MemberResponseDto,
nullable: true,
})
member!: MemberResponseDto | null;
@ApiProperty({
description: '[비회원 구매] 주문서의 주문자 이름 — 회원이거나 모르면 null',
nullable: true,
example: '홍길동',
})
ordererName!: string | 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,131 @@
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:
'업체에 전달할 링크 — 아임웹 사이트의 /go 입구 (사이트 주소를 모르면 백엔드 /r/{코드})',
example: 'https://www.moneyguider.co.kr/go?c=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,88 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsEnum, IsOptional } from 'class-validator';
import { ConversionType } from '../entities/partner-conversion.entity';
import { PartnerLinkProductDto } from './partner-link.dto';
import { PartnerPeriodQueryDto, PartnerStatsDto } from './partner.dto';
/** 업체 화면 요약 기간 — 없으면 전체 */
export class PortalPeriodQueryDto {
@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 PortalConversionQueryDto extends PartnerPeriodQueryDto {
@ApiPropertyOptional({
description: '실적 종류 — application(신청) · purchase(구매)',
enum: ConversionType,
})
@IsOptional()
@IsEnum(ConversionType, { message: '실적 종류 값이 올바르지 않습니다.' })
type?: ConversionType;
}
/** 업체 화면 요약 — 로그인한 업체 계정의 업체 */
export class PortalSummaryDto {
@ApiProperty({ description: '업체 ID' })
partnerId!: string;
@ApiProperty({ description: '업체 이름', example: '머니튜브' })
partnerName!: string;
@ApiProperty({ description: '발급된 링크 수', example: 3 })
linkCount!: number;
@ApiProperty({ description: '기간 내 실적', type: PartnerStatsDto })
stats!: PartnerStatsDto;
}
/**
* 업체 화면 실적 한 건 — 누가 왔는지는 가린 이름만.
* 연락처·이메일·아이디·회원 코드·주문번호는 내려주지 않는다 (개인정보 제3자 제공 방지).
*/
export class PortalConversionDto {
@ApiProperty({ description: '실적 ID' })
id!: string;
@ApiProperty({ description: '실적 종류', enum: ConversionType })
type!: ConversionType;
@ApiProperty({
description: '신청·주문 시각',
example: '2026-10-05T05:00:00.000Z',
})
convertedAt!: string;
@ApiProperty({ description: '상품', type: PartnerLinkProductDto })
product!: PartnerLinkProductDto;
@ApiProperty({
description: '가린 이름 (예: 홍*동) — 모르면 null',
nullable: true,
example: '홍*동',
})
maskedName!: string | null;
@ApiProperty({ description: '회원인가 (비회원 구매면 false)', example: true })
isMember!: boolean;
@ApiProperty({
description: '실적에 들어가는가 — 구매가 환불·취소되면 false',
example: true,
})
isValid!: boolean;
}
@@ -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,85 @@
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;
/** 아임웹 회원 코드 — 비회원 구매(주문완료 화면이 보낸 클릭 ID 로 이은 것)면 null */
@Column({ name: 'member_code', type: 'varchar', length: 40, nullable: true })
memberCode!: string | null;
/** 신청 기록 ID (구매면 null) */
@Column({ name: 'application_id', type: 'uuid', nullable: true })
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,390 @@
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('결제 전 주문·클릭 ID 없는 비회원 주문·링크 없는 상품은 잡지 않는다', 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('비회원 주문도 주문완료 화면이 보낸 클릭 ID 로 그 상품 링크에 귀속한다', async () => {
const wtime = new Date();
const clickedAt = new Date(wtime.getTime() - DAY);
const { service, conversionStore } = setup({
clicks: [
{
ref: 'AAAAAAAAAAAAAAAA',
linkId: LINK_A.id,
clickedAt,
link: { ...LINK_A, isActive: true } as PartnerLink,
},
],
});
const result = await service.attributeOrder(
order({ isMember: 'N', memberCode: null, wtime: wtime.toISOString() }),
linked,
['AAAAAAAAAAAAAAAA'],
);
expect(result).toEqual({ created: 1, updated: 0 });
expect(conversionStore[0]).toMatchObject({
type: ConversionType.PURCHASE,
linkId: LINK_A.id,
memberCode: null,
orderNo: '202610050001',
touchedAt: clickedAt,
});
});
it('다른 상품 링크·중지된 링크의 클릭 ID 는 귀속하지 않는다', async () => {
const clickedAt = new Date(Date.now() - DAY);
const { service, conversionStore } = setup({
clicks: [
{
ref: 'AAAAAAAAAAAAAAAA',
linkId: 'link-other',
clickedAt,
link: { id: 'link-other', prodNo: 60, isActive: true } as PartnerLink,
},
{
ref: 'BBBBBBBBBBBBBBBB',
linkId: LINK_B.id,
clickedAt,
link: { ...LINK_B, isActive: false } as PartnerLink,
},
],
});
await service.attributeOrder(
order({ isMember: 'N', memberCode: null }),
linked,
['AAAAAAAAAAAAAAAA', 'BBBBBBBBBBBBBBBB'],
);
expect(conversionStore).toHaveLength(0);
});
it('회원 주문은 회원 클릭과 주문완료 클릭 ID 중 마지막 클릭에 귀속한다', async () => {
const wtime = new Date();
const { service, conversionStore, touch } = setup({
clicks: [
{
ref: 'BBBBBBBBBBBBBBBB',
linkId: LINK_B.id,
clickedAt: new Date(wtime.getTime() - DAY),
link: { ...LINK_B, isActive: true } as PartnerLink,
},
],
});
touch(LINK_A, new Date(wtime.getTime() - 3 * DAY));
await service.attributeOrder(
order({ wtime: wtime.toISOString() }),
linked,
['BBBBBBBBBBBBBBBB'],
);
expect(conversionStore[0]).toMatchObject({
linkId: LINK_B.id,
memberCode: MEMBER_CODE,
});
});
it('클릭보다 먼저 한 주문은 그 링크의 실적이 아니다', async () => {
const { service, conversionStore, touch } = setup();
const wtime = new Date(Date.now() - 3 * DAY);
touch(LINK_A, new Date(Date.now() - DAY));
await service.attributeOrder(order({ wtime: wtime.toISOString() }), linked);
expect(conversionStore).toHaveLength(0);
});
});
@@ -0,0 +1,328 @@
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,
TouchCandidate,
} 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}`,
);
}
/**
* 아임웹 주문 한 건을 반영한다.
* - 이미 잡힌 구매는 환불·취소 여부만 갱신한다.
* - 새로 결제완료된 상품은 마지막 클릭 링크가 있으면 구매로 잡는다.
* 클릭 후보는 회원의 클릭 연결(partner_touches)과 주문완료 화면이 보낸 클릭 ID 를 합친 것이다.
* 주문완료 화면의 클릭 ID 로는 비회원 구매도 잡힌다.
*
* @param linkedProdNos 업체 링크가 있는 상품번호 — 그 밖의 상품은 볼 필요가 없다
* @param partnerRefs 주문완료 화면이 보낸 이 주문의 업체 링크 클릭 ID (없으면 빈 배열)
*/
async attributeOrder(
order: ImwebOrderData,
linkedProdNos: ReadonlySet<number>,
partnerRefs: readonly string[] = [],
): Promise<OrderAttributionResult> {
const result: OrderAttributionResult = { created: 0, updated: 0 };
const orderNo = String(order.orderNo);
const orderedAt = new Date(order.wtime);
if (Number.isNaN(orderedAt.getTime())) return result;
const memberCode =
order.isMember === 'Y' && order.memberCode ? order.memberCode : null;
const { products } = evaluateOrder(order);
for (const [prodNo, counts] of products) {
if (!linkedProdNos.has(prodNo)) continue;
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;
}
// 결제 전이면 다음 동기화에서 다시 본다.
// 회원도 아니고 주문완료 화면이 클릭 ID 를 보내지도 않았으면 이을 방법이 없다.
if (!counts || (!memberCode && partnerRefs.length === 0)) continue;
const touch = pickLastTouch(
[
...(memberCode
? await this.memberTouches(memberCode, prodNo, orderedAt)
: []),
...(await this.refTouches(partnerRefs, prodNo, orderedAt)),
],
orderedAt,
);
if (!touch) continue;
await this.insertConversion({
type: ConversionType.PURCHASE,
touch,
memberCode,
prodNo,
applicationId: null,
orderNo,
convertedAt: orderedAt,
});
result.created += 1;
this.logger.log(
`업체 실적(구매): link=${touch.linkId} member=${memberCode ?? 'guest'} 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> {
return pickLastTouch(
await this.memberTouches(memberCode, prodNo, convertedAt),
convertedAt,
);
}
/** 회원의 그 상품 링크 클릭 연결 — 전환 전 30일 안 (전환 뒤 클릭은 pickLastTouch 가 거른다) */
private memberTouches(
memberCode: string,
prodNo: number,
convertedAt: Date,
): Promise<PartnerTouch[]> {
return this.touches.find({
where: {
memberCode,
prodNo,
clickedAt: MoreThanOrEqual(
new Date(convertedAt.getTime() - ATTRIBUTION_WINDOW_MS),
),
},
});
}
/**
* 주문완료 화면이 보낸 클릭 ID 중 그 상품의 링크 클릭 — 전환 전 30일 안.
* 회원 연결(recordTouches)과 같이 실제로 있는 클릭만, 중지된 링크의 클릭은 뺀다.
*/
private async refTouches(
refs: readonly string[],
prodNo: number,
convertedAt: Date,
): Promise<TouchCandidate[]> {
const valid = [...new Set(refs)]
.filter((ref) => CLICK_REF_PATTERN.test(ref))
.slice(0, PARTNER_REFS_MAX);
if (valid.length === 0) return [];
const found = await this.clicks.find({
where: {
ref: In(valid),
clickedAt: MoreThanOrEqual(
new Date(convertedAt.getTime() - ATTRIBUTION_WINDOW_MS),
),
},
relations: { link: true },
});
return found
.filter((click) => click.link?.isActive && click.link.prodNo === prodNo)
.map((click) => ({ linkId: click.linkId, clickedAt: click.clickedAt }));
}
private async insertConversion(input: {
type: ConversionType;
touch: TouchCandidate;
memberCode: string | null;
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,429 @@
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 { PurchasesService } from '@/modules/purchases/purchases.service';
import {
CLICK_REF_LENGTH,
LINK_CODE_LENGTH,
LINK_CODE_PATTERN,
buildEntryUrl,
isBotUserAgent,
normalizeTargetUrl,
toBase62,
withPartnerRef,
withPartnerUtm,
} from '@/shared/utils/partner-link';
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
import {
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,
private readonly purchases: PurchasesService,
) {}
/** 링크 목록 — 최근 발급순, 기간 내 실적 포함 */
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)
.filter((code): code is string => !!code),
),
];
// 비회원 구매는 회원 정보가 없어 주문자 이름으로 보여준다
const guestOrderNos = items
.filter((row) => !row.memberCode && row.orderNo)
.map((row) => row.orderNo as string);
const [memberRows, groupTitles, ordererNames] = await Promise.all([
codes.length > 0
? this.members.find({ where: { memberCode: In(codes) } })
: Promise.resolve([]),
this.membersService.groupTitleMap(),
this.purchases.ordererNames(guestOrderNos),
]);
const membersByCode = new Map(memberRows.map((m) => [m.memberCode, m]));
return toPaginatedResult(
items.map((row) => {
const member = row.memberCode
? membersByCode.get(row.memberCode)
: undefined;
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,
ordererName:
!row.memberCode && row.orderNo
? (ordererNames.get(row.orderNo) ?? null)
: 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 },
relations: { partner: true },
})
: null;
if (!link) {
const home = this.imwebConfig.siteUrl;
if (!home) throw new BusinessException('RES_001', { context: { code } });
return home;
}
// 업체 UTM 은 클릭 기록 여부와 상관없이 붙인다 — 유입경로 기록용이고 업체 실적과는 별개다
const target = withPartnerUtm(
link.targetUrl,
link.partner?.name ?? '',
link.code,
);
if (!link.isActive || method !== 'GET' || isBotUserAgent(userAgent)) {
return target;
}
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 target;
}
return withPartnerRef(target, 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 siteUrl = this.imwebConfig.siteUrl;
const shortBase = this.shortLinkBase();
return rows.map((row) => ({
id: row.id,
code: row.code,
shortUrl: siteUrl
? buildEntryUrl(siteUrl, row.code)
: shortBase
? `${shortBase}/${SHORT_LINK_PATH}/${row.code}`
: '',
partner: {
id: row.partnerId,
name: partnerById.get(row.partnerId)?.name ?? '',
},
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.
* 업체에는 아임웹 /go 주소를 주고, 이 주소는 아임웹 사이트 주소를 모를 때만 대신 쓴다.
*/
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,120 @@
import type { SchedulerRegistry } from '@nestjs/schedule';
import type { ImwebConfig } from '@/modules/imweb/imweb.config';
import type { ImwebAuthService } from '@/modules/imweb/imweb-auth.service';
import type { ImwebOrderData } from '@/modules/imweb/imweb.types';
import type { OrderInflowsService } from '@/modules/purchases/order-inflows.service';
import type { PurchasesService } from '@/modules/purchases/purchases.service';
import type { PartnerAttributionService } from './partner-attribution.service';
import { PartnerOrderSyncService } from './partner-order-sync.service';
const DAY_MS = 86_400_000;
const NOW = new Date('2026-10-06T00:00:00.000Z');
const ORDER = { orderNo: 1, wtime: '2026-10-05T00:00:00Z' } as ImwebOrderData;
function setup(
linked: number[] = [],
refsByOrder = new Map<string, string[]>(),
) {
const get = jest.fn(() =>
Promise.resolve({ data: { list: [ORDER], totalPage: 1 } }),
);
const auth = {
requireConnection: jest.fn(() => Promise.resolve({ unitCode: 'u1' })),
authorizedGet: get,
};
const attribution = {
linkedProdNos: jest.fn(() => Promise.resolve(new Set(linked))),
attributeOrder: jest.fn(() => Promise.resolve({ created: 0, updated: 0 })),
};
const purchases = { recordOrders: jest.fn(() => Promise.resolve(1)) };
const orderInflows = {
partnerRefsByOrder: jest.fn(() => Promise.resolve(refsByOrder)),
prune: jest.fn(() => Promise.resolve(0)),
};
const service = new PartnerOrderSyncService(
attribution as unknown as PartnerAttributionService,
purchases as unknown as PurchasesService,
orderInflows as unknown as OrderInflowsService,
auth as unknown as ImwebAuthService,
{} as ImwebConfig,
{} as SchedulerRegistry,
);
/** n번째 아임웹 호출의 조회 시작 시각 */
const sinceOf = (call: number) => {
const params = (get.mock.calls[call] as unknown[])[1] as {
startWtime: string;
};
return new Date(params.startWtime).getTime();
};
return { service, get, attribution, purchases, orderInflows, sinceOf };
}
async function runOnce(service: PartnerOrderSyncService): Promise<void> {
service.start();
await service.waitForIdle();
}
describe('PartnerOrderSyncService', () => {
beforeEach(() => jest.useFakeTimers({ now: NOW, doNotFake: ['setTimeout'] }));
afterEach(() => jest.useRealTimers());
it('업체 링크가 없어도 주문을 훑어 구매 내역을 남긴다 (회원 여부로 거르지 않는다)', async () => {
const { service, get, purchases, attribution } = setup([]);
await runOnce(service);
expect(purchases.recordOrders).toHaveBeenCalledWith([ORDER]);
expect(attribution.attributeOrder).not.toHaveBeenCalled();
expect(get.mock.calls[0]).not.toHaveProperty('1.isMember');
});
it('업체 링크가 있으면 같은 주문으로 업체 실적도 반영한다', async () => {
const { service, attribution } = setup([52]);
await runOnce(service);
expect(attribution.attributeOrder).toHaveBeenCalledWith(
ORDER,
new Set([52]),
[],
);
});
it('주문완료 화면이 보낸 클릭 ID 를 그 주문의 업체 실적 판정에 넘긴다 (비회원 구매 귀속)', async () => {
const refs = ['AAAAAAAAAAAAAAAA'];
const { service, attribution, orderInflows } = setup(
[52],
new Map([['1', refs]]),
);
await runOnce(service);
expect(orderInflows.partnerRefsByOrder).toHaveBeenCalledWith(['1']);
expect(attribution.attributeOrder).toHaveBeenCalledWith(
ORDER,
new Set([52]),
refs,
);
});
it('끝까지 성공했을 때만 오래된 구매 때 유입을 정리한다', async () => {
const { service, get, orderInflows } = setup();
get.mockImplementationOnce(() => Promise.reject(new Error('down')));
await runOnce(service);
expect(orderInflows.prune).not.toHaveBeenCalled();
await runOnce(service);
expect(orderInflows.prune).toHaveBeenCalledTimes(1);
});
it('첫 실행은 1년치, 성공한 뒤부터는 60일치만 본다', async () => {
const { service, sinceOf } = setup();
await runOnce(service);
await runOnce(service);
expect(NOW.getTime() - sinceOf(0)).toBe(365 * DAY_MS);
expect(NOW.getTime() - sinceOf(1)).toBe(60 * DAY_MS);
});
it('1년치 조회가 실패하면 다음 실행에서 다시 1년치를 본다', async () => {
const { service, get, sinceOf } = setup();
get.mockImplementationOnce(() => Promise.reject(new Error('down')));
await runOnce(service);
await runOnce(service);
expect(service.getStatus().lastErrorCode).toBeNull();
expect(NOW.getTime() - sinceOf(1)).toBe(365 * DAY_MS);
});
});
@@ -0,0 +1,212 @@
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 { OrderInflowsService } from '@/modules/purchases/order-inflows.service';
import { PurchasesService } from '@/modules/purchases/purchases.service';
import { OrderSyncStatusDto } from './dto/partner-conversion.dto';
import { PartnerAttributionService } from './partner-attribution.service';
const SCHEDULE_NAME = 'partner-order-sync';
/**
* 이만큼 지난 주문까지 다시 본다 — 귀속 기간(30일)에 환불·취소가 늦게 반영되는 기간을 더했다.
* 그보다 오래된 구매의 환불은 반영되지 않는다.
*/
const ORDER_LOOKBACK_DAYS = 60;
/**
* 서버가 뜬 뒤 첫 실행은 1년치를 본다 — 구매 내역을 처음 채우고, 서버가 꺼져 있던 동안의 변경도 메운다.
* 끝까지 성공한 뒤부터는 ORDER_LOOKBACK_DAYS 만 본다.
*/
const PURCHASE_BACKFILL_DAYS = 365;
const DAY_MS = 86_400_000;
const ORDER_PAGE_SIZE = 100;
/** 페이지 사이 쉬는 시간 — 아임웹 호출 제한이 문서에 없어 보수적으로 둔다 */
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');
}
/**
* 주문 동기화 — 아임웹 주문을 주기적으로 훑어
* (1) 결제된 주문을 구매 내역(purchases)에 남기고 (회원·비회원 모두, 구매 때 유입도 함께 채운다)
* (2) 업체 링크가 있는 상품이면 업체 실적(구매)으로 잡는다. 둘 다 환불·취소를 반영한다.
*
* 아임웹은 주문 완료를 알려주지 않으므로(웹훅 없음) 회원 동기화와 같은 주기로 최근 주문을 다시 본다.
*/
@Injectable()
export class PartnerOrderSyncService
implements OnApplicationBootstrap, OnApplicationShutdown
{
private readonly logger = new Logger(PartnerOrderSyncService.name);
/** 진행 중인 실행 — 동시에 두 번 돌지 않게 하고, 종료 시 기다린다 */
private running: Promise<void> | null = null;
/** 1년치를 한 번 끝까지 봤는가 — 중간에 실패하면 다음 실행에서 다시 1년치를 본다 */
private isBackfilled = false;
private status: OrderSyncStatusDto = {
isRunning: false,
lastStartedAt: null,
lastFinishedAt: null,
lastErrorCode: null,
checkedOrderCount: 0,
createdCount: 0,
};
constructor(
private readonly attribution: PartnerAttributionService,
private readonly purchases: PurchasesService,
private readonly orderInflows: OrderInflowsService,
private readonly auth: ImwebAuthService,
private readonly config: ImwebConfig,
private readonly scheduler: SchedulerRegistry,
) {}
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;
let purchases = 0;
const lookbackDays = this.isBackfilled
? ORDER_LOOKBACK_DAYS
: PURCHASE_BACKFILL_DAYS;
try {
const linkedProdNos = await this.attribution.linkedProdNos();
const { unitCode } = await this.auth.requireConnection();
const since = new Date(Date.now() - lookbackDays * DAY_MS);
for (let page = 1; page <= MAX_PAGES; page += 1) {
if (page > 1) await sleep(PAGE_DELAY_MS);
// 비회원 주문도 구매 내역에 남기므로 회원 여부로 거르지 않는다
const { data } = await this.auth.authorizedGet<ImwebOrderPageData>(
IMWEB_ORDERS_PATH,
{
page,
limit: ORDER_PAGE_SIZE,
unitCode,
startWtime: toImwebTime(since),
},
);
purchases += await this.purchases.recordOrders(data.list);
// 업체 실적은 업체 링크가 있는 상품만.
// 비회원 주문은 주문완료 화면이 보낸 클릭 ID 가 있을 때만 잡힌다
if (linkedProdNos.size > 0) {
const refsByOrder = await this.orderInflows.partnerRefsByOrder(
data.list.map((order) => String(order.orderNo)),
);
for (const order of data.list) {
const result = await this.attribution.attributeOrder(
order,
linkedProdNos,
refsByOrder.get(String(order.orderNo)) ?? [],
);
created += result.created;
updated += result.updated;
}
}
checked += data.list.length;
if (data.list.length === 0 || page >= data.totalPage) break;
}
this.isBackfilled = true;
// 주문과 이어지지 않은 채 오래된 구매 때 유입을 지운다 (동기화가 끝까지 성공했을 때만)
await this.orderInflows.prune();
this.status = {
...this.status,
lastErrorCode: null,
checkedOrderCount: checked,
createdCount: created,
};
this.logger.log(
`주문 동기화 완료: days=${lookbackDays} orders=${checked} purchases=${purchases} partnerCreated=${created} partnerUpdated=${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,87 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentAccount } from '@/common/decorators/current-admin.decorator';
import { PaginatedResult } from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { PartnerAuthGuard } from '@/common/guards/admin-auth.guard';
import { ADMIN_BEARER_AUTH } from '@/modules/admin-auth/admin-auth.constants';
import type { VerifiedAccount } from '@/modules/admin-auth/admin-auth.service';
import { PartnerLinkResponseDto } from './dto/partner-link.dto';
import {
PortalConversionDto,
PortalConversionQueryDto,
PortalPeriodQueryDto,
PortalSummaryDto,
} from './dto/partner-portal.dto';
import { PartnerPeriodQueryDto } from './dto/partner.dto';
import { PartnerPortalService } from './partner-portal.service';
/** 가드가 업체 계정만 통과시키지만, 업체 ID 가 없으면 아무것도 보여주지 않는다 */
function partnerIdOf(account: VerifiedAccount): string {
if (!account.partnerId) {
throw new BusinessException('AUTH_003', {
context: { reason: 'no_partner' },
});
}
return account.partnerId;
}
/** 업체 화면 — 업체 계정 전용. 업체 ID 는 로그인한 계정에서만 가져온다 */
@ApiTags('Partner Portal')
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@UseGuards(PartnerAuthGuard)
@Controller('partner-portal')
export class PartnerPortalController {
constructor(private readonly portal: PartnerPortalService) {}
@Get('summary')
@ApiOperation({ summary: '내 업체 요약 — 링크 수·기간 실적' })
@ApiStandardResponse(PortalSummaryDto, { description: '조회 성공' })
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('AUTH_003', '업체 계정이 아님')
@ApiErrorResponse('ACCOUNT_003', '임시 비밀번호 변경 필요')
summary(
@CurrentAccount() account: VerifiedAccount,
@Query() query: PortalPeriodQueryDto,
): Promise<PortalSummaryDto> {
return this.portal.summary(partnerIdOf(account), query);
}
@Get('links')
@ApiOperation({ summary: '내 업체 링크 — 업체에 줄 링크와 기간 실적' })
@ApiStandardResponse(PartnerLinkResponseDto, {
description: '조회 성공 — data 는 { items, pageInfo }',
isArray: true,
})
@ApiErrorResponse('AUTH_003', '업체 계정이 아님')
@ApiErrorResponse('ACCOUNT_003', '임시 비밀번호 변경 필요')
findLinks(
@CurrentAccount() account: VerifiedAccount,
@Query() query: PartnerPeriodQueryDto,
): Promise<PaginatedResult<PartnerLinkResponseDto>> {
return this.portal.findLinks(partnerIdOf(account), query);
}
@Get('conversions')
@ApiOperation({
summary: '내 업체 신청·구매 — 최근순, 이름은 가려서 (연락처 없음)',
})
@ApiStandardResponse(PortalConversionDto, {
description: '조회 성공 — data 는 { items, pageInfo }',
isArray: true,
})
@ApiErrorResponse('AUTH_003', '업체 계정이 아님')
@ApiErrorResponse('ACCOUNT_003', '임시 비밀번호 변경 필요')
@ApiErrorResponse('VAL_001', '필터·커서 형식 오류')
findConversions(
@CurrentAccount() account: VerifiedAccount,
@Query() query: PortalConversionQueryDto,
): Promise<PaginatedResult<PortalConversionDto>> {
return this.portal.findConversions(partnerIdOf(account), query);
}
}
@@ -0,0 +1,134 @@
import type { Repository } from 'typeorm';
import type { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import type { ImwebProduct } from '@/modules/imweb/entities/imweb-product.entity';
import type { PurchasesService } from '@/modules/purchases/purchases.service';
import {
ConversionType,
PartnerConversion,
} from './entities/partner-conversion.entity';
import type { PartnerLinksService } from './partner-links.service';
import { PartnerPortalService } from './partner-portal.service';
import type { PartnerStatsService } from './partner-stats.service';
import type { PartnersService } from './partners.service';
const PARTNER_ID = 'partner-1';
function conversion(overrides: Partial<PartnerConversion>): PartnerConversion {
return {
id: 'c-1',
type: ConversionType.APPLICATION,
linkId: 'link-1',
partnerId: PARTNER_ID,
prodNo: 52,
memberCode: 'm-1',
applicationId: 'app-1',
orderNo: null,
isValid: true,
touchedAt: new Date('2026-10-01T00:00:00Z'),
convertedAt: new Date('2026-10-02T00:00:00Z'),
updatedAt: new Date(),
...overrides,
};
}
/** 조회 쿼리 빌더 흉내 — where 조건을 기록하고 정해 둔 행을 돌려준다 */
interface QueryMock {
where: jest.Mock<QueryMock, unknown[]>;
andWhere: jest.Mock<QueryMock, unknown[]>;
orderBy: jest.Mock<QueryMock, unknown[]>;
addOrderBy: jest.Mock<QueryMock, unknown[]>;
take: jest.Mock<QueryMock, unknown[]>;
clone: jest.Mock<QueryMock, []>;
getCount: jest.Mock<Promise<number>, []>;
getMany: jest.Mock<Promise<PartnerConversion[]>, []>;
}
function setup(rows: PartnerConversion[]) {
const query: QueryMock = {
where: jest.fn(() => query),
andWhere: jest.fn(() => query),
orderBy: jest.fn(() => query),
addOrderBy: jest.fn(() => query),
take: jest.fn(() => query),
clone: jest.fn(() => query),
getCount: jest.fn(() => Promise.resolve(rows.length)),
getMany: jest.fn(() => Promise.resolve(rows)),
};
const conversions = { createQueryBuilder: jest.fn(() => query) };
const members = {
find: jest.fn(() =>
Promise.resolve([
{
memberCode: 'm-1',
name: '홍길동',
callnum: '010-1234-5678',
email: 'hong@test.dev',
},
]),
),
};
const products = {
find: jest.fn(() => Promise.resolve([{ prodNo: 52, name: 'AI 강의' }])),
};
const purchases = {
ordererNames: jest.fn(() =>
Promise.resolve(new Map([['202610060001', '김비회']])),
),
};
const service = new PartnerPortalService(
conversions as unknown as Repository<PartnerConversion>,
members as unknown as Repository<ImwebMember>,
products as unknown as Repository<ImwebProduct>,
{} as PartnersService,
{} as PartnerLinksService,
{} as PartnerStatsService,
purchases as unknown as PurchasesService,
);
return { service, query };
}
describe('PartnerPortalService.findConversions', () => {
it('로그인한 업체의 실적만 조회한다', async () => {
const { service, query } = setup([]);
await service.findConversions(PARTNER_ID, { limit: 20 });
expect(query.where).toHaveBeenCalledWith(
expect.stringContaining('partnerId'),
{ partnerId: PARTNER_ID },
);
});
it('신청·구매자는 가린 이름만 — 연락처·이메일·회원 코드·주문번호는 내려주지 않는다', async () => {
const { service } = setup([
conversion({}),
conversion({
id: 'c-2',
type: ConversionType.PURCHASE,
memberCode: null,
applicationId: null,
orderNo: '202610060001',
}),
]);
const page = await service.findConversions(PARTNER_ID, { limit: 20 });
expect(page.items).toEqual([
expect.objectContaining({
maskedName: '홍*동',
isMember: true,
product: { prodNo: 52, name: 'AI 강의' },
}),
expect.objectContaining({ maskedName: '김*회', isMember: false }),
]);
const body = JSON.stringify(page);
for (const secret of [
'010-1234-5678',
'hong@test.dev',
'm-1',
'202610060001',
'홍길동',
]) {
expect(body).not.toContain(secret);
}
});
});
@@ -0,0 +1,211 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
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 { PurchasesService } from '@/modules/purchases/purchases.service';
import { maskName } from '@/shared/utils/mask-name';
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
import { PartnerLinkResponseDto } from './dto/partner-link.dto';
import {
PortalConversionDto,
PortalConversionQueryDto,
PortalPeriodQueryDto,
PortalSummaryDto,
} from './dto/partner-portal.dto';
import { PartnerPeriodQueryDto } from './dto/partner.dto';
import { PartnerConversion } from './entities/partner-conversion.entity';
import { PartnerLinksService } from './partner-links.service';
import { PartnerStatsService } from './partner-stats.service';
import { PartnersService } from './partners.service';
const ALIAS = 'v';
/** 커서의 고유값은 실적 ID(UUID)다 — 형식이 다르면 DB 오류 대신 VAL_001 */
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* 업체 화면 — 업체 계정이 자기 업체의 링크·실적을 본다 (PartnerAuthGuard).
*
* 업체 ID 는 항상 로그인한 계정에서 받는다 — 요청값으로 다른 업체를 볼 수 없다.
* 신청·구매자는 가린 이름만 보여주고 연락처·이메일·아이디·주문번호는 내려주지 않는다.
*/
@Injectable()
export class PartnerPortalService {
constructor(
@InjectRepository(PartnerConversion)
private readonly conversions: Repository<PartnerConversion>,
@InjectRepository(ImwebMember)
private readonly members: Repository<ImwebMember>,
@InjectRepository(ImwebProduct)
private readonly products: Repository<ImwebProduct>,
private readonly partners: PartnersService,
private readonly links: PartnerLinksService,
private readonly stats: PartnerStatsService,
private readonly purchases: PurchasesService,
) {}
async summary(
partnerId: string,
query: PortalPeriodQueryDto,
): Promise<PortalSummaryDto> {
const partner = await this.partners.require(partnerId);
const [statsById, linkCounts] = await Promise.all([
this.stats.byPartners([partnerId], query),
this.stats.linkCounts([partnerId]),
]);
return {
partnerId,
partnerName: partner.name,
linkCount: linkCounts.get(partnerId) ?? 0,
stats: statsById.get(partnerId) ?? {
clickCount: 0,
applicationCount: 0,
purchaseCount: 0,
},
};
}
/** 자기 업체의 링크 — 관리자 화면과 같은 응답 (업체에 줄 링크·기간 실적) */
findLinks(
partnerId: string,
query: PartnerPeriodQueryDto,
): Promise<PaginatedResult<PartnerLinkResponseDto>> {
return this.links.findMany({
cursor: query.cursor,
limit: query.limit,
from: query.from,
to: query.to,
partnerId,
});
}
/** 자기 업체 실적 목록 — 최근순, 가린 이름만 */
async findConversions(
partnerId: string,
query: PortalConversionQueryDto,
): Promise<PaginatedResult<PortalConversionDto>> {
const base = this.conversions
.createQueryBuilder(ALIAS)
.where(`${ALIAS}.partnerId = :partnerId`, { partnerId });
if (query.type) {
base.andWhere(`${ALIAS}.type = :type`, { type: query.type });
}
if (query.from) {
base.andWhere(`${ALIAS}.convertedAt >= :from`, { from: query.from });
}
if (query.to) {
base.andWhere(`${ALIAS}.convertedAt < :to`, { to: query.to });
}
const total = await base.clone().getCount();
const page = base
.clone()
.orderBy(`${ALIAS}.convertedAt`, 'DESC')
.addOrderBy(`${ALIAS}.id`, 'DESC')
.take(query.limit + 1);
if (query.cursor) {
const cursor = decodeTimeCursor(query.cursor);
if (!cursor || !UUID_PATTERN.test(cursor.key)) {
throw new BusinessException('VAL_001', {
context: { reason: 'invalid_cursor' },
});
}
page.andWhere(
`(${ALIAS}.convertedAt, ${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 names = await this.namesOf(items);
const productNames = await this.productNamesOf(items);
return toPaginatedResult(
items.map(
(row): PortalConversionDto => ({
id: row.id,
type: row.type,
convertedAt: row.convertedAt.toISOString(),
product: {
prodNo: row.prodNo,
name: productNames.get(row.prodNo) ?? null,
},
maskedName: maskName(names.get(row.id)),
isMember: !!row.memberCode,
isValid: row.isValid,
}),
),
{
hasNext,
nextCursor:
hasNext && last
? encodeTimeCursor({
time: last.convertedAt.toISOString(),
key: last.id,
})
: null,
total,
},
);
}
/** 실적 ID → 이름 (가리기 전) — 회원은 회원 이름, 비회원 구매는 주문자 이름 */
private async namesOf(
rows: PartnerConversion[],
): Promise<Map<string, string>> {
const memberCodes = [
...new Set(
rows
.map((row) => row.memberCode)
.filter((code): code is string => !!code),
),
];
const guestOrderNos = rows
.filter((row) => !row.memberCode && row.orderNo)
.map((row) => row.orderNo as string);
const [memberRows, ordererNames] = await Promise.all([
memberCodes.length > 0
? this.members.find({
select: { memberCode: true, name: true },
where: { memberCode: In(memberCodes) },
})
: Promise.resolve([]),
this.purchases.ordererNames(guestOrderNos),
]);
const memberNames = new Map(memberRows.map((m) => [m.memberCode, m.name]));
const names = new Map<string, string>();
for (const row of rows) {
const name = row.memberCode
? memberNames.get(row.memberCode)
: row.orderNo
? ordererNames.get(row.orderNo)
: undefined;
if (name) names.set(row.id, name);
}
return names;
}
private async productNamesOf(
rows: PartnerConversion[],
): Promise<Map<number, string>> {
const prodNos = [...new Set(rows.map((row) => row.prodNo))];
if (prodNos.length === 0) return new Map();
const products = await this.products.find({
select: { prodNo: true, name: true },
where: { prodNo: In(prodNos) },
});
return new Map(products.map((p) => [p.prodNo, p.name]));
}
}
@@ -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,73 @@
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 { PurchasesModule } from '@/modules/purchases/purchases.module';
import { PartnerConversion } from './entities/partner-conversion.entity';
import { PartnerLinkClick } from './entities/partner-link-click.entity';
import { PartnerLink } from './entities/partner-link.entity';
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 { PartnerPortalController } from './partner-portal.controller';
import { PartnerPortalService } from './partner-portal.service';
import {
PartnerRedirectController,
PartnerTouchesController,
} from './partner-public.controller';
import { PartnerStatsService } from './partner-stats.service';
import { PartnersController } from './partners.controller';
import { PartnersService } from './partners.service';
/**
* 업체 링크 — 업체·상품별 단축 링크를 발급하고, 그 링크로 들어온 회원의 신청·구매를 센다.
* 업체 계정은 업체 화면(PartnerPortalController)에서 자기 업체의 링크·실적만 본다.
* 신청은 ApplicationsModule 이 PartnerAttributionService 를 불러 귀속하고,
* 구매는 PartnerOrderSyncService 가 아임웹 주문을 주기적으로 훑어 찾는다
* (같은 동기화가 업체와 무관한 구매 내역도 PurchasesModule 에 남긴다).
*/
@Module({
imports: [
TypeOrmModule.forFeature([
Partner,
PartnerLink,
PartnerLinkClick,
PartnerTouch,
PartnerConversion,
ImwebProduct,
ImwebMember,
]),
AdminAuthModule,
ImwebModule,
MembersModule,
PurchasesModule,
],
controllers: [
PartnersController,
PartnerLinksController,
PartnerOrderSyncController,
PartnerRedirectController,
PartnerTouchesController,
PartnerPortalController,
],
providers: [
PartnerPortalService,
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;
}
}
}
@@ -0,0 +1,54 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMaxSize,
IsArray,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { PARTNER_REFS_MAX } from '@/modules/partners/dto/partner-touch.dto';
import { CLICK_REF_PATTERN } from '@/shared/utils/partner-link';
/** 아임웹 주문번호 — 숫자만 (예: 202610064563182). 저장 칸(30자)을 넘지 않게 */
export const ORDER_NO_PATTERN = /^\d{1,30}$/;
/** imweb-header-top.html 이 만드는 값은 200자 남짓 — 넉넉히 두되 무한정 받지 않는다 */
const INFLOW_RAW_MAX_LENGTH = 1000;
/** 주문완료 화면(imweb-header-top.html)이 보내는 구매 때 유입 */
export class CreateOrderInflowDto {
@ApiProperty({
description: '아임웹 주문번호 (주문완료 주소의 order_no)',
example: '202610064563182',
})
@IsString()
@Matches(ORDER_NO_PATTERN, { message: '주문번호 형식이 올바르지 않습니다.' })
orderNo!: string;
@ApiPropertyOptional({
description:
'주문 순간의 유입정보 (imweb-header-top.html 의 MG_UTM.peek().value, 파이프 형식)',
example: 'src=facebook|med=paid|cmp=fall|ts=2026-10-05',
})
@IsOptional()
@IsString()
@MaxLength(INFLOW_RAW_MAX_LENGTH)
inflow?: string;
@ApiPropertyOptional({
description:
'브라우저가 보관한 업체 링크 클릭 ID 목록 (imweb-header-top.html 의 MG_PARTNER.refs())',
example: ['AbCd1234EfGh5678'],
type: [String],
})
@IsOptional()
@IsArray()
@ArrayMaxSize(PARTNER_REFS_MAX)
@IsString({ each: true })
@Matches(CLICK_REF_PATTERN, {
each: true,
message: '클릭 정보 형식이 올바르지 않습니다.',
})
partnerRefs?: string[];
}
@@ -0,0 +1,68 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow';
/**
* 주문완료 화면이 보낸 구매 때 유입 — 주문번호당 한 줄 (먼저 온 값만 남긴다).
*
* 아임웹 주문 API 에는 손님이 어디서 왔는지가 없어서, 주문완료 화면(imweb-header-top.html)이
* 그 순간 브라우저에 남아 있던 유입과 업체 링크 클릭 ID 를 보낸다.
* 공개 페이지에서 오는 값이라 받을 때는 확인하지 않고, 주문 동기화가 아임웹에 실제로 있는
* 주문번호와 이을 때만 쓴다 (없는 주문번호는 쓰이지 않고 보관 기간이 지나면 지워진다).
*/
@Entity({ name: 'order_inflows' })
@Index('idx_order_inflows_received', ['receivedAt'])
export class OrderInflow {
/** 아임웹 주문번호 (주문완료 주소의 order_no) */
@PrimaryColumn({ name: 'order_no', type: 'varchar', length: 30 })
orderNo!: string;
/** 유입정보 원본 (파이프 형식) — 형식이 아니면 null */
@Column({ name: 'inflow_raw', type: 'text', nullable: true })
inflowRaw!: string | null;
@Column({
name: 'inflow_source',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowSource!: string | null;
@Column({
name: 'inflow_medium',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowMedium!: string | null;
@Column({
name: 'inflow_campaign',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowCampaign!: string | null;
@Column({
name: 'inflow_content',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowContent!: string | null;
/** 브라우저가 보관한 업체 링크 클릭 ID (MG_PARTNER.refs()) — 비회원 구매도 업체 실적으로 잇는다 */
@Column({
name: 'partner_refs',
type: 'varchar',
length: 16,
array: true,
default: () => "'{}'",
})
partnerRefs!: string[];
@Column({ name: 'received_at', type: 'timestamptz' })
receivedAt!: Date;
}
@@ -0,0 +1,31 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { ApiErrorResponse } from '@/common/decorators/api-standard-response.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { CreateOrderInflowDto } from './dto/create-order-inflow.dto';
import { OrderInflowsService } from './order-inflows.service';
/**
* 구매 때 유입 — 아임웹 주문완료 화면(공개 페이지)의 Header Code 가 부른다.
* 로그인 없이 열려 있으므로 요청 수를 엄격히 제한한다 (신청 기록과 같다).
*/
@ApiTags('Order Inflows')
@Controller('order-inflows')
export class OrderInflowsController {
constructor(private readonly orderInflows: OrderInflowsService) {}
@Post()
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform()
@Throttle({ default: { ttl: 60_000, limit: 10 } })
@ApiOperation({
summary: '구매 때 유입 기록 — 항상 204 (같은 주문번호는 처음 값만 남는다)',
})
@ApiErrorResponse('VAL_001', '입력값 오류')
@ApiErrorResponse('REQ_001', '요청 수 초과 (1분 10회)')
async create(@Body() dto: CreateOrderInflowDto): Promise<void> {
await this.orderInflows.record(dto);
}
}
@@ -0,0 +1,108 @@
import type { Repository } from 'typeorm';
import type { ImwebConfig } from '@/modules/imweb/imweb.config';
import type { OrderInflow } from './order-inflow.entity';
import {
ORDER_INFLOW_RETENTION_DAYS,
OrderInflowsService,
} from './order-inflows.service';
const DAY_MS = 86_400_000;
/** insert 쿼리 빌더 흉내 — 메서드가 자기 자신을 돌려준다 */
interface InsertQueryMock {
insert: jest.Mock<InsertQueryMock, []>;
values: jest.Mock<InsertQueryMock, [OrderInflow]>;
orIgnore: jest.Mock<InsertQueryMock, []>;
execute: jest.Mock<Promise<void>, []>;
}
function setup() {
const inserted: OrderInflow[] = [];
const builder: InsertQueryMock = {
insert: jest.fn(() => builder),
values: jest.fn((row: OrderInflow) => {
inserted.push(row);
return builder;
}),
orIgnore: jest.fn(() => builder),
execute: jest.fn(() => Promise.resolve()),
};
const repo = {
create: jest.fn((row: OrderInflow) => row),
createQueryBuilder: jest.fn(() => builder),
find: jest.fn(() => Promise.resolve([] as Partial<OrderInflow>[])),
delete: jest.fn(() => Promise.resolve({ affected: 0 })),
};
const service = new OrderInflowsService(
repo as unknown as Repository<OrderInflow>,
{ trackingHost: 'utm.moneyguider.co.kr' } as ImwebConfig,
);
return { service, repo, builder, inserted };
}
describe('OrderInflowsService.record', () => {
it('유입정보를 풀어 주문번호와 함께 남긴다 (같은 주문번호는 처음 값만)', async () => {
const { service, builder, inserted } = setup();
await service.record({
orderNo: '202610064563182',
inflow: 'src=업체A|med=partner|cmp=CODE1234|lp=/free-shop/',
partnerRefs: ['AAAAAAAAAAAAAAAA', 'AAAAAAAAAAAAAAAA'],
});
expect(builder.orIgnore).toHaveBeenCalled();
expect(inserted[0]).toMatchObject({
orderNo: '202610064563182',
inflowRaw: 'src=업체A|med=partner|cmp=CODE1234|lp=/free-shop/',
inflowSource: '업체A',
inflowMedium: 'partner',
inflowCampaign: 'CODE1234',
partnerRefs: ['AAAAAAAAAAAAAAAA'],
});
});
it('유입정보가 없거나 형식이 아니면 유입 칸은 비우고 클릭 ID 만 남긴다', async () => {
const { service, inserted } = setup();
await service.record({
orderNo: '202610064563182',
inflow: 'hello',
partnerRefs: ['AAAAAAAAAAAAAAAA'],
});
expect(inserted[0]).toMatchObject({
inflowRaw: null,
inflowSource: null,
partnerRefs: ['AAAAAAAAAAAAAAAA'],
});
});
});
describe('OrderInflowsService.partnerRefsByOrder', () => {
it('클릭 ID 가 있는 주문만 돌려준다', async () => {
const { service, repo } = setup();
repo.find.mockResolvedValueOnce([
{ orderNo: '1', partnerRefs: ['AAAAAAAAAAAAAAAA'] },
{ orderNo: '2', partnerRefs: [] },
]);
const refs = await service.partnerRefsByOrder(['1', '2', '1']);
expect(refs).toEqual(new Map([['1', ['AAAAAAAAAAAAAAAA']]]));
});
it('주문번호가 없으면 DB 를 보지 않는다', async () => {
const { service, repo } = setup();
await expect(service.partnerRefsByOrder([])).resolves.toEqual(new Map());
expect(repo.find).not.toHaveBeenCalled();
});
});
describe('OrderInflowsService.prune', () => {
it('보관 기간이 지난 값만 지운다', async () => {
const { service, repo } = setup();
const now = new Date('2026-10-06T00:00:00.000Z');
await service.prune(now);
const [where] = repo.delete.mock.calls[0] as unknown as [
{ receivedAt: { value: Date } },
];
expect(now.getTime() - where.receivedAt.value.getTime()).toBe(
ORDER_INFLOW_RETENTION_DAYS * DAY_MS,
);
});
});
@@ -0,0 +1,89 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, LessThan, Repository } from 'typeorm';
import { ImwebConfig } from '@/modules/imweb/imweb.config';
import { parseInflow } from '@/shared/utils/inflow';
import { CreateOrderInflowDto } from './dto/create-order-inflow.dto';
import { OrderInflow } from './order-inflow.entity';
/**
* 이만큼 지나도록 아임웹 주문과 이어지지 않으면 지운다 — 없는 주문번호이거나 끝내 결제되지 않은 주문이다.
* 무통장입금은 입금 확인 후에야 구매로 잡히므로 입금 기한보다 넉넉히 둔다.
*/
export const ORDER_INFLOW_RETENTION_DAYS = 90;
const DAY_MS = 86_400_000;
/**
* 구매 때 유입 수집 — 주문완료 화면이 보낸 값을 주문번호당 한 줄로 받아 둔다.
*
* 공개 페이지에서 오는 값이라 정말 그 주문인지는 확인할 수 없다. 그래서
* (1) 먼저 온 값만 남기고 (2) 주문 동기화가 아임웹에 실제로 있는 주문과 이을 때만 쓰며
* (3) 이어지지 않은 값은 보관 기간이 지나면 지운다.
*/
@Injectable()
export class OrderInflowsService {
private readonly logger = new Logger(OrderInflowsService.name);
constructor(
@InjectRepository(OrderInflow)
private readonly inflows: Repository<OrderInflow>,
private readonly config: ImwebConfig,
) {}
/** 같은 주문번호가 다시 오면 처음 값을 그대로 둔다 (새로고침·중복 전송에도 안전) */
async record(dto: CreateOrderInflowDto): Promise<void> {
const receivedAt = new Date();
const inflow = parseInflow(
dto.inflow,
receivedAt.toISOString(),
this.config.trackingHost,
);
await this.inflows
.createQueryBuilder()
.insert()
.values(
this.inflows.create({
orderNo: dto.orderNo,
inflowRaw: inflow.source ? (dto.inflow?.trim() ?? null) : null,
inflowSource: inflow.source,
inflowMedium: inflow.medium,
inflowCampaign: inflow.campaign,
inflowContent: inflow.content,
partnerRefs: [...new Set(dto.partnerRefs ?? [])],
receivedAt,
}),
)
.orIgnore()
.execute();
}
/** 주문번호 → 주문완료 화면이 보낸 업체 링크 클릭 ID (보낸 적 없거나 비었으면 빠진다) */
async partnerRefsByOrder(
orderNos: readonly string[],
): Promise<Map<string, string[]>> {
const unique = [...new Set(orderNos)];
if (unique.length === 0) return new Map();
const rows = await this.inflows.find({
select: { orderNo: true, partnerRefs: true },
where: { orderNo: In(unique) },
});
return new Map(
rows
.filter((row) => row.partnerRefs.length > 0)
.map((row) => [row.orderNo, row.partnerRefs]),
);
}
/** 보관 기간이 지난 값을 지운다 — 주문 동기화가 끝날 때 부른다 */
async prune(now: Date = new Date()): Promise<number> {
const result = await this.inflows.delete({
receivedAt: LessThan(
new Date(now.getTime() - ORDER_INFLOW_RETENTION_DAYS * DAY_MS),
),
});
const deleted = result.affected ?? 0;
if (deleted > 0) this.logger.log(`구매 때 유입 정리: ${deleted}건`);
return deleted;
}
}
@@ -0,0 +1,128 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryColumn,
} from 'typeorm';
import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow';
/**
* 유료 구매 — 아임웹 주문을 주기적으로 훑어 결제된 주문의 상품을 남긴다 (주문·상품당 한 줄).
*
* 회원·비회원 주문을 모두 남긴다. 비회원은 회원 코드가 없어 주문자 정보로만 누군지 안다.
* 나중에 환불·취소되면 지우지 않고 isValid=false 로 바꾼다.
* 금액은 관리하지 않는다.
*/
@Entity({ name: 'purchases' })
@Index('uq_purchases_order_product', ['orderNo', 'prodNo'], { unique: true })
@Index('idx_purchases_cursor', ['orderedAt', 'id'])
@Index('idx_purchases_member', ['memberCode'])
export class Purchase {
/** 앱에서 crypto.randomUUID() 로 만든다 */
@PrimaryColumn({ name: 'id', type: 'uuid' })
id!: string;
/** 아임웹 주문번호 */
@Column({ name: 'order_no', type: 'varchar', length: 30 })
orderNo!: string;
@Column({ name: 'prod_no', type: 'int' })
prodNo!: number;
/** 주문 당시 상품명 */
@Column({
name: 'product_name',
type: 'varchar',
length: 200,
nullable: true,
})
productName!: string | null;
/** 아임웹 회원 코드 — 비회원 주문이면 null */
@Column({ name: 'member_code', type: 'varchar', length: 40, nullable: true })
memberCode!: string | null;
/** 주문 당시 아이디 — 비회원 주문이면 null */
@Column({ name: 'member_uid', type: 'varchar', length: 255, nullable: true })
memberUid!: string | null;
@Column({
name: 'orderer_name',
type: 'varchar',
length: 100,
nullable: true,
})
ordererName!: string | null;
@Column({
name: 'orderer_email',
type: 'varchar',
length: 255,
nullable: true,
})
ordererEmail!: string | null;
@Column({
name: 'orderer_call',
type: 'varchar',
length: 40,
nullable: true,
})
ordererCall!: string | null;
/** 주문 시각 (아임웹 wtime) */
@Column({ name: 'ordered_at', type: 'timestamptz' })
orderedAt!: Date;
/** 지금 구매로 세는가 — 환불·취소·반품되면 false */
@Column({ name: 'is_valid', type: 'boolean', default: true })
isValid!: boolean;
/*
* 구매 때 유입 — 주문완료 화면이 보낸 유입(order_inflows)을 주문 동기화가 주문번호로 이어 채운다.
* 주문완료 화면이 보내지 못한 주문(다른 기기·스크립트 차단·도입 전 주문)은 null 이다.
*/
/** 유입정보 원본 (imweb-header-top.html 의 MG_UTM.peek().value, 파이프 형식) */
@Column({ name: 'inflow_raw', type: 'text', nullable: true })
inflowRaw!: string | null;
@Index()
@Column({
name: 'inflow_source',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowSource!: string | null;
@Column({
name: 'inflow_medium',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowMedium!: string | null;
@Column({
name: 'inflow_campaign',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowCampaign!: string | null;
@Column({
name: 'inflow_content',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowContent!: string | null;
/** 처음 잡힌 시각 */
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
}
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ImwebModule } from '@/modules/imweb/imweb.module';
import { OrderInflow } from './order-inflow.entity';
import { OrderInflowsController } from './order-inflows.controller';
import { OrderInflowsService } from './order-inflows.service';
import { Purchase } from './purchase.entity';
import { PurchasesService } from './purchases.service';
/**
* 유료 구매 기록 — 아임웹 주문 동기화(PartnersModule 의 PartnerOrderSyncService)가 채우고,
* 관리자 신청·구매 내역(ActivitiesModule)이 읽는다.
* 구매 때 유입은 주문완료 화면이 보낸 값(order_inflows)을 동기화가 주문번호로 이어 채운다.
*/
@Module({
imports: [TypeOrmModule.forFeature([Purchase, OrderInflow]), ImwebModule],
controllers: [OrderInflowsController],
providers: [PurchasesService, OrderInflowsService],
exports: [PurchasesService, OrderInflowsService],
})
export class PurchasesModule {}
@@ -0,0 +1,137 @@
import type { Repository } from 'typeorm';
import type { ImwebOrderData } from '@/modules/imweb/imweb.types';
import type { Purchase } from './purchase.entity';
import { PurchasesService } from './purchases.service';
const PAID = [{ paymentStatus: 'PAYMENT_COMPLETE', isCancel: 'N' }];
function order(overrides: Partial<ImwebOrderData>): ImwebOrderData {
return {
orderNo: 202610050001,
isMember: 'Y',
memberCode: 'm20261005abcdef',
memberUid: 'user@test.dev',
ordererName: '홍길동',
ordererEmail: 'user@test.dev',
ordererCall: '010-1234-5678',
wtime: '2026-10-05T03:00:00.000Z',
payments: PAID,
sections: [
{ sectionItems: [{ productInfo: { prodNo: 52, prodName: 'AI 강의' } }] },
],
...overrides,
};
}
/** insert 쿼리 빌더 흉내 — 메서드가 자기 자신을 돌려준다 */
interface InsertQueryMock {
insert: jest.Mock<InsertQueryMock, []>;
values: jest.Mock<InsertQueryMock, [Purchase[]]>;
orUpdate: jest.Mock<InsertQueryMock, unknown[]>;
execute: jest.Mock<Promise<void>, []>;
}
function setup() {
const inserted: Purchase[][] = [];
const query: InsertQueryMock = {
insert: jest.fn(() => query),
values: jest.fn((rows: Purchase[]) => {
inserted.push(rows);
return query;
}),
orUpdate: jest.fn(() => query),
execute: jest.fn(() => Promise.resolve()),
};
const repo = {
create: jest.fn((row: Purchase) => row),
createQueryBuilder: jest.fn(() => query),
// 구매 때 유입 채우기 (UPDATE ... FROM order_inflows)
query: jest.fn(() => Promise.resolve()),
};
const service = new PurchasesService(repo as unknown as Repository<Purchase>);
return { service, inserted, query, repo };
}
describe('PurchasesService.recordOrders', () => {
it('결제된 회원 주문을 주문·상품 단위로 남긴다', async () => {
const { service, inserted } = setup();
await expect(service.recordOrders([order({})])).resolves.toBe(1);
expect(inserted[0][0]).toMatchObject({
orderNo: '202610050001',
prodNo: 52,
productName: 'AI 강의',
memberCode: 'm20261005abcdef',
memberUid: 'user@test.dev',
ordererName: '홍길동',
isValid: true,
});
});
it('비회원 주문은 회원 정보 없이 주문자 정보로 남긴다', async () => {
const { service, inserted } = setup();
await service.recordOrders([
order({ isMember: 'N', memberCode: '', memberUid: '' }),
]);
expect(inserted[0][0]).toMatchObject({
memberCode: null,
memberUid: null,
ordererName: '홍길동',
ordererCall: '010-1234-5678',
});
});
it('결제 전 주문만 있으면 DB 를 부르지 않는다', async () => {
const { service, query, repo } = setup();
await expect(
service.recordOrders([
order({ payments: [{ paymentStatus: 'PAYMENT_PREPARATION' }] }),
]),
).resolves.toBe(0);
expect(query.execute).not.toHaveBeenCalled();
expect(repo.query).not.toHaveBeenCalled();
});
it('구매를 남긴 뒤 같은 주문번호의 구매 때 유입을 아직 비어 있는 구매에만 채운다', async () => {
const { service, repo } = setup();
await service.recordOrders([order({}), order({ orderNo: 202610050002 })]);
expect(repo.query).toHaveBeenCalledTimes(1);
const [sql, params] = repo.query.mock.calls[0] as unknown as [
string,
unknown[],
];
expect(sql).toContain('"order_inflows"');
expect(sql).toContain('p."inflow_source" IS NULL');
expect(params).toEqual([['202610050001', '202610050002']]);
});
it('구매 때 유입은 다시 볼 때 덮어쓰지 않는다 (갱신 목록에 없다)', async () => {
const { service, query } = setup();
await service.recordOrders([order({})]);
const [columns] = query.orUpdate.mock.calls[0] as [string[]];
expect(columns.some((column) => column.startsWith('inflow_'))).toBe(false);
});
it('같은 주문이 두 번 와도 한 줄로 모은다 (ON CONFLICT 이중 갱신 방지)', async () => {
const { service, inserted } = setup();
await expect(service.recordOrders([order({}), order({})])).resolves.toBe(1);
expect(inserted[0]).toHaveLength(1);
});
it('이미 있는 주문·상품은 환불 여부와 주문자 정보만 갱신한다', async () => {
const { service, query } = setup();
await service.recordOrders([order({})]);
expect(query.orUpdate).toHaveBeenCalledWith(
expect.arrayContaining(['is_valid', 'orderer_name']),
['order_no', 'prod_no'],
{ skipUpdateIfNoValuesChanged: true },
);
});
it('주문 시각을 읽을 수 없으면 건너뛴다', async () => {
const { service } = setup();
await expect(
service.recordOrders([order({ wtime: 'not-a-date' })]),
).resolves.toBe(0);
});
});
@@ -0,0 +1,130 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { In, Repository } from 'typeorm';
import { ImwebOrderData } from '@/modules/imweb/imweb.types';
import { purchaseLines } from '@/shared/utils/order-purchase';
import { Purchase } from './purchase.entity';
/** 컬럼 길이 — 아임웹이 더 긴 값을 보내도 저장이 실패하지 않게 자른다 */
const PRODUCT_NAME_MAX = 200;
const NAME_MAX = 100;
const EMAIL_MAX = 255;
const CALL_MAX = 40;
function clip(value: string | null | undefined, max: number): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed.slice(0, max) : null;
}
/** 다시 볼 때마다 갱신하는 값 — 환불·취소 여부와 주문자 정보 */
const OVERWRITE_COLUMNS = [
'is_valid',
'product_name',
'member_code',
'member_uid',
'orderer_name',
'orderer_email',
'orderer_call',
];
const CONFLICT_COLUMNS = ['order_no', 'prod_no'];
/**
* 유료 구매 기록 — 주문 동기화(PartnerOrderSyncService)가 아임웹 주문 한 페이지씩 넘겨준다.
* 같은 주문·상품은 한 줄만 남고, 다시 보면 환불·취소 여부를 갱신한다 (여러 번 불러도 안전).
* 구매 때 유입은 주문완료 화면이 보낸 값을 주문번호로 이어 채운다 (OrderInflowsService).
*/
@Injectable()
export class PurchasesService {
constructor(
@InjectRepository(Purchase)
private readonly purchases: Repository<Purchase>,
) {}
/** @returns 남기거나 갱신한 대상 줄 수 (결제된 주문의 상품 수) */
async recordOrders(orders: readonly ImwebOrderData[]): Promise<number> {
// 같은 주문·상품이 한 문장에 두 번 들어가면 ON CONFLICT 가 실패하므로 하나로 모은다
const rows = new Map<string, Purchase>();
for (const order of orders) {
const orderedAt = new Date(order.wtime);
if (Number.isNaN(orderedAt.getTime())) continue;
const orderNo = String(order.orderNo);
const isMember = order.isMember === 'Y' && !!order.memberCode;
for (const line of purchaseLines(order)) {
rows.set(
`${orderNo}:${line.prodNo}`,
this.purchases.create({
id: randomUUID(),
orderNo,
prodNo: line.prodNo,
productName: clip(line.productName, PRODUCT_NAME_MAX),
memberCode: isMember ? (order.memberCode ?? null) : null,
memberUid: isMember ? clip(order.memberUid, EMAIL_MAX) : null,
ordererName: clip(order.ordererName, NAME_MAX),
ordererEmail: clip(order.ordererEmail, EMAIL_MAX),
ordererCall: clip(order.ordererCall, CALL_MAX),
orderedAt,
isValid: line.isValid,
}),
);
}
}
if (rows.size === 0) return 0;
await this.purchases
.createQueryBuilder()
.insert()
.values([...rows.values()])
// 바뀐 게 없으면 다시 쓰지 않는다
.orUpdate(OVERWRITE_COLUMNS, CONFLICT_COLUMNS, {
skipUpdateIfNoValuesChanged: true,
})
.execute();
await this.fillInflows([
...new Set([...rows.values()].map((row) => row.orderNo)),
]);
return rows.size;
}
/** 주문번호 → 주문자 이름 — 업체 실적의 비회원 구매를 누군지 보여줄 때 쓴다 */
async ordererNames(
orderNos: readonly string[],
): Promise<Map<string, string>> {
const unique = [...new Set(orderNos)];
if (unique.length === 0) return new Map();
const rows = await this.purchases.find({
select: { orderNo: true, ordererName: true },
where: { orderNo: In(unique) },
});
const names = new Map<string, string>();
for (const row of rows) {
if (row.ordererName) names.set(row.orderNo, row.ordererName);
}
return names;
}
/**
* 주문완료 화면이 보낸 구매 때 유입(order_inflows)을 같은 주문번호의 구매에 채운다.
* 아직 유입이 없는 구매에만 채운다 — 동기화가 같은 주문을 다시 봐도 값이 바뀌지 않는다.
* 유입이 구매보다 늦게 들어와도 다음 동기화(최근 60일)에서 채워진다.
*/
private async fillInflows(orderNos: string[]): Promise<void> {
if (orderNos.length === 0) return;
await this.purchases.query(
`UPDATE "purchases" AS p
SET "inflow_raw" = o."inflow_raw",
"inflow_source" = o."inflow_source",
"inflow_medium" = o."inflow_medium",
"inflow_campaign" = o."inflow_campaign",
"inflow_content" = o."inflow_content"
FROM "order_inflows" AS o
WHERE p."order_no" = o."order_no"
AND p."order_no" = ANY($1)
AND p."inflow_source" IS NULL
AND o."inflow_source" IS NOT NULL`,
[orderNos],
);
}
}
@@ -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;
/** 공개 주소로 쓸 수 없는 값 — 로컬 주소와 예시 파일의 예시 도메인 */
+1 -1
View File
@@ -2,7 +2,7 @@ import { InflowStatus, parseInflow } from './inflow';
const HOST = 'utm.moneyguider.co.kr';
/** header-top.html 의 serialize(asUrl) 와 같은 방식으로 홈페이지 값을 만든다 */
/** imweb-header-top.html 의 serialize(asUrl) 와 같은 방식으로 홈페이지 값을 만든다 */
function homePage(params: Record<string, string>): string {
const query = Object.entries(params)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* 회원 유입경로 파싱 유틸 (순수 함수).
*
* 아임웹 Header Code 의 UTM 스크립트(header-top.html)가 회원가입 폼의 '홈페이지' 항목에
* 아임웹 Header Code 의 UTM 스크립트(imweb/imweb-header-top.html)가 회원가입 폼의 '홈페이지' 항목에
* 아래 형식으로 유입정보를 기록한다. 아임웹 Open API 회원 조회에는 사용자 정의 가입 항목이
* 나오지 않고 homePage 만 나오기 때문에 이 값을 풀어서 쓴다.
*
@@ -0,0 +1,22 @@
import { maskName } from './mask-name';
describe('maskName', () => {
it('세 글자 이상이면 첫 글자와 마지막 글자만 남긴다', () => {
expect(maskName('홍길동')).toBe('홍*동');
expect(maskName('남궁민수')).toBe('남**수');
});
it('두 글자면 첫 글자만, 한 글자면 모두 가린다', () => {
expect(maskName('김철')).toBe('김*');
expect(maskName('이')).toBe('*');
});
it('값이 없으면 null 이다', () => {
expect(maskName(null)).toBeNull();
expect(maskName(' ')).toBeNull();
});
it('공백·영문도 글자 수대로 가린다 (길이로 이름을 추측하기 어렵게 앞뒤만 남긴다)', () => {
expect(maskName(' John Smith ')).toBe('J********h');
});
});
+18
View File
@@ -0,0 +1,18 @@
/**
* 이름 가리기 (순수 함수) — 업체 계정 화면에 신청·구매자를 보여줄 때 쓴다.
*
* 업체는 자기 링크로 누가 왔는지 대략 확인할 수 있어야 하지만, 개인을 특정할 수 있으면 안 된다.
* 연락처·이메일은 아예 내려주지 않고, 이름은 첫 글자와 마지막 글자만 남긴다.
* 홍길동 → 홍*동 · 김철 → 김* · 남궁민수 → 남**수 · 이 → *
*/
const MASK = '*';
export function maskName(name: string | null | undefined): string | null {
const trimmed = name?.trim();
if (!trimmed) return null;
// 한글·이모지도 한 글자로 센다 (UTF-16 길이가 아니라 코드 포인트 기준)
const chars = [...trimmed.replace(/\s+/g, ' ')];
if (chars.length === 1) return MASK;
if (chars.length === 2) return `${chars[0]}${MASK}`;
return `${chars[0]}${MASK.repeat(chars.length - 2)}${chars.at(-1)}`;
}
@@ -0,0 +1,80 @@
import { OrderForPurchase, purchaseLines } from './order-purchase';
function order(
paymentStatus: string,
sections: NonNullable<OrderForPurchase['sections']>,
): OrderForPurchase {
return { payments: [{ paymentStatus, isCancel: 'N' }], sections };
}
const item = (prodNo: number, prodName?: string, isPromotion = 'N') => ({
isPromotion,
productInfo: { prodNo, prodName },
});
describe('purchaseLines', () => {
it('결제완료 주문의 상품을 상품명과 함께 구매로 남긴다', () => {
const lines = purchaseLines(
order('PAYMENT_COMPLETE', [
{
orderSectionStatus: 'PURCHASE_CONFIRMATION',
sectionItems: [item(52, ' AI 강의 ')],
},
]),
);
expect(lines).toEqual([
{ prodNo: 52, productName: 'AI 강의', isValid: true },
]);
});
it('결제 전·실패한 주문은 구매가 아니다', () => {
for (const status of [
'PAYMENT_PREPARATION',
'PAYMENT_OVERDUE',
'PAYMENT_FAILED',
'CANCELLED_BEFORE_DEPOSIT',
]) {
expect(
purchaseLines(order(status, [{ sectionItems: [item(52)] }])),
).toEqual([]);
}
});
it('전액 환불된 주문도 남기되 구매로 세지 않는다 (환불·취소 표시)', () => {
expect(
purchaseLines(order('REFUND_COMPLETE', [{ sectionItems: [item(52)] }])),
).toEqual([{ prodNo: 52, productName: null, isValid: false }]);
});
it('취소된 섹션의 상품은 세지 않고, 남은 상품은 그대로 센다', () => {
const lines = purchaseLines(
order('PARTIAL_REFUND_COMPLETE', [
{ orderSectionStatus: 'CANCEL_COMPLETE', sectionItems: [item(52)] },
{ orderSectionStatus: 'DELIVERY_COMPLETE', sectionItems: [item(53)] },
]),
);
expect(lines).toEqual([
{ prodNo: 52, productName: null, isValid: false },
{ prodNo: 53, productName: null, isValid: true },
]);
});
it('사은품 품목은 구매 내역에 넣지 않는다', () => {
expect(
purchaseLines(
order('PAYMENT_COMPLETE', [
{ sectionItems: [item(52), item(99, '사은품', 'Y')] },
]),
).map((line) => line.prodNo),
).toEqual([52]);
});
it('취소 결제 행만 있는 주문은 결제된 것으로 보지 않는다', () => {
expect(
purchaseLines({
payments: [{ paymentStatus: 'REFUND_COMPLETE', isCancel: 'Y' }],
sections: [{ sectionItems: [item(52)] }],
}),
).toEqual([]);
});
});
@@ -0,0 +1,66 @@
/**
* 구매 내역 판정 (순수 함수) — 아임웹 주문 한 건에서 구매 내역으로 남길 상품을 고른다.
*
* - 한 번이라도 결제된 주문의 상품만 남긴다. 결제 대기·실패·입금 전 취소 주문은 구매가 아니다.
* - 나중에 환불·취소된 상품도 지우지 않고 isValid=false 로 남긴다 (화면에 '환불·취소'로 보인다).
* - '지금 구매로 세는가'는 업체 실적과 같은 규칙(evaluateOrder)을 쓴다 — 두 화면의 구매 수가 어긋나지 않게.
*/
import { evaluateOrder, OrderForAttribution } from './partner-attribution';
/** 결제가 한 번은 끝났던 결제 상태 — 지금은 환불됐어도 구매 기록은 남긴다 */
const EVER_PAID_PAYMENT_STATUSES = new Set([
'PAYMENT_COMPLETE',
'PARTIAL_REFUND_COMPLETE',
'REFUND_PROCESSING',
'REFUND_COMPLETE',
'REFUND_FAILED',
]);
/** 판정에 필요한 주문 필드 — 업체 실적 판정 필드 + 상품명 */
export interface OrderForPurchase extends OrderForAttribution {
sections?: {
orderSectionStatus?: string | null;
sectionItems?: {
isPromotion?: string | null;
productInfo?: {
prodNo?: number | null;
prodName?: string | null;
} | null;
}[];
}[];
}
export interface PurchaseLine {
prodNo: number;
/** 주문 당시 상품명 (없으면 null) */
productName: string | null;
/** 지금 구매로 세는가 — 환불·취소·반품되면 false */
isValid: boolean;
}
/** 주문 한 건 → 상품별 구매 내역 (상품당 한 줄, 사은품 제외) */
export function purchaseLines(order: OrderForPurchase): PurchaseLine[] {
const wasPaid = (order.payments ?? []).some(
(payment) =>
payment.isCancel !== 'Y' &&
EVER_PAID_PAYMENT_STATUSES.has(payment.paymentStatus ?? ''),
);
if (!wasPaid) return [];
const names = new Map<number, string>();
for (const section of order.sections ?? []) {
for (const item of section.sectionItems ?? []) {
const prodNo = item.productInfo?.prodNo;
const name = item.productInfo?.prodName?.trim();
if (typeof prodNo === 'number' && name && !names.has(prodNo)) {
names.set(prodNo, name);
}
}
}
return [...evaluateOrder(order).products].map(([prodNo, isValid]) => ({
prodNo,
productName: names.get(prodNo) ?? null,
isValid,
}));
}
@@ -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,134 @@
import {
buildEntryUrl,
isBotUserAgent,
normalizeTargetUrl,
toBase62,
withPartnerRef,
withPartnerUtm,
} 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('buildEntryUrl', () => {
it('아임웹 사이트의 /go 페이지 주소를 만든다', () => {
expect(buildEntryUrl('https://www.moneyguider.co.kr', 'Ab3kX9pQ')).toBe(
'https://www.moneyguider.co.kr/go?c=Ab3kX9pQ',
);
});
});
describe('withPartnerUtm', () => {
const TARGET = 'https://www.moneyguider.co.kr/free-shop/?idx=52';
it('UTM 이 없으면 업체 이름·partner·링크 코드를 붙인다', () => {
const url = new URL(withPartnerUtm(TARGET, '머니 튜브', 'Ab3kX9pQ'));
expect(url.searchParams.get('idx')).toBe('52');
expect(url.searchParams.get('utm_source')).toBe('머니 튜브');
expect(url.searchParams.get('utm_medium')).toBe('partner');
expect(url.searchParams.get('utm_campaign')).toBe('Ab3kX9pQ');
});
it('관리자가 넣은 UTM 이 하나라도 있으면 건드리지 않는다', () => {
const custom = `${TARGET}&utm_campaign=fall`;
expect(withPartnerUtm(custom, '머니튜브', 'Ab3kX9pQ')).toBe(custom);
});
it('업체 이름이 비어 있으면 링크 코드를 유입원으로 쓴다', () => {
const url = new URL(withPartnerUtm(TARGET, ' ', 'Ab3kX9pQ'));
expect(url.searchParams.get('utm_source')).toBe('Ab3kX9pQ');
});
});
describe('isBotUserAgent', () => {
it('링크 미리보기·검색봇은 사람으로 세지 않는다', () => {
expect(
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');
});
});
+131
View File
@@ -0,0 +1,131 @@
/**
* 업체 링크 유틸 (순수 함수) — 이동 주소 검증, 클릭 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();
}
/**
* 업체에 전달하는 입구 주소 — 아임웹 사이트의 '/go' 페이지.
* https://www.moneyguider.co.kr/go?c={코드}
* Header Code(imweb/imweb-header-top.html)가 이 주소를 보면 페이지를 그리기 전에
* 백엔드 단축 링크(/r/{코드})로 넘긴다. 아임웹에 'go' 페이지가 있어야 한다.
*/
export const PARTNER_ENTRY_PATH = '/go';
export const PARTNER_ENTRY_PARAM = 'c';
export function buildEntryUrl(siteUrl: string, code: string): string {
const url = new URL(PARTNER_ENTRY_PATH, siteUrl);
url.searchParams.set(PARTNER_ENTRY_PARAM, code);
return url.toString();
}
/** UTM 파라미터 접두어 — imweb-header-top.html 의 UTM 스크립트가 읽는다 */
const UTM_PARAM_PREFIX = 'utm_';
/**
* 업체 링크로 온 방문의 매체 값.
* imweb-header-top.html 의 PARTNER_MEDIUM 과 같아야 한다 — 이 값이면 UTM 스크립트가 최초유입을 업체로 덮어쓴다
*/
export const PARTNER_UTM_MEDIUM = 'partner';
/**
* 업체 링크 UTM 을 붙인다 — 이동 주소에 UTM 이 하나도 없을 때만.
* (관리자가 이동 주소에 직접 넣은 UTM 이 있으면 그대로 둔다)
*
* 그래야 UTM 스크립트가 이 방문을 direct 가 아니라 그 업체로 기록해,
* 신청 때 유입·가입 경로에도 업체가 나타난다.
* - utm_source 업체 이름 (집계 화면에서 읽기 쉽게)
* - utm_medium partner
* - utm_campaign 링크 코드 (업체 이름을 바꿔도 변하지 않는 식별자)
*/
export function withPartnerUtm(
targetUrl: string,
partnerName: string,
linkCode: string,
): string {
const url = new URL(targetUrl);
for (const key of url.searchParams.keys()) {
if (key.toLowerCase().startsWith(UTM_PARAM_PREFIX)) return url.toString();
}
url.searchParams.set('utm_source', partnerName.trim() || linkCode);
url.searchParams.set('utm_medium', PARTNER_UTM_MEDIUM);
url.searchParams.set('utm_campaign', linkCode);
return url.toString();
}
/**
* 사람이 아닌 요청 — 링크 미리보기(카카오톡·페이스북 등)와 검색봇.
* 업체가 카톡방에 링크를 올리면 미리보기용 요청이 먼저 오므로 세지 않는다.
* 카카오톡 인앱 브라우저(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);
}

Some files were not shown because too many files have changed in this diff Show More