diff --git a/src/index.ts b/src/index.ts index 0d0ad33..8db0119 100644 --- a/src/index.ts +++ b/src/index.ts @@ -138,8 +138,9 @@ app.use("/api/prompts", promptRoutes); // 페이플 PCD_RST_URL 서버 콜백 (urlencoded/json 자체 파싱) app.use("/api/prompts/purchases", purchaseWebhookRouter); -// 프롬프트 결제 라우터 -app.use("/api/prompts/purchases", refundRouter); +// 프롬프트 결제 라우터 — purchaseRouter가 동적 :id 라우트를 추후 추가해도 +// refundRouter 경로(:purchaseId/refund, :purchaseId/refund-eligibility)와 +// 충돌하지 않도록 refundRouter는 purchaseRouter 뒤에 마운트. app.use( "/api/prompts/purchases", express.text({ type: "text/plain" }), @@ -153,6 +154,7 @@ app.use( }, purchaseRouter ); +app.use("/api/prompts/purchases", refundRouter); // 채팅 라우터 app.use("/api/chat", chatRouter); diff --git a/src/prompts/routes/prompt.download.route.ts b/src/prompts/routes/prompt.download.route.ts index 4092027..09af072 100644 --- a/src/prompts/routes/prompt.download.route.ts +++ b/src/prompts/routes/prompt.download.route.ts @@ -8,40 +8,23 @@ const router = Router(); * @swagger * tags: * name: PromptDownload - * description: 프롬프트 다운로드 / 상세 내용 조회 API - */ - -/** - * @swagger - * components: - * schemas: - * PromptContent: - * type: object - * properties: - * prompt_id: - * type: integer - * example: 123 - * title: - * type: string - * example: "챗GPT로 마케팅 자동화하기" - * content: - * type: string - * example: "당신은 마케팅 전문가입니다. 이 프롬프트는..." - * download_count: - * type: integer - * example: 42 - * created_at: - * type: string - * format: date-time - * example: "2025-07-29T10:00:00Z" + * description: 프롬프트 다운로드 / 구매 목록 조회 API */ /** * @swagger * /api/prompts/{promptId}/downloads: * get: - * summary: 프롬프트 상세 내용 또는 다운로드 - * description: 로그인한 사용자가 프롬프트 상세 내용을 확인하거나 다운로드합니다. + * summary: 프롬프트 상세 내용 다운로드 + * description: | + * 로그인한 사용자가 구매한(또는 무료) 프롬프트의 실제 본문을 조회합니다. + * + * 유료 프롬프트의 경우: + * - 결제 완료(`Payment.status='Succeed'`)된 본인 구매만 허용 + * - 환불된 구매는 403 Refunded로 차단 + * - 첫 다운로드 시점에 `Purchase.downloaded_at`을 기록 — 이후 환불 불가 (#485) + * + * 무료 프롬프트는 Purchase row가 없으면 자동 생성. * tags: [PromptDownload] * security: * - jwt: [] @@ -49,38 +32,93 @@ const router = Router(); * - in: path * name: promptId * required: true - * description: 조회할 프롬프트 ID - * schema: - * type: integer + * schema: { type: integer } * responses: * 200: - * description: 프롬프트 내용 반환 성공 + * description: 다운로드 성공 * content: * application/json: * schema: - * $ref: '#/components/schemas/PromptContent' + * type: object + * properties: + * message: { type: string, example: 프롬프트 다운로드 완료 } + * title: { type: string, example: "챗GPT 마케팅 자동화" } + * prompt: { type: string, description: 프롬프트 본문 } + * is_free: { type: boolean } + * is_paid: { type: boolean } + * statusCode: { type: integer, example: 200 } * 401: - * description: 인증이 필요합니다 + * description: 로그인 필요 + * 403: + * description: 결제 미완료 또는 환불됨 * content: * application/json: * schema: * type: object * properties: - * error: - * type: string - * example: Unauthorized - * message: - * type: string - * example: 로그인이 필요합니다. - * statusCode: - * type: number - * example: 401 + * error: { type: string, description: "Forbidden | Refunded" } + * message: { type: string } + * statusCode: { type: integer, example: 403 } + * examples: + * notPaid: + * summary: 유료 프롬프트 미결제 + * value: { error: Forbidden, message: 해당 프롬프트는 무료가 아니며, 결제가 완료되지 않았습니다., statusCode: 403 } + * refunded: + * summary: 환불된 프롬프트 재다운로드 시도 + * value: { error: Refunded, message: 환불된 프롬프트는 다시 다운로드할 수 없습니다., statusCode: 403 } * 404: - * description: 프롬프트를 찾을 수 없음 + * description: 프롬프트 없음 * 500: * description: 서버 오류 */ router.get('/:promptId/downloads', authenticateJwt, PromptDownloadController.getPromptContent); + +/** + * @swagger + * /api/prompts/downloads: + * get: + * summary: 본인이 구매한 프롬프트 목록 조회 + * description: 로그인한 사용자가 다운로드(구매)한 프롬프트 목록을 최신순으로 반환합니다. 무료/유료 모두 포함. + * tags: [PromptDownload] + * security: + * - jwt: [] + * responses: + * 200: + * description: 목록 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: { type: string, example: 프롬프트 다운로드 목록 조회 성공 } + * statusCode: { type: integer, example: 200 } + * data: + * type: array + * items: + * type: object + * properties: + * prompt_id: { type: integer } + * title: { type: string } + * description: { type: string, nullable: true } + * price: { type: integer } + * models: { type: array, items: { type: string } } + * imageUrls: { type: array, items: { type: string } } + * has_review: { type: boolean } + * is_recent_review: { type: boolean, description: 최근 30일 내 리뷰 작성 여부 } + * userNickname: { type: string } + * userProfileImageUrl: { type: string, nullable: true } + * userReview: + * type: object + * nullable: true + * properties: + * review_id: { type: integer } + * content: { type: string } + * rating: { type: number } + * 401: + * description: 로그인 필요 + * 500: + * description: 서버 오류 + */ router.get('/downloads', authenticateJwt, PromptDownloadController.getDownloadedPrompts); -export default router; \ No newline at end of file +export default router; diff --git a/src/purchases/routes/purchase.route.ts b/src/purchases/routes/purchase.route.ts index d28f123..c1c5827 100644 --- a/src/purchases/routes/purchase.route.ts +++ b/src/purchases/routes/purchase.route.ts @@ -101,11 +101,6 @@ router.post('/requests', authenticateJwt, PurchaseRequestController.requestPurch * purchased_at: * type: string * format: date-time - * pg: - * type: string - * description: 결제 제공자 (DB Enum) - * enum: [TOSSPAYMENTS, KAKAOPAY, TOSSPAY, NAVERPAY, SAMSUNGPAY, APPLEPAY, LPAY, PAYCO, SSG, PINPAY] - * nullable: true * statusCode: * type: integer */ @@ -152,7 +147,7 @@ router.get('/', authenticateJwt, PurchaseHistoryController.list); * type: object * properties: * message: { type: string } - * status: { type: string, enum: [Succeed, Failed, Pending] } + * status: { type: string, enum: [Succeed, Failed, Pending, Refunded] } * purchase_id: { type: integer } * statusCode: { type: integer } */ diff --git a/src/settlements/dtos/admin-seller.dto.ts b/src/settlements/dtos/admin-seller.dto.ts index b8a4ec2..b6f46d9 100644 --- a/src/settlements/dtos/admin-seller.dto.ts +++ b/src/settlements/dtos/admin-seller.dto.ts @@ -85,6 +85,7 @@ export interface IndividualSellerDetail { name: string; email: string; registration_type: 'INDIVIDUAL'; + status: 'APPROVED' | 'PENDING' | 'REJECTED'; settlement_account: SettlementAccountSummary; created_at: Date; updated_at: Date; @@ -97,6 +98,7 @@ export interface BusinessSellerDetail { name: string; email: string; registration_type: 'BUSINESS'; + status: 'APPROVED' | 'PENDING' | 'REJECTED'; business_number: string | null; representative_name: string | null; company_name: string | null; diff --git a/src/settlements/routes/admin-seller.route.ts b/src/settlements/routes/admin-seller.route.ts index c0ae618..fe07413 100644 --- a/src/settlements/routes/admin-seller.route.ts +++ b/src/settlements/routes/admin-seller.route.ts @@ -454,6 +454,7 @@ router.get('/business', authenticateJwt, isAdmin, getBusinessSellerList); * name: { type: string, example: 홍길동 } * email: { type: string, example: gildong@example.com } * registration_type: { type: string, example: INDIVIDUAL } + * status: { type: string, enum: [APPROVED, PENDING, REJECTED], example: APPROVED } * settlement_account: * type: object * properties: @@ -517,6 +518,7 @@ router.get( * name: { type: string, example: 홍길동 } * email: { type: string, example: gildong@example.com } * registration_type: { type: string, example: BUSINESS } + * status: { type: string, enum: [APPROVED, PENDING, REJECTED], example: APPROVED } * business_number: { type: string, nullable: true, example: "123-45-67890" } * representative_name: { type: string, nullable: true, example: 홍길동 } * company_name: { type: string, nullable: true, example: 홍길동컴퍼니 } diff --git a/src/settlements/routes/settlement.route.ts b/src/settlements/routes/settlement.route.ts index a2808bc..b7d6fc6 100644 --- a/src/settlements/routes/settlement.route.ts +++ b/src/settlements/routes/settlement.route.ts @@ -504,12 +504,11 @@ router.post("/register/business", authenticateJwt, registerBusiness); * get: * summary: 정산 예정 금액 조회 (대시보드) * description: | - * 로그인한 판매자의 정산 예정 금액(Settlement.status='Pending'인 행의 amount 합계)을 조회합니다. + * 로그인한 판매자의 정산 예정 금액(`Settlement.status='Pending'`인 행의 amount 합계)을 조회합니다. * 정산관리 화면 상단 대시보드에 노출. * - * ⚠️ 현재 코드에는 Settlement.status를 'Succeed'로 업데이트하는 정산 완료 처리 흐름이 없어서, - * 모든 미정산 거래의 누계가 반환됩니다. 별도 이슈에서 Payple 정산내역 조회와 연동한 동기화 흐름이 구현된 뒤에는 - * 실제 정산 예정분만 반환됩니다. + * 정산 완료 처리는 매일 KST 08:00에 동작하는 `settlement-sync` cron이 Payple 정산내역 조회 결과를 + * 바탕으로 `Pending → Succeed`로 전이합니다 (#482). 환불된 거래는 `Refunded` 상태가 되어 본 합계에서 제외됩니다 (#485). * tags: [Settlement] * security: * - jwt: [] diff --git a/src/settlements/services/admin-seller.service.ts b/src/settlements/services/admin-seller.service.ts index 1fea150..2e6a9cd 100644 --- a/src/settlements/services/admin-seller.service.ts +++ b/src/settlements/services/admin-seller.service.ts @@ -252,6 +252,7 @@ export const getIndividualSellerDetail = async ( name: account.user.name, email: account.user.email, registration_type: 'INDIVIDUAL', + status: account.status, settlement_account: { bank_code: account.bank_code, account_number: account.account_number, @@ -281,6 +282,7 @@ export const getBusinessSellerDetail = async ( name: account.user.name, email: account.user.email, registration_type: 'BUSINESS', + status: account.status, business_number: account.business_number, representative_name: account.representative_name, company_name: account.company_name, diff --git a/swagger.json b/swagger.json index 1471582..64287a8 100644 --- a/swagger.json +++ b/swagger.json @@ -3775,8 +3775,8 @@ }, "/api/prompts/{promptId}/downloads": { "get": { - "summary": "프롬프트 상세 내용 또는 다운로드", - "description": "로그인한 사용자가 프롬프트 상세 내용을 확인하거나 다운로드합니다.", + "summary": "프롬프트 상세 내용 다운로드", + "description": "로그인한 사용자가 구매한(또는 무료) 프롬프트의 실제 본문을 조회합니다.\n\n유료 프롬프트의 경우:\n- 결제 완료(`Payment.status='Succeed'`)된 본인 구매만 허용\n- 환불된 구매는 403 Refunded로 차단\n- 첫 다운로드 시점에 `Purchase.downloaded_at`을 기록 — 이후 환불 불가 (#485)\n\n무료 프롬프트는 Purchase row가 없으면 자동 생성.\n", "tags": [ "PromptDownload" ], @@ -3790,7 +3790,6 @@ "in": "path", "name": "promptId", "required": true, - "description": "조회할 프롬프트 ID", "schema": { "type": "integer" } @@ -3798,17 +3797,44 @@ ], "responses": { "200": { - "description": "프롬프트 내용 반환 성공", + "description": "다운로드 성공", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PromptContent" + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "프롬프트 다운로드 완료" + }, + "title": { + "type": "string", + "example": "챗GPT 마케팅 자동화" + }, + "prompt": { + "type": "string", + "description": "프롬프트 본문" + }, + "is_free": { + "type": "boolean" + }, + "is_paid": { + "type": "boolean" + }, + "statusCode": { + "type": "integer", + "example": 200 + } + } } } } }, "401": { - "description": "인증이 필요합니다", + "description": "로그인 필요" + }, + "403": { + "description": "결제 미완료 또는 환불됨", "content": { "application/json": { "schema": { @@ -3816,15 +3842,33 @@ "properties": { "error": { "type": "string", - "example": "Unauthorized" + "description": "Forbidden | Refunded" }, "message": { - "type": "string", - "example": "로그인이 필요합니다." + "type": "string" }, "statusCode": { - "type": "number", - "example": 401 + "type": "integer", + "example": 403 + } + } + }, + "examples": { + "notPaid": { + "summary": "유료 프롬프트 미결제", + "value": { + "error": "Forbidden", + "message": "해당 프롬프트는 무료가 아니며", + "결제가 완료되지 않았습니다.": null, + "statusCode": 403 + } + }, + "refunded": { + "summary": "환불된 프롬프트 재다운로드 시도", + "value": { + "error": "Refunded", + "message": "환불된 프롬프트는 다시 다운로드할 수 없습니다.", + "statusCode": 403 } } } @@ -3832,7 +3876,111 @@ } }, "404": { - "description": "프롬프트를 찾을 수 없음" + "description": "프롬프트 없음" + }, + "500": { + "description": "서버 오류" + } + } + } + }, + "/api/prompts/downloads": { + "get": { + "summary": "본인이 구매한 프롬프트 목록 조회", + "description": "로그인한 사용자가 다운로드(구매)한 프롬프트 목록을 최신순으로 반환합니다. 무료/유료 모두 포함.", + "tags": [ + "PromptDownload" + ], + "security": [ + { + "jwt": [] + } + ], + "responses": { + "200": { + "description": "목록 조회 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "프롬프트 다운로드 목록 조회 성공" + }, + "statusCode": { + "type": "integer", + "example": 200 + }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "prompt_id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "price": { + "type": "integer" + }, + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "imageUrls": { + "type": "array", + "items": { + "type": "string" + } + }, + "has_review": { + "type": "boolean" + }, + "is_recent_review": { + "type": "boolean", + "description": "최근 30일 내 리뷰 작성 여부" + }, + "userNickname": { + "type": "string" + }, + "userProfileImageUrl": { + "type": "string", + "nullable": true + }, + "userReview": { + "type": "object", + "nullable": true, + "properties": { + "review_id": { + "type": "integer" + }, + "content": { + "type": "string" + }, + "rating": { + "type": "number" + } + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "로그인 필요" }, "500": { "description": "서버 오류" @@ -4822,23 +4970,6 @@ "purchased_at": { "type": "string", "format": "date-time" - }, - "pg": { - "type": "string", - "description": "결제 제공자 (DB Enum)", - "enum": [ - "TOSSPAYMENTS", - "KAKAOPAY", - "TOSSPAY", - "NAVERPAY", - "SAMSUNGPAY", - "APPLEPAY", - "LPAY", - "PAYCO", - "SSG", - "PINPAY" - ], - "nullable": true } } } @@ -4941,7 +5072,8 @@ "enum": [ "Succeed", "Failed", - "Pending" + "Pending", + "Refunded" ] }, "purchase_id": { @@ -6688,6 +6820,15 @@ "type": "string", "example": "INDIVIDUAL" }, + "status": { + "type": "string", + "enum": [ + "APPROVED", + "PENDING", + "REJECTED" + ], + "example": "APPROVED" + }, "settlement_account": { "type": "object", "properties": { @@ -6801,6 +6942,15 @@ "type": "string", "example": "BUSINESS" }, + "status": { + "type": "string", + "enum": [ + "APPROVED", + "PENDING", + "REJECTED" + ], + "example": "APPROVED" + }, "business_number": { "type": "string", "nullable": true, @@ -7853,7 +8003,7 @@ "/api/settlements/pending-amount": { "get": { "summary": "정산 예정 금액 조회 (대시보드)", - "description": "로그인한 판매자의 정산 예정 금액(Settlement.status='Pending'인 행의 amount 합계)을 조회합니다.\n정산관리 화면 상단 대시보드에 노출.\n\n⚠️ 현재 코드에는 Settlement.status를 'Succeed'로 업데이트하는 정산 완료 처리 흐름이 없어서,\n모든 미정산 거래의 누계가 반환됩니다. 별도 이슈에서 Payple 정산내역 조회와 연동한 동기화 흐름이 구현된 뒤에는\n실제 정산 예정분만 반환됩니다.\n", + "description": "로그인한 판매자의 정산 예정 금액(`Settlement.status='Pending'`인 행의 amount 합계)을 조회합니다.\n정산관리 화면 상단 대시보드에 노출.\n\n정산 완료 처리는 매일 KST 08:00에 동작하는 `settlement-sync` cron이 Payple 정산내역 조회 결과를\n바탕으로 `Pending → Succeed`로 전이합니다 (#482). 환불된 거래는 `Refunded` 상태가 되어 본 합계에서 제외됩니다 (#485).\n", "tags": [ "Settlement" ], @@ -8715,32 +8865,6 @@ }, "components": { "schemas": { - "PromptContent": { - "type": "object", - "properties": { - "prompt_id": { - "type": "integer", - "example": 123 - }, - "title": { - "type": "string", - "example": "챗GPT로 마케팅 자동화하기" - }, - "content": { - "type": "string", - "example": "당신은 마케팅 전문가입니다. 이 프롬프트는..." - }, - "download_count": { - "type": "integer", - "example": 42 - }, - "created_at": { - "type": "string", - "format": "date-time", - "example": "2025-07-29T10:00:00Z" - } - } - }, "PromptModel": { "type": "object", "properties": { @@ -8819,7 +8943,7 @@ }, { "name": "PromptDownload", - "description": "프롬프트 다운로드 / 상세 내용 조회 API" + "description": "프롬프트 다운로드 / 구매 목록 조회 API" }, { "name": "PromptLike",