feat: [demo] 데모 테넌트 운영 자동화 (Phase 2)
- DemoLimitMiddleware: 쇼케이스 읽기전용, 만료 체크, 외부연동 차단 - DemoTenantService: 파트너/체험 테넌트 생성, 기간 연장, 정식 전환 - ResetDemoShowcaseCommand: 매일 자정 데이터 리셋 + 샘플 재시드 - ManufacturingPresetSeeder: 부서/거래처/품목/견적/수주 샘플 데이터 - 스케줄러 등록 (00:00 demo:reset-showcase --seed) - 미들웨어 별칭 등록 (demo.limit)
This commit is contained in:
174
app/Console/Commands/ResetDemoShowcaseCommand.php
Normal file
174
app/Console/Commands/ResetDemoShowcaseCommand.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 데모 쇼케이스 테넌트 데이터 리셋 커맨드
|
||||
*
|
||||
* 매일 자정에 쇼케이스 테넌트의 비즈니스 데이터를 삭제하고
|
||||
* 샘플 데이터를 다시 시드한다.
|
||||
*
|
||||
* 기존 코드 영향 없음: DEMO_SHOWCASE 테넌트만 대상
|
||||
*
|
||||
* @see docs/features/sales/demo-tenant-policy.md
|
||||
*/
|
||||
class ResetDemoShowcaseCommand extends Command
|
||||
{
|
||||
protected $signature = 'demo:reset-showcase
|
||||
{--seed : 리셋 후 샘플 데이터 시드}
|
||||
{--dry-run : 실제 삭제 없이 대상만 표시}';
|
||||
|
||||
protected $description = '데모 쇼케이스 테넌트의 비즈니스 데이터를 리셋합니다';
|
||||
|
||||
/**
|
||||
* 리셋 대상 테이블 목록 (tenant_id 기반)
|
||||
* 순서 중요: FK 의존성 역순으로 삭제
|
||||
*/
|
||||
private const RESET_TABLES = [
|
||||
// 영업/주문
|
||||
'order_item_components',
|
||||
'order_items',
|
||||
'order_histories',
|
||||
'orders',
|
||||
'quotes',
|
||||
|
||||
// 생산
|
||||
'production_results',
|
||||
'production_plans',
|
||||
|
||||
// 자재/재고
|
||||
'material_inspection_items',
|
||||
'material_inspections',
|
||||
'material_receipts',
|
||||
'lot_sales',
|
||||
'lots',
|
||||
|
||||
// 마스터
|
||||
'price_histories',
|
||||
'product_components',
|
||||
'items',
|
||||
'clients',
|
||||
|
||||
// 파일 (데모 데이터 관련)
|
||||
// files는 morphable이므로 별도 처리 필요
|
||||
|
||||
// 조직
|
||||
'departments',
|
||||
|
||||
// 감사 로그 (데모 데이터)
|
||||
'audit_logs',
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$showcases = Tenant::withoutGlobalScopes()
|
||||
->where('tenant_type', Tenant::TYPE_DEMO_SHOWCASE)
|
||||
->get();
|
||||
|
||||
if ($showcases->isEmpty()) {
|
||||
$this->info('데모 쇼케이스 테넌트가 없습니다.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($showcases as $tenant) {
|
||||
$this->info("리셋 대상: [{$tenant->id}] {$tenant->company_name}");
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->showStats($tenant);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->resetTenantData($tenant);
|
||||
|
||||
if ($this->option('seed')) {
|
||||
$this->seedSampleData($tenant);
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function showStats(Tenant $tenant): void
|
||||
{
|
||||
foreach (self::RESET_TABLES as $table) {
|
||||
if (! \Schema::hasTable($table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! \Schema::hasColumn($table, 'tenant_id')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count = DB::table($table)->where('tenant_id', $tenant->id)->count();
|
||||
if ($count > 0) {
|
||||
$this->line(" - {$table}: {$count}건");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function resetTenantData(Tenant $tenant): void
|
||||
{
|
||||
$totalDeleted = 0;
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
foreach (self::RESET_TABLES as $table) {
|
||||
if (! \Schema::hasTable($table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! \Schema::hasColumn($table, 'tenant_id')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$deleted = DB::table($table)->where('tenant_id', $tenant->id)->delete();
|
||||
if ($deleted > 0) {
|
||||
$this->line(" 삭제: {$table} → {$deleted}건");
|
||||
$totalDeleted += $deleted;
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
$this->info(" 총 {$totalDeleted}건 삭제 완료");
|
||||
|
||||
Log::info('데모 쇼케이스 리셋 완료', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'deleted_count' => $totalDeleted,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error(" 리셋 실패: {$e->getMessage()}");
|
||||
Log::error('데모 쇼케이스 리셋 실패', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private function seedSampleData(Tenant $tenant): void
|
||||
{
|
||||
$preset = $tenant->getDemoPreset() ?? 'manufacturing';
|
||||
$this->info(" 샘플 데이터 시드: {$preset}");
|
||||
|
||||
try {
|
||||
$seeder = new \Database\Seeders\Demo\ManufacturingPresetSeeder;
|
||||
$seeder->run($tenant->id);
|
||||
$this->info(' 샘플 데이터 시드 완료');
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" 시드 실패: {$e->getMessage()}");
|
||||
Log::error('데모 샘플 데이터 시드 실패', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
93
app/Http/Middleware/DemoLimitMiddleware.php
Normal file
93
app/Http/Middleware/DemoLimitMiddleware.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 데모 테넌트 제한 미들웨어
|
||||
*
|
||||
* - DEMO_SHOWCASE: 모든 쓰기 작업 차단 (읽기 전용)
|
||||
* - DEMO_PARTNER / DEMO_TRIAL: 만료 체크 + 차단 기능 체크
|
||||
*
|
||||
* 기존 코드 영향 없음: 프로덕션 테넌트(STD/TPL/HQ)는 즉시 통과
|
||||
*
|
||||
* @see docs/features/sales/demo-tenant-policy.md
|
||||
*/
|
||||
class DemoLimitMiddleware
|
||||
{
|
||||
/**
|
||||
* 데모에서 차단하는 라우트 프리픽스 (외부 시스템 연동)
|
||||
*/
|
||||
private const BLOCKED_ROUTE_PREFIXES = [
|
||||
'api/v1/barobill',
|
||||
'api/v1/ecount',
|
||||
];
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$tenantId = app('tenant_id');
|
||||
if (! $tenantId) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$tenant = Tenant::withoutGlobalScopes()->find($tenantId);
|
||||
if (! $tenant || ! $tenant->isDemoTenant()) {
|
||||
// 프로덕션 테넌트 → 즉시 통과 (기존 동작 유지)
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// 1. 만료 체크 (파트너 데모, 고객 체험)
|
||||
if ($tenant->isDemoExpired()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '체험 기간이 만료되었습니다. 정식 계약을 진행해 주세요.',
|
||||
'error_code' => 'DEMO_EXPIRED',
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 2. 쇼케이스 → 읽기 전용 (GET, HEAD, OPTIONS만 허용)
|
||||
if ($tenant->isDemoShowcase() && ! $request->isMethodSafe()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '데모 환경에서는 조회만 가능합니다.',
|
||||
'error_code' => 'DEMO_READ_ONLY',
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 3. 읽기전용 옵션이 설정된 데모 테넌트
|
||||
if ($tenant->isDemoReadOnly() && ! $request->isMethodSafe()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '데모 환경에서는 조회만 가능합니다.',
|
||||
'error_code' => 'DEMO_READ_ONLY',
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 4. 차단 기능 체크 (바로빌, 이카운트 등 외부 연동)
|
||||
if ($this->isBlockedRoute($request)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '데모 환경에서 사용할 수 없는 기능입니다. 정식 계약 후 이용 가능합니다.',
|
||||
'error_code' => 'DEMO_FEATURE_BLOCKED',
|
||||
], 403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function isBlockedRoute(Request $request): bool
|
||||
{
|
||||
$path = $request->path();
|
||||
foreach (self::BLOCKED_ROUTE_PREFIXES as $prefix) {
|
||||
if (str_starts_with($path, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
167
app/Services/Demo/DemoTenantService.php
Normal file
167
app/Services/Demo/DemoTenantService.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Demo;
|
||||
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Services\Service;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 데모 테넌트 생성/관리 서비스
|
||||
*
|
||||
* 기존 코드 영향 없음: 데모 전용 로직만 포함
|
||||
*
|
||||
* @see docs/features/sales/demo-tenant-policy.md
|
||||
*/
|
||||
class DemoTenantService extends Service
|
||||
{
|
||||
/**
|
||||
* 기본 데모 수량 제한
|
||||
*/
|
||||
private const DEFAULT_DEMO_LIMITS = [
|
||||
'max_items' => 100,
|
||||
'max_orders' => 50,
|
||||
'max_productions' => 30,
|
||||
'max_users' => 5,
|
||||
'max_storage_gb' => 1,
|
||||
'max_ai_tokens' => 100000,
|
||||
];
|
||||
|
||||
/**
|
||||
* 사용 가능한 프리셋 목록
|
||||
*/
|
||||
private const AVAILABLE_PRESETS = [
|
||||
'manufacturing', // 제조업 기본
|
||||
// 향후 추가 예정:
|
||||
// 'blinds', // 블라인드/스크린
|
||||
// 'construction', // 시공/건설
|
||||
// 'distribution', // 유통/도소매
|
||||
];
|
||||
|
||||
/**
|
||||
* 파트너 데모 테넌트 생성 (Tier 2)
|
||||
* 파트너 승인 시 호출
|
||||
*/
|
||||
public function createPartnerDemo(int $partnerId, string $preset = 'manufacturing'): Tenant
|
||||
{
|
||||
$tenant = new Tenant;
|
||||
$tenant->forceFill([
|
||||
'company_name' => '파트너데모_'.$partnerId,
|
||||
'code' => 'DEMO_P_'.$partnerId,
|
||||
'email' => 'demo-partner-'.$partnerId.'@codebridge-x.com',
|
||||
'tenant_st_code' => 'active',
|
||||
'tenant_type' => Tenant::TYPE_DEMO_PARTNER,
|
||||
'demo_source_partner_id' => $partnerId,
|
||||
'options' => [
|
||||
Tenant::OPTION_DEMO_PRESET => $preset,
|
||||
Tenant::OPTION_DEMO_LIMITS => self::DEFAULT_DEMO_LIMITS,
|
||||
],
|
||||
]);
|
||||
$tenant->save();
|
||||
|
||||
Log::info('파트너 데모 테넌트 생성', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'partner_id' => $partnerId,
|
||||
'preset' => $preset,
|
||||
]);
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* 고객 체험 테넌트 생성 (Tier 3)
|
||||
* 파트너가 요청 → 본사 승인 후 호출
|
||||
*/
|
||||
public function createTrialDemo(
|
||||
int $partnerId,
|
||||
string $companyName,
|
||||
string $email,
|
||||
int $durationDays = 30,
|
||||
string $preset = 'manufacturing'
|
||||
): Tenant {
|
||||
$tenant = new Tenant;
|
||||
$tenant->forceFill([
|
||||
'company_name' => $companyName,
|
||||
'code' => 'DEMO_T_'.strtoupper(substr(md5(uniqid()), 0, 8)),
|
||||
'email' => $email,
|
||||
'tenant_st_code' => 'trial',
|
||||
'tenant_type' => Tenant::TYPE_DEMO_TRIAL,
|
||||
'demo_expires_at' => now()->addDays($durationDays),
|
||||
'demo_source_partner_id' => $partnerId,
|
||||
'options' => [
|
||||
Tenant::OPTION_DEMO_PRESET => $preset,
|
||||
Tenant::OPTION_DEMO_LIMITS => self::DEFAULT_DEMO_LIMITS,
|
||||
],
|
||||
]);
|
||||
$tenant->save();
|
||||
|
||||
Log::info('고객 체험 테넌트 생성', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'partner_id' => $partnerId,
|
||||
'company_name' => $companyName,
|
||||
'expires_at' => $tenant->demo_expires_at->toDateString(),
|
||||
]);
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* 체험 기간 연장 (최대 1회, 30일)
|
||||
*/
|
||||
public function extendTrial(Tenant $tenant, int $days = 30): bool
|
||||
{
|
||||
if (! $tenant->isDemoTrial()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 이미 연장한 이력이 있는지 체크 (options에 기록)
|
||||
if ($tenant->getOption('demo_extended', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$newExpiry = ($tenant->demo_expires_at ?? now())->addDays($days);
|
||||
$tenant->forceFill(['demo_expires_at' => $newExpiry]);
|
||||
$tenant->setOption('demo_extended', true);
|
||||
$tenant->save();
|
||||
|
||||
Log::info('고객 체험 기간 연장', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'new_expires_at' => $newExpiry->toDateString(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데모 → 정식 전환
|
||||
*/
|
||||
public function convertToProduction(Tenant $tenant): void
|
||||
{
|
||||
$tenant->convertToProduction();
|
||||
|
||||
Log::info('데모 → 정식 테넌트 전환', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'company_name' => $tenant->company_name,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용 가능한 프리셋 목록
|
||||
*/
|
||||
public function getAvailablePresets(): array
|
||||
{
|
||||
return self::AVAILABLE_PRESETS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 만료된 체험 테넌트 조회
|
||||
*/
|
||||
public function getExpiredTrials(): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return Tenant::withoutGlobalScopes()
|
||||
->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)
|
||||
->whereNotNull('demo_expires_at')
|
||||
->where('demo_expires_at', '<', now())
|
||||
->get();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user