ITN Dev 07-22
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
+++ src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
@@ -49,7 +49,9 @@
             "managerName", "담당자명",
             "managerTitle", "직급/직함",
             "managerPhone", "연락처",
-            "managerEmail", "이메일");
+            "managerEmail", "이메일",
+            "contactName", "담당자",
+            "body", "메모 내용");
 
     @ExceptionHandler(OrgNotFoundException.class)
     public ResponseEntity<ApiError> handleNotFound(OrgNotFoundException e) {
 
src/main/java/kr/itn/itnhub/memo/MemoRequest.java (added)
+++ src/main/java/kr/itn/itnhub/memo/MemoRequest.java
@@ -0,0 +1,9 @@
+package kr.itn.itnhub.memo;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public record MemoRequest(
+        @NotBlank @Size(max = 100) String contactName,
+        @NotBlank String body) {
+}
 
src/main/java/kr/itn/itnhub/memo/WorkMemo.java (added)
+++ src/main/java/kr/itn/itnhub/memo/WorkMemo.java
@@ -0,0 +1,25 @@
+package kr.itn.itnhub.memo;
+
+public class WorkMemo {
+
+    private Long id;
+    private Long orgId;
+    private String contactName;
+    private String body;
+    private long createdAt;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+
+    public Long getOrgId() { return orgId; }
+    public void setOrgId(Long orgId) { this.orgId = orgId; }
+
+    public String getContactName() { return contactName; }
+    public void setContactName(String contactName) { this.contactName = contactName; }
+
+    public String getBody() { return body; }
+    public void setBody(String body) { this.body = body; }
+
+    public long getCreatedAt() { return createdAt; }
+    public void setCreatedAt(long createdAt) { this.createdAt = createdAt; }
+}
 
src/main/java/kr/itn/itnhub/memo/WorkMemoController.java (added)
+++ src/main/java/kr/itn/itnhub/memo/WorkMemoController.java
@@ -0,0 +1,40 @@
+package kr.itn.itnhub.memo;
+
+import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/** 기관관리 상세의 업무메모 탭이 부르는 엔드포인트. */
+@RestController
+public class WorkMemoController {
+
+    private final WorkMemoService memoService;
+
+    public WorkMemoController(WorkMemoService memoService) {
+        this.memoService = memoService;
+    }
+
+    @GetMapping("/api/orgs/{id}/memos")
+    public List<WorkMemo> list(@PathVariable Long id) {
+        return memoService.list(id);
+    }
+
+    @PostMapping("/api/orgs/{id}/memos")
+    public WorkMemo create(@PathVariable Long id, @Valid @RequestBody MemoRequest request) {
+        return memoService.create(id, request);
+    }
+
+    @ResponseStatus(HttpStatus.NO_CONTENT)
+    @DeleteMapping("/api/orgs/{id}/memos/{memoId}")
+    public void delete(@PathVariable Long id, @PathVariable Long memoId) {
+        memoService.delete(id, memoId);
+    }
+}
 
src/main/java/kr/itn/itnhub/memo/WorkMemoMapper.java (added)
+++ src/main/java/kr/itn/itnhub/memo/WorkMemoMapper.java
@@ -0,0 +1,21 @@
+package kr.itn.itnhub.memo;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface WorkMemoMapper {
+
+    /** 최신순. */
+    List<WorkMemo> findByOrg(@Param("orgId") Long orgId);
+
+    int insert(WorkMemo memo);
+
+    /**
+     * id와 org_id를 함께 조건으로 걸어, URL의 orgId와 실제 소유 기관이 다른 메모는
+     * 삭제되지 않게 한다(다른 기관 URL로 삭제 시도하는 실수를 막는다).
+     */
+    int delete(@Param("orgId") Long orgId, @Param("id") Long id);
+}
 
src/main/java/kr/itn/itnhub/memo/WorkMemoService.java (added)
+++ src/main/java/kr/itn/itnhub/memo/WorkMemoService.java
@@ -0,0 +1,59 @@
+package kr.itn.itnhub.memo;
+
+import kr.itn.itnhub.org.OrgNotFoundException;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * 기관관리 상세의 업무메모 탭이 쓰는 서비스. 전화 통화 등 담당자와의 연락 내용을
+ * 기관별로 남겨두는 단순 CRUD이며, Mattermost와는 무관한 순수 DB 기능이다.
+ */
+@Service
+public class WorkMemoService {
+
+    private final WorkMemoMapper memoMapper;
+    private final OrganizationMapper orgMapper;
+
+    public WorkMemoService(WorkMemoMapper memoMapper, OrganizationMapper orgMapper) {
+        this.memoMapper = memoMapper;
+        this.orgMapper = orgMapper;
+    }
+
+    public List<WorkMemo> list(Long orgId) {
+        requireOrg(orgId);
+        return memoMapper.findByOrg(orgId);
+    }
+
+    @Transactional
+    public WorkMemo create(Long orgId, MemoRequest request) {
+        requireOrg(orgId);
+
+        WorkMemo memo = new WorkMemo();
+        memo.setOrgId(orgId);
+        memo.setContactName(request.contactName());
+        memo.setBody(request.body());
+        memoMapper.insert(memo);
+
+        // 방금 넣은 행이 created_at 기준 가장 최신이므로 목록의 첫 행이 곧 이 메모다.
+        return memoMapper.findByOrg(orgId).get(0);
+    }
+
+    @Transactional
+    public void delete(Long orgId, Long memoId) {
+        requireOrg(orgId);
+
+        int deleted = memoMapper.delete(orgId, memoId);
+        if (deleted == 0) {
+            throw new OrgNotFoundException("메모를 찾을 수 없습니다: " + memoId);
+        }
+    }
+
+    private void requireOrg(Long orgId) {
+        if (orgMapper.findById(orgId) == null) {
+            throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
+        }
+    }
+}
 
src/main/resources/db/migration/V2__work_memo.sql (added)
+++ src/main/resources/db/migration/V2__work_memo.sql
@@ -0,0 +1,9 @@
+create table work_memo (
+    id            bigserial primary key,
+    org_id        bigint       not null references organization (id) on delete cascade,
+    contact_name  varchar(100) not null,
+    body          text         not null,
+    created_at    timestamptz  not null default now()
+);
+
+create index ix_work_memo_org_created on work_memo (org_id, created_at desc);
 
src/main/resources/mapper/WorkMemoMapper.xml (added)
+++ src/main/resources/mapper/WorkMemoMapper.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="kr.itn.itnhub.memo.WorkMemoMapper">
+
+  <!--
+    created_at을 밀리초 epoch로 내려준다 - 게시물 탭(PostView.createAt)과 동일한
+    관례를 맞추기 위함이다.
+  -->
+  <sql id="columns">
+    id, org_id, contact_name, body,
+    (extract(epoch from created_at) * 1000)::bigint as created_at
+  </sql>
+
+  <select id="findByOrg" resultType="kr.itn.itnhub.memo.WorkMemo">
+    select <include refid="columns"/>
+    from work_memo
+    where org_id = #{orgId}
+    order by created_at desc, id desc
+  </select>
+
+  <insert id="insert" parameterType="kr.itn.itnhub.memo.WorkMemo">
+    insert into work_memo (org_id, contact_name, body)
+    values (#{orgId}, #{contactName}, #{body})
+  </insert>
+
+  <delete id="delete">
+    delete from work_memo
+    where id = #{id} and org_id = #{orgId}
+  </delete>
+
+</mapper>
 
src/test/java/kr/itn/itnhub/memo/WorkMemoControllerTest.java (added)
+++ src/test/java/kr/itn/itnhub/memo/WorkMemoControllerTest.java
@@ -0,0 +1,172 @@
+package kr.itn.itnhub.memo;
+
+import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.http.MediaType;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.test.context.support.WithAnonymousUser;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@AutoConfigureMockMvc
+@WithMockUser(roles = "ADMIN")
+class WorkMemoControllerTest extends AbstractDbTest {
+
+    @Autowired
+    MockMvc mvc;
+
+    @Autowired
+    OrganizationMapper orgMapper;
+
+    @Autowired
+    WorkMemoMapper memoMapper;
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    private Long orgId;
+
+    @BeforeEach
+    void setUp() {
+        jdbc.update("delete from work_memo");
+        jdbc.update("delete from organization");
+
+        Organization org = new Organization();
+        org.setOrgNo("001");
+        org.setOrgName("국제방송교류재단");
+        org.setChannelSlug("001");
+        orgMapper.upsertBySeed(org);
+
+        orgId = orgMapper.findAll().get(0).getId();
+    }
+
+    @Test
+    void 메모를_등록하면_생성된_메모를_돌려주고_목록에도_보인다() throws Exception {
+        mvc.perform(post("/api/orgs/{id}/memos", orgId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {
+                                  "contactName": "송민지 과장",
+                                  "body": "채널 생성 일정 문의"
+                                }
+                                """))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.contactName").value("송민지 과장"))
+                .andExpect(jsonPath("$.body").value("채널 생성 일정 문의"))
+                .andExpect(jsonPath("$.createdAt").isNumber());
+
+        mvc.perform(get("/api/orgs/{id}/memos", orgId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$[0].contactName").value("송민지 과장"))
+                .andExpect(jsonPath("$[0].body").value("채널 생성 일정 문의"));
+    }
+
+    @Test
+    void 목록은_최신순이다() throws Exception {
+        mvc.perform(post("/api/orgs/{id}/memos", orgId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"contactName": "송민지", "body": "첫번째 통화"}
+                                """))
+                .andExpect(status().isOk());
+
+        mvc.perform(post("/api/orgs/{id}/memos", orgId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"contactName": "송민지", "body": "두번째 통화"}
+                                """))
+                .andExpect(status().isOk());
+
+        mvc.perform(get("/api/orgs/{id}/memos", orgId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$[0].body").value("두번째 통화"))
+                .andExpect(jsonPath("$[1].body").value("첫번째 통화"));
+    }
+
+    @Test
+    void 메모_내용이_비면_400과_한글_메시지를_돌려준다() throws Exception {
+        mvc.perform(post("/api/orgs/{id}/memos", orgId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"contactName": "송민지", "body": ""}
+                                """))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.message").value(containsString("메모 내용")));
+    }
+
+    @Test
+    void 존재하지_않는_기관이면_404다() throws Exception {
+        long missingId = orgId + 999999L;
+
+        mvc.perform(get("/api/orgs/{id}/memos", missingId))
+                .andExpect(status().isNotFound())
+                .andExpect(jsonPath("$.message").value(containsString(String.valueOf(missingId))));
+    }
+
+    @Test
+    void 메모를_삭제하면_204와_함께_목록에서_사라진다() throws Exception {
+        mvc.perform(post("/api/orgs/{id}/memos", orgId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"contactName": "송민지", "body": "삭제될 메모"}
+                                """))
+                .andExpect(status().isOk());
+        Long memoId = memoMapper.findByOrg(orgId).get(0).getId();
+
+        mvc.perform(delete("/api/orgs/{id}/memos/{memoId}", orgId, memoId).with(csrf()))
+                .andExpect(status().isNoContent());
+
+        mvc.perform(get("/api/orgs/{id}/memos", orgId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$").isEmpty());
+    }
+
+    @Test
+    void 다른_기관_경로로_삭제하면_404다() throws Exception {
+        mvc.perform(post("/api/orgs/{id}/memos", orgId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"contactName": "송민지", "body": "삭제될 메모"}
+                                """))
+                .andExpect(status().isOk());
+        Long memoId = memoMapper.findByOrg(orgId).get(0).getId();
+
+        Organization other = new Organization();
+        other.setOrgNo("002");
+        other.setOrgName("다른기관");
+        other.setChannelSlug("002");
+        orgMapper.upsertBySeed(other);
+        Long otherOrgId = orgMapper.findAll().stream()
+                .filter(o -> o.getOrgNo().equals("002"))
+                .findFirst().get().getId();
+
+        mvc.perform(delete("/api/orgs/{id}/memos/{memoId}", otherOrgId, memoId).with(csrf()))
+                .andExpect(status().isNotFound());
+    }
+
+    @Test
+    @WithAnonymousUser
+    void 인증없이_호출하면_401이다() throws Exception {
+        mvc.perform(get("/api/orgs/{id}/memos", orgId))
+                .andExpect(status().isUnauthorized());
+    }
+}
Add a comment
List