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/*
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
RUN mkdir -p /app/data
|
||||
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||
COPY --chown=appuser:appuser . .
|
||||
RUN mkdir -p /app/data && chown appuser:appuser /app/data
|
||||
EXPOSE 8000
|
||||
USER appuser
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -2,6 +2,13 @@ import json
|
||||
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 도우미입니다.
|
||||
사용자가 현장에서 발견한 문제를 설명하면, 아래 카테고리 목록을 참고하여 가장 적합한 신고 유형과 카테고리를 제안해야 합니다.
|
||||
|
||||
@@ -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]
|
||||
category_context += f"\n[{type_label} ({type_key})]\n" + "\n".join(cat_names) + "\n"
|
||||
|
||||
safe_text = sanitize_user_input(user_text)
|
||||
prompt = f"""카테고리 목록:
|
||||
{category_context}
|
||||
|
||||
사용자 입력: "{user_text}"
|
||||
사용자 입력:
|
||||
<user_input>{safe_text}</user_input>
|
||||
|
||||
위 카테고리 목록을 참고하여 JSON으로 응답하세요."""
|
||||
|
||||
@@ -71,12 +80,14 @@ async def analyze_user_input(user_text: str, categories: dict) -> dict:
|
||||
async def summarize_report(data: dict) -> dict:
|
||||
"""최종 신고 내용을 요약"""
|
||||
prompt = f"""신고 정보:
|
||||
- 설명: {data.get('description', '')}
|
||||
- 유형: {data.get('type', '')}
|
||||
- 카테고리: {data.get('category', '')}
|
||||
- 항목: {data.get('item', '')}
|
||||
- 위치: {data.get('location', '')}
|
||||
- 프로젝트: {data.get('project', '')}
|
||||
<user_input>
|
||||
- 설명: {sanitize_user_input(data.get('description', ''))}
|
||||
- 유형: {sanitize_user_input(data.get('type', ''))}
|
||||
- 카테고리: {sanitize_user_input(data.get('category', ''))}
|
||||
- 항목: {sanitize_user_input(data.get('item', ''))}
|
||||
- 위치: {sanitize_user_input(data.get('location', ''))}
|
||||
- 프로젝트: {sanitize_user_input(data.get('project', ''))}
|
||||
</user_input>
|
||||
|
||||
위 정보를 보기 좋게 요약하여 JSON으로 응답하세요."""
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ services:
|
||||
- mariadb_data:/var/lib/mysql
|
||||
- ./scripts/migrate-users.sql:/docker-entrypoint-initdb.d/99-sso-users.sql
|
||||
ports:
|
||||
- "30306:3306"
|
||||
- "127.0.0.1:30306:3306"
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
|
||||
timeout: 20s
|
||||
@@ -608,7 +608,7 @@ services:
|
||||
container_name: tk-phpmyadmin
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "30880:80"
|
||||
- "127.0.0.1:30880:80"
|
||||
environment:
|
||||
- PMA_HOST=mariadb
|
||||
- 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);
|
||||
|
||||
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(
|
||||
{ user_id: user.user_id, type: 'refresh' },
|
||||
JWT_REFRESH_SECRET,
|
||||
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
|
||||
{ expiresIn: JWT_REFRESH_EXPIRES_IN, algorithm: 'HS256' }
|
||||
);
|
||||
|
||||
// SSO 쿠키는 클라이언트(login.html)에서 domain=.technicalkorea.net으로 설정
|
||||
@@ -159,7 +159,7 @@ async function loginForm(req, res, next) {
|
||||
await userModel.updateLastLogin(user.user_id);
|
||||
|
||||
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({
|
||||
access_token,
|
||||
@@ -187,7 +187,8 @@ async function validate(req, res, next) {
|
||||
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);
|
||||
if (!user || !user.is_active) {
|
||||
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' });
|
||||
}
|
||||
|
||||
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);
|
||||
if (!user || !user.is_active) {
|
||||
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 토큰이 필요합니다' });
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(refresh_token, JWT_REFRESH_SECRET);
|
||||
const decoded = jwt.verify(refresh_token, JWT_REFRESH_SECRET, { algorithms: ['HS256'] });
|
||||
if (decoded.type !== '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 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(
|
||||
{ user_id: user.user_id, type: 'refresh' },
|
||||
JWT_REFRESH_SECRET,
|
||||
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
|
||||
{ expiresIn: JWT_REFRESH_EXPIRES_IN, algorithm: 'HS256' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -163,7 +163,7 @@ const ProxyInputController = {
|
||||
} catch (err) {
|
||||
try { await conn.rollback(); } catch (e) {}
|
||||
logger.error('대리입력 오류:', err);
|
||||
res.status(500).json({ success: false, message: '대리입력 처리 중 오류가 발생했습니다.', error: err.message });
|
||||
res.status(500).json({ success: false, message: '대리입력 처리 중 오류가 발생했습니다.' });
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
@@ -182,7 +182,7 @@ const ProxyInputController = {
|
||||
res.json({ success: true, data });
|
||||
} catch (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 });
|
||||
} catch (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(
|
||||
'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]
|
||||
);
|
||||
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 });
|
||||
} catch (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]);
|
||||
|
||||
if (userRows.length === 0) {
|
||||
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다.' });
|
||||
return res.status(404).json({ success: false, message: '사용자를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
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 } });
|
||||
} catch (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) => {
|
||||
try {
|
||||
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;
|
||||
@@ -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]);
|
||||
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: '페이지 접근 권한이 업데이트되었습니다.' });
|
||||
} catch (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) => {
|
||||
try {
|
||||
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;
|
||||
@@ -163,7 +163,7 @@ router.delete('/users/:userId/page-access/:pageId', requireAuth, async (req, res
|
||||
res.json({ success: true, message: '페이지 접근 권한이 회수되었습니다.' });
|
||||
} catch (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) => {
|
||||
try {
|
||||
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();
|
||||
@@ -195,7 +195,7 @@ router.get('/page-access/summary', requireAuth, async (req, res) => {
|
||||
res.json({ success: true, data: summary });
|
||||
} catch (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 .
|
||||
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
|
||||
|
||||
# 애플리케이션 실행
|
||||
USER appuser
|
||||
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"))
|
||||
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")
|
||||
NODE_ENV: str = os.getenv("NODE_ENV", "development")
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
import aiohttp
|
||||
import jwt as pyjwt
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -206,10 +207,29 @@ 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"])
|
||||
async def proxy_to_express(path: str, request: Request) -> Dict[str, Any]:
|
||||
"""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 구성
|
||||
target_url = f"{settings.EXPRESS_API_URL}/api/{path}"
|
||||
|
||||
@@ -219,9 +239,9 @@ async def proxy_to_express(path: str, request: Request) -> Dict[str, Any]:
|
||||
|
||||
params = dict(request.query_params)
|
||||
|
||||
# GET 요청에 대해서만 캐싱 적용
|
||||
# GET 요청에 대해서만 캐싱 적용 (user_id 포함하여 사용자 간 캐시 격리)
|
||||
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)
|
||||
|
||||
if cached_result is not None:
|
||||
|
||||
@@ -4,3 +4,4 @@ aiohttp==3.9.1
|
||||
python-multipart==0.0.6
|
||||
redis==5.0.1
|
||||
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 escHtml(s) { return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||
// 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 projectItems = projects.map(function(p) {
|
||||
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-desc">' + esc(p.job_no || '') + '</div>' +
|
||||
'</div>';
|
||||
@@ -315,7 +315,7 @@
|
||||
// 공정 pill 버튼
|
||||
var pillHtml = workTypes.map(function(wt) {
|
||||
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('');
|
||||
pillHtml += '<button type="button" class="pill-btn-add" onclick="toggleAddWorkType()">+ 추가</button>';
|
||||
|
||||
@@ -335,7 +335,7 @@
|
||||
container.innerHTML =
|
||||
'<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="list-item-skip' + skipSelected + '" onclick="selectProject(null, \'\')">' +
|
||||
'<div class="list-item-skip' + skipSelected + '" data-action="selectProject" data-project-id="" data-project-name="">' +
|
||||
'선택 안함' +
|
||||
'</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) {
|
||||
|
||||
@@ -844,14 +844,14 @@
|
||||
|
||||
<!-- Scripts -->
|
||||
<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/base-state.js?v=2026031401"></script>
|
||||
<script src="/js/tbm/state.js?v=2026031401"></script>
|
||||
<script src="/js/tbm/utils.js?v=2026031401"></script>
|
||||
<script src="/js/tbm/api.js?v=2026031401"></script>
|
||||
<script src="/js/tbm-create.js?v=2026031401"></script>
|
||||
<script src="/js/common/utils.js?v=2026040101"></script>
|
||||
<script src="/js/common/base-state.js?v=2026040101"></script>
|
||||
<script src="/js/tbm/state.js?v=2026040101"></script>
|
||||
<script src="/js/tbm/utils.js?v=2026040101"></script>
|
||||
<script src="/js/tbm/api.js?v=2026040101"></script>
|
||||
<script src="/js/tbm-create.js?v=2026040101"></script>
|
||||
<script>initAuth();</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<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="/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>
|
||||
<body class="bg-gray-50">
|
||||
@@ -265,13 +265,13 @@
|
||||
|
||||
<!-- 공통 모듈 -->
|
||||
<script src="/static/js/tkfb-core.js?v=2026033108"></script>
|
||||
<script src="/js/api-base.js?v=2026031401"></script>
|
||||
<script src="/js/common/utils.js?v=2026031401"></script>
|
||||
<script src="/js/common/base-state.js?v=2026031401"></script>
|
||||
<script src="/js/api-base.js?v=2026040101"></script>
|
||||
<script src="/js/common/utils.js?v=2026040101"></script>
|
||||
<script src="/js/common/base-state.js?v=2026040101"></script>
|
||||
|
||||
<script src="/js/tbm/state.js?v=2026031401"></script>
|
||||
<script src="/js/tbm/utils.js?v=2026031401"></script>
|
||||
<script src="/js/tbm/api.js?v=2026031401"></script>
|
||||
<script src="/js/tbm/state.js?v=2026040101"></script>
|
||||
<script src="/js/tbm/utils.js?v=2026040101"></script>
|
||||
<script src="/js/tbm/api.js?v=2026040101"></script>
|
||||
<script src="/js/tbm-mobile.js?v=2026033102"></script>
|
||||
<script>initAuth();</script>
|
||||
</body>
|
||||
|
||||
@@ -64,6 +64,7 @@ function showToast(msg, type = 'success') {
|
||||
|
||||
/* ===== Escape ===== */
|
||||
function escapeHtml(str) { if (!str) return ''; const d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
|
||||
const escHtml = escapeHtml;
|
||||
|
||||
/* ===== Helpers ===== */
|
||||
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 .
|
||||
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 디렉토리 생성
|
||||
RUN mkdir -p /app/uploads
|
||||
RUN mkdir -p /app/uploads && chown appuser:appuser /app/uploads
|
||||
|
||||
# 포트 노출
|
||||
EXPOSE 8000
|
||||
|
||||
# 실행 명령
|
||||
USER appuser
|
||||
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 .
|
||||
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
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
USER appuser
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -164,11 +164,11 @@ ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"}
|
||||
# API 정보는 /info 엔드포인트로 이동됨
|
||||
|
||||
@router.get("/test")
|
||||
async def test_endpoint():
|
||||
async def test_endpoint(current_user: dict = Depends(get_current_user)):
|
||||
return {"status": "파일 API가 정상 작동합니다!"}
|
||||
|
||||
@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:
|
||||
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("/")
|
||||
async def get_files(
|
||||
job_no: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""파일 목록 조회"""
|
||||
try:
|
||||
@@ -646,7 +647,8 @@ async def get_files(
|
||||
@router.get("/list")
|
||||
async def get_files_list(
|
||||
job_no: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""파일 목록 조회 (리비전 모드 확인용)"""
|
||||
try:
|
||||
@@ -696,7 +698,8 @@ async def get_files_list(
|
||||
@router.get("/project/{project_code}")
|
||||
async def get_files_by_project(
|
||||
project_code: str,
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""프로젝트별 파일 목록 조회"""
|
||||
try:
|
||||
@@ -733,7 +736,7 @@ async def get_files_by_project(
|
||||
raise HTTPException(status_code=500, detail=f"프로젝트 파일 조회 실패: {str(e)}")
|
||||
|
||||
@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:
|
||||
# 총 파일 수
|
||||
@@ -774,7 +777,7 @@ async def get_files_stats(db: Session = Depends(get_db)):
|
||||
raise HTTPException(status_code=500, detail=f"통계 조회 실패: {str(e)}")
|
||||
|
||||
@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:
|
||||
# 자재 먼저 삭제
|
||||
@@ -814,7 +817,8 @@ async def get_materials(
|
||||
sort_by: Optional[str] = None,
|
||||
exclude_requested: bool = True, # 구매신청된 자재 제외 여부
|
||||
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가지로 필터링 가능) - 신버전
|
||||
@@ -1511,7 +1515,8 @@ async def get_materials(
|
||||
async def get_materials_summary(
|
||||
project_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:
|
||||
@@ -1566,7 +1571,8 @@ async def compare_revisions(
|
||||
filename: str,
|
||||
old_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(
|
||||
file_id: Optional[int] = None,
|
||||
job_no: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
PIPE 상세 정보 조회
|
||||
@@ -1905,7 +1912,8 @@ async def get_pipe_details(
|
||||
async def get_fitting_details(
|
||||
file_id: Optional[int] = None,
|
||||
job_no: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
FITTING 상세 정보 조회
|
||||
@@ -1960,7 +1968,8 @@ async def get_fitting_details(
|
||||
async def get_valve_details(
|
||||
file_id: Optional[int] = None,
|
||||
job_no: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
VALVE 상세 정보 조회
|
||||
@@ -2016,7 +2025,8 @@ async def get_user_requirements(
|
||||
file_id: int,
|
||||
job_no: 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")
|
||||
async def create_user_requirement(
|
||||
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(
|
||||
file_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,
|
||||
action: str = "delete",
|
||||
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 ..database import get_db
|
||||
from ..auth.middleware import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,7 +27,8 @@ async def compare_material_revisions(
|
||||
current_revision: str,
|
||||
previous_revision: Optional[str] = None,
|
||||
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(
|
||||
job_no: str = Query(..., description="Job 번호"),
|
||||
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(
|
||||
job_no: str = Query(..., description="Job 번호"),
|
||||
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,
|
||||
revision: str,
|
||||
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:
|
||||
# 입력 데이터 검증
|
||||
if not job_no or not revision:
|
||||
@@ -265,7 +270,8 @@ async def get_purchase_status(
|
||||
job_no: str = Query(..., description="Job 번호"),
|
||||
revision: 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) {
|
||||
const db = getPool();
|
||||
const conn = await db.getConnection();
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const results = await vacationRequestModel.getById(id);
|
||||
@@ -145,11 +147,13 @@ const vacationController = {
|
||||
return res.status(400).json({ success: false, error: '이미 취소된 신청입니다' });
|
||||
}
|
||||
|
||||
await conn.beginTransaction();
|
||||
|
||||
// 승인된 건 취소 시 잔여일 복구
|
||||
if (existing.status === 'approved') {
|
||||
const year = new Date(existing.start_date).getFullYear();
|
||||
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',
|
||||
reviewed_by: userId,
|
||||
review_note: '취소됨'
|
||||
});
|
||||
}, conn);
|
||||
await conn.commit();
|
||||
|
||||
res.json({ success: true, message: '휴가 신청이 취소되었습니다' });
|
||||
} catch (error) {
|
||||
await conn.rollback();
|
||||
console.error('휴가 취소 오류:', error);
|
||||
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다' });
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -178,6 +187,8 @@ const vacationController = {
|
||||
},
|
||||
|
||||
async approveRequest(req, res) {
|
||||
const db = getPool();
|
||||
const conn = await db.getConnection();
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { review_note } = req.body;
|
||||
@@ -192,18 +203,22 @@ const vacationController = {
|
||||
}
|
||||
|
||||
const request = results[0];
|
||||
|
||||
// 잔여일 차감
|
||||
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: '휴가 신청이 승인되었습니다' });
|
||||
} catch (error) {
|
||||
await conn.rollback();
|
||||
console.error('휴가 승인 오류:', error);
|
||||
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다' });
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -92,14 +92,15 @@ const vacationBalanceModel = {
|
||||
// 2단계: 남은 차감분을 우선순위 순서로 (이미 차감한 행 제외)
|
||||
if (remaining > 0) {
|
||||
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(`
|
||||
SELECT id, total_days, used_days, (total_days - used_days) AS remaining_days, balance_type
|
||||
FROM sp_vacation_balances
|
||||
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')
|
||||
FOR UPDATE
|
||||
`, [userId, year]);
|
||||
`, queryParams);
|
||||
|
||||
for (const b of balances) {
|
||||
if (remaining <= 0) break;
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
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 = {
|
||||
async create(data, conn) {
|
||||
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;
|
||||
},
|
||||
|
||||
@@ -81,7 +94,15 @@ const vacationRequestModel = {
|
||||
|
||||
async update(requestId, data) {
|
||||
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;
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user