feat: implement VM snapshot management, enhance client resource loading, and refactor deployment workflow for production

This commit is contained in:
pbao9
2026-05-11 11:09:55 +07:00
parent 8aef607e1f
commit 96778e7bed
51 changed files with 2536 additions and 1249 deletions
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
@@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers\Api\Admin\Invoice;
use App\Http\Controllers\Controller;
use App\Jobs\Mail\SendInformationVirtualMachine;
use App\Jobs\VirtualMachine\Provision\SendVmCredentialsEmailJob;
use App\Models\Invoice;
use App\Models\ModuleVmware;
use App\Models\Setting;
use App\Services\InvoicePdfService;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class InvoiceTestController extends Controller
{
public function sendInvoiceEmail(Request $request, string $invoiceNumber): JsonResponse
{
$invoice = Invoice::with(['invoiceItems.product', 'customer', 'order.orderItems'])
->where('invoice_number', $invoiceNumber)
->first();
if (!$invoice) {
return response()->json([
'success' => false,
'message' => 'Không tìm thấy hóa đơn.',
], 404);
}
$customer = $invoice->customer;
if (!$customer?->email) {
return response()->json([
'success' => false,
'message' => 'Khách hàng không có email.',
], 400);
}
try {
$pdfService = app(InvoicePdfService::class);
$invoicePdf = $pdfService->generate($invoice);
$invoicePdfFileName = $pdfService->generateFileName($invoice);
} catch (\Throwable $e) {
return response()->json([
'success' => false,
'message' => 'Lỗi tạo PDF: ' . $e->getMessage(),
], 500);
}
$vm = ModuleVmware::with(['customer', 'os.osGroup', 'order.orderItems'])
->where('order_id', $invoice->order_id)
->first();
if ($vm) {
SendVmCredentialsEmailJob::dispatch($vm->id);
return response()->json([
'success' => true,
'message' => 'Đã dispatch SendVmCredentialsEmailJob (có PDF hóa đơn đính kèm).',
'data' => [
'invoice_id' => $invoice->id,
'invoice_number' => $invoice->invoice_number,
'customer_email' => $customer->email,
'vm_id' => $vm->id,
'pdf_file_name' => $invoicePdfFileName,
],
]);
}
$setting = Setting::where('key', 'setting')->first();
$settingValue = $setting ? json_decode((string) $setting->value) : null;
$emailData = [
'company_name' => $settingValue->company ?? 'Thế Giới Số',
'company_logo' => $settingValue->logo ?? '',
'client_name' => trim($customer->firstname . ' ' . $customer->lastname),
'product_name' => $invoice->invoiceItems->first()?->product?->name ?? 'Dịch vụ',
'product_billing' => 'N/A',
'product_price' => number_format((float) $invoice->total, 0, ',', '.'),
'service_host' => 'N/A',
'service_username' => 'N/A',
'service_port' => 'N/A',
'service_password' => 'N/A',
'os_name' => 'N/A',
'service_due_exp' => $invoice->due_date ? $invoice->due_date->format('d/m/Y') : 'N/A',
];
SendInformationVirtualMachine::dispatch($emailData, $customer, $invoicePdf, $invoicePdfFileName);
return response()->json([
'success' => true,
'message' => 'Đã gửi email hóa đơn (không có VM liên kết, sử dụng thông tin hóa đơn).',
'data' => [
'invoice_id' => $invoice->id,
'invoice_number' => $invoice->invoice_number,
'customer_email' => $customer->email,
'pdf_file_name' => $invoicePdfFileName,
],
]);
}
public function previewInvoicePdf(Request $request, string $invoiceNumber)
{
$invoice = Invoice::with(['invoiceItems.product', 'customer', 'order.orderItems'])
->where('invoice_number', $invoiceNumber)
->first();
if (!$invoice) {
return response()->json([
'success' => false,
'message' => 'Không tìm thấy hóa đơn.',
], 404);
}
$setting = Setting::where('key', 'setting')->first();
$settingValue = $setting ? json_decode((string) $setting->value) : null;
return Pdf::loadView('pdf.invoice', [
'invoice' => $invoice,
'invoiceItems' => $invoice->invoiceItems,
'customer' => $invoice->customer,
'order' => $invoice->order,
'companyName' => $settingValue->company ?? 'Thế Giới Số',
'companyAddress' => $settingValue->address ?? '',
'companyPhone' => $settingValue->phone ?? '',
'companyEmail' => $settingValue->email ?? '',
])->stream($invoice->invoice_number . '.pdf');
}
}
@@ -87,7 +87,7 @@ class ClientController extends Controller
return response()->json([
'error' => false,
'data' => [
'client' => new ClientResource(auth('client')->user()),
'client' => new ClientResource(auth('client')->user()->loadCount(['vps', 'hosting', 'domain'])),
],
]);
}
@@ -133,7 +133,7 @@ class ClientController extends Controller
return response()->json([
'error' => false,
'data' => [
'client' => new ClientResource($result['client']),
'client' => new ClientResource($result['client']->loadCount(['vps', 'hosting', 'domain'])),
'token' => $result['token'],
'refresh_token' => $result['refresh_token'],
'expires_in' => config('jwt.ttl', 60) * 60, // seconds
@@ -185,7 +185,7 @@ class ClientController extends Controller
return response()->json([
'error' => false,
'data' => [
'client' => new ClientResource($result['client']),
'client' => new ClientResource($result['client']->loadCount(['vps', 'hosting', 'domain'])),
'token' => $result['token'],
'refresh_token' => $result['refresh_token'],
'expires_in' => config('jwt.ttl', 60) * 60,
@@ -282,7 +282,7 @@ class ClientController extends Controller
'token' => $result['token'],
'refresh_token' => $result['refresh_token'],
'expires_in' => config('jwt.ttl', 60) * 60,
'client' => new ClientResource($result['client']),
'client' => new ClientResource($result['client']->loadCount(['vps', 'hosting', 'domain'])),
],
]);
} catch (\Illuminate\Validation\ValidationException $e) {
@@ -304,7 +304,7 @@ class ClientController extends Controller
'error' => false,
'message' => 'Cập nhật thông tin thành công',
'data' => [
'client' => new ClientResource($user),
'client' => new ClientResource($user->loadCount(['vps', 'hosting', 'domain'])),
],
]);
}
@@ -6,9 +6,11 @@ use App\Http\Controllers\Controller;
use App\Http\Resources\Api\Client\Service\ServiceClientDetailResource;
use \App\Http\Resources\Api\Client\Service\ServiceClientResource;
use App\Http\Services\vCenter;
use App\Models\Order;
use App\Models\Service;
use App\Repositories\Api\ServiceClient\ServiceClientRepository;
use App\Services\Api\ServiceClient\ServiceClientService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ServiceController extends Controller
@@ -103,4 +105,61 @@ class ServiceController extends Controller
]);
}
public function getTicketByOrderNum(Request $request, vCenter $serviceVCenter): JsonResponse
{
$orderNum = (string) $request->query('order_num', '');
if ($orderNum === '') {
return response()->json(['error' => true, 'message' => 'Thiếu order_num'], 422);
}
$customer = $request->user();
$order = Order::with(['vmWare.vCenter'])
->where('customer_id', $customer->id)
->where('order_num', $orderNum)
->first();
if (!$order) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy đơn hàng'], 404);
}
$vm = $order->vmWare->first();
if (!$vm) {
return response()->json(['error' => true, 'message' => 'Đơn hàng chưa có VM'], 404);
}
$vcenter = $vm->vCenter;
if (!$vcenter) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy thông tin vCenter'], 404);
}
if (!$serviceVCenter->loginServer($vcenter)) {
return response()->json(['error' => true, 'message' => 'Không thể đăng nhập vào vCenter']);
}
$consoleTicket = $serviceVCenter->GetConsoleTickets($vm->vmware_id);
if ($consoleTicket === 'Login_Again') {
if ($serviceVCenter->loginServer($vcenter)) {
$consoleTicket = $serviceVCenter->GetConsoleTickets($vm->vmware_id);
} else {
return response()->json(['error' => true, 'message' => 'Không thể đăng nhập lại vào vCenter']);
}
}
if (is_string($consoleTicket)) {
return response()->json(['error' => true, 'message' => "Failed to get console ticket: $consoleTicket"]);
}
if ($consoleTicket['error']) {
return response()->json(['error' => true, 'message' => $consoleTicket['data']]);
}
return response()->json([
'error' => false,
'data' => $consoleTicket['data'],
'order_num' => $order->order_num,
'vm_id' => $vm->id,
]);
}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Api\Service\Concerns;
use App\Models\ModuleVmware;
use App\Models\Service;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
trait ResolvesVm
{
protected function resolveVmById(int|string $productId, Request $request): ModuleVmware|JsonResponse
{
$customer = $request->user();
$vm = null;
if (is_string($productId) && preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $productId)) {
$service = Service::with('vmware.ipAddress.host.datacenter.vCenter', 'vmware.vCenter')
->where('uuid', $productId)
->where('customer_id', $customer->id)
->first();
if ($service && $service->vmware) {
$vm = $service->vmware;
}
} elseif (is_numeric($productId)) {
$vm = ModuleVmware::with(['vCenter', 'ipAddress.host.datacenter.vCenter'])
->find((int) $productId);
} else {
$vm = ModuleVmware::with(['vCenter', 'ipAddress.host.datacenter.vCenter'])
->where('vmware_id', $productId)
->where('customer_id', $customer->id)
->first();
}
if (!$vm) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404);
}
if ((int) $vm->customer_id !== (int) $customer->id) {
return response()->json(['error' => true, 'message' => 'Bạn không có quyền thao tác VM này'], 403);
}
return $vm;
}
protected function getConsoleTicket(\App\Http\Services\vCenter $serviceVCenter, ModuleVmware $vm): array|JsonResponse
{
$vcenter = $vm->vCenter;
if (!$vcenter) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy thông tin vCenter'], 404);
}
if (!$serviceVCenter->loginServer($vcenter)) {
return response()->json(['error' => true, 'message' => 'Không thể đăng nhập vào vCenter']);
}
$consoleTicket = $serviceVCenter->GetConsoleTickets($vm->vmware_id);
if ($consoleTicket === 'Login_Again') {
if ($serviceVCenter->loginServer($vcenter)) {
$consoleTicket = $serviceVCenter->GetConsoleTickets($vm->vmware_id);
} else {
return response()->json(['error' => true, 'message' => 'Không thể đăng nhập lại vào vCenter']);
}
}
if (is_string($consoleTicket)) {
return response()->json(['error' => true, 'message' => "Failed to get console ticket: $consoleTicket"]);
}
if ($consoleTicket['error']) {
return response()->json(['error' => true, 'message' => $consoleTicket['data']]);
}
return $consoleTicket;
}
}
@@ -7,12 +7,14 @@ use App\Enums\Order\OrderStatus;
use App\Helpers\OrderHelper;
use App\Http\Controllers\Controller;
use App\Jobs\VirtualMachine\Action\VMJobs;
use App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob;
use App\Jobs\VirtualMachine\Provision\RetryProvisioningOrchestrator;
use App\Models\Log as CustomerActivityLog;
use App\Http\Requests\Api\Order\PayOrderRequest;
use App\Http\Requests\Api\Order\StoreOrderRequest;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use App\Models\ModuleVmware;
use App\Models\Order;
use App\Models\Product;
use App\Models\Service;
@@ -194,6 +196,41 @@ class OrderController extends Controller
}
}
/**
* POST /v1/client/orders/{id}/retry — retry VM provisioning for a paid order
*
* Dispatches RetryProvisioningOrchestrator which:
* 1. Searches vSphere for existing VM by server_name
* 2. If found → links VM to module_vmware + services
* 3. If not found → dispatches CreateVirtualMachineJob
* 4. Each failure step notifies Telegram
*/
public function retryOrderProvisioning(Request $request, int $id): JsonResponse
{
try {
$order = Order::where('id', $id)
->where('customer_id', $request->user()->id)
->first();
if (!$order) {
return $this->notFound('Đơn hàng không tồn tại.');
}
if (!$order->invoice || (int) $order->invoice->status !== InvoiceStatus::PAID->value) {
return $this->error('Đơn hàng chưa được thanh toán.');
}
RetryProvisioningOrchestrator::dispatch($order->id, $request->user()->id);
return response()->json([
'success' => true,
'message' => 'Đã yêu cầu khởi tạo lại dịch vụ. Hệ thống sẽ kiểm tra và xử lý trong vài phút.',
]);
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
// =========================================================================
// Invoices
// =========================================================================
@@ -305,15 +342,13 @@ class OrderController extends Controller
{
try {
$services = Service::query()
->with(['product', 'addons', 'invoice', 'orderItem'])
->with(['product', 'addons', 'invoice', 'orderItem', 'vmware.ipAddress', 'vmware.os'])
->where('customer_id', $request->user()->id)
->orderByDesc('created_at')
->get();
$data = $services->map(function (Service $service) {
$vmware = $service->vm_id
? ModuleVmware::with(['ipAddress', 'os'])->find((int) $service->vm_id)
: null;
$vmware = $service->vmware;
return [
'id' => $service->id,
@@ -357,7 +392,7 @@ class OrderController extends Controller
{
try {
$service = Service::query()
->with(['product', 'addons', 'invoice', 'orderItem'])
->with(['product', 'addons', 'invoice', 'orderItem', 'vmware.ipAddress', 'vmware.os', 'vmware.vCenter'])
->where('uuid', $uuid)
->where('customer_id', $request->user()->id)
->first();
@@ -366,9 +401,7 @@ class OrderController extends Controller
return $this->notFound('Không tìm thấy dịch vụ.');
}
$vmware = $service->vm_id
? ModuleVmware::with(['ipAddress', 'os', 'vCenter'])->find((int) $service->vm_id)
: null;
$vmware = $service->vmware;
$data = [
'id' => $service->id,
@@ -0,0 +1,197 @@
<?php
namespace App\Http\Controllers\Api\Service;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Api\Service\Concerns\ResolvesVm;
use App\Http\Services\vCenter;
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 Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class SnapshotController extends Controller
{
use ResolvesVm;
public function store(Request $request): JsonResponse
{
$customer = $request->user();
$payload = $request->validate([
'product' => ['required'],
'description' => ['nullable', 'string', 'max:1000'],
]);
$vm = $this->resolveVmById($payload['product'], $request);
if ($vm instanceof JsonResponse) {
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);
$snapshot = ModuleSnapshot::create([
'name' => $snapshotName,
'description' => $payload['description'] ?? null,
'customer_id' => $customer->id,
'module_vmware_id' => $vm->id,
'status' => 'pending',
'snapshot_ref' => null,
]);
CreateSnapshot::dispatch($snapshot->id);
return response()->json([
'error' => false,
'message' => 'Đã gửi yêu cầu tạo snapshot vào hàng đợi',
'data' => [
'id' => $snapshot->id,
'name' => $snapshot->name,
'description' => $snapshot->description,
'status' => 'pending',
],
]);
}
public function index(Request $request, vCenter $serviceVCenter): JsonResponse
{
$productParam = $request->query('product');
if (!$productParam) {
return response()->json(['error' => true, 'message' => 'Thiếu product id'], 422);
}
$vm = $this->resolveVmById($productParam, $request);
if ($vm instanceof JsonResponse) {
return $vm;
}
$remote = $serviceVCenter->getSnapShot($vm);
if (is_string($remote) || ($remote['error'] ?? true)) {
return response()->json([
'error' => true,
'message' => is_string($remote) ? $remote : ($remote['message'] ?? 'Không thể lấy danh sách snapshot'),
], 400);
}
$remoteItems = $remote['data'] ?? [];
foreach ($remoteItems as $item) {
$ref = (string) ($item['id'] ?? $item['name'] ?? '');
$name = (string) ($item['name'] ?? $ref);
if ($name === '') {
continue;
}
$snapshot = ModuleSnapshot::query()
->where('module_vmware_id', $vm->id)
->where(function ($q) use ($ref, $name) {
$q->where('snapshot_ref', $ref)->orWhere('name', $name);
})
->first();
$payload = [
'name' => $name,
'description' => $item['description'] ?? null,
'customer_id' => $request->user()->id,
'module_vmware_id' => $vm->id,
'status' => 'success',
'snapshot_ref' => $ref ?: $name,
'size_gb' => isset($item['size_gb']) ? (float) $item['size_gb'] : null,
'size_last_synced_at' => now(),
];
if ($snapshot) {
$snapshot->update($payload);
} else {
ModuleSnapshot::create($payload);
}
}
$dbSnapshots = ModuleSnapshot::query()
->where('module_vmware_id', $vm->id)
->where('customer_id', $request->user()->id)
->orderByDesc('created_at')
->get()
->map(function ($snap) {
return [
'id' => $snap->id,
'name' => $snap->name,
'description' => $snap->description,
'snapshot_ref' => $snap->snapshot_ref,
'status' => $snap->status,
'size_gb' => $snap->size_gb,
'created_at' => $snap->created_at?->toISOString(),
];
});
return response()->json([
'error' => false,
'data' => $dbSnapshots,
]);
}
public function revert(Request $request): JsonResponse
{
$customer = $request->user();
$payload = $request->validate([
'product' => ['required'],
'snapshot_id' => ['required', 'numeric'],
]);
$vm = $this->resolveVmById($payload['product'], $request);
if ($vm instanceof JsonResponse) {
return $vm;
}
$snapshot = ModuleSnapshot::where('id', $payload['snapshot_id'])
->where('module_vmware_id', $vm->id)
->where('customer_id', $customer->id)
->first();
if (!$snapshot) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy snapshot'], 404);
}
RevertSnapshot::dispatch($vm->id, $snapshot->id);
return response()->json([
'error' => false,
'message' => 'Đã gửi yêu cầu revert snapshot vào hàng đợi',
]);
}
public function destroy(Request $request): JsonResponse
{
$customer = $request->user();
$payload = $request->validate([
'product' => ['required'],
'snapshot_id' => ['required', 'numeric'],
]);
$vm = $this->resolveVmById($payload['product'], $request);
if ($vm instanceof JsonResponse) {
return $vm;
}
$snapshot = ModuleSnapshot::where('id', $payload['snapshot_id'])
->where('module_vmware_id', $vm->id)
->where('customer_id', $customer->id)
->first();
if (!$snapshot) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy snapshot'], 404);
}
RemoveSnapshot::dispatch($vm->id, $snapshot->id);
return response()->json([
'error' => false,
'message' => 'Đã gửi yêu cầu xóa snapshot vào hàng đợi',
]);
}
}
@@ -6,8 +6,10 @@ use App\Actions\VM\ChangeIpVMAction;
use App\Actions\VM\ChangePortVMAction;
use App\Actions\VM\PowerOffVMAction;
use App\Actions\VM\PowerOnVMAction;
use App\Http\Controllers\Api\Service\Concerns\ResolvesVm;
use App\Http\Controllers\Controller;
use App\Http\Services\Telegram;
use App\Http\Services\vCenter;
use App\Models\ModuleVmware;
use App\Models\Service;
use Illuminate\Http\JsonResponse;
@@ -16,6 +18,9 @@ use Illuminate\Support\Facades\Log;
class VmController extends Controller
{
use ResolvesVm {
ResolvesVm::resolveVmById as resolveVmForClient;
}
// -------------------------------------------------------------------------
// Power ON
// -------------------------------------------------------------------------
@@ -240,6 +245,65 @@ class VmController extends Controller
], 400);
}
// -------------------------------------------------------------------------
// Console Ticket
// -------------------------------------------------------------------------
public function consoleTicket(Request $request, vCenter $serviceVCenter): JsonResponse
{
$productId = $request->get('product');
if (!$productId) {
return response()->json(['error' => true, 'message' => 'Please choose product']);
}
$vm = ModuleVmware::with('vCenter')->find((int) $productId);
if (!$vm) {
return response()->json(['error' => true, 'message' => "Can't find product"]);
}
$result = $this->getConsoleTicket($serviceVCenter, $vm);
if ($result instanceof JsonResponse) {
return $result;
}
return response()->json([
'error' => false,
'data' => $result['data'],
]);
}
// -------------------------------------------------------------------------
// Restart VM
// -------------------------------------------------------------------------
public function restart(Request $request, vCenter $serviceVCenter): JsonResponse
{
$productId = $request->input('product');
if (!$productId) {
return response()->json(['error' => true, 'message' => 'Thiếu product id'], 422);
}
$vm = $this->resolveVmForClient($productId, $request);
if ($vm instanceof JsonResponse) {
return $vm;
}
$result = $serviceVCenter->restartVM($vm, $vm->vmware_id);
if (is_string($result) || ($result['error'] ?? true)) {
return response()->json([
'error' => true,
'message' => is_string($result) ? $result : ($result['data'] ?? 'Không thể restart VM'),
], 400);
}
return response()->json([
'error' => false,
'message' => 'Đã gửi lệnh restart VM',
'data' => $result['data'] ?? 'success',
]);
}
// -------------------------------------------------------------------------
// Shared helpers
// -------------------------------------------------------------------------
@@ -0,0 +1,219 @@
<?php
namespace App\Http\Controllers\Api\Service;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Api\Service\Concerns\ResolvesVm;
use App\Http\Services\vCenter;
use App\Models\ModuleVmware;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class VmRebuildController extends Controller
{
use ResolvesVm;
public function options(Request $request): JsonResponse
{
$productId = (int) $request->query('product');
if (!$productId) {
return response()->json(['error' => true, 'message' => 'Thiếu product id'], 422);
}
$vm = ModuleVmware::with(['order.orderItems.product.osVersions.osGroup'])
->find($productId);
if (!$vm) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404);
}
if ((int) $vm->customer_id !== (int) $request->user()->id) {
return response()->json(['error' => true, 'message' => 'Bạn không có quyền thao tác VM này'], 403);
}
$product = optional($vm->order?->orderItems?->first())->product;
if (!$product) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy product của dịch vụ hiện tại'], 404);
}
$osOptions = $product->osVersions
->where('status', 1)
->map(fn($os) => [
'id' => $os->id,
'name' => $os->name,
'version' => $os->version,
'group' => $os->osGroup?->name,
])
->values();
return response()->json([
'error' => false,
'data' => [
'product_id' => $product->id,
'product_uuid' => $product->uuid,
'product_name' => $product->name,
'os_options' => $osOptions,
],
]);
}
public function rebuild(Request $request, vCenter $serviceVCenter): JsonResponse
{
$customer = $request->user();
$payload = $request->validate([
'product' => ['required', 'integer'],
'os_id' => ['required', 'integer', 'exists:os,id'],
]);
$vm = ModuleVmware::with(['order.orderItems.product', 'vCenter', 'ipAddress.host.datacenter.vCenter'])
->find($payload['product']);
if (!$vm) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404);
}
if ((int) $vm->customer_id !== (int) $customer->id) {
return response()->json(['error' => true, 'message' => 'Bạn không có quyền thao tác VM này'], 403);
}
$product = optional($vm->order?->orderItems?->first())->product;
if (!$product) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy product của dịch vụ hiện tại'], 404);
}
$allowedOs = $product->osVersions()->where('status', 1)->pluck('id')->toArray();
if (!in_array((int) $payload['os_id'], array_map('intval', $allowedOs), true)) {
return response()->json(['error' => true, 'message' => 'OS không hợp lệ cho product hiện tại'], 422);
}
DB::table('reinstall_logs')->insert([
'product_id' => $vm->id,
'product_type' => 4,
'custome_id' => $customer->id,
'os_id' => $payload['os_id'],
'status' => 'pending',
'initiator' => 'client',
'note' => 'Client request rebuild from portal-client',
'created_at' => now(),
'updated_at' => now(),
]);
$result = $serviceVCenter->reBuildVPS((string) $vm->name);
if (is_string($result) || ($result['error'] ?? false)) {
return response()->json([
'error' => true,
'message' => is_string($result) ? $result : ($result['message'] ?? 'Không thể rebuild VM'),
], 400);
}
return response()->json([
'error' => false,
'message' => 'Đã gửi yêu cầu rebuild VM',
'data' => $result,
]);
}
public function addonOptions(Request $request): JsonResponse
{
$productId = (int) $request->query('product');
if (!$productId) {
return response()->json(['error' => true, 'message' => 'Thiếu product id'], 422);
}
$vm = ModuleVmware::with(['order.orderItems.product.addons'])
->find($productId);
if (!$vm) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404);
}
if ((int) $vm->customer_id !== (int) $request->user()->id) {
return response()->json(['error' => true, 'message' => 'Bạn không có quyền thao tác VM này'], 403);
}
$product = optional($vm->order?->orderItems?->first())->product;
if (!$product) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy product của dịch vụ hiện tại'], 404);
}
$addons = $product->addons()->where('status', true)->get()->map(function ($addon) {
return [
'id' => $addon->id,
'code' => strtolower((string) $addon->code),
'name' => $addon->name,
'price' => (float) $addon->price,
'min' => (int) ($addon->min ?? 0),
'max' => (int) ($addon->max ?? 0),
'step' => (int) ($addon->step ?? 1),
];
});
return response()->json([
'error' => false,
'data' => [
'product_id' => $product->id,
'product_name' => $product->name,
'base_specs' => [
'cpu' => (int) ($product->cpu ?? 0),
'ram' => (int) ($product->ram ?? 0),
'disk' => (int) ($product->disk ?? 0),
'bandwidth' => (int) ($product->bandwidth ?? 0),
'gpu' => $product->gpu,
],
'addons' => $addons->values(),
],
]);
}
public function upgradeAddons(Request $request, vCenter $serviceVCenter): JsonResponse
{
$customer = $request->user();
$payload = $request->validate([
'product' => ['required', 'integer'],
'cpu' => ['nullable', 'integer', 'min:0'],
'ram' => ['nullable', 'integer', 'min:0'],
'disk' => ['nullable', 'integer', 'min:0'],
'bandwidth' => ['nullable', 'integer', 'min:0'],
'gpu' => ['nullable', 'string', 'max:255'],
]);
$vm = ModuleVmware::with(['vCenter', 'ipAddress.host.datacenter.vCenter'])
->find($payload['product']);
if (!$vm) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404);
}
if ((int) $vm->customer_id !== (int) $customer->id) {
return response()->json(['error' => true, 'message' => 'Bạn không có quyền thao tác VM này'], 403);
}
$vcenter = $vm->vCenter ?? $vm->ipAddress?->host?->datacenter?->vCenter;
if (!$vcenter || !$serviceVCenter->loginServer($vcenter)) {
return response()->json(['error' => true, 'message' => 'Không thể đăng nhập vCenter'], 400);
}
$applied = [];
if (isset($payload['cpu']) && $payload['cpu'] > 0) {
$res = $serviceVCenter->UpgradeCPUVirtualMachine($vm->vmware_id, (int) $payload['cpu']);
if (is_array($res) && !($res['error'] ?? true)) {
$applied[] = 'cpu';
}
}
if (isset($payload['ram']) && $payload['ram'] > 0) {
$res = $serviceVCenter->UpgradeMemoryVirtualMachine($vm->vmware_id, (int) $payload['ram']);
if (is_array($res) && !($res['error'] ?? true)) {
$applied[] = 'ram';
}
}
if (isset($payload['disk']) && $payload['disk'] > 0) {
$res = $serviceVCenter->UpgradeDiskVirtualMachine($vm, (int) $payload['disk']);
if (is_array($res) && !($res['error'] ?? true)) {
$applied[] = 'disk';
}
}
return response()->json([
'error' => false,
'message' => 'Đã gửi yêu cầu nâng cấp addons',
'data' => [
'applied' => $applied,
'note' => 'bandwidth/gpu hiện được ghi nhận yêu cầu, chưa có luồng tự động áp vào VM',
],
]);
}
}
+8 -9
View File
@@ -1,16 +1,13 @@
<?php
use App\Repositories\Setting\SettingCachedRepository;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
if (!function_exists('setting')) {
function setting(string $key = '')
{
// Cách gọi setting('setting') để lấy toàn bộ group json
$repo = app()->make(\App\Repositories\Setting\SettingCachedRepository::class);
// Nếu có dấu chấm => lấy theo group
if (Str::contains($key, '.')) {
[$group, $subKey] = explode('.', $key, 2);
$groupData = $repo->getByKey($group);
@@ -30,26 +27,28 @@ if (!function_exists('setting')) {
if (!function_exists('format_price')) {
function format_price($price, $suffix = '₫')
{
if ($price === null)
if ($price === null) {
return '';
}
return number_format($price, 0, ',', '.') . ' ' . $suffix;
}
}
if (!function_exists('format_point')) {
function format_point($point, $suffix = '𝑝')
{
if ($point === null)
if ($point === null) {
return '';
}
return number_format($point, 0, ',', '.') . ' ' . $suffix;
}
}
if (!function_exists('format_day')) {
function format_day($date, $format = 'd/m/Y')
{
if (empty($date))
if (empty($date)) {
return '';
}
try {
return \Carbon\Carbon::parse($date)->format($format);
} catch (\Exception $e) {
@@ -83,7 +82,7 @@ if (!function_exists('generate_order_num')) {
function generate_order_num(string $prefix = 'TGS', int $length = 6): string
{
do {
$randomNumber = random_int(10 ** ($length - 1), (10 ** $length) - 1);
$randomNumber = random_int(10 ** ($length - 1), 10 ** $length - 1);
$orderNum = $prefix . $randomNumber;
} while (\App\Models\Order::where('order_num', $orderNum)->exists());
@@ -104,4 +103,4 @@ function is_active_route($path)
function show_class($path)
{
return request()->is(...(array) $path) ? 'show' : '';
}
}
@@ -48,11 +48,11 @@ class ClientResource extends JsonResource
'point_total' => $this->point,
],
'service' => [
'vps' => $this->vps()->count(),
'vps' => $this->vps_count ?? $this->vps()->count(),
'proxy' => 0,
'server' => 0,
'hosting' => $this->hosting()->count(),
'domain' => $this->domain()->count(),
'hosting' => $this->hosting_count ?? $this->hosting()->count(),
'domain' => $this->domain_count ?? $this->domain()->count(),
]
];
}
+33
View File
@@ -103,6 +103,39 @@ class vCenter
return "Login_Again";
}
/**
* Search VM by exact name in vCenter.
* Returns the first matching VM array or null if not found.
*/
public function findVirtualMachineByName(string $vmName): ?array
{
$check = $this->checkApiExits();
if (!$check) {
Log::warning('findVirtualMachineByName: API check failed');
return null;
}
$response = Http::withoutVerifying()
->withHeaders([
'vmware-api-session-id' => $this->token
])
->get("https://$this->apiUri/rest/vcenter/vm", [
'filter.names.1' => $vmName,
]);
if (!$response->successful()) {
Log::warning('findVirtualMachineByName: HTTP failed', ['status' => $response->status()]);
return null;
}
$json = $response->json();
if (isset($json['type']) || empty($json['value'])) {
return null;
}
return $json['value'][0] ?? null;
}
public function checkApiExits(): bool
{
BIN
View File
Binary file not shown.
@@ -19,27 +19,23 @@ class SendInformationVirtualMachine implements ShouldQueue
public $data = [];
public $customer;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($data = [],$customer = [])
protected ?string $invoicePdf;
protected ?string $invoicePdfFileName;
public function __construct($data = [], $customer = [], ?string $invoicePdf = null, ?string $invoicePdfFileName = null)
{
$this->data = $data;
$this->customer = $customer;
$this->invoicePdf = $invoicePdf;
$this->invoicePdfFileName = $invoicePdfFileName;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
try {
$email = new CreateVirtualMachine(2,$this->data);
$email = new CreateVirtualMachine(2, $this->data, $this->invoicePdf, $this->invoicePdfFileName);
Mail::to($this->customer->email)->send($email);
@@ -58,7 +58,7 @@ class AssignIpToGuestJob implements ShouldQueue
// ─────────────────────────────────────────────────────────────────────────
public function handle(VsphereHostSshService $ssh): void
public function handle(VsphereHostSshService $ssh, vCenter $vCenterService): void
{
Log::info('AssignIpToGuestJob: start', [
'module_vmware_id' => $this->moduleVmwareId,
@@ -198,6 +198,38 @@ class AssignIpToGuestJob implements ShouldQueue
]);
ModuleVmware::where('id', $this->moduleVmwareId)->update(['status' => 0]);
// Still send credential email — try to get IP from vSphere guest identity
// so the customer at least gets server info even if IP assignment failed
try {
$moduleVmware = ModuleVmware::find($this->moduleVmwareId);
if ($moduleVmware && !$moduleVmware->ip) {
$vcenter = $moduleVmware->vCenter ?? vCenter::first();
if ($vcenter && $vCenterService->loginServer($vcenter)) {
$guestInfo = $vCenterService->GetIPAddressVirtualMachine($this->vmId);
if (isset($guestInfo['error']) && $guestInfo['error'] === false) {
$guestIp = $guestInfo['data']['ip_address'] ?? null;
if ($guestIp) {
$moduleVmware->update(['ip' => $guestIp, 'status' => 1]);
Log::info('AssignIpToGuestJob: recovered IP from guest identity', [
'module_vmware_id' => $this->moduleVmwareId,
'ip' => $guestIp,
]);
}
}
}
}
SendVmCredentialsEmailJob::dispatch($this->moduleVmwareId);
Log::info('AssignIpToGuestJob: dispatched SendVmCredentialsEmailJob after failed attempts', [
'module_vmware_id' => $this->moduleVmwareId,
]);
} catch (\Throwable $e) {
Log::error('AssignIpToGuestJob: failed to send email after exhausted attempts', [
'module_vmware_id' => $this->moduleVmwareId,
'error' => $e->getMessage(),
]);
}
}
public function failed(\Throwable $e): void
@@ -0,0 +1,359 @@
<?php
namespace App\Jobs\VirtualMachine\Provision;
use App\Http\Services\Telegram;
use App\Http\Services\vCenter;
use App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob;
use App\Models\Invoice;
use App\Models\ModuleVmware;
use App\Models\Os;
use App\Models\OrderItem;
use App\Models\Service;
use App\Models\vCenter as VcenterModel;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* RetryProvisioningOrchestrator
*
* Orchestrator Job — retries VM provisioning for a paid order.
*
* Flow:
* 1. Load order_items by order_id → get server_name
* 2. Resolve vcenter via DB chain (os → osGroup → host → datacenter → vcenter)
* 3. Login vCenter, search VM by server_name
* 4. If VM EXISTS in vSphere → link vm-id to module_vmware + services
* → dispatch AssignIpToGuestJob (if no IP) + SendVmCredentialsEmailJob
* 5. If VM NOT in vSphere → dispatch CreateVirtualMachineJob
* 6. Each failure step notifies Telegram with specific error info
*/
class RetryProvisioningOrchestrator implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = 300;
public function __construct(
public readonly int $orderId,
public readonly int $customerId,
) {}
public function handle(vCenter $vCenterService, Telegram $telegram): void
{
Log::info('RetryProvisioningOrchestrator: start', [
'order_id' => $this->orderId,
'customer_id' => $this->customerId,
]);
$orderItems = OrderItem::where('order_id', $this->orderId)->get();
if ($orderItems->isEmpty()) {
$this->failStep('order_items', 'Không tìm thấy order_items cho order #' . $this->orderId, $telegram);
return;
}
foreach ($orderItems as $item) {
$this->processItem($item, $vCenterService, $telegram);
}
}
private function processItem(OrderItem $item, vCenter $vCenterService, Telegram $telegram): void
{
$serverName = $item->server_name;
if (empty($serverName)) {
$this->failStep('order_item', "OrderItem #{$item->id} không có server_name", $telegram);
return;
}
// ── Already linked and has IP? Skip ─────────────────────────────
$existingService = Service::where('order_item_id', $item->id)->whereNull('deleted_at')->first();
if ($existingService && $existingService->vm_id) {
$vmware = ModuleVmware::find($existingService->vm_id);
if ($vmware && $vmware->ip) {
Log::info('RetryProvisioningOrchestrator: already linked with IP, skipping', [
'order_item_id' => $item->id,
'service_id' => $existingService->id,
'vm_id' => $existingService->vm_id,
'ip' => $vmware->ip,
]);
return;
}
}
// ── Step 1: Resolve vcenter from DB chain ────────────────────────
$vcenterModel = $this->resolveVcenter($item, $telegram);
if (!$vcenterModel) {
$vcenterModel = VcenterModel::first();
if (!$vcenterModel) {
$this->failStep('vcenter', 'Không tìm thấy vCenter nào trong hệ thống cho order_item #' . $item->id, $telegram);
return;
}
Log::warning('RetryProvisioningOrchestrator: fallback to first vcenter', ['vcenter_id' => $vcenterModel->id]);
}
// ── Step 2: Login vCenter and search VM ─────────────────────────
$loginResult = $vCenterService->loginServer($vcenterModel);
if (!$loginResult) {
$this->failStep('vcenter_login', 'Không thể đăng nhập vào vCenter ' . $vcenterModel->host . ' cho VM ' . $serverName, $telegram);
return;
}
// Search by REST API first
$vm = $vCenterService->findVirtualMachineByName($serverName);
// If REST search fails, try listing all VMs and filter manually
if (!$vm) {
Log::info('RetryProvisioningOrchestrator: REST search returned null, trying list-all fallback', [
'server_name' => $serverName,
]);
$allVms = $vCenterService->getAllVirtualMachine();
if (is_array($allVms)) {
foreach ($allVms as $candidate) {
if (isset($candidate['name']) && $candidate['name'] === $serverName) {
$vm = $candidate;
Log::info('RetryProvisioningOrchestrator: found VM via list-all fallback', [
'server_name' => $serverName,
'vm_id' => $candidate['vm'] ?? null,
]);
break;
}
}
}
}
if ($vm) {
// ── VM EXISTS → link to DB + assign IP + send email ────────────
$vmId = $vm['vm'] ?? null;
if (!$vmId) {
$this->failStep('vm_search', 'Tìm thấy VM nhưng không lấy được vm_id cho ' . $serverName, $telegram);
return;
}
Log::info('RetryProvisioningOrchestrator: VM found in vSphere', [
'server_name' => $serverName,
'vm_id' => $vmId,
'vcenter' => $vcenterModel->host,
]);
try {
$moduleVmware = $this->linkVmToDb($item, $vmId, $vcenterModel, $serverName);
// Determine OS family for AssignIpToGuestJob
$osModel = $item->os_id ? Os::find($item->os_id) : null;
$osFamily = $this->resolveOsFamily($osModel);
// If VM has no IP yet → dispatch AssignIpToGuestJob
if (!$moduleVmware->ip) {
AssignIpToGuestJob::dispatch(
moduleVmwareId: $moduleVmware->id,
vmId: $vmId,
osFamily: $osFamily,
guestPassword: $moduleVmware->plain_password ?? \App\Services\VmProvisionService::TEMPLATE_PASSWORD,
);
Log::info('RetryProvisioningOrchestrator: dispatched AssignIpToGuestJob', [
'module_vmware_id' => $moduleVmware->id,
'vm_id' => $vmId,
]);
} else {
// Already has IP → just send credential email
SendVmCredentialsEmailJob::dispatch($moduleVmware->id);
Log::info('RetryProvisioningOrchestrator: dispatched SendVmCredentialsEmailJob', [
'module_vmware_id' => $moduleVmware->id,
]);
}
$telegram->SendMessage(
"✅ Phục hồi VM thành công\n"
. "━━━━━━━━━━━━━━━━━━━━\n"
. "🖥️ Tên VM: {$serverName}\n"
. "🔑 VM ID: {$vmId}\n"
. "📦 Đơn hàng: #{$this->orderId}\n"
. "🌐 Có IP: " . ($moduleVmware->ip ? 'Có (' . $moduleVmware->ip . ')' : 'Chưa có — đang gán IP') . "\n"
. "⏱️ Thời gian: " . now()->format('d/m/Y H:i:s')
);
} catch (\Throwable $e) {
$this->failStep('link_vm', 'Lỗi liên kết VM ' . $serverName . ': ' . $e->getMessage(), $telegram);
}
} else {
// ── VM NOT in vSphere → dispatch CreateVirtualMachineJob ──────
Log::info('RetryProvisioningOrchestrator: VM not found in vSphere, dispatching CreateVirtualMachineJob', [
'server_name' => $serverName,
'order_item_id' => $item->id,
]);
CreateVirtualMachineJob::dispatch($item, true);
$telegram->SendMessage(
"🆕 Tạo VM mới\n"
. "━━━━━━━━━━━━━━━━━━━━\n"
. "🖥️ Tên VM: {$serverName}\n"
. "📦 Đơn hàng: #{$this->orderId}\n"
. "📝 VM không có trong vSphere, đã gửi job tạo mới\n"
. "⏱️ Thời gian: " . now()->format('d/m/Y H:i:s')
);
}
}
/**
* Resolve vcenter from DB chain:
* Path 1: order_items.os_id → os → osGroup → host → datacenter → vcenter
* Path 2: invoices → invoice_items → product_id → os_product → os → osGroup → host → datacenter → vcenter
* Path 3: module_vmware with same order_id (partial provisioning)
*/
private function resolveVcenter(OrderItem $item, Telegram $telegram): ?VcenterModel
{
if ($item->os_id) {
$os = Os::with('osGroup.host.datacenter.vCenter')->find($item->os_id);
$vcenter = $os?->osGroup?->host?->datacenter?->vCenter;
if ($vcenter) {
Log::info('resolveVcenter: found via os → osGroup chain', ['vcenter_id' => $vcenter->id]);
return $vcenter;
}
}
$invoice = Invoice::where('order_id', $item->order_id)->first();
if ($invoice) {
$invoiceItem = $invoice->invoiceItems()->whereNotNull('product_id')->first();
if ($invoiceItem) {
$osRecord = DB::table('os_product')
->where('product_id', $invoiceItem->product_id)
->first();
if ($osRecord) {
$os = Os::with('osGroup.host.datacenter.vCenter')->find($osRecord->os_id);
$vcenter = $os?->osGroup?->host?->datacenter?->vCenter;
if ($vcenter) {
Log::info('resolveVcenter: found via invoice_items → os_product chain', ['vcenter_id' => $vcenter->id]);
return $vcenter;
}
}
}
}
$existingVmware = ModuleVmware::where('order_id', $item->order_id)->first();
if ($existingVmware?->vcenter_id) {
$vcenter = VcenterModel::find($existingVmware->vcenter_id);
if ($vcenter) {
Log::info('resolveVcenter: found via existing module_vmware', ['vcenter_id' => $vcenter->id]);
return $vcenter;
}
}
$this->failStep('resolve_vcenter', 'Không thể tìm vcenter cho order_item #' . $item->id, $telegram);
return null;
}
/**
* Link an existing VM from vSphere to module_vmware + services tables.
* Returns the ModuleVmware record.
*/
private function linkVmToDb(OrderItem $item, string $vmId, VcenterModel $vcenterModel, string $serverName): ModuleVmware
{
$order = $item->order;
$customer = $order->customer;
$months = (int) ($item->months ?? 0);
$firstPayment = (int) $item->price * ($months ?: 1);
$nextDueDate = $months > 0 ? now()->addMonths($months)->timestamp : null;
$moduleVmware = ModuleVmware::where('vmware_id', $vmId)
->where('vcenter_id', $vcenterModel->id)
->first();
if (!$moduleVmware) {
$moduleVmware = ModuleVmware::create([
'name' => $serverName,
'module_ip_address' => null,
'ip' => null,
'port' => 3389,
'order_id' => $order->id,
'first_payment' => $firstPayment,
'next_payment' => $firstPayment,
'next_due_date' => $nextDueDate,
'next_invoice' => $nextDueDate,
'user' => 'Administrator',
'password' => \App\Services\VmProvisionService::TEMPLATE_PASSWORD,
'plain_password' => \App\Services\VmProvisionService::TEMPLATE_PASSWORD,
'hostname' => $serverName,
'vmware_id' => $vmId,
'vcenter_id' => $vcenterModel->id,
'os_id' => $item->os_id ?? 1,
'customer_id' => $customer->id,
'status' => 1,
]);
}
$service = Service::withTrashed()->firstOrNew([
'order_item_id' => $item->id,
]);
if ($service->trashed()) {
$service->restore();
}
$service->fill([
'customer_id' => $customer->id,
'product_id' => $item->product_id,
'invoice_id' => $order->invoice?->id,
'vm_id' => $moduleVmware->id,
'server_name' => $serverName,
'status' => 'active',
'started_at' => now(),
'expires_at' => $months > 0 ? now()->addMonths($months) : null,
]);
if (!$service->exists && !$service->uuid) {
$service->uuid = (string) \Illuminate\Support\Str::uuid();
}
$service->save();
if (!$moduleVmware->service_id) {
$moduleVmware->update(['service_id' => $service->id]);
}
Log::info('RetryProvisioningOrchestrator: VM linked to DB', [
'module_vmware_id' => $moduleVmware->id,
'service_id' => $service->id,
'vmware_id' => $vmId,
]);
return $moduleVmware;
}
private function resolveOsFamily(?Os $os): string
{
if (!$os) {
return 'LINUX';
}
$groupName = strtolower($os->osGroup?->name ?? '');
if (str_contains($groupName, 'windows')) {
return 'WINDOWS';
}
return 'LINUX';
}
private function failStep(string $step, string $message, Telegram $telegram): void
{
Log::error("RetryProvisioningOrchestrator [{$step}]: {$message}", [
'order_id' => $this->orderId,
]);
$telegram->SendErrorMessage(
"Bước: {$step}\n"
. "Đơn hàng: #{$this->orderId}\n"
. "Lỗi: {$message}"
);
}
}
@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace App\Jobs\VirtualMachine\Provision;
use App\Jobs\Mail\SendInformationVirtualMachine;
use App\Models\Invoice;
use App\Models\ModuleVmware;
use App\Models\Setting;
use App\Services\InvoicePdfService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -17,7 +19,7 @@ use Illuminate\Support\Facades\Log;
/**
* SendVmCredentialsEmailJob
*
* Sends the final VM credential email to the customer.
* Sends the final VM credential email to the customer with an attached PDF invoice.
* Always loads fresh data from DB so it gets the real IP and
* the final password saved by ChangePasswordJob.
*
@@ -80,12 +82,35 @@ class SendVmCredentialsEmailJob implements ShouldQueue
: 'N/A',
];
SendInformationVirtualMachine::dispatch($emailData, $customer);
// Generate invoice PDF attachment (synchronous, no queue)
$invoicePdf = null;
$invoicePdfFileName = null;
$invoice = Invoice::where('order_id', $vm->order_id)
->with(['invoiceItems.product', 'customer', 'order.orderItems'])
->first();
if ($invoice) {
try {
$pdfService = app(InvoicePdfService::class);
$invoicePdf = $pdfService->generate($invoice);
$invoicePdfFileName = $pdfService->generateFileName($invoice);
} catch (\Throwable $e) {
Log::error('SendVmCredentialsEmailJob: failed to generate invoice PDF', [
'module_vmware_id' => $this->moduleVmwareId,
'invoice_id' => $invoice->id,
'error' => $e->getMessage(),
]);
}
}
SendInformationVirtualMachine::dispatch($emailData, $customer, $invoicePdf, $invoicePdfFileName);
Log::info('SendVmCredentialsEmailJob: email dispatched', [
'module_vmware_id' => $this->moduleVmwareId,
'customer_email' => $customer->email,
'ip' => $vm->ip,
'invoice_attached' => $invoicePdf !== null,
]);
}
+15 -22
View File
@@ -5,6 +5,7 @@ namespace App\Mail;
use App\Models\Template;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
@@ -16,22 +17,17 @@ class CreateVirtualMachine extends Mailable
protected $template = null;
protected $data;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct($template = 0, $data = [])
protected ?string $invoicePdf = null;
protected ?string $invoicePdfFileName = null;
public function __construct($template = 0, $data = [], ?string $invoicePdf = null, ?string $invoicePdfFileName = null)
{
$this->data = $data;
$this->template = Template::find($template);
$this->invoicePdf = $invoicePdf;
$this->invoicePdfFileName = $invoicePdfFileName;
}
/**
* Get the message envelope.
*
* @return Envelope
*/
public function envelope()
{
return new Envelope(
@@ -39,11 +35,6 @@ class CreateVirtualMachine extends Mailable
);
}
/**
* Get the message content definition.
*
* @return Content
*/
public function content()
{
return new Content(
@@ -83,13 +74,15 @@ class CreateVirtualMachine extends Mailable
);
}
/**
* Get the attachments for the message.
*
* @return array
*/
public function attachments()
public function attachments(): array
{
if ($this->invoicePdf !== null && $this->invoicePdfFileName !== null) {
return [
Attachment::fromData(fn () => $this->invoicePdf, $this->invoicePdfFileName)
->mime('application/pdf'),
];
}
return [];
}
}
BIN
View File
Binary file not shown.
+10 -5
View File
@@ -33,6 +33,7 @@ class Product extends Model
'is_active',
'sort_order',
'product_categories_id',
'module_ip_address',
'published_at',
];
@@ -130,6 +131,11 @@ class Product extends Model
* Relationship: Product has many addons (through product type)
* Addons are linked via type in product_addon table
*/
public function ipAddress()
{
return $this->belongsTo(ModuleIpAddress::class, 'module_ip_address');
}
public function addons()
{
return $this->hasMany(Addon::class, 'type', 'product_type')
@@ -141,11 +147,10 @@ class Product extends Model
*/
public function getMonthlyPricingAttribute()
{
return $this->pricings()
->where('is_active', 1)
->where('billing_cycle', 'monthly')
->first()
?? $this->pricings()->where('is_active', 1)->orderBy('price')->first();
$pricings = $this->pricings;
return $pricings->firstWhere('billing_cycle', 'monthly')
?? $pricings->where('is_active', 1)->sortBy('price')->first();
}
/**
-5
View File
@@ -11,7 +11,6 @@ use App\Observers\ModuleIpAddressObserver;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -57,10 +56,6 @@ class AppServiceProvider extends ServiceProvider
\App\Repositories\Employee\EmployeeRepositoryInterface::class,
\App\Repositories\Employee\EmployeeRepository::class
);
if (File::exists(base_path('app/Http/Helpers/function_helper.php'))) {
File::requireOnce(base_path('app/Http/Helpers/function_helper.php'));
}
}
/**
+59 -209
View File
@@ -119,282 +119,132 @@ class RepositoryProvider extends ServiceProvider
public function register()
{
//Addon
$this->app->bind(
AddonRepositoryInterface::class,
AddonCachedRepository::class
);
$this->app->singleton(AddonRepositoryInterface::class, AddonCachedRepository::class);
//Banned
$this->app->bind(
BannedRepositoryInterface::class,
BannedCachedRepository::class
);
$this->app->singleton(BannedRepositoryInterface::class, BannedCachedRepository::class);
//Customer
$this->app->bind(
CustomerRepositoryInterface::class,
CustomerCachedRepository::class
);
$this->app->singleton(CustomerRepositoryInterface::class, CustomerCachedRepository::class);
//Customer
$this->app->bind(
CustomerRepositoryInterface::class,
CustomerCachedRepository::class
);
$this->app->singleton(CustomerCachedRepository::class);
//Customer Log
$this->app->bind(
CustomerLogRepositoryInterface::class,
CustomerLogCachedRepository::class
);
$this->app->singleton(CustomerLogRepositoryInterface::class, CustomerLogCachedRepository::class);
//Discount
$this->app->bind(
DiscountRepositoryInterface::class,
DiscountCachedRepository::class
);
$this->app->singleton(DiscountRepositoryInterface::class, DiscountCachedRepository::class);
//Domain
$this->app->bind(
DomainRepositoryInterface::class,
DomainCachedRepository::class
);
$this->app->singleton(DomainRepositoryInterface::class, DomainCachedRepository::class);
//Domain Type
$this->app->bind(
DomainTypeRepositoryInterface::class,
DomainTypeCachedRepository::class
);
$this->app->singleton(DomainTypeRepositoryInterface::class, DomainTypeCachedRepository::class);
//File
$this->app->bind(
FileRepositoryInterface::class,
FileCachedRepository::class
);
$this->app->singleton(FileRepositoryInterface::class, FileCachedRepository::class);
//Group
$this->app->bind(
GroupRepositoryInterface::class,
GroupCachedRepository::class
);
$this->app->singleton(GroupRepositoryInterface::class, GroupCachedRepository::class);
//Hosting
$this->app->bind(
HostingRepositoryInterface::class,
HostingCachedRepository::class
);
$this->app->singleton(HostingRepositoryInterface::class, HostingCachedRepository::class);
//Invoice
$this->app->bind(
InvoiceRepositoryInterface::class,
InvoiceCachedRepository::class
);
$this->app->singleton(InvoiceRepositoryInterface::class, InvoiceCachedRepository::class);
//Invoice Attachment
$this->app->bind(
InvoiceAttachmentRepositoryInterface::class,
InvoiceAttachmentRepository::class
);
$this->app->singleton(InvoiceAttachmentRepositoryInterface::class, InvoiceAttachmentRepository::class);
//Invoice Item
$this->app->bind(
InvoiceItemRepositoryInterface::class,
InvoiceItemCachedRepository::class
);
$this->app->singleton(InvoiceItemRepositoryInterface::class, InvoiceItemCachedRepository::class);
//Log
$this->app->bind(
LogRepositoryInterface::class,
LogCachedRepository::class
);
$this->app->singleton(LogRepositoryInterface::class, LogCachedRepository::class);
//Mail
$this->app->bind(
MailRepositoryInterface::class,
MailCachedRepository::class
);
$this->app->singleton(MailRepositoryInterface::class, MailCachedRepository::class);
//Note
$this->app->bind(
NoteRepositoryInterface::class,
NoteCachedRepository::class
);
$this->app->singleton(NoteRepositoryInterface::class, NoteCachedRepository::class);
//Order
$this->app->bind(
OrderRepositoryInterface::class,
OrderCachedRepository::class
);
$this->app->singleton(OrderRepositoryInterface::class, OrderCachedRepository::class);
//Os
$this->app->bind(
OsRepositoryInterface::class,
OsCachedRepository::class
);
$this->app->singleton(OsRepositoryInterface::class, OsCachedRepository::class);
// Os Group
$this->app->bind(
OsGroupRepositoryInterface::class,
OsGroupCachedRepository::class
);
$this->app->singleton(OsGroupRepositoryInterface::class, OsGroupCachedRepository::class);
// Payment
$this->app->bind(
PaymentReppositoryInterface::class,
PaymentCachedReppository::class
);
$this->app->singleton(PaymentReppositoryInterface::class, PaymentCachedReppository::class);
// Menu
$this->app->bind(
MenuRepositoryInterface::class,
MenuCachedRepository::class
);
$this->app->singleton(MenuRepositoryInterface::class, MenuCachedRepository::class);
//Post
$this->app->bind(
PostRepositoryInterface::class,
PostCachedRepository::class
);
$this->app->singleton(PostRepositoryInterface::class, PostCachedRepository::class);
//Setting
$this->app->bind(
SettingRepositoryInterface::class,
SettingCachedRepository::class
);
$this->app->singleton(SettingRepositoryInterface::class, SettingCachedRepository::class);
//Staff
$this->app->bind(
StaffRepositoryInterface::class,
StaffRepository::class
);
$this->app->singleton(StaffRepositoryInterface::class, StaffRepository::class);
//Template
$this->app->bind(
TemplateRepositoryInterface::class,
TemplateCachedRepository::class
);
$this->app->singleton(TemplateRepositoryInterface::class, TemplateCachedRepository::class);
//Ticket
$this->app->bind(
TicketRepositoryInterface::class,
TicketCachedRepository::class
);
$this->app->singleton(TicketRepositoryInterface::class, TicketCachedRepository::class);
//Ticket Reply
$this->app->bind(
TicketReplyRepositoryInterface::class,
TicketReplyCachedRepository::class
);
$this->app->singleton(TicketReplyRepositoryInterface::class, TicketReplyCachedRepository::class);
//Transaction
$this->app->bind(
TransactionRepositoryInterface::class,
TransactionCachedRepository::class
);
$this->app->singleton(TransactionRepositoryInterface::class, TransactionCachedRepository::class);
//Service
$this->app->bind(
ServiceRepositoryInterface::class,
ServiceRepository::class
);
$this->app->singleton(ServiceRepositoryInterface::class, ServiceRepository::class);
//Module Datacenter
$this->app->bind(
DatacenterRepositoryInterface::class,
DatacenterCachedRepository::class
);
$this->app->singleton(DatacenterRepositoryInterface::class, DatacenterCachedRepository::class);
//Module Datastore
$this->app->bind(
DatastoreRepositoryInterface::class,
DatastoreCachedRepository::class
);
$this->app->singleton(DatastoreRepositoryInterface::class, DatastoreCachedRepository::class);
// Module DnsServer
$this->app->bind(
DnsServerRepositoryInterface::class,
DnsServerCachedRepository::class
);
$this->app->singleton(DnsServerRepositoryInterface::class, DnsServerCachedRepository::class);
//Module Domain
$this->app->bind(
ModuleDomainRepositoryInterface::class,
ModuleDomainCachedRepository::class
);
$this->app->singleton(ModuleDomainRepositoryInterface::class, ModuleDomainCachedRepository::class);
//Module Folder
$this->app->bind(
FolderRepositoryInterface::class,
FolderCachedRepository::class
);
$this->app->singleton(FolderRepositoryInterface::class, FolderCachedRepository::class);
//Host
$this->app->bind(
HostRepositoryInterface::class,
HostCachedRepository::class
);
$this->app->singleton(HostRepositoryInterface::class, HostCachedRepository::class);
//Hosting
$this->app->bind(
ModuleHostingRepositoryInterface::class,
ModuleHostingCachedRepository::class
);
$this->app->singleton(ModuleHostingRepositoryInterface::class, ModuleHostingCachedRepository::class);
//Hosting Server
$this->app->bind(
HostingServerRepositoryInterface::class,
HostingServerCachedRepository::class
);
$this->app->singleton(HostingServerRepositoryInterface::class, HostingServerCachedRepository::class);
//IpAddress
$this->app->bind(
IpAddressRepositoryInterface::class,
IpAddressCachedRepository::class
);
$this->app->singleton(IpAddressRepositoryInterface::class, IpAddressCachedRepository::class);
//Network
$this->app->bind(
NetworkRepositoryInterface::class,
NetworkCachedRepository::class
);
$this->app->singleton(NetworkRepositoryInterface::class, NetworkCachedRepository::class);
//Snapshot
$this->app->bind(
SnapshotRepositoryInterface::class,
SnapshotCachedRepository::class
);
$this->app->singleton(SnapshotRepositoryInterface::class, SnapshotCachedRepository::class);
//Snapshot Limit
$this->app->bind(
SnapshotLimitRepositoryInterface::class,
SnapshotLimitCachedRepository::class
);
$this->app->singleton(SnapshotLimitRepositoryInterface::class, SnapshotLimitCachedRepository::class);
//vmware
$this->app->bind(
VmwareRepositoryInterface::class,
VmwareCachedRepository::class
);
$this->app->singleton(VmwareRepositoryInterface::class, VmwareCachedRepository::class);
//User
$this->app->bind(
UserRepositoryInterface::class,
UserCachedRepository::class
);
$this->app->singleton(UserRepositoryInterface::class, UserCachedRepository::class);
//vCenter
$this->app->bind(
vCenterRepositoryInterface::class,
vCenterCachedRepository::class
);
$this->app->singleton(vCenterRepositoryInterface::class, vCenterCachedRepository::class);
//vps
$this->app->bind(
VPSRepositoryInterface::class,
VPSCachedRepository::class
);
$this->app->singleton(VPSRepositoryInterface::class, VPSCachedRepository::class);
//Module IP Pool
$this->app->bind(
IPPoolRepositoryInterface::class,
IPPoolCachedRepository::class
);
$this->app->singleton(IPPoolRepositoryInterface::class, IPPoolCachedRepository::class);
//Module VirtualMachine
$this->app->bind(
VirtualMachineRepositoryInterface::class,
VirtualMachineRepository::class
);
$this->app->singleton(VirtualMachineRepositoryInterface::class, VirtualMachineRepository::class);
// Product
$this->app->bind(
ProductRepositoryInterface::class,
ProductRepository::class
);
$this->app->singleton(ProductRepositoryInterface::class, ProductRepository::class);
// Product Category
$this->app->bind(
ProductCategoryRepositoryInterface::class,
ProductCategoryRepository::class
);
$this->app->singleton(ProductCategoryRepositoryInterface::class, ProductCategoryRepository::class);
// Client API
$this->app->bind(
$this->app->singleton(
\App\Repositories\Api\Client\ClientRepositoryInterface::class,
\App\Repositories\Api\Client\ClientRepository::class
\App\Repositories\Api\Client\ClientRepository::class,
);
// Service API
$this->app->bind(
$this->app->singleton(
\App\Repositories\Api\Service\ServiceRepositoryInterface::class,
\App\Repositories\Api\Service\ServiceRepository::class
\App\Repositories\Api\Service\ServiceRepository::class,
);
// Service Client API
$this->app->bind(
$this->app->singleton(
\App\Repositories\Api\ServiceClient\ServiceClientRepositoryInterface::class,
\App\Repositories\Api\ServiceClient\ServiceClientRepository::class
\App\Repositories\Api\ServiceClient\ServiceClientRepository::class,
);
// DNS Manager
$this->app->bind(DnsManagerServerRepositoryInterface::class, DnsManagerServerRepository::class);
$this->app->bind(DnsDomainRepositoryInterface::class, DnsDomainRepository::class);
$this->app->bind(DnsRecordRepositoryInterface::class, DnsRecordRepository::class);
$this->app->singleton(DnsManagerServerRepositoryInterface::class, DnsManagerServerRepository::class);
$this->app->singleton(DnsDomainRepositoryInterface::class, DnsDomainRepository::class);
$this->app->singleton(DnsRecordRepositoryInterface::class, DnsRecordRepository::class);
}
/**
+4 -12
View File
@@ -38,28 +38,20 @@ class RouteServiceProvider extends ServiceProvider
$this->configureRateLimiting();
$this->routes(function () {
// 1. Agency API — domain riêng → chỉ client.php
Route::domain(config('app.api_agency_url'))
->middleware(['api', 'json.response'])
Route::middleware(['api', 'json.response'])
->namespace($this->namespace)
->group(base_path('routes/client.php'));
// 2. System API — bind về APP_URL
Route::domain(config('app.url'))
->prefix('api')
Route::prefix('api')
->middleware(['api', 'json.response'])
->namespace($this->namespace)
->group(base_path('routes/api.php'));
// 3. Admin Backend routes
Route::domain(config('app.url'))
->middleware('web')
Route::middleware('web')
->namespace($this->namespace)
->group(base_path('routes/admin.php'));
// 4. Web routes
Route::domain(config('app.url'))
->middleware('web')
Route::middleware('web')
->namespace($this->namespace)
->group(base_path('routes/web.php'));
});
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Services;
use App\Models\Invoice;
use App\Models\Setting;
use Barryvdh\DomPDF\Facade\Pdf;
class InvoicePdfService
{
public function generate(Invoice $invoice): string
{
$invoice->load(['invoiceItems.product', 'customer', 'order.orderItems']);
$setting = Setting::where('key', 'setting')->first();
$settingValue = $setting ? json_decode((string) $setting->value) : null;
$customer = $invoice->customer;
$order = $invoice->order;
$data = [
'invoice' => $invoice,
'invoiceItems' => $invoice->invoiceItems,
'customer' => $customer,
'order' => $order,
'companyName' => $settingValue->company ?? 'Thế Giới Số',
'companyAddress' => $settingValue->address ?? '',
'companyPhone' => $settingValue->phone ?? '',
'companyEmail' => $settingValue->email ?? '',
];
return Pdf::loadView('pdf.invoice', $data)->output();
}
public function generateFileName(Invoice $invoice): string
{
return 'Hoa-don-' . $invoice->invoice_number . '.pdf';
}
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.