feat(vm): hoàn tiền khi cấp VM lỗi, theo dõi job queue, uuid dịch vụ cho ticket, chuẩn hoá RAM addon
- VmProvisionException/VmProvisionRefund xử lý lỗi cấp phát VM - QueueJobInspector + QueueMonitorController xem chi tiết job - Thêm service uuid cho ticket, thời lượng cho activity_logs - Migration chuẩn hoá RAM addon sang MB, trạng thái deleting cho snapshot Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
7136bec265
commit
62596d9063
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class VmProvisionException extends RuntimeException
|
||||
{
|
||||
public function __construct(string $customerMessage, public readonly string $adminDetail)
|
||||
{
|
||||
parent::__construct($customerMessage);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Http\Resources\Api\Client\Ticket\ClientTicketResource;
|
||||
use App\Jobs\Ticket\TicketJobs;
|
||||
use App\Mail\TicketCreated;
|
||||
use App\Mail\Admin\TicketAdminNotification;
|
||||
use App\Models\Service;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\Notification;
|
||||
@@ -69,6 +70,15 @@ class TicketController extends Controller
|
||||
/**
|
||||
* Create a new ticket for the authenticated customer.
|
||||
*/
|
||||
private function assertServiceTicketable(string $serviceUuid, int $customerId): void
|
||||
{
|
||||
$failed = Service::where('uuid', $serviceUuid)->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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -142,7 +142,7 @@ $ErrorActionPreference = 'Stop'
|
||||
|
||||
Import-Module VMware.PowerCLI -ErrorAction Stop
|
||||
<CERT>
|
||||
Connect-VIServer -Server '<SERVER>' -User '<USER>' -Password '<PASS>' | Out-Null
|
||||
Connect-VIServer -Server <SERVER> -User <USER> -Password <PASS> | Out-Null
|
||||
|
||||
& {
|
||||
<CMD>
|
||||
@@ -190,7 +190,7 @@ $ErrorActionPreference = 'Stop'
|
||||
|
||||
Import-Module VMware.PowerCLI -ErrorAction Stop
|
||||
<CERT>
|
||||
Connect-VIServer -Server '<SERVER>' -User '<USER>' -Password '<PASS>' | Out-Null
|
||||
Connect-VIServer -Server <SERVER> -User <USER> -Password <PASS> | Out-Null
|
||||
|
||||
& {
|
||||
<CMD>
|
||||
|
||||
@@ -106,7 +106,7 @@ $ErrorActionPreference = 'Stop'
|
||||
|
||||
Import-Module VMware.PowerCLI -ErrorAction Stop
|
||||
<CERT>
|
||||
Connect-VIServer -Server '<SERVER>' -User '<USER>' -Password '<PASS>' | Out-Null
|
||||
Connect-VIServer -Server <SERVER> -User <USER> -Password <PASS> | Out-Null
|
||||
|
||||
& {
|
||||
<CMD>
|
||||
@@ -154,7 +154,7 @@ $ErrorActionPreference = 'Stop'
|
||||
|
||||
Import-Module VMware.PowerCLI -ErrorAction Stop
|
||||
<CERT>
|
||||
Connect-VIServer -Server '<SERVER>' -User '<USER>' -Password '<PASS>' | Out-Null
|
||||
Connect-VIServer -Server <SERVER> -User <USER> -Password <PASS> | Out-Null
|
||||
|
||||
& {
|
||||
<CMD>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ class Ticket extends Model
|
||||
'ticket_num',
|
||||
'subject',
|
||||
'customer_id',
|
||||
'service_uuid',
|
||||
'description',
|
||||
'status',
|
||||
'room',
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\VirtualMachine;
|
||||
|
||||
use App\Enums\Invoice\InvoiceStatus;
|
||||
use App\Enums\Order\OrderStatus;
|
||||
use App\Models\OrderItem;
|
||||
use App\Services\InvoicePaymentService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class VmProvisionRefund
|
||||
{
|
||||
public function __construct(private readonly InvoicePaymentService $payments)
|
||||
{
|
||||
}
|
||||
|
||||
public function refund(OrderItem $item, string $reason): bool
|
||||
{
|
||||
$order = $item->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? [];
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
class QueueJobInspector
|
||||
{
|
||||
private const SENSITIVE = '/pass|secret|token|key|otp|code/i';
|
||||
|
||||
public static function describe(object $job): array
|
||||
{
|
||||
$payload = json_decode($job->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('ticket', function (Blueprint $table) {
|
||||
$table->uuid('service_uuid')->nullable()->index()->after('customer_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('ticket', function (Blueprint $table) {
|
||||
$table->dropColumn('service_uuid');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('service_addons')
|
||||
->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
|
||||
{
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('activity_logs', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement("ALTER TABLE module_snapshot MODIFY status ENUM('pending','processing','success','failed','deleting') NOT NULL DEFAULT 'pending'");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('module_snapshot')->where('status', 'deleting')->update(['status' => 'success']);
|
||||
DB::statement("ALTER TABLE module_snapshot MODIFY status ENUM('pending','processing','success','failed') NOT NULL DEFAULT 'pending'");
|
||||
}
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user