feat: Re:Link MVP 초기 구현 - 도메인/서비스/프론트엔드 전체

- 모노레포 구조 (Turborepo + pnpm): @relink/domain, @relink/shared, @relink/infrastructure, @relink/database, @relink/web
- 도메인 레이어: 매장(store), 매칭(matching), 업체(vendor), 보조금(subsidy), 계약/에스크로(contract) TDD 완료 (158 단위 테스트)
- 서비스 레이어: 전 도메인 서비스 함수 + 통합 테스트 (58 테스트)
- 프론트엔드: Next.js 15 App Router, 13개 페이지 (사용자 6 + 관리자 7)
- 인프라: PostgreSQL 16 + PostGIS, Prisma ORM, Docker Compose, AuditLog + OutboxEvent 패턴
- .env 파일 포함 (로컬 개발 기본값만 포함, 실제 시크릿 없음)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Johngreen
2026-03-07 17:39:56 +09:00
commit 16bd2cb92a
170 changed files with 23628 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
DATABASE_URL="postgresql://relink:relink_dev@localhost:5432/relink_dev"
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@relink/database",
"version": "0.0.1",
"private": false,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist",
"prisma:generate": "prisma generate",
"prisma:push": "prisma db push",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "tsx seeds/seed.ts"
},
"prisma": {
"seed": "tsx seeds/seed.ts"
},
"dependencies": {
"@prisma/client": "^6.1.0"
},
"devDependencies": {
"@types/node": "^22.10.2",
"prisma": "^6.1.0",
"tsup": "^8.3.5",
"tsx": "^4.19.0",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
code,name_ko,parent_code,depth,sort_order,is_leaf,is_active,is_beta_enabled
FNB,음식점/카페,,0,1,false,true,true
FNB.CAFE,카페,FNB,1,1,true,true,true
FNB.BAKERY,베이커리,FNB,1,2,true,true,true
FNB.KOREAN,한식,FNB,1,3,true,true,true
FNB.CHICKEN,치킨,FNB,1,4,true,true,true
FNB.BAR,주점,FNB,1,5,true,true,true
FNB.DESSERT,디저트,FNB,1,6,true,true,true
FNB.FASTCASUAL,간편식/패스트캐주얼,FNB,1,7,true,true,true
1 code name_ko parent_code depth sort_order is_leaf is_active is_beta_enabled
2 FNB 음식점/카페 0 1 false true true
3 FNB.CAFE 카페 FNB 1 1 true true true
4 FNB.BAKERY 베이커리 FNB 1 2 true true true
5 FNB.KOREAN 한식 FNB 1 3 true true true
6 FNB.CHICKEN 치킨 FNB 1 4 true true true
7 FNB.BAR 주점 FNB 1 5 true true true
8 FNB.DESSERT 디저트 FNB 1 6 true true true
9 FNB.FASTCASUAL 간편식/패스트캐주얼 FNB 1 7 true true true
@@ -0,0 +1,13 @@
code,name_ko,full_name_ko,region_type,parent_code,depth,path_code,sort_order,is_active,is_beta_enabled,latitude,longitude
KR,대한민국,대한민국,COUNTRY,,0,KR,1,true,false,,
KR.SEOUL,서울특별시,서울특별시,SIDO,KR,1,KR.SEOUL,1,true,false,37.5665,126.9780
KR.SEOUL.GANGNAM,강남구,서울특별시 강남구,SIGUNGU,KR.SEOUL,2,KR.SEOUL.GANGNAM,1,true,false,37.5172,127.0473
KR.SEOUL.MAPO,마포구,서울특별시 마포구,SIGUNGU,KR.SEOUL,2,KR.SEOUL.MAPO,2,true,false,37.5663,126.9014
KR.SEOUL.GANGNAM.YEOKSAM,역삼,서울특별시 강남구 역삼,COMMERCIAL_AREA,KR.SEOUL.GANGNAM,3,KR.SEOUL.GANGNAM.YEOKSAM,1,true,true,37.5007,127.0365
KR.SEOUL.GANGNAM.SEOLLEUNG,선릉,서울특별시 강남구 선릉,COMMERCIAL_AREA,KR.SEOUL.GANGNAM,3,KR.SEOUL.GANGNAM.SEOLLEUNG,2,true,true,37.5045,127.0490
KR.SEOUL.GANGNAM.NONHYEON,논현,서울특별시 강남구 논현,COMMERCIAL_AREA,KR.SEOUL.GANGNAM,3,KR.SEOUL.GANGNAM.NONHYEON,3,true,true,37.5112,127.0276
KR.SEOUL.MAPO.HONGDAE,홍대,서울특별시 마포구 홍대,COMMERCIAL_AREA,KR.SEOUL.MAPO,3,KR.SEOUL.MAPO.HONGDAE,1,true,true,37.5563,126.9237
KR.SEOUL.MAPO.HAPJEONG,합정,서울특별시 마포구 합정,COMMERCIAL_AREA,KR.SEOUL.MAPO,3,KR.SEOUL.MAPO.HAPJEONG,2,true,true,37.5496,126.9139
KR.SEOUL.MAPO.YEONNAM,연남,서울특별시 마포구 연남,COMMERCIAL_AREA,KR.SEOUL.MAPO,3,KR.SEOUL.MAPO.YEONNAM,3,true,true,37.5660,126.9247
KR.BETA.GANGNAM_CORE,강남권 베타 클러스터,강남권 베타 클러스터,BETA_CLUSTER,KR.SEOUL.GANGNAM,3,KR.BETA.GANGNAM_CORE,10,true,true,37.5058,127.0370
KR.BETA.MAPO_CORE,마포권 베타 클러스터,마포권 베타 클러스터,BETA_CLUSTER,KR.SEOUL.MAPO,3,KR.BETA.MAPO_CORE,10,true,true,37.5573,126.9192
1 code name_ko full_name_ko region_type parent_code depth path_code sort_order is_active is_beta_enabled latitude longitude
2 KR 대한민국 대한민국 COUNTRY 0 KR 1 true false
3 KR.SEOUL 서울특별시 서울특별시 SIDO KR 1 KR.SEOUL 1 true false 37.5665 126.9780
4 KR.SEOUL.GANGNAM 강남구 서울특별시 강남구 SIGUNGU KR.SEOUL 2 KR.SEOUL.GANGNAM 1 true false 37.5172 127.0473
5 KR.SEOUL.MAPO 마포구 서울특별시 마포구 SIGUNGU KR.SEOUL 2 KR.SEOUL.MAPO 2 true false 37.5663 126.9014
6 KR.SEOUL.GANGNAM.YEOKSAM 역삼 서울특별시 강남구 역삼 COMMERCIAL_AREA KR.SEOUL.GANGNAM 3 KR.SEOUL.GANGNAM.YEOKSAM 1 true true 37.5007 127.0365
7 KR.SEOUL.GANGNAM.SEOLLEUNG 선릉 서울특별시 강남구 선릉 COMMERCIAL_AREA KR.SEOUL.GANGNAM 3 KR.SEOUL.GANGNAM.SEOLLEUNG 2 true true 37.5045 127.0490
8 KR.SEOUL.GANGNAM.NONHYEON 논현 서울특별시 강남구 논현 COMMERCIAL_AREA KR.SEOUL.GANGNAM 3 KR.SEOUL.GANGNAM.NONHYEON 3 true true 37.5112 127.0276
9 KR.SEOUL.MAPO.HONGDAE 홍대 서울특별시 마포구 홍대 COMMERCIAL_AREA KR.SEOUL.MAPO 3 KR.SEOUL.MAPO.HONGDAE 1 true true 37.5563 126.9237
10 KR.SEOUL.MAPO.HAPJEONG 합정 서울특별시 마포구 합정 COMMERCIAL_AREA KR.SEOUL.MAPO 3 KR.SEOUL.MAPO.HAPJEONG 2 true true 37.5496 126.9139
11 KR.SEOUL.MAPO.YEONNAM 연남 서울특별시 마포구 연남 COMMERCIAL_AREA KR.SEOUL.MAPO 3 KR.SEOUL.MAPO.YEONNAM 3 true true 37.5660 126.9247
12 KR.BETA.GANGNAM_CORE 강남권 베타 클러스터 강남권 베타 클러스터 BETA_CLUSTER KR.SEOUL.GANGNAM 3 KR.BETA.GANGNAM_CORE 10 true true 37.5058 127.0370
13 KR.BETA.MAPO_CORE 마포권 베타 클러스터 마포권 베타 클러스터 BETA_CLUSTER KR.SEOUL.MAPO 3 KR.BETA.MAPO_CORE 10 true true 37.5573 126.9192
+157
View File
@@ -0,0 +1,157 @@
import { PrismaClient } from '@prisma/client';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const prisma = new PrismaClient();
interface RegionRow {
code: string;
name_ko: string;
full_name_ko: string;
region_type: string;
parent_code: string;
depth: string;
path_code: string;
sort_order: string;
is_active: string;
is_beta_enabled: string;
latitude: string;
longitude: string;
}
interface IndustryRow {
code: string;
name_ko: string;
parent_code: string;
depth: string;
sort_order: string;
is_leaf: string;
is_active: string;
is_beta_enabled: string;
}
function parseCsv(filePath: string): Record<string, string>[] {
const content = readFileSync(filePath, 'utf-8');
const lines = content.trim().split('\n');
const headers = lines[0]!.split(',');
return lines.slice(1).map((line) => {
const values = line.split(',');
const row: Record<string, string> = {};
for (let i = 0; i < headers.length; i++) {
row[headers[i]!] = values[i] ?? '';
}
return row;
});
}
async function seedRegions(): Promise<void> {
const csvPath = resolve(__dirname, 'master-data/regions.v1.csv');
const rows = parseCsv(csvPath) as unknown as RegionRow[];
// code → id 매핑을 위한 맵
const codeToId = new Map<string, bigint>();
// depth 순으로 정렬하여 부모를 먼저 upsert
const sorted = [...rows].sort((a, b) => parseInt(a.depth) - parseInt(b.depth));
for (const row of sorted) {
const parentId = row.parent_code ? (codeToId.get(row.parent_code) ?? null) : null;
const result = await prisma.regionHierarchy.upsert({
where: { code: row.code },
update: {
nameKo: row.name_ko,
fullNameKo: row.full_name_ko || null,
regionType: row.region_type as never,
parentId,
depth: parseInt(row.depth),
pathCode: row.path_code,
sortOrder: parseInt(row.sort_order),
isActive: row.is_active === 'true',
isBetaEnabled: row.is_beta_enabled === 'true',
latitude: row.latitude ? parseFloat(row.latitude) : null,
longitude: row.longitude ? parseFloat(row.longitude) : null,
},
create: {
code: row.code,
nameKo: row.name_ko,
fullNameKo: row.full_name_ko || null,
regionType: row.region_type as never,
parentId,
depth: parseInt(row.depth),
pathCode: row.path_code,
sortOrder: parseInt(row.sort_order),
isActive: row.is_active === 'true',
isBetaEnabled: row.is_beta_enabled === 'true',
latitude: row.latitude ? parseFloat(row.latitude) : null,
longitude: row.longitude ? parseFloat(row.longitude) : null,
},
});
codeToId.set(row.code, result.id);
}
console.log(`Seeded ${sorted.length} regions`);
}
async function seedIndustries(): Promise<void> {
const csvPath = resolve(__dirname, 'master-data/industries.v1.csv');
const rows = parseCsv(csvPath) as unknown as IndustryRow[];
const codeToId = new Map<string, bigint>();
const sorted = [...rows].sort((a, b) => parseInt(a.depth) - parseInt(b.depth));
for (const row of sorted) {
const parentId = row.parent_code ? (codeToId.get(row.parent_code) ?? null) : null;
const result = await prisma.industryTaxonomy.upsert({
where: { code: row.code },
update: {
nameKo: row.name_ko,
parentId,
depth: parseInt(row.depth),
sortOrder: parseInt(row.sort_order),
isLeaf: row.is_leaf === 'true',
isActive: row.is_active === 'true',
isBetaEnabled: row.is_beta_enabled === 'true',
},
create: {
code: row.code,
nameKo: row.name_ko,
parentId,
depth: parseInt(row.depth),
sortOrder: parseInt(row.sort_order),
isLeaf: row.is_leaf === 'true',
isActive: row.is_active === 'true',
isBetaEnabled: row.is_beta_enabled === 'true',
},
});
codeToId.set(row.code, result.id);
}
console.log(`Seeded ${sorted.length} industries`);
}
async function main(): Promise<void> {
console.log('Starting seed...');
await seedRegions();
await seedIndustries();
console.log('Seed completed successfully');
}
main()
.catch((e) => {
console.error('Seed failed:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+26
View File
@@ -0,0 +1,26 @@
import { PrismaClient } from '@prisma/client';
let prismaInstance: PrismaClient | null = null;
/**
* Creates or returns a singleton PrismaClient instance.
* In development, reuses the instance across hot-reloads via a global variable.
*/
export function createPrismaClient(): PrismaClient {
if (prismaInstance) {
return prismaInstance;
}
const client = new PrismaClient({
log:
process.env['NODE_ENV'] === 'development'
? ['query', 'error', 'warn']
: ['error'],
});
if (process.env['NODE_ENV'] !== 'production') {
prismaInstance = client;
}
return client;
}
+12
View File
@@ -0,0 +1,12 @@
/**
* @relink/database - Prisma client and database utilities
*/
export { createPrismaClient } from './client.js';
export {
getTestPrismaClient,
setupTestDatabase,
teardownTestDatabase,
cleanAllTables,
seedTestMasterData,
} from './test-helpers.js';
+194
View File
@@ -0,0 +1,194 @@
import { PrismaClient } from '@prisma/client';
import { execSync } from 'node:child_process';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCHEMA_PATH = resolve(__dirname, '../prisma/schema.prisma');
const TEST_DATABASE_URL =
process.env['DATABASE_TEST_URL'] ??
'postgresql://relink:relink_test@localhost:5433/relink_test';
let testClient: PrismaClient | null = null;
export function getTestPrismaClient(): PrismaClient {
if (testClient) {
return testClient;
}
testClient = new PrismaClient({
datasourceUrl: TEST_DATABASE_URL,
log: ['error'],
});
return testClient;
}
export async function setupTestDatabase(): Promise<PrismaClient> {
execSync(`DATABASE_URL="${TEST_DATABASE_URL}" npx prisma db push --schema="${SCHEMA_PATH}" --skip-generate --accept-data-loss`, {
stdio: 'pipe',
cwd: resolve(__dirname, '..'),
});
const client = getTestPrismaClient();
await client.$connect();
return client;
}
export async function teardownTestDatabase(): Promise<void> {
if (testClient) {
await testClient.$disconnect();
testClient = null;
}
}
const TABLE_NAMES = [
'signature_evidences',
'contract_versions',
'escrow_transactions',
'inspection_records',
'dispute_cases',
'contracts',
'subsidy_documents',
'subsidy_checklist_items',
'subsidy_cases',
'match_requests',
'store_photos',
'store_lifecycles',
'store_facilities',
'store_leases',
'stores',
'vendor_coverage_regions',
'vendor_certifications',
'vendors',
'user_consents',
'user_profiles',
'users',
'outbox_events',
'event_logs',
'audit_logs',
'idempotency_keys',
'feature_flags',
'policy_versions',
'industry_taxonomies',
'region_hierarchies',
];
export async function cleanAllTables(prisma: PrismaClient): Promise<void> {
for (const table of TABLE_NAMES) {
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "${table}" CASCADE`);
}
}
export async function seedTestMasterData(prisma: PrismaClient): Promise<void> {
// 지역 마스터 데이터
const kr = await prisma.regionHierarchy.create({
data: {
code: 'KR',
nameKo: '대한민국',
regionType: 'COUNTRY',
depth: 0,
pathCode: 'KR',
sortOrder: 1,
isActive: true,
isBetaEnabled: false,
},
});
const seoul = await prisma.regionHierarchy.create({
data: {
code: 'KR.SEOUL',
nameKo: '서울특별시',
regionType: 'SIDO',
parentId: kr.id,
depth: 1,
pathCode: 'KR.SEOUL',
sortOrder: 1,
isActive: true,
isBetaEnabled: false,
},
});
const gangnam = await prisma.regionHierarchy.create({
data: {
code: 'KR.SEOUL.GANGNAM',
nameKo: '강남구',
regionType: 'SIGUNGU',
parentId: seoul.id,
depth: 2,
pathCode: 'KR.SEOUL.GANGNAM',
sortOrder: 1,
isActive: true,
isBetaEnabled: false,
},
});
await prisma.regionHierarchy.create({
data: {
code: 'KR.BETA.GANGNAM_CORE',
nameKo: '강남권 베타 클러스터',
regionType: 'BETA_CLUSTER',
parentId: gangnam.id,
depth: 3,
pathCode: 'KR.BETA.GANGNAM_CORE',
sortOrder: 10,
isActive: true,
isBetaEnabled: true,
},
});
// 비베타 지역 (테스트용)
await prisma.regionHierarchy.create({
data: {
code: 'KR.BUSAN',
nameKo: '부산광역시',
regionType: 'SIDO',
parentId: kr.id,
depth: 1,
pathCode: 'KR.BUSAN',
sortOrder: 2,
isActive: true,
isBetaEnabled: false,
},
});
// 업종 마스터 데이터
const fnb = await prisma.industryTaxonomy.create({
data: {
code: 'FNB',
nameKo: '음식점/카페',
depth: 0,
sortOrder: 1,
isLeaf: false,
isActive: true,
isBetaEnabled: true,
},
});
await prisma.industryTaxonomy.create({
data: {
code: 'FNB.CAFE',
nameKo: '카페',
parentId: fnb.id,
depth: 1,
sortOrder: 1,
isLeaf: true,
isActive: true,
isBetaEnabled: true,
},
});
await prisma.industryTaxonomy.create({
data: {
code: 'FNB.KOREAN',
nameKo: '한식',
parentId: fnb.id,
depth: 1,
sortOrder: 3,
isLeaf: true,
isActive: true,
isBetaEnabled: true,
},
});
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
sourcemap: true,
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});