DomainTenant (HQ · Store)

Tenant Model (HQ · Store)

SPEC #002 정합. linkmusic-msa-space-was/src/main/kotlin/.../domain/entity/.

Overview

LinkMusic 의 권한 격리 단위 = Tenant = HQ + 산하 Store 묶음. 두 tenant type 이 있다:

  1. FRANCHISE 본사 — 일반 가맹 본부. 산하에 DIRECT / FRANCHISE 매장.
  2. INDEPENDENT 가상 본사 — 시스템 전체 1개. 개인 매장 (INDEPENDENT 매장 type) 이 모두 산하.

Hq Entity

@Entity
@Table(name = "hq")
class Hq(
    var name: String,
    @Enumerated(EnumType.STRING) var type: HqType,  // FRANCHISE / INDEPENDENT
    @Enumerated(EnumType.STRING) var status: HqStatus = HqStatus.ONBOARDING,  // SPEC #013 (가상 본사는 ACTIVE 고정)
    var businessNumber: String? = null,  // SPEC #013 · nullable · NNN-NN-NNNNN
    @Enumerated(EnumType.STRING) var plan: PlanType?,
    var billingAnchorDay: Int?,          // 1~31, INDEPENDENT 는 NULL
    var llmAutomentEnabled: Boolean = true,  // INDEPENDENT 는 false 강제
    var llmKeywords: String? = null,     // comma-separated, ≤10
    var llmMaxPerHour: Int = 6,          // 0~12, INDEPENDENT 는 0
) : BaseEntity()

스니펫은 현재 스키마(SPEC #002 + #013 확장). status(HqStatus 상태머신)·businessNumber 는 SPEC #013 도입 — 상세는 Status Lifecycle · Data Schema.

컬럼타입제약
idUUIDPK, BaseEntity (Style.TIME)
nameStringNOT NULL
typeHqTypeNOT NULL · FRANCHISE / INDEPENDENT
statusHqStatusNOT NULL · default ONBOARDING · ACTIVE/ONBOARDING/UNPAID/SUSPENDED (SPEC #013) · 정지/복구 전이 SPEC #018
businessNumberString?nullable · NNN-NN-NNNNN (SPEC #013)
planPlanType?nullable · INDEPENDENT 는 NULL
billingAnchorDayInt?nullable · 1~31 · INDEPENDENT 는 NULL
llmAutomentEnabledBooleandefault true · INDEPENDENT 는 false
llmKeywordsString?nullable · ≤200 chars · INDEPENDENT 는 NULL
llmMaxPerHourIntdefault 6 · 0~12 · INDEPENDENT 는 0
createdAt/updatedAt/deletedAt/createdBy/updatedByBaseEntity

Store Entity

@Entity
@Table(name = "store")
class Store(
    @Column(name = "hq_id") var hqId: UUID,
    @Enumerated(EnumType.STRING) var type: StoreType,  // DIRECT / FRANCHISE / INDEPENDENT
    var name: String,
    var address: String? = null,
    var managerName: String? = null,
    var managerEmail: String? = null,
    var managerPhone: String? = null,
    @Enumerated(EnumType.STRING) var status: StoreStatus = StoreStatus.ACTIVE,
    var plan: PlanType? = null,           // INDEPENDENT 만 채움
    var billingAnchorDay: Int? = null,    // INDEPENDENT 만 채움
    var lastHeartbeatAt: Instant? = null,
    var lastOnlineAt: Instant? = null,
    var lastTrustPlaybackLogAt: Instant? = null,
    var closedAt: Instant? = null,
) : BaseEntity()
컬럼타입제약
idUUIDPK
hqIdUUIDFK → hq.id NOT NULL
typeStoreTypeNOT NULL · DIRECT / FRANCHISE / INDEPENDENT
nameStringNOT NULL
addressString?nullable
managerName/Email/PhoneString?nullable
statusStoreStatusdefault ACTIVE
plan, billingAnchorDaynullableINDEPENDENT 만 채움
lastHeartbeatAtInstant?indexed
lastOnlineAtInstant?nullable
lastTrustPlaybackLogAtInstant?TRUST 신탁 로그
closedAtInstant?폐점 시각 (soft-delete 와 별개)

가상 본사 (INDEPENDENT singleton)

  • 고정 ID: 00000000-0000-0000-0000-000000000001
  • 애플리케이션 상수: IndependentHqIds.SINGLETON: UUID
  • Flyway V2__add_hq_store.sql 에서 INSERT.
  • partial unique index 로 중복 차단:
    CREATE UNIQUE INDEX idx_hq_independent_singleton ON hq (type) WHERE type = 'INDEPENDENT';
  • 모든 INDEPENDENT 매장은 hqId = SINGLETON 으로 INSERT (SPEC #005 §2-3).
  • 가상 본사는 정지 불가 — 항시 ACTIVE 고정. suspend 시도 시 403 INDEPENDENT_HQ_SUSPENSION_FORBIDDEN (SPEC #018).

HqStatus 전이 규칙 (SPEC #018)

운영사(OPERATOR)가 본사를 정지/복구한다. 상세 lifecycle 은 Status Lifecycle 참고.

전이endpoint허용 출발 상태도착
정지POST /api/v1/admin/hq/{id}/suspendACTIVE · ONBOARDING · UNPAIDSUSPENDED
복구POST /api/v1/admin/hq/{id}/reactivateSUSPENDEDACTIVE
  • 원자적 UPDATE + affected-row 검증 (race 방지). 허용 외 출발 상태 → 409 HQ_INVALID_STATUS_TRANSITION.
  • INDEPENDENT 가상 본사 → 403 INDEPENDENT_HQ_SUSPENSION_FORBIDDEN.
  • 세션 무효화: 정지 시 해당 본사 소속 관리자(HQ_MANAGER)의 active RefreshToken 을 즉시 revoke. 정지 상태에서는 로그인/refresh 가 차단된다.
  • 정지 사유(reason) 저장은 v1 미포함(후속).

결제 단위 분기

Tenant결제 단위billingAnchorDay 위치
FRANCHISE 본사HQ 단위 (한 본사가 산하 매장 청구 묶음)Hq.billingAnchorDay
INDEPENDENT 매장Store 단위Store.billingAnchorDay

Plan 분기

TenantPlan 위치
FRANCHISE 본사Hq.plan (산하 모든 매장 공통)
INDEPENDENT 매장Store.plan (매장 단위)

Implementation

  • HqRepository : JpaRepository<Hq, UUID>findByType(type), findIndependent()
  • StoreRepository : JpaRepository<Store, UUID> — 기본 + findByHqId(hqId)
  • domain/exception/ — 도메인 invariant 위반 시 DomainException 하위 클래스 던짐

도메인 invariant

Hq init 시:

  • type = INDEPENDENT → plan, billingAnchorDay 는 NULL, llmAutomentEnabled = false, llmKeywords = NULL, llmMaxPerHour = 0

Store init 시:

  • type = INDEPENDENT → plan, billingAnchorDay NOT NULL
  • type = DIRECT / FRANCHISE → plan, billingAnchorDay NULL (Hq 가 책임)

Constraints

  • INDEPENDENT 본사는 시스템 전체 1개 (DB partial unique).
  • Store.hqId = NOT NULL — 모든 매장은 반드시 어떤 본사 산하.
  • closedAtdeletedAt — 폐점은 도메인, soft-delete 는 기술적.

Roadmap

  • Hq.status 컬럼 도입 (SPEC #013) ✅ · 정지/복구 전이 + 세션 무효화 (SPEC #018)
  • HqStatus 자동 전환 (ONBOARDING→ACTIVE·UNPAID 결제 실패) · 정지 사유 저장 (후속)
  • Hq.businessRegistrationNumber (v1.1+)
  • FRANCHISE 본사 직접결제 옵션 Hq.billingMode (v1.1+)

References

  • SPEC #002 §2-4·§2-5·§2-6
  • linkmusic-msa-space-was/src/main/kotlin/.../domain/entity/Hq.kt
  • linkmusic-msa-space-was/src/main/kotlin/.../domain/entity/Store.kt
  • linkmusic-msa-space-was/src/main/resources/db/migration/V2__add_hq_store.sql