generated from ttipo/project-base
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59c84dcac5 | |||
| 7bef853664 | |||
| 2f01bea6ef | |||
| c5440e0cf4 | |||
| b1c6e6c7b6 | |||
| c54df9cefa |
@@ -31,7 +31,8 @@ tools: Read, Grep, Glob, Edit, Write, Bash, Skill
|
||||
- **env 파일 존재**: 루트 `.env.{APP_ENV}` 와 `backend/.env.{APP_ENV}` 가 둘 다 있어야 기동한다.
|
||||
- **운영 배포 전**: `BACKEND_API_URL`·`CORS_ORIGIN` 이 실제 도메인인지 확인한다.
|
||||
`localhost`·`example.com` 이 남아 있으면 백엔드가 기동에 실패한다 ([[env-structure]]).
|
||||
- **프론트엔드**: 개발에서는 컨테이너로 띄우지 않는다(profile `frontend` 뒤에 있다). 운영만 nginx 정적 호스팅이다.
|
||||
- **프론트엔드**: 개발·운영 모두 컨테이너로 띄우지 않는다(base 의 profile `frontend` 뒤에 있다).
|
||||
관리자 화면은 아임웹 코드 위젯이다. nginx 정적 호스팅이 필요할 때만 `COMPOSE_PROFILES=frontend` 로 켠다.
|
||||
|
||||
## 완료 기준 (실행하고 결과를 보고한다)
|
||||
|
||||
|
||||
@@ -32,9 +32,13 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.d
|
||||
> ⚠️ `.env.production` 의 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인으로 교체**해야 한다.
|
||||
> `localhost` 나 예시 도메인(`example.com`)이 남아 있으면 백엔드가 기동에 실패한다 ([[env-structure]]).
|
||||
> 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓰는 경우에만 `ALLOW_LOCALHOST_URL=true` 를 함께 준다.
|
||||
>
|
||||
> **운영에서도 프론트엔드 컨테이너는 기본 비활성이다** (base `docker-compose.yml` 의 profile `frontend`).
|
||||
> 관리자 화면은 아임웹 코드 위젯(`imweb/`)으로 제공하므로 backend + db 만 빌드·기동한다.
|
||||
> nginx 정적 호스팅이 필요해지면 루트 `.env.production` 에 `COMPOSE_PROFILES=frontend` 를 넣는다.
|
||||
|
||||
```bash
|
||||
# 운영 환경 배포
|
||||
# 운영 환경 배포 — backend + db
|
||||
sudo docker compose --env-file .env.production up -d --build
|
||||
|
||||
# 로그 확인
|
||||
@@ -77,7 +81,8 @@ COMPOSE_PROFILES=frontend
|
||||
- 호스트 vite 와 컨테이너 vite 는 같은 호스트 포트(`FRONTEND_PORT`)를 쓴다 — **동시에 띄우지 말 것.**
|
||||
- 컨테이너 **내부** 포트는 5173 고정이다 (compose 의 `--port`·healthcheck·포트 매핑이 함께 묶여 있다).
|
||||
루트 env 는 컨테이너 안에 없으므로 `vite.config.ts` 는 기본값으로 떨어지고, compose 의 `--port` 가 이긴다.
|
||||
- 운영(`docker-compose.yml` 단독)에는 profile 이 없으므로 nginx 정적 호스팅이 그대로 동작한다.
|
||||
- profile `frontend` 는 base `docker-compose.yml` 에 걸려 있어 **운영에서도 기본 비활성**이다.
|
||||
운영에서 같은 profile 을 켜면 dev 오버라이드가 없으므로 vite 대신 nginx 정적 호스팅(8080)으로 뜬다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,7 +48,8 @@ cd frontend && npm install && npm run dev
|
||||
> vite 를 컨테이너에서 돌리면 소스가 Windows 바인드 마운트 너머에 있어 첫 서빙까지 수 분이 걸립니다.
|
||||
> 호스트에서 `npm run dev` 로 띄우면 1~2초이고, `/api` 요청은 vite 프록시가 백엔드 컨테이너로 넘깁니다.
|
||||
> 컨테이너로 띄워야 한다면 `--profile frontend` 를 붙이세요 (아래 "프론트엔드 실행 방식" 참조).
|
||||
> 운영(`docker-compose.yml` 단독)은 그대로 nginx 정적 호스팅으로 동작합니다.
|
||||
> **운영에서도 프론트엔드 컨테이너는 기본 비활성입니다.** 관리자 화면은 아임웹 코드 위젯(`imweb/`)으로 제공하므로
|
||||
> 운영 배포는 backend + db 만 빌드·기동합니다. nginx 정적 호스팅이 필요해지면 `COMPOSE_PROFILES=frontend` 를 설정하세요.
|
||||
|
||||
> Redis 는 기본적으로 뜨지 않습니다. 필요해지면 루트 `.env.{env}` 에 `COMPOSE_PROFILES=redis` 를 설정하세요.
|
||||
|
||||
@@ -58,7 +59,7 @@ cd frontend && npm install && npm run dev
|
||||
| --- | --- | --- | --- |
|
||||
| **호스트 vite (개발 기본)** | `cd frontend && npm run dev` | 1~2초 (최초 의존성 사전번들 10초 내외) | 일상 개발 |
|
||||
| 컨테이너 vite (선택) | `docker compose ... --profile frontend up -d` | 수 분 (Windows 바인드 마운트) | 호스트에 Node 를 둘 수 없을 때 |
|
||||
| nginx 정적 (운영) | `docker compose --env-file .env.production up -d --build` | - | 배포 |
|
||||
| nginx 정적 (운영, 기본 비활성) | `docker compose --env-file .env.production --profile frontend up -d --build` | - | Vue 화면을 배포할 때 |
|
||||
|
||||
> 컨테이너 방식을 상시 사용하려면 루트 `.env.development` 에 `COMPOSE_PROFILES=frontend` 를 넣습니다.
|
||||
> (redis 와 함께 켤 때는 `COMPOSE_PROFILES=frontend,redis`)
|
||||
|
||||
@@ -12,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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+186
-122
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
@@ -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
@@ -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,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,86 @@
|
||||
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;
|
||||
|
||||
/** 신청 순간의 유입정보 원본 (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;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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 { ApplicationListQueryDto } from './dto/application-query.dto';
|
||||
import {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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,204 @@
|
||||
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('유입정보가 없거나 스크립트 형식이 아니면 비워 둔다', 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,307 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { IsNull, QueryFailedError, Repository } from 'typeorm';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
|
||||
import { ImwebConfig } from '@/modules/imweb/imweb.config';
|
||||
import { ImwebMemberLookupService } from '@/modules/imweb/imweb-member-lookup.service';
|
||||
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
|
||||
import { MembersService } from '@/modules/members/members.service';
|
||||
import { PartnerAttributionService } from '@/modules/partners/partner-attribution.service';
|
||||
import { parseInflow } from '@/shared/utils/inflow';
|
||||
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
|
||||
import { MemberApplication } from './application.entity';
|
||||
import { ApplicationListQueryDto } from './dto/application-query.dto';
|
||||
import {
|
||||
ApplicationProductDto,
|
||||
ApplicationResponseDto,
|
||||
} from './dto/application-response.dto';
|
||||
import {
|
||||
CreateApplicationDto,
|
||||
CreateApplicationResultDto,
|
||||
} from './dto/create-application.dto';
|
||||
|
||||
const ALIAS = 'a';
|
||||
const MEMBER_ALIAS = 'm';
|
||||
/** Postgres 유일 제약 위반 — 같은 회원·상품이 동시에 두 번 들어온 경우 */
|
||||
const PG_UNIQUE_VIOLATION = '23505';
|
||||
|
||||
function escapeLike(value: string): string {
|
||||
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof QueryFailedError &&
|
||||
(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 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,
|
||||
});
|
||||
|
||||
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.applications
|
||||
.createQueryBuilder(ALIAS)
|
||||
// 회원 검색을 위해 동기화된 회원과 잇는다 (탈퇴 회원은 회원 정보 없이 남는다)
|
||||
.leftJoin(
|
||||
ImwebMember,
|
||||
MEMBER_ALIAS,
|
||||
`${MEMBER_ALIAS}.member_code = ${ALIAS}.member_code`,
|
||||
);
|
||||
|
||||
if (query.productIdx) {
|
||||
base.andWhere(`${ALIAS}.productIdx = :productIdx`, {
|
||||
productIdx: query.productIdx,
|
||||
});
|
||||
}
|
||||
if (query.appliedFrom) {
|
||||
base.andWhere(`${ALIAS}.appliedAt >= :appliedFrom`, {
|
||||
appliedFrom: query.appliedFrom,
|
||||
});
|
||||
}
|
||||
if (query.appliedTo) {
|
||||
base.andWhere(`${ALIAS}.appliedAt < :appliedTo`, {
|
||||
appliedTo: query.appliedTo,
|
||||
});
|
||||
}
|
||||
if (query.source) {
|
||||
base.andWhere(`${ALIAS}.inflowSource = :source`, {
|
||||
source: query.source,
|
||||
});
|
||||
}
|
||||
if (query.q) {
|
||||
base.andWhere(
|
||||
`(${MEMBER_ALIAS}.name ILIKE :q OR ${ALIAS}.member_uid ILIKE :q OR ${MEMBER_ALIAS}.email ILIKE :q OR ${MEMBER_ALIAS}.callnum ILIKE :q)`,
|
||||
{ q: `%${escapeLike(query.q.trim())}%` },
|
||||
);
|
||||
}
|
||||
|
||||
const total = await base.clone().getCount();
|
||||
|
||||
const page = base
|
||||
.clone()
|
||||
.orderBy(`${ALIAS}.appliedAt`, 'DESC')
|
||||
.addOrderBy(`${ALIAS}.id`, 'DESC')
|
||||
.take(query.limit + 1);
|
||||
|
||||
if (query.cursor) {
|
||||
const cursor = decodeTimeCursor(query.cursor);
|
||||
if (!cursor) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'invalid_cursor' },
|
||||
});
|
||||
}
|
||||
page.andWhere(
|
||||
`(${ALIAS}.appliedAt, ${ALIAS}.id) < (:cursorTime, :cursorId)`,
|
||||
{
|
||||
cursorTime: cursor.time,
|
||||
cursorId: cursor.key,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await page.getMany();
|
||||
const hasNext = rows.length > query.limit;
|
||||
const items = rows.slice(0, query.limit);
|
||||
const last = items.at(-1);
|
||||
|
||||
const [membersByCode, groupTitles] = await Promise.all([
|
||||
this.membersByCode(items.map((row) => row.memberCode)),
|
||||
this.membersService.groupTitleMap(),
|
||||
]);
|
||||
|
||||
return toPaginatedResult(
|
||||
items.map((row) => {
|
||||
const member = membersByCode.get(row.memberCode);
|
||||
return {
|
||||
id: row.id,
|
||||
appliedAt: row.appliedAt.toISOString(),
|
||||
kakaoClickedAt: row.kakaoClickedAt?.toISOString() ?? null,
|
||||
productIdx: row.productIdx,
|
||||
productName: row.productName,
|
||||
memberUid: row.memberUid,
|
||||
member: member ? MemberResponseDto.from(member, groupTitles) : null,
|
||||
inflow: {
|
||||
source: row.inflowSource,
|
||||
medium: row.inflowMedium,
|
||||
campaign: row.inflowCampaign,
|
||||
content: row.inflowContent,
|
||||
},
|
||||
};
|
||||
}),
|
||||
{
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && last
|
||||
? encodeTimeCursor({
|
||||
time: last.appliedAt.toISOString(),
|
||||
key: last.id,
|
||||
})
|
||||
: null,
|
||||
total,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 상품별 신청 수 — 최근 신청이 있는 상품부터 */
|
||||
async products(): Promise<ApplicationProductDto[]> {
|
||||
const rows = await this.applications
|
||||
.createQueryBuilder(ALIAS)
|
||||
.select(`${ALIAS}.productIdx`, 'productIdx')
|
||||
// 상품명은 바뀔 수 있어 가장 최근 신청의 표시값을 쓴다
|
||||
.addSelect(
|
||||
`(ARRAY_AGG(${ALIAS}.product_name ORDER BY ${ALIAS}.applied_at DESC))[1]`,
|
||||
'productName',
|
||||
)
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy(`${ALIAS}.productIdx`)
|
||||
.orderBy(`MAX(${ALIAS}.applied_at)`, 'DESC')
|
||||
.getRawMany<{
|
||||
productIdx: string;
|
||||
productName: string | null;
|
||||
count: string;
|
||||
}>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
productIdx: row.productIdx,
|
||||
productName: row.productName,
|
||||
count: Number(row.count),
|
||||
}));
|
||||
}
|
||||
|
||||
private findExisting(
|
||||
memberCode: string,
|
||||
productIdx: string,
|
||||
): Promise<MemberApplication | null> {
|
||||
return this.applications.findOne({ where: { memberCode, productIdx } });
|
||||
}
|
||||
|
||||
private async membersByCode(
|
||||
codes: string[],
|
||||
): Promise<Map<string, ImwebMember>> {
|
||||
if (codes.length === 0) return new Map();
|
||||
const rows = await this.members
|
||||
.createQueryBuilder(MEMBER_ALIAS)
|
||||
.where(`${MEMBER_ALIAS}.memberCode IN (:...codes)`, { codes })
|
||||
.getMany();
|
||||
return new Map(rows.map((row) => [row.memberCode, row]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsDateString,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
|
||||
import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow';
|
||||
|
||||
const SEARCH_MAX_LENGTH = 100;
|
||||
|
||||
/** 관리자 신청 내역 조회 */
|
||||
export class ApplicationListQueryDto extends CursorPaginationQueryDto {
|
||||
@ApiPropertyOptional({ description: '상품번호', example: '52' })
|
||||
@IsOptional()
|
||||
@Matches(/^\d{1,20}$/, { message: '상품번호 형식이 올바르지 않습니다.' })
|
||||
productIdx?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '신청 시각 시작 (이상, ISO 8601)',
|
||||
example: '2026-09-30T15:00:00.000Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: '신청 시작일 형식이 올바르지 않습니다.' })
|
||||
appliedFrom?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '신청 시각 끝 (미만, ISO 8601)',
|
||||
example: '2026-10-31T15:00:00.000Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: '신청 종료일 형식이 올바르지 않습니다.' })
|
||||
appliedTo?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '신청 순간의 유입원',
|
||||
example: 'facebook',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
|
||||
source?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '검색어 — 이름·아이디·이메일·연락처 부분 일치',
|
||||
example: '홍길동',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(SEARCH_MAX_LENGTH, { message: '검색어가 너무 깁니다.' })
|
||||
q?: string;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
|
||||
|
||||
/** 신청 순간의 유입정보 */
|
||||
export class ApplicationInflowDto {
|
||||
@ApiProperty({ description: '유입원', nullable: true, example: 'facebook' })
|
||||
source!: string | null;
|
||||
|
||||
@ApiProperty({ description: '매체', nullable: true, example: 'paid' })
|
||||
medium!: string | null;
|
||||
|
||||
@ApiProperty({ description: '캠페인', nullable: true, example: 'fall' })
|
||||
campaign!: string | null;
|
||||
|
||||
@ApiProperty({ description: '콘텐츠', nullable: true, example: 'video_a' })
|
||||
content!: string | null;
|
||||
}
|
||||
|
||||
/** 관리자 신청 내역 한 건 */
|
||||
export class ApplicationResponseDto {
|
||||
@ApiProperty({ description: '신청 기록 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '신청 시각',
|
||||
example: '2026-10-05T03:00:00.000Z',
|
||||
})
|
||||
appliedAt!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '오픈카톡 버튼을 처음 누른 시각 (안 눌렀으면 null)',
|
||||
nullable: true,
|
||||
})
|
||||
kakaoClickedAt!: string | null;
|
||||
|
||||
@ApiProperty({ description: '상품번호', example: '52' })
|
||||
productIdx!: string;
|
||||
|
||||
@ApiProperty({ description: '상품명 (신청 화면 표시값)', nullable: true })
|
||||
productName!: string | null;
|
||||
|
||||
@ApiProperty({ description: '신청 당시 아이디', example: 'user@example.com' })
|
||||
memberUid!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '회원 정보 (동기화된 회원 기준) — 탈퇴 등으로 없으면 null',
|
||||
type: MemberResponseDto,
|
||||
nullable: true,
|
||||
})
|
||||
member!: MemberResponseDto | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '신청 순간의 유입정보',
|
||||
type: ApplicationInflowDto,
|
||||
})
|
||||
inflow!: ApplicationInflowDto;
|
||||
}
|
||||
|
||||
/** 상품별 신청 수 — 필터 칩에 쓴다 */
|
||||
export class ApplicationProductDto {
|
||||
@ApiProperty({ description: '상품번호', example: '52' })
|
||||
productIdx!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '가장 최근 신청에 표시된 상품명',
|
||||
nullable: true,
|
||||
})
|
||||
productName!: string | null;
|
||||
|
||||
@ApiProperty({ description: '신청 수', example: 120 })
|
||||
count!: number;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
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;
|
||||
/** 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:
|
||||
'신청 순간의 유입정보 (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:
|
||||
'브라우저가 보관한 업체 링크 클릭 ID 목록 (imweb/partner-ref.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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
/** 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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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(/필요한 열이 없습니다/);
|
||||
});
|
||||
});
|
||||
@@ -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,72 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* 업체 단축 링크 — 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');
|
||||
res.redirect(HttpStatus.FOUND, target);
|
||||
}
|
||||
}
|
||||
|
||||
/** 아임웹 Header Code 스크립트(imweb/partner-ref.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' })),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parsePositiveInt } from './env';
|
||||
import { parseList, parsePositiveInt } from './env';
|
||||
|
||||
describe('parsePositiveInt', () => {
|
||||
it('정상적인 양의 정수 문자열을 숫자로 변환한다', () => {
|
||||
@@ -25,3 +25,17 @@ describe('parsePositiveInt', () => {
|
||||
expect(parsePositiveInt('1.5', 100)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseList', () => {
|
||||
it('콤마로 나누고 앞뒤 공백과 빈 항목을 버린다', () => {
|
||||
expect(parseList('https://a.com, https://b.com,,')).toEqual([
|
||||
'https://a.com',
|
||||
'https://b.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('값이 없거나 비어 있으면 빈 배열', () => {
|
||||
expect(parseList(undefined)).toEqual([]);
|
||||
expect(parseList(' ')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user