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
@@ -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;
}
}