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:
baodev0
2026-10-30 11:54:05 +07:00
co-authored by Claude Sonnet 5.5
parent 7136bec265
commit 62596d9063
32 changed files with 514 additions and 107 deletions
+13
View File
@@ -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>
+36 -67
View File
@@ -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,
+5 -1
View File
@@ -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();
});
}
}
+1
View File
@@ -38,6 +38,7 @@ class Ticket extends Model
'ticket_num',
'subject',
'customer_id',
'service_uuid',
'description',
'status',
'room',
+6
View File
@@ -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', [
+20
View File
@@ -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),
]);
}
+2
View File
@@ -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;
+37 -10
View File
@@ -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'] ?? [];
+66
View File
@@ -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'");
}
};
+1
View File
@@ -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)
+1
View File
@@ -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