feat(backend): 아임웹 회원 유입경로·신청 기록 관리자 API 추가

아임웹 관리자 페이지에서 회원을 유입경로·그룹 기준으로 보고, 무료 강의 신청을
기록·조회하기 위한 백엔드다. 관리자 화면(아임웹)과 API(wandergo.net)가 다른 사이트라
인증은 쿠키 대신 Bearer 토큰으로 한다.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 16:21:25 +09:00
parent 4be82a716b
commit c54df9cefa
84 changed files with 6612 additions and 132 deletions
+111
View File
@@ -0,0 +1,111 @@
/**
* 아임웹 회원 엑셀의 '유입경로' 가져오기.
*
* '유입경로'(사용자 정의 가입 항목)는 아임웹 Open API 로 나오지 않는다.
* 가입 폼 '홈페이지' 항목에 유입정보를 쓰기 전에 가입한 회원은 이 엑셀로만 유입경로를 알 수 있다.
*
* 개인정보 파일이므로 저장소 폴더에 두지 말고 컨테이너 임시 경로로 복사해 실행한 뒤 지운다.
*
* docker cp "회원2026_10_05_1.xls" moneyguide-backend:/tmp/members-1.xls
* docker exec moneyguide-backend npm run inflow:import -- /tmp/members-1.xls [/tmp/members-2.xls ...]
* docker exec -u root moneyguide-backend rm /tmp/members-1.xls (docker cp 로 넣은 파일은 root 소유)
*
* Git Bash 에서는 /tmp 경로가 Windows 경로로 바뀌므로 앞에 MSYS_NO_PATHCONV=1 을 붙인다.
*
* 같은 고유키는 덮어쓰므로 여러 번 실행해도 안전하다. 출력에는 개수만 나온다 (개인정보 없음).
*/
import 'reflect-metadata';
import { basename } from 'path';
import { readSheet } from 'read-excel-file/node';
import { DataSource } from 'typeorm';
import cliDataSource from '../src/data-source';
import { DEFAULT_TRACKING_HOST } from '../src/modules/imweb/imweb.config';
import {
ImportRow,
applyInflowImport,
extractImportRows,
} from '../src/modules/imweb/inflow-import';
async function main(): Promise<void> {
const files = process.argv.slice(2);
if (files.length === 0) {
console.error(
'사용법: npm run inflow:import -- <엑셀 경로> [엑셀 경로 ...]',
);
process.exit(1);
}
// 여러 파일에 같은 회원이 있으면 뒤 파일의 값을 쓴다
const merged = new Map<string, ImportRow & { file: string }>();
for (const file of files) {
const sheet = await readSheet(file);
const { rows, skippedEmpty, duplicates } = extractImportRows(sheet);
console.log(
`${basename(file)}: 유입경로 ${rows.length}행 (빈 값 건너뜀 ${skippedEmpty}, 파일 안 중복 ${duplicates})`,
);
for (const row of rows)
merged.set(row.memberCode, { ...row, file: basename(file) });
}
// 개발 환경의 쿼리 로그가 켜져 있으면 유입경로 값이 화면에 찍히므로 끈다
const dataSource = new DataSource({
...cliDataSource.options,
logging: false,
});
await dataSource.initialize();
try {
const trackingHost =
process.env.IMWEB_TRACKING_HOST?.trim() || DEFAULT_TRACKING_HOST;
const byFile = new Map<string, ImportRow[]>();
for (const { file, ...row } of merged.values()) {
byFile.set(file, [...(byFile.get(file) ?? []), row]);
}
const totals = {
stored: 0,
matched: 0,
appliedFromImport: 0,
atJoin: 0,
afterJoin: 0,
unparsable: 0,
};
await dataSource.transaction(async (manager) => {
for (const [file, rows] of byFile) {
const result = await applyInflowImport(
manager,
rows,
file,
trackingHost,
);
for (const key of Object.keys(totals) as (keyof typeof totals)[]) {
totals[key] += result[key];
}
}
});
console.log('');
console.log(`보관: ${totals.stored}명`);
console.log(
`DB 회원과 매칭: ${totals.matched}명 (매칭 안 된 ${totals.stored - totals.matched}명은 동기화되면 자동 반영)`,
);
console.log(
`엑셀 값 적용: ${totals.appliedFromImport}명 — 가입 때 기록 ${totals.atJoin} / 가입 후 기록 ${totals.afterJoin}`,
);
if (totals.unparsable > 0) {
console.log(
`스크립트 형식이 아니라 풀지 못한 값: ${totals.unparsable}명`,
);
}
} finally {
await dataSource.destroy();
}
}
main().catch((error: unknown) => {
console.error(
'유입경로 가져오기 실패:',
error instanceof Error ? error.message : error,
);
process.exit(1);
});