- 백엔드 TenantController: GET /api/tenant/check?subdomain=xxx (메타 DB 강제 라우팅 + CompanyResolver 로 존재 여부 반환) - frontend/lib/tenant/subdomain.ts: 호스트 파싱 + 예약어(solution/www/admin 등) 제외 - TenantGuard 클라이언트 컴포넌트: layout.tsx 에서 wrap, sessionStorage 로 같은 서브도메인 재체크 방지 - /tenant-not-found 페이지: v5 solid+glow 스타일 등록되지 않은 서브도메인 접속 시 즉시 /tenant-not-found 로 리다이렉트.
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
package com.erp.tenant;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 테넌트 서브도메인 검증용 공개 API.
|
||||
* 로그인 전 프론트 TenantGuard 가 "회사 존재 여부" 체크에 사용.
|
||||
*
|
||||
* GET /api/tenant/check?subdomain=qnc
|
||||
* → { "subdomain": "qnc", "exists": true }
|
||||
*
|
||||
* SecurityConfig 에서 /api/** 전부 permitAll 이라 별도 설정 불필요.
|
||||
* 메타 DB 로 강제 라우팅해야 테넌트 DB context 가 섞이지 않음.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/tenant")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TenantController {
|
||||
|
||||
private final CompanyResolver companyResolver;
|
||||
|
||||
@GetMapping("/check")
|
||||
public Map<String, Object> check(@RequestParam("subdomain") String subdomain) {
|
||||
DbContextHolder.setMeta();
|
||||
try {
|
||||
String dbName = companyResolver.resolveDbName(subdomain);
|
||||
Map<String, Object> res = new LinkedHashMap<>();
|
||||
res.put("subdomain", subdomain);
|
||||
res.put("exists", dbName != null);
|
||||
return res;
|
||||
} finally {
|
||||
DbContextHolder.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ThemeProvider } from "@/components/providers/ThemeProvider";
|
||||
import { QueryProvider } from "@/providers/QueryProvider";
|
||||
import { RegistryProvider } from "./registry-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TenantGuard } from "@/components/TenantGuard";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
@@ -51,7 +52,9 @@ export default function RootLayout({
|
||||
<div id="root" className="h-full">
|
||||
<ThemeProvider>
|
||||
<QueryProvider>
|
||||
<RegistryProvider>{children}</RegistryProvider>
|
||||
<RegistryProvider>
|
||||
<TenantGuard>{children}</TenantGuard>
|
||||
</RegistryProvider>
|
||||
<Toaster />
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, Home } from "lucide-react";
|
||||
|
||||
export default function TenantNotFoundPage() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "var(--v5-surface-solid)",
|
||||
padding: "2rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 520,
|
||||
width: "100%",
|
||||
padding: "2.6rem 2.2rem",
|
||||
background: "var(--v5-surface-solid)",
|
||||
border: "1px solid var(--v5-border)",
|
||||
borderRadius: 14,
|
||||
boxShadow: "var(--v5-glow-md)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
padding: "1rem",
|
||||
background: "rgba(var(--v5-red-rgb, 255,107,107), 0.1)",
|
||||
borderRadius: "50%",
|
||||
marginBottom: "1.2rem",
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={40} color="var(--v5-red, #ff6b6b)" strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "1.3rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--v5-text)",
|
||||
marginBottom: "0.6rem",
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
존재하지 않는 회사입니다
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.85rem",
|
||||
color: "var(--v5-text-sec)",
|
||||
lineHeight: 1.65,
|
||||
marginBottom: "1.8rem",
|
||||
}}
|
||||
>
|
||||
요청하신 서브도메인에 등록된 회사를 찾을 수 없습니다.
|
||||
<br />
|
||||
주소를 다시 확인하시거나 아래 버튼으로 메인 사이트로 이동해주세요.
|
||||
</p>
|
||||
<a
|
||||
href="https://solution.invyone.com"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.75rem 1.3rem",
|
||||
background: "var(--v5-primary)",
|
||||
color: "#fff",
|
||||
borderRadius: 8,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
boxShadow: "var(--v5-glow-sm)",
|
||||
}}
|
||||
>
|
||||
<Home size={15} strokeWidth={1.75} />
|
||||
메인 사이트로 이동
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { extractTenantSubdomain } from "@/lib/tenant/subdomain";
|
||||
|
||||
const CHECK_CACHE_KEY = "tenant-check-ok";
|
||||
|
||||
/**
|
||||
* 테넌트 서브도메인 접근 차단 Guard.
|
||||
* - 메인 사이트(solution.invyone.com 등 예약어) 는 통과
|
||||
* - *.invyone.com 테넌트 서브도메인은 백엔드 /api/tenant/check 로 존재 검증
|
||||
* - 미등록 회사면 /tenant-not-found 로 리다이렉트
|
||||
*
|
||||
* 같은 세션 내 재체크를 피하려고 sessionStorage 에 1회 캐시.
|
||||
*/
|
||||
export function TenantGuard({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (pathname === "/tenant-not-found") return;
|
||||
|
||||
const sub = extractTenantSubdomain(window.location.hostname);
|
||||
if (!sub) return;
|
||||
|
||||
if (sessionStorage.getItem(CHECK_CACHE_KEY) === sub) return;
|
||||
|
||||
apiClient
|
||||
.get(`/tenant/check`, { params: { subdomain: sub } })
|
||||
.then((res) => {
|
||||
if (res.data?.exists) {
|
||||
sessionStorage.setItem(CHECK_CACHE_KEY, sub);
|
||||
} else {
|
||||
router.replace("/tenant-not-found");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// API 호출 실패 시 낙관적으로 통과. 백엔드 잠시 재기동 중일 수 있음.
|
||||
});
|
||||
}, [pathname, router]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 테넌트 서브도메인 헬퍼.
|
||||
* 메인 사이트(solution, www, admin 등 예약어) 는 null 을 리턴해서
|
||||
* TenantGuard 가 체크를 스킵하게 한다.
|
||||
*
|
||||
* 백엔드 provisioning 의 RESERVED_SUBDOMAINS 와 같은 값을 유지할 것.
|
||||
*/
|
||||
const RESERVED_MAIN = new Set([
|
||||
"solution",
|
||||
"www",
|
||||
"admin",
|
||||
"api",
|
||||
"app",
|
||||
"static",
|
||||
"assets",
|
||||
"main",
|
||||
"mail",
|
||||
"blog",
|
||||
"dev",
|
||||
"test",
|
||||
"staging",
|
||||
"prod",
|
||||
"console",
|
||||
]);
|
||||
|
||||
export function extractTenantSubdomain(host: string): string | null {
|
||||
if (!host) return null;
|
||||
|
||||
const cleanHost = host.split(":")[0].toLowerCase();
|
||||
if (!cleanHost.endsWith(".invyone.com")) return null;
|
||||
|
||||
const prefix = cleanHost.substring(0, cleanHost.length - ".invyone.com".length);
|
||||
if (!prefix) return null;
|
||||
|
||||
if (RESERVED_MAIN.has(prefix)) return null;
|
||||
|
||||
return prefix;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "==== [1] Terminating all vexplor sessions ===="
|
||||
docker exec -i invyone-db psql -U postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname LIKE '%vexplor%' AND pid <> pg_backend_pid();" 2>&1 | tail -3
|
||||
|
||||
sleep 2
|
||||
|
||||
echo ""
|
||||
echo "==== [2] Dropping 6 test databases ===="
|
||||
for db in newtest5_vexplor newtest6_vexplor test002_vexplor test07_vexplor test08_vexplor wace_vexplor; do
|
||||
echo " - Dropping $db"
|
||||
docker exec -i invyone-db psql -U postgres -c "DROP DATABASE IF EXISTS \"$db\";" 2>&1
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "==== [3] Deleting 8 test company records ===="
|
||||
docker exec -i invyone-db psql -U postgres -d vexplor -c "DELETE FROM COMPANY_MNG WHERE COMPANY_CODE IN ('NEWTEST03','NEWTEST05','NEWTEST06','TEST07','TEST08','TEST002','WACE11','COMPANY_10');" 2>&1
|
||||
|
||||
echo ""
|
||||
echo "==== [4] RENAME vexplor -> invyone ===="
|
||||
docker exec -i invyone-db psql -U postgres -c "ALTER DATABASE vexplor RENAME TO invyone;" 2>&1
|
||||
|
||||
echo ""
|
||||
echo "==== [5] Final DB list ===="
|
||||
docker exec -i invyone-db psql -U postgres -l | grep -v template | grep -v postgres
|
||||
|
||||
echo ""
|
||||
echo "==== [6] Company count in invyone ===="
|
||||
docker exec -i invyone-db psql -U postgres -d invyone -tAc "SELECT COUNT(*) || ' companies in COMPANY_MNG' FROM COMPANY_MNG;"
|
||||
|
||||
echo ""
|
||||
echo "==== [7] Scale backend back to 1 ===="
|
||||
KUBECONFIG=/home/chpark/.kube/config kubectl scale deployment backend-spring --replicas=1 -n invyone
|
||||
|
||||
echo ""
|
||||
echo "==== DONE ===="
|
||||
Reference in New Issue
Block a user