- application.yml, k8s configmap, docker-compose 4종: SPRING_DATASOURCE_URL path
- provisioning 코드: {prefix}_vexplor → {prefix}_invyone 테넌트 DB 네이밍 규칙
- 프론트 마법사: Step1Basic, Step4Run 미리보기 라벨
- CompanyAccordionRow: 기본 DB 이름 포맷
- 마이그레이션/멀티테넌시 문서 동기화
- Traefik 와일드카드 설정 산출물 보관 (notes/)
비밀번호(vexplor0909!!) 및 역사 기록 문서(INVYONE_CONCEPT,
DDD1542, test-output, dashboard-runtime-fixes) 는 의도적으로 미변경.
This commit is contained in:
@@ -24,7 +24,7 @@ import java.util.UUID;
|
||||
*
|
||||
* 6 단계:
|
||||
* 1. REGISTER_META — COMPANY_MNG 에 db_status='provisioning' 으로 row 선반영 (initiate)
|
||||
* 2. CREATE_DATABASE — CREATE DATABASE "{prefix}_vexplor"
|
||||
* 2. CREATE_DATABASE — CREATE DATABASE "{prefix}_invyone"
|
||||
* 3. COPY_SCHEMA — pg_dump --schema-only | psql
|
||||
* 4. COPY_DATA — 선택 + 필수 그룹의 테이블들 JDBC 복사
|
||||
* 5. CREATE_ADMIN — user_info 에 {prefix}_admin INSERT (BCrypt)
|
||||
@@ -53,7 +53,7 @@ public class CompanyProvisioningService {
|
||||
|
||||
private String metaDbName() {
|
||||
int slash = metaJdbcUrl.lastIndexOf('/');
|
||||
if (slash < 0) return "vexplor";
|
||||
if (slash < 0) return "invyone";
|
||||
String tail = metaJdbcUrl.substring(slash + 1);
|
||||
int q = tail.indexOf('?');
|
||||
return q < 0 ? tail : tail.substring(0, q);
|
||||
@@ -63,7 +63,7 @@ public class CompanyProvisioningService {
|
||||
public ProvisioningStatus initiate(Map<String, Object> req) {
|
||||
String companyCode = (String) req.get("company_code");
|
||||
String dbPrefix = (String) req.get("db_prefix");
|
||||
String dbName = dbPrefix + "_vexplor";
|
||||
String dbName = dbPrefix + "_invyone";
|
||||
String subdomain = (String) req.get("subdomain");
|
||||
|
||||
// 템플릿 그룹 수 집계: 필수(3) + 선택된 optional
|
||||
|
||||
@@ -99,7 +99,7 @@ public class ProvisioningController {
|
||||
|
||||
if (dbPrefix != null) {
|
||||
boolean formatOk = isValidDbPrefix(dbPrefix);
|
||||
String dbName = dbPrefix + "_vexplor";
|
||||
String dbName = dbPrefix + "_invyone";
|
||||
boolean exists = false;
|
||||
if (formatOk) {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
@@ -161,7 +161,7 @@ public class ProvisioningController {
|
||||
"error", "invalid_format", "field", "db_prefix"));
|
||||
}
|
||||
// 중복
|
||||
String dbName = dbPrefix + "_vexplor";
|
||||
String dbName = dbPrefix + "_invyone";
|
||||
if (sqlSession.selectOne("provisioning.existsCompanyCode", Map.of("company_code", companyCode)) != null) {
|
||||
return ResponseEntity.status(409).body(Map.of("error", "duplicate", "field", "company_code"));
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ public class ProvisioningStatus {
|
||||
private String id;
|
||||
/** 생성 요청한 회사 코드 */
|
||||
private String companyCode;
|
||||
/** 생성 대상 DB 이름 (예: qnc_vexplor) */
|
||||
/** 생성 대상 DB 이름 (예: qnc_invyone) */
|
||||
private String dbName;
|
||||
/** 서브도메인 */
|
||||
private String subdomain;
|
||||
|
||||
@@ -14,7 +14,7 @@ spring:
|
||||
jackson:
|
||||
default-property-inclusion: always
|
||||
datasource:
|
||||
url: jdbc:postgresql://183.99.177.40:5432/vexplor
|
||||
url: jdbc:postgresql://183.99.177.40:5432/invyone
|
||||
username: postgres
|
||||
password: "vexplor0909!!"
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
SOURCE_HOST="localhost"
|
||||
SOURCE_PORT="5432"
|
||||
SOURCE_DB="vexplor"
|
||||
SOURCE_DB="invyone"
|
||||
SOURCE_USER="postgres"
|
||||
|
||||
TARGET_HOST="대상_호스트"
|
||||
|
||||
@@ -14,7 +14,7 @@ Phase 1 은 **라우팅 no-op** 단계 — 컬럼만 추가, 실제 라우팅은
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|---|---|---|
|
||||
| `DB_NAME` | VARCHAR(64) | 실제 DB 이름 (예: `qnc_vexplor`). Phase 3 이전엔 NULL 허용 |
|
||||
| `DB_NAME` | VARCHAR(64) | 실제 DB 이름 (예: `qnc_invyone`). Phase 3 이전엔 NULL 허용 |
|
||||
| `SUBDOMAIN` | VARCHAR(64) | 접속 서브도메인 (예: `qnc`). UNIQUE |
|
||||
| `DB_HOST` | VARCHAR(128) | DB 서버 호스트 (향후 분산 대비). 기본 NULL |
|
||||
| `DB_STATUS` | VARCHAR(20) | `provisioning` / `schema_copied` / `admin_created` / `active` / `failed` / `suspended`. 기본 `active` |
|
||||
@@ -44,13 +44,13 @@ CREATE UNIQUE INDEX IF NOT EXISTS UX_COMPANY_MNG_SUBDOMAIN
|
||||
## 실행 방법
|
||||
|
||||
### 방법 1: DBeaver / pgAdmin
|
||||
1. `vexplor` DB 연결
|
||||
1. `invyone` DB 연결
|
||||
2. 위 SQL 블록 복사 & 실행
|
||||
3. 확인 쿼리 돌려서 컬럼 생성 여부 확인
|
||||
|
||||
### 방법 2: psql
|
||||
```bash
|
||||
psql -h 183.99.177.40 -U postgres -d vexplor <<'SQL'
|
||||
psql -h 183.99.177.40 -U postgres -d invyone <<'SQL'
|
||||
ALTER TABLE COMPANY_MNG
|
||||
ADD COLUMN IF NOT EXISTS DB_NAME VARCHAR(64),
|
||||
ADD COLUMN IF NOT EXISTS SUBDOMAIN VARCHAR(64),
|
||||
@@ -99,6 +99,6 @@ ALTER TABLE COMPANY_MNG
|
||||
|
||||
## 체크리스트
|
||||
|
||||
- [ ] `vexplor` DB 에 위 SQL 실행
|
||||
- [ ] `invyone` DB 에 위 SQL 실행
|
||||
- [ ] 확인 쿼리 1, 2, 3 모두 통과
|
||||
- [ ] `SELECT * FROM COMPANY_MNG LIMIT 1;` 로 기존 데이터 정상 조회 확인
|
||||
|
||||
@@ -26,11 +26,11 @@ CREATE UNIQUE INDEX IF NOT EXISTS UX_COMPANY_MNG_DB_NAME
|
||||
## 실행 방법
|
||||
|
||||
### DBeaver / pgAdmin
|
||||
위 SQL 블록을 `vexplor` DB 에 실행.
|
||||
위 SQL 블록을 `invyone` DB 에 실행.
|
||||
|
||||
### psql
|
||||
```bash
|
||||
psql -h 183.99.177.40 -U postgres -d vexplor <<'SQL'
|
||||
psql -h 183.99.177.40 -U postgres -d invyone <<'SQL'
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS UX_COMPANY_MNG_DB_NAME
|
||||
ON COMPANY_MNG (DB_NAME)
|
||||
WHERE DB_NAME IS NOT NULL;
|
||||
|
||||
@@ -39,7 +39,7 @@ ALTER TABLE COMPANY_MNG
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
psql -h 183.99.177.40 -U postgres -d vexplor <<'SQL'
|
||||
psql -h 183.99.177.40 -U postgres -d invyone <<'SQL'
|
||||
ALTER TABLE COMPANY_MNG
|
||||
ADD COLUMN IF NOT EXISTS PLAN VARCHAR(20) DEFAULT 'Starter',
|
||||
ADD COLUMN IF NOT EXISTS INDUSTRY VARCHAR(50),
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
environment:
|
||||
- SPRING_PROFILES_ACTIVE=dev
|
||||
- SERVER_PORT=8081
|
||||
- SPRING_DATASOURCE_URL=jdbc:postgresql://183.99.177.40:5432/vexplor
|
||||
- SPRING_DATASOURCE_URL=jdbc:postgresql://183.99.177.40:5432/invyone
|
||||
- SPRING_DATASOURCE_USERNAME=postgres
|
||||
- SPRING_DATASOURCE_PASSWORD=vexplor0909!!
|
||||
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
||||
|
||||
@@ -11,7 +11,7 @@ services:
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: prod
|
||||
SERVER_PORT: "8081"
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://183.99.177.40:5432/vexplor
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://183.99.177.40:5432/invyone
|
||||
SPRING_DATASOURCE_USERNAME: postgres
|
||||
SPRING_DATASOURCE_PASSWORD: "vexplor0909!!"
|
||||
JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
environment:
|
||||
- SPRING_PROFILES_ACTIVE=dev
|
||||
- SERVER_PORT=8082
|
||||
- SPRING_DATASOURCE_URL=jdbc:postgresql://183.99.177.40:5432/vexplor
|
||||
- SPRING_DATASOURCE_URL=jdbc:postgresql://183.99.177.40:5432/invyone
|
||||
- SPRING_DATASOURCE_USERNAME=postgres
|
||||
- SPRING_DATASOURCE_PASSWORD=vexplor0909!!
|
||||
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
environment:
|
||||
- SPRING_PROFILES_ACTIVE=prod
|
||||
- SERVER_PORT=8081
|
||||
- SPRING_DATASOURCE_URL=jdbc:postgresql://183.99.177.40:5432/vexplor
|
||||
- SPRING_DATASOURCE_URL=jdbc:postgresql://183.99.177.40:5432/invyone
|
||||
- SPRING_DATASOURCE_USERNAME=postgres
|
||||
- SPRING_DATASOURCE_PASSWORD=vexplor0909!!
|
||||
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
INVYONE 은 **회사(=tenant)별로 독립된 PostgreSQL 데이터베이스**를 가진다. 접속한 서브도메인에 따라 요청이 자동으로 해당 회사 DB 로 라우팅된다.
|
||||
|
||||
```
|
||||
qnc.invyone.com → qnc_vexplor DB
|
||||
kookje.invyone.com → kookje_vexplor DB
|
||||
vexplor (메타 DB) → 회사 정보·라우팅 테이블·SUPER_ADMIN 데이터
|
||||
qnc.invyone.com → qnc_invyone DB
|
||||
kookje.invyone.com → kookje_invyone DB
|
||||
invyone (메타 DB) → 회사 정보·라우팅 테이블·SUPER_ADMIN 데이터
|
||||
```
|
||||
|
||||
### 설계 원칙
|
||||
@@ -46,25 +46,25 @@ vexplor (메타 DB) → 회사 정보·라우팅 테이블·SUPER_ADMIN 데이
|
||||
↓ (HTTP Host 헤더)
|
||||
[SubdomainResolverFilter]
|
||||
↓ subdomain=qnc → CompanyResolver.resolveDbName("qnc")
|
||||
↓ DbContextHolder.set("qnc_vexplor")
|
||||
↓ DbContextHolder.set("qnc_invyone")
|
||||
[JwtAuthenticationFilter]
|
||||
↓
|
||||
[Controller → Service → sqlSession.selectList(...)]
|
||||
↓
|
||||
[TenantRoutingDataSource.determineTargetDataSource()]
|
||||
↓ DbContextHolder.get() = "qnc_vexplor"
|
||||
[HikariDataSource for qnc_vexplor] → 실제 SQL 실행
|
||||
↓ DbContextHolder.get() = "qnc_invyone"
|
||||
[HikariDataSource for qnc_invyone] → 실제 SQL 실행
|
||||
```
|
||||
|
||||
### 2.3 메타 DB 의 역할
|
||||
|
||||
`vexplor` DB (COMPANY_MNG 테이블) 는 **라우팅 룩업 테이블**만 담당:
|
||||
`invyone` DB (COMPANY_MNG 테이블) 는 **라우팅 룩업 테이블**만 담당:
|
||||
|
||||
| 컬럼 | 용도 |
|
||||
|---|---|
|
||||
| `COMPANY_CODE` | 회사 식별자 (PK) |
|
||||
| `SUBDOMAIN` | 서브도메인 prefix (UNIQUE) |
|
||||
| `DB_NAME` | 실제 tenant DB 이름 (예: `qnc_vexplor`) (UNIQUE) |
|
||||
| `DB_NAME` | 실제 tenant DB 이름 (예: `qnc_invyone`) (UNIQUE) |
|
||||
| `DB_HOST` | tenant DB 호스트 (분산 대비, 현재 단일) |
|
||||
| `DB_STATUS` | `provisioning` / `active` / `failed` / `suspended` |
|
||||
| `PLAN`, `INDUSTRY`, `TEMPLATES_COUNT`, `DB_QUOTA_GB` | UI 메타 |
|
||||
@@ -87,7 +87,7 @@ vexplor (메타 DB) → 회사 정보·라우팅 테이블·SUPER_ADMIN 데이
|
||||
|
||||
```
|
||||
1. REGISTER_META COMPANY_MNG 에 status='provisioning' 선반영
|
||||
2. CREATE_DATABASE CREATE DATABASE "{prefix}_vexplor" (postgres 기본 DB 경유)
|
||||
2. CREATE_DATABASE CREATE DATABASE "{prefix}_invyone" (postgres 기본 DB 경유)
|
||||
3. COPY_SCHEMA pg_dump --schema-only | psql (ProcessBuilder 배열 인자)
|
||||
4. COPY_DATA 선택된 그룹 테이블 JDBC 복사 (company_code 필터)
|
||||
5. CREATE_ADMIN {prefix}_admin BCrypt 계정 생성
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function CompanyAccordionRow({
|
||||
const sub = r.subdomain || "";
|
||||
const name = r.company_name || r.name || r.company_code;
|
||||
const plan = (r.plan || "Starter").toString();
|
||||
const dbName = r.db_name || `${sub}_vexplor`;
|
||||
const dbName = r.db_name || `${sub}_invyone`;
|
||||
const dbPct = Number(r.db_pct) || 0;
|
||||
const users = Number(r.users) || 0;
|
||||
const active30 = Number(r.active30) || 0;
|
||||
|
||||
@@ -167,7 +167,7 @@ export default function Step1Basic({
|
||||
value={state.db_prefix || ""}
|
||||
onChange={onDbPrefixChange}
|
||||
placeholder="영문 소문자 · 숫자 · 밑줄"
|
||||
suffix="_vexplor"
|
||||
suffix="_invyone"
|
||||
mono
|
||||
status={availToInputStatus(prefStatus)}
|
||||
/>
|
||||
@@ -289,7 +289,7 @@ export default function Step1Basic({
|
||||
<span style={{ color: "var(--v5-primary)", fontWeight: 700 }}>
|
||||
{state.db_prefix || "___"}
|
||||
</span>
|
||||
<span style={{ color: "var(--v5-text-sec)" }}>_vexplor</span>
|
||||
<span style={{ color: "var(--v5-text-sec)" }}>_invyone</span>
|
||||
</PreviewField>
|
||||
|
||||
<PreviewField label="회사 코드">
|
||||
|
||||
@@ -527,7 +527,7 @@ export default function Step4Run({
|
||||
idx={i}
|
||||
status={rowStatus(s.key)}
|
||||
isLast={i === DISPLAY_STEPS.length - 1}
|
||||
dbName={`${state.db_prefix || "___"}_vexplor`}
|
||||
dbName={`${state.db_prefix || "___"}_invyone`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -6,13 +6,13 @@ metadata:
|
||||
data:
|
||||
SPRING_PROFILES_ACTIVE: "prod"
|
||||
SERVER_PORT: "8081"
|
||||
SPRING_DATASOURCE_URL: "jdbc:postgresql://183.99.177.40:5432/vexplor"
|
||||
SPRING_DATASOURCE_URL: "jdbc:postgresql://183.99.177.40:5432/invyone"
|
||||
SPRING_DATASOURCE_USERNAME: "postgres"
|
||||
JWT_EXPIRATION: "86400000"
|
||||
FILE_UPLOAD_DIR: "./uploads"
|
||||
NODE_ENV: "production"
|
||||
NODE_PORT: "8080"
|
||||
DATABASE_URL: "postgresql://postgres:vexplor0909!!@183.99.177.40:5432/vexplor"
|
||||
DATABASE_URL: "postgresql://postgres:vexplor0909!!@183.99.177.40:5432/invyone"
|
||||
CORS_ORIGIN: "https://solution.invyone.com"
|
||||
NEXT_PUBLIC_API_URL: "https://solution.invyone.com/api"
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
| 목업에 없지만 UI 에서 파생 | 소스 |
|
||||
|---|---|
|
||||
| `{prefix}.invyone.com` 프리뷰 | `SUBDOMAIN` + 하드코딩 도메인 |
|
||||
| `{prefix}_vexplor` DB 명 | `DB_NAME` |
|
||||
| `{prefix}_invyone` DB 명 | `DB_NAME` |
|
||||
| 상태 dot | `DB_STATUS` |
|
||||
|
||||
### 1.2 런타임 집계 (CompanyStatsService)
|
||||
@@ -67,7 +67,7 @@
|
||||
| 목업 필드 | 계산 |
|
||||
|---|---|
|
||||
| `url_preview` (`qnc.invyone.com`) | `${subdomain}.invyone.com` |
|
||||
| `db_name_display` (`qnc_vexplor`) | `db_name` (이미 있음) |
|
||||
| `db_name_display` (`qnc_invyone`) | `db_name` (이미 있음) |
|
||||
|
||||
---
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
"company_code": "COMPANY_10",
|
||||
"company_name": "큐엔씨",
|
||||
"subdomain": "qnc",
|
||||
"db_name": "qnc_vexplor",
|
||||
"db_name": "qnc_invyone",
|
||||
"db_host": "183.99.177.40",
|
||||
"db_status": "active",
|
||||
"status": "active",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v2.11
|
||||
container_name: traefik
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
command:
|
||||
- --api.insecure=false
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --providers.docker.network=traefik-net
|
||||
- --providers.file.directory=/dynamic
|
||||
- --providers.file.watch=true
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --entrypoints.web.http.redirections.entryPoint.to=websecure
|
||||
- --entrypoints.web.http.redirections.entryPoint.scheme=https
|
||||
- --entrypoints.web.http.redirections.entryPoint.permanent=true
|
||||
- --certificatesresolvers.le.acme.httpchallenge=true
|
||||
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
|
||||
- --certificatesresolvers.le.acme.email=admin@junggomoa.com
|
||||
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
|
||||
- --certificatesresolvers.ledns.acme.dnschallenge=true
|
||||
- --certificatesresolvers.ledns.acme.dnschallenge.provider=porkbun
|
||||
- --certificatesresolvers.ledns.acme.email=admin@junggomoa.com
|
||||
- --certificatesresolvers.ledns.acme.storage=/letsencrypt/acme-dns.json
|
||||
- --log.level=INFO
|
||||
environment:
|
||||
- PORKBUN_API_KEY=${PORKBUN_API_KEY}
|
||||
- PORKBUN_SECRET_API_KEY=${PORKBUN_SECRET_API_KEY}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- /opt/docker/traefik/letsencrypt:/letsencrypt
|
||||
- /opt/docker/traefik/dynamic:/dynamic:ro
|
||||
networks:
|
||||
- traefik-net
|
||||
|
||||
networks:
|
||||
traefik-net:
|
||||
external: true
|
||||
@@ -0,0 +1,35 @@
|
||||
http:
|
||||
routers:
|
||||
invyone-tenant-frontend:
|
||||
rule: "HostRegexp(`{sub:[a-z0-9-]+}.invyone.com`)"
|
||||
entryPoints:
|
||||
- web
|
||||
- websecure
|
||||
service: invyone-tenant-frontend
|
||||
tls:
|
||||
certResolver: ledns
|
||||
domains:
|
||||
- main: invyone.com
|
||||
sans:
|
||||
- "*.invyone.com"
|
||||
|
||||
invyone-tenant-api:
|
||||
rule: "HostRegexp(`{sub:[a-z0-9-]+}.invyone.com`) && PathPrefix(`/api`)"
|
||||
entryPoints:
|
||||
- web
|
||||
- websecure
|
||||
service: invyone-tenant-api
|
||||
tls:
|
||||
certResolver: ledns
|
||||
priority: 100
|
||||
|
||||
services:
|
||||
invyone-tenant-frontend:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://host.docker.internal:30000"
|
||||
|
||||
invyone-tenant-api:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://host.docker.internal:30081"
|
||||
Reference in New Issue
Block a user