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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -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'],
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user