From 2a6dfca65f3f625eb7e2aae898343e1b849ac2be Mon Sep 17 00:00:00 2001 From: minij02 Date: Sat, 23 May 2026 01:34:36 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A0=95=EC=82=B0=20-=20=EC=A0=95?= =?UTF-8?q?=EC=82=B0=20=EC=99=84=EB=A3=8C=20=EB=8F=99=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?cron=20+=20=EB=B3=B4=EC=95=88=20=EB=B3=B4=EA=B0=95=20(#482)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Payple 정산내역 조회 결과로 Settlement.status를 Pending → Succeed로 전이. ### 동기화 잡 (신규) - src/settlements/jobs/settlement-sync.job.ts: KST 08:00 node-cron - src/settlements/services/settlement-sync.service.ts: 동기화 코어 로직 - Redis 분산 락 (SET NX EX 600)으로 중복 실행 차단 - 어제 KST 정산일 1일 범위 호출 - 페이지네이션: PCD_LASTKEY를 Redis 24h TTL로 영속화 → 한도 도달 시 다음 cron이 이어받기 - 페이지당 1.1초 sleep (Payple 1초 1회 한도 준수) - 잡당 최대 페이지 한도 (기본 100, env PAYPLE_SETTLEMENT_SYNC_MAX_PAGES_PER_RUN) - APPROVAL만 처리, CANCEL은 skip + WARN (환불 흐름은 별도 이슈) - 금액 일치 검증 (PCD_SETTLE_AMOUNT === Settlement.amount), 불일치 시 discrepancy 카운트 + ERROR 로그 - 멱등 가드: WHERE payment_id=? AND status='Pending' updateMany - 결과 카운트 로그 (updated/skipped/missing/cancel_skipped/discrepancy/failed) - src/index.ts에 startSettlementSyncJob 부트스트랩 ### Auth 캐시 보안 보강 (payple-settlement.ts) - AUTH_CACHE_TTL_SECONDS 25분 → 15분 단축 - 캐시 payload에서 cstId/custKey 제외 (매 호출 env 직접 로드, 캐시 노출 영역 축소) - 응답 에러 로그에 redactPaypleLog 적용 ### 로그 redactor 확장 (payple.ts) - REDACTED_FIELDS에 정산 조회 필드 추가: PCD_CUST_KEY / PCD_AUTH_KEY / PCD_CST_ID / PCD_PAYER_NAME / PCD_PAY_BANKNUM / PCD_PAY_CARDNUM / PCD_LASTKEY / AuthKey ### 환경변수 - PAYPLE_SETTLEMENT_SYNC_ENABLED ('false'로 비활성화 가능) - PAYPLE_SETTLEMENT_SYNC_MAX_PAGES_PER_RUN (기본 100) 별도 이슈로 분리: - 환불 (CANCEL) 흐름 + Status.Refunded enum 확장 + Refund 모델 - 6개월 백필 스크립트 - Payple Webhook 도입 - PAYPLE_CUST_KEY 분리 / Secrets Manager 이전 --- src/index.ts | 2 + src/settlements/jobs/settlement-sync.job.ts | 26 ++ .../services/settlement-sync.service.ts | 227 ++++++++++++++++++ src/settlements/utils/payple-settlement.ts | 57 +++-- src/settlements/utils/payple.ts | 10 + 5 files changed, 302 insertions(+), 20 deletions(-) create mode 100644 src/settlements/jobs/settlement-sync.job.ts create mode 100644 src/settlements/services/settlement-sync.service.ts diff --git a/src/index.ts b/src/index.ts index 0495696..b183060 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,7 @@ import passwordRouter from "./password/routes/password.route"; import chatRouter from "./chat/routes/chat.route"; import { initSocket } from "./socket/server"; import { startPromptStatSnapshotJob } from "./stats/jobs/prompt-stat-snapshot.job"; +import { startSettlementSyncJob } from "./settlements/jobs/settlement-sync.job"; import morgan = require('morgan'); const PORT = 3000; const app = express(); @@ -47,6 +48,7 @@ const server = http.createServer(app); initSocket(server) startPromptStatSnapshotJob(); +startSettlementSyncJob(); // 1. 응답 핸들러(json 파서보다 위에) app.use(responseHandler); app.use((req, res, next) => { diff --git a/src/settlements/jobs/settlement-sync.job.ts b/src/settlements/jobs/settlement-sync.job.ts new file mode 100644 index 0000000..6f51836 --- /dev/null +++ b/src/settlements/jobs/settlement-sync.job.ts @@ -0,0 +1,26 @@ +import cron from 'node-cron'; +import { runSettlementSyncJob } from '../services/settlement-sync.service'; + +// KST 08:00에 어제 정산일 기준 동기화. +// Payple 정산 확정 시각은 명세상 불분명 — sandbox 검증 후 cron 표현식 조정 가능. +const CRON_EXPRESSION = '0 8 * * *'; +const TIMEZONE = 'Asia/Seoul'; + +export const startSettlementSyncJob = (): void => { + cron.schedule( + CRON_EXPRESSION, + async () => { + try { + await runSettlementSyncJob(); + } catch (error) { + console.error('[settlement-sync-cron] scheduled run failed', error); + } + }, + { timezone: TIMEZONE }, + ); + + console.log('[settlement-sync-cron] scheduled', { + cron: CRON_EXPRESSION, + timezone: TIMEZONE, + }); +}; diff --git a/src/settlements/services/settlement-sync.service.ts b/src/settlements/services/settlement-sync.service.ts new file mode 100644 index 0000000..81ba478 --- /dev/null +++ b/src/settlements/services/settlement-sync.service.ts @@ -0,0 +1,227 @@ +import prisma from '../../config/prisma'; +import redisClient from '../../config/redis'; +import { + fetchPaypleSettlements, + PaypleSettlementItem, +} from '../utils/payple-settlement'; + +// 정산 완료 동기화 잡 — Payple 정산내역 조회 결과를 우리 Settlement에 반영. +// +// 정책 (#482 확정 사항): +// - APPROVAL만 처리. CANCEL은 skip + WARN (환불은 별도 이슈) +// - 금액 검증: PCD_SETTLE_AMOUNT === Settlement.amount일 때만 Succeed 전이 +// - 멱등 가드: WHERE status='Pending' updateMany — 재실행 안전 +// - 분산 락: Redis SET NX EX 600 — 컨테이너 중복 실행 방지 +// - 페이지 사이 1.1초 sleep (Payple 1초 1회 한도) +// - 페이지 한도 도달 시 lastKey 영속화 → 다음 cron이 이어받기 + +const SYNC_LOCK_KEY = 'payple:settlement:sync:lock'; +const SYNC_LOCK_TTL_SECONDS = 600; +const LAST_KEY_PREFIX = 'payple:settlement:sync:lastkey'; +const LAST_KEY_TTL_SECONDS = 24 * 60 * 60; +const RATE_LIMIT_SLEEP_MS = 1100; +const DEFAULT_MAX_PAGES_PER_RUN = 100; +const DEFAULT_PAGE_LIMIT = 3000; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const formatKstDate = (date: Date): string => { + const kstOffsetMs = 9 * 60 * 60 * 1000; + const kst = new Date(date.getTime() + kstOffsetMs); + const yyyy = kst.getUTCFullYear(); + const mm = String(kst.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(kst.getUTCDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +}; + +const getYesterdayKst = (now: Date = new Date()): string => { + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + return formatKstDate(yesterday); +}; + +interface SyncCounters { + date: string; + pages: number; + processed: number; + updated: number; + skipped: number; + missing: number; + cancel_skipped: number; + discrepancy: number; + failed: number; +} + +const newCounters = (date: string): SyncCounters => ({ + date, + pages: 0, + processed: 0, + updated: 0, + skipped: 0, + missing: 0, + cancel_skipped: 0, + discrepancy: 0, + failed: 0, +}); + +// APPROVAL 한 건 처리. counters는 mutable. +const processApproval = async (item: PaypleSettlementItem, counters: SyncCounters): Promise => { + const payment = await prisma.payment.findUnique({ + where: { pcd_pay_oid: item.payOid }, + select: { payment_id: true, settlement: { select: { amount: true, status: true } } }, + }); + + if (!payment) { + counters.missing += 1; + console.warn('[settlement-sync] missing payment for OID', { payOid: item.payOid }); + return; + } + if (!payment.settlement) { + counters.missing += 1; + console.warn('[settlement-sync] missing settlement for payment', { + payOid: item.payOid, + paymentId: payment.payment_id, + }); + return; + } + if (payment.settlement.amount !== item.settleAmount) { + counters.discrepancy += 1; + console.error('[settlement-sync] amount discrepancy — manual review required', { + payOid: item.payOid, + paymentId: payment.payment_id, + ourAmount: payment.settlement.amount, + paypleAmount: item.settleAmount, + }); + return; + } + + // 멱등 가드: Pending 행만 Succeed로 + const result = await prisma.settlement.updateMany({ + where: { payment_id: payment.payment_id, status: 'Pending' }, + data: { status: 'Succeed' }, + }); + + if (result.count > 0) { + counters.updated += 1; + } else { + counters.skipped += 1; + } +}; + +interface RunOptions { + date?: string; + maxPagesPerRun?: number; + pageLimit?: number; +} + +// 단일 날짜에 대한 동기화 1회 실행. +// 본 함수는 분산 락 안에서만 호출되어야 한다. +export const runSettlementSyncForDate = async ( + options: RunOptions = {}, +): Promise => { + const date = options.date ?? getYesterdayKst(); + const maxPages = options.maxPagesPerRun ?? DEFAULT_MAX_PAGES_PER_RUN; + const pageLimit = options.pageLimit ?? DEFAULT_PAGE_LIMIT; + const counters = newCounters(date); + const lastKeyKey = `${LAST_KEY_PREFIX}:${date}`; + + let lastKey = (await redisClient.get(lastKeyKey)) ?? undefined; + + for (let page = 0; page < maxPages; page += 1) { + if (page > 0) await sleep(RATE_LIMIT_SLEEP_MS); + + let response; + try { + response = await fetchPaypleSettlements({ + startDate: date, + endDate: date, + limit: pageLimit, + lastKey: lastKey, + }); + } catch (err: any) { + counters.failed += 1; + console.error('[settlement-sync] page fetch failed', { + date, + page, + error: err?.message, + }); + // 페이지 실패 시 lastKey 보존 (다음 cron이 이어받음) 후 종료 + break; + } + + counters.pages += 1; + + for (const item of response.items) { + counters.processed += 1; + try { + if (item.txnType === 'CANCEL') { + counters.cancel_skipped += 1; + console.warn('[settlement-sync] CANCEL skipped (handled in separate flow)', { + payOid: item.payOid, + }); + continue; + } + if (item.txnType === 'APPROVAL') { + await processApproval(item, counters); + continue; + } + // 알 수 없는 txnType + counters.failed += 1; + console.error('[settlement-sync] unknown txnType', { + payOid: item.payOid, + txnType: item.txnType, + }); + } catch (err: any) { + counters.failed += 1; + console.error('[settlement-sync] item processing failed', { + payOid: item.payOid, + error: err?.message, + }); + } + } + + if (!response.hasMore || !response.lastKey) { + // 완료 — lastKey 캐시 제거 + await redisClient.del(lastKeyKey); + lastKey = undefined; + break; + } + + lastKey = response.lastKey; + await redisClient.set(lastKeyKey, lastKey, { EX: LAST_KEY_TTL_SECONDS }); + } + + return counters; +}; + +// cron 진입점 — 분산 락 + 환경 토글 + 결과 로그. +export const runSettlementSyncJob = async (): Promise => { + if (process.env.PAYPLE_SETTLEMENT_SYNC_ENABLED === 'false') { + console.log('[settlement-sync] disabled by env'); + return; + } + + const lockAcquired = await redisClient.set(SYNC_LOCK_KEY, '1', { + NX: true, + EX: SYNC_LOCK_TTL_SECONDS, + }); + if (lockAcquired !== 'OK') { + console.warn('[settlement-sync] lock held by another instance — skip this run'); + return; + } + + const startedAt = Date.now(); + try { + const maxPages = process.env.PAYPLE_SETTLEMENT_SYNC_MAX_PAGES_PER_RUN + ? Number(process.env.PAYPLE_SETTLEMENT_SYNC_MAX_PAGES_PER_RUN) + : undefined; + const counters = await runSettlementSyncForDate({ maxPagesPerRun: maxPages }); + console.log('[settlement-sync] completed', { + ...counters, + elapsedMs: Date.now() - startedAt, + }); + } catch (err: any) { + console.error('[settlement-sync] job failed', { error: err?.message }); + } finally { + await redisClient.del(SYNC_LOCK_KEY); + } +}; diff --git a/src/settlements/utils/payple-settlement.ts b/src/settlements/utils/payple-settlement.ts index 0c4a104..9fa4c48 100644 --- a/src/settlements/utils/payple-settlement.ts +++ b/src/settlements/utils/payple-settlement.ts @@ -1,25 +1,31 @@ import axios from 'axios'; import redisClient from '../../config/redis'; import { AppError } from '../../errors/AppError'; +import { redactPaypleLog } from './payple'; // Payple 정산내역 조회용 파트너 인증 + 조회 유틸. // 정산내역 조회는 PCD_SETTLEMENT_FLAG=Y로 인증 받고, 응답의 PCD_PAY_HOST + PCD_PAY_URL로 호출. // 호출 제한: 1초 1회 / 2분 20회 (호출자가 throttling 책임). // -// 본 이슈에서는 인프라만 추가. 외부 endpoint 노출 없음. 향후 정산 완료 동기화/검증/리포트에 재사용. +// 보안 정책 (#482 보강): +// - Auth 캐시 TTL을 15분으로 단축 (Payple 토큰 만료 추정치보다 짧게) +// - cstId/custKey는 캐시에서 제외하고 매 호출 시 env에서 직접 로드 (캐시 손상 시 영역 축소) +// - 요청/응답 로그는 redactPaypleLog로 마스킹 const AUTH_CACHE_KEY = 'payple:settlement:auth'; -// Payple 토큰 만료 정책이 명세상 불분명 — 안전하게 25분 캐시 (보통 30분 만료 가정) -const AUTH_CACHE_TTL_SECONDS = 25 * 60; +const AUTH_CACHE_TTL_SECONDS = 15 * 60; -interface PaypleSettlementAuth { - cstId: string; - custKey: string; +interface PaypleSettlementAuthCache { authKey: string; payHost: string; payUrl: string; } +export interface PaypleSettlementAuth extends PaypleSettlementAuthCache { + cstId: string; + custKey: string; +} + const getCpayBaseUrl = (): string => { const url = process.env.PAYPLE_CPAY_URL; if (!url) { @@ -33,26 +39,35 @@ const getCpayBaseUrl = (): string => { const getSettlementAuthPath = (): string => process.env.PAYPLE_SETTLEMENT_AUTH_PATH || '/php/auth.php'; +const loadCredentialsFromEnv = (): { cstId: string; custKey: string } => { + const cstId = process.env.PAYPLE_CST_ID; + const custKey = process.env.PAYPLE_CUST_KEY; + if (!cstId || !custKey) { + throw new AppError('Payple 인증 설정이 누락되었습니다.', 500, 'ConfigError'); + } + return { cstId, custKey }; +}; + export const fetchPaypleSettlementAuth = async (): Promise => { + // 자격증명은 매 호출 env에서 (캐시 노출 영역 축소) + const { cstId, custKey } = loadCredentialsFromEnv(); + const cached = await redisClient.get(AUTH_CACHE_KEY); if (cached) { try { - return JSON.parse(cached); + const parsed: PaypleSettlementAuthCache = JSON.parse(cached); + if (parsed.authKey && parsed.payHost && parsed.payUrl) { + return { ...parsed, cstId, custKey }; + } } catch { // 캐시 손상 — 재발급 } } - const cst_id = process.env.PAYPLE_CST_ID; - const custKey = process.env.PAYPLE_CUST_KEY; - if (!cst_id || !custKey) { - throw new AppError('Payple 인증 설정이 누락되었습니다.', 500, 'ConfigError'); - } - const url = `${getCpayBaseUrl()}${getSettlementAuthPath()}`; const res = await axios.post( url, - { cst_id, custKey, PCD_SETTLEMENT_FLAG: 'Y' }, + { cst_id: cstId, custKey, PCD_SETTLEMENT_FLAG: 'Y' }, { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' } }, ); @@ -61,15 +76,14 @@ export const fetchPaypleSettlementAuth = async (): Promise throw new AppError('Payple 정산내역 조회 인증에 실패했습니다.', 502, 'PaypleAuthFailed'); } - const auth: PaypleSettlementAuth = { - cstId: res.data.cst_id, - custKey: res.data.custKey, + // 캐시에는 authKey/payHost/payUrl만 저장 (cstId/custKey 노출 영역 최소화) + const cacheable: PaypleSettlementAuthCache = { authKey: res.data.AuthKey, payHost: res.data.PCD_PAY_HOST, payUrl: res.data.PCD_PAY_URL, }; - await redisClient.set(AUTH_CACHE_KEY, JSON.stringify(auth), { EX: AUTH_CACHE_TTL_SECONDS }); - return auth; + await redisClient.set(AUTH_CACHE_KEY, JSON.stringify(cacheable), { EX: AUTH_CACHE_TTL_SECONDS }); + return { ...cacheable, cstId, custKey }; }; export type PaypleMethod = 'CARD' | 'EASYPAY' | 'TRANSFER'; @@ -136,7 +150,10 @@ export const fetchPaypleSettlements = async ( }); if (res.data?.PCD_PAY_RST !== 'success') { - console.error('[payple-settlement] query failed', { code: res.data?.PCD_PAY_CODE }); + console.error('[payple-settlement] query failed', { + code: res.data?.PCD_PAY_CODE, + response: redactPaypleLog(res.data), + }); throw new AppError( `Payple 정산내역 조회에 실패했습니다. (${res.data?.PCD_PAY_CODE ?? 'UNKNOWN'})`, 502, diff --git a/src/settlements/utils/payple.ts b/src/settlements/utils/payple.ts index c4d000e..68d9e91 100644 --- a/src/settlements/utils/payple.ts +++ b/src/settlements/utils/payple.ts @@ -23,6 +23,7 @@ export class AccountVerificationError extends AppError { // 민감 정보 redactor — 로그에 노출되면 안 되는 필드 const REDACTED_FIELDS = new Set([ + // 실명인증/은행/토큰 'account_num', 'account_holder_info', 'custKey', @@ -30,6 +31,15 @@ const REDACTED_FIELDS = new Set([ 'billing_tran_id', 'bank_tran_id', 'access_token', + // 정산내역 조회 (payple-settlement.ts에서 재사용) + 'PCD_CUST_KEY', + 'PCD_AUTH_KEY', + 'PCD_CST_ID', + 'PCD_PAYER_NAME', + 'PCD_PAY_BANKNUM', + 'PCD_PAY_CARDNUM', + 'PCD_LASTKEY', + 'AuthKey', ]); const redactValue = (v: unknown): string => {