From afc714022f078944ab50c1f1831cef2cf40f2668 Mon Sep 17 00:00:00 2001 From: koreacomp5 Date: Fri, 28 Nov 2025 06:12:58 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B8=EA=B8=B0=EA=B8=80=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/.prompt/new.md | 37 +++++++++++ prisma/schema.prisma | 16 +++++ src/app/api/auth/permissions/route.ts | 32 ++++++++-- src/app/api/posts/[id]/view/route.ts | 19 ++++++ src/app/api/posts/popular/route.ts | 92 +++++++++++++++++++++++++++ src/app/components/PostList.tsx | 92 +++++++++++++++++++++++++-- src/lib/rbac.ts | 20 ++++-- 7 files changed, 294 insertions(+), 14 deletions(-) create mode 100644 .cursor/.prompt/new.md create mode 100644 src/app/api/posts/popular/route.ts diff --git a/.cursor/.prompt/new.md b/.cursor/.prompt/new.md new file mode 100644 index 0000000..d4a8156 --- /dev/null +++ b/.cursor/.prompt/new.md @@ -0,0 +1,37 @@ +7.게시판 열 구분선 잘보이게 요청 / 모바일 최적화 요청 및 구분선,높이 조절 요청 +8.글삭제기능 어드민추가 + +9-1.시크로드 참고 - 제휴업체 리스트, 지역별 / 제휴업체 정보 생성 요청 (제휴업체 프로필,출근부) +9-2.제휴업체 및 프로필 등록은, 권한을부여한 제휴업체가 직접 등록하도록 해야합니다 +9-3.제휴업체 등록 주인이하면안됨, 시크로드 - 제휴문의 - 제휴문의글쓰기 참고요청 +제휴문의 게시판 ( 관리자 승인후) → 이동 +제휴업소 리스트 게시판 (노출됨)" + +10.배너관리 이미지 사이즈 규격 및 제휴업체 등록시 이미지 사이즈 규격 +11,게시판, 주간인기글 , 일간인기글 추가 요청 + +12.https://search.google.com/search-console/welcome 등록요청 +13.메인페이지 제휴업체 배너 이미지 규격작게 수정요청, 한번에 4개이상 정도 보이는 정도 +14.메인페이지 제휴업체 클릭시 제휴업체 카테고리 하이퍼연결 추가 요청 +15.레벨별 아이콘 페이지 / https://seekrod.co.kr/bbs/page.php?hid=point 참고 +16. 게시글 상단고정 기능 +17. 메인화면 큰 카테고리 빼기 +18. google SEO: header, meta, description 동적설정 +2. 회색 배경 영역(비밀글, 글자수)이 글 작성 영역 안에 있어야 함 +로그인 프로세스 수정: 로그인시 로그인 팝업 모달 +로그인 로그오프 시, 메인 프로필 디자인 누락되서 추가(지금상태로 가도 무상관) +1. 게시글에서 목록돌아가는 버튼 디자인과 다름 +2. 게시글에서 제목, 내용, 댓글 디자인 다름 +3. 게시글에서 최하단에 게시글리스트 부분 디자인 다 +4. 게시글 리스트에서 리스트버튼 하단 divider 색상이 너무 연함 +5. 표시개수 필요없을 거 같은데, 그냥 고정해버리죠? + +9. 게시글 이미지 배치사이즈 +10. 외부접속 가입없어도 가능 +11. 글뷰에서 게시글 리스트로 +12. 게시글 리스트 디자인 +13. 포인트 규칙 +14. 게시판권한 확인 +15.메인뷰 3열 사이즈 변경 +16. 로그인 안됐을때 카드 휑함 +17.이미지 사이즈 미리불러오기 \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 94fd73d..3f660ab 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -217,6 +217,7 @@ model Post { reports Report[] stat PostStat? viewLogs PostViewLog[] + dailyViews DailyPostView[] @@index([boardId, status, createdAt]) @@index([boardId, isPinned, pinnedOrder]) @@ -438,6 +439,21 @@ model PostStat { @@map("post_stats") } +// 일일 게시글 조회수 (날짜별 집계) +model DailyPostView { + id String @id @default(cuid()) + postId String + date DateTime // 날짜만 사용 (시간은 00:00:00) + viewCount Int @default(0) + + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + + @@unique([postId, date]) + @@index([date, viewCount]) + @@index([postId, date]) + @@map("daily_post_views") +} + // 신고(게시글/댓글 대상) model Report { id String @id @default(cuid()) diff --git a/src/app/api/auth/permissions/route.ts b/src/app/api/auth/permissions/route.ts index c5609a0..894717a 100644 --- a/src/app/api/auth/permissions/route.ts +++ b/src/app/api/auth/permissions/route.ts @@ -5,13 +5,33 @@ import { getUserIdFromRequest } from "@/lib/auth"; export async function GET(req: Request) { const userId = getUserIdFromRequest(req); if (!userId) return NextResponse.json({ permissions: [] }); - const roles = await prisma.userRole.findMany({ where: { userId }, select: { roleId: true } }); - if (roles.length === 0) return NextResponse.json({ permissions: [] }); - const roleIds = roles.map((r) => r.roleId); - const permissions = await prisma.rolePermission.findMany({ - where: { roleId: { in: roleIds }, allowed: true }, - select: { resource: true, action: true }, + + const user = await prisma.user.findUnique({ + where: { userId }, + select: { + authLevel: true, + userRoles: { select: { roleId: true } }, + }, }); + if (!user) return NextResponse.json({ permissions: [] }); + + const roleIds = user.userRoles.map((r) => r.roleId); + const rolePermissions = + roleIds.length > 0 + ? await prisma.rolePermission.findMany({ + where: { roleId: { in: roleIds }, allowed: true }, + select: { resource: true, action: true }, + }) + : []; + + const hasAdminPerm = rolePermissions.some( + (perm) => perm.resource === "ADMIN" && perm.action === "ADMINISTER" + ); + const permissions = + user.authLevel === "ADMIN" && !hasAdminPerm + ? [{ resource: "ADMIN" as const, action: "ADMINISTER" as const }, ...rolePermissions] + : rolePermissions; + return NextResponse.json({ permissions }); } diff --git a/src/app/api/posts/[id]/view/route.ts b/src/app/api/posts/[id]/view/route.ts index fa7e70e..b8e2997 100644 --- a/src/app/api/posts/[id]/view/route.ts +++ b/src/app/api/posts/[id]/view/route.ts @@ -57,6 +57,25 @@ export async function POST(req: Request, context: { params: Promise<{ id: string update: { views: { increment: 1 } }, create: { postId: id, views: 1 }, }); + + // 일일 조회수 업데이트 (오늘 날짜) + const today = new Date(); + today.setHours(0, 0, 0, 0); + await tx.dailyPostView.upsert({ + where: { + postId_date: { + postId: id, + date: today, + }, + }, + update: { viewCount: { increment: 1 } }, + create: { + postId: id, + date: today, + viewCount: 1, + }, + }); + counted = true; }); diff --git a/src/app/api/posts/popular/route.ts b/src/app/api/posts/popular/route.ts new file mode 100644 index 0000000..4ced366 --- /dev/null +++ b/src/app/api/posts/popular/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const boardId = searchParams.get("boardId"); + const period = searchParams.get("period") || "daily"; // daily | weekly + + // 날짜 범위 계산 + const now = new Date(); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0); + const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999); + const startOfWeek = new Date(now); + startOfWeek.setDate(now.getDate() - 7); + startOfWeek.setHours(0, 0, 0, 0); + + const dateFilter = period === "daily" + ? { + date: { + gte: startOfToday, + lte: endOfToday, + } + } + : { date: { gte: startOfWeek } }; + + // 일일 조회수 테이블에서 조회수 합계 계산 + const dailyViews = await prisma.dailyPostView.groupBy({ + by: ["postId"], + where: dateFilter, + _sum: { + viewCount: true, + }, + orderBy: { + _sum: { + viewCount: "desc", + }, + }, + take: 20, // 조회수 상위 20개만 가져와서 게시글 정보 조회 + }); + + if (dailyViews.length === 0) { + return NextResponse.json({ items: [], period }); + } + + const postIds = dailyViews.map((dv) => dv.postId); + + // 게시글 정보 조회 + const posts = await prisma.post.findMany({ + where: { + id: { in: postIds }, + status: "published", + ...(boardId ? { boardId } : {}), + }, + select: { + id: true, + title: true, + createdAt: true, + boardId: true, + board: { select: { id: true, name: true, slug: true } }, + isPinned: true, + status: true, + author: { select: { userId: true, nickname: true } }, + stat: { select: { recommendCount: true, views: true, commentsCount: true } }, + postTags: { select: { tag: { select: { name: true, slug: true } } } }, + }, + }); + + // 조회수와 게시글 매핑 + const viewCountMap = new Map( + dailyViews.map((dv) => [dv.postId, dv._sum.viewCount ?? 0]) + ); + + // 조회수 순으로 정렬 (조회수가 0보다 큰 것만) + const postsWithViews = posts + .map((post) => ({ + ...post, + viewCount: viewCountMap.get(post.id) ?? 0, + })) + .filter((post) => post.viewCount > 0) // 조회수가 0보다 큰 것만 + .sort((a, b) => { + // 고정글 우선 + if (a.isPinned && !b.isPinned) return -1; + if (!a.isPinned && b.isPinned) return 1; + // 조회수 순 + if (b.viewCount !== a.viewCount) return b.viewCount - a.viewCount; + // 최신순 + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }) + .slice(0, 5); // 상위 5개만 + + return NextResponse.json({ items: postsWithViews, period }); +} diff --git a/src/app/components/PostList.tsx b/src/app/components/PostList.tsx index adba2ce..b4b7c2a 100644 --- a/src/app/components/PostList.tsx +++ b/src/app/components/PostList.tsx @@ -19,6 +19,7 @@ type Item = { stat?: { recommendCount: number; views: number; commentsCount: number } | null; postTags?: { tag: { name: string; slug: string } }[]; author?: { nickname: string } | null; + viewCount?: number; // 일일/주간 조회수 }; type Resp = { @@ -45,6 +46,12 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s const listContainerRef = useRef(null); const [lockedMinHeight, setLockedMinHeight] = useState(null); + // 인기글 데이터 가져오기 (board variant일 때만) + const popularDailyKey = variant === "board" ? `/api/posts/popular?${boardId ? `boardId=${boardId}&` : ""}period=daily` : null; + const popularWeeklyKey = variant === "board" ? `/api/posts/popular?${boardId ? `boardId=${boardId}&` : ""}period=weekly` : null; + const { data: dailyData } = useSWR<{ items: Item[] }>(popularDailyKey, fetcher); + const { data: weeklyData } = useSWR<{ items: Item[] }>(popularWeeklyKey, fetcher); + // board 변형에서는 URL에서 pageSize를 읽고, 기본값은 20 const defaultPageSize = variant === "board" ? 20 : 10; const pageSizeParam = sp.get("pageSize"); @@ -184,6 +191,83 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s return (
+ {/* 인기글 섹션 (board variant일 때만 표시) */} + {variant === "board" && (dailyData?.items.length || weeklyData?.items.length) && ( +
+ {/* 일간 인기글 */} + {dailyData?.items && dailyData.items.length > 0 && ( +
+
+

🔥 일간 인기글

+
+
    + {dailyData.items.map((p) => ( +
  • +
    +
    +
    router.push(`/posts/${p.id}`)} + onKeyDown={(e) => { if (e.key === "Enter") router.push(`/posts/${p.id}`); }} + > + + {stripHtml(p.title)} + + {(p.stat?.commentsCount ?? 0) > 0 && ( + [{p.stat?.commentsCount}] + )} +
    +
    +
    + {p.viewCount ?? 0} +
    +
    +
  • + ))} +
+
+ )} + + {/* 주간 인기글 */} + {weeklyData?.items && weeklyData.items.length > 0 && ( +
+
+

⭐ 주간 인기글

+
+
    + {weeklyData.items.map((p) => ( +
  • +
    +
    +
    router.push(`/posts/${p.id}`)} + onKeyDown={(e) => { if (e.key === "Enter") router.push(`/posts/${p.id}`); }} + > + + {stripHtml(p.title)} + + {(p.stat?.commentsCount ?? 0) > 0 && ( + [{p.stat?.commentsCount}] + )} +
    +
    +
    + {p.viewCount ?? 0} +
    +
    +
  • + ))} +
+
+ )} +
+ )} + {/* 정렬 스위치 (board 변형에서는 상단 툴바를 숨김) */} {variant !== "board" && (
@@ -209,7 +293,7 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s {/* 리스트 테이블 헤더 (board 변형에서는 숨김) */} {variant !== "board" && ( -
+
제목
작성자
@@ -227,10 +311,10 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s {/* 아이템들 */}
-
    +
      {items.map((p) => ( -
    • -
      +
    • +
      {/* bullet/공지 아이콘 자리 */}
      diff --git a/src/lib/rbac.ts b/src/lib/rbac.ts index 32ee010..74e943d 100644 --- a/src/lib/rbac.ts +++ b/src/lib/rbac.ts @@ -9,12 +9,23 @@ export async function checkPermission(options: { const { userId, resource, action } = options; if (!userId) return false; - const userRoles = await prisma.userRole.findMany({ + const user = await prisma.user.findUnique({ where: { userId }, - select: { roleId: true }, + select: { + authLevel: true, + userRoles: { select: { roleId: true } }, + }, }); - if (userRoles.length === 0) return false; - const roleIds = userRoles.map((r) => r.roleId); + if (!user) return false; + + // 사용자 레코드가 ADMIN 권한이면 모든 리소스/액션 허용 + if (user.authLevel === "ADMIN") { + return true; + } + + const roleIds = user.userRoles.map((r) => r.roleId); + if (roleIds.length === 0) return false; + const has = await prisma.rolePermission.findFirst({ where: { roleId: { in: roleIds }, @@ -25,6 +36,7 @@ export async function checkPermission(options: { select: { id: true }, }); if (has) return true; + // ADMIN.ADMINISTER 이면 모든 리소스/액션 허용 const isAdmin = await prisma.rolePermission.findFirst({ where: {