generated from ttipo/project-base
feat: 아임웹 관리자 대시보드 화면 추가
아임웹 관리자 전용 페이지의 코드 위젯에 붙여 쓰는 단일 HTML 화면이다. 개요·회원 목록·신청 내역·연동 관리 메뉴로 구성하고, 회원 그룹·신청 상품은 항상 펼쳐 둔 칩으로 거른다. 로컬 확인용 정적 서버와 설치·운영 안내를 함께 둔다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+116
@@ -0,0 +1,116 @@
|
||||
# 아임웹 회원 유입경로 관리자 화면
|
||||
|
||||
아임웹 관리자 전용 페이지에 붙여 넣는 화면(`imweb-members.html`)과 로컬 테스트 도구.
|
||||
|
||||
```
|
||||
아임웹 관리자 페이지 (코드 위젯) ← 1차 보호: 아임웹 페이지 접근 권한
|
||||
│ fetch + Authorization 헤더 (토큰은 sessionStorage)
|
||||
▼
|
||||
백엔드 /api/admin/* ← 2차 보호: 관리자 로그인 (Authorization: Bearer)
|
||||
│ 주기적 전체 동기화 (기본 60분)
|
||||
▼
|
||||
아임웹 Open API /member-info/members ← homePage 에 UTM 스크립트가 기록한 유입정보
|
||||
```
|
||||
|
||||
유입정보는 아임웹 Header Code 의 UTM 스크립트(`header-top.html`)가 회원가입 폼의
|
||||
'홈페이지' 항목에 `https://utm.moneyguider.co.kr/?src=..&med=..&jd=..` 형식으로 남긴 값이다.
|
||||
백엔드가 이 값을 풀어서 유입원·매체·캠페인별로 조회·집계한다.
|
||||
|
||||
## 로컬에서 띄우기
|
||||
|
||||
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)`
|
||||
(site-info:write 는 아임웹이 연동 때 필수로 요구한다 — 없으면 errorCode 30156)
|
||||
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` → <http://localhost:5173/imweb-members.html>
|
||||
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명)는 복구할 수 없다.
|
||||
|
||||
## 강의 신청 기록 (신청 완료 화면 → 신청 내역)
|
||||
|
||||
아임웹 `/apply` 페이지의 신청 완료 위젯(`money/product-free-complete.html`)이 표시될 때
|
||||
`POST /api/applications` 로 신청을 남긴다. 관리자 화면 **신청 내역** 메뉴에서 본다.
|
||||
|
||||
- 보내는 값: `MEMBER_UID`(회원 아이디 또는 회원 코드), 상품번호(`idx`), 상품명, 그 순간의 유입정보(`MG_UTM.peek()`)
|
||||
- 백엔드는 아임웹에 실제로 있는 회원인지 확인하고(동기화 전 신규 가입자는 아임웹 API 로 직접 확인),
|
||||
회원·상품당 한 건만 남긴다. 이름·연락처는 브라우저 값이 아니라 아임웹 회원 정보를 쓴다
|
||||
- 오픈카톡 버튼 클릭은 `POST /api/applications/{id}/kakao-clicks` 로 처음 한 번만 남는다
|
||||
- 공개 API 라 요청 수를 제한한다 (신청 1분 10회, 클릭 1분 20회 — IP 기준)
|
||||
- 운영 백엔드 CORS_ORIGIN 에 아임웹 도메인(www 유무 둘 다)이 있어야 한다
|
||||
- 한계: 신청 완료 화면은 공개 페이지라 "실제로 그 회원이 보냈는지"는 확인할 수 없다
|
||||
(있는 회원인지만 확인). 무료 신청 기록 용도라 이 수준으로 둔다
|
||||
|
||||
## 인증 방식 — 왜 쿠키가 아닌가
|
||||
|
||||
백엔드(`moneyguider.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://moneyguider.wandergo.net/api/imweb/oauth/callback`)
|
||||
|
||||
## 운영 배포 시
|
||||
|
||||
- 루트 `.env.production`
|
||||
- `BACKEND_API_URL=https://moneyguider.wandergo.net/api`
|
||||
- `CORS_ORIGIN=https://www.moneyguider.co.kr,https://moneyguider.co.kr`
|
||||
- `IMWEB_ADMIN_PAGE_URL=` 아임웹 관리자 페이지 실제 주소
|
||||
- 아임웹 개발자센터 redirect URI 를 `https://moneyguider.wandergo.net/api/imweb/oauth/callback` 으로 등록
|
||||
- `imweb-members.html` 의 `API_BASE` 운영 주소(`https://moneyguider.wandergo.net/api`)를 확인하고 아임웹 코드 위젯에 붙여 넣는다
|
||||
- 아임웹에서 그 페이지의 접근 권한을 관리자로 제한한다
|
||||
|
||||
## 유입 기록 상태
|
||||
|
||||
| 상태 | 의미 |
|
||||
| --- | --- |
|
||||
| 가입 시 (`at_join`) | 회원가입 폼에서 기록 — 가입 경로로 집계한다 |
|
||||
| 가입 후 (`after_join`) | 기록일(`jd`)이 가입일과 다름 — 회원정보 수정 화면에서 채워진 값이라 가입 경로가 아니다 |
|
||||
| 없음 (`none`) | 스크립트 설치 전 가입, 직접 입력한 홈페이지 주소 등 |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 로컬 테스트용 정적 서버 — imweb-members.html 을 아임웹 페이지처럼 감싸서 띄운다.
|
||||
*
|
||||
* node imweb/serve-local.js → http://localhost:5173/imweb-members.html
|
||||
*
|
||||
* 포트 5173 은 백엔드 CORS 기본 허용 주소(FRONTEND_URL)와 루트 env 의
|
||||
* IMWEB_ADMIN_PAGE_URL 에 맞춘 것이다. 바꾸면 두 곳도 함께 바꿔야 한다.
|
||||
*/
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PORT = 5173;
|
||||
const PAGE = 'imweb-members.html';
|
||||
|
||||
http
|
||||
.createServer((req, res) => {
|
||||
const name = path.basename(new URL(req.url, 'http://localhost').pathname) || PAGE;
|
||||
if (name !== PAGE) {
|
||||
res.writeHead(302, { Location: `/${PAGE}` });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
fs.readFile(path.join(__dirname, PAGE), 'utf8', (err, html) => {
|
||||
if (err) {
|
||||
res.writeHead(500);
|
||||
res.end(String(err));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(
|
||||
'<!doctype html><html lang="ko"><head><meta charset="utf-8">' +
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
|
||||
'<title>회원 유입경로 (로컬)</title></head>' +
|
||||
`<body style="margin:0;padding:24px 0;background:#fff">${html}</body></html>`,
|
||||
);
|
||||
});
|
||||
})
|
||||
.listen(PORT, '127.0.0.1', () => {
|
||||
process.stdout.write(`http://localhost:${PORT}/${PAGE}\n`);
|
||||
});
|
||||
Reference in New Issue
Block a user