feat: 비디오 스트리밍 기능 안정화

main
thkim 2025-10-01 15:58:04 +09:00
parent bd5d965402
commit 5d10ef2575
5 changed files with 284 additions and 82 deletions

View File

@ -400,88 +400,88 @@ public class HeaderController {
@RequestMapping(value = "/web/map/mapTop.do")
public String mapTop(@RequestParam HashMap<String, Object> params, ModelMap model, HttpServletRequest request, HttpServletResponse response) throws Exception {
//request
String oPROJECT_CODE = StringUtils.defaultString((String) String.valueOf(params.get("PROJECT_CODE"))); //프로젝트코드
String GUBUN = StringUtils.defaultString((String) String.valueOf(params.get("GUBUN")));
//gubun 직접입력일때
String COORDINATE_2 = StringUtils.defaultString((String) String.valueOf(params.get("COORDINATE_2")));
String HOLE_COORDINATE = StringUtils.defaultString((String) String.valueOf(params.get("HOLE_COORDINATE")));
String oHOLE_CODE = StringUtils.defaultString((String) String.valueOf(params.get("HOLE_CODE")));
String X = StringUtils.defaultString((String) String.valueOf(params.get("X")));
String Y = StringUtils.defaultString((String) String.valueOf(params.get("Y")));
WebUtil wUtil = new WebUtil();
EgovMap map = new EgovMap();
ArrayList array = new ArrayList();
int count = 0;
String fX = "";
String fY = "";
//좌표 가져오기
//내용조회
if("".equals(oPROJECT_CODE) == false){
String sQry = "";
//시추공정보 가져오기
if("POP".equals(GUBUN) == true){
params.put("PROJECT_CODE", oPROJECT_CODE);
array = headerService.getHoleInfoTrue(params);
}else{
params.put("PROJECT_CODE", oPROJECT_CODE);
params.put("HOLE_CODE", oHOLE_CODE);
array = headerService.getHoleInfoElse(params);
//request
String oPROJECT_CODE = StringUtils.defaultString((String) String.valueOf(params.get("PROJECT_CODE"))); //프로젝트코드
String GUBUN = StringUtils.defaultString((String) String.valueOf(params.get("GUBUN")));
//gubun 직접입력일때
String COORDINATE_2 = StringUtils.defaultString((String) String.valueOf(params.get("COORDINATE_2")));
String HOLE_COORDINATE = StringUtils.defaultString((String) String.valueOf(params.get("HOLE_COORDINATE")));
String oHOLE_CODE = StringUtils.defaultString((String) String.valueOf(params.get("HOLE_CODE")));
String X = StringUtils.defaultString((String) String.valueOf(params.get("X")));
String Y = StringUtils.defaultString((String) String.valueOf(params.get("Y")));
WebUtil wUtil = new WebUtil();
EgovMap map = new EgovMap();
ArrayList array = new ArrayList();
int count = 0;
String fX = "";
String fY = "";
//좌표 가져오기
//내용조회
if("".equals(oPROJECT_CODE) == false){
String sQry = "";
//시추공정보 가져오기
if("POP".equals(GUBUN) == true){
params.put("PROJECT_CODE", oPROJECT_CODE);
array = headerService.getHoleInfoTrue(params);
}else{
params.put("PROJECT_CODE", oPROJECT_CODE);
params.put("HOLE_CODE", oHOLE_CODE);
array = headerService.getHoleInfoElse(params);
}
// Array 로 받기
}
// Array 로 받기
}
if("POP".equals(GUBUN) == false && "".equals(oHOLE_CODE) == false){
Double inX = 0.0 ;
Double inY = 0.0 ;
//degree일때
if("Degree".equals(COORDINATE_2) == true){
//경위도 일때 도분초 계산
String[] arrayX = X.split("-");
String[] arrayY = Y.split("-");
inX = wUtil.getDegreeLatLongMS(Double.parseDouble(arrayX[0]), Double.parseDouble(arrayX[1]), Double.parseDouble(arrayX[2]));
inY = wUtil.getDegreeLatLongMS(Double.parseDouble(arrayY[0]), Double.parseDouble(arrayY[1]), Double.parseDouble(arrayY[2]));
fX = inX+"";
fY = inY+"";
}else{
//경위도로 변환
HashMap map01 = wUtil.setCoordinateConvertXY(Double.parseDouble(X), Double.parseDouble(Y) , HOLE_COORDINATE , "4326");
String tempX = wUtil.isNullOb(map01.get("X"),"0");
String tempY = wUtil.isNullOb(map01.get("Y"),"0");
inX = Double.parseDouble(tempX);
inY = Double.parseDouble(tempY);
fX = inX+"";
fY = inY+"";
if("POP".equals(GUBUN) == false && "".equals(oHOLE_CODE) == false){
Double inX = 0.0 ;
Double inY = 0.0 ;
//degree일때
if("Degree".equals(COORDINATE_2) == true){
//경위도 일때 도분초 계산
String[] arrayX = X.split("-");
String[] arrayY = Y.split("-");
inX = wUtil.getDegreeLatLongMS(Double.parseDouble(arrayX[0]), Double.parseDouble(arrayX[1]), Double.parseDouble(arrayX[2]));
inY = wUtil.getDegreeLatLongMS(Double.parseDouble(arrayY[0]), Double.parseDouble(arrayY[1]), Double.parseDouble(arrayY[2]));
fX = inX+"";
fY = inY+"";
}else{
//경위도로 변환
HashMap map01 = wUtil.setCoordinateConvertXY(Double.parseDouble(X), Double.parseDouble(Y) , HOLE_COORDINATE , "4326");
String tempX = wUtil.isNullOb(map01.get("X"),"0");
String tempY = wUtil.isNullOb(map01.get("Y"),"0");
inX = Double.parseDouble(tempX);
inY = Double.parseDouble(tempY);
fX = inX+"";
fY = inY+"";
}
model.put("inX", inX);
model.put("inY", inY);
}
model.put("inX", inX);
model.put("inY", inY);
}
model.put("fX", fX);
model.put("fY", fY);
model.put("array", array);
model.put("gubun", GUBUN);
model.put("oHOLE_CODE", oHOLE_CODE);
model.put("COORDINATE_2", COORDINATE_2);
model.put("fX", fX);
model.put("fY", fY);
model.put("array", array);
model.put("gubun", GUBUN);
model.put("oHOLE_CODE", oHOLE_CODE);
model.put("COORDINATE_2", COORDINATE_2);

View File

@ -102,7 +102,7 @@ public class ManageListController {
return "/web/manage/list";
};
// 지반정보등록 (관리자)
// 지반정보등록 (관리자) - 일반 입력자도 여기 로직을 통해 화면이 보여짐.
@RequestMapping(value = "/meta_info.do")
public String meta_info(@RequestParam HashMap<String, Object> params,
ModelMap model, HttpServletRequest request,

View File

@ -0,0 +1,195 @@
package geoinfo.videos;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import egovframework.com.cmm.service.EgovProperties;
@Controller
@RequestMapping("/video")
public class VideoStreamingController {
// 동영상 파일이 저장된 기본 경로
private final String VIDEO_DIRECTORY = EgovProperties.getProperty("Geoinfo.FilePath") + "videos";
@RequestMapping(value = "/stream.do", method = RequestMethod.GET)
public void streamVideo(@RequestParam("name") String videoName, HttpServletRequest request, HttpServletResponse response) {
// 1. 경로 및 파일 유효성 검사
if (!StringUtils.hasText(videoName)) {
sendError(response, "비디오 파일명이 비어있습니다.", HttpServletResponse.SC_BAD_REQUEST);
return;
}
// URL 인코딩된 파일 이름 디코딩
String decodedVideoName;
try {
decodedVideoName = URLDecoder.decode(videoName, "UTF-8");
} catch (UnsupportedEncodingException e) {
sendError(response, "비디오 파일명 디코딩에 실패했습니다.", HttpServletResponse.SC_BAD_REQUEST);
return;
}
File videoFile = new File(VIDEO_DIRECTORY, decodedVideoName);
// Path Traversal 공격을 방지하기 위한 보안 코드 추가
try {
String videoDirPath = new File(VIDEO_DIRECTORY).getCanonicalPath();
String requestedFilePath = videoFile.getCanonicalPath();
// 요청된 파일의 실제 경로가 허용된 비디오 디렉토리 내에 있는지 확인
if (!requestedFilePath.startsWith(videoDirPath)) {
sendError(response, "잘못된 파일 경로입니다.", HttpServletResponse.SC_BAD_REQUEST);
return;
}
} catch (IOException e) {
// 경로 처리 중 오류 발생
sendError(response, "파일 경로 처리 중 오류가 발생했습니다.", HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
return;
}
if (!videoFile.exists() || !videoFile.isFile()) {
sendError(response, "요청하신 동영상 파일을 찾을 수 없습니다.", HttpServletResponse.SC_NOT_FOUND);
return;
}
RandomAccessFile randomFile = null;
OutputStream outputStream = null;
try {
long rangeStart = 0;
long rangeEnd = 0;
boolean isRangeRequest = false;
// 2. Range 헤더 파싱
String rangeHeader = request.getHeader("Range");
if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
isRangeRequest = true;
rangeHeader = rangeHeader.substring("bytes=".length());
String[] ranges = rangeHeader.split("-");
try {
rangeStart = Long.parseLong(ranges[0]);
if (ranges.length > 1) {
rangeEnd = Long.parseLong(ranges[1]);
}
} catch (NumberFormatException e) {
// Range 형식이 잘못된 경우 무시하고 전체 전송 시도
rangeStart = 0;
rangeEnd = 0;
isRangeRequest = false;
}
}
long fileLength = videoFile.length();
// Range 헤더가 없거나 잘못된 경우, rangeEnd를 파일 끝으로 설정
if (rangeEnd == 0) {
rangeEnd = fileLength - 1;
}
// 3. 응답 헤더 설정
String fileName = videoFile.getName();
String mimeType = "video/mp4"; // video/webm, video/ogg 등 파일에 맞게 설정
// 브라우저별 한글 파일명 처리
String header = request.getHeader("User-Agent");
String encodedFilename;
if (header.contains("MSIE") || header.contains("Trident")) { // IE, Edge
encodedFilename = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20");
} else { // Chrome, Firefox, Opera
encodedFilename = new String(fileName.getBytes("UTF-8"), "ISO-8859-1");
}
response.setHeader("Content-Disposition", "inline; filename=\"" + encodedFilename + "\"");
response.setContentType(mimeType);
response.setHeader("Accept-Ranges", "bytes");
long rangeLength = rangeEnd - rangeStart + 1;
if (isRangeRequest) {
// 스트리밍 (부분 전송)
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); // 206
response.setHeader("Content-Length", String.valueOf(rangeLength));
response.setHeader("Content-Range", "bytes " + rangeStart + "-" + rangeEnd + "/" + fileLength);
} else {
// 전체 전송
response.setStatus(HttpServletResponse.SC_OK); // 200
response.setHeader("Content-Length", String.valueOf(fileLength));
}
// 4. 파일 데이터 전송
randomFile = new RandomAccessFile(videoFile, "r");
randomFile.seek(rangeStart);
outputStream = response.getOutputStream();
byte[] buffer = new byte[8192]; // 8KB 버퍼
int bytesRead;
long bytesToWrite = rangeLength;
while (bytesToWrite > 0 && (bytesRead = randomFile.read(buffer, 0, (int) Math.min(bytesToWrite, buffer.length))) != -1) {
outputStream.write(buffer, 0, bytesRead);
bytesToWrite -= bytesRead;
}
outputStream.flush();
} catch (IOException e) {
// 클라이언트가 연결을 중단하는 경우 (예: 동영상 탐색) IOException이 발생할 수 있음
// 이 경우는 정상적인 동작이므로 에러 로그를 남기지 않음
} catch (Exception e) {
e.printStackTrace();
sendError(response, "알 수 없는 오류가 발생했습니다.", HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
} finally {
if (outputStream != null) {
try {
outputStream.close();
} catch (IOException e) {}
}
if (randomFile != null) {
try {
randomFile.close();
} catch (IOException e) {}
}
}
}
/**
* .
* UTF-8 .
* @param response HttpServletResponse
* @param message
* @param status HTTP
*/
private void sendError(HttpServletResponse response, String message, int status) {
try {
// 1. 응답 상태 코드를 직접 설정합니다.
response.setStatus(status);
// 2. 응답의 컨텐츠 타입과 문자 인코딩을 UTF-8로 명확하게 지정합니다.
response.setContentType("text/plain; charset=UTF-8");
response.setCharacterEncoding("UTF-8");
// 3. 응답 본문에 직접 에러 메시지를 작성합니다.
response.getWriter().write(message);
response.getWriter().flush();
} catch (IOException e) {
// 클라이언트가 연결을 끊었거나 다른 I/O 오류 발생 시
e.printStackTrace();
}
}
}

View File

@ -8,6 +8,8 @@
<meta name="apple-mobile-web-app-capable" content="yes">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<link href="/js/map/gis/css/map.css" rel="stylesheet"/>
<title>지도„</title>
</head>
<body onload="initMap();">

View File

@ -16,6 +16,7 @@
<filter-name>encodingFilter</filter-name>
<url-pattern>*.do</url-pattern>
<url-pattern>*.json</url-pattern>
<url-pattern>/video/stream/*</url-pattern>
</filter-mapping>
<!-- 멀티파트필터 적용 -->
@ -72,8 +73,12 @@
</servlet-mapping>
<servlet-mapping>
<servlet-name>action</servlet-name>
<url-pattern>*.json</url-pattern>
<url-pattern>*.json</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>action</servlet-name>
<url-pattern>/video/stream/*</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>ImageServlet</servlet-name>
<servlet-class>net.sf.jasperreports.j2ee.servlets.ImageServlet</servlet-class>