diff --git a/.gitignore b/.gitignore index 2bf47f9..9c9af6e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,7 @@ node_modules .env.dev /generated/prisma -dist/ \ No newline at end of file +dist/ + +# Local tooling state +.omc/ \ No newline at end of file diff --git a/package.json b/package.json index 2dbbb5d..211b4e5 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.1", "multer": "^2.0.2", + "node-cron": "^4.2.1", "nodemailer": "^7.0.10", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", @@ -67,6 +68,7 @@ "@types/morgan": "^1.9.10", "@types/multer": "^2.0.0", "@types/node": "^24.0.10", + "@types/node-cron": "^3.0.11", "@types/nodemailer": "^7.0.3", "@types/passport": "^1.0.17", "@types/passport-jwt": "^4.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7ee95f..6b48593 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: multer: specifier: ^2.0.2 version: 2.0.2 + node-cron: + specifier: ^4.2.1 + version: 4.2.1 nodemailer: specifier: ^7.0.10 version: 7.0.10 @@ -150,6 +153,9 @@ importers: '@types/node': specifier: ^24.0.10 version: 24.10.0 + '@types/node-cron': + specifier: ^3.0.11 + version: 3.0.11 '@types/nodemailer': specifier: ^7.0.3 version: 7.0.3 @@ -1042,6 +1048,9 @@ packages: '@types/multer@2.0.0': resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==} + '@types/node-cron@3.0.11': + resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} + '@types/node@24.10.0': resolution: {integrity: sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==} @@ -2823,6 +2832,10 @@ packages: resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} engines: {node: ^18 || ^20 || >= 21} + node-cron@4.2.1: + resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} + engines: {node: '>=6.0.0'} + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} @@ -5305,6 +5318,8 @@ snapshots: dependencies: '@types/express': 5.0.5 + '@types/node-cron@3.0.11': {} + '@types/node@24.10.0': dependencies: undici-types: 7.16.0 @@ -7419,6 +7434,8 @@ snapshots: node-addon-api@8.5.0: {} + node-cron@4.2.1: {} + node-fetch-native@1.6.7: {} node-gyp-build@4.8.4: {} diff --git a/prisma/migrations/20260517005759_add_user_last_active_at/migration.sql b/prisma/migrations/20260517005759_add_user_last_active_at/migration.sql new file mode 100644 index 0000000..1cb1a4b --- /dev/null +++ b/prisma/migrations/20260517005759_add_user_last_active_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `User` ADD COLUMN `last_active_at` DATETIME(3) NULL; diff --git a/prisma/migrations/20260517013225_add_prompt_stat_daily/migration.sql b/prisma/migrations/20260517013225_add_prompt_stat_daily/migration.sql new file mode 100644 index 0000000..e8df0ed --- /dev/null +++ b/prisma/migrations/20260517013225_add_prompt_stat_daily/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE `PromptStatDaily` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `prompt_id` INTEGER NOT NULL, + `snapshot_date` VARCHAR(10) NOT NULL, + `views` INTEGER NOT NULL DEFAULT 0, + `downloads` INTEGER NOT NULL DEFAULT 0, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `PromptStatDaily_snapshot_date_idx`(`snapshot_date`), + UNIQUE INDEX `PromptStatDaily_prompt_id_snapshot_date_key`(`prompt_id`, `snapshot_date`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 62791ba..551cf9a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,6 +18,7 @@ model User { status Boolean userstatus userStatus @default(active) inactive_date DateTime? + last_active_at DateTime? created_at DateTime @default(now()) updated_at DateTime @updatedAt role Role @default(USER) @@ -283,6 +284,18 @@ model PromptModel { @@index([prompt_id], map: "PromptModel_prompt_id_fkey") } +model PromptStatDaily { + id Int @id @default(autoincrement()) + prompt_id Int + snapshot_date String @db.VarChar(10) + views Int @default(0) + downloads Int @default(0) + created_at DateTime @default(now()) + + @@unique([prompt_id, snapshot_date]) + @@index([snapshot_date]) +} + model Model { model_id Int @id @default(autoincrement()) name String @db.VarChar(50) diff --git a/src/config/passport.ts b/src/config/passport.ts index cae2725..c31f9cf 100644 --- a/src/config/passport.ts +++ b/src/config/passport.ts @@ -12,6 +12,7 @@ import { configureGoogleStrategy } from "./social/google"; import { configureNaverStrategy } from "./social/naver"; import { configureKakaoStrategy } from "./social/kakao"; import { isActive } from "../utils/status"; +import { recordUserActivity } from "../utils/user-activity"; import prisma from "./prisma"; // JWT Strategy 설정 @@ -40,6 +41,8 @@ passport.use( return done(error, false); } + void recordUserActivity(user.user_id); + return done(null, user); } catch (err) { return done(err as Error, false); diff --git a/src/index.ts b/src/index.ts index 877461d..0495696 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import http from "http"; import { Server } from "socket.io" import { responseHandler } from "./middlewares/responseHandler"; import { errorHandler } from "./middlewares/errorHandler"; +import { visitorTracker } from "./middlewares/visitorTracker"; import "reflect-metadata"; import passport from "./config/passport"; import swaggerUi from "swagger-ui-express"; @@ -29,11 +30,14 @@ import "./notifications/listeners/notification.listener"; // 알림 리스터 im import messageRouter from "./messages/routes/message.route"; import adminPromptRouter from "./prompts/routes/admin-prompt.route"; import adminMemberRouter from "./members/routes/admin-member.route"; +import adminSellerRouter from "./settlements/routes/admin-seller.route"; +import adminStatsRouter from "./stats/routes/admin-stats.route"; import signupRouter from "./signup/routes/signup.route" import signinRouter from "./signin/routes/signin.route"; 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 morgan = require('morgan'); const PORT = 3000; const app = express(); @@ -42,6 +46,7 @@ const app = express(); const server = http.createServer(app); initSocket(server) +startPromptStatSnapshotJob(); // 1. 응답 핸들러(json 파서보다 위에) app.use(responseHandler); app.use((req, res, next) => { @@ -100,6 +105,9 @@ app.use( swaggerUi.setup(swaggerJsdoc(swaggerOptions)) ); +// 방문자 추적 미들웨어 (응답 종료 시 HyperLogLog에 visitor_id 누적) +app.use(visitorTracker); + // 3. 모든 라우터들 // 로그인 라우터 @@ -153,6 +161,8 @@ app.use("/api/prompts", promptLikeRouter); // admin app.use("/api/admin/prompts", adminPromptRouter); +app.use("/api/admin/sellers", adminSellerRouter); +app.use("/api/admin/stats", adminStatsRouter); app.use("/api/admin", adminMemberRouter); // 팁 라우터 diff --git a/src/middlewares/visitorTracker.ts b/src/middlewares/visitorTracker.ts new file mode 100644 index 0000000..cfdf5f7 --- /dev/null +++ b/src/middlewares/visitorTracker.ts @@ -0,0 +1,34 @@ +import { Request, Response, NextFunction } from 'express'; +import { + computeVisitorId, + isBotUserAgent, + recordVisit, +} from '../utils/visitor-tracking'; + +const SKIP_PATH_PREFIXES = [ + '/health', + '/api-docs', + '/uploads', + '/api/notifications/sse', +]; + +const SKIP_METHODS = new Set(['OPTIONS', 'HEAD']); + +export const visitorTracker = ( + req: Request, + res: Response, + next: NextFunction, +): void => { + if (SKIP_METHODS.has(req.method)) return next(); + if (SKIP_PATH_PREFIXES.some((prefix) => req.path.startsWith(prefix))) { + return next(); + } + if (isBotUserAgent(req.headers['user-agent'])) return next(); + + res.on('finish', () => { + if (res.statusCode >= 400) return; + void recordVisit(computeVisitorId(req)); + }); + + next(); +}; diff --git a/src/settlements/controllers/admin-seller.controller.ts b/src/settlements/controllers/admin-seller.controller.ts new file mode 100644 index 0000000..82fdb21 --- /dev/null +++ b/src/settlements/controllers/admin-seller.controller.ts @@ -0,0 +1,163 @@ +import { Request, Response, NextFunction } from 'express'; +import { AppError } from '../../errors/AppError'; +import { + approvePendingBusinessSeller, + cancelSeller, + getBusinessSellerDetail, + getIndividualSellerDetail, + getPendingBusinessSellerDetail, + listBusinessSellers, + listIndividualSellers, + listPendingBusinessSellers, + rejectPendingBusinessSeller, +} from '../services/admin-seller.service'; +import { + ListPendingQueryDto, + ListSellersQueryDto, +} from '../dtos/admin-seller.dto'; + +const parseUserIdParam = (raw: string): number => { + const userId = Number(raw); + if (!Number.isInteger(userId) || userId <= 0) { + throw new AppError( + '유효하지 않은 사용자 ID입니다.', + 400, + 'ValidationError', + ); + } + return userId; +}; + +export const getPendingSellerList = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const result = await listPendingBusinessSellers( + req.query.page, + req.query.limit, + ); + return res.success(result, '승인 대기 사업자 판매자 목록을 조회했습니다.'); + } catch (error) { + return next(error); + } +}; + +export const getPendingSellerDetail = async ( + req: Request<{ userId: string }>, + res: Response, + next: NextFunction, +) => { + try { + const userId = parseUserIdParam(req.params.userId); + const result = await getPendingBusinessSellerDetail(userId); + return res.success(result, '승인 대기 사업자 판매자 상세 정보를 조회했습니다.'); + } catch (error) { + return next(error); + } +}; + +export const approveSeller = async ( + req: Request<{ userId: string }>, + res: Response, + next: NextFunction, +) => { + try { + const userId = parseUserIdParam(req.params.userId); + await approvePendingBusinessSeller(userId); + return res.success({ user_id: userId }, '사업자 판매자 등록을 승인했습니다.'); + } catch (error) { + return next(error); + } +}; + +export const rejectSeller = async ( + req: Request<{ userId: string }>, + res: Response, + next: NextFunction, +) => { + try { + const userId = parseUserIdParam(req.params.userId); + await rejectPendingBusinessSeller(userId); + return res.success({ user_id: userId }, '사업자 판매자 등록을 반려했습니다.'); + } catch (error) { + return next(error); + } +}; + +export const getIndividualSellerList = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const result = await listIndividualSellers( + req.query.page, + req.query.limit, + req.query.search, + ); + return res.success(result, '개인 판매자 목록을 조회했습니다.'); + } catch (error) { + return next(error); + } +}; + +export const getBusinessSellerList = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const result = await listBusinessSellers( + req.query.page, + req.query.limit, + req.query.search, + ); + return res.success(result, '사업자 판매자 목록을 조회했습니다.'); + } catch (error) { + return next(error); + } +}; + +export const getIndividualSellerDetailHandler = async ( + req: Request<{ userId: string }>, + res: Response, + next: NextFunction, +) => { + try { + const userId = parseUserIdParam(req.params.userId); + const result = await getIndividualSellerDetail(userId); + return res.success(result, '개인 판매자 상세 정보를 조회했습니다.'); + } catch (error) { + return next(error); + } +}; + +export const getBusinessSellerDetailHandler = async ( + req: Request<{ userId: string }>, + res: Response, + next: NextFunction, +) => { + try { + const userId = parseUserIdParam(req.params.userId); + const result = await getBusinessSellerDetail(userId); + return res.success(result, '사업자 판매자 상세 정보를 조회했습니다.'); + } catch (error) { + return next(error); + } +}; + +export const cancelSellerHandler = async ( + req: Request<{ userId: string }>, + res: Response, + next: NextFunction, +) => { + try { + const userId = parseUserIdParam(req.params.userId); + const result = await cancelSeller(userId); + return res.success(result, '판매자 등록을 취소했습니다.'); + } catch (error) { + return next(error); + } +}; diff --git a/src/settlements/dtos/admin-seller.dto.ts b/src/settlements/dtos/admin-seller.dto.ts new file mode 100644 index 0000000..b8a4ec2 --- /dev/null +++ b/src/settlements/dtos/admin-seller.dto.ts @@ -0,0 +1,112 @@ +export interface ListPendingQueryDto { + page?: string; + limit?: string; +} + +export interface PendingSellerUserSummary { + user_id: number; + name: string; + nickname: string; + email: string; + profile_image_url: string | null; +} + +export interface PendingSellerListItem { + user: PendingSellerUserSummary; + business_number: string | null; + company_name: string | null; + representative_name: string | null; + business_license_url: string | null; + created_at: Date; +} + +export interface PendingSellerListResponse { + items: PendingSellerListItem[]; + pagination: { + page: number; + limit: number; + total: number; + total_pages: number; + has_next: boolean; + }; +} + +export interface PendingSellerDetail extends PendingSellerListItem { + bank_code: string; + account_number: string; + account_holder: string; +} + +export interface ListSellersQueryDto { + page?: string; + limit?: string; + search?: string; +} + +export interface SettlementAccountSummary { + bank_code: string; + account_number: string; + account_holder: string; +} + +export interface IndividualSellerListItem { + user_id: number; + name: string; + email: string; + settlement_account: SettlementAccountSummary; + created_at: Date; +} + +export interface BusinessSellerListItem { + user_id: number; + profile_image_url: string | null; + nickname: string; + name: string; + email: string; + settlement_account: SettlementAccountSummary; + created_at: Date; +} + +export interface SellerListResponse { + items: T[]; + pagination: { + page: number; + limit: number; + total: number; + total_pages: number; + has_next: boolean; + }; +} + +export interface IndividualSellerDetail { + user_id: number; + profile_image_url: string | null; + nickname: string; + name: string; + email: string; + registration_type: 'INDIVIDUAL'; + settlement_account: SettlementAccountSummary; + created_at: Date; + updated_at: Date; +} + +export interface BusinessSellerDetail { + user_id: number; + profile_image_url: string | null; + nickname: string; + name: string; + email: string; + registration_type: 'BUSINESS'; + business_number: string | null; + representative_name: string | null; + company_name: string | null; + business_license_url: string | null; + settlement_account: SettlementAccountSummary; + created_at: Date; + updated_at: Date; +} + +export interface SellerCancellationResult { + user_id: number; + deactivated_prompt_count: number; +} diff --git a/src/settlements/repositories/admin-seller.repository.ts b/src/settlements/repositories/admin-seller.repository.ts new file mode 100644 index 0000000..e8af522 --- /dev/null +++ b/src/settlements/repositories/admin-seller.repository.ts @@ -0,0 +1,135 @@ +import prisma from '../../config/prisma'; +import { Prisma } from '@prisma/client'; + +const pendingBusinessFilter = { + seller_type: 'BUSINESS' as const, + status: 'PENDING' as const, +}; + +const buildSearchFilter = (search?: string): Prisma.UserWhereInput | undefined => { + if (!search) return undefined; + return { + OR: [ + { name: { contains: search } }, + { email: { contains: search } }, + { nickname: { contains: search } }, + ], + }; +}; + +const buildApprovedSellerWhere = ( + sellerType: 'INDIVIDUAL' | 'BUSINESS', + search?: string, +): Prisma.SettlementAccountWhereInput => { + const userFilter = buildSearchFilter(search); + return { + seller_type: sellerType, + status: 'APPROVED', + ...(userFilter ? { user: userFilter } : {}), + }; +}; + +const userInclude = { + user: { + select: { + user_id: true, + name: true, + nickname: true, + email: true, + profileImage: { select: { url: true } }, + }, + }, +}; + +export const AdminSellerRepository = { + findPendingBusinessSellers: async (skip: number, take: number) => { + return prisma.settlementAccount.findMany({ + where: pendingBusinessFilter, + include: userInclude, + orderBy: { created_at: 'desc' }, + skip, + take, + }); + }, + + countPendingBusinessSellers: async () => { + return prisma.settlementAccount.count({ where: pendingBusinessFilter }); + }, + + findPendingBusinessSellerByUserId: async (userId: number) => { + return prisma.settlementAccount.findFirst({ + where: { ...pendingBusinessFilter, user_id: userId }, + include: userInclude, + }); + }, + + updateBusinessSellerStatus: async ( + userId: number, + status: 'APPROVED' | 'REJECTED', + ) => { + return prisma.settlementAccount.update({ + where: { user_id: userId }, + data: { + status, + is_active: status === 'APPROVED', + }, + }); + }, + + findApprovedSellers: async ( + sellerType: 'INDIVIDUAL' | 'BUSINESS', + skip: number, + take: number, + search?: string, + ) => { + return prisma.settlementAccount.findMany({ + where: buildApprovedSellerWhere(sellerType, search), + include: userInclude, + orderBy: { created_at: 'desc' }, + skip, + take, + }); + }, + + countApprovedSellers: async ( + sellerType: 'INDIVIDUAL' | 'BUSINESS', + search?: string, + ) => { + return prisma.settlementAccount.count({ + where: buildApprovedSellerWhere(sellerType, search), + }); + }, + + findApprovedSellerByUserId: async ( + sellerType: 'INDIVIDUAL' | 'BUSINESS', + userId: number, + ) => { + return prisma.settlementAccount.findFirst({ + where: { + seller_type: sellerType, + status: 'APPROVED', + user_id: userId, + }, + include: userInclude, + }); + }, + + findApprovedSellerAnyType: async (userId: number) => { + return prisma.settlementAccount.findFirst({ + where: { user_id: userId, status: 'APPROVED' }, + }); + }, + + cancelSellerTransaction: async (userId: number) => { + const [deactivatedPrompts] = await prisma.$transaction([ + prisma.prompt.updateMany({ + where: { user_id: userId, inactive_date: null }, + data: { inactive_date: new Date() }, + }), + prisma.settlementAccount.delete({ + where: { user_id: userId }, + }), + ]); + return { deactivated_prompt_count: deactivatedPrompts.count }; + }, +}; diff --git a/src/settlements/routes/admin-seller.route.ts b/src/settlements/routes/admin-seller.route.ts new file mode 100644 index 0000000..c0ae618 --- /dev/null +++ b/src/settlements/routes/admin-seller.route.ts @@ -0,0 +1,604 @@ +import { Router } from 'express'; +import { authenticateJwt } from '../../config/passport'; +import { isAdmin } from '../../middlewares/isAdmin'; +import { + approveSeller, + cancelSellerHandler, + getBusinessSellerDetailHandler, + getBusinessSellerList, + getIndividualSellerDetailHandler, + getIndividualSellerList, + getPendingSellerDetail, + getPendingSellerList, + rejectSeller, +} from '../controllers/admin-seller.controller'; + +const router = Router(); + +/** + * @swagger + * tags: + * - name: AdminSeller + * description: 관리자 - 판매자 관리 API + */ + +/** + * @swagger + * /api/admin/sellers/pending: + * get: + * summary: 사업자 판매자 승인 대기 목록 조회 + * description: 관리자가 승인 대기 상태(`PENDING`)인 사업자 판매자 등록 신청 목록을 조회합니다. + * tags: [AdminSeller] + * security: + * - jwt: [] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * minimum: 1 + * default: 1 + * description: 페이지 번호 + * - in: query + * name: limit + * schema: + * type: integer + * minimum: 1 + * maximum: 50 + * default: 10 + * description: 페이지 당 항목 수 + * responses: + * 200: + * description: 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: 승인 대기 사업자 판매자 목록을 조회했습니다. + * statusCode: + * type: integer + * example: 200 + * data: + * type: object + * properties: + * items: + * type: array + * items: + * type: object + * properties: + * user: + * type: object + * properties: + * user_id: { type: integer, example: 12 } + * name: { type: string, example: 홍길동 } + * nickname: { type: string, example: gildong } + * email: { type: string, example: gildong@example.com } + * profile_image_url: + * type: string + * nullable: true + * example: https://cdn.example.com/users/12.png + * business_number: { type: string, nullable: true, example: "123-45-67890" } + * company_name: { type: string, nullable: true, example: 홍길동컴퍼니 } + * representative_name: { type: string, nullable: true, example: 홍길동 } + * business_license_url: + * type: string + * nullable: true + * example: https://s3.example.com/licenses/12.pdf + * created_at: { type: string, format: date-time } + * pagination: + * type: object + * properties: + * page: { type: integer, example: 1 } + * limit: { type: integer, example: 10 } + * total: { type: integer, example: 23 } + * total_pages: { type: integer, example: 3 } + * has_next: { type: boolean, example: true } + * 401: + * description: 인증 실패 - 로그인하지 않은 사용자 + * 403: + * description: 권한 없음 - 관리자가 아님 + */ +router.get('/pending', authenticateJwt, isAdmin, getPendingSellerList); + +/** + * @swagger + * /api/admin/sellers/pending/{userId}: + * get: + * summary: 사업자 판매자 승인 대기 상세 조회 + * description: 관리자가 특정 사용자의 승인 대기 중인 사업자 등록 신청 상세 정보를 조회합니다. + * tags: [AdminSeller] + * security: + * - jwt: [] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: integer + * description: 사용자 ID + * responses: + * 200: + * description: 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: 승인 대기 사업자 판매자 상세 정보를 조회했습니다. + * statusCode: + * type: integer + * example: 200 + * data: + * type: object + * properties: + * user: + * type: object + * properties: + * user_id: { type: integer } + * name: { type: string } + * nickname: { type: string } + * email: { type: string } + * profile_image_url: { type: string, nullable: true } + * business_number: { type: string, nullable: true } + * company_name: { type: string, nullable: true } + * representative_name: { type: string, nullable: true } + * business_license_url: { type: string, nullable: true } + * bank_code: { type: string } + * account_number: { type: string } + * account_holder: { type: string } + * created_at: { type: string, format: date-time } + * 400: + * description: 유효하지 않은 사용자 ID + * 401: + * description: 인증 실패 + * 403: + * description: 권한 없음 + * 404: + * description: 승인 대기 중인 신청이 존재하지 않음 + */ +router.get('/pending/:userId', authenticateJwt, isAdmin, getPendingSellerDetail); + +/** + * @swagger + * /api/admin/sellers/pending/{userId}/approve: + * patch: + * summary: 사업자 판매자 등록 승인 + * description: 관리자가 사업자 판매자 등록 신청을 승인합니다. `SettlementAccount.status`가 `APPROVED`로 전환되고 `is_active`가 `true`로 설정됩니다. + * tags: [AdminSeller] + * security: + * - jwt: [] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: integer + * description: 사용자 ID + * responses: + * 200: + * description: 승인 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: 사업자 판매자 등록을 승인했습니다. + * statusCode: + * type: integer + * example: 200 + * data: + * type: object + * properties: + * user_id: { type: integer, example: 12 } + * 400: + * description: 유효하지 않은 사용자 ID + * 401: + * description: 인증 실패 + * 403: + * description: 권한 없음 + * 404: + * description: 승인 대기 중인 신청이 존재하지 않음 + */ +router.patch( + '/pending/:userId/approve', + authenticateJwt, + isAdmin, + approveSeller, +); + +/** + * @swagger + * /api/admin/sellers/pending/{userId}: + * delete: + * summary: 사업자 판매자 등록 반려 + * description: 관리자가 사업자 판매자 등록 신청을 반려합니다. `SettlementAccount.status`가 `REJECTED`로 전환되어 승인 대기 목록에서 제외됩니다(soft delete). + * tags: [AdminSeller] + * security: + * - jwt: [] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: integer + * description: 사용자 ID + * responses: + * 200: + * description: 반려 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: 사업자 판매자 등록을 반려했습니다. + * statusCode: + * type: integer + * example: 200 + * data: + * type: object + * properties: + * user_id: { type: integer, example: 12 } + * 400: + * description: 유효하지 않은 사용자 ID + * 401: + * description: 인증 실패 + * 403: + * description: 권한 없음 + * 404: + * description: 승인 대기 중인 신청이 존재하지 않음 + */ +router.delete('/pending/:userId', authenticateJwt, isAdmin, rejectSeller); + +/** + * @swagger + * /api/admin/sellers/individual: + * get: + * summary: 개인 판매자 목록 조회 / 검색 + * description: 관리자가 승인 완료(`APPROVED`) 상태의 개인 판매자 목록을 조회합니다. `search` 파라미터로 실명/이메일/닉네임 부분 일치 검색이 가능합니다. + * tags: [AdminSeller] + * security: + * - jwt: [] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * minimum: 1 + * default: 1 + * description: 페이지 번호 + * - in: query + * name: limit + * schema: + * type: integer + * minimum: 1 + * maximum: 50 + * default: 10 + * description: 페이지 당 항목 수 + * - in: query + * name: search + * schema: + * type: string + * description: 실명, 이메일, 닉네임 부분 일치 검색어 + * responses: + * 200: + * description: 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: 개인 판매자 목록을 조회했습니다. + * statusCode: + * type: integer + * example: 200 + * data: + * type: object + * properties: + * items: + * type: array + * items: + * type: object + * properties: + * user_id: { type: integer, example: 12 } + * name: { type: string, example: 홍길동 } + * email: { type: string, example: gildong@example.com } + * settlement_account: + * type: object + * properties: + * bank_code: { type: string, example: KOOKMIN } + * account_number: { type: string, example: "1234567890" } + * account_holder: { type: string, example: 홍길동 } + * created_at: { type: string, format: date-time } + * pagination: + * type: object + * properties: + * page: { type: integer, example: 1 } + * limit: { type: integer, example: 10 } + * total: { type: integer, example: 23 } + * total_pages: { type: integer, example: 3 } + * has_next: { type: boolean, example: true } + * 401: + * description: 인증 실패 + * 403: + * description: 권한 없음 + */ +router.get('/individual', authenticateJwt, isAdmin, getIndividualSellerList); + +/** + * @swagger + * /api/admin/sellers/business: + * get: + * summary: 사업자 판매자 목록 조회 / 검색 + * description: 관리자가 승인 완료(`APPROVED`) 상태의 사업자 판매자 목록을 조회합니다. `search` 파라미터로 실명/이메일/닉네임 부분 일치 검색이 가능합니다. + * tags: [AdminSeller] + * security: + * - jwt: [] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * minimum: 1 + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * minimum: 1 + * maximum: 50 + * default: 10 + * - in: query + * name: search + * schema: + * type: string + * description: 실명, 이메일, 닉네임 부분 일치 검색어 + * responses: + * 200: + * description: 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: 사업자 판매자 목록을 조회했습니다. + * statusCode: + * type: integer + * example: 200 + * data: + * type: object + * properties: + * items: + * type: array + * items: + * type: object + * properties: + * user_id: { type: integer, example: 12 } + * profile_image_url: + * type: string + * nullable: true + * example: https://cdn.example.com/users/12.png + * nickname: { type: string, example: gildong } + * name: { type: string, example: 홍길동 } + * email: { type: string, example: gildong@example.com } + * settlement_account: + * type: object + * properties: + * bank_code: { type: string, example: KOOKMIN } + * account_number: { type: string, example: "1234567890" } + * account_holder: { type: string, example: 홍길동 } + * created_at: { type: string, format: date-time } + * pagination: + * type: object + * properties: + * page: { type: integer, example: 1 } + * limit: { type: integer, example: 10 } + * total: { type: integer, example: 23 } + * total_pages: { type: integer, example: 3 } + * has_next: { type: boolean, example: true } + * 401: + * description: 인증 실패 + * 403: + * description: 권한 없음 + */ +router.get('/business', authenticateJwt, isAdmin, getBusinessSellerList); + +/** + * @swagger + * /api/admin/sellers/individual/{userId}: + * get: + * summary: 개인 판매자 상세 조회 + * description: 관리자가 승인 완료(`APPROVED`)된 개인 판매자의 상세 정보를 조회합니다. + * tags: [AdminSeller] + * security: + * - jwt: [] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: integer + * description: 사용자 ID + * responses: + * 200: + * description: 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: 개인 판매자 상세 정보를 조회했습니다. + * statusCode: + * type: integer + * example: 200 + * data: + * type: object + * properties: + * user_id: { type: integer, example: 12 } + * profile_image_url: { type: string, nullable: true } + * nickname: { type: string, example: gildong } + * name: { type: string, example: 홍길동 } + * email: { type: string, example: gildong@example.com } + * registration_type: { type: string, example: INDIVIDUAL } + * settlement_account: + * type: object + * properties: + * bank_code: { type: string, example: KOOKMIN } + * account_number: { type: string, example: "1234567890" } + * account_holder: { type: string, example: 홍길동 } + * created_at: { type: string, format: date-time } + * updated_at: { type: string, format: date-time } + * 400: + * description: 유효하지 않은 사용자 ID + * 401: + * description: 인증 실패 + * 403: + * description: 권한 없음 + * 404: + * description: 승인 완료된 개인 판매자 정보가 존재하지 않음 + */ +router.get( + '/individual/:userId', + authenticateJwt, + isAdmin, + getIndividualSellerDetailHandler, +); + +/** + * @swagger + * /api/admin/sellers/business/{userId}: + * get: + * summary: 사업자 판매자 상세 조회 + * description: 관리자가 승인 완료(`APPROVED`)된 사업자 판매자의 상세 정보를 조회합니다. 사업자등록증 URL과 사업자 정보를 포함합니다. + * tags: [AdminSeller] + * security: + * - jwt: [] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: integer + * description: 사용자 ID + * responses: + * 200: + * description: 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: 사업자 판매자 상세 정보를 조회했습니다. + * statusCode: + * type: integer + * example: 200 + * data: + * type: object + * properties: + * user_id: { type: integer, example: 12 } + * profile_image_url: { type: string, nullable: true } + * nickname: { type: string, example: gildong } + * name: { type: string, example: 홍길동 } + * email: { type: string, example: gildong@example.com } + * registration_type: { type: string, example: BUSINESS } + * business_number: { type: string, nullable: true, example: "123-45-67890" } + * representative_name: { type: string, nullable: true, example: 홍길동 } + * company_name: { type: string, nullable: true, example: 홍길동컴퍼니 } + * business_license_url: + * type: string + * nullable: true + * example: https://s3.example.com/licenses/12.pdf + * settlement_account: + * type: object + * properties: + * bank_code: { type: string } + * account_number: { type: string } + * account_holder: { type: string } + * created_at: { type: string, format: date-time } + * updated_at: { type: string, format: date-time } + * 400: + * description: 유효하지 않은 사용자 ID + * 401: + * description: 인증 실패 + * 403: + * description: 권한 없음 + * 404: + * description: 승인 완료된 사업자 판매자 정보가 존재하지 않음 + */ +router.get( + '/business/:userId', + authenticateJwt, + isAdmin, + getBusinessSellerDetailHandler, +); + +/** + * @swagger + * /api/admin/sellers/{userId}: + * delete: + * summary: 판매자 등록 취소 + * description: | + * 관리자가 승인 완료(`APPROVED`) 상태의 판매자(개인/사업자 공통) 등록을 취소합니다. + * 다음 작업이 트랜잭션으로 함께 수행됩니다. + * + * - 사용자의 활성 프롬프트 일괄 비활성화 (`Prompt.inactive_date = now()`) + * - `SettlementAccount` 레코드 하드 삭제 + * + * 기존 구매/정산 이력은 영향받지 않습니다. + * tags: [AdminSeller] + * security: + * - jwt: [] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: integer + * description: 사용자 ID + * responses: + * 200: + * description: 취소 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: 판매자 등록을 취소했습니다. + * statusCode: + * type: integer + * example: 200 + * data: + * type: object + * properties: + * user_id: { type: integer, example: 12 } + * deactivated_prompt_count: { type: integer, example: 5 } + * 400: + * description: 유효하지 않은 사용자 ID + * 401: + * description: 인증 실패 + * 403: + * description: 권한 없음 + * 404: + * description: 승인 완료된 판매자가 아님 + */ +router.delete('/:userId', authenticateJwt, isAdmin, cancelSellerHandler); + +export default router; diff --git a/src/settlements/services/admin-seller.service.ts b/src/settlements/services/admin-seller.service.ts new file mode 100644 index 0000000..1fea150 --- /dev/null +++ b/src/settlements/services/admin-seller.service.ts @@ -0,0 +1,315 @@ +import { AdminSellerRepository } from '../repositories/admin-seller.repository'; +import { AppError } from '../../errors/AppError'; +import { + BusinessSellerDetail, + BusinessSellerListItem, + IndividualSellerDetail, + IndividualSellerListItem, + PendingSellerDetail, + PendingSellerListItem, + PendingSellerListResponse, + SellerCancellationResult, + SellerListResponse, +} from '../dtos/admin-seller.dto'; + +const DEFAULT_PAGE = 1; +const DEFAULT_LIMIT = 10; +const MAX_LIMIT = 50; + +const parsePagination = (page?: string, limit?: string) => { + const parsedPage = Number(page); + const parsedLimit = Number(limit); + + const safePage = + Number.isInteger(parsedPage) && parsedPage > 0 ? parsedPage : DEFAULT_PAGE; + const safeLimit = + Number.isInteger(parsedLimit) && parsedLimit > 0 + ? Math.min(parsedLimit, MAX_LIMIT) + : DEFAULT_LIMIT; + + return { page: safePage, limit: safeLimit }; +}; + +type SellerWithUser = Awaited< + ReturnType +>; + +const toListItem = ( + account: NonNullable, +): PendingSellerListItem => ({ + user: { + user_id: account.user.user_id, + name: account.user.name, + nickname: account.user.nickname, + email: account.user.email, + profile_image_url: account.user.profileImage?.url ?? null, + }, + business_number: account.business_number, + company_name: account.company_name, + representative_name: account.representative_name, + business_license_url: account.business_license_url, + created_at: account.created_at, +}); + +const toDetail = ( + account: NonNullable, +): PendingSellerDetail => ({ + ...toListItem(account), + bank_code: account.bank_code, + account_number: account.account_number, + account_holder: account.account_holder, +}); + +export const listPendingBusinessSellers = async ( + pageParam?: string, + limitParam?: string, +): Promise => { + const { page, limit } = parsePagination(pageParam, limitParam); + + const [accounts, total] = await Promise.all([ + AdminSellerRepository.findPendingBusinessSellers((page - 1) * limit, limit), + AdminSellerRepository.countPendingBusinessSellers(), + ]); + + const totalPages = total === 0 ? 0 : Math.ceil(total / limit); + + return { + items: accounts.map(toListItem), + pagination: { + page, + limit, + total, + total_pages: totalPages, + has_next: page < totalPages, + }, + }; +}; + +export const getPendingBusinessSellerDetail = async ( + userId: number, +): Promise => { + const account = + await AdminSellerRepository.findPendingBusinessSellerByUserId(userId); + + if (!account) { + throw new AppError( + '해당 사용자의 승인 대기 중인 사업자 등록 신청이 존재하지 않습니다.', + 404, + 'PendingSellerNotFound', + ); + } + + return toDetail(account); +}; + +export const approvePendingBusinessSeller = async (userId: number) => { + const account = + await AdminSellerRepository.findPendingBusinessSellerByUserId(userId); + + if (!account) { + throw new AppError( + '해당 사용자의 승인 대기 중인 사업자 등록 신청이 존재하지 않습니다.', + 404, + 'PendingSellerNotFound', + ); + } + + await AdminSellerRepository.updateBusinessSellerStatus(userId, 'APPROVED'); +}; + +export const rejectPendingBusinessSeller = async (userId: number) => { + const account = + await AdminSellerRepository.findPendingBusinessSellerByUserId(userId); + + if (!account) { + throw new AppError( + '해당 사용자의 승인 대기 중인 사업자 등록 신청이 존재하지 않습니다.', + 404, + 'PendingSellerNotFound', + ); + } + + await AdminSellerRepository.updateBusinessSellerStatus(userId, 'REJECTED'); +}; + +const normalizeSearch = (search?: string): string | undefined => { + const trimmed = search?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +}; + +const toSettlementAccountSummary = (account: NonNullable) => ({ + bank_code: account.bank_code, + account_number: account.account_number, + account_holder: account.account_holder, +}); + +const toIndividualListItem = ( + account: NonNullable, +): IndividualSellerListItem => ({ + user_id: account.user.user_id, + name: account.user.name, + email: account.user.email, + settlement_account: toSettlementAccountSummary(account), + created_at: account.created_at, +}); + +const toBusinessListItem = ( + account: NonNullable, +): BusinessSellerListItem => ({ + user_id: account.user.user_id, + profile_image_url: account.user.profileImage?.url ?? null, + nickname: account.user.nickname, + name: account.user.name, + email: account.user.email, + settlement_account: toSettlementAccountSummary(account), + created_at: account.created_at, +}); + +const buildPagination = (page: number, limit: number, total: number) => { + const totalPages = total === 0 ? 0 : Math.ceil(total / limit); + return { + page, + limit, + total, + total_pages: totalPages, + has_next: page < totalPages, + }; +}; + +export const listIndividualSellers = async ( + pageParam?: string, + limitParam?: string, + searchParam?: string, +): Promise> => { + const { page, limit } = parsePagination(pageParam, limitParam); + const search = normalizeSearch(searchParam); + + const [accounts, total] = await Promise.all([ + AdminSellerRepository.findApprovedSellers( + 'INDIVIDUAL', + (page - 1) * limit, + limit, + search, + ), + AdminSellerRepository.countApprovedSellers('INDIVIDUAL', search), + ]); + + return { + items: accounts.map(toIndividualListItem), + pagination: buildPagination(page, limit, total), + }; +}; + +export const listBusinessSellers = async ( + pageParam?: string, + limitParam?: string, + searchParam?: string, +): Promise> => { + const { page, limit } = parsePagination(pageParam, limitParam); + const search = normalizeSearch(searchParam); + + const [accounts, total] = await Promise.all([ + AdminSellerRepository.findApprovedSellers( + 'BUSINESS', + (page - 1) * limit, + limit, + search, + ), + AdminSellerRepository.countApprovedSellers('BUSINESS', search), + ]); + + return { + items: accounts.map(toBusinessListItem), + pagination: buildPagination(page, limit, total), + }; +}; + +const sellerNotFound = (sellerType: 'INDIVIDUAL' | 'BUSINESS') => { + const label = sellerType === 'INDIVIDUAL' ? '개인' : '사업자'; + return new AppError( + `해당 사용자의 승인 완료된 ${label} 판매자 등록 정보가 존재하지 않습니다.`, + 404, + 'SellerNotFound', + ); +}; + +export const getIndividualSellerDetail = async ( + userId: number, +): Promise => { + const account = await AdminSellerRepository.findApprovedSellerByUserId( + 'INDIVIDUAL', + userId, + ); + + if (!account) { + throw sellerNotFound('INDIVIDUAL'); + } + + return { + user_id: account.user.user_id, + profile_image_url: account.user.profileImage?.url ?? null, + nickname: account.user.nickname, + name: account.user.name, + email: account.user.email, + registration_type: 'INDIVIDUAL', + settlement_account: { + bank_code: account.bank_code, + account_number: account.account_number, + account_holder: account.account_holder, + }, + created_at: account.created_at, + updated_at: account.updated_at, + }; +}; + +export const getBusinessSellerDetail = async ( + userId: number, +): Promise => { + const account = await AdminSellerRepository.findApprovedSellerByUserId( + 'BUSINESS', + userId, + ); + + if (!account) { + throw sellerNotFound('BUSINESS'); + } + + return { + user_id: account.user.user_id, + profile_image_url: account.user.profileImage?.url ?? null, + nickname: account.user.nickname, + name: account.user.name, + email: account.user.email, + registration_type: 'BUSINESS', + business_number: account.business_number, + representative_name: account.representative_name, + company_name: account.company_name, + business_license_url: account.business_license_url, + settlement_account: { + bank_code: account.bank_code, + account_number: account.account_number, + account_holder: account.account_holder, + }, + created_at: account.created_at, + updated_at: account.updated_at, + }; +}; + +export const cancelSeller = async ( + userId: number, +): Promise => { + const account = await AdminSellerRepository.findApprovedSellerAnyType(userId); + + if (!account) { + throw new AppError( + '해당 사용자는 승인 완료된 판매자로 등록되어 있지 않습니다.', + 404, + 'SellerNotFound', + ); + } + + const { deactivated_prompt_count } = + await AdminSellerRepository.cancelSellerTransaction(userId); + + return { user_id: userId, deactivated_prompt_count }; +}; diff --git a/src/stats/controllers/admin-popular-prompts.controller.ts b/src/stats/controllers/admin-popular-prompts.controller.ts new file mode 100644 index 0000000..862c49f --- /dev/null +++ b/src/stats/controllers/admin-popular-prompts.controller.ts @@ -0,0 +1,15 @@ +import { Request, Response, NextFunction } from 'express'; +import { getPopularPrompts } from '../services/admin-popular-prompts.service'; + +export const getPopularPromptsHandler = async ( + _req: Request, + res: Response, + next: NextFunction, +) => { + try { + const result = await getPopularPrompts(); + return res.success(result, '인기 프롬프트를 조회했습니다.'); + } catch (error) { + return next(error); + } +}; diff --git a/src/stats/controllers/admin-prompt-stats.controller.ts b/src/stats/controllers/admin-prompt-stats.controller.ts new file mode 100644 index 0000000..37ef857 --- /dev/null +++ b/src/stats/controllers/admin-prompt-stats.controller.ts @@ -0,0 +1,31 @@ +import { Request, Response, NextFunction } from 'express'; +import { + getNewPromptStats, + getTopSalesPrompts, +} from '../services/admin-prompt-stats.service'; + +export const getNewPromptStatsHandler = async ( + _req: Request, + res: Response, + next: NextFunction, +) => { + try { + const result = await getNewPromptStats(); + return res.success(result, '신규 프롬프트 통계를 조회했습니다.'); + } catch (error) { + return next(error); + } +}; + +export const getTopSalesPromptsHandler = async ( + _req: Request, + res: Response, + next: NextFunction, +) => { + try { + const result = await getTopSalesPrompts(); + return res.success(result, '매출 상위 프롬프트를 조회했습니다.'); + } catch (error) { + return next(error); + } +}; diff --git a/src/stats/controllers/admin-stats.controller.ts b/src/stats/controllers/admin-stats.controller.ts new file mode 100644 index 0000000..f0880f1 --- /dev/null +++ b/src/stats/controllers/admin-stats.controller.ts @@ -0,0 +1,31 @@ +import { Request, Response, NextFunction } from 'express'; +import { + getActiveUserStats, + getMemberStats, +} from '../services/admin-stats.service'; + +export const getMemberStatsHandler = async ( + _req: Request, + res: Response, + next: NextFunction, +) => { + try { + const result = await getMemberStats(); + return res.success(result, '회원 가입 현황을 조회했습니다.'); + } catch (error) { + return next(error); + } +}; + +export const getActiveUserStatsHandler = async ( + _req: Request, + res: Response, + next: NextFunction, +) => { + try { + const result = await getActiveUserStats(); + return res.success(result, '활성 사용자 통계를 조회했습니다.'); + } catch (error) { + return next(error); + } +}; diff --git a/src/stats/controllers/admin-visitor-stats.controller.ts b/src/stats/controllers/admin-visitor-stats.controller.ts new file mode 100644 index 0000000..01886cf --- /dev/null +++ b/src/stats/controllers/admin-visitor-stats.controller.ts @@ -0,0 +1,16 @@ +import { Request, Response, NextFunction } from 'express'; +import { getVisitorStats } from '../services/admin-visitor-stats.service'; +import { VisitorStatsQueryDto } from '../dtos/admin-stats.dto'; + +export const getVisitorStatsHandler = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const result = await getVisitorStats(req.query.month); + return res.success(result, '방문자 통계를 조회했습니다.'); + } catch (error) { + return next(error); + } +}; diff --git a/src/stats/dtos/admin-popular-prompts.dto.ts b/src/stats/dtos/admin-popular-prompts.dto.ts new file mode 100644 index 0000000..c478e12 --- /dev/null +++ b/src/stats/dtos/admin-popular-prompts.dto.ts @@ -0,0 +1,14 @@ +export interface PopularPromptItem { + rank: number; + prompt_id: number; + title: string; + views_delta: number; + downloads_delta: number; + score: number; +} + +export interface PopularPromptsResponse { + period_days: number; + snapshot_date: string; + items: PopularPromptItem[]; +} diff --git a/src/stats/dtos/admin-prompt-stats.dto.ts b/src/stats/dtos/admin-prompt-stats.dto.ts new file mode 100644 index 0000000..38e1df1 --- /dev/null +++ b/src/stats/dtos/admin-prompt-stats.dto.ts @@ -0,0 +1,22 @@ +export interface DailyUploadBucket { + date: string; + count: number; +} + +export interface NewPromptStatsResponse { + daily_count: number; + weekly_count: number; + daily_uploads: DailyUploadBucket[]; +} + +export interface TopSalesPromptItem { + rank: number; + prompt_id: number; + title: string | null; + total_sales: number; +} + +export interface TopSalesPromptsResponse { + period_days: number; + items: TopSalesPromptItem[]; +} diff --git a/src/stats/dtos/admin-stats.dto.ts b/src/stats/dtos/admin-stats.dto.ts new file mode 100644 index 0000000..fbc8789 --- /dev/null +++ b/src/stats/dtos/admin-stats.dto.ts @@ -0,0 +1,38 @@ +export type SignupChannel = 'email' | 'google' | 'kakao' | 'naver'; + +export interface SignupChannelStats { + count: number; + ratio: number; +} + +export interface MemberStatsResponse { + total_members: number; + by_signup_channel: Record; +} + +export interface ActiveUserStatsResponse { + window_days: number; + current_count: number; + previous_count: number; + change_rate: number | null; +} + +export interface VisitorStatsQueryDto { + month?: string; +} + +export interface VisitorDailyBucket { + date: string; + count: number; +} + +export interface VisitorStatsResponse { + daily_count: number; + window_days: number; + current_count: number; + previous_count: number; + change_rate: number | null; + month: string | null; + month_total: number | null; + month_daily: VisitorDailyBucket[] | null; +} diff --git a/src/stats/jobs/prompt-stat-snapshot.job.ts b/src/stats/jobs/prompt-stat-snapshot.job.ts new file mode 100644 index 0000000..0d6bfda --- /dev/null +++ b/src/stats/jobs/prompt-stat-snapshot.job.ts @@ -0,0 +1,29 @@ +import cron from 'node-cron'; +import { + ensureTodaySnapshot, + runDailyPromptStatJob, +} from '../services/prompt-stat-snapshot.service'; + +const CRON_EXPRESSION = '0 0 * * *'; +const TIMEZONE = 'Asia/Seoul'; + +export const startPromptStatSnapshotJob = (): void => { + void ensureTodaySnapshot(); + + cron.schedule( + CRON_EXPRESSION, + async () => { + try { + await runDailyPromptStatJob(); + } catch (error) { + console.error('[prompt-stat-cron] scheduled run failed', error); + } + }, + { timezone: TIMEZONE }, + ); + + console.log('[prompt-stat-cron] scheduled', { + cron: CRON_EXPRESSION, + timezone: TIMEZONE, + }); +}; diff --git a/src/stats/repositories/admin-popular-prompts.repository.ts b/src/stats/repositories/admin-popular-prompts.repository.ts new file mode 100644 index 0000000..1beccb9 --- /dev/null +++ b/src/stats/repositories/admin-popular-prompts.repository.ts @@ -0,0 +1,26 @@ +import prisma from '../../config/prisma'; + +export const AdminPopularPromptsRepository = { + findActivePrompts: async () => { + return prisma.prompt.findMany({ + where: { inactive_date: null }, + select: { + prompt_id: true, + title: true, + views: true, + downloads: true, + }, + }); + }, + + findSnapshotsByDate: async (snapshotDate: string, promptIds: number[]) => { + if (promptIds.length === 0) return []; + return prisma.promptStatDaily.findMany({ + where: { + snapshot_date: snapshotDate, + prompt_id: { in: promptIds }, + }, + select: { prompt_id: true, views: true, downloads: true }, + }); + }, +}; diff --git a/src/stats/repositories/admin-prompt-stats.repository.ts b/src/stats/repositories/admin-prompt-stats.repository.ts new file mode 100644 index 0000000..beab2f4 --- /dev/null +++ b/src/stats/repositories/admin-prompt-stats.repository.ts @@ -0,0 +1,28 @@ +import prisma from '../../config/prisma'; + +export const AdminPromptStatsRepository = { + findActivePromptCreatedAtsSince: async (since: Date) => { + return prisma.prompt.findMany({ + where: { created_at: { gte: since }, inactive_date: null }, + select: { created_at: true }, + }); + }, + + groupPaidPurchaseAmountSince: async (since: Date, take: number) => { + return prisma.purchase.groupBy({ + by: ['prompt_id'], + where: { created_at: { gte: since }, is_free: false }, + _sum: { amount: true }, + orderBy: { _sum: { amount: 'desc' } }, + take, + }); + }, + + findPromptTitlesByIds: async (promptIds: number[]) => { + if (promptIds.length === 0) return []; + return prisma.prompt.findMany({ + where: { prompt_id: { in: promptIds } }, + select: { prompt_id: true, title: true }, + }); + }, +}; diff --git a/src/stats/repositories/admin-stats.repository.ts b/src/stats/repositories/admin-stats.repository.ts new file mode 100644 index 0000000..ce409de --- /dev/null +++ b/src/stats/repositories/admin-stats.repository.ts @@ -0,0 +1,20 @@ +import prisma from '../../config/prisma'; + +export const AdminStatsRepository = { + countMembersBySocialType: async () => { + return prisma.user.groupBy({ + by: ['social_type'], + where: { userstatus: { not: 'deleted' } }, + _count: { _all: true }, + }); + }, + + countActiveUsersInRange: async (start: Date, end: Date) => { + return prisma.user.count({ + where: { + userstatus: { not: 'deleted' }, + last_active_at: { gte: start, lt: end }, + }, + }); + }, +}; diff --git a/src/stats/repositories/admin-visitor-stats.repository.ts b/src/stats/repositories/admin-visitor-stats.repository.ts new file mode 100644 index 0000000..b64239b --- /dev/null +++ b/src/stats/repositories/admin-visitor-stats.repository.ts @@ -0,0 +1,24 @@ +import redisClient from '../../config/redis'; +import { buildHllKey } from '../../utils/visitor-tracking'; + +export const AdminVisitorStatsRepository = { + countUniqueOnDate: async (kstDate: string): Promise => { + return Number(await redisClient.pfCount(buildHllKey(kstDate))); + }, + + countUniqueOverRange: async (kstDates: string[]): Promise => { + if (kstDates.length === 0) return 0; + const keys = kstDates.map(buildHllKey); + return Number(await redisClient.pfCount(keys)); + }, + + countUniquePerDay: async ( + kstDates: string[], + ): Promise<{ date: string; count: number }[]> => { + if (kstDates.length === 0) return []; + const counts = await Promise.all( + kstDates.map((d) => redisClient.pfCount(buildHllKey(d))), + ); + return kstDates.map((date, i) => ({ date, count: Number(counts[i]) })); + }, +}; diff --git a/src/stats/routes/admin-stats.route.ts b/src/stats/routes/admin-stats.route.ts new file mode 100644 index 0000000..43198d2 --- /dev/null +++ b/src/stats/routes/admin-stats.route.ts @@ -0,0 +1,358 @@ +import { Router } from 'express'; +import { authenticateJwt } from '../../config/passport'; +import { isAdmin } from '../../middlewares/isAdmin'; +import { + getActiveUserStatsHandler, + getMemberStatsHandler, +} from '../controllers/admin-stats.controller'; +import { + getNewPromptStatsHandler, + getTopSalesPromptsHandler, +} from '../controllers/admin-prompt-stats.controller'; +import { getVisitorStatsHandler } from '../controllers/admin-visitor-stats.controller'; +import { getPopularPromptsHandler } from '../controllers/admin-popular-prompts.controller'; + +const router = Router(); + +/** + * @swagger + * tags: + * - name: AdminStats + * description: 관리자 - 대시보드 통계 API + */ + +/** + * @swagger + * /api/admin/stats/members: + * get: + * summary: 회원 가입 현황 조회 + * description: | + * 관리자 대시보드의 사용자 통계 영역에서 사용할 회원 가입 현황을 조회합니다. + * + * - 총 회원수: 탈퇴(`deleted`) 상태를 제외한 전체 회원수 + * - 가입 경로별 비율: 자체 이메일(`NONE`) / 구글 / 카카오 / 네이버 + * + * `ratio`는 0~1 사이 값(소수점 4자리)으로 반환됩니다. + * tags: [AdminStats] + * security: + * - jwt: [] + * responses: + * 200: + * description: 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: 회원 가입 현황을 조회했습니다. + * statusCode: + * type: integer + * example: 200 + * data: + * type: object + * properties: + * total_members: { type: integer, example: 1234 } + * by_signup_channel: + * type: object + * properties: + * email: + * type: object + * properties: + * count: { type: integer, example: 700 } + * ratio: { type: number, example: 0.5673 } + * google: + * type: object + * properties: + * count: { type: integer, example: 250 } + * ratio: { type: number, example: 0.2026 } + * kakao: + * type: object + * properties: + * count: { type: integer, example: 200 } + * ratio: { type: number, example: 0.1621 } + * naver: + * type: object + * properties: + * count: { type: integer, example: 84 } + * ratio: { type: number, example: 0.0681 } + * 401: + * description: 인증 실패 + * 403: + * description: 권한 없음 + */ +router.get('/members', authenticateJwt, isAdmin, getMemberStatsHandler); + +/** + * @swagger + * /api/admin/stats/active-users: + * get: + * summary: 활성 사용자 통계 조회 + * description: | + * 최근 30일 롤링 윈도우 기준 활성 사용자 수와 전 구간(그 이전 30일) 대비 증감율을 반환합니다. + * + * - `current_count`: now - 30d ~ now 사이에 `last_active_at`이 기록된 사용자 수 + * - `previous_count`: now - 60d ~ now - 30d 사이의 활성 사용자 수 + * - `change_rate`: `(current - previous) / previous` (소수 4자리). 이전 구간이 0이면 `null` + * - 탈퇴(`userstatus=deleted`) 사용자는 제외 + * + * 활동 기록은 JWT 인증을 통과한 모든 요청에서 5분 throttle로 갱신됩니다. + * tags: [AdminStats] + * security: + * - jwt: [] + * responses: + * 200: + * description: 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: { type: string, example: 활성 사용자 통계를 조회했습니다. } + * statusCode: { type: integer, example: 200 } + * data: + * type: object + * properties: + * window_days: { type: integer, example: 30 } + * current_count: { type: integer, example: 1234 } + * previous_count: { type: integer, example: 1100 } + * change_rate: + * type: number + * nullable: true + * example: 0.1218 + * 401: + * description: 인증 실패 + * 403: + * description: 권한 없음 + */ +router.get( + '/active-users', + authenticateJwt, + isAdmin, + getActiveUserStatsHandler, +); + +/** + * @swagger + * /api/admin/stats/visitors: + * get: + * summary: 방문자 통계 조회 + * description: | + * 방문자 통계를 조회합니다. 모든 카운트는 KST(Asia/Seoul) 캘린더 기준이며 Redis HyperLogLog를 사용해 고유 방문자 수를 추정(오차 ~0.81%)합니다. + * + * - `daily_count`: 오늘(KST) 고유 방문자 수 + * - `current_count`: 최근 30일 롤링 윈도우 고유 방문자 수 (합집합) + * - `previous_count`: 그 이전 30일 (now-60d ~ now-30d) 고유 방문자 수 + * - `change_rate`: `(current - previous) / previous` (소수 4자리). 이전 구간 0이면 `null` + * - `?month=YYYY-MM` 파라미터 제공 시 `month_total`(해당 월 고유 방문자 합) + `month_daily`(일별 분포) 함께 반환 + * + * 활동 기록은 모든 비-봇 API 요청에서 KST 일별 HyperLogLog 키에 `PFADD`로 누적됩니다. + * tags: [AdminStats] + * security: + * - jwt: [] + * parameters: + * - in: query + * name: month + * schema: + * type: string + * pattern: "^\\d{4}-(0[1-9]|1[0-2])$" + * example: "2026-05" + * description: 특정 월의 일별 분포 조회 (선택) + * responses: + * 200: + * description: 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: { type: string, example: 방문자 통계를 조회했습니다. } + * statusCode: { type: integer, example: 200 } + * data: + * type: object + * properties: + * daily_count: { type: integer, example: 312 } + * window_days: { type: integer, example: 30 } + * current_count: { type: integer, example: 9450 } + * previous_count: { type: integer, example: 8200 } + * change_rate: + * type: number + * nullable: true + * example: 0.1524 + * month: + * type: string + * nullable: true + * example: "2026-05" + * month_total: + * type: integer + * nullable: true + * example: 8970 + * month_daily: + * type: array + * nullable: true + * items: + * type: object + * properties: + * date: { type: string, example: "2026-05-01" } + * count: { type: integer, example: 287 } + * 400: + * description: 잘못된 month 파라미터 형식 + * 401: + * description: 인증 실패 + * 403: + * description: 권한 없음 + */ +router.get('/visitors', authenticateJwt, isAdmin, getVisitorStatsHandler); + +/** + * @swagger + * /api/admin/stats/prompts/new: + * get: + * summary: 신규 프롬프트 통계 조회 + * description: | + * 관리자 대시보드의 신규 프롬프트 영역에서 사용하는 통계 API. + * + * - `daily_count`: 최근 24시간(롤링 윈도우) 동안 업로드된 활성 프롬프트 수 + * - `weekly_count`: 최근 7일(KST 캘린더 기준) 업로드 합계 + * - `daily_uploads`: 최근 7일치 일자별 업로드 수 (KST 기준 `YYYY-MM-DD`, 막대 그래프용) + * + * 비활성(`inactive_date IS NOT NULL`) 프롬프트는 모두 제외됩니다. + * tags: [AdminStats] + * security: + * - jwt: [] + * responses: + * 200: + * description: 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: { type: string, example: 신규 프롬프트 통계를 조회했습니다. } + * statusCode: { type: integer, example: 200 } + * data: + * type: object + * properties: + * daily_count: { type: integer, example: 12 } + * weekly_count: { type: integer, example: 86 } + * daily_uploads: + * type: array + * items: + * type: object + * properties: + * date: { type: string, example: "2026-05-11" } + * count: { type: integer, example: 14 } + * 401: + * description: 인증 실패 + * 403: + * description: 권한 없음 + */ +router.get('/prompts/new', authenticateJwt, isAdmin, getNewPromptStatsHandler); + +/** + * @swagger + * /api/admin/stats/prompts/top-sales: + * get: + * summary: 매출 상위 프롬프트 Top 5 조회 + * description: | + * 최근 30일간 매출액 기준 상위 5개 프롬프트를 조회합니다. + * + * - 기준: `Purchase.amount` 합계 (`is_free=false`만 합산) + * - 기간: 최근 30일(롤링 윈도우) + * - 정렬: `total_sales DESC`, 동률 시 Prisma 기본 순서 + * - 프롬프트가 삭제·비활성화되어도 매출 집계에는 포함되며, 제목이 없을 경우 `title: null`로 반환됩니다. + * tags: [AdminStats] + * security: + * - jwt: [] + * responses: + * 200: + * description: 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: { type: string, example: 매출 상위 프롬프트를 조회했습니다. } + * statusCode: { type: integer, example: 200 } + * data: + * type: object + * properties: + * period_days: { type: integer, example: 30 } + * items: + * type: array + * items: + * type: object + * properties: + * rank: { type: integer, example: 1 } + * prompt_id: { type: integer, example: 42 } + * title: { type: string, nullable: true, example: "ChatGPT 마케팅 카피 30종" } + * total_sales: { type: integer, example: 825000 } + * 401: + * description: 인증 실패 + * 403: + * description: 권한 없음 + */ +router.get( + '/prompts/top-sales', + authenticateJwt, + isAdmin, + getTopSalesPromptsHandler, +); + +/** + * @swagger + * /api/admin/stats/prompts/popular: + * get: + * summary: 인기 프롬프트 Top 5 조회 + * description: | + * 최근 7일간 (조회수 증가분 + 다운로드 증가분) 기준 상위 5개 프롬프트를 조회합니다. + * + * - 일일 스냅샷(`PromptStatDaily`)과 현재값의 차분으로 7일 윈도우 계산 + * - 활성(`inactive_date IS NULL`) 프롬프트만 포함 + * - 7일 전 스냅샷이 없으면 baseline=0으로 간주 (신규 프롬프트도 포함됨) + * - 동률 시 정렬 안정성은 보장하지 않음 + * + * 스냅샷은 매일 00:00 KST에 자동 수행되며 90일치 보관됩니다. + * tags: [AdminStats] + * security: + * - jwt: [] + * responses: + * 200: + * description: 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: { type: string, example: 인기 프롬프트를 조회했습니다. } + * statusCode: { type: integer, example: 200 } + * data: + * type: object + * properties: + * period_days: { type: integer, example: 7 } + * snapshot_date: { type: string, example: "2026-05-10" } + * items: + * type: array + * items: + * type: object + * properties: + * rank: { type: integer, example: 1 } + * prompt_id: { type: integer, example: 42 } + * title: { type: string, example: "ChatGPT 마케팅 카피 30종" } + * views_delta: { type: integer, example: 1240 } + * downloads_delta: { type: integer, example: 87 } + * score: { type: integer, example: 1327 } + * 401: + * description: 인증 실패 + * 403: + * description: 권한 없음 + */ +router.get( + '/prompts/popular', + authenticateJwt, + isAdmin, + getPopularPromptsHandler, +); + +export default router; diff --git a/src/stats/services/admin-popular-prompts.service.ts b/src/stats/services/admin-popular-prompts.service.ts new file mode 100644 index 0000000..97245c0 --- /dev/null +++ b/src/stats/services/admin-popular-prompts.service.ts @@ -0,0 +1,48 @@ +import { AdminPopularPromptsRepository } from '../repositories/admin-popular-prompts.repository'; +import { + PopularPromptItem, + PopularPromptsResponse, +} from '../dtos/admin-popular-prompts.dto'; +import { toKstDateString } from '../../utils/visitor-tracking'; + +const PERIOD_DAYS = 7; +const TOP_LIMIT = 5; +const DAY_MS = 24 * 60 * 60 * 1000; + +export const getPopularPrompts = async (): Promise => { + const targetSnapshotDate = toKstDateString( + new Date(Date.now() - PERIOD_DAYS * DAY_MS), + ); + + const prompts = await AdminPopularPromptsRepository.findActivePrompts(); + const snapshots = await AdminPopularPromptsRepository.findSnapshotsByDate( + targetSnapshotDate, + prompts.map((p) => p.prompt_id), + ); + const snapshotMap = new Map(snapshots.map((s) => [s.prompt_id, s])); + + const scored = prompts.map((p) => { + const snap = snapshotMap.get(p.prompt_id); + const views_delta = p.views - (snap?.views ?? 0); + const downloads_delta = p.downloads - (snap?.downloads ?? 0); + return { + prompt_id: p.prompt_id, + title: p.title, + views_delta, + downloads_delta, + score: views_delta + downloads_delta, + }; + }); + + scored.sort((a, b) => b.score - a.score); + + const items: PopularPromptItem[] = scored + .slice(0, TOP_LIMIT) + .map((s, i) => ({ rank: i + 1, ...s })); + + return { + period_days: PERIOD_DAYS, + snapshot_date: targetSnapshotDate, + items, + }; +}; diff --git a/src/stats/services/admin-prompt-stats.service.ts b/src/stats/services/admin-prompt-stats.service.ts new file mode 100644 index 0000000..a4139bb --- /dev/null +++ b/src/stats/services/admin-prompt-stats.service.ts @@ -0,0 +1,94 @@ +import { AdminPromptStatsRepository } from '../repositories/admin-prompt-stats.repository'; +import { + DailyUploadBucket, + NewPromptStatsResponse, + TopSalesPromptsResponse, +} from '../dtos/admin-prompt-stats.dto'; + +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000; +const TOP_SALES_PERIOD_DAYS = 30; +const TOP_SALES_LIMIT = 5; + +const toKstDateString = (date: Date): string => { + return new Date(date.getTime() + KST_OFFSET_MS).toISOString().slice(0, 10); +}; + +const startOfKstDay = (kstDateString: string): Date => { + return new Date(`${kstDateString}T00:00:00+09:00`); +}; + +const buildLast7KstDates = (now: Date): string[] => { + const todayKst = toKstDateString(now); + const [year, month, day] = todayKst.split('-').map(Number); + const dates: string[] = []; + for (let offset = 6; offset >= 0; offset--) { + const d = new Date(Date.UTC(year, month - 1, day - offset)); + dates.push(d.toISOString().slice(0, 10)); + } + return dates; +}; + +export const getNewPromptStats = async (): Promise => { + const now = new Date(); + const dates = buildLast7KstDates(now); + const windowStart = startOfKstDay(dates[0]); + + const prompts = + await AdminPromptStatsRepository.findActivePromptCreatedAtsSince( + windowStart, + ); + + const buckets: Record = Object.fromEntries( + dates.map((d) => [d, 0]), + ); + for (const p of prompts) { + const key = toKstDateString(p.created_at); + if (key in buckets) { + buckets[key]++; + } + } + + const daily_uploads: DailyUploadBucket[] = dates.map((date) => ({ + date, + count: buckets[date], + })); + + const weekly_count = daily_uploads.reduce((sum, b) => sum + b.count, 0); + + const last24hStart = new Date(now.getTime() - DAY_MS); + const daily_count = prompts.filter( + (p) => p.created_at.getTime() >= last24hStart.getTime(), + ).length; + + return { + daily_count, + weekly_count, + daily_uploads, + }; +}; + +export const getTopSalesPrompts = async (): Promise => { + const since = new Date(Date.now() - TOP_SALES_PERIOD_DAYS * DAY_MS); + + const grouped = + await AdminPromptStatsRepository.groupPaidPurchaseAmountSince( + since, + TOP_SALES_LIMIT, + ); + + const titles = await AdminPromptStatsRepository.findPromptTitlesByIds( + grouped.map((g) => g.prompt_id), + ); + const titleMap = new Map(titles.map((t) => [t.prompt_id, t.title])); + + return { + period_days: TOP_SALES_PERIOD_DAYS, + items: grouped.map((g, idx) => ({ + rank: idx + 1, + prompt_id: g.prompt_id, + title: titleMap.get(g.prompt_id) ?? null, + total_sales: g._sum.amount ?? 0, + })), + }; +}; diff --git a/src/stats/services/admin-stats.service.ts b/src/stats/services/admin-stats.service.ts new file mode 100644 index 0000000..a7cff50 --- /dev/null +++ b/src/stats/services/admin-stats.service.ts @@ -0,0 +1,76 @@ +import { AdminStatsRepository } from '../repositories/admin-stats.repository'; +import { + ActiveUserStatsResponse, + MemberStatsResponse, + SignupChannel, + SignupChannelStats, +} from '../dtos/admin-stats.dto'; + +const SOCIAL_TYPE_TO_CHANNEL: Record = { + NONE: 'email', + GOOGLE: 'google', + KAKAO: 'kakao', + NAVER: 'naver', +}; + +const emptyChannelStats = (): Record => ({ + email: { count: 0, ratio: 0 }, + google: { count: 0, ratio: 0 }, + kakao: { count: 0, ratio: 0 }, + naver: { count: 0, ratio: 0 }, +}); + +const round4 = (value: number): number => Math.round(value * 10000) / 10000; + +export const getMemberStats = async (): Promise => { + const grouped = await AdminStatsRepository.countMembersBySocialType(); + + const byChannel = emptyChannelStats(); + let total = 0; + + for (const row of grouped) { + const channel = SOCIAL_TYPE_TO_CHANNEL[row.social_type]; + if (!channel) continue; + byChannel[channel].count = row._count._all; + total += row._count._all; + } + + if (total > 0) { + (Object.keys(byChannel) as SignupChannel[]).forEach((channel) => { + byChannel[channel].ratio = round4(byChannel[channel].count / total); + }); + } + + return { + total_members: total, + by_signup_channel: byChannel, + }; +}; + +const ACTIVE_WINDOW_DAYS = 30; +const DAY_MS = 24 * 60 * 60 * 1000; + +export const getActiveUserStats = async (): Promise => { + const now = new Date(); + const currentStart = new Date(now.getTime() - ACTIVE_WINDOW_DAYS * DAY_MS); + const previousStart = new Date( + currentStart.getTime() - ACTIVE_WINDOW_DAYS * DAY_MS, + ); + + const [current_count, previous_count] = await Promise.all([ + AdminStatsRepository.countActiveUsersInRange(currentStart, now), + AdminStatsRepository.countActiveUsersInRange(previousStart, currentStart), + ]); + + const change_rate = + previous_count === 0 + ? null + : round4((current_count - previous_count) / previous_count); + + return { + window_days: ACTIVE_WINDOW_DAYS, + current_count, + previous_count, + change_rate, + }; +}; diff --git a/src/stats/services/admin-visitor-stats.service.ts b/src/stats/services/admin-visitor-stats.service.ts new file mode 100644 index 0000000..7b5e60e --- /dev/null +++ b/src/stats/services/admin-visitor-stats.service.ts @@ -0,0 +1,92 @@ +import { AdminVisitorStatsRepository } from '../repositories/admin-visitor-stats.repository'; +import { AppError } from '../../errors/AppError'; +import { toKstDateString } from '../../utils/visitor-tracking'; +import { + VisitorDailyBucket, + VisitorStatsResponse, +} from '../dtos/admin-stats.dto'; + +const WINDOW_DAYS = 30; +const DAY_MS = 24 * 60 * 60 * 1000; +const MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/; + +const round4 = (value: number): number => Math.round(value * 10000) / 10000; + +const buildDateRange = (startDate: Date, days: number): string[] => { + const dates: string[] = []; + for (let i = 0; i < days; i++) { + const d = new Date(startDate.getTime() + i * DAY_MS); + dates.push(toKstDateString(d)); + } + return dates; +}; + +const buildMonthDates = (month: string): string[] => { + if (!MONTH_PATTERN.test(month)) { + throw new AppError( + 'month 파라미터는 YYYY-MM 형식이어야 합니다.', + 400, + 'ValidationError', + ); + } + const [year, mon] = month.split('-').map(Number); + const daysInMonth = new Date(Date.UTC(year, mon, 0)).getUTCDate(); + const dates: string[] = []; + for (let day = 1; day <= daysInMonth; day++) { + const dateStr = `${month}-${String(day).padStart(2, '0')}`; + dates.push(dateStr); + } + return dates; +}; + +export const getVisitorStats = async ( + monthParam?: string, +): Promise => { + const now = new Date(); + const todayKst = toKstDateString(now); + + const currentStart = new Date(now.getTime() - WINDOW_DAYS * DAY_MS); + const previousStart = new Date( + currentStart.getTime() - WINDOW_DAYS * DAY_MS, + ); + + const currentDates = buildDateRange(currentStart, WINDOW_DAYS); + const previousDates = buildDateRange(previousStart, WINDOW_DAYS); + + const [daily_count, current_count, previous_count] = await Promise.all([ + AdminVisitorStatsRepository.countUniqueOnDate(todayKst), + AdminVisitorStatsRepository.countUniqueOverRange(currentDates), + AdminVisitorStatsRepository.countUniqueOverRange(previousDates), + ]); + + const change_rate = + previous_count === 0 + ? null + : round4((current_count - previous_count) / previous_count); + + let month: string | null = null; + let month_total: number | null = null; + let month_daily: VisitorDailyBucket[] | null = null; + + if (monthParam) { + const monthDates = buildMonthDates(monthParam); + const [total, dailyBuckets] = await Promise.all([ + AdminVisitorStatsRepository.countUniqueOverRange(monthDates), + AdminVisitorStatsRepository.countUniquePerDay(monthDates), + ]); + month = monthParam; + month_total = total; + month_daily = dailyBuckets; + } + + return { + daily_count, + window_days: WINDOW_DAYS, + current_count, + previous_count, + change_rate, + month, + month_total, + month_daily, + }; +}; diff --git a/src/stats/services/prompt-stat-snapshot.service.ts b/src/stats/services/prompt-stat-snapshot.service.ts new file mode 100644 index 0000000..4ecfdd6 --- /dev/null +++ b/src/stats/services/prompt-stat-snapshot.service.ts @@ -0,0 +1,69 @@ +import prisma from '../../config/prisma'; +import { toKstDateString } from '../../utils/visitor-tracking'; + +const SNAPSHOT_RETENTION_DAYS = 90; +const DAY_MS = 24 * 60 * 60 * 1000; + +export const snapshotAllPromptStats = async ( + date: Date = new Date(), +): Promise<{ snapshotted: number }> => { + const snapshot_date = toKstDateString(date); + + const prompts = await prisma.prompt.findMany({ + select: { prompt_id: true, views: true, downloads: true }, + }); + + if (prompts.length === 0) return { snapshotted: 0 }; + + const result = await prisma.promptStatDaily.createMany({ + data: prompts.map((p) => ({ + prompt_id: p.prompt_id, + snapshot_date, + views: p.views, + downloads: p.downloads, + })), + skipDuplicates: true, + }); + + return { snapshotted: result.count }; +}; + +export const cleanupOldSnapshots = async ( + now: Date = new Date(), +): Promise<{ deleted: number }> => { + const cutoff = toKstDateString( + new Date(now.getTime() - SNAPSHOT_RETENTION_DAYS * DAY_MS), + ); + + const result = await prisma.promptStatDaily.deleteMany({ + where: { snapshot_date: { lt: cutoff } }, + }); + + return { deleted: result.count }; +}; + +export const runDailyPromptStatJob = async (): Promise => { + const startedAt = Date.now(); + const snap = await snapshotAllPromptStats(); + const cleanup = await cleanupOldSnapshots(); + const elapsedMs = Date.now() - startedAt; + console.log('[prompt-stat-cron] daily job completed', { + snapshotted: snap.snapshotted, + deleted: cleanup.deleted, + elapsed_ms: elapsedMs, + }); +}; + +export const ensureTodaySnapshot = async (): Promise => { + const today = toKstDateString(new Date()); + const exists = await prisma.promptStatDaily.count({ + where: { snapshot_date: today }, + }); + if (exists > 0) return; + + try { + await runDailyPromptStatJob(); + } catch (error) { + console.error('[prompt-stat-cron] startup snapshot failed', error); + } +}; diff --git a/src/utils/user-activity.ts b/src/utils/user-activity.ts new file mode 100644 index 0000000..08820e8 --- /dev/null +++ b/src/utils/user-activity.ts @@ -0,0 +1,28 @@ +import redisClient from '../config/redis'; +import prisma from '../config/prisma'; + +const THROTTLE_SECONDS = 300; + +export const recordUserActivity = async (userId: number): Promise => { + try { + const key = `user_active:${userId}`; + const setResult = await redisClient.set(key, '1', { + NX: true, + EX: THROTTLE_SECONDS, + }); + + if (setResult !== 'OK') { + return; + } + + await prisma.user.update({ + where: { user_id: userId }, + data: { last_active_at: new Date() }, + }); + } catch (error) { + console.error('[user-activity] failed to record activity', { + userId, + error, + }); + } +}; diff --git a/src/utils/visitor-tracking.ts b/src/utils/visitor-tracking.ts new file mode 100644 index 0000000..e4dc21e --- /dev/null +++ b/src/utils/visitor-tracking.ts @@ -0,0 +1,58 @@ +import { createHash } from 'crypto'; +import { Request } from 'express'; +import redisClient from '../config/redis'; + +const HLL_KEY_PREFIX = 'visitors:hll:'; +const HLL_TTL_SECONDS = 60 * 60 * 24 * 400; +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; + +const BOT_UA_PATTERN = + /bot|crawl|spider|scrape|preview|monitor|uptime|curl|wget|python-requests|node-fetch|axios|http-client|libwww|java\/|okhttp/i; + +export const toKstDateString = (date: Date): string => + new Date(date.getTime() + KST_OFFSET_MS).toISOString().slice(0, 10); + +export const buildHllKey = (kstDate: string): string => + `${HLL_KEY_PREFIX}${kstDate}`; + +export const isBotUserAgent = (ua: string | undefined): boolean => { + if (!ua) return true; + return BOT_UA_PATTERN.test(ua); +}; + +export const getClientIp = (req: Request): string => { + const xff = req.headers['x-forwarded-for']; + if (typeof xff === 'string' && xff.length > 0) { + return xff.split(',')[0].trim(); + } + return req.ip || req.socket.remoteAddress || 'unknown'; +}; + +const hashIp = (ip: string): string => { + const salt = process.env.VISITOR_HASH_SALT ?? ''; + return createHash('sha256').update(`${ip}:${salt}`).digest('hex').slice(0, 24); +}; + +export const computeVisitorId = (req: Request): string => { + const userId = (req.user as { user_id?: number } | undefined)?.user_id; + if (userId) { + return `u:${userId}`; + } + return `i:${hashIp(getClientIp(req))}`; +}; + +export const recordVisit = async ( + visitorId: string, + date: Date = new Date(), +): Promise => { + try { + const key = buildHllKey(toKstDateString(date)); + await redisClient.pfAdd(key, visitorId); + await redisClient.expire(key, HLL_TTL_SECONDS); + } catch (error) { + console.error('[visitor-tracking] failed to record visit', { + visitorId, + error, + }); + } +}; diff --git a/swagger.json b/swagger.json index 72666f0..c831dbb 100644 --- a/swagger.json +++ b/swagger.json @@ -5,215 +5,6 @@ "version": "1.0.0" }, "paths": { - "/api/members/me/accounts": { - "post": { - "summary": "계좌 등록", - "description": "사용자가 본인의 계좌를 등록합니다. 한 사용자당 하나의 계좌만 등록 가능합니다.", - "tags": [ - "Account" - ], - "security": [ - { - "jwt": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "bank_code", - "account_number", - "account_holder" - ], - "properties": { - "bank_code": { - "type": "string", - "example": "090" - }, - "account_number": { - "type": "string", - "example": "3333222111000" - }, - "account_holder": { - "type": "string", - "example": "홍길동" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "계좌 등록 성공", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "example": "계좌 정보가 등록되었습니다." - }, - "account_id": { - "type": "integer", - "example": 123 - }, - "statusCode": { - "type": "integer", - "example": 200 - } - } - } - } - } - }, - "400": { - "description": "유효하지 않은 은행 코드 등 잘못된 요청" - }, - "409": { - "description": "이미 계좌가 등록된 경우" - } - } - }, - "get": { - "summary": "계좌 정보 조회", - "description": "로그인한 사용자의 등록된 계좌 정보를 조회합니다.", - "tags": [ - "Account" - ], - "security": [ - { - "jwt": [] - } - ], - "responses": { - "200": { - "description": "계좌 정보 조회 성공", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "example": "계좌 정보를 불러왔습니다." - }, - "data": { - "type": "object", - "properties": { - "account_id": { - "type": "integer", - "example": 123 - }, - "bank_code": { - "type": "string", - "example": "090" - }, - "bank_name": { - "type": "string", - "example": "카카오뱅크" - }, - "account_number": { - "type": "string", - "example": "3333222111000" - }, - "account_holder": { - "type": "string", - "example": "홍길동" - } - } - }, - "statusCode": { - "type": "integer", - "example": 200 - } - } - } - } - } - }, - "401": { - "description": "인증 실패" - }, - "404": { - "description": "등록된 계좌 정보 없음" - } - } - }, - "patch": { - "summary": "계좌 정보 수정", - "description": "등록된 계좌 정보를 새 계좌로 수정합니다. 본인 소유 계좌만 등록 가능합니다.", - "tags": [ - "Account" - ], - "security": [ - { - "jwt": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "bank_code", - "account_number", - "account_holder" - ], - "properties": { - "bank_code": { - "type": "string", - "example": "090" - }, - "account_number": { - "type": "string", - "example": "9876543210000" - }, - "account_holder": { - "type": "string", - "example": "홍길동" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "계좌 정보 수정 성공", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "example": "계좌 정보가 수정되었습니다." - }, - "statusCode": { - "type": "integer", - "example": 200 - } - } - } - } - } - }, - "400": { - "description": "유효하지 않은 은행 코드 또는 중복된 계좌" - }, - "404": { - "description": "등록된 계좌 없음" - } - } - } - }, "/api/auth/login/google/callback": { "get": { "summary": "구글 로그인 콜백", @@ -1184,11 +975,12 @@ } } }, - "/api/inquiries": { + "/api/chat/rooms": { "post": { - "summary": "1:1 문의 등록", + "summary": "채팅방 생성 또는 반환", + "description": "상대방과의 1:1 채팅방을 생성하거나 이미 존재하는 채팅방을 반환합니다.", "tags": [ - "Inquiry" + "Chat" ], "security": [ { @@ -1202,28 +994,12 @@ "schema": { "type": "object", "required": [ - "receiver_id", - "type", - "title", - "content" + "partner_id" ], "properties": { - "receiver_id": { + "partner_id": { "type": "integer", - "description": "수신자 회원 ID" - }, - "type": { - "type": "string", - "enum": [ - "buyer", - "non_buyer" - ] - }, - "title": { - "type": "string" - }, - "content": { - "type": "string" + "example": 34 } } } @@ -1231,17 +1007,50 @@ } }, "responses": { - "201": { - "description": "문의 등록 성공" + "200": { + "description": "채팅방 생성/반환 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "채팅방을 성공적으로 생성/반환했습니다." + }, + "data": { + "type": "object", + "properties": { + "room_id": { + "type": "integer", + "example": 35 + }, + "is_new": { + "type": "boolean", + "example": true, + "description": "이미 존재하는 채팅방인 경우 false, 새로 생성된 채팅방인 경우 true" + } + } + }, + "statusCode": { + "type": "integer", + "example": 200 + } + } + } + } + } + }, + "401": { + "description": "인증 실패 (토큰 없음/만료/유효하지 않음)" } } - } - }, - "/api/inquiries/received": { + }, "get": { - "summary": "받은 문의 목록 조회", + "summary": "채팅방 목록 조회", + "description": "내 채팅방 목록을 조회합니다.
메세지가 존재하지 않으면 해당 채팅방은 목록에서 제외됩니다.
filter(전체/안읽음/고정), search(상대 닉네임 검색), cursor 기반 페이징을 지원합니다.\n", "tags": [ - "Inquiry" + "Chat" ], "security": [ { @@ -1251,30 +1060,178 @@ "parameters": [ { "in": "query", - "name": "type", + "name": "filter", "required": false, "schema": { "type": "string", "enum": [ - "buyer", - "non_buyer" - ] + "all", + "unread", + "pinned" + ], + "default": "all" }, - "description": "필터링할 문의 유형" + "description": "조회 필터 (기본값 all)", + "example": "unread" + }, + { + "in": "query", + "name": "search", + "required": false, + "schema": { + "type": "string" + }, + "description": "상대방 닉네임 검색 키워드 (기본값 없음)", + "example": "달팽이" + }, + { + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "type": "integer" + }, + "description": "마지막으로 조회된 room_id (첫 요청 생략 가능)", + "example": 70 + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer", + "default": 20 + }, + "description": "가져올 채팅방 개수 (기본값 20)", + "example": 20 } ], "responses": { "200": { - "description": "받은 문의 목록 조회 성공" - } - } - } + "description": "채팅방 목록 조회 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "채팅방 목록을 성공적으로 조회했습니다." + }, + "data": { + "type": "object", + "properties": { + "rooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "room_id": { + "type": "integer", + "example": 12 + }, + "partner": { + "type": "object", + "properties": { + "user_id": { + "type": "integer", + "example": 67 + }, + "nickname": { + "type": "string", + "example": "달팽이" + }, + "profile_image_url": { + "type": "string", + "example": "https://...png" + } + } + }, + "last_message": { + "type": "object", + "nullable": true, + "properties": { + "content": { + "type": "string", + "nullable": true, + "example": "안녕하세요 너무 신기하네요" + }, + "sent_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "example": "2025-10-18T10:15:00Z" + }, + "has_attachments": { + "type": "boolean", + "example": false + }, + "attachment_summary": { + "type": "object", + "nullable": true, + "properties": { + "image_count": { + "type": "integer", + "example": 2 + }, + "file_count": { + "type": "integer", + "example": 0 + } + } + } + } + }, + "unread_count": { + "type": "integer", + "example": 123 + }, + "is_pinned": { + "type": "boolean", + "example": false + } + } + } + }, + "page": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean", + "example": false + }, + "total_count": { + "type": "integer", + "example": 3 + } + } + } + } + }, + "statusCode": { + "type": "integer", + "example": 200 + } + } + } + } + } + }, + "400": { + "description": "잘못된 요청 (유효하지 않은 filter 값)" + }, + "401": { + "description": "인증 실패 (토큰 없음/만료/유효하지 않음)" + } + } + } }, - "/api/inquiries/{inquiryId}": { + "/api/chat/rooms/{roomId}": { "get": { - "summary": "문의 상세 조회", + "summary": "채팅방 상세 조회", + "description": "채팅방 상세 정보(상대 정보/차단 상태/메시지 목록/페이지 정보)를 조회합니다.
메시지는 최신순(DESC)으로 반환되며, cursor 기반으로 과거 메시지를 추가로 불러올 수 있습니다.\n", "tags": [ - "Inquiry" + "Chat" ], "security": [ { @@ -1284,44 +1241,39 @@ "parameters": [ { "in": "path", - "name": "inquiryId", + "name": "roomId", "required": true, "schema": { "type": "integer" }, - "description": "문의 ID" - } - ], - "responses": { - "200": { - "description": "문의 상세 조회 성공" - } - } - }, - "delete": { - "summary": "문의 삭제", - "tags": [ - "Inquiry" - ], - "security": [ - { - "jwt": [] - } - ], - "parameters": [ + "description": "채팅방 ID", + "example": 2 + }, { - "in": "path", - "name": "inquiryId", - "required": true, + "in": "query", + "name": "cursor", + "required": false, "schema": { "type": "integer" }, - "description": "삭제할 문의 ID" + "description": "이번 응답에서 가장 오래된 message_id (첫 요청은 생략)", + "example": 70 + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer", + "default": 20 + }, + "description": "가져올 메시지 개수 (기본값 20)", + "example": 20 } ], "responses": { "200": { - "description": "문의 삭제 성공", + "description": "채팅방 상세 조회 성공", "content": { "application/json": { "schema": { @@ -1329,7 +1281,153 @@ "properties": { "message": { "type": "string", - "example": "문의가 성공적으로 삭제되었습니다." + "example": "채팅방 상세를 성공적으로 조회했습니다." + }, + "data": { + "type": "object", + "properties": { + "room": { + "type": "object", + "properties": { + "room_id": { + "type": "integer", + "example": 2 + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2025-08-21T12:26:42.522Z" + }, + "is_pinned": { + "type": "boolean", + "example": true + } + } + }, + "my": { + "type": "object", + "properties": { + "user_id": { + "type": "integer", + "example": 45 + }, + "left_at": { + "type": "string", + "format": "date-time", + "example": "2026-01-20T10:00:00.000Z" + } + } + }, + "partner": { + "type": "object", + "properties": { + "user_id": { + "type": "integer", + "example": 67 + }, + "nickname": { + "type": "string", + "example": "달팽이" + }, + "profile_image_url": { + "type": "string", + "example": "https://...png" + }, + "role": { + "type": "string", + "example": "USER" + } + } + }, + "block_status": { + "type": "object", + "properties": { + "i_blocked_partner": { + "type": "boolean", + "example": true + }, + "partner_blocked_me": { + "type": "boolean", + "example": false + } + } + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "message_id": { + "type": "integer", + "example": 56 + }, + "sender_id": { + "type": "integer", + "example": 45 + }, + "content": { + "type": "string", + "example": "혹시 이 사진이랑 파일도 프롬프트에 사용할 수 있나요?" + }, + "sent_at": { + "type": "string", + "format": "date-time", + "example": "2025-08-21T12:26:42.522Z" + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "attachment_id": { + "type": "integer", + "example": 23 + }, + "url": { + "type": "string", + "example": "https://...png" + }, + "type": { + "type": "string", + "enum": [ + "IMAGE", + "FILE" + ], + "example": "IMAGE" + }, + "original_name": { + "type": "string", + "example": "picture.png" + }, + "size": { + "type": "integer", + "example": 27187 + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2025-08-21T12:26:42.522Z" + } + } + } + } + } + } + }, + "page": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean", + "example": false + }, + "total_count": { + "type": "integer", + "example": 2 + } + } + } + } }, "statusCode": { "type": "integer", @@ -1341,107 +1439,354 @@ } }, "401": { - "description": "인증 실패", + "description": "인증 실패 (토큰 없음/만료/유효하지 않음)" + }, + "404": { + "description": "채팅방을 찾을 수 없음" + } + } + } + }, + "/api/chat/block": { + "post": { + "summary": "사용자 차단", + "description": "상대방을 차단합니다.\n", + "tags": [ + "Chat" + ], + "security": [ + { + "jwt": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "blocked_user_id" + ], + "properties": { + "blocked_user_id": { + "type": "integer", + "example": 5 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "차단 성공", "content": { "application/json": { "schema": { "type": "object", "properties": { - "error": { - "type": "string", - "example": "Unauthorized" - }, "message": { "type": "string", - "example": "로그인이 필요합니다." + "example": "상대방을 성공적으로 차단했습니다." + }, + "data": { + "nullable": true, + "example": null }, "statusCode": { "type": "integer", - "example": 401 + "example": 200 } } } } } }, - "403": { - "description": "권한 없음", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string", - "example": "Forbidden" - }, - "message": { + "400": { + "description": "잘못된 요청" + }, + "401": { + "description": "인증 실패 (토큰 없음/만료/유효하지 않음)" + } + } + } + }, + "/api/chat/rooms/{roomId}/leave": { + "patch": { + "summary": "채팅방 나가기", + "description": "채팅방을 나갑니다.
채팅방을 나가면 채팅방 목록 조회 리스트에서 제외됩니다.
나갔더라도 채팅방은 유지되며 계속적으로 수신이 가능합니다. 다시 입장할 수 있지만 나가기 전 메시지들은 볼 수 없습니다.\n", + "tags": [ + "Chat" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "roomId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "채팅방 ID", + "example": 2 + } + ], + "responses": { + "200": { + "description": "채팅방 나가기 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string", - "example": "문의를 삭제할 권한이 없습니다." + "example": "채팅방을 성공적으로 나갔습니다." + }, + "data": { + "nullable": true, + "example": null }, "statusCode": { "type": "integer", - "example": 403 + "example": 200 } } } } } }, - "404": { - "description": "문의 없음", + "400": { + "description": "잘못된 요청" + }, + "401": { + "description": "인증 실패 (토큰 없음/만료/유효하지 않음)" + } + } + } + }, + "/api/chat/presigned-url": { + "post": { + "summary": "Presigned URL 발급", + "description": "파일 업로드를 위한 presigned url을 발급합니다.

**업로드 프로세스:**
1) 본 API를 호출하여 파일별 url 과 key 를 받습니다.
2) 받은 url 로 PUT 요청을 보내 실제 파일을 업로드합니다.
3) 업로드가 모두 성공하면, 채팅 메시지 전송 API 호출 시 서버로부터 받은 key 값들을 함께 보냅니다.\n", + "tags": [ + "Chat" + ], + "security": [ + { + "jwt": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "files" + ], + "properties": { + "files": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "content_type" + ], + "properties": { + "name": { + "type": "string", + "example": "cat.jpg" + }, + "content_type": { + "type": "string", + "example": "image/jpg" + } + } + } + } + } + }, + "example": { + "files": [ + { + "name": "cat.jpg", + "content_type": "image/jpg" + }, + { + "name": "dog.png", + "content_type": "image/png" + }, + { + "name": "info.pdf", + "content_type": "application/pdf" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "presign 발급 성공", "content": { "application/json": { "schema": { "type": "object", "properties": { - "error": { - "type": "string", - "example": "NotFound" - }, "message": { "type": "string", - "example": "해당 문의를 찾을 수 없습니다." + "example": "presign을 성공적으로 발급했습니다." + }, + "data": { + "type": "object", + "properties": { + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "cat.jpg" + }, + "url": { + "type": "string", + "example": "https://s3.aws.com/bucket/random-key-1?signature=..." + }, + "key": { + "type": "string", + "example": "uploads/random-key-1.jpg" + } + } + } + } + } }, "statusCode": { "type": "integer", - "example": 404 + "example": 200 } } + }, + "example": { + "message": "presign을 성공적으로 발급했습니다.", + "data": { + "attachments": [ + { + "name": "cat.jpg", + "url": "https://s3.aws.com/bucket/random-key-1?signature=...", + "key": "uploads/random-key-1.jpg" + }, + { + "name": "dog.jpg", + "url": "https://s3.aws.com/bucket/random-key-2?signature=...", + "key": "uploads/random-key-2.png" + }, + { + "name": "info.pdf", + "url": "https://s3.aws.com/bucket/random-key-3?signature=...", + "key": "uploads/random-key-3.pdf" + } + ] + }, + "statusCode": 200 } } } }, - "500": { - "description": "서버 오류", + "400": { + "description": "잘못된 요청" + }, + "401": { + "description": "인증 실패 (토큰 없음/만료/유효하지 않음)" + } + } + } + }, + "/api/chat/rooms/{roomId}/pin": { + "patch": { + "summary": "채팅방 고정 토글", + "description": "채팅방 고정을 토글합니다.

`isPinned`:
\n - false → 토글 결과 = 고정 해제
\n - true → 토글 결과 = 고정\n", + "tags": [ + "Chat" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "roomId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "채팅방 ID", + "example": 2 + } + ], + "responses": { + "200": { + "description": "채팅방 고정 토글 성공", "content": { "application/json": { "schema": { "type": "object", "properties": { - "error": { - "type": "string", - "example": "InternalServerError" - }, "message": { "type": "string", - "example": "알 수 없는 오류가 발생했습니다." + "example": "채팅방 고정을 성공적으로 토글했습니다." + }, + "data": { + "type": "object", + "properties": { + "isPinned": { + "type": "boolean", + "example": true + } + } }, "statusCode": { "type": "integer", - "example": 500 + "example": 200 } } + }, + "example": { + "message": "채팅방 고정을 성공적으로 토글했습니다.", + "data": { + "isPinned": true + }, + "statusCode": 200 } } } + }, + "400": { + "description": "잘못된 요청" + }, + "401": { + "description": "인증 실패 (토큰 없음/만료/유효하지 않음)" + }, + "404": { + "description": "채팅방을 찾을 수 없음" } } } }, - "/api/inquiries/{inquiryId}/replies": { + "/api/inquiries": { "post": { - "summary": "문의 답변 등록", + "summary": "1:1 문의 등록", "tags": [ "Inquiry" ], @@ -1450,16 +1795,6 @@ "jwt": [] } ], - "parameters": [ - { - "in": "path", - "name": "inquiryId", - "required": true, - "schema": { - "type": "integer" - } - } - ], "requestBody": { "required": true, "content": { @@ -1467,12 +1802,28 @@ "schema": { "type": "object", "required": [ + "receiver_id", + "type", + "title", "content" ], "properties": { - "content": { + "receiver_id": { + "type": "integer", + "description": "수신자 회원 ID" + }, + "type": { "type": "string", - "example": "답변 드립니다. 감사합니다." + "enum": [ + "buyer", + "non_buyer" + ] + }, + "title": { + "type": "string" + }, + "content": { + "type": "string" } } } @@ -1481,14 +1832,14 @@ }, "responses": { "201": { - "description": "답변 등록 성공" + "description": "문의 등록 성공" } } } }, - "/api/inquiries/{inquiryId}/read": { - "patch": { - "summary": "문의 읽음 처리", + "/api/inquiries/received": { + "get": { + "summary": "받은 문의 목록 조회", "tags": [ "Inquiry" ], @@ -1499,36 +1850,285 @@ ], "parameters": [ { - "in": "path", - "name": "inquiryId", - "required": true, + "in": "query", + "name": "type", + "required": false, "schema": { - "type": "integer" - } + "type": "string", + "enum": [ + "buyer", + "non_buyer" + ] + }, + "description": "필터링할 문의 유형" } ], "responses": { "200": { - "description": "읽음 처리 성공" + "description": "받은 문의 목록 조회 성공" } } } }, - "/api/members/followers/{memberId}": { + "/api/inquiries/{inquiryId}": { "get": { - "summary": "회원의 팔로워 목록 조회", + "summary": "문의 상세 조회", "tags": [ - "Member" + "Inquiry" + ], + "security": [ + { + "jwt": [] + } ], "parameters": [ { "in": "path", - "name": "memberId", + "name": "inquiryId", "required": true, "schema": { "type": "integer" }, - "description": "회원 ID" + "description": "문의 ID" + } + ], + "responses": { + "200": { + "description": "문의 상세 조회 성공" + } + } + }, + "delete": { + "summary": "문의 삭제", + "tags": [ + "Inquiry" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "inquiryId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "삭제할 문의 ID" + } + ], + "responses": { + "200": { + "description": "문의 삭제 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "문의가 성공적으로 삭제되었습니다." + }, + "statusCode": { + "type": "integer", + "example": 200 + } + } + } + } + } + }, + "401": { + "description": "인증 실패", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + }, + "message": { + "type": "string", + "example": "로그인이 필요합니다." + }, + "statusCode": { + "type": "integer", + "example": 401 + } + } + } + } + } + }, + "403": { + "description": "권한 없음", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Forbidden" + }, + "message": { + "type": "string", + "example": "문의를 삭제할 권한이 없습니다." + }, + "statusCode": { + "type": "integer", + "example": 403 + } + } + } + } + } + }, + "404": { + "description": "문의 없음", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "NotFound" + }, + "message": { + "type": "string", + "example": "해당 문의를 찾을 수 없습니다." + }, + "statusCode": { + "type": "integer", + "example": 404 + } + } + } + } + } + }, + "500": { + "description": "서버 오류", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "InternalServerError" + }, + "message": { + "type": "string", + "example": "알 수 없는 오류가 발생했습니다." + }, + "statusCode": { + "type": "integer", + "example": 500 + } + } + } + } + } + } + } + } + }, + "/api/inquiries/{inquiryId}/replies": { + "post": { + "summary": "문의 답변 등록", + "tags": [ + "Inquiry" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "inquiryId", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "content": { + "type": "string", + "example": "답변 드립니다. 감사합니다." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "답변 등록 성공" + } + } + } + }, + "/api/inquiries/{inquiryId}/read": { + "patch": { + "summary": "문의 읽음 처리", + "tags": [ + "Inquiry" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "inquiryId", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "읽음 처리 성공" + } + } + } + }, + "/api/members/followers/{memberId}": { + "get": { + "summary": "회원의 팔로워 목록 조회", + "tags": [ + "Member" + ], + "parameters": [ + { + "in": "path", + "name": "memberId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "회원 ID" } ], "responses": { @@ -2389,11 +2989,6 @@ "tags": [ "Member" ], - "security": [ - { - "jwt": [] - } - ], "parameters": [ { "in": "path", @@ -2964,7 +3559,7 @@ "/api/notifications/me": { "get": { "summary": "내 알림 목록 조회", - "description": "- 커서 기반 페이지네이션(cursor-based-pagination) 사용.\n - `cursor`는 이전 요청에서 받은 마지막 데이터의 ID를 의미하며, 이를 기준으로 이후 데이터를 조회.\n - 첫 요청 시에는 `cursor`를 생략하여 최신 데이터부터 조회.\n - `has_more` 속성으로 더 불러올 데이터가 있는지 미리 확인 가능.\n\n- type 종류:\n - FOLLOW: 누가 나를 팔로우 했을 때 \n - NEW_PROMPT: 알림 설정한 프롬프터가 새 프롬프트를 올렸을 때\n - INQUIRY: 나에게 문의사항이 도착했을 때\n - ANNOUNCEMENT: 공지사항이 등록되었을 때\n - REPORT: 내 신고가 접수되었을 때\n\n- actor 필드는 알림을 유발한 사용자를 뜻하며, 타입이 REPORT, ANNOUNCEMENT일 때에만 null입니다.\n- profile_image 필드는 타입이 FOLLOW, NEW_PROMPT일 경우에만 반환됩니다.\n", + "description": "- 커서 기반 페이지네이션(cursor-based-pagination) 사용.\n - `cursor`는 이전 요청에서 받은 마지막 데이터의 ID를 의미하며, 이를 기준으로 이후 데이터를 조회.\n\n - 첫 요청 시에는 `cursor`를 생략하여 최신 데이터부터 조회.\n\n - `has_more` 속성으로 더 불러올 데이터가 있는지 미리 확인 가능.\n\n- type 종류:\n - `FOLLOW`: 누가 나를 팔로우 했을 때 \n\n - `NEW_PROMPT`: 알림 설정한 프롬프터가 새 프롬프트를 올렸을 때\n\n - `INQUIRY`: 나에게 문의사항이 도착했을 때\n\n - `ANNOUNCEMENT`: 공지사항이 등록되었을 때\n\n - `REPORT`: 내 신고가 접수되었을 때\n\n - `ADMIN_MESSAGE`: 관리자 메시지가 도착했을 때\n\n- `actor` 필드는 알림을 유발한 사용자를 뜻하며, 타입이 `REPORT`, `ANNOUNCEMENT`일 때에는 null입니다.\n", "tags": [ "Notifications" ], @@ -3016,26 +3611,30 @@ }, { "notification_id": 599, - "content": "신고가 접수되었습니다.", - "type": "REPORT", + "content": "`홍길동`님이 새 프롬프트를 업로드하셨습니다.", + "type": "NEW_PROMPT", "created_at": "2025-10-26T16:57:04.162Z", - "link_url": null, - "actor": null + "link_url": "/profile/10", + "actor": { + "user_id": 10, + "nickname": "홍길동", + "profile_image": "https://promptplace-s3.s3.ap-northeast-2.amazonaws.com/profile-images/1a2b3c4d-5678-90ab-cdef-1234567890ab_1755892991870.png" + } }, { "notification_id": 598, - "content": "신고가 접수되었습니다.", - "type": "REPORT", + "content": "새로운 공지사항이 등록되었습니다.", + "type": "ANNOUNCEMENT", "created_at": "2025-10-26T13:38:26.906Z", "link_url": null, "actor": null }, { "notification_id": 489, - "content": "‘또도도잉’님이 회원님을 팔로우합니다.", - "type": "FOLLOW", + "content": "프롬프트에 새로운 문의가 도착했습니다.", + "type": "INQUIRY", "created_at": "2025-08-21T12:26:45.288Z", - "link_url": "/profile/33", + "link_url": "/inquiries/2", "actor": { "user_id": 33, "nickname": "또도도잉", @@ -3053,7 +3652,19 @@ "nickname": "또도도잉", "profile_image": "https://promptplace-s3.s3.ap-northeast-2.amazonaws.com/profile-images/3b137096-7915-408d-ad94-b70e5aa53107_1755892991870.png" } - } + }, + { + "notification_id": 487, + "content": "안녕하세요. 프롬프트 플레이스 관리자입니다. 회원님의 원활한 서비스 이용을 위해 공지사항을 확인해주세요.", + "type": "ADMIN_MESSAGE", + "created_at": "2025-08-21T12:26:42.522Z", + "link_url": null, + "actor": { + "user_id": 45, + "nickname": "관리자", + "profile_image": "https://promptplace-s3.s3.ap-northeast-2.amazonaws.com/profile-images/3b137096-7915-408d-ad94-b70e5aa53107_1755892991870.png" + } + } ] }, "statusCode": 200 @@ -3706,6 +4317,7 @@ "usage_guide": "사용 가이드 2", "price": 1000, "is_free": false, + "is_paid": true, "tags": [ { "tag_id": 2, @@ -3787,6 +4399,122 @@ "description": "인증 실패" } } + }, + "patch": { + "summary": "프롬프트 이미지 순서 변경", + "tags": [ + "Prompts" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "promptId", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "image_url": { + "type": "string" + }, + "order_index": { + "type": "integer" + } + }, + "required": [ + "image_url", + "order_index" + ] + } + } + } + }, + "responses": { + "200": { + "description": "이미지 순서 변경 성공" + }, + "400": { + "description": "잘못된 요청" + }, + "401": { + "description": "인증 실패" + }, + "403": { + "description": "권한 없음" + }, + "404": { + "description": "이미지를 찾을 수 없음" + } + } + }, + "delete": { + "summary": "프롬프트 이미지 삭제", + "tags": [ + "Prompts" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "promptId", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "order_index": { + "type": "integer" + } + }, + "required": [ + "order_index" + ] + } + } + } + }, + "responses": { + "200": { + "description": "이미지 삭제 성공" + }, + "400": { + "description": "잘못된 요청" + }, + "401": { + "description": "인증 실패" + }, + "403": { + "description": "권한 없음" + }, + "404": { + "description": "이미지를 찾을 수 없음" + } + } } }, "/api/prompts/{promptId}": { @@ -3939,8 +4667,8 @@ }, "/api/prompts/purchases/requests": { "post": { - "summary": "결제 요청 생성", - "description": "결제 시작을 위한 요청을 생성합니다.", + "summary": "결제 요청 생성 (페이플 인증 + 주문서 발행)", + "description": "페이플 파트너 인증을 수행하고 프론트의 PaypleCpayAuthCheck 호출에 필요한 PCD_* 필드 묶음을 반환합니다.", "tags": [ "Purchase" ], @@ -3955,28 +4683,22 @@ "application/json": { "schema": { "type": "object", + "required": [ + "prompt_id" + ], "properties": { "prompt_id": { - "type": "integer" + "type": "integer", + "example": 12 }, - "pg": { + "pay_type": { "type": "string", "enum": [ - "kakaopay", - "tosspayments" - ] - }, - "merchant_uid": { - "type": "string" - }, - "amount": { - "type": "integer" - }, - "buyer_name": { - "type": "string" - }, - "redirect_url": { - "type": "string" + "card", + "transfer" + ], + "default": "card", + "description": "결제 수단 (card=카드, transfer=계좌이체)" } } } @@ -3985,24 +4707,142 @@ }, "responses": { "200": { - "description": "결제 요청 생성 성공", + "description": "주문서 생성 성공 (페이플 일반결제 연동 데이터 반환)", "content": { "application/json": { "schema": { "type": "object", "properties": { "message": { - "type": "string" + "type": "string", + "example": "주문서가 생성되었습니다." }, - "payment_gateway": { - "type": "string" + "statusCode": { + "type": "integer", + "example": 200 }, - "merchant_uid": { - "type": "string" + "PCD_CST_ID": { + "type": "string", + "description": "페이플 가맹점 ID" + }, + "PCD_CUST_KEY": { + "type": "string", + "description": "페이플 가맹점 Key" + }, + "PCD_AUTH_KEY": { + "type": "string", + "description": "페이플 인증 토큰" + }, + "PCD_PAY_TYPE": { + "type": "string", + "enum": [ + "card", + "transfer" + ] + }, + "PCD_PAY_WORK": { + "type": "string", + "enum": [ + "PAY" + ] + }, + "PCD_PAY_HOST": { + "type": "string", + "description": "페이플 결제 호스트 (재검증 시 사용)" + }, + "PCD_PAY_URL": { + "type": "string", + "description": "페이플 결제 URL (재검증 시 사용)" + }, + "PCD_PAY_OID": { + "type": "string", + "description": "서버 생성 주문 번호" + }, + "PCD_PAY_GOODS": { + "type": "string", + "description": "주문명 (프롬프트 제목)" + }, + "PCD_PAY_TOTAL": { + "type": "number", + "description": "결제 금액 (서버 검증 기준)" }, - "redirect_url": { + "PCD_USER_DEFINE1": { + "type": "string", + "description": "검증/웹훅용 메타 (JSON 문자열, prompt_id/user_id 포함)", + "example": "{\"prompt_id\":12,\"user_id\":5}" + } + } + } + } + } + } + } + } + }, + "/api/prompts/purchases": { + "get": { + "summary": "결제 내역 조회", + "description": "인증된 사용자의 결제 내역을 최신순으로 조회합니다.", + "tags": [ + "Purchase" + ], + "security": [ + { + "jwt": [] + } + ], + "responses": { + "200": { + "description": "조회 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "purchases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "prompt_id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "price": { + "type": "integer" + }, + "seller_nickname": { + "type": "string" + }, + "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" } @@ -4010,26 +4850,14 @@ } } } - }, - "400": { - "description": "잘못된 요청" - }, - "401": { - "description": "인증 실패" - }, - "404": { - "description": "리소스 없음" - }, - "409": { - "description": "중복/상태 충돌" } } } }, "/api/prompts/purchases/complete": { "post": { - "summary": "결제 완료 처리(Webhook/리다이렉트 후 서버 검증)", - "description": "포트원 imp_uid 기반으로 서버에서 결제 검증 후 구매/결제/정산을 기록합니다.", + "summary": "결제 완료 처리 (페이플 검증 및 저장)", + "description": "페이플 결제 완료 후 프론트가 받은 PCD_* 결과 객체를 서버로 그대로 전달하면, PCD_PAY_REQKEY로 페이플에 재검증 후 구매를 확정합니다.", "tags": [ "Purchase" ], @@ -4044,11 +4872,52 @@ "application/json": { "schema": { "type": "object", + "required": [ + "PCD_PAY_RST", + "PCD_PAY_OID", + "PCD_PAY_REQKEY", + "PCD_AUTH_KEY" + ], "properties": { - "imp_uid": { + "PCD_PAY_RST": { + "type": "string", + "enum": [ + "success", + "error", + "close" + ] + }, + "PCD_PAY_CODE": { + "type": "string" + }, + "PCD_PAY_MSG": { + "type": "string" + }, + "PCD_PAY_OID": { + "type": "string", + "description": "주문 번호 (요청 시 발급된 값)" + }, + "PCD_PAY_REQKEY": { + "type": "string", + "description": "페이플 재검증 키" + }, + "PCD_AUTH_KEY": { + "type": "string", + "description": "페이플 인증 토큰" + }, + "PCD_PAY_HOST": { + "type": "string" + }, + "PCD_PAY_URL": { + "type": "string" + }, + "PCD_PAY_TOTAL": { + "type": "number" + }, + "PCD_PAY_TYPE": { "type": "string" }, - "merchant_uid": { + "PCD_USER_DEFINE1": { "type": "string" } } @@ -4058,7 +4927,7 @@ }, "responses": { "200": { - "description": "결제 완료 처리 성공", + "description": "결제 성공 및 저장 완료", "content": { "application/json": { "schema": { @@ -4076,8 +4945,7 @@ ] }, "purchase_id": { - "type": "integer", - "nullable": true + "type": "integer" }, "statusCode": { "type": "integer" @@ -4086,128 +4954,6 @@ } } } - }, - "400": { - "description": "검증 실패/유효하지 않은 요청" - }, - "401": { - "description": "인증 실패" - }, - "404": { - "description": "리소스 없음" - }, - "409": { - "description": "충돌" - }, - "500": { - "description": "서버 오류" - } - } - } - }, - "/api/prompts/purchases": { - "get": { - "summary": "내 결제 내역 조회", - "description": "인증된 사용자의 결제(구매) 내역을 조회합니다.", - "tags": [ - "Purchase" - ], - "security": [ - { - "jwt": [] - } - ], - "parameters": [ - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" - }, - "required": false, - "description": "페이지 번호 (옵션)" - }, - { - "in": "query", - "name": "pageSize", - "schema": { - "type": "integer" - }, - "required": false, - "description": "페이지 크기 (옵션)" - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "Succeed", - "Failed", - "Pending" - ] - }, - "required": false, - "description": "결제 상태 필터 (옵션)" - } - ], - "responses": { - "200": { - "description": "결제 내역 조회 성공", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "purchases": { - "type": "array", - "items": { - "type": "object", - "properties": { - "prompt_id": { - "type": "integer" - }, - "title": { - "type": "string" - }, - "price": { - "type": "integer" - }, - "purchased_at": { - "type": "string", - "format": "date-time" - }, - "seller_nickname": { - "type": "string", - "nullable": true - }, - "pg": { - "type": "string", - "enum": [ - "kakaopay", - "tosspay", - null - ] - } - } - } - }, - "statusCode": { - "type": "integer" - } - } - } - } - } - }, - "401": { - "description": "인증 실패" - }, - "500": { - "description": "서버 오류" } } } @@ -4318,7 +5064,7 @@ }, "get": { "summary": "신고된 프롬프트 목록 조회 (관리자 전용)", - "description": "커서 기반 페이지네이션(cursor-based-pagination) 사용.\n- `cursor`는 이전 요청에서 받은 마지막 목록의 ID를 의미하며, 이를 기준으로 이후 데이터를 조회.\n- 첫 요청 시에는 `cursor`를 생략하여 최신 리뷰부터 조회.\n- `has_more` 속성으로 더 불러올 데이터가 있는지 미리 확인 가능.\n", + "description": "커서 기반 페이지네이션(cursor-based-pagination) 사용.\n- `cursor`는 이전 요청에서 받은 마지막 목록의 ID를 의미하며, 이를 기준으로 이후 데이터를 조회.\n- 첫 요청 시에는 `cursor`를 생략하여 최신 신고부터 조회.\n- `has_more` 속성으로 더 불러올 데이터가 있는지 미리 확인 가능.\n", "tags": [ "Report" ], @@ -4335,7 +5081,7 @@ "schema": { "type": "integer" }, - "description": "마지막으로 조회된 ID. 처음 요청 시 생략 가능. 예: 첫 요청에서 id=80~70까지 받았다면 다음 요청 시 `cursor=70`\n" + "description": "마지막으로 조회된 ID. 처음 요청 시 생략 가능. 예: 첫 요청에서 id=80~70까지 받았다면 다음 요청 시 `cursor=70`\n" }, { "in": "query", @@ -4345,7 +5091,7 @@ "type": "integer", "default": 10 }, - "description": "가져올 리뷰 수 (기본값 10)" + "description": "가져올 신고 수 (기본값 10)" } ], "responses": { @@ -4403,6 +5149,11 @@ "has_more": { "type": "boolean", "example": false + }, + "total_count": { + "type": "integer", + "example": 57, + "description": "시스템에 존재하는 전체 신고 수" } } }, @@ -4866,172 +5617,2229 @@ } } }, - "/api/reviews/{reviewId}": { - "delete": { - "summary": "특정 리뷰 삭제", - "description": "### API 설명\n\n- 작성자가 본인의 리뷰를 삭제할 수 있는 API입니다. \n- 리뷰 작성일로부터 **30일 이내**인 경우에만 삭제가 가능합니다. \n- `Authorization` 헤더를 포함한 로그인된 사용자만 접근할 수 있습니다.\n\n### Path Variable\n| 항목 | 설명 | 예시 | 필수 여부 |\n|-----------|---------------------|--------|-----------|\n| reviewId | 삭제할 리뷰 ID | `63` | ✅ |\n\n### Header\n```json\n{\n \"Authorization\": \"Bearer {access_token}\"\n}\n```\n", + "/api/reviews/{reviewId}": { + "delete": { + "summary": "특정 리뷰 삭제", + "description": "### API 설명\n\n- 작성자가 본인의 리뷰를 삭제할 수 있는 API입니다. \n- 리뷰 작성일로부터 **30일 이내**인 경우에만 삭제가 가능합니다. \n- `Authorization` 헤더를 포함한 로그인된 사용자만 접근할 수 있습니다.\n\n### Path Variable\n| 항목 | 설명 | 예시 | 필수 여부 |\n|-----------|---------------------|--------|-----------|\n| reviewId | 삭제할 리뷰 ID | `63` | ✅ |\n\n### Header\n```json\n{\n \"Authorization\": \"Bearer {access_token}\"\n}\n```\n", + "tags": [ + "Reviews" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "reviewId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "삭제할 리뷰 ID" + } + ], + "responses": { + "200": { + "description": "리뷰가 성공적으로 삭제되었습니다.", + "content": { + "application/json": { + "example": { + "message": "리뷰가 성공적으로 삭제되었습니다.", + "data": {}, + "statusCode": 200 + } + } + } + }, + "403": { + "description": "리뷰 작성일로부터 30일이 지나 삭제할 수 없습니다.", + "content": { + "application/json": { + "example": { + "error": "Forbidden", + "message": "리뷰 작성일로부터 30일이 지나 삭제할 수 없습니다.", + "statusCode": 403 + } + } + } + } + } + }, + "patch": { + "summary": "특정 리뷰 수정", + "description": "### API 설명\n\n- 사용자가 본인의 리뷰를 수정할 수 있는 API입니다. \n- 리뷰 내용(`content`)과 평점(`rating`)을 모두 전달해야 합니다. \n- `Authorization` 헤더를 포함한 로그인 사용자만 접근할 수 있습니다.\n\n### Path Variable\n| 항목 | 설명 | 예시 | 필수 여부 |\n|-----------|------------------|--------|-----------|\n| reviewId | 수정할 리뷰 ID | `59` | ✅ |\n\n### Request Body\n| 항목 | 설명 | 예시 | 필수 여부 |\n|----------|------------|----------------------|-----------|\n| rating | 평점 (0~5) | `4.5` | ✅ |\n| content | 리뷰 내용 | 수정된 전체 내용입니다. | ✅ |\n\n### Header\n```json\n{\n \"Authorization\": \"Bearer {access_token}\"\n}\n```\n", + "tags": [ + "Reviews" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "reviewId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "수정할 리뷰 ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "rating", + "content" + ], + "properties": { + "rating": { + "type": "number", + "format": "float", + "example": 4.5, + "description": "평점 (0.0 ~ 5.0)" + }, + "content": { + "type": "string", + "example": "수정된 전체 내용입니다.", + "description": "리뷰 내용" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "리뷰가 성공적으로 수정되었습니다.", + "content": { + "application/json": { + "example": { + "message": "리뷰가 성공적으로 수정되었습니다.", + "data": { + "review_id": 59, + "prompt_id": 10, + "writer_name": "남아린", + "rating": 4.5, + "content": "수정된 전체 내용입니다.", + "updated_at": "2025-08-07T09:16:14.838Z" + }, + "statusCode": 200 + } + } + } + } + } + } + }, + "/api/reviews/{reviewId}/edit": { + "get": { + "summary": "리뷰 수정 화면 데이터 조회", + "description": "### API 설명\n\n- 해당 리뷰의 수정 화면에 필요한 정보를 조회하는 API입니다. \n- 프롬프트 정보, 모델 정보, 작성자 정보 및 기존 리뷰 내용을 포함합니다. \n- 로그인한 사용자만 접근할 수 있으며, `Authorization` 헤더가 필요합니다.\n\n### Path Variable\n| 항목 | 설명 | 예시 | 필수 여부 |\n|-----------|------------------|--------|-----------|\n| reviewId | 수정할 리뷰 ID | `63` | ✅ |\n\n### Header\n```json\n{\n \"Authorization\": \"Bearer {access_token}\"\n}\n```\n", + "tags": [ + "Reviews" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "reviewId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "수정할 리뷰 ID" + } + ], + "responses": { + "200": { + "description": "리뷰 수정 화면 데이터를 성공적으로 불러왔습니다.", + "content": { + "application/json": { + "example": { + "message": "리뷰 수정 화면 데이터를 성공적으로 불러왔습니다.", + "data": { + "prompter_id": 3, + "prompter_nickname": "닉네임3", + "prompt_id": 5, + "prompt_title": "프롬프트 3", + "model_id": 3, + "model_name": "모델 이름 3", + "rating_avg": "4.0", + "content": "너무 유용했어요!" + }, + "statusCode": 200 + } + } + } + } + } + } + }, + "/api/admin/sellers/pending": { + "get": { + "summary": "사업자 판매자 승인 대기 목록 조회", + "description": "관리자가 승인 대기 상태(`PENDING`)인 사업자 판매자 등록 신청 목록을 조회합니다.", + "tags": [ + "AdminSeller" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "description": "페이지 번호" + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 10 + }, + "description": "페이지 당 항목 수" + } + ], + "responses": { + "200": { + "description": "조회 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "승인 대기 사업자 판매자 목록을 조회했습니다." + }, + "statusCode": { + "type": "integer", + "example": 200 + }, + "data": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "user_id": { + "type": "integer", + "example": 12 + }, + "name": { + "type": "string", + "example": "홍길동" + }, + "nickname": { + "type": "string", + "example": "gildong" + }, + "email": { + "type": "string", + "example": "gildong@example.com" + }, + "profile_image_url": { + "type": "string", + "nullable": true, + "example": "https://cdn.example.com/users/12.png" + } + } + }, + "business_number": { + "type": "string", + "nullable": true, + "example": "123-45-67890" + }, + "company_name": { + "type": "string", + "nullable": true, + "example": "홍길동컴퍼니" + }, + "representative_name": { + "type": "string", + "nullable": true, + "example": "홍길동" + }, + "business_license_url": { + "type": "string", + "nullable": true, + "example": "https://s3.example.com/licenses/12.pdf" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "pagination": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "example": 1 + }, + "limit": { + "type": "integer", + "example": 10 + }, + "total": { + "type": "integer", + "example": 23 + }, + "total_pages": { + "type": "integer", + "example": 3 + }, + "has_next": { + "type": "boolean", + "example": true + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "인증 실패 - 로그인하지 않은 사용자" + }, + "403": { + "description": "권한 없음 - 관리자가 아님" + } + } + } + }, + "/api/admin/sellers/pending/{userId}": { + "get": { + "summary": "사업자 판매자 승인 대기 상세 조회", + "description": "관리자가 특정 사용자의 승인 대기 중인 사업자 등록 신청 상세 정보를 조회합니다.", + "tags": [ + "AdminSeller" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "사용자 ID" + } + ], + "responses": { + "200": { + "description": "조회 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "승인 대기 사업자 판매자 상세 정보를 조회했습니다." + }, + "statusCode": { + "type": "integer", + "example": 200 + }, + "data": { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "user_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "email": { + "type": "string" + }, + "profile_image_url": { + "type": "string", + "nullable": true + } + } + }, + "business_number": { + "type": "string", + "nullable": true + }, + "company_name": { + "type": "string", + "nullable": true + }, + "representative_name": { + "type": "string", + "nullable": true + }, + "business_license_url": { + "type": "string", + "nullable": true + }, + "bank_code": { + "type": "string" + }, + "account_number": { + "type": "string" + }, + "account_holder": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + }, + "400": { + "description": "유효하지 않은 사용자 ID" + }, + "401": { + "description": "인증 실패" + }, + "403": { + "description": "권한 없음" + }, + "404": { + "description": "승인 대기 중인 신청이 존재하지 않음" + } + } + }, + "delete": { + "summary": "사업자 판매자 등록 반려", + "description": "관리자가 사업자 판매자 등록 신청을 반려합니다. `SettlementAccount.status`가 `REJECTED`로 전환되어 승인 대기 목록에서 제외됩니다(soft delete).", + "tags": [ + "AdminSeller" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "사용자 ID" + } + ], + "responses": { + "200": { + "description": "반려 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "사업자 판매자 등록을 반려했습니다." + }, + "statusCode": { + "type": "integer", + "example": 200 + }, + "data": { + "type": "object", + "properties": { + "user_id": { + "type": "integer", + "example": 12 + } + } + } + } + } + } + } + }, + "400": { + "description": "유효하지 않은 사용자 ID" + }, + "401": { + "description": "인증 실패" + }, + "403": { + "description": "권한 없음" + }, + "404": { + "description": "승인 대기 중인 신청이 존재하지 않음" + } + } + } + }, + "/api/admin/sellers/pending/{userId}/approve": { + "patch": { + "summary": "사업자 판매자 등록 승인", + "description": "관리자가 사업자 판매자 등록 신청을 승인합니다. `SettlementAccount.status`가 `APPROVED`로 전환되고 `is_active`가 `true`로 설정됩니다.", + "tags": [ + "AdminSeller" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "사용자 ID" + } + ], + "responses": { + "200": { + "description": "승인 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "사업자 판매자 등록을 승인했습니다." + }, + "statusCode": { + "type": "integer", + "example": 200 + }, + "data": { + "type": "object", + "properties": { + "user_id": { + "type": "integer", + "example": 12 + } + } + } + } + } + } + } + }, + "400": { + "description": "유효하지 않은 사용자 ID" + }, + "401": { + "description": "인증 실패" + }, + "403": { + "description": "권한 없음" + }, + "404": { + "description": "승인 대기 중인 신청이 존재하지 않음" + } + } + } + }, + "/api/admin/sellers/individual": { + "get": { + "summary": "개인 판매자 목록 조회 / 검색", + "description": "관리자가 승인 완료(`APPROVED`) 상태의 개인 판매자 목록을 조회합니다. `search` 파라미터로 실명/이메일/닉네임 부분 일치 검색이 가능합니다.", + "tags": [ + "AdminSeller" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "description": "페이지 번호" + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 10 + }, + "description": "페이지 당 항목 수" + }, + { + "in": "query", + "name": "search", + "schema": { + "type": "string" + }, + "description": "실명, 이메일, 닉네임 부분 일치 검색어" + } + ], + "responses": { + "200": { + "description": "조회 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "개인 판매자 목록을 조회했습니다." + }, + "statusCode": { + "type": "integer", + "example": 200 + }, + "data": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "user_id": { + "type": "integer", + "example": 12 + }, + "name": { + "type": "string", + "example": "홍길동" + }, + "email": { + "type": "string", + "example": "gildong@example.com" + }, + "settlement_account": { + "type": "object", + "properties": { + "bank_code": { + "type": "string", + "example": "KOOKMIN" + }, + "account_number": { + "type": "string", + "example": "1234567890" + }, + "account_holder": { + "type": "string", + "example": "홍길동" + } + } + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "pagination": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "example": 1 + }, + "limit": { + "type": "integer", + "example": 10 + }, + "total": { + "type": "integer", + "example": 23 + }, + "total_pages": { + "type": "integer", + "example": 3 + }, + "has_next": { + "type": "boolean", + "example": true + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "인증 실패" + }, + "403": { + "description": "권한 없음" + } + } + } + }, + "/api/admin/sellers/business": { + "get": { + "summary": "사업자 판매자 목록 조회 / 검색", + "description": "관리자가 승인 완료(`APPROVED`) 상태의 사업자 판매자 목록을 조회합니다. `search` 파라미터로 실명/이메일/닉네임 부분 일치 검색이 가능합니다.", + "tags": [ + "AdminSeller" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 10 + } + }, + { + "in": "query", + "name": "search", + "schema": { + "type": "string" + }, + "description": "실명, 이메일, 닉네임 부분 일치 검색어" + } + ], + "responses": { + "200": { + "description": "조회 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "사업자 판매자 목록을 조회했습니다." + }, + "statusCode": { + "type": "integer", + "example": 200 + }, + "data": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "user_id": { + "type": "integer", + "example": 12 + }, + "profile_image_url": { + "type": "string", + "nullable": true, + "example": "https://cdn.example.com/users/12.png" + }, + "nickname": { + "type": "string", + "example": "gildong" + }, + "name": { + "type": "string", + "example": "홍길동" + }, + "email": { + "type": "string", + "example": "gildong@example.com" + }, + "settlement_account": { + "type": "object", + "properties": { + "bank_code": { + "type": "string", + "example": "KOOKMIN" + }, + "account_number": { + "type": "string", + "example": "1234567890" + }, + "account_holder": { + "type": "string", + "example": "홍길동" + } + } + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "pagination": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "example": 1 + }, + "limit": { + "type": "integer", + "example": 10 + }, + "total": { + "type": "integer", + "example": 23 + }, + "total_pages": { + "type": "integer", + "example": 3 + }, + "has_next": { + "type": "boolean", + "example": true + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "인증 실패" + }, + "403": { + "description": "권한 없음" + } + } + } + }, + "/api/admin/sellers/individual/{userId}": { + "get": { + "summary": "개인 판매자 상세 조회", + "description": "관리자가 승인 완료(`APPROVED`)된 개인 판매자의 상세 정보를 조회합니다.", + "tags": [ + "AdminSeller" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "사용자 ID" + } + ], + "responses": { + "200": { + "description": "조회 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "개인 판매자 상세 정보를 조회했습니다." + }, + "statusCode": { + "type": "integer", + "example": 200 + }, + "data": { + "type": "object", + "properties": { + "user_id": { + "type": "integer", + "example": 12 + }, + "profile_image_url": { + "type": "string", + "nullable": true + }, + "nickname": { + "type": "string", + "example": "gildong" + }, + "name": { + "type": "string", + "example": "홍길동" + }, + "email": { + "type": "string", + "example": "gildong@example.com" + }, + "registration_type": { + "type": "string", + "example": "INDIVIDUAL" + }, + "settlement_account": { + "type": "object", + "properties": { + "bank_code": { + "type": "string", + "example": "KOOKMIN" + }, + "account_number": { + "type": "string", + "example": "1234567890" + }, + "account_holder": { + "type": "string", + "example": "홍길동" + } + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + }, + "400": { + "description": "유효하지 않은 사용자 ID" + }, + "401": { + "description": "인증 실패" + }, + "403": { + "description": "권한 없음" + }, + "404": { + "description": "승인 완료된 개인 판매자 정보가 존재하지 않음" + } + } + } + }, + "/api/admin/sellers/business/{userId}": { + "get": { + "summary": "사업자 판매자 상세 조회", + "description": "관리자가 승인 완료(`APPROVED`)된 사업자 판매자의 상세 정보를 조회합니다. 사업자등록증 URL과 사업자 정보를 포함합니다.", + "tags": [ + "AdminSeller" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "사용자 ID" + } + ], + "responses": { + "200": { + "description": "조회 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "사업자 판매자 상세 정보를 조회했습니다." + }, + "statusCode": { + "type": "integer", + "example": 200 + }, + "data": { + "type": "object", + "properties": { + "user_id": { + "type": "integer", + "example": 12 + }, + "profile_image_url": { + "type": "string", + "nullable": true + }, + "nickname": { + "type": "string", + "example": "gildong" + }, + "name": { + "type": "string", + "example": "홍길동" + }, + "email": { + "type": "string", + "example": "gildong@example.com" + }, + "registration_type": { + "type": "string", + "example": "BUSINESS" + }, + "business_number": { + "type": "string", + "nullable": true, + "example": "123-45-67890" + }, + "representative_name": { + "type": "string", + "nullable": true, + "example": "홍길동" + }, + "company_name": { + "type": "string", + "nullable": true, + "example": "홍길동컴퍼니" + }, + "business_license_url": { + "type": "string", + "nullable": true, + "example": "https://s3.example.com/licenses/12.pdf" + }, + "settlement_account": { + "type": "object", + "properties": { + "bank_code": { + "type": "string" + }, + "account_number": { + "type": "string" + }, + "account_holder": { + "type": "string" + } + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + }, + "400": { + "description": "유효하지 않은 사용자 ID" + }, + "401": { + "description": "인증 실패" + }, + "403": { + "description": "권한 없음" + }, + "404": { + "description": "승인 완료된 사업자 판매자 정보가 존재하지 않음" + } + } + } + }, + "/api/admin/sellers/{userId}": { + "delete": { + "summary": "판매자 등록 취소", + "description": "관리자가 승인 완료(`APPROVED`) 상태의 판매자(개인/사업자 공통) 등록을 취소합니다.\n다음 작업이 트랜잭션으로 함께 수행됩니다.\n\n- 사용자의 활성 프롬프트 일괄 비활성화 (`Prompt.inactive_date = now()`)\n- `SettlementAccount` 레코드 하드 삭제\n\n기존 구매/정산 이력은 영향받지 않습니다.\n", + "tags": [ + "AdminSeller" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "사용자 ID" + } + ], + "responses": { + "200": { + "description": "취소 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "판매자 등록을 취소했습니다." + }, + "statusCode": { + "type": "integer", + "example": 200 + }, + "data": { + "type": "object", + "properties": { + "user_id": { + "type": "integer", + "example": 12 + }, + "deactivated_prompt_count": { + "type": "integer", + "example": 5 + } + } + } + } + } + } + } + }, + "400": { + "description": "유효하지 않은 사용자 ID" + }, + "401": { + "description": "인증 실패" + }, + "403": { + "description": "권한 없음" + }, + "404": { + "description": "승인 완료된 판매자가 아님" + } + } + } + }, + "/api/settlements/accounts": { + "get": { + "summary": "등록된 정산 계좌 정보 조회", + "description": "현재 로그인한 사용자의 정산용 계좌 정보(은행, 계좌번호, 예금주명)를 조회합니다.", + "tags": [ + "Settlement" + ], + "security": [ + { + "jwt": [] + } + ], + "responses": { + "200": { + "description": "계좌 정보 조회 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "계좌 정보 조회가 완료되었습니다." + }, + "data": { + "type": "object", + "properties": { + "bank": { + "type": "string", + "example": "KOOKMIN" + }, + "accountNumber": { + "type": "string", + "example": "1234567890" + }, + "holderName": { + "type": "string", + "example": "홍길동" + } + } + }, + "statusCode": { + "type": "integer", + "example": 200 + } + } + } + } + } + }, + "401": { + "description": "인증 실패 - 로그인하지 않은 사용자", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + }, + "message": { + "type": "string", + "example": "로그인이 필요합니다." + }, + "statusCode": { + "type": "integer", + "example": 401 + } + } + } + } + } + }, + "404": { + "description": "계좌 정보 없음 - 등록된 계좌가 없는 경우", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "AccountNotFound" + }, + "message": { + "type": "string", + "example": "등록된 계좌 정보가 존재하지 않습니다." + }, + "statusCode": { + "type": "integer", + "example": 404 + } + } + } + } + } + }, + "500": { + "description": "서버 오류", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "InternalServerError" + }, + "message": { + "type": "string", + "example": "알 수 없는 오류가 발생했습니다." + }, + "statusCode": { + "type": "integer", + "example": 500 + } + } + } + } + } + } + } + } + }, + "/api/settlements/upload/business-license": { + "post": { + "summary": "사업자등록증 업로드 (개인/법인 사업자)", + "description": "개인 또는 법인 사업자의 사업자등록증 파일(이미지 또는 PDF, 최대 20MB)을 업로드하고 S3 URL을 반환받습니다.", + "tags": [ + "Settlement" + ], + "security": [ + { + "jwt": [] + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "file" + ], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "업로드할 사업자등록증 파일 (jpg, jpeg, png, pdf) / 최대 20MB" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "파일 업로드 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "사업자등록증 업로드가 완료되었습니다." + }, + "fileUrl": { + "type": "string", + "example": "https://promptplace-storage.s3.ap-northeast-2.amazonaws.com/business-licenses/123-1709865432123.jpg" + }, + "statusCode": { + "type": "integer", + "example": 200 + } + } + } + } + } + }, + "400": { + "description": "업로드할 파일 누락", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "ValidationError" + }, + "message": { + "type": "string", + "example": "업로드할 파일이 첨부되지 않았습니다." + }, + "statusCode": { + "type": "integer", + "example": 400 + } + } + } + } + } + }, + "401": { + "description": "인증 실패 - 로그인하지 않은 사용자", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Unauthorized" + }, + "message": { + "type": "string", + "example": "로그인이 필요합니다." + }, + "statusCode": { + "type": "integer", + "example": 401 + } + } + } + } + } + }, + "413": { + "description": "파일 용량 제한 초과 (20MB)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "FileTooLarge" + }, + "message": { + "type": "string", + "example": "파일 크기는 최대 20MB까지만 허용됩니다." + }, + "statusCode": { + "type": "integer", + "example": 413 + } + } + } + } + } + }, + "415": { + "description": "지원하지 않는 파일 형식", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "InvalidFileType" + }, + "message": { + "type": "string", + "example": "지원하지 않는 파일 형식입니다. (jpg, jpeg, png, pdf만 가능)" + }, + "statusCode": { + "type": "integer", + "example": 415 + } + } + } + } + } + }, + "500": { + "description": "서버 오류 - 알 수 없는 예외 발생", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "InternalServerError" + }, + "message": { + "type": "string", + "example": "서버 오류가 발생했습니다." + }, + "statusCode": { + "type": "integer", + "example": 500 + } + } + } + } + } + } + } + } + }, + "/api/settlements/sales/monthly": { + "get": { + "summary": "월별 판매 내역 조회", + "description": "로그인한 판매자의 특정 연-월에 발생한 판매(Settlement) 내역을 조회합니다. year/month 미지정 시 현재 UTC 기준 년/월을 사용합니다. 결제수단(pay_type)과 카드사명(card_name)은 페이플 일반결제 검증 결과를 그대로 노출합니다.", + "tags": [ + "Settlement" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "year", + "schema": { + "type": "integer", + "example": 2026 + }, + "description": "조회할 연도 (기본값 현재 UTC 연도)" + }, + { + "in": "query", + "name": "month", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "example": 5 + }, + "description": "조회할 월 (1-12, 기본값 현재 UTC 월)" + } + ], + "responses": { + "200": { + "description": "조회 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "월별 판매 내역 조회 성공" + }, + "year": { + "type": "integer", + "example": 2026 + }, + "month": { + "type": "integer", + "example": 5 + }, + "summary": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "example": 3 + }, + "total_sales": { + "type": "integer", + "description": "원래 판매가 합계", + "example": 30000 + }, + "total_settled": { + "type": "integer", + "description": "정산 금액(수수료 차감 후) 합계", + "example": 27000 + }, + "total_fee": { + "type": "integer", + "example": 3000 + } + } + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "settlement_id": { + "type": "integer" + }, + "sold_at": { + "type": "string", + "format": "date-time" + }, + "prompt_id": { + "type": "integer" + }, + "prompt_title": { + "type": "string" + }, + "buyer_id": { + "type": "integer" + }, + "buyer_nickname": { + "type": "string", + "nullable": true + }, + "pay_type": { + "type": "string", + "nullable": true, + "description": "페이플 결제 타입 (card/transfer)" + }, + "card_name": { + "type": "string", + "nullable": true, + "description": "카드사명 (페이플 PCD_PAY_CARDNAME)" + }, + "sale_price": { + "type": "integer" + }, + "settled_amount": { + "type": "integer" + }, + "fee": { + "type": "integer" + }, + "status": { + "type": "string", + "enum": [ + "Pending", + "Succeed", + "Failed" + ] + } + } + } + }, + "statusCode": { + "type": "integer", + "example": 200 + } + } + } + } + } + }, + "400": { + "description": "잘못된 year/month 값" + }, + "401": { + "description": "로그인 필요" + } + } + } + }, + "/api/settlements/yearly": { + "get": { + "summary": "연도별 누적 정산 내역 조회", + "description": "로그인한 판매자의 연도별 누적 정산 합계를 조회합니다. 각 연도 row는 해당 연도의 판매 건수, 원래 판매가 합계(total_sales), 정산 금액 합계(total_settled), 수수료 합계(total_fee), 상태별 정산금(succeeded/pending) 누적치를 포함합니다.", + "tags": [ + "Settlement" + ], + "security": [ + { + "jwt": [] + } + ], + "responses": { + "200": { + "description": "조회 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "연도별 누적 정산 내역 조회 성공" + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "year": { + "type": "integer", + "example": 2026 + }, + "count": { + "type": "integer", + "example": 50 + }, + "total_sales": { + "type": "integer", + "example": 500000 + }, + "total_settled": { + "type": "integer", + "example": 450000 + }, + "total_fee": { + "type": "integer", + "example": 50000 + }, + "succeeded_amount": { + "type": "integer", + "example": 400000 + }, + "pending_amount": { + "type": "integer", + "example": 50000 + } + } + } + }, + "statusCode": { + "type": "integer", + "example": 200 + } + } + } + } + } + }, + "401": { + "description": "로그인 필요" + } + } + } + }, + "/api/admin/stats/members": { + "get": { + "summary": "회원 가입 현황 조회", + "description": "관리자 대시보드의 사용자 통계 영역에서 사용할 회원 가입 현황을 조회합니다.\n\n- 총 회원수: 탈퇴(`deleted`) 상태를 제외한 전체 회원수\n- 가입 경로별 비율: 자체 이메일(`NONE`) / 구글 / 카카오 / 네이버\n\n`ratio`는 0~1 사이 값(소수점 4자리)으로 반환됩니다.\n", + "tags": [ + "AdminStats" + ], + "security": [ + { + "jwt": [] + } + ], + "responses": { + "200": { + "description": "조회 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "회원 가입 현황을 조회했습니다." + }, + "statusCode": { + "type": "integer", + "example": 200 + }, + "data": { + "type": "object", + "properties": { + "total_members": { + "type": "integer", + "example": 1234 + }, + "by_signup_channel": { + "type": "object", + "properties": { + "email": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "example": 700 + }, + "ratio": { + "type": "number", + "example": 0.5673 + } + } + }, + "google": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "example": 250 + }, + "ratio": { + "type": "number", + "example": 0.2026 + } + } + }, + "kakao": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "example": 200 + }, + "ratio": { + "type": "number", + "example": 0.1621 + } + } + }, + "naver": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "example": 84 + }, + "ratio": { + "type": "number", + "example": 0.0681 + } + } + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "인증 실패" + }, + "403": { + "description": "권한 없음" + } + } + } + }, + "/api/admin/stats/active-users": { + "get": { + "summary": "활성 사용자 통계 조회", + "description": "최근 30일 롤링 윈도우 기준 활성 사용자 수와 전 구간(그 이전 30일) 대비 증감율을 반환합니다.\n\n- `current_count`: now - 30d ~ now 사이에 `last_active_at`이 기록된 사용자 수\n- `previous_count`: now - 60d ~ now - 30d 사이의 활성 사용자 수\n- `change_rate`: `(current - previous) / previous` (소수 4자리). 이전 구간이 0이면 `null`\n- 탈퇴(`userstatus=deleted`) 사용자는 제외\n\n활동 기록은 JWT 인증을 통과한 모든 요청에서 5분 throttle로 갱신됩니다.\n", + "tags": [ + "AdminStats" + ], + "security": [ + { + "jwt": [] + } + ], + "responses": { + "200": { + "description": "조회 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "활성 사용자 통계를 조회했습니다." + }, + "statusCode": { + "type": "integer", + "example": 200 + }, + "data": { + "type": "object", + "properties": { + "window_days": { + "type": "integer", + "example": 30 + }, + "current_count": { + "type": "integer", + "example": 1234 + }, + "previous_count": { + "type": "integer", + "example": 1100 + }, + "change_rate": { + "type": "number", + "nullable": true, + "example": 0.1218 + } + } + } + } + } + } + } + }, + "401": { + "description": "인증 실패" + }, + "403": { + "description": "권한 없음" + } + } + } + }, + "/api/admin/stats/visitors": { + "get": { + "summary": "방문자 통계 조회", + "description": "방문자 통계를 조회합니다. 모든 카운트는 KST(Asia/Seoul) 캘린더 기준이며 Redis HyperLogLog를 사용해 고유 방문자 수를 추정(오차 ~0.81%)합니다.\n\n- `daily_count`: 오늘(KST) 고유 방문자 수\n- `current_count`: 최근 30일 롤링 윈도우 고유 방문자 수 (합집합)\n- `previous_count`: 그 이전 30일 (now-60d ~ now-30d) 고유 방문자 수\n- `change_rate`: `(current - previous) / previous` (소수 4자리). 이전 구간 0이면 `null`\n- `?month=YYYY-MM` 파라미터 제공 시 `month_total`(해당 월 고유 방문자 합) + `month_daily`(일별 분포) 함께 반환\n\n활동 기록은 모든 비-봇 API 요청에서 KST 일별 HyperLogLog 키에 `PFADD`로 누적됩니다.\n", + "tags": [ + "AdminStats" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "month", + "schema": { + "type": "string", + "pattern": "^\\d{4}-(0[1-9]|1[0-2])$", + "example": "2026-05" + }, + "description": "특정 월의 일별 분포 조회 (선택)" + } + ], + "responses": { + "200": { + "description": "조회 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "방문자 통계를 조회했습니다." + }, + "statusCode": { + "type": "integer", + "example": 200 + }, + "data": { + "type": "object", + "properties": { + "daily_count": { + "type": "integer", + "example": 312 + }, + "window_days": { + "type": "integer", + "example": 30 + }, + "current_count": { + "type": "integer", + "example": 9450 + }, + "previous_count": { + "type": "integer", + "example": 8200 + }, + "change_rate": { + "type": "number", + "nullable": true, + "example": 0.1524 + }, + "month": { + "type": "string", + "nullable": true, + "example": "2026-05" + }, + "month_total": { + "type": "integer", + "nullable": true, + "example": 8970 + }, + "month_daily": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "date": { + "type": "string", + "example": "2026-05-01" + }, + "count": { + "type": "integer", + "example": 287 + } + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "잘못된 month 파라미터 형식" + }, + "401": { + "description": "인증 실패" + }, + "403": { + "description": "권한 없음" + } + } + } + }, + "/api/admin/stats/prompts/new": { + "get": { + "summary": "신규 프롬프트 통계 조회", + "description": "관리자 대시보드의 신규 프롬프트 영역에서 사용하는 통계 API.\n\n- `daily_count`: 최근 24시간(롤링 윈도우) 동안 업로드된 활성 프롬프트 수\n- `weekly_count`: 최근 7일(KST 캘린더 기준) 업로드 합계\n- `daily_uploads`: 최근 7일치 일자별 업로드 수 (KST 기준 `YYYY-MM-DD`, 막대 그래프용)\n\n비활성(`inactive_date IS NOT NULL`) 프롬프트는 모두 제외됩니다.\n", "tags": [ - "Reviews" + "AdminStats" ], "security": [ { "jwt": [] } ], - "parameters": [ - { - "in": "path", - "name": "reviewId", - "required": true, - "schema": { - "type": "integer" - }, - "description": "삭제할 리뷰 ID" - } - ], "responses": { "200": { - "description": "리뷰가 성공적으로 삭제되었습니다.", + "description": "조회 성공", "content": { "application/json": { - "example": { - "message": "리뷰가 성공적으로 삭제되었습니다.", - "data": {}, - "statusCode": 200 + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "신규 프롬프트 통계를 조회했습니다." + }, + "statusCode": { + "type": "integer", + "example": 200 + }, + "data": { + "type": "object", + "properties": { + "daily_count": { + "type": "integer", + "example": 12 + }, + "weekly_count": { + "type": "integer", + "example": 86 + }, + "daily_uploads": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string", + "example": "2026-05-11" + }, + "count": { + "type": "integer", + "example": 14 + } + } + } + } + } + } + } } } } }, + "401": { + "description": "인증 실패" + }, "403": { - "description": "리뷰 작성일로부터 30일이 지나 삭제할 수 없습니다.", - "content": { - "application/json": { - "example": { - "error": "Forbidden", - "message": "리뷰 작성일로부터 30일이 지나 삭제할 수 없습니다.", - "statusCode": 403 - } - } - } + "description": "권한 없음" } } - }, - "patch": { - "summary": "특정 리뷰 수정", - "description": "### API 설명\n\n- 사용자가 본인의 리뷰를 수정할 수 있는 API입니다. \n- 리뷰 내용(`content`)과 평점(`rating`)을 모두 전달해야 합니다. \n- `Authorization` 헤더를 포함한 로그인 사용자만 접근할 수 있습니다.\n\n### Path Variable\n| 항목 | 설명 | 예시 | 필수 여부 |\n|-----------|------------------|--------|-----------|\n| reviewId | 수정할 리뷰 ID | `59` | ✅ |\n\n### Request Body\n| 항목 | 설명 | 예시 | 필수 여부 |\n|----------|------------|----------------------|-----------|\n| rating | 평점 (0~5) | `4.5` | ✅ |\n| content | 리뷰 내용 | 수정된 전체 내용입니다. | ✅ |\n\n### Header\n```json\n{\n \"Authorization\": \"Bearer {access_token}\"\n}\n```\n", + } + }, + "/api/admin/stats/prompts/top-sales": { + "get": { + "summary": "매출 상위 프롬프트 Top 5 조회", + "description": "최근 30일간 매출액 기준 상위 5개 프롬프트를 조회합니다.\n\n- 기준: `Purchase.amount` 합계 (`is_free=false`만 합산)\n- 기간: 최근 30일(롤링 윈도우)\n- 정렬: `total_sales DESC`, 동률 시 Prisma 기본 순서\n- 프롬프트가 삭제·비활성화되어도 매출 집계에는 포함되며, 제목이 없을 경우 `title: null`로 반환됩니다.\n", "tags": [ - "Reviews" + "AdminStats" ], "security": [ { "jwt": [] } ], - "parameters": [ - { - "in": "path", - "name": "reviewId", - "required": true, - "schema": { - "type": "integer" - }, - "description": "수정할 리뷰 ID" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "rating", - "content" - ], - "properties": { - "rating": { - "type": "number", - "format": "float", - "example": 4.5, - "description": "평점 (0.0 ~ 5.0)" - }, - "content": { - "type": "string", - "example": "수정된 전체 내용입니다.", - "description": "리뷰 내용" - } - } - } - } - } - }, "responses": { "200": { - "description": "리뷰가 성공적으로 수정되었습니다.", + "description": "조회 성공", "content": { "application/json": { - "example": { - "message": "리뷰가 성공적으로 수정되었습니다.", - "data": { - "review_id": 59, - "prompt_id": 10, - "writer_name": "남아린", - "rating": 4.5, - "content": "수정된 전체 내용입니다.", - "updated_at": "2025-08-07T09:16:14.838Z" - }, - "statusCode": 200 + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "매출 상위 프롬프트를 조회했습니다." + }, + "statusCode": { + "type": "integer", + "example": 200 + }, + "data": { + "type": "object", + "properties": { + "period_days": { + "type": "integer", + "example": 30 + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "rank": { + "type": "integer", + "example": 1 + }, + "prompt_id": { + "type": "integer", + "example": 42 + }, + "title": { + "type": "string", + "nullable": true, + "example": "ChatGPT 마케팅 카피 30종" + }, + "total_sales": { + "type": "integer", + "example": 825000 + } + } + } + } + } + } + } } } } + }, + "401": { + "description": "인증 실패" + }, + "403": { + "description": "권한 없음" } } } }, - "/api/reviews/{reviewId}/edit": { + "/api/admin/stats/prompts/popular": { "get": { - "summary": "리뷰 수정 화면 데이터 조회", - "description": "### API 설명\n\n- 해당 리뷰의 수정 화면에 필요한 정보를 조회하는 API입니다. \n- 프롬프트 정보, 모델 정보, 작성자 정보 및 기존 리뷰 내용을 포함합니다. \n- 로그인한 사용자만 접근할 수 있으며, `Authorization` 헤더가 필요합니다.\n\n### Path Variable\n| 항목 | 설명 | 예시 | 필수 여부 |\n|-----------|------------------|--------|-----------|\n| reviewId | 수정할 리뷰 ID | `63` | ✅ |\n\n### Header\n```json\n{\n \"Authorization\": \"Bearer {access_token}\"\n}\n```\n", + "summary": "인기 프롬프트 Top 5 조회", + "description": "최근 7일간 (조회수 증가분 + 다운로드 증가분) 기준 상위 5개 프롬프트를 조회합니다.\n\n- 일일 스냅샷(`PromptStatDaily`)과 현재값의 차분으로 7일 윈도우 계산\n- 활성(`inactive_date IS NULL`) 프롬프트만 포함\n- 7일 전 스냅샷이 없으면 baseline=0으로 간주 (신규 프롬프트도 포함됨)\n- 동률 시 정렬 안정성은 보장하지 않음\n\n스냅샷은 매일 00:00 KST에 자동 수행되며 90일치 보관됩니다.\n", "tags": [ - "Reviews" + "AdminStats" ], "security": [ { "jwt": [] } ], - "parameters": [ - { - "in": "path", - "name": "reviewId", - "required": true, - "schema": { - "type": "integer" - }, - "description": "수정할 리뷰 ID" - } - ], "responses": { "200": { - "description": "리뷰 수정 화면 데이터를 성공적으로 불러왔습니다.", + "description": "조회 성공", "content": { "application/json": { - "example": { - "message": "리뷰 수정 화면 데이터를 성공적으로 불러왔습니다.", - "data": { - "prompter_id": 3, - "prompter_nickname": "닉네임3", - "prompt_id": 5, - "prompt_title": "프롬프트 3", - "model_id": 3, - "model_name": "모델 이름 3", - "rating_avg": "4.0", - "content": "너무 유용했어요!" - }, - "statusCode": 200 + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "인기 프롬프트를 조회했습니다." + }, + "statusCode": { + "type": "integer", + "example": 200 + }, + "data": { + "type": "object", + "properties": { + "period_days": { + "type": "integer", + "example": 7 + }, + "snapshot_date": { + "type": "string", + "example": "2026-05-10" + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "rank": { + "type": "integer", + "example": 1 + }, + "prompt_id": { + "type": "integer", + "example": 42 + }, + "title": { + "type": "string", + "example": "ChatGPT 마케팅 카피 30종" + }, + "views_delta": { + "type": "integer", + "example": 1240 + }, + "downloads_delta": { + "type": "integer", + "example": 87 + }, + "score": { + "type": "integer", + "example": 1327 + } + } + } + } + } + } + } } } } + }, + "401": { + "description": "인증 실패" + }, + "403": { + "description": "권한 없음" } } } @@ -5331,14 +8139,14 @@ } }, "tags": [ - { - "name": "Account", - "description": "계좌 관련 API" - }, { "name": "Auth", "description": "인증 및 로그인 관련 API" }, + { + "name": "Chat", + "description": "채팅 관련 API" + }, { "name": "Inquiry", "description": "1:1 문의 관련 API" @@ -5379,6 +8187,18 @@ "name": "Reviews", "description": "프롬프트 리뷰 관련 API" }, + { + "name": "AdminSeller", + "description": "관리자 - 판매자 관리 API" + }, + { + "name": "Settlement", + "description": "정산 관리 관련 API" + }, + { + "name": "AdminStats", + "description": "관리자 - 대시보드 통계 API" + }, { "name": "Tip", "description": "팁 관련 API"