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:
Hyungi Ahn
2026-04-01 10:48:58 +09:00
parent 766cb90e8f
commit f09c86ee01
24 changed files with 215 additions and 305 deletions

View File

@@ -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"]

View File

@@ -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으로 응답하세요."""

View File

@@ -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}

View File

@@ -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}"

View File

@@ -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({

View File

@@ -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: '조회 중 오류가 발생했습니다.' });
} }
} }
}; };

View File

@@ -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;

View File

@@ -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: '페이지 접근 권한 요약을 불러오는데 실패했습니다.' });
} }
}); });

View File

@@ -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"]

View File

@@ -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")

View File

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

View File

@@ -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

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); } function escHtml(s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
// 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); });

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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); }

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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)
): ):
""" """
누락된 도면 처리 누락된 도면 처리

View File

@@ -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)
): ):
""" """
발주 상태 조회 발주 상태 조회

View File

@@ -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();
} }
}, },

View File

@@ -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;

View File

@@ -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;
}, },