diff --git a/.env.development.example b/.env.development.example index 0fcef22..5c725b5 100644 --- a/.env.development.example +++ b/.env.development.example @@ -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,20 @@ 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_CODE=S20230730923b9e1418574 +IMWEB_ADMIN_PAGE_URL=http://localhost:5173/imweb-members.html +IMWEB_SYNC_INTERVAL_MINUTES=60 +IMWEB_PAGE_SIZE=100 diff --git a/.env.production.example b/.env.production.example index 7aae564..4c3ab00 100644 --- a/.env.production.example +++ b/.env.production.example @@ -46,8 +46,10 @@ REDIS_DATA_DIR=./data/redis # 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓰는 경우에만 # ALLOW_LOCALHOST_URL=true 로 이 검사를 건너뛴다. 실제 배포에는 설정하지 않는다. # ========================================== +# 예) 백엔드 https://moneyguider.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 남용 방지) @@ -80,3 +82,20 @@ 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_CODE=S20230730923b9e1418574 +IMWEB_ADMIN_PAGE_URL=https://www.moneyguider.co.kr/admin-members +IMWEB_SYNC_INTERVAL_MINUTES=60 +IMWEB_PAGE_SIZE=100 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbd8045..dda9db5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/backend/.env.development.example b/backend/.env.development.example index 2f36cf9..08e5eef 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -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 diff --git a/backend/.env.production.example b/backend/.env.production.example index 71068cd..2280f07 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -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 diff --git a/backend/package-lock.json b/backend/package-lock.json index f62dd97..93fe10d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index ccc5784..9b2fabe 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/scripts/import-inflow.ts b/backend/scripts/import-inflow.ts new file mode 100644 index 0000000..ac3617a --- /dev/null +++ b/backend/scripts/import-inflow.ts @@ -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 { + const files = process.argv.slice(2); + if (files.length === 0) { + console.error( + '사용법: npm run inflow:import -- <엑셀 경로> [엑셀 경로 ...]', + ); + process.exit(1); + } + + // 여러 파일에 같은 회원이 있으면 뒤 파일의 값을 쓴다 + const merged = new Map(); + 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(); + 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); +}); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index d84c929..7faa9ec 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -7,7 +7,11 @@ 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 { UploadModule } from '@/modules/upload/upload.module'; import { UserModule } from '@/modules/user/user.module'; import { parsePositiveInt } from '@/shared/utils/env'; @@ -60,6 +64,10 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수 HealthModule, UploadModule, UserModule, + AdminAuthModule, + ImwebModule, + MembersModule, + ApplicationsModule, ], providers: [ ShutdownLogger, diff --git a/backend/src/common/decorators/current-admin.decorator.ts b/backend/src/common/decorators/current-admin.decorator.ts new file mode 100644 index 0000000..3465234 --- /dev/null +++ b/backend/src/common/decorators/current-admin.decorator.ts @@ -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(); + if (!request.admin) throw new BusinessException('AUTH_001'); + return request.admin; + }, +); diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index b742aac..ba60541 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -73,6 +73,36 @@ 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: + '아임웹 연동 설정이 완료되지 않았습니다. 관리자에게 문의해 주세요.', + }, + // 강의 신청 기록 — 신청 완료 화면이 보낸다 + APPLY_001: { + status: HttpStatus.BAD_REQUEST, + message: '회원 정보를 확인할 수 없어 신청을 기록하지 못했습니다.', + }, } as const satisfies Record; export const ERROR_CODES = { diff --git a/backend/src/common/guards/admin-auth.guard.ts b/backend/src/common/guards/admin-auth.guard.ts new file mode 100644 index 0000000..1e71c07 --- /dev/null +++ b/backend/src/common/guards/admin-auth.guard.ts @@ -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 { + const request = context.switchToHttp().getRequest(); + const token = readBearerToken(request.headers.authorization); + const verified = await this.auth.verifyAccessToken(token); + request.admin = verified.admin; + request.adminSessionId = verified.sessionId; + return true; + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 314c824..5638ac3 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -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, { @@ -40,8 +41,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 +56,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 { diff --git a/backend/src/migrations/1791171834616-AddAdminAndImwebMembers.ts b/backend/src/migrations/1791171834616-AddAdminAndImwebMembers.ts new file mode 100644 index 0000000..d67f029 --- /dev/null +++ b/backend/src/migrations/1791171834616-AddAdminAndImwebMembers.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAdminAndImwebMembers1791171834616 implements MigrationInterface { + name = 'AddAdminAndImwebMembers1791171834616'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } +} diff --git a/backend/src/migrations/1791176284930-AddInflowImport.ts b/backend/src/migrations/1791176284930-AddInflowImport.ts new file mode 100644 index 0000000..764b860 --- /dev/null +++ b/backend/src/migrations/1791176284930-AddInflowImport.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddInflowImport1791176284930 implements MigrationInterface { + name = 'AddInflowImport1791176284930'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query( + `ALTER TABLE "imweb_members" DROP COLUMN "inflow_origin"`, + ); + await queryRunner.query(`DROP TABLE "imweb_member_inflow_imports"`); + } +} diff --git a/backend/src/migrations/1791179523253-AddAdminSessions.ts b/backend/src/migrations/1791179523253-AddAdminSessions.ts new file mode 100644 index 0000000..e4fb999 --- /dev/null +++ b/backend/src/migrations/1791179523253-AddAdminSessions.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAdminSessions1791179523253 implements MigrationInterface { + name = 'AddAdminSessions1791179523253'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query( + `DROP INDEX "public"."IDX_ce73d53a035a036b02fc45d3f9"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_52683d67b3d122a8867d745beb"`, + ); + await queryRunner.query(`DROP TABLE "admin_sessions"`); + } +} diff --git a/backend/src/migrations/1791180231324-UseEnvAdminAccount.ts b/backend/src/migrations/1791180231324-UseEnvAdminAccount.ts new file mode 100644 index 0000000..d0ed5f5 --- /dev/null +++ b/backend/src/migrations/1791180231324-UseEnvAdminAccount.ts @@ -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 { + 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 { + 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") `, + ); + } +} diff --git a/backend/src/migrations/1791181662391-AddImwebGroups.ts b/backend/src/migrations/1791181662391-AddImwebGroups.ts new file mode 100644 index 0000000..69db51e --- /dev/null +++ b/backend/src/migrations/1791181662391-AddImwebGroups.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddImwebGroups1791181662391 implements MigrationInterface { + name = 'AddImwebGroups1791181662391'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query( + `ALTER TABLE "imweb_members" DROP COLUMN "group_codes"`, + ); + await queryRunner.query(`DROP TABLE "imweb_groups"`); + } +} diff --git a/backend/src/migrations/1791184024124-AddMemberApplications.ts b/backend/src/migrations/1791184024124-AddMemberApplications.ts new file mode 100644 index 0000000..d97a34a --- /dev/null +++ b/backend/src/migrations/1791184024124-AddMemberApplications.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMemberApplications1791184024124 implements MigrationInterface { + name = 'AddMemberApplications1791184024124'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } +} diff --git a/backend/src/modules/admin-auth/admin-auth.constants.ts b/backend/src/modules/admin-auth/admin-auth.constants.ts new file mode 100644 index 0000000..881e460 --- /dev/null +++ b/backend/src/modules/admin-auth/admin-auth.constants.ts @@ -0,0 +1,58 @@ +import { parseDurationMs } from '@/shared/utils/duration'; + +/** + * 관리자 인증은 쿠키가 아니라 `Authorization: Bearer` 헤더로 한다. + * + * 관리자 화면은 아임웹(www.moneyguider.co.kr)에, API 는 다른 사이트(moneyguider.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, + ); +} diff --git a/backend/src/modules/admin-auth/admin-auth.controller.ts b/backend/src/modules/admin-auth/admin-auth.controller.ts new file mode 100644 index 0000000..d3e0b17 --- /dev/null +++ b/backend/src/modules/admin-auth/admin-auth.controller.ts @@ -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 { + 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 { + 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 { + if (!req.adminSessionId) throw new BusinessException('AUTH_001'); + await this.auth.logout(req.adminSessionId); + } +} diff --git a/backend/src/modules/admin-auth/admin-auth.module.ts b/backend/src/modules/admin-auth/admin-auth.module.ts new file mode 100644 index 0000000..fd7fec8 --- /dev/null +++ b/backend/src/modules/admin-auth/admin-auth.module.ts @@ -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('JWT_SECRET'), + }), + }), + ], + controllers: [AdminAuthController], + providers: [AdminAuthService, AdminAuthGuard], + // 다른 모듈의 컨트롤러가 @UseGuards(AdminAuthGuard) 를 쓰려면 서비스와 가드가 필요하다 + exports: [AdminAuthService, AdminAuthGuard], +}) +export class AdminAuthModule {} diff --git a/backend/src/modules/admin-auth/admin-auth.service.spec.ts b/backend/src/modules/admin-auth/admin-auth.service.spec.ts new file mode 100644 index 0000000..e4ef278 --- /dev/null +++ b/backend/src/modules/admin-auth/admin-auth.service.spec.ts @@ -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 = {}) { + // env 는 테스트 중에 바꿀 수 있게 객체로 둔다 (비밀번호 변경 시나리오) + const values: Record = { + ADMIN_USERNAME: USERNAME, + ADMIN_PASSWORD: PASSWORD, + JWT_SECRET: SECRET, + ...env, + }; + const config = { get: (key: string) => values[key] }; + + // 세션 저장소 — 메모리 + const store = new Map(); + const sessions = { + create: jest.fn((row: Partial) => 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) => { + 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, + 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', + }); + }); +}); diff --git a/backend/src/modules/admin-auth/admin-auth.service.ts b/backend/src/modules/admin-auth/admin-auth.service.ts new file mode 100644 index 0000000..4ee719c --- /dev/null +++ b/backend/src/modules/admin-auth/admin-auth.service.ts @@ -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, + private readonly jwt: JwtService, + private readonly config: ConfigService, + ) {} + + /** 아이디·비밀번호를 env 계정과 비교하고 새 세션을 연다. 실패 원인은 구분하지 않는다. */ + async login( + username: string, + password: string, + userAgent?: string, + ): Promise { + 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 { + 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 { + await this.sessions.update({ id: sessionId }, { revokedAt: new Date() }); + this.logger.log(`관리자 로그아웃: session=${sessionId}`); + } + + /** + * 액세스 토큰을 검증한다 — 가드에서 쓴다. + * 서명·만료뿐 아니라 세션과 계정 지문도 확인해, 로그아웃·비밀번호 변경이 바로 반영된다. + * (토큰이 페이지 스크립트에 노출되는 Bearer 방식이라 즉시 무효화가 필요하다) + */ + async verifyAccessToken(token: string | null): Promise { + 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(ADMIN_USERNAME_KEY) ?? '').trim(); + const password = this.config.get(ADMIN_PASSWORD_KEY) ?? ''; + const secret = this.config.get('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 { + 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 { + 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 { + if (!token) throw this.rejected('no_token'); + let payload: AdminTokenPayload; + try { + payload = await this.jwt.verifyAsync(token); + } catch { + throw this.rejected('invalid_token'); + } + // 리프레시 토큰을 액세스 토큰 자리에 쓰는 것을 막는다 (반대도 마찬가지) + if (payload.typ !== type) throw this.rejected('wrong_token_type'); + return payload; + } +} diff --git a/backend/src/modules/admin-auth/admin-session.entity.ts b/backend/src/modules/admin-auth/admin-session.entity.ts new file mode 100644 index 0000000..3d3b591 --- /dev/null +++ b/backend/src/modules/admin-auth/admin-session.entity.ts @@ -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; +} diff --git a/backend/src/modules/admin-auth/dto/admin-response.dto.ts b/backend/src/modules/admin-auth/dto/admin-response.dto.ts new file mode 100644 index 0000000..eddaac4 --- /dev/null +++ b/backend/src/modules/admin-auth/dto/admin-response.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** 로그인한 관리자 */ +export class AdminResponseDto { + @ApiProperty({ + description: '관리자 아이디 (env ADMIN_USERNAME)', + example: 'admin', + }) + username!: string; +} diff --git a/backend/src/modules/admin-auth/dto/admin-session.dto.ts b/backend/src/modules/admin-auth/dto/admin-session.dto.ts new file mode 100644 index 0000000..10c0efc --- /dev/null +++ b/backend/src/modules/admin-auth/dto/admin-session.dto.ts @@ -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; +} diff --git a/backend/src/modules/admin-auth/dto/login.dto.ts b/backend/src/modules/admin-auth/dto/login.dto.ts new file mode 100644 index 0000000..2854082 --- /dev/null +++ b/backend/src/modules/admin-auth/dto/login.dto.ts @@ -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; +} diff --git a/backend/src/modules/applications/application.entity.ts b/backend/src/modules/applications/application.entity.ts new file mode 100644 index 0000000..ae2f04a --- /dev/null +++ b/backend/src/modules/applications/application.entity.ts @@ -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; +} diff --git a/backend/src/modules/applications/applications.controller.ts b/backend/src/modules/applications/applications.controller.ts new file mode 100644 index 0000000..65a3500 --- /dev/null +++ b/backend/src/modules/applications/applications.controller.ts @@ -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 { + 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 { + 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> { + return this.applications.findMany(query); + } + + @Get('products') + @ApiOperation({ summary: '상품별 신청 수 (최근 신청이 있는 상품부터)' }) + @ApiStandardResponse(ApplicationProductDto, { + description: '조회 성공', + isArray: true, + }) + @ApiErrorResponse('AUTH_001', '로그인 필요') + products(): Promise { + return this.applications.products(); + } +} diff --git a/backend/src/modules/applications/applications.module.ts b/backend/src/modules/applications/applications.module.ts new file mode 100644 index 0000000..d8266c8 --- /dev/null +++ b/backend/src/modules/applications/applications.module.ts @@ -0,0 +1,26 @@ +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 { MemberApplication } from './application.entity'; +import { + AdminApplicationsController, + ApplicationsController, +} from './applications.controller'; +import { ApplicationsService } from './applications.service'; + +/** 강의 신청 기록 — 신청 완료 화면이 보내고, 관리자 화면에서 본다 */ +@Module({ + imports: [ + TypeOrmModule.forFeature([MemberApplication, ImwebMember]), + AdminAuthModule, + ImwebModule, + MembersModule, + ], + controllers: [ApplicationsController, AdminApplicationsController], + providers: [ApplicationsService], +}) +export class ApplicationsModule {} diff --git a/backend/src/modules/applications/applications.service.spec.ts b/backend/src/modules/applications/applications.service.spec.ts new file mode 100644 index 0000000..8bd07ea --- /dev/null +++ b/backend/src/modules/applications/applications.service.spec.ts @@ -0,0 +1,133 @@ +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 { 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 }) => + 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 service = new ApplicationsService( + applications as unknown as Repository, + {} as Repository, + lookup as unknown as ImwebMemberLookupService, + {} as MembersService, + { trackingHost: 'utm.moneyguider.co.kr' } as ImwebConfig, + ); + return { service, store, applications, lookup }; +} + +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 }); + }); +}); diff --git a/backend/src/modules/applications/applications.service.ts b/backend/src/modules/applications/applications.service.ts new file mode 100644 index 0000000..ff435a6 --- /dev/null +++ b/backend/src/modules/applications/applications.service.ts @@ -0,0 +1,274 @@ +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 { 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, + @InjectRepository(ImwebMember) + private readonly members: Repository, + private readonly lookup: ImwebMemberLookupService, + private readonly membersService: MembersService, + private readonly config: ImwebConfig, + ) {} + + /** 신청 기록 — 같은 회원·상품이면 기존 기록을 돌려준다 (몇 번을 불러도 한 건) */ + async create(dto: CreateApplicationDto): Promise { + const member = await this.lookup.find(dto.memberUid); + if (!member) { + // 아이디(이메일)는 로그에 남기지 않는다 + throw new BusinessException('APPLY_001', { + context: { reason: 'member_not_found', productIdx: dto.productIdx }, + }); + } + + const existing = await this.findExisting(member.memberCode, dto.productIdx); + if (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}`, + ); + return { id: row.id, isNew: true }; + } + + /** 오픈카톡 버튼 클릭 — 처음 누른 시각만 남긴다. 없는 ID 도 조용히 넘어간다 */ + async markKakaoClicked(id: string): Promise { + await this.applications.update( + { id, kakaoClickedAt: IsNull() }, + { kakaoClickedAt: new Date() }, + ); + } + + async findMany( + query: ApplicationListQueryDto, + ): Promise> { + 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 { + 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 { + return this.applications.findOne({ where: { memberCode, productIdx } }); + } + + private async membersByCode( + codes: string[], + ): Promise> { + 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])); + } +} diff --git a/backend/src/modules/applications/dto/application-query.dto.ts b/backend/src/modules/applications/dto/application-query.dto.ts new file mode 100644 index 0000000..5b505d2 --- /dev/null +++ b/backend/src/modules/applications/dto/application-query.dto.ts @@ -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; +} diff --git a/backend/src/modules/applications/dto/application-response.dto.ts b/backend/src/modules/applications/dto/application-response.dto.ts new file mode 100644 index 0000000..71e86b5 --- /dev/null +++ b/backend/src/modules/applications/dto/application-response.dto.ts @@ -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; +} diff --git a/backend/src/modules/applications/dto/create-application.dto.ts b/backend/src/modules/applications/dto/create-application.dto.ts new file mode 100644 index 0000000..ab7e869 --- /dev/null +++ b/backend/src/modules/applications/dto/create-application.dto.ts @@ -0,0 +1,61 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, +} from 'class-validator'; + +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; +} + +/** 신청 기록 결과 — 회원 정보는 돌려주지 않는다 */ +export class CreateApplicationResultDto { + @ApiProperty({ description: '신청 기록 ID — 오픈카톡 클릭 기록에 쓴다' }) + id!: string; + + @ApiProperty({ + description: '이번에 새로 기록됐는가 (같은 회원·상품이면 false)', + example: true, + }) + isNew!: boolean; +} diff --git a/backend/src/modules/imweb/dto/imweb-status.dto.ts b/backend/src/modules/imweb/dto/imweb-status.dto.ts new file mode 100644 index 0000000..a6fa63e --- /dev/null +++ b/backend/src/modules/imweb/dto/imweb-status.dto.ts @@ -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; +} diff --git a/backend/src/modules/imweb/entities/imweb-connection.entity.ts b/backend/src/modules/imweb/entities/imweb-connection.entity.ts new file mode 100644 index 0000000..2cbacfb --- /dev/null +++ b/backend/src/modules/imweb/entities/imweb-connection.entity.ts @@ -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; +} diff --git a/backend/src/modules/imweb/entities/imweb-group.entity.ts b/backend/src/modules/imweb/entities/imweb-group.entity.ts new file mode 100644 index 0000000..41f5e61 --- /dev/null +++ b/backend/src/modules/imweb/entities/imweb-group.entity.ts @@ -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; +} diff --git a/backend/src/modules/imweb/entities/imweb-member-inflow-import.entity.ts b/backend/src/modules/imweb/entities/imweb-member-inflow-import.entity.ts new file mode 100644 index 0000000..83c6e72 --- /dev/null +++ b/backend/src/modules/imweb/entities/imweb-member-inflow-import.entity.ts @@ -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; +} diff --git a/backend/src/modules/imweb/entities/imweb-member.entity.ts b/backend/src/modules/imweb/entities/imweb-member.entity.ts new file mode 100644 index 0000000..335121d --- /dev/null +++ b/backend/src/modules/imweb/entities/imweb-member.entity.ts @@ -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; +} diff --git a/backend/src/modules/imweb/entities/imweb-sync-run.entity.ts b/backend/src/modules/imweb/entities/imweb-sync-run.entity.ts new file mode 100644 index 0000000..434e914 --- /dev/null +++ b/backend/src/modules/imweb/entities/imweb-sync-run.entity.ts @@ -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; +} diff --git a/backend/src/modules/imweb/imweb-admin.controller.ts b/backend/src/modules/imweb/imweb-admin.controller.ts new file mode 100644 index 0000000..ae56f1b --- /dev/null +++ b/backend/src/modules/imweb/imweb-admin.controller.ts @@ -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 { + 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 { + 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 { + 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 { + const run = await this.sync.start(SyncTrigger.MANUAL); + return SyncRunDto.from(run); + } +} diff --git a/backend/src/modules/imweb/imweb-admin.service.ts b/backend/src/modules/imweb/imweb-admin.service.ts new file mode 100644 index 0000000..a1232a3 --- /dev/null +++ b/backend/src/modules/imweb/imweb-admin.service.ts @@ -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, + private readonly auth: ImwebAuthService, + private readonly sync: ImwebSyncService, + private readonly config: ImwebConfig, + ) {} + + async getStatus(): Promise { + 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, + }; + } +} diff --git a/backend/src/modules/imweb/imweb-auth.service.spec.ts b/backend/src/modules/imweb/imweb-auth.service.spec.ts new file mode 100644 index 0000000..03fa418 --- /dev/null +++ b/backend/src/modules/imweb/imweb-auth.service.spec.ts @@ -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 { + 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 => 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, + 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', + ); + 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(); + }); + }); +}); diff --git a/backend/src/modules/imweb/imweb-auth.service.ts b/backend/src/modules/imweb/imweb-auth.service.ts new file mode 100644 index 0000000..a51ab9a --- /dev/null +++ b/backend/src/modules/imweb/imweb-auth.service.ts @@ -0,0 +1,315 @@ +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 | null = null; + + constructor( + @InjectRepository(ImwebConnection) + private readonly connections: Repository, + @Inject(CACHE_MANAGER) private readonly cache: Cache, + private readonly http: ImwebHttpClient, + private readonly config: ImwebConfig, + ) {} + + /** 아임웹 동의 화면 주소를 만든다. state 는 callback 에서 한 번만 쓸 수 있다. */ + async createAuthorizationUrl(adminUsername: string): Promise { + 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 { + this.assertConfigured(); + + const stateKey = `${OAUTH_STATE_KEY_PREFIX}${state ?? ''}`; + const adminUsername = state + ? await this.cache.get(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(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( + 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 { + try { + await this.http.patchJson( + 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 { + return this.connections.findOne({ + where: { siteCode: this.config.siteCode }, + }); + } + + /** 연결을 끊는다 — 저장된 토큰을 지운다. 아임웹 쪽 권한은 그대로 남는다. */ + async disconnect(): Promise { + await this.connections.delete({ siteCode: this.config.siteCode }); + this.logger.log(`아임웹 연결 해제: siteCode=${this.config.siteCode}`); + } + + /** + * 토큰을 붙여 GET 을 호출한다. + * 액세스 토큰이 만료 직전이면 먼저 갱신하고, 401 을 받으면 한 번 갱신 후 다시 시도한다. + */ + async authorizedGet( + path: string, + query: Record, + ): 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(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(path, query, connection.accessToken), + ); + return { data, unitCode: connection.unitCode }; + } + + /** 연결 정보 — 설정이 없으면 IMWEB_005, 연결 전이면 IMWEB_001 */ + async requireConnection(): Promise { + this.assertConfigured(); + const connection = await this.findConnection(); + if (!connection) throw new BusinessException('IMWEB_001'); + return connection; + } + + private refresh(): Promise { + this.refreshing ??= this.doRefresh().finally(() => { + this.refreshing = null; + }); + return this.refreshing; + } + + private async doRefresh(): Promise { + const connection = await this.requireConnection(); + + let token: ImwebTokenData; + try { + token = await this.http.postForm(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(call: () => Promise): Promise { + try { + return await call(); + } catch (error) { + throw this.toBusinessError(error); + } + } + + /** 아임웹 호출 실패를 사용자 응답용 에러 코드로 바꾼다 */ + private toBusinessError(error: unknown): unknown { + if (error instanceof BusinessException) return error; + 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; + } +} diff --git a/backend/src/modules/imweb/imweb-http.client.ts b/backend/src/modules/imweb/imweb-http.client.ts new file mode 100644 index 0000000..845b6a0 --- /dev/null +++ b/backend/src/modules/imweb/imweb-http.client.ts @@ -0,0 +1,173 @@ +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_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; + } + + /** 요청 자체가 거부됨 — 다시 시도해도 같은 결과 */ + isClientError(): boolean { + return this.status >= HTTP_BAD_REQUEST && this.status < HTTP_SERVER_ERROR; + } +} + +export type QueryValue = string | number | undefined; + +function sleep(ms: number): Promise { + 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( + path: string, + query: Record, + accessToken: string, + ): Promise { + 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(path, url, { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}` }, + }); + } + + async patchJson( + path: string, + body: Record, + accessToken: string, + ): Promise { + return this.send(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(path: string, form: Record): Promise { + return this.send(path, `${IMWEB_API_BASE_URL}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(form).toString(), + }); + } + + private async send( + path: string, + url: string, + init: RequestInit, + ): Promise { + 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).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 { + 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; + } +} diff --git a/backend/src/modules/imweb/imweb-member-lookup.service.spec.ts b/backend/src/modules/imweb/imweb-member-lookup.service.spec.ts new file mode 100644 index 0000000..951a470 --- /dev/null +++ b/backend/src/modules/imweb/imweb-member-lookup.service.spec.ts @@ -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, + runs as unknown as Repository, + 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(); + }); +}); diff --git a/backend/src/modules/imweb/imweb-member-lookup.service.ts b/backend/src/modules/imweb/imweb-member-lookup.service.ts new file mode 100644 index 0000000..9384368 --- /dev/null +++ b/backend/src/modules/imweb/imweb-member-lookup.service.ts @@ -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, + @InjectRepository(ImwebSyncRun) + private readonly runs: Repository, + private readonly auth: ImwebAuthService, + private readonly config: ImwebConfig, + ) {} + + async find(identifier: string): Promise { + 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( + 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; + } +} diff --git a/backend/src/modules/imweb/imweb-oauth.controller.ts b/backend/src/modules/imweb/imweb-oauth.controller.ts new file mode 100644 index 0000000..49b47f5 --- /dev/null +++ b/backend/src/modules/imweb/imweb-oauth.controller.ts @@ -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 { + 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(); + } +} diff --git a/backend/src/modules/imweb/imweb-sync.service.spec.ts b/backend/src/modules/imweb/imweb-sync.service.spec.ts new file mode 100644 index 0000000..191592a --- /dev/null +++ b/backend/src/modules/imweb/imweb-sync.service.spec.ts @@ -0,0 +1,299 @@ +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 { 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) => 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[]> => + 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 => + path === '/member-info/groups' + ? Promise.resolve({ data: groupPage }) + : (memberPages(path, query) as Promise), + ), + }; + const config = { pageSize: 2, trackingHost: HOST }; + + const service = new ImwebSyncService( + members as unknown as Repository, + runs as unknown as Repository, + imports as unknown as Repository, + groups as unknown as Repository, + auth as unknown as ImwebAuthService, + config as unknown as ImwebConfig, + {} as SchedulerRegistry, + ); + const finalRun = () => savedRuns.at(-1); + return { + service, + members, + runs, + imports, + groups, + auth, + 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, 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']); + }); +}); diff --git a/backend/src/modules/imweb/imweb-sync.service.ts b/backend/src/modules/imweb/imweb-sync.service.ts new file mode 100644 index 0000000..3f0f981 --- /dev/null +++ b/backend/src/modules/imweb/imweb-sync.service.ts @@ -0,0 +1,313 @@ +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 { 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 { + 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 | null = null; + + constructor( + @InjectRepository(ImwebMember) + private readonly members: Repository, + @InjectRepository(ImwebSyncRun) + private readonly runs: Repository, + @InjectRepository(ImwebMemberInflowImport) + private readonly imports: Repository, + @InjectRepository(ImwebGroup) + private readonly groups: Repository, + private readonly auth: ImwebAuthService, + private readonly config: ImwebConfig, + private readonly scheduler: SchedulerRegistry, + ) {} + + async onApplicationBootstrap(): Promise { + // 서버가 동기화 도중 꺼졌다면 그 기록은 영원히 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 { + if (this.scheduler.doesExist('interval', SCHEDULE_NAME)) { + this.scheduler.deleteInterval(SCHEDULE_NAME); + } + // 진행 중인 동기화가 끝나길 기다려 DB 커넥션이 먼저 닫히지 않게 한다 + await this.waitForIdle(); + } + + /** 진행 중인 동기화가 끝날 때까지 기다린다 */ + async waitForIdle(): Promise { + await this.running?.catch(() => undefined); + } + + /** + * 동기화를 시작하고 실행 기록을 바로 돌려준다 (끝까지 기다리지 않는다). + * 이미 진행 중이면 IMWEB_004. + */ + async start(trigger: SyncTrigger): Promise { + 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 { + return this.runs.findOne({ where: {}, order: { id: 'DESC' } }); + } + + /** 마지막으로 성공한 실행 기록 */ + findLatestSucceededRun(): Promise { + return this.runs.findOne({ + where: { status: SyncStatus.SUCCEEDED }, + order: { id: 'DESC' }, + }); + } + + private async runScheduled(): Promise { + 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 { + const startedAt = Date.now(); + try { + await this.syncGroups(); + 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 { + const syncedAt = new Date(); + const rows: ImwebGroup[] = []; + + for (let page = 1; page <= MAX_PAGES; page += 1) { + const { data } = await this.auth.authorizedGet( + 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))), + }); + } + } + + /** 아임웹 회원 전체를 페이지 단위로 받아 저장한다. 받은 회원 수를 돌려준다. */ + private async fetchAll(run: ImwebSyncRun): Promise { + 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( + 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> { + 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 { + // 아무것도 못 받았는데 지우면 전체가 날아간다 — 응답 이상으로 보고 건너뛴다 + 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; + } +} diff --git a/backend/src/modules/imweb/imweb.config.ts b/backend/src/modules/imweb/imweb.config.ts new file mode 100644 index 0000000..41a47a5 --- /dev/null +++ b/backend/src/modules/imweb/imweb.config.ts @@ -0,0 +1,104 @@ +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'; + +/** + * 요청 권한 — 아임웹 개발자센터 앱 설정의 권한과 일치해야 한다. + * 회원 정보는 읽기만 받는다. site-info:write 는 아임웹이 인가 때 필수로 요구하고(errorCode 30156) + * 연동완료 처리에만 쓴다. (write 는 읽기를 포함한다) + */ +export const IMWEB_SCOPES = 'site-info:write member-info: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'); + } + + get trackingHost(): string { + return this.read('IMWEB_TRACKING_HOST') || DEFAULT_TRACKING_HOST; + } + + get syncIntervalMs(): number { + const minutes = parsePositiveInt( + this.config.get('IMWEB_SYNC_INTERVAL_MINUTES'), + DEFAULT_SYNC_INTERVAL_MINUTES, + ); + return minutes * 60_000; + } + + get pageSize(): number { + return parsePositiveInt( + this.config.get('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(key) ?? '').trim(); + } +} diff --git a/backend/src/modules/imweb/imweb.module.ts b/backend/src/modules/imweb/imweb.module.ts new file mode 100644 index 0000000..652522e --- /dev/null +++ b/backend/src/modules/imweb/imweb.module.ts @@ -0,0 +1,42 @@ +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 { 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 { ImwebSyncService } from './imweb-sync.service'; + +/** 아임웹 Open API 연동 — OAuth 연결, 토큰 관리, 회원 동기화 */ +@Module({ + imports: [ + TypeOrmModule.forFeature([ + ImwebConnection, + ImwebGroup, + ImwebMember, + ImwebMemberInflowImport, + ImwebSyncRun, + ]), + AdminAuthModule, + ], + controllers: [ImwebAdminController, ImwebOAuthController], + providers: [ + ImwebConfig, + ImwebHttpClient, + ImwebAuthService, + ImwebSyncService, + ImwebAdminService, + ImwebMemberLookupService, + ], + exports: [ImwebConfig, ImwebMemberLookupService], +}) +export class ImwebModule {} diff --git a/backend/src/modules/imweb/imweb.types.ts b/backend/src/modules/imweb/imweb.types.ts new file mode 100644 index 0000000..097696c --- /dev/null +++ b/backend/src/modules/imweb/imweb.types.ts @@ -0,0 +1,77 @@ +/** + * 아임웹 Open API 응답 타입 — openapi.json(developers-docs.imweb.me/reference) 기준. + * 쓰는 필드만 옮겼다. 아임웹이 필드를 추가해도 무시하면 되므로 깨지지 않는다. + */ + +/** 모든 응답의 공통 껍데기 */ +export interface ImwebEnvelope { + 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[]; +} + +/** + * 아임웹 에러 응답 — 문서의 Error List (statusCode / errorCode / message). + * 본문 구조는 문서에 정의돼 있지 않아 모든 필드를 선택으로 둔다. + * 토큰 무효(30101)·만료(30102)는 HTTP 401 로 온다. + */ +export interface ImwebErrorBody { + statusCode?: number; + errorCode?: number | string; + message?: string; +} diff --git a/backend/src/modules/imweb/inflow-import.spec.ts b/backend/src/modules/imweb/inflow-import.spec.ts new file mode 100644 index 0000000..cbc3c4e --- /dev/null +++ b/backend/src/modules/imweb/inflow-import.spec.ts @@ -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(/필요한 열이 없습니다/); + }); +}); diff --git a/backend/src/modules/imweb/inflow-import.ts b/backend/src/modules/imweb/inflow-import.ts new file mode 100644 index 0000000..71d043f --- /dev/null +++ b/backend/src/modules/imweb/inflow-import.ts @@ -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(); + 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 { + 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; +} diff --git a/backend/src/modules/imweb/member-row.mapper.spec.ts b/backend/src/modules/imweb/member-row.mapper.spec.ts new file mode 100644 index 0000000..de8e001 --- /dev/null +++ b/backend/src/modules/imweb/member-row.mapper.spec.ts @@ -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([]); + }); +}); diff --git a/backend/src/modules/imweb/member-row.mapper.ts b/backend/src/modules/imweb/member-row.mapper.ts new file mode 100644 index 0000000..7529da4 --- /dev/null +++ b/backend/src/modules/imweb/member-row.mapper.ts @@ -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, + }; +} diff --git a/backend/src/modules/members/dto/member-query.dto.ts b/backend/src/modules/members/dto/member-query.dto.ts new file mode 100644 index 0000000..0b5b770 --- /dev/null +++ b/backend/src/modules/members/dto/member-query.dto.ts @@ -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'; +} diff --git a/backend/src/modules/members/dto/member-response.dto.ts b/backend/src/modules/members/dto/member-response.dto.ts new file mode 100644 index 0000000..af1feb4 --- /dev/null +++ b/backend/src/modules/members/dto/member-response.dto.ts @@ -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; +} diff --git a/backend/src/modules/members/members.controller.ts b/backend/src/modules/members/members.controller.ts new file mode 100644 index 0000000..f53bf3c --- /dev/null +++ b/backend/src/modules/members/members.controller.ts @@ -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> { + return this.members.findMany(query); + } + + @Get('groups') + @ApiOperation({ + summary: '회원 그룹 목록과 그룹별 회원 수 (아임웹 그룹 순서)', + }) + @ApiStandardResponse(MemberGroupDto, { + description: '조회 성공', + isArray: true, + }) + @ApiErrorResponse('AUTH_001', '로그인 필요') + findGroups(): Promise { + return this.members.findGroups(); + } + + @Get('inflow-stats') + @ApiOperation({ summary: '유입경로별 가입자 수 집계' }) + @ApiStandardResponse(InflowStatsResponseDto, { description: '집계 성공' }) + @ApiErrorResponse('AUTH_001', '로그인 필요') + @ApiErrorResponse('VAL_001', '필터 형식 오류') + stats(@Query() query: InflowStatsQueryDto): Promise { + return this.members.stats(query); + } +} diff --git a/backend/src/modules/members/members.module.ts b/backend/src/modules/members/members.module.ts new file mode 100644 index 0000000..1a22fdc --- /dev/null +++ b/backend/src/modules/members/members.module.ts @@ -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 {} diff --git a/backend/src/modules/members/members.service.ts b/backend/src/modules/members/members.service.ts new file mode 100644 index 0000000..8a2b1ec --- /dev/null +++ b/backend/src/modules/members/members.service.ts @@ -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 = { + source: 'inflowSource', + medium: 'inflowMedium', + campaign: 'inflowCampaign', + content: 'inflowContent', +}; + +/** 필터 값 → 엔티티 속성 */ +const FILTER_PROPERTY = { + source: 'inflowSource', + medium: 'inflowMedium', + campaign: 'inflowCampaign', + content: 'inflowContent', +} as const satisfies Record; + +/** 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, + @InjectRepository(ImwebGroup) + private readonly groups: Repository, + ) {} + + /** 회원 그룹 목록과 그룹별 회원 수 — 아임웹 그룹 순서대로 (회원이 없는 그룹도 포함) */ + async findGroups(): Promise { + 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 + > { + 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> { + 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 { + 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 { + 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; + } +} diff --git a/backend/src/shared/config/env.validation.spec.ts b/backend/src/shared/config/env.validation.spec.ts index 65ef407..e824ce6 100644 --- a/backend/src/shared/config/env.validation.spec.ts +++ b/backend/src/shared/config/env.validation.spec.ts @@ -12,6 +12,13 @@ const prod = (extra: Record = {}) => ({ 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,64 @@ 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({ + NODE_ENV: 'development', + IMWEB_SYNC_INTERVAL_MINUTES: '0', + }), + ).toThrow(/IMWEB_SYNC_INTERVAL_MINUTES 는 양의 정수/); + }); + it('예시 placeholder 가 그대로면 실패한다', () => { expect(() => validateEnv(prod({ DB_PASSWORD: 'CHANGE-ME-BEFORE-DEPLOY' })), diff --git a/backend/src/shared/config/env.validation.ts b/backend/src/shared/config/env.validation.ts index 674a545..a39141d 100644 --- a/backend/src/shared/config/env.validation.ts +++ b/backend/src/shared/config/env.validation.ts @@ -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,24 @@ 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', +] as const; /** 공개 주소로 쓸 수 없는 값 — 로컬 주소와 예시 파일의 예시 도메인 */ const NON_PUBLIC_URL_PATTERNS = [ @@ -95,6 +116,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) { diff --git a/backend/src/shared/utils/bearer.spec.ts b/backend/src/shared/utils/bearer.spec.ts new file mode 100644 index 0000000..f91d787 --- /dev/null +++ b/backend/src/shared/utils/bearer.spec.ts @@ -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(); + }); +}); diff --git a/backend/src/shared/utils/bearer.ts b/backend/src/shared/utils/bearer.ts new file mode 100644 index 0000000..a2e2318 --- /dev/null +++ b/backend/src/shared/utils/bearer.ts @@ -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; +} diff --git a/backend/src/shared/utils/credential.spec.ts b/backend/src/shared/utils/credential.spec.ts new file mode 100644 index 0000000..c03898c --- /dev/null +++ b/backend/src/shared/utils/credential.spec.ts @@ -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', + ); + }); +}); diff --git a/backend/src/shared/utils/credential.ts b/backend/src/shared/utils/credential.ts new file mode 100644 index 0000000..649235c --- /dev/null +++ b/backend/src/shared/utils/credential.ts @@ -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); +} diff --git a/backend/src/shared/utils/duration.spec.ts b/backend/src/shared/utils/duration.spec.ts new file mode 100644 index 0000000..a2e07e2 --- /dev/null +++ b/backend/src/shared/utils/duration.spec.ts @@ -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); + }); +}); diff --git a/backend/src/shared/utils/duration.ts b/backend/src/shared/utils/duration.ts new file mode 100644 index 0000000..455f24e --- /dev/null +++ b/backend/src/shared/utils/duration.ts @@ -0,0 +1,37 @@ +/** + * 기간 문자열 파싱 유틸 (순수 함수). + * + * `JWT_ACCESS_EXPIRES_IN=1h` 같은 env 값을 밀리초로 바꾼다. + * JWT 만료와 쿠키 maxAge 가 같은 값을 써야 어긋나지 않는다. + */ + +const UNIT_TO_MS: Readonly> = { + 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; +} diff --git a/backend/src/shared/utils/env.spec.ts b/backend/src/shared/utils/env.spec.ts index f88e1bb..3be54f9 100644 --- a/backend/src/shared/utils/env.spec.ts +++ b/backend/src/shared/utils/env.spec.ts @@ -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([]); + }); +}); diff --git a/backend/src/shared/utils/env.ts b/backend/src/shared/utils/env.ts index 366f163..50a75b4 100644 --- a/backend/src/shared/utils/env.ts +++ b/backend/src/shared/utils/env.ts @@ -20,3 +20,15 @@ export function parsePositiveInt( return parsed; } + +/** + * 콤마로 구분한 목록을 배열로 파싱한다. 공백·빈 항목은 버린다. + * 예) `CORS_ORIGIN=https://www.a.com, https://a.com` + */ +export function parseList(value: string | undefined): string[] { + if (value === undefined) return []; + return value + .split(',') + .map((item) => item.trim()) + .filter((item) => item !== ''); +} diff --git a/backend/src/shared/utils/inflow.spec.ts b/backend/src/shared/utils/inflow.spec.ts new file mode 100644 index 0000000..7e49e7e --- /dev/null +++ b/backend/src/shared/utils/inflow.spec.ts @@ -0,0 +1,187 @@ +import { InflowStatus, parseInflow } from './inflow'; + +const HOST = 'utm.moneyguider.co.kr'; + +/** header-top.html 의 serialize(asUrl) 와 같은 방식으로 홈페이지 값을 만든다 */ +function homePage(params: Record): string { + const query = Object.entries(params) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); + return `https://${HOST}/?${query}`; +} + +describe('parseInflow', () => { + it('스크립트가 기록한 유입정보를 필드별로 꺼낸다', () => { + const result = parseInflow( + homePage({ + src: 'naver', + med: 'cpc', + jd: '2026-10-05', + cmp: '가을 프로모션', + cnt: 'banner_a', + trm: '재테크', + last: 'instagram/retarget', + cid: 'n_ad_group', + lp: '/free-shop/', + pid: '52', + ref: 'search.naver.com', + ts: '2026-10-04', + }), + '2026-10-05T03:00:00.000Z', + HOST, + ); + + expect(result).toEqual({ + status: InflowStatus.AT_JOIN, + source: 'naver', + medium: 'cpc', + campaign: '가을 프로모션', + content: 'banner_a', + term: '재테크', + clickId: 'n_ad_group', + referrer: 'search.naver.com', + landingPath: '/free-shop/', + productId: '52', + firstTouchDate: '2026-10-04', + lastTouch: 'instagram/retarget', + recordedDate: '2026-10-05', + }); + }); + + it('가입일은 KST 기준으로 비교한다 (UTC 15시 이후는 KST 다음 날)', () => { + // UTC 2026-10-04 16:00 = KST 2026-10-05 01:00 + const result = parseInflow( + homePage({ src: 'google', med: 'organic', jd: '2026-10-05' }), + '2026-10-04T16:00:00.000Z', + HOST, + ); + expect(result.status).toBe(InflowStatus.AT_JOIN); + }); + + it('폼을 연 다음 날 제출한 경우(기록일이 가입일 하루 전)도 가입 기록으로 본다', () => { + const result = parseInflow( + homePage({ src: 'kakao', med: 'social', jd: '2026-10-04' }), + '2026-10-05T00:30:00.000Z', + HOST, + ); + expect(result.status).toBe(InflowStatus.AT_JOIN); + }); + + it('가입 후 회원정보 수정 때 채워진 값은 AFTER_JOIN 으로 구분한다', () => { + const result = parseInflow( + homePage({ src: 'direct', med: 'none', jd: '2026-10-20' }), + '2026-03-01T00:00:00.000Z', + HOST, + ); + expect(result.status).toBe(InflowStatus.AFTER_JOIN); + expect(result.source).toBe('direct'); + }); + + it('기록일이 가입일보다 이틀 이상 앞서면 가입 기록으로 보지 않는다', () => { + const result = parseInflow( + homePage({ src: 'naver', med: 'cpc', jd: '2026-10-01' }), + '2026-10-05T03:00:00.000Z', + HOST, + ); + expect(result.status).toBe(InflowStatus.AFTER_JOIN); + }); + + it('jd 가 없으면 판별 근거가 없으므로 가입 기록으로 둔다', () => { + const result = parseInflow( + homePage({ src: 'naver', med: 'cpc' }), + '2026-10-05T03:00:00.000Z', + HOST, + ); + expect(result.status).toBe(InflowStatus.AT_JOIN); + expect(result.recordedDate).toBeNull(); + }); + + it('홈페이지가 비었거나 회원이 직접 입력한 주소면 NONE', () => { + const join = '2026-10-05T03:00:00.000Z'; + expect(parseInflow(null, join, HOST).status).toBe(InflowStatus.NONE); + expect(parseInflow('', join, HOST).status).toBe(InflowStatus.NONE); + expect(parseInflow('https://myblog.com/?src=x', join, HOST).status).toBe( + InflowStatus.NONE, + ); + expect(parseInflow('not a url', join, HOST).status).toBe(InflowStatus.NONE); + }); + + it('src 가 없으면 NONE', () => { + const result = parseInflow( + homePage({ med: 'cpc', jd: '2026-10-05' }), + '2026-10-05T03:00:00.000Z', + HOST, + ); + expect(result.status).toBe(InflowStatus.NONE); + }); + + it('날짜 형식이 아닌 ts/jd 는 버린다', () => { + const result = parseInflow( + homePage({ src: 'naver', jd: '어제', ts: '2026/10/05' }), + '2026-10-05T03:00:00.000Z', + HOST, + ); + expect(result.recordedDate).toBeNull(); + expect(result.firstTouchDate).toBeNull(); + }); + + it('긴 값은 컬럼 길이(100자)로 자른다', () => { + const result = parseInflow( + homePage({ src: 'naver', cmp: 'a'.repeat(300) }), + '2026-10-05T03:00:00.000Z', + HOST, + ); + expect(result.campaign).toHaveLength(100); + }); +}); + +describe('parseInflow — 파이프 형식 (유입경로 항목·엑셀)', () => { + it('파이프 형식을 URL 형식과 같은 필드로 꺼낸다', () => { + const result = parseInflow( + 'src=facebook|med=paid|cmp=가을 이벤트|cnt=video_a|cid=fbclid|lp=/free-shop/|pid=52|ts=2026-09-01|jd=2026-09-01', + '2026-09-01T05:00:00.000Z', + HOST, + ); + expect(result).toMatchObject({ + status: InflowStatus.AT_JOIN, + source: 'facebook', + medium: 'paid', + campaign: '가을 이벤트', + content: 'video_a', + clickId: 'fbclid', + landingPath: '/free-shop/', + productId: '52', + firstTouchDate: '2026-09-01', + recordedDate: '2026-09-01', + }); + }); + + it('예전 스크립트의 UTC 날짜(KST 0~9시 가입은 jd 가 하루 이름)도 가입 기록으로 본다', () => { + // KST 2026-09-02 03:00 가입 = UTC 2026-09-01 18:00, 예전 스크립트는 jd=2026-09-01 로 기록 + const result = parseInflow( + 'src=google|med=organic|jd=2026-09-01', + '2026-09-01T18:00:00.000Z', + HOST, + ); + expect(result.status).toBe(InflowStatus.AT_JOIN); + }); + + it('값 안의 & 나 % 를 그대로 둔다 (파이프 형식은 인코딩하지 않는다)', () => { + const result = parseInflow( + 'src=naver|cmp=a&b 50%|jd=2026-09-01', + '2026-09-01T05:00:00.000Z', + HOST, + ); + expect(result.campaign).toBe('a&b 50%'); + }); + + it('스크립트 형식이 아니면 NONE', () => { + const join = '2026-09-01T05:00:00.000Z'; + expect(parseInflow('인스타그램 보고 왔어요', join, HOST).status).toBe( + InflowStatus.NONE, + ); + expect(parseInflow('med=cpc|jd=2026-09-01', join, HOST).status).toBe( + InflowStatus.NONE, + ); + }); +}); diff --git a/backend/src/shared/utils/inflow.ts b/backend/src/shared/utils/inflow.ts new file mode 100644 index 0000000..4907846 --- /dev/null +++ b/backend/src/shared/utils/inflow.ts @@ -0,0 +1,199 @@ +/** + * 회원 유입경로 파싱 유틸 (순수 함수). + * + * 아임웹 Header Code 의 UTM 스크립트(header-top.html)가 회원가입 폼의 '홈페이지' 항목에 + * 아래 형식으로 유입정보를 기록한다. 아임웹 Open API 회원 조회에는 사용자 정의 가입 항목이 + * 나오지 않고 homePage 만 나오기 때문에 이 값을 풀어서 쓴다. + * + * https://utm.moneyguider.co.kr/?src=naver&med=cpc&jd=2026-10-05&cmp=fall&lp=%2Fapply + * + * 같은 스크립트가 사용자 정의 가입 항목 '유입경로'에는 파이프 형식으로 기록한다. + * 이 항목은 API 로 나오지 않아 아임웹 회원 엑셀로 받아 가져온다 (scripts/import-inflow.ts). + * + * src=naver|med=cpc|cmp=fall|lp=/apply|ts=2026-10-04|jd=2026-10-05 + * + * | 키 | 의미 | + * | ---- | ---------------------------- | + * | src | 유입원 (utm_source) | + * | med | 매체 (utm_medium) | + * | cmp | 캠페인 (utm_campaign) | + * | cnt | 콘텐츠 (utm_content) | + * | trm | 검색어 (utm_term) | + * | cid | 광고 클릭 ID 종류 (gclid 등) | + * | ref | 외부 리퍼러 호스트 | + * | lp | 첫 랜딩 경로 | + * | pid | 랜딩 상품/게시글 idx | + * | ts | 최초 유입일 (KST) | + * | last | 최종 유입 (최초와 다를 때만) | + * | jd | 값을 기록한 날 (KST) | + */ + +/** 유입정보를 어디서 가져왔는가 */ +export enum InflowOrigin { + /** 아임웹 API 의 homePage (가입 폼 '홈페이지' 항목) */ + HOMEPAGE = 'homepage', + /** 아임웹 회원 엑셀의 '유입경로' 항목 — homePage 기록 전 가입자 */ + IMPORT = 'import', +} + +/** 유입정보가 언제 기록되었는가 */ +export enum InflowStatus { + /** 회원가입 폼에서 기록됨 — 가입 당시 유입경로 */ + AT_JOIN = 'at_join', + /** 가입 이후 회원정보 수정 화면에서 채워짐 — 가입 경로가 아니다 */ + AFTER_JOIN = 'after_join', + /** 기록 없음 (스크립트 설치 전 가입, 다른 홈페이지 주소 입력 등) */ + NONE = 'none', +} + +export interface ParsedInflow { + status: InflowStatus; + source: string | null; + medium: string | null; + campaign: string | null; + content: string | null; + term: string | null; + clickId: string | null; + referrer: string | null; + landingPath: string | null; + productId: string | null; + /** 최초 유입일 YYYY-MM-DD (KST) */ + firstTouchDate: string | null; + /** 최종 유입 `유입원/캠페인` — 최초 유입과 다를 때만 있다 */ + lastTouch: string | null; + /** 값을 기록한 날 YYYY-MM-DD (KST) */ + recordedDate: string | null; +} + +/** 저장 컬럼 길이 — 엔티티의 varchar 길이와 맞춘다 */ +export const INFLOW_VALUE_MAX_LENGTH = 100; + +const KST_OFFSET_MS = 9 * 3_600_000; +const DAY_MS = 86_400_000; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +/** + * 가입 폼을 연 날과 제출한 날이 하루 어긋날 수 있다 (23:59 에 폼을 열고 00:01 에 제출). + * 기록일(jd)이 가입일 하루 전까지는 가입 때 기록된 값으로 본다. + */ +const JOIN_RECORD_TOLERANCE_DAYS = 1; + +const EMPTY: Omit = { + source: null, + medium: null, + campaign: null, + content: null, + term: null, + clickId: null, + referrer: null, + landingPath: null, + productId: null, + firstTouchDate: null, + lastTouch: null, + recordedDate: null, +}; + +function pick(params: URLSearchParams, key: string): string | null { + const value = params.get(key)?.trim(); + if (!value) return null; + return value.slice(0, INFLOW_VALUE_MAX_LENGTH); +} + +function pickDate(params: URLSearchParams, key: string): string | null { + const value = pick(params, key); + return value && DATE_PATTERN.test(value) ? value : null; +} + +/** ISO 시각을 KST 날짜(YYYY-MM-DD)의 UTC 자정 타임스탬프로 바꾼다. */ +function kstDayStart(iso: string): number | null { + const time = Date.parse(iso); + if (Number.isNaN(time)) return null; + const kstDate = new Date(time + KST_OFFSET_MS).toISOString().slice(0, 10); + return Date.parse(`${kstDate}T00:00:00.000Z`); +} + +/** 기록일이 가입일 기준 허용 범위 안이면 가입 때 기록된 값이다. */ +function isRecordedAtJoin(recordedDate: string, joinTime: string): boolean { + const joinDay = kstDayStart(joinTime); + if (joinDay === null) return true; // 가입일을 모르면 판별할 수 없으므로 가입 기록으로 둔다 + const recordedDay = Date.parse(`${recordedDate}T00:00:00.000Z`); + const diffDays = (joinDay - recordedDay) / DAY_MS; + return diffDays >= 0 && diffDays <= JOIN_RECORD_TOLERANCE_DAYS; +} + +const URL_PATTERN = /^https?:/; //i; +const PIPE_PATTERN = /^src=/i; + +/** 스크립트가 쓴 값을 키-값으로 바꾼다. 스크립트 형식이 아니면 null. */ +function toParams(value: string, trackingHost: string): URLSearchParams | null { + if (URL_PATTERN.test(value)) { + let url: URL; + try { + url = new URL(value); + } catch { + return null; + } + // 회원이 직접 입력한 진짜 홈페이지 주소는 유입정보가 아니다 + if (url.hostname.toLowerCase() !== trackingHost.toLowerCase()) return null; + return url.searchParams; + } + + if (PIPE_PATTERN.test(value)) { + // 스크립트가 값에서 | 와 = 를 지우고 쓰므로 그대로 나누면 된다 (인코딩 없음) + const params = new URLSearchParams(); + for (const part of value.split('|')) { + const separator = part.indexOf('='); + if (separator > 0) { + params.set(part.slice(0, separator).trim(), part.slice(separator + 1)); + } + } + return params; + } + return null; +} + +/** + * 스크립트가 기록한 값에서 유입정보를 꺼낸다 — URL(홈페이지 항목)·파이프(유입경로 항목) 형식 모두. + * + * @param value 아임웹 회원의 homePage 값, 또는 엑셀의 '유입경로' 값 + * @param joinTime 아임웹 회원의 가입 시각 (ISO) + * @param trackingHost 유입 기록용 가짜 호스트 (예: utm.moneyguider.co.kr) + */ +export function parseInflow( + value: string | null | undefined, + joinTime: string, + trackingHost: string, +): ParsedInflow { + const none: ParsedInflow = { status: InflowStatus.NONE, ...EMPTY }; + const trimmed = value?.trim(); + if (!trimmed) return none; + + const params = toParams(trimmed, trackingHost); + if (!params) return none; + + const source = pick(params, 'src'); + if (!source) return none; + + const recordedDate = pickDate(params, 'jd'); + // 스크립트는 항상 jd 를 넣는다. 없으면 판별 근거가 없으므로 가입 기록으로 둔다. + const status = + recordedDate === null || isRecordedAtJoin(recordedDate, joinTime) + ? InflowStatus.AT_JOIN + : InflowStatus.AFTER_JOIN; + + return { + status, + source, + medium: pick(params, 'med'), + campaign: pick(params, 'cmp'), + content: pick(params, 'cnt'), + term: pick(params, 'trm'), + clickId: pick(params, 'cid'), + referrer: pick(params, 'ref'), + landingPath: pick(params, 'lp'), + productId: pick(params, 'pid'), + firstTouchDate: pickDate(params, 'ts'), + lastTouch: pick(params, 'last'), + recordedDate, + }; +} diff --git a/backend/src/shared/utils/jwt.spec.ts b/backend/src/shared/utils/jwt.spec.ts new file mode 100644 index 0000000..245ac64 --- /dev/null +++ b/backend/src/shared/utils/jwt.spec.ts @@ -0,0 +1,25 @@ +import { readJwtExpiryMs } from './jwt'; + +function fakeJwt(payload: object): string { + const encode = (value: object) => + Buffer.from(JSON.stringify(value)).toString('base64url'); + return `${encode({ alg: 'HS256' })}.${encode(payload)}.signature`; +} + +describe('readJwtExpiryMs', () => { + it('exp(초)를 밀리초로 반환한다', () => { + expect(readJwtExpiryMs(fakeJwt({ exp: 1_800_000_000 }))).toBe( + 1_800_000_000_000, + ); + }); + + it('exp 가 없거나 숫자가 아니면 null', () => { + expect(readJwtExpiryMs(fakeJwt({ sub: 'x' }))).toBeNull(); + expect(readJwtExpiryMs(fakeJwt({ exp: 'soon' }))).toBeNull(); + }); + + it('JWT 형식이 아니면 null', () => { + expect(readJwtExpiryMs('opaque-token')).toBeNull(); + expect(readJwtExpiryMs('a.%%%.c')).toBeNull(); + }); +}); diff --git a/backend/src/shared/utils/jwt.ts b/backend/src/shared/utils/jwt.ts new file mode 100644 index 0000000..1d367ae --- /dev/null +++ b/backend/src/shared/utils/jwt.ts @@ -0,0 +1,23 @@ +/** + * JWT 페이로드 읽기 유틸 (순수 함수). + * + * 외부(아임웹)가 발급한 토큰의 만료 시각을 알기 위해 쓴다. **서명은 검증하지 않는다** — + * 우리가 발급한 토큰의 검증에는 쓰지 말 것 (그건 JwtService.verifyAsync 가 한다). + */ + +/** JWT 의 exp(초)를 밀리초 타임스탬프로 꺼낸다. JWT 가 아니거나 exp 가 없으면 null. */ +export function readJwtExpiryMs(token: string): number | null { + const parts = token.split('.'); + if (parts.length !== 3) return null; + + try { + const json = Buffer.from(parts[1], 'base64url').toString('utf8'); + const payload: unknown = JSON.parse(json); + if (typeof payload !== 'object' || payload === null) return null; + + const exp: unknown = (payload as Record).exp; + return typeof exp === 'number' && Number.isFinite(exp) ? exp * 1000 : null; + } catch { + return null; + } +} diff --git a/backend/src/shared/utils/time-cursor.spec.ts b/backend/src/shared/utils/time-cursor.spec.ts new file mode 100644 index 0000000..975ee00 --- /dev/null +++ b/backend/src/shared/utils/time-cursor.spec.ts @@ -0,0 +1,20 @@ +import { decodeTimeCursor, encodeTimeCursor } from './time-cursor'; + +describe('시각 커서', () => { + it('인코딩한 커서를 그대로 되돌린다', () => { + const cursor = { time: '2026-10-05T03:00:00.000Z', key: 'm20261005abc' }; + expect(decodeTimeCursor(encodeTimeCursor(cursor))).toEqual(cursor); + }); + + it('형식이 틀린 커서는 null', () => { + expect(decodeTimeCursor('not-base64-json')).toBeNull(); + expect( + decodeTimeCursor(Buffer.from('{"t":1}').toString('base64url')), + ).toBeNull(); + expect( + decodeTimeCursor( + Buffer.from('{"t":"어제","c":"m1"}').toString('base64url'), + ), + ).toBeNull(); + }); +}); diff --git a/backend/src/shared/utils/time-cursor.ts b/backend/src/shared/utils/time-cursor.ts new file mode 100644 index 0000000..e340506 --- /dev/null +++ b/backend/src/shared/utils/time-cursor.ts @@ -0,0 +1,34 @@ +/** + * 시각 내림차순 목록의 커서 (순수 함수) — 회원 목록·신청 내역이 함께 쓴다. + * 같은 시각이 여럿이어도 건너뛰거나 중복되지 않게 고유값(key)을 함께 담는다. + */ +export interface TimeCursor { + /** 정렬 기준 시각 (ISO) */ + time: string; + /** 같은 시각끼리 순서를 정하는 고유값 */ + key: string; +} + +export function encodeTimeCursor(cursor: TimeCursor): string { + return Buffer.from( + JSON.stringify({ t: cursor.time, c: cursor.key }), + ).toString('base64url'); +} + +/** 형식이 맞지 않으면 null — 호출부가 VAL_001 로 응답한다 */ +export function decodeTimeCursor(raw: string): TimeCursor | null { + try { + const parsed: unknown = JSON.parse( + Buffer.from(raw, 'base64url').toString('utf8'), + ); + if (typeof parsed !== 'object' || parsed === null) return null; + + const { t, c } = parsed as Record; + if (typeof t !== 'string' || typeof c !== 'string') return null; + if (Number.isNaN(Date.parse(t)) || c === '') return null; + + return { time: t, key: c }; + } catch { + return null; + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 35bd0c5..fde0463 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -46,6 +46,12 @@ services: build: target: builder command: npm run start:dev + # 개발 모드는 파일 감시 + 타입 검사(tsc --watch)가 함께 돌고, 같은 컨테이너에서 lint·jest 도 실행한다. + # 운영 한도(1g)로는 메모리가 부족해 OOM 으로 강제 종료된 적이 있어 개발에서만 늘린다. + deploy: + resources: + limits: + memory: 2g ports: - "${BACKEND_PORT}:${BACKEND_PORT}" - "${WEBSOCKET_PORT}:${WEBSOCKET_PORT}" diff --git a/docker-compose.yml b/docker-compose.yml index 7ccb969..f589241 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,6 +70,11 @@ services: - DB_SYNCHRONIZE=${DB_SYNCHRONIZE:-false} - UPLOAD_DIR=/app/uploads - UPLOAD_MAX_SIZE_MB=${UPLOAD_MAX_SIZE_MB:-5} + # 아임웹 연동 — 비밀값(IMWEB_CLIENT_ID/SECRET)은 env_file(backend/.env.*)로 들어온다 + - IMWEB_SITE_CODE=${IMWEB_SITE_CODE:-} + - IMWEB_ADMIN_PAGE_URL=${IMWEB_ADMIN_PAGE_URL:-} + - IMWEB_SYNC_INTERVAL_MINUTES=${IMWEB_SYNC_INTERVAL_MINUTES:-60} + - IMWEB_PAGE_SIZE=${IMWEB_PAGE_SIZE:-100} - REDIS_HOST=redis - REDIS_PORT=6379 - REDIS_PASSWORD=${REDIS_PASSWORD} diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index dad4a42..33f8f1e 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -11,6 +11,12 @@ const Map kErrorCodeMessages = { 'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', 'FILE_001': '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.', 'FILE_002': '파일 크기가 허용 범위를 초과했습니다.', + 'IMWEB_001': '아임웹 연결이 되어 있지 않습니다. 먼저 아임웹 계정을 연결해 주세요.', + 'IMWEB_002': '아임웹에서 정보를 가져오지 못했습니다. 잠시 후 다시 시도해 주세요.', + 'IMWEB_003': '아임웹 연결 요청이 만료되었거나 올바르지 않습니다. 처음부터 다시 연결해 주세요.', + 'IMWEB_004': '회원 동기화가 이미 진행 중입니다. 잠시 후 다시 확인해 주세요.', + 'IMWEB_005': '아임웹 연동 설정이 완료되지 않았습니다. 관리자에게 문의해 주세요.', + 'APPLY_001': '회원 정보를 확인할 수 없어 신청을 기록하지 못했습니다.', }; /// 매핑 실패 시 사용하는 폴백 코드 diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index 7071d62..6a16ba8 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -11,6 +11,12 @@ export const ERROR_CODE_MESSAGES = { BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', FILE_001: '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.', FILE_002: '파일 크기가 허용 범위를 초과했습니다.', + IMWEB_001: '아임웹 연결이 되어 있지 않습니다. 먼저 아임웹 계정을 연결해 주세요.', + IMWEB_002: '아임웹에서 정보를 가져오지 못했습니다. 잠시 후 다시 시도해 주세요.', + IMWEB_003: '아임웹 연결 요청이 만료되었거나 올바르지 않습니다. 처음부터 다시 연결해 주세요.', + IMWEB_004: '회원 동기화가 이미 진행 중입니다. 잠시 후 다시 확인해 주세요.', + IMWEB_005: '아임웹 연동 설정이 완료되지 않았습니다. 관리자에게 문의해 주세요.', + APPLY_001: '회원 정보를 확인할 수 없어 신청을 기록하지 못했습니다.', } as const export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES