File name
Commit message
Commit date
File name
Commit message
Commit date
jykim 09-15 00e045f 전송자격인증제 migration UNIX

대시보드 → 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 이후)에 아래 블록 추가:


# 대시보드 요약 API(n8n→Slack) 공유 토큰
Globals.dashboard.api.token=CHANGE_ME_DEV_TOKEN
  • Step 2: 환경별 프로퍼티에 동일 키 추가(override)

globals_local.properties, globals_dev.properties, globals_prod.properties 각 파일 끝에 아래를 추가(운영 값은 배포 시 교체):


# 대시보드 요약 API(n8n→Slack) 공유 토큰
Globals.dashboard.api.token=CHANGE_ME_TOKEN

활성 프로파일은 -Dspring.profiles.active로 결정되며 globals_<profile>.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
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<AmtPoint> dayChart, List<AmtPoint> 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<AgentStat> 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:

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<AmtPoint> dayChart = new ArrayList<AmtPoint>();
        private List<AmtPoint> monthChart = new ArrayList<AmtPoint>();
        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<AgentStat> agents = new ArrayList<AgentStat>();
        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
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:

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:

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<MjonPayVO> 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<MjonPayVO> 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<MberManageVO> apply = conectStatsService.selectMberCmpHstStatsDashboard(req);
        req.setHstSttus("02");
        List<MberManageVO> approve = conectStatsService.selectMberCmpHstStatsDashboard(req);
        req.setHstSttus("03");
        List<MberManageVO> 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<MberManageVO> priv = conectStatsService.selectMberTotStats(totReq);
        totReq.setDept("C");
        List<MberManageVO> 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<MjonMsgStatVO> 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<MjonMsgVO> 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
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:

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")를 추가:

    @com.fasterxml.jackson.annotation.JsonProperty("isSuccess")
    public boolean isSuccess() { return isSuccess; }

    (lombok 게터와 충돌 시 lombok @Getter를 필드에서 제거하고 위 명시 게터만 둔다.)

  • 확인 후 터미널 A 종료(Ctrl+C).

  • Step 4: Commit

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:

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의 <interceptors> 블록 안(첫 <interceptor> 위, line 10 직후)에 추가:

        <!-- 대시보드 요약 API 토큰 인증 -->
        <interceptor>
            <mapping path="/api/dashboard/**"/>
            <beans:bean class="itn.com.cmm.interceptor.DashboardApiAuthInterceptor"/>
        </interceptor>

기존 IPIgnoreInterceptorHandler는 /** 매핑에 /cmm/**, /js/**, /publish/**만 제외한다. /api/dashboard/**도 그 인터셉터를 거치지만, 해당 핸들러는 특정 URI(errorLogInsert, *Ajax.do 등)에만 동작하므로 본 API 흐름에는 영향 없음(통과). 빌드 후 Step 6에서 실제 통과를 확인한다.

  • Step 3: 시큐리티 예외 추가

context-security.xml의 security="none" 목록(line 9~14 인근)에 한 줄 추가:

    <security:http pattern="/api/dashboard/**" security="none"/>

이 엔드포인트는 토큰으로 자체 보호하므로 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_<profile>.properties에 설정된 값. 로컬 기동은 보통 local 프로파일.

Run(터미널 B):

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

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:

// 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:

{
  "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:

# 대시보드 일일 리포트 → Slack (n8n)

## 구성
- `n8n-workflow.json` : import용 워크플로우 (Schedule → HTTP Request → Code → Slack)
- `code-node.js` : Code 노드에 붙여넣을 변환 스니펫

## 설정 순서
1. n8n에서 Settings → Variables/Environment에 `MJON_DASH_TOKEN` 추가
   (값 = 앱 `globals_<profile>.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
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에서 확정.