Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.springframework.web.bind.annotation.RequestParam;

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;
Expand Down Expand Up @@ -110,6 +111,24 @@ ResponseEntity<LostItemArticleResponse> getLostItemArticle(
@UserId Integer userId
);

@ApiResponses(
value = {
@ApiResponse(responseCode = "200"),
@ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))),
}
)
@Operation(summary = "분실물 게시글 단건 조회 V2", description = """
### V2 변경점
- `is_council` 필드 제거
- `organization` 객체 추가 (단체 정보)
- 일반 유저 게시글인 경우 `organization: null`
""")
@GetMapping("/lost-item/v2/{id}")
ResponseEntity<LostItemArticleResponseV2> getLostItemArticleV2(
@Parameter(in = PATH) @PathVariable("id") Integer articleId,
@UserId Integer userId
);

@ApiResponses(
value = {
@ApiResponse(responseCode = "201"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.springframework.web.bind.annotation.RestController;

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;
Expand Down Expand Up @@ -87,6 +88,14 @@ public ResponseEntity<LostItemArticleResponse> getLostItemArticle(
return ResponseEntity.ok().body(lostItemArticleService.getLostItemArticle(articleId, userId));
}

@GetMapping("/lost-item/v2/{id}")
Copy link

Copilot AI Jan 19, 2026

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.

Suggested change
@GetMapping("/lost-item/v2/{id}")
@GetMapping("/lost-item/v2/{id}")
@Override

Copilot uses AI. Check for mistakes.
public ResponseEntity<LostItemArticleResponseV2> getLostItemArticleV2(
@PathVariable("id") Integer articleId,
@UserId Integer userId
) {
return ResponseEntity.ok().body(lostItemArticleService.getLostItemArticleV2(articleId, userId));
}

@PostMapping("/lost-item")
public ResponseEntity<LostItemArticleResponse> createLostItemArticle(
@Auth(permit = {GENERAL, STUDENT, COUNCIL}) Integer studentId,
Expand Down
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
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new getLostItemArticleV2 method lacks test coverage. Given that the codebase has test infrastructure in place (as evidenced by LostItemChatRoomInfoServiceTest and fixtures), unit tests should be added to verify the organization lookup behavior, including scenarios where:

  1. An article is authored by a user with an associated organization
  2. An article is authored by a user without an organization (null case)
  3. An article is authored by a deleted/null user

Copilot uses AI. Check for mistakes.

@Transactional
public LostItemArticleResponse createLostItemArticle(Integer userId, LostItemArticlesRequest requests) {
Board lostItemBoard = boardRepository.getById(LOST_ITEM_BOARD_ID);
Expand Down
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
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Organization entity has an is_deleted field but is missing the @Where(clause = "is_deleted=0") annotation that is consistently used across the codebase for soft-delete functionality. This annotation ensures that deleted entities are automatically filtered out from queries by default. For consistency with other entities (like Article, KoinNotice, User, etc.), add this annotation to the class level.

Suggested change
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 uses AI. Check for mistakes.
public class Organization extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;

@NotNull
@Column(name = "user_id", nullable = false, unique = true)
private Integer userId;

@NotNull
@Column(name = "name", nullable = false, length = 100)
private String name;

@NotNull
@Column(name = "location", nullable = false, length = 255)
private String location;

@NotNull
@Column(name = "is_deleted", nullable = false)
Copy link

Copilot AI Jan 19, 2026

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")

Suggested change
@Column(name = "is_deleted", nullable = false)
@Column(name = "is_deleted", nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")

Copilot uses AI. Check for mistakes.
private Boolean isDeleted = false;
Copy link

Copilot AI Jan 19, 2026

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.

Copilot uses AI. Check for mistakes.
}
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);
}
16 changes: 16 additions & 0 deletions src/main/resources/db/migration/V228__add_organizations_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- organizations 테이블 생성 (단체 정보 관리)
CREATE TABLE `organizations` (
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The table creation should include the schema prefix for consistency with other recent migrations in the codebase. Recent migrations (V194, V222, V226) consistently use CREATE TABLE IF NOT EXISTS \koin`.`table_name`pattern. Update the statement to:CREATE TABLE IF NOT EXISTS `koin`.`organizations``

Suggested change
CREATE TABLE `organizations` (
CREATE TABLE IF NOT EXISTS `koin`.`organizations` (

Copilot uses AI. Check for mistakes.
`id` INT NOT NULL AUTO_INCREMENT,
Copy link

Copilot AI Jan 19, 2026

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'

Suggested change
`id` INT NOT NULL AUTO_INCREMENT,
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '고유 ID',

Copilot uses AI. Check for mistakes.
`user_id` INT NOT NULL COMMENT '단체 계정 user_id',
Copy link

Copilot AI Jan 19, 2026

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'

Suggested change
`user_id` INT NOT NULL COMMENT '단체 계정 user_id',
`user_id` INT UNSIGNED NOT NULL COMMENT '단체 계정 user_id',

Copilot uses AI. Check for mistakes.
`name` VARCHAR(100) NOT NULL COMMENT '단체명 (예: 총학생회, 컴퓨터공학부)',
`location` VARCHAR(255) NOT NULL COMMENT '방문 장소 (예: 학생회관 320호 총학생회 사무실)',
`is_deleted` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
Comment on lines +8 to +9
Copy link

Copilot AI Jan 19, 2026

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.

Suggested change
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

Copilot uses AI. Check for mistakes.
PRIMARY KEY (`id`),
UNIQUE KEY `uk_organizations_user_id` (`user_id`)
Copy link

Copilot AI Jan 19, 2026

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='단체 정보';

-- 초기 데이터 (총학생회) - user_id는 실제 총학생회 계정 ID로 변경 필요
-- INSERT INTO `organizations` (`user_id`, `name`, `location`)
-- VALUES ({총학생회_user_id}, '총학생회', '학생회관 320호 총학생회 사무실로 방문');
Loading