-
Notifications
You must be signed in to change notification settings - Fork 2
feat: OOO에서 작성한 글 기능 구현 #2138
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: develop
Are you sure you want to change the base?
feat: OOO에서 작성한 글 기능 구현 #2138
Changes from all commits
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,127 @@ | ||
| package in.koreatech.koin.domain.community.article.dto; | ||
|
|
||
| import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; | ||
| import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.time.LocalDateTime; | ||
| import java.util.List; | ||
|
|
||
| import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; | ||
| import com.fasterxml.jackson.databind.annotation.JsonNaming; | ||
|
|
||
| import in.koreatech.koin.domain.community.article.model.Article; | ||
| import in.koreatech.koin.domain.community.article.model.LostItemArticle; | ||
| import in.koreatech.koin.domain.community.article.model.LostItemImage; | ||
| import in.koreatech.koin.domain.organization.model.Organization; | ||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
|
|
||
| @JsonNaming(value = SnakeCaseStrategy.class) | ||
| public record LostItemArticleResponseV2( | ||
| @Schema(description = "게시글 id", example = "17368", requiredMode = REQUIRED) | ||
| Integer id, | ||
|
|
||
| @Schema(description = "게시판 id", example = "14", requiredMode = REQUIRED) | ||
| Integer boardId, | ||
|
|
||
| @Schema(description = "게시글 타입", example = "LOST", requiredMode = NOT_REQUIRED) | ||
| String type, | ||
|
|
||
| @Schema(description = "분실물 종류", example = "신분증", requiredMode = REQUIRED) | ||
| String category, | ||
|
|
||
| @Schema(description = "습득 장소", example = "학생회관 앞", requiredMode = REQUIRED) | ||
| String foundPlace, | ||
|
|
||
| @Schema(description = "습득 날짜", example = "2025-01-01", requiredMode = REQUIRED) | ||
| LocalDate foundDate, | ||
|
|
||
| @Schema(description = "본문", example = "학생회관 앞 계단에 …") | ||
| String content, | ||
|
|
||
| @Schema(description = "작성자", example = "총학생회", requiredMode = REQUIRED) | ||
| String author, | ||
|
|
||
| @Schema(description = "단체 정보 (일반 유저인 경우 null)", requiredMode = NOT_REQUIRED) | ||
| InnerOrganizationResponse organization, | ||
|
|
||
| @Schema(description = "내 게시글 여부", example = "true", requiredMode = NOT_REQUIRED) | ||
| Boolean isMine, | ||
|
|
||
| @Schema(description = "분실물 게시글 찾음 상태 여부", example = "false", requiredMode = REQUIRED) | ||
| Boolean isFound, | ||
|
|
||
| @Schema(description = "분실물 사진") | ||
| List<InnerLostItemImageResponse> images, | ||
|
|
||
| @Schema(description = "이전글 id", example = "17367") | ||
| Integer prevId, | ||
|
|
||
| @Schema(description = "다음글 id", example = "17369") | ||
| Integer nextId, | ||
|
|
||
| @Schema(description = "등록일", example = "2025-01-10", requiredMode = REQUIRED) | ||
| LocalDate registeredAt, | ||
|
|
||
| @Schema(description = "수정일", example = "2025-01-10 16:53:22", requiredMode = REQUIRED) | ||
| LocalDateTime updatedAt | ||
| ) { | ||
|
|
||
| public static LostItemArticleResponseV2 of(Article article, Boolean isMine, Organization organization) { | ||
| LostItemArticle lostItemArticle = article.getLostItemArticle(); | ||
|
|
||
| return new LostItemArticleResponseV2( | ||
| article.getId(), | ||
| article.getBoard().getId(), | ||
| lostItemArticle.getType(), | ||
| lostItemArticle.getCategory(), | ||
| lostItemArticle.getFoundPlace(), | ||
| lostItemArticle.getFoundDate(), | ||
| article.getContent(), | ||
| article.getAuthor(), | ||
| organization != null ? InnerOrganizationResponse.from(organization) : null, | ||
| isMine, | ||
| lostItemArticle.getIsFound(), | ||
| lostItemArticle.getImages().stream() | ||
| .filter(image -> !image.getIsDeleted()) | ||
| .map(InnerLostItemImageResponse::from) | ||
| .toList(), | ||
| article.getPrevId(), | ||
| article.getNextId(), | ||
| article.getRegisteredAt(), | ||
| article.getUpdatedAt() | ||
| ); | ||
| } | ||
|
|
||
| @JsonNaming(value = SnakeCaseStrategy.class) | ||
| public record InnerOrganizationResponse( | ||
| @Schema(description = "단체명", example = "총학생회", requiredMode = REQUIRED) | ||
| String name, | ||
|
|
||
| @Schema(description = "방문 장소", example = "학생회관 320호 총학생회 사무실로 방문", requiredMode = REQUIRED) | ||
| String location | ||
| ) { | ||
| public static InnerOrganizationResponse from(Organization organization) { | ||
| return new InnerOrganizationResponse( | ||
| organization.getName(), | ||
| organization.getLocation() | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| @JsonNaming(value = SnakeCaseStrategy.class) | ||
| public record InnerLostItemImageResponse( | ||
| @Schema(description = "분실물 이미지 id", example = "1", requiredMode = REQUIRED) | ||
| Integer id, | ||
|
|
||
| @Schema(description = "이미지 Url", example = "https://api.koreatech.in/image.jpg", requiredMode = REQUIRED) | ||
| String imageUrl | ||
| ) { | ||
| public static InnerLostItemImageResponse from(LostItemImage lostItemImage) { | ||
| return new InnerLostItemImageResponse( | ||
| lostItemImage.getId(), | ||
| lostItemImage.getImageUrl() | ||
| ); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ | |
| import in.koreatech.koin.common.event.ArticleKeywordEvent; | ||
| import in.koreatech.koin.common.model.Criteria; | ||
| import in.koreatech.koin.domain.community.article.dto.LostItemArticleResponse; | ||
| import in.koreatech.koin.domain.community.article.dto.LostItemArticleResponseV2; | ||
| import in.koreatech.koin.domain.community.article.dto.LostItemArticleStatisticsResponse; | ||
| import in.koreatech.koin.domain.community.article.dto.LostItemArticleUpdateRequest; | ||
| import in.koreatech.koin.domain.community.article.dto.LostItemArticlesRequest; | ||
|
|
@@ -33,6 +34,8 @@ | |
| import in.koreatech.koin.domain.community.article.repository.BoardRepository; | ||
| import in.koreatech.koin.domain.community.article.repository.LostItemArticleRepository; | ||
| import in.koreatech.koin.domain.community.util.KeywordExtractor; | ||
| import in.koreatech.koin.domain.organization.model.Organization; | ||
| import in.koreatech.koin.domain.organization.repository.OrganizationRepository; | ||
| import in.koreatech.koin.domain.user.model.User; | ||
| import in.koreatech.koin.domain.user.repository.UserRepository; | ||
| import in.koreatech.koin.global.auth.exception.AuthorizationException; | ||
|
|
@@ -58,6 +61,7 @@ public class LostItemArticleService { | |
| private final LostItemArticleRepository lostItemArticleRepository; | ||
| private final BoardRepository boardRepository; | ||
| private final UserRepository userRepository; | ||
| private final OrganizationRepository organizationRepository; | ||
| private final PopularKeywordTracker popularKeywordTracker; | ||
| private final ApplicationEventPublisher eventPublisher; | ||
| private final KeywordExtractor keywordExtractor; | ||
|
|
@@ -141,6 +145,25 @@ public LostItemArticleResponse getLostItemArticle(Integer articleId, Integer use | |
| return LostItemArticleResponse.of(article, isMine); | ||
| } | ||
|
|
||
| public LostItemArticleResponseV2 getLostItemArticleV2(Integer articleId, Integer userId) { | ||
| Article article = articleRepository.getById(articleId); | ||
| setPrevNextArticle(LOST_ITEM_BOARD_ID, article); | ||
|
|
||
| LostItemArticle lostItemArticle = article.getLostItemArticle(); | ||
| User author = lostItemArticle.getAuthor(); | ||
|
|
||
| boolean isMine = author != null && Objects.equals(author.getId(), userId); | ||
|
|
||
| Organization organization = null; | ||
| if (author != null) { | ||
| organization = organizationRepository | ||
| .findByUserIdAndIsDeletedFalse(author.getId()) | ||
| .orElse(null); | ||
| } | ||
|
|
||
| return LostItemArticleResponseV2.of(article, isMine, organization); | ||
| } | ||
|
Comment on lines
+148
to
+165
|
||
|
|
||
| @Transactional | ||
| public LostItemArticleResponse createLostItemArticle(Integer userId, LostItemArticlesRequest requests) { | ||
| Board lostItemBoard = boardRepository.getById(LOST_ITEM_BOARD_ID); | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,40 @@ | ||||||||||||||||||||||||||||||
| package in.koreatech.koin.domain.organization.model; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| import in.koreatech.koin.common.model.BaseEntity; | ||||||||||||||||||||||||||||||
| import jakarta.persistence.Column; | ||||||||||||||||||||||||||||||
| import jakarta.persistence.Entity; | ||||||||||||||||||||||||||||||
| import jakarta.persistence.GeneratedValue; | ||||||||||||||||||||||||||||||
| import jakarta.persistence.GenerationType; | ||||||||||||||||||||||||||||||
| import jakarta.persistence.Id; | ||||||||||||||||||||||||||||||
| import jakarta.persistence.Table; | ||||||||||||||||||||||||||||||
| import jakarta.validation.constraints.NotNull; | ||||||||||||||||||||||||||||||
| import lombok.AccessLevel; | ||||||||||||||||||||||||||||||
| import lombok.Getter; | ||||||||||||||||||||||||||||||
| import lombok.NoArgsConstructor; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| @Getter | ||||||||||||||||||||||||||||||
| @Entity | ||||||||||||||||||||||||||||||
| @Table(name = "organizations") | ||||||||||||||||||||||||||||||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||||||||||||||||||||||||||||||
|
Comment on lines
+13
to
+18
|
||||||||||||||||||||||||||||||
| import lombok.NoArgsConstructor; | |
| @Getter | |
| @Entity | |
| @Table(name = "organizations") | |
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | |
| import lombok.NoArgsConstructor; | |
| import org.hibernate.annotations.Where; | |
| @Getter | |
| @Entity | |
| @Table(name = "organizations") | |
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | |
| @Where(clause = "is_deleted=0") |
Copilot
AI
Jan 19, 2026
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.
The is_deleted column definition in the SQL migration uses TINYINT(1) which is the standard for boolean columns in this codebase. However, the corresponding entity field uses Boolean type without the columnDefinition attribute. For consistency with other entities in the codebase (like KoinNotice, Club, ClubQna), add a columnDefinition attribute to ensure proper mapping: @Column(name = "is_deleted", nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
| @Column(name = "is_deleted", nullable = false) | |
| @Column(name = "is_deleted", nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0") |
Copilot
AI
Jan 19, 2026
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.
For consistency with other entities in the codebase that use boolean fields (such as KoinNotice, Club), consider using the static import import static java.lang.Boolean.FALSE; and initializing the field as private Boolean isDeleted = FALSE; instead of false. This follows the established pattern in the project.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package in.koreatech.koin.domain.organization.repository; | ||
|
|
||
| import java.util.Optional; | ||
|
|
||
| import org.springframework.data.repository.Repository; | ||
|
|
||
| import in.koreatech.koin.domain.organization.model.Organization; | ||
|
|
||
| public interface OrganizationRepository extends Repository<Organization, Integer> { | ||
|
|
||
| Optional<Organization> findByUserIdAndIsDeletedFalse(Integer userId); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,16 @@ | ||||||||
| -- organizations 테이블 생성 (단체 정보 관리) | ||||||||
| CREATE TABLE `organizations` ( | ||||||||
|
||||||||
| CREATE TABLE `organizations` ( | |
| CREATE TABLE IF NOT EXISTS `koin`.`organizations` ( |
Copilot
AI
Jan 19, 2026
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.
The id column uses INT instead of INT UNSIGNED which is the standard in recent migrations (V140+). For consistency with the codebase and to allow for a larger range of positive IDs, use INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '고유 ID'
| `id` INT NOT NULL AUTO_INCREMENT, | |
| `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '고유 ID', |
Copilot
AI
Jan 19, 2026
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.
The user_id column uses INT instead of INT UNSIGNED. Since this column references the users table's id column (which is typically INT UNSIGNED in this codebase), it should match that type for proper foreign key compatibility. Use INT UNSIGNED NOT NULL COMMENT '단체 계정 user_id'
| `user_id` INT NOT NULL COMMENT '단체 계정 user_id', | |
| `user_id` INT UNSIGNED NOT NULL COMMENT '단체 계정 user_id', |
Copilot
AI
Jan 19, 2026
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.
The created_at and updated_at columns should be removed from this migration. Since the Organization entity extends BaseEntity, these timestamp fields are automatically managed by JPA's auditing mechanism. Including them in the SQL migration will cause conflicts with JPA's entity mappings and create duplicate column definitions.
| `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
Copilot
AI
Jan 19, 2026
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.
The user_id column references a user but does not include a FOREIGN KEY constraint. Based on the codebase patterns (e.g., V100, V152, V204), columns that reference other tables should include explicit foreign key constraints. Consider adding: CONSTRAINT fk_organizations_user_id FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE or ON DELETE SET NULL depending on the desired behavior when a user is deleted.
| UNIQUE KEY `uk_organizations_user_id` (`user_id`) | |
| UNIQUE KEY `uk_organizations_user_id` (`user_id`), | |
| CONSTRAINT `fk_organizations_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE |
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.
This method overrides LostItemArticleApi.getLostItemArticleV2; it is advisable to add an Override annotation.