-
Notifications
You must be signed in to change notification settings - Fork 1
Feat/8/story detail #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e279381
aced3e7
fdfff91
ceb8917
1cfd92f
dda1a61
67bbd4a
904cd66
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| package com.likelion.monday.domain.story.dto; | ||
|
|
||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
| import java.time.LocalDateTime; | ||
| import java.util.List; | ||
|
|
||
| @Schema(description = "사연 상세 조회 응답") | ||
| public record StoryDetailResDto( | ||
|
|
||
| @Schema(description = "사연 ID", example = "1") | ||
| Long storyId, | ||
|
|
||
| @Schema(description = "반려동물 이름", example = "머고") | ||
| String petName, | ||
|
|
||
| @Schema(description = "반려동물 종류", example = "고양이") | ||
| String petType, | ||
|
|
||
| @Schema(description = "반려동물 나이", example = "3") | ||
| Integer petAge, | ||
|
|
||
| @Schema(description = "사연 제목", example = "우리 집 귀염둥이에게") | ||
| String title, | ||
|
|
||
| @Schema(description = "사연 본문") | ||
| String content, | ||
|
|
||
| @Schema(description = "작성자 닉네임", example = "매기") | ||
| String nickname, | ||
|
|
||
| @Schema(description = "노출 순서대로 정렬된 사진 URL 목록") | ||
| List<String> imageUrls, | ||
|
|
||
| @Schema(description = "작성 일시") | ||
| LocalDateTime createdAt, | ||
|
|
||
| @Schema(description = "조회수", example = "128") | ||
| int viewCount, | ||
|
|
||
| @Schema(description = "공감수", example = "12") | ||
| int likeCount | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| package com.likelion.monday.domain.story.entity; | ||
|
|
||
| import com.likelion.monday.global.entity.BaseEntity; | ||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.FetchType; | ||
| import jakarta.persistence.GeneratedValue; | ||
| import jakarta.persistence.GenerationType; | ||
| import jakarta.persistence.Id; | ||
| import jakarta.persistence.JoinColumn; | ||
| import jakarta.persistence.ManyToOne; | ||
| import jakarta.persistence.Table; | ||
| import jakarta.persistence.UniqueConstraint; | ||
| import lombok.AccessLevel; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| /** | ||
| * 비로그인 사용자의 중복 조회 방지를 위해 accountId(로그인 시)와 guestKey(비로그인 시) 중 하나로 식별한다. | ||
| * 같은 사연을 같은 계정/게스트가 다시 조회해도 조회수가 중복으로 올라가지 않도록 유니크 제약을 건다. | ||
| * accountId/guestKey 중 정확히 하나만 채워지도록 하는 검증은 상세조회 API를 만들 때 서비스 레이어에서 처리한다. | ||
| */ | ||
| @Entity | ||
| @Table( | ||
| name = "story_view", | ||
| uniqueConstraints = { | ||
| @UniqueConstraint(name = "uk_story_view_account", columnNames = {"story_id", "account_id"}), | ||
| @UniqueConstraint(name = "uk_story_view_guest", columnNames = {"story_id", "guest_key"}) | ||
| } | ||
| ) | ||
| @Getter | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| public class StoryView extends BaseEntity { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| private Long id; | ||
|
|
||
| @ManyToOne(fetch = FetchType.LAZY) | ||
| @JoinColumn(name = "story_id", nullable = false) | ||
| private Story story; | ||
|
|
||
| @Column(name = "account_id") | ||
| private Long accountId; | ||
|
|
||
| @Column(name = "guest_key", length = 100) | ||
| private String guestKey; | ||
|
|
||
| @Builder | ||
| private StoryView(Story story, Long accountId, String guestKey) { | ||
| this.story = story; | ||
| this.accountId = accountId; | ||
| this.guestKey = guestKey; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.likelion.monday.domain.story.repository; | ||
|
|
||
| import com.likelion.monday.domain.story.entity.StoryView; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| public interface StoryViewRepository extends JpaRepository<StoryView, Long> { | ||
|
|
||
| boolean existsByStory_IdAndAccountId(Long storyId, Long accountId); | ||
|
|
||
| boolean existsByStory_IdAndGuestKey(Long storyId, String guestKey); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| package com.likelion.monday.global.cookie; | ||
|
|
||
| import jakarta.servlet.http.Cookie; | ||
| import jakarta.servlet.http.HttpServletRequest; | ||
| import jakarta.servlet.http.HttpServletResponse; | ||
| import java.util.UUID; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| /** | ||
| * 비로그인 사용자를 식별하기 위한 guest_key를 쿠키로 관리한다. | ||
| * 요청에 쿠키가 있으면 그 값을 그대로 쓰고, 없으면 새로 발급해서 응답에 심어준다. | ||
| * 조회/공감 등 중복 방지가 필요한 곳에서 공통으로 사용한다. | ||
| * | ||
| * TODO: 배포 도메인이 FE와 분리되는 경우 SameSite=None; Secure 설정이 필요할 수 있음 (CORS/withCredentials 논의와 함께 확인) | ||
| */ | ||
| @Component | ||
| public class GuestKeyProvider { | ||
|
|
||
| private static final String COOKIE_NAME = "guest_key"; | ||
| private static final int COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365; | ||
|
|
||
| public String resolve(HttpServletRequest request, HttpServletResponse response) { | ||
| String existing = findExisting(request); | ||
| if (existing != null) { | ||
| return existing; | ||
| } | ||
|
|
||
| String newGuestKey = UUID.randomUUID().toString(); | ||
| response.addCookie(createCookie(newGuestKey)); | ||
| return newGuestKey; | ||
| } | ||
|
|
||
| private String findExisting(HttpServletRequest request) { | ||
| Cookie[] cookies = request.getCookies(); | ||
| if (cookies == null) { | ||
| return null; | ||
| } | ||
|
|
||
| for (Cookie cookie : cookies) { | ||
| if (COOKIE_NAME.equals(cookie.getName())) { | ||
| return cookie.getValue(); | ||
| } | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| private Cookie createCookie(String guestKey) { | ||
| Cookie cookie = new Cookie(COOKIE_NAME, guestKey); | ||
| cookie.setHttpOnly(true); | ||
| cookie.setPath("/"); | ||
| cookie.setMaxAge(COOKIE_MAX_AGE_SECONDS); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: printf '%s\n' '--- GuestKeyProvider.java ---'
cat -n src/main/java/com/likelion/monday/global/cookie/GuestKeyProvider.java
printf '%s\n' '--- relevant transport/security configuration references ---'
rg -n --glob '!build/**' --glob '!target/**' '(Secure|SameSite|HSTS|Strict-Transport-Security|server\.ssl|forward-headers|redirect|https)' src config . 2>/dev/null | head -200Repository: 2026-Monday-Project/Backend Length of output: 5439 Sensitive Data Exposure (CWE-614): Sensitive Cookie in HTTPS Session Without 'Secure' Attribute Reachability: External · Exploitability: Moderate 운영 환경에서는
🤖 Prompt for AI AgentsSource: Linters/SAST tools 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -euo pipefail
fd -t f . | rg '(application.*\.(yml|yaml|properties)$|Cors|Security|Cookie)' | while IFS= read -r file; do
rg -n -C 3 'guest_key|SameSite|setSecure|allowCredentials|allowedOrigins|CorsConfiguration' "$file" || true
doneRepository: 2026-Monday-Project/Backend Length of output: 165 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- target file ---'
cat -n src/main/java/com/likelion/monday/global/cookie/GuestKeyProvider.java
printf '%s\n' '--- directly related symbols ---'
rg -n -C 4 'GuestKeyProvider|guest_key|setHttpOnly|setMaxAge|setSecure|SameSite|allowCredentials|allowedOrigins|CorsConfiguration|cors' src/main/java src/test || true
printf '%s\n' '--- configuration files ---'
fd -t f -i 'application.*' . | while IFS= read -r file; do
printf '\n--- %s ---\n' "$file"
cat -n "$file"
doneRepository: 2026-Monday-Project/Backend Length of output: 13175 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- StoryController relevant methods ---'
cat -n src/main/java/com/likelion/monday/domain/story/controller/StoryController.java | sed -n '30,110p'
printf '%s\n' '--- view-recording call chain ---'
rg -n -C 5 'getStory|view|GuestKeyProvider|guestKey|StoryView' \
src/main/java/com/likelion/monday/domain/story/controller \
src/main/java/com/likelion/monday/domain/story/service \
src/main/java/com/likelion/monday/domain/story/repository || true
printf '%s\n' '--- cookie/API declarations and build versions ---'
rg -n -C 3 'jakarta.servlet|spring-boot|servlet|Cookie|SameSite|CORS|Cors' \
build.gradle* settings.gradle* pom.xml gradle.properties src/main/java src/main/resources 2>/dev/null || trueRepository: 2026-Monday-Project/Backend Length of output: 31223 🌐 Web query:
💡 Result: In modern web development (as of August 2026), the SameSite attribute is a critical mechanism for controlling cookie behavior in cross-site contexts [1][2]. Default Behavior If the SameSite attribute is not explicitly specified, browsers typically default to SameSite=Lax [1][3]. Under this default, the browser only sends cookies for same-site requests and top-level navigations (e.g., clicking a link) [4][2]. Crucially, this default restriction prevents cookies from being sent in cross-site subresource requests, such as those made via the Fetch API or XMLHttpRequest [4][2]. Cross-Site Requests and Credentials When making cross-site requests using the Fetch API or XMLHttpRequest, the browser will ignore Set-Cookie headers in the response and withhold credentials (including cookies) unless specific requirements are met [1]. To enable cookies in cross-site contexts: 1. Set the cookie with SameSite=None; Secure [1][2][5]. The Secure attribute is mandatory when SameSite=None is used; without it, the browser will reject the cookie [1][5][6]. 2. For fetch requests, you must explicitly set the credentials option to 'include' or 'same-origin' (depending on your requirements) [7][8]. 3. For cross-site requests, the server must also include the appropriate CORS headers, specifically Access-Control-Allow-Credentials: true, to allow the browser to process the credentials [8]. Best Practices Because browser defaults and privacy protections (such as cookie partitioning and intelligent tracking prevention) can vary across implementations, it is strongly recommended to explicitly set the SameSite attribute on every cookie to ensure consistent behavior [3][6]. Relying on default browser behavior can lead to unpredictable results across different user agents [3][6]. Citations:
교차 사이트 배포이면 FE와 API가 서로 다른 schemeful site이면 🤖 Prompt for AI Agents |
||
| return cookie; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
🔎 Supported by static analysis
🏁 Script executed:
Repository: 2026-Monday-Project/Backend
Length of output: 6854
🏁 Script executed:
Repository: 2026-Monday-Project/Backend
Length of output: 11934
🏁 Script executed:
Repository: 2026-Monday-Project/Backend
Length of output: 1273
🏁 Script executed:
Repository: 2026-Monday-Project/Backend
Length of output: 3578
조회수 증가를 DB 원자 연산으로 변경하세요.
StoryService.recordView()는 조회 기록을 확인한 뒤StoryViewRepository.save()와Story.increaseViewCount()를 수행합니다.Story와BaseEntity에는@Version이 없습니다. 따라서 서로 다른guestKey의 동시 요청이 같은viewCount를 읽고 같은 증가 결과를 저장하면, 한 조회수 증가가 유실될 수 있습니다.exists확인, 조건부 삽입,view_count = view_count + 1갱신을 원자적 DB 연산으로 처리하거나Story행 잠금으로 직렬화하세요.StoryView.id가GenerationType.IDENTITY이므로 유니크 제약 예외가 항상 커밋 시점까지 지연된다고 단정할 수는 없습니다.🤖 Prompt for AI Agents