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
+8
View File
@@ -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,
@@ -0,0 +1,17 @@
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import type { AdminRequest } from '@/common/guards/admin-auth.guard';
import type { AdminResponseDto } from '@/modules/admin-auth/dto/admin-response.dto';
/**
* AdminAuthGuard 가 검증한 관리자를 핸들러 인자로 받는다.
* 가드 없이 쓰면 관리자 정보가 없으므로 AUTH_001 로 막는다.
*/
export const CurrentAdmin = createParamDecorator(
(_data: unknown, context: ExecutionContext): AdminResponseDto => {
const request = context.switchToHttp().getRequest<AdminRequest>();
if (!request.admin) throw new BusinessException('AUTH_001');
return request.admin;
},
);
+30
View File
@@ -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<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
@@ -0,0 +1,31 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import type { Request } from 'express';
import { AdminAuthService } from '@/modules/admin-auth/admin-auth.service';
import { AdminResponseDto } from '@/modules/admin-auth/dto/admin-response.dto';
import { readBearerToken } from '@/shared/utils/bearer';
/** 가드가 검증한 관리자를 요청 객체에 실어 둔다 */
export interface AdminRequest extends Request {
admin?: AdminResponseDto;
/** 이 요청의 로그인 세션 — 로그아웃할 때 이 세션만 끊는다 */
adminSessionId?: string;
}
/**
* 관리자 전용 라우트 보호 — `Authorization: Bearer` 의 액세스 토큰을 검증한다.
* 실패하면 AUTH_001(401). 화면은 401 을 받으면 리프레시 토큰으로 갱신을 한 번 시도한다.
*/
@Injectable()
export class AdminAuthGuard implements CanActivate {
constructor(private readonly auth: AdminAuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AdminRequest>();
const token = readBearerToken(request.headers.authorization);
const verified = await this.auth.verifyAccessToken(token);
request.admin = verified.admin;
request.adminSessionId = verified.sessionId;
return true;
}
}
+8 -4
View File
@@ -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 {
@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAdminAndImwebMembers1791171834616 implements MigrationInterface {
name = 'AddAdminAndImwebMembers1791171834616';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "admin_users" ("id" SERIAL NOT NULL, "username" character varying(50) NOT NULL, "password_hash" character varying(100) NOT NULL, "token_version" integer NOT NULL DEFAULT '0', "last_login_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_2873882c38e8c07d98cb64f962d" UNIQUE ("username"), CONSTRAINT "PK_06744d221bb6145dc61e5dc441d" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "imweb_connections" ("site_code" character varying(40) NOT NULL, "unit_code" character varying(40) NOT NULL, "access_token" text NOT NULL, "refresh_token" text NOT NULL, "access_token_expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "scope" character varying(255), "connected_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_578dd7ff2be366a4480f4f49c6d" PRIMARY KEY ("site_code"))`,
);
await queryRunner.query(
`CREATE TABLE "imweb_members" ("member_code" character varying(40) NOT NULL, "uid" character varying(255) NOT NULL, "name" character varying(100) NOT NULL, "email" character varying(255), "callnum" character varying(30), "social_provider" character varying(20), "join_time" TIMESTAMP WITH TIME ZONE NOT NULL, "last_login_time" TIMESTAMP WITH TIME ZONE, "home_page" text, "inflow_status" character varying(20) NOT NULL, "inflow_source" character varying(100), "inflow_medium" character varying(100), "inflow_campaign" character varying(100), "inflow_content" character varying(100), "inflow_term" character varying(100), "inflow_click_id" character varying(100), "inflow_referrer" character varying(100), "inflow_landing_path" character varying(100), "inflow_product_id" character varying(100), "inflow_first_touch_date" date, "inflow_last_touch" character varying(100), "inflow_recorded_date" date, "last_sync_run_id" integer NOT NULL, "synced_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_38dda90f47d9e1f1fc4ba86273e" PRIMARY KEY ("member_code"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_c2a5af1f585eb1cf214864511b" ON "imweb_members" ("inflow_status") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_612f4cfc08bd49160be3d26571" ON "imweb_members" ("inflow_source") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_b5527a840c8f41ee25858e3cc9" ON "imweb_members" ("inflow_medium") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_5e4276d07745c2ce06b6828eb0" ON "imweb_members" ("inflow_campaign") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_bc68104ee1dcd0fee8f4bf093e" ON "imweb_members" ("last_sync_run_id") `,
);
await queryRunner.query(
`CREATE INDEX "idx_imweb_members_join_cursor" ON "imweb_members" ("join_time", "member_code") `,
);
await queryRunner.query(
`CREATE TABLE "imweb_sync_runs" ("id" SERIAL NOT NULL, "trigger" character varying(20) NOT NULL, "status" character varying(20) NOT NULL, "started_at" TIMESTAMP WITH TIME ZONE NOT NULL, "finished_at" TIMESTAMP WITH TIME ZONE, "fetched_count" integer NOT NULL DEFAULT '0', "removed_count" integer NOT NULL DEFAULT '0', "error_code" character varying(20), CONSTRAINT "PK_8dc1a26be110a57fa2eb0e7b4d1" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_881a04460c9d208e67319905b0" ON "imweb_sync_runs" ("started_at") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."IDX_881a04460c9d208e67319905b0"`,
);
await queryRunner.query(`DROP TABLE "imweb_sync_runs"`);
await queryRunner.query(
`DROP INDEX "public"."idx_imweb_members_join_cursor"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_bc68104ee1dcd0fee8f4bf093e"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_5e4276d07745c2ce06b6828eb0"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_b5527a840c8f41ee25858e3cc9"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_612f4cfc08bd49160be3d26571"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_c2a5af1f585eb1cf214864511b"`,
);
await queryRunner.query(`DROP TABLE "imweb_members"`);
await queryRunner.query(`DROP TABLE "imweb_connections"`);
await queryRunner.query(`DROP TABLE "admin_users"`);
}
}
@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddInflowImport1791176284930 implements MigrationInterface {
name = 'AddInflowImport1791176284930';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "imweb_member_inflow_imports" ("member_code" character varying(40) NOT NULL, "raw_value" text NOT NULL, "source_file" character varying(255) NOT NULL, "imported_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_65c32a8c14f337b52379daf8d0c" PRIMARY KEY ("member_code"))`,
);
await queryRunner.query(
`ALTER TABLE "imweb_members" ADD "inflow_origin" character varying(20)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "imweb_members" DROP COLUMN "inflow_origin"`,
);
await queryRunner.query(`DROP TABLE "imweb_member_inflow_imports"`);
}
}
@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAdminSessions1791179523253 implements MigrationInterface {
name = 'AddAdminSessions1791179523253';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "admin_sessions" ("id" uuid NOT NULL, "admin_id" integer NOT NULL, "user_agent" character varying(200), "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "last_used_at" TIMESTAMP WITH TIME ZONE NOT NULL, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "revoked_at" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_38bb553c2372215d48de2306c5e" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_52683d67b3d122a8867d745beb" ON "admin_sessions" ("admin_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_ce73d53a035a036b02fc45d3f9" ON "admin_sessions" ("expires_at") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."IDX_ce73d53a035a036b02fc45d3f9"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_52683d67b3d122a8867d745beb"`,
);
await queryRunner.query(`DROP TABLE "admin_sessions"`);
}
}
@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 관리자 계정을 DB(admin_users)에서 env 단일 계정(ADMIN_USERNAME / ADMIN_PASSWORD)으로 옮긴다.
* - 기존 세션은 예전 형식 토큰과 묶여 있어 어차피 쓸 수 없으므로 비운다 (다시 로그인하면 된다)
* - admin_users 는 엔티티를 지워도 TypeORM 이 자동으로 지우지 않아 직접 삭제한다
*/
export class UseEnvAdminAccount1791180231324 implements MigrationInterface {
name = 'UseEnvAdminAccount1791180231324';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM "admin_sessions"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_52683d67b3d122a8867d745beb"`,
);
await queryRunner.query(
`ALTER TABLE "admin_sessions" DROP COLUMN "admin_id"`,
);
await queryRunner.query(`DROP TABLE "admin_users"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "admin_users" ("id" SERIAL NOT NULL, "username" character varying(50) NOT NULL, "password_hash" character varying(100) NOT NULL, "token_version" integer NOT NULL DEFAULT '0', "last_login_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_2873882c38e8c07d98cb64f962d" UNIQUE ("username"), CONSTRAINT "PK_06744d221bb6145dc61e5dc441d" PRIMARY KEY ("id"))`,
);
// NOT NULL 컬럼을 다시 넣으려면 세션이 비어 있어야 한다
await queryRunner.query(`DELETE FROM "admin_sessions"`);
await queryRunner.query(
`ALTER TABLE "admin_sessions" ADD "admin_id" integer NOT NULL`,
);
await queryRunner.query(
`CREATE INDEX "IDX_52683d67b3d122a8867d745beb" ON "admin_sessions" ("admin_id") `,
);
}
}
@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddImwebGroups1791181662391 implements MigrationInterface {
name = 'AddImwebGroups1791181662391';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "imweb_groups" ("group_code" character varying(40) NOT NULL, "title" character varying(200) NOT NULL, "sort_order" integer NOT NULL, "synced_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_eabbfeebf4468c2087710c95623" PRIMARY KEY ("group_code"))`,
);
await queryRunner.query(
`ALTER TABLE "imweb_members" ADD "group_codes" text array NOT NULL DEFAULT '{}'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "imweb_members" DROP COLUMN "group_codes"`,
);
await queryRunner.query(`DROP TABLE "imweb_groups"`);
}
}
@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMemberApplications1791184024124 implements MigrationInterface {
name = 'AddMemberApplications1791184024124';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "member_applications" ("id" uuid NOT NULL, "member_code" character varying(40) NOT NULL, "member_uid" character varying(255) NOT NULL, "product_idx" character varying(20) NOT NULL, "product_name" character varying(200), "applied_at" TIMESTAMP WITH TIME ZONE NOT NULL, "kakao_clicked_at" TIMESTAMP WITH TIME ZONE, "inflow_raw" text, "inflow_source" character varying(100), "inflow_medium" character varying(100), "inflow_campaign" character varying(100), "inflow_content" character varying(100), CONSTRAINT "PK_9d4ff55268968b0cdcd613500d4" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_76d5f4037b504da8bf297a0de4" ON "member_applications" ("product_idx") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_4cac90f709d2816667c7c700ce" ON "member_applications" ("inflow_source") `,
);
await queryRunner.query(
`CREATE INDEX "idx_member_applications_cursor" ON "member_applications" ("applied_at", "id") `,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "uq_member_applications_member_product" ON "member_applications" ("member_code", "product_idx") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."uq_member_applications_member_product"`,
);
await queryRunner.query(
`DROP INDEX "public"."idx_member_applications_cursor"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_4cac90f709d2816667c7c700ce"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_76d5f4037b504da8bf297a0de4"`,
);
await queryRunner.query(`DROP TABLE "member_applications"`);
}
}
@@ -0,0 +1,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,
);
}
@@ -0,0 +1,95 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
HttpStatus,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentAdmin } from '@/common/decorators/current-admin.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
import type { AdminRequest } from '@/common/guards/admin-auth.guard';
import { BusinessException } from '@/common/errors/business.exception';
import { ADMIN_BEARER_AUTH, LOGIN_THROTTLE } from './admin-auth.constants';
import { AdminAuthService } from './admin-auth.service';
import { AdminResponseDto } from './dto/admin-response.dto';
import { AdminSessionDto, RenewSessionDto } from './dto/admin-session.dto';
import { LoginDto } from './dto/login.dto';
/**
* 관리자 세션 — 로그인·갱신·로그아웃.
* 관리자 화면과 API 가 다른 사이트라 쿠키를 쓸 수 없어 토큰을 본문으로 내려준다
* (이유와 보완책은 admin-auth.constants.ts 참조).
*/
@ApiTags('Admin Session')
@Controller('admin/sessions')
export class AdminAuthController {
constructor(private readonly auth: AdminAuthService) {}
@Post()
@Throttle(LOGIN_THROTTLE)
@ApiOperation({ summary: '관리자 로그인 — 액세스·리프레시 토큰 발급' })
@ApiStandardResponse(AdminSessionDto, {
status: 201,
description: '로그인 성공',
})
@ApiErrorResponse('AUTH_001', '아이디 또는 비밀번호 불일치')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
@ApiErrorResponse('REQ_001', '로그인 시도 횟수 초과 (1분 5회)')
login(
@Body() dto: LoginDto,
@Headers('user-agent') userAgent?: string,
): Promise<AdminSessionDto> {
return this.auth.login(dto.username, dto.password, userAgent);
}
@Post('renewal')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '세션 갱신 — 리프레시 토큰으로 새 토큰 발급' })
@ApiStandardResponse(AdminSessionDto, { description: '갱신 성공' })
@ApiErrorResponse('AUTH_001', '리프레시 토큰 만료·무효·로그아웃됨')
@ApiErrorResponse('VAL_001', '리프레시 토큰 누락')
renew(@Body() dto: RenewSessionDto): Promise<AdminSessionDto> {
return this.auth.renew(dto.refreshToken);
}
@Get('current')
@UseGuards(AdminAuthGuard)
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@ApiOperation({ summary: '현재 로그인한 관리자' })
@ApiStandardResponse(AdminResponseDto, { description: '조회 성공' })
@ApiErrorResponse('AUTH_001', '로그인 필요')
current(@CurrentAdmin() admin: AdminResponseDto): AdminResponseDto {
return admin;
}
@Delete()
@UseGuards(AdminAuthGuard)
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform()
@ApiOperation({
summary:
'로그아웃 — 이 기기의 세션만 끊는다 (같은 계정의 다른 기기는 유지)',
})
@ApiErrorResponse(
'AUTH_001',
'이미 만료된 세션 (화면에서 토큰만 지우면 된다)',
)
async logout(@Req() req: AdminRequest): Promise<void> {
if (!req.adminSessionId) throw new BusinessException('AUTH_001');
await this.auth.logout(req.adminSessionId);
}
}
@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
import { AdminAuthController } from './admin-auth.controller';
import { AdminAuthService } from './admin-auth.service';
import { AdminSession } from './admin-session.entity';
@Module({
imports: [
TypeOrmModule.forFeature([AdminSession]),
JwtModule.registerAsync({
inject: [ConfigService],
// 운영에서는 env 검증이 JWT_SECRET 을 필수로 요구한다.
// 개발에서 비어 있으면 서명 시점에 실패(SYS_001)하므로 backend/.env.development 에 넣을 것.
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET'),
}),
}),
],
controllers: [AdminAuthController],
providers: [AdminAuthService, AdminAuthGuard],
// 다른 모듈의 컨트롤러가 @UseGuards(AdminAuthGuard) 를 쓰려면 서비스와 가드가 필요하다
exports: [AdminAuthService, AdminAuthGuard],
})
export class AdminAuthModule {}
@@ -0,0 +1,208 @@
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { randomUUID } from 'crypto';
import type { Repository } from 'typeorm';
import { AdminAuthService } from './admin-auth.service';
import { AdminSession } from './admin-session.entity';
const USERNAME = 'moneyguider';
const PASSWORD = 'correct-horse-battery';
const SECRET = 'test-secret';
function setup(env: Record<string, string> = {}) {
// env 는 테스트 중에 바꿀 수 있게 객체로 둔다 (비밀번호 변경 시나리오)
const values: Record<string, string> = {
ADMIN_USERNAME: USERNAME,
ADMIN_PASSWORD: PASSWORD,
JWT_SECRET: SECRET,
...env,
};
const config = { get: (key: string) => values[key] };
// 세션 저장소 — 메모리
const store = new Map<string, AdminSession>();
const sessions = {
create: jest.fn((row: Partial<AdminSession>) => row),
save: jest.fn((row: AdminSession) => {
row.createdAt ??= new Date();
store.set(row.id, row);
return Promise.resolve(row);
}),
findOne: jest.fn(({ where }: { where: { id: string } }) =>
Promise.resolve(store.get(where.id) ?? null),
),
update: jest.fn(({ id }: { id: string }, patch: Partial<AdminSession>) => {
const row = store.get(id);
if (row) Object.assign(row, patch);
return Promise.resolve();
}),
delete: jest.fn(() => Promise.resolve({ affected: 0 })),
};
const service = new AdminAuthService(
sessions as unknown as Repository<AdminSession>,
new JwtService({ secret: SECRET }),
config as unknown as ConfigService,
);
return { service, store, values };
}
describe('AdminAuthService (env 단일 계정)', () => {
it('env 계정으로 로그인하면 세션을 만들고 토큰·만료 시각을 돌려준다', async () => {
const { service, store } = setup();
const session = await service.login(USERNAME, PASSWORD, 'Mozilla/5.0');
expect(session.admin).toEqual({ username: USERNAME });
expect(Date.parse(session.refreshTokenExpiresAt)).toBeGreaterThan(
Date.parse(session.accessTokenExpiresAt),
);
expect(store.size).toBe(1);
expect([...store.values()][0].userAgent).toBe('Mozilla/5.0');
const verified = await service.verifyAccessToken(session.accessToken);
expect(verified).toEqual({
admin: { username: USERNAME },
sessionId: [...store.keys()][0],
});
});
it('토큰에 비밀번호가 들어가지 않는다', async () => {
const { service } = setup();
const session = await service.login(USERNAME, PASSWORD);
const payload = Buffer.from(
session.accessToken.split('.')[1],
'base64url',
).toString('utf8');
expect(payload).not.toContain(PASSWORD);
});
it('아이디나 비밀번호가 틀리면 같은 AUTH_001, 세션을 만들지 않는다', async () => {
const { service, store } = setup();
await expect(service.login(USERNAME, 'wrong')).rejects.toMatchObject({
code: 'AUTH_001',
});
await expect(service.login('nobody', PASSWORD)).rejects.toMatchObject({
code: 'AUTH_001',
});
expect(store.size).toBe(0);
});
it('env 에 계정이 없으면 아무도 로그인할 수 없다 (빈 값으로도 안 된다)', async () => {
const { service } = setup({ ADMIN_USERNAME: '', ADMIN_PASSWORD: '' });
await expect(service.login('', '')).rejects.toMatchObject({
code: 'AUTH_001',
});
});
it('한 계정으로 여러 기기에서 로그인하고, 한 기기의 로그아웃은 그 기기만 끊는다', async () => {
const { service } = setup();
const deviceA = await service.login(USERNAME, PASSWORD);
const deviceB = await service.login(USERNAME, PASSWORD);
const a = await service.verifyAccessToken(deviceA.accessToken);
await service.logout(a.sessionId);
await expect(
service.verifyAccessToken(deviceA.accessToken),
).rejects.toMatchObject({ code: 'AUTH_001' });
await expect(service.renew(deviceA.refreshToken)).rejects.toMatchObject({
code: 'AUTH_001',
});
await expect(
service.verifyAccessToken(deviceB.accessToken),
).resolves.toMatchObject({ admin: { username: USERNAME } });
await expect(service.renew(deviceB.refreshToken)).resolves.toBeDefined();
});
it('env 비밀번호를 바꾸면 모든 기기가 끊기고, 새 비밀번호로만 로그인된다', async () => {
const { service, values } = setup();
const deviceA = await service.login(USERNAME, PASSWORD);
const deviceB = await service.login(USERNAME, PASSWORD);
values.ADMIN_PASSWORD = 'a-brand-new-password'; // env 변경 후 재시작한 상황
for (const device of [deviceA, deviceB]) {
await expect(
service.verifyAccessToken(device.accessToken),
).rejects.toMatchObject({ code: 'AUTH_001' });
await expect(service.renew(device.refreshToken)).rejects.toMatchObject({
code: 'AUTH_001',
});
}
await expect(service.login(USERNAME, PASSWORD)).rejects.toMatchObject({
code: 'AUTH_001',
});
await expect(
service.login(USERNAME, 'a-brand-new-password'),
).resolves.toBeDefined();
});
it('리프레시 토큰으로 같은 세션의 새 토큰을 받고 세션 만료가 늦춰진다', async () => {
const { service, store } = setup();
const session = await service.login(USERNAME, PASSWORD);
const row = [...store.values()][0];
row.expiresAt = new Date(Date.now() + 1000); // 곧 만료될 세션
const renewed = await service.renew(session.refreshToken);
const verified = await service.verifyAccessToken(renewed.accessToken);
expect(verified.sessionId).toBe(row.id);
expect(store.size).toBe(1);
expect(row.expiresAt.getTime()).toBeGreaterThan(Date.now() + 60_000);
});
it('만료된 세션의 토큰은 거부한다', async () => {
const { service, store } = setup();
const session = await service.login(USERNAME, PASSWORD);
[...store.values()][0].expiresAt = new Date(Date.now() - 1);
await expect(
service.verifyAccessToken(session.accessToken),
).rejects.toMatchObject({ code: 'AUTH_001' });
});
it('액세스·리프레시 토큰을 서로의 자리에 쓸 수 없다', async () => {
const { service } = setup();
const session = await service.login(USERNAME, PASSWORD);
await expect(
service.verifyAccessToken(session.refreshToken),
).rejects.toMatchObject({ code: 'AUTH_001' });
await expect(service.renew(session.accessToken)).rejects.toMatchObject({
code: 'AUTH_001',
});
});
it('위조됐거나 이전 형식(계정 지문 없음)의 토큰은 거부한다', async () => {
const { service } = setup();
await expect(service.verifyAccessToken(null)).rejects.toMatchObject({
code: 'AUTH_001',
});
const forged = new JwtService({ secret: 'other-secret' }).sign({
sub: USERNAME,
typ: 'access',
cred: 'x',
sid: randomUUID(),
});
await expect(service.verifyAccessToken(forged)).rejects.toMatchObject({
code: 'AUTH_001',
});
// DB 계정 시절 형식 (sub 가 숫자, cred 없음)
const legacy = new JwtService({ secret: SECRET }).sign({
sub: 1,
username: USERNAME,
typ: 'access',
ver: 0,
sid: randomUUID(),
});
await expect(service.verifyAccessToken(legacy)).rejects.toMatchObject({
code: 'AUTH_001',
});
});
});
@@ -0,0 +1,225 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { LessThan, Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { credentialFingerprint, safeEqual } from '@/shared/utils/credential';
import {
ADMIN_PASSWORD_KEY,
ADMIN_USERNAME_KEY,
AdminTokenPayload,
AdminTokenType,
accessTokenTtlMs,
refreshTokenTtlMs,
} from './admin-auth.constants';
import { AdminSession } from './admin-session.entity';
import { AdminResponseDto } from './dto/admin-response.dto';
import { AdminSessionDto } from './dto/admin-session.dto';
/** User-Agent 저장 길이 (엔티티 컬럼과 맞춘다) */
const USER_AGENT_MAX_LENGTH = 200;
/** 끝난 세션(만료·로그아웃)을 이 기간이 지나면 지운다 — 최근 기록은 확인용으로 잠시 남긴다 */
const ENDED_SESSION_RETENTION_MS = 7 * 86_400_000;
/** 가드가 확인한 요청의 주인 */
export interface VerifiedAdmin {
admin: AdminResponseDto;
sessionId: string;
}
interface AdminAccount {
username: string;
password: string;
/** 현재 env 계정의 지문 — 토큰의 cred 와 같아야 유효하다 */
fingerprint: string;
}
/**
* 관리자 인증 — env(ADMIN_USERNAME / ADMIN_PASSWORD)에 둔 단일 계정.
*
* 계정 하나를 여러 사람이 함께 쓰는 것을 전제로, 로그인한 기기마다 세션을 따로 둔다.
* - 로그아웃: 그 기기의 세션만 끊는다
* - env 비밀번호를 바꾸고 재시작: 계정 지문이 달라져 모든 기기가 끊긴다
*/
@Injectable()
export class AdminAuthService {
private readonly logger = new Logger(AdminAuthService.name);
constructor(
@InjectRepository(AdminSession)
private readonly sessions: Repository<AdminSession>,
private readonly jwt: JwtService,
private readonly config: ConfigService,
) {}
/** 아이디·비밀번호를 env 계정과 비교하고 새 세션을 연다. 실패 원인은 구분하지 않는다. */
async login(
username: string,
password: string,
userAgent?: string,
): Promise<AdminSessionDto> {
const account = this.account();
// 둘 다 항상 비교해 어느 쪽이 틀렸는지 응답 시간으로 드러나지 않게 한다
const usernameMatched = safeEqual(username, account?.username ?? '');
const passwordMatched = safeEqual(password, account?.password ?? '');
if (!account || !usernameMatched || !passwordMatched) {
throw new BusinessException('AUTH_001', {
context: {
reason: account ? 'invalid_credentials' : 'admin_not_configured',
},
});
}
await this.removeEndedSessions();
const now = Date.now();
const session = await this.sessions.save(
this.sessions.create({
id: randomUUID(),
userAgent: userAgent?.slice(0, USER_AGENT_MAX_LENGTH) || null,
lastUsedAt: new Date(now),
expiresAt: new Date(now + refreshTokenTtlMs()),
revokedAt: null,
}),
);
this.logger.log(`관리자 로그인: session=${session.id}`);
return this.issue(account, session.id, now);
}
/** 리프레시 토큰으로 같은 세션의 새 토큰 쌍을 발급하고 세션 만료를 늦춘다. */
async renew(refreshToken: string): Promise<AdminSessionDto> {
const payload = await this.verify(refreshToken, 'refresh');
const { account, session } = await this.findActive(payload);
const now = Date.now();
session.lastUsedAt = new Date(now);
session.expiresAt = new Date(now + refreshTokenTtlMs());
await this.sessions.save(session);
return this.issue(account, session.id, now);
}
/** 로그아웃 — 이 세션만 끊는다. 같은 계정으로 로그인한 다른 기기는 그대로다. */
async logout(sessionId: string): Promise<void> {
await this.sessions.update({ id: sessionId }, { revokedAt: new Date() });
this.logger.log(`관리자 로그아웃: session=${sessionId}`);
}
/**
* 액세스 토큰을 검증한다 — 가드에서 쓴다.
* 서명·만료뿐 아니라 세션과 계정 지문도 확인해, 로그아웃·비밀번호 변경이 바로 반영된다.
* (토큰이 페이지 스크립트에 노출되는 Bearer 방식이라 즉시 무효화가 필요하다)
*/
async verifyAccessToken(token: string | null): Promise<VerifiedAdmin> {
const payload = await this.verify(token, 'access');
const { account, session } = await this.findActive(payload);
return { admin: { username: account.username }, sessionId: session.id };
}
/** env 계정. 아이디·비밀번호가 비어 있으면 null — 아무도 로그인할 수 없다. */
private account(): AdminAccount | null {
const username = (this.config.get<string>(ADMIN_USERNAME_KEY) ?? '').trim();
const password = this.config.get<string>(ADMIN_PASSWORD_KEY) ?? '';
const secret = this.config.get<string>('JWT_SECRET') ?? '';
if (!username || !password || !secret) {
this.logger.warn(
`관리자 계정이 설정되지 않았습니다 — backend/.env 의 ${ADMIN_USERNAME_KEY}·${ADMIN_PASSWORD_KEY}·JWT_SECRET 을 확인하세요`,
);
return null;
}
return {
username,
password,
fingerprint: credentialFingerprint(username, password, secret),
};
}
/** 토큰이 가리키는 세션과 계정이 아직 유효한지 확인한다. */
private async findActive(
payload: AdminTokenPayload,
): Promise<{ account: AdminAccount; session: AdminSession }> {
const account = this.account();
// env 아이디·비밀번호가 바뀌었으면 이전에 발급한 모든 토큰이 무효다
if (
!account ||
typeof payload.cred !== 'string' ||
payload.sub !== account.username ||
!safeEqual(payload.cred, account.fingerprint)
) {
throw this.rejected('credential_changed');
}
if (!payload.sid) throw this.rejected('no_session');
const session = await this.sessions.findOne({ where: { id: payload.sid } });
if (!session) throw this.rejected('no_session');
if (session.revokedAt) throw this.rejected('logged_out');
if (session.expiresAt.getTime() <= Date.now()) {
throw this.rejected('session_expired');
}
return { account, session };
}
private rejected(reason: string): BusinessException {
return new BusinessException('AUTH_001', { context: { reason } });
}
/** 끝난 세션 중 보관 기간이 지난 것을 지운다 (로그인 때마다 가볍게 정리) */
private async removeEndedSessions(): Promise<void> {
const cutoff = new Date(Date.now() - ENDED_SESSION_RETENTION_MS);
await this.sessions.delete({ expiresAt: LessThan(cutoff) });
await this.sessions.delete({ revokedAt: LessThan(cutoff) });
}
private async issue(
account: AdminAccount,
sessionId: string,
now: number,
): Promise<AdminSessionDto> {
const base = {
sub: account.username,
cred: account.fingerprint,
sid: sessionId,
};
const accessTtlMs = accessTokenTtlMs();
const refreshTtlMs = refreshTokenTtlMs();
const [accessToken, refreshToken] = await Promise.all([
this.jwt.signAsync(
{ ...base, typ: 'access' } satisfies AdminTokenPayload,
{ expiresIn: Math.floor(accessTtlMs / 1000) },
),
this.jwt.signAsync(
{ ...base, typ: 'refresh' } satisfies AdminTokenPayload,
{ expiresIn: Math.floor(refreshTtlMs / 1000) },
),
]);
return {
admin: { username: account.username },
accessToken,
accessTokenExpiresAt: new Date(now + accessTtlMs).toISOString(),
refreshToken,
refreshTokenExpiresAt: new Date(now + refreshTtlMs).toISOString(),
};
}
private async verify(
token: string | null,
type: AdminTokenType,
): Promise<AdminTokenPayload> {
if (!token) throw this.rejected('no_token');
let payload: AdminTokenPayload;
try {
payload = await this.jwt.verifyAsync<AdminTokenPayload>(token);
} catch {
throw this.rejected('invalid_token');
}
// 리프레시 토큰을 액세스 토큰 자리에 쓰는 것을 막는다 (반대도 마찬가지)
if (payload.typ !== type) throw this.rejected('wrong_token_type');
return payload;
}
}
@@ -0,0 +1,41 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryColumn,
} from 'typeorm';
/**
* 관리자 로그인 세션 — 로그인한 기기(브라우저)마다 한 행.
*
* 관리자 계정(env 단일 계정) 하나를 여러 사람이 함께 쓰므로, 로그아웃은 그 기기의 세션만 끊어야 한다.
* (계정 단위로 끊으면 한 사람이 로그아웃할 때 모두가 함께 로그아웃된다)
* 모든 세션을 한 번에 끊는 것은 env 비밀번호 변경(토큰의 계정 지문)이 맡는다.
*/
@Entity({ name: 'admin_sessions' })
export class AdminSession {
/** 앱에서 crypto.randomUUID() 로 만든다 — DB 의 uuid 확장(uuid-ossp)에 기대지 않는다 */
@PrimaryColumn({ name: 'id', type: 'uuid' })
id!: string;
/** 어느 브라우저에서 로그인했는지 — 확인·감사용 (개인정보 아님, 길이 제한) */
@Column({ name: 'user_agent', type: 'varchar', length: 200, nullable: true })
userAgent!: string | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
/** 마지막으로 세션을 갱신한 시각 */
@Column({ name: 'last_used_at', type: 'timestamptz' })
lastUsedAt!: Date;
/** 리프레시 토큰 만료 시각 — 지나면 다시 로그인해야 한다 */
@Index()
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt!: Date;
/** 로그아웃한 시각 — 값이 있으면 이 세션의 토큰은 모두 무효 */
@Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
revokedAt!: Date | null;
}
@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
/** 로그인한 관리자 */
export class AdminResponseDto {
@ApiProperty({
description: '관리자 아이디 (env ADMIN_USERNAME)',
example: 'admin',
})
username!: string;
}
@@ -0,0 +1,40 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { AdminResponseDto } from './admin-response.dto';
/** JWT 길이 상한 — 비정상적으로 긴 입력을 검증 전에 거른다 */
const TOKEN_MAX_LENGTH = 2048;
/** 로그인·갱신 응답 — 화면은 토큰을 sessionStorage 에 두고 Authorization 헤더로 보낸다 */
export class AdminSessionDto {
@ApiProperty({ description: '로그인한 관리자', type: AdminResponseDto })
admin!: AdminResponseDto;
@ApiProperty({ description: '액세스 토큰 — Authorization: Bearer 로 보낸다' })
accessToken!: string;
@ApiProperty({
description: '액세스 토큰 만료 시각',
example: '2026-10-05T05:00:00.000Z',
})
accessTokenExpiresAt!: string;
@ApiProperty({ description: '리프레시 토큰 — 세션 갱신에만 쓴다' })
refreshToken!: string;
@ApiProperty({
description: '리프레시 토큰 만료 시각 — 이후에는 다시 로그인해야 한다',
example: '2026-10-05T16:00:00.000Z',
})
refreshTokenExpiresAt!: string;
}
/** 세션 갱신 요청 */
export class RenewSessionDto {
@ApiProperty({ description: '로그인 때 받은 리프레시 토큰' })
@IsString({ message: '세션 정보가 올바르지 않습니다.' })
@IsNotEmpty({ message: '세션 정보가 올바르지 않습니다.' })
@MaxLength(TOKEN_MAX_LENGTH)
refreshToken!: string;
}
@@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
const USERNAME_MAX_LENGTH = 50;
/** bcrypt 는 72바이트까지만 쓰므로 그 이상은 받을 이유가 없다 */
const PASSWORD_MAX_LENGTH = 72;
export class LoginDto {
@ApiProperty({ description: '관리자 아이디', example: 'admin' })
@IsString({ message: '아이디를 입력해 주세요.' })
@IsNotEmpty({ message: '아이디를 입력해 주세요.' })
@MaxLength(USERNAME_MAX_LENGTH, { message: '아이디가 너무 깁니다.' })
username!: string;
@ApiProperty({ description: '비밀번호', example: 'a-long-password' })
@IsString({ message: '비밀번호를 입력해 주세요.' })
@IsNotEmpty({ message: '비밀번호를 입력해 주세요.' })
@MaxLength(PASSWORD_MAX_LENGTH, { message: '비밀번호가 너무 깁니다.' })
password!: string;
}
@@ -0,0 +1,86 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow';
/**
* 강의 신청 기록 — 신청 완료 화면(아임웹 /apply)이 보낸다.
*
* 회원 한 명이 같은 상품에 여러 번 들어와도 한 건만 남긴다 (회원·상품 유일).
* 이름·연락처 같은 회원 정보는 여기 두지 않는다 — imweb_members 에서 member_code 로 찾는다.
* (브라우저가 보낸 값은 위조될 수 있어서 회원 정보로 쓰지 않는다)
*/
@Entity({ name: 'member_applications' })
@Index('uq_member_applications_member_product', ['memberCode', 'productIdx'], {
unique: true,
})
@Index('idx_member_applications_cursor', ['appliedAt', 'id'])
export class MemberApplication {
/** 앱에서 crypto.randomUUID() 로 만든다 */
@PrimaryColumn({ name: 'id', type: 'uuid' })
id!: string;
@Column({ name: 'member_code', type: 'varchar', length: 40 })
memberCode!: string;
/** 신청 당시 아이디 — 회원이 탈퇴해 imweb_members 에서 사라져도 누구였는지 남긴다 */
@Column({ name: 'member_uid', type: 'varchar', length: 255 })
memberUid!: string;
/** 아임웹 상품번호 (?idx=) */
@Index()
@Column({ name: 'product_idx', type: 'varchar', length: 20 })
productIdx!: string;
/** 신청 화면에 표시된 상품명 — 표시용 참고값 (브라우저가 보낸 값) */
@Column({
name: 'product_name',
type: 'varchar',
length: 200,
nullable: true,
})
productName!: string | null;
@Column({ name: 'applied_at', type: 'timestamptz' })
appliedAt!: Date;
/** 오픈카톡 입장 버튼을 처음 누른 시각 — 실제 입장 여부는 알 수 없다 */
@Column({ name: 'kakao_clicked_at', type: 'timestamptz', nullable: true })
kakaoClickedAt!: Date | null;
/** 신청 순간의 유입정보 원본 (header-top.html 이 저장해 둔 값, 파이프 형식) */
@Column({ name: 'inflow_raw', type: 'text', nullable: true })
inflowRaw!: string | null;
@Index()
@Column({
name: 'inflow_source',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowSource!: string | null;
@Column({
name: 'inflow_medium',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowMedium!: string | null;
@Column({
name: 'inflow_campaign',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowCampaign!: string | null;
@Column({
name: 'inflow_content',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowContent!: string | null;
}
@@ -0,0 +1,107 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { PaginatedResult } from '@/common/dto/pagination.dto';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
import { ADMIN_BEARER_AUTH } from '@/modules/admin-auth/admin-auth.constants';
import { ApplicationsService } from './applications.service';
import { ApplicationListQueryDto } from './dto/application-query.dto';
import {
ApplicationProductDto,
ApplicationResponseDto,
} from './dto/application-response.dto';
import {
CreateApplicationDto,
CreateApplicationResultDto,
} from './dto/create-application.dto';
/**
* 신청 기록 — 아임웹 신청 완료 화면(공개 페이지)이 부른다.
* 로그인 없이 열려 있으므로 요청 수를 엄격히 제한한다.
*/
@ApiTags('Applications')
@Controller('applications')
export class ApplicationsController {
constructor(private readonly applications: ApplicationsService) {}
@Post()
@Throttle({ default: { ttl: 60_000, limit: 10 } })
@ApiOperation({
summary: '강의 신청 기록 — 같은 회원·상품은 한 건만 (여러 번 불러도 안전)',
})
@ApiStandardResponse(CreateApplicationResultDto, {
status: 201,
description: '기록됨 (이미 있으면 기존 기록)',
})
@ApiErrorResponse('APPLY_001', '아임웹에 없는 회원')
@ApiErrorResponse('VAL_001', '입력값 오류')
@ApiErrorResponse('REQ_001', '요청 수 초과 (1분 10회)')
create(
@Body() dto: CreateApplicationDto,
): Promise<CreateApplicationResultDto> {
return this.applications.create(dto);
}
@Post(':id/kakao-clicks')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform()
@Throttle({ default: { ttl: 60_000, limit: 20 } })
@ApiOperation({ summary: '오픈카톡 버튼 클릭 기록 (처음 한 번만 남는다)' })
@ApiErrorResponse('VAL_001', 'ID 형식 오류')
async kakaoClick(
@Param('id', new ParseUUIDPipe()) id: string,
): Promise<void> {
await this.applications.markKakaoClicked(id);
}
}
/** 관리자 신청 내역 조회 */
@ApiTags('Admin Applications')
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@UseGuards(AdminAuthGuard)
@Controller('admin/applications')
export class AdminApplicationsController {
constructor(private readonly applications: ApplicationsService) {}
@Get()
@ApiOperation({ summary: '신청 내역 (최신순, 커서 페이지네이션)' })
@ApiStandardResponse(ApplicationResponseDto, {
description: '조회 성공 — data 는 { items, pageInfo }',
isArray: true,
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '필터·커서 형식 오류')
findMany(
@Query() query: ApplicationListQueryDto,
): Promise<PaginatedResult<ApplicationResponseDto>> {
return this.applications.findMany(query);
}
@Get('products')
@ApiOperation({ summary: '상품별 신청 수 (최근 신청이 있는 상품부터)' })
@ApiStandardResponse(ApplicationProductDto, {
description: '조회 성공',
isArray: true,
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
products(): Promise<ApplicationProductDto[]> {
return this.applications.products();
}
}
@@ -0,0 +1,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 {}
@@ -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<MemberApplication> }) =>
Promise.resolve(
store.find(
(r) =>
r.memberCode === where.memberCode &&
r.productIdx === where.productIdx,
) ?? null,
),
),
insert: jest.fn((row: MemberApplication) => {
store.push(row);
return Promise.resolve();
}),
update: jest.fn(() => Promise.resolve()),
};
const lookup = { find: jest.fn(() => Promise.resolve(member)) };
const service = new ApplicationsService(
applications as unknown as Repository<MemberApplication>,
{} as Repository<ImwebMember>,
lookup as unknown as ImwebMemberLookupService,
{} as MembersService,
{ trackingHost: 'utm.moneyguider.co.kr' } as ImwebConfig,
);
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 });
});
});
@@ -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<MemberApplication>,
@InjectRepository(ImwebMember)
private readonly members: Repository<ImwebMember>,
private readonly lookup: ImwebMemberLookupService,
private readonly membersService: MembersService,
private readonly config: ImwebConfig,
) {}
/** 신청 기록 — 같은 회원·상품이면 기존 기록을 돌려준다 (몇 번을 불러도 한 건) */
async create(dto: CreateApplicationDto): Promise<CreateApplicationResultDto> {
const member = await this.lookup.find(dto.memberUid);
if (!member) {
// 아이디(이메일)는 로그에 남기지 않는다
throw new BusinessException('APPLY_001', {
context: { reason: 'member_not_found', productIdx: dto.productIdx },
});
}
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<void> {
await this.applications.update(
{ id, kakaoClickedAt: IsNull() },
{ kakaoClickedAt: new Date() },
);
}
async findMany(
query: ApplicationListQueryDto,
): Promise<PaginatedResult<ApplicationResponseDto>> {
const base = this.applications
.createQueryBuilder(ALIAS)
// 회원 검색을 위해 동기화된 회원과 잇는다 (탈퇴 회원은 회원 정보 없이 남는다)
.leftJoin(
ImwebMember,
MEMBER_ALIAS,
`${MEMBER_ALIAS}.member_code = ${ALIAS}.member_code`,
);
if (query.productIdx) {
base.andWhere(`${ALIAS}.productIdx = :productIdx`, {
productIdx: query.productIdx,
});
}
if (query.appliedFrom) {
base.andWhere(`${ALIAS}.appliedAt >= :appliedFrom`, {
appliedFrom: query.appliedFrom,
});
}
if (query.appliedTo) {
base.andWhere(`${ALIAS}.appliedAt < :appliedTo`, {
appliedTo: query.appliedTo,
});
}
if (query.source) {
base.andWhere(`${ALIAS}.inflowSource = :source`, {
source: query.source,
});
}
if (query.q) {
base.andWhere(
`(${MEMBER_ALIAS}.name ILIKE :q OR ${ALIAS}.member_uid ILIKE :q OR ${MEMBER_ALIAS}.email ILIKE :q OR ${MEMBER_ALIAS}.callnum ILIKE :q)`,
{ q: `%${escapeLike(query.q.trim())}%` },
);
}
const total = await base.clone().getCount();
const page = base
.clone()
.orderBy(`${ALIAS}.appliedAt`, 'DESC')
.addOrderBy(`${ALIAS}.id`, 'DESC')
.take(query.limit + 1);
if (query.cursor) {
const cursor = decodeTimeCursor(query.cursor);
if (!cursor) {
throw new BusinessException('VAL_001', {
context: { reason: 'invalid_cursor' },
});
}
page.andWhere(
`(${ALIAS}.appliedAt, ${ALIAS}.id) < (:cursorTime, :cursorId)`,
{
cursorTime: cursor.time,
cursorId: cursor.key,
},
);
}
const rows = await page.getMany();
const hasNext = rows.length > query.limit;
const items = rows.slice(0, query.limit);
const last = items.at(-1);
const [membersByCode, groupTitles] = await Promise.all([
this.membersByCode(items.map((row) => row.memberCode)),
this.membersService.groupTitleMap(),
]);
return toPaginatedResult(
items.map((row) => {
const member = membersByCode.get(row.memberCode);
return {
id: row.id,
appliedAt: row.appliedAt.toISOString(),
kakaoClickedAt: row.kakaoClickedAt?.toISOString() ?? null,
productIdx: row.productIdx,
productName: row.productName,
memberUid: row.memberUid,
member: member ? MemberResponseDto.from(member, groupTitles) : null,
inflow: {
source: row.inflowSource,
medium: row.inflowMedium,
campaign: row.inflowCampaign,
content: row.inflowContent,
},
};
}),
{
hasNext,
nextCursor:
hasNext && last
? encodeTimeCursor({
time: last.appliedAt.toISOString(),
key: last.id,
})
: null,
total,
},
);
}
/** 상품별 신청 수 — 최근 신청이 있는 상품부터 */
async products(): Promise<ApplicationProductDto[]> {
const rows = await this.applications
.createQueryBuilder(ALIAS)
.select(`${ALIAS}.productIdx`, 'productIdx')
// 상품명은 바뀔 수 있어 가장 최근 신청의 표시값을 쓴다
.addSelect(
`(ARRAY_AGG(${ALIAS}.product_name ORDER BY ${ALIAS}.applied_at DESC))[1]`,
'productName',
)
.addSelect('COUNT(*)', 'count')
.groupBy(`${ALIAS}.productIdx`)
.orderBy(`MAX(${ALIAS}.applied_at)`, 'DESC')
.getRawMany<{
productIdx: string;
productName: string | null;
count: string;
}>();
return rows.map((row) => ({
productIdx: row.productIdx,
productName: row.productName,
count: Number(row.count),
}));
}
private findExisting(
memberCode: string,
productIdx: string,
): Promise<MemberApplication | null> {
return this.applications.findOne({ where: { memberCode, productIdx } });
}
private async membersByCode(
codes: string[],
): Promise<Map<string, ImwebMember>> {
if (codes.length === 0) return new Map();
const rows = await this.members
.createQueryBuilder(MEMBER_ALIAS)
.where(`${MEMBER_ALIAS}.memberCode IN (:...codes)`, { codes })
.getMany();
return new Map(rows.map((row) => [row.memberCode, row]));
}
}
@@ -0,0 +1,55 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow';
const SEARCH_MAX_LENGTH = 100;
/** 관리자 신청 내역 조회 */
export class ApplicationListQueryDto extends CursorPaginationQueryDto {
@ApiPropertyOptional({ description: '상품번호', example: '52' })
@IsOptional()
@Matches(/^\d{1,20}$/, { message: '상품번호 형식이 올바르지 않습니다.' })
productIdx?: string;
@ApiPropertyOptional({
description: '신청 시각 시작 (이상, ISO 8601)',
example: '2026-09-30T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '신청 시작일 형식이 올바르지 않습니다.' })
appliedFrom?: string;
@ApiPropertyOptional({
description: '신청 시각 끝 (미만, ISO 8601)',
example: '2026-10-31T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '신청 종료일 형식이 올바르지 않습니다.' })
appliedTo?: string;
@ApiPropertyOptional({
description: '신청 순간의 유입원',
example: 'facebook',
})
@IsOptional()
@IsString()
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
source?: string;
@ApiPropertyOptional({
description: '검색어 — 이름·아이디·이메일·연락처 부분 일치',
example: '홍길동',
})
@IsOptional()
@IsString()
@MaxLength(SEARCH_MAX_LENGTH, { message: '검색어가 너무 깁니다.' })
q?: string;
}
@@ -0,0 +1,73 @@
import { ApiProperty } from '@nestjs/swagger';
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
/** 신청 순간의 유입정보 */
export class ApplicationInflowDto {
@ApiProperty({ description: '유입원', nullable: true, example: 'facebook' })
source!: string | null;
@ApiProperty({ description: '매체', nullable: true, example: 'paid' })
medium!: string | null;
@ApiProperty({ description: '캠페인', nullable: true, example: 'fall' })
campaign!: string | null;
@ApiProperty({ description: '콘텐츠', nullable: true, example: 'video_a' })
content!: string | null;
}
/** 관리자 신청 내역 한 건 */
export class ApplicationResponseDto {
@ApiProperty({ description: '신청 기록 ID' })
id!: string;
@ApiProperty({
description: '신청 시각',
example: '2026-10-05T03:00:00.000Z',
})
appliedAt!: string;
@ApiProperty({
description: '오픈카톡 버튼을 처음 누른 시각 (안 눌렀으면 null)',
nullable: true,
})
kakaoClickedAt!: string | null;
@ApiProperty({ description: '상품번호', example: '52' })
productIdx!: string;
@ApiProperty({ description: '상품명 (신청 화면 표시값)', nullable: true })
productName!: string | null;
@ApiProperty({ description: '신청 당시 아이디', example: 'user@example.com' })
memberUid!: string;
@ApiProperty({
description: '회원 정보 (동기화된 회원 기준) — 탈퇴 등으로 없으면 null',
type: MemberResponseDto,
nullable: true,
})
member!: MemberResponseDto | null;
@ApiProperty({
description: '신청 순간의 유입정보',
type: ApplicationInflowDto,
})
inflow!: ApplicationInflowDto;
}
/** 상품별 신청 수 — 필터 칩에 쓴다 */
export class ApplicationProductDto {
@ApiProperty({ description: '상품번호', example: '52' })
productIdx!: string;
@ApiProperty({
description: '가장 최근 신청에 표시된 상품명',
nullable: true,
})
productName!: string | null;
@ApiProperty({ description: '신청 수', example: 120 })
count!: number;
}
@@ -0,0 +1,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;
}
@@ -0,0 +1,115 @@
import { ApiProperty } from '@nestjs/swagger';
import {
ImwebSyncRun,
SyncStatus,
SyncTrigger,
} from '../entities/imweb-sync-run.entity';
/** 회원 동기화 실행 기록 */
export class SyncRunDto {
@ApiProperty({ description: '실행 ID', example: 12 })
id!: number;
@ApiProperty({
description: '실행 계기',
enum: SyncTrigger,
example: SyncTrigger.MANUAL,
})
trigger!: SyncTrigger;
@ApiProperty({
description: '상태',
enum: SyncStatus,
example: SyncStatus.SUCCEEDED,
})
status!: SyncStatus;
@ApiProperty({
description: '시작 시각',
example: '2026-10-05T05:00:00.000Z',
})
startedAt!: string;
@ApiProperty({
description: '종료 시각 (진행 중이면 null)',
nullable: true,
example: '2026-10-05T05:01:30.000Z',
})
finishedAt!: string | null;
@ApiProperty({
description: '받아온 회원 수 (진행 중이면 현재까지)',
example: 1520,
})
fetchedCount!: number;
@ApiProperty({ description: '탈퇴로 정리한 회원 수', example: 3 })
removedCount!: number;
@ApiProperty({
description: '실패 시 에러 코드',
nullable: true,
example: null,
})
errorCode!: string | null;
static from(run: ImwebSyncRun): SyncRunDto {
return {
id: run.id,
trigger: run.trigger,
status: run.status,
startedAt: run.startedAt.toISOString(),
finishedAt: run.finishedAt?.toISOString() ?? null,
fetchedCount: run.fetchedCount,
removedCount: run.removedCount,
errorCode: run.errorCode,
};
}
}
/** 아임웹 연동 상태 — 관리자 화면 상단에 표시 */
export class ImwebStatusDto {
@ApiProperty({
description: '서버에 아임웹 연동 설정(env)이 되어 있는가',
example: true,
})
isConfigured!: boolean;
@ApiProperty({ description: '아임웹 계정이 연결되어 있는가', example: true })
isConnected!: boolean;
@ApiProperty({
description: '연결한 시각',
nullable: true,
example: '2026-10-05T04:00:00.000Z',
})
connectedAt!: string | null;
@ApiProperty({ description: '동기화된 회원 수', example: 1520 })
memberCount!: number;
@ApiProperty({
description: '가장 최근 동기화 실행 (진행 중일 수 있다)',
type: SyncRunDto,
nullable: true,
})
latestRun!: SyncRunDto | null;
@ApiProperty({
description: '마지막으로 성공한 동기화 — 목록 데이터의 기준 시각',
type: SyncRunDto,
nullable: true,
})
latestSucceededRun!: SyncRunDto | null;
}
/** 아임웹 동의 화면 주소 */
export class AuthorizationUrlDto {
@ApiProperty({
description:
'브라우저를 이 주소로 보내면 아임웹 동의 화면이 열린다 (10분 안에 완료)',
example: 'https://openapi.imweb.me/oauth2/authorize?responseType=code&...',
})
authorizationUrl!: string;
}
@@ -0,0 +1,40 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* 아임웹 OAuth 연결 정보 — 사이트당 한 행.
*
* ⚠️ 리프레시 토큰은 회원 개인정보를 읽을 수 있는 열쇠다. 응답·로그에 절대 싣지 않는다.
*/
@Entity({ name: 'imweb_connections' })
export class ImwebConnection {
@PrimaryColumn({ name: 'site_code', type: 'varchar', length: 40 })
siteCode!: string;
/** 회원 조회에 필요한 유닛 코드 (/site-info 의 첫 유닛) */
@Column({ name: 'unit_code', type: 'varchar', length: 40 })
unitCode!: string;
@Column({ name: 'access_token', type: 'text' })
accessToken!: string;
@Column({ name: 'refresh_token', type: 'text' })
refreshToken!: string;
@Column({ name: 'access_token_expires_at', type: 'timestamptz' })
accessTokenExpiresAt!: Date;
@Column({ name: 'scope', type: 'varchar', length: 255, nullable: true })
scope!: string | null;
@CreateDateColumn({ name: 'connected_at', type: 'timestamptz' })
connectedAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,21 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
/**
* 아임웹 회원 그룹 — 동기화 때마다 아임웹 목록으로 덮어쓴다.
* 회원이 어느 그룹인지는 imweb_members.group_codes 에 코드로 들어 있다.
*/
@Entity({ name: 'imweb_groups' })
export class ImwebGroup {
@PrimaryColumn({ name: 'group_code', type: 'varchar', length: 40 })
groupCode!: string;
@Column({ name: 'title', type: 'varchar', length: 200 })
title!: string;
/** 아임웹이 주는 순서 — 관리자 화면에 아임웹과 같은 순서로 보여준다 */
@Column({ name: 'sort_order', type: 'int' })
sortOrder!: number;
@Column({ name: 'synced_at', type: 'timestamptz' })
syncedAt!: Date;
}
@@ -0,0 +1,28 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
/**
* 아임웹 회원 엑셀에서 가져온 '유입경로' 원본 값.
*
* 이 항목(사용자 정의 가입 항목)은 Open API 로 나오지 않는다. 그래서 엑셀로 받아 여기에 보관하고,
* 동기화가 회원 행을 만들 때 homePage 에 유입정보가 없으면 이 값을 쓴다.
* imweb_members 에 직접 쓰지 않는 이유: 매 동기화가 그 컬럼들을 homePage 기준으로 덮어쓴다.
*
* 탈퇴 등으로 회원이 사라져도 지우지 않는다 (가져온 원본 기록).
*/
@Entity({ name: 'imweb_member_inflow_imports' })
export class ImwebMemberInflowImport {
/** 아임웹 회원 코드 — 엑셀의 '고유키' 열 */
@PrimaryColumn({ name: 'member_code', type: 'varchar', length: 40 })
memberCode!: string;
/** '유입경로' 원본 (파이프 형식) — 파싱은 쓸 때 가입 시각과 함께 한다 */
@Column({ name: 'raw_value', type: 'text' })
rawValue!: string;
/** 가져온 파일 이름 (경로 제외) — 어느 엑셀에서 왔는지 추적용 */
@Column({ name: 'source_file', type: 'varchar', length: 255 })
sourceFile!: string;
@Column({ name: 'imported_at', type: 'timestamptz' })
importedAt!: Date;
}
@@ -0,0 +1,174 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import {
INFLOW_VALUE_MAX_LENGTH,
InflowOrigin,
InflowStatus,
} from '@/shared/utils/inflow';
/**
* 아임웹 회원 사본 — 유입경로로 필터·집계하려고 우리 DB 에 동기화해 둔다.
* (아임웹 API 는 홈페이지 값으로 검색하거나 집계할 수 없다)
*
* 원본은 아임웹이다. 이 테이블은 동기화 때마다 덮어쓰며, 여기서 수정하지 않는다.
* 아임웹에서 탈퇴한 회원은 다음 전체 동기화 때 삭제된다.
*/
@Entity({ name: 'imweb_members' })
@Index('idx_imweb_members_join_cursor', ['joinTime', 'memberCode'])
export class ImwebMember {
@PrimaryColumn({ name: 'member_code', type: 'varchar', length: 40 })
memberCode!: string;
/** 아임웹 로그인 아이디 (이메일 또는 소셜 식별자) */
@Column({ name: 'uid', type: 'varchar', length: 255 })
uid!: string;
@Column({ name: 'name', type: 'varchar', length: 100 })
name!: string;
@Column({ name: 'email', type: 'varchar', length: 255, nullable: true })
email!: string | null;
@Column({ name: 'callnum', type: 'varchar', length: 30, nullable: true })
callnum!: string | null;
/** 소셜 가입 경로 (kakao, naver 등) — 일반 가입이면 null */
@Column({
name: 'social_provider',
type: 'varchar',
length: 20,
nullable: true,
})
socialProvider!: string | null;
@Column({ name: 'join_time', type: 'timestamptz' })
joinTime!: Date;
@Column({ name: 'last_login_time', type: 'timestamptz', nullable: true })
lastLoginTime!: Date | null;
/** 소속 회원 그룹 코드 — 이름은 imweb_groups 에서 찾는다 */
@Column({
name: 'group_codes',
type: 'text',
array: true,
default: () => "'{}'",
})
groupCodes!: string[];
/** 아임웹 homePage 원본 — 파싱 규칙이 바뀌면 재동기화 없이 다시 풀 수 있게 남긴다 */
@Column({ name: 'home_page', type: 'text', nullable: true })
homePage!: string | null;
/** 유입정보 출처 — homePage 가 우선, 없으면 엑셀에서 가져온 값. 둘 다 없으면 null */
@Column({
name: 'inflow_origin',
type: 'varchar',
length: 20,
nullable: true,
})
inflowOrigin!: InflowOrigin | null;
@Index()
@Column({ name: 'inflow_status', type: 'varchar', length: 20 })
inflowStatus!: InflowStatus;
@Index()
@Column({
name: 'inflow_source',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowSource!: string | null;
@Index()
@Column({
name: 'inflow_medium',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowMedium!: string | null;
@Index()
@Column({
name: 'inflow_campaign',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowCampaign!: string | null;
@Column({
name: 'inflow_content',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowContent!: string | null;
@Column({
name: 'inflow_term',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowTerm!: string | null;
@Column({
name: 'inflow_click_id',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowClickId!: string | null;
@Column({
name: 'inflow_referrer',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowReferrer!: string | null;
@Column({
name: 'inflow_landing_path',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowLandingPath!: string | null;
@Column({
name: 'inflow_product_id',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowProductId!: string | null;
/** 최초 유입일 (KST 날짜) */
@Column({ name: 'inflow_first_touch_date', type: 'date', nullable: true })
inflowFirstTouchDate!: string | null;
@Column({
name: 'inflow_last_touch',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowLastTouch!: string | null;
/** 유입정보를 기록한 날 (KST 날짜) */
@Column({ name: 'inflow_recorded_date', type: 'date', nullable: true })
inflowRecordedDate!: string | null;
/** 마지막으로 이 회원을 본 동기화 실행 — 전체 동기화 후 이 값이 낡은 행은 탈퇴 회원이다 */
@Index()
@Column({ name: 'last_sync_run_id', type: 'int' })
lastSyncRunId!: number;
@Column({ name: 'synced_at', type: 'timestamptz' })
syncedAt!: Date;
}
@@ -0,0 +1,44 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
export enum SyncTrigger {
MANUAL = 'manual',
SCHEDULE = 'schedule',
}
export enum SyncStatus {
RUNNING = 'running',
SUCCEEDED = 'succeeded',
FAILED = 'failed',
}
/** 회원 동기화 실행 기록 — 관리자 화면에 마지막 동기화 시각·결과를 보여준다 */
@Entity({ name: 'imweb_sync_runs' })
export class ImwebSyncRun {
@PrimaryGeneratedColumn({ name: 'id' })
id!: number;
@Column({ name: 'trigger', type: 'varchar', length: 20 })
trigger!: SyncTrigger;
@Column({ name: 'status', type: 'varchar', length: 20 })
status!: SyncStatus;
@Index()
@Column({ name: 'started_at', type: 'timestamptz' })
startedAt!: Date;
@Column({ name: 'finished_at', type: 'timestamptz', nullable: true })
finishedAt!: Date | null;
/** 아임웹에서 받아온 회원 수 */
@Column({ name: 'fetched_count', type: 'int', default: 0 })
fetchedCount!: number;
/** 아임웹에 더 이상 없어 지운 회원 수 (탈퇴) */
@Column({ name: 'removed_count', type: 'int', default: 0 })
removedCount!: number;
/** 실패 시 에러 코드 (IMWEB_002 등) — 내부 메시지는 로그에만 남긴다 */
@Column({ name: 'error_code', type: 'varchar', length: 20, nullable: true })
errorCode!: string | null;
}
@@ -0,0 +1,90 @@
import {
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentAdmin } from '@/common/decorators/current-admin.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
import { ADMIN_BEARER_AUTH } from '@/modules/admin-auth/admin-auth.constants';
import { AdminResponseDto } from '@/modules/admin-auth/dto/admin-response.dto';
import {
AuthorizationUrlDto,
ImwebStatusDto,
SyncRunDto,
} from './dto/imweb-status.dto';
import { SyncTrigger } from './entities/imweb-sync-run.entity';
import { ImwebAdminService } from './imweb-admin.service';
import { ImwebAuthService } from './imweb-auth.service';
import { ImwebSyncService } from './imweb-sync.service';
/** 아임웹 연동 관리 — 연결·해제·동기화 */
@ApiTags('Admin Imweb')
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@UseGuards(AdminAuthGuard)
@Controller('admin/imweb')
export class ImwebAdminController {
constructor(
private readonly admin: ImwebAdminService,
private readonly auth: ImwebAuthService,
private readonly sync: ImwebSyncService,
) {}
@Get('connection')
@ApiOperation({ summary: '아임웹 연동 상태와 최근 동기화 결과' })
@ApiStandardResponse(ImwebStatusDto, { description: '조회 성공' })
@ApiErrorResponse('AUTH_001', '로그인 필요')
getStatus(): Promise<ImwebStatusDto> {
return this.admin.getStatus();
}
@Post('authorizations')
@ApiOperation({ summary: '아임웹 연결 시작 — 동의 화면 주소 발급' })
@ApiStandardResponse(AuthorizationUrlDto, {
status: 201,
description: '발급 성공',
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('IMWEB_005', '서버 env 에 아임웹 설정이 없음')
async createAuthorization(
@CurrentAdmin() current: AdminResponseDto,
): Promise<AuthorizationUrlDto> {
const authorizationUrl = await this.auth.createAuthorizationUrl(
current.username,
);
return { authorizationUrl };
}
@Delete('connection')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform()
@ApiOperation({ summary: '아임웹 연결 해제 — 저장된 토큰 삭제' })
@ApiErrorResponse('AUTH_001', '로그인 필요')
async disconnect(): Promise<void> {
await this.auth.disconnect();
}
@Post('sync-runs')
@HttpCode(HttpStatus.ACCEPTED)
@ApiOperation({
summary: '회원 동기화 시작 — 바로 응답하고 백그라운드에서 진행',
})
@ApiStandardResponse(SyncRunDto, { status: 202, description: '시작됨' })
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('IMWEB_001', '아임웹 미연결')
@ApiErrorResponse('IMWEB_004', '이미 진행 중')
async startSync(): Promise<SyncRunDto> {
const run = await this.sync.start(SyncTrigger.MANUAL);
return SyncRunDto.from(run);
}
}
@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ImwebStatusDto, SyncRunDto } from './dto/imweb-status.dto';
import { ImwebMember } from './entities/imweb-member.entity';
import { ImwebConfig } from './imweb.config';
import { ImwebAuthService } from './imweb-auth.service';
import { ImwebSyncService } from './imweb-sync.service';
/** 관리자 화면용 연동 상태 조회 */
@Injectable()
export class ImwebAdminService {
constructor(
@InjectRepository(ImwebMember)
private readonly members: Repository<ImwebMember>,
private readonly auth: ImwebAuthService,
private readonly sync: ImwebSyncService,
private readonly config: ImwebConfig,
) {}
async getStatus(): Promise<ImwebStatusDto> {
const isConfigured = this.config.isConfigured();
const [connection, memberCount, latestRun, latestSucceededRun] =
await Promise.all([
isConfigured ? this.auth.findConnection() : Promise.resolve(null),
this.members.count(),
this.sync.findLatestRun(),
this.sync.findLatestSucceededRun(),
]);
return {
isConfigured,
isConnected: connection !== null,
connectedAt: connection?.connectedAt.toISOString() ?? null,
memberCount,
latestRun: latestRun ? SyncRunDto.from(latestRun) : null,
latestSucceededRun: latestSucceededRun
? SyncRunDto.from(latestSucceededRun)
: null,
};
}
}
@@ -0,0 +1,287 @@
import type { Cache } from 'cache-manager';
import type { Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { ImwebConnection } from './entities/imweb-connection.entity';
import { ImwebConfig } from './imweb.config';
import { ImwebAuthService } from './imweb-auth.service';
import { ImwebHttpClient, ImwebHttpError } from './imweb-http.client';
const HOUR_MS = 3_600_000;
function jwtExpiringIn(ms: number): string {
const payload = Buffer.from(
JSON.stringify({ exp: Math.floor((Date.now() + ms) / 1000) }),
).toString('base64url');
return `header.${payload}.signature`;
}
function connection(overrides: Partial<ImwebConnection> = {}): ImwebConnection {
return {
siteCode: 'S-site',
unitCode: 'u-unit',
accessToken: 'old-access',
refreshToken: 'old-refresh',
accessTokenExpiresAt: new Date(Date.now() + HOUR_MS),
scope: null,
connectedAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function setup(stored: ImwebConnection | null = connection()) {
let current = stored;
const repo = {
findOne: jest.fn(() => Promise.resolve(current)),
save: jest.fn((row: ImwebConnection) => {
current = row;
return Promise.resolve(row);
}),
create: jest.fn((row: ImwebConnection) => row),
delete: jest.fn(() => {
current = null;
return Promise.resolve({ affected: 1 });
}),
};
const cache = {
set: jest.fn(() => Promise.resolve()),
get: jest.fn((): Promise<unknown> => Promise.resolve(undefined)),
del: jest.fn(() => Promise.resolve(true)),
};
const http = { get: jest.fn(), postForm: jest.fn(), patchJson: jest.fn() };
const config = {
clientId: 'client-id',
clientSecret: 'client-secret',
siteCode: 'S-site',
redirectUri: 'http://localhost:3100/api/imweb/oauth/callback',
isConfigured: () => true,
};
const service = new ImwebAuthService(
repo as unknown as Repository<ImwebConnection>,
cache as unknown as Cache,
http as unknown as ImwebHttpClient,
config as unknown as ImwebConfig,
);
return { service, repo, cache, http };
}
describe('ImwebAuthService', () => {
it('동의 화면 주소에 필수 파라미터를 넣고 state 를 저장한다', async () => {
const { service, cache } = setup();
const url = new URL(await service.createAuthorizationUrl('admin'));
expect(url.origin + url.pathname).toBe(
'https://openapi.imweb.me/oauth2/authorize',
);
expect(url.searchParams.get('responseType')).toBe('code');
expect(url.searchParams.get('clientId')).toBe('client-id');
expect(url.searchParams.get('siteCode')).toBe('S-site');
expect(url.searchParams.get('scope')).toBe(
'site-info:write member-info:read',
);
const state = url.searchParams.get('state');
expect(state).toBeTruthy();
expect(cache.set).toHaveBeenCalledWith(
`imweb:oauth-state:${state}`,
'admin',
expect.any(Number),
);
});
it('모르는 state 로 돌아오면 토큰을 요청하지 않고 IMWEB_003', async () => {
const { service, http } = setup();
await expect(
service.completeAuthorization('code', 'unknown-state'),
).rejects.toMatchObject({ code: 'IMWEB_003' });
expect(http.postForm).not.toHaveBeenCalled();
});
it('401 을 받으면 토큰을 갱신하고 새 토큰으로 한 번 다시 호출한다', async () => {
const { service, http, repo } = setup();
http.get
.mockRejectedValueOnce(new ImwebHttpError('/x', 401, '30102'))
.mockResolvedValueOnce({ ok: true });
http.postForm.mockResolvedValueOnce({
accessToken: jwtExpiringIn(HOUR_MS),
refreshToken: 'new-refresh',
});
const result = await service.authorizedGet('/x', {});
expect(result).toEqual({ data: { ok: true }, unitCode: 'u-unit' });
expect(http.postForm).toHaveBeenCalledWith(
'/oauth2/token',
expect.objectContaining({
grantType: 'refresh_token',
refreshToken: 'old-refresh',
}),
);
expect(http.get).toHaveBeenLastCalledWith(
'/x',
{},
expect.stringMatching(/^header\./),
);
expect(repo.save).toHaveBeenCalledWith(
expect.objectContaining({ refreshToken: 'new-refresh' }),
);
});
it('동시에 여러 요청이 401 을 받아도 갱신은 한 번만 한다', async () => {
const { service, http } = setup();
http.get.mockImplementation((_p: string, _q: object, token: string) =>
token === 'old-access'
? Promise.reject(new ImwebHttpError('/x', 401, '30101'))
: Promise.resolve('ok'),
);
http.postForm.mockResolvedValue({
accessToken: jwtExpiringIn(HOUR_MS),
refreshToken: 'new-refresh',
});
await Promise.all([
service.authorizedGet('/x', {}),
service.authorizedGet('/x', {}),
service.authorizedGet('/x', {}),
]);
expect(http.postForm).toHaveBeenCalledTimes(1);
});
it('만료 직전 토큰은 호출 전에 먼저 갱신한다', async () => {
const { service, http } = setup(
connection({ accessTokenExpiresAt: new Date(Date.now() + 10_000) }),
);
http.postForm.mockResolvedValueOnce({
accessToken: jwtExpiringIn(HOUR_MS),
refreshToken: 'new-refresh',
});
http.get.mockResolvedValueOnce('ok');
await service.authorizedGet('/x', {});
expect(http.postForm).toHaveBeenCalledTimes(1);
expect(http.get).toHaveBeenCalledTimes(1);
});
it('갱신 응답에 리프레시 토큰이 없으면 기존 것을 계속 쓴다', async () => {
const { service, http, repo } = setup(
connection({ accessTokenExpiresAt: new Date(0) }),
);
http.postForm.mockResolvedValueOnce({
accessToken: jwtExpiringIn(HOUR_MS),
refreshToken: '',
});
http.get.mockResolvedValueOnce('ok');
await service.authorizedGet('/x', {});
expect(repo.save).toHaveBeenCalledWith(
expect.objectContaining({ refreshToken: 'old-refresh' }),
);
});
it('리프레시 토큰까지 거부되면 연결을 지우고 IMWEB_001 (재연결 필요)', async () => {
const { service, http, repo } = setup(
connection({ accessTokenExpiresAt: new Date(0) }),
);
http.postForm.mockRejectedValueOnce(
new ImwebHttpError('/oauth2/token', 401, '30101'),
);
await expect(service.authorizedGet('/x', {})).rejects.toMatchObject({
code: 'IMWEB_001',
});
expect(repo.delete).toHaveBeenCalled();
expect(http.get).not.toHaveBeenCalled();
});
it('아임웹 5xx 는 IMWEB_002 로 바꿔 던지고 연결은 유지한다', async () => {
const { service, http, repo } = setup();
http.get.mockRejectedValueOnce(new ImwebHttpError('/x', 503, null));
const error: unknown = await service
.authorizedGet('/x', {})
.catch((e: unknown) => e);
expect(error).toBeInstanceOf(BusinessException);
expect(error).toMatchObject({ code: 'IMWEB_002' });
expect(repo.delete).not.toHaveBeenCalled();
});
it('연결 전이면 IMWEB_001', async () => {
const { service } = setup(null);
await expect(service.authorizedGet('/x', {})).rejects.toMatchObject({
code: 'IMWEB_001',
});
});
describe('callback 완료', () => {
function connectable(stored: ImwebConnection | null = null) {
const ctx = setup(stored);
ctx.cache.get.mockResolvedValueOnce('admin'); // 발급해 둔 state 의 관리자 아이디
ctx.http.postForm.mockResolvedValueOnce({
accessToken: jwtExpiringIn(HOUR_MS),
refreshToken: 'refresh',
scope: 'site-info:write member-info:read',
});
ctx.http.get.mockResolvedValueOnce({
siteCode: 'S-site',
unitList: [{ unitCode: 'u-unit' }],
});
return ctx;
}
it('토큰 발급 → 유닛 코드 저장 → 연동완료 처리 순으로 진행하고 state 는 한 번만 쓴다', async () => {
const { service, http, repo, cache } = connectable();
http.patchJson.mockResolvedValueOnce(true);
await service.completeAuthorization('auth-code', 'state-1');
expect(http.postForm).toHaveBeenCalledWith(
'/oauth2/token',
expect.objectContaining({
grantType: 'authorization_code',
code: 'auth-code',
}),
);
expect(repo.save).toHaveBeenCalledWith(
expect.objectContaining({
unitCode: 'u-unit',
refreshToken: 'refresh',
}),
);
expect(http.patchJson).toHaveBeenCalledWith(
'/site-info/integration-complete',
{},
expect.any(String),
);
expect(cache.del).toHaveBeenCalledWith('imweb:oauth-state:state-1');
});
it('이미 연동완료된 사이트(30128)를 다시 연결해도 성공으로 본다', async () => {
const { service, http } = connectable();
http.patchJson.mockRejectedValueOnce(
new ImwebHttpError('/site-info/integration-complete', 404, '30128'),
);
await expect(
service.completeAuthorization('auth-code', 'state-1'),
).resolves.toBeUndefined();
});
it('연동완료 처리가 실패해도 연결은 저장된다', async () => {
const { service, http, repo } = connectable();
http.patchJson.mockRejectedValueOnce(
new ImwebHttpError('/site-info/integration-complete', 403, '30103'),
);
await service.completeAuthorization('auth-code', 'state-1');
expect(repo.save).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,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<ImwebConnection> | null = null;
constructor(
@InjectRepository(ImwebConnection)
private readonly connections: Repository<ImwebConnection>,
@Inject(CACHE_MANAGER) private readonly cache: Cache,
private readonly http: ImwebHttpClient,
private readonly config: ImwebConfig,
) {}
/** 아임웹 동의 화면 주소를 만든다. state 는 callback 에서 한 번만 쓸 수 있다. */
async createAuthorizationUrl(adminUsername: string): Promise<string> {
this.assertConfigured();
const state = randomBytes(OAUTH_STATE_BYTES).toString('base64url');
await this.cache.set(
`${OAUTH_STATE_KEY_PREFIX}${state}`,
adminUsername,
OAUTH_STATE_TTL_MS,
);
const params = new URLSearchParams({
responseType: 'code',
clientId: this.config.clientId,
redirectUri: this.config.redirectUri,
scope: IMWEB_SCOPES,
state,
siteCode: this.config.siteCode,
});
return `${IMWEB_API_BASE_URL}${IMWEB_AUTHORIZE_PATH}?${params.toString()}`;
}
/** callback — state 를 확인하고 인가 코드를 토큰으로 바꿔 저장한다. */
async completeAuthorization(
code: string | undefined,
state: string | undefined,
): Promise<void> {
this.assertConfigured();
const stateKey = `${OAUTH_STATE_KEY_PREFIX}${state ?? ''}`;
const adminUsername = state
? await this.cache.get<string>(stateKey)
: undefined;
if (!code || !adminUsername) {
throw new BusinessException('IMWEB_003', {
context: { hasCode: Boolean(code), hasState: Boolean(state) },
});
}
await this.cache.del(stateKey); // 재사용 방지
const token = await this.callImweb(() =>
this.http.postForm<ImwebTokenData>(IMWEB_TOKEN_PATH, {
clientId: this.config.clientId,
clientSecret: this.config.clientSecret,
redirectUri: this.config.redirectUri,
code,
grantType: 'authorization_code',
}),
);
// 회원 조회에 필요한 유닛 코드를 받아 둔다
const site = await this.callImweb(() =>
this.http.get<ImwebSiteInfoData>(
IMWEB_SITE_INFO_PATH,
{},
token.accessToken,
),
);
const unitCode = site.unitList[0]?.unitCode;
if (!unitCode) {
throw new BusinessException('IMWEB_002', {
context: { reason: 'no_unit', siteCode: site.siteCode },
});
}
await this.connections.save(
this.connections.create({
siteCode: this.config.siteCode,
unitCode,
...this.tokenFields(token),
}),
);
await this.markIntegrationComplete(token.accessToken);
this.logger.log(
`아임웹 연결 완료: siteCode=${this.config.siteCode} unitCode=${unitCode} admin=${adminUsername}`,
);
}
/**
* 아임웹 앱 연동 상태를 '연동완료'로 바꾼다.
* 이미 완료된 사이트를 다시 연결하면 30128(연동중 상태만 가능)이 오므로 정상으로 본다.
* 그 밖의 실패도 연결 자체를 막지는 않는다 — 회원 조회가 되는지가 실제 기준이다.
*/
private async markIntegrationComplete(accessToken: string): Promise<void> {
try {
await this.http.patchJson<boolean>(
IMWEB_INTEGRATION_COMPLETE_PATH,
{},
accessToken,
);
} catch (error) {
if (
error instanceof ImwebHttpError &&
error.errorCode === ALREADY_INTEGRATED_CODE
) {
return;
}
this.logger.warn(
`아임웹 연동완료 처리 실패(연결은 유지): ${error instanceof ImwebHttpError ? `status=${error.status} errorCode=${error.errorCode ?? '-'}` : String(error)}`,
);
}
}
/** 연결 정보 (없으면 null) */
findConnection(): Promise<ImwebConnection | null> {
return this.connections.findOne({
where: { siteCode: this.config.siteCode },
});
}
/** 연결을 끊는다 — 저장된 토큰을 지운다. 아임웹 쪽 권한은 그대로 남는다. */
async disconnect(): Promise<void> {
await this.connections.delete({ siteCode: this.config.siteCode });
this.logger.log(`아임웹 연결 해제: siteCode=${this.config.siteCode}`);
}
/**
* 토큰을 붙여 GET 을 호출한다.
* 액세스 토큰이 만료 직전이면 먼저 갱신하고, 401 을 받으면 한 번 갱신 후 다시 시도한다.
*/
async authorizedGet<T>(
path: string,
query: Record<string, QueryValue>,
): Promise<{ data: T; unitCode: string }> {
let connection = await this.requireConnection();
if (this.isExpiringSoon(connection)) {
connection = await this.refresh();
}
try {
const data = await this.http.get<T>(path, query, connection.accessToken);
return { data, unitCode: connection.unitCode };
} catch (error) {
if (!(error instanceof ImwebHttpError) || !error.isUnauthorized()) {
throw this.toBusinessError(error);
}
}
// 만료 시각보다 일찍 무효가 된 경우(아임웹 쪽 재발급 등) — 한 번만 갱신 후 재시도
connection = await this.refresh();
const data = await this.callImweb(() =>
this.http.get<T>(path, query, connection.accessToken),
);
return { data, unitCode: connection.unitCode };
}
/** 연결 정보 — 설정이 없으면 IMWEB_005, 연결 전이면 IMWEB_001 */
async requireConnection(): Promise<ImwebConnection> {
this.assertConfigured();
const connection = await this.findConnection();
if (!connection) throw new BusinessException('IMWEB_001');
return connection;
}
private refresh(): Promise<ImwebConnection> {
this.refreshing ??= this.doRefresh().finally(() => {
this.refreshing = null;
});
return this.refreshing;
}
private async doRefresh(): Promise<ImwebConnection> {
const connection = await this.requireConnection();
let token: ImwebTokenData;
try {
token = await this.http.postForm<ImwebTokenData>(IMWEB_TOKEN_PATH, {
clientId: this.config.clientId,
clientSecret: this.config.clientSecret,
refreshToken: connection.refreshToken,
grantType: 'refresh_token',
});
} catch (error) {
// 리프레시 토큰까지 무효면 관리자가 다시 연결해야 한다
if (error instanceof ImwebHttpError && error.isClientError()) {
this.logger.warn(
`아임웹 토큰 갱신 거부 — 재연결 필요: status=${error.status} errorCode=${error.errorCode ?? '-'}`,
);
await this.disconnect();
throw new BusinessException('IMWEB_001', {
context: { reason: 'refresh_rejected', errorCode: error.errorCode },
});
}
throw this.toBusinessError(error);
}
Object.assign(connection, this.tokenFields(token, connection.refreshToken));
return this.connections.save(connection);
}
private tokenFields(
token: ImwebTokenData,
previousRefreshToken?: string,
): Pick<
ImwebConnection,
'accessToken' | 'refreshToken' | 'accessTokenExpiresAt' | 'scope'
> {
const expiresAtMs =
readJwtExpiryMs(token.accessToken) ??
Date.now() + ACCESS_TOKEN_FALLBACK_TTL_MS;
return {
accessToken: token.accessToken,
// 갱신 응답에 새 리프레시 토큰이 없으면 기존 것을 계속 쓴다
refreshToken: token.refreshToken || previousRefreshToken || '',
accessTokenExpiresAt: new Date(expiresAtMs),
scope: token.scope ?? null,
};
}
private isExpiringSoon(connection: ImwebConnection): boolean {
return (
connection.accessTokenExpiresAt.getTime() -
ACCESS_TOKEN_REFRESH_MARGIN_MS <=
Date.now()
);
}
private assertConfigured(): void {
if (!this.config.isConfigured()) {
throw new BusinessException('IMWEB_005', {
context: { reason: 'missing_imweb_env' },
});
}
}
private async callImweb<T>(call: () => Promise<T>): Promise<T> {
try {
return await call();
} catch (error) {
throw this.toBusinessError(error);
}
}
/** 아임웹 호출 실패를 사용자 응답용 에러 코드로 바꾼다 */
private toBusinessError(error: unknown): unknown {
if (error instanceof BusinessException) return error;
if (error instanceof ImwebHttpError) {
// 전역 필터는 5xx 의 context 를 남기지 않으므로 진단 정보는 여기서 남긴다 (토큰·본문 제외)
this.logger.warn(
`[IMWEB_002] ${error.path} status=${error.status} errorCode=${error.errorCode ?? '-'}`,
);
return new BusinessException('IMWEB_002', {
context: {
path: error.path,
status: error.status,
errorCode: error.errorCode,
},
});
}
return error;
}
}
@@ -0,0 +1,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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isRetryable(status: number): boolean {
return (
status === 0 ||
status === HTTP_TOO_MANY_REQUESTS ||
status >= HTTP_SERVER_ERROR
);
}
/**
* 아임웹 Open API 저수준 HTTP 클라이언트 — 토큰은 모른다(ImwebAuthService 가 넣어 준다).
*
* 로그에는 경로·상태·errorCode 만 남긴다. 요청/응답 본문에는 토큰·회원 개인정보가 있어 남기지 않는다.
*/
@Injectable()
export class ImwebHttpClient {
private readonly logger = new Logger(ImwebHttpClient.name);
async get<T>(
path: string,
query: Record<string, QueryValue>,
accessToken: string,
): Promise<T> {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== '') params.set(key, String(value));
}
const url = `${IMWEB_API_BASE_URL}${path}?${params.toString()}`;
return this.send<T>(path, url, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
});
}
async patchJson<T>(
path: string,
body: Record<string, unknown>,
accessToken: string,
): Promise<T> {
return this.send<T>(path, `${IMWEB_API_BASE_URL}${path}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
}
/** 토큰 발급처럼 application/x-www-form-urlencoded 로 보내야 하는 요청 */
async postForm<T>(path: string, form: Record<string, string>): Promise<T> {
return this.send<T>(path, `${IMWEB_API_BASE_URL}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(form).toString(),
});
}
private async send<T>(
path: string,
url: string,
init: RequestInit,
): Promise<T> {
let lastError: ImwebHttpError | null = null;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
if (attempt > 0) await sleep(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1));
try {
const response = await fetch(url, {
...init,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
const body = await this.readJson(response);
if (response.ok) {
return (body as ImwebEnvelope<T>).data;
}
lastError = new ImwebHttpError(
path,
response.status,
this.errorCode(body),
);
} catch (error) {
if (error instanceof ImwebHttpError) throw error;
// 네트워크 오류·타임아웃
lastError = new ImwebHttpError(path, 0, null);
this.logger.debug(`아임웹 네트워크 오류: ${path} ${String(error)}`);
}
if (!isRetryable(lastError.status) || attempt === MAX_RETRIES) break;
this.logger.warn(
`아임웹 API 재시도 ${attempt + 1}/${MAX_RETRIES}: ${path} status=${lastError.status}`,
);
}
throw lastError ?? new ImwebHttpError(path, 0, null);
}
private async readJson(response: Response): Promise<unknown> {
try {
return (await response.json()) as unknown;
} catch {
return null;
}
}
/**
* 아임웹 errorCode 를 꺼낸다. 에러 본문 형식이 문서에 정의돼 있지 않아
* 최상위(errorCode)와 중첩(error.errorCode / error.code) 둘 다 본다.
* 판단에는 HTTP 상태를 쓰고, 이 값은 로그·진단용으로만 쓴다.
*/
private errorCode(body: unknown): string | null {
if (typeof body !== 'object' || body === null) return null;
const top = body as ImwebErrorBody & {
error?: ImwebErrorBody & { code?: unknown };
};
const code: unknown =
top.errorCode ?? top.error?.errorCode ?? top.error?.code;
return typeof code === 'string' || typeof code === 'number'
? String(code)
: null;
}
}
@@ -0,0 +1,84 @@
import type { Repository } from 'typeorm';
import { ImwebMember } from './entities/imweb-member.entity';
import { ImwebSyncRun } from './entities/imweb-sync-run.entity';
import type { ImwebConfig } from './imweb.config';
import type { ImwebAuthService } from './imweb-auth.service';
import { ImwebMemberLookupService } from './imweb-member-lookup.service';
const API_MEMBER = {
memberCode: 'm20261005abcdef',
uid: 'new@test.dev',
name: '신규',
email: 'new@test.dev',
joinTime: '2026-10-05T03:00:00.000Z',
group: ['g1'],
};
function setup(local: ImwebMember | null = null, apiList = [API_MEMBER]) {
const members = {
findOne: jest.fn(() => Promise.resolve(local)),
upsert: jest.fn(() => Promise.resolve()),
};
const runs = { findOne: jest.fn(() => Promise.resolve({ id: 7 })) };
const auth = {
requireConnection: jest.fn(() => Promise.resolve({ unitCode: 'u-unit' })),
authorizedGet: jest.fn(() =>
Promise.resolve({ data: { list: apiList, totalPage: 1 } }),
),
};
const service = new ImwebMemberLookupService(
members as unknown as Repository<ImwebMember>,
runs as unknown as Repository<ImwebSyncRun>,
auth as unknown as ImwebAuthService,
{ trackingHost: 'utm.moneyguider.co.kr' } as ImwebConfig,
);
return { service, members, auth };
}
describe('ImwebMemberLookupService', () => {
it('동기화된 회원이면 아임웹을 부르지 않는다', async () => {
const local = { memberCode: 'm1', uid: 'a@test.dev' } as ImwebMember;
const { service, auth } = setup(local);
await expect(service.find('a@test.dev')).resolves.toBe(local);
expect(auth.authorizedGet).not.toHaveBeenCalled();
});
it('동기화 전 신규 회원은 아이디로 아임웹에 물어 찾고 저장해 둔다', async () => {
const { service, auth, members } = setup();
const found = await service.find(' new@test.dev ');
expect(auth.authorizedGet).toHaveBeenCalledWith('/member-info/members', {
page: 1,
limit: 1,
unitCode: 'u-unit',
memberUid: 'new@test.dev',
});
expect(found).toMatchObject({
memberCode: 'm20261005abcdef',
groupCodes: ['g1'],
lastSyncRunId: 7,
});
expect(members.upsert).toHaveBeenCalled();
});
it('회원 코드 형식이면 회원 코드로 묻는다', async () => {
const { service, auth } = setup();
await service.find('m20261005abcdef');
expect(auth.authorizedGet).toHaveBeenCalledWith(
'/member-info/members',
expect.objectContaining({ memberCode: 'm20261005abcdef' }),
);
});
it('아임웹에도 없거나 빈 값이면 null', async () => {
const { service } = setup(null, []);
await expect(service.find('ghost@test.dev')).resolves.toBeNull();
await expect(service.find(' ')).resolves.toBeNull();
});
});
@@ -0,0 +1,73 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ImwebMember } from './entities/imweb-member.entity';
import { ImwebSyncRun, SyncStatus } from './entities/imweb-sync-run.entity';
import { IMWEB_MEMBERS_PATH, ImwebConfig } from './imweb.config';
import { ImwebAuthService } from './imweb-auth.service';
import { ImwebMemberPageData } from './imweb.types';
import { toMemberRow } from './member-row.mapper';
/** 아임웹 회원 코드 형식 — 예) m20250402e3b6987310679 */
const MEMBER_CODE_PATTERN = /^m\d{8}[0-9a-z]{6,}$/i;
/**
* 회원 한 명 찾기 — 아이디(이메일 등) 또는 회원 코드로.
*
* 1) 동기화해 둔 imweb_members 에서 먼저 찾고
* 2) 없으면(동기화 전 신규 가입자) 아임웹 API 에 직접 물어 찾은 회원을 저장해 둔다.
* 다음 전체 동기화가 같은 행을 그대로 덮어쓰므로 중복이 생기지 않는다.
*/
@Injectable()
export class ImwebMemberLookupService {
constructor(
@InjectRepository(ImwebMember)
private readonly members: Repository<ImwebMember>,
@InjectRepository(ImwebSyncRun)
private readonly runs: Repository<ImwebSyncRun>,
private readonly auth: ImwebAuthService,
private readonly config: ImwebConfig,
) {}
async find(identifier: string): Promise<ImwebMember | null> {
const id = identifier.trim();
if (!id) return null;
const local = await this.members.findOne({
where: [{ uid: id }, { memberCode: id }],
});
if (local) return local;
const { unitCode } = await this.auth.requireConnection();
const byCode = MEMBER_CODE_PATTERN.test(id);
// 페이지 방식 목록 API 를 쓴다 — 회원 단건 API 와 달리 homePage·group 이 함께 온다
const { data } = await this.auth.authorizedGet<ImwebMemberPageData>(
IMWEB_MEMBERS_PATH,
{
page: 1,
limit: 1,
unitCode,
...(byCode ? { memberCode: id } : { memberUid: id }),
},
);
const found = data.list[0];
if (!found) return null;
// 마지막 성공 실행 번호로 저장해야 다음 동기화의 탈퇴 정리에 잘못 걸리지 않는다
const lastRun = await this.runs.findOne({
where: { status: SyncStatus.SUCCEEDED },
order: { id: 'DESC' },
});
const row = toMemberRow(
found,
lastRun?.id ?? 0,
new Date(),
this.config.trackingHost,
);
if (!row) return null;
await this.members.upsert(row, { conflictPaths: ['memberCode'] });
return row;
}
}
@@ -0,0 +1,93 @@
import { Controller, Get, Logger, Query, Res } from '@nestjs/common';
import { ApiExcludeController } from '@nestjs/swagger';
import type { Response } from 'express';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { SyncTrigger } from './entities/imweb-sync-run.entity';
import { ImwebConfig } from './imweb.config';
import { ImwebAuthService } from './imweb-auth.service';
import { ImwebSyncService } from './imweb-sync.service';
/** 관리자 화면으로 돌려보낼 때 결과를 알리는 쿼리 */
const RESULT_PARAM = 'imweb';
const ERROR_CODE_PARAM = 'code';
/** 아임웹이 동의 화면에서 거부할 때 넘겨주는 오류 코드 (예: 30156 권한 부족) */
const IMWEB_ERROR_PARAM = 'imwebCode';
const IMWEB_ERROR_CODE_PATTERN = /^d{1,10}$/;
const RESULT_CONNECTED = 'connected';
const RESULT_ERROR = 'error';
/**
* 아임웹 OAuth callback — 아임웹 동의 화면이 브라우저를 이 주소로 보낸다.
*
* 사람이 보는 화면 이동이라 JSON 대신 관리자 화면으로 리다이렉트한다.
* 관리자 로그인 가드는 걸지 않는다 — 시작할 때 발급한 일회용 state 로 요청을 확인한다.
*/
@ApiExcludeController()
@SkipTransform()
@Controller('imweb/oauth')
export class ImwebOAuthController {
private readonly logger = new Logger(ImwebOAuthController.name);
constructor(
private readonly auth: ImwebAuthService,
private readonly sync: ImwebSyncService,
private readonly config: ImwebConfig,
) {}
@Get('callback')
async callback(
@Query('code') code: string | undefined,
@Query('state') state: string | undefined,
@Query('errorCode') imwebErrorCode: string | undefined,
@Res() res: Response,
): Promise<void> {
const target = this.config.adminPageUrl;
if (!target) throw new BusinessException('IMWEB_005');
// 아임웹이 인가를 거부하면 code 대신 errorCode·message 를 붙여 돌려보낸다
if (!code && imwebErrorCode) {
const safeCode = IMWEB_ERROR_CODE_PATTERN.test(imwebErrorCode)
? imwebErrorCode
: undefined;
this.logger.warn(
`[IMWEB_003] 아임웹 인가 거부: errorCode=${safeCode ?? '-'}`,
);
res.redirect(
this.withResult(target, RESULT_ERROR, 'IMWEB_003', safeCode),
);
return;
}
try {
await this.auth.completeAuthorization(code, state);
} catch (error) {
const errorCode =
error instanceof BusinessException ? error.code : 'SYS_001';
this.logger.warn(`[${errorCode}] 아임웹 연결 실패`);
res.redirect(this.withResult(target, RESULT_ERROR, errorCode));
return;
}
// 연결하자마자 첫 동기화를 돌린다 — 끝까지 기다리지 않는다
this.sync.start(SyncTrigger.MANUAL).catch((error: unknown) => {
this.logger.warn(`연결 직후 동기화 시작 실패: ${String(error)}`);
});
res.redirect(this.withResult(target, RESULT_CONNECTED));
}
private withResult(
target: string,
result: string,
errorCode?: string,
imwebErrorCode?: string,
): string {
const url = new URL(target);
url.searchParams.set(RESULT_PARAM, result);
if (errorCode) url.searchParams.set(ERROR_CODE_PARAM, errorCode);
if (imwebErrorCode) url.searchParams.set(IMWEB_ERROR_PARAM, imwebErrorCode);
return url.toString();
}
}
@@ -0,0 +1,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<ImwebSyncRun>) => row),
save: jest.fn((row: ImwebSyncRun) => {
row.id ??= 10;
savedRuns.push({ ...row });
return Promise.resolve(row);
}),
update: jest.fn(() => Promise.resolve()),
findOne: jest.fn(() =>
Promise.resolve(
options.previousSucceededRunId
? { id: options.previousSucceededRunId, status: SyncStatus.SUCCEEDED }
: null,
),
),
};
const imports = {
find: jest.fn(
(): Promise<Pick<ImwebMemberInflowImport, 'memberCode' | 'rawValue'>[]> =>
Promise.resolve([]),
),
};
// 회원 목록 응답은 테스트마다 memberPages 로 정하고, 그룹 목록은 고정 응답을 준다
const memberPages = jest.fn();
const groupPage = {
list: [
{ siteGroupCode: 'g-free', title: ' 9/7 무료강의 그룹 ' },
{ siteGroupCode: 'g-voucher', title: '수출바우처+' },
],
totalCount: 2,
totalPage: 1,
currentPage: 1,
};
const groups = {
upsert: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve({ affected: 0 })),
};
const auth = {
requireConnection: jest.fn(() =>
Promise.resolve({ unitCode: 'u-unit', siteCode: 'S-site' }),
),
authorizedGet: jest.fn(
(path: string, query: object): Promise<unknown> =>
path === '/member-info/groups'
? Promise.resolve({ data: groupPage })
: (memberPages(path, query) as Promise<unknown>),
),
};
const config = { pageSize: 2, trackingHost: HOST };
const service = new ImwebSyncService(
members as unknown as Repository<ImwebMember>,
runs as unknown as Repository<ImwebSyncRun>,
imports as unknown as Repository<ImwebMemberInflowImport>,
groups as unknown as Repository<ImwebGroup>,
auth as unknown as ImwebAuthService,
config as unknown as ImwebConfig,
{} as SchedulerRegistry,
);
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']);
});
});
@@ -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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 아임웹 회원 → 우리 DB 전체 동기화.
*
* 아임웹 회원 목록 API 중 homePage(유입정보)를 주는 것은 페이지 방식뿐이라
* 변경분만 가져올 수 없고 매번 전체를 훑는다. 주기는 IMWEB_SYNC_INTERVAL_MINUTES (기본 60분).
*/
@Injectable()
export class ImwebSyncService
implements OnApplicationBootstrap, OnApplicationShutdown
{
private readonly logger = new Logger(ImwebSyncService.name);
/**
* 단일 인스턴스 기준 잠금 — 여러 인스턴스로 늘리면 DB 잠금으로 바꿔야 한다.
* await 전에 동기적으로 세워야 버튼을 연달아 눌러도 두 번 시작되지 않는다.
*/
private busy = false;
/** 진행 중인 동기화 — 종료 시 기다리는 용도 */
private running: Promise<void> | null = null;
constructor(
@InjectRepository(ImwebMember)
private readonly members: Repository<ImwebMember>,
@InjectRepository(ImwebSyncRun)
private readonly runs: Repository<ImwebSyncRun>,
@InjectRepository(ImwebMemberInflowImport)
private readonly imports: Repository<ImwebMemberInflowImport>,
@InjectRepository(ImwebGroup)
private readonly groups: Repository<ImwebGroup>,
private readonly auth: ImwebAuthService,
private readonly config: ImwebConfig,
private readonly scheduler: SchedulerRegistry,
) {}
async onApplicationBootstrap(): Promise<void> {
// 서버가 동기화 도중 꺼졌다면 그 기록은 영원히 running 으로 남는다 — 실패로 정리한다
await this.runs.update(
{ status: SyncStatus.RUNNING },
{
status: SyncStatus.FAILED,
finishedAt: new Date(),
errorCode: FALLBACK_ERROR_CODE,
},
);
const timer = setInterval(() => {
void this.runScheduled();
}, this.config.syncIntervalMs);
this.scheduler.addInterval(SCHEDULE_NAME, timer);
}
async onApplicationShutdown(): Promise<void> {
if (this.scheduler.doesExist('interval', SCHEDULE_NAME)) {
this.scheduler.deleteInterval(SCHEDULE_NAME);
}
// 진행 중인 동기화가 끝나길 기다려 DB 커넥션이 먼저 닫히지 않게 한다
await this.waitForIdle();
}
/** 진행 중인 동기화가 끝날 때까지 기다린다 */
async waitForIdle(): Promise<void> {
await this.running?.catch(() => undefined);
}
/**
* 동기화를 시작하고 실행 기록을 바로 돌려준다 (끝까지 기다리지 않는다).
* 이미 진행 중이면 IMWEB_004.
*/
async start(trigger: SyncTrigger): Promise<ImwebSyncRun> {
if (this.busy) throw new BusinessException('IMWEB_004');
this.busy = true;
try {
await this.auth.requireConnection(); // 연결 전이면 IMWEB_001 로 바로 알려준다
const run = await this.runs.save(
this.runs.create({
trigger,
status: SyncStatus.RUNNING,
startedAt: new Date(),
finishedAt: null,
fetchedCount: 0,
removedCount: 0,
errorCode: null,
}),
);
this.running = this.execute(run).finally(() => {
this.running = null;
this.busy = false;
});
return run;
} catch (error) {
this.busy = false;
throw error;
}
}
/** 가장 최근 실행 기록 */
findLatestRun(): Promise<ImwebSyncRun | null> {
return this.runs.findOne({ where: {}, order: { id: 'DESC' } });
}
/** 마지막으로 성공한 실행 기록 */
findLatestSucceededRun(): Promise<ImwebSyncRun | null> {
return this.runs.findOne({
where: { status: SyncStatus.SUCCEEDED },
order: { id: 'DESC' },
});
}
private async runScheduled(): Promise<void> {
if (this.busy) return;
const connection = this.config.isConfigured()
? await this.auth.findConnection()
: null;
if (!connection) return; // 아직 연결 전이면 조용히 건너뛴다
try {
await this.start(SyncTrigger.SCHEDULE);
} catch (error) {
this.logger.warn(`예약 동기화 시작 실패: ${String(error)}`);
}
}
private async execute(run: ImwebSyncRun): Promise<void> {
const startedAt = Date.now();
try {
await this.syncGroups();
const fetched = await this.fetchAll(run);
run.fetchedCount = fetched;
run.removedCount = await this.removeDeparted(run, fetched);
run.status = SyncStatus.SUCCEEDED;
this.logger.log(
`회원 동기화 완료: run=${run.id} fetched=${fetched} removed=${run.removedCount} ${Date.now() - startedAt}ms`,
);
} catch (error) {
run.status = SyncStatus.FAILED;
run.errorCode =
error instanceof BusinessException ? error.code : FALLBACK_ERROR_CODE;
this.logger.error(
`[${run.errorCode}] 회원 동기화 실패: run=${run.id}`,
error instanceof Error ? error.stack : String(error),
);
} finally {
run.finishedAt = new Date();
await this.runs.save(run);
}
}
/**
* 회원 그룹 목록(코드 → 이름)을 아임웹과 맞춘다. 아임웹에서 지운 그룹은 여기서도 지운다.
* 그룹은 수십 개 수준이라 한 번에 받는다.
*/
private async syncGroups(): Promise<void> {
const syncedAt = new Date();
const rows: ImwebGroup[] = [];
for (let page = 1; page <= MAX_PAGES; page += 1) {
const { data } = await this.auth.authorizedGet<ImwebGroupPageData>(
IMWEB_GROUPS_PATH,
{ page, limit: GROUP_PAGE_SIZE },
);
for (const group of data.list) {
if (!group.siteGroupCode) continue;
rows.push({
groupCode: group.siteGroupCode,
title:
(group.title ?? '').trim().slice(0, GROUP_TITLE_MAX_LENGTH) ||
group.siteGroupCode,
sortOrder: rows.length,
syncedAt,
});
}
if (data.list.length === 0 || page >= data.totalPage) break;
}
if (rows.length > 0) {
await this.groups.upsert(rows, { conflictPaths: ['groupCode'] });
}
// 아임웹에서 사라진 그룹 정리 — 받은 목록이 비었으면 응답 이상으로 보고 건너뛴다
if (rows.length > 0) {
await this.groups.delete({
groupCode: Not(In(rows.map((row) => row.groupCode))),
});
}
}
/** 아임웹 회원 전체를 페이지 단위로 받아 저장한다. 받은 회원 수를 돌려준다. */
private async fetchAll(run: ImwebSyncRun): Promise<number> {
const syncedAt = new Date();
const { unitCode } = await this.auth.requireConnection();
let fetched = 0;
for (let page = 1; page <= MAX_PAGES; page += 1) {
if (page > 1) await sleep(PAGE_DELAY_MS);
const { data } = await this.auth.authorizedGet<ImwebMemberPageData>(
IMWEB_MEMBERS_PATH,
{ page, limit: this.config.pageSize, unitCode },
);
// homePage 기록 전 가입자는 엑셀에서 가져온 유입경로로 채운다
const imported = await this.findImported(
data.list.map((m) => m.memberCode),
);
const rows = data.list
.map((member) =>
toMemberRow(
member,
run.id,
syncedAt,
this.config.trackingHost,
imported.get(member.memberCode) ?? null,
),
)
.filter((row): row is ImwebMember => row !== null);
if (rows.length > 0) {
await this.members.upsert(rows, { conflictPaths: ['memberCode'] });
}
fetched += data.list.length;
// 진행 상황을 관리자 화면에서 볼 수 있게 남긴다
run.fetchedCount = fetched;
await this.runs.update(run.id, { fetchedCount: fetched });
if (data.list.length === 0 || page >= data.totalPage) break;
}
return fetched;
}
/** 엑셀에서 가져온 '유입경로' 원본 — 회원 코드 → 값 */
private async findImported(
memberCodes: string[],
): Promise<Map<string, string>> {
const codes = memberCodes.filter(Boolean);
if (codes.length === 0) return new Map();
const rows = await this.imports.find({
select: { memberCode: true, rawValue: true },
where: { memberCode: In(codes) },
});
return new Map(rows.map((row) => [row.memberCode, row.rawValue]));
}
/**
* 탈퇴 회원 정리 — **두 번 연속** 전체 동기화에서 보이지 않은 회원만 지운다.
*
* 페이지 방식은 동기화 도중 가입·탈퇴가 생기면 순서가 밀려 한두 명을 건너뛸 수 있다.
* 한 번 못 봤다고 지우면 멀쩡한 회원이 사라졌다 다음 동기화에 다시 생기므로,
* 직전 성공 실행에서도 못 본 회원만 지운다.
*/
private async removeDeparted(
run: ImwebSyncRun,
fetched: number,
): Promise<number> {
// 아무것도 못 받았는데 지우면 전체가 날아간다 — 응답 이상으로 보고 건너뛴다
if (fetched === 0) return 0;
const previous = await this.runs.findOne({
where: { status: SyncStatus.SUCCEEDED, id: LessThan(run.id) },
order: { id: 'DESC' },
});
if (!previous) return 0;
const result = await this.members.delete({
lastSyncRunId: LessThan(previous.id),
});
return result.affected ?? 0;
}
}
+104
View File
@@ -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<string>('IMWEB_SYNC_INTERVAL_MINUTES'),
DEFAULT_SYNC_INTERVAL_MINUTES,
);
return minutes * 60_000;
}
get pageSize(): number {
return parsePositiveInt(
this.config.get<string>('IMWEB_PAGE_SIZE'),
DEFAULT_PAGE_SIZE,
);
}
/** OAuth 연결에 필요한 값이 모두 있는가 */
isConfigured(): boolean {
return [
this.clientId,
this.clientSecret,
this.siteCode,
this.redirectUri,
this.adminPageUrl,
].every((value) => value !== '');
}
private read(key: string): string {
return (this.config.get<string>(key) ?? '').trim();
}
}
+42
View File
@@ -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 {}
+77
View File
@@ -0,0 +1,77 @@
/**
* 아임웹 Open API 응답 타입 — openapi.json(developers-docs.imweb.me/reference) 기준.
* 쓰는 필드만 옮겼다. 아임웹이 필드를 추가해도 무시하면 되므로 깨지지 않는다.
*/
/** 모든 응답의 공통 껍데기 */
export interface ImwebEnvelope<T> {
statusCode: number;
data: T;
}
/** POST /oauth2/token */
export interface ImwebTokenData {
accessToken: string;
refreshToken: string;
scope?: string;
}
/** GET /site-info */
export interface ImwebSiteInfoData {
siteCode: string;
unitList: { unitCode: string; name?: string }[];
}
/** GET /member-info/members — ReadMemberResponseDto */
export interface ImwebMemberData {
memberCode: string;
uid: string;
name: string;
email: string;
callnum?: string | null;
homePage?: string | null;
joinTime: string;
lastLoginTime?: string | null;
/** 소속 회원 그룹 코드 목록 (운영진 그룹은 포함되지 않는다) */
group?: string[] | null;
socialLogin?: Partial<
Record<
'googleId' | 'facebookId' | 'kakaoId' | 'naverId' | 'appleId' | 'lineId',
string | null
>
> | null;
}
/** GET /member-info/groups — 회원 그룹 한 개 */
export interface ImwebGroupData {
siteGroupCode: string;
title: string;
}
/** GET /member-info/groups — 목록 (페이지 방식) */
export interface ImwebGroupPageData {
totalCount: number;
totalPage: number;
currentPage: number;
list: ImwebGroupData[];
}
/** GET /member-info/members — ReadAllMembersResponseDto */
export interface ImwebMemberPageData {
totalCount: number;
totalPage: number;
currentPage: number;
pageSize: number;
list: ImwebMemberData[];
}
/**
* 아임웹 에러 응답 — 문서의 Error List (statusCode / errorCode / message).
* 본문 구조는 문서에 정의돼 있지 않아 모든 필드를 선택으로 둔다.
* 토큰 무효(30101)·만료(30102)는 HTTP 401 로 온다.
*/
export interface ImwebErrorBody {
statusCode?: number;
errorCode?: number | string;
message?: string;
}
@@ -0,0 +1,77 @@
import { extractImportRows } from './inflow-import';
/** 실제 아임웹 회원 엑셀과 같은 열 구성 (일부) */
const HEADER = [
'언어',
'고유키',
'이메일',
'아이디',
'이름',
'유입경로',
'가입일',
];
describe('extractImportRows', () => {
it('고유키·유입경로만 뽑고 나머지 열(개인정보)은 버린다', () => {
const result = extractImportRows([
HEADER,
[
'KR',
'm001',
'a@test.dev',
'a@test.dev',
'홍길동',
' src=facebook|med=paid|jd=2026-09-01 ',
'2026-09-01 10:00:00',
],
[
'KR',
'm002',
'b@test.dev',
'b@test.dev',
'김철수',
'src=google|med=organic|jd=2026-09-02',
'2026-09-02 11:00:00',
],
]);
expect(result.rows).toEqual([
{ memberCode: 'm001', rawValue: 'src=facebook|med=paid|jd=2026-09-01' },
{ memberCode: 'm002', rawValue: 'src=google|med=organic|jd=2026-09-02' },
]);
expect(JSON.stringify(result)).not.toContain('홍길동');
});
it('고유키나 유입경로가 빈 행은 건너뛴다', () => {
const result = extractImportRows([
HEADER,
['KR', 'm001', '', '', '', '', ''],
['KR', null, '', '', '', 'src=naver', ''],
['KR', 'm003', '', '', '', 'src=naver|jd=2026-09-01', ''],
]);
expect(result.rows).toHaveLength(1);
expect(result.skippedEmpty).toBe(2);
});
it('같은 고유키가 여러 번 나오면 마지막 값을 쓰고 개수를 센다', () => {
const result = extractImportRows([
HEADER,
['KR', 'm001', '', '', '', 'src=naver', ''],
['KR', 'm001', '', '', '', 'src=google', ''],
]);
expect(result.rows).toEqual([
{ memberCode: 'm001', rawValue: 'src=google' },
]);
expect(result.duplicates).toBe(1);
});
it('필요한 열이 없으면 다른 엑셀로 보고 거부한다', () => {
expect(() =>
extractImportRows([
['이름', '연락처'],
['홍길동', '010'],
]),
).toThrow(/고유키, 유입경로/);
expect(() => extractImportRows([])).toThrow(/필요한 열이 없습니다/);
});
});
+177
View File
@@ -0,0 +1,177 @@
import { EntityManager, In } from 'typeorm';
import { InflowOrigin, InflowStatus } from '@/shared/utils/inflow';
import { ImwebMemberInflowImport } from './entities/imweb-member-inflow-import.entity';
import { ImwebMember } from './entities/imweb-member.entity';
import { resolveInflowColumns } from './member-row.mapper';
/**
* 아임웹 회원 엑셀 → '유입경로' 가져오기.
*
* 아임웹 관리자 > 회원 > 엑셀 다운로드 파일 기준. 확장자는 .xls 지만 실제 형식은 xlsx 다.
* 필요한 열은 둘뿐이고 나머지(이름·연락처 등 개인정보)는 읽기만 하고 버린다.
*/
export const IMPORT_COLUMNS = {
/** 아임웹 회원 코드 (API 의 memberCode 와 같다) */
memberCode: '고유키',
/** 사용자 정의 가입 항목 — UTM 스크립트가 파이프 형식으로 기록 */
inflow: '유입경로',
} as const;
const MEMBER_CODE_MAX_LENGTH = 40;
/** DB 왕복을 줄이되 IN 절이 너무 길어지지 않게 나눠 처리한다 */
const CHUNK_SIZE = 500;
export interface ImportRow {
memberCode: string;
rawValue: string;
}
export interface ExtractResult {
rows: ImportRow[];
/** 고유키나 유입경로가 비어 건너뛴 행 */
skippedEmpty: number;
/** 같은 고유키가 여러 번 나와 뒤의 것으로 덮은 횟수 */
duplicates: number;
}
/** 셀 값을 문자열로 — 엑셀 라이브러리가 숫자·날짜로 줄 수도 있다 */
function text(cell: unknown): string {
if (typeof cell === 'string') return cell.trim();
if (typeof cell === 'number' || typeof cell === 'boolean') {
return String(cell);
}
if (cell instanceof Date) return cell.toISOString();
return '';
}
/**
* 시트 행(첫 행 = 열 이름)에서 고유키·유입경로만 뽑는다 (순수 함수).
* 필요한 열이 없으면 다른 엑셀을 넣은 것이므로 예외를 던진다.
*/
export function extractImportRows(
sheet: readonly (readonly unknown[])[],
): ExtractResult {
const [header = [], ...data] = sheet;
const titles = header.map(text);
const codeIndex = titles.indexOf(IMPORT_COLUMNS.memberCode);
const inflowIndex = titles.indexOf(IMPORT_COLUMNS.inflow);
const missing: string[] = [];
if (codeIndex < 0) missing.push(IMPORT_COLUMNS.memberCode);
if (inflowIndex < 0) missing.push(IMPORT_COLUMNS.inflow);
if (missing.length > 0) {
throw new Error(
`엑셀에 필요한 열이 없습니다: ${missing.join(', ')} (아임웹 회원 엑셀인지 확인해 주세요)`,
);
}
const byCode = new Map<string, string>();
let skippedEmpty = 0;
let duplicates = 0;
for (const row of data) {
const memberCode = text(row[codeIndex]);
const rawValue = text(row[inflowIndex]);
if (
!memberCode ||
!rawValue ||
memberCode.length > MEMBER_CODE_MAX_LENGTH
) {
skippedEmpty += 1;
continue;
}
if (byCode.has(memberCode)) duplicates += 1;
byCode.set(memberCode, rawValue);
}
return {
rows: [...byCode].map(([memberCode, rawValue]) => ({
memberCode,
rawValue,
})),
skippedEmpty,
duplicates,
};
}
export interface ApplyResult {
/** 가져온(보관한) 행 */
stored: number;
/** DB 회원과 매칭된 행 — 아직 동기화 안 된 회원은 다음 동기화 때 붙는다 */
matched: number;
/** 매칭된 회원 중 엑셀 값이 적용된 수 (homePage 값이 있는 회원은 그대로 둔다) */
appliedFromImport: number;
/** 적용된 값의 기록 상태 */
atJoin: number;
afterJoin: number;
/** 스크립트 형식이 아니라 풀 수 없었던 값 */
unparsable: number;
}
/**
* 가져온 행을 보관하고, 이미 동기화된 회원에는 바로 반영한다.
* 같은 고유키는 덮어쓰므로 같은 파일을 다시 가져와도 안전하다.
* 트랜잭션 안에서 부른다 — 중간에 실패하면 아무것도 바뀌지 않아야 한다.
*/
export async function applyInflowImport(
manager: EntityManager,
rows: ImportRow[],
sourceFile: string,
trackingHost: string,
importedAt: Date = new Date(),
): Promise<ApplyResult> {
const result: ApplyResult = {
stored: 0,
matched: 0,
appliedFromImport: 0,
atJoin: 0,
afterJoin: 0,
unparsable: 0,
};
for (let i = 0; i < rows.length; i += CHUNK_SIZE) {
const chunk = rows.slice(i, i + CHUNK_SIZE);
await manager.upsert(
ImwebMemberInflowImport,
chunk.map((row) => ({ ...row, sourceFile, importedAt })),
['memberCode'],
);
result.stored += chunk.length;
const members = await manager.find(ImwebMember, {
select: { memberCode: true, joinTime: true, homePage: true },
where: { memberCode: In(chunk.map((row) => row.memberCode)) },
});
const rawByCode = new Map(
chunk.map((row) => [row.memberCode, row.rawValue]),
);
for (const member of members) {
result.matched += 1;
const columns = resolveInflowColumns(
member.homePage,
rawByCode.get(member.memberCode),
member.joinTime.toISOString(),
trackingHost,
);
if (columns.inflowOrigin === InflowOrigin.IMPORT) {
result.appliedFromImport += 1;
if (columns.inflowStatus === InflowStatus.AT_JOIN) result.atJoin += 1;
if (columns.inflowStatus === InflowStatus.AFTER_JOIN)
result.afterJoin += 1;
} else if (columns.inflowOrigin === null) {
result.unparsable += 1;
}
// homePage 값이 있는 회원은 결과가 같으므로 다시 써도 무방하다
await manager.update(
ImwebMember,
{ memberCode: member.memberCode },
columns,
);
}
}
return result;
}
@@ -0,0 +1,84 @@
import { InflowStatus } from '@/shared/utils/inflow';
import { ImwebMemberData } from './imweb.types';
import { toGroupCodes, toMemberRow } from './member-row.mapper';
const HOST = 'utm.moneyguider.co.kr';
const SYNCED_AT = new Date('2026-10-05T05:00:00.000Z');
const base: ImwebMemberData = {
memberCode: 'm20261005abc',
uid: 'user@example.com',
name: ' 홍길동 ',
email: 'user@example.com',
callnum: '01012345678',
homePage: `https://${HOST}/?src=naver&med=cpc&jd=2026-10-05&cmp=fall`,
joinTime: '2026-10-05T03:00:00.000Z',
lastLoginTime: '2026-10-05T04:00:00.000Z',
};
describe('toMemberRow', () => {
it('회원 정보와 유입정보를 한 행으로 만든다', () => {
const row = toMemberRow(base, 7, SYNCED_AT, HOST);
expect(row).toMatchObject({
memberCode: 'm20261005abc',
name: '홍길동',
joinTime: new Date('2026-10-05T03:00:00.000Z'),
inflowStatus: InflowStatus.AT_JOIN,
inflowSource: 'naver',
inflowMedium: 'cpc',
inflowCampaign: 'fall',
inflowRecordedDate: '2026-10-05',
lastSyncRunId: 7,
syncedAt: SYNCED_AT,
});
});
it('소셜 로그인 정보로 가입 경로를 정한다', () => {
const row = toMemberRow(
{ ...base, socialLogin: { googleId: null, kakaoId: '12345' } },
1,
SYNCED_AT,
HOST,
);
expect(row?.socialProvider).toBe('kakao');
});
it('소셜 정보가 없으면 null', () => {
expect(toMemberRow(base, 1, SYNCED_AT, HOST)?.socialProvider).toBeNull();
});
it('빈 문자열 필드는 null 로 저장한다', () => {
const row = toMemberRow(
{ ...base, email: '', callnum: ' ', homePage: '' },
1,
SYNCED_AT,
HOST,
);
expect(row?.email).toBeNull();
expect(row?.callnum).toBeNull();
expect(row?.homePage).toBeNull();
expect(row?.inflowStatus).toBe(InflowStatus.NONE);
});
it('가입 시각을 읽을 수 없으면 저장하지 않는다', () => {
expect(
toMemberRow({ ...base, joinTime: 'invalid' }, 1, SYNCED_AT, HOST),
).toBeNull();
});
});
describe('toGroupCodes', () => {
it('문자열 코드만 남기고 중복·빈 값을 지운다', () => {
expect(toGroupCodes([' g1 ', 'g2', 'g1', '', 3, null])).toEqual([
'g1',
'g2',
]);
});
it('배열이 아니면 빈 배열', () => {
expect(toGroupCodes(null)).toEqual([]);
expect(toGroupCodes(undefined)).toEqual([]);
expect(toGroupCodes('g1')).toEqual([]);
});
});
@@ -0,0 +1,156 @@
import {
InflowOrigin,
InflowStatus,
ParsedInflow,
parseInflow,
} from '@/shared/utils/inflow';
import { ImwebMember } from './entities/imweb-member.entity';
import { ImwebMemberData } from './imweb.types';
/** 엔티티 varchar 길이와 맞춘 자르기 한도 */
const UID_MAX_LENGTH = 255;
const NAME_MAX_LENGTH = 100;
const EMAIL_MAX_LENGTH = 255;
const CALLNUM_MAX_LENGTH = 30;
/** 그룹 코드 길이·개수 상한 — 이상한 응답이 와도 행이 비대해지지 않게 */
const GROUP_CODE_MAX_LENGTH = 40;
const GROUP_MAX_COUNT = 50;
/** 아임웹 socialLogin 필드 → 저장할 가입 경로 이름 (먼저 나오는 것을 쓴다) */
const SOCIAL_PROVIDERS = [
['kakaoId', 'kakao'],
['naverId', 'naver'],
['googleId', 'google'],
['appleId', 'apple'],
['facebookId', 'facebook'],
['lineId', 'line'],
] as const;
function clip(value: string | null | undefined, max: number): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed.slice(0, max) : null;
}
function toDate(iso: string | null | undefined): Date | null {
if (!iso) return null;
const time = Date.parse(iso);
return Number.isNaN(time) ? null : new Date(time);
}
/** 소속 그룹 코드 — 문자열만, 중복 없이 */
export function toGroupCodes(groups: unknown): string[] {
if (!Array.isArray(groups)) return [];
const codes = groups
.filter((code): code is string => typeof code === 'string')
.map((code) => code.trim())
.filter((code) => code !== '' && code.length <= GROUP_CODE_MAX_LENGTH);
return [...new Set(codes)].slice(0, GROUP_MAX_COUNT);
}
function socialProvider(member: ImwebMemberData): string | null {
const social = member.socialLogin;
if (!social) return null;
const found = SOCIAL_PROVIDERS.find(([key]) => Boolean(social[key]));
return found ? found[1] : null;
}
/** imweb_members 의 유입정보 컬럼들 */
export type MemberInflowColumns = Pick<
ImwebMember,
| 'inflowOrigin'
| 'inflowStatus'
| 'inflowSource'
| 'inflowMedium'
| 'inflowCampaign'
| 'inflowContent'
| 'inflowTerm'
| 'inflowClickId'
| 'inflowReferrer'
| 'inflowLandingPath'
| 'inflowProductId'
| 'inflowFirstTouchDate'
| 'inflowLastTouch'
| 'inflowRecordedDate'
>;
function toColumns(
inflow: ParsedInflow,
origin: InflowOrigin | null,
): MemberInflowColumns {
return {
inflowOrigin: origin,
inflowStatus: inflow.status,
inflowSource: inflow.source,
inflowMedium: inflow.medium,
inflowCampaign: inflow.campaign,
inflowContent: inflow.content,
inflowTerm: inflow.term,
inflowClickId: inflow.clickId,
inflowReferrer: inflow.referrer,
inflowLandingPath: inflow.landingPath,
inflowProductId: inflow.productId,
inflowFirstTouchDate: inflow.firstTouchDate,
inflowLastTouch: inflow.lastTouch,
inflowRecordedDate: inflow.recordedDate,
};
}
/**
* 회원의 유입정보를 정한다 (순수 함수).
* homePage(가입 폼 '홈페이지')에 유입정보가 있으면 그것을, 없으면 엑셀에서 가져온 값을 쓴다.
* 둘 다 같은 스크립트가 같은 순간에 쓴 값이라 겹치면 homePage 를 믿는다.
*/
export function resolveInflowColumns(
homePage: string | null | undefined,
importedValue: string | null | undefined,
joinTimeIso: string,
trackingHost: string,
): MemberInflowColumns {
const fromHomePage = parseInflow(homePage, joinTimeIso, trackingHost);
if (fromHomePage.status !== InflowStatus.NONE) {
return toColumns(fromHomePage, InflowOrigin.HOMEPAGE);
}
const fromImport = parseInflow(importedValue, joinTimeIso, trackingHost);
if (fromImport.status !== InflowStatus.NONE) {
return toColumns(fromImport, InflowOrigin.IMPORT);
}
return toColumns(fromHomePage, null);
}
/**
* 아임웹 회원 한 명을 저장할 행으로 바꾼다 (순수 함수).
* 가입 시각을 읽을 수 없으면 null — 유입 판별·정렬이 불가능하므로 저장하지 않는다.
*
* @param importedValue 엑셀에서 가져온 '유입경로' 원본 (없으면 null)
*/
export function toMemberRow(
member: ImwebMemberData,
syncRunId: number,
syncedAt: Date,
trackingHost: string,
importedValue: string | null = null,
): ImwebMember | null {
const joinTime = toDate(member.joinTime);
if (!member.memberCode || !joinTime) return null;
return {
memberCode: member.memberCode,
uid: clip(member.uid, UID_MAX_LENGTH) ?? member.memberCode,
name: clip(member.name, NAME_MAX_LENGTH) ?? '',
email: clip(member.email, EMAIL_MAX_LENGTH),
callnum: clip(member.callnum, CALLNUM_MAX_LENGTH),
socialProvider: socialProvider(member),
joinTime,
lastLoginTime: toDate(member.lastLoginTime),
groupCodes: toGroupCodes(member.group),
homePage: member.homePage?.trim() || null,
...resolveInflowColumns(
member.homePage,
importedValue,
member.joinTime,
trackingHost,
),
lastSyncRunId: syncRunId,
syncedAt,
};
}
@@ -0,0 +1,118 @@
import { ApiPropertyOptional, IntersectionType } from '@nestjs/swagger';
import {
IsDateString,
IsEnum,
IsIn,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { INFLOW_VALUE_MAX_LENGTH, InflowStatus } from '@/shared/utils/inflow';
const SEARCH_MAX_LENGTH = 100;
const GROUP_CODE_MAX_LENGTH = 40;
/** 목록·집계가 함께 쓰는 필터 */
export class MemberFilterDto {
@ApiPropertyOptional({
description: '가입 시각 시작 (이상, ISO 8601)',
example: '2026-09-30T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '가입 시작일 형식이 올바르지 않습니다.' })
joinedFrom?: string;
@ApiPropertyOptional({
description: '가입 시각 끝 (미만, ISO 8601)',
example: '2026-10-31T15:00:00.000Z',
})
@IsOptional()
@IsDateString({}, { message: '가입 종료일 형식이 올바르지 않습니다.' })
joinedTo?: string;
@ApiPropertyOptional({
description: '유입 기록 상태',
enum: InflowStatus,
example: InflowStatus.AT_JOIN,
})
@IsOptional()
@IsEnum(InflowStatus, { message: '유입 기록 상태 값이 올바르지 않습니다.' })
inflowStatus?: InflowStatus;
@ApiPropertyOptional({ description: '유입원 (utm_source)', example: 'naver' })
@IsOptional()
@IsString()
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
source?: string;
@ApiPropertyOptional({ description: '매체 (utm_medium)', example: 'cpc' })
@IsOptional()
@IsString()
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
medium?: string;
@ApiPropertyOptional({
description: '캠페인 (utm_campaign)',
example: 'fall',
})
@IsOptional()
@IsString()
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
campaign?: string;
@ApiPropertyOptional({
description: '콘텐츠 (utm_content)',
example: 'banner_a',
})
@IsOptional()
@IsString()
@MaxLength(INFLOW_VALUE_MAX_LENGTH)
content?: string;
@ApiPropertyOptional({
description: '회원 그룹 코드 (GET /admin/members/groups 의 code)',
example: 'g202609074ed64993dde35',
})
@IsOptional()
@IsString()
@MaxLength(GROUP_CODE_MAX_LENGTH)
groupCode?: string;
}
/** 회원 목록 조회 — 커서 페이지네이션 + 공통 필터 + 검색어 */
export class MemberListQueryDto extends IntersectionType(
CursorPaginationQueryDto,
MemberFilterDto,
) {
@ApiPropertyOptional({
description: '검색어 — 이름·아이디·이메일·연락처 부분 일치',
example: '홍길동',
})
@IsOptional()
@IsString()
@MaxLength(SEARCH_MAX_LENGTH, { message: '검색어가 너무 깁니다.' })
q?: string;
}
/** 집계 기준 */
export const INFLOW_GROUP_BY = [
'source',
'medium',
'campaign',
'content',
] as const;
export type InflowGroupBy = (typeof INFLOW_GROUP_BY)[number];
/** 유입원별 집계 */
export class InflowStatsQueryDto extends MemberFilterDto {
@ApiPropertyOptional({
description: '집계 기준',
enum: INFLOW_GROUP_BY,
default: 'source',
})
@IsOptional()
@IsIn(INFLOW_GROUP_BY, { message: '집계 기준 값이 올바르지 않습니다.' })
groupBy: InflowGroupBy = 'source';
}
@@ -0,0 +1,262 @@
import { ApiProperty } from '@nestjs/swagger';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { InflowOrigin, InflowStatus } from '@/shared/utils/inflow';
import { INFLOW_GROUP_BY } from './member-query.dto';
import type { InflowGroupBy } from './member-query.dto';
/** 회원의 유입정보 */
export class MemberInflowDto {
@ApiProperty({
description:
'출처 — homepage: 가입 폼 홈페이지 항목(API) / import: 아임웹 회원 엑셀 / 없으면 null',
enum: InflowOrigin,
nullable: true,
example: InflowOrigin.HOMEPAGE,
})
origin!: InflowOrigin | null;
@ApiProperty({
description:
'기록 상태 — at_join: 가입 때 기록 / after_join: 가입 후 기록 / none: 없음',
enum: InflowStatus,
example: InflowStatus.AT_JOIN,
})
status!: InflowStatus;
@ApiProperty({ description: '유입원', nullable: true, example: 'naver' })
source!: string | null;
@ApiProperty({ description: '매체', nullable: true, example: 'cpc' })
medium!: string | null;
@ApiProperty({ description: '캠페인', nullable: true, example: 'fall' })
campaign!: string | null;
@ApiProperty({ description: '콘텐츠', nullable: true, example: 'banner_a' })
content!: string | null;
@ApiProperty({ description: '검색어', nullable: true, example: '재테크' })
term!: string | null;
@ApiProperty({
description: '광고 클릭 ID 종류',
nullable: true,
example: 'gclid',
})
clickId!: string | null;
@ApiProperty({
description: '외부 리퍼러 호스트',
nullable: true,
example: 'm.search.naver.com',
})
referrer!: string | null;
@ApiProperty({
description: '첫 랜딩 경로',
nullable: true,
example: '/free-shop/',
})
landingPath!: string | null;
@ApiProperty({
description: '랜딩 상품/게시글 idx',
nullable: true,
example: '52',
})
productId!: string | null;
@ApiProperty({
description: '최초 유입일 (KST, YYYY-MM-DD)',
nullable: true,
example: '2026-10-04',
})
firstTouchDate!: string | null;
@ApiProperty({
description: '최종 유입 (최초와 다를 때만, 유입원/캠페인)',
nullable: true,
example: 'instagram/retarget',
})
lastTouch!: string | null;
@ApiProperty({
description: '기록일 (KST, YYYY-MM-DD)',
nullable: true,
example: '2026-10-05',
})
recordedDate!: string | null;
}
/** 관리자 화면 회원 한 명 */
export class MemberResponseDto {
@ApiProperty({
description: '아임웹 회원 코드',
example: 'm20261005e3b6987310679',
})
memberCode!: string;
@ApiProperty({ description: '아이디', example: 'user@example.com' })
uid!: string;
@ApiProperty({ description: '이름', example: '홍길동' })
name!: string;
@ApiProperty({
description: '이메일',
nullable: true,
example: 'user@example.com',
})
email!: string | null;
@ApiProperty({
description: '연락처',
nullable: true,
example: '01012345678',
})
callnum!: string | null;
@ApiProperty({
description: '소셜 가입 경로 (일반 가입이면 null)',
nullable: true,
example: 'kakao',
})
socialProvider!: string | null;
@ApiProperty({
description: '가입 시각',
example: '2026-10-05T03:00:00.000Z',
})
joinTime!: string;
@ApiProperty({
description: '최근 로그인',
nullable: true,
example: '2026-10-05T04:00:00.000Z',
})
lastLoginTime!: string | null;
@ApiProperty({
description: '소속 회원 그룹 (아임웹 그룹 순서)',
type: () => [MemberGroupRefDto],
})
groups!: MemberGroupRefDto[];
@ApiProperty({ description: '유입정보', type: MemberInflowDto })
inflow!: MemberInflowDto;
/**
* @param groupTitles 그룹 코드 → { 이름, 순서 } — 동기화된 그룹 목록에서 만든다.
* 목록에 없는 코드(그사이 아임웹에서 지운 그룹)는 코드를 이름 대신 보여준다.
*/
static from(
member: ImwebMember,
groupTitles: ReadonlyMap<
string,
{ title: string; sortOrder: number }
> = new Map(),
): MemberResponseDto {
const groups = member.groupCodes
.map((code) => ({
code,
title: groupTitles.get(code)?.title ?? code,
order: groupTitles.get(code)?.sortOrder ?? Number.MAX_SAFE_INTEGER,
}))
.sort((a, b) => a.order - b.order)
.map(({ code, title }) => ({ code, title }));
return {
memberCode: member.memberCode,
uid: member.uid,
name: member.name,
email: member.email,
callnum: member.callnum,
socialProvider: member.socialProvider,
groups,
joinTime: member.joinTime.toISOString(),
lastLoginTime: member.lastLoginTime?.toISOString() ?? null,
inflow: {
origin: member.inflowOrigin,
status: member.inflowStatus,
source: member.inflowSource,
medium: member.inflowMedium,
campaign: member.inflowCampaign,
content: member.inflowContent,
term: member.inflowTerm,
clickId: member.inflowClickId,
referrer: member.inflowReferrer,
landingPath: member.inflowLandingPath,
productId: member.inflowProductId,
firstTouchDate: member.inflowFirstTouchDate,
lastTouch: member.inflowLastTouch,
recordedDate: member.inflowRecordedDate,
},
};
}
}
/** 집계 한 줄 */
/** 회원에게 붙은 그룹 */
export class MemberGroupRefDto {
@ApiProperty({ description: '그룹 코드', example: 'g202609074ed64993dde35' })
code!: string;
@ApiProperty({ description: '그룹 이름', example: '9/7 무료강의 그룹' })
title!: string;
}
/** 그룹 목록 한 줄 — 필터 칩에 쓴다 */
export class MemberGroupDto extends MemberGroupRefDto {
@ApiProperty({
description: '동기화된 회원 중 이 그룹에 속한 수',
example: 2489,
})
memberCount!: number;
}
export class InflowStatItemDto {
@ApiProperty({
description: '집계 값 (값이 없으면 null)',
nullable: true,
example: 'naver',
})
key!: string | null;
@ApiProperty({ description: '회원 수', example: 120 })
count!: number;
}
/** 기록 상태별 회원 수 */
export class InflowStatusCountsDto {
@ApiProperty({ description: '가입 때 기록', example: 900 })
atJoin!: number;
@ApiProperty({ description: '가입 후 기록 (가입 경로 아님)', example: 30 })
afterJoin!: number;
@ApiProperty({ description: '기록 없음', example: 590 })
none!: number;
}
/** 유입원별 집계 결과 */
export class InflowStatsResponseDto {
@ApiProperty({
description: '집계 기준',
enum: INFLOW_GROUP_BY,
example: 'source',
})
groupBy!: InflowGroupBy;
@ApiProperty({
description:
'가입 때 기록된 회원만 집계한 결과 (회원 수 많은 순, 최대 100개)',
type: [InflowStatItemDto],
})
items!: InflowStatItemDto[];
@ApiProperty({
description: '같은 필터의 기록 상태별 회원 수',
type: InflowStatusCountsDto,
})
statusCounts!: InflowStatusCountsDto;
}
@@ -0,0 +1,65 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { PaginatedResult } from '@/common/dto/pagination.dto';
import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
import { ADMIN_BEARER_AUTH } from '@/modules/admin-auth/admin-auth.constants';
import {
InflowStatsQueryDto,
MemberListQueryDto,
} from './dto/member-query.dto';
import {
InflowStatsResponseDto,
MemberGroupDto,
MemberResponseDto,
} from './dto/member-response.dto';
import { MembersService } from './members.service';
/** 관리자 회원 조회 — 유입경로 기준 목록·집계 */
@ApiTags('Admin Members')
@ApiBearerAuth(ADMIN_BEARER_AUTH)
@UseGuards(AdminAuthGuard)
@Controller('admin/members')
export class MembersController {
constructor(private readonly members: MembersService) {}
@Get()
@ApiOperation({ summary: '회원 목록 (가입 최신순, 커서 페이지네이션)' })
@ApiStandardResponse(MemberResponseDto, {
description: '조회 성공 — data 는 { items, pageInfo }',
isArray: true,
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '필터·커서 형식 오류')
findMany(
@Query() query: MemberListQueryDto,
): Promise<PaginatedResult<MemberResponseDto>> {
return this.members.findMany(query);
}
@Get('groups')
@ApiOperation({
summary: '회원 그룹 목록과 그룹별 회원 수 (아임웹 그룹 순서)',
})
@ApiStandardResponse(MemberGroupDto, {
description: '조회 성공',
isArray: true,
})
@ApiErrorResponse('AUTH_001', '로그인 필요')
findGroups(): Promise<MemberGroupDto[]> {
return this.members.findGroups();
}
@Get('inflow-stats')
@ApiOperation({ summary: '유입경로별 가입자 수 집계' })
@ApiStandardResponse(InflowStatsResponseDto, { description: '집계 성공' })
@ApiErrorResponse('AUTH_001', '로그인 필요')
@ApiErrorResponse('VAL_001', '필터 형식 오류')
stats(@Query() query: InflowStatsQueryDto): Promise<InflowStatsResponseDto> {
return this.members.stats(query);
}
}
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminAuthModule } from '@/modules/admin-auth/admin-auth.module';
import { ImwebGroup } from '@/modules/imweb/entities/imweb-group.entity';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { MembersController } from './members.controller';
import { MembersService } from './members.service';
/** 관리자 회원 조회 — 데이터는 ImwebModule 의 동기화가 채운다 */
@Module({
imports: [
TypeOrmModule.forFeature([ImwebMember, ImwebGroup]),
AdminAuthModule,
],
controllers: [MembersController],
providers: [MembersService],
exports: [MembersService],
})
export class MembersModule {}
@@ -0,0 +1,228 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, SelectQueryBuilder } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { ImwebGroup } from '@/modules/imweb/entities/imweb-group.entity';
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
import { InflowStatus } from '@/shared/utils/inflow';
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
import {
InflowGroupBy,
InflowStatsQueryDto,
MemberFilterDto,
MemberListQueryDto,
} from './dto/member-query.dto';
import {
InflowStatsResponseDto,
MemberGroupDto,
MemberResponseDto,
} from './dto/member-response.dto';
const ALIAS = 'm';
/** 집계 결과 최대 개수 — 캠페인·콘텐츠는 값 종류가 많을 수 있다 */
const STATS_MAX_ITEMS = 100;
/** 집계 기준 → 엔티티 속성 (쿼리 빌더가 컬럼명으로 바꾼다) */
const GROUP_BY_PROPERTY: Record<InflowGroupBy, keyof ImwebMember> = {
source: 'inflowSource',
medium: 'inflowMedium',
campaign: 'inflowCampaign',
content: 'inflowContent',
};
/** 필터 값 → 엔티티 속성 */
const FILTER_PROPERTY = {
source: 'inflowSource',
medium: 'inflowMedium',
campaign: 'inflowCampaign',
content: 'inflowContent',
} as const satisfies Record<string, keyof ImwebMember>;
/** LIKE 패턴의 와일드카드(%, _)와 이스케이프 문자를 글자 그대로 찾게 한다 */
function escapeLike(value: string): string {
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
}
/**
* 관리자 회원 조회 — 동기화해 둔 imweb_members 를 유입경로로 필터·집계한다.
* 아임웹을 직접 부르지 않으므로 빠르고, 아임웹 호출 제한과 무관하다.
*/
@Injectable()
export class MembersService {
constructor(
@InjectRepository(ImwebMember)
private readonly members: Repository<ImwebMember>,
@InjectRepository(ImwebGroup)
private readonly groups: Repository<ImwebGroup>,
) {}
/** 회원 그룹 목록과 그룹별 회원 수 — 아임웹 그룹 순서대로 (회원이 없는 그룹도 포함) */
async findGroups(): Promise<MemberGroupDto[]> {
const rows = await this.groups
.createQueryBuilder('g')
.select('g.groupCode', 'code')
.addSelect('g.title', 'title')
.addSelect(
(sub) =>
sub
.select('COUNT(*)')
.from(ImwebMember, 'm')
.where('g.group_code = ANY(m.group_codes)'),
'memberCount',
)
.orderBy('g.sortOrder', 'ASC')
.getRawMany<{ code: string; title: string; memberCount: string }>();
return rows.map((row) => ({
code: row.code,
title: row.title,
memberCount: Number(row.memberCount),
}));
}
/** 그룹 코드 → { 이름, 순서 } — 회원 응답에 그룹 이름을 붙일 때 쓴다 (신청 내역도 함께 쓴다) */
async groupTitleMap(): Promise<
Map<string, { title: string; sortOrder: number }>
> {
const groups = await this.groups.find({
select: { groupCode: true, title: true, sortOrder: true },
});
return new Map(
groups.map((g) => [
g.groupCode,
{ title: g.title, sortOrder: g.sortOrder },
]),
);
}
async findMany(
query: MemberListQueryDto,
): Promise<PaginatedResult<MemberResponseDto>> {
const base = this.filtered(query);
if (query.q) {
base.andWhere(
`(${ALIAS}.name ILIKE :q OR ${ALIAS}.uid ILIKE :q OR ${ALIAS}.email ILIKE :q OR ${ALIAS}.callnum ILIKE :q)`,
{ q: `%${escapeLike(query.q.trim())}%` },
);
}
const total = await base.clone().getCount();
const page = base
.clone()
.orderBy(`${ALIAS}.joinTime`, 'DESC')
.addOrderBy(`${ALIAS}.memberCode`, 'DESC')
// 다음 페이지가 있는지 알기 위해 한 건 더 가져온다
.take(query.limit + 1);
if (query.cursor) {
const cursor = decodeTimeCursor(query.cursor);
if (!cursor) {
throw new BusinessException('VAL_001', {
context: { reason: 'invalid_cursor' },
});
}
page.andWhere(
`(${ALIAS}.joinTime, ${ALIAS}.memberCode) < (:cursorTime, :cursorCode)`,
{ cursorTime: cursor.time, cursorCode: cursor.key },
);
}
const [rows, groupTitles] = await Promise.all([
page.getMany(),
this.groupTitleMap(),
]);
const hasNext = rows.length > query.limit;
const items = rows.slice(0, query.limit);
const last = items.at(-1);
return toPaginatedResult(
items.map((member) => MemberResponseDto.from(member, groupTitles)),
{
hasNext,
nextCursor:
hasNext && last
? encodeTimeCursor({
time: last.joinTime.toISOString(),
key: last.memberCode,
})
: null,
total,
},
);
}
async stats(query: InflowStatsQueryDto): Promise<InflowStatsResponseDto> {
const property = GROUP_BY_PROPERTY[query.groupBy];
// 순위는 가입 때 기록된 회원만 센다 — 가입 후 채워진 값은 가입 경로가 아니다
const grouped = await this.filtered({
...query,
inflowStatus: InflowStatus.AT_JOIN,
})
.select(`${ALIAS}.${property}`, 'key')
.addSelect('COUNT(*)', 'count')
.groupBy(`${ALIAS}.${property}`)
.orderBy('count', 'DESC')
.addOrderBy('key', 'ASC', 'NULLS LAST')
.limit(STATS_MAX_ITEMS)
.getRawMany<{ key: string | null; count: string }>();
const byStatus = await this.filtered({ ...query, inflowStatus: undefined })
.select(`${ALIAS}.inflowStatus`, 'status')
.addSelect('COUNT(*)', 'count')
.groupBy(`${ALIAS}.inflowStatus`)
.getRawMany<{ status: InflowStatus; count: string }>();
const countOf = (status: InflowStatus) =>
Number(byStatus.find((row) => row.status === status)?.count ?? 0);
return {
groupBy: query.groupBy,
items: grouped.map((row) => ({ key: row.key, count: Number(row.count) })),
statusCounts: {
atJoin: countOf(InflowStatus.AT_JOIN),
afterJoin: countOf(InflowStatus.AFTER_JOIN),
none: countOf(InflowStatus.NONE),
},
};
}
/** 목록·집계 공통 필터 */
private filtered(filter: MemberFilterDto): SelectQueryBuilder<ImwebMember> {
const qb = this.members.createQueryBuilder(ALIAS);
if (filter.joinedFrom) {
qb.andWhere(`${ALIAS}.joinTime >= :joinedFrom`, {
joinedFrom: filter.joinedFrom,
});
}
if (filter.joinedTo) {
qb.andWhere(`${ALIAS}.joinTime < :joinedTo`, {
joinedTo: filter.joinedTo,
});
}
if (filter.inflowStatus) {
qb.andWhere(`${ALIAS}.inflowStatus = :inflowStatus`, {
inflowStatus: filter.inflowStatus,
});
}
if (filter.groupCode) {
qb.andWhere(`:groupCode = ANY(${ALIAS}.group_codes)`, {
groupCode: filter.groupCode,
});
}
for (const [key, property] of Object.entries(FILTER_PROPERTY)) {
const value = filter[key as keyof typeof FILTER_PROPERTY];
if (value) {
qb.andWhere(`${ALIAS}.${property} = :${key}`, { [key]: value });
}
}
return qb;
}
}
@@ -12,6 +12,13 @@ const prod = (extra: Record<string, string> = {}) => ({
DB_USER: 'app_user',
DB_PASSWORD: 'a-real-secret',
DB_NAME: 'project_db',
JWT_SECRET: 'a-real-jwt-secret',
ADMIN_USERNAME: 'moneyguider',
ADMIN_PASSWORD: 'a-long-real-password',
IMWEB_CLIENT_ID: 'client-id',
IMWEB_CLIENT_SECRET: 'a-real-client-secret',
IMWEB_SITE_CODE: 'S20230730923b9e1418574',
IMWEB_ADMIN_PAGE_URL: 'https://www.moneyguider.co.kr/admin-members',
...extra,
});
@@ -45,6 +52,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' })),
+33 -1
View File
@@ -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) {
+16
View File
@@ -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();
});
});
+12
View File
@@ -0,0 +1,12 @@
/**
* Authorization 헤더 파싱 유틸 (순수 함수).
*/
const BEARER_PATTERN = /^Bearer\s+(\S+)\s*$/i;
/** `Authorization: Bearer <토큰>` 에서 토큰을 꺼낸다. 형식이 다르면 null. */
export function readBearerToken(header: string | undefined): string | null {
if (!header) return null;
const match = BEARER_PATTERN.exec(header.trim());
return match ? match[1] : null;
}
@@ -0,0 +1,41 @@
import { credentialFingerprint, safeEqual } from './credential';
describe('safeEqual', () => {
it('같은 문자열만 true', () => {
expect(safeEqual('admin', 'admin')).toBe(true);
expect(safeEqual('admin', 'Admin')).toBe(false);
expect(safeEqual('admin', 'admin ')).toBe(false);
});
it('길이가 달라도 예외 없이 false', () => {
expect(safeEqual('a', 'a-much-longer-value')).toBe(false);
expect(safeEqual('', 'x')).toBe(false);
});
});
describe('credentialFingerprint', () => {
it('같은 계정·비밀키면 같은 지문', () => {
expect(credentialFingerprint('admin', 'pw-1', 'secret')).toBe(
credentialFingerprint('admin', 'pw-1', 'secret'),
);
});
it('비밀번호·아이디·비밀키 중 하나만 바뀌어도 지문이 달라진다', () => {
const base = credentialFingerprint('admin', 'pw-1', 'secret');
expect(credentialFingerprint('admin', 'pw-2', 'secret')).not.toBe(base);
expect(credentialFingerprint('admin2', 'pw-1', 'secret')).not.toBe(base);
expect(credentialFingerprint('admin', 'pw-1', 'secret-2')).not.toBe(base);
});
it('아이디·비밀번호 경계를 섞어도 같은 지문이 나오지 않는다', () => {
expect(credentialFingerprint('ab', 'c', 's')).not.toBe(
credentialFingerprint('a', 'bc', 's'),
);
});
it('지문에 비밀번호가 그대로 들어가지 않는다', () => {
expect(credentialFingerprint('admin', 'my-password', 's')).not.toContain(
'my-password',
);
});
});
+29
View File
@@ -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);
}
+22
View File
@@ -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);
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* 기간 문자열 파싱 유틸 (순수 함수).
*
* `JWT_ACCESS_EXPIRES_IN=1h` 같은 env 값을 밀리초로 바꾼다.
* JWT 만료와 쿠키 maxAge 가 같은 값을 써야 어긋나지 않는다.
*/
const UNIT_TO_MS: Readonly<Record<string, number>> = {
s: 1_000,
m: 60_000,
h: 3_600_000,
d: 86_400_000,
};
const DURATION_PATTERN = /^(\d+)\s*([smhd])$/i;
/**
* `30s` · `15m` · `1h` · `14d` 형식을 밀리초로 변환한다.
* 값이 없거나 형식이 맞지 않으면 기본값을 반환한다.
*/
export function parseDurationMs(
value: string | undefined,
fallbackMs: number,
): number {
if (value === undefined) return fallbackMs;
const match = DURATION_PATTERN.exec(value.trim());
if (!match) return fallbackMs;
const amount = Number(match[1]);
const unit = UNIT_TO_MS[match[2].toLowerCase()];
if (!Number.isInteger(amount) || amount <= 0 || unit === undefined) {
return fallbackMs;
}
return amount * unit;
}
+15 -1
View File
@@ -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([]);
});
});
+12
View File
@@ -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 !== '');
}
+187
View File
@@ -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, string>): 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,
);
});
});
+199
View File
@@ -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<ParsedInflow, 'status'> = {
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,
};
}
+25
View File
@@ -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();
});
});
+23
View File
@@ -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<string, unknown>).exp;
return typeof exp === 'number' && Number.isFinite(exp) ? exp * 1000 : null;
} catch {
return null;
}
}
@@ -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();
});
});
+34
View File
@@ -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<string, unknown>;
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;
}
}