diff --git a/app/Exceptions/VmProvisionException.php b/app/Exceptions/VmProvisionException.php new file mode 100644 index 0000000..e636507 --- /dev/null +++ b/app/Exceptions/VmProvisionException.php @@ -0,0 +1,13 @@ +where('customer_id', $customerId)->where('status', 'failed')->exists(); + abort_unless($failed, 404, 'Dịch vụ không tồn tại.'); + + $hasOpenTicket = Ticket::where('service_uuid', $serviceUuid)->whereIn('status', Ticket::UNRESOLVED_STATUSES)->exists(); + abort_if($hasOpenTicket, 422, 'Dịch vụ này đã có ticket đang được xử lý.'); + } + public function store(Request $request) { $request->validate([ @@ -76,11 +86,17 @@ class TicketController extends Controller 'description' => ['required', 'string', 'min:20'], 'room' => ['nullable', 'string'], 'data' => ['nullable', 'array'], + 'service_uuid' => ['nullable', 'uuid'], 'images' => ['nullable', 'array', 'max:5'], 'images.*' => ['image', 'mimes:jpg,jpeg,png,gif,webp', 'max:5120'], ]); $customer = auth('client')->user(); + $serviceUuid = $request->input('service_uuid'); + + if ($serviceUuid) { + $this->assertServiceTicketable($serviceUuid, $customer->id); + } $ticketNum = 'TKT-' . now()->format('Ymd') . '-' . strtoupper(Str::random(5)); @@ -96,6 +112,7 @@ class TicketController extends Controller 'data' => $request->input('data') ? json_encode($request->input('data')) : null, 'gallery' => $gallery ?: null, 'customer_id' => $customer->id, + 'service_uuid' => $serviceUuid, ]); TicketJobs::dispatch($ticket, $customer); diff --git a/app/Http/Controllers/Api/QueueMonitorController.php b/app/Http/Controllers/Api/QueueMonitorController.php index 8993dfb..6cc589e 100644 --- a/app/Http/Controllers/Api/QueueMonitorController.php +++ b/app/Http/Controllers/Api/QueueMonitorController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Support\QueueJobInspector; use Illuminate\Http\JsonResponse; use Laravel\Horizon\Contracts\JobRepository; use Laravel\Horizon\Contracts\MetricsRepository; @@ -71,6 +72,14 @@ class QueueMonitorController extends Controller ]); } + public function show(string $id): JsonResponse + { + $job = $this->jobs->getJobs([$id])->first(); + abort_if(!$job, 404, "Không tìm thấy job #{$id}"); + + return response()->json(['success' => true, 'data' => QueueJobInspector::describe($job)]); + } + /** * POST /api/queue/jobs/failed/{id}/retry * Re-queue a failed job by its Horizon job ID. diff --git a/app/Http/Controllers/Api/Service/Concerns/ManagesVmAccess.php b/app/Http/Controllers/Api/Service/Concerns/ManagesVmAccess.php index c38e018..1ec8d2a 100644 --- a/app/Http/Controllers/Api/Service/Concerns/ManagesVmAccess.php +++ b/app/Http/Controllers/Api/Service/Concerns/ManagesVmAccess.php @@ -8,9 +8,44 @@ use App\Models\Os; use App\Services\VmProvisionService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; trait ManagesVmAccess { + private const CLICK_LABELS = [ + 'snapshot' => 'Snapshot', + 'change-password' => 'Đổi mật khẩu', + 'change-ip' => 'Đổi IP', + 'change-port' => 'Đổi Remote Port', + 'renew' => 'Gia hạn', + 'console' => 'Truy cập console', + 'rebuild' => 'Rebuild máy chủ', + 'addons' => 'Addons', + ]; + + public function actionClick(Request $request): JsonResponse + { + $data = $request->validate([ + 'product' => 'required', + 'action' => ['required', 'string', Rule::in(array_keys(self::CLICK_LABELS))], + ]); + + $vm = $this->resolveVmById($data['product'], $request); + if ($vm instanceof JsonResponse) { + return $vm; + } + + $this->activityLogger->log( + event: 'click_' . str_replace('-', '_', $data['action']), + logName: 'VM', + description: "Customer {$request->user()->email} bấm \"" . self::CLICK_LABELS[$data['action']] . "\" trên VM: {$vm->name} (ID: {$vm->vmware_id})", + subject: $vm, + newValues: ['action' => $data['action']], + ); + + return response()->json(['success' => true]); + } + public function consoleTicket(Request $request, vCenter $serviceVCenter): JsonResponse { $productId = $request->get('product'); @@ -53,7 +88,7 @@ trait ManagesVmAccess $validated = $request->validate([ 'product' => ['required'], 'current_password' => ['required', 'string'], - 'new_password' => ['required', 'string', 'min:8'], + 'new_password' => ['required', 'string', 'min:12', 'max:64', 'regex:/^[A-Za-z0-9!@#^*_+=?.,:~]+$/'], ]); $vm = $this->resolveVmById($validated['product'], $request); diff --git a/app/Http/Controllers/Api/Service/Concerns/ManagesVmPower.php b/app/Http/Controllers/Api/Service/Concerns/ManagesVmPower.php index 957ed26..0ec5a98 100644 --- a/app/Http/Controllers/Api/Service/Concerns/ManagesVmPower.php +++ b/app/Http/Controllers/Api/Service/Concerns/ManagesVmPower.php @@ -33,6 +33,7 @@ trait ManagesVmPower return $this->vmLocked(); } + $startedAt = now(); try { $result = $action->execute($vm); } finally { @@ -44,6 +45,8 @@ trait ManagesVmPower logName: 'VM', description: "Customer {$request->user()->email} bật VM: {$vm->name} (ID: {$vm->vmware_id})", subject: $vm, + startedAt: $startedAt, + finishedAt: now(), ); return $this->vmResponse($result, 'Đã gửi lệnh bật VM.'); @@ -71,6 +74,7 @@ trait ManagesVmPower return $this->vmLocked(); } + $startedAt = now(); try { $result = $action->execute($vm); } finally { @@ -82,6 +86,8 @@ trait ManagesVmPower logName: 'VM', description: "Customer {$request->user()->email} tắt VM: {$vm->name} (ID: {$vm->vmware_id})", subject: $vm, + startedAt: $startedAt, + finishedAt: now(), ); return $this->vmResponse($result, 'Đã gửi lệnh tắt VM.'); @@ -104,6 +110,7 @@ trait ManagesVmPower return $this->vmLocked(); } + $startedAt = now(); try { $result = $serviceVCenter->restartVM($vm, $vm->vmware_id); } finally { @@ -122,6 +129,8 @@ trait ManagesVmPower logName: 'VM', description: "Customer {$request->user()->email} restart VM: {$vm->name} (ID: {$vm->vmware_id})", subject: $vm, + startedAt: $startedAt, + finishedAt: now(), ); return response()->json([ diff --git a/app/Http/Controllers/Api/Service/SnapshotController.php b/app/Http/Controllers/Api/Service/SnapshotController.php index 339593e..f16cd61 100644 --- a/app/Http/Controllers/Api/Service/SnapshotController.php +++ b/app/Http/Controllers/Api/Service/SnapshotController.php @@ -9,6 +9,7 @@ use App\Jobs\VirtualMachine\Snapshot\CreateSnapshot; use App\Jobs\VirtualMachine\Snapshot\RevertSnapshot; use App\Jobs\VirtualMachine\Snapshot\RemoveSnapshot; use App\Models\ModuleSnapshot; +use App\Models\ModuleVmware; use App\Services\ActivityLogger; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -25,6 +26,10 @@ class SnapshotController extends Controller $payload = $request->validate([ 'product' => ['required'], 'description' => ['nullable', 'string', 'max:1000'], + 'name' => ['nullable', 'string', 'max:80', 'regex:/^[A-Za-z0-9._-]+$/'], + ], [ + 'name.regex' => 'Tên snapshot chỉ gồm chữ không dấu, số và các ký tự . _ -', + 'name.max' => 'Tên snapshot tối đa 80 ký tự.', ]); $vm = $this->resolveVmById($payload['product'], $request); @@ -32,11 +37,10 @@ class SnapshotController extends Controller return $vm; } - $today = now()->format('d-m-Y'); - $existingCount = ModuleSnapshot::where('module_vmware_id', $vm->id) - ->whereDate('created_at', today()) - ->count(); - $snapshotName = sprintf('%s-%s-%d', $vm->name, $today, $existingCount + 1); + $snapshotName = $payload['name'] ?? $this->suggestName($vm); + if (ModuleSnapshot::where('module_vmware_id', $vm->id)->where('name', $snapshotName)->exists()) { + return response()->json(['error' => true, 'message' => 'Tên snapshot đã tồn tại, vui lòng chọn tên khác.'], 422); + } $snapshot = ModuleSnapshot::create([ 'name' => $snapshotName, @@ -69,6 +73,18 @@ class SnapshotController extends Controller ]); } + private function suggestName(ModuleVmware $vm): string + { + $prefix = sprintf('%s-%s-', $vm->name, now()->format('d-m-Y')); + $sequence = ModuleSnapshot::where('module_vmware_id', $vm->id)->whereDate('created_at', today())->count() + 1; + + while (ModuleSnapshot::where('module_vmware_id', $vm->id)->where('name', $prefix . $sequence)->exists()) { + $sequence++; + } + + return $prefix . $sequence; + } + public function index(Request $request, vCenter $serviceVCenter): JsonResponse { $productParam = $request->query('product'); @@ -116,6 +132,9 @@ class SnapshotController extends Controller ]; if ($snapshot) { + if ($snapshot->status === 'deleting') { + unset($payload['status']); + } $snapshot->update($payload); } else { ModuleSnapshot::create($payload); @@ -142,6 +161,7 @@ class SnapshotController extends Controller return response()->json([ 'error' => false, 'data' => $dbSnapshots, + 'suggested_name' => $this->suggestName($vm), ]); } @@ -204,7 +224,17 @@ class SnapshotController extends Controller return response()->json(['error' => true, 'message' => 'Không tìm thấy snapshot'], 404); } - RemoveSnapshot::dispatch($vm->id, $snapshot->id); + if ($snapshot->status === 'deleting') { + return response()->json(['error' => true, 'message' => 'Snapshot đang được xóa.'], 422); + } + + $deletedNow = $snapshot->status === 'failed'; + if ($deletedNow) { + $snapshot->delete(); + } else { + $snapshot->update(['status' => 'deleting']); + RemoveSnapshot::dispatch($vm->id, $snapshot->id); + } $this->activityLogger->log( event: 'deleted', @@ -215,7 +245,7 @@ class SnapshotController extends Controller return response()->json([ 'error' => false, - 'message' => 'Đã gửi yêu cầu xóa snapshot vào hàng đợi', + 'message' => $deletedNow ? 'Đã xóa snapshot' : 'Đã gửi yêu cầu xóa snapshot vào hàng đợi', ]); } } \ No newline at end of file diff --git a/app/Http/Resources/Api/Service/VpsServiceResource.php b/app/Http/Resources/Api/Service/VpsServiceResource.php index 1755071..e35a7e0 100644 --- a/app/Http/Resources/Api/Service/VpsServiceResource.php +++ b/app/Http/Resources/Api/Service/VpsServiceResource.php @@ -3,6 +3,7 @@ namespace App\Http\Resources\Api\Service; use App\Models\Product; +use App\Models\Ticket; use App\Support\ServiceGroup; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -45,6 +46,9 @@ class VpsServiceResource extends JsonResource 'order_num' => $order?->order_num, 'status' => $this->status, 'status_label' => $this->status_label, + 'failure_ticket' => $this->status === 'failed' + ? Ticket::where('service_uuid', $this->uuid)->latest('id')->first(['ticket_num', 'status']) + : null, 'auto_renew' => (bool) $this->auto_renew, 'cancel_at_expiry' => (bool) $this->cancel_at_expiry, 'started_at' => $this->started_at?->toISOString(), diff --git a/app/Http/Services/vCenter/Concerns/ManagesSsh.php b/app/Http/Services/vCenter/Concerns/ManagesSsh.php index 775ca8f..bbda332 100644 --- a/app/Http/Services/vCenter/Concerns/ManagesSsh.php +++ b/app/Http/Services/vCenter/Concerns/ManagesSsh.php @@ -142,7 +142,7 @@ $ErrorActionPreference = 'Stop' Import-Module VMware.PowerCLI -ErrorAction Stop -Connect-VIServer -Server '' -User '' -Password '' | Out-Null +Connect-VIServer -Server -User -Password | Out-Null & { @@ -190,7 +190,7 @@ $ErrorActionPreference = 'Stop' Import-Module VMware.PowerCLI -ErrorAction Stop -Connect-VIServer -Server '' -User '' -Password '' | Out-Null +Connect-VIServer -Server -User -Password | Out-Null & { diff --git a/app/Http/Services/vCenter/PowerShellService.php b/app/Http/Services/vCenter/PowerShellService.php index 0c1bf65..d1a7358 100644 --- a/app/Http/Services/vCenter/PowerShellService.php +++ b/app/Http/Services/vCenter/PowerShellService.php @@ -106,7 +106,7 @@ $ErrorActionPreference = 'Stop' Import-Module VMware.PowerCLI -ErrorAction Stop -Connect-VIServer -Server '' -User '' -Password '' | Out-Null +Connect-VIServer -Server -User -Password | Out-Null & { @@ -154,7 +154,7 @@ $ErrorActionPreference = 'Stop' Import-Module VMware.PowerCLI -ErrorAction Stop -Connect-VIServer -Server '' -User '' -Password '' | Out-Null +Connect-VIServer -Server -User -Password | Out-Null & { diff --git a/app/Jobs/VirtualMachine/Action/VMJobs.php b/app/Jobs/VirtualMachine/Action/VMJobs.php index 99040d6..bebf53a 100644 --- a/app/Jobs/VirtualMachine/Action/VMJobs.php +++ b/app/Jobs/VirtualMachine/Action/VMJobs.php @@ -51,90 +51,59 @@ class VMJobs implements ShouldQueue $vcenter = $product->IpAddress->Host->Datacenter->vCenter; $serviceVCenter->setConnection($vcenter->host, $vcenter->token); + $vmwareId = $product->vmware_id; $VirtualMachineStatus = null; $status = 1; - switch ($this->action) { - case "Suspend": - $VirtualMachineStatus = $serviceVCenter->SuspendVirtualMachine($product->vmware_id); - if ($VirtualMachineStatus === "Login_Again") { - $token = $serviceVCenter->loginServer($product->IpAddress->Host->Datacenter->vCenter); - if ($token) { - vCenter::where('id', $product->IpAddress->Host->Datacenter->vCenter->id)->update(['token' => $token]); - $serviceVCenter->token = $token; - } - $VirtualMachineStatus = $serviceVCenter->SuspendVirtualMachine($product->vmware_id); + $call = function (callable $action) use ($serviceVCenter, $vcenter) { + $result = $action(); + if ($result === 'Login_Again') { + $token = $serviceVCenter->loginServer($vcenter); + if ($token) { + vCenter::where('id', $vcenter->id)->update(['token' => $token]); + $serviceVCenter->token = $token; } + $result = $action(); + } + + return $result; + }; + + $powerState = fn () => $call(fn () => $serviceVCenter->CheckVirtualMachine($vmwareId))['power_state'] ?? null; + + switch ($this->action) { + case 'Suspend': + $VirtualMachineStatus = $call(fn () => $serviceVCenter->SuspendVirtualMachine($vmwareId)); $status = 2; break; - case "Reboot": - $VirtualMachineStatus = $serviceVCenter->RebootVirtualMachine($product->vmware_id); - if ($VirtualMachineStatus === "Login_Again") { - $token = $serviceVCenter->loginServer($product->IpAddress->Host->Datacenter->vCenter); - if ($token) { - vCenter::where('id', $product->IpAddress->Host->Datacenter->vCenter->id)->update(['token' => $token]); - $serviceVCenter->token = $token; - } - $VirtualMachineStatus = $serviceVCenter->RebootVirtualMachine($product->vmware_id); + case 'Reboot': + $VirtualMachineStatus = $call(fn () => $serviceVCenter->RebootVirtualMachine($product, $vmwareId)); + break; + + case 'Start': + if ($powerState() !== 'POWERED_ON') { + $VirtualMachineStatus = $call(fn () => $serviceVCenter->StartVirtualMachine($product, $vmwareId)); } break; - case "Start": - $VirtualMachineStatus = $serviceVCenter->CheckVirtualMachine($product->vmware_id); - if ($VirtualMachineStatus === "Login_Again") { - $token = $serviceVCenter->loginServer($product->IpAddress->Host->Datacenter->vCenter); - if ($token) { - vCenter::where('id', $product->IpAddress->Host->Datacenter->vCenter->id)->update(['token' => $token]); - $serviceVCenter->token = $token; - } - $VirtualMachineStatus = $serviceVCenter->CheckVirtualMachine($product->vmware_id); - } - if ($VirtualMachineStatus['data']['operations_ready'] === true) { - break; - } - $VirtualMachineStatus = $serviceVCenter->StartVirtualMachine($product->vmware_id); - break; - - case "ShutdownGuest": - $VirtualMachineStatus = $serviceVCenter->CheckVirtualMachine($product->vmware_id); - if ($VirtualMachineStatus === "Login_Again") { - $token = $serviceVCenter->loginServer($product->IpAddress->Host->Datacenter->vCenter); - if ($token) { - vCenter::where('id', $product->IpAddress->Host->Datacenter->vCenter->id)->update(['token' => $token]); - $serviceVCenter->token = $token; - } - $VirtualMachineStatus = $serviceVCenter->CheckVirtualMachine($product->vmware_id); - } - if ($VirtualMachineStatus['data']['operations_ready'] === false) { - $status = 0; - break; - } - $VirtualMachineStatus = $serviceVCenter->ShutdownGuestVirtualMachine($product->vmware_id); + case 'ShutdownGuest': $status = 0; + if ($powerState() !== 'POWERED_OFF') { + $VirtualMachineStatus = $call(fn () => $serviceVCenter->ShutdownGuestVirtualMachine($product, $vmwareId)); + } break; - case "PowerOff": - $VirtualMachineStatus = $serviceVCenter->CheckVirtualMachine($product->vmware_id); - if ($VirtualMachineStatus === "Login_Again") { - $token = $serviceVCenter->loginServer($product->IpAddress->Host->Datacenter->vCenter); - if ($token) { - vCenter::where('id', $product->IpAddress->Host->Datacenter->vCenter->id)->update(['token' => $token]); - $serviceVCenter->token = $token; - } - $VirtualMachineStatus = $serviceVCenter->CheckVirtualMachine($product->vmware_id); - } - if ($VirtualMachineStatus['data']['operations_ready'] === false) { - $status = 0; - break; - } - $VirtualMachineStatus = $serviceVCenter->PowerOffVirtualMachine($product->vmware_id); + case 'PowerOff': $status = 0; + if ($powerState() !== 'POWERED_OFF') { + $VirtualMachineStatus = $call(fn () => $serviceVCenter->PowerOffVirtualMachine($vmwareId)); + } break; } - if ($VirtualMachineStatus && $VirtualMachineStatus['error']) { - throw new \Exception(json_encode($VirtualMachineStatus['data'])); + if (is_array($VirtualMachineStatus) && ($VirtualMachineStatus['error'] ?? false)) { + throw new \Exception(json_encode($VirtualMachineStatus['data'] ?? $VirtualMachineStatus)); } if ($this->update) { diff --git a/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php b/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php index b96f913..a75d5e6 100644 --- a/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php +++ b/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php @@ -6,13 +6,17 @@ namespace App\Jobs\VirtualMachine\Create; use App\Http\Services\vCenter; use App\Jobs\VirtualMachine\Provision\UpdateVmConfigJob; +use App\Exceptions\VmProvisionException; use App\Models\OrderItem; +use App\Models\Service; use App\Models\VmProvisionLock; +use App\Services\ActivityLogger; use App\Services\VirtualMachine\VmAddonResolver; use App\Services\VirtualMachine\VmInfrastructureResolver; use App\Services\VirtualMachine\VmIpReservationService; use App\Services\VirtualMachine\VmModuleService; use App\Services\VirtualMachine\VmNotificationService; +use App\Services\VirtualMachine\VmProvisionRefund; use App\Services\VirtualMachine\VmProvisionLogger; use App\Services\VirtualMachine\VmServiceSyncService; use App\Services\VirtualMachine\VmVcenterErrorTranslator; @@ -51,6 +55,7 @@ class CreateVirtualMachineJob implements ShouldQueue VmNotificationService $notification, VmVcenterErrorTranslator $errorTranslator, VmIpReservationService $ipReservation, + VmProvisionRefund $refund, ): bool { $reservation = null; $ipPoolId = null; @@ -141,8 +146,9 @@ class CreateVirtualMachineJob implements ShouldQueue ]); if (is_array($result) && ($result['error'] ?? false)) { - throw new RuntimeException( - $errorTranslator->translate($result, $this->item->server_name) + throw new VmProvisionException( + $errorTranslator->translate($result, $this->item->server_name), + $errorTranslator->detail($result), ); } @@ -161,7 +167,11 @@ class CreateVirtualMachineJob implements ShouldQueue 'vm_id' => $vmId, ]); - $moduleVmware = $moduleService->create($this->item, $infra, $vmId); + $moduleVmware = $moduleService->create($this->item, $infra, $vmId, [ + 'cpu' => (int) $product->cpu + $addonData['cpu'], + 'ram_mb' => ((int) $product->ram + $addonData['ram']) * 1024, + 'disk_gb' => (int) $product->disk + $addonData['disk'], + ]); $vmLogger->success('ModuleVmware created', [ 'module_vmware_id' => $moduleVmware->id, @@ -210,14 +220,31 @@ class CreateVirtualMachineJob implements ShouldQueue } $lock?->update(['status' => 'failed']); + Service::where('order_item_id', $this->item->id) + ->whereIn('status', ['pending', 'provisioning']) + ->update(['status' => 'failed']); + + $adminDetail = $e instanceof VmProvisionException ? $e->adminDetail : $e->getMessage(); $vmLogger->exception('Create VM job failed', $e, [ 'order_item_id' => $this->item->id, 'is_retry' => $this->isRetry, + 'admin_detail' => $adminDetail, ]); + $failedService = Service::where('order_item_id', $this->item->id)->first(); + if ($failedService) { + app(ActivityLogger::class)->log( + 'provision_failed', + 'service', + 'Khởi tạo máy ảo thất bại — ' . $adminDetail, + $failedService, + ); + } + if (!$this->isRetry) { - $notification->failed($this->item, $e->getMessage()); + $refunded = $refund->refund($this->item, $e->getMessage()); + $notification->failed($this->item, $e->getMessage() . ($refunded ? ' (đã tự động hoàn tiền cho khách)' : '')); } return false; diff --git a/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php b/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php index cba044e..33c2121 100644 --- a/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php +++ b/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php @@ -206,8 +206,9 @@ class UpdateVmConfigJob implements ShouldQueue 'output' => trim($output), ]); + $moduleVmware->update(['cpu' => $totalCpu, 'ram_mb' => $totalRam * 1024]); + if ($isManualOverride) { - $moduleVmware->update(['cpu' => $totalCpu, 'ram_mb' => $totalRam * 1024]); $this->syncServiceAddons($moduleVmware, $totalCpu, $totalRam, $logger); return; } diff --git a/app/Jobs/VirtualMachine/Snapshot/RemoveSnapshot.php b/app/Jobs/VirtualMachine/Snapshot/RemoveSnapshot.php index 4a83d87..7cb905e 100644 --- a/app/Jobs/VirtualMachine/Snapshot/RemoveSnapshot.php +++ b/app/Jobs/VirtualMachine/Snapshot/RemoveSnapshot.php @@ -160,6 +160,10 @@ class RemoveSnapshot implements ShouldQueue public function failed(\Throwable $exception) { + ModuleSnapshot::where('id', $this->snapshotId) + ->where('status', 'deleting') + ->update(['status' => 'success', 'error_message' => $exception->getMessage()]); + Log::error('❌ Job RemoveSnapshot failed', [ 'product_id' => $this->productId, 'snapshot_id' => $this->snapshotId, diff --git a/app/Models/ActivityLog.php b/app/Models/ActivityLog.php index 5dffb6b..4d42946 100644 --- a/app/Models/ActivityLog.php +++ b/app/Models/ActivityLog.php @@ -23,19 +23,23 @@ class ActivityLog extends Model 'new_values', 'ip_address', 'user_agent', + 'started_at', + 'finished_at', 'created_at', ]; protected $casts = [ 'old_values' => 'array', 'new_values' => 'array', + 'started_at' => 'datetime:Y-m-d\TH:i:s.v\Z', + 'finished_at' => 'datetime:Y-m-d\TH:i:s.v\Z', 'created_at' => 'datetime', ]; protected static function booting(): void { static::creating(function (self $model) { - $model->created_at = now(); + $model->created_at ??= now(); }); } } diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index ee12975..1a29a50 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -38,6 +38,7 @@ class Ticket extends Model 'ticket_num', 'subject', 'customer_id', + 'service_uuid', 'description', 'status', 'room', diff --git a/app/Services/ActivityLogger.php b/app/Services/ActivityLogger.php index 2270629..3343599 100644 --- a/app/Services/ActivityLogger.php +++ b/app/Services/ActivityLogger.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Jobs\LogActivityJob; +use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Log; @@ -25,6 +26,8 @@ class ActivityLogger ?Model $subject = null, array $oldValues = [], array $newValues = [], + ?CarbonInterface $startedAt = null, + ?CarbonInterface $finishedAt = null, ): void { try { [$causerType, $causerId, $causerName] = $this->resolveCauser(); @@ -42,6 +45,9 @@ class ActivityLogger 'new_values' => $newValues ?: null, 'ip_address' => request()->getClientIp(), 'user_agent' => request()->userAgent(), + 'started_at' => $startedAt?->format('Y-m-d H:i:s.v'), + 'finished_at' => $finishedAt?->format('Y-m-d H:i:s.v'), + 'created_at' => now()->format('Y-m-d H:i:s.v'), ]); } catch (\Throwable $e) { Log::warning('[ActivityLogger] Failed to dispatch log job', [ diff --git a/app/Services/Api/Order/OrderService.php b/app/Services/Api/Order/OrderService.php index 74ba0e8..6778565 100644 --- a/app/Services/Api/Order/OrderService.php +++ b/app/Services/Api/Order/OrderService.php @@ -4,6 +4,7 @@ namespace App\Services\Api\Order; use App\Enums\Invoice\InvoiceStatus; use App\Enums\Order\OrderStatus; +use App\Enums\Product\ProductTypeEnums; use App\Helpers\OrderHelper; use App\Models\Order; use App\Models\Product; @@ -13,6 +14,7 @@ use App\Repositories\Api\Service\ServiceRepository; use App\Services\InvoicePaymentService; use App\Services\AdminPaymentNotifier; use App\Services\Coupon\CouponService; +use App\Services\VirtualMachine\VmIpReservationService; use App\Support\AddonQuantityValidator; use Illuminate\Support\Facades\DB; @@ -25,6 +27,7 @@ class OrderService protected InvoicePaymentService $invoicePaymentService, protected AdminPaymentNotifier $adminPaymentNotifier, protected CouponService $couponService, + protected VmIpReservationService $vmIpReservation, ) {} // ========================================================================= @@ -46,6 +49,8 @@ class OrderService ['product' => $product, 'base_price' => $basePrice, 'addons_meta' => $addonsMeta, 'sub_total' => $subTotal] = $this->quote($data); $couponCode = $data['coupon_code'] ?? null; + $this->assertInfrastructureAvailable([$product]); + $expectedTotal = $couponCode ? $subTotal - $this->couponService->preview($couponCode, $customer, $product, $data['billing_cycle'], $subTotal)['discount'] : $subTotal; @@ -100,6 +105,19 @@ class OrderService }); } + private function assertInfrastructureAvailable(iterable $products): void + { + $needsVm = collect($products)->contains(fn ($product) => in_array( + ProductTypeEnums::tryFrom($product?->category?->type ?? ''), + [ProductTypeEnums::VPS, ProductTypeEnums::CLOUD], + true + )); + + if ($needsVm && !$this->vmIpReservation->hasFreeIp()) { + throw new \InvalidArgumentException('Hiện tại hạ tầng đang đầy vui lòng liên hệ quản trị viên'); + } + } + /** * Giá niêm yết (chu kỳ + addon) tính phía server; nguồn duy nhất cho create() và preview mã giảm giá. * @@ -143,6 +161,8 @@ class OrderService throw new \InvalidArgumentException('Đơn hàng đã được thanh toán hoặc đang xử lý.'); } + $this->assertInfrastructureAvailable($order->orderItems->map->product); + $processedOrder = DB::transaction(function () use ($order, $method) { $customer = $order->customer; diff --git a/app/Services/Concerns/ActivatesPaidInvoices.php b/app/Services/Concerns/ActivatesPaidInvoices.php index 601be6e..edab515 100644 --- a/app/Services/Concerns/ActivatesPaidInvoices.php +++ b/app/Services/Concerns/ActivatesPaidInvoices.php @@ -269,7 +269,7 @@ trait ActivatesPaidInvoices ServiceAddon::create([ 'service_id' => $service->id, 'addon_type' => $type, - 'addon_value' => (string) $value, + 'addon_value' => (string) ($type === 'ram' ? (int) $value * 1024 : $value), 'price' => 0, ]); } @@ -288,7 +288,7 @@ trait ActivatesPaidInvoices ServiceAddon::create([ 'service_id' => $service->id, 'addon_type' => strtolower((string) $addon->code), - 'addon_value' => (string) ($meta['qty'] ?? 1), + 'addon_value' => (string) (strtolower((string) $addon->code) === 'ram' ? (int) ($meta['qty'] ?? 1) * 1024 : ($meta['qty'] ?? 1)), 'price' => (int) ($meta['price'] ?? $addon->price), ]); } diff --git a/app/Services/CustomerRenewalNotifier.php b/app/Services/CustomerRenewalNotifier.php index 66e1477..dbbe154 100644 --- a/app/Services/CustomerRenewalNotifier.php +++ b/app/Services/CustomerRenewalNotifier.php @@ -42,6 +42,8 @@ class CustomerRenewalNotifier 'data' => ['service_id' => $service->id, 'invoice_id' => $invoice->id, 'months' => $months], ]); + app(ServiceRenewalNotifier::class)->notifyCustomerRenewal($service, $invoice, $months, $previousExpiresAt); + $adminEmail = json_decode((string) Setting::where('key', 'setting')->value('value'), true)['admin_email'] ?? null; if (! $adminEmail || ! AutoEmailGate::allows(self::SCENARIO)) { return; diff --git a/app/Services/ServiceRenewalNotifier.php b/app/Services/ServiceRenewalNotifier.php index cb16305..92d0efe 100644 --- a/app/Services/ServiceRenewalNotifier.php +++ b/app/Services/ServiceRenewalNotifier.php @@ -34,11 +34,44 @@ class ServiceRenewalNotifier return; } + $data = $this->payload( + $service, + $invoice, + $months, + $previousExpiresAt, + $payment === 'balance' ? 'Trừ số dư tài khoản' : 'Đã thu ngoài hệ thống', + ) + ['admin_name' => $adminName]; + + AutoEmailGate::markManual(); + + $this->safely(fn () => $this->sendToCustomer($service, $data, $adminId)); + $this->safely(fn () => $this->sendToAdmin($data)); + } + + public function notifyCustomerRenewal( + Service $service, + Invoice $invoice, + int $months, + ?CarbonInterface $previousExpiresAt, + ): void { + if (! AutoEmailGate::allows(self::CUSTOMER_SCENARIO)) { + return; + } + + $paymentLabel = $invoice->payment_method === 'balance' ? 'Trừ số dư tài khoản' : (string) ($invoice->payment_method ?: 'N/A'); + $data = $this->payload($service, $invoice, $months, $previousExpiresAt, $paymentLabel); + + $this->safely(fn () => $this->sendToCustomer($service, $data, null)); + } + + private function payload(Service $service, Invoice $invoice, int $months, ?CarbonInterface $previousExpiresAt, string $paymentLabel): array + { $service->loadMissing(['customer', 'product']); $customer = $service->customer; $serverName = (string) $service->server_name; + $amount = number_format((float) $invoice->total, 0, ',', '.') . ' VNĐ'; - $data = [ + return [ 'client_name' => $customer->fullname ?? '', 'customer_name' => $customer->fullname ?? '', 'customer_email' => $customer->email ?? '', @@ -48,16 +81,10 @@ class ServiceRenewalNotifier 'old_expires_at' => $previousExpiresAt?->format('d/m/Y') ?? 'N/A', 'expires_at' => $service->expires_at?->format('d/m/Y') ?? 'N/A', 'invoice_number' => $invoice->invoice_number, - 'amount_formatted' => number_format((float) $invoice->total, 0, ',', '.') . ' VNĐ', - 'payment_method_label' => $payment === 'balance' ? 'Trừ số dư tài khoản' : 'Đã thu ngoài hệ thống', - 'admin_name' => $adminName, - 'price_row' => EmailTemplateShell::row('Số tiền gia hạn', number_format((float) $invoice->total, 0, ',', '.') . ' VNĐ'), + 'amount_formatted' => $amount, + 'payment_method_label' => $paymentLabel, + 'price_row' => EmailTemplateShell::row('Số tiền gia hạn', $amount), ]; - - AutoEmailGate::markManual(); - - $this->safely(fn () => $this->sendToCustomer($service, $data, $adminId)); - $this->safely(fn () => $this->sendToAdmin($data)); } private function sendToCustomer(Service $service, array $data, ?int $adminId): void diff --git a/app/Services/VirtualMachine/VmInfrastructureResolver.php b/app/Services/VirtualMachine/VmInfrastructureResolver.php index 6ef3fef..c1d7e29 100644 --- a/app/Services/VirtualMachine/VmInfrastructureResolver.php +++ b/app/Services/VirtualMachine/VmInfrastructureResolver.php @@ -14,8 +14,8 @@ use RuntimeException; * * active vCenter → active Datacenter → active Host → active Datastore + Network * - * Product/OS host binding is informational only — it no longer drives VM - * creation. Datastore, network, resource pool and folder are exceptions: if + * The OS host (and its datacenter) overrides the active host when set, because the + * OS template, datastore, network, resource pool and folder belong to that host. Datastore, network, resource pool and folder are exceptions: if * the OS has datastore_id / module_network_id / resource_pool_id / folder_id * set, they override the active chain / global default (an OS can target a * different datastore/IP pool/resource pool/folder than whatever happens to @@ -32,7 +32,7 @@ class VmInfrastructureResolver public function resolve(OrderItem $item, $product): VmInfrastructureContext { - $os = Os::with(['osGroup', 'network', 'datastore', 'resourcePool', 'folder'])->find($item->os_id); + $os = Os::with(['osGroup', 'host.datacenter', 'network', 'datastore', 'resourcePool', 'folder'])->find($item->os_id); if (!$os) { throw new RuntimeException( @@ -41,6 +41,8 @@ class VmInfrastructureResolver } $chain = $this->activeProvider->resolveChain(); + $host = $os->host ?? $chain['host']; + $datacenter = $os->host?->datacenter ?? $chain['datacenter']; $network = $os->network ?? $chain['network']; $datastore = $os->datastore ?? $chain['datastore']; $folderId = $os->folder?->folder ?? $this->providerSettings->defaultFolder(); @@ -50,7 +52,8 @@ class VmInfrastructureResolver 'order_item_id' => $item->id, 'vcenter_id' => $chain['vcenter']->id, 'datacenter_id' => $chain['datacenter']->id, - 'host_id' => $chain['host']->id, + 'host_id' => $host->id, + 'host_from_os' => $os->host !== null, 'datastore_id' => $datastore->id, 'datastore_from_os' => $os->datastore !== null, 'network_id' => $network->id, @@ -66,8 +69,8 @@ class VmInfrastructureResolver return new VmInfrastructureContext( os: $os, - host: $chain['host'], - datacenter: $chain['datacenter'], + host: $host, + datacenter: $datacenter, vcenter: $chain['vcenter'], template: $os->template_id, isWindows: $isWindows, diff --git a/app/Services/VirtualMachine/VmIpReservationService.php b/app/Services/VirtualMachine/VmIpReservationService.php index c14795f..f1da6c9 100644 --- a/app/Services/VirtualMachine/VmIpReservationService.php +++ b/app/Services/VirtualMachine/VmIpReservationService.php @@ -18,6 +18,11 @@ use Illuminate\Support\Facades\Log; */ class VmIpReservationService { + public function hasFreeIp(): bool + { + return $this->availableIps()->exists(); + } + /** * @return array{reservation:ModuleIpAddress,ip_pool_id:int,gateway:string,cidr:int}|null */ @@ -25,8 +30,7 @@ class VmIpReservationService { return DB::transaction(function () use ($networkId, $hostId, $dns) { // ip_pools.status is the single source of truth for availability. - $ipPool = IpPool::where('module_network_id', $networkId) - ->whereIn('status', ['free', 'reserved']) + $ipPool = $this->availableIps() ->lockForUpdate() ->inRandomOrder() ->first(); @@ -44,7 +48,6 @@ class VmIpReservationService // module_ip_address mirrors ip_pools — update the existing synced row // (created when the IP was added to ip_pools) instead of inserting a new one. $reservation = ModuleIpAddress::where('ip', $ipPool->ip_address) - ->where('module_network_id', $networkId) ->lockForUpdate() ->first(); @@ -101,6 +104,13 @@ class VmIpReservationService }); } + private function availableIps() + { + return IpPool::whereIn('status', ['free', 'reserved']) + ->whereNotNull('gateway') + ->where('gateway', '!=', ''); + } + public function freeIpsInNetwork(int $networkId) { return IpPool::where('module_network_id', $networkId) diff --git a/app/Services/VirtualMachine/VmModuleService.php b/app/Services/VirtualMachine/VmModuleService.php index 956bcfd..a01d222 100644 --- a/app/Services/VirtualMachine/VmModuleService.php +++ b/app/Services/VirtualMachine/VmModuleService.php @@ -15,7 +15,7 @@ class VmModuleService * Create ModuleVmware shell. IP and final password are filled in later * by AssignIpToGuestJob → ChangePasswordViaVsphereJob. */ - public function create(OrderItem $item, VmInfrastructureContext $infra, string $vmId): ModuleVmware + public function create(OrderItem $item, VmInfrastructureContext $infra, string $vmId, array $specs = []): ModuleVmware { $customer = $item->order->customer; $serverName = $item->server_name ?? ('VM-' . $vmId); @@ -43,6 +43,9 @@ class VmModuleService 'os_id' => $item->os_id ?? 1, 'customer_id' => $customer->id, 'status' => 1, + 'cpu' => $specs['cpu'] ?? null, + 'ram_mb' => $specs['ram_mb'] ?? null, + 'disk_gb' => $specs['disk_gb'] ?? null, ]); } } diff --git a/app/Services/VirtualMachine/VmProvisionRefund.php b/app/Services/VirtualMachine/VmProvisionRefund.php new file mode 100644 index 0000000..7ca696a --- /dev/null +++ b/app/Services/VirtualMachine/VmProvisionRefund.php @@ -0,0 +1,41 @@ +order; + $invoice = $order?->invoice; + + if (!$invoice || (int) $invoice->status !== InvoiceStatus::PAID->value || $order->orderItems()->count() !== 1) { + return false; + } + + try { + $this->payments->refund($invoice, null, null, 'Tự động hoàn tiền do khởi tạo máy ảo thất bại: ' . $reason); + + $order->update(['status' => OrderStatus::CANCELLED->value]); + + return true; + } catch (Throwable $e) { + Log::error('VmProvisionRefund failed', ['order_item_id' => $item->id, 'error' => $e->getMessage()]); + + return false; + } + } +} diff --git a/app/Services/VirtualMachine/VmVcenterErrorTranslator.php b/app/Services/VirtualMachine/VmVcenterErrorTranslator.php index 73915a0..d057877 100644 --- a/app/Services/VirtualMachine/VmVcenterErrorTranslator.php +++ b/app/Services/VirtualMachine/VmVcenterErrorTranslator.php @@ -14,6 +14,18 @@ class VmVcenterErrorTranslator 'TIMED_OUT' => 'Yêu cầu khởi tạo máy chủ hết thời gian chờ. Vui lòng thử lại sau.', ]; + public function detail(array $result): string + { + $data = $result['data'] ?? []; + $messages = is_array($data) ? collect($data['messages'] ?? [])->pluck('default_message')->filter() : collect(); + + if ($messages->isNotEmpty()) { + return ($data['error_type'] ?? 'vCenter') . ': ' . $messages->implode(' | '); + } + + return is_string($data) ? $data : (string) json_encode($data, JSON_UNESCAPED_UNICODE); + } + public function translate(array $result, ?string $serverName = null): string { $data = $result['data'] ?? []; diff --git a/app/Support/QueueJobInspector.php b/app/Support/QueueJobInspector.php new file mode 100644 index 0000000..34a21a9 --- /dev/null +++ b/app/Support/QueueJobInspector.php @@ -0,0 +1,66 @@ +payload ?? '', true) ?: []; + $command = $payload['data']['command'] ?? null; + unset($payload['data']['command']); + + return [ + 'id' => $job->id, + 'name' => $payload['displayName'] ?? $job->name ?? null, + 'connection' => $job->connection ?? null, + 'queue' => $job->queue ?? null, + 'status' => $job->status ?? null, + 'attempts' => $payload['attempts'] ?? 0, + 'max_tries' => $payload['maxTries'] ?? null, + 'timeout' => $payload['timeout'] ?? null, + 'tags' => $payload['tags'] ?? [], + 'pushed_at' => self::iso($payload['pushedAt'] ?? null), + 'reserved_at' => self::iso($job->reserved_at ?? null), + 'completed_at' => self::iso($job->completed_at ?? null), + 'failed_at' => self::iso($job->failed_at ?? null), + 'retried_by' => json_decode($job->retried_by ?? '[]', true) ?: [], + 'properties' => is_string($command) ? self::properties($command) : [], + 'exception' => $job->exception ?? null, + 'context' => json_decode($job->context ?? 'null', true), + 'payload' => $payload, + ]; + } + + private static function properties(string $serialized): array + { + preg_match_all( + '/s:\d+:"(?:\x00[^\x00]*\x00)?(\w+)";(?:i:(-?\d+)|d:([-\d.eE+]+)|b:([01])|s:\d+:"(.*?)"|N);/s', + $serialized, + $matches, + PREG_SET_ORDER + ); + + $properties = []; + foreach ($matches as $m) { + $name = $m[1]; + $value = match (true) { + ($m[2] ?? '') !== '' => (int) $m[2], + ($m[3] ?? '') !== '' => (float) $m[3], + ($m[4] ?? '') !== '' => $m[4] === '1', + array_key_exists(5, $m) && $m[5] !== '' => $m[5], + default => null, + }; + $properties[$name] = preg_match(self::SENSITIVE, $name) && $value !== null ? '********' : $value; + } + + return $properties; + } + + private static function iso(int|float|string|null $ts): ?string + { + return $ts ? \Carbon\Carbon::createFromTimestamp((int) $ts)->toIso8601String() : null; + } +} diff --git a/database/migrations/2026_10_30_000001_add_service_uuid_to_ticket_table.php b/database/migrations/2026_10_30_000001_add_service_uuid_to_ticket_table.php new file mode 100644 index 0000000..5ca90e4 --- /dev/null +++ b/database/migrations/2026_10_30_000001_add_service_uuid_to_ticket_table.php @@ -0,0 +1,22 @@ +uuid('service_uuid')->nullable()->index()->after('customer_id'); + }); + } + + public function down(): void + { + Schema::table('ticket', function (Blueprint $table) { + $table->dropColumn('service_uuid'); + }); + } +}; diff --git a/database/migrations/2026_10_30_000002_normalize_ram_service_addons_to_mb.php b/database/migrations/2026_10_30_000002_normalize_ram_service_addons_to_mb.php new file mode 100644 index 0000000..51e8ad8 --- /dev/null +++ b/database/migrations/2026_10_30_000002_normalize_ram_service_addons_to_mb.php @@ -0,0 +1,29 @@ +where('addon_type', 'ram') + ->whereRaw('CAST(addon_value AS UNSIGNED) < 256') + ->update(['addon_value' => DB::raw('CAST(addon_value AS UNSIGNED) * 1024')]); + + DB::table('module_vmware as m') + ->join('services as s', 's.vm_id', '=', 'm.id') + ->join('products as p', 'p.id', '=', 's.product_id') + ->whereNull('m.ram_mb') + ->update([ + 'm.cpu' => DB::raw('p.cpu'), + 'm.ram_mb' => DB::raw('p.ram * 1024'), + 'm.disk_gb' => DB::raw('p.disk'), + ]); + } + + public function down(): void + { + } +}; diff --git a/database/migrations/2026_10_30_000003_add_timing_to_activity_logs.php b/database/migrations/2026_10_30_000003_add_timing_to_activity_logs.php new file mode 100644 index 0000000..30e10f2 --- /dev/null +++ b/database/migrations/2026_10_30_000003_add_timing_to_activity_logs.php @@ -0,0 +1,23 @@ +timestamp('started_at', 3)->nullable()->after('user_agent'); + $table->timestamp('finished_at', 3)->nullable()->after('started_at'); + }); + } + + public function down(): void + { + Schema::table('activity_logs', function (Blueprint $table) { + $table->dropColumn(['started_at', 'finished_at']); + }); + } +}; diff --git a/database/migrations/2026_10_30_000004_add_deleting_status_to_module_snapshot.php b/database/migrations/2026_10_30_000004_add_deleting_status_to_module_snapshot.php new file mode 100644 index 0000000..07daff4 --- /dev/null +++ b/database/migrations/2026_10_30_000004_add_deleting_status_to_module_snapshot.php @@ -0,0 +1,18 @@ +where('status', 'deleting')->update(['status' => 'success']); + DB::statement("ALTER TABLE module_snapshot MODIFY status ENUM('pending','processing','success','failed') NOT NULL DEFAULT 'pending'"); + } +}; diff --git a/routes/api/admin/system.php b/routes/api/admin/system.php index b359647..0c8008f 100644 --- a/routes/api/admin/system.php +++ b/routes/api/admin/system.php @@ -107,6 +107,7 @@ Route::prefix('queue') Route::get('jobs/failed', 'failed'); Route::post('jobs/failed/{id}/retry', 'retry'); Route::get('jobs/metrics', 'metrics'); + Route::get('jobs/{id}', 'show'); }); // Email Templates (module: settings) diff --git a/routes/client.php b/routes/client.php index ce3ddf5..639df3a 100644 --- a/routes/client.php +++ b/routes/client.php @@ -338,6 +338,7 @@ Route::prefix('v1/system') Route::post('vm-change-password', 'changePassword')->name('vm.change-password'); Route::get('get-ticket-console', 'consoleTicket')->name('vm.console-ticket'); Route::post('vm-restart', 'restart')->name('vm.restart'); + Route::post('vm-action-click', 'actionClick')->name('vm.action-click'); }); // VM Snapshot operations