fix(security): CRITICAL 보안 이슈 13건 일괄 수정
- SEC-42: JWT algorithm HS256 명시 (sign 5곳, verify 3곳) - SEC-44: MariaDB/PhpMyAdmin 포트 127.0.0.1 바인딩 - SEC-29: escHtml = escapeHtml alias 추가 (XSS 방지) - SEC-39: Python Dockerfile 4개 non-root user + chown - SEC-43: deploy-remote.sh 삭제 (평문 비밀번호 포함) - SEC-11,12: SQL SET ? → 명시적 컬럼 whitelist + IN절 parameterized - QA-34: vacation approveRequest/cancelRequest 트랜잭션 래핑 - SEC-32,34: material_comparison.py 5개 엔드포인트 인증 + confirmed_by - SEC-33: files.py 17개 미인증 엔드포인트 인증 추가 - SEC-37: chatbot 프롬프트 인젝션 방어 (sanitize + XML 구분자) - SEC-38: fastapi-bridge 프록시 JWT 검증 + 캐시 키 user_id 포함 - SEC-58/QA-98: monthly-comparison API_BASE_URL 수정 + 401 처리 - SEC-61: monthlyComparisonModel SELECT FOR UPDATE 추가 - SEC-63: proxyInputController 에러 메시지 노출 제거 - QA-103: pageAccessRoutes error→message 통일 - SEC-62: tbm-create onclick 인젝션 → data-attribute event delegation - QA-99: tbm-mobile/create 캐시 버스팅 갱신 - QA-100,101: ESC 키 리스너 cleanup 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,9 @@ WORKDIR /app
|
|||||||
RUN apt-get update && apt-get install -y gcc build-essential && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y gcc build-essential && rm -rf /var/lib/apt/lists/*
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY . .
|
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||||
RUN mkdir -p /app/data
|
COPY --chown=appuser:appuser . .
|
||||||
|
RUN mkdir -p /app/data && chown appuser:appuser /app/data
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
USER appuser
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import json
|
|||||||
from services.ollama_client import ollama_client
|
from services.ollama_client import ollama_client
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_user_input(text: str, max_length: int = 500) -> str:
|
||||||
|
"""사용자 입력 길이 제한 및 정리"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
return str(text)[:max_length].strip()
|
||||||
|
|
||||||
|
|
||||||
ANALYZE_SYSTEM_PROMPT = """당신은 공장 현장 신고 접수를 도와주는 AI 도우미입니다.
|
ANALYZE_SYSTEM_PROMPT = """당신은 공장 현장 신고 접수를 도와주는 AI 도우미입니다.
|
||||||
사용자가 현장에서 발견한 문제를 설명하면, 아래 카테고리 목록을 참고하여 가장 적합한 신고 유형과 카테고리를 제안해야 합니다.
|
사용자가 현장에서 발견한 문제를 설명하면, 아래 카테고리 목록을 참고하여 가장 적합한 신고 유형과 카테고리를 제안해야 합니다.
|
||||||
|
|
||||||
@@ -35,10 +42,12 @@ async def analyze_user_input(user_text: str, categories: dict) -> dict:
|
|||||||
cat_names = [f" - ID {c['id']}: {c['name']}" for c in cats]
|
cat_names = [f" - ID {c['id']}: {c['name']}" for c in cats]
|
||||||
category_context += f"\n[{type_label} ({type_key})]\n" + "\n".join(cat_names) + "\n"
|
category_context += f"\n[{type_label} ({type_key})]\n" + "\n".join(cat_names) + "\n"
|
||||||
|
|
||||||
|
safe_text = sanitize_user_input(user_text)
|
||||||
prompt = f"""카테고리 목록:
|
prompt = f"""카테고리 목록:
|
||||||
{category_context}
|
{category_context}
|
||||||
|
|
||||||
사용자 입력: "{user_text}"
|
사용자 입력:
|
||||||
|
<user_input>{safe_text}</user_input>
|
||||||
|
|
||||||
위 카테고리 목록을 참고하여 JSON으로 응답하세요."""
|
위 카테고리 목록을 참고하여 JSON으로 응답하세요."""
|
||||||
|
|
||||||
@@ -71,12 +80,14 @@ async def analyze_user_input(user_text: str, categories: dict) -> dict:
|
|||||||
async def summarize_report(data: dict) -> dict:
|
async def summarize_report(data: dict) -> dict:
|
||||||
"""최종 신고 내용을 요약"""
|
"""최종 신고 내용을 요약"""
|
||||||
prompt = f"""신고 정보:
|
prompt = f"""신고 정보:
|
||||||
- 설명: {data.get('description', '')}
|
<user_input>
|
||||||
- 유형: {data.get('type', '')}
|
- 설명: {sanitize_user_input(data.get('description', ''))}
|
||||||
- 카테고리: {data.get('category', '')}
|
- 유형: {sanitize_user_input(data.get('type', ''))}
|
||||||
- 항목: {data.get('item', '')}
|
- 카테고리: {sanitize_user_input(data.get('category', ''))}
|
||||||
- 위치: {data.get('location', '')}
|
- 항목: {sanitize_user_input(data.get('item', ''))}
|
||||||
- 프로젝트: {data.get('project', '')}
|
- 위치: {sanitize_user_input(data.get('location', ''))}
|
||||||
|
- 프로젝트: {sanitize_user_input(data.get('project', ''))}
|
||||||
|
</user_input>
|
||||||
|
|
||||||
위 정보를 보기 좋게 요약하여 JSON으로 응답하세요."""
|
위 정보를 보기 좋게 요약하여 JSON으로 응답하세요."""
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ services:
|
|||||||
- mariadb_data:/var/lib/mysql
|
- mariadb_data:/var/lib/mysql
|
||||||
- ./scripts/migrate-users.sql:/docker-entrypoint-initdb.d/99-sso-users.sql
|
- ./scripts/migrate-users.sql:/docker-entrypoint-initdb.d/99-sso-users.sql
|
||||||
ports:
|
ports:
|
||||||
- "30306:3306"
|
- "127.0.0.1:30306:3306"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
|
||||||
timeout: 20s
|
timeout: 20s
|
||||||
@@ -608,7 +608,7 @@ services:
|
|||||||
container_name: tk-phpmyadmin
|
container_name: tk-phpmyadmin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "30880:80"
|
- "127.0.0.1:30880:80"
|
||||||
environment:
|
environment:
|
||||||
- PMA_HOST=mariadb
|
- PMA_HOST=mariadb
|
||||||
- PMA_USER=${PMA_USER:-root}
|
- PMA_USER=${PMA_USER:-root}
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# ===================================================================
|
|
||||||
# TK Factory Services - 원격 배포 스크립트 (맥북에서 실행)
|
|
||||||
# ===================================================================
|
|
||||||
# 사용법: ./scripts/deploy-remote.sh
|
|
||||||
# 설정: ~/.tk-deploy-config
|
|
||||||
# ===================================================================
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
CONFIG_FILE="$HOME/.tk-deploy-config"
|
|
||||||
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
CYAN='\033[0;36m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
# === 설정 로드 ===
|
|
||||||
if [ ! -f "$CONFIG_FILE" ]; then
|
|
||||||
echo -e "${RED}ERROR: 설정 파일이 없습니다: $CONFIG_FILE${NC}"
|
|
||||||
cat <<'EXAMPLE'
|
|
||||||
|
|
||||||
다음 내용으로 생성하세요:
|
|
||||||
|
|
||||||
NAS_HOST=100.71.132.52
|
|
||||||
NAS_USER=hyungi
|
|
||||||
NAS_DEPLOY_PATH=/volume1/docker_1/tk-factory-services
|
|
||||||
NAS_SUDO_PASS=<sudo 비밀번호>
|
|
||||||
EXAMPLE
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
source "$CONFIG_FILE"
|
|
||||||
|
|
||||||
for var in NAS_HOST NAS_USER NAS_DEPLOY_PATH NAS_SUDO_PASS; do
|
|
||||||
if [ -z "${!var}" ]; then
|
|
||||||
echo -e "${RED}ERROR: $CONFIG_FILE에 $var 가 설정되지 않았습니다${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
DOCKER="/usr/local/bin/docker"
|
|
||||||
DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}"
|
|
||||||
|
|
||||||
# === 헬퍼 함수 ===
|
|
||||||
ssh_cmd() {
|
|
||||||
ssh -o ConnectTimeout=10 "${NAS_USER}@${NAS_HOST}" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
nas_docker() {
|
|
||||||
ssh_cmd "cd ${NAS_DEPLOY_PATH} && echo '${NAS_SUDO_PASS}' | sudo -S ${DOCKER} $*" 2>&1
|
|
||||||
}
|
|
||||||
|
|
||||||
# === Phase 1: Pre-flight 체크 ===
|
|
||||||
echo "=== TK Factory Services - 원격 배포 ==="
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}[1/5] Pre-flight 체크${NC}"
|
|
||||||
|
|
||||||
cd "$PROJECT_DIR"
|
|
||||||
|
|
||||||
# Working tree clean 확인
|
|
||||||
if [ -n "$(git status --porcelain)" ]; then
|
|
||||||
echo -e "${RED}ERROR: 로컬에 커밋되지 않은 변경사항이 있습니다${NC}"
|
|
||||||
echo ""
|
|
||||||
git status --short
|
|
||||||
echo ""
|
|
||||||
echo "먼저 커밋하거나 stash하세요."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 로컬 커밋 정보
|
|
||||||
LOCAL_HASH=$(git rev-parse HEAD)
|
|
||||||
LOCAL_SHORT=$(git rev-parse --short HEAD)
|
|
||||||
LOCAL_MSG=$(git log -1 --format='%s')
|
|
||||||
LOCAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
|
||||||
|
|
||||||
# origin 동기화 확인
|
|
||||||
git fetch origin --quiet
|
|
||||||
|
|
||||||
ORIGIN_HASH=$(git rev-parse "origin/${LOCAL_BRANCH}" 2>/dev/null || echo "")
|
|
||||||
if [ "$LOCAL_HASH" != "$ORIGIN_HASH" ]; then
|
|
||||||
echo -e "${RED}ERROR: 로컬 커밋이 origin에 push되지 않았습니다${NC}"
|
|
||||||
echo " 로컬: ${LOCAL_SHORT} (${LOCAL_MSG})"
|
|
||||||
echo " 원격: $(git rev-parse --short "origin/${LOCAL_BRANCH}" 2>/dev/null || echo 'N/A')"
|
|
||||||
echo ""
|
|
||||||
echo "먼저 push하세요: git push origin ${LOCAL_BRANCH}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e " 로컬 HEAD: ${GREEN}${LOCAL_SHORT}${NC} - ${LOCAL_MSG}"
|
|
||||||
echo -e " 브랜치: ${LOCAL_BRANCH}"
|
|
||||||
|
|
||||||
# === Phase 2: NAS 상태 비교 ===
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}[2/5] NAS 배포 상태 확인${NC}"
|
|
||||||
|
|
||||||
NAS_HASH=$(ssh_cmd "cd ${NAS_DEPLOY_PATH} && git log -1 --format='%H'" 2>/dev/null || echo "")
|
|
||||||
|
|
||||||
if [ -z "$NAS_HASH" ]; then
|
|
||||||
echo -e "${RED}ERROR: NAS에서 git 정보를 가져올 수 없습니다${NC}"
|
|
||||||
echo " 경로: ${NAS_DEPLOY_PATH}"
|
|
||||||
echo " NAS에 git clone이 완료되었는지 확인하세요."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
NAS_SHORT="${NAS_HASH:0:7}"
|
|
||||||
NAS_MSG=$(ssh_cmd "cd ${NAS_DEPLOY_PATH} && git log -1 --format='%s'" 2>/dev/null)
|
|
||||||
|
|
||||||
if [ "$LOCAL_HASH" = "$NAS_HASH" ]; then
|
|
||||||
echo -e " ${GREEN}이미 최신 버전입니다!${NC} (${NAS_SHORT} - ${NAS_MSG})"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e " NAS 현재: ${YELLOW}${NAS_SHORT}${NC} - ${NAS_MSG}"
|
|
||||||
echo -e " 배포 대상: ${GREEN}${LOCAL_SHORT}${NC} - ${LOCAL_MSG}"
|
|
||||||
|
|
||||||
# 배포될 커밋 목록
|
|
||||||
COMMIT_COUNT=$(git log "${NAS_HASH}..${LOCAL_HASH}" --oneline | wc -l | tr -d ' ')
|
|
||||||
echo ""
|
|
||||||
echo "=== 배포될 커밋 (${COMMIT_COUNT}개) ==="
|
|
||||||
git log "${NAS_HASH}..${LOCAL_HASH}" --oneline --no-decorate
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
read -p "배포를 진행하시겠습니까? [y/N] " confirm
|
|
||||||
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
|
|
||||||
echo "배포가 취소되었습니다."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# === Phase 3: 배포 실행 ===
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}[3/5] NAS 코드 업데이트${NC}"
|
|
||||||
|
|
||||||
ssh_cmd "cd ${NAS_DEPLOY_PATH} && git fetch origin && git reset --hard origin/${DEPLOY_BRANCH}"
|
|
||||||
|
|
||||||
UPDATED_HASH=$(ssh_cmd "cd ${NAS_DEPLOY_PATH} && git log -1 --format='%H %s'" 2>/dev/null)
|
|
||||||
echo -e " ${GREEN}완료${NC}: ${UPDATED_HASH}"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}[4/5] Docker 컨테이너 빌드 및 재시작${NC}"
|
|
||||||
echo " (빌드에 시간이 걸릴 수 있습니다...)"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
nas_docker "compose up -d --build"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo " nginx 프록시 컨테이너 재시작 (IP 캐시 갱신)..."
|
|
||||||
nas_docker "restart tk-gateway tk-system2-web tk-system3-web"
|
|
||||||
|
|
||||||
# === Phase 4: 배포 후 검증 ===
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}[5/5] 배포 검증${NC} (15초 대기 후 health check)"
|
|
||||||
sleep 15
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Container Status ==="
|
|
||||||
nas_docker "compose ps --format 'table {{.Name}}\t{{.Status}}'" || true
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== HTTP Health Check ==="
|
|
||||||
HEALTH_PASS=0
|
|
||||||
HEALTH_FAIL=0
|
|
||||||
|
|
||||||
check_remote() {
|
|
||||||
local name="$1"
|
|
||||||
local path="$2"
|
|
||||||
local status
|
|
||||||
status=$(ssh_cmd "curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 http://localhost:${path}" 2>/dev/null || echo "000")
|
|
||||||
if [ "$status" -ge 200 ] 2>/dev/null && [ "$status" -lt 400 ] 2>/dev/null; then
|
|
||||||
printf " %-25s ${GREEN}OK${NC} (%s)\n" "$name" "$status"
|
|
||||||
((HEALTH_PASS++))
|
|
||||||
else
|
|
||||||
printf " %-25s ${RED}FAIL${NC} (%s)\n" "$name" "$status"
|
|
||||||
((HEALTH_FAIL++))
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
check_remote "Gateway" "30000/"
|
|
||||||
check_remote "SSO Auth" "30050/health"
|
|
||||||
check_remote "System 1 API" "30005/api/health"
|
|
||||||
check_remote "System 1 Web" "30080/"
|
|
||||||
check_remote "System 1 FastAPI" "30008/health"
|
|
||||||
check_remote "System 2 API" "30105/api/health"
|
|
||||||
check_remote "System 2 Web" "30180/"
|
|
||||||
check_remote "System 3 API" "30200/api/health"
|
|
||||||
check_remote "System 3 Web" "30280/"
|
|
||||||
check_remote "tkuser API" "30300/api/health"
|
|
||||||
check_remote "tkuser Web" "30380/"
|
|
||||||
check_remote "phpMyAdmin" "30880/"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo " Health: PASS=${HEALTH_PASS} FAIL=${HEALTH_FAIL}"
|
|
||||||
|
|
||||||
# === Phase 5: 배포 로그 기록 ===
|
|
||||||
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
|
|
||||||
ssh_cmd "echo '${TIMESTAMP} | ${LOCAL_SHORT} | ${LOCAL_MSG}' >> ${NAS_DEPLOY_PATH}/DEPLOY_LOG"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
if [ "$HEALTH_FAIL" -gt 0 ]; then
|
|
||||||
echo -e "${YELLOW}배포 완료 (일부 서비스 health check 실패)${NC}"
|
|
||||||
echo " 로그 확인: ssh ${NAS_USER}@${NAS_HOST} \"cd ${NAS_DEPLOY_PATH} && echo '...' | sudo -S ${DOCKER} compose logs --tail=50\""
|
|
||||||
else
|
|
||||||
echo -e "${GREEN}배포 완료!${NC}"
|
|
||||||
fi
|
|
||||||
echo " 버전: ${LOCAL_SHORT} - ${LOCAL_MSG}"
|
|
||||||
@@ -83,11 +83,11 @@ async function login(req, res, next) {
|
|||||||
await userModel.updateLastLogin(user.user_id);
|
await userModel.updateLastLogin(user.user_id);
|
||||||
|
|
||||||
const payload = createTokenPayload(user);
|
const payload = createTokenPayload(user);
|
||||||
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' });
|
||||||
const refresh_token = jwt.sign(
|
const refresh_token = jwt.sign(
|
||||||
{ user_id: user.user_id, type: 'refresh' },
|
{ user_id: user.user_id, type: 'refresh' },
|
||||||
JWT_REFRESH_SECRET,
|
JWT_REFRESH_SECRET,
|
||||||
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
|
{ expiresIn: JWT_REFRESH_EXPIRES_IN, algorithm: 'HS256' }
|
||||||
);
|
);
|
||||||
|
|
||||||
// SSO 쿠키는 클라이언트(login.html)에서 domain=.technicalkorea.net으로 설정
|
// SSO 쿠키는 클라이언트(login.html)에서 domain=.technicalkorea.net으로 설정
|
||||||
@@ -159,7 +159,7 @@ async function loginForm(req, res, next) {
|
|||||||
await userModel.updateLastLogin(user.user_id);
|
await userModel.updateLastLogin(user.user_id);
|
||||||
|
|
||||||
const payload = createTokenPayload(user);
|
const payload = createTokenPayload(user);
|
||||||
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' });
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
access_token,
|
access_token,
|
||||||
@@ -187,7 +187,8 @@ async function validate(req, res, next) {
|
|||||||
return res.status(401).json({ success: false, error: '토큰이 필요합니다' });
|
return res.status(401).json({ success: false, error: '토큰이 필요합니다' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoded = jwt.verify(token, JWT_SECRET);
|
// TODO: issuer/audience 클레임 검증 추가 검토
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
|
||||||
const user = await userModel.findById(decoded.user_id || decoded.id);
|
const user = await userModel.findById(decoded.user_id || decoded.id);
|
||||||
if (!user || !user.is_active) {
|
if (!user || !user.is_active) {
|
||||||
return res.status(401).json({ success: false, error: '유효하지 않은 사용자입니다' });
|
return res.status(401).json({ success: false, error: '유효하지 않은 사용자입니다' });
|
||||||
@@ -229,7 +230,7 @@ async function me(req, res, next) {
|
|||||||
return res.status(401).json({ detail: 'Not authenticated' });
|
return res.status(401).json({ detail: 'Not authenticated' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoded = jwt.verify(token, JWT_SECRET);
|
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
|
||||||
const user = await userModel.findById(decoded.user_id || decoded.id);
|
const user = await userModel.findById(decoded.user_id || decoded.id);
|
||||||
if (!user || !user.is_active) {
|
if (!user || !user.is_active) {
|
||||||
return res.status(401).json({ detail: 'User not found or inactive' });
|
return res.status(401).json({ detail: 'User not found or inactive' });
|
||||||
@@ -261,7 +262,7 @@ async function refresh(req, res, next) {
|
|||||||
return res.status(400).json({ success: false, error: 'Refresh 토큰이 필요합니다' });
|
return res.status(400).json({ success: false, error: 'Refresh 토큰이 필요합니다' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoded = jwt.verify(refresh_token, JWT_REFRESH_SECRET);
|
const decoded = jwt.verify(refresh_token, JWT_REFRESH_SECRET, { algorithms: ['HS256'] });
|
||||||
if (decoded.type !== 'refresh') {
|
if (decoded.type !== 'refresh') {
|
||||||
return res.status(401).json({ success: false, error: '유효하지 않은 Refresh 토큰입니다' });
|
return res.status(401).json({ success: false, error: '유효하지 않은 Refresh 토큰입니다' });
|
||||||
}
|
}
|
||||||
@@ -272,11 +273,11 @@ async function refresh(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = createTokenPayload(user);
|
const payload = createTokenPayload(user);
|
||||||
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' });
|
||||||
const new_refresh_token = jwt.sign(
|
const new_refresh_token = jwt.sign(
|
||||||
{ user_id: user.user_id, type: 'refresh' },
|
{ user_id: user.user_id, type: 'refresh' },
|
||||||
JWT_REFRESH_SECRET,
|
JWT_REFRESH_SECRET,
|
||||||
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
|
{ expiresIn: JWT_REFRESH_EXPIRES_IN, algorithm: 'HS256' }
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ const ProxyInputController = {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
try { await conn.rollback(); } catch (e) {}
|
try { await conn.rollback(); } catch (e) {}
|
||||||
logger.error('대리입력 오류:', err);
|
logger.error('대리입력 오류:', err);
|
||||||
res.status(500).json({ success: false, message: '대리입력 처리 중 오류가 발생했습니다.', error: err.message });
|
res.status(500).json({ success: false, message: '대리입력 처리 중 오류가 발생했습니다.' });
|
||||||
} finally {
|
} finally {
|
||||||
conn.release();
|
conn.release();
|
||||||
}
|
}
|
||||||
@@ -182,7 +182,7 @@ const ProxyInputController = {
|
|||||||
res.json({ success: true, data });
|
res.json({ success: true, data });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('일별 현황 조회 오류:', err);
|
logger.error('일별 현황 조회 오류:', err);
|
||||||
res.status(500).json({ success: false, message: '조회 중 오류가 발생했습니다.', error: err.message });
|
res.status(500).json({ success: false, message: '조회 중 오류가 발생했습니다.' });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ const ProxyInputController = {
|
|||||||
res.json({ success: true, data });
|
res.json({ success: true, data });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('일별 상세 조회 오류:', err);
|
logger.error('일별 상세 조회 오류:', err);
|
||||||
res.status(500).json({ success: false, message: '조회 중 오류가 발생했습니다.', error: err.message });
|
res.status(500).json({ success: false, message: '조회 중 오류가 발생했습니다.' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ const MonthlyComparisonModel = {
|
|||||||
|
|
||||||
// 기존 상태 체크 + 전환 검증
|
// 기존 상태 체크 + 전환 검증
|
||||||
const [existing] = await conn.query(
|
const [existing] = await conn.query(
|
||||||
'SELECT id, status FROM monthly_work_confirmations WHERE user_id = ? AND year = ? AND month = ?',
|
'SELECT id, status FROM monthly_work_confirmations WHERE user_id = ? AND year = ? AND month = ? FOR UPDATE',
|
||||||
[data.user_id, data.year, data.month]
|
[data.user_id, data.year, data.month]
|
||||||
);
|
);
|
||||||
const currentStatus = existing.length > 0 ? existing[0].status : null;
|
const currentStatus = existing.length > 0 ? existing[0].status : null;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ router.get('/pages', requireAuth, async (req, res) => {
|
|||||||
res.json({ success: true, data: pages });
|
res.json({ success: true, data: pages });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('페이지 목록 조회 오류:', error);
|
console.error('페이지 목록 조회 오류:', error);
|
||||||
res.status(500).json({ success: false, error: '페이지 목록을 불러오는데 실패했습니다.' });
|
res.status(500).json({ success: false, message: '페이지 목록을 불러오는데 실패했습니다.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
|
|||||||
`, [userId]);
|
`, [userId]);
|
||||||
|
|
||||||
if (userRows.length === 0) {
|
if (userRows.length === 0) {
|
||||||
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다.' });
|
return res.status(404).json({ success: false, message: '사용자를 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = userRows[0];
|
const user = userRows[0];
|
||||||
@@ -99,7 +99,7 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
|
|||||||
res.json({ success: true, data: { user, pageAccess } });
|
res.json({ success: true, data: { user, pageAccess } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('페이지 접근 권한 조회 오류:', error);
|
console.error('페이지 접근 권한 조회 오류:', error);
|
||||||
res.status(500).json({ success: false, error: '페이지 접근 권한을 불러오는데 실패했습니다.' });
|
res.status(500).json({ success: false, message: '페이지 접근 권한을 불러오는데 실패했습니다.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
|
|||||||
router.post('/users/:userId/page-access', requireAuth, async (req, res) => {
|
router.post('/users/:userId/page-access', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!isAdminRole(req.user.role)) {
|
if (!isAdminRole(req.user.role)) {
|
||||||
return res.status(403).json({ success: false, error: '권한이 없습니다. Admin 계정만 사용자 권한을 관리할 수 있습니다.' });
|
return res.status(403).json({ success: false, message: '권한이 없습니다. Admin 계정만 사용자 권한을 관리할 수 있습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { userId } = req.params;
|
const { userId } = req.params;
|
||||||
@@ -123,7 +123,7 @@ router.post('/users/:userId/page-access', requireAuth, async (req, res) => {
|
|||||||
// 사용자 존재 확인
|
// 사용자 존재 확인
|
||||||
const [userRows] = await db.query('SELECT user_id FROM sso_users WHERE user_id = ?', [userId]);
|
const [userRows] = await db.query('SELECT user_id FROM sso_users WHERE user_id = ?', [userId]);
|
||||||
if (userRows.length === 0) {
|
if (userRows.length === 0) {
|
||||||
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다.' });
|
return res.status(404).json({ success: false, message: '사용자를 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 페이지 접근 권한 업데이트
|
// 페이지 접근 권한 업데이트
|
||||||
@@ -138,7 +138,7 @@ router.post('/users/:userId/page-access', requireAuth, async (req, res) => {
|
|||||||
res.json({ success: true, message: '페이지 접근 권한이 업데이트되었습니다.' });
|
res.json({ success: true, message: '페이지 접근 권한이 업데이트되었습니다.' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('페이지 접근 권한 부여 오류:', error);
|
console.error('페이지 접근 권한 부여 오류:', error);
|
||||||
res.status(500).json({ success: false, error: '페이지 접근 권한을 업데이트하는데 실패했습니다.' });
|
res.status(500).json({ success: false, message: '페이지 접근 권한을 업데이트하는데 실패했습니다.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ router.post('/users/:userId/page-access', requireAuth, async (req, res) => {
|
|||||||
router.delete('/users/:userId/page-access/:pageId', requireAuth, async (req, res) => {
|
router.delete('/users/:userId/page-access/:pageId', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!isAdminRole(req.user.role)) {
|
if (!isAdminRole(req.user.role)) {
|
||||||
return res.status(403).json({ success: false, error: '권한이 없습니다.' });
|
return res.status(403).json({ success: false, message: '권한이 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { userId, pageId } = req.params;
|
const { userId, pageId } = req.params;
|
||||||
@@ -163,7 +163,7 @@ router.delete('/users/:userId/page-access/:pageId', requireAuth, async (req, res
|
|||||||
res.json({ success: true, message: '페이지 접근 권한이 회수되었습니다.' });
|
res.json({ success: true, message: '페이지 접근 권한이 회수되었습니다.' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('페이지 접근 권한 회수 오류:', error);
|
console.error('페이지 접근 권한 회수 오류:', error);
|
||||||
res.status(500).json({ success: false, error: '페이지 접근 권한을 회수하는데 실패했습니다.' });
|
res.status(500).json({ success: false, message: '페이지 접근 권한을 회수하는데 실패했습니다.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ router.delete('/users/:userId/page-access/:pageId', requireAuth, async (req, res
|
|||||||
router.get('/page-access/summary', requireAuth, async (req, res) => {
|
router.get('/page-access/summary', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!isAdminRole(req.user.role)) {
|
if (!isAdminRole(req.user.role)) {
|
||||||
return res.status(403).json({ success: false, error: '권한이 없습니다.' });
|
return res.status(403).json({ success: false, message: '권한이 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
@@ -195,7 +195,7 @@ router.get('/page-access/summary', requireAuth, async (req, res) => {
|
|||||||
res.json({ success: true, data: summary });
|
res.json({ success: true, data: summary });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('페이지 접근 권한 요약 조회 오류:', error);
|
console.error('페이지 접근 권한 요약 조회 오류:', error);
|
||||||
res.status(500).json({ success: false, error: '페이지 접근 권한 요약을 불러오는데 실패했습니다.' });
|
res.status(500).json({ success: false, message: '페이지 접근 권한 요약을 불러오는데 실패했습니다.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ RUN apt-get update && apt-get install -y \
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# non-root user 생성
|
||||||
|
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||||
|
|
||||||
# 애플리케이션 코드 복사
|
# 애플리케이션 코드 복사
|
||||||
COPY . .
|
COPY --chown=appuser:appuser . .
|
||||||
|
|
||||||
# 포트 노출
|
# 포트 노출
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# 애플리케이션 실행
|
# 애플리케이션 실행
|
||||||
|
USER appuser
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -8,6 +8,7 @@ class Settings:
|
|||||||
# 기본 설정
|
# 기본 설정
|
||||||
FASTAPI_PORT: int = int(os.getenv("FASTAPI_PORT", "8000"))
|
FASTAPI_PORT: int = int(os.getenv("FASTAPI_PORT", "8000"))
|
||||||
EXPRESS_API_URL: str = os.getenv("EXPRESS_API_URL", "http://system1-api:3005")
|
EXPRESS_API_URL: str = os.getenv("EXPRESS_API_URL", "http://system1-api:3005")
|
||||||
|
JWT_SECRET: str = os.getenv("JWT_SECRET", "")
|
||||||
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379")
|
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379")
|
||||||
NODE_ENV: str = os.getenv("NODE_ENV", "development")
|
NODE_ENV: str = os.getenv("NODE_ENV", "development")
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import logging
|
|||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import jwt as pyjwt
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI, Request, HTTPException
|
from fastapi import FastAPI, Request, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -206,22 +207,41 @@ async def analytics_dashboard():
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _verify_proxy_token(request: Request) -> dict:
|
||||||
|
"""프록시 요청의 JWT 토큰을 검증하여 사용자 정보 반환"""
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
if not auth_header.startswith("Bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="Missing or invalid authorization")
|
||||||
|
token = auth_header.split(" ", 1)[1]
|
||||||
|
if not settings.JWT_SECRET:
|
||||||
|
logger.warning("JWT_SECRET이 설정되지 않아 토큰 검증을 건너뜁니다")
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
payload = pyjwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
|
||||||
|
return payload
|
||||||
|
except pyjwt.InvalidTokenError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
|
||||||
@app.api_route("/api/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
@app.api_route("/api/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||||
async def proxy_to_express(path: str, request: Request) -> Dict[str, Any]:
|
async def proxy_to_express(path: str, request: Request) -> Dict[str, Any]:
|
||||||
"""Express.js API로 모든 요청을 프록시 (GET 요청은 캐싱 적용)"""
|
"""Express.js API로 모든 요청을 프록시 (GET 요청은 캐싱 적용)"""
|
||||||
|
|
||||||
|
# JWT 검증 (defense in depth — Express 백엔드도 자체 검증함)
|
||||||
|
user_payload = _verify_proxy_token(request)
|
||||||
|
user_id = user_payload.get("user_id", user_payload.get("id", "anon"))
|
||||||
|
|
||||||
# Express.js API URL 구성
|
# Express.js API URL 구성
|
||||||
target_url = f"{settings.EXPRESS_API_URL}/api/{path}"
|
target_url = f"{settings.EXPRESS_API_URL}/api/{path}"
|
||||||
|
|
||||||
# 요청 데이터 준비
|
# 요청 데이터 준비
|
||||||
headers = dict(request.headers)
|
headers = dict(request.headers)
|
||||||
headers.pop("host", None) # host 헤더 제거
|
headers.pop("host", None) # host 헤더 제거
|
||||||
|
|
||||||
params = dict(request.query_params)
|
params = dict(request.query_params)
|
||||||
|
|
||||||
# GET 요청에 대해서만 캐싱 적용
|
# GET 요청에 대해서만 캐싱 적용 (user_id 포함하여 사용자 간 캐시 격리)
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
cache_key = cache_manager._generate_key("api", path, **params)
|
cache_key = cache_manager._generate_key("api", path, _uid=str(user_id), **params)
|
||||||
cached_result = await cache_manager.get(cache_key)
|
cached_result = await cache_manager.get(cache_key)
|
||||||
|
|
||||||
if cached_result is not None:
|
if cached_result is not None:
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ uvicorn[standard]==0.24.0
|
|||||||
aiohttp==3.9.1
|
aiohttp==3.9.1
|
||||||
python-multipart==0.0.6
|
python-multipart==0.0.6
|
||||||
redis==5.0.1
|
redis==5.0.1
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
|
PyJWT==2.8.0
|
||||||
@@ -395,4 +395,6 @@ async function submitReject() {
|
|||||||
function fmtNum(v) { var n = parseFloat(v) || 0; return n % 1 === 0 ? n.toString() : n.toFixed(1); }
|
function fmtNum(v) { var n = parseFloat(v) || 0; return n % 1 === 0 ? n.toString() : n.toFixed(1); }
|
||||||
function escHtml(s) { return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
function escHtml(s) { return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||||
// showToast — tkfb-core.js의 전역 showToast 사용 (재정의 불필요)
|
// showToast — tkfb-core.js의 전역 showToast 사용 (재정의 불필요)
|
||||||
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeRejectModal(); });
|
function handleEscKey(e) { if (e.key === 'Escape') closeRejectModal(); }
|
||||||
|
document.addEventListener('keydown', handleEscKey);
|
||||||
|
window.addEventListener('beforeunload', function() { document.removeEventListener('keydown', handleEscKey); });
|
||||||
|
|||||||
@@ -306,7 +306,7 @@
|
|||||||
var skipSelected = W.projectId === null ? ' selected' : '';
|
var skipSelected = W.projectId === null ? ' selected' : '';
|
||||||
var projectItems = projects.map(function(p) {
|
var projectItems = projects.map(function(p) {
|
||||||
var selected = W.projectId === p.project_id ? ' selected' : '';
|
var selected = W.projectId === p.project_id ? ' selected' : '';
|
||||||
return '<div class="list-item' + selected + '" onclick="selectProject(' + p.project_id + ', \'' + esc(p.project_name).replace(/'/g, "\\'") + '\')">' +
|
return '<div class="list-item' + selected + '" data-action="selectProject" data-project-id="' + p.project_id + '" data-project-name="' + esc(p.project_name) + '">' +
|
||||||
'<div class="item-title">' + esc(p.project_name) + '</div>' +
|
'<div class="item-title">' + esc(p.project_name) + '</div>' +
|
||||||
'<div class="item-desc">' + esc(p.job_no || '') + '</div>' +
|
'<div class="item-desc">' + esc(p.job_no || '') + '</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
@@ -315,7 +315,7 @@
|
|||||||
// 공정 pill 버튼
|
// 공정 pill 버튼
|
||||||
var pillHtml = workTypes.map(function(wt) {
|
var pillHtml = workTypes.map(function(wt) {
|
||||||
var selected = W.workTypeId === wt.id ? ' selected' : '';
|
var selected = W.workTypeId === wt.id ? ' selected' : '';
|
||||||
return '<button type="button" class="pill-btn' + selected + '" onclick="selectWorkType(' + wt.id + ', \'' + esc(wt.name).replace(/'/g, "\\'") + '\')">' + esc(wt.name) + '</button>';
|
return '<button type="button" class="pill-btn' + selected + '" data-action="selectWorkType" data-wt-id="' + wt.id + '" data-wt-name="' + esc(wt.name) + '">' + esc(wt.name) + '</button>';
|
||||||
}).join('');
|
}).join('');
|
||||||
pillHtml += '<button type="button" class="pill-btn-add" onclick="toggleAddWorkType()">+ 추가</button>';
|
pillHtml += '<button type="button" class="pill-btn-add" onclick="toggleAddWorkType()">+ 추가</button>';
|
||||||
|
|
||||||
@@ -335,7 +335,7 @@
|
|||||||
container.innerHTML =
|
container.innerHTML =
|
||||||
'<div class="wizard-section">' +
|
'<div class="wizard-section">' +
|
||||||
'<div class="section-title"><span class="sn">2</span>프로젝트 선택 <span style="font-size:0.75rem;font-weight:400;color:#9ca3af;">(선택사항)</span></div>' +
|
'<div class="section-title"><span class="sn">2</span>프로젝트 선택 <span style="font-size:0.75rem;font-weight:400;color:#9ca3af;">(선택사항)</span></div>' +
|
||||||
'<div class="list-item-skip' + skipSelected + '" onclick="selectProject(null, \'\')">' +
|
'<div class="list-item-skip' + skipSelected + '" data-action="selectProject" data-project-id="" data-project-name="">' +
|
||||||
'선택 안함' +
|
'선택 안함' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
(projects.length > 0 ? projectItems : '<div class="empty-state">등록된 프로젝트가 없습니다</div>') +
|
(projects.length > 0 ? projectItems : '<div class="empty-state">등록된 프로젝트가 없습니다</div>') +
|
||||||
@@ -357,6 +357,19 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Event delegation for project/workType selection
|
||||||
|
container.onclick = function(e) {
|
||||||
|
var el = e.target.closest('[data-action]');
|
||||||
|
if (!el) return;
|
||||||
|
var action = el.getAttribute('data-action');
|
||||||
|
if (action === 'selectProject') {
|
||||||
|
var pid = el.getAttribute('data-project-id');
|
||||||
|
selectProject(pid ? parseInt(pid) : null, el.getAttribute('data-project-name') || '');
|
||||||
|
} else if (action === 'selectWorkType') {
|
||||||
|
selectWorkType(parseInt(el.getAttribute('data-wt-id')), el.getAttribute('data-wt-name') || '');
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
window.selectProject = function(projectId, projectName) {
|
window.selectProject = function(projectId, projectName) {
|
||||||
|
|||||||
@@ -844,14 +844,14 @@
|
|||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="/static/js/tkfb-core.js?v=2026033108"></script>
|
<script src="/static/js/tkfb-core.js?v=2026033108"></script>
|
||||||
<script src="/js/api-base.js?v=2026031401"></script>
|
<script src="/js/api-base.js?v=2026040101"></script>
|
||||||
<!-- 공통 모듈 -->
|
<!-- 공통 모듈 -->
|
||||||
<script src="/js/common/utils.js?v=2026031401"></script>
|
<script src="/js/common/utils.js?v=2026040101"></script>
|
||||||
<script src="/js/common/base-state.js?v=2026031401"></script>
|
<script src="/js/common/base-state.js?v=2026040101"></script>
|
||||||
<script src="/js/tbm/state.js?v=2026031401"></script>
|
<script src="/js/tbm/state.js?v=2026040101"></script>
|
||||||
<script src="/js/tbm/utils.js?v=2026031401"></script>
|
<script src="/js/tbm/utils.js?v=2026040101"></script>
|
||||||
<script src="/js/tbm/api.js?v=2026031401"></script>
|
<script src="/js/tbm/api.js?v=2026040101"></script>
|
||||||
<script src="/js/tbm-create.js?v=2026031401"></script>
|
<script src="/js/tbm-create.js?v=2026040101"></script>
|
||||||
<script>initAuth();</script>
|
<script>initAuth();</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026033108">
|
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026033108">
|
||||||
<link rel="stylesheet" href="/css/tbm-mobile.css?v=2026031401">
|
<link rel="stylesheet" href="/css/tbm-mobile.css?v=2026040101">
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50">
|
<body class="bg-gray-50">
|
||||||
@@ -265,13 +265,13 @@
|
|||||||
|
|
||||||
<!-- 공통 모듈 -->
|
<!-- 공통 모듈 -->
|
||||||
<script src="/static/js/tkfb-core.js?v=2026033108"></script>
|
<script src="/static/js/tkfb-core.js?v=2026033108"></script>
|
||||||
<script src="/js/api-base.js?v=2026031401"></script>
|
<script src="/js/api-base.js?v=2026040101"></script>
|
||||||
<script src="/js/common/utils.js?v=2026031401"></script>
|
<script src="/js/common/utils.js?v=2026040101"></script>
|
||||||
<script src="/js/common/base-state.js?v=2026031401"></script>
|
<script src="/js/common/base-state.js?v=2026040101"></script>
|
||||||
|
|
||||||
<script src="/js/tbm/state.js?v=2026031401"></script>
|
<script src="/js/tbm/state.js?v=2026040101"></script>
|
||||||
<script src="/js/tbm/utils.js?v=2026031401"></script>
|
<script src="/js/tbm/utils.js?v=2026040101"></script>
|
||||||
<script src="/js/tbm/api.js?v=2026031401"></script>
|
<script src="/js/tbm/api.js?v=2026040101"></script>
|
||||||
<script src="/js/tbm-mobile.js?v=2026033102"></script>
|
<script src="/js/tbm-mobile.js?v=2026033102"></script>
|
||||||
<script>initAuth();</script>
|
<script>initAuth();</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ function showToast(msg, type = 'success') {
|
|||||||
|
|
||||||
/* ===== Escape ===== */
|
/* ===== Escape ===== */
|
||||||
function escapeHtml(str) { if (!str) return ''; const d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
|
function escapeHtml(str) { if (!str) return ''; const d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
|
||||||
|
const escHtml = escapeHtml;
|
||||||
|
|
||||||
/* ===== Helpers ===== */
|
/* ===== Helpers ===== */
|
||||||
function formatDate(d) { if (!d) return ''; return String(d).substring(0, 10); }
|
function formatDate(d) { if (!d) return ''; return String(d).substring(0, 10); }
|
||||||
|
|||||||
@@ -11,14 +11,18 @@ RUN apt-get update && apt-get install -y \
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# non-root user 생성
|
||||||
|
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||||
|
|
||||||
# 애플리케이션 파일 복사
|
# 애플리케이션 파일 복사
|
||||||
COPY . .
|
COPY --chown=appuser:appuser . .
|
||||||
|
|
||||||
# uploads 디렉토리 생성
|
# uploads 디렉토리 생성
|
||||||
RUN mkdir -p /app/uploads
|
RUN mkdir -p /app/uploads && chown appuser:appuser /app/uploads
|
||||||
|
|
||||||
# 포트 노출
|
# 포트 노출
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# 실행 명령
|
# 실행 명령
|
||||||
|
USER appuser
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ RUN apt-get update && apt-get install -y \
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||||
|
COPY --chown=appuser:appuser . .
|
||||||
|
|
||||||
ENV PYTHONPATH=/app
|
ENV PYTHONPATH=/app
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
USER appuser
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -164,11 +164,11 @@ ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"}
|
|||||||
# API 정보는 /info 엔드포인트로 이동됨
|
# API 정보는 /info 엔드포인트로 이동됨
|
||||||
|
|
||||||
@router.get("/test")
|
@router.get("/test")
|
||||||
async def test_endpoint():
|
async def test_endpoint(current_user: dict = Depends(get_current_user)):
|
||||||
return {"status": "파일 API가 정상 작동합니다!"}
|
return {"status": "파일 API가 정상 작동합니다!"}
|
||||||
|
|
||||||
@router.post("/add-missing-columns")
|
@router.post("/add-missing-columns")
|
||||||
async def add_missing_columns(db: Session = Depends(get_db)):
|
async def add_missing_columns(db: Session = Depends(get_db), current_user: dict = Depends(get_current_user)):
|
||||||
"""누락된 컬럼들 추가"""
|
"""누락된 컬럼들 추가"""
|
||||||
try:
|
try:
|
||||||
db.execute(text("ALTER TABLE files ADD COLUMN IF NOT EXISTS parsed_count INTEGER DEFAULT 0"))
|
db.execute(text("ALTER TABLE files ADD COLUMN IF NOT EXISTS parsed_count INTEGER DEFAULT 0"))
|
||||||
@@ -602,7 +602,8 @@ async def upload_file(
|
|||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def get_files(
|
async def get_files(
|
||||||
job_no: Optional[str] = None,
|
job_no: Optional[str] = None,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""파일 목록 조회"""
|
"""파일 목록 조회"""
|
||||||
try:
|
try:
|
||||||
@@ -646,7 +647,8 @@ async def get_files(
|
|||||||
@router.get("/list")
|
@router.get("/list")
|
||||||
async def get_files_list(
|
async def get_files_list(
|
||||||
job_no: Optional[str] = None,
|
job_no: Optional[str] = None,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""파일 목록 조회 (리비전 모드 확인용)"""
|
"""파일 목록 조회 (리비전 모드 확인용)"""
|
||||||
try:
|
try:
|
||||||
@@ -696,7 +698,8 @@ async def get_files_list(
|
|||||||
@router.get("/project/{project_code}")
|
@router.get("/project/{project_code}")
|
||||||
async def get_files_by_project(
|
async def get_files_by_project(
|
||||||
project_code: str,
|
project_code: str,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""프로젝트별 파일 목록 조회"""
|
"""프로젝트별 파일 목록 조회"""
|
||||||
try:
|
try:
|
||||||
@@ -733,7 +736,7 @@ async def get_files_by_project(
|
|||||||
raise HTTPException(status_code=500, detail=f"프로젝트 파일 조회 실패: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"프로젝트 파일 조회 실패: {str(e)}")
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
async def get_files_stats(db: Session = Depends(get_db)):
|
async def get_files_stats(db: Session = Depends(get_db), current_user: dict = Depends(get_current_user)):
|
||||||
"""파일 및 자재 통계 조회"""
|
"""파일 및 자재 통계 조회"""
|
||||||
try:
|
try:
|
||||||
# 총 파일 수
|
# 총 파일 수
|
||||||
@@ -774,7 +777,7 @@ async def get_files_stats(db: Session = Depends(get_db)):
|
|||||||
raise HTTPException(status_code=500, detail=f"통계 조회 실패: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"통계 조회 실패: {str(e)}")
|
||||||
|
|
||||||
@router.delete("/delete/{file_id}")
|
@router.delete("/delete/{file_id}")
|
||||||
async def delete_file(file_id: int, db: Session = Depends(get_db)):
|
async def delete_file(file_id: int, db: Session = Depends(get_db), current_user: dict = Depends(get_current_user)):
|
||||||
"""파일 삭제"""
|
"""파일 삭제"""
|
||||||
try:
|
try:
|
||||||
# 자재 먼저 삭제
|
# 자재 먼저 삭제
|
||||||
@@ -814,7 +817,8 @@ async def get_materials(
|
|||||||
sort_by: Optional[str] = None,
|
sort_by: Optional[str] = None,
|
||||||
exclude_requested: bool = True, # 구매신청된 자재 제외 여부
|
exclude_requested: bool = True, # 구매신청된 자재 제외 여부
|
||||||
group_by_spec: bool = False, # 같은 사양끼리 그룹화
|
group_by_spec: bool = False, # 같은 사양끼리 그룹화
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
저장된 자재 목록 조회 (job_no, filename, revision 3가지로 필터링 가능) - 신버전
|
저장된 자재 목록 조회 (job_no, filename, revision 3가지로 필터링 가능) - 신버전
|
||||||
@@ -1511,7 +1515,8 @@ async def get_materials(
|
|||||||
async def get_materials_summary(
|
async def get_materials_summary(
|
||||||
project_id: Optional[int] = None,
|
project_id: Optional[int] = None,
|
||||||
file_id: Optional[int] = None,
|
file_id: Optional[int] = None,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""자재 요약 통계"""
|
"""자재 요약 통계"""
|
||||||
try:
|
try:
|
||||||
@@ -1566,7 +1571,8 @@ async def compare_revisions(
|
|||||||
filename: str,
|
filename: str,
|
||||||
old_revision: str,
|
old_revision: str,
|
||||||
new_revision: str,
|
new_revision: str,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
리비전 간 자재 비교
|
리비전 간 자재 비교
|
||||||
@@ -1846,7 +1852,8 @@ async def compare_revisions(
|
|||||||
async def get_pipe_details(
|
async def get_pipe_details(
|
||||||
file_id: Optional[int] = None,
|
file_id: Optional[int] = None,
|
||||||
job_no: Optional[str] = None,
|
job_no: Optional[str] = None,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
PIPE 상세 정보 조회
|
PIPE 상세 정보 조회
|
||||||
@@ -1905,7 +1912,8 @@ async def get_pipe_details(
|
|||||||
async def get_fitting_details(
|
async def get_fitting_details(
|
||||||
file_id: Optional[int] = None,
|
file_id: Optional[int] = None,
|
||||||
job_no: Optional[str] = None,
|
job_no: Optional[str] = None,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
FITTING 상세 정보 조회
|
FITTING 상세 정보 조회
|
||||||
@@ -1960,7 +1968,8 @@ async def get_fitting_details(
|
|||||||
async def get_valve_details(
|
async def get_valve_details(
|
||||||
file_id: Optional[int] = None,
|
file_id: Optional[int] = None,
|
||||||
job_no: Optional[str] = None,
|
job_no: Optional[str] = None,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
VALVE 상세 정보 조회
|
VALVE 상세 정보 조회
|
||||||
@@ -2016,7 +2025,8 @@ async def get_user_requirements(
|
|||||||
file_id: int,
|
file_id: int,
|
||||||
job_no: Optional[str] = None,
|
job_no: Optional[str] = None,
|
||||||
status: Optional[str] = None,
|
status: Optional[str] = None,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
사용자 요구사항 조회
|
사용자 요구사항 조회
|
||||||
@@ -2090,7 +2100,8 @@ class UserRequirementCreate(BaseModel):
|
|||||||
@router.post("/user-requirements")
|
@router.post("/user-requirements")
|
||||||
async def create_user_requirement(
|
async def create_user_requirement(
|
||||||
requirement: UserRequirementCreate,
|
requirement: UserRequirementCreate,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
사용자 요구사항 생성
|
사용자 요구사항 생성
|
||||||
@@ -2137,7 +2148,8 @@ async def create_user_requirement(
|
|||||||
async def delete_user_requirements(
|
async def delete_user_requirements(
|
||||||
file_id: Optional[int] = None,
|
file_id: Optional[int] = None,
|
||||||
material_id: Optional[int] = None,
|
material_id: Optional[int] = None,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
사용자 요구사항 삭제 (파일별 또는 자재별)
|
사용자 요구사항 삭제 (파일별 또는 자재별)
|
||||||
@@ -2622,7 +2634,8 @@ async def process_missing_drawings(
|
|||||||
file_id: int,
|
file_id: int,
|
||||||
action: str = "delete",
|
action: str = "delete",
|
||||||
drawings: List[str] = [],
|
drawings: List[str] = [],
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
누락된 도면 처리
|
누락된 도면 처리
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from typing import List, Optional, Dict
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
|
from ..auth.middleware import get_current_user
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -26,7 +27,8 @@ async def compare_material_revisions(
|
|||||||
current_revision: str,
|
current_revision: str,
|
||||||
previous_revision: Optional[str] = None,
|
previous_revision: Optional[str] = None,
|
||||||
save_result: bool = True,
|
save_result: bool = True,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
리비전간 자재 비교 및 추가 발주 필요량 계산
|
리비전간 자재 비교 및 추가 발주 필요량 계산
|
||||||
@@ -82,7 +84,8 @@ async def compare_material_revisions(
|
|||||||
async def get_comparison_history(
|
async def get_comparison_history(
|
||||||
job_no: str = Query(..., description="Job 번호"),
|
job_no: str = Query(..., description="Job 번호"),
|
||||||
limit: int = Query(10, ge=1, le=50),
|
limit: int = Query(10, ge=1, le=50),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
자재 비교 이력 조회
|
자재 비교 이력 조회
|
||||||
@@ -127,7 +130,8 @@ async def get_comparison_history(
|
|||||||
async def get_material_inventory_status(
|
async def get_material_inventory_status(
|
||||||
job_no: str = Query(..., description="Job 번호"),
|
job_no: str = Query(..., description="Job 번호"),
|
||||||
material_hash: Optional[str] = Query(None, description="특정 자재 해시"),
|
material_hash: Optional[str] = Query(None, description="특정 자재 해시"),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
자재별 누적 재고 현황 조회
|
자재별 누적 재고 현황 조회
|
||||||
@@ -148,8 +152,8 @@ async def confirm_material_purchase(
|
|||||||
job_no: str,
|
job_no: str,
|
||||||
revision: str,
|
revision: str,
|
||||||
confirmations: List[Dict],
|
confirmations: List[Dict],
|
||||||
confirmed_by: str = "system",
|
db: Session = Depends(get_db),
|
||||||
db: Session = Depends(get_db)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
자재 발주 확정 처리
|
자재 발주 확정 처리
|
||||||
@@ -162,6 +166,7 @@ async def confirm_material_purchase(
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
"""
|
"""
|
||||||
|
confirmed_by = current_user.get('username', current_user.get('name', 'unknown'))
|
||||||
try:
|
try:
|
||||||
# 입력 데이터 검증
|
# 입력 데이터 검증
|
||||||
if not job_no or not revision:
|
if not job_no or not revision:
|
||||||
@@ -265,7 +270,8 @@ async def get_purchase_status(
|
|||||||
job_no: str = Query(..., description="Job 번호"),
|
job_no: str = Query(..., description="Job 번호"),
|
||||||
revision: Optional[str] = Query(None, description="리비전 (전체 조회시 생략)"),
|
revision: Optional[str] = Query(None, description="리비전 (전체 조회시 생략)"),
|
||||||
status: Optional[str] = Query(None, description="발주 상태 필터"),
|
status: Optional[str] = Query(None, description="발주 상태 필터"),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
발주 상태 조회
|
발주 상태 조회
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ const vacationController = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async cancelRequest(req, res) {
|
async cancelRequest(req, res) {
|
||||||
|
const db = getPool();
|
||||||
|
const conn = await db.getConnection();
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const results = await vacationRequestModel.getById(id);
|
const results = await vacationRequestModel.getById(id);
|
||||||
@@ -145,11 +147,13 @@ const vacationController = {
|
|||||||
return res.status(400).json({ success: false, error: '이미 취소된 신청입니다' });
|
return res.status(400).json({ success: false, error: '이미 취소된 신청입니다' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
// 승인된 건 취소 시 잔여일 복구
|
// 승인된 건 취소 시 잔여일 복구
|
||||||
if (existing.status === 'approved') {
|
if (existing.status === 'approved') {
|
||||||
const year = new Date(existing.start_date).getFullYear();
|
const year = new Date(existing.start_date).getFullYear();
|
||||||
await vacationBalanceModel.restoreDays(
|
await vacationBalanceModel.restoreDays(
|
||||||
existing.user_id, existing.vacation_type_id, year, parseFloat(existing.days_used)
|
existing.user_id, existing.vacation_type_id, year, parseFloat(existing.days_used), conn
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,11 +161,16 @@ const vacationController = {
|
|||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
reviewed_by: userId,
|
reviewed_by: userId,
|
||||||
review_note: '취소됨'
|
review_note: '취소됨'
|
||||||
});
|
}, conn);
|
||||||
|
await conn.commit();
|
||||||
|
|
||||||
res.json({ success: true, message: '휴가 신청이 취소되었습니다' });
|
res.json({ success: true, message: '휴가 신청이 취소되었습니다' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await conn.rollback();
|
||||||
console.error('휴가 취소 오류:', error);
|
console.error('휴가 취소 오류:', error);
|
||||||
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다' });
|
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다' });
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -178,6 +187,8 @@ const vacationController = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async approveRequest(req, res) {
|
async approveRequest(req, res) {
|
||||||
|
const db = getPool();
|
||||||
|
const conn = await db.getConnection();
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { review_note } = req.body;
|
const { review_note } = req.body;
|
||||||
@@ -192,18 +203,22 @@ const vacationController = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const request = results[0];
|
const request = results[0];
|
||||||
|
|
||||||
// 잔여일 차감
|
|
||||||
const year = new Date(request.start_date).getFullYear();
|
const year = new Date(request.start_date).getFullYear();
|
||||||
await vacationBalanceModel.deductDays(
|
|
||||||
request.user_id, request.vacation_type_id, year, parseFloat(request.days_used)
|
|
||||||
);
|
|
||||||
|
|
||||||
await vacationRequestModel.updateStatus(id, { status: 'approved', reviewed_by, review_note });
|
await conn.beginTransaction();
|
||||||
|
await vacationBalanceModel.deductDays(
|
||||||
|
request.user_id, request.vacation_type_id, year, parseFloat(request.days_used), conn
|
||||||
|
);
|
||||||
|
await vacationRequestModel.updateStatus(id, { status: 'approved', reviewed_by, review_note }, conn);
|
||||||
|
await conn.commit();
|
||||||
|
|
||||||
res.json({ success: true, message: '휴가 신청이 승인되었습니다' });
|
res.json({ success: true, message: '휴가 신청이 승인되었습니다' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await conn.rollback();
|
||||||
console.error('휴가 승인 오류:', error);
|
console.error('휴가 승인 오류:', error);
|
||||||
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다' });
|
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다' });
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -92,14 +92,15 @@ const vacationBalanceModel = {
|
|||||||
// 2단계: 남은 차감분을 우선순위 순서로 (이미 차감한 행 제외)
|
// 2단계: 남은 차감분을 우선순위 순서로 (이미 차감한 행 제외)
|
||||||
if (remaining > 0) {
|
if (remaining > 0) {
|
||||||
const deductedIds = exactMatch.map(b => b.id);
|
const deductedIds = exactMatch.map(b => b.id);
|
||||||
const excludeClause = deductedIds.length > 0 ? `AND id NOT IN (${deductedIds.join(',')})` : '';
|
const excludeClause = deductedIds.length > 0 ? `AND id NOT IN (${Array(deductedIds.length).fill('?').join(',')})` : '';
|
||||||
|
const queryParams = [userId, year, ...deductedIds];
|
||||||
const [balances] = await c.query(`
|
const [balances] = await c.query(`
|
||||||
SELECT id, total_days, used_days, (total_days - used_days) AS remaining_days, balance_type
|
SELECT id, total_days, used_days, (total_days - used_days) AS remaining_days, balance_type
|
||||||
FROM sp_vacation_balances
|
FROM sp_vacation_balances
|
||||||
WHERE user_id = ? AND year = ? AND (total_days - used_days) > 0 ${excludeClause}
|
WHERE user_id = ? AND year = ? AND (total_days - used_days) > 0 ${excludeClause}
|
||||||
ORDER BY FIELD(balance_type, 'CARRY_OVER', 'AUTO', 'MANUAL', 'LONG_SERVICE', 'COMPANY_GRANT')
|
ORDER BY FIELD(balance_type, 'CARRY_OVER', 'AUTO', 'MANUAL', 'LONG_SERVICE', 'COMPANY_GRANT')
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
`, [userId, year]);
|
`, queryParams);
|
||||||
|
|
||||||
for (const b of balances) {
|
for (const b of balances) {
|
||||||
if (remaining <= 0) break;
|
if (remaining <= 0) break;
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
const { getPool } = require('../middleware/auth');
|
const { getPool } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const ALLOWED_CREATE_COLUMNS = ['user_id', 'vacation_type_id', 'start_date', 'end_date', 'days_used', 'reason', 'status', 'reviewed_by', 'review_note'];
|
||||||
|
const ALLOWED_UPDATE_COLUMNS = ['vacation_type_id', 'start_date', 'end_date', 'days_used', 'reason'];
|
||||||
|
|
||||||
const vacationRequestModel = {
|
const vacationRequestModel = {
|
||||||
async create(data, conn) {
|
async create(data, conn) {
|
||||||
const db = conn || getPool();
|
const db = conn || getPool();
|
||||||
const [result] = await db.query('INSERT INTO sp_vacation_requests SET ?', data);
|
const filtered = {};
|
||||||
|
for (const key of ALLOWED_CREATE_COLUMNS) {
|
||||||
|
if (data[key] !== undefined) filtered[key] = data[key];
|
||||||
|
}
|
||||||
|
const columns = Object.keys(filtered);
|
||||||
|
const placeholders = columns.map(() => '?').join(', ');
|
||||||
|
const values = columns.map(c => filtered[c]);
|
||||||
|
const [result] = await db.query(
|
||||||
|
`INSERT INTO sp_vacation_requests (${columns.join(', ')}) VALUES (${placeholders})`,
|
||||||
|
values
|
||||||
|
);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -81,7 +94,15 @@ const vacationRequestModel = {
|
|||||||
|
|
||||||
async update(requestId, data) {
|
async update(requestId, data) {
|
||||||
const db = getPool();
|
const db = getPool();
|
||||||
const [result] = await db.query('UPDATE sp_vacation_requests SET ? WHERE request_id = ?', [data, requestId]);
|
const filtered = {};
|
||||||
|
for (const key of ALLOWED_UPDATE_COLUMNS) {
|
||||||
|
if (data[key] !== undefined) filtered[key] = data[key];
|
||||||
|
}
|
||||||
|
const columns = Object.keys(filtered);
|
||||||
|
if (columns.length === 0) return { affectedRows: 0 };
|
||||||
|
const setClause = columns.map(c => `${c} = ?`).join(', ');
|
||||||
|
const values = [...columns.map(c => filtered[c]), requestId];
|
||||||
|
const [result] = await db.query(`UPDATE sp_vacation_requests SET ${setClause} WHERE request_id = ?`, values);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user