# 아임웹 회원 유입경로 관리자 화면 아임웹 관리자 전용 페이지에 붙여 넣는 화면(`imweb-members.html`)과 로컬 테스트 도구. ``` 아임웹 관리자 페이지 (코드 위젯) ← 1차 보호: 아임웹 페이지 접근 권한 │ fetch + Authorization 헤더 (토큰은 sessionStorage) ▼ 백엔드 /api/admin/* ← 2차 보호: 관리자 로그인 (Authorization: Bearer) │ 주기적 전체 동기화 (기본 60분) ▼ 아임웹 Open API /member-info/members ← homePage 에 UTM 스크립트가 기록한 유입정보 ``` 유입정보는 아임웹 Header Code 의 UTM 스크립트(`imweb-header-top.html`)가 회원가입 폼의 '홈페이지' 항목에 `https://utm.moneyguider.co.kr/?src=..&med=..&jd=..` 형식으로 남긴 값이다. 백엔드가 이 값을 풀어서 유입원·매체·캠페인별로 조회·집계한다. ## 파일 — 아임웹 어디에 붙이나 아임웹에 실제로 들어가 있는 코드의 원본이다. 고친 뒤에는 아임웹에 **다시 붙여 넣어야** 반영된다. | 파일 | 아임웹 위치 | 하는 일 | | --- | --- | --- | | `imweb-header-top.html` | Header Code (사이트 전체) | UTM 유입경로 기록(`MG_UTM`) + 업체 링크 클릭 보관(`MG_PARTNER`) | | `imweb-header.html` | Header Code (사이트 전체) | 로그인 링크의 back_url 에 UTM 을 붙여 로그인 후에도 유지 | | `imweb-free-apply.html` | `/apply` 페이지 HTML 위젯 | 신청 완료 화면 + 신청 기록·오픈카톡 클릭 전송 | | `imweb-members.html` | 관리자 전용 페이지 코드 위젯 | 관리자 대시보드 | | `serve-local.js` | — | 로컬 테스트용 정적 서버 | ## 로컬에서 띄우기 1. 백엔드 env 에 아임웹 앱 키와 관리자 계정 입력 — `backend/.env.development` ``` IMWEB_CLIENT_ID=... IMWEB_CLIENT_SECRET=... ADMIN_USERNAME=... # 관리자 화면 로그인 아이디 (여러 사람이 함께 쓴다) ADMIN_PASSWORD=... # 12자 이상. 바꾸고 재시작하면 모든 기기가 로그아웃된다 ``` 2. 아임웹 개발자센터 앱 설정 - redirect URI: `http://localhost:3100/api/imweb/oauth/callback` (= 루트 `BACKEND_API_URL` + `/imweb/oauth/callback`, 정확히 일치해야 한다) - 권한: `사이트 정보 읽기∙쓰기(site-info:write)`, `회원 정보 읽기(member-info:read)`, `상품 읽기(product:read)`, `주문 읽기(order:read)` (백엔드가 이 네 가지를 요청한다 — 앱 설정에 하나라도 없으면 연결이 errorCode 30156 으로 거부된다. site-info:write 는 아임웹이 연동 때 필수로 요구한다) 3. 백엔드 기동·마이그레이션 ```bash docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d docker exec moneyguide-backend npm run migration:run ``` 4. 화면 띄우기 — `node imweb/serve-local.js` → 5. 로그인 → **아임웹 연결** → 아임웹 동의 → 돌아오면 첫 동기화가 자동으로 시작된다 ## 과거 회원 유입경로 가져오기 (엑셀) 가입 폼 '홈페이지' 항목에 기록하기 전 가입자의 유입경로는 사용자 정의 항목 '유입경로'에만 있고, 이 항목은 Open API 로 나오지 않는다. 아임웹 관리자 > 회원 > 엑셀 다운로드로 받아 가져온다. ```bash # Git Bash 는 MSYS_NO_PATHCONV=1 필요 (/tmp 경로가 바뀌는 것을 막는다) 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 ``` - `고유키`(= API memberCode)로 매칭하고 `유입경로` 열만 읽는다. 나머지 열(개인정보)은 쓰지 않는다. - 원본은 `imweb_member_inflow_imports` 에 보관한다. 동기화가 매번 homePage 기준으로 다시 쓰므로 회원 행에 직접 넣으면 지워진다 — homePage 에 유입정보가 없을 때 이 값을 쓴다 (homePage 우선). - 같은 고유키는 덮어쓰므로 다시 실행해도 안전하다. 출력은 개수만 나온다. - 엑셀 파일은 가져온 뒤 지운다. - 2026-10-05 `회원2026_10_05_1.xls` (2026-08-20 ~ 09-07 가입자 2,489명) 가져옴. 유입경로 항목이 생긴 2026-08-20 이전 가입자(약 12,500명)는 복구할 수 없다. ## 신청·구매 내역 (관리자 화면) 관리자 화면 **신청·구매** 메뉴는 무료 신청과 유료 구매를 한 화면에서 본다 (`GET /api/admin/activities` · `/groups` · `/inflow-stats`). - 종류 [전체 · 무료 신청 · 유료 구매] 로 목록을 거르고, 위쪽 표에서 상품별·업체별 신청 수·구매 수를 함께 본다 - 구매 수는 결제완료만 센다 — 환불·취소된 구매는 목록에 '환불·취소'로 남고, 표에는 '환불 N'으로 따로 적는다 - 유입 집계: '신청·구매 때 유입'(신청 완료·주문완료 화면이 남긴 그 순간의 경로)과 '가입 경로'(회원이 가입할 때 기록된 경로) 두 기준으로 본다. 주문완료 화면 연동 전 구매는 구매 때 유입이 없고, 비회원 구매는 가입 경로가 없다 ## 강의 신청 기록 (신청 완료 화면 → 신청·구매 내역) 아임웹 `/apply` 페이지의 신청 완료 위젯(`imweb-free-apply.html`)이 표시될 때 `POST /api/applications` 로 신청을 남긴다. 관리자 화면 **신청·구매** 메뉴에서 본다. - 보내는 값: `MEMBER_UID`(회원 아이디 또는 회원 코드), 상품번호(`idx`), 상품명, 최초 유입(`MG_UTM.peek().value`), 최종 유입(`MG_UTM.peek().last` 를 같은 파이프 형식으로), 업체 링크 클릭 ID(`MG_PARTNER.refs()`) — `peek()` 는 문자열이 아니라 객체를 돌려준다 (최종 유입은 저장만 하고 화면에는 보여주지 않는다) - **가입 경로와 신청 유입은 따로 남는다** — 관리자 화면 '유입' 집계에서 기준을 바꿔 비교한다 | 기준 | 값 | 예) 3주 전 네이버로 가입 → 오늘 페이스북 광고로 와서 신청 | | --- | --- | --- | | 신청 때 유입 | 신청 시점 브라우저에 남아 있던 첫 경로 (UTM 스크립트 보관 2일) | facebook | | 가입 경로 | 그 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외) | naver | 다른 기기에서 신청하면 신청 때 유입은 direct 로 남는다. - **업체 링크는 첫 경로보다 우선한다** — 업체 링크(`utm_medium=partner`)로 들어오면 보관 중인 첫 경로를 그 업체로 바꾼다. 예) 어제 광고로 들어와 가입 → 오늘 업체 링크로 신청: 가입 경로 광고, 신청 때 유입 업체. 여러 업체 링크를 거치면 마지막 업체가 남는다 (업체 실적의 마지막 클릭 규칙과 같다) - 백엔드는 아임웹에 실제로 있는 회원인지 확인하고(동기화 전 신규 가입자는 아임웹 API 로 직접 확인), 회원·상품당 한 건만 남긴다. 이름·연락처는 브라우저 값이 아니라 아임웹 회원 정보를 쓴다 - 오픈카톡 버튼 클릭은 `POST /api/applications/{id}/kakao-clicks` 로 처음 한 번만 남는다 - 공개 API 라 요청 수를 제한한다 (신청 1분 10회, 클릭 1분 20회 — IP 기준) - 운영 백엔드 CORS_ORIGIN 에 아임웹 도메인(www 유무 둘 다)이 있어야 한다 - 한계: 신청 완료 화면은 공개 페이지라 "실제로 그 회원이 보냈는지"는 확인할 수 없다 (있는 회원인지만 확인). 무료 신청 기록 용도라 이 수준으로 둔다 ## 유료 구매 기록 (아임웹 주문 → 신청·구매 내역) 아임웹은 주문 완료를 알려주지 않으므로(웹훅 없음) 서버가 1시간마다 아임웹 주문(`GET /orders`)을 훑어 결제된 주문을 `purchases` 에 주문·상품당 한 줄로 남긴다. 화면의 '구매 집계 새로고침'(업체 링크 메뉴)으로 바로 돌릴 수도 있다. - 회원·비회원 주문을 모두 남긴다. 비회원은 주문서의 주문자 이름·이메일·연락처로 보여준다 - 결제 전·결제 실패·입금 전 취소 주문은 남기지 않는다. 나중에 환불·취소되면 지우지 않고 '환불·취소'로 바꾼다 - 서버가 뜬 뒤 첫 실행은 **최근 1년**, 그 뒤로는 **최근 60일**만 다시 본다 (60일보다 오래된 주문의 환불은 반영되지 않는다) - 사은품 품목은 넣지 않는다. 0원 주문도 결제완료면 구매로 남는다 - 금액은 관리하지 않는다 - 구매 때 유입은 아래 '구매 때 유입' 절 — 현황·한계는 [`docs/purchase-tracking.md`](../docs/purchase-tracking.md) ## 구매 때 유입 (주문완료 화면 → 구매 기록) 아임웹 주문에는 손님이 어디서 왔는지가 없어서, Header Code(`imweb-header-top.html` 의 '구매 때 유입 전송')가 주문완료 화면(`/shop_payment_complete?order_no=주문번호`)에서 `POST /api/order-inflows` 로 보낸다. ``` 주문완료 화면 ── { orderNo, inflow: MG_UTM.peek().value, partnerRefs: MG_PARTNER.refs() } │ POST /api/order-inflows (공개 · 1분 10회 · 항상 204 · 같은 주문번호는 처음 값만) ▼ order_inflows ── 주문 동기화(1시간마다)가 같은 주문번호의 구매에 붙인다 ├─▶ purchases.inflow_* 구매 때 유입 (비어 있는 구매에만 — 한 번 붙으면 바뀌지 않는다) └─▶ partner_conversions 업체 실적 — 회원 클릭 연결 + 주문완료 클릭 ID 중 마지막 클릭 (비회원도) ``` - 주소의 `order_no` 가 아임웹 API 의 `orderNo` 와 같은 값이다 (관리자 화면에서 확인함) - 유입 기준은 무료 신청과 같다 — 처음 들어온 경로(2일 보관), 단 업체 링크로 오면 그 업체가 우선 - 결제창(카카오페이·네이버페이·카드사)에서 돌아올 때의 리퍼러는 유입으로 치지 않는다 (`/shop_payment*` 경로 제외, v1.3) - 무통장입금은 주문완료 때 받아 두었다가 입금이 확인돼 구매로 잡힐 때 붙는다 - 90일 동안 주문과 이어지지 않은 값(없는 주문번호·끝내 결제되지 않은 주문)은 동기화가 지운다 - 한계: 다른 기기에서 결제하거나 스크립트가 막힌 브라우저면 남지 않는다. 공개 API 라 주문번호를 아는 사람은 먼저 가짜 값을 보낼 수 있다 (실제 주문에만 붙고, 업체 실적은 실제로 있는 클릭만 인정한다) ## 업체 링크 (업체별 신청·구매 추적) 상품마다 업체에 단축 링크를 발급하고, 그 링크로 들어온 회원의 신청·구매를 업체별로 센다. 관리자 화면 **업체 링크** 메뉴에서 업체 추가 → 링크 발급 → 실적 확인. ``` 업체에 링크 전달 ── https://www.moneyguider.co.kr/go?c={코드} (아임웹 'go' 페이지) │ ⓪ Header Code 가 페이지를 그리기 전에 백엔드 단축 링크 moneyguide.wandergo.net/r/{코드} 로 넘긴다 ▼ 백엔드 단축 링크 │ ① 클릭 기록 (카톡 미리보기·검색봇 제외) 후 상품 페이지로 이동하며 클릭 ID(mg_ref)와 │ 업체 UTM(utm_source=업체 이름, utm_medium=partner, utm_campaign=링크 코드)을 붙인다 │ (이동 주소에 관리자가 직접 넣은 UTM 이 있으면 그것을 쓴다) ▼ 상품 페이지 ── imweb-header-top.html(Header Code)의 업체 링크 추적이 클릭 ID 를 30일 보관하고 주소창에서 지운다 │ (UTM 스크립트처럼 localStorage + .moneyguider.co.kr 쿠키 — www ↔ apex 이동에도 유지) │ ② 로그인 회원이 확인되면 "이 회원이 이 클릭으로 들어왔다"를 POST /api/partner-touches 로 알린다 ▼ 신청: 신청 완료 위젯이 신청 기록에 클릭 ID 를 함께 보낸다 → 바로 귀속 구매: 구매 기록과 같은 주문 동기화(1시간마다)가 결제완료 상품을 귀속 (회원은 클릭 연결, 비회원은 주문완료 화면의 클릭 ID), 환불·취소는 집계에서 뺀다 ``` - **귀속 규칙**: 신청·구매 직전 30일 안에 누른 **그 상품의** 업체 링크 중 마지막 것 (마지막 클릭) - **구매 수**: 결제완료 주문만 센다. 전액 환불되거나 해당 상품 품목이 모두 취소·반품되면 빠진다. 금액은 관리하지 않는다 - **링크는 지우지 않고 중지**한다 — 중지해도 상품 페이지로 이동은 되지만 클릭·실적은 쌓이지 않는다. 링크가 있는 업체는 삭제할 수 없다 (실적 기록 보존) - 업체 링크로 온 방문은 UTM 스크립트가 업체 UTM 으로 기록하므로, 신청 때 유입·가입 경로 집계에도 업체 이름이 나온다. 먼저 광고 등으로 들어와 첫 경로가 남아 있어도 업체로 덮어쓴다 (Header Code v1.2) - 단, 관리자가 이동 주소에 직접 UTM 을 넣은 링크는 `utm_medium=partner` 가 아니므로 덮어쓰지 않는다 - 업체 실적은 **그 상품의** 링크만 세지만 유입은 상품을 가리지 않는다 — A 상품 업체 링크로 들어와 B 상품을 신청하면 신청 때 유입은 업체, 업체 칸은 비어 있다 - 이동 주소는 아임웹 사이트(www 유무 무관) 안만 허용한다 — 단축 링크가 피싱에 쓰이지 않게. 사이트 주소는 `IMWEB_SITE_URL`, 비우면 `IMWEB_ADMIN_PAGE_URL` 의 origin - 한계 - 링크를 누른 브라우저와 신청·구매한 브라우저가 다르면(휴대폰 → PC) 추적되지 않는다 - 비회원 주문은 주문완료 화면이 클릭 ID 를 보냈을 때만 센다 (위 '구매 때 유입') - 공개 페이지에서 오는 기록이라 위조를 완전히 막을 수는 없다 (실제 아임웹 회원·실제 클릭인지만 확인) - 60일보다 오래된 구매의 환불은 반영되지 않는다 ### 처음 켤 때 1. 아임웹 개발자센터 앱 권한에 `상품 읽기`·`주문 읽기` 추가 (위 '로컬에서 띄우기' 2번) 2. 백엔드 배포 (마이그레이션 자동 실행) 3. 관리자 화면 **연동 관리 → 아임웹 연결**을 다시 한다 — 새 권한이 담긴 토큰을 받는다. 다시 연결하기 전에는 상품 불러오기·구매 집계가 `IMWEB_006`(권한 부족)으로 실패한다. 회원 동기화는 그대로 돈다 4. `imweb-header-top.html` 을 아임웹 **Header Code** 에 다시 붙여 넣는다 (UTM 스크립트 아래에 업체 링크 추적이 추가됨). `imweb-header.html`(로그인 링크 UTM 보존)은 바꾸지 않았다 5. `imweb-free-apply.html` 을 신청 완료 위젯에 다시 붙여 넣는다 (신청 기록·클릭 ID 전송 추가됨) 6. 아임웹에 주소(URL)가 `go` 인 페이지를 만든다 — 메뉴에서 숨기고 전체 공개. 내용은 비워 둬도 된다 (업체에 주는 `www.moneyguider.co.kr/go?c=코드` 의 입구. 이 페이지가 없으면 링크가 404 가 된다) 7. 업체 링크 메뉴에서 **상품 불러오기** → 업체 추가 → 링크 발급 ## 업체 계정 (업체가 자기 실적을 직접 본다) 같은 관리자 페이지에 업체도 로그인한다. 역할에 따라 보이는 메뉴와 쓸 수 있는 API 가 다르다. | 역할 | 계정 | 볼 수 있는 것 | | --- | --- | --- | | 최고관리자 | env 의 `ADMIN_USERNAME`/`ADMIN_PASSWORD` (지금과 같다) | 모든 메뉴 + 업체 계정 관리 | | 업체 계정 | 최고관리자가 업체마다 만든다 (DB `admin_accounts`) | **내 업체 실적**만 — 자기 링크(나눠 줄 주소)·클릭·신청·구매 수, 신청·구매 목록 | - 만들기: **업체 링크** → 업체를 고르고 **계정 관리** → **+ 계정 만들기** → 아이디 입력 → 서버가 만든 **임시 비밀번호를 한 번만** 보여준다 (닫으면 다시 볼 수 없다). 로그인 주소·아이디와 함께 업체에 전달한다 - 업체는 첫 로그인 때 비밀번호를 직접 바꿔야 실적을 볼 수 있다 (바꾸기 전 업체 화면 API 는 `ACCOUNT_003`) - 비밀번호를 잊으면 최고관리자가 **비밀번호 초기화** → 새 임시 비밀번호. 열려 있던 화면은 바로 로그아웃된다 - 거래가 끝나면 **사용 스위치를 끈다** (중지) — 로그인할 수 없고 열려 있던 화면도 바로 끊긴다. 업체를 지우면 계정도 함께 지워진다 - 개인정보: 업체 화면의 신청·구매자는 **이름 일부만**(홍\*동) 보이고 연락처·이메일·아이디·주문번호는 내려주지 않는다 - 권한은 서버가 막는다 — 기존 관리 API 는 모두 최고관리자 전용(`AdminAuthGuard`, 업체 토큰은 `AUTH_003`), 업체 화면 API(`/api/partner-portal/*`)는 **로그인 정보의 업체 ID 로만** 거른다 (요청값으로 다른 업체를 볼 수 없다) - 아이디는 대소문자를 가리지 않는다. 최고관리자 아이디와 같은 아이디는 만들 수 없다 (`ACCOUNT_001`) - 아임웹 회원 계정으로 최고관리자를 판단하지 않는다 — 브라우저의 아임웹 회원 정보(`MEMBER_UID`)는 누구나 다른 값으로 보낼 수 있어 권한 판단에 쓸 수 없다. 페이지를 아임웹 회원 그룹으로 제한하는 것은 화면 단계의 추가 잠금으로만 쓴다 | API | 누가 | 용도 | | --- | --- | --- | | `GET/POST /api/admin/partners/{업체 ID}/accounts` | 최고관리자 | 업체 계정 목록·만들기 | | `PATCH /api/admin/partner-accounts/{ID}` | 최고관리자 | 사용/중지 | | `POST /api/admin/partner-accounts/{ID}/password-resets` | 최고관리자 | 비밀번호 초기화 | | `PUT /api/admin/accounts/current/password` | 업체 계정 본인 | 비밀번호 변경 (1분 5회) | | `GET /api/partner-portal/summary` · `links` · `conversions` | 업체 계정 | 내 업체 요약·링크·신청·구매 | ## 인증 방식 — 왜 쿠키가 아닌가 백엔드(`moneyguide.wandergo.net`)와 관리자 화면(`www.moneyguider.co.kr`)이 **다른 사이트**라 인증 쿠키는 제3자 쿠키가 되어 Safari·Chrome 시크릿 모드 등에서 차단된다. 그래서 - 로그인 응답으로 토큰을 받아 **sessionStorage** 에 두고 `Authorization: Bearer` 로 보낸다 (탭을 닫으면 사라짐) - 최고관리자 계정은 env 의 단일 계정이다. 여러 사람이 같은 계정으로 각자 로그인하고, 로그아웃은 그 기기만 끊는다. env 비밀번호를 바꾸고 재시작하면 모든 기기가 끊긴다 - 업체 계정(위 절)도 같은 방식이다. 비밀번호를 바꾸거나 초기화·중지하면 그 계정의 다른 기기가 끊긴다 - 액세스 1시간 / 리프레시 12시간 (`JWT_ACCESS_EXPIRES_IN` / `JWT_REFRESH_EXPIRES_IN`) - 서버가 요청마다 토큰 세대를 확인해 **로그아웃하면 발급된 토큰이 즉시 모두 무효**가 된다 로컬에서 같은 조건을 재현하려면 화면을 `http://127.0.0.1:5173/imweb-members.html` 로 연다 (API 는 `localhost:3100` — 브라우저는 둘을 다른 사이트로 본다). ## 연결이 안 될 때 아임웹 연결 버튼을 누른 뒤 아래 같은 JSON 화면이 뜨면 개발자센터의 redirect URI 가 다르다. ``` {"statusCode":400,"error":{"errorCode":"30098","message":"클라이언트 정보가 올바르지 않습니다.","innerMessage":"Invalid redirect uri"}} ``` 개발자센터 앱 설정의 redirect URI 를 `BACKEND_API_URL + /imweb/oauth/callback` 과 **글자 하나까지** 같게 등록한다. (로컬 `http://localhost:3100/api/imweb/oauth/callback`, 운영 `https://moneyguide.wandergo.net/api/imweb/oauth/callback`) ## 운영 배포 시 - 루트 `.env.production` - `BACKEND_API_URL=https://moneyguide.wandergo.net/api` - `CORS_ORIGIN=https://www.moneyguider.co.kr,https://moneyguider.co.kr` - `IMWEB_ADMIN_PAGE_URL=` 아임웹 관리자 페이지 실제 주소 - 아임웹 개발자센터 redirect URI 를 `https://moneyguide.wandergo.net/api/imweb/oauth/callback` 으로 등록 - `imweb-members.html` 의 `API_BASE` 운영 주소(`https://moneyguide.wandergo.net/api`)를 확인하고 아임웹 코드 위젯에 붙여 넣는다 - 아임웹에서 그 페이지의 접근 권한을 관리자로 제한한다 - 업체 링크를 쓰려면 위 '업체 링크 → 처음 켤 때' 순서를 따른다 (`imweb-header-top.html`·`imweb-free-apply.html` 의 `API_BASE` 도 같은 운영 주소) ## 유입 기록 상태 | 상태 | 의미 | | --- | --- | | 가입 시 (`at_join`) | 회원가입 폼에서 기록 — 가입 경로로 집계한다 | | 가입 후 (`after_join`) | 기록일(`jd`)이 가입일과 다름 — 회원정보 수정 화면에서 채워진 값이라 가입 경로가 아니다 | | 없음 (`none`) | 스크립트 설치 전 가입, 직접 입력한 홈페이지 주소 등 |