浏览代码

update 2024-02-15

junggilpark 1 年之前
父节点
当前提交
b5c3956c98
共有 27 个文件被更改,包括 945 次插入73 次删除
  1. 6 0
      pom.xml
  2. 2 1
      src/main/java/com/its/web/ItsWebServerApplication.java
  3. 67 0
      src/main/java/com/its/web/controller/admin/AdminController.java
  4. 32 0
      src/main/java/com/its/web/controller/notice/NoticeController.java
  5. 2 12
      src/main/java/com/its/web/controller/view/ViewController.java
  6. 6 0
      src/main/java/com/its/web/dto/admin/PrincipalDetail.java
  7. 27 0
      src/main/java/com/its/web/dto/notice/AttachFileDto.java
  8. 4 1
      src/main/java/com/its/web/mapper/its/notice/NoticeMapper.java
  9. 1 1
      src/main/java/com/its/web/security/WebLoginFailureHandler.java
  10. 59 38
      src/main/java/com/its/web/security/WebLoginSuccessHandler.java
  11. 36 0
      src/main/java/com/its/web/security/WebPasswordEncoder.java
  12. 17 4
      src/main/java/com/its/web/security/WebSecurityConfig.java
  13. 55 2
      src/main/java/com/its/web/service/notice/NoticeService.java
  14. 3 1
      src/main/resources/application-dev.yml
  15. 3 1
      src/main/resources/application-prod.yml
  16. 11 2
      src/main/resources/mybatis/mapper/its/notice/NoticeMapper.xml
  17. 99 0
      src/main/resources/static/css/admin-main.css
  18. 84 0
      src/main/resources/static/css/login.css
  19. 100 0
      src/main/resources/static/css/notice.css
  20. 二进制
      src/main/resources/static/images/background/bg_title.png
  21. 37 6
      src/main/resources/templates/admin/login.html
  22. 61 0
      src/main/resources/templates/admin/main.html
  23. 64 0
      src/main/resources/templates/admin/notice-list.html
  24. 91 0
      src/main/resources/templates/admin/notice-view.html
  25. 1 2
      src/main/resources/templates/center/way.html
  26. 44 0
      src/main/resources/templates/include/admin-header.html
  27. 33 2
      src/main/resources/templates/notice/view.html

+ 6 - 0
pom.xml

@@ -212,6 +212,12 @@
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-security</artifactId>
         </dependency>
+        <!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-oauth2-client -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-oauth2-client</artifactId>
+            <version>2.4.13</version>
+        </dependency>
 
         <dependency>
             <groupId>javax.servlet</groupId>

+ 2 - 1
src/main/java/com/its/web/ItsWebServerApplication.java

@@ -2,8 +2,9 @@ package com.its.web;
 
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;
 
-@SpringBootApplication
+@SpringBootApplication(exclude = SecurityAutoConfiguration.class)
 public class ItsWebServerApplication {
 
     public static void main(String[] args) {

+ 67 - 0
src/main/java/com/its/web/controller/admin/AdminController.java

@@ -0,0 +1,67 @@
+package com.its.web.controller.admin;
+
+import com.its.web.service.notice.NoticeService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.ibatis.annotations.Param;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import javax.annotation.Nullable;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+import java.io.IOException;
+
+@Slf4j
+@RequiredArgsConstructor
+@Controller
+@RequestMapping(value="/phits")
+@Api(tags = "99-2.운영자 화면 이동")
+public class AdminController {
+    private final NoticeService noticeService;
+
+    @ApiOperation(value = "00.로그인")
+    @GetMapping("")
+    public String login(HttpSession session,
+                        Model model,
+                        HttpServletResponse res,
+                        @Nullable @Param("LoginFail") String loginFail) throws IOException {
+        model.addAttribute("loginFail", loginFail);
+        return "admin/login";
+    }
+
+    @ApiOperation(value = "00.메인화면")
+    @GetMapping("/main")
+    public String adminMain(Model model) {
+        model.addAttribute("notice", this.noticeService.findMainNotice(5));
+        return "admin/main";
+    }
+
+    @ApiOperation(value = "06.운영자 - 01.공지사항 - 01.공지사항 목록")
+    @GetMapping("/notice-list")
+    public String adminNoticeList(Model model, @Nullable @Param("page") String page,
+                                  @Nullable @Param("searchType") String searchType,
+                                  @Nullable @Param("searchText")String searchText) {
+        model.addAttribute("list", this.noticeService.findAllList(page, searchType, searchText));
+        return "admin/notice-list";
+    }
+
+    @ApiOperation(value = "06.운영자 - 01.공지사항 - 02.공지사항 상세내용")
+    @GetMapping("/notice-view/{boardNo}")
+    public String adminNoticeView(Model model, @PathVariable("boardNo")String boardNo) {
+        model.addAttribute("notice", this.noticeService.findNotice(boardNo));
+        return "admin/notice-view";
+    }
+
+    @ApiOperation(value = "06.운영자 - 01.공지사항 - 03.공지사항 글쓰기")
+    @GetMapping("/notice-write")
+    public String adminNoticeWrite(Model model) {
+        model.addAttribute("notice", this.noticeService.findMainNotice(5));
+        return "admin/main";
+    }
+}

+ 32 - 0
src/main/java/com/its/web/controller/notice/NoticeController.java

@@ -0,0 +1,32 @@
+package com.its.web.controller.notice;
+
+import com.its.web.dto.notice.AttachFileDto;
+import com.its.web.service.notice.NoticeService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.ibatis.annotations.Param;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.bind.annotation.RestController;
+
+@Slf4j
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/notice")
+@Api(tags = "04.공지사항")
+public class NoticeController {
+
+    private final NoticeService service;
+
+    @ApiOperation(value = "01.첨부파일 다운로드")
+    @PostMapping(value = "/attach", produces = {"application/json; charset=utf8"})
+    @ResponseBody
+    public AttachFileDto getAttachFile(@Param("boardNo") String boardNo,
+                                       @Param("fileId") String fileId,
+                                       @Param("fileName") String fileName) {
+        return this.service.getAttachFile(boardNo, fileId, fileName);
+    }
+}

+ 2 - 12
src/main/java/com/its/web/controller/view/ViewController.java

@@ -19,15 +19,12 @@ import javax.annotation.Nullable;
 @Slf4j
 @RequiredArgsConstructor
 @Controller
-@Api(tags = "99.화면 이동")
+@Api(tags = "99-1.사용자 화면 이동")
 public class ViewController {
 
     private final TrafficService trafficService;
-
     private final StatisticsService statisticsService;
-
     private final CommonService commonService;
-
     private final NoticeService noticeService;
 
     @ApiOperation(value = "01.메인화면")
@@ -35,7 +32,7 @@ public class ViewController {
     public String main(Model model) {
         model.addAttribute("selected", "");
         model.addAttribute("incident", this.trafficService.findMainIncident());
-        model.addAttribute("notice", this.noticeService.findMainNotice());
+        model.addAttribute("notice", this.noticeService.findMainNotice(3));
         return "main/main";
     }
 
@@ -144,11 +141,4 @@ public class ViewController {
         model.addAttribute("notice", this.noticeService.findNotice(boardNo));
         return "notice/view";
     }
-
-    @ApiOperation(value = "06.운영자 - 01.로그인")
-    @GetMapping("/phits")
-    public String login(Model model, @Nullable @Param("LoginFail") String loginFail) {
-        model.addAttribute("loginFail", loginFail);
-        return "admin/login";
-    }
 }

+ 6 - 0
src/main/java/com/its/web/dto/admin/PrincipalDetail.java

@@ -1,11 +1,13 @@
 package com.its.web.dto.admin;
 
+import lombok.ToString;
 import org.springframework.security.core.GrantedAuthority;
 import org.springframework.security.core.userdetails.UserDetails;
 
 import java.util.ArrayList;
 import java.util.Collection;
 
+@ToString
 public class PrincipalDetail implements UserDetails {
     private TbWwwMemberDto user;
 
@@ -13,6 +15,10 @@ public class PrincipalDetail implements UserDetails {
         this.user = user;
     }
 
+    public TbWwwMemberDto getUser() {
+        return this.user;
+    }
+
     @Override
     public Collection<? extends GrantedAuthority> getAuthorities() {
         Collection<GrantedAuthority> collectors = new ArrayList<>();

+ 27 - 0
src/main/java/com/its/web/dto/notice/AttachFileDto.java

@@ -0,0 +1,27 @@
+package com.its.web.dto.notice;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@ApiModel("AttachFileDto(공지사항 첨부파일 Dto)")
+public class AttachFileDto {
+    @ApiModelProperty("공지사항 ID")
+    @JsonProperty("board_no")
+    private String boardNo;
+
+    @ApiModelProperty("전송 메시지")
+    @JsonProperty("message")
+    private String message;
+
+    @ApiModelProperty("첨부 파일")
+    @JsonProperty("attach_file")
+    private byte[] attachFile;
+
+}

+ 4 - 1
src/main/java/com/its/web/mapper/its/notice/NoticeMapper.java

@@ -5,6 +5,7 @@ import com.its.web.pagination.Pagination;
 import org.apache.ibatis.annotations.Mapper;
 
 import java.util.List;
+import java.util.Map;
 
 @Mapper
 public interface NoticeMapper {
@@ -15,5 +16,7 @@ public interface NoticeMapper {
 
     List<NoticeDto> findAllNotice(Pagination page);
 
-    List<NoticeDto> findMainNotice();
+    List<NoticeDto> findMainNotice(int num);
+
+    int getAttachFileCount(Map<String, String> paramMap);
 }

+ 1 - 1
src/main/java/com/its/web/security/WebLoginFailureHandler.java

@@ -44,8 +44,8 @@ public class WebLoginFailureHandler implements AuthenticationFailureHandler {
         }
         log.error("{}: {}, {}", key, message, request.getParameter("username"));
         request.setAttribute(key, message);
-
         message = URLEncoder.encode(message, "UTF-8");
         response.sendRedirect("/phits?LoginFail=" + message);
+//        request.getRequestDispatcher("/phits").forward(request, response);
     }
 }

+ 59 - 38
src/main/java/com/its/web/security/WebLoginSuccessHandler.java

@@ -1,5 +1,6 @@
 package com.its.web.security;
 
+import com.its.web.dto.admin.PrincipalDetail;
 import com.its.web.dto.admin.TbWwwMemberDto;
 import com.its.web.mapper.its.admin.AdminMapper;
 import lombok.AllArgsConstructor;
@@ -16,7 +17,9 @@ import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import javax.servlet.http.HttpSession;
 import java.io.IOException;
+import java.text.SimpleDateFormat;
 import java.util.ArrayList;
+import java.util.Date;
 import java.util.List;
 
 @Slf4j
@@ -29,8 +32,9 @@ public class WebLoginSuccessHandler implements AuthenticationSuccessHandler {
 
     @Override
     public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
-
-//        String currSysTime = ItsUtils.getSysTime();
+        SimpleDateFormat sdfDate = new SimpleDateFormat("yyyyMMddHHmmss");
+        Date dtNow = new Date();
+        String currSysTime = sdfDate.format(dtNow);
         // IP, 세션 ID
         WebAuthenticationDetails details = (WebAuthenticationDetails)authentication.getDetails();
 
@@ -44,43 +48,28 @@ public class WebLoginSuccessHandler implements AuthenticationSuccessHandler {
             log.info("login Roll: {}", auth.getAuthority());
         }
 
-        TbWwwMemberDto userInfr = (TbWwwMemberDto)authentication.getPrincipal();
-//        String remoteIp = ItsUtils.getHttpServletRemoteIP(request);
-//        TbUserCnncHs cnncHs = TbUserCnncHs.builder()
-//                .operSystId(userInfr.getOperSystId())
-//                .userId(userInfr.getUserId())
-//                .loginHms(ItsUtils.getSysTime())
-//                .logoutHms("")
-//                .build();
-//        this.cnncHsRepo.insertData(cnncHs.getOperSystId(), cnncHs.getLoginHms(), cnncHs.getUserId(), cnncHs.getLogoutHms());
-//        log.info("Login History: {}, {}", cnncHs, remoteIp);
-//
-//        userInfr.setLoginHms(cnncHs.getLoginHms());
-//        userInfr.setLogoutHms("");
-//
-//        String uri = WebConstants.DEFAULT_URI;
-//        String domain = "/";
-//
-//        HttpSession session = request.getSession(false); // 세션을 생성 하지 않음
-//        if (session != null) {
-//            log.info("Session[{}}] [{}], Login Authentication: User: {}, {}, {}, Login History: {}, {}",
-//                    session.getId(), currSysTime, userInfr.getUserId(), userInfr.getOperSystId(), userInfr.getLoginHms(), remoteIp, cnncHs.getLoginHms());
-//        } else {
-//            session = request.getSession(true); // 새로운 세션을 생성
-//            session.setMaxInactiveInterval(WebConstants.MAX_INACTIVE_SESSION_TIMEOUT);
-//            log.info("Session[{}] [{}], Login Authentication, Session Create: User: {}, {}, {}, {}, Login History: {}, {}",
-//                    session.getId(), currSysTime, userInfr.getUserId(), userInfr.getOperSystId(), userInfr.getLoginHms(), session.getMaxInactiveInterval(), remoteIp, cnncHs.getLoginHms());
-//        }
-//        session.setAttribute(WebConstants.USER_UUID, WebMvcConfig.encUserId(cnncHs.getUserId()));
-//        session.setAttribute(WebConstants.USER_TIME, cnncHs.getLoginHms());
-//        session.setAttribute(WebConstants.LOGIN_USER, userInfr);
-//
-//        CookieUtils.setCookie(response, WebConstants.USER_UUID, WebMvcConfig.encUserId(cnncHs.getUserId()), 60*60, domain);
-//        CookieUtils.setCookie(response, WebConstants.USER_TIME, cnncHs.getLoginHms(), 60*60, domain);
-//        CookieUtils.setCookie(response, WebConstants.USER_OPER_SYST_ID, userInfr.getOperSystId(), 60*60, domain);
+        PrincipalDetail principal = (PrincipalDetail)authentication.getPrincipal();
+        TbWwwMemberDto userInfr = principal.getUser();
+
+        String remoteIp = this.getHttpServletRemoteIP(request);
+        log.info("Login History: {}, {}", currSysTime, remoteIp);
+
+        HttpSession session = request.getSession(false); // 세션을 생성 하지 않음
+        if (session != null) {
+            log.info("Session[{}}] [{}], Login Authentication: User: {}, {}, Login History: {}, {}",
+                    session.getId(), currSysTime, userInfr.getEmail(), userInfr.getMemberNm(), remoteIp, currSysTime);
+        } else {
+            session = request.getSession(true); // 새로운 세션을 생성
+            session.setMaxInactiveInterval(60);
+            log.info("Session[{}] [{}], Login Authentication, Session Create: User: {}, {}, {}, Login History: {}, {}",
+                    session.getId(), currSysTime, userInfr.getEmail(), userInfr.getMemberNm(), currSysTime, session.getMaxInactiveInterval(), remoteIp, currSysTime);
+        }
+        session.setAttribute("UUID", userInfr.getEmail());
+        session.setAttribute("TIME", currSysTime);
+        session.setAttribute("NAME", userInfr.getMemberNm());
 
-//        clearAuthenticationAttributes(request);
-//        response.sendRedirect(uri);
+        clearAuthenticationAttributes(request);
+        response.sendRedirect("/phits/main");
     }
 
     /**
@@ -97,4 +86,36 @@ public class WebLoginSuccessHandler implements AuthenticationSuccessHandler {
 
         session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
     }
+
+    public static String getHttpServletRemoteIP(HttpServletRequest request) {
+        if (request == null) {
+            return "";
+        }
+
+        String ipAddress = request.getHeader("X-FORWARDED-FOR");
+        // proxy 환경일 경우
+        if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
+            ipAddress = request.getHeader("Proxy-Client-IP");
+        }
+        // 웹로직 서버일 경우
+        if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
+            ipAddress = request.getHeader("WL-Proxy-Client-IP");
+        }
+        if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
+            ipAddress = request.getHeader("HTTP_CLIENT_IP");
+        }
+        if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
+            ipAddress = request.getHeader("HTTP_X_FORWARDED_FOR");
+        }
+        // 기타
+        if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
+            ipAddress = request.getRemoteAddr() ;
+        }
+        //-Djava.net.preferIPv4Stack=true
+        if (ipAddress.equals("0:0:0:0:0:0:0:1"))   //==> ipv6 <== default
+        {
+            ipAddress = "127.0.0.1";   //==> localhost
+        }
+        return ipAddress;
+    }
 }

+ 36 - 0
src/main/java/com/its/web/security/WebPasswordEncoder.java

@@ -0,0 +1,36 @@
+package com.its.web.security;
+
+import com.google.common.hash.Hashing;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang.StringUtils;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+import java.nio.charset.StandardCharsets;
+
+@Slf4j
+public class WebPasswordEncoder implements PasswordEncoder {
+    @Override
+    public String encode(CharSequence charSequence) {
+        if (charSequence == null) {
+            throw new IllegalArgumentException("Encode Password Cannot be null");
+        }
+        return charSequence.toString();
+    }
+
+    @Override
+    public boolean matches(CharSequence charSequence, String s) {
+        if (charSequence == null) {
+            throw new IllegalArgumentException("rawPassword cannot be null");
+        }
+
+        if (s == null || s.length() == 0) {
+            log.warn("Empty encoded password");
+            return false;
+        }
+        // 사용자가 입력한 비밀번호는 암호화가 되어있지 않기 때문에 암호화를 한 후
+        // 데이터베이스의 비밀번호와 비교해야 함.
+        String rawEncodedPassword = Hashing.sha256().hashString(charSequence, StandardCharsets.UTF_8).toString();
+
+        return StringUtils.equals(rawEncodedPassword, s);
+    }
+}

+ 17 - 4
src/main/java/com/its/web/security/WebSecurityConfig.java

@@ -8,6 +8,9 @@ import org.springframework.context.annotation.Configuration;
 import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.core.session.SessionRegistry;
+import org.springframework.security.core.session.SessionRegistryImpl;
+import org.springframework.security.crypto.password.PasswordEncoder;
 import org.springframework.security.web.SecurityFilterChain;
 
 @Slf4j
@@ -27,21 +30,31 @@ public class WebSecurityConfig {
                 .antMatchers("/swagger-ui.html", "/swagger/**", "/swagger-resources/**", "/webjars/**", "/v2/api-docs").permitAll()
                 .antMatchers("/", "/trafficMap/**", "/api/**", "/notice/**", "/center/**", "/statistics/**").permitAll()
                 .antMatchers("/images/**", "/css/**", "/js/**", "/font/**").permitAll()
-                .antMatchers("/phits").hasIpAddress("192.168.20.46")
+                .antMatchers("/phits").hasIpAddress("127.0.0.1")
                 .anyRequest().authenticated();
         http.formLogin()
                 .loginPage("/phits")
                 .loginProcessingUrl("/phits/login")
                 .failureUrl("/")
-                .successHandler(this.webLoginSuccessHandler);
-//                .failureHandler(this.webLoginFailureHandler);
+                .successHandler(this.webLoginSuccessHandler)
+                .failureHandler(this.webLoginFailureHandler);
         return http.build();
     }
 
+    @Bean
+    public PasswordEncoder passwordEncoder() {
+        return new WebPasswordEncoder();
+    }
+
+    @Bean
+    public SessionRegistry sessionRegistry() {
+        return new SessionRegistryImpl();
+    }
+
     public DaoAuthenticationProvider daoAuthenticationProvider() {
         DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
         authenticationProvider.setUserDetailsService(this.loginService);
-//        authenticationProvider.setPasswordEncoder(passwordEncoder());
+        authenticationProvider.setPasswordEncoder(passwordEncoder());
         // loadUserByUsername 의 UsernameNotFoundException 이 BadCredentialsException 로 발생함.
         // Exception 을 catch 하기 위해서는 아래를 false 로 설정하면 됨.
         authenticationProvider.setHideUserNotFoundExceptions(true);

+ 55 - 2
src/main/java/com/its/web/service/notice/NoticeService.java

@@ -1,13 +1,19 @@
 package com.its.web.service.notice;
 
+import com.its.web.dto.notice.AttachFileDto;
 import com.its.web.dto.notice.NoticeDto;
 import com.its.web.mapper.its.notice.NoticeMapper;
 import com.its.web.pagination.Pagination;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.io.FileUtils;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 
+import java.io.*;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 @Slf4j
 @RequiredArgsConstructor
@@ -15,6 +21,9 @@ import java.util.List;
 public class NoticeService {
     private final NoticeMapper mapper;
 
+    @Value("${board-location}")
+    String boardLocation;
+
     public Pagination findAllList(String page, String searchType, String searchText) {
         Pagination pagination = new Pagination(page, this.mapper.getNoticeTotalCount(), searchType, searchText);
         List<NoticeDto> list = this.mapper.findAllNotice(pagination);
@@ -32,7 +41,51 @@ public class NoticeService {
         }
     }
 
-    public List<NoticeDto> findMainNotice() {
-        return this.mapper.findMainNotice();
+    public List<NoticeDto> findMainNotice(int num) {
+        return this.mapper.findMainNotice(num);
+    }
+
+    public AttachFileDto getAttachFile(String boardNo, String fileId, String fileName) {
+        AttachFileDto dto = new AttachFileDto();
+        dto.setBoardNo(boardNo);
+        String message = null;
+        if (boardNo != null && fileId != null) {
+            Map<String, String> paramMap = new HashMap<>();
+
+            paramMap.put("boardNo", boardNo);
+            paramMap.put("fileId", fileId);
+            int result = this.mapper.getAttachFileCount(paramMap);
+            if (result > 0) {
+                log.info("boardLocation : {}", boardLocation);
+                File file = new File(boardLocation, fileId);
+                int fileSize = (int) file.length();
+
+                if (fileSize > 0) {
+                    try {
+                        byte[] byteArray = FileUtils.readFileToByteArray(file);
+                        if (byteArray.length > 0) {
+                            int i = 0;
+                            dto.setAttachFile(new byte[byteArray.length]);
+                            for(byte b : byteArray) {
+                                dto.getAttachFile()[i++] = b;
+                            }
+                            message = "파일 전송 완료.";
+                        }
+                        else {
+                            message = "파일을 찾을 수 없습니다.";
+                        }
+                    }
+                    catch (FileNotFoundException e) {
+                        log.error("File Not FoundException");
+                        message = "파일을 찾을 수 없습니다.";
+                    } catch (IOException e) {
+                        log.error("IOException");
+                        message = "파일을 읽는 중 오류가 발생하였습니다.";
+                    }
+                }
+            }
+        }
+        dto.setMessage(message);
+        return dto;
     }
 }

+ 3 - 1
src/main/resources/application-dev.yml

@@ -17,4 +17,6 @@ spring:
       idleTimeout: 30000
       connectTimeout: 10000
 
-kakao-url: //dapi.kakao.com/v2/maps/sdk.js?appkey=89c10f45ef100270bc75a54eb9e5b0ca&libraries=drawing&libraries=clusterer
+kakao-url: //dapi.kakao.com/v2/maps/sdk.js?appkey=89c10f45ef100270bc75a54eb9e5b0ca&libraries=drawing&libraries=clusterer
+popup-location: C:\00.PROJECT\24.01.PHITS-WEB\uploads\popup
+board-location: C:\00.PROJECT\24.01.PHITS-WEB\uploads\board

+ 3 - 1
src/main/resources/application-prod.yml

@@ -16,4 +16,6 @@ spring:
       maximumPoolSize: 20
       idleTimeout: 30000
       connectTimeout: 10000
-kakao-url: //dapi.kakao.com/v2/maps/sdk.js?appkey=818515fbf1c2ac66fdac8c66163c7a3e&libraries=drawing&libraries=clusterer
+kakao-url: //dapi.kakao.com/v2/maps/sdk.js?appkey=818515fbf1c2ac66fdac8c66163c7a3e&libraries=drawing&libraries=clusterer
+popup-location: C:\00.PROJECT\24.01.PHITS-WEB\uploads\popup
+board-location: C:\00.PROJECT\24.01.PHITS-WEB\uploads\board

+ 11 - 2
src/main/resources/mybatis/mapper/its/notice/NoticeMapper.xml

@@ -113,7 +113,7 @@
            AND BOARDID = 1
     </delete>
 
-    <select id="findMainNotice" resultType="com.its.web.dto.notice.NoticeDto">
+    <select id="findMainNotice" parameterType="java.lang.Integer" resultType="com.its.web.dto.notice.NoticeDto">
         SELECT
             B.boardno         as board_no,
             B.regdate         as reg_date,
@@ -128,7 +128,16 @@
                     from TB_WWW_BOARD
                     order by regdate desc) A
              ) B
-        where ROWNUM BETWEEN 1 AND 3
+        where ROWNUM BETWEEN 1 AND #{num}
+    </select>
+
+    <select id="getAttachFileCount" parameterType="java.util.HashMap" resultType="java.lang.Integer">
+        SELECT
+            COUNT(*)
+        FROM
+            TB_WWW_BOARD
+        WHERE BOARDNO = #{boardNo}
+          AND ATTACHFILEID LIKE '%'||#{fileId}||'%'
     </select>
 
 </mapper>

+ 99 - 0
src/main/resources/static/css/admin-main.css

@@ -0,0 +1,99 @@
+/* 메인화면 */
+
+.mainWrap {
+    overflow: auto;
+    width: 100%;
+    height: calc(100% - 208px);
+    display: flex;
+    -webkit-box-pack: center;
+    justify-content: center;
+}
+
+.mainWrap .main {
+    max-width: 1200px;
+    width: 95%;
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    padding: 50px 0px;
+    -webkit-box-pack: center;
+    justify-content: center;
+    min-height: 529px;
+}
+
+.mainWrap .main .top {
+    display: flex;
+    align-items: center;
+    width: 100%;
+    height: calc(45% - 5px);
+    margin-bottom: 5px;
+}
+.mainWrap .main .top > div:nth-child(1) {
+    margin-right: 5px;
+}
+
+.mainWrap .main .top > div:nth-child(2) {
+    margin-left: 5px;
+}
+
+.mainWrap .main .top > div {
+    width: calc(50% - 5px);
+    height: 100%;
+    border: 1px solid #eeeeee;
+    box-shadow: rgba(0, 0, 0, 0.16) 0px 3px 6px;
+    padding: 20px;
+}
+
+.mainWrap .main .bottom {
+    margin-top: 5px;
+    display: flex;
+    flex-direction: column;
+    padding: 20px;
+    width: 100%;
+    height: calc(55% - 5px);
+    border: 1px solid #eeeeee;
+    box-shadow: rgba(0, 0, 0, 0.16) 0px 3px 6px;
+}
+
+.content {
+    width: 100%;
+}
+
+.content > div {
+    display: flex;
+    align-items: center;
+    width: 100%;
+    padding : 10px;
+    border-bottom : 1px solid black;
+    font-weight: bold;
+    justify-content: space-between;
+}
+.content > div > div {
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    overflow: hidden;
+}
+
+.sub-title {
+    color: #007fce;
+    font-weight: bold;
+    padding: 5px 0 10px 0;
+}
+
+@media (max-width: 450px) {
+}
+
+@media (min-width: 420px) {
+
+
+}
+
+@media (min-width: 765px) {
+}
+
+@media (min-width: 920px) {
+}
+
+
+@media (max-height: 900px) {
+}

+ 84 - 0
src/main/resources/static/css/login.css

@@ -1,3 +1,4 @@
+
 .loginWrap {
     width: 100%;
     height: calc(100% - 98px);
@@ -5,4 +6,87 @@
     align-items: center;
     justify-content: center;
     flex-direction: column;
+    min-height: 420px;
+    overflow: auto;
+}
+
+.loginWrap .container {
+    height: 100%;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    flex-direction: column;
+}
+
+.logo {
+    display: flex;
+    -webkit-box-align: center;
+    align-items: center;
+    cursor: pointer;
+    padding-bottom: 2rem;
+}
+
+.logo > img {
+    width: auto;
+    height: 50px;
+    margin-right: 1rem;
+}
+
+.logo > p {
+    font-size: 24px;
+    font-weight: bold;
+}
+.title {
+    padding : 2rem;
+    font-weight: bold;
+    color : rgb(102, 102, 102);
+    font-size: 20px;
+}
+
+.loginWrap input {
+    width: 72%;
+    height: 50px;
+    padding: 0.75rem;
+    margin-bottom: 1rem;
+    border: 1px solid rgb(102, 186, 204);
+    border-radius: 10px;
+    font-size: 18px;
+    text-align: center;
+    outline-color: rgb(102, 186, 204);
+}
+
+.loginWrap input::placeholder {
+    text-align: center;
+    color: rgb(102, 186, 204);
+}
+
+.loginWrap .button {
+    display: inline-block;
+    width: 72%;
+    height: 50px;
+    color: rgb(255, 255, 255);
+    margin: 50px 0px 0px;
+    padding: 0.75rem;
+    border: 0px;
+    border-radius: 10px;
+    box-shadow: rgba(0, 0, 0, 0.15) 0px 2px 4px;
+    background-color: rgb(51, 102, 171);
+    text-align: center;
+    line-height: calc(50px - 1.5rem);
+}
+
+.loginWrap .button:hover {
+    filter: brightness(1.1);
+    cursor: pointer;
+}
+
+@media (max-width: 720px) {
+    .loginWrap {
+        height: calc(100% - 106px);
+    }
+}
+@media (max-width: 560px) {
+    .loginWrap {
+        height: calc(100% - 117px);
+    }
 }

+ 100 - 0
src/main/resources/static/css/notice.css

@@ -15,6 +15,7 @@
     font-weight: bold;
     font-size: 18px;
 }
+
 .menu > div:not(:first-child) {
     margin-left: 30px;
 }
@@ -71,6 +72,15 @@
     justify-content: center;
     overflow: auto;
 }
+.noticeWrap .admin-header {
+    width: 200px;
+    height: 50px;
+    margin: 24px auto;
+    color: white;
+    text-align: center;
+    font: bold 18px / 40px NanumGothic;
+    background: url('/images/background/bg_title.png') 0px 0px / contain no-repeat transparent;
+}
 
 .noticeWrap .container {
     max-width: 1200px;
@@ -98,6 +108,68 @@
     height: calc(100% - 101px);
 }
 
+.noticeWrap .admin-content {
+    width: 100%;
+    height: calc(100% - 150px);
+    min-height: 500px;
+    padding: 30px 60px;
+    transition: all 0.3s ease 0s;
+    background-color: rgb(255, 255, 255);
+    box-shadow: rgba(0, 0, 0, 0.15) 0px 3px 6px;
+}
+
+.noticeWrap .admin-content .list a{
+    list-style: none;
+    display: flex;
+    -webkit-box-pack: justify;
+    justify-content: space-between;
+    padding: 0.5rem;
+    border-bottom: 2px solid lightgrey;
+    gap: 15px;
+}
+.noticeWrap .admin-content .list {
+    padding: 0;
+    margin: 0;
+    height: calc(100% - 126px);
+    overflow: auto;
+}
+.noticeWrap .content.admin-view:hover {
+    box-shadow: rgba(0, 0, 0, 0.19) 0px 6px 12px;
+}
+.noticeWrap .content.admin-view .button-box {
+    padding: 16px 0px;
+    display: flex;
+    -webkit-box-pack: justify;
+    justify-content: space-between;
+}
+.noticeWrap .content.admin-view {
+    width: 100%;
+    height: calc(100% - 150px);
+    min-height: 500px;
+    padding: 30px 60px;
+    transition: all 0.3s ease 0s;
+    background-color: rgb(255, 255, 255);
+    box-shadow: rgba(0, 0, 0, 0.15) 0px 3px 6px;
+    margin: 0;
+}
+.noticeWrap .admin-content .title {
+    padding: 16px 0px;
+    display: flex;
+    justify-content: right;
+    border-bottom: 1px solid rgb(33, 84, 153);
+}
+
+.noticeWrap .admin-content .title a {
+    background: rgb(51, 102, 171);
+    box-shadow: rgba(0, 0, 0, 0.15) 0px 3px 6px;
+    border: none;
+    padding: 4px 16px;
+    color: rgb(255, 255, 255);
+}
+.noticeWrap .admin-content .title a:hover {
+    filter: brightness(1.1);
+}
+
 .noticeWrap .content.list {
     margin: 0px;
     padding: 0px;
@@ -157,7 +229,29 @@
     cursor: pointer;
     font-weight: bold;
 }
+.bl-button {
+    background: rgb(51, 102, 171);
+    box-shadow: rgba(0, 0, 0, 0.15) 0px 3px 6px;
+    border: none;
+    margin: 0px 10px 0px 0px;
+    padding: 4px 16px;
+    color: rgb(255, 255, 255);
+    cursor: pointer;
+}
+
+.bl-button:hover,
+.wt-button:hover{
+    filter: brightness(1.1);
+}
 
+.wt-button{
+    background: rgb(204, 234, 234);
+    box-shadow: rgba(0, 0, 0, 0.15) 0px 3px 6px;
+    border: none;
+    padding: 4px 16px;
+    color: rgb(0, 0, 0);
+    cursor: pointer;
+}
 .noticeWrap .content .view-box > div{
     display: flex;
     flex-direction: column;
@@ -184,6 +278,11 @@
     font-size: 16px;
     outline: none!important;
 }
+.noticeWrap .content .view-box .b_content.modify,
+.noticeWrap .content .view-box .title.modify {
+    background: rgba(204, 234, 234, 0.3);
+}
+
 .noticeWrap .content .view-box .attach-box .attach{
     height: 40px;
     padding: 8px 16px;
@@ -287,6 +386,7 @@
         padding: 1rem 0;
     }
 
+
     .noticeWrap .container {
         min-height: 0;
     }

二进制
src/main/resources/static/images/background/bg_title.png


+ 37 - 6
src/main/resources/templates/admin/login.html

@@ -12,12 +12,43 @@
 </head>
 <body id="body">
 <div class="loginWrap">
-    <div></div>
-    <div>관리자</div>
-    <input name="username" placeholder="ID">
-    <input name="password" placeholder="비밀번호">
-    <div>로그인</div>
+    <form class="container" method="post" th:action="@{/phits/login}">
+        <div class="logo">
+            <img src="/images/logo/logo.png">
+            <p>포항시교통정보센터</p>
+        </div>
+        <div class="title">관리자</div>
+        <input id="id" type="text" name="username" placeholder="ID">
+        <input id="pwd" type="password" name="password" placeholder="비밀번호">
+        <div onclick="login()" class="button">로그인</div>
+    </form>
 </div>
 <th:block th:include="/include/footer.html"></th:block>
 </body>
-</html>
+</html>
+
+<script>
+    let loginFail = "";
+    loginFail = "[[${param.LoginFail}]]";
+    if (loginFail) {
+        alert(loginFail);
+    }
+    function login() {
+        const $id = $('#id');
+        const $pwd = $('#pwd');
+        if (isNull($id.val())) {
+            alert("ID를 입력해주세요");
+            $id.focus();
+            return
+        }
+
+        if (isNull($pwd.val())) {
+            alert("비밀번호를 입력해주세요");
+            $pwd.focus();
+            return
+        }
+
+        const form =  $('.container');
+        form.submit();
+    }
+</script>

+ 61 - 0
src/main/resources/templates/admin/main.html

@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<html lang="en" xmlns:th=http://www.thymeleaf.org>
+
+<head>
+    <meta charset="utf-8"/>
+    <meta name="viewport" content="width=device-width,initial-scale=1"/>
+    <meta name="theme-color" content="#000000"/>
+    <meta name="description" content="포항시 교통정보센터입니다"/>
+    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
+    <title>포항시 교통정보센터</title>
+    <th:block th:include="/include/head.html"></th:block>
+    <link rel="stylesheet" th:href="@{/css/admin-main.css}">
+    <script th:type="javascript" th:src="@{/js/highcharts.js}"></script>
+    <script th:type="javascript" th:src="@{/js/modules/accessibility.js}"></script>
+    <script th:type="javascript" th:src="@{/js/modules/export-data.js}"></script>
+    <script th:type="javascript" th:src="@{/js/modules/exporting.js}"></script>
+</head>
+<body id="body">
+<th:block th:include="/include/admin-header.html"></th:block>
+<div class="mainWrap">
+    <div class="main">
+        <div class="top">
+            <div>
+                <div class="sub-title">공지사항</div>
+                <div class="content">
+                    <div th:each="item, i:${notice}">
+                        <div th:onclick="movePath([['/notice/view/'+ ${item.getBoardNo()}]])" th:title="${item.getBSubject()}" th:text="${item.getBSubject()}"></div>
+                        <span th:text="${item.getRegDate()}"></span>
+                    </div>
+                    <div th:each="item, i:${notice}">
+                        <div th:onclick="movePath([['/notice/view/'+ ${item.getBoardNo()}]])"
+                             th:title="${item.getBSubject()}"
+                             th:text="${item.getBSubject()}"></div>
+                        <span th:text="${item.getRegDate()}"></span>
+                    </div>
+                    <div>
+                        <div th:title="${notice.get(0).getBSubject()}"  th:text="${notice.get(0).getBSubject()}"></div>
+                        <span th:text="${notice.get(0).getRegDate()}"></span>
+                    </div>
+                </div>
+            </div>
+            <div>
+                <div class="sub-title">팝업공지</div>
+                <div></div>
+            </div>
+        </div>
+        <div class="bottom">
+            <div class="sub-title">접속자 통계</div>
+            <div id="chart"></div>
+        </div>
+    </div>
+</div>
+<th:block th:include="/include/footer.html"></th:block>
+
+</body>
+
+<script th:inline="javascript">
+
+</script>
+
+</html>

+ 64 - 0
src/main/resources/templates/admin/notice-list.html

@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<html lang="en" xmlns:th=http://www.thymeleaf.org>
+<head>
+    <meta charset="utf-8"/>
+    <meta name="viewport" content="width=device-width,initial-scale=1"/>
+    <meta name="theme-color" content="#000000"/>
+    <meta name="description" content="포항시 교통정보센터입니다"/>
+    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
+    <title>포항시 교통정보센터</title>
+    <th:block th:include="/include/head.html"></th:block>
+    <link rel="stylesheet" th:href="@{/css/notice.css}">
+</head>
+<body id="body">
+<th:block th:include="/include/admin-header.html"></th:block>
+<div class="noticeWrap">
+    <div class="container">
+        <h2 class="admin-header">공지사항</h2>
+        <div class="admin-content">
+            <div class="title">
+                <a th:href="@{/phits/notice-write}">글쓰기</a>
+            </div>
+            <div class="list">
+                <a th:each="item : ${list.getList()}" th:href="@{'/phits/notice-view/'+${item.getBoardNo()}}" style="display: flex;">
+                    <div th:text="${item.getBSubject()}"></div>
+                    <div class="item-right">
+                        <div th:text="${item.getRegDate()}"></div>
+                        <div th:text="${item.getReadCount() + '회'}"></div>
+                    </div>
+                </a>
+            </div>
+            <div class="pagination">
+                <div class="previous">
+                    <img th:if="${list.getPrevPage() == 0}" src="/images/icon/previous.png" alt="이전">
+                    <img th:if="${list.getPrevPage() > 0}" class="active" src="/images/icon/previous_active.png" alt="이전">
+                </div>
+                <div class="pages">
+                    <a th:class="${m == list.getCurrentPage() ? 'active' : ''}"
+                       th:each="m : ${#numbers.sequence(list.getStartPage(), list.getEndPage())}"
+                       th:onclick="moveNoticePage([[${m}]])"
+                       th:text="${m}"></a>
+                </div>
+                <div class="next">
+                    <img th:if="${list.getNextPage() == 0}" src="/images/icon/next.png">
+                    <img th:if="${list.getNextPage() > 0}" class="active" src="/images/icon/next_active.png" alt="다음">
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+<th:block th:include="/include/footer.html"></th:block>
+</body>
+</html>
+
+<script>
+    function moveNoticePage(page) {
+        const activePage = $(".pages > a.active");
+        if (activePage[0] && activePage.text() == page ) {
+            return;
+        }
+        let searchText = '';
+        let searchType = '';
+        window.location.href = '/notice/list?page=' + page + '&searchText='+searchText+'&searchType=' + searchType;
+    }
+</script>

+ 91 - 0
src/main/resources/templates/admin/notice-view.html

@@ -0,0 +1,91 @@
+<!DOCTYPE html>
+<html lang="en" xmlns:th=http://www.thymeleaf.org>
+<head>
+    <meta charset="utf-8"/>
+    <meta name="viewport" content="width=device-width,initial-scale=1"/>
+    <meta name="theme-color" content="#000000"/>
+    <meta name="description" content="포항시 교통정보센터입니다"/>
+    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
+    <title>포항시 교통정보센터</title>
+    <th:block th:include="/include/head.html"></th:block>
+    <link rel="stylesheet" th:href="@{/css/notice.css}">
+</head>
+<body id="body">
+<th:block th:include="/include/admin-header.html"></th:block>
+<div class="noticeWrap">
+    <div class="container view">
+        <h2 class="admin-header">공지사항</h2>
+        <div class="content admin-view">
+            <div class="button-box">
+                <div class="bl-button" onclick="movePath('/phits/notice-list')">목록</div>
+                <div style="display: flex;">
+                    <div class="bl-button" onclick="edit()">편집</div>
+                    <div class="wt-button" onclick="delEvent()">삭제</div>
+                </div>
+            </div>
+            <div class="view-box">
+                <div>
+                    <input class="title" th:value="${notice.getBSubject()}" readonly>
+                    <textarea class="b_content" rows="16" th:text="${notice.getBContent()}" readonly></textarea>
+                    <div class="attach-box">
+                        <div class="attach">
+                            <div th:if="${notice.getAttachFile() == '||' or #strings.isEmpty(notice.getAttachFile())}">첨부파일 없음</div>
+                            <div class="attach-file" th:if="${notice.getAttachFile() != '||' and not #strings.isEmpty(notice.getAttachFile())}"
+                                 th:each="item, i : ${#strings.arraySplit(notice.getAttachFile(), '|')}"
+                                 th:text="${item}" th:title="${item + ' 다운로드'}"
+                                 th:onclick="attachFileDownload([[${i.index}]], [[${notice}]], [[${item}]])"
+                            ></div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+<th:block th:include="/include/footer.html"></th:block>
+</body>
+</html>
+<script th:inline="javascript">
+    let isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
+
+    if (isMobile) {
+        $('.b_content').attr('rows', 10);
+    }
+
+    function attachFileDownload(index, notice, fileName) {
+        let attachFileId = notice.attach_file_id;
+        if (attachFileId && attachFileId.split('|').length > 0) {
+            attachFileId = attachFileId.split('|')[index];
+        }
+        const param = {
+            boardNo : notice.board_no,
+            fileId  : attachFileId,
+        }
+        getDataAsync('/api/notice/attach', 'POST', param, null, (jsonData)=>{
+            const attachFile = jsonData.attach_file;
+            const ext = fileName.substring(fileName.lastIndexOf('.') + 1, fileName.length);
+            if (attachFile.length > 0) {
+                if (!ext) {
+                    return alert("파일 확장자명이 잘못 되었습니다.");
+                }
+                const file = new Blob([attachFile], {type: "application/" + ext});
+                const link = document.createElement('a');
+                link.href = window.URL.createObjectURL(file);
+                link.download = fileName;
+                link.click();
+            }
+            else {
+                alert(jsonData.message);
+            }
+
+        }, null);
+    }
+
+    function edit() {
+
+    }
+
+    function delEvent() {
+
+    }
+</script>

+ 1 - 2
src/main/resources/templates/center/way.html

@@ -40,7 +40,6 @@
 
 <script th:inline="javascript">
     const container = document.getElementById('map'); //지도를 담을 영역의 DOM 레퍼런스
-    // const position = new kakao.maps.LatLng(36.018993682733615, 129.34316175442146);
     const position = new kakao.maps.LatLng(36.01899593392164, 129.34316182110044);
     const options = { //지도를 생성할 때 필요한 기본 옵션
         // center: new kakao.maps.LatLng(36.0191816, 129.3432983), //지도의 중심좌표.
@@ -60,7 +59,7 @@
 
     marker.setMap(_Map);
 
-    new kakao.maps.event.addListener(marker, 'click', function(event) {
+    new kakao.maps.event.addListener(marker, 'click', function() {
         openBusSite(0);
     });
 

+ 44 - 0
src/main/resources/templates/include/admin-header.html

@@ -0,0 +1,44 @@
+<header>
+    <div class="header-menu">
+        <div class="logo" th:onclick="movePath('/phits/main')">
+            <div>
+                <img src="/images/logo/logo.png" alt="포항시 로고">
+            </div>
+            <p>포항시교통정보센터</p>
+        </div>
+        <div class="top-menu">
+            <div class="top-menu-cont" th:classappend="${selected == 'traffic'} ? ' on' : ''">
+                <a th:href="@{/phits/notice-list}">공지사항</a>
+            </div>
+            <div class="top-menu-cont" th:classappend="${selected == 'statistics'} ? ' on' : ''">
+                <a th:href="@{/phits/statistics}">접속자통계</a>
+            </div>
+            <div class="top-menu-cont">
+                <a th:href="@{/phits/popup}">팝업공지</a>
+            </div>
+            <div class="top-menu-cont">
+                <a th:href="@{/phits/cctv}">CCTV 관리</a>
+            </div>
+            <span tabindex="0" id="menu" onclick="menuOpen()">
+                <img src="/images/icon/menu.png" alt="menu">
+            </span>
+        </div>
+    </div>
+</header>
+
+<script th:inline="javascript">
+
+    function closeModal(target) {
+        $(target).css('display', 'none');
+    }
+
+    function movePath(uri) {
+        window.location.href = uri;
+    }
+
+    function openModal(target) {
+        $(target).css('display', 'flex');
+        $(target + " .content").scrollTop(0);
+    }
+
+</script>

+ 33 - 2
src/main/resources/templates/notice/view.html

@@ -24,8 +24,10 @@
                         <div class="attach">
                             <div th:if="${notice.getAttachFile() == '||' or #strings.isEmpty(notice.getAttachFile())}">첨부파일 없음</div>
                             <div class="attach-file" th:if="${notice.getAttachFile() != '||' and not #strings.isEmpty(notice.getAttachFile())}"
-                                 th:each="item : ${#strings.arraySplit(notice.getAttachFile(), '|')}"
-                                 th:text="${item}" th:title="${item + ' 다운로드'}"></div>
+                                 th:each="item, i : ${#strings.arraySplit(notice.getAttachFile(), '|')}"
+                                 th:text="${item}" th:title="${item + ' 다운로드'}"
+                                 th:onclick="attachFileDownload([[${i.index}]], [[${notice}]], [[${item}]])"
+                            ></div>
                         </div>
                         <div class="list-btn" onclick="movePath('/notice/list')">목록</div>
                     </div>
@@ -43,4 +45,33 @@
     if (isMobile) {
         $('.b_content').attr('rows', 10);
     }
+
+    function attachFileDownload(index, notice, fileName) {
+        let attachFileId = notice.attach_file_id;
+        if (attachFileId && attachFileId.split('|').length > 0) {
+            attachFileId = attachFileId.split('|')[index];
+        }
+        const param = {
+            boardNo : notice.board_no,
+            fileId  : attachFileId,
+        }
+        getDataAsync('/api/notice/attach', 'POST', param, null, (jsonData)=>{
+            const attachFile = jsonData.attach_file;
+            const ext = fileName.substring(fileName.lastIndexOf('.') + 1, fileName.length);
+            if (attachFile.length > 0) {
+                if (!ext) {
+                    return alert("파일 확장자명이 잘못 되었습니다.");
+                }
+                const file = new Blob([attachFile], {type: "application/" + ext});
+                const link = document.createElement('a');
+                link.href = window.URL.createObjectURL(file);
+                link.download = fileName;
+                link.click();
+            }
+            else {
+                alert(jsonData.message);
+            }
+
+        }, null);
+    }
 </script>