Skip to content

Commit 0be8ba7

Browse files
authored
Merge pull request #484 from PromptPlace/develop
Develop
2 parents 3ca62c2 + 5a4ed4b commit 0be8ba7

5 files changed

Lines changed: 302 additions & 20 deletions

File tree

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import passwordRouter from "./password/routes/password.route";
3838
import chatRouter from "./chat/routes/chat.route";
3939
import { initSocket } from "./socket/server";
4040
import { startPromptStatSnapshotJob } from "./stats/jobs/prompt-stat-snapshot.job";
41+
import { startSettlementSyncJob } from "./settlements/jobs/settlement-sync.job";
4142
import morgan = require('morgan');
4243
const PORT = 3000;
4344
const app = express();
@@ -47,6 +48,7 @@ const server = http.createServer(app);
4748

4849
initSocket(server)
4950
startPromptStatSnapshotJob();
51+
startSettlementSyncJob();
5052
// 1. 응답 핸들러(json 파서보다 위에)
5153
app.use(responseHandler);
5254
app.use((req, res, next) => {
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import cron from 'node-cron';
2+
import { runSettlementSyncJob } from '../services/settlement-sync.service';
3+
4+
// KST 08:00에 어제 정산일 기준 동기화.
5+
// Payple 정산 확정 시각은 명세상 불분명 — sandbox 검증 후 cron 표현식 조정 가능.
6+
const CRON_EXPRESSION = '0 8 * * *';
7+
const TIMEZONE = 'Asia/Seoul';
8+
9+
export const startSettlementSyncJob = (): void => {
10+
cron.schedule(
11+
CRON_EXPRESSION,
12+
async () => {
13+
try {
14+
await runSettlementSyncJob();
15+
} catch (error) {
16+
console.error('[settlement-sync-cron] scheduled run failed', error);
17+
}
18+
},
19+
{ timezone: TIMEZONE },
20+
);
21+
22+
console.log('[settlement-sync-cron] scheduled', {
23+
cron: CRON_EXPRESSION,
24+
timezone: TIMEZONE,
25+
});
26+
};
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import prisma from '../../config/prisma';
2+
import redisClient from '../../config/redis';
3+
import {
4+
fetchPaypleSettlements,
5+
PaypleSettlementItem,
6+
} from '../utils/payple-settlement';
7+
8+
// 정산 완료 동기화 잡 — Payple 정산내역 조회 결과를 우리 Settlement에 반영.
9+
//
10+
// 정책 (#482 확정 사항):
11+
// - APPROVAL만 처리. CANCEL은 skip + WARN (환불은 별도 이슈)
12+
// - 금액 검증: PCD_SETTLE_AMOUNT === Settlement.amount일 때만 Succeed 전이
13+
// - 멱등 가드: WHERE status='Pending' updateMany — 재실행 안전
14+
// - 분산 락: Redis SET NX EX 600 — 컨테이너 중복 실행 방지
15+
// - 페이지 사이 1.1초 sleep (Payple 1초 1회 한도)
16+
// - 페이지 한도 도달 시 lastKey 영속화 → 다음 cron이 이어받기
17+
18+
const SYNC_LOCK_KEY = 'payple:settlement:sync:lock';
19+
const SYNC_LOCK_TTL_SECONDS = 600;
20+
const LAST_KEY_PREFIX = 'payple:settlement:sync:lastkey';
21+
const LAST_KEY_TTL_SECONDS = 24 * 60 * 60;
22+
const RATE_LIMIT_SLEEP_MS = 1100;
23+
const DEFAULT_MAX_PAGES_PER_RUN = 100;
24+
const DEFAULT_PAGE_LIMIT = 3000;
25+
26+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
27+
28+
const formatKstDate = (date: Date): string => {
29+
const kstOffsetMs = 9 * 60 * 60 * 1000;
30+
const kst = new Date(date.getTime() + kstOffsetMs);
31+
const yyyy = kst.getUTCFullYear();
32+
const mm = String(kst.getUTCMonth() + 1).padStart(2, '0');
33+
const dd = String(kst.getUTCDate()).padStart(2, '0');
34+
return `${yyyy}-${mm}-${dd}`;
35+
};
36+
37+
const getYesterdayKst = (now: Date = new Date()): string => {
38+
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
39+
return formatKstDate(yesterday);
40+
};
41+
42+
interface SyncCounters {
43+
date: string;
44+
pages: number;
45+
processed: number;
46+
updated: number;
47+
skipped: number;
48+
missing: number;
49+
cancel_skipped: number;
50+
discrepancy: number;
51+
failed: number;
52+
}
53+
54+
const newCounters = (date: string): SyncCounters => ({
55+
date,
56+
pages: 0,
57+
processed: 0,
58+
updated: 0,
59+
skipped: 0,
60+
missing: 0,
61+
cancel_skipped: 0,
62+
discrepancy: 0,
63+
failed: 0,
64+
});
65+
66+
// APPROVAL 한 건 처리. counters는 mutable.
67+
const processApproval = async (item: PaypleSettlementItem, counters: SyncCounters): Promise<void> => {
68+
const payment = await prisma.payment.findUnique({
69+
where: { pcd_pay_oid: item.payOid },
70+
select: { payment_id: true, settlement: { select: { amount: true, status: true } } },
71+
});
72+
73+
if (!payment) {
74+
counters.missing += 1;
75+
console.warn('[settlement-sync] missing payment for OID', { payOid: item.payOid });
76+
return;
77+
}
78+
if (!payment.settlement) {
79+
counters.missing += 1;
80+
console.warn('[settlement-sync] missing settlement for payment', {
81+
payOid: item.payOid,
82+
paymentId: payment.payment_id,
83+
});
84+
return;
85+
}
86+
if (payment.settlement.amount !== item.settleAmount) {
87+
counters.discrepancy += 1;
88+
console.error('[settlement-sync] amount discrepancy — manual review required', {
89+
payOid: item.payOid,
90+
paymentId: payment.payment_id,
91+
ourAmount: payment.settlement.amount,
92+
paypleAmount: item.settleAmount,
93+
});
94+
return;
95+
}
96+
97+
// 멱등 가드: Pending 행만 Succeed로
98+
const result = await prisma.settlement.updateMany({
99+
where: { payment_id: payment.payment_id, status: 'Pending' },
100+
data: { status: 'Succeed' },
101+
});
102+
103+
if (result.count > 0) {
104+
counters.updated += 1;
105+
} else {
106+
counters.skipped += 1;
107+
}
108+
};
109+
110+
interface RunOptions {
111+
date?: string;
112+
maxPagesPerRun?: number;
113+
pageLimit?: number;
114+
}
115+
116+
// 단일 날짜에 대한 동기화 1회 실행.
117+
// 본 함수는 분산 락 안에서만 호출되어야 한다.
118+
export const runSettlementSyncForDate = async (
119+
options: RunOptions = {},
120+
): Promise<SyncCounters> => {
121+
const date = options.date ?? getYesterdayKst();
122+
const maxPages = options.maxPagesPerRun ?? DEFAULT_MAX_PAGES_PER_RUN;
123+
const pageLimit = options.pageLimit ?? DEFAULT_PAGE_LIMIT;
124+
const counters = newCounters(date);
125+
const lastKeyKey = `${LAST_KEY_PREFIX}:${date}`;
126+
127+
let lastKey = (await redisClient.get(lastKeyKey)) ?? undefined;
128+
129+
for (let page = 0; page < maxPages; page += 1) {
130+
if (page > 0) await sleep(RATE_LIMIT_SLEEP_MS);
131+
132+
let response;
133+
try {
134+
response = await fetchPaypleSettlements({
135+
startDate: date,
136+
endDate: date,
137+
limit: pageLimit,
138+
lastKey: lastKey,
139+
});
140+
} catch (err: any) {
141+
counters.failed += 1;
142+
console.error('[settlement-sync] page fetch failed', {
143+
date,
144+
page,
145+
error: err?.message,
146+
});
147+
// 페이지 실패 시 lastKey 보존 (다음 cron이 이어받음) 후 종료
148+
break;
149+
}
150+
151+
counters.pages += 1;
152+
153+
for (const item of response.items) {
154+
counters.processed += 1;
155+
try {
156+
if (item.txnType === 'CANCEL') {
157+
counters.cancel_skipped += 1;
158+
console.warn('[settlement-sync] CANCEL skipped (handled in separate flow)', {
159+
payOid: item.payOid,
160+
});
161+
continue;
162+
}
163+
if (item.txnType === 'APPROVAL') {
164+
await processApproval(item, counters);
165+
continue;
166+
}
167+
// 알 수 없는 txnType
168+
counters.failed += 1;
169+
console.error('[settlement-sync] unknown txnType', {
170+
payOid: item.payOid,
171+
txnType: item.txnType,
172+
});
173+
} catch (err: any) {
174+
counters.failed += 1;
175+
console.error('[settlement-sync] item processing failed', {
176+
payOid: item.payOid,
177+
error: err?.message,
178+
});
179+
}
180+
}
181+
182+
if (!response.hasMore || !response.lastKey) {
183+
// 완료 — lastKey 캐시 제거
184+
await redisClient.del(lastKeyKey);
185+
lastKey = undefined;
186+
break;
187+
}
188+
189+
lastKey = response.lastKey;
190+
await redisClient.set(lastKeyKey, lastKey, { EX: LAST_KEY_TTL_SECONDS });
191+
}
192+
193+
return counters;
194+
};
195+
196+
// cron 진입점 — 분산 락 + 환경 토글 + 결과 로그.
197+
export const runSettlementSyncJob = async (): Promise<void> => {
198+
if (process.env.PAYPLE_SETTLEMENT_SYNC_ENABLED === 'false') {
199+
console.log('[settlement-sync] disabled by env');
200+
return;
201+
}
202+
203+
const lockAcquired = await redisClient.set(SYNC_LOCK_KEY, '1', {
204+
NX: true,
205+
EX: SYNC_LOCK_TTL_SECONDS,
206+
});
207+
if (lockAcquired !== 'OK') {
208+
console.warn('[settlement-sync] lock held by another instance — skip this run');
209+
return;
210+
}
211+
212+
const startedAt = Date.now();
213+
try {
214+
const maxPages = process.env.PAYPLE_SETTLEMENT_SYNC_MAX_PAGES_PER_RUN
215+
? Number(process.env.PAYPLE_SETTLEMENT_SYNC_MAX_PAGES_PER_RUN)
216+
: undefined;
217+
const counters = await runSettlementSyncForDate({ maxPagesPerRun: maxPages });
218+
console.log('[settlement-sync] completed', {
219+
...counters,
220+
elapsedMs: Date.now() - startedAt,
221+
});
222+
} catch (err: any) {
223+
console.error('[settlement-sync] job failed', { error: err?.message });
224+
} finally {
225+
await redisClient.del(SYNC_LOCK_KEY);
226+
}
227+
};

src/settlements/utils/payple-settlement.ts

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,31 @@
11
import axios from 'axios';
22
import redisClient from '../../config/redis';
33
import { AppError } from '../../errors/AppError';
4+
import { redactPaypleLog } from './payple';
45

56
// Payple 정산내역 조회용 파트너 인증 + 조회 유틸.
67
// 정산내역 조회는 PCD_SETTLEMENT_FLAG=Y로 인증 받고, 응답의 PCD_PAY_HOST + PCD_PAY_URL로 호출.
78
// 호출 제한: 1초 1회 / 2분 20회 (호출자가 throttling 책임).
89
//
9-
// 본 이슈에서는 인프라만 추가. 외부 endpoint 노출 없음. 향후 정산 완료 동기화/검증/리포트에 재사용.
10+
// 보안 정책 (#482 보강):
11+
// - Auth 캐시 TTL을 15분으로 단축 (Payple 토큰 만료 추정치보다 짧게)
12+
// - cstId/custKey는 캐시에서 제외하고 매 호출 시 env에서 직접 로드 (캐시 손상 시 영역 축소)
13+
// - 요청/응답 로그는 redactPaypleLog로 마스킹
1014

1115
const AUTH_CACHE_KEY = 'payple:settlement:auth';
12-
// Payple 토큰 만료 정책이 명세상 불분명 — 안전하게 25분 캐시 (보통 30분 만료 가정)
13-
const AUTH_CACHE_TTL_SECONDS = 25 * 60;
16+
const AUTH_CACHE_TTL_SECONDS = 15 * 60;
1417

15-
interface PaypleSettlementAuth {
16-
cstId: string;
17-
custKey: string;
18+
interface PaypleSettlementAuthCache {
1819
authKey: string;
1920
payHost: string;
2021
payUrl: string;
2122
}
2223

24+
export interface PaypleSettlementAuth extends PaypleSettlementAuthCache {
25+
cstId: string;
26+
custKey: string;
27+
}
28+
2329
const getCpayBaseUrl = (): string => {
2430
const url = process.env.PAYPLE_CPAY_URL;
2531
if (!url) {
@@ -33,26 +39,35 @@ const getCpayBaseUrl = (): string => {
3339
const getSettlementAuthPath = (): string =>
3440
process.env.PAYPLE_SETTLEMENT_AUTH_PATH || '/php/auth.php';
3541

42+
const loadCredentialsFromEnv = (): { cstId: string; custKey: string } => {
43+
const cstId = process.env.PAYPLE_CST_ID;
44+
const custKey = process.env.PAYPLE_CUST_KEY;
45+
if (!cstId || !custKey) {
46+
throw new AppError('Payple 인증 설정이 누락되었습니다.', 500, 'ConfigError');
47+
}
48+
return { cstId, custKey };
49+
};
50+
3651
export const fetchPaypleSettlementAuth = async (): Promise<PaypleSettlementAuth> => {
52+
// 자격증명은 매 호출 env에서 (캐시 노출 영역 축소)
53+
const { cstId, custKey } = loadCredentialsFromEnv();
54+
3755
const cached = await redisClient.get(AUTH_CACHE_KEY);
3856
if (cached) {
3957
try {
40-
return JSON.parse(cached);
58+
const parsed: PaypleSettlementAuthCache = JSON.parse(cached);
59+
if (parsed.authKey && parsed.payHost && parsed.payUrl) {
60+
return { ...parsed, cstId, custKey };
61+
}
4162
} catch {
4263
// 캐시 손상 — 재발급
4364
}
4465
}
4566

46-
const cst_id = process.env.PAYPLE_CST_ID;
47-
const custKey = process.env.PAYPLE_CUST_KEY;
48-
if (!cst_id || !custKey) {
49-
throw new AppError('Payple 인증 설정이 누락되었습니다.', 500, 'ConfigError');
50-
}
51-
5267
const url = `${getCpayBaseUrl()}${getSettlementAuthPath()}`;
5368
const res = await axios.post(
5469
url,
55-
{ cst_id, custKey, PCD_SETTLEMENT_FLAG: 'Y' },
70+
{ cst_id: cstId, custKey, PCD_SETTLEMENT_FLAG: 'Y' },
5671
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' } },
5772
);
5873

@@ -61,15 +76,14 @@ export const fetchPaypleSettlementAuth = async (): Promise<PaypleSettlementAuth>
6176
throw new AppError('Payple 정산내역 조회 인증에 실패했습니다.', 502, 'PaypleAuthFailed');
6277
}
6378

64-
const auth: PaypleSettlementAuth = {
65-
cstId: res.data.cst_id,
66-
custKey: res.data.custKey,
79+
// 캐시에는 authKey/payHost/payUrl만 저장 (cstId/custKey 노출 영역 최소화)
80+
const cacheable: PaypleSettlementAuthCache = {
6781
authKey: res.data.AuthKey,
6882
payHost: res.data.PCD_PAY_HOST,
6983
payUrl: res.data.PCD_PAY_URL,
7084
};
71-
await redisClient.set(AUTH_CACHE_KEY, JSON.stringify(auth), { EX: AUTH_CACHE_TTL_SECONDS });
72-
return auth;
85+
await redisClient.set(AUTH_CACHE_KEY, JSON.stringify(cacheable), { EX: AUTH_CACHE_TTL_SECONDS });
86+
return { ...cacheable, cstId, custKey };
7387
};
7488

7589
export type PaypleMethod = 'CARD' | 'EASYPAY' | 'TRANSFER';
@@ -136,7 +150,10 @@ export const fetchPaypleSettlements = async (
136150
});
137151

138152
if (res.data?.PCD_PAY_RST !== 'success') {
139-
console.error('[payple-settlement] query failed', { code: res.data?.PCD_PAY_CODE });
153+
console.error('[payple-settlement] query failed', {
154+
code: res.data?.PCD_PAY_CODE,
155+
response: redactPaypleLog(res.data),
156+
});
140157
throw new AppError(
141158
`Payple 정산내역 조회에 실패했습니다. (${res.data?.PCD_PAY_CODE ?? 'UNKNOWN'})`,
142159
502,

0 commit comments

Comments
 (0)