8 Commits

Author SHA1 Message Date
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
ttipo b1c6e6c7b6 feat: 아임웹 관리자 대시보드 화면 추가
아임웹 관리자 전용 페이지의 코드 위젯에 붙여 쓰는 단일 HTML 화면이다.
개요·회원 목록·신청 내역·연동 관리 메뉴로 구성하고, 회원 그룹·신청 상품은
항상 펼쳐 둔 칩으로 거른다. 로컬 확인용 정적 서버와 설치·운영 안내를 함께 둔다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:21:25 +09:00
ttipo c54df9cefa feat(backend): 아임웹 회원 유입경로·신청 기록 관리자 API 추가
아임웹 관리자 페이지에서 회원을 유입경로·그룹 기준으로 보고, 무료 강의 신청을
기록·조회하기 위한 백엔드다. 관리자 화면(아임웹)과 API(wandergo.net)가 다른 사이트라
인증은 쿠키 대신 Bearer 토큰으로 한다.

- 관리자 인증: env 단일 계정(ADMIN_USERNAME/PASSWORD)을 여러 사람이 공유,
  기기별 세션(로그아웃은 그 기기만, 비밀번호 변경 시 전체 무효)
- 아임웹 Open API 연동: OAuth 연결·토큰 갱신, 회원·그룹 주기 동기화,
  가입 폼 '홈페이지' 항목의 유입정보 파싱, 회원 엑셀 '유입경로' 가져오기 스크립트
- 회원 조회: 유입경로·그룹·기간 필터, 유입원 집계
- 강의 신청 기록: 신청 완료 화면이 보내는 공개 API(회원 확인·중복 방지·요청 제한),
  오픈카톡 클릭 기록, 관리자 신청 내역 조회
- CORS 다중 도메인, env 검증(운영 필수값), CI e2e 전 마이그레이션 적용,
  개발 컨테이너 메모리 한도 2g, 에러 코드 IMWEB_001~005·APPLY_001

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:21:25 +09:00
122 changed files with 14486 additions and 145 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)으로 뜬다.
---
+23 -1
View File
@@ -40,7 +40,8 @@ REDIS_DATA_DIR=./data/redis
# API 경로 설정
# ==========================================
BACKEND_API_URL=http://localhost:3000/api
CORS_ORIGIN=http://localhost:5173
# 127.0.0.1:5173 은 imweb/serve-local.js 로 관리자 화면을 '다른 사이트' 조건(운영의 아임웹 ↔ wandergo.net)으로 띄울 때 쓴다
CORS_ORIGIN=http://localhost:5173,http://127.0.0.1:5173
# ==========================================
# Rate Limiting (API 남용 방지)
@@ -79,3 +80,24 @@ COMPOSE_PROFILES=
# ==========================================
UPLOAD_DATA_DIR=./data/uploads
UPLOAD_MAX_SIZE_MB=5
# ==========================================
# 아임웹 연동 (회원 유입경로 관리자 화면)
#
# 비밀값(IMWEB_CLIENT_ID / IMWEB_CLIENT_SECRET)은 backend/.env.{APP_ENV} 에 둔다.
# OAuth redirect URI 는 BACKEND_API_URL + /imweb/oauth/callback 으로 자동 계산된다
# → 아임웹 개발자센터 앱 설정의 redirect URI 와 **정확히 같아야** 한다.
#
# IMWEB_SITE_CODE 아임웹 사이트 코드 (사이트 HTML 의 siteCode 값)
# 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
+28 -4
View File
@@ -46,8 +46,10 @@ REDIS_DATA_DIR=./data/redis
# 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓰는 경우에만
# ALLOW_LOCALHOST_URL=true 로 이 검사를 건너뛴다. 실제 배포에는 설정하지 않는다.
# ==========================================
# 예) 백엔드 https://moneyguide.wandergo.net/api, 관리자 화면(아임웹) https://www.moneyguider.co.kr
# → CORS_ORIGIN 에 아임웹 도메인(www 유무 둘 다)을 넣는다
BACKEND_API_URL=https://api.example.com/api
CORS_ORIGIN=https://app.example.com
CORS_ORIGIN=https://www.example.com,https://example.com
# ==========================================
# Rate Limiting (API 남용 방지)
@@ -68,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=
@@ -80,3 +84,23 @@ COMPOSE_PROFILES=
# ==========================================
UPLOAD_DATA_DIR=./data/uploads
UPLOAD_MAX_SIZE_MB=5
# ==========================================
# 아임웹 연동 (회원 유입경로 관리자 화면)
#
# 비밀값(IMWEB_CLIENT_ID / IMWEB_CLIENT_SECRET)은 backend/.env.{APP_ENV} 에 둔다.
# OAuth redirect URI 는 BACKEND_API_URL + /imweb/oauth/callback 으로 자동 계산된다
# → 아임웹 개발자센터 앱 설정의 redirect URI 와 **정확히 같아야** 한다.
#
# IMWEB_SITE_CODE 아임웹 사이트 코드 (사이트 HTML 의 siteCode 값)
# 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
+12
View File
@@ -56,6 +56,18 @@ jobs:
- name: Test
run: npm run test
# e2e 는 실제 스키마가 필요하다 — 부팅 시 테이블을 읽는 모듈(아임웹 동기화)이 있다.
# 마이그레이션이 빈 DB 에 깨끗하게 적용되는지도 함께 검증된다.
- name: Migrate
run: npm run migration:run
env:
NODE_ENV: test
DB_HOST: postgres
DB_PORT: 5432
DB_USER: ci_user
DB_PASSWORD: ci_password
DB_NAME: ci_db
# 표준 응답 래퍼·에러 코드·traceId 계약 검증 (.claude/rules/response-format.md)
- name: E2E test
run: npm run test:e2e
+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 -1
View File
@@ -12,4 +12,13 @@
# JWT 서명 키 — 인증 도입 시 사용. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
JWT_SECRET=change-me-generate-a-long-random-string
JWT_ACCESS_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=14d
JWT_REFRESH_EXPIRES_IN=12h
# 아임웹 개발자센터에서 발급받은 앱 키 — 회원 개인정보를 읽을 수 있으므로 절대 커밋하지 않는다
IMWEB_CLIENT_ID=change-me-imweb-client-id
IMWEB_CLIENT_SECRET=change-me-imweb-client-secret
# 관리자 계정 — 관리자 화면 로그인용 단일 계정 (여러 사람이 함께 쓴다)
# 비밀번호를 바꾸고 재시작하면 로그인해 있던 모든 기기가 로그아웃된다. 12자 이상.
ADMIN_USERNAME=change-me-admin-username
ADMIN_PASSWORD=change-me-admin-password
+10 -1
View File
@@ -12,4 +12,13 @@
# JWT 서명 키 — 인증 도입 시 사용. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
JWT_SECRET=change-me-generate-a-long-random-string
JWT_ACCESS_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=14d
JWT_REFRESH_EXPIRES_IN=12h
# 아임웹 개발자센터에서 발급받은 앱 키 — 회원 개인정보를 읽을 수 있으므로 절대 커밋하지 않는다
IMWEB_CLIENT_ID=change-me-imweb-client-id
IMWEB_CLIENT_SECRET=change-me-imweb-client-secret
# 관리자 계정 — 관리자 화면 로그인용 단일 계정 (여러 사람이 함께 쓴다)
# 비밀번호를 바꾸고 재시작하면 로그인해 있던 모든 기기가 로그아웃된다. 12자 이상.
ADMIN_USERNAME=change-me-admin-username
ADMIN_PASSWORD=change-me-admin-password
+186 -122
View File
@@ -13,6 +13,7 @@
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^11.2.6",
@@ -25,6 +26,7 @@
"helmet": "^8.1.0",
"nest-winston": "^1.10.0",
"pg": "^8.20.0",
"read-excel-file": "^9.3.10",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.28",
@@ -177,24 +179,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@angular-devkit/schematics-cli/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@angular-devkit/schematics-cli/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -202,22 +186,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@angular-devkit/schematics-cli/node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@angular-devkit/schematics-cli/node_modules/rxjs": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
@@ -283,24 +251,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@angular-devkit/schematics/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@angular-devkit/schematics/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -308,22 +258,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@angular-devkit/schematics/node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@angular-devkit/schematics/node_modules/rxjs": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
@@ -2951,6 +2885,19 @@
}
}
},
"node_modules/@nestjs/jwt": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz",
"integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==",
"license": "MIT",
"dependencies": {
"@types/jsonwebtoken": "9.0.10",
"jsonwebtoken": "9.0.3"
},
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
}
},
"node_modules/@nestjs/mapped-types": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz",
@@ -3092,24 +3039,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@nestjs/schematics/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nestjs/schematics/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -3117,22 +3046,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@nestjs/schematics/node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nestjs/schematics/node_modules/rxjs": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
@@ -3534,7 +3447,7 @@
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.41.tgz",
"integrity": "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==",
"dev": true,
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -3578,7 +3491,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3595,7 +3507,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3612,7 +3523,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3629,7 +3539,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3646,7 +3555,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3663,7 +3571,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3680,7 +3587,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3697,7 +3603,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3714,7 +3619,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3731,7 +3635,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3748,7 +3651,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3765,7 +3667,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3779,14 +3680,14 @@
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@swc/types": {
"version": "0.1.26",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz",
"integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3"
@@ -4040,6 +3941,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/luxon": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz",
@@ -4053,6 +3964,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz",
@@ -4067,7 +3984,6 @@
"version": "22.19.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz",
"integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -5938,6 +5854,12 @@
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -6823,6 +6745,15 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -7449,6 +7380,12 @@
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
"license": "MIT"
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/figures": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
@@ -8100,7 +8037,6 @@
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/handlebars": {
@@ -9573,6 +9509,49 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
@@ -9690,6 +9669,42 @@
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@@ -9704,6 +9719,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/log-symbols": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
@@ -10196,7 +10217,6 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"dev": true,
"license": "MIT"
},
"node_modules/node-releases": {
@@ -11057,6 +11077,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/read-excel-file": {
"version": "9.3.10",
"resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-9.3.10.tgz",
"integrity": "sha512-zFcBdzunLCGBmLRT4Q3mLPJnhKPAXXfGAZ83feODlEqX2aRUbgF1h/n2oY0UF7I8tVwnJNRas6fTRD5veDUs+g==",
"license": "MIT",
"dependencies": {
"fflate": "^0.8.3",
"saxen": "^11.1.0",
"unzipper-esm": "^0.13.3",
"worker-f": "^0.1.12"
},
"engines": {
"node": ">=18"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -11247,6 +11282,15 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/saxen": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/saxen/-/saxen-11.2.0.tgz",
"integrity": "sha512-2bG564AAJcm/3vyZC0Uhs/mACrH+4AHMaKHKAB2M5xMqomUZa27FQ7MYaQGFKVcJk/MVzSEQ34A8QZWW+eiWEw==",
"license": "MIT",
"engines": {
"node": ">= 20.12"
}
},
"node_modules/schema-utils": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
@@ -11294,7 +11338,6 @@
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -12923,7 +12966,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/unicorn-magic": {
@@ -12996,6 +13038,19 @@
"@unrs/resolver-binding-win32-x64-msvc": "1.12.2"
}
},
"node_modules/unzipper-esm": {
"version": "0.13.3",
"resolved": "https://registry.npmjs.org/unzipper-esm/-/unzipper-esm-0.13.3.tgz",
"integrity": "sha512-LUO6VZ6fCzkDbdMev0/fOhoIeVGKaOkTIOoYxVLE0SQjfvmAHK+oywl7lfhloSZIsdGJ25mJ18Mtd9CyTASjrA==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.2",
"node-int64": "^0.4.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/update-browserslist-db": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
@@ -13438,6 +13493,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/worker-f": {
"version": "0.1.20",
"resolved": "https://registry.npmjs.org/worker-f/-/worker-f-0.1.20.tgz",
"integrity": "sha512-7z5K5z4x++FykhpDTfriT/dOu7CmSap9BBv38DVFU3CD38obopq+DoFH75yBV3butpGm+OeqR9BKdSvYJSnUfQ==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+3
View File
@@ -20,6 +20,7 @@
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"sync:error-codes": "ts-node -P tsconfig.json scripts/sync-error-codes.ts",
"inflow:import": "ts-node -r tsconfig-paths/register scripts/import-inflow.ts",
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d src/data-source.ts",
"migration:generate": "npm run typeorm -- migration:generate",
"migration:create": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:create",
@@ -32,6 +33,7 @@
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^11.2.6",
@@ -44,6 +46,7 @@
"helmet": "^8.1.0",
"nest-winston": "^1.10.0",
"pg": "^8.20.0",
"read-excel-file": "^9.3.10",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.28",
+111
View File
@@ -0,0 +1,111 @@
/**
* 아임웹 회원 엑셀의 '유입경로' 가져오기.
*
* '유입경로'(사용자 정의 가입 항목)는 아임웹 Open API 로 나오지 않는다.
* 가입 폼 '홈페이지' 항목에 유입정보를 쓰기 전에 가입한 회원은 이 엑셀로만 유입경로를 알 수 있다.
*
* 개인정보 파일이므로 저장소 폴더에 두지 말고 컨테이너 임시 경로로 복사해 실행한 뒤 지운다.
*
* docker cp "회원2026_10_05_1.xls" moneyguide-backend:/tmp/members-1.xls
* docker exec moneyguide-backend npm run inflow:import -- /tmp/members-1.xls [/tmp/members-2.xls ...]
* docker exec -u root moneyguide-backend rm /tmp/members-1.xls (docker cp 로 넣은 파일은 root 소유)
*
* Git Bash 에서는 /tmp 경로가 Windows 경로로 바뀌므로 앞에 MSYS_NO_PATHCONV=1 을 붙인다.
*
* 같은 고유키는 덮어쓰므로 여러 번 실행해도 안전하다. 출력에는 개수만 나온다 (개인정보 없음).
*/
import 'reflect-metadata';
import { basename } from 'path';
import { readSheet } from 'read-excel-file/node';
import { DataSource } from 'typeorm';
import cliDataSource from '../src/data-source';
import { DEFAULT_TRACKING_HOST } from '../src/modules/imweb/imweb.config';
import {
ImportRow,
applyInflowImport,
extractImportRows,
} from '../src/modules/imweb/inflow-import';
async function main(): Promise<void> {
const files = process.argv.slice(2);
if (files.length === 0) {
console.error(
'사용법: npm run inflow:import -- <엑셀 경로> [엑셀 경로 ...]',
);
process.exit(1);
}
// 여러 파일에 같은 회원이 있으면 뒤 파일의 값을 쓴다
const merged = new Map<string, ImportRow & { file: string }>();
for (const file of files) {
const sheet = await readSheet(file);
const { rows, skippedEmpty, duplicates } = extractImportRows(sheet);
console.log(
`${basename(file)}: 유입경로 ${rows.length}행 (빈 값 건너뜀 ${skippedEmpty}, 파일 안 중복 ${duplicates})`,
);
for (const row of rows)
merged.set(row.memberCode, { ...row, file: basename(file) });
}
// 개발 환경의 쿼리 로그가 켜져 있으면 유입경로 값이 화면에 찍히므로 끈다
const dataSource = new DataSource({
...cliDataSource.options,
logging: false,
});
await dataSource.initialize();
try {
const trackingHost =
process.env.IMWEB_TRACKING_HOST?.trim() || DEFAULT_TRACKING_HOST;
const byFile = new Map<string, ImportRow[]>();
for (const { file, ...row } of merged.values()) {
byFile.set(file, [...(byFile.get(file) ?? []), row]);
}
const totals = {
stored: 0,
matched: 0,
appliedFromImport: 0,
atJoin: 0,
afterJoin: 0,
unparsable: 0,
};
await dataSource.transaction(async (manager) => {
for (const [file, rows] of byFile) {
const result = await applyInflowImport(
manager,
rows,
file,
trackingHost,
);
for (const key of Object.keys(totals) as (keyof typeof totals)[]) {
totals[key] += result[key];
}
}
});
console.log('');
console.log(`보관: ${totals.stored}명`);
console.log(
`DB 회원과 매칭: ${totals.matched}명 (매칭 안 된 ${totals.stored - totals.matched}명은 동기화되면 자동 반영)`,
);
console.log(
`엑셀 값 적용: ${totals.appliedFromImport}명 — 가입 때 기록 ${totals.atJoin} / 가입 후 기록 ${totals.afterJoin}`,
);
if (totals.unparsable > 0) {
console.log(
`스크립트 형식이 아니라 풀지 못한 값: ${totals.unparsable}명`,
);
}
} finally {
await dataSource.destroy();
}
}
main().catch((error: unknown) => {
console.error(
'유입경로 가져오기 실패:',
error instanceof Error ? error.message : error,
);
process.exit(1);
});
+10
View File
@@ -7,7 +7,12 @@ import { CacheModule } from '@nestjs/cache-manager';
import { APP_GUARD } from '@nestjs/core';
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
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';
@@ -60,6 +65,11 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
HealthModule,
UploadModule,
UserModule,
AdminAuthModule,
ImwebModule,
MembersModule,
ApplicationsModule,
PartnersModule,
],
providers: [
ShutdownLogger,
@@ -0,0 +1,17 @@
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';
/**
* AdminAuthGuard 가 검증한 관리자를 핸들러 인자로 받는다.
* 가드 없이 쓰면 관리자 정보가 없으므로 AUTH_001 로 막는다.
*/
export const CurrentAdmin = createParamDecorator(
(_data: unknown, context: ExecutionContext): AdminResponseDto => {
const request = context.switchToHttp().getRequest<AdminRequest>();
if (!request.admin) throw new BusinessException('AUTH_001');
return request.admin;
},
);
+54
View File
@@ -73,6 +73,60 @@ const DOMAIN_ERROR_CODES = {
status: HttpStatus.PAYLOAD_TOO_LARGE,
message: '파일 크기가 허용 범위를 초과했습니다.',
},
// 아임웹 연동 — 회원 동기화·유입경로 조회
IMWEB_001: {
status: HttpStatus.CONFLICT,
message:
'아임웹 연결이 되어 있지 않습니다. 먼저 아임웹 계정을 연결해 주세요.',
},
IMWEB_002: {
status: HttpStatus.BAD_GATEWAY,
message:
'아임웹에서 정보를 가져오지 못했습니다. 잠시 후 다시 시도해 주세요.',
},
IMWEB_003: {
status: HttpStatus.BAD_REQUEST,
message:
'아임웹 연결 요청이 만료되었거나 올바르지 않습니다. 처음부터 다시 연결해 주세요.',
},
IMWEB_004: {
status: HttpStatus.CONFLICT,
message: '회원 동기화가 이미 진행 중입니다. 잠시 후 다시 확인해 주세요.',
},
IMWEB_005: {
status: HttpStatus.SERVICE_UNAVAILABLE,
message:
'아임웹 연동 설정이 완료되지 않았습니다. 관리자에게 문의해 주세요.',
},
// 연결된 토큰에 필요한 권한(상품·주문 읽기 등)이 없을 때 — 아임웹이 403 으로 거부한다
IMWEB_006: {
status: HttpStatus.FORBIDDEN,
message:
'아임웹 권한이 부족합니다. 연동 관리에서 아임웹 연결을 다시 해 주세요.',
},
// 강의 신청 기록 — 신청 완료 화면이 보낸다
APPLY_001: {
status: HttpStatus.BAD_REQUEST,
message: '회원 정보를 확인할 수 없어 신청을 기록하지 못했습니다.',
},
// 업체 링크 — 업체별 신청·구매 추적
PARTNER_001: {
status: HttpStatus.CONFLICT,
message: '이 업체에는 같은 상품의 링크가 이미 있습니다.',
},
PARTNER_002: {
status: HttpStatus.CONFLICT,
message:
'링크가 발급된 업체는 삭제할 수 없습니다. 실적 기록을 지키기 위해 링크를 중지해 주세요.',
},
PARTNER_003: {
status: HttpStatus.BAD_REQUEST,
message: '이동 주소는 우리 아임웹 사이트 안의 주소여야 합니다.',
},
PARTNER_004: {
status: HttpStatus.CONFLICT,
message: '같은 이름의 업체가 이미 있습니다.',
},
} as const satisfies Record<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
@@ -0,0 +1,31 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import type { Request } from 'express';
import { AdminAuthService } 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;
}
/**
* 관리자 전용 라우트 보호 — `Authorization: Bearer` 의 액세스 토큰을 검증한다.
* 실패하면 AUTH_001(401). 화면은 401 을 받으면 리프레시 토큰으로 갱신을 한 번 시도한다.
*/
@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;
return true;
}
}
+14 -6
View File
@@ -15,6 +15,7 @@ import { Reflector } from '@nestjs/core';
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
import { winstonConfig } from '@/shared/logger/winston.config';
import { parseList } from '@/shared/utils/env';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
@@ -27,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 },
],
});
// 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 추가
@@ -40,8 +45,14 @@ async function bootstrap() {
app.use(helmet());
// 2. 화이트리스트 기반 CORS 정책
// CORS_ORIGIN 은 콤마로 여러 개를 줄 수 있다
// (예: 아임웹 관리자 페이지 https://www.moneyguider.co.kr,https://moneyguider.co.kr)
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
const corsOriginEnv = process.env.CORS_ORIGIN;
const whitelist = [
frontendUrl,
'http://localhost:3000',
...parseList(process.env.CORS_ORIGIN),
];
app.enableCors({
origin: (
@@ -49,9 +60,6 @@ async function bootstrap() {
callback: (err: Error | null, allow?: boolean) => void,
) => {
// 서버간의 통신(origin이 undefined)이거나 명시된 리스트일 때만 허용
const whitelist = [frontendUrl, 'http://localhost:3000'];
if (corsOriginEnv) whitelist.push(corsOriginEnv);
if (!origin || whitelist.indexOf(origin) !== -1) {
callback(null, true);
} else {
@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAdminAndImwebMembers1791171834616 implements MigrationInterface {
name = 'AddAdminAndImwebMembers1791171834616';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "admin_users" ("id" SERIAL NOT NULL, "username" character varying(50) NOT NULL, "password_hash" character varying(100) NOT NULL, "token_version" integer NOT NULL DEFAULT '0', "last_login_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_2873882c38e8c07d98cb64f962d" UNIQUE ("username"), CONSTRAINT "PK_06744d221bb6145dc61e5dc441d" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "imweb_connections" ("site_code" character varying(40) NOT NULL, "unit_code" character varying(40) NOT NULL, "access_token" text NOT NULL, "refresh_token" text NOT NULL, "access_token_expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "scope" character varying(255), "connected_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_578dd7ff2be366a4480f4f49c6d" PRIMARY KEY ("site_code"))`,
);
await queryRunner.query(
`CREATE TABLE "imweb_members" ("member_code" character varying(40) NOT NULL, "uid" character varying(255) NOT NULL, "name" character varying(100) NOT NULL, "email" character varying(255), "callnum" character varying(30), "social_provider" character varying(20), "join_time" TIMESTAMP WITH TIME ZONE NOT NULL, "last_login_time" TIMESTAMP WITH TIME ZONE, "home_page" text, "inflow_status" character varying(20) NOT NULL, "inflow_source" character varying(100), "inflow_medium" character varying(100), "inflow_campaign" character varying(100), "inflow_content" character varying(100), "inflow_term" character varying(100), "inflow_click_id" character varying(100), "inflow_referrer" character varying(100), "inflow_landing_path" character varying(100), "inflow_product_id" character varying(100), "inflow_first_touch_date" date, "inflow_last_touch" character varying(100), "inflow_recorded_date" date, "last_sync_run_id" integer NOT NULL, "synced_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_38dda90f47d9e1f1fc4ba86273e" PRIMARY KEY ("member_code"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_c2a5af1f585eb1cf214864511b" ON "imweb_members" ("inflow_status") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_612f4cfc08bd49160be3d26571" ON "imweb_members" ("inflow_source") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_b5527a840c8f41ee25858e3cc9" ON "imweb_members" ("inflow_medium") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_5e4276d07745c2ce06b6828eb0" ON "imweb_members" ("inflow_campaign") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_bc68104ee1dcd0fee8f4bf093e" ON "imweb_members" ("last_sync_run_id") `,
);
await queryRunner.query(
`CREATE INDEX "idx_imweb_members_join_cursor" ON "imweb_members" ("join_time", "member_code") `,
);
await queryRunner.query(
`CREATE TABLE "imweb_sync_runs" ("id" SERIAL NOT NULL, "trigger" character varying(20) NOT NULL, "status" character varying(20) NOT NULL, "started_at" TIMESTAMP WITH TIME ZONE NOT NULL, "finished_at" TIMESTAMP WITH TIME ZONE, "fetched_count" integer NOT NULL DEFAULT '0', "removed_count" integer NOT NULL DEFAULT '0', "error_code" character varying(20), CONSTRAINT "PK_8dc1a26be110a57fa2eb0e7b4d1" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_881a04460c9d208e67319905b0" ON "imweb_sync_runs" ("started_at") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."IDX_881a04460c9d208e67319905b0"`,
);
await queryRunner.query(`DROP TABLE "imweb_sync_runs"`);
await queryRunner.query(
`DROP INDEX "public"."idx_imweb_members_join_cursor"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_bc68104ee1dcd0fee8f4bf093e"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_5e4276d07745c2ce06b6828eb0"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_b5527a840c8f41ee25858e3cc9"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_612f4cfc08bd49160be3d26571"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_c2a5af1f585eb1cf214864511b"`,
);
await queryRunner.query(`DROP TABLE "imweb_members"`);
await queryRunner.query(`DROP TABLE "imweb_connections"`);
await queryRunner.query(`DROP TABLE "admin_users"`);
}
}
@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddInflowImport1791176284930 implements MigrationInterface {
name = 'AddInflowImport1791176284930';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "imweb_member_inflow_imports" ("member_code" character varying(40) NOT NULL, "raw_value" text NOT NULL, "source_file" character varying(255) NOT NULL, "imported_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_65c32a8c14f337b52379daf8d0c" PRIMARY KEY ("member_code"))`,
);
await queryRunner.query(
`ALTER TABLE "imweb_members" ADD "inflow_origin" character varying(20)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "imweb_members" DROP COLUMN "inflow_origin"`,
);
await queryRunner.query(`DROP TABLE "imweb_member_inflow_imports"`);
}
}
@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAdminSessions1791179523253 implements MigrationInterface {
name = 'AddAdminSessions1791179523253';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "admin_sessions" ("id" uuid NOT NULL, "admin_id" integer NOT NULL, "user_agent" character varying(200), "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "last_used_at" TIMESTAMP WITH TIME ZONE NOT NULL, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "revoked_at" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_38bb553c2372215d48de2306c5e" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_52683d67b3d122a8867d745beb" ON "admin_sessions" ("admin_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_ce73d53a035a036b02fc45d3f9" ON "admin_sessions" ("expires_at") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."IDX_ce73d53a035a036b02fc45d3f9"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_52683d67b3d122a8867d745beb"`,
);
await queryRunner.query(`DROP TABLE "admin_sessions"`);
}
}
@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 관리자 계정을 DB(admin_users)에서 env 단일 계정(ADMIN_USERNAME / ADMIN_PASSWORD)으로 옮긴다.
* - 기존 세션은 예전 형식 토큰과 묶여 있어 어차피 쓸 수 없으므로 비운다 (다시 로그인하면 된다)
* - admin_users 는 엔티티를 지워도 TypeORM 이 자동으로 지우지 않아 직접 삭제한다
*/
export class UseEnvAdminAccount1791180231324 implements MigrationInterface {
name = 'UseEnvAdminAccount1791180231324';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM "admin_sessions"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_52683d67b3d122a8867d745beb"`,
);
await queryRunner.query(
`ALTER TABLE "admin_sessions" DROP COLUMN "admin_id"`,
);
await queryRunner.query(`DROP TABLE "admin_users"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "admin_users" ("id" SERIAL NOT NULL, "username" character varying(50) NOT NULL, "password_hash" character varying(100) NOT NULL, "token_version" integer NOT NULL DEFAULT '0', "last_login_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_2873882c38e8c07d98cb64f962d" UNIQUE ("username"), CONSTRAINT "PK_06744d221bb6145dc61e5dc441d" PRIMARY KEY ("id"))`,
);
// NOT NULL 컬럼을 다시 넣으려면 세션이 비어 있어야 한다
await queryRunner.query(`DELETE FROM "admin_sessions"`);
await queryRunner.query(
`ALTER TABLE "admin_sessions" ADD "admin_id" integer NOT NULL`,
);
await queryRunner.query(
`CREATE INDEX "IDX_52683d67b3d122a8867d745beb" ON "admin_sessions" ("admin_id") `,
);
}
}
@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddImwebGroups1791181662391 implements MigrationInterface {
name = 'AddImwebGroups1791181662391';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "imweb_groups" ("group_code" character varying(40) NOT NULL, "title" character varying(200) NOT NULL, "sort_order" integer NOT NULL, "synced_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_eabbfeebf4468c2087710c95623" PRIMARY KEY ("group_code"))`,
);
await queryRunner.query(
`ALTER TABLE "imweb_members" ADD "group_codes" text array NOT NULL DEFAULT '{}'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "imweb_members" DROP COLUMN "group_codes"`,
);
await queryRunner.query(`DROP TABLE "imweb_groups"`);
}
}
@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMemberApplications1791184024124 implements MigrationInterface {
name = 'AddMemberApplications1791184024124';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "member_applications" ("id" uuid NOT NULL, "member_code" character varying(40) NOT NULL, "member_uid" character varying(255) NOT NULL, "product_idx" character varying(20) NOT NULL, "product_name" character varying(200), "applied_at" TIMESTAMP WITH TIME ZONE NOT NULL, "kakao_clicked_at" TIMESTAMP WITH TIME ZONE, "inflow_raw" text, "inflow_source" character varying(100), "inflow_medium" character varying(100), "inflow_campaign" character varying(100), "inflow_content" character varying(100), CONSTRAINT "PK_9d4ff55268968b0cdcd613500d4" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_76d5f4037b504da8bf297a0de4" ON "member_applications" ("product_idx") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_4cac90f709d2816667c7c700ce" ON "member_applications" ("inflow_source") `,
);
await queryRunner.query(
`CREATE INDEX "idx_member_applications_cursor" ON "member_applications" ("applied_at", "id") `,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "uq_member_applications_member_product" ON "member_applications" ("member_code", "product_idx") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."uq_member_applications_member_product"`,
);
await queryRunner.query(
`DROP INDEX "public"."idx_member_applications_cursor"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_4cac90f709d2816667c7c700ce"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_76d5f4037b504da8bf297a0de4"`,
);
await queryRunner.query(`DROP TABLE "member_applications"`);
}
}
@@ -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,58 @@
import { parseDurationMs } from '@/shared/utils/duration';
/**
* 관리자 인증은 쿠키가 아니라 `Authorization: Bearer` 헤더로 한다.
*
* 관리자 화면은 아임웹(www.moneyguider.co.kr)에, API 는 다른 사이트(moneyguide.wandergo.net)에 있다.
* 이 조합에서 인증 쿠키는 제3자 쿠키가 되어 Safari·Chrome 시크릿 모드 등에서 차단되므로
* 쿠키 방식(.claude/rules/security.md 의 기본값)을 쓸 수 없다.
*
* 대신 토큰이 페이지 스크립트에 노출되는 점을 아래로 보완한다.
* - 화면은 토큰을 sessionStorage 에만 둔다 (탭을 닫으면 사라진다)
* - 리프레시 토큰 수명을 짧게(기본 12시간) 둔다
* - 가드가 요청마다 세션·계정 지문을 확인해 로그아웃·비밀번호 변경을 바로 반영한다
*/
const DEFAULT_ACCESS_TTL_MS = 3_600_000; // 1시간
const DEFAULT_REFRESH_TTL_MS = 12 * 3_600_000; // 12시간
/**
* 관리자 계정 — env 에 하나만 둔다 (여러 사람이 함께 쓴다).
* 비밀값이므로 backend/.env.{env} 에 적는다 (루트 .env 아님).
*/
export const ADMIN_USERNAME_KEY = 'ADMIN_USERNAME';
export const ADMIN_PASSWORD_KEY = 'ADMIN_PASSWORD';
/** env 비밀번호 최소 길이 — env 검증이 강제한다 */
export const ADMIN_PASSWORD_MIN_LENGTH = 12;
/** 로그인 시도 제한 — 1분에 5회 (브루트포스 방지, 전역 한도보다 엄격하게) */
export const LOGIN_THROTTLE = { default: { ttl: 60_000, limit: 5 } };
/** Swagger 의 Bearer 인증 이름 (main.ts 의 addBearerAuth 기본값) */
export const ADMIN_BEARER_AUTH = 'bearer';
export type AdminTokenType = 'access' | 'refresh';
export interface AdminTokenPayload {
/** 관리자 아이디 (env ADMIN_USERNAME) */
sub: string;
typ: AdminTokenType;
/** 계정 지문 — env 아이디·비밀번호가 바뀌면 달라져 모든 토큰이 무효가 된다 */
cred: string;
/** AdminSession.id — 로그아웃은 이 세션만 끊는다 (공유 계정의 다른 기기는 유지) */
sid: string;
}
export function accessTokenTtlMs(): number {
return parseDurationMs(
process.env.JWT_ACCESS_EXPIRES_IN,
DEFAULT_ACCESS_TTL_MS,
);
}
export function refreshTokenTtlMs(): number {
return parseDurationMs(
process.env.JWT_REFRESH_EXPIRES_IN,
DEFAULT_REFRESH_TTL_MS,
);
}
@@ -0,0 +1,95 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
HttpStatus,
Post,
Req,
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 { CurrentAdmin } from '@/common/decorators/current-admin.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { AdminAuthGuard } 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';
import { AdminAuthService } from './admin-auth.service';
import { AdminResponseDto } from './dto/admin-response.dto';
import { AdminSessionDto, RenewSessionDto } from './dto/admin-session.dto';
import { LoginDto } from './dto/login.dto';
/**
* 관리자 세션 — 로그인·갱신·로그아웃.
* 관리자 화면과 API 가 다른 사이트라 쿠키를 쓸 수 없어 토큰을 본문으로 내려준다
* (이유와 보완책은 admin-auth.constants.ts 참조).
*/
@ApiTags('Admin Session')
@Controller('admin/sessions')
export class AdminAuthController {
constructor(private readonly auth: AdminAuthService) {}
@Post()
@Throttle(LOGIN_THROTTLE)
@ApiOperation({ summary: '관리자 로그인 — 액세스·리프레시 토큰 발급' })
@ApiStandardResponse(AdminSessionDto, {
status: 201,
description: '로그인 성공',
})
@ApiErrorResponse('AUTH_001', '아이디 또는 비밀번호 불일치')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
@ApiErrorResponse('REQ_001', '로그인 시도 횟수 초과 (1분 5회)')
login(
@Body() dto: LoginDto,
@Headers('user-agent') userAgent?: string,
): Promise<AdminSessionDto> {
return this.auth.login(dto.username, dto.password, userAgent);
}
@Post('renewal')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '세션 갱신 — 리프레시 토큰으로 새 토큰 발급' })
@ApiStandardResponse(AdminSessionDto, { description: '갱신 성공' })
@ApiErrorResponse('AUTH_001', '리프레시 토큰 만료·무효·로그아웃됨')
@ApiErrorResponse('VAL_001', '리프레시 토큰 누락')
renew(@Body() dto: RenewSessionDto): Promise<AdminSessionDto> {
return this.auth.renew(dto.refreshToken);
}
@Get('current')
@UseGuards(AdminAuthGuard)
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@ApiOperation({ summary: '현재 로그인한 관리자' })
@ApiStandardResponse(AdminResponseDto, { description: '조회 성공' })
@ApiErrorResponse('AUTH_001', '로그인 필요')
current(@CurrentAdmin() admin: AdminResponseDto): AdminResponseDto {
return admin;
}
@Delete()
@UseGuards(AdminAuthGuard)
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform()
@ApiOperation({
summary:
'로그아웃 — 이 기기의 세션만 끊는다 (같은 계정의 다른 기기는 유지)',
})
@ApiErrorResponse(
'AUTH_001',
'이미 만료된 세션 (화면에서 토큰만 지우면 된다)',
)
async logout(@Req() req: AdminRequest): Promise<void> {
if (!req.adminSessionId) throw new BusinessException('AUTH_001');
await this.auth.logout(req.adminSessionId);
}
}
@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
import { AdminAuthController } from './admin-auth.controller';
import { AdminAuthService } from './admin-auth.service';
import { AdminSession } from './admin-session.entity';
@Module({
imports: [
TypeOrmModule.forFeature([AdminSession]),
JwtModule.registerAsync({
inject: [ConfigService],
// 운영에서는 env 검증이 JWT_SECRET 을 필수로 요구한다.
// 개발에서 비어 있으면 서명 시점에 실패(SYS_001)하므로 backend/.env.development 에 넣을 것.
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET'),
}),
}),
],
controllers: [AdminAuthController],
providers: [AdminAuthService, AdminAuthGuard],
// 다른 모듈의 컨트롤러가 @UseGuards(AdminAuthGuard) 를 쓰려면 서비스와 가드가 필요하다
exports: [AdminAuthService, AdminAuthGuard],
})
export class AdminAuthModule {}
@@ -0,0 +1,208 @@
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { randomUUID } from 'crypto';
import type { Repository } from 'typeorm';
import { AdminAuthService } from './admin-auth.service';
import { AdminSession } from './admin-session.entity';
const USERNAME = 'moneyguider';
const PASSWORD = 'correct-horse-battery';
const SECRET = 'test-secret';
function setup(env: Record<string, string> = {}) {
// env 는 테스트 중에 바꿀 수 있게 객체로 둔다 (비밀번호 변경 시나리오)
const values: Record<string, string> = {
ADMIN_USERNAME: USERNAME,
ADMIN_PASSWORD: PASSWORD,
JWT_SECRET: SECRET,
...env,
};
const config = { get: (key: string) => values[key] };
// 세션 저장소 — 메모리
const store = new Map<string, AdminSession>();
const sessions = {
create: jest.fn((row: Partial<AdminSession>) => row),
save: jest.fn((row: AdminSession) => {
row.createdAt ??= new Date();
store.set(row.id, row);
return Promise.resolve(row);
}),
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);
if (row) Object.assign(row, patch);
return Promise.resolve();
}),
delete: jest.fn(() => Promise.resolve({ affected: 0 })),
};
const service = new AdminAuthService(
sessions as unknown as Repository<AdminSession>,
new JwtService({ secret: SECRET }),
config as unknown as ConfigService,
);
return { service, store, values };
}
describe('AdminAuthService (env 단일 계정)', () => {
it('env 계정으로 로그인하면 세션을 만들고 토큰·만료 시각을 돌려준다', async () => {
const { service, store } = setup();
const session = await service.login(USERNAME, PASSWORD, 'Mozilla/5.0');
expect(session.admin).toEqual({ username: USERNAME });
expect(Date.parse(session.refreshTokenExpiresAt)).toBeGreaterThan(
Date.parse(session.accessTokenExpiresAt),
);
expect(store.size).toBe(1);
expect([...store.values()][0].userAgent).toBe('Mozilla/5.0');
const verified = await service.verifyAccessToken(session.accessToken);
expect(verified).toEqual({
admin: { username: USERNAME },
sessionId: [...store.keys()][0],
});
});
it('토큰에 비밀번호가 들어가지 않는다', async () => {
const { service } = setup();
const session = await service.login(USERNAME, PASSWORD);
const payload = Buffer.from(
session.accessToken.split('.')[1],
'base64url',
).toString('utf8');
expect(payload).not.toContain(PASSWORD);
});
it('아이디나 비밀번호가 틀리면 같은 AUTH_001, 세션을 만들지 않는다', async () => {
const { service, store } = setup();
await expect(service.login(USERNAME, 'wrong')).rejects.toMatchObject({
code: 'AUTH_001',
});
await expect(service.login('nobody', PASSWORD)).rejects.toMatchObject({
code: 'AUTH_001',
});
expect(store.size).toBe(0);
});
it('env 에 계정이 없으면 아무도 로그인할 수 없다 (빈 값으로도 안 된다)', async () => {
const { service } = setup({ ADMIN_USERNAME: '', ADMIN_PASSWORD: '' });
await expect(service.login('', '')).rejects.toMatchObject({
code: 'AUTH_001',
});
});
it('한 계정으로 여러 기기에서 로그인하고, 한 기기의 로그아웃은 그 기기만 끊는다', async () => {
const { service } = setup();
const deviceA = await service.login(USERNAME, PASSWORD);
const deviceB = await service.login(USERNAME, PASSWORD);
const a = await service.verifyAccessToken(deviceA.accessToken);
await service.logout(a.sessionId);
await expect(
service.verifyAccessToken(deviceA.accessToken),
).rejects.toMatchObject({ code: 'AUTH_001' });
await expect(service.renew(deviceA.refreshToken)).rejects.toMatchObject({
code: 'AUTH_001',
});
await expect(
service.verifyAccessToken(deviceB.accessToken),
).resolves.toMatchObject({ admin: { username: USERNAME } });
await expect(service.renew(deviceB.refreshToken)).resolves.toBeDefined();
});
it('env 비밀번호를 바꾸면 모든 기기가 끊기고, 새 비밀번호로만 로그인된다', async () => {
const { service, values } = setup();
const deviceA = await service.login(USERNAME, PASSWORD);
const deviceB = await service.login(USERNAME, PASSWORD);
values.ADMIN_PASSWORD = 'a-brand-new-password'; // env 변경 후 재시작한 상황
for (const device of [deviceA, deviceB]) {
await expect(
service.verifyAccessToken(device.accessToken),
).rejects.toMatchObject({ code: 'AUTH_001' });
await expect(service.renew(device.refreshToken)).rejects.toMatchObject({
code: 'AUTH_001',
});
}
await expect(service.login(USERNAME, PASSWORD)).rejects.toMatchObject({
code: 'AUTH_001',
});
await expect(
service.login(USERNAME, 'a-brand-new-password'),
).resolves.toBeDefined();
});
it('리프레시 토큰으로 같은 세션의 새 토큰을 받고 세션 만료가 늦춰진다', async () => {
const { service, store } = setup();
const session = await service.login(USERNAME, PASSWORD);
const row = [...store.values()][0];
row.expiresAt = new Date(Date.now() + 1000); // 곧 만료될 세션
const renewed = await service.renew(session.refreshToken);
const verified = await service.verifyAccessToken(renewed.accessToken);
expect(verified.sessionId).toBe(row.id);
expect(store.size).toBe(1);
expect(row.expiresAt.getTime()).toBeGreaterThan(Date.now() + 60_000);
});
it('만료된 세션의 토큰은 거부한다', async () => {
const { service, store } = setup();
const session = await service.login(USERNAME, PASSWORD);
[...store.values()][0].expiresAt = new Date(Date.now() - 1);
await expect(
service.verifyAccessToken(session.accessToken),
).rejects.toMatchObject({ code: 'AUTH_001' });
});
it('액세스·리프레시 토큰을 서로의 자리에 쓸 수 없다', async () => {
const { service } = setup();
const session = await service.login(USERNAME, PASSWORD);
await expect(
service.verifyAccessToken(session.refreshToken),
).rejects.toMatchObject({ code: 'AUTH_001' });
await expect(service.renew(session.accessToken)).rejects.toMatchObject({
code: 'AUTH_001',
});
});
it('위조됐거나 이전 형식(계정 지문 없음)의 토큰은 거부한다', async () => {
const { service } = setup();
await expect(service.verifyAccessToken(null)).rejects.toMatchObject({
code: 'AUTH_001',
});
const forged = new JwtService({ secret: 'other-secret' }).sign({
sub: USERNAME,
typ: 'access',
cred: 'x',
sid: randomUUID(),
});
await expect(service.verifyAccessToken(forged)).rejects.toMatchObject({
code: 'AUTH_001',
});
// DB 계정 시절 형식 (sub 가 숫자, cred 없음)
const legacy = new JwtService({ secret: SECRET }).sign({
sub: 1,
username: USERNAME,
typ: 'access',
ver: 0,
sid: randomUUID(),
});
await expect(service.verifyAccessToken(legacy)).rejects.toMatchObject({
code: 'AUTH_001',
});
});
});
@@ -0,0 +1,225 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { LessThan, Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { credentialFingerprint, safeEqual } from '@/shared/utils/credential';
import {
ADMIN_PASSWORD_KEY,
ADMIN_USERNAME_KEY,
AdminTokenPayload,
AdminTokenType,
accessTokenTtlMs,
refreshTokenTtlMs,
} from './admin-auth.constants';
import { AdminSession } from './admin-session.entity';
import { AdminResponseDto } from './dto/admin-response.dto';
import { AdminSessionDto } from './dto/admin-session.dto';
/** User-Agent 저장 길이 (엔티티 컬럼과 맞춘다) */
const USER_AGENT_MAX_LENGTH = 200;
/** 끝난 세션(만료·로그아웃)을 이 기간이 지나면 지운다 — 최근 기록은 확인용으로 잠시 남긴다 */
const ENDED_SESSION_RETENTION_MS = 7 * 86_400_000;
/** 가드가 확인한 요청의 주인 */
export interface VerifiedAdmin {
admin: AdminResponseDto;
sessionId: string;
}
interface AdminAccount {
username: string;
password: string;
/** 현재 env 계정의 지문 — 토큰의 cred 와 같아야 유효하다 */
fingerprint: string;
}
/**
* 관리자 인증 — env(ADMIN_USERNAME / ADMIN_PASSWORD)에 둔 단일 계정.
*
* 계정 하나를 여러 사람이 함께 쓰는 것을 전제로, 로그인한 기기마다 세션을 따로 둔다.
* - 로그아웃: 그 기기의 세션만 끊는다
* - env 비밀번호를 바꾸고 재시작: 계정 지문이 달라져 모든 기기가 끊긴다
*/
@Injectable()
export class AdminAuthService {
private readonly logger = new Logger(AdminAuthService.name);
constructor(
@InjectRepository(AdminSession)
private readonly sessions: Repository<AdminSession>,
private readonly jwt: JwtService,
private readonly config: ConfigService,
) {}
/** 아이디·비밀번호를 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',
},
});
}
await this.removeEndedSessions();
const now = Date.now();
const session = await this.sessions.save(
this.sessions.create({
id: randomUUID(),
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}`);
return this.issue(account, session.id, now);
}
/** 리프레시 토큰으로 같은 세션의 새 토큰 쌍을 발급하고 세션 만료를 늦춘다. */
async renew(refreshToken: string): Promise<AdminSessionDto> {
const payload = await this.verify(refreshToken, 'refresh');
const { account, 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);
}
/** 로그아웃 — 이 세션만 끊는다. 같은 계정으로 로그인한 다른 기기는 그대로다. */
async logout(sessionId: string): Promise<void> {
await this.sessions.update({ id: sessionId }, { revokedAt: new Date() });
this.logger.log(`관리자 로그아웃: session=${sessionId}`);
}
/**
* 액세스 토큰을 검증한다 — 가드에서 쓴다.
* 서명·만료뿐 아니라 세션과 계정 지문도 확인해, 로그아웃·비밀번호 변경이 바로 반영된다.
* (토큰이 페이지 스크립트에 노출되는 Bearer 방식이라 즉시 무효화가 필요하다)
*/
async verifyAccessToken(token: string | null): Promise<VerifiedAdmin> {
const payload = await this.verify(token, 'access');
const { account, session } = await this.findActive(payload);
return { admin: { username: account.username }, sessionId: session.id };
}
/** env 계정. 아이디·비밀번호가 비어 있으면 null — 아무도 로그인할 수 없다. */
private account(): AdminAccount | 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') ?? '';
if (!username || !password || !secret) {
this.logger.warn(
`관리자 계정이 설정되지 않았습니다 — backend/.env 의 ${ADMIN_USERNAME_KEY}·${ADMIN_PASSWORD_KEY}·JWT_SECRET 을 확인하세요`,
);
return null;
}
return {
username,
password,
fingerprint: credentialFingerprint(username, password, secret),
};
}
/** 토큰이 가리키는 세션과 계정이 아직 유효한지 확인한다. */
private async findActive(
payload: AdminTokenPayload,
): Promise<{ account: AdminAccount; session: AdminSession }> {
const account = this.account();
// env 아이디·비밀번호가 바뀌었으면 이전에 발급한 모든 토큰이 무효다
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');
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');
}
return { account, session };
}
private rejected(reason: string): BusinessException {
return new BusinessException('AUTH_001', { context: { reason } });
}
/** 끝난 세션 중 보관 기간이 지난 것을 지운다 (로그인 때마다 가볍게 정리) */
private async removeEndedSessions(): Promise<void> {
const cutoff = new Date(Date.now() - ENDED_SESSION_RETENTION_MS);
await this.sessions.delete({ expiresAt: LessThan(cutoff) });
await this.sessions.delete({ revokedAt: LessThan(cutoff) });
}
private async issue(
account: AdminAccount,
sessionId: string,
now: number,
): Promise<AdminSessionDto> {
const base = {
sub: account.username,
cred: account.fingerprint,
sid: sessionId,
};
const accessTtlMs = accessTokenTtlMs();
const refreshTtlMs = refreshTokenTtlMs();
const [accessToken, refreshToken] = await Promise.all([
this.jwt.signAsync(
{ ...base, typ: 'access' } satisfies AdminTokenPayload,
{ expiresIn: Math.floor(accessTtlMs / 1000) },
),
this.jwt.signAsync(
{ ...base, typ: 'refresh' } satisfies AdminTokenPayload,
{ expiresIn: Math.floor(refreshTtlMs / 1000) },
),
]);
return {
admin: { username: account.username },
accessToken,
accessTokenExpiresAt: new Date(now + accessTtlMs).toISOString(),
refreshToken,
refreshTokenExpiresAt: new Date(now + refreshTtlMs).toISOString(),
};
}
private async verify(
token: string | null,
type: AdminTokenType,
): Promise<AdminTokenPayload> {
if (!token) throw this.rejected('no_token');
let payload: AdminTokenPayload;
try {
payload = await this.jwt.verifyAsync<AdminTokenPayload>(token);
} catch {
throw this.rejected('invalid_token');
}
// 리프레시 토큰을 액세스 토큰 자리에 쓰는 것을 막는다 (반대도 마찬가지)
if (payload.typ !== type) throw this.rejected('wrong_token_type');
return payload;
}
}
@@ -0,0 +1,41 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryColumn,
} from 'typeorm';
/**
* 관리자 로그인 세션 — 로그인한 기기(브라우저)마다 한 행.
*
* 관리자 계정(env 단일 계정) 하나를 여러 사람이 함께 쓰므로, 로그아웃은 그 기기의 세션만 끊어야 한다.
* (계정 단위로 끊으면 한 사람이 로그아웃할 때 모두가 함께 로그아웃된다)
* 모든 세션을 한 번에 끊는 것은 env 비밀번호 변경(토큰의 계정 지문)이 맡는다.
*/
@Entity({ name: 'admin_sessions' })
export class AdminSession {
/** 앱에서 crypto.randomUUID() 로 만든다 — DB 의 uuid 확장(uuid-ossp)에 기대지 않는다 */
@PrimaryColumn({ name: 'id', type: 'uuid' })
id!: string;
/** 어느 브라우저에서 로그인했는지 — 확인·감사용 (개인정보 아님, 길이 제한) */
@Column({ name: 'user_agent', type: 'varchar', length: 200, nullable: true })
userAgent!: string | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
/** 마지막으로 세션을 갱신한 시각 */
@Column({ name: 'last_used_at', type: 'timestamptz' })
lastUsedAt!: Date;
/** 리프레시 토큰 만료 시각 — 지나면 다시 로그인해야 한다 */
@Index()
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt!: Date;
/** 로그아웃한 시각 — 값이 있으면 이 세션의 토큰은 모두 무효 */
@Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
revokedAt!: Date | null;
}
@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
/** 로그인한 관리자 */
export class AdminResponseDto {
@ApiProperty({
description: '관리자 아이디 (env ADMIN_USERNAME)',
example: 'admin',
})
username!: string;
}
@@ -0,0 +1,40 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { AdminResponseDto } from './admin-response.dto';
/** JWT 길이 상한 — 비정상적으로 긴 입력을 검증 전에 거른다 */
const TOKEN_MAX_LENGTH = 2048;
/** 로그인·갱신 응답 — 화면은 토큰을 sessionStorage 에 두고 Authorization 헤더로 보낸다 */
export class AdminSessionDto {
@ApiProperty({ description: '로그인한 관리자', type: AdminResponseDto })
admin!: AdminResponseDto;
@ApiProperty({ description: '액세스 토큰 — Authorization: Bearer 로 보낸다' })
accessToken!: string;
@ApiProperty({
description: '액세스 토큰 만료 시각',
example: '2026-10-05T05:00:00.000Z',
})
accessTokenExpiresAt!: string;
@ApiProperty({ description: '리프레시 토큰 — 세션 갱신에만 쓴다' })
refreshToken!: string;
@ApiProperty({
description: '리프레시 토큰 만료 시각 — 이후에는 다시 로그인해야 한다',
example: '2026-10-05T16:00:00.000Z',
})
refreshTokenExpiresAt!: string;
}
/** 세션 갱신 요청 */
export class RenewSessionDto {
@ApiProperty({ description: '로그인 때 받은 리프레시 토큰' })
@IsString({ message: '세션 정보가 올바르지 않습니다.' })
@IsNotEmpty({ message: '세션 정보가 올바르지 않습니다.' })
@MaxLength(TOKEN_MAX_LENGTH)
refreshToken!: string;
}
@@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
const USERNAME_MAX_LENGTH = 50;
/** bcrypt 는 72바이트까지만 쓰므로 그 이상은 받을 이유가 없다 */
const PASSWORD_MAX_LENGTH = 72;
export class LoginDto {
@ApiProperty({ description: '관리자 아이디', example: 'admin' })
@IsString({ message: '아이디를 입력해 주세요.' })
@IsNotEmpty({ message: '아이디를 입력해 주세요.' })
@MaxLength(USERNAME_MAX_LENGTH, { message: '아이디가 너무 깁니다.' })
username!: string;
@ApiProperty({ description: '비밀번호', example: 'a-long-password' })
@IsString({ message: '비밀번호를 입력해 주세요.' })
@IsNotEmpty({ message: '비밀번호를 입력해 주세요.' })
@MaxLength(PASSWORD_MAX_LENGTH, { message: '비밀번호가 너무 깁니다.' })
password!: string;
}
@@ -0,0 +1,129 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow';
/**
* 강의 신청 기록 — 신청 완료 화면(아임웹 /apply)이 보낸다.
*
* 회원 한 명이 같은 상품에 여러 번 들어와도 한 건만 남긴다 (회원·상품 유일).
* 이름·연락처 같은 회원 정보는 여기 두지 않는다 — imweb_members 에서 member_code 로 찾는다.
* (브라우저가 보낸 값은 위조될 수 있어서 회원 정보로 쓰지 않는다)
*/
@Entity({ name: 'member_applications' })
@Index('uq_member_applications_member_product', ['memberCode', 'productIdx'], {
unique: true,
})
@Index('idx_member_applications_cursor', ['appliedAt', 'id'])
export class MemberApplication {
/** 앱에서 crypto.randomUUID() 로 만든다 */
@PrimaryColumn({ name: 'id', type: 'uuid' })
id!: string;
@Column({ name: 'member_code', type: 'varchar', length: 40 })
memberCode!: string;
/** 신청 당시 아이디 — 회원이 탈퇴해 imweb_members 에서 사라져도 누구였는지 남긴다 */
@Column({ name: 'member_uid', type: 'varchar', length: 255 })
memberUid!: string;
/** 아임웹 상품번호 (?idx=) */
@Index()
@Column({ name: 'product_idx', type: 'varchar', length: 20 })
productIdx!: string;
/** 신청 화면에 표시된 상품명 — 표시용 참고값 (브라우저가 보낸 값) */
@Column({
name: 'product_name',
type: 'varchar',
length: 200,
nullable: true,
})
productName!: string | null;
@Column({ name: 'applied_at', type: 'timestamptz' })
appliedAt!: Date;
/** 오픈카톡 입장 버튼을 처음 누른 시각 — 실제 입장 여부는 알 수 없다 */
@Column({ name: 'kakao_clicked_at', type: 'timestamptz', nullable: true })
kakaoClickedAt!: Date | null;
/** 신청 순간의 유입정보 원본 (imweb-header-top.html 이 저장해 둔 값, 파이프 형식) */
@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;
/*
* 신청 때 최종 유입 — 신청 직전에 마지막으로 들어온 경로 (신청을 일으킨 유입).
* 위 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;
}
@@ -0,0 +1,127 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
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 { 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 {
ApplicationInflowStatsQueryDto,
ApplicationListQueryDto,
} from './dto/application-query.dto';
import {
ApplicationInflowStatsResponseDto,
ApplicationProductDto,
ApplicationResponseDto,
} from './dto/application-response.dto';
import {
CreateApplicationDto,
CreateApplicationResultDto,
} from './dto/create-application.dto';
/**
* 신청 기록 — 아임웹 신청 완료 화면(공개 페이지)이 부른다.
* 로그인 없이 열려 있으므로 요청 수를 엄격히 제한한다.
*/
@ApiTags('Applications')
@Controller('applications')
export class ApplicationsController {
constructor(private readonly applications: ApplicationsService) {}
@Post()
@Throttle({ default: { ttl: 60_000, limit: 10 } })
@ApiOperation({
summary: '강의 신청 기록 — 같은 회원·상품은 한 건만 (여러 번 불러도 안전)',
})
@ApiStandardResponse(CreateApplicationResultDto, {
status: 201,
description: '기록됨 (이미 있으면 기존 기록)',
})
@ApiErrorResponse('APPLY_001', '아임웹에 없는 회원')
@ApiErrorResponse('VAL_001', '입력값 오류')
@ApiErrorResponse('REQ_001', '요청 수 초과 (1분 10회)')
create(
@Body() dto: CreateApplicationDto,
): Promise<CreateApplicationResultDto> {
return this.applications.create(dto);
}
@Post(':id/kakao-clicks')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform()
@Throttle({ default: { ttl: 60_000, limit: 20 } })
@ApiOperation({ summary: '오픈카톡 버튼 클릭 기록 (처음 한 번만 남는다)' })
@ApiErrorResponse('VAL_001', 'ID 형식 오류')
async kakaoClick(
@Param('id', new ParseUUIDPipe()) id: string,
): Promise<void> {
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();
}
@Get('inflow-stats')
@ApiOperation({
summary:
'신청 유입 집계 — 신청 때 최종·최초 유입 또는 가입 경로 기준으로 신청 수를 센다',
})
@ApiStandardResponse(ApplicationInflowStatsResponseDto, {
description: '집계 성공',
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '필터·기준 형식 오류')
inflowStats(
@Query() query: ApplicationInflowStatsQueryDto,
): Promise<ApplicationInflowStatsResponseDto> {
return this.applications.inflowStats(query);
}
}
@@ -0,0 +1,29 @@
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 { ApplicationsService } from './applications.service';
/** 강의 신청 기록 — 신청 완료 화면이 보내고, 관리자 화면에서 본다 */
@Module({
imports: [
TypeOrmModule.forFeature([MemberApplication, ImwebMember]),
AdminAuthModule,
ImwebModule,
MembersModule,
// 신청을 업체 링크 실적으로 귀속한다
PartnersModule,
],
controllers: [ApplicationsController, AdminApplicationsController],
providers: [ApplicationsService],
})
export class ApplicationsModule {}
@@ -0,0 +1,244 @@
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';
const MEMBER = {
memberCode: 'm20261005abcdef',
uid: 'user@test.dev',
} as ImwebMember;
function setup(member: ImwebMember | null = MEMBER) {
const store: MemberApplication[] = [];
const applications = {
create: jest.fn((row: MemberApplication) => row),
findOne: jest.fn(({ where }: { where: Partial<MemberApplication> }) =>
Promise.resolve(
store.find(
(r) =>
r.memberCode === where.memberCode &&
r.productIdx === where.productIdx,
) ?? null,
),
),
insert: jest.fn((row: MemberApplication) => {
store.push(row);
return Promise.resolve();
}),
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, attribution, calls };
}
describe('ApplicationsService.create', () => {
it('확인된 회원의 신청을 기록하고 유입정보를 풀어 둔다', async () => {
const { service, store } = setup();
const result = await service.create({
memberUid: 'user@test.dev',
productIdx: '52',
productName: ' 무료 강의 ',
inflow: 'src=facebook|med=paid|cmp=fall|cnt=video_a|jd=2026-10-05',
});
expect(result.isNew).toBe(true);
expect(store[0]).toMatchObject({
id: result.id,
memberCode: MEMBER.memberCode,
memberUid: MEMBER.uid, // 브라우저가 보낸 값이 아니라 아임웹 회원 값
productIdx: '52',
productName: '무료 강의',
inflowSource: 'facebook',
inflowMedium: 'paid',
inflowCampaign: 'fall',
inflowContent: 'video_a',
kakaoClickedAt: null,
});
});
it('같은 회원·상품은 다시 불러도 한 건만 남기고 기존 ID 를 돌려준다', async () => {
const { service, store } = setup();
const dto = { memberUid: 'user@test.dev', productIdx: '52' };
const first = await service.create(dto);
const second = await service.create(dto);
expect(second).toEqual({ id: first.id, isNew: false });
expect(store).toHaveLength(1);
});
it('같은 회원이라도 다른 상품은 따로 기록한다', async () => {
const { service, store } = setup();
await service.create({ memberUid: 'user@test.dev', productIdx: '52' });
await service.create({ memberUid: 'user@test.dev', productIdx: '60' });
expect(store).toHaveLength(2);
});
it('아임웹에 없는 회원이면 APPLY_001 이고 아무것도 남기지 않는다', async () => {
const { service, store } = setup(null);
await expect(
service.create({ memberUid: 'nobody@test.dev', productIdx: '52' }),
).rejects.toMatchObject({ code: 'APPLY_001' });
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();
await service.create({
memberUid: 'user@test.dev',
productIdx: '52',
inflow: '아무 글자',
});
expect(store[0]).toMatchObject({ inflowRaw: null, inflowSource: null });
});
it('동시에 두 번 들어와 유일 제약에 걸리면 먼저 들어간 기록을 돌려준다', async () => {
const { service, store, applications } = setup();
const winner = {
id: 'winner-id',
memberCode: MEMBER.memberCode,
productIdx: '52',
};
applications.insert.mockImplementationOnce(() => {
store.push(winner as MemberApplication); // 다른 요청이 먼저 넣었다
const error = new QueryFailedError('INSERT', [], new Error('duplicate'));
(error.driverError as unknown as { code: string }).code = '23505';
return Promise.reject(error);
});
const result = await service.create({
memberUid: 'user@test.dev',
productIdx: '52',
});
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();
});
});
@@ -0,0 +1,400 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import {
IsNull,
QueryFailedError,
Repository,
SelectQueryBuilder,
} 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 { 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 { PartnerAttributionService } from '@/modules/partners/partner-attribution.service';
import { InflowStatus, parseInflow } from '@/shared/utils/inflow';
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
import { MemberApplication } from './application.entity';
import {
ApplicationFilterDto,
ApplicationInflowBasis,
ApplicationInflowStatsQueryDto,
ApplicationListQueryDto,
} from './dto/application-query.dto';
import {
ApplicationInflowStatsResponseDto,
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';
/** 집계 결과 최대 줄 수 (회원 유입 집계와 같다) */
const STATS_MAX_ITEMS = 100;
/**
* 유입 기준·항목 → SQL 식 (항목 이름이 곧 컬럼 접미사다: inflow_source 등).
* 가입 경로는 가입 때 기록된 값만 쓴다 — 가입 후 회원정보 수정 화면에서 채워진 값은 가입 경로가 아니다.
* 값은 모두 enum 으로 검증된 것이라 문자열로 넣어도 안전하다.
*/
function inflowExpression(
basis: ApplicationInflowBasis,
field: InflowGroupBy,
): string {
if (basis === 'apply_first') return `${ALIAS}.inflow_${field}`;
if (basis === 'apply_last') return `${ALIAS}.inflow_last_${field}`;
return `CASE WHEN ${MEMBER_ALIAS}.inflow_status = '${InflowStatus.AT_JOIN}' THEN ${MEMBER_ALIAS}.inflow_${field} END`;
}
function escapeLike(value: string): string {
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
}
function isUniqueViolation(error: unknown): boolean {
return (
error instanceof QueryFailedError &&
(error.driverError as { code?: string } | undefined)?.code ===
PG_UNIQUE_VIOLATION
);
}
/**
* 강의 신청 기록.
*
* 신청 완료 화면은 공개 페이지라 보낸 사람이 정말 그 회원인지는 확인할 수 없다.
* 그래서 (1) 아임웹에 실제로 있는 회원인지 확인하고 (2) 회원·상품당 한 건만 남기며
* (3) 화면에 보이는 회원 정보는 브라우저가 보낸 값이 아니라 아임웹에서 받은 값만 쓴다.
*/
@Injectable()
export class ApplicationsService {
private readonly logger = new Logger(ApplicationsService.name);
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,
) {}
/** 신청 기록 — 같은 회원·상품이면 기존 기록을 돌려준다 (몇 번을 불러도 한 건) */
async create(dto: CreateApplicationDto): Promise<CreateApplicationResultDto> {
const member = await this.lookup.find(dto.memberUid);
if (!member) {
// 아이디(이메일)는 로그에 남기지 않는다
throw new BusinessException('APPLY_001', {
context: { reason: 'member_not_found', productIdx: dto.productIdx },
});
}
// 업체 링크 클릭 연결을 먼저 남겨야 바로 아래 귀속에서 찾을 수 있다
await this.recordPartnerRefs(member.memberCode, dto.partnerRefs);
const existing = await this.findExisting(member.memberCode, dto.productIdx);
if (existing) {
// 연결 기록이 신청보다 늦게 들어온 경우를 위해 다시 시도한다 (이미 귀속됐으면 그대로)
await this.attributeToPartner(existing);
return { id: existing.id, isNew: false };
}
const now = new Date();
const inflow = parseInflow(
dto.inflow,
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,
memberUid: member.uid,
productIdx: dto.productIdx,
productName: dto.productName?.trim() || null,
appliedAt: now,
kakaoClickedAt: null,
inflowRaw: inflow.source ? (dto.inflow?.trim() ?? null) : null,
inflowSource: inflow.source,
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 {
await this.applications.insert(row);
} catch (error) {
// 같은 회원이 동시에 두 번 보낸 경우 — 먼저 들어간 기록을 돌려준다
if (!isUniqueViolation(error)) throw error;
const winner = await this.findExisting(member.memberCode, dto.productIdx);
if (winner) return { id: winner.id, isNew: false };
throw error;
}
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(
{ id, kakaoClickedAt: IsNull() },
{ kakaoClickedAt: new Date() },
);
}
async findMany(
query: ApplicationListQueryDto,
): Promise<PaginatedResult<ApplicationResponseDto>> {
const base = this.filtered(query);
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,
},
lastInflow: {
source: row.inflowLastSource,
medium: row.inflowLastMedium,
campaign: row.inflowLastCampaign,
content: row.inflowLastContent,
},
};
}),
{
hasNext,
nextCursor:
hasNext && last
? encodeTimeCursor({
time: last.appliedAt.toISOString(),
key: last.id,
})
: null,
total,
},
);
}
/**
* 신청 유입 집계 — 같은 신청을 '신청 때 최종 / 최초 유입' 또는 '가입 경로'로 나눠 센다.
* 기준을 바꿔 보면 "가입은 네이버로 했지만 이번 신청은 페이스북 광고로 왔다" 같은 차이가 보인다.
*/
async inflowStats(
query: ApplicationInflowStatsQueryDto,
): Promise<ApplicationInflowStatsResponseDto> {
const base = this.filtered(query);
const total = await base.clone().getCount();
const rows = await base
.clone()
.select(inflowExpression(query.basis, query.groupBy), 'key')
.addSelect('COUNT(*)', 'count')
.groupBy('key')
.orderBy('count', 'DESC')
.addOrderBy('key', 'ASC', 'NULLS LAST')
.limit(STATS_MAX_ITEMS)
.getRawMany<{ key: string | null; count: string }>();
return {
basis: query.basis,
groupBy: query.groupBy,
total,
items: rows.map((row) => ({ key: row.key, count: Number(row.count) })),
};
}
/** 목록·집계 공통 필터 — 회원 검색·가입 경로를 위해 동기화된 회원과 잇는다 */
private filtered(
filter: ApplicationFilterDto &
Partial<
Pick<
ApplicationListQueryDto,
'source' | 'inflowBasis' | 'inflowField' | 'inflowValue' | 'q'
>
>,
): SelectQueryBuilder<MemberApplication> {
const qb = this.applications
.createQueryBuilder(ALIAS)
// 탈퇴 회원은 회원 정보 없이 남는다
.leftJoin(
ImwebMember,
MEMBER_ALIAS,
`${MEMBER_ALIAS}.member_code = ${ALIAS}.member_code`,
);
if (filter.productIdx) {
qb.andWhere(`${ALIAS}.productIdx = :productIdx`, {
productIdx: filter.productIdx,
});
}
if (filter.appliedFrom) {
qb.andWhere(`${ALIAS}.appliedAt >= :appliedFrom`, {
appliedFrom: filter.appliedFrom,
});
}
if (filter.appliedTo) {
qb.andWhere(`${ALIAS}.appliedAt < :appliedTo`, {
appliedTo: filter.appliedTo,
});
}
if (filter.source) {
qb.andWhere(`${ALIAS}.inflowSource = :source`, { source: filter.source });
}
// 집계 막대를 눌러 거를 때 — 기준·항목·값이 모두 있어야 한다
if (filter.inflowBasis && filter.inflowField && filter.inflowValue) {
qb.andWhere(
`${inflowExpression(filter.inflowBasis, filter.inflowField)} = :inflowValue`,
{ inflowValue: filter.inflowValue },
);
}
if (filter.q) {
qb.andWhere(
`(${MEMBER_ALIAS}.name ILIKE :q OR ${ALIAS}.member_uid ILIKE :q OR ${MEMBER_ALIAS}.email ILIKE :q OR ${MEMBER_ALIAS}.callnum ILIKE :q)`,
{ q: `%${escapeLike(filter.q.trim())}%` },
);
}
return qb;
}
/** 상품별 신청 수 — 최근 신청이 있는 상품부터 */
async products(): Promise<ApplicationProductDto[]> {
const rows = await this.applications
.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]));
}
}
@@ -0,0 +1,130 @@
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;
/**
* 신청 유입을 어느 기록으로 볼 것인가.
* - apply_last : 신청 때 최종 유입 — 신청 직전에 마지막으로 들어온 경로 (신청을 일으킨 유입)
* - apply_first : 신청 때 최초 유입 — 신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)
* - join : 신청한 회원의 가입 경로 — 가입 때 기록된 값만 (가입 후 채워진 값은 제외)
*/
export const APPLICATION_INFLOW_BASIS = [
'apply_last',
'apply_first',
'join',
] as const;
export type ApplicationInflowBasis = (typeof APPLICATION_INFLOW_BASIS)[number];
/** 신청 목록·집계가 함께 쓰는 필터 */
export class ApplicationFilterDto {
@ApiPropertyOptional({ description: '상품번호', example: '52' })
@IsOptional()
@Matches(/^\d{1,20}$/, { message: '상품번호 형식이 올바르지 않습니다.' })
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;
}
/** 관리자 신청 내역 조회 */
export class ApplicationListQueryDto extends IntersectionType(
CursorPaginationQueryDto,
ApplicationFilterDto,
) {
@ApiPropertyOptional({
description:
'신청 때 최초 유입원 (inflowBasis 필터가 생기기 전부터 있던 값)',
example: 'facebook',
})
@IsOptional()
@IsString()
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
source?: string;
@ApiPropertyOptional({
description:
'유입 필터 기준 — apply_last(신청 때 최종) · apply_first(신청 때 최초) · join(가입 경로). inflowField·inflowValue 와 함께 쓴다',
enum: APPLICATION_INFLOW_BASIS,
})
@IsOptional()
@IsIn(APPLICATION_INFLOW_BASIS, {
message: '유입 기준 값이 올바르지 않습니다.',
})
inflowBasis?: ApplicationInflowBasis;
@ApiPropertyOptional({
description: '유입 필터 항목 — source · medium · campaign · content',
enum: INFLOW_GROUP_BY,
})
@IsOptional()
@IsIn(INFLOW_GROUP_BY, { message: '유입 항목 값이 올바르지 않습니다.' })
inflowField?: InflowGroupBy;
@ApiPropertyOptional({
description: '유입 필터 값 (집계 막대를 눌러 거를 때)',
example: 'facebook',
})
@IsOptional()
@IsString()
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
inflowValue?: string;
@ApiPropertyOptional({
description: '검색어 — 이름·아이디·이메일·연락처 부분 일치',
example: '홍길동',
})
@IsOptional()
@IsString()
@MaxLength(SEARCH_MAX_LENGTH, { message: '검색어가 너무 깁니다.' })
q?: string;
}
/** 신청 유입 집계 — 같은 신청을 신청 때 유입 / 가입 경로로 나눠 볼 수 있다 */
export class ApplicationInflowStatsQueryDto extends ApplicationFilterDto {
@ApiPropertyOptional({
description:
'집계 기준 — apply_last(신청 때 최종, 기본) · apply_first(신청 때 최초) · join(가입 경로)',
enum: APPLICATION_INFLOW_BASIS,
default: 'apply_last',
})
@IsOptional()
@IsIn(APPLICATION_INFLOW_BASIS, {
message: '유입 기준 값이 올바르지 않습니다.',
})
basis: ApplicationInflowBasis = 'apply_last';
@ApiPropertyOptional({
description: '집계 항목',
enum: INFLOW_GROUP_BY,
default: 'source',
})
@IsOptional()
@IsIn(INFLOW_GROUP_BY, { message: '집계 기준 값이 올바르지 않습니다.' })
groupBy: InflowGroupBy = 'source';
}
@@ -0,0 +1,124 @@
import { ApiProperty } from '@nestjs/swagger';
import { INFLOW_GROUP_BY } from '@/modules/members/dto/member-query.dto';
import type { InflowGroupBy } from '@/modules/members/dto/member-query.dto';
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
import { APPLICATION_INFLOW_BASIS } from './application-query.dto';
import type { ApplicationInflowBasis } from './application-query.dto';
/** 신청 순간의 유입정보 */
export class ApplicationInflowDto {
@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:
'신청 때 최초 유입 — 신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)',
type: ApplicationInflowDto,
})
inflow!: ApplicationInflowDto;
@ApiProperty({
description:
'신청 때 최종 유입 — 신청 직전에 마지막으로 들어온 경로. 유입이 한 번뿐이면 최초와 같다',
type: ApplicationInflowDto,
})
lastInflow!: ApplicationInflowDto;
}
/** 상품별 신청 수 — 필터 칩에 쓴다 */
export class ApplicationProductDto {
@ApiProperty({ description: '상품번호', example: '52' })
productIdx!: string;
@ApiProperty({
description: '가장 최근 신청에 표시된 상품명',
nullable: true,
})
productName!: string | null;
@ApiProperty({ description: '신청 수', example: 120 })
count!: number;
}
/** 신청 유입 집계 한 줄 */
export class ApplicationInflowStatItemDto {
@ApiProperty({
description: '집계 값 (값이 없으면 null)',
nullable: true,
example: 'facebook',
})
key!: string | null;
@ApiProperty({ description: '신청 수', example: 42 })
count!: number;
}
/** 신청 유입 집계 결과 */
export class ApplicationInflowStatsResponseDto {
@ApiProperty({
description: '집계 기준',
enum: APPLICATION_INFLOW_BASIS,
example: 'apply_last',
})
basis!: ApplicationInflowBasis;
@ApiProperty({
description: '집계 항목',
enum: INFLOW_GROUP_BY,
example: 'source',
})
groupBy!: InflowGroupBy;
@ApiProperty({ description: '필터에 맞는 전체 신청 수', example: 310 })
total!: number;
@ApiProperty({
description: '신청 수 많은 순 (최대 100개, 값 없음은 key=null)',
type: [ApplicationInflowStatItemDto],
})
items!: ApplicationInflowStatItemDto[];
}
@@ -0,0 +1,92 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMaxSize,
IsArray,
IsNotEmpty,
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';
const MEMBER_ID_MAX_LENGTH = 255;
const PRODUCT_NAME_MAX_LENGTH = 200;
/** imweb-header-top.html 이 만드는 값은 200자 남짓 — 넉넉히 두되 무한정 받지 않는다 */
const INFLOW_RAW_MAX_LENGTH = 1000;
/** 신청 완료 화면이 보내는 신청 기록 */
export class CreateApplicationDto {
@ApiProperty({
description: '아임웹 회원 아이디 또는 회원 코드 (window.MEMBER_UID)',
example: 'user@example.com',
})
@IsString()
@IsNotEmpty({ message: '회원 정보가 없습니다.' })
@MaxLength(MEMBER_ID_MAX_LENGTH)
memberUid!: string;
@ApiProperty({ description: '아임웹 상품번호 (?idx=)', example: '52' })
@IsString()
@Matches(/^\d{1,20}$/, { message: '상품번호 형식이 올바르지 않습니다.' })
productIdx!: string;
@ApiPropertyOptional({
description: '신청 화면에 표시된 상품명 (표시용)',
example: '[무료한정] 하루 2시간 월1000 버는 AI 효자 상품 찾기',
})
@IsOptional()
@IsString()
@MaxLength(PRODUCT_NAME_MAX_LENGTH)
productName?: string;
@ApiPropertyOptional({
description:
'신청 순간의 유입정보 (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[];
}
/** 신청 기록 결과 — 회원 정보는 돌려주지 않는다 */
export class CreateApplicationResultDto {
@ApiProperty({ description: '신청 기록 ID — 오픈카톡 클릭 기록에 쓴다' })
id!: string;
@ApiProperty({
description: '이번에 새로 기록됐는가 (같은 회원·상품이면 false)',
example: true,
})
isNew!: boolean;
}
@@ -0,0 +1,115 @@
import { ApiProperty } from '@nestjs/swagger';
import {
ImwebSyncRun,
SyncStatus,
SyncTrigger,
} from '../entities/imweb-sync-run.entity';
/** 회원 동기화 실행 기록 */
export class SyncRunDto {
@ApiProperty({ description: '실행 ID', example: 12 })
id!: number;
@ApiProperty({
description: '실행 계기',
enum: SyncTrigger,
example: SyncTrigger.MANUAL,
})
trigger!: SyncTrigger;
@ApiProperty({
description: '상태',
enum: SyncStatus,
example: SyncStatus.SUCCEEDED,
})
status!: SyncStatus;
@ApiProperty({
description: '시작 시각',
example: '2026-10-05T05:00:00.000Z',
})
startedAt!: string;
@ApiProperty({
description: '종료 시각 (진행 중이면 null)',
nullable: true,
example: '2026-10-05T05:01:30.000Z',
})
finishedAt!: string | null;
@ApiProperty({
description: '받아온 회원 수 (진행 중이면 현재까지)',
example: 1520,
})
fetchedCount!: number;
@ApiProperty({ description: '탈퇴로 정리한 회원 수', example: 3 })
removedCount!: number;
@ApiProperty({
description: '실패 시 에러 코드',
nullable: true,
example: null,
})
errorCode!: string | null;
static from(run: ImwebSyncRun): SyncRunDto {
return {
id: run.id,
trigger: run.trigger,
status: run.status,
startedAt: run.startedAt.toISOString(),
finishedAt: run.finishedAt?.toISOString() ?? null,
fetchedCount: run.fetchedCount,
removedCount: run.removedCount,
errorCode: run.errorCode,
};
}
}
/** 아임웹 연동 상태 — 관리자 화면 상단에 표시 */
export class ImwebStatusDto {
@ApiProperty({
description: '서버에 아임웹 연동 설정(env)이 되어 있는가',
example: true,
})
isConfigured!: boolean;
@ApiProperty({ description: '아임웹 계정이 연결되어 있는가', example: true })
isConnected!: boolean;
@ApiProperty({
description: '연결한 시각',
nullable: true,
example: '2026-10-05T04:00:00.000Z',
})
connectedAt!: string | null;
@ApiProperty({ description: '동기화된 회원 수', example: 1520 })
memberCount!: number;
@ApiProperty({
description: '가장 최근 동기화 실행 (진행 중일 수 있다)',
type: SyncRunDto,
nullable: true,
})
latestRun!: SyncRunDto | null;
@ApiProperty({
description: '마지막으로 성공한 동기화 — 목록 데이터의 기준 시각',
type: SyncRunDto,
nullable: true,
})
latestSucceededRun!: SyncRunDto | null;
}
/** 아임웹 동의 화면 주소 */
export class AuthorizationUrlDto {
@ApiProperty({
description:
'브라우저를 이 주소로 보내면 아임웹 동의 화면이 열린다 (10분 안에 완료)',
example: 'https://openapi.imweb.me/oauth2/authorize?responseType=code&...',
})
authorizationUrl!: 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,40 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* 아임웹 OAuth 연결 정보 — 사이트당 한 행.
*
* ⚠️ 리프레시 토큰은 회원 개인정보를 읽을 수 있는 열쇠다. 응답·로그에 절대 싣지 않는다.
*/
@Entity({ name: 'imweb_connections' })
export class ImwebConnection {
@PrimaryColumn({ name: 'site_code', type: 'varchar', length: 40 })
siteCode!: string;
/** 회원 조회에 필요한 유닛 코드 (/site-info 의 첫 유닛) */
@Column({ name: 'unit_code', type: 'varchar', length: 40 })
unitCode!: string;
@Column({ name: 'access_token', type: 'text' })
accessToken!: string;
@Column({ name: 'refresh_token', type: 'text' })
refreshToken!: string;
@Column({ name: 'access_token_expires_at', type: 'timestamptz' })
accessTokenExpiresAt!: Date;
@Column({ name: 'scope', type: 'varchar', length: 255, nullable: true })
scope!: string | null;
@CreateDateColumn({ name: 'connected_at', type: 'timestamptz' })
connectedAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,21 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
/**
* 아임웹 회원 그룹 — 동기화 때마다 아임웹 목록으로 덮어쓴다.
* 회원이 어느 그룹인지는 imweb_members.group_codes 에 코드로 들어 있다.
*/
@Entity({ name: 'imweb_groups' })
export class ImwebGroup {
@PrimaryColumn({ name: 'group_code', type: 'varchar', length: 40 })
groupCode!: string;
@Column({ name: 'title', type: 'varchar', length: 200 })
title!: string;
/** 아임웹이 주는 순서 — 관리자 화면에 아임웹과 같은 순서로 보여준다 */
@Column({ name: 'sort_order', type: 'int' })
sortOrder!: number;
@Column({ name: 'synced_at', type: 'timestamptz' })
syncedAt!: Date;
}
@@ -0,0 +1,28 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
/**
* 아임웹 회원 엑셀에서 가져온 '유입경로' 원본 값.
*
* 이 항목(사용자 정의 가입 항목)은 Open API 로 나오지 않는다. 그래서 엑셀로 받아 여기에 보관하고,
* 동기화가 회원 행을 만들 때 homePage 에 유입정보가 없으면 이 값을 쓴다.
* imweb_members 에 직접 쓰지 않는 이유: 매 동기화가 그 컬럼들을 homePage 기준으로 덮어쓴다.
*
* 탈퇴 등으로 회원이 사라져도 지우지 않는다 (가져온 원본 기록).
*/
@Entity({ name: 'imweb_member_inflow_imports' })
export class ImwebMemberInflowImport {
/** 아임웹 회원 코드 — 엑셀의 '고유키' 열 */
@PrimaryColumn({ name: 'member_code', type: 'varchar', length: 40 })
memberCode!: string;
/** '유입경로' 원본 (파이프 형식) — 파싱은 쓸 때 가입 시각과 함께 한다 */
@Column({ name: 'raw_value', type: 'text' })
rawValue!: string;
/** 가져온 파일 이름 (경로 제외) — 어느 엑셀에서 왔는지 추적용 */
@Column({ name: 'source_file', type: 'varchar', length: 255 })
sourceFile!: string;
@Column({ name: 'imported_at', type: 'timestamptz' })
importedAt!: Date;
}
@@ -0,0 +1,174 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import {
INFLOW_VALUE_MAX_LENGTH,
InflowOrigin,
InflowStatus,
} from '@/shared/utils/inflow';
/**
* 아임웹 회원 사본 — 유입경로로 필터·집계하려고 우리 DB 에 동기화해 둔다.
* (아임웹 API 는 홈페이지 값으로 검색하거나 집계할 수 없다)
*
* 원본은 아임웹이다. 이 테이블은 동기화 때마다 덮어쓰며, 여기서 수정하지 않는다.
* 아임웹에서 탈퇴한 회원은 다음 전체 동기화 때 삭제된다.
*/
@Entity({ name: 'imweb_members' })
@Index('idx_imweb_members_join_cursor', ['joinTime', 'memberCode'])
export class ImwebMember {
@PrimaryColumn({ name: 'member_code', type: 'varchar', length: 40 })
memberCode!: string;
/** 아임웹 로그인 아이디 (이메일 또는 소셜 식별자) */
@Column({ name: 'uid', type: 'varchar', length: 255 })
uid!: string;
@Column({ name: 'name', type: 'varchar', length: 100 })
name!: string;
@Column({ name: 'email', type: 'varchar', length: 255, nullable: true })
email!: string | null;
@Column({ name: 'callnum', type: 'varchar', length: 30, nullable: true })
callnum!: string | null;
/** 소셜 가입 경로 (kakao, naver 등) — 일반 가입이면 null */
@Column({
name: 'social_provider',
type: 'varchar',
length: 20,
nullable: true,
})
socialProvider!: string | null;
@Column({ name: 'join_time', type: 'timestamptz' })
joinTime!: Date;
@Column({ name: 'last_login_time', type: 'timestamptz', nullable: true })
lastLoginTime!: Date | null;
/** 소속 회원 그룹 코드 — 이름은 imweb_groups 에서 찾는다 */
@Column({
name: 'group_codes',
type: 'text',
array: true,
default: () => "'{}'",
})
groupCodes!: string[];
/** 아임웹 homePage 원본 — 파싱 규칙이 바뀌면 재동기화 없이 다시 풀 수 있게 남긴다 */
@Column({ name: 'home_page', type: 'text', nullable: true })
homePage!: string | null;
/** 유입정보 출처 — homePage 가 우선, 없으면 엑셀에서 가져온 값. 둘 다 없으면 null */
@Column({
name: 'inflow_origin',
type: 'varchar',
length: 20,
nullable: true,
})
inflowOrigin!: InflowOrigin | null;
@Index()
@Column({ name: 'inflow_status', type: 'varchar', length: 20 })
inflowStatus!: InflowStatus;
@Index()
@Column({
name: 'inflow_source',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowSource!: string | null;
@Index()
@Column({
name: 'inflow_medium',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowMedium!: string | null;
@Index()
@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;
@Column({
name: 'inflow_term',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowTerm!: string | null;
@Column({
name: 'inflow_click_id',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowClickId!: string | null;
@Column({
name: 'inflow_referrer',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowReferrer!: string | null;
@Column({
name: 'inflow_landing_path',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowLandingPath!: string | null;
@Column({
name: 'inflow_product_id',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowProductId!: string | null;
/** 최초 유입일 (KST 날짜) */
@Column({ name: 'inflow_first_touch_date', type: 'date', nullable: true })
inflowFirstTouchDate!: string | null;
@Column({
name: 'inflow_last_touch',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowLastTouch!: string | null;
/** 유입정보를 기록한 날 (KST 날짜) */
@Column({ name: 'inflow_recorded_date', type: 'date', nullable: true })
inflowRecordedDate!: string | null;
/** 마지막으로 이 회원을 본 동기화 실행 — 전체 동기화 후 이 값이 낡은 행은 탈퇴 회원이다 */
@Index()
@Column({ name: 'last_sync_run_id', type: 'int' })
lastSyncRunId!: number;
@Column({ name: 'synced_at', type: 'timestamptz' })
syncedAt!: Date;
}
@@ -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;
}
@@ -0,0 +1,44 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
export enum SyncTrigger {
MANUAL = 'manual',
SCHEDULE = 'schedule',
}
export enum SyncStatus {
RUNNING = 'running',
SUCCEEDED = 'succeeded',
FAILED = 'failed',
}
/** 회원 동기화 실행 기록 — 관리자 화면에 마지막 동기화 시각·결과를 보여준다 */
@Entity({ name: 'imweb_sync_runs' })
export class ImwebSyncRun {
@PrimaryGeneratedColumn({ name: 'id' })
id!: number;
@Column({ name: 'trigger', type: 'varchar', length: 20 })
trigger!: SyncTrigger;
@Column({ name: 'status', type: 'varchar', length: 20 })
status!: SyncStatus;
@Index()
@Column({ name: 'started_at', type: 'timestamptz' })
startedAt!: Date;
@Column({ name: 'finished_at', type: 'timestamptz', nullable: true })
finishedAt!: Date | null;
/** 아임웹에서 받아온 회원 수 */
@Column({ name: 'fetched_count', type: 'int', default: 0 })
fetchedCount!: number;
/** 아임웹에 더 이상 없어 지운 회원 수 (탈퇴) */
@Column({ name: 'removed_count', type: 'int', default: 0 })
removedCount!: number;
/** 실패 시 에러 코드 (IMWEB_002 등) — 내부 메시지는 로그에만 남긴다 */
@Column({ name: 'error_code', type: 'varchar', length: 20, nullable: true })
errorCode!: string | null;
}
@@ -0,0 +1,90 @@
import {
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} 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 { ADMIN_BEARER_AUTH } from '@/modules/admin-auth/admin-auth.constants';
import { AdminResponseDto } from '@/modules/admin-auth/dto/admin-response.dto';
import {
AuthorizationUrlDto,
ImwebStatusDto,
SyncRunDto,
} from './dto/imweb-status.dto';
import { SyncTrigger } from './entities/imweb-sync-run.entity';
import { ImwebAdminService } from './imweb-admin.service';
import { ImwebAuthService } from './imweb-auth.service';
import { ImwebSyncService } from './imweb-sync.service';
/** 아임웹 연동 관리 — 연결·해제·동기화 */
@ApiTags('Admin Imweb')
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@UseGuards(AdminAuthGuard)
@Controller('admin/imweb')
export class ImwebAdminController {
constructor(
private readonly admin: ImwebAdminService,
private readonly auth: ImwebAuthService,
private readonly sync: ImwebSyncService,
) {}
@Get('connection')
@ApiOperation({ summary: '아임웹 연동 상태와 최근 동기화 결과' })
@ApiStandardResponse(ImwebStatusDto, { description: '조회 성공' })
@ApiErrorResponse('AUTH_001', '로그인 필요')
getStatus(): Promise<ImwebStatusDto> {
return this.admin.getStatus();
}
@Post('authorizations')
@ApiOperation({ summary: '아임웹 연결 시작 — 동의 화면 주소 발급' })
@ApiStandardResponse(AuthorizationUrlDto, {
status: 201,
description: '발급 성공',
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('IMWEB_005', '서버 env 에 아임웹 설정이 없음')
async createAuthorization(
@CurrentAdmin() current: AdminResponseDto,
): Promise<AuthorizationUrlDto> {
const authorizationUrl = await this.auth.createAuthorizationUrl(
current.username,
);
return { authorizationUrl };
}
@Delete('connection')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform()
@ApiOperation({ summary: '아임웹 연결 해제 — 저장된 토큰 삭제' })
@ApiErrorResponse('AUTH_001', '로그인 필요')
async disconnect(): Promise<void> {
await this.auth.disconnect();
}
@Post('sync-runs')
@HttpCode(HttpStatus.ACCEPTED)
@ApiOperation({
summary: '회원 동기화 시작 — 바로 응답하고 백그라운드에서 진행',
})
@ApiStandardResponse(SyncRunDto, { status: 202, description: '시작됨' })
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('IMWEB_001', '아임웹 미연결')
@ApiErrorResponse('IMWEB_004', '이미 진행 중')
async startSync(): Promise<SyncRunDto> {
const run = await this.sync.start(SyncTrigger.MANUAL);
return SyncRunDto.from(run);
}
}
@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ImwebStatusDto, SyncRunDto } from './dto/imweb-status.dto';
import { ImwebMember } from './entities/imweb-member.entity';
import { ImwebConfig } from './imweb.config';
import { ImwebAuthService } from './imweb-auth.service';
import { ImwebSyncService } from './imweb-sync.service';
/** 관리자 화면용 연동 상태 조회 */
@Injectable()
export class ImwebAdminService {
constructor(
@InjectRepository(ImwebMember)
private readonly members: Repository<ImwebMember>,
private readonly auth: ImwebAuthService,
private readonly sync: ImwebSyncService,
private readonly config: ImwebConfig,
) {}
async getStatus(): Promise<ImwebStatusDto> {
const isConfigured = this.config.isConfigured();
const [connection, memberCount, latestRun, latestSucceededRun] =
await Promise.all([
isConfigured ? this.auth.findConnection() : Promise.resolve(null),
this.members.count(),
this.sync.findLatestRun(),
this.sync.findLatestSucceededRun(),
]);
return {
isConfigured,
isConnected: connection !== null,
connectedAt: connection?.connectedAt.toISOString() ?? null,
memberCount,
latestRun: latestRun ? SyncRunDto.from(latestRun) : null,
latestSucceededRun: latestSucceededRun
? SyncRunDto.from(latestSucceededRun)
: null,
};
}
}
@@ -0,0 +1,287 @@
import type { Cache } from 'cache-manager';
import type { Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { ImwebConnection } from './entities/imweb-connection.entity';
import { ImwebConfig } from './imweb.config';
import { ImwebAuthService } from './imweb-auth.service';
import { ImwebHttpClient, ImwebHttpError } from './imweb-http.client';
const HOUR_MS = 3_600_000;
function jwtExpiringIn(ms: number): string {
const payload = Buffer.from(
JSON.stringify({ exp: Math.floor((Date.now() + ms) / 1000) }),
).toString('base64url');
return `header.${payload}.signature`;
}
function connection(overrides: Partial<ImwebConnection> = {}): ImwebConnection {
return {
siteCode: 'S-site',
unitCode: 'u-unit',
accessToken: 'old-access',
refreshToken: 'old-refresh',
accessTokenExpiresAt: new Date(Date.now() + HOUR_MS),
scope: null,
connectedAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function setup(stored: ImwebConnection | null = connection()) {
let current = stored;
const repo = {
findOne: jest.fn(() => Promise.resolve(current)),
save: jest.fn((row: ImwebConnection) => {
current = row;
return Promise.resolve(row);
}),
create: jest.fn((row: ImwebConnection) => row),
delete: jest.fn(() => {
current = null;
return Promise.resolve({ affected: 1 });
}),
};
const cache = {
set: jest.fn(() => Promise.resolve()),
get: jest.fn((): Promise<unknown> => Promise.resolve(undefined)),
del: jest.fn(() => Promise.resolve(true)),
};
const http = { get: jest.fn(), postForm: jest.fn(), patchJson: jest.fn() };
const config = {
clientId: 'client-id',
clientSecret: 'client-secret',
siteCode: 'S-site',
redirectUri: 'http://localhost:3100/api/imweb/oauth/callback',
isConfigured: () => true,
};
const service = new ImwebAuthService(
repo as unknown as Repository<ImwebConnection>,
cache as unknown as Cache,
http as unknown as ImwebHttpClient,
config as unknown as ImwebConfig,
);
return { service, repo, cache, http };
}
describe('ImwebAuthService', () => {
it('동의 화면 주소에 필수 파라미터를 넣고 state 를 저장한다', async () => {
const { service, cache } = setup();
const url = new URL(await service.createAuthorizationUrl('admin'));
expect(url.origin + url.pathname).toBe(
'https://openapi.imweb.me/oauth2/authorize',
);
expect(url.searchParams.get('responseType')).toBe('code');
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 product:read order:read',
);
const state = url.searchParams.get('state');
expect(state).toBeTruthy();
expect(cache.set).toHaveBeenCalledWith(
`imweb:oauth-state:${state}`,
'admin',
expect.any(Number),
);
});
it('모르는 state 로 돌아오면 토큰을 요청하지 않고 IMWEB_003', async () => {
const { service, http } = setup();
await expect(
service.completeAuthorization('code', 'unknown-state'),
).rejects.toMatchObject({ code: 'IMWEB_003' });
expect(http.postForm).not.toHaveBeenCalled();
});
it('401 을 받으면 토큰을 갱신하고 새 토큰으로 한 번 다시 호출한다', async () => {
const { service, http, repo } = setup();
http.get
.mockRejectedValueOnce(new ImwebHttpError('/x', 401, '30102'))
.mockResolvedValueOnce({ ok: true });
http.postForm.mockResolvedValueOnce({
accessToken: jwtExpiringIn(HOUR_MS),
refreshToken: 'new-refresh',
});
const result = await service.authorizedGet('/x', {});
expect(result).toEqual({ data: { ok: true }, unitCode: 'u-unit' });
expect(http.postForm).toHaveBeenCalledWith(
'/oauth2/token',
expect.objectContaining({
grantType: 'refresh_token',
refreshToken: 'old-refresh',
}),
);
expect(http.get).toHaveBeenLastCalledWith(
'/x',
{},
expect.stringMatching(/^header\./),
);
expect(repo.save).toHaveBeenCalledWith(
expect.objectContaining({ refreshToken: 'new-refresh' }),
);
});
it('동시에 여러 요청이 401 을 받아도 갱신은 한 번만 한다', async () => {
const { service, http } = setup();
http.get.mockImplementation((_p: string, _q: object, token: string) =>
token === 'old-access'
? Promise.reject(new ImwebHttpError('/x', 401, '30101'))
: Promise.resolve('ok'),
);
http.postForm.mockResolvedValue({
accessToken: jwtExpiringIn(HOUR_MS),
refreshToken: 'new-refresh',
});
await Promise.all([
service.authorizedGet('/x', {}),
service.authorizedGet('/x', {}),
service.authorizedGet('/x', {}),
]);
expect(http.postForm).toHaveBeenCalledTimes(1);
});
it('만료 직전 토큰은 호출 전에 먼저 갱신한다', async () => {
const { service, http } = setup(
connection({ accessTokenExpiresAt: new Date(Date.now() + 10_000) }),
);
http.postForm.mockResolvedValueOnce({
accessToken: jwtExpiringIn(HOUR_MS),
refreshToken: 'new-refresh',
});
http.get.mockResolvedValueOnce('ok');
await service.authorizedGet('/x', {});
expect(http.postForm).toHaveBeenCalledTimes(1);
expect(http.get).toHaveBeenCalledTimes(1);
});
it('갱신 응답에 리프레시 토큰이 없으면 기존 것을 계속 쓴다', async () => {
const { service, http, repo } = setup(
connection({ accessTokenExpiresAt: new Date(0) }),
);
http.postForm.mockResolvedValueOnce({
accessToken: jwtExpiringIn(HOUR_MS),
refreshToken: '',
});
http.get.mockResolvedValueOnce('ok');
await service.authorizedGet('/x', {});
expect(repo.save).toHaveBeenCalledWith(
expect.objectContaining({ refreshToken: 'old-refresh' }),
);
});
it('리프레시 토큰까지 거부되면 연결을 지우고 IMWEB_001 (재연결 필요)', async () => {
const { service, http, repo } = setup(
connection({ accessTokenExpiresAt: new Date(0) }),
);
http.postForm.mockRejectedValueOnce(
new ImwebHttpError('/oauth2/token', 401, '30101'),
);
await expect(service.authorizedGet('/x', {})).rejects.toMatchObject({
code: 'IMWEB_001',
});
expect(repo.delete).toHaveBeenCalled();
expect(http.get).not.toHaveBeenCalled();
});
it('아임웹 5xx 는 IMWEB_002 로 바꿔 던지고 연결은 유지한다', async () => {
const { service, http, repo } = setup();
http.get.mockRejectedValueOnce(new ImwebHttpError('/x', 503, null));
const error: unknown = await service
.authorizedGet('/x', {})
.catch((e: unknown) => e);
expect(error).toBeInstanceOf(BusinessException);
expect(error).toMatchObject({ code: 'IMWEB_002' });
expect(repo.delete).not.toHaveBeenCalled();
});
it('연결 전이면 IMWEB_001', async () => {
const { service } = setup(null);
await expect(service.authorizedGet('/x', {})).rejects.toMatchObject({
code: 'IMWEB_001',
});
});
describe('callback 완료', () => {
function connectable(stored: ImwebConnection | null = null) {
const ctx = setup(stored);
ctx.cache.get.mockResolvedValueOnce('admin'); // 발급해 둔 state 의 관리자 아이디
ctx.http.postForm.mockResolvedValueOnce({
accessToken: jwtExpiringIn(HOUR_MS),
refreshToken: 'refresh',
scope: 'site-info:write member-info:read',
});
ctx.http.get.mockResolvedValueOnce({
siteCode: 'S-site',
unitList: [{ unitCode: 'u-unit' }],
});
return ctx;
}
it('토큰 발급 → 유닛 코드 저장 → 연동완료 처리 순으로 진행하고 state 는 한 번만 쓴다', async () => {
const { service, http, repo, cache } = connectable();
http.patchJson.mockResolvedValueOnce(true);
await service.completeAuthorization('auth-code', 'state-1');
expect(http.postForm).toHaveBeenCalledWith(
'/oauth2/token',
expect.objectContaining({
grantType: 'authorization_code',
code: 'auth-code',
}),
);
expect(repo.save).toHaveBeenCalledWith(
expect.objectContaining({
unitCode: 'u-unit',
refreshToken: 'refresh',
}),
);
expect(http.patchJson).toHaveBeenCalledWith(
'/site-info/integration-complete',
{},
expect.any(String),
);
expect(cache.del).toHaveBeenCalledWith('imweb:oauth-state:state-1');
});
it('이미 연동완료된 사이트(30128)를 다시 연결해도 성공으로 본다', async () => {
const { service, http } = connectable();
http.patchJson.mockRejectedValueOnce(
new ImwebHttpError('/site-info/integration-complete', 404, '30128'),
);
await expect(
service.completeAuthorization('auth-code', 'state-1'),
).resolves.toBeUndefined();
});
it('연동완료 처리가 실패해도 연결은 저장된다', async () => {
const { service, http, repo } = connectable();
http.patchJson.mockRejectedValueOnce(
new ImwebHttpError('/site-info/integration-complete', 403, '30103'),
);
await service.completeAuthorization('auth-code', 'state-1');
expect(repo.save).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,324 @@
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import type { Cache } from 'cache-manager';
import { randomBytes } from 'crypto';
import { Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { readJwtExpiryMs } from '@/shared/utils/jwt';
import { ImwebConnection } from './entities/imweb-connection.entity';
import {
IMWEB_API_BASE_URL,
IMWEB_AUTHORIZE_PATH,
IMWEB_INTEGRATION_COMPLETE_PATH,
IMWEB_SCOPES,
IMWEB_SITE_INFO_PATH,
IMWEB_TOKEN_PATH,
ImwebConfig,
} from './imweb.config';
import {
ImwebHttpClient,
ImwebHttpError,
QueryValue,
} from './imweb-http.client';
import { ImwebSiteInfoData, ImwebTokenData } from './imweb.types';
/** 연동완료 처리 시 '이미 완료된 앱' 응답 (연동중 상태만 완료 처리 가능) */
const ALREADY_INTEGRATED_CODE = '30128';
/** OAuth state 보관 시간 — 이 안에 아임웹 동의 화면을 마쳐야 한다 */
const OAUTH_STATE_TTL_MS = 10 * 60_000;
const OAUTH_STATE_KEY_PREFIX = 'imweb:oauth-state:';
const OAUTH_STATE_BYTES = 32;
/** 만료 직전 토큰으로 호출하다 실패하지 않도록 이만큼 일찍 갱신한다 */
const ACCESS_TOKEN_REFRESH_MARGIN_MS = 60_000;
/** 토큰에서 만료 시각을 못 읽으면 보수적으로 이 시간 뒤 만료로 본다 */
const ACCESS_TOKEN_FALLBACK_TTL_MS = 30 * 60_000;
/**
* 아임웹 OAuth 연결과 토큰 관리.
*
* 흐름: 관리자가 '아임웹 연결' → 아임웹 동의 화면 → callback 으로 인가 코드 →
* 토큰 발급·저장 → 이후 액세스 토큰이 만료되면 리프레시 토큰으로 자동 갱신.
*/
@Injectable()
export class ImwebAuthService {
private readonly logger = new Logger(ImwebAuthService.name);
/**
* 진행 중인 토큰 갱신. 동시에 여러 요청이 401 을 받아도 갱신은 한 번만 한다.
* (리프레시 토큰이 교체되는 경우 두 번 갱신하면 두 번째가 낡은 토큰으로 실패한다)
*/
private refreshing: Promise<ImwebConnection> | null = null;
constructor(
@InjectRepository(ImwebConnection)
private readonly connections: Repository<ImwebConnection>,
@Inject(CACHE_MANAGER) private readonly cache: Cache,
private readonly http: ImwebHttpClient,
private readonly config: ImwebConfig,
) {}
/** 아임웹 동의 화면 주소를 만든다. state 는 callback 에서 한 번만 쓸 수 있다. */
async createAuthorizationUrl(adminUsername: string): Promise<string> {
this.assertConfigured();
const state = randomBytes(OAUTH_STATE_BYTES).toString('base64url');
await this.cache.set(
`${OAUTH_STATE_KEY_PREFIX}${state}`,
adminUsername,
OAUTH_STATE_TTL_MS,
);
const params = new URLSearchParams({
responseType: 'code',
clientId: this.config.clientId,
redirectUri: this.config.redirectUri,
scope: IMWEB_SCOPES,
state,
siteCode: this.config.siteCode,
});
return `${IMWEB_API_BASE_URL}${IMWEB_AUTHORIZE_PATH}?${params.toString()}`;
}
/** callback — state 를 확인하고 인가 코드를 토큰으로 바꿔 저장한다. */
async completeAuthorization(
code: string | undefined,
state: string | undefined,
): Promise<void> {
this.assertConfigured();
const stateKey = `${OAUTH_STATE_KEY_PREFIX}${state ?? ''}`;
const adminUsername = state
? await this.cache.get<string>(stateKey)
: undefined;
if (!code || !adminUsername) {
throw new BusinessException('IMWEB_003', {
context: { hasCode: Boolean(code), hasState: Boolean(state) },
});
}
await this.cache.del(stateKey); // 재사용 방지
const token = await this.callImweb(() =>
this.http.postForm<ImwebTokenData>(IMWEB_TOKEN_PATH, {
clientId: this.config.clientId,
clientSecret: this.config.clientSecret,
redirectUri: this.config.redirectUri,
code,
grantType: 'authorization_code',
}),
);
// 회원 조회에 필요한 유닛 코드를 받아 둔다
const site = await this.callImweb(() =>
this.http.get<ImwebSiteInfoData>(
IMWEB_SITE_INFO_PATH,
{},
token.accessToken,
),
);
const unitCode = site.unitList[0]?.unitCode;
if (!unitCode) {
throw new BusinessException('IMWEB_002', {
context: { reason: 'no_unit', siteCode: site.siteCode },
});
}
await this.connections.save(
this.connections.create({
siteCode: this.config.siteCode,
unitCode,
...this.tokenFields(token),
}),
);
await this.markIntegrationComplete(token.accessToken);
this.logger.log(
`아임웹 연결 완료: siteCode=${this.config.siteCode} unitCode=${unitCode} admin=${adminUsername}`,
);
}
/**
* 아임웹 앱 연동 상태를 '연동완료'로 바꾼다.
* 이미 완료된 사이트를 다시 연결하면 30128(연동중 상태만 가능)이 오므로 정상으로 본다.
* 그 밖의 실패도 연결 자체를 막지는 않는다 — 회원 조회가 되는지가 실제 기준이다.
*/
private async markIntegrationComplete(accessToken: string): Promise<void> {
try {
await this.http.patchJson<boolean>(
IMWEB_INTEGRATION_COMPLETE_PATH,
{},
accessToken,
);
} catch (error) {
if (
error instanceof ImwebHttpError &&
error.errorCode === ALREADY_INTEGRATED_CODE
) {
return;
}
this.logger.warn(
`아임웹 연동완료 처리 실패(연결은 유지): ${error instanceof ImwebHttpError ? `status=${error.status} errorCode=${error.errorCode ?? '-'}` : String(error)}`,
);
}
}
/** 연결 정보 (없으면 null) */
findConnection(): Promise<ImwebConnection | null> {
return this.connections.findOne({
where: { siteCode: this.config.siteCode },
});
}
/** 연결을 끊는다 — 저장된 토큰을 지운다. 아임웹 쪽 권한은 그대로 남는다. */
async disconnect(): Promise<void> {
await this.connections.delete({ siteCode: this.config.siteCode });
this.logger.log(`아임웹 연결 해제: siteCode=${this.config.siteCode}`);
}
/**
* 토큰을 붙여 GET 을 호출한다.
* 액세스 토큰이 만료 직전이면 먼저 갱신하고, 401 을 받으면 한 번 갱신 후 다시 시도한다.
*/
async authorizedGet<T>(
path: string,
query: Record<string, QueryValue>,
): Promise<{ data: T; unitCode: string }> {
let connection = await this.requireConnection();
if (this.isExpiringSoon(connection)) {
connection = await this.refresh();
}
try {
const data = await this.http.get<T>(path, query, connection.accessToken);
return { data, unitCode: connection.unitCode };
} catch (error) {
if (!(error instanceof ImwebHttpError) || !error.isUnauthorized()) {
throw this.toBusinessError(error);
}
}
// 만료 시각보다 일찍 무효가 된 경우(아임웹 쪽 재발급 등) — 한 번만 갱신 후 재시도
connection = await this.refresh();
const data = await this.callImweb(() =>
this.http.get<T>(path, query, connection.accessToken),
);
return { data, unitCode: connection.unitCode };
}
/** 연결 정보 — 설정이 없으면 IMWEB_005, 연결 전이면 IMWEB_001 */
async requireConnection(): Promise<ImwebConnection> {
this.assertConfigured();
const connection = await this.findConnection();
if (!connection) throw new BusinessException('IMWEB_001');
return connection;
}
private refresh(): Promise<ImwebConnection> {
this.refreshing ??= this.doRefresh().finally(() => {
this.refreshing = null;
});
return this.refreshing;
}
private async doRefresh(): Promise<ImwebConnection> {
const connection = await this.requireConnection();
let token: ImwebTokenData;
try {
token = await this.http.postForm<ImwebTokenData>(IMWEB_TOKEN_PATH, {
clientId: this.config.clientId,
clientSecret: this.config.clientSecret,
refreshToken: connection.refreshToken,
grantType: 'refresh_token',
});
} catch (error) {
// 리프레시 토큰까지 무효면 관리자가 다시 연결해야 한다
if (error instanceof ImwebHttpError && error.isClientError()) {
this.logger.warn(
`아임웹 토큰 갱신 거부 — 재연결 필요: status=${error.status} errorCode=${error.errorCode ?? '-'}`,
);
await this.disconnect();
throw new BusinessException('IMWEB_001', {
context: { reason: 'refresh_rejected', errorCode: error.errorCode },
});
}
throw this.toBusinessError(error);
}
Object.assign(connection, this.tokenFields(token, connection.refreshToken));
return this.connections.save(connection);
}
private tokenFields(
token: ImwebTokenData,
previousRefreshToken?: string,
): Pick<
ImwebConnection,
'accessToken' | 'refreshToken' | 'accessTokenExpiresAt' | 'scope'
> {
const expiresAtMs =
readJwtExpiryMs(token.accessToken) ??
Date.now() + ACCESS_TOKEN_FALLBACK_TTL_MS;
return {
accessToken: token.accessToken,
// 갱신 응답에 새 리프레시 토큰이 없으면 기존 것을 계속 쓴다
refreshToken: token.refreshToken || previousRefreshToken || '',
accessTokenExpiresAt: new Date(expiresAtMs),
scope: token.scope ?? null,
};
}
private isExpiringSoon(connection: ImwebConnection): boolean {
return (
connection.accessTokenExpiresAt.getTime() -
ACCESS_TOKEN_REFRESH_MARGIN_MS <=
Date.now()
);
}
private assertConfigured(): void {
if (!this.config.isConfigured()) {
throw new BusinessException('IMWEB_005', {
context: { reason: 'missing_imweb_env' },
});
}
}
private async callImweb<T>(call: () => Promise<T>): Promise<T> {
try {
return await call();
} catch (error) {
throw this.toBusinessError(error);
}
}
/** 아임웹 호출 실패를 사용자 응답용 에러 코드로 바꾼다 */
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(
`[IMWEB_002] ${error.path} status=${error.status} errorCode=${error.errorCode ?? '-'}`,
);
return new BusinessException('IMWEB_002', {
context: {
path: error.path,
status: error.status,
errorCode: error.errorCode,
},
});
}
return error;
}
}
@@ -0,0 +1,179 @@
import { Injectable, Logger } from '@nestjs/common';
import { IMWEB_API_BASE_URL } from './imweb.config';
import { ImwebEnvelope, ImwebErrorBody } from './imweb.types';
const REQUEST_TIMEOUT_MS = 15_000;
/** 429·5xx·네트워크 오류일 때 다시 시도하는 횟수 (첫 시도 제외) */
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;
/**
* 아임웹 API 호출 실패. 사용자 응답으로 바로 나가지 않고,
* 호출한 서비스가 IMWEB_002 등 에러 코드로 바꿔 던진다.
*/
export class ImwebHttpError extends Error {
constructor(
readonly path: string,
/** HTTP 상태. 네트워크 오류·타임아웃이면 0 */
readonly status: number,
/** 아임웹 errorCode (30101 등) */
readonly errorCode: string | null,
) {
super(
`아임웹 API 실패: ${path} status=${status} errorCode=${errorCode ?? '-'}`,
);
this.name = 'ImwebHttpError';
}
/** 토큰 무효·만료 (아임웹 errorCode 30101·30102) */
isUnauthorized(): boolean {
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;
}
}
export type QueryValue = string | number | undefined;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isRetryable(status: number): boolean {
return (
status === 0 ||
status === HTTP_TOO_MANY_REQUESTS ||
status >= HTTP_SERVER_ERROR
);
}
/**
* 아임웹 Open API 저수준 HTTP 클라이언트 — 토큰은 모른다(ImwebAuthService 가 넣어 준다).
*
* 로그에는 경로·상태·errorCode 만 남긴다. 요청/응답 본문에는 토큰·회원 개인정보가 있어 남기지 않는다.
*/
@Injectable()
export class ImwebHttpClient {
private readonly logger = new Logger(ImwebHttpClient.name);
async get<T>(
path: string,
query: Record<string, QueryValue>,
accessToken: string,
): Promise<T> {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== '') params.set(key, String(value));
}
const url = `${IMWEB_API_BASE_URL}${path}?${params.toString()}`;
return this.send<T>(path, url, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
});
}
async patchJson<T>(
path: string,
body: Record<string, unknown>,
accessToken: string,
): Promise<T> {
return this.send<T>(path, `${IMWEB_API_BASE_URL}${path}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
}
/** 토큰 발급처럼 application/x-www-form-urlencoded 로 보내야 하는 요청 */
async postForm<T>(path: string, form: Record<string, string>): Promise<T> {
return this.send<T>(path, `${IMWEB_API_BASE_URL}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(form).toString(),
});
}
private async send<T>(
path: string,
url: string,
init: RequestInit,
): Promise<T> {
let lastError: ImwebHttpError | null = null;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
if (attempt > 0) await sleep(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1));
try {
const response = await fetch(url, {
...init,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
const body = await this.readJson(response);
if (response.ok) {
return (body as ImwebEnvelope<T>).data;
}
lastError = new ImwebHttpError(
path,
response.status,
this.errorCode(body),
);
} catch (error) {
if (error instanceof ImwebHttpError) throw error;
// 네트워크 오류·타임아웃
lastError = new ImwebHttpError(path, 0, null);
this.logger.debug(`아임웹 네트워크 오류: ${path} ${String(error)}`);
}
if (!isRetryable(lastError.status) || attempt === MAX_RETRIES) break;
this.logger.warn(
`아임웹 API 재시도 ${attempt + 1}/${MAX_RETRIES}: ${path} status=${lastError.status}`,
);
}
throw lastError ?? new ImwebHttpError(path, 0, null);
}
private async readJson(response: Response): Promise<unknown> {
try {
return (await response.json()) as unknown;
} catch {
return null;
}
}
/**
* 아임웹 errorCode 를 꺼낸다. 에러 본문 형식이 문서에 정의돼 있지 않아
* 최상위(errorCode)와 중첩(error.errorCode / error.code) 둘 다 본다.
* 판단에는 HTTP 상태를 쓰고, 이 값은 로그·진단용으로만 쓴다.
*/
private errorCode(body: unknown): string | null {
if (typeof body !== 'object' || body === null) return null;
const top = body as ImwebErrorBody & {
error?: ImwebErrorBody & { code?: unknown };
};
const code: unknown =
top.errorCode ?? top.error?.errorCode ?? top.error?.code;
return typeof code === 'string' || typeof code === 'number'
? String(code)
: null;
}
}
@@ -0,0 +1,84 @@
import type { Repository } from 'typeorm';
import { ImwebMember } from './entities/imweb-member.entity';
import { ImwebSyncRun } from './entities/imweb-sync-run.entity';
import type { ImwebConfig } from './imweb.config';
import type { ImwebAuthService } from './imweb-auth.service';
import { ImwebMemberLookupService } from './imweb-member-lookup.service';
const API_MEMBER = {
memberCode: 'm20261005abcdef',
uid: 'new@test.dev',
name: '신규',
email: 'new@test.dev',
joinTime: '2026-10-05T03:00:00.000Z',
group: ['g1'],
};
function setup(local: ImwebMember | null = null, apiList = [API_MEMBER]) {
const members = {
findOne: jest.fn(() => Promise.resolve(local)),
upsert: jest.fn(() => Promise.resolve()),
};
const runs = { findOne: jest.fn(() => Promise.resolve({ id: 7 })) };
const auth = {
requireConnection: jest.fn(() => Promise.resolve({ unitCode: 'u-unit' })),
authorizedGet: jest.fn(() =>
Promise.resolve({ data: { list: apiList, totalPage: 1 } }),
),
};
const service = new ImwebMemberLookupService(
members as unknown as Repository<ImwebMember>,
runs as unknown as Repository<ImwebSyncRun>,
auth as unknown as ImwebAuthService,
{ trackingHost: 'utm.moneyguider.co.kr' } as ImwebConfig,
);
return { service, members, auth };
}
describe('ImwebMemberLookupService', () => {
it('동기화된 회원이면 아임웹을 부르지 않는다', async () => {
const local = { memberCode: 'm1', uid: 'a@test.dev' } as ImwebMember;
const { service, auth } = setup(local);
await expect(service.find('a@test.dev')).resolves.toBe(local);
expect(auth.authorizedGet).not.toHaveBeenCalled();
});
it('동기화 전 신규 회원은 아이디로 아임웹에 물어 찾고 저장해 둔다', async () => {
const { service, auth, members } = setup();
const found = await service.find(' new@test.dev ');
expect(auth.authorizedGet).toHaveBeenCalledWith('/member-info/members', {
page: 1,
limit: 1,
unitCode: 'u-unit',
memberUid: 'new@test.dev',
});
expect(found).toMatchObject({
memberCode: 'm20261005abcdef',
groupCodes: ['g1'],
lastSyncRunId: 7,
});
expect(members.upsert).toHaveBeenCalled();
});
it('회원 코드 형식이면 회원 코드로 묻는다', async () => {
const { service, auth } = setup();
await service.find('m20261005abcdef');
expect(auth.authorizedGet).toHaveBeenCalledWith(
'/member-info/members',
expect.objectContaining({ memberCode: 'm20261005abcdef' }),
);
});
it('아임웹에도 없거나 빈 값이면 null', async () => {
const { service } = setup(null, []);
await expect(service.find('ghost@test.dev')).resolves.toBeNull();
await expect(service.find(' ')).resolves.toBeNull();
});
});
@@ -0,0 +1,73 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ImwebMember } from './entities/imweb-member.entity';
import { ImwebSyncRun, SyncStatus } from './entities/imweb-sync-run.entity';
import { IMWEB_MEMBERS_PATH, ImwebConfig } from './imweb.config';
import { ImwebAuthService } from './imweb-auth.service';
import { ImwebMemberPageData } from './imweb.types';
import { toMemberRow } from './member-row.mapper';
/** 아임웹 회원 코드 형식 — 예) m20250402e3b6987310679 */
const MEMBER_CODE_PATTERN = /^m\d{8}[0-9a-z]{6,}$/i;
/**
* 회원 한 명 찾기 — 아이디(이메일 등) 또는 회원 코드로.
*
* 1) 동기화해 둔 imweb_members 에서 먼저 찾고
* 2) 없으면(동기화 전 신규 가입자) 아임웹 API 에 직접 물어 찾은 회원을 저장해 둔다.
* 다음 전체 동기화가 같은 행을 그대로 덮어쓰므로 중복이 생기지 않는다.
*/
@Injectable()
export class ImwebMemberLookupService {
constructor(
@InjectRepository(ImwebMember)
private readonly members: Repository<ImwebMember>,
@InjectRepository(ImwebSyncRun)
private readonly runs: Repository<ImwebSyncRun>,
private readonly auth: ImwebAuthService,
private readonly config: ImwebConfig,
) {}
async find(identifier: string): Promise<ImwebMember | null> {
const id = identifier.trim();
if (!id) return null;
const local = await this.members.findOne({
where: [{ uid: id }, { memberCode: id }],
});
if (local) return local;
const { unitCode } = await this.auth.requireConnection();
const byCode = MEMBER_CODE_PATTERN.test(id);
// 페이지 방식 목록 API 를 쓴다 — 회원 단건 API 와 달리 homePage·group 이 함께 온다
const { data } = await this.auth.authorizedGet<ImwebMemberPageData>(
IMWEB_MEMBERS_PATH,
{
page: 1,
limit: 1,
unitCode,
...(byCode ? { memberCode: id } : { memberUid: id }),
},
);
const found = data.list[0];
if (!found) return null;
// 마지막 성공 실행 번호로 저장해야 다음 동기화의 탈퇴 정리에 잘못 걸리지 않는다
const lastRun = await this.runs.findOne({
where: { status: SyncStatus.SUCCEEDED },
order: { id: 'DESC' },
});
const row = toMemberRow(
found,
lastRun?.id ?? 0,
new Date(),
this.config.trackingHost,
);
if (!row) return null;
await this.members.upsert(row, { conflictPaths: ['memberCode'] });
return row;
}
}
@@ -0,0 +1,93 @@
import { Controller, Get, Logger, Query, Res } from '@nestjs/common';
import { ApiExcludeController } from '@nestjs/swagger';
import type { Response } from 'express';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { SyncTrigger } from './entities/imweb-sync-run.entity';
import { ImwebConfig } from './imweb.config';
import { ImwebAuthService } from './imweb-auth.service';
import { ImwebSyncService } from './imweb-sync.service';
/** 관리자 화면으로 돌려보낼 때 결과를 알리는 쿼리 */
const RESULT_PARAM = 'imweb';
const ERROR_CODE_PARAM = 'code';
/** 아임웹이 동의 화면에서 거부할 때 넘겨주는 오류 코드 (예: 30156 권한 부족) */
const IMWEB_ERROR_PARAM = 'imwebCode';
const IMWEB_ERROR_CODE_PATTERN = /^d{1,10}$/;
const RESULT_CONNECTED = 'connected';
const RESULT_ERROR = 'error';
/**
* 아임웹 OAuth callback — 아임웹 동의 화면이 브라우저를 이 주소로 보낸다.
*
* 사람이 보는 화면 이동이라 JSON 대신 관리자 화면으로 리다이렉트한다.
* 관리자 로그인 가드는 걸지 않는다 — 시작할 때 발급한 일회용 state 로 요청을 확인한다.
*/
@ApiExcludeController()
@SkipTransform()
@Controller('imweb/oauth')
export class ImwebOAuthController {
private readonly logger = new Logger(ImwebOAuthController.name);
constructor(
private readonly auth: ImwebAuthService,
private readonly sync: ImwebSyncService,
private readonly config: ImwebConfig,
) {}
@Get('callback')
async callback(
@Query('code') code: string | undefined,
@Query('state') state: string | undefined,
@Query('errorCode') imwebErrorCode: string | undefined,
@Res() res: Response,
): Promise<void> {
const target = this.config.adminPageUrl;
if (!target) throw new BusinessException('IMWEB_005');
// 아임웹이 인가를 거부하면 code 대신 errorCode·message 를 붙여 돌려보낸다
if (!code && imwebErrorCode) {
const safeCode = IMWEB_ERROR_CODE_PATTERN.test(imwebErrorCode)
? imwebErrorCode
: undefined;
this.logger.warn(
`[IMWEB_003] 아임웹 인가 거부: errorCode=${safeCode ?? '-'}`,
);
res.redirect(
this.withResult(target, RESULT_ERROR, 'IMWEB_003', safeCode),
);
return;
}
try {
await this.auth.completeAuthorization(code, state);
} catch (error) {
const errorCode =
error instanceof BusinessException ? error.code : 'SYS_001';
this.logger.warn(`[${errorCode}] 아임웹 연결 실패`);
res.redirect(this.withResult(target, RESULT_ERROR, errorCode));
return;
}
// 연결하자마자 첫 동기화를 돌린다 — 끝까지 기다리지 않는다
this.sync.start(SyncTrigger.MANUAL).catch((error: unknown) => {
this.logger.warn(`연결 직후 동기화 시작 실패: ${String(error)}`);
});
res.redirect(this.withResult(target, RESULT_CONNECTED));
}
private withResult(
target: string,
result: string,
errorCode?: string,
imwebErrorCode?: string,
): string {
const url = new URL(target);
url.searchParams.set(RESULT_PARAM, result);
if (errorCode) url.searchParams.set(ERROR_CODE_PARAM, errorCode);
if (imwebErrorCode) url.searchParams.set(IMWEB_ERROR_PARAM, imwebErrorCode);
return url.toString();
}
}
@@ -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();
}
}
@@ -0,0 +1,322 @@
import type { SchedulerRegistry } from '@nestjs/schedule';
import type { Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { ImwebGroup } from './entities/imweb-group.entity';
import { ImwebMember } from './entities/imweb-member.entity';
import { ImwebMemberInflowImport } from './entities/imweb-member-inflow-import.entity';
import {
ImwebSyncRun,
SyncStatus,
SyncTrigger,
} 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';
const HOST = 'utm.moneyguider.co.kr';
function member(code: string): ImwebMemberData {
return {
memberCode: code,
uid: `${code}@test.dev`,
name: code,
email: `${code}@test.dev`,
homePage: `https://${HOST}/?src=naver&med=cpc&jd=2026-10-05`,
joinTime: '2026-10-05T03:00:00.000Z',
};
}
function page(
list: ImwebMemberData[],
currentPage: number,
totalPage: number,
): ImwebMemberPageData {
return {
list,
currentPage,
totalPage,
pageSize: list.length,
totalCount: list.length * totalPage,
};
}
function setup(options: { previousSucceededRunId?: number } = {}) {
const savedRuns: ImwebSyncRun[] = [];
const members = {
upsert: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve({ affected: 2 })),
};
const runs = {
create: jest.fn((row: Partial<ImwebSyncRun>) => row),
save: jest.fn((row: ImwebSyncRun) => {
row.id ??= 10;
savedRuns.push({ ...row });
return Promise.resolve(row);
}),
update: jest.fn(() => Promise.resolve()),
findOne: jest.fn(() =>
Promise.resolve(
options.previousSucceededRunId
? { id: options.previousSucceededRunId, status: SyncStatus.SUCCEEDED }
: null,
),
),
};
const imports = {
find: jest.fn(
(): Promise<Pick<ImwebMemberInflowImport, 'memberCode' | 'rawValue'>[]> =>
Promise.resolve([]),
),
};
// 회원 목록 응답은 테스트마다 memberPages 로 정하고, 그룹 목록은 고정 응답을 준다
const memberPages = jest.fn();
const groupPage = {
list: [
{ siteGroupCode: 'g-free', title: ' 9/7 무료강의 그룹 ' },
{ siteGroupCode: 'g-voucher', title: '수출바우처+' },
],
totalCount: 2,
totalPage: 1,
currentPage: 1,
};
const groups = {
upsert: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve({ affected: 0 })),
};
const auth = {
requireConnection: jest.fn(() =>
Promise.resolve({ unitCode: 'u-unit', siteCode: 'S-site' }),
),
authorizedGet: jest.fn(
(path: string, query: object): Promise<unknown> =>
path === '/member-info/groups'
? Promise.resolve({ data: groupPage })
: (memberPages(path, query) as Promise<unknown>),
),
};
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>,
runs as unknown as Repository<ImwebSyncRun>,
imports as unknown as Repository<ImwebMemberInflowImport>,
groups as unknown as Repository<ImwebGroup>,
auth as unknown as ImwebAuthService,
config as unknown as ImwebConfig,
{} as SchedulerRegistry,
products as unknown as ImwebProductService,
);
const finalRun = () => savedRuns.at(-1);
return {
service,
members,
runs,
imports,
groups,
auth,
products,
memberPages,
finalRun,
};
}
describe('ImwebSyncService', () => {
it('마지막 페이지까지 받아 저장하고 성공으로 기록한다', async () => {
const { service, members, memberPages, finalRun } = setup();
memberPages
.mockResolvedValueOnce({ data: page([member('m1'), member('m2')], 1, 2) })
.mockResolvedValueOnce({ data: page([member('m3')], 2, 2) });
await service.start(SyncTrigger.MANUAL);
await service.waitForIdle();
expect(memberPages).toHaveBeenNthCalledWith(1, '/member-info/members', {
page: 1,
limit: 2,
unitCode: 'u-unit',
});
expect(memberPages).toHaveBeenCalledTimes(2);
expect(members.upsert).toHaveBeenCalledTimes(2);
expect(finalRun()).toMatchObject({
status: SyncStatus.SUCCEEDED,
fetchedCount: 3,
});
});
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,
});
memberPages.mockResolvedValueOnce({
data: page([member('m1')], 1, 1),
});
await service.start(SyncTrigger.MANUAL);
await service.waitForIdle();
const [where] = members.delete.mock.calls[0] as unknown as [
{ lastSyncRunId: { value: number; type: string } },
];
expect(where.lastSyncRunId).toMatchObject({ type: 'lessThan', value: 9 });
expect(finalRun()?.removedCount).toBe(2);
});
it('첫 동기화(이전 성공 실행 없음)에서는 아무도 지우지 않는다', async () => {
const { service, members, memberPages } = setup();
memberPages.mockResolvedValueOnce({
data: page([member('m1')], 1, 1),
});
await service.start(SyncTrigger.MANUAL);
await service.waitForIdle();
expect(members.delete).not.toHaveBeenCalled();
});
it('회원을 한 명도 못 받으면 지우지 않는다 (응답 이상으로 간주)', async () => {
const { service, members, memberPages } = setup({
previousSucceededRunId: 9,
});
memberPages.mockResolvedValueOnce({ data: page([], 1, 0) });
await service.start(SyncTrigger.MANUAL);
await service.waitForIdle();
expect(members.delete).not.toHaveBeenCalled();
});
it('도중에 실패하면 에러 코드와 함께 실패로 기록하고 지우지 않는다', async () => {
const { service, members, memberPages, finalRun } = setup({
previousSucceededRunId: 9,
});
memberPages
.mockResolvedValueOnce({ data: page([member('m1')], 1, 3) })
.mockRejectedValueOnce(new BusinessException('IMWEB_002'));
await service.start(SyncTrigger.MANUAL);
await service.waitForIdle();
expect(finalRun()).toMatchObject({
status: SyncStatus.FAILED,
errorCode: 'IMWEB_002',
});
expect(members.delete).not.toHaveBeenCalled();
});
it('진행 중에 다시 시작하면 IMWEB_004 — 연달아 눌러도 한 번만 시작된다', async () => {
const { service, memberPages, runs } = setup();
memberPages.mockResolvedValue({ data: page([member('m1')], 1, 1) });
const results = await Promise.allSettled([
service.start(SyncTrigger.MANUAL),
service.start(SyncTrigger.MANUAL),
]);
await service.waitForIdle();
expect(results.map((r) => r.status)).toEqual(['fulfilled', 'rejected']);
expect((results[1] as PromiseRejectedResult).reason).toMatchObject({
code: 'IMWEB_004',
});
expect(runs.create).toHaveBeenCalledTimes(1);
});
it('연결 전이면 실행 기록을 만들지 않고 IMWEB_001, 잠금도 풀린다', async () => {
const { service, auth, memberPages, runs } = setup();
auth.requireConnection.mockRejectedValueOnce(
new BusinessException('IMWEB_001'),
);
await expect(service.start(SyncTrigger.MANUAL)).rejects.toMatchObject({
code: 'IMWEB_001',
});
expect(runs.create).not.toHaveBeenCalled();
// 잠금이 풀렸으므로 다음 시도는 IMWEB_004 가 아니다
memberPages.mockResolvedValue({ data: page([], 1, 0) });
await expect(service.start(SyncTrigger.MANUAL)).resolves.toBeDefined();
await service.waitForIdle();
});
it('homePage 가 비어 있으면 엑셀에서 가져온 유입경로로 채운다', async () => {
const { service, members, imports, memberPages } = setup();
memberPages.mockResolvedValueOnce({
data: page([{ ...member('m1'), homePage: '' }, member('m2')], 1, 1),
});
imports.find.mockResolvedValueOnce([
{ memberCode: 'm1', rawValue: 'src=facebook|med=paid|jd=2026-10-05' },
// homePage 에 값이 있는 회원은 엑셀 값을 쓰지 않는다
{ memberCode: 'm2', rawValue: 'src=youtube|med=video|jd=2026-10-05' },
]);
await service.start(SyncTrigger.MANUAL);
await service.waitForIdle();
const [saved] = members.upsert.mock.calls[0] as unknown as [ImwebMember[]];
expect(saved.find((r) => r.memberCode === 'm1')).toMatchObject({
inflowOrigin: 'import',
inflowSource: 'facebook',
});
expect(saved.find((r) => r.memberCode === 'm2')).toMatchObject({
inflowOrigin: 'homepage',
inflowSource: 'naver',
});
});
it('그룹 목록을 아임웹 순서대로 저장하고, 회원의 그룹 코드도 함께 저장한다', async () => {
const { service, groups, members, memberPages } = setup();
memberPages.mockResolvedValueOnce({
data: page(
[{ ...member('m1'), group: ['g-free', 'g-voucher', 'g-free'] }],
1,
1,
),
});
await service.start(SyncTrigger.MANUAL);
await service.waitForIdle();
const [savedGroups] = groups.upsert.mock.calls[0] as unknown as [
ImwebGroup[],
];
expect(savedGroups).toEqual([
expect.objectContaining({
groupCode: 'g-free',
title: '9/7 무료강의 그룹',
sortOrder: 0,
}),
expect.objectContaining({
groupCode: 'g-voucher',
title: '수출바우처+',
sortOrder: 1,
}),
]);
expect(groups.delete).toHaveBeenCalled(); // 아임웹에서 사라진 그룹 정리
const [savedMembers] = members.upsert.mock.calls[0] as unknown as [
ImwebMember[],
];
expect(savedMembers[0].groupCodes).toEqual(['g-free', 'g-voucher']);
});
});
@@ -0,0 +1,330 @@
import {
Injectable,
Logger,
OnApplicationBootstrap,
OnApplicationShutdown,
} from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { In, LessThan, Not, Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { ImwebGroup } from './entities/imweb-group.entity';
import { ImwebMember } from './entities/imweb-member.entity';
import { ImwebMemberInflowImport } from './entities/imweb-member-inflow-import.entity';
import {
ImwebSyncRun,
SyncStatus,
SyncTrigger,
} from './entities/imweb-sync-run.entity';
import {
IMWEB_GROUPS_PATH,
IMWEB_MEMBERS_PATH,
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';
const SCHEDULE_NAME = 'imweb-member-sync';
/** 페이지 사이 쉬는 시간 — 아임웹 호출 제한이 문서에 없어 보수적으로 둔다 */
const PAGE_DELAY_MS = 300;
/** 응답이 이상해도 무한 루프에 빠지지 않도록 (페이지 100건 기준 100만 명) */
const MAX_PAGES = 10_000;
const FALLBACK_ERROR_CODE = 'SYS_001';
/** 그룹 목록 한 번에 받는 개수 */
const GROUP_PAGE_SIZE = 100;
/** 엔티티 title 길이와 맞춘다 */
const GROUP_TITLE_MAX_LENGTH = 200;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 아임웹 회원 → 우리 DB 전체 동기화.
*
* 아임웹 회원 목록 API 중 homePage(유입정보)를 주는 것은 페이지 방식뿐이라
* 변경분만 가져올 수 없고 매번 전체를 훑는다. 주기는 IMWEB_SYNC_INTERVAL_MINUTES (기본 60분).
*/
@Injectable()
export class ImwebSyncService
implements OnApplicationBootstrap, OnApplicationShutdown
{
private readonly logger = new Logger(ImwebSyncService.name);
/**
* 단일 인스턴스 기준 잠금 — 여러 인스턴스로 늘리면 DB 잠금으로 바꿔야 한다.
* await 전에 동기적으로 세워야 버튼을 연달아 눌러도 두 번 시작되지 않는다.
*/
private busy = false;
/** 진행 중인 동기화 — 종료 시 기다리는 용도 */
private running: Promise<void> | null = null;
constructor(
@InjectRepository(ImwebMember)
private readonly members: Repository<ImwebMember>,
@InjectRepository(ImwebSyncRun)
private readonly runs: Repository<ImwebSyncRun>,
@InjectRepository(ImwebMemberInflowImport)
private readonly imports: Repository<ImwebMemberInflowImport>,
@InjectRepository(ImwebGroup)
private readonly groups: Repository<ImwebGroup>,
private readonly auth: ImwebAuthService,
private readonly config: ImwebConfig,
private readonly scheduler: SchedulerRegistry,
private readonly products: ImwebProductService,
) {}
async onApplicationBootstrap(): Promise<void> {
// 서버가 동기화 도중 꺼졌다면 그 기록은 영원히 running 으로 남는다 — 실패로 정리한다
await this.runs.update(
{ status: SyncStatus.RUNNING },
{
status: SyncStatus.FAILED,
finishedAt: new Date(),
errorCode: FALLBACK_ERROR_CODE,
},
);
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);
}
// 진행 중인 동기화가 끝나길 기다려 DB 커넥션이 먼저 닫히지 않게 한다
await this.waitForIdle();
}
/** 진행 중인 동기화가 끝날 때까지 기다린다 */
async waitForIdle(): Promise<void> {
await this.running?.catch(() => undefined);
}
/**
* 동기화를 시작하고 실행 기록을 바로 돌려준다 (끝까지 기다리지 않는다).
* 이미 진행 중이면 IMWEB_004.
*/
async start(trigger: SyncTrigger): Promise<ImwebSyncRun> {
if (this.busy) throw new BusinessException('IMWEB_004');
this.busy = true;
try {
await this.auth.requireConnection(); // 연결 전이면 IMWEB_001 로 바로 알려준다
const run = await this.runs.save(
this.runs.create({
trigger,
status: SyncStatus.RUNNING,
startedAt: new Date(),
finishedAt: null,
fetchedCount: 0,
removedCount: 0,
errorCode: null,
}),
);
this.running = this.execute(run).finally(() => {
this.running = null;
this.busy = false;
});
return run;
} catch (error) {
this.busy = false;
throw error;
}
}
/** 가장 최근 실행 기록 */
findLatestRun(): Promise<ImwebSyncRun | null> {
return this.runs.findOne({ where: {}, order: { id: 'DESC' } });
}
/** 마지막으로 성공한 실행 기록 */
findLatestSucceededRun(): Promise<ImwebSyncRun | null> {
return this.runs.findOne({
where: { status: SyncStatus.SUCCEEDED },
order: { id: 'DESC' },
});
}
private async runScheduled(): Promise<void> {
if (this.busy) return;
const connection = this.config.isConfigured()
? await this.auth.findConnection()
: null;
if (!connection) return; // 아직 연결 전이면 조용히 건너뛴다
try {
await this.start(SyncTrigger.SCHEDULE);
} catch (error) {
this.logger.warn(`예약 동기화 시작 실패: ${String(error)}`);
}
}
private async execute(run: ImwebSyncRun): Promise<void> {
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);
run.status = SyncStatus.SUCCEEDED;
this.logger.log(
`회원 동기화 완료: run=${run.id} fetched=${fetched} removed=${run.removedCount} ${Date.now() - startedAt}ms`,
);
} catch (error) {
run.status = SyncStatus.FAILED;
run.errorCode =
error instanceof BusinessException ? error.code : FALLBACK_ERROR_CODE;
this.logger.error(
`[${run.errorCode}] 회원 동기화 실패: run=${run.id}`,
error instanceof Error ? error.stack : String(error),
);
} finally {
run.finishedAt = new Date();
await this.runs.save(run);
}
}
/**
* 회원 그룹 목록(코드 → 이름)을 아임웹과 맞춘다. 아임웹에서 지운 그룹은 여기서도 지운다.
* 그룹은 수십 개 수준이라 한 번에 받는다.
*/
private async syncGroups(): Promise<void> {
const syncedAt = new Date();
const rows: ImwebGroup[] = [];
for (let page = 1; page <= MAX_PAGES; page += 1) {
const { data } = await this.auth.authorizedGet<ImwebGroupPageData>(
IMWEB_GROUPS_PATH,
{ page, limit: GROUP_PAGE_SIZE },
);
for (const group of data.list) {
if (!group.siteGroupCode) continue;
rows.push({
groupCode: group.siteGroupCode,
title:
(group.title ?? '').trim().slice(0, GROUP_TITLE_MAX_LENGTH) ||
group.siteGroupCode,
sortOrder: rows.length,
syncedAt,
});
}
if (data.list.length === 0 || page >= data.totalPage) break;
}
if (rows.length > 0) {
await this.groups.upsert(rows, { conflictPaths: ['groupCode'] });
}
// 아임웹에서 사라진 그룹 정리 — 받은 목록이 비었으면 응답 이상으로 보고 건너뛴다
if (rows.length > 0) {
await this.groups.delete({
groupCode: Not(In(rows.map((row) => row.groupCode))),
});
}
}
/**
* 상품 목록도 같은 주기로 맞춘다. 실패해도 회원 동기화는 계속한다 —
* 상품 읽기 권한을 추가한 뒤 아직 다시 연결하지 않은 동안(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();
const { unitCode } = await this.auth.requireConnection();
let fetched = 0;
for (let page = 1; page <= MAX_PAGES; page += 1) {
if (page > 1) await sleep(PAGE_DELAY_MS);
const { data } = await this.auth.authorizedGet<ImwebMemberPageData>(
IMWEB_MEMBERS_PATH,
{ page, limit: this.config.pageSize, unitCode },
);
// homePage 기록 전 가입자는 엑셀에서 가져온 유입경로로 채운다
const imported = await this.findImported(
data.list.map((m) => m.memberCode),
);
const rows = data.list
.map((member) =>
toMemberRow(
member,
run.id,
syncedAt,
this.config.trackingHost,
imported.get(member.memberCode) ?? null,
),
)
.filter((row): row is ImwebMember => row !== null);
if (rows.length > 0) {
await this.members.upsert(rows, { conflictPaths: ['memberCode'] });
}
fetched += data.list.length;
// 진행 상황을 관리자 화면에서 볼 수 있게 남긴다
run.fetchedCount = fetched;
await this.runs.update(run.id, { fetchedCount: fetched });
if (data.list.length === 0 || page >= data.totalPage) break;
}
return fetched;
}
/** 엑셀에서 가져온 '유입경로' 원본 — 회원 코드 → 값 */
private async findImported(
memberCodes: string[],
): Promise<Map<string, string>> {
const codes = memberCodes.filter(Boolean);
if (codes.length === 0) return new Map();
const rows = await this.imports.find({
select: { memberCode: true, rawValue: true },
where: { memberCode: In(codes) },
});
return new Map(rows.map((row) => [row.memberCode, row.rawValue]));
}
/**
* 탈퇴 회원 정리 — **두 번 연속** 전체 동기화에서 보이지 않은 회원만 지운다.
*
* 페이지 방식은 동기화 도중 가입·탈퇴가 생기면 순서가 밀려 한두 명을 건너뛸 수 있다.
* 한 번 못 봤다고 지우면 멀쩡한 회원이 사라졌다 다음 동기화에 다시 생기므로,
* 직전 성공 실행에서도 못 본 회원만 지운다.
*/
private async removeDeparted(
run: ImwebSyncRun,
fetched: number,
): Promise<number> {
// 아무것도 못 받았는데 지우면 전체가 날아간다 — 응답 이상으로 보고 건너뛴다
if (fetched === 0) return 0;
const previous = await this.runs.findOne({
where: { status: SyncStatus.SUCCEEDED, id: LessThan(run.id) },
order: { id: 'DESC' },
});
if (!previous) return 0;
const result = await this.members.delete({
lastSyncRunId: LessThan(previous.id),
});
return result.affected ?? 0;
}
}
+127
View File
@@ -0,0 +1,127 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { parsePositiveInt } from '@/shared/utils/env';
/** 아임웹 Open API 주소 (developers-docs.imweb.me/reference) */
export const IMWEB_API_BASE_URL = 'https://openapi.imweb.me';
export const IMWEB_AUTHORIZE_PATH = '/oauth2/authorize';
export const IMWEB_TOKEN_PATH = '/oauth2/token';
export const IMWEB_SITE_INFO_PATH = '/site-info';
/** 연동완료 처리 — 인가 직후 앱 연동 상태를 '연동중' → '연동완료' 로 바꾼다 */
export const IMWEB_INTEGRATION_COMPLETE_PATH =
'/site-info/integration-complete';
/**
* 회원 목록 — 페이지 방식만 homePage(유입정보)를 내려준다.
* 커서 방식(/member-info/members/cursor)은 homePage 가 없어서 쓰지 않는다.
*/
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';
/**
* 요청 권한 — 아임웹 개발자센터 앱 설정의 권한과 일치해야 한다.
* 앱 설정에 없는 권한을 요청하면 인가가 거부된다(errorCode 30156).
* 회원·상품·주문은 읽기만 받는다. site-info:write 는 아임웹이 인가 때 필수로 요구하고
* 연동완료 처리에만 쓴다. (write 는 읽기를 포함한다)
*
* ⚠️ 권한을 바꾸면 관리자 화면에서 '아임웹 연결'을 다시 해야 새 권한이 담긴 토큰을 받는다.
*/
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;
/** imweb-header-top.html 이 홈페이지 항목에 쓰는 가짜 호스트 ('https://utm.' + OWN_HOST) */
export const DEFAULT_TRACKING_HOST = 'utm.moneyguider.co.kr';
/**
* 아임웹 연동 설정.
*
* 비밀값(client id/secret)은 backend/.env.{env}, 나머지는 루트 .env → compose 로 들어온다.
* 값이 빠져 있어도 서버는 뜬다 (CI·e2e 는 아임웹 없이 돈다). 대신 연동 기능을 쓰는 순간
* IMWEB_005 로 막는다. 운영에서는 env 검증이 필수값을 강제한다.
*/
@Injectable()
export class ImwebConfig {
constructor(private readonly config: ConfigService) {}
get clientId(): string {
return this.read('IMWEB_CLIENT_ID');
}
get clientSecret(): string {
return this.read('IMWEB_CLIENT_SECRET');
}
get siteCode(): string {
return this.read('IMWEB_SITE_CODE');
}
/** 아임웹에 등록한 redirect URI 와 정확히 같아야 한다 */
get redirectUri(): string {
const apiUrl = this.read('BACKEND_API_URL').replace(/\/+$/, '');
return apiUrl ? `${apiUrl}${IMWEB_CALLBACK_PATH}` : '';
}
/** 연결을 마친 뒤 돌아갈 관리자 화면 주소 */
get adminPageUrl(): string {
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;
}
get syncIntervalMs(): number {
const minutes = parsePositiveInt(
this.config.get<string>('IMWEB_SYNC_INTERVAL_MINUTES'),
DEFAULT_SYNC_INTERVAL_MINUTES,
);
return minutes * 60_000;
}
get pageSize(): number {
return parsePositiveInt(
this.config.get<string>('IMWEB_PAGE_SIZE'),
DEFAULT_PAGE_SIZE,
);
}
/** OAuth 연결에 필요한 값이 모두 있는가 */
isConfigured(): boolean {
return [
this.clientId,
this.clientSecret,
this.siteCode,
this.redirectUri,
this.adminPageUrl,
].every((value) => value !== '');
}
private read(key: string): string {
return (this.config.get<string>(key) ?? '').trim();
}
}
+52
View File
@@ -0,0 +1,52 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminAuthModule } from '@/modules/admin-auth/admin-auth.module';
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';
import { ImwebAuthService } from './imweb-auth.service';
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 연결, 토큰 관리, 회원·상품 동기화 */
@Module({
imports: [
TypeOrmModule.forFeature([
ImwebConnection,
ImwebGroup,
ImwebMember,
ImwebMemberInflowImport,
ImwebProduct,
ImwebSyncRun,
]),
AdminAuthModule,
],
controllers: [
ImwebAdminController,
ImwebOAuthController,
ImwebProductsController,
],
providers: [
ImwebConfig,
ImwebHttpClient,
ImwebAuthService,
ImwebSyncService,
ImwebAdminService,
ImwebMemberLookupService,
ImwebProductService,
],
// 업체 링크 모듈이 주문 조회(ImwebAuthService)와 회원 확인(ImwebMemberLookupService)을 쓴다
exports: [ImwebConfig, ImwebAuthService, ImwebMemberLookupService],
})
export class ImwebModule {}
+136
View File
@@ -0,0 +1,136 @@
/**
* 아임웹 Open API 응답 타입 — openapi.json(developers-docs.imweb.me/reference) 기준.
* 쓰는 필드만 옮겼다. 아임웹이 필드를 추가해도 무시하면 되므로 깨지지 않는다.
*/
/** 모든 응답의 공통 껍데기 */
export interface ImwebEnvelope<T> {
statusCode: number;
data: T;
}
/** POST /oauth2/token */
export interface ImwebTokenData {
accessToken: string;
refreshToken: string;
scope?: string;
}
/** GET /site-info */
export interface ImwebSiteInfoData {
siteCode: string;
unitList: { unitCode: string; name?: string }[];
}
/** GET /member-info/members — ReadMemberResponseDto */
export interface ImwebMemberData {
memberCode: string;
uid: string;
name: string;
email: string;
callnum?: string | null;
homePage?: string | null;
joinTime: string;
lastLoginTime?: string | null;
/** 소속 회원 그룹 코드 목록 (운영진 그룹은 포함되지 않는다) */
group?: string[] | null;
socialLogin?: Partial<
Record<
'googleId' | 'facebookId' | 'kakaoId' | 'naverId' | 'appleId' | 'lineId',
string | null
>
> | null;
}
/** GET /member-info/groups — 회원 그룹 한 개 */
export interface ImwebGroupData {
siteGroupCode: string;
title: string;
}
/** GET /member-info/groups — 목록 (페이지 방식) */
export interface ImwebGroupPageData {
totalCount: number;
totalPage: number;
currentPage: number;
list: ImwebGroupData[];
}
/** GET /member-info/members — ReadAllMembersResponseDto */
export interface ImwebMemberPageData {
totalCount: number;
totalPage: number;
currentPage: number;
pageSize: number;
list: ImwebMemberData[];
}
/** GET /products — ReadShopProductResponseDto (쓰는 필드만) */
export interface ImwebProductData {
prodNo: number;
name: string;
/** sale(판매중) · soldout(품절) · nosale(숨김) */
prodStatus?: string | null;
/** normal(일반) · digital(디지털) · subscribe(이용권) */
prodType?: string | null;
/** 유닛별 전시 여부 Y/N */
isDisplay?: string | null;
productImages?: string[] | null;
addTime?: string | null;
editTime?: string | null;
}
/** GET /products — ReadAllShopProductsResponseDto (페이지 방식) */
export interface ImwebProductPageData {
totalCount: number;
totalPage: number;
currentPage: number;
pageSize: number;
list: ImwebProductData[];
}
/** GET /orders — OrderSectionItemDataDto (쓰는 필드만) */
export interface ImwebOrderSectionItemData {
orderSectionItemNo?: string;
/** 사은품 여부 Y/N */
isPromotion?: string | null;
productInfo?: { prodNo?: number | null; prodName?: string | null } | null;
}
/** GET /orders — OrderDataDto (쓰는 필드만) */
export interface ImwebOrderData {
orderNo: number;
isMember?: string | null;
memberCode?: string | null;
memberUid?: string | null;
/** 주문 생성 시각 (UTC) */
wtime: string;
payments?: {
paymentStatus?: string | null;
isCancel?: string | null;
}[];
sections?: {
orderSectionStatus?: string | null;
sectionItems?: ImwebOrderSectionItemData[];
}[];
}
/** GET /orders — ReadAllOrderResponseDto (페이지 방식) */
export interface ImwebOrderPageData {
totalCount: number;
totalPage: number;
currentPage: number;
pageSize: number;
list: ImwebOrderData[];
}
/**
* 아임웹 에러 응답 — 문서의 Error List (statusCode / errorCode / message).
* 본문 구조는 문서에 정의돼 있지 않아 모든 필드를 선택으로 둔다.
* 토큰 무효(30101)·만료(30102)는 HTTP 401 로 온다.
*/
export interface ImwebErrorBody {
statusCode?: number;
errorCode?: number | string;
message?: string;
}
@@ -0,0 +1,77 @@
import { extractImportRows } from './inflow-import';
/** 실제 아임웹 회원 엑셀과 같은 열 구성 (일부) */
const HEADER = [
'언어',
'고유키',
'이메일',
'아이디',
'이름',
'유입경로',
'가입일',
];
describe('extractImportRows', () => {
it('고유키·유입경로만 뽑고 나머지 열(개인정보)은 버린다', () => {
const result = extractImportRows([
HEADER,
[
'KR',
'm001',
'a@test.dev',
'a@test.dev',
'홍길동',
' src=facebook|med=paid|jd=2026-09-01 ',
'2026-09-01 10:00:00',
],
[
'KR',
'm002',
'b@test.dev',
'b@test.dev',
'김철수',
'src=google|med=organic|jd=2026-09-02',
'2026-09-02 11:00:00',
],
]);
expect(result.rows).toEqual([
{ memberCode: 'm001', rawValue: 'src=facebook|med=paid|jd=2026-09-01' },
{ memberCode: 'm002', rawValue: 'src=google|med=organic|jd=2026-09-02' },
]);
expect(JSON.stringify(result)).not.toContain('홍길동');
});
it('고유키나 유입경로가 빈 행은 건너뛴다', () => {
const result = extractImportRows([
HEADER,
['KR', 'm001', '', '', '', '', ''],
['KR', null, '', '', '', 'src=naver', ''],
['KR', 'm003', '', '', '', 'src=naver|jd=2026-09-01', ''],
]);
expect(result.rows).toHaveLength(1);
expect(result.skippedEmpty).toBe(2);
});
it('같은 고유키가 여러 번 나오면 마지막 값을 쓰고 개수를 센다', () => {
const result = extractImportRows([
HEADER,
['KR', 'm001', '', '', '', 'src=naver', ''],
['KR', 'm001', '', '', '', 'src=google', ''],
]);
expect(result.rows).toEqual([
{ memberCode: 'm001', rawValue: 'src=google' },
]);
expect(result.duplicates).toBe(1);
});
it('필요한 열이 없으면 다른 엑셀로 보고 거부한다', () => {
expect(() =>
extractImportRows([
['이름', '연락처'],
['홍길동', '010'],
]),
).toThrow(/고유키, 유입경로/);
expect(() => extractImportRows([])).toThrow(/필요한 열이 없습니다/);
});
});
+177
View File
@@ -0,0 +1,177 @@
import { EntityManager, In } from 'typeorm';
import { InflowOrigin, InflowStatus } from '@/shared/utils/inflow';
import { ImwebMemberInflowImport } from './entities/imweb-member-inflow-import.entity';
import { ImwebMember } from './entities/imweb-member.entity';
import { resolveInflowColumns } from './member-row.mapper';
/**
* 아임웹 회원 엑셀 → '유입경로' 가져오기.
*
* 아임웹 관리자 > 회원 > 엑셀 다운로드 파일 기준. 확장자는 .xls 지만 실제 형식은 xlsx 다.
* 필요한 열은 둘뿐이고 나머지(이름·연락처 등 개인정보)는 읽기만 하고 버린다.
*/
export const IMPORT_COLUMNS = {
/** 아임웹 회원 코드 (API 의 memberCode 와 같다) */
memberCode: '고유키',
/** 사용자 정의 가입 항목 — UTM 스크립트가 파이프 형식으로 기록 */
inflow: '유입경로',
} as const;
const MEMBER_CODE_MAX_LENGTH = 40;
/** DB 왕복을 줄이되 IN 절이 너무 길어지지 않게 나눠 처리한다 */
const CHUNK_SIZE = 500;
export interface ImportRow {
memberCode: string;
rawValue: string;
}
export interface ExtractResult {
rows: ImportRow[];
/** 고유키나 유입경로가 비어 건너뛴 행 */
skippedEmpty: number;
/** 같은 고유키가 여러 번 나와 뒤의 것으로 덮은 횟수 */
duplicates: number;
}
/** 셀 값을 문자열로 — 엑셀 라이브러리가 숫자·날짜로 줄 수도 있다 */
function text(cell: unknown): string {
if (typeof cell === 'string') return cell.trim();
if (typeof cell === 'number' || typeof cell === 'boolean') {
return String(cell);
}
if (cell instanceof Date) return cell.toISOString();
return '';
}
/**
* 시트 행(첫 행 = 열 이름)에서 고유키·유입경로만 뽑는다 (순수 함수).
* 필요한 열이 없으면 다른 엑셀을 넣은 것이므로 예외를 던진다.
*/
export function extractImportRows(
sheet: readonly (readonly unknown[])[],
): ExtractResult {
const [header = [], ...data] = sheet;
const titles = header.map(text);
const codeIndex = titles.indexOf(IMPORT_COLUMNS.memberCode);
const inflowIndex = titles.indexOf(IMPORT_COLUMNS.inflow);
const missing: string[] = [];
if (codeIndex < 0) missing.push(IMPORT_COLUMNS.memberCode);
if (inflowIndex < 0) missing.push(IMPORT_COLUMNS.inflow);
if (missing.length > 0) {
throw new Error(
`엑셀에 필요한 열이 없습니다: ${missing.join(', ')} (아임웹 회원 엑셀인지 확인해 주세요)`,
);
}
const byCode = new Map<string, string>();
let skippedEmpty = 0;
let duplicates = 0;
for (const row of data) {
const memberCode = text(row[codeIndex]);
const rawValue = text(row[inflowIndex]);
if (
!memberCode ||
!rawValue ||
memberCode.length > MEMBER_CODE_MAX_LENGTH
) {
skippedEmpty += 1;
continue;
}
if (byCode.has(memberCode)) duplicates += 1;
byCode.set(memberCode, rawValue);
}
return {
rows: [...byCode].map(([memberCode, rawValue]) => ({
memberCode,
rawValue,
})),
skippedEmpty,
duplicates,
};
}
export interface ApplyResult {
/** 가져온(보관한) 행 */
stored: number;
/** DB 회원과 매칭된 행 — 아직 동기화 안 된 회원은 다음 동기화 때 붙는다 */
matched: number;
/** 매칭된 회원 중 엑셀 값이 적용된 수 (homePage 값이 있는 회원은 그대로 둔다) */
appliedFromImport: number;
/** 적용된 값의 기록 상태 */
atJoin: number;
afterJoin: number;
/** 스크립트 형식이 아니라 풀 수 없었던 값 */
unparsable: number;
}
/**
* 가져온 행을 보관하고, 이미 동기화된 회원에는 바로 반영한다.
* 같은 고유키는 덮어쓰므로 같은 파일을 다시 가져와도 안전하다.
* 트랜잭션 안에서 부른다 — 중간에 실패하면 아무것도 바뀌지 않아야 한다.
*/
export async function applyInflowImport(
manager: EntityManager,
rows: ImportRow[],
sourceFile: string,
trackingHost: string,
importedAt: Date = new Date(),
): Promise<ApplyResult> {
const result: ApplyResult = {
stored: 0,
matched: 0,
appliedFromImport: 0,
atJoin: 0,
afterJoin: 0,
unparsable: 0,
};
for (let i = 0; i < rows.length; i += CHUNK_SIZE) {
const chunk = rows.slice(i, i + CHUNK_SIZE);
await manager.upsert(
ImwebMemberInflowImport,
chunk.map((row) => ({ ...row, sourceFile, importedAt })),
['memberCode'],
);
result.stored += chunk.length;
const members = await manager.find(ImwebMember, {
select: { memberCode: true, joinTime: true, homePage: true },
where: { memberCode: In(chunk.map((row) => row.memberCode)) },
});
const rawByCode = new Map(
chunk.map((row) => [row.memberCode, row.rawValue]),
);
for (const member of members) {
result.matched += 1;
const columns = resolveInflowColumns(
member.homePage,
rawByCode.get(member.memberCode),
member.joinTime.toISOString(),
trackingHost,
);
if (columns.inflowOrigin === InflowOrigin.IMPORT) {
result.appliedFromImport += 1;
if (columns.inflowStatus === InflowStatus.AT_JOIN) result.atJoin += 1;
if (columns.inflowStatus === InflowStatus.AFTER_JOIN)
result.afterJoin += 1;
} else if (columns.inflowOrigin === null) {
result.unparsable += 1;
}
// homePage 값이 있는 회원은 결과가 같으므로 다시 써도 무방하다
await manager.update(
ImwebMember,
{ memberCode: member.memberCode },
columns,
);
}
}
return result;
}
@@ -0,0 +1,84 @@
import { InflowStatus } from '@/shared/utils/inflow';
import { ImwebMemberData } from './imweb.types';
import { toGroupCodes, toMemberRow } from './member-row.mapper';
const HOST = 'utm.moneyguider.co.kr';
const SYNCED_AT = new Date('2026-10-05T05:00:00.000Z');
const base: ImwebMemberData = {
memberCode: 'm20261005abc',
uid: 'user@example.com',
name: ' 홍길동 ',
email: 'user@example.com',
callnum: '01012345678',
homePage: `https://${HOST}/?src=naver&med=cpc&jd=2026-10-05&cmp=fall`,
joinTime: '2026-10-05T03:00:00.000Z',
lastLoginTime: '2026-10-05T04:00:00.000Z',
};
describe('toMemberRow', () => {
it('회원 정보와 유입정보를 한 행으로 만든다', () => {
const row = toMemberRow(base, 7, SYNCED_AT, HOST);
expect(row).toMatchObject({
memberCode: 'm20261005abc',
name: '홍길동',
joinTime: new Date('2026-10-05T03:00:00.000Z'),
inflowStatus: InflowStatus.AT_JOIN,
inflowSource: 'naver',
inflowMedium: 'cpc',
inflowCampaign: 'fall',
inflowRecordedDate: '2026-10-05',
lastSyncRunId: 7,
syncedAt: SYNCED_AT,
});
});
it('소셜 로그인 정보로 가입 경로를 정한다', () => {
const row = toMemberRow(
{ ...base, socialLogin: { googleId: null, kakaoId: '12345' } },
1,
SYNCED_AT,
HOST,
);
expect(row?.socialProvider).toBe('kakao');
});
it('소셜 정보가 없으면 null', () => {
expect(toMemberRow(base, 1, SYNCED_AT, HOST)?.socialProvider).toBeNull();
});
it('빈 문자열 필드는 null 로 저장한다', () => {
const row = toMemberRow(
{ ...base, email: '', callnum: ' ', homePage: '' },
1,
SYNCED_AT,
HOST,
);
expect(row?.email).toBeNull();
expect(row?.callnum).toBeNull();
expect(row?.homePage).toBeNull();
expect(row?.inflowStatus).toBe(InflowStatus.NONE);
});
it('가입 시각을 읽을 수 없으면 저장하지 않는다', () => {
expect(
toMemberRow({ ...base, joinTime: 'invalid' }, 1, SYNCED_AT, HOST),
).toBeNull();
});
});
describe('toGroupCodes', () => {
it('문자열 코드만 남기고 중복·빈 값을 지운다', () => {
expect(toGroupCodes([' g1 ', 'g2', 'g1', '', 3, null])).toEqual([
'g1',
'g2',
]);
});
it('배열이 아니면 빈 배열', () => {
expect(toGroupCodes(null)).toEqual([]);
expect(toGroupCodes(undefined)).toEqual([]);
expect(toGroupCodes('g1')).toEqual([]);
});
});
@@ -0,0 +1,156 @@
import {
InflowOrigin,
InflowStatus,
ParsedInflow,
parseInflow,
} from '@/shared/utils/inflow';
import { ImwebMember } from './entities/imweb-member.entity';
import { ImwebMemberData } from './imweb.types';
/** 엔티티 varchar 길이와 맞춘 자르기 한도 */
const UID_MAX_LENGTH = 255;
const NAME_MAX_LENGTH = 100;
const EMAIL_MAX_LENGTH = 255;
const CALLNUM_MAX_LENGTH = 30;
/** 그룹 코드 길이·개수 상한 — 이상한 응답이 와도 행이 비대해지지 않게 */
const GROUP_CODE_MAX_LENGTH = 40;
const GROUP_MAX_COUNT = 50;
/** 아임웹 socialLogin 필드 → 저장할 가입 경로 이름 (먼저 나오는 것을 쓴다) */
const SOCIAL_PROVIDERS = [
['kakaoId', 'kakao'],
['naverId', 'naver'],
['googleId', 'google'],
['appleId', 'apple'],
['facebookId', 'facebook'],
['lineId', 'line'],
] as const;
function clip(value: string | null | undefined, max: number): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed.slice(0, max) : null;
}
function toDate(iso: string | null | undefined): Date | null {
if (!iso) return null;
const time = Date.parse(iso);
return Number.isNaN(time) ? null : new Date(time);
}
/** 소속 그룹 코드 — 문자열만, 중복 없이 */
export function toGroupCodes(groups: unknown): string[] {
if (!Array.isArray(groups)) return [];
const codes = groups
.filter((code): code is string => typeof code === 'string')
.map((code) => code.trim())
.filter((code) => code !== '' && code.length <= GROUP_CODE_MAX_LENGTH);
return [...new Set(codes)].slice(0, GROUP_MAX_COUNT);
}
function socialProvider(member: ImwebMemberData): string | null {
const social = member.socialLogin;
if (!social) return null;
const found = SOCIAL_PROVIDERS.find(([key]) => Boolean(social[key]));
return found ? found[1] : null;
}
/** imweb_members 의 유입정보 컬럼들 */
export type MemberInflowColumns = Pick<
ImwebMember,
| 'inflowOrigin'
| 'inflowStatus'
| 'inflowSource'
| 'inflowMedium'
| 'inflowCampaign'
| 'inflowContent'
| 'inflowTerm'
| 'inflowClickId'
| 'inflowReferrer'
| 'inflowLandingPath'
| 'inflowProductId'
| 'inflowFirstTouchDate'
| 'inflowLastTouch'
| 'inflowRecordedDate'
>;
function toColumns(
inflow: ParsedInflow,
origin: InflowOrigin | null,
): MemberInflowColumns {
return {
inflowOrigin: origin,
inflowStatus: inflow.status,
inflowSource: inflow.source,
inflowMedium: inflow.medium,
inflowCampaign: inflow.campaign,
inflowContent: inflow.content,
inflowTerm: inflow.term,
inflowClickId: inflow.clickId,
inflowReferrer: inflow.referrer,
inflowLandingPath: inflow.landingPath,
inflowProductId: inflow.productId,
inflowFirstTouchDate: inflow.firstTouchDate,
inflowLastTouch: inflow.lastTouch,
inflowRecordedDate: inflow.recordedDate,
};
}
/**
* 회원의 유입정보를 정한다 (순수 함수).
* homePage(가입 폼 '홈페이지')에 유입정보가 있으면 그것을, 없으면 엑셀에서 가져온 값을 쓴다.
* 둘 다 같은 스크립트가 같은 순간에 쓴 값이라 겹치면 homePage 를 믿는다.
*/
export function resolveInflowColumns(
homePage: string | null | undefined,
importedValue: string | null | undefined,
joinTimeIso: string,
trackingHost: string,
): MemberInflowColumns {
const fromHomePage = parseInflow(homePage, joinTimeIso, trackingHost);
if (fromHomePage.status !== InflowStatus.NONE) {
return toColumns(fromHomePage, InflowOrigin.HOMEPAGE);
}
const fromImport = parseInflow(importedValue, joinTimeIso, trackingHost);
if (fromImport.status !== InflowStatus.NONE) {
return toColumns(fromImport, InflowOrigin.IMPORT);
}
return toColumns(fromHomePage, null);
}
/**
* 아임웹 회원 한 명을 저장할 행으로 바꾼다 (순수 함수).
* 가입 시각을 읽을 수 없으면 null — 유입 판별·정렬이 불가능하므로 저장하지 않는다.
*
* @param importedValue 엑셀에서 가져온 '유입경로' 원본 (없으면 null)
*/
export function toMemberRow(
member: ImwebMemberData,
syncRunId: number,
syncedAt: Date,
trackingHost: string,
importedValue: string | null = null,
): ImwebMember | null {
const joinTime = toDate(member.joinTime);
if (!member.memberCode || !joinTime) return null;
return {
memberCode: member.memberCode,
uid: clip(member.uid, UID_MAX_LENGTH) ?? member.memberCode,
name: clip(member.name, NAME_MAX_LENGTH) ?? '',
email: clip(member.email, EMAIL_MAX_LENGTH),
callnum: clip(member.callnum, CALLNUM_MAX_LENGTH),
socialProvider: socialProvider(member),
joinTime,
lastLoginTime: toDate(member.lastLoginTime),
groupCodes: toGroupCodes(member.group),
homePage: member.homePage?.trim() || null,
...resolveInflowColumns(
member.homePage,
importedValue,
member.joinTime,
trackingHost,
),
lastSyncRunId: syncRunId,
syncedAt,
};
}
@@ -0,0 +1,118 @@
import { ApiPropertyOptional, IntersectionType } from '@nestjs/swagger';
import {
IsDateString,
IsEnum,
IsIn,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { INFLOW_VALUE_MAX_LENGTH, InflowStatus } from '@/shared/utils/inflow';
const SEARCH_MAX_LENGTH = 100;
const GROUP_CODE_MAX_LENGTH = 40;
/** 목록·집계가 함께 쓰는 필터 */
export class MemberFilterDto {
@ApiPropertyOptional({
description: '가입 시각 시작 (이상, ISO 8601)',
example: '2026-09-30T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '가입 시작일 형식이 올바르지 않습니다.' })
joinedFrom?: string;
@ApiPropertyOptional({
description: '가입 시각 끝 (미만, ISO 8601)',
example: '2026-10-31T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '가입 종료일 형식이 올바르지 않습니다.' })
joinedTo?: string;
@ApiPropertyOptional({
description: '유입 기록 상태',
enum: InflowStatus,
example: InflowStatus.AT_JOIN,
})
@IsOptional()
@IsEnum(InflowStatus, { message: '유입 기록 상태 값이 올바르지 않습니다.' })
inflowStatus?: InflowStatus;
@ApiPropertyOptional({ description: '유입원 (utm_source)', example: 'naver' })
@IsOptional()
@IsString()
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
source?: string;
@ApiPropertyOptional({ description: '매체 (utm_medium)', example: 'cpc' })
@IsOptional()
@IsString()
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
medium?: string;
@ApiPropertyOptional({
description: '캠페인 (utm_campaign)',
example: 'fall',
})
@IsOptional()
@IsString()
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
campaign?: string;
@ApiPropertyOptional({
description: '콘텐츠 (utm_content)',
example: 'banner_a',
})
@IsOptional()
@IsString()
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
content?: string;
@ApiPropertyOptional({
description: '회원 그룹 코드 (GET /admin/members/groups 의 code)',
example: 'g202609074ed64993dde35',
})
@IsOptional()
@IsString()
@MaxLength(GROUP_CODE_MAX_LENGTH)
groupCode?: string;
}
/** 회원 목록 조회 — 커서 페이지네이션 + 공통 필터 + 검색어 */
export class MemberListQueryDto extends IntersectionType(
CursorPaginationQueryDto,
MemberFilterDto,
) {
@ApiPropertyOptional({
description: '검색어 — 이름·아이디·이메일·연락처 부분 일치',
example: '홍길동',
})
@IsOptional()
@IsString()
@MaxLength(SEARCH_MAX_LENGTH, { message: '검색어가 너무 깁니다.' })
q?: string;
}
/** 집계 기준 */
export const INFLOW_GROUP_BY = [
'source',
'medium',
'campaign',
'content',
] as const;
export type InflowGroupBy = (typeof INFLOW_GROUP_BY)[number];
/** 유입원별 집계 */
export class InflowStatsQueryDto extends MemberFilterDto {
@ApiPropertyOptional({
description: '집계 기준',
enum: INFLOW_GROUP_BY,
default: 'source',
})
@IsOptional()
@IsIn(INFLOW_GROUP_BY, { message: '집계 기준 값이 올바르지 않습니다.' })
groupBy: InflowGroupBy = 'source';
}
@@ -0,0 +1,262 @@
import { ApiProperty } from '@nestjs/swagger';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { InflowOrigin, InflowStatus } from '@/shared/utils/inflow';
import { INFLOW_GROUP_BY } from './member-query.dto';
import type { InflowGroupBy } from './member-query.dto';
/** 회원의 유입정보 */
export class MemberInflowDto {
@ApiProperty({
description:
'출처 — homepage: 가입 폼 홈페이지 항목(API) / import: 아임웹 회원 엑셀 / 없으면 null',
enum: InflowOrigin,
nullable: true,
example: InflowOrigin.HOMEPAGE,
})
origin!: InflowOrigin | null;
@ApiProperty({
description:
'기록 상태 — at_join: 가입 때 기록 / after_join: 가입 후 기록 / none: 없음',
enum: InflowStatus,
example: InflowStatus.AT_JOIN,
})
status!: InflowStatus;
@ApiProperty({ description: '유입원', nullable: true, example: 'naver' })
source!: string | null;
@ApiProperty({ description: '매체', nullable: true, example: 'cpc' })
medium!: string | null;
@ApiProperty({ description: '캠페인', nullable: true, example: 'fall' })
campaign!: string | null;
@ApiProperty({ description: '콘텐츠', nullable: true, example: 'banner_a' })
content!: string | null;
@ApiProperty({ description: '검색어', nullable: true, example: '재테크' })
term!: string | null;
@ApiProperty({
description: '광고 클릭 ID 종류',
nullable: true,
example: 'gclid',
})
clickId!: string | null;
@ApiProperty({
description: '외부 리퍼러 호스트',
nullable: true,
example: 'm.search.naver.com',
})
referrer!: string | null;
@ApiProperty({
description: '첫 랜딩 경로',
nullable: true,
example: '/free-shop/',
})
landingPath!: string | null;
@ApiProperty({
description: '랜딩 상품/게시글 idx',
nullable: true,
example: '52',
})
productId!: string | null;
@ApiProperty({
description: '최초 유입일 (KST, YYYY-MM-DD)',
nullable: true,
example: '2026-10-04',
})
firstTouchDate!: string | null;
@ApiProperty({
description: '최종 유입 (최초와 다를 때만, 유입원/캠페인)',
nullable: true,
example: 'instagram/retarget',
})
lastTouch!: string | null;
@ApiProperty({
description: '기록일 (KST, YYYY-MM-DD)',
nullable: true,
example: '2026-10-05',
})
recordedDate!: string | null;
}
/** 관리자 화면 회원 한 명 */
export class MemberResponseDto {
@ApiProperty({
description: '아임웹 회원 코드',
example: 'm20261005e3b6987310679',
})
memberCode!: string;
@ApiProperty({ description: '아이디', example: 'user@example.com' })
uid!: string;
@ApiProperty({ description: '이름', example: '홍길동' })
name!: string;
@ApiProperty({
description: '이메일',
nullable: true,
example: 'user@example.com',
})
email!: string | null;
@ApiProperty({
description: '연락처',
nullable: true,
example: '01012345678',
})
callnum!: string | null;
@ApiProperty({
description: '소셜 가입 경로 (일반 가입이면 null)',
nullable: true,
example: 'kakao',
})
socialProvider!: string | null;
@ApiProperty({
description: '가입 시각',
example: '2026-10-05T03:00:00.000Z',
})
joinTime!: string;
@ApiProperty({
description: '최근 로그인',
nullable: true,
example: '2026-10-05T04:00:00.000Z',
})
lastLoginTime!: string | null;
@ApiProperty({
description: '소속 회원 그룹 (아임웹 그룹 순서)',
type: () => [MemberGroupRefDto],
})
groups!: MemberGroupRefDto[];
@ApiProperty({ description: '유입정보', type: MemberInflowDto })
inflow!: MemberInflowDto;
/**
* @param groupTitles 그룹 코드 → { 이름, 순서 } — 동기화된 그룹 목록에서 만든다.
* 목록에 없는 코드(그사이 아임웹에서 지운 그룹)는 코드를 이름 대신 보여준다.
*/
static from(
member: ImwebMember,
groupTitles: ReadonlyMap<
string,
{ title: string; sortOrder: number }
> = new Map(),
): MemberResponseDto {
const groups = member.groupCodes
.map((code) => ({
code,
title: groupTitles.get(code)?.title ?? code,
order: groupTitles.get(code)?.sortOrder ?? Number.MAX_SAFE_INTEGER,
}))
.sort((a, b) => a.order - b.order)
.map(({ code, title }) => ({ code, title }));
return {
memberCode: member.memberCode,
uid: member.uid,
name: member.name,
email: member.email,
callnum: member.callnum,
socialProvider: member.socialProvider,
groups,
joinTime: member.joinTime.toISOString(),
lastLoginTime: member.lastLoginTime?.toISOString() ?? null,
inflow: {
origin: member.inflowOrigin,
status: member.inflowStatus,
source: member.inflowSource,
medium: member.inflowMedium,
campaign: member.inflowCampaign,
content: member.inflowContent,
term: member.inflowTerm,
clickId: member.inflowClickId,
referrer: member.inflowReferrer,
landingPath: member.inflowLandingPath,
productId: member.inflowProductId,
firstTouchDate: member.inflowFirstTouchDate,
lastTouch: member.inflowLastTouch,
recordedDate: member.inflowRecordedDate,
},
};
}
}
/** 집계 한 줄 */
/** 회원에게 붙은 그룹 */
export class MemberGroupRefDto {
@ApiProperty({ description: '그룹 코드', example: 'g202609074ed64993dde35' })
code!: string;
@ApiProperty({ description: '그룹 이름', example: '9/7 무료강의 그룹' })
title!: string;
}
/** 그룹 목록 한 줄 — 필터 칩에 쓴다 */
export class MemberGroupDto extends MemberGroupRefDto {
@ApiProperty({
description: '동기화된 회원 중 이 그룹에 속한 수',
example: 2489,
})
memberCount!: number;
}
export class InflowStatItemDto {
@ApiProperty({
description: '집계 값 (값이 없으면 null)',
nullable: true,
example: 'naver',
})
key!: string | null;
@ApiProperty({ description: '회원 수', example: 120 })
count!: number;
}
/** 기록 상태별 회원 수 */
export class InflowStatusCountsDto {
@ApiProperty({ description: '가입 때 기록', example: 900 })
atJoin!: number;
@ApiProperty({ description: '가입 후 기록 (가입 경로 아님)', example: 30 })
afterJoin!: number;
@ApiProperty({ description: '기록 없음', example: 590 })
none!: number;
}
/** 유입원별 집계 결과 */
export class InflowStatsResponseDto {
@ApiProperty({
description: '집계 기준',
enum: INFLOW_GROUP_BY,
example: 'source',
})
groupBy!: InflowGroupBy;
@ApiProperty({
description:
'가입 때 기록된 회원만 집계한 결과 (회원 수 많은 순, 최대 100개)',
type: [InflowStatItemDto],
})
items!: InflowStatItemDto[];
@ApiProperty({
description: '같은 필터의 기록 상태별 회원 수',
type: InflowStatusCountsDto,
})
statusCounts!: InflowStatusCountsDto;
}
@@ -0,0 +1,65 @@
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 {
InflowStatsQueryDto,
MemberListQueryDto,
} from './dto/member-query.dto';
import {
InflowStatsResponseDto,
MemberGroupDto,
MemberResponseDto,
} from './dto/member-response.dto';
import { MembersService } from './members.service';
/** 관리자 회원 조회 — 유입경로 기준 목록·집계 */
@ApiTags('Admin Members')
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@UseGuards(AdminAuthGuard)
@Controller('admin/members')
export class MembersController {
constructor(private readonly members: MembersService) {}
@Get()
@ApiOperation({ summary: '회원 목록 (가입 최신순, 커서 페이지네이션)' })
@ApiStandardResponse(MemberResponseDto, {
description: '조회 성공 — data 는 { items, pageInfo }',
isArray: true,
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '필터·커서 형식 오류')
findMany(
@Query() query: MemberListQueryDto,
): Promise<PaginatedResult<MemberResponseDto>> {
return this.members.findMany(query);
}
@Get('groups')
@ApiOperation({
summary: '회원 그룹 목록과 그룹별 회원 수 (아임웹 그룹 순서)',
})
@ApiStandardResponse(MemberGroupDto, {
description: '조회 성공',
isArray: true,
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
findGroups(): Promise<MemberGroupDto[]> {
return this.members.findGroups();
}
@Get('inflow-stats')
@ApiOperation({ summary: '유입경로별 가입자 수 집계' })
@ApiStandardResponse(InflowStatsResponseDto, { description: '집계 성공' })
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '필터 형식 오류')
stats(@Query() query: InflowStatsQueryDto): Promise<InflowStatsResponseDto> {
return this.members.stats(query);
}
}
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminAuthModule } from '@/modules/admin-auth/admin-auth.module';
import { ImwebGroup } from '@/modules/imweb/entities/imweb-group.entity';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { MembersController } from './members.controller';
import { MembersService } from './members.service';
/** 관리자 회원 조회 — 데이터는 ImwebModule 의 동기화가 채운다 */
@Module({
imports: [
TypeOrmModule.forFeature([ImwebMember, ImwebGroup]),
AdminAuthModule,
],
controllers: [MembersController],
providers: [MembersService],
exports: [MembersService],
})
export class MembersModule {}
@@ -0,0 +1,228 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, SelectQueryBuilder } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { ImwebGroup } from '@/modules/imweb/entities/imweb-group.entity';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { InflowStatus } from '@/shared/utils/inflow';
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
import {
InflowGroupBy,
InflowStatsQueryDto,
MemberFilterDto,
MemberListQueryDto,
} from './dto/member-query.dto';
import {
InflowStatsResponseDto,
MemberGroupDto,
MemberResponseDto,
} from './dto/member-response.dto';
const ALIAS = 'm';
/** 집계 결과 최대 개수 — 캠페인·콘텐츠는 값 종류가 많을 수 있다 */
const STATS_MAX_ITEMS = 100;
/** 집계 기준 → 엔티티 속성 (쿼리 빌더가 컬럼명으로 바꾼다) */
const GROUP_BY_PROPERTY: Record<InflowGroupBy, keyof ImwebMember> = {
source: 'inflowSource',
medium: 'inflowMedium',
campaign: 'inflowCampaign',
content: 'inflowContent',
};
/** 필터 값 → 엔티티 속성 */
const FILTER_PROPERTY = {
source: 'inflowSource',
medium: 'inflowMedium',
campaign: 'inflowCampaign',
content: 'inflowContent',
} as const satisfies Record<string, keyof ImwebMember>;
/** LIKE 패턴의 와일드카드(%, _)와 이스케이프 문자를 글자 그대로 찾게 한다 */
function escapeLike(value: string): string {
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
}
/**
* 관리자 회원 조회 — 동기화해 둔 imweb_members 를 유입경로로 필터·집계한다.
* 아임웹을 직접 부르지 않으므로 빠르고, 아임웹 호출 제한과 무관하다.
*/
@Injectable()
export class MembersService {
constructor(
@InjectRepository(ImwebMember)
private readonly members: Repository<ImwebMember>,
@InjectRepository(ImwebGroup)
private readonly groups: Repository<ImwebGroup>,
) {}
/** 회원 그룹 목록과 그룹별 회원 수 — 아임웹 그룹 순서대로 (회원이 없는 그룹도 포함) */
async findGroups(): Promise<MemberGroupDto[]> {
const rows = await this.groups
.createQueryBuilder('g')
.select('g.groupCode', 'code')
.addSelect('g.title', 'title')
.addSelect(
(sub) =>
sub
.select('COUNT(*)')
.from(ImwebMember, 'm')
.where('g.group_code = ANY(m.group_codes)'),
'memberCount',
)
.orderBy('g.sortOrder', 'ASC')
.getRawMany<{ code: string; title: string; memberCount: string }>();
return rows.map((row) => ({
code: row.code,
title: row.title,
memberCount: Number(row.memberCount),
}));
}
/** 그룹 코드 → { 이름, 순서 } — 회원 응답에 그룹 이름을 붙일 때 쓴다 (신청 내역도 함께 쓴다) */
async groupTitleMap(): Promise<
Map<string, { title: string; sortOrder: number }>
> {
const groups = await this.groups.find({
select: { groupCode: true, title: true, sortOrder: true },
});
return new Map(
groups.map((g) => [
g.groupCode,
{ title: g.title, sortOrder: g.sortOrder },
]),
);
}
async findMany(
query: MemberListQueryDto,
): Promise<PaginatedResult<MemberResponseDto>> {
const base = this.filtered(query);
if (query.q) {
base.andWhere(
`(${ALIAS}.name ILIKE :q OR ${ALIAS}.uid ILIKE :q OR ${ALIAS}.email ILIKE :q OR ${ALIAS}.callnum ILIKE :q)`,
{ q: `%${escapeLike(query.q.trim())}%` },
);
}
const total = await base.clone().getCount();
const page = base
.clone()
.orderBy(`${ALIAS}.joinTime`, 'DESC')
.addOrderBy(`${ALIAS}.memberCode`, '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}.joinTime, ${ALIAS}.memberCode) < (:cursorTime, :cursorCode)`,
{ cursorTime: cursor.time, cursorCode: cursor.key },
);
}
const [rows, groupTitles] = await Promise.all([
page.getMany(),
this.groupTitleMap(),
]);
const hasNext = rows.length > query.limit;
const items = rows.slice(0, query.limit);
const last = items.at(-1);
return toPaginatedResult(
items.map((member) => MemberResponseDto.from(member, groupTitles)),
{
hasNext,
nextCursor:
hasNext && last
? encodeTimeCursor({
time: last.joinTime.toISOString(),
key: last.memberCode,
})
: null,
total,
},
);
}
async stats(query: InflowStatsQueryDto): Promise<InflowStatsResponseDto> {
const property = GROUP_BY_PROPERTY[query.groupBy];
// 순위는 가입 때 기록된 회원만 센다 — 가입 후 채워진 값은 가입 경로가 아니다
const grouped = await this.filtered({
...query,
inflowStatus: InflowStatus.AT_JOIN,
})
.select(`${ALIAS}.${property}`, 'key')
.addSelect('COUNT(*)', 'count')
.groupBy(`${ALIAS}.${property}`)
.orderBy('count', 'DESC')
.addOrderBy('key', 'ASC', 'NULLS LAST')
.limit(STATS_MAX_ITEMS)
.getRawMany<{ key: string | null; count: string }>();
const byStatus = await this.filtered({ ...query, inflowStatus: undefined })
.select(`${ALIAS}.inflowStatus`, 'status')
.addSelect('COUNT(*)', 'count')
.groupBy(`${ALIAS}.inflowStatus`)
.getRawMany<{ status: InflowStatus; count: string }>();
const countOf = (status: InflowStatus) =>
Number(byStatus.find((row) => row.status === status)?.count ?? 0);
return {
groupBy: query.groupBy,
items: grouped.map((row) => ({ key: row.key, count: Number(row.count) })),
statusCounts: {
atJoin: countOf(InflowStatus.AT_JOIN),
afterJoin: countOf(InflowStatus.AFTER_JOIN),
none: countOf(InflowStatus.NONE),
},
};
}
/** 목록·집계 공통 필터 */
private filtered(filter: MemberFilterDto): SelectQueryBuilder<ImwebMember> {
const qb = this.members.createQueryBuilder(ALIAS);
if (filter.joinedFrom) {
qb.andWhere(`${ALIAS}.joinTime >= :joinedFrom`, {
joinedFrom: filter.joinedFrom,
});
}
if (filter.joinedTo) {
qb.andWhere(`${ALIAS}.joinTime < :joinedTo`, {
joinedTo: filter.joinedTo,
});
}
if (filter.inflowStatus) {
qb.andWhere(`${ALIAS}.inflowStatus = :inflowStatus`, {
inflowStatus: filter.inflowStatus,
});
}
if (filter.groupCode) {
qb.andWhere(`:groupCode = ANY(${ALIAS}.group_codes)`, {
groupCode: filter.groupCode,
});
}
for (const [key, property] of Object.entries(FILTER_PROPERTY)) {
const value = filter[key as keyof typeof FILTER_PROPERTY];
if (value) {
qb.andWhere(`${ALIAS}.${property} = :${key}`, { [key]: value });
}
}
return qb;
}
}
@@ -0,0 +1,98 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
import { ConversionType } from '../entities/partner-conversion.entity';
/** 링크 실적 상세 조회 */
export class ConversionListQueryDto extends CursorPaginationQueryDto {
@ApiPropertyOptional({
description: '실적 종류 — application(신청) · purchase(구매)',
enum: ConversionType,
})
@IsOptional()
@IsEnum(ConversionType, { message: '실적 종류 값이 올바르지 않습니다.' })
type?: ConversionType;
}
/** 실적 한 건 — 어느 회원이 언제 신청·구매했는가 */
export class ConversionResponseDto {
@ApiProperty({ description: '실적 ID' })
id!: string;
@ApiProperty({
description: '실적 종류',
enum: ConversionType,
example: ConversionType.APPLICATION,
})
type!: ConversionType;
@ApiProperty({
description: '신청·주문 시각',
example: '2026-10-05T05:00:00.000Z',
})
convertedAt!: string;
@ApiProperty({
description: '귀속한 링크 클릭 시각',
example: '2026-10-04T12:00:00.000Z',
})
touchedAt!: string;
@ApiProperty({
description: '아임웹 주문번호 (신청이면 null)',
nullable: true,
example: '202610050001234',
})
orderNo!: string | null;
@ApiProperty({
description: '집계에 들어가는가 — 구매가 환불·취소되면 false',
example: true,
})
isValid!: boolean;
@ApiProperty({ description: '아임웹 회원 코드' })
memberCode!: string;
@ApiProperty({
description: '회원 정보 (동기화된 회원 기준) — 탈퇴 등으로 없으면 null',
type: MemberResponseDto,
nullable: true,
})
member!: MemberResponseDto | null;
}
/** 주문 실적 새로고침 상태 (서버 메모리 기준 — 재시작하면 비워진다) */
export class OrderSyncStatusDto {
@ApiProperty({ description: '진행 중인가', example: false })
isRunning!: boolean;
@ApiProperty({
description: '마지막 시작 시각',
nullable: true,
example: '2026-10-05T05:00:00.000Z',
})
lastStartedAt!: string | null;
@ApiProperty({
description: '마지막 종료 시각',
nullable: true,
example: '2026-10-05T05:00:20.000Z',
})
lastFinishedAt!: string | null;
@ApiProperty({
description: '마지막 실패 에러 코드 (성공이면 null)',
nullable: true,
example: null,
})
lastErrorCode!: string | null;
@ApiProperty({ description: '마지막 실행에서 확인한 주문 수', example: 120 })
checkedOrderCount!: number;
@ApiProperty({ description: '마지막 실행에서 새로 잡은 구매 수', example: 2 })
createdCount!: number;
}
@@ -0,0 +1,130 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
Min,
} from 'class-validator';
import { PartnerPeriodQueryDto, PartnerStatsDto } from './partner.dto';
const TARGET_URL_MAX_LENGTH = 2000;
export class CreatePartnerLinkDto {
@ApiProperty({ description: '업체 ID' })
@IsUUID('4', { message: '업체를 선택해 주세요.' })
partnerId!: string;
@ApiProperty({ description: '아임웹 상품번호', example: 52 })
@Type(() => Number)
@IsInt({ message: '상품을 선택해 주세요.' })
@Min(1, { message: '상품을 선택해 주세요.' })
prodNo!: number;
@ApiProperty({
description: '이동할 상품 페이지 주소 — 아임웹 사이트 안의 주소만',
example: 'https://www.moneyguider.co.kr/free-shop/?idx=52',
})
@IsString()
@IsNotEmpty({ message: '이동 주소를 입력해 주세요.' })
@MaxLength(TARGET_URL_MAX_LENGTH, { message: '이동 주소가 너무 깁니다.' })
targetUrl!: string;
}
export class UpdatePartnerLinkDto {
@ApiPropertyOptional({
description: '이동할 상품 페이지 주소',
example: 'https://www.moneyguider.co.kr/free-shop/?idx=52',
})
@IsOptional()
@IsString()
@IsNotEmpty({ message: '이동 주소를 입력해 주세요.' })
@MaxLength(TARGET_URL_MAX_LENGTH, { message: '이동 주소가 너무 깁니다.' })
targetUrl?: string;
@ApiPropertyOptional({
description:
'사용 여부 — 중지하면 이동은 되지만 클릭·실적을 새로 남기지 않는다',
example: false,
})
@IsOptional()
@IsBoolean({ message: '사용 여부 값이 올바르지 않습니다.' })
isActive?: boolean;
}
export class PartnerLinkListQueryDto extends PartnerPeriodQueryDto {
@ApiPropertyOptional({ description: '업체 ID' })
@IsOptional()
@IsUUID('4', { message: '업체 ID 형식이 올바르지 않습니다.' })
partnerId?: string;
@ApiPropertyOptional({ description: '아임웹 상품번호', example: 52 })
@IsOptional()
@Type(() => Number)
@IsInt({ message: '상품번호 형식이 올바르지 않습니다.' })
@Min(1, { message: '상품번호 형식이 올바르지 않습니다.' })
prodNo?: number;
}
export class PartnerLinkPartnerDto {
@ApiProperty({ description: '업체 ID' })
id!: string;
@ApiProperty({ description: '업체 이름', example: '머니튜브' })
name!: string;
}
export class PartnerLinkProductDto {
@ApiProperty({ description: '아임웹 상품번호', example: 52 })
prodNo!: number;
@ApiProperty({
description: '상품명 — 아임웹에서 지워진 상품이면 null',
nullable: true,
})
name!: string | null;
}
/** 업체 링크 한 개 */
export class PartnerLinkResponseDto {
@ApiProperty({ description: '링크 ID' })
id!: string;
@ApiProperty({ description: '단축 링크 코드', example: 'Ab3kX9pQ' })
code!: string;
@ApiProperty({
description: '업체에 전달할 단축 링크',
example: 'https://moneyguide.wandergo.net/r/Ab3kX9pQ',
})
shortUrl!: string;
@ApiProperty({ description: '업체', type: PartnerLinkPartnerDto })
partner!: PartnerLinkPartnerDto;
@ApiProperty({ description: '상품', type: PartnerLinkProductDto })
product!: PartnerLinkProductDto;
@ApiProperty({
description: '이동할 상품 페이지 주소',
example: 'https://www.moneyguider.co.kr/free-shop/?idx=52',
})
targetUrl!: string;
@ApiProperty({ description: '사용 여부', example: true })
isActive!: boolean;
@ApiProperty({ description: '기간 내 실적', type: PartnerStatsDto })
stats!: PartnerStatsDto;
@ApiProperty({
description: '발급 시각',
example: '2026-10-05T05:00:00.000Z',
})
createdAt!: string;
}
@@ -0,0 +1,41 @@
import { ApiProperty } from '@nestjs/swagger';
import {
ArrayMaxSize,
IsArray,
IsNotEmpty,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { CLICK_REF_PATTERN } from '@/shared/utils/partner-link';
const MEMBER_ID_MAX_LENGTH = 255;
/** 한 번에 받는 클릭 ID 수 — 브라우저는 최근 30일 것만 보관한다 */
export const PARTNER_REFS_MAX = 20;
/** 아임웹 Header Code 스크립트가 보내는 회원 ↔ 링크 클릭 연결 */
export class CreatePartnerTouchDto {
@ApiProperty({
description: '아임웹 회원 아이디 또는 회원 코드 (window.MEMBER_UID)',
example: 'user@example.com',
})
@IsString()
@IsNotEmpty({ message: '회원 정보가 없습니다.' })
@MaxLength(MEMBER_ID_MAX_LENGTH)
memberUid!: string;
@ApiProperty({
description: '브라우저가 보관한 업체 링크 클릭 ID (mg_ref) 목록',
example: ['AbCd1234EfGh5678'],
type: [String],
})
@IsArray()
@ArrayMaxSize(PARTNER_REFS_MAX)
@IsString({ each: true })
@Matches(CLICK_REF_PATTERN, {
each: true,
message: '클릭 정보 형식이 올바르지 않습니다.',
})
refs!: string[];
}
@@ -0,0 +1,113 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import {
IsDateString,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { Partner } from '../entities/partner.entity';
const PARTNER_NAME_MAX_LENGTH = 100;
const PARTNER_MEMO_MAX_LENGTH = 1000;
/** 실적 집계 기간 — 목록의 클릭·신청·구매 수를 이 기간으로 센다 (없으면 전체) */
export class PartnerPeriodQueryDto extends CursorPaginationQueryDto {
@ApiPropertyOptional({
description: '집계 시작 (이상, ISO 8601)',
example: '2026-09-30T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '집계 시작일 형식이 올바르지 않습니다.' })
from?: string;
@ApiPropertyOptional({
description: '집계 끝 (미만, ISO 8601)',
example: '2026-10-31T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '집계 종료일 형식이 올바르지 않습니다.' })
to?: string;
}
export class CreatePartnerDto {
@ApiProperty({ description: '업체 이름', example: '머니튜브' })
@IsString()
@IsNotEmpty({ message: '업체 이름을 입력해 주세요.' })
@MaxLength(PARTNER_NAME_MAX_LENGTH, { message: '업체 이름이 너무 깁니다.' })
name!: string;
@ApiPropertyOptional({
description: '관리자 메모 (담당자·정산 조건 등)',
example: '담당 김OO / 신청 1건당 정산',
})
@IsOptional()
@IsString()
@MaxLength(PARTNER_MEMO_MAX_LENGTH, { message: '메모가 너무 깁니다.' })
memo?: string;
}
export class UpdatePartnerDto extends PartialType(CreatePartnerDto) {}
/** 업체·링크의 실적 */
export class PartnerStatsDto {
@ApiProperty({ description: '링크 클릭 수 (사람만)', example: 320 })
clickCount!: number;
@ApiProperty({ description: '신청 수', example: 41 })
applicationCount!: number;
@ApiProperty({
description: '구매 수 — 결제완료 주문 (환불·취소 제외)',
example: 7,
})
purchaseCount!: number;
}
/** 업체 한 곳 */
export class PartnerResponseDto {
@ApiProperty({ description: '업체 ID' })
id!: string;
@ApiProperty({ description: '업체 이름', example: '머니튜브' })
name!: string;
@ApiProperty({ description: '관리자 메모', nullable: true })
memo!: string | null;
@ApiProperty({ description: '발급된 링크 수', example: 3 })
linkCount!: number;
@ApiProperty({
description: '기간 내 실적 (모든 링크 합계)',
type: PartnerStatsDto,
})
stats!: PartnerStatsDto;
@ApiProperty({
description: '등록 시각',
example: '2026-10-05T05:00:00.000Z',
})
createdAt!: string;
static from(
partner: Partner,
linkCount = 0,
stats: PartnerStatsDto = {
clickCount: 0,
applicationCount: 0,
purchaseCount: 0,
},
): PartnerResponseDto {
return {
id: partner.id,
name: partner.name,
memo: partner.memo,
linkCount,
stats,
createdAt: partner.createdAt.toISOString(),
};
}
}
@@ -0,0 +1,84 @@
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { PartnerLink } from './partner-link.entity';
export enum ConversionType {
/** 무료 강의 신청 — 신청 완료 화면이 보낸 신청 기록 */
APPLICATION = 'application',
/** 결제완료 주문 — 아임웹 주문 동기화가 찾는다 */
PURCHASE = 'purchase',
}
/**
* 업체 실적 — 업체 링크로 들어온 회원의 신청·구매 한 건.
*
* 신청은 신청 기록당 하나, 구매는 주문·상품당 하나다.
* 구매가 나중에 전액 환불·취소되면 지우지 않고 isValid=false 로 바꿔 집계에서 뺀다.
* 금액은 관리하지 않는다 (건수만 센다).
*/
@Entity({ name: 'partner_conversions' })
@Index('uq_partner_conversions_application', ['applicationId'], {
unique: true,
})
@Index('uq_partner_conversions_order_product', ['orderNo', 'prodNo'], {
unique: true,
})
@Index('idx_partner_conversions_link_time', ['linkId', 'convertedAt'])
@Index('idx_partner_conversions_partner_time', ['partnerId', 'convertedAt'])
@Index('idx_partner_conversions_cursor', ['convertedAt', 'id'])
export class PartnerConversion {
/** 앱에서 crypto.randomUUID() 로 만든다 */
@PrimaryColumn({ name: 'id', type: 'uuid' })
id!: string;
@Column({ name: 'type', type: 'varchar', length: 20 })
type!: ConversionType;
@Column({ name: 'link_id', type: 'uuid' })
linkId!: string;
@ManyToOne(() => PartnerLink, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'link_id' })
link?: PartnerLink;
/** 링크의 업체 — 업체별 집계용으로 함께 둔다 */
@Column({ name: 'partner_id', type: 'uuid' })
partnerId!: string;
@Column({ name: 'prod_no', type: 'int' })
prodNo!: number;
@Column({ name: 'member_code', type: 'varchar', length: 40 })
memberCode!: string;
/** 신청 기록 ID (구매면 null) */
@Column({ name: 'application_id', type: 'uuid', nullable: true })
applicationId!: string | null;
/** 아임웹 주문번호 (신청이면 null) */
@Column({ name: 'order_no', type: 'varchar', length: 30, nullable: true })
orderNo!: string | null;
/** 집계에 넣는가 — 구매가 전액 환불·취소되면 false */
@Column({ name: 'is_valid', type: 'boolean', default: true })
isValid!: boolean;
/** 귀속한 클릭의 시각 */
@Column({ name: 'touched_at', type: 'timestamptz' })
touchedAt!: Date;
/** 신청 시각 또는 주문 시각 */
@Column({ name: 'converted_at', type: 'timestamptz' })
convertedAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,35 @@
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
} from 'typeorm';
import { PartnerLink } from './partner-link.entity';
/**
* 업체 링크 클릭 — 사람이 누른 것만 남긴다 (링크 미리보기·검색봇 제외).
*
* ref 는 상품 페이지 주소에 mg_ref 로 붙여 보내는 클릭 ID 다.
* 회원과 클릭을 이을 때 클릭 시각을 브라우저가 아니라 이 기록에서 가져온다.
* IP·브라우저 정보는 남기지 않는다.
*/
@Entity({ name: 'partner_link_clicks' })
@Index('idx_partner_link_clicks_link_time', ['linkId', 'clickedAt'])
export class PartnerLinkClick {
/** 클릭 ID (영숫자 16자) */
@PrimaryColumn({ name: 'ref', type: 'varchar', length: 16 })
ref!: string;
@Column({ name: 'link_id', type: 'uuid' })
linkId!: string;
@ManyToOne(() => PartnerLink, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'link_id' })
link?: PartnerLink;
@Column({ name: 'clicked_at', type: 'timestamptz' })
clickedAt!: Date;
}
@@ -0,0 +1,60 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { Partner } from './partner.entity';
/**
* 업체 링크 — 업체·상품 조합당 하나. 업체에는 단축 링크(/r/{code})를 준다.
*
* 실적 기록을 지키기 위해 지우지 않고 중지(isActive=false)만 한다.
* 중지된 링크도 이동은 시켜 주지만 클릭·실적은 새로 남기지 않는다.
*/
@Entity({ name: 'partner_links' })
@Index('uq_partner_links_partner_product', ['partnerId', 'prodNo'], {
unique: true,
})
@Index('idx_partner_links_cursor', ['createdAt', 'id'])
export class PartnerLink {
/** 앱에서 crypto.randomUUID() 로 만든다 */
@PrimaryColumn({ name: 'id', type: 'uuid' })
id!: string;
/** 단축 링크 코드 (영숫자 8자) */
@Index('uq_partner_links_code', { unique: true })
@Column({ name: 'code', type: 'varchar', length: 16 })
code!: string;
@Column({ name: 'partner_id', type: 'uuid' })
partnerId!: string;
/** 링크가 있는 업체는 지울 수 없다 (DB 에서도 막는다) */
@ManyToOne(() => Partner, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'partner_id' })
partner?: Partner;
/** 아임웹 상품번호 — 상품이 아임웹에서 지워져도 링크·실적은 남도록 FK 를 걸지 않는다 */
@Index()
@Column({ name: 'prod_no', type: 'int' })
prodNo!: number;
/** 이동할 상품 페이지 주소 — 아임웹 사이트 안의 주소만 허용한다 */
@Column({ name: 'target_url', type: 'text' })
targetUrl!: string;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,57 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
} from 'typeorm';
import { PartnerLinkClick } from './partner-link-click.entity';
import { PartnerLink } from './partner-link.entity';
/**
* 회원 ↔ 링크 클릭 연결 — "이 회원이 이 링크를 이때 눌렀다".
*
* 링크를 누른 브라우저에서 로그인한 회원이 확인되면 기록한다 (Header Code 스크립트·신청 완료 화면).
* 신청·구매가 생기면 이 기록에서 마지막 클릭을 찾아 업체 실적으로 잇는다.
*/
@Entity({ name: 'partner_touches' })
@Index('uq_partner_touches_member_click', ['memberCode', 'clickRef'], {
unique: true,
})
@Index('idx_partner_touches_member_product', ['memberCode', 'prodNo'])
export class PartnerTouch {
/** 앱에서 crypto.randomUUID() 로 만든다 */
@PrimaryColumn({ name: 'id', type: 'uuid' })
id!: string;
@Column({ name: 'member_code', type: 'varchar', length: 40 })
memberCode!: string;
@Column({ name: 'click_ref', type: 'varchar', length: 16 })
clickRef!: string;
@ManyToOne(() => PartnerLinkClick, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'click_ref' })
click?: PartnerLinkClick;
@Column({ name: 'link_id', type: 'uuid' })
linkId!: string;
@ManyToOne(() => PartnerLink, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'link_id' })
link?: PartnerLink;
/** 링크의 상품번호 — 신청·구매 상품과 맞춰 보려고 함께 둔다 */
@Column({ name: 'prod_no', type: 'int' })
prodNo!: number;
/** 클릭 시각 (클릭 기록의 값) */
@Column({ name: 'clicked_at', type: 'timestamptz' })
clickedAt!: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
}
@@ -0,0 +1,27 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryColumn,
} from 'typeorm';
/** 업체 — 상품별 링크를 받아 신청·구매 회원을 데려오는 제휴처 */
@Entity({ name: 'partners' })
@Index('idx_partners_cursor', ['createdAt', 'id'])
export class Partner {
/** 앱에서 crypto.randomUUID() 로 만든다 */
@PrimaryColumn({ name: 'id', type: 'uuid' })
id!: string;
@Index('uq_partners_name', { unique: true })
@Column({ name: 'name', type: 'varchar', length: 100 })
name!: string;
/** 관리자 메모 (담당자·정산 조건 등) */
@Column({ name: 'memo', type: 'text', nullable: true })
memo!: string | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
}
@@ -0,0 +1,306 @@
import type { Repository } from 'typeorm';
import type { ImwebMemberLookupService } from '@/modules/imweb/imweb-member-lookup.service';
import type { ImwebOrderData } from '@/modules/imweb/imweb.types';
import {
ConversionType,
PartnerConversion,
} from './entities/partner-conversion.entity';
import { PartnerLinkClick } from './entities/partner-link-click.entity';
import { PartnerLink } from './entities/partner-link.entity';
import { PartnerTouch } from './entities/partner-touch.entity';
import { PartnerAttributionService } from './partner-attribution.service';
const MEMBER_CODE = 'm20261005abcdef';
const LINK_A = { id: 'link-a', partnerId: 'partner-a', prodNo: 52 };
const LINK_B = { id: 'link-b', partnerId: 'partner-b', prodNo: 52 };
const DAY = 86_400_000;
/** insert().values().orIgnore().execute() 를 흉내 내 값을 store 에 넣는다 */
function insertBuilder<T>(store: T[]) {
const builder = {
insert: () => builder,
values: (rows: T | T[]) => {
store.push(...(Array.isArray(rows) ? rows : [rows]));
return builder;
},
orIgnore: () => builder,
execute: () => Promise.resolve(),
};
return builder;
}
function setup(options: { clicks?: Partial<PartnerLinkClick>[] } = {}) {
const touchStore: PartnerTouch[] = [];
const conversionStore: PartnerConversion[] = [];
const linksById = new Map(
[LINK_A, LINK_B].map((link) => [link.id, link as PartnerLink]),
);
const clicks = {
find: jest.fn(() => Promise.resolve(options.clicks ?? [])),
};
const touches = {
create: jest.fn((row: PartnerTouch) => row),
createQueryBuilder: jest.fn(() => insertBuilder(touchStore)),
find: jest.fn(({ where }: { where: Partial<PartnerTouch> }) =>
Promise.resolve(
touchStore.filter(
(t) => t.memberCode === where.memberCode && t.prodNo === where.prodNo,
),
),
),
};
const conversions = {
create: jest.fn((row: PartnerConversion) => row),
createQueryBuilder: jest.fn(() => insertBuilder(conversionStore)),
exists: jest.fn(({ where }: { where: Partial<PartnerConversion> }) =>
Promise.resolve(
conversionStore.some((c) => c.applicationId === where.applicationId),
),
),
findOne: jest.fn(({ where }: { where: Partial<PartnerConversion> }) =>
Promise.resolve(
conversionStore.find(
(c) => c.orderNo === where.orderNo && c.prodNo === where.prodNo,
) ?? null,
),
),
update: jest.fn((id: string, patch: Partial<PartnerConversion>) => {
const row = conversionStore.find((c) => c.id === id);
if (row) Object.assign(row, patch);
return Promise.resolve();
}),
};
const links = {
findOne: jest.fn(({ where }: { where: { id: string } }) =>
Promise.resolve(linksById.get(where.id) ?? null),
),
};
const lookup = {
find: jest.fn(() => Promise.resolve({ memberCode: MEMBER_CODE })),
};
const service = new PartnerAttributionService(
clicks as unknown as Repository<PartnerLinkClick>,
touches as unknown as Repository<PartnerTouch>,
conversions as unknown as Repository<PartnerConversion>,
links as unknown as Repository<PartnerLink>,
lookup as unknown as ImwebMemberLookupService,
);
/** 회원이 그 링크를 이 시각에 눌렀다고 기록해 둔다 */
const touch = (link: typeof LINK_A, clickedAt: Date) =>
touchStore.push({
id: `touch-${touchStore.length}`,
memberCode: MEMBER_CODE,
clickRef: `ref${touchStore.length}`.padEnd(16, '0'),
linkId: link.id,
prodNo: link.prodNo,
clickedAt,
createdAt: clickedAt,
});
return { service, touchStore, conversionStore, clicks, lookup, touch };
}
function order(overrides: Partial<ImwebOrderData> = {}): ImwebOrderData {
return {
orderNo: 202610050001,
isMember: 'Y',
memberCode: MEMBER_CODE,
wtime: new Date().toISOString(),
payments: [{ paymentStatus: 'PAYMENT_COMPLETE', isCancel: 'N' }],
sections: [
{
orderSectionStatus: 'PRODUCT_PREPARATION',
sectionItems: [{ isPromotion: 'N', productInfo: { prodNo: 52 } }],
},
],
...overrides,
};
}
describe('PartnerAttributionService.recordTouches', () => {
it('사용 중인 링크의 클릭만 회원과 잇는다 (클릭 시각은 서버 기록)', async () => {
const clickedAt = new Date(Date.now() - DAY);
const { service, touchStore } = setup({
clicks: [
{
ref: 'AAAAAAAAAAAAAAAA',
linkId: LINK_A.id,
clickedAt,
link: { ...LINK_A, isActive: true } as PartnerLink,
},
{
ref: 'BBBBBBBBBBBBBBBB',
linkId: LINK_B.id,
clickedAt,
link: { ...LINK_B, isActive: false } as PartnerLink,
},
],
});
await service.recordTouches(MEMBER_CODE, [
'AAAAAAAAAAAAAAAA',
'BBBBBBBBBBBBBBBB',
]);
expect(touchStore).toHaveLength(1);
expect(touchStore[0]).toMatchObject({
memberCode: MEMBER_CODE,
clickRef: 'AAAAAAAAAAAAAAAA',
linkId: LINK_A.id,
prodNo: 52,
clickedAt,
});
});
it('형식이 아닌 클릭 ID 만 오면 DB 를 보지 않는다', async () => {
const { service, clicks } = setup();
await service.recordTouches(MEMBER_CODE, ['../../etc', 'short']);
expect(clicks.find).not.toHaveBeenCalled();
});
});
describe('PartnerAttributionService.touchFromBrowser', () => {
it('아임웹에 없는 회원이면 조용히 버린다', async () => {
const { service, lookup, clicks } = setup();
lookup.find.mockResolvedValueOnce(null as never);
await service.touchFromBrowser({
memberUid: 'nobody@test.dev',
refs: ['AAAAAAAAAAAAAAAA'],
});
expect(clicks.find).not.toHaveBeenCalled();
});
it('회원 확인이 실패해도 예외를 밖으로 던지지 않는다 (공개 API)', async () => {
const { service, lookup } = setup();
lookup.find.mockRejectedValueOnce(new Error('imweb down'));
await expect(
service.touchFromBrowser({
memberUid: 'user@test.dev',
refs: ['AAAAAAAAAAAAAAAA'],
}),
).resolves.toBeUndefined();
});
});
describe('PartnerAttributionService.attributeApplication', () => {
const appliedAt = new Date('2026-10-20T00:00:00.000Z');
const application = {
id: 'app-1',
memberCode: MEMBER_CODE,
productIdx: '52',
appliedAt,
};
it('마지막으로 누른 링크에 신청을 귀속한다', async () => {
const { service, conversionStore, touch } = setup();
touch(LINK_A, new Date(appliedAt.getTime() - 5 * DAY));
touch(LINK_B, new Date(appliedAt.getTime() - 1 * DAY));
await service.attributeApplication(application);
expect(conversionStore).toHaveLength(1);
expect(conversionStore[0]).toMatchObject({
type: ConversionType.APPLICATION,
linkId: LINK_B.id,
partnerId: LINK_B.partnerId,
applicationId: 'app-1',
orderNo: null,
isValid: true,
convertedAt: appliedAt,
});
});
it('클릭이 없으면 아무것도 남기지 않는다', async () => {
const { service, conversionStore } = setup();
await service.attributeApplication(application);
expect(conversionStore).toHaveLength(0);
});
it('이미 귀속된 신청은 다시 남기지 않는다', async () => {
const { service, conversionStore, touch } = setup();
touch(LINK_A, new Date(appliedAt.getTime() - DAY));
await service.attributeApplication(application);
await service.attributeApplication(application);
expect(conversionStore).toHaveLength(1);
});
});
describe('PartnerAttributionService.attributeOrder', () => {
const linked = new Set([52]);
it('링크로 들어온 회원의 결제완료 주문을 구매로 잡는다', async () => {
const { service, conversionStore, touch } = setup();
const wtime = new Date();
touch(LINK_A, new Date(wtime.getTime() - 2 * DAY));
const result = await service.attributeOrder(
order({ wtime: wtime.toISOString() }),
linked,
);
expect(result).toEqual({ created: 1, updated: 0 });
expect(conversionStore[0]).toMatchObject({
type: ConversionType.PURCHASE,
linkId: LINK_A.id,
orderNo: '202610050001',
prodNo: 52,
applicationId: null,
isValid: true,
});
});
it('이미 잡힌 구매가 환불되면 지우지 않고 집계에서 뺀다', async () => {
const { service, conversionStore, touch } = setup();
touch(LINK_A, new Date(Date.now() - DAY));
await service.attributeOrder(order(), linked);
const result = await service.attributeOrder(
order({ payments: [{ paymentStatus: 'REFUND_COMPLETE' }] }),
linked,
);
expect(result).toEqual({ created: 0, updated: 1 });
expect(conversionStore).toHaveLength(1);
expect(conversionStore[0].isValid).toBe(false);
});
it('결제 전 주문·비회원 주문·링크 없는 상품은 잡지 않는다', async () => {
const { service, conversionStore, touch } = setup();
touch(LINK_A, new Date(Date.now() - DAY));
await service.attributeOrder(
order({ payments: [{ paymentStatus: 'PAYMENT_PREPARATION' }] }),
linked,
);
await service.attributeOrder(
order({ isMember: 'N', memberCode: null }),
linked,
);
await service.attributeOrder(order(), new Set([60]));
expect(conversionStore).toHaveLength(0);
});
it('클릭보다 먼저 한 주문은 그 링크의 실적이 아니다', async () => {
const { service, conversionStore, touch } = setup();
const wtime = new Date(Date.now() - 3 * DAY);
touch(LINK_A, new Date(Date.now() - DAY));
await service.attributeOrder(order({ wtime: wtime.toISOString() }), linked);
expect(conversionStore).toHaveLength(0);
});
});
@@ -0,0 +1,277 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { In, MoreThanOrEqual, Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { ImwebMemberLookupService } from '@/modules/imweb/imweb-member-lookup.service';
import { ImwebOrderData } from '@/modules/imweb/imweb.types';
import {
ATTRIBUTION_WINDOW_MS,
evaluateOrder,
pickLastTouch,
} from '@/shared/utils/partner-attribution';
import { CLICK_REF_PATTERN } from '@/shared/utils/partner-link';
import {
CreatePartnerTouchDto,
PARTNER_REFS_MAX,
} from './dto/partner-touch.dto';
import {
ConversionType,
PartnerConversion,
} from './entities/partner-conversion.entity';
import { PartnerLinkClick } from './entities/partner-link-click.entity';
import { PartnerLink } from './entities/partner-link.entity';
import { PartnerTouch } from './entities/partner-touch.entity';
/** 신청 기록 중 귀속에 필요한 값 */
export interface ApplicationForAttribution {
id: string;
memberCode: string;
productIdx: string;
appliedAt: Date;
}
/** 주문 한 건을 반영한 결과 */
export interface OrderAttributionResult {
created: number;
updated: number;
}
/**
* 업체 실적 귀속 — 회원과 링크 클릭을 잇고, 신청·구매를 마지막 클릭 링크에 기록한다.
*
* 규칙(마지막 클릭·30일)은 shared/utils/partner-attribution.ts 의 순수 함수가 정한다.
* 회원 ↔ 클릭 연결은 공개 페이지에서 오므로 실제 아임웹 회원인지, 실제로 있는 클릭인지만 확인한다.
*/
@Injectable()
export class PartnerAttributionService {
private readonly logger = new Logger(PartnerAttributionService.name);
constructor(
@InjectRepository(PartnerLinkClick)
private readonly clicks: Repository<PartnerLinkClick>,
@InjectRepository(PartnerTouch)
private readonly touches: Repository<PartnerTouch>,
@InjectRepository(PartnerConversion)
private readonly conversions: Repository<PartnerConversion>,
@InjectRepository(PartnerLink)
private readonly links: Repository<PartnerLink>,
private readonly lookup: ImwebMemberLookupService,
) {}
/**
* Header Code 스크립트가 보낸 회원 ↔ 클릭 연결.
* 공개 API 라 실패 사유를 밖에 알리지 않는다 — 없는 회원·잘못된 클릭은 조용히 버린다.
*/
async touchFromBrowser(dto: CreatePartnerTouchDto): Promise<void> {
if (dto.refs.length === 0) return;
try {
const member = await this.lookup.find(dto.memberUid);
if (!member) return;
await this.recordTouches(member.memberCode, dto.refs);
} catch (error) {
// 아임웹 미연결·일시 장애 등 — 다음 페이지 방문 때 다시 보낸다
const code = error instanceof BusinessException ? error.code : 'SYS_001';
this.logger.warn(`[${code}] 업체 링크 연결 기록 실패`);
}
}
/**
* 회원과 클릭을 잇는다. 30일이 지난 클릭과 중지된 링크의 클릭은 버린다.
* 같은 회원·클릭은 한 번만 남는다 (여러 번 보내도 안전).
*/
async recordTouches(
memberCode: string,
refs: readonly string[],
): Promise<void> {
const valid = [...new Set(refs)]
.filter((ref) => CLICK_REF_PATTERN.test(ref))
.slice(0, PARTNER_REFS_MAX);
if (valid.length === 0) return;
const since = new Date(Date.now() - ATTRIBUTION_WINDOW_MS);
const found = await this.clicks.find({
where: { ref: In(valid), clickedAt: MoreThanOrEqual(since) },
relations: { link: true },
});
const rows: PartnerTouch[] = [];
for (const click of found) {
if (!click.link?.isActive) continue;
rows.push(
this.touches.create({
id: randomUUID(),
memberCode,
clickRef: click.ref,
linkId: click.linkId,
prodNo: click.link.prodNo,
clickedAt: click.clickedAt,
}),
);
}
if (rows.length === 0) return;
await this.touches
.createQueryBuilder()
.insert()
.values(rows)
.orIgnore() // (회원, 클릭) 유일 — 이미 있으면 그대로 둔다
.execute();
}
/**
* 신청을 마지막 클릭 링크에 귀속한다. 귀속할 클릭이 없으면 아무것도 남기지 않는다.
* 이미 귀속된 신청이면 그대로 둔다 (여러 번 불러도 안전).
*/
async attributeApplication(
application: ApplicationForAttribution,
): Promise<void> {
const prodNo = Number(application.productIdx);
if (!Number.isInteger(prodNo)) return;
const exists = await this.conversions.exists({
where: { applicationId: application.id },
});
if (exists) return;
const touch = await this.findLastTouch(
application.memberCode,
prodNo,
application.appliedAt,
);
if (!touch) return;
await this.insertConversion({
type: ConversionType.APPLICATION,
touch,
memberCode: application.memberCode,
prodNo,
applicationId: application.id,
orderNo: null,
convertedAt: application.appliedAt,
});
this.logger.log(
`업체 실적(신청): link=${touch.linkId} member=${application.memberCode} product=${prodNo}`,
);
}
/**
* 아임웹 주문 한 건을 반영한다.
* - 이미 잡힌 구매는 환불·취소 여부만 갱신한다.
* - 새로 결제완료된 상품은 마지막 클릭 링크가 있으면 구매로 잡는다.
*
* @param linkedProdNos 업체 링크가 있는 상품번호 — 그 밖의 상품은 볼 필요가 없다
*/
async attributeOrder(
order: ImwebOrderData,
linkedProdNos: ReadonlySet<number>,
): Promise<OrderAttributionResult> {
const result: OrderAttributionResult = { created: 0, updated: 0 };
const orderNo = String(order.orderNo);
const orderedAt = new Date(order.wtime);
if (Number.isNaN(orderedAt.getTime())) return result;
const { products } = evaluateOrder(order);
for (const [prodNo, counts] of products) {
if (!linkedProdNos.has(prodNo)) continue;
const existing = await this.conversions.findOne({
where: { orderNo, prodNo },
});
if (existing) {
if (existing.isValid !== counts) {
await this.conversions.update(existing.id, { isValid: counts });
result.updated += 1;
}
continue;
}
// 결제 전이면 다음 동기화에서 다시 본다. 비회원 주문은 회원과 이을 수 없다.
if (!counts || order.isMember !== 'Y' || !order.memberCode) continue;
const touch = await this.findLastTouch(
order.memberCode,
prodNo,
orderedAt,
);
if (!touch) continue;
await this.insertConversion({
type: ConversionType.PURCHASE,
touch,
memberCode: order.memberCode,
prodNo,
applicationId: null,
orderNo,
convertedAt: orderedAt,
});
result.created += 1;
this.logger.log(
`업체 실적(구매): link=${touch.linkId} member=${order.memberCode} order=${orderNo} product=${prodNo}`,
);
}
return result;
}
/** 업체 링크가 있는 상품번호 — 주문 동기화가 볼 상품을 좁힌다 */
async linkedProdNos(): Promise<Set<number>> {
const rows = await this.links
.createQueryBuilder('l')
.select('DISTINCT l.prod_no', 'prodNo')
.getRawMany<{ prodNo: number }>();
return new Set(rows.map((row) => Number(row.prodNo)));
}
private async findLastTouch(
memberCode: string,
prodNo: number,
convertedAt: Date,
): Promise<PartnerTouch | null> {
const candidates = await this.touches.find({
where: {
memberCode,
prodNo,
clickedAt: MoreThanOrEqual(
new Date(convertedAt.getTime() - ATTRIBUTION_WINDOW_MS),
),
},
});
return pickLastTouch(candidates, convertedAt);
}
private async insertConversion(input: {
type: ConversionType;
touch: PartnerTouch;
memberCode: string;
prodNo: number;
applicationId: string | null;
orderNo: string | null;
convertedAt: Date;
}): Promise<void> {
const link = await this.links.findOne({
where: { id: input.touch.linkId },
});
if (!link) return;
await this.conversions
.createQueryBuilder()
.insert()
.values(
this.conversions.create({
id: randomUUID(),
type: input.type,
linkId: link.id,
partnerId: link.partnerId,
prodNo: input.prodNo,
memberCode: input.memberCode,
applicationId: input.applicationId,
orderNo: input.orderNo,
isValid: true,
touchedAt: input.touch.clickedAt,
convertedAt: input.convertedAt,
}),
)
.orIgnore() // 신청·주문당 하나 — 동시에 두 번 들어와도 한 건
.execute();
}
}
@@ -0,0 +1,132 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { PaginatedResult } from '@/common/dto/pagination.dto';
import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
import { ADMIN_BEARER_AUTH } from '@/modules/admin-auth/admin-auth.constants';
import {
ConversionListQueryDto,
ConversionResponseDto,
OrderSyncStatusDto,
} from './dto/partner-conversion.dto';
import {
CreatePartnerLinkDto,
PartnerLinkListQueryDto,
PartnerLinkResponseDto,
UpdatePartnerLinkDto,
} from './dto/partner-link.dto';
import { PartnerLinksService } from './partner-links.service';
import { PartnerOrderSyncService } from './partner-order-sync.service';
/** 업체 링크 관리 — 발급·중지와 링크별 신청·구매 실적 */
@ApiTags('Admin Partner Links')
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@UseGuards(AdminAuthGuard)
@Controller('admin/partner-links')
export class PartnerLinksController {
constructor(private readonly links: PartnerLinksService) {}
@Get()
@ApiOperation({ summary: '업체 링크 목록 (최근 발급순) — 기간 내 실적 포함' })
@ApiStandardResponse(PartnerLinkResponseDto, {
description: '조회 성공 — data 는 { items, pageInfo }',
isArray: true,
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '필터·기간·커서 형식 오류')
findMany(
@Query() query: PartnerLinkListQueryDto,
): Promise<PaginatedResult<PartnerLinkResponseDto>> {
return this.links.findMany(query);
}
@Post()
@ApiOperation({ summary: '업체 링크 발급 — 업체·상품 조합당 하나' })
@ApiStandardResponse(PartnerLinkResponseDto, {
status: 201,
description: '발급 성공',
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '입력값 오류')
@ApiErrorResponse('RES_001', '없는 업체, 또는 불러오지 않은 상품')
@ApiErrorResponse('PARTNER_001', '같은 업체·상품의 링크가 있음')
@ApiErrorResponse('PARTNER_003', '이동 주소가 사이트 밖')
create(@Body() dto: CreatePartnerLinkDto): Promise<PartnerLinkResponseDto> {
return this.links.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: '업체 링크 수정 — 이동 주소 변경, 사용/중지' })
@ApiStandardResponse(PartnerLinkResponseDto, { description: '수정 성공' })
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('RES_001', '없는 링크')
@ApiErrorResponse('PARTNER_003', '이동 주소가 사이트 밖')
update(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() dto: UpdatePartnerLinkDto,
): Promise<PartnerLinkResponseDto> {
return this.links.update(id, dto);
}
@Get(':id/conversions')
@ApiOperation({ summary: '링크 실적 상세 — 신청·구매한 회원 (최근순)' })
@ApiStandardResponse(ConversionResponseDto, {
description: '조회 성공 — data 는 { items, pageInfo }',
isArray: true,
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('RES_001', '없는 링크')
findConversions(
@Param('id', new ParseUUIDPipe()) id: string,
@Query() query: ConversionListQueryDto,
): Promise<PaginatedResult<ConversionResponseDto>> {
return this.links.findConversions(id, query);
}
}
/** 업체 구매 집계 새로고침 — 아임웹 주문을 다시 훑는다 (기본 1시간마다 자동) */
@ApiTags('Admin Partner Links')
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@UseGuards(AdminAuthGuard)
@Controller('admin/partner-order-sync-runs')
export class PartnerOrderSyncController {
constructor(private readonly orderSync: PartnerOrderSyncService) {}
@Post()
@HttpCode(HttpStatus.ACCEPTED)
@ApiOperation({
summary: '구매 집계 새로고침 시작 — 바로 응답하고 백그라운드에서 진행',
})
@ApiStandardResponse(OrderSyncStatusDto, {
status: 202,
description: '시작됨',
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
start(): OrderSyncStatusDto {
return this.orderSync.start();
}
@Get('latest')
@ApiOperation({ summary: '구매 집계 새로고침 상태 (서버 재시작 시 비워짐)' })
@ApiStandardResponse(OrderSyncStatusDto, { description: '조회 성공' })
@ApiErrorResponse('AUTH_001', '로그인 필요')
latest(): OrderSyncStatusDto {
return this.orderSync.getStatus();
}
}
@@ -0,0 +1,391 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { randomBytes, randomUUID } from 'crypto';
import { In, Repository } from 'typeorm';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { ImwebProduct } from '@/modules/imweb/entities/imweb-product.entity';
import { ImwebConfig } from '@/modules/imweb/imweb.config';
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
import { MembersService } from '@/modules/members/members.service';
import {
CLICK_REF_LENGTH,
LINK_CODE_LENGTH,
LINK_CODE_PATTERN,
isBotUserAgent,
normalizeTargetUrl,
toBase62,
withPartnerRef,
} from '@/shared/utils/partner-link';
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
import {
ConversionListQueryDto,
ConversionResponseDto,
} from './dto/partner-conversion.dto';
import {
CreatePartnerLinkDto,
PartnerLinkListQueryDto,
PartnerLinkResponseDto,
UpdatePartnerLinkDto,
} from './dto/partner-link.dto';
import { PartnerConversion } from './entities/partner-conversion.entity';
import { PartnerLinkClick } from './entities/partner-link-click.entity';
import { PartnerLink } from './entities/partner-link.entity';
import { Partner } from './entities/partner.entity';
import { PartnerStatsService } from './partner-stats.service';
import { PartnersService, isUniqueViolation } from './partners.service';
const ALIAS = 'l';
const CONVERSION_ALIAS = 'v';
/** 단축 링크 경로 — main.ts 에서 /api prefix 를 붙이지 않는다 */
export const SHORT_LINK_PATH = 'r';
/** 코드가 우연히 겹치면 다시 만든다 (62^8 이라 사실상 일어나지 않는다) */
const CODE_RETRIES = 3;
const UQ_PARTNER_PRODUCT = 'uq_partner_links_partner_product';
function constraintOf(error: unknown): string | undefined {
return (error as QueryErrorWithConstraint).driverError?.constraint;
}
interface QueryErrorWithConstraint {
driverError?: { constraint?: string };
}
/** 업체 링크 관리 — 발급·수정, 링크별 실적, 단축 링크 이동 */
@Injectable()
export class PartnerLinksService {
private readonly logger = new Logger(PartnerLinksService.name);
constructor(
@InjectRepository(PartnerLink)
private readonly links: Repository<PartnerLink>,
@InjectRepository(PartnerLinkClick)
private readonly clicks: Repository<PartnerLinkClick>,
@InjectRepository(PartnerConversion)
private readonly conversions: Repository<PartnerConversion>,
@InjectRepository(Partner)
private readonly partnerRows: Repository<Partner>,
@InjectRepository(ImwebProduct)
private readonly products: Repository<ImwebProduct>,
@InjectRepository(ImwebMember)
private readonly members: Repository<ImwebMember>,
private readonly partners: PartnersService,
private readonly stats: PartnerStatsService,
private readonly membersService: MembersService,
private readonly imwebConfig: ImwebConfig,
private readonly config: ConfigService,
) {}
/** 링크 목록 — 최근 발급순, 기간 내 실적 포함 */
async findMany(
query: PartnerLinkListQueryDto,
): Promise<PaginatedResult<PartnerLinkResponseDto>> {
const base = this.links.createQueryBuilder(ALIAS);
if (query.partnerId) {
base.andWhere(`${ALIAS}.partnerId = :partnerId`, {
partnerId: query.partnerId,
});
}
if (query.prodNo) {
base.andWhere(`${ALIAS}.prodNo = :prodNo`, { prodNo: query.prodNo });
}
const total = await base.clone().getCount();
const page = base
.clone()
.orderBy(`${ALIAS}.createdAt`, 'DESC')
.addOrderBy(`${ALIAS}.id`, 'DESC')
.take(query.limit + 1);
if (query.cursor) {
const cursor = decodeTimeCursor(query.cursor);
if (!cursor) {
throw new BusinessException('VAL_001', {
context: { reason: 'invalid_cursor' },
});
}
page.andWhere(
`(${ALIAS}.createdAt, ${ALIAS}.id) < (:cursorTime, :cursorId)`,
{ cursorTime: cursor.time, cursorId: cursor.key },
);
}
const rows = await page.getMany();
const hasNext = rows.length > query.limit;
const items = rows.slice(0, query.limit);
const last = items.at(-1);
return toPaginatedResult(await this.toResponses(items, query), {
hasNext,
nextCursor:
hasNext && last
? encodeTimeCursor({
time: last.createdAt.toISOString(),
key: last.id,
})
: null,
total,
});
}
/** 링크 발급 — 업체·상품 조합당 하나 */
async create(dto: CreatePartnerLinkDto): Promise<PartnerLinkResponseDto> {
await this.partners.require(dto.partnerId);
const product = await this.products.findOne({
where: { prodNo: dto.prodNo },
});
if (!product) {
// 관리자 화면에서 '상품 불러오기'를 먼저 해야 한다
throw new BusinessException('RES_001', {
context: { reason: 'product_not_synced', prodNo: dto.prodNo },
});
}
const targetUrl = this.requireTargetUrl(dto.targetUrl);
const duplicated = await this.links.exists({
where: { partnerId: dto.partnerId, prodNo: dto.prodNo },
});
if (duplicated) {
throw new BusinessException('PARTNER_001', {
context: { partnerId: dto.partnerId, prodNo: dto.prodNo },
});
}
for (let attempt = 1; attempt <= CODE_RETRIES; attempt += 1) {
const row = this.links.create({
id: randomUUID(),
code: toBase62(randomBytes(LINK_CODE_LENGTH)),
partnerId: dto.partnerId,
prodNo: dto.prodNo,
targetUrl,
isActive: true,
});
try {
await this.links.insert(row);
this.logger.log(
`업체 링크 발급: link=${row.id} partner=${dto.partnerId} product=${dto.prodNo}`,
);
return this.findOneResponse(row.id);
} catch (error) {
if (!isUniqueViolation(error)) throw error;
if (constraintOf(error) === UQ_PARTNER_PRODUCT) {
throw new BusinessException('PARTNER_001', {
context: { partnerId: dto.partnerId, prodNo: dto.prodNo },
});
}
// 코드가 겹친 경우 — 새 코드로 다시
}
}
throw new BusinessException('SYS_001', {
context: { reason: 'link_code_collision' },
});
}
async update(
id: string,
dto: UpdatePartnerLinkDto,
): Promise<PartnerLinkResponseDto> {
const row = await this.require(id);
if (dto.targetUrl !== undefined) {
row.targetUrl = this.requireTargetUrl(dto.targetUrl);
}
if (dto.isActive !== undefined) row.isActive = dto.isActive;
await this.links.save(row);
this.logger.log(`업체 링크 수정: link=${id} active=${row.isActive}`);
return this.findOneResponse(id);
}
/** 링크 실적 상세 — 어느 회원이 언제 신청·구매했는지 (최근순) */
async findConversions(
linkId: string,
query: ConversionListQueryDto,
): Promise<PaginatedResult<ConversionResponseDto>> {
await this.require(linkId);
const base = this.conversions
.createQueryBuilder(CONVERSION_ALIAS)
.where(`${CONVERSION_ALIAS}.linkId = :linkId`, { linkId });
if (query.type) {
base.andWhere(`${CONVERSION_ALIAS}.type = :type`, { type: query.type });
}
const total = await base.clone().getCount();
const page = base
.clone()
.orderBy(`${CONVERSION_ALIAS}.convertedAt`, 'DESC')
.addOrderBy(`${CONVERSION_ALIAS}.id`, 'DESC')
.take(query.limit + 1);
if (query.cursor) {
const cursor = decodeTimeCursor(query.cursor);
if (!cursor) {
throw new BusinessException('VAL_001', {
context: { reason: 'invalid_cursor' },
});
}
page.andWhere(
`(${CONVERSION_ALIAS}.convertedAt, ${CONVERSION_ALIAS}.id) < (:cursorTime, :cursorId)`,
{ cursorTime: cursor.time, cursorId: cursor.key },
);
}
const rows = await page.getMany();
const hasNext = rows.length > query.limit;
const items = rows.slice(0, query.limit);
const last = items.at(-1);
const codes = [...new Set(items.map((row) => row.memberCode))];
const [memberRows, groupTitles] = await Promise.all([
codes.length > 0
? this.members.find({ where: { memberCode: In(codes) } })
: Promise.resolve([]),
this.membersService.groupTitleMap(),
]);
const membersByCode = new Map(memberRows.map((m) => [m.memberCode, m]));
return toPaginatedResult(
items.map((row) => {
const member = membersByCode.get(row.memberCode);
return {
id: row.id,
type: row.type,
convertedAt: row.convertedAt.toISOString(),
touchedAt: row.touchedAt.toISOString(),
orderNo: row.orderNo,
isValid: row.isValid,
memberCode: row.memberCode,
member: member ? MemberResponseDto.from(member, groupTitles) : null,
};
}),
{
hasNext,
nextCursor:
hasNext && last
? encodeTimeCursor({
time: last.convertedAt.toISOString(),
key: last.id,
})
: null,
total,
},
);
}
/**
* 단축 링크가 보낼 주소.
* - 사용 중인 링크: 사람의 클릭이면 기록하고 클릭 ID 를 붙인다. 봇·미리보기는 기록 없이 이동만.
* - 중지된 링크: 이동만 시킨다 (실적을 남기지 않는다).
* - 없는 코드: 사이트 첫 화면으로 보낸다.
*/
async resolveRedirect(
code: string,
userAgent: string | undefined,
method: string,
): Promise<string> {
const link = LINK_CODE_PATTERN.test(code)
? await this.links.findOne({ where: { code } })
: null;
if (!link) {
const home = this.imwebConfig.siteUrl;
if (!home) throw new BusinessException('RES_001', { context: { code } });
return home;
}
if (!link.isActive || method !== 'GET' || isBotUserAgent(userAgent)) {
return link.targetUrl;
}
const ref = toBase62(randomBytes(CLICK_REF_LENGTH));
try {
await this.clicks.insert({ ref, linkId: link.id, clickedAt: new Date() });
} catch (error) {
// 클릭 기록이 실패해도 이동은 시킨다 — 업체 링크가 막히는 것이 더 나쁘다
this.logger.warn(
`업체 링크 클릭 기록 실패: link=${link.id} ${String(error)}`,
);
return link.targetUrl;
}
return withPartnerRef(link.targetUrl, ref);
}
private async require(id: string): Promise<PartnerLink> {
const row = await this.links.findOne({ where: { id } });
if (!row)
throw new BusinessException('RES_001', { context: { linkId: id } });
return row;
}
private requireTargetUrl(raw: string): string {
const siteUrl = this.imwebConfig.siteUrl;
if (!siteUrl) {
throw new BusinessException('IMWEB_005', {
context: { reason: 'missing_site_url' },
});
}
const normalized = normalizeTargetUrl(raw, siteUrl);
if (!normalized) {
throw new BusinessException('PARTNER_003', { context: { siteUrl } });
}
return normalized;
}
private async findOneResponse(id: string): Promise<PartnerLinkResponseDto> {
const row = await this.require(id);
const [response] = await this.toResponses([row], {});
return response;
}
private async toResponses(
rows: PartnerLink[],
period: { from?: string; to?: string },
): Promise<PartnerLinkResponseDto[]> {
if (rows.length === 0) return [];
const partnerIds = [...new Set(rows.map((row) => row.partnerId))];
const prodNos = [...new Set(rows.map((row) => row.prodNo))];
const [statsById, partnerRows, productRows] = await Promise.all([
this.stats.byLinks(
rows.map((row) => row.id),
period,
),
this.partnerRows.find({ where: { id: In(partnerIds) } }),
this.products.find({ where: { prodNo: In(prodNos) } }),
]);
const partnerById = new Map(partnerRows.map((p) => [p.id, p]));
const productByNo = new Map(productRows.map((p) => [p.prodNo, p]));
const shortBase = this.shortLinkBase();
return rows.map((row) => ({
id: row.id,
code: row.code,
shortUrl: shortBase ? `${shortBase}/${SHORT_LINK_PATH}/${row.code}` : '',
partner: {
id: row.partnerId,
name: partnerById.get(row.partnerId)?.name ?? '',
},
product: {
prodNo: row.prodNo,
name: productByNo.get(row.prodNo)?.name ?? null,
},
targetUrl: row.targetUrl,
isActive: row.isActive,
stats: statsById.get(row.id) ?? PartnerStatsService.empty(),
createdAt: row.createdAt.toISOString(),
}));
}
/** 단축 링크 앞부분 — 백엔드 공개 주소(BACKEND_API_URL)의 origin */
private shortLinkBase(): string {
const apiUrl = (this.config.get<string>('BACKEND_API_URL') ?? '').trim();
if (!apiUrl) return '';
try {
return new URL(apiUrl).origin;
} catch {
return '';
}
}
}
@@ -0,0 +1,186 @@
import {
Injectable,
Logger,
OnApplicationBootstrap,
OnApplicationShutdown,
} from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { BusinessException } from '@/common/errors/business.exception';
import { IMWEB_ORDERS_PATH, ImwebConfig } from '@/modules/imweb/imweb.config';
import { ImwebAuthService } from '@/modules/imweb/imweb-auth.service';
import { ImwebOrderPageData } from '@/modules/imweb/imweb.types';
import { OrderSyncStatusDto } from './dto/partner-conversion.dto';
import { PartnerAttributionService } from './partner-attribution.service';
const SCHEDULE_NAME = 'partner-order-sync';
/**
* 이만큼 지난 주문까지 다시 본다 — 귀속 기간(30일)에 환불·취소가 늦게 반영되는 기간을 더했다.
* 그보다 오래된 구매의 환불은 반영되지 않는다.
*/
const ORDER_LOOKBACK_DAYS = 60;
const DAY_MS = 86_400_000;
const ORDER_PAGE_SIZE = 100;
/** 페이지 사이 쉬는 시간 — 아임웹 호출 제한이 문서에 없어 보수적으로 둔다 */
const PAGE_DELAY_MS = 300;
/** 응답이 이상해도 무한 루프에 빠지지 않도록 */
const MAX_PAGES = 1_000;
const FALLBACK_ERROR_CODE = 'SYS_001';
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** 아임웹 예시 형식(2024-09-01T00:00:00Z)에 맞춰 밀리초를 뗀다 */
function toImwebTime(date: Date): string {
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
}
/**
* 업체 링크 구매 집계 — 아임웹 주문을 주기적으로 훑어 구매를 잡고 환불·취소를 반영한다.
*
* 아임웹은 주문 완료를 알려주지 않으므로(웹훅 없음) 회원 동기화와 같은 주기로 최근 주문을 다시 본다.
* 업체 링크가 하나도 없으면 아임웹을 부르지 않는다.
*/
@Injectable()
export class PartnerOrderSyncService
implements OnApplicationBootstrap, OnApplicationShutdown
{
private readonly logger = new Logger(PartnerOrderSyncService.name);
/** 진행 중인 실행 — 동시에 두 번 돌지 않게 하고, 종료 시 기다린다 */
private running: Promise<void> | null = null;
private status: OrderSyncStatusDto = {
isRunning: false,
lastStartedAt: null,
lastFinishedAt: null,
lastErrorCode: null,
checkedOrderCount: 0,
createdCount: 0,
};
constructor(
private readonly attribution: PartnerAttributionService,
private readonly auth: ImwebAuthService,
private readonly config: ImwebConfig,
private readonly scheduler: SchedulerRegistry,
) {}
onApplicationBootstrap(): void {
const timer = setInterval(() => {
void this.runScheduled();
}, this.config.syncIntervalMs);
this.scheduler.addInterval(SCHEDULE_NAME, timer);
}
async onApplicationShutdown(): Promise<void> {
if (this.scheduler.doesExist('interval', SCHEDULE_NAME)) {
this.scheduler.deleteInterval(SCHEDULE_NAME);
}
await this.waitForIdle();
}
async waitForIdle(): Promise<void> {
await this.running?.catch(() => undefined);
}
getStatus(): OrderSyncStatusDto {
return { ...this.status };
}
/**
* 새로고침을 시작하고 상태를 바로 돌려준다 (끝까지 기다리지 않는다).
* 이미 진행 중이면 새로 시작하지 않고 현재 상태를 돌려준다.
*/
start(): OrderSyncStatusDto {
if (!this.running) {
this.status = {
...this.status,
isRunning: true,
lastStartedAt: new Date().toISOString(),
};
this.running = this.execute().finally(() => {
this.running = null;
this.status = {
...this.status,
isRunning: false,
lastFinishedAt: new Date().toISOString(),
};
});
}
return this.getStatus();
}
private async runScheduled(): Promise<void> {
if (this.running || !this.config.isConfigured()) return;
const connection = await this.auth.findConnection();
if (!connection) return; // 아직 연결 전이면 조용히 건너뛴다
this.start();
}
private async execute(): Promise<void> {
const startedAt = Date.now();
let checked = 0;
let created = 0;
let updated = 0;
try {
const linkedProdNos = await this.attribution.linkedProdNos();
if (linkedProdNos.size > 0) {
const { unitCode } = await this.auth.requireConnection();
const since = new Date(Date.now() - ORDER_LOOKBACK_DAYS * DAY_MS);
for (let page = 1; page <= MAX_PAGES; page += 1) {
if (page > 1) await sleep(PAGE_DELAY_MS);
const { data } = await this.auth.authorizedGet<ImwebOrderPageData>(
IMWEB_ORDERS_PATH,
{
page,
limit: ORDER_PAGE_SIZE,
unitCode,
isMember: 'Y',
startWtime: toImwebTime(since),
},
);
for (const order of data.list) {
const result = await this.attribution.attributeOrder(
order,
linkedProdNos,
);
created += result.created;
updated += result.updated;
}
checked += data.list.length;
if (data.list.length === 0 || page >= data.totalPage) break;
}
}
this.status = {
...this.status,
lastErrorCode: null,
checkedOrderCount: checked,
createdCount: created,
};
this.logger.log(
`업체 구매 집계 완료: orders=${checked} created=${created} updated=${updated} ${Date.now() - startedAt}ms`,
);
} catch (error) {
const code =
error instanceof BusinessException ? error.code : FALLBACK_ERROR_CODE;
this.status = {
...this.status,
lastErrorCode: code,
checkedOrderCount: checked,
createdCount: created,
};
// 권한 부족·미연결 등 예상된 실패는 warn, 그 밖의 오류만 error 로 남긴다
if (error instanceof BusinessException) {
this.logger.warn(`[${code}] 업체 구매 집계 실패`);
} else {
this.logger.error(
`[${code}] 업체 구매 집계 실패`,
error instanceof Error ? error.stack : String(error),
);
}
}
}
}
@@ -0,0 +1,79 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Req,
Res,
} from '@nestjs/common';
import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger';
import { SkipThrottle, Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
import { ApiErrorResponse } from '@/common/decorators/api-standard-response.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { CreatePartnerTouchDto } from './dto/partner-touch.dto';
import { PartnerAttributionService } from './partner-attribution.service';
import { PartnerLinksService, SHORT_LINK_PATH } from './partner-links.service';
const REFERRER_POLICY_HEADER = 'Referrer-Policy';
/** 브라우저 기본값 — 다른 사이트로는 출처(도메인)만 보낸다 */
const REDIRECT_REFERRER_POLICY = 'strict-origin-when-cross-origin';
/**
* 업체 단축 링크 — moneyguide.wandergo.net/r/{코드} (main.ts 에서 /api prefix 제외).
*
* 사람이 보는 화면 이동이라 JSON 대신 상품 페이지로 리다이렉트한다.
* 같은 통신사 IP 뒤의 여러 사용자가 동시에 누를 수 있어 요청 수 제한을 걸지 않는다
* (클릭 수가 부풀 수는 있지만 실적은 실제 회원의 신청·구매로만 잡힌다).
*/
@ApiExcludeController()
@SkipTransform()
@SkipThrottle()
@Controller(SHORT_LINK_PATH)
export class PartnerRedirectController {
constructor(private readonly links: PartnerLinksService) {}
@Get(':code')
async redirect(
@Param('code') code: string,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
const target = await this.links.resolveRedirect(
code,
req.headers['user-agent'],
req.method,
);
// 매 클릭을 서버가 받아야 클릭 ID 가 새로 발급된다 — 브라우저·프록시가 저장하지 않게 한다
res.setHeader('Cache-Control', 'no-store');
// helmet 기본값(no-referrer)이 리다이렉트에 붙으면 업체가 링크를 올린 곳(블로그 등)이 지워져
// 상품 페이지의 UTM 스크립트가 유입원을 direct 로 기록한다 — 출처(도메인)만은 넘긴다
res.setHeader(REFERRER_POLICY_HEADER, REDIRECT_REFERRER_POLICY);
res.redirect(HttpStatus.FOUND, target);
}
}
/** 아임웹 Header Code(imweb/imweb-header-top.html 의 업체 링크 추적)가 회원 ↔ 링크 클릭 연결을 보낸다 */
@ApiTags('Partner Touches')
@Controller('partner-touches')
export class PartnerTouchesController {
constructor(private readonly attribution: PartnerAttributionService) {}
@Post()
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform()
@Throttle({ default: { ttl: 60_000, limit: 30 } })
@ApiOperation({
summary:
'회원 ↔ 업체 링크 클릭 연결 — 항상 204 (없는 회원·클릭은 조용히 버린다)',
})
@ApiErrorResponse('VAL_001', '입력값 오류')
@ApiErrorResponse('REQ_001', '요청 수 초과 (1분 30회)')
async create(@Body() dto: CreatePartnerTouchDto): Promise<void> {
await this.attribution.touchFromBrowser(dto);
}
}
@@ -0,0 +1,160 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, SelectQueryBuilder } from 'typeorm';
import { PartnerStatsDto } from './dto/partner.dto';
import {
ConversionType,
PartnerConversion,
} from './entities/partner-conversion.entity';
import { PartnerLinkClick } from './entities/partner-link-click.entity';
import { PartnerLink } from './entities/partner-link.entity';
/** 집계 기간 — 없으면 전체 */
export interface StatsPeriod {
from?: string;
to?: string;
}
const EMPTY_STATS: PartnerStatsDto = {
clickCount: 0,
applicationCount: 0,
purchaseCount: 0,
};
function withPeriod<T extends object>(
query: SelectQueryBuilder<T>,
column: string,
period: StatsPeriod,
): SelectQueryBuilder<T> {
if (period.from) query.andWhere(`${column} >= :from`, { from: period.from });
if (period.to) query.andWhere(`${column} < :to`, { to: period.to });
return query;
}
/** 링크·업체별 클릭·신청·구매 수 집계 (환불·취소된 구매는 뺀다) */
@Injectable()
export class PartnerStatsService {
constructor(
@InjectRepository(PartnerLinkClick)
private readonly clicks: Repository<PartnerLinkClick>,
@InjectRepository(PartnerConversion)
private readonly conversions: Repository<PartnerConversion>,
@InjectRepository(PartnerLink)
private readonly links: Repository<PartnerLink>,
) {}
/** 링크 ID → 실적 */
async byLinks(
linkIds: string[],
period: StatsPeriod,
): Promise<Map<string, PartnerStatsDto>> {
if (linkIds.length === 0) return new Map();
const [clickRows, conversionRows] = await Promise.all([
withPeriod(
this.clicks
.createQueryBuilder('c')
.select('c.link_id', 'key')
.addSelect('COUNT(*)', 'count')
.where('c.link_id IN (:...ids)', { ids: linkIds })
.groupBy('c.link_id'),
'c.clicked_at',
period,
).getRawMany<{ key: string; count: string }>(),
withPeriod(
this.conversions
.createQueryBuilder('v')
.select('v.link_id', 'key')
.addSelect('v.type', 'type')
.addSelect('COUNT(*)', 'count')
.where('v.link_id IN (:...ids)', { ids: linkIds })
.andWhere('v.is_valid = true')
.groupBy('v.link_id')
.addGroupBy('v.type'),
'v.converted_at',
period,
).getRawMany<{ key: string; type: ConversionType; count: string }>(),
]);
return this.merge(clickRows, conversionRows);
}
/** 업체 ID → 실적 (모든 링크 합계) */
async byPartners(
partnerIds: string[],
period: StatsPeriod,
): Promise<Map<string, PartnerStatsDto>> {
if (partnerIds.length === 0) return new Map();
const [clickRows, conversionRows] = await Promise.all([
withPeriod(
this.clicks
.createQueryBuilder('c')
.innerJoin(PartnerLink, 'l', 'l.id = c.link_id')
.select('l.partner_id', 'key')
.addSelect('COUNT(*)', 'count')
.where('l.partner_id IN (:...ids)', { ids: partnerIds })
.groupBy('l.partner_id'),
'c.clicked_at',
period,
).getRawMany<{ key: string; count: string }>(),
withPeriod(
this.conversions
.createQueryBuilder('v')
.select('v.partner_id', 'key')
.addSelect('v.type', 'type')
.addSelect('COUNT(*)', 'count')
.where('v.partner_id IN (:...ids)', { ids: partnerIds })
.andWhere('v.is_valid = true')
.groupBy('v.partner_id')
.addGroupBy('v.type'),
'v.converted_at',
period,
).getRawMany<{ key: string; type: ConversionType; count: string }>(),
]);
return this.merge(clickRows, conversionRows);
}
/** 업체 ID → 발급된 링크 수 */
async linkCounts(partnerIds: string[]): Promise<Map<string, number>> {
if (partnerIds.length === 0) return new Map();
const rows = await this.links
.createQueryBuilder('l')
.select('l.partner_id', 'key')
.addSelect('COUNT(*)', 'count')
.where('l.partner_id IN (:...ids)', { ids: partnerIds })
.groupBy('l.partner_id')
.getRawMany<{ key: string; count: string }>();
return new Map(rows.map((row) => [row.key, Number(row.count)]));
}
private merge(
clickRows: { key: string; count: string }[],
conversionRows: { key: string; type: ConversionType; count: string }[],
): Map<string, PartnerStatsDto> {
const stats = new Map<string, PartnerStatsDto>();
const get = (key: string): PartnerStatsDto => {
let value = stats.get(key);
if (!value) {
value = { ...EMPTY_STATS };
stats.set(key, value);
}
return value;
};
for (const row of clickRows) get(row.key).clickCount = Number(row.count);
for (const row of conversionRows) {
const value = get(row.key);
if (row.type === ConversionType.APPLICATION) {
value.applicationCount = Number(row.count);
} else if (row.type === ConversionType.PURCHASE) {
value.purchaseCount = Number(row.count);
}
}
return stats;
}
static empty(): PartnerStatsDto {
return { ...EMPTY_STATS };
}
}
@@ -0,0 +1,91 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { PaginatedResult } from '@/common/dto/pagination.dto';
import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
import { ADMIN_BEARER_AUTH } from '@/modules/admin-auth/admin-auth.constants';
import {
CreatePartnerDto,
PartnerPeriodQueryDto,
PartnerResponseDto,
UpdatePartnerDto,
} from './dto/partner.dto';
import { PartnersService } from './partners.service';
/** 업체 관리 — 업체별 링크를 발급해 신청·구매를 추적한다 */
@ApiTags('Admin Partners')
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@UseGuards(AdminAuthGuard)
@Controller('admin/partners')
export class PartnersController {
constructor(private readonly partners: PartnersService) {}
@Get()
@ApiOperation({ summary: '업체 목록 (최근 등록순) — 기간 내 실적 포함' })
@ApiStandardResponse(PartnerResponseDto, {
description: '조회 성공 — data 는 { items, pageInfo }',
isArray: true,
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '기간·커서 형식 오류')
findMany(
@Query() query: PartnerPeriodQueryDto,
): Promise<PaginatedResult<PartnerResponseDto>> {
return this.partners.findMany(query);
}
@Post()
@ApiOperation({ summary: '업체 등록' })
@ApiStandardResponse(PartnerResponseDto, {
status: 201,
description: '등록 성공',
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '입력값 오류')
@ApiErrorResponse('PARTNER_004', '같은 이름의 업체가 있음')
create(@Body() dto: CreatePartnerDto): Promise<PartnerResponseDto> {
return this.partners.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: '업체 이름·메모 수정' })
@ApiStandardResponse(PartnerResponseDto, { description: '수정 성공' })
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('RES_001', '없는 업체')
@ApiErrorResponse('PARTNER_004', '같은 이름의 업체가 있음')
update(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() dto: UpdatePartnerDto,
): Promise<PartnerResponseDto> {
return this.partners.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform()
@ApiOperation({ summary: '업체 삭제 — 링크가 없는 업체만' })
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('RES_001', '없는 업체')
@ApiErrorResponse('PARTNER_002', '링크가 발급된 업체')
async remove(@Param('id', new ParseUUIDPipe()) id: string): Promise<void> {
await this.partners.remove(id);
}
}
@@ -0,0 +1,65 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminAuthModule } from '@/modules/admin-auth/admin-auth.module';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { ImwebProduct } from '@/modules/imweb/entities/imweb-product.entity';
import { ImwebModule } from '@/modules/imweb/imweb.module';
import { MembersModule } from '@/modules/members/members.module';
import { PartnerConversion } from './entities/partner-conversion.entity';
import { PartnerLinkClick } from './entities/partner-link-click.entity';
import { PartnerLink } from './entities/partner-link.entity';
import { PartnerTouch } from './entities/partner-touch.entity';
import { Partner } from './entities/partner.entity';
import { PartnerAttributionService } from './partner-attribution.service';
import {
PartnerLinksController,
PartnerOrderSyncController,
} from './partner-links.controller';
import { PartnerLinksService } from './partner-links.service';
import { PartnerOrderSyncService } from './partner-order-sync.service';
import {
PartnerRedirectController,
PartnerTouchesController,
} from './partner-public.controller';
import { PartnerStatsService } from './partner-stats.service';
import { PartnersController } from './partners.controller';
import { PartnersService } from './partners.service';
/**
* 업체 링크 — 업체·상품별 단축 링크를 발급하고, 그 링크로 들어온 회원의 신청·구매를 센다.
* 신청은 ApplicationsModule 이 PartnerAttributionService 를 불러 귀속하고,
* 구매는 PartnerOrderSyncService 가 아임웹 주문을 주기적으로 훑어 찾는다.
*/
@Module({
imports: [
TypeOrmModule.forFeature([
Partner,
PartnerLink,
PartnerLinkClick,
PartnerTouch,
PartnerConversion,
ImwebProduct,
ImwebMember,
]),
AdminAuthModule,
ImwebModule,
MembersModule,
],
controllers: [
PartnersController,
PartnerLinksController,
PartnerOrderSyncController,
PartnerRedirectController,
PartnerTouchesController,
],
providers: [
PartnersService,
PartnerLinksService,
PartnerStatsService,
PartnerAttributionService,
PartnerOrderSyncService,
],
exports: [PartnerAttributionService],
})
export class PartnersModule {}
@@ -0,0 +1,165 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { QueryFailedError, Repository } from 'typeorm';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
import {
CreatePartnerDto,
PartnerPeriodQueryDto,
PartnerResponseDto,
UpdatePartnerDto,
} from './dto/partner.dto';
import { PartnerLink } from './entities/partner-link.entity';
import { Partner } from './entities/partner.entity';
import { PartnerStatsService } from './partner-stats.service';
const ALIAS = 'p';
/** Postgres 유일 제약 위반 */
const PG_UNIQUE_VIOLATION = '23505';
export function isUniqueViolation(error: unknown): boolean {
return (
error instanceof QueryFailedError &&
(error.driverError as { code?: string } | undefined)?.code ===
PG_UNIQUE_VIOLATION
);
}
/** 업체 관리 — 등록·수정·삭제와 업체별 실적 */
@Injectable()
export class PartnersService {
private readonly logger = new Logger(PartnersService.name);
constructor(
@InjectRepository(Partner)
private readonly partners: Repository<Partner>,
@InjectRepository(PartnerLink)
private readonly links: Repository<PartnerLink>,
private readonly stats: PartnerStatsService,
) {}
/** 업체 목록 — 최근 등록순, 기간 내 실적 포함 */
async findMany(
query: PartnerPeriodQueryDto,
): Promise<PaginatedResult<PartnerResponseDto>> {
const total = await this.partners.count();
const page = this.partners
.createQueryBuilder(ALIAS)
.orderBy(`${ALIAS}.createdAt`, 'DESC')
.addOrderBy(`${ALIAS}.id`, 'DESC')
.take(query.limit + 1);
if (query.cursor) {
const cursor = decodeTimeCursor(query.cursor);
if (!cursor) {
throw new BusinessException('VAL_001', {
context: { reason: 'invalid_cursor' },
});
}
page.andWhere(
`(${ALIAS}.createdAt, ${ALIAS}.id) < (:cursorTime, :cursorId)`,
{ cursorTime: cursor.time, cursorId: cursor.key },
);
}
const rows = await page.getMany();
const hasNext = rows.length > query.limit;
const items = rows.slice(0, query.limit);
const ids = items.map((row) => row.id);
const [statsById, linkCounts] = await Promise.all([
this.stats.byPartners(ids, query),
this.stats.linkCounts(ids),
]);
const last = items.at(-1);
return toPaginatedResult(
items.map((row) =>
PartnerResponseDto.from(
row,
linkCounts.get(row.id) ?? 0,
statsById.get(row.id) ?? PartnerStatsService.empty(),
),
),
{
hasNext,
nextCursor:
hasNext && last
? encodeTimeCursor({
time: last.createdAt.toISOString(),
key: last.id,
})
: null,
total,
},
);
}
async create(dto: CreatePartnerDto): Promise<PartnerResponseDto> {
const row = this.partners.create({
id: randomUUID(),
name: dto.name.trim(),
memo: dto.memo?.trim() || null,
});
await this.saveUnique(row);
this.logger.log(`업체 등록: id=${row.id}`);
return PartnerResponseDto.from(row);
}
async update(id: string, dto: UpdatePartnerDto): Promise<PartnerResponseDto> {
const row = await this.require(id);
if (dto.name !== undefined) row.name = dto.name.trim();
if (dto.memo !== undefined) row.memo = dto.memo.trim() || null;
await this.saveUnique(row);
const [statsById, linkCounts] = await Promise.all([
this.stats.byPartners([id], {}),
this.stats.linkCounts([id]),
]);
return PartnerResponseDto.from(
row,
linkCounts.get(id) ?? 0,
statsById.get(id) ?? PartnerStatsService.empty(),
);
}
/** 링크가 하나라도 있으면 지우지 않는다 — 실적 기록이 업체를 가리키고 있다 */
async remove(id: string): Promise<void> {
await this.require(id);
const hasLinks = await this.links.exists({ where: { partnerId: id } });
if (hasLinks) {
throw new BusinessException('PARTNER_002', {
context: { partnerId: id },
});
}
await this.partners.delete(id);
this.logger.log(`업체 삭제: id=${id}`);
}
/** 업체 — 없으면 RES_001 */
async require(id: string): Promise<Partner> {
const row = await this.partners.findOne({ where: { id } });
if (!row) {
throw new BusinessException('RES_001', { context: { partnerId: id } });
}
return row;
}
private async saveUnique(row: Partner): Promise<void> {
try {
await this.partners.save(row);
} catch (error) {
if (isUniqueViolation(error)) {
throw new BusinessException('PARTNER_004', {
context: { partnerId: row.id },
});
}
throw error;
}
}
}
@@ -12,6 +12,13 @@ const prod = (extra: Record<string, string> = {}) => ({
DB_USER: 'app_user',
DB_PASSWORD: 'a-real-secret',
DB_NAME: 'project_db',
JWT_SECRET: 'a-real-jwt-secret',
ADMIN_USERNAME: 'moneyguider',
ADMIN_PASSWORD: 'a-long-real-password',
IMWEB_CLIENT_ID: 'client-id',
IMWEB_CLIENT_SECRET: 'a-real-client-secret',
IMWEB_SITE_CODE: 'S20230730923b9e1418574',
IMWEB_ADMIN_PAGE_URL: 'https://www.moneyguider.co.kr/admin-members',
...extra,
});
@@ -45,6 +52,74 @@ describe('validateEnv', () => {
);
});
it('관리자 인증·아임웹 연동 설정이 없으면 실패한다', () => {
const missing = omit(
prod(),
'JWT_SECRET',
'IMWEB_CLIENT_SECRET',
'IMWEB_ADMIN_PAGE_URL',
);
expect(() => validateEnv(missing)).toThrow(
/JWT_SECRET 가 설정되지 않았습니다/,
);
expect(() => validateEnv(missing)).toThrow(
/IMWEB_CLIENT_SECRET 가 설정되지 않았습니다/,
);
expect(() => validateEnv(missing)).toThrow(
/IMWEB_ADMIN_PAGE_URL 가 설정되지 않았습니다/,
);
});
it('관리자 계정이 없거나 예시 값이면 실패한다', () => {
expect(() =>
validateEnv(omit(prod(), 'ADMIN_USERNAME', 'ADMIN_PASSWORD')),
).toThrow(/ADMIN_PASSWORD 가 설정되지 않았습니다/);
expect(() =>
validateEnv(prod({ ADMIN_PASSWORD: 'change-me-admin-password' })),
).toThrow(/ADMIN_PASSWORD 가 예시 값 그대로입니다/);
});
it('관리자 비밀번호가 12자 미만이면 환경과 무관하게 실패한다', () => {
expect(() =>
validateEnv({ NODE_ENV: 'development', ADMIN_PASSWORD: 'short' }),
).toThrow(/ADMIN_PASSWORD 는 12자 이상/);
expect(() =>
validateEnv({
NODE_ENV: 'development',
ADMIN_PASSWORD: 'twelve-chars',
}),
).not.toThrow();
});
it('아임웹 관리자 화면 주소가 로컬 주소면 실패한다', () => {
expect(() =>
validateEnv(
prod({
IMWEB_ADMIN_PAGE_URL: 'http://localhost:5173/imweb-members.html',
}),
),
).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({
NODE_ENV: 'development',
IMWEB_SYNC_INTERVAL_MINUTES: '0',
}),
).toThrow(/IMWEB_SYNC_INTERVAL_MINUTES 는 양의 정수/);
});
it('예시 placeholder 가 그대로면 실패한다', () => {
expect(() =>
validateEnv(prod({ DB_PASSWORD: 'CHANGE-ME-BEFORE-DEPLOY' })),
+35 -1
View File
@@ -16,6 +16,15 @@ const REQUIRED_IN_PRODUCTION = [
'DB_USER',
'DB_PASSWORD',
'DB_NAME',
// 관리자 인증 — 없으면 아무도 로그인할 수 없다 (env 단일 계정)
'JWT_SECRET',
'ADMIN_USERNAME',
'ADMIN_PASSWORD',
// 아임웹 연동 — 회원 유입경로 관리자 화면의 데이터 원천
'IMWEB_CLIENT_ID',
'IMWEB_CLIENT_SECRET',
'IMWEB_SITE_CODE',
'IMWEB_ADMIN_PAGE_URL',
] as const;
/** 값이 있다면 양의 정수여야 하는 키 */
@@ -24,6 +33,8 @@ const POSITIVE_INT_KEYS = [
'DB_PORT',
'THROTTLE_TTL_MS',
'THROTTLE_LIMIT',
'IMWEB_SYNC_INTERVAL_MINUTES',
'IMWEB_PAGE_SIZE',
] as const;
/** 운영 환경에서 placeholder 로 남아 있으면 안 되는 키 */
@@ -31,14 +42,26 @@ const NO_PLACEHOLDER_IN_PRODUCTION = [
'DB_PASSWORD',
'REDIS_PASSWORD',
'JWT_SECRET',
'IMWEB_CLIENT_SECRET',
'ADMIN_PASSWORD',
] as const;
/** 관리자 비밀번호 최소 길이 — 회원 개인정보를 여는 유일한 열쇠라 짧은 값을 막는다 */
const ADMIN_PASSWORD_MIN_LENGTH = 12;
/**
* 운영에서 **공개 주소**여야 하는 키.
* 로컬 주소가 남은 채 배포되면 서버는 정상 기동하지만
* 브라우저가 API 에 닿지 못하거나 CORS 화이트리스트가 실제 도메인을 막아 **조용히** 실패한다.
*/
const PUBLIC_URL_IN_PRODUCTION = ['CORS_ORIGIN', 'BACKEND_API_URL'] as const;
const PUBLIC_URL_IN_PRODUCTION = [
'CORS_ORIGIN',
'BACKEND_API_URL',
// 아임웹 OAuth 가 끝나면 브라우저를 이 주소로 돌려보낸다
'IMWEB_ADMIN_PAGE_URL',
// (선택) 업체 링크의 이동 주소를 허용할 사이트 — 비우면 위 주소의 origin
'IMWEB_SITE_URL',
] as const;
/** 공개 주소로 쓸 수 없는 값 — 로컬 주소와 예시 파일의 예시 도메인 */
const NON_PUBLIC_URL_PATTERNS = [
@@ -95,6 +118,17 @@ export function validateEnv(
}
}
// 관리자 비밀번호는 환경과 무관하게 길이를 본다 (비어 있으면 로그인 자체가 막힌다)
const adminPassword = readString(config, 'ADMIN_PASSWORD');
if (
adminPassword !== undefined &&
adminPassword.length < ADMIN_PASSWORD_MIN_LENGTH
) {
errors.push(
`ADMIN_PASSWORD 는 ${ADMIN_PASSWORD_MIN_LENGTH}자 이상이어야 합니다. (현재 ${adminPassword.length}자)`,
);
}
if (isProduction) {
// 2. 운영 필수값 — 없으면 기동 실패
for (const key of REQUIRED_IN_PRODUCTION) {
+16
View File
@@ -0,0 +1,16 @@
import { readBearerToken } from './bearer';
describe('readBearerToken', () => {
it('Bearer 토큰을 꺼낸다 (대소문자 무시)', () => {
expect(readBearerToken('Bearer abc.def.ghi')).toBe('abc.def.ghi');
expect(readBearerToken('bearer abc')).toBe('abc');
});
it('형식이 다르거나 비어 있으면 null', () => {
expect(readBearerToken(undefined)).toBeNull();
expect(readBearerToken('')).toBeNull();
expect(readBearerToken('Basic dXNlcjpwdw==')).toBeNull();
expect(readBearerToken('Bearer')).toBeNull();
expect(readBearerToken('Bearer a b')).toBeNull();
});
});
+12
View File
@@ -0,0 +1,12 @@
/**
* Authorization 헤더 파싱 유틸 (순수 함수).
*/
const BEARER_PATTERN = /^Bearer\s+(\S+)\s*$/i;
/** `Authorization: Bearer <토큰>` 에서 토큰을 꺼낸다. 형식이 다르면 null. */
export function readBearerToken(header: string | undefined): string | null {
if (!header) return null;
const match = BEARER_PATTERN.exec(header.trim());
return match ? match[1] : null;
}
@@ -0,0 +1,41 @@
import { credentialFingerprint, safeEqual } from './credential';
describe('safeEqual', () => {
it('같은 문자열만 true', () => {
expect(safeEqual('admin', 'admin')).toBe(true);
expect(safeEqual('admin', 'Admin')).toBe(false);
expect(safeEqual('admin', 'admin ')).toBe(false);
});
it('길이가 달라도 예외 없이 false', () => {
expect(safeEqual('a', 'a-much-longer-value')).toBe(false);
expect(safeEqual('', 'x')).toBe(false);
});
});
describe('credentialFingerprint', () => {
it('같은 계정·비밀키면 같은 지문', () => {
expect(credentialFingerprint('admin', 'pw-1', 'secret')).toBe(
credentialFingerprint('admin', 'pw-1', 'secret'),
);
});
it('비밀번호·아이디·비밀키 중 하나만 바뀌어도 지문이 달라진다', () => {
const base = credentialFingerprint('admin', 'pw-1', 'secret');
expect(credentialFingerprint('admin', 'pw-2', 'secret')).not.toBe(base);
expect(credentialFingerprint('admin2', 'pw-1', 'secret')).not.toBe(base);
expect(credentialFingerprint('admin', 'pw-1', 'secret-2')).not.toBe(base);
});
it('아이디·비밀번호 경계를 섞어도 같은 지문이 나오지 않는다', () => {
expect(credentialFingerprint('ab', 'c', 's')).not.toBe(
credentialFingerprint('a', 'bc', 's'),
);
});
it('지문에 비밀번호가 그대로 들어가지 않는다', () => {
expect(credentialFingerprint('admin', 'my-password', 's')).not.toContain(
'my-password',
);
});
});
+29
View File
@@ -0,0 +1,29 @@
/**
* env 관리자 계정 확인 유틸 (순수 함수).
*/
import { createHash, createHmac, timingSafeEqual } from 'crypto';
/** 길이까지 숨기기 위해 해시를 비교한다 — 응답 시간으로 값을 추측할 수 없다 */
export function safeEqual(a: string, b: string): boolean {
const left = createHash('sha256').update(a, 'utf8').digest();
const right = createHash('sha256').update(b, 'utf8').digest();
return timingSafeEqual(left, right);
}
const FINGERPRINT_LENGTH = 32;
/**
* 계정 지문 — 토큰에 넣어 두고 요청마다 현재 env 값의 지문과 비교한다.
* env 의 아이디·비밀번호를 바꾸고 재시작하면 지문이 달라져 발급된 토큰이 모두 무효가 된다.
* 비밀키로 만든 HMAC 이라 토큰을 열어 봐도 비밀번호를 거꾸로 알아낼 수 없다.
*/
export function credentialFingerprint(
username: string,
password: string,
secret: string,
): string {
return createHmac('sha256', secret)
.update(`${username}\u0000${password}`, 'utf8')
.digest('base64url')
.slice(0, FINGERPRINT_LENGTH);
}
+22
View File
@@ -0,0 +1,22 @@
import { parseDurationMs } from './duration';
describe('parseDurationMs', () => {
it('초·분·시간·일 단위를 밀리초로 변환한다', () => {
expect(parseDurationMs('30s', 0)).toBe(30_000);
expect(parseDurationMs('15m', 0)).toBe(900_000);
expect(parseDurationMs('1h', 0)).toBe(3_600_000);
expect(parseDurationMs('14d', 0)).toBe(1_209_600_000);
});
it('대문자 단위와 앞뒤 공백을 허용한다', () => {
expect(parseDurationMs(' 2H ', 0)).toBe(7_200_000);
});
it('값이 없거나 형식이 틀리면 기본값을 쓴다', () => {
expect(parseDurationMs(undefined, 42)).toBe(42);
expect(parseDurationMs('', 42)).toBe(42);
expect(parseDurationMs('1w', 42)).toBe(42);
expect(parseDurationMs('abc', 42)).toBe(42);
expect(parseDurationMs('0h', 42)).toBe(42);
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* 기간 문자열 파싱 유틸 (순수 함수).
*
* `JWT_ACCESS_EXPIRES_IN=1h` 같은 env 값을 밀리초로 바꾼다.
* JWT 만료와 쿠키 maxAge 가 같은 값을 써야 어긋나지 않는다.
*/
const UNIT_TO_MS: Readonly<Record<string, number>> = {
s: 1_000,
m: 60_000,
h: 3_600_000,
d: 86_400_000,
};
const DURATION_PATTERN = /^(\d+)\s*([smhd])$/i;
/**
* `30s` · `15m` · `1h` · `14d` 형식을 밀리초로 변환한다.
* 값이 없거나 형식이 맞지 않으면 기본값을 반환한다.
*/
export function parseDurationMs(
value: string | undefined,
fallbackMs: number,
): number {
if (value === undefined) return fallbackMs;
const match = DURATION_PATTERN.exec(value.trim());
if (!match) return fallbackMs;
const amount = Number(match[1]);
const unit = UNIT_TO_MS[match[2].toLowerCase()];
if (!Number.isInteger(amount) || amount <= 0 || unit === undefined) {
return fallbackMs;
}
return amount * unit;
}

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