diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/tenant/CreateTenantRequest.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/tenant/CreateTenantRequest.java new file mode 100644 index 0000000..28d44e1 --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/tenant/CreateTenantRequest.java @@ -0,0 +1,13 @@ +package kr.devslab.kit.admin.tenant; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import kr.devslab.kit.tenant.TenantMode; + +public record CreateTenantRequest( + @NotBlank @Size(max = 64) String id, + @NotBlank @Size(max = 128) String name, + @NotNull TenantMode mode +) { +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/tenant/RenameTenantRequest.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/tenant/RenameTenantRequest.java new file mode 100644 index 0000000..ab253ec --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/tenant/RenameTenantRequest.java @@ -0,0 +1,7 @@ +package kr.devslab.kit.admin.tenant; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record RenameTenantRequest(@NotBlank @Size(max = 128) String name) { +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/tenant/TenantAdminController.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/tenant/TenantAdminController.java new file mode 100644 index 0000000..6961167 --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/tenant/TenantAdminController.java @@ -0,0 +1,70 @@ +package kr.devslab.kit.admin.tenant; + +import jakarta.validation.Valid; +import java.util.List; +import kr.devslab.kit.admin.AdminApiPaths; +import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.tenant.TenantMetadata; +import kr.devslab.kit.tenant.TenantService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(AdminApiPaths.BASE + "/tenants") +public class TenantAdminController { + + private final TenantService tenantService; + + public TenantAdminController(TenantService tenantService) { + this.tenantService = tenantService; + } + + @PostMapping + public ResponseEntity create(@Valid @RequestBody CreateTenantRequest req) { + TenantMetadata created = tenantService.create(TenantId.of(req.id()), req.name(), req.mode()); + return ResponseEntity.status(201).body(created); + } + + @GetMapping + public List list() { + return tenantService.findAll(); + } + + @GetMapping("/{id}") + public ResponseEntity get(@PathVariable String id) { + return tenantService.findById(TenantId.of(id)) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PutMapping("/{id}") + public ResponseEntity rename(@PathVariable String id, @Valid @RequestBody RenameTenantRequest req) { + tenantService.rename(TenantId.of(id), req.name()); + return ResponseEntity.noContent().build(); + } + + @PutMapping("/{id}/activate") + public ResponseEntity activate(@PathVariable String id) { + tenantService.activate(TenantId.of(id)); + return ResponseEntity.noContent().build(); + } + + @PutMapping("/{id}/deactivate") + public ResponseEntity deactivate(@PathVariable String id) { + tenantService.deactivate(TenantId.of(id)); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable String id) { + tenantService.delete(TenantId.of(id)); + return ResponseEntity.noContent().build(); + } +} diff --git a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/TenantAutoConfiguration.java b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/TenantAutoConfiguration.java index 0c19309..bf04edd 100644 --- a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/TenantAutoConfiguration.java +++ b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/TenantAutoConfiguration.java @@ -1,12 +1,19 @@ package kr.devslab.kit.autoconfigure; +import jakarta.persistence.EntityManager; +import java.time.Clock; import kr.devslab.kit.tenant.TenantContextHolder; import kr.devslab.kit.tenant.TenantResolver; +import kr.devslab.kit.tenant.TenantService; import kr.devslab.kit.tenant.core.DefaultTenantContextHolder; import kr.devslab.kit.tenant.core.FixedTenantResolver; import kr.devslab.kit.tenant.core.HeaderTenantResolver; import kr.devslab.kit.tenant.core.SubdomainTenantResolver; +import kr.devslab.kit.tenant.core.repository.JpaPlatformTenantRepository; +import kr.devslab.kit.tenant.core.service.DefaultTenantService; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -48,4 +55,12 @@ public TenantResolver tenantResolver(DevslabKitProperties properties) { ); }; } + + @Bean + @ConditionalOnClass(EntityManager.class) + @ConditionalOnBean(JpaPlatformTenantRepository.class) + @ConditionalOnMissingBean + public TenantService tenantService(JpaPlatformTenantRepository repository, Clock clock) { + return new DefaultTenantService(repository, clock); + } } diff --git a/devslab-kit-tenant-api/src/main/java/kr/devslab/kit/tenant/TenantMetadata.java b/devslab-kit-tenant-api/src/main/java/kr/devslab/kit/tenant/TenantMetadata.java new file mode 100644 index 0000000..7dda9c4 --- /dev/null +++ b/devslab-kit-tenant-api/src/main/java/kr/devslab/kit/tenant/TenantMetadata.java @@ -0,0 +1,23 @@ +package kr.devslab.kit.tenant; + +import java.time.Instant; +import java.util.Objects; +import kr.devslab.kit.core.id.TenantId; + +public record TenantMetadata( + TenantId id, + String name, + TenantMode mode, + boolean active, + Instant createdAt +) { + + public TenantMetadata { + Objects.requireNonNull(id, "TenantMetadata id must not be null"); + Objects.requireNonNull(mode, "TenantMetadata mode must not be null"); + Objects.requireNonNull(createdAt, "TenantMetadata createdAt must not be null"); + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("TenantMetadata name must not be null or blank"); + } + } +} diff --git a/devslab-kit-tenant-api/src/main/java/kr/devslab/kit/tenant/TenantService.java b/devslab-kit-tenant-api/src/main/java/kr/devslab/kit/tenant/TenantService.java new file mode 100644 index 0000000..a548992 --- /dev/null +++ b/devslab-kit-tenant-api/src/main/java/kr/devslab/kit/tenant/TenantService.java @@ -0,0 +1,22 @@ +package kr.devslab.kit.tenant; + +import java.util.List; +import java.util.Optional; +import kr.devslab.kit.core.id.TenantId; + +public interface TenantService { + + TenantMetadata create(TenantId id, String name, TenantMode mode); + + void rename(TenantId id, String newName); + + void deactivate(TenantId id); + + void activate(TenantId id); + + void delete(TenantId id); + + Optional findById(TenantId id); + + List findAll(); +} diff --git a/devslab-kit-tenant-core/build.gradle.kts b/devslab-kit-tenant-core/build.gradle.kts index a7353b3..5b235d0 100644 --- a/devslab-kit-tenant-core/build.gradle.kts +++ b/devslab-kit-tenant-core/build.gradle.kts @@ -3,6 +3,11 @@ description = "devslab-kit :: tenant default implementation" dependencies { api(project(":devslab-kit-tenant-api")) + api("org.springframework.boot:spring-boot-starter-data-jpa") + compileOnly("org.springframework:spring-web") compileOnly("jakarta.servlet:jakarta.servlet-api") + + compileOnly("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok") } diff --git a/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/entity/PlatformTenantEntity.java b/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/entity/PlatformTenantEntity.java new file mode 100644 index 0000000..e6bc118 --- /dev/null +++ b/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/entity/PlatformTenantEntity.java @@ -0,0 +1,47 @@ +package kr.devslab.kit.tenant.core.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import kr.devslab.kit.tenant.TenantMode; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "platform_tenant") +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PlatformTenantEntity { + + @Id + @Column(name = "id", nullable = false, updatable = false, length = 64) + private String id; + + @Column(name = "name", nullable = false, length = 128) + private String name; + + @Enumerated(EnumType.STRING) + @Column(name = "mode", nullable = false, length = 16) + private TenantMode mode; + + @Column(name = "active", nullable = false) + private boolean active; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + public PlatformTenantEntity(String id, String name, TenantMode mode, boolean active, Instant createdAt) { + this.id = id; + this.name = name; + this.mode = mode; + this.active = active; + this.createdAt = createdAt; + } +} diff --git a/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/repository/JpaPlatformTenantRepository.java b/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/repository/JpaPlatformTenantRepository.java new file mode 100644 index 0000000..55ca445 --- /dev/null +++ b/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/repository/JpaPlatformTenantRepository.java @@ -0,0 +1,7 @@ +package kr.devslab.kit.tenant.core.repository; + +import kr.devslab.kit.tenant.core.entity.PlatformTenantEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface JpaPlatformTenantRepository extends JpaRepository { +} diff --git a/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/service/DefaultTenantService.java b/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/service/DefaultTenantService.java new file mode 100644 index 0000000..0c71093 --- /dev/null +++ b/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/service/DefaultTenantService.java @@ -0,0 +1,88 @@ +package kr.devslab.kit.tenant.core.service; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.tenant.TenantMetadata; +import kr.devslab.kit.tenant.TenantMode; +import kr.devslab.kit.tenant.TenantService; +import kr.devslab.kit.tenant.core.entity.PlatformTenantEntity; +import kr.devslab.kit.tenant.core.repository.JpaPlatformTenantRepository; +import org.springframework.transaction.annotation.Transactional; + +public class DefaultTenantService implements TenantService { + + private final JpaPlatformTenantRepository repository; + private final Clock clock; + + public DefaultTenantService(JpaPlatformTenantRepository repository, Clock clock) { + this.repository = repository; + this.clock = clock; + } + + @Override + @Transactional + public TenantMetadata create(TenantId id, String name, TenantMode mode) { + if (repository.findById(id.value()).isPresent()) { + throw new IllegalStateException("Tenant already exists: " + id); + } + PlatformTenantEntity entity = new PlatformTenantEntity( + id.value(), name, mode, true, Instant.now(clock)); + repository.save(entity); + return toMetadata(entity); + } + + @Override + @Transactional + public void rename(TenantId id, String newName) { + PlatformTenantEntity entity = repository.findById(id.value()) + .orElseThrow(() -> new IllegalArgumentException("Tenant not found: " + id)); + entity.setName(newName); + } + + @Override + @Transactional + public void deactivate(TenantId id) { + PlatformTenantEntity entity = repository.findById(id.value()) + .orElseThrow(() -> new IllegalArgumentException("Tenant not found: " + id)); + entity.setActive(false); + } + + @Override + @Transactional + public void activate(TenantId id) { + PlatformTenantEntity entity = repository.findById(id.value()) + .orElseThrow(() -> new IllegalArgumentException("Tenant not found: " + id)); + entity.setActive(true); + } + + @Override + @Transactional + public void delete(TenantId id) { + repository.deleteById(id.value()); + } + + @Override + @Transactional(readOnly = true) + public Optional findById(TenantId id) { + return repository.findById(id.value()).map(this::toMetadata); + } + + @Override + @Transactional(readOnly = true) + public List findAll() { + return repository.findAll().stream().map(this::toMetadata).toList(); + } + + private TenantMetadata toMetadata(PlatformTenantEntity e) { + return new TenantMetadata( + TenantId.of(e.getId()), + e.getName(), + e.getMode(), + e.isActive(), + e.getCreatedAt() + ); + } +} diff --git a/devslab-kit-tenant-core/src/main/resources/db/migration/V7__platform_tenant.sql b/devslab-kit-tenant-core/src/main/resources/db/migration/V7__platform_tenant.sql new file mode 100644 index 0000000..e1bcce4 --- /dev/null +++ b/devslab-kit-tenant-core/src/main/resources/db/migration/V7__platform_tenant.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS platform_tenant ( + id varchar(64) PRIMARY KEY, + name varchar(128) NOT NULL, + mode varchar(16) NOT NULL, + active boolean NOT NULL DEFAULT true, + created_at timestamp with time zone NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_platform_tenant_active ON platform_tenant(active);