Tenant Model (HQ · Store)
SPEC #002 정합. linkmusic-msa-space-was/src/main/kotlin/.../domain/entity/.
Overview
LinkMusic 의 권한 격리 단위 = Tenant = HQ + 산하 Store 묶음. 두 tenant type 이 있다:
- FRANCHISE 본사 — 일반 가맹 본부. 산하에
DIRECT/FRANCHISE매장. - 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.
| 컬럼 | 타입 | 제약 |
|---|---|---|
| id | UUID | PK, BaseEntity (Style.TIME) |
| name | String | NOT NULL |
| type | HqType | NOT NULL · FRANCHISE / INDEPENDENT |
| status | HqStatus | NOT NULL · default ONBOARDING · ACTIVE/ONBOARDING/UNPAID/SUSPENDED (SPEC #013) · 정지/복구 전이 SPEC #018 |
| businessNumber | String? | nullable · NNN-NN-NNNNN (SPEC #013) |
| plan | PlanType? | nullable · INDEPENDENT 는 NULL |
| billingAnchorDay | Int? | nullable · 1~31 · INDEPENDENT 는 NULL |
| llmAutomentEnabled | Boolean | default true · INDEPENDENT 는 false |
| llmKeywords | String? | nullable · ≤200 chars · INDEPENDENT 는 NULL |
| llmMaxPerHour | Int | default 6 · 0~12 · INDEPENDENT 는 0 |
| createdAt/updatedAt/deletedAt/createdBy/updatedBy | BaseEntity |
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()| 컬럼 | 타입 | 제약 |
|---|---|---|
| id | UUID | PK |
| hqId | UUID | FK → hq.id NOT NULL |
| type | StoreType | NOT NULL · DIRECT / FRANCHISE / INDEPENDENT |
| name | String | NOT NULL |
| address | String? | nullable |
| managerName/Email/Phone | String? | nullable |
| status | StoreStatus | default ACTIVE |
| plan, billingAnchorDay | nullable | INDEPENDENT 만 채움 |
| lastHeartbeatAt | Instant? | indexed |
| lastOnlineAt | Instant? | nullable |
| lastTrustPlaybackLogAt | Instant? | TRUST 신탁 로그 |
| closedAt | Instant? | 폐점 시각 (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시도 시 403INDEPENDENT_HQ_SUSPENSION_FORBIDDEN(SPEC #018).
HqStatus 전이 규칙 (SPEC #018)
운영사(OPERATOR)가 본사를 정지/복구한다. 상세 lifecycle 은 Status Lifecycle 참고.
| 전이 | endpoint | 허용 출발 상태 | 도착 |
|---|---|---|---|
| 정지 | POST /api/v1/admin/hq/{id}/suspend | ACTIVE · ONBOARDING · UNPAID | SUSPENDED |
| 복구 | POST /api/v1/admin/hq/{id}/reactivate | SUSPENDED | ACTIVE |
- 원자적 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 분기
| Tenant | Plan 위치 |
|---|---|
| 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 — 모든 매장은 반드시 어떤 본사 산하.
closedAt≠deletedAt— 폐점은 도메인, 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.ktlinkmusic-msa-space-was/src/main/kotlin/.../domain/entity/Store.ktlinkmusic-msa-space-was/src/main/resources/db/migration/V2__add_hq_store.sql