# Startover `schema.prisma` 설계 초안 ## 목적 이 문서는 Startover MVP의 데이터 모델을 실제 `schema.prisma`로 내리기 전에, 엔티티/필드/관계/인덱스/제약 조건을 고정하기 위한 초안이다. 대상 범위는 `assisted marketplace MVP`이며, 아래 원칙을 따른다. - 운영자 개입형 플로우를 우선한다. - 정보 공개는 제한 공개가 기본값이다. - 결제/정산/분쟁은 append-only 로그와 감사 로그를 남긴다. - 마스터 데이터는 enum이 아니라 테이블과 seed로 관리한다. - 하드코딩 대신 `PolicyVersion`, `RegionHierarchy`, `IndustryTaxonomy`를 참조한다. ## 설계 원칙 ### 네이밍 - Prisma 모델명: `PascalCase` - Prisma 필드명: `camelCase` - 실제 DB 테이블/컬럼명: `snake_case` - 실제 DB명은 `@@map`, `@map`으로 매핑한다. ### 공통 타입 - 내부 PK: `BigInt @id @default(autoincrement())` - 외부 노출용 ID: `publicId String @unique` - 금액: `Decimal @db.Decimal(14, 2)` - 시각: `DateTime @db.Timestamptz(6)` - 상태: 안정적인 값만 enum으로 관리 - 자주 바뀌는 코드: enum보다 `String` 코드 또는 마스터 테이블 사용 ### 공통 컬럼 대부분의 트랜잭션성 모델은 아래 공통 컬럼을 가진다. - `id` - `publicId` 또는 비즈니스 고유 키 - `createdAt` - `updatedAt` - 필요한 경우 `deletedAt` append-only 성격의 모델은 `updatedAt`, `deletedAt` 없이 생성 시점만 가진다. ## 모델 설계 ### 1. 사용자/권한 #### `User` 목적: 사용자 기본 계정과 로그인 주체를 관리한다. 주요 필드: - `id` - `publicId` - `email` - `emailNormalized` - `phone` - `phoneNormalized` - `name` - `primaryRole` - `status` - `emailVerifiedAt` - `lastLoginAt` - `createdAt` - `updatedAt` 관계: - `profiles UserProfile[]` - `consents UserConsent[]` - `stores Store[]` - `vendors Vendor[]` 인덱스: - `@unique(emailNormalized)` - `@@index([primaryRole, status])` #### `UserProfile` 목적: 역할별 추가 프로필 정보를 확장 가능하게 저장한다. 주요 필드: - `id` - `userId` - `profileType` - `businessRegistrationNumber` - `metadataJson` - `createdAt` - `updatedAt` #### `UserConsent` 목적: 개인정보, 정보 공개, 마케팅, 제3자 제공 동의를 버전 단위로 저장한다. 주요 필드: - `id` - `userId` - `consentType` - `policyVersionId` - `isGranted` - `grantedAt` - `revokedAt` ### 2. 매장 공급 도메인 #### `Store` 목적: 폐업자가 등록한 매장의 중심 aggregate다. 주요 필드: - `id` - `publicId` - `ownerUserId` - `listingTitle` - `publicSummary` - `industryLeafId` - `regionClusterId` - `regionLeafId` - `roadAddress` - `detailAddress` - `latitude` - `longitude` - `reviewStatus` - `publicationStatus` - `dealStatus` - `policyVersionId` - `publishedAt` - `approvedAt` - `rejectedAt` - `createdAt` - `updatedAt` 핵심 설계: - `StoreStatus` 단일 컬럼 대신 `reviewStatus`, `publicationStatus`, `dealStatus` 3축으로 분리한다. - 검색/필터에 쓰는 값은 JSON이 아니라 정식 컬럼으로 둔다. - 주소 공개 정책 때문에 상세 주소는 API 응답 단계에서 권한 필터링한다. 인덱스: - `@@index([ownerUserId, createdAt])` - `@@index([reviewStatus, createdAt])` - `@@index([regionClusterId, industryLeafId, publicationStatus, dealStatus, publishedAt])` #### `StoreLease` 목적: 임대/권리금 관련 정보를 1:1 확장 테이블로 저장한다. 주요 필드: - `storeId` - `depositAmount` - `monthlyRentAmount` - `maintenanceFeeAmount` - `premiumAmount` - `transferable` - `leaseExpiresAt` - `remainingLeaseMonths` - `createdAt` - `updatedAt` 제약: - `storeId` unique - 금액 필드는 모두 `>= 0` #### `StoreFacility` 목적: F&B 필터링과 인수 판단에 필요한 설비 정보를 저장한다. 주요 필드: - `storeId` - `exclusiveAreaSqm` - `floorLevel` - `seatCount` - `hasGas` - `hasDrainage` - `hasDuct` - `electricCapacityKw` - `kitchenEquipmentSummary` - `parkingCount` - `restroomType` - `createdAt` - `updatedAt` 제약: - `storeId` unique - `exclusiveAreaSqm > 0` #### `StoreLifecycle` 목적: 폐업/인수/철거 일정 정보를 저장한다. 주요 필드: - `storeId` - `closingPlannedAt` - `takeoverAvailableAt` - `demolitionPreferredAt` - `vacateByAt` - `isCurrentlyOperating` - `urgencyLevel` - `createdAt` - `updatedAt` #### `StorePhoto` 목적: 매장 사진과 공개 범위를 저장한다. 주요 필드: - `id` - `storeId` - `storageKey` - `photoCategory` - `visibilityScope` - `sortOrder` - `uploadedByUserId` - `checksumSha256` - `width` - `height` - `takenAt` - `isRepresentative` - `createdAt` 인덱스: - `@@index([storeId, sortOrder])` ### 3. 매칭 도메인 #### `MatchRequest` 목적: 창업자/업체/운영자 추천 기반 매칭 요청을 관리한다. 주요 필드: - `id` - `publicId` - `storeId` - `matchType` - `requesterUserId` - `requesterVendorId` - `sourceType` - `status` - `operatorAssigneeId` - `message` - `decisionReasonCode` - `acceptedAt` - `rejectedAt` - `closedAt` - `createdAt` - `updatedAt` 핵심 설계: - `ACQUISITION`, `DEMOLITION`, `INTERIOR`를 한 테이블로 관리한다. - `matchType`에 따라 필요한 FK가 달라지므로 raw SQL `CHECK` 제약을 둔다. 인덱스: - `@@index([storeId, status, createdAt])` - `@@index([requesterUserId, status, createdAt])` - `@@index([requesterVendorId, status, createdAt])` - partial unique index: - 열린 상태에서 동일 사용자/동일 매장/동일 요청 타입 중복 금지 ### 4. 지원금 도메인 #### `SubsidyCase` 목적: 매장 기준 지원금 진행 건을 관리한다. 주요 필드: - `id` - `publicId` - `storeId` - `applicantUserId` - `programCode` - `status` - `eligibilityResult` - `policyVersionId` - `operatorAssigneeId` - `checklistVersionCode` - `submissionReadyAt` - `submittedAt` - `reviewedAt` - `rejectionReasonCode` - `eligibilitySnapshotJson` - `createdAt` - `updatedAt` 인덱스: - `@@index([storeId, status, createdAt])` - `@@index([applicantUserId, status, createdAt])` - `@@index([operatorAssigneeId, status, updatedAt])` - partial unique index: - 같은 `storeId + programCode` 조합의 active case는 1개 #### `SubsidyChecklistItem` 목적: 정책 버전에 따라 생성된 체크리스트 항목을 저장한다. 주요 필드: - `id` - `subsidyCaseId` - `itemCode` - `isRequired` - `status` - `checkedAt` - `checkedByUserId` #### `SubsidyDocument` 목적: 지원금 첨부 서류와 검토 상태를 관리한다. 주요 필드: - `id` - `subsidyCaseId` - `documentTypeCode` - `storageKey` - `reviewStatus` - `uploadedByUserId` - `uploadedAt` - `reviewedAt` - `reviewedByUserId` ### 5. 업체 도메인 #### `Vendor` 목적: 철거/인테리어 업체의 기본 프로필을 관리한다. 주요 필드: - `id` - `publicId` - `ownerUserId` - `vendorType` - `businessName` - `contactName` - `contactPhoneNormalized` - `businessRegistrationNumber` - `serviceIntro` - `primaryRegionId` - `certificationStatus` - `latestCertificationId` - `deletedAt` - `createdAt` - `updatedAt` 인덱스: - `@@index([ownerUserId])` - `@@index([vendorType, certificationStatus, createdAt])` #### `VendorCoverageRegion` 목적: 업체 서비스 가능 지역을 다대다 조인으로 관리한다. 주요 필드: - `id` - `vendorId` - `regionId` - `isPrimary` - `createdAt` 인덱스: - `@@unique([vendorId, regionId])` - `@@index([regionId, vendorId])` #### `VendorCertification` 목적: 업체 인증 이력을 append-only에 가깝게 유지한다. 주요 필드: - `id` - `vendorId` - `status` - `requestedScopeCode` - `documentChecklistJson` - `appliedAt` - `reviewedAt` - `reviewedByUserId` - `reasonCode` - `validUntil` - `createdAt` - `updatedAt` ### 6. 신뢰 인프라 도메인 #### `Contract` 목적: 매칭에서 생성된 계약의 현재 상태와 참여자를 관리한다. 주요 필드: - `id` - `publicId` - `matchRequestId` - `storeId` - `contractType` - `status` - `createdByUserId` - `storeOwnerUserId` - `buyerUserId` - `vendorId` - `policyVersionId` - `templateCode` - `currentVersionId` - `escrowStatus` - `signedAt` - `effectiveAt` - `completedAt` - `cancelledAt` - `createdAt` - `updatedAt` 인덱스: - `@@unique([matchRequestId])` - `@@index([storeId, status, createdAt])` - `@@index([vendorId, status, createdAt])` - `@@index([buyerUserId, status, createdAt])` #### `ContractVersion` 목적: 계약 문서를 불변 버전으로 저장한다. 주요 필드: - `id` - `contractId` - `versionNo` - `templateCode` - `templateVersion` - `documentStorageKey` - `documentSha256` - `renderedSnapshotJson` - `changeSummary` - `createdByUserId` - `createdAt` 인덱스: - `@@unique([contractId, versionNo])` #### `SignatureEvidence` 목적: 전자서명 또는 동의 증적을 저장한다. 주요 필드: - `id` - `contractId` - `contractVersionId` - `signerRole` - `signerUserId` - `evidenceType` - `providerCode` - `providerEventId` - `signedAt` - `signatureHash` - `payloadJson` - `ipAddress` - `userAgent` - `createdAt` 인덱스: - `@@index([contractVersionId, signedAt])` - 가능한 경우 `@unique(providerEventId)` #### `EscrowTransaction` 목적: 결제/환불/정산/보류 관련 금전 이벤트를 immutable ledger 형태로 저장한다. 주요 필드: - `id` - `contractId` - `transactionType` - `providerCode` - `providerTransactionId` - `status` - `amount` - `currencyCode` - `requestedByUserId` - `approvedByUserId` - `idempotencyKey` - `rawPayloadJson` - `occurredAt` - `createdAt` 인덱스: - `@unique(providerTransactionId)` - `@unique(idempotencyKey)` - `@@index([contractId, occurredAt])` - `@@index([status, occurredAt])` #### `InspectionRecord` 목적: 검수 요청과 검토 결과를 저장한다. 주요 필드: - `id` - `contractId` - `inspectionType` - `status` - `submittedByUserId` - `submittedAt` - `reviewedByUserId` - `reviewedAt` - `reviewMemo` - `evidencePayloadJson` - `createdAt` - `updatedAt` #### `DisputeCase` 목적: 분쟁 접수, 조사, 중재, 종료 상태를 관리한다. 주요 필드: - `id` - `contractId` - `openedByUserId` - `assignedOperatorId` - `status` - `reasonCode` - `description` - `resolutionCode` - `resolutionSummary` - `evidencePayloadJson` - `openedAt` - `resolvedAt` - `resolvedByUserId` - `createdAt` - `updatedAt` 인덱스: - `@@index([contractId, status, openedAt])` - `@@index([assignedOperatorId, status, openedAt])` - partial unique index: - 계약당 active dispute는 1건 ### 7. 운영/분석 도메인 #### `PolicyVersion` 목적: 정책 버전을 모델에 귀속시키기 위한 기준 테이블이다. 주요 필드: - `id` - `policyType` - `versionCode` - `contentHash` - `effectiveFrom` - `isActive` - `createdAt` #### `AuditLog` 목적: 누가 무엇을 왜 바꿨는지를 운영 감사 기준으로 남긴다. 주요 필드: - `id` - `resourceType` - `resourceId` - `actionType` - `actorUserId` - `actorRole` - `reasonCode` - `memo` - `beforeJson` - `afterJson` - `requestId` - `correlationId` - `ipHash` - `userAgent` - `createdAt` 인덱스: - `@@index([resourceType, resourceId, createdAt])` - `@@index([actorUserId, createdAt])` - `@@index([actionType, createdAt])` #### `EventLog` 목적: 도메인 이벤트를 append-only로 보관한다. 주요 필드: - `id` - `aggregateType` - `aggregateId` - `eventName` - `eventVersion` - `eventKey` - `payloadJson` - `actorUserId` - `causationId` - `correlationId` - `piiLevel` - `occurredAt` - `recordedAt` 인덱스: - `@unique(eventKey)` - `@@index([aggregateType, aggregateId, occurredAt])` - `@@index([eventName, occurredAt])` #### `OutboxEvent` 목적: 외부 발행/재시도/실패 처리를 EventLog와 분리해 관리한다. 주요 필드: - `id` - `aggregateType` - `aggregateId` - `eventName` - `payloadJson` - `publishStatus` - `availableAt` - `retryCount` - `lastError` - `createdAt` 인덱스: - `@@index([publishStatus, availableAt])` #### `IdempotencyKey` 목적: 웹훅과 중복 요청 방지를 위한 키 저장소다. 주요 필드: - `id` - `scope` - `idempotencyKey` - `requestHash` - `responseHash` - `expiresAt` - `createdAt` 인덱스: - `@@unique([scope, idempotencyKey])` ## Enum 초안 ### 고정 enum 후보 - `UserRole` - `CLOSING_OWNER` - `FOUNDER` - `VENDOR_MANAGER` - `OPS_MANAGER` - `SUBSIDY_OPERATOR` - `TRUST_OPERATOR` - `FINANCE_OPERATOR` - `SUPER_ADMIN` - `UserStatus` - `PENDING_VERIFICATION` - `ACTIVE` - `SUSPENDED` - `DEACTIVATED` - `StoreReviewStatus` - `DRAFT` - `SUBMITTED` - `REVIEWING` - `APPROVED` - `REJECTED` - `StorePublicationStatus` - `PRIVATE` - `RESTRICTED` - `PUBLISHED` - `UNPUBLISHED` - `StoreDealStatus` - `OPEN` - `MATCHING` - `RESERVED` - `CONTRACTED` - `CLOSED` - `CANCELLED` - `PhotoCategory` - `EXTERIOR` - `INTERIOR` - `KITCHEN` - `EQUIPMENT` - `FLOOR_PLAN` - `DOCUMENT` - `VisibilityScope` - `INTERNAL` - `MATCHED_ONLY` - `PUBLIC_SUMMARY` - `MatchType` - `ACQUISITION` - `DEMOLITION` - `INTERIOR` - `MatchRequestStatus` - `OPEN` - `REVIEWING` - `ACCEPTED` - `REJECTED` - `CONTRACTING` - `EXPIRED` - `CANCELLED` - `COMPLETED` - `SubsidyCaseStatus` - `DRAFT` - `ELIGIBILITY_CHECKED` - `DOCUMENTS_PENDING` - `REVIEWING` - `READY_TO_SUBMIT` - `SUBMITTED` - `APPROVED` - `REJECTED` - `CLOSED` - `EligibilityResult` - `UNKNOWN` - `ELIGIBLE` - `CONDITIONALLY_ELIGIBLE` - `NOT_ELIGIBLE` - `VendorType` - `DEMOLITION` - `INTERIOR` - `BOTH` - `VendorCertificationStatus` - `APPLIED` - `REVIEWING` - `APPROVED` - `REJECTED` - `SUSPENDED` - `EXPIRED` - `ContractType` - `ACQUISITION` - `DEMOLITION` - `INTERIOR` - `ContractStatus` - `DRAFT` - `GENERATED` - `SIGNING` - `SIGNED` - `ACTIVE` - `COMPLETED` - `CANCELLED` - `TERMINATED` - `EscrowStatus` - `NOT_STARTED` - `DEPOSIT_PENDING` - `HOLDING` - `RELEASE_REVIEW` - `RELEASED` - `REFUNDED` - `DISPUTED` - `EscrowTransactionType` - `DEPOSIT` - `RELEASE` - `REFUND` - `ADJUSTMENT` - `HOLD` - `InspectionStatus` - `REQUESTED` - `SUBMITTED` - `REVIEWING` - `APPROVED` - `REJECTED` - `DisputeStatus` - `OPEN` - `INVESTIGATING` - `MEDIATING` - `RESOLVED` - `CLOSED` - `RegionType` - `COUNTRY` - `SIDO` - `SIGUNGU` - `ADMIN_DONG` - `COMMERCIAL_AREA` - `BETA_CLUSTER` ### enum으로 두지 않을 값 아래는 자주 바뀌거나 운영 정책에 따라 변할 수 있으므로 enum보다 코드 값 또는 마스터 데이터로 둔다. - `reasonCode` - `programCode` - `documentTypeCode` - `actionType` - `eventName` - `templateCode` ## 마이그레이션 전략 ### 기본 원칙 - 운영 DB 변경은 항상 migration 파일로 관리한다. - 스키마 변경과 데이터 backfill은 분리한다. - 대용량 테이블 인덱스는 raw SQL로 `CREATE INDEX CONCURRENTLY`를 사용한다. - Prisma가 표현하기 어려운 partial unique index, check constraint, PostGIS index는 custom SQL migration으로 보강한다. ### 첫 스키마에서 넣을 custom SQL 후보 1. 열린 매칭 요청 중복 방지 partial unique index 2. 지원금 active case 중복 방지 partial unique index 3. 계약당 active dispute 1건 제한 partial unique index 4. `amount >= 0`, `exclusive_area_sqm > 0` 같은 check constraint 5. 향후 `locationPoint`를 도입할 경우 GIST index ## MVP에서 나중으로 미룰 모델 아래 모델은 개념만 유지하고 실제 첫 `schema.prisma`에서는 보류할 수 있다. - `PriceEstimate` - `VendorQuote` - `StoreAsset` - `AlertSubscription` - `NotificationDeliveryLog` - `KpiSnapshot` ## 바로 다음 단계 1. 이 문서를 기준으로 실제 `prisma/schema.prisma` 초안을 생성한다. 2. `docs/master-data/beta-master-data.md`와 FK 관계를 맞춘다. 3. `D001`, `D002`, `D003` 정책 문서가 완성되면 nullable/required 필드를 다시 조정한다.