diff --git a/src/index.ts b/src/index.ts index 746319e..78cfba3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -81,12 +81,11 @@ const allowedOrigins = [ app.use( cors({ origin: function (origin, callback) { - // origin이 undefined일 수 있으므로 체크 필요 - if (!origin || allowedOrigins.includes(origin)) { - callback(null, true); - } else { - callback(new Error("Not allowed by CORS")); - } + // ponytail: 미허용 origin 은 throw(=500) 대신 false — CORS 헤더만 빼고 요청은 통과시킨다. + // 브라우저는 여전히 응답을 읽지 못하므로 보호 수준은 같고, 페이플 결제창(cpay.payple.kr)이 + // PCD_RST_URL 로 보내는 폼 POST 리다이렉트 같은 서드파티 네비게이션이 500 으로 죽지 않는다. + // origin 이 undefined 일 수 있으므로(서버-투-서버 호출) 그대로 허용. + callback(null, !origin || allowedOrigins.includes(origin)); }, credentials: true, }) diff --git a/src/prompts/controllers/prompt.controller.ts b/src/prompts/controllers/prompt.controller.ts index db095dd..f2f8230 100644 --- a/src/prompts/controllers/prompt.controller.ts +++ b/src/prompts/controllers/prompt.controller.ts @@ -10,6 +10,7 @@ import { PatchPromptImageDto } from "../dtos/patch-prompt-image.dto"; import { DeletePromptImageDto } from "../dtos/delete-prompt-image.dto"; import { validate } from "class-validator"; import { plainToInstance } from "class-transformer"; +import {AdminSellerRepository} from "../../settlements/repositories/admin-seller.repository"; export const searchPrompts = async (req: Request, res: Response) => { try { @@ -209,6 +210,32 @@ export const createPrompt = async (req: Request, res: Response) => { }); } + // + 추가 : 유료 프롬프트 가격 설정 정의 + + if (!dto.is_free) { + // 유료 선택 시: 가격 제한 최소 100원, 최대 100,000원 + if (dto.price < 100 || dto.price > 100000) { + return res.fail({ + statusCode: 400, + error: "BadRequest", + message: "유료 프롬프트의 가격은 최소 100원, 최대 100,000원으로 설정해야 합니다.", + }); + } + + const isApprovedSeller = await AdminSellerRepository.findApprovedSellerAnyType(userId); + + if (!isApprovedSeller) { + return res.fail({ + statusCode: 403, + error: "Forbidden", + message: "판매자로 승인된 사용자만 유료 프롬프트를 올릴 수 있습니다.", + }); + } + } else { + // 무료 선택 시: 혹시 모를 클라이언트의 잘못된 값 전달 방지를 위해 가격을 0으로 강제 초기화 + dto.price = 0; + } + // 3. 서비스 호출 const result = await promptService.createPromptWrite(userId, dto); return res.status(201).success(result, "프롬프트 업로드 성공"); diff --git a/src/purchases/controller/purchase.webhook.controller.ts b/src/purchases/controller/purchase.webhook.controller.ts index d9e77b0..3488ed4 100644 --- a/src/purchases/controller/purchase.webhook.controller.ts +++ b/src/purchases/controller/purchase.webhook.controller.ts @@ -1,6 +1,7 @@ import { Request, Response, NextFunction } from 'express'; import { WebhookService } from '../services/purchase.webhook.service'; import { PayplePaymentResult } from '../utils/payple'; +import { redactPaypleLog } from '../../settlements/utils/payple'; type RedirectStatus = 'success' | 'fail' | 'error' | 'invalid'; @@ -58,3 +59,49 @@ export const WebhookController = { } }, }; + +// 페이플 파트너 관리자에 등록한 결제결과 수신 웹훅. +// PCD_RST_URL 겸용인 handleWebhook과 달리 절대 리다이렉트하지 않는다 — +// 페이플은 302를 수신 실패로 보고 재전송하므로 성공/무시 모두 200이어야 한다. +// 실패 시에만 500을 반환해 페이플 재전송을 유도한다 (payout-webhook과 동일 규약). +export const handlePaypleWebhook = async (req: Request, res: Response) => { + const body = (req.body ?? {}) as Partial & { PCD_REFUND_TOTAL?: string }; + + if (typeof body.PCD_PAY_RST !== 'string') { + console.warn('[payple-webhook] 알 수 없는 페이로드', { body: redactPaypleLog(body) }); + return res.status(200).send('OK'); + } + + // 취소완료 이벤트도 같은 URL로 들어오지만 PCD_USER_DEFINE1(prompt_id/user_id)이 없어 + // 결제 처리 로직을 태울 수 없다. 환불 정본은 admin-refund 워크플로(#533)이므로 기록만 남긴다. + if (body.PCD_REFUND_TOTAL !== undefined) { + console.log('[payple-webhook] 취소 이벤트 수신 (처리 안 함)', { + oid: body.PCD_PAY_OID, + code: body.PCD_PAY_CODE, + refundTotal: body.PCD_REFUND_TOTAL, + }); + return res.status(200).send('OK'); + } + + if (body.PCD_PAY_RST !== 'success') { + console.log('[payple-webhook] 비성공 결과', { + oid: body.PCD_PAY_OID, + code: body.PCD_PAY_CODE, + msg: body.PCD_PAY_MSG, + }); + return res.status(200).send('OK'); + } + + try { + // 멱등성은 handlePaypleResult의 findExistingPurchase가 보장한다. + // /complete와 웹훅이 동시에 도착해도 구매가 중복 생성되지 않는다. + await WebhookService.handlePaypleResult(body as PayplePaymentResult); + return res.status(200).send('OK'); + } catch (err: any) { + console.error('[payple-webhook] 처리 실패 — 재전송 대기', { + oid: body.PCD_PAY_OID, + error: err?.message, + }); + return res.status(500).send('ERROR'); + } +}; diff --git a/src/purchases/routes/purchase.webhook.route.ts b/src/purchases/routes/purchase.webhook.route.ts index 82d19fc..fc1ff46 100644 --- a/src/purchases/routes/purchase.webhook.route.ts +++ b/src/purchases/routes/purchase.webhook.route.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import express from 'express'; -import { WebhookController } from '../controller/purchase.webhook.controller'; +import { WebhookController, handlePaypleWebhook } from '../controller/purchase.webhook.controller'; const router = Router(); @@ -11,4 +11,48 @@ router.post( WebhookController.handleWebhook ); +/** + * @swagger + * /api/prompts/purchases/payple-webhook: + * post: + * summary: Payple 결제결과 수신 webhook (가맹점 미수신 결과) + * description: | + * 파트너 관리자 〉 기본정보에 등록한 결제결과 수신 URL. 브라우저가 결제창에서 + * 돌아오지 못한 결제를 서버-투-서버로 보완해 결제결과 누락을 방지한다. + * + * PCD_RST_URL 겸용인 `/payple-result`와 달리 리다이렉트하지 않는다. + * (302를 수신 실패로 보고 재전송하는 것을 막기 위함) + * + * 멱등: 이미 처리된 결제면 아무것도 하지 않고 200. 처리 실패 시에만 500으로 + * 페이플 재전송을 유도한다. 취소완료 이벤트는 로그만 남기고 200. + * tags: [Purchase] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * PCD_PAY_RST: { type: string, description: success / error / close } + * PCD_PAY_CODE: { type: string } + * PCD_PAY_MSG: { type: string } + * PCD_PAY_OID: { type: string } + * PCD_PAY_TOTAL: { type: string } + * PCD_PAY_REQKEY: { type: string } + * PCD_AUTH_KEY: { type: string } + * PCD_PAY_COFURL: { type: string, description: 웹훅 페이로드의 재검증 URL (PCD_PAY_URL은 빈 값) } + * PCD_USER_DEFINE1: { type: string, description: prompt_id / user_id / agreed_at JSON } + * responses: + * 200: + * description: 처리 완료 또는 무시 (재전송 불필요) + * 500: + * description: 처리 실패 — 페이플 재전송 필요 + */ +router.post( + '/payple-webhook', + express.urlencoded({ extended: true }), + express.json(), + handlePaypleWebhook +); + export default router; diff --git a/src/purchases/utils/payple.ts b/src/purchases/utils/payple.ts index c66a130..9d3a4d8 100644 --- a/src/purchases/utils/payple.ts +++ b/src/purchases/utils/payple.ts @@ -94,6 +94,7 @@ export interface PayplePaymentResult { PCD_AUTH_KEY?: string; PCD_PAY_HOST?: string; PCD_PAY_URL?: string; + PCD_PAY_COFURL?: string; PCD_PAY_ISTAX?: string; PCD_PAY_TAXTOTAL?: string | number; PCD_PAY_CARDRECEIPT?: string; @@ -143,6 +144,31 @@ function parsePaypleTime(t?: string): Date { return new Date(`${y}-${mo}-${d}T${h}:${mi}:${s}+09:00`); } +// 재검증 요청을 보낼 URL 확정. +// 브라우저 리턴 페이로드는 PCD_PAY_HOST + PCD_PAY_URL 조합으로 오지만, +// 웹훅 페이로드는 PCD_PAY_URL이 빈 문자열이고 전체 URL이 PCD_PAY_COFURL로 온다. +// 두 값 모두 요청 본문에서 오므로 payple.kr 도메인인지 반드시 확인한다 — 확인이 없으면 +// 인증 없는 웹훅 엔드포인트를 통해 임의 호스트로 요청을 유도할 수 있다 (SSRF). +export function resolvePaypleConfirmUrl(result: PayplePaymentResult): string { + const raw = result.PCD_PAY_URL + ? `${result.PCD_PAY_HOST ?? ''}${result.PCD_PAY_URL}` + : result.PCD_PAY_COFURL ?? ''; + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new AppError('페이플 결제 검증에 필요한 키가 누락되었습니다.', 400, 'InvalidPaymentData'); + } + + const host = parsed.hostname; + if (parsed.protocol !== 'https:' || (host !== 'payple.kr' && !host.endsWith('.payple.kr'))) { + throw new AppError('페이플 결제 검증 요청 대상이 올바르지 않습니다.', 400, 'InvalidPaymentData'); + } + + return parsed.toString(); +} + export async function verifyPayplePayment( result: PayplePaymentResult, expected: { amount: number } @@ -163,17 +189,17 @@ export async function verifyPayplePayment( const reqKey = result.PCD_PAY_REQKEY; const authKey = result.PCD_AUTH_KEY; - const payHost = result.PCD_PAY_HOST; - const payUrl = result.PCD_PAY_URL; - if (!reqKey || !authKey || !payHost || !payUrl) { + if (!reqKey || !authKey) { throw new AppError('페이플 결제 검증에 필요한 키가 누락되었습니다.', 400, 'InvalidPaymentData'); } + const confirmUrl = resolvePaypleConfirmUrl(result); + let verified: PayplePaymentResult; try { const { data } = await axios.post( - `${payHost}${payUrl}`, + confirmUrl, { PCD_CST_ID: PAYPLE_PAY_CST_ID, PCD_CUST_KEY: PAYPLE_PAY_CUST_KEY, diff --git a/src/settlements/utils/payple.ts b/src/settlements/utils/payple.ts index c9c803a..800cf7d 100644 --- a/src/settlements/utils/payple.ts +++ b/src/settlements/utils/payple.ts @@ -48,6 +48,9 @@ const REDACTED_FIELDS = new Set([ 'PCD_PAYER_NAME', 'PCD_PAY_BANKNUM', 'PCD_PAY_CARDNUM', + // 결제결과 웹훅 페이로드에 실려 오는 개인정보 — 원문 로그 금지 + 'PCD_PAYER_HP', + 'PCD_PAYER_EMAIL', 'PCD_LASTKEY', 'AuthKey', // 결제 취소 (payple-refund.ts에서 재사용) diff --git a/swagger.json b/swagger.json index 4e1ce19..d3c0801 100644 --- a/swagger.json +++ b/swagger.json @@ -5161,6 +5161,65 @@ } } }, + "/api/prompts/purchases/payple-webhook": { + "post": { + "summary": "Payple 결제결과 수신 webhook (가맹점 미수신 결과)", + "description": "파트너 관리자 〉 기본정보에 등록한 결제결과 수신 URL. 브라우저가 결제창에서\n돌아오지 못한 결제를 서버-투-서버로 보완해 결제결과 누락을 방지한다.\n\nPCD_RST_URL 겸용인 `/payple-result`와 달리 리다이렉트하지 않는다.\n(302를 수신 실패로 보고 재전송하는 것을 막기 위함)\n\n멱등: 이미 처리된 결제면 아무것도 하지 않고 200. 처리 실패 시에만 500으로\n페이플 재전송을 유도한다. 취소완료 이벤트는 로그만 남기고 200.\n", + "tags": [ + "Purchase" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "PCD_PAY_RST": { + "type": "string", + "description": "success / error / close" + }, + "PCD_PAY_CODE": { + "type": "string" + }, + "PCD_PAY_MSG": { + "type": "string" + }, + "PCD_PAY_OID": { + "type": "string" + }, + "PCD_PAY_TOTAL": { + "type": "string" + }, + "PCD_PAY_REQKEY": { + "type": "string" + }, + "PCD_AUTH_KEY": { + "type": "string" + }, + "PCD_PAY_COFURL": { + "type": "string", + "description": "웹훅 페이로드의 재검증 URL (PCD_PAY_URL은 빈 값)" + }, + "PCD_USER_DEFINE1": { + "type": "string", + "description": "prompt_id / user_id / agreed_at JSON" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "처리 완료 또는 무시 (재전송 불필요)" + }, + "500": { + "description": "처리 실패 — 페이플 재전송 필요" + } + } + } + }, "/api/admin/refunds/pending": { "get": { "summary": "검토 대기 환불 신청 목록",