feat: 업무메모 백엔드 API 추가
기관관리 상세의 업무메모 탭이 쓸 CRUD 엔드포인트를 추가한다. 통화 등
담당자와의 연락 내용을 기관별로 남기는 단순 기능이라 Mattermost 연동
없이 순수 DB로만 처리한다.
- V2__work_memo.sql: work_memo 테이블, org_id+created_at 인덱스
- WorkMemoMapper/xml: 최신순 조회, 등록, (orgId, id) 동시 조건 삭제
- WorkMemoService/Controller: GET/POST /api/orgs/{id}/memos,
DELETE /api/orgs/{id}/memos/{memoId}
- GlobalExceptionHandler: contactName/body 검증 메시지 한글 라벨 추가
- WorkMemoControllerTest: 등록/조회/최신순/검증/404/삭제/인증 검증
Co-Authored-By: Claude Opus 4.8 (1M context)
@9d5783e6511c1a25beb2ba195ef991bd4f3fedc7
--- src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
+++ src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
... | ... | @@ -49,7 +49,9 @@ |
| 49 | 49 |
"managerName", "담당자명", |
| 50 | 50 |
"managerTitle", "직급/직함", |
| 51 | 51 |
"managerPhone", "연락처", |
| 52 |
- "managerEmail", "이메일"); |
|
| 52 |
+ "managerEmail", "이메일", |
|
| 53 |
+ "contactName", "담당자", |
|
| 54 |
+ "body", "메모 내용"); |
|
| 53 | 55 |
|
| 54 | 56 |
@ExceptionHandler(OrgNotFoundException.class) |
| 55 | 57 |
public ResponseEntity<ApiError> handleNotFound(OrgNotFoundException e) {
|
+++ src/main/java/kr/itn/itnhub/memo/MemoRequest.java
... | ... | @@ -0,0 +1,9 @@ |
| 1 | +package kr.itn.itnhub.memo; | |
| 2 | + | |
| 3 | +import jakarta.validation.constraints.NotBlank; | |
| 4 | +import jakarta.validation.constraints.Size; | |
| 5 | + | |
| 6 | +public record MemoRequest( | |
| 7 | + @NotBlank @Size(max = 100) String contactName, | |
| 8 | + @NotBlank String body) { | |
| 9 | +} |
+++ src/main/java/kr/itn/itnhub/memo/WorkMemo.java
... | ... | @@ -0,0 +1,25 @@ |
| 1 | +package kr.itn.itnhub.memo; | |
| 2 | + | |
| 3 | +public class WorkMemo { | |
| 4 | + | |
| 5 | + private Long id; | |
| 6 | + private Long orgId; | |
| 7 | + private String contactName; | |
| 8 | + private String body; | |
| 9 | + private long createdAt; | |
| 10 | + | |
| 11 | + public Long getId() { return id; } | |
| 12 | + public void setId(Long id) { this.id = id; } | |
| 13 | + | |
| 14 | + public Long getOrgId() { return orgId; } | |
| 15 | + public void setOrgId(Long orgId) { this.orgId = orgId; } | |
| 16 | + | |
| 17 | + public String getContactName() { return contactName; } | |
| 18 | + public void setContactName(String contactName) { this.contactName = contactName; } | |
| 19 | + | |
| 20 | + public String getBody() { return body; } | |
| 21 | + public void setBody(String body) { this.body = body; } | |
| 22 | + | |
| 23 | + public long getCreatedAt() { return createdAt; } | |
| 24 | + public void setCreatedAt(long createdAt) { this.createdAt = createdAt; } | |
| 25 | +} |
+++ src/main/java/kr/itn/itnhub/memo/WorkMemoController.java
... | ... | @@ -0,0 +1,40 @@ |
| 1 | +package kr.itn.itnhub.memo; | |
| 2 | + | |
| 3 | +import jakarta.validation.Valid; | |
| 4 | +import org.springframework.http.HttpStatus; | |
| 5 | +import org.springframework.web.bind.annotation.DeleteMapping; | |
| 6 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 7 | +import org.springframework.web.bind.annotation.PathVariable; | |
| 8 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 9 | +import org.springframework.web.bind.annotation.RequestBody; | |
| 10 | +import org.springframework.web.bind.annotation.ResponseStatus; | |
| 11 | +import org.springframework.web.bind.annotation.RestController; | |
| 12 | + | |
| 13 | +import java.util.List; | |
| 14 | + | |
| 15 | +/** 기관관리 상세의 업무메모 탭이 부르는 엔드포인트. */ | |
| 16 | +@RestController | |
| 17 | +public class WorkMemoController { | |
| 18 | + | |
| 19 | + private final WorkMemoService memoService; | |
| 20 | + | |
| 21 | + public WorkMemoController(WorkMemoService memoService) { | |
| 22 | + this.memoService = memoService; | |
| 23 | + } | |
| 24 | + | |
| 25 | + @GetMapping("/api/orgs/{id}/memos") | |
| 26 | + public List<WorkMemo> list(@PathVariable Long id) { | |
| 27 | + return memoService.list(id); | |
| 28 | + } | |
| 29 | + | |
| 30 | + @PostMapping("/api/orgs/{id}/memos") | |
| 31 | + public WorkMemo create(@PathVariable Long id, @Valid @RequestBody MemoRequest request) { | |
| 32 | + return memoService.create(id, request); | |
| 33 | + } | |
| 34 | + | |
| 35 | + @ResponseStatus(HttpStatus.NO_CONTENT) | |
| 36 | + @DeleteMapping("/api/orgs/{id}/memos/{memoId}") | |
| 37 | + public void delete(@PathVariable Long id, @PathVariable Long memoId) { | |
| 38 | + memoService.delete(id, memoId); | |
| 39 | + } | |
| 40 | +} |
+++ src/main/java/kr/itn/itnhub/memo/WorkMemoMapper.java
... | ... | @@ -0,0 +1,21 @@ |
| 1 | +package kr.itn.itnhub.memo; | |
| 2 | + | |
| 3 | +import org.apache.ibatis.annotations.Mapper; | |
| 4 | +import org.apache.ibatis.annotations.Param; | |
| 5 | + | |
| 6 | +import java.util.List; | |
| 7 | + | |
| 8 | +@Mapper | |
| 9 | +public interface WorkMemoMapper { | |
| 10 | + | |
| 11 | + /** 최신순. */ | |
| 12 | + List<WorkMemo> findByOrg(@Param("orgId") Long orgId); | |
| 13 | + | |
| 14 | + int insert(WorkMemo memo); | |
| 15 | + | |
| 16 | + /** | |
| 17 | + * id와 org_id를 함께 조건으로 걸어, URL의 orgId와 실제 소유 기관이 다른 메모는 | |
| 18 | + * 삭제되지 않게 한다(다른 기관 URL로 삭제 시도하는 실수를 막는다). | |
| 19 | + */ | |
| 20 | + int delete(@Param("orgId") Long orgId, @Param("id") Long id); | |
| 21 | +} |
+++ src/main/java/kr/itn/itnhub/memo/WorkMemoService.java
... | ... | @@ -0,0 +1,59 @@ |
| 1 | +package kr.itn.itnhub.memo; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.org.OrgNotFoundException; | |
| 4 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 5 | +import org.springframework.stereotype.Service; | |
| 6 | +import org.springframework.transaction.annotation.Transactional; | |
| 7 | + | |
| 8 | +import java.util.List; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * 기관관리 상세의 업무메모 탭이 쓰는 서비스. 전화 통화 등 담당자와의 연락 내용을 | |
| 12 | + * 기관별로 남겨두는 단순 CRUD이며, Mattermost와는 무관한 순수 DB 기능이다. | |
| 13 | + */ | |
| 14 | +@Service | |
| 15 | +public class WorkMemoService { | |
| 16 | + | |
| 17 | + private final WorkMemoMapper memoMapper; | |
| 18 | + private final OrganizationMapper orgMapper; | |
| 19 | + | |
| 20 | + public WorkMemoService(WorkMemoMapper memoMapper, OrganizationMapper orgMapper) { | |
| 21 | + this.memoMapper = memoMapper; | |
| 22 | + this.orgMapper = orgMapper; | |
| 23 | + } | |
| 24 | + | |
| 25 | + public List<WorkMemo> list(Long orgId) { | |
| 26 | + requireOrg(orgId); | |
| 27 | + return memoMapper.findByOrg(orgId); | |
| 28 | + } | |
| 29 | + | |
| 30 | + @Transactional | |
| 31 | + public WorkMemo create(Long orgId, MemoRequest request) { | |
| 32 | + requireOrg(orgId); | |
| 33 | + | |
| 34 | + WorkMemo memo = new WorkMemo(); | |
| 35 | + memo.setOrgId(orgId); | |
| 36 | + memo.setContactName(request.contactName()); | |
| 37 | + memo.setBody(request.body()); | |
| 38 | + memoMapper.insert(memo); | |
| 39 | + | |
| 40 | + // 방금 넣은 행이 created_at 기준 가장 최신이므로 목록의 첫 행이 곧 이 메모다. | |
| 41 | + return memoMapper.findByOrg(orgId).get(0); | |
| 42 | + } | |
| 43 | + | |
| 44 | + @Transactional | |
| 45 | + public void delete(Long orgId, Long memoId) { | |
| 46 | + requireOrg(orgId); | |
| 47 | + | |
| 48 | + int deleted = memoMapper.delete(orgId, memoId); | |
| 49 | + if (deleted == 0) { | |
| 50 | + throw new OrgNotFoundException("메모를 찾을 수 없습니다: " + memoId); | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + private void requireOrg(Long orgId) { | |
| 55 | + if (orgMapper.findById(orgId) == null) { | |
| 56 | + throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId); | |
| 57 | + } | |
| 58 | + } | |
| 59 | +} |
+++ src/main/resources/db/migration/V2__work_memo.sql
... | ... | @@ -0,0 +1,9 @@ |
| 1 | +create table work_memo ( | |
| 2 | + id bigserial primary key, | |
| 3 | + org_id bigint not null references organization (id) on delete cascade, | |
| 4 | + contact_name varchar(100) not null, | |
| 5 | + body text not null, | |
| 6 | + created_at timestamptz not null default now() | |
| 7 | +); | |
| 8 | + | |
| 9 | +create index ix_work_memo_org_created on work_memo (org_id, created_at desc); |
+++ src/main/resources/mapper/WorkMemoMapper.xml
... | ... | @@ -0,0 +1,32 @@ |
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" | |
| 3 | + "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> | |
| 4 | +<mapper namespace="kr.itn.itnhub.memo.WorkMemoMapper"> | |
| 5 | + | |
| 6 | + <!-- | |
| 7 | + created_at을 밀리초 epoch로 내려준다 - 게시물 탭(PostView.createAt)과 동일한 | |
| 8 | + 관례를 맞추기 위함이다. | |
| 9 | + --> | |
| 10 | + <sql id="columns"> | |
| 11 | + id, org_id, contact_name, body, | |
| 12 | + (extract(epoch from created_at) * 1000)::bigint as created_at | |
| 13 | + </sql> | |
| 14 | + | |
| 15 | + <select id="findByOrg" resultType="kr.itn.itnhub.memo.WorkMemo"> | |
| 16 | + select <include refid="columns"/> | |
| 17 | + from work_memo | |
| 18 | + where org_id = #{orgId} | |
| 19 | + order by created_at desc, id desc | |
| 20 | + </select> | |
| 21 | + | |
| 22 | + <insert id="insert" parameterType="kr.itn.itnhub.memo.WorkMemo"> | |
| 23 | + insert into work_memo (org_id, contact_name, body) | |
| 24 | + values (#{orgId}, #{contactName}, #{body}) | |
| 25 | + </insert> | |
| 26 | + | |
| 27 | + <delete id="delete"> | |
| 28 | + delete from work_memo | |
| 29 | + where id = #{id} and org_id = #{orgId} | |
| 30 | + </delete> | |
| 31 | + | |
| 32 | +</mapper> |
+++ src/test/java/kr/itn/itnhub/memo/WorkMemoControllerTest.java
... | ... | @@ -0,0 +1,172 @@ |
| 1 | +package kr.itn.itnhub.memo; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.AbstractDbTest; | |
| 4 | +import kr.itn.itnhub.org.Organization; | |
| 5 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 6 | +import org.junit.jupiter.api.BeforeEach; | |
| 7 | +import org.junit.jupiter.api.Test; | |
| 8 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 9 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 10 | +import org.springframework.http.MediaType; | |
| 11 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 12 | +import org.springframework.security.test.context.support.WithAnonymousUser; | |
| 13 | +import org.springframework.security.test.context.support.WithMockUser; | |
| 14 | +import org.springframework.test.web.servlet.MockMvc; | |
| 15 | + | |
| 16 | +import static org.hamcrest.Matchers.containsString; | |
| 17 | +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; | |
| 18 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; | |
| 19 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 20 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |
| 21 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 22 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 23 | + | |
| 24 | +@AutoConfigureMockMvc | |
| 25 | +@WithMockUser(roles = "ADMIN") | |
| 26 | +class WorkMemoControllerTest extends AbstractDbTest { | |
| 27 | + | |
| 28 | + @Autowired | |
| 29 | + MockMvc mvc; | |
| 30 | + | |
| 31 | + @Autowired | |
| 32 | + OrganizationMapper orgMapper; | |
| 33 | + | |
| 34 | + @Autowired | |
| 35 | + WorkMemoMapper memoMapper; | |
| 36 | + | |
| 37 | + @Autowired | |
| 38 | + JdbcTemplate jdbc; | |
| 39 | + | |
| 40 | + private Long orgId; | |
| 41 | + | |
| 42 | + @BeforeEach | |
| 43 | + void setUp() { | |
| 44 | + jdbc.update("delete from work_memo"); | |
| 45 | + jdbc.update("delete from organization"); | |
| 46 | + | |
| 47 | + Organization org = new Organization(); | |
| 48 | + org.setOrgNo("001"); | |
| 49 | + org.setOrgName("국제방송교류재단"); | |
| 50 | + org.setChannelSlug("001"); | |
| 51 | + orgMapper.upsertBySeed(org); | |
| 52 | + | |
| 53 | + orgId = orgMapper.findAll().get(0).getId(); | |
| 54 | + } | |
| 55 | + | |
| 56 | + @Test | |
| 57 | + void 메모를_등록하면_생성된_메모를_돌려주고_목록에도_보인다() throws Exception { | |
| 58 | + mvc.perform(post("/api/orgs/{id}/memos", orgId) | |
| 59 | + .with(csrf()) | |
| 60 | + .contentType(MediaType.APPLICATION_JSON) | |
| 61 | + .content(""" | |
| 62 | + { | |
| 63 | + "contactName": "송민지 과장", | |
| 64 | + "body": "채널 생성 일정 문의" | |
| 65 | + } | |
| 66 | + """)) | |
| 67 | + .andExpect(status().isOk()) | |
| 68 | + .andExpect(jsonPath("$.contactName").value("송민지 과장")) | |
| 69 | + .andExpect(jsonPath("$.body").value("채널 생성 일정 문의")) | |
| 70 | + .andExpect(jsonPath("$.createdAt").isNumber()); | |
| 71 | + | |
| 72 | + mvc.perform(get("/api/orgs/{id}/memos", orgId)) | |
| 73 | + .andExpect(status().isOk()) | |
| 74 | + .andExpect(jsonPath("$[0].contactName").value("송민지 과장")) | |
| 75 | + .andExpect(jsonPath("$[0].body").value("채널 생성 일정 문의")); | |
| 76 | + } | |
| 77 | + | |
| 78 | + @Test | |
| 79 | + void 목록은_최신순이다() throws Exception { | |
| 80 | + mvc.perform(post("/api/orgs/{id}/memos", orgId) | |
| 81 | + .with(csrf()) | |
| 82 | + .contentType(MediaType.APPLICATION_JSON) | |
| 83 | + .content(""" | |
| 84 | + {"contactName": "송민지", "body": "첫번째 통화"} | |
| 85 | + """)) | |
| 86 | + .andExpect(status().isOk()); | |
| 87 | + | |
| 88 | + mvc.perform(post("/api/orgs/{id}/memos", orgId) | |
| 89 | + .with(csrf()) | |
| 90 | + .contentType(MediaType.APPLICATION_JSON) | |
| 91 | + .content(""" | |
| 92 | + {"contactName": "송민지", "body": "두번째 통화"} | |
| 93 | + """)) | |
| 94 | + .andExpect(status().isOk()); | |
| 95 | + | |
| 96 | + mvc.perform(get("/api/orgs/{id}/memos", orgId)) | |
| 97 | + .andExpect(status().isOk()) | |
| 98 | + .andExpect(jsonPath("$[0].body").value("두번째 통화")) | |
| 99 | + .andExpect(jsonPath("$[1].body").value("첫번째 통화")); | |
| 100 | + } | |
| 101 | + | |
| 102 | + @Test | |
| 103 | + void 메모_내용이_비면_400과_한글_메시지를_돌려준다() throws Exception { | |
| 104 | + mvc.perform(post("/api/orgs/{id}/memos", orgId) | |
| 105 | + .with(csrf()) | |
| 106 | + .contentType(MediaType.APPLICATION_JSON) | |
| 107 | + .content(""" | |
| 108 | + {"contactName": "송민지", "body": ""} | |
| 109 | + """)) | |
| 110 | + .andExpect(status().isBadRequest()) | |
| 111 | + .andExpect(jsonPath("$.message").value(containsString("메모 내용"))); | |
| 112 | + } | |
| 113 | + | |
| 114 | + @Test | |
| 115 | + void 존재하지_않는_기관이면_404다() throws Exception { | |
| 116 | + long missingId = orgId + 999999L; | |
| 117 | + | |
| 118 | + mvc.perform(get("/api/orgs/{id}/memos", missingId)) | |
| 119 | + .andExpect(status().isNotFound()) | |
| 120 | + .andExpect(jsonPath("$.message").value(containsString(String.valueOf(missingId)))); | |
| 121 | + } | |
| 122 | + | |
| 123 | + @Test | |
| 124 | + void 메모를_삭제하면_204와_함께_목록에서_사라진다() throws Exception { | |
| 125 | + mvc.perform(post("/api/orgs/{id}/memos", orgId) | |
| 126 | + .with(csrf()) | |
| 127 | + .contentType(MediaType.APPLICATION_JSON) | |
| 128 | + .content(""" | |
| 129 | + {"contactName": "송민지", "body": "삭제될 메모"} | |
| 130 | + """)) | |
| 131 | + .andExpect(status().isOk()); | |
| 132 | + Long memoId = memoMapper.findByOrg(orgId).get(0).getId(); | |
| 133 | + | |
| 134 | + mvc.perform(delete("/api/orgs/{id}/memos/{memoId}", orgId, memoId).with(csrf())) | |
| 135 | + .andExpect(status().isNoContent()); | |
| 136 | + | |
| 137 | + mvc.perform(get("/api/orgs/{id}/memos", orgId)) | |
| 138 | + .andExpect(status().isOk()) | |
| 139 | + .andExpect(jsonPath("$").isEmpty()); | |
| 140 | + } | |
| 141 | + | |
| 142 | + @Test | |
| 143 | + void 다른_기관_경로로_삭제하면_404다() throws Exception { | |
| 144 | + mvc.perform(post("/api/orgs/{id}/memos", orgId) | |
| 145 | + .with(csrf()) | |
| 146 | + .contentType(MediaType.APPLICATION_JSON) | |
| 147 | + .content(""" | |
| 148 | + {"contactName": "송민지", "body": "삭제될 메모"} | |
| 149 | + """)) | |
| 150 | + .andExpect(status().isOk()); | |
| 151 | + Long memoId = memoMapper.findByOrg(orgId).get(0).getId(); | |
| 152 | + | |
| 153 | + Organization other = new Organization(); | |
| 154 | + other.setOrgNo("002"); | |
| 155 | + other.setOrgName("다른기관"); | |
| 156 | + other.setChannelSlug("002"); | |
| 157 | + orgMapper.upsertBySeed(other); | |
| 158 | + Long otherOrgId = orgMapper.findAll().stream() | |
| 159 | + .filter(o -> o.getOrgNo().equals("002")) | |
| 160 | + .findFirst().get().getId(); | |
| 161 | + | |
| 162 | + mvc.perform(delete("/api/orgs/{id}/memos/{memoId}", otherOrgId, memoId).with(csrf())) | |
| 163 | + .andExpect(status().isNotFound()); | |
| 164 | + } | |
| 165 | + | |
| 166 | + @Test | |
| 167 | + @WithAnonymousUser | |
| 168 | + void 인증없이_호출하면_401이다() throws Exception { | |
| 169 | + mvc.perform(get("/api/orgs/{id}/memos", orgId)) | |
| 170 | + .andExpect(status().isUnauthorized()); | |
| 171 | + } | |
| 172 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?