Compare commits
239 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b17c1fadf | |||
| 7d204bfffd | |||
| bd4286f7ac | |||
| 318cac4f68 | |||
| 2f398ae0b3 | |||
| 58ede650ae | |||
| 4c5b672f40 | |||
| 904fdd33e7 | |||
| f73e468f66 | |||
| b25a6324f8 | |||
| fc615a70be | |||
| 947b31eff5 | |||
| 46707bd116 | |||
| 467a41a3a8 | |||
| 75f6883497 | |||
| d306ac2865 | |||
| 78c5e3e358 | |||
| 6b204806b6 | |||
| d8877b243a | |||
| 90787d837f | |||
| 752e4fb644 | |||
| 14832a28ab | |||
| a0a4dc3bf5 | |||
| 8fff53b165 | |||
| c530a67cee | |||
| 34060d9534 | |||
| 2348800e68 | |||
| d61777ab5f | |||
| d5f9814865 | |||
| 824a3100ce | |||
| 387a5c2bd7 | |||
| 3883031c0b | |||
| 2f52d9587e | |||
| 4f13d2e440 | |||
| 1613fae8fb | |||
| c350ebe86a | |||
| 5335dc78b0 | |||
| ecad2915ce | |||
| 0552425f47 | |||
| ca241c017d | |||
| ec679ac640 | |||
| 1e1b3e103c | |||
| 35d5a00b20 | |||
| 0365b743f5 | |||
| ff3d4c2cc5 | |||
| 44f5b134a5 | |||
| ff4033b927 | |||
| efea906ead | |||
| 420b92bc7b | |||
| 0328f618b9 | |||
| f53307a72e | |||
| cbf94dc90f | |||
| aeddd7dc2a | |||
| 5fdd1c67b1 | |||
| 54a8f97f78 | |||
| 0199d1624b | |||
| b3f955d97d | |||
| ae899a3589 | |||
| 43b0455364 | |||
| b752de23a1 | |||
| 574319811c | |||
| 8f92fb2368 | |||
| 6fcb101f59 | |||
| 47eed68072 | |||
| d8f606ab00 | |||
| e8f517ed18 | |||
| d02bc38f6c | |||
| 0c9e22a679 | |||
| 570b3267ab | |||
| 0bba1836fb | |||
| f70719aecb | |||
| 3ab7deb196 | |||
| d592547242 | |||
| 6f8461a533 | |||
| 17172cf9b3 | |||
| f9a9c67891 | |||
| f31a7f852f | |||
| 2675c82904 | |||
| dce665caea | |||
| 5c0dca004b | |||
| acbd61e25a | |||
| dc77c07cc4 | |||
| 6d5ca2f23a | |||
| 1ba310236c | |||
| 0d5d1fe10d | |||
| c3e04adb23 | |||
| 7bd08dcf9d | |||
| 3dbc2107d8 | |||
| 3eeb0764bf | |||
| 4a8413000b | |||
| 7706403caa | |||
| 3c24956efd | |||
| a7683d4d0e | |||
| c3e5d7fc1b | |||
| 33a245e4e8 | |||
| c4a62b7e35 | |||
| 081feff51f | |||
| 90035dd5c6 | |||
| ffa6799053 | |||
| 7315603f0f | |||
| 8bdc9a958f | |||
| 6561aad7ef | |||
| af23fd0316 | |||
| 6a9fc06f0e | |||
| c0bd420c66 | |||
| 6ab7c3e780 | |||
| 4a83bfc8e8 | |||
| 6a7d261d23 | |||
| e4856dcae5 | |||
| baffd6affb | |||
| a5bbd1eb7c | |||
| 1bd0fd8b80 | |||
| 4b97448467 | |||
| 63279296f8 | |||
| 1b9604f66e | |||
| b16439098a | |||
| 36d93d91cf | |||
| 77bd8cab75 | |||
| f0781022de | |||
| 0a8be2df1e | |||
| 9c658ffd36 | |||
| 68c1cb5b14 | |||
| 74ddc4936f | |||
| 3d220373d8 | |||
| 3d5b2a4911 | |||
| d2b77d348b | |||
| 0e895a90fa | |||
| 59f5cf22f0 | |||
| c4631efbd2 | |||
| 84b9060e4e | |||
| 3280be8bd4 | |||
| b782bb298f | |||
| 798fdf18b3 | |||
| 8eb4e8c9a2 | |||
| 48d74170fc | |||
| e8ba13f52b | |||
| 5d2283cb47 | |||
| de0bfc1af4 | |||
| f3c3087393 | |||
| da77de58ac | |||
| 59fbbd95fa | |||
| e1be30f8ca | |||
| 47a2f97da5 | |||
| d6cfa9973f | |||
| a6b66ac0d1 | |||
| 4514275347 | |||
| ec9ef74204 | |||
| d0dd86371b | |||
| 11d46c98bf | |||
| 80cd2b2d07 | |||
| e70267f738 | |||
| 3a0ab10ee6 | |||
| 7635412b7b | |||
| b999b425cb | |||
| 19f7615367 | |||
| 04cea72f33 | |||
| 53f2638b82 | |||
| db06c95724 | |||
| a76b85f1e5 | |||
| 81637b64a0 | |||
| 5cc255d8df | |||
| 8804b9fbfa | |||
| 9755869754 | |||
| b7ebc69755 | |||
| bad1010621 | |||
| df9a539017 | |||
| 59f230187d | |||
| 9d11616761 | |||
| 205428533d | |||
| b5a60d1c8b | |||
| 1d37b8d2ea | |||
| 016442973e | |||
| faa77160af | |||
| 42f7ae35db | |||
| 552cb50bbd | |||
| dc4508d5ea | |||
| ecca02fbbf | |||
| 7d9ec39b5d | |||
| 4d19c31440 | |||
| a41f99c579 | |||
| cdc55dfd48 | |||
| 517c42b5cb | |||
| 2d11d222db | |||
| 57ffbcbbc7 | |||
| 3ed53a6708 | |||
| 3003a056d9 | |||
| e44ba2953a | |||
| 3e6bce70d1 | |||
| 49b4cdf562 | |||
| 7c57c69f84 | |||
| a74dff4fa2 | |||
| 442f641305 | |||
| 5f945363b2 | |||
| 2d938ea45b | |||
| 280e25a4df | |||
| 568eb14503 | |||
| e347a75953 | |||
| a3c74f926c | |||
| 36d6ad508d | |||
| 22d073f563 | |||
| 7e3e4078d0 | |||
| b2ac4a08bf | |||
| 6965dfdd57 | |||
| d0978f9398 | |||
| a8ded6455d | |||
| a6be4f2efe | |||
| 383b837a60 | |||
| e16fb16987 | |||
| 8b8186d1c0 | |||
| 12454a79d4 | |||
| 2b954db854 | |||
| 57c6c1b472 | |||
| 52386efb83 | |||
| eed70014c2 | |||
| 4306fa6f4b | |||
| 4b31fe1b27 | |||
| 29682e5b63 | |||
| 229b09b895 | |||
| b81794a2a5 | |||
| 30ab45a3c6 | |||
| b1971d9107 | |||
| 2431451cef | |||
| 5af633d251 | |||
| 1761b5d599 | |||
| 68f85f3736 | |||
| 06998cd2a5 | |||
| 6d4f486e35 | |||
| 5812925929 | |||
| 76f43cea9b | |||
| 8c861144dc | |||
| 8be7e16e56 | |||
| 94c9b4b602 | |||
| 563aef6490 | |||
| 991b3aa831 | |||
| 4508766d06 | |||
| 44c7313deb | |||
| 1a4586e126 | |||
| 3eda684787 | |||
| a5de92de65 |
@@ -11,7 +11,7 @@ description: API 요청 시 항상 전용 API 클라이언트를 사용하도록
|
||||
|
||||
## 이유
|
||||
|
||||
1. **환경별 URL 자동 처리**: 프로덕션(`v1.invion.com`)과 개발(`localhost`) 환경에서 올바른 백엔드 서버로 요청
|
||||
1. **환경별 URL 자동 처리**: 프로덕션(`v1.invyone.com`)과 개발(`localhost`) 환경에서 올바른 백엔드 서버로 요청
|
||||
2. **일관된 에러 처리**: 모든 API 호출에서 동일한 에러 핸들링
|
||||
3. **인증 토큰 자동 포함**: Authorization 헤더 자동 추가
|
||||
4. **유지보수성**: API 변경 시 한 곳에서만 수정
|
||||
@@ -116,9 +116,9 @@ const getApiBaseUrl = (): string => {
|
||||
if (typeof window !== "undefined") {
|
||||
const currentHost = window.location.hostname;
|
||||
|
||||
// 프로덕션: v1.invion.com → api.invion.com
|
||||
if (currentHost === "v1.invion.com") {
|
||||
return "https://api.invion.com/api";
|
||||
// 프로덕션: v1.invyone.com → api.invyone.com
|
||||
if (currentHost === "v1.invyone.com") {
|
||||
return "https://api.invyone.com/api";
|
||||
}
|
||||
|
||||
// 로컬 개발
|
||||
@@ -155,7 +155,7 @@ API 클라이언트는 자동으로 환경을 감지합니다:
|
||||
|
||||
| 현재 호스트 | 백엔드 API URL |
|
||||
| ---------------- | ----------------------------- |
|
||||
| `v1.invion.com` | `https://api.invion.com/api` |
|
||||
| `v1.invyone.com` | `https://api.invyone.com/api` |
|
||||
| `localhost:9771` | `http://localhost:8080/api` |
|
||||
| `localhost:3000` | `http://localhost:8080/api` |
|
||||
|
||||
|
||||
@@ -661,7 +661,7 @@ if (req.user && req.user.companyCode !== "*") {
|
||||
|
||||
| 환경 | 프론트엔드 | 백엔드 API |
|
||||
|------|-----------|-----------|
|
||||
| 프로덕션 | `v1.invion.com` | `https://api.invion.com/api` |
|
||||
| 프로덕션 | `v1.invyone.com` | `https://api.invyone.com/api` |
|
||||
| 개발 (로컬) | `localhost:9771` 또는 `localhost:3000` | `http://localhost:8080/api` |
|
||||
|
||||
- 프론트엔드 `apiClient`가 `window.location.hostname` 기반으로 자동 판별
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Force LF for shell scripts and Gradle wrapper so they work in Linux containers
|
||||
# regardless of host autocrlf settings.
|
||||
*.sh text eol=lf
|
||||
gradlew text eol=lf
|
||||
@@ -31,6 +31,7 @@ jobs:
|
||||
- name: Build frontend
|
||||
run: |
|
||||
docker build -t ${{ env.REGISTRY }}/${{ env.PROJECT }}/frontend:${{ env.SHORT_SHA }} \
|
||||
--build-arg GIT_SHA=${{ env.SHORT_SHA }} \
|
||||
-f docker/deploy/frontend.Dockerfile \
|
||||
frontend/
|
||||
|
||||
@@ -70,3 +71,56 @@ jobs:
|
||||
# Rollout 상태 확인
|
||||
kubectl rollout status deployment/backend-spring -n invyone --timeout=180s
|
||||
kubectl rollout status deployment/frontend -n invyone --timeout=120s
|
||||
|
||||
# ---- 실패 시 진단 (pod stdout / events 캡처) ----
|
||||
- name: Diagnose on failure
|
||||
if: failure()
|
||||
run: |
|
||||
export KUBECONFIG=/home/chpark/.kube/config
|
||||
echo "============================================"
|
||||
echo "=== Pods (-n invyone) ==="
|
||||
echo "============================================"
|
||||
kubectl get pods -n invyone -o wide || true
|
||||
|
||||
echo
|
||||
echo "============================================"
|
||||
echo "=== Deployment images (현재 어떤 tag 가 떠있는지) ==="
|
||||
echo "============================================"
|
||||
kubectl get deploy -n invyone -o jsonpath='{range .items[*]}{.metadata.name}{": "}{.spec.template.spec.containers[*].image}{"\n"}{end}' || true
|
||||
|
||||
echo
|
||||
echo "============================================"
|
||||
echo "=== backend-spring describe ==="
|
||||
echo "============================================"
|
||||
kubectl describe deployment backend-spring -n invyone || true
|
||||
kubectl describe pods -n invyone -l app=backend-spring | tail -120 || true
|
||||
|
||||
echo
|
||||
echo "============================================"
|
||||
echo "=== backend-spring ALL pod logs (per pod) ==="
|
||||
echo "============================================"
|
||||
# deployment/<name> selector 는 active ReplicaSet 한 개만 봐서
|
||||
# 새로 뜨다 죽은 ReplicaSet 의 pod 를 놓침.
|
||||
# label selector 로 모든 backend-spring pod 순회.
|
||||
for p in $(kubectl get pods -n invyone -l app=backend-spring -o name 2>/dev/null); do
|
||||
echo "------------------------------"
|
||||
echo "--- $p (current, tail 500) ---"
|
||||
echo "------------------------------"
|
||||
kubectl logs -n invyone $p --all-containers=true --tail=500 2>&1 || true
|
||||
echo
|
||||
echo "--- $p (previous, if exists, tail 500) ---"
|
||||
kubectl logs -n invyone $p --all-containers=true --tail=500 --previous 2>&1 || true
|
||||
echo
|
||||
done
|
||||
|
||||
echo
|
||||
echo "============================================"
|
||||
echo "=== frontend logs (참고, tail 200) ==="
|
||||
echo "============================================"
|
||||
kubectl logs -n invyone deployment/frontend --tail=200 --all-containers=true || true
|
||||
|
||||
echo
|
||||
echo "============================================"
|
||||
echo "=== Recent Warning events ==="
|
||||
echo "============================================"
|
||||
kubectl get events -n invyone --sort-by='.lastTimestamp' --field-selector type=Warning 2>/dev/null | tail -30 || true
|
||||
|
||||
+10
-1
@@ -2,6 +2,14 @@
|
||||
.claude/
|
||||
CLAUDE.local.md
|
||||
|
||||
# direnv (per-developer JAVA_HOME / shell env)
|
||||
.envrc
|
||||
.direnv/
|
||||
|
||||
# OMC (oh-my-claudecode) 작업용 임시 상태 — 절대 추적 금지
|
||||
# planning, autopilot state, agent transcript, project memory 등 포함
|
||||
.omc/
|
||||
|
||||
# Syncthing local stub (each machine has its own; real patterns in .stignore-shared)
|
||||
.stignore
|
||||
.stfolder/
|
||||
@@ -40,6 +48,7 @@ bin/
|
||||
|
||||
# Gradle
|
||||
.gradle/
|
||||
.gradle-cache/
|
||||
**/backend/.gradle/
|
||||
|
||||
# Cache
|
||||
@@ -122,6 +131,7 @@ tokens.json
|
||||
|
||||
# 데이터베이스 덤프
|
||||
*.sql
|
||||
!**/db/migration/*.sql
|
||||
*.dump
|
||||
db/dump/
|
||||
db/backup/
|
||||
@@ -173,7 +183,6 @@ uploads/
|
||||
*.hwpx
|
||||
|
||||
# ===== 기타 =====
|
||||
claude.md
|
||||
|
||||
# Agent Pipeline 로컬 파일
|
||||
_local/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
21
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: web-prototype
|
||||
description: |
|
||||
General-purpose desktop web prototype. Single self-contained HTML file built
|
||||
by copying the seed `assets/template.html` and pasting section layouts from
|
||||
`references/layouts.md`. Default for any landing / marketing / docs / SaaS
|
||||
page when no more specific skill matches.
|
||||
triggers:
|
||||
- "prototype"
|
||||
- "mockup"
|
||||
- "landing"
|
||||
- "single page"
|
||||
- "marketing page"
|
||||
- "homepage"
|
||||
od:
|
||||
mode: prototype
|
||||
platform: desktop
|
||||
scenario: design
|
||||
preview:
|
||||
type: html
|
||||
entry: index.html
|
||||
design_system:
|
||||
requires: true
|
||||
sections: [color, typography, layout, components]
|
||||
---
|
||||
|
||||
# Web Prototype Skill
|
||||
|
||||
Produce a single, self-contained HTML prototype using the bundled seed and layout library — **not** by writing CSS from scratch. The seed already encodes good defaults (typography, spacing, accent budget). Your job is to compose it.
|
||||
|
||||
## Resource map
|
||||
|
||||
```
|
||||
web-prototype/
|
||||
├── SKILL.md ← you're reading this
|
||||
├── assets/
|
||||
│ └── template.html ← seed: tokens + class system + chrome (READ FIRST)
|
||||
└── references/
|
||||
├── layouts.md ← 8 paste-ready section skeletons
|
||||
└── checklist.md ← P0/P1/P2 self-review
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 0 — Pre-flight (do this once before writing anything)
|
||||
|
||||
1. **Read `assets/template.html` end-to-end** — at minimum through the `<style>` block. The class inventory at the top of `references/layouts.md` lists every class that must be defined there; if one is missing, add it to `<style>` rather than re-defining it inline on every section.
|
||||
2. **Read `references/layouts.md`** so you know which section skeletons exist. Don't write a section type that isn't covered — pick the closest layout and adapt.
|
||||
3. **Read the active DESIGN.md** (already injected into your system prompt). Map its colors to the six `:root` variables in the seed; don't introduce new tokens.
|
||||
|
||||
### Step 1 — Copy the seed
|
||||
|
||||
Copy `assets/template.html` to the project root as `index.html`. Replace the six `:root` variables with the active design system's tokens. Replace the page `<title>` and the topnav brand.
|
||||
|
||||
### Step 2 — Plan the section list
|
||||
|
||||
**Pick layouts before writing copy.** Default rhythms (from `layouts.md`):
|
||||
|
||||
| Page kind | Default rhythm |
|
||||
|---|---|
|
||||
| Landing | 1 hero → 3 features → 4 stats *or* 5 quote → custom split → 6 cta |
|
||||
| Marketing / editorial | 1 hero-center → 7 log list → 6 cta |
|
||||
| Pricing | 1 hero-center → 8 comparison table → 6 cta |
|
||||
| Docs index | 1 hero-center → 7 log list (sections of docs) → 6 cta |
|
||||
|
||||
State the chosen list in one sentence to the user *before* writing — they can redirect cheaply now and not after 200 lines of HTML.
|
||||
|
||||
### Step 3 — Paste and fill
|
||||
|
||||
For each chosen layout, copy the `<section>` block from `layouts.md` into `<main id="content">` of your `index.html`. Replace bracketed `[REPLACE]` strings with real, specific copy from the user's brief. **No filler** — if a slot is empty, the section is the wrong choice; pick a different layout.
|
||||
|
||||
### Step 4 — Self-check
|
||||
|
||||
Run through `references/checklist.md` top to bottom. Every P0 item must pass before you move on. P1 items should pass; P2 are bonus.
|
||||
|
||||
### Step 5 — Emit the artifact
|
||||
|
||||
Wrap `index.html` in `<artifact>` tags. One sentence before describing what's there. Stop after `</artifact>`.
|
||||
|
||||
## Hard rules (the seed protects most of these — don't fight it)
|
||||
|
||||
- **Single accent, used at most twice per screen.** Eyebrow + primary CTA is the default budget.
|
||||
- **Display font is serif** (Iowan Old Style / Charter / Georgia in the seed). Sans for body. Mono for numerics, captions, eyebrows.
|
||||
- **Image placeholders, not external URLs.** Use the `.ph-img` class — never link to a stock photo CDN.
|
||||
- **Mobile reflow already works** via the seed's media query at 920px. Don't break it by adding fixed widths.
|
||||
- **`data-od-id` on every `<section>`** so comment mode can target it.
|
||||
|
||||
## Output contract
|
||||
|
||||
```
|
||||
<artifact identifier="kebab-case-slug" type="text/html" title="Human Title">
|
||||
<!doctype html>
|
||||
<html>...</html>
|
||||
</artifact>
|
||||
```
|
||||
|
||||
One sentence before the artifact. Nothing after.
|
||||
@@ -0,0 +1,338 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
OD web-prototype seed.
|
||||
|
||||
Copy this file to <project>/index.html, then fill `<main id="content">` by
|
||||
pasting blocks from `references/layouts.md`. Every class referenced in
|
||||
layouts.md is defined here — DO NOT remove unused ones unless you also
|
||||
remove their callers, and DO NOT invent new global classes (use inline
|
||||
style="…" for one-off tweaks).
|
||||
|
||||
Theme tokens at the top of `<style>` are the *only* place to set palette
|
||||
and type. They map cleanly onto a DESIGN.md — when an active design system
|
||||
is injected, swap these six variables and everything reflows.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>[REPLACE] Page title · brand</title>
|
||||
<style>
|
||||
/* ─── tokens ─────────────────────────────────────────────────────────
|
||||
Six variables. Bind them to the active DESIGN.md and stop.
|
||||
Do not introduce raw hex anywhere else in this file. */
|
||||
:root {
|
||||
--bg: #fafaf7; /* page background — never #fff */
|
||||
--surface: #ffffff; /* cards, modals, raised areas */
|
||||
--fg: #1a1916; /* primary text — never #000 */
|
||||
--muted: #6b6964; /* secondary text, captions */
|
||||
--border: #e8e5df; /* hairlines, dividers */
|
||||
--accent: #c96442; /* one accent — used at most 2× per screen */
|
||||
|
||||
/* derived — do not change */
|
||||
--accent-soft: color-mix(in oklch, var(--accent) 14%, transparent);
|
||||
--fg-soft: color-mix(in oklch, var(--fg) 6%, transparent);
|
||||
|
||||
/* type — display = serif (default), body = sans, mono for numerics */
|
||||
--font-display: 'Iowan Old Style', 'Charter', Georgia, 'Times New Roman', serif;
|
||||
--font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
--font-mono: ui-monospace, 'JetBrains Mono', 'SF Mono', Menlo, monospace;
|
||||
|
||||
/* scale — clamp() so it works at 1280, 1440, 1920 without media queries */
|
||||
--fs-h1: clamp(44px, 6vw, 76px);
|
||||
--fs-h2: clamp(32px, 4vw, 48px);
|
||||
--fs-h3: 22px;
|
||||
--fs-lead: 19px;
|
||||
--fs-body: 16px;
|
||||
--fs-meta: 13px;
|
||||
|
||||
/* spacing — 8-point grid */
|
||||
--gap-xs: 8px;
|
||||
--gap-sm: 12px;
|
||||
--gap-md: 20px;
|
||||
--gap-lg: 32px;
|
||||
--gap-xl: 56px;
|
||||
--gap-2xl: 96px;
|
||||
--container: 1120px;
|
||||
--gutter: 32px;
|
||||
|
||||
--radius: 10px;
|
||||
--radius-lg: 16px;
|
||||
}
|
||||
|
||||
/* ─── reset & base ──────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--fs-body);
|
||||
line-height: 1.55;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
img, svg { display: block; max-width: 100%; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { font: inherit; cursor: pointer; }
|
||||
p { text-wrap: pretty; }
|
||||
h1, h2, h3, h4 { text-wrap: balance; }
|
||||
|
||||
/* ─── layout primitives ─────────────────────────────────────────── */
|
||||
.container {
|
||||
max-width: var(--container);
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--gutter);
|
||||
}
|
||||
.section {
|
||||
padding-block: clamp(48px, 8vw, var(--gap-2xl));
|
||||
}
|
||||
.section + .section { border-top: 1px solid var(--border); }
|
||||
.stack { display: flex; flex-direction: column; }
|
||||
.stack > * + * { margin-top: var(--gap-md); }
|
||||
.row { display: flex; align-items: center; gap: var(--gap-md); }
|
||||
.row-between { display: flex; align-items: center; justify-content: space-between; gap: var(--gap-md); }
|
||||
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--gap-lg); }
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--gap-lg); }
|
||||
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--gap-md); }
|
||||
.grid-2-1 { display: grid; grid-template-columns: 2fr 1fr; gap: var(--gap-xl); align-items: start; }
|
||||
.grid-1-2 { display: grid; grid-template-columns: 1fr 2fr; gap: var(--gap-xl); align-items: start; }
|
||||
@media (max-width: 920px) {
|
||||
.grid-2, .grid-3, .grid-4, .grid-2-1, .grid-1-2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ─── type ──────────────────────────────────────────────────────── */
|
||||
.h1, h1 { font-family: var(--font-display); font-size: var(--fs-h1); line-height: 1.04; letter-spacing: -0.02em; margin: 0; }
|
||||
.h2, h2 { font-family: var(--font-display); font-size: var(--fs-h2); line-height: 1.1; letter-spacing: -0.015em; margin: 0; }
|
||||
.h3, h3 { font-size: var(--fs-h3); font-weight: 600; line-height: 1.3; letter-spacing: -0.005em; margin: 0; }
|
||||
.lead { font-size: var(--fs-lead); line-height: 1.55; color: var(--muted); max-width: 60ch; margin: 0; }
|
||||
.eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin: 0 0 var(--gap-md);
|
||||
}
|
||||
.meta { font-family: var(--font-mono); font-size: var(--fs-meta); color: var(--muted); }
|
||||
.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ─── chrome: nav + footer ──────────────────────────────────────── */
|
||||
.topnav {
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
background: color-mix(in oklch, var(--bg) 92%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.topnav-inner { display: flex; align-items: center; justify-content: space-between; padding-block: 14px; }
|
||||
.topnav .logo { font-family: var(--font-display); font-size: 19px; font-weight: 600; letter-spacing: -0.01em; }
|
||||
.topnav nav { display: flex; gap: var(--gap-lg); }
|
||||
.topnav nav a { font-size: 14px; color: var(--muted); }
|
||||
.topnav nav a:hover { color: var(--fg); }
|
||||
.pagefoot { padding-block: var(--gap-xl); color: var(--muted); font-size: 13px; border-top: 1px solid var(--border); }
|
||||
.pagefoot .row-between { flex-wrap: wrap; gap: var(--gap-md); }
|
||||
|
||||
/* ─── buttons ───────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 11px 20px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid transparent;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.005em;
|
||||
transition: transform 0.05s ease, background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn-primary { background: var(--accent); color: var(--surface); border-color: var(--accent); }
|
||||
.btn-primary:hover { background: color-mix(in oklch, var(--accent) 88%, black); }
|
||||
.btn-secondary { background: transparent; color: var(--fg); border-color: var(--border); }
|
||||
.btn-secondary:hover { border-color: var(--fg); }
|
||||
.btn-ghost { background: transparent; color: var(--fg); border-color: transparent; padding-inline: 8px; }
|
||||
.btn-ghost:hover { color: var(--accent); }
|
||||
.btn-arrow::after { content: '→'; transition: transform 0.15s ease; }
|
||||
.btn-arrow:hover::after { transform: translateX(2px); }
|
||||
|
||||
/* ─── card / surface ────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 28px;
|
||||
}
|
||||
.card-flat { background: transparent; border: 0; padding: 0; }
|
||||
.card-rule { background: transparent; border: 0; border-top: 1px solid var(--fg); padding: 24px 0 0; border-radius: 0; }
|
||||
|
||||
/* ─── feature cell (icon + h3 + p) ──────────────────────────────── */
|
||||
.feature .feature-mark {
|
||||
width: 36px; height: 36px;
|
||||
display: grid; place-items: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--accent);
|
||||
margin-bottom: var(--gap-md);
|
||||
}
|
||||
.feature .feature-mark svg { width: 18px; height: 18px; }
|
||||
.feature h3 { margin-bottom: 6px; }
|
||||
.feature p { margin: 0; color: var(--muted); font-size: 15px; }
|
||||
|
||||
/* ─── stat (big number + label) ─────────────────────────────────── */
|
||||
.stat .stat-num {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(56px, 8vw, 96px);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.stat .stat-label { color: var(--muted); font-size: 14px; margin-top: 8px; max-width: 24ch; }
|
||||
.stat .stat-unit { font-size: 0.5em; opacity: 0.7; margin-left: 2px; }
|
||||
|
||||
/* ─── quote / testimonial ───────────────────────────────────────── */
|
||||
.quote {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(24px, 2.6vw, 32px);
|
||||
line-height: 1.32;
|
||||
letter-spacing: -0.01em;
|
||||
max-width: 28ch;
|
||||
}
|
||||
.quote-author { color: var(--muted); font-size: 14px; margin-top: var(--gap-md); }
|
||||
.quote-mark {
|
||||
font-family: var(--font-display);
|
||||
font-size: 140px; line-height: 0.7;
|
||||
color: var(--accent); opacity: 0.18;
|
||||
margin-bottom: -28px;
|
||||
}
|
||||
|
||||
/* ─── pill / badge / tag ────────────────────────────────────────── */
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tag {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 4px 10px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ─── form field ────────────────────────────────────────────────── */
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field label { font-size: 13px; color: var(--muted); }
|
||||
.input, .textarea {
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
}
|
||||
.input:focus, .textarea:focus {
|
||||
outline: 2px solid var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.textarea { min-height: 96px; resize: vertical; line-height: 1.55; }
|
||||
|
||||
/* ─── table (data-style, no chrome) ─────────────────────────────── */
|
||||
.ds-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.ds-table th, .ds-table td { padding: 12px 14px; text-align: left; border-bottom: 1px solid var(--border); }
|
||||
.ds-table th { color: var(--muted); font-weight: 500; font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.04em; text-transform: uppercase; }
|
||||
.ds-table tbody tr:hover { background: var(--fg-soft); }
|
||||
.ds-table .num-col { font-family: var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; }
|
||||
|
||||
/* ─── image placeholder (DS-tinted block, never broken <img>) ──── */
|
||||
.ph-img {
|
||||
background:
|
||||
linear-gradient(135deg, var(--accent-soft), var(--fg-soft)),
|
||||
var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
aspect-ratio: 16 / 10;
|
||||
display: grid; place-items: center;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.ph-img.square { aspect-ratio: 1 / 1; }
|
||||
.ph-img.portrait { aspect-ratio: 3 / 4; }
|
||||
.ph-img.wide { aspect-ratio: 16 / 9; }
|
||||
|
||||
/* ─── divider ───────────────────────────────────────────────────── */
|
||||
.rule { border: 0; border-top: 1px solid var(--border); margin: 0; }
|
||||
.rule-strong { border: 0; border-top: 1px solid var(--fg); margin: 0; }
|
||||
|
||||
/* ─── hero variants used by layouts.md ──────────────────────────── */
|
||||
.hero { padding-block: clamp(80px, 12vw, 160px); }
|
||||
.hero-center { text-align: center; max-width: 32ch; margin-inline: auto; }
|
||||
.hero h1 { margin-bottom: var(--gap-md); }
|
||||
.hero .lead { margin-bottom: var(--gap-lg); }
|
||||
.hero-cta { display: inline-flex; gap: var(--gap-sm); flex-wrap: wrap; }
|
||||
.hero-center .hero-cta { justify-content: center; }
|
||||
.hero-split { display: grid; grid-template-columns: 1fr 1fr; gap: var(--gap-2xl); align-items: center; }
|
||||
@media (max-width: 920px) { .hero-split { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ─── log row (newsletter, blog list, changelog) ────────────────── */
|
||||
.log-row { display: grid; grid-template-columns: 120px 1fr 100px; gap: var(--gap-lg); padding: 22px 0; border-top: 1px solid var(--border); align-items: baseline; }
|
||||
.log-row .meta { color: var(--muted); }
|
||||
.log-row h3 { font-size: 19px; }
|
||||
.log-row .pull { text-align: right; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topnav" data-od-id="topnav">
|
||||
<div class="container topnav-inner">
|
||||
<span class="logo">[REPLACE] Brand</span>
|
||||
<nav>
|
||||
<a href="#">[REPLACE] Link 1</a>
|
||||
<a href="#">[REPLACE] Link 2</a>
|
||||
<a href="#">[REPLACE] Link 3</a>
|
||||
</nav>
|
||||
<button class="btn btn-primary">[REPLACE] CTA</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="content">
|
||||
<!--
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ PASTE LAYOUTS FROM references/layouts.md HERE. │
|
||||
│ ► Each layout block is a self-contained `<section>` — │
|
||||
│ drop in 3–6 of them per page. │
|
||||
│ ► Always lead with one hero (Layout 1 or 2). │
|
||||
│ ► End with a CTA strip + footer (Layout 6 / footer below). │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
-->
|
||||
<section class="section hero" data-od-id="hero">
|
||||
<div class="container hero-center">
|
||||
<p class="eyebrow">[REPLACE] Eyebrow</p>
|
||||
<h1>[REPLACE] One sharp sentence about what this is.</h1>
|
||||
<p class="lead">[REPLACE] One subhead sentence — concrete value, not a tagline.</p>
|
||||
<div class="hero-cta">
|
||||
<button class="btn btn-primary">[REPLACE] Primary CTA</button>
|
||||
<button class="btn btn-secondary">[REPLACE] Secondary</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="pagefoot" data-od-id="footer">
|
||||
<div class="container row-between">
|
||||
<span>© [REPLACE] Brand · [REPLACE] Year</span>
|
||||
<span class="meta">[REPLACE] tagline · contact@example.com</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,81 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Tomato — focused work timer</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #fafaf9; --fg: #1c1b1a; --muted: #6b6964; --border: #e6e4e0;
|
||||
--accent: #c96442; --surface: #ffffff;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.55 -apple-system, system-ui, sans-serif; }
|
||||
header, main, footer { max-width: 1080px; margin: 0 auto; padding: 0 32px; }
|
||||
header { display: flex; justify-content: space-between; align-items: center; padding-top: 20px; }
|
||||
.logo { font-weight: 600; font-size: 17px; letter-spacing: -0.01em; }
|
||||
nav a { color: var(--fg); text-decoration: none; margin-left: 24px; font-size: 14px; }
|
||||
nav a:hover { color: var(--accent); }
|
||||
.hero { padding: 96px 0 80px; text-align: center; }
|
||||
.hero h1 { font-size: clamp(44px, 6vw, 76px); line-height: 1.05; letter-spacing: -0.02em; margin: 0 0 20px; max-width: 18ch; margin-inline: auto; }
|
||||
.hero p { color: var(--muted); font-size: 19px; max-width: 52ch; margin: 0 auto 32px; }
|
||||
.cta { display: inline-flex; gap: 12px; }
|
||||
button { font: inherit; cursor: pointer; padding: 12px 22px; border-radius: 8px; }
|
||||
.btn-primary { background: var(--accent); color: white; border: 1px solid var(--accent); font-weight: 500; }
|
||||
.btn-secondary { background: transparent; color: var(--fg); border: 1px solid var(--border); }
|
||||
.features { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; padding: 56px 0 96px; }
|
||||
@media (max-width: 720px) { .features { grid-template-columns: 1fr; } }
|
||||
.feature { padding: 28px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; }
|
||||
.feature .icon { width: 36px; height: 36px; border-radius: 8px; background: var(--accent); margin-bottom: 16px; opacity: 0.12; }
|
||||
.feature h3 { margin: 0 0 8px; font-size: 17px; letter-spacing: -0.01em; }
|
||||
.feature p { margin: 0; color: var(--muted); font-size: 14px; }
|
||||
.closing { padding: 64px 0 96px; text-align: center; border-top: 1px solid var(--border); }
|
||||
.closing h2 { font-size: 32px; margin: 0 0 12px; letter-spacing: -0.01em; }
|
||||
.closing p { color: var(--muted); margin: 0 0 28px; }
|
||||
footer { padding: 32px; color: var(--muted); font-size: 13px; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header data-od-id="topnav">
|
||||
<span class="logo">🍅 Tomato</span>
|
||||
<nav>
|
||||
<a href="#features">Features</a>
|
||||
<a href="#pricing">Pricing</a>
|
||||
<a href="#login">Sign in</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<section class="hero" data-od-id="hero">
|
||||
<h1>Twenty-five minutes at a time.</h1>
|
||||
<p>The pomodoro timer that actually keeps your hands off Slack. Block notifications, log every cycle, ship more before lunch.</p>
|
||||
<div class="cta">
|
||||
<button class="btn-primary">Start a session</button>
|
||||
<button class="btn-secondary">See how it works</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="features" id="features">
|
||||
<div class="feature" data-od-id="feature-block">
|
||||
<div class="icon"></div>
|
||||
<h3>Block on, not off</h3>
|
||||
<p>Slack and email go quiet for 25 minutes. They come back loud at the break, with a digest.</p>
|
||||
</div>
|
||||
<div class="feature" data-od-id="feature-stats">
|
||||
<div class="icon"></div>
|
||||
<h3>Stats that don't lie</h3>
|
||||
<p>Weekly review tells you which days you actually shipped versus which you only seemed busy.</p>
|
||||
</div>
|
||||
<div class="feature" data-od-id="feature-team">
|
||||
<div class="icon"></div>
|
||||
<h3>Team-friendly silences</h3>
|
||||
<p>Your status auto-updates so teammates know when to ask, when to wait, and when you're done.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="closing" data-od-id="closing">
|
||||
<h2>Stop measuring meetings. Start measuring focus.</h2>
|
||||
<p>Free for solo. $4/mo per teammate after that.</p>
|
||||
<button class="btn-primary">Try Tomato free</button>
|
||||
</section>
|
||||
</main>
|
||||
<footer>© Tomato Labs · Made for people who'd rather be making.</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
|
||||
"specVersion": "1.0.0",
|
||||
"name": "example-web-prototype",
|
||||
"title": "Web Prototype",
|
||||
"version": "0.1.1",
|
||||
"description": "General-purpose desktop web prototype. Single self-contained HTML file built\nby copying the seed `assets/template.html` and pasting section layouts from\n`references/layouts.md`. Default for any landing / marketing / docs / SaaS\npage when no more specific skill matches.",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Open Design",
|
||||
"url": "https://github.com/nexu-io"
|
||||
},
|
||||
"homepage": "https://github.com/nexu-io/open-design/tree/main/plugins/_official/examples/web-prototype",
|
||||
"tags": [
|
||||
"example",
|
||||
"first-party",
|
||||
"prototype",
|
||||
"design",
|
||||
"web",
|
||||
"desktop",
|
||||
"mockup",
|
||||
"landing",
|
||||
"single-page",
|
||||
"marketing-page",
|
||||
"homepage"
|
||||
],
|
||||
"compat": {
|
||||
"agentSkills": [
|
||||
{
|
||||
"path": "./SKILL.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
"od": {
|
||||
"kind": "scenario",
|
||||
"taskKind": "new-generation",
|
||||
"mode": "prototype",
|
||||
"platform": "desktop",
|
||||
"scenario": "design",
|
||||
"surface": "web",
|
||||
"preview": {
|
||||
"type": "html",
|
||||
"entry": "./example.html"
|
||||
},
|
||||
"useCase": {
|
||||
"query": {
|
||||
"en": "Build a {{fidelity}} {{artifactKind}} for {{audience}}. Use {{designSystem}} as the design-system direction and start from {{template}}. Single self-contained HTML file built by copying the seed `assets/template.html` and pasting section layouts from `references/layouts.md`.",
|
||||
"zh-CN": "使用这个插件完成以下任务:为 {{audience}} 构建一个 {{fidelity}} 的 {{artifactKind}}。设计系统方向使用 {{designSystem}},从 {{template}} 开始。使用 `assets/template.html` 种子并从 `references/layouts.md` 粘贴版面,输出单文件 HTML。"
|
||||
},
|
||||
"exampleOutputs": [
|
||||
{
|
||||
"path": "./example.html",
|
||||
"title": "Web Prototype"
|
||||
}
|
||||
]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"name": "artifactKind",
|
||||
"label": "Artifact kind",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"placeholder": "SaaS landing page",
|
||||
"default": "web prototype"
|
||||
},
|
||||
{
|
||||
"name": "fidelity",
|
||||
"label": "Fidelity",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"options": [
|
||||
"wireframe",
|
||||
"high-fidelity"
|
||||
],
|
||||
"default": "high-fidelity"
|
||||
},
|
||||
{
|
||||
"name": "audience",
|
||||
"label": "Audience",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"placeholder": "startup founders evaluating an AI CRM",
|
||||
"default": "product evaluators"
|
||||
},
|
||||
{
|
||||
"name": "designSystem",
|
||||
"label": "Design system",
|
||||
"type": "string",
|
||||
"placeholder": "OpenAI, Linear, shadcn, or custom brand notes",
|
||||
"default": "the active project design system"
|
||||
},
|
||||
{
|
||||
"name": "template",
|
||||
"label": "Template",
|
||||
"type": "string",
|
||||
"placeholder": "marketing homepage, dashboard, docs page",
|
||||
"default": "the bundled web prototype seed"
|
||||
}
|
||||
],
|
||||
"context": {
|
||||
"skills": [
|
||||
{
|
||||
"path": "./SKILL.md"
|
||||
}
|
||||
],
|
||||
"designSystem": {
|
||||
"primary": true
|
||||
},
|
||||
"assets": [
|
||||
"./example.html",
|
||||
"./assets/template.html",
|
||||
"./references/checklist.md",
|
||||
"./references/layouts.md"
|
||||
]
|
||||
},
|
||||
"pipeline": {
|
||||
"stages": [
|
||||
{
|
||||
"id": "generate",
|
||||
"atoms": [
|
||||
"file-write",
|
||||
"live-artifact"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": [
|
||||
"prompt:inject",
|
||||
"fs:write"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# Web prototype checklist
|
||||
|
||||
Run this before emitting `<artifact>`. P0 = must pass; P1 = should pass; P2 = nice to have.
|
||||
|
||||
## P0 — must pass
|
||||
|
||||
- [ ] **No raw hex outside `:root` token block.** Every color is `var(--bg)` / `var(--fg)` / `var(--muted)` / `var(--border)` / `var(--accent)` / `var(--surface)` (or a `color-mix()` of those). Grep `#[0-9a-fA-F]{3,8}` outside `:root{}` should return nothing.
|
||||
- [ ] **All headings use `var(--font-display)`.** No sans-serif `<h1>` / `<h2>`. Inter / Roboto / system-sans never serve as a display face.
|
||||
- [ ] **Accent appears at most twice per screen.** Count: eyebrow color, primary CTA fill, anything else? If three or more, demote one to `var(--fg)` or `var(--muted)`.
|
||||
- [ ] **No purple/violet gradient backgrounds.** No `linear-gradient(... #a855f7 / #8b5cf6 / purple ...)`. The seed template has no gradients on backgrounds — keep it that way.
|
||||
- [ ] **No emoji used as feature icons.** Use the inline SVG monoline marks shipped in Layout 3, or a tasteful single-character glyph in `--font-mono`. ✨ 🚀 🎯 are out.
|
||||
- [ ] **No invented metrics.** Every number on the page came from the user, the brief, or is clearly labelled as a placeholder (e.g. `[REPLACE] · 38×`). "10× faster", "99.9% uptime" without source = remove.
|
||||
- [ ] **No filler copy.** Zero "Feature One / Feature Two", lorem ipsum, "Lorem ipsum dolor". If a section feels empty, delete it; do not pad.
|
||||
- [ ] **`data-od-id` on every top-level `<section>`.** Used by comment mode to target sections.
|
||||
- [ ] **Mobile reflow works.** All `grid-2`, `grid-3`, `grid-4`, `grid-2-1`, `grid-1-2` collapse to one column at ≤920px (the default media query in `template.html` does this). Verify by mentally narrowing — no horizontal scroll.
|
||||
- [ ] **No `scrollIntoView()` calls.** Breaks the OD preview iframe. Use `scrollTo({...})` if you need scroll behaviour.
|
||||
|
||||
## P1 — should pass
|
||||
|
||||
- [ ] **One decisive flourish.** A pull quote, a striking stat, a real-feeling photograph, one micro-animation on the hero. *One.* Not three.
|
||||
- [ ] **Section rhythm alternates.** No two stat rows in a row. No two feature triplets in a row. No two quote blocks in a row.
|
||||
- [ ] **Headlines under 14 words.** If longer, the writing is doing the design's job.
|
||||
- [ ] **Lead text under 56 ch / two sentences.** `max-width: 60ch` on `.lead` enforces this; don't override.
|
||||
- [ ] **CTA buttons say what happens.** "Start free" beats "Get Started". "Read the story" beats "Learn More".
|
||||
- [ ] **Hover states present** for all `<a>` and `.btn`. Seed template covers this.
|
||||
- [ ] **Numerics use `.num` (mono, tabular).** Prices, stats, version numbers, dates.
|
||||
- [ ] **One image style per page.** Don't mix square portrait headshots with widescreen product hero with vertical phone mock — pick a lane.
|
||||
|
||||
## P2 — nice to have
|
||||
|
||||
- [ ] **`text-wrap: pretty` / `balance`** on long paragraphs / headings (already on `<p>` and `h*` in seed).
|
||||
- [ ] **`color-mix()` for derived tones.** No additional `--accent-50` / `--accent-300` Bootstrap-style tokens — derive on the spot.
|
||||
- [ ] **Sticky topnav has frosted glass** (already in seed via `backdrop-filter: blur()`).
|
||||
- [ ] **Loaded fonts are system-first.** Iowan Old Style / Charter for serif, system stack for sans. Only pull a Google Font if DESIGN.md specifies one.
|
||||
|
||||
## Anti-slop spot-check
|
||||
|
||||
Look at the page for two seconds. If your gut says any of:
|
||||
|
||||
- "looks like every Cursor / Linear / Vercel ripoff I've seen this month"
|
||||
- "this could be any AI startup's homepage"
|
||||
- "the feature row has an icon, a heading, and three lines of vague benefit copy"
|
||||
|
||||
…go back, replace one feature cell with something more specific to *this* product (a screenshot, a concrete example, a sample of the actual output), and remove one accent.
|
||||
@@ -0,0 +1,247 @@
|
||||
# Web prototype layouts
|
||||
|
||||
**8 paste-ready section skeletons.** Drop into `<main id="content">` of `assets/template.html`. Don't write sections from scratch — pick the closest layout, paste, swap copy.
|
||||
|
||||
## Pre-flight (do this once before pasting anything)
|
||||
|
||||
1. **Read `assets/template.html`** through the end of the `<style>` block. Every class used below must exist there. If one is missing, add it to `<style>` rather than inlining it on each section.
|
||||
2. **Pick a section list before writing copy.** Default rhythms:
|
||||
- **Landing**: 1 hero → 2 features → 3 stat-row OR quote → 4 split → 6 cta-strip → footer
|
||||
- **Marketing / editorial**: 1 hero-center → 7 log-list → 4 split → 6 cta-strip
|
||||
- **Pricing / docs**: 1 hero-center → table-driven → 6 cta-strip
|
||||
3. **One accent per screen, used at most twice.** The hero eyebrow and the primary button already use it; budget any third usage carefully.
|
||||
|
||||
## Class inventory (must exist in `template.html`)
|
||||
|
||||
> `section` `container` `hero` `hero-center` `hero-split` `hero-cta` `eyebrow` `lead` `h1` `h2` `h3` `meta` `num` `btn` `btn-primary` `btn-secondary` `btn-ghost` `btn-arrow` `card` `card-flat` `card-rule` `feature` `feature-mark` `stat` `stat-num` `stat-label` `stat-unit` `quote` `quote-mark` `quote-author` `pill` `tag` `field` `input` `textarea` `ds-table` `num-col` `ph-img` `square` `portrait` `wide` `rule` `rule-strong` `grid-2` `grid-3` `grid-4` `grid-2-1` `grid-1-2` `row` `row-between` `stack` `log-row` `pull` `topnav` `pagefoot`
|
||||
|
||||
If you reach for a class not on this list, define it in `<style>` first or use `style="…"` inline. Never invent a global class on a `<section>` that isn't backed by CSS.
|
||||
|
||||
---
|
||||
|
||||
## Layout 1 — Hero, centered
|
||||
|
||||
Use when the page leads with a thesis sentence (most landings, most marketing pages). One eyebrow, one h1 (≤14 words), one lead sentence, two CTAs.
|
||||
|
||||
```html
|
||||
<section class="section hero" data-od-id="hero">
|
||||
<div class="container hero-center">
|
||||
<p class="eyebrow">EYEBROW · CONTEXT</p>
|
||||
<h1>One sharp sentence about what this is.</h1>
|
||||
<p class="lead">One concrete-value subhead — what changes for the reader.</p>
|
||||
<div class="hero-cta">
|
||||
<button class="btn btn-primary">Primary action</button>
|
||||
<button class="btn btn-secondary">Secondary</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
## Layout 2 — Hero, split (text + visual)
|
||||
|
||||
Use when there is a real product visual (product UI, screenshot, photograph). Left half copy, right half a `ph-img` placeholder the user replaces.
|
||||
|
||||
```html
|
||||
<section class="section" data-od-id="hero-split">
|
||||
<div class="container hero-split">
|
||||
<div>
|
||||
<p class="eyebrow">EYEBROW · ROLE</p>
|
||||
<h1>Headline that names the change.</h1>
|
||||
<p class="lead" style="margin-top: 20px;">A short subhead — concrete, not corporate. Two sentences max.</p>
|
||||
<div class="hero-cta" style="margin-top: 28px;">
|
||||
<button class="btn btn-primary">Primary action</button>
|
||||
<button class="btn btn-ghost btn-arrow">Read the story</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ph-img wide" aria-label="Hero visual placeholder">[ Hero visual · 16:9 ]</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
## Layout 3 — Feature triplet
|
||||
|
||||
Three feature cells. Lead with a small `<h2>` framing the row. Don't put an icon on every heading — one tasteful mark per cell, monoline.
|
||||
|
||||
```html
|
||||
<section class="section" data-od-id="features">
|
||||
<div class="container stack" style="gap: 56px;">
|
||||
<div style="max-width: 36ch;">
|
||||
<p class="eyebrow">WHAT'S DIFFERENT</p>
|
||||
<h2>Three things you'll notice in the first ten minutes.</h2>
|
||||
</div>
|
||||
<div class="grid-3">
|
||||
<div class="feature card-flat">
|
||||
<div class="feature-mark">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 3v18M3 12h18"/></svg>
|
||||
</div>
|
||||
<h3>Specific feature one</h3>
|
||||
<p>Two-sentence description that names the user value, not the technology.</p>
|
||||
</div>
|
||||
<div class="feature card-flat">
|
||||
<div class="feature-mark">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="8"/><path d="M12 8v4l3 2"/></svg>
|
||||
</div>
|
||||
<h3>Specific feature two</h3>
|
||||
<p>Two-sentence description that names the user value, not the technology.</p>
|
||||
</div>
|
||||
<div class="feature card-flat">
|
||||
<div class="feature-mark">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 7h16M4 12h10M4 17h16"/></svg>
|
||||
</div>
|
||||
<h3>Specific feature three</h3>
|
||||
<p>Two-sentence description that names the user value, not the technology.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
## Layout 4 — Stat row (data billboard)
|
||||
|
||||
Use when there are real numbers. Three stats max — four feels like a brochure. **Don't invent metrics.** If you don't have a number, use a different layout.
|
||||
|
||||
```html
|
||||
<section class="section" data-od-id="stats">
|
||||
<div class="container">
|
||||
<p class="eyebrow" style="margin-bottom: 40px;">BY THE NUMBERS · 2026</p>
|
||||
<div class="grid-3">
|
||||
<div class="stat">
|
||||
<div class="stat-num num">38<span class="stat-unit">×</span></div>
|
||||
<p class="stat-label">less data moved over the wire vs. naive sync, on real customer workloads.</p>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-num num">3,184</div>
|
||||
<p class="stat-label">paying teams, including 14 of the YC W26 batch.</p>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-num num">$0.04<span class="stat-unit">/GB</span></div>
|
||||
<p class="stat-label">average egress saved — typical $1,800/mo bill drops to $200.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
## Layout 5 — Pull quote (testimonial)
|
||||
|
||||
A single decisive quote with attribution. Use sparingly — one per page, never two in a row.
|
||||
|
||||
```html
|
||||
<section class="section" data-od-id="quote">
|
||||
<div class="container" style="max-width: 800px;">
|
||||
<div class="quote-mark">"</div>
|
||||
<blockquote class="quote">Filebase pays for itself in the first month. We were going to hire a dedicated DevOps person to babysit our sync — instead we just switched.</blockquote>
|
||||
<p class="quote-author">— Mira Hassan, CTO at Northwind Studios</p>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
## Layout 6 — CTA strip (closing)
|
||||
|
||||
End the page on one decisive ask. Centered, generous whitespace, one primary button. No secondary unless the page has zero other buttons.
|
||||
|
||||
```html
|
||||
<section class="section" data-od-id="cta-strip" style="text-align: center;">
|
||||
<div class="container" style="max-width: 600px;">
|
||||
<h2>Stop measuring meetings. Start measuring focus.</h2>
|
||||
<p class="lead" style="margin: 16px auto 32px;">Free for solo. $4/mo per teammate after that.</p>
|
||||
<button class="btn btn-primary">Start free</button>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
## Layout 7 — Log list (changelog / blog index / posts)
|
||||
|
||||
Editorial layout for a list of dated entries. Date in mono on the left, title + dek in the middle, optional pull stat on the right. Borders on top, never around — boxes feel like a brochure.
|
||||
|
||||
```html
|
||||
<section class="section" data-od-id="log">
|
||||
<div class="container">
|
||||
<div class="row-between" style="margin-bottom: 32px;">
|
||||
<h2>Recent changes</h2>
|
||||
<a class="btn btn-ghost btn-arrow" href="#">View all</a>
|
||||
</div>
|
||||
<div>
|
||||
<article class="log-row">
|
||||
<span class="meta">Apr 27, 2026</span>
|
||||
<div>
|
||||
<h3>Sync engine v3 — half the wire bytes</h3>
|
||||
<p style="margin: 4px 0 0; color: var(--muted); font-size: 14px;">A new content-defined chunker that produces 38× fewer post-edit changes on Final Cut projects.</p>
|
||||
</div>
|
||||
<span class="pull meta">Engineering</span>
|
||||
</article>
|
||||
<article class="log-row">
|
||||
<span class="meta">Apr 19, 2026</span>
|
||||
<div>
|
||||
<h3>Per-folder bandwidth budgets</h3>
|
||||
<p style="margin: 4px 0 0; color: var(--muted); font-size: 14px;">Cap how much a single project can pull each month — useful for archive folders.</p>
|
||||
</div>
|
||||
<span class="pull meta">Product</span>
|
||||
</article>
|
||||
<article class="log-row">
|
||||
<span class="meta">Apr 04, 2026</span>
|
||||
<div>
|
||||
<h3>S3 + R2 dual-region replication</h3>
|
||||
<p style="margin: 4px 0 0; color: var(--muted); font-size: 14px;">Two providers, automatic failover. Enterprise tier only for now.</p>
|
||||
</div>
|
||||
<span class="pull meta">Infra</span>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
## Layout 8 — Comparison table (pricing, plan matrix, before/after)
|
||||
|
||||
Hairline borders, mono numerics, one column highlighted via an accent border. Don't put the whole row in surface-color — that screams "table".
|
||||
|
||||
```html
|
||||
<section class="section" data-od-id="pricing">
|
||||
<div class="container">
|
||||
<div style="text-align: center; max-width: 36ch; margin: 0 auto 56px;">
|
||||
<p class="eyebrow">PRICING</p>
|
||||
<h2>One row of features. Three lines of pricing.</h2>
|
||||
</div>
|
||||
<table class="ds-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Feature</th>
|
||||
<th class="num-col">Solo</th>
|
||||
<th class="num-col">Team</th>
|
||||
<th class="num-col">Enterprise</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Sync engine v3</td><td class="num-col">✓</td><td class="num-col">✓</td><td class="num-col">✓</td></tr>
|
||||
<tr><td>Per-folder budgets</td><td class="num-col">—</td><td class="num-col">✓</td><td class="num-col">✓</td></tr>
|
||||
<tr><td>SAML / SCIM</td><td class="num-col">—</td><td class="num-col">—</td><td class="num-col">✓</td></tr>
|
||||
<tr><td>Dedicated infra</td><td class="num-col">—</td><td class="num-col">—</td><td class="num-col">✓</td></tr>
|
||||
<tr style="border-top: 1px solid var(--fg);">
|
||||
<td><strong>Monthly</strong></td>
|
||||
<td class="num-col"><strong>$0</strong></td>
|
||||
<td class="num-col"><strong>$4 / seat</strong></td>
|
||||
<td class="num-col"><strong>Talk to us</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Section rhythm — when in doubt
|
||||
|
||||
For a 5-section landing:
|
||||
1. Hero (Layout 1 or 2)
|
||||
2. Features (Layout 3)
|
||||
3. Stats *or* quote (Layout 4 or 5)
|
||||
4. Split detail (custom, using `grid-2-1` / `grid-1-2`)
|
||||
5. CTA + footer (Layout 6)
|
||||
|
||||
For a 4-section docs/marketing index:
|
||||
1. Hero center (Layout 1)
|
||||
2. Log list (Layout 7)
|
||||
3. CTA + footer (Layout 6)
|
||||
|
||||
Two stat rows in a row, two quote blocks in a row, two feature triplets in a row — all visual fatigue. Alternate.
|
||||
@@ -0,0 +1,321 @@
|
||||
# INVYONE — Claude 작업 컨벤션
|
||||
|
||||
이 파일은 git 에 올라가는 **프로젝트 공용** Claude 가이드입니다. 모든 머신/팀원의 Claude Code 인스턴스가 이 컨벤션을 따라야 합니다.
|
||||
|
||||
(개인용 셋업은 `CLAUDE.local.md` — git 추적 제외, syncthing 동기화)
|
||||
|
||||
---
|
||||
|
||||
## 📝 분석 / 리포트 / 메모 MD 파일 저장 규칙 (★필수)
|
||||
|
||||
Claude 가 코드 분석, 보안 감사, 리팩토링 검토, 설계 문서, 회의록 등 **새 MD 파일을 작성**할 때는 다음 위치에 저장합니다.
|
||||
|
||||
### 저장 경로
|
||||
|
||||
```
|
||||
notes/{git-user-name}/{YYYY-MM-DD}-{slug}.md
|
||||
```
|
||||
|
||||
- `notes/` — 프로젝트 루트의 메모/리포트 모음 폴더 (이 폴더로 통일)
|
||||
- `{git-user-name}` — `git config user.name` 으로 자동 결정 (예: `gbpark`, `park`)
|
||||
- `{YYYY-MM-DD}-{slug}.md` — 날짜 prefix + 짧은 제목 slug (kebab-case)
|
||||
|
||||
**예시:**
|
||||
```
|
||||
notes/gbpark/2026-04-08-auth-security-audit.md
|
||||
notes/gbpark/2026-04-12-component-v2-migration-plan.md
|
||||
notes/park/2026-04-15-docker-port-conflict-resolution.md
|
||||
```
|
||||
|
||||
### 규칙
|
||||
|
||||
1. **사용자 폴더가 이미 있으면 그 안에 넣는다** — 없으면 `mkdir -p notes/{git-user}` 로 생성
|
||||
2. **파일명은 항상 날짜 + slug 조합** — 시간순 정렬되어 추적 용이
|
||||
3. **README 나 docs/ 와는 분리** — `README.md`, `docs/` 는 사용자/개발자용 공식 문서. `notes/` 는 작업 기록·분석·메모용
|
||||
4. **MD 외 다른 산출물 (스크립트, JSON 등) 도 같이 둘 수 있음** — 필요하면 `notes/{git-user}/{slug}/` 식 하위 폴더 사용
|
||||
5. **새 폴더/파일 작성 후엔 git add 권장** — syncthing 도 자동 동기화 (`notes/` 는 `.stignore-shared` 에 없음)
|
||||
|
||||
### 어디에 안 넣는가
|
||||
|
||||
- `_local/`, `_backup/`, `_pipeline/` — syncthing ignore. 머신 로컬용
|
||||
- `docs/` — 공식 개발 문서. 작업 기록 아님
|
||||
- 프로젝트 루트 직접 (`./STATUS.md`, `./PLAN.MD` 등) — 이미 기존에 있는 것 외에 새로 만들지 말 것
|
||||
|
||||
---
|
||||
|
||||
## 컨벤션이 적용되는 시나리오
|
||||
|
||||
| 사용자 요청 | 저장 위치 |
|
||||
|---|---|
|
||||
| "이 코드 분석해서 md 로 정리해줘" | `notes/{git-user}/{date}-{topic}.md` |
|
||||
| "보안 감사 리포트 만들어줘" | `notes/{git-user}/{date}-security-audit.md` |
|
||||
| "리팩토링 플랜 md 로 뽑아줘" | `notes/{git-user}/{date}-refactor-plan.md` |
|
||||
| "회의 노트 정리해줘" | `notes/{git-user}/{date}-meeting-notes.md` |
|
||||
| "마이그레이션 가이드 작성" | `notes/{git-user}/{date}-migration-guide.md` |
|
||||
|
||||
---
|
||||
|
||||
## Claude 사용 시 추가 주의사항
|
||||
|
||||
- **이 컨벤션은 사용자 명시 요청 없이도 자동 적용** — 사용자가 "md 만들어줘" 라고만 해도 위 경로에 저장
|
||||
- **현재 git user 확인이 필요하면** `git config user.name` 실행
|
||||
- **사용자 폴더가 처음이면** 만들면서 `.gitkeep` 정도만 두지 말고 바로 첫 노트 작성
|
||||
|
||||
---
|
||||
|
||||
## 🎨 공통 디자인 시스템 / CSS 참조 규칙 (★★★ 무조건 적용)
|
||||
|
||||
UI 작업(컴포넌트 작성, HTML 목업, 새 페이지/화면, 디자인 리빌딩, 스타일 수정 등)을 할 때는 **반드시** 아래 공통 CSS 파일들을 먼저 읽고 그 안의 토큰/클래스 컨벤션을 100% 따라야 합니다. 절대 새 색상/간격/라운드/그림자 값을 즉흥으로 만들지 말 것.
|
||||
|
||||
### "v5" 의 정체 (★ 헷갈리지 말 것)
|
||||
|
||||
**INVION v5 = 디자인 시안 5번째 = 최종 채택본. 현재 컨셉은 "Solid + Glow" (2026-04-21 개정)**
|
||||
|
||||
- 디자이너가 v1~v5 까지 5번 시안을 만들고 그 중 **v5 가 확정**되어 React 로 포팅됨
|
||||
- 시안 원본 HTML (참고용, 여기엔 아직 glassmorphism 이 남아있음):
|
||||
- `frontend/invion-layout-v5.html` (973줄, 풀 레이아웃 셸)
|
||||
- `frontend/invion-preview-v5.html` (1049줄, 미리보기/모션 데모)
|
||||
- 폐기된 시안: `frontend/invion-preview-v1~v4.html` (참고만, 적용 금지)
|
||||
- **현재 적용 컨셉 (2026-04-21 개정)**:
|
||||
- **로그인 페이지**: 우주(별/성운/별똥별/입자) 배경 + 글래스 카드 **유지**
|
||||
- **메인 화면 이후 전부**: **반투명/blur/cosmic 배경 폐기**. 불투명 솔리드 카드 + primary-color 글로우 + 보라(`#6c5ce7`)/시안(`#00cec9`)/핑크(`#fd79a8`) 액센트
|
||||
- v5 토큰이 옮겨진 곳: `frontend/styles/v5-layout.css`, `frontend/app/(auth)/login/login.css`
|
||||
|
||||
⚠️ **POP 디자이너의 "v5 그리드 시스템"** (`PopRenderer.tsx`, `pop-layout.ts` 등) 은 **별개 의미** — POP 화면 데이터 포맷의 5번째 버전. UI 디자인 v5 와 무관. 혼동 금지.
|
||||
|
||||
### 항상 먼저 읽어야 하는 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|---|---|
|
||||
| `frontend/invion-layout-v5.html` | **v5 디자인 원본 (시안)** — 모든 v5 토큰/클래스의 진실의 원천. 포팅된 css 와 다르면 이게 정답 |
|
||||
| `frontend/styles/v5-layout.css` | **INVION v5 React 포팅 메인** — `--v5-*` CSS 변수, 헤더/사이드바/탭/모달 등 모든 v5- 컴포넌트 클래스 정의. UI 작업 전 무조건 먼저 읽기 |
|
||||
| `frontend/app/globals.css` | shadcn/Tailwind 토큰 (`--background`, `--primary`, `--foreground` 등 HSL), 다크모드 변수, 전역 reset |
|
||||
| `frontend/app/(auth)/login/login.css` | **로그인 전용** 코스믹 배경(별/성운/입자) + 글래스 카드. 이 컨셉은 **로그인에만** 적용 — 메인 화면에 옮기지 말 것 |
|
||||
| `frontend/components/layout/AppLayout.tsx` | v5 클래스가 실제로 어떻게 조립되는지 — 헤더/사이드바/탭/플라이아웃 사용 예 |
|
||||
|
||||
### 필수 준수 사항
|
||||
|
||||
1. **디자인 토큰은 무조건 변수 사용** — `--v5-primary`, `--v5-cyan`, `--v5-surface-solid`, `--v5-glow-sm/md/lg`, `hsl(var(--primary))` 등. 즉흥 hex/rgb 금지
|
||||
2. **클래스명은 v5- 접두사 컨벤션 따르기** — 새 컴포넌트도 `.v5-card`, `.v5-btn`, `.v5-bdg` 처럼 같은 네이밍. shadcn 컴포넌트 사용 시 그대로 사용
|
||||
3. **반투명/블러 금지 (★2026-04-21 신규)** — 메인 화면 이후 전 영역에서 `backdrop-filter: blur(...)`, `var(--v5-glass)`, `var(--v5-glass-strong)` 사용 **금지**. 카드/모달/사이드바/헤더 배경은 `var(--v5-surface-solid)` (라이트 `#ffffff` / 다크 `#11102a`) 를 쓰고, 테두리는 `border-border` 또는 `var(--v5-border)`. 예외: `frontend/app/(auth)/login/` 과 `frontend/styles/builder-ide.css` 는 별도 스코프라 기존 유지
|
||||
4. **글로우는 유지** — 그림자는 검은 drop-shadow 대신 `var(--v5-glow-sm/md/lg)` (primary-color glow) 사용. 모달/강조 카드에 liberal 하게 사용 가능
|
||||
5. **다크/라이트 모드는 둘 다 동작** — `.dark` 변형 잊지 말 것. 다크에서 `--v5-surface-solid` 는 `#11102a`, 라이트는 `#ffffff`. 별/입자/별똥별/성운은 **로그인에만** 존재, 메인은 평범한 단색 배경
|
||||
6. **컴팩트 폰트 사이즈 유지** — v5 는 0.55~0.85rem 의 컴팩트 UI. 새로 만들 때도 같은 스케일 따를 것
|
||||
7. **새 UI 패턴은 v5-layout.css 에 합치는 것을 기본 방향으로** — 일회성 inline `<style>` 보다 공통화 우선
|
||||
|
||||
### 작업 순서 (UI 작업 시 반드시)
|
||||
|
||||
```
|
||||
1) frontend/styles/v5-layout.css 읽기
|
||||
2) 필요하면 globals.css, login.css 도 함께 읽기
|
||||
3) 기존 v5- 클래스 중 재사용 가능한 것 찾기
|
||||
4) 모자라는 부분만 같은 토큰/네이밍으로 추가
|
||||
5) 작업 결과를 사용자에게 보여줄 때 "어떤 v5 토큰/클래스를 따랐는지" 명시
|
||||
```
|
||||
|
||||
### 예외
|
||||
|
||||
- 단발성 디버그/실험 페이지(`debug-*`, `test-*`)는 임시 스타일 허용
|
||||
- `notes/` 안의 1회성 HTML 목업은 v5 토큰을 inline 으로 가져와 standalone 이어도 됨 (단 토큰 값은 반드시 v5-layout.css 와 동일해야 함)
|
||||
|
||||
이 규칙은 사용자가 명시 요청하지 않아도 모든 UI 작업에 자동 적용됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 백엔드 코딩 규칙 — 스타일 (★★★ 절대 규칙)
|
||||
|
||||
### 아키텍처: 3레이어 (Mapper Interface 금지)
|
||||
|
||||
```
|
||||
Controller (@RestController)
|
||||
↓
|
||||
Service (extends BaseService) — sqlSession 직접 사용
|
||||
↓
|
||||
XML (resources/mapper/[module].xml) — 소문자 파일명
|
||||
```
|
||||
|
||||
- **@Mapper 인터페이스 생성 금지** — `sqlSession.selectList("namespace.queryId", params)` 직접 호출
|
||||
- Service는 반드시 `BaseService` 상속, `@Slf4j`, `@Autowired CommonService`
|
||||
|
||||
### 데이터: Map<String, Object> — DTO/엔티티 클래스 금지
|
||||
|
||||
로우코드 ERP: 테이블/컬럼이 런타임에 결정됨. DTO 클래스 사전 생성 불가.
|
||||
- 모든 파라미터: `Map<String, Object>`
|
||||
- 모든 응답: `Map<String, Object>` 또는 `List<Map<String, Object>>`
|
||||
- `ApiResponse`만 유일한 DTO
|
||||
|
||||
### 네이밍 컨벤션 (절대 규칙)
|
||||
|
||||
| 위치 | 컨벤션 | 예시 |
|
||||
|---|---|---|
|
||||
| **Java 코드** | camelCase | `getOrderList()`, `String companyCode` |
|
||||
| **Map 키 (params.put, row.get)** | snake_case | `params.put("company_code", ...)`, `row.get("table_name")` |
|
||||
| **#{파라미터}** | snake_case | `#{company_code}`, `#{table_name}` |
|
||||
| **SQL (키워드/테이블/컬럼)** | UPPER_SNAKE | `SELECT COMPANY_CODE FROM TEMPLATES` |
|
||||
| **SELECT 쉼표** | 앞에 | `, COLUMN_NAME` |
|
||||
| **XML 파일명** | 소문자, Mapper 안 붙임 | `meta.xml`, `template.xml` |
|
||||
| **XML namespace** | 파일명과 동일 | `namespace="meta"` |
|
||||
| **OGNL test** | 바깥 작은따옴표 | `test='company_code != "*"'` |
|
||||
|
||||
### 메서드명 패턴
|
||||
|
||||
| 조작 | 패턴 | 예시 |
|
||||
|---|---|---|
|
||||
| 목록 | `get[Module]List` | `getTemplateList` |
|
||||
| 카운트 | `get[Module]ListCnt` | `getTemplateListCnt` |
|
||||
| 단건 | `get[Module]Info` | `getTemplateInfo` |
|
||||
| 등록 | `insert[Module]` | `insertTemplate` |
|
||||
| 수정 | `update[Module]` | `updateTemplate` |
|
||||
| 삭제 | `delete[Module]` | `deleteTemplate` |
|
||||
|
||||
**List API는 반드시 Count 쿼리 동반** (`getXxxList` + `getXxxListCnt` = 한 세트)
|
||||
|
||||
### common 레이어 필수
|
||||
|
||||
```xml
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="common.dynamicOrderBy"/>
|
||||
<include refid="common.pagination"/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 프론트엔드 데이터 타입 규칙 (★★★ 절대 규칙)
|
||||
|
||||
### Record<string, any> — 별도 인터페이스 정의 금지
|
||||
|
||||
백엔드가 `Map<String, Object>`이므로 프론트도 `Record<string, any>`.
|
||||
|
||||
```typescript
|
||||
// ❌ 금지 — 불필요한 인터페이스 정의
|
||||
interface TableInfo {
|
||||
table_name: string;
|
||||
column_count: number;
|
||||
}
|
||||
|
||||
// ✅ 올바름
|
||||
const tables: Record<string, any>[] = await getTableList();
|
||||
```
|
||||
|
||||
### 유일한 예외: invyone-component.ts 규격 타입
|
||||
|
||||
`frontend/types/invyone-component.ts`에 확정된 타입만 예외:
|
||||
- `FieldConfig`, `FieldType`, `FieldRef`
|
||||
- `Component`, `ComponentType`, `Position`
|
||||
- `DataPort`, `Connection`
|
||||
- `Template`, `ViewConfig`
|
||||
- 각 `ComponentTypeConfig` (TableConfig, FormConfig 등)
|
||||
|
||||
이것들은 시스템의 핵심 계약이므로 타입 유지. **그 외 전부 `Record<string, any>`.**
|
||||
|
||||
---
|
||||
|
||||
## 📐 INVYONE 로우코드 핵심 구조 (★ 모든 세션이 알아야 할 것)
|
||||
|
||||
### VEX → INVYONE 관계
|
||||
|
||||
INVYONE은 이미 운영 중인 로우코드 플랫폼 VEX(Node.js)의 2세대 리뉴얼(Java/Spring).
|
||||
**핵심 원칙: 더 단순한 구조로 재설계하되, VEX 운영 기능은 전부 계승. 단순화 ≠ 기능 삭제.**
|
||||
|
||||
### FieldConfig 단일 규격
|
||||
|
||||
VEX의 ColumnConfig(354줄) + FilterConfig + FormField → **FieldConfig 하나(~30줄)로 통합**.
|
||||
테이블/폼/검색 전부 같은 FieldConfig를 공유하며, 렌더러가 type을 보고 각자 다르게 렌더.
|
||||
|
||||
### 대시보드 = 메뉴
|
||||
|
||||
사이드바 메뉴 항목 = 대시보드. 별도 대시보드 UI가 아님.
|
||||
`대시보드 생성("수주관리") → 사이드바 메뉴에 자동 등록 → 템플릿 카드 배치 → 화면 완성`
|
||||
|
||||
### 구현 순서
|
||||
|
||||
1. DB 메타 읽기 → FieldConfig 변환
|
||||
2. 규격 기반 컴포넌트 (FcTable/FcForm/FcSearch)
|
||||
3. 개발자 빌더 (수동 템플릿 구성)
|
||||
4. 대시보드(=메뉴) 시스템
|
||||
5. 제어 모드 (비즈니스 룰)
|
||||
6. 자동생성/프리셋 (맨 마지막)
|
||||
|
||||
### 설계 문서 위치
|
||||
|
||||
| 문서 | 위치 |
|
||||
|---|---|
|
||||
| 컴포넌트 규격 v1.0 | `notes/gbpark/2026-04-08-invyone-component-spec.md` |
|
||||
| 아키텍처 결정 | `notes/gbpark/2026-04-09-invyone-architecture.md` |
|
||||
| 로우코드 플랫폼 SPEC | `notes/gbpark/2026-04-08-lowcode-platform-spec.md` |
|
||||
| Phase 1~5 구현 설계 | `notes/gbpark/2026-04-10-phase{1~5}-*.md` |
|
||||
| mockup (시각적 진실의 원천) | `notes/gbpark/2026-04-08-invyone-mockup/` |
|
||||
| FieldConfig TS 타입 | `frontend/types/invyone-component.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 🏢 멀티테넌시 · 서브도메인 라우팅 (★★★ 아키텍처 핵심)
|
||||
|
||||
INVYONE 은 **DB-per-tenant** 구조. 회사 하나 = 전용 PostgreSQL DB 하나 = 전용 서브도메인 하나.
|
||||
`qnc.invyone.com` 접속 → `SubdomainResolverFilter` 가 Host 헤더 파싱 → `TenantRoutingDataSource` 가 `qnc_invyone` DB 로 자동 라우팅.
|
||||
|
||||
**전체 설명·플로우·배포 가이드는 반드시 읽을 것:**
|
||||
- **`docs/MULTI_TENANCY_ARCHITECTURE.md`** ← 이거 하나만 읽으면 끝 (환경별 도메인 전략, CORS 패턴, 배포 체크리스트 포함)
|
||||
|
||||
### 절대 건드리면 안 되는 3가지
|
||||
|
||||
1. **테넌트 도메인에서 `NEXT_PUBLIC_API_URL=/api` 같은 Next rewrite 쓰지 말 것.**
|
||||
Rewrite 가 Host 헤더를 변조해 `*.invyone.com` 서브도메인 파싱이 실패함.
|
||||
→ `frontend/lib/api/client.ts` 의 "`.invyone.com` 이면 직접 `:8083/api`" 분기가 NEXT_PUBLIC_API_URL 체크보다 **앞에** 와야 함.
|
||||
|
||||
2. **CORS 는 `setAllowedOriginPatterns`** (`setAllowedOrigins` 아님). 와일드카드 매칭 필요.
|
||||
**YAML 의 `[*]` 는 반드시 따옴표로 감쌀 것** (sequence 로 해석됨).
|
||||
`CORS_ALLOWED_ORIGINS` 는 `.env` 에 있고 `.gitignore` 대상 → **서버마다 수동 세팅**.
|
||||
|
||||
3. **회사별 Hikari 풀은 반드시 `minIdle=0`.**
|
||||
회사 N개 × minIdle=2 하면 Postgres `max_connections` 금방 초과.
|
||||
`TenantDataSourceFactory.createTenant(...)` 만 사용할 것.
|
||||
|
||||
### 회사 생성 = API 호출 한 번
|
||||
|
||||
```
|
||||
POST /api/admin/provisioning/companies
|
||||
→ 6단계 자동 실행 (DB 생성 → pg_dump 스키마 복제 → 템플릿 데이터 → 관리자 → 메타 등록)
|
||||
→ 13~15초 내 완료, 바로 서브도메인 접속 가능
|
||||
```
|
||||
필수 필드 4개만: `company_code`, `company_name`, `subdomain`, `db_prefix`.
|
||||
나머지(사업자번호·대표자·이메일 등) 전부 optional.
|
||||
|
||||
### 환경별 도메인 — 요약
|
||||
|
||||
| 환경 | 접속 | 추가 설정 |
|
||||
|---|---|---|
|
||||
| **운영** | 실 `*.invyone.com` (+ 메인 `solution.invyone.com`) | **세팅 완료 (2026-04-24)** — Porkbun 와일드카드 DNS + Traefik v2.11 + Let's Encrypt DNS-01 와일드카드 TLS |
|
||||
| **로컬 (직접 `docker up`)** | `*.localhost` 자동 | 0 (RFC 6761, Chrome/Firefox 기본 지원) |
|
||||
| **원격 공유 개발서버** | `nip.io` or `hosts` 편집 | 경우 따라 |
|
||||
|
||||
### 새 환경 붙일 때 체크리스트
|
||||
|
||||
- [ ] `.env` 의 `CORS_ALLOWED_ORIGINS` 에 해당 도메인 패턴 포함 (`http://*.invyone.com:[*]` 등)
|
||||
- [ ] DB 마이그레이션 `RUN_079/080/081.md` 운영 DB 에서 1회 실행
|
||||
- [ ] `docker compose build --no-cache backend-spring` (`postgresql16-client` 포함)
|
||||
- [ ] 프로덕션은 `tenant.provisioning.require-super-admin=true`
|
||||
|
||||
### 관련 코드 맵
|
||||
|
||||
```
|
||||
backend-spring/src/main/java/com/erp/
|
||||
├── tenant/ # 라우팅 (Filter / Holder / CompanyResolver / RoutingDataSource / Factory / DataSourceConfig / TenantController)
|
||||
└── provisioning/ # 회사 생성 (Service / Registry / DatabaseCreator / SchemaCopier / DataCopier / AdminAccountCreator / Controller / StatsService)
|
||||
|
||||
backend-spring/src/main/resources/mapper/
|
||||
├── tenant.xml # resolveDbNameBySubdomain
|
||||
└── provisioning.xml # exists / insertCompanyWithTenant / listCompaniesForUi / updateDbStatus
|
||||
|
||||
frontend/
|
||||
├── lib/api/provisioning.ts
|
||||
├── lib/tenant/subdomain.ts # 호스트 파싱 + 예약어 제외
|
||||
├── components/TenantGuard.tsx # 미등록 서브도메인 차단 (layout wrap)
|
||||
├── components/admin/provisioning/ # 메인 화면 (accordion / stats strip / status dot / sparkline)
|
||||
│ └── wizard/ # 4-step 마법사 (force_password_change 옵션 포함)
|
||||
├── app/tenant-not-found/page.tsx # v5 solid+glow 에러 페이지
|
||||
└── app/(main)/admin/sysMng/subdomainList/ # 회사 프로비저닝 페이지 엔트리
|
||||
|
||||
db/migrations/RUN_079/080/081_MIGRATION.md
|
||||
notes/gbpark/2026-04-24-traefik-wildcard/ # 운영 Traefik 설정 산출물 (2026-04-24)
|
||||
```
|
||||
@@ -1,25 +0,0 @@
|
||||
# AI Assistant API (INVION 내장) - 환경 변수
|
||||
# 이 파일을 .env 로 복사한 뒤 값 설정
|
||||
|
||||
NODE_ENV=development
|
||||
PORT=3100
|
||||
|
||||
# PostgreSQL (AI 어시스턴트 전용 DB)
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=ai_assistant
|
||||
DB_PASSWORD=ai_assistant_password
|
||||
DB_NAME=ai_assistant_db
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||
JWT_EXPIRES_IN=7d
|
||||
JWT_REFRESH_SECRET=your-refresh-secret-key-change-in-production
|
||||
JWT_REFRESH_EXPIRES_IN=30d
|
||||
|
||||
# LLM (구글 키 등)
|
||||
GEMINI_API_KEY=your-gemini-api-key
|
||||
GEMINI_MODEL=gemini-2.0-flash
|
||||
|
||||
RATE_LIMIT_WINDOW_MS=60000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
@@ -1,17 +0,0 @@
|
||||
# AI 어시스턴트 API - Docker (Windows 개발용)
|
||||
FROM node:20-bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV NODE_ENV=development
|
||||
EXPOSE 3100
|
||||
|
||||
CMD ["node", "src/app.js"]
|
||||
@@ -1,43 +0,0 @@
|
||||
# AI 어시스턴트 API (INVION 내장)
|
||||
|
||||
INVION와 **같은 서비스**로 동작하도록 이 API는 포트 3100에서 구동되고, backend-node가 `/api/ai/v1` 요청을 여기로 프록시합니다.
|
||||
|
||||
## 동작 방식
|
||||
|
||||
- **프론트(9771)** → `/api/ai/v1/*` 호출
|
||||
- **Next.js** → `8080/api/ai/v1/*` 로 rewrite
|
||||
- **backend-node(8080)** → `3100/api/v1/*` 로 프록시 → **이 서비스**
|
||||
|
||||
따라서 사용자는 **다른 포트를 쓰지 않고** INVION만 켜도 AI 기능을 사용할 수 있습니다.
|
||||
|
||||
## 서비스 올리는 순서 (한 번에 동작하게)
|
||||
|
||||
1. **AI 어시스턴트 API (이 폴더, 포트 3100)**
|
||||
```bash
|
||||
cd ai-assistant
|
||||
npm install
|
||||
cp .env.example .env # 필요 시 DB, JWT, GEMINI_API_KEY 등 수정
|
||||
npm start
|
||||
```
|
||||
|
||||
2. **backend-node (포트 8080)**
|
||||
```bash
|
||||
cd backend-node
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. **프론트 (포트 9771)**
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
브라우저에서는 `http://localhost:9771` 만 사용하면 되고, AI API는 같은 오리진의 `/api/ai/v1` 로 호출됩니다.
|
||||
|
||||
## 환경 변수
|
||||
|
||||
- `.env.example` 을 `.env` 로 복사 후 수정
|
||||
- `PORT=3100` (기본값)
|
||||
- PostgreSQL: `DB_*`
|
||||
- JWT: `JWT_SECRET`, `JWT_REFRESH_SECRET`
|
||||
- LLM: `GEMINI_API_KEY` 등
|
||||
Generated
-3455
File diff suppressed because it is too large
Load Diff
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"name": "ai-assistant-api",
|
||||
"version": "1.0.0",
|
||||
"description": "AI Assistant API (INVION 내장) - 포트 3100에서 구동, backend-node가 /api/ai/v1 로 프록시",
|
||||
"private": true,
|
||||
"main": "src/app.js",
|
||||
"scripts": {
|
||||
"start": "node src/app.js",
|
||||
"dev": "nodemon src/app.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.0.0",
|
||||
"axios": "^1.6.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"express-validator": "^7.0.1",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pg": "^8.11.3",
|
||||
"pg-hstore": "^2.3.4",
|
||||
"sequelize": "^6.35.2",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.11.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
// src/app.js
|
||||
// AI Assistant API 서버 메인 엔트리포인트
|
||||
|
||||
require('dotenv').config();
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const compression = require('compression');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const swaggerUi = require('swagger-ui-express');
|
||||
const swaggerSpec = require('./config/swagger.config');
|
||||
|
||||
const logger = require('./config/logger.config');
|
||||
const { sequelize } = require('./models');
|
||||
const routes = require('./routes');
|
||||
const errorHandler = require('./middlewares/error-handler.middleware');
|
||||
|
||||
const app = express();
|
||||
// INVION 내장 시 backend-node가 이 포트로 프록시하므로 기본 3100 사용
|
||||
const PORT = process.env.PORT || 3100;
|
||||
|
||||
// ===========================================
|
||||
// 미들웨어 설정
|
||||
// ===========================================
|
||||
|
||||
// Trust proxy (Docker/Nginx 환경)
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// CORS 설정 (helmet보다 먼저 설정)
|
||||
app.use(cors({
|
||||
origin: true, // 모든 origin 허용
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||||
}));
|
||||
|
||||
// Preflight 요청 처리
|
||||
app.options('*', cors());
|
||||
|
||||
// 보안 헤더 (CORS 이후에 설정)
|
||||
app.use(helmet({
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
crossOriginOpenerPolicy: { policy: 'unsafe-none' },
|
||||
}));
|
||||
|
||||
// 요청 본문 파싱
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// 압축
|
||||
app.use(compression());
|
||||
|
||||
// Rate Limiting (전역)
|
||||
const limiter = rateLimit({
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000,
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 100,
|
||||
message: {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
message: '요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.',
|
||||
},
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
app.use(limiter);
|
||||
|
||||
// 요청 로깅
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
logger.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`);
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// 헬스 체크
|
||||
// ===========================================
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// Swagger API 문서
|
||||
// ===========================================
|
||||
|
||||
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
|
||||
explorer: true,
|
||||
customCss: '.swagger-ui .topbar { display: none }',
|
||||
customSiteTitle: 'AI Assistant API 문서',
|
||||
swaggerOptions: {
|
||||
persistAuthorization: true,
|
||||
displayRequestDuration: true,
|
||||
},
|
||||
}));
|
||||
|
||||
// Swagger JSON
|
||||
app.get('/api-docs.json', (req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.send(swaggerSpec);
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// API 라우트
|
||||
// ===========================================
|
||||
|
||||
app.use('/api/v1', routes);
|
||||
|
||||
// ===========================================
|
||||
// 404 처리
|
||||
// ===========================================
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'NOT_FOUND',
|
||||
message: `요청한 리소스를 찾을 수 없습니다: ${req.method} ${req.originalUrl}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// 에러 핸들러
|
||||
// ===========================================
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
// ===========================================
|
||||
// 서버 시작
|
||||
// ===========================================
|
||||
|
||||
async function startServer() {
|
||||
try {
|
||||
// 데이터베이스 연결
|
||||
await sequelize.authenticate();
|
||||
logger.info('✅ 데이터베이스 연결 성공');
|
||||
|
||||
// 테이블 동기화 (테이블이 없으면 생성)
|
||||
await sequelize.sync();
|
||||
logger.info('✅ 데이터베이스 스키마 동기화 완료');
|
||||
|
||||
// 초기 데이터 설정 (관리자 계정, LLM 프로바이더)
|
||||
const initService = require('./services/init.service');
|
||||
await initService.initialize();
|
||||
|
||||
// 서버 시작
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`🚀 AI Assistant API 서버가 포트 ${PORT}에서 실행 중입니다`);
|
||||
logger.info(`📚 API 문서 (Swagger): http://localhost:${PORT}/api-docs`);
|
||||
logger.info(`📚 API 엔드포인트: http://localhost:${PORT}/api/v1`);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('❌ 서버 시작 실패:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 프로세스 종료 처리
|
||||
process.on('SIGTERM', async () => {
|
||||
logger.info('SIGTERM 신호 수신, 서버 종료 중...');
|
||||
await sequelize.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
logger.info('SIGINT 신호 수신, 서버 종료 중...');
|
||||
await sequelize.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
startServer();
|
||||
|
||||
module.exports = app;
|
||||
@@ -1,474 +0,0 @@
|
||||
// src/controllers/admin.controller.js
|
||||
// 관리자 컨트롤러
|
||||
|
||||
const { LLMProvider, User, UsageLog, ApiKey } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
const logger = require('../config/logger.config');
|
||||
|
||||
// ===== LLM 프로바이더 관리 =====
|
||||
|
||||
/**
|
||||
* LLM 프로바이더 목록 조회
|
||||
*/
|
||||
exports.getProviders = async (req, res, next) => {
|
||||
try {
|
||||
const providers = await LLMProvider.findAll({
|
||||
order: [['priority', 'ASC']],
|
||||
attributes: [
|
||||
'id',
|
||||
'name',
|
||||
'displayName',
|
||||
'endpoint',
|
||||
'modelName',
|
||||
'priority',
|
||||
'maxTokens',
|
||||
'temperature',
|
||||
'timeoutMs',
|
||||
'costPer1kInputTokens',
|
||||
'costPer1kOutputTokens',
|
||||
'isActive',
|
||||
'isHealthy',
|
||||
'lastHealthCheck',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
// API 키는 마스킹해서 반환
|
||||
'apiKey',
|
||||
],
|
||||
});
|
||||
|
||||
// API 키 마스킹
|
||||
const maskedProviders = providers.map((p) => {
|
||||
const data = p.toJSON();
|
||||
if (data.apiKey) {
|
||||
// 앞 8자만 보여주고 나머지는 마스킹
|
||||
data.apiKey = data.apiKey.substring(0, 8) + '****' + data.apiKey.slice(-4);
|
||||
data.hasApiKey = true;
|
||||
} else {
|
||||
data.hasApiKey = false;
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: maskedProviders,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* LLM 프로바이더 추가
|
||||
*/
|
||||
exports.createProvider = async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
displayName,
|
||||
endpoint,
|
||||
apiKey,
|
||||
modelName,
|
||||
priority = 50,
|
||||
maxTokens = 4096,
|
||||
temperature = 0.7,
|
||||
timeoutMs = 60000,
|
||||
costPer1kInputTokens = 0,
|
||||
costPer1kOutputTokens = 0,
|
||||
} = req.body;
|
||||
|
||||
// 중복 이름 확인
|
||||
const existing = await LLMProvider.findOne({ where: { name } });
|
||||
if (existing) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'PROVIDER_EXISTS',
|
||||
message: '이미 존재하는 프로바이더 이름입니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const provider = await LLMProvider.create({
|
||||
name,
|
||||
displayName,
|
||||
endpoint,
|
||||
apiKey,
|
||||
modelName,
|
||||
priority,
|
||||
maxTokens,
|
||||
temperature,
|
||||
timeoutMs,
|
||||
costPer1kInputTokens,
|
||||
costPer1kOutputTokens,
|
||||
isActive: true,
|
||||
isHealthy: true,
|
||||
});
|
||||
|
||||
logger.info(`LLM 프로바이더 추가: ${name} (${modelName})`);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
displayName: provider.displayName,
|
||||
modelName: provider.modelName,
|
||||
priority: provider.priority,
|
||||
isActive: provider.isActive,
|
||||
message: 'LLM 프로바이더가 추가되었습니다.',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* LLM 프로바이더 수정
|
||||
*/
|
||||
exports.updateProvider = async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const provider = await LLMProvider.findByPk(id);
|
||||
if (!provider) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'PROVIDER_NOT_FOUND',
|
||||
message: 'LLM 프로바이더를 찾을 수 없습니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 허용된 필드만 업데이트
|
||||
const allowedFields = [
|
||||
'displayName',
|
||||
'endpoint',
|
||||
'apiKey',
|
||||
'modelName',
|
||||
'priority',
|
||||
'maxTokens',
|
||||
'temperature',
|
||||
'timeoutMs',
|
||||
'costPer1kInputTokens',
|
||||
'costPer1kOutputTokens',
|
||||
'isActive',
|
||||
'isHealthy',
|
||||
];
|
||||
|
||||
allowedFields.forEach((field) => {
|
||||
if (updates[field] !== undefined) {
|
||||
provider[field] = updates[field];
|
||||
}
|
||||
});
|
||||
|
||||
await provider.save();
|
||||
|
||||
logger.info(`LLM 프로바이더 수정: ${provider.name}`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
displayName: provider.displayName,
|
||||
modelName: provider.modelName,
|
||||
isActive: provider.isActive,
|
||||
message: 'LLM 프로바이더가 수정되었습니다.',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* LLM 프로바이더 삭제
|
||||
*/
|
||||
exports.deleteProvider = async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const provider = await LLMProvider.findByPk(id);
|
||||
if (!provider) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'PROVIDER_NOT_FOUND',
|
||||
message: 'LLM 프로바이더를 찾을 수 없습니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const providerName = provider.name;
|
||||
await provider.destroy();
|
||||
|
||||
logger.info(`LLM 프로바이더 삭제: ${providerName}`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'LLM 프로바이더가 삭제되었습니다.',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 사용자 관리 =====
|
||||
|
||||
/**
|
||||
* 사용자 목록 조회
|
||||
*/
|
||||
exports.getUsers = async (req, res, next) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page, 10) || 1;
|
||||
const limit = parseInt(req.query.limit, 10) || 100;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { count, rows: users } = await User.findAndCountAll({
|
||||
attributes: [
|
||||
'id',
|
||||
'email',
|
||||
'name',
|
||||
'role',
|
||||
'status',
|
||||
'plan',
|
||||
'monthlyTokenLimit',
|
||||
'lastLoginAt',
|
||||
'createdAt',
|
||||
],
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
// 페이지네이션 없이 간단한 배열로 반환 (프론트엔드 호환)
|
||||
return res.json({
|
||||
success: true,
|
||||
data: users,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 사용자 정보 수정
|
||||
*/
|
||||
exports.updateUser = async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { role, status, plan, monthlyTokenLimit } = req.body;
|
||||
|
||||
const user = await User.findByPk(id);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'USER_NOT_FOUND',
|
||||
message: '사용자를 찾을 수 없습니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (role) user.role = role;
|
||||
if (status) user.status = status;
|
||||
if (plan) user.plan = plan;
|
||||
if (monthlyTokenLimit !== undefined) user.monthlyTokenLimit = monthlyTokenLimit;
|
||||
|
||||
await user.save();
|
||||
|
||||
logger.info(`사용자 정보 수정: ${user.email} (role: ${user.role}, status: ${user.status})`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: user.toSafeJSON(),
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 시스템 통계 =====
|
||||
|
||||
/**
|
||||
* 사용자별 사용량 통계
|
||||
*/
|
||||
exports.getUsageByUser = async (req, res, next) => {
|
||||
try {
|
||||
const days = parseInt(req.query.days, 10) || 7;
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - days);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
|
||||
// 사용자별 집계 (raw SQL 사용)
|
||||
const userStats = await UsageLog.sequelize.query(`
|
||||
SELECT
|
||||
u.id as "userId",
|
||||
u.email,
|
||||
u.name,
|
||||
COALESCE(SUM(ul.total_tokens), 0) as "totalTokens",
|
||||
COALESCE(SUM(ul.cost_usd), 0) as "totalCost",
|
||||
COUNT(ul.id) as "requestCount"
|
||||
FROM users u
|
||||
LEFT JOIN usage_logs ul ON u.id = ul.user_id AND ul.created_at >= :startDate
|
||||
GROUP BY u.id, u.email, u.name
|
||||
HAVING COUNT(ul.id) > 0
|
||||
ORDER BY SUM(ul.total_tokens) DESC NULLS LAST
|
||||
`, {
|
||||
replacements: { startDate },
|
||||
type: UsageLog.sequelize.QueryTypes.SELECT,
|
||||
});
|
||||
|
||||
// 데이터 정리
|
||||
const result = userStats.map((stat) => ({
|
||||
userId: stat.userId,
|
||||
email: stat.email || 'Unknown',
|
||||
name: stat.name || '',
|
||||
totalTokens: parseInt(stat.totalTokens, 10) || 0,
|
||||
totalCost: parseFloat(stat.totalCost) || 0,
|
||||
requestCount: parseInt(stat.requestCount, 10) || 0,
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 프로바이더별 사용량 통계
|
||||
*/
|
||||
exports.getUsageByProvider = async (req, res, next) => {
|
||||
try {
|
||||
const days = parseInt(req.query.days, 10) || 7;
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - days);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
|
||||
// 프로바이더별 집계 (컬럼명 수정: providerName, modelName)
|
||||
const providerStats = await UsageLog.findAll({
|
||||
where: {
|
||||
createdAt: { [Op.gte]: startDate },
|
||||
},
|
||||
attributes: [
|
||||
'providerName',
|
||||
'modelName',
|
||||
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'],
|
||||
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('prompt_tokens')), 'promptTokens'],
|
||||
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('completion_tokens')), 'completionTokens'],
|
||||
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'],
|
||||
[UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'],
|
||||
[UsageLog.sequelize.fn('AVG', UsageLog.sequelize.col('response_time_ms')), 'avgResponseTime'],
|
||||
],
|
||||
group: ['providerName', 'modelName'],
|
||||
order: [[UsageLog.sequelize.literal('"totalTokens"'), 'DESC']],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
// 데이터 정리
|
||||
const result = providerStats.map((stat) => ({
|
||||
provider: stat.providerName || 'Unknown',
|
||||
model: stat.modelName || 'Unknown',
|
||||
totalTokens: parseInt(stat.totalTokens, 10) || 0,
|
||||
promptTokens: parseInt(stat.promptTokens, 10) || 0,
|
||||
completionTokens: parseInt(stat.completionTokens, 10) || 0,
|
||||
totalCost: parseFloat(stat.totalCost) || 0,
|
||||
requestCount: parseInt(stat.requestCount, 10) || 0,
|
||||
avgResponseTime: Math.round(parseFloat(stat.avgResponseTime) || 0),
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 시스템 통계 조회
|
||||
*/
|
||||
exports.getStats = async (req, res, next) => {
|
||||
try {
|
||||
// 전체 사용자 수
|
||||
const totalUsers = await User.count();
|
||||
const activeUsers = await User.count({ where: { status: 'active' } });
|
||||
|
||||
// 전체 API 키 수
|
||||
const totalApiKeys = await ApiKey.count();
|
||||
const activeApiKeys = await ApiKey.count({ where: { status: 'active' } });
|
||||
|
||||
// 오늘 사용량
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const todayUsage = await UsageLog.findOne({
|
||||
where: {
|
||||
createdAt: { [Op.gte]: today },
|
||||
},
|
||||
attributes: [
|
||||
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'],
|
||||
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'],
|
||||
[UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'],
|
||||
],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
// 이번 달 사용량
|
||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const monthlyUsage = await UsageLog.findOne({
|
||||
where: {
|
||||
createdAt: { [Op.gte]: monthStart },
|
||||
},
|
||||
attributes: [
|
||||
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'],
|
||||
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'],
|
||||
[UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'],
|
||||
],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
// 활성 프로바이더 수
|
||||
const activeProviders = await LLMProvider.count({ where: { isActive: true, isHealthy: true } });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
users: {
|
||||
total: totalUsers,
|
||||
active: activeUsers,
|
||||
},
|
||||
apiKeys: {
|
||||
total: totalApiKeys,
|
||||
active: activeApiKeys,
|
||||
},
|
||||
providers: {
|
||||
active: activeProviders,
|
||||
},
|
||||
usage: {
|
||||
today: {
|
||||
tokens: parseInt(todayUsage?.totalTokens, 10) || 0,
|
||||
cost: parseFloat(todayUsage?.totalCost) || 0,
|
||||
requests: parseInt(todayUsage?.requestCount, 10) || 0,
|
||||
},
|
||||
monthly: {
|
||||
tokens: parseInt(monthlyUsage?.totalTokens, 10) || 0,
|
||||
cost: parseFloat(monthlyUsage?.totalCost) || 0,
|
||||
requests: parseInt(monthlyUsage?.requestCount, 10) || 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
@@ -1,215 +0,0 @@
|
||||
// src/controllers/api-key.controller.js
|
||||
// API 키 컨트롤러
|
||||
|
||||
const { ApiKey } = require('../models');
|
||||
const logger = require('../config/logger.config');
|
||||
|
||||
/**
|
||||
* API 키 발급
|
||||
*/
|
||||
exports.create = async (req, res, next) => {
|
||||
try {
|
||||
const { name, expiresInDays, permissions } = req.body;
|
||||
const userId = req.user.userId;
|
||||
|
||||
// API 키 생성
|
||||
const rawKey = ApiKey.generateKey();
|
||||
const keyHash = ApiKey.hashKey(rawKey);
|
||||
const keyPrefix = rawKey.substring(0, 12);
|
||||
|
||||
// 만료 일시 계산
|
||||
let expiresAt = null;
|
||||
if (expiresInDays) {
|
||||
expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + expiresInDays);
|
||||
}
|
||||
|
||||
const apiKey = await ApiKey.create({
|
||||
userId,
|
||||
name,
|
||||
keyPrefix,
|
||||
keyHash,
|
||||
permissions: permissions || ['chat:read', 'chat:write'],
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
logger.info(`API 키 발급: ${name} (user: ${userId})`);
|
||||
|
||||
// 주의: 원본 키는 이 응답에서만 반환됨 (다시 조회 불가)
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
id: apiKey.id,
|
||||
name: apiKey.name,
|
||||
key: rawKey, // 원본 키 (한 번만 표시)
|
||||
keyPrefix: apiKey.keyPrefix,
|
||||
permissions: apiKey.permissions,
|
||||
expiresAt: apiKey.expiresAt,
|
||||
createdAt: apiKey.createdAt,
|
||||
message: '⚠️ API 키는 이 응답에서만 확인할 수 있습니다. 안전한 곳에 저장하세요.',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* API 키 목록 조회
|
||||
*/
|
||||
exports.list = async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
|
||||
const apiKeys = await ApiKey.findAll({
|
||||
where: { userId },
|
||||
attributes: [
|
||||
'id',
|
||||
'name',
|
||||
'keyPrefix',
|
||||
'permissions',
|
||||
'rateLimit',
|
||||
'status',
|
||||
'expiresAt',
|
||||
'lastUsedAt',
|
||||
'totalRequests',
|
||||
'createdAt',
|
||||
],
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: apiKeys,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* API 키 상세 조회
|
||||
*/
|
||||
exports.get = async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.userId;
|
||||
|
||||
const apiKey = await ApiKey.findOne({
|
||||
where: { id, userId },
|
||||
attributes: [
|
||||
'id',
|
||||
'name',
|
||||
'keyPrefix',
|
||||
'permissions',
|
||||
'rateLimit',
|
||||
'status',
|
||||
'expiresAt',
|
||||
'lastUsedAt',
|
||||
'totalRequests',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
],
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'API_KEY_NOT_FOUND',
|
||||
message: 'API 키를 찾을 수 없습니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: apiKey,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* API 키 수정
|
||||
*/
|
||||
exports.update = async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, status } = req.body;
|
||||
const userId = req.user.userId;
|
||||
|
||||
const apiKey = await ApiKey.findOne({
|
||||
where: { id, userId },
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'API_KEY_NOT_FOUND',
|
||||
message: 'API 키를 찾을 수 없습니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (name) apiKey.name = name;
|
||||
if (status) apiKey.status = status;
|
||||
|
||||
await apiKey.save();
|
||||
|
||||
logger.info(`API 키 수정: ${apiKey.name} (id: ${id})`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: apiKey.id,
|
||||
name: apiKey.name,
|
||||
keyPrefix: apiKey.keyPrefix,
|
||||
status: apiKey.status,
|
||||
updatedAt: apiKey.updatedAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* API 키 폐기
|
||||
*/
|
||||
exports.revoke = async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.userId;
|
||||
|
||||
const apiKey = await ApiKey.findOne({
|
||||
where: { id, userId },
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'API_KEY_NOT_FOUND',
|
||||
message: 'API 키를 찾을 수 없습니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
apiKey.status = 'revoked';
|
||||
await apiKey.save();
|
||||
|
||||
logger.info(`API 키 폐기: ${apiKey.name} (id: ${id})`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'API 키가 폐기되었습니다.',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
@@ -1,195 +0,0 @@
|
||||
// src/controllers/auth.controller.js
|
||||
// 인증 컨트롤러
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { User } = require('../models');
|
||||
const logger = require('../config/logger.config');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
|
||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
|
||||
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'your-refresh-secret';
|
||||
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '30d';
|
||||
|
||||
/**
|
||||
* JWT 토큰 생성
|
||||
*/
|
||||
function generateTokens(user) {
|
||||
const accessToken = jwt.sign(
|
||||
{ userId: user.id, email: user.email, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: JWT_EXPIRES_IN }
|
||||
);
|
||||
|
||||
const refreshToken = jwt.sign(
|
||||
{ userId: user.id },
|
||||
JWT_REFRESH_SECRET,
|
||||
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
|
||||
);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입
|
||||
*/
|
||||
exports.register = async (req, res, next) => {
|
||||
try {
|
||||
const { email, password, name } = req.body;
|
||||
|
||||
// 이메일 중복 확인
|
||||
const existingUser = await User.findOne({ where: { email } });
|
||||
if (existingUser) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'EMAIL_ALREADY_EXISTS',
|
||||
message: '이미 등록된 이메일입니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 사용자 생성
|
||||
const user = await User.create({
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
});
|
||||
|
||||
// 토큰 생성
|
||||
const tokens = generateTokens(user);
|
||||
|
||||
logger.info(`새 사용자 가입: ${email}`);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
user: user.toSafeJSON(),
|
||||
...tokens,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 로그인
|
||||
*/
|
||||
exports.login = async (req, res, next) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
// 사용자 조회
|
||||
const user = await User.findOne({ where: { email } });
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_CREDENTIALS',
|
||||
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 비밀번호 검증
|
||||
const isValidPassword = await user.validatePassword(password);
|
||||
if (!isValidPassword) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_CREDENTIALS',
|
||||
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 계정 상태 확인
|
||||
if (user.status !== 'active') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'ACCOUNT_INACTIVE',
|
||||
message: '계정이 비활성화되었습니다. 관리자에게 문의하세요.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 마지막 로그인 시간 업데이트
|
||||
user.lastLoginAt = new Date();
|
||||
await user.save();
|
||||
|
||||
// 토큰 생성
|
||||
const tokens = generateTokens(user);
|
||||
|
||||
logger.info(`사용자 로그인: ${email}`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
user: user.toSafeJSON(),
|
||||
...tokens,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 토큰 갱신
|
||||
*/
|
||||
exports.refresh = async (req, res, next) => {
|
||||
try {
|
||||
const { refreshToken } = req.body;
|
||||
|
||||
// 리프레시 토큰 검증
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET);
|
||||
} catch (error) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_REFRESH_TOKEN',
|
||||
message: '유효하지 않은 리프레시 토큰입니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 사용자 조회
|
||||
const user = await User.findByPk(decoded.userId);
|
||||
if (!user || user.status !== 'active') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'USER_NOT_FOUND',
|
||||
message: '사용자를 찾을 수 없습니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 새 토큰 생성
|
||||
const tokens = generateTokens(user);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: tokens,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 로그아웃
|
||||
*/
|
||||
exports.logout = async (req, res) => {
|
||||
// 클라이언트에서 토큰 삭제 처리
|
||||
// 서버에서는 특별한 처리 없음 (필요시 블랙리스트 구현)
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: '로그아웃되었습니다.',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,152 +0,0 @@
|
||||
// src/controllers/chat.controller.js
|
||||
// 채팅 컨트롤러 (OpenAI 호환 API)
|
||||
|
||||
const llmService = require('../services/llm.service');
|
||||
const logger = require('../config/logger.config');
|
||||
|
||||
/**
|
||||
* 채팅 완성 API (OpenAI 호환)
|
||||
* POST /api/v1/chat/completions
|
||||
*/
|
||||
exports.completions = async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
model = 'gemini-2.0-flash',
|
||||
messages,
|
||||
temperature = 0.7,
|
||||
max_tokens = 4096,
|
||||
stream = false,
|
||||
} = req.body;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// 스트리밍 응답 처리
|
||||
if (stream) {
|
||||
return handleStreamingResponse(req, res, {
|
||||
model,
|
||||
messages,
|
||||
temperature,
|
||||
maxTokens: max_tokens,
|
||||
});
|
||||
}
|
||||
|
||||
// 일반 응답 처리
|
||||
const result = await llmService.chat({
|
||||
model,
|
||||
messages,
|
||||
temperature,
|
||||
maxTokens: max_tokens,
|
||||
userId: req.user.id,
|
||||
apiKeyId: req.apiKey?.id,
|
||||
});
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
// 사용량 정보 저장 (미들웨어에서 처리)
|
||||
req.usageData = {
|
||||
providerId: result.providerId,
|
||||
providerName: result.provider,
|
||||
modelName: result.model,
|
||||
promptTokens: result.usage.promptTokens,
|
||||
completionTokens: result.usage.completionTokens,
|
||||
totalTokens: result.usage.totalTokens,
|
||||
costUsd: result.cost,
|
||||
responseTimeMs: responseTime,
|
||||
success: true,
|
||||
};
|
||||
|
||||
// OpenAI 호환 응답 형식
|
||||
return res.json({
|
||||
id: `chatcmpl-${Date.now()}`,
|
||||
object: 'chat.completion',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: result.model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: result.text,
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: result.usage.promptTokens,
|
||||
completion_tokens: result.usage.completionTokens,
|
||||
total_tokens: result.usage.totalTokens,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('채팅 완성 오류:', error);
|
||||
|
||||
// 사용량 정보 저장 (실패)
|
||||
req.usageData = {
|
||||
success: false,
|
||||
errorMessage: error.message,
|
||||
};
|
||||
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 스트리밍 응답 처리
|
||||
*/
|
||||
async function handleStreamingResponse(req, res, params) {
|
||||
const { model, messages, temperature, maxTokens } = params;
|
||||
|
||||
// SSE 헤더 설정
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
try {
|
||||
// 스트리밍 응답 생성
|
||||
const stream = await llmService.chatStream({
|
||||
model,
|
||||
messages,
|
||||
temperature,
|
||||
maxTokens,
|
||||
userId: req.user.id,
|
||||
apiKeyId: req.apiKey?.id,
|
||||
});
|
||||
|
||||
// 스트림 이벤트 처리
|
||||
for await (const chunk of stream) {
|
||||
const data = {
|
||||
id: `chatcmpl-${Date.now()}`,
|
||||
object: 'chat.completion.chunk',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
content: chunk.text,
|
||||
},
|
||||
finish_reason: chunk.done ? 'stop' : null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
// 스트림 종료
|
||||
res.write('data: [DONE]\n\n');
|
||||
res.end();
|
||||
} catch (error) {
|
||||
logger.error('스트리밍 오류:', error);
|
||||
|
||||
const errorData = {
|
||||
error: {
|
||||
message: error.message,
|
||||
type: 'server_error',
|
||||
},
|
||||
};
|
||||
|
||||
res.write(`data: ${JSON.stringify(errorData)}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// src/controllers/model.controller.js
|
||||
// 모델 컨트롤러
|
||||
|
||||
const { LLMProvider } = require('../models');
|
||||
|
||||
/**
|
||||
* 사용 가능한 모델 목록 조회
|
||||
*/
|
||||
exports.list = async (req, res, next) => {
|
||||
try {
|
||||
const providers = await LLMProvider.getActiveProviders();
|
||||
|
||||
// OpenAI 호환 형식으로 변환
|
||||
const models = providers.map((provider) => ({
|
||||
id: provider.modelName,
|
||||
object: 'model',
|
||||
created: Math.floor(new Date(provider.createdAt).getTime() / 1000),
|
||||
owned_by: provider.name,
|
||||
permission: [],
|
||||
root: provider.modelName,
|
||||
parent: null,
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
object: 'list',
|
||||
data: models,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 모델 상세 정보 조회
|
||||
*/
|
||||
exports.get = async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const provider = await LLMProvider.findOne({
|
||||
where: { modelName: id, isActive: true },
|
||||
});
|
||||
|
||||
if (!provider) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: `모델 '${id}'을(를) 찾을 수 없습니다.`,
|
||||
type: 'invalid_request_error',
|
||||
param: 'model',
|
||||
code: 'model_not_found',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
id: provider.modelName,
|
||||
object: 'model',
|
||||
created: Math.floor(new Date(provider.createdAt).getTime() / 1000),
|
||||
owned_by: provider.name,
|
||||
permission: [],
|
||||
root: provider.modelName,
|
||||
parent: null,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
@@ -1,177 +0,0 @@
|
||||
// src/controllers/usage.controller.js
|
||||
// 사용량 컨트롤러
|
||||
|
||||
const { UsageLog, User } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
/**
|
||||
* 사용량 요약 조회
|
||||
*/
|
||||
exports.getSummary = async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
|
||||
// 사용자 정보 조회
|
||||
const user = await User.findByPk(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'USER_NOT_FOUND',
|
||||
message: '사용자를 찾을 수 없습니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 이번 달 사용량
|
||||
const now = new Date();
|
||||
const monthlyUsage = await UsageLog.getMonthlyTotalByUser(
|
||||
userId,
|
||||
now.getFullYear(),
|
||||
now.getMonth() + 1
|
||||
);
|
||||
|
||||
// 오늘 사용량
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const todayEnd = new Date(todayStart);
|
||||
todayEnd.setDate(todayEnd.getDate() + 1);
|
||||
|
||||
const todayUsage = await UsageLog.findOne({
|
||||
where: {
|
||||
userId,
|
||||
createdAt: {
|
||||
[Op.between]: [todayStart, todayEnd],
|
||||
},
|
||||
},
|
||||
attributes: [
|
||||
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'],
|
||||
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'],
|
||||
[UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'],
|
||||
],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
plan: user.plan,
|
||||
limit: {
|
||||
monthly: user.monthlyTokenLimit,
|
||||
remaining: Math.max(0, user.monthlyTokenLimit - monthlyUsage.totalTokens),
|
||||
},
|
||||
usage: {
|
||||
today: {
|
||||
tokens: parseInt(todayUsage?.totalTokens, 10) || 0,
|
||||
cost: parseFloat(todayUsage?.totalCost) || 0,
|
||||
requests: parseInt(todayUsage?.requestCount, 10) || 0,
|
||||
},
|
||||
monthly: monthlyUsage,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 일별 사용량 조회
|
||||
*/
|
||||
exports.getDailyUsage = async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const { startDate, endDate } = req.query;
|
||||
|
||||
// 기본값: 최근 30일
|
||||
const end = endDate ? new Date(endDate) : new Date();
|
||||
const start = startDate ? new Date(startDate) : new Date(end);
|
||||
if (!startDate) {
|
||||
start.setDate(start.getDate() - 30);
|
||||
}
|
||||
|
||||
const dailyUsage = await UsageLog.getDailyUsageByUser(userId, start, end);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
startDate: start.toISOString().split('T')[0],
|
||||
endDate: end.toISOString().split('T')[0],
|
||||
usage: dailyUsage,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 월별 사용량 조회
|
||||
*/
|
||||
exports.getMonthlyUsage = async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const now = new Date();
|
||||
const year = parseInt(req.query.year, 10) || now.getFullYear();
|
||||
const month = parseInt(req.query.month, 10) || (now.getMonth() + 1);
|
||||
|
||||
const monthlyUsage = await UsageLog.getMonthlyTotalByUser(userId, year, month);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
year,
|
||||
month,
|
||||
usage: monthlyUsage,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 사용량 로그 목록 조회
|
||||
*/
|
||||
exports.getLogs = async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const page = parseInt(req.query.page, 10) || 1;
|
||||
const limit = parseInt(req.query.limit, 10) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { count, rows: logs } = await UsageLog.findAndCountAll({
|
||||
where: { userId },
|
||||
attributes: [
|
||||
'id',
|
||||
'providerName',
|
||||
'modelName',
|
||||
'promptTokens',
|
||||
'completionTokens',
|
||||
'totalTokens',
|
||||
'costUsd',
|
||||
'responseTimeMs',
|
||||
'success',
|
||||
'errorMessage',
|
||||
'createdAt',
|
||||
],
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
logs,
|
||||
pagination: {
|
||||
total: count,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(count / limit),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
@@ -1,113 +0,0 @@
|
||||
// src/controllers/user.controller.js
|
||||
// 사용자 컨트롤러
|
||||
|
||||
const { User, UsageLog } = require('../models');
|
||||
const logger = require('../config/logger.config');
|
||||
|
||||
/**
|
||||
* 내 정보 조회
|
||||
*/
|
||||
exports.getMe = async (req, res, next) => {
|
||||
try {
|
||||
const user = await User.findByPk(req.user.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'USER_NOT_FOUND',
|
||||
message: '사용자를 찾을 수 없습니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 이번 달 사용량 조회
|
||||
const now = new Date();
|
||||
const monthlyUsage = await UsageLog.getMonthlyTotalByUser(
|
||||
user.id,
|
||||
now.getFullYear(),
|
||||
now.getMonth() + 1
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...user.toSafeJSON(),
|
||||
usage: {
|
||||
monthly: monthlyUsage,
|
||||
limit: user.monthlyTokenLimit,
|
||||
remaining: Math.max(0, user.monthlyTokenLimit - monthlyUsage.totalTokens),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 내 정보 수정
|
||||
*/
|
||||
exports.updateMe = async (req, res, next) => {
|
||||
try {
|
||||
const { name, password } = req.body;
|
||||
|
||||
const user = await User.findByPk(req.user.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'USER_NOT_FOUND',
|
||||
message: '사용자를 찾을 수 없습니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 업데이트할 필드만 설정
|
||||
if (name) user.name = name;
|
||||
if (password) user.password = password;
|
||||
|
||||
await user.save();
|
||||
|
||||
logger.info(`사용자 정보 수정: ${user.email}`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: user.toSafeJSON(),
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 계정 삭제
|
||||
*/
|
||||
exports.deleteMe = async (req, res, next) => {
|
||||
try {
|
||||
const user = await User.findByPk(req.user.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'USER_NOT_FOUND',
|
||||
message: '사용자를 찾을 수 없습니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 소프트 삭제 (상태 변경)
|
||||
user.status = 'inactive';
|
||||
await user.save();
|
||||
|
||||
logger.info(`사용자 계정 삭제: ${user.email}`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: '계정이 삭제되었습니다.',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
@@ -1,257 +0,0 @@
|
||||
// src/middlewares/auth.middleware.js
|
||||
// 인증 미들웨어
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { ApiKey, User } = require('../models');
|
||||
const logger = require('../config/logger.config');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
|
||||
|
||||
/**
|
||||
* JWT 토큰 인증 미들웨어
|
||||
* Authorization: Bearer <JWT_TOKEN>
|
||||
*/
|
||||
exports.authenticateJWT = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: '인증 토큰이 필요합니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
req.user = decoded;
|
||||
return next();
|
||||
} catch (error) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'TOKEN_EXPIRED',
|
||||
message: '토큰이 만료되었습니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_TOKEN',
|
||||
message: '유효하지 않은 토큰입니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* API 키 인증 미들웨어
|
||||
* Authorization: Bearer <API_KEY>
|
||||
*/
|
||||
exports.authenticateApiKey = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
message: 'API 키가 필요합니다.',
|
||||
type: 'invalid_request_error',
|
||||
code: 'missing_api_key',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const apiKeyValue = authHeader.substring(7);
|
||||
|
||||
// API 키 접두사 확인
|
||||
const prefix = process.env.API_KEY_PREFIX || 'sk-';
|
||||
if (!apiKeyValue.startsWith(prefix)) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
message: '유효하지 않은 API 키 형식입니다.',
|
||||
type: 'invalid_request_error',
|
||||
code: 'invalid_api_key',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// API 키 조회
|
||||
const apiKey = await ApiKey.findByKey(apiKeyValue);
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
message: '유효하지 않은 API 키입니다.',
|
||||
type: 'invalid_request_error',
|
||||
code: 'invalid_api_key',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 만료 확인
|
||||
if (apiKey.isExpired()) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
message: 'API 키가 만료되었습니다.',
|
||||
type: 'invalid_request_error',
|
||||
code: 'expired_api_key',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 사용자 상태 확인
|
||||
if (apiKey.user.status !== 'active') {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: '계정이 비활성화되었습니다.',
|
||||
type: 'invalid_request_error',
|
||||
code: 'account_inactive',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 사용 기록 업데이트
|
||||
await apiKey.recordUsage();
|
||||
|
||||
// 요청 객체에 사용자 및 API 키 정보 추가
|
||||
req.user = {
|
||||
id: apiKey.user.id,
|
||||
userId: apiKey.user.id,
|
||||
email: apiKey.user.email,
|
||||
role: apiKey.user.role,
|
||||
plan: apiKey.user.plan,
|
||||
};
|
||||
req.apiKey = apiKey;
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
logger.error('API 키 인증 오류:', error);
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 관리자 권한 확인 미들웨어
|
||||
*/
|
||||
exports.requireAdmin = (req, res, next) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: '관리자 권한이 필요합니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
/**
|
||||
* JWT 또는 API 키 인증 미들웨어
|
||||
* JWT 토큰과 API 키 모두 허용
|
||||
*/
|
||||
exports.authenticateAny = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: '인증이 필요합니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const prefix = process.env.API_KEY_PREFIX || 'sk-';
|
||||
|
||||
// API 키인 경우
|
||||
if (token.startsWith(prefix)) {
|
||||
const apiKey = await ApiKey.findByKey(token);
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
message: '유효하지 않은 API 키입니다.',
|
||||
type: 'invalid_request_error',
|
||||
code: 'invalid_api_key',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (apiKey.isExpired()) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
message: 'API 키가 만료되었습니다.',
|
||||
type: 'invalid_request_error',
|
||||
code: 'expired_api_key',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (apiKey.user.status !== 'active') {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: '계정이 비활성화되었습니다.',
|
||||
type: 'invalid_request_error',
|
||||
code: 'account_inactive',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await apiKey.recordUsage();
|
||||
|
||||
req.user = {
|
||||
id: apiKey.user.id,
|
||||
userId: apiKey.user.id,
|
||||
email: apiKey.user.email,
|
||||
role: apiKey.user.role,
|
||||
plan: apiKey.user.plan,
|
||||
};
|
||||
req.apiKey = apiKey;
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
// JWT 토큰인 경우
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
req.user = decoded;
|
||||
return next();
|
||||
} catch (error) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'TOKEN_EXPIRED',
|
||||
message: '토큰이 만료되었습니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_TOKEN',
|
||||
message: '유효하지 않은 토큰입니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
@@ -1,80 +0,0 @@
|
||||
// src/middlewares/error-handler.middleware.js
|
||||
// 에러 핸들러 미들웨어
|
||||
|
||||
const logger = require('../config/logger.config');
|
||||
|
||||
/**
|
||||
* 전역 에러 핸들러
|
||||
*/
|
||||
module.exports = (err, req, res, _next) => {
|
||||
// 에러 로깅
|
||||
logger.error('에러 발생:', {
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
});
|
||||
|
||||
// Sequelize 유효성 검사 에러
|
||||
if (err.name === 'SequelizeValidationError') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: '데이터 유효성 검사 실패',
|
||||
details: err.errors.map((e) => ({
|
||||
field: e.path,
|
||||
message: e.message,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Sequelize 고유 제약 조건 에러
|
||||
if (err.name === 'SequelizeUniqueConstraintError') {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'DUPLICATE_ENTRY',
|
||||
message: '중복된 데이터가 존재합니다.',
|
||||
details: err.errors.map((e) => ({
|
||||
field: e.path,
|
||||
message: e.message,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// JWT 에러
|
||||
if (err.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_TOKEN',
|
||||
message: '유효하지 않은 토큰입니다.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 기본 에러 응답
|
||||
const statusCode = err.statusCode || 500;
|
||||
const message = err.message || '서버 오류가 발생했습니다.';
|
||||
|
||||
// 프로덕션 환경에서는 상세 에러 숨김
|
||||
const response = {
|
||||
success: false,
|
||||
error: {
|
||||
code: err.code || 'INTERNAL_ERROR',
|
||||
message: process.env.NODE_ENV === 'production' && statusCode === 500
|
||||
? '서버 오류가 발생했습니다.'
|
||||
: message,
|
||||
},
|
||||
};
|
||||
|
||||
// 개발 환경에서는 스택 트레이스 포함
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
response.error.stack = err.stack;
|
||||
}
|
||||
|
||||
return res.status(statusCode).json(response);
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
// src/middlewares/usage-logger.middleware.js
|
||||
// 사용량 로깅 미들웨어
|
||||
|
||||
const { UsageLog } = require('../models');
|
||||
const logger = require('../config/logger.config');
|
||||
|
||||
/**
|
||||
* 사용량 로깅 미들웨어
|
||||
* 응답 완료 후 사용량 정보를 데이터베이스에 저장
|
||||
*/
|
||||
exports.usageLogger = (req, res, next) => {
|
||||
// 응답 완료 후 처리
|
||||
res.on('finish', async () => {
|
||||
try {
|
||||
// 사용량 데이터가 없으면 스킵
|
||||
if (!req.usageData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usageData = {
|
||||
userId: req.user?.id || req.user?.userId,
|
||||
apiKeyId: req.apiKey?.id || null,
|
||||
providerId: req.usageData.providerId || null,
|
||||
providerName: req.usageData.providerName || null,
|
||||
modelName: req.usageData.modelName || null,
|
||||
promptTokens: req.usageData.promptTokens || 0,
|
||||
completionTokens: req.usageData.completionTokens || 0,
|
||||
totalTokens: req.usageData.totalTokens || 0,
|
||||
costUsd: req.usageData.costUsd || 0,
|
||||
responseTimeMs: req.usageData.responseTimeMs || null,
|
||||
success: req.usageData.success !== false,
|
||||
errorMessage: req.usageData.errorMessage || null,
|
||||
requestIp: req.ip || req.connection?.remoteAddress,
|
||||
userAgent: req.headers['user-agent'] || null,
|
||||
};
|
||||
|
||||
await UsageLog.create(usageData);
|
||||
|
||||
logger.debug('사용량 로그 저장:', {
|
||||
userId: usageData.userId,
|
||||
tokens: usageData.totalTokens,
|
||||
cost: usageData.costUsd,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('사용량 로그 저장 실패:', error);
|
||||
}
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
// src/middlewares/validation.middleware.js
|
||||
// 유효성 검사 미들웨어
|
||||
|
||||
const { validationResult } = require('express-validator');
|
||||
|
||||
/**
|
||||
* 요청 유효성 검사 결과 처리
|
||||
*/
|
||||
exports.validateRequest = (req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
const formattedErrors = errors.array().map((error) => ({
|
||||
field: error.path,
|
||||
message: error.msg,
|
||||
value: error.value,
|
||||
}));
|
||||
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: '입력값이 올바르지 않습니다.',
|
||||
details: formattedErrors,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -1,130 +0,0 @@
|
||||
// src/models/api-key.model.js
|
||||
// API 키 모델
|
||||
|
||||
const { DataTypes } = require('sequelize');
|
||||
const crypto = require('crypto');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const ApiKey = sequelize.define('ApiKey', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
comment: '소유자 사용자 ID',
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false,
|
||||
comment: 'API 키 이름 (사용자 지정)',
|
||||
},
|
||||
keyPrefix: {
|
||||
type: DataTypes.STRING(12),
|
||||
allowNull: false,
|
||||
comment: 'API 키 접두사 (표시용)',
|
||||
},
|
||||
keyHash: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
comment: 'API 키 해시 (SHA-256)',
|
||||
},
|
||||
permissions: {
|
||||
type: DataTypes.JSONB,
|
||||
defaultValue: ['chat:read', 'chat:write'],
|
||||
comment: '권한 목록',
|
||||
},
|
||||
rateLimit: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 60, // 분당 60회
|
||||
comment: '분당 요청 제한',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('active', 'revoked', 'expired'),
|
||||
defaultValue: 'active',
|
||||
comment: 'API 키 상태',
|
||||
},
|
||||
expiresAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: '만료 일시 (null이면 무기한)',
|
||||
},
|
||||
lastUsedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: '마지막 사용 시간',
|
||||
},
|
||||
totalRequests: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
comment: '총 요청 수',
|
||||
},
|
||||
}, {
|
||||
tableName: 'api_keys',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
indexes: [
|
||||
{
|
||||
fields: ['key_hash'],
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
fields: ['user_id'],
|
||||
},
|
||||
{
|
||||
fields: ['status'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// 클래스 메서드: API 키 생성
|
||||
ApiKey.generateKey = function() {
|
||||
const prefix = process.env.API_KEY_PREFIX || 'sk-';
|
||||
const length = parseInt(process.env.API_KEY_LENGTH, 10) || 48;
|
||||
const randomPart = crypto.randomBytes(length).toString('base64url').slice(0, length);
|
||||
return `${prefix}${randomPart}`;
|
||||
};
|
||||
|
||||
// 클래스 메서드: API 키 해시 생성
|
||||
ApiKey.hashKey = function(key) {
|
||||
return crypto.createHash('sha256').update(key).digest('hex');
|
||||
};
|
||||
|
||||
// 클래스 메서드: API 키로 조회
|
||||
ApiKey.findByKey = async function(key) {
|
||||
const keyHash = this.hashKey(key);
|
||||
const apiKey = await this.findOne({
|
||||
where: { keyHash, status: 'active' },
|
||||
});
|
||||
|
||||
if (apiKey) {
|
||||
// 사용자 정보 별도 조회
|
||||
const { User } = require('./index');
|
||||
apiKey.user = await User.findByPk(apiKey.userId);
|
||||
}
|
||||
|
||||
return apiKey;
|
||||
};
|
||||
|
||||
// 인스턴스 메서드: 사용 기록 업데이트
|
||||
ApiKey.prototype.recordUsage = async function() {
|
||||
this.lastUsedAt = new Date();
|
||||
this.totalRequests += 1;
|
||||
await this.save();
|
||||
};
|
||||
|
||||
// 인스턴스 메서드: 만료 여부 확인
|
||||
ApiKey.prototype.isExpired = function() {
|
||||
if (!this.expiresAt) return false;
|
||||
return new Date() > this.expiresAt;
|
||||
};
|
||||
|
||||
return ApiKey;
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
// src/models/index.js
|
||||
// Sequelize 모델 인덱스
|
||||
|
||||
const { Sequelize } = require('sequelize');
|
||||
const config = require('../config/database.config');
|
||||
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
const dbConfig = config[env];
|
||||
|
||||
// Sequelize 인스턴스 생성
|
||||
const sequelize = new Sequelize(
|
||||
dbConfig.database,
|
||||
dbConfig.username,
|
||||
dbConfig.password,
|
||||
{
|
||||
host: dbConfig.host,
|
||||
port: dbConfig.port,
|
||||
dialect: dbConfig.dialect,
|
||||
logging: dbConfig.logging,
|
||||
pool: dbConfig.pool,
|
||||
dialectOptions: dbConfig.dialectOptions,
|
||||
}
|
||||
);
|
||||
|
||||
// 모델 임포트
|
||||
const User = require('./user.model')(sequelize);
|
||||
const ApiKey = require('./api-key.model')(sequelize);
|
||||
const UsageLog = require('./usage-log.model')(sequelize);
|
||||
const LLMProvider = require('./llm-provider.model')(sequelize);
|
||||
|
||||
// 관계 설정
|
||||
// User - ApiKey (1:N)
|
||||
User.hasMany(ApiKey, { foreignKey: 'userId', as: 'apiKeys' });
|
||||
ApiKey.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||
|
||||
// User - UsageLog (1:N)
|
||||
User.hasMany(UsageLog, { foreignKey: 'userId', as: 'usageLogs' });
|
||||
UsageLog.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||
|
||||
// ApiKey - UsageLog (1:N)
|
||||
ApiKey.hasMany(UsageLog, { foreignKey: 'apiKeyId', as: 'usageLogs' });
|
||||
UsageLog.belongsTo(ApiKey, { foreignKey: 'apiKeyId', as: 'apiKey' });
|
||||
|
||||
// LLMProvider - UsageLog (1:N)
|
||||
LLMProvider.hasMany(UsageLog, { foreignKey: 'providerId', as: 'usageLogs' });
|
||||
UsageLog.belongsTo(LLMProvider, { foreignKey: 'providerId', as: 'provider' });
|
||||
|
||||
module.exports = {
|
||||
sequelize,
|
||||
Sequelize,
|
||||
User,
|
||||
ApiKey,
|
||||
UsageLog,
|
||||
LLMProvider,
|
||||
};
|
||||
@@ -1,143 +0,0 @@
|
||||
// src/models/llm-provider.model.js
|
||||
// LLM 프로바이더 모델
|
||||
|
||||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const LLMProvider = sequelize.define('LLMProvider', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
comment: '프로바이더 이름 (gemini, openai, claude 등)',
|
||||
},
|
||||
displayName: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false,
|
||||
comment: '표시 이름',
|
||||
},
|
||||
endpoint: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: true,
|
||||
comment: 'API 엔드포인트 URL',
|
||||
},
|
||||
apiKey: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'API 키 (암호화 저장 권장)',
|
||||
},
|
||||
modelName: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false,
|
||||
comment: '기본 모델 이름',
|
||||
},
|
||||
priority: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 100,
|
||||
comment: '우선순위 (낮을수록 우선)',
|
||||
},
|
||||
maxTokens: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 4096,
|
||||
comment: '최대 토큰 수',
|
||||
},
|
||||
temperature: {
|
||||
type: DataTypes.FLOAT,
|
||||
defaultValue: 0.7,
|
||||
comment: '기본 온도',
|
||||
},
|
||||
timeoutMs: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 60000,
|
||||
comment: '타임아웃 (밀리초)',
|
||||
},
|
||||
costPer1kInputTokens: {
|
||||
type: DataTypes.DECIMAL(10, 6),
|
||||
defaultValue: 0,
|
||||
comment: '입력 토큰 1K당 비용 (USD)',
|
||||
},
|
||||
costPer1kOutputTokens: {
|
||||
type: DataTypes.DECIMAL(10, 6),
|
||||
defaultValue: 0,
|
||||
comment: '출력 토큰 1K당 비용 (USD)',
|
||||
},
|
||||
isActive: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true,
|
||||
comment: '활성화 여부',
|
||||
},
|
||||
isHealthy: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true,
|
||||
comment: '건강 상태',
|
||||
},
|
||||
lastHealthCheck: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: '마지막 헬스 체크 시간',
|
||||
},
|
||||
healthCheckUrl: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: true,
|
||||
comment: '헬스 체크 URL',
|
||||
},
|
||||
config: {
|
||||
type: DataTypes.JSONB,
|
||||
defaultValue: {},
|
||||
comment: '추가 설정',
|
||||
},
|
||||
}, {
|
||||
tableName: 'llm_providers',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
indexes: [
|
||||
{
|
||||
fields: ['name'],
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
fields: ['priority'],
|
||||
},
|
||||
{
|
||||
fields: ['is_active', 'is_healthy'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// 클래스 메서드: 활성 프로바이더 목록 조회 (우선순위 순)
|
||||
LLMProvider.getActiveProviders = async function() {
|
||||
return this.findAll({
|
||||
where: { isActive: true },
|
||||
order: [['priority', 'ASC']],
|
||||
});
|
||||
};
|
||||
|
||||
// 클래스 메서드: 건강한 프로바이더 목록 조회
|
||||
LLMProvider.getHealthyProviders = async function() {
|
||||
return this.findAll({
|
||||
where: { isActive: true, isHealthy: true },
|
||||
order: [['priority', 'ASC']],
|
||||
});
|
||||
};
|
||||
|
||||
// 인스턴스 메서드: 헬스 상태 업데이트
|
||||
LLMProvider.prototype.updateHealth = async function(isHealthy) {
|
||||
this.isHealthy = isHealthy;
|
||||
this.lastHealthCheck = new Date();
|
||||
await this.save();
|
||||
};
|
||||
|
||||
// 인스턴스 메서드: 비용 계산
|
||||
LLMProvider.prototype.calculateCost = function(promptTokens, completionTokens) {
|
||||
const inputCost = (promptTokens / 1000) * parseFloat(this.costPer1kInputTokens || 0);
|
||||
const outputCost = (completionTokens / 1000) * parseFloat(this.costPer1kOutputTokens || 0);
|
||||
return inputCost + outputCost;
|
||||
};
|
||||
|
||||
return LLMProvider;
|
||||
};
|
||||
@@ -1,164 +0,0 @@
|
||||
// src/models/usage-log.model.js
|
||||
// 사용량 로그 모델
|
||||
|
||||
const { DataTypes, Op } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const UsageLog = sequelize.define('UsageLog', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
comment: '사용자 ID',
|
||||
},
|
||||
apiKeyId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'api_keys',
|
||||
key: 'id',
|
||||
},
|
||||
comment: 'API 키 ID',
|
||||
},
|
||||
providerId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'llm_providers',
|
||||
key: 'id',
|
||||
},
|
||||
comment: 'LLM 프로바이더 ID',
|
||||
},
|
||||
providerName: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
comment: 'LLM 프로바이더 이름',
|
||||
},
|
||||
modelName: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
comment: '사용된 모델 이름',
|
||||
},
|
||||
promptTokens: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
comment: '프롬프트 토큰 수',
|
||||
},
|
||||
completionTokens: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
comment: '완성 토큰 수',
|
||||
},
|
||||
totalTokens: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
comment: '총 토큰 수',
|
||||
},
|
||||
costUsd: {
|
||||
type: DataTypes.DECIMAL(10, 6),
|
||||
defaultValue: 0,
|
||||
comment: '비용 (USD)',
|
||||
},
|
||||
responseTimeMs: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: '응답 시간 (밀리초)',
|
||||
},
|
||||
success: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true,
|
||||
comment: '성공 여부',
|
||||
},
|
||||
errorMessage: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: '에러 메시지',
|
||||
},
|
||||
requestIp: {
|
||||
type: DataTypes.STRING(45),
|
||||
allowNull: true,
|
||||
comment: '요청 IP 주소',
|
||||
},
|
||||
userAgent: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: true,
|
||||
comment: 'User-Agent',
|
||||
},
|
||||
}, {
|
||||
tableName: 'usage_logs',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
indexes: [
|
||||
{
|
||||
fields: ['user_id'],
|
||||
},
|
||||
{
|
||||
fields: ['api_key_id'],
|
||||
},
|
||||
{
|
||||
fields: ['created_at'],
|
||||
},
|
||||
{
|
||||
fields: ['provider_name'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// 클래스 메서드: 사용자별 일별 사용량 조회
|
||||
UsageLog.getDailyUsageByUser = async function(userId, startDate, endDate) {
|
||||
return this.findAll({
|
||||
where: {
|
||||
userId,
|
||||
createdAt: {
|
||||
[Op.between]: [startDate, endDate],
|
||||
},
|
||||
},
|
||||
attributes: [
|
||||
[sequelize.fn('DATE', sequelize.col('created_at')), 'date'],
|
||||
[sequelize.fn('SUM', sequelize.col('total_tokens')), 'totalTokens'],
|
||||
[sequelize.fn('SUM', sequelize.col('cost_usd')), 'totalCost'],
|
||||
[sequelize.fn('COUNT', sequelize.col('id')), 'requestCount'],
|
||||
],
|
||||
group: [sequelize.fn('DATE', sequelize.col('created_at'))],
|
||||
order: [[sequelize.fn('DATE', sequelize.col('created_at')), 'ASC']],
|
||||
raw: true,
|
||||
});
|
||||
};
|
||||
|
||||
// 클래스 메서드: 사용자별 월간 총 사용량 조회
|
||||
UsageLog.getMonthlyTotalByUser = async function(userId, year, month) {
|
||||
const startDate = new Date(year, month - 1, 1);
|
||||
const endDate = new Date(year, month, 0, 23, 59, 59);
|
||||
|
||||
const result = await this.findOne({
|
||||
where: {
|
||||
userId,
|
||||
createdAt: {
|
||||
[Op.between]: [startDate, endDate],
|
||||
},
|
||||
},
|
||||
attributes: [
|
||||
[sequelize.fn('SUM', sequelize.col('total_tokens')), 'totalTokens'],
|
||||
[sequelize.fn('SUM', sequelize.col('cost_usd')), 'totalCost'],
|
||||
[sequelize.fn('COUNT', sequelize.col('id')), 'requestCount'],
|
||||
],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
return {
|
||||
totalTokens: parseInt(result.totalTokens, 10) || 0,
|
||||
totalCost: parseFloat(result.totalCost) || 0,
|
||||
requestCount: parseInt(result.requestCount, 10) || 0,
|
||||
};
|
||||
};
|
||||
|
||||
return UsageLog;
|
||||
};
|
||||
@@ -1,92 +0,0 @@
|
||||
// src/models/user.model.js
|
||||
// 사용자 모델
|
||||
|
||||
const { DataTypes } = require('sequelize');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const User = sequelize.define('User', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
validate: {
|
||||
isEmail: true,
|
||||
},
|
||||
comment: '이메일 (로그인 ID)',
|
||||
},
|
||||
password: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
comment: '비밀번호 (해시)',
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false,
|
||||
comment: '사용자 이름',
|
||||
},
|
||||
role: {
|
||||
type: DataTypes.ENUM('user', 'admin'),
|
||||
defaultValue: 'user',
|
||||
comment: '역할 (user: 일반 사용자, admin: 관리자)',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('active', 'inactive', 'suspended'),
|
||||
defaultValue: 'active',
|
||||
comment: '계정 상태',
|
||||
},
|
||||
plan: {
|
||||
type: DataTypes.ENUM('free', 'basic', 'pro', 'enterprise'),
|
||||
defaultValue: 'free',
|
||||
comment: '요금제 플랜',
|
||||
},
|
||||
monthlyTokenLimit: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 100000, // 무료 플랜 기본 10만 토큰
|
||||
comment: '월간 토큰 한도',
|
||||
},
|
||||
lastLoginAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: '마지막 로그인 시간',
|
||||
},
|
||||
}, {
|
||||
tableName: 'users',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
hooks: {
|
||||
// 비밀번호 해싱
|
||||
beforeCreate: async (user) => {
|
||||
if (user.password) {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
user.password = await bcrypt.hash(user.password, salt);
|
||||
}
|
||||
},
|
||||
beforeUpdate: async (user) => {
|
||||
if (user.changed('password')) {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
user.password = await bcrypt.hash(user.password, salt);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 인스턴스 메서드: 비밀번호 검증
|
||||
User.prototype.validatePassword = async function(password) {
|
||||
return bcrypt.compare(password, this.password);
|
||||
};
|
||||
|
||||
// 인스턴스 메서드: 안전한 JSON 변환 (비밀번호 제외)
|
||||
User.prototype.toSafeJSON = function() {
|
||||
const values = { ...this.get() };
|
||||
delete values.password;
|
||||
return values;
|
||||
};
|
||||
|
||||
return User;
|
||||
};
|
||||
@@ -1,151 +0,0 @@
|
||||
// src/routes/admin.routes.js
|
||||
// 관리자 라우트
|
||||
|
||||
const express = require('express');
|
||||
const { body, param } = require('express-validator');
|
||||
const adminController = require('../controllers/admin.controller');
|
||||
const { authenticateJWT, requireAdmin } = require('../middlewares/auth.middleware');
|
||||
const { validateRequest } = require('../middlewares/validation.middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 모든 라우트에 JWT 인증 + 관리자 권한 필요
|
||||
router.use(authenticateJWT);
|
||||
router.use(requireAdmin);
|
||||
|
||||
// ===== LLM 프로바이더 관리 =====
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/providers
|
||||
* LLM 프로바이더 목록 조회
|
||||
*/
|
||||
router.get('/providers', adminController.getProviders);
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/providers
|
||||
* LLM 프로바이더 추가
|
||||
*/
|
||||
router.post(
|
||||
'/providers',
|
||||
[
|
||||
body('name')
|
||||
.trim()
|
||||
.isLength({ min: 1, max: 50 })
|
||||
.withMessage('프로바이더 이름은 1-50자 사이여야 합니다'),
|
||||
body('displayName')
|
||||
.trim()
|
||||
.isLength({ min: 1, max: 100 })
|
||||
.withMessage('표시 이름은 1-100자 사이여야 합니다'),
|
||||
body('modelName')
|
||||
.trim()
|
||||
.isLength({ min: 1, max: 100 })
|
||||
.withMessage('모델 이름은 1-100자 사이여야 합니다'),
|
||||
body('apiKey')
|
||||
.optional()
|
||||
.isString(),
|
||||
body('priority')
|
||||
.optional()
|
||||
.isInt({ min: 1, max: 100 }),
|
||||
validateRequest,
|
||||
],
|
||||
adminController.createProvider
|
||||
);
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/admin/providers/:id
|
||||
* LLM 프로바이더 수정 (API 키 설정 포함)
|
||||
*/
|
||||
router.patch(
|
||||
'/providers/:id',
|
||||
[
|
||||
param('id')
|
||||
.isUUID()
|
||||
.withMessage('유효한 프로바이더 ID가 아닙니다'),
|
||||
body('apiKey')
|
||||
.optional()
|
||||
.isString(),
|
||||
body('modelName')
|
||||
.optional()
|
||||
.isString(),
|
||||
body('isActive')
|
||||
.optional()
|
||||
.isBoolean(),
|
||||
body('priority')
|
||||
.optional()
|
||||
.isInt({ min: 1, max: 100 }),
|
||||
validateRequest,
|
||||
],
|
||||
adminController.updateProvider
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/admin/providers/:id
|
||||
* LLM 프로바이더 삭제
|
||||
*/
|
||||
router.delete(
|
||||
'/providers/:id',
|
||||
[
|
||||
param('id')
|
||||
.isUUID()
|
||||
.withMessage('유효한 프로바이더 ID가 아닙니다'),
|
||||
validateRequest,
|
||||
],
|
||||
adminController.deleteProvider
|
||||
);
|
||||
|
||||
// ===== 사용자 관리 =====
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/users
|
||||
* 사용자 목록 조회
|
||||
*/
|
||||
router.get('/users', adminController.getUsers);
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/admin/users/:id
|
||||
* 사용자 정보 수정 (역할, 상태, 플랜 등)
|
||||
*/
|
||||
router.patch(
|
||||
'/users/:id',
|
||||
[
|
||||
param('id')
|
||||
.isUUID()
|
||||
.withMessage('유효한 사용자 ID가 아닙니다'),
|
||||
body('role')
|
||||
.optional()
|
||||
.isIn(['user', 'admin']),
|
||||
body('status')
|
||||
.optional()
|
||||
.isIn(['active', 'inactive', 'suspended']),
|
||||
body('plan')
|
||||
.optional()
|
||||
.isIn(['free', 'basic', 'pro', 'enterprise']),
|
||||
body('monthlyTokenLimit')
|
||||
.optional()
|
||||
.isInt({ min: 0 }),
|
||||
validateRequest,
|
||||
],
|
||||
adminController.updateUser
|
||||
);
|
||||
|
||||
// ===== 시스템 통계 =====
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/stats
|
||||
* 시스템 통계 조회
|
||||
*/
|
||||
router.get('/stats', adminController.getStats);
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/usage/by-user
|
||||
* 사용자별 사용량 통계
|
||||
*/
|
||||
router.get('/usage/by-user', adminController.getUsageByUser);
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/usage/by-provider
|
||||
* 프로바이더별 사용량 통계
|
||||
*/
|
||||
router.get('/usage/by-provider', adminController.getUsageByProvider);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,99 +0,0 @@
|
||||
// src/routes/api-key.routes.js
|
||||
// API 키 라우트
|
||||
|
||||
const express = require('express');
|
||||
const { body, param } = require('express-validator');
|
||||
const apiKeyController = require('../controllers/api-key.controller');
|
||||
const { authenticateJWT } = require('../middlewares/auth.middleware');
|
||||
const { validateRequest } = require('../middlewares/validation.middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 모든 라우트에 JWT 인증 적용
|
||||
router.use(authenticateJWT);
|
||||
|
||||
/**
|
||||
* POST /api/v1/api-keys
|
||||
* API 키 발급
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
[
|
||||
body('name')
|
||||
.trim()
|
||||
.isLength({ min: 1, max: 100 })
|
||||
.withMessage('API 키 이름은 1-100자 사이여야 합니다'),
|
||||
body('expiresInDays')
|
||||
.optional()
|
||||
.isInt({ min: 1, max: 365 })
|
||||
.withMessage('만료 기간은 1-365일 사이여야 합니다'),
|
||||
body('permissions')
|
||||
.optional()
|
||||
.isArray()
|
||||
.withMessage('권한은 배열이어야 합니다'),
|
||||
validateRequest,
|
||||
],
|
||||
apiKeyController.create
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/v1/api-keys
|
||||
* API 키 목록 조회
|
||||
*/
|
||||
router.get('/', apiKeyController.list);
|
||||
|
||||
/**
|
||||
* GET /api/v1/api-keys/:id
|
||||
* API 키 상세 조회
|
||||
*/
|
||||
router.get(
|
||||
'/:id',
|
||||
[
|
||||
param('id')
|
||||
.isUUID()
|
||||
.withMessage('유효한 API 키 ID가 아닙니다'),
|
||||
validateRequest,
|
||||
],
|
||||
apiKeyController.get
|
||||
);
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/api-keys/:id
|
||||
* API 키 수정
|
||||
*/
|
||||
router.patch(
|
||||
'/:id',
|
||||
[
|
||||
param('id')
|
||||
.isUUID()
|
||||
.withMessage('유효한 API 키 ID가 아닙니다'),
|
||||
body('name')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ min: 1, max: 100 })
|
||||
.withMessage('API 키 이름은 1-100자 사이여야 합니다'),
|
||||
body('status')
|
||||
.optional()
|
||||
.isIn(['active', 'revoked'])
|
||||
.withMessage('상태는 active 또는 revoked여야 합니다'),
|
||||
validateRequest,
|
||||
],
|
||||
apiKeyController.update
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/api-keys/:id
|
||||
* API 키 폐기
|
||||
*/
|
||||
router.delete(
|
||||
'/:id',
|
||||
[
|
||||
param('id')
|
||||
.isUUID()
|
||||
.withMessage('유효한 API 키 ID가 아닙니다'),
|
||||
validateRequest,
|
||||
],
|
||||
apiKeyController.revoke
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,76 +0,0 @@
|
||||
// src/routes/auth.routes.js
|
||||
// 인증 라우트
|
||||
|
||||
const express = require('express');
|
||||
const { body } = require('express-validator');
|
||||
const authController = require('../controllers/auth.controller');
|
||||
const { validateRequest } = require('../middlewares/validation.middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/register
|
||||
* 회원가입
|
||||
*/
|
||||
router.post(
|
||||
'/register',
|
||||
[
|
||||
body('email')
|
||||
.isEmail()
|
||||
.normalizeEmail()
|
||||
.withMessage('유효한 이메일 주소를 입력해주세요'),
|
||||
body('password')
|
||||
.isLength({ min: 8 })
|
||||
.withMessage('비밀번호는 최소 8자 이상이어야 합니다')
|
||||
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
|
||||
.withMessage('비밀번호는 대문자, 소문자, 숫자를 포함해야 합니다'),
|
||||
body('name')
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 100 })
|
||||
.withMessage('이름은 2-100자 사이여야 합니다'),
|
||||
validateRequest,
|
||||
],
|
||||
authController.register
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/login
|
||||
* 로그인
|
||||
*/
|
||||
router.post(
|
||||
'/login',
|
||||
[
|
||||
body('email')
|
||||
.isEmail()
|
||||
.normalizeEmail()
|
||||
.withMessage('유효한 이메일 주소를 입력해주세요'),
|
||||
body('password')
|
||||
.notEmpty()
|
||||
.withMessage('비밀번호를 입력해주세요'),
|
||||
validateRequest,
|
||||
],
|
||||
authController.login
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/refresh
|
||||
* 토큰 갱신
|
||||
*/
|
||||
router.post(
|
||||
'/refresh',
|
||||
[
|
||||
body('refreshToken')
|
||||
.notEmpty()
|
||||
.withMessage('리프레시 토큰을 입력해주세요'),
|
||||
validateRequest,
|
||||
],
|
||||
authController.refresh
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/logout
|
||||
* 로그아웃
|
||||
*/
|
||||
router.post('/logout', authController.logout);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,55 +0,0 @@
|
||||
// src/routes/chat.routes.js
|
||||
// 채팅 API 라우트 (OpenAI 호환)
|
||||
|
||||
const express = require('express');
|
||||
const { body } = require('express-validator');
|
||||
const chatController = require('../controllers/chat.controller');
|
||||
const { authenticateAny } = require('../middlewares/auth.middleware');
|
||||
const { validateRequest } = require('../middlewares/validation.middleware');
|
||||
const { usageLogger } = require('../middlewares/usage-logger.middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* POST /api/v1/chat/completions
|
||||
* 채팅 완성 API (OpenAI 호환)
|
||||
*
|
||||
* 인증: Bearer API_KEY 또는 JWT 토큰
|
||||
*/
|
||||
router.post(
|
||||
'/completions',
|
||||
authenticateAny,
|
||||
[
|
||||
body('model')
|
||||
.optional()
|
||||
.isString()
|
||||
.withMessage('모델은 문자열이어야 합니다'),
|
||||
body('messages')
|
||||
.isArray({ min: 1 })
|
||||
.withMessage('메시지 배열이 필요합니다'),
|
||||
body('messages.*.role')
|
||||
.isIn(['system', 'user', 'assistant'])
|
||||
.withMessage('메시지 역할은 system, user, assistant 중 하나여야 합니다'),
|
||||
body('messages.*.content')
|
||||
.isString()
|
||||
.notEmpty()
|
||||
.withMessage('메시지 내용이 필요합니다'),
|
||||
body('temperature')
|
||||
.optional()
|
||||
.isFloat({ min: 0, max: 2 })
|
||||
.withMessage('온도는 0-2 사이여야 합니다'),
|
||||
body('max_tokens')
|
||||
.optional()
|
||||
.isInt({ min: 1, max: 128000 })
|
||||
.withMessage('최대 토큰은 1-128000 사이여야 합니다'),
|
||||
body('stream')
|
||||
.optional()
|
||||
.isBoolean()
|
||||
.withMessage('스트림은 불리언이어야 합니다'),
|
||||
validateRequest,
|
||||
],
|
||||
usageLogger,
|
||||
chatController.completions
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,45 +0,0 @@
|
||||
// src/routes/index.js
|
||||
// API 라우트 인덱스
|
||||
|
||||
const express = require('express');
|
||||
const authRoutes = require('./auth.routes');
|
||||
const userRoutes = require('./user.routes');
|
||||
const apiKeyRoutes = require('./api-key.routes');
|
||||
const chatRoutes = require('./chat.routes');
|
||||
const usageRoutes = require('./usage.routes');
|
||||
const modelRoutes = require('./model.routes');
|
||||
const adminRoutes = require('./admin.routes');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// API 정보
|
||||
router.get('/', (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
name: 'AI Assistant API',
|
||||
version: '1.0.0',
|
||||
description: 'LLM API Platform - OpenAI 호환 API',
|
||||
endpoints: {
|
||||
auth: '/api/v1/auth',
|
||||
users: '/api/v1/users',
|
||||
apiKeys: '/api/v1/api-keys',
|
||||
chat: '/api/v1/chat',
|
||||
models: '/api/v1/models',
|
||||
usage: '/api/v1/usage',
|
||||
},
|
||||
documentation: 'https://docs.example.com',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// 라우트 등록
|
||||
router.use('/auth', authRoutes);
|
||||
router.use('/users', userRoutes);
|
||||
router.use('/api-keys', apiKeyRoutes);
|
||||
router.use('/chat', chatRoutes);
|
||||
router.use('/models', modelRoutes);
|
||||
router.use('/usage', usageRoutes);
|
||||
router.use('/admin', adminRoutes);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,24 +0,0 @@
|
||||
// src/routes/model.routes.js
|
||||
// 모델 라우트
|
||||
|
||||
const express = require('express');
|
||||
const modelController = require('../controllers/model.controller');
|
||||
const { authenticateAny } = require('../middlewares/auth.middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /api/v1/models
|
||||
* 사용 가능한 모델 목록 조회
|
||||
* JWT 토큰 또는 API 키로 인증
|
||||
*/
|
||||
router.get('/', authenticateAny, modelController.list);
|
||||
|
||||
/**
|
||||
* GET /api/v1/models/:id
|
||||
* 모델 상세 정보 조회
|
||||
* JWT 토큰 또는 API 키로 인증
|
||||
*/
|
||||
router.get('/:id', authenticateAny, modelController.get);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,81 +0,0 @@
|
||||
// src/routes/usage.routes.js
|
||||
// 사용량 라우트
|
||||
|
||||
const express = require('express');
|
||||
const { query } = require('express-validator');
|
||||
const usageController = require('../controllers/usage.controller');
|
||||
const { authenticateJWT } = require('../middlewares/auth.middleware');
|
||||
const { validateRequest } = require('../middlewares/validation.middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 모든 라우트에 JWT 인증 적용
|
||||
router.use(authenticateJWT);
|
||||
|
||||
/**
|
||||
* GET /api/v1/usage
|
||||
* 사용량 요약 조회
|
||||
*/
|
||||
router.get('/', usageController.getSummary);
|
||||
|
||||
/**
|
||||
* GET /api/v1/usage/daily
|
||||
* 일별 사용량 조회
|
||||
*/
|
||||
router.get(
|
||||
'/daily',
|
||||
[
|
||||
query('startDate')
|
||||
.optional()
|
||||
.isISO8601()
|
||||
.withMessage('시작 날짜는 ISO 8601 형식이어야 합니다'),
|
||||
query('endDate')
|
||||
.optional()
|
||||
.isISO8601()
|
||||
.withMessage('종료 날짜는 ISO 8601 형식이어야 합니다'),
|
||||
validateRequest,
|
||||
],
|
||||
usageController.getDailyUsage
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/v1/usage/monthly
|
||||
* 월별 사용량 조회
|
||||
*/
|
||||
router.get(
|
||||
'/monthly',
|
||||
[
|
||||
query('year')
|
||||
.optional()
|
||||
.isInt({ min: 2020, max: 2100 })
|
||||
.withMessage('연도는 2020-2100 사이여야 합니다'),
|
||||
query('month')
|
||||
.optional()
|
||||
.isInt({ min: 1, max: 12 })
|
||||
.withMessage('월은 1-12 사이여야 합니다'),
|
||||
validateRequest,
|
||||
],
|
||||
usageController.getMonthlyUsage
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/v1/usage/logs
|
||||
* 사용량 로그 목록 조회
|
||||
*/
|
||||
router.get(
|
||||
'/logs',
|
||||
[
|
||||
query('page')
|
||||
.optional()
|
||||
.isInt({ min: 1 })
|
||||
.withMessage('페이지는 1 이상이어야 합니다'),
|
||||
query('limit')
|
||||
.optional()
|
||||
.isInt({ min: 1, max: 100 })
|
||||
.withMessage('한도는 1-100 사이여야 합니다'),
|
||||
validateRequest,
|
||||
],
|
||||
usageController.getLogs
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,50 +0,0 @@
|
||||
// src/routes/user.routes.js
|
||||
// 사용자 라우트
|
||||
|
||||
const express = require('express');
|
||||
const { body } = require('express-validator');
|
||||
const userController = require('../controllers/user.controller');
|
||||
const { authenticateJWT } = require('../middlewares/auth.middleware');
|
||||
const { validateRequest } = require('../middlewares/validation.middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 모든 라우트에 JWT 인증 적용
|
||||
router.use(authenticateJWT);
|
||||
|
||||
/**
|
||||
* GET /api/v1/users/me
|
||||
* 내 정보 조회
|
||||
*/
|
||||
router.get('/me', userController.getMe);
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/users/me
|
||||
* 내 정보 수정
|
||||
*/
|
||||
router.patch(
|
||||
'/me',
|
||||
[
|
||||
body('name')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 100 })
|
||||
.withMessage('이름은 2-100자 사이여야 합니다'),
|
||||
body('password')
|
||||
.optional()
|
||||
.isLength({ min: 8 })
|
||||
.withMessage('비밀번호는 최소 8자 이상이어야 합니다')
|
||||
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
|
||||
.withMessage('비밀번호는 대문자, 소문자, 숫자를 포함해야 합니다'),
|
||||
validateRequest,
|
||||
],
|
||||
userController.updateMe
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/users/me
|
||||
* 계정 삭제
|
||||
*/
|
||||
router.delete('/me', userController.deleteMe);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,74 +0,0 @@
|
||||
// src/seeders/001-llm-providers.js
|
||||
// LLM 프로바이더 시드 데이터
|
||||
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
const now = new Date();
|
||||
|
||||
await queryInterface.bulkInsert('llm_providers', [
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: 'gemini',
|
||||
display_name: 'Google Gemini',
|
||||
endpoint: null, // SDK 사용
|
||||
api_key: process.env.GEMINI_API_KEY || '',
|
||||
model_name: 'gemini-2.0-flash',
|
||||
priority: 1,
|
||||
max_tokens: 8192,
|
||||
temperature: 0.7,
|
||||
timeout_ms: 60000,
|
||||
cost_per_1k_input_tokens: 0.00025,
|
||||
cost_per_1k_output_tokens: 0.001,
|
||||
is_active: true,
|
||||
is_healthy: true,
|
||||
config: JSON.stringify({}),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: 'openai',
|
||||
display_name: 'OpenAI GPT',
|
||||
endpoint: 'https://api.openai.com/v1/chat/completions',
|
||||
api_key: process.env.OPENAI_API_KEY || '',
|
||||
model_name: 'gpt-4o-mini',
|
||||
priority: 2,
|
||||
max_tokens: 4096,
|
||||
temperature: 0.7,
|
||||
timeout_ms: 60000,
|
||||
cost_per_1k_input_tokens: 0.00015,
|
||||
cost_per_1k_output_tokens: 0.0006,
|
||||
is_active: true,
|
||||
is_healthy: true,
|
||||
config: JSON.stringify({}),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: 'claude',
|
||||
display_name: 'Anthropic Claude',
|
||||
endpoint: 'https://api.anthropic.com/v1/messages',
|
||||
api_key: process.env.CLAUDE_API_KEY || '',
|
||||
model_name: 'claude-3-haiku-20240307',
|
||||
priority: 3,
|
||||
max_tokens: 4096,
|
||||
temperature: 0.7,
|
||||
timeout_ms: 60000,
|
||||
cost_per_1k_input_tokens: 0.00025,
|
||||
cost_per_1k_output_tokens: 0.00125,
|
||||
is_active: true,
|
||||
is_healthy: true,
|
||||
config: JSON.stringify({}),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.bulkDelete('llm_providers', null, {});
|
||||
},
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
// src/services/init.service.js
|
||||
// 초기 데이터 설정 서비스
|
||||
|
||||
const { User, LLMProvider } = require('../models');
|
||||
const logger = require('../config/logger.config');
|
||||
|
||||
/**
|
||||
* 초기 관리자 계정 생성
|
||||
*/
|
||||
async function createDefaultAdmin() {
|
||||
try {
|
||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@admin.com';
|
||||
const adminPassword = process.env.ADMIN_PASSWORD || 'Admin123!';
|
||||
|
||||
const existing = await User.findOne({ where: { email: adminEmail } });
|
||||
if (existing) {
|
||||
logger.info(`관리자 계정 이미 존재: ${adminEmail}`);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const admin = await User.create({
|
||||
email: adminEmail,
|
||||
password: adminPassword,
|
||||
name: '관리자',
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
plan: 'enterprise',
|
||||
monthlyTokenLimit: 10000000, // 1000만 토큰
|
||||
});
|
||||
|
||||
logger.info(`✅ 기본 관리자 계정 생성: ${adminEmail}`);
|
||||
return admin;
|
||||
} catch (error) {
|
||||
logger.error('관리자 계정 생성 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 LLM 프로바이더 생성
|
||||
*/
|
||||
async function createDefaultProviders() {
|
||||
try {
|
||||
const providers = [
|
||||
{
|
||||
name: 'gemini',
|
||||
displayName: 'Google Gemini',
|
||||
endpoint: null,
|
||||
apiKey: process.env.GEMINI_API_KEY || '',
|
||||
modelName: process.env.GEMINI_MODEL || 'gemini-2.0-flash',
|
||||
priority: 1,
|
||||
maxTokens: 8192,
|
||||
temperature: 0.7,
|
||||
timeoutMs: 60000,
|
||||
costPer1kInputTokens: 0.00025,
|
||||
costPer1kOutputTokens: 0.001,
|
||||
},
|
||||
{
|
||||
name: 'openai',
|
||||
displayName: 'OpenAI GPT',
|
||||
endpoint: 'https://api.openai.com/v1/chat/completions',
|
||||
apiKey: process.env.OPENAI_API_KEY || '',
|
||||
modelName: process.env.OPENAI_MODEL || 'gpt-4o-mini',
|
||||
priority: 2,
|
||||
maxTokens: 4096,
|
||||
temperature: 0.7,
|
||||
timeoutMs: 60000,
|
||||
costPer1kInputTokens: 0.00015,
|
||||
costPer1kOutputTokens: 0.0006,
|
||||
},
|
||||
{
|
||||
name: 'claude',
|
||||
displayName: 'Anthropic Claude',
|
||||
endpoint: 'https://api.anthropic.com/v1/messages',
|
||||
apiKey: process.env.CLAUDE_API_KEY || '',
|
||||
modelName: process.env.CLAUDE_MODEL || 'claude-3-haiku-20240307',
|
||||
priority: 3,
|
||||
maxTokens: 4096,
|
||||
temperature: 0.7,
|
||||
timeoutMs: 60000,
|
||||
costPer1kInputTokens: 0.00025,
|
||||
costPer1kOutputTokens: 0.00125,
|
||||
},
|
||||
];
|
||||
|
||||
for (const providerData of providers) {
|
||||
const existing = await LLMProvider.findOne({ where: { name: providerData.name } });
|
||||
if (existing) {
|
||||
// API 키가 환경변수에 설정되어 있고 DB에는 없으면 업데이트
|
||||
if (providerData.apiKey && !existing.apiKey) {
|
||||
existing.apiKey = providerData.apiKey;
|
||||
existing.modelName = providerData.modelName;
|
||||
await existing.save();
|
||||
logger.info(`LLM 프로바이더 API 키 업데이트: ${providerData.name}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
await LLMProvider.create({
|
||||
...providerData,
|
||||
isActive: true,
|
||||
isHealthy: !!providerData.apiKey, // API 키가 있으면 healthy
|
||||
});
|
||||
logger.info(`✅ LLM 프로바이더 생성: ${providerData.name} (${providerData.modelName})`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('LLM 프로바이더 생성 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기화 실행
|
||||
*/
|
||||
async function initialize() {
|
||||
logger.info('🔧 초기 데이터 설정 시작...');
|
||||
|
||||
await createDefaultAdmin();
|
||||
await createDefaultProviders();
|
||||
|
||||
logger.info('✅ 초기 데이터 설정 완료');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initialize,
|
||||
createDefaultAdmin,
|
||||
createDefaultProviders,
|
||||
};
|
||||
@@ -1,385 +0,0 @@
|
||||
// src/services/llm.service.js
|
||||
// LLM 서비스 - 멀티 프로바이더 지원
|
||||
|
||||
const axios = require('axios');
|
||||
const { LLMProvider } = require('../models');
|
||||
const logger = require('../config/logger.config');
|
||||
|
||||
class LLMService {
|
||||
constructor() {
|
||||
this.providers = [];
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스 초기화
|
||||
*/
|
||||
async initialize() {
|
||||
if (this.initialized) return;
|
||||
|
||||
try {
|
||||
await this.loadProviders();
|
||||
this.initialized = true;
|
||||
logger.info('✅ LLM 서비스 초기화 완료');
|
||||
} catch (error) {
|
||||
logger.error('❌ LLM 서비스 초기화 실패:', error);
|
||||
// 초기화 실패 시 기본 프로바이더 사용
|
||||
this.providers = this.getDefaultProviders();
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터베이스에서 프로바이더 로드
|
||||
*/
|
||||
async loadProviders() {
|
||||
try {
|
||||
const providers = await LLMProvider.getHealthyProviders();
|
||||
|
||||
if (providers.length === 0) {
|
||||
logger.warn('⚠️ 활성 프로바이더가 없습니다. 기본 프로바이더 사용');
|
||||
this.providers = this.getDefaultProviders();
|
||||
} else {
|
||||
this.providers = providers.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
endpoint: p.endpoint,
|
||||
apiKey: p.apiKey,
|
||||
modelName: p.modelName,
|
||||
priority: p.priority,
|
||||
maxTokens: p.maxTokens,
|
||||
temperature: p.temperature,
|
||||
timeoutMs: p.timeoutMs,
|
||||
costPer1kInputTokens: parseFloat(p.costPer1kInputTokens) || 0,
|
||||
costPer1kOutputTokens: parseFloat(p.costPer1kOutputTokens) || 0,
|
||||
isHealthy: p.isHealthy,
|
||||
config: p.config,
|
||||
}));
|
||||
}
|
||||
|
||||
logger.info(`📥 ${this.providers.length}개 프로바이더 로드됨`);
|
||||
} catch (error) {
|
||||
logger.error('프로바이더 로드 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 프로바이더 설정 (환경 변수 기반)
|
||||
*/
|
||||
getDefaultProviders() {
|
||||
const providers = [];
|
||||
|
||||
// Gemini
|
||||
if (process.env.GEMINI_API_KEY) {
|
||||
providers.push({
|
||||
id: 'default-gemini',
|
||||
name: 'gemini',
|
||||
apiKey: process.env.GEMINI_API_KEY,
|
||||
modelName: process.env.GEMINI_MODEL || 'gemini-2.0-flash',
|
||||
priority: 1,
|
||||
maxTokens: 8192,
|
||||
temperature: 0.7,
|
||||
timeoutMs: 60000,
|
||||
costPer1kInputTokens: 0.00025,
|
||||
costPer1kOutputTokens: 0.001,
|
||||
isHealthy: true,
|
||||
});
|
||||
}
|
||||
|
||||
// OpenAI
|
||||
if (process.env.OPENAI_API_KEY) {
|
||||
providers.push({
|
||||
id: 'default-openai',
|
||||
name: 'openai',
|
||||
endpoint: 'https://api.openai.com/v1/chat/completions',
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
modelName: process.env.OPENAI_MODEL || 'gpt-4o-mini',
|
||||
priority: 2,
|
||||
maxTokens: 4096,
|
||||
temperature: 0.7,
|
||||
timeoutMs: 60000,
|
||||
costPer1kInputTokens: 0.00015,
|
||||
costPer1kOutputTokens: 0.0006,
|
||||
isHealthy: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Claude
|
||||
if (process.env.CLAUDE_API_KEY) {
|
||||
providers.push({
|
||||
id: 'default-claude',
|
||||
name: 'claude',
|
||||
endpoint: 'https://api.anthropic.com/v1/messages',
|
||||
apiKey: process.env.CLAUDE_API_KEY,
|
||||
modelName: process.env.CLAUDE_MODEL || 'claude-3-haiku-20240307',
|
||||
priority: 3,
|
||||
maxTokens: 4096,
|
||||
temperature: 0.7,
|
||||
timeoutMs: 60000,
|
||||
costPer1kInputTokens: 0.00025,
|
||||
costPer1kOutputTokens: 0.00125,
|
||||
isHealthy: true,
|
||||
});
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 채팅 API 호출 (자동 fallback)
|
||||
*/
|
||||
async chat(params) {
|
||||
const {
|
||||
model,
|
||||
messages,
|
||||
temperature = 0.7,
|
||||
maxTokens = 4096,
|
||||
userId,
|
||||
apiKeyId,
|
||||
} = params;
|
||||
|
||||
// 초기화 확인
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
let lastError = null;
|
||||
|
||||
// 요청된 모델에 맞는 프로바이더 찾기
|
||||
const requestedProvider = this.providers.find(
|
||||
(p) => p.modelName === model || p.name === model
|
||||
);
|
||||
|
||||
// 우선순위 순으로 프로바이더 정렬
|
||||
const sortedProviders = requestedProvider
|
||||
? [requestedProvider, ...this.providers.filter((p) => p !== requestedProvider)]
|
||||
: this.providers;
|
||||
|
||||
// 프로바이더 순회 (fallback)
|
||||
for (const provider of sortedProviders) {
|
||||
if (!provider.isHealthy) {
|
||||
logger.warn(`⚠️ ${provider.name} 건강하지 않음, 건너뜀`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`🚀 ${provider.name} (${provider.modelName}) 시도 중...`);
|
||||
|
||||
const result = await this.callProvider(provider, {
|
||||
messages,
|
||||
maxTokens: maxTokens || provider.maxTokens,
|
||||
temperature: temperature || provider.temperature,
|
||||
});
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
// 비용 계산
|
||||
const cost = this.calculateCost(
|
||||
result.usage.promptTokens,
|
||||
result.usage.completionTokens,
|
||||
provider.costPer1kInputTokens,
|
||||
provider.costPer1kOutputTokens
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`✅ ${provider.name} 성공 (${responseTime}ms, ${result.usage.totalTokens} tokens)`
|
||||
);
|
||||
|
||||
return {
|
||||
text: result.text,
|
||||
provider: provider.name,
|
||||
providerId: provider.id,
|
||||
model: provider.modelName,
|
||||
usage: result.usage,
|
||||
responseTime,
|
||||
cost,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`❌ ${provider.name} 실패:`, error.message);
|
||||
lastError = error;
|
||||
|
||||
// 다음 프로바이더로 fallback
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 프로바이더 실패
|
||||
throw new Error(
|
||||
`모든 LLM 프로바이더가 실패했습니다: ${lastError?.message || '알 수 없는 오류'}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 프로바이더 호출
|
||||
*/
|
||||
async callProvider(provider, { messages, maxTokens, temperature }) {
|
||||
const timeout = provider.timeoutMs || 60000;
|
||||
|
||||
switch (provider.name) {
|
||||
case 'gemini':
|
||||
return this.callGemini(provider, { messages, maxTokens, temperature });
|
||||
case 'openai':
|
||||
return this.callOpenAI(provider, { messages, maxTokens, temperature, timeout });
|
||||
case 'claude':
|
||||
return this.callClaude(provider, { messages, maxTokens, temperature, timeout });
|
||||
default:
|
||||
throw new Error(`지원하지 않는 프로바이더: ${provider.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini API 호출
|
||||
*/
|
||||
async callGemini(provider, { messages, maxTokens, temperature }) {
|
||||
const { GoogleGenAI } = require('@google/genai');
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey: provider.apiKey });
|
||||
|
||||
// 메시지 변환 (OpenAI 형식 -> Gemini 형식)
|
||||
const contents = messages.map((msg) => ({
|
||||
role: msg.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text: msg.content }],
|
||||
}));
|
||||
|
||||
// system 메시지 처리
|
||||
const systemMessage = messages.find((m) => m.role === 'system');
|
||||
const systemInstruction = systemMessage ? systemMessage.content : undefined;
|
||||
|
||||
const config = {
|
||||
maxOutputTokens: maxTokens,
|
||||
temperature,
|
||||
};
|
||||
|
||||
const result = await ai.models.generateContent({
|
||||
model: provider.modelName,
|
||||
contents: contents.filter((c) => c.role !== 'system'),
|
||||
systemInstruction,
|
||||
config,
|
||||
});
|
||||
|
||||
// 응답 텍스트 추출
|
||||
let text = '';
|
||||
if (result.candidates?.[0]?.content?.parts) {
|
||||
text = result.candidates[0].content.parts
|
||||
.filter((p) => p.text)
|
||||
.map((p) => p.text)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
const usage = result.usageMetadata || {};
|
||||
const promptTokens = usage.promptTokenCount ?? 0;
|
||||
const completionTokens = usage.candidatesTokenCount ?? 0;
|
||||
|
||||
return {
|
||||
text,
|
||||
usage: {
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
totalTokens: promptTokens + completionTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI API 호출
|
||||
*/
|
||||
async callOpenAI(provider, { messages, maxTokens, temperature, timeout }) {
|
||||
const response = await axios.post(
|
||||
provider.endpoint,
|
||||
{
|
||||
model: provider.modelName,
|
||||
messages,
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
},
|
||||
{
|
||||
timeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
text: response.data.choices[0].message.content,
|
||||
usage: {
|
||||
promptTokens: response.data.usage.prompt_tokens,
|
||||
completionTokens: response.data.usage.completion_tokens,
|
||||
totalTokens: response.data.usage.total_tokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude API 호출
|
||||
*/
|
||||
async callClaude(provider, { messages, maxTokens, temperature, timeout }) {
|
||||
// system 메시지 분리
|
||||
const systemMessage = messages.find((m) => m.role === 'system');
|
||||
const otherMessages = messages.filter((m) => m.role !== 'system');
|
||||
|
||||
const response = await axios.post(
|
||||
provider.endpoint,
|
||||
{
|
||||
model: provider.modelName,
|
||||
messages: otherMessages,
|
||||
system: systemMessage?.content,
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
},
|
||||
{
|
||||
timeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': provider.apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
text: response.data.content[0].text,
|
||||
usage: {
|
||||
promptTokens: response.data.usage.input_tokens,
|
||||
completionTokens: response.data.usage.output_tokens,
|
||||
totalTokens:
|
||||
response.data.usage.input_tokens + response.data.usage.output_tokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 스트리밍 채팅 (제너레이터)
|
||||
*/
|
||||
async *chatStream(params) {
|
||||
// 현재는 간단한 구현 (전체 응답 후 청크로 분할)
|
||||
// 실제 스트리밍은 각 프로바이더의 스트리밍 API 사용 필요
|
||||
const result = await this.chat(params);
|
||||
|
||||
// 텍스트를 청크로 분할하여 전송
|
||||
const chunkSize = 10;
|
||||
for (let i = 0; i < result.text.length; i += chunkSize) {
|
||||
yield {
|
||||
text: result.text.slice(i, i + chunkSize),
|
||||
done: i + chunkSize >= result.text.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비용 계산
|
||||
*/
|
||||
calculateCost(promptTokens, completionTokens, inputCost, outputCost) {
|
||||
const inputTotal = (promptTokens / 1000) * inputCost;
|
||||
const outputTotal = (completionTokens / 1000) * outputCost;
|
||||
return parseFloat((inputTotal + outputTotal).toFixed(6));
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스
|
||||
const llmService = new LLMService();
|
||||
|
||||
module.exports = llmService;
|
||||
@@ -1,359 +0,0 @@
|
||||
// src/swagger/api-docs.js
|
||||
// Swagger API 문서 정의
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /auth/register:
|
||||
* post:
|
||||
* tags: [Auth]
|
||||
* summary: 회원가입
|
||||
* description: 새 계정을 생성합니다.
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [email, password, name]
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* example: user@example.com
|
||||
* password:
|
||||
* type: string
|
||||
* minLength: 8
|
||||
* example: Password123!
|
||||
* description: 8자 이상, 영문/숫자/특수문자 포함
|
||||
* name:
|
||||
* type: string
|
||||
* example: 홍길동
|
||||
* responses:
|
||||
* 201:
|
||||
* description: 회원가입 성공
|
||||
* 400:
|
||||
* description: 유효성 검사 실패
|
||||
* 409:
|
||||
* description: 이미 존재하는 이메일
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /auth/login:
|
||||
* post:
|
||||
* tags: [Auth]
|
||||
* summary: 로그인
|
||||
* description: 이메일과 비밀번호로 로그인합니다.
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [email, password]
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* example: admin@admin.com
|
||||
* password:
|
||||
* type: string
|
||||
* example: Admin123!
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 로그인 성공
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* user:
|
||||
* type: object
|
||||
* accessToken:
|
||||
* type: string
|
||||
* description: JWT 액세스 토큰
|
||||
* refreshToken:
|
||||
* type: string
|
||||
* description: JWT 리프레시 토큰
|
||||
* 401:
|
||||
* description: 인증 실패
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /chat/completions:
|
||||
* post:
|
||||
* tags: [Chat]
|
||||
* summary: 채팅 완성 (OpenAI 호환)
|
||||
* description: |
|
||||
* AI 모델에 메시지를 보내고 응답을 받습니다.
|
||||
* OpenAI API와 호환되는 형식입니다.
|
||||
*
|
||||
* **인증**: JWT 토큰 또는 API 키 (sk-xxx)
|
||||
* security:
|
||||
* - BearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ChatCompletionRequest'
|
||||
* examples:
|
||||
* simple:
|
||||
* summary: 간단한 질문
|
||||
* value:
|
||||
* model: gemini-2.0-flash
|
||||
* messages:
|
||||
* - role: user
|
||||
* content: 안녕하세요!
|
||||
* with_system:
|
||||
* summary: 시스템 프롬프트 포함
|
||||
* value:
|
||||
* model: gemini-2.0-flash
|
||||
* messages:
|
||||
* - role: system
|
||||
* content: 당신은 친절한 AI 어시스턴트입니다.
|
||||
* - role: user
|
||||
* content: 파이썬으로 Hello World 출력하는 코드 알려줘
|
||||
* temperature: 0.7
|
||||
* max_tokens: 1000
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 성공
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ChatCompletionResponse'
|
||||
* 401:
|
||||
* description: 인증 실패
|
||||
* 429:
|
||||
* description: 요청 한도 초과
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /models:
|
||||
* get:
|
||||
* tags: [Models]
|
||||
* summary: 모델 목록 조회
|
||||
* description: 사용 가능한 AI 모델 목록을 조회합니다.
|
||||
* security:
|
||||
* - BearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 성공
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* object:
|
||||
* type: string
|
||||
* example: list
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* example: gemini-2.0-flash
|
||||
* object:
|
||||
* type: string
|
||||
* example: model
|
||||
* owned_by:
|
||||
* type: string
|
||||
* example: google
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api-keys:
|
||||
* get:
|
||||
* tags: [API Keys]
|
||||
* summary: API 키 목록 조회
|
||||
* description: 발급받은 API 키 목록을 조회합니다.
|
||||
* security:
|
||||
* - BearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 성공
|
||||
* post:
|
||||
* tags: [API Keys]
|
||||
* summary: API 키 발급
|
||||
* description: 새 API 키를 발급받습니다. 키는 한 번만 표시됩니다.
|
||||
* security:
|
||||
* - BearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [name]
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* example: My API Key
|
||||
* description: API 키 이름
|
||||
* responses:
|
||||
* 201:
|
||||
* description: 발급 성공
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* key:
|
||||
* type: string
|
||||
* description: 발급된 API 키 (한 번만 표시)
|
||||
* example: sk-abc123def456...
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api-keys/{id}:
|
||||
* delete:
|
||||
* tags: [API Keys]
|
||||
* summary: API 키 폐기
|
||||
* description: API 키를 폐기합니다.
|
||||
* security:
|
||||
* - BearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: API 키 ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 폐기 성공
|
||||
* 404:
|
||||
* description: API 키를 찾을 수 없음
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /usage:
|
||||
* get:
|
||||
* tags: [Usage]
|
||||
* summary: 사용량 요약 조회
|
||||
* description: 오늘/이번 달 사용량 요약을 조회합니다.
|
||||
* security:
|
||||
* - BearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 성공
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* plan:
|
||||
* type: string
|
||||
* example: free
|
||||
* limit:
|
||||
* type: object
|
||||
* properties:
|
||||
* monthly:
|
||||
* type: integer
|
||||
* remaining:
|
||||
* type: integer
|
||||
* usage:
|
||||
* type: object
|
||||
* properties:
|
||||
* today:
|
||||
* type: object
|
||||
* monthly:
|
||||
* type: object
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /usage/logs:
|
||||
* get:
|
||||
* tags: [Usage]
|
||||
* summary: 사용 로그 조회
|
||||
* description: API 호출 로그를 조회합니다.
|
||||
* security:
|
||||
* - BearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 1
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 20
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 성공
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/users:
|
||||
* get:
|
||||
* tags: [Admin]
|
||||
* summary: 사용자 목록 조회 (관리자)
|
||||
* description: 모든 사용자 목록을 조회합니다. 관리자 권한 필요.
|
||||
* security:
|
||||
* - BearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 성공
|
||||
* 403:
|
||||
* description: 권한 없음
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/providers:
|
||||
* get:
|
||||
* tags: [Admin]
|
||||
* summary: LLM 프로바이더 목록 (관리자)
|
||||
* description: LLM 프로바이더 설정을 조회합니다. 관리자 권한 필요.
|
||||
* security:
|
||||
* - BearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 성공
|
||||
* 403:
|
||||
* description: 권한 없음
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/stats:
|
||||
* get:
|
||||
* tags: [Admin]
|
||||
* summary: 시스템 통계 (관리자)
|
||||
* description: 시스템 전체 통계를 조회합니다. 관리자 권한 필요.
|
||||
* security:
|
||||
* - BearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 성공
|
||||
* 403:
|
||||
* description: 권한 없음
|
||||
*/
|
||||
@@ -1,17 +0,0 @@
|
||||
|
||||
# ==================== 운영/작업 지원 위젯 데이터 소스 설정 ====================
|
||||
# 옵션: file | database | memory
|
||||
# - file: 파일 기반 (빠른 개발/테스트)
|
||||
# - database: PostgreSQL DB (실제 운영)
|
||||
# - memory: 메모리 목 데이터 (테스트)
|
||||
|
||||
TODO_DATA_SOURCE=file
|
||||
BOOKING_DATA_SOURCE=file
|
||||
MAINTENANCE_DATA_SOURCE=memory
|
||||
DOCUMENT_DATA_SOURCE=memory
|
||||
|
||||
|
||||
# OpenWeatherMap API 키 추가 (실시간 날씨)
|
||||
# https://openweathermap.org/api 에서 무료 가입 후 발급
|
||||
OPENWEATHER_API_KEY=your_openweathermap_api_key_here
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 🔑 공유 API 키 (팀 전체 사용)
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
#
|
||||
# ⚠️ 주의: 이 파일은 Git에 커밋됩니다!
|
||||
# 팀원들이 동일한 API 키를 사용합니다.
|
||||
#
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
# 한국은행 환율 API 키
|
||||
# 발급: https://www.bok.or.kr/portal/openapi/OpenApiGuide.do
|
||||
BOK_API_KEY=OXIGPQXH68NUKVKL5KT9
|
||||
|
||||
# 기상청 API Hub 키
|
||||
# 발급: https://apihub.kma.go.kr/
|
||||
KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA
|
||||
|
||||
# ITS 국가교통정보센터 API 키
|
||||
# 발급: https://www.its.go.kr/
|
||||
ITS_API_KEY=d6b9befec3114d648284674b8fddcc32
|
||||
|
||||
# 한국도로공사 OpenOASIS API 키
|
||||
# 발급: https://data.ex.co.kr/ (OpenOASIS 신청)
|
||||
EXWAY_API_KEY=7820214492
|
||||
|
||||
# ExchangeRate API 키 (백업용, 선택사항)
|
||||
# 발급: https://www.exchangerate-api.com/
|
||||
# EXCHANGERATE_API_KEY=your_exchangerate_api_key_here
|
||||
|
||||
# Kakao API 키 (Geocoding용, 선택사항)
|
||||
# 발급: https://developers.kakao.com/
|
||||
# KAKAO_API_KEY=your_kakao_api_key_here
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 📝 사용 방법
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
#
|
||||
# 1. 이 파일을 복사하여 .env 파일 생성:
|
||||
# $ cp .env.shared .env
|
||||
#
|
||||
# 2. 그대로 사용하면 됩니다!
|
||||
# (팀 전체가 동일한 키 사용)
|
||||
#
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
@@ -1,174 +0,0 @@
|
||||
|
||||
# 🔌 API 연동 가이드
|
||||
|
||||
## 📊 현재 상태
|
||||
|
||||
### ✅ 작동 중인 API
|
||||
|
||||
1. **기상청 특보 API** (완벽 작동!)
|
||||
- API 키: `ogdXr2e9T4iHV69nvV-IwA`
|
||||
- 상태: ✅ 14건 실시간 특보 수신 중
|
||||
- 제공 데이터: 대설/강풍/한파/태풍/폭염 특보
|
||||
|
||||
2. **한국은행 환율 API** (완벽 작동!)
|
||||
- API 키: `OXIGPQXH68NUKVKL5KT9`
|
||||
- 상태: ✅ 환율 위젯 작동 중
|
||||
|
||||
### ⚠️ 더미 데이터 사용 중
|
||||
|
||||
3. **교통사고 정보**
|
||||
- 한국도로공사 API: ❌ 서버 호출 차단
|
||||
- 현재 상태: 더미 데이터 (2건)
|
||||
|
||||
4. **도로공사 정보**
|
||||
- 한국도로공사 API: ❌ 서버 호출 차단
|
||||
- 현재 상태: 더미 데이터 (2건)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 실시간 교통정보 연동하기
|
||||
|
||||
### 📌 국토교통부 ITS API (추천!)
|
||||
|
||||
#### 1단계: API 신청
|
||||
1. https://www.data.go.kr/ 접속
|
||||
2. 검색: **"ITS 돌발정보"** 또는 **"실시간 교통정보"**
|
||||
3. **활용신청** 클릭
|
||||
4. **승인 대기 (1~2일)**
|
||||
|
||||
#### 2단계: API 키 추가
|
||||
승인 완료되면 `.env` 파일에 추가:
|
||||
|
||||
```env
|
||||
# 국토교통부 ITS API 키
|
||||
ITS_API_KEY=발급받은_API_키
|
||||
```
|
||||
|
||||
#### 3단계: 서버 재시작
|
||||
```bash
|
||||
docker restart pms-backend-mac
|
||||
```
|
||||
|
||||
#### 4단계: 확인
|
||||
- 로그에서 `✅ 국토교통부 ITS 교통사고 API 응답 수신 완료` 확인
|
||||
- 더미 데이터 대신 실제 데이터가 표시됨!
|
||||
|
||||
---
|
||||
|
||||
## 🔍 한국도로공사 API 문제
|
||||
|
||||
### 발급된 키
|
||||
```
|
||||
EXWAY_API_KEY=7820214492
|
||||
```
|
||||
|
||||
### 문제 상황
|
||||
- ❌ 서버/백엔드에서 호출 시: `Request Blocked` (400)
|
||||
- ❌ curl 명령어: `Request Blocked`
|
||||
- ❌ 모든 엔드포인트 차단됨
|
||||
|
||||
### 가능한 원인
|
||||
1. **브라우저에서만 접근 허용**
|
||||
- Referer 헤더 검증
|
||||
- User-Agent 검증
|
||||
|
||||
2. **IP 화이트리스트**
|
||||
- 특정 IP에서만 접근 가능
|
||||
- 서버 IP 등록 필요
|
||||
|
||||
3. **API 키 활성화 대기**
|
||||
- 발급 후 승인 대기 중
|
||||
- 몇 시간~1일 소요
|
||||
|
||||
### 해결 방법
|
||||
1. 한국도로공사 담당자 문의 (054-811-4533)
|
||||
2. 국토교통부 ITS API 사용 (더 안정적)
|
||||
|
||||
---
|
||||
|
||||
## 📝 코드 구조
|
||||
|
||||
### 다중 API 폴백 시스템
|
||||
```typescript
|
||||
// 1순위: 국토교통부 ITS API
|
||||
if (process.env.ITS_API_KEY) {
|
||||
try {
|
||||
// ITS API 호출
|
||||
return itsData;
|
||||
} catch {
|
||||
console.log('2순위 API로 전환');
|
||||
}
|
||||
}
|
||||
|
||||
// 2순위: 한국도로공사 API
|
||||
try {
|
||||
// 한국도로공사 API 호출
|
||||
return exwayData;
|
||||
} catch {
|
||||
console.log('더미 데이터 사용');
|
||||
}
|
||||
|
||||
// 3순위: 더미 데이터
|
||||
return dummyData;
|
||||
```
|
||||
|
||||
### 파일 위치
|
||||
- 서비스: `backend-node/src/services/riskAlertService.ts`
|
||||
- 컨트롤러: `backend-node/src/controllers/riskAlertController.ts`
|
||||
- 라우트: `backend-node/src/routes/riskAlertRoutes.ts`
|
||||
|
||||
---
|
||||
|
||||
## 💡 현재 대시보드 위젯 데이터
|
||||
|
||||
### 리스크/알림 위젯
|
||||
```
|
||||
✅ 날씨특보: 14건 (실제 기상청 데이터)
|
||||
⚠️ 교통사고: 2건 (더미 데이터)
|
||||
⚠️ 도로공사: 2건 (더미 데이터)
|
||||
─────────────────────────
|
||||
총 18건의 알림
|
||||
```
|
||||
|
||||
### 개선 후 (ITS API 연동 시)
|
||||
```
|
||||
✅ 날씨특보: 14건 (실제 기상청 데이터)
|
||||
✅ 교통사고: N건 (실제 ITS 데이터)
|
||||
✅ 도로공사: N건 (실제 ITS 데이터)
|
||||
─────────────────────────
|
||||
총 N건의 알림 (모두 실시간!)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 다음 단계
|
||||
|
||||
### 단기 (지금)
|
||||
- [x] 기상청 특보 API 연동 완료
|
||||
- [x] 한국은행 환율 API 연동 완료
|
||||
- [x] 다중 API 폴백 시스템 구축
|
||||
- [ ] 국토교통부 ITS API 신청
|
||||
|
||||
### 장기 (향후)
|
||||
- [ ] 서울시 TOPIS API 추가 (서울시 교통정보)
|
||||
- [ ] 경찰청 교통사고 정보 API (승인 필요)
|
||||
- [ ] 기상청 단기예보 API 추가
|
||||
|
||||
---
|
||||
|
||||
## 📞 문의
|
||||
|
||||
### 한국도로공사
|
||||
- 전화: 054-811-4533 (컨텐츠 문의)
|
||||
- 전화: 070-8656-8771 (시스템 장애)
|
||||
|
||||
### 공공데이터포털
|
||||
- 웹사이트: https://www.data.go.kr/
|
||||
- 고객센터: 1661-0423
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-10-14
|
||||
**작성자**: AI Assistant
|
||||
**상태**: ✅ 기상청 특보 작동 중, ITS API 연동 준비 완료
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
|
||||
# 🔑 API 키 현황 및 연동 상태
|
||||
|
||||
## ✅ 완벽 작동 중
|
||||
|
||||
### 1. 기상청 API Hub
|
||||
- **API 키**: `ogdXr2e9T4iHV69nvV-IwA`
|
||||
- **상태**: ✅ 14건 실시간 특보 수신 중
|
||||
- **제공 데이터**: 대설/강풍/한파/태풍/폭염 특보
|
||||
- **코드 위치**: `backend-node/src/services/riskAlertService.ts`
|
||||
|
||||
### 2. 한국은행 환율 API
|
||||
- **API 키**: `OXIGPQXH68NUKVKL5KT9`
|
||||
- **상태**: ✅ 환율 위젯 작동 중
|
||||
- **제공 데이터**: USD/EUR/JPY/CNY 환율
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 연동 대기 중
|
||||
|
||||
### 3. 한국도로공사 OpenOASIS API
|
||||
- **API 키**: `7820214492`
|
||||
- **상태**: ❌ 엔드포인트 URL 불명
|
||||
- **문제**:
|
||||
- 발급 이메일에 사용법 없음
|
||||
- 매뉴얼에 상세 정보 없음
|
||||
- 테스트한 URL 모두 실패
|
||||
|
||||
**해결 방법**:
|
||||
```
|
||||
📞 한국도로공사 고객센터 문의
|
||||
|
||||
컨텐츠 문의: 054-811-4533
|
||||
시스템 장애: 070-8656-8771
|
||||
|
||||
문의 내용:
|
||||
"OpenOASIS API 인증키(7820214492)를 발급받았는데
|
||||
사용 방법과 엔드포인트 URL을 알려주세요.
|
||||
- 돌발상황정보 API
|
||||
- 교통사고 정보
|
||||
- 도로공사 정보"
|
||||
```
|
||||
|
||||
### 4. 국토교통부 ITS API
|
||||
- **API 키**: `d6b9befec3114d648284674b8fddcc32`
|
||||
- **상태**: ❌ 엔드포인트 URL 불명
|
||||
- **승인 API**:
|
||||
- 교통소통정보
|
||||
- 돌발상황정보
|
||||
- CCTV 화상자료
|
||||
- 교통예측정보
|
||||
- 차량검지정보
|
||||
- 도로전광표지(VMS)
|
||||
- 주의운전구간
|
||||
- 가변형 속도제한표지(VSL)
|
||||
- 위험물질 운송차량 사고정보
|
||||
|
||||
**해결 방법**:
|
||||
```
|
||||
📞 ITS 국가교통정보센터 문의
|
||||
|
||||
전화: 1577-6782
|
||||
이메일: its@ex.co.kr
|
||||
|
||||
문의 내용:
|
||||
"ITS API 인증키(d6b9befec3114d648284674b8fddcc32)를
|
||||
발급받았는데 매뉴얼에 엔드포인트 URL이 없습니다.
|
||||
돌발상황정보 API의 정확한 URL과 파라미터를
|
||||
알려주세요."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 백엔드 연동 준비 완료
|
||||
|
||||
### 파일 위치
|
||||
- **서비스**: `backend-node/src/services/riskAlertService.ts`
|
||||
- **컨트롤러**: `backend-node/src/controllers/riskAlertController.ts`
|
||||
- **라우트**: `backend-node/src/routes/riskAlertRoutes.ts`
|
||||
|
||||
### 다중 API 폴백 시스템
|
||||
```typescript
|
||||
1순위: 국토교통부 ITS API (process.env.ITS_API_KEY)
|
||||
2순위: 한국도로공사 API (process.env.EXWAY_API_KEY)
|
||||
3순위: 더미 데이터 (현실적인 예시)
|
||||
```
|
||||
|
||||
### 연동 방법
|
||||
```bash
|
||||
# .env 파일에 추가
|
||||
ITS_API_KEY=d6b9befec3114d648284674b8fddcc32
|
||||
EXWAY_API_KEY=7820214492
|
||||
|
||||
# 백엔드 재시작
|
||||
docker restart pms-backend-mac
|
||||
|
||||
# 로그 확인
|
||||
docker logs pms-backend-mac --tail 50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 현재 리스크/알림 시스템
|
||||
|
||||
```
|
||||
✅ 기상특보: 14건 (실시간 기상청 데이터)
|
||||
⚠️ 교통사고: 2건 (더미 데이터)
|
||||
⚠️ 도로공사: 2건 (더미 데이터)
|
||||
────────────────────────────
|
||||
총 18건의 알림
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 다음 단계
|
||||
|
||||
### 단기 (지금)
|
||||
- [x] 기상청 특보 API 연동 완료
|
||||
- [x] 한국은행 환율 API 연동 완료
|
||||
- [x] ITS/한국도로공사 API 키 발급 완료
|
||||
- [x] 다중 API 폴백 시스템 구축
|
||||
- [ ] **API 엔드포인트 URL 확인 (고객센터 문의)**
|
||||
|
||||
### 중기 (API URL 확인 후)
|
||||
- [ ] ITS API 연동 (즉시 가능)
|
||||
- [ ] 한국도로공사 API 연동 (즉시 가능)
|
||||
- [ ] 실시간 교통사고 데이터 표시
|
||||
- [ ] 실시간 도로공사 데이터 표시
|
||||
|
||||
### 장기 (추가 기능)
|
||||
- [ ] 서울시 TOPIS API 추가
|
||||
- [ ] CCTV 화상 자료 연동
|
||||
- [ ] 도로전광표지(VMS) 정보
|
||||
- [ ] 교통예측정보
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-10-14
|
||||
**상태**: 기상청 특보 작동 중, 교통정보 API URL 확인 필요
|
||||
|
||||
@@ -1,418 +0,0 @@
|
||||
# Phase 1: Raw Query 기반 구조 사용 가이드
|
||||
|
||||
## 📋 개요
|
||||
|
||||
Phase 1에서 구현한 Raw Query 기반 데이터베이스 아키텍처 사용 방법입니다.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 구현된 모듈
|
||||
|
||||
### 1. **DatabaseManager** (`src/database/db.ts`)
|
||||
|
||||
PostgreSQL 연결 풀 기반 핵심 모듈
|
||||
|
||||
**주요 함수:**
|
||||
- `query<T>(sql, params)` - 기본 쿼리 실행
|
||||
- `queryOne<T>(sql, params)` - 단일 행 조회
|
||||
- `transaction(callback)` - 트랜잭션 실행
|
||||
- `getPool()` - 연결 풀 가져오기
|
||||
- `getPoolStatus()` - 연결 풀 상태 확인
|
||||
|
||||
### 2. **QueryBuilder** (`src/utils/queryBuilder.ts`)
|
||||
|
||||
동적 쿼리 생성 유틸리티
|
||||
|
||||
**주요 메서드:**
|
||||
- `QueryBuilder.select(tableName, options)` - SELECT 쿼리
|
||||
- `QueryBuilder.insert(tableName, data, options)` - INSERT 쿼리
|
||||
- `QueryBuilder.update(tableName, data, where, options)` - UPDATE 쿼리
|
||||
- `QueryBuilder.delete(tableName, where, options)` - DELETE 쿼리
|
||||
- `QueryBuilder.count(tableName, where)` - COUNT 쿼리
|
||||
- `QueryBuilder.exists(tableName, where)` - EXISTS 쿼리
|
||||
|
||||
### 3. **DatabaseValidator** (`src/utils/databaseValidator.ts`)
|
||||
|
||||
SQL Injection 방지 및 입력 검증
|
||||
|
||||
**주요 메서드:**
|
||||
- `validateTableName(tableName)` - 테이블명 검증
|
||||
- `validateColumnName(columnName)` - 컬럼명 검증
|
||||
- `validateWhereClause(where)` - WHERE 조건 검증
|
||||
- `sanitizeInput(input)` - 입력 값 Sanitize
|
||||
|
||||
### 4. **타입 정의** (`src/types/database.ts`)
|
||||
|
||||
TypeScript 타입 안전성 보장
|
||||
|
||||
---
|
||||
|
||||
## 🚀 사용 예제
|
||||
|
||||
### 1. 기본 쿼리 실행
|
||||
|
||||
```typescript
|
||||
import { query, queryOne } from '../database/db';
|
||||
|
||||
// 여러 행 조회
|
||||
const users = await query<User>(
|
||||
'SELECT * FROM users WHERE status = $1',
|
||||
['active']
|
||||
);
|
||||
|
||||
// 단일 행 조회
|
||||
const user = await queryOne<User>(
|
||||
'SELECT * FROM users WHERE user_id = $1',
|
||||
['user123']
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('사용자를 찾을 수 없습니다.');
|
||||
}
|
||||
```
|
||||
|
||||
### 2. QueryBuilder 사용
|
||||
|
||||
#### SELECT
|
||||
|
||||
```typescript
|
||||
import { query } from '../database/db';
|
||||
import { QueryBuilder } from '../utils/queryBuilder';
|
||||
|
||||
// 기본 SELECT
|
||||
const { query: sql, params } = QueryBuilder.select('users', {
|
||||
where: { status: 'active' },
|
||||
orderBy: 'created_at DESC',
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const users = await query(sql, params);
|
||||
|
||||
// 복잡한 SELECT (JOIN, WHERE, ORDER BY)
|
||||
const { query: sql2, params: params2 } = QueryBuilder.select('users', {
|
||||
columns: ['users.user_id', 'users.username', 'departments.dept_name'],
|
||||
joins: [
|
||||
{
|
||||
type: 'LEFT',
|
||||
table: 'departments',
|
||||
on: 'users.dept_id = departments.dept_id',
|
||||
},
|
||||
],
|
||||
where: { 'users.status': 'active' },
|
||||
orderBy: ['users.created_at DESC', 'users.username ASC'],
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const result = await query(sql2, params2);
|
||||
```
|
||||
|
||||
#### INSERT
|
||||
|
||||
```typescript
|
||||
import { query } from '../database/db';
|
||||
import { QueryBuilder } from '../utils/queryBuilder';
|
||||
|
||||
// 기본 INSERT
|
||||
const { query: sql, params } = QueryBuilder.insert(
|
||||
'users',
|
||||
{
|
||||
user_id: 'new_user',
|
||||
username: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
returning: ['id', 'user_id'],
|
||||
}
|
||||
);
|
||||
|
||||
const [newUser] = await query(sql, params);
|
||||
console.log('생성된 사용자 ID:', newUser.id);
|
||||
|
||||
// UPSERT (INSERT ... ON CONFLICT)
|
||||
const { query: sql2, params: params2 } = QueryBuilder.insert(
|
||||
'users',
|
||||
{
|
||||
user_id: 'user123',
|
||||
username: 'Jane',
|
||||
email: 'jane@example.com',
|
||||
},
|
||||
{
|
||||
onConflict: {
|
||||
columns: ['user_id'],
|
||||
action: 'DO UPDATE',
|
||||
updateSet: ['username', 'email'],
|
||||
},
|
||||
returning: ['*'],
|
||||
}
|
||||
);
|
||||
|
||||
const [upsertedUser] = await query(sql2, params2);
|
||||
```
|
||||
|
||||
#### UPDATE
|
||||
|
||||
```typescript
|
||||
import { query } from '../database/db';
|
||||
import { QueryBuilder } from '../utils/queryBuilder';
|
||||
|
||||
const { query: sql, params } = QueryBuilder.update(
|
||||
'users',
|
||||
{
|
||||
username: 'Updated Name',
|
||||
email: 'updated@example.com',
|
||||
updated_at: new Date(),
|
||||
},
|
||||
{
|
||||
user_id: 'user123',
|
||||
},
|
||||
{
|
||||
returning: ['*'],
|
||||
}
|
||||
);
|
||||
|
||||
const [updatedUser] = await query(sql, params);
|
||||
```
|
||||
|
||||
#### DELETE
|
||||
|
||||
```typescript
|
||||
import { query } from '../database/db';
|
||||
import { QueryBuilder } from '../utils/queryBuilder';
|
||||
|
||||
const { query: sql, params } = QueryBuilder.delete(
|
||||
'users',
|
||||
{
|
||||
user_id: 'user_to_delete',
|
||||
},
|
||||
{
|
||||
returning: ['user_id', 'username'],
|
||||
}
|
||||
);
|
||||
|
||||
const [deletedUser] = await query(sql, params);
|
||||
console.log('삭제된 사용자:', deletedUser.username);
|
||||
```
|
||||
|
||||
### 3. 트랜잭션 사용
|
||||
|
||||
```typescript
|
||||
import { transaction } from '../database/db';
|
||||
|
||||
// 복잡한 트랜잭션 처리
|
||||
const result = await transaction(async (client) => {
|
||||
// 1. 사용자 생성
|
||||
const userResult = await client.query(
|
||||
'INSERT INTO users (user_id, username, email) VALUES ($1, $2, $3) RETURNING id',
|
||||
['new_user', 'John', 'john@example.com']
|
||||
);
|
||||
|
||||
const userId = userResult.rows[0].id;
|
||||
|
||||
// 2. 역할 할당
|
||||
await client.query(
|
||||
'INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)',
|
||||
[userId, 'admin']
|
||||
);
|
||||
|
||||
// 3. 로그 생성
|
||||
await client.query(
|
||||
'INSERT INTO audit_logs (action, user_id, details) VALUES ($1, $2, $3)',
|
||||
['USER_CREATED', userId, JSON.stringify({ username: 'John' })]
|
||||
);
|
||||
|
||||
return { success: true, userId };
|
||||
});
|
||||
|
||||
console.log('트랜잭션 완료:', result);
|
||||
```
|
||||
|
||||
### 4. JSON 필드 쿼리 (JSONB)
|
||||
|
||||
```typescript
|
||||
import { query } from '../database/db';
|
||||
import { QueryBuilder } from '../utils/queryBuilder';
|
||||
|
||||
// JSON 필드 쿼리 (config->>'type' = 'form')
|
||||
const { query: sql, params } = QueryBuilder.select('screen_management', {
|
||||
columns: ['*'],
|
||||
where: {
|
||||
company_code: 'COMPANY_001',
|
||||
"config->>'type'": 'form',
|
||||
},
|
||||
});
|
||||
|
||||
const screens = await query(sql, params);
|
||||
```
|
||||
|
||||
### 5. 동적 테이블 쿼리
|
||||
|
||||
```typescript
|
||||
import { query } from '../database/db';
|
||||
import { DatabaseValidator } from '../utils/databaseValidator';
|
||||
|
||||
async function queryDynamicTable(tableName: string, filters: Record<string, any>) {
|
||||
// 테이블명 검증 (SQL Injection 방지)
|
||||
if (!DatabaseValidator.validateTableName(tableName)) {
|
||||
throw new Error('유효하지 않은 테이블명입니다.');
|
||||
}
|
||||
|
||||
// WHERE 조건 검증
|
||||
if (!DatabaseValidator.validateWhereClause(filters)) {
|
||||
throw new Error('유효하지 않은 WHERE 조건입니다.');
|
||||
}
|
||||
|
||||
const { query: sql, params } = QueryBuilder.select(tableName, {
|
||||
where: filters,
|
||||
});
|
||||
|
||||
return await query(sql, params);
|
||||
}
|
||||
|
||||
// 사용 예
|
||||
const data = await queryDynamicTable('company_data_001', {
|
||||
status: 'active',
|
||||
region: 'Seoul',
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 보안 고려사항
|
||||
|
||||
### 1. **항상 Parameterized Query 사용**
|
||||
|
||||
```typescript
|
||||
// ❌ 위험: SQL Injection 취약
|
||||
const userId = req.params.userId;
|
||||
const sql = `SELECT * FROM users WHERE user_id = '${userId}'`;
|
||||
const users = await query(sql);
|
||||
|
||||
// ✅ 안전: Parameterized Query
|
||||
const userId = req.params.userId;
|
||||
const users = await query('SELECT * FROM users WHERE user_id = $1', [userId]);
|
||||
```
|
||||
|
||||
### 2. **식별자 검증**
|
||||
|
||||
```typescript
|
||||
import { DatabaseValidator } from '../utils/databaseValidator';
|
||||
|
||||
// 테이블명/컬럼명 검증
|
||||
if (!DatabaseValidator.validateTableName(tableName)) {
|
||||
throw new Error('유효하지 않은 테이블명입니다.');
|
||||
}
|
||||
|
||||
if (!DatabaseValidator.validateColumnName(columnName)) {
|
||||
throw new Error('유효하지 않은 컬럼명입니다.');
|
||||
}
|
||||
```
|
||||
|
||||
### 3. **입력 값 Sanitize**
|
||||
|
||||
```typescript
|
||||
import { DatabaseValidator } from '../utils/databaseValidator';
|
||||
|
||||
const sanitizedData = DatabaseValidator.sanitizeInput(userInput);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 성능 최적화 팁
|
||||
|
||||
### 1. **연결 풀 모니터링**
|
||||
|
||||
```typescript
|
||||
import { getPoolStatus } from '../database/db';
|
||||
|
||||
const status = getPoolStatus();
|
||||
console.log('연결 풀 상태:', {
|
||||
total: status.totalCount,
|
||||
idle: status.idleCount,
|
||||
waiting: status.waitingCount,
|
||||
});
|
||||
```
|
||||
|
||||
### 2. **배치 INSERT**
|
||||
|
||||
```typescript
|
||||
import { transaction } from '../database/db';
|
||||
|
||||
// 대량 데이터 삽입 시 트랜잭션 사용
|
||||
await transaction(async (client) => {
|
||||
for (const item of largeDataset) {
|
||||
await client.query('INSERT INTO items (name, value) VALUES ($1, $2)', [
|
||||
item.name,
|
||||
item.value,
|
||||
]);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 3. **인덱스 활용 쿼리**
|
||||
|
||||
```typescript
|
||||
// WHERE 절에 인덱스 컬럼 사용
|
||||
const { query: sql, params } = QueryBuilder.select('users', {
|
||||
where: {
|
||||
user_id: 'user123', // 인덱스 컬럼
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 실행
|
||||
|
||||
```bash
|
||||
# 테스트 실행
|
||||
npm test -- database.test.ts
|
||||
|
||||
# 특정 테스트만 실행
|
||||
npm test -- database.test.ts -t "QueryBuilder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 에러 핸들링
|
||||
|
||||
```typescript
|
||||
import { query } from '../database/db';
|
||||
|
||||
try {
|
||||
const users = await query('SELECT * FROM users WHERE status = $1', ['active']);
|
||||
return users;
|
||||
} catch (error: any) {
|
||||
console.error('쿼리 실행 실패:', error.message);
|
||||
|
||||
// PostgreSQL 에러 코드 확인
|
||||
if (error.code === '23505') {
|
||||
throw new Error('중복된 값이 존재합니다.');
|
||||
}
|
||||
|
||||
if (error.code === '23503') {
|
||||
throw new Error('외래 키 제약 조건 위반입니다.');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 다음 단계 (Phase 2)
|
||||
|
||||
Phase 1 기반 구조가 완성되었으므로, Phase 2에서는:
|
||||
|
||||
1. **screenManagementService.ts** 전환 (46개 호출)
|
||||
2. **tableManagementService.ts** 전환 (35개 호출)
|
||||
3. **dataflowService.ts** 전환 (31개 호출)
|
||||
|
||||
등 핵심 서비스를 Raw Query로 전환합니다.
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-09-30
|
||||
**버전**: 1.0.0
|
||||
**담당**: Backend Development Team
|
||||
@@ -1,208 +0,0 @@
|
||||
re# PLM System Backend - Node.js + TypeScript
|
||||
|
||||
Java Spring Boot에서 Node.js + TypeScript로 리팩토링된 PLM 시스템 백엔드입니다.
|
||||
|
||||
## 🚀 기술 스택
|
||||
|
||||
- **Runtime**: Node.js ^20.10.0
|
||||
- **Framework**: Express ^4.18.2
|
||||
- **Language**: TypeScript ^5.3.3
|
||||
- **Database**: PostgreSQL ^8.11.3 (Raw Query with `pg`)
|
||||
- **Authentication**: JWT + Passport
|
||||
- **Testing**: Jest + Supertest
|
||||
|
||||
## 📋 프로젝트 구조
|
||||
|
||||
```
|
||||
backend-node/
|
||||
├── src/
|
||||
│ ├── database/ # 데이터베이스 유틸리티
|
||||
│ │ ├── db.ts # PostgreSQL Raw Query 헬퍼
|
||||
│ │ └── ...
|
||||
│ ├── controllers/ # HTTP 요청 처리
|
||||
│ ├── services/ # 비즈니스 로직
|
||||
│ ├── middleware/ # Express 미들웨어
|
||||
│ │ └── errorHandler.ts
|
||||
│ ├── utils/ # 유틸리티 함수
|
||||
│ │ └── logger.ts
|
||||
│ ├── types/ # TypeScript 타입 정의
|
||||
│ │ └── common.ts
|
||||
│ ├── validators/ # 입력 검증 스키마
|
||||
│ └── app.ts # 애플리케이션 진입점
|
||||
├── logs/ # 로그 파일
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 🛠️ 설치 및 실행
|
||||
|
||||
### 1. 의존성 설치
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. 환경 변수 설정
|
||||
|
||||
`.env` 파일을 생성하고 다음 내용을 추가하세요:
|
||||
|
||||
```env
|
||||
DATABASE_URL="postgresql://postgres:ph0909!!@39.117.244.52:11132/plm"
|
||||
JWT_SECRET="your-super-secret-jwt-key-change-in-production"
|
||||
JWT_EXPIRES_IN="24h"
|
||||
PORT=8080
|
||||
NODE_ENV=development
|
||||
```
|
||||
|
||||
### 3. 개발 서버 실행
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 5. 프로덕션 빌드
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## 📊 데이터베이스 스키마
|
||||
|
||||
PostgreSQL 데이터베이스를 직접 Raw Query로 사용합니다.
|
||||
|
||||
### 핵심 테이블
|
||||
|
||||
- `user_info` - 사용자 정보
|
||||
- `dept_info` - 부서 정보
|
||||
- `menu_info` - 메뉴 정보
|
||||
- `comm_code` - 공통 코드
|
||||
- `multi_lang_key_master` - 다국어 키 마스터
|
||||
- `multi_lang_text` - 다국어 텍스트
|
||||
|
||||
자세한 스키마 정보는 `docs/Database_Schema_Collection.md`를 참조하세요.
|
||||
|
||||
## 🔐 인증 시스템
|
||||
|
||||
JWT 기반 인증 시스템을 구현했습니다:
|
||||
|
||||
- Access Token (24시간)
|
||||
- Refresh Token (7일)
|
||||
- 토큰 자동 갱신
|
||||
|
||||
## 📝 API 문서
|
||||
|
||||
### 헬스 체크
|
||||
|
||||
```
|
||||
GET http://localhost:8080/health
|
||||
```
|
||||
|
||||
### 사용자 관리 (예정)
|
||||
|
||||
```
|
||||
GET http://localhost:8080/api/users # 사용자 목록 조회
|
||||
GET http://localhost:8080/api/users/:id # 특정 사용자 조회
|
||||
POST http://localhost:8080/api/users # 사용자 생성
|
||||
PUT http://localhost:8080/api/users/:id # 사용자 수정
|
||||
DELETE http://localhost:8080/api/users/:id # 사용자 삭제
|
||||
```
|
||||
|
||||
### 메뉴 관리 (예정)
|
||||
|
||||
```
|
||||
GET http://localhost:8080/api/menus # 메뉴 목록 조회
|
||||
POST http://localhost:8080/api/menus # 메뉴 생성
|
||||
PUT http://localhost:8080/api/menus/:id # 메뉴 수정
|
||||
DELETE http://localhost:8080/api/menus/:id # 메뉴 삭제
|
||||
```
|
||||
|
||||
## 🧪 테스트
|
||||
|
||||
```bash
|
||||
# 전체 테스트 실행
|
||||
npm test
|
||||
|
||||
# 테스트 감시 모드
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
## 📦 스크립트
|
||||
|
||||
- `npm run dev` - 개발 서버 실행 (nodemon)
|
||||
- `npm run build` - TypeScript 컴파일
|
||||
- `npm start` - 프로덕션 서버 실행
|
||||
- `npm test` - 테스트 실행
|
||||
- `npm run lint` - ESLint 검사
|
||||
- `npm run format` - Prettier 포맷팅
|
||||
|
||||
## 🔧 개발 가이드
|
||||
|
||||
### 새로운 API 추가
|
||||
|
||||
1. `src/controllers/`에 컨트롤러 생성
|
||||
2. `src/services/`에 서비스 로직 생성
|
||||
3. `src/types/`에 타입 정의 추가
|
||||
4. `src/validators/`에 검증 스키마 추가
|
||||
5. `src/app.ts`에 라우터 등록
|
||||
|
||||
### 데이터베이스 스키마 변경
|
||||
|
||||
1. SQL 마이그레이션 파일 작성 (`db/` 디렉토리)
|
||||
2. PostgreSQL에서 직접 실행
|
||||
3. 필요 시 TypeScript 타입 정의 업데이트 (`src/types/`)
|
||||
|
||||
## 📋 마이그레이션 체크리스트
|
||||
|
||||
### ✅ Phase 1: 기반 구축 (완료)
|
||||
|
||||
- [x] Node.js + TypeScript 프로젝트 설정
|
||||
- [x] 기존 데이터베이스 스키마 분석
|
||||
- [x] PostgreSQL Raw Query 시스템 구축
|
||||
- [x] 기본 인증 시스템 구현
|
||||
- [x] 에러 처리 및 로깅 설정
|
||||
|
||||
### 🔄 Phase 2: 핵심 API 개발 (진행 중)
|
||||
|
||||
- [ ] 사용자 관리 API (`user_info` 테이블 기반)
|
||||
- [ ] 부서 관리 API (`dept_info` 테이블 기반)
|
||||
- [ ] 메뉴 관리 API (`menu_info` 테이블 기반)
|
||||
- [ ] 권한 관리 API (`authority_master`, `rel_menu_auth` 테이블 기반)
|
||||
- [ ] 다국어 관리 API (`multi_lang_key_master`, `multi_lang_text` 테이블 기반)
|
||||
- [ ] 공통 코드 관리 API (`comm_code` 테이블 기반)
|
||||
|
||||
### ⏳ Phase 3: 비즈니스 로직 API (예정)
|
||||
|
||||
- [ ] 회사 관리 API (`company_mng` 테이블 기반)
|
||||
- [ ] 계약 관리 API (`contract_mgmt` 테이블 기반)
|
||||
- [ ] 주문 관리 API (`order_mgmt` 테이블 기반)
|
||||
- [ ] 재고 관리 API (`inventory_mgmt` 테이블 기반)
|
||||
- [ ] 부품 관리 API (`part_mgmt` 테이블 기반)
|
||||
|
||||
## 🚀 배포
|
||||
|
||||
### Docker 배포
|
||||
|
||||
```bash
|
||||
# Docker 이미지 빌드
|
||||
docker build -t pms-backend-node .
|
||||
|
||||
# 컨테이너 실행
|
||||
docker run -p 8080:8080 pms-backend-node
|
||||
```
|
||||
|
||||
### 환경별 설정
|
||||
|
||||
- **Development**: `NODE_ENV=development`
|
||||
- **Production**: `NODE_ENV=production`
|
||||
- **Test**: `NODE_ENV=test`
|
||||
|
||||
## 📞 지원
|
||||
|
||||
프로젝트 관련 문의사항이 있으시면 개발팀에 연락해주세요.
|
||||
|
||||
---
|
||||
|
||||
**버전**: 1.0.0
|
||||
**마지막 업데이트**: 2024년 12월
|
||||
@@ -1,87 +0,0 @@
|
||||
# 🔑 API 키 설정 가이드
|
||||
|
||||
## 빠른 시작 (신규 팀원용)
|
||||
|
||||
### 1. API 키 파일 복사
|
||||
```bash
|
||||
cd backend-node
|
||||
cp .env.shared .env
|
||||
```
|
||||
|
||||
### 2. 끝!
|
||||
- `.env.shared` 파일에 **팀 공유 API 키**가 이미 들어있습니다
|
||||
- 그대로 복사해서 사용하면 됩니다
|
||||
- 추가 발급 필요 없음!
|
||||
|
||||
---
|
||||
|
||||
## 📋 포함된 API 키
|
||||
|
||||
### ✅ 한국은행 환율 API
|
||||
- 용도: 환율 정보 조회
|
||||
- 키: `OXIGPQXH68NUKVKL5KT9`
|
||||
|
||||
### ✅ 기상청 API Hub
|
||||
- 용도: 날씨특보, 기상정보
|
||||
- 키: `ogdXr2e9T4iHV69nvV-IwA`
|
||||
|
||||
### ✅ ITS 국가교통정보센터
|
||||
- 용도: 교통사고, 도로공사 정보
|
||||
- 키: `d6b9befec3114d648284674b8fddcc32`
|
||||
|
||||
### ✅ 한국도로공사 OpenOASIS
|
||||
- 용도: 고속도로 교통정보
|
||||
- 키: `7820214492`
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
### Git 관리
|
||||
```bash
|
||||
✅ .env.shared → Git에 커밋됨 (팀 공유용)
|
||||
❌ .env → Git에 커밋 안 됨 (개인 설정)
|
||||
```
|
||||
|
||||
### 보안
|
||||
- **팀 내부 프로젝트**이므로 키 공유가 안전합니다
|
||||
- 외부 공개 프로젝트라면 각자 발급받아야 합니다
|
||||
|
||||
---
|
||||
|
||||
## 🚀 서버 시작
|
||||
|
||||
```bash
|
||||
# 1. API 키 설정 (최초 1회만)
|
||||
cp .env.shared .env
|
||||
|
||||
# 2. 서버 시작
|
||||
npm run dev
|
||||
|
||||
# 또는 Docker
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 트러블슈팅
|
||||
|
||||
### `.env` 파일이 없다는 오류
|
||||
```bash
|
||||
# 해결: .env.shared를 복사
|
||||
cp .env.shared .env
|
||||
```
|
||||
|
||||
### API 호출이 실패함
|
||||
```bash
|
||||
# 1. .env 파일 확인
|
||||
cat .env
|
||||
|
||||
# 2. API 키가 제대로 복사되었는지 확인
|
||||
# 3. 서버 재시작
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**팀원 여러분, `.env.shared`를 복사해서 사용하세요!** 👍
|
||||
@@ -1,35 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "773568c7-0fc8-403d-ace2-01a11fae7189",
|
||||
"customerName": "김철수",
|
||||
"customerPhone": "010-1234-5678",
|
||||
"pickupLocation": "서울시 강남구 역삼동 123",
|
||||
"dropoffLocation": "경기도 성남시 분당구 정자동 456",
|
||||
"scheduledTime": "2025-10-14T10:03:32.556Z",
|
||||
"vehicleType": "truck",
|
||||
"cargoType": "전자제품",
|
||||
"weight": 500,
|
||||
"status": "accepted",
|
||||
"priority": "urgent",
|
||||
"createdAt": "2025-10-14T08:03:32.556Z",
|
||||
"updatedAt": "2025-10-14T08:06:45.073Z",
|
||||
"estimatedCost": 150000,
|
||||
"acceptedAt": "2025-10-14T08:06:45.073Z"
|
||||
},
|
||||
{
|
||||
"id": "0751b297-18df-42c0-871c-85cded1f6dae",
|
||||
"customerName": "이영희",
|
||||
"customerPhone": "010-9876-5432",
|
||||
"pickupLocation": "서울시 송파구 잠실동 789",
|
||||
"dropoffLocation": "인천시 남동구 구월동 321",
|
||||
"scheduledTime": "2025-10-14T12:03:32.556Z",
|
||||
"vehicleType": "van",
|
||||
"cargoType": "가구",
|
||||
"weight": 300,
|
||||
"status": "pending",
|
||||
"priority": "normal",
|
||||
"createdAt": "2025-10-14T07:53:32.556Z",
|
||||
"updatedAt": "2025-10-14T07:53:32.556Z",
|
||||
"estimatedCost": 80000
|
||||
}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
@@ -1,80 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "58d2b26f-5197-4df1-b5d4-724a72ee1d05",
|
||||
"title": "연동되어주려무니",
|
||||
"description": "ㅁㄴㅇㄹ",
|
||||
"priority": "normal",
|
||||
"status": "in_progress",
|
||||
"assignedTo": "",
|
||||
"dueDate": "2025-10-21T15:21",
|
||||
"createdAt": "2025-10-20T06:21:19.817Z",
|
||||
"updatedAt": "2025-10-20T09:00:26.948Z",
|
||||
"isUrgent": false,
|
||||
"order": 3
|
||||
},
|
||||
{
|
||||
"id": "c8292b4d-bb45-487c-aa29-55b78580b837",
|
||||
"title": "오늘의 힐일",
|
||||
"description": "이거 데이터베이스랑 연결하기",
|
||||
"priority": "normal",
|
||||
"status": "pending",
|
||||
"assignedTo": "",
|
||||
"dueDate": "2025-10-23T14:04",
|
||||
"createdAt": "2025-10-23T05:04:50.249Z",
|
||||
"updatedAt": "2025-10-23T05:04:50.249Z",
|
||||
"isUrgent": false,
|
||||
"order": 4
|
||||
},
|
||||
{
|
||||
"id": "2c7f90a3-947c-4693-8525-7a2a707172c0",
|
||||
"title": "테스트용 일정",
|
||||
"description": "ㅁㄴㅇㄹ",
|
||||
"priority": "low",
|
||||
"status": "pending",
|
||||
"assignedTo": "",
|
||||
"dueDate": "2025-10-16T18:16",
|
||||
"createdAt": "2025-10-23T05:13:14.076Z",
|
||||
"updatedAt": "2025-10-23T05:13:14.076Z",
|
||||
"isUrgent": false,
|
||||
"order": 5
|
||||
},
|
||||
{
|
||||
"id": "499feff6-92c7-45a9-91fa-ca727edf90f2",
|
||||
"title": "ㅁSdf",
|
||||
"description": "asdfsdfs",
|
||||
"priority": "normal",
|
||||
"status": "pending",
|
||||
"assignedTo": "",
|
||||
"dueDate": "",
|
||||
"createdAt": "2025-10-23T05:15:38.430Z",
|
||||
"updatedAt": "2025-10-23T05:15:38.430Z",
|
||||
"isUrgent": false,
|
||||
"order": 6
|
||||
},
|
||||
{
|
||||
"id": "166c3910-9908-457f-8c72-8d0183f12e2f",
|
||||
"title": "ㅎㄹㅇㄴ",
|
||||
"description": "ㅎㄹㅇㄴ",
|
||||
"priority": "normal",
|
||||
"status": "pending",
|
||||
"assignedTo": "",
|
||||
"dueDate": "",
|
||||
"createdAt": "2025-10-23T05:21:01.515Z",
|
||||
"updatedAt": "2025-10-23T05:21:01.515Z",
|
||||
"isUrgent": false,
|
||||
"order": 7
|
||||
},
|
||||
{
|
||||
"id": "bfa9d476-bb98-41d5-9d74-b016be011bba",
|
||||
"title": "ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹ",
|
||||
"description": "ㅁㄴㅇㄹㄴㅇㄹ",
|
||||
"priority": "normal",
|
||||
"status": "pending",
|
||||
"assignedTo": "",
|
||||
"dueDate": "",
|
||||
"createdAt": "2025-10-23T05:21:25.781Z",
|
||||
"updatedAt": "2025-10-23T05:21:25.781Z",
|
||||
"isUrgent": false,
|
||||
"order": 8
|
||||
}
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
// multer 패키지 설치 스크립트
|
||||
const { exec } = require("child_process");
|
||||
|
||||
console.log("📦 multer 패키지 설치 중...");
|
||||
|
||||
exec("npm install multer @types/multer", (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error("❌ 설치 실패:", error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
console.log("⚠️ 경고:", stderr);
|
||||
}
|
||||
|
||||
console.log("✅ multer 설치 완료");
|
||||
console.log(stdout);
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
roots: ["<rootDir>/src"],
|
||||
testMatch: ["**/__tests__/**/*.ts", "**/?(*.)+(spec|test).ts"],
|
||||
transform: {
|
||||
"^.+\\.ts$": "ts-jest",
|
||||
},
|
||||
collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts", "!src/tests/**"],
|
||||
coverageDirectory: "coverage",
|
||||
coverageReporters: ["text", "lcov", "html"],
|
||||
setupFilesAfterEnv: ["<rootDir>/src/tests/setup.ts"],
|
||||
testTimeout: 30000,
|
||||
verbose: true,
|
||||
// 환경 변수 설정
|
||||
setupFiles: ["<rootDir>/src/tests/env.setup.ts"],
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"watch": ["src"],
|
||||
"ext": "ts,json",
|
||||
"ignore": ["src/**/*.spec.ts"],
|
||||
"exec": "node -r ts-node/register/transpile-only src/app.ts"
|
||||
}
|
||||
Generated
-11637
File diff suppressed because it is too large
Load Diff
@@ -1,96 +0,0 @@
|
||||
{
|
||||
"name": "pms-backend-node",
|
||||
"version": "1.0.0",
|
||||
"description": "PLM System Backend - Node.js + TypeScript",
|
||||
"main": "dist/app.js",
|
||||
"scripts": {
|
||||
"start": "node dist/app.js",
|
||||
"dev": "nodemon src/app.ts",
|
||||
"build": "tsc",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"lint": "eslint src/ --ext .ts",
|
||||
"lint:fix": "eslint src/ --ext .ts --fix",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"keywords": [
|
||||
"plm",
|
||||
"nodejs",
|
||||
"typescript",
|
||||
"express",
|
||||
"postgresql"
|
||||
],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@types/mssql": "^9.1.8",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bwip-js": "^4.8.0",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"docx": "^9.5.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"imap": "^0.8.19",
|
||||
"joi": "^17.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailparser": "^3.7.5",
|
||||
"mssql": "^11.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.15.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^6.10.1",
|
||||
"oracledb": "^6.9.0",
|
||||
"pg": "^8.16.3",
|
||||
"quill": "^2.0.3",
|
||||
"react-quill": "^2.0.0",
|
||||
"redis": "^4.6.10",
|
||||
"uuid": "^13.0.0",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/bwip-js": "^3.2.3",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/imap": "^0.8.42",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/multer": "^1.4.13",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"@types/nodemailer": "^6.4.20",
|
||||
"@types/oracledb": "^6.9.1",
|
||||
"@types/pg": "^8.15.5",
|
||||
"@types/sanitize-html": "^2.9.5",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"eslint": "^8.55.0",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"prettier": "^3.1.0",
|
||||
"supertest": "^6.3.4",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.10.0",
|
||||
"npm": ">=10.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
/**
|
||||
* 테이블 타입관리 성능 테스트 스크립트
|
||||
* 최적화 전후 성능 비교용
|
||||
*/
|
||||
|
||||
const axios = require("axios");
|
||||
|
||||
const BASE_URL = "http://localhost:3001/api";
|
||||
const TEST_TABLE = "user_info"; // 테스트할 테이블명
|
||||
|
||||
// 성능 측정 함수
|
||||
async function measurePerformance(name, fn) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await fn();
|
||||
const end = Date.now();
|
||||
const duration = end - start;
|
||||
|
||||
console.log(`✅ ${name}: ${duration}ms`);
|
||||
return { success: true, duration, result };
|
||||
} catch (error) {
|
||||
const end = Date.now();
|
||||
const duration = end - start;
|
||||
|
||||
console.log(`❌ ${name}: ${duration}ms (실패: ${error.message})`);
|
||||
return { success: false, duration, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// 테스트 함수들
|
||||
const tests = {
|
||||
// 1. 테이블 목록 조회 성능
|
||||
async testTableList() {
|
||||
return await axios.get(`${BASE_URL}/table-management/tables`);
|
||||
},
|
||||
|
||||
// 2. 컬럼 목록 조회 성능 (첫 페이지)
|
||||
async testColumnListFirstPage() {
|
||||
return await axios.get(
|
||||
`${BASE_URL}/table-management/tables/${TEST_TABLE}/columns?page=1&size=50`
|
||||
);
|
||||
},
|
||||
|
||||
// 3. 컬럼 목록 조회 성능 (큰 페이지)
|
||||
async testColumnListLargePage() {
|
||||
return await axios.get(
|
||||
`${BASE_URL}/table-management/tables/${TEST_TABLE}/columns?page=1&size=200`
|
||||
);
|
||||
},
|
||||
|
||||
// 4. 캐시 효과 테스트 (동일한 요청 반복)
|
||||
async testCacheEffect() {
|
||||
// 첫 번째 요청 (캐시 미스)
|
||||
await axios.get(
|
||||
`${BASE_URL}/table-management/tables/${TEST_TABLE}/columns?page=1&size=50`
|
||||
);
|
||||
|
||||
// 두 번째 요청 (캐시 히트)
|
||||
return await axios.get(
|
||||
`${BASE_URL}/table-management/tables/${TEST_TABLE}/columns?page=1&size=50`
|
||||
);
|
||||
},
|
||||
|
||||
// 5. 동시 요청 처리 성능
|
||||
async testConcurrentRequests() {
|
||||
const requests = Array(10)
|
||||
.fill()
|
||||
.map((_, i) =>
|
||||
axios.get(
|
||||
`${BASE_URL}/table-management/tables/${TEST_TABLE}/columns?page=${i + 1}&size=20`
|
||||
)
|
||||
);
|
||||
|
||||
return await Promise.all(requests);
|
||||
},
|
||||
};
|
||||
|
||||
// 메인 테스트 실행
|
||||
async function runPerformanceTests() {
|
||||
console.log("🚀 테이블 타입관리 성능 테스트 시작\n");
|
||||
console.log(`📊 테스트 대상: ${BASE_URL}`);
|
||||
console.log(`📋 테스트 테이블: ${TEST_TABLE}\n`);
|
||||
|
||||
const results = {};
|
||||
|
||||
// 각 테스트 실행
|
||||
for (const [testName, testFn] of Object.entries(tests)) {
|
||||
console.log(`\n--- ${testName} ---`);
|
||||
|
||||
// 각 테스트를 3번 실행하여 평균 계산
|
||||
const runs = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const result = await measurePerformance(`실행 ${i + 1}`, testFn);
|
||||
runs.push(result);
|
||||
|
||||
// 테스트 간 간격
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
// 성공한 실행들의 평균 시간 계산
|
||||
const successfulRuns = runs.filter((r) => r.success);
|
||||
if (successfulRuns.length > 0) {
|
||||
const avgDuration =
|
||||
successfulRuns.reduce((sum, r) => sum + r.duration, 0) /
|
||||
successfulRuns.length;
|
||||
const minDuration = Math.min(...successfulRuns.map((r) => r.duration));
|
||||
const maxDuration = Math.max(...successfulRuns.map((r) => r.duration));
|
||||
|
||||
results[testName] = {
|
||||
average: Math.round(avgDuration),
|
||||
min: minDuration,
|
||||
max: maxDuration,
|
||||
successRate: (successfulRuns.length / runs.length) * 100,
|
||||
};
|
||||
|
||||
console.log(
|
||||
`📈 평균: ${Math.round(avgDuration)}ms, 최소: ${minDuration}ms, 최대: ${maxDuration}ms`
|
||||
);
|
||||
} else {
|
||||
results[testName] = { error: "모든 테스트 실패" };
|
||||
console.log("❌ 모든 테스트 실패");
|
||||
}
|
||||
}
|
||||
|
||||
// 결과 요약
|
||||
console.log("\n" + "=".repeat(50));
|
||||
console.log("📊 성능 테스트 결과 요약");
|
||||
console.log("=".repeat(50));
|
||||
|
||||
for (const [testName, result] of Object.entries(results)) {
|
||||
if (result.error) {
|
||||
console.log(`❌ ${testName}: ${result.error}`);
|
||||
} else {
|
||||
console.log(
|
||||
`✅ ${testName}: ${result.average}ms (${result.min}-${result.max}ms, 성공률: ${result.successRate}%)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 성능 기준 평가
|
||||
console.log("\n" + "=".repeat(50));
|
||||
console.log("🎯 성능 기준 평가");
|
||||
console.log("=".repeat(50));
|
||||
|
||||
const benchmarks = {
|
||||
testTableList: { good: 200, acceptable: 500 },
|
||||
testColumnListFirstPage: { good: 300, acceptable: 800 },
|
||||
testColumnListLargePage: { good: 500, acceptable: 1200 },
|
||||
testCacheEffect: { good: 50, acceptable: 150 },
|
||||
testConcurrentRequests: { good: 1000, acceptable: 3000 },
|
||||
};
|
||||
|
||||
for (const [testName, result] of Object.entries(results)) {
|
||||
if (result.error) continue;
|
||||
|
||||
const benchmark = benchmarks[testName];
|
||||
if (!benchmark) continue;
|
||||
|
||||
let status = "🔴 느림";
|
||||
if (result.average <= benchmark.good) {
|
||||
status = "🟢 우수";
|
||||
} else if (result.average <= benchmark.acceptable) {
|
||||
status = "🟡 양호";
|
||||
}
|
||||
|
||||
console.log(`${status} ${testName}: ${result.average}ms`);
|
||||
}
|
||||
|
||||
console.log("\n✨ 성능 테스트 완료!");
|
||||
}
|
||||
|
||||
// 에러 핸들링
|
||||
process.on("unhandledRejection", (error) => {
|
||||
console.error("❌ 처리되지 않은 에러:", error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// 테스트 실행
|
||||
if (require.main === module) {
|
||||
runPerformanceTests().catch(console.error);
|
||||
}
|
||||
|
||||
module.exports = { runPerformanceTests, measurePerformance };
|
||||
@@ -1,52 +0,0 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function addButtonWebType() {
|
||||
try {
|
||||
console.log("🔍 버튼 웹타입 확인 중...");
|
||||
|
||||
// 기존 button 웹타입 확인
|
||||
const existingButton = await prisma.web_type_standards.findUnique({
|
||||
where: { web_type: "button" },
|
||||
});
|
||||
|
||||
if (existingButton) {
|
||||
console.log("✅ 버튼 웹타입이 이미 존재합니다.");
|
||||
console.log("📄 기존 설정:", JSON.stringify(existingButton, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("➕ 버튼 웹타입 추가 중...");
|
||||
|
||||
// 버튼 웹타입 추가
|
||||
const buttonWebType = await prisma.web_type_standards.create({
|
||||
data: {
|
||||
web_type: "button",
|
||||
type_name: "버튼",
|
||||
type_name_eng: "Button",
|
||||
description: "클릭 가능한 버튼 컴포넌트",
|
||||
category: "action",
|
||||
component_name: "ButtonWidget",
|
||||
config_panel: "ButtonConfigPanel",
|
||||
default_config: {
|
||||
actionType: "custom",
|
||||
variant: "default",
|
||||
},
|
||||
sort_order: 100,
|
||||
is_active: "Y",
|
||||
created_by: "system",
|
||||
updated_by: "system",
|
||||
},
|
||||
});
|
||||
|
||||
console.log("✅ 버튼 웹타입이 성공적으로 추가되었습니다!");
|
||||
console.log("📄 추가된 설정:", JSON.stringify(buttonWebType, null, 2));
|
||||
} catch (error) {
|
||||
console.error("❌ 버튼 웹타입 추가 실패:", error);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
addButtonWebType();
|
||||
@@ -1,34 +0,0 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function addDataMappingColumn() {
|
||||
try {
|
||||
console.log(
|
||||
"🔄 external_call_configs 테이블에 data_mapping_config 컬럼 추가 중..."
|
||||
);
|
||||
|
||||
// data_mapping_config JSONB 컬럼 추가
|
||||
await prisma.$executeRaw`
|
||||
ALTER TABLE external_call_configs
|
||||
ADD COLUMN IF NOT EXISTS data_mapping_config JSONB
|
||||
`;
|
||||
|
||||
console.log("✅ data_mapping_config 컬럼이 성공적으로 추가되었습니다.");
|
||||
|
||||
// 기존 레코드에 기본값 설정
|
||||
await prisma.$executeRaw`
|
||||
UPDATE external_call_configs
|
||||
SET data_mapping_config = '{"direction": "none"}'::jsonb
|
||||
WHERE data_mapping_config IS NULL
|
||||
`;
|
||||
|
||||
console.log("✅ 기존 레코드에 기본값이 설정되었습니다.");
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 추가 실패:", error);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
addDataMappingColumn();
|
||||
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* 외부 DB 연결 정보 추가 스크립트
|
||||
* 비밀번호를 암호화하여 안전하게 저장
|
||||
*/
|
||||
|
||||
import { Pool } from "pg";
|
||||
import { CredentialEncryption } from "../src/utils/credentialEncryption";
|
||||
|
||||
async function addExternalDbConnection() {
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || "localhost",
|
||||
port: parseInt(process.env.DB_PORT || "5432"),
|
||||
database: process.env.DB_NAME || "plm",
|
||||
user: process.env.DB_USER || "postgres",
|
||||
password: process.env.DB_PASSWORD || "ph0909!!",
|
||||
});
|
||||
|
||||
// 환경 변수에서 암호화 키 가져오기 (없으면 기본값 사용)
|
||||
const encryptionKey =
|
||||
process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development";
|
||||
const encryption = new CredentialEncryption(encryptionKey);
|
||||
|
||||
try {
|
||||
// 외부 DB 연결 정보 (실제 사용할 외부 DB 정보를 여기에 입력)
|
||||
const externalDbConnections = [
|
||||
{
|
||||
name: "운영_외부_PostgreSQL",
|
||||
description: "운영용 외부 PostgreSQL 데이터베이스",
|
||||
dbType: "postgresql",
|
||||
host: "39.117.244.52",
|
||||
port: 11132,
|
||||
databaseName: "plm",
|
||||
username: "postgres",
|
||||
password: "ph0909!!", // 이 값은 암호화되어 저장됩니다
|
||||
sslEnabled: false,
|
||||
isActive: true,
|
||||
},
|
||||
// 필요한 경우 추가 외부 DB 연결 정보를 여기에 추가
|
||||
// {
|
||||
// name: "테스트_MySQL",
|
||||
// description: "테스트용 MySQL 데이터베이스",
|
||||
// dbType: "mysql",
|
||||
// host: "test-mysql.example.com",
|
||||
// port: 3306,
|
||||
// databaseName: "testdb",
|
||||
// username: "testuser",
|
||||
// password: "testpass",
|
||||
// sslEnabled: true,
|
||||
// isActive: true,
|
||||
// },
|
||||
];
|
||||
|
||||
for (const conn of externalDbConnections) {
|
||||
// 비밀번호 암호화
|
||||
const encryptedPassword = encryption.encrypt(conn.password);
|
||||
|
||||
// 중복 체크 (이름 기준)
|
||||
const existingResult = await pool.query(
|
||||
"SELECT id FROM flow_external_db_connection WHERE name = $1",
|
||||
[conn.name]
|
||||
);
|
||||
|
||||
if (existingResult.rows.length > 0) {
|
||||
console.log(
|
||||
`⚠️ 이미 존재하는 연결: ${conn.name} (ID: ${existingResult.rows[0].id})`
|
||||
);
|
||||
|
||||
// 기존 연결 업데이트
|
||||
await pool.query(
|
||||
`UPDATE flow_external_db_connection
|
||||
SET description = $1,
|
||||
db_type = $2,
|
||||
host = $3,
|
||||
port = $4,
|
||||
database_name = $5,
|
||||
username = $6,
|
||||
password_encrypted = $7,
|
||||
ssl_enabled = $8,
|
||||
is_active = $9,
|
||||
updated_at = NOW(),
|
||||
updated_by = 'system'
|
||||
WHERE name = $10`,
|
||||
[
|
||||
conn.description,
|
||||
conn.dbType,
|
||||
conn.host,
|
||||
conn.port,
|
||||
conn.databaseName,
|
||||
conn.username,
|
||||
encryptedPassword,
|
||||
conn.sslEnabled,
|
||||
conn.isActive,
|
||||
conn.name,
|
||||
]
|
||||
);
|
||||
console.log(`✅ 연결 정보 업데이트 완료: ${conn.name}`);
|
||||
} else {
|
||||
// 새 연결 추가
|
||||
const result = await pool.query(
|
||||
`INSERT INTO flow_external_db_connection (
|
||||
name,
|
||||
description,
|
||||
db_type,
|
||||
host,
|
||||
port,
|
||||
database_name,
|
||||
username,
|
||||
password_encrypted,
|
||||
ssl_enabled,
|
||||
is_active,
|
||||
created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'system')
|
||||
RETURNING id`,
|
||||
[
|
||||
conn.name,
|
||||
conn.description,
|
||||
conn.dbType,
|
||||
conn.host,
|
||||
conn.port,
|
||||
conn.databaseName,
|
||||
conn.username,
|
||||
encryptedPassword,
|
||||
conn.sslEnabled,
|
||||
conn.isActive,
|
||||
]
|
||||
);
|
||||
console.log(
|
||||
`✅ 새 연결 추가 완료: ${conn.name} (ID: ${result.rows[0].id})`
|
||||
);
|
||||
}
|
||||
|
||||
// 연결 테스트
|
||||
console.log(`🔍 연결 테스트 중: ${conn.name}...`);
|
||||
const testPool = new Pool({
|
||||
host: conn.host,
|
||||
port: conn.port,
|
||||
database: conn.databaseName,
|
||||
user: conn.username,
|
||||
password: conn.password,
|
||||
ssl: conn.sslEnabled,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
try {
|
||||
const client = await testPool.connect();
|
||||
await client.query("SELECT 1");
|
||||
client.release();
|
||||
console.log(`✅ 연결 테스트 성공: ${conn.name}`);
|
||||
} catch (testError: any) {
|
||||
console.error(`❌ 연결 테스트 실패: ${conn.name}`, testError.message);
|
||||
} finally {
|
||||
await testPool.end();
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n✅ 모든 외부 DB 연결 정보 처리 완료");
|
||||
} catch (error) {
|
||||
console.error("❌ 외부 DB 연결 정보 추가 오류:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
addExternalDbConnection()
|
||||
.then(() => {
|
||||
console.log("✅ 스크립트 완료");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("❌ 스크립트 실패:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function addMissingColumns() {
|
||||
try {
|
||||
console.log("🔄 누락된 컬럼들을 screen_layouts 테이블에 추가 중...");
|
||||
|
||||
// layout_type 컬럼 추가
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
ALTER TABLE screen_layouts
|
||||
ADD COLUMN IF NOT EXISTS layout_type VARCHAR(50);
|
||||
`;
|
||||
console.log("✅ layout_type 컬럼 추가 완료");
|
||||
} catch (error) {
|
||||
console.log(
|
||||
"ℹ️ layout_type 컬럼이 이미 존재하거나 추가 중 오류:",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
// layout_config 컬럼 추가
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
ALTER TABLE screen_layouts
|
||||
ADD COLUMN IF NOT EXISTS layout_config JSONB;
|
||||
`;
|
||||
console.log("✅ layout_config 컬럼 추가 완료");
|
||||
} catch (error) {
|
||||
console.log(
|
||||
"ℹ️ layout_config 컬럼이 이미 존재하거나 추가 중 오류:",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
// zones_config 컬럼 추가
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
ALTER TABLE screen_layouts
|
||||
ADD COLUMN IF NOT EXISTS zones_config JSONB;
|
||||
`;
|
||||
console.log("✅ zones_config 컬럼 추가 완료");
|
||||
} catch (error) {
|
||||
console.log(
|
||||
"ℹ️ zones_config 컬럼이 이미 존재하거나 추가 중 오류:",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
// zone_id 컬럼 추가
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
ALTER TABLE screen_layouts
|
||||
ADD COLUMN IF NOT EXISTS zone_id VARCHAR(100);
|
||||
`;
|
||||
console.log("✅ zone_id 컬럼 추가 완료");
|
||||
} catch (error) {
|
||||
console.log(
|
||||
"ℹ️ zone_id 컬럼이 이미 존재하거나 추가 중 오류:",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
// 인덱스 생성 (성능 향상)
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
CREATE INDEX IF NOT EXISTS idx_screen_layouts_layout_type
|
||||
ON screen_layouts(layout_type);
|
||||
`;
|
||||
console.log("✅ layout_type 인덱스 생성 완료");
|
||||
} catch (error) {
|
||||
console.log("ℹ️ layout_type 인덱스 생성 중 오류:", error.message);
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
CREATE INDEX IF NOT EXISTS idx_screen_layouts_zone_id
|
||||
ON screen_layouts(zone_id);
|
||||
`;
|
||||
console.log("✅ zone_id 인덱스 생성 완료");
|
||||
} catch (error) {
|
||||
console.log("ℹ️ zone_id 인덱스 생성 중 오류:", error.message);
|
||||
}
|
||||
|
||||
// 최종 테이블 구조 확인
|
||||
const columns = await prisma.$queryRaw`
|
||||
SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'screen_layouts'
|
||||
ORDER BY ordinal_position
|
||||
`;
|
||||
|
||||
console.log("\n📋 screen_layouts 테이블 최종 구조:");
|
||||
console.table(columns);
|
||||
|
||||
console.log("\n🎉 모든 누락된 컬럼 추가 작업이 완료되었습니다!");
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 추가 중 오류 발생:", error);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
addMissingColumns();
|
||||
@@ -1,318 +0,0 @@
|
||||
/**
|
||||
* 탑씰(company_7) 버튼 스타일 일괄 변경 스크립트
|
||||
*
|
||||
* 사용법:
|
||||
* npx ts-node scripts/btn-bulk-update-company7.ts --test # 1건만 테스트 (ROLLBACK)
|
||||
* npx ts-node scripts/btn-bulk-update-company7.ts --run # 전체 실행 (COMMIT)
|
||||
* npx ts-node scripts/btn-bulk-update-company7.ts --backup # 백업 테이블만 생성
|
||||
* npx ts-node scripts/btn-bulk-update-company7.ts --restore # 백업에서 원복
|
||||
*/
|
||||
|
||||
import { Pool } from "pg";
|
||||
|
||||
// ── 배포 DB 연결 ──
|
||||
const pool = new Pool({
|
||||
connectionString:
|
||||
"postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor",
|
||||
});
|
||||
|
||||
const COMPANY_CODE = "COMPANY_7";
|
||||
const BACKUP_TABLE = "screen_layouts_v2_backup_20260313";
|
||||
|
||||
// ── 액션별 기본 아이콘 매핑 (frontend/lib/button-icon-map.tsx 기준) ──
|
||||
const actionIconMap: Record<string, string> = {
|
||||
save: "Check",
|
||||
delete: "Trash2",
|
||||
edit: "Pencil",
|
||||
navigate: "ArrowRight",
|
||||
modal: "Maximize2",
|
||||
transferData: "SendHorizontal",
|
||||
excel_download: "Download",
|
||||
excel_upload: "Upload",
|
||||
quickInsert: "Zap",
|
||||
control: "Settings",
|
||||
barcode_scan: "ScanLine",
|
||||
operation_control: "Truck",
|
||||
event: "Send",
|
||||
copy: "Copy",
|
||||
};
|
||||
const FALLBACK_ICON = "SquareMousePointer";
|
||||
|
||||
function getIconForAction(actionType?: string): string {
|
||||
if (actionType && actionIconMap[actionType]) {
|
||||
return actionIconMap[actionType];
|
||||
}
|
||||
return FALLBACK_ICON;
|
||||
}
|
||||
|
||||
// ── 버튼 컴포넌트인지 판별 (최상위 + 탭 내부 둘 다 지원) ──
|
||||
function isTopLevelButton(comp: any): boolean {
|
||||
return (
|
||||
comp.url?.includes("v2-button-primary") ||
|
||||
comp.overrides?.type === "v2-button-primary"
|
||||
);
|
||||
}
|
||||
|
||||
function isTabChildButton(comp: any): boolean {
|
||||
return comp.componentType === "v2-button-primary";
|
||||
}
|
||||
|
||||
function isButtonComponent(comp: any): boolean {
|
||||
return isTopLevelButton(comp) || isTabChildButton(comp);
|
||||
}
|
||||
|
||||
// ── 탭 위젯인지 판별 ──
|
||||
function isTabsWidget(comp: any): boolean {
|
||||
return (
|
||||
comp.url?.includes("v2-tabs-widget") ||
|
||||
comp.overrides?.type === "v2-tabs-widget"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 버튼 스타일 변경 (최상위 버튼용: overrides 사용) ──
|
||||
function applyButtonStyle(config: any, actionType: string | undefined) {
|
||||
const iconName = getIconForAction(actionType);
|
||||
|
||||
config.displayMode = "icon-text";
|
||||
|
||||
config.icon = {
|
||||
name: iconName,
|
||||
type: "lucide",
|
||||
size: "보통",
|
||||
...(config.icon?.color ? { color: config.icon.color } : {}),
|
||||
};
|
||||
|
||||
config.iconTextPosition = "right";
|
||||
config.iconGap = 6;
|
||||
|
||||
if (!config.style) config.style = {};
|
||||
delete config.style.width; // 레거시 하드코딩 너비 제거 (size.width만 사용)
|
||||
config.style.borderRadius = "8px";
|
||||
config.style.labelColor = "#FFFFFF";
|
||||
config.style.fontSize = "12px";
|
||||
config.style.fontWeight = "normal";
|
||||
config.style.labelTextAlign = "left";
|
||||
|
||||
if (actionType === "delete") {
|
||||
config.style.backgroundColor = "#F04544";
|
||||
} else if (actionType === "excel_upload" || actionType === "excel_download") {
|
||||
config.style.backgroundColor = "#212121";
|
||||
} else {
|
||||
config.style.backgroundColor = "#3B83F6";
|
||||
}
|
||||
}
|
||||
|
||||
function updateButtonStyle(comp: any): boolean {
|
||||
if (isTopLevelButton(comp)) {
|
||||
const overrides = comp.overrides || {};
|
||||
const actionType = overrides.action?.type;
|
||||
|
||||
if (!comp.size) comp.size = {};
|
||||
comp.size.height = 40;
|
||||
|
||||
applyButtonStyle(overrides, actionType);
|
||||
comp.overrides = overrides;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isTabChildButton(comp)) {
|
||||
const config = comp.componentConfig || {};
|
||||
const actionType = config.action?.type;
|
||||
|
||||
if (!comp.size) comp.size = {};
|
||||
comp.size.height = 40;
|
||||
|
||||
applyButtonStyle(config, actionType);
|
||||
comp.componentConfig = config;
|
||||
|
||||
// 탭 내부 버튼은 렌더러가 comp.style (최상위)에서 스타일을 읽음
|
||||
if (!comp.style) comp.style = {};
|
||||
comp.style.borderRadius = "8px";
|
||||
comp.style.labelColor = "#FFFFFF";
|
||||
comp.style.fontSize = "12px";
|
||||
comp.style.fontWeight = "normal";
|
||||
comp.style.labelTextAlign = "left";
|
||||
comp.style.backgroundColor = config.style.backgroundColor;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── 백업 테이블 생성 ──
|
||||
async function createBackup() {
|
||||
console.log(`\n=== 백업 테이블 생성: ${BACKUP_TABLE} ===`);
|
||||
|
||||
const exists = await pool.query(
|
||||
`SELECT to_regclass($1) AS tbl`,
|
||||
[BACKUP_TABLE],
|
||||
);
|
||||
if (exists.rows[0].tbl) {
|
||||
console.log(`백업 테이블이 이미 존재합니다: ${BACKUP_TABLE}`);
|
||||
const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`);
|
||||
console.log(`기존 백업 레코드 수: ${count.rows[0].count}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`CREATE TABLE ${BACKUP_TABLE} AS
|
||||
SELECT * FROM screen_layouts_v2
|
||||
WHERE company_code = $1`,
|
||||
[COMPANY_CODE],
|
||||
);
|
||||
|
||||
const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`);
|
||||
console.log(`백업 완료. 레코드 수: ${count.rows[0].count}`);
|
||||
}
|
||||
|
||||
// ── 백업에서 원복 ──
|
||||
async function restoreFromBackup() {
|
||||
console.log(`\n=== 백업에서 원복: ${BACKUP_TABLE} ===`);
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE screen_layouts_v2 AS target
|
||||
SET layout_data = backup.layout_data,
|
||||
updated_at = backup.updated_at
|
||||
FROM ${BACKUP_TABLE} AS backup
|
||||
WHERE target.screen_id = backup.screen_id
|
||||
AND target.company_code = backup.company_code
|
||||
AND target.layer_id = backup.layer_id`,
|
||||
);
|
||||
console.log(`원복 완료. 변경된 레코드 수: ${result.rowCount}`);
|
||||
}
|
||||
|
||||
// ── 메인: 버튼 일괄 변경 ──
|
||||
async function updateButtons(testMode: boolean) {
|
||||
const modeLabel = testMode ? "테스트 (1건, ROLLBACK)" : "전체 실행 (COMMIT)";
|
||||
console.log(`\n=== 버튼 일괄 변경 시작 [${modeLabel}] ===`);
|
||||
|
||||
// company_7 레코드 조회
|
||||
const rows = await pool.query(
|
||||
`SELECT screen_id, layer_id, company_code, layout_data
|
||||
FROM screen_layouts_v2
|
||||
WHERE company_code = $1
|
||||
ORDER BY screen_id, layer_id`,
|
||||
[COMPANY_CODE],
|
||||
);
|
||||
console.log(`대상 레코드 수: ${rows.rowCount}`);
|
||||
|
||||
if (!rows.rowCount) {
|
||||
console.log("변경할 레코드가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
let totalUpdated = 0;
|
||||
let totalButtons = 0;
|
||||
const targetRows = testMode ? [rows.rows[0]] : rows.rows;
|
||||
|
||||
for (const row of targetRows) {
|
||||
const layoutData = row.layout_data;
|
||||
if (!layoutData?.components || !Array.isArray(layoutData.components)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let buttonsInRow = 0;
|
||||
for (const comp of layoutData.components) {
|
||||
// 최상위 버튼 처리
|
||||
if (updateButtonStyle(comp)) {
|
||||
buttonsInRow++;
|
||||
}
|
||||
|
||||
// 탭 위젯 내부 버튼 처리
|
||||
if (isTabsWidget(comp)) {
|
||||
const tabs = comp.overrides?.tabs || [];
|
||||
for (const tab of tabs) {
|
||||
const tabComps = tab.components || [];
|
||||
for (const tabComp of tabComps) {
|
||||
if (updateButtonStyle(tabComp)) {
|
||||
buttonsInRow++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buttonsInRow > 0) {
|
||||
await client.query(
|
||||
`UPDATE screen_layouts_v2
|
||||
SET layout_data = $1, updated_at = NOW()
|
||||
WHERE screen_id = $2 AND company_code = $3 AND layer_id = $4`,
|
||||
[JSON.stringify(layoutData), row.screen_id, row.company_code, row.layer_id],
|
||||
);
|
||||
totalUpdated++;
|
||||
totalButtons += buttonsInRow;
|
||||
|
||||
console.log(
|
||||
` screen_id=${row.screen_id}, layer_id=${row.layer_id} → 버튼 ${buttonsInRow}개 변경`,
|
||||
);
|
||||
|
||||
// 테스트 모드: 변경 전후 비교를 위해 첫 번째 버튼 출력
|
||||
if (testMode) {
|
||||
const sampleBtn = layoutData.components.find(isButtonComponent);
|
||||
if (sampleBtn) {
|
||||
console.log("\n--- 변경 후 샘플 버튼 ---");
|
||||
console.log(JSON.stringify(sampleBtn, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n--- 결과 ---`);
|
||||
console.log(`변경된 레코드: ${totalUpdated}개`);
|
||||
console.log(`변경된 버튼: ${totalButtons}개`);
|
||||
|
||||
if (testMode) {
|
||||
await client.query("ROLLBACK");
|
||||
console.log("\n[테스트 모드] ROLLBACK 완료. 실제 DB 변경 없음.");
|
||||
} else {
|
||||
await client.query("COMMIT");
|
||||
console.log("\nCOMMIT 완료.");
|
||||
}
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
console.error("\n에러 발생. ROLLBACK 완료.", err);
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ── CLI 진입점 ──
|
||||
async function main() {
|
||||
const arg = process.argv[2];
|
||||
|
||||
if (!arg || !["--test", "--run", "--backup", "--restore"].includes(arg)) {
|
||||
console.log("사용법:");
|
||||
console.log(" --test : 1건 테스트 (ROLLBACK, DB 변경 없음)");
|
||||
console.log(" --run : 전체 실행 (COMMIT)");
|
||||
console.log(" --backup : 백업 테이블 생성");
|
||||
console.log(" --restore : 백업에서 원복");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
if (arg === "--backup") {
|
||||
await createBackup();
|
||||
} else if (arg === "--restore") {
|
||||
await restoreFromBackup();
|
||||
} else if (arg === "--test") {
|
||||
await createBackup();
|
||||
await updateButtons(true);
|
||||
} else if (arg === "--run") {
|
||||
await createBackup();
|
||||
await updateButtons(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("스크립트 실행 실패:", err);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* dashboards 테이블 구조 확인 스크립트
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
async function checkDashboardStructure() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🔍 dashboards 테이블 구조 확인 중...\n');
|
||||
|
||||
// 컬럼 정보 조회
|
||||
const columns = await client.query(`
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'dashboards'
|
||||
ORDER BY ordinal_position
|
||||
`);
|
||||
|
||||
console.log('📋 dashboards 테이블 컬럼:\n');
|
||||
columns.rows.forEach((col, index) => {
|
||||
console.log(`${index + 1}. ${col.column_name} (${col.data_type}) - Nullable: ${col.is_nullable}`);
|
||||
});
|
||||
|
||||
// 샘플 데이터 조회
|
||||
console.log('\n📊 샘플 데이터 (첫 1개):');
|
||||
const sample = await client.query(`
|
||||
SELECT * FROM dashboards LIMIT 1
|
||||
`);
|
||||
|
||||
if (sample.rows.length > 0) {
|
||||
console.log(JSON.stringify(sample.rows[0], null, 2));
|
||||
} else {
|
||||
console.log('❌ 데이터가 없습니다.');
|
||||
}
|
||||
|
||||
// dashboard_elements 테이블도 확인
|
||||
console.log('\n🔍 dashboard_elements 테이블 구조 확인 중...\n');
|
||||
|
||||
const elemColumns = await client.query(`
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'dashboard_elements'
|
||||
ORDER BY ordinal_position
|
||||
`);
|
||||
|
||||
console.log('📋 dashboard_elements 테이블 컬럼:\n');
|
||||
elemColumns.rows.forEach((col, index) => {
|
||||
console.log(`${index + 1}. ${col.column_name} (${col.data_type}) - Nullable: ${col.is_nullable}`);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 오류 발생:', error.message);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
checkDashboardStructure();
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* 데이터베이스 테이블 확인 스크립트
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
async function checkTables() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🔍 데이터베이스 테이블 확인 중...\n');
|
||||
|
||||
// 테이블 목록 조회
|
||||
const result = await client.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name
|
||||
`);
|
||||
|
||||
console.log(`📊 총 ${result.rows.length}개의 테이블 발견:\n`);
|
||||
result.rows.forEach((row, index) => {
|
||||
console.log(`${index + 1}. ${row.table_name}`);
|
||||
});
|
||||
|
||||
// dashboard 관련 테이블 검색
|
||||
console.log('\n🔎 dashboard 관련 테이블:');
|
||||
const dashboardTables = result.rows.filter(row =>
|
||||
row.table_name.toLowerCase().includes('dashboard')
|
||||
);
|
||||
|
||||
if (dashboardTables.length === 0) {
|
||||
console.log('❌ dashboard 관련 테이블을 찾을 수 없습니다.');
|
||||
} else {
|
||||
dashboardTables.forEach(row => {
|
||||
console.log(`✅ ${row.table_name}`);
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 오류 발생:', error.message);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
checkTables();
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function createComponentTable() {
|
||||
try {
|
||||
console.log("🔧 component_standards 테이블 생성 중...");
|
||||
|
||||
// 테이블 생성 SQL
|
||||
await prisma.$executeRaw`
|
||||
CREATE TABLE IF NOT EXISTS component_standards (
|
||||
component_code VARCHAR(50) PRIMARY KEY,
|
||||
component_name VARCHAR(100) NOT NULL,
|
||||
component_name_eng VARCHAR(100),
|
||||
description TEXT,
|
||||
category VARCHAR(50) NOT NULL,
|
||||
icon_name VARCHAR(50),
|
||||
default_size JSON,
|
||||
component_config JSON NOT NULL,
|
||||
preview_image VARCHAR(255),
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_active CHAR(1) DEFAULT 'Y',
|
||||
is_public CHAR(1) DEFAULT 'Y',
|
||||
company_code VARCHAR(50) NOT NULL,
|
||||
created_date TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by VARCHAR(50),
|
||||
updated_date TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by VARCHAR(50)
|
||||
)
|
||||
`;
|
||||
|
||||
console.log("✅ component_standards 테이블 생성 완료");
|
||||
|
||||
// 인덱스 생성
|
||||
await prisma.$executeRaw`
|
||||
CREATE INDEX IF NOT EXISTS idx_component_standards_category
|
||||
ON component_standards (category)
|
||||
`;
|
||||
|
||||
await prisma.$executeRaw`
|
||||
CREATE INDEX IF NOT EXISTS idx_component_standards_company
|
||||
ON component_standards (company_code)
|
||||
`;
|
||||
|
||||
console.log("✅ 인덱스 생성 완료");
|
||||
|
||||
// 테이블 코멘트 추가
|
||||
await prisma.$executeRaw`
|
||||
COMMENT ON TABLE component_standards IS 'UI 컴포넌트 표준 정보를 저장하는 테이블'
|
||||
`;
|
||||
|
||||
console.log("✅ 테이블 코멘트 추가 완료");
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 생성 실패:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 실행
|
||||
if (require.main === module) {
|
||||
createComponentTable()
|
||||
.then(() => {
|
||||
console.log("🎉 테이블 생성 완료!");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("💥 테이블 생성 실패:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { createComponentTable };
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* 비밀번호 암호화 유틸리티
|
||||
*/
|
||||
|
||||
import { CredentialEncryption } from "../src/utils/credentialEncryption";
|
||||
|
||||
const encryptionKey =
|
||||
process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development";
|
||||
const encryption = new CredentialEncryption(encryptionKey);
|
||||
const password = process.argv[2] || "ph0909!!";
|
||||
|
||||
const encrypted = encryption.encrypt(password);
|
||||
console.log("\n원본 비밀번호:", password);
|
||||
console.log("암호화된 비밀번호:", encrypted);
|
||||
console.log("\n복호화 테스트:", encryption.decrypt(encrypted));
|
||||
console.log("✅ 암호화/복호화 성공\n");
|
||||
@@ -1,309 +0,0 @@
|
||||
/**
|
||||
* 레이아웃 표준 데이터 초기화 스크립트
|
||||
* 기본 레이아웃들을 layout_standards 테이블에 삽입합니다.
|
||||
*/
|
||||
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 기본 레이아웃 데이터
|
||||
const PREDEFINED_LAYOUTS = [
|
||||
{
|
||||
layout_code: "GRID_2X2_001",
|
||||
layout_name: "2x2 그리드",
|
||||
layout_name_eng: "2x2 Grid",
|
||||
description: "2행 2열의 균등한 그리드 레이아웃입니다.",
|
||||
layout_type: "grid",
|
||||
category: "basic",
|
||||
icon_name: "grid",
|
||||
default_size: { width: 800, height: 600 },
|
||||
layout_config: {
|
||||
grid: { rows: 2, columns: 2, gap: 16 },
|
||||
},
|
||||
zones_config: [
|
||||
{
|
||||
id: "zone1",
|
||||
name: "상단 좌측",
|
||||
position: { row: 0, column: 0 },
|
||||
size: { width: "50%", height: "50%" },
|
||||
},
|
||||
{
|
||||
id: "zone2",
|
||||
name: "상단 우측",
|
||||
position: { row: 0, column: 1 },
|
||||
size: { width: "50%", height: "50%" },
|
||||
},
|
||||
{
|
||||
id: "zone3",
|
||||
name: "하단 좌측",
|
||||
position: { row: 1, column: 0 },
|
||||
size: { width: "50%", height: "50%" },
|
||||
},
|
||||
{
|
||||
id: "zone4",
|
||||
name: "하단 우측",
|
||||
position: { row: 1, column: 1 },
|
||||
size: { width: "50%", height: "50%" },
|
||||
},
|
||||
],
|
||||
sort_order: 1,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "DEFAULT",
|
||||
},
|
||||
{
|
||||
layout_code: "FORM_TWO_COLUMN_001",
|
||||
layout_name: "2단 폼 레이아웃",
|
||||
layout_name_eng: "Two Column Form",
|
||||
description: "좌우 2단으로 구성된 폼 레이아웃입니다.",
|
||||
layout_type: "grid",
|
||||
category: "form",
|
||||
icon_name: "columns",
|
||||
default_size: { width: 800, height: 400 },
|
||||
layout_config: {
|
||||
grid: { rows: 1, columns: 2, gap: 24 },
|
||||
},
|
||||
zones_config: [
|
||||
{
|
||||
id: "left",
|
||||
name: "좌측 입력 영역",
|
||||
position: { row: 0, column: 0 },
|
||||
size: { width: "50%", height: "100%" },
|
||||
},
|
||||
{
|
||||
id: "right",
|
||||
name: "우측 입력 영역",
|
||||
position: { row: 0, column: 1 },
|
||||
size: { width: "50%", height: "100%" },
|
||||
},
|
||||
],
|
||||
sort_order: 2,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "DEFAULT",
|
||||
},
|
||||
{
|
||||
layout_code: "FLEXBOX_ROW_001",
|
||||
layout_name: "가로 플렉스박스",
|
||||
layout_name_eng: "Horizontal Flexbox",
|
||||
description: "가로 방향으로 배치되는 플렉스박스 레이아웃입니다.",
|
||||
layout_type: "flexbox",
|
||||
category: "basic",
|
||||
icon_name: "flex",
|
||||
default_size: { width: 800, height: 300 },
|
||||
layout_config: {
|
||||
flexbox: {
|
||||
direction: "row",
|
||||
justify: "flex-start",
|
||||
align: "stretch",
|
||||
wrap: "nowrap",
|
||||
gap: 16,
|
||||
},
|
||||
},
|
||||
zones_config: [
|
||||
{
|
||||
id: "left",
|
||||
name: "좌측 영역",
|
||||
position: {},
|
||||
size: { width: "50%", height: "100%" },
|
||||
},
|
||||
{
|
||||
id: "right",
|
||||
name: "우측 영역",
|
||||
position: {},
|
||||
size: { width: "50%", height: "100%" },
|
||||
},
|
||||
],
|
||||
sort_order: 3,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "DEFAULT",
|
||||
},
|
||||
{
|
||||
layout_code: "SPLIT_HORIZONTAL_001",
|
||||
layout_name: "수평 분할",
|
||||
layout_name_eng: "Horizontal Split",
|
||||
description: "크기 조절이 가능한 수평 분할 레이아웃입니다.",
|
||||
layout_type: "split",
|
||||
category: "basic",
|
||||
icon_name: "separator-horizontal",
|
||||
default_size: { width: 800, height: 400 },
|
||||
layout_config: {
|
||||
split: {
|
||||
direction: "horizontal",
|
||||
ratio: [50, 50],
|
||||
minSize: [200, 200],
|
||||
resizable: true,
|
||||
splitterSize: 4,
|
||||
},
|
||||
},
|
||||
zones_config: [
|
||||
{
|
||||
id: "left",
|
||||
name: "좌측 패널",
|
||||
position: {},
|
||||
size: { width: "50%", height: "100%" },
|
||||
isResizable: true,
|
||||
},
|
||||
{
|
||||
id: "right",
|
||||
name: "우측 패널",
|
||||
position: {},
|
||||
size: { width: "50%", height: "100%" },
|
||||
isResizable: true,
|
||||
},
|
||||
],
|
||||
sort_order: 4,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "DEFAULT",
|
||||
},
|
||||
{
|
||||
layout_code: "TABS_HORIZONTAL_001",
|
||||
layout_name: "수평 탭",
|
||||
layout_name_eng: "Horizontal Tabs",
|
||||
description: "상단에 탭이 있는 탭 레이아웃입니다.",
|
||||
layout_type: "tabs",
|
||||
category: "navigation",
|
||||
icon_name: "tabs",
|
||||
default_size: { width: 800, height: 500 },
|
||||
layout_config: {
|
||||
tabs: {
|
||||
position: "top",
|
||||
variant: "default",
|
||||
size: "md",
|
||||
defaultTab: "tab1",
|
||||
closable: false,
|
||||
},
|
||||
},
|
||||
zones_config: [
|
||||
{
|
||||
id: "tab1",
|
||||
name: "첫 번째 탭",
|
||||
position: {},
|
||||
size: { width: "100%", height: "100%" },
|
||||
},
|
||||
{
|
||||
id: "tab2",
|
||||
name: "두 번째 탭",
|
||||
position: {},
|
||||
size: { width: "100%", height: "100%" },
|
||||
},
|
||||
{
|
||||
id: "tab3",
|
||||
name: "세 번째 탭",
|
||||
position: {},
|
||||
size: { width: "100%", height: "100%" },
|
||||
},
|
||||
],
|
||||
sort_order: 5,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "DEFAULT",
|
||||
},
|
||||
{
|
||||
layout_code: "TABLE_WITH_FILTERS_001",
|
||||
layout_name: "필터가 있는 테이블",
|
||||
layout_name_eng: "Table with Filters",
|
||||
description: "상단에 필터가 있고 하단에 테이블이 있는 레이아웃입니다.",
|
||||
layout_type: "flexbox",
|
||||
category: "table",
|
||||
icon_name: "table",
|
||||
default_size: { width: 1000, height: 600 },
|
||||
layout_config: {
|
||||
flexbox: {
|
||||
direction: "column",
|
||||
justify: "flex-start",
|
||||
align: "stretch",
|
||||
wrap: "nowrap",
|
||||
gap: 16,
|
||||
},
|
||||
},
|
||||
zones_config: [
|
||||
{
|
||||
id: "filters",
|
||||
name: "검색 필터",
|
||||
position: {},
|
||||
size: { width: "100%", height: "auto" },
|
||||
},
|
||||
{
|
||||
id: "table",
|
||||
name: "데이터 테이블",
|
||||
position: {},
|
||||
size: { width: "100%", height: "1fr" },
|
||||
},
|
||||
],
|
||||
sort_order: 6,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "DEFAULT",
|
||||
},
|
||||
];
|
||||
|
||||
async function initializeLayoutStandards() {
|
||||
try {
|
||||
console.log("🏗️ 레이아웃 표준 데이터 초기화 시작...");
|
||||
|
||||
// 기존 데이터 확인
|
||||
const existingLayouts = await prisma.layout_standards.count();
|
||||
if (existingLayouts > 0) {
|
||||
console.log(`⚠️ 이미 ${existingLayouts}개의 레이아웃이 존재합니다.`);
|
||||
console.log(
|
||||
"기존 데이터를 삭제하고 새로 생성하시겠습니까? (기본값: 건너뛰기)"
|
||||
);
|
||||
|
||||
// 기존 데이터가 있으면 건너뛰기 (안전을 위해)
|
||||
console.log("💡 기존 데이터를 유지하고 건너뜁니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 삽입
|
||||
let insertedCount = 0;
|
||||
|
||||
for (const layoutData of PREDEFINED_LAYOUTS) {
|
||||
try {
|
||||
await prisma.layout_standards.create({
|
||||
data: {
|
||||
...layoutData,
|
||||
created_date: new Date(),
|
||||
updated_date: new Date(),
|
||||
created_by: "SYSTEM",
|
||||
updated_by: "SYSTEM",
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ ${layoutData.layout_name} 생성 완료`);
|
||||
insertedCount++;
|
||||
} catch (error) {
|
||||
console.error(`❌ ${layoutData.layout_name} 생성 실패:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`🎉 레이아웃 표준 데이터 초기화 완료! (${insertedCount}/${PREDEFINED_LAYOUTS.length})`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("❌ 레이아웃 표준 데이터 초기화 실패:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
if (require.main === module) {
|
||||
initializeLayoutStandards()
|
||||
.then(() => {
|
||||
console.log("✨ 스크립트 실행 완료");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("💥 스크립트 실행 실패:", error);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { initializeLayoutStandards };
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* 🔥 버튼 제어관리 성능 최적화 인덱스 설치 스크립트
|
||||
*
|
||||
* 사용법:
|
||||
* node scripts/install-dataflow-indexes.js
|
||||
*/
|
||||
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function installDataflowIndexes() {
|
||||
try {
|
||||
console.log("🔥 Starting Button Dataflow Performance Optimization...\n");
|
||||
|
||||
// SQL 파일 읽기
|
||||
const sqlFilePath = path.join(
|
||||
__dirname,
|
||||
"../database/migrations/add_button_dataflow_indexes.sql"
|
||||
);
|
||||
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
||||
|
||||
console.log("📖 Reading SQL migration file...");
|
||||
console.log(`📁 File: ${sqlFilePath}\n`);
|
||||
|
||||
// 데이터베이스 연결 확인
|
||||
console.log("🔍 Checking database connection...");
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
console.log("✅ Database connection OK\n");
|
||||
|
||||
// 기존 인덱스 상태 확인
|
||||
console.log("🔍 Checking existing indexes...");
|
||||
const existingIndexes = await prisma.$queryRaw`
|
||||
SELECT indexname, tablename
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'dataflow_diagrams'
|
||||
AND indexname LIKE 'idx_dataflow%'
|
||||
ORDER BY indexname;
|
||||
`;
|
||||
|
||||
if (existingIndexes.length > 0) {
|
||||
console.log("📋 Existing dataflow indexes:");
|
||||
existingIndexes.forEach((idx) => {
|
||||
console.log(` - ${idx.indexname}`);
|
||||
});
|
||||
} else {
|
||||
console.log("📋 No existing dataflow indexes found");
|
||||
}
|
||||
console.log("");
|
||||
|
||||
// 테이블 상태 확인
|
||||
console.log("🔍 Checking dataflow_diagrams table stats...");
|
||||
const tableStats = await prisma.$queryRaw`
|
||||
SELECT
|
||||
COUNT(*) as total_rows,
|
||||
COUNT(*) FILTER (WHERE control IS NOT NULL) as with_control,
|
||||
COUNT(*) FILTER (WHERE plan IS NOT NULL) as with_plan,
|
||||
COUNT(*) FILTER (WHERE category IS NOT NULL) as with_category,
|
||||
COUNT(DISTINCT company_code) as companies
|
||||
FROM dataflow_diagrams;
|
||||
`;
|
||||
|
||||
if (tableStats.length > 0) {
|
||||
const stats = tableStats[0];
|
||||
console.log(`📊 Table Statistics:`);
|
||||
console.log(` - Total rows: ${stats.total_rows}`);
|
||||
console.log(` - With control: ${stats.with_control}`);
|
||||
console.log(` - With plan: ${stats.with_plan}`);
|
||||
console.log(` - With category: ${stats.with_category}`);
|
||||
console.log(` - Companies: ${stats.companies}`);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
// SQL 실행
|
||||
console.log("🚀 Installing performance indexes...");
|
||||
console.log("⏳ This may take a few minutes for large datasets...\n");
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// SQL을 문장별로 나누어 실행 (PostgreSQL 함수 때문에)
|
||||
const sqlStatements = sqlContent
|
||||
.split(/;\s*(?=\n|$)/)
|
||||
.filter(
|
||||
(stmt) =>
|
||||
stmt.trim().length > 0 &&
|
||||
!stmt.trim().startsWith("--") &&
|
||||
!stmt.trim().startsWith("/*")
|
||||
);
|
||||
|
||||
for (let i = 0; i < sqlStatements.length; i++) {
|
||||
const statement = sqlStatements[i].trim();
|
||||
if (statement.length === 0) continue;
|
||||
|
||||
try {
|
||||
// DO 블록이나 복합 문장 처리
|
||||
if (
|
||||
statement.includes("DO $$") ||
|
||||
statement.includes("CREATE OR REPLACE VIEW")
|
||||
) {
|
||||
console.log(
|
||||
`⚡ Executing statement ${i + 1}/${sqlStatements.length}...`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(statement + ";");
|
||||
} else if (statement.startsWith("CREATE INDEX")) {
|
||||
const indexName =
|
||||
statement.match(/CREATE INDEX[^"]*"?([^"\s]+)"?/)?.[1] || "unknown";
|
||||
console.log(`🔧 Creating index: ${indexName}...`);
|
||||
await prisma.$executeRawUnsafe(statement + ";");
|
||||
} else if (statement.startsWith("ANALYZE")) {
|
||||
console.log(`📊 Analyzing table statistics...`);
|
||||
await prisma.$executeRawUnsafe(statement + ";");
|
||||
} else {
|
||||
await prisma.$executeRawUnsafe(statement + ";");
|
||||
}
|
||||
} catch (error) {
|
||||
// 이미 존재하는 인덱스 에러는 무시
|
||||
if (error.message.includes("already exists")) {
|
||||
console.log(`⚠️ Index already exists, skipping...`);
|
||||
} else {
|
||||
console.error(`❌ Error executing statement: ${error.message}`);
|
||||
console.error(`📝 Statement: ${statement.substring(0, 100)}...`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
const executionTime = (endTime - startTime) / 1000;
|
||||
|
||||
console.log(
|
||||
`\n✅ Index installation completed in ${executionTime.toFixed(2)} seconds!`
|
||||
);
|
||||
|
||||
// 설치된 인덱스 확인
|
||||
console.log("\n🔍 Verifying installed indexes...");
|
||||
const newIndexes = await prisma.$queryRaw`
|
||||
SELECT
|
||||
indexname,
|
||||
pg_size_pretty(pg_relation_size(indexrelid)) as size
|
||||
FROM pg_stat_user_indexes
|
||||
WHERE tablename = 'dataflow_diagrams'
|
||||
AND indexname LIKE 'idx_dataflow%'
|
||||
ORDER BY indexname;
|
||||
`;
|
||||
|
||||
if (newIndexes.length > 0) {
|
||||
console.log("📋 Installed indexes:");
|
||||
newIndexes.forEach((idx) => {
|
||||
console.log(` ✅ ${idx.indexname} (${idx.size})`);
|
||||
});
|
||||
}
|
||||
|
||||
// 성능 통계 조회
|
||||
console.log("\n📊 Performance statistics:");
|
||||
try {
|
||||
const perfStats =
|
||||
await prisma.$queryRaw`SELECT * FROM dataflow_performance_stats;`;
|
||||
if (perfStats.length > 0) {
|
||||
const stats = perfStats[0];
|
||||
console.log(` - Table size: ${stats.table_size}`);
|
||||
console.log(` - Total diagrams: ${stats.total_rows}`);
|
||||
console.log(` - With control: ${stats.with_control}`);
|
||||
console.log(` - Companies: ${stats.companies}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(" ⚠️ Performance view not available yet");
|
||||
}
|
||||
|
||||
console.log("\n🎯 Performance Optimization Complete!");
|
||||
console.log("Expected improvements:");
|
||||
console.log(" - Button dataflow lookup: 500ms+ → 10-50ms ⚡");
|
||||
console.log(" - Category filtering: 200ms+ → 5-20ms ⚡");
|
||||
console.log(" - Company queries: 100ms+ → 5-15ms ⚡");
|
||||
|
||||
console.log("\n💡 Monitor performance with:");
|
||||
console.log(" SELECT * FROM dataflow_performance_stats;");
|
||||
console.log(" SELECT * FROM dataflow_index_efficiency;");
|
||||
} catch (error) {
|
||||
console.error("\n❌ Error installing dataflow indexes:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 실행
|
||||
if (require.main === module) {
|
||||
installDataflowIndexes()
|
||||
.then(() => {
|
||||
console.log("\n🎉 Installation completed successfully!");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("\n💥 Installation failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { installDataflowIndexes };
|
||||
@@ -1,46 +0,0 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function getComponents() {
|
||||
try {
|
||||
const components = await prisma.component_standards.findMany({
|
||||
where: { is_active: "Y" },
|
||||
select: {
|
||||
component_code: true,
|
||||
component_name: true,
|
||||
category: true,
|
||||
component_config: true,
|
||||
},
|
||||
orderBy: [{ category: "asc" }, { sort_order: "asc" }],
|
||||
});
|
||||
|
||||
console.log("📋 데이터베이스 컴포넌트 목록:");
|
||||
console.log("=".repeat(60));
|
||||
|
||||
const grouped = components.reduce((acc, comp) => {
|
||||
if (!acc[comp.category]) {
|
||||
acc[comp.category] = [];
|
||||
}
|
||||
acc[comp.category].push(comp);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
Object.entries(grouped).forEach(([category, comps]) => {
|
||||
console.log(`\n🏷️ ${category.toUpperCase()} 카테고리:`);
|
||||
comps.forEach((comp) => {
|
||||
const type = comp.component_config?.type || "unknown";
|
||||
console.log(
|
||||
` - ${comp.component_code}: ${comp.component_name} (type: ${type})`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`\n총 ${components.length}개 컴포넌트 발견`);
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
getComponents();
|
||||
@@ -1,168 +0,0 @@
|
||||
import { query } from "../src/database/db";
|
||||
import { logger } from "../src/utils/logger";
|
||||
|
||||
/**
|
||||
* input_type을 web_type으로 마이그레이션하는 스크립트
|
||||
*
|
||||
* 목적:
|
||||
* - column_labels 테이블의 input_type 값을 읽어서
|
||||
* - 해당하는 기본 web_type 값으로 변환
|
||||
* - web_type이 null인 경우에만 업데이트
|
||||
*/
|
||||
|
||||
// input_type → 기본 web_type 매핑
|
||||
const INPUT_TYPE_TO_WEB_TYPE: Record<string, string> = {
|
||||
text: "text", // 일반 텍스트
|
||||
number: "number", // 정수
|
||||
date: "date", // 날짜
|
||||
code: "code", // 코드 선택박스
|
||||
entity: "entity", // 엔티티 참조
|
||||
select: "select", // 선택박스
|
||||
checkbox: "checkbox", // 체크박스
|
||||
radio: "radio", // 라디오버튼
|
||||
direct: "text", // direct는 text로 매핑
|
||||
};
|
||||
|
||||
async function migrateInputTypeToWebType() {
|
||||
try {
|
||||
logger.info("=".repeat(60));
|
||||
logger.info("input_type → web_type 마이그레이션 시작");
|
||||
logger.info("=".repeat(60));
|
||||
|
||||
// 1. 현재 상태 확인
|
||||
const stats = await query<{
|
||||
total: string;
|
||||
has_input_type: string;
|
||||
has_web_type: string;
|
||||
needs_migration: string;
|
||||
}>(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(input_type) FILTER (WHERE input_type IS NOT NULL) as has_input_type,
|
||||
COUNT(web_type) FILTER (WHERE web_type IS NOT NULL) as has_web_type,
|
||||
COUNT(*) FILTER (WHERE input_type IS NOT NULL AND web_type IS NULL) as needs_migration
|
||||
FROM column_labels`
|
||||
);
|
||||
|
||||
const stat = stats[0];
|
||||
logger.info("\n📊 현재 상태:");
|
||||
logger.info(` - 전체 컬럼: ${stat.total}개`);
|
||||
logger.info(` - input_type 있음: ${stat.has_input_type}개`);
|
||||
logger.info(` - web_type 있음: ${stat.has_web_type}개`);
|
||||
logger.info(` - 마이그레이션 필요: ${stat.needs_migration}개`);
|
||||
|
||||
if (parseInt(stat.needs_migration) === 0) {
|
||||
logger.info("\n✅ 마이그레이션이 필요한 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. input_type별 분포 확인
|
||||
const distribution = await query<{
|
||||
input_type: string;
|
||||
count: string;
|
||||
}>(
|
||||
`SELECT
|
||||
input_type,
|
||||
COUNT(*) as count
|
||||
FROM column_labels
|
||||
WHERE input_type IS NOT NULL AND web_type IS NULL
|
||||
GROUP BY input_type
|
||||
ORDER BY input_type`
|
||||
);
|
||||
|
||||
logger.info("\n📋 input_type별 분포:");
|
||||
distribution.forEach((item) => {
|
||||
const webType =
|
||||
INPUT_TYPE_TO_WEB_TYPE[item.input_type] || item.input_type;
|
||||
logger.info(` - ${item.input_type} → ${webType}: ${item.count}개`);
|
||||
});
|
||||
|
||||
// 3. 마이그레이션 실행
|
||||
logger.info("\n🔄 마이그레이션 실행 중...");
|
||||
|
||||
let totalUpdated = 0;
|
||||
|
||||
for (const [inputType, webType] of Object.entries(INPUT_TYPE_TO_WEB_TYPE)) {
|
||||
const result = await query(
|
||||
`UPDATE column_labels
|
||||
SET
|
||||
web_type = $1,
|
||||
updated_date = NOW()
|
||||
WHERE input_type = $2
|
||||
AND web_type IS NULL
|
||||
RETURNING id, table_name, column_name`,
|
||||
[webType, inputType]
|
||||
);
|
||||
|
||||
if (result.length > 0) {
|
||||
logger.info(
|
||||
` ✓ ${inputType} → ${webType}: ${result.length}개 업데이트`
|
||||
);
|
||||
totalUpdated += result.length;
|
||||
|
||||
// 처음 5개만 출력
|
||||
result.slice(0, 5).forEach((row: any) => {
|
||||
logger.info(` - ${row.table_name}.${row.column_name}`);
|
||||
});
|
||||
if (result.length > 5) {
|
||||
logger.info(` ... 외 ${result.length - 5}개`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 결과 확인
|
||||
const afterStats = await query<{
|
||||
total: string;
|
||||
has_web_type: string;
|
||||
}>(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(web_type) FILTER (WHERE web_type IS NOT NULL) as has_web_type
|
||||
FROM column_labels`
|
||||
);
|
||||
|
||||
const afterStat = afterStats[0];
|
||||
|
||||
logger.info("\n" + "=".repeat(60));
|
||||
logger.info("✅ 마이그레이션 완료!");
|
||||
logger.info("=".repeat(60));
|
||||
logger.info(`📊 최종 통계:`);
|
||||
logger.info(` - 전체 컬럼: ${afterStat.total}개`);
|
||||
logger.info(` - web_type 설정됨: ${afterStat.has_web_type}개`);
|
||||
logger.info(` - 업데이트된 컬럼: ${totalUpdated}개`);
|
||||
logger.info("=".repeat(60));
|
||||
|
||||
// 5. 샘플 데이터 출력
|
||||
logger.info("\n📝 샘플 데이터 (check_report_mng 테이블):");
|
||||
const samples = await query<{
|
||||
column_name: string;
|
||||
input_type: string;
|
||||
web_type: string;
|
||||
detail_settings: string;
|
||||
}>(
|
||||
`SELECT
|
||||
column_name,
|
||||
input_type,
|
||||
web_type,
|
||||
detail_settings
|
||||
FROM column_labels
|
||||
WHERE table_name = 'check_report_mng'
|
||||
ORDER BY column_name
|
||||
LIMIT 10`
|
||||
);
|
||||
|
||||
samples.forEach((sample) => {
|
||||
logger.info(
|
||||
` ${sample.column_name}: ${sample.input_type} → ${sample.web_type}`
|
||||
);
|
||||
});
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error("❌ 마이그레이션 실패:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
migrateInputTypeToWebType();
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* system_notice 테이블 생성 마이그레이션 실행
|
||||
*/
|
||||
const { Pool } = require('pg');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm',
|
||||
ssl: false,
|
||||
});
|
||||
|
||||
async function run() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const sqlPath = path.join(__dirname, '../../db/migrations/1050_create_system_notice.sql');
|
||||
const sql = fs.readFileSync(sqlPath, 'utf8');
|
||||
await client.query(sql);
|
||||
console.log('OK: system_notice 테이블 생성 완료');
|
||||
|
||||
// 검증
|
||||
const result = await client.query(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name='system_notice' ORDER BY ordinal_position"
|
||||
);
|
||||
console.log('컬럼:', result.rows.map(r => r.column_name).join(', '));
|
||||
} catch (e) {
|
||||
console.error('ERROR:', e.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* SQL 마이그레이션 실행 스크립트
|
||||
* 사용법: node scripts/run-migration.js
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Pool } = require('pg');
|
||||
|
||||
// DATABASE_URL에서 연결 정보 파싱
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
||||
|
||||
// 데이터베이스 연결 설정
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
async function runMigration() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🔄 마이그레이션 시작...\n');
|
||||
|
||||
// SQL 파일 읽기 (Docker 컨테이너 내부 경로)
|
||||
const sqlPath = '/tmp/migration.sql';
|
||||
const sql = fs.readFileSync(sqlPath, 'utf8');
|
||||
|
||||
console.log('📄 SQL 파일 로드 완료');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
|
||||
// SQL 실행
|
||||
await client.query(sql);
|
||||
|
||||
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('✅ 마이그레이션 성공적으로 완료되었습니다!');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.error('❌ 마이그레이션 실패:');
|
||||
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.error(error);
|
||||
console.error('\n💡 롤백이 필요한 경우 롤백 스크립트를 실행하세요.');
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
// 실행
|
||||
runMigration();
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* system_notice 마이그레이션 실행 스크립트
|
||||
* 사용법: node scripts/run-notice-migration.js
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm',
|
||||
ssl: false,
|
||||
});
|
||||
|
||||
async function run() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const sqlPath = path.join(__dirname, '../../db/migrations/1050_create_system_notice.sql');
|
||||
const sql = fs.readFileSync(sqlPath, 'utf8');
|
||||
|
||||
console.log('마이그레이션 실행 중...');
|
||||
await client.query(sql);
|
||||
console.log('마이그레이션 완료');
|
||||
|
||||
// 컬럼 확인
|
||||
const check = await client.query(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name='system_notice' ORDER BY ordinal_position"
|
||||
);
|
||||
console.log('테이블 컬럼:', check.rows.map(r => r.column_name).join(', '));
|
||||
} catch (e) {
|
||||
console.error('오류:', e.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -1,294 +0,0 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 기본 템플릿 데이터 정의
|
||||
const defaultTemplates = [
|
||||
{
|
||||
template_code: "advanced-data-table-v2",
|
||||
template_name: "고급 데이터 테이블 v2",
|
||||
template_name_eng: "Advanced Data Table v2",
|
||||
description:
|
||||
"검색, 필터링, 페이징, CRUD 기능이 포함된 완전한 데이터 테이블 컴포넌트",
|
||||
category: "table",
|
||||
icon_name: "table",
|
||||
default_size: {
|
||||
width: 1000,
|
||||
height: 680,
|
||||
},
|
||||
layout_config: {
|
||||
components: [
|
||||
{
|
||||
type: "datatable",
|
||||
label: "고급 데이터 테이블",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 1000, height: 680 },
|
||||
style: {
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "#ffffff",
|
||||
padding: "0",
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
id: "id",
|
||||
label: "ID",
|
||||
type: "number",
|
||||
visible: true,
|
||||
sortable: true,
|
||||
filterable: false,
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
label: "이름",
|
||||
type: "text",
|
||||
visible: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
label: "이메일",
|
||||
type: "email",
|
||||
visible: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
label: "상태",
|
||||
type: "select",
|
||||
visible: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
id: "created_date",
|
||||
label: "생성일",
|
||||
type: "date",
|
||||
visible: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 120,
|
||||
},
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
id: "status",
|
||||
label: "상태",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "전체", value: "" },
|
||||
{ label: "활성", value: "active" },
|
||||
{ label: "비활성", value: "inactive" },
|
||||
],
|
||||
},
|
||||
{ id: "name", label: "이름", type: "text" },
|
||||
{ id: "email", label: "이메일", type: "text" },
|
||||
],
|
||||
pagination: {
|
||||
enabled: true,
|
||||
pageSize: 10,
|
||||
pageSizeOptions: [5, 10, 20, 50, 100],
|
||||
showPageSizeSelector: true,
|
||||
showPageInfo: true,
|
||||
showFirstLast: true,
|
||||
},
|
||||
actions: {
|
||||
showSearchButton: true,
|
||||
searchButtonText: "검색",
|
||||
enableExport: true,
|
||||
enableRefresh: true,
|
||||
enableAdd: true,
|
||||
enableEdit: true,
|
||||
enableDelete: true,
|
||||
addButtonText: "추가",
|
||||
editButtonText: "수정",
|
||||
deleteButtonText: "삭제",
|
||||
},
|
||||
addModalConfig: {
|
||||
title: "새 데이터 추가",
|
||||
description: "테이블에 새로운 데이터를 추가합니다.",
|
||||
width: "lg",
|
||||
layout: "two-column",
|
||||
gridColumns: 2,
|
||||
fieldOrder: ["name", "email", "status"],
|
||||
requiredFields: ["name", "email"],
|
||||
hiddenFields: ["id", "created_date"],
|
||||
advancedFieldConfigs: {
|
||||
status: {
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "활성", value: "active" },
|
||||
{ label: "비활성", value: "inactive" },
|
||||
],
|
||||
},
|
||||
},
|
||||
submitButtonText: "추가",
|
||||
cancelButtonText: "취소",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
sort_order: 1,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "*",
|
||||
created_by: "system",
|
||||
updated_by: "system",
|
||||
},
|
||||
{
|
||||
template_code: "universal-button",
|
||||
template_name: "범용 버튼",
|
||||
template_name_eng: "Universal Button",
|
||||
description:
|
||||
"다양한 기능을 설정할 수 있는 범용 버튼. 상세설정에서 기능을 선택하세요.",
|
||||
category: "button",
|
||||
icon_name: "mouse-pointer",
|
||||
default_size: {
|
||||
width: 80,
|
||||
height: 36,
|
||||
},
|
||||
layout_config: {
|
||||
components: [
|
||||
{
|
||||
type: "widget",
|
||||
widgetType: "button",
|
||||
label: "버튼",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 80, height: 36 },
|
||||
style: {
|
||||
backgroundColor: "#3b82f6",
|
||||
color: "#ffffff",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
sort_order: 2,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "*",
|
||||
created_by: "system",
|
||||
updated_by: "system",
|
||||
},
|
||||
{
|
||||
template_code: "file-upload",
|
||||
template_name: "파일 첨부",
|
||||
template_name_eng: "File Upload",
|
||||
description: "드래그앤드롭 파일 업로드 영역",
|
||||
category: "file",
|
||||
icon_name: "upload",
|
||||
default_size: {
|
||||
width: 300,
|
||||
height: 120,
|
||||
},
|
||||
layout_config: {
|
||||
components: [
|
||||
{
|
||||
type: "widget",
|
||||
widgetType: "file",
|
||||
label: "파일 첨부",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 300, height: 120 },
|
||||
style: {
|
||||
border: "2px dashed #d1d5db",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "#f9fafb",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "14px",
|
||||
color: "#6b7280",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
sort_order: 3,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "*",
|
||||
created_by: "system",
|
||||
updated_by: "system",
|
||||
},
|
||||
{
|
||||
template_code: "form-container",
|
||||
template_name: "폼 컨테이너",
|
||||
template_name_eng: "Form Container",
|
||||
description: "입력 폼을 위한 기본 컨테이너 레이아웃",
|
||||
category: "form",
|
||||
icon_name: "form",
|
||||
default_size: {
|
||||
width: 400,
|
||||
height: 300,
|
||||
},
|
||||
layout_config: {
|
||||
components: [
|
||||
{
|
||||
type: "container",
|
||||
label: "폼 컨테이너",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 400, height: 300 },
|
||||
style: {
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "#ffffff",
|
||||
padding: "16px",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
sort_order: 4,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "*",
|
||||
created_by: "system",
|
||||
updated_by: "system",
|
||||
},
|
||||
];
|
||||
|
||||
async function seedTemplates() {
|
||||
console.log("🌱 템플릿 시드 데이터 삽입 시작...");
|
||||
|
||||
try {
|
||||
// 기존 템플릿이 있는지 확인하고 없는 경우에만 삽입
|
||||
for (const template of defaultTemplates) {
|
||||
const existing = await prisma.template_standards.findUnique({
|
||||
where: { template_code: template.template_code },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await prisma.template_standards.create({
|
||||
data: template,
|
||||
});
|
||||
console.log(`✅ 템플릿 '${template.template_name}' 생성됨`);
|
||||
} else {
|
||||
console.log(`⏭️ 템플릿 '${template.template_name}' 이미 존재함`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🎉 템플릿 시드 데이터 삽입 완료!");
|
||||
} catch (error) {
|
||||
console.error("❌ 템플릿 시드 데이터 삽입 실패:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트가 직접 실행될 때만 시드 함수 실행
|
||||
if (require.main === module) {
|
||||
seedTemplates().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { seedTemplates };
|
||||
@@ -1,411 +0,0 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 실제 UI 구성에 필요한 컴포넌트들
|
||||
const uiComponents = [
|
||||
// === 액션 컴포넌트 ===
|
||||
{
|
||||
component_code: "button-primary",
|
||||
component_name: "기본 버튼",
|
||||
component_name_eng: "Primary Button",
|
||||
description: "일반적인 액션을 위한 기본 버튼 컴포넌트",
|
||||
category: "action",
|
||||
icon_name: "MousePointer",
|
||||
default_size: { width: 100, height: 36 },
|
||||
component_config: {
|
||||
type: "button",
|
||||
variant: "primary",
|
||||
text: "버튼",
|
||||
action: "custom",
|
||||
style: {
|
||||
backgroundColor: "#3b82f6",
|
||||
color: "#ffffff",
|
||||
borderRadius: "6px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
},
|
||||
},
|
||||
sort_order: 10,
|
||||
},
|
||||
{
|
||||
component_code: "button-secondary",
|
||||
component_name: "보조 버튼",
|
||||
component_name_eng: "Secondary Button",
|
||||
description: "보조 액션을 위한 버튼 컴포넌트",
|
||||
category: "action",
|
||||
icon_name: "MousePointer",
|
||||
default_size: { width: 100, height: 36 },
|
||||
component_config: {
|
||||
type: "button",
|
||||
variant: "secondary",
|
||||
text: "취소",
|
||||
action: "cancel",
|
||||
style: {
|
||||
backgroundColor: "#f1f5f9",
|
||||
color: "#475569",
|
||||
borderRadius: "6px",
|
||||
fontSize: "14px",
|
||||
},
|
||||
},
|
||||
sort_order: 11,
|
||||
},
|
||||
|
||||
// === 레이아웃 컴포넌트 ===
|
||||
{
|
||||
component_code: "card-basic",
|
||||
component_name: "기본 카드",
|
||||
component_name_eng: "Basic Card",
|
||||
description: "정보를 그룹화하는 기본 카드 컴포넌트",
|
||||
category: "layout",
|
||||
icon_name: "Square",
|
||||
default_size: { width: 400, height: 300 },
|
||||
component_config: {
|
||||
type: "card",
|
||||
title: "카드 제목",
|
||||
showHeader: true,
|
||||
showFooter: false,
|
||||
style: {
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "8px",
|
||||
padding: "16px",
|
||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.1)",
|
||||
},
|
||||
},
|
||||
sort_order: 20,
|
||||
},
|
||||
{
|
||||
component_code: "dashboard-grid",
|
||||
component_name: "대시보드 그리드",
|
||||
component_name_eng: "Dashboard Grid",
|
||||
description: "대시보드를 위한 그리드 레이아웃 컴포넌트",
|
||||
category: "layout",
|
||||
icon_name: "LayoutGrid",
|
||||
default_size: { width: 800, height: 600 },
|
||||
component_config: {
|
||||
type: "dashboard",
|
||||
columns: 3,
|
||||
gap: 16,
|
||||
items: [],
|
||||
style: {
|
||||
backgroundColor: "#f8fafc",
|
||||
padding: "20px",
|
||||
borderRadius: "8px",
|
||||
},
|
||||
},
|
||||
sort_order: 21,
|
||||
},
|
||||
{
|
||||
component_code: "panel-collapsible",
|
||||
component_name: "접을 수 있는 패널",
|
||||
component_name_eng: "Collapsible Panel",
|
||||
description: "접고 펼칠 수 있는 패널 컴포넌트",
|
||||
category: "layout",
|
||||
icon_name: "ChevronDown",
|
||||
default_size: { width: 500, height: 200 },
|
||||
component_config: {
|
||||
type: "panel",
|
||||
title: "패널 제목",
|
||||
collapsible: true,
|
||||
defaultExpanded: true,
|
||||
style: {
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "8px",
|
||||
},
|
||||
},
|
||||
sort_order: 22,
|
||||
},
|
||||
|
||||
// === 데이터 표시 컴포넌트 ===
|
||||
{
|
||||
component_code: "stats-card",
|
||||
component_name: "통계 카드",
|
||||
component_name_eng: "Statistics Card",
|
||||
description: "수치와 통계를 표시하는 카드 컴포넌트",
|
||||
category: "data",
|
||||
icon_name: "BarChart3",
|
||||
default_size: { width: 250, height: 120 },
|
||||
component_config: {
|
||||
type: "stats",
|
||||
title: "총 판매량",
|
||||
value: "1,234",
|
||||
unit: "개",
|
||||
trend: "up",
|
||||
percentage: "+12.5%",
|
||||
style: {
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "8px",
|
||||
padding: "20px",
|
||||
},
|
||||
},
|
||||
sort_order: 30,
|
||||
},
|
||||
{
|
||||
component_code: "progress-bar",
|
||||
component_name: "진행률 표시",
|
||||
component_name_eng: "Progress Bar",
|
||||
description: "작업 진행률을 표시하는 컴포넌트",
|
||||
category: "data",
|
||||
icon_name: "BarChart2",
|
||||
default_size: { width: 300, height: 60 },
|
||||
component_config: {
|
||||
type: "progress",
|
||||
label: "진행률",
|
||||
value: 65,
|
||||
max: 100,
|
||||
showPercentage: true,
|
||||
style: {
|
||||
backgroundColor: "#f1f5f9",
|
||||
borderRadius: "4px",
|
||||
height: "8px",
|
||||
},
|
||||
},
|
||||
sort_order: 31,
|
||||
},
|
||||
{
|
||||
component_code: "chart-basic",
|
||||
component_name: "기본 차트",
|
||||
component_name_eng: "Basic Chart",
|
||||
description: "데이터를 시각화하는 기본 차트 컴포넌트",
|
||||
category: "data",
|
||||
icon_name: "TrendingUp",
|
||||
default_size: { width: 500, height: 300 },
|
||||
component_config: {
|
||||
type: "chart",
|
||||
chartType: "line",
|
||||
title: "차트 제목",
|
||||
data: [],
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: "top" },
|
||||
},
|
||||
},
|
||||
},
|
||||
sort_order: 32,
|
||||
},
|
||||
|
||||
// === 네비게이션 컴포넌트 ===
|
||||
{
|
||||
component_code: "breadcrumb",
|
||||
component_name: "브레드크럼",
|
||||
component_name_eng: "Breadcrumb",
|
||||
description: "현재 위치를 표시하는 네비게이션 컴포넌트",
|
||||
category: "navigation",
|
||||
icon_name: "ChevronRight",
|
||||
default_size: { width: 400, height: 32 },
|
||||
component_config: {
|
||||
type: "breadcrumb",
|
||||
items: [
|
||||
{ label: "홈", href: "/" },
|
||||
{ label: "관리자", href: "/admin" },
|
||||
{ label: "현재 페이지" },
|
||||
],
|
||||
separator: ">",
|
||||
},
|
||||
sort_order: 40,
|
||||
},
|
||||
{
|
||||
component_code: "tabs-horizontal",
|
||||
component_name: "가로 탭",
|
||||
component_name_eng: "Horizontal Tabs",
|
||||
description: "컨텐츠를 탭으로 구분하는 네비게이션 컴포넌트",
|
||||
category: "navigation",
|
||||
icon_name: "Tabs",
|
||||
default_size: { width: 500, height: 300 },
|
||||
component_config: {
|
||||
type: "tabs",
|
||||
orientation: "horizontal",
|
||||
tabs: [
|
||||
{ id: "tab1", label: "탭 1", content: "첫 번째 탭 내용" },
|
||||
{ id: "tab2", label: "탭 2", content: "두 번째 탭 내용" },
|
||||
],
|
||||
defaultTab: "tab1",
|
||||
},
|
||||
sort_order: 41,
|
||||
},
|
||||
{
|
||||
component_code: "pagination",
|
||||
component_name: "페이지네이션",
|
||||
component_name_eng: "Pagination",
|
||||
description: "페이지를 나눠서 표시하는 네비게이션 컴포넌트",
|
||||
category: "navigation",
|
||||
icon_name: "ChevronLeft",
|
||||
default_size: { width: 300, height: 40 },
|
||||
component_config: {
|
||||
type: "pagination",
|
||||
currentPage: 1,
|
||||
totalPages: 10,
|
||||
showFirst: true,
|
||||
showLast: true,
|
||||
showPrevNext: true,
|
||||
},
|
||||
sort_order: 42,
|
||||
},
|
||||
|
||||
// === 피드백 컴포넌트 ===
|
||||
{
|
||||
component_code: "alert-info",
|
||||
component_name: "정보 알림",
|
||||
component_name_eng: "Info Alert",
|
||||
description: "정보를 사용자에게 알리는 컴포넌트",
|
||||
category: "feedback",
|
||||
icon_name: "Info",
|
||||
default_size: { width: 400, height: 60 },
|
||||
component_config: {
|
||||
type: "alert",
|
||||
variant: "info",
|
||||
title: "알림",
|
||||
message: "중요한 정보를 확인해주세요.",
|
||||
dismissible: true,
|
||||
icon: true,
|
||||
},
|
||||
sort_order: 50,
|
||||
},
|
||||
{
|
||||
component_code: "badge-status",
|
||||
component_name: "상태 뱃지",
|
||||
component_name_eng: "Status Badge",
|
||||
description: "상태나 카테고리를 표시하는 뱃지 컴포넌트",
|
||||
category: "feedback",
|
||||
icon_name: "Tag",
|
||||
default_size: { width: 80, height: 24 },
|
||||
component_config: {
|
||||
type: "badge",
|
||||
text: "활성",
|
||||
variant: "success",
|
||||
size: "sm",
|
||||
style: {
|
||||
backgroundColor: "#10b981",
|
||||
color: "#ffffff",
|
||||
borderRadius: "12px",
|
||||
fontSize: "12px",
|
||||
},
|
||||
},
|
||||
sort_order: 51,
|
||||
},
|
||||
{
|
||||
component_code: "loading-spinner",
|
||||
component_name: "로딩 스피너",
|
||||
component_name_eng: "Loading Spinner",
|
||||
description: "로딩 상태를 표시하는 스피너 컴포넌트",
|
||||
category: "feedback",
|
||||
icon_name: "RefreshCw",
|
||||
default_size: { width: 100, height: 100 },
|
||||
component_config: {
|
||||
type: "loading",
|
||||
variant: "spinner",
|
||||
size: "md",
|
||||
message: "로딩 중...",
|
||||
overlay: false,
|
||||
},
|
||||
sort_order: 52,
|
||||
},
|
||||
|
||||
// === 입력 컴포넌트 ===
|
||||
{
|
||||
component_code: "search-box",
|
||||
component_name: "검색 박스",
|
||||
component_name_eng: "Search Box",
|
||||
description: "검색 기능이 있는 입력 컴포넌트",
|
||||
category: "input",
|
||||
icon_name: "Search",
|
||||
default_size: { width: 300, height: 40 },
|
||||
component_config: {
|
||||
type: "search",
|
||||
placeholder: "검색어를 입력하세요...",
|
||||
showButton: true,
|
||||
debounce: 500,
|
||||
style: {
|
||||
borderRadius: "20px",
|
||||
border: "1px solid #d1d5db",
|
||||
},
|
||||
},
|
||||
sort_order: 60,
|
||||
},
|
||||
{
|
||||
component_code: "filter-dropdown",
|
||||
component_name: "필터 드롭다운",
|
||||
component_name_eng: "Filter Dropdown",
|
||||
description: "데이터 필터링을 위한 드롭다운 컴포넌트",
|
||||
category: "input",
|
||||
icon_name: "Filter",
|
||||
default_size: { width: 200, height: 40 },
|
||||
component_config: {
|
||||
type: "filter",
|
||||
label: "필터",
|
||||
options: [
|
||||
{ value: "all", label: "전체" },
|
||||
{ value: "active", label: "활성" },
|
||||
{ value: "inactive", label: "비활성" },
|
||||
],
|
||||
defaultValue: "all",
|
||||
multiple: false,
|
||||
},
|
||||
sort_order: 61,
|
||||
},
|
||||
];
|
||||
|
||||
async function seedUIComponents() {
|
||||
try {
|
||||
console.log("🚀 UI 컴포넌트 시딩 시작...");
|
||||
|
||||
// 기존 데이터 삭제
|
||||
console.log("📝 기존 컴포넌트 데이터 삭제 중...");
|
||||
await prisma.$executeRaw`DELETE FROM component_standards`;
|
||||
|
||||
// 새 컴포넌트 데이터 삽입
|
||||
console.log("📦 새로운 UI 컴포넌트 삽입 중...");
|
||||
|
||||
for (const component of uiComponents) {
|
||||
await prisma.component_standards.create({
|
||||
data: {
|
||||
...component,
|
||||
company_code: "DEFAULT",
|
||||
created_by: "system",
|
||||
updated_by: "system",
|
||||
},
|
||||
});
|
||||
console.log(`✅ ${component.component_name} 컴포넌트 생성됨`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n🎉 총 ${uiComponents.length}개의 UI 컴포넌트가 성공적으로 생성되었습니다!`
|
||||
);
|
||||
|
||||
// 카테고리별 통계
|
||||
const categoryCounts = {};
|
||||
uiComponents.forEach((component) => {
|
||||
categoryCounts[component.category] =
|
||||
(categoryCounts[component.category] || 0) + 1;
|
||||
});
|
||||
|
||||
console.log("\n📊 카테고리별 컴포넌트 수:");
|
||||
Object.entries(categoryCounts).forEach(([category, count]) => {
|
||||
console.log(` ${category}: ${count}개`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ UI 컴포넌트 시딩 실패:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 실행
|
||||
if (require.main === module) {
|
||||
seedUIComponents()
|
||||
.then(() => {
|
||||
console.log("✨ UI 컴포넌트 시딩 완료!");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("💥 시딩 실패:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { seedUIComponents, uiComponents };
|
||||
@@ -1,209 +0,0 @@
|
||||
/**
|
||||
* 디지털 트윈 외부 DB (DO_DY) 연결 및 쿼리 테스트 스크립트
|
||||
* READ-ONLY: SELECT 쿼리만 실행
|
||||
*/
|
||||
|
||||
import { Pool } from "pg";
|
||||
import mysql from "mysql2/promise";
|
||||
import { CredentialEncryption } from "../src/utils/credentialEncryption";
|
||||
|
||||
async function testDigitalTwinDb() {
|
||||
// 내부 DB 연결 (연결 정보 저장용)
|
||||
const internalPool = new Pool({
|
||||
host: process.env.DB_HOST || "localhost",
|
||||
port: parseInt(process.env.DB_PORT || "5432"),
|
||||
database: process.env.DB_NAME || "plm",
|
||||
user: process.env.DB_USER || "postgres",
|
||||
password: process.env.DB_PASSWORD || "ph0909!!",
|
||||
});
|
||||
|
||||
const encryptionKey =
|
||||
process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development";
|
||||
const encryption = new CredentialEncryption(encryptionKey);
|
||||
|
||||
try {
|
||||
console.log("🚀 디지털 트윈 외부 DB 연결 테스트 시작\n");
|
||||
|
||||
// 디지털 트윈 외부 DB 연결 정보
|
||||
const digitalTwinConnection = {
|
||||
name: "디지털트윈_DO_DY",
|
||||
description: "디지털 트윈 후판(자재) 재고 정보 데이터베이스 (MariaDB)",
|
||||
dbType: "mysql", // MariaDB는 MySQL 프로토콜 사용
|
||||
host: "1.240.13.83",
|
||||
port: 4307,
|
||||
databaseName: "DO_DY",
|
||||
username: "root",
|
||||
password: "pohangms619!#",
|
||||
sslEnabled: false,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
console.log("📝 연결 정보:");
|
||||
console.log(` - 이름: ${digitalTwinConnection.name}`);
|
||||
console.log(` - DB 타입: ${digitalTwinConnection.dbType}`);
|
||||
console.log(` - 호스트: ${digitalTwinConnection.host}:${digitalTwinConnection.port}`);
|
||||
console.log(` - 데이터베이스: ${digitalTwinConnection.databaseName}\n`);
|
||||
|
||||
// 1. 외부 DB 직접 연결 테스트
|
||||
console.log("🔍 외부 DB 직접 연결 테스트 중...");
|
||||
|
||||
const externalConnection = await mysql.createConnection({
|
||||
host: digitalTwinConnection.host,
|
||||
port: digitalTwinConnection.port,
|
||||
database: digitalTwinConnection.databaseName,
|
||||
user: digitalTwinConnection.username,
|
||||
password: digitalTwinConnection.password,
|
||||
connectTimeout: 10000,
|
||||
});
|
||||
|
||||
console.log("✅ 외부 DB 연결 성공!\n");
|
||||
|
||||
// 2. SELECT 쿼리 실행
|
||||
console.log("📊 WSTKKY 테이블 쿼리 실행 중...\n");
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
SKUMKEY -- 제품번호
|
||||
, SKUDESC -- 자재명
|
||||
, SKUTHIC -- 두께
|
||||
, SKUWIDT -- 폭
|
||||
, SKULENG -- 길이
|
||||
, SKUWEIG -- 중량
|
||||
, STOTQTY -- 수량
|
||||
, SUOMKEY -- 단위
|
||||
FROM DO_DY.WSTKKY
|
||||
LIMIT 10
|
||||
`;
|
||||
|
||||
const [rows] = await externalConnection.execute(query);
|
||||
|
||||
console.log("✅ 쿼리 실행 성공!\n");
|
||||
console.log(`📦 조회된 데이터: ${Array.isArray(rows) ? rows.length : 0}건\n`);
|
||||
|
||||
if (Array.isArray(rows) && rows.length > 0) {
|
||||
console.log("🔍 샘플 데이터 (첫 3건):\n");
|
||||
rows.slice(0, 3).forEach((row: any, index: number) => {
|
||||
console.log(`[${index + 1}]`);
|
||||
console.log(` 제품번호(SKUMKEY): ${row.SKUMKEY}`);
|
||||
console.log(` 자재명(SKUDESC): ${row.SKUDESC}`);
|
||||
console.log(` 두께(SKUTHIC): ${row.SKUTHIC}`);
|
||||
console.log(` 폭(SKUWIDT): ${row.SKUWIDT}`);
|
||||
console.log(` 길이(SKULENG): ${row.SKULENG}`);
|
||||
console.log(` 중량(SKUWEIG): ${row.SKUWEIG}`);
|
||||
console.log(` 수량(STOTQTY): ${row.STOTQTY}`);
|
||||
console.log(` 단위(SUOMKEY): ${row.SUOMKEY}\n`);
|
||||
});
|
||||
|
||||
// 전체 데이터 JSON 출력
|
||||
console.log("📄 전체 데이터 (JSON):");
|
||||
console.log(JSON.stringify(rows, null, 2));
|
||||
console.log("\n");
|
||||
}
|
||||
|
||||
await externalConnection.end();
|
||||
|
||||
// 3. 내부 DB에 연결 정보 저장
|
||||
console.log("💾 내부 DB에 연결 정보 저장 중...");
|
||||
|
||||
const encryptedPassword = encryption.encrypt(digitalTwinConnection.password);
|
||||
|
||||
// 중복 체크
|
||||
const existingResult = await internalPool.query(
|
||||
"SELECT id FROM flow_external_db_connection WHERE name = $1",
|
||||
[digitalTwinConnection.name]
|
||||
);
|
||||
|
||||
let connectionId: number;
|
||||
|
||||
if (existingResult.rows.length > 0) {
|
||||
connectionId = existingResult.rows[0].id;
|
||||
console.log(`⚠️ 이미 존재하는 연결 (ID: ${connectionId})`);
|
||||
|
||||
// 기존 연결 업데이트
|
||||
await internalPool.query(
|
||||
`UPDATE flow_external_db_connection
|
||||
SET description = $1,
|
||||
db_type = $2,
|
||||
host = $3,
|
||||
port = $4,
|
||||
database_name = $5,
|
||||
username = $6,
|
||||
password_encrypted = $7,
|
||||
ssl_enabled = $8,
|
||||
is_active = $9,
|
||||
updated_at = NOW(),
|
||||
updated_by = 'system'
|
||||
WHERE name = $10`,
|
||||
[
|
||||
digitalTwinConnection.description,
|
||||
digitalTwinConnection.dbType,
|
||||
digitalTwinConnection.host,
|
||||
digitalTwinConnection.port,
|
||||
digitalTwinConnection.databaseName,
|
||||
digitalTwinConnection.username,
|
||||
encryptedPassword,
|
||||
digitalTwinConnection.sslEnabled,
|
||||
digitalTwinConnection.isActive,
|
||||
digitalTwinConnection.name,
|
||||
]
|
||||
);
|
||||
console.log(`✅ 연결 정보 업데이트 완료`);
|
||||
} else {
|
||||
// 새 연결 추가
|
||||
const result = await internalPool.query(
|
||||
`INSERT INTO flow_external_db_connection (
|
||||
name,
|
||||
description,
|
||||
db_type,
|
||||
host,
|
||||
port,
|
||||
database_name,
|
||||
username,
|
||||
password_encrypted,
|
||||
ssl_enabled,
|
||||
is_active,
|
||||
created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'system')
|
||||
RETURNING id`,
|
||||
[
|
||||
digitalTwinConnection.name,
|
||||
digitalTwinConnection.description,
|
||||
digitalTwinConnection.dbType,
|
||||
digitalTwinConnection.host,
|
||||
digitalTwinConnection.port,
|
||||
digitalTwinConnection.databaseName,
|
||||
digitalTwinConnection.username,
|
||||
encryptedPassword,
|
||||
digitalTwinConnection.sslEnabled,
|
||||
digitalTwinConnection.isActive,
|
||||
]
|
||||
);
|
||||
connectionId = result.rows[0].id;
|
||||
console.log(`✅ 새 연결 추가 완료 (ID: ${connectionId})`);
|
||||
}
|
||||
|
||||
console.log("\n✅ 모든 테스트 완료!");
|
||||
console.log(`\n📌 연결 ID: ${connectionId}`);
|
||||
console.log(" 이 ID를 사용하여 플로우 관리나 제어 관리에서 외부 DB를 연동할 수 있습니다.");
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("\n❌ 오류 발생:", error.message);
|
||||
console.error("상세 정보:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
await internalPool.end();
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
testDigitalTwinDb()
|
||||
.then(() => {
|
||||
console.log("\n🎉 스크립트 완료");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("\n💥 스크립트 실패:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function testTemplateCreation() {
|
||||
console.log("🧪 템플릿 생성 테스트 시작...");
|
||||
|
||||
try {
|
||||
// 1. 테이블 존재 여부 확인
|
||||
console.log("1. 템플릿 테이블 존재 여부 확인 중...");
|
||||
|
||||
try {
|
||||
const count = await prisma.template_standards.count();
|
||||
console.log(`✅ template_standards 테이블 발견 (현재 ${count}개 레코드)`);
|
||||
} catch (error) {
|
||||
if (error.code === "P2021") {
|
||||
console.log("❌ template_standards 테이블이 존재하지 않습니다.");
|
||||
console.log("👉 데이터베이스 마이그레이션이 필요합니다.");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 2. 샘플 템플릿 생성 테스트
|
||||
console.log("2. 샘플 템플릿 생성 중...");
|
||||
|
||||
const sampleTemplate = {
|
||||
template_code: "test-button-" + Date.now(),
|
||||
template_name: "테스트 버튼",
|
||||
template_name_eng: "Test Button",
|
||||
description: "테스트용 버튼 템플릿",
|
||||
category: "button",
|
||||
icon_name: "mouse-pointer",
|
||||
default_size: {
|
||||
width: 80,
|
||||
height: 36,
|
||||
},
|
||||
layout_config: {
|
||||
components: [
|
||||
{
|
||||
type: "widget",
|
||||
widgetType: "button",
|
||||
label: "테스트 버튼",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 80, height: 36 },
|
||||
style: {
|
||||
backgroundColor: "#3b82f6",
|
||||
color: "#ffffff",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
sort_order: 999,
|
||||
is_active: "Y",
|
||||
is_public: "Y",
|
||||
company_code: "*",
|
||||
created_by: "test",
|
||||
updated_by: "test",
|
||||
};
|
||||
|
||||
const created = await prisma.template_standards.create({
|
||||
data: sampleTemplate,
|
||||
});
|
||||
|
||||
console.log("✅ 샘플 템플릿 생성 성공:", created.template_code);
|
||||
|
||||
// 3. 생성된 템플릿 조회 테스트
|
||||
console.log("3. 템플릿 조회 테스트 중...");
|
||||
|
||||
const retrieved = await prisma.template_standards.findUnique({
|
||||
where: { template_code: created.template_code },
|
||||
});
|
||||
|
||||
if (retrieved) {
|
||||
console.log("✅ 템플릿 조회 성공:", retrieved.template_name);
|
||||
console.log(
|
||||
"📄 Layout Config:",
|
||||
JSON.stringify(retrieved.layout_config, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 카테고리 목록 조회 테스트
|
||||
console.log("4. 카테고리 목록 조회 테스트 중...");
|
||||
|
||||
const categories = await prisma.template_standards.findMany({
|
||||
where: { is_active: "Y" },
|
||||
select: { category: true },
|
||||
distinct: ["category"],
|
||||
});
|
||||
|
||||
console.log(
|
||||
"✅ 발견된 카테고리:",
|
||||
categories.map((c) => c.category)
|
||||
);
|
||||
|
||||
// 5. 테스트 데이터 정리
|
||||
console.log("5. 테스트 데이터 정리 중...");
|
||||
|
||||
await prisma.template_standards.delete({
|
||||
where: { template_code: created.template_code },
|
||||
});
|
||||
|
||||
console.log("✅ 테스트 데이터 정리 완료");
|
||||
|
||||
console.log("🎉 모든 테스트 통과!");
|
||||
} catch (error) {
|
||||
console.error("❌ 테스트 실패:", error);
|
||||
console.error("📋 상세 정보:", {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
stack: error.stack?.split("\n").slice(0, 5),
|
||||
});
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
testTemplateCreation();
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* 마이그레이션 검증 스크립트
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
async function verifyMigration() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🔍 마이그레이션 결과 검증 중...\n');
|
||||
|
||||
// 전체 요소 수
|
||||
const total = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements
|
||||
`);
|
||||
|
||||
// 새로운 subtype별 개수
|
||||
const mapV2 = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'map-summary-v2'
|
||||
`);
|
||||
|
||||
const chart = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'chart'
|
||||
`);
|
||||
|
||||
const listV2 = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'list-v2'
|
||||
`);
|
||||
|
||||
const metricV2 = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'custom-metric-v2'
|
||||
`);
|
||||
|
||||
const alertV2 = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'risk-alert-v2'
|
||||
`);
|
||||
|
||||
// 테스트 subtype 남아있는지 확인
|
||||
const remaining = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype LIKE '%-test%'
|
||||
`);
|
||||
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('📊 마이그레이션 결과 요약');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log(`전체 요소 수: ${total.rows[0].count}`);
|
||||
console.log(`map-summary-v2: ${mapV2.rows[0].count}`);
|
||||
console.log(`chart: ${chart.rows[0].count}`);
|
||||
console.log(`list-v2: ${listV2.rows[0].count}`);
|
||||
console.log(`custom-metric-v2: ${metricV2.rows[0].count}`);
|
||||
console.log(`risk-alert-v2: ${alertV2.rows[0].count}`);
|
||||
console.log('');
|
||||
|
||||
if (parseInt(remaining.rows[0].count) > 0) {
|
||||
console.log(`⚠️ 테스트 subtype이 ${remaining.rows[0].count}개 남아있습니다!`);
|
||||
} else {
|
||||
console.log('✅ 모든 테스트 subtype이 정상적으로 변경되었습니다!');
|
||||
}
|
||||
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('');
|
||||
console.log('🎉 마이그레이션이 성공적으로 완료되었습니다!');
|
||||
console.log('');
|
||||
console.log('다음 단계:');
|
||||
console.log('1. 프론트엔드 애플리케이션을 새로고침하세요');
|
||||
console.log('2. 대시보드를 열어 위젯이 정상적으로 작동하는지 확인하세요');
|
||||
console.log('3. 문제가 발생하면 백업에서 복원하세요');
|
||||
console.log('');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 오류 발생:', error.message);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
verifyMigration();
|
||||
|
||||
@@ -1,448 +0,0 @@
|
||||
import "dotenv/config";
|
||||
process.env.TZ = "Asia/Seoul";
|
||||
import "express-async-errors"; // async 라우트 핸들러의 에러를 Express 에러 핸들러로 자동 전달
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import helmet from "helmet";
|
||||
import compression from "compression";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import path from "path";
|
||||
import config from "./config/environment";
|
||||
import { logger } from "./utils/logger";
|
||||
import { errorHandler } from "./middleware/errorHandler";
|
||||
import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
|
||||
|
||||
// ============================================
|
||||
// 프로세스 레벨 예외 처리 (서버 크래시 방지)
|
||||
// ============================================
|
||||
|
||||
// 처리되지 않은 Promise 거부 핸들러
|
||||
process.on(
|
||||
"unhandledRejection",
|
||||
(reason: Error | any, promise: Promise<any>) => {
|
||||
logger.error("⚠️ Unhandled Promise Rejection:", {
|
||||
reason: reason?.message || reason,
|
||||
stack: reason?.stack,
|
||||
});
|
||||
// 프로세스를 종료하지 않고 로깅만 수행
|
||||
// 심각한 에러의 경우 graceful shutdown 고려
|
||||
},
|
||||
);
|
||||
|
||||
// 처리되지 않은 예외 핸들러
|
||||
process.on("uncaughtException", (error: Error) => {
|
||||
logger.error("🔥 Uncaught Exception:", {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
// 예외 발생 후에도 서버를 유지하되, 상태가 불안정할 수 있으므로 주의
|
||||
// 심각한 에러의 경우 graceful shutdown 후 재시작 권장
|
||||
});
|
||||
|
||||
// SIGTERM 시그널 처리 (Docker/Kubernetes 환경)
|
||||
process.on("SIGTERM", () => {
|
||||
logger.info("📴 SIGTERM 시그널 수신, graceful shutdown 시작...");
|
||||
const { stopAiAssistant } = require("./utils/startAiAssistant");
|
||||
stopAiAssistant();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// SIGINT 시그널 처리 (Ctrl+C)
|
||||
process.on("SIGINT", () => {
|
||||
logger.info("📴 SIGINT 시그널 수신, graceful shutdown 시작...");
|
||||
const { stopAiAssistant } = require("./utils/startAiAssistant");
|
||||
stopAiAssistant();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// 라우터 임포트
|
||||
import authRoutes from "./routes/authRoutes";
|
||||
import adminRoutes from "./routes/adminRoutes";
|
||||
import multilangRoutes from "./routes/multilangRoutes";
|
||||
import tableManagementRoutes from "./routes/tableManagementRoutes";
|
||||
import entityJoinRoutes from "./routes/entityJoinRoutes";
|
||||
import screenManagementRoutes from "./routes/screenManagementRoutes";
|
||||
import commonCodeRoutes from "./routes/commonCodeRoutes";
|
||||
import dynamicFormRoutes from "./routes/dynamicFormRoutes";
|
||||
import fileRoutes from "./routes/fileRoutes";
|
||||
import companyManagementRoutes from "./routes/companyManagementRoutes";
|
||||
import dataflowRoutes from "./routes/dataflowRoutes";
|
||||
import dataflowDiagramRoutes from "./routes/dataflowDiagramRoutes";
|
||||
import webTypeStandardRoutes from "./routes/webTypeStandardRoutes";
|
||||
import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes";
|
||||
import screenStandardRoutes from "./routes/screenStandardRoutes";
|
||||
import templateStandardRoutes from "./routes/templateStandardRoutes";
|
||||
import componentStandardRoutes from "./routes/componentStandardRoutes";
|
||||
import layoutRoutes from "./routes/layoutRoutes";
|
||||
import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes";
|
||||
import mailAccountFileRoutes from "./routes/mailAccountFileRoutes";
|
||||
import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes";
|
||||
import mailSentHistoryRoutes from "./routes/mailSentHistoryRoutes";
|
||||
import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes";
|
||||
import dataRoutes from "./routes/dataRoutes";
|
||||
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
||||
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
||||
import externalRestApiConnectionRoutes from "./routes/externalRestApiConnectionRoutes";
|
||||
import multiConnectionRoutes from "./routes/multiConnectionRoutes";
|
||||
import screenFileRoutes from "./routes/screenFileRoutes";
|
||||
//import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
|
||||
import batchRoutes from "./routes/batchRoutes";
|
||||
import batchManagementRoutes from "./routes/batchManagementRoutes";
|
||||
import batchExecutionLogRoutes from "./routes/batchExecutionLogRoutes";
|
||||
// import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes"; // 파일이 존재하지 않음
|
||||
import ddlRoutes from "./routes/ddlRoutes";
|
||||
import entityReferenceRoutes from "./routes/entityReferenceRoutes";
|
||||
import externalCallRoutes from "./routes/externalCallRoutes";
|
||||
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
|
||||
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
|
||||
import dashboardRoutes from "./routes/dashboardRoutes";
|
||||
import reportRoutes from "./routes/reportRoutes";
|
||||
import barcodeLabelRoutes from "./routes/barcodeLabelRoutes";
|
||||
import openApiProxyRoutes from "./routes/openApiProxyRoutes"; // 날씨/환율 API
|
||||
import deliveryRoutes from "./routes/deliveryRoutes"; // 배송/화물 관리
|
||||
import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관리
|
||||
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
|
||||
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
||||
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
||||
import excelMappingRoutes from "./routes/excelMappingRoutes"; // 엑셀 매핑 템플릿
|
||||
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
|
||||
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
|
||||
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
|
||||
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
|
||||
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
|
||||
import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
|
||||
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
||||
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
|
||||
import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리
|
||||
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
||||
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
||||
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
||||
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
||||
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
||||
import entitySearchRoutes, {
|
||||
entityOptionsRouter,
|
||||
} from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
|
||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
||||
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
|
||||
import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머)
|
||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
|
||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
||||
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
|
||||
import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자동 입력 관리
|
||||
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
|
||||
import packagingRoutes from "./routes/packagingRoutes"; // 포장/적재정보 관리
|
||||
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
|
||||
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
||||
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
||||
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
|
||||
import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준
|
||||
import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스)
|
||||
import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
|
||||
import moldRoutes from "./routes/moldRoutes"; // 금형 관리
|
||||
import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리
|
||||
import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 관리
|
||||
import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트
|
||||
import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
||||
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
// import userRoutes from './routes/userRoutes';
|
||||
// import menuRoutes from './routes/menuRoutes';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.set("trust proxy", true);
|
||||
|
||||
// 기본 미들웨어
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
...helmet.contentSecurityPolicy.getDefaultDirectives(),
|
||||
"frame-ancestors": [
|
||||
"'self'",
|
||||
"http://localhost:9771",
|
||||
"http://localhost:3000",
|
||||
], // 프론트엔드 도메인 허용
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.use(compression());
|
||||
app.use(express.json({ limit: "10mb" }));
|
||||
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
||||
|
||||
// 정적 파일 서빙 전에 CORS 미들웨어 추가 (OPTIONS 요청 처리)
|
||||
app.options("/uploads/*", (req, res) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
// 정적 파일 서빙 (업로드된 파일들)
|
||||
app.use(
|
||||
"/uploads",
|
||||
(req, res, next) => {
|
||||
// 모든 정적 파일 요청에 CORS 헤더 추가
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||
res.setHeader(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Content-Type, Authorization",
|
||||
);
|
||||
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
next();
|
||||
},
|
||||
express.static(path.join(process.cwd(), "uploads")),
|
||||
);
|
||||
|
||||
// CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨
|
||||
app.use(
|
||||
cors({
|
||||
origin: config.cors.origin, // 이미 배열 또는 boolean으로 처리됨
|
||||
credentials: config.cors.credentials,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"Access-Control-Request-Method",
|
||||
"Access-Control-Request-Headers",
|
||||
],
|
||||
preflightContinue: false,
|
||||
optionsSuccessStatus: 200,
|
||||
}),
|
||||
);
|
||||
|
||||
// Rate Limiting (개발 환경에서는 완화)
|
||||
const limiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000, // 1분
|
||||
max: config.nodeEnv === "development" ? 10000 : 10000, // 개발환경에서는 10000으로 증가, 운영환경에서는 100
|
||||
message: {
|
||||
error: "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.",
|
||||
},
|
||||
skip: (req) => {
|
||||
// 헬스 체크와 자주 호출되는 API들은 Rate Limiting 완화
|
||||
return (
|
||||
req.path === "/health" ||
|
||||
req.path.includes("/table-management/") ||
|
||||
req.path.includes("/external-db-connections/") ||
|
||||
req.path.includes("/screen-management/") ||
|
||||
req.path.includes("/multi-connection/") ||
|
||||
req.path.includes("/dataflow-diagrams/")
|
||||
);
|
||||
},
|
||||
});
|
||||
app.use("/api/", limiter);
|
||||
|
||||
// 토큰 자동 갱신 미들웨어 (모든 API 요청에 적용)
|
||||
// 토큰이 1시간 이내에 만료되는 경우 자동으로 갱신하여 응답 헤더에 포함
|
||||
app.use("/api/", refreshTokenIfNeeded);
|
||||
|
||||
// 헬스 체크 엔드포인트
|
||||
app.get("/health", (req, res) => {
|
||||
res.status(200).json({
|
||||
status: "OK",
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: config.nodeEnv,
|
||||
});
|
||||
});
|
||||
|
||||
// API 라우터
|
||||
app.use("/api/auth", authRoutes);
|
||||
app.use("/api/admin", adminRoutes);
|
||||
app.use("/api/multilang", multilangRoutes);
|
||||
app.use("/api/table-management", tableManagementRoutes);
|
||||
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
|
||||
app.use("/api/screen-management", screenManagementRoutes);
|
||||
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
|
||||
app.use("/api/pop", popActionRoutes); // POP 액션 실행
|
||||
app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리
|
||||
app.use("/api/common-codes", commonCodeRoutes);
|
||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||
app.use("/api/files", fileRoutes);
|
||||
app.use("/api/company-management", companyManagementRoutes);
|
||||
app.use("/api/dataflow", dataflowRoutes);
|
||||
app.use("/api/dataflow-diagrams", dataflowDiagramRoutes);
|
||||
app.use("/api/admin/web-types", webTypeStandardRoutes);
|
||||
app.use("/api/admin/button-actions", buttonActionStandardRoutes);
|
||||
app.use("/api/admin/template-standards", templateStandardRoutes);
|
||||
app.use("/api/admin/component-standards", componentStandardRoutes);
|
||||
app.use("/api/layouts", layoutRoutes);
|
||||
app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정
|
||||
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
|
||||
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
|
||||
app.use("/api/mail/sent", mailSentHistoryRoutes); // 메일 발송 이력
|
||||
app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신
|
||||
app.use("/api/screen", screenStandardRoutes);
|
||||
app.use("/api/data", dataRoutes);
|
||||
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
||||
app.use("/api/external-db-connections", externalDbConnectionRoutes);
|
||||
app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes);
|
||||
app.use("/api/multi-connection", multiConnectionRoutes);
|
||||
app.use("/api/screen-files", screenFileRoutes);
|
||||
app.use("/api/batch-configs", batchRoutes);
|
||||
app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿
|
||||
app.use("/api/batch-management", batchManagementRoutes);
|
||||
app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
|
||||
// app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음
|
||||
app.use("/api/ddl", ddlRoutes);
|
||||
app.use("/api/entity-reference", entityReferenceRoutes);
|
||||
app.use("/api/external-calls", externalCallRoutes);
|
||||
app.use("/api/external-call-configs", externalCallConfigRoutes);
|
||||
app.use("/api/dataflow", dataflowExecutionRoutes);
|
||||
app.use("/api/dashboards", dashboardRoutes);
|
||||
app.use("/api/admin/reports", reportRoutes);
|
||||
app.use("/api/admin/barcode-labels", barcodeLabelRoutes);
|
||||
app.use("/api/open-api", openApiProxyRoutes); // 날씨/환율 외부 API
|
||||
app.use("/api/delivery", deliveryRoutes); // 배송/화물 관리
|
||||
app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리
|
||||
app.use("/api/todos", todoRoutes); // To-Do 관리
|
||||
app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
|
||||
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
|
||||
app.use("/api/yard-layouts", yardLayoutRoutes); // 3D 필드
|
||||
// app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석)
|
||||
app.use("/api/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제)
|
||||
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
|
||||
app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지)
|
||||
app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
|
||||
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
|
||||
app.use("/api/production", productionRoutes); // 생산계획 관리
|
||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
||||
app.use("/api/departments", departmentRoutes); // 부서 관리
|
||||
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
|
||||
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
|
||||
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
||||
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||
app.use("/api/entity", entityOptionsRouter); // 엔티티 옵션 (V2Select용)
|
||||
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
||||
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
||||
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
|
||||
app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 관리
|
||||
app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
|
||||
app.use("/api/packaging", packagingRoutes); // 포장/적재정보 관리
|
||||
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
|
||||
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
||||
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
||||
app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트)
|
||||
app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준
|
||||
app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력
|
||||
app.use("/api/mold", moldRoutes); // 금형 관리
|
||||
app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리
|
||||
app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리
|
||||
app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
|
||||
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
||||
app.use("/api/design", designRoutes); // 설계 모듈
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
app.use("/api/approval", approvalRoutes); // 결재 시스템
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
// app.use('/api/menus', menuRoutes);
|
||||
|
||||
// 404 핸들러
|
||||
app.use("*", (req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "요청한 리소스를 찾을 수 없습니다.",
|
||||
path: req.originalUrl,
|
||||
});
|
||||
});
|
||||
|
||||
// 에러 핸들러
|
||||
app.use(errorHandler);
|
||||
|
||||
// 서버 시작
|
||||
const PORT = config.port;
|
||||
const HOST = config.host;
|
||||
|
||||
app.listen(PORT, HOST, async () => {
|
||||
logger.info(`🚀 Server is running on ${HOST}:${PORT}`);
|
||||
logger.info(`📊 Environment: ${config.nodeEnv}`);
|
||||
logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`);
|
||||
logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`);
|
||||
|
||||
// 데이터베이스 마이그레이션 실행
|
||||
try {
|
||||
const {
|
||||
runDashboardMigration,
|
||||
runTableHistoryActionMigration,
|
||||
runDtgManagementLogMigration,
|
||||
runApprovalSystemMigration,
|
||||
} = await import("./database/runMigration");
|
||||
|
||||
await runDashboardMigration();
|
||||
await runTableHistoryActionMigration();
|
||||
await runDtgManagementLogMigration();
|
||||
await runApprovalSystemMigration();
|
||||
} catch (error) {
|
||||
logger.error(`❌ 마이그레이션 실패:`, error);
|
||||
}
|
||||
|
||||
// 배치 스케줄러 초기화
|
||||
try {
|
||||
await BatchSchedulerService.initializeScheduler();
|
||||
logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`);
|
||||
} catch (error) {
|
||||
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);
|
||||
}
|
||||
|
||||
// 리스크/알림 자동 갱신 시작
|
||||
try {
|
||||
const { RiskAlertCacheService } = await import(
|
||||
"./services/riskAlertCacheService"
|
||||
);
|
||||
const cacheService = RiskAlertCacheService.getInstance();
|
||||
cacheService.startAutoRefresh();
|
||||
logger.info(`⏰ 리스크/알림 자동 갱신이 시작되었습니다. (10분 간격)`);
|
||||
} catch (error) {
|
||||
logger.error(`❌ 리스크/알림 자동 갱신 시작 실패:`, error);
|
||||
}
|
||||
|
||||
// 메일 자동 삭제 (30일 지난 삭제된 메일) - 매일 새벽 2시 실행
|
||||
try {
|
||||
const cron = await import("node-cron");
|
||||
const { mailSentHistoryService } = await import(
|
||||
"./services/mailSentHistoryService"
|
||||
);
|
||||
|
||||
cron.schedule("0 2 * * *", async () => {
|
||||
try {
|
||||
logger.info("🗑️ 30일 지난 삭제된 메일 자동 삭제 시작...");
|
||||
const deletedCount =
|
||||
await mailSentHistoryService.cleanupOldDeletedMails();
|
||||
logger.info(`✅ 30일 지난 메일 ${deletedCount}개 자동 삭제 완료`);
|
||||
} catch (error) {
|
||||
logger.error("❌ 메일 자동 삭제 실패:", error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`⏰ 메일 자동 삭제 스케줄러가 시작되었습니다. (매일 새벽 2시)`);
|
||||
} catch (error) {
|
||||
logger.error(`❌ 메일 자동 삭제 스케줄러 시작 실패:`, error);
|
||||
}
|
||||
|
||||
// AI 어시스턴트 서비스 함께 기동 (한 번에 킬 가능)
|
||||
try {
|
||||
const { startAiAssistant } = await import("./utils/startAiAssistant");
|
||||
startAiAssistant();
|
||||
} catch (error) {
|
||||
logger.warn("⚠️ AI 어시스턴트 기동 스킵:", error);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -1,139 +0,0 @@
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
|
||||
// .env 파일 로드
|
||||
dotenv.config({ path: path.resolve(process.cwd(), ".env") });
|
||||
|
||||
interface Config {
|
||||
// 서버 설정
|
||||
port: number;
|
||||
host: string;
|
||||
nodeEnv: string;
|
||||
|
||||
// 데이터베이스 설정
|
||||
databaseUrl: string;
|
||||
|
||||
// JWT 설정
|
||||
jwt: {
|
||||
secret: string;
|
||||
expiresIn: string;
|
||||
refreshExpiresIn: string;
|
||||
};
|
||||
|
||||
// 보안 설정
|
||||
bcryptRounds: number;
|
||||
sessionSecret: string;
|
||||
|
||||
// CORS 설정
|
||||
cors: {
|
||||
origin: string | string[] | boolean; // 타입을 확장하여 배열과 boolean도 허용
|
||||
credentials: boolean;
|
||||
};
|
||||
|
||||
// 로깅 설정
|
||||
logging: {
|
||||
level: string;
|
||||
file: string;
|
||||
};
|
||||
|
||||
// API 설정
|
||||
apiPrefix: string;
|
||||
apiVersion: string;
|
||||
|
||||
// 파일 업로드 설정
|
||||
maxFileSize: number;
|
||||
uploadDir: string;
|
||||
|
||||
// 이메일 설정
|
||||
smtpHost: string;
|
||||
smtpPort: number;
|
||||
smtpUser: string;
|
||||
smtpPass: string;
|
||||
|
||||
// Redis 설정
|
||||
redisUrl: string;
|
||||
|
||||
// 개발 환경 설정
|
||||
debug: boolean;
|
||||
showErrorDetails: boolean;
|
||||
}
|
||||
|
||||
// CORS origin 처리 함수
|
||||
const getCorsOrigin = (): string[] | boolean => {
|
||||
// 개발 환경에서는 모든 origin 허용
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 환경변수가 있으면 쉼표로 구분하여 배열로 변환
|
||||
if (process.env.CORS_ORIGIN) {
|
||||
return process.env.CORS_ORIGIN.split(",").map((origin) => origin.trim());
|
||||
}
|
||||
|
||||
// 기본값: 허용할 도메인들
|
||||
return [
|
||||
"http://localhost:9771", // 로컬 개발 환경
|
||||
"http://192.168.0.70:5555", // 내부 네트워크 접근
|
||||
"http://39.117.244.52:5555", // 외부 네트워크 접근
|
||||
"https://v1.invion.com", // 운영 프론트엔드
|
||||
"https://api.invion.com", // 운영 백엔드
|
||||
];
|
||||
};
|
||||
|
||||
const config: Config = {
|
||||
// 서버 설정
|
||||
port: parseInt(process.env.PORT || "8080", 10),
|
||||
host: process.env.HOST || "0.0.0.0",
|
||||
nodeEnv: process.env.NODE_ENV || "development",
|
||||
|
||||
// 데이터베이스 설정
|
||||
databaseUrl:
|
||||
process.env.DATABASE_URL ||
|
||||
"postgresql://postgres:postgres@localhost:5432/ilshin",
|
||||
|
||||
// JWT 설정
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || "ilshin-plm-super-secret-jwt-key-2024",
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || "24h",
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
|
||||
},
|
||||
|
||||
// 보안 설정
|
||||
bcryptRounds: parseInt(process.env.BCRYPT_ROUNDS || "12", 10),
|
||||
sessionSecret: process.env.SESSION_SECRET || "ilshin-plm-session-secret-2024",
|
||||
|
||||
// CORS 설정
|
||||
cors: {
|
||||
origin: getCorsOrigin(),
|
||||
credentials: true, // 쿠키 및 인증 정보 포함 허용
|
||||
},
|
||||
|
||||
// 로깅 설정
|
||||
logging: {
|
||||
level: process.env.LOG_LEVEL || "info",
|
||||
file: process.env.LOG_FILE || "logs/app.log",
|
||||
},
|
||||
|
||||
// API 설정
|
||||
apiPrefix: process.env.API_PREFIX || "/api",
|
||||
apiVersion: process.env.API_VERSION || "v1",
|
||||
|
||||
// 파일 업로드 설정
|
||||
maxFileSize: parseInt(process.env.MAX_FILE_SIZE || "10485760", 10),
|
||||
uploadDir: process.env.UPLOAD_DIR || "uploads",
|
||||
|
||||
// 이메일 설정
|
||||
smtpHost: process.env.SMTP_HOST || "smtp.gmail.com",
|
||||
smtpPort: parseInt(process.env.SMTP_PORT || "587", 10),
|
||||
smtpUser: process.env.SMTP_USER || "",
|
||||
smtpPass: process.env.SMTP_PASS || "",
|
||||
|
||||
// Redis 설정
|
||||
redisUrl: process.env.REDIS_URL || "redis://localhost:6379",
|
||||
|
||||
// 개발 환경 설정
|
||||
debug: process.env.DEBUG === "true",
|
||||
showErrorDetails: process.env.SHOW_ERROR_DETAILS === "true",
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,118 +0,0 @@
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// 업로드 디렉토리 경로 (운영: /app/uploads/mail-attachments, 개발: 프로젝트 루트)
|
||||
const UPLOAD_DIR = process.env.NODE_ENV === 'production'
|
||||
? '/app/uploads/mail-attachments'
|
||||
: path.join(process.cwd(), 'uploads', 'mail-attachments');
|
||||
|
||||
// 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지
|
||||
try {
|
||||
if (!fs.existsSync(UPLOAD_DIR)) {
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('메일 첨부파일 디렉토리 생성 실패:', error);
|
||||
// 디렉토리가 이미 존재하거나 권한이 없어도 서비스는 계속 실행
|
||||
}
|
||||
|
||||
// 간단한 파일명 정규화 함수 (한글-분석.txt 방식)
|
||||
function normalizeFileName(filename: string): string {
|
||||
if (!filename) return filename;
|
||||
|
||||
try {
|
||||
// NFC 정규화만 수행 (복잡한 디코딩 제거)
|
||||
return filename.normalize('NFC');
|
||||
} catch (error) {
|
||||
console.error(`Failed to normalize filename: ${filename}`, error);
|
||||
return filename;
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 저장 설정
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, UPLOAD_DIR);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
try {
|
||||
// 파일명 정규화 (한글-분석.txt 방식)
|
||||
file.originalname = file.originalname.normalize('NFC');
|
||||
|
||||
console.log('File upload - Processing:', {
|
||||
original: file.originalname,
|
||||
originalHex: Buffer.from(file.originalname).toString('hex'),
|
||||
});
|
||||
|
||||
// UUID + 확장자로 유니크한 파일명 생성
|
||||
const uniqueId = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const ext = path.extname(file.originalname);
|
||||
const filename = `${uniqueId}${ext}`;
|
||||
|
||||
console.log('Generated filename:', {
|
||||
original: file.originalname,
|
||||
generated: filename,
|
||||
});
|
||||
|
||||
cb(null, filename);
|
||||
} catch (error) {
|
||||
console.error('Filename processing error:', error);
|
||||
const fallbackFilename = `${Date.now()}-${Math.round(Math.random() * 1e9)}_error.tmp`;
|
||||
cb(null, fallbackFilename);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 파일 필터 (허용할 파일 타입)
|
||||
const fileFilter = (req: any, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
|
||||
// 파일명 정규화 (fileFilter가 filename보다 먼저 실행되므로 여기서 먼저 처리)
|
||||
try {
|
||||
// NFD를 NFC로 정규화만 수행
|
||||
file.originalname = file.originalname.normalize('NFC');
|
||||
} catch (error) {
|
||||
console.warn('Failed to normalize filename in fileFilter:', error);
|
||||
}
|
||||
|
||||
// 위험한 파일 확장자 차단
|
||||
const dangerousExtensions = ['.exe', '.bat', '.cmd', '.sh', '.ps1', '.msi'];
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
|
||||
if (dangerousExtensions.includes(ext)) {
|
||||
console.log(`❌ 차단된 파일 타입: ${ext}`);
|
||||
cb(new Error(`보안상의 이유로 ${ext} 파일은 첨부할 수 없습니다.`));
|
||||
return;
|
||||
}
|
||||
|
||||
cb(null, true);
|
||||
};
|
||||
|
||||
// Multer 설정
|
||||
export const uploadMailAttachment = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB 제한
|
||||
files: 5, // 최대 5개 파일
|
||||
},
|
||||
});
|
||||
|
||||
// 첨부파일 정보 추출 헬퍼
|
||||
export interface AttachmentInfo {
|
||||
filename: string;
|
||||
originalName: string;
|
||||
size: number;
|
||||
path: string;
|
||||
mimetype: string;
|
||||
}
|
||||
|
||||
export const extractAttachmentInfo = (files: Express.Multer.File[]): AttachmentInfo[] => {
|
||||
return files.map((file) => ({
|
||||
filename: file.filename,
|
||||
originalName: file.originalname,
|
||||
size: file.size,
|
||||
path: file.path,
|
||||
mimetype: file.mimetype,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -1,884 +0,0 @@
|
||||
import { Response } from "express";
|
||||
import https from "https";
|
||||
import axios, { AxiosRequestConfig } from "axios";
|
||||
import { logger } from "../utils/logger";
|
||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import { DashboardService } from "../services/DashboardService";
|
||||
import {
|
||||
CreateDashboardRequest,
|
||||
UpdateDashboardRequest,
|
||||
DashboardListQuery,
|
||||
} from "../types/dashboard";
|
||||
import { PostgreSQLService } from "../database/PostgreSQLService";
|
||||
import { ExternalRestApiConnectionService } from "../services/externalRestApiConnectionService";
|
||||
|
||||
/**
|
||||
* 대시보드 컨트롤러
|
||||
* - REST API 엔드포인트 처리
|
||||
* - 요청 검증 및 응답 포맷팅
|
||||
*/
|
||||
export class DashboardController {
|
||||
/**
|
||||
* 대시보드 생성
|
||||
* POST /api/dashboards
|
||||
*/
|
||||
async createDashboard(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
elements,
|
||||
isPublic = false,
|
||||
tags,
|
||||
category,
|
||||
settings,
|
||||
}: CreateDashboardRequest = req.body;
|
||||
|
||||
// 유효성 검증
|
||||
if (!title || title.trim().length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "대시보드 제목이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!elements || !Array.isArray(elements)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "대시보드 요소 데이터가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 제목 길이 체크
|
||||
if (title.length > 200) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "제목은 200자를 초과할 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 설명 길이 체크
|
||||
if (description && description.length > 1000) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "설명은 1000자를 초과할 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dashboardData: CreateDashboardRequest = {
|
||||
title: title.trim(),
|
||||
description: description?.trim(),
|
||||
isPublic,
|
||||
elements,
|
||||
tags,
|
||||
category,
|
||||
settings,
|
||||
};
|
||||
|
||||
// console.log('대시보드 생성 시작:', { title: dashboardData.title, userId, elementsCount: elements.length });
|
||||
|
||||
const savedDashboard = await DashboardService.createDashboard(
|
||||
dashboardData,
|
||||
userId,
|
||||
companyCode
|
||||
);
|
||||
|
||||
// console.log('대시보드 생성 성공:', { id: savedDashboard.id, title: savedDashboard.title });
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: savedDashboard,
|
||||
message: "대시보드가 성공적으로 생성되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
// console.error('Dashboard creation error:', {
|
||||
// message: error?.message,
|
||||
// stack: error?.stack,
|
||||
// error
|
||||
// });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error?.message || "대시보드 생성 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development" ? error?.message : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 목록 조회
|
||||
* GET /api/dashboards
|
||||
*/
|
||||
async getDashboards(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
const query: DashboardListQuery = {
|
||||
page: parseInt(req.query.page as string) || 1,
|
||||
limit: Math.min(parseInt(req.query.limit as string) || 20, 100), // 최대 100개
|
||||
search: req.query.search as string,
|
||||
category: req.query.category as string,
|
||||
isPublic:
|
||||
req.query.isPublic === "true"
|
||||
? true
|
||||
: req.query.isPublic === "false"
|
||||
? false
|
||||
: undefined,
|
||||
createdBy: req.query.createdBy as string,
|
||||
};
|
||||
|
||||
// 페이지 번호 유효성 검증
|
||||
if (query.page! < 1) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "페이지 번호는 1 이상이어야 합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await DashboardService.getDashboards(
|
||||
query,
|
||||
userId,
|
||||
companyCode
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.dashboards,
|
||||
pagination: result.pagination,
|
||||
});
|
||||
} catch (error) {
|
||||
// console.error('Dashboard list error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "대시보드 목록 조회 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 상세 조회
|
||||
* GET /api/dashboards/:id
|
||||
*/
|
||||
async getDashboard(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user?.userId;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
if (!id) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "대시보드 ID가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dashboard = await DashboardService.getDashboardById(
|
||||
id,
|
||||
userId,
|
||||
companyCode
|
||||
);
|
||||
|
||||
if (!dashboard) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "대시보드를 찾을 수 없거나 접근 권한이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 조회수 증가 (본인이 만든 대시보드가 아닌 경우에만)
|
||||
if (userId && dashboard.createdBy !== userId) {
|
||||
await DashboardService.incrementViewCount(id);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: dashboard,
|
||||
});
|
||||
} catch (error) {
|
||||
// console.error('Dashboard get error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "대시보드 조회 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 수정
|
||||
* PUT /api/dashboards/:id
|
||||
*/
|
||||
async updateDashboard(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "대시보드 ID가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updateData: UpdateDashboardRequest = req.body;
|
||||
|
||||
// 유효성 검증
|
||||
if (updateData.title !== undefined) {
|
||||
if (
|
||||
typeof updateData.title !== "string" ||
|
||||
updateData.title.trim().length === 0
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 제목을 입력해주세요.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (updateData.title.length > 200) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "제목은 200자를 초과할 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateData.title = updateData.title.trim();
|
||||
}
|
||||
|
||||
if (
|
||||
updateData.description !== undefined &&
|
||||
updateData.description &&
|
||||
updateData.description.length > 1000
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "설명은 1000자를 초과할 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedDashboard = await DashboardService.updateDashboard(
|
||||
id,
|
||||
updateData,
|
||||
userId
|
||||
);
|
||||
|
||||
if (!updatedDashboard) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "대시보드를 찾을 수 없거나 수정 권한이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: updatedDashboard,
|
||||
message: "대시보드가 성공적으로 수정되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
// console.error('Dashboard update error:', error);
|
||||
|
||||
if ((error as Error).message.includes("권한이 없습니다")) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: (error as Error).message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "대시보드 수정 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 삭제
|
||||
* DELETE /api/dashboards/:id
|
||||
*/
|
||||
async deleteDashboard(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "대시보드 ID가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await DashboardService.deleteDashboard(id, userId);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "대시보드를 찾을 수 없거나 삭제 권한이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "대시보드가 성공적으로 삭제되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
// console.error('Dashboard delete error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "대시보드 삭제 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 내 대시보드 목록 조회
|
||||
* GET /api/dashboards/my
|
||||
*/
|
||||
async getMyDashboards(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
const query: DashboardListQuery = {
|
||||
page: parseInt(req.query.page as string) || 1,
|
||||
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
||||
search: req.query.search as string,
|
||||
category: req.query.category as string,
|
||||
// createdBy 제거 - 회사 대시보드 전체 표시
|
||||
};
|
||||
|
||||
const result = await DashboardService.getDashboards(
|
||||
query,
|
||||
userId,
|
||||
companyCode
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.dashboards,
|
||||
pagination: result.pagination,
|
||||
});
|
||||
} catch (error) {
|
||||
// console.error('My dashboards error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "내 대시보드 목록 조회 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼리 실행 (SELECT만)
|
||||
* POST /api/dashboards/execute-query
|
||||
*/
|
||||
async executeQuery(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
// 개발용으로 인증 체크 제거
|
||||
// const userId = req.user?.userId;
|
||||
// if (!userId) {
|
||||
// res.status(401).json({
|
||||
// success: false,
|
||||
// message: '인증이 필요합니다.'
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
|
||||
const { query } = req.body;
|
||||
|
||||
// 유효성 검증
|
||||
if (!query || typeof query !== "string" || query.trim().length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "쿼리가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// SQL 인젝션 방지를 위한 기본적인 검증
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
if (!trimmedQuery.startsWith("select")) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "SELECT 쿼리만 허용됩니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 쿼리 실행
|
||||
const result = await PostgreSQLService.query(query.trim());
|
||||
|
||||
// 결과 변환
|
||||
const columns = result.fields?.map((field) => field.name) || [];
|
||||
const rows = result.rows || [];
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
columns,
|
||||
rows,
|
||||
rowCount: rows.length,
|
||||
},
|
||||
message: "쿼리가 성공적으로 실행되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
// console.error('Query execution error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "쿼리 실행 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: "쿼리 실행 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DML 쿼리 실행 (INSERT, UPDATE, DELETE)
|
||||
* POST /api/dashboards/execute-dml
|
||||
*/
|
||||
async executeDML(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { query } = req.body;
|
||||
|
||||
// 유효성 검증
|
||||
if (!query || typeof query !== "string" || query.trim().length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "쿼리가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// SQL 인젝션 방지를 위한 기본적인 검증
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
const allowedCommands = ["insert", "update", "delete"];
|
||||
const isAllowed = allowedCommands.some((cmd) =>
|
||||
trimmedQuery.startsWith(cmd)
|
||||
);
|
||||
|
||||
if (!isAllowed) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "INSERT, UPDATE, DELETE 쿼리만 허용됩니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 위험한 명령어 차단
|
||||
const dangerousPatterns = [
|
||||
/drop\s+table/i,
|
||||
/drop\s+database/i,
|
||||
/truncate/i,
|
||||
/alter\s+table/i,
|
||||
/create\s+table/i,
|
||||
];
|
||||
|
||||
if (dangerousPatterns.some((pattern) => pattern.test(query))) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "허용되지 않는 쿼리입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 쿼리 실행
|
||||
const result = await PostgreSQLService.query(query.trim());
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rowCount: result.rowCount || 0,
|
||||
command: result.command,
|
||||
},
|
||||
message: "쿼리가 성공적으로 실행되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("DML execution error:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "쿼리 실행 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: "쿼리 실행 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 API 프록시 (CORS 우회용)
|
||||
* POST /api/dashboards/fetch-external-api
|
||||
*/
|
||||
async fetchExternalApi(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const {
|
||||
url,
|
||||
method = "GET",
|
||||
headers = {},
|
||||
queryParams = {},
|
||||
body,
|
||||
externalConnectionId, // 프론트엔드에서 선택된 커넥션 ID를 전달받아야 함
|
||||
} = req.body;
|
||||
|
||||
if (!url || typeof url !== "string") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "URL이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 쿼리 파라미터 추가
|
||||
const urlObj = new URL(url);
|
||||
Object.entries(queryParams).forEach(([key, value]) => {
|
||||
if (key && value) {
|
||||
urlObj.searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
// Axios 요청 설정
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
url: urlObj.toString(),
|
||||
method: method.toUpperCase(),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
...headers,
|
||||
},
|
||||
timeout: 60000, // 60초 타임아웃
|
||||
validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리)
|
||||
};
|
||||
|
||||
// 연결 정보 (응답에 포함용)
|
||||
let connectionInfo: { saveToHistory?: boolean } | null = null;
|
||||
|
||||
// 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
|
||||
if (externalConnectionId) {
|
||||
try {
|
||||
// 사용자 회사 코드가 있으면 사용하고, 없으면 '*' (최고 관리자)로 시도
|
||||
let companyCode = req.user?.companyCode;
|
||||
|
||||
if (!companyCode) {
|
||||
companyCode = "*";
|
||||
}
|
||||
|
||||
// 커넥션 로드
|
||||
const connectionResult =
|
||||
await ExternalRestApiConnectionService.getConnectionById(
|
||||
Number(externalConnectionId),
|
||||
companyCode
|
||||
);
|
||||
|
||||
if (connectionResult.success && connectionResult.data) {
|
||||
const connection = connectionResult.data;
|
||||
|
||||
// 연결 정보 저장 (응답에 포함)
|
||||
connectionInfo = {
|
||||
saveToHistory: connection.save_to_history === "Y",
|
||||
};
|
||||
|
||||
// 인증 헤더 생성 (DB 토큰 등)
|
||||
const authHeaders =
|
||||
await ExternalRestApiConnectionService.getAuthHeaders(
|
||||
connection.auth_type,
|
||||
connection.auth_config,
|
||||
connection.company_code
|
||||
);
|
||||
|
||||
// 기존 헤더에 인증 헤더 병합
|
||||
requestConfig.headers = {
|
||||
...requestConfig.headers,
|
||||
...authHeaders,
|
||||
};
|
||||
|
||||
// API Key가 Query Param인 경우 처리
|
||||
if (
|
||||
connection.auth_type === "api-key" &&
|
||||
connection.auth_config?.keyLocation === "query" &&
|
||||
connection.auth_config?.keyName &&
|
||||
connection.auth_config?.keyValue
|
||||
) {
|
||||
const currentUrl = new URL(requestConfig.url!);
|
||||
currentUrl.searchParams.append(
|
||||
connection.auth_config.keyName,
|
||||
connection.auth_config.keyValue
|
||||
);
|
||||
requestConfig.url = currentUrl.toString();
|
||||
}
|
||||
}
|
||||
} catch (connError) {
|
||||
logger.error(
|
||||
`외부 커넥션(${externalConnectionId}) 정보 로드 및 인증 적용 실패:`,
|
||||
connError
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Body 처리
|
||||
if (body) {
|
||||
requestConfig.data = body;
|
||||
}
|
||||
|
||||
// 디버깅 로그: 실제 요청 정보 출력
|
||||
logger.info(`[fetchExternalApi] 요청 정보:`, {
|
||||
url: requestConfig.url,
|
||||
method: requestConfig.method,
|
||||
headers: requestConfig.headers,
|
||||
body: requestConfig.data,
|
||||
externalConnectionId,
|
||||
});
|
||||
|
||||
// TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
|
||||
// ExternalRestApiConnectionService와 동일한 로직 적용
|
||||
const bypassDomains = ["thiratis.com"];
|
||||
const hostname = urlObj.hostname;
|
||||
const shouldBypassTls = bypassDomains.some((domain) =>
|
||||
hostname.includes(domain)
|
||||
);
|
||||
|
||||
if (shouldBypassTls) {
|
||||
requestConfig.httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩
|
||||
const isKmaApi = urlObj.hostname.includes("kma.go.kr");
|
||||
if (isKmaApi) {
|
||||
requestConfig.responseType = "arraybuffer";
|
||||
}
|
||||
|
||||
const response = await axios(requestConfig);
|
||||
|
||||
if (response.status >= 400) {
|
||||
throw new Error(
|
||||
`외부 API 오류: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
let data = response.data;
|
||||
const contentType = response.headers["content-type"];
|
||||
|
||||
// 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR)
|
||||
if (isKmaApi && Buffer.isBuffer(data)) {
|
||||
const iconv = require("iconv-lite");
|
||||
const buffer = Buffer.from(data);
|
||||
const utf8Text = buffer.toString("utf-8");
|
||||
|
||||
// UTF-8로 정상 디코딩되었는지 확인
|
||||
if (
|
||||
utf8Text.includes("특보") ||
|
||||
utf8Text.includes("경보") ||
|
||||
utf8Text.includes("주의보") ||
|
||||
(utf8Text.includes("#START7777") && !utf8Text.includes("�"))
|
||||
) {
|
||||
data = { text: utf8Text, contentType, encoding: "utf-8" };
|
||||
} else {
|
||||
// EUC-KR로 디코딩
|
||||
const eucKrText = iconv.decode(buffer, "EUC-KR");
|
||||
data = { text: eucKrText, contentType, encoding: "euc-kr" };
|
||||
}
|
||||
}
|
||||
// 텍스트 응답인 경우 포맷팅
|
||||
else if (typeof data === "string") {
|
||||
data = { text: data, contentType };
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data,
|
||||
connectionInfo, // 외부 연결 정보 (saveToHistory 등)
|
||||
});
|
||||
} catch (error: any) {
|
||||
const status = error.response?.status || 500;
|
||||
const message = error.response?.statusText || error.message;
|
||||
|
||||
logger.error("외부 API 호출 오류:", {
|
||||
message,
|
||||
status,
|
||||
data: error.response?.data,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "외부 API 호출 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? message
|
||||
: "외부 API 호출 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 스키마 조회 (날짜 컬럼 감지용)
|
||||
* POST /api/dashboards/table-schema
|
||||
*/
|
||||
async getTableSchema(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.body;
|
||||
|
||||
if (!tableName || typeof tableName !== "string") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 테이블명 검증 (SQL 인젝션 방지)
|
||||
if (!/^[a-z_][a-z0-9_]*$/i.test(tableName)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 테이블명입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// PostgreSQL information_schema에서 컬럼 정보 조회
|
||||
const query = `
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
udt_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = $1
|
||||
ORDER BY ordinal_position
|
||||
`;
|
||||
|
||||
const result = await PostgreSQLService.query(query, [
|
||||
tableName.toLowerCase(),
|
||||
]);
|
||||
|
||||
// 날짜/시간 타입 컬럼 필터링
|
||||
const dateColumns = result.rows
|
||||
.filter((row: any) => {
|
||||
const dataType = row.data_type?.toLowerCase();
|
||||
const udtName = row.udt_name?.toLowerCase();
|
||||
return (
|
||||
dataType === "timestamp" ||
|
||||
dataType === "timestamp without time zone" ||
|
||||
dataType === "timestamp with time zone" ||
|
||||
dataType === "date" ||
|
||||
dataType === "time" ||
|
||||
dataType === "time without time zone" ||
|
||||
dataType === "time with time zone" ||
|
||||
udtName === "timestamp" ||
|
||||
udtName === "timestamptz" ||
|
||||
udtName === "date" ||
|
||||
udtName === "time" ||
|
||||
udtName === "timetz"
|
||||
);
|
||||
})
|
||||
.map((row: any) => row.column_name);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
tableName,
|
||||
columns: result.rows.map((row: any) => ({
|
||||
name: row.column_name,
|
||||
type: row.data_type,
|
||||
udtName: row.udt_name,
|
||||
})),
|
||||
dateColumns,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "테이블 스키마 조회 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: "스키마 조회 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
import { Request, Response } from "express";
|
||||
import YardLayoutService from "../services/YardLayoutService";
|
||||
|
||||
export class YardLayoutController {
|
||||
// 모든 야드 레이아웃 목록 조회
|
||||
async getAllLayouts(req: Request, res: Response) {
|
||||
try {
|
||||
const layouts = await YardLayoutService.getAllLayouts();
|
||||
res.json({ success: true, data: layouts });
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching yard layouts:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 야드 레이아웃 상세 조회
|
||||
async getLayoutById(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const layout = await YardLayoutService.getLayoutById(parseInt(id));
|
||||
|
||||
if (!layout) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: layout });
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 새 야드 레이아웃 생성
|
||||
async createLayout(req: Request, res: Response) {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "야드 이름은 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const created_by = (req as any).user?.userId || "system";
|
||||
const layout = await YardLayoutService.createLayout({
|
||||
name,
|
||||
description,
|
||||
created_by,
|
||||
});
|
||||
|
||||
return res.status(201).json({ success: true, data: layout });
|
||||
} catch (error: any) {
|
||||
console.error("Error creating yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 생성 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 야드 레이아웃 수정
|
||||
async updateLayout(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description } = req.body;
|
||||
|
||||
const layout = await YardLayoutService.updateLayout(parseInt(id), {
|
||||
name,
|
||||
description,
|
||||
});
|
||||
|
||||
if (!layout) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: layout });
|
||||
} catch (error: any) {
|
||||
console.error("Error updating yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 수정 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 야드 레이아웃 삭제
|
||||
async deleteLayout(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const layout = await YardLayoutService.deleteLayout(parseInt(id));
|
||||
|
||||
if (!layout) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "야드 레이아웃이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 삭제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 야드의 모든 배치 자재 조회
|
||||
async getPlacementsByLayoutId(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const placements = await YardLayoutService.getPlacementsByLayoutId(
|
||||
parseInt(id)
|
||||
);
|
||||
|
||||
res.json({ success: true, data: placements });
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching placements:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 자재 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 야드에 자재 배치 추가 (빈 요소 또는 설정된 요소)
|
||||
async addMaterialPlacement(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const placementData = req.body;
|
||||
|
||||
// 데이터 바인딩 재설계 후 material_code와 external_material_id는 선택사항
|
||||
// 빈 요소를 추가할 수 있어야 함
|
||||
|
||||
const placement = await YardLayoutService.addMaterialPlacement(
|
||||
parseInt(id),
|
||||
placementData
|
||||
);
|
||||
|
||||
return res.status(201).json({ success: true, data: placement });
|
||||
} catch (error: any) {
|
||||
console.error("Error adding material placement:", error);
|
||||
|
||||
if (error.code === "23505") {
|
||||
// 유니크 제약 조건 위반
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: "이미 배치된 자재입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "자재 배치 추가 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 배치 정보 수정
|
||||
async updatePlacement(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const placementData = req.body;
|
||||
|
||||
const placement = await YardLayoutService.updatePlacement(
|
||||
parseInt(id),
|
||||
placementData
|
||||
);
|
||||
|
||||
if (!placement) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 정보를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: placement });
|
||||
} catch (error: any) {
|
||||
console.error("Error updating placement:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 정보 수정 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 배치 해제
|
||||
async removePlacement(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const placement = await YardLayoutService.removePlacement(parseInt(id));
|
||||
|
||||
if (!placement) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 정보를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: "배치가 해제되었습니다." });
|
||||
} catch (error: any) {
|
||||
console.error("Error removing placement:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 해제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 여러 배치 일괄 업데이트
|
||||
async batchUpdatePlacements(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { placements } = req.body;
|
||||
|
||||
if (!Array.isArray(placements) || placements.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "배치 목록이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const updatedPlacements = await YardLayoutService.batchUpdatePlacements(
|
||||
parseInt(id),
|
||||
placements
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: updatedPlacements });
|
||||
} catch (error: any) {
|
||||
console.error("Error batch updating placements:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 일괄 업데이트 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 야드 레이아웃 복제
|
||||
async duplicateLayout(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "새 야드 이름은 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const layout = await YardLayoutService.duplicateLayout(
|
||||
parseInt(id),
|
||||
name
|
||||
);
|
||||
|
||||
return res.status(201).json({ success: true, data: layout });
|
||||
} catch (error: any) {
|
||||
console.error("Error duplicating yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 복제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new YardLayoutController();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,488 +0,0 @@
|
||||
import { Response } from "express";
|
||||
import { query } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
function buildCompanyFilter(companyCode: string, alias: string, paramIdx: number) {
|
||||
if (companyCode === "*") return { condition: "", params: [] as any[], nextIdx: paramIdx };
|
||||
return {
|
||||
condition: `${alias}.company_code = $${paramIdx}`,
|
||||
params: [companyCode],
|
||||
nextIdx: paramIdx + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDateFilter(startDate: string | undefined, endDate: string | undefined, dateExpr: string, paramIdx: number) {
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = paramIdx;
|
||||
|
||||
if (startDate) {
|
||||
conditions.push(`${dateExpr} >= $${idx}`);
|
||||
params.push(startDate);
|
||||
idx++;
|
||||
}
|
||||
if (endDate) {
|
||||
conditions.push(`${dateExpr} <= $${idx}`);
|
||||
params.push(endDate);
|
||||
idx++;
|
||||
}
|
||||
|
||||
return { conditions, params, nextIdx: idx };
|
||||
}
|
||||
|
||||
function buildWhereClause(conditions: string[]): string {
|
||||
return conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
}
|
||||
|
||||
function extractFilterSet(rows: any[], field: string, labelField?: string): { value: string; label: string }[] {
|
||||
const set = new Map<string, string>();
|
||||
rows.forEach((r: any) => {
|
||||
const val = r[field];
|
||||
if (val && val !== "미지정") set.set(val, r[labelField || field] || val);
|
||||
});
|
||||
return [...set.entries()].map(([value, label]) => ({ value, label }));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 생산 리포트
|
||||
// ============================================
|
||||
export async function getProductionReportData(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
||||
|
||||
const { startDate, endDate } = req.query;
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const cf = buildCompanyFilter(companyCode, "wi", idx);
|
||||
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
||||
|
||||
const df = buildDateFilter(startDate, endDate, "COALESCE(wi.start_date, wi.created_date::date::text)", idx);
|
||||
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(wi.start_date, wi.created_date::date::text) as date,
|
||||
COALESCE(wi.routing, '미지정') as process,
|
||||
COALESCE(ei.equipment_name, wi.equipment_id, '미지정') as equipment,
|
||||
COALESCE(ii.item_name, wi.item_id, '미지정') as item,
|
||||
COALESCE(wi.worker, '미지정') as worker,
|
||||
CAST(COALESCE(NULLIF(wi.qty, ''), '0') AS numeric) as "planQty",
|
||||
COALESCE(pr.production_qty, 0) as "prodQty",
|
||||
COALESCE(pr.defect_qty, 0) as "defectQty",
|
||||
0 as "runTime",
|
||||
0 as "downTime",
|
||||
wi.status,
|
||||
wi.company_code
|
||||
FROM work_instruction wi
|
||||
LEFT JOIN (
|
||||
SELECT wo_id, company_code,
|
||||
SUM(CAST(COALESCE(NULLIF(production_qty, ''), '0') AS numeric)) as production_qty,
|
||||
SUM(CAST(COALESCE(NULLIF(defect_qty, ''), '0') AS numeric)) as defect_qty
|
||||
FROM production_record GROUP BY wo_id, company_code
|
||||
) pr ON wi.id = pr.wo_id AND wi.company_code = pr.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (equipment_code, company_code)
|
||||
equipment_code, equipment_name, equipment_type, company_code
|
||||
FROM equipment_info ORDER BY equipment_code, company_code, created_date DESC
|
||||
) ei ON wi.equipment_id = ei.equipment_code AND wi.company_code = ei.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (item_number, company_code)
|
||||
item_number, item_name, company_code
|
||||
FROM item_info ORDER BY item_number, company_code, created_date DESC
|
||||
) ii ON wi.item_id = ii.item_number AND wi.company_code = ii.company_code
|
||||
${whereClause}
|
||||
ORDER BY date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
const dataRows = await query(dataQuery, params);
|
||||
|
||||
logger.info("생산 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataRows,
|
||||
filterOptions: {
|
||||
processes: extractFilterSet(dataRows, "process"),
|
||||
equipment: extractFilterSet(dataRows, "equipment"),
|
||||
items: extractFilterSet(dataRows, "item"),
|
||||
workers: extractFilterSet(dataRows, "worker"),
|
||||
},
|
||||
totalCount: dataRows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("생산 리포트 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "생산 리포트 데이터 조회에 실패했습니다", error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 재고 리포트
|
||||
// ============================================
|
||||
export async function getInventoryReportData(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const cf = buildCompanyFilter(companyCode, "ist", idx);
|
||||
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(ist.updated_date, ist.created_date)::date::text as date,
|
||||
ist.item_code,
|
||||
COALESCE(ii.item_name, ist.item_code, '미지정') as item,
|
||||
COALESCE(wi.warehouse_name, ist.warehouse_code, '미지정') as warehouse,
|
||||
'일반' as category,
|
||||
CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric) as "currentQty",
|
||||
CAST(COALESCE(NULLIF(ist.safety_qty::text, ''), '0') AS numeric) as "safetyQty",
|
||||
COALESCE(ih_in.in_qty, 0) as "inQty",
|
||||
COALESCE(ih_out.out_qty, 0) as "outQty",
|
||||
0 as "stockValue",
|
||||
GREATEST(CAST(COALESCE(NULLIF(ist.safety_qty::text, ''), '0') AS numeric)
|
||||
- CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric), 0) as "shortageQty",
|
||||
CASE WHEN CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric) > 0
|
||||
AND COALESCE(ih_out.out_qty, 0) > 0
|
||||
THEN ROUND(COALESCE(ih_out.out_qty, 0)::numeric
|
||||
/ CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '1') AS numeric), 2)
|
||||
ELSE 0 END as "turnover",
|
||||
ist.company_code
|
||||
FROM inventory_stock ist
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (item_number, company_code)
|
||||
item_number, item_name, company_code
|
||||
FROM item_info ORDER BY item_number, company_code, created_date DESC
|
||||
) ii ON ist.item_code = ii.item_number AND ist.company_code = ii.company_code
|
||||
LEFT JOIN warehouse_info wi ON ist.warehouse_code = wi.warehouse_code
|
||||
AND ist.company_code = wi.company_code
|
||||
LEFT JOIN (
|
||||
SELECT item_code, company_code,
|
||||
SUM(CAST(COALESCE(NULLIF(quantity::text, ''), '0') AS numeric)) as in_qty
|
||||
FROM inventory_history WHERE transaction_type = 'IN'
|
||||
GROUP BY item_code, company_code
|
||||
) ih_in ON ist.item_code = ih_in.item_code AND ist.company_code = ih_in.company_code
|
||||
LEFT JOIN (
|
||||
SELECT item_code, company_code,
|
||||
SUM(CAST(COALESCE(NULLIF(quantity::text, ''), '0') AS numeric)) as out_qty
|
||||
FROM inventory_history WHERE transaction_type = 'OUT'
|
||||
GROUP BY item_code, company_code
|
||||
) ih_out ON ist.item_code = ih_out.item_code AND ist.company_code = ih_out.company_code
|
||||
${whereClause}
|
||||
ORDER BY date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
const dataRows = await query(dataQuery, params);
|
||||
|
||||
logger.info("재고 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataRows,
|
||||
filterOptions: {
|
||||
items: extractFilterSet(dataRows, "item"),
|
||||
warehouses: extractFilterSet(dataRows, "warehouse"),
|
||||
categories: [
|
||||
{ value: "원자재", label: "원자재" }, { value: "부자재", label: "부자재" },
|
||||
{ value: "반제품", label: "반제품" }, { value: "완제품", label: "완제품" },
|
||||
{ value: "일반", label: "일반" },
|
||||
],
|
||||
},
|
||||
totalCount: dataRows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("재고 리포트 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "재고 리포트 데이터 조회에 실패했습니다", error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 구매 리포트
|
||||
// ============================================
|
||||
export async function getPurchaseReportData(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
||||
|
||||
const { startDate, endDate } = req.query;
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const cf = buildCompanyFilter(companyCode, "po", idx);
|
||||
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
||||
|
||||
const df = buildDateFilter(startDate, endDate, "COALESCE(po.order_date, po.created_date::date::text)", idx);
|
||||
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(po.order_date, po.created_date::date::text) as date,
|
||||
po.purchase_no,
|
||||
COALESCE(po.supplier_name, po.supplier_code, '미지정') as supplier,
|
||||
COALESCE(po.item_name, po.item_code, '미지정') as item,
|
||||
po.item_code,
|
||||
COALESCE(po.manager, '미지정') as manager,
|
||||
po.status,
|
||||
CAST(COALESCE(NULLIF(po.order_qty, ''), '0') AS numeric) as "orderQty",
|
||||
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric) as "receiveQty",
|
||||
CAST(COALESCE(NULLIF(po.unit_price, ''), '0') AS numeric) as "unitPrice",
|
||||
CAST(COALESCE(NULLIF(po.amount, ''), '0') AS numeric) as "orderAmt",
|
||||
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric)
|
||||
* CAST(COALESCE(NULLIF(po.unit_price, ''), '0') AS numeric) as "receiveAmt",
|
||||
1 as "orderCnt",
|
||||
po.company_code
|
||||
FROM purchase_order_mng po
|
||||
${whereClause}
|
||||
ORDER BY date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
const dataRows = await query(dataQuery, params);
|
||||
|
||||
logger.info("구매 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataRows,
|
||||
filterOptions: {
|
||||
suppliers: extractFilterSet(dataRows, "supplier"),
|
||||
items: extractFilterSet(dataRows, "item"),
|
||||
managers: extractFilterSet(dataRows, "manager"),
|
||||
statuses: extractFilterSet(dataRows, "status"),
|
||||
},
|
||||
totalCount: dataRows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("구매 리포트 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "구매 리포트 데이터 조회에 실패했습니다", error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 품질 리포트
|
||||
// ============================================
|
||||
export async function getQualityReportData(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
||||
|
||||
const { startDate, endDate } = req.query;
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const cf = buildCompanyFilter(companyCode, "pr", idx);
|
||||
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
||||
|
||||
const df = buildDateFilter(startDate, endDate, "COALESCE(pr.production_date, pr.created_date::date::text)", idx);
|
||||
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(pr.production_date, pr.created_date::date::text) as date,
|
||||
COALESCE(ii.item_name, wi.item_id, '미지정') as item,
|
||||
'일반검사' as "defectType",
|
||||
COALESCE(wi.routing, '미지정') as process,
|
||||
COALESCE(pr.worker_name, '미지정') as inspector,
|
||||
CAST(COALESCE(NULLIF(pr.production_qty, ''), '0') AS numeric) as "inspQty",
|
||||
CAST(COALESCE(NULLIF(pr.production_qty, ''), '0') AS numeric)
|
||||
- CAST(COALESCE(NULLIF(pr.defect_qty, ''), '0') AS numeric) as "passQty",
|
||||
CAST(COALESCE(NULLIF(pr.defect_qty, ''), '0') AS numeric) as "defectQty",
|
||||
0 as "reworkQty",
|
||||
0 as "scrapQty",
|
||||
0 as "claimCnt",
|
||||
pr.company_code
|
||||
FROM production_record pr
|
||||
LEFT JOIN work_instruction wi ON pr.wo_id = wi.id AND pr.company_code = wi.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (item_number, company_code)
|
||||
item_number, item_name, company_code
|
||||
FROM item_info ORDER BY item_number, company_code, created_date DESC
|
||||
) ii ON wi.item_id = ii.item_number AND wi.company_code = ii.company_code
|
||||
${whereClause}
|
||||
ORDER BY date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
const dataRows = await query(dataQuery, params);
|
||||
|
||||
logger.info("품질 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataRows,
|
||||
filterOptions: {
|
||||
items: extractFilterSet(dataRows, "item"),
|
||||
defectTypes: [
|
||||
{ value: "외관불량", label: "외관불량" }, { value: "치수불량", label: "치수불량" },
|
||||
{ value: "기능불량", label: "기능불량" }, { value: "재질불량", label: "재질불량" },
|
||||
{ value: "일반검사", label: "일반검사" },
|
||||
],
|
||||
processes: extractFilterSet(dataRows, "process"),
|
||||
inspectors: extractFilterSet(dataRows, "inspector"),
|
||||
},
|
||||
totalCount: dataRows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("품질 리포트 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "품질 리포트 데이터 조회에 실패했습니다", error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 설비 리포트
|
||||
// ============================================
|
||||
export async function getEquipmentReportData(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const cf = buildCompanyFilter(companyCode, "ei", idx);
|
||||
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(ei.updated_date, ei.created_date)::date::text as date,
|
||||
ei.equipment_code,
|
||||
COALESCE(ei.equipment_name, ei.equipment_code) as equipment,
|
||||
COALESCE(ei.equipment_type, '미지정') as "equipType",
|
||||
COALESCE(ei.location, '미지정') as line,
|
||||
COALESCE(ui.user_name, ei.manager_id, '미지정') as manager,
|
||||
ei.status,
|
||||
CAST(COALESCE(NULLIF(ei.capacity_per_day::text, ''), '0') AS numeric) as "runTime",
|
||||
0 as "downTime",
|
||||
100 as "opRate",
|
||||
0 as "faultCnt",
|
||||
0 as "mtbf",
|
||||
0 as "mttr",
|
||||
0 as "maintCost",
|
||||
CAST(COALESCE(NULLIF(ei.capacity_per_day::text, ''), '0') AS numeric) as "prodQty",
|
||||
ei.company_code
|
||||
FROM equipment_info ei
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (user_id) user_id, user_name FROM user_info
|
||||
) ui ON ei.manager_id = ui.user_id
|
||||
${whereClause}
|
||||
ORDER BY equipment ASC
|
||||
`;
|
||||
|
||||
const dataRows = await query(dataQuery, params);
|
||||
|
||||
logger.info("설비 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataRows,
|
||||
filterOptions: {
|
||||
equipment: extractFilterSet(dataRows, "equipment"),
|
||||
equipTypes: extractFilterSet(dataRows, "equipType"),
|
||||
lines: extractFilterSet(dataRows, "line"),
|
||||
managers: extractFilterSet(dataRows, "manager"),
|
||||
},
|
||||
totalCount: dataRows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("설비 리포트 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "설비 리포트 데이터 조회에 실패했습니다", error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 금형 리포트
|
||||
// ============================================
|
||||
export async function getMoldReportData(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const cf = buildCompanyFilter(companyCode, "mm", idx);
|
||||
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(mm.updated_date, mm.created_date)::date::text as date,
|
||||
mm.mold_code,
|
||||
COALESCE(mm.mold_name, mm.mold_code) as mold,
|
||||
COALESCE(mm.mold_type, mm.category, '미지정') as "moldType",
|
||||
COALESCE(ii.item_name, '미지정') as item,
|
||||
COALESCE(mm.manufacturer, '미지정') as maker,
|
||||
mm.operation_status as status,
|
||||
CAST(COALESCE(NULLIF(mm.shot_count::text, ''), '0') AS numeric) as "shotCnt",
|
||||
CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '0') AS numeric) as "guaranteeShot",
|
||||
CASE WHEN CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '0') AS numeric) > 0
|
||||
THEN ROUND(
|
||||
CAST(COALESCE(NULLIF(mm.shot_count::text, ''), '0') AS numeric) * 100.0
|
||||
/ CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '1') AS numeric), 1)
|
||||
ELSE 0 END as "lifeRate",
|
||||
0 as "repairCnt",
|
||||
0 as "repairCost",
|
||||
0 as "prodQty",
|
||||
0 as "defectRate",
|
||||
CAST(COALESCE(NULLIF(mm.cavity_count::text, ''), '0') AS numeric) as "cavityUse",
|
||||
mm.company_code
|
||||
FROM mold_mng mm
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (item_number, company_code)
|
||||
item_number, item_name, company_code
|
||||
FROM item_info ORDER BY item_number, company_code, created_date DESC
|
||||
) ii ON mm.mold_code = ii.item_number AND mm.company_code = ii.company_code
|
||||
${whereClause}
|
||||
ORDER BY mold ASC
|
||||
`;
|
||||
|
||||
const dataRows = await query(dataQuery, params);
|
||||
|
||||
logger.info("금형 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataRows,
|
||||
filterOptions: {
|
||||
molds: extractFilterSet(dataRows, "mold"),
|
||||
moldTypes: extractFilterSet(dataRows, "moldType"),
|
||||
items: extractFilterSet(dataRows, "item"),
|
||||
makers: extractFilterSet(dataRows, "maker"),
|
||||
},
|
||||
totalCount: dataRows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("금형 리포트 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "금형 리포트 데이터 조회에 실패했습니다", error: error.message });
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,212 +0,0 @@
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { query, queryOne } from "../database/db";
|
||||
|
||||
// ============================================================
|
||||
// 대결 위임 설정 (Approval Proxy Settings) CRUD
|
||||
// ============================================================
|
||||
|
||||
export class ApprovalProxyController {
|
||||
// 대결 위임 목록 조회 (user_info JOIN으로 이름/부서 포함)
|
||||
static async getProxySettings(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const rows = await query<any>(
|
||||
`SELECT ps.*,
|
||||
u1.user_name AS original_user_name, u1.dept_name AS original_dept_name,
|
||||
u2.user_name AS proxy_user_name, u2.dept_name AS proxy_dept_name
|
||||
FROM approval_proxy_settings ps
|
||||
LEFT JOIN user_info u1 ON ps.original_user_id = u1.user_id AND ps.company_code = u1.company_code
|
||||
LEFT JOIN user_info u2 ON ps.proxy_user_id = u2.user_id AND ps.company_code = u2.company_code
|
||||
WHERE ps.company_code = $1
|
||||
ORDER BY ps.created_at DESC`,
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: rows });
|
||||
} catch (error) {
|
||||
console.error("대결 위임 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "대결 위임 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 대결 위임 생성 (기간 중복 체크 포함)
|
||||
static async createProxySetting(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { original_user_id, proxy_user_id, start_date, end_date, reason, is_active = "Y" } = req.body;
|
||||
|
||||
if (!original_user_id || !proxy_user_id) {
|
||||
return res.status(400).json({ success: false, message: "위임자와 대결자는 필수입니다." });
|
||||
}
|
||||
if (!start_date || !end_date) {
|
||||
return res.status(400).json({ success: false, message: "시작일과 종료일은 필수입니다." });
|
||||
}
|
||||
if (original_user_id === proxy_user_id) {
|
||||
return res.status(400).json({ success: false, message: "위임자와 대결자가 동일할 수 없습니다." });
|
||||
}
|
||||
|
||||
// 같은 기간 중복 체크 (daterange 오버랩)
|
||||
const overlap = await queryOne<any>(
|
||||
`SELECT COUNT(*) AS cnt FROM approval_proxy_settings
|
||||
WHERE original_user_id = $1 AND is_active = 'Y'
|
||||
AND daterange(start_date, end_date, '[]') && daterange($2::date, $3::date, '[]')
|
||||
AND company_code = $4`,
|
||||
[original_user_id, start_date, end_date, companyCode]
|
||||
);
|
||||
|
||||
if (overlap && parseInt(overlap.cnt) > 0) {
|
||||
return res.status(400).json({ success: false, message: "해당 기간에 이미 대결 설정이 존재합니다." });
|
||||
}
|
||||
|
||||
const [row] = await query<any>(
|
||||
`INSERT INTO approval_proxy_settings
|
||||
(original_user_id, proxy_user_id, start_date, end_date, reason, is_active, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[original_user_id, proxy_user_id, start_date, end_date, reason || null, is_active, companyCode]
|
||||
);
|
||||
|
||||
return res.status(201).json({ success: true, data: row, message: "대결 위임이 생성되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("대결 위임 생성 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "대결 위임 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 대결 위임 수정
|
||||
static async updateProxySetting(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const existing = await queryOne<any>(
|
||||
"SELECT id FROM approval_proxy_settings WHERE id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({ success: false, message: "대결 위임 설정을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
const { proxy_user_id, start_date, end_date, reason, is_active } = req.body;
|
||||
|
||||
const fields: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (proxy_user_id !== undefined) { fields.push(`proxy_user_id = $${idx++}`); params.push(proxy_user_id); }
|
||||
if (start_date !== undefined) { fields.push(`start_date = $${idx++}`); params.push(start_date); }
|
||||
if (end_date !== undefined) { fields.push(`end_date = $${idx++}`); params.push(end_date); }
|
||||
if (reason !== undefined) { fields.push(`reason = $${idx++}`); params.push(reason); }
|
||||
if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); }
|
||||
|
||||
if (fields.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "수정할 필드가 없습니다." });
|
||||
}
|
||||
|
||||
fields.push(`updated_at = NOW()`);
|
||||
params.push(id, companyCode);
|
||||
|
||||
const [row] = await query<any>(
|
||||
`UPDATE approval_proxy_settings SET ${fields.join(", ")}
|
||||
WHERE id = $${idx++} AND company_code = $${idx++}
|
||||
RETURNING *`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: row, message: "대결 위임이 수정되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("대결 위임 수정 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "대결 위임 수정 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 대결 위임 삭제
|
||||
static async deleteProxySetting(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await query<any>(
|
||||
"DELETE FROM approval_proxy_settings WHERE id = $1 AND company_code = $2 RETURNING id",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (result.length === 0) {
|
||||
return res.status(404).json({ success: false, message: "대결 위임 설정을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: "대결 위임이 삭제되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("대결 위임 삭제 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "대결 위임 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 사용자의 현재 활성 대결자 조회
|
||||
static async checkActiveProxy(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { userId } = req.params;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ success: false, message: "userId 파라미터는 필수입니다." });
|
||||
}
|
||||
|
||||
const rows = await query<any>(
|
||||
`SELECT ps.*, u.user_name AS proxy_user_name
|
||||
FROM approval_proxy_settings ps
|
||||
LEFT JOIN user_info u ON ps.proxy_user_id = u.user_id AND ps.company_code = u.company_code
|
||||
WHERE ps.original_user_id = $1 AND ps.is_active = 'Y'
|
||||
AND ps.start_date <= CURRENT_DATE AND ps.end_date >= CURRENT_DATE
|
||||
AND ps.company_code = $2`,
|
||||
[userId, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: rows });
|
||||
} catch (error) {
|
||||
console.error("활성 대결자 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "활성 대결자 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import { auditLogService, getClientIp, AuditAction, AuditResourceType } from "../services/auditLogService";
|
||||
import { query } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
export const getAuditLogs = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const isSuperAdmin = req.user?.userType === "SUPER_ADMIN";
|
||||
|
||||
const {
|
||||
companyCode,
|
||||
userId,
|
||||
resourceType,
|
||||
action,
|
||||
tableName,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
search,
|
||||
page,
|
||||
limit,
|
||||
} = req.query;
|
||||
|
||||
const result = await auditLogService.queryLogs(
|
||||
{
|
||||
companyCode: (companyCode as string) || (isSuperAdmin ? undefined : userCompanyCode),
|
||||
userId: userId as string,
|
||||
resourceType: resourceType as string,
|
||||
action: action as string,
|
||||
tableName: tableName as string,
|
||||
dateFrom: dateFrom as string,
|
||||
dateTo: dateTo as string,
|
||||
search: search as string,
|
||||
page: page ? parseInt(page as string, 10) : 1,
|
||||
limit: limit ? parseInt(limit as string, 10) : 50,
|
||||
},
|
||||
isSuperAdmin
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
total: result.total,
|
||||
page: page ? parseInt(page as string, 10) : 1,
|
||||
limit: limit ? parseInt(limit as string, 10) : 50,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("감사 로그 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "감사 로그 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getAuditLogStats = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const isSuperAdmin = req.user?.userType === "SUPER_ADMIN";
|
||||
const { companyCode, days } = req.query;
|
||||
|
||||
const targetCompany = isSuperAdmin
|
||||
? (companyCode as string) || undefined
|
||||
: userCompanyCode;
|
||||
|
||||
const stats = await auditLogService.getStats(
|
||||
targetCompany,
|
||||
days ? parseInt(days as string, 10) : 30
|
||||
);
|
||||
|
||||
res.json({ success: true, data: stats });
|
||||
} catch (error: any) {
|
||||
logger.error("감사 로그 통계 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "감사 로그 통계 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getAuditLogUsers = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const isSuperAdmin = req.user?.userType === "SUPER_ADMIN";
|
||||
const { companyCode } = req.query;
|
||||
|
||||
const conditions: string[] = ["LOWER(u.status) = 'active'"];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
conditions.push(`u.company_code = $${paramIndex++}`);
|
||||
params.push(userCompanyCode);
|
||||
} else if (companyCode) {
|
||||
conditions.push(`u.company_code = $${paramIndex++}`);
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
conditions.push(`u.company_code != '*'`);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const users = await query<{ user_id: string; user_name: string; count: number }>(
|
||||
`SELECT
|
||||
u.user_id,
|
||||
u.user_name,
|
||||
COALESCE(sal.log_count, 0)::int as count
|
||||
FROM user_info u
|
||||
LEFT JOIN (
|
||||
SELECT user_id, COUNT(*) as log_count
|
||||
FROM system_audit_log
|
||||
GROUP BY user_id
|
||||
) sal ON u.user_id = sal.user_id
|
||||
${whereClause}
|
||||
ORDER BY count DESC, u.user_name ASC`,
|
||||
params
|
||||
);
|
||||
|
||||
res.json({ success: true, data: users });
|
||||
} catch (error: any) {
|
||||
logger.error("감사 로그 사용자 목록 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "사용자 목록 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 프론트엔드에서 직접 감사 로그 기록 (그룹 복제 등 프론트 오케스트레이션 작업용)
|
||||
*/
|
||||
export const createAuditLog = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { action, resourceType, resourceId, resourceName, tableName, summary, changes } = req.body;
|
||||
|
||||
if (!action || !resourceType) {
|
||||
res.status(400).json({ success: false, message: "action, resourceType은 필수입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
await auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: action as AuditAction,
|
||||
resourceType: resourceType as AuditResourceType,
|
||||
resourceId: resourceId || undefined,
|
||||
resourceName: resourceName || undefined,
|
||||
tableName: tableName || undefined,
|
||||
summary: summary || undefined,
|
||||
changes: changes || undefined,
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("감사 로그 기록 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "감사 로그 기록 실패" });
|
||||
}
|
||||
};
|
||||
@@ -1,561 +0,0 @@
|
||||
// 인증 컨트롤러
|
||||
// 기존 Java ApiLoginController를 Node.js로 포팅
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { AuthService } from "../services/authService";
|
||||
import { JwtUtils } from "../utils/jwtUtils";
|
||||
import { LoginRequest, UserInfo, ApiResponse, PersonBean } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
import { sendSmartFactoryLog } from "../utils/smartFactoryLog";
|
||||
|
||||
export class AuthController {
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* 기존 Java ApiLoginController.login() 메서드 포팅
|
||||
*/
|
||||
static async login(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { userId, password }: LoginRequest = req.body;
|
||||
const remoteAddr = req.ip || req.connection.remoteAddress || "unknown";
|
||||
|
||||
logger.debug(`로그인 요청: ${userId}`);
|
||||
|
||||
// 입력값 검증
|
||||
if (!userId || !password) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "사용자 ID와 비밀번호를 입력해주세요.",
|
||||
error: {
|
||||
code: "INVALID_INPUT",
|
||||
details: "필수 입력값이 누락되었습니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 로그인 프로세스 실행
|
||||
const loginResult = await AuthService.processLogin(
|
||||
userId,
|
||||
password,
|
||||
remoteAddr
|
||||
);
|
||||
|
||||
if (loginResult.success && loginResult.userInfo && loginResult.token) {
|
||||
// 로그인 성공
|
||||
const userInfo: UserInfo = {
|
||||
userId: loginResult.userInfo.userId,
|
||||
userName: loginResult.userInfo.userName || "",
|
||||
deptName: loginResult.userInfo.deptName || "",
|
||||
companyCode: loginResult.userInfo.companyCode || "ILSHIN",
|
||||
};
|
||||
|
||||
logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`);
|
||||
|
||||
// 메뉴 조회를 위한 공통 파라미터
|
||||
const { AdminService } = await import("../services/adminService");
|
||||
const paramMap = {
|
||||
userId: loginResult.userInfo.userId,
|
||||
userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN",
|
||||
userType: loginResult.userInfo.userType,
|
||||
userLang: "ko",
|
||||
};
|
||||
|
||||
// 사용자의 첫 번째 접근 가능한 메뉴 조회
|
||||
let firstMenuPath: string | null = null;
|
||||
try {
|
||||
const menuList = await AdminService.getUserMenuList(paramMap);
|
||||
logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
|
||||
|
||||
const firstMenu = menuList.find((menu: any) => {
|
||||
const level = menu.lev || menu.level;
|
||||
const url = menu.menu_url || menu.url;
|
||||
return level >= 2 && url && url.trim() !== "" && url !== "#";
|
||||
});
|
||||
|
||||
if (firstMenu) {
|
||||
firstMenuPath = firstMenu.menu_url || firstMenu.url;
|
||||
logger.debug(`첫 번째 메뉴: ${firstMenuPath}`);
|
||||
} else {
|
||||
logger.debug("접근 가능한 메뉴 없음, 메인 페이지로 이동");
|
||||
}
|
||||
} catch (menuError) {
|
||||
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);
|
||||
}
|
||||
|
||||
// 스마트공장 활용 로그 전송 (비동기, 응답 블로킹 안 함)
|
||||
sendSmartFactoryLog({
|
||||
userId: userInfo.userId,
|
||||
remoteAddr,
|
||||
useType: "접속",
|
||||
}).catch(() => {});
|
||||
|
||||
// POP 랜딩 경로 조회
|
||||
let popLandingPath: string | null = null;
|
||||
try {
|
||||
const popResult = await AdminService.getPopMenuList(paramMap);
|
||||
if (popResult.landingMenu?.menu_url) {
|
||||
popLandingPath = popResult.landingMenu.menu_url;
|
||||
} else if (popResult.childMenus.length === 1) {
|
||||
popLandingPath = popResult.childMenus[0].menu_url;
|
||||
} else if (popResult.childMenus.length > 1) {
|
||||
popLandingPath = "/pop";
|
||||
}
|
||||
logger.debug(`POP 랜딩 경로: ${popLandingPath}`);
|
||||
} catch (popError) {
|
||||
logger.warn("POP 메뉴 조회 중 오류 (무시):", popError);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "로그인 성공",
|
||||
data: {
|
||||
userInfo,
|
||||
token: loginResult.token,
|
||||
firstMenuPath,
|
||||
popLandingPath,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 로그인 실패
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "로그인 실패",
|
||||
error: {
|
||||
code: "LOGIN_FAILED",
|
||||
details:
|
||||
loginResult.errorReason || "알 수 없는 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`로그인 API 오류: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "서버 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "SERVER_ERROR",
|
||||
details:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/switch-company
|
||||
* WACE 관리자 전용: 다른 회사로 전환
|
||||
*/
|
||||
static async switchCompany(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { companyCode } = req.body;
|
||||
const authHeader = req.get("Authorization");
|
||||
const token = authHeader && authHeader.split(" ")[1];
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 토큰이 필요합니다.",
|
||||
error: { code: "TOKEN_MISSING" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 사용자 정보 확인
|
||||
const currentUser = JwtUtils.verifyToken(token);
|
||||
|
||||
// WACE 관리자 권한 체크 (userType = "SUPER_ADMIN"만 확인)
|
||||
// 이미 다른 회사로 전환한 상태(companyCode != "*")에서도 다시 전환 가능해야 함
|
||||
if (currentUser.userType !== "SUPER_ADMIN") {
|
||||
logger.warn(`회사 전환 권한 없음: userId=${currentUser.userId}, userType=${currentUser.userType}, companyCode=${currentUser.companyCode}`);
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "회사 전환은 최고 관리자(SUPER_ADMIN)만 가능합니다.",
|
||||
error: { code: "FORBIDDEN" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 전환할 회사 코드 검증
|
||||
if (!companyCode || companyCode.trim() === "") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "전환할 회사 코드가 필요합니다.",
|
||||
error: { code: "INVALID_INPUT" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`=== WACE 관리자 회사 전환 ===`, {
|
||||
userId: currentUser.userId,
|
||||
originalCompanyCode: currentUser.companyCode,
|
||||
targetCompanyCode: companyCode,
|
||||
});
|
||||
|
||||
// 회사 코드 존재 여부 확인 (company_code가 "*"가 아닌 경우만)
|
||||
if (companyCode !== "*") {
|
||||
const { query } = await import("../database/db");
|
||||
const companies = await query<any>(
|
||||
"SELECT company_code, company_name FROM company_mng WHERE company_code = $1",
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
if (companies.length === 0) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "존재하지 않는 회사 코드입니다.",
|
||||
error: { code: "COMPANY_NOT_FOUND" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 새로운 JWT 토큰 발급 (company_code만 변경)
|
||||
const newPersonBean: PersonBean = {
|
||||
...currentUser,
|
||||
companyCode: companyCode.trim(), // 전환할 회사 코드로 변경
|
||||
};
|
||||
|
||||
const newToken = JwtUtils.generateToken(newPersonBean);
|
||||
|
||||
logger.info(`✅ 회사 전환 성공: ${currentUser.userId} → ${companyCode}`);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "회사 전환 완료",
|
||||
data: {
|
||||
token: newToken,
|
||||
companyCode: companyCode.trim(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`회사 전환 API 오류: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "회사 전환 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "SERVER_ERROR",
|
||||
details:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* 기존 Java ApiLoginController.logout() 메서드 포팅
|
||||
*/
|
||||
static async logout(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const remoteAddr = req.ip || req.connection.remoteAddress || "unknown";
|
||||
|
||||
// JWT 토큰에서 사용자 정보 추출
|
||||
const authHeader = req.get("Authorization");
|
||||
const token = authHeader && authHeader.split(" ")[1];
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const userInfo = JwtUtils.verifyToken(token);
|
||||
await AuthService.processLogout(userInfo.userId, remoteAddr);
|
||||
} catch (tokenError) {
|
||||
logger.warn(
|
||||
`로그아웃 시 토큰 검증 실패: ${tokenError instanceof Error ? tokenError.message : tokenError}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "로그아웃되었습니다.",
|
||||
data: null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`로그아웃 API 오류: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "로그아웃 처리 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "LOGOUT_ERROR",
|
||||
details:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* 기존 Java ApiLoginController.getCurrentUser() 메서드 포팅
|
||||
*/
|
||||
static async getCurrentUser(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const authHeader = req.get("Authorization");
|
||||
const token = authHeader && authHeader.split(" ")[1];
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증되지 않은 사용자입니다.",
|
||||
error: {
|
||||
code: "NOT_AUTHENTICATED",
|
||||
details: "세션이 만료되었거나 로그인이 필요합니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const userInfo = JwtUtils.verifyToken(token);
|
||||
|
||||
// DB에서 최신 사용자 정보 조회 (locale 포함)
|
||||
const dbUserInfo = await AuthService.getUserInfo(userInfo.userId);
|
||||
|
||||
if (!dbUserInfo) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "사용자 정보를 찾을 수 없습니다.",
|
||||
error: {
|
||||
code: "USER_NOT_FOUND",
|
||||
details: "사용자 정보가 삭제되었거나 존재하지 않습니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환
|
||||
// ⚠️ JWT 토큰의 companyCode를 우선 사용 (회사 전환 기능 지원)
|
||||
const userInfoResponse: any = {
|
||||
userId: dbUserInfo.userId,
|
||||
userName: dbUserInfo.userName || "",
|
||||
deptName: dbUserInfo.deptName || "",
|
||||
companyCode: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
|
||||
company_code: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
|
||||
userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선
|
||||
userTypeName: dbUserInfo.userTypeName || "일반사용자",
|
||||
email: dbUserInfo.email || "",
|
||||
photo: dbUserInfo.photo,
|
||||
locale: dbUserInfo.locale || "KR", // locale 정보 추가
|
||||
deptCode: dbUserInfo.deptCode, // 추가 필드
|
||||
isAdmin:
|
||||
dbUserInfo.userType === "ADMIN" || dbUserInfo.userId === "plm_admin",
|
||||
};
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "사용자 정보 조회 성공",
|
||||
data: userInfoResponse,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`사용자 정보 조회 API 오류: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증되지 않은 사용자입니다.",
|
||||
error: {
|
||||
code: "NOT_AUTHENTICATED",
|
||||
details: "세션이 만료되었거나 로그인이 필요합니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/status
|
||||
* 기존 Java ApiLoginController.checkAuthStatus() 메서드 포팅
|
||||
*/
|
||||
static async checkAuthStatus(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const authHeader = req.get("Authorization");
|
||||
const token = authHeader && authHeader.split(" ")[1];
|
||||
|
||||
if (!token) {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "세션 상태 확인",
|
||||
data: {
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = JwtUtils.validateToken(token);
|
||||
|
||||
if (!validation.isValid) {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "세션 상태 확인",
|
||||
data: {
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
error: validation.error,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 토큰에서 사용자 정보 추출하여 관리자 권한 확인
|
||||
let isAdmin = false;
|
||||
try {
|
||||
const userInfo = JwtUtils.verifyToken(token);
|
||||
// 기존 Java 로직과 동일: plm_admin 사용자만 관리자로 인식
|
||||
isAdmin =
|
||||
userInfo.userId === "plm_admin" || userInfo.userType === "ADMIN";
|
||||
|
||||
logger.info(`인증 상태 확인: ${userInfo.userId}, 관리자: ${isAdmin}`);
|
||||
} catch (error) {
|
||||
logger.error(`토큰에서 사용자 정보 추출 실패: ${error}`);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "세션 상태 확인",
|
||||
data: {
|
||||
isLoggedIn: true,
|
||||
isAdmin: isAdmin,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`세션 상태 확인 API 오류: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "세션 상태 확인 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "SESSION_CHECK_ERROR",
|
||||
details:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/refresh
|
||||
* JWT 토큰 갱신 API
|
||||
*/
|
||||
static async refreshToken(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const authHeader = req.get("Authorization");
|
||||
const token = authHeader && authHeader.split(" ")[1];
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "토큰이 필요합니다.",
|
||||
error: {
|
||||
code: "TOKEN_MISSING",
|
||||
details: "인증 토큰이 필요합니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newToken = JwtUtils.refreshToken(token);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "토큰 갱신 성공",
|
||||
data: {
|
||||
token: newToken,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`토큰 갱신 API 오류: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "토큰 갱신에 실패했습니다.",
|
||||
error: {
|
||||
code: "TOKEN_REFRESH_ERROR",
|
||||
details:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/signup
|
||||
* 공차중계 회원가입 API
|
||||
*/
|
||||
static async signup(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType } = req.body;
|
||||
|
||||
logger.info(`=== 공차중계 회원가입 API 호출 ===`);
|
||||
logger.info(`userId: ${userId}, vehicleNumber: ${vehicleNumber}`);
|
||||
|
||||
// 입력값 검증
|
||||
if (!userId || !password || !userName || !phoneNumber || !licenseNumber || !vehicleNumber) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 입력값이 누락되었습니다.",
|
||||
error: {
|
||||
code: "INVALID_INPUT",
|
||||
details: "아이디, 비밀번호, 이름, 연락처, 면허번호, 차량번호는 필수입니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 회원가입 처리
|
||||
const signupResult = await AuthService.signupDriver({
|
||||
userId,
|
||||
password,
|
||||
userName,
|
||||
phoneNumber,
|
||||
licenseNumber,
|
||||
vehicleNumber,
|
||||
vehicleType,
|
||||
});
|
||||
|
||||
if (signupResult.success) {
|
||||
logger.info(`공차중계 회원가입 성공: ${userId}`);
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "회원가입이 완료되었습니다.",
|
||||
});
|
||||
} else {
|
||||
logger.warn(`공차중계 회원가입 실패: ${userId} - ${signupResult.message}`);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: signupResult.message || "회원가입에 실패했습니다.",
|
||||
error: {
|
||||
code: "SIGNUP_FAILED",
|
||||
details: signupResult.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("공차중계 회원가입 API 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "회원가입 처리 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "SIGNUP_ERROR",
|
||||
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
/**
|
||||
* 바코드 라벨 관리 컨트롤러
|
||||
* ZD421 등 바코드 프린터용 라벨 CRUD 및 레이아웃/템플릿
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import barcodeLabelService from "../services/barcodeLabelService";
|
||||
|
||||
function getUserId(req: Request): string {
|
||||
return (req as any).user?.userId || "SYSTEM";
|
||||
}
|
||||
|
||||
export class BarcodeLabelController {
|
||||
async getLabels(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const page = Math.max(1, parseInt((req.query.page as string) || "1", 10));
|
||||
const limit = Math.min(100, Math.max(1, parseInt((req.query.limit as string) || "20", 10)));
|
||||
const searchText = (req.query.searchText as string) || "";
|
||||
const useYn = (req.query.useYn as string) || "Y";
|
||||
const sortBy = (req.query.sortBy as string) || "created_at";
|
||||
const sortOrder = (req.query.sortOrder as "ASC" | "DESC") || "DESC";
|
||||
|
||||
const data = await barcodeLabelService.getLabels({
|
||||
page,
|
||||
limit,
|
||||
searchText,
|
||||
useYn,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getLabelById(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { labelId } = req.params;
|
||||
const label = await barcodeLabelService.getLabelById(labelId);
|
||||
if (!label) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "바코드 라벨을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
return res.json({ success: true, data: label });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getLayout(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { labelId } = req.params;
|
||||
const layout = await barcodeLabelService.getLayout(labelId);
|
||||
if (!layout) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
return res.json({ success: true, data: layout });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createLabel(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const body = req.body as {
|
||||
labelNameKor?: string;
|
||||
labelNameEng?: string;
|
||||
description?: string;
|
||||
templateId?: string;
|
||||
};
|
||||
if (!body?.labelNameKor?.trim()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "라벨명(한글)은 필수입니다.",
|
||||
});
|
||||
}
|
||||
const labelId = await barcodeLabelService.createLabel(
|
||||
{
|
||||
labelNameKor: body.labelNameKor.trim(),
|
||||
labelNameEng: body.labelNameEng?.trim(),
|
||||
description: body.description?.trim(),
|
||||
templateId: body.templateId?.trim(),
|
||||
},
|
||||
getUserId(req)
|
||||
);
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: { labelId },
|
||||
message: "바코드 라벨이 생성되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateLabel(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { labelId } = req.params;
|
||||
const body = req.body as {
|
||||
labelNameKor?: string;
|
||||
labelNameEng?: string;
|
||||
description?: string;
|
||||
useYn?: string;
|
||||
};
|
||||
const success = await barcodeLabelService.updateLabel(
|
||||
labelId,
|
||||
{
|
||||
labelNameKor: body.labelNameKor?.trim(),
|
||||
labelNameEng: body.labelNameEng?.trim(),
|
||||
description: body.description !== undefined ? body.description : undefined,
|
||||
useYn: body.useYn,
|
||||
},
|
||||
getUserId(req)
|
||||
);
|
||||
if (!success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "바코드 라벨을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
return res.json({ success: true, message: "수정되었습니다." });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async saveLayout(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { labelId } = req.params;
|
||||
const layout = req.body as { width_mm: number; height_mm: number; components: any[] };
|
||||
if (!layout || typeof layout.width_mm !== "number" || typeof layout.height_mm !== "number" || !Array.isArray(layout.components)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "width_mm, height_mm, components 배열이 필요합니다.",
|
||||
});
|
||||
}
|
||||
await barcodeLabelService.saveLayout(
|
||||
labelId,
|
||||
{ width_mm: layout.width_mm, height_mm: layout.height_mm, components: layout.components },
|
||||
getUserId(req)
|
||||
);
|
||||
return res.json({ success: true, message: "레이아웃이 저장되었습니다." });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteLabel(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { labelId } = req.params;
|
||||
const success = await barcodeLabelService.deleteLabel(labelId);
|
||||
if (!success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "바코드 라벨을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
return res.json({ success: true, message: "삭제되었습니다." });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async copyLabel(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { labelId } = req.params;
|
||||
const newId = await barcodeLabelService.copyLabel(labelId, getUserId(req));
|
||||
if (!newId) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "바코드 라벨을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
return res.json({
|
||||
success: true,
|
||||
data: { labelId: newId },
|
||||
message: "복사되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getTemplates(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const templates = await barcodeLabelService.getTemplates();
|
||||
return res.json({ success: true, data: templates });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getTemplateById(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { templateId } = req.params;
|
||||
const template = await barcodeLabelService.getTemplateById(templateId);
|
||||
if (!template) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "템플릿을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
const layout = JSON.parse(template.layout_json);
|
||||
return res.json({ success: true, data: { ...template, layout } });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new BarcodeLabelController();
|
||||
@@ -1,339 +0,0 @@
|
||||
// 배치관리 컨트롤러
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { BatchService } from "../services/batchService";
|
||||
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
||||
import { BatchExternalDbService } from "../services/batchExternalDbService";
|
||||
import {
|
||||
BatchConfigFilter,
|
||||
CreateBatchConfigRequest,
|
||||
UpdateBatchConfigRequest,
|
||||
} from "../types/batchTypes";
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
userId: string;
|
||||
username: string;
|
||||
companyCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class BatchController {
|
||||
/**
|
||||
* 배치 설정 목록 조회 (회사별)
|
||||
* GET /api/batch-configs
|
||||
*/
|
||||
static async getBatchConfigs(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { page = 1, limit = 10, search, isActive } = req.query;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
const filter: BatchConfigFilter = {
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
search: search as string,
|
||||
is_active: isActive as string,
|
||||
};
|
||||
|
||||
const result = await BatchService.getBatchConfigs(
|
||||
filter,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.pagination,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 목록 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 목록 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용 가능한 커넥션 목록 조회
|
||||
* GET /api/batch-configs/connections
|
||||
*/
|
||||
static async getAvailableConnections(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
try {
|
||||
const result = await BatchExternalDbService.getAvailableConnections();
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("커넥션 목록 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "커넥션 목록 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 목록 조회 (내부/외부 DB)
|
||||
* GET /api/batch-configs/connections/:type/tables
|
||||
* GET /api/batch-configs/connections/:type/:id/tables
|
||||
*/
|
||||
static async getTablesFromConnection(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
try {
|
||||
const { type, id } = req.params;
|
||||
|
||||
if (!type || (type !== "internal" && type !== "external")) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)",
|
||||
});
|
||||
}
|
||||
|
||||
const connectionId = type === "external" ? Number(id) : undefined;
|
||||
const result = await BatchService.getTables(
|
||||
type as "internal" | "external",
|
||||
connectionId
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
return res.json(result);
|
||||
} else {
|
||||
return res.status(500).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "테이블 목록 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 컬럼 정보 조회 (내부/외부 DB)
|
||||
* GET /api/batch-configs/connections/:type/tables/:tableName/columns
|
||||
* GET /api/batch-configs/connections/:type/:id/tables/:tableName/columns
|
||||
*/
|
||||
static async getTableColumns(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { type, id, tableName } = req.params;
|
||||
|
||||
if (!type || !tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "연결 타입과 테이블명을 모두 지정해주세요.",
|
||||
});
|
||||
}
|
||||
|
||||
if (type !== "internal" && type !== "external") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)",
|
||||
});
|
||||
}
|
||||
|
||||
const connectionId = type === "external" ? Number(id) : undefined;
|
||||
const result = await BatchService.getColumns(
|
||||
tableName,
|
||||
type as "internal" | "external",
|
||||
connectionId
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
return res.json(result);
|
||||
} else {
|
||||
return res.status(500).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 정보 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "컬럼 정보 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 배치 설정 조회 (회사별)
|
||||
* GET /api/batch-configs/:id
|
||||
*/
|
||||
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const result = await BatchService.getBatchConfigById(Number(id));
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: result.message || "배치 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 생성
|
||||
* POST /api/batch-configs
|
||||
*/
|
||||
static async createBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { batchName, description, cronSchedule, mappings } = req.body;
|
||||
|
||||
if (
|
||||
!batchName ||
|
||||
!cronSchedule ||
|
||||
!mappings ||
|
||||
!Array.isArray(mappings)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)",
|
||||
});
|
||||
}
|
||||
|
||||
const batchConfig = await BatchService.createBatchConfig({
|
||||
batchName,
|
||||
description,
|
||||
cronSchedule,
|
||||
mappings,
|
||||
} as CreateBatchConfigRequest);
|
||||
|
||||
// 생성된 배치가 활성화 상태라면 스케줄러에 등록 (즉시 실행 비활성화)
|
||||
if (
|
||||
batchConfig.data &&
|
||||
batchConfig.data.is_active === "Y" &&
|
||||
batchConfig.data.id
|
||||
) {
|
||||
await BatchSchedulerService.updateBatchSchedule(
|
||||
batchConfig.data.id,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: batchConfig,
|
||||
message: "배치 설정이 성공적으로 생성되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 생성 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 생성에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 수정 (회사별)
|
||||
* PUT /api/batch-configs/:id
|
||||
*/
|
||||
static async updateBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { batchName, description, cronSchedule, mappings, isActive } =
|
||||
req.body;
|
||||
const userId = req.user?.userId;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
if (!batchName || !cronSchedule) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule)",
|
||||
});
|
||||
}
|
||||
|
||||
const batchConfig = await BatchService.updateBatchConfig(
|
||||
Number(id),
|
||||
{
|
||||
batchName,
|
||||
description,
|
||||
cronSchedule,
|
||||
mappings,
|
||||
isActive,
|
||||
} as UpdateBatchConfigRequest,
|
||||
userId,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
if (!batchConfig) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 스케줄러에서 배치 스케줄 업데이트 (즉시 실행 비활성화)
|
||||
await BatchSchedulerService.updateBatchSchedule(Number(id), false);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: batchConfig,
|
||||
message: "배치 설정이 성공적으로 수정되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 수정 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 수정에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 삭제 (논리 삭제, 회사별)
|
||||
* DELETE /api/batch-configs/:id
|
||||
*/
|
||||
static async deleteBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user?.userId;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const result = await BatchService.deleteBatchConfig(
|
||||
Number(id),
|
||||
userId,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "배치 설정이 성공적으로 삭제되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 삭제 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 삭제에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
// 배치 실행 로그 컨트롤러
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { BatchExecutionLogService } from "../services/batchExecutionLogService";
|
||||
import {
|
||||
BatchExecutionLogFilter,
|
||||
CreateBatchExecutionLogRequest,
|
||||
UpdateBatchExecutionLogRequest,
|
||||
} from "../types/batchExecutionLogTypes";
|
||||
|
||||
export class BatchExecutionLogController {
|
||||
/**
|
||||
* 배치 실행 로그 목록 조회
|
||||
*/
|
||||
static async getExecutionLogs(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const {
|
||||
batch_config_id,
|
||||
execution_status,
|
||||
start_date,
|
||||
end_date,
|
||||
page,
|
||||
limit,
|
||||
} = req.query;
|
||||
|
||||
const filter: BatchExecutionLogFilter = {
|
||||
batch_config_id: batch_config_id ? Number(batch_config_id) : undefined,
|
||||
execution_status: execution_status as string,
|
||||
start_date: start_date ? new Date(start_date as string) : undefined,
|
||||
end_date: end_date ? new Date(end_date as string) : undefined,
|
||||
page: page ? Number(page) : undefined,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
};
|
||||
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const result = await BatchExecutionLogService.getExecutionLogs(
|
||||
filter,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 실행 로그 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 로그 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 로그 생성
|
||||
*/
|
||||
static async createExecutionLog(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const data: CreateBatchExecutionLogRequest = req.body;
|
||||
|
||||
// 멀티테넌시: company_code가 없으면 현재 사용자 회사 코드로 설정
|
||||
if (!data.company_code) {
|
||||
data.company_code = req.user?.companyCode || "*";
|
||||
}
|
||||
|
||||
const result = await BatchExecutionLogService.createExecutionLog(data);
|
||||
|
||||
if (result.success) {
|
||||
res.status(201).json(result);
|
||||
} else {
|
||||
res.status(500).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 실행 로그 생성 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 로그 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 로그 업데이트
|
||||
*/
|
||||
static async updateExecutionLog(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const data: UpdateBatchExecutionLogRequest = req.body;
|
||||
|
||||
const result = await BatchExecutionLogService.updateExecutionLog(
|
||||
Number(id),
|
||||
data
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 실행 로그 업데이트 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 로그 업데이트 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 로그 삭제
|
||||
*/
|
||||
static async deleteExecutionLog(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await BatchExecutionLogService.deleteExecutionLog(
|
||||
Number(id)
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 실행 로그 삭제 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 로그 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 배치의 최신 실행 로그 조회
|
||||
*/
|
||||
static async getLatestExecutionLog(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { batchConfigId } = req.params;
|
||||
|
||||
const result = await BatchExecutionLogService.getLatestExecutionLog(
|
||||
Number(batchConfigId)
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("최신 배치 실행 로그 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "최신 배치 실행 로그 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 통계 조회
|
||||
*/
|
||||
static async getExecutionStats(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { batch_config_id, start_date, end_date } = req.query;
|
||||
|
||||
const result = await BatchExecutionLogService.getExecutionStats(
|
||||
batch_config_id ? Number(batch_config_id) : undefined,
|
||||
start_date ? new Date(start_date as string) : undefined,
|
||||
end_date ? new Date(end_date as string) : undefined
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 실행 통계 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 통계 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user