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
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@relink/infrastructure",
"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"
},
"dependencies": {
"@prisma/client": "^6.1.0",
"@relink/application": "workspace:*",
"@relink/database": "workspace:*",
"@relink/domain": "workspace:*",
"@relink/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.10.2",
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
+39
View File
@@ -0,0 +1,39 @@
import type { Prisma } from '@prisma/client';
type TxClient = Prisma.TransactionClient;
export interface CreateAuditLogInput {
readonly resourceType: string;
readonly resourceId: string;
readonly actionType: string;
readonly actorUserId: bigint;
readonly actorRole: string;
readonly reasonCode?: string;
readonly memo?: string;
readonly beforeJson?: Record<string, unknown>;
readonly afterJson?: Record<string, unknown>;
readonly requestId?: string;
readonly correlationId?: string;
readonly ipHash?: string;
readonly userAgent?: string;
}
export async function createAuditLog(tx: TxClient, input: CreateAuditLogInput) {
return tx.auditLog.create({
data: {
resourceType: input.resourceType,
resourceId: input.resourceId,
actionType: input.actionType,
actorUserId: input.actorUserId,
actorRole: input.actorRole,
reasonCode: input.reasonCode ?? null,
memo: input.memo ?? null,
beforeJson: (input.beforeJson as object) ?? undefined,
afterJson: (input.afterJson as object) ?? undefined,
requestId: input.requestId ?? null,
correlationId: input.correlationId ?? null,
ipHash: input.ipHash ?? null,
userAgent: input.userAgent ?? null,
},
});
}
+37
View File
@@ -0,0 +1,37 @@
import type { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
type TxClient = Prisma.TransactionClient;
export interface RecordEventInput {
readonly aggregateType: string;
readonly aggregateId: string;
readonly eventName: string;
readonly eventVersion: number;
readonly payloadJson: Record<string, unknown>;
readonly actorUserId?: bigint;
readonly causationId?: string;
readonly correlationId?: string;
readonly piiLevel?: 'NONE' | 'LOW' | 'HIGH';
readonly occurredAt?: Date;
}
export async function recordEvent(tx: TxClient, input: RecordEventInput) {
const eventKey = `${input.aggregateType}:${input.aggregateId}:${input.eventName}:${randomUUID()}`;
return tx.eventLog.create({
data: {
aggregateType: input.aggregateType,
aggregateId: input.aggregateId,
eventName: input.eventName,
eventVersion: input.eventVersion,
eventKey,
payloadJson: input.payloadJson as object,
actorUserId: input.actorUserId ?? null,
causationId: input.causationId ?? null,
correlationId: input.correlationId ?? null,
piiLevel: input.piiLevel ?? 'NONE',
occurredAt: input.occurredAt ?? new Date(),
},
});
}
@@ -0,0 +1,48 @@
import type { PrismaClient } from '@prisma/client';
export async function isFeatureEnabled(
prisma: PrismaClient,
flagKey: string,
): Promise<boolean> {
const flag = await prisma.featureFlag.findUnique({
where: { flagKey },
select: { isEnabled: true },
});
return flag?.isEnabled ?? false;
}
export async function getFeatureFlags(
prisma: PrismaClient,
): Promise<ReadonlyMap<string, boolean>> {
const flags = await prisma.featureFlag.findMany({
select: { flagKey: true, isEnabled: true },
});
return new Map(flags.map((f) => [f.flagKey, f.isEnabled]));
}
export async function setFeatureFlag(
prisma: PrismaClient,
flagKey: string,
isEnabled: boolean,
description?: string,
) {
const now = new Date();
return prisma.featureFlag.upsert({
where: { flagKey },
update: {
isEnabled,
enabledAt: isEnabled ? now : undefined,
disabledAt: isEnabled ? undefined : now,
},
create: {
flagKey,
isEnabled,
description: description ?? null,
enabledAt: isEnabled ? now : null,
disabledAt: isEnabled ? null : now,
},
});
}
@@ -0,0 +1,51 @@
import type { PrismaClient, Prisma } from '@prisma/client';
type TxClient = Prisma.TransactionClient;
const DEFAULT_TTL_HOURS = 24;
export async function checkIdempotencyKey(
prisma: PrismaClient,
scope: string,
key: string,
): Promise<{ exists: boolean; responseHash: string | null }> {
const existing = await prisma.idempotencyKey.findUnique({
where: { scope_idempotencyKey: { scope, idempotencyKey: key } },
select: { responseHash: true, expiresAt: true },
});
if (!existing) {
return { exists: false, responseHash: null };
}
if (existing.expiresAt && existing.expiresAt < new Date()) {
await prisma.idempotencyKey.delete({
where: { scope_idempotencyKey: { scope, idempotencyKey: key } },
});
return { exists: false, responseHash: null };
}
return { exists: true, responseHash: existing.responseHash };
}
export async function saveIdempotencyKey(
tx: TxClient,
scope: string,
key: string,
requestHash: string,
responseHash: string,
ttlHours: number = DEFAULT_TTL_HOURS,
) {
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + ttlHours);
return tx.idempotencyKey.create({
data: {
scope,
idempotencyKey: key,
requestHash,
responseHash,
expiresAt,
},
});
}
+9
View File
@@ -0,0 +1,9 @@
export type { Logger } from './logger.js';
export type { CreateAuditLogInput } from './audit-log.js';
export { createAuditLog } from './audit-log.js';
export type { RecordEventInput } from './event-log.js';
export { recordEvent } from './event-log.js';
export type { EnqueueOutboxInput } from './outbox.js';
export { enqueueOutboxEvent, claimPendingOutboxEvents, markOutboxEventStatus } from './outbox.js';
export { isFeatureEnabled, getFeatureFlags, setFeatureFlag } from './feature-flag.js';
export { checkIdempotencyKey, saveIdempotencyKey } from './idempotency.js';
+9
View File
@@ -0,0 +1,9 @@
/**
* Logger interface for infrastructure layer.
*/
export interface Logger {
info(message: string, meta?: Record<string, unknown>): void;
warn(message: string, meta?: Record<string, unknown>): void;
error(message: string, meta?: Record<string, unknown>): void;
debug(message: string, meta?: Record<string, unknown>): void;
}
+51
View File
@@ -0,0 +1,51 @@
import type { PrismaClient, Prisma } from '@prisma/client';
type TxClient = Prisma.TransactionClient;
type PublishStatusValue = 'PENDING' | 'PUBLISHED' | 'FAILED';
export interface EnqueueOutboxInput {
readonly aggregateType: string;
readonly aggregateId: string;
readonly eventName: string;
readonly payloadJson: Record<string, unknown>;
}
export async function enqueueOutboxEvent(tx: TxClient, input: EnqueueOutboxInput) {
return tx.outboxEvent.create({
data: {
aggregateType: input.aggregateType,
aggregateId: input.aggregateId,
eventName: input.eventName,
payloadJson: input.payloadJson as object,
publishStatus: 'PENDING',
retryCount: 0,
},
});
}
export async function claimPendingOutboxEvents(prisma: PrismaClient, batchSize: number = 50) {
return prisma.outboxEvent.findMany({
where: {
publishStatus: 'PENDING',
availableAt: { lte: new Date() },
},
orderBy: { createdAt: 'asc' },
take: batchSize,
});
}
export async function markOutboxEventStatus(
prisma: PrismaClient,
eventId: bigint,
status: PublishStatusValue,
lastError?: string,
) {
return prisma.outboxEvent.update({
where: { id: eventId },
data: {
publishStatus: status,
retryCount: status === 'FAILED' ? { increment: 1 } : undefined,
lastError: lastError ?? null,
},
});
}
+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"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
sourcemap: true,
external: ['@relink/application', '@relink/domain', '@relink/shared'],
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});