# 대시보드 → n8n → Slack 정기 리포트 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 문자ON 대시보드의 매출/회원/문자발송 지표를 토큰 인증 JSON API로 노출하고, n8n이 스케줄로 호출해 Slack에 정기 요약을 게시한다. **Architecture:** 신규 모듈 `itn.let.mjo.dashboardapi`에 `@RestController` 1개 + Service/Impl + 응답 DTO + 토큰 인증 인터셉터를 추가한다. 집계는 기존 검증된 서비스(`mjonPayService`, `conectStatsService`, `mjonMsgService`)를 **재사용**하며 신규 SQL/DAO는 없다. Slack 표현(콤마/원화/Block Kit)은 n8n이 담당하고 앱은 raw 숫자 JSON만 반환한다. **Tech Stack:** Java 1.8, Spring MVC 4.3 (eGovFramework 3.9.0), iBATIS(SqlMapClient), Jackson 2.5.1, Lombok 1.18.24, Maven(WAR). n8n(Schedule + HTTP Request + Code + Slack). ## Global Constraints - Java 1.8 / Spring 4.3 문법만 사용. 신규 라이브러리 추가 금지(기존 pom.xml 의존성만). - 신규 SQL/DAO 작성 금지 — 기존 서비스 메서드만 호출. - 기존 4종 패턴 준수: `web`(@Controller/@RestController) / `service`(인터페이스+VO) / `service.impl`(@Service). - 빈 스캔 분리 규칙: 컨트롤러·인터셉터=디스패처 컨텍스트(`*-servlet.xml`), 서비스=루트 컨텍스트(`context-*.xml`). - 관리자 로그 AOP(`itn..*Controller.select*` 등) 회피를 위해 컨트롤러 메서드명은 `getDashboardSummary`(=`select*`/`insert*`/`update*`/`delete*` 아님)로 짓는다. - 토큰 헤더명: `X-DASHBOARD-TOKEN`. 프로퍼티 키: `Globals.dashboard.api.token`. - **테스트 인프라 없음**: 본 저장소는 `src/test`가 없고 surefire `skipTests=true`다. 따라서 각 태스크의 검증은 **`mvn compile` 성공 + 수동 curl/실행 확인**으로 수행한다(자동 단위테스트는 본 작업 범위 아님 — 스펙에서 수동 검증으로 합의됨). - 인코딩 UTF-8. 토큰 값은 로그/응답에 출력 금지. --- ### Task 1: 토큰 프로퍼티 추가 **Files:** - Modify: `src/main/resources/egovframework/egovProps/globals.properties` (끝에 추가) - Modify: `src/main/resources/egovframework/egovProps/globals_local.properties` - Modify: `src/main/resources/egovframework/egovProps/globals_dev.properties` - Modify: `src/main/resources/egovframework/egovProps/globals_prod.properties` **Interfaces:** - Consumes: 없음 - Produces: 프로퍼티 키 `Globals.dashboard.api.token` (이후 Task 5 인터셉터가 `globalSettings`로 읽음) - [ ] **Step 1: `globals.properties` 끝에 기본 토큰 추가** `globals.properties` 파일 맨 끝(line 101 이후)에 아래 블록 추가: ```properties # 대시보드 요약 API(n8n→Slack) 공유 토큰 Globals.dashboard.api.token=CHANGE_ME_DEV_TOKEN ``` - [ ] **Step 2: 환경별 프로퍼티에 동일 키 추가(override)** `globals_local.properties`, `globals_dev.properties`, `globals_prod.properties` 각 파일 끝에 아래를 추가(운영 값은 배포 시 교체): ```properties # 대시보드 요약 API(n8n→Slack) 공유 토큰 Globals.dashboard.api.token=CHANGE_ME_TOKEN ``` > 활성 프로파일은 `-Dspring.profiles.active`로 결정되며 `globals_.properties`가 실제 사용된다(`context-properties.xml`의 `globalSettings`). `globals.properties`는 공통 기본값. - [ ] **Step 3: 추가 확인** Run: `grep -rn "Globals.dashboard.api.token" src/main/resources/egovframework/egovProps/` Expected: 4개 파일 모두에서 키가 출력됨. - [ ] **Step 4: Commit** ```bash git add src/main/resources/egovframework/egovProps/globals*.properties git commit -m "feat(dashboard-api): 대시보드 요약 API 토큰 프로퍼티 추가" ``` --- ### Task 2: 응답 DTO (DashboardSummaryVO) **Files:** - Create: `src/main/java/itn/let/mjo/dashboardapi/service/DashboardSummaryVO.java` **Interfaces:** - Consumes: 없음 - Produces: 아래 타입들(이후 Task 3 서비스가 채우고 Task 4 컨트롤러가 반환). Lombok `@Getter @Setter` 사용. - `DashboardSummaryVO` { `boolean isSuccess`, `String msg`, `String baseDate`, `Sales sales`, `Members members`, `Sms sms` } - `DashboardSummaryVO.Sales` { `long todayAmt`, `List dayChart`, `List monthChart`, `long yearAmtSum` } - `DashboardSummaryVO.AmtPoint` { `String label`, `long amt` } - `DashboardSummaryVO.Members` { `int userNorCnt, userComCnt, mberNorTot, mberBlockTot, mberSecessTot, cmpApply, cmpApprove, cmpReject, todayExpireCnt, todayStopCnt` } - `DashboardSummaryVO.Sms` { `List agents`, `int todaySignupCnt` } - `DashboardSummaryVO.AgentStat` { `String agentCode`, `int succCnt`, `int failCnt`, `int succRate` } - [ ] **Step 1: DTO 파일 작성** `src/main/java/itn/let/mjo/dashboardapi/service/DashboardSummaryVO.java`: ```java package itn.let.mjo.dashboardapi.service; import java.io.Serializable; import java.util.ArrayList; import java.util.List; import lombok.Getter; import lombok.Setter; /** * 대시보드 요약 API(n8n→Slack) 응답 DTO. * 앱은 표현 포맷 없이 raw 숫자만 제공한다(콤마/원화는 n8n에서 처리). */ @Getter @Setter public class DashboardSummaryVO implements Serializable { private static final long serialVersionUID = 1L; private boolean isSuccess = true; private String msg = ""; private String baseDate; private Sales sales = new Sales(); private Members members = new Members(); private Sms sms = new Sms(); /** 매출(VAT 포함, 단위: 원) */ @Getter @Setter public static class Sales implements Serializable { private static final long serialVersionUID = 1L; private long todayAmt; private List dayChart = new ArrayList(); private List monthChart = new ArrayList(); private long yearAmtSum; } /** 금액 시계열 한 점 (label = "MM-DD" 또는 "N월") */ @Getter @Setter public static class AmtPoint implements Serializable { private static final long serialVersionUID = 1L; private String label; private long amt; public AmtPoint() { } public AmtPoint(String label, long amt) { this.label = label; this.amt = amt; } } /** 회원 현황 */ @Getter @Setter public static class Members implements Serializable { private static final long serialVersionUID = 1L; private int userNorCnt; private int userComCnt; private int mberNorTot; private int mberBlockTot; private int mberSecessTot; private int cmpApply; private int cmpApprove; private int cmpReject; private int todayExpireCnt; private int todayStopCnt; } /** 문자 발송 현황 */ @Getter @Setter public static class Sms implements Serializable { private static final long serialVersionUID = 1L; private List agents = new ArrayList(); private int todaySignupCnt; } /** 전송사별 발송 통계 */ @Getter @Setter public static class AgentStat implements Serializable { private static final long serialVersionUID = 1L; private String agentCode; private int succCnt; private int failCnt; private int succRate; } } ``` > 주의: Jackson 2.5.1은 `boolean isSuccess` 게터를 `success`로 직렬화할 수 있다. 기존 코드와 동일하게 키를 `isSuccess`로 노출하려면 Task 4에서 확인한다(필요 시 `@com.fasterxml.jackson.annotation.JsonProperty("isSuccess")`를 게터에 부여). 본 DTO는 우선 lombok 기본 게터로 두고 Task 4 검증 단계에서 키를 확정한다. - [ ] **Step 2: 컴파일 확인** Run: `mvn -q -o compile` (오프라인 불가 시 `mvn -q compile`) Expected: BUILD SUCCESS. `DashboardSummaryVO` 컴파일 에러 없음. - [ ] **Step 3: Commit** ```bash git add src/main/java/itn/let/mjo/dashboardapi/service/DashboardSummaryVO.java git commit -m "feat(dashboard-api): 요약 응답 DTO 추가" ``` --- ### Task 3: 집계 서비스 (Service + Impl) **Files:** - Create: `src/main/java/itn/let/mjo/dashboardapi/service/DashboardSummaryService.java` - Create: `src/main/java/itn/let/mjo/dashboardapi/service/impl/DashboardSummaryServiceImpl.java` **Interfaces:** - Consumes: - `DashboardSummaryVO` (Task 2) - 기존 빈: `mjonPayService`(`itn.let.mjo.pay.service.MjonPayService`), `conectStatsService`(`itn.let.sts.cst.service.EgovConectStatsService`), `mjonMsgService`(`itn.let.mjo.msg.service.MjonMsgService`) - Produces: `DashboardSummaryService.getDashboardSummary(String baseDate) -> DashboardSummaryVO` - [ ] **Step 1: 서비스 인터페이스 작성** `src/main/java/itn/let/mjo/dashboardapi/service/DashboardSummaryService.java`: ```java package itn.let.mjo.dashboardapi.service; /** * 대시보드 요약 API 집계 서비스. * 기존 대시보드 서비스 메서드를 조합해 raw 숫자 요약을 만든다(신규 SQL 없음). */ public interface DashboardSummaryService { /** * @param baseDate "yyyy-MM-dd" 또는 null(오늘) * @return 매출/회원/문자 요약 */ DashboardSummaryVO getDashboardSummary(String baseDate) throws Exception; } ``` - [ ] **Step 2: 구현체 작성** `src/main/java/itn/let/mjo/dashboardapi/service/impl/DashboardSummaryServiceImpl.java`: ```java package itn.let.mjo.dashboardapi.service.impl; import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; import javax.annotation.Resource; import org.springframework.stereotype.Service; import itn.let.mjo.dashboardapi.service.DashboardSummaryService; import itn.let.mjo.dashboardapi.service.DashboardSummaryVO; import itn.let.mjo.msg.service.MjonMsgService; import itn.let.mjo.msg.service.MjonMsgStatVO; import itn.let.mjo.msg.service.MjonMsgVO; import itn.let.mjo.pay.service.MjonPayService; import itn.let.mjo.pay.service.MjonPayVO; import itn.let.sts.cst.service.EgovConectStatsService; import itn.let.uss.umt.service.MberManageVO; @Service("dashboardSummaryService") public class DashboardSummaryServiceImpl implements DashboardSummaryService { @Resource(name = "mjonPayService") private MjonPayService mjonPayService; @Resource(name = "conectStatsService") private EgovConectStatsService conectStatsService; @Resource(name = "mjonMsgService") private MjonMsgService mjonMsgService; @Override public DashboardSummaryVO getDashboardSummary(String baseDate) throws Exception { DashboardSummaryVO vo = new DashboardSummaryVO(); if (baseDate == null || baseDate.trim().isEmpty()) { baseDate = new SimpleDateFormat("yyyy-MM-dd").format(new Date()); } vo.setBaseDate(baseDate); // 지표 블록별 try/catch: 개별 실패가 전체 응답을 막지 않도록 한다. try { fillSales(vo.getSales()); } catch (Exception e) { vo.setSuccess(false); vo.setMsg(appendMsg(vo.getMsg(), "sales:" + e.getMessage())); } try { fillMembers(vo.getMembers()); } catch (Exception e) { vo.setSuccess(false); vo.setMsg(appendMsg(vo.getMsg(), "members:" + e.getMessage())); } try { fillSms(vo.getSms()); } catch (Exception e) { vo.setSuccess(false); vo.setMsg(appendMsg(vo.getMsg(), "sms:" + e.getMessage())); } return vo; } /** 매출(일/월/연) — selectPayDayChartDashboard, selectPayMonthChartDashboard, selectPayYearChart 재사용 */ private void fillSales(DashboardSummaryVO.Sales sales) throws Exception { // 일별(최근 4일, 최신순) MjonPayVO dayReq = new MjonPayVO(); dayReq.setFirstIndex(0); dayReq.setRecordCountPerPage(4); dayReq.setSearchSortCnd("regDate"); dayReq.setSearchSortOrd("desc"); List dayList = mjonPayService.selectPayDayChartDashboard(dayReq); if (dayList != null) { for (MjonPayVO p : dayList) { String label = p.getRegDate(); if (label != null && label.length() >= 10) { label = label.substring(5, 10); // MM-DD } sales.getDayChart().add(new DashboardSummaryVO.AmtPoint(label, toLong(p.getAmtTotSum()))); } if (!dayList.isEmpty()) { sales.setTodayAmt(toLong(dayList.get(0).getAmtTotSum())); // 최신순 첫 행 = 당일 } } // 월별(최근 4개월, 최신순) MjonPayVO monthReq = new MjonPayVO(); monthReq.setFirstIndex(0); monthReq.setRecordCountPerPage(4); monthReq.setSearchSortCnd("regDate"); monthReq.setSearchSortOrd("desc"); List monthList = mjonPayService.selectPayMonthChartDashboard(monthReq); if (monthList != null) { for (MjonPayVO p : monthList) { String label = p.getRegDate(); if (label != null && label.length() >= 7) { label = Integer.parseInt(label.substring(5, 7)) + "월"; } sales.getMonthChart().add(new DashboardSummaryVO.AmtPoint(label, toLong(p.getAmtTotSum()))); } } // 연간 누적 MjonPayVO yearInfo = mjonPayService.selectPayYearChart(new MjonPayVO()); if (yearInfo != null) { sales.setYearAmtSum(toLong(yearInfo.getAmtYearSum())); } } /** 회원 현황 — EgovMainController.selectMemberStatsAjax 로직 재현 */ private void fillMembers(DashboardSummaryVO.Members m) throws Exception { MberManageVO req = new MberManageVO(); req.setHstSttus("01"); List apply = conectStatsService.selectMberCmpHstStatsDashboard(req); req.setHstSttus("02"); List approve = conectStatsService.selectMberCmpHstStatsDashboard(req); req.setHstSttus("03"); List reject = conectStatsService.selectMberCmpHstStatsDashboard(req); m.setCmpApply((apply == null || apply.isEmpty()) ? 0 : apply.get(0).getTotCnt()); m.setCmpApprove((approve == null || approve.isEmpty()) ? 0 : approve.get(0).getTotCnt()); m.setCmpReject((reject == null || reject.isEmpty()) ? 0 : reject.get(0).getTotCnt()); MberManageVO totReq = new MberManageVO(); totReq.setDept("P"); List priv = conectStatsService.selectMberTotStats(totReq); totReq.setDept("C"); List comp = conectStatsService.selectMberTotStats(totReq); if (priv != null) { for (MberManageVO v : priv) { accumulateMember(m, v, true); } } if (comp != null) { for (MberManageVO v : comp) { accumulateMember(m, v, false); } } String statStr = conectStatsService.selectTodaySttCountString(totReq); if (statStr != null) { String[] split = statStr.split("_"); if (split.length == 3) { m.setTodayExpireCnt(toInt(split[0])); m.setTodayStopCnt(toInt(split[1])); // split[2]=환불신청수는 1차 범위 제외 } } } private void accumulateMember(DashboardSummaryVO.Members m, MberManageVO v, boolean isPrivate) { String sttus = v.getMberSttus(); int cnt = v.getTotCnt(); if ("Y".equals(sttus)) { if (isPrivate) { m.setUserNorCnt(cnt); } else { m.setUserComCnt(cnt); } m.setMberNorTot(m.getMberNorTot() + cnt); } else if ("B".equals(sttus)) { m.setMberBlockTot(m.getMberBlockTot() + cnt); } else if ("N".equals(sttus)) { m.setMberSecessTot(m.getMberSecessTot() + cnt); } } /** 문자 발송 현황 — selectAgentSmsCountStatMainList, selectMemDayChartDashboard 재사용 */ private void fillSms(DashboardSummaryVO.Sms s) throws Exception { MjonMsgStatVO statReq = new MjonMsgStatVO(); statReq.setFirstIndex(0); statReq.setRecordCountPerPage(3); List agents = mjonMsgService.selectAgentSmsCountStatMainList(statReq); if (agents != null) { for (MjonMsgStatVO a : agents) { DashboardSummaryVO.AgentStat stat = new DashboardSummaryVO.AgentStat(); stat.setAgentCode(a.getAgentCode()); int succ = a.getSumSuccCnt(); int fail = a.getSumFailCnt(); stat.setSuccCnt(succ); stat.setFailCnt(fail); int total = succ + fail; stat.setSuccRate(total == 0 ? 0 : (int) Math.round(succ * 100.0 / total)); s.getAgents().add(stat); } } MjonMsgVO memReq = new MjonMsgVO(); memReq.setFirstIndex(0); memReq.setRecordCountPerPage(4); memReq.setSearchSortCnd("regDate"); memReq.setSearchSortOrd("desc"); List memDay = mjonMsgService.selectMemDayChartDashboard(memReq); if (memDay != null && !memDay.isEmpty()) { s.setTodaySignupCnt(memDay.get(0).getRegCount()); } } /** "12345", "12,345", "12345.0", null → long(0) 안전 변환 */ private long toLong(String s) { if (s == null) { return 0L; } String digits = s.replaceAll("[^0-9-]", ""); if (digits.isEmpty() || "-".equals(digits)) { return 0L; } try { return Long.parseLong(digits); } catch (NumberFormatException e) { return 0L; } } private int toInt(String s) { return (int) toLong(s); } private String appendMsg(String base, String add) { if (base == null || base.isEmpty()) { return add; } return base + "; " + add; } } ``` > 검증 포인트(구현 중 확인): `MberManageVO.getTotCnt()`, `MjonMsgStatVO.getSumFailCnt()`, `MjonMsgVO.getRegCount()`가 존재해야 한다(기존 `EgovMainController`/VO에서 사용 중). 시그니처가 다르면 해당 게터명으로 맞춘다. `vo.setSuccess(false)`는 lombok이 `boolean isSuccess` 필드에 대해 생성하는 setter다(필드명 기준 `setSuccess`). - [ ] **Step 3: 컴파일 확인** Run: `mvn -q compile` Expected: BUILD SUCCESS. 메서드/게터 미스매치 없음(에러 시 위 검증 포인트의 실제 게터명으로 수정). - [ ] **Step 4: Commit** ```bash git add src/main/java/itn/let/mjo/dashboardapi/service/ git commit -m "feat(dashboard-api): 매출/회원/문자 요약 집계 서비스 추가" ``` --- ### Task 4: REST 컨트롤러 **Files:** - Create: `src/main/java/itn/let/mjo/dashboardapi/web/DashboardApiController.java` **Interfaces:** - Consumes: `DashboardSummaryService.getDashboardSummary(String)` (Task 3), `DashboardSummaryVO` (Task 2) - Produces: HTTP `GET /api/dashboard/summary.do` → JSON 직렬화된 `DashboardSummaryVO` - [ ] **Step 1: 컨트롤러 작성** `src/main/java/itn/let/mjo/dashboardapi/web/DashboardApiController.java`: ```java package itn.let.mjo.dashboardapi.web; import javax.annotation.Resource; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import itn.let.mjo.dashboardapi.service.DashboardSummaryService; import itn.let.mjo.dashboardapi.service.DashboardSummaryVO; /** * 대시보드 요약 JSON API(n8n→Slack 전용). * 인증은 DashboardApiAuthInterceptor(토큰 헤더)가 담당한다. * 메서드명은 select* 가 아니어서 관리자 로그 AOP 대상에서 제외된다. */ @RestController public class DashboardApiController { @Resource(name = "dashboardSummaryService") private DashboardSummaryService dashboardSummaryService; @RequestMapping(value = "/api/dashboard/summary.do", method = RequestMethod.GET) public DashboardSummaryVO getDashboardSummary( @RequestParam(value = "date", required = false) String date) throws Exception { return dashboardSummaryService.getDashboardSummary(date); } } ``` - [ ] **Step 2: 빌드(WAR) 확인** Run: `mvn -q clean package` Expected: BUILD SUCCESS, `target/mjon.war` 생성. - [ ] **Step 3: 로컬 기동 후 JSON 키 확인(인증 인터셉터 적용 전, 임시)** > 이 시점엔 아직 인증이 없으므로 토큰 없이 호출된다. JSON 직렬화 키만 확인한다. Run(터미널 A): `mvn tomcat7:run` Run(터미널 B): `curl -s "http://localhost/api/dashboard/summary.do?date=2026-06-23"` Expected: `sales`/`members`/`sms` 블록이 있는 JSON. 성공 플래그 키가 `isSuccess`인지 `success`인지 확인. - 키가 `success`로 나오면 `DashboardSummaryVO`의 게터에 `@com.fasterxml.jackson.annotation.JsonProperty("isSuccess")`를 추가: ```java @com.fasterxml.jackson.annotation.JsonProperty("isSuccess") public boolean isSuccess() { return isSuccess; } ``` (lombok 게터와 충돌 시 lombok `@Getter`를 필드에서 제거하고 위 명시 게터만 둔다.) - 확인 후 터미널 A 종료(Ctrl+C). - [ ] **Step 4: Commit** ```bash git add src/main/java/itn/let/mjo/dashboardapi/web/DashboardApiController.java src/main/java/itn/let/mjo/dashboardapi/service/DashboardSummaryVO.java git commit -m "feat(dashboard-api): /api/dashboard/summary.do RestController 추가" ``` --- ### Task 5: 토큰 인증 인터셉터 + 매핑 + 시큐리티 예외 **Files:** - Create: `src/main/java/itn/com/cmm/interceptor/DashboardApiAuthInterceptor.java` - Modify: `src/main/webapp/WEB-INF/config/egovframework/springmvc/egov-interceptor-servlet.xml` - Modify: `src/main/resources/egovframework/spring/com/context-security.xml:14` (security="none" 블록 인근에 추가) **Interfaces:** - Consumes: 프로퍼티 `Globals.dashboard.api.token` via `globalSettings`(Task 1) - Produces: `/api/dashboard/**` 요청에 대한 토큰 검증(불일치/누락 시 401 JSON, 컨트롤러 진입 차단) - [ ] **Step 1: 인터셉터 작성** `src/main/java/itn/com/cmm/interceptor/DashboardApiAuthInterceptor.java`: ```java package itn.com.cmm.interceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; /** * 대시보드 요약 API 토큰 인증. * X-DASHBOARD-TOKEN 헤더가 설정 토큰과 일치할 때만 통과시킨다. */ public class DashboardApiAuthInterceptor extends HandlerInterceptorAdapter { private static final String HEADER = "X-DASHBOARD-TOKEN"; @Value("#{globalSettings['Globals.dashboard.api.token']}") private String apiToken; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader(HEADER); boolean ok = apiToken != null && !apiToken.trim().isEmpty() && apiToken.equals(token); if (!ok) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"isSuccess\":false,\"msg\":\"unauthorized\"}"); return false; // 컨트롤러 진입 차단 (토큰 값은 로그/응답에 출력하지 않음) } return true; } } ``` - [ ] **Step 2: 인터셉터 매핑 등록** `egov-interceptor-servlet.xml`의 `` 블록 안(첫 `` 위, line 10 직후)에 추가: ```xml ``` > 기존 `IPIgnoreInterceptorHandler`는 `/**` 매핑에 `/cmm/**`, `/js/**`, `/publish/**`만 제외한다. `/api/dashboard/**`도 그 인터셉터를 거치지만, 해당 핸들러는 특정 URI(`errorLogInsert`, `*Ajax.do` 등)에만 동작하므로 본 API 흐름에는 영향 없음(통과). 빌드 후 Step 6에서 실제 통과를 확인한다. - [ ] **Step 3: 시큐리티 예외 추가** `context-security.xml`의 `security="none"` 목록(line 9~14 인근)에 한 줄 추가: ```xml ``` > 이 엔드포인트는 토큰으로 자체 보호하므로 Spring Security 필터 체인/세션이 필요 없다. DB 기반 URL 권한(`secured-object`)에 걸리는 것을 방지한다. - [ ] **Step 4: 빌드 확인** Run: `mvn -q clean package` Expected: BUILD SUCCESS, `target/mjon.war` 생성. - [ ] **Step 5: 토큰 없는 호출 → 401 확인** Run(터미널 A): `mvn tomcat7:run` Run(터미널 B): `curl -s -o /dev/null -w "%{http_code}\n" "http://localhost/api/dashboard/summary.do"` Expected: `401` - [ ] **Step 6: 올바른 토큰 → 200 + JSON 확인** > 토큰 값은 활성 프로파일의 `globals_.properties`에 설정된 값. 로컬 기동은 보통 `local` 프로파일. Run(터미널 B): ```bash curl -s -H "X-DASHBOARD-TOKEN: CHANGE_ME_TOKEN" "http://localhost/api/dashboard/summary.do" | head -c 600 ``` Expected: HTTP 200, `sales`/`members`/`sms` 블록이 채워진 JSON. (틀린 토큰으로는 401) - 확인 후 터미널 A 종료. - [ ] **Step 7: Commit** ```bash git add src/main/java/itn/com/cmm/interceptor/DashboardApiAuthInterceptor.java \ src/main/webapp/WEB-INF/config/egovframework/springmvc/egov-interceptor-servlet.xml \ src/main/resources/egovframework/spring/com/context-security.xml git commit -m "feat(dashboard-api): 토큰 인증 인터셉터 및 시큐리티 예외 추가" ``` --- ### Task 6: n8n 워크플로우 + Slack 산출물 **Files:** - Create: `docs/superpowers/n8n/n8n-workflow.json` - Create: `docs/superpowers/n8n/code-node.js` - Create: `docs/superpowers/n8n/README.md` **Interfaces:** - Consumes: `GET /api/dashboard/summary.do` 응답 JSON(Task 2~5) - Produces: import 가능한 n8n 워크플로우 + Slack Block Kit 변환 스니펫 - [ ] **Step 1: Code 노드 변환 스니펫 작성** `docs/superpowers/n8n/code-node.js`: ```javascript // n8n Code 노드: 도메인 JSON → Slack Block Kit // 입력: 이전 HTTP Request 노드의 JSON 응답 const d = $input.first().json; const won = (n) => (n == null ? '0' : Number(n).toLocaleString('ko-KR')) + '원'; const sales = d.sales || {}; const m = d.members || {}; const sms = d.sms || {}; const agentLines = (sms.agents || []) .map(a => `• ${a.agentCode}: 성공 ${Number(a.succCnt).toLocaleString('ko-KR')} / 실패 ${Number(a.failCnt).toLocaleString('ko-KR')} (성공률 ${a.succRate}%)`) .join('\n') || '데이터 없음'; const dayLines = (sales.dayChart || []) .map(p => `${p.label} ${won(p.amt)}`) .join(' | ') || '데이터 없음'; const blocks = [ { type: 'header', text: { type: 'plain_text', text: `📊 문자ON 일일 리포트 — ${d.baseDate}`, emoji: true } }, { type: 'section', fields: [ { type: 'mrkdwn', text: `*당일 매출*\n${won(sales.todayAmt)}` }, { type: 'mrkdwn', text: `*연간 누적*\n${won(sales.yearAmtSum)}` }, ] }, { type: 'section', text: { type: 'mrkdwn', text: `*최근 매출(일별)*\n${dayLines}` } }, { type: 'divider' }, { type: 'section', fields: [ { type: 'mrkdwn', text: `*개인/기업 회원*\n${m.userNorCnt} / ${m.userComCnt}` }, { type: 'mrkdwn', text: `*기업 신청/승인/반려*\n${m.cmpApply} / ${m.cmpApprove} / ${m.cmpReject}` }, { type: 'mrkdwn', text: `*금일 탈퇴/정지*\n${m.todayExpireCnt} / ${m.todayStopCnt}` }, { type: 'mrkdwn', text: `*일 가입자수*\n${sms.todaySignupCnt}` }, ] }, { type: 'divider' }, { type: 'section', text: { type: 'mrkdwn', text: `*전송사별 발송*\n${agentLines}` } }, ]; return [{ json: { blocks } }]; ``` - [ ] **Step 2: 워크플로우 JSON 작성** `docs/superpowers/n8n/n8n-workflow.json`: ```json { "name": "mjon-dashboard-daily-slack", "nodes": [ { "parameters": { "rule": { "interval": [ { "field": "cronExpression", "expression": "0 9 * * *" } ] } }, "id": "schedule", "name": "Schedule Trigger", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1, "position": [ 240, 300 ] }, { "parameters": { "url": "https://CHANGE_ME_HOST/api/dashboard/summary.do", "options": {}, "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "X-DASHBOARD-TOKEN", "value": "={{ $env.MJON_DASH_TOKEN }}" } ] } }, "id": "http", "name": "HTTP Request", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4, "position": [ 460, 300 ] }, { "parameters": { "language": "javaScript", "jsCode": "// docs/superpowers/n8n/code-node.js 내용을 여기에 붙여넣는다" }, "id": "code", "name": "Format Slack Blocks", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 680, 300 ] }, { "parameters": { "resource": "message", "operation": "post", "channelId": "CHANGE_ME_CHANNEL", "blocksUi": "={{ $json.blocks }}", "text": "문자ON 일일 리포트" }, "id": "slack", "name": "Slack", "type": "n8n-nodes-base.slack", "typeVersion": 2, "position": [ 900, 300 ] } ], "connections": { "Schedule Trigger": { "main": [ [ { "node": "HTTP Request", "type": "main", "index": 0 } ] ] }, "HTTP Request": { "main": [ [ { "node": "Format Slack Blocks", "type": "main", "index": 0 } ] ] }, "Format Slack Blocks": { "main": [ [ { "node": "Slack", "type": "main", "index": 0 } ] ] } }, "settings": {} } ``` - [ ] **Step 3: README 작성** `docs/superpowers/n8n/README.md`: ```markdown # 대시보드 일일 리포트 → Slack (n8n) ## 구성 - `n8n-workflow.json` : import용 워크플로우 (Schedule → HTTP Request → Code → Slack) - `code-node.js` : Code 노드에 붙여넣을 변환 스니펫 ## 설정 순서 1. n8n에서 Settings → Variables/Environment에 `MJON_DASH_TOKEN` 추가 (값 = 앱 `globals_.properties`의 `Globals.dashboard.api.token`). 2. `n8n-workflow.json` import. 3. HTTP Request 노드의 `CHANGE_ME_HOST`를 관리자 호스트로 교체. 4. Code 노드(`Format Slack Blocks`)에 `code-node.js` 내용 붙여넣기. 5. Slack 노드: 자격증명 연결 후 `CHANGE_ME_CHANNEL`을 대상 채널로 교체. 6. 스케줄: 기본 매일 09:00(`0 9 * * *`). 필요 시 cron 수정. ## 수동 검증 - 워크플로우 상단 **Execute Workflow** 실행 → 대상 Slack 채널에 리포트 수신 확인. - 토큰 불일치 시 HTTP Request 노드가 401로 실패하는지 확인. ``` - [ ] **Step 4: 산출물 확인** Run: `ls docs/superpowers/n8n/` Expected: `README.md code-node.js n8n-workflow.json` - [ ] **Step 5: Commit** ```bash git add docs/superpowers/n8n/ git commit -m "feat(dashboard-api): n8n 워크플로우 및 Slack 변환 산출물 추가" ``` --- ## 검증(통합) 시나리오 모든 태스크 완료 후: 1. `mvn clean package` → `target/mjon.war` 생성(BUILD SUCCESS). 2. `mvn tomcat7:run` 기동. 3. `curl -s -o /dev/null -w "%{http_code}" http://localhost/api/dashboard/summary.do` → `401`. 4. `curl -s -H "X-DASHBOARD-TOKEN: <토큰>" http://localhost/api/dashboard/summary.do` → `200` + 매출/회원/문자 JSON. 5. n8n: `MJON_DASH_TOKEN` 설정 → 워크플로우 import → **Execute Workflow** → Slack 채널 수신 확인. 6. 운영 배포: `globals_prod.properties`의 토큰을 실제 값으로 교체 후 WAR 배포. ## 자체 검토 결과 - **스펙 커버리지**: 엔드포인트 계약(Task 2·4) / 인증(Task 1·5) / Java 컴포넌트(Task 2~5) / n8n·Slack(Task 6) / 에러처리·검증(서비스 블록별 try/catch, 통합 시나리오) — 스펙 8개 결정사항 모두 태스크에 매핑됨. - **placeholder**: `CHANGE_ME_*`는 의도된 사용자 치환값(호스트/채널/토큰)이며 코드 placeholder 아님. - **타입 일관성**: `getDashboardSummary(String)`, `DashboardSummaryVO`/`Sales`/`Members`/`Sms`/`AmtPoint`/`AgentStat`, 빈 이름(`dashboardSummaryService`, `mjonPayService`, `conectStatsService`, `mjonMsgService`)이 태스크 간 일치. `setSuccess`(lombok, `boolean isSuccess`) 사용 일관. - **리스크**: 기존 VO 게터명(`getTotCnt`/`getSumFailCnt`/`getRegCount`/`getAmtTotSum`) 미스매치 가능성 → Task 3 Step 3 컴파일에서 검출·수정. Jackson의 `isSuccess`/`success` 키 차이 → Task 4 Step 3에서 확정.