Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
27 changes: 27 additions & 0 deletions src/prompts/controllers/prompt.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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, "ํ”„๋กฌํ”„ํŠธ ์—…๋กœ๋“œ ์„ฑ๊ณต");
Expand Down
47 changes: 47 additions & 0 deletions src/purchases/controller/purchase.webhook.controller.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<PayplePaymentResult> & { 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');
}
};
46 changes: 45 additions & 1 deletion src/purchases/routes/purchase.webhook.route.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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;
34 changes: 30 additions & 4 deletions src/purchases/utils/payple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 }
Expand All @@ -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<PayplePaymentResult>(
`${payHost}${payUrl}`,
confirmUrl,
{
PCD_CST_ID: PAYPLE_PAY_CST_ID,
PCD_CUST_KEY: PAYPLE_PAY_CUST_KEY,
Expand Down
3 changes: 3 additions & 0 deletions src/settlements/utils/payple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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์—์„œ ์žฌ์‚ฌ์šฉ)
Expand Down
59 changes: 59 additions & 0 deletions swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "๊ฒ€ํ†  ๋Œ€๊ธฐ ํ™˜๋ถˆ ์‹ ์ฒญ ๋ชฉ๋ก",
Expand Down
Loading