feat: tkuser 통합 관리 서비스 + 전체 시스템 SSO 쿠키 인증 통합

- tkuser 서비스 신규 추가 (API + Web)
  - 사용자/권한/프로젝트/부서/작업자/작업장/설비/작업/휴가 통합 관리
  - 작업장 탭: 공장→작업장 드릴다운 네비게이션 + 구역지도 클릭 연동
  - 작업 탭: 공정(work_types)→작업(tasks) 계층 관리
  - 휴가 탭: 유형 관리 + 연차 배정(근로기준법 자동계산)
- 전 시스템 SSO 쿠키 인증으로 통합 (.technicalkorea.net 공유)
- System 2: 작업 이슈 리포트 기능 강화
- System 3: tkuser API 연동, 페이지 권한 체계 적용
- docker-compose에 tkuser-api, tkuser-web 서비스 추가
- ARCHITECTURE.md, DEPLOYMENT.md 문서 작성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-12 13:45:52 +09:00
parent 6495b8af32
commit 733bb0cb35
96 changed files with 9721 additions and 825 deletions

View File

@@ -8,7 +8,7 @@ WORKDIR /usr/src/app
COPY package*.json ./
# 프로덕션 의존성만 설치
RUN npm ci --only=production
RUN npm install --omit=dev
# 앱 소스 복사
COPY . .

View File

@@ -13,10 +13,14 @@ const logger = require('../utils/logger');
* 허용된 Origin 목록
*/
const allowedOrigins = [
'http://localhost:20000', // 웹 UI
'https://tkfb.technicalkorea.net', // Gateway (프로덕션)
'https://tkreport.technicalkorea.net', // System 2
'https://tkqc.technicalkorea.net', // System 3
'http://localhost:20000', // 웹 UI (로컬)
'http://localhost:30080', // 웹 UI (Docker)
'http://localhost:3005', // API 서버
'http://localhost:3000', // 개발 포트
'http://127.0.0.1:20000', // 로컬호스트 대체
'http://127.0.0.1:20000',
'http://127.0.0.1:3005',
'http://127.0.0.1:3000'
];

View File

@@ -7,7 +7,7 @@ from typing import List
class Settings:
# 기본 설정
FASTAPI_PORT: int = int(os.getenv("FASTAPI_PORT", "8000"))
EXPRESS_API_URL: str = os.getenv("EXPRESS_API_URL", "http://api:20005")
EXPRESS_API_URL: str = os.getenv("EXPRESS_API_URL", "http://system1-api:3005")
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379")
NODE_ENV: str = os.getenv("NODE_ENV", "development")

View File

@@ -6,25 +6,29 @@ function getApiBaseUrl() {
const hostname = window.location.hostname;
const protocol = window.location.protocol;
const port = window.location.port;
console.log('🌐 감지된 환경:', { hostname, protocol, port });
// 🔗 nginx 프록시를 통한 접근 (권장)
// nginx가 /api/ 요청을 백엔드로 프록시하므로 포트 없이 접근
if (hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.') ||
hostname === 'localhost' || hostname === '127.0.0.1' ||
hostname.includes('.local') || hostname.includes('hyungi')) {
// 현재 웹서버의 도메인/IP를 그대로 사용하되 API 포트(config.api.port)로 직접 연결
const baseUrl = `${protocol}//${hostname}:${config.api.port}${config.api.path}`;
console.log('✅ nginx 프록시 사용:', baseUrl);
// 🔗 외부 도메인 (Cloudflare Tunnel) - Gateway nginx가 /api/를 프록시
if (hostname.includes('technicalkorea.net')) {
const baseUrl = `${protocol}//${hostname}${config.api.path}`;
console.log('✅ Gateway 프록시 사용:', baseUrl);
return baseUrl;
}
// 🚨 백업: 직접 접근 (nginx 프록시 실패시에만)
console.warn('⚠️ 직접 API 접근 (백업 모드)');
return `${protocol}//${hostname}:${config.api.port}${config.api.path}`;
// 🔗 로컬/내부 네트워크 - API 포트 직접 접근
if (hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.') ||
hostname === 'localhost' || hostname === '127.0.0.1' ||
hostname.includes('.local') || hostname.includes('hyungi')) {
const baseUrl = `${protocol}//${hostname}:${config.api.port}${config.api.path}`;
console.log('✅ 로컬 직접 접근:', baseUrl);
return baseUrl;
}
// 🚨 기타: 포트 없이 상대 경로
const baseUrl = `${protocol}//${hostname}${config.api.path}`;
console.log('✅ 기본 프록시 사용:', baseUrl);
return baseUrl;
}
// API 설정

View File

@@ -2,7 +2,7 @@
// ES6 모듈 의존성 제거 - 브라우저 호환성 개선
// API 설정 (window 객체에서 가져오기)
const API_BASE_URL = window.API_BASE_URL || 'http://localhost:20005/api';
const API_BASE_URL = window.API_BASE_URL || 'http://localhost:30005/api';
// 인증 관련 함수들 (직접 구현)
function getToken() {

View File

@@ -7,7 +7,7 @@ export const config = {
// API 관련 설정
api: {
// 로컬 개발 및 Docker 환경에서 사용하는 API 서버 포트
port: 20005,
port: 30005,
// API의 기본 경로
path: '/api',
},

View File

@@ -1115,7 +1115,7 @@ async function loadWorkplaceMap(categoryId, layoutImagePath, workplaces) {
mapCtx = mapCanvas.getContext('2d');
// 이미지 URL 생성
const baseUrl = window.API_BASE_URL || 'http://localhost:20005';
const baseUrl = window.API_BASE_URL || 'http://localhost:30005';
const apiBaseUrl = baseUrl.replace('/api', ''); // /api 제거
const fullImageUrl = layoutImagePath.startsWith('http')
? layoutImagePath

View File

@@ -3,7 +3,7 @@
* category_type=nonconformity 고정 필터
*/
const API_BASE = window.API_BASE_URL || 'http://localhost:20005/api';
const API_BASE = window.API_BASE_URL || 'http://localhost:30005/api';
const CATEGORY_TYPE = 'nonconformity';
// 상태 한글 변환
@@ -110,7 +110,7 @@ function renderIssues(issues) {
return;
}
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
const baseUrl = (window.API_BASE_URL || 'http://localhost:30005').replace('/api', '');
issueList.innerHTML = issues.map(issue => {
const reportDate = new Date(issue.report_date).toLocaleString('ko-KR', {

View File

@@ -3,7 +3,7 @@
* category_type=safety 고정 필터
*/
const API_BASE = window.API_BASE_URL || 'http://localhost:20005/api';
const API_BASE = window.API_BASE_URL || 'http://localhost:30005/api';
const CATEGORY_TYPE = 'safety';
// 상태 한글 변환
@@ -110,7 +110,7 @@ function renderIssues(issues) {
return;
}
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
const baseUrl = (window.API_BASE_URL || 'http://localhost:30005').replace('/api', '');
issueList.innerHTML = issues.map(issue => {
const reportDate = new Date(issue.report_date).toLocaleString('ko-KR', {

View File

@@ -1672,7 +1672,7 @@ async function loadWorkplaceMap(categoryId, layoutImagePath) {
mapCtx = mapCanvas.getContext('2d');
// 이미지 URL 생성
const baseUrl = window.API_BASE_URL || 'http://localhost:20005';
const baseUrl = window.API_BASE_URL || 'http://localhost:30005';
const apiBaseUrl = baseUrl.replace('/api', ''); // /api 제거
const fullImageUrl = layoutImagePath.startsWith('http')
? layoutImagePath

View File

@@ -304,7 +304,7 @@ async function loadWorkplaceMap() {
// 레이아웃 이미지가 있으면 표시
if (selectedCategory && selectedCategory.layout_image) {
// API_BASE_URL에서 /api 제거하고 이미지 경로 생성
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
const baseUrl = (window.API_BASE_URL || 'http://localhost:30005').replace('/api', '');
const fullImageUrl = selectedCategory.layout_image.startsWith('http')
? selectedCategory.layout_image
: `${baseUrl}${selectedCategory.layout_image}`;

View File

@@ -5,7 +5,7 @@
class WorkAnalysisAPIClient {
constructor() {
this.baseURL = window.API_BASE_URL || 'http://localhost:20005/api';
this.baseURL = window.API_BASE_URL || 'http://localhost:30005/api';
}
/**

View File

@@ -88,7 +88,7 @@ async function loadLayoutMapData() {
// 이미지 경로를 전체 URL로 변환
const fullImageUrl = category.layout_image.startsWith('http')
? category.layout_image
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${category.layout_image}`.replace('/api/', '/');
: `${window.API_BASE_URL || 'http://localhost:30005/api'}${category.layout_image}`.replace('/api/', '/');
currentImageDiv.innerHTML = `
<img src="${fullImageUrl}" style="max-width: 100%; max-height: 300px; border-radius: 4px;" alt="현재 레이아웃 이미지">
@@ -210,7 +210,7 @@ async function uploadLayoutImage() {
formData.append('image', file);
// 업로드 요청
const response = await fetch(`${window.API_BASE_URL || 'http://localhost:20005/api'}/workplaces/categories/${currentCategoryId}/layout-image`, {
const response = await fetch(`${window.API_BASE_URL || 'http://localhost:30005/api'}/workplaces/categories/${currentCategoryId}/layout-image`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('sso_token')}`
@@ -224,7 +224,7 @@ async function uploadLayoutImage() {
window.showToast('이미지가 성공적으로 업로드되었습니다.', 'success');
// 이미지 경로를 전체 URL로 변환
const fullImageUrl = `${window.API_BASE_URL || 'http://localhost:20005/api'}${result.data.image_path}`.replace('/api/', '/');
const fullImageUrl = `${window.API_BASE_URL || 'http://localhost:30005/api'}${result.data.image_path}`.replace('/api/', '/');
// 이미지를 캔버스에 로드
loadImageToCanvas(fullImageUrl);

View File

@@ -147,7 +147,7 @@ async function updateLayoutPreview(category) {
// 이미지 경로를 전체 URL로 변환
const fullImageUrl = category.layout_image.startsWith('http')
? category.layout_image
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${category.layout_image}`.replace('/api/', '/');
: `${window.API_BASE_URL || 'http://localhost:30005/api'}${category.layout_image}`.replace('/api/', '/');
// Canvas 컨테이너 생성
previewDiv.innerHTML = `
@@ -267,7 +267,7 @@ async function loadWorkplaceMapThumbnail(workplace) {
if (workplace.layout_image) {
const fullImageUrl = workplace.layout_image.startsWith('http')
? workplace.layout_image
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${workplace.layout_image}`.replace('/api/', '/');
: `${window.API_BASE_URL || 'http://localhost:30005/api'}${workplace.layout_image}`.replace('/api/', '/');
// 설비 정보 로드
let equipmentCount = 0;
@@ -327,7 +327,7 @@ async function loadWorkplaceMapThumbnail(workplace) {
const fullImageUrl = category.layout_image.startsWith('http')
? category.layout_image
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${category.layout_image}`.replace('/api/', '/');
: `${window.API_BASE_URL || 'http://localhost:30005/api'}${category.layout_image}`.replace('/api/', '/');
// 캔버스 생성
const canvasId = `thumbnail-canvas-${workplace.workplace_id}`;
@@ -1019,7 +1019,7 @@ async function openWorkplaceMapModal(workplaceId) {
if (preview && workplace.layout_image) {
const fullImageUrl = workplace.layout_image.startsWith('http')
? workplace.layout_image
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${workplace.layout_image}`.replace('/api/', '/');
: `${window.API_BASE_URL || 'http://localhost:30005/api'}${workplace.layout_image}`.replace('/api/', '/');
preview.innerHTML = `<img src="${fullImageUrl}" alt="작업장 레이아웃" style="max-width: 100%; border-radius: 4px;">`;
// 캔버스 초기화
@@ -1126,7 +1126,7 @@ async function uploadWorkplaceLayout() {
try {
showToast('이미지 업로드 중...', 'info');
const response = await fetch(`${window.API_BASE_URL || 'http://localhost:20005/api'}/workplaces/${window.currentWorkplaceMapId}/layout-image`, {
const response = await fetch(`${window.API_BASE_URL || 'http://localhost:30005/api'}/workplaces/${window.currentWorkplaceMapId}/layout-image`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('sso_token')}`
@@ -1148,7 +1148,7 @@ async function uploadWorkplaceLayout() {
if (preview && result.data.image_path) {
const fullImageUrl = result.data.image_path.startsWith('http')
? result.data.image_path
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${result.data.image_path}`.replace('/api/', '/');
: `${window.API_BASE_URL || 'http://localhost:30005/api'}${result.data.image_path}`.replace('/api/', '/');
preview.innerHTML = `<img src="${fullImageUrl}" alt="작업장 레이아웃" style="max-width: 100%; border-radius: 4px;">`;
// 캔버스 초기화 (설비 영역 편집용)
@@ -1678,7 +1678,7 @@ async function openFullscreenEquipmentEditor() {
// 이미지 URL 생성
const fullImageUrl = workplace.layout_image.startsWith('http')
? workplace.layout_image
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${workplace.layout_image}`.replace('/api/', '/');
: `${window.API_BASE_URL || 'http://localhost:30005/api'}${workplace.layout_image}`.replace('/api/', '/');
// 캔버스 초기화
initFullscreenCanvas(fullImageUrl);

View File

@@ -25,7 +25,7 @@ class WorkplaceUtils {
* API URL 생성
*/
getApiBaseUrl() {
return window.API_BASE_URL || 'http://localhost:20005/api';
return window.API_BASE_URL || 'http://localhost:30005/api';
}
/**

View File

@@ -129,7 +129,7 @@ async function loadMapImage() {
return new Promise((resolve, reject) => {
const img = new Image();
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
const baseUrl = (window.API_BASE_URL || 'http://localhost:30005').replace('/api', '');
const fullImageUrl = selectedCategory.layout_image.startsWith('http')
? selectedCategory.layout_image
: `${baseUrl}${selectedCategory.layout_image}`;
@@ -563,7 +563,7 @@ async function initDetailMap(workplace) {
// 작업장에 레이아웃 이미지가 있는지 확인
if (workplace.layout_image) {
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
const baseUrl = (window.API_BASE_URL || 'http://localhost:30005').replace('/api', '');
const imageUrl = workplace.layout_image.startsWith('http')
? workplace.layout_image
: `${baseUrl}${workplace.layout_image}`;

View File

@@ -7,7 +7,7 @@
<title>작업 현황판 | 테크니컬코리아</title>
<!-- 리소스 프리로딩 -->
<link rel="preconnect" href="http://localhost:20005" crossorigin>
<!-- preconnect는 Gateway 프록시 사용 시 불필요 -->
<link rel="preload" href="/css/design-system.css" as="style">
<link rel="preload" href="/js/api-base.js" as="script">
<link rel="preload" href="/js/app-init.js?v=2" as="script">